commit
6f460894b2
@ -101,6 +101,8 @@ make format # 格式化代码
|
||||
- 尽量使用较新的语法,避免使用旧版本的语法(版本兼容到 3.12+)
|
||||
- 更新 [roadmap.md](docs/develop-guides/roadmap.md) 文档记录本次修改,多个类似的功能更新已经补充在一起
|
||||
- 开发完成后务必在 docker 中进行测试,可以读取 .env 获取管理员账户和密码
|
||||
- 不允许把代码写得稀碎:不要为简单线性逻辑拆出一堆细碎 helper;优先写成职责清晰、结构完整、可一眼读懂的实现。
|
||||
- 拆函数必须服务于明确的复用、隔离副作用或降低认知负担;如果拆分后调用链更绕、上下文更分散,就应合并回更直接的实现。
|
||||
|
||||
**其他**:
|
||||
|
||||
|
||||
64
CLAUDE.md
64
CLAUDE.md
@ -7,15 +7,69 @@ Yuxi 是一个基于大模型的智能知识库与知识图谱智能体开发平
|
||||
|
||||
## 开发准则
|
||||
|
||||
Avoid over-engineering. Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused.
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
Don't add features, refactor code, or make "improvements" beyond what was asked. A bug fix doesn't need surrounding code cleaned up. A simple feature doesn't need extra configurability.
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs). Don't use backwards-compatibility shims when you can just change the code.
|
||||
## 1. Think Before Coding
|
||||
|
||||
Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task. Reuse existing abstractions where possible and follow the DRY principle.
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
To ensure readability, it is necessary to add essential comments at key points, particularly to explain the functionality of a function and the design intent.
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
## 开发与调试工作流 (Development & Debugging Workflow)
|
||||
|
||||
|
||||
@ -2,10 +2,12 @@ from .backend import ProvisionerSandboxBackend
|
||||
from .paths import (
|
||||
VIRTUAL_PATH_PREFIX,
|
||||
ensure_thread_dirs,
|
||||
ensure_workspace_default_files,
|
||||
resolve_virtual_path,
|
||||
sandbox_outputs_dir,
|
||||
sandbox_uploads_dir,
|
||||
sandbox_user_data_dir,
|
||||
sandbox_workspace_agents_prompt_file,
|
||||
sandbox_workspace_dir,
|
||||
virtual_path_for_thread_file,
|
||||
)
|
||||
@ -51,6 +53,7 @@ __all__ = [
|
||||
"ProvisionerSandboxProvider",
|
||||
"VIRTUAL_PATH_PREFIX",
|
||||
"ensure_thread_dirs",
|
||||
"ensure_workspace_default_files",
|
||||
"get_sandbox_provider",
|
||||
"init_sandbox_provider",
|
||||
"resolve_virtual_path",
|
||||
@ -58,6 +61,7 @@ __all__ = [
|
||||
"sandbox_outputs_dir",
|
||||
"sandbox_uploads_dir",
|
||||
"sandbox_user_data_dir",
|
||||
"sandbox_workspace_agents_prompt_file",
|
||||
"sandbox_workspace_dir",
|
||||
"shutdown_sandbox_provider",
|
||||
"virtual_path_for_thread_file",
|
||||
|
||||
@ -4,7 +4,15 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from yuxi import config as conf
|
||||
from yuxi.utils.paths import OUTPUTS_DIR_NAME, UPLOADS_DIR_NAME, VIRTUAL_PATH_PREFIX, WORKSPACE_DIR_NAME
|
||||
from yuxi.utils.logging_config import logger
|
||||
from yuxi.utils.paths import (
|
||||
OUTPUTS_DIR_NAME,
|
||||
UPLOADS_DIR_NAME,
|
||||
VIRTUAL_PATH_PREFIX,
|
||||
WORKSPACE_AGENTS_DIR_NAME,
|
||||
WORKSPACE_AGENTS_PROMPT_FILE_NAME,
|
||||
WORKSPACE_DIR_NAME,
|
||||
)
|
||||
|
||||
_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
@ -51,6 +59,33 @@ def sandbox_workspace_dir(thread_id: str, user_id: str) -> Path:
|
||||
return _global_user_data_dir(user_id) / WORKSPACE_DIR_NAME
|
||||
|
||||
|
||||
def sandbox_workspace_agents_prompt_file(thread_id: str, user_id: str) -> Path:
|
||||
return sandbox_workspace_dir(thread_id, user_id) / WORKSPACE_AGENTS_DIR_NAME / WORKSPACE_AGENTS_PROMPT_FILE_NAME
|
||||
|
||||
|
||||
def ensure_workspace_default_files(workspace_dir: Path) -> None:
|
||||
agents_dir = workspace_dir / WORKSPACE_AGENTS_DIR_NAME
|
||||
agents_file = agents_dir / WORKSPACE_AGENTS_PROMPT_FILE_NAME
|
||||
|
||||
try:
|
||||
agents_dir.mkdir(parents=True, exist_ok=True)
|
||||
except FileExistsError:
|
||||
logger.warning("工作区默认 Agents 目录创建失败:路径已被文件占用")
|
||||
return
|
||||
except OSError as exc:
|
||||
logger.warning(f"工作区默认 Agents 目录初始化失败: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
with agents_file.open("xb"):
|
||||
pass
|
||||
except FileExistsError:
|
||||
if agents_file.is_dir():
|
||||
logger.warning("工作区默认 AGENTS.md 创建失败:路径已被目录占用")
|
||||
except OSError as exc:
|
||||
logger.warning(f"工作区默认 Agents 文件初始化失败: {exc}")
|
||||
|
||||
|
||||
def sandbox_uploads_dir(thread_id: str) -> Path:
|
||||
return _thread_root_dir(thread_id) / UPLOADS_DIR_NAME
|
||||
|
||||
@ -61,7 +96,9 @@ def sandbox_outputs_dir(thread_id: str) -> Path:
|
||||
|
||||
def ensure_thread_dirs(thread_id: str, user_id: str) -> None:
|
||||
_global_user_data_dir(user_id).mkdir(parents=True, exist_ok=True)
|
||||
sandbox_workspace_dir(thread_id, user_id).mkdir(parents=True, exist_ok=True)
|
||||
workspace_dir = sandbox_workspace_dir(thread_id, user_id)
|
||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
ensure_workspace_default_files(workspace_dir)
|
||||
sandbox_uploads_dir(thread_id).mkdir(parents=True, exist_ok=True)
|
||||
sandbox_outputs_dir(thread_id).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ from typing import Any
|
||||
from langchain.messages import AIMessage, AIMessageChunk, HumanMessage
|
||||
from langgraph.types import Command
|
||||
from yuxi import config as conf
|
||||
from yuxi.agents.backends.sandbox.paths import sandbox_workspace_agents_prompt_file
|
||||
from yuxi.agents.buildin import agent_manager
|
||||
from yuxi.agents.state import AgentStatePayload
|
||||
from yuxi.plugins.guard import content_guard
|
||||
@ -30,6 +31,43 @@ from yuxi.utils.question_utils import (
|
||||
normalize_questions as _normalize_interrupt_questions,
|
||||
)
|
||||
|
||||
WORKSPACE_AGENTS_PROMPT_MAX_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def _load_workspace_agents_prompt(thread_id: str, user_id: str) -> str:
|
||||
prompt_file = sandbox_workspace_agents_prompt_file(thread_id, user_id)
|
||||
try:
|
||||
with prompt_file.open("rb") as buffer:
|
||||
content = buffer.read(WORKSPACE_AGENTS_PROMPT_MAX_BYTES + 1)
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
except IsADirectoryError:
|
||||
logger.warning("读取工作区 AGENTS.md 失败: 路径是目录")
|
||||
return ""
|
||||
except OSError as exc:
|
||||
logger.warning(f"读取工作区 AGENTS.md 失败: {exc}")
|
||||
return ""
|
||||
|
||||
prompt = content[:WORKSPACE_AGENTS_PROMPT_MAX_BYTES].decode("utf-8", errors="replace").strip()
|
||||
if not prompt:
|
||||
return ""
|
||||
if len(content) > WORKSPACE_AGENTS_PROMPT_MAX_BYTES:
|
||||
return f"{prompt}\n\n[AGENTS.md 内容已截断]"
|
||||
return prompt
|
||||
|
||||
|
||||
async def _build_agent_input_context(agent_config: dict, *, thread_id: str, user_id: str) -> dict:
|
||||
input_context = dict(agent_config or {})
|
||||
agents_prompt = await asyncio.to_thread(_load_workspace_agents_prompt, thread_id, user_id)
|
||||
|
||||
if agents_prompt:
|
||||
agents_section = f"用户工作区 agents/AGENTS.md 内容:\n{agents_prompt}"
|
||||
base_prompt = str(input_context.get("system_prompt") or "").rstrip()
|
||||
input_context["system_prompt"] = f"{base_prompt}\n\n{agents_section}" if base_prompt else agents_section
|
||||
|
||||
input_context.update({"user_id": user_id, "thread_id": thread_id})
|
||||
return input_context
|
||||
|
||||
|
||||
def _build_state_files(attachments: list[dict]) -> dict:
|
||||
"""将附件列表转换为 StateBackend 格式的 files 字典
|
||||
@ -560,7 +598,7 @@ async def agent_chat(
|
||||
thread_id = str(uuid.uuid4())
|
||||
logger.warning(f"No thread_id provided, generated new thread_id: {thread_id}")
|
||||
|
||||
input_context = agent_config | {"user_id": user_id, "thread_id": thread_id}
|
||||
input_context = await _build_agent_input_context(agent_config, thread_id=thread_id, user_id=user_id)
|
||||
langfuse_run = _build_langfuse_run_context(
|
||||
current_user=current_user,
|
||||
thread_id=thread_id,
|
||||
@ -776,7 +814,7 @@ async def stream_agent_chat(
|
||||
thread_id = str(uuid.uuid4())
|
||||
logger.warning(f"No thread_id provided, generated new thread_id: {thread_id}")
|
||||
|
||||
input_context = agent_config | {"user_id": user_id, "thread_id": thread_id}
|
||||
input_context = await _build_agent_input_context(agent_config, thread_id=thread_id, user_id=user_id)
|
||||
langfuse_run = _build_langfuse_run_context(
|
||||
current_user=current_user,
|
||||
thread_id=thread_id,
|
||||
@ -1011,8 +1049,7 @@ async def stream_agent_resume(
|
||||
return
|
||||
|
||||
context = agent.context_schema()
|
||||
context.update(agent_config or {})
|
||||
context.update({"user_id": user_id, "thread_id": thread_id})
|
||||
context.update(await _build_agent_input_context(agent_config or {}, thread_id=thread_id, user_id=user_id))
|
||||
graph = await agent.get_graph(context=context)
|
||||
langfuse_run = _build_langfuse_run_context(
|
||||
current_user=current_user,
|
||||
|
||||
@ -2,7 +2,6 @@ import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from yuxi.agents.backends.sandbox import (
|
||||
@ -13,6 +12,7 @@ from yuxi.agents.buildin import agent_manager
|
||||
from yuxi.config import config as app_config
|
||||
from yuxi.plugins.parser import Parser
|
||||
from yuxi.repositories.conversation_repository import ConversationRepository
|
||||
from yuxi.services.upload_utils import write_upload_to_path
|
||||
from yuxi.utils.datetime_utils import utc_isoformat
|
||||
from yuxi.utils.logging_config import logger
|
||||
from yuxi.utils.paths import VIRTUAL_PATH_UPLOADS
|
||||
@ -41,21 +41,12 @@ def _ensure_workdir() -> Path:
|
||||
|
||||
|
||||
async def _write_upload_to_disk(upload: UploadFile, dest: Path) -> int:
|
||||
await upload.seek(0)
|
||||
written = 0
|
||||
chunk_size = 1024 * 1024
|
||||
|
||||
async with aiofiles.open(dest, "wb") as buffer:
|
||||
while True:
|
||||
chunk = await upload.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
written += len(chunk)
|
||||
if written > MAX_ATTACHMENT_SIZE_BYTES:
|
||||
raise ValueError("附件过大,当前仅支持 5 MB 以内的文件")
|
||||
await buffer.write(chunk)
|
||||
|
||||
return written
|
||||
return await write_upload_to_path(
|
||||
upload,
|
||||
dest,
|
||||
max_size_bytes=MAX_ATTACHMENT_SIZE_BYTES,
|
||||
too_large_message="附件过大,当前仅支持 5 MB 以内的文件",
|
||||
)
|
||||
|
||||
|
||||
def _truncate_markdown(markdown: str) -> tuple[str, bool]:
|
||||
|
||||
43
backend/package/yuxi/services/upload_utils.py
Normal file
43
backend/package/yuxi/services/upload_utils.py
Normal file
@ -0,0 +1,43 @@
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
from fastapi import UploadFile
|
||||
|
||||
|
||||
async def write_upload_to_buffer(
|
||||
upload: UploadFile,
|
||||
buffer,
|
||||
*,
|
||||
max_size_bytes: int,
|
||||
too_large_message: str,
|
||||
chunk_size: int = 1024 * 1024,
|
||||
) -> int:
|
||||
await upload.seek(0)
|
||||
written = 0
|
||||
|
||||
while chunk := await upload.read(chunk_size):
|
||||
written += len(chunk)
|
||||
if written > max_size_bytes:
|
||||
raise ValueError(too_large_message)
|
||||
await buffer.write(chunk)
|
||||
|
||||
return written
|
||||
|
||||
|
||||
async def write_upload_to_path(
|
||||
upload: UploadFile,
|
||||
dest: Path,
|
||||
*,
|
||||
max_size_bytes: int,
|
||||
too_large_message: str,
|
||||
mode: str = "wb",
|
||||
chunk_size: int = 1024 * 1024,
|
||||
) -> int:
|
||||
async with aiofiles.open(dest, mode) as buffer:
|
||||
return await write_upload_to_buffer(
|
||||
upload,
|
||||
buffer,
|
||||
max_size_bytes=max_size_bytes,
|
||||
too_large_message=too_large_message,
|
||||
chunk_size=chunk_size,
|
||||
)
|
||||
277
backend/package/yuxi/services/workspace_service.py
Normal file
277
backend/package/yuxi/services/workspace_service.py
Normal file
@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import mimetypes
|
||||
import shutil
|
||||
from pathlib import Path, PurePosixPath
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiofiles
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from yuxi.agents.backends.sandbox.paths import _global_user_data_dir, ensure_workspace_default_files
|
||||
from yuxi.services.upload_utils import write_upload_to_buffer
|
||||
from yuxi.services.viewer_filesystem_service import _detect_preview_type
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
|
||||
from yuxi.utils.paths import WORKSPACE_DIR_NAME
|
||||
|
||||
EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"}
|
||||
MAX_WORKSPACE_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def _workspace_root(user: User) -> Path:
|
||||
try:
|
||||
user_data_root = _global_user_data_dir(str(user.id)).resolve()
|
||||
root = user_data_root / WORKSPACE_DIR_NAME
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=403, detail="Access denied") from exc
|
||||
if root.is_symlink():
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
resolved_root = root.resolve()
|
||||
try:
|
||||
resolved_root.relative_to(user_data_root)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=403, detail="Access denied") from exc
|
||||
ensure_workspace_default_files(resolved_root)
|
||||
return resolved_root
|
||||
|
||||
|
||||
def _normalize_workspace_path(path: str | None) -> PurePosixPath:
|
||||
raw_path = (path or "/").strip() or "/"
|
||||
if not raw_path.startswith("/"):
|
||||
raw_path = f"/{raw_path}"
|
||||
normalized = PurePosixPath(raw_path)
|
||||
if ".." in normalized.parts:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return normalized
|
||||
|
||||
|
||||
def _resolve_workspace_path(user: User, path: str | None) -> Path:
|
||||
root = _workspace_root(user)
|
||||
normalized = _normalize_workspace_path(path)
|
||||
relative_parts = [part for part in normalized.parts if part not in {"/", ""}]
|
||||
target = (root.joinpath(*relative_parts) if relative_parts else root).resolve()
|
||||
try:
|
||||
target.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=403, detail="Access denied") from exc
|
||||
return target
|
||||
|
||||
|
||||
def _entry_for_path(root: Path, path: Path) -> dict:
|
||||
stat = path.stat()
|
||||
is_dir = path.is_dir()
|
||||
relative = path.relative_to(root).as_posix()
|
||||
display_path = f"/{relative}" if relative else "/"
|
||||
if is_dir and display_path != "/" and not display_path.endswith("/"):
|
||||
display_path = f"{display_path}/"
|
||||
return {
|
||||
"path": display_path,
|
||||
"name": path.name or "工作区",
|
||||
"is_dir": is_dir,
|
||||
"size": 0 if is_dir else stat.st_size,
|
||||
"modified_at": utc_isoformat_from_timestamp(stat.st_mtime) or "",
|
||||
}
|
||||
|
||||
|
||||
def _sort_entries(entries: list[dict]) -> list[dict]:
|
||||
return sorted(entries, key=lambda item: (not bool(item.get("is_dir")), str(item.get("name") or "").lower()))
|
||||
|
||||
|
||||
def _validate_child_name(name: str, *, field_name: str) -> str:
|
||||
clean_name = str(name or "").strip()
|
||||
if not clean_name:
|
||||
raise HTTPException(status_code=422, detail=f"{field_name} 不能为空")
|
||||
if clean_name in {".", ".."} or "/" in clean_name or "\\" in clean_name:
|
||||
raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符")
|
||||
if PurePosixPath(clean_name).name != clean_name:
|
||||
raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符")
|
||||
return clean_name
|
||||
|
||||
|
||||
def _resolve_parent_directory(user: User, parent_path: str) -> Path:
|
||||
parent = _resolve_workspace_path(user, parent_path)
|
||||
if not parent.exists():
|
||||
raise HTTPException(status_code=404, detail="目标目录不存在")
|
||||
if not parent.is_dir():
|
||||
raise HTTPException(status_code=400, detail="目标路径不是目录")
|
||||
return parent
|
||||
|
||||
|
||||
def _resolve_new_child(root: Path, parent: Path, name: str) -> Path:
|
||||
target = parent / name
|
||||
try:
|
||||
target.resolve(strict=False).relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=403, detail="Access denied") from exc
|
||||
if target.exists():
|
||||
raise HTTPException(status_code=400, detail="同名文件或文件夹已存在")
|
||||
return target
|
||||
|
||||
|
||||
def _list_directory(root: Path, target: Path) -> list[dict]:
|
||||
entries = [_entry_for_path(root, child) for child in target.iterdir()]
|
||||
return _sort_entries(entries)
|
||||
|
||||
|
||||
async def list_workspace_tree(*, path: str, current_user: User) -> dict:
|
||||
root = _workspace_root(current_user)
|
||||
target = _resolve_workspace_path(current_user, path)
|
||||
if not target.exists():
|
||||
return {"entries": []}
|
||||
if not target.is_dir():
|
||||
raise HTTPException(status_code=400, detail="当前路径不是目录")
|
||||
entries = await asyncio.to_thread(_list_directory, root, target)
|
||||
return {"entries": entries}
|
||||
|
||||
|
||||
async def read_workspace_file_content(*, path: str, current_user: User) -> dict:
|
||||
target = _resolve_workspace_path(current_user, path)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=400, detail="当前路径是目录")
|
||||
|
||||
raw_content = await asyncio.to_thread(target.read_bytes)
|
||||
preview_type, supported, message = _detect_preview_type(path, raw_content)
|
||||
if preview_type in {"image", "pdf"} or not supported:
|
||||
return {
|
||||
"content": None,
|
||||
"preview_type": preview_type,
|
||||
"supported": supported,
|
||||
"message": message,
|
||||
}
|
||||
try:
|
||||
content = raw_content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return {
|
||||
"content": None,
|
||||
"preview_type": "unsupported",
|
||||
"supported": False,
|
||||
"message": "当前文件不是 UTF-8 文本,暂不支持预览",
|
||||
}
|
||||
return {
|
||||
"content": content,
|
||||
"preview_type": preview_type,
|
||||
"supported": supported,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
async def write_workspace_file_content(*, path: str, content: str, current_user: User) -> dict:
|
||||
root = _workspace_root(current_user)
|
||||
target = _resolve_workspace_path(current_user, path)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=400, detail="当前路径是目录")
|
||||
if target.suffix.lower() not in EDITABLE_WORKSPACE_SUFFIXES:
|
||||
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
|
||||
|
||||
raw_content = await asyncio.to_thread(target.read_bytes)
|
||||
preview_type, supported, _message = _detect_preview_type(path, raw_content)
|
||||
if preview_type not in {"markdown", "text"} or not supported:
|
||||
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
|
||||
try:
|
||||
raw_content.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail="当前文件不是 UTF-8 文本") from exc
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(target.write_text, content, encoding="utf-8")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": _normalize_workspace_path(path).as_posix(),
|
||||
"entry": _entry_for_path(root, target),
|
||||
}
|
||||
|
||||
|
||||
async def delete_workspace_path(*, path: str, current_user: User) -> dict:
|
||||
root = _workspace_root(current_user)
|
||||
target = _resolve_workspace_path(current_user, path)
|
||||
if target == root:
|
||||
raise HTTPException(status_code=400, detail="工作区根目录不允许删除")
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
try:
|
||||
if target.is_dir():
|
||||
await asyncio.to_thread(shutil.rmtree, target)
|
||||
else:
|
||||
await asyncio.to_thread(target.unlink)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return {"success": True, "path": _normalize_workspace_path(path).as_posix()}
|
||||
|
||||
|
||||
async def create_workspace_directory(*, parent_path: str, name: str, current_user: User) -> dict:
|
||||
root = _workspace_root(current_user)
|
||||
directory_name = _validate_child_name(name, field_name="文件夹名")
|
||||
parent = _resolve_parent_directory(current_user, parent_path)
|
||||
target = _resolve_new_child(root, parent, directory_name)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(target.mkdir)
|
||||
except FileExistsError as exc:
|
||||
raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return {"success": True, "entry": _entry_for_path(root, target)}
|
||||
|
||||
|
||||
async def upload_workspace_file(*, parent_path: str, file: UploadFile, current_user: User) -> dict:
|
||||
root = _workspace_root(current_user)
|
||||
file_name = _validate_child_name(Path(file.filename or "").name, field_name="文件名")
|
||||
parent = _resolve_parent_directory(current_user, parent_path)
|
||||
target = _resolve_new_child(root, parent, file_name)
|
||||
created_file = False
|
||||
upload_completed = False
|
||||
|
||||
try:
|
||||
async with aiofiles.open(target, "xb") as buffer:
|
||||
created_file = True
|
||||
await write_upload_to_buffer(
|
||||
file,
|
||||
buffer,
|
||||
max_size_bytes=MAX_WORKSPACE_UPLOAD_SIZE_BYTES,
|
||||
too_large_message="文件过大,当前仅支持 100 MB 以内的文件",
|
||||
)
|
||||
upload_completed = True
|
||||
except FileExistsError as exc:
|
||||
raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
finally:
|
||||
if created_file and not upload_completed and target.exists():
|
||||
with contextlib.suppress(OSError):
|
||||
await asyncio.to_thread(target.unlink)
|
||||
|
||||
return {"success": True, "entry": _entry_for_path(root, target)}
|
||||
|
||||
|
||||
async def download_workspace_file(*, path: str, current_user: User) -> StreamingResponse | FileResponse:
|
||||
target = _resolve_workspace_path(current_user, path)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=400, detail="当前路径是目录")
|
||||
|
||||
file_name = target.name or "download"
|
||||
media_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream"
|
||||
headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}"}
|
||||
if target.stat().st_size > 1024 * 1024 * 16:
|
||||
return FileResponse(path=target, media_type=media_type, headers=headers)
|
||||
|
||||
content = await asyncio.to_thread(target.read_bytes)
|
||||
return StreamingResponse(io.BytesIO(content), media_type=media_type, headers=headers)
|
||||
@ -4,6 +4,8 @@ from yuxi import config
|
||||
|
||||
VIRTUAL_PATH_PREFIX = config.sandbox_virtual_path_prefix
|
||||
WORKSPACE_DIR_NAME = "workspace"
|
||||
WORKSPACE_AGENTS_DIR_NAME = "agents"
|
||||
WORKSPACE_AGENTS_PROMPT_FILE_NAME = "AGENTS.md"
|
||||
UPLOADS_DIR_NAME = "uploads"
|
||||
OUTPUTS_DIR_NAME = "outputs"
|
||||
VIRTUAL_SKILLS_PATH = "/home/gem/skills"
|
||||
@ -16,6 +18,8 @@ VIRTUAL_PATH_OUTPUTS = (Path(VIRTUAL_PATH_PREFIX) / OUTPUTS_DIR_NAME).as_posix()
|
||||
__all__ = [
|
||||
"VIRTUAL_PATH_PREFIX",
|
||||
"WORKSPACE_DIR_NAME",
|
||||
"WORKSPACE_AGENTS_DIR_NAME",
|
||||
"WORKSPACE_AGENTS_PROMPT_FILE_NAME",
|
||||
"UPLOADS_DIR_NAME",
|
||||
"OUTPUTS_DIR_NAME",
|
||||
"VIRTUAL_PATH_WORKSPACE",
|
||||
|
||||
@ -15,6 +15,7 @@ from server.routers.task_router import tasks
|
||||
from server.routers.tool_router import tools
|
||||
from server.routers.apikey_router import apikey_router
|
||||
from server.routers.filesystem_router import filesystem_router
|
||||
from server.routers.workspace_router import workspace
|
||||
|
||||
_LITE_MODE = os.environ.get("LITE_MODE", "").lower() in ("true", "1")
|
||||
|
||||
@ -36,6 +37,7 @@ router.include_router(subagents_router) # /api/system/subagents/* 子智能体
|
||||
router.include_router(tools) # /api/system/tools/* 工具列表与配置
|
||||
router.include_router(apikey_router) # /api/apikey/* API Key 管理
|
||||
router.include_router(filesystem_router) # /api/viewer/filesystem/* 工作台文件系统视图
|
||||
router.include_router(workspace) # /api/workspace/* 用户个人工作区
|
||||
|
||||
if not _LITE_MODE:
|
||||
from server.routers.graph_router import graph
|
||||
|
||||
93
backend/server/routers/workspace_router.py
Normal file
93
backend/server/routers/workspace_router.py
Normal file
@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from server.utils.auth_middleware import get_required_user
|
||||
from yuxi.services.workspace_service import (
|
||||
create_workspace_directory,
|
||||
delete_workspace_path,
|
||||
download_workspace_file,
|
||||
list_workspace_tree,
|
||||
read_workspace_file_content,
|
||||
upload_workspace_file,
|
||||
write_workspace_file_content,
|
||||
)
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
|
||||
workspace = APIRouter(prefix="/workspace", tags=["workspace"])
|
||||
|
||||
|
||||
class CreateWorkspaceDirectoryRequest(BaseModel):
|
||||
parent_path: str
|
||||
name: str
|
||||
|
||||
|
||||
class UpdateWorkspaceFileContentRequest(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
@workspace.get("/tree", response_model=dict)
|
||||
async def get_workspace_tree(
|
||||
path: str = Query("/", description="工作区目录路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await list_workspace_tree(path=path, current_user=current_user)
|
||||
|
||||
|
||||
@workspace.get("/file", response_model=dict)
|
||||
async def get_workspace_file(
|
||||
path: str = Query(..., description="工作区文件路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await read_workspace_file_content(path=path, current_user=current_user)
|
||||
|
||||
|
||||
@workspace.put("/file", response_model=dict)
|
||||
async def update_workspace_file(
|
||||
payload: UpdateWorkspaceFileContentRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await write_workspace_file_content(
|
||||
path=payload.path,
|
||||
content=payload.content,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
@workspace.delete("/file", response_model=dict)
|
||||
async def delete_workspace_file_route(
|
||||
path: str = Query(..., description="工作区文件或目录路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await delete_workspace_path(path=path, current_user=current_user)
|
||||
|
||||
|
||||
@workspace.post("/directory", response_model=dict)
|
||||
async def create_workspace_directory_route(
|
||||
payload: CreateWorkspaceDirectoryRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await create_workspace_directory(
|
||||
parent_path=payload.parent_path,
|
||||
name=payload.name,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
@workspace.post("/upload", response_model=dict)
|
||||
async def upload_workspace_file_route(
|
||||
parent_path: str = Form(..., description="父目录路径"),
|
||||
file: UploadFile = File(..., description="上传文件"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await upload_workspace_file(parent_path=parent_path, file=file, current_user=current_user)
|
||||
|
||||
|
||||
@workspace.get("/download")
|
||||
async def download_workspace(
|
||||
path: str = Query(..., description="工作区文件路径"),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await download_workspace_file(path=path, current_user=current_user)
|
||||
@ -8,6 +8,21 @@ from langchain.messages import AIMessage, HumanMessage
|
||||
from yuxi.services import chat_service as svc
|
||||
|
||||
|
||||
def _empty_agents_prompt(_thread_id: str, _user_id: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class _FakeAgentConfigRepo:
|
||||
def __init__(self, _db):
|
||||
pass
|
||||
|
||||
async def get_by_id(self, config_id: int):
|
||||
return SimpleNamespace(id=config_id)
|
||||
|
||||
async def get_or_create_default(self, *, department_id: str, agent_id: str, created_by: str):
|
||||
return SimpleNamespace(id=999, department_id=department_id, agent_id=agent_id, created_by=created_by)
|
||||
|
||||
|
||||
class _FakeConvRepo:
|
||||
def __init__(self, _db):
|
||||
self.saved_messages: list[dict] = []
|
||||
@ -112,10 +127,12 @@ async def test_agent_chat_uses_invoke_messages_and_persists_langgraph_state(monk
|
||||
monkeypatch.setattr(svc, "_build_langfuse_run_context", fake_build_langfuse_run_context)
|
||||
monkeypatch.setattr(svc, "get_trace_info", fake_get_trace_info)
|
||||
monkeypatch.setattr(svc, "flush_langfuse", lambda: calls.setdefault("flushed", True))
|
||||
monkeypatch.setattr(svc, "_load_workspace_agents_prompt", _empty_agents_prompt)
|
||||
|
||||
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda agent_id: FakeAgent())
|
||||
monkeypatch.setattr(svc, "get_agent_config_by_id", fake_get_agent_config_by_id)
|
||||
monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo)
|
||||
monkeypatch.setattr(svc, "AgentConfigRepository", _FakeAgentConfigRepo)
|
||||
monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state)
|
||||
monkeypatch.setattr(svc.content_guard, "check", fake_guard_check)
|
||||
|
||||
@ -193,10 +210,12 @@ async def test_agent_chat_sync_returns_finished_even_when_state_has_interrupt(mo
|
||||
)
|
||||
monkeypatch.setattr(svc, "get_trace_info", lambda _run_context: {})
|
||||
monkeypatch.setattr(svc, "flush_langfuse", lambda: None)
|
||||
monkeypatch.setattr(svc, "_load_workspace_agents_prompt", _empty_agents_prompt)
|
||||
|
||||
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda agent_id: FakeAgent())
|
||||
monkeypatch.setattr(svc, "get_agent_config_by_id", fake_get_agent_config_by_id)
|
||||
monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo)
|
||||
monkeypatch.setattr(svc, "AgentConfigRepository", _FakeAgentConfigRepo)
|
||||
monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state)
|
||||
monkeypatch.setattr(svc.content_guard, "check", fake_guard_check)
|
||||
|
||||
@ -214,3 +233,37 @@ async def test_agent_chat_sync_returns_finished_even_when_state_has_interrupt(mo
|
||||
assert result["response"] == "Need input later"
|
||||
assert result["thread_id"] == "thread-2"
|
||||
assert result["request_id"] == "req-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_agent_input_context_merges_workspace_agents_prompt(monkeypatch: pytest.MonkeyPatch):
|
||||
def fake_agents_prompt(_thread_id: str, _user_id: str) -> str:
|
||||
return "回答前先读取 AGENTS.md"
|
||||
|
||||
monkeypatch.setattr(svc, "_load_workspace_agents_prompt", fake_agents_prompt)
|
||||
|
||||
context = await svc._build_agent_input_context(
|
||||
{"system_prompt": "原始系统提示词", "temperature": 0.1},
|
||||
thread_id="thread-1",
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
assert context["system_prompt"] == "原始系统提示词\n\n用户工作区 agents/AGENTS.md 内容:\n回答前先读取 AGENTS.md"
|
||||
assert context["temperature"] == 0.1
|
||||
assert context["thread_id"] == "thread-1"
|
||||
assert context["user_id"] == "user-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_agent_input_context_keeps_prompt_when_workspace_agents_prompt_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(svc, "_load_workspace_agents_prompt", _empty_agents_prompt)
|
||||
|
||||
context = await svc._build_agent_input_context(
|
||||
{"system_prompt": "原始系统提示词"},
|
||||
thread_id="thread-1",
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
assert context["system_prompt"] == "原始系统提示词"
|
||||
|
||||
183
backend/test/unit/services/test_workspace_service.py
Normal file
183
backend/test/unit/services/test_workspace_service.py
Normal file
@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from yuxi.agents.backends.sandbox import paths as workspace_paths
|
||||
from yuxi.services import workspace_service as svc
|
||||
|
||||
|
||||
def test_workspace_root_creates_default_agents_prompt_file(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
|
||||
root = svc._workspace_root(SimpleNamespace(id="user-1"))
|
||||
|
||||
agents_file = root / "agents" / "AGENTS.md"
|
||||
assert agents_file.is_file()
|
||||
assert agents_file.read_text(encoding="utf-8") == ""
|
||||
|
||||
|
||||
def test_ensure_thread_dirs_creates_default_agents_prompt_file(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
|
||||
workspace_paths.ensure_thread_dirs("thread-1", "user-1")
|
||||
|
||||
agents_file = tmp_path / "threads" / "shared" / "user-1" / "workspace" / "agents" / "AGENTS.md"
|
||||
assert agents_file.is_file()
|
||||
assert agents_file.read_text(encoding="utf-8") == ""
|
||||
|
||||
|
||||
def test_workspace_root_keeps_existing_agents_prompt_file(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
agents_dir = tmp_path / "threads" / "shared" / "user-1" / "workspace" / "agents"
|
||||
agents_dir.mkdir(parents=True)
|
||||
agents_file = agents_dir / "AGENTS.md"
|
||||
agents_file.write_text("保留已有内容", encoding="utf-8")
|
||||
|
||||
root = svc._workspace_root(SimpleNamespace(id="user-1"))
|
||||
|
||||
assert root == tmp_path / "threads" / "shared" / "user-1" / "workspace"
|
||||
assert agents_file.read_text(encoding="utf-8") == "保留已有内容"
|
||||
|
||||
|
||||
def test_workspace_root_rejects_symlink_root(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
user_root = tmp_path / "threads" / "shared" / "user-1"
|
||||
outside_root = tmp_path / "outside"
|
||||
user_root.mkdir(parents=True)
|
||||
outside_root.mkdir()
|
||||
(user_root / "workspace").symlink_to(outside_root, target_is_directory=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
svc._workspace_root(SimpleNamespace(id="user-1"))
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_workspace_file_content_returns_unsupported_for_non_utf8_text(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
user = SimpleNamespace(id="user-1")
|
||||
root = svc._workspace_root(user)
|
||||
target = root / "bad.txt"
|
||||
target.write_bytes(b"\xff\xfe\x00")
|
||||
|
||||
result = await svc.read_workspace_file_content(path="/bad.txt", current_user=user)
|
||||
|
||||
assert result["content"] is None
|
||||
assert result["preview_type"] == "unsupported"
|
||||
assert result["supported"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_workspace_file_content_updates_markdown_file(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
user = SimpleNamespace(id="user-1")
|
||||
root = svc._workspace_root(user)
|
||||
target = root / "note.md"
|
||||
target.write_text("旧内容", encoding="utf-8")
|
||||
|
||||
result = await svc.write_workspace_file_content(path="/note.md", content="# 新内容", current_user=user)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["path"] == "/note.md"
|
||||
assert result["entry"]["path"] == "/note.md"
|
||||
assert target.read_text(encoding="utf-8") == "# 新内容"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_workspace_file_content_updates_txt_file(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
user = SimpleNamespace(id="user-1")
|
||||
root = svc._workspace_root(user)
|
||||
target = root / "note.txt"
|
||||
target.write_text("old", encoding="utf-8")
|
||||
|
||||
await svc.write_workspace_file_content(path="/note.txt", content="new", current_user=user)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_workspace_file_content_rejects_unsupported_suffix(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
user = SimpleNamespace(id="user-1")
|
||||
root = svc._workspace_root(user)
|
||||
target = root / "script.py"
|
||||
target.write_text("print('hello')", encoding="utf-8")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.write_workspace_file_content(path="/script.py", content="print('bye')", current_user=user)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert target.read_text(encoding="utf-8") == "print('hello')"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_workspace_file_content_rejects_directory_and_missing_file(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
user = SimpleNamespace(id="user-1")
|
||||
svc._workspace_root(user)
|
||||
|
||||
with pytest.raises(HTTPException) as directory_error:
|
||||
await svc.write_workspace_file_content(path="/agents/", content="x", current_user=user)
|
||||
with pytest.raises(HTTPException) as missing_error:
|
||||
await svc.write_workspace_file_content(path="/missing.md", content="x", current_user=user)
|
||||
|
||||
assert directory_error.value.status_code == 400
|
||||
assert missing_error.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_workspace_file_content_blocks_path_traversal(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.write_workspace_file_content(
|
||||
path="/../outside.md",
|
||||
content="x",
|
||||
current_user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_workspace_file_writes_file(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
user = SimpleNamespace(id="user-1")
|
||||
root = svc._workspace_root(user)
|
||||
upload = UploadFile(filename="demo.txt", file=BytesIO(b"hello"))
|
||||
|
||||
result = await svc.upload_workspace_file(parent_path="/", file=upload, current_user=user)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["entry"]["path"] == "/demo.txt"
|
||||
assert result["entry"]["size"] == 5
|
||||
assert (root / "demo.txt").read_bytes() == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_workspace_file_rejects_oversized_file_and_cleans_partial_file(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
monkeypatch.setattr(svc, "MAX_WORKSPACE_UPLOAD_SIZE_BYTES", 5)
|
||||
user = SimpleNamespace(id="user-1")
|
||||
root = svc._workspace_root(user)
|
||||
upload = UploadFile(filename="large.txt", file=BytesIO(b"123456"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.upload_workspace_file(parent_path="/", file=upload, current_user=user)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "100 MB" in exc_info.value.detail
|
||||
assert not (root / "large.txt").exists()
|
||||
@ -183,6 +183,18 @@ Context 的价值不只在“配置页面”。它贯穿了从配置加载到实
|
||||
|
||||
也就是说,运行期 Context 的基础来源并不是前端临时状态,而是数据库中保存的 AgentConfig。
|
||||
|
||||
此外,用户工作区会默认创建 `agents/AGENTS.md`。当 Agent 开始执行时,后端会读取当前用户工作区下的这个文件,并将其内容追加到 `system_prompt`,用于补充该用户对 Agent 的长期指令或工作区约定。该文件属于用户级共享工作区,内容会随 `user_id` 和当前 `thread_id` 映射到运行时工作区路径;文件不存在、为空或不可读时不会影响 Agent 启动,单次注入内容最多读取 64 KiB,超出部分会截断并追加提示。
|
||||
|
||||
合并后的提示词结构可以理解为:
|
||||
|
||||
```text
|
||||
AgentConfig.config_json.context.system_prompt
|
||||
+ 用户工作区 agents/AGENTS.md 内容
|
||||
+ 运行期中间件继续追加的系统提示段
|
||||
```
|
||||
|
||||
因此,`agents/AGENTS.md` 适合放置用户维度的稳定约束,不适合放置一次性任务要求;一次性要求仍应直接写在当前对话中。
|
||||
|
||||
### 4.2 Context 实例化阶段
|
||||
|
||||
`BaseAgent` 在运行前会创建 `context_schema()` 实例,并通过 `update_from_dict()` 注入配置值。
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
- 完善 Skills 的环境变量注入
|
||||
- 拓宽检索的知识源,统一多知识源(channel),目前已知知识库/知识图谱/网页,可拓展:个人知识库、数据库、历史对话等
|
||||
- 前置任务,多知识库并行检索(扩展 query_kb)
|
||||
- 新增 query_keywords 工具,专门用于基于关键词命中的排序,也结合词频(和 BM25 的区别?)
|
||||
- 评估
|
||||
|
||||
### Bugs
|
||||
- 目前的知识库的图片存在公开访问风险
|
||||
@ -35,6 +37,7 @@
|
||||
### 0.6.2 开发记录
|
||||
|
||||
<!-- 0.6.2 的内容请放在这里 -->
|
||||
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
|
||||
- 加固 JWT 鉴权安全:移除历史默认密钥回退,初始化脚本支持生成并持久化 `JWT_SECRET_KEY` 与 `YUXI_INSTANCE_ID`,签发和验证令牌时校验 `iss/aud`,并在鉴权阶段拒绝已删除或登录锁定用户继续使用旧令牌访问系统。
|
||||
- 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。
|
||||
- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
|
||||
|
||||
49
web/src/apis/workspace_api.js
Normal file
49
web/src/apis/workspace_api.js
Normal file
@ -0,0 +1,49 @@
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from './base'
|
||||
|
||||
const buildQuery = (params) => {
|
||||
const query = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
query.set(key, String(value))
|
||||
}
|
||||
})
|
||||
return query.toString()
|
||||
}
|
||||
|
||||
export const getWorkspaceTree = (path = '/') => {
|
||||
const query = buildQuery({ path })
|
||||
return apiGet(`/api/workspace/tree?${query}`)
|
||||
}
|
||||
|
||||
export const getWorkspaceFileContent = (path) => {
|
||||
const query = buildQuery({ path })
|
||||
return apiGet(`/api/workspace/file?${query}`)
|
||||
}
|
||||
|
||||
export const saveWorkspaceFileContent = (path, content) => {
|
||||
return apiPut('/api/workspace/file', { path, content })
|
||||
}
|
||||
|
||||
export const deleteWorkspacePath = (path) => {
|
||||
const query = buildQuery({ path })
|
||||
return apiDelete(`/api/workspace/file?${query}`)
|
||||
}
|
||||
|
||||
export const createWorkspaceDirectory = (parentPath, name) => {
|
||||
return apiPost('/api/workspace/directory', {
|
||||
parent_path: parentPath,
|
||||
name
|
||||
})
|
||||
}
|
||||
|
||||
export const uploadWorkspaceFile = (parentPath, file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('parent_path', parentPath)
|
||||
formData.append('file', file)
|
||||
return apiPost('/api/workspace/upload', formData)
|
||||
}
|
||||
|
||||
export const downloadWorkspaceFile = (path) => {
|
||||
const query = buildQuery({ path })
|
||||
return apiGet(`/api/workspace/download?${query}`, {}, true, 'blob')
|
||||
}
|
||||
@ -39,7 +39,6 @@ body {
|
||||
|
||||
.layout-container {
|
||||
width: 100%;
|
||||
padding: 0 var(--page-padding);
|
||||
|
||||
h2 {
|
||||
margin: 20px 0 10px 0;
|
||||
|
||||
@ -9,6 +9,42 @@
|
||||
<span class="file-path-title">{{ filePath }}</span>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<div v-if="canEdit" class="preview-mode-switch">
|
||||
<button
|
||||
class="preview-mode-btn"
|
||||
:class="{ active: editMode === 'preview' }"
|
||||
@click="editMode = 'preview'"
|
||||
title="预览"
|
||||
>
|
||||
<Eye :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="preview-mode-btn"
|
||||
:class="{ active: editMode === 'edit' }"
|
||||
@click="editMode = 'edit'"
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="canEdit && editMode === 'edit'"
|
||||
class="modal-action-btn"
|
||||
:disabled="saving || !draftChanged"
|
||||
@click="requestSave"
|
||||
title="保存"
|
||||
>
|
||||
<Save :size="18" />
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit && editMode === 'edit'"
|
||||
class="modal-action-btn"
|
||||
:disabled="saving"
|
||||
@click="cancelEdit"
|
||||
title="取消编辑"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
<div v-if="isHtmlFile" class="preview-mode-switch">
|
||||
<button
|
||||
class="preview-mode-btn"
|
||||
@ -43,14 +79,28 @@
|
||||
>
|
||||
<Maximize2 :size="18" />
|
||||
</button>
|
||||
<button v-if="showClose" class="modal-action-btn" @click="$emit('close')" title="关闭">
|
||||
<X :size="18" />
|
||||
<button
|
||||
v-if="showClose"
|
||||
class="modal-action-btn"
|
||||
@click="$emit('close')"
|
||||
:title="closeTitle"
|
||||
:aria-label="closeTitle"
|
||||
>
|
||||
<component :is="closeIconComponent" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-content" :class="contentClass">
|
||||
<template v-if="file?.previewType === 'image' && file?.previewUrl">
|
||||
<template v-if="canEdit && editMode === 'edit'">
|
||||
<textarea
|
||||
v-model="draftContent"
|
||||
class="file-edit-textarea"
|
||||
:disabled="saving"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="file?.previewType === 'image' && file?.previewUrl">
|
||||
<div class="image-preview-wrapper">
|
||||
<img :src="file.previewUrl" :alt="filePath" class="image-preview" />
|
||||
</div>
|
||||
@ -190,13 +240,30 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { Code2, Download, Globe, Maximize2, X } from 'lucide-vue-next'
|
||||
import {
|
||||
Code2,
|
||||
Download,
|
||||
Eye,
|
||||
Globe,
|
||||
Maximize2,
|
||||
PanelRightClose,
|
||||
Pencil,
|
||||
Save,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import { MdPreview } from 'md-editor-v3'
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
import 'md-editor-v3/lib/preview.css'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
|
||||
import { getCodeLanguageByPath, isHtmlPreview, isMarkdownPreview } from '@/utils/file_preview'
|
||||
import {
|
||||
getCodeLanguageByPath,
|
||||
getPreviewFileExtension,
|
||||
isHtmlPreview,
|
||||
isMarkdownPreview
|
||||
} from '@/utils/file_preview'
|
||||
|
||||
const EDITABLE_EXTENSIONS = new Set(['.md', '.markdown', '.mdx', '.txt'])
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
@ -223,10 +290,23 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
closeVariant: {
|
||||
type: String,
|
||||
default: 'close',
|
||||
validator: (value) => ['close', 'collapse-right'].includes(value)
|
||||
},
|
||||
fullHeight: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
containerClass: {
|
||||
type: [String, Array, Object],
|
||||
default: ''
|
||||
@ -237,15 +317,32 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['close', 'download'])
|
||||
const emit = defineEmits(['close', 'download', 'save'])
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
|
||||
const closeTitle = computed(() =>
|
||||
props.closeVariant === 'collapse-right' ? '收起预览面板' : '关闭预览'
|
||||
)
|
||||
const closeIconComponent = computed(() =>
|
||||
props.closeVariant === 'collapse-right' ? PanelRightClose : X
|
||||
)
|
||||
const htmlPreviewMode = ref('render')
|
||||
const editMode = ref('preview')
|
||||
const draftContent = ref('')
|
||||
const fullscreenPreviewVisible = ref(false)
|
||||
const htmlPreviewRenderKey = ref(0)
|
||||
|
||||
const isMarkdown = computed(() => isMarkdownPreview(props.filePath, props.file?.previewType))
|
||||
const canEdit = computed(
|
||||
() =>
|
||||
props.editable &&
|
||||
props.file?.supported !== false &&
|
||||
typeof props.file?.content === 'string' &&
|
||||
EDITABLE_EXTENSIONS.has(getPreviewFileExtension(props.filePath))
|
||||
)
|
||||
const savedContent = computed(() => formatContent(props.file?.content))
|
||||
const draftChanged = computed(() => draftContent.value !== savedContent.value)
|
||||
const isHtmlFile = computed(
|
||||
() =>
|
||||
props.file?.previewType === 'text' &&
|
||||
@ -293,6 +390,20 @@ const formatContent = (content) => {
|
||||
return String(content)
|
||||
}
|
||||
|
||||
const syncDraftContent = () => {
|
||||
draftContent.value = savedContent.value
|
||||
editMode.value = 'preview'
|
||||
}
|
||||
|
||||
const requestSave = () => {
|
||||
if (!canEdit.value || props.saving) return
|
||||
emit('save', draftContent.value)
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
syncDraftContent()
|
||||
}
|
||||
|
||||
const openFullscreenPreview = () => {
|
||||
if (!props.file) return
|
||||
fullscreenPreviewVisible.value = true
|
||||
@ -309,6 +420,10 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch([() => props.filePath, () => props.file?.content, canEdit], syncDraftContent, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
watch([() => props.filePath, () => props.file?.previewType, () => props.file?.content], () => {
|
||||
if (isHtmlFile.value) {
|
||||
htmlPreviewRenderKey.value += 1
|
||||
@ -381,8 +496,8 @@ onUnmounted(() => {
|
||||
|
||||
.modal-action-btn,
|
||||
.preview-mode-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -401,6 +516,18 @@ onUnmounted(() => {
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.modal-action-btn:disabled,
|
||||
.preview-mode-btn:disabled {
|
||||
color: var(--gray-300);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-action-btn:disabled:hover,
|
||||
.preview-mode-btn:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--gray-300);
|
||||
}
|
||||
|
||||
.preview-mode-btn.active {
|
||||
background: var(--gray-0);
|
||||
color: var(--gray-900);
|
||||
@ -436,6 +563,25 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.file-edit-textarea {
|
||||
width: 100%;
|
||||
min-height: calc(80vh - 40px);
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
resize: none;
|
||||
background: var(--gray-0);
|
||||
color: var(--gray-1000);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.file-edit-textarea:disabled {
|
||||
color: var(--gray-600);
|
||||
background: var(--gray-25);
|
||||
}
|
||||
|
||||
.file-content pre,
|
||||
.file-content-pre {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
|
||||
@ -94,7 +94,7 @@
|
||||
@click.stop="$emit('toggle-panel')"
|
||||
title="查看文件"
|
||||
>
|
||||
<FolderCode :size="18" />
|
||||
<FolderKanban :size="18" />
|
||||
<span>文件</span>
|
||||
</button>
|
||||
<slot name="actions-left-extra"></slot>
|
||||
@ -108,7 +108,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import MessageInputComponent from '@/components/MessageInputComponent.vue'
|
||||
import ImagePreviewComponent from '@/components/ImagePreviewComponent.vue'
|
||||
import AttachmentOptionsComponent from '@/components/AttachmentOptionsComponent.vue'
|
||||
import { FolderCode, SquareCheck } from 'lucide-vue-next'
|
||||
import { FolderKanban, SquareCheck } from 'lucide-vue-next'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
|
||||
@ -2,12 +2,15 @@
|
||||
<div ref="panelRef" class="agent-panel" :class="{ resizing: isResizing }">
|
||||
<!-- 拖拽手柄 -->
|
||||
<div class="resize-handle" @pointerdown="startResize"></div>
|
||||
<div class="panel-header" :class="{ 'is-compact': isCompactHeader }">
|
||||
<div class="panel-header">
|
||||
<div class="panel-header-main">
|
||||
<div class="panel-title">
|
||||
<span><strong>文件系统</strong></span>
|
||||
</div>
|
||||
<div class="window-actions">
|
||||
<button class="header-action-btn" title="刷新" @click="emitRefresh">
|
||||
<RefreshCw :size="15" />
|
||||
</button>
|
||||
<button
|
||||
class="header-action-btn"
|
||||
:title="isExpanded ? '恢复高度' : '向上展开'"
|
||||
@ -20,34 +23,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-toolbar">
|
||||
<button
|
||||
class="header-action-btn"
|
||||
title="新建文件夹"
|
||||
:disabled="!threadId"
|
||||
@click="openCreateDirectoryModal"
|
||||
>
|
||||
<FolderPlus :size="15" />
|
||||
</button>
|
||||
<button
|
||||
class="header-action-btn"
|
||||
title="上传文件"
|
||||
:disabled="!threadId"
|
||||
@click="openUploadFilePicker"
|
||||
>
|
||||
<Upload :size="15" />
|
||||
</button>
|
||||
<button class="header-action-btn" title="刷新" @click="emitRefresh">
|
||||
<RefreshCw :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="uploadInputRef"
|
||||
class="hidden-file-input"
|
||||
type="file"
|
||||
@change="handleUploadInputChange"
|
||||
/>
|
||||
<div class="tab-content">
|
||||
<div class="files-display">
|
||||
<div v-if="!threadId" class="empty">创建对话后可查看工作区</div>
|
||||
@ -106,6 +82,7 @@
|
||||
:filePath="currentFilePath"
|
||||
:fullHeight="true"
|
||||
:showClose="true"
|
||||
closeVariant="collapse-right"
|
||||
:showDownload="true"
|
||||
:showFullscreen="true"
|
||||
@download="downloadFile"
|
||||
@ -142,49 +119,20 @@
|
||||
@close="closePreview"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="createDirectoryModalVisible"
|
||||
title="新建文件夹"
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
:confirmLoading="creatingDirectory"
|
||||
@ok="createDirectory"
|
||||
@cancel="closeCreateDirectoryModal"
|
||||
>
|
||||
<p>文件夹将创建在{{ resolveWorkspaceTargetDirectory() }}下</p>
|
||||
<a-input
|
||||
v-model:value="newDirectoryName"
|
||||
placeholder="输入文件夹名"
|
||||
:maxlength="120"
|
||||
@pressEnter="createDirectory"
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Download,
|
||||
FolderPlus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
X
|
||||
} from 'lucide-vue-next'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Download, RefreshCw, Trash2, X } from 'lucide-vue-next'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import FileTreeComponent from '@/components/FileTreeComponent.vue'
|
||||
import AgentFilePreview from '@/components/AgentFilePreview.vue'
|
||||
import {
|
||||
createViewerDirectory,
|
||||
deleteViewerFile,
|
||||
downloadViewerFile,
|
||||
getViewerFileContent,
|
||||
getViewerFileSystemTree,
|
||||
uploadViewerFile
|
||||
getViewerFileSystemTree
|
||||
} from '@/apis/viewer_filesystem'
|
||||
|
||||
const props = defineProps({
|
||||
@ -220,20 +168,15 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['refresh', 'close', 'resize', 'resizing', 'toggle-expand'])
|
||||
const INLINE_PREVIEW_MIN_WIDTH = 920
|
||||
const WORKSPACE_PATH = '/home/gem/user-data/workspace'
|
||||
const DEFAULT_EXPANDED_ROOT_DIRECTORY_NAME = 'user-data'
|
||||
|
||||
const panelRef = ref(null)
|
||||
const uploadInputRef = ref(null)
|
||||
const modalVisible = ref(false)
|
||||
const createDirectoryModalVisible = ref(false)
|
||||
const currentFile = ref(null)
|
||||
const currentFilePath = ref('')
|
||||
const loadingFiles = ref(false)
|
||||
const filesystemError = ref('')
|
||||
const panelWidth = ref(0)
|
||||
const newDirectoryName = ref('')
|
||||
const creatingDirectory = ref(false)
|
||||
const uploadingFile = ref(false)
|
||||
|
||||
const dynamicTreeData = ref([])
|
||||
const selectedKeys = ref([])
|
||||
@ -241,7 +184,6 @@ const expandedKeys = ref([])
|
||||
const deletingPaths = ref(new Set())
|
||||
|
||||
const useInlinePreview = computed(() => panelWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
|
||||
const isCompactHeader = computed(() => panelWidth.value > 0 && panelWidth.value < 360)
|
||||
|
||||
const buildDisplayName = (fullPath) => {
|
||||
const normalized = String(fullPath || '').replace(/\/+$/, '')
|
||||
@ -329,43 +271,6 @@ const removeTreeNode = (nodes, targetKey) => {
|
||||
|
||||
const normalizePathKey = (path) => String(path || '').replace(/\/+$/, '')
|
||||
|
||||
const isWorkspacePath = (path) => {
|
||||
const normalizedPath = normalizePathKey(path)
|
||||
return normalizedPath === WORKSPACE_PATH || normalizedPath.startsWith(`${WORKSPACE_PATH}/`)
|
||||
}
|
||||
|
||||
const parentPathOf = (path) => {
|
||||
const normalizedPath = normalizePathKey(path)
|
||||
if (!normalizedPath || normalizedPath === '/') return '/'
|
||||
const parts = normalizedPath.split('/').filter(Boolean)
|
||||
parts.pop()
|
||||
return parts.length ? `/${parts.join('/')}` : '/'
|
||||
}
|
||||
|
||||
const findTreeNode = (nodes, targetKey) => {
|
||||
const normalizedTargetKey = normalizePathKey(targetKey)
|
||||
for (const node of nodes) {
|
||||
if (normalizePathKey(node.key) === normalizedTargetKey) return node
|
||||
if (node.children?.length) {
|
||||
const child = findTreeNode(node.children, targetKey)
|
||||
if (child) return child
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const resolveWorkspaceTargetDirectory = () => {
|
||||
const selectedKey = selectedKeys.value[0]
|
||||
if (!selectedKey) return WORKSPACE_PATH
|
||||
|
||||
// 根据当前选中节点推断写入目录,避免把文件上传到只读命名空间。
|
||||
const selectedNode = findTreeNode(dynamicTreeData.value, selectedKey)
|
||||
const targetPath = selectedNode?.isLeaf
|
||||
? parentPathOf(selectedKey)
|
||||
: normalizePathKey(selectedKey)
|
||||
return isWorkspacePath(targetPath) ? targetPath : ''
|
||||
}
|
||||
|
||||
const isSameOrChildPath = (path, targetPath) => {
|
||||
const normalizedPath = normalizePathKey(path)
|
||||
const normalizedTargetPath = normalizePathKey(targetPath)
|
||||
@ -402,6 +307,22 @@ const getFileName = (fileItem) => {
|
||||
return '未知文件'
|
||||
}
|
||||
|
||||
const loadDirectoryChildren = async (directoryPath) => {
|
||||
const res = await getViewerFileSystemTree(
|
||||
props.threadId,
|
||||
directoryPath,
|
||||
props.agentId,
|
||||
props.agentConfigId
|
||||
)
|
||||
return sortEntries(res?.entries || []).map((entry) => createTreeNode(entry))
|
||||
}
|
||||
|
||||
const findDefaultExpandedRootNode = (nodes) => {
|
||||
return nodes.find(
|
||||
(node) => !node.isLeaf && buildDisplayName(node.key) === DEFAULT_EXPANDED_ROOT_DIRECTORY_NAME
|
||||
)
|
||||
}
|
||||
|
||||
const refreshFileSystem = async () => {
|
||||
if (!props.threadId) {
|
||||
dynamicTreeData.value = []
|
||||
@ -420,9 +341,21 @@ const refreshFileSystem = async () => {
|
||||
props.agentConfigId
|
||||
)
|
||||
if (res?.entries) {
|
||||
dynamicTreeData.value = sortEntries(res.entries).map((entry) => createTreeNode(entry))
|
||||
expandedKeys.value = []
|
||||
const rootNodes = sortEntries(res.entries).map((entry) => createTreeNode(entry))
|
||||
const defaultExpandedNode = findDefaultExpandedRootNode(rootNodes)
|
||||
|
||||
dynamicTreeData.value = rootNodes
|
||||
expandedKeys.value = defaultExpandedNode ? [defaultExpandedNode.key] : []
|
||||
selectedKeys.value = []
|
||||
|
||||
if (defaultExpandedNode) {
|
||||
try {
|
||||
const children = await loadDirectoryChildren(defaultExpandedNode.key)
|
||||
dynamicTreeData.value = updateTreeChildren(rootNodes, defaultExpandedNode.key, children)
|
||||
} catch (error) {
|
||||
console.error('Failed to load default expanded directory', error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dynamicTreeData.value = []
|
||||
}
|
||||
@ -435,47 +368,14 @@ const refreshFileSystem = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = (treeNode) => {
|
||||
return new Promise((resolve) => {
|
||||
if (treeNode.isLeaf || (treeNode.children && treeNode.children.length > 0) || !props.threadId) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const loadData = async (treeNode) => {
|
||||
if (treeNode.isLeaf || treeNode.children?.length || !props.threadId) return
|
||||
|
||||
getViewerFileSystemTree(props.threadId, treeNode.key, props.agentId, props.agentConfigId)
|
||||
.then((res) => {
|
||||
if (res?.entries) {
|
||||
const children = sortEntries(res.entries).map((entry) => createTreeNode(entry))
|
||||
dynamicTreeData.value = updateTreeChildren(dynamicTreeData.value, treeNode.key, children)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load children for', treeNode.key, error)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const refreshDirectoryChildren = async (directoryPath) => {
|
||||
const normalizedDirectoryPath = normalizePathKey(directoryPath)
|
||||
const targetNode = findTreeNode(dynamicTreeData.value, normalizedDirectoryPath)
|
||||
if (!targetNode || targetNode.isLeaf) {
|
||||
return
|
||||
}
|
||||
|
||||
const res = await getViewerFileSystemTree(
|
||||
props.threadId,
|
||||
normalizedDirectoryPath,
|
||||
props.agentId,
|
||||
props.agentConfigId
|
||||
)
|
||||
if (res?.entries) {
|
||||
const children = sortEntries(res.entries).map((entry) => createTreeNode(entry))
|
||||
dynamicTreeData.value = updateTreeChildren(dynamicTreeData.value, targetNode.key, children)
|
||||
if (!expandedKeys.value.includes(targetNode.key)) {
|
||||
expandedKeys.value = [...expandedKeys.value, targetNode.key]
|
||||
}
|
||||
try {
|
||||
const children = await loadDirectoryChildren(treeNode.key)
|
||||
dynamicTreeData.value = updateTreeChildren(dynamicTreeData.value, treeNode.key, children)
|
||||
} catch (error) {
|
||||
console.error('Failed to load children for', treeNode.key, error)
|
||||
}
|
||||
}
|
||||
|
||||
@ -595,100 +495,6 @@ const confirmDeleteNode = (node) => {
|
||||
})
|
||||
}
|
||||
|
||||
const openCreateDirectoryModal = () => {
|
||||
if (!props.threadId) return
|
||||
const targetDirectory = resolveWorkspaceTargetDirectory()
|
||||
if (!targetDirectory) {
|
||||
message.warning('只能在 workspace 目录下新建文件夹')
|
||||
return
|
||||
}
|
||||
newDirectoryName.value = ''
|
||||
createDirectoryModalVisible.value = true
|
||||
}
|
||||
|
||||
const closeCreateDirectoryModal = () => {
|
||||
createDirectoryModalVisible.value = false
|
||||
newDirectoryName.value = ''
|
||||
}
|
||||
|
||||
const createDirectory = async () => {
|
||||
if (creatingDirectory.value) return
|
||||
const targetDirectory = resolveWorkspaceTargetDirectory()
|
||||
const directoryName = newDirectoryName.value.trim()
|
||||
|
||||
if (!targetDirectory) {
|
||||
message.warning('只能在 workspace 目录下新建文件夹')
|
||||
return
|
||||
}
|
||||
if (!directoryName) {
|
||||
message.warning('请输入文件夹名')
|
||||
return
|
||||
}
|
||||
|
||||
creatingDirectory.value = true
|
||||
try {
|
||||
await createViewerDirectory(
|
||||
props.threadId,
|
||||
targetDirectory,
|
||||
directoryName,
|
||||
props.agentId,
|
||||
props.agentConfigId
|
||||
)
|
||||
await refreshDirectoryChildren(targetDirectory)
|
||||
closeCreateDirectoryModal()
|
||||
message.success('文件夹创建成功')
|
||||
} catch (error) {
|
||||
console.error('创建文件夹失败:', error)
|
||||
message.error(error?.message || '创建文件夹失败')
|
||||
} finally {
|
||||
creatingDirectory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openUploadFilePicker = () => {
|
||||
if (!props.threadId || uploadingFile.value) return
|
||||
const targetDirectory = resolveWorkspaceTargetDirectory()
|
||||
if (!targetDirectory) {
|
||||
message.warning('只能上传到 workspace 目录')
|
||||
return
|
||||
}
|
||||
if (uploadInputRef.value) {
|
||||
uploadInputRef.value.value = ''
|
||||
uploadInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadInputChange = async (event) => {
|
||||
const file = event.target?.files?.[0]
|
||||
if (!file || uploadingFile.value) return
|
||||
|
||||
const targetDirectory = resolveWorkspaceTargetDirectory()
|
||||
if (!targetDirectory) {
|
||||
message.warning('只能上传到 workspace 目录')
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
uploadingFile.value = true
|
||||
try {
|
||||
await uploadViewerFile(
|
||||
props.threadId,
|
||||
targetDirectory,
|
||||
file,
|
||||
props.agentId,
|
||||
props.agentConfigId
|
||||
)
|
||||
await refreshDirectoryChildren(targetDirectory)
|
||||
message.success('文件上传成功')
|
||||
} catch (error) {
|
||||
console.error('上传文件失败:', error)
|
||||
message.error(error?.message || '上传文件失败')
|
||||
} finally {
|
||||
uploadingFile.value = false
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const downloadFile = async (fileItem) => {
|
||||
if (!props.threadId || !fileItem?.path) return
|
||||
|
||||
@ -869,37 +675,11 @@ watch(useInlinePreview, (isInline) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: 2px;
|
||||
padding: 4px 16px;
|
||||
min-height: 44px;
|
||||
background: var(--gray-25);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.is-compact {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
|
||||
.panel-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-toolbar {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 4px;
|
||||
border-right: none;
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
background: var(--gray-0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header-main {
|
||||
@ -964,8 +744,6 @@ watch(useInlinePreview, (isInline) => {
|
||||
|
||||
.file-toolbar {
|
||||
order: 2;
|
||||
padding-right: 8px;
|
||||
border-right: 1px solid var(--gray-300);
|
||||
}
|
||||
|
||||
.window-actions {
|
||||
@ -973,10 +751,6 @@ watch(useInlinePreview, (isInline) => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@ -1130,125 +904,6 @@ watch(useInlinePreview, (isInline) => {
|
||||
}
|
||||
}
|
||||
|
||||
.todo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--gray-150);
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--main-10);
|
||||
border-color: var(--gray-200);
|
||||
}
|
||||
}
|
||||
|
||||
.todo-status {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 2px;
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
|
||||
&.completed {
|
||||
color: #52c41a;
|
||||
}
|
||||
&.in-progress {
|
||||
color: #1890ff;
|
||||
}
|
||||
&.pending {
|
||||
color: #faad14;
|
||||
}
|
||||
&.cancelled {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
&.unknown {
|
||||
color: var(--gray-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.todo-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--gray-1000);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.todo-item.completed & {
|
||||
color: var(--gray-500);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 4px;
|
||||
|
||||
.list-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 13px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: var(--gray-400);
|
||||
cursor: help;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-500);
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 6px;
|
||||
background: var(--gray-0);
|
||||
color: var(--gray-700);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--gray-50);
|
||||
color: var(--main-700);
|
||||
border-color: var(--main-300);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* File Tree Styles - VS Code Style Refined */
|
||||
.file-tree-container {
|
||||
margin: 0 -4px;
|
||||
|
||||
@ -266,7 +266,11 @@ const renameChat = async (chatId) => {
|
||||
|
||||
.actions-mask {
|
||||
opacity: 1;
|
||||
background: linear-gradient(to right, transparent, color-mix(in srgb, var(--main-color) 6%, var(--gray-0)) 20px);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--main-color) 6%, var(--gray-0)) 20px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -75,7 +75,14 @@
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { getKbTypeLabel, getKbTypeIcon, getKbTypeColor, parseModelSpec, buildDisplaySpec, buildLlmInfoPayload } from '@/utils/kb_utils'
|
||||
import {
|
||||
getKbTypeLabel,
|
||||
getKbTypeIcon,
|
||||
getKbTypeColor,
|
||||
parseModelSpec,
|
||||
buildDisplaySpec,
|
||||
buildLlmInfoPayload
|
||||
} from '@/utils/kb_utils'
|
||||
import { LeftOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import HeaderComponent from '@/components/HeaderComponent.vue'
|
||||
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
|
||||
|
||||
@ -158,7 +158,13 @@ import { ref, reactive, computed, h, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDatabaseStore } from '@/stores/database'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getKbTypeLabel, getKbTypeColor, parseModelSpec, buildDisplaySpec, buildLlmInfoPayload } from '@/utils/kb_utils'
|
||||
import {
|
||||
getKbTypeLabel,
|
||||
getKbTypeColor,
|
||||
parseModelSpec,
|
||||
buildDisplaySpec,
|
||||
buildLlmInfoPayload
|
||||
} from '@/utils/kb_utils'
|
||||
import {
|
||||
CHUNK_PRESET_OPTIONS,
|
||||
CHUNK_PRESET_LABEL_MAP,
|
||||
|
||||
@ -259,7 +259,10 @@ defineExpose({
|
||||
background: var(--gray-0);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
|
||||
@ -469,7 +469,6 @@ onMounted(() => {
|
||||
@import '@/assets/css/extensions.less';
|
||||
@import '@/assets/css/extension-detail.less';
|
||||
|
||||
|
||||
/* 工具列表样式 */
|
||||
.tools-tab {
|
||||
.tools-toolbar {
|
||||
@ -607,7 +606,6 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.mcp-detail {
|
||||
.detail-content-wrapper {
|
||||
flex: 1;
|
||||
|
||||
@ -323,10 +323,6 @@ const currentSkillStatusLabel = computed(() => {
|
||||
return '已上传'
|
||||
})
|
||||
|
||||
const currentSkillStatusTone = computed(() => {
|
||||
return currentSkill.value?.status === 'update_available' ? 'warning' : 'default'
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (!selectedPath.value || selectedIsDir.value) return false
|
||||
return fileContent.value !== originalFileContent.value
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="info-card"
|
||||
:class="{ 'info-card-disabled': disabled }"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<div class="info-card" :class="{ 'info-card-disabled': disabled }" @click="$emit('click')">
|
||||
<div class="info-card-header">
|
||||
<div class="info-card-icon">
|
||||
<slot name="icon">
|
||||
@ -12,9 +8,7 @@
|
||||
</div>
|
||||
<div class="info-card-info">
|
||||
<span class="info-card-name" :title="title">{{ title }}</span>
|
||||
<span v-if="subtitle" class="info-card-subtitle" :title="subtitle">{{
|
||||
subtitle
|
||||
}}</span>
|
||||
<span v-if="subtitle" class="info-card-subtitle" :title="subtitle">{{ subtitle }}</span>
|
||||
</div>
|
||||
<div class="info-card-status">
|
||||
<slot name="status" />
|
||||
@ -54,10 +48,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.tags || (normalizedTags && normalizedTags.length > 0)"
|
||||
class="info-card-tags"
|
||||
>
|
||||
<div v-if="$slots.tags || (normalizedTags && normalizedTags.length > 0)" class="info-card-tags">
|
||||
<slot name="tags">
|
||||
<span
|
||||
v-for="(tag, idx) in normalizedTags"
|
||||
@ -254,7 +245,6 @@ const normalizedTags = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.card-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
422
web/src/components/workspace/WorkspaceFileList.vue
Normal file
422
web/src/components/workspace/WorkspaceFileList.vue
Normal file
@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<section class="workspace-file-list">
|
||||
<div class="file-list-header">
|
||||
<div class="path-line">
|
||||
<a-breadcrumb class="path-breadcrumb">
|
||||
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item.path">
|
||||
<button
|
||||
type="button"
|
||||
class="breadcrumb-action"
|
||||
:class="{ current: item.path === normalizedCurrentPath }"
|
||||
:disabled="item.path === normalizedCurrentPath"
|
||||
:title="item.path"
|
||||
@click="$emit('select-path', item.path)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
<div class="list-actions">
|
||||
<span class="entry-count">{{ entries.length }} 项</span>
|
||||
<a-tooltip title="多选">
|
||||
<a-button
|
||||
size="small"
|
||||
class="lucide-icon-btn"
|
||||
:type="selectionMode ? 'primary' : 'default'"
|
||||
aria-label="多选"
|
||||
@click="toggleSelectionMode"
|
||||
>
|
||||
<ListChecks :size="14" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-button
|
||||
v-if="selectionMode"
|
||||
size="small"
|
||||
danger
|
||||
:disabled="!selectedPaths.length"
|
||||
:loading="deletingPaths.length > 0"
|
||||
@click="$emit('delete-selected')"
|
||||
>
|
||||
删除选中
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-table" role="table" aria-label="工作区文件列表">
|
||||
<div class="file-row table-head" :class="{ 'selection-enabled': selectionMode }" role="row">
|
||||
<span v-if="selectionMode" class="selection-cell">
|
||||
<a-checkbox
|
||||
:checked="allSelected"
|
||||
:indeterminate="partiallySelected"
|
||||
:disabled="!entries.length"
|
||||
aria-label="全选当前目录文件"
|
||||
@change="toggleAllSelection"
|
||||
/>
|
||||
</span>
|
||||
<span>名称</span>
|
||||
<span>大小</span>
|
||||
<span>修改时间</span>
|
||||
<span class="action-head">操作</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in entries"
|
||||
:key="entry.path"
|
||||
class="file-row"
|
||||
:class="{
|
||||
selected: selectedPath === entry.path,
|
||||
deleting: isDeleting(entry.path),
|
||||
'selection-enabled': selectionMode
|
||||
}"
|
||||
role="row"
|
||||
tabindex="0"
|
||||
@click="$emit('select-entry', entry)"
|
||||
@keydown.enter="$emit('select-entry', entry)"
|
||||
>
|
||||
<span v-if="selectionMode" class="selection-cell" @click.stop>
|
||||
<a-checkbox
|
||||
:checked="selectedPathSet.has(entry.path)"
|
||||
:disabled="isDeleting(entry.path)"
|
||||
:aria-label="`选择 ${entry.name}`"
|
||||
@change="(event) => toggleEntrySelection(entry.path, event.target.checked)"
|
||||
/>
|
||||
</span>
|
||||
<span class="name-cell">
|
||||
<Folder v-if="entry.is_dir" :size="17" class="folder-icon" />
|
||||
<component
|
||||
v-else
|
||||
:is="getFileIcon(entry.path)"
|
||||
:style="{ color: getFileIconColor(entry.path), fontSize: '16px' }"
|
||||
/>
|
||||
<span class="entry-name" :title="entry.name">{{ entry.name }}</span>
|
||||
</span>
|
||||
<span>{{ entry.is_dir ? '-' : formatFileSize(entry.size) }}</span>
|
||||
<span>{{ formatRelativeTime(entry.modified_at) }}</span>
|
||||
<span class="action-cell" @click.stop>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<button
|
||||
type="button"
|
||||
class="more-action"
|
||||
:disabled="isDeleting(entry.path)"
|
||||
aria-label="更多操作"
|
||||
@click.stop
|
||||
>
|
||||
<MoreHorizontal :size="16" />
|
||||
</button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
v-if="!entry.is_dir"
|
||||
key="download"
|
||||
@click="$emit('download-entry', entry)"
|
||||
>
|
||||
<span class="menu-item-content">
|
||||
<Download :size="14" />
|
||||
<span>下载</span>
|
||||
</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" danger @click="$emit('delete-entry', entry)">
|
||||
<span class="menu-item-content">
|
||||
<Trash2 :size="14" />
|
||||
<span>删除</span>
|
||||
</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="list-state">
|
||||
<a-spin />
|
||||
<span>正在加载文件...</span>
|
||||
</div>
|
||||
<a-empty v-else-if="!entries.length" class="list-empty" description="当前文件夹为空" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Download, Folder, ListChecks, MoreHorizontal, Trash2 } from 'lucide-vue-next'
|
||||
import {
|
||||
formatFileSize,
|
||||
formatRelativeTime,
|
||||
getFileIcon,
|
||||
getFileIconColor
|
||||
} from '@/utils/file_utils'
|
||||
|
||||
const props = defineProps({
|
||||
entries: { type: Array, default: () => [] },
|
||||
currentPath: { type: String, default: '/' },
|
||||
selectedPath: { type: String, default: '' },
|
||||
selectedPaths: { type: Array, default: () => [] },
|
||||
deletingPaths: { type: Array, default: () => [] },
|
||||
selectionMode: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'select-entry',
|
||||
'select-path',
|
||||
'update:selectedPaths',
|
||||
'update:selectionMode',
|
||||
'delete-selected',
|
||||
'delete-entry',
|
||||
'download-entry'
|
||||
])
|
||||
|
||||
const selectedPathSet = computed(() => new Set(props.selectedPaths))
|
||||
const deletingPathSet = computed(() => new Set(props.deletingPaths))
|
||||
const entryPaths = computed(() => props.entries.map((entry) => entry.path))
|
||||
const normalizedCurrentPath = computed(() => (props.currentPath || '/').replace(/\/+$/, '') || '/')
|
||||
const breadcrumbItems = computed(() => {
|
||||
const normalizedPath = normalizedCurrentPath.value
|
||||
if (normalizedPath === '/') {
|
||||
return [{ name: '工作区', path: '/' }]
|
||||
}
|
||||
|
||||
const segments = normalizedPath.split('/').filter(Boolean)
|
||||
return segments.reduce(
|
||||
(items, segment) => {
|
||||
const parentPath = items[items.length - 1].path
|
||||
const path = parentPath === '/' ? `/${segment}` : `${parentPath}/${segment}`
|
||||
items.push({ name: segment, path })
|
||||
return items
|
||||
},
|
||||
[{ name: '工作区', path: '/' }]
|
||||
)
|
||||
})
|
||||
|
||||
const allSelected = computed(() => {
|
||||
return (
|
||||
entryPaths.value.length > 0 && entryPaths.value.every((path) => selectedPathSet.value.has(path))
|
||||
)
|
||||
})
|
||||
|
||||
const partiallySelected = computed(() => {
|
||||
return !allSelected.value && entryPaths.value.some((path) => selectedPathSet.value.has(path))
|
||||
})
|
||||
|
||||
const isDeleting = (path) => deletingPathSet.value.has(path)
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
const nextMode = !props.selectionMode
|
||||
emit('update:selectionMode', nextMode)
|
||||
if (!nextMode) {
|
||||
emit('update:selectedPaths', [])
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAllSelection = (event) => {
|
||||
emit('update:selectedPaths', event.target.checked ? [...entryPaths.value] : [])
|
||||
}
|
||||
|
||||
const toggleEntrySelection = (path, checked) => {
|
||||
const nextSelectedPaths = new Set(props.selectedPaths)
|
||||
if (checked) {
|
||||
nextSelectedPaths.add(path)
|
||||
} else {
|
||||
nextSelectedPaths.delete(path)
|
||||
}
|
||||
emit(
|
||||
'update:selectedPaths',
|
||||
[...nextSelectedPaths].filter((selectedPath) => entryPaths.value.includes(selectedPath))
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.workspace-file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--gray-0);
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
}
|
||||
|
||||
.path-line,
|
||||
.list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.path-breadcrumb {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-action {
|
||||
max-width: 180px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--main-800);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 400;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--main-600);
|
||||
}
|
||||
|
||||
&.current,
|
||||
&:disabled {
|
||||
color: var(--gray-900);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--gray-500);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-table {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) 76px 118px 34px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--gray-50);
|
||||
background: transparent;
|
||||
color: var(--gray-700);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
&.selection-enabled {
|
||||
grid-template-columns: 34px minmax(150px, 1fr) 76px 118px 34px;
|
||||
}
|
||||
|
||||
&:not(.table-head) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover:not(.table-head),
|
||||
&.selected {
|
||||
background: var(--main-20);
|
||||
color: var(--gray-1000);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
box-shadow: inset 3px 0 0 var(--main-color);
|
||||
}
|
||||
|
||||
&.deleting {
|
||||
opacity: 0.62;
|
||||
}
|
||||
}
|
||||
|
||||
.table-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
min-height: 34px;
|
||||
background: var(--gray-25);
|
||||
color: var(--gray-500);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.selection-cell,
|
||||
.action-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-head,
|
||||
.action-cell {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.more-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--gray-500);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--gray-300);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
color: var(--main-500);
|
||||
fill: var(--main-500);
|
||||
fill-opacity: 0.16;
|
||||
}
|
||||
|
||||
.entry-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 180px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.list-empty {
|
||||
margin-top: 48px;
|
||||
}
|
||||
</style>
|
||||
108
web/src/components/workspace/WorkspacePreviewPane.vue
Normal file
108
web/src/components/workspace/WorkspacePreviewPane.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<aside class="workspace-preview-pane">
|
||||
<AgentFilePreview
|
||||
v-if="file"
|
||||
:file="file"
|
||||
:file-path="filePath"
|
||||
:show-download="false"
|
||||
:show-close="true"
|
||||
close-variant="collapse-right"
|
||||
:show-fullscreen="true"
|
||||
:full-height="true"
|
||||
:editable="editable"
|
||||
:saving="saving"
|
||||
container-class="workspace-preview-container"
|
||||
content-class="workspace-preview-content"
|
||||
@close="$emit('close')"
|
||||
@save="$emit('save', $event)"
|
||||
/>
|
||||
<div v-else-if="loading" class="preview-state">
|
||||
<a-spin />
|
||||
<span>正在加载预览...</span>
|
||||
</div>
|
||||
<div v-else class="preview-empty">
|
||||
<FileSearch :size="28" />
|
||||
<h3>选择文件以预览</h3>
|
||||
<p>支持 Markdown、TXT 编辑,其他格式保持只读预览。</p>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { FileSearch } from 'lucide-vue-next'
|
||||
import AgentFilePreview from '@/components/AgentFilePreview.vue'
|
||||
|
||||
defineProps({
|
||||
file: { type: Object, default: null },
|
||||
filePath: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
editable: { type: Boolean, default: false },
|
||||
saving: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
defineEmits(['close', 'save'])
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.workspace-preview-pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--gray-100);
|
||||
background: var(--gray-0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.workspace-preview-container) {
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:deep(.workspace-preview-content) {
|
||||
flex: 1 1 auto;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.workspace-preview-content .html-preview),
|
||||
:deep(.workspace-preview-content .pdf-preview) {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.preview-state,
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 260px;
|
||||
padding: 24px;
|
||||
color: var(--gray-500);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-state {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
gap: 8px;
|
||||
|
||||
h3 {
|
||||
margin: 6px 0 0;
|
||||
color: var(--gray-800);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 240px;
|
||||
margin: 0;
|
||||
color: var(--gray-500);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
177
web/src/components/workspace/WorkspaceSidebar.vue
Normal file
177
web/src/components/workspace/WorkspaceSidebar.vue
Normal file
@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<aside class="workspace-sidebar">
|
||||
<section class="sidebar-section">
|
||||
<button
|
||||
type="button"
|
||||
class="workspace-nav-item"
|
||||
:class="{ active: activeKey === 'personal' && !isQuickAccessPath(currentPath) }"
|
||||
@click="$emit('select-personal')"
|
||||
>
|
||||
<FolderKanban :size="16" />
|
||||
<span>个人工作区</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-section">
|
||||
<div class="section-title">快速访问</div>
|
||||
<button
|
||||
type="button"
|
||||
class="workspace-nav-item secondary"
|
||||
:class="{
|
||||
active: activeKey === 'personal' && isSameOrChildPath(currentPath, savedArtifactsPath)
|
||||
}"
|
||||
@click="$emit('select-path', savedArtifactsPath)"
|
||||
>
|
||||
<Archive :size="15" />
|
||||
<span>Saved Artifacts</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="workspace-nav-item secondary"
|
||||
:class="{ active: activeKey === 'personal' && isSameOrChildPath(currentPath, agentsPath) }"
|
||||
@click="$emit('select-path', agentsPath)"
|
||||
>
|
||||
<Bot :size="15" />
|
||||
<span>Agents</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-section">
|
||||
<div class="section-title">知识库</div>
|
||||
<button
|
||||
v-for="database in databases"
|
||||
:key="database.db_id || database.id || database.name"
|
||||
type="button"
|
||||
class="workspace-nav-item secondary"
|
||||
:class="{ active: activeKey === `database:${database.db_id}` }"
|
||||
@click="$emit('select-database', database)"
|
||||
>
|
||||
<LibraryBig :size="15" />
|
||||
<span>{{ database.name }}</span>
|
||||
</button>
|
||||
<div v-if="loadingDatabases" class="sidebar-muted">正在加载知识库...</div>
|
||||
<div v-else-if="!databases.length" class="sidebar-muted">暂无可访问知识库</div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-section">
|
||||
<div class="section-title">共享空间</div>
|
||||
<button type="button" class="workspace-nav-item secondary disabled" disabled>
|
||||
<UsersRound :size="15" />
|
||||
<span>团队工作区</span>
|
||||
<span class="soon-tag">即将支持</span>
|
||||
</button>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Archive, Bot, FolderKanban, LibraryBig, UsersRound } from 'lucide-vue-next'
|
||||
|
||||
const savedArtifactsPath = '/saved_artifacts'
|
||||
const agentsPath = '/agents/'
|
||||
const quickAccessPaths = [savedArtifactsPath, agentsPath]
|
||||
|
||||
const normalizePath = (path) => String(path || '/').replace(/\/$/, '') || '/'
|
||||
const isSameOrChildPath = (path, targetPath) => {
|
||||
const current = normalizePath(path)
|
||||
const target = normalizePath(targetPath)
|
||||
return current === target || current.startsWith(`${target}/`)
|
||||
}
|
||||
const isQuickAccessPath = (path) =>
|
||||
quickAccessPaths.some((targetPath) => isSameOrChildPath(path, targetPath))
|
||||
|
||||
defineProps({
|
||||
activeKey: { type: String, default: 'personal' },
|
||||
currentPath: { type: String, default: '/' },
|
||||
databases: { type: Array, default: () => [] },
|
||||
loadingDatabases: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
defineEmits(['select-personal', 'select-database', 'select-path'])
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.workspace-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
padding: 14px 10px;
|
||||
border-right: 1px solid var(--gray-100);
|
||||
background: var(--gray-0);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 0 8px;
|
||||
color: var(--gray-500);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.workspace-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--gray-600);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
|
||||
span:first-of-type {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&.active {
|
||||
border-color: transparent;
|
||||
color: var(--main-600);
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
min-height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--gray-400);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.soon-tag {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: var(--gray-400);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sidebar-muted {
|
||||
padding: 6px 8px;
|
||||
color: var(--gray-500);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@ -8,6 +8,7 @@ import {
|
||||
ClipboardList,
|
||||
Blocks,
|
||||
Box,
|
||||
FolderKanban,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
MessageCirclePlus
|
||||
@ -131,6 +132,13 @@ const mainList = computed(() => {
|
||||
}
|
||||
]
|
||||
|
||||
items.push({
|
||||
name: '工作区',
|
||||
path: '/workspace',
|
||||
icon: FolderKanban,
|
||||
activeIcon: FolderKanban
|
||||
})
|
||||
|
||||
if (userStore.isAdmin) {
|
||||
if (!isLiteMode) {
|
||||
items.push({
|
||||
|
||||
@ -51,6 +51,19 @@ const router = createRouter({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/workspace',
|
||||
name: 'workspace',
|
||||
component: AppLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'WorkspaceComp',
|
||||
component: () => import('../views/WorkspaceView.vue'),
|
||||
meta: { keepAlive: true, requiresAuth: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/graph',
|
||||
name: 'graph',
|
||||
|
||||
@ -43,9 +43,7 @@
|
||||
<div class="new-database-form">
|
||||
<!-- 知识库类型选择 -->
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">
|
||||
知识库类型<span class="required-mark">*</span>
|
||||
</h3>
|
||||
<h3 class="section-title">知识库类型<span class="required-mark">*</span></h3>
|
||||
<div class="kb-type-cards">
|
||||
<div
|
||||
v-for="(typeInfo, typeKey) in orderedKbTypes"
|
||||
@ -156,7 +154,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 隐私设置(暂时隐藏)
|
||||
<!-- 隐私设置(暂时隐藏)
|
||||
<h3 style="margin-top: 20px">隐私设置</h3>
|
||||
<div class="privacy-config">
|
||||
<a-switch
|
||||
@ -250,7 +248,14 @@ import ExtensionCardGrid from '@/components/extensions/ExtensionCardGrid.vue'
|
||||
import InfoCard from '@/components/shared/InfoCard.vue'
|
||||
import dayjs, { parseToShanghai } from '@/utils/time'
|
||||
import AiTextarea from '@/components/AiTextarea.vue'
|
||||
import { getKbTypeLabel, getKbTypeIcon, getKbTypeColor, parseModelSpec, buildDisplaySpec, buildLlmInfoPayload } from '@/utils/kb_utils'
|
||||
import {
|
||||
getKbTypeLabel,
|
||||
getKbTypeIcon,
|
||||
getKbTypeColor,
|
||||
parseModelSpec,
|
||||
buildDisplaySpec,
|
||||
buildLlmInfoPayload
|
||||
} from '@/utils/kb_utils'
|
||||
import { CHUNK_PRESET_OPTIONS, getChunkPresetDescription } from '@/utils/chunk_presets'
|
||||
|
||||
const route = useRoute()
|
||||
@ -458,7 +463,10 @@ const buildRequestData = () => {
|
||||
|
||||
if (newDatabase.kb_type === 'lightrag') {
|
||||
requestData.additional_params.language = newDatabase.language || 'English'
|
||||
if (newDatabase.llm_info.model_spec || (newDatabase.llm_info.provider && newDatabase.llm_info.model_name)) {
|
||||
if (
|
||||
newDatabase.llm_info.model_spec ||
|
||||
(newDatabase.llm_info.provider && newDatabase.llm_info.model_name)
|
||||
) {
|
||||
requestData.llm_info = buildLlmInfoPayload(newDatabase.llm_info)
|
||||
}
|
||||
}
|
||||
|
||||
758
web/src/views/WorkspaceView.vue
Normal file
758
web/src/views/WorkspaceView.vue
Normal file
@ -0,0 +1,758 @@
|
||||
<template>
|
||||
<div class="workspace-view layout-container">
|
||||
<PageHeader title="工作区" :loading="loadingTree || loadingPreview" :show-border="true">
|
||||
<template #actions>
|
||||
<a-button :disabled="activeSourceKey !== 'personal'" @click="openCreateDirectoryModal">
|
||||
新建文件夹
|
||||
</a-button>
|
||||
<a-button
|
||||
:loading="uploadingFile"
|
||||
:disabled="activeSourceKey !== 'personal'"
|
||||
@click="openUploadFilePicker"
|
||||
>
|
||||
上传文件
|
||||
</a-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<input
|
||||
ref="uploadInputRef"
|
||||
class="upload-input"
|
||||
type="file"
|
||||
@change="handleUploadInputChange"
|
||||
/>
|
||||
|
||||
<div class="workspace-shell" :class="{ 'is-sidebar-collapsed': sidebarCollapsed }">
|
||||
<div v-if="!sidebarCollapsed" class="workspace-sidebar-slot">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-collapse-action"
|
||||
aria-label="收起工作区侧边栏"
|
||||
@click="sidebarCollapsed = true"
|
||||
>
|
||||
<ChevronLeft :size="16" />
|
||||
</button>
|
||||
<WorkspaceSidebar
|
||||
:active-key="activeSourceKey"
|
||||
:current-path="currentPath"
|
||||
:databases="databases"
|
||||
:loading-databases="loadingDatabases"
|
||||
@select-personal="selectPersonalWorkspace"
|
||||
@select-database="selectDatabase"
|
||||
@select-path="selectWorkspacePath"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="sidebar-expand-action"
|
||||
aria-label="展开工作区侧边栏"
|
||||
@click="sidebarCollapsed = false"
|
||||
>
|
||||
<ChevronRight :size="16" />
|
||||
</button>
|
||||
|
||||
<main
|
||||
ref="workspaceMainRef"
|
||||
class="workspace-main"
|
||||
:class="{ 'is-inline-preview': showInlinePreview }"
|
||||
:style="workspaceMainStyle"
|
||||
>
|
||||
<template v-if="activeSourceKey === 'personal'">
|
||||
<WorkspaceFileList
|
||||
:entries="filteredEntries"
|
||||
:current-path="currentPath"
|
||||
:selected-path="selectedEntry?.path || ''"
|
||||
:selected-paths="selectedPaths"
|
||||
:deleting-paths="deletingPaths"
|
||||
:selection-mode="selectionMode"
|
||||
:loading="loadingTree"
|
||||
@select-entry="handleSelectEntry"
|
||||
@select-path="selectWorkspacePath"
|
||||
@update:selected-paths="selectedPaths = $event"
|
||||
@update:selection-mode="handleSelectionModeChange"
|
||||
@delete-selected="confirmDeleteEntries(selectedEntries)"
|
||||
@delete-entry="(entry) => confirmDeleteEntries([entry])"
|
||||
@download-entry="downloadEntry"
|
||||
/>
|
||||
<div
|
||||
v-if="showInlinePreview"
|
||||
class="workspace-preview-resizer"
|
||||
role="separator"
|
||||
aria-label="调整预览宽度"
|
||||
tabindex="0"
|
||||
@pointerdown="startPreviewResize"
|
||||
></div>
|
||||
<WorkspacePreviewPane
|
||||
v-if="showInlinePreview"
|
||||
:file="previewFile"
|
||||
:file-path="selectedEntry?.path || ''"
|
||||
:loading="loadingPreview"
|
||||
:editable="true"
|
||||
:saving="savingPreviewFile"
|
||||
@close="closePreview"
|
||||
@save="handleSavePreviewFile"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else class="workspace-placeholder">
|
||||
<LibraryBig :size="32" />
|
||||
<h2>{{ selectedDatabase?.name || '知识库' }}</h2>
|
||||
<p>当前版本仅展示可访问知识库到列表级别,知识库文件浏览后续支持。</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:open="createDirectoryModalVisible"
|
||||
title="新建文件夹"
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
:confirm-loading="creatingDirectory"
|
||||
@ok="createDirectory"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="newDirectoryName"
|
||||
placeholder="请输入文件夹名称"
|
||||
:disabled="creatingDirectory"
|
||||
@keyup.enter="createDirectory"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
:open="previewModalVisible && !useInlinePreview"
|
||||
width="880px"
|
||||
:style="{ maxWidth: '92vw', top: '5vh' }"
|
||||
:bodyStyle="{ maxHeight: '90vh', overflow: 'auto' }"
|
||||
:footer="null"
|
||||
:closable="false"
|
||||
wrapClassName="workspace-file-preview-modal"
|
||||
@cancel="closePreview"
|
||||
>
|
||||
<AgentFilePreview
|
||||
:file="previewFile"
|
||||
:filePath="selectedEntry?.path || ''"
|
||||
:showClose="true"
|
||||
:showDownload="false"
|
||||
:showFullscreen="true"
|
||||
:editable="activeSourceKey === 'personal'"
|
||||
:saving="savingPreviewFile"
|
||||
@close="closePreview"
|
||||
@save="handleSavePreviewFile"
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { ChevronLeft, ChevronRight, LibraryBig } from 'lucide-vue-next'
|
||||
import PageHeader from '@/components/shared/PageHeader.vue'
|
||||
import AgentFilePreview from '@/components/AgentFilePreview.vue'
|
||||
import WorkspaceFileList from '@/components/workspace/WorkspaceFileList.vue'
|
||||
import WorkspacePreviewPane from '@/components/workspace/WorkspacePreviewPane.vue'
|
||||
import WorkspaceSidebar from '@/components/workspace/WorkspaceSidebar.vue'
|
||||
import { databaseApi } from '@/apis/knowledge_api'
|
||||
import {
|
||||
createWorkspaceDirectory,
|
||||
deleteWorkspacePath,
|
||||
downloadWorkspaceFile,
|
||||
getWorkspaceFileContent,
|
||||
getWorkspaceTree,
|
||||
saveWorkspaceFileContent,
|
||||
uploadWorkspaceFile
|
||||
} from '@/apis/workspace_api'
|
||||
|
||||
const activeSourceKey = ref('personal')
|
||||
const currentPath = ref('/')
|
||||
const entries = ref([])
|
||||
const selectedEntry = ref(null)
|
||||
const selectedPaths = ref([])
|
||||
const selectionMode = ref(false)
|
||||
const previewFile = ref(null)
|
||||
const previewObjectUrl = ref('')
|
||||
const previewModalVisible = ref(false)
|
||||
const loadingTree = ref(false)
|
||||
const loadingPreview = ref(false)
|
||||
const savingPreviewFile = ref(false)
|
||||
const loadingDatabases = ref(false)
|
||||
const databases = ref([])
|
||||
const selectedDatabase = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const workspaceMainRef = ref(null)
|
||||
const workspaceMainWidth = ref(0)
|
||||
const createDirectoryModalVisible = ref(false)
|
||||
const newDirectoryName = ref('')
|
||||
const creatingDirectory = ref(false)
|
||||
const uploadingFile = ref(false)
|
||||
const uploadInputRef = ref(null)
|
||||
const deletingPaths = ref([])
|
||||
const sidebarCollapsed = ref(false)
|
||||
const previewWidthPercent = ref(50)
|
||||
const previewRequestId = ref(0)
|
||||
const INLINE_PREVIEW_MIN_WIDTH = 960
|
||||
|
||||
const useInlinePreview = computed(() => workspaceMainWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
|
||||
const showInlinePreview = computed(() => useInlinePreview.value && Boolean(previewFile.value))
|
||||
const workspaceMainStyle = computed(() => {
|
||||
if (!showInlinePreview.value) return {}
|
||||
const listWidthPercent = 100 - previewWidthPercent.value
|
||||
return {
|
||||
gridTemplateColumns: `minmax(0, ${listWidthPercent}%) 6px minmax(280px, ${previewWidthPercent.value}%)`
|
||||
}
|
||||
})
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
const keyword = searchQuery.value.trim().toLowerCase()
|
||||
if (!keyword) return entries.value
|
||||
return entries.value.filter((entry) =>
|
||||
String(entry.name || '')
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
const selectedEntries = computed(() => {
|
||||
const selectedPathSet = new Set(selectedPaths.value)
|
||||
return entries.value.filter((entry) => selectedPathSet.has(entry.path))
|
||||
})
|
||||
|
||||
const revokePreviewObjectUrl = () => {
|
||||
if (!previewObjectUrl.value) return
|
||||
URL.revokeObjectURL(previewObjectUrl.value)
|
||||
previewObjectUrl.value = ''
|
||||
}
|
||||
|
||||
const normalizePreviewFile = async (entry, response) => {
|
||||
const previewType = response.preview_type || response.previewType || 'text'
|
||||
const file = {
|
||||
...response,
|
||||
previewType,
|
||||
supported: response.supported !== false
|
||||
}
|
||||
|
||||
if (previewType === 'image' || previewType === 'pdf') {
|
||||
const downloadResponse = await downloadWorkspaceFile(entry.path)
|
||||
const blob = await downloadResponse.blob()
|
||||
revokePreviewObjectUrl()
|
||||
previewObjectUrl.value = URL.createObjectURL(blob)
|
||||
file.previewUrl = previewObjectUrl.value
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
const syncSelectedPaths = () => {
|
||||
const entryPathSet = new Set(entries.value.map((entry) => entry.path))
|
||||
selectedPaths.value = selectedPaths.value.filter((path) => entryPathSet.has(path))
|
||||
}
|
||||
|
||||
const clearWorkspaceSelection = () => {
|
||||
selectedPaths.value = []
|
||||
}
|
||||
|
||||
const handleSelectionModeChange = (enabled) => {
|
||||
selectionMode.value = enabled
|
||||
if (!enabled) {
|
||||
clearWorkspaceSelection()
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkspaceEntries = async (path = '/') => {
|
||||
loadingTree.value = true
|
||||
try {
|
||||
const response = await getWorkspaceTree(path)
|
||||
entries.value = response.entries || []
|
||||
currentPath.value = path
|
||||
syncSelectedPaths()
|
||||
if (!selectedPaths.value.length) {
|
||||
selectionMode.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('加载工作区目录失败:', error)
|
||||
message.error('加载工作区目录失败')
|
||||
} finally {
|
||||
loadingTree.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadDatabases = async () => {
|
||||
loadingDatabases.value = true
|
||||
try {
|
||||
const response = await databaseApi.getAccessibleDatabases()
|
||||
databases.value = response?.databases || []
|
||||
} catch (error) {
|
||||
console.warn('加载可访问知识库失败:', error)
|
||||
databases.value = []
|
||||
} finally {
|
||||
loadingDatabases.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectPersonalWorkspace = async () => {
|
||||
activeSourceKey.value = 'personal'
|
||||
selectedDatabase.value = null
|
||||
closePreview()
|
||||
clearWorkspaceSelection()
|
||||
if (currentPath.value !== '/' || !entries.value.length) {
|
||||
await loadWorkspaceEntries('/')
|
||||
}
|
||||
}
|
||||
|
||||
const selectWorkspacePath = async (path) => {
|
||||
activeSourceKey.value = 'personal'
|
||||
selectedDatabase.value = null
|
||||
closePreview()
|
||||
clearWorkspaceSelection()
|
||||
await loadWorkspaceEntries(path)
|
||||
}
|
||||
|
||||
const selectDatabase = (database) => {
|
||||
closePreview()
|
||||
clearWorkspaceSelection()
|
||||
selectedDatabase.value = database
|
||||
activeSourceKey.value = `database:${database.db_id}`
|
||||
}
|
||||
|
||||
const handleSelectEntry = async (entry) => {
|
||||
if (entry.is_dir) {
|
||||
closePreview()
|
||||
clearWorkspaceSelection()
|
||||
await loadWorkspaceEntries(entry.path)
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = previewRequestId.value + 1
|
||||
previewRequestId.value = requestId
|
||||
selectedEntry.value = entry
|
||||
revokePreviewObjectUrl()
|
||||
previewFile.value = {
|
||||
...entry,
|
||||
content: 'Loading...',
|
||||
supported: true,
|
||||
previewType: 'text',
|
||||
message: '',
|
||||
previewUrl: ''
|
||||
}
|
||||
previewModalVisible.value = !useInlinePreview.value
|
||||
loadingPreview.value = true
|
||||
try {
|
||||
const response = await getWorkspaceFileContent(entry.path)
|
||||
if (previewRequestId.value !== requestId || selectedEntry.value?.path !== entry.path) return
|
||||
previewFile.value = await normalizePreviewFile(entry, response)
|
||||
} catch (error) {
|
||||
if (previewRequestId.value !== requestId || selectedEntry.value?.path !== entry.path) return
|
||||
console.warn('加载文件预览失败:', error)
|
||||
previewFile.value = {
|
||||
...entry,
|
||||
content: `Error loading file: ${error?.message || 'unknown error'}`,
|
||||
supported: false,
|
||||
previewType: 'unsupported',
|
||||
message: error?.message || '文件预览失败',
|
||||
previewUrl: ''
|
||||
}
|
||||
message.error('加载文件预览失败')
|
||||
} finally {
|
||||
if (previewRequestId.value === requestId) {
|
||||
loadingPreview.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewRequestId.value += 1
|
||||
previewModalVisible.value = false
|
||||
selectedEntry.value = null
|
||||
previewFile.value = null
|
||||
loadingPreview.value = false
|
||||
revokePreviewObjectUrl()
|
||||
}
|
||||
|
||||
const handleSavePreviewFile = async (content) => {
|
||||
if (!selectedEntry.value?.path || savingPreviewFile.value) return
|
||||
|
||||
savingPreviewFile.value = true
|
||||
try {
|
||||
const response = await saveWorkspaceFileContent(selectedEntry.value.path, content)
|
||||
if (response.entry) {
|
||||
selectedEntry.value = response.entry
|
||||
}
|
||||
previewFile.value = {
|
||||
...previewFile.value,
|
||||
content
|
||||
}
|
||||
await loadWorkspaceEntries(currentPath.value)
|
||||
message.success('文件保存成功')
|
||||
} catch (error) {
|
||||
console.warn('保存工作区文件失败:', error)
|
||||
message.error(error?.message || '文件保存失败')
|
||||
} finally {
|
||||
savingPreviewFile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateDirectoryModal = () => {
|
||||
if (activeSourceKey.value !== 'personal') return
|
||||
newDirectoryName.value = ''
|
||||
createDirectoryModalVisible.value = true
|
||||
}
|
||||
|
||||
const createDirectory = async () => {
|
||||
if (creatingDirectory.value) return
|
||||
const directoryName = newDirectoryName.value.trim()
|
||||
if (!directoryName) {
|
||||
message.warning('请输入文件夹名')
|
||||
return
|
||||
}
|
||||
|
||||
creatingDirectory.value = true
|
||||
try {
|
||||
await createWorkspaceDirectory(currentPath.value, directoryName)
|
||||
await loadWorkspaceEntries(currentPath.value)
|
||||
createDirectoryModalVisible.value = false
|
||||
newDirectoryName.value = ''
|
||||
message.success('文件夹创建成功')
|
||||
} catch (error) {
|
||||
console.warn('创建文件夹失败:', error)
|
||||
message.error(error?.message || '创建文件夹失败')
|
||||
} finally {
|
||||
creatingDirectory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openUploadFilePicker = () => {
|
||||
if (activeSourceKey.value !== 'personal' || uploadingFile.value) return
|
||||
if (uploadInputRef.value) {
|
||||
uploadInputRef.value.value = ''
|
||||
uploadInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadInputChange = async (event) => {
|
||||
const file = event.target?.files?.[0]
|
||||
if (!file || uploadingFile.value) return
|
||||
|
||||
uploadingFile.value = true
|
||||
try {
|
||||
await uploadWorkspaceFile(currentPath.value, file)
|
||||
await loadWorkspaceEntries(currentPath.value)
|
||||
message.success('文件上传成功')
|
||||
} catch (error) {
|
||||
console.warn('上传文件失败:', error)
|
||||
message.error(error?.message || '上传文件失败')
|
||||
} finally {
|
||||
uploadingFile.value = false
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const comparablePath = (path) => String(path || '/').replace(/\/$/, '') || '/'
|
||||
|
||||
const isSameOrChildPath = (path, targetPath) => {
|
||||
const normalizedPath = comparablePath(path)
|
||||
const normalizedTargetPath = comparablePath(targetPath)
|
||||
return (
|
||||
normalizedPath === normalizedTargetPath || normalizedPath.startsWith(`${normalizedTargetPath}/`)
|
||||
)
|
||||
}
|
||||
|
||||
const confirmDeleteEntries = (targetEntries) => {
|
||||
const validEntries = (targetEntries || []).filter(Boolean)
|
||||
if (!validEntries.length) return
|
||||
|
||||
const isBatch = validEntries.length > 1
|
||||
const firstEntry = validEntries[0]
|
||||
Modal.confirm({
|
||||
title: isBatch
|
||||
? `确认删除选中的 ${validEntries.length} 项?`
|
||||
: firstEntry.is_dir
|
||||
? `确认删除文件夹「${firstEntry.name}」?`
|
||||
: `确认删除文件「${firstEntry.name}」?`,
|
||||
content:
|
||||
isBatch || firstEntry.is_dir
|
||||
? '将删除文件夹及其所有内容,删除后不可恢复。'
|
||||
: '删除后不可恢复。',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => deleteEntries(validEntries)
|
||||
})
|
||||
}
|
||||
|
||||
const deleteEntries = async (targetEntries) => {
|
||||
const paths = targetEntries.map((entry) => entry.path)
|
||||
deletingPaths.value = paths
|
||||
try {
|
||||
await Promise.all(paths.map((path) => deleteWorkspacePath(path)))
|
||||
if (
|
||||
selectedEntry.value &&
|
||||
paths.some((path) => isSameOrChildPath(selectedEntry.value.path, path))
|
||||
) {
|
||||
closePreview()
|
||||
}
|
||||
clearWorkspaceSelection()
|
||||
await loadWorkspaceEntries(currentPath.value)
|
||||
message.success(paths.length > 1 ? '选中项删除成功' : '删除成功')
|
||||
} catch (error) {
|
||||
console.warn('删除工作区文件失败:', error)
|
||||
message.error(error?.message || '删除失败')
|
||||
await loadWorkspaceEntries(currentPath.value)
|
||||
} finally {
|
||||
deletingPaths.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const parseDownloadFilename = (contentDisposition) => {
|
||||
if (!contentDisposition) return ''
|
||||
|
||||
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
if (utf8Match && utf8Match[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1])
|
||||
} catch (error) {
|
||||
console.warn('解析 UTF-8 文件名失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const asciiMatch = contentDisposition.match(/filename="?([^";]+)"?/i)
|
||||
if (asciiMatch && asciiMatch[1]) {
|
||||
return asciiMatch[1]
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const downloadEntry = async (entry) => {
|
||||
if (!entry?.path || entry.is_dir) return
|
||||
|
||||
try {
|
||||
const response = await downloadWorkspaceFile(entry.path)
|
||||
const blob = await response.blob()
|
||||
const contentDisposition =
|
||||
response.headers.get('Content-Disposition') || response.headers.get('content-disposition')
|
||||
const filename = parseDownloadFilename(contentDisposition) || entry.name || 'download'
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.warn('下载文件失败:', error)
|
||||
message.error(error?.message || '下载文件失败')
|
||||
}
|
||||
}
|
||||
|
||||
let resizePointerId = null
|
||||
|
||||
const stopPreviewResize = () => {
|
||||
resizePointerId = null
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
window.removeEventListener('pointermove', resizePreview)
|
||||
window.removeEventListener('pointerup', stopPreviewResize)
|
||||
window.removeEventListener('pointercancel', stopPreviewResize)
|
||||
}
|
||||
|
||||
const resizePreview = (event) => {
|
||||
if (!workspaceMainRef.value || resizePointerId === null || event.pointerId !== resizePointerId)
|
||||
return
|
||||
const rect = workspaceMainRef.value.getBoundingClientRect()
|
||||
const relativeX = event.clientX - rect.left
|
||||
const nextPreviewPercent = Math.round(((rect.width - relativeX) / rect.width) * 100)
|
||||
previewWidthPercent.value = Math.min(70, Math.max(30, nextPreviewPercent))
|
||||
}
|
||||
|
||||
const startPreviewResize = (event) => {
|
||||
if (!showInlinePreview.value) return
|
||||
resizePointerId = event.pointerId
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
window.addEventListener('pointermove', resizePreview)
|
||||
window.addEventListener('pointerup', stopPreviewResize)
|
||||
window.addEventListener('pointercancel', stopPreviewResize)
|
||||
}
|
||||
|
||||
let workspaceResizeObserver = null
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadWorkspaceEntries('/'), loadDatabases()])
|
||||
|
||||
if (workspaceMainRef.value && typeof ResizeObserver !== 'undefined') {
|
||||
workspaceMainWidth.value = workspaceMainRef.value.clientWidth || 0
|
||||
workspaceResizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
workspaceMainWidth.value = entry.contentRect.width
|
||||
})
|
||||
workspaceResizeObserver.observe(workspaceMainRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
workspaceResizeObserver?.disconnect()
|
||||
workspaceResizeObserver = null
|
||||
stopPreviewResize()
|
||||
revokePreviewObjectUrl()
|
||||
})
|
||||
|
||||
watch(useInlinePreview, (isInline, wasInline) => {
|
||||
if (!previewFile.value) {
|
||||
previewModalVisible.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (isInline) {
|
||||
previewModalVisible.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (wasInline) {
|
||||
closePreview()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.workspace-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.upload-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 195px minmax(0, 1fr);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
background: var(--gray-0);
|
||||
overflow: hidden;
|
||||
|
||||
&.is-sidebar-collapsed {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-sidebar-slot {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace-sidebar-slot :deep(.workspace-sidebar) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-collapse-action,
|
||||
.sidebar-expand-action {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 4;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--gray-150);
|
||||
background: var(--gray-0);
|
||||
color: var(--gray-600);
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
|
||||
|
||||
&:hover {
|
||||
background: var(--main-20);
|
||||
color: var(--main-color);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-collapse-action {
|
||||
right: -13px;
|
||||
width: 26px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sidebar-expand-action {
|
||||
left: 0;
|
||||
width: 22px;
|
||||
border-left: 0;
|
||||
border-radius: 0 12px 12px 0;
|
||||
}
|
||||
|
||||
.workspace-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace-preview-resizer {
|
||||
width: 6px;
|
||||
min-width: 6px;
|
||||
border-left: 1px solid var(--gray-100);
|
||||
border-right: 1px solid var(--gray-100);
|
||||
background: var(--gray-25);
|
||||
cursor: col-resize;
|
||||
|
||||
&:hover {
|
||||
background: var(--main-20);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-placeholder {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 360px;
|
||||
padding: 32px;
|
||||
color: var(--gray-500);
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
margin: 8px 0 0;
|
||||
color: var(--gray-900);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 360px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.workspace-file-preview-modal {
|
||||
.ant-modal {
|
||||
z-index: 1050;
|
||||
|
||||
.ant-modal-content {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user