Compare commits

..

2 Commits

Author SHA1 Message Date
8c7cf0732b Align knowledge storage with real folders and add WebDAV import surface
Knowledge files were only partitioned in the database, which made nested uploads, local folder visibility, and delete behavior diverge from the UI. This change makes folder selection drive physical storage paths, keeps original filenames, adds a minimal WebDAV mount/sync path, and reshapes the knowledge panel so local and remote sources can share the same surface.

Constraint: Existing knowledge flow already depends on local-folder-backed uploads and document indexing
Rejected: Real-time bidirectional WebDAV sync | too much conflict and lifecycle complexity for the first pass
Confidence: medium
Scope-risk: moderate
Reversibility: messy
Directive: Keep remote mounts single-direction into local knowledge folders until etag-based incremental sync and conflict rules are verified
Tested: Python py_compile on new/modified backend files; LSP diagnostics on new frontend/backend files; manual targeted code-path inspection
Not-tested: Full pytest/vitest end-to-end runs blocked by environment temp/cache permission errors; live WebDAV server interoperability
2026-04-09 17:26:37 +08:00
aa12c92a5a feat(temple): add Temple modal with Tools browser and Skills management 2026-04-08 16:46:02 +08:00
30 changed files with 5347 additions and 67 deletions

View File

@@ -29,6 +29,8 @@ from app.routers import (
agent_skills_router,
agent_sessions_router,
terminal_router,
tools_router,
remote_mount_router,
)
from app.routers.scheduler import router as scheduler_router
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
@@ -129,6 +131,8 @@ app.include_router(marketplace_router)
app.include_router(agent_skills_router)
app.include_router(agent_sessions_router)
app.include_router(terminal_router)
app.include_router(tools_router)
app.include_router(remote_mount_router)
@app.get("/api/health")

View File

