feat(paths): 引入虚拟路径常量,重构路径管理逻辑

This commit is contained in:
Wenjie Zhang 2026-04-01 01:58:57 +08:00
parent 3ec8305eab
commit da6b9691af
8 changed files with 83 additions and 133 deletions

View File

@ -4,21 +4,19 @@ import re
from pathlib import Path
from yuxi import config as conf
from yuxi.utils.paths import (
VIRTUAL_PATH_PREFIX,
WORKSPACE_DIR_NAME,
OUTPUTS_DIR_NAME,
UPLOADS_DIR_NAME
)
DEFAULT_VIRTUAL_PATH_PREFIX = "/home/gem/user-data"
VIRTUAL_PATH_PREFIX = DEFAULT_VIRTUAL_PATH_PREFIX
_WORKSPACE_DIR_NAME = "workspace"
_UPLOADS_DIR_NAME = "uploads"
_OUTPUTS_DIR_NAME = "outputs"
_SAFE_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def get_virtual_path_prefix() -> str:
configured = str(getattr(conf, "sandbox_virtual_path_prefix", "") or "").strip()
if not configured:
return DEFAULT_VIRTUAL_PATH_PREFIX
return "/" + configured.strip("/")
return "/" + VIRTUAL_PATH_PREFIX.strip("/")
def _validate_thread_id(thread_id: str) -> str:
@ -46,15 +44,15 @@ def sandbox_user_data_dir(thread_id: str) -> Path:
def sandbox_workspace_dir(thread_id: str) -> Path:
_validate_thread_id(thread_id)
return _global_user_data_dir() / _WORKSPACE_DIR_NAME
return _global_user_data_dir() / WORKSPACE_DIR_NAME
def sandbox_uploads_dir(thread_id: str) -> Path:
return _thread_root_dir(thread_id) / _UPLOADS_DIR_NAME
return _thread_root_dir(thread_id) / UPLOADS_DIR_NAME
def sandbox_outputs_dir(thread_id: str) -> Path:
return _thread_root_dir(thread_id) / _OUTPUTS_DIR_NAME
return _thread_root_dir(thread_id) / OUTPUTS_DIR_NAME
def ensure_thread_dirs(thread_id: str) -> None:
@ -72,16 +70,16 @@ def _resolve_user_data_base_dir(thread_id: str, relative_path: str) -> tuple[Pat
return base_dir.resolve(), base_dir.resolve()
namespace = parts[0]
if namespace == _WORKSPACE_DIR_NAME:
if namespace == WORKSPACE_DIR_NAME:
# Workspace is shared across threads, so it lives outside the per-thread root.
base_dir = sandbox_workspace_dir(thread_id)
target_path = base_dir.joinpath(*parts[1:]) if len(parts) > 1 else base_dir
return base_dir.resolve(), target_path.resolve()
if namespace == _UPLOADS_DIR_NAME:
if namespace == UPLOADS_DIR_NAME:
base_dir = sandbox_uploads_dir(thread_id)
target_path = base_dir.joinpath(*parts[1:]) if len(parts) > 1 else base_dir
return base_dir.resolve(), target_path.resolve()
if namespace == _OUTPUTS_DIR_NAME:
if namespace == OUTPUTS_DIR_NAME:
base_dir = sandbox_outputs_dir(thread_id)
target_path = base_dir.joinpath(*parts[1:]) if len(parts) > 1 else base_dir
return base_dir.resolve(), target_path.resolve()
@ -124,7 +122,7 @@ def virtual_path_for_thread_file(thread_id: str, path: str | Path) -> str:
else:
workspace_relative = relative_path.as_posix()
relative_path_str = (
_WORKSPACE_DIR_NAME if workspace_relative in {"", "."} else f"{_WORKSPACE_DIR_NAME}/{workspace_relative}"
WORKSPACE_DIR_NAME if workspace_relative in {"", "."} else f"{WORKSPACE_DIR_NAME}/{workspace_relative}"
)
prefix = get_virtual_path_prefix().rstrip("/")

View File

@ -1,19 +1,28 @@
PROMPT = """
from yuxi.utils.paths import (
VIRTUAL_PATH_PREFIX,
VIRTUAL_PATH_WORKSPACE,
VIRTUAL_PATH_OUTPUTS,
VIRTUAL_PATH_UPLOADS,
VIRTUAL_KBS_PATH
)
PROMPT = f"""
你是一个人工智能助手 语析专门用来回答用户的问题请根据用户提供的信息尽可能详细地回答问题
如果你不确定答案可以说你不知道但请尽量提供相关的信息或建议请保持礼貌和专业
系统主要工作路径为 /home/gem/user-data/但必须遵守规范
- /home/gem/user-data/workspace/用于存放工作文件用户目录不要轻易写入
- /home/gem/user-data/outputs/用于写入的文件夹
- /home/gem/user-data/outputs/tmp/用于存放中间结果或备份内容
- /home/gem/user-data/uploads/用于存放用户上传的文件
系统主要工作路径为 {VIRTUAL_PATH_PREFIX}但必须遵守规范
- {VIRTUAL_PATH_WORKSPACE}用于存放工作文件用户目录不要轻易写入
- {VIRTUAL_PATH_OUTPUTS}用于写入的文件夹
- {VIRTUAL_PATH_OUTPUTS}/tmp/用于存放中间结果或备份内容
- {VIRTUAL_PATH_UPLOADS}用于存放用户上传的文件
非必要不写入其他路径
如果启用了知识库除了使用知识库工具之外
当需要精准获取信息的时候或者 query_kb 中没有找到相关的内容还可以直接访问知识库文件系统
路径为 /home/gem/kbs/来获取信息
源文件可能无法解析可以在 /home/gem/kbs/<db_name>/parsed/ 中找到解析后的 markdown 文件
路径为 {VIRTUAL_KBS_PATH}来获取信息
源文件可能无法解析可以在 {VIRTUAL_KBS_PATH}/<db_name>/parsed/ 中找到解析后的 markdown 文件
你需要根据任务的复杂程度来使用 write_todos 来记录规划和待办事项确保任务的每个步骤都被记录和跟踪
"""

View File

@ -1,4 +1,12 @@
DEEP_PROMPT = """你是一位专家级研究员。你的工作是进行彻底的研究,然后撰写一份精美的报告。
from yuxi.utils.paths import (
VIRTUAL_PATH_PREFIX,
VIRTUAL_PATH_WORKSPACE,
VIRTUAL_PATH_OUTPUTS,
VIRTUAL_PATH_UPLOADS,
)
DEEP_PROMPT = f"""你是一位专家级研究员。你的工作是进行彻底的研究,然后撰写一份精美的报告。
你应该做的第一件事是把原始的用户问题写入 `question.txt`以便你有一个记录
@ -78,9 +86,9 @@ DEEP_PROMPT = """你是一位专家级研究员。你的工作是进行彻底的
你可以使用一些工具
允许的写入路径为 /home/gem/user-data/请将需要保存的文件写入该目录下
推荐使用的路径
- /home/gem/user-data/workspace/用于存放工作文件和中间结果
- /home/gem/user-data/outputs/用于存放最终输出结果
- /home/gem/user-data/uploads/用于存放用户上传的文件
系统主要工作路径为 {VIRTUAL_PATH_PREFIX}但必须遵守规范
- {VIRTUAL_PATH_WORKSPACE}用于存放工作文件用户目录不要轻易写入
- {VIRTUAL_PATH_OUTPUTS}用于写入的文件夹
- {VIRTUAL_PATH_OUTPUTS}/tmp/用于存放中间结果或备份内容
- {VIRTUAL_PATH_UPLOADS}用于存放用户上传的文件
"""

View File

@ -16,6 +16,7 @@ from yuxi.agents.toolkits.registry import ToolExtraMetadata, _all_tool_instances
from yuxi.storage.minio import aupload_file_to_minio
from yuxi.utils import logger
from yuxi.utils.question_utils import normalize_questions
from yuxi.utils.paths import VIRTUAL_PATH_OUTPUTS
# Lazy initialization for TavilySearch (only when API key is available)
_tavily_search_instance = None
@ -67,7 +68,7 @@ class PresentArtifactsInput(BaseModel):
"""Expose artifact files to the frontend after the agent finishes."""
filepaths: list[str] = Field(
description="需要展示给用户的文件绝对路径列表,只允许位于 /home/gem/user-data/outputs/"
description=f"需要展示给用户的文件绝对路径列表,只允许位于 {VIRTUAL_PATH_OUTPUTS}"
)
@ -130,16 +131,16 @@ def calculator(a: float, b: float, operation: str) -> float:
raise
PRESENT_ARTIFACTS_DESCRIPTION = """
PRESENT_ARTIFACTS_DESCRIPTION = f"""
将已经生成好的结果文件展示给用户
使用场景
1. 你已经在 `/home/gem/user-data/outputs/` 下写好了最终结果文件
1. 你已经在 `{VIRTUAL_PATH_OUTPUTS}` 下写好了最终结果文件
2. 你希望前端在对话结束后显示这些结果文件卡片
3. 这些文件需要支持下载或预览
注意事项
1. 只能传入 `/home/gem/user-data/outputs/` 下的文件
1. 只能传入 `{VIRTUAL_PATH_OUTPUTS}` 下的文件
2. 不要传入中间过程文件只有真正需要给用户看的结果文件才调用
3. 可以一次传多个文件
"""

View File

@ -13,12 +13,14 @@ 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.doc_converter import ATTACHMENT_ALLOWED_EXTENSIONS, MAX_ATTACHMENT_SIZE_BYTES
from yuxi.utils.datetime_utils import utc_isoformat
from yuxi.utils.logging_config import logger
from yuxi.utils.paths import VIRTUAL_PATH_UPLOADS
UPLOADS_VIRTUAL_PREFIX = "/home/gem/user-data/uploads"
ATTACHMENT_ALLOWED_EXTENSIONS: tuple[str, ...] = ()
MAX_ATTACHMENT_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB
MAX_ATTACHMENT_MARKDOWN_CHARS = 32_000
MAX_ATTACHMENT_MARKDOWN_CHARS = 32_000 # TODO: 转 MARKDOWN的时候不应该裁剪
@dataclass(slots=True)
@ -107,7 +109,7 @@ async def require_user_conversation(conv_repo: ConversationRepository, thread_id
def _make_upload_virtual_path(file_name: str) -> str:
safe_name = file_name.replace("/", "_").replace("\\", "_").strip(" .")
return f"{UPLOADS_VIRTUAL_PREFIX}/{safe_name or 'attachment.bin'}"
return f"{VIRTUAL_PATH_UPLOADS}/{safe_name or 'attachment.bin'}"
def _make_attachment_path(file_name: str) -> str:
@ -127,7 +129,7 @@ def _make_attachment_path(file_name: str) -> str:
def _build_attachment_storage_path(*, user_id: str, thread_id: str, file_name: str) -> tuple[str, Path]:
"""返回附件虚拟路径和宿主机落盘路径。"""
relative_name = _make_attachment_path(file_name)
virtual_path = f"/home/gem/user-data/uploads/attachments/{relative_name}"
virtual_path = f"{VIRTUAL_PATH_UPLOADS}/attachments/{relative_name}"
host_dir = Path(app_config.save_dir) / "threads" / thread_id / "user-data" / "uploads" / "attachments"
host_dir.mkdir(parents=True, exist_ok=True)

View File

@ -1,94 +0,0 @@
"""Helpers for converting uploaded documents into markdown snippets."""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from pathlib import Path
import aiofiles
from fastapi import UploadFile
from yuxi.config import config as app_config
from yuxi.plugins.parser import Parser
from yuxi.utils import logger
ATTACHMENT_ALLOWED_EXTENSIONS: tuple[str, ...] = ()
MAX_ATTACHMENT_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB
MAX_ATTACHMENT_MARKDOWN_CHARS = 32_000
@dataclass(slots=True)
class ConversionResult:
"""Represents the normalized output of an uploaded attachment."""
file_id: str
file_name: str
file_type: str | None
file_size: int
markdown: str
truncated: bool
def _ensure_workdir() -> Path:
workdir = Path(app_config.save_dir) / "uploads" / "chat_attachments"
workdir.mkdir(parents=True, exist_ok=True)
return workdir
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
def _truncate_markdown(markdown: str) -> tuple[str, bool]:
if len(markdown) <= MAX_ATTACHMENT_MARKDOWN_CHARS:
return markdown, False
truncated_content = markdown[: MAX_ATTACHMENT_MARKDOWN_CHARS - 100].rstrip()
truncated_content = f"{truncated_content}\n\n[内容已截断,超出 {MAX_ATTACHMENT_MARKDOWN_CHARS} 字符限制]"
return truncated_content, True
async def convert_upload_to_markdown(upload: UploadFile) -> ConversionResult:
"""Persist an UploadFile temporarily, convert it to markdown, and clean up."""
if not upload.filename:
raise ValueError("无法识别的文件名")
file_name = Path(upload.filename).name
suffix = Path(file_name).suffix.lower()
if ATTACHMENT_ALLOWED_EXTENSIONS and suffix not in ATTACHMENT_ALLOWED_EXTENSIONS:
allowed = ", ".join(ATTACHMENT_ALLOWED_EXTENSIONS)
raise ValueError(f"不支持的文件类型: {suffix or '未知'},当前仅支持 {allowed}")
temp_dir = _ensure_workdir()
temp_path = temp_dir / f"{uuid.uuid4().hex}{suffix}"
try:
file_size = await _write_upload_to_disk(upload, temp_path)
markdown = await Parser.aparse(str(temp_path))
markdown, truncated = _truncate_markdown(markdown)
return ConversionResult(
file_id=uuid.uuid4().hex,
file_name=file_name,
file_type=upload.content_type,
file_size=file_size,
markdown=markdown,
truncated=truncated,
)
except Exception as exc: # noqa: BLE001
logger.error("Attachment conversion failed: %s", exc)
raise

View File

@ -0,0 +1,25 @@
from pathlib import Path
from yuxi import config
VIRTUAL_PATH_PREFIX = config.sandbox_virtual_path_prefix
WORKSPACE_DIR_NAME = "workspace"
UPLOADS_DIR_NAME = "uploads"
OUTPUTS_DIR_NAME = "outputs"
VIRTUAL_SKILLS_PATH = "/home/gem/skills"
VIRTUAL_KBS_PATH = "/home/gem/kbs"
VIRTUAL_PATH_WORKSPACE = (Path(VIRTUAL_PATH_PREFIX) / WORKSPACE_DIR_NAME).as_posix()
VIRTUAL_PATH_UPLOADS = (Path(VIRTUAL_PATH_PREFIX) / UPLOADS_DIR_NAME).as_posix()
VIRTUAL_PATH_OUTPUTS = (Path(VIRTUAL_PATH_PREFIX) / OUTPUTS_DIR_NAME).as_posix()
__all__ = [
"VIRTUAL_PATH_PREFIX",
"WORKSPACE_DIR_NAME",
"UPLOADS_DIR_NAME",
"OUTPUTS_DIR_NAME",
"VIRTUAL_PATH_WORKSPACE",
"VIRTUAL_PATH_UPLOADS",
"VIRTUAL_PATH_OUTPUTS",
"VIRTUAL_SKILLS_PATH",
"VIRTUAL_KBS_PATH",
]

View File

@ -43,6 +43,7 @@ from yuxi.services.feedback_service import get_message_feedback_view, submit_mes
from yuxi.repositories.agent_config_repository import AgentConfigRepository
from yuxi.utils.logging_config import logger
from yuxi.utils.image_processor import process_uploaded_image
from yuxi.utils.paths import VIRTUAL_PATH_PREFIX
# TODO当前文件的功能过于庞杂路由标签混乱
@ -817,7 +818,7 @@ async def delete_thread_attachment(
@chat.get("/thread/{thread_id}/files", response_model=ThreadFileListResponse)
async def list_thread_files(
thread_id: str,
path: str = Query("/home/gem/user-data"),
path: str = Query(f"{VIRTUAL_PATH_PREFIX}"),
recursive: bool = Query(False),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),