diff --git a/backend/app/main.py b/backend/app/main.py index b5922d2..572bd95 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -29,6 +29,8 @@ from app.routers import ( agent_skills_router, agent_sessions_router, terminal_router, + tools_router, + remote_mount_router, ) from app.routers.scheduler import router as scheduler_router from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status @@ -129,6 +131,8 @@ app.include_router(marketplace_router) app.include_router(agent_skills_router) app.include_router(agent_sessions_router) app.include_router(terminal_router) +app.include_router(tools_router) +app.include_router(remote_mount_router) @app.get("/api/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 979c8ce..d023f33 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,7 +2,17 @@ from app.models.base import Base from app.models.user import User from app.models.folder import Folder from app.models.document import Document, DocumentChunk -from app.models.task import Task, TaskHistory +from app.models.task import ( + Task, + TaskAssigneeType, + TaskDispatchStatus, + TaskHistory, + TaskPriority, + TaskQuadrant, + TaskSource, + TaskStatus, + TaskSubTask, +) from app.models.forum import ForumPost, ForumReply from app.models.agent import Agent, AgentMessage from app.models.conversation import Conversation, Message @@ -23,6 +33,7 @@ from app.models.reminder import Reminder, ReminderStatus from app.models.goal import Goal, GoalStatus from app.models.skill import Skill from app.models.log import Log, LogType, LogLevel +from app.models.remote_mount import RemoteMount, RemoteSyncItem __all__ = [ "Base", @@ -31,7 +42,14 @@ __all__ = [ "Document", "DocumentChunk", "Task", + "TaskSubTask", "TaskHistory", + "TaskStatus", + "TaskPriority", + "TaskSource", + "TaskQuadrant", + "TaskAssigneeType", + "TaskDispatchStatus", "ForumPost", "ForumReply", "Agent", @@ -61,4 +79,6 @@ __all__ = [ "Log", "LogType", "LogLevel", + "RemoteMount", + "RemoteSyncItem", ] diff --git a/backend/app/models/remote_mount.py b/backend/app/models/remote_mount.py new file mode 100644 index 0000000..cbee7f4 --- /dev/null +++ b/backend/app/models/remote_mount.py @@ -0,0 +1,34 @@ +from sqlalchemy import Boolean, Column, ForeignKey, String, Text, UniqueConstraint + +from app.models.base import BaseModel + + +class RemoteMount(BaseModel): + __tablename__ = "remote_mounts" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_remote_mount_user_name"), + ) + + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + name = Column(String(255), nullable=False) + mount_type = Column(String(32), nullable=False, default="webdav") + base_url = Column(String(1000), nullable=False) + username = Column(String(255), nullable=True) + password_encrypted = Column(Text, nullable=True) + root_path = Column(String(1000), nullable=False, default="/") + is_active = Column(Boolean, nullable=False, default=True) + last_sync_at = Column(String(64), nullable=True) + + +class RemoteSyncItem(BaseModel): + __tablename__ = "remote_sync_items" + + mount_id = Column(String(36), ForeignKey("remote_mounts.id"), nullable=False, index=True) + remote_path = Column(String(2000), nullable=False) + remote_etag = Column(String(512), nullable=True) + remote_modified_at = Column(String(128), nullable=True) + local_folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True) + local_document_id = Column(String(36), ForeignKey("documents.id"), nullable=True) + sync_status = Column(String(32), nullable=False, default="synced") + last_error = Column(Text, nullable=True) + last_synced_at = Column(String(64), nullable=True) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 1c70869..6096964 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -21,3 +21,5 @@ from app.routers.plugins import _marketplace_router as marketplace_router from app.routers.agent_skills import router as agent_skills_router from app.routers.agent_sessions import router as agent_sessions_router from app.routers.terminal import router as terminal_router +from app.routers.tools import router as tools_router +from app.routers.remote_mount import router as remote_mount_router diff --git a/backend/app/routers/folder.py b/backend/app/routers/folder.py index f5b3c59..b45dd8b 100644 --- a/backend/app/routers/folder.py +++ b/backend/app/routers/folder.py @@ -1,17 +1,20 @@ from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ from typing import List +import shutil + from app.database import get_db from app.models.folder import Folder from app.models.user import User -from app.schemas.folder import FolderCreate, FolderUpdate, FolderOut, FolderTreeOut from app.routers.auth import get_current_user +from app.schemas.folder import FolderCreate, FolderOut, FolderTreeOut, FolderUpdate +from app.services.document_service import DocumentService router = APIRouter(prefix="/api/folders", tags=["文件夹"]) + def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]: - """递归构建文件夹树""" tree = [] for folder in folders: if folder.parent_id == parent_id: @@ -20,30 +23,29 @@ def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[Fold id=folder.id, name=folder.name, parent_id=folder.parent_id, - children=children + children=children, )) return tree + @router.get("", response_model=List[FolderTreeOut]) async def get_folders( db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), ): - """获取用户的完整文件夹树""" result = await db.execute( select(Folder).where(Folder.user_id == current_user.id) ) folders = result.scalars().all() return build_folder_tree(list(folders)) + @router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED) async def create_folder( folder_data: FolderCreate, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), ): - """创建文件夹""" - # 验证父文件夹存在且属于当前用户 if folder_data.parent_id: result = await db.execute( select(Folder).where( @@ -53,13 +55,12 @@ async def create_folder( if not result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="父文件夹不存在") - # 检查同名文件夹 result = await db.execute( select(Folder).where( and_( Folder.user_id == current_user.id, Folder.parent_id == folder_data.parent_id, - Folder.name == folder_data.name + Folder.name == folder_data.name, ) ) ) @@ -69,21 +70,24 @@ async def create_folder( folder = Folder( user_id=current_user.id, name=folder_data.name, - parent_id=folder_data.parent_id + parent_id=folder_data.parent_id, ) db.add(folder) await db.commit() await db.refresh(folder) + + document_service = DocumentService(db, current_user.id) + await document_service.ensure_folder_directory(current_user.id, folder.id) return folder + @router.put("/{folder_id}", response_model=FolderOut) async def rename_folder( folder_id: str, folder_data: FolderUpdate, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), ): - """重命名文件夹""" result = await db.execute( select(Folder).where( and_(Folder.id == folder_id, Folder.user_id == current_user.id) @@ -93,18 +97,22 @@ async def rename_folder( if not folder: raise HTTPException(status_code=404, detail="文件夹不存在") + old_name = folder.name folder.name = folder_data.name + + document_service = DocumentService(db, current_user.id) + await document_service.rename_folder_directory(current_user.id, folder.id, old_name, folder_data.name) await db.commit() await db.refresh(folder) return folder + @router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_folder( folder_id: str, db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) + current_user: User = Depends(get_current_user), ): - """删除文件夹(级联删除文档)""" from app.models.document import Document from app.services.knowledge_service import KnowledgeService @@ -117,15 +125,16 @@ async def delete_folder( if not folder: raise HTTPException(status_code=404, detail="文件夹不存在") + document_service = DocumentService(db, current_user.id) + folder_path = await document_service._get_storage_directory(current_user.id, folder_id) + async def delete_recursive(fid: str): - # 删除子文件夹(先递归) children = await db.execute( select(Folder).where(Folder.parent_id == fid) ) for child in children.scalars(): await delete_recursive(child.id) - # 删除文档 docs = await db.execute( select(Document).where(Document.folder_id == fid) ) @@ -134,10 +143,12 @@ async def delete_folder( await knowledge_service.delete_from_vectorstore(current_user.id, doc.id) await db.delete(doc) - # 删除文件夹本身 folder_to_delete = await db.get(Folder, fid) if folder_to_delete: await db.delete(folder_to_delete) await delete_recursive(folder_id) await db.commit() + + if folder_path.exists(): + shutil.rmtree(folder_path, ignore_errors=True) diff --git a/backend/app/routers/remote_mount.py b/backend/app/routers/remote_mount.py new file mode 100644 index 0000000..73d757e --- /dev/null +++ b/backend/app/routers/remote_mount.py @@ -0,0 +1,130 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.remote_mount import RemoteMount +from app.models.user import User +from app.routers.auth import get_current_user +from app.schemas.remote_mount import ( + RemoteMountCreate, + RemoteMountOut, + RemoteMountTreeOut, + RemoteNodeOut, + RemoteSyncRequest, + RemoteSyncResultOut, +) +from app.services.remote_sync_service import RemoteSyncService +from app.services.secret_service import encrypt_secret +from app.services.webdav_service import WebDavNode, WebDavService + +router = APIRouter(prefix="/api/remote-mounts", tags=["远程挂载"]) + + +def _to_node_out(node: WebDavNode) -> RemoteNodeOut: + return RemoteNodeOut( + path=node.path, + name=node.name, + is_dir=node.is_dir, + size=node.size, + modified_at=node.modified_at, + etag=node.etag, + children=[_to_node_out(child) for child in node.children], + ) + + +@router.get("", response_model=list[RemoteMountOut]) +async def list_remote_mounts( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(RemoteMount).where(RemoteMount.user_id == current_user.id).order_by(RemoteMount.created_at.desc()) + ) + return list(result.scalars().all()) + + +@router.post("", response_model=RemoteMountOut, status_code=status.HTTP_201_CREATED) +async def create_remote_mount( + payload: RemoteMountCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + existing = await db.execute( + select(RemoteMount).where(and_(RemoteMount.user_id == current_user.id, RemoteMount.name == payload.name)) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="同名远程挂载已存在") + + mount = RemoteMount( + user_id=current_user.id, + name=payload.name, + mount_type="webdav", + base_url=str(payload.base_url), + username=payload.username, + password_encrypted=encrypt_secret(payload.password), + root_path=payload.root_path, + is_active=True, + ) + try: + await WebDavService(mount).list_directory(payload.root_path) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"WebDAV 连接失败: {exc}") from exc + + db.add(mount) + await db.commit() + await db.refresh(mount) + return mount + + +async def _get_user_mount(db: AsyncSession, user_id: str, mount_id: str) -> RemoteMount: + result = await db.execute( + select(RemoteMount).where(and_(RemoteMount.id == mount_id, RemoteMount.user_id == user_id)) + ) + mount = result.scalar_one_or_none() + if mount is None: + raise HTTPException(status_code=404, detail="远程挂载不存在") + return mount + + +@router.get("/{mount_id}/tree", response_model=RemoteMountTreeOut) +async def get_remote_tree( + mount_id: str, + path: str | None = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + mount = await _get_user_mount(db, current_user.id, mount_id) + try: + nodes = await WebDavService(mount).list_tree(path or mount.root_path) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=f"远程目录读取失败: {exc}") from exc + + return RemoteMountTreeOut( + mount_id=mount.id, + root_path=path or mount.root_path, + nodes=[_to_node_out(node) for node in nodes], + ) + + +@router.post("/{mount_id}/sync", response_model=RemoteSyncResultOut) +async def sync_remote_mount( + mount_id: str, + payload: RemoteSyncRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + mount = await _get_user_mount(db, current_user.id, mount_id) + try: + result = await RemoteSyncService(db, current_user.id).sync_remote_path( + mount, + payload.remote_path, + payload.local_folder_id, + payload.mode, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=f"远程同步失败: {exc}") from exc + + return RemoteSyncResultOut(**result) diff --git a/backend/app/schemas/remote_mount.py b/backend/app/schemas/remote_mount.py new file mode 100644 index 0000000..4830560 --- /dev/null +++ b/backend/app/schemas/remote_mount.py @@ -0,0 +1,58 @@ +from datetime import datetime +from pydantic import BaseModel, Field, HttpUrl + + +class RemoteMountCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + base_url: HttpUrl + username: str | None = Field(default=None, max_length=255) + password: str | None = Field(default=None, max_length=2000) + root_path: str = Field(default="/", min_length=1, max_length=1000) + + +class RemoteMountOut(BaseModel): + id: str + name: str + mount_type: str + base_url: str + username: str | None + root_path: str + is_active: bool + last_sync_at: str | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class RemoteNodeOut(BaseModel): + path: str + name: str + is_dir: bool + size: int | None = None + modified_at: str | None = None + etag: str | None = None + children: list["RemoteNodeOut"] = [] + + +class RemoteMountTreeOut(BaseModel): + mount_id: str + root_path: str + nodes: list[RemoteNodeOut] + + +class RemoteSyncRequest(BaseModel): + remote_path: str = Field(..., min_length=1, max_length=2000) + local_folder_id: str = Field(..., min_length=1, max_length=36) + mode: str = Field(default="file", pattern="^(file|folder)$") + + +class RemoteSyncResultOut(BaseModel): + synced: int + skipped: int + failed: int + document_ids: list[str] + errors: list[str] + + +RemoteNodeOut.model_rebuild() diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 652347b..c98811a 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -5,6 +5,7 @@ from pathlib import Path import tempfile +import shutil from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from fastapi import UploadFile @@ -18,7 +19,6 @@ import json import os import re import aiofiles -import uuid from dataclasses import dataclass, field @@ -52,9 +52,9 @@ class DocumentService: if ext not in ALLOWED_EXTENSIONS: raise ValueError(f"不支持的文件类型: {ext}") - os.makedirs(settings.UPLOAD_DIR, exist_ok=True) - file_id = str(uuid.uuid4()) - file_path = os.path.join(settings.UPLOAD_DIR, f"{file_id}{ext}") + folder_path = await self._get_storage_directory(user_id, folder_id) + folder_path.mkdir(parents=True, exist_ok=True) + file_path = self._resolve_unique_file_path(folder_path, file.filename) content = await file.read() file_size = len(content) @@ -64,7 +64,7 @@ class DocumentService: async with aiofiles.open(file_path, "wb") as f: await f.write(content) - parsed = await self._parse_document(file_path, ext) + parsed = await self._parse_document(str(file_path), ext) parsed.structured_markdown = self._render_structured_markdown(parsed) doc = Document( @@ -73,7 +73,7 @@ class DocumentService: filename=file.filename, file_type=ext[1:], file_size=file_size, - file_path=file_path, + file_path=str(file_path), summary=parsed.summary[:500] if len(parsed.summary) > 500 else parsed.summary, folder_id=folder_id, ingestion_status="uploaded", @@ -171,6 +171,83 @@ class DocumentService: return "/" + "/".join(path_parts) if path_parts else None + async def ensure_folder_directory(self, user_id: str, folder_id: str | None) -> Path: + folder_path = await self._get_storage_directory(user_id, folder_id) + folder_path.mkdir(parents=True, exist_ok=True) + return folder_path + + async def delete_folder_directory(self, user_id: str, folder_id: str) -> None: + folder_path = await self._get_storage_directory(user_id, folder_id) + if folder_path.exists(): + shutil.rmtree(folder_path, ignore_errors=True) + + async def rename_folder_directory(self, user_id: str, folder_id: str, old_name: str, new_name: str) -> None: + folder = await self.db.get(Folder, folder_id) + if folder is None: + return + + parent_path = await self._get_storage_directory(user_id, folder.parent_id) + old_path = parent_path / self._sanitize_storage_name(old_name) + new_path = parent_path / self._sanitize_storage_name(new_name) + + if old_path != new_path: + parent_path.mkdir(parents=True, exist_ok=True) + if old_path.exists(): + old_path.rename(new_path) + else: + new_path.mkdir(parents=True, exist_ok=True) + else: + new_path.mkdir(parents=True, exist_ok=True) + + document_result = await self.db.execute( + select(Document).where(Document.user_id == user_id) + ) + for document in document_result.scalars().all(): + try: + relative_path = Path(document.file_path).relative_to(old_path) + except ValueError: + continue + document.file_path = str(new_path / relative_path) + + async def _get_storage_directory(self, user_id: str, folder_id: str | None) -> Path: + base_path = Path(settings.UPLOAD_DIR) / user_id + if not folder_id: + return base_path + + folders = await self.db.execute( + select(Folder).where(Folder.user_id == user_id) + ) + folder_map = {folder.id: folder for folder in folders.scalars().all()} + + path_segments: list[str] = [] + current_id = folder_id + while current_id: + folder = folder_map.get(current_id) + if folder is None: + raise ValueError("鐖舵枃浠跺す涓嶅瓨鍦?") + path_segments.insert(0, self._sanitize_storage_name(folder.name)) + current_id = folder.parent_id + + return base_path.joinpath(*path_segments) + + def _resolve_unique_file_path(self, directory: Path, original_name: str) -> Path: + safe_name = self._sanitize_storage_name(Path(original_name).name, is_file=True) + stem = Path(safe_name).stem + suffix = Path(safe_name).suffix + candidate = directory / safe_name + counter = 2 + while candidate.exists(): + candidate = directory / f"{stem}-{counter}{suffix}" + counter += 1 + return candidate + + def _sanitize_storage_name(self, name: str, is_file: bool = False) -> str: + invalid_chars = '<>:"/\\|?*' + sanitized = ''.join('_' if char in invalid_chars or ord(char) < 32 else char for char in name).strip().rstrip('.') + if not sanitized: + return 'untitled' if is_file else 'folder' + return sanitized + async def delete_document(self, user_id: str, document_id: str): result = await self.db.execute( select(Document).where( diff --git a/backend/app/services/remote_sync_service.py b/backend/app/services/remote_sync_service.py new file mode 100644 index 0000000..b1dbdba --- /dev/null +++ b/backend/app/services/remote_sync_service.py @@ -0,0 +1,108 @@ +from io import BytesIO +from datetime import UTC, datetime + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.datastructures import UploadFile + +from app.models.folder import Folder +from app.models.remote_mount import RemoteMount, RemoteSyncItem +from app.services.document_service import DocumentService +from app.services.webdav_service import WebDavNode, WebDavService + + +class RemoteSyncService: + def __init__(self, db: AsyncSession, user_id: str): + self.db = db + self.user_id = user_id + + async def sync_remote_path( + self, + mount: RemoteMount, + remote_path: str, + local_folder_id: str, + mode: str = "file", + ) -> dict: + folder = await self.db.execute( + select(Folder).where(and_(Folder.id == local_folder_id, Folder.user_id == self.user_id)) + ) + if folder.scalar_one_or_none() is None: + raise ValueError("本地目标文件夹不存在") + + webdav = WebDavService(mount) + document_service = DocumentService(self.db, self.user_id) + + synced = 0 + skipped = 0 + failed = 0 + document_ids: list[str] = [] + errors: list[str] = [] + + if mode == "folder": + nodes = await webdav.list_tree(remote_path) + targets = self._flatten_files(nodes) + else: + name = remote_path.rstrip("/").split("/")[-1] or "remote-file" + targets = [WebDavNode(path=remote_path, name=name, is_dir=False)] + + for node in targets: + try: + content, filename = await webdav.download_file(node.path) + upload = UploadFile(filename=filename, file=BytesIO(content)) + document = await document_service.upload_document(self.user_id, upload, folder_id=local_folder_id) + await self._upsert_sync_item(mount.id, node, local_folder_id, document.id) + document_ids.append(document.id) + synced += 1 + except Exception as exc: # noqa: BLE001 + failed += 1 + errors.append(f"{node.path}: {exc}") + await self._upsert_sync_item(mount.id, node, local_folder_id, None, status="failed", error=str(exc)) + + mount.last_sync_at = datetime.now(UTC).isoformat() + await self.db.commit() + return { + "synced": synced, + "skipped": skipped, + "failed": failed, + "document_ids": document_ids, + "errors": errors, + } + + def _flatten_files(self, nodes: list[WebDavNode]) -> list[WebDavNode]: + results: list[WebDavNode] = [] + for node in nodes: + if node.is_dir: + results.extend(self._flatten_files(node.children)) + else: + results.append(node) + return results + + async def _upsert_sync_item( + self, + mount_id: str, + node: WebDavNode, + local_folder_id: str, + local_document_id: str | None, + status: str = "synced", + error: str | None = None, + ) -> None: + result = await self.db.execute( + select(RemoteSyncItem).where( + and_(RemoteSyncItem.mount_id == mount_id, RemoteSyncItem.remote_path == node.path) + ) + ) + sync_item = result.scalar_one_or_none() + if sync_item is None: + sync_item = RemoteSyncItem( + mount_id=mount_id, + remote_path=node.path, + ) + self.db.add(sync_item) + + sync_item.remote_etag = node.etag + sync_item.remote_modified_at = node.modified_at + sync_item.local_folder_id = local_folder_id + sync_item.local_document_id = local_document_id + sync_item.sync_status = status + sync_item.last_error = error + sync_item.last_synced_at = datetime.now(UTC).isoformat() diff --git a/backend/app/services/secret_service.py b/backend/app/services/secret_service.py new file mode 100644 index 0000000..6fa3789 --- /dev/null +++ b/backend/app/services/secret_service.py @@ -0,0 +1,24 @@ +import base64 +import hashlib + +from cryptography.fernet import Fernet + +from app.config import settings + + +def _build_fernet() -> Fernet: + digest = hashlib.sha256(settings.SECRET_KEY.encode("utf-8")).digest() + key = base64.urlsafe_b64encode(digest) + return Fernet(key) + + +def encrypt_secret(value: str | None) -> str | None: + if not value: + return None + return _build_fernet().encrypt(value.encode("utf-8")).decode("utf-8") + + +def decrypt_secret(value: str | None) -> str | None: + if not value: + return None + return _build_fernet().decrypt(value.encode("utf-8")).decode("utf-8") diff --git a/backend/app/services/webdav_service.py b/backend/app/services/webdav_service.py new file mode 100644 index 0000000..257bc47 --- /dev/null +++ b/backend/app/services/webdav_service.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass, field +from urllib.parse import quote, urljoin +import xml.etree.ElementTree as ET + +import httpx + +from app.models.remote_mount import RemoteMount +from app.services.secret_service import decrypt_secret + + +WEBDAV_NAMESPACE = { + "d": "DAV:", +} + + +@dataclass +class WebDavNode: + path: str + name: str + is_dir: bool + size: int | None = None + modified_at: str | None = None + etag: str | None = None + children: list["WebDavNode"] = field(default_factory=list) + + +class WebDavService: + def __init__(self, mount: RemoteMount): + self.mount = mount + self.username = mount.username or None + self.password = decrypt_secret(mount.password_encrypted) + + def _normalize_remote_path(self, remote_path: str | None = None) -> str: + path = remote_path or self.mount.root_path or "/" + if not path.startswith("/"): + path = f"/{path}" + return path + + def _build_url(self, remote_path: str | None = None) -> str: + path = self._normalize_remote_path(remote_path) + encoded = "/".join(quote(segment) for segment in path.split("/") if segment) + if not encoded: + return self.mount.base_url.rstrip("/") + "/" + return urljoin(self.mount.base_url.rstrip("/") + "/", encoded) + + async def list_directory(self, remote_path: str | None = None) -> list[WebDavNode]: + path = self._normalize_remote_path(remote_path) + body = """ + + + + + + + + +""" + async with httpx.AsyncClient(timeout=30.0, auth=self._auth()) as client: + response = await client.request( + "PROPFIND", + self._build_url(path), + headers={"Depth": "1", "Content-Type": "application/xml"}, + content=body, + ) + response.raise_for_status() + return self._parse_propfind(path, response.text) + + async def list_tree(self, remote_path: str | None = None, max_depth: int = 4) -> list[WebDavNode]: + path = self._normalize_remote_path(remote_path) + nodes = await self.list_directory(path) + if max_depth <= 1: + return nodes + + for node in nodes: + if node.is_dir: + node.children = await self.list_tree(node.path, max_depth=max_depth - 1) + return nodes + + async def download_file(self, remote_path: str) -> tuple[bytes, str]: + normalized = self._normalize_remote_path(remote_path) + async with httpx.AsyncClient(timeout=120.0, auth=self._auth()) as client: + response = await client.get(self._build_url(normalized)) + response.raise_for_status() + name = normalized.rstrip("/").split("/")[-1] or "remote-file" + return response.content, name + + def _auth(self) -> httpx.BasicAuth | None: + if self.username and self.password: + return httpx.BasicAuth(self.username, self.password) + return None + + def _parse_propfind(self, parent_path: str, payload: str) -> list[WebDavNode]: + root = ET.fromstring(payload) + nodes: list[WebDavNode] = [] + + for response in root.findall("d:response", WEBDAV_NAMESPACE): + href = response.findtext("d:href", default="", namespaces=WEBDAV_NAMESPACE) + if not href: + continue + + normalized_href = "/" + href.split("://", 1)[-1].split("/", 1)[-1].strip("/") + normalized_href = "/" if normalized_href == "/" else normalized_href.rstrip("/") + normalized_parent = self._normalize_remote_path(parent_path).rstrip("/") or "/" + if normalized_href.rstrip("/") == normalized_parent.rstrip("/"): + continue + + prop = response.find("d:propstat/d:prop", WEBDAV_NAMESPACE) + if prop is None: + continue + + is_dir = prop.find("d:resourcetype/d:collection", WEBDAV_NAMESPACE) is not None + display_name = prop.findtext("d:displayname", default="", namespaces=WEBDAV_NAMESPACE) or normalized_href.split("/")[-1] + size_text = prop.findtext("d:getcontentlength", default="", namespaces=WEBDAV_NAMESPACE) + etag = prop.findtext("d:getetag", default=None, namespaces=WEBDAV_NAMESPACE) + modified_at = prop.findtext("d:getlastmodified", default=None, namespaces=WEBDAV_NAMESPACE) + + nodes.append(WebDavNode( + path=normalized_href, + name=display_name, + is_dir=is_dir, + size=int(size_text) if size_text.isdigit() else None, + etag=etag, + modified_at=modified_at, + )) + + nodes.sort(key=lambda item: (not item.is_dir, item.name.lower())) + return nodes diff --git a/backend/tests/backend/app/services/test_document_service.py b/backend/tests/backend/app/services/test_document_service.py index bba48b8..f33f0b8 100644 --- a/backend/tests/backend/app/services/test_document_service.py +++ b/backend/tests/backend/app/services/test_document_service.py @@ -15,6 +15,7 @@ from starlette.datastructures import UploadFile import app.models # noqa: F401 from app.database import Base from app.models.document import Document, DocumentChunk +from app.models.folder import Folder from app.models.user import User from app.services.auth_service import get_password_hash from app.services.document_service import DocumentService @@ -199,6 +200,29 @@ async def test_upload_document_persists_structured_metadata_json(document_test_e assert stored_document.normalized_content == 'title\n\nplain text body for metadata storage' +@pytest.mark.asyncio +async def test_upload_document_stores_file_in_nested_folder_with_original_name(document_test_env): + session, user = document_test_env + service = DocumentService(session) + + root = Folder(user_id=user.id, name='Projects') + session.add(root) + await session.flush() + child = Folder(user_id=user.id, name='Specs', parent_id=root.id) + session.add(child) + await session.commit() + await session.refresh(child) + + upload = UploadFile(filename='system-design.md', file=BytesIO(b'# Design')) + document = await service.upload_document(user.id, upload, folder_id=child.id) + + file_path = Path(document.file_path) + assert file_path.name == 'system-design.md' + assert file_path.parent.name == 'Specs' + assert file_path.parent.parent.name == 'Projects' + assert file_path.exists() + + @pytest.mark.asyncio async def test_upload_document_extracts_docx_heading_and_table_structure(document_test_env): session, user = document_test_env diff --git a/backend/tests/backend/app/services/test_webdav_service.py b/backend/tests/backend/app/services/test_webdav_service.py new file mode 100644 index 0000000..2d249d2 --- /dev/null +++ b/backend/tests/backend/app/services/test_webdav_service.py @@ -0,0 +1,39 @@ +from app.models.remote_mount import RemoteMount +from app.services.secret_service import encrypt_secret +from app.services.webdav_service import WebDavService + + +def test_parse_propfind_returns_sorted_nodes(): + mount = RemoteMount( + user_id='user-1', + name='Docs', + mount_type='webdav', + base_url='https://example.com/dav/', + username='alice', + password_encrypted=encrypt_secret('secret'), + root_path='/knowledge', + is_active=True, + ) + payload = """ + + + /knowledge/ + knowledge + + + /knowledge/specs/ + specs + + + /knowledge/roadmap.md + roadmap.md128"etag-1"Wed, 09 Apr 2026 10:00:00 GMT + +""" + + nodes = WebDavService(mount)._parse_propfind('/knowledge', payload) + + assert [node.name for node in nodes] == ['specs', 'roadmap.md'] + assert nodes[0].is_dir is True + assert nodes[1].is_dir is False + assert nodes[1].size == 128 + assert nodes[1].etag == '"etag-1"' diff --git a/backend/tests/backend/app/test_folder_router.py b/backend/tests/backend/app/test_folder_router.py new file mode 100644 index 0000000..eeb48f1 --- /dev/null +++ b/backend/tests/backend/app/test_folder_router.py @@ -0,0 +1,90 @@ +from httpx import ASGITransport, AsyncClient +from pathlib import Path + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +import app.models # noqa: F401 +from app.database import Base, get_db +from app.main import app +from app.models.folder import Folder +from app.models.user import User +from app.routers.auth import get_current_user +from app.services.auth_service import get_password_hash + + +@pytest.fixture +async def folder_router_env(tmp_path, monkeypatch): + db_path = tmp_path / 'test_folders_router.db' + upload_dir = tmp_path / 'uploads' + engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with session_factory() as session: + user = User( + email='folders@example.com', + hashed_password=get_password_hash('secret123'), + full_name='Folder Tester', + ) + session.add(user) + await session.commit() + await session.refresh(user) + + monkeypatch.setattr('app.services.document_service.settings.UPLOAD_DIR', str(upload_dir)) + + async def override_get_db(): + async with session_factory() as session: + yield session + + async def override_get_current_user(): + return user + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_current_user] = override_get_current_user + + try: + yield user, upload_dir, session_factory + finally: + app.dependency_overrides.clear() + await engine.dispose() + + +@pytest.mark.asyncio +async def test_create_folder_creates_matching_local_directory(folder_router_env): + user, upload_dir, _session_factory = folder_router_env + transport = ASGITransport(app=app) + + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + response = await client.post('/api/folders', json={'name': 'Projects', 'parent_id': None}) + + assert response.status_code == 201 + folder_id = response.json()['id'] + + expected_path = upload_dir / user.id / 'Projects' + assert expected_path.exists() + assert expected_path.is_dir() + assert folder_id + + +@pytest.mark.asyncio +async def test_rename_folder_moves_local_directory(folder_router_env): + user, upload_dir, session_factory = folder_router_env + + async with session_factory() as session: + folder = Folder(user_id=user.id, name='Old', parent_id=None) + session.add(folder) + await session.commit() + await session.refresh(folder) + + (upload_dir / user.id / 'Old').mkdir(parents=True, exist_ok=True) + transport = ASGITransport(app=app) + + async with AsyncClient(transport=transport, base_url='http://testserver') as client: + response = await client.put(f'/api/folders/{folder.id}', json={'name': 'New'}) + + assert response.status_code == 200 + assert not (upload_dir / user.id / 'Old').exists() + assert (upload_dir / user.id / 'New').exists() diff --git a/frontend/src/api/remoteMount.ts b/frontend/src/api/remoteMount.ts new file mode 100644 index 0000000..fc19242 --- /dev/null +++ b/frontend/src/api/remoteMount.ts @@ -0,0 +1,72 @@ +import api from './index' + +export interface RemoteMountCreate { + name: string + base_url: string + username?: string | null + password?: string | null + root_path?: string +} + +export interface RemoteMount { + id: string + name: string + mount_type: string + base_url: string + username: string | null + root_path: string + is_active: boolean + last_sync_at: string | null + created_at: string + updated_at: string +} + +export interface RemoteNode { + path: string + name: string + is_dir: boolean + size?: number | null + modified_at?: string | null + etag?: string | null + children: RemoteNode[] +} + +export interface RemoteMountTreeResponse { + mount_id: string + root_path: string + nodes: RemoteNode[] +} + +export interface RemoteSyncRequest { + remote_path: string + local_folder_id: string + mode?: 'file' | 'folder' +} + +export interface RemoteSyncResult { + synced: number + skipped: number + failed: number + document_ids: string[] + errors: string[] +} + +export const remoteMountApi = { + list() { + return api.get('/api/remote-mounts') + }, + + create(data: RemoteMountCreate) { + return api.post('/api/remote-mounts', data) + }, + + getTree(mountId: string, path?: string) { + return api.get(`/api/remote-mounts/${mountId}/tree`, { + params: path ? { path } : undefined, + }) + }, + + sync(mountId: string, data: RemoteSyncRequest) { + return api.post(`/api/remote-mounts/${mountId}/sync`, data) + }, +} diff --git a/frontend/src/components/chat/KnowledgeRAG.css b/frontend/src/components/chat/KnowledgeRAG.css new file mode 100644 index 0000000..d4cf4ff --- /dev/null +++ b/frontend/src/components/chat/KnowledgeRAG.css @@ -0,0 +1,935 @@ +/* KnowledgeRAG.css - RAG Panel Styles (Jarvis HUD Style) */ + +.rag-panel-shell { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.rag-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.52); + backdrop-filter: blur(4px); + animation: fadeIn var(--transition-mid); +} + +.rag-panel { + position: relative; + width: 95%; + max-width: 1400px; + height: 90vh; + background: var(--bg-panel); + border: 1px solid var(--border-mid); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + animation: scaleIn var(--transition-mid); + box-shadow: var(--glow-cyan), 0 30px 80px rgba(0, 0, 0, 0.6); + overflow: hidden; +} + +/* Tech corners */ +.rag-panel::before, +.rag-panel::after { + content: ''; + position: absolute; + width: 20px; + height: 20px; + border: 1px solid var(--accent-cyan); + pointer-events: none; +} + +.rag-panel::before { + top: -1px; + left: -1px; + border-right: none; + border-bottom: none; + border-top-left-radius: var(--radius-lg); +} + +.rag-panel::after { + top: -1px; + right: -1px; + border-left: none; + border-bottom: none; + border-top-right-radius: var(--radius-lg); +} + +/* Header */ +.rag-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + border-bottom: 1px solid var(--border-dim); + background: var(--accent-cyan-dim); +} + +.coord-tag { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--accent-cyan); + letter-spacing: 0.15em; +} + +.btn-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid var(--border-dim); + border-radius: var(--radius-sm); + color: var(--text-dim); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-close:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +/* Body - Split Layout */ +.rag-body { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Left Explorer */ +.rag-explorer { + width: 42%; + min-width: 380px; + max-width: 560px; + border-right: 1px solid var(--border-dim); + display: flex; + flex-direction: column; + background: var(--bg-deep); +} + +.explorer-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border-dim); +} + +.toolbar-title { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-display); + font-size: 11px; + color: var(--accent-cyan); + letter-spacing: 0.15em; +} + +.toolbar-actions { + display: flex; + gap: 8px; +} + +.action-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--accent-cyan-dim); + border: 1px solid var(--border-mid); + border-radius: var(--radius-sm); + color: var(--accent-cyan); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.1em; + cursor: pointer; + transition: all var(--transition-fast); +} + +.action-btn:hover { + background: rgba(0, 245, 212, 0.2); + border-color: var(--accent-cyan); +} + +.action-btn.danger { + background: var(--accent-amber-dim); + border-color: rgba(249, 168, 37, 0.3); + color: var(--accent-amber); +} + +.action-btn.danger:hover { + background: rgba(249, 168, 37, 0.2); + border-color: var(--accent-amber); +} + +.hidden-input { + display: none; +} + +.explorer-mode-switch { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--border-dim); +} + +.mode-btn { + padding: 8px 10px; + border: 1px solid var(--border-mid); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.14em; + cursor: pointer; +} + +.mode-btn.active { + color: var(--accent-cyan); + border-color: var(--accent-cyan); + background: var(--accent-cyan-dim); +} + +/* Folder Tree */ +.folder-tree { + flex: 1; + overflow-y: auto; + padding: 8px 0; + min-height: 0; +} + +.remote-tree { + padding-top: 0; +} + +.remote-mount-strip, +.remote-sync-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; +} + +.remote-mount-strip { + border-bottom: 1px solid var(--border-dim); +} + +.remote-mount-select { + flex: 1; + min-width: 0; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-mid); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; +} + +.remote-sync-hint { + justify-content: space-between; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + border-bottom: 1px solid rgba(255,255,255,0.04); +} + +.remote-sync-hint strong { + color: var(--accent-cyan); + font-weight: 500; +} + +.folder-node { + position: relative; +} + +.folder-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-fast); + user-select: none; +} + +.folder-row:hover { + background: var(--accent-cyan-dim); +} + +.remote-row .action-btn.small { + opacity: 0.9; +} + +.folder-row.active { + background: rgba(0, 245, 212, 0.12); + border-left: 2px solid var(--accent-cyan); +} + +.folder-contents { + margin: 2px 10px 8px 0; + padding: 8px 0 0 12px; + border-left: 1px dashed rgba(0, 245, 212, 0.18); +} + +.folder-contents-state { + display: flex; + align-items: center; + gap: 8px; + min-height: 30px; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 10px; +} + +.folder-file-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.folder-row:hover .folder-icon { + color: var(--accent-cyan); +} + +.folder-toggle { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + color: var(--text-dim); + flex-shrink: 0; + cursor: pointer; + transition: color var(--transition-fast); +} + +.folder-toggle:hover { + color: var(--accent-cyan); +} + +.folder-toggle-spacer { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.folder-icon { + width: 18px; + height: 18px; + padding: 2px; + border-radius: 4px; + color: var(--accent-cyan); + background: rgba(0, 245, 212, 0.08); + border: 1px solid rgba(0, 245, 212, 0.16); + flex-shrink: 0; +} + +.folder-row.active .folder-icon { + background: rgba(0, 245, 212, 0.16); + border-color: rgba(0, 245, 212, 0.32); +} + +.folder-name { + flex: 1; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.folder-actions { + display: flex; + gap: 4px; + margin-left: auto; +} + +.action-btn.small { + width: 22px; + height: 22px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.action-btn.primary { + background: var(--accent-cyan-dim); + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.action-btn.primary:hover { + background: rgba(0, 245, 212, 0.2); + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +/* Inline Dialogs */ +.inline-dialog { + padding: 12px 16px; + border-bottom: 1px solid var(--border-dim); + background: var(--bg-panel); +} + +.rag-dialog-overlay { + position: absolute; + inset: 0; + z-index: 20; + background: rgba(0, 0, 0, 0.56); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.rag-dialog-card { + width: min(440px, 100%); + background: var(--bg-panel); + border: 1px solid var(--border-mid); + border-radius: var(--radius-md); + box-shadow: var(--glow-cyan), 0 20px 50px rgba(0, 0, 0, 0.45); + padding: 16px; +} + +.dialog-content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dialog-label { + font-family: var(--font-display); + font-size: 11px; + color: var(--accent-cyan); + letter-spacing: 0.1em; +} + +.dialog-text { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); + margin: 0; + line-height: 1.5; +} + +.dialog-input { + padding: 8px 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-mid); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + outline: none; + transition: border-color var(--transition-fast); +} + +.dialog-input:focus { + border-color: var(--accent-cyan); +} + +.dialog-input::placeholder { + color: var(--text-dim); +} + +.dialog-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.count-badge { + padding: 2px 8px; + background: var(--accent-cyan-dim); + border-radius: 10px; + font-size: 9px; +} + +.loading-state, +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 10px; + gap: 8px; +} + +.file-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + margin-bottom: 4px; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-fast); +} + +.file-item:hover { + background: var(--accent-cyan-dim); + border-color: var(--border-dim); +} + +.file-item.active { + background: var(--accent-cyan-dim); + border-color: var(--border-mid); +} + +.tree-file-row { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 7px 10px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + text-align: left; + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.tree-file-row:hover, +.tree-file-row.active { + background: rgba(0, 245, 212, 0.08); + color: var(--text-primary); +} + +.tree-file-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.04em; +} + +.tree-file-meta { + display: inline-flex; + align-items: center; + gap: 12px; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.tree-file-delete { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 4px; + color: var(--text-dim); +} + +.tree-file-delete:hover { + background: rgba(249, 168, 37, 0.18); + color: var(--accent-amber); +} + +.file-idx { + font-family: var(--font-mono); + font-size: 8px; + color: var(--accent-cyan); + opacity: 0.4; + width: 20px; +} + +.file-info { + flex: 1; + min-width: 0; +} + +.file-name { + display: block; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-meta { + display: block; + font-family: var(--font-mono); + font-size: 9px; + color: var(--accent-cyan); + opacity: 0.5; + margin-top: 2px; +} + +/* Right Chat */ +.rag-chat { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg-deep); + min-width: 0; +} + +.chat-header { + padding: 12px 20px; + border-bottom: 1px solid var(--border-dim); +} + +.chat-title { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-display); + font-size: 11px; + color: var(--accent-cyan); + letter-spacing: 0.15em; +} + +/* Messages */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +/* Input at bottom-right */ +.rag-input-area { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid var(--border-dim); + background: var(--bg-deep); +} + +.rag-input { + flex: 1; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-mid); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + outline: none; + transition: all var(--transition-fast); +} + +.rag-input:focus { + border-color: var(--accent-cyan); + box-shadow: 0 0 0 2px var(--accent-cyan-dim); +} + +.rag-input::placeholder { + color: var(--text-dim); +} + +.rag-send-btn { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background: var(--accent-cyan); + border: none; + border-radius: var(--radius-md); + color: #000; + cursor: pointer; + transition: all var(--transition-fast); +} + +.rag-send-btn:hover { + background: #fff; + box-shadow: 0 0 20px var(--accent-cyan-glow); +} + +.chat-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: var(--text-dim); +} + +.welcome-icon { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + background: var(--accent-cyan-dim); + border: 1px solid var(--border-mid); + border-radius: 50%; + margin-bottom: 16px; + color: var(--accent-cyan); +} + +.chat-welcome p { + margin: 4px 0; + font-family: var(--font-mono); + font-size: 13px; +} + +.example-hint { + opacity: 0.6; + font-size: 11px !important; +} + +.message-wrapper { + display: flex; + margin-bottom: 16px; +} + +.message-wrapper.user { + justify-content: flex-end; +} + +.message-wrapper.assistant { + justify-content: flex-start; +} + +.message-bubble { + max-width: 75%; + padding: 14px 18px; + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; +} + +.message-wrapper.user .message-bubble { + background: linear-gradient(135deg, var(--accent-cyan-dim) 0%, rgba(0, 245, 212, 0.05) 100%); + border: 1px solid var(--border-mid); + border-radius: var(--radius-md) var(--radius-md) 4px var(--radius-md); + color: var(--text-primary); +} + +.message-wrapper.assistant .message-bubble { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-dim); + border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4px; + color: var(--text-secondary); +} + +.message-bubble.loading { + display: flex; + align-items: center; + gap: 10px; + color: var(--accent-cyan); +} + +.message-content { + white-space: pre-wrap; +} + +/* Sources */ +.sources-list { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-dim); +} + +.sources-label { + font-size: 9px; + color: var(--text-dim); + letter-spacing: 0.1em; + margin-bottom: 8px; +} + +.source-item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + margin-top: 4px; + background: var(--accent-cyan-dim); + border: 1px solid var(--border-dim); + border-radius: var(--radius-sm); + font-size: 11px; + color: var(--accent-cyan); + cursor: pointer; + transition: all var(--transition-fast); +} + +.source-item:hover { + background: rgba(0, 245, 212, 0.15); + border-color: var(--accent-cyan); +} + +.source-item .similarity { + margin-left: auto; + font-size: 9px; + opacity: 0.6; +} + +/* Document Preview */ +.doc-preview-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + padding: 40px; +} + +.doc-preview-panel { + width: 100%; + max-width: 1000px; + max-height: 80%; + background: var(--bg-panel); + border: 1px solid var(--border-mid); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.preview-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: var(--accent-cyan-dim); + border-bottom: 1px solid var(--border-mid); +} + +.preview-title { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-display); + font-size: 14px; + color: var(--accent-cyan); +} + +.preview-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid var(--border-dim); + border-radius: var(--radius-sm); + color: var(--text-dim); + cursor: pointer; + transition: all var(--transition-fast); +} + +.preview-close:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.preview-body { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.preview-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + gap: 12px; + color: var(--accent-cyan); + font-family: var(--font-mono); + font-size: 11px; +} + +.preview-content { + margin: 0; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.8; + color: var(--accent-cyan); + white-space: pre-wrap; + word-break: break-word; +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.85); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.fade-enter-active, +.fade-leave-active { + transition: opacity var(--transition-mid); +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.spin { + animation: spin 1.5s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@media (max-width: 1100px) { + .rag-body { + flex-direction: column; + } + + .rag-explorer { + width: 100%; + min-width: 0; + max-width: none; + max-height: 38vh; + border-right: none; + border-bottom: 1px solid var(--border-dim); + } +} diff --git a/frontend/src/components/chat/KnowledgeRAGPanel.test.ts b/frontend/src/components/chat/KnowledgeRAGPanel.test.ts new file mode 100644 index 0000000..17cf60c --- /dev/null +++ b/frontend/src/components/chat/KnowledgeRAGPanel.test.ts @@ -0,0 +1,224 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const makeRef = (value: T) => ({ value, __v_isRef: true as const }) + + const folders = makeRef([ + { + id: 'root-1', + name: 'Root', + parent_id: null, + children: [ + { + id: 'child-1', + name: 'Child', + parent_id: 'root-1', + children: [], + }, + ], + }, + ]) + + const documents = makeRef([ + { + id: 'doc-1', + title: 'Spec', + file_type: 'md', + file_size: 128, + created_at: '2026-04-09T10:00:00.000Z', + }, + ]) + + return { + folders, + documents, + currentFolderId: makeRef(null), + currentFolder: makeRef(null), + isLoadingDocuments: makeRef(false), + uploadInput: makeRef(null), + triggerUpload: vi.fn(), + handleUpload: vi.fn(), + handleDeleteDocument: vi.fn(), + openDocument: vi.fn(), + enterFolder: vi.fn(async (folder: any) => { + mocks.currentFolderId.value = folder.id + mocks.currentFolder.value = folder + }), + activeDocumentContent: makeRef('Preview'), + isLoadingDocumentContent: makeRef(false), + folderCreate: vi.fn(), + folderGetTree: vi.fn(), + folderDelete: vi.fn(), + documentUpload: vi.fn(), + } +}) + +vi.mock('@/pages/knowledge/composables/useKnowledgeView', () => ({ + useKnowledgeView: () => ({ + documents: mocks.documents, + folders: mocks.folders, + currentFolderId: mocks.currentFolderId, + currentFolder: mocks.currentFolder, + isLoadingDocuments: mocks.isLoadingDocuments, + getFileTypeColor: () => '#fff', + formatFileSize: (size: number) => `${size} B`, + formatDate: () => '2026/04/09', + triggerUpload: mocks.triggerUpload, + uploadInput: mocks.uploadInput, + handleUpload: mocks.handleUpload, + handleDeleteDocument: mocks.handleDeleteDocument, + openDocument: mocks.openDocument, + activeDocumentContent: mocks.activeDocumentContent, + isLoadingDocumentContent: mocks.isLoadingDocumentContent, + enterFolder: mocks.enterFolder, + }), +})) + +vi.mock('@/api/folder', () => ({ + folderApi: { + create: mocks.folderCreate, + getTree: mocks.folderGetTree, + delete: mocks.folderDelete, + }, +})) + +vi.mock('@/api/document', () => ({ + documentApi: { + upload: mocks.documentUpload, + }, +})) + +import KnowledgeRAGPanel from './KnowledgeRAGPanel.vue' + +describe('KnowledgeRAGPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.folders.value = [ + { + id: 'root-1', + name: 'Root', + parent_id: null, + children: [ + { + id: 'child-1', + name: 'Child', + parent_id: 'root-1', + children: [], + }, + ], + }, + ] + mocks.documents.value = [{ id: 'doc-1', title: 'Spec', file_type: 'md', file_size: 128, created_at: '2026-04-09T10:00:00.000Z' }] + mocks.currentFolderId.value = null + mocks.currentFolder.value = null + mocks.folderGetTree.mockResolvedValue({ data: mocks.folders.value }) + mocks.folderCreate.mockResolvedValue({ data: {} }) + mocks.folderDelete.mockResolvedValue({ data: {} }) + mocks.documentUpload.mockResolvedValue({ data: {} }) + }) + + it('renders nested folders in a collapsible tree and selects a folder on click', async () => { + const wrapper = mount(KnowledgeRAGPanel, { + props: { isChatLoading: false }, + }) + + expect(wrapper.text()).toContain('Root') + expect(wrapper.text()).not.toContain('Child') + + await wrapper.find('.folder-toggle').trigger('click') + expect(wrapper.text()).toContain('Child') + + await wrapper.find('.folder-row').trigger('click') + + expect(mocks.enterFolder).toHaveBeenCalledWith(expect.objectContaining({ id: 'root-1' })) + expect(mocks.currentFolderId.value).toBe('root-1') + expect(wrapper.text()).toContain('Spec') + + await wrapper.find('.folder-row').trigger('click') + expect(wrapper.text()).toContain('Spec') + }) + + it('uses top NEW to create root folder when nothing is selected', async () => { + const wrapper = mount(KnowledgeRAGPanel, { + props: { isChatLoading: false }, + }) + + await wrapper.find('.toolbar-actions .action-btn').trigger('click') + + expect(wrapper.find('.rag-dialog-overlay').exists()).toBe(true) + expect(wrapper.find('.inline-dialog .dialog-input').exists()).toBe(false) + + await wrapper.find('.dialog-input').setValue('RootNew') + await wrapper.find('.action-btn.primary').trigger('click') + await flushPromises() + + expect(mocks.folderCreate).toHaveBeenCalledWith({ + name: 'RootNew', + parent_id: null, + }) + }) + + it('uses top NEW to create child folder under selected folder', async () => { + const wrapper = mount(KnowledgeRAGPanel, { + props: { isChatLoading: false }, + }) + + await wrapper.find('.folder-row').trigger('click') + await wrapper.find('.toolbar-actions .action-btn').trigger('click') + + await wrapper.find('.dialog-input').setValue('Nested') + await wrapper.find('.action-btn.primary').trigger('click') + await flushPromises() + + expect(mocks.folderCreate).toHaveBeenCalledWith({ + name: 'Nested', + parent_id: 'root-1', + }) + }) + + it('shows delete action only for selected folder and hides per-row create button', async () => { + const wrapper = mount(KnowledgeRAGPanel, { + props: { isChatLoading: false }, + }) + + expect(wrapper.find('button[title="Delete folder"]').exists()).toBe(false) + expect(wrapper.find('button[title="New child folder"]').exists()).toBe(false) + + await wrapper.find('.folder-row').trigger('click') + + expect(wrapper.find('button[title="Delete folder"]').exists()).toBe(true) + expect(wrapper.findAll('button[title="Delete folder"]')).toHaveLength(1) + }) + + it('does not show chevron toggle for leaf folders', async () => { + const wrapper = mount(KnowledgeRAGPanel, { + props: { isChatLoading: false }, + }) + + await wrapper.find('.folder-toggle').trigger('click') + + expect(wrapper.findAll('.folder-toggle')).toHaveLength(1) + expect(wrapper.findAll('.folder-toggle-spacer')).toHaveLength(1) + }) + + it('uploads to the currently selected child folder', async () => { + const wrapper = mount(KnowledgeRAGPanel, { + props: { isChatLoading: false }, + }) + + await wrapper.find('.folder-toggle').trigger('click') + await wrapper.findAll('.folder-row')[1].trigger('click') + + const upload = wrapper.find('input[type="file"]') + Object.defineProperty(upload.element, 'files', { + value: [new File(['hello'], 'note.md', { type: 'text/markdown' })], + configurable: true, + }) + + await upload.trigger('change') + await flushPromises() + + expect(mocks.documentUpload).toHaveBeenCalledWith(expect.any(File), 'child-1') + }) +}) diff --git a/frontend/src/components/chat/KnowledgeRAGPanel.vue b/frontend/src/components/chat/KnowledgeRAGPanel.vue new file mode 100644 index 0000000..85d86c5 --- /dev/null +++ b/frontend/src/components/chat/KnowledgeRAGPanel.vue @@ -0,0 +1,771 @@ + + + + + + + + + + + + KNOWLEDGE_RAG + + + + + + + + + + + + + + + RAG CONVERSATION + + + + + + + + + Use natural language to search the selected knowledge folder. + Example: "Find the latest project planning notes." + + + + + {{ msg.content }} + + SOURCES + + + {{ source.title }} + {{ formatSimilarity(source.similarity) }} + + + + + + + + + + SEARCHING... + + + + + + + + + + + + + + + + + + NEW FOLDER / {{ newFolderTargetLabel }} + + + CANCEL + + + CREATE + + + + + + + + + + + + NEW WEBDAV MOUNT + + + + + + + CANCEL + CREATE + + + + + + + + + + + + + {{ selectedDoc.title }} + + + + + + + + + LOADING_CONTENT... + + {{ activeDocumentContent || 'NO_CONTENT' }} + + + + + + + + +
Use natural language to search the selected knowledge folder.
Example: "Find the latest project planning notes."
{{ activeDocumentContent || 'NO_CONTENT' }}