feat: Add presence heartbeat for Matrix online status

- 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
This commit is contained in:
Apple
2025-11-27 00:19:40 -08:00
parent 5bed515852
commit 3de3c8cb36
6371 changed files with 1317450 additions and 932 deletions

View File

@@ -0,0 +1,17 @@
FROM python:3.11-slim
WORKDIR /app
# Dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# App files
COPY . .
# Expose port
EXPOSE 7003
# Run
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7003"]

View File

@@ -0,0 +1,59 @@
"""
Second Me Service — Персональний агент користувача
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import logging
import os
import routes
import repository
# Logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(
title="DAARION Second Me Service",
version="1.0.0",
description="Персональний цифровий двійник користувача"
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routes
app.include_router(routes.router)
# Health check
@app.get("/health")
async def health():
return {"status": "healthy", "service": "secondme-service"}
@app.on_event("startup")
async def startup():
"""Startup event"""
logger.info("🚀 Second Me Service starting...")
logger.info(f"SECONDME_AGENT_ID: {os.getenv('SECONDME_AGENT_ID', 'ag_secondme_global')}")
logger.info(f"AGENTS_SERVICE_URL: {os.getenv('AGENTS_SERVICE_URL', 'http://agents-service:7002')}")
@app.on_event("shutdown")
async def shutdown():
"""Shutdown event"""
logger.info("🛑 Second Me Service shutting down...")
await repository.close_pool()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7003)

View File

@@ -0,0 +1,68 @@
"""
Pydantic Models для Second Me Service
"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
# =============================================================================
# Second Me Session
# =============================================================================
class SecondMeSession(BaseModel):
id: str
user_id: str
agent_id: Optional[str] = None
created_at: datetime
last_interaction_at: Optional[datetime] = None
# =============================================================================
# Second Me Message
# =============================================================================
class SecondMeMessageBase(BaseModel):
content: str = Field(..., min_length=1, max_length=10000)
class SecondMeMessageCreate(SecondMeMessageBase):
role: str = Field(..., pattern="^(user|assistant)$")
class SecondMeMessageRead(SecondMeMessageBase):
id: str
session_id: str
user_id: str
role: str
tokens_used: Optional[int] = None
latency_ms: Optional[int] = None
created_at: datetime
# =============================================================================
# Second Me Invoke
# =============================================================================
class SecondMeInvokePayload(BaseModel):
prompt: str = Field(..., min_length=1, max_length=5000)
class SecondMeInvokeResponse(BaseModel):
response: str
tokens_used: int
latency_ms: int
history: List[dict] = []
# =============================================================================
# Second Me Profile
# =============================================================================
class SecondMeProfile(BaseModel):
user_id: str
agent_id: str
total_interactions: int
last_interaction: Optional[datetime] = None

View File

