Files
leocrm/app/plugins/builtins/ai_assistant/routes.py
T
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

806 lines
27 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, Request, 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.core.visibility import apply_visibility_filter
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,
ChatFolderCreate,
ChatFolderUpdate,
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.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.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.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.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"])
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.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"])
is_system_admin = current_user.get("is_system_admin", False)
stmt = (
select(AIChatSession)
.where(AIChatSession.tenant_id == tenant_id)
)
stmt = await apply_visibility_filter(
db, stmt, "ai_chat_session", AIChatSession, user_id, tenant_id, is_system_admin
)
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,
owner_id=user_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,
request: Request,
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"])
# Rate limit — AI policy (cost-sensitive LLM call)
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)
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():
# Use a fresh DB session — the Depends(get_db) session closes after response
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(
stream_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 # noqa: E402
from pathlib import Path # noqa: E402
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,
)