feat(agent): 交付物卡片新增“保存到工作区”能力:支持将单个交付物复制到共享目录 workspace/saved_artifacts/,并复用现有文件树/预览/mention 体系立即可见

This commit is contained in:
Wenjie Zhang 2026-03-31 20:18:22 +08:00
parent 824270a035
commit 62d763a1a0
9 changed files with 269 additions and 15 deletions

View File

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

View File

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

View File

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

View File

@ -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,
)
# =============================================================================
# > === 消息反馈分组 ===
# =============================================================================

View File

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

View File

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

View File

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

View File

@ -38,6 +38,15 @@
<button class="item-action-btn" title="下载" @click.stop="downloadFile(file)">
<Download :size="15" />
</button>
<button
class="item-action-btn"
:title="isSaving(file.path) ? '保存中' : '保存到工作区'"
:disabled="isSaving(file.path)"
@click.stop="saveToWorkspace(file)"
>
<LoaderCircle v-if="isSaving(file.path)" :size="15" class="item-action-spin" />
<Save v-else :size="15" />
</button>
</div>
</div>
</div>
@ -69,7 +78,9 @@
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { ChevronDown, Download, Eye, FolderOutput } from 'lucide-vue-next'
import { message } from 'ant-design-vue'
import { ChevronDown, Download, Eye, FolderOutput, LoaderCircle, Save } from 'lucide-vue-next'
import { threadApi } from '@/apis/agent_api'
import AgentFilePreview from '@/components/AgentFilePreview.vue'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
import { getPreviewTypeByPath } from '@/utils/file_preview'
@ -93,6 +104,7 @@ const props = defineProps({
default: null
}
})
const emit = defineEmits(['saved'])
const normalizedArtifacts = computed(() =>
(props.artifacts || [])
@ -114,6 +126,7 @@ const expanded = ref(false)
const modalVisible = ref(false)
const currentFile = ref(null)
const currentFilePath = ref('')
const savingState = ref({})
const parseDownloadFilename = (contentDisposition) => {
if (!contentDisposition) return ''
@ -224,6 +237,30 @@ const downloadFile = async (file) => {
window.URL.revokeObjectURL(url)
}
const isSaving = (path) => !!savingState.value[path]
const setSaving = (path, saving) => {
savingState.value = {
...savingState.value,
[path]: saving
}
}
const saveToWorkspace = async (file) => {
if (!props.threadId || !file?.path || isSaving(file.path)) return
setSaving(file.path, true)
try {
const result = await threadApi.saveThreadArtifactToWorkspace(props.threadId, file.path)
message.success(`已保存到工作区:${result.saved_path}`)
emit('saved', result)
} catch (error) {
message.error(error?.message || '保存到工作区失败')
} finally {
setSaving(file.path, false)
}
}
onUnmounted(() => {
revokeCurrentPreviewUrl()
})
@ -435,11 +472,30 @@ watch(
transition: all 0.2s ease;
}
.item-action-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.item-action-btn:hover {
color: var(--main-700);
background: var(--gray-100);
}
.item-action-spin {
animation: artifacts-spin 1s linear infinite;
}
@keyframes artifacts-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.output-item {
align-items: flex-start;

View File

@ -155,6 +155,7 @@
:thread-id="currentChatId"
:agent-id="currentThread?.agent_id || currentAgentId"
:agent-config-id="selectedAgentConfigId"
@saved="handleArtifactSaved"
/>
<AgentInputArea
@ -848,6 +849,11 @@ const refreshThreadFilesAndAttachments = async (threadId) => {
await Promise.all([fetchThreadFiles(threadId), fetchThreadAttachments(threadId)])
}
const handleArtifactSaved = async () => {
if (!currentChatId.value) return
await refreshThreadFilesAndAttachments(currentChatId.value)
}
const fetchAgentState = async (agentId, threadId) => {
if (!threadId) return
try {