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

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

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

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

tsc clean, build successful, backend import OK
This commit is contained in:
Agent Zero
2026-08-21 13:34:54 +02:00
parent 94c7c8fff5
commit 7f61dfb25b
22 changed files with 361 additions and 1472 deletions
@@ -0,0 +1,34 @@
"""Drop AI chat tables (migrated to comm conversations)
Revision ID: 0137
Revises: 0136
Create Date: 2026-08-21
AI chat functionality is now handled by the kommunikation plugin's
comm_conversations and comm_messages tables. The old AI-specific tables
(ai_chat_sessions, ai_chat_messages, ai_chat_attachments, ai_conversations,
ai_messages) are no longer needed and are dropped.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0137"
down_revision = "0136"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Use IF EXISTS to avoid errors if tables are already gone
op.execute("DROP TABLE IF EXISTS ai_chat_attachments CASCADE")
op.execute("DROP TABLE IF EXISTS ai_chat_messages CASCADE")
op.execute("DROP TABLE IF EXISTS ai_chat_sessions CASCADE")
op.execute("DROP TABLE IF EXISTS ai_messages CASCADE")
op.execute("DROP TABLE IF EXISTS ai_conversations CASCADE")
def downgrade() -> None:
# Tables cannot be restored — data was migrated or was empty.
pass
-2
View File
@@ -31,7 +31,6 @@ from app.core.service_container import get_container # noqa: E402
from app.plugins.registry import get_registry # noqa: E402
from app.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",
}
+4 -4
View File
@@ -166,8 +166,8 @@ export const deleteSession = (id: string) => apiDelete(`/ai/sessions/${id}`);
// ─── Messages ───
export const fetchMessages = (sessionId: string) =>
apiGet<ChatMessage[]>(`/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<StreamEvent> {
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',
-196
View File
@@ -1,196 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import clsx from 'clsx';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
streamChat, fetchMessages, fetchAttachments, uploadAttachment, getAttachmentDownloadUrl,
type ChatMessage, type ChatAttachment,
} from '@/api/ai';
interface ChatWindowProps {
sessionId: string;
agentId?: string | null;
className?: string;
compact?: boolean;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function MessageContent({ content, role }: { content: string; role: string }) {
if (role === 'user') {
return <div className="whitespace-pre-wrap break-words">{content}</div>;
}
return (
<div className="ai-markdown max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
function ActivityIndicator({ status }: { status: string | null }) {
if (!status) return null;
return (
<div className="flex items-center gap-2 px-4 py-2 text-xs text-secondary-500">
<div className="flex gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary-400 animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-primary-400 animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-primary-400 animate-bounce [animation-delay:300ms]" />
</div>
<span>{status}</span>
</div>
);
}
export function ChatWindow({ sessionId, agentId, className, compact }: ChatWindowProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [activity, setActivity] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploadingFile, setUploadingFile] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!sessionId) return;
setMessages([]);
setAttachments([]);
setError(null);
fetchMessages(sessionId).then(setMessages).catch((e) => setError(e?.message || 'Failed to load'));
fetchAttachments(sessionId).then(setAttachments).catch(() => {});
}, [sessionId]);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages, streamingContent, activity]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
setUploadingFile(true);
try {
for (const file of files) {
const att = await uploadAttachment(sessionId, file);
setAttachments((prev) => [...prev, att]);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed');
} finally {
setUploadingFile(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSend = async () => {
const content = input.trim();
if (!content || isStreaming) return;
setInput('');
setIsStreaming(true);
setStreamingContent('');
setActivity('Denke nach...');
setError(null);
const userMsg: ChatMessage = { id: 'temp-' + Date.now(), session_id: sessionId, role: 'user', content, tokens: 0, model_used: '' };
setMessages((prev) => [...prev, userMsg]);
try {
const stream = streamChat(sessionId, content, agentId || undefined);
let accumulated = '';
for await (const event of stream) {
if (event.type === 'token' && event.content) {
accumulated += event.content;
setStreamingContent(accumulated);
setActivity(null);
} else if (event.type === 'tool_calls' && event.tools) {
setActivity(`Rufe Tool auf: ${event.tools.join(', ')}`);
} else if (event.type === 'tool_result' && event.tool) {
setActivity(`Tool '${event.tool}' ausgeführt`);
} else if (event.type === 'done') {
setActivity(null);
const msgs = await fetchMessages(sessionId);
setMessages(msgs);
setStreamingContent('');
} else if (event.type === 'error') {
setError(event.content || 'Unknown error');
setActivity(null);
}
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Stream failed');
} finally {
setIsStreaming(false);
setStreamingContent('');
setActivity(null);
inputRef.current?.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
};
return (
<div className={clsx('flex flex-col h-full bg-white', className)}>
{attachments.length > 0 && (
<div className={clsx('border-b border-secondary-200 bg-secondary-50', compact ? 'px-2 py-1' : 'px-4 py-2')}>
<div className="flex flex-wrap gap-2">
{attachments.map((att) => (
<a key={att.id} href={getAttachmentDownloadUrl(att.id)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 rounded-md bg-white border border-secondary-200 px-2 py-1 text-xs hover:border-primary-400">
<svg className="w-3.5 h-3.5 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
<span className="max-w-32 truncate">{att.filename}</span>
<span className="text-secondary-400">{formatSize(att.size_bytes)}</span>
</a>
))}
</div>
</div>
)}
<div ref={scrollRef} className={clsx('flex-1 overflow-y-auto', compact ? 'p-3' : 'p-6')}>
{messages.length === 0 && !streamingContent && !error && (
<div className="flex items-center justify-center h-full text-secondary-400 text-sm">Starte eine Konversation...</div>
)}
{messages.map((msg) => (
<div key={msg.id} className="mb-6">
<div className={clsx('text-xs font-medium mb-1', msg.role === 'user' ? 'text-primary-600' : 'text-secondary-400')}>
{msg.role === 'user' ? 'Du' : 'KI'}
</div>
<div className={clsx('rounded-lg px-4 py-2 text-sm', msg.role === 'user' ? 'bg-primary-50 border border-primary-100' : '')}>
<MessageContent content={msg.content} role={msg.role} />
{msg.tool_calls && msg.tool_calls.length > 0 && (
<div className="mt-2 text-xs text-secondary-400 border-l-2 border-secondary-200 pl-2">
Tools: {msg.tool_calls.map((tc: any) => tc.function?.name).join(', ')}
</div>
)}
</div>
</div>
))}
{streamingContent && (
<div className="mb-6">
<div className="text-xs font-medium mb-1 text-secondary-400">KI</div>
<div className="text-sm text-secondary-900">
<MessageContent content={streamingContent} role="assistant" />
</div>
</div>
)}
<ActivityIndicator status={activity} />
{error && <div className="mb-4 text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</div>}
</div>
<div className={clsx('border-t border-secondary-200', compact ? 'p-2' : 'p-4')}>
<div className="flex items-end gap-2">
<input ref={fileInputRef} type="file" multiple onChange={handleFileSelect} className="hidden" />
<button onClick={() => fileInputRef.current?.click()} disabled={uploadingFile || isStreaming} className="rounded-lg border border-secondary-300 p-2 text-secondary-500 hover:bg-secondary-100 disabled:opacity-50" title="Datei anhängen">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
</button>
<textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} disabled={isStreaming} placeholder="Nachricht eingeben..." rows={compact ? 1 : 2} className={clsx('flex-1 resize-none rounded-lg border border-secondary-300 px-3 py-2 text-sm', 'focus:outline-none focus:ring-2 focus:ring-primary-500', 'disabled:opacity-50')} />
<button onClick={handleSend} disabled={isStreaming || !input.trim()} className={clsx('rounded-lg px-4 py-2 text-sm font-medium text-white', 'bg-primary-600 hover:bg-primary-700', 'disabled:opacity-50 disabled:cursor-not-allowed')}>{isStreaming ? '...' : 'Senden'}</button>
</div>
</div>
</div>
);
}
-385
View File
@@ -1,385 +0,0 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import clsx from 'clsx';
import { useQueryClient } from '@tanstack/react-query';
import {
fetchSessions, createSession, deleteSession, updateSession,
fetchFolders, createFolder, deleteFolder, updateFolder,
type ChatSession, type ChatFolder,
} from '@/api/ai';
interface SessionListProps {
activeSessionId: string | null;
onSelectSession: (id: string) => void;
isSidebar?: boolean;
className?: string;
}
interface TreeNode {
folder: ChatFolder | null;
sessions: ChatSession[];
children: TreeNode[];
}
interface ContextMenuState {
x: number;
y: number;
type: 'session' | 'folder' | 'root';
id: string | null;
}
function buildTree(folders: ChatFolder[], sessions: ChatSession[]): TreeNode[] {
const rootSessions = sessions.filter((s) => !s.folder_id);
const root: TreeNode = { folder: null, sessions: rootSessions, children: [] };
const folderMap = new Map<string, TreeNode>();
for (const f of folders) folderMap.set(f.id, { folder: f, sessions: [], children: [] });
for (const f of folders) {
const node = folderMap.get(f.id)!;
if (f.parent_id && folderMap.has(f.parent_id)) folderMap.get(f.parent_id)!.children.push(node);
else root.children.push(node);
}
for (const s of sessions) {
if (s.folder_id && folderMap.has(s.folder_id)) folderMap.get(s.folder_id)!.sessions.push(s);
}
return [root];
}
function TreeItem({
node, activeSessionId, onSelectSession, depth, onContextMenu, onDragSession, onDragFolder, onDropToFolder, onDelete, onRename,
}: {
node: TreeNode;
activeSessionId: string | null;
onSelectSession: (id: string) => void;
depth: number;
onContextMenu: (e: React.MouseEvent, type: 'session' | 'folder' | 'root', id: string | null) => void;
onDragSession: (sessionId: string) => void;
onDragFolder: (folderId: string) => void;
onDropToFolder: (folderId: string | null) => void;
onDelete: (id: string, type: 'session' | 'folder') => void;
onRename: (id: string, type: 'session' | 'folder') => void;
}) {
const [expanded, setExpanded] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
const isRoot = node.folder === null;
const folderId = node.folder?.id || null;
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const data = e.dataTransfer.getData('text/plain');
if (data.startsWith('folder:')) {
const draggedFolderId = data.slice(7);
if (draggedFolderId !== folderId) {
onDropToFolder(folderId);
}
} else {
onDropToFolder(folderId);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isRoot) setIsDragOver(true);
};
return (
<div>
{/* Folder header - drop target */}
{!isRoot && (
<div
onDragOver={handleDragOver}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onContextMenu={(e) => onContextMenu(e, 'folder', node.folder!.id)}
className={clsx(
'group flex items-center gap-1 px-2 py-1.5 text-sm font-medium rounded-md cursor-pointer',
isDragOver ? 'bg-primary-100 ring-2 ring-primary-400' : 'text-secondary-700 hover:bg-secondary-100'
)}
style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => setExpanded(!expanded)}
>
<svg className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-90')} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', `folder:${node.folder!.id}`);
e.dataTransfer.effectAllowed = 'move';
onDragFolder(node.folder!.id);
}}
className="cursor-grab active:cursor-grabbing flex items-center"
title="Ordner verschieben"
>
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</span>
<span className="flex-1 truncate">{node.folder!.name}</span>
<button onClick={(e) => { e.stopPropagation(); onRename(node.folder!.id, 'folder'); }} className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-primary-600 p-0.5" title="Umbenennen">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</button>
<button onClick={(e) => { e.stopPropagation(); onDelete(node.folder!.id, 'folder'); }} className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600 p-0.5" title="Löschen">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
)}
{/* Sessions */}
{(isRoot || expanded) && (
<div>
{node.sessions.map((session) => (
<div
key={session.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', session.id);
e.dataTransfer.effectAllowed = 'move';
onDragSession(session.id);
}}
onContextMenu={(e) => onContextMenu(e, 'session', session.id)}
onClick={() => onSelectSession(session.id)}
className={clsx(
'group flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer rounded-md',
'hover:bg-secondary-100',
activeSessionId === session.id && 'bg-primary-50'
)}
style={{ paddingLeft: depth * 12 + 24 }}
>
<svg className="w-3.5 h-3.5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h8M8 8h8m-8 4h8m-8 4h8" />
</svg>
<span className={clsx('flex-1 truncate', activeSessionId === session.id && 'font-bold text-primary-700')}>
{session.title}
</span>
<button onClick={(e) => { e.stopPropagation(); onRename(session.id, 'session'); }} className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-primary-600 p-0.5" title="Umbenennen">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</button>
<button onClick={(e) => { e.stopPropagation(); onDelete(session.id, 'session'); }} className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600 p-0.5" title="Löschen">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
))}
</div>
)}
{/* Root drop zone */}
{isRoot && (
<div
onDragOver={handleDragOver}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onContextMenu={(e) => onContextMenu(e, 'root', null)}
className={clsx(
'min-h-[12px] mt-1 rounded-md transition-colors',
isDragOver ? 'bg-primary-50 ring-1 ring-primary-300' : 'hover:bg-secondary-50'
)}
/>
)}
{/* Child folders */}
{(isRoot || expanded) && node.children.map((child) => (
<TreeItem
key={child.folder!.id}
node={child}
activeSessionId={activeSessionId}
onSelectSession={onSelectSession}
depth={depth + 1}
onContextMenu={onContextMenu}
onDragSession={onDragSession}
onDragFolder={onDragFolder}
onDropToFolder={onDropToFolder}
onDelete={onDelete}
onRename={onRename}
/>
))}
</div>
);
}
function ContextMenu({
state, onClose, onRename, onDelete, onNewFolder, onNewChat,
}: {
state: ContextMenuState;
onClose: () => void;
onRename: (id: string, type: 'session' | 'folder') => void;
onDelete: (id: string, type: 'session' | 'folder') => void;
onNewFolder: () => void;
onNewChat: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onClose]);
const items: { label: string; action: () => void; danger?: boolean }[] = [];
if (state.type === 'session') {
items.push({ label: 'Umbenennen', action: () => { onRename(state.id!, 'session'); onClose(); } });
items.push({ label: 'Löschen', action: () => { onDelete(state.id!, 'session'); onClose(); }, danger: true });
} else if (state.type === 'folder') {
items.push({ label: 'Umbenennen', action: () => { onRename(state.id!, 'folder'); onClose(); } });
items.push({ label: 'Neuer Unterordner', action: () => { onClose(); /* TODO */ } });
items.push({ label: 'Löschen', action: () => { onDelete(state.id!, 'folder'); onClose(); }, danger: true });
} else {
items.push({ label: 'Neuer Chat', action: () => { onNewChat(); onClose(); } });
items.push({ label: 'Neuer Ordner', action: () => { onNewFolder(); onClose(); } });
}
return (
<div
ref={ref}
className="fixed z-50 bg-white border border-secondary-200 rounded-lg shadow-lg py-1 min-w-[160px]"
style={{ left: state.x, top: state.y }}
>
{items.map((item, i) => (
<button
key={i}
onClick={item.action}
className={clsx(
'w-full text-left px-3 py-1.5 text-sm hover:bg-secondary-100',
item.danger && 'text-red-600 hover:bg-red-50'
)}
>
{item.label}
</button>
))}
</div>
);
}
export function SessionList({ activeSessionId, onSelectSession, isSidebar, className }: SessionListProps) {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [folders, setFolders] = useState<ChatFolder[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dragSessionId, setDragSessionId] = useState<string | null>(null);
const [dragFolderId, setDragFolderId] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const queryClient = useQueryClient();
const loadData = async () => {
try {
setLoading(true);
const [s, f] = await Promise.all([fetchSessions(isSidebar ? true : undefined), fetchFolders()]);
setSessions(s); setFolders(f); setError(null);
} catch (e) { setError(e instanceof Error ? e.message : 'Failed to load'); }
finally { setLoading(false); }
};
useEffect(() => { loadData(); }, [isSidebar]);
const handleContextMenu = useCallback((e: React.MouseEvent, type: 'session' | 'folder' | 'root', id: string | null) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, type, id });
}, []);
const handleRename = async (id: string, type: 'session' | 'folder') => {
const newName = prompt('Neuer Name:', type === 'session'
? sessions.find(s => s.id === id)?.title || ''
: folders.find(f => f.id === id)?.name || ''
);
if (!newName) return;
try {
if (type === 'session') await updateSession(id, { title: newName });
else {
await updateFolder(id, { name: newName });
queryClient.invalidateQueries({ queryKey: ['ai', 'folders'] });
}
loadData();
} catch (e) { setError(e instanceof Error ? e.message : 'Rename failed'); }
};
const handleDelete = async (id: string, type: 'session' | 'folder') => {
if (!confirm(type === 'session' ? 'Chat löschen?' : 'Ordner löschen? Chats bleiben erhalten.')) return;
try {
if (type === 'session') {
await deleteSession(id);
if (activeSessionId === id) onSelectSession('');
} else {
await deleteFolder(id);
}
loadData();
} catch (e) { setError(e instanceof Error ? e.message : 'Delete failed'); }
};
const handleNewFolder = () => {
const name = prompt('Ordnername:');
if (!name) return;
createFolder({ name }).then(() => loadData()).catch((e) => setError(e?.message));
};
const handleNewChat = async () => {
try {
const session = await createSession({ is_sidebar: isSidebar || false });
onSelectSession(session.id);
loadData();
} catch (e) { setError(e instanceof Error ? e.message : 'Failed'); }
};
const handleDropToFolder = async (folderId: string | null) => {
if (dragFolderId) {
try {
await updateFolder(dragFolderId, { parent_id: folderId });
setDragFolderId(null);
loadData();
} catch (e) { setError(e instanceof Error ? e.message : 'Failed to move folder'); }
return;
}
if (!dragSessionId) return;
try {
await updateSession(dragSessionId, { folder_id: folderId });
setDragSessionId(null);
loadData();
} catch (e) { setError(e instanceof Error ? e.message : 'Failed to move'); }
};
if (loading) return <div className="p-4 text-sm text-secondary-400">Laden...</div>;
const tree = buildTree(folders, sessions);
return (
<div className={clsx('flex flex-col h-full', className)}>
{error && <div className="p-2 text-xs text-red-600">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</div>}
<div
className="flex-1 overflow-y-auto p-2"
onContextMenu={(e) => {
if (e.target === e.currentTarget) handleContextMenu(e, 'root', null);
}}
>
{tree.map((node) => (
<TreeItem
key={node.folder?.id || 'root'}
node={node}
activeSessionId={activeSessionId}
onSelectSession={onSelectSession}
depth={0}
onContextMenu={handleContextMenu}
onDragSession={setDragSessionId}
onDragFolder={setDragFolderId}
onDropToFolder={handleDropToFolder}
onDelete={handleDelete}
onRename={handleRename}
/>
))}
</div>
{contextMenu && (
<ContextMenu
state={contextMenu}
onClose={() => setContextMenu(null)}
onRename={handleRename}
onDelete={handleDelete}
onNewFolder={handleNewFolder}
onNewChat={handleNewChat}
/>
)}
</div>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { ChatWindow } from '@/components/ai/ChatWindow';
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { SuggestionList } from '@/components/ai/SuggestionSidebar';
import { ImprovementPanel } from '@/components/ai/ImprovementPanel';
@@ -216,7 +216,7 @@ export function AISidebar() {
);
}
if (sessionId) {
return <ChatWindow sessionId={sessionId} compact className="h-full" />;
return <div className="flex items-center justify-center h-full text-secondary-400 text-sm">KI Chat im Kommunikations-Plugin verfügbar</div>;
}
return (
<div className="flex items-center justify-center h-full text-sm text-red-500">
+16 -14
View File
@@ -1,12 +1,8 @@
import { asError } from '@/utils/errorTypes';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Sparkles, Send } from 'lucide-react';
import {
streamChat,
createSession,
fetchMessages,
type ChatMessage,
} from '@/api/ai';
import { streamChat, fetchMessages } from '@/api/ai';
import { apiClient } from '@/api/client';
interface AiChatPanelProps {
windowTitle: string;
@@ -30,24 +26,30 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const msgIdCounter = useRef(0);
// Create a sidebar session on mount
// Create a comm conversation for AI chat on mount
useEffect(() => {
let cancelled = false;
setLoadingSession(true);
createSession({ title: `KI: ${windowTitle}`, is_sidebar: true })
.then((session) => {
apiClient.post('/comm/conversations', {
title: `KI: ${windowTitle}`,
participant_ids: [],
is_direct: false,
metadata: { conversation_type: 'ai' },
})
.then((res) => {
if (cancelled) return;
setSessionId(session.id);
return fetchMessages(session.id).then((msgs: ChatMessage[]) => {
const convId = res.data.id;
setSessionId(convId);
return fetchMessages(convId).then((msgs) => {
if (cancelled) return;
setMessages(
msgs.map((m) => ({ id: m.id, role: m.role, content: m.content }))
msgs.map((m, i) => ({ id: `msg-${i}`, role: m.role, content: m.content }))
);
});
})
.catch((e) => {
if (!cancelled) {
console.error('AI session creation failed:', e);
console.error('AI conversation creation failed:', e);
setError('KI Chat ist in diesem Kontext nicht verfügbar');
}
})
@@ -90,7 +92,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
} else if (event.type === 'done') {
const msgs = await fetchMessages(sessionId);
setMessages(
msgs.map((m) => ({ id: m.id, role: m.role, content: m.content }))
msgs.map((m, i) => ({ id: `msg-${i}`, role: m.role, content: m.content }))
);
setStreamingContent('');
} else if (event.type === 'error') {
-132
View File
@@ -1,132 +0,0 @@
import React, { useState, useEffect } from 'react';
import { SessionList } from '@/components/ai/SessionList';
import { ChatWindow } from '@/components/ai/ChatWindow';
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
import { fetchAgents, createSession, type AIAgent } from '@/api/ai';
import { ChevronLeft, ExternalLink, Folder, Plus } from 'lucide-react';
export function AIAssistantPage() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [agents, setAgents] = useState<AIAgent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [mobileView, setMobileView] = useState<'list' | 'chat'>('list');
const [refreshKey, setRefreshKey] = useState(0);
const { registerItems, unregisterPlugin } = usePluginToolbarStore();
useEffect(() => { fetchAgents().then(setAgents).catch(() => {}); }, []);
useEffect(() => {
registerItems('ai_assistant', [
{
id: 'agent-select',
plugin: 'ai_assistant',
label: 'Agent',
type: 'select',
group: 'agent',
selectOptions: agents.map((a) => ({ value: a.id, label: a.name })),
selectValue: selectedAgentId || '',
onSelect: (val: string) => setSelectedAgentId(val || null),
onClick: () => {},
},
{
id: 'new-chat',
plugin: 'ai_assistant',
label: 'Neuer Chat',
type: 'button',
group: 'actions',
icon: (
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
),
onClick: async () => {
try {
const session = await createSession({ is_sidebar: false });
setActiveSessionId(session.id);
setMobileView('chat');
setRefreshKey(k => k + 1);
} catch (e) {
console.error('Failed to create session:', e);
}
},
},
{
id: 'new-folder',
plugin: 'ai_assistant',
label: 'Ordner',
type: 'button',
group: 'actions',
icon: (
<Folder className="w-3.5 h-3.5" strokeWidth={2} />
),
onClick: () => {
const name = prompt('Ordnername:');
if (name) {
import('@/api/ai').then(({ createFolder }) => {
createFolder({ name }).then(() => setRefreshKey(k => k + 1)).catch(() => {});
});
}
},
},
{
id: 'open-standalone',
plugin: 'ai_assistant',
label: 'In neuem Fenster',
type: 'button',
group: 'actions',
icon: (
<ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />
),
onClick: () => {
window.open('/ai-assistant-standalone', '_blank', 'width=800,height=600');
},
},
]);
return () => unregisterPlugin('ai_assistant');
}, [agents, selectedAgentId, registerItems, unregisterPlugin]);
const handleSelectSession = (id: string) => { setActiveSessionId(id); setMobileView('chat'); };
return (
<div className="flex h-full" data-testid="ai-assistant-page">
{/* Desktop: two-pane layout */}
<div className="hidden md:flex">
<ResizablePanel
initialWidth={288}
minWidth={200}
maxWidth={500}
className="border-r border-secondary-200 bg-white"
data-testid="ai-session-pane"
>
<SessionList key={refreshKey} activeSessionId={activeSessionId} onSelectSession={setActiveSessionId} className="h-full" />
</ResizablePanel>
</div>
<div className="hidden md:flex flex-1 flex-col">
{activeSessionId ? <ChatWindow sessionId={activeSessionId} agentId={selectedAgentId} className="flex-1" /> : (
<div className="flex-1 flex items-center justify-center text-secondary-400"><div className="text-center"><div className="text-4xl mb-3">🤖</div><div className="text-sm">Wähle einen Chat oder erstelle einen neuen</div></div></div>
)}
</div>
{/* Mobile: single-pane view switching */}
<div className="flex md:hidden flex-1 flex-col overflow-hidden">
{mobileView === 'list' && (
<div className="flex-1 flex flex-col bg-white">
<SessionList key={refreshKey} activeSessionId={activeSessionId} onSelectSession={handleSelectSession} className="flex-1" />
</div>
)}
{mobileView === 'chat' && activeSessionId && (
<div className="flex-1 flex flex-col">
<div className="h-14 flex items-center gap-2 px-3 border-b border-secondary-200 bg-white">
<button onClick={() => setMobileView('list')} className="p-2 rounded-lg hover:bg-secondary-100" aria-label="Zurück">
<ChevronLeft className="w-5 h-5" strokeWidth={2} />
</button>
<span className="text-sm font-medium text-secondary-700 truncate">Chat</span>
</div>
<ChatWindow sessionId={activeSessionId} agentId={selectedAgentId} className="flex-1" />
</div>
)}
{mobileView === 'chat' && !activeSessionId && (
<div className="flex-1 flex items-center justify-center text-secondary-400"><div className="text-center"><div className="text-4xl mb-3">🤖</div><div className="text-sm">Wähle einen Chat</div></div></div>
)}
</div>
</div>
);
}
@@ -1,10 +0,0 @@
import React from 'react';
import { AIAssistantPage } from './AIAssistant';
export function AIAssistantStandalonePage() {
return (
<div className="h-screen w-screen overflow-hidden bg-white">
<AIAssistantPage />
</div>
);
}
+56 -30
View File
@@ -11,7 +11,7 @@ import {
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
import { apiClient } from '@/api/client';
import { streamChat, fetchAgents, type AIAgent } from '@/api/ai';
import { streamChat, fetchAgents, fetchMessages as fetchAiMessages, type AIAgent } from '@/api/ai';
// ─── Types ───
@@ -322,7 +322,6 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
const [miniApps, setMiniApps] = useState<MiniApp[]>([]);
const [agents, setAgents] = useState<AIAgent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [aiSessionId, setAiSessionId] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
@@ -331,11 +330,38 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
if (!convId) return;
setLoading(true);
setError(null);
fetchMessages(convId)
.then(msgs => { setMessages(msgs); setLoading(false); })
.catch(e => { setError(e?.message || 'Fehler beim Laden'); setLoading(false); });
if (category === 'ai') {
// AI conversations load messages from the AI endpoint
fetchAiMessages(convId)
.then(msgs => {
// Convert {role, content}[] to Message[] format
const mapped: Message[] = msgs.map((m, i) => ({
id: `ai-msg-${i}`,
conversation_id: convId,
sender_id: null,
sender_type: m.role === 'user' ? 'user' : 'ai',
content: m.content,
content_format: 'text',
created_at: null,
edited_at: null,
is_pinned: false,
blocks: [],
attachments: [],
reactions: [],
reply_to_id: null,
}));
setMessages(mapped);
setLoading(false);
})
.catch(e => { setError(e?.message || 'Fehler beim Laden'); setLoading(false); });
} else {
// Regular conversations load from comm endpoint
fetchMessages(convId)
.then(msgs => { setMessages(msgs as unknown as Message[]); setLoading(false); })
.catch(e => { setError(e?.message || 'Fehler beim Laden'); setLoading(false); });
}
markConversationRead(convId).catch(() => {});
}, [convId]);
}, [convId, category]);
// Load agents and mini-apps
useEffect(() => {
@@ -386,12 +412,12 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
setError(null);
try {
if (category === 'ai' && aiSessionId) {
// AI streaming chat
if (category === 'ai') {
// AI streaming chat via comm conversation
setAiStreaming(true);
setStreamingContent('');
let accumulated = '';
for await (const event of streamChat(aiSessionId, content, selectedAgentId || undefined)) {
for await (const event of streamChat(convId, content, selectedAgentId || undefined)) {
if (event.type === 'token' && event.content) {
accumulated += event.content;
setStreamingContent(accumulated);
@@ -405,8 +431,23 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
setAiStreaming(false);
setStreamingContent('');
// Reload messages to get the AI response
const msgs = await fetchMessages(convId);
setMessages(msgs);
const msgs = await fetchAiMessages(convId);
const mapped: Message[] = msgs.map((m, i) => ({
id: `ai-msg-${i}`,
conversation_id: convId,
sender_id: null,
sender_type: m.role === 'user' ? 'user' : 'ai',
content: m.content,
content_format: 'text',
created_at: null,
edited_at: null,
is_pinned: false,
blocks: [],
attachments: [],
reactions: [],
reply_to_id: null,
}));
setMessages(mapped);
} else {
// Regular message
const msg = await sendMessage(convId, content);
@@ -426,25 +467,10 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
}
};
const startAiSession = async () => {
try {
const r = await apiClient.post('/ai/sessions', {
title: 'KI Chat',
agent_id: selectedAgentId,
is_sidebar: false,
});
setAiSessionId(r.data.id);
} catch (e: unknown) { const errObj = asError(e);
setError('KI Session konnte nicht gestartet werden');
}
};
// Auto-start AI session for AI conversations
// Auto-scroll
useEffect(() => {
if (category === 'ai' && !aiSessionId && selectedAgentId) {
startAiSession();
}
}, [category, aiSessionId, selectedAgentId]);
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages, streamingContent]);
return (
<div className="flex flex-col h-full bg-white">
@@ -462,7 +488,7 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
{category === 'ai' && agents.length > 0 && (
<select
value={selectedAgentId || ''}
onChange={(e) => { setSelectedAgentId(e.target.value || null); setAiSessionId(null); }}
onChange={(e) => { setSelectedAgentId(e.target.value || null); }}
className="text-xs border border-secondary-200 rounded px-2 py-1 bg-white"
>
{agents.map(a => (
-6
View File
@@ -38,12 +38,10 @@ const TrashPage = React.lazy(() => import('@/pages/Trash').then(m => ({ default:
const MailPage = React.lazy(() => import('@/pages/Mail').then(m => ({ default: m.MailPage })));
const MailSettingsPage = React.lazy(() => import('@/pages/MailSettings').then(m => ({ default: m.MailSettingsPage })));
const SettingsNotificationsPage = React.lazy(() => import('@/pages/SettingsNotifications').then(m => ({ default: m.SettingsNotificationsPage })));
const AIAssistantPage = React.lazy(() => import('@/pages/AIAssistant').then(m => ({ default: m.AIAssistantPage })));
const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage })));
const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings })));
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m => ({ default: m.SettingsMcpPage })));
const AIAssistantStandalonePage = React.lazy(() => import('@/pages/AIAssistantStandalone').then(m => ({ default: m.AIAssistantStandalonePage })));
const DmsStandalonePage = React.lazy(() => import('@/pages/DmsStandalone').then(m => ({ default: m.DmsStandalonePage })));
const CalendarStandalonePage = React.lazy(() => import('@/pages/CalendarStandalone').then(m => ({ default: m.CalendarStandalonePage })));
const MailStandalonePage = React.lazy(() => import('@/pages/MailStandalone').then(m => ({ default: m.MailStandalonePage })));
@@ -111,8 +109,6 @@ const router = createBrowserRouter([
element: <LoginPage />,
},
{
path: '/ai-assistant-standalone',
element: <ErrorBoundary>{withSuspense(<AIAssistantStandalonePage />)}</ErrorBoundary>,
},
{
path: '/dms-standalone',
@@ -178,7 +174,6 @@ const router = createBrowserRouter([
{ path: 'overview', element: withSuspense(<AgentsOverviewPage />) },
{ path: 'automation', element: withSuspense(<AutomationDashboardPage />) },
{ path: 'automation-settings', element: withSuspense(<AutomationSettingsPage />) },
{ path: 'ai-assistant', element: withSuspense(<AIAssistantPage />) },
{ path: 'ai-settings', element: withSuspense(<AISettingsPage />) },
{ path: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) },
{ path: '*', element: withSuspense(<AgentsPlaceholderPage />) },
@@ -258,7 +253,6 @@ const router = createBrowserRouter([
{ path: '/trash', element: <PermissionRoute permission="contacts:read">{withSuspense(<TrashPage />)}</PermissionRoute> },
{ path: '/mail', element: <PermissionRoute permission="mail:read">{withSuspense(<MailPage />)}</PermissionRoute> },
{ path: '/mail/settings', element: <PermissionRoute permission="mail:read">{withSuspense(<MailSettingsPage />)}</PermissionRoute> },
{ path: '/ai-assistant', element: <PermissionRoute permission="ai:read">{withSuspense(<AIAssistantPage />)}</PermissionRoute> },
{ path: '/reports', element: <PermissionRoute permission="reports:read">{withSuspense(<ReportsPage />)}</PermissionRoute> },
{ path: '/tasks', element: <PermissionRoute permission="tasks:read">{withSuspense(<TasksPage />)}</PermissionRoute> },
{ path: '/communication', element: <PermissionRoute permission="communication:read">{withSuspense(<CommunicationPage />)}</PermissionRoute> },