@@ -0,0 +1,182 @@
"""
Repository для Second Me Service
"""
import os
import asyncpg
from typing import Optional, List
from datetime import datetime
import secrets
# Database connection
_pool: Optional[asyncpg.Pool] = None
async def get_pool() -> asyncpg.Pool:
"""Отримати connection pool"""
global _pool
if _pool is None:
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/daarion")
_pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10)
return _pool
async def close_pool():
"""Закрити connection pool"""
global _pool
if _pool is not None:
await _pool.close()
_pool = None
def generate_id(prefix: str) -> str:
"""Генерувати простий ID"""
return f"{prefix}_{secrets.token_urlsafe(12)}"
# =============================================================================
# Second Me Sessions
# =============================================================================
async def get_or_create_session(user_id: str, agent_id: Optional[str] = None) -> dict:
"""
Отримати або створити сесію для користувача
Для MVP: одна активна сесія на користувача
"""
pool = await get_pool()
# Спробувати знайти останню сесію
query_find = """
SELECT id, user_id, agent_id, created_at, last_interaction_at
FROM secondme_sessions
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 1
"""
existing = await pool.fetchrow(query_find, user_id)
if existing:
return dict(existing)
# Створити нову
session_id = generate_id("smsess")
query_create = """
INSERT INTO secondme_sessions (id, user_id, agent_id)
VALUES ($1, $2, $3)
RETURNING id, user_id, agent_id, created_at, last_interaction_at
"""
new_session = await pool.fetchrow(query_create, session_id, user_id, agent_id)
return dict(new_session)
async def update_session_interaction(session_id: str):
"""Оновити час останньої взаємодії"""
pool = await get_pool()
query = """
UPDATE secondme_sessions
SET last_interaction_at = NOW()
WHERE id = $1
"""
await pool.execute(query, session_id)
# =============================================================================
# Second Me Messages
# =============================================================================
async def get_session_messages(session_id: str, limit: int = 5) -> List[dict]:
"""Отримати останні N повідомлень сесії"""
pool = await get_pool()
query = """
SELECT id, session_id, user_id, role, content, tokens_used, latency_ms, created_at
FROM secondme_messages
WHERE session_id = $1
ORDER BY created_at DESC
LIMIT $2
"""
rows = await pool.fetch(query, session_id, limit)
# Reverse для правильного порядку
return [dict(row) for row in reversed(rows)]
async def get_user_messages(user_id: str, limit: int = 5) -> List[dict]:
"""Отримати останні N повідомлень користувача з всіх сесій"""
pool = await get_pool()
query = """
SELECT sm.id, sm.session_id, sm.user_id, sm.role, sm.content, sm.tokens_used, sm.latency_ms, sm.created_at
FROM secondme_messages sm
JOIN secondme_sessions ss ON sm.session_id = ss.id
WHERE ss.user_id = $1
ORDER BY sm.created_at DESC
LIMIT $2
"""
rows = await pool.fetch(query, user_id, limit)
# Reverse для правильного порядку
return [dict(row) for row in reversed(rows)]
async def create_message(
session_id: str,
user_id: str,
role: str,
content: str,
tokens_used: Optional[int] = None,
latency_ms: Optional[int] = None
) -> dict:
"""Створити повідомлення"""
pool = await get_pool()
message_id = generate_id("smmsg")
query = """
INSERT INTO secondme_messages (id, session_id, user_id, role, content, tokens_used, latency_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, session_id, user_id, role, content, tokens_used, latency_ms, created_at
"""
row = await pool.fetchrow(query, message_id, session_id, user_id, role, content, tokens_used, latency_ms)
return dict(row)
async def get_user_stats(user_id: str) -> dict:
"""Отримати статистику користувача"""
pool = await get_pool()
query = """
SELECT
COUNT(DISTINCT ss.id) as total_sessions,
COUNT(sm.id) as total_messages,
MAX(sm.created_at) as last_interaction
FROM secondme_sessions ss
LEFT JOIN secondme_messages sm ON ss.id = sm.session_id
WHERE ss.user_id = $1
"""
row = await pool.fetchrow(query, user_id)
return dict(row) if row else {"total_sessions": 0, "total_messages": 0, "last_interaction": None}
async def clear_user_history(user_id: str):
"""Очистити історію користувача"""
pool = await get_pool()
# Видалити всі сесії (каскадно видаляться повідомлення)
query = """
DELETE FROM secondme_sessions
WHERE user_id = $1
"""
await pool.execute(query, user_id)

View File

@@ -0,0 +1,6 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
asyncpg==0.29.0
httpx==0.25.2

View File

@@ -0,0 +1,98 @@
"""
Second Me Service API Routes
"""
from fastapi import APIRouter, HTTPException, Depends
from typing import List
import logging
from models import (
SecondMeInvokePayload,
SecondMeInvokeResponse,
SecondMeProfile
)
import service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/secondme", tags=["secondme"])
# =============================================================================
# Second Me API
# =============================================================================
@router.post("/invoke", response_model=SecondMeInvokeResponse)
async def invoke_secondme(payload: SecondMeInvokePayload):
"""
Викликати Second Me agent
"""
try:
# TODO: витягнути user_id з JWT
user_id = "u_mock_user" # Mock для MVP
result = await service.invoke_second_me(user_id, payload.prompt)
# Додати історію
history = await service.get_user_history(user_id, limit=10)
return {
**result,
"history": history
}
except Exception as e:
logger.error(f"Failed to invoke SecondMe: {e}")
raise HTTPException(status_code=500, detail="Failed to invoke SecondMe")
@router.get("/history")
async def get_secondme_history(limit: int = 5):
"""
Отримати історію розмов з Second Me
"""
try:
# TODO: витягнути user_id з JWT
user_id = "u_mock_user" # Mock для MVP
history = await service.get_user_history(user_id, limit=limit)
return history
except Exception as e:
logger.error(f"Failed to get SecondMe history: {e}")
raise HTTPException(status_code=500, detail="Failed to get history")
@router.get("/profile", response_model=SecondMeProfile)
async def get_secondme_profile():
"""
Отримати профіль Second Me користувача
"""
try:
# TODO: витягнути user_id з JWT
user_id = "u_mock_user" # Mock для MVP
profile = await service.get_user_profile(user_id)
return profile
except Exception as e:
logger.error(f"Failed to get SecondMe profile: {e}")
raise HTTPException(status_code=500, detail="Failed to get profile")
@router.post("/history/clear")
async def clear_secondme_history():
"""
Очистити історію розмов з Second Me
"""
try:
# TODO: витягнути user_id з JWT
user_id = "u_mock_user" # Mock для MVP
await service.clear_user_history(user_id)
return {"status": "cleared"}
except Exception as e:
logger.error(f"Failed to clear SecondMe history: {e}")
raise HTTPException(status_code=500, detail="Failed to clear history")

