diff --git a/app/plugins/builtins/ai_assistant/migrations/0002_folders_attachments.sql b/app/plugins/builtins/ai_assistant/migrations/0002_folders_attachments.sql new file mode 100644 index 0000000..edfe247 --- /dev/null +++ b/app/plugins/builtins/ai_assistant/migrations/0002_folders_attachments.sql @@ -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); diff --git a/app/plugins/builtins/ai_assistant/models.py b/app/plugins/builtins/ai_assistant/models.py index f4d9606..8f7650c 100644 --- a/app/plugins/builtins/ai_assistant/models.py +++ b/app/plugins/builtins/ai_assistant/models.py @@ -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) diff --git a/app/plugins/builtins/ai_assistant/plugin.py b/app/plugins/builtins/ai_assistant/plugin.py index fcdc6f3..e4c234c 100644 --- a/app/plugins/builtins/ai_assistant/plugin.py +++ b/app/plugins/builtins/ai_assistant/plugin.py @@ -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", diff --git a/app/plugins/builtins/ai_assistant/routes.py b/app/plugins/builtins/ai_assistant/routes.py index f72e331..ff2c955 100644 --- a/app/plugins/builtins/ai_assistant/routes.py +++ b/app/plugins/builtins/ai_assistant/routes.py @@ -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, + ) diff --git a/app/plugins/builtins/ai_assistant/schemas.py b/app/plugins/builtins/ai_assistant/schemas.py index 38d861e..52f1e28 100644 --- a/app/plugins/builtins/ai_assistant/schemas.py +++ b/app/plugins/builtins/ai_assistant/schemas.py @@ -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): diff --git a/app/plugins/builtins/ai_assistant/services.py b/app/plugins/builtins/ai_assistant/services.py index e4ca90c..c9e1a44 100644 --- a/app/plugins/builtins/ai_assistant/services.py +++ b/app/plugins/builtins/ai_assistant/services.py @@ -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: