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, read_workspace_file_content as read_workspace_file_content_response,
) )
from yuxi.services.workspace_service import ( 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.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
@ -195,6 +195,10 @@ def _viewer_response_from_workspace_response(response: dict) -> dict:
result = {**response} result = {**response}
if "entry" in result and isinstance(result["entry"], dict): if "entry" in result and isinstance(result["entry"], dict):
result["entry"] = _viewer_entry_from_workspace_entry(result["entry"]) 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 return result
@ -534,11 +538,11 @@ async def create_viewer_directory(
return _viewer_response_from_workspace_response(response) return _viewer_response_from_workspace_response(response)
async def upload_viewer_file( async def upload_viewer_files(
*, *,
thread_id: str, thread_id: str,
parent_path: str, parent_path: str,
file: UploadFile, files: list[UploadFile],
current_user: User, current_user: User,
db: AsyncSession, db: AsyncSession,
) -> dict: ) -> dict:
@ -555,9 +559,9 @@ async def upload_viewer_file(
if not _is_workspace_path(normalized_parent): if not _is_workspace_path(normalized_parent):
raise HTTPException(status_code=400, detail="当前路径不支持写入") 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), parent_path=_workspace_relative_path(normalized_parent),
file=file, files=files,
current_user=current_user, current_user=current_user,
) )
return _viewer_response_from_workspace_response(response) 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"} EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"}
MAX_WORKSPACE_UPLOAD_SIZE_BYTES = MAX_UPLOAD_SIZE_BYTES MAX_WORKSPACE_UPLOAD_SIZE_BYTES = MAX_UPLOAD_SIZE_BYTES
MAX_WORKSPACE_UPLOAD_FILES = 50
def _workspace_root(user: User) -> Path: 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)} return {"success": True, "entry": _entry_for_path(root, target)}
async def upload_workspace_file(*, parent_path: str, file: UploadFile, current_user: User) -> dict: async def _write_workspace_upload(file: UploadFile, target: Path) -> None:
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)
created_file = False created_file = False
upload_completed = False upload_completed = False
@ -278,8 +275,38 @@ async def upload_workspace_file(*, parent_path: str, file: UploadFile, current_u
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
await asyncio.to_thread(target.unlink) 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)) 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: 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, download_viewer_file,
list_viewer_filesystem_tree, list_viewer_filesystem_tree,
read_viewer_file_content, read_viewer_file_content,
upload_viewer_file, upload_viewer_files,
) )
from yuxi.storage.postgres.models_business import User 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) @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"), thread_id: str = Form(..., description="线程 ID"),
parent_path: str = Form(..., description="父目录路径"), parent_path: str = Form(..., description="父目录路径"),
file: UploadFile = File(..., description="上传文件"), files: list[UploadFile] = File(..., description="上传文件列表"),
current_user: User = Depends(get_required_user), current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
return await upload_viewer_file( return await upload_viewer_files(
thread_id=thread_id, thread_id=thread_id,
parent_path=parent_path, parent_path=parent_path,
file=file, files=files,
current_user=current_user, current_user=current_user,
db=db, db=db,
) )

View File

