Files
leocrm/app/plugins/builtins/ai_assistant/routes.py
T
Agent Zero d3618d8365
Check Cross-Plugin Imports / check (push) Has been cancelled
fix(ai-assistant): apply_visibility_filter Import wieder hinzugefügt
2026-08-21 20:25:08 +02:00

591 lines
19 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, 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,
AIChatFolder,
AIModel,
AIPreset,
AIProvider,
)
from app.plugins.builtins.ai_assistant.schemas import (
AIAgentCreate,
AIAgentUpdate,
AIModelCreate,
AIModelUpdate,
AIPresetCreate,
AIPresetUpdate,
AIProviderCreate,
AIProviderUpdate,
ChatFolderCreate,
ChatFolderUpdate,
ChatSendRequest,
)
from app.plugins.builtins.ai_assistant.services import (
agent_to_response,
folder_to_response,
get_agent_by_id,
get_comm_messages,
get_default_agent,
get_preset_by_id,
get_provider_by_id,
model_to_response,
preset_to_response,
provider_to_response,
stream_chat_comm,
)
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.is_(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.flush()
await db.refresh(provider)
await db.commit()
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.is_(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.flush()
await db.refresh(provider)
await db.commit()
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.flush()
await db.refresh(model)
await db.commit()
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.flush()
await db.refresh(model)
await db.commit()
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.flush()
await db.refresh(preset)
await db.commit()
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.flush()
await db.refresh(preset)
await db.commit()
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"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("is_system_admin", False)
query = select(AIAgent).where(AIAgent.tenant_id == tenant_id)
query = await apply_visibility_filter(
db, query, "ai_agent", AIAgent, user_id, tenant_id, is_system_admin
)
result = await db.execute(query)
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"])
user_id = uuid.UUID(current_user["user_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,
owner_id=user_id,
)
db.add(agent)
await db.flush()
await db.refresh(agent)
await db.commit()
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.flush()
await db.refresh(agent)
await db.commit()
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()
items = registry.list_for_api()
return {"items": items, "total": len(items)}
# ─── 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.flush()
await db.refresh(folder)
await db.commit()
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.flush()
await db.refresh(folder)
await db.commit()
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}
# ─── AI Chat Streaming (via comm_conversations) ───
@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"])
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",
},
)
@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"])
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]