feat: 支持工作区批量上传文件
This commit is contained in:
parent
6fa1355f04
commit
2e09fe328e
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 || '上传文件失败')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user