feat: chat folders, attachments, treeview backend - migration, models, routes

This commit is contained in:
Agent Zero
2026-07-17 09:15:39 +02:00
parent 50650f5b17
commit 9a29206190
6 changed files with 343 additions and 3 deletions
@@ -0,0 +1,34 @@
-- AI Assistant plugin migration 0002: chat folders + attachments
CREATE TABLE IF NOT EXISTS ai_chat_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
parent_id UUID REFERENCES ai_chat_folders(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_ai_folders_user ON ai_chat_folders(user_id);
CREATE INDEX IF NOT EXISTS ix_ai_folders_tenant ON ai_chat_folders(tenant_id);
CREATE INDEX IF NOT EXISTS ix_ai_folders_parent ON ai_chat_folders(parent_id);
ALTER TABLE ai_chat_sessions ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES ai_chat_folders(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS ai_chat_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID REFERENCES ai_chat_messages(id) ON DELETE CASCADE,
session_id UUID NOT NULL REFERENCES ai_chat_sessions(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(255) NOT NULL DEFAULT 'application/octet-stream',
size_bytes INTEGER NOT NULL DEFAULT 0,
storage_path VARCHAR(1024) NOT NULL,
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_ai_attachments_message ON ai_chat_attachments(message_id);
CREATE INDEX IF NOT EXISTS ix_ai_attachments_session ON ai_chat_attachments(session_id);
CREATE INDEX IF NOT EXISTS ix_ai_attachments_tenant ON ai_chat_attachments(tenant_id);
@@ -176,3 +176,58 @@ class AIChatMessage(Base, TenantMixin):
tool_results: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
model_used: Mapped[str] = mapped_column(String(200), nullable=False, default="")
# --- Chat Folders ---
class AIChatFolder(Base, TenantMixin):
"""Folder for organizing chat sessions."""
__tablename__ = "ai_chat_folders"
__table_args__ = (
Index("ix_ai_folders_user", "user_id"),
Index("ix_ai_folders_tenant", "tenant_id"),
Index("ix_ai_folders_parent", "parent_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("ai_chat_folders.id", ondelete="CASCADE"),
nullable=True,
)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
# --- Chat Attachments ---
class AIChatAttachment(Base, TenantMixin):
"""File attached to a chat message."""
__tablename__ = "ai_chat_attachments"
__table_args__ = (
Index("ix_ai_attachments_message", "message_id"),
Index("ix_ai_attachments_session", "session_id"),
Index("ix_ai_attachments_tenant", "tenant_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
message_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("ai_chat_messages.id", ondelete="CASCADE"),
nullable=True,
)
session_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("ai_chat_sessions.id", ondelete="CASCADE"),
nullable=False,
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str] = mapped_column(String(255), nullable=False, default="application/octet-stream")
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
+1 -1
View File
@@ -31,7 +31,7 @@ class AIAssistantPlugin(BasePlugin):
),
],
events=[],
migrations=["0001_initial.sql"],
migrations=["0001_initial.sql", "0002_folders_attachments.sql"],
permissions=[
"ai:read",
"ai:write",
+194 -2
View File
@@ -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,
)
@@ -169,6 +169,7 @@ class ChatSessionUpdate(BaseModel):
title: str | None = Field(None, max_length=255)
is_pinned: bool | None = None
agent_id: str | None = None
folder_id: str | None = None
class ChatSessionResponse(BaseModel):
@@ -179,6 +180,7 @@ class ChatSessionResponse(BaseModel):
title: str
is_pinned: bool
is_sidebar: bool
folder_id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -203,6 +205,39 @@ class ChatSendRequest(BaseModel):
agent_id: str | None = None # override session agent
# ─── Folders ───
class ChatFolderCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
parent_id: str | None = None
class ChatFolderUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
parent_id: str | None = None
class ChatFolderResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
parent_id: str | None = None
user_id: str
created_at: datetime | None = None
# ─── Attachments ───
class ChatAttachmentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
message_id: str | None = None
session_id: str
filename: str
mime_type: str
size_bytes: int
# ─── Tools ───
class AIToolResponse(BaseModel):
@@ -19,6 +19,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.permissions import check_permission
from app.plugins.builtins.ai_assistant.models import (
AIAgent,
AIChatAttachment,
AIChatFolder,
AIChatMessage,
AIChatSession,
AIModel,
@@ -113,6 +115,7 @@ def session_to_response(session: AIChatSession) -> dict[str, Any]:
"title": session.title,
"is_pinned": session.is_pinned,
"is_sidebar": session.is_sidebar,
"folder_id": str(session.folder_id) if hasattr(session, 'folder_id') and session.folder_id else None,
"created_at": session.created_at.isoformat() if session.created_at else None,
"updated_at": session.updated_at.isoformat() if session.updated_at else None,
}
@@ -132,6 +135,27 @@ def message_to_response(msg: AIChatMessage) -> dict[str, Any]:
}
def folder_to_response(folder: AIChatFolder) -> dict[str, Any]:
return {
"id": str(folder.id),
"name": folder.name,
"parent_id": str(folder.parent_id) if folder.parent_id else None,
"user_id": str(folder.user_id),
"created_at": folder.created_at.isoformat() if folder.created_at else None,
}
def attachment_to_response(att: AIChatAttachment) -> dict[str, Any]:
return {
"id": str(att.id),
"message_id": str(att.message_id) if att.message_id else None,
"session_id": str(att.session_id),
"filename": att.filename,
"mime_type": att.mime_type,
"size_bytes": att.size_bytes,
}
# ─── Provider/Model/Preset/Agent CRUD ───
async def get_default_provider(db: AsyncSession, tenant_id: uuid.UUID) -> AIProvider | None: