feat: add Replay Mode

This commit is contained in:
Li Xin 2025-04-24 21:51:08 +08:00
parent 3735706065
commit cb4b7b7495
10 changed files with 1035 additions and 40 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1,15 @@
// 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 { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import type { Option } from "~/core/messages"; import type { Option } from "~/core/messages";
import { sendMessage, useStore } from "~/core/store"; import { sendMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
@ -10,10 +17,12 @@ import { cn } from "~/lib/utils";
import { ConversationStarter } from "./conversation-starter"; import { ConversationStarter } from "./conversation-starter";
import { InputBox } from "./input-box"; import { InputBox } from "./input-box";
import { MessageListView } from "./message-list-view"; import { MessageListView } from "./message-list-view";
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 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(
@ -56,22 +65,39 @@ export function MessagesBlock({ className }: { className?: string }) {
onFeedback={handleFeedback} onFeedback={handleFeedback}
onSendMessage={handleSend} onSendMessage={handleSend}
/> />
<div className="relative flex h-42 shrink-0 pb-4"> {!replaying ? (
{!responding && messageCount === 0 && ( <div className="relative flex h-42 shrink-0 pb-4">
<ConversationStarter {!responding && messageCount === 0 && (
className="absolute top-[-218px] left-0" <ConversationStarter
className="absolute top-[-218px] left-0"
onSend={handleSend}
/>
)}
<InputBox
className="h-full w-full"
responding={responding}
feedback={feedback}
onSend={handleSend} onSend={handleSend}
onCancel={handleCancel}
onRemoveFeedback={handleRemoveFeedback}
/> />
)} </div>
<InputBox ) : (
className="h-full w-full" <div className="flex h-42 w-full items-center justify-center">
responding={responding} <Card className="w-full">
feedback={feedback} <CardHeader>
onSend={handleSend} <CardTitle>
onCancel={handleCancel} <RainbowText animated={responding}>Replay Mode</RainbowText>
onRemoveFeedback={handleRemoveFeedback} </CardTitle>
/> <CardDescription>
</div> <RainbowText animated={responding}>
DeerFlow is now replaying the conversation...
</RainbowText>
</CardDescription>
</CardHeader>
</Card>
</div>
)}
</div> </div>
); );
} }

View File

