mirror of
https://git.mirrors.martin98.com/https://github.com/bytedance/deer-flow
synced 2025-08-20 01:09:06 +08:00
feat: add Replay Mode
This commit is contained in:
parent
3735706065
commit
cb4b7b7495
908
web/public/replay/nanjing-traditional-dishes.txt
Normal file
908
web/public/replay/nanjing-traditional-dishes.txt
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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">
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
13
web/src/core/replay/get-replay-id.ts
Normal file
13
web/src/core/replay/get-replay-id.ts
Normal 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;
|
||||||
|
}
|
13
web/src/core/replay/hooks.ts
Normal file
13
web/src/core/replay/hooks.ts
Normal 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 };
|
||||||
|
}
|
4
web/src/core/replay/index.ts
Normal file
4
web/src/core/replay/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
export * from "./hooks";
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user