"""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, )