feat: Unified Messaging System — kommunikation plugin, AI/Proactive/System participants, MessageSidebar, Rich Content Renderer

Phase 1: Backend plugin kommunikation (13 files, 10 tables, REST API, WebSocket, RBAC, DMS Bridge, Participant Registry, Mini-App Registry, Search Provider)
Phase 2: AI plugins as participants (ai_assistant + ai_proactive dock as participants, heartbeat job)
Phase 3: system_notif plugin (system events → chat messages, pinned System room)
Phase 4: Frontend MessageSidebar (replaces AISidebar, same design, comm API client, WebSocket hook, commStore)
Phase 5: Rich Content Block Renderer (11 components: Markdown, HTML, Image, Audio, Video, File, ActionCard, ContactCard, MiniApp, BlockRenderer)
BasePlugin: added services property + _container in on_activate
This commit is contained in:
Agent Zero
2026-07-22 01:22:15 +02:00
parent 5980d38c66
commit cc3ac9a43d
45 changed files with 8154 additions and 113 deletions
@@ -0,0 +1,187 @@
"""DMS Bridge — integrates with the DMS plugin for file storage."""
from __future__ import annotations
import logging
import os
import uuid
from typing import Any
from fastapi import UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.dms.models import File as DmsFile, Folder
logger = logging.getLogger(__name__)
DMS_STORAGE_BASE = os.environ.get("DMS_STORAGE_BASE", "/tmp/dms")
COMM_FOLDER_NAME = "_kommunikation"
MAX_DIRECT_UPLOAD = 100 * 1024 * 1024 # 100 MB — larger files must be DMS references
class DmsBridge:
"""Bridge to the DMS plugin for attachment storage."""
@staticmethod
async def ensure_comm_folder(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> Folder:
"""Ensure the _kommunikation root folder exists in DMS."""
result = await db.execute(
select(Folder).where(
Folder.name == COMM_FOLDER_NAME,
Folder.parent_id.is_(None),
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
folder = result.scalar_one_or_none()
if folder is None:
folder = Folder(
tenant_id=tenant_id,
name=COMM_FOLDER_NAME,
parent_id=None,
created_by=user_id,
)
db.add(folder)
await db.flush()
return folder
@staticmethod
async def ensure_conversation_folder(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
conversation_id: uuid.UUID,
) -> Folder:
"""Ensure a sub-folder for a specific conversation exists."""
root_folder = await DmsBridge.ensure_comm_folder(db, tenant_id, user_id)
conv_name = str(conversation_id)
result = await db.execute(
select(Folder).where(
Folder.name == conv_name,
Folder.parent_id == root_folder.id,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
folder = result.scalar_one_or_none()
if folder is None:
folder = Folder(
tenant_id=tenant_id,
name=conv_name,
parent_id=root_folder.id,
created_by=user_id,
)
db.add(folder)
await db.flush()
return folder
@staticmethod
async def store_attachment(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
file: UploadFile,
) -> dict[str, Any]:
"""Store an uploaded file in the DMS under _kommunikation/{conversation_id}/.
Returns dict with file_id, file_name, file_type, file_size.
"""
# Check size limit
content = await file.read()
file_size = len(content)
if file_size > MAX_DIRECT_UPLOAD:
raise ValueError(
f"File size {file_size} exceeds direct upload limit ({MAX_DIRECT_UPLOAD} bytes). "
f"Use DMS reference instead."
)
# Ensure conversation folder
folder = await DmsBridge.ensure_conversation_folder(
db, tenant_id, user_id, conversation_id
)
# Save file to disk
file_id = uuid.uuid4()
file_ext = os.path.splitext(file.filename or "")[1] or ""
storage_path = os.path.join(
DMS_STORAGE_BASE,
str(tenant_id),
str(folder.id),
f"{file_id}{file_ext}",
)
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
with open(storage_path, "wb") as f:
f.write(content)
# Create DMS file record
dms_file = DmsFile(
tenant_id=tenant_id,
name=file.filename or f"{file_id}",
folder_id=folder.id,
uploaded_by=user_id,
mime_type=file.content_type or "application/octet-stream",
size_bytes=file_size,
storage_path=storage_path,
)
db.add(dms_file)
await db.flush()
return {
"file_id": str(dms_file.id),
"file_name": dms_file.name,
"file_type": dms_file.mime_type,
"file_size": dms_file.size_bytes,
"file_source": "comm",
}
@staticmethod
async def reference_external_file(
db: AsyncSession,
tenant_id: uuid.UUID,
file_id: uuid.UUID,
) -> dict[str, Any] | None:
"""Reference an existing DMS file without copying it.
Returns dict with file metadata or None if file not found.
"""
result = await db.execute(
select(DmsFile).where(
DmsFile.id == file_id,
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
)
)
dms_file = result.scalar_one_or_none()
if dms_file is None:
return None
return {
"file_id": str(dms_file.id),
"file_name": dms_file.name,
"file_type": dms_file.mime_type,
"file_size": dms_file.size_bytes,
"file_source": "dms",
}
@staticmethod
async def get_file(
db: AsyncSession,
tenant_id: uuid.UUID,
file_id: uuid.UUID,
) -> DmsFile | None:
"""Get a DMS file by ID."""
result = await db.execute(
select(DmsFile).where(
DmsFile.id == file_id,
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()