mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-10 22:18:59 +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 { 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";
|
import { DEMO_THREAD_IDS, isDemoThreadId } from "@/core/threads/static-demo";
|
||||||
|
|
||||||
export const dynamicParams = false;
|
export const dynamicParams = false;
|
||||||
|
|||||||
@ -1,463 +1,5 @@
|
|||||||
"use client";
|
import ChatPage from "@/components/workspace/chats/chat-page";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
export default function WorkspaceChatPage() {
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
return <ChatPage />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { SHOWCASE_ROUTE_PREFIX } from "@/core/threads/static-demo";
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
|
|
||||||
export const THREAD_CHAT_RESET_EVENT = "deer-flow:thread-chat-reset";
|
export const THREAD_CHAT_RESET_EVENT = "deer-flow:thread-chat-reset";
|
||||||
@ -120,7 +121,7 @@ export function useThreadChat() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isMock =
|
const isMock =
|
||||||
actualPathname.startsWith("/showcase/") ||
|
actualPathname.startsWith(`${SHOWCASE_ROUTE_PREFIX}/`) ||
|
||||||
searchParams.get("mock") === "true";
|
searchParams.get("mock") === "true";
|
||||||
return {
|
return {
|
||||||
threadId: isNewPath ? (newThreadIdRef.current ?? threadId) : threadId,
|
threadId: isNewPath ? (newThreadIdRef.current ?? threadId) : threadId,
|
||||||
|
|||||||
@ -19,6 +19,8 @@ export const DEMO_THREAD_IDS = [
|
|||||||
"fe3f7974-1bcb-4a01-a950-79673baafefd",
|
"fe3f7974-1bcb-4a01-a950-79673baafefd",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const SHOWCASE_ROUTE_PREFIX = "/showcase";
|
||||||
|
|
||||||
export const STATIC_DEMO_ARTIFACTS: Readonly<
|
export const STATIC_DEMO_ARTIFACTS: Readonly<
|
||||||
Record<string, readonly string[]>
|
Record<string, readonly string[]>
|
||||||
> = {
|
> = {
|
||||||
@ -132,7 +134,7 @@ export function isDemoThreadId(threadId: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function pathOfPublicDemoThread(threadId: string): string {
|
export function pathOfPublicDemoThread(threadId: string): string {
|
||||||
return `/showcase/${encodeURIComponent(threadId)}`;
|
return `${SHOWCASE_ROUTE_PREFIX}/${encodeURIComponent(threadId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ThreadSearchParams = NonNullable<
|
export type ThreadSearchParams = NonNullable<
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user