From 932f990841952f421fed3b8c683a9316aab166ce Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sat, 30 May 2026 14:59:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E7=9A=84=E6=96=87=E4=BB=B6=E7=B3=BB=E7=BB=9F=E7=9A=84=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/remote_install.py} | 0 backend/package/yuxi/agents/skills/service.py | 2 +- .../agents/toolkits/buildin/install_skill.py | 2 +- backend/package/yuxi/knowledge/base.py | 6 +- backend/package/yuxi/plugins/__init__.py | 1 - .../yuxi/services/agent_runtime_service.py | 56 ++++ backend/package/yuxi/services/chat_service.py | 2 +- .../yuxi/services/conversation_service.py | 2 +- backend/package/yuxi/services/file_preview.py | 102 +++++++ .../yuxi/services/filesystem_service.py | 145 ---------- .../services/viewer_filesystem_service.py | 257 ++++++------------ .../yuxi/services/workspace_service.py | 8 +- .../package/yuxi/storage/minio/__init__.py | 3 +- backend/package/yuxi/storage/minio/utils.py | 29 +- .../package/yuxi/{services => utils}/guard.py | 0 .../yuxi/{services => utils}/upload_utils.py | 23 -- backend/server/routers/auth_router.py | 2 +- backend/server/routers/knowledge_router.py | 2 +- backend/server/routers/skill_router.py | 2 +- backend/server/routers/user_router.py | 2 +- .../skills/test_remote_install.py} | 12 +- .../toolkits/buildin/test_install_skill.py | 2 +- docs/develop-guides/roadmap.md | 1 + 23 files changed, 289 insertions(+), 372 deletions(-) rename backend/package/yuxi/{services/remote_skill_install_service.py => agents/skills/remote_install.py} (100%) delete mode 100644 backend/package/yuxi/plugins/__init__.py create mode 100644 backend/package/yuxi/services/agent_runtime_service.py create mode 100644 backend/package/yuxi/services/file_preview.py delete mode 100644 backend/package/yuxi/services/filesystem_service.py rename backend/package/yuxi/{services => utils}/guard.py (100%) rename backend/package/yuxi/{services => utils}/upload_utils.py (65%) rename backend/test/unit/{services/test_remote_skill_install_service.py => agents/skills/test_remote_install.py} (98%) diff --git a/backend/package/yuxi/services/remote_skill_install_service.py b/backend/package/yuxi/agents/skills/remote_install.py similarity index 100% rename from backend/package/yuxi/services/remote_skill_install_service.py rename to backend/package/yuxi/agents/skills/remote_install.py diff --git a/backend/package/yuxi/agents/skills/service.py b/backend/package/yuxi/agents/skills/service.py index 2d18abf2..a3002fdf 100644 --- a/backend/package/yuxi/agents/skills/service.py +++ b/backend/package/yuxi/agents/skills/service.py @@ -829,7 +829,7 @@ async def prepare_remote_skill_install( skills: list[str], operator: User, ) -> dict[str, Any]: - from yuxi.services.remote_skill_install_service import prepare_remote_skills_batch + from yuxi.agents.skills.remote_install import prepare_remote_skills_batch repo = SkillRepository(db) draft_dir = get_skill_drafts_root_dir() / str(uuid.uuid4()) diff --git a/backend/package/yuxi/agents/toolkits/buildin/install_skill.py b/backend/package/yuxi/agents/toolkits/buildin/install_skill.py index fc1567ac..384c175c 100644 --- a/backend/package/yuxi/agents/toolkits/buildin/install_skill.py +++ b/backend/package/yuxi/agents/toolkits/buildin/install_skill.py @@ -146,7 +146,7 @@ # skill_names: list[str] | None = None, # ) -> Command: # """执行异步安装任务的核心逻辑""" -# from yuxi.services.remote_skill_install_service import prepare_remote_skills_batch +# from yuxi.agents.skills.remote_install import prepare_remote_skills_batch # from yuxi.agents.skills.service import ( # import_skill_dir, # normalize_string_list, diff --git a/backend/package/yuxi/knowledge/base.py b/backend/package/yuxi/knowledge/base.py index b20860e5..60b66500 100644 --- a/backend/package/yuxi/knowledge/base.py +++ b/backend/package/yuxi/knowledge/base.py @@ -539,7 +539,7 @@ class KnowledgeBase(ABC): } async def read_file_preview(self, kb_id: str, file_id: str, variant: str = "parsed") -> dict: - from yuxi.services.viewer_filesystem_service import _detect_preview_type + from yuxi.services.file_preview import detect_preview_type file_meta = self._get_file_meta(kb_id, file_id) if file_meta.get("is_folder"): @@ -590,7 +590,7 @@ class KnowledgeBase(ABC): "message": "文件没有可预览的原始内容", } - preview_type, supported, message = _detect_preview_type(filename, b"") + preview_type, supported, message = detect_preview_type(filename, b"") if preview_type in {"image", "pdf"}: return { **response, @@ -601,7 +601,7 @@ class KnowledgeBase(ABC): } raw_content = await self._read_minio_bytes(original_path) - preview_type, supported, message = _detect_preview_type(filename, raw_content) + preview_type, supported, message = detect_preview_type(filename, raw_content) if preview_type in {"image", "pdf"} or not supported: return { **response, diff --git a/backend/package/yuxi/plugins/__init__.py b/backend/package/yuxi/plugins/__init__.py deleted file mode 100644 index c9c2ef67..00000000 --- a/backend/package/yuxi/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__all__: list[str] = [] diff --git a/backend/package/yuxi/services/agent_runtime_service.py b/backend/package/yuxi/services/agent_runtime_service.py new file mode 100644 index 00000000..de124f47 --- /dev/null +++ b/backend/package/yuxi/services/agent_runtime_service.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.agents.buildin import agent_manager +from yuxi.agents.context import BaseContext, normalize_agent_context_config, prepare_agent_runtime_context +from yuxi.repositories.agent_repository import AgentRepository +from yuxi.repositories.conversation_repository import ConversationRepository +from yuxi.services.conversation_service import require_user_conversation +from yuxi.storage.postgres.models_business import User + + +async def resolve_agent_runtime_context( + *, + db: AsyncSession, + user: User, + bound_agent_id: str, +) -> BaseContext: + agent_item = await AgentRepository(db).get_visible_by_slug(slug=bound_agent_id, user=user) + if not agent_item: + raise HTTPException(status_code=404, detail="智能体不存在") + + backend = agent_manager.get_agent(agent_item.backend_id) + if not backend: + raise HTTPException(status_code=404, detail="智能体后端不存在") + + context_schema = backend.context_schema + context = context_schema(thread_id="", uid=str(user.uid)) + normalized_config = await normalize_agent_context_config( + (agent_item.config_json or {}).get("context", {}), + db=db, + user=user, + context_schema=context_schema, + ) + context.update_from_dict(normalized_config) + return context + + +async def resolve_thread_agent_runtime_context( + *, + thread_id: str, + user: User, + db: AsyncSession, +) -> BaseContext: + conv_repo = ConversationRepository(db) + conversation = await require_user_conversation(conv_repo, thread_id, str(user.uid)) + + runtime_context = await resolve_agent_runtime_context( + db=db, + user=user, + bound_agent_id=conversation.agent_id, + ) + runtime_context.thread_id = thread_id + runtime_context.uid = str(user.uid) + await prepare_agent_runtime_context(runtime_context) + return runtime_context diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index aeac4c0c..4dc6a743 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -12,7 +12,7 @@ from yuxi.agents.backends.sandbox.paths import sandbox_workspace_agents_prompt_f from yuxi.agents.buildin import agent_manager from yuxi.agents.context import normalize_agent_context_config from yuxi.agents.state import AgentStatePayload -from yuxi.services.guard import content_guard +from yuxi.utils.guard import content_guard from yuxi.repositories.agent_repository import AgentRepository from yuxi.repositories.conversation_repository import ConversationRepository from yuxi.services.conversation_service import serialize_attachment diff --git a/backend/package/yuxi/services/conversation_service.py b/backend/package/yuxi/services/conversation_service.py index a8f636cb..f7b00205 100644 --- a/backend/package/yuxi/services/conversation_service.py +++ b/backend/package/yuxi/services/conversation_service.py @@ -16,7 +16,7 @@ from yuxi.knowledge.parser import Parser from yuxi.repositories.agent_repository import AgentRepository from yuxi.repositories.conversation_repository import ConversationRepository from yuxi.services.mention_search_service import invalidate_mention_cache -from yuxi.services.upload_utils import read_upload_with_limit, write_upload_to_path +from yuxi.utils.upload_utils import read_upload_with_limit, write_upload_to_path from yuxi.storage.minio import StorageError, get_minio_client from yuxi.storage.postgres.models_business import User from yuxi.utils.datetime_utils import utc_isoformat diff --git a/backend/package/yuxi/services/file_preview.py b/backend/package/yuxi/services/file_preview.py new file mode 100644 index 00000000..4bbb82e3 --- /dev/null +++ b/backend/package/yuxi/services/file_preview.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import mimetypes +from pathlib import PurePosixPath + +_MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"}) +_PDF_EXTENSIONS = frozenset({".pdf"}) +_TEXT_EXTENSIONS = frozenset( + { + ".txt", + ".text", + ".log", + ".json", + ".jsonl", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + ".conf", + ".csv", + ".tsv", + ".py", + ".js", + ".ts", + ".jsx", + ".tsx", + ".vue", + ".html", + ".htm", + ".css", + ".less", + ".scss", + ".xml", + ".sql", + ".sh", + ".bash", + ".zsh", + ".fish", + ".env", + ".dockerfile", + ".gitignore", + ".weather", + } +) +_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"}) +_BINARY_SIGNATURES = ( + b"\x7fELF", + b"MZ", + b"%PDF-", + b"PK\x03\x04", + b"PK\x05\x06", + b"PK\x07\x08", + b"\x89PNG\r\n\x1a\n", + b"\xff\xd8\xff", + b"GIF87a", + b"GIF89a", + b"RIFF", +) + + +def detect_preview_type(path: str, raw_content: bytes) -> tuple[str, bool, str | None]: + suffix = PurePosixPath(path).suffix.lower() + mime_type, _encoding = mimetypes.guess_type(path) + head = raw_content[:1024] + + if suffix in _IMAGE_EXTENSIONS or (mime_type and mime_type.startswith("image/")): + return "image", True, None + + if suffix in _PDF_EXTENSIONS or mime_type == "application/pdf" or head.startswith(b"%PDF-"): + return "pdf", True, None + + if suffix in _MARKDOWN_EXTENSIONS: + return "markdown", True, None + + if suffix in _TEXT_EXTENSIONS: + return "text", True, None + + if b"\x00" in head: + return "unsupported", False, "当前文件是二进制文件,暂不支持预览" + + if any(head.startswith(signature) for signature in _BINARY_SIGNATURES): + if head.startswith(b"RIFF") and b"WEBP" in head[:16]: + return "image", True, None + return "unsupported", False, "当前文件格式暂不支持预览" + + if mime_type: + if mime_type.startswith("text/"): + return "text", True, None + if mime_type in {"application/json", "application/xml", "application/javascript"}: + return "text", True, None + if mime_type.startswith("application/"): + return "unsupported", False, "当前文件格式暂不支持预览" + + if not raw_content: + return "text", True, None + + try: + raw_content.decode("utf-8") + return "text", True, None + except UnicodeDecodeError: + return "unsupported", False, "当前文件不是可读文本,暂不支持预览" diff --git a/backend/package/yuxi/services/filesystem_service.py b/backend/package/yuxi/services/filesystem_service.py deleted file mode 100644 index 95664375..00000000 --- a/backend/package/yuxi/services/filesystem_service.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -import asyncio - -from fastapi import HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from yuxi.agents.backends import create_agent_composite_backend -from yuxi.agents.backends.sandbox.backend import _looks_like_binary -from yuxi.agents.buildin import agent_manager -from yuxi.agents.context import BaseContext, normalize_agent_context_config, prepare_agent_runtime_context -from yuxi.repositories.agent_repository import AgentRepository -from yuxi.repositories.conversation_repository import ConversationRepository -from yuxi.services.conversation_service import require_user_conversation -from yuxi.storage.postgres.models_business import User - - -async def _resolve_filesystem_context( - *, - db: AsyncSession, - user: User, - bound_agent_id: str, -) -> BaseContext: - agent_item = await AgentRepository(db).get_visible_by_slug(slug=bound_agent_id, user=user) - if not agent_item: - raise HTTPException(status_code=404, detail="智能体不存在") - - backend = agent_manager.get_agent(agent_item.backend_id) - if not backend: - raise HTTPException(status_code=404, detail="智能体后端不存在") - - context_schema = backend.context_schema - context = context_schema(thread_id="", uid=str(user.uid)) - normalized_config = await normalize_agent_context_config( - (agent_item.config_json or {}).get("context", {}), - db=db, - user=user, - context_schema=context_schema, - ) - context.update_from_dict(normalized_config) - return context - - -async def _resolve_filesystem_state( - *, - thread_id: str, - user: User, - db: AsyncSession, -): - conv_repo = ConversationRepository(db) - conversation = await require_user_conversation(conv_repo, thread_id, str(user.uid)) - - runtime_context = await _resolve_filesystem_context( - db=db, - user=user, - bound_agent_id=conversation.agent_id, - ) - runtime_context.thread_id = thread_id - runtime_context.uid = str(user.uid) - await prepare_agent_runtime_context(runtime_context) - - return runtime_context - - -async def list_filesystem_entries_view( - *, - thread_id: str, - path: str, - current_user: User, - db: AsyncSession, -) -> dict: - if not thread_id: - raise HTTPException(status_code=422, detail="thread_id 不能为空") - - normalized_path = (path or "/").strip() or "/" - - runtime_context = await _resolve_filesystem_state( - thread_id=thread_id, - user=current_user, - db=db, - ) - - runtime_stub = type("RuntimeStub", (), {"context": runtime_context})() - composite_backend = create_agent_composite_backend(runtime_stub) - try: - entries = await asyncio.to_thread(composite_backend.ls_info, normalized_path) - except PermissionError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) from e - - return {"entries": entries or []} - - -async def read_file_content_view( - *, - thread_id: str, - path: str, - current_user: User, - db: AsyncSession, -) -> dict: - if not thread_id: - raise HTTPException(status_code=422, detail="thread_id 不能为空") - if not path: - raise HTTPException(status_code=422, detail="path 不能为空") - - normalized_path = path.strip() - - runtime_context = await _resolve_filesystem_state( - thread_id=thread_id, - user=current_user, - db=db, - ) - - runtime_stub = type("RuntimeStub", (), {"context": runtime_context})() - composite_backend = create_agent_composite_backend(runtime_stub) - try: - responses = await asyncio.to_thread(composite_backend.download_files, [normalized_path]) - except PermissionError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) from e - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) from e - - response = responses[0] if responses else None - if response is None: - raise HTTPException(status_code=404, detail="文件不存在") - if response.error == "file_not_found": - raise HTTPException(status_code=404, detail="文件不存在") - if response.error == "is_directory": - raise HTTPException(status_code=400, detail="当前路径是目录") - if response.error == "read_failed": - raise HTTPException(status_code=400, detail="文件读取失败") - if response.error: - raise HTTPException(status_code=400, detail=response.error) - - raw_content = response.content or b"" - if _looks_like_binary(raw_content): - raise HTTPException(status_code=400, detail="当前文件是二进制文件,不能按文本读取") - try: - content = raw_content.decode("utf-8") - except UnicodeDecodeError: - content = raw_content.decode("utf-8", errors="replace") - - return {"content": content} diff --git a/backend/package/yuxi/services/viewer_filesystem_service.py b/backend/package/yuxi/services/viewer_filesystem_service.py index 035dd564..75a7c34e 100644 --- a/backend/package/yuxi/services/viewer_filesystem_service.py +++ b/backend/package/yuxi/services/viewer_filesystem_service.py @@ -1,14 +1,12 @@ 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 sqlalchemy.ext.asyncio import AsyncSession @@ -24,65 +22,20 @@ from yuxi.agents.backends.sandbox import ( ) from yuxi.agents.backends.skills_backend import SelectedSkillsReadonlyBackend from yuxi.agents.skills.service import normalize_string_list -from yuxi.services.filesystem_service import _resolve_filesystem_state +from yuxi.services.agent_runtime_service import resolve_thread_agent_runtime_context +from yuxi.services.file_preview import detect_preview_type +from yuxi.services.workspace_service import ( + create_workspace_directory as create_workspace_directory_entry, + delete_workspace_path, + download_workspace_file as download_workspace_file_response, + list_workspace_tree, + read_workspace_file_content as read_workspace_file_content_response, + upload_workspace_file as upload_workspace_file_entry, +) from yuxi.storage.postgres.models_business import User from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp from yuxi.utils.paths import VIRTUAL_PATH_OUTPUTS, VIRTUAL_PATH_UPLOADS, VIRTUAL_PATH_WORKSPACE -_MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"}) -_PDF_EXTENSIONS = frozenset({".pdf"}) -_TEXT_EXTENSIONS = frozenset( - { - ".txt", - ".text", - ".log", - ".json", - ".jsonl", - ".yaml", - ".yml", - ".toml", - ".ini", - ".cfg", - ".conf", - ".csv", - ".tsv", - ".py", - ".js", - ".ts", - ".jsx", - ".tsx", - ".vue", - ".html", - ".htm", - ".css", - ".less", - ".scss", - ".xml", - ".sql", - ".sh", - ".bash", - ".zsh", - ".fish", - ".env", - ".dockerfile", - ".gitignore", - ".weather", - } -) -_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"}) -_BINARY_SIGNATURES = ( - b"\x7fELF", - b"MZ", - b"%PDF-", - b"PK\x03\x04", - b"PK\x05\x06", - b"PK\x07\x08", - b"\x89PNG\r\n\x1a\n", - b"\xff\xd8\xff", - b"GIF87a", - b"GIF89a", - b"RIFF", -) _PROTECTED_USER_DATA_ROOTS = frozenset( { VIRTUAL_PATH_WORKSPACE, @@ -92,49 +45,6 @@ _PROTECTED_USER_DATA_ROOTS = frozenset( ) -def _detect_preview_type(path: str, raw_content: bytes) -> tuple[str, bool, str | None]: - suffix = PurePosixPath(path).suffix.lower() - mime_type, _encoding = mimetypes.guess_type(path) - head = raw_content[:1024] - - if suffix in _IMAGE_EXTENSIONS or (mime_type and mime_type.startswith("image/")): - return "image", True, None - - if suffix in _PDF_EXTENSIONS or mime_type == "application/pdf" or head.startswith(b"%PDF-"): - return "pdf", True, None - - if suffix in _MARKDOWN_EXTENSIONS: - return "markdown", True, None - - if suffix in _TEXT_EXTENSIONS: - return "text", True, None - - if b"\x00" in head: - return "unsupported", False, "当前文件是二进制文件,暂不支持预览" - - if any(head.startswith(signature) for signature in _BINARY_SIGNATURES): - if head.startswith(b"RIFF") and b"WEBP" in head[:16]: - return "image", True, None - return "unsupported", False, "当前文件格式暂不支持预览" - - if mime_type: - if mime_type.startswith("text/"): - return "text", True, None - if mime_type in {"application/json", "application/xml", "application/javascript"}: - return "text", True, None - if mime_type.startswith("application/"): - return "unsupported", False, "当前文件格式暂不支持预览" - - if not raw_content: - return "text", True, None - - try: - raw_content.decode("utf-8") - return "text", True, None - except UnicodeDecodeError: - return "unsupported", False, "当前文件不是可读文本,暂不支持预览" - - def _normalize_path(path: str | None) -> str: normalized = (path or "/").strip() or "/" if not normalized.startswith("/"): @@ -247,45 +157,36 @@ def _list_local_entries(thread_id: str, uid: str, actual_path) -> list[dict]: return entries -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 _workspace_relative_path(path: str) -> str: + if path == VIRTUAL_PATH_WORKSPACE: + return "/" + if not path.startswith(f"{VIRTUAL_PATH_WORKSPACE}/"): + raise HTTPException(status_code=400, detail="当前路径不是工作区路径") + return path[len(VIRTUAL_PATH_WORKSPACE) :] or "/" -def _resolve_workspace_parent_dir(thread_id: str, uid: str, parent_path: str) -> Path: - normalized_parent = _normalize_path(parent_path) - if not _is_workspace_path(normalized_parent): - raise HTTPException(status_code=400, detail="当前路径不支持写入") - - ensure_thread_dirs(thread_id, uid) - try: - actual_parent = _resolve_local_user_data_path(thread_id, uid, normalized_parent) - except ValueError as exc: - # workspace 写入边界按真实路径校验,软链接逃逸应表现为权限拒绝。 - if "path traversal" in str(exc): - raise HTTPException(status_code=403, detail="Access denied") from exc - raise - if not actual_parent.exists(): - raise HTTPException(status_code=404, detail="目标目录不存在") - if not actual_parent.is_dir(): - raise HTTPException(status_code=400, detail="目标路径不是目录") - return actual_parent +def _viewer_entry_from_workspace_entry(entry: dict) -> dict: + path = str(entry.get("virtual_path") or "") + if not path: + workspace_path = str(entry.get("path") or "/") + path = VIRTUAL_PATH_WORKSPACE if workspace_path == "/" else f"{VIRTUAL_PATH_WORKSPACE}{workspace_path}" + is_dir = bool(entry.get("is_dir", False)) + if is_dir and not path.endswith("/"): + path = f"{path}/" + return { + "path": path, + "name": str(entry.get("name", "") or PurePosixPath(path.rstrip("/")).name or path), + "is_dir": is_dir, + "size": int(entry.get("size", 0) or 0), + "modified_at": str(entry.get("modified_at", "") or ""), + } -def _resolve_new_workspace_child(thread_id: str, uid: str, parent_path: Path, name: str) -> Path: - target_path = parent_path / name - workspace_root = sandbox_workspace_dir(thread_id, uid).resolve() - if not _is_path_within(target_path.resolve(strict=False), workspace_root): - raise HTTPException(status_code=403, detail="Access denied") - if target_path.exists(): - raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") - return target_path +def _viewer_response_from_workspace_response(response: dict) -> dict: + result = {**response} + if "entry" in result and isinstance(result["entry"], dict): + result["entry"] = _viewer_entry_from_workspace_entry(result["entry"]) + return result def _list_user_data_root_entries(thread_id: str, uid: str) -> list[dict]: @@ -315,7 +216,7 @@ async def _resolve_viewer_state( current_user: User, db: AsyncSession, ): - runtime_context = await _resolve_filesystem_state( + runtime_context = await resolve_thread_agent_runtime_context( thread_id=thread_id, user=current_user, db=db, @@ -361,6 +262,13 @@ async def list_viewer_filesystem_tree( if _is_user_data_path(normalized_path): uid = str(current_user.uid) ensure_thread_dirs(thread_id, uid) + if _is_workspace_path(normalized_path): + response = await list_workspace_tree( + path=_workspace_relative_path(normalized_path), + current_user=current_user, + ) + entries = [_viewer_entry_from_workspace_entry(entry) for entry in response.get("entries", [])] + return {"entries": _sort_entries(entries)} if normalized_path == USER_DATA_PATH: entries = await asyncio.to_thread(_list_user_data_root_entries, thread_id, uid) return {"entries": _sort_entries(entries)} @@ -403,13 +311,18 @@ async def read_viewer_file_content( try: if _is_user_data_path(normalized_path): + if _is_workspace_path(normalized_path): + return await read_workspace_file_content_response( + path=_workspace_relative_path(normalized_path), + current_user=current_user, + ) actual_path = _resolve_local_user_data_path(thread_id, str(current_user.uid), normalized_path) if not actual_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") if not actual_path.is_file(): raise HTTPException(status_code=400, detail="当前路径是目录") raw_content = await asyncio.to_thread(actual_path.read_bytes) - preview_type, supported, message = _detect_preview_type(normalized_path, raw_content) + preview_type, supported, message = detect_preview_type(normalized_path, raw_content) if preview_type in {"image", "pdf"} or not supported: return { "content": None, @@ -447,7 +360,7 @@ async def read_viewer_file_content( raise HTTPException(status_code=400, detail=str(response.error)) raw_content = response.content or b"" - preview_type, supported, message = _detect_preview_type(normalized_path, raw_content) + preview_type, supported, message = detect_preview_type(normalized_path, raw_content) if preview_type in {"image", "pdf"}: return { @@ -480,7 +393,7 @@ async def download_viewer_file( path: str, current_user: User, db: AsyncSession, -) -> StreamingResponse: +) -> StreamingResponse | FileResponse: normalized_path = _normalize_path(path) sandbox_backend, skills_backend, _selected_skills = await _resolve_viewer_state( thread_id=thread_id, @@ -490,6 +403,11 @@ async def download_viewer_file( try: if _is_user_data_path(normalized_path): + if _is_workspace_path(normalized_path): + return await download_workspace_file_response( + path=_workspace_relative_path(normalized_path), + current_user=current_user, + ) actual_path = _resolve_local_user_data_path(thread_id, str(current_user.uid), normalized_path) if not actual_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") @@ -558,6 +476,9 @@ async def delete_viewer_file( raise HTTPException(status_code=400, detail="当前目录不允许删除") try: + if _is_workspace_path(normalized_path): + await delete_workspace_path(path=_workspace_relative_path(normalized_path), current_user=current_user) + return {"success": True, "path": normalized_path} actual_path = _resolve_local_user_data_path(thread_id, str(current_user.uid), normalized_path) if not actual_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") @@ -590,21 +511,16 @@ async def create_viewer_directory( db=db, ) - uid = str(current_user.uid) - directory_name = _validate_child_name(name, field_name="文件夹名") + normalized_parent = _normalize_path(parent_path) + if not _is_workspace_path(normalized_parent): + raise HTTPException(status_code=400, detail="当前路径不支持写入") - try: - actual_parent = _resolve_workspace_parent_dir(thread_id, uid, parent_path) - target_path = _resolve_new_workspace_child(thread_id, uid, actual_parent, directory_name) - await asyncio.to_thread(target_path.mkdir) - except FileExistsError as e: - raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from e - except PermissionError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) from e - - return {"success": True, "entry": _entry_for_local_path(thread_id, uid, target_path)} + response = await create_workspace_directory_entry( + parent_path=_workspace_relative_path(normalized_parent), + name=name, + current_user=current_user, + ) + return _viewer_response_from_workspace_response(response) async def upload_viewer_file( @@ -624,30 +540,13 @@ async def upload_viewer_file( db=db, ) - uid = str(current_user.uid) - file_name = _validate_child_name(Path(file.filename or "").name, field_name="文件名") - target_path: Path | None = None - created_file = False - upload_completed = False + normalized_parent = _normalize_path(parent_path) + if not _is_workspace_path(normalized_parent): + raise HTTPException(status_code=400, detail="当前路径不支持写入") - try: - actual_parent = _resolve_workspace_parent_dir(thread_id, uid, parent_path) - target_path = _resolve_new_workspace_child(thread_id, uid, actual_parent, file_name) - async with aiofiles.open(target_path, "xb") as buffer: - created_file = True - while chunk := await file.read(1024 * 1024): - await buffer.write(chunk) - upload_completed = True - except FileExistsError as e: - raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from e - except PermissionError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) from e - finally: - # 上传来自用户输入,传输中断时清理本次创建的半成品文件。 - if created_file and not upload_completed and target_path and target_path.exists(): - with contextlib.suppress(OSError): - await asyncio.to_thread(target_path.unlink) - - return {"success": True, "entry": _entry_for_local_path(thread_id, uid, target_path)} + response = await upload_workspace_file_entry( + parent_path=_workspace_relative_path(normalized_parent), + file=file, + current_user=current_user, + ) + return _viewer_response_from_workspace_response(response) diff --git a/backend/package/yuxi/services/workspace_service.py b/backend/package/yuxi/services/workspace_service.py index 86202a18..7bf7254d 100644 --- a/backend/package/yuxi/services/workspace_service.py +++ b/backend/package/yuxi/services/workspace_service.py @@ -12,8 +12,8 @@ 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 MAX_UPLOAD_SIZE_BYTES, write_upload_to_buffer -from yuxi.services.viewer_filesystem_service import _detect_preview_type +from yuxi.services.file_preview import detect_preview_type +from yuxi.utils.upload_utils import MAX_UPLOAD_SIZE_BYTES, write_upload_to_buffer from yuxi.storage.postgres.models_business import User from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp from yuxi.utils.paths import VIRTUAL_PATH_WORKSPACE, WORKSPACE_DIR_NAME @@ -155,7 +155,7 @@ async def read_workspace_file_content(*, path: str, current_user: User) -> dict: 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) + preview_type, supported, message = detect_preview_type(path, raw_content) if preview_type in {"image", "pdf"} or not supported: return { "content": None, @@ -191,7 +191,7 @@ async def write_workspace_file_content(*, path: str, content: str, current_user: 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) + 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: diff --git a/backend/package/yuxi/storage/minio/__init__.py b/backend/package/yuxi/storage/minio/__init__.py index 9775c2b1..278a9f3b 100644 --- a/backend/package/yuxi/storage/minio/__init__.py +++ b/backend/package/yuxi/storage/minio/__init__.py @@ -5,7 +5,7 @@ MinIO 存储模块 # 导出核心功能 from .client import MinIOClient, StorageError, UploadResult, aupload_file_to_minio, get_minio_client -from .utils import generate_unique_filename, get_file_size +from .utils import generate_unique_filename, get_file_size, upload_image_to_minio # 导出常用函数 __all__ = [ @@ -19,4 +19,5 @@ __all__ = [ # 工具函数 "get_file_size", "generate_unique_filename", + "upload_image_to_minio", ] diff --git a/backend/package/yuxi/storage/minio/utils.py b/backend/package/yuxi/storage/minio/utils.py index 9a0a61ce..2b2854db 100644 --- a/backend/package/yuxi/storage/minio/utils.py +++ b/backend/package/yuxi/storage/minio/utils.py @@ -4,6 +4,13 @@ MinIO 存储工具函数 """ import os +import uuid + +from fastapi import UploadFile + +from yuxi.utils.upload_utils import read_upload_with_limit + +from .client import aupload_file_to_minio def get_file_size(file_path: str) -> int: @@ -13,9 +20,27 @@ def get_file_size(file_path: str) -> int: def generate_unique_filename(original_name: str) -> str: """生成唯一的文件名""" - import uuid - name_parts = original_name.rsplit(".", 1) base_name = name_parts[0] if len(name_parts) == 2 else original_name extension = f".{name_parts[1]}" if len(name_parts) == 2 else "" return f"{base_name}_{uuid.uuid4().hex[:8]}{extension}" + + +async def upload_image_to_minio( + upload: UploadFile, + *, + object_prefix: str, + max_size_bytes: int, + too_large_message: str, +) -> str: + if not upload.content_type or not upload.content_type.startswith("image/"): + raise ValueError("只能上传图片文件") + + file_content = await read_upload_with_limit( + upload, + max_size_bytes=max_size_bytes, + too_large_message=too_large_message, + ) + file_extension = upload.filename.rsplit(".", 1)[-1].lower() if upload.filename and "." in upload.filename else "jpg" + object_name = f"{object_prefix.strip('/')}/{uuid.uuid4()}.{file_extension}" + return await aupload_file_to_minio("public", object_name, file_content) diff --git a/backend/package/yuxi/services/guard.py b/backend/package/yuxi/utils/guard.py similarity index 100% rename from backend/package/yuxi/services/guard.py rename to backend/package/yuxi/utils/guard.py diff --git a/backend/package/yuxi/services/upload_utils.py b/backend/package/yuxi/utils/upload_utils.py similarity index 65% rename from backend/package/yuxi/services/upload_utils.py rename to backend/package/yuxi/utils/upload_utils.py index 9fb42d9f..d5240311 100644 --- a/backend/package/yuxi/services/upload_utils.py +++ b/backend/package/yuxi/utils/upload_utils.py @@ -1,11 +1,8 @@ -import uuid from pathlib import Path import aiofiles from fastapi import UploadFile -from yuxi.storage.minio import aupload_file_to_minio - MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024 @@ -66,23 +63,3 @@ async def write_upload_to_path( too_large_message=too_large_message, chunk_size=chunk_size, ) - - -async def upload_image_to_minio( - upload: UploadFile, - *, - object_prefix: str, - max_size_bytes: int, - too_large_message: str, -) -> str: - if not upload.content_type or not upload.content_type.startswith("image/"): - raise ValueError("只能上传图片文件") - - file_content = await read_upload_with_limit( - upload, - max_size_bytes=max_size_bytes, - too_large_message=too_large_message, - ) - file_extension = upload.filename.rsplit(".", 1)[-1].lower() if upload.filename and "." in upload.filename else "jpg" - object_name = f"{object_prefix.strip('/')}/{uuid.uuid4()}.{file_extension}" - return await aupload_file_to_minio("public", object_name, file_content) diff --git a/backend/server/routers/auth_router.py b/backend/server/routers/auth_router.py index 5e10a8e9..fd93b22f 100644 --- a/backend/server/routers/auth_router.py +++ b/backend/server/routers/auth_router.py @@ -22,7 +22,7 @@ from server.utils.auth_middleware import ( from yuxi.utils.auth_utils import AuthUtils from yuxi.services.user_identity_service import generate_unique_uid, validate_username, is_valid_phone_number from yuxi.services.operation_log_service import log_operation -from yuxi.services.upload_utils import upload_image_to_minio +from yuxi.storage.minio import upload_image_to_minio from yuxi.utils.datetime_utils import utc_now_naive # OIDC 认证相关导入 diff --git a/backend/server/routers/knowledge_router.py b/backend/server/routers/knowledge_router.py index 57669f01..602e1927 100644 --- a/backend/server/routers/knowledge_router.py +++ b/backend/server/routers/knowledge_router.py @@ -29,7 +29,7 @@ from yuxi.knowledge.utils.sample_question_utils import ( ) from yuxi.knowledge.utils.url_fetcher import fetch_url_content from yuxi.models.providers.cache import model_cache -from yuxi.services.upload_utils import MAX_UPLOAD_SIZE_BYTES, read_upload_with_limit, write_upload_to_path +from yuxi.utils.upload_utils import MAX_UPLOAD_SIZE_BYTES, read_upload_with_limit, write_upload_to_path from yuxi.services.workspace_service import MAX_WORKSPACE_UPLOAD_SIZE_BYTES, resolve_workspace_file_path from yuxi.storage.postgres.models_business import User from yuxi.storage.minio.client import MinIOClient, StorageError, aupload_file_to_minio, get_minio_client diff --git a/backend/server/routers/skill_router.py b/backend/server/routers/skill_router.py index 43439ee2..2eea4670 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -34,7 +34,7 @@ from yuxi.agents.skills.service import ( update_skill_file, update_skill_share_config, ) -from yuxi.services.remote_skill_install_service import list_remote_skills, search_remote_skills +from yuxi.agents.skills.remote_install import list_remote_skills, search_remote_skills from yuxi.storage.postgres.models_business import User from yuxi.utils.logging_config import logger diff --git a/backend/server/routers/user_router.py b/backend/server/routers/user_router.py index dcb0d11b..b88934f3 100644 --- a/backend/server/routers/user_router.py +++ b/backend/server/routers/user_router.py @@ -12,7 +12,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from server.utils.auth_middleware import get_db, get_required_user -from yuxi.services.upload_utils import upload_image_to_minio +from yuxi.storage.minio import upload_image_to_minio from yuxi.storage.postgres.models_business import APIKey, AgentEnv, User from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime, format_utc_datetime, utc_now_naive diff --git a/backend/test/unit/services/test_remote_skill_install_service.py b/backend/test/unit/agents/skills/test_remote_install.py similarity index 98% rename from backend/test/unit/services/test_remote_skill_install_service.py rename to backend/test/unit/agents/skills/test_remote_install.py index e3d6f65d..7a8cd81a 100644 --- a/backend/test/unit/services/test_remote_skill_install_service.py +++ b/backend/test/unit/agents/skills/test_remote_install.py @@ -5,7 +5,7 @@ from types import SimpleNamespace import pytest -from yuxi.services import remote_skill_install_service as svc +from yuxi.agents.skills import remote_install as svc def test_parse_available_skills_from_cli_output() -> None: @@ -242,12 +242,14 @@ async def test_install_remote_skills_batch_partial_failure(monkeypatch: pytest.M skill_dir_base = home / ".agents" / "skills" (skill_dir_base / "skill-a").mkdir(parents=True, exist_ok=True) (skill_dir_base / "skill-a" / "SKILL.md").write_text( - "---\nname: skill-a\ndescription: demo\n---\n# A\n", encoding="utf-8", + "---\nname: skill-a\ndescription: demo\n---\n# A\n", + encoding="utf-8", ) # skill-b directory missing (simulate install failure from CLI side) (skill_dir_base / "skill-c").mkdir(parents=True, exist_ok=True) (skill_dir_base / "skill-c" / "SKILL.md").write_text( - "---\nname: skill-c\ndescription: demo\n---\n# C\n", encoding="utf-8", + "---\nname: skill-c\ndescription: demo\n---\n# C\n", + encoding="utf-8", ) return "installed" @@ -280,7 +282,8 @@ async def test_install_remote_skills_batch_handles_invalid_names(monkeypatch: py skill_dir_base = home / ".agents" / "skills" (skill_dir_base / "valid-skill").mkdir(parents=True, exist_ok=True) (skill_dir_base / "valid-skill" / "SKILL.md").write_text( - "---\nname: valid-skill\ndescription: demo\n---\n# Valid\n", encoding="utf-8", + "---\nname: valid-skill\ndescription: demo\n---\n# Valid\n", + encoding="utf-8", ) return "installed" @@ -365,4 +368,3 @@ async def test_search_remote_skills(monkeypatch: pytest.MonkeyPatch) -> None: } ] assert captured["args"] == ["npx", "-y", "skills", "find", "web"] - diff --git a/backend/test/unit/agents/toolkits/buildin/test_install_skill.py b/backend/test/unit/agents/toolkits/buildin/test_install_skill.py index 9195e448..4e46000e 100644 --- a/backend/test/unit/agents/toolkits/buildin/test_install_skill.py +++ b/backend/test/unit/agents/toolkits/buildin/test_install_skill.py @@ -338,7 +338,7 @@ async def test_install_skill_git_with_skill_names_passes_admin_check(mock_pg): results=[{"slug": "test-skill", "success": True, "source_dir": Path("/tmp/test-skill")}], cleanup=MagicMock(), ) - with patch("yuxi.services.remote_skill_install_service.prepare_remote_skills_batch") as mock_prepare: + with patch("yuxi.agents.skills.remote_install.prepare_remote_skills_batch") as mock_prepare: mock_prepare.return_value = preparation with patch("yuxi.agents.skills.service.import_skill_dir") as mock_import: diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 259ed3ca..035c8c85 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -69,6 +69,7 @@ - 聊天附件新增 MinIO tmp 临时上传、可选 PDF/图片解析、确认后加入线程附件的流程;前端改为弹窗内上传、解析与确认。 - 标准化 Agent run/SSE 执行链路:run 创建时持久化输入消息并提交后入队,worker 统一写入 Redis Stream envelope,SSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复。 - 收敛后端模块边界:文档解析从 `plugins.parser` 移动到 `knowledge.parser`,内容审查从 `plugins.guard` 移动到 `services.guard`。 +- 收敛文件服务边界:文件预览判断抽为独立服务,Viewer 文件系统的 workspace 分支复用用户 workspace 服务,线程运行时上下文解析从泛化 `filesystem_service` 拆出为 agent runtime helper。 ---