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

@@ -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"),