Files
deer-flow/frontend/src/components/workspace/messages/message-group.tsx
2026-02-02 09:32:18 +08:00

492 lines
15 KiB
TypeScript

import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
ChevronUp,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTodoIcon,
MessageCircleQuestionMarkIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtSearchResult,
ChainOfThoughtSearchResults,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { CodeBlock } from "@/components/ai-elements/code-block";
import { MessageResponse } from "@/components/ai-elements/message";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks";
import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { useArtifacts } from "../artifacts";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
export function MessageGroup({
className,
messages,
isLoading = false,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
}) {
const { t } = useI18n();
const [showAbove, setShowAbove] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [showLastThinking, setShowLastThinking] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const steps = useMemo(() => convertToSteps(messages), [messages]);
const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall");
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);
return (
<ChainOfThought
className={cn("w-full gap-2 rounded-lg border py-0", className)}
open={true}
>
{aboveLastToolCallSteps.length > 0 && (
<Button
key="above"
className="w-full items-start justify-start text-left"
variant="ghost"
onClick={() => setShowAbove(!showAbove)}
>
<ChainOfThoughtStep
label={
<span className="opacity-60">
{showAbove
? t.toolCalls.lessSteps
: t.toolCalls.moreSteps(aboveLastToolCallSteps.length)}
</span>
}
icon={
<ChevronUp
className={cn(
"size-4 opacity-60 transition-transform duration-200",
showAbove ? "rotate-180" : "",
)}
/>
}
></ChainOfThoughtStep>
</Button>
)}
{lastToolCallStep && (
<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>
) : (
<ToolCall key={step.id} {...step} isLoading={isLoading} />
),
)}
{lastToolCallStep && (
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
<ToolCall
key={lastToolCallStep.id}
{...lastToolCallStep}
isLast={true}
isLoading={isLoading}
/>
</FlipDisplay>
)}
</ChainOfThoughtContent>
)}
{lastReasoningStep && (
<>
<Button
key={lastReasoningStep.id}
className="w-full items-start justify-start text-left"
variant="ghost"
onClick={() => setShowLastThinking(!showLastThinking)}
>
<div className="flex w-full items-center justify-between">
<ChainOfThoughtStep
className="font-normal"
label={t.common.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>
);
}
function ToolCall({
id,
messageId,
name,
args,
result,
isLast = false,
isLoading = false,
}: {
id?: string;
messageId?: string;
name: string;
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
isLast?: boolean;
isLoading?: boolean;
}) {
const { t } = useI18n();
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
useArtifacts();
if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (typeof args.query === "string") {
label = t.toolCalls.searchOnWebFor(args.query);
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
{Array.isArray(result) && (
<ChainOfThoughtSearchResults>
{result.map((item) => (
<ChainOfThoughtSearchResult key={item.url}>
<a href={item.url} target="_blank" rel="noreferrer">
{item.title}
</a>
</ChainOfThoughtSearchResult>
))}
</ChainOfThoughtSearchResults>
)}
</ChainOfThoughtStep>
);
} else if (name === "image_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedImages;
if (typeof args.query === "string") {
label = t.toolCalls.searchForRelatedImagesFor(args.query);
}
const results = (
result as {
results: {
source_url: string;
thumbnail_url: string;
image_url: string;
title: string;
}[];
}
)?.results;
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
{Array.isArray(results) && (
<ChainOfThoughtSearchResults>
{Array.isArray(results) &&
results.map((item) => (
<Tooltip key={item.image_url} content={item.title}>
<a
className="size-24 overflow-hidden rounded-lg object-cover"
href={item.source_url}
target="_blank"
rel="noreferrer"
>
<div className="bg-accent size-24">
<img
className="size-full object-cover"
src={item.thumbnail_url}
alt={item.title}
width={100}
height={100}
/>
</div>
</a>
</Tooltip>
))}
</ChainOfThoughtSearchResults>
)}
</ChainOfThoughtStep>
);
} else if (name === "web_fetch") {
const url = (args as { url: string })?.url;
let title = url;
if (typeof result === "string") {
const potentialTitle = extractTitleFromMarkdown(result);
if (potentialTitle && potentialTitle.toLowerCase() !== "untitled") {
title = potentialTitle;
}
}
return (
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={t.toolCalls.viewWebPage}
icon={GlobeIcon}
onClick={() => {
window.open(url, "_blank");
}}
>
<ChainOfThoughtSearchResult>
{url && (
<Tooltip content={<pre>{result as string}</pre>}>
<a href={url} target="_blank" rel="noreferrer">
{title}
</a>
</Tooltip>
)}
</ChainOfThoughtSearchResult>
</ChainOfThoughtStep>
);
} else if (name === "ls") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.listFolder;
}
const path: string | undefined = (args as { path: string })?.path;
return (
<ChainOfThoughtStep key={id} label={description} icon={FolderOpenIcon}>
{path && (
<Tooltip content={<pre>{result as string}</pre>}>
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
</Tooltip>
)}
</ChainOfThoughtStep>
);
} else if (name === "read_file") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.readFile;
}
const { path } = args as { path: string; content: string };
return (
<ChainOfThoughtStep key={id} label={description} icon={BookOpenTextIcon}>
{path && (
<Tooltip
content={
<pre className="max-w-[95vw] whitespace-pre-wrap">
{result as string}
</pre>
}
>
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
</Tooltip>
)}
</ChainOfThoughtStep>
);
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
if (isLoading && isLast && autoOpen && autoSelect && path) {
setTimeout(() => {
const url = new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString();
if (selectedArtifact === url) {
return;
}
select(url, true);
setOpen(true);
}, 100);
}
return (
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={description}
icon={NotebookPenIcon}
onClick={() => {
select(
new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString(),
);
setOpen(true);
}}
>
{path && (
<Tooltip content={t.toolCalls.clickToViewContent}>
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
</Tooltip>
)}
</ChainOfThoughtStep>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })
?.description;
if (!description) {
return t.toolCalls.executeCommand;
}
const command: string | undefined = (args as { command: string })?.command;
return (
<ChainOfThoughtStep
key={id}
label={description}
icon={SquareTerminalIcon}
>
{command && (
<Tooltip content={<pre>{result as string}</pre>}>
<CodeBlock
className="mx-0 cursor-pointer border-none px-0"
showLineNumbers={false}
language="bash"
code={command}
/>
</Tooltip>
)}
</ChainOfThoughtStep>
);
} else if (name === "ask_clarification") {
return (
<ChainOfThoughtStep
key={id}
label={t.toolCalls.needYourHelp}
icon={MessageCircleQuestionMarkIcon}
></ChainOfThoughtStep>
);
} else if (name === "write_todos") {
return (
<ChainOfThoughtStep
key={id}
label={t.toolCalls.writeTodos}
icon={ListTodoIcon}
></ChainOfThoughtStep>
);
} else {
const description: string | undefined = (args as { description: string })
?.description;
return (
<ChainOfThoughtStep
key={id}
label={description ?? t.toolCalls.useTool(name)}
icon={WrenchIcon}
></ChainOfThoughtStep>
);
}
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
}
interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
reasoning: string | null;
}
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
name: string;
args: Record<string, unknown>;
result?: string;
}
type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[]): CoTStep[] {
const steps: CoTStep[] = [];
for (const message of messages) {
if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message);
if (reasoning) {
const step: CoTReasoningStep = {
id: message.id,
messageId: message.id,
type: "reasoning",
reasoning: extractReasoningContentFromMessage(message),
};
steps.push(step);
}
for (const tool_call of message.tool_calls ?? []) {
const step: CoTToolCallStep = {
id: tool_call.id,
messageId: message.id,
type: "toolCall",
name: tool_call.name,
args: tool_call.args,
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = findToolCallResult(toolCallId, messages);
if (toolCallResult) {
try {
const json = JSON.parse(toolCallResult);
step.result = json;
} catch {
step.result = toolCallResult;
}
}
}
steps.push(step);
}
}
}
return steps;
}