feat(paths): 引入虚拟路径常量,重构路径管理逻辑
This commit is contained in:
parent
3ec8305eab
commit
da6b9691af
@ -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("/")
|
||||
|
||||
@ -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 来记录规划和待办事项,确保任务的每个步骤都被记录和跟踪。
|
||||
"""
|
||||
|
||||
@ -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}:用于存放用户上传的文件
|
||||
"""
|
||||
|
||||
@ -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. 可以一次传多个文件
|
||||
"""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
25
backend/package/yuxi/utils/paths.py
Normal file
25
backend/package/yuxi/utils/paths.py
Normal 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",
|
||||
]
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user