feat: enhance replay mode in static website

This commit is contained in:
Li Xin
2025-05-08 09:53:09 +08:00
parent ff112701f7
commit 574c913240
4 changed files with 124 additions and 26 deletions

View File

@@ -14,9 +14,11 @@ import {
CardTitle,
} from "~/components/ui/card";
import { fastForwardReplay } from "~/core/api";
import { useReplayMetadata } from "~/core/api/hooks";
import type { Option } from "~/core/messages";
import { useReplay } from "~/core/replay";
import { sendMessage, useStore } from "~/core/store";
import { env } from "~/env";
import { cn } from "~/lib/utils";
import { ConversationStarter } from "./conversation-starter";
@@ -28,6 +30,7 @@ export function MessagesBlock({ className }: { className?: string }) {
const messageCount = useStore((state) => state.messageIds.length);
const responding = useStore((state) => state.responding);
const { isReplay } = useReplay();
const { title: replayTitle, hasError: replayHasError } = useReplayMetadata();
const [replayStarted, setReplayStarted] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const [feedback, setFeedback] = useState<{ option: Option } | null>(null);
@@ -123,7 +126,7 @@ export function MessagesBlock({ className }: { className?: string }) {
<CardHeader>
<CardTitle>
<RainbowText animated={responding}>
{responding ? "Replaying" : "Replay Mode"}
{responding ? "Replaying" : `${replayTitle}`}
</RainbowText>
</CardTitle>
<CardDescription>
@@ -137,26 +140,43 @@ export function MessagesBlock({ className }: { className?: string }) {
</CardDescription>
</CardHeader>
</div>
<div className="pr-4">
{responding && (
<Button
className={cn(fastForwarding && "animate-pulse")}
variant={fastForwarding ? "default" : "outline"}
onClick={handleFastForwardReplay}
>
<FastForward size={16} />
Fast Forward
</Button>
)}
{!replayStarted && (
<Button className="w-24" onClick={handleStartReplay}>
<Play size={16} />
Play
</Button>
)}
</div>
{!replayHasError && (
<div className="pr-4">
{responding && (
<Button
className={cn(fastForwarding && "animate-pulse")}
variant={fastForwarding ? "default" : "outline"}
onClick={handleFastForwardReplay}
>
<FastForward size={16} />
Fast Forward
</Button>
)}
{!replayStarted && (
<Button className="w-24" onClick={handleStartReplay}>
<Play size={16} />
Play
</Button>
)}
</div>
)}
</div>
</Card>
{!replayStarted && env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
<div className="text-muted-foreground w-full text-center text-xs">
* This site is for demo purposes only. If you want to try your
own question, please{" "}
<a
className="underline"
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
>
click here
</a>{" "}
to clone it locally and run it.
</div>
)}
</motion.div>
</>
)}

View File

@@ -26,8 +26,8 @@ export function Welcome({ className }: { className?: string }) {
>
🦌 DeerFlow
</a>
, a research tool built on cutting-edge language models, helps you
search on web, browse information, and handle complex tasks.
, a deep research assistant built on cutting-edge language models, helps
you search on web, browse information, and handle complex tasks.
</div>
</motion.div>
);

View File

@@ -1,6 +1,8 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { env } from "~/env";
import type { MCPServerMetadata } from "../mcp";
import { extractReplayIdFromSearchParams } from "../replay/get-replay-id";
import { fetchStream } from "../sse";
@@ -30,7 +32,11 @@ export async function* chatStream(
},
options: { abortSignal?: AbortSignal } = {},
) {
if (location.search.includes("mock") || location.search.includes("replay=")) {
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY ||
location.search.includes("mock") ||
location.search.includes("replay=")
) {
return yield* chatReplayStream(userMessage, params, options);
}
const stream = fetchStream(resolveServiceURL("chat/stream"), {
@@ -77,13 +83,13 @@ async function* chatReplayStream(
if (replayId) {
replayFilePath = `/replay/${replayId}.txt`;
} else {
replayFilePath = "/mock/before-interrupt.txt";
// Fallback to a default replay
replayFilePath = `/replay/eiffel-tower-vs-tallest-building.txt`;
}
}
const res = await fetch(replayFilePath, {
signal: options.abortSignal,
const text = await fetchReplay(replayFilePath, {
abortSignal: options.abortSignal,
});
const text = await res.text();
const chunks = text.split("\n\n");
for (const chunk of chunks) {
const [eventRaw, dataRaw] = chunk.split("\n") as [string, string];
@@ -116,6 +122,43 @@ async function* chatReplayStream(
}
}
const replayCache = new Map<string, string>();
export async function fetchReplay(
url: string,
options: { abortSignal?: AbortSignal } = {},
) {
if (replayCache.has(url)) {
return replayCache.get(url)!;
}
const res = await fetch(url, {
signal: options.abortSignal,
});
if (!res.ok) {
throw new Error(`Failed to fetch replay: ${res.statusText}`);
}
const text = await res.text();
replayCache.set(url, text);
return text;
}
export async function fetchReplayTitle() {
const res = chatReplayStream(
"",
{
thread_id: "__mock__",
auto_accepted_plan: false,
max_plan_iterations: 3,
max_step_num: 1,
},
{},
);
for await (const event of res) {
if (event.type === "message_chunk") {
return event.data.content;
}
}
}
export async function sleepInReplay(ms: number) {
if (fastForwardReplaying) {
await sleep(0);

35
web/src/core/api/hooks.ts Normal file
View File

@@ -0,0 +1,35 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { useEffect, useState } from "react";
import { fetchReplayTitle } from "./chat";
export function useReplayMetadata() {
const [title, setTitle] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<boolean>(false);
useEffect(() => {
if (title || isLoading) {
return;
}
setIsLoading(true);
fetchReplayTitle()
.then((title) => {
setError(false);
setTitle(title ?? null);
if (title) {
document.title = `${title} - DeerFlow`;
}
})
.catch(() => {
setError(true);
setTitle("Error: the replay is not available.");
document.title = "DeerFlow";
})
.finally(() => {
setIsLoading(false);
});
}, [isLoading, title]);
return { title, isLoading, hasError: error };
}