feat(UI-Overhaul-Phase2): AI Assistent in Kommunikation integriert
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user