fix(backend): 修复 replace 标记泄漏到展示层

This commit is contained in:
肖应宇 2026-04-24 17:55:25 +08:00
parent ecf8e11ece
commit 8082fe0823
7 changed files with 98 additions and 17 deletions

View File

@ -87,6 +87,9 @@ class ArtifactReconcileMiddleware(AgentMiddleware[ArtifactReconcileState]):
if not isinstance(artifact, str): if not isinstance(artifact, str):
changed = True changed = True
continue continue
if artifact == ARTIFACTS_REPLACE_SENTINEL:
changed = True
continue
actual_path = self._to_outputs_file(artifact, outputs_dir) actual_path = self._to_outputs_file(artifact, outputs_dir)
if actual_path is None: 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]: def merge_artifacts(existing: list[str] | None, new: list[str] | None) -> list[str]:
"""Reducer for artifacts list - merges and deduplicates artifacts.""" """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: if new and new[0] == ARTIFACTS_REPLACE_SENTINEL:
return list(dict.fromkeys(new[1:])) return list(dict.fromkeys(cleaned_new))
if existing is None: if existing is None:
return new or [] return cleaned_new
if new is None: if new is None:
return existing return cleaned_existing
# Use dict.fromkeys to deduplicate while preserving order # 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]: 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 == { assert result == {
"artifacts": [ARTIFACTS_REPLACE_SENTINEL, "/mnt/user-data/outputs/report.md"] "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

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

View File

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

View File

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