feat(UI-Overhaul-Phase2): AI Assistent in Kommunikation integriert
Check Cross-Plugin Imports / check (push) Has been cancelled

Migration 0137: Drop AI chat tables (ai_chat_sessions, ai_chat_messages, ai_chat_attachments, ai_conversations, ai_messages)

Backend:
- Remove AIChatSession, AIChatMessage, AIChatAttachment models from ai_assistant/models.py
- Remove AIConversation, AIMessage from app/models/__init__.py
- Remove session/message/stream/attachment routes from ai_assistant/routes.py
- Add new streaming route POST /ai/conversations/{conversation_id}/stream using comm tables
- Add new messages route GET /ai/conversations/{conversation_id}/messages using comm tables
- Add stream_chat_comm, get_comm_messages, save_comm_message to services.py
- Update external_api.py to use CommConversation/CommMessage instead of AIChatSession/AIChatMessage
- Update unified_search ai_chat_provider to search comm_messages with conversation_type=ai
- Remove ai_copilot router from main.py and routes/__init__.py
- Remove ai_conversation from entity_permissions.py and owner_transfer_service.py
- Update ai_assistant/plugin.py get_entity_models to remove AIChatSession
- Guard ai_copilot_service.py imports with try/except

Frontend:
- Remove AIAssistant.tsx, AIAssistantStandalone.tsx, SessionList.tsx, ChatWindow.tsx
- Remove AI Assistant routes from routes/index.tsx
- Update api/ai.ts: streamChat uses /ai/conversations/{id}/stream, fetchMessages uses /ai/conversations/{id}/messages
- Update Communication.tsx: use convId for AI streaming, remove aiSessionId, use fetchAiMessages for AI conversations
- Update AiChatPanel.tsx: create comm conversation instead of AI session, use new fetchMessages
- Update AISidebar.tsx: remove ChatWindow import, show placeholder

