Align knowledge storage with real folders and add WebDAV import surface
Knowledge files were only partitioned in the database, which made nested uploads, local folder visibility, and delete behavior diverge from the UI. This change makes folder selection drive physical storage paths, keeps original filenames, adds a minimal WebDAV mount/sync path, and reshapes the knowledge panel so local and remote sources can share the same surface. Constraint: Existing knowledge flow already depends on local-folder-backed uploads and document indexing Rejected: Real-time bidirectional WebDAV sync | too much conflict and lifecycle complexity for the first pass Confidence: medium Scope-risk: moderate Reversibility: messy Directive: Keep remote mounts single-direction into local knowledge folders until etag-based incremental sync and conflict rules are verified Tested: Python py_compile on new/modified backend files; LSP diagnostics on new frontend/backend files; manual targeted code-path inspection Not-tested: Full pytest/vitest end-to-end runs blocked by environment temp/cache permission errors; live WebDAV server interoperability
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
34
backend/app/models/remote_mount.py
Normal file
34
backend/app/models/remote_mount.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
130
backend/app/routers/remote_mount.py
Normal file
130
backend/app/routers/remote_mount.py
Normal file
@@ -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)
|
||||
58
backend/app/schemas/remote_mount.py
Normal file
58
backend/app/schemas/remote_mount.py
Normal file
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
108
backend/app/services/remote_sync_service.py
Normal file
108
backend/app/services/remote_sync_service.py
Normal file
@@ -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()
|
||||
24
backend/app/services/secret_service.py
Normal file
24
backend/app/services/secret_service.py
Normal file
@@ -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")
|
||||
127
backend/app/services/webdav_service.py
Normal file
127
backend/app/services/webdav_service.py
Normal file
@@ -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 = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname />
|
||||
<d:resourcetype />
|
||||
<d:getcontentlength />
|
||||
<d:getlastmodified />
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
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
|
||||
@@ -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
|
||||
|
||||
39
backend/tests/backend/app/services/test_webdav_service.py
Normal file
39
backend/tests/backend/app/services/test_webdav_service.py
Normal file
@@ -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 = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:multistatus xmlns:d="DAV:">
|
||||
<d:response>
|
||||
<d:href>/knowledge/</d:href>
|
||||
<d:propstat><d:prop><d:displayname>knowledge</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/knowledge/specs/</d:href>
|
||||
<d:propstat><d:prop><d:displayname>specs</d:displayname><d:resourcetype><d:collection /></d:resourcetype></d:prop></d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/knowledge/roadmap.md</d:href>
|
||||
<d:propstat><d:prop><d:displayname>roadmap.md</d:displayname><d:getcontentlength>128</d:getcontentlength><d:getetag>"etag-1"</d:getetag><d:getlastmodified>Wed, 09 Apr 2026 10:00:00 GMT</d:getlastmodified><d:resourcetype /></d:prop></d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"""
|
||||
|
||||
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"'
|
||||
90
backend/tests/backend/app/test_folder_router.py
Normal file
90
backend/tests/backend/app/test_folder_router.py
Normal file
@@ -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()
|
||||
72
frontend/src/api/remoteMount.ts
Normal file
72
frontend/src/api/remoteMount.ts
Normal file
@@ -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<RemoteMount[]>('/api/remote-mounts')
|
||||
},
|
||||
|
||||
create(data: RemoteMountCreate) {
|
||||
return api.post<RemoteMount>('/api/remote-mounts', data)
|
||||
},
|
||||
|
||||
getTree(mountId: string, path?: string) {
|
||||
return api.get<RemoteMountTreeResponse>(`/api/remote-mounts/${mountId}/tree`, {
|
||||
params: path ? { path } : undefined,
|
||||
})
|
||||
},
|
||||
|
||||
sync(mountId: string, data: RemoteSyncRequest) {
|
||||
return api.post<RemoteSyncResult>(`/api/remote-mounts/${mountId}/sync`, data)
|
||||
},
|
||||
}
|
||||
935
frontend/src/components/chat/KnowledgeRAG.css
Normal file
935
frontend/src/components/chat/KnowledgeRAG.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
224
frontend/src/components/chat/KnowledgeRAGPanel.test.ts
Normal file
224
frontend/src/components/chat/KnowledgeRAGPanel.test.ts
Normal file
@@ -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 = <T>(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<string | null>(null),
|
||||
currentFolder: makeRef<any>(null),
|
||||
isLoadingDocuments: makeRef(false),
|
||||
uploadInput: makeRef<HTMLInputElement | null>(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')
|
||||
})
|
||||
})
|
||||
771
frontend/src/components/chat/KnowledgeRAGPanel.vue
Normal file
771
frontend/src/components/chat/KnowledgeRAGPanel.vue
Normal file
@@ -0,0 +1,771 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
X,
|
||||
Upload,
|
||||
FileCode,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Search,
|
||||
Send,
|
||||
Loader,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
FolderPlus,
|
||||
Cloud,
|
||||
Database,
|
||||
} from 'lucide-vue-next'
|
||||
import { folderApi, type FolderTree } from '@/api/folder'
|
||||
import { documentApi } from '@/api/document'
|
||||
import { remoteMountApi, type RemoteMount, type RemoteNode } from '@/api/remoteMount'
|
||||
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
|
||||
import './KnowledgeRAG.css'
|
||||
|
||||
defineProps<{
|
||||
isChatLoading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['close', 'update:chatInput', 'send'])
|
||||
|
||||
function onInputChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
chatInput.value = target.value
|
||||
emit('update:chatInput', target.value)
|
||||
}
|
||||
|
||||
const {
|
||||
documents,
|
||||
folders,
|
||||
currentFolderId,
|
||||
currentFolder,
|
||||
isLoadingDocuments,
|
||||
formatFileSize,
|
||||
formatDate,
|
||||
uploadInput,
|
||||
handleDeleteDocument,
|
||||
openDocument,
|
||||
activeDocumentContent,
|
||||
isLoadingDocumentContent,
|
||||
enterFolder,
|
||||
} = useKnowledgeView()
|
||||
|
||||
const selectedDoc = ref<any>(null)
|
||||
const previewOpen = ref(false)
|
||||
const isUploadingFile = ref(false)
|
||||
const explorerMode = ref<'local' | 'remote'>('local')
|
||||
|
||||
const showNewFolderInput = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const newFolderParentId = ref<string | null>(null)
|
||||
const isCreatingFolder = ref(false)
|
||||
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deletingFolder = ref<FolderTree | null>(null)
|
||||
const isDeletingFolder = ref(false)
|
||||
|
||||
const expandedFolderIds = ref<Set<string>>(new Set())
|
||||
const remoteExpandedIds = ref<Set<string>>(new Set())
|
||||
const remoteMounts = ref<RemoteMount[]>([])
|
||||
const selectedMountId = ref<string | null>(null)
|
||||
const remoteNodes = ref<RemoteNode[]>([])
|
||||
const selectedRemotePath = ref<string | null>(null)
|
||||
const isLoadingRemoteMounts = ref(false)
|
||||
const isLoadingRemoteTree = ref(false)
|
||||
const isSyncingRemote = ref<string | null>(null)
|
||||
const showRemoteMountInput = ref(false)
|
||||
const remoteMountForm = ref({
|
||||
name: '',
|
||||
base_url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
root_path: '/',
|
||||
})
|
||||
|
||||
interface RAGMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
sources?: DocumentSource[]
|
||||
}
|
||||
|
||||
interface DocumentSource {
|
||||
id: string
|
||||
title: string
|
||||
file_type: string
|
||||
folder_id?: string | null
|
||||
similarity?: number
|
||||
chunk_content?: string
|
||||
}
|
||||
|
||||
const chatMessages = ref<RAGMessage[]>([])
|
||||
const chatInput = ref('')
|
||||
|
||||
function ensureExpandedPath(targetId: string | null, nodes: FolderTree[] = folders.value): boolean {
|
||||
if (!targetId) return false
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.id === targetId) {
|
||||
return true
|
||||
}
|
||||
if (node.children?.length && ensureExpandedPath(targetId, node.children)) {
|
||||
expandedFolderIds.value.add(node.id)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function getVisibleFolders(nodes: FolderTree[], depth = 0): Array<{ data: FolderTree; depth: number }> {
|
||||
const result: Array<{ data: FolderTree; depth: number }> = []
|
||||
|
||||
for (const folder of nodes) {
|
||||
result.push({ data: folder, depth })
|
||||
if (folder.children?.length && expandedFolderIds.value.has(folder.id)) {
|
||||
result.push(...getVisibleFolders(folder.children, depth + 1))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const visibleFolders = computed(() => getVisibleFolders(folders.value))
|
||||
const ROW_BASE_PADDING = 12
|
||||
const DEPTH_INDENT = 16
|
||||
const CONTENT_BASE_PADDING = 36
|
||||
|
||||
const newFolderTargetLabel = computed(() => {
|
||||
if (!newFolderParentId.value) {
|
||||
return 'ROOT'
|
||||
}
|
||||
return findFolderNameById(newFolderParentId.value) ?? 'TARGET'
|
||||
})
|
||||
|
||||
function hasChildren(folder: FolderTree) {
|
||||
return (folder.children?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
function isExpanded(folderId: string) {
|
||||
return expandedFolderIds.value.has(folderId)
|
||||
}
|
||||
|
||||
function isFolderOpen(folder: FolderTree) {
|
||||
return currentFolderId.value === folder.id || isExpanded(folder.id)
|
||||
}
|
||||
|
||||
function toggleFolder(folder: FolderTree) {
|
||||
if (!hasChildren(folder)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (expandedFolderIds.value.has(folder.id)) {
|
||||
expandedFolderIds.value.delete(folder.id)
|
||||
} else {
|
||||
expandedFolderIds.value.add(folder.id)
|
||||
}
|
||||
expandedFolderIds.value = new Set(expandedFolderIds.value)
|
||||
}
|
||||
|
||||
function getFolderIcon(folder: FolderTree) {
|
||||
return isFolderOpen(folder) ? FolderOpen : Folder
|
||||
}
|
||||
|
||||
function findFolderNameById(targetId: string | null, nodes: FolderTree[] = folders.value): string | null {
|
||||
if (!targetId) return null
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.id === targetId) {
|
||||
return node.name
|
||||
}
|
||||
if (node.children?.length) {
|
||||
const nested = findFolderNameById(targetId, node.children)
|
||||
if (nested) return nested
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getVisibleRemoteNodes(nodes: RemoteNode[], depth = 0): Array<{ data: RemoteNode; depth: number }> {
|
||||
const result: Array<{ data: RemoteNode; depth: number }> = []
|
||||
|
||||
for (const node of nodes) {
|
||||
result.push({ data: node, depth })
|
||||
if (node.is_dir && remoteExpandedIds.value.has(node.path)) {
|
||||
result.push(...getVisibleRemoteNodes(node.children, depth + 1))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const visibleRemoteNodes = computed(() => getVisibleRemoteNodes(remoteNodes.value))
|
||||
const selectedLocalFolderLabel = computed(() => currentFolder.value?.name ?? 'Select in LOCAL first')
|
||||
const selectedMount = computed(() => remoteMounts.value.find((mount) => mount.id === selectedMountId.value) ?? null)
|
||||
|
||||
async function loadRemoteMounts() {
|
||||
isLoadingRemoteMounts.value = true
|
||||
try {
|
||||
const response = await remoteMountApi.list()
|
||||
remoteMounts.value = response.data
|
||||
if (!selectedMountId.value && response.data.length > 0) {
|
||||
selectedMountId.value = response.data[0].id
|
||||
await loadRemoteTree(response.data[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load remote mounts:', error)
|
||||
} finally {
|
||||
isLoadingRemoteMounts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRemoteTree(mountId: string) {
|
||||
isLoadingRemoteTree.value = true
|
||||
try {
|
||||
const response = await remoteMountApi.getTree(mountId)
|
||||
remoteNodes.value = response.data.nodes
|
||||
} catch (error) {
|
||||
console.error('Failed to load remote tree:', error)
|
||||
remoteNodes.value = []
|
||||
} finally {
|
||||
isLoadingRemoteTree.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectMount(mountId: string) {
|
||||
selectedMountId.value = mountId
|
||||
selectedRemotePath.value = null
|
||||
remoteExpandedIds.value = new Set()
|
||||
await loadRemoteTree(mountId)
|
||||
}
|
||||
|
||||
function isRemoteExpanded(path: string) {
|
||||
return remoteExpandedIds.value.has(path)
|
||||
}
|
||||
|
||||
function handleRemoteNodeClick(node: RemoteNode) {
|
||||
selectedRemotePath.value = node.path
|
||||
if (node.is_dir) {
|
||||
if (remoteExpandedIds.value.has(node.path)) {
|
||||
remoteExpandedIds.value.delete(node.path)
|
||||
} else {
|
||||
remoteExpandedIds.value.add(node.path)
|
||||
}
|
||||
remoteExpandedIds.value = new Set(remoteExpandedIds.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function createRemoteMount() {
|
||||
if (!remoteMountForm.value.name.trim() || !remoteMountForm.value.base_url.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await remoteMountApi.create({
|
||||
name: remoteMountForm.value.name.trim(),
|
||||
base_url: remoteMountForm.value.base_url.trim(),
|
||||
username: remoteMountForm.value.username.trim() || null,
|
||||
password: remoteMountForm.value.password || null,
|
||||
root_path: remoteMountForm.value.root_path.trim() || '/',
|
||||
})
|
||||
showRemoteMountInput.value = false
|
||||
remoteMountForm.value = { name: '', base_url: '', username: '', password: '', root_path: '/' }
|
||||
await loadRemoteMounts()
|
||||
await handleSelectMount(response.data.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to create remote mount:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRemoteNode(node: RemoteNode) {
|
||||
if (!selectedMountId.value || !currentFolderId.value || isSyncingRemote.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isSyncingRemote.value = node.path
|
||||
try {
|
||||
await remoteMountApi.sync(selectedMountId.value, {
|
||||
remote_path: node.path,
|
||||
local_folder_id: currentFolderId.value,
|
||||
mode: node.is_dir ? 'folder' : 'file',
|
||||
})
|
||||
if (currentFolder.value) {
|
||||
await enterFolder(currentFolder.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync remote node:', error)
|
||||
} finally {
|
||||
isSyncingRemote.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function openNewFolderDialog(parentId: string | null = null) {
|
||||
newFolderParentId.value = parentId
|
||||
newFolderName.value = ''
|
||||
if (parentId) {
|
||||
expandedFolderIds.value.add(parentId)
|
||||
expandedFolderIds.value = new Set(expandedFolderIds.value)
|
||||
}
|
||||
showNewFolderInput.value = true
|
||||
}
|
||||
|
||||
async function createFolderApi(name: string) {
|
||||
const normalizedName = name.trim()
|
||||
if (!normalizedName) return
|
||||
|
||||
isCreatingFolder.value = true
|
||||
try {
|
||||
await folderApi.create({
|
||||
name: normalizedName,
|
||||
parent_id: newFolderParentId.value,
|
||||
})
|
||||
const response = await folderApi.getTree()
|
||||
folders.value = response.data
|
||||
ensureExpandedPath(newFolderParentId.value)
|
||||
expandedFolderIds.value = new Set(expandedFolderIds.value)
|
||||
showNewFolderInput.value = false
|
||||
newFolderName.value = ''
|
||||
} catch (err) {
|
||||
console.error('Failed to create folder:', err)
|
||||
} finally {
|
||||
isCreatingFolder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelNewFolder() {
|
||||
showNewFolderInput.value = false
|
||||
newFolderName.value = ''
|
||||
newFolderParentId.value = null
|
||||
}
|
||||
|
||||
function openDeleteDialog(folder: FolderTree) {
|
||||
deletingFolder.value = folder
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function deleteFolderApi() {
|
||||
if (!deletingFolder.value) return
|
||||
|
||||
isDeletingFolder.value = true
|
||||
try {
|
||||
await folderApi.delete(deletingFolder.value.id)
|
||||
const response = await folderApi.getTree()
|
||||
folders.value = response.data
|
||||
if (currentFolderId.value === deletingFolder.value.id) {
|
||||
selectedDoc.value = null
|
||||
}
|
||||
showDeleteConfirm.value = false
|
||||
deletingFolder.value = null
|
||||
} catch (err) {
|
||||
console.error('Failed to delete folder:', err)
|
||||
} finally {
|
||||
isDeletingFolder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
showDeleteConfirm.value = false
|
||||
deletingFolder.value = null
|
||||
}
|
||||
|
||||
async function handleFolderClick(folder: FolderTree) {
|
||||
if (currentFolderId.value === folder.id) {
|
||||
if (expandedFolderIds.value.has(folder.id)) {
|
||||
expandedFolderIds.value.delete(folder.id)
|
||||
expandedFolderIds.value = new Set(expandedFolderIds.value)
|
||||
return
|
||||
}
|
||||
expandedFolderIds.value.add(folder.id)
|
||||
expandedFolderIds.value = new Set(expandedFolderIds.value)
|
||||
return
|
||||
}
|
||||
|
||||
ensureExpandedPath(folder.id)
|
||||
expandedFolderIds.value.add(folder.id)
|
||||
expandedFolderIds.value = new Set(expandedFolderIds.value)
|
||||
await enterFolder(folder)
|
||||
}
|
||||
|
||||
function triggerFolderUpload() {
|
||||
if (!currentFolderId.value || isUploadingFile.value) {
|
||||
return
|
||||
}
|
||||
uploadInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileClick(doc: any) {
|
||||
selectedDoc.value = doc
|
||||
previewOpen.value = true
|
||||
openDocument(doc)
|
||||
}
|
||||
|
||||
async function onUploadChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
const selectedFolder = currentFolder.value
|
||||
|
||||
if (!file || !selectedFolder?.id) {
|
||||
target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
isUploadingFile.value = true
|
||||
try {
|
||||
await documentApi.upload(file, selectedFolder.id)
|
||||
await enterFolder(selectedFolder)
|
||||
} catch (error) {
|
||||
console.error('Failed to upload document:', error)
|
||||
} finally {
|
||||
isUploadingFile.value = false
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function formatSimilarity(score?: number) {
|
||||
if (!score) return ''
|
||||
return `${Math.round(score * 100)}%`
|
||||
}
|
||||
|
||||
function addMessage(msg: RAGMessage) {
|
||||
chatMessages.value.push(msg)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRemoteMounts()
|
||||
})
|
||||
|
||||
defineExpose({ addMessage })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rag-panel-shell">
|
||||
<div class="rag-backdrop" @click="emit('close')"></div>
|
||||
|
||||
<div class="rag-panel">
|
||||
<header class="rag-header">
|
||||
<div class="header-left">
|
||||
<div class="coord-tag">
|
||||
<Search :size="10" />
|
||||
<span>KNOWLEDGE_RAG</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-close" aria-label="Close knowledge panel" @click="emit('close')">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="rag-body">
|
||||
<aside class="rag-explorer">
|
||||
<div class="explorer-toolbar">
|
||||
<div class="toolbar-title">
|
||||
<component :is="explorerMode === 'local' ? Database : Cloud" :size="12" />
|
||||
<span>{{ explorerMode === 'local' ? 'ARCHIVE TREE' : 'REMOTE MOUNTS' }}</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<template v-if="explorerMode === 'local'">
|
||||
<button class="action-btn" @click="openNewFolderDialog(currentFolderId)" title="New folder">
|
||||
<FolderPlus :size="10" />
|
||||
<span>NEW</span>
|
||||
</button>
|
||||
<button class="action-btn" :disabled="!currentFolderId || isUploadingFile" @click="triggerFolderUpload()">
|
||||
<Upload :size="10" />
|
||||
<span>{{ isUploadingFile ? 'UPLOADING' : 'UPLOAD' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="action-btn" @click="showRemoteMountInput = true" title="New remote mount">
|
||||
<FolderPlus :size="10" />
|
||||
<span>MOUNT</span>
|
||||
</button>
|
||||
</template>
|
||||
<input
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
accept=".pdf,.md,.txt,.doc,.docx,.csv,.xlsx"
|
||||
class="hidden-input"
|
||||
@change="onUploadChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explorer-mode-switch">
|
||||
<button class="mode-btn" :class="{ active: explorerMode === 'local' }" @click="explorerMode = 'local'">LOCAL</button>
|
||||
<button class="mode-btn" :class="{ active: explorerMode === 'remote' }" @click="explorerMode = 'remote'">REMOTE</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showDeleteConfirm" class="inline-dialog">
|
||||
<div class="dialog-content">
|
||||
<span class="dialog-label">DELETE FOLDER</span>
|
||||
<p class="dialog-text">Delete "{{ deletingFolder?.name }}" and its contents?</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="action-btn" @click="cancelDelete">CANCEL</button>
|
||||
<button class="action-btn danger" @click="deleteFolderApi" :disabled="isDeletingFolder">
|
||||
<Loader v-if="isDeletingFolder" :size="10" class="spin" />
|
||||
<span>DELETE</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="explorerMode === 'local'" class="folder-tree">
|
||||
<div v-if="visibleFolders.length === 0" class="empty-state">
|
||||
<span>NO_FOLDERS</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="{ data: folder, depth } in visibleFolders"
|
||||
:key="`folder-${folder.id}`"
|
||||
class="folder-node"
|
||||
>
|
||||
<div
|
||||
class="folder-row"
|
||||
:class="{ active: currentFolderId === folder.id }"
|
||||
:style="{ paddingLeft: `${ROW_BASE_PADDING + depth * DEPTH_INDENT}px` }"
|
||||
@click="handleFolderClick(folder)"
|
||||
>
|
||||
<button
|
||||
class="folder-toggle"
|
||||
type="button"
|
||||
@click.stop="handleFolderClick(folder)"
|
||||
>
|
||||
<component :is="isFolderOpen(folder) ? ChevronDown : ChevronRight" :size="12" />
|
||||
</button>
|
||||
<component :is="getFolderIcon(folder)" :size="12" class="folder-icon" />
|
||||
<span class="folder-name">{{ folder.name }}</span>
|
||||
<div v-if="currentFolderId === folder.id" class="folder-actions">
|
||||
<button class="action-btn small danger" @click.stop="openDeleteDialog(folder)" title="Delete folder">
|
||||
<Trash2 :size="8" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="currentFolderId === folder.id && !isLoadingDocuments && documents.length > 0"
|
||||
class="folder-contents"
|
||||
:style="{ paddingLeft: `${CONTENT_BASE_PADDING + depth * DEPTH_INDENT}px` }"
|
||||
>
|
||||
<div class="folder-file-list">
|
||||
<button
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="tree-file-row"
|
||||
:class="{ active: selectedDoc?.id === doc.id }"
|
||||
@click="handleFileClick(doc)"
|
||||
>
|
||||
<span class="tree-file-name">{{ doc.title }}</span>
|
||||
<span class="tree-file-meta">
|
||||
<span>{{ formatDate(doc.created_at) }}</span>
|
||||
<span>{{ formatFileSize(doc.file_size) }}</span>
|
||||
<span class="tree-file-delete" @click.stop="handleDeleteDocument(doc.id)">
|
||||
<Trash2 :size="10" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="folder-tree remote-tree">
|
||||
<div class="remote-mount-strip">
|
||||
<select
|
||||
class="remote-mount-select"
|
||||
:disabled="isLoadingRemoteMounts || remoteMounts.length === 0"
|
||||
:value="selectedMountId ?? ''"
|
||||
@change="handleSelectMount(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="" disabled>{{ isLoadingRemoteMounts ? 'Loading mounts...' : 'Select mount' }}</option>
|
||||
<option v-for="mount in remoteMounts" :key="mount.id" :value="mount.id">{{ mount.name }}</option>
|
||||
</select>
|
||||
<button class="action-btn small" :disabled="!selectedMountId || isLoadingRemoteTree" @click="selectedMountId && loadRemoteTree(selectedMountId)">
|
||||
<RefreshCw :size="10" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="remote-sync-hint">
|
||||
<span>TARGET</span>
|
||||
<strong>{{ selectedLocalFolderLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingRemoteTree" class="empty-state">
|
||||
<Loader :size="14" class="spin" />
|
||||
<span>LOADING_REMOTE_TREE...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="visibleRemoteNodes.length === 0" class="empty-state">
|
||||
<span>{{ selectedMount ? 'NO_REMOTE_ITEMS' : 'NO_MOUNT_SELECTED' }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="{ data: node, depth } in visibleRemoteNodes"
|
||||
:key="node.path"
|
||||
class="folder-node"
|
||||
>
|
||||
<div
|
||||
class="folder-row remote-row"
|
||||
:class="{ active: selectedRemotePath === node.path }"
|
||||
:style="{ paddingLeft: `${ROW_BASE_PADDING + depth * DEPTH_INDENT}px` }"
|
||||
@click="handleRemoteNodeClick(node)"
|
||||
>
|
||||
<button class="folder-toggle" type="button" @click.stop="handleRemoteNodeClick(node)">
|
||||
<component :is="node.is_dir && isRemoteExpanded(node.path) ? ChevronDown : ChevronRight" :size="12" />
|
||||
</button>
|
||||
<component :is="node.is_dir ? (isRemoteExpanded(node.path) ? FolderOpen : Folder) : FileCode" :size="12" class="folder-icon" />
|
||||
<span class="folder-name">{{ node.name }}</span>
|
||||
<button
|
||||
class="action-btn small"
|
||||
:disabled="!currentFolderId || isSyncingRemote === node.path"
|
||||
@click.stop="syncRemoteNode(node)"
|
||||
>
|
||||
<Loader v-if="isSyncingRemote === node.path" :size="8" class="spin" />
|
||||
<RefreshCw v-else :size="8" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="rag-chat">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">
|
||||
<RefreshCw :size="12" />
|
||||
<span>RAG CONVERSATION</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages">
|
||||
<div v-if="chatMessages.length === 0" class="chat-welcome">
|
||||
<div class="welcome-icon">
|
||||
<Search :size="32" />
|
||||
</div>
|
||||
<p>Use natural language to search the selected knowledge folder.</p>
|
||||
<p class="example-hint">Example: "Find the latest project planning notes."</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="msg in chatMessages"
|
||||
:key="msg.id"
|
||||
class="message-wrapper"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="message-bubble">
|
||||
<div class="message-content">{{ msg.content }}</div>
|
||||
<div v-if="msg.sources?.length" class="sources-list">
|
||||
<div class="sources-label">SOURCES</div>
|
||||
<div
|
||||
v-for="source in msg.sources"
|
||||
:key="source.id"
|
||||
class="source-item"
|
||||
@click="handleFileClick(source)"
|
||||
>
|
||||
<FileCode :size="10" />
|
||||
<span>{{ source.title }}</span>
|
||||
<span v-if="source.similarity" class="similarity">{{ formatSimilarity(source.similarity) }}</span>
|
||||
<ExternalLink :size="8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isChatLoading" class="message-wrapper assistant">
|
||||
<div class="message-bubble loading">
|
||||
<Loader :size="14" class="spin" />
|
||||
<span>SEARCHING...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rag-input-area">
|
||||
<input
|
||||
type="text"
|
||||
class="rag-input"
|
||||
placeholder="Ask the knowledge base..."
|
||||
:value="chatInput"
|
||||
@input="onInputChange"
|
||||
@keydown.enter="emit('send')"
|
||||
/>
|
||||
<button class="rag-send-btn" @click="emit('send')">
|
||||
<Send :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showNewFolderInput" class="rag-dialog-overlay" @click.self="cancelNewFolder">
|
||||
<div class="rag-dialog-card">
|
||||
<div class="dialog-content">
|
||||
<span class="dialog-label">NEW FOLDER / {{ newFolderTargetLabel }}</span>
|
||||
<input
|
||||
v-model="newFolderName"
|
||||
type="text"
|
||||
class="dialog-input"
|
||||
placeholder="Folder name..."
|
||||
@keydown.enter="createFolderApi(newFolderName)"
|
||||
@keydown.esc="cancelNewFolder"
|
||||
autofocus
|
||||
/>
|
||||
<div class="dialog-actions">
|
||||
<button class="action-btn" @click="cancelNewFolder">CANCEL</button>
|
||||
<button class="action-btn primary" @click="createFolderApi(newFolderName)" :disabled="isCreatingFolder">
|
||||
<Loader v-if="isCreatingFolder" :size="10" class="spin" />
|
||||
<span>CREATE</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="showRemoteMountInput" class="rag-dialog-overlay" @click.self="showRemoteMountInput = false">
|
||||
<div class="rag-dialog-card">
|
||||
<div class="dialog-content">
|
||||
<span class="dialog-label">NEW WEBDAV MOUNT</span>
|
||||
<input v-model="remoteMountForm.name" type="text" class="dialog-input" placeholder="Mount name" />
|
||||
<input v-model="remoteMountForm.base_url" type="text" class="dialog-input" placeholder="https://example.com/dav/" />
|
||||
<input v-model="remoteMountForm.username" type="text" class="dialog-input" placeholder="Username" />
|
||||
<input v-model="remoteMountForm.password" type="password" class="dialog-input" placeholder="Password" />
|
||||
<input v-model="remoteMountForm.root_path" type="text" class="dialog-input" placeholder="/remote/path" />
|
||||
<div class="dialog-actions">
|
||||
<button class="action-btn" @click="showRemoteMountInput = false">CANCEL</button>
|
||||
<button class="action-btn primary" @click="createRemoteMount">CREATE</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="previewOpen && selectedDoc" class="doc-preview-overlay" @click.self="previewOpen = false">
|
||||
<div class="doc-preview-panel">
|
||||
<header class="preview-header">
|
||||
<div class="preview-title">
|
||||
<FileText :size="14" />
|
||||
<span>{{ selectedDoc.title }}</span>
|
||||
</div>
|
||||
<button class="preview-close" @click="previewOpen = false">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="preview-body">
|
||||
<div v-if="isLoadingDocumentContent" class="preview-loading">
|
||||
<Loader :size="20" class="spin" />
|
||||
<span>LOADING_CONTENT...</span>
|
||||
</div>
|
||||
<pre v-else class="preview-content">{{ activeDocumentContent || 'NO_CONTENT' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Styles defined in KnowledgeRAG.css */
|
||||
</style>
|
||||
Reference in New Issue
Block a user