feat: chat folders, attachments, treeview backend - migration, models, routes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user