mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-24 06:34:46 +08:00
feat: implement basic web app
This commit is contained in:
1
frontend/src/components/workspace/message-list/index.ts
Normal file
1
frontend/src/components/workspace/message-list/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./message-list";
|
||||
329
frontend/src/components/workspace/message-list/message-group.tsx
Normal file
329
frontend/src/components/workspace/message-list/message-group.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
BookOpenTextIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
LightbulbIcon,
|
||||
ListTreeIcon,
|
||||
NotebookPenIcon,
|
||||
SearchIcon,
|
||||
SquareTerminalIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
ChainOfThoughtHeader,
|
||||
ChainOfThoughtSearchResult,
|
||||
ChainOfThoughtSearchResults,
|
||||
ChainOfThoughtStep,
|
||||
} from "@/components/ai-elements/chain-of-thought";
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
} from "./utils";
|
||||
|
||||
export function MessageGroup({
|
||||
className,
|
||||
messages,
|
||||
isLoading = false,
|
||||
}: {
|
||||
className?: string;
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
const [open, setOpen] = useState(false);
|
||||
const lastStep = steps[steps.length - 1]!;
|
||||
const { label, icon } = describeStep(lastStep);
|
||||
return (
|
||||
<ChainOfThought
|
||||
className={cn("w-full rounded-lg border px-4 pt-4", className)}
|
||||
defaultOpen={false}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<ChainOfThoughtHeader
|
||||
className="min-h-6"
|
||||
icon={
|
||||
open && steps.length > 1 ? <ListTreeIcon className="size-4" /> : icon
|
||||
}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div>
|
||||
{open && steps.length > 1 ? (
|
||||
<div>{steps.length} steps</div>
|
||||
) : (
|
||||
<MessageResponse rehypePlugins={rehypePlugins}>
|
||||
{label}
|
||||
</MessageResponse>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{!open && steps.length > 1 && (
|
||||
<div className="tet-xs opacity-60">
|
||||
{steps.length - 1} more step
|
||||
{steps.length - 1 > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ChainOfThoughtHeader>
|
||||
<ChainOfThoughtContent className="pb-4">
|
||||
{steps.map((step) =>
|
||||
step.type === "reasoning" ? (
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
label={
|
||||
<MessageResponse rehypePlugins={rehypePlugins}>
|
||||
{step.reasoning ?? ""}
|
||||
</MessageResponse>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ToolCall key={step.id} {...step} />
|
||||
),
|
||||
)}
|
||||
</ChainOfThoughtContent>
|
||||
</ChainOfThought>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCall({
|
||||
id,
|
||||
name,
|
||||
args,
|
||||
result,
|
||||
}: {
|
||||
id?: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string | Record<string, unknown>;
|
||||
}) {
|
||||
if (name === "web_search") {
|
||||
let label: React.ReactNode = "Search for related information";
|
||||
if (typeof args.query === "string") {
|
||||
label = (
|
||||
<div>
|
||||
Search on the web for{" "}
|
||||
<span className="font-bold">"{args.query}"</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 === "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} label="View web page" icon={GlobeIcon}>
|
||||
<ChainOfThoughtSearchResult>
|
||||
{url && (
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
{title}
|
||||
</a>
|
||||
)}
|
||||
</ChainOfThoughtSearchResult>
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "ls") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
description = "List folder";
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={description} icon={FolderOpenIcon}>
|
||||
{path && (
|
||||
<ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "read_file") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
description = "Read file";
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={description} icon={BookOpenTextIcon}>
|
||||
{path && (
|
||||
<ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "write_file" || name === "str_replace") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
description = "Write file";
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={description} icon={NotebookPenIcon}>
|
||||
{path && (
|
||||
<ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "bash") {
|
||||
const description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
return "Execute command";
|
||||
}
|
||||
const command: string | undefined = (args as { command: string })?.command;
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={description}
|
||||
icon={SquareTerminalIcon}
|
||||
>
|
||||
{command && (
|
||||
<ChainOfThoughtSearchResult>{command}</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else {
|
||||
const description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={
|
||||
description ?? (
|
||||
<div>
|
||||
Use tool <b>{name}</b>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
icon={WrenchIcon}
|
||||
></ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface GenericCoTStep<T extends string = string> {
|
||||
id?: 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,
|
||||
type: "reasoning",
|
||||
reasoning: extractReasoningContentFromMessage(message),
|
||||
};
|
||||
steps.push(step);
|
||||
}
|
||||
for (const tool_call of message.tool_calls ?? []) {
|
||||
const step: CoTToolCallStep = {
|
||||
id: tool_call.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;
|
||||
}
|
||||
|
||||
function describeStep(step: CoTStep): {
|
||||
label: string;
|
||||
icon: React.ReactElement;
|
||||
} {
|
||||
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 on the 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 {
|
||||
label = `Call tool "${step.name}"`;
|
||||
icon = <WrenchIcon className="size-4" />;
|
||||
}
|
||||
if (typeof step.args.description === "string") {
|
||||
label = step.args.description;
|
||||
}
|
||||
return { label, icon };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { memo } from "react";
|
||||
|
||||
import {
|
||||
Message as AIElementMessage,
|
||||
MessageContent as AIElementMessageContent,
|
||||
MessageResponse as AIElementMessageResponse,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { extractContentFromMessage, hasReasoning, hasToolCalls } from "./utils";
|
||||
|
||||
export function MessageListItem({
|
||||
className,
|
||||
message,
|
||||
messagesInGroup,
|
||||
isLoading,
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
messagesInGroup: Message[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AIElementMessage
|
||||
className={cn("relative", "group/conversation-message", className)}
|
||||
from={message.type === "human" ? "user" : "assistant"}
|
||||
>
|
||||
<MessageContent
|
||||
className={className}
|
||||
message={message}
|
||||
messagesInGroup={messagesInGroup}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</AIElementMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageContent_({
|
||||
className,
|
||||
message,
|
||||
messagesInGroup,
|
||||
isLoading = false,
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
messagesInGroup: Message[];
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
{hasReasoning(message) && (
|
||||
<MessageGroup
|
||||
className="mb-2"
|
||||
messages={[message]}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
<AIElementMessageResponse rehypePlugins={rehypePlugins}>
|
||||
{extractContentFromMessage(message)}
|
||||
</AIElementMessageResponse>
|
||||
{hasToolCalls(message) && (
|
||||
<MessageGroup messages={messagesInGroup} isLoading={isLoading} />
|
||||
)}
|
||||
</AIElementMessageContent>
|
||||
);
|
||||
}
|
||||
const MessageContent = memo(MessageContent_);
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { UseStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import type { MessageThreadState } from "@/core/thread";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
import { MessageListSkeleton } from "./skeleton";
|
||||
import { groupMessages, hasContent } from "./utils";
|
||||
|
||||
export function MessageList({
|
||||
className,
|
||||
thread,
|
||||
}: {
|
||||
className?: string;
|
||||
thread: UseStream<MessageThreadState>;
|
||||
}) {
|
||||
if (thread.isThreadLoading) {
|
||||
return <MessageListSkeleton />;
|
||||
}
|
||||
return (
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center pt-2", className)}
|
||||
>
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-md)">
|
||||
{groupMessages(
|
||||
thread.messages,
|
||||
(groupedMessages, groupIndex, isLastGroup) => {
|
||||
if (groupedMessages[0] && hasContent(groupedMessages[0])) {
|
||||
const message = groupedMessages[0];
|
||||
return (
|
||||
<MessageListItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
messagesInGroup={groupedMessages}
|
||||
isLoading={thread.isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageGroup
|
||||
key={groupedMessages[0]!.id}
|
||||
messages={groupedMessages}
|
||||
isLoading={thread.isLoading}
|
||||
/>
|
||||
);
|
||||
},
|
||||
thread.isLoading,
|
||||
)}
|
||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||
<div className="h-40" />
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton className="-translate-y-16 backdrop-blur-xs" />
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function MessageListSkeleton() {
|
||||
// TODO: Add a loading state
|
||||
return null;
|
||||
}
|
||||
157
frontend/src/components/workspace/message-list/utils.ts
Normal file
157
frontend/src/components/workspace/message-list/utils.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
export function groupMessages<T>(
|
||||
messages: Message[],
|
||||
mapper: (
|
||||
groupedMessages: Message[],
|
||||
groupIndex: number,
|
||||
isLastGroup: boolean,
|
||||
) => T,
|
||||
isLoading = false,
|
||||
): T[] {
|
||||
if (messages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const resultsOfGroups: T[] = [];
|
||||
let currentGroup: Message[] = [];
|
||||
const lastMessage = messages[messages.length - 1]!;
|
||||
const yieldCurrentGroup = () => {
|
||||
if (currentGroup.length > 0) {
|
||||
const resultOfGroup = mapper(
|
||||
currentGroup,
|
||||
resultsOfGroups.length,
|
||||
currentGroup.includes(lastMessage),
|
||||
);
|
||||
if (resultOfGroup !== undefined && resultOfGroup !== null) {
|
||||
resultsOfGroups.push(resultOfGroup);
|
||||
}
|
||||
currentGroup = [];
|
||||
}
|
||||
};
|
||||
let messageIndex = 0;
|
||||
for (const message of messages) {
|
||||
if (message.type === "human") {
|
||||
// Human messages are always shown as a individual group
|
||||
yieldCurrentGroup();
|
||||
currentGroup.push(message);
|
||||
yieldCurrentGroup();
|
||||
} else if (message.type === "tool") {
|
||||
// Tool messages are always shown with the assistant messages that contains the tool calls
|
||||
currentGroup.push(message);
|
||||
} else if (message.type === "ai") {
|
||||
if (
|
||||
hasToolCalls(message) ||
|
||||
(extractTextFromMessage(message) === "" &&
|
||||
extractReasoningContentFromMessage(message) !== "" &&
|
||||
messageIndex === messages.length - 1 &&
|
||||
isLoading)
|
||||
) {
|
||||
// Assistant messages without any content are folded into the previous group
|
||||
// Normally, these are tool calls (with or without thinking)
|
||||
currentGroup.push(message);
|
||||
} else {
|
||||
// Assistant messages with content (text or images) are shown as a group if they have content
|
||||
// No matter whether it has tool calls or not
|
||||
yieldCurrentGroup();
|
||||
currentGroup.push(message);
|
||||
}
|
||||
}
|
||||
messageIndex++;
|
||||
}
|
||||
yieldCurrentGroup();
|
||||
return resultsOfGroups;
|
||||
}
|
||||
|
||||
export function extractTextFromMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content.trim();
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.map((content) => (content.type === "text" ? content.text : ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function extractContentFromMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content.trim();
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.map((content) => {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
return content.text;
|
||||
case "image_url":
|
||||
const imageURL = extractURLFromImageURLContent(content.image_url);
|
||||
return ``;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function extractReasoningContentFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !message.additional_kwargs) {
|
||||
return null;
|
||||
}
|
||||
if ("reasoning_content" in message.additional_kwargs) {
|
||||
return message.additional_kwargs.reasoning_content as string | null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractURLFromImageURLContent(
|
||||
content:
|
||||
| string
|
||||
| {
|
||||
url: string;
|
||||
},
|
||||
) {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return content.url;
|
||||
}
|
||||
|
||||
export function hasContent(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content.trim().length > 0;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content.length > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasReasoning(message: Message) {
|
||||
return (
|
||||
message.type === "ai" &&
|
||||
typeof message.additional_kwargs?.reasoning_content === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function hasToolCalls(message: Message) {
|
||||
return (
|
||||
message.type === "ai" && message.tool_calls && message.tool_calls.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
||||
for (const message of messages) {
|
||||
if (message.type === "tool" && message.tool_call_id === toolCallId) {
|
||||
const content = extractTextFromMessage(message);
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user