Files
leocrm/app/plugins/builtins/ai_assistant/routes.py
T
Agent Zero 5ec1fc9b05 Phase 1: Fix all critical release blockers (B1-B10)
B1: Remove duplicate get_redis() — singleton no longer overwritten
B2: Plugin routes now enforce activation status via require_active_plugin()
B3: Fix UploadFile ForwardRef error — remove functools.wraps from wrap_plugin_route
B4: DMS upload uses true streaming via save_stream() instead of RAM accumulation
B5: Worker on_startup registers plugin event handlers + webhook dispatcher
B6: Implement send_password_reset_email job, remove raw token logging
B7: Webhook SSRF protection (IP validation, no redirects), secret removed from response
B8: RLS repair migration 0044 + separate crm_runtime DB user (NOSUPERUSER, NOBYPASSRLS)
B9: Fix .env.docker.example AUTH_SECRET → SECRET_KEY
B10: Remove Redis default password, remove exposed DB/Redis ports

Also: add frontend_url to config, add SMTP settings to .env.docker.example,
update prestart.sh to use MIGRATION_DATABASE_URL for alembic.
2026-07-26 20:45:42 +02:00

782 lines
26 KiB
Python

"""AI Assistant plugin routes — providers, models, presets, agents,
sessions, messages, tools, and streaming chat.
"""
from __future__ import annotations
import uuid
import aiofiles
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()
items = registry.list_for_api()
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"])
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", response_model=None, 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}")
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,
)
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,
)