mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
refactor(frontend): share showcase chat page (#4765)
This commit is contained in:
parent
6cbf20fd39
commit
e23dd8f88b
@ -1,6 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import ChatPage from "@/app/workspace/chats/[thread_id]/page";
|
||||
import ChatPage from "@/components/workspace/chats/chat-page";
|
||||
import { DEMO_THREAD_IDS, isDemoThreadId } from "@/core/threads/static-demo";
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
@ -1,463 +1,5 @@
|
||||
"use client";
|
||||
import ChatPage from "@/components/workspace/chats/chat-page";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { BrowserTrigger } from "@/components/workspace/browser-view";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import { ContextUsageBadge } from "@/components/workspace/context-usage-badge";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { GoalStatus } from "@/components/workspace/goal-status";
|
||||
import {
|
||||
InputBox,
|
||||
type InputBoxSubmitOptions,
|
||||
} from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import {
|
||||
SidecarProvider,
|
||||
SidecarTrigger,
|
||||
} from "@/components/workspace/sidecar";
|
||||
import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { useActiveGoal } from "@/components/workspace/use-active-goal";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useBrowserControlEnabled } from "@/core/features";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
buildHumanInputResponseText,
|
||||
hasOpenHumanInputRequest,
|
||||
type HumanInputRequest,
|
||||
type HumanInputResponse,
|
||||
} from "@/core/messages/human-input";
|
||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
||||
import {
|
||||
useBranchThread,
|
||||
useThreadMetadata,
|
||||
useThreadStream,
|
||||
useThreadTokenUsage,
|
||||
} from "@/core/threads/hooks";
|
||||
import {
|
||||
selectContextUsage,
|
||||
threadTokenUsageToTokenUsage,
|
||||
} from "@/core/threads/token-usage";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
// `isNewThread` tracks whether the backend has the thread yet — gates the
|
||||
// SDK's history fetch (see issue #2746). `isWelcomeMode` is the visual
|
||||
// welcome layout (centered input, hero, quick actions); we flip it to false
|
||||
// the moment the user submits so the UI animates immediately, even though
|
||||
// `isNewThread` stays true until the backend actually creates the thread.
|
||||
const [isWelcomeMode, setIsWelcomeMode] = useState(isNewThread);
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||
const { enabled: browserControlEnabled } = useBrowserControlEnabled();
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const threadTokenUsage = useThreadTokenUsage(
|
||||
isNewThread || isMock ? undefined : threadId,
|
||||
{ enabled: !isMock },
|
||||
);
|
||||
const threadMetadata = useThreadMetadata(threadId, {
|
||||
enabled: !isNewThread && !isMock,
|
||||
isMock,
|
||||
});
|
||||
const branchThread = useBranchThread();
|
||||
const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data);
|
||||
const contextUsage = selectContextUsage(threadTokenUsage.data);
|
||||
const mountedRef = useRef(false);
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
}, []);
|
||||
|
||||
// Keep welcome layout in sync when navigating between threads (sidebar
|
||||
// clicks, "new chat" button). Submitting in /chats/new flips the layout
|
||||
// via onSend below — `isNewThread` stays true until onStart, so this effect
|
||||
// is harmless during the submit transition.
|
||||
useEffect(() => {
|
||||
setIsWelcomeMode(isNewThread);
|
||||
}, [isNewThread]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const {
|
||||
thread,
|
||||
pendingUsageMessages,
|
||||
sendMessage,
|
||||
regenerateMessage,
|
||||
editAndRegenerateMessage,
|
||||
isUploading,
|
||||
isHistoryLoading,
|
||||
hasMoreHistory,
|
||||
loadMoreHistory,
|
||||
} = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
displayThreadId: threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
// onSend only animates the UI; do NOT flip `isNewThread` here — the
|
||||
// LangGraph SDK eagerly fetches /history the moment it receives a
|
||||
// thread id and assumes the thread exists on the backend (issue #2746).
|
||||
onSend: () => {
|
||||
setIsWelcomeMode(false);
|
||||
},
|
||||
onStart: (createdThreadId) => {
|
||||
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
||||
history.replaceState(null, "", `/workspace/chats/${createdThreadId}`);
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const hasThreadMessages = thread.messages.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
threadMetadata.data === null &&
|
||||
!threadMetadata.isLoading &&
|
||||
!threadMetadata.isFetching &&
|
||||
!isHistoryLoading &&
|
||||
!hasMoreHistory &&
|
||||
!hasThreadMessages
|
||||
) {
|
||||
router.replace("/workspace/chats/new");
|
||||
}
|
||||
}, [
|
||||
hasMoreHistory,
|
||||
hasThreadMessages,
|
||||
isHistoryLoading,
|
||||
isMock,
|
||||
isNewThread,
|
||||
router,
|
||||
threadMetadata.data,
|
||||
threadMetadata.isFetching,
|
||||
threadMetadata.isLoading,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage, options?: InputBoxSubmitOptions) => {
|
||||
const sendPromise = sendMessage(threadId, message, undefined, options);
|
||||
if (message.files.length > 0) {
|
||||
return sendPromise;
|
||||
}
|
||||
void sendPromise;
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleSubmitHumanInput = useCallback(
|
||||
async (request: HumanInputRequest, response: HumanInputResponse) => {
|
||||
let sent = false;
|
||||
await sendMessage(
|
||||
threadId,
|
||||
{
|
||||
text: buildHumanInputResponseText(request, response),
|
||||
files: [],
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
additionalKwargs: {
|
||||
hide_from_ui: true,
|
||||
human_input_response: response,
|
||||
},
|
||||
onSent: () => {
|
||||
sent = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
return sent;
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
const handleRegenerate = useCallback(
|
||||
(messageId: string, supersededMessageIds: string[]) =>
|
||||
regenerateMessage(threadId, messageId, supersededMessageIds),
|
||||
[regenerateMessage, threadId],
|
||||
);
|
||||
const handleEditAndRegenerate = useCallback(
|
||||
(messageId: string, replacementText: string) =>
|
||||
editAndRegenerateMessage(threadId, messageId, replacementText),
|
||||
[editAndRegenerateMessage, threadId],
|
||||
);
|
||||
const handleBranchTurn = useCallback(
|
||||
async (messageId: string, messageIds: string[]) => {
|
||||
if (
|
||||
isNewThread ||
|
||||
isMock ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await branchThread.mutateAsync({
|
||||
threadId,
|
||||
messageId,
|
||||
messageIds,
|
||||
});
|
||||
toast.success(t.conversation.branchCreated);
|
||||
router.push(`/workspace/chats/${response.thread_id}`);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t.conversation.branchFailed,
|
||||
);
|
||||
}
|
||||
},
|
||||
[branchThread, isMock, isNewThread, router, t, threadId],
|
||||
);
|
||||
|
||||
const tokenUsageInlineMode = tokenUsageEnabled
|
||||
? localSettings.tokenUsage.inlineMode
|
||||
: "off";
|
||||
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
|
||||
const browserEnabled = !isNewThread && !isMock && browserControlEnabled;
|
||||
const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal(
|
||||
threadId,
|
||||
thread.values.goal,
|
||||
);
|
||||
const hasOpenHumanInputCard = useMemo(
|
||||
() =>
|
||||
hasOpenHumanInputRequest(
|
||||
thread.messages,
|
||||
(message) => !isHiddenFromUIMessage(message),
|
||||
),
|
||||
[thread.messages],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<SidecarProvider
|
||||
parentThreadId={threadId}
|
||||
context={settings.context}
|
||||
isMock={isMock}
|
||||
>
|
||||
<ChatBox threadId={threadId} browserEnabled={browserEnabled}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
|
||||
isWelcomeMode
|
||||
? "bg-background/0 backdrop-blur-none"
|
||||
: "bg-background/80 shadow-xs backdrop-blur",
|
||||
)}
|
||||
>
|
||||
{!isMock && <SidebarTrigger className="md:hidden" />}
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!isNewThread && !isMock && (
|
||||
<ThreadScheduledTasksLink threadId={threadId} />
|
||||
)}
|
||||
{tokenUsageEnabled ? (
|
||||
<TokenUsageIndicator
|
||||
threadId={isNewThread ? undefined : threadId}
|
||||
backendUsage={backendTokenUsage}
|
||||
contextUsage={contextUsage}
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
pendingMessages={pendingUsageMessages}
|
||||
preferences={localSettings.tokenUsage}
|
||||
onPreferencesChange={(preferences) =>
|
||||
setLocalSettings("tokenUsage", preferences)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ContextUsageBadge contextUsage={contextUsage} />
|
||||
)}
|
||||
<SidecarTrigger />
|
||||
{browserEnabled && <BrowserTrigger />}
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex min-h-0 flex-1 justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isWelcomeMode && "pt-10")}
|
||||
testId="main-message-list"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
loadMoreHistory={loadMoreHistory}
|
||||
isHistoryLoading={isHistoryLoading}
|
||||
tokenUsageInlineMode={tokenUsageInlineMode}
|
||||
canRegenerate={
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
|
||||
!isUploading &&
|
||||
!thread.isLoading
|
||||
}
|
||||
onRegenerateMessage={handleRegenerate}
|
||||
canEdit={
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
|
||||
!isUploading &&
|
||||
!thread.isLoading &&
|
||||
!branchThread.isPending &&
|
||||
!hasGoal &&
|
||||
!hasOpenHumanInputCard
|
||||
}
|
||||
onEditAndRegenerateMessage={handleEditAndRegenerate}
|
||||
onSubmitHumanInput={
|
||||
isMock || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
? undefined
|
||||
: handleSubmitHumanInput
|
||||
}
|
||||
canBranch={
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
|
||||
!isUploading &&
|
||||
!thread.isLoading &&
|
||||
!branchThread.isPending
|
||||
}
|
||||
onBranchTurn={handleBranchTurn}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
|
||||
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isWelcomeMode &&
|
||||
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
|
||||
isWelcomeMode
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
{(hasGoal || hasTodos) && (
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 left-0 z-0",
|
||||
isWelcomeMode ? "absolute -top-4" : "relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0 flex flex-col",
|
||||
isWelcomeMode ? "absolute" : "relative",
|
||||
)}
|
||||
>
|
||||
{activeGoal && <GoalStatus goal={activeGoal} />}
|
||||
{hasTodos && (
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mountedRef.current ? (
|
||||
<InputBox
|
||||
className={cn(
|
||||
"bg-background/5 w-full",
|
||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||
)}
|
||||
isWelcomeMode={isWelcomeMode}
|
||||
threadId={threadId}
|
||||
draftThreadId={isNewThread ? "new" : threadId}
|
||||
autoFocus={isWelcomeMode}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isWelcomeMode &&
|
||||
!hasGoal &&
|
||||
!hasTodos && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
isMock ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading ||
|
||||
(!isNewThread && isHistoryLoading)
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onGoalChange={setLocalGoal}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-background/5 h-32 w-full rounded-2xl",
|
||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</SidecarProvider>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
export default function WorkspaceChatPage() {
|
||||
return <ChatPage />;
|
||||
}
|
||||
|
||||
462
frontend/src/components/workspace/chats/chat-page.tsx
Normal file
462
frontend/src/components/workspace/chats/chat-page.tsx
Normal file
@ -0,0 +1,462 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { BrowserTrigger } from "@/components/workspace/browser-view";
|
||||
import { ContextUsageBadge } from "@/components/workspace/context-usage-badge";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { GoalStatus } from "@/components/workspace/goal-status";
|
||||
import {
|
||||
InputBox,
|
||||
type InputBoxSubmitOptions,
|
||||
} from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import {
|
||||
SidecarProvider,
|
||||
SidecarTrigger,
|
||||
} from "@/components/workspace/sidecar";
|
||||
import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { useActiveGoal } from "@/components/workspace/use-active-goal";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useBrowserControlEnabled } from "@/core/features";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
buildHumanInputResponseText,
|
||||
hasOpenHumanInputRequest,
|
||||
type HumanInputRequest,
|
||||
type HumanInputResponse,
|
||||
} from "@/core/messages/human-input";
|
||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
||||
import {
|
||||
useBranchThread,
|
||||
useThreadMetadata,
|
||||
useThreadStream,
|
||||
useThreadTokenUsage,
|
||||
} from "@/core/threads/hooks";
|
||||
import {
|
||||
selectContextUsage,
|
||||
threadTokenUsageToTokenUsage,
|
||||
} from "@/core/threads/token-usage";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ChatBox } from "./chat-box";
|
||||
import { useSpecificChatMode } from "./use-chat-mode";
|
||||
import { useThreadChat } from "./use-thread-chat";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
// `isNewThread` tracks whether the backend has the thread yet — gates the
|
||||
// SDK's history fetch (see issue #2746). `isWelcomeMode` is the visual
|
||||
// welcome layout (centered input, hero, quick actions); we flip it to false
|
||||
// the moment the user submits so the UI animates immediately, even though
|
||||
// `isNewThread` stays true until the backend actually creates the thread.
|
||||
const [isWelcomeMode, setIsWelcomeMode] = useState(isNewThread);
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||
const { enabled: browserControlEnabled } = useBrowserControlEnabled();
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const threadTokenUsage = useThreadTokenUsage(
|
||||
isNewThread || isMock ? undefined : threadId,
|
||||
{ enabled: !isMock },
|
||||
);
|
||||
const threadMetadata = useThreadMetadata(threadId, {
|
||||
enabled: !isNewThread && !isMock,
|
||||
isMock,
|
||||
});
|
||||
const branchThread = useBranchThread();
|
||||
const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data);
|
||||
const contextUsage = selectContextUsage(threadTokenUsage.data);
|
||||
const mountedRef = useRef(false);
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
}, []);
|
||||
|
||||
// Keep welcome layout in sync when navigating between threads (sidebar
|
||||
// clicks, "new chat" button). Submitting in /chats/new flips the layout
|
||||
// via onSend below — `isNewThread` stays true until onStart, so this effect
|
||||
// is harmless during the submit transition.
|
||||
useEffect(() => {
|
||||
setIsWelcomeMode(isNewThread);
|
||||
}, [isNewThread]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const {
|
||||
thread,
|
||||
pendingUsageMessages,
|
||||
sendMessage,
|
||||
regenerateMessage,
|
||||
editAndRegenerateMessage,
|
||||
isUploading,
|
||||
isHistoryLoading,
|
||||
hasMoreHistory,
|
||||
loadMoreHistory,
|
||||
} = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
displayThreadId: threadId,
|
||||
context: settings.context,
|
||||
isMock,
|
||||
// onSend only animates the UI; do NOT flip `isNewThread` here — the
|
||||
// LangGraph SDK eagerly fetches /history the moment it receives a
|
||||
// thread id and assumes the thread exists on the backend (issue #2746).
|
||||
onSend: () => {
|
||||
setIsWelcomeMode(false);
|
||||
},
|
||||
onStart: (createdThreadId) => {
|
||||
// ! Important: Never use next.js router for navigation in this case, otherwise it will cause the thread to re-mount and lose all states. Use native history API instead.
|
||||
history.replaceState(null, "", `/workspace/chats/${createdThreadId}`);
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const hasThreadMessages = thread.messages.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
threadMetadata.data === null &&
|
||||
!threadMetadata.isLoading &&
|
||||
!threadMetadata.isFetching &&
|
||||
!isHistoryLoading &&
|
||||
!hasMoreHistory &&
|
||||
!hasThreadMessages
|
||||
) {
|
||||
router.replace("/workspace/chats/new");
|
||||
}
|
||||
}, [
|
||||
hasMoreHistory,
|
||||
hasThreadMessages,
|
||||
isHistoryLoading,
|
||||
isMock,
|
||||
isNewThread,
|
||||
router,
|
||||
threadMetadata.data,
|
||||
threadMetadata.isFetching,
|
||||
threadMetadata.isLoading,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage, options?: InputBoxSubmitOptions) => {
|
||||
const sendPromise = sendMessage(threadId, message, undefined, options);
|
||||
if (message.files.length > 0) {
|
||||
return sendPromise;
|
||||
}
|
||||
void sendPromise;
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleSubmitHumanInput = useCallback(
|
||||
async (request: HumanInputRequest, response: HumanInputResponse) => {
|
||||
let sent = false;
|
||||
await sendMessage(
|
||||
threadId,
|
||||
{
|
||||
text: buildHumanInputResponseText(request, response),
|
||||
files: [],
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
additionalKwargs: {
|
||||
hide_from_ui: true,
|
||||
human_input_response: response,
|
||||
},
|
||||
onSent: () => {
|
||||
sent = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
return sent;
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
const handleRegenerate = useCallback(
|
||||
(messageId: string, supersededMessageIds: string[]) =>
|
||||
regenerateMessage(threadId, messageId, supersededMessageIds),
|
||||
[regenerateMessage, threadId],
|
||||
);
|
||||
const handleEditAndRegenerate = useCallback(
|
||||
(messageId: string, replacementText: string) =>
|
||||
editAndRegenerateMessage(threadId, messageId, replacementText),
|
||||
[editAndRegenerateMessage, threadId],
|
||||
);
|
||||
const handleBranchTurn = useCallback(
|
||||
async (messageId: string, messageIds: string[]) => {
|
||||
if (
|
||||
isNewThread ||
|
||||
isMock ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await branchThread.mutateAsync({
|
||||
threadId,
|
||||
messageId,
|
||||
messageIds,
|
||||
});
|
||||
toast.success(t.conversation.branchCreated);
|
||||
router.push(`/workspace/chats/${response.thread_id}`);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t.conversation.branchFailed,
|
||||
);
|
||||
}
|
||||
},
|
||||
[branchThread, isMock, isNewThread, router, t, threadId],
|
||||
);
|
||||
|
||||
const tokenUsageInlineMode = tokenUsageEnabled
|
||||
? localSettings.tokenUsage.inlineMode
|
||||
: "off";
|
||||
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
|
||||
const browserEnabled = !isNewThread && !isMock && browserControlEnabled;
|
||||
const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal(
|
||||
threadId,
|
||||
thread.values.goal,
|
||||
);
|
||||
const hasOpenHumanInputCard = useMemo(
|
||||
() =>
|
||||
hasOpenHumanInputRequest(
|
||||
thread.messages,
|
||||
(message) => !isHiddenFromUIMessage(message),
|
||||
),
|
||||
[thread.messages],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<SidecarProvider
|
||||
parentThreadId={threadId}
|
||||
context={settings.context}
|
||||
isMock={isMock}
|
||||
>
|
||||
<ChatBox threadId={threadId} browserEnabled={browserEnabled}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
|
||||
isWelcomeMode
|
||||
? "bg-background/0 backdrop-blur-none"
|
||||
: "bg-background/80 shadow-xs backdrop-blur",
|
||||
)}
|
||||
>
|
||||
{!isMock && <SidebarTrigger className="md:hidden" />}
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!isNewThread && !isMock && (
|
||||
<ThreadScheduledTasksLink threadId={threadId} />
|
||||
)}
|
||||
{tokenUsageEnabled ? (
|
||||
<TokenUsageIndicator
|
||||
threadId={isNewThread ? undefined : threadId}
|
||||
backendUsage={backendTokenUsage}
|
||||
contextUsage={contextUsage}
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
pendingMessages={pendingUsageMessages}
|
||||
preferences={localSettings.tokenUsage}
|
||||
onPreferencesChange={(preferences) =>
|
||||
setLocalSettings("tokenUsage", preferences)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ContextUsageBadge contextUsage={contextUsage} />
|
||||
)}
|
||||
<SidecarTrigger />
|
||||
{browserEnabled && <BrowserTrigger />}
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex min-h-0 flex-1 justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isWelcomeMode && "pt-10")}
|
||||
testId="main-message-list"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
loadMoreHistory={loadMoreHistory}
|
||||
isHistoryLoading={isHistoryLoading}
|
||||
tokenUsageInlineMode={tokenUsageInlineMode}
|
||||
canRegenerate={
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
|
||||
!isUploading &&
|
||||
!thread.isLoading
|
||||
}
|
||||
onRegenerateMessage={handleRegenerate}
|
||||
canEdit={
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
|
||||
!isUploading &&
|
||||
!thread.isLoading &&
|
||||
!branchThread.isPending &&
|
||||
!hasGoal &&
|
||||
!hasOpenHumanInputCard
|
||||
}
|
||||
onEditAndRegenerateMessage={handleEditAndRegenerate}
|
||||
onSubmitHumanInput={
|
||||
isMock || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
? undefined
|
||||
: handleSubmitHumanInput
|
||||
}
|
||||
canBranch={
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
|
||||
!isUploading &&
|
||||
!thread.isLoading &&
|
||||
!branchThread.isPending
|
||||
}
|
||||
onBranchTurn={handleBranchTurn}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
|
||||
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isWelcomeMode &&
|
||||
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
|
||||
isWelcomeMode
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
{(hasGoal || hasTodos) && (
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 left-0 z-0",
|
||||
isWelcomeMode ? "absolute -top-4" : "relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0 flex flex-col",
|
||||
isWelcomeMode ? "absolute" : "relative",
|
||||
)}
|
||||
>
|
||||
{activeGoal && <GoalStatus goal={activeGoal} />}
|
||||
{hasTodos && (
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mountedRef.current ? (
|
||||
<InputBox
|
||||
className={cn(
|
||||
"bg-background/5 w-full",
|
||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||
)}
|
||||
isWelcomeMode={isWelcomeMode}
|
||||
threadId={threadId}
|
||||
draftThreadId={isNewThread ? "new" : threadId}
|
||||
autoFocus={isWelcomeMode}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isWelcomeMode &&
|
||||
!hasGoal &&
|
||||
!hasTodos && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
isMock ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading ||
|
||||
(!isNewThread && isHistoryLoading)
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onGoalChange={setLocalGoal}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-background/5 h-32 w-full rounded-2xl",
|
||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</SidecarProvider>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { SHOWCASE_ROUTE_PREFIX } from "@/core/threads/static-demo";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
|
||||
export const THREAD_CHAT_RESET_EVENT = "deer-flow:thread-chat-reset";
|
||||
@ -120,7 +121,7 @@ export function useThreadChat() {
|
||||
}, []);
|
||||
|
||||
const isMock =
|
||||
actualPathname.startsWith("/showcase/") ||
|
||||
actualPathname.startsWith(`${SHOWCASE_ROUTE_PREFIX}/`) ||
|
||||
searchParams.get("mock") === "true";
|
||||
return {
|
||||
threadId: isNewPath ? (newThreadIdRef.current ?? threadId) : threadId,
|
||||
|
||||
@ -19,6 +19,8 @@ export const DEMO_THREAD_IDS = [
|
||||
"fe3f7974-1bcb-4a01-a950-79673baafefd",
|
||||
] as const;
|
||||
|
||||
export const SHOWCASE_ROUTE_PREFIX = "/showcase";
|
||||
|
||||
export const STATIC_DEMO_ARTIFACTS: Readonly<
|
||||
Record<string, readonly string[]>
|
||||
> = {
|
||||
@ -132,7 +134,7 @@ export function isDemoThreadId(threadId: string): boolean {
|
||||
}
|
||||
|
||||
export function pathOfPublicDemoThread(threadId: string): string {
|
||||
return `/showcase/${encodeURIComponent(threadId)}`;
|
||||
return `${SHOWCASE_ROUTE_PREFIX}/${encodeURIComponent(threadId)}`;
|
||||
}
|
||||
|
||||
export type ThreadSearchParams = NonNullable<
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user