From ecf8e11ecebc1aec9766c775dab6a7701544633d Mon Sep 17 00:00:00 2001 From: MT-Mint <798521692@qq.com> Date: Fri, 24 Apr 2026 17:55:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(references):=20=E7=BB=9F=E4=B8=80=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E6=9D=A5=E6=BA=90=E5=B9=B6=E8=BF=87=E6=BB=A4=20upload?= =?UTF-8?q?s/skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/gateway/routers/artifacts.py | 65 +++++++++++++++++++ backend/tests/test_artifacts_router.py | 40 ++++++++++++ .../src/components/workspace/input-box.tsx | 64 ++++++++---------- frontend/src/core/artifacts/references.ts | 38 +++++++++++ 4 files changed, 170 insertions(+), 37 deletions(-) create mode 100644 frontend/src/core/artifacts/references.ts diff --git a/backend/app/gateway/routers/artifacts.py b/backend/app/gateway/routers/artifacts.py index 0ec64154..5e62ed3d 100644 --- a/backend/app/gateway/routers/artifacts.py +++ b/backend/app/gateway/routers/artifacts.py @@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse, PlainTextResponse, Response from app.gateway.path_utils import resolve_thread_virtual_path +from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths logger = logging.getLogger(__name__) @@ -62,6 +63,38 @@ def _find_compat_filename_match(missing_path: Path) -> Path | None: return matches[0] if len(matches) == 1 else None +def _list_reference_files_in_dir( + thread_id: str, + root_dir: Path, + virtual_prefix: str, + source: str, +) -> list[dict[str, str]]: + if not root_dir.is_dir(): + return [] + + files: list[dict[str, str]] = [] + for file_path in sorted(root_dir.rglob("*")): + if not file_path.is_file(): + continue + relative_path = file_path.relative_to(root_dir).as_posix() + # Internal uploaded skills are bootstrap assets, not user-facing references. + if source == "upload" and relative_path.startswith("skill/"): + continue + virtual_path = f"{virtual_prefix}/{relative_path}" + encoded_virtual_path = quote(virtual_path, safe="/") + files.append( + { + "filename": file_path.name, + "size": str(file_path.stat().st_size), + "virtual_path": virtual_path, + "artifact_url": f"/api/threads/{thread_id}/artifacts{encoded_virtual_path}", + "source": source, + } + ) + + return files + + def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool: """Check if file is text by examining content for null bytes.""" try: @@ -106,6 +139,38 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte return None +@router.get( + "/threads/{thread_id}/artifacts/list", + summary="List Reference Files", + description="List current files under outputs and uploads for @ references.", +) +async def list_reference_files(thread_id: str) -> dict: + """List real files from outputs/uploads so mention candidates stay fresh.""" + paths = get_paths() + outputs_dir = paths.sandbox_outputs_dir(thread_id) + uploads_dir = paths.sandbox_uploads_dir(thread_id) + + outputs_virtual_prefix = f"{VIRTUAL_PATH_PREFIX}/outputs" + uploads_virtual_prefix = f"{VIRTUAL_PATH_PREFIX}/uploads" + output_files = _list_reference_files_in_dir( + thread_id, + outputs_dir, + outputs_virtual_prefix, + "artifact", + ) + upload_files = _list_reference_files_in_dir( + thread_id, + uploads_dir, + uploads_virtual_prefix, + "upload", + ) + files = [*output_files, *upload_files] + return { + "files": files, + "count": len(files), + } + + @router.get( "/threads/{thread_id}/artifacts/{path:path}", summary="Get Artifact File", diff --git a/backend/tests/test_artifacts_router.py b/backend/tests/test_artifacts_router.py index cbb86af5..ace00eeb 100644 --- a/backend/tests/test_artifacts_router.py +++ b/backend/tests/test_artifacts_router.py @@ -130,3 +130,43 @@ def test_get_artifact_compat_fallback_for_dash_spacing(tmp_path, monkeypatch) -> assert bytes(response.body).decode("utf-8") == "ok" assert response.media_type == "text/markdown" + + +def test_list_reference_files_returns_outputs_and_uploads(tmp_path, monkeypatch) -> None: + outputs_dir = tmp_path / "outputs" + uploads_dir = tmp_path / "uploads" + outputs_dir.mkdir() + uploads_dir.mkdir() + (outputs_dir / "notes.md").write_text("hello", encoding="utf-8") + (outputs_dir / "figures").mkdir() + (outputs_dir / "figures" / "plot.png").write_bytes(b"png") + (uploads_dir / "dataset.csv").write_text("a,b\n1,2\n", encoding="utf-8") + (uploads_dir / "skill").mkdir() + (uploads_dir / "skill" / "internal.txt").write_text("hidden", encoding="utf-8") + + class _FakePaths: + def sandbox_outputs_dir(self, _thread_id: str) -> Path: + return outputs_dir + + def sandbox_uploads_dir(self, _thread_id: str) -> Path: + return uploads_dir + + monkeypatch.setattr(artifacts_router, "get_paths", lambda: _FakePaths()) + + app = FastAPI() + app.include_router(artifacts_router.router) + + with TestClient(app) as client: + response = client.get("/api/threads/thread-1/artifacts/list") + + assert response.status_code == 200 + payload = response.json() + assert payload["count"] == 3 + by_path = {item["virtual_path"]: item for item in payload["files"]} + + assert "/mnt/user-data/outputs/notes.md" in by_path + assert "/mnt/user-data/outputs/figures/plot.png" in by_path + assert "/mnt/user-data/uploads/dataset.csv" in by_path + assert "/mnt/user-data/uploads/skill/internal.txt" not in by_path + assert by_path["/mnt/user-data/outputs/notes.md"]["source"] == "artifact" + assert by_path["/mnt/user-data/uploads/dataset.csv"]["source"] == "upload" diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 4928893d..3f38a73f 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -70,6 +70,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tag } from "@/components/ui/tag"; +import { useReferenceFiles } from "@/core/artifacts/references"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types"; @@ -80,7 +81,6 @@ import { MENTION_REFERENCE_EVENT, type MentionReferenceEventDetail, } from "@/core/threads/reference-events"; -import { useUploadedFiles } from "@/core/uploads/hooks"; import { useIframeSkill } from "@/hooks/use-iframe-skill"; import { cn } from "@/lib/utils"; @@ -96,7 +96,6 @@ import { import { Suggestion, Suggestions } from "../ai-elements/suggestion"; import { ScrollArea } from "../ui/scroll-area"; -import { useThread } from "./messages/context"; import { ModeHoverGuide } from "./mode-hover-guide"; import { Tooltip } from "./tooltip"; @@ -260,7 +259,6 @@ export function InputBox({ }), [t], ); - const { thread } = useThread(); const searchParams = useSearchParams(); const iframeSkill = useIframeSkill({ threadId: threadIdFromProps }); const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping; @@ -294,7 +292,7 @@ export function InputBox({ } | null>(null); const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false); const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false); - const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps); + const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps); // isNewThread 时禁用收缩,始终保持展开(除非已提交消息) const effectiveIsFocused = @@ -439,49 +437,41 @@ export function InputBox({ ); const mentionCandidates = useMemo(() => { - const artifactCandidates = (thread.values.artifacts ?? []).map((path) => { - const filename = path.split("/").pop() ?? path; - return { - key: `artifact:${path}`, - filename, - path, - pathTail: getPathTail(path), - ref_source: "artifact" as const, - ref_kind: "mention" as const, - typeLabel: referenceSourceLabels.artifact, - isImage: isImageFilename(filename), - previewUrl: threadId + const deduped = new Map(); + (referenceFilesData?.files ?? []).forEach((file) => { + const path = file.virtual_path || ""; + const filename = file.filename ?? path.split("/").pop() ?? path; + const refSource = file.source === "upload" ? "upload" : "artifact"; + const typeLabel = + refSource === "upload" + ? referenceSourceLabels.upload + : referenceSourceLabels.artifact; + const previewUrl = + file.artifact_url || + (threadId ? urlOfArtifact({ filepath: path, threadId, }) - : undefined, - }; - }); + : undefined); - const uploadCandidates = - uploadedFilesData?.files.map((file) => ({ - key: `upload:${file.virtual_path || file.filename}`, - filename: file.filename, - path: file.virtual_path, - pathTail: getPathTail(file.virtual_path), - ref_source: "upload" as const, - ref_kind: "mention" as const, - typeLabel: referenceSourceLabels.upload, - isImage: isImageFilename(file.filename), - previewUrl: file.artifact_url, - })) ?? []; - - const deduped = new Map(); - [...artifactCandidates, ...uploadCandidates].forEach((candidate) => { - deduped.set(candidate.key, candidate); + deduped.set(`${refSource}:${path || filename}`, { + key: `${refSource}:${path || filename}`, + filename, + path, + pathTail: getPathTail(path), + ref_source: refSource, + ref_kind: "mention", + typeLabel, + isImage: isImageFilename(filename), + previewUrl, + }); }); return [...deduped.values()]; }, [ + referenceFilesData?.files, referenceSourceLabels.artifact, referenceSourceLabels.upload, - thread.values.artifacts, - uploadedFilesData?.files, threadId, ]); diff --git a/frontend/src/core/artifacts/references.ts b/frontend/src/core/artifacts/references.ts new file mode 100644 index 00000000..e3371396 --- /dev/null +++ b/frontend/src/core/artifacts/references.ts @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query"; + +import { getBackendBaseURL } from "../config"; + +export type ReferenceFileInfo = { + filename: string; + size: string; + virtual_path: string; + artifact_url: string; + source: "artifact" | "upload"; +}; + +type ListReferenceFilesResponse = { + files: ReferenceFileInfo[]; + count: number; +}; + +async function listReferenceFiles( + threadId: string, +): Promise { + const response = await fetch( + `${getBackendBaseURL()}/api/threads/${threadId}/artifacts/list`, + ); + if (!response.ok) { + throw new Error("Failed to list reference files"); + } + return response.json(); +} + +export function useReferenceFiles(threadId: string | undefined) { + return useQuery({ + queryKey: ["references", "list", threadId], + queryFn: () => listReferenceFiles(threadId ?? ""), + enabled: Boolean(threadId), + refetchInterval: 5000, + refetchOnWindowFocus: true, + }); +}