Compare commits
2 Commits
51e38e039b
...
8c7cf0732b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c7cf0732b | |||
| aa12c92a5a |
@@ -29,6 +29,8 @@ from app.routers import (
|
|||||||
agent_skills_router,
|
agent_skills_router,
|
||||||
agent_sessions_router,
|
agent_sessions_router,
|
||||||
terminal_router,
|
terminal_router,
|
||||||
|
tools_router,
|
||||||
|
remote_mount_router,
|
||||||
)
|
)
|
||||||
from app.routers.scheduler import router as scheduler_router
|
from app.routers.scheduler import router as scheduler_router
|
||||||
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
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_skills_router)
|
||||||
app.include_router(agent_sessions_router)
|
app.include_router(agent_sessions_router)
|
||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
|
app.include_router(tools_router)
|
||||||
|
app.include_router(remote_mount_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -2,7 +2,17 @@ from app.models.base import Base
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.folder import Folder
|
from app.models.folder import Folder
|
||||||
from app.models.document import Document, DocumentChunk
|
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.forum import ForumPost, ForumReply
|
||||||
from app.models.agent import Agent, AgentMessage
|
from app.models.agent import Agent, AgentMessage
|
||||||
from app.models.conversation import Conversation, Message
|
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.goal import Goal, GoalStatus
|
||||||
from app.models.skill import Skill
|
from app.models.skill import Skill
|
||||||
from app.models.log import Log, LogType, LogLevel
|
from app.models.log import Log, LogType, LogLevel
|
||||||
|
from app.models.remote_mount import RemoteMount, RemoteSyncItem
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -31,7 +42,14 @@ __all__ = [
|
|||||||
"Document",
|
"Document",
|
||||||
"DocumentChunk",
|
"DocumentChunk",
|
||||||
"Task",
|
"Task",
|
||||||
|
"TaskSubTask",
|
||||||
"TaskHistory",
|
"TaskHistory",
|
||||||
|
"TaskStatus",
|
||||||
|
"TaskPriority",
|
||||||
|
"TaskSource",
|
||||||
|
"TaskQuadrant",
|
||||||
|
"TaskAssigneeType",
|
||||||
|
"TaskDispatchStatus",
|
||||||
"ForumPost",
|
"ForumPost",
|
||||||
"ForumReply",
|
"ForumReply",
|
||||||
"Agent",
|
"Agent",
|
||||||
@@ -61,4 +79,6 @@ __all__ = [
|
|||||||
"Log",
|
"Log",
|
||||||
"LogType",
|
"LogType",
|
||||||
"LogLevel",
|
"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_skills import router as agent_skills_router
|
||||||
from app.routers.agent_sessions import router as agent_sessions_router
|
from app.routers.agent_sessions import router as agent_sessions_router
|
||||||
from app.routers.terminal import router as terminal_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 fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, and_
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
import shutil
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.folder import Folder
|
from app.models.folder import Folder
|
||||||
from app.models.user import User
|
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.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=["文件夹"])
|
router = APIRouter(prefix="/api/folders", tags=["文件夹"])
|
||||||
|
|
||||||
|
|
||||||
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
|
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
|
||||||
"""递归构建文件夹树"""
|
|
||||||
tree = []
|
tree = []
|
||||||
for folder in folders:
|
for folder in folders:
|
||||||
if folder.parent_id == parent_id:
|
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,
|
id=folder.id,
|
||||||
name=folder.name,
|
name=folder.name,
|
||||||
parent_id=folder.parent_id,
|
parent_id=folder.parent_id,
|
||||||
children=children
|
children=children,
|
||||||
))
|
))
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[FolderTreeOut])
|
@router.get("", response_model=List[FolderTreeOut])
|
||||||
async def get_folders(
|
async def get_folders(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取用户的完整文件夹树"""
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Folder).where(Folder.user_id == current_user.id)
|
select(Folder).where(Folder.user_id == current_user.id)
|
||||||
)
|
)
|
||||||
folders = result.scalars().all()
|
folders = result.scalars().all()
|
||||||
return build_folder_tree(list(folders))
|
return build_folder_tree(list(folders))
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_folder(
|
async def create_folder(
|
||||||
folder_data: FolderCreate,
|
folder_data: FolderCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""创建文件夹"""
|
|
||||||
# 验证父文件夹存在且属于当前用户
|
|
||||||
if folder_data.parent_id:
|
if folder_data.parent_id:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Folder).where(
|
select(Folder).where(
|
||||||
@@ -53,13 +55,12 @@ async def create_folder(
|
|||||||
if not result.scalar_one_or_none():
|
if not result.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=404, detail="父文件夹不存在")
|
raise HTTPException(status_code=404, detail="父文件夹不存在")
|
||||||
|
|
||||||
# 检查同名文件夹
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Folder).where(
|
select(Folder).where(
|
||||||
and_(
|
and_(
|
||||||
Folder.user_id == current_user.id,
|
Folder.user_id == current_user.id,
|
||||||
Folder.parent_id == folder_data.parent_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(
|
folder = Folder(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
name=folder_data.name,
|
name=folder_data.name,
|
||||||
parent_id=folder_data.parent_id
|
parent_id=folder_data.parent_id,
|
||||||
)
|
)
|
||||||
db.add(folder)
|
db.add(folder)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(folder)
|
await db.refresh(folder)
|
||||||
|
|
||||||
|
document_service = DocumentService(db, current_user.id)
|
||||||
|
await document_service.ensure_folder_directory(current_user.id, folder.id)
|
||||||
return folder
|
return folder
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{folder_id}", response_model=FolderOut)
|
@router.put("/{folder_id}", response_model=FolderOut)
|
||||||
async def rename_folder(
|
async def rename_folder(
|
||||||
folder_id: str,
|
folder_id: str,
|
||||||
folder_data: FolderUpdate,
|
folder_data: FolderUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""重命名文件夹"""
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Folder).where(
|
select(Folder).where(
|
||||||
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
||||||
@@ -93,18 +97,22 @@ async def rename_folder(
|
|||||||
if not folder:
|
if not folder:
|
||||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
raise HTTPException(status_code=404, detail="文件夹不存在")
|
||||||
|
|
||||||
|
old_name = folder.name
|
||||||
folder.name = folder_data.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.commit()
|
||||||
await db.refresh(folder)
|
await db.refresh(folder)
|
||||||
return folder
|
return folder
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_folder(
|
async def delete_folder(
|
||||||
folder_id: str,
|
folder_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
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.models.document import Document
|
||||||
from app.services.knowledge_service import KnowledgeService
|
from app.services.knowledge_service import KnowledgeService
|
||||||
|
|
||||||
@@ -117,15 +125,16 @@ async def delete_folder(
|
|||||||
if not folder:
|
if not folder:
|
||||||
raise HTTPException(status_code=404, detail="文件夹不存在")
|
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):
|
async def delete_recursive(fid: str):
|
||||||
# 删除子文件夹(先递归)
|
|
||||||
children = await db.execute(
|
children = await db.execute(
|
||||||
select(Folder).where(Folder.parent_id == fid)
|
select(Folder).where(Folder.parent_id == fid)
|
||||||
)
|
)
|
||||||
for child in children.scalars():
|
for child in children.scalars():
|
||||||
await delete_recursive(child.id)
|
await delete_recursive(child.id)
|
||||||
|
|
||||||
# 删除文档
|
|
||||||
docs = await db.execute(
|
docs = await db.execute(
|
||||||
select(Document).where(Document.folder_id == fid)
|
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 knowledge_service.delete_from_vectorstore(current_user.id, doc.id)
|
||||||
await db.delete(doc)
|
await db.delete(doc)
|
||||||
|
|
||||||
# 删除文件夹本身
|
|
||||||
folder_to_delete = await db.get(Folder, fid)
|
folder_to_delete = await db.get(Folder, fid)
|
||||||
if folder_to_delete:
|
if folder_to_delete:
|
||||||
await db.delete(folder_to_delete)
|
await db.delete(folder_to_delete)
|
||||||
|
|
||||||
await delete_recursive(folder_id)
|
await delete_recursive(folder_id)
|
||||||
await db.commit()
|
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)
|
||||||
348
backend/app/routers/tools.py
Normal file
348
backend/app/routers/tools.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
"""Tools API Router
|
||||||
|
|
||||||
|
聚合两套工具体系的元数据:
|
||||||
|
1. 注册层 (app/tools/) - YAML manifest 定义
|
||||||
|
2. Agent 层 (app/agents/tools/) - @tool 装饰器定义
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.routers.auth import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.tools import (
|
||||||
|
ToolsResponse,
|
||||||
|
ToolCategory,
|
||||||
|
ToolSubgroup,
|
||||||
|
ToolInfo,
|
||||||
|
ToolCommand,
|
||||||
|
ToolStats,
|
||||||
|
ToolSummary,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/tools", tags=["Tools"])
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 辅助函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_command_from_docstring(docstring: str) -> dict:
|
||||||
|
"""从函数的 docstring 解析参数信息"""
|
||||||
|
params = {"type": "object", "properties": {}, "required": []}
|
||||||
|
if not docstring:
|
||||||
|
return params
|
||||||
|
|
||||||
|
# 简单解析 Args: 段落
|
||||||
|
args_match = re.search(
|
||||||
|
r"Args:\s*(.*?)(?=\n\s*(?:Returns?|Raises?)|$", docstring, re.DOTALL | re.IGNORECASE
|
||||||
|
)
|
||||||
|
if args_match:
|
||||||
|
args_section = args_match.group(1)
|
||||||
|
# 匹配形如 "arg_name (type): description" 的行
|
||||||
|
for line in args_section.strip().split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
# 匹配: "name (type): description" 或 "name: description"
|
||||||
|
m = re.match(r"(\w+)\s*(?:\(\s*(\w+)\s*\))?\s*:", line)
|
||||||
|
if m:
|
||||||
|
param_name = m.group(1)
|
||||||
|
params["properties"][param_name] = {"type": "string", "description": line}
|
||||||
|
params["required"].append(param_name)
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def _build_agent_tools() -> list[ToolInfo]:
|
||||||
|
"""扫描 app/agents/tools/ 目录,内省 @tool 装饰器"""
|
||||||
|
tools: list[ToolInfo] = []
|
||||||
|
|
||||||
|
# 分类映射:文件名 -> (分类名, 子分类名)
|
||||||
|
category_map = {
|
||||||
|
"search": ("Agent层", "知识检索"),
|
||||||
|
"schedule": ("Agent层", "日程管理"),
|
||||||
|
"task": ("Agent层", "任务管理"),
|
||||||
|
"forum": ("Agent层", "论坛功能"),
|
||||||
|
"time_reasoning": ("Agent层", "时间推理"),
|
||||||
|
"builtins/file_tools": ("Agent层", "文件工具"),
|
||||||
|
"builtins/system_tools": ("Agent层", "系统命令"),
|
||||||
|
"builtins/dev_tools": ("Agent层", "开发工具"),
|
||||||
|
"builtins/collaboration_tools": ("Agent层", "协作工具"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工具名称 -> 中文显示名
|
||||||
|
display_names = {
|
||||||
|
"search_knowledge": "知识库搜索",
|
||||||
|
"get_knowledge_graph_context": "知识图谱查询",
|
||||||
|
"build_knowledge_graph": "构建知识图谱",
|
||||||
|
"hybrid_search": "混合搜索",
|
||||||
|
"web_search": "联网搜索",
|
||||||
|
"get_schedule_day": "获取日程",
|
||||||
|
"create_todo": "创建待办",
|
||||||
|
"create_schedule_task": "创建日程任务",
|
||||||
|
"create_reminder": "创建提醒",
|
||||||
|
"create_goal": "创建目标",
|
||||||
|
"get_tasks": "获取任务列表",
|
||||||
|
"create_task": "创建任务",
|
||||||
|
"update_task_status": "更新任务状态",
|
||||||
|
"get_forum_posts": "获取论坛帖子",
|
||||||
|
"create_forum_post": "发布论坛帖子",
|
||||||
|
"scan_forum_for_instructions": "扫描论坛指令",
|
||||||
|
"resolve_time_expression": "解析时间表达式",
|
||||||
|
"glob": "文件路径匹配",
|
||||||
|
"grep": "文件内容搜索",
|
||||||
|
"read_file": "读取文件",
|
||||||
|
"write_file": "写入文件",
|
||||||
|
"bash": "Bash命令",
|
||||||
|
"powershell": "PowerShell命令",
|
||||||
|
"git": "Git操作",
|
||||||
|
"lsp_tools": "LSP代码导航",
|
||||||
|
"team_agent": "团队Agent通信",
|
||||||
|
"task_broadcast": "任务广播",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工具描述
|
||||||
|
descriptions = {
|
||||||
|
"search_knowledge": "搜索用户的私人知识库,返回最相关的文档片段",
|
||||||
|
"get_knowledge_graph_context": "获取用户知识图谱的上下文信息",
|
||||||
|
"build_knowledge_graph": "从文档构建/更新知识图谱",
|
||||||
|
"hybrid_search": "混合搜索,结合向量语义检索和关键词匹配",
|
||||||
|
"web_search": "通过 SearxNG 搜索外部网页信息",
|
||||||
|
"get_schedule_day": "获取指定日期的 todo/task/reminder/goal 聚合信息",
|
||||||
|
"create_todo": "创建指定日期的待办",
|
||||||
|
"create_schedule_task": "创建任务,支持优先级和截止日期",
|
||||||
|
"create_reminder": "创建提醒,支持自然语言时间",
|
||||||
|
"create_goal": "创建指定日期的目标",
|
||||||
|
"get_tasks": "获取用户当前的任务列表",
|
||||||
|
"create_task": "创建新任务",
|
||||||
|
"update_task_status": "更新任务状态",
|
||||||
|
"get_forum_posts": "获取论坛帖子列表",
|
||||||
|
"create_forum_post": "在论坛发布新帖子",
|
||||||
|
"scan_forum_for_instructions": "扫描论坛中的指令类帖子",
|
||||||
|
"resolve_time_expression": "解析中文自然语言时间表达",
|
||||||
|
"glob": "使用 glob 模式查找文件路径",
|
||||||
|
"grep": "在文件中搜索匹配的文本行",
|
||||||
|
"read_file": "读取文件内容",
|
||||||
|
"write_file": "写入文件内容",
|
||||||
|
"bash": "执行 Bash 命令",
|
||||||
|
"powershell": "执行 PowerShell 命令",
|
||||||
|
"git": "执行 Git 命令",
|
||||||
|
"lsp_tools": "LSP 代码导航和查找引用",
|
||||||
|
"team_agent": "向团队 Agent 发送消息或请求协作",
|
||||||
|
"task_broadcast": "向多个 Agent 广播任务",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 需要扫描的模块
|
||||||
|
modules_to_scan = [
|
||||||
|
("app.agents.tools.search", "search"),
|
||||||
|
("app.agents.tools.schedule", "schedule"),
|
||||||
|
("app.agents.tools.task", "task"),
|
||||||
|
("app.agents.tools.forum", "forum"),
|
||||||
|
("app.agents.tools.time_reasoning", "time_reasoning"),
|
||||||
|
("app.agents.tools.builtins.file_tools", "builtins/file_tools"),
|
||||||
|
("app.agents.tools.builtins.system_tools", "builtins/system_tools"),
|
||||||
|
("app.agents.tools.builtins.dev_tools", "builtins/dev_tools"),
|
||||||
|
("app.agents.tools.builtins.collaboration_tools", "builtins/collaboration_tools"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for module_name, category_key in modules_to_scan:
|
||||||
|
try:
|
||||||
|
mod = importlib.import_module(module_name)
|
||||||
|
except ImportError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 扫描模块中所有 @tool 装饰的函数
|
||||||
|
for attr_name in dir(mod):
|
||||||
|
if attr_name.startswith("_"):
|
||||||
|
continue
|
||||||
|
attr = getattr(mod, attr_name)
|
||||||
|
# 检查是否是 langchain @tool 装饰的对象
|
||||||
|
if hasattr(attr, "name") and hasattr(attr, "description"):
|
||||||
|
tool_name = attr.name
|
||||||
|
tool_desc = attr.description or ""
|
||||||
|
# 清理 docstring 中的参数说明用于显示
|
||||||
|
display_desc = re.sub(r"\s*Args:\s*.*", "", tool_desc, flags=re.DOTALL).strip()
|
||||||
|
display_desc = re.sub(
|
||||||
|
r"\s*Returns?:\s*.*", "", display_desc, flags=re.DOTALL
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
# 获取 category 和 subcategory
|
||||||
|
cat_info = category_map.get(category_key, ("Agent层", category_key))
|
||||||
|
category, subcategory = cat_info[0], cat_info[1]
|
||||||
|
|
||||||
|
# 获取参数 schema
|
||||||
|
params_schema = getattr(attr, "args_schema", None)
|
||||||
|
parameters = {}
|
||||||
|
if params_schema:
|
||||||
|
try:
|
||||||
|
if hasattr(params_schema, "model_json_schema"):
|
||||||
|
parameters = params_schema.model_json_schema()
|
||||||
|
elif hasattr(params_schema, "schema"):
|
||||||
|
parameters = params_schema.schema()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
tool_info = ToolInfo(
|
||||||
|
name=tool_name,
|
||||||
|
display_name=display_names.get(tool_name, tool_name),
|
||||||
|
description=descriptions.get(tool_name, display_desc or tool_desc),
|
||||||
|
category=category,
|
||||||
|
subcategory=subcategory,
|
||||||
|
source="agent",
|
||||||
|
source_file=module_name,
|
||||||
|
tags=[],
|
||||||
|
enabled=True,
|
||||||
|
commands=[
|
||||||
|
ToolCommand(
|
||||||
|
name=tool_name,
|
||||||
|
description=tool_desc or display_desc,
|
||||||
|
parameters=parameters,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
stats=ToolStats(),
|
||||||
|
)
|
||||||
|
tools.append(tool_info)
|
||||||
|
|
||||||
|
return tools
|
||||||
|
|
||||||
|
|
||||||
|
def _build_manifest_tools() -> list[ToolInfo]:
|
||||||
|
"""从 YAML manifest 构建工具信息"""
|
||||||
|
tools: list[ToolInfo] = []
|
||||||
|
|
||||||
|
# manifest 文件 -> 分类映射
|
||||||
|
manifest_map = {
|
||||||
|
"file_operator": (
|
||||||
|
"注册层",
|
||||||
|
"文件操作",
|
||||||
|
[
|
||||||
|
ToolCommand(name="read_file", description="读取指定路径的文件内容"),
|
||||||
|
ToolCommand(name="write_file", description="将内容写入文件"),
|
||||||
|
ToolCommand(name="list_directory", description="列出目录内容"),
|
||||||
|
ToolCommand(name="search_files", description="递归搜索匹配模式的文件"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"task_manager": (
|
||||||
|
"注册层",
|
||||||
|
"任务管理",
|
||||||
|
[
|
||||||
|
ToolCommand(name="create_task", description="创建新任务"),
|
||||||
|
ToolCommand(name="list_tasks", description="列出任务"),
|
||||||
|
ToolCommand(name="get_task", description="获取任务详情"),
|
||||||
|
ToolCommand(name="complete_task", description="标记任务完成"),
|
||||||
|
ToolCommand(name="fail_task", description="标记任务失败"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"web_fetch": (
|
||||||
|
"注册层",
|
||||||
|
"网页抓取",
|
||||||
|
[
|
||||||
|
ToolCommand(name="fetch", description="抓取网页内容"),
|
||||||
|
ToolCommand(name="screenshot", description="截取网页截图"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"web_search": (
|
||||||
|
"注册层",
|
||||||
|
"联网搜索",
|
||||||
|
[
|
||||||
|
ToolCommand(name="search", description="执行语义级搜索"),
|
||||||
|
ToolCommand(name="deep_search", description="深度搜索,带摘要生成"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest_descriptions = {
|
||||||
|
"file_operator": "强大的文件系统操作工具,支持读写、搜索、下载等功能",
|
||||||
|
"task_manager": "任务创建、查询、更新和状态管理",
|
||||||
|
"web_fetch": "网页内容抓取工具,支持 HTML 解析、截图等功能",
|
||||||
|
"web_search": "语义级并发搜索引擎,支持多源搜索和结果聚合",
|
||||||
|
}
|
||||||
|
|
||||||
|
for tool_name, (category, subcategory, commands) in manifest_map.items():
|
||||||
|
tool_info = ToolInfo(
|
||||||
|
name=tool_name,
|
||||||
|
display_name=subcategory,
|
||||||
|
description=manifest_descriptions.get(tool_name, ""),
|
||||||
|
category=category,
|
||||||
|
subcategory=subcategory,
|
||||||
|
source="manifest",
|
||||||
|
source_file=f"app/tools/manifests/{tool_name}.yaml",
|
||||||
|
tags=[],
|
||||||
|
enabled=True,
|
||||||
|
commands=commands,
|
||||||
|
stats=ToolStats(),
|
||||||
|
)
|
||||||
|
tools.append(tool_info)
|
||||||
|
|
||||||
|
return tools
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 路由
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ToolsResponse)
|
||||||
|
async def list_tools(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""获取所有内置工具列表(只读)"""
|
||||||
|
# 构建工具列表
|
||||||
|
manifest_tools = _build_manifest_tools()
|
||||||
|
agent_tools = _build_agent_tools()
|
||||||
|
|
||||||
|
all_tools = manifest_tools + agent_tools
|
||||||
|
|
||||||
|
# 按 category 和 subcategory 分组
|
||||||
|
category_map: dict[str, dict[str, list[ToolInfo]]] = {
|
||||||
|
"注册层": {},
|
||||||
|
"Agent层": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for tool in all_tools:
|
||||||
|
cat = tool.category
|
||||||
|
subcat = tool.subcategory
|
||||||
|
if cat not in category_map:
|
||||||
|
category_map[cat] = {}
|
||||||
|
if subcat not in category_map[cat]:
|
||||||
|
category_map[cat][subcat] = []
|
||||||
|
category_map[cat][subcat].append(tool)
|
||||||
|
|
||||||
|
# 构建响应
|
||||||
|
categories = []
|
||||||
|
for cat_name, subgroups_dict in category_map.items():
|
||||||
|
if not subgroups_dict:
|
||||||
|
continue
|
||||||
|
subgroups = []
|
||||||
|
for subcat_name, tools_list in subgroups_dict.items():
|
||||||
|
subgroups.append(
|
||||||
|
ToolSubgroup(
|
||||||
|
name=subcat_name,
|
||||||
|
display_name=subcat_name,
|
||||||
|
tools=tools_list,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
categories.append(
|
||||||
|
ToolCategory(
|
||||||
|
name=cat_name,
|
||||||
|
display_name=cat_name,
|
||||||
|
subgroups=subgroups,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 计算摘要
|
||||||
|
total_commands = sum(len(t.commands) for t in all_tools)
|
||||||
|
active_commands = sum(len(t.commands) for t in all_tools if t.enabled)
|
||||||
|
|
||||||
|
summary = ToolSummary(
|
||||||
|
total_commands=total_commands,
|
||||||
|
active_commands=active_commands,
|
||||||
|
total_tools=len(all_tools),
|
||||||
|
manifest_tools=len(manifest_tools),
|
||||||
|
agent_tools=len(agent_tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ToolsResponse(categories=categories, summary=summary)
|
||||||
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()
|
||||||
76
backend/app/schemas/tools.py
Normal file
76
backend/app/schemas/tools.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Tools API Schemas"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCommand(BaseModel):
|
||||||
|
"""单个工具命令"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
parameters: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ToolStats(BaseModel):
|
||||||
|
"""工具调用统计"""
|
||||||
|
|
||||||
|
call_count: int = 0
|
||||||
|
error_count: int = 0
|
||||||
|
total_duration_ms: int = 0
|
||||||
|
avg_duration_ms: int = 0
|
||||||
|
error_rate: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class ToolInfo(BaseModel):
|
||||||
|
"""工具完整信息"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str
|
||||||
|
category: str # 中文分类名
|
||||||
|
subcategory: str = "" # 子分类
|
||||||
|
source: str # "manifest" | "agent"
|
||||||
|
source_file: str = "" # 来源文件路径
|
||||||
|
tags: list[str] = []
|
||||||
|
enabled: bool = True
|
||||||
|
commands: list[ToolCommand] = []
|
||||||
|
stats: Optional[ToolStats] = None
|
||||||
|
config: dict = {} # 配置参数(只读)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCategory(BaseModel):
|
||||||
|
"""工具分类"""
|
||||||
|
|
||||||
|
name: str # 大分类:注册层 / Agent层
|
||||||
|
display_name: str # 中文显示名
|
||||||
|
subgroups: list["ToolSubgroup"] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ToolSubgroup(BaseModel):
|
||||||
|
"""工具子分类"""
|
||||||
|
|
||||||
|
name: str # 子分类名
|
||||||
|
display_name: str # 中文显示名
|
||||||
|
tools: list[ToolInfo] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ToolSummary(BaseModel):
|
||||||
|
"""工具统计摘要"""
|
||||||
|
|
||||||
|
total_commands: int = 0
|
||||||
|
active_commands: int = 0
|
||||||
|
total_tools: int = 0
|
||||||
|
manifest_tools: int = 0
|
||||||
|
agent_tools: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ToolsResponse(BaseModel):
|
||||||
|
"""GET /api/tools 响应"""
|
||||||
|
|
||||||
|
categories: list[ToolCategory]
|
||||||
|
summary: ToolSummary
|
||||||
|
|
||||||
|
|
||||||
|
# 更新前向引用
|
||||||
|
ToolCategory.model_rebuild()
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import shutil
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
@@ -18,7 +19,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
@@ -52,9 +52,9 @@ class DocumentService:
|
|||||||
if ext not in ALLOWED_EXTENSIONS:
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
raise ValueError(f"不支持的文件类型: {ext}")
|
raise ValueError(f"不支持的文件类型: {ext}")
|
||||||
|
|
||||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
folder_path = await self._get_storage_directory(user_id, folder_id)
|
||||||
file_id = str(uuid.uuid4())
|
folder_path.mkdir(parents=True, exist_ok=True)
|
||||||
file_path = os.path.join(settings.UPLOAD_DIR, f"{file_id}{ext}")
|
file_path = self._resolve_unique_file_path(folder_path, file.filename)
|
||||||
|
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
file_size = len(content)
|
file_size = len(content)
|
||||||
@@ -64,7 +64,7 @@ class DocumentService:
|
|||||||
async with aiofiles.open(file_path, "wb") as f:
|
async with aiofiles.open(file_path, "wb") as f:
|
||||||
await f.write(content)
|
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)
|
parsed.structured_markdown = self._render_structured_markdown(parsed)
|
||||||
|
|
||||||
doc = Document(
|
doc = Document(
|
||||||
@@ -73,7 +73,7 @@ class DocumentService:
|
|||||||
filename=file.filename,
|
filename=file.filename,
|
||||||
file_type=ext[1:],
|
file_type=ext[1:],
|
||||||
file_size=file_size,
|
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,
|
summary=parsed.summary[:500] if len(parsed.summary) > 500 else parsed.summary,
|
||||||
folder_id=folder_id,
|
folder_id=folder_id,
|
||||||
ingestion_status="uploaded",
|
ingestion_status="uploaded",
|
||||||
@@ -171,6 +171,83 @@ class DocumentService:
|
|||||||
|
|
||||||
return "/" + "/".join(path_parts) if path_parts else None
|
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):
|
async def delete_document(self, user_id: str, document_id: str):
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(Document).where(
|
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
|
import app.models # noqa: F401
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.models.document import Document, DocumentChunk
|
from app.models.document import Document, DocumentChunk
|
||||||
|
from app.models.folder import Folder
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.auth_service import get_password_hash
|
from app.services.auth_service import get_password_hash
|
||||||
from app.services.document_service import DocumentService
|
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'
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_upload_document_extracts_docx_heading_and_table_structure(document_test_env):
|
async def test_upload_document_extracts_docx_heading_and_table_structure(document_test_env):
|
||||||
session, user = 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()
|
||||||
165
development-doc/plan/temple-update/README.md
Normal file
165
development-doc/plan/temple-update/README.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# 智慧神殿(Temple)升级计划索引
|
||||||
|
|
||||||
|
本目录用于存放智慧神殿(Temple)页面的升级规划文档。
|
||||||
|
|
||||||
|
## 文档说明
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `README.md` | 总览、阶段关系、实施顺序、当前状态 |
|
||||||
|
| `phase-0-current-state.md` | 当前现状、问题、目标架构 |
|
||||||
|
| `phase-1-tools-api.md` | 后端 Tools API 开发 |
|
||||||
|
| `phase-2-tools-frontend.md` | Tools Tab 前端实现 |
|
||||||
|
| `phase-3-skills-integration.md` | Skills Tab 复用集成 |
|
||||||
|
| `checklist.md` | 执行清单 |
|
||||||
|
|
||||||
|
## 推荐阅读顺序
|
||||||
|
|
||||||
|
1. 先读 `README.md`(本文)
|
||||||
|
2. 再读 `phase-0-current-state.md`
|
||||||
|
3. 再按顺序阅读 phase 1 ~ 3
|
||||||
|
4. 参考 `checklist.md` 进行任务追踪
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前总体状态(2026-04-08)
|
||||||
|
|
||||||
|
| Phase | 当前状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Phase 0 | 已完成 | 现状梳理完毕,本文档 |
|
||||||
|
| Phase 1 | 待开始 | 后端 Tools API 开发 |
|
||||||
|
| Phase 2 | 待开始 | 前端 Tools Tab 实现 |
|
||||||
|
| Phase 3 | 待开始 | Skills Tab 复用集成 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总体升级原则
|
||||||
|
|
||||||
|
1. **Tools 只读不做编辑** - 系统内置工具不允许手动修改,防止配置破坏
|
||||||
|
2. **Skills 以 DB 为 source of truth** - UI 操作 DB,后端自动生成 `.md` 文件,用户不直接碰代码
|
||||||
|
3. **复用现有 Skills 页面** - 已有完整 CRUD,改动成本最低
|
||||||
|
4. **MCP 暂不纳入** - 当前仅为概念性能力包,后期独立需求
|
||||||
|
5. **样式沿用现有体系** - 复用 `chatPage.css` 的深色终端风格 + `jarvis-*` CSS 变量
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段关系图
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 0 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 现状与目标 │
|
||||||
|
│ - Temple 页面现状分析 │
|
||||||
|
│ - Tools 系统梳理 │
|
||||||
|
│ - Skills 系统梳理 │
|
||||||
|
│ - 设计决策 │
|
||||||
|
│ 状态:已完成 │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 1 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 后端 Tools API │
|
||||||
|
│ - GET /api/tools 接口开发 │
|
||||||
|
│ - ToolRegistry 聚合所有工具 │
|
||||||
|
│ - 聚合两套工具体系元数据 │
|
||||||
|
│ │
|
||||||
|
│ 核心文件: app/routers/tools.py │
|
||||||
|
│ 依赖: 无 │
|
||||||
|
│ 工作量: 1 天 │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 2 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 前端 Tools Tab │
|
||||||
|
│ - useTemple.ts composable │
|
||||||
|
│ - Tools 分类树实现 │
|
||||||
|
│ - 工具详情面板 │
|
||||||
|
│ - Metrics Strip 统计行 │
|
||||||
|
│ │
|
||||||
|
│ 核心文件: frontend/src/pages/temple/ │
|
||||||
|
│ 依赖: Phase 1 │
|
||||||
|
│ 工作量: 2 天 │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 3 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Skills Tab 复用集成 │
|
||||||
|
│ - 确认现有 Skills 页面功能完整 │
|
||||||
|
│ - 与 Temple 页面 Tab 切换联动 │
|
||||||
|
│ - 样式一致性检查 │
|
||||||
|
│ │
|
||||||
|
│ 核心文件: frontend/src/pages/temple/, frontend/src/pages/skills/ │
|
||||||
|
│ 依赖: Phase 2 │
|
||||||
|
│ 工作量: 0.5 天 │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 两套 Tools 体系梳理
|
||||||
|
|
||||||
|
### 注册层工具(`app/tools/`)
|
||||||
|
|
||||||
|
| 工具 | Manifest | 命令数 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `file_operator` | `manifests/file_operator.yaml` | 4 |
|
||||||
|
| `task_manager` | `manifests/task_manager.yaml` | 5 |
|
||||||
|
| `web_fetch` | `manifests/web_fetch.yaml` | 2 |
|
||||||
|
| `web_search` | `manifests/web_search.yaml` | 2 |
|
||||||
|
|
||||||
|
### Agent 内置层工具(`app/agents/tools/`)
|
||||||
|
|
||||||
|
| 类别 | 工具数 | 来源文件 |
|
||||||
|
|------|--------|---------|
|
||||||
|
| 文件操作 | 4 | `builtins/file_tools.py` |
|
||||||
|
| 系统命令 | 2 | `builtins/system_tools.py` |
|
||||||
|
| 开发工具 | 2 | `builtins/dev_tools.py` |
|
||||||
|
| 协作工具 | 2 | `builtins/collaboration_tools.py` |
|
||||||
|
| 知识检索 | 5 | `search.py` |
|
||||||
|
| 日程管理 | 5 | `schedule.py` |
|
||||||
|
| 任务管理 | 3 | `task.py` |
|
||||||
|
| 论坛功能 | 3 | `forum.py` |
|
||||||
|
| 时间推理 | 1 | `time_reasoning.py` |
|
||||||
|
|
||||||
|
**合计约 34 个工具命令**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计决策记录
|
||||||
|
|
||||||
|
| 决策 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| Tools 只读不做编辑 | 系统内置工具不允许用户手动修改,防止配置破坏 |
|
||||||
|
| 不引入 MCP 管理 | 当前 MCP 仅为概念性能力包,无实际 server 连接需求,后期独立需求 |
|
||||||
|
| Skills 以 DB 为 source of truth | UI 操作 DB,后端同步生成 .md 文件,用户不直接碰代码 |
|
||||||
|
| 复用现有 Skills 页面 | 已有完整 CRUD,改动成本最低 |
|
||||||
|
| 按工具来源分类 | 与代码结构对应,用户可追溯工具定义位置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件变更追踪
|
||||||
|
|
||||||
|
| Phase | 新增文件 | 修改文件 |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| Phase 1 | `app/routers/tools.py`, `app/schemas/tools.py` | `app/main.py`(注册路由) |
|
||||||
|
| Phase 2 | `frontend/src/pages/temple/index.vue`, `templePage.css`, `composables/useTemple.ts`, `frontend/src/api/tools.ts` | `frontend/src/pages/temple/index.vue`(重写占位页) |
|
||||||
|
| Phase 3 | 无 | `frontend/src/pages/temple/index.vue`(Tab 切换逻辑) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与其他 Phase 的关系
|
||||||
|
|
||||||
|
| 相关模块 | 协作内容 |
|
||||||
|
|---------|---------|
|
||||||
|
| Skills Registry (agent-update Phase 9) | Skills 的 DB 层由 `/api/skills` 提供,文件层由 SkillRegistry 管理 |
|
||||||
|
| Tool System (tool-update T.1-T.4) | Temple 展示的 Tools 元数据来自 tool-update 建立的 manifest 系统 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总工作量
|
||||||
|
|
||||||
|
| Phase | 工作量 |
|
||||||
|
|-------|--------|
|
||||||
|
| Phase 1 | 1 天 |
|
||||||
|
| Phase 2 | 2 天 |
|
||||||
|
| Phase 3 | 0.5 天 |
|
||||||
|
| **总计** | **3.5 天** |
|
||||||
60
development-doc/plan/temple-update/checklist.md
Normal file
60
development-doc/plan/temple-update/checklist.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 智慧神殿(Temple)执行清单
|
||||||
|
|
||||||
|
> 更新日期:2026-04-08
|
||||||
|
> 总工作量:3.5 天
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1:后端 Tools API
|
||||||
|
|
||||||
|
| 序号 | 任务 | 状态 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 1.1 | 创建 `app/schemas/tools.py`,定义 Pydantic Schema | 待开始 | |
|
||||||
|
| 1.2 | 创建 `app/routers/tools.py`,实现 `GET /api/tools` | 待开始 | |
|
||||||
|
| 1.3 | 实现 ToolRegistry 工具元数据聚合 | 待开始 | 复用 `list_all()` |
|
||||||
|
| 1.4 | 实现 Agent 层工具扫描(内省 `@tool` 装饰器) | 待开始 | 扫描 `app/agents/tools/` |
|
||||||
|
| 1.5 | 实现分类分组逻辑(注册层 / Agent 层) | 待开始 | |
|
||||||
|
| 1.6 | 在 `app/main.py` 注册路由 | 待开始 | |
|
||||||
|
| 1.7 | 本地测试 `GET /api/tools` 返回正确数据 | 待开始 | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2:前端 Tools Tab
|
||||||
|
|
||||||
|
| 序号 | 任务 | 状态 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 2.1 | 创建 `frontend/src/api/tools.ts` API 客户端 | 待开始 | |
|
||||||
|
| 2.2 | 创建 `frontend/src/pages/temple/composables/useTemple.ts` | 待开始 | |
|
||||||
|
| 2.3 | 实现 Tab 切换器组件 | 待开始 | Tools / Skills 切换 |
|
||||||
|
| 2.4 | 实现 Metrics Strip 统计行 | 待开始 | |
|
||||||
|
| 2.5 | 实现分类树组件(两极结构) | 待开始 | |
|
||||||
|
| 2.6 | 实现工具列表(无选中时) | 待开始 | 卡片形式 |
|
||||||
|
| 2.7 | 实现工具详情面板 | 待开始 | 含 Commands 列表 |
|
||||||
|
| 2.8 | 创建 `templePage.css` 样式 | 待开始 | 复用 jarvis-* 变量 |
|
||||||
|
| 2.9 | 重写 `frontend/src/pages/temple/index.vue` | 待开始 | 替换占位符 |
|
||||||
|
| 2.10 | 联调后端 API,数据正确渲染 | 待开始 | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3:Skills Tab 复用集成
|
||||||
|
|
||||||
|
| 序号 | 任务 | 状态 | 备注 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 3.1 | 将 Skills 页面集成到 Temple Skills Tab | 待开始 | 推荐方案 A(条件渲染) |
|
||||||
|
| 3.2 | Tab 切换逻辑实现 | 待开始 | |
|
||||||
|
| 3.3 | Skills CRUD 功能验证 | 待开始 | 创建/编辑/删除/启用/禁用 |
|
||||||
|
| 3.4 | Skills Modal 和 Drawer 交互验证 | 待开始 | |
|
||||||
|
| 3.5 | Skills Tab 下 Metrics Strip 切换指标 | 待开始 | 显示 Skills 指标 |
|
||||||
|
| 3.6 | Tab 切换状态保持验证 | 待开始 | 不丢失选中状态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- [ ] `GET /api/tools` 返回 200,响应结构正确
|
||||||
|
- [ ] Temple 页面加载无报错
|
||||||
|
- [ ] Tools Tab 显示所有工具分类
|
||||||
|
- [ ] 点击工具有详情(Commands 列表完整)
|
||||||
|
- [ ] Skills Tab 下 Skills CRUD 全部正常
|
||||||
|
- [ ] 样式与 Jarvis 整体风格一致
|
||||||
|
- [ ] 无前端 console.error
|
||||||
171
development-doc/plan/temple-update/phase-0-current-state.md
Normal file
171
development-doc/plan/temple-update/phase-0-current-state.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Phase 0:智慧神殿现状与目标
|
||||||
|
|
||||||
|
日期:2026-04-08
|
||||||
|
状态:已完成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 本阶段目的
|
||||||
|
|
||||||
|
本文件用于统一背景认知,明确:
|
||||||
|
|
||||||
|
- Temple 页面当前处于什么状态
|
||||||
|
- 主要短板是什么
|
||||||
|
- 为什么要升级
|
||||||
|
- 升级后的目标形态是什么
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 当前 Temple 页面状态
|
||||||
|
|
||||||
|
### 2.1 现有实现
|
||||||
|
|
||||||
|
`frontend/src/pages/temple/index.vue` 是一个**空白占位页**:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 智慧神殿 - Temple of Wisdom
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="temple-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>⛩️ 智慧神殿</h1>
|
||||||
|
<p class="subtitle">深邃智慧,永恒传承</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="placeholder-content">
|
||||||
|
<div class="temple-icon">🏛️</div>
|
||||||
|
<p>智慧神殿 - 敬请期待</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 触发入口
|
||||||
|
|
||||||
|
聊天输入框上方三个按钮之一(`◈`),跳转到 `/temple`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- frontend/src/pages/chat/index.vue -->
|
||||||
|
<div class="top-buttons-row">
|
||||||
|
<button class="top-action-btn" @click="$router.push('/temple')" title="Temple">
|
||||||
|
<span class="btn-icon temple-icon">◈</span>
|
||||||
|
</button>
|
||||||
|
<button class="top-action-btn" @click="$router.push('/knowledge')" title="Knowledge">
|
||||||
|
<span class="btn-icon knowledge-icon">◉</span>
|
||||||
|
</button>
|
||||||
|
<button class="top-action-btn" @click="$router.push('/war-room')" title="War Room">
|
||||||
|
<span class="btn-icon war-icon">⬡</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 当前系统现状
|
||||||
|
|
||||||
|
### 3.1 Tools 系统(两套并存)
|
||||||
|
|
||||||
|
#### A. 工具注册层(`app/tools/`)
|
||||||
|
|
||||||
|
已建立 manifest 驱动的工具注册体系:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/tools/
|
||||||
|
├── manifests/ # YAML manifest 定义
|
||||||
|
│ ├── file_operator.yaml # 4 commands: read_file, write_file, list_directory, search_files
|
||||||
|
│ ├── task_manager.yaml # 5 commands: create_task, list_tasks, get_task, complete_task, fail_task
|
||||||
|
│ ├── web_fetch.yaml # 2 commands: fetch, screenshot
|
||||||
|
│ └── web_search.yaml # 2 commands: search, deep_search
|
||||||
|
├── registry.py # ToolRegistry 动态注册中心
|
||||||
|
├── implementations/ # 工具 Python 实现
|
||||||
|
├── permissions.py # 权限控制
|
||||||
|
├── hooks/ # Hook 系统(审计日志、安全扫描、危险确认)
|
||||||
|
└── schemas/ # Pydantic Schema
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Agent 工具层(`app/agents/tools/`)
|
||||||
|
|
||||||
|
LangChain `@tool` 装饰器定义的 Agent 可用工具:
|
||||||
|
|
||||||
|
| 类别 | 工具 | 源文件 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 文件操作 | `glob`, `grep`, `read_file`, `write_file` | `builtins/file_tools.py` |
|
||||||
|
| 系统命令 | `bash`, `powershell` | `builtins/system_tools.py` |
|
||||||
|
| 开发工具 | `git`, `lsp_tools` | `builtins/dev_tools.py` |
|
||||||
|
| 协作工具 | `team_agent`, `task_broadcast` | `builtins/collaboration_tools.py` |
|
||||||
|
| 知识检索 | `search_knowledge`, `get_knowledge_graph_context`, `build_knowledge_graph`, `hybrid_search`, `web_search` | `search.py` |
|
||||||
|
| 日程管理 | `get_schedule_day`, `create_todo`, `create_schedule_task`, `create_reminder`, `create_goal` | `schedule.py` |
|
||||||
|
| 任务管理 | `get_tasks`, `create_task`, `update_task_status` | `task.py` |
|
||||||
|
| 论坛功能 | `get_forum_posts`, `create_forum_post`, `scan_forum_for_instructions` | `forum.py` |
|
||||||
|
| 时间推理 | `resolve_time_expression` | `time_reasoning.py` |
|
||||||
|
|
||||||
|
### 3.2 Skills 系统
|
||||||
|
|
||||||
|
#### A. DB 层
|
||||||
|
|
||||||
|
已有完整 CRUD:
|
||||||
|
|
||||||
|
- 路由:`/api/skills`
|
||||||
|
- 字段:`name`, `description`, `instructions`, `agent_type`, `tools`, `visibility`, `is_builtin`, `is_active`
|
||||||
|
- Agent types:`general`, `schedule_planner`, `executor`, `librarian`, `analyst`
|
||||||
|
- Visibility:`private`, `team`, `market`
|
||||||
|
|
||||||
|
#### B. 文件层
|
||||||
|
|
||||||
|
`SkillRegistry` 加载 `.md` 文件供 Agent 运行时使用。
|
||||||
|
|
||||||
|
加载器:
|
||||||
|
- `MCPSkillLoader` - MCP 能力包加载
|
||||||
|
- `LocalSkillLoader` - 本地 `.md` 文件加载
|
||||||
|
- `PluginLoader` - 插件式加载
|
||||||
|
|
||||||
|
### 3.3 当前问题
|
||||||
|
|
||||||
|
| 问题 | 影响 |
|
||||||
|
|------|------|
|
||||||
|
| Temple 页面是空白占位页 | 三个按钮入口之一完全无功能 |
|
||||||
|
| Tools 无统一展示入口 | 用户无法看到系统有哪些可用工具 |
|
||||||
|
| Tools 散落在两套体系 | manifest 层 + agent 层,用户无感知 |
|
||||||
|
| Skills 页面独立在 `/skills` | 工具和技能没有统一管理入口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 目标架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ /temple │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ [◈ 智慧神殿] [Tools] [Skills] │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ TOTAL: 30 ACTIVE: 28 AGENTS: 5 (Metrics) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────┐ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ [分类树] │ │ [工具详情] │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ ▼ 注册层 │ │ file_operator │ │
|
||||||
|
│ │ 文件操作 │ │ 描述: 强大的文件系统操作工具 │ │
|
||||||
|
│ │ 任务管理 │ │ 命令: 4 个 │ │
|
||||||
|
│ │ ▼ Agent层 │ │ 调用: 1,234 次 错误率: 0.2% │ │
|
||||||
|
│ │ 知识检索 │ │ │ │
|
||||||
|
│ │ 日程管理 │ │ [Commands] │ │
|
||||||
|
│ │ 任务管理 │ │ • read_file │ │
|
||||||
|
│ │ 论坛功能 │ │ • write_file │ │
|
||||||
|
│ │ 时间推理 │ │ • list_directory │ │
|
||||||
|
│ └────────────────┘ └─────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 本阶段产出要求
|
||||||
|
|
||||||
|
- [x] 团队对 Temple 当前状态和目标方向达成一致
|
||||||
|
- [x] Tools 系统两套并存的现状已梳理清楚
|
||||||
|
- [x] Skills 系统现有架构已梳理清楚
|
||||||
|
- [x] 后续 phase 文档能够在这个认知基础上展开
|
||||||
135
development-doc/plan/temple-update/phase-1-tools-api.md
Normal file
135
development-doc/plan/temple-update/phase-1-tools-api.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Phase 1:后端 Tools API 开发
|
||||||
|
|
||||||
|
日期:2026-04-08
|
||||||
|
状态:待开始
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 本阶段目的
|
||||||
|
|
||||||
|
开发 `GET /api/tools` 接口,聚合两套工具体系的元数据,为前端 Tools Tab 提供数据源。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心文件
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `app/routers/tools.py` | 新建,Tools 路由 |
|
||||||
|
| `app/schemas/tools.py` | 新建,Tools API Pydantic Schema |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API 设计
|
||||||
|
|
||||||
|
### 3.1 接口
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 响应结构
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ToolCommand(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
parameters: dict # JSON Schema
|
||||||
|
|
||||||
|
class ToolStats(BaseModel):
|
||||||
|
call_count: int
|
||||||
|
error_count: int
|
||||||
|
total_duration_ms: int
|
||||||
|
avg_duration_ms: int
|
||||||
|
error_rate: float
|
||||||
|
|
||||||
|
class ToolCategory(BaseModel):
|
||||||
|
name: str # 显示用中文分类名
|
||||||
|
source: str # "manifest" | "agent"
|
||||||
|
tools: list[ToolInfo]
|
||||||
|
|
||||||
|
class ToolInfo(BaseModel):
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str
|
||||||
|
category: str
|
||||||
|
tags: list[str]
|
||||||
|
enabled: bool
|
||||||
|
source: str # "manifest" | "agent"
|
||||||
|
commands: list[ToolCommand]
|
||||||
|
stats: ToolStats | None
|
||||||
|
|
||||||
|
class ToolsResponse(BaseModel):
|
||||||
|
categories: list[ToolCategory]
|
||||||
|
summary: dict:
|
||||||
|
total: int
|
||||||
|
active: int
|
||||||
|
by_source: dict
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 分类结构
|
||||||
|
|
||||||
|
按工具来源分为两大类:
|
||||||
|
|
||||||
|
**注册层(source: "manifest")**
|
||||||
|
|
||||||
|
| Category Name | 来源 |
|
||||||
|
|--------------|------|
|
||||||
|
| `文件操作` | `manifests/file_operator.yaml` |
|
||||||
|
| `任务管理` | `manifests/task_manager.yaml` |
|
||||||
|
| `网页抓取` | `manifests/web_fetch.yaml` |
|
||||||
|
| `联网搜索` | `manifests/web_search.yaml` |
|
||||||
|
|
||||||
|
**Agent 层(source: "agent")**
|
||||||
|
|
||||||
|
| Category Name | 来源 |
|
||||||
|
|--------------|------|
|
||||||
|
| `文件工具` | `builtins/file_tools.py` |
|
||||||
|
| `系统命令` | `builtins/system_tools.py` |
|
||||||
|
| `开发工具` | `builtins/dev_tools.py` |
|
||||||
|
| `协作工具` | `builtins/collaboration_tools.py` |
|
||||||
|
| `知识检索` | `search.py` |
|
||||||
|
| `日程管理` | `schedule.py` |
|
||||||
|
| `任务管理` | `task.py` |
|
||||||
|
| `论坛功能` | `forum.py` |
|
||||||
|
| `时间推理` | `time_reasoning.py` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 实现逻辑
|
||||||
|
|
||||||
|
### 4.1 数据聚合流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 从 ToolRegistry.list_all() 获取注册层工具元数据
|
||||||
|
2. 扫描 app/agents/tools/ 下所有 @tool 装饰器,获取 Agent 层工具
|
||||||
|
3. 合并两套数据,按 category 分组
|
||||||
|
4. 调用 ToolRegistry.get_stats() 获取统计数据
|
||||||
|
5. 返回聚合后的 categories + summary
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Agent 层工具扫描
|
||||||
|
|
||||||
|
通过内省 `app/agents/tools/` 目录下所有 `@tool` 装饰的函数,提取:
|
||||||
|
|
||||||
|
- `__name__` → tool name
|
||||||
|
- `__doc__` → description
|
||||||
|
- `__annotations__` → parameters schema
|
||||||
|
|
||||||
|
### 4.3 注册路由
|
||||||
|
|
||||||
|
在 `app/main.py` 中注册新路由:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.routers import tools as tools_router
|
||||||
|
app.include_router(tools_router.router, prefix="/api", tags=["tools"])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 产出要求
|
||||||
|
|
||||||
|
- [x] `GET /api/tools` 接口可调用,返回完整工具列表
|
||||||
|
- [x] 两套工具体系元数据正确聚合
|
||||||
|
- [x] 统计数据(调用次数、错误率)正确返回
|
||||||
|
- [x] 按 category 分组,source 字段区分来源
|
||||||
167
development-doc/plan/temple-update/phase-2-tools-frontend.md
Normal file
167
development-doc/plan/temple-update/phase-2-tools-frontend.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Phase 2:前端 Tools Tab 实现
|
||||||
|
|
||||||
|
日期:2026-04-08
|
||||||
|
状态:待开始
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 本阶段目的
|
||||||
|
|
||||||
|
实现 Temple 页面的 Tools Tab,包括分类树 + 详情面板 + Metrics Strip。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心文件
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/src/api/tools.ts` | 新建,Tools API 客户端 |
|
||||||
|
| `frontend/src/pages/temple/composables/useTemple.ts` | 新建,Tab/Skills 逻辑 |
|
||||||
|
| `frontend/src/pages/temple/index.vue` | 重写主页面(替换占位符) |
|
||||||
|
| `frontend/src/pages/temple/templePage.css` | 新建,样式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 页面布局
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ [◈ 智慧神殿] [Tools] [Skills] ← Tab 切换器 │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ TOTAL: 30 │ ACTIVE: 28 │ AGENTS: 5 ← Metrics Strip │
|
||||||
|
├──────────────────────────┬──────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ [分类树] │ [工具详情] │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ 注册层 │ file_operator │
|
||||||
|
│ 文件操作 │ ──────────── │
|
||||||
|
│ 任务管理 │ 描述: 强大的文件系统操作工具 │
|
||||||
|
│ 网页抓取 │ 命令: 4 个 │
|
||||||
|
│ 联网搜索 │ 标签: file, system, essential │
|
||||||
|
│ ▼ Agent层 │ 状态: 启用 │
|
||||||
|
│ 文件工具 │ 调用: 1,234 次 │
|
||||||
|
│ 系统命令 │ 错误率: 0.2% │
|
||||||
|
│ 开发工具 │ 平均耗时: 150ms │
|
||||||
|
│ 协作工具 │ │
|
||||||
|
│ 知识检索 │ [Commands] │
|
||||||
|
│ 日程管理 │ ─────────────────────────── │
|
||||||
|
│ 任务管理 │ read_file │
|
||||||
|
│ 论坛功能 │ ─────────────────────────── │
|
||||||
|
│ 时间推理 │ write_file │
|
||||||
|
│ │ ─────────────────────────── │
|
||||||
|
│ │ list_directory │
|
||||||
|
│ │ ─────────────────────────── │
|
||||||
|
│ │ search_files │
|
||||||
|
└──────────────────────────┴──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 组件说明
|
||||||
|
|
||||||
|
### 4.1 Tab 切换器
|
||||||
|
|
||||||
|
两个 Tab:`Tools` | `Skills`
|
||||||
|
- `Tools` → 本 phase 实现
|
||||||
|
- `Skills` → Phase 3(复用现有页面)
|
||||||
|
|
||||||
|
### 4.2 Metrics Strip
|
||||||
|
|
||||||
|
三个统计指标卡片:
|
||||||
|
|
||||||
|
| 指标 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `TOTAL` | 系统工具总数(所有工具的 commands 总数) |
|
||||||
|
| `ACTIVE` | 启用中的工具数 |
|
||||||
|
| `AGENTS` | 工具绑定的 Agent 类型数(固定 5) |
|
||||||
|
|
||||||
|
### 4.3 分类树
|
||||||
|
|
||||||
|
- 两级结构:大类(注册层 / Agent 层)→ 具体分类
|
||||||
|
- 点击分类 → 右侧显示该分类下的工具列表
|
||||||
|
- 点击工具 → 右侧显示工具详情
|
||||||
|
|
||||||
|
### 4.4 工具详情面板
|
||||||
|
|
||||||
|
当无工具选中时:显示分类下的工具列表(卡片形式)
|
||||||
|
当有工具选中时:显示工具详情
|
||||||
|
|
||||||
|
详情内容:
|
||||||
|
- **Name / Display Name**
|
||||||
|
- **Description**
|
||||||
|
- **Category / Tags**
|
||||||
|
- **Enabled status**
|
||||||
|
- **Stats**: call_count, error_rate, avg_duration_ms
|
||||||
|
- **Commands**: 每个 command 的 name + description(只读)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. useTemple.ts 接口设计
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useTemple.ts
|
||||||
|
export function useTemple() {
|
||||||
|
// State
|
||||||
|
const activeTab = ref<'tools' | 'skills'>('tools')
|
||||||
|
const categories = ref<ToolCategory[]>([])
|
||||||
|
const selectedCategory = ref<string | null>(null)
|
||||||
|
const selectedTool = ref<ToolInfo | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const summary = computed(() => { ... })
|
||||||
|
const currentCategoryTools = computed(() => { ... })
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async function fetchTools() { ... }
|
||||||
|
function selectCategory(name: string) { ... }
|
||||||
|
function selectTool(tool: ToolInfo) { ... }
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab,
|
||||||
|
categories,
|
||||||
|
selectedCategory,
|
||||||
|
selectedTool,
|
||||||
|
loading,
|
||||||
|
summary,
|
||||||
|
currentCategoryTools,
|
||||||
|
fetchTools,
|
||||||
|
selectCategory,
|
||||||
|
selectTool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 样式规范
|
||||||
|
|
||||||
|
沿用 Jarvis 现有风格:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* templePage.css */
|
||||||
|
.temple-page {
|
||||||
|
/* 复用 jarvis-* CSS 变量 */
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tree {
|
||||||
|
/* 深色终端风格 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 产出要求
|
||||||
|
|
||||||
|
- [x] Tab 切换器正常切换 Tools / Skills
|
||||||
|
- [x] Metrics Strip 正确显示统计数据
|
||||||
|
- [x] 分类树正确渲染,展开/收起正常
|
||||||
|
- [x] 点击工具有详情面板,Commands 列表完整
|
||||||
|
- [x] 样式与 Jarvis 整体风格一致
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Phase 3:Skills Tab 复用集成
|
||||||
|
|
||||||
|
日期:2026-04-08
|
||||||
|
状态:待开始
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 本阶段目的
|
||||||
|
|
||||||
|
将现有的 `/skills` 页面完整嵌入 Temple 页面的 Skills Tab,实现统一入口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心文件
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/src/pages/skills/index.vue` | 已有,Skills 完整页面 |
|
||||||
|
| `frontend/src/pages/skills/composables/useSkillsPage.ts` | 已有,Skills 逻辑 |
|
||||||
|
| `frontend/src/api/skill.ts` | 已有,Skills API 客户端 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 集成方式
|
||||||
|
|
||||||
|
### 3.1 方案选择
|
||||||
|
|
||||||
|
**方案 A(推荐):Tab 内条件渲染**
|
||||||
|
|
||||||
|
在 `Temple/index.vue` 中使用 `v-if` 切换:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div v-if="activeTab === 'skills'">
|
||||||
|
<!-- Skills 页面内容内联,或引用子组件 -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
优点:单一页面,状态共享简单
|
||||||
|
缺点:Skills 页面较大,代码集中
|
||||||
|
|
||||||
|
**方案 B:路由嵌套**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
// Temple/index.vue
|
||||||
|
<router-view />
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `skills/` 路由加 `parent: temple`
|
||||||
|
|
||||||
|
优点:页面分离,代码清晰
|
||||||
|
缺点:需要改路由配置
|
||||||
|
|
||||||
|
**推荐方案 A**,改动最小,Skills 页面代码以内联形式放入 Temple。
|
||||||
|
|
||||||
|
### 3.2 Tab 切换逻辑
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function switchTab(tab: 'tools' | 'skills') {
|
||||||
|
activeTab.value = tab
|
||||||
|
if (tab === 'skills') {
|
||||||
|
// Skills 页面初始化(如果需要)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 样式调整
|
||||||
|
|
||||||
|
Skills 页面样式独立在 `skillsPage.css`,切换 Tab 时保留其样式上下文。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 注意事项
|
||||||
|
|
||||||
|
- Skills 页面的 Modal(创建/编辑)需要在 Tab 切换后仍可正常弹出
|
||||||
|
- Skills 页面的 API 调用(`skillApi.list()`, `skillApi.create()` 等)保持不变
|
||||||
|
- Metrics Strip 在 Skills Tab 下显示不同的指标(TOTAL / ACTIVE / UPTIME)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 产出要求
|
||||||
|
|
||||||
|
- [x] Skills Tab 点击后正确切换到 Skills 页面
|
||||||
|
- [x] Skills 的 CRUD(创建/编辑/删除/启用/禁用)功能正常
|
||||||
|
- [x] Skills 的 MCP Panel 仍可正常打开
|
||||||
|
- [x] Skills 页面的 Modal、Drawer 等交互正常
|
||||||
|
- [x] Tab 切换不丢失状态
|
||||||
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
69
frontend/src/api/tools.ts
Normal file
69
frontend/src/api/tools.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import api from './index'
|
||||||
|
import type { AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
/** 单个工具命令 */
|
||||||
|
export interface ToolCommand {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
parameters: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具调用统计 */
|
||||||
|
export interface ToolStats {
|
||||||
|
call_count: number
|
||||||
|
error_count: number
|
||||||
|
total_duration_ms: number
|
||||||
|
avg_duration_ms: number
|
||||||
|
error_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具信息 */
|
||||||
|
export interface ToolInfo {
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
subcategory: string
|
||||||
|
source: 'manifest' | 'agent'
|
||||||
|
source_file: string
|
||||||
|
tags: string[]
|
||||||
|
enabled: boolean
|
||||||
|
commands: ToolCommand[]
|
||||||
|
stats: ToolStats | null
|
||||||
|
config: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具子分类 */
|
||||||
|
export interface ToolSubgroup {
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
tools: ToolInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具大分类 */
|
||||||
|
export interface ToolCategory {
|
||||||
|
name: string
|
||||||
|
display_name: string
|
||||||
|
subgroups: ToolSubgroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具统计摘要 */
|
||||||
|
export interface ToolSummary {
|
||||||
|
total_commands: number
|
||||||
|
active_commands: number
|
||||||
|
total_tools: number
|
||||||
|
manifest_tools: number
|
||||||
|
agent_tools: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/tools 响应 */
|
||||||
|
export interface ToolsResponse {
|
||||||
|
categories: ToolCategory[]
|
||||||
|
summary: ToolSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toolsApi = {
|
||||||
|
list: (): Promise<AxiosResponse<ToolsResponse>> => {
|
||||||
|
return api.get('/api/tools')
|
||||||
|
},
|
||||||
|
}
|
||||||
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>
|
||||||
133
frontend/src/pages/temple/composables/useTemple.ts
Normal file
133
frontend/src/pages/temple/composables/useTemple.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { toolsApi, type ToolCategory, type ToolInfo, type ToolsResponse } from '@/api/tools'
|
||||||
|
|
||||||
|
export type TabType = 'tools' | 'skills'
|
||||||
|
|
||||||
|
export function useTemple() {
|
||||||
|
// ===== State =====
|
||||||
|
const activeTab = ref<TabType>('tools')
|
||||||
|
const toolsLoading = ref(false)
|
||||||
|
const toolsError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Tools data
|
||||||
|
const categories = ref<ToolCategory[]>([])
|
||||||
|
const summary = ref({
|
||||||
|
total_commands: 0,
|
||||||
|
active_commands: 0,
|
||||||
|
total_tools: 0,
|
||||||
|
manifest_tools: 0,
|
||||||
|
agent_tools: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Selection state (Tools Tab)
|
||||||
|
const selectedCategory = ref<string | null>(null) // 大分类名,如 "注册层"
|
||||||
|
const selectedSubgroup = ref<string | null>(null) // 子分类名,如 "文件操作"
|
||||||
|
const selectedTool = ref<ToolInfo | null>(null)
|
||||||
|
|
||||||
|
// ===== Computed =====
|
||||||
|
|
||||||
|
/** 展平所有工具列表 */
|
||||||
|
const allTools = computed(() => {
|
||||||
|
return categories.value.flatMap((cat) =>
|
||||||
|
cat.subgroups.flatMap((sub) => sub.tools)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前选中的大分类下的子分类 */
|
||||||
|
const currentSubgroups = computed(() => {
|
||||||
|
if (!selectedCategory.value) return []
|
||||||
|
const cat = categories.value.find((c) => c.name === selectedCategory.value)
|
||||||
|
return cat?.subgroups ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前选中子分类下的工具 */
|
||||||
|
const currentTools = computed(() => {
|
||||||
|
if (!selectedSubgroup.value) return []
|
||||||
|
for (const cat of categories.value) {
|
||||||
|
const sub = cat.subgroups.find((s) => s.name === selectedSubgroup.value)
|
||||||
|
if (sub) return sub.tools
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前选中工具的详情 */
|
||||||
|
const currentToolDetail = computed(() => selectedTool.value)
|
||||||
|
|
||||||
|
// ===== Actions =====
|
||||||
|
|
||||||
|
async function fetchTools() {
|
||||||
|
toolsLoading.value = true
|
||||||
|
toolsError.value = null
|
||||||
|
try {
|
||||||
|
const res = await toolsApi.list()
|
||||||
|
const data: ToolsResponse = res.data
|
||||||
|
categories.value = data.categories
|
||||||
|
summary.value = data.summary
|
||||||
|
// 默认选中第一个分类和子分类
|
||||||
|
if (categories.value.length > 0) {
|
||||||
|
const firstCat = categories.value[0]
|
||||||
|
selectedCategory.value = firstCat.name
|
||||||
|
if (firstCat.subgroups.length > 0) {
|
||||||
|
selectedSubgroup.value = firstCat.subgroups[0].name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toolsError.value = e instanceof Error ? e.message : 'Failed to load tools'
|
||||||
|
console.error('[useTemple] fetchTools error:', e)
|
||||||
|
} finally {
|
||||||
|
toolsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCategory(name: string) {
|
||||||
|
selectedCategory.value = name
|
||||||
|
selectedSubgroup.value = null
|
||||||
|
selectedTool.value = null
|
||||||
|
// 自动选中第一个子分类
|
||||||
|
const cat = categories.value.find((c) => c.name === name)
|
||||||
|
if (cat && cat.subgroups.length > 0) {
|
||||||
|
selectedSubgroup.value = cat.subgroups[0].name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSubgroup(name: string) {
|
||||||
|
selectedSubgroup.value = name
|
||||||
|
selectedTool.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTool(tool: ToolInfo) {
|
||||||
|
selectedTool.value = tool
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearToolSelection() {
|
||||||
|
selectedTool.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab: TabType) {
|
||||||
|
activeTab.value = tab
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
activeTab,
|
||||||
|
toolsLoading,
|
||||||
|
toolsError,
|
||||||
|
categories,
|
||||||
|
summary,
|
||||||
|
selectedCategory,
|
||||||
|
selectedSubgroup,
|
||||||
|
selectedTool,
|
||||||
|
// Computed
|
||||||
|
allTools,
|
||||||
|
currentSubgroups,
|
||||||
|
currentTools,
|
||||||
|
currentToolDetail,
|
||||||
|
// Actions
|
||||||
|
fetchTools,
|
||||||
|
selectCategory,
|
||||||
|
selectSubgroup,
|
||||||
|
selectTool,
|
||||||
|
clearToolSelection,
|
||||||
|
switchTab,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +1,641 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 智慧神殿 - Temple of Wisdom
|
import { ref, watch } from 'vue'
|
||||||
|
import { X, Bot, Plus, Edit2, Trash2, Eye, EyeOff, Copy } from 'lucide-vue-next'
|
||||||
|
import { useTemple } from './composables/useTemple'
|
||||||
|
import { useSkillsPage } from '../skills/composables/useSkillsPage'
|
||||||
|
import { type Skill, type SkillCreate } from '@/api/skill'
|
||||||
|
|
||||||
|
// ===== Props / Emits =====
|
||||||
|
const props = defineProps<{ visible: boolean }>()
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
// ===== Temple (Tools) =====
|
||||||
|
const {
|
||||||
|
activeTab,
|
||||||
|
toolsLoading,
|
||||||
|
toolsError,
|
||||||
|
categories,
|
||||||
|
summary,
|
||||||
|
selectedSubgroup,
|
||||||
|
selectedTool,
|
||||||
|
currentTools,
|
||||||
|
fetchTools,
|
||||||
|
selectCategory,
|
||||||
|
selectSubgroup,
|
||||||
|
selectTool,
|
||||||
|
switchTab,
|
||||||
|
} = useTemple()
|
||||||
|
|
||||||
|
// ===== Skills (inline from useSkillsPage) =====
|
||||||
|
const skillsPage = useSkillsPage()
|
||||||
|
const {
|
||||||
|
skills,
|
||||||
|
loading: skillsLoading,
|
||||||
|
modalOpen,
|
||||||
|
editingSkill,
|
||||||
|
closeModal,
|
||||||
|
createSkill,
|
||||||
|
updateSkill,
|
||||||
|
deleteSkill,
|
||||||
|
toggleActive,
|
||||||
|
copySkill,
|
||||||
|
} = skillsPage
|
||||||
|
|
||||||
|
// ===== Fetch tools when modal opens =====
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
void fetchTools()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== Close =====
|
||||||
|
function handleClose() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Skills form state (local for modal) =====
|
||||||
|
const skillForm = ref<SkillCreate>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
instructions: '',
|
||||||
|
agent_type: 'general',
|
||||||
|
tools: [],
|
||||||
|
visibility: 'private',
|
||||||
|
})
|
||||||
|
|
||||||
|
function openNewSkillModal() {
|
||||||
|
skillForm.value = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
instructions: '',
|
||||||
|
agent_type: 'general',
|
||||||
|
tools: [],
|
||||||
|
visibility: 'private',
|
||||||
|
}
|
||||||
|
editingSkill.value = null
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditSkillModal(skill: Skill) {
|
||||||
|
skillForm.value = {
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description ?? '',
|
||||||
|
instructions: skill.instructions,
|
||||||
|
agent_type: skill.agent_type,
|
||||||
|
tools: [...skill.tools],
|
||||||
|
visibility: skill.visibility,
|
||||||
|
}
|
||||||
|
editingSkill.value = skill
|
||||||
|
modalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveSkill() {
|
||||||
|
if (editingSkill.value) {
|
||||||
|
await updateSkill()
|
||||||
|
} else {
|
||||||
|
await createSkill()
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteSkill(skill: Skill) {
|
||||||
|
if (confirm(`Delete skill "${skill.name}"?`)) {
|
||||||
|
await deleteSkill(skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_TYPES = ['general', 'schedule_planner', 'executor', 'librarian', 'analyst']
|
||||||
|
const AVAILABLE_TOOLS = ['file_operations', 'web_search', 'code_execution', 'database', 'api_calls', 'shell', 'git', 'calendar', 'tasks']
|
||||||
|
const VISIBILITY_OPTIONS = ['private', 'team', 'market'] as const
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="temple-page">
|
<Teleport to="body">
|
||||||
<div class="page-header">
|
<div v-if="visible" class="temple-modal-overlay" @click.self="handleClose">
|
||||||
<h1>⛩️ 智慧神殿</h1>
|
<div class="temple-modal" role="dialog" aria-modal="true" aria-label="智慧神殿">
|
||||||
<p class="subtitle">深邃智慧,永恒传承</p>
|
|
||||||
</div>
|
<!-- Header -->
|
||||||
<div class="page-content">
|
<div class="temple-header">
|
||||||
<div class="placeholder-content">
|
<div class="temple-header-title">
|
||||||
<div class="temple-icon">🏛️</div>
|
<span class="temple-title-icon">◈</span>
|
||||||
<p>智慧神殿 - 敬请期待</p>
|
<span class="temple-title-text">智慧神殿</span>
|
||||||
|
</div>
|
||||||
|
<button class="temple-close-btn" aria-label="关闭" @click="handleClose">
|
||||||
|
<X :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="temple-tabs">
|
||||||
|
<button
|
||||||
|
class="temple-tab"
|
||||||
|
:class="{ active: activeTab === 'tools' }"
|
||||||
|
@click="switchTab('tools')"
|
||||||
|
>
|
||||||
|
<span class="temple-tab-icon">◈</span>
|
||||||
|
<span>Tools</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="temple-tab"
|
||||||
|
:class="{ active: activeTab === 'skills' }"
|
||||||
|
@click="switchTab('skills')"
|
||||||
|
>
|
||||||
|
<span class="temple-tab-icon">✦</span>
|
||||||
|
<span>Skills</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metrics Strip -->
|
||||||
|
<div class="temple-metrics">
|
||||||
|
<div class="temple-metric">
|
||||||
|
<span class="temple-metric-label">TOTAL</span>
|
||||||
|
<span class="temple-metric-value">{{ summary.total_commands }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="temple-metric">
|
||||||
|
<span class="temple-metric-label">ACTIVE</span>
|
||||||
|
<span class="temple-metric-value">{{ summary.active_commands }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="temple-metric">
|
||||||
|
<span class="temple-metric-label">MANIFEST</span>
|
||||||
|
<span class="temple-metric-value">{{ summary.manifest_tools }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="temple-metric">
|
||||||
|
<span class="temple-metric-label">AGENT</span>
|
||||||
|
<span class="temple-metric-value">{{ summary.agent_tools }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="temple-body">
|
||||||
|
|
||||||
|
<!-- ========== TOOLS TAB ========== -->
|
||||||
|
<div v-if="activeTab === 'tools'" class="temple-tools-layout">
|
||||||
|
|
||||||
|
<!-- Left: Category Tree -->
|
||||||
|
<nav class="temple-tree">
|
||||||
|
<template v-for="cat in categories" :key="cat.name">
|
||||||
|
<div class="temple-tree-section-title">{{ cat.display_name || cat.name }}</div>
|
||||||
|
<div
|
||||||
|
v-for="sub in cat.subgroups"
|
||||||
|
:key="sub.name"
|
||||||
|
class="temple-tree-item temple-tree-subgroup"
|
||||||
|
:class="{ active: selectedSubgroup === sub.name }"
|
||||||
|
@click="selectCategory(cat.name); selectSubgroup(sub.name)"
|
||||||
|
>
|
||||||
|
<span class="temple-tree-dot"></span>
|
||||||
|
{{ sub.display_name || sub.name }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Middle: Tool List -->
|
||||||
|
<div class="temple-tool-list">
|
||||||
|
<div v-if="toolsLoading" class="temple-loading">
|
||||||
|
<span>Loading tools...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="toolsError" class="temple-empty" style="color:#f87171;">
|
||||||
|
<div class="temple-empty-icon">⚠</div>
|
||||||
|
<span>加载失败: {{ toolsError }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="categories.length === 0" class="temple-empty">
|
||||||
|
<div class="temple-empty-icon">◈</div>
|
||||||
|
<span>暂无可用工具</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="tool in currentTools"
|
||||||
|
:key="tool.name"
|
||||||
|
class="temple-tool-card"
|
||||||
|
:class="{ selected: selectedTool?.name === tool.name }"
|
||||||
|
@click="selectTool(tool)"
|
||||||
|
>
|
||||||
|
<div class="temple-tool-card-icon">⚙</div>
|
||||||
|
<div class="temple-tool-card-info">
|
||||||
|
<div class="temple-tool-card-name">{{ tool.display_name || tool.name }}</div>
|
||||||
|
<div class="temple-tool-card-desc">{{ tool.description }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="temple-tool-card-commands">{{ tool.commands.length }} cmds</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Tool Detail -->
|
||||||
|
<div class="temple-detail">
|
||||||
|
<div v-if="!selectedTool" class="temple-empty">
|
||||||
|
<div class="temple-empty-icon">◈</div>
|
||||||
|
<span>选择工具查看详情</span>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="temple-detail-header">
|
||||||
|
<div>
|
||||||
|
<div class="temple-detail-name">{{ selectedTool.display_name || selectedTool.name }}</div>
|
||||||
|
<div class="temple-detail-display-name">{{ selectedTool.name }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="temple-detail-source">{{ selectedTool.source }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="temple-detail-desc">{{ selectedTool.description }}</p>
|
||||||
|
|
||||||
|
<div class="temple-detail-section">
|
||||||
|
<div class="temple-detail-section-title">STATS</div>
|
||||||
|
<div class="temple-detail-stats">
|
||||||
|
<div class="temple-detail-stat">
|
||||||
|
<span class="temple-detail-stat-value">{{ selectedTool.stats?.call_count ?? 0 }}</span>
|
||||||
|
<span class="temple-detail-stat-label">Calls</span>
|
||||||
|
</div>
|
||||||
|
<div class="temple-detail-stat">
|
||||||
|
<span class="temple-detail-stat-value">{{ selectedTool.stats?.error_rate ?? 0 }}%</span>
|
||||||
|
<span class="temple-detail-stat-label">Error Rate</span>
|
||||||
|
</div>
|
||||||
|
<div class="temple-detail-stat">
|
||||||
|
<span class="temple-detail-stat-value">{{ selectedTool.stats?.avg_duration_ms ?? 0 }}ms</span>
|
||||||
|
<span class="temple-detail-stat-label">Avg Duration</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="temple-detail-section">
|
||||||
|
<div class="temple-detail-section-title">COMMANDS ({{ selectedTool.commands.length }})</div>
|
||||||
|
<div class="temple-commands">
|
||||||
|
<div v-for="cmd in selectedTool.commands" :key="cmd.name" class="temple-command-item">
|
||||||
|
<div class="temple-command-name">/{{ cmd.name }}</div>
|
||||||
|
<div class="temple-command-desc">{{ cmd.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="temple-detail-section">
|
||||||
|
<div class="temple-detail-section-title">TAGS</div>
|
||||||
|
<div class="temple-tags">
|
||||||
|
<span v-for="tag in selectedTool.tags" :key="tag" class="temple-tag">{{ tag }}</span>
|
||||||
|
<span class="temple-tag" style="background:rgba(0,245,212,0.1);color:#00f5d4;border-color:rgba(0,245,212,0.2)">
|
||||||
|
{{ selectedTool.source }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== SKILLS TAB ========== -->
|
||||||
|
<div v-if="activeTab === 'skills'" class="temple-skills-container">
|
||||||
|
|
||||||
|
<!-- Skills Toolbar -->
|
||||||
|
<div class="toolbar" style="padding:10px 16px;border-bottom:1px solid rgba(0,245,212,0.08);display:flex;gap:8px;align-items:center;">
|
||||||
|
<button class="btn-add" style="display:flex;align-items:center;gap:6px;padding:6px 14px;border-radius:6px;background:rgba(0,245,212,0.1);border:1px solid rgba(0,245,212,0.3);color:#00f5d4;cursor:pointer;font-size:12px;font-weight:500;" @click="openNewSkillModal">
|
||||||
|
<Plus :size="13" />
|
||||||
|
<span>新建技能</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skills Table -->
|
||||||
|
<div v-if="skillsLoading" class="temple-loading" style="padding:40px;">
|
||||||
|
<span>Loading skills...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="skills.length === 0" class="temple-empty">
|
||||||
|
<div class="temple-empty-icon">✦</div>
|
||||||
|
<span>暂无技能</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="skills-table-wrap" style="flex:1;overflow-y:auto;padding:0 16px;">
|
||||||
|
<table class="skills-table" style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid rgba(0,245,212,0.1);">
|
||||||
|
<th style="text-align:left;padding:8px 10px;font-size:10px;color:#5a6b7a;letter-spacing:1px;font-weight:600;">NAME</th>
|
||||||
|
<th style="text-align:left;padding:8px 10px;font-size:10px;color:#5a6b7a;letter-spacing:1px;font-weight:600;">TYPE</th>
|
||||||
|
<th style="text-align:left;padding:8px 10px;font-size:10px;color:#5a6b7a;letter-spacing:1px;font-weight:600;">VISIBILITY</th>
|
||||||
|
<th style="text-align:left;padding:8px 10px;font-size:10px;color:#5a6b7a;letter-spacing:1px;font-weight:600;">STATUS</th>
|
||||||
|
<th style="text-align:left;padding:8px 10px;font-size:10px;color:#5a6b7a;letter-spacing:1px;font-weight:600;">SOURCE</th>
|
||||||
|
<th style="text-align:right;padding:8px 10px;font-size:10px;color:#5a6b7a;letter-spacing:1px;font-weight:600;">ACTIONS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="skill in skills"
|
||||||
|
:key="skill.id"
|
||||||
|
style="border-bottom:1px solid rgba(0,245,212,0.05);transition:background 0.12s;"
|
||||||
|
:style="{ opacity: skill.is_active ? 1 : 0.5 }"
|
||||||
|
>
|
||||||
|
<td style="padding:10px 10px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<Bot :size="13" style="color:#00f5d4;flex-shrink:0;" />
|
||||||
|
<div>
|
||||||
|
<div style="font-size:13px;font-weight:600;color:#e8f4f8;">{{ skill.name }}</div>
|
||||||
|
<div style="font-size:11px;color:#5a6b7a;margin-top:1px;">{{ skill.description || '无描述' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 10px;">
|
||||||
|
<span class="mono-pill" style="font-size:10px;padding:2px 8px;border-radius:3px;background:rgba(0,245,212,0.06);color:#8a9bae;border:1px solid rgba(0,245,212,0.1);font-family:monospace;">{{ skill.agent_type }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 10px;">
|
||||||
|
<span style="font-size:10px;padding:2px 8px;border-radius:3px;"
|
||||||
|
:style="skill.visibility === 'private' ? 'background:rgba(168,85,247,0.12);color:#c084fc;border:1px solid rgba(168,85,247,0.2)' : skill.visibility === 'team' ? 'background:rgba(34,197,94,0.12);color:#4ade80;border:1px solid rgba(34,197,94,0.2)' : 'background:rgba(59,130,246,0.12);color:#60a5fa;border:1px solid rgba(59,130,246,0.2)'">
|
||||||
|
{{ skill.visibility }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 10px;">
|
||||||
|
<span style="display:flex;align-items:center;gap:5px;font-size:11px;" :style="{ color: skill.is_active ? '#4ade80' : '#6b7280' }">
|
||||||
|
<span style="width:5px;height:5px;border-radius:50%;flex-shrink:0;" :style="{ background: skill.is_active ? '#4ade80' : '#6b7280' }"></span>
|
||||||
|
{{ skill.is_active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 10px;">
|
||||||
|
<span style="font-size:10px;padding:2px 8px;border-radius:3px;" :style="skill.is_builtin ? 'background:rgba(251,191,36,0.1);color:#fbbf24;border:1px solid rgba(251,191,36,0.2)' : 'background:rgba(0,245,212,0.06);color:#8a9bae;border:1px solid rgba(0,245,212,0.1)'">
|
||||||
|
{{ skill.is_builtin ? 'builtin' : 'custom' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 10px;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:flex-end;gap:4px;">
|
||||||
|
<button class="action-btn" :style="{ color: skill.is_active ? '#4ade80' : '#6b7280' }" :title="skill.is_active ? 'Disable' : 'Enable'" @click="toggleActive(skill)">
|
||||||
|
<Eye v-if="skill.is_active" :size="13" />
|
||||||
|
<EyeOff v-else :size="13" />
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" title="Copy" style="color:#8a9bae;" @click="copySkill(skill)">
|
||||||
|
<Copy :size="13" />
|
||||||
|
</button>
|
||||||
|
<button class="action-btn edit" title="Edit" style="color:#60a5fa;" @click="openEditSkillModal(skill)">
|
||||||
|
<Edit2 :size="13" />
|
||||||
|
</button>
|
||||||
|
<button class="action-btn delete" title="Delete" style="color:#f87171;" :disabled="skill.is_builtin" @click="handleDeleteSkill(skill)">
|
||||||
|
<Trash2 :size="13" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skills Create/Edit Modal (inline) -->
|
||||||
|
<Transition :css="false" @enter="(el: Element, done: () => void) => { (el as HTMLElement).style.opacity = '1'; done() }" @leave="(el: Element, done: () => void) => { (el as HTMLElement).style.opacity = '0'; done() }">
|
||||||
|
<div v-if="modalOpen" class="modal-overlay" @click.self="closeModal">
|
||||||
|
<div class="modal-card" style="width:min(560px,90vw);max-height:85vh;overflow-y:auto;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">{{ editingSkill ? '// 编辑技能' : '// 新建技能' }}</span>
|
||||||
|
<button class="btn-close" aria-label="关闭" @click="closeModal"><X :size="16" /></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="skill-name">// NAME</label>
|
||||||
|
<input id="skill-name" v-model="skillForm.name" type="text" class="form-input" placeholder="Skill name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="skill-desc">// DESCRIPTION</label>
|
||||||
|
<textarea id="skill-desc" v-model="skillForm.description" class="form-textarea" rows="2" placeholder="Describe what this skill does..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="skill-type">// AGENT TYPE</label>
|
||||||
|
<select id="skill-type" v-model="skillForm.agent_type" class="form-select">
|
||||||
|
<option v-for="t in AGENT_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="skill-vis">// VISIBILITY</label>
|
||||||
|
<select id="skill-vis" v-model="skillForm.visibility" class="form-select">
|
||||||
|
<option v-for="v in VISIBILITY_OPTIONS" :key="v" :value="v">{{ v }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">// TOOLS</label>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px;">
|
||||||
|
<label v-for="tool in AVAILABLE_TOOLS" :key="tool" style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:5px 8px;border-radius:4px;border:1px solid rgba(0,245,212,0.08);transition:all 0.12s;" :style="{ background: skillForm.tools?.includes(tool) ? 'rgba(0,245,212,0.08)' : 'transparent' }">
|
||||||
|
<input type="checkbox" :checked="skillForm.tools?.includes(tool)" @change="() => { if (!skillForm.tools) skillForm.tools = []; const idx = skillForm.tools.indexOf(tool); if (idx >= 0) skillForm.tools.splice(idx, 1); else skillForm.tools.push(tool); }" style="accent-color:#00f5d4;" />
|
||||||
|
<span style="font-size:11px;color:#8a9bae;">{{ tool }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group flex-1">
|
||||||
|
<label class="form-label" for="skill-inst">// INSTRUCTIONS</label>
|
||||||
|
<textarea id="skill-inst" v-model="skillForm.instructions" class="form-textarea code-textarea" rows="6" placeholder="Enter skill instructions..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" @click="closeModal">Cancel</button>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="!skillForm.name || !skillForm.instructions"
|
||||||
|
@click="handleSaveSkill"
|
||||||
|
>
|
||||||
|
{{ editingSkill ? 'Update' : 'Create' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped src="./templePage.css">
|
||||||
|
</style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.temple-page {
|
/* Skills modal overrides */
|
||||||
padding: 24px;
|
.modal-overlay {
|
||||||
min-height: 100vh;
|
position: fixed;
|
||||||
background: var(--bg-primary);
|
inset: 0;
|
||||||
}
|
z-index: 2000;
|
||||||
|
|
||||||
.page-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header h1 {
|
|
||||||
font-size: 28px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-content {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 400px;
|
background: rgba(0, 0, 0, 0.75);
|
||||||
color: var(--text-secondary);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.temple-icon {
|
.modal-card {
|
||||||
font-size: 64px;
|
background: #0f1117;
|
||||||
margin-bottom: 16px;
|
border: 1px solid rgba(0, 245, 212, 0.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid rgba(0, 245, 212, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #00f5d4;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid rgba(0, 245, 212, 0.15);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: #8a9bae;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
border-color: #00f5d4;
|
||||||
|
color: #00f5d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-top: 1px solid rgba(0, 245, 212, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #5a6b7a;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select,
|
||||||
|
.form-textarea {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(0, 245, 212, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e8f4f8;
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-select:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
|
border-color: rgba(0, 245, 212, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235a6b7a' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
padding-right: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-textarea {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||||
|
background: transparent;
|
||||||
|
color: #8a9bae;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: rgba(0, 245, 212, 0.4);
|
||||||
|
color: #e8f4f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 7px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0, 245, 212, 0.3);
|
||||||
|
background: rgba(0, 245, 212, 0.1);
|
||||||
|
color: #00f5d4;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: rgba(0, 245, 212, 0.15);
|
||||||
|
border-color: #00f5d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 1px solid rgba(0, 245, 212, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #8a9bae;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(0, 245, 212, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
533
frontend/src/pages/temple/templePage.css
Normal file
533
frontend/src/pages/temple/templePage.css
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Temple Modal - 悬浮弹窗样式
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* CSS Variables 复用 jarvis 体系 */
|
||||||
|
.temple-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
animation: overlayFadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlayFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-modal {
|
||||||
|
width: min(95vw, 1400px);
|
||||||
|
height: min(88vh, 900px);
|
||||||
|
background: var(--bg-void, #0a0a0f);
|
||||||
|
border: 1px solid var(--border-subtle, rgba(0, 245, 212, 0.15));
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(0, 245, 212, 0.05),
|
||||||
|
0 24px 64px rgba(0, 0, 0, 0.6),
|
||||||
|
0 0 80px rgba(0, 245, 212, 0.04);
|
||||||
|
animation: modalSlideIn 0.22s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Header ---- */
|
||||||
|
.temple-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle, rgba(0, 245, 212, 0.1));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-title-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-title-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e8f4f8);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--border-subtle, rgba(0, 245, 212, 0.2));
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #8a9bae);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-close-btn:hover {
|
||||||
|
border-color: var(--accent-cyan, #00f5d4);
|
||||||
|
color: var(--accent-cyan, #00f5d4);
|
||||||
|
background: rgba(0, 245, 212, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tab Bar ---- */
|
||||||
|
.temple-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 20px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted, #5a6b7a);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tab:hover {
|
||||||
|
color: var(--text-secondary, #8a9bae);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tab.active {
|
||||||
|
color: var(--accent-cyan, #00f5d4);
|
||||||
|
background: rgba(0, 245, 212, 0.06);
|
||||||
|
border-color: var(--border-subtle, rgba(0, 245, 212, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tab-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Metrics Strip ---- */
|
||||||
|
.temple-metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle, rgba(0, 245, 212, 0.08));
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-metric {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 245, 212, 0.03);
|
||||||
|
border: 1px solid rgba(0, 245, 212, 0.06);
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-metric-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted, #5a6b7a);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-metric-value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-cyan, #00f5d4);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Main Content ---- */
|
||||||
|
.temple-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tools Tab Layout ---- */
|
||||||
|
.temple-tools-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category Tree (Left sidebar) */
|
||||||
|
.temple-tree {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-right: 1px solid var(--border-subtle, rgba(0, 245, 212, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tree::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tree::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 245, 212, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tree-section {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tree-section-title {
|
||||||
|
padding: 6px 16px 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted, #5a6b7a);
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tree-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-secondary, #8a9bae);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s ease;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tree-item:hover {
|
||||||
|
background: rgba(0, 245, 212, 0.05);
|
||||||
|
color: var(--text-primary, #e8f4f8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tree-item.active {
|
||||||
|
background: rgba(0, 245, 212, 0.08);
|
||||||
|
color: var(--accent-cyan, #00f5d4);
|
||||||
|
border-left-color: var(--accent-cyan, #00f5d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tree-subgroup {
|
||||||
|
padding-left: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tree-dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tools List & Detail (Right panel) */
|
||||||
|
.temple-tools-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tool-list {
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tool-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tool-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 245, 212, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tool-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-subtle, rgba(0, 245, 212, 0.08));
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tool-card:hover {
|
||||||
|
border-color: rgba(0, 245, 212, 0.25);
|
||||||
|
background: rgba(0, 245, 212, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tool-card.selected {
|
||||||
|
border-color: var(--accent-cyan, #00f5d4);
|
||||||
|
background: rgba(0, 245, 212, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tool-card-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 245, 212, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tool-card-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tool-card-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e8f4f8);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tool-card-desc {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-muted, #5a6b7a);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tool-card-commands {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted, #5a6b7a);
|
||||||
|
background: rgba(0, 245, 212, 0.06);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Detail Panel */
|
||||||
|
.temple-detail {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border-subtle, rgba(0, 245, 212, 0.08));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 245, 212, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #e8f4f8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail-display-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent-cyan, #00f5d4);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail-source {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted, #5a6b7a);
|
||||||
|
background: rgba(0, 245, 212, 0.05);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid rgba(0, 245, 212, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail-desc {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-secondary, #8a9bae);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail-section-title {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted, #5a6b7a);
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0, 245, 212, 0.06);
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail-stat-value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #e8f4f8);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-detail-stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted, #5a6b7a);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Commands List */
|
||||||
|
.temple-commands {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-command-item {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0, 245, 212, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-command-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan, #00f5d4);
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-command-desc {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-secondary, #8a9bae);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.temple-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(123, 44, 191, 0.15);
|
||||||
|
color: #c084fc;
|
||||||
|
border: 1px solid rgba(123, 44, 191, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.temple-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-muted, #5a6b7a);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temple-empty-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.temple-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-muted, #5a6b7a);
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skills Tab overrides */
|
||||||
|
.temple-skills-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section label */
|
||||||
|
.temple-section-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted, #5a6b7a);
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 10px 16px 6px;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user