feat: 优化当前的文件系统的解析
This commit is contained in:
parent
5322b21c32
commit
932f990841
@ -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())
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1 +0,0 @@
|
||||
__all__: list[str] = []
|
||||
56
backend/package/yuxi/services/agent_runtime_service.py
Normal file
56
backend/package/yuxi/services/agent_runtime_service.py
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
102
backend/package/yuxi/services/file_preview.py
Normal file
102
backend/package/yuxi/services/file_preview.py
Normal file
@ -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, "当前文件不是可读文本,暂不支持预览"
|
||||
@ -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}
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
@ -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 认证相关导入
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user