@ -15,7 +15,7 @@ from yuxi.services.workspace_service import (
download_workspace_file, download_workspace_file,
list_workspace_tree, list_workspace_tree,
read_workspace_file_content, read_workspace_file_content,
upload_workspace_file, upload_workspace_files,
write_workspace_file_content, write_workspace_file_content,
) )
from yuxi.storage.postgres.models_business import User from yuxi.storage.postgres.models_business import User
@ -164,12 +164,12 @@ async def create_workspace_directory_route(
@workspace.post("/upload", response_model=dict) @workspace.post("/upload", response_model=dict)
async def upload_workspace_file_route( async def upload_workspace_files_route(
parent_path: str = Form(..., description="父目录路径"), parent_path: str = Form(..., description="父目录路径"),
file: UploadFile = File(..., description="上传文件"), files: list[UploadFile] = File(..., description="上传文件列表"),
current_user: User = Depends(get_required_user), 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") @workspace.get("/download")

View File

@ -128,7 +128,7 @@ async def _upload(
response = await client.post( response = await client.post(
"/api/viewer/filesystem/upload", "/api/viewer/filesystem/upload",
data={"thread_id": thread_id, "parent_path": parent_path, "agent_id": agent_id}, 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, headers=headers,
) )
assert response.status_code == 200, response.text 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( response = await test_client.post(
"/api/viewer/filesystem/upload", "/api/viewer/filesystem/upload",
data={"thread_id": thread_id, "parent_path": "/home/gem/user-data/workspace/upload-target"}, 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, headers=headers,
) )
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
payload = response.json() payload = response.json()
assert payload["success"] is True 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( file_response = await test_client.get(
"/api/viewer/filesystem/file", "/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( response = await test_client.post(
"/api/viewer/filesystem/upload", "/api/viewer/filesystem/upload",
data={"thread_id": thread_id, "parent_path": "/home/gem/user-data/workspace"}, 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, headers=headers,
) )
assert response.status_code == 400, response.text 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( upload_response = await test_client.post(
"/api/viewer/filesystem/upload", "/api/viewer/filesystem/upload",
data={"thread_id": thread_id, "parent_path": parent_path}, 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, headers=headers,
) )
assert upload_response.status_code == 400, upload_response.text 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( response = await test_client.post(
"/api/viewer/filesystem/upload", "/api/viewer/filesystem/upload",
data={"thread_id": thread_id, "parent_path": parent_path}, 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, headers=headers,
) )

View File

@ -155,22 +155,26 @@ async def test_write_workspace_file_content_blocks_path_traversal(tmp_path: Path
@pytest.mark.asyncio @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)) monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
user = _user() user = _user()
root = svc._workspace_root(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["success"] is True
assert result["entry"]["path"] == "/demo.txt" assert [entry["path"] for entry in result["entries"]] == ["/demo.txt", "/notes.md"]
assert result["entry"]["size"] == 5 assert result["entries"][0]["size"] == 5
assert (root / "demo.txt").read_bytes() == b"hello" assert (root / "demo.txt").read_bytes() == b"hello"
assert (root / "notes.md").read_bytes() == b"# notes"
@pytest.mark.asyncio @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, tmp_path: Path,
monkeypatch, monkeypatch,
) -> None: ) -> 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) monkeypatch.setattr(svc, "MAX_WORKSPACE_UPLOAD_SIZE_BYTES", 5)
user = _user() user = _user()
root = svc._workspace_root(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: 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 exc_info.value.status_code == 400
assert "100 MB" in exc_info.value.detail assert "100 MB" in exc_info.value.detail
assert not (root / "small.txt").exists()
assert not (root / "large.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` 合并注入,用户变量优先。 - 新增用户级沙盒环境变量:增加 `agent_envs` 表与 `/api/user/agent-env` 接口,设置面板支持当前用户维护 Agent 沙盒环境变量;创建新沙盒时与全局 `sandbox.env` 合并注入,用户变量优先。
- 收敛用户身份命名:原业务登录标识统一改为 `uid`Agent/LangGraph runtime、conversation、agent_run、sandbox 路径和前端用户态均使用字符串 `uid``user_id` 仅保留给外部响应中的数值 `users.id` 或真实外键场景。 - 收敛用户身份命名:原业务登录标识统一改为 `uid`Agent/LangGraph runtime、conversation、agent_run、sandbox 路径和前端用户态均使用字符串 `uid``user_id` 仅保留给外部响应中的数值 `users.id` 或真实外键场景。
- 工作区知识库分类显示:知识库侧边栏按创建者分组为“我的知识库”和“共享知识库”,自己创建的知识库显示在“我的知识库”下,非自己创建的显示在“共享知识库”下;`knowledge_bases` 表新增 `created_by` 字段记录创建者 uid。 - 工作区知识库分类显示:知识库侧边栏按创建者分组为“我的知识库”和“共享知识库”,自己创建的知识库显示在“我的知识库”下,非自己创建的显示在“共享知识库”下;`knowledge_bases` 表新增 `created_by` 字段记录创建者 uid。
- 工作区文件上传支持多选:`/workspace/upload` 与 Viewer 工作区上传统一使用 `files` 多文件字段,一次最多上传 50 个文件,批量上传失败时清理本次已写入文件。
- 聊天附件新增 MinIO tmp 临时上传、可选 PDF/图片解析、确认后加入线程附件的流程;前端改为弹窗内上传、解析与确认。 - 聊天附件新增 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` 路由。 - 标准化 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` - 收敛后端模块边界:文档解析从 `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() const formData = new FormData()
formData.set('thread_id', threadId) formData.set('thread_id', threadId)
formData.set('parent_path', parentPath) formData.set('parent_path', parentPath)
formData.set('file', file) files.forEach((file) => formData.append('files', file))
return apiPost('/api/viewer/filesystem/upload', formData) 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() const formData = new FormData()
formData.append('parent_path', parentPath) formData.append('parent_path', parentPath)
formData.append('file', file) files.forEach((file) => formData.append('files', file))
return apiPost('/api/workspace/upload', formData) return apiPost('/api/workspace/upload', formData)
} }

View File

@ -20,6 +20,7 @@
ref="uploadInputRef" ref="uploadInputRef"
class="upload-input" class="upload-input"
type="file" type="file"
multiple
@change="handleUploadInputChange" @change="handleUploadInputChange"
/> />
@ -172,7 +173,7 @@ import {
getWorkspaceKnowledgeTree, getWorkspaceKnowledgeTree,
getWorkspaceTree, getWorkspaceTree,
saveWorkspaceFileContent, saveWorkspaceFileContent,
uploadWorkspaceFile uploadWorkspaceFiles
} from '@/apis/workspace_api' } from '@/apis/workspace_api'
const userStore = useUserStore() const userStore = useUserStore()
@ -206,6 +207,7 @@ const sidebarCollapsed = ref(false)
const previewWidthPercent = ref(50) const previewWidthPercent = ref(50)
const previewRequestId = ref(0) const previewRequestId = ref(0)
const INLINE_PREVIEW_MIN_WIDTH = 960 const INLINE_PREVIEW_MIN_WIDTH = 960
const MAX_WORKSPACE_UPLOAD_FILES = 50
const useInlinePreview = computed(() => workspaceMainWidth.value >= INLINE_PREVIEW_MIN_WIDTH) const useInlinePreview = computed(() => workspaceMainWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
const isKnowledgeSource = computed(() => activeSourceKey.value.startsWith('database:')) const isKnowledgeSource = computed(() => activeSourceKey.value.startsWith('database:'))
@ -635,14 +637,19 @@ const openUploadFilePicker = () => {
} }
const handleUploadInputChange = async (event) => { const handleUploadInputChange = async (event) => {
const file = event.target?.files?.[0] const files = Array.from(event.target?.files || [])
if (!file || uploadingFile.value) return 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 uploadingFile.value = true
try { try {
await uploadWorkspaceFile(currentPath.value, file) await uploadWorkspaceFiles(currentPath.value, files)
await loadWorkspaceEntries(currentPath.value) await loadWorkspaceEntries(currentPath.value)
message.success('文件上传成功') message.success(`${files.length} 个文件上传成功`)
} catch (error) { } catch (error) {
console.warn('上传文件失败:', error) console.warn('上传文件失败:', error)
message.error(error?.message || '上传文件失败') message.error(error?.message || '上传文件失败')