mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
feat(frontend): implement (thought for x second) thinking duration indicator (#3627)
This commit is contained in:
parent
e97d93503d
commit
df5d90fbb1
@ -19,6 +19,7 @@ type ReasoningContextValue = {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (open: boolean) => void;
|
setIsOpen: (open: boolean) => void;
|
||||||
duration: number | undefined;
|
duration: number | undefined;
|
||||||
|
startTime: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||||
@ -37,6 +38,7 @@ export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
|||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
startTimeProp?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTO_CLOSE_DELAY = 1000;
|
const AUTO_CLOSE_DELAY = 1000;
|
||||||
@ -50,6 +52,7 @@ export const Reasoning = memo(
|
|||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
duration: durationProp,
|
duration: durationProp,
|
||||||
|
startTimeProp,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: ReasoningProps) => {
|
}: ReasoningProps) => {
|
||||||
@ -64,19 +67,24 @@ export const Reasoning = memo(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||||
const [startTime, setStartTime] = useState<number | null>(null);
|
const [startTime, setStartTime] = useState<number | null>(
|
||||||
|
() => startTimeProp ?? (isStreaming ? Date.now() : null),
|
||||||
|
);
|
||||||
|
|
||||||
// Track duration when streaming starts and ends
|
// Track duration when streaming starts and ends
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
if (startTime === null) {
|
// Force sync the start time with the Turn start time if provided
|
||||||
|
if (startTimeProp != null && startTime !== startTimeProp) {
|
||||||
|
setStartTime(startTimeProp);
|
||||||
|
} else if (startTimeProp == null && startTime === null) {
|
||||||
setStartTime(Date.now());
|
setStartTime(Date.now());
|
||||||
}
|
}
|
||||||
} else if (startTime !== null) {
|
} else if (startTime !== null) {
|
||||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
||||||
setStartTime(null);
|
setStartTime(null);
|
||||||
}
|
}
|
||||||
}, [isStreaming, startTime, setDuration]);
|
}, [isStreaming, startTimeProp, startTime, setDuration]);
|
||||||
|
|
||||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -97,7 +105,7 @@ export const Reasoning = memo(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ReasoningContext.Provider
|
<ReasoningContext.Provider
|
||||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
value={{ isStreaming, isOpen, setIsOpen, duration, startTime }}
|
||||||
>
|
>
|
||||||
<Collapsible
|
<Collapsible
|
||||||
className={cn("not-prose mb-4", className)}
|
className={cn("not-prose mb-4", className)}
|
||||||
@ -115,10 +123,44 @@ export const Reasoning = memo(
|
|||||||
export type ReasoningTriggerProps = ComponentProps<
|
export type ReasoningTriggerProps = ComponentProps<
|
||||||
typeof CollapsibleTrigger
|
typeof CollapsibleTrigger
|
||||||
> & {
|
> & {
|
||||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
getThinkingMessage?: (
|
||||||
|
isStreaming: boolean,
|
||||||
|
duration?: number,
|
||||||
|
startTime?: number | null,
|
||||||
|
) => ReactNode;
|
||||||
|
hasContent?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
const LiveTimer = ({ startTime }: { startTime: number }) => {
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const calculateElapsed = () => Math.ceil((Date.now() - startTime) / 1000);
|
||||||
|
setElapsed(calculateElapsed());
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setElapsed(calculateElapsed());
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [startTime]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||||
|
<span className="text-muted-foreground/80">({elapsed}s)</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultGetThinkingMessage = (
|
||||||
|
isStreaming: boolean,
|
||||||
|
duration?: number,
|
||||||
|
startTime?: number | null,
|
||||||
|
) => {
|
||||||
|
if (isStreaming && startTime != null && startTime !== undefined) {
|
||||||
|
return <LiveTimer startTime={startTime} />;
|
||||||
|
}
|
||||||
if (isStreaming || duration === 0) {
|
if (isStreaming || duration === 0) {
|
||||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||||
}
|
}
|
||||||
@ -133,14 +175,16 @@ export const ReasoningTrigger = memo(
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
getThinkingMessage = defaultGetThinkingMessage,
|
getThinkingMessage = defaultGetThinkingMessage,
|
||||||
|
hasContent = true,
|
||||||
...props
|
...props
|
||||||
}: ReasoningTriggerProps) => {
|
}: ReasoningTriggerProps) => {
|
||||||
const { isStreaming, isOpen, duration } = useReasoning();
|
const { isStreaming, isOpen, duration, startTime } = useReasoning();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsibleTrigger
|
<CollapsibleTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
|
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
|
||||||
|
!hasContent && "cursor-default",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -148,13 +192,15 @@ export const ReasoningTrigger = memo(
|
|||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
<BrainIcon className="size-4" />
|
<BrainIcon className="size-4" />
|
||||||
{getThinkingMessage(isStreaming, duration)}
|
{getThinkingMessage(isStreaming, duration, startTime)}
|
||||||
|
{hasContent && (
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4 transition-transform",
|
"size-4 transition-transform",
|
||||||
isOpen ? "rotate-180" : "rotate-0",
|
isOpen ? "rotate-180" : "rotate-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
|
useEffect,
|
||||||
type AnchorHTMLAttributes,
|
type AnchorHTMLAttributes,
|
||||||
type ImgHTMLAttributes,
|
type ImgHTMLAttributes,
|
||||||
} from "react";
|
} from "react";
|
||||||
@ -124,6 +125,7 @@ export function MessageListItem({
|
|||||||
runId,
|
runId,
|
||||||
threadId,
|
threadId,
|
||||||
showCopyButton = true,
|
showCopyButton = true,
|
||||||
|
turnStartTime,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
@ -132,6 +134,7 @@ export function MessageListItem({
|
|||||||
feedback?: FeedbackData | null;
|
feedback?: FeedbackData | null;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
showCopyButton?: boolean;
|
showCopyButton?: boolean;
|
||||||
|
turnStartTime?: number | null;
|
||||||
}) {
|
}) {
|
||||||
const isHuman = message.type === "human";
|
const isHuman = message.type === "human";
|
||||||
return (
|
return (
|
||||||
@ -144,6 +147,7 @@ export function MessageListItem({
|
|||||||
message={message}
|
message={message}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
turnStartTime={turnStartTime}
|
||||||
/>
|
/>
|
||||||
{!isLoading && showCopyButton && (
|
{!isLoading && showCopyButton && (
|
||||||
<MessageToolbar
|
<MessageToolbar
|
||||||
@ -211,14 +215,20 @@ function MessageContent_({
|
|||||||
message,
|
message,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
threadId,
|
threadId,
|
||||||
|
turnStartTime,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
turnStartTime?: number | null;
|
||||||
}) {
|
}) {
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
const isHuman = message.type === "human";
|
const isHuman = message.type === "human";
|
||||||
|
const [wasLoading, setWasLoading] = useState(isLoading);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) setWasLoading(true);
|
||||||
|
}, [isLoading]);
|
||||||
const components = useMemo(
|
const components = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
||||||
@ -324,6 +334,14 @@ function MessageContent_({
|
|||||||
return (
|
return (
|
||||||
<AIElementMessageContent className={className}>
|
<AIElementMessageContent className={className}>
|
||||||
{filesList}
|
{filesList}
|
||||||
|
{!isHuman && (!!reasoningContent || wasLoading) && (
|
||||||
|
<Reasoning isStreaming={isLoading} startTimeProp={turnStartTime}>
|
||||||
|
<ReasoningTrigger hasContent={!!reasoningContent} />
|
||||||
|
{reasoningContent && (
|
||||||
|
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
||||||
|
)}
|
||||||
|
</Reasoning>
|
||||||
|
)}
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
content={contentToDisplay}
|
content={contentToDisplay}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { ChevronUpIcon, Loader2Icon } from "lucide-react";
|
import { ChevronUpIcon, Loader2Icon } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
} from "@/components/ai-elements/conversation";
|
} from "@/components/ai-elements/conversation";
|
||||||
|
import {
|
||||||
|
Reasoning,
|
||||||
|
ReasoningTrigger,
|
||||||
|
} from "@/components/ai-elements/reasoning";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
@ -179,10 +183,32 @@ export function MessageList({
|
|||||||
isHistoryLoading?: boolean;
|
isHistoryLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
const [turnStartTime, setTurnStartTime] = useState<number | null>(null);
|
||||||
const updateSubtask = useUpdateSubtask();
|
const prevIsLoading = useRef(thread.isLoading);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (thread.isLoading && !prevIsLoading.current) {
|
||||||
|
setTurnStartTime(Date.now());
|
||||||
|
}
|
||||||
|
prevIsLoading.current = thread.isLoading;
|
||||||
|
}, [thread.isLoading]);
|
||||||
const messages = thread.messages;
|
const messages = thread.messages;
|
||||||
const groupedMessages = getMessageGroups(messages);
|
const groupedMessages = getMessageGroups(messages);
|
||||||
|
const hasActiveAssistantText = useMemo(() => {
|
||||||
|
let lastHumanIndex = -1;
|
||||||
|
for (let i = groupedMessages.length - 1; i >= 0; i--) {
|
||||||
|
if (groupedMessages[i]?.type === "human") {
|
||||||
|
lastHumanIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastHumanIndex === -1) return false;
|
||||||
|
return groupedMessages
|
||||||
|
.slice(lastHumanIndex)
|
||||||
|
.some((g) => g.type === "assistant");
|
||||||
|
}, [groupedMessages]);
|
||||||
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
|
const updateSubtask = useUpdateSubtask();
|
||||||
const lastGroupIndex = groupedMessages.length - 1;
|
const lastGroupIndex = groupedMessages.length - 1;
|
||||||
const turnUsageMessagesByGroupIndex =
|
const turnUsageMessagesByGroupIndex =
|
||||||
getAssistantTurnUsageMessages(groupedMessages);
|
getAssistantTurnUsageMessages(groupedMessages);
|
||||||
@ -296,9 +322,17 @@ export function MessageList({
|
|||||||
<MessageListItem
|
<MessageListItem
|
||||||
key={`${group.id}/${msg.id}`}
|
key={`${group.id}/${msg.id}`}
|
||||||
message={msg}
|
message={msg}
|
||||||
isLoading={thread.isLoading}
|
isLoading={
|
||||||
|
thread.isLoading &&
|
||||||
|
groupIndex === groupedMessages.length - 1
|
||||||
|
}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
showCopyButton={group.type !== "assistant"}
|
showCopyButton={group.type !== "assistant"}
|
||||||
|
turnStartTime={
|
||||||
|
groupIndex === groupedMessages.length - 1
|
||||||
|
? turnStartTime
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -479,7 +513,13 @@ export function MessageList({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
{thread.isLoading && !hasActiveAssistantText && (
|
||||||
|
<div className="w-full">
|
||||||
|
<Reasoning isStreaming={true} startTimeProp={turnStartTime}>
|
||||||
|
<ReasoningTrigger hasContent={false} />
|
||||||
|
</Reasoning>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ height: `${paddingBottom}px` }} />
|
<div style={{ height: `${paddingBottom}px` }} />
|
||||||
</ConversationContent>
|
</ConversationContent>
|
||||||
</Conversation>
|
</Conversation>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user