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

@@ -5,7 +5,7 @@ Memory Service - FastAPI додаток
"""
import os
from typing import Optional, List
from typing import Optional, List, Dict, Any
from datetime import datetime
from fastapi import FastAPI, Depends, HTTPException, Query, Header
@@ -32,7 +32,7 @@ from app.crud import (
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://user:password@localhost:5432/microdao"
"sqlite:///./memory.db" # SQLite для розробки, PostgreSQL для продакшену
)
# Створюємо engine та sessionmaker
@@ -389,6 +389,35 @@ async def delete_memory_event(
return {"success": True}
# ========== Monitor Events Endpoints (Batch Processing) ==========
from app.monitor_events import MonitorEventBatch, MonitorEventResponse, save_monitor_events_batch, save_monitor_event_single
@app.post("/api/memory/monitor-events/batch", response_model=MonitorEventResponse)
async def save_monitor_events_batch_endpoint(
batch: MonitorEventBatch,
db: Session = Depends(get_db),
authorization: Optional[str] = Header(None)
):
"""
Зберегти батч подій Monitor Agent
Оптимізовано для збору метрик з багатьох нод
"""
return await save_monitor_events_batch(batch, db, authorization)
@app.post("/api/memory/monitor-events/{node_id}", response_model=AgentMemoryEventResponse)
async def save_monitor_event_endpoint(
node_id: str,
event: Dict[str, Any],
db: Session = Depends(get_db),
authorization: Optional[str] = Header(None)
):
"""
Зберегти одну подію Monitor Agent
"""
return await save_monitor_event_single(node_id, event, db, authorization)
# ========== Token Gate Integration Endpoint ==========
@app.post("/token-gate/check", response_model=TokenGateCheckResponse)

View File

@@ -7,10 +7,33 @@ from sqlalchemy import (
Column, String, Text, JSON, TIMESTAMP,
CheckConstraint, Index, Boolean, Integer
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
from pgvector.sqlalchemy import Vector
import os
# Перевірка типу бази даних
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./memory.db")
IS_SQLITE = "sqlite" in DATABASE_URL.lower()
if IS_SQLITE:
# Для SQLite використовуємо стандартні типи
from sqlalchemy import JSON as JSONB_TYPE
UUID_TYPE = String # SQLite не має UUID, використовуємо String
else:
# Для PostgreSQL використовуємо специфічні типи
from sqlalchemy.dialects.postgresql import UUID, JSONB
UUID_TYPE = UUID
JSONB_TYPE = JSONB
try:
from pgvector.sqlalchemy import Vector
HAS_PGVECTOR = True
except ImportError:
HAS_PGVECTOR = False
# Заглушка для SQLite
class Vector:
def __init__(self, *args, **kwargs):
pass
Base = declarative_base()
@@ -23,7 +46,7 @@ class UserFact(Base):
"""
__tablename__ = "user_facts"
id = Column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
user_id = Column(String, nullable=False, index=True) # Без FK constraint для тестування
team_id = Column(String, nullable=True, index=True) # Без FK constraint, оскільки teams може не існувати
@@ -32,18 +55,18 @@ class UserFact(Base):
# Значення факту (може бути текст, число, boolean, JSON)
fact_value = Column(Text, nullable=True)
fact_value_json = Column(JSONB, nullable=True)
fact_value_json = Column(JSONB_TYPE, nullable=True)
# Метадані: джерело, впевненість, термін дії
meta = Column(JSONB, nullable=False, server_default="{}")
meta = Column(JSONB_TYPE, nullable=False, server_default="{}")
# Токен-гейт: чи залежить факт від токенів/активності
token_gated = Column(Boolean, nullable=False, server_default="false")
token_requirements = Column(JSONB, nullable=True) # {"token": "DAAR", "min_balance": 1}
token_requirements = Column(JSONB_TYPE, nullable=True) # {"token": "DAAR", "min_balance": 1}
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(TIMESTAMP(timezone=True), nullable=True, onupdate=func.now())
expires_at = Column(TIMESTAMP(timezone=True), nullable=True) # Для тимчасових фактів
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
updated_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=True, onupdate=func.now())
expires_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=True) # Для тимчасових фактів
__table_args__ = (
Index("idx_user_facts_user_key", "user_id", "fact_key"),
@@ -59,7 +82,7 @@ class DialogSummary(Base):
"""
__tablename__ = "dialog_summaries"
id = Column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
# Контекст діалогу (без FK constraints для тестування)
team_id = Column(String, nullable=False, index=True)
@@ -68,24 +91,24 @@ class DialogSummary(Base):
user_id = Column(String, nullable=True, index=True)
# Період, який охоплює підсумок
period_start = Column(TIMESTAMP(timezone=True), nullable=False)
period_end = Column(TIMESTAMP(timezone=True), nullable=False)
period_start = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False)
period_end = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False)
# Підсумок
summary_text = Column(Text, nullable=False)
summary_json = Column(JSONB, nullable=True) # Структуровані дані
summary_json = Column(JSONB_TYPE, nullable=True) # Структуровані дані
# Статистика
message_count = Column(Integer, nullable=False, server_default="0")
participant_count = Column(Integer, nullable=False, server_default="0")
# Ключові теми/теги
topics = Column(JSONB, nullable=True) # ["project-planning", "bug-fix", ...]
topics = Column(JSONB_TYPE, nullable=True) # ["project-planning", "bug-fix", ...]
# Метадані
meta = Column(JSONB, nullable=False, server_default="{}")
meta = Column(JSONB_TYPE, nullable=False, server_default="{}")
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
__table_args__ = (
Index("idx_dialog_summaries_team_period", "team_id", "period_start", "period_end"),
@@ -101,7 +124,7 @@ class AgentMemoryEvent(Base):
"""
__tablename__ = "agent_memory_events"
id = Column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
# Без FK constraints для тестування
agent_id = Column(String, nullable=False, index=True)
@@ -117,9 +140,9 @@ class AgentMemoryEvent(Base):
# Тіло події
body_text = Column(Text, nullable=True)
body_json = Column(JSONB, nullable=True)
body_json = Column(JSONB_TYPE, nullable=True)
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
__table_args__ = (
CheckConstraint("scope IN ('short_term', 'mid_term', 'long_term')", name="ck_agent_memory_scope"),
@@ -136,18 +159,18 @@ class AgentMemoryFactsVector(Base):
"""
__tablename__ = "agent_memory_facts_vector"
id = Column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
# Без FK constraints для тестування
agent_id = Column(String, nullable=False, index=True)
team_id = Column(String, nullable=False, index=True)
fact_text = Column(Text, nullable=False)
embedding = Column(Vector(1536), nullable=True) # OpenAI ada-002 embedding size
embedding = Column(Vector(1536), nullable=True) if HAS_PGVECTOR else Column(Text, nullable=True) # OpenAI ada-002 embedding size
meta = Column(JSONB, nullable=False, server_default="{}")
meta = Column(JSONB_TYPE, nullable=False, server_default="{}")
created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
__table_args__ = (
Index("idx_agent_memory_facts_vector_agent_team", "agent_id", "team_id"),

View File

@@ -0,0 +1,130 @@
"""
Monitor Events API - Автоматичне збереження подій Monitor Agent
Підтримує батчинг для оптимізації
"""
from typing import List, Optional, Dict, Any
from datetime import datetime
from fastapi import Depends, Header
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.crud import create_agent_memory_event
from app.schemas import AgentMemoryEventCreate, AgentMemoryEventResponse
# ========== Schemas ==========
class MonitorEventBatch(BaseModel):
"""Батч подій для збереження"""
node_id: str
events: List[Dict[str, Any]]
batch_size: Optional[int] = None
class MonitorEventResponse(BaseModel):
"""Відповідь на збереження подій"""
saved: int
failed: int
node_id: str
timestamp: str
# ========== Functions ==========
async def save_monitor_events_batch(
batch: MonitorEventBatch,
db: Session,
authorization: Optional[str] = None
) -> MonitorEventResponse:
"""
Зберегти батч подій Monitor Agent
Оптимізовано для збору метрик з багатьох нод
Зберігає події в загальну пам'ять (monitor) та специфічну пам'ять (monitor-node-{node_id} або monitor-microdao-{microdao_id})
"""
saved = 0
failed = 0
# Визначаємо agent_id на основі node_id
# Формат: monitor-node-{node_id} для ноди, monitor-microdao-{microdao_id} для мікроДАО
if "microdao" in batch.node_id:
specific_agent_id = batch.node_id # Вже в форматі monitor-microdao-{id}
else:
specific_agent_id = f"monitor-node-{batch.node_id}"
# Загальний agent_id для всіх Monitor Agent
global_agent_id = "monitor"
for event_data in batch.events:
try:
# 1. Зберегти в специфічну пам'ять (monitor-node-{node_id} або monitor-microdao-{microdao_id})
specific_event = AgentMemoryEventCreate(
agent_id=specific_agent_id,
team_id=event_data.get("team_id", "system"),
channel_id=event_data.get("channel_id"),
user_id=event_data.get("user_id"),
scope=event_data.get("scope", "long_term"),
kind=event_data.get("kind", "system_event"),
body_text=event_data.get("body_text", ""),
body_json=event_data.get("body_json", {})
)
create_agent_memory_event(db, specific_event)
# 2. Зберегти в загальну пам'ять (monitor) - тільки важливі події
# Зберігаємо всі події в загальну пам'ять для агрегації
global_event = AgentMemoryEventCreate(
agent_id=global_agent_id,
team_id=event_data.get("team_id", "system"),
channel_id=event_data.get("channel_id"),
user_id=event_data.get("user_id"),
scope=event_data.get("scope", "long_term"),
kind=event_data.get("kind", "system_event"),
body_text=event_data.get("body_text", ""),
body_json={
**event_data.get("body_json", {}),
"source_node": batch.node_id, # Додаємо джерело події
"specific_agent_id": specific_agent_id
}
)
create_agent_memory_event(db, global_event)
saved += 2 # Збережено в обидві пам'яті
# TODO: Збереження в Qdrant, Milvus, Neo4j (асинхронно)
# await save_to_qdrant(event_data)
# await save_to_milvus(event_data)
# await save_to_neo4j(event_data)
except Exception as e:
print(f"Error saving event: {e}")
failed += 1
return MonitorEventResponse(
saved=saved,
failed=failed,
node_id=batch.node_id,
timestamp=datetime.utcnow().isoformat()
)
async def save_monitor_event_single(
node_id: str,
event: Dict[str, Any],
db: Session,
authorization: Optional[str] = None
) -> AgentMemoryEventResponse:
"""
Зберегти одну подію Monitor Agent
"""
agent_id = f"monitor-{node_id}"
memory_event = AgentMemoryEventCreate(
agent_id=agent_id,
team_id=event.get("team_id", "system"),
channel_id=event.get("channel_id"),
user_id=event.get("user_id"),
scope=event.get("scope", "long_term"),
kind=event.get("kind", "system_event"),
body_text=event.get("body_text", ""),
body_json=event.get("body_json", {})
)
db_event = create_agent_memory_event(db, memory_event)
return AgentMemoryEventResponse.model_validate(db_event)