feat: enhance replay and fast-forward replay

This commit is contained in:
Li Xin 2025-04-24 22:15:17 +08:00
parent cb4b7b7495
commit cb9201bd34
5 changed files with 68 additions and 28 deletions

View File

@ -1,16 +1,19 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates // Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { useSearchParams } from "next/navigation"; import { FastForward } from "lucide-react";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { Button } from "~/components/ui/button";
import { import {
Card, Card,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { fastForwardReplay } from "~/core/api";
import type { Option } from "~/core/messages"; import type { Option } from "~/core/messages";
import { useReplay } from "~/core/replay";
import { sendMessage, useStore } from "~/core/store"; import { sendMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
@ -22,7 +25,7 @@ import { RainbowText } from "./rainbow-text";
export function MessagesBlock({ className }: { className?: string }) { export function MessagesBlock({ className }: { className?: string }) {
const messageCount = useStore((state) => state.messageIds.length); const messageCount = useStore((state) => state.messageIds.length);
const responding = useStore((state) => state.responding); const responding = useStore((state) => state.responding);
const replaying = useSearchParams().get("replay") !== null; const { isReplay } = useReplay();
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const [feedback, setFeedback] = useState<{ option: Option } | null>(null); const [feedback, setFeedback] = useState<{ option: Option } | null>(null);
const handleSend = useCallback( const handleSend = useCallback(
@ -58,6 +61,11 @@ export function MessagesBlock({ className }: { className?: string }) {
const handleRemoveFeedback = useCallback(() => { const handleRemoveFeedback = useCallback(() => {
setFeedback(null); setFeedback(null);
}, [setFeedback]); }, [setFeedback]);
const [fastForwarding, setFastForwarding] = useState(false);
const handleFastForwardReplay = useCallback(() => {
setFastForwarding(!fastForwarding);
fastForwardReplay(!fastForwarding);
}, [fastForwarding]);
return ( return (
<div className={cn("flex h-full flex-col", className)}> <div className={cn("flex h-full flex-col", className)}>
<MessageListView <MessageListView
@ -65,7 +73,7 @@ export function MessagesBlock({ className }: { className?: string }) {
onFeedback={handleFeedback} onFeedback={handleFeedback}
onSendMessage={handleSend} onSendMessage={handleSend}
/> />
{!replaying ? ( {!isReplay ? (
<div className="relative flex h-42 shrink-0 pb-4"> <div className="relative flex h-42 shrink-0 pb-4">
{!responding && messageCount === 0 && ( {!responding && messageCount === 0 && (
<ConversationStarter <ConversationStarter
@ -85,16 +93,32 @@ export function MessagesBlock({ className }: { className?: string }) {
) : ( ) : (
<div className="flex h-42 w-full items-center justify-center"> <div className="flex h-42 w-full items-center justify-center">
<Card className="w-full"> <Card className="w-full">
<CardHeader> <div className="flex items-center justify-between">
<CardTitle> <div className="flex-grow">
<RainbowText animated={responding}>Replay Mode</RainbowText> <CardHeader>
</CardTitle> <CardTitle>
<CardDescription> <RainbowText animated={responding}>Replay Mode</RainbowText>
<RainbowText animated={responding}> </CardTitle>
DeerFlow is now replaying the conversation... <CardDescription>
</RainbowText> <RainbowText animated={responding}>
</CardDescription> DeerFlow is now replaying the conversation...
</CardHeader> </RainbowText>
</CardDescription>
</CardHeader>
</div>
<div className="pr-4">
{responding && (
<Button
className={cn(fastForwarding && "animate-pulse")}
variant={fastForwarding ? "default" : "outline"}
onClick={handleFastForwardReplay}
>
Fast Forward
<FastForward size={16} />
</Button>
)}
</div>
</div>
</Card> </Card>
</div> </div>
)} )}

View File

@ -7,6 +7,7 @@ import { useCallback, useEffect, useState } from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card"; import { Card } from "~/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { useReplay } from "~/core/replay";
import { listenToPodcast, openResearch, useStore } from "~/core/store"; import { listenToPodcast, openResearch, useStore } from "~/core/store";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
@ -32,6 +33,7 @@ export function ResearchBlock({
const reportStreaming = useStore((state) => const reportStreaming = useStore((state) =>
reportId ? (state.messages.get(reportId)?.isStreaming ?? false) : false, reportId ? (state.messages.get(reportId)?.isStreaming ?? false) : false,
); );
const { isReplay } = useReplay();
useEffect(() => { useEffect(() => {
if (hasReport) { if (hasReport) {
setActiveTab("report"); setActiveTab("report");
@ -72,6 +74,7 @@ export function ResearchBlock({
className="text-gray-400" className="text-gray-400"
size="icon" size="icon"
variant="ghost" variant="ghost"
disabled={isReplay}
onClick={handleGeneratePodcast} onClick={handleGeneratePodcast}
> >
<Headphones /> <Headphones />

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import type { MCPServerMetadata } from "../mcp"; import type { MCPServerMetadata } from "../mcp";
import { extractReplayIdFromURL } from "../replay/get-replay-id"; import { extractReplayIdFromSearchParams } from "../replay/get-replay-id";
import { fetchStream } from "../sse"; import { fetchStream } from "../sse";
import { sleep } from "../utils"; import { sleep } from "../utils";
@ -66,7 +66,7 @@ async function* chatReplayStream(
? "/mock/before-interrupt.txt" ? "/mock/before-interrupt.txt"
: "/mock/after-interrupt.txt"; : "/mock/after-interrupt.txt";
} else { } else {
const replayId = extractReplayIdFromURL(); const replayId = extractReplayIdFromSearchParams(window.location.search);
if (replayId) { if (replayId) {
replayFilePath = `/replay/${replayId}.txt`; replayFilePath = `/replay/${replayId}.txt`;
} else { } else {
@ -90,17 +90,17 @@ async function* chatReplayStream(
} as ChatEvent; } as ChatEvent;
if (chatEvent.type === "message_chunk") { if (chatEvent.type === "message_chunk") {
if (!chatEvent.data.finish_reason) { if (!chatEvent.data.finish_reason) {
await sleep(250 + Math.random() * 250); await sleepInReplay(250 + Math.random() * 250);
} }
} else if (chatEvent.type === "tool_call_result") { } else if (chatEvent.type === "tool_call_result") {
await sleep(1500); await sleepInReplay(1500);
} }
yield chatEvent; yield chatEvent;
if (chatEvent.type === "tool_call_result") { if (chatEvent.type === "tool_call_result") {
await sleep(800); await sleepInReplay(800);
} else if (chatEvent.type === "message_chunk") { } else if (chatEvent.type === "message_chunk") {
if (chatEvent.data.role === "user") { if (chatEvent.data.role === "user") {
await sleep(1000); await sleepInReplay(1000);
} }
} }
} catch (e) { } catch (e) {
@ -108,3 +108,16 @@ async function* chatReplayStream(
} }
} }
} }
export async function sleepInReplay(ms: number) {
if (fastForwardReplaying) {
await sleep(0);
} else {
await sleep(ms);
}
}
let fastForwardReplaying = false;
export function fastForwardReplay(value: boolean) {
fastForwardReplaying = value;
}

View File

@ -1,11 +1,8 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates // Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
export function extractReplayIdFromURL() { export function extractReplayIdFromSearchParams(params: string) {
if (typeof window === "undefined") { const urlParams = new URLSearchParams(params);
return null;
}
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("replay")) { if (urlParams.has("replay")) {
return urlParams.get("replay"); return urlParams.get("replay");
} }

View File

@ -1,13 +1,16 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates // Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import { useSearchParams } from "next/navigation";
import { useMemo } from "react"; import { useMemo } from "react";
import { extractReplayIdFromURL } from "./get-replay-id"; import { extractReplayIdFromSearchParams } from "./get-replay-id";
export function useReplay() { export function useReplay() {
const replayId = useMemo(() => { const searchParams = useSearchParams();
return extractReplayIdFromURL(); const replayId = useMemo(
}, []); () => extractReplayIdFromSearchParams(searchParams.toString()),
[searchParams],
);
return { isReplay: replayId != null, replayId }; return { isReplay: replayId != null, replayId };
} }