@ -5,10 +5,11 @@
import { GithubOutlined } from "@ant-design/icons"; import { GithubOutlined } from "@ant-design/icons";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { useStore } from "~/core/store"; import { useReplay } from "~/core/replay";
import { sendMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Logo } from "./_components/logo"; import { Logo } from "./_components/logo";
@ -19,11 +20,17 @@ import { Tooltip } from "./_components/tooltip";
import { SettingsDialog } from "./_settings/dialogs/settings-dialog"; import { SettingsDialog } from "./_settings/dialogs/settings-dialog";
export default function HomePage() { export default function HomePage() {
const { isReplay } = useReplay();
const openResearchId = useStore((state) => state.openResearchId); const openResearchId = useStore((state) => state.openResearchId);
const doubleColumnMode = useMemo( const doubleColumnMode = useMemo(
() => openResearchId !== null, () => openResearchId !== null,
[openResearchId], [openResearchId],
); );
useEffect(() => {
if (isReplay) {
void sendMessage();
}
}, [isReplay]);
return ( return (
<div className="flex h-full w-full justify-center"> <div className="flex h-full w-full justify-center">
<header className="fixed top-0 left-0 flex h-12 w-full w-screen items-center justify-between px-4"> <header className="fixed top-0 left-0 flex h-12 w-full w-screen items-center justify-between px-4">

View File

@ -2,6 +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 { fetchStream } from "../sse"; import { fetchStream } from "../sse";
import { sleep } from "../utils"; import { sleep } from "../utils";
@ -28,8 +29,8 @@ export function chatStream(
}, },
options: { abortSignal?: AbortSignal } = {}, options: { abortSignal?: AbortSignal } = {},
) { ) {
if (location.search.includes("mock")) { if (location.search.includes("mock") || location.search.includes("replay=")) {
return chatStreamMock(userMessage, params, options); return chatReplayStream(userMessage, params, options);
} }
return fetchStream<ChatEvent>(resolveServiceURL("chat/stream"), { return fetchStream<ChatEvent>(resolveServiceURL("chat/stream"), {
body: JSON.stringify({ body: JSON.stringify({
@ -40,7 +41,7 @@ export function chatStream(
}); });
} }
async function* chatStreamMock( async function* chatReplayStream(
userMessage: string, userMessage: string,
params: { params: {
thread_id: string; thread_id: string;
@ -57,30 +58,51 @@ async function* chatStreamMock(
}, },
options: { abortSignal?: AbortSignal } = {}, options: { abortSignal?: AbortSignal } = {},
): AsyncIterable<ChatEvent> { ): AsyncIterable<ChatEvent> {
const mockFile = const urlParams = new URLSearchParams(window.location.search);
params.interrupt_feedback === "accepted" let replayFilePath = "";
? "/mock-before-interrupt.txt" if (urlParams.has("mock")) {
: "/mock-after-interrupt.txt"; replayFilePath =
const res = await fetch(mockFile, { params.interrupt_feedback === "accepted"
? "/mock/before-interrupt.txt"
: "/mock/after-interrupt.txt";
} else {
const replayId = extractReplayIdFromURL();
if (replayId) {
replayFilePath = `/replay/${replayId}.txt`;
} else {
replayFilePath = "/mock/before-interrupt.txt";
}
}
const res = await fetch(replayFilePath, {
signal: options.abortSignal, signal: options.abortSignal,
}); });
await sleep(500);
const text = await res.text(); const text = await res.text();
const chunks = text.split("\n\n"); const chunks = text.split("\n\n");
for (const chunk of chunks) { for (const chunk of chunks) {
const [eventRaw, dataRaw] = chunk.split("\n") as [string, string]; const [eventRaw, dataRaw] = chunk.split("\n") as [string, string];
const [, event] = eventRaw.split("event: ", 2) as [string, string]; const [, event] = eventRaw.split("event: ", 2) as [string, string];
const [, data] = dataRaw.split("data: ", 2) as [string, string]; const [, data] = dataRaw.split("data: ", 2) as [string, string];
if (event === "message_chunk") {
await sleep(100);
} else if (event === "tool_call_result") {
await sleep(2000);
}
try { try {
yield { const chatEvent = {
type: event, type: event,
data: JSON.parse(data), data: JSON.parse(data),
} as ChatEvent; } as ChatEvent;
if (chatEvent.type === "message_chunk") {
if (!chatEvent.data.finish_reason) {
await sleep(250 + Math.random() * 250);
}
} else if (chatEvent.type === "tool_call_result") {
await sleep(1500);
}
yield chatEvent;
if (chatEvent.type === "tool_call_result") {
await sleep(800);
} else if (chatEvent.type === "message_chunk") {
if (chatEvent.data.role === "user") {
await sleep(1000);
}
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }

View File

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

View File

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

View File

@ -0,0 +1,4 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export * from "./hooks";

View File

@ -39,7 +39,7 @@ export const useStore = create<{
})); }));
export async function sendMessage( export async function sendMessage(
content: string, content?: string,
{ {
interruptFeedback, interruptFeedback,
}: { }: {
@ -47,13 +47,15 @@ export async function sendMessage(
} = {}, } = {},
options: { abortSignal?: AbortSignal } = {}, options: { abortSignal?: AbortSignal } = {},
) { ) {
appendMessage({ if (content !== undefined) {
id: nanoid(), appendMessage({
threadId: THREAD_ID, id: nanoid(),
role: "user", threadId: THREAD_ID,
content: content, role: "user",
contentChunks: [content], content: content,
}); contentChunks: [content],
});
}
setResponding(true); setResponding(true);
try { try {
@ -104,7 +106,7 @@ export async function sendMessage(
}; };
} }
const stream = chatStream( const stream = chatStream(
content, content ?? "[REPLAY]",
{ {
thread_id: THREAD_ID, thread_id: THREAD_ID,
auto_accepted_plan: generalSettings.autoAcceptedPlan, auto_accepted_plan: generalSettings.autoAcceptedPlan,