feat: 优化当前的文件系统的解析

This commit is contained in:
Wenjie Zhang 2026-05-30 14:59:03 +08:00
parent 5322b21c32
commit 932f990841
23 changed files with 289 additions and 372 deletions

View File

@ -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())

View File

@ -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,

View File

@ -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,

View File

@ -1 +0,0 @@
__all__: list[str] = []

View 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

View File

@ -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

View File

@ -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

View 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, "当前文件不是可读文本,暂不支持预览"

View File

@ -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}

View File

@ -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)

View File

@ -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:

View File

@ -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",
]

View File

@ -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)

View File

@ -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)

View File

@ -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 认证相关导入

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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:

View File

@ -69,6 +69,7 @@
- 聊天附件新增 MinIO tmp 临时上传、可选 PDF/图片解析、确认后加入线程附件的流程;前端改为弹窗内上传、解析与确认。
- 标准化 Agent run/SSE 执行链路run 创建时持久化输入消息并提交后入队worker 统一写入 Redis Stream envelopeSSE 输出 `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。
---