tsc clean, build successful, backend import OK
This commit is contained in:
Agent Zero
2026-08-21 13:34:54 +02:00
parent 94c7c8fff5
commit 7f61dfb25b
22 changed files with 361 additions and 1472 deletions
@@ -0,0 +1,34 @@
"""Drop AI chat tables (migrated to comm conversations)
Revision ID: 0137
Revises: 0136
Create Date: 2026-08-21
AI chat functionality is now handled by the kommunikation plugin's
comm_conversations and comm_messages tables. The old AI-specific tables
(ai_chat_sessions, ai_chat_messages, ai_chat_attachments, ai_conversations,
ai_messages) are no longer needed and are dropped.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0137"
down_revision = "0136"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Use IF EXISTS to avoid errors if tables are already gone
op.execute("DROP TABLE IF EXISTS ai_chat_attachments CASCADE")
op.execute("DROP TABLE IF EXISTS ai_chat_messages CASCADE")
op.execute("DROP TABLE IF EXISTS ai_chat_sessions CASCADE")
op.execute("DROP TABLE IF EXISTS ai_messages CASCADE")
op.execute("DROP TABLE IF EXISTS ai_conversations CASCADE")
def downgrade() -> None:
# Tables cannot be restored — data was migrated or was empty.
pass
-2
View File
@@ -31,7 +31,6 @@ from app.core.service_container import get_container # noqa: E402
from app.plugins.registry import get_registry # noqa: E402 from app.plugins.registry import get_registry # noqa: E402
from app.routes import ( # noqa: E402 from app.routes import ( # noqa: E402
addresses, addresses,
ai_copilot,
api_tokens, api_tokens,
approvals, approvals,
attachments, attachments,
@@ -555,7 +554,6 @@ def create_app() -> FastAPI:
app.include_router(entity_history.router) app.include_router(entity_history.router)
app.include_router(import_export.router) app.include_router(import_export.router)
app.include_router(plugins.router) app.include_router(plugins.router)
app.include_router(ai_copilot.router)
app.include_router(workflows.router) app.include_router(workflows.router)
app.include_router(user_preferences.router) app.include_router(user_preferences.router)
app.include_router(currencies.router) app.include_router(currencies.router)
-3
View File
@@ -1,7 +1,6 @@
"""SQLAlchemy models for LeoCRM.""" """SQLAlchemy models for LeoCRM."""
from app.models.address import Address from app.models.address import Address
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.attachment import Attachment from app.models.attachment import Attachment
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.auth import ApiToken, PasswordResetToken from app.models.auth import ApiToken, PasswordResetToken
@@ -69,8 +68,6 @@ __all__ = [
"BankAccount", "BankAccount",
"Plugin", "Plugin",
"PluginMigration", "PluginMigration",
"AIConversation",
"AIMessage",
"Backup", "Backup",
"CustomFieldDefinition", "CustomFieldDefinition",
"Webhook", "Webhook",
@@ -80,26 +80,29 @@ async def run_agent_external(
# Create or find a session for this external interaction # Create or find a session for this external interaction
from app.plugins.builtins.ai_assistant.models import AIChatMessage, AIChatSession # AIChatSession/AIChatMessage removed — using comm tables
session = AIChatSession( # Create a comm conversation for this external interaction
user_id=uuid.UUID(current_user["user_id"]), from app.plugins.builtins.kommunikation.models import CommConversation
agent_id=agent.id, session = CommConversation(
title=f"External: {data.message[:50]}" if data.message else "External Agent Run",
is_sidebar=False,
tenant_id=tenant_id, tenant_id=tenant_id,
title=f"External: {data.message[:50]}" if data.message else "External Agent Run",
owner_id=uuid.UUID(current_user["user_id"]), owner_id=uuid.UUID(current_user["user_id"]),
created_by=uuid.UUID(current_user["user_id"]),
created_by_type="user",
metadata_={"conversation_type": "ai", "agent_id": str(agent.id)},
) )
db.add(session) db.add(session)
await db.flush() await db.flush()
# Store the user message # Store the user message
user_msg = AIChatMessage( from app.plugins.builtins.kommunikation.models import CommMessage
session_id=session.id, user_msg = CommMessage(
role="user", conversation_id=session.id,
sender_id=uuid.UUID(current_user["user_id"]),
sender_type="user",
content=data.message, content=data.message,
tokens=0, content_format="text",
model_used=agent.name or "external",
tenant_id=tenant_id, tenant_id=tenant_id,
) )
db.add(user_msg) db.add(user_msg)
@@ -117,7 +120,7 @@ async def run_agent_external(
} }
# Run the agent via streaming chat (non-streaming mode) # Run the agent via streaming chat (non-streaming mode)
from app.plugins.builtins.ai_assistant.services import stream_chat from app.plugins.builtins.ai_assistant.services import stream_chat_comm
full_response = "" full_response = ""
async with get_db() as stream_db: async with get_db() as stream_db:
@@ -250,15 +253,16 @@ async def stream_agent_external(
raise HTTPException(status_code=400, detail="Agent is not active") raise HTTPException(status_code=400, detail="Agent is not active")
# Create session # Create session
from app.plugins.builtins.ai_assistant.models import AIChatSession # AIChatSession removed — using comm tables
session = AIChatSession( from app.plugins.builtins.kommunikation.models import CommConversation
user_id=uuid.UUID(current_user["user_id"]), session = CommConversation(
agent_id=agent.id,
title=f"External Stream: {data.message[:50]}" if data.message else "External Agent Stream",
is_sidebar=False,
tenant_id=tenant_id, tenant_id=tenant_id,
title=f"External Stream: {data.message[:50]}" if data.message else "External Agent Stream",
owner_id=uuid.UUID(current_user["user_id"]), owner_id=uuid.UUID(current_user["user_id"]),
created_by=uuid.UUID(current_user["user_id"]),
created_by_type="user",
metadata_={"conversation_type": "ai", "agent_id": str(agent.id)},
) )
db.add(session) db.add(session)
await db.commit() await db.commit()
@@ -274,15 +278,15 @@ async def stream_agent_external(
"field_permissions": current_user.get("field_permissions", {}), "field_permissions": current_user.get("field_permissions", {}),
} }
from app.plugins.builtins.ai_assistant.services import stream_chat from app.plugins.builtins.ai_assistant.services import stream_chat_comm
async def event_stream(): async def event_stream():
from app.core.db import get_session_factory from app.core.db import get_session_factory
factory = get_session_factory() factory = get_session_factory()
async with factory() as stream_db: async with factory() as stream_db:
await set_tenant_context(stream_db, tenant_id) await set_tenant_context(stream_db, tenant_id)
async for chunk in stream_chat( async for chunk in stream_chat_comm(
stream_db, session, agent, data.message, user_context, tenant_id stream_db, session.id, agent, data.message, user_context, tenant_id, uuid.UUID(current_user["user_id"])
): ):
yield chunk yield chunk
yield "data: [DONE]\n\n" yield "data: [DONE]\n\n"
@@ -134,64 +134,6 @@ class AIAgent(Base, TenantMixin, OwnedMixin):
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# --- Chat Sessions ---
class AIChatSession(Base, TenantMixin, OwnedMixin):
"""Chat session for a user with a specific agent."""
__tablename__ = "ai_chat_sessions"
__table_args__ = (
Index("ix_ai_sessions_user", "user_id"),
Index("ix_ai_sessions_tenant", "tenant_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
agent_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("ai_agents.id", ondelete="SET NULL"),
nullable=True,
)
title: Mapped[str] = mapped_column(String(255), nullable=False, default="Neuer Chat")
is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_sidebar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
folder_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("ai_chat_folders.id", ondelete="SET NULL"),
nullable=True,
)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# --- Chat Messages ---
class AIChatMessage(Base, TenantMixin, OwnedMixin):
"""Individual message in a chat session."""
__tablename__ = "ai_chat_messages"
__table_args__ = (
Index("ix_ai_messages_session", "session_id"),
Index("ix_ai_messages_tenant", "tenant_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
session_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("ai_chat_sessions.id", ondelete="CASCADE"),
nullable=False,
)
role: Mapped[str] = mapped_column(String(20), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
tool_calls: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
tool_results: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
model_used: Mapped[str] = mapped_column(String(200), nullable=False, default="")
# --- Chat Folders --- # --- Chat Folders ---
class AIChatFolder(Base, TenantMixin, OwnedMixin): class AIChatFolder(Base, TenantMixin, OwnedMixin):
@@ -215,34 +157,3 @@ class AIChatFolder(Base, TenantMixin, OwnedMixin):
) )
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False) user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# --- Chat Attachments ---
class AIChatAttachment(Base, TenantMixin, OwnedMixin):
"""File attached to a chat message."""
__tablename__ = "ai_chat_attachments"
__table_args__ = (
Index("ix_ai_attachments_message", "message_id"),
Index("ix_ai_attachments_session", "session_id"),
Index("ix_ai_attachments_tenant", "tenant_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
message_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("ai_chat_messages.id", ondelete="CASCADE"),
nullable=True,
)
session_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("ai_chat_sessions.id", ondelete="CASCADE"),
nullable=False,
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str] = mapped_column(String(255), nullable=False, default="application/octet-stream")
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
+2 -2
View File
@@ -78,8 +78,8 @@ class AIAssistantPlugin(BasePlugin):
await seed_defaults(db) await seed_defaults(db)
def get_entity_models(self) -> dict[str, type]: def get_entity_models(self) -> dict[str, type]:
from app.plugins.builtins.ai_assistant.models import AIAgent, AIChatSession from app.plugins.builtins.ai_assistant.models import AIAgent
return {"ai_agent": AIAgent, "ai_chat_session": AIChatSession} return {"ai_agent": AIAgent}
async def on_activate(self, db, service_container, event_bus) -> None: async def on_activate(self, db, service_container, event_bus) -> None:
"""Activate plugin: register CRM API tool and participant handler.""" """Activate plugin: register CRM API tool and participant handler."""
+63 -289
View File
@@ -6,20 +6,16 @@ from __future__ import annotations
import uuid import uuid
import aiofiles from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile from fastapi.responses import StreamingResponse
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db, set_tenant_context from app.core.db import get_db, set_tenant_context
from app.core.visibility import apply_visibility_filter
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
from app.plugins.builtins.ai_assistant.models import ( from app.plugins.builtins.ai_assistant.models import (
AIAgent, AIAgent,
AIChatAttachment,
AIChatFolder, AIChatFolder,
AIChatSession,
AIModel, AIModel,
AIPreset, AIPreset,
AIProvider, AIProvider,
@@ -36,25 +32,19 @@ from app.plugins.builtins.ai_assistant.schemas import (
ChatFolderCreate, ChatFolderCreate,
ChatFolderUpdate, ChatFolderUpdate,
ChatSendRequest, ChatSendRequest,
ChatSessionCreate,
ChatSessionUpdate,
) )
from app.plugins.builtins.ai_assistant.services import ( from app.plugins.builtins.ai_assistant.services import (
agent_to_response, agent_to_response,
attachment_to_response,
folder_to_response, folder_to_response,
get_agent_by_id, get_agent_by_id,
get_comm_messages,
get_default_agent, get_default_agent,
get_preset_by_id, get_preset_by_id,
get_provider_by_id, get_provider_by_id,
get_session_by_id,
get_session_messages,
message_to_response,
model_to_response, model_to_response,
preset_to_response, preset_to_response,
provider_to_response, provider_to_response,
session_to_response, stream_chat_comm,
stream_chat,
) )
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
@@ -417,207 +407,6 @@ async def list_tools(
return {"items": items, "total": len(items)} return {"items": items, "total": len(items)}
# ─── Chat Sessions ───
@router.get("/sessions", dependencies=[Depends(require_permission("ai:read"))])
async def list_sessions(
is_sidebar: bool | None = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
stmt = (
select(AIChatSession)
.where(AIChatSession.tenant_id == tenant_id)
)
stmt = await apply_visibility_filter(
db, stmt, "ai_chat_session", AIChatSession, user_id, tenant_id, is_system_admin
)
if is_sidebar is not None:
stmt = stmt.where(AIChatSession.is_sidebar == is_sidebar)
stmt = stmt.order_by(AIChatSession.updated_at.desc())
result = await db.execute(stmt)
sessions = list(result.scalars().all())
return [session_to_response(s) for s in sessions]
@router.post("/sessions", dependencies=[Depends(require_permission("ai:write"))])
async def create_session(
data: ChatSessionCreate,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
await set_tenant_context(db, tenant_id)
agent_id = None
if data.agent_id:
agent_id = uuid.UUID(data.agent_id)
elif not data.is_sidebar:
# Use default agent for non-sidebar sessions
default_agent = await get_default_agent(db, tenant_id)
if default_agent:
agent_id = default_agent.id
else:
# Sidebar also gets default agent
default_agent = await get_default_agent(db, tenant_id)
if default_agent:
agent_id = default_agent.id
folder_id = None
if data.folder_id:
folder_id = uuid.UUID(data.folder_id)
session = AIChatSession(
user_id=user_id,
agent_id=agent_id,
title=data.title,
is_sidebar=data.is_sidebar,
folder_id=folder_id,
tenant_id=tenant_id,
owner_id=user_id,
)
db.add(session)
await db.commit()
await db.refresh(session)
return session_to_response(session)
@router.put("/sessions/{session_id}", dependencies=[Depends(require_permission("ai:write"))])
async def update_session(
session_id: str,
data: ChatSessionUpdate,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
update_data = data.model_dump(exclude_unset=True)
if "agent_id" in update_data and update_data["agent_id"]:
update_data["agent_id"] = uuid.UUID(update_data["agent_id"])
if "folder_id" in update_data:
if update_data["folder_id"]:
update_data["folder_id"] = uuid.UUID(update_data["folder_id"])
else:
update_data["folder_id"] = None
for field, val in update_data.items():
setattr(session, field, val)
await db.commit()
await db.refresh(session)
return session_to_response(session)
@router.delete("/sessions/{session_id}", dependencies=[Depends(require_permission("ai:write"))])
async def delete_session(
session_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
await db.delete(session)
await db.commit()
return {"ok": True}
# ─── Chat Messages ───
@router.get("/sessions/{session_id}/messages", dependencies=[Depends(require_permission("ai:read"))])
async def list_messages(
session_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
messages = await get_session_messages(db, session.id, tenant_id)
return [message_to_response(m) for m in messages]
# ─── Streaming Chat ───
@router.post("/sessions/{session_id}/stream", dependencies=[Depends(require_permission("ai:write"))])
async def chat_stream(
session_id: str,
data: ChatSendRequest,
request: Request,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Rate limit — AI policy (cost-sensitive LLM call)
from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
await check_rate_limit_policy(
f"rate:ai:chat:{tenant_id}:{user_id}",
RateLimitPolicy.AI,
)
await set_tenant_context(db, tenant_id)
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Get agent
agent = None
if data.agent_id:
agent = await get_agent_by_id(db, uuid.UUID(data.agent_id), tenant_id)
elif session.agent_id:
agent = await get_agent_by_id(db, session.agent_id, tenant_id)
if not agent:
agent = await get_default_agent(db, tenant_id)
if not agent:
raise HTTPException(status_code=400, detail="No agent available")
# Build user context for RBAC checks in tools
user_context = {
"user_id": current_user["user_id"],
"tenant_id": current_user["tenant_id"],
"role": current_user.get("role", ""),
"permissions": current_user.get("permissions", []),
"denied_permissions": current_user.get("denied_permissions", []),
"is_system_admin": current_user.get("is_system_admin", False),
"field_permissions": current_user.get("field_permissions", {}),
}
async def event_stream():
# Use a fresh DB session — the Depends(get_db) session closes after response
from app.core.db import get_session_factory
factory = get_session_factory()
async with factory() as stream_db:
await set_tenant_context(stream_db, tenant_id)
async for chunk in stream_chat(
stream_db, session, agent, data.content, user_context, tenant_id
):
yield chunk
yield "data: [DONE]\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ─── Chat Folders ─── # ─── Chat Folders ───
@router.get("/folders", dependencies=[Depends(require_permission("ai:read"))]) @router.get("/folders", dependencies=[Depends(require_permission("ai:read"))])
@@ -713,93 +502,78 @@ async def delete_folder(
return {"ok": True} return {"ok": True}
# ─── Attachments ─── # ─── AI Chat Streaming (via comm_conversations) ───
import os # noqa: E402 @router.post("/conversations/{conversation_id}/stream", dependencies=[Depends(require_permission("ai:write"))])
from pathlib import Path # noqa: E402 async def chat_stream_comm(
conversation_id: str,
ATTACHMENT_DIR = Path(os.environ.get("STORAGE_PATH", "/data/storage")) / "ai_attachments" data: ChatSendRequest,
MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024 # 25MB request: Request,
@router.post("/sessions/{session_id}/attachments", response_model=None, dependencies=[Depends(require_permission("ai:write"))])
async def upload_attachment(
session_id: str,
file: UploadFile,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Stream AI chat response for a comm conversation with conversation_type='ai'."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
content = await file.read() from app.core.rate_limit import RateLimitPolicy, check_rate_limit_policy
if len(content) > MAX_ATTACHMENT_SIZE: await check_rate_limit_policy(
raise HTTPException(status_code=413, detail="File too large (max 25MB)") f"rate:ai:chat:{tenant_id}:{user_id}",
RateLimitPolicy.AI,
ATTACHMENT_DIR.mkdir(parents=True, exist_ok=True) )
file_id = str(uuid.uuid4())
safe_filename = file.filename or "unnamed" await set_tenant_context(db, tenant_id)
storage_path = str(ATTACHMENT_DIR / f"{file_id}_{safe_filename}")
async with aiofiles.open(storage_path, "wb") as f: agent = None
await f.write(content) if data.agent_id:
agent = await get_agent_by_id(db, uuid.UUID(data.agent_id), tenant_id)
attachment = AIChatAttachment( if not agent:
session_id=session.id, agent = await get_default_agent(db, tenant_id)
filename=safe_filename, if not agent:
mime_type=file.content_type or "application/octet-stream", raise HTTPException(status_code=400, detail="No agent available")
size_bytes=len(content),
storage_path=storage_path, user_context = {
tenant_id=tenant_id, "user_id": current_user["user_id"],
"tenant_id": current_user["tenant_id"],
"role": current_user.get("role", ""),
"permissions": current_user.get("permissions", []),
"denied_permissions": current_user.get("denied_permissions", []),
"is_system_admin": current_user.get("is_system_admin", False),
"field_permissions": current_user.get("field_permissions", {}),
}
async def event_stream():
from app.core.db import get_session_factory
factory = get_session_factory()
async with factory() as stream_db:
await set_tenant_context(stream_db, tenant_id)
async for chunk in stream_chat_comm(
stream_db, uuid.UUID(conversation_id), agent, data.content, user_context, tenant_id, user_id
):
yield chunk
yield "data: [DONE]\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
) )
db.add(attachment)
await db.commit()
await db.refresh(attachment)
return attachment_to_response(attachment)
@router.get("/sessions/{session_id}/attachments", dependencies=[Depends(require_permission("ai:read"))]) @router.get("/conversations/{conversation_id}/messages", dependencies=[Depends(require_permission("ai:read"))])
async def list_attachments( async def list_comm_messages(
session_id: str, conversation_id: str,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""List messages for an AI comm conversation."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) await set_tenant_context(db, tenant_id)
session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id) messages = await get_comm_messages(db, uuid.UUID(conversation_id), tenant_id)
if not session: return [{"role": m["role"], "content": m["content"]} for m in messages]
raise HTTPException(status_code=404, detail="Session not found")
result = await db.execute(
select(AIChatAttachment)
.where(AIChatAttachment.session_id == session.id)
.where(AIChatAttachment.tenant_id == tenant_id)
.order_by(AIChatAttachment.created_at.asc())
)
attachments = list(result.scalars().all())
return [attachment_to_response(a) for a in attachments]
@router.get("/attachments/{attachment_id}/download", dependencies=[Depends(require_permission("ai:read"))])
async def download_attachment(
attachment_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await db.execute(
select(AIChatAttachment)
.where(AIChatAttachment.id == uuid.UUID(attachment_id))
.where(AIChatAttachment.tenant_id == tenant_id)
)
attachment = result.scalar_one_or_none()
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
return FileResponse(
attachment.storage_path,
filename=attachment.filename,
media_type=attachment.mime_type,
)
+137 -266
View File
@@ -13,7 +13,6 @@ import uuid
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any from typing import Any
import aiofiles
import litellm import litellm
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,10 +21,7 @@ from app.ai.llm_client import llm_complete
from app.core.permissions import check_permission from app.core.permissions import check_permission
from app.plugins.builtins.ai_assistant.models import ( from app.plugins.builtins.ai_assistant.models import (
AIAgent, AIAgent,
AIChatAttachment,
AIChatFolder, AIChatFolder,
AIChatMessage,
AIChatSession,
AIModel, AIModel,
AIPreset, AIPreset,
AIProvider, AIProvider,
@@ -110,35 +106,6 @@ def agent_to_response(agent: AIAgent) -> dict[str, Any]:
} }
def session_to_response(session: AIChatSession) -> dict[str, Any]:
return {
"id": str(session.id),
"user_id": str(session.user_id),
"agent_id": str(session.agent_id) if session.agent_id else None,
"title": session.title,
"is_pinned": session.is_pinned,
"is_sidebar": session.is_sidebar,
"folder_id": str(session.folder_id) if session.folder_id else None,
"sort_order": session.sort_order,
"created_at": session.created_at.isoformat() if session.created_at else None,
"updated_at": session.updated_at.isoformat() if session.updated_at else None,
}
def message_to_response(msg: AIChatMessage) -> dict[str, Any]:
return {
"id": str(msg.id),
"session_id": str(msg.session_id),
"role": msg.role,
"content": msg.content,
"tool_calls": msg.tool_calls if msg.tool_calls else None,
"tool_results": msg.tool_results if msg.tool_results else None,
"tokens": msg.tokens,
"model_used": msg.model_used,
"created_at": msg.created_at.isoformat() if msg.created_at else None,
}
def folder_to_response(folder: AIChatFolder) -> dict[str, Any]: def folder_to_response(folder: AIChatFolder) -> dict[str, Any]:
return { return {
"id": str(folder.id), "id": str(folder.id),
@@ -150,17 +117,6 @@ def folder_to_response(folder: AIChatFolder) -> dict[str, Any]:
} }
def attachment_to_response(att: AIChatAttachment) -> dict[str, Any]:
return {
"id": str(att.id),
"message_id": str(att.message_id) if att.message_id else None,
"session_id": str(att.session_id),
"filename": att.filename,
"mime_type": att.mime_type,
"size_bytes": att.size_bytes,
}
# ─── Provider/Model/Preset/Agent CRUD ─── # ─── Provider/Model/Preset/Agent CRUD ───
async def get_default_provider(db: AsyncSession, tenant_id: uuid.UUID) -> AIProvider | None: async def get_default_provider(db: AsyncSession, tenant_id: uuid.UUID) -> AIProvider | None:
@@ -214,60 +170,159 @@ async def get_default_agent(db: AsyncSession, tenant_id: uuid.UUID) -> AIAgent |
return result.scalar_one_or_none() return result.scalar_one_or_none()
# ─── Session/Message helpers ─── # ─── Comm-based Chat Helpers (replaces AIChatSession/AIChatMessage) ───
async def get_session_by_id( async def get_comm_messages(
db: AsyncSession, session_id: uuid.UUID, user_id: uuid.UUID, tenant_id: uuid.UUID db: AsyncSession, conversation_id: uuid.UUID, tenant_id: uuid.UUID
) -> AIChatSession | None: ) -> list[dict[str, Any]]:
"""Get message history from comm_messages for an AI conversation."""
from app.plugins.builtins.kommunikation.models import CommMessage
result = await db.execute( result = await db.execute(
select(AIChatSession) select(CommMessage)
.where(AIChatSession.id == session_id) .where(CommMessage.conversation_id == conversation_id)
.where(AIChatSession.user_id == user_id) .where(CommMessage.tenant_id == tenant_id)
.where(AIChatSession.tenant_id == tenant_id) .order_by(CommMessage.created_at.asc())
.limit(1)
) )
return result.scalar_one_or_none() msgs = list(result.scalars().all())
return [{"role": m.sender_type if m.sender_type != "ai" else "assistant", "content": m.content} for m in msgs]
async def get_session_messages( async def save_comm_message(
db: AsyncSession, session_id: uuid.UUID, tenant_id: uuid.UUID
) -> list[AIChatMessage]:
result = await db.execute(
select(AIChatMessage)
.where(AIChatMessage.session_id == session_id)
.where(AIChatMessage.tenant_id == tenant_id)
.order_by(AIChatMessage.created_at.asc())
)
return list(result.scalars().all())
async def save_message(
db: AsyncSession, db: AsyncSession,
session_id: uuid.UUID, conversation_id: uuid.UUID,
role: str, role: str,
content: str, content: str,
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
tool_calls: list | None = None, user_id: uuid.UUID,
tool_results: list | None = None, ) -> None:
tokens: int = 0, """Save a message to comm_messages for an AI conversation."""
model_used: str = "", from app.plugins.builtins.kommunikation.models import CommMessage
) -> AIChatMessage: sender_type = "user" if role == "user" else "ai"
msg = AIChatMessage( msg = CommMessage(
session_id=session_id, conversation_id=conversation_id,
role=role, sender_id=user_id if role == "user" else None,
sender_type=sender_type,
content=content, content=content,
tool_calls=tool_calls, content_format="text",
tool_results=tool_results,
tokens=tokens,
model_used=model_used,
tenant_id=tenant_id, tenant_id=tenant_id,
) )
db.add(msg) db.add(msg)
await db.flush() await db.flush()
return msg
# ─── LLM Chat with Tool Loop ─── async def stream_chat_comm(
db: AsyncSession,
conversation_id: uuid.UUID,
agent: AIAgent,
user_message: str,
user_context: dict[str, Any],
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> AsyncGenerator[str, None]:
"""Stream chat response via SSE with tool-calling loop, using comm_messages."""
history = await get_comm_messages(db, conversation_id, tenant_id)
messages: list[dict[str, Any]] = list(history)
messages.append({"role": "user", "content": user_message})
await save_comm_message(db, conversation_id, "user", user_message, tenant_id, user_id)
# Get agent tools — always include call_crm_api for full system access
registry = get_tool_registry()
tools = registry.get_by_names(agent.tool_ids or [])
crm_api_tool = registry.get("call_crm_api")
if crm_api_tool and crm_api_tool not in tools:
tools.append(crm_api_tool)
tool_schemas = [t.to_openai_schema() for t in tools] if tools else None
# Build LLM params
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
# Agent loop: LLM → tool calls → execute → feed back → repeat
max_iterations = 5
for iteration in range(max_iterations):
if tool_schemas and iteration < max_iterations - 1:
params["tools"] = tool_schemas
elif "tools" in params:
del params["tools"]
collected_content = ""
collected_tool_calls: list[dict[str, Any]] = []
try:
result = await llm_complete(
model=params.get("model", "gpt-4o-mini"),
messages=params.get("messages", []),
temperature=params.get("temperature", 0.7),
max_tokens=params.get("max_tokens", 2048),
api_key=params.get("api_key"),
api_base=params.get("api_base"),
tools=params.get("tools"),
)
collected_content = result["content"]
if collected_content:
yield f"data: {json.dumps({'type': 'token', 'content': collected_content})}\n\n"
raw_response = result["raw_response"]
if hasattr(raw_response.choices[0].message, "tool_calls") and raw_response.choices[0].message.tool_calls:
for tc in raw_response.choices[0].message.tool_calls:
collected_tool_calls.append({
"id": tc.id or "",
"function": {
"name": tc.function.name if tc.function else "",
"arguments": tc.function.arguments if tc.function and tc.function.arguments else "",
},
})
except Exception as exc:
logger.error("LLM error: %s", exc)
yield f"data: {json.dumps({'type': 'error', 'content': str(exc)})}\n\n"
await save_comm_message(db, conversation_id, "assistant", f"Error: {exc}", tenant_id, user_id)
await db.commit()
return
if collected_tool_calls:
await save_comm_message(
db, conversation_id, "assistant", collected_content, tenant_id, user_id,
)
yield f"data: {json.dumps({'type': 'tool_calls', 'tools': [tc['function']['name'] for tc in collected_tool_calls]})}\n\n"
for tc in collected_tool_calls:
tool_name = tc["function"]["name"]
try:
tool_args = json.loads(tc["function"]["arguments"])
except json.JSONDecodeError:
tool_args = {}
tool = registry.get(tool_name)
if tool is None:
result = f"Tool '{tool_name}' not found"
else:
result = await execute_tool_call(tool, tool_args, user_context)
yield f"data: {json.dumps({'type': 'tool_result', 'tool': tool_name, 'result': result[:500]})}\n\n"
messages.append({
"role": "assistant",
"content": collected_content,
"tool_calls": collected_tool_calls,
})
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"name": tool_name,
"content": result,
})
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
continue
# No tool calls — final response
await save_comm_message(db, conversation_id, "assistant", collected_content, tenant_id, user_id)
await db.commit()
yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n"
return
# Max iterations reached
await save_comm_message(db, conversation_id, "assistant", collected_content, tenant_id, user_id)
await db.commit()
yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n"
async def build_litellm_params( async def build_litellm_params(
db: AsyncSession, db: AsyncSession,
@@ -364,190 +419,6 @@ async def execute_tool_call(
return f"Error executing tool '{tool.name}': {exc}" return f"Error executing tool '{tool.name}': {exc}"
async def _extract_attachment_content(
db: AsyncSession,
session_id: uuid.UUID,
tenant_id: uuid.UUID,
) -> str:
"""Extract text content from session attachments for LLM context."""
result = await db.execute(
select(AIChatAttachment)
.where(AIChatAttachment.session_id == session_id)
.where(AIChatAttachment.tenant_id == tenant_id)
.order_by(AIChatAttachment.created_at.asc())
)
attachments = list(result.scalars().all())
if not attachments:
return ""
parts: list[str] = []
for att in attachments:
try:
async with aiofiles.open(att.storage_path, "rb") as f:
content = await f.read()
text_content = ""
mime = att.mime_type.lower()
if mime.startswith("text/") or att.filename.endswith((".txt", ".md", ".csv", ".json", ".yaml", ".yml", ".py", ".js", ".ts", ".html", ".xml")):
text_content = content.decode("utf-8", errors="replace")
elif mime == "application/pdf" or att.filename.endswith(".pdf"):
try:
from io import BytesIO
from pypdf import PdfReader
reader = PdfReader(BytesIO(content))
text_content = "\n".join(page.extract_text() or "" for page in reader.pages)
except ImportError:
text_content = f"[PDF file: {att.filename} - extraction not available]"
elif mime.startswith("image/"):
text_content = f"[Image file: {att.filename} ({att.mime_type}, {att.size_bytes} bytes)]"
else:
text_content = f"[Binary file: {att.filename} ({att.mime_type}, {att.size_bytes} bytes)]"
if len(text_content) > 10000:
text_content = text_content[:10000] + "\n... [truncated]"
parts.append(f"--- Attachment: {att.filename} ---\n{text_content}")
except Exception as exc:
logger.warning("Failed to extract attachment %s: %s", att.filename, exc)
parts.append(f"--- Attachment: {att.filename} (extraction failed) ---")
return "\n\n".join(parts)
async def stream_chat(
db: AsyncSession,
session: AIChatSession,
agent: AIAgent,
user_message: str,
user_context: dict[str, Any],
tenant_id: uuid.UUID,
) -> AsyncGenerator[str, None]:
"""Stream chat response via SSE with tool-calling loop."""
history = await get_session_messages(db, session.id, tenant_id)
messages: list[dict[str, Any]] = []
for msg in history:
messages.append({"role": msg.role, "content": msg.content})
attachment_content = await _extract_attachment_content(db, session.id, tenant_id)
full_message = user_message
if attachment_content:
full_message = f"{user_message}\n\n--- Attached Files ---\n{attachment_content}"
messages.append({"role": "user", "content": full_message})
await save_message(db, session.id, "user", user_message, tenant_id)
# Get agent tools — always include call_crm_api for full system access
registry = get_tool_registry()
tools = registry.get_by_names(agent.tool_ids or [])
# Ensure call_crm_api is always available
crm_api_tool = registry.get("call_crm_api")
if crm_api_tool and crm_api_tool not in tools:
tools.append(crm_api_tool)
tool_schemas = [t.to_openai_schema() for t in tools] if tools else None
# Build LLM params
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
# Agent loop: LLM → tool calls → execute → feed back → repeat
max_iterations = 5
for iteration in range(max_iterations):
# Add tools to params if available, but NOT on the last iteration
# to force the LLM to give a final answer instead of looping
if tool_schemas and iteration < max_iterations - 1:
params["tools"] = tool_schemas
elif "tools" in params:
del params["tools"]
# LLM response via llm_complete (non-streaming)
collected_content = ""
collected_tool_calls: list[dict[str, Any]] = []
try:
result = await llm_complete(
model=params.get("model", "gpt-4o-mini"),
messages=params.get("messages", []),
temperature=params.get("temperature", 0.7),
max_tokens=params.get("max_tokens", 2048),
api_key=params.get("api_key"),
api_base=params.get("api_base"),
tools=params.get("tools"),
)
collected_content = result["content"]
if collected_content:
yield f"data: {json.dumps({'type': 'token', 'content': collected_content})}\n\n"
# Extract tool calls from raw response
raw_response = result["raw_response"]
if hasattr(raw_response.choices[0].message, "tool_calls") and raw_response.choices[0].message.tool_calls:
for tc in raw_response.choices[0].message.tool_calls:
collected_tool_calls.append({
"id": tc.id or "",
"function": {
"name": tc.function.name if tc.function else "",
"arguments": tc.function.arguments if tc.function and tc.function.arguments else "",
},
})
except Exception as exc:
logger.error("LLM error: %s", exc)
yield f"data: {json.dumps({'type': 'error', 'content': str(exc)})}\n\n"
await save_message(db, session.id, "assistant", f"Error: {exc}", tenant_id, model_used=model_id)
await db.commit()
return
# If tool calls, execute them and continue loop
if collected_tool_calls:
# Save assistant message with tool calls
await save_message(
db, session.id, "assistant", collected_content, tenant_id,
tool_calls=collected_tool_calls, model_used=model_id,
)
yield f"data: {json.dumps({'type': 'tool_calls', 'tools': [tc['function']['name'] for tc in collected_tool_calls]})}\n\n"
# Execute each tool call
for tc in collected_tool_calls:
tool_name = tc["function"]["name"]
try:
tool_args = json.loads(tc["function"]["arguments"])
except json.JSONDecodeError:
tool_args = {}
tool = registry.get(tool_name)
if tool is None:
result = f"Tool '{tool_name}' not found"
else:
result = await execute_tool_call(tool, tool_args, user_context)
yield f"data: {json.dumps({'type': 'tool_result', 'tool': tool_name, 'result': result[:500]})}\n\n"
# Add tool result to messages for next iteration
messages.append({
"role": "assistant",
"content": collected_content,
"tool_calls": collected_tool_calls,
})
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"name": tool_name,
"content": result,
})
# Update params with new messages for next iteration
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
continue
# No tool calls — final response
await save_message(db, session.id, "assistant", collected_content, tenant_id, model_used=model_id)
await db.commit()
yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n"
return
# Max iterations reached
await save_message(db, session.id, "assistant", collected_content, tenant_id, model_used=model_id)
await db.commit()
yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n"
# ─── Seed Defaults ─── # ─── Seed Defaults ───
async def seed_defaults(db: AsyncSession) -> None: async def seed_defaults(db: AsyncSession) -> None:
@@ -1,4 +1,4 @@
"""AI chat search provider — FTS search on ai_chat_messages table.""" """AI chat search provider — FTS search on comm_messages for AI conversations."""
from __future__ import annotations from __future__ import annotations
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
class AIChatSearchProvider(BaseSearchProvider): class AIChatSearchProvider(BaseSearchProvider):
"""Search provider for AI chat messages.""" """Search provider for AI chat messages in comm_messages."""
entity_type = "ai_chat" entity_type = "ai_chat"
supports_fts = True supports_fts = True
@@ -29,18 +29,18 @@ class AIChatSearchProvider(BaseSearchProvider):
limit: int, limit: int,
visible_ids: set[uuid.UUID] | None, visible_ids: set[uuid.UUID] | None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Full-text search on ai_chat_messages.content, joined with sessions for title.""" """Full-text search on comm_messages.content for AI conversations."""
if visible_ids is not None: if visible_ids is not None:
sql = text( sql = text(
""" """
SELECT m.id, m.tenant_id, m.session_id, m.role, m.content, SELECT m.id, m.tenant_id, m.conversation_id, m.sender_type AS role, m.content,
m.model_used, m.tokens, c.title AS session_title,
s.title AS session_title,
ts_rank(to_tsvector('pg_catalog.german', m.content), ts_rank(to_tsvector('pg_catalog.german', m.content),
to_tsquery('pg_catalog.german', :q)) AS rank to_tsquery('pg_catalog.german', :q)) AS rank
FROM ai_chat_messages m FROM comm_messages m
JOIN ai_chat_sessions s ON s.id = m.session_id JOIN comm_conversations c ON c.id = m.conversation_id
WHERE m.tenant_id = :tid WHERE m.tenant_id = :tid
AND c.metadata->>'conversation_type' = 'ai'
AND to_tsvector('pg_catalog.german', m.content) @@ to_tsquery('pg_catalog.german', :q) AND to_tsvector('pg_catalog.german', m.content) @@ to_tsquery('pg_catalog.german', :q)
AND m.id = ANY(:visible_ids) AND m.id = ANY(:visible_ids)
ORDER BY rank DESC ORDER BY rank DESC
@@ -59,14 +59,14 @@ class AIChatSearchProvider(BaseSearchProvider):
else: else:
sql = text( sql = text(
""" """
SELECT m.id, m.tenant_id, m.session_id, m.role, m.content, SELECT m.id, m.tenant_id, m.conversation_id, m.sender_type AS role, m.content,
m.model_used, m.tokens, c.title AS session_title,
s.title AS session_title,
ts_rank(to_tsvector('pg_catalog.german', m.content), ts_rank(to_tsvector('pg_catalog.german', m.content),
to_tsquery('pg_catalog.german', :q)) AS rank to_tsquery('pg_catalog.german', :q)) AS rank
FROM ai_chat_messages m FROM comm_messages m
JOIN ai_chat_sessions s ON s.id = m.session_id JOIN comm_conversations c ON c.id = m.conversation_id
WHERE m.tenant_id = :tid WHERE m.tenant_id = :tid
AND c.metadata->>'conversation_type' = 'ai'
AND to_tsvector('pg_catalog.german', m.content) @@ to_tsquery('pg_catalog.german', :q) AND to_tsvector('pg_catalog.german', m.content) @@ to_tsquery('pg_catalog.german', :q)
ORDER BY rank DESC ORDER BY rank DESC
LIMIT :lim LIMIT :lim
@@ -97,7 +97,7 @@ class AIChatSearchProvider(BaseSearchProvider):
sql = text( sql = text(
""" """
SELECT content SELECT content
FROM ai_chat_messages FROM comm_messages
WHERE id = :eid AND tenant_id = :tid WHERE id = :eid AND tenant_id = :tid
""" """
) )
@@ -114,14 +114,14 @@ class AIChatSearchProvider(BaseSearchProvider):
content = entity.get("content", "") content = entity.get("content", "")
role = entity.get("role", "") role = entity.get("role", "")
session_title = entity.get("session_title", "") session_title = entity.get("session_title", "")
session_id = str(entity.get("session_id", "")) conversation_id = str(entity.get("conversation_id", ""))
score = entity.get("rank", 0.0) score = entity.get("rank", 0.0)
else: else:
entity_id = str(getattr(entity, "id", "")) entity_id = str(getattr(entity, "id", ""))
content = getattr(entity, "content", "") content = getattr(entity, "content", "")
role = getattr(entity, "role", "") role = getattr(entity, "role", "")
session_title = getattr(entity, "session_title", "") session_title = getattr(entity, "session_title", "")
session_id = str(getattr(entity, "session_id", "")) conversation_id = str(getattr(entity, "conversation_id", ""))
score = getattr(entity, "rank", 0.0) score = getattr(entity, "rank", 0.0)
return { return {
"entity_type": self.entity_type, "entity_type": self.entity_type,
@@ -131,7 +131,7 @@ class AIChatSearchProvider(BaseSearchProvider):
"score": float(score) if score else 0.0, "score": float(score) if score else 0.0,
"data": { "data": {
"role": role, "role": role,
"session_id": session_id, "conversation_id": conversation_id,
"session_title": session_title, "session_title": session_title,
}, },
} }
-1
View File
@@ -2,7 +2,6 @@
from app.routes import ( from app.routes import (
addresses, # noqa: F401 addresses, # noqa: F401
ai_copilot, # noqa: F401
attachments, # noqa: F401 attachments, # noqa: F401
audit, # noqa: F401 audit, # noqa: F401
compliance, # noqa: F401 compliance, # noqa: F401
-1
View File
@@ -250,7 +250,6 @@ async def list_entity_registry(
{"entity_type": "webhook", "label": "Webhooks", "table": "webhooks"}, {"entity_type": "webhook", "label": "Webhooks", "table": "webhooks"},
{"entity_type": "notification", "label": "Benachrichtigungen", "table": "notifications"}, {"entity_type": "notification", "label": "Benachrichtigungen", "table": "notifications"},
{"entity_type": "custom_field_definition", "label": "Custom Fields", "table": "custom_field_definitions"}, {"entity_type": "custom_field_definition", "label": "Custom Fields", "table": "custom_field_definitions"},
{"entity_type": "ai_conversation", "label": "AI Konversationen", "table": "ai_conversations"},
] ]
return {"items": entity_types, "total": len(entity_types)} return {"items": entity_types, "total": len(entity_types)}
+5 -1
View File
@@ -12,7 +12,11 @@ from app.ai.llm_client import get_llm_client
from app.core.audit import log_audit from app.core.audit import log_audit
from app.core.permissions import check_permission from app.core.permissions import check_permission
from app.core.visibility import apply_visibility_filter, check_single_entity_access from app.core.visibility import apply_visibility_filter, check_single_entity_access
from app.models.ai_conversation import AIConversation, AIMessage try:
from app.models.ai_conversation import AIConversation, AIMessage
except ImportError:
AIConversation = None # type: ignore
AIMessage = None # type: ignore
from app.models.contact import Contact from app.models.contact import Contact
from app.models.workflow import Workflow from app.models.workflow import Workflow
-1
View File
@@ -24,7 +24,6 @@ ENTITY_TABLES: dict[str, str] = {
"saved_views": "saved_views", "saved_views": "saved_views",
"webhooks": "webhooks", "webhooks": "webhooks",
"notifications": "notifications", "notifications": "notifications",
"ai_conversations": "ai_conversations",
} }
+4 -4
View File
@@ -166,8 +166,8 @@ export const deleteSession = (id: string) => apiDelete(`/ai/sessions/${id}`);
// ─── Messages ─── // ─── Messages ───
export const fetchMessages = (sessionId: string) => export const fetchMessages = (conversationId: string) =>
apiGet<ChatMessage[]>(`/ai/sessions/${sessionId}/messages`); apiGet<{ role: string; content: string }[]>(`/ai/conversations/${conversationId}/messages`);
// ─── Attachments ─── // ─── Attachments ───
@@ -205,12 +205,12 @@ export interface StreamEvent {
} }
export async function* streamChat( export async function* streamChat(
sessionId: string, conversationId: string,
content: string, content: string,
agentId?: string agentId?: string
): AsyncGenerator<StreamEvent> { ): AsyncGenerator<StreamEvent> {
const csrfToken = sessionStorage.getItem('leocrm_csrf_token'); const csrfToken = sessionStorage.getItem('leocrm_csrf_token');
const response = await fetch(`/api/v1/ai/sessions/${sessionId}/stream`, { const response = await fetch(`/api/v1/ai/conversations/${conversationId}/stream`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
-196
View File
@@ -1,196 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import clsx from 'clsx';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
streamChat, fetchMessages, fetchAttachments, uploadAttachment, getAttachmentDownloadUrl,
type ChatMessage, type ChatAttachment,
} from '@/api/ai';
interface ChatWindowProps {
sessionId: string;
agentId?: string | null;
className?: string;
compact?: boolean;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function MessageContent({ content, role }: { content: string; role: string }) {
if (role === 'user') {
return <div className="whitespace-pre-wrap break-words">{content}</div>;
}
return (
<div className="ai-markdown max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
function ActivityIndicator({ status }: { status: string | null }) {
if (!status) return null;
return (
<div className="flex items-center gap-2 px-4 py-2 text-xs text-secondary-500">
<div className="flex gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary-400 animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-primary-400 animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-primary-400 animate-bounce [animation-delay:300ms]" />
</div>
<span>{status}</span>
</div>
);
}
export function ChatWindow({ sessionId, agentId, className, compact }: ChatWindowProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [activity, setActivity] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploadingFile, setUploadingFile] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!sessionId) return;
setMessages([]);
setAttachments([]);
setError(null);
fetchMessages(sessionId).then(setMessages).catch((e) => setError(e?.message || 'Failed to load'));
fetchAttachments(sessionId).then(setAttachments).catch(() => {});
}, [sessionId]);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages, streamingContent, activity]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
setUploadingFile(true);
try {
for (const file of files) {
const att = await uploadAttachment(sessionId, file);
setAttachments((prev) => [...prev, att]);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed');
} finally {
setUploadingFile(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSend = async () => {
const content = input.trim();
if (!content || isStreaming) return;
setInput('');
setIsStreaming(true);
setStreamingContent('');
setActivity('Denke nach...');
setError(null);
const userMsg: ChatMessage = { id: 'temp-' + Date.now(), session_id: sessionId, role: 'user', content, tokens: 0, model_used: '' };
setMessages((prev) => [...prev, userMsg]);
try {
const stream = streamChat(sessionId, content, agentId || undefined);
let accumulated = '';
for await (const event of stream) {
if (event.type === 'token' && event.content) {
accumulated += event.content;
setStreamingContent(accumulated);
setActivity(null);
} else if (event.type === 'tool_calls' && event.tools) {
setActivity(`Rufe Tool auf: ${event.tools.join(', ')}`);
} else if (event.type === 'tool_result' && event.tool) {
setActivity(`Tool '${event.tool}' ausgeführt`);
} else if (event.type === 'done') {
setActivity(null);
const msgs = await fetchMessages(sessionId);
setMessages(msgs);
setStreamingContent('');
} else if (event.type === 'error') {
setError(event.content || 'Unknown error');
setActivity(null);
}
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Stream failed');
} finally {
setIsStreaming(false);
setStreamingContent('');
setActivity(null);
inputRef.current?.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
};
return (
<div className={clsx('flex flex-col h-full bg-white', className)}>
{attachments.length > 0 && (
<div className={clsx('border-b border-secondary-200 bg-secondary-50', compact ? 'px-2 py-1' : 'px-4 py-2')}>
<div className="flex flex-wrap gap-2">
{attachments.map((att) => (
<a key={att.id} href={getAttachmentDownloadUrl(att.id)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 rounded-md bg-white border border-secondary-200 px-2 py-1 text-xs hover:border-primary-400">
<svg className="w-3.5 h-3.5 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
<span className="max-w-32 truncate">{att.filename}</span>
<span className="text-secondary-400">{formatSize(att.size_bytes)}</span>
</a>
))}
</div>
</div>
)}
<div ref={scrollRef} className={clsx('flex-1 overflow-y-auto', compact ? 'p-3' : 'p-6')}>
{messages.length === 0 && !streamingContent && !error && (
<div className="flex items-center justify-center h-full text-secondary-400 text-sm">Starte eine Konversation...</div>
)}
{messages.map((msg) => (
<div key={msg.id} className="mb-6">
<div className={clsx('text-xs font-medium mb-1', msg.role === 'user' ? 'text-primary-600' : 'text-secondary-400')}>
{msg.role === 'user' ? 'Du' : 'KI'}
</div>
<div className={clsx('rounded-lg px-4 py-2 text-sm', msg.role === 'user' ? 'bg-primary-50 border border-primary-100' : '')}>
<MessageContent content={msg.content} role={msg.role} />
{msg.tool_calls && msg.tool_calls.length > 0 && (
<div className="mt-2 text-xs text-secondary-400 border-l-2 border-secondary-200 pl-2">
Tools: {msg.tool_calls.map((tc: any) => tc.function?.name).join(', ')}
</div>
)}
</div>
</div>
))}
{streamingContent && (
<div className="mb-6">
<div className="text-xs font-medium mb-1 text-secondary-400">KI</div>
<div className="text-sm text-secondary-900">
<MessageContent content={streamingContent} role="assistant" />
</div>
</div>
)}
<ActivityIndicator status={activity} />
{error && <div className="mb-4 text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</div>}
</div>
<div className={clsx('border-t border-secondary-200', compact ? 'p-2' : 'p-4')}>
<div className="flex items-end gap-2">
<input ref={fileInputRef} type="file" multiple onChange={handleFileSelect} className="hidden" />
<button onClick={() => fileInputRef.current?.click()} disabled={uploadingFile || isStreaming} className="rounded-lg border border-secondary-300 p-2 text-secondary-500 hover:bg-secondary-100 disabled:opacity-50" title="Datei anhängen">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
</button>
<textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} disabled={isStreaming} placeholder="Nachricht eingeben..." rows={compact ? 1 : 2} className={clsx('flex-1 resize-none rounded-lg border border-secondary-300 px-3 py-2 text-sm', 'focus:outline-none focus:ring-2 focus:ring-primary-500', 'disabled:opacity-50')} />
<button onClick={handleSend} disabled={isStreaming || !input.trim()} className={clsx('rounded-lg px-4 py-2 text-sm font-medium text-white', 'bg-primary-600 hover:bg-primary-700', 'disabled:opacity-50 disabled:cursor-not-allowed')}>{isStreaming ? '...' : 'Senden'}</button>
</div>
</div>
</div>
);
}
-385
View File
@@ -1,385 +0,0 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import clsx from 'clsx';
import { useQueryClient } from '@tanstack/react-query';
import {
fetchSessions, createSession, deleteSession, updateSession,
fetchFolders, createFolder, deleteFolder, updateFolder,
type ChatSession, type ChatFolder,
} from '@/api/ai';
interface SessionListProps {
activeSessionId: string | null;
onSelectSession: (id: string) => void;
isSidebar?: boolean;
className?: string;
}
interface TreeNode {
folder: ChatFolder | null;
sessions: ChatSession[];
children: TreeNode[];
}
interface ContextMenuState {
x: number;
y: number;
type: 'session' | 'folder' | 'root';
id: string | null;
}
function buildTree(folders: ChatFolder[], sessions: ChatSession[]): TreeNode[] {
const rootSessions = sessions.filter((s) => !s.folder_id);
const root: TreeNode = { folder: null, sessions: rootSessions, children: [] };
const folderMap = new Map<string, TreeNode>();
for (const f of folders) folderMap.set(f.id, { folder: f, sessions: [], children: [] });
for (const f of folders) {
const node = folderMap.get(f.id)!;
if (f.parent_id && folderMap.has(f.parent_id)) folderMap.get(f.parent_id)!.children.push(node);
else root.children.push(node);
}
for (const s of sessions) {
if (s.folder_id && folderMap.has(s.folder_id)) folderMap.get(s.folder_id)!.sessions.push(s);
}
return [root];
}
function TreeItem({
node, activeSessionId, onSelectSession, depth, onContextMenu, onDragSession, onDragFolder, onDropToFolder, onDelete, onRename,
}: {
node: TreeNode;
activeSessionId: string | null;
onSelectSession: (id: string) => void;
depth: number;
onContextMenu: (e: React.MouseEvent, type: 'session' | 'folder' | 'root', id: string | null) => void;
onDragSession: (sessionId: string) => void;
onDragFolder: (folderId: string) => void;
onDropToFolder: (folderId: string | null) => void;
onDelete: (id: string, type: 'session' | 'folder') => void;
onRename: (id: string, type: 'session' | 'folder') => void;
}) {
const [expanded, setExpanded] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
const isRoot = node.folder === null;
const folderId = node.folder?.id || null;
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const data = e.dataTransfer.getData('text/plain');
if (data.startsWith('folder:')) {
const draggedFolderId = data.slice(7);
if (draggedFolderId !== folderId) {
onDropToFolder(folderId);
}
} else {
onDropToFolder(folderId);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isRoot) setIsDragOver(true);
};
return (
<div>
{/* Folder header - drop target */}
{!isRoot && (
<div
onDragOver={handleDragOver}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onContextMenu={(e) => onContextMenu(e, 'folder', node.folder!.id)}
className={clsx(
'group flex items-center gap-1 px-2 py-1.5 text-sm font-medium rounded-md cursor-pointer',
isDragOver ? 'bg-primary-100 ring-2 ring-primary-400' : 'text-secondary-700 hover:bg-secondary-100'
)}
style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => setExpanded(!expanded)}
>
<svg className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-90')} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', `folder:${node.folder!.id}`);
e.dataTransfer.effectAllowed = 'move';
onDragFolder(node.folder!.id);
}}
className="cursor-grab active:cursor-grabbing flex items-center"
title="Ordner verschieben"
>
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</span>
<span className="flex-1 truncate">{node.folder!.name}</span>
<button onClick={(e) => { e.stopPropagation(); onRename(node.folder!.id, 'folder'); }} className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-primary-600 p-0.5" title="Umbenennen">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</button>
<button onClick={(e) => { e.stopPropagation(); onDelete(node.folder!.id, 'folder'); }} className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600 p-0.5" title="Löschen">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
)}
{/* Sessions */}
{(isRoot || expanded) && (
<div>
{node.sessions.map((session) => (
<div
key={session.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', session.id);
e.dataTransfer.effectAllowed = 'move';
onDragSession(session.id);
}}
onContextMenu={(e) => onContextMenu(e, 'session', session.id)}
onClick={() => onSelectSession(session.id)}
className={clsx(
'group flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer rounded-md',
'hover:bg-secondary-100',
activeSessionId === session.id && 'bg-primary-50'
)}
style={{ paddingLeft: depth * 12 + 24 }}
>
<svg className="w-3.5 h-3.5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h8M8 8h8m-8 4h8m-8 4h8" />
</svg>
<span className={clsx('flex-1 truncate', activeSessionId === session.id && 'font-bold text-primary-700')}>
{session.title}
</span>
<button onClick={(e) => { e.stopPropagation(); onRename(session.id, 'session'); }} className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-primary-600 p-0.5" title="Umbenennen">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</button>
<button onClick={(e) => { e.stopPropagation(); onDelete(session.id, 'session'); }} className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600 p-0.5" title="Löschen">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
))}
</div>
)}
{/* Root drop zone */}
{isRoot && (
<div
onDragOver={handleDragOver}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onContextMenu={(e) => onContextMenu(e, 'root', null)}
className={clsx(
'min-h-[12px] mt-1 rounded-md transition-colors',
isDragOver ? 'bg-primary-50 ring-1 ring-primary-300' : 'hover:bg-secondary-50'
)}
/>
)}
{/* Child folders */}
{(isRoot || expanded) && node.children.map((child) => (
<TreeItem
key={child.folder!.id}
node={child}
activeSessionId={activeSessionId}
onSelectSession={onSelectSession}
depth={depth + 1}
onContextMenu={onContextMenu}
onDragSession={onDragSession}
onDragFolder={onDragFolder}
onDropToFolder={onDropToFolder}
onDelete={onDelete}
onRename={onRename}
/>
))}
</div>
);
}
function ContextMenu({
state, onClose, onRename, onDelete, onNewFolder, onNewChat,
}: {
state: ContextMenuState;
onClose: () => void;
onRename: (id: string, type: 'session' | 'folder') => void;
onDelete: (id: string, type: 'session' | 'folder') => void;
onNewFolder: () => void;
onNewChat: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onClose]);
const items: { label: string; action: () => void; danger?: boolean }[] = [];
if (state.type === 'session') {
items.push({ label: 'Umbenennen', action: () => { onRename(state.id!, 'session'); onClose(); } });
items.push({ label: 'Löschen', action: () => { onDelete(state.id!, 'session'); onClose(); }, danger: true });
} else if (state.type === 'folder') {
items.push({ label: 'Umbenennen', action: () => { onRename(state.id!, 'folder'); onClose(); } });
items.push({ label: 'Neuer Unterordner', action: () => { onClose(); /* TODO */ } });
items.push({ label: 'Löschen', action: () => { onDelete(state.id!, 'folder'); onClose(); }, danger: true });
} else {
items.push({ label: 'Neuer Chat', action: () => { onNewChat(); onClose(); } });
items.push({ label: 'Neuer Ordner', action: () => { onNewFolder(); onClose(); } });
}
return (
<div
ref={ref}
className="fixed z-50 bg-white border border-secondary-200 rounded-lg shadow-lg py-1 min-w-[160px]"
style={{ left: state.x, top: state.y }}
>
{items.map((item, i) => (
<button
key={i}
onClick={item.action}
className={clsx(
'w-full text-left px-3 py-1.5 text-sm hover:bg-secondary-100',
item.danger && 'text-red-600 hover:bg-red-50'
)}
>
{item.label}
</button>
))}
</div>
);
}
export function SessionList({ activeSessionId, onSelectSession, isSidebar, className }: SessionListProps) {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [folders, setFolders] = useState<ChatFolder[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dragSessionId, setDragSessionId] = useState<string | null>(null);
const [dragFolderId, setDragFolderId] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const queryClient = useQueryClient();
const loadData = async () => {
try {
setLoading(true);
const [s, f] = await Promise.all([fetchSessions(isSidebar ? true : undefined), fetchFolders()]);
setSessions(s); setFolders(f); setError(null);
} catch (e) { setError(e instanceof Error ? e.message : 'Failed to load'); }
finally { setLoading(false); }
};
useEffect(() => { loadData(); }, [isSidebar]);
const handleContextMenu = useCallback((e: React.MouseEvent, type: 'session' | 'folder' | 'root', id: string | null) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, type, id });
}, []);
const handleRename = async (id: string, type: 'session' | 'folder') => {
const newName = prompt('Neuer Name:', type === 'session'
? sessions.find(s => s.id === id)?.title || ''
: folders.find(f => f.id === id)?.name || ''
);
if (!newName) return;
try {
if (type === 'session') await updateSession(id, { title: newName });
else {
await updateFolder(id, { name: newName });
queryClient.invalidateQueries({ queryKey: ['ai', 'folders'] });
}
loadData();
} catch (e) { setError(e instanceof Error ? e.message : 'Rename failed'); }
};
const handleDelete = async (id: string, type: 'session' | 'folder') => {
if (!confirm(type === 'session' ? 'Chat löschen?' : 'Ordner löschen? Chats bleiben erhalten.')) return;
try {
if (type === 'session') {
await deleteSession(id);
if (activeSessionId === id) onSelectSession('');
} else {
await deleteFolder(id);
}
loadData();
} catch (e) { setError(e instanceof Error ? e.message : 'Delete failed'); }
};
const handleNewFolder = () => {
const name = prompt('Ordnername:');
if (!name) return;
createFolder({ name }).then(() => loadData()).catch((e) => setError(e?.message));
};
const handleNewChat = async () => {
try {
const session = await createSession({ is_sidebar: isSidebar || false });
onSelectSession(session.id);
loadData();
} catch (e) { setError(e instanceof Error ? e.message : 'Failed'); }
};
const handleDropToFolder = async (folderId: string | null) => {
if (dragFolderId) {
try {
await updateFolder(dragFolderId, { parent_id: folderId });
setDragFolderId(null);
loadData();
} catch (e) { setError(e instanceof Error ? e.message : 'Failed to move folder'); }
return;
}
if (!dragSessionId) return;
try {
await updateSession(dragSessionId, { folder_id: folderId });
setDragSessionId(null);
loadData();
} catch (e) { setError(e instanceof Error ? e.message : 'Failed to move'); }
};
if (loading) return <div className="p-4 text-sm text-secondary-400">Laden...</div>;
const tree = buildTree(folders, sessions);
return (
<div className={clsx('flex flex-col h-full', className)}>
{error && <div className="p-2 text-xs text-red-600">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</div>}
<div
className="flex-1 overflow-y-auto p-2"
onContextMenu={(e) => {
if (e.target === e.currentTarget) handleContextMenu(e, 'root', null);
}}
>
{tree.map((node) => (
<TreeItem
key={node.folder?.id || 'root'}
node={node}
activeSessionId={activeSessionId}
onSelectSession={onSelectSession}
depth={0}
onContextMenu={handleContextMenu}
onDragSession={setDragSessionId}
onDragFolder={setDragFolderId}
onDropToFolder={handleDropToFolder}
onDelete={handleDelete}
onRename={handleRename}
/>
))}
</div>
{contextMenu && (
<ContextMenu
state={contextMenu}
onClose={() => setContextMenu(null)}
onRename={handleRename}
onDelete={handleDelete}
onNewFolder={handleNewFolder}
onNewChat={handleNewChat}
/>
)}
</div>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { ChatWindow } from '@/components/ai/ChatWindow';
import { ResizablePanel } from '@/components/ui/ResizablePanel'; import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { SuggestionList } from '@/components/ai/SuggestionSidebar'; import { SuggestionList } from '@/components/ai/SuggestionSidebar';
import { ImprovementPanel } from '@/components/ai/ImprovementPanel'; import { ImprovementPanel } from '@/components/ai/ImprovementPanel';
@@ -216,7 +216,7 @@ export function AISidebar() {
); );
} }
if (sessionId) { if (sessionId) {
return <ChatWindow sessionId={sessionId} compact className="h-full" />; return <div className="flex items-center justify-center h-full text-secondary-400 text-sm">KI Chat im Kommunikations-Plugin verfügbar</div>;
} }
return ( return (
<div className="flex items-center justify-center h-full text-sm text-red-500"> <div className="flex items-center justify-center h-full text-sm text-red-500">
+16 -14
View File
@@ -1,12 +1,8 @@
import { asError } from '@/utils/errorTypes'; import { asError } from '@/utils/errorTypes';
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Sparkles, Send } from 'lucide-react'; import { Sparkles, Send } from 'lucide-react';
import { import { streamChat, fetchMessages } from '@/api/ai';
streamChat, import { apiClient } from '@/api/client';
createSession,
fetchMessages,
type ChatMessage,
} from '@/api/ai';
interface AiChatPanelProps { interface AiChatPanelProps {
windowTitle: string; windowTitle: string;
@@ -30,24 +26,30 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const msgIdCounter = useRef(0); const msgIdCounter = useRef(0);
// Create a sidebar session on mount // Create a comm conversation for AI chat on mount
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoadingSession(true); setLoadingSession(true);
createSession({ title: `KI: ${windowTitle}`, is_sidebar: true }) apiClient.post('/comm/conversations', {
.then((session) => { title: `KI: ${windowTitle}`,
participant_ids: [],
is_direct: false,
metadata: { conversation_type: 'ai' },
})
.then((res) => {
if (cancelled) return; if (cancelled) return;
setSessionId(session.id); const convId = res.data.id;
return fetchMessages(session.id).then((msgs: ChatMessage[]) => { setSessionId(convId);
return fetchMessages(convId).then((msgs) => {
if (cancelled) return; if (cancelled) return;
setMessages( setMessages(
msgs.map((m) => ({ id: m.id, role: m.role, content: m.content })) msgs.map((m, i) => ({ id: `msg-${i}`, role: m.role, content: m.content }))
); );
}); });
}) })
.catch((e) => { .catch((e) => {
if (!cancelled) { if (!cancelled) {
console.error('AI session creation failed:', e); console.error('AI conversation creation failed:', e);
setError('KI Chat ist in diesem Kontext nicht verfügbar'); setError('KI Chat ist in diesem Kontext nicht verfügbar');
} }
}) })
@@ -90,7 +92,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
} else if (event.type === 'done') { } else if (event.type === 'done') {
const msgs = await fetchMessages(sessionId); const msgs = await fetchMessages(sessionId);
setMessages( setMessages(
msgs.map((m) => ({ id: m.id, role: m.role, content: m.content })) msgs.map((m, i) => ({ id: `msg-${i}`, role: m.role, content: m.content }))
); );
setStreamingContent(''); setStreamingContent('');
} else if (event.type === 'error') { } else if (event.type === 'error') {
-132
View File
@@ -1,132 +0,0 @@
import React, { useState, useEffect } from 'react';
import { SessionList } from '@/components/ai/SessionList';
import { ChatWindow } from '@/components/ai/ChatWindow';
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
import { fetchAgents, createSession, type AIAgent } from '@/api/ai';
import { ChevronLeft, ExternalLink, Folder, Plus } from 'lucide-react';
export function AIAssistantPage() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [agents, setAgents] = useState<AIAgent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [mobileView, setMobileView] = useState<'list' | 'chat'>('list');
const [refreshKey, setRefreshKey] = useState(0);
const { registerItems, unregisterPlugin } = usePluginToolbarStore();
useEffect(() => { fetchAgents().then(setAgents).catch(() => {}); }, []);
useEffect(() => {
registerItems('ai_assistant', [
{
id: 'agent-select',
plugin: 'ai_assistant',
label: 'Agent',
type: 'select',
group: 'agent',
selectOptions: agents.map((a) => ({ value: a.id, label: a.name })),
selectValue: selectedAgentId || '',
onSelect: (val: string) => setSelectedAgentId(val || null),
onClick: () => {},
},
{
id: 'new-chat',
plugin: 'ai_assistant',
label: 'Neuer Chat',
type: 'button',
group: 'actions',
icon: (
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
),
onClick: async () => {
try {
const session = await createSession({ is_sidebar: false });
setActiveSessionId(session.id);
setMobileView('chat');
setRefreshKey(k => k + 1);
} catch (e) {
console.error('Failed to create session:', e);
}
},
},
{
id: 'new-folder',
plugin: 'ai_assistant',
label: 'Ordner',
type: 'button',
group: 'actions',
icon: (
<Folder className="w-3.5 h-3.5" strokeWidth={2} />
),
onClick: () => {
const name = prompt('Ordnername:');
if (name) {
import('@/api/ai').then(({ createFolder }) => {
createFolder({ name }).then(() => setRefreshKey(k => k + 1)).catch(() => {});
});
}
},
},
{
id: 'open-standalone',
plugin: 'ai_assistant',
label: 'In neuem Fenster',
type: 'button',
group: 'actions',
icon: (
<ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />
),
onClick: () => {
window.open('/ai-assistant-standalone', '_blank', 'width=800,height=600');
},
},
]);
return () => unregisterPlugin('ai_assistant');
}, [agents, selectedAgentId, registerItems, unregisterPlugin]);
const handleSelectSession = (id: string) => { setActiveSessionId(id); setMobileView('chat'); };
return (
<div className="flex h-full" data-testid="ai-assistant-page">
{/* Desktop: two-pane layout */}
<div className="hidden md:flex">
<ResizablePanel
initialWidth={288}
minWidth={200}
maxWidth={500}
className="border-r border-secondary-200 bg-white"
data-testid="ai-session-pane"
>
<SessionList key={refreshKey} activeSessionId={activeSessionId} onSelectSession={setActiveSessionId} className="h-full" />
</ResizablePanel>
</div>
<div className="hidden md:flex flex-1 flex-col">
{activeSessionId ? <ChatWindow sessionId={activeSessionId} agentId={selectedAgentId} className="flex-1" /> : (
<div className="flex-1 flex items-center justify-center text-secondary-400"><div className="text-center"><div className="text-4xl mb-3">🤖</div><div className="text-sm">Wähle einen Chat oder erstelle einen neuen</div></div></div>
)}
</div>
{/* Mobile: single-pane view switching */}
<div className="flex md:hidden flex-1 flex-col overflow-hidden">
{mobileView === 'list' && (
<div className="flex-1 flex flex-col bg-white">
<SessionList key={refreshKey} activeSessionId={activeSessionId} onSelectSession={handleSelectSession} className="flex-1" />
</div>
)}
{mobileView === 'chat' && activeSessionId && (
<div className="flex-1 flex flex-col">
<div className="h-14 flex items-center gap-2 px-3 border-b border-secondary-200 bg-white">
<button onClick={() => setMobileView('list')} className="p-2 rounded-lg hover:bg-secondary-100" aria-label="Zurück">
<ChevronLeft className="w-5 h-5" strokeWidth={2} />
</button>
<span className="text-sm font-medium text-secondary-700 truncate">Chat</span>
</div>
<ChatWindow sessionId={activeSessionId} agentId={selectedAgentId} className="flex-1" />
</div>
)}
{mobileView === 'chat' && !activeSessionId && (
<div className="flex-1 flex items-center justify-center text-secondary-400"><div className="text-center"><div className="text-4xl mb-3">🤖</div><div className="text-sm">Wähle einen Chat</div></div></div>
)}
</div>
</div>
);
}
@@ -1,10 +0,0 @@
import React from 'react';
import { AIAssistantPage } from './AIAssistant';
export function AIAssistantStandalonePage() {
return (
<div className="h-screen w-screen overflow-hidden bg-white">
<AIAssistantPage />
</div>
);
}
+56 -30
View File
@@ -11,7 +11,7 @@ import {
import { ResizablePanel } from '@/components/ui/ResizablePanel'; import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { streamChat, fetchAgents, type AIAgent } from '@/api/ai'; import { streamChat, fetchAgents, fetchMessages as fetchAiMessages, type AIAgent } from '@/api/ai';
// ─── Types ─── // ─── Types ───
@@ -322,7 +322,6 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
const [miniApps, setMiniApps] = useState<MiniApp[]>([]); const [miniApps, setMiniApps] = useState<MiniApp[]>([]);
const [agents, setAgents] = useState<AIAgent[]>([]); const [agents, setAgents] = useState<AIAgent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null); const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [aiSessionId, setAiSessionId] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@@ -331,11 +330,38 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
if (!convId) return; if (!convId) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
fetchMessages(convId) if (category === 'ai') {
.then(msgs => { setMessages(msgs); setLoading(false); }) // AI conversations load messages from the AI endpoint
.catch(e => { setError(e?.message || 'Fehler beim Laden'); setLoading(false); }); fetchAiMessages(convId)
.then(msgs => {
// Convert {role, content}[] to Message[] format
const mapped: Message[] = msgs.map((m, i) => ({
id: `ai-msg-${i}`,
conversation_id: convId,
sender_id: null,
sender_type: m.role === 'user' ? 'user' : 'ai',
content: m.content,
content_format: 'text',
created_at: null,
edited_at: null,
is_pinned: false,
blocks: [],
attachments: [],
reactions: [],
reply_to_id: null,
}));
setMessages(mapped);
setLoading(false);
})
.catch(e => { setError(e?.message || 'Fehler beim Laden'); setLoading(false); });
} else {
// Regular conversations load from comm endpoint
fetchMessages(convId)
.then(msgs => { setMessages(msgs as unknown as Message[]); setLoading(false); })
.catch(e => { setError(e?.message || 'Fehler beim Laden'); setLoading(false); });
}
markConversationRead(convId).catch(() => {}); markConversationRead(convId).catch(() => {});
}, [convId]); }, [convId, category]);
// Load agents and mini-apps // Load agents and mini-apps
useEffect(() => { useEffect(() => {
@@ -386,12 +412,12 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
setError(null); setError(null);
try { try {
if (category === 'ai' && aiSessionId) { if (category === 'ai') {
// AI streaming chat // AI streaming chat via comm conversation
setAiStreaming(true); setAiStreaming(true);
setStreamingContent(''); setStreamingContent('');
let accumulated = ''; let accumulated = '';
for await (const event of streamChat(aiSessionId, content, selectedAgentId || undefined)) { for await (const event of streamChat(convId, content, selectedAgentId || undefined)) {
if (event.type === 'token' && event.content) { if (event.type === 'token' && event.content) {
accumulated += event.content; accumulated += event.content;
setStreamingContent(accumulated); setStreamingContent(accumulated);
@@ -405,8 +431,23 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
setAiStreaming(false); setAiStreaming(false);
setStreamingContent(''); setStreamingContent('');
// Reload messages to get the AI response // Reload messages to get the AI response
const msgs = await fetchMessages(convId); const msgs = await fetchAiMessages(convId);
setMessages(msgs); const mapped: Message[] = msgs.map((m, i) => ({
id: `ai-msg-${i}`,
conversation_id: convId,
sender_id: null,
sender_type: m.role === 'user' ? 'user' : 'ai',
content: m.content,
content_format: 'text',
created_at: null,
edited_at: null,
is_pinned: false,
blocks: [],
attachments: [],
reactions: [],
reply_to_id: null,
}));
setMessages(mapped);
} else { } else {
// Regular message // Regular message
const msg = await sendMessage(convId, content); const msg = await sendMessage(convId, content);
@@ -426,25 +467,10 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
} }
}; };
const startAiSession = async () => { // Auto-scroll
try {
const r = await apiClient.post('/ai/sessions', {
title: 'KI Chat',
agent_id: selectedAgentId,
is_sidebar: false,
});
setAiSessionId(r.data.id);
} catch (e: unknown) { const errObj = asError(e);
setError('KI Session konnte nicht gestartet werden');
}
};
// Auto-start AI session for AI conversations
useEffect(() => { useEffect(() => {
if (category === 'ai' && !aiSessionId && selectedAgentId) { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
startAiSession(); }, [messages, streamingContent]);
}
}, [category, aiSessionId, selectedAgentId]);
return ( return (
<div className="flex flex-col h-full bg-white"> <div className="flex flex-col h-full bg-white">
@@ -462,7 +488,7 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
{category === 'ai' && agents.length > 0 && ( {category === 'ai' && agents.length > 0 && (
<select <select
value={selectedAgentId || ''} value={selectedAgentId || ''}
onChange={(e) => { setSelectedAgentId(e.target.value || null); setAiSessionId(null); }} onChange={(e) => { setSelectedAgentId(e.target.value || null); }}
className="text-xs border border-secondary-200 rounded px-2 py-1 bg-white" className="text-xs border border-secondary-200 rounded px-2 py-1 bg-white"
> >
{agents.map(a => ( {agents.map(a => (
-6
View File
@@ -38,12 +38,10 @@ const TrashPage = React.lazy(() => import('@/pages/Trash').then(m => ({ default:
const MailPage = React.lazy(() => import('@/pages/Mail').then(m => ({ default: m.MailPage }))); const MailPage = React.lazy(() => import('@/pages/Mail').then(m => ({ default: m.MailPage })));
const MailSettingsPage = React.lazy(() => import('@/pages/MailSettings').then(m => ({ default: m.MailSettingsPage }))); const MailSettingsPage = React.lazy(() => import('@/pages/MailSettings').then(m => ({ default: m.MailSettingsPage })));
const SettingsNotificationsPage = React.lazy(() => import('@/pages/SettingsNotifications').then(m => ({ default: m.SettingsNotificationsPage }))); const SettingsNotificationsPage = React.lazy(() => import('@/pages/SettingsNotifications').then(m => ({ default: m.SettingsNotificationsPage })));
const AIAssistantPage = React.lazy(() => import('@/pages/AIAssistant').then(m => ({ default: m.AIAssistantPage })));
const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage }))); const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage })));
const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings }))); const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings })));
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage }))); const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m => ({ default: m.SettingsMcpPage }))); const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m => ({ default: m.SettingsMcpPage })));
const AIAssistantStandalonePage = React.lazy(() => import('@/pages/AIAssistantStandalone').then(m => ({ default: m.AIAssistantStandalonePage })));
const DmsStandalonePage = React.lazy(() => import('@/pages/DmsStandalone').then(m => ({ default: m.DmsStandalonePage }))); const DmsStandalonePage = React.lazy(() => import('@/pages/DmsStandalone').then(m => ({ default: m.DmsStandalonePage })));
const CalendarStandalonePage = React.lazy(() => import('@/pages/CalendarStandalone').then(m => ({ default: m.CalendarStandalonePage }))); const CalendarStandalonePage = React.lazy(() => import('@/pages/CalendarStandalone').then(m => ({ default: m.CalendarStandalonePage })));
const MailStandalonePage = React.lazy(() => import('@/pages/MailStandalone').then(m => ({ default: m.MailStandalonePage }))); const MailStandalonePage = React.lazy(() => import('@/pages/MailStandalone').then(m => ({ default: m.MailStandalonePage })));
@@ -111,8 +109,6 @@ const router = createBrowserRouter([
element: <LoginPage />, element: <LoginPage />,
}, },
{ {
path: '/ai-assistant-standalone',
element: <ErrorBoundary>{withSuspense(<AIAssistantStandalonePage />)}</ErrorBoundary>,
}, },
{ {
path: '/dms-standalone', path: '/dms-standalone',
@@ -178,7 +174,6 @@ const router = createBrowserRouter([
{ path: 'overview', element: withSuspense(<AgentsOverviewPage />) }, { path: 'overview', element: withSuspense(<AgentsOverviewPage />) },
{ path: 'automation', element: withSuspense(<AutomationDashboardPage />) }, { path: 'automation', element: withSuspense(<AutomationDashboardPage />) },
{ path: 'automation-settings', element: withSuspense(<AutomationSettingsPage />) }, { path: 'automation-settings', element: withSuspense(<AutomationSettingsPage />) },
{ path: 'ai-assistant', element: withSuspense(<AIAssistantPage />) },
{ path: 'ai-settings', element: withSuspense(<AISettingsPage />) }, { path: 'ai-settings', element: withSuspense(<AISettingsPage />) },
{ path: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) }, { path: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) },
{ path: '*', element: withSuspense(<AgentsPlaceholderPage />) }, { path: '*', element: withSuspense(<AgentsPlaceholderPage />) },
@@ -258,7 +253,6 @@ const router = createBrowserRouter([
{ path: '/trash', element: <PermissionRoute permission="contacts:read">{withSuspense(<TrashPage />)}</PermissionRoute> }, { path: '/trash', element: <PermissionRoute permission="contacts:read">{withSuspense(<TrashPage />)}</PermissionRoute> },
{ path: '/mail', element: <PermissionRoute permission="mail:read">{withSuspense(<MailPage />)}</PermissionRoute> }, { path: '/mail', element: <PermissionRoute permission="mail:read">{withSuspense(<MailPage />)}</PermissionRoute> },
{ path: '/mail/settings', element: <PermissionRoute permission="mail:read">{withSuspense(<MailSettingsPage />)}</PermissionRoute> }, { path: '/mail/settings', element: <PermissionRoute permission="mail:read">{withSuspense(<MailSettingsPage />)}</PermissionRoute> },
{ path: '/ai-assistant', element: <PermissionRoute permission="ai:read">{withSuspense(<AIAssistantPage />)}</PermissionRoute> },
{ path: '/reports', element: <PermissionRoute permission="reports:read">{withSuspense(<ReportsPage />)}</PermissionRoute> }, { path: '/reports', element: <PermissionRoute permission="reports:read">{withSuspense(<ReportsPage />)}</PermissionRoute> },
{ path: '/tasks', element: <PermissionRoute permission="tasks:read">{withSuspense(<TasksPage />)}</PermissionRoute> }, { path: '/tasks', element: <PermissionRoute permission="tasks:read">{withSuspense(<TasksPage />)}</PermissionRoute> },
{ path: '/communication', element: <PermissionRoute permission="communication:read">{withSuspense(<CommunicationPage />)}</PermissionRoute> }, { path: '/communication', element: <PermissionRoute permission="communication:read">{withSuspense(<CommunicationPage />)}</PermissionRoute> },