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