Compare commits

...

2 Commits

11 changed files with 268 additions and 54 deletions

View File

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

View File

@ -87,6 +87,9 @@ class ArtifactReconcileMiddleware(AgentMiddleware[ArtifactReconcileState]):
if not isinstance(artifact, str):
changed = True
continue
if artifact == ARTIFACTS_REPLACE_SENTINEL:
changed = True
continue
actual_path = self._to_outputs_file(artifact, outputs_dir)
if actual_path is None:

View File

@ -22,14 +22,22 @@ class ViewedImageData(TypedDict):
def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
"""Reducer for artifacts list - merges and deduplicates artifacts."""
def _clean(values: list[str] | None) -> list[str]:
if not values:
return []
return [v for v in values if isinstance(v, str) and v != ARTIFACTS_REPLACE_SENTINEL]
cleaned_existing = _clean(existing)
cleaned_new = _clean(new)
if new and new[0] == ARTIFACTS_REPLACE_SENTINEL:
return list(dict.fromkeys(new[1:]))
return list(dict.fromkeys(cleaned_new))
if existing is None:
return new or []
return cleaned_new
if new is None:
return existing
return cleaned_existing
# Use dict.fromkeys to deduplicate while preserving order
return list(dict.fromkeys(existing + new))
return list(dict.fromkeys(cleaned_existing + cleaned_new))
def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[str, ViewedImageData] | None) -> dict[str, ViewedImageData]:

View File

@ -87,3 +87,25 @@ def test_before_model_discovers_outputs_when_artifacts_empty(tmp_path):
assert result == {
"artifacts": [ARTIFACTS_REPLACE_SENTINEL, "/mnt/user-data/outputs/report.md"]
}
def test_before_model_drops_leaked_replace_sentinel(tmp_path):
outputs_dir = tmp_path / "outputs"
outputs_dir.mkdir()
keep = outputs_dir / "keep.md"
keep.write_text("ok", encoding="utf-8")
middleware = ArtifactReconcileMiddleware()
state = {
"thread_data": {"outputs_path": str(outputs_dir)},
"artifacts": [
ARTIFACTS_REPLACE_SENTINEL,
"/mnt/user-data/outputs/keep.md",
],
}
result = middleware.before_model(state, runtime=SimpleNamespace(context={}))
assert result == {
"artifacts": [ARTIFACTS_REPLACE_SENTINEL, "/mnt/user-data/outputs/keep.md"]
}

View File

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

View File

@ -32,3 +32,35 @@ def test_merge_artifacts_supports_replace_sentinel():
"/mnt/user-data/outputs/b.md",
"/mnt/user-data/outputs/c.md",
]
def test_merge_artifacts_always_strips_sentinel_from_existing():
existing = [
"/mnt/user-data/outputs/a.md",
ARTIFACTS_REPLACE_SENTINEL,
"/mnt/user-data/outputs/b.md",
]
result = merge_artifacts(existing, None)
assert result == [
"/mnt/user-data/outputs/a.md",
"/mnt/user-data/outputs/b.md",
]
def test_merge_artifacts_strips_sentinel_from_non_replace_payload():
existing = ["/mnt/user-data/outputs/a.md"]
new = [
"/mnt/user-data/outputs/b.md",
ARTIFACTS_REPLACE_SENTINEL,
"/mnt/user-data/outputs/c.md",
]
result = merge_artifacts(existing, new)
assert result == [
"/mnt/user-data/outputs/a.md",
"/mnt/user-data/outputs/b.md",
"/mnt/user-data/outputs/c.md",
]

View File

