feat: 优化当前的文件系统的解析
This commit is contained in:
parent
5322b21c32
commit
932f990841
@ -829,7 +829,7 @@ async def prepare_remote_skill_install(
|
|||||||
skills: list[str],
|
skills: list[str],
|
||||||
operator: User,
|
operator: User,
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
repo = SkillRepository(db)
|
||||||
draft_dir = get_skill_drafts_root_dir() / str(uuid.uuid4())
|
draft_dir = get_skill_drafts_root_dir() / str(uuid.uuid4())
|
||||||
|
|||||||
@ -146,7 +146,7 @@
|
|||||||
# skill_names: list[str] | None = None,
|
# skill_names: list[str] | None = None,
|
||||||
# ) -> Command:
|
# ) -> 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 (
|
# from yuxi.agents.skills.service import (
|
||||||
# import_skill_dir,
|
# import_skill_dir,
|
||||||
# normalize_string_list,
|
# 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:
|
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)
|
file_meta = self._get_file_meta(kb_id, file_id)
|
||||||
if file_meta.get("is_folder"):
|
if file_meta.get("is_folder"):
|
||||||
@ -590,7 +590,7 @@ class KnowledgeBase(ABC):
|
|||||||
"message": "文件没有可预览的原始内容",
|
"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"}:
|
if preview_type in {"image", "pdf"}:
|
||||||
return {
|
return {
|
||||||
**response,
|
**response,
|
||||||
@ -601,7 +601,7 @@ class KnowledgeBase(ABC):
|
|||||||
}
|
}
|
||||||
|
|
||||||
raw_content = await self._read_minio_bytes(original_path)
|
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:
|
if preview_type in {"image", "pdf"} or not supported:
|
||||||
return {
|
return {
|
||||||
**response,
|
**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.buildin import agent_manager
|
||||||
from yuxi.agents.context import normalize_agent_context_config
|
from yuxi.agents.context import normalize_agent_context_config
|
||||||
from yuxi.agents.state import AgentStatePayload
|
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.agent_repository import AgentRepository
|
||||||
from yuxi.repositories.conversation_repository import ConversationRepository
|
from yuxi.repositories.conversation_repository import ConversationRepository
|
||||||
from yuxi.services.conversation_service import serialize_attachment
|
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.agent_repository import AgentRepository
|
||||||
from yuxi.repositories.conversation_repository import ConversationRepository
|
from yuxi.repositories.conversation_repository import ConversationRepository
|
||||||
from yuxi.services.mention_search_service import invalidate_mention_cache
|
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.minio import StorageError, get_minio_client
|
||||||
from yuxi.storage.postgres.models_business import User
|
from yuxi.storage.postgres.models_business import User
|
||||||
from yuxi.utils.datetime_utils import utc_isoformat
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import io
|
import io
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
from fastapi import HTTPException, UploadFile
|
from fastapi import HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.backends.skills_backend import SelectedSkillsReadonlyBackend
|
||||||
from yuxi.agents.skills.service import normalize_string_list
|
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.storage.postgres.models_business import User
|
||||||
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
|
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
|
||||||
from yuxi.utils.paths import VIRTUAL_PATH_OUTPUTS, VIRTUAL_PATH_UPLOADS, VIRTUAL_PATH_WORKSPACE
|
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(
|
_PROTECTED_USER_DATA_ROOTS = frozenset(
|
||||||
{
|
{
|
||||||
VIRTUAL_PATH_WORKSPACE,
|
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:
|
def _normalize_path(path: str | None) -> str:
|
||||||
normalized = (path or "/").strip() or "/"
|
normalized = (path or "/").strip() or "/"
|
||||||
if not normalized.startswith("/"):
|
if not normalized.startswith("/"):
|
||||||
@ -247,45 +157,36 @@ def _list_local_entries(thread_id: str, uid: str, actual_path) -> list[dict]:
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def _validate_child_name(name: str, *, field_name: str) -> str:
|
def _workspace_relative_path(path: str) -> str:
|
||||||
clean_name = str(name or "").strip()
|
if path == VIRTUAL_PATH_WORKSPACE:
|
||||||
if not clean_name:
|
return "/"
|
||||||
raise HTTPException(status_code=422, detail=f"{field_name} 不能为空")
|
if not path.startswith(f"{VIRTUAL_PATH_WORKSPACE}/"):
|
||||||
if clean_name in {".", ".."} or "/" in clean_name or "\\" in clean_name:
|
raise HTTPException(status_code=400, detail="当前路径不是工作区路径")
|
||||||
raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符")
|
return path[len(VIRTUAL_PATH_WORKSPACE) :] or "/"
|
||||||
if PurePosixPath(clean_name).name != clean_name:
|
|
||||||
raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符")
|
|
||||||
return clean_name
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_workspace_parent_dir(thread_id: str, uid: str, parent_path: str) -> Path:
|
def _viewer_entry_from_workspace_entry(entry: dict) -> dict:
|
||||||
normalized_parent = _normalize_path(parent_path)
|
path = str(entry.get("virtual_path") or "")
|
||||||
if not _is_workspace_path(normalized_parent):
|
if not path:
|
||||||
raise HTTPException(status_code=400, detail="当前路径不支持写入")
|
workspace_path = str(entry.get("path") or "/")
|
||||||
|
path = VIRTUAL_PATH_WORKSPACE if workspace_path == "/" else f"{VIRTUAL_PATH_WORKSPACE}{workspace_path}"
|
||||||
ensure_thread_dirs(thread_id, uid)
|
is_dir = bool(entry.get("is_dir", False))
|
||||||
try:
|
if is_dir and not path.endswith("/"):
|
||||||
actual_parent = _resolve_local_user_data_path(thread_id, uid, normalized_parent)
|
path = f"{path}/"
|
||||||
except ValueError as exc:
|
return {
|
||||||
# workspace 写入边界按真实路径校验,软链接逃逸应表现为权限拒绝。
|
"path": path,
|
||||||
if "path traversal" in str(exc):
|
"name": str(entry.get("name", "") or PurePosixPath(path.rstrip("/")).name or path),
|
||||||
raise HTTPException(status_code=403, detail="Access denied") from exc
|
"is_dir": is_dir,
|
||||||
raise
|
"size": int(entry.get("size", 0) or 0),
|
||||||
if not actual_parent.exists():
|
"modified_at": str(entry.get("modified_at", "") or ""),
|
||||||
raise HTTPException(status_code=404, detail="目标目录不存在")
|
}
|
||||||
if not actual_parent.is_dir():
|
|
||||||
raise HTTPException(status_code=400, detail="目标路径不是目录")
|
|
||||||
return actual_parent
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_new_workspace_child(thread_id: str, uid: str, parent_path: Path, name: str) -> Path:
|
def _viewer_response_from_workspace_response(response: dict) -> dict:
|
||||||
target_path = parent_path / name
|
result = {**response}
|
||||||
workspace_root = sandbox_workspace_dir(thread_id, uid).resolve()
|
if "entry" in result and isinstance(result["entry"], dict):
|
||||||
if not _is_path_within(target_path.resolve(strict=False), workspace_root):
|
result["entry"] = _viewer_entry_from_workspace_entry(result["entry"])
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
return result
|
||||||
if target_path.exists():
|
|
||||||
raise HTTPException(status_code=400, detail="同名文件或文件夹已存在")
|
|
||||||
return target_path
|
|
||||||
|
|
||||||
|
|
||||||
def _list_user_data_root_entries(thread_id: str, uid: str) -> list[dict]:
|
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,
|
current_user: User,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
):
|
):
|
||||||
runtime_context = await _resolve_filesystem_state(
|
runtime_context = await resolve_thread_agent_runtime_context(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
db=db,
|
db=db,
|
||||||
@ -361,6 +262,13 @@ async def list_viewer_filesystem_tree(
|
|||||||
if _is_user_data_path(normalized_path):
|
if _is_user_data_path(normalized_path):
|
||||||
uid = str(current_user.uid)
|
uid = str(current_user.uid)
|
||||||
ensure_thread_dirs(thread_id, 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:
|
if normalized_path == USER_DATA_PATH:
|
||||||
entries = await asyncio.to_thread(_list_user_data_root_entries, thread_id, uid)
|
entries = await asyncio.to_thread(_list_user_data_root_entries, thread_id, uid)
|
||||||
return {"entries": _sort_entries(entries)}
|
return {"entries": _sort_entries(entries)}
|
||||||
@ -403,13 +311,18 @@ async def read_viewer_file_content(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if _is_user_data_path(normalized_path):
|
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)
|
actual_path = _resolve_local_user_data_path(thread_id, str(current_user.uid), normalized_path)
|
||||||
if not actual_path.exists():
|
if not actual_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="文件不存在")
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
if not actual_path.is_file():
|
if not actual_path.is_file():
|
||||||
raise HTTPException(status_code=400, detail="当前路径是目录")
|
raise HTTPException(status_code=400, detail="当前路径是目录")
|
||||||
raw_content = await asyncio.to_thread(actual_path.read_bytes)
|
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:
|
if preview_type in {"image", "pdf"} or not supported:
|
||||||
return {
|
return {
|
||||||
"content": None,
|
"content": None,
|
||||||
@ -447,7 +360,7 @@ async def read_viewer_file_content(
|
|||||||
raise HTTPException(status_code=400, detail=str(response.error))
|
raise HTTPException(status_code=400, detail=str(response.error))
|
||||||
|
|
||||||
raw_content = response.content or b""
|
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"}:
|
if preview_type in {"image", "pdf"}:
|
||||||
return {
|
return {
|
||||||
@ -480,7 +393,7 @@ async def download_viewer_file(
|
|||||||
path: str,
|
path: str,
|
||||||
current_user: User,
|
current_user: User,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse | FileResponse:
|
||||||
normalized_path = _normalize_path(path)
|
normalized_path = _normalize_path(path)
|
||||||
sandbox_backend, skills_backend, _selected_skills = await _resolve_viewer_state(
|
sandbox_backend, skills_backend, _selected_skills = await _resolve_viewer_state(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
@ -490,6 +403,11 @@ async def download_viewer_file(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if _is_user_data_path(normalized_path):
|
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)
|
actual_path = _resolve_local_user_data_path(thread_id, str(current_user.uid), normalized_path)
|
||||||
if not actual_path.exists():
|
if not actual_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="文件不存在")
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
@ -558,6 +476,9 @@ async def delete_viewer_file(
|
|||||||
raise HTTPException(status_code=400, detail="当前目录不允许删除")
|
raise HTTPException(status_code=400, detail="当前目录不允许删除")
|
||||||
|
|
||||||
try:
|
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)
|
actual_path = _resolve_local_user_data_path(thread_id, str(current_user.uid), normalized_path)
|
||||||
if not actual_path.exists():
|
if not actual_path.exists():
|
||||||
raise HTTPException(status_code=404, detail="文件不存在")
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
@ -590,21 +511,16 @@ async def create_viewer_directory(
|
|||||||
db=db,
|
db=db,
|
||||||
)
|
)
|
||||||
|
|
||||||
uid = str(current_user.uid)
|
normalized_parent = _normalize_path(parent_path)
|
||||||
directory_name = _validate_child_name(name, field_name="文件夹名")
|
if not _is_workspace_path(normalized_parent):
|
||||||
|
raise HTTPException(status_code=400, detail="当前路径不支持写入")
|
||||||
|
|
||||||
try:
|
response = await create_workspace_directory_entry(
|
||||||
actual_parent = _resolve_workspace_parent_dir(thread_id, uid, parent_path)
|
parent_path=_workspace_relative_path(normalized_parent),
|
||||||
target_path = _resolve_new_workspace_child(thread_id, uid, actual_parent, directory_name)
|
name=name,
|
||||||
await asyncio.to_thread(target_path.mkdir)
|
current_user=current_user,
|
||||||
except FileExistsError as e:
|
)
|
||||||
raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from e
|
return _viewer_response_from_workspace_response(response)
|
||||||
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)}
|
|
||||||
|
|
||||||
|
|
||||||
async def upload_viewer_file(
|
async def upload_viewer_file(
|
||||||
@ -624,30 +540,13 @@ async def upload_viewer_file(
|
|||||||
db=db,
|
db=db,
|
||||||
)
|
)
|
||||||
|
|
||||||
uid = str(current_user.uid)
|
normalized_parent = _normalize_path(parent_path)
|
||||||
file_name = _validate_child_name(Path(file.filename or "").name, field_name="文件名")
|
if not _is_workspace_path(normalized_parent):
|
||||||
target_path: Path | None = None
|
raise HTTPException(status_code=400, detail="当前路径不支持写入")
|
||||||
created_file = False
|
|
||||||
upload_completed = False
|
|
||||||
|
|
||||||
try:
|
response = await upload_workspace_file_entry(
|
||||||
actual_parent = _resolve_workspace_parent_dir(thread_id, uid, parent_path)
|
parent_path=_workspace_relative_path(normalized_parent),
|
||||||
target_path = _resolve_new_workspace_child(thread_id, uid, actual_parent, file_name)
|
file=file,
|
||||||
async with aiofiles.open(target_path, "xb") as buffer:
|
current_user=current_user,
|
||||||
created_file = True
|
)
|
||||||
while chunk := await file.read(1024 * 1024):
|
return _viewer_response_from_workspace_response(response)
|
||||||
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)}
|
|
||||||
|
|||||||
@ -12,8 +12,8 @@ import aiofiles
|
|||||||
from fastapi import HTTPException, UploadFile
|
from fastapi import HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from yuxi.agents.backends.sandbox.paths import _global_user_data_dir, ensure_workspace_default_files
|
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.file_preview import detect_preview_type
|
||||||
from yuxi.services.viewer_filesystem_service 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.storage.postgres.models_business import User
|
||||||
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
|
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
|
||||||
from yuxi.utils.paths import VIRTUAL_PATH_WORKSPACE, WORKSPACE_DIR_NAME
|
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="当前路径是目录")
|
raise HTTPException(status_code=400, detail="当前路径是目录")
|
||||||
|
|
||||||
raw_content = await asyncio.to_thread(target.read_bytes)
|
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:
|
if preview_type in {"image", "pdf"} or not supported:
|
||||||
return {
|
return {
|
||||||
"content": None,
|
"content": None,
|
||||||
@ -191,7 +191,7 @@ async def write_workspace_file_content(*, path: str, content: str, current_user:
|
|||||||
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
|
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
|
||||||
|
|
||||||
raw_content = await asyncio.to_thread(target.read_bytes)
|
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:
|
if preview_type not in {"markdown", "text"} or not supported:
|
||||||
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
|
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -5,7 +5,7 @@ MinIO 存储模块
|
|||||||
|
|
||||||
# 导出核心功能
|
# 导出核心功能
|
||||||
from .client import MinIOClient, StorageError, UploadResult, aupload_file_to_minio, get_minio_client
|
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__ = [
|
__all__ = [
|
||||||
@ -19,4 +19,5 @@ __all__ = [
|
|||||||
# 工具函数
|
# 工具函数
|
||||||
"get_file_size",
|
"get_file_size",
|
||||||
"generate_unique_filename",
|
"generate_unique_filename",
|
||||||
|
"upload_image_to_minio",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -4,6 +4,13 @@ MinIO 存储工具函数
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
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:
|
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:
|
def generate_unique_filename(original_name: str) -> str:
|
||||||
"""生成唯一的文件名"""
|
"""生成唯一的文件名"""
|
||||||
import uuid
|
|
||||||
|
|
||||||
name_parts = original_name.rsplit(".", 1)
|
name_parts = original_name.rsplit(".", 1)
|
||||||
base_name = name_parts[0] if len(name_parts) == 2 else original_name
|
base_name = name_parts[0] if len(name_parts) == 2 else original_name
|
||||||
extension = f".{name_parts[1]}" if len(name_parts) == 2 else ""
|
extension = f".{name_parts[1]}" if len(name_parts) == 2 else ""
|
||||||
return f"{base_name}_{uuid.uuid4().hex[:8]}{extension}"
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
|
|
||||||
from yuxi.storage.minio import aupload_file_to_minio
|
|
||||||
|
|
||||||
MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024
|
MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
@ -66,23 +63,3 @@ async def write_upload_to_path(
|
|||||||
too_large_message=too_large_message,
|
too_large_message=too_large_message,
|
||||||
chunk_size=chunk_size,
|
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.utils.auth_utils import AuthUtils
|
||||||
from yuxi.services.user_identity_service import generate_unique_uid, validate_username, is_valid_phone_number
|
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.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
|
from yuxi.utils.datetime_utils import utc_now_naive
|
||||||
|
|
||||||
# OIDC 认证相关导入
|
# 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.knowledge.utils.url_fetcher import fetch_url_content
|
||||||
from yuxi.models.providers.cache import model_cache
|
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.services.workspace_service import MAX_WORKSPACE_UPLOAD_SIZE_BYTES, resolve_workspace_file_path
|
||||||
from yuxi.storage.postgres.models_business import User
|
from yuxi.storage.postgres.models_business import User
|
||||||
from yuxi.storage.minio.client import MinIOClient, StorageError, aupload_file_to_minio, get_minio_client
|
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_file,
|
||||||
update_skill_share_config,
|
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.storage.postgres.models_business import User
|
||||||
from yuxi.utils.logging_config import logger
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from server.utils.auth_middleware import get_db, get_required_user
|
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.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
|
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
|
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:
|
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 = home / ".agents" / "skills"
|
||||||
(skill_dir_base / "skill-a").mkdir(parents=True, exist_ok=True)
|
(skill_dir_base / "skill-a").mkdir(parents=True, exist_ok=True)
|
||||||
(skill_dir_base / "skill-a" / "SKILL.md").write_text(
|
(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-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").mkdir(parents=True, exist_ok=True)
|
||||||
(skill_dir_base / "skill-c" / "SKILL.md").write_text(
|
(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"
|
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 = home / ".agents" / "skills"
|
||||||
(skill_dir_base / "valid-skill").mkdir(parents=True, exist_ok=True)
|
(skill_dir_base / "valid-skill").mkdir(parents=True, exist_ok=True)
|
||||||
(skill_dir_base / "valid-skill" / "SKILL.md").write_text(
|
(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"
|
return "installed"
|
||||||
|
|
||||||
@ -365,4 +368,3 @@ async def test_search_remote_skills(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert captured["args"] == ["npx", "-y", "skills", "find", "web"]
|
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")}],
|
results=[{"slug": "test-skill", "success": True, "source_dir": Path("/tmp/test-skill")}],
|
||||||
cleanup=MagicMock(),
|
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
|
mock_prepare.return_value = preparation
|
||||||
|
|
||||||
with patch("yuxi.agents.skills.service.import_skill_dir") as mock_import:
|
with patch("yuxi.agents.skills.service.import_skill_dir") as mock_import:
|
||||||
|
|||||||
@ -69,6 +69,7 @@
|
|||||||
- 聊天附件新增 MinIO tmp 临时上传、可选 PDF/图片解析、确认后加入线程附件的流程;前端改为弹窗内上传、解析与确认。
|
- 聊天附件新增 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 恢复。
|
- 标准化 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`。
|
- 收敛后端模块边界:文档解析从 `plugins.parser` 移动到 `knowledge.parser`,内容审查从 `plugins.guard` 移动到 `services.guard`。
|
||||||
|
- 收敛文件服务边界:文件预览判断抽为独立服务,Viewer 文件系统的 workspace 分支复用用户 workspace 服务,线程运行时上下文解析从泛化 `filesystem_service` 拆出为 agent runtime helper。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user