fix(frontend): persist artifact panel state (#4580)

This commit is contained in:
qin-chenghan 2026-07-30 15:54:15 +08:00 committed by GitHub
parent c066819f69
commit 0d8e11ad49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 120 additions and 2 deletions

View File

@ -926,6 +926,8 @@ Image bytes loaded for a vision-model call are transient: DeerFlow removes the h
After each run, DeerFlow records a workspace change summary for the run-owned `workspace` and `outputs` directories. The Web UI shows a compact "files changed" badge on the assistant turn; opening it reveals created, modified, and deleted files with text diffs when safe to display. Uploads are excluded because they are user inputs, not agent-generated changes. Large, binary, or sensitive-looking files are shown as metadata only.
Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh.
With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log.
`AioSandboxProvider` normally detects thread-data mounts from its backend: local

View File

@ -68,6 +68,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
1. Optional composer helpers such as `core/input-polish` can rewrite the local draft before submission, and `core/voice-input` can transcribe browser microphone input into that same local draft; confirmed user input then flows to thread hooks (`core/threads/hooks.ts`) → LangGraph SDK streaming
2. Stream events update thread state (messages, artifacts, todos, goal). The main thread stream uses the LangGraph SDK's `throttle: true` mode so updates received in the same macrotask coalesce before React is notified; do not replace it with a numeric delay without validating the SDK's trailing-debounce behavior on a continuous stream.
File-tool artifact auto-open work must run in an effect with timer cleanup; never schedule timers while rendering streamed `write_file` or `str_replace` updates.
`ThreadState.artifacts` remains the authoritative artifact list. The artifacts provider persists only thread-scoped panel UI state (`open`, selected path, and a refresh bootstrap cache) in session storage; an initial empty stream value must not overwrite that restored state before history finishes loading.
3. `useThreadHistory` loads persisted conversation pages from `GET /api/threads/{id}/messages/page`, preserving the backend's thread-global event `seq`; rendering overlays checkpoint/live copies at their matching canonical identities (a summarized checkpoint may contain a protected early input plus a recent tail). Context-compaction rescue diffs every retained visible identity rather than slicing at the first anchor, and keeps a run-scoped ledger of committed visible messages so replacement updates and repeated rolling checkpoint windows cannot erase an already displayed step. The resolver suppresses checkpoint/transient prefixes whose canonical position is still behind an unloaded cursor page instead of collapsing that unknown gap before a recent anchor, then adds optimistic messages without timestamp re-sorting. History invalidation preserves already-loaded pages so their established ordering positions are not discarded.
4. Stop actions call the LangGraph SDK stream stop path; `core/threads/hooks.ts` invalidates current-thread, thread-history, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits
5. TanStack Query manages server state; localStorage stores user settings

View File

@ -1,7 +1,10 @@
import { usePathname } from "next/navigation";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
@ -27,6 +30,46 @@ const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
undefined,
);
const ARTIFACTS_STORAGE_PREFIX = "deerflow:artifacts:v1";
type PersistedArtifactsState = {
artifacts: string[];
selectedArtifact: string | null;
open: boolean;
};
function storageKey(pathname: string) {
return `${ARTIFACTS_STORAGE_PREFIX}:${encodeURIComponent(pathname)}`;
}
function readPersistedState(pathname: string): PersistedArtifactsState | null {
try {
const raw = window.sessionStorage.getItem(storageKey(pathname));
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as Partial<PersistedArtifactsState>;
if (
!Array.isArray(parsed.artifacts) ||
!parsed.artifacts.every((artifact) => typeof artifact === "string") ||
!(
parsed.selectedArtifact === null ||
typeof parsed.selectedArtifact === "string"
) ||
typeof parsed.open !== "boolean"
) {
return null;
}
return {
artifacts: parsed.artifacts,
selectedArtifact: parsed.selectedArtifact,
open: parsed.open,
};
} catch {
return null;
}
}
interface ArtifactsProviderProps {
children: ReactNode;
}
@ -40,6 +83,36 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
);
const [autoOpen, setAutoOpen] = useState(true);
const { setOpen: setSidebarOpen } = useSidebar();
const pathname = usePathname();
const hydratedPathRef = useRef<string | null>(null);
useEffect(() => {
if (!pathname) {
return;
}
const persisted = readPersistedState(pathname);
setArtifacts(persisted?.artifacts ?? []);
setSelectedArtifact(persisted?.selectedArtifact ?? null);
setOpen(persisted?.open ?? env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true");
setAutoOpen(true);
setAutoSelect(!persisted?.selectedArtifact);
hydratedPathRef.current = pathname;
}, [pathname]);
useEffect(() => {
if (!pathname || hydratedPathRef.current !== pathname) {
return;
}
try {
window.sessionStorage.setItem(
storageKey(pathname),
JSON.stringify({ artifacts, selectedArtifact, open }),
);
} catch {
// Browser storage can be disabled or full; panel state must keep working.
}
}, [artifacts, open, pathname, selectedArtifact]);
const select = useCallback(
(artifact: string, autoSelect = false) => {

View File

@ -72,11 +72,12 @@ const ChatBox: React.FC<{
if (threadIdRef.current !== threadId) {
threadIdRef.current = threadId;
deselect();
setArtifacts([]);
}
// Update artifacts from the current thread
if (threadArtifacts) {
// An empty initial state must not erase artifacts restored by the provider
// before the persisted thread state has arrived.
if (threadArtifacts && threadArtifacts.length > 0) {
setArtifacts(threadArtifacts);
}

View File

@ -13,6 +13,7 @@ const MARKDOWN_THREAD_ID = "00000000-0000-0000-0000-000000003121";
const MARKDOWN_ANCHOR_THREAD_ID = "00000000-0000-0000-0000-000000003123";
const JSON_THREAD_ID = "00000000-0000-0000-0000-000000003122";
const PRESENTED_THREAD_ID = "00000000-0000-0000-0000-000000003123";
const PERSISTED_PANEL_THREAD_ID = "00000000-0000-0000-0000-000000003125";
const PDF_THREAD_ID = "00000000-0000-0000-0000-000000003124";
function writeFileMessages({
@ -326,6 +327,46 @@ test.describe("Artifact preview stability", () => {
await expect(artifactsPanel.getByText("Presented Report")).toBeVisible();
});
test("restores the artifact panel and selected file after a page refresh", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: PERSISTED_PANEL_THREAD_ID,
title: "Persisted artifact panel",
messages: presentFilesMessages(),
artifacts: [MARKDOWN_ARTIFACT_PATH],
},
],
});
await page.route(
`**/api/threads/${PERSISTED_PANEL_THREAD_ID}/artifacts/mnt/user-data/outputs/presented-report.md`,
(route) =>
route.fulfill({
status: 200,
contentType: "text/markdown",
body: "# Presented Report\n\nGenerated content",
}),
);
await page.goto(`/workspace/chats/${PERSISTED_PANEL_THREAD_ID}`);
await expect(page.getByText("presented-report.md")).toBeVisible({
timeout: 15_000,
});
await page.getByText("presented-report.md").first().click();
const artifactsPanel = page.locator("#artifacts");
await expect(artifactsPanel.getByText("Presented Report")).toBeVisible();
await page.reload();
await expect(page.getByTestId("artifact-trigger")).toBeVisible();
await expect(
page.locator("#artifacts").getByText("Presented Report"),
).toBeVisible();
});
test("renders sandboxed iframe for a browser-previewable non-code file (urlOfArtifact path)", async ({
page,
}) => {