fix(backend): 修复 replace 标记泄漏到展示层
This commit is contained in:
parent
ecf8e11ece
commit
8082fe0823
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue