diff --git a/backend/package/yuxi/agents/backends/sandbox/paths.py b/backend/package/yuxi/agents/backends/sandbox/paths.py index 04976085..e949414f 100644 --- a/backend/package/yuxi/agents/backends/sandbox/paths.py +++ b/backend/package/yuxi/agents/backends/sandbox/paths.py @@ -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("/") diff --git a/backend/package/yuxi/agents/buildin/chatbot/prompt.py b/backend/package/yuxi/agents/buildin/chatbot/prompt.py index 367fd924..591b7bdc 100644 --- a/backend/package/yuxi/agents/buildin/chatbot/prompt.py +++ b/backend/package/yuxi/agents/buildin/chatbot/prompt.py @@ -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//parsed/ 中找到解析后的 markdown 文件。 +(路径为 {VIRTUAL_KBS_PATH})来获取信息。 +源文件可能无法解析,可以在 {VIRTUAL_KBS_PATH}//parsed/ 中找到解析后的 markdown 文件。 你需要根据任务的复杂程度来使用 write_todos 来记录规划和待办事项,确保任务的每个步骤都被记录和跟踪。 """ diff --git a/backend/package/yuxi/agents/buildin/deep_agent/prompt.py b/backend/package/yuxi/agents/buildin/deep_agent/prompt.py index 02b3da70..983d3690 100644 --- a/backend/package/yuxi/agents/buildin/deep_agent/prompt.py +++ b/backend/package/yuxi/agents/buildin/deep_agent/prompt.py @@ -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}:用于存放用户上传的文件 """ diff --git a/backend/package/yuxi/agents/toolkits/buildin/tools.py b/backend/package/yuxi/agents/toolkits/buildin/tools.py index 7b9a984d..3dfcfd98 100644 --- a/backend/package/yuxi/agents/toolkits/buildin/tools.py +++ b/backend/package/yuxi/agents/toolkits/buildin/tools.py @@ -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. 可以一次传多个文件 """ diff --git a/backend/package/yuxi/services/conversation_service.py b/backend/package/yuxi/services/conversation_service.py index 6ab17977..46cde9f0 100644 --- a/backend/package/yuxi/services/conversation_service.py +++ b/backend/package/yuxi/services/conversation_service.py @@ -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) diff --git a/backend/package/yuxi/services/doc_converter.py b/backend/package/yuxi/services/doc_converter.py deleted file mode 100644 index a32427e9..00000000 --- a/backend/package/yuxi/services/doc_converter.py +++ /dev/null @@ -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 diff --git a/backend/package/yuxi/utils/paths.py b/backend/package/yuxi/utils/paths.py new file mode 100644 index 00000000..79555e1c --- /dev/null +++ b/backend/package/yuxi/utils/paths.py @@ -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", +] diff --git a/backend/server/routers/chat_router.py b/backend/server/routers/chat_router.py index 6ed2a0c9..8951eb35 100644 --- a/backend/server/routers/chat_router.py +++ b/backend/server/routers/chat_router.py @@ -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),