Files
leocrm/app/plugins/builtins/ai_assistant/routes.py
T

780 lines
26 KiB
Python

"""AI Assistant plugin routes — providers, models, presets, agents,
sessions, messages, tools, and streaming chat.
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db, set_tenant_context
from app.deps import get_current_user, require_permission
from app.plugins.builtins.ai_assistant.models import (
AIAgent,
AIChatAttachment,
AIChatFolder,
AIChatSession,
AIModel,
AIPreset,
AIProvider,
)
from app.plugins.builtins.ai_assistant.schemas import (
AIAgentCreate,
AIAgentUpdate,
AIModelCreate,
AIModelUpdate,
AIPresetCreate,
AIPresetUpdate,
AIProviderCreate,
AIProviderUpdate,
ChatAttachmentResponse,
ChatFolderCreate,
ChatFolderUpdate,
ChatFolderResponse,
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_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,
)
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
router = APIRouter(prefix="/api/v1/ai", tags=["ai-assistant"])
# ─── Providers ───
@router.get("/providers", dependencies=[Depends(require_permission("ai:config"))])
async def list_providers(
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(AIProvider).where(AIProvider.tenant_id == tenant_id)
)
providers = list(result.scalars().all())
return [provider_to_response(p) for p in providers]
@router.post("/providers", dependencies=[Depends(require_permission("ai:config"))])
async def create_provider(
data: AIProviderCreate,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
await set_tenant_context(db, tenant_id)
# If is_default, unset other defaults
if data.is_default:
existing = await db.execute(
select(AIProvider)
.where(AIProvider.tenant_id == tenant_id)
.where(AIProvider.is_default == True)
)
for p in existing.scalars().all():
p.is_default = False
provider = AIProvider(
name=data.name,
provider_type=data.provider_type,
api_key=data.api_key,
base_url=data.base_url,
is_active=data.is_active,
is_default=data.is_default,
config=data.config,
tenant_id=tenant_id,
)
db.add(provider)
await db.commit()
await db.refresh(provider)
return provider_to_response(provider)
@router.put("/providers/{provider_id}", dependencies=[Depends(require_permission("ai:config"))])
async def update_provider(
provider_id: str,
data: AIProviderUpdate,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
provider = await get_provider_by_id(db, uuid.UUID(provider_id), tenant_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
if data.is_default:
existing = await db.execute(
select(AIProvider)
.where(AIProvider.tenant_id == tenant_id)
.where(AIProvider.is_default == True)
.where(AIProvider.id != provider.id)
)
for p in existing.scalars().all():
p.is_default = False
for field, val in data.model_dump(exclude_unset=True).items():
setattr(provider, field, val)
await db.commit()
await db.refresh(provider)
return provider_to_response(provider)
@router.delete("/providers/{provider_id}", dependencies=[Depends(require_permission("ai:config"))])
async def delete_provider(
provider_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
provider = await get_provider_by_id(db, uuid.UUID(provider_id), tenant_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
await db.delete(provider)
await db.commit()
return {"ok": True}
# ─── Models ───
@router.get("/models", dependencies=[Depends(require_permission("ai:config"))])
async def list_models(
provider_id: str | None = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
stmt = select(AIModel).where(AIModel.tenant_id == tenant_id)
if provider_id:
stmt = stmt.where(AIModel.provider_id == uuid.UUID(provider_id))
result = await db.execute(stmt)
models = list(result.scalars().all())
return [model_to_response(m) for m in models]
@router.post("/models", dependencies=[Depends(require_permission("ai:config"))])
async def create_model(
data: AIModelCreate,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
await set_tenant_context(db, tenant_id)
model = AIModel(
provider_id=uuid.UUID(data.provider_id),
model_id=data.model_id,
display_name=data.display_name,
context_window=data.context_window,
supports_tools=data.supports_tools,
supports_streaming=data.supports_streaming,
is_active=data.is_active,
config=data.config,
tenant_id=tenant_id,
)
db.add(model)
await db.commit()
await db.refresh(model)
return model_to_response(model)
@router.put("/models/{model_id}", dependencies=[Depends(require_permission("ai:config"))])
async def update_model(
model_id: str,
data: AIModelUpdate,
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(AIModel)
.where(AIModel.id == uuid.UUID(model_id))
.where(AIModel.tenant_id == tenant_id)
)
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="Model not found")
for field, val in data.model_dump(exclude_unset=True).items():
setattr(model, field, val)
await db.commit()
await db.refresh(model)
return model_to_response(model)
@router.delete("/models/{model_id}", dependencies=[Depends(require_permission("ai:config"))])
async def delete_model(
model_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(AIModel)
.where(AIModel.id == uuid.UUID(model_id))
.where(AIModel.tenant_id == tenant_id)
)
model = result.scalar_one_or_none()
if not model:
raise HTTPException(status_code=404, detail="Model not found")
await db.delete(model)
await db.commit()
return {"ok": True}
# ─── Presets ───
@router.get("/presets", dependencies=[Depends(require_permission("ai:config"))])
async def list_presets(
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(AIPreset).where(AIPreset.tenant_id == tenant_id)
)
presets = list(result.scalars().all())
return [preset_to_response(p) for p in presets]
@router.post("/presets", dependencies=[Depends(require_permission("ai:config"))])
async def create_preset(
data: AIPresetCreate,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
await set_tenant_context(db, tenant_id)
preset = AIPreset(
name=data.name,
model_id=data.model_id,
provider_id=uuid.UUID(data.provider_id) if data.provider_id else None,
temperature=data.temperature,
max_tokens=data.max_tokens,
top_p=data.top_p,
system_prompt=data.system_prompt,
config=data.config,
is_active=data.is_active,
tenant_id=tenant_id,
)
db.add(preset)
await db.commit()
await db.refresh(preset)
return preset_to_response(preset)
@router.put("/presets/{preset_id}", dependencies=[Depends(require_permission("ai:config"))])
async def update_preset(
preset_id: str,
data: AIPresetUpdate,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
preset = await get_preset_by_id(db, uuid.UUID(preset_id), tenant_id)
if not preset:
raise HTTPException(status_code=404, detail="Preset not found")
update_data = data.model_dump(exclude_unset=True)
if "provider_id" in update_data and update_data["provider_id"]:
update_data["provider_id"] = uuid.UUID(update_data["provider_id"])
for field, val in update_data.items():
setattr(preset, field, val)
await db.commit()
await db.refresh(preset)
return preset_to_response(preset)
@router.delete("/presets/{preset_id}", dependencies=[Depends(require_permission("ai:config"))])
async def delete_preset(
preset_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
preset = await get_preset_by_id(db, uuid.UUID(preset_id), tenant_id)
if not preset:
raise HTTPException(status_code=404, detail="Preset not found")
await db.delete(preset)
await db.commit()
return {"ok": True}
# ─── Agents ───
@router.get("/agents", dependencies=[Depends(require_permission("ai:read"))])
async def list_agents(
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(AIAgent).where(AIAgent.tenant_id == tenant_id)
)
agents = list(result.scalars().all())
return [agent_to_response(a) for a in agents]
@router.post("/agents", dependencies=[Depends(require_permission("ai:agents"))])
async def create_agent(
data: AIAgentCreate,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
await set_tenant_context(db, tenant_id)
agent = AIAgent(
name=data.name,
description=data.description,
system_prompt=data.system_prompt,
preset_id=uuid.UUID(data.preset_id) if data.preset_id else None,
tool_ids=data.tool_ids,
is_active=data.is_active,
config=data.config,
tenant_id=tenant_id,
)
db.add(agent)
await db.commit()
await db.refresh(agent)
return agent_to_response(agent)
@router.put("/agents/{agent_id}", dependencies=[Depends(require_permission("ai:agents"))])
async def update_agent(
agent_id: str,
data: AIAgentUpdate,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
agent = await get_agent_by_id(db, uuid.UUID(agent_id), tenant_id)
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
update_data = data.model_dump(exclude_unset=True)
if "preset_id" in update_data and update_data["preset_id"]:
update_data["preset_id"] = uuid.UUID(update_data["preset_id"])
for field, val in update_data.items():
setattr(agent, field, val)
await db.commit()
await db.refresh(agent)
return agent_to_response(agent)
@router.delete("/agents/{agent_id}", dependencies=[Depends(require_permission("ai:agents"))])
async def delete_agent(
agent_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
agent = await get_agent_by_id(db, uuid.UUID(agent_id), tenant_id)
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
if agent.is_default:
raise HTTPException(status_code=400, detail="Cannot delete default agent")
await db.delete(agent)
await db.commit()
return {"ok": True}
# ─── Tools ───
@router.get("/tools", dependencies=[Depends(require_permission("ai:read"))])
async def list_tools(
current_user: dict = Depends(get_current_user),
):
registry = get_tool_registry()
return registry.list_for_api()
# ─── 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"])
stmt = (
select(AIChatSession)
.where(AIChatSession.tenant_id == tenant_id)
.where(AIChatSession.user_id == user_id)
)
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,
)
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,
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")
# 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():
async for chunk in stream_chat(
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"))])
async def list_folders(
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"])
result = await db.execute(
select(AIChatFolder)
.where(AIChatFolder.tenant_id == tenant_id)
.where(AIChatFolder.user_id == user_id)
.order_by(AIChatFolder.name.asc())
)
folders = list(result.scalars().all())
return [folder_to_response(f) for f in folders]
@router.post("/folders", dependencies=[Depends(require_permission("ai:write"))])
async def create_folder(
data: ChatFolderCreate,
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)
folder = AIChatFolder(
name=data.name,
parent_id=uuid.UUID(data.parent_id) if data.parent_id else None,
user_id=user_id,
tenant_id=tenant_id,
)
db.add(folder)
await db.commit()
await db.refresh(folder)
return folder_to_response(folder)
@router.put("/folders/{folder_id}", dependencies=[Depends(require_permission("ai:write"))])
async def update_folder(
folder_id: str,
data: ChatFolderUpdate,
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"])
result = await db.execute(
select(AIChatFolder)
.where(AIChatFolder.id == uuid.UUID(folder_id))
.where(AIChatFolder.tenant_id == tenant_id)
.where(AIChatFolder.user_id == user_id)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
update_data = data.model_dump(exclude_unset=True)
if "parent_id" in update_data:
if update_data["parent_id"]:
update_data["parent_id"] = uuid.UUID(update_data["parent_id"])
else:
update_data["parent_id"] = None
for field, val in update_data.items():
setattr(folder, field, val)
await db.commit()
await db.refresh(folder)
return folder_to_response(folder)
@router.delete("/folders/{folder_id}", dependencies=[Depends(require_permission("ai:write"))])
async def delete_folder(
folder_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"])
result = await db.execute(
select(AIChatFolder)
.where(AIChatFolder.id == uuid.UUID(folder_id))
.where(AIChatFolder.tenant_id == tenant_id)
.where(AIChatFolder.user_id == user_id)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await db.delete(folder)
await db.commit()
return {"ok": True}
# ─── Attachments ───
import os
from pathlib import Path
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", dependencies=[Depends(require_permission("ai:write"))])
async def upload_attachment(
session_id: str,
file: UploadFile,
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")
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}")
with open(storage_path, "wb") as f:
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,
)
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,
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")
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]
@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,
)