mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-20 12:54:45 +08:00
feat: re-implement message group
This commit is contained in:
@@ -15,7 +15,13 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { createContext, memo, useContext, useMemo } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
isValidElement,
|
||||||
|
memo,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
type ChainOfThoughtContextValue = {
|
type ChainOfThoughtContextValue = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -108,7 +114,7 @@ export const ChainOfThoughtHeader = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon | React.ReactElement;
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
status?: "complete" | "active" | "pending";
|
status?: "complete" | "active" | "pending";
|
||||||
@@ -141,7 +147,7 @@ export const ChainOfThoughtStep = memo(
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="relative mt-0.5">
|
<div className="relative mt-0.5">
|
||||||
<Icon className="size-4" />
|
{isValidElement(Icon) ? Icon : <Icon className="size-4" />}
|
||||||
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
|
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-2 overflow-hidden">
|
<div className="flex-1 space-y-2 overflow-hidden">
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export const CodeBlock = ({
|
|||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="[&>pre]:bg-background! [&>pre]:text-foreground! hidden overflow-auto dark:block [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm"
|
className="[&>pre]:bg-background! [&>pre]:text-foreground! hidden size-full overflow-auto dark:block [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:p-4 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap"
|
||||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||||
dangerouslySetInnerHTML={{ __html: darkHtml }}
|
dangerouslySetInnerHTML={{ __html: darkHtml }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function FlipDisplay({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative h-6 overflow-hidden", className)}>
|
<div className={cn("relative overflow-hidden", className)}>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={uniqueKey}
|
key={uniqueKey}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import {
|
import {
|
||||||
BookOpenTextIcon,
|
BookOpenTextIcon,
|
||||||
|
ChevronUp,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
ListTreeIcon,
|
|
||||||
NotebookPenIcon,
|
NotebookPenIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SquareTerminalIcon,
|
SquareTerminalIcon,
|
||||||
@@ -16,12 +16,12 @@ import { useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
ChainOfThought,
|
ChainOfThought,
|
||||||
ChainOfThoughtContent,
|
ChainOfThoughtContent,
|
||||||
ChainOfThoughtHeader,
|
|
||||||
ChainOfThoughtSearchResult,
|
ChainOfThoughtSearchResult,
|
||||||
ChainOfThoughtSearchResults,
|
ChainOfThoughtSearchResults,
|
||||||
ChainOfThoughtStep,
|
ChainOfThoughtStep,
|
||||||
} from "@/components/ai-elements/chain-of-thought";
|
} from "@/components/ai-elements/chain-of-thought";
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
findToolCallResult,
|
findToolCallResult,
|
||||||
@@ -42,67 +42,123 @@ export function MessageGroup({
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [showAbove, setShowAbove] = useState(false);
|
||||||
|
const [showLastThinking, setShowLastThinking] = useState(false);
|
||||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||||
const stepCount = useMemo(
|
const lastToolCallStep = useMemo(() => {
|
||||||
() => steps.filter((step) => step.type !== "reasoning").length,
|
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||||
[steps],
|
return filteredSteps[filteredSteps.length - 1];
|
||||||
);
|
}, [steps]);
|
||||||
|
const aboveLastToolCallSteps = useMemo(() => {
|
||||||
|
if (lastToolCallStep) {
|
||||||
|
const index = steps.indexOf(lastToolCallStep);
|
||||||
|
return steps.slice(0, index);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [lastToolCallStep, steps]);
|
||||||
|
const lastReasoningStep = useMemo(() => {
|
||||||
|
if (lastToolCallStep) {
|
||||||
|
const index = steps.indexOf(lastToolCallStep);
|
||||||
|
return steps.slice(index + 1).find((step) => step.type === "reasoning");
|
||||||
|
} else {
|
||||||
|
const filteredSteps = steps.filter((step) => step.type === "reasoning");
|
||||||
|
return filteredSteps[filteredSteps.length - 1];
|
||||||
|
}
|
||||||
|
}, [lastToolCallStep, steps]);
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const lastStep = steps[steps.length - 1];
|
|
||||||
const { label, icon } = describeStep(lastStep);
|
|
||||||
return (
|
return (
|
||||||
<ChainOfThought
|
<ChainOfThought
|
||||||
className={cn("w-full rounded-lg border px-3 py-2", className)}
|
className={cn("w-full gap-2 rounded-lg border py-0", className)}
|
||||||
defaultOpen={false}
|
open={true}
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
>
|
>
|
||||||
<ChainOfThoughtHeader
|
{aboveLastToolCallSteps.length > 0 && (
|
||||||
className="min-h-6"
|
<Button
|
||||||
icon={
|
key="above"
|
||||||
open && stepCount > 1 ? <ListTreeIcon className="size-4" /> : icon
|
className="w-full items-start justify-start text-left"
|
||||||
}
|
variant="ghost"
|
||||||
>
|
onClick={() => setShowAbove(!showAbove)}
|
||||||
<div className="flex w-full items-center justify-between">
|
>
|
||||||
<div>
|
<ChainOfThoughtStep
|
||||||
<div>
|
label={
|
||||||
{open && stepCount > 1 ? (
|
<span className="opacity-60">
|
||||||
<div>{stepCount} steps</div>
|
{showAbove
|
||||||
|
? "Less steps"
|
||||||
|
: `${aboveLastToolCallSteps.length} more steps`}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
<ChevronUp
|
||||||
|
className={cn(
|
||||||
|
"size-4 opacity-60 transition-transform duration-200",
|
||||||
|
showAbove ? "rotate-180" : "",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
></ChainOfThoughtStep>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{aboveLastToolCallSteps.length > 0 && (
|
||||||
|
<ChainOfThoughtContent className="px-4 pb-2">
|
||||||
|
{showAbove &&
|
||||||
|
aboveLastToolCallSteps.map((step) =>
|
||||||
|
step.type === "reasoning" ? (
|
||||||
|
<ChainOfThoughtStep
|
||||||
|
key={step.id}
|
||||||
|
label={
|
||||||
|
<MessageResponse rehypePlugins={rehypePlugins}>
|
||||||
|
{step.reasoning ?? ""}
|
||||||
|
</MessageResponse>
|
||||||
|
}
|
||||||
|
></ChainOfThoughtStep>
|
||||||
) : (
|
) : (
|
||||||
<FlipDisplay uniqueKey={`step-${stepCount}`}>
|
<ToolCall key={step.id} {...step} />
|
||||||
<MessageResponse rehypePlugins={rehypePlugins}>
|
),
|
||||||
{label}
|
|
||||||
</MessageResponse>
|
|
||||||
</FlipDisplay>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{!open && stepCount > 1 && (
|
|
||||||
<div>
|
|
||||||
{stepCount > 1 ? `${stepCount} steps` : `${stepCount} step`}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{lastToolCallStep && (
|
||||||
</div>
|
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
|
||||||
</ChainOfThoughtHeader>
|
<ToolCall key={lastToolCallStep.id} {...lastToolCallStep} />
|
||||||
<ChainOfThoughtContent className="pb-2">
|
</FlipDisplay>
|
||||||
{steps.map((step) =>
|
)}
|
||||||
step.type === "reasoning" ? (
|
</ChainOfThoughtContent>
|
||||||
<ChainOfThoughtStep
|
)}
|
||||||
key={step.id}
|
{lastReasoningStep && (
|
||||||
label={
|
<>
|
||||||
<MessageResponse rehypePlugins={rehypePlugins}>
|
<Button
|
||||||
{step.reasoning ?? ""}
|
key={lastReasoningStep.id}
|
||||||
</MessageResponse>
|
className="w-full items-start justify-start text-left"
|
||||||
}
|
variant="ghost"
|
||||||
/>
|
onClick={() => setShowLastThinking(!showLastThinking)}
|
||||||
) : (
|
>
|
||||||
<ToolCall key={step.id} {...step} />
|
<div className="flex w-full items-center justify-between">
|
||||||
),
|
<ChainOfThoughtStep
|
||||||
)}
|
className="font-normal"
|
||||||
</ChainOfThoughtContent>
|
label="Thinking"
|
||||||
|
icon={LightbulbIcon}
|
||||||
|
></ChainOfThoughtStep>
|
||||||
|
<div>
|
||||||
|
<ChevronUp
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground size-4",
|
||||||
|
showLastThinking ? "" : "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
{showLastThinking && (
|
||||||
|
<ChainOfThoughtContent className="px-4 pb-2">
|
||||||
|
<ChainOfThoughtStep
|
||||||
|
key={lastReasoningStep.id}
|
||||||
|
label={
|
||||||
|
<MessageResponse rehypePlugins={rehypePlugins}>
|
||||||
|
{lastReasoningStep.reasoning ?? ""}
|
||||||
|
</MessageResponse>
|
||||||
|
}
|
||||||
|
></ChainOfThoughtStep>
|
||||||
|
</ChainOfThoughtContent>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ChainOfThought>
|
</ChainOfThought>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,7 +212,15 @@ function ToolCall({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep key={id} label="View web page" icon={GlobeIcon}>
|
<ChainOfThoughtStep
|
||||||
|
key={id}
|
||||||
|
className="cursor-pointer"
|
||||||
|
label="View web page"
|
||||||
|
icon={GlobeIcon}
|
||||||
|
onClick={() => {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ChainOfThoughtSearchResult>
|
<ChainOfThoughtSearchResult>
|
||||||
{url && (
|
{url && (
|
||||||
<a href={url} target="_blank" rel="noreferrer">
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
@@ -331,47 +395,3 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
|||||||
}
|
}
|
||||||
return steps;
|
return steps;
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeStep(step: CoTStep | undefined): {
|
|
||||||
label: string;
|
|
||||||
icon: React.ReactElement;
|
|
||||||
} {
|
|
||||||
if (!step) {
|
|
||||||
return { label: "Thinking", icon: <LightbulbIcon className="size-4" /> };
|
|
||||||
}
|
|
||||||
if (step.type === "reasoning") {
|
|
||||||
return { label: "Thinking", icon: <LightbulbIcon className="size-4" /> };
|
|
||||||
} else {
|
|
||||||
let label: string;
|
|
||||||
let icon: React.ReactElement = <WrenchIcon className="size-4" />;
|
|
||||||
if (step.name === "web_search") {
|
|
||||||
label = `Search "${(step.args as { query: string }).query}" on web`;
|
|
||||||
icon = <SearchIcon className="size-4" />;
|
|
||||||
} else if (step.name === "web_fetch") {
|
|
||||||
label = "View web page";
|
|
||||||
icon = <GlobeIcon className="size-4" />;
|
|
||||||
} else if (step.name === "ls") {
|
|
||||||
label = "List folder";
|
|
||||||
icon = <FolderOpenIcon className="size-4" />;
|
|
||||||
} else if (step.name === "read_file") {
|
|
||||||
label = "Read file";
|
|
||||||
icon = <BookOpenTextIcon className="size-4" />;
|
|
||||||
} else if (step.name === "write_file" || step.name === "str_replace") {
|
|
||||||
label = "Write file";
|
|
||||||
icon = <NotebookPenIcon className="size-4" />;
|
|
||||||
} else if (step.name === "bash") {
|
|
||||||
label = "Execute command";
|
|
||||||
icon = <SquareTerminalIcon className="size-4" />;
|
|
||||||
} else if (step.name === "present_files") {
|
|
||||||
label = "Present files";
|
|
||||||
icon = <FileTextIcon className="size-4" />;
|
|
||||||
} else {
|
|
||||||
label = `Call tool "${step.name}"`;
|
|
||||||
icon = <WrenchIcon className="size-4" />;
|
|
||||||
}
|
|
||||||
if (typeof step.args.description === "string") {
|
|
||||||
label = step.args.description;
|
|
||||||
}
|
|
||||||
return { label, icon };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user