feat(agent): 交付物卡片新增“保存到工作区”能力:支持将单个交付物复制到共享目录 workspace/saved_artifacts/,并复用现有文件树/预览/mention 体系立即可见
This commit is contained in:
parent
824270a035
commit
62d763a1a0
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# > === 消息反馈分组 ===
|
||||
# =============================================================================
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user