diff --git a/backend/package/yuxi/services/filesystem_service.py b/backend/package/yuxi/services/filesystem_service.py index 0085a6c9..a6a1169b 100644 --- a/backend/package/yuxi/services/filesystem_service.py +++ b/backend/package/yuxi/services/filesystem_service.py @@ -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})() diff --git a/backend/package/yuxi/services/upload_utils.py b/backend/package/yuxi/services/upload_utils.py index 6a1b7902..589f27fb 100644 --- a/backend/package/yuxi/services/upload_utils.py +++ b/backend/package/yuxi/services/upload_utils.py @@ -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) diff --git a/backend/package/yuxi/services/viewer_filesystem_service.py b/backend/package/yuxi/services/viewer_filesystem_service.py index 21e4b240..fb28c911 100644 --- a/backend/package/yuxi/services/viewer_filesystem_service.py +++ b/backend/package/yuxi/services/viewer_filesystem_service.py @@ -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, ) diff --git a/backend/server/routers/filesystem_router.py b/backend/server/routers/filesystem_router.py index f4dfb5c9..80027944 100644 --- a/backend/server/routers/filesystem_router.py +++ b/backend/server/routers/filesystem_router.py @@ -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, ) diff --git a/web/src/apis/viewer_filesystem.js b/web/src/apis/viewer_filesystem.js index 385bc059..f13b6436 100644 --- a/web/src/apis/viewer_filesystem.js +++ b/web/src/apis/viewer_filesystem.js @@ -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) } diff --git a/web/src/utils/file_preview.js b/web/src/utils/file_preview.js index b9986f51..cc77422c 100644 --- a/web/src/utils/file_preview.js +++ b/web/src/utils/file_preview.js @@ -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 = { diff --git a/web/src/utils/file_utils.js b/web/src/utils/file_utils.js index 6d4b5555..70bcecfd 100644 --- a/web/src/utils/file_utils.js +++ b/web/src/utils/file_utils.js @@ -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) +} diff --git a/web/src/utils/upload_limits.js b/web/src/utils/upload_limits.js new file mode 100644 index 00000000..b3d95d99 --- /dev/null +++ b/web/src/utils/upload_limits.js @@ -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