mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
fix(frontend): persist artifact panel state (#4580)
This commit is contained in:
parent
c066819f69
commit
0d8e11ad49
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user