From 62d763a1a0788398327646e823c6fdf2c59dac4f Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Tue, 31 Mar 2026 20:18:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E4=BA=A4=E4=BB=98=E7=89=A9?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=96=B0=E5=A2=9E=E2=80=9C=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=88=B0=E5=B7=A5=E4=BD=9C=E5=8C=BA=E2=80=9D=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=EF=BC=9A=E6=94=AF=E6=8C=81=E5=B0=86=E5=8D=95=E4=B8=AA=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E7=89=A9=E5=A4=8D=E5=88=B6=E5=88=B0=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=20`workspace/saved=5Fartifacts/`=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=A4=8D=E7=94=A8=E7=8E=B0=E6=9C=89=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=A0=91/=E9=A2=84=E8=A7=88/mention=20=E4=BD=93=E7=B3=BB?= =?UTF-8?q?=E7=AB=8B=E5=8D=B3=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../package/yuxi/agents/backends/__init__.py | 6 - .../yuxi/agents/backends/sandbox/__init__.py | 8 -- .../yuxi/services/thread_files_service.py | 50 ++++++++ backend/server/routers/chat_router.py | 28 +++++ .../test/integration/api/test_chat_router.py | 118 ++++++++++++++++++ docs/develop-guides/roadmap.md | 1 + web/src/apis/agent_api.js | 9 ++ web/src/components/AgentArtifactsCard.vue | 58 ++++++++- web/src/components/AgentChatComponent.vue | 6 + 9 files changed, 269 insertions(+), 15 deletions(-) diff --git a/backend/package/yuxi/agents/backends/__init__.py b/backend/package/yuxi/agents/backends/__init__.py index c7e3b372..cdf6730e 100644 --- a/backend/package/yuxi/agents/backends/__init__.py +++ b/backend/package/yuxi/agents/backends/__init__.py @@ -5,13 +5,10 @@ from .knowledge_base_backend import KBS_PATH, KnowledgeBaseReadonlyBackend, reso from .sandbox import ( IDLE_CHECK_INTERVAL, LARGE_TOOL_RESULTS_DIR, - OUTPUTS_DIR, SKILLS_PATH, THREADS_DIR, - UPLOADS_DIR, USER_DATA_PATH, VIRTUAL_PATH_PREFIX, - WORKSPACE_DIR, LocalContainerBackend, ProvisionerSandboxBackend, RemoteSandboxBackend, @@ -61,9 +58,6 @@ __all__ = [ "VIRTUAL_PATH_PREFIX", "USER_DATA_PATH", "SKILLS_PATH", - "WORKSPACE_DIR", - "OUTPUTS_DIR", - "UPLOADS_DIR", "LARGE_TOOL_RESULTS_DIR", "THREADS_DIR", "IDLE_CHECK_INTERVAL", diff --git a/backend/package/yuxi/agents/backends/sandbox/__init__.py b/backend/package/yuxi/agents/backends/sandbox/__init__.py index 6e3d6734..d4417bfd 100644 --- a/backend/package/yuxi/agents/backends/sandbox/__init__.py +++ b/backend/package/yuxi/agents/backends/sandbox/__init__.py @@ -30,11 +30,6 @@ SandboxInfo = SandboxConnection USER_DATA_PATH = VIRTUAL_PATH_PREFIX SKILLS_PATH = "/home/gem/skills" -# Relative host-side directory names under thread user-data. -WORKSPACE_DIR = "workspace" -UPLOADS_DIR = "uploads" -OUTPUTS_DIR = "outputs" - # Backward-compatible constants kept for old call sites. THREADS_DIR = "threads" LARGE_TOOL_RESULTS_DIR = "large-tool-results" @@ -50,9 +45,6 @@ __all__ = [ "SandboxBackend", "SandboxInfo", "THREADS_DIR", - "UPLOADS_DIR", - "USER_DATA_PATH", - "WORKSPACE_DIR", "YuxiSandboxBackend", "YuxiSandboxProvider", "ProvisionerSandboxBackend", diff --git a/backend/package/yuxi/services/thread_files_service.py b/backend/package/yuxi/services/thread_files_service.py index bda68ba8..c30cf63a 100644 --- a/backend/package/yuxi/services/thread_files_service.py +++ b/backend/package/yuxi/services/thread_files_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shutil from pathlib import Path from typing import Any @@ -227,6 +228,55 @@ async def resolve_thread_artifact_view( return actual_path +async def save_thread_artifact_to_workspace_view( + *, + thread_id: str, + current_user_id: str, + db, + path: str, +) -> dict[str, str]: + source_path = await resolve_thread_artifact_view( + thread_id=thread_id, + current_user_id=current_user_id, + db=db, + path=path, + ) + + target_dir = sandbox_workspace_dir(thread_id) / "saved_artifacts" + target_dir.mkdir(parents=True, exist_ok=True) + + target_path = _next_available_artifact_path(target_dir, source_path.name) + with source_path.open("rb") as src, target_path.open("wb") as dst: + shutil.copyfileobj(src, dst) + + saved_virtual_path = virtual_path_for_thread_file(thread_id, target_path) + return { + "name": target_path.name, + "source_path": "/" + path.lstrip("/"), + "saved_path": saved_virtual_path, + "saved_artifact_url": f"/api/chat/thread/{thread_id}/artifacts/{saved_virtual_path.lstrip('/')}", + } + + +def _next_available_artifact_path(target_dir: Path, filename: str) -> Path: + candidate = target_dir / filename + if not candidate.exists(): + return candidate + + base_name = Path(filename).stem + suffix = Path(filename).suffix + index = 1 + while True: + candidate = target_dir / f"{base_name} ({index}){suffix}" + if not candidate.exists(): + return candidate + + index += 1 + if index >= 1000: + # This is a safety check to prevent infinite loops in case of some unexpected issue with file naming. + raise RuntimeError(f"Unable to find available filename for {filename} after 1000 attempts.") + + def _is_path_within(path: Path, root: Path) -> bool: try: path.relative_to(root) diff --git a/backend/server/routers/chat_router.py b/backend/server/routers/chat_router.py index 1965e2e4..733a7bf7 100644 --- a/backend/server/routers/chat_router.py +++ b/backend/server/routers/chat_router.py @@ -37,6 +37,7 @@ from yuxi.services.thread_files_service import ( list_thread_files_view, read_thread_file_content_view, resolve_thread_artifact_view, + save_thread_artifact_to_workspace_view, ) from yuxi.services.feedback_service import get_message_feedback_view, submit_message_feedback_view from yuxi.repositories.agent_config_repository import AgentConfigRepository @@ -687,6 +688,17 @@ class ThreadFileContentResponse(BaseModel): artifact_url: str +class SaveThreadArtifactRequest(BaseModel): + path: str + + +class SaveThreadArtifactResponse(BaseModel): + name: str + source_path: str + saved_path: str + saved_artifact_url: str + + # ============================================================================= # > === 会话管理分组 === # ============================================================================= @@ -860,6 +872,22 @@ async def get_thread_artifact( return FileResponse(path=file_path, media_type=media_type, headers=headers) +@chat.post("/thread/{thread_id}/artifacts/save", response_model=SaveThreadArtifactResponse) +async def save_thread_artifact_to_workspace( + thread_id: str, + request: SaveThreadArtifactRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_required_user), +): + """保存交付物到共享 workspace/saved_artifacts 目录。""" + return await save_thread_artifact_to_workspace_view( + thread_id=thread_id, + current_user_id=str(current_user.id), + db=db, + path=request.path, + ) + + # ============================================================================= # > === 消息反馈分组 === # ============================================================================= diff --git a/backend/test/integration/api/test_chat_router.py b/backend/test/integration/api/test_chat_router.py index 27b4449e..91bcdd71 100644 --- a/backend/test/integration/api/test_chat_router.py +++ b/backend/test/integration/api/test_chat_router.py @@ -4,7 +4,10 @@ Integration tests for chat router endpoints. from __future__ import annotations +import uuid + import pytest +from yuxi.agents.backends.sandbox import ensure_thread_dirs, sandbox_user_data_dir, sandbox_workspace_dir pytestmark = [pytest.mark.asyncio, pytest.mark.integration] @@ -14,6 +17,33 @@ async def test_chat_endpoints_require_authentication(test_client): assert (await test_client.get("/api/chat/agent")).status_code == 401 +async def _create_thread_for_user(test_client, headers: dict[str, str]) -> str: + agents_resp = await test_client.get("/api/chat/agent", headers=headers) + assert agents_resp.status_code == 200, agents_resp.text + agents = agents_resp.json().get("agents", []) + if not agents: + pytest.skip("No agents available for chat router integration tests.") + + agent_id = agents[0].get("id") + if not agent_id: + pytest.skip("Agent payload missing id field.") + + create_resp = await test_client.post( + "/api/chat/thread", + json={ + "agent_id": agent_id, + "title": f"chat-router-test-{uuid.uuid4().hex[:8]}", + "metadata": {}, + }, + headers=headers, + ) + assert create_resp.status_code == 200, create_resp.text + payload = create_resp.json() + thread_id = payload.get("thread_id") or payload.get("id") + assert thread_id, f"Create thread response missing thread identifier: {payload}" + return thread_id + + async def test_admin_can_list_agents(test_client, admin_headers): response = await test_client.get("/api/chat/agent", headers=admin_headers) assert response.status_code == 200, response.text @@ -58,3 +88,91 @@ async def test_setting_default_agent_requires_admin(test_client, admin_headers, ) assert update_response.status_code == 200, update_response.text assert update_response.json()["default_agent_id"] == candidate_agent_id + + +async def test_save_thread_artifact_to_workspace_copies_output_file(test_client, standard_user): + headers = standard_user["headers"] + thread_id = await _create_thread_for_user(test_client, headers) + filename = f"artifact-{uuid.uuid4().hex[:8]}.md" + + ensure_thread_dirs(thread_id) + source_path = sandbox_user_data_dir(thread_id) / "outputs" / filename + source_path.write_text("# artifact\n", encoding="utf-8") + + response = await test_client.post( + f"/api/chat/thread/{thread_id}/artifacts/save", + json={"path": f"/home/gem/user-data/outputs/{filename}"}, + headers=headers, + ) + assert response.status_code == 200, response.text + + payload = response.json() + assert payload["name"] == filename + assert payload["source_path"] == f"/home/gem/user-data/outputs/{filename}" + assert payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{filename}" + + saved_path = sandbox_workspace_dir(thread_id) / "saved_artifacts" / filename + assert saved_path.exists() + assert saved_path.read_text(encoding="utf-8") == "# artifact\n" + + download_response = await test_client.get(payload["saved_artifact_url"], headers=headers) + assert download_response.status_code == 200, download_response.text + assert download_response.text == "# artifact\n" + + +async def test_save_thread_artifact_to_workspace_auto_renames_conflicts(test_client, standard_user): + headers = standard_user["headers"] + thread_id = await _create_thread_for_user(test_client, headers) + filename = f"artifact-{uuid.uuid4().hex[:8]}.txt" + renamed_filename = filename.replace(".txt", " (1).txt") + + ensure_thread_dirs(thread_id) + source_path = sandbox_user_data_dir(thread_id) / "outputs" / filename + source_path.write_text("first\n", encoding="utf-8") + + first_response = await test_client.post( + f"/api/chat/thread/{thread_id}/artifacts/save", + json={"path": f"/home/gem/user-data/outputs/{filename}"}, + headers=headers, + ) + assert first_response.status_code == 200, first_response.text + + source_path.write_text("second\n", encoding="utf-8") + second_response = await test_client.post( + f"/api/chat/thread/{thread_id}/artifacts/save", + json={"path": f"/home/gem/user-data/outputs/{filename}"}, + headers=headers, + ) + assert second_response.status_code == 200, second_response.text + + first_payload = first_response.json() + second_payload = second_response.json() + assert first_payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{filename}" + assert second_payload["saved_path"] == f"/home/gem/user-data/workspace/saved_artifacts/{renamed_filename}" + + first_saved = sandbox_workspace_dir(thread_id) / "saved_artifacts" / filename + second_saved = sandbox_workspace_dir(thread_id) / "saved_artifacts" / renamed_filename + assert first_saved.read_text(encoding="utf-8") == "first\n" + assert second_saved.read_text(encoding="utf-8") == "second\n" + + +async def test_save_thread_artifact_to_workspace_rejects_invalid_paths(test_client, standard_user): + headers = standard_user["headers"] + thread_id = await _create_thread_for_user(test_client, headers) + + invalid_response = await test_client.post( + f"/api/chat/thread/{thread_id}/artifacts/save", + json={"path": "/home/gem/user-data/not-allowed/demo.txt"}, + headers=headers, + ) + assert invalid_response.status_code == 404, invalid_response.text + + ensure_thread_dirs(thread_id) + directory_path = sandbox_workspace_dir(thread_id) / "nested-dir" + directory_path.mkdir(parents=True, exist_ok=True) + directory_response = await test_client.post( + f"/api/chat/thread/{thread_id}/artifacts/save", + json={"path": "/home/gem/user-data/workspace/nested-dir"}, + headers=headers, + ) + assert directory_response.status_code == 400, directory_response.text diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 1779e818..963875d7 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -37,6 +37,7 @@ - 新增沙盒环境,详见后续文档更新,统一沙盒虚拟路径前缀默认值为 `/home/gem/user-data` - 新增基于沙盒的文件系统,前端工作台可以查看文件系统,支持预览(文本、图片、PDF、HTML)、下载文件 - 新增 `present_artifacts` 内置工具:Agent 可将 `/home/gem/user-data/outputs/` 下的结果文件显式写入 LangGraph state 的 `artifacts` 字段,前端支持在输入框顶部以默认折叠的堆叠卡片展示本轮交付物文件,并保持可下载、可预览能力 +- 交付物卡片新增“保存到工作区”能力:支持将单个交付物复制到共享目录 `workspace/saved_artifacts/`,并复用现有文件树/预览/mention 体系立即可见 - 新增基于沙盒的知识库只读映射,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown - 重构附件系统,直接集成在了沙盒文件系统中,附件上传后直接落盘到沙盒挂载目录 - 优化前端流式消息体验:新增通用 `useStreamSmoother` 调度层,统一平滑 Agent runs SSE、普通聊天流与审批恢复流中的 `loading` chunk diff --git a/web/src/apis/agent_api.js b/web/src/apis/agent_api.js index ad803f17..3b03d4eb 100644 --- a/web/src/apis/agent_api.js +++ b/web/src/apis/agent_api.js @@ -383,6 +383,15 @@ export const threadApi = { downloadThreadArtifact: (threadId, path) => apiGet(threadApi.getThreadArtifactUrl(threadId, path, true), {}, true, 'blob'), + /** + * 保存交付物到 workspace/saved_artifacts + * @param {string} threadId + * @param {string} path + * @returns {Promise} + */ + saveThreadArtifactToWorkspace: (threadId, path) => + apiPost(`/api/chat/thread/${threadId}/artifacts/save`, { path }), + /** * 上传附件 * @param {string} threadId diff --git a/web/src/components/AgentArtifactsCard.vue b/web/src/components/AgentArtifactsCard.vue index 7b487080..d8ded1cf 100644 --- a/web/src/components/AgentArtifactsCard.vue +++ b/web/src/components/AgentArtifactsCard.vue @@ -38,6 +38,15 @@ + @@ -69,7 +78,9 @@