feat: 支持工作区批量上传文件

This commit is contained in:
Wenjie Zhang 2026-06-03 23:32:32 +08:00
parent 6fa1355f04
commit 2e09fe328e
12 changed files with 106 additions and 43 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 envelopeSSE 输出 `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`

View File

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

View File

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

View File

@ -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 || '上传文件失败')