diff --git a/backend/package/yuxi/services/viewer_filesystem_service.py b/backend/package/yuxi/services/viewer_filesystem_service.py index e28bec0b..3b99e7ad 100644 --- a/backend/package/yuxi/services/viewer_filesystem_service.py +++ b/backend/package/yuxi/services/viewer_filesystem_service.py @@ -38,7 +38,7 @@ from yuxi.services.workspace_service import ( read_workspace_file_content as read_workspace_file_content_response, ) from yuxi.services.workspace_service import ( - upload_workspace_file as upload_workspace_file_entry, + upload_workspace_files as upload_workspace_files_entry, ) from yuxi.storage.postgres.models_business import User from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp @@ -195,6 +195,10 @@ 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"]) + if "entries" in result and isinstance(result["entries"], list): + result["entries"] = [ + _viewer_entry_from_workspace_entry(entry) for entry in result["entries"] if isinstance(entry, dict) + ] return result @@ -534,11 +538,11 @@ async def create_viewer_directory( return _viewer_response_from_workspace_response(response) -async def upload_viewer_file( +async def upload_viewer_files( *, thread_id: str, parent_path: str, - file: UploadFile, + files: list[UploadFile], current_user: User, db: AsyncSession, ) -> dict: @@ -555,9 +559,9 @@ async def upload_viewer_file( if not _is_workspace_path(normalized_parent): raise HTTPException(status_code=400, detail="当前路径不支持写入") - response = await upload_workspace_file_entry( + response = await upload_workspace_files_entry( parent_path=_workspace_relative_path(normalized_parent), - file=file, + files=files, current_user=current_user, ) return _viewer_response_from_workspace_response(response) diff --git a/backend/package/yuxi/services/workspace_service.py b/backend/package/yuxi/services/workspace_service.py index eedbcbd5..3bb4eefe 100644 --- a/backend/package/yuxi/services/workspace_service.py +++ b/backend/package/yuxi/services/workspace_service.py @@ -21,6 +21,7 @@ from yuxi.utils.upload_utils import MAX_UPLOAD_SIZE_BYTES, write_upload_to_buffe EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"} MAX_WORKSPACE_UPLOAD_SIZE_BYTES = MAX_UPLOAD_SIZE_BYTES +MAX_WORKSPACE_UPLOAD_FILES = 50 def _workspace_root(user: User) -> Path: @@ -249,11 +250,7 @@ async def create_workspace_directory(*, parent_path: str, name: str, current_use return {"success": True, "entry": _entry_for_path(root, target)} -async def upload_workspace_file(*, parent_path: str, file: UploadFile, current_user: User) -> dict: - root = _workspace_root(current_user) - file_name = _validate_child_name(Path(file.filename or "").name, field_name="文件名") - parent = _resolve_parent_directory(current_user, parent_path) - target = _resolve_new_child(root, parent, file_name) +async def _write_workspace_upload(file: UploadFile, target: Path) -> None: created_file = False upload_completed = False @@ -278,8 +275,38 @@ async def upload_workspace_file(*, parent_path: str, file: UploadFile, current_u with contextlib.suppress(OSError): await asyncio.to_thread(target.unlink) + +async def upload_workspace_files(*, parent_path: str, files: list[UploadFile], current_user: User) -> dict: + if not files: + raise HTTPException(status_code=400, detail="请选择至少一个文件") + if len(files) > MAX_WORKSPACE_UPLOAD_FILES: + raise HTTPException(status_code=400, detail=f"一次最多上传 {MAX_WORKSPACE_UPLOAD_FILES} 个文件") + + root = _workspace_root(current_user) + parent = _resolve_parent_directory(current_user, parent_path) + seen_names = set() + upload_targets: list[tuple[UploadFile, Path]] = [] + + for file in files: + file_name = _validate_child_name(Path(file.filename or "").name, field_name="文件名") + if file_name in seen_names: + raise HTTPException(status_code=400, detail=f"选择的文件中存在重复文件名: {file_name}") + seen_names.add(file_name) + upload_targets.append((file, _resolve_new_child(root, parent, file_name))) + + completed_targets: list[Path] = [] + try: + for file, target in upload_targets: + await _write_workspace_upload(file, target) + completed_targets.append(target) + except HTTPException: + for target in completed_targets: + with contextlib.suppress(OSError): + await asyncio.to_thread(target.unlink) + raise + await invalidate_workspace_mention_cache(str(current_user.uid)) - return {"success": True, "entry": _entry_for_path(root, target)} + return {"success": True, "entries": [_entry_for_path(root, target) for _file, target in upload_targets]} async def download_workspace_file(*, path: str, current_user: User) -> StreamingResponse | FileResponse: diff --git a/backend/server/routers/filesystem_router.py b/backend/server/routers/filesystem_router.py index 80027944..60a1ad9f 100644 --- a/backend/server/routers/filesystem_router.py +++ b/backend/server/routers/filesystem_router.py @@ -17,7 +17,7 @@ from yuxi.services.viewer_filesystem_service import ( download_viewer_file, list_viewer_filesystem_tree, read_viewer_file_content, - upload_viewer_file, + upload_viewer_files, ) from yuxi.storage.postgres.models_business import User @@ -91,17 +91,17 @@ async def create_viewer_directory_route( @filesystem_router.post("/upload", response_model=dict) -async def upload_viewer_file_route( +async def upload_viewer_files_route( thread_id: str = Form(..., description="线程 ID"), parent_path: str = Form(..., description="父目录路径"), - file: UploadFile = File(..., description="上传文件"), + files: list[UploadFile] = File(..., description="上传文件列表"), current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - return await upload_viewer_file( + return await upload_viewer_files( thread_id=thread_id, parent_path=parent_path, - file=file, + files=files, current_user=current_user, db=db, ) diff --git a/backend/server/routers/workspace_router.py b/backend/server/routers/workspace_router.py index 6d67b435..c29e97ef 100644 --- a/backend/server/routers/workspace_router.py +++ b/backend/server/routers/workspace_router.py @@ -15,7 +15,7 @@ from yuxi.services.workspace_service import ( download_workspace_file, list_workspace_tree, read_workspace_file_content, - upload_workspace_file, + upload_workspace_files, write_workspace_file_content, ) from yuxi.storage.postgres.models_business import User @@ -164,12 +164,12 @@ async def create_workspace_directory_route( @workspace.post("/upload", response_model=dict) -async def upload_workspace_file_route( +async def upload_workspace_files_route( parent_path: str = Form(..., description="父目录路径"), - file: UploadFile = File(..., description="上传文件"), + files: list[UploadFile] = File(..., description="上传文件列表"), current_user: User = Depends(get_required_user), ): - return await upload_workspace_file(parent_path=parent_path, file=file, current_user=current_user) + return await upload_workspace_files(parent_path=parent_path, files=files, current_user=current_user) @workspace.get("/download") diff --git a/backend/test/e2e/test_viewer_filesystem_e2e.py b/backend/test/e2e/test_viewer_filesystem_e2e.py index fe524a7e..72096ec5 100644 --- a/backend/test/e2e/test_viewer_filesystem_e2e.py +++ b/backend/test/e2e/test_viewer_filesystem_e2e.py @@ -128,7 +128,7 @@ async def _upload( response = await client.post( "/api/viewer/filesystem/upload", data={"thread_id": thread_id, "parent_path": parent_path, "agent_id": agent_id}, - files={"file": (file_name, content, "text/plain")}, + files={"files": (file_name, content, "text/plain")}, headers=headers, ) assert response.status_code == 200, response.text diff --git a/backend/test/integration/api/test_viewer_filesystem_router.py b/backend/test/integration/api/test_viewer_filesystem_router.py index 546ad7ed..ac59a055 100644 --- a/backend/test/integration/api/test_viewer_filesystem_router.py +++ b/backend/test/integration/api/test_viewer_filesystem_router.py @@ -542,13 +542,13 @@ async def test_viewer_upload_file_writes_to_workspace_subdirectory(test_client, response = await test_client.post( "/api/viewer/filesystem/upload", data={"thread_id": thread_id, "parent_path": "/home/gem/user-data/workspace/upload-target"}, - files={"file": ("uploaded.txt", b"uploaded from viewer\n", "text/plain")}, + files={"files": ("uploaded.txt", b"uploaded from viewer\n", "text/plain")}, headers=headers, ) assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True - assert payload["entry"]["path"] == "/home/gem/user-data/workspace/upload-target/uploaded.txt" + assert payload["entries"][0]["path"] == "/home/gem/user-data/workspace/upload-target/uploaded.txt" file_response = await test_client.get( "/api/viewer/filesystem/file", @@ -592,7 +592,7 @@ async def test_viewer_upload_file_rejects_conflict_without_overwrite(test_client response = await test_client.post( "/api/viewer/filesystem/upload", data={"thread_id": thread_id, "parent_path": "/home/gem/user-data/workspace"}, - files={"file": ("existing.txt", b"replace me\n", "text/plain")}, + files={"files": ("existing.txt", b"replace me\n", "text/plain")}, headers=headers, ) assert response.status_code == 400, response.text @@ -623,7 +623,7 @@ async def test_viewer_write_rejects_non_workspace_paths(test_client, standard_us upload_response = await test_client.post( "/api/viewer/filesystem/upload", data={"thread_id": thread_id, "parent_path": parent_path}, - files={"file": ("blocked.txt", b"blocked", "text/plain")}, + files={"files": ("blocked.txt", b"blocked", "text/plain")}, headers=headers, ) assert upload_response.status_code == 400, upload_response.text diff --git a/backend/test/integration/api/test_viewer_filesystem_security.py b/backend/test/integration/api/test_viewer_filesystem_security.py index 5e0208ef..a9deba60 100644 --- a/backend/test/integration/api/test_viewer_filesystem_security.py +++ b/backend/test/integration/api/test_viewer_filesystem_security.py @@ -70,7 +70,7 @@ async def test_viewer_upload_blocks_workspace_symlink_escape(test_client, standa response = await test_client.post( "/api/viewer/filesystem/upload", data={"thread_id": thread_id, "parent_path": parent_path}, - files={"file": ("escape.txt", b"outside", "text/plain")}, + files={"files": ("escape.txt", b"outside", "text/plain")}, headers=headers, ) diff --git a/backend/test/unit/services/test_workspace_service.py b/backend/test/unit/services/test_workspace_service.py index dd1ebfca..07b00a25 100644 --- a/backend/test/unit/services/test_workspace_service.py +++ b/backend/test/unit/services/test_workspace_service.py @@ -155,22 +155,26 @@ async def test_write_workspace_file_content_blocks_path_traversal(tmp_path: Path @pytest.mark.asyncio -async def test_upload_workspace_file_writes_file(tmp_path: Path, monkeypatch) -> None: +async def test_upload_workspace_files_writes_files(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) user = _user() root = svc._workspace_root(user) - upload = UploadFile(filename="demo.txt", file=BytesIO(b"hello")) + uploads = [ + UploadFile(filename="demo.txt", file=BytesIO(b"hello")), + UploadFile(filename="notes.md", file=BytesIO(b"# notes")), + ] - result = await svc.upload_workspace_file(parent_path="/", file=upload, current_user=user) + result = await svc.upload_workspace_files(parent_path="/", files=uploads, current_user=user) assert result["success"] is True - assert result["entry"]["path"] == "/demo.txt" - assert result["entry"]["size"] == 5 + assert [entry["path"] for entry in result["entries"]] == ["/demo.txt", "/notes.md"] + assert result["entries"][0]["size"] == 5 assert (root / "demo.txt").read_bytes() == b"hello" + assert (root / "notes.md").read_bytes() == b"# notes" @pytest.mark.asyncio -async def test_upload_workspace_file_rejects_oversized_file_and_cleans_partial_file( +async def test_upload_workspace_files_rejects_oversized_file_and_cleans_partial_files( tmp_path: Path, monkeypatch, ) -> None: @@ -178,11 +182,31 @@ async def test_upload_workspace_file_rejects_oversized_file_and_cleans_partial_f monkeypatch.setattr(svc, "MAX_WORKSPACE_UPLOAD_SIZE_BYTES", 5) user = _user() root = svc._workspace_root(user) - upload = UploadFile(filename="large.txt", file=BytesIO(b"123456")) + uploads = [ + UploadFile(filename="small.txt", file=BytesIO(b"12345")), + UploadFile(filename="large.txt", file=BytesIO(b"123456")), + ] with pytest.raises(HTTPException) as exc_info: - await svc.upload_workspace_file(parent_path="/", file=upload, current_user=user) + await svc.upload_workspace_files(parent_path="/", files=uploads, current_user=user) assert exc_info.value.status_code == 400 assert "100 MB" in exc_info.value.detail + assert not (root / "small.txt").exists() assert not (root / "large.txt").exists() + + +@pytest.mark.asyncio +async def test_upload_workspace_files_rejects_more_than_limit(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) + user = _user() + uploads = [ + UploadFile(filename=f"demo-{index}.txt", file=BytesIO(b"hello")) + for index in range(svc.MAX_WORKSPACE_UPLOAD_FILES + 1) + ] + + with pytest.raises(HTTPException) as exc_info: + await svc.upload_workspace_files(parent_path="/", files=uploads, current_user=user) + + assert exc_info.value.status_code == 400 + assert f"一次最多上传 {svc.MAX_WORKSPACE_UPLOAD_FILES} 个文件" in exc_info.value.detail diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 1e8e51bd..af1723a9 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -68,6 +68,7 @@ - 新增用户级沙盒环境变量:增加 `agent_envs` 表与 `/api/user/agent-env` 接口,设置面板支持当前用户维护 Agent 沙盒环境变量;创建新沙盒时与全局 `sandbox.env` 合并注入,用户变量优先。 - 收敛用户身份命名:原业务登录标识统一改为 `uid`,Agent/LangGraph runtime、conversation、agent_run、sandbox 路径和前端用户态均使用字符串 `uid`;`user_id` 仅保留给外部响应中的数值 `users.id` 或真实外键场景。 - 工作区知识库分类显示:知识库侧边栏按创建者分组为“我的知识库”和“共享知识库”,自己创建的知识库显示在“我的知识库”下,非自己创建的显示在“共享知识库”下;`knowledge_bases` 表新增 `created_by` 字段记录创建者 uid。 +- 工作区文件上传支持多选:`/workspace/upload` 与 Viewer 工作区上传统一使用 `files` 多文件字段,一次最多上传 50 个文件,批量上传失败时清理本次已写入文件。 - 聊天附件新增 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 恢复;事件 envelope 构造收敛到统一 helper,前端优先使用 envelope 一级 `thread_id` 路由。 - 收敛后端模块边界:文档解析从 `plugins.parser` 移动到 `knowledge.parser`,内容审查从 `plugins.guard` 移动到 `services.guard`。 diff --git a/web/src/apis/viewer_filesystem.js b/web/src/apis/viewer_filesystem.js index f13b6436..3e7bdd96 100644 --- a/web/src/apis/viewer_filesystem.js +++ b/web/src/apis/viewer_filesystem.js @@ -45,10 +45,10 @@ export const createViewerDirectory = (threadId, parentPath, name) => { }) } -export const uploadViewerFile = (threadId, parentPath, file) => { +export const uploadViewerFiles = (threadId, parentPath, files) => { const formData = new FormData() formData.set('thread_id', threadId) formData.set('parent_path', parentPath) - formData.set('file', file) + files.forEach((file) => formData.append('files', file)) return apiPost('/api/viewer/filesystem/upload', formData) } diff --git a/web/src/apis/workspace_api.js b/web/src/apis/workspace_api.js index bbccb678..f5eeb147 100644 --- a/web/src/apis/workspace_api.js +++ b/web/src/apis/workspace_api.js @@ -56,10 +56,10 @@ export const createWorkspaceDirectory = (parentPath, name) => { }) } -export const uploadWorkspaceFile = (parentPath, file) => { +export const uploadWorkspaceFiles = (parentPath, files) => { const formData = new FormData() formData.append('parent_path', parentPath) - formData.append('file', file) + files.forEach((file) => formData.append('files', file)) return apiPost('/api/workspace/upload', formData) } diff --git a/web/src/views/WorkspaceView.vue b/web/src/views/WorkspaceView.vue index d03f55ec..71bb7d64 100644 --- a/web/src/views/WorkspaceView.vue +++ b/web/src/views/WorkspaceView.vue @@ -20,6 +20,7 @@ ref="uploadInputRef" class="upload-input" type="file" + multiple @change="handleUploadInputChange" /> @@ -172,7 +173,7 @@ import { getWorkspaceKnowledgeTree, getWorkspaceTree, saveWorkspaceFileContent, - uploadWorkspaceFile + uploadWorkspaceFiles } from '@/apis/workspace_api' const userStore = useUserStore() @@ -206,6 +207,7 @@ const sidebarCollapsed = ref(false) const previewWidthPercent = ref(50) const previewRequestId = ref(0) const INLINE_PREVIEW_MIN_WIDTH = 960 +const MAX_WORKSPACE_UPLOAD_FILES = 50 const useInlinePreview = computed(() => workspaceMainWidth.value >= INLINE_PREVIEW_MIN_WIDTH) const isKnowledgeSource = computed(() => activeSourceKey.value.startsWith('database:')) @@ -635,14 +637,19 @@ const openUploadFilePicker = () => { } const handleUploadInputChange = async (event) => { - const file = event.target?.files?.[0] - if (!file || uploadingFile.value) return + const files = Array.from(event.target?.files || []) + if (!files.length || uploadingFile.value) return + if (files.length > MAX_WORKSPACE_UPLOAD_FILES) { + message.warning(`一次最多上传 ${MAX_WORKSPACE_UPLOAD_FILES} 个文件`) + event.target.value = '' + return + } uploadingFile.value = true try { - await uploadWorkspaceFile(currentPath.value, file) + await uploadWorkspaceFiles(currentPath.value, files) await loadWorkspaceEntries(currentPath.value) - message.success('文件上传成功') + message.success(`${files.length} 个文件上传成功`) } catch (error) { console.warn('上传文件失败:', error) message.error(error?.message || '上传文件失败')