From 7f61dfb25becf9d749057178b4527f3ab68a1a63 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 21 Aug 2026 13:34:54 +0200 Subject: [PATCH] feat(UI-Overhaul-Phase2): AI Assistent in Kommunikation integriert 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 --- alembic/versions/0137_drop_ai_chat_tables.py | 34 ++ app/main.py | 2 - app/models/__init__.py | 3 - .../builtins/ai_assistant/external_api.py | 46 +- app/plugins/builtins/ai_assistant/models.py | 89 ---- app/plugins/builtins/ai_assistant/plugin.py | 4 +- app/plugins/builtins/ai_assistant/routes.py | 352 +++------------ app/plugins/builtins/ai_assistant/services.py | 403 ++++++------------ .../providers/ai_chat_provider.py | 34 +- app/routes/__init__.py | 1 - app/routes/entity_permissions.py | 1 - app/services/ai_copilot_service.py | 6 +- app/services/owner_transfer_service.py | 1 - frontend/src/api/ai.ts | 8 +- frontend/src/components/ai/ChatWindow.tsx | 196 --------- frontend/src/components/ai/SessionList.tsx | 385 ----------------- frontend/src/components/layout/AISidebar.tsx | 4 +- .../src/components/window/AiChatPanel.tsx | 30 +- frontend/src/pages/AIAssistant.tsx | 132 ------ frontend/src/pages/AIAssistantStandalone.tsx | 10 - frontend/src/pages/Communication.tsx | 86 ++-- frontend/src/routes/index.tsx | 6 - 22 files changed, 361 insertions(+), 1472 deletions(-) create mode 100644 alembic/versions/0137_drop_ai_chat_tables.py delete mode 100644 frontend/src/components/ai/ChatWindow.tsx delete mode 100644 frontend/src/components/ai/SessionList.tsx delete mode 100644 frontend/src/pages/AIAssistant.tsx delete mode 100644 frontend/src/pages/AIAssistantStandalone.tsx diff --git a/alembic/versions/0137_drop_ai_chat_tables.py b/alembic/versions/0137_drop_ai_chat_tables.py new file mode 100644 index 0000000..0812d26 --- /dev/null +++ b/alembic/versions/0137_drop_ai_chat_tables.py @@ -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 diff --git a/app/main.py b/app/main.py index 8bc8ea0..41bf3da 100644 --- a/app/main.py +++ b/app/main.py @@ -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.routes import ( # noqa: E402 addresses, - ai_copilot, api_tokens, approvals, attachments, @@ -555,7 +554,6 @@ def create_app() -> FastAPI: app.include_router(entity_history.router) app.include_router(import_export.router) app.include_router(plugins.router) - app.include_router(ai_copilot.router) app.include_router(workflows.router) app.include_router(user_preferences.router) app.include_router(currencies.router) diff --git a/app/models/__init__.py b/app/models/__init__.py index 4c9f5cb..0295ea7 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,7 +1,6 @@ """SQLAlchemy models for LeoCRM.""" from app.models.address import Address -from app.models.ai_conversation import AIConversation, AIMessage from app.models.attachment import Attachment from app.models.audit import AuditLog from app.models.auth import ApiToken, PasswordResetToken @@ -69,8 +68,6 @@ __all__ = [ "BankAccount", "Plugin", "PluginMigration", - "AIConversation", - "AIMessage", "Backup", "CustomFieldDefinition", "Webhook", diff --git a/app/plugins/builtins/ai_assistant/external_api.py b/app/plugins/builtins/ai_assistant/external_api.py index ad20c8d..0808bf0 100644 --- a/app/plugins/builtins/ai_assistant/external_api.py +++ b/app/plugins/builtins/ai_assistant/external_api.py @@ -80,26 +80,29 @@ async def run_agent_external( # 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( - user_id=uuid.UUID(current_user["user_id"]), - agent_id=agent.id, - title=f"External: {data.message[:50]}" if data.message else "External Agent Run", - is_sidebar=False, + # Create a comm conversation for this external interaction + from app.plugins.builtins.kommunikation.models import CommConversation + session = CommConversation( 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"]), + created_by=uuid.UUID(current_user["user_id"]), + created_by_type="user", + metadata_={"conversation_type": "ai", "agent_id": str(agent.id)}, ) db.add(session) await db.flush() # Store the user message - user_msg = AIChatMessage( - session_id=session.id, - role="user", + from app.plugins.builtins.kommunikation.models import CommMessage + user_msg = CommMessage( + conversation_id=session.id, + sender_id=uuid.UUID(current_user["user_id"]), + sender_type="user", content=data.message, - tokens=0, - model_used=agent.name or "external", + content_format="text", tenant_id=tenant_id, ) db.add(user_msg) @@ -117,7 +120,7 @@ async def run_agent_external( } # 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 = "" 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") # Create session - from app.plugins.builtins.ai_assistant.models import AIChatSession + # AIChatSession removed — using comm tables - session = AIChatSession( - user_id=uuid.UUID(current_user["user_id"]), - agent_id=agent.id, - title=f"External Stream: {data.message[:50]}" if data.message else "External Agent Stream", - is_sidebar=False, + from app.plugins.builtins.kommunikation.models import CommConversation + session = CommConversation( 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"]), + created_by=uuid.UUID(current_user["user_id"]), + created_by_type="user", + metadata_={"conversation_type": "ai", "agent_id": str(agent.id)}, ) db.add(session) await db.commit() @@ -274,15 +278,15 @@ async def stream_agent_external( "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(): 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.message, user_context, tenant_id + async for chunk in stream_chat_comm( + stream_db, session.id, agent, data.message, user_context, tenant_id, uuid.UUID(current_user["user_id"]) ): yield chunk yield "data: [DONE]\n\n" diff --git a/app/plugins/builtins/ai_assistant/models.py b/app/plugins/builtins/ai_assistant/models.py index 650b6ca..360bed1 100644 --- a/app/plugins/builtins/ai_assistant/models.py +++ b/app/plugins/builtins/ai_assistant/models.py @@ -134,64 +134,6 @@ class AIAgent(Base, TenantMixin, OwnedMixin): 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 --- 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) 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) diff --git a/app/plugins/builtins/ai_assistant/plugin.py b/app/plugins/builtins/ai_assistant/plugin.py index e2654f1..dfeebce 100644 --- a/app/plugins/builtins/ai_assistant/plugin.py +++ b/app/plugins/builtins/ai_assistant/plugin.py @@ -78,8 +78,8 @@ class AIAssistantPlugin(BasePlugin): await seed_defaults(db) def get_entity_models(self) -> dict[str, type]: - from app.plugins.builtins.ai_assistant.models import AIAgent, AIChatSession - return {"ai_agent": AIAgent, "ai_chat_session": AIChatSession} + from app.plugins.builtins.ai_assistant.models import AIAgent + return {"ai_agent": AIAgent} async def on_activate(self, db, service_container, event_bus) -> None: """Activate plugin: register CRM API tool and participant handler.""" diff --git a/app/plugins/builtins/ai_assistant/routes.py b/app/plugins/builtins/ai_assistant/routes.py index 1ce34d0..fc14a75 100644 --- a/app/plugins/builtins/ai_assistant/routes.py +++ b/app/plugins/builtins/ai_assistant/routes.py @@ -6,20 +6,16 @@ from __future__ import annotations import uuid -import aiofiles -from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile -from fastapi.responses import FileResponse, StreamingResponse +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession 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.plugins.builtins.ai_assistant.models import ( AIAgent, - AIChatAttachment, AIChatFolder, - AIChatSession, AIModel, AIPreset, AIProvider, @@ -36,25 +32,19 @@ from app.plugins.builtins.ai_assistant.schemas import ( ChatFolderCreate, ChatFolderUpdate, ChatSendRequest, - ChatSessionCreate, - ChatSessionUpdate, ) from app.plugins.builtins.ai_assistant.services import ( agent_to_response, - attachment_to_response, folder_to_response, get_agent_by_id, + get_comm_messages, get_default_agent, get_preset_by_id, get_provider_by_id, - get_session_by_id, - get_session_messages, - message_to_response, model_to_response, preset_to_response, provider_to_response, - session_to_response, - stream_chat, + stream_chat_comm, ) 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)} -# ─── 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 ─── @router.get("/folders", dependencies=[Depends(require_permission("ai:read"))]) @@ -713,93 +502,78 @@ async def delete_folder( return {"ok": True} -# ─── Attachments ─── +# ─── AI Chat Streaming (via comm_conversations) ─── -import os # noqa: E402 -from pathlib import Path # noqa: E402 - -ATTACHMENT_DIR = Path(os.environ.get("STORAGE_PATH", "/data/storage")) / "ai_attachments" -MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024 # 25MB - - -@router.post("/sessions/{session_id}/attachments", response_model=None, dependencies=[Depends(require_permission("ai:write"))]) -async def upload_attachment( - session_id: str, - file: UploadFile, +@router.post("/conversations/{conversation_id}/stream", dependencies=[Depends(require_permission("ai:write"))]) +async def chat_stream_comm( + conversation_id: str, + data: ChatSendRequest, + request: Request, current_user: dict = Depends(get_current_user), 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"]) 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() - if len(content) > MAX_ATTACHMENT_SIZE: - raise HTTPException(status_code=413, detail="File too large (max 25MB)") - - ATTACHMENT_DIR.mkdir(parents=True, exist_ok=True) - file_id = str(uuid.uuid4()) - safe_filename = file.filename or "unnamed" - storage_path = str(ATTACHMENT_DIR / f"{file_id}_{safe_filename}") - async with aiofiles.open(storage_path, "wb") as f: - await f.write(content) - - attachment = AIChatAttachment( - session_id=session.id, - filename=safe_filename, - mime_type=file.content_type or "application/octet-stream", - size_bytes=len(content), - storage_path=storage_path, - tenant_id=tenant_id, + 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) + + agent = None + if data.agent_id: + agent = await get_agent_by_id(db, uuid.UUID(data.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") + + 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(): + 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"))]) -async def list_attachments( - session_id: str, +@router.get("/conversations/{conversation_id}/messages", dependencies=[Depends(require_permission("ai:read"))]) +async def list_comm_messages( + conversation_id: str, current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + """List messages for an AI comm conversation.""" 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") - - 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] + await set_tenant_context(db, tenant_id) + messages = await get_comm_messages(db, uuid.UUID(conversation_id), tenant_id) + return [{"role": m["role"], "content": m["content"]} for m in messages] -@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, - ) diff --git a/app/plugins/builtins/ai_assistant/services.py b/app/plugins/builtins/ai_assistant/services.py index 6fefcea..5f97abf 100644 --- a/app/plugins/builtins/ai_assistant/services.py +++ b/app/plugins/builtins/ai_assistant/services.py @@ -13,7 +13,6 @@ import uuid from collections.abc import AsyncGenerator from typing import Any -import aiofiles import litellm from sqlalchemy import select 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.plugins.builtins.ai_assistant.models import ( AIAgent, - AIChatAttachment, AIChatFolder, - AIChatMessage, - AIChatSession, AIModel, AIPreset, 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]: return { "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 ─── 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() -# ─── Session/Message helpers ─── +# ─── Comm-based Chat Helpers (replaces AIChatSession/AIChatMessage) ─── -async def get_session_by_id( - db: AsyncSession, session_id: uuid.UUID, user_id: uuid.UUID, tenant_id: uuid.UUID -) -> AIChatSession | None: +async def get_comm_messages( + db: AsyncSession, conversation_id: uuid.UUID, tenant_id: uuid.UUID +) -> 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( - select(AIChatSession) - .where(AIChatSession.id == session_id) - .where(AIChatSession.user_id == user_id) - .where(AIChatSession.tenant_id == tenant_id) - .limit(1) + select(CommMessage) + .where(CommMessage.conversation_id == conversation_id) + .where(CommMessage.tenant_id == tenant_id) + .order_by(CommMessage.created_at.asc()) ) - 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( - 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( +async def save_comm_message( db: AsyncSession, - session_id: uuid.UUID, + conversation_id: uuid.UUID, role: str, content: str, tenant_id: uuid.UUID, - tool_calls: list | None = None, - tool_results: list | None = None, - tokens: int = 0, - model_used: str = "", -) -> AIChatMessage: - msg = AIChatMessage( - session_id=session_id, - role=role, + user_id: uuid.UUID, +) -> None: + """Save a message to comm_messages for an AI conversation.""" + from app.plugins.builtins.kommunikation.models import CommMessage + sender_type = "user" if role == "user" else "ai" + msg = CommMessage( + conversation_id=conversation_id, + sender_id=user_id if role == "user" else None, + sender_type=sender_type, content=content, - tool_calls=tool_calls, - tool_results=tool_results, - tokens=tokens, - model_used=model_used, + content_format="text", tenant_id=tenant_id, ) db.add(msg) 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( db: AsyncSession, @@ -364,190 +419,6 @@ async def execute_tool_call( 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 ─── async def seed_defaults(db: AsyncSession) -> None: diff --git a/app/plugins/builtins/unified_search/providers/ai_chat_provider.py b/app/plugins/builtins/unified_search/providers/ai_chat_provider.py index 9544231..befb648 100644 --- a/app/plugins/builtins/unified_search/providers/ai_chat_provider.py +++ b/app/plugins/builtins/unified_search/providers/ai_chat_provider.py @@ -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 @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) class AIChatSearchProvider(BaseSearchProvider): - """Search provider for AI chat messages.""" + """Search provider for AI chat messages in comm_messages.""" entity_type = "ai_chat" supports_fts = True @@ -29,18 +29,18 @@ class AIChatSearchProvider(BaseSearchProvider): limit: int, visible_ids: set[uuid.UUID] | None, ) -> 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: sql = text( """ - SELECT m.id, m.tenant_id, m.session_id, m.role, m.content, - m.model_used, m.tokens, - s.title AS session_title, + SELECT m.id, m.tenant_id, m.conversation_id, m.sender_type AS role, m.content, + c.title AS session_title, ts_rank(to_tsvector('pg_catalog.german', m.content), to_tsquery('pg_catalog.german', :q)) AS rank - FROM ai_chat_messages m - JOIN ai_chat_sessions s ON s.id = m.session_id + FROM comm_messages m + JOIN comm_conversations c ON c.id = m.conversation_id 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 m.id = ANY(:visible_ids) ORDER BY rank DESC @@ -59,14 +59,14 @@ class AIChatSearchProvider(BaseSearchProvider): else: sql = text( """ - SELECT m.id, m.tenant_id, m.session_id, m.role, m.content, - m.model_used, m.tokens, - s.title AS session_title, + SELECT m.id, m.tenant_id, m.conversation_id, m.sender_type AS role, m.content, + c.title AS session_title, ts_rank(to_tsvector('pg_catalog.german', m.content), to_tsquery('pg_catalog.german', :q)) AS rank - FROM ai_chat_messages m - JOIN ai_chat_sessions s ON s.id = m.session_id + FROM comm_messages m + JOIN comm_conversations c ON c.id = m.conversation_id 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) ORDER BY rank DESC LIMIT :lim @@ -97,7 +97,7 @@ class AIChatSearchProvider(BaseSearchProvider): sql = text( """ SELECT content - FROM ai_chat_messages + FROM comm_messages WHERE id = :eid AND tenant_id = :tid """ ) @@ -114,14 +114,14 @@ class AIChatSearchProvider(BaseSearchProvider): content = entity.get("content", "") role = entity.get("role", "") 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) else: entity_id = str(getattr(entity, "id", "")) content = getattr(entity, "content", "") role = getattr(entity, "role", "") 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) return { "entity_type": self.entity_type, @@ -131,7 +131,7 @@ class AIChatSearchProvider(BaseSearchProvider): "score": float(score) if score else 0.0, "data": { "role": role, - "session_id": session_id, + "conversation_id": conversation_id, "session_title": session_title, }, } diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 7166944..36f6c34 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -2,7 +2,6 @@ from app.routes import ( addresses, # noqa: F401 - ai_copilot, # noqa: F401 attachments, # noqa: F401 audit, # noqa: F401 compliance, # noqa: F401 diff --git a/app/routes/entity_permissions.py b/app/routes/entity_permissions.py index 9ddcfe9..0b9aa9c 100644 --- a/app/routes/entity_permissions.py +++ b/app/routes/entity_permissions.py @@ -250,7 +250,6 @@ async def list_entity_registry( {"entity_type": "webhook", "label": "Webhooks", "table": "webhooks"}, {"entity_type": "notification", "label": "Benachrichtigungen", "table": "notifications"}, {"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)} diff --git a/app/services/ai_copilot_service.py b/app/services/ai_copilot_service.py index 79e7173..d2a5365 100644 --- a/app/services/ai_copilot_service.py +++ b/app/services/ai_copilot_service.py @@ -12,7 +12,11 @@ from app.ai.llm_client import get_llm_client from app.core.audit import log_audit from app.core.permissions import check_permission 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.workflow import Workflow diff --git a/app/services/owner_transfer_service.py b/app/services/owner_transfer_service.py index 2b246ab..4f9c06c 100644 --- a/app/services/owner_transfer_service.py +++ b/app/services/owner_transfer_service.py @@ -24,7 +24,6 @@ ENTITY_TABLES: dict[str, str] = { "saved_views": "saved_views", "webhooks": "webhooks", "notifications": "notifications", - "ai_conversations": "ai_conversations", } diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts index 19b14c6..b62571a 100644 --- a/frontend/src/api/ai.ts +++ b/frontend/src/api/ai.ts @@ -166,8 +166,8 @@ export const deleteSession = (id: string) => apiDelete(`/ai/sessions/${id}`); // ─── Messages ─── -export const fetchMessages = (sessionId: string) => - apiGet(`/ai/sessions/${sessionId}/messages`); +export const fetchMessages = (conversationId: string) => + apiGet<{ role: string; content: string }[]>(`/ai/conversations/${conversationId}/messages`); // ─── Attachments ─── @@ -205,12 +205,12 @@ export interface StreamEvent { } export async function* streamChat( - sessionId: string, + conversationId: string, content: string, agentId?: string ): AsyncGenerator { 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', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/components/ai/ChatWindow.tsx b/frontend/src/components/ai/ChatWindow.tsx deleted file mode 100644 index 07fe848..0000000 --- a/frontend/src/components/ai/ChatWindow.tsx +++ /dev/null @@ -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
{content}
; - } - return ( -
- {content} -
- ); -} - -function ActivityIndicator({ status }: { status: string | null }) { - if (!status) return null; - return ( -
-
- - - -
- {status} -
- ); -} - -export function ChatWindow({ sessionId, agentId, className, compact }: ChatWindowProps) { - const [messages, setMessages] = useState([]); - const [attachments, setAttachments] = useState([]); - const [input, setInput] = useState(''); - const [isStreaming, setIsStreaming] = useState(false); - const [streamingContent, setStreamingContent] = useState(''); - const [activity, setActivity] = useState(null); - const [error, setError] = useState(null); - const [uploadingFile, setUploadingFile] = useState(false); - const scrollRef = useRef(null); - const inputRef = useRef(null); - const fileInputRef = useRef(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) => { - 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 ( -
- {attachments.length > 0 && ( -
-
- {attachments.map((att) => ( - - - {att.filename} - {formatSize(att.size_bytes)} - - ))} -
-
- )} -
- {messages.length === 0 && !streamingContent && !error && ( -
Starte eine Konversation...
- )} - {messages.map((msg) => ( -
-
- {msg.role === 'user' ? 'Du' : 'KI'} -
-
- - {msg.tool_calls && msg.tool_calls.length > 0 && ( -
- ⚙️ Tools: {msg.tool_calls.map((tc: any) => tc.function?.name).join(', ')} -
- )} -
-
- ))} - {streamingContent && ( -
-
KI
-
- -
-
- )} - - {error &&
{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}
} -
-
-
- - -