@@ -2,7 +2,17 @@ from app.models.base import Base
from app.models.user import User
from app.models.folder import Folder
from app.models.document import Document, DocumentChunk
from app.models.task import Task, TaskHistory
from app.models.task import (
Task,
TaskAssigneeType,
TaskDispatchStatus,
TaskHistory,
TaskPriority,
TaskQuadrant,
TaskSource,
TaskStatus,
TaskSubTask,
)
from app.models.forum import ForumPost, ForumReply
from app.models.agent import Agent, AgentMessage
from app.models.conversation import Conversation, Message
@@ -23,6 +33,7 @@ from app.models.reminder import Reminder, ReminderStatus
from app.models.goal import Goal, GoalStatus
from app.models.skill import Skill
from app.models.log import Log, LogType, LogLevel
from app.models.remote_mount import RemoteMount, RemoteSyncItem
__all__ = [
"Base",
@@ -31,7 +42,14 @@ __all__ = [
"Document",
"DocumentChunk",
"Task",
"TaskSubTask",
"TaskHistory",
"TaskStatus",
"TaskPriority",
"TaskSource",
"TaskQuadrant",
"TaskAssigneeType",
"TaskDispatchStatus",
"ForumPost",
"ForumReply",
"Agent",
@@ -61,4 +79,6 @@ __all__ = [
"Log",
"LogType",
"LogLevel",
"RemoteMount",
"RemoteSyncItem",
]

View 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)

View File

@@ -21,3 +21,5 @@ from app.routers.plugins import _marketplace_router as marketplace_router
from app.routers.agent_skills import router as agent_skills_router
from app.routers.agent_sessions import router as agent_sessions_router
from app.routers.terminal import router as terminal_router
from app.routers.tools import router as tools_router
from app.routers.remote_mount import router as remote_mount_router

View File

@@ -1,17 +1,20 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from typing import List
import shutil
from app.database import get_db
from app.models.folder import Folder
from app.models.user import User
from app.schemas.folder import FolderCreate, FolderUpdate, FolderOut, FolderTreeOut
from app.routers.auth import get_current_user
from app.schemas.folder import FolderCreate, FolderOut, FolderTreeOut, FolderUpdate
from app.services.document_service import DocumentService
router = APIRouter(prefix="/api/folders", tags=["文件夹"])
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
"""递归构建文件夹树"""
tree = []
for folder in folders:
if folder.parent_id == parent_id:
@@ -20,30 +23,29 @@ def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[Fold
id=folder.id,
name=folder.name,
parent_id=folder.parent_id,
children=children
children=children,
))
return tree
@router.get("", response_model=List[FolderTreeOut])
async def get_folders(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
):
"""获取用户的完整文件夹树"""
result = await db.execute(
select(Folder).where(Folder.user_id == current_user.id)
)
folders = result.scalars().all()
return build_folder_tree(list(folders))
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
async def create_folder(
folder_data: FolderCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
):
"""创建文件夹"""
# 验证父文件夹存在且属于当前用户
if folder_data.parent_id:
result = await db.execute(
select(Folder).where(
@@ -53,13 +55,12 @@ async def create_folder(
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="父文件夹不存在")
# 检查同名文件夹
result = await db.execute(
select(Folder).where(
and_(
Folder.user_id == current_user.id,
Folder.parent_id == folder_data.parent_id,
Folder.name == folder_data.name
Folder.name == folder_data.name,
)
)
)
@@ -69,21 +70,24 @@ async def create_folder(
folder = Folder(
user_id=current_user.id,
name=folder_data.name,
parent_id=folder_data.parent_id
parent_id=folder_data.parent_id,
)
db.add(folder)
await db.commit()
await db.refresh(folder)
document_service = DocumentService(db, current_user.id)
await document_service.ensure_folder_directory(current_user.id, folder.id)
return folder
@router.put("/{folder_id}", response_model=FolderOut)
async def rename_folder(
folder_id: str,
folder_data: FolderUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
):
"""重命名文件夹"""
result = await db.execute(
select(Folder).where(
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
@@ -93,18 +97,22 @@ async def rename_folder(
if not folder:
raise HTTPException(status_code=404, detail="文件夹不存在")
old_name = folder.name
folder.name = folder_data.name
document_service = DocumentService(db, current_user.id)
await document_service.rename_folder_directory(current_user.id, folder.id, old_name, folder_data.name)
await db.commit()
await db.refresh(folder)
return folder
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_folder(
folder_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
):
"""删除文件夹(级联删除文档)"""
from app.models.document import Document
from app.services.knowledge_service import KnowledgeService
@@ -117,15 +125,16 @@ async def delete_folder(
if not folder:
raise HTTPException(status_code=404, detail="文件夹不存在")
document_service = DocumentService(db, current_user.id)
folder_path = await document_service._get_storage_directory(current_user.id, folder_id)
async def delete_recursive(fid: str):
# 删除子文件夹(先递归)
children = await db.execute(
select(Folder).where(Folder.parent_id == fid)
)
for child in children.scalars():
await delete_recursive(child.id)
# 删除文档
docs = await db.execute(
select(Document).where(Document.folder_id == fid)
)
@@ -134,10 +143,12 @@ async def delete_folder(
await knowledge_service.delete_from_vectorstore(current_user.id, doc.id)
await db.delete(doc)
# 删除文件夹本身
folder_to_delete = await db.get(Folder, fid)
if folder_to_delete:
await db.delete(folder_to_delete)
await delete_recursive(folder_id)
await db.commit()
if folder_path.exists():
shutil.rmtree(folder_path, ignore_errors=True)

View 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)

View 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)

View 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()

View 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()

View File

@@ -5,6 +5,7 @@
from pathlib import Path
import tempfile
import shutil
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastapi import UploadFile
@@ -18,7 +19,6 @@ import json
import os
import re
import aiofiles
import uuid
from dataclasses import dataclass, field
@@ -52,9 +52,9 @@ class DocumentService:
if ext not in ALLOWED_EXTENSIONS:
raise ValueError(f"不支持的文件类型: {ext}")
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
file_id = str(uuid.uuid4())
file_path = os.path.join(settings.UPLOAD_DIR, f"{file_id}{ext}")
folder_path = await self._get_storage_directory(user_id, folder_id)
folder_path.mkdir(parents=True, exist_ok=True)
file_path = self._resolve_unique_file_path(folder_path, file.filename)
content = await file.read()
file_size = len(content)
@@ -64,7 +64,7 @@ class DocumentService:
async with aiofiles.open(file_path, "wb") as f:
await f.write(content)
parsed = await self._parse_document(file_path, ext)
parsed = await self._parse_document(str(file_path), ext)
parsed.structured_markdown = self._render_structured_markdown(parsed)
doc = Document(
@@ -73,7 +73,7 @@ class DocumentService:
filename=file.filename,
file_type=ext[1:],
file_size=file_size,
file_path=file_path,
file_path=str(file_path),
summary=parsed.summary[:500] if len(parsed.summary) > 500 else parsed.summary,
folder_id=folder_id,
ingestion_status="uploaded",
@@ -171,6 +171,83 @@ class DocumentService:
return "/" + "/".join(path_parts) if path_parts else None
async def ensure_folder_directory(self, user_id: str, folder_id: str | None) -> Path:
folder_path = await self._get_storage_directory(user_id, folder_id)
folder_path.mkdir(parents=True, exist_ok=True)
return folder_path
async def delete_folder_directory(self, user_id: str, folder_id: str) -> None:
folder_path = await self._get_storage_directory(user_id, folder_id)
if folder_path.exists():
shutil.rmtree(folder_path, ignore_errors=True)
async def rename_folder_directory(self, user_id: str, folder_id: str, old_name: str, new_name: str) -> None:
folder = await self.db.get(Folder, folder_id)
if folder is None:
return
parent_path = await self._get_storage_directory(user_id, folder.parent_id)
old_path = parent_path / self._sanitize_storage_name(old_name)
new_path = parent_path / self._sanitize_storage_name(new_name)
if old_path != new_path:
parent_path.mkdir(parents=True, exist_ok=True)
if old_path.exists():
old_path.rename(new_path)
else:
new_path.mkdir(parents=True, exist_ok=True)
else:
new_path.mkdir(parents=True, exist_ok=True)
document_result = await self.db.execute(
select(Document).where(Document.user_id == user_id)
)
for document in document_result.scalars().all():
try:
relative_path = Path(document.file_path).relative_to(old_path)
except ValueError:
continue
document.file_path = str(new_path / relative_path)
async def _get_storage_directory(self, user_id: str, folder_id: str | None) -> Path:
base_path = Path(settings.UPLOAD_DIR) / user_id
if not folder_id:
return base_path
folders = await self.db.execute(
select(Folder).where(Folder.user_id == user_id)
)
folder_map = {folder.id: folder for folder in folders.scalars().all()}
path_segments: list[str] = []
current_id = folder_id
while current_id:
folder = folder_map.get(current_id)
if folder is None:
raise ValueError("鐖舵枃浠跺す涓嶅瓨鍦?")
path_segments.insert(0, self._sanitize_storage_name(folder.name))
current_id = folder.parent_id
return base_path.joinpath(*path_segments)
def _resolve_unique_file_path(self, directory: Path, original_name: str) -> Path:
safe_name = self._sanitize_storage_name(Path(original_name).name, is_file=True)
stem = Path(safe_name).stem
suffix = Path(safe_name).suffix
candidate = directory / safe_name
counter = 2
while candidate.exists():
candidate = directory / f"{stem}-{counter}{suffix}"
counter += 1
return candidate
def _sanitize_storage_name(self, name: str, is_file: bool = False) -> str:
invalid_chars = '<>:"/\\|?*'
sanitized = ''.join('_' if char in invalid_chars or ord(char) < 32 else char for char in name).strip().rstrip('.')
if not sanitized:
return 'untitled' if is_file else 'folder'
return sanitized
async def delete_document(self, user_id: str, document_id: str):
result = await self.db.execute(
select(Document).where(

View 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()

View 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")

View 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

View File

@@ -15,6 +15,7 @@ from starlette.datastructures import UploadFile
import app.models # noqa: F401
from app.database import Base
from app.models.document import Document, DocumentChunk
from app.models.folder import Folder
from app.models.user import User
from app.services.auth_service import get_password_hash
from app.services.document_service import DocumentService
@@ -199,6 +200,29 @@ async def test_upload_document_persists_structured_metadata_json(document_test_e
assert stored_document.normalized_content == 'title\n\nplain text body for metadata storage'
@pytest.mark.asyncio
async def test_upload_document_stores_file_in_nested_folder_with_original_name(document_test_env):
session, user = document_test_env
service = DocumentService(session)
root = Folder(user_id=user.id, name='Projects')
session.add(root)
await session.flush()
child = Folder(user_id=user.id, name='Specs', parent_id=root.id)
session.add(child)
await session.commit()
await session.refresh(child)
upload = UploadFile(filename='system-design.md', file=BytesIO(b'# Design'))
document = await service.upload_document(user.id, upload, folder_id=child.id)
file_path = Path(document.file_path)
assert file_path.name == 'system-design.md'
assert file_path.parent.name == 'Specs'
assert file_path.parent.parent.name == 'Projects'
assert file_path.exists()
@pytest.mark.asyncio
async def test_upload_document_extracts_docx_heading_and_table_structure(document_test_env):
session, user = document_test_env

View 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"'

View 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()

View 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 天** |

View 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 3Skills 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

View 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 文档能够在这个认知基础上展开

View 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 字段区分来源

View 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 整体风格一致

View File

@@ -0,0 +1,88 @@
# Phase 3Skills 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 切换不丢失状态

View 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
View 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')
},
}

View 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);
}
}

View 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')
})
})

View 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>

View 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,
}
}

View File

@@ -1,56 +1,641 @@
<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>
<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>
<Teleport to="body">
<div v-if="visible" class="temple-modal-overlay" @click.self="handleClose">
<div class="temple-modal" role="dialog" aria-modal="true" aria-label="智慧神殿">
<!-- Header -->
<div class="temple-header">
<div class="temple-header-title">
<span class="temple-title-icon"></span>
<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>
</Teleport>
</template>
<style scoped src="./templePage.css">
</style>
<style scoped>
.temple-page {
padding: 24px;
min-height: 100vh;
background: var(--bg-primary);
}
.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 {
/* Skills modal overrides */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
}
.temple-icon {
font-size: 64px;
margin-bottom: 16px;
.modal-card {
background: #0f1117;
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>

View 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;
}