feat: chat folders, attachments, treeview backend - migration, models, routes
This commit is contained in:
@@ -6,8 +6,8 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -15,6 +15,8 @@ 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,
|
||||
@@ -29,12 +31,18 @@ from app.plugins.builtins.ai_assistant.schemas import (
|
||||
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,
|
||||
@@ -572,3 +580,187 @@ async def chat_stream(
|
||||
"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 and update_data["parent_id"]:
|
||||
update_data["parent_id"] = uuid.UUID(update_data["parent_id"])
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user