View File

@@ -0,0 +1,178 @@
"""
Second Me Service Logic
"""
import os
import httpx
import time
import logging
from typing import List, Dict
import repository
logger = logging.getLogger(__name__)
# Config
SECONDME_AGENT_ID = os.getenv("SECONDME_AGENT_ID", "ag_secondme_global")
AGENTS_SERVICE_URL = os.getenv("AGENTS_SERVICE_URL", "http://agents-service:7002")
async def invoke_second_me(user_id: str, prompt: str) -> Dict:
"""
Викликати Second Me agent для користувача
1. Отримати або створити сесію
2. Зберегти user prompt
3. Зібрати контекст (останні N повідомлень)
4. Викликати Agents Core
5. Зберегти assistant відповідь
6. Повернути результат
"""
start_time = time.monotonic()
# 1. Отримати/створити сесію
session = await repository.get_or_create_session(user_id, agent_id=SECONDME_AGENT_ID)
session_id = session["id"]
# 2. Зберегти user prompt
await repository.create_message(
session_id=session_id,
user_id=user_id,
role="user",
content=prompt
)
# 3. Зібрати контекст
messages = await repository.get_session_messages(session_id, limit=10)
# Сформувати контекст для LLM
context_messages = []
for msg in messages:
context_messages.append({
"role": msg["role"],
"content": msg["content"]
})
# 4. Викликати Agents Core
try:
response_text, tokens_used = await call_agents_core(
agent_id=SECONDME_AGENT_ID,
user_id=user_id,
prompt=prompt,
context=context_messages
)
except Exception as e:
logger.error(f"Failed to call Agents Core: {e}")
# Fallback до mock відповіді
response_text = f"Я — твій Second Me. Ти запитав: '{prompt}'. На жаль, зараз я не можу підключитися до LLM, але я тут для тебе! 🤖"
tokens_used = 50
latency_ms = int((time.monotonic() - start_time) * 1000)
# 5. Зберегти assistant відповідь
await repository.create_message(
session_id=session_id,
user_id=user_id,
role="assistant",
content=response_text,
tokens_used=tokens_used,
latency_ms=latency_ms
)
# Оновити час останньої взаємодії
await repository.update_session_interaction(session_id)
# 6. Повернути результат
return {
"response": response_text,
"tokens_used": tokens_used,
"latency_ms": latency_ms
}
async def call_agents_core(
agent_id: str,
user_id: str,
prompt: str,
context: List[Dict]
) -> tuple[str, int]:
"""
Викликати Agents Core service
Returns: (response_text, tokens_used)
"""
# Формуємо input з контекстом
context_str = ""
if context:
for msg in context[-5:]: # Останні 5 повідомлень
role = msg["role"]
content = msg["content"]
context_str += f"{role.capitalize()}: {content}\n"
input_text = f"""You are Second Me — персональний цифровий двійник користувача в DAARION City.
Контекст попередніх розмов:
{context_str}
Поточне запитання користувача:
{prompt}
Твоя відповідь (українською мовою):"""
payload = {
"input": input_text,
"context": {
"user_id": user_id,
"kind": "secondme",
"agent_id": agent_id
}
}
url = f"{AGENTS_SERVICE_URL}/agents/invoke"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, json={
"agent_id": agent_id,
"payload": payload
})
response.raise_for_status()
data = response.json()
# TODO: адаптувати під реальний формат відповіді Agents Core
response_text = data.get("response", data.get("reply", "Немає відповіді"))
tokens_used = data.get("tokens_used", 100)
return response_text, tokens_used
async def get_user_history(user_id: str, limit: int = 5) -> List[Dict]:
"""Отримати історію користувача"""
messages = await repository.get_user_messages(user_id, limit=limit)
return [
{
"role": msg["role"],
"content": msg["content"],
"created_at": msg["created_at"].isoformat()
}
for msg in messages
]
async def get_user_profile(user_id: str) -> Dict:
"""Отримати профіль Second Me для користувача"""
stats = await repository.get_user_stats(user_id)
return {
"user_id": user_id,
"agent_id": SECONDME_AGENT_ID,
"total_interactions": stats.get("total_messages", 0),
"last_interaction": stats.get("last_interaction").isoformat() if stats.get("last_interaction") else None
}
async def clear_user_history(user_id: str):
"""Очистити історію користувача"""
await repository.clear_user_history(user_id)