refactor: Filesystem & Upload 代码重构

- filesystem_service 简化文件元数据处理
- viewer_filesystem_service 移除冗余逻辑
- 新增 upload_utils 上传工具模块
- filesystem_router 移除废弃端点
- 前端 viewer_filesystem.js 接口重构,新增 upload_limits.js 上传限制
- file_preview、file_utils 更新预览与工具函数
This commit is contained in:
Wenjie Zhang 2026-05-24 00:46:35 +08:00
parent d2859ab8cd
commit 42521bab63
8 changed files with 132 additions and 123 deletions

View File

@ -6,8 +6,9 @@ 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_config_repository import AgentConfigRepository
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
@ -17,29 +18,23 @@ async def _resolve_filesystem_context(
*,
db: AsyncSession,
user: User,
agent_id: str,
agent_config_id: int | None,
bound_agent_id: str,
) -> BaseContext:
context = BaseContext(thread_id="", uid=str(user.uid))
repo = AgentConfigRepository(db)
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="智能体不存在")
config_item = None
if agent_config_id is not None:
config_item = await repo.get_by_id(config_id=int(agent_config_id))
if config_item is not None and (config_item.uid != str(user.uid) or config_item.agent_id != agent_id):
config_item = None
if config_item is None:
config_item = await repo.get_or_create_default(
uid=str(user.uid),
agent_id=agent_id,
created_by=str(user.uid),
)
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(
(config_item.config_json or {}).get("context", {}),
(agent_item.config_json or {}).get("context", {}),
db=db,
user=user,
context_schema=context_schema,
)
context.update_from_dict(normalized_config)
return context
@ -50,8 +45,6 @@ async def _resolve_filesystem_state(
thread_id: str,
user: User,
db: AsyncSession,
agent_id: str | None,
agent_config_id: int | None,
):
conv_repo = ConversationRepository(db)
conversation = await require_user_conversation(conv_repo, thread_id, str(user.uid))
@ -59,8 +52,7 @@ async def _resolve_filesystem_state(
runtime_context = await _resolve_filesystem_context(
db=db,
user=user,
agent_id=agent_id or conversation.agent_id,
agent_config_id=agent_config_id,
bound_agent_id=conversation.agent_id,
)
runtime_context.thread_id = thread_id
runtime_context.uid = str(user.uid)
@ -73,8 +65,6 @@ async def list_filesystem_entries_view(
*,
thread_id: str,
path: str,
agent_id: str | None,
agent_config_id: int | None,
current_user: User,
db: AsyncSession,
) -> dict:
@ -87,8 +77,6 @@ async def list_filesystem_entries_view(
thread_id=thread_id,
user=current_user,
db=db,
agent_id=agent_id,
agent_config_id=agent_config_id,
)
runtime_stub = type("RuntimeStub", (), {"context": runtime_context})()
@ -107,8 +95,6 @@ async def read_file_content_view(
*,
thread_id: str,
path: str,
agent_id: str | None,
agent_config_id: int | None,
current_user: User,
db: AsyncSession,
) -> dict:
@ -123,8 +109,6 @@ async def read_file_content_view(
thread_id=thread_id,
user=current_user,
db=db,
agent_id=agent_id,
agent_config_id=agent_config_id,
)
runtime_stub = type("RuntimeStub", (), {"context": runtime_context})()

View File

@ -1,8 +1,11 @@
import uuid
from pathlib import Path
import aiofiles
from fastapi import UploadFile
from yuxi.storage.minio import aupload_file_to_minio
async def write_upload_to_buffer(
upload: UploadFile,
@ -24,6 +27,26 @@ async def write_upload_to_buffer(
return written
async def read_upload_with_limit(
upload: UploadFile,
*,
max_size_bytes: int,
too_large_message: str,
chunk_size: int = 1024 * 1024,
) -> bytes:
await upload.seek(0)
written = 0
chunks: list[bytes] = []
while chunk := await upload.read(chunk_size):
written += len(chunk)
if written > max_size_bytes:
raise ValueError(too_large_message)
chunks.append(chunk)
return b"".join(chunks)
async def write_upload_to_path(
upload: UploadFile,
dest: Path,
@ -41,3 +64,23 @@ 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

@ -334,8 +334,6 @@ def _list_user_data_root_entries(thread_id: str, uid: str) -> list[dict]:
async def _resolve_viewer_state(
*,
thread_id: str,
agent_id: str | None,
agent_config_id: int | None,
current_user: User,
db: AsyncSession,
):
@ -343,8 +341,6 @@ async def _resolve_viewer_state(
thread_id=thread_id,
user=current_user,
db=db,
agent_id=agent_id,
agent_config_id=agent_config_id,
)
selected_skills = getattr(runtime_context, "_readable_skills", [])
selected_skills = normalize_string_list(selected_skills if isinstance(selected_skills, list) else [])
@ -358,8 +354,6 @@ async def list_viewer_filesystem_tree(
*,
thread_id: str,
path: str,
agent_id: str | None,
agent_config_id: int | None,
current_user: User,
db: AsyncSession,
) -> dict:
@ -369,8 +363,6 @@ async def list_viewer_filesystem_tree(
normalized_path = _normalize_path(path)
sandbox_backend, skills_backend, selected_skills = await _resolve_viewer_state(
thread_id=thread_id,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)
@ -418,8 +410,6 @@ async def read_viewer_file_content(
*,
thread_id: str,
path: str,
agent_id: str | None,
agent_config_id: int | None,
current_user: User,
db: AsyncSession,
) -> dict:
@ -429,8 +419,6 @@ async def read_viewer_file_content(
sandbox_backend, skills_backend, _selected_skills = await _resolve_viewer_state(
thread_id=thread_id,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)
@ -512,16 +500,12 @@ async def download_viewer_file(
*,
thread_id: str,
path: str,
agent_id: str | None,
agent_config_id: int | None,
current_user: User,
db: AsyncSession,
) -> StreamingResponse:
normalized_path = _normalize_path(path)
sandbox_backend, skills_backend, _selected_skills = await _resolve_viewer_state(
thread_id=thread_id,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)
@ -577,8 +561,6 @@ async def delete_viewer_file(
*,
thread_id: str,
path: str,
agent_id: str | None,
agent_config_id: int | None,
current_user: User,
db: AsyncSession,
) -> dict:
@ -588,8 +570,6 @@ async def delete_viewer_file(
normalized_path = _normalize_path(path)
await _resolve_viewer_state(
thread_id=thread_id,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)
@ -620,8 +600,6 @@ async def create_viewer_directory(
thread_id: str,
parent_path: str,
name: str,
agent_id: str | None,
agent_config_id: int | None,
current_user: User,
db: AsyncSession,
) -> dict:
@ -630,8 +608,6 @@ async def create_viewer_directory(
await _resolve_viewer_state(
thread_id=thread_id,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)
@ -658,8 +634,6 @@ async def upload_viewer_file(
thread_id: str,
parent_path: str,
file: UploadFile,
agent_id: str | None,
agent_config_id: int | None,
current_user: User,
db: AsyncSession,
) -> dict:
@ -668,8 +642,6 @@ async def upload_viewer_file(
await _resolve_viewer_state(
thread_id=thread_id,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)

View File

@ -28,24 +28,18 @@ class CreateViewerDirectoryRequest(BaseModel):
thread_id: str
parent_path: str
name: str
agent_id: str | None = None
agent_config_id: int | None = None
@filesystem_router.get("/tree", response_model=dict)
async def get_viewer_tree(
thread_id: str = Query(..., description="线程 ID"),
path: str = Query("/", description="目录路径"),
agent_id: str | None = Query(None, description="智能体 ID"),
agent_config_id: int | None = Query(None, description="智能体配置 ID"),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
return await list_viewer_filesystem_tree(
thread_id=thread_id,
path=path,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)
@ -55,16 +49,12 @@ async def get_viewer_tree(
async def get_viewer_file(
thread_id: str = Query(..., description="线程 ID"),
path: str = Query(..., description="文件路径"),
agent_id: str | None = Query(None, description="智能体 ID"),
agent_config_id: int | None = Query(None, description="智能体配置 ID"),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
return await read_viewer_file_content(
thread_id=thread_id,
path=path,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)
@ -74,16 +64,12 @@ async def get_viewer_file(
async def delete_viewer_file_route(
thread_id: str = Query(..., description="线程 ID"),
path: str = Query(..., description="文件路径"),
agent_id: str | None = Query(None, description="智能体 ID"),
agent_config_id: int | None = Query(None, description="智能体配置 ID"),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
return await delete_viewer_file(
thread_id=thread_id,
path=path,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)
@ -99,8 +85,6 @@ async def create_viewer_directory_route(
thread_id=payload.thread_id,
parent_path=payload.parent_path,
name=payload.name,
agent_id=payload.agent_id,
agent_config_id=payload.agent_config_id,
current_user=current_user,
db=db,
)
@ -110,8 +94,6 @@ async def create_viewer_directory_route(
async def upload_viewer_file_route(
thread_id: str = Form(..., description="线程 ID"),
parent_path: str = Form(..., description="父目录路径"),
agent_id: str | None = Form(None, description="智能体 ID"),
agent_config_id: int | None = Form(None, description="智能体配置 ID"),
file: UploadFile = File(..., description="上传文件"),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
@ -120,8 +102,6 @@ async def upload_viewer_file_route(
thread_id=thread_id,
parent_path=parent_path,
file=file,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)
@ -131,16 +111,12 @@ async def upload_viewer_file_route(
async def download_viewer(
thread_id: str = Query(..., description="线程 ID"),
path: str = Query(..., description="文件路径"),
agent_id: str | None = Query(None, description="智能体 ID"),
agent_config_id: int | None = Query(None, description="智能体配置 ID"),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
return await download_viewer_file(
thread_id=thread_id,
path=path,
agent_id=agent_id,
agent_config_id=agent_config_id,
current_user=current_user,
db=db,
)

View File

@ -10,72 +10,45 @@ const buildQuery = (params) => {
return query.toString()
}
const buildViewerQuery = (threadId, path, agentId = null, agentConfigId = null) => {
const buildViewerQuery = (threadId, path) => {
return buildQuery({
thread_id: threadId,
path,
agent_id: agentId,
agent_config_id: agentConfigId
path
})
}
export const getViewerFileSystemTree = (
threadId,
path = '/',
agentId = null,
agentConfigId = null
) => {
const query = buildViewerQuery(threadId, path, agentId, agentConfigId)
export const getViewerFileSystemTree = (threadId, path = '/') => {
const query = buildViewerQuery(threadId, path)
return apiGet(`/api/viewer/filesystem/tree?${query}`)
}
export const getViewerFileContent = (threadId, path, agentId = null, agentConfigId = null) => {
const query = buildViewerQuery(threadId, path, agentId, agentConfigId)
export const getViewerFileContent = (threadId, path) => {
const query = buildViewerQuery(threadId, path)
return apiGet(`/api/viewer/filesystem/file?${query}`)
}
export const downloadViewerFile = (threadId, path, agentId = null, agentConfigId = null) => {
const query = buildViewerQuery(threadId, path, agentId, agentConfigId)
export const downloadViewerFile = (threadId, path) => {
const query = buildViewerQuery(threadId, path)
return apiGet(`/api/viewer/filesystem/download?${query}`, {}, true, 'blob')
}
export const deleteViewerFile = (threadId, path, agentId = null, agentConfigId = null) => {
const query = buildViewerQuery(threadId, path, agentId, agentConfigId)
export const deleteViewerFile = (threadId, path) => {
const query = buildViewerQuery(threadId, path)
return apiDelete(`/api/viewer/filesystem/file?${query}`)
}
export const createViewerDirectory = (
threadId,
parentPath,
name,
agentId = null,
agentConfigId = null
) => {
export const createViewerDirectory = (threadId, parentPath, name) => {
return apiPost('/api/viewer/filesystem/directory', {
thread_id: threadId,
parent_path: parentPath,
name,
agent_id: agentId,
agent_config_id: agentConfigId
name
})
}
export const uploadViewerFile = (
threadId,
parentPath,
file,
agentId = null,
agentConfigId = null
) => {
export const uploadViewerFile = (threadId, parentPath, file) => {
const formData = new FormData()
formData.set('thread_id', threadId)
formData.set('parent_path', parentPath)
if (agentId !== undefined && agentId !== null && agentId !== '') {
formData.set('agent_id', agentId)
}
if (agentConfigId !== undefined && agentConfigId !== null && agentConfigId !== '') {
formData.set('agent_config_id', agentConfigId)
}
formData.set('file', file)
return apiPost('/api/viewer/filesystem/upload', formData)
}

View File

@ -1,5 +1,15 @@
const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdx'])
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg'])
const IMAGE_EXTENSIONS = new Set([
'.apng',
'.avif',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.bmp',
'.webp',
'.svg'
])
const PDF_EXTENSIONS = new Set(['.pdf'])
const HTML_EXTENSIONS = new Set(['.html', '.htm'])
const CODE_LANGUAGE_ALIASES = {

View File

@ -11,6 +11,7 @@ import {
LinkOutlined,
CodeFilled
} from '@ant-design/icons-vue'
import { getPreviewFileExtension, getPreviewTypeByPath } from '@/utils/file_preview'
import { formatRelative, parseToShanghai } from '@/utils/time'
const DEFAULT_FILE_ICON = { icon: FileFilled, color: 'var(--gray-600)' }
@ -30,6 +31,8 @@ const FILE_ICON_CONFIG = {
csv: { icon: FileExcelFilled, color: 'var(--color-success-500)' },
ppt: { icon: FilePptFilled, color: 'var(--color-warning-700)' },
pptx: { icon: FilePptFilled, color: 'var(--color-warning-700)' },
apng: { icon: FileImageFilled, color: 'var(--color-accent-700)' },
avif: { icon: FileImageFilled, color: 'var(--color-accent-700)' },
jpg: { icon: FileImageFilled, color: 'var(--color-accent-700)' },
jpeg: { icon: FileImageFilled, color: 'var(--color-accent-700)' },
png: { icon: FileImageFilled, color: 'var(--color-accent-700)' },
@ -110,3 +113,49 @@ export const formatFileSize = (bytes) => {
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
export const getDisplayFileName = (pathOrName, fallback = '文件') => {
const value = String(pathOrName || '').trim()
if (!value) return fallback
return value.split('/').pop() || value || fallback
}
export const getFileExtensionLabel = (pathOrName) => {
const extension = getPreviewFileExtension(pathOrName).replace(/^\./, '')
return extension ? extension.toUpperCase() : ''
}
export const getMimeSubtypeLabel = (mimeType) => {
const subtype = String(mimeType || '')
.split('/')
.pop()
?.trim()
return subtype ? subtype.toUpperCase() : ''
}
export const normalizeAttachmentPreview = (attachment) => {
const name = getDisplayFileName(
attachment?.file_name || attachment?.name || attachment?.path,
'附件'
)
const fileId = attachment?.file_id || attachment?.path || name
const fileType = String(attachment?.file_type || '')
const sizeLabel = formatFileSize(attachment?.file_size)
const typeLabel = getFileExtensionLabel(name) || getMimeSubtypeLabel(fileType) || '文件'
return {
raw: attachment,
fileId,
name,
previewUrl: attachment?.original_artifact_url || attachment?.artifact_url || '',
isImage: fileType.startsWith('image/') || getPreviewTypeByPath(name) === 'image',
meta: [typeLabel, sizeLabel === '-' ? '' : sizeLabel].filter(Boolean).join(' · '),
icon: getFileIcon(name),
iconColor: getFileIconColor(name)
}
}
export const normalizeAttachmentPreviews = (attachments) => {
if (!Array.isArray(attachments)) return []
return attachments.map(normalizeAttachmentPreview).filter((attachment) => attachment.fileId)
}

View File

@ -0,0 +1,2 @@
export const MAX_IMAGE_UPLOAD_SIZE_MB = 5
export const MAX_IMAGE_UPLOAD_SIZE_BYTES = MAX_IMAGE_UPLOAD_SIZE_MB * 1024 * 1024