@ -32,6 +32,7 @@ import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { Welcome } from "@/components/workspace/welcome";
import { getAPIClient } from "@/core/api";
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useNotification } from "@/core/notification/hooks";
@ -210,6 +211,10 @@ export default function ChatPage() {
const result = thread.values?.title ?? "";
return result === "Untitled" ? "" : result;
}, [thread.values?.title]);
const sanitizedArtifacts = useMemo(
() => sanitizeArtifactPaths(thread.values.artifacts),
[thread.values.artifacts],
);
const [hasSubmitted, setHasSubmitted] = useState(false);
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
@ -258,21 +263,21 @@ export default function ChatPage() {
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => {
setArtifacts(thread.values.artifacts);
setArtifacts(sanitizedArtifacts);
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (thread?.values?.artifacts?.length > 0) {
if (sanitizedArtifacts.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
selectArtifact(sanitizedArtifacts[0]!);
}
}
}, [
autoSelectFirstArtifact,
sanitizedArtifacts,
selectArtifact,
setArtifacts,
thread.values.artifacts,
]);
const artifactPanelOpen = useMemo(() => {
@ -492,7 +497,7 @@ export default function ChatPage() {
) : (
<div className="relative flex size-full justify-center px-[20px]">
<div className="z-30"></div>
{thread.values.artifacts?.length === 0 ? (
{sanitizedArtifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title={t.chatPage.noArtifactSelectedTitle}
@ -518,7 +523,7 @@ export default function ChatPage() {
<main className="min-h-0 grow overflow-auto">
<ArtifactFileList
className="mb-[207px] max-w-(--container-width-sm) pt-[20px]"
files={thread.values.artifacts ?? []}
files={sanitizedArtifacts}
threadId={threadId}
/>
</main>

View File

@ -5,6 +5,7 @@ import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { sanitizeArtifactPaths } from "@/core/artifacts/utils";
import {
ResizableHandle,
ResizablePanel,
@ -43,6 +44,10 @@ const ChatBox: React.FC<{
deselect,
selectedArtifact,
} = useArtifacts();
const sanitizedArtifacts = useMemo(
() => sanitizeArtifactPaths(thread.values.artifacts),
[thread.values.artifacts],
);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => {
@ -52,7 +57,7 @@ const ChatBox: React.FC<{
}
// Update artifacts from the current thread
setArtifacts(thread.values.artifacts);
setArtifacts(sanitizedArtifacts);
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
// if (
@ -66,19 +71,19 @@ const ChatBox: React.FC<{
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (thread?.values?.artifacts?.length > 0) {
if (sanitizedArtifacts.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
selectArtifact(sanitizedArtifacts[0]!);
}
}
}, [
threadId,
autoSelectFirstArtifact,
deselect,
sanitizedArtifacts,
selectArtifact,
selectedArtifact,
setArtifacts,
thread.values.artifacts,
]);
const artifactPanelOpen = useMemo(() => {
@ -151,7 +156,7 @@ const ChatBox: React.FC<{
<XIcon />
</Button>
</div>
{thread.values.artifacts?.length === 0 ? (
{sanitizedArtifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title={t.chatPage.noArtifactSelectedTitle}
@ -167,7 +172,7 @@ const ChatBox: React.FC<{
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
files={sanitizedArtifacts}
threadId={threadId ?? ""}
/>
</main>

View File

@ -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<MentionCandidate[]>(() => {
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<string, MentionCandidate>();
(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<string, MentionCandidate>();
[...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,
]);

View File

@ -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<ListReferenceFilesResponse> {
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,
});
}

View File

@ -1,6 +1,8 @@
import { getBackendBaseURL } from "../config";
import type { AgentThread } from "../threads";
const ARTIFACTS_REPLACE_SENTINEL = "__deerflow_replace_artifacts__";
export function urlOfArtifact({
filepath,
threadId,
@ -19,9 +21,13 @@ export function urlOfArtifact({
}
export function extractArtifactsFromThread(thread: AgentThread) {
return thread.values.artifacts ?? [];
return sanitizeArtifactPaths(thread.values.artifacts);
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
}
export function sanitizeArtifactPaths(paths: string[] | undefined | null) {
return (paths ?? []).filter((path) => path !== ARTIFACTS_REPLACE_SENTINEL);
}