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
-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.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)
-3
View File
@@ -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",
@@ -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"
@@ -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)
+2 -2
View File
@@ -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."""
+63 -289
View File
@@ -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,
)
+137 -266
View File
@@ -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:
@@ -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,
},
}
-1
View File
@@ -2,7 +2,6 @@
from app.routes import (
addresses, # noqa: F401
ai_copilot, # noqa: F401
attachments, # noqa: F401
audit, # 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": "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)}
+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.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
-1
View File
@@ -24,7 +24,6 @@ ENTITY_TABLES: dict[str, str] = {
"saved_views": "saved_views",
"webhooks": "webhooks",
"notifications": "notifications",
"ai_conversations": "ai_conversations",
}