7f61dfb25b
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
482 lines
16 KiB
Python
482 lines
16 KiB
Python
"""Business logic for the AI Assistant plugin.
|
|
|
|
Uses LiteLLM for multi-provider LLM calls and PydanticAI for the agent loop.
|
|
Tools from the global ToolRegistry are wrapped as PydanticAI tools with
|
|
RBAC permission checks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from collections.abc import AsyncGenerator
|
|
from typing import Any
|
|
|
|
import litellm
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.ai.llm_client import llm_complete
|
|
from app.core.permissions import check_permission
|
|
from app.plugins.builtins.ai_assistant.models import (
|
|
AIAgent,
|
|
AIChatFolder,
|
|
AIModel,
|
|
AIPreset,
|
|
AIProvider,
|
|
)
|
|
from app.plugins.builtins.ai_assistant.tool_registry import (
|
|
AITool,
|
|
get_tool_registry,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Suppress litellm verbose logging
|
|
litellm.suppress_debug_info = True
|
|
|
|
|
|
# ─── Helpers ───
|
|
|
|
def mask_api_key(key: str) -> str:
|
|
"""Mask API key for display: show first 8 and last 4 chars."""
|
|
if not key or len(key) <= 12:
|
|
return "***"
|
|
return f"{key[:8]}...{key[-4:]}"
|
|
|
|
|
|
def provider_to_response(provider: AIProvider) -> dict[str, Any]:
|
|
return {
|
|
"id": str(provider.id),
|
|
"name": provider.name,
|
|
"provider_type": provider.provider_type,
|
|
"api_key": mask_api_key(provider.api_key),
|
|
"base_url": provider.base_url,
|
|
"is_active": provider.is_active,
|
|
"is_default": provider.is_default,
|
|
"config": provider.config or {},
|
|
"created_at": provider.created_at.isoformat() if provider.created_at else None,
|
|
"updated_at": provider.updated_at.isoformat() if provider.updated_at else None,
|
|
}
|
|
|
|
|
|
def model_to_response(model: AIModel) -> dict[str, Any]:
|
|
return {
|
|
"id": str(model.id),
|
|
"provider_id": str(model.provider_id),
|
|
"model_id": model.model_id,
|
|
"display_name": model.display_name,
|
|
"context_window": model.context_window,
|
|
"supports_tools": model.supports_tools,
|
|
"supports_streaming": model.supports_streaming,
|
|
"is_active": model.is_active,
|
|
"config": model.config or {},
|
|
}
|
|
|
|
|
|
def preset_to_response(preset: AIPreset) -> dict[str, Any]:
|
|
return {
|
|
"id": str(preset.id),
|
|
"name": preset.name,
|
|
"model_id": preset.model_id,
|
|
"provider_id": str(preset.provider_id) if preset.provider_id else None,
|
|
"temperature": preset.temperature,
|
|
"max_tokens": preset.max_tokens,
|
|
"top_p": preset.top_p,
|
|
"system_prompt": preset.system_prompt,
|
|
"config": preset.config or {},
|
|
"is_active": preset.is_active,
|
|
}
|
|
|
|
|
|
def agent_to_response(agent: AIAgent) -> dict[str, Any]:
|
|
return {
|
|
"id": str(agent.id),
|
|
"name": agent.name,
|
|
"description": agent.description,
|
|
"system_prompt": agent.system_prompt,
|
|
"preset_id": str(agent.preset_id) if agent.preset_id else None,
|
|
"tool_ids": agent.tool_ids or [],
|
|
"is_default": agent.is_default,
|
|
"is_active": agent.is_active,
|
|
"config": agent.config or {},
|
|
"created_at": agent.created_at.isoformat() if agent.created_at else None,
|
|
"updated_at": agent.updated_at.isoformat() if agent.updated_at else None,
|
|
}
|
|
|
|
|
|
def folder_to_response(folder: AIChatFolder) -> dict[str, Any]:
|
|
return {
|
|
"id": str(folder.id),
|
|
"name": folder.name,
|
|
"parent_id": str(folder.parent_id) if folder.parent_id else None,
|
|
"user_id": str(folder.user_id),
|
|
"sort_order": folder.sort_order,
|
|
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
|
}
|
|
|
|
|
|
# ─── Provider/Model/Preset/Agent CRUD ───
|
|
|
|
async def get_default_provider(db: AsyncSession, tenant_id: uuid.UUID) -> AIProvider | None:
|
|
"""Get the default provider for a tenant."""
|
|
result = await db.execute(
|
|
select(AIProvider)
|
|
.where(AIProvider.tenant_id == tenant_id)
|
|
.where(AIProvider.is_default.is_(True))
|
|
.limit(1)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def get_provider_by_id(db: AsyncSession, provider_id: uuid.UUID, tenant_id: uuid.UUID) -> AIProvider | None:
|
|
result = await db.execute(
|
|
select(AIProvider)
|
|
.where(AIProvider.id == provider_id)
|
|
.where(AIProvider.tenant_id == tenant_id)
|
|
.limit(1)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def get_preset_by_id(db: AsyncSession, preset_id: uuid.UUID, tenant_id: uuid.UUID) -> AIPreset | None:
|
|
result = await db.execute(
|
|
select(AIPreset)
|
|
.where(AIPreset.id == preset_id)
|
|
.where(AIPreset.tenant_id == tenant_id)
|
|
.limit(1)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def get_agent_by_id(db: AsyncSession, agent_id: uuid.UUID, tenant_id: uuid.UUID) -> AIAgent | None:
|
|
result = await db.execute(
|
|
select(AIAgent)
|
|
.where(AIAgent.id == agent_id)
|
|
.where(AIAgent.tenant_id == tenant_id)
|
|
.limit(1)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def get_default_agent(db: AsyncSession, tenant_id: uuid.UUID) -> AIAgent | None:
|
|
result = await db.execute(
|
|
select(AIAgent)
|
|
.where(AIAgent.tenant_id == tenant_id)
|
|
.where(AIAgent.is_default.is_(True))
|
|
.limit(1)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
# ─── Comm-based Chat Helpers (replaces AIChatSession/AIChatMessage) ───
|
|
|
|
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(CommMessage)
|
|
.where(CommMessage.conversation_id == conversation_id)
|
|
.where(CommMessage.tenant_id == tenant_id)
|
|
.order_by(CommMessage.created_at.asc())
|
|
)
|
|
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 save_comm_message(
|
|
db: AsyncSession,
|
|
conversation_id: uuid.UUID,
|
|
role: str,
|
|
content: str,
|
|
tenant_id: uuid.UUID,
|
|
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,
|
|
content_format="text",
|
|
tenant_id=tenant_id,
|
|
)
|
|
db.add(msg)
|
|
await db.flush()
|
|
|
|
|
|
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,
|
|
agent: AIAgent,
|
|
messages: list[dict[str, Any]],
|
|
tenant_id: uuid.UUID,
|
|
) -> dict[str, Any]:
|
|
"""Build LiteLLM completion parameters from agent + preset."""
|
|
# Get preset
|
|
preset: AIPreset | None = None
|
|
if agent.preset_id:
|
|
preset = await get_preset_by_id(db, agent.preset_id, tenant_id)
|
|
|
|
model_id = "gpt-4o-mini" # fallback
|
|
temperature = 0.7
|
|
max_tokens = 2048
|
|
top_p = 1.0
|
|
system_prompt = agent.system_prompt or ""
|
|
|
|
if preset:
|
|
model_id = preset.model_id
|
|
temperature = preset.temperature
|
|
max_tokens = preset.max_tokens
|
|
top_p = preset.top_p
|
|
if preset.system_prompt and not system_prompt:
|
|
system_prompt = preset.system_prompt
|
|
|
|
# Get provider for API key
|
|
provider: AIProvider | None = None
|
|
if preset and preset.provider_id:
|
|
provider = await get_provider_by_id(db, preset.provider_id, tenant_id)
|
|
if not provider:
|
|
provider = await get_default_provider(db, tenant_id)
|
|
|
|
# Build litellm model string with provider prefix
|
|
# LiteLLM always needs a provider prefix, even for OpenAI-compatible APIs
|
|
litellm_model = model_id
|
|
if provider:
|
|
litellm_model = f"{provider.provider_type}/{model_id}"
|
|
|
|
# Build messages with system prompt
|
|
litellm_messages = []
|
|
if system_prompt:
|
|
# Inject CRM API context into system prompt
|
|
try:
|
|
from app.plugins.builtins.ai_assistant.crm_api_tool import get_api_context_for_prompt
|
|
api_context = await get_api_context_for_prompt()
|
|
system_prompt = system_prompt + api_context
|
|
except Exception:
|
|
logger.warning("Failed to inject API context into system prompt")
|
|
litellm_messages.append({"role": "system", "content": system_prompt})
|
|
litellm_messages.extend(messages)
|
|
|
|
params: dict[str, Any] = {
|
|
"model": litellm_model,
|
|
"messages": litellm_messages,
|
|
"temperature": temperature,
|
|
"max_tokens": max_tokens,
|
|
"top_p": top_p,
|
|
"stream": True,
|
|
}
|
|
|
|
# Set API key from provider
|
|
if provider and provider.api_key:
|
|
if provider.provider_type == "openai":
|
|
params["api_key"] = provider.api_key
|
|
elif provider.provider_type == "anthropic":
|
|
params["api_key"] = provider.api_key
|
|
else:
|
|
params["api_key"] = provider.api_key
|
|
|
|
if provider and provider.base_url:
|
|
params["api_base"] = provider.base_url
|
|
|
|
return params, model_id
|
|
|
|
|
|
async def execute_tool_call(
|
|
tool: AITool,
|
|
arguments: dict[str, Any],
|
|
user_context: dict[str, Any],
|
|
) -> str:
|
|
"""Execute a tool call with RBAC permission check."""
|
|
# Check RBAC permission if tool requires one
|
|
if tool.required_permission:
|
|
if not check_permission(user_context, tool.required_permission):
|
|
return f"Error: Permission '{tool.required_permission}' required for tool '{tool.name}'"
|
|
|
|
try:
|
|
result = await tool.handler(arguments=arguments, context=user_context)
|
|
return result
|
|
except Exception as exc:
|
|
logger.warning("Tool '%s' execution error: %s", tool.name, exc)
|
|
return f"Error executing tool '{tool.name}': {exc}"
|
|
|
|
|
|
# ─── Seed Defaults ───
|
|
|
|
async def seed_defaults(db: AsyncSession) -> None:
|
|
"""Seed default provider, preset, and agent for existing tenants."""
|
|
from app.models import Tenant
|
|
|
|
result = await db.execute(select(Tenant))
|
|
tenants = list(result.scalars().all())
|
|
|
|
for tenant in tenants:
|
|
# Check if default provider already exists
|
|
existing = await get_default_provider(db, tenant.id)
|
|
if existing:
|
|
continue
|
|
|
|
# Create default OpenAI provider (no key, user must configure)
|
|
provider = AIProvider(
|
|
name="OpenAI",
|
|
provider_type="openai",
|
|
api_key="",
|
|
base_url="",
|
|
is_active=True,
|
|
is_default=True,
|
|
config={},
|
|
tenant_id=tenant.id,
|
|
)
|
|
db.add(provider)
|
|
await db.flush()
|
|
|
|
# Create default preset
|
|
preset = AIPreset(
|
|
name="Standard",
|
|
model_id="gpt-4o-mini",
|
|
provider_id=provider.id,
|
|
temperature=0.7,
|
|
max_tokens=2048,
|
|
top_p=1.0,
|
|
system_prompt="Du bist ein hilfreicher KI-Assistent für ein CRM-System.",
|
|
is_active=True,
|
|
tenant_id=tenant.id,
|
|
)
|
|
db.add(preset)
|
|
await db.flush()
|
|
|
|
# Create default agent
|
|
agent = AIAgent(
|
|
name="Standard Assistent",
|
|
description="Allgemeiner KI-Assistent",
|
|
system_prompt="Du bist ein hilfreicher KI-Assistent. Antworte präzise und hilfreich.",
|
|
preset_id=preset.id,
|
|
tool_ids=[],
|
|
is_default=True,
|
|
is_active=True,
|
|
config={},
|
|
tenant_id=tenant.id,
|
|
)
|
|
db.add(agent)
|
|
|
|
await db.commit()
|
|
logger.info("AI Assistant: default providers, presets, and agents seeded")
|