mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-20 12:54:45 +08:00
feat: implement basic web app
This commit is contained in:
14
frontend/src/components/workspace/github-icon.tsx
Normal file
14
frontend/src/components/workspace/github-icon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export function GithubIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
height={32}
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
width={32}
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path d="M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
71
frontend/src/components/workspace/input-box.tsx
Normal file
71
frontend/src/components/workspace/input-box.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ChatStatus } from "ai";
|
||||
import { useCallback, type ComponentProps } from "react";
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputBody,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
type PromptInputMessage,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function InputBox({
|
||||
className,
|
||||
autoFocus,
|
||||
status = "ready",
|
||||
onSubmit,
|
||||
onStop,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
status?: ChatStatus;
|
||||
onSubmit?: (message: PromptInputMessage) => void;
|
||||
onStop?: () => void;
|
||||
}) {
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
onStop?.();
|
||||
return;
|
||||
}
|
||||
if (!message.text) {
|
||||
return;
|
||||
}
|
||||
onSubmit?.(message);
|
||||
},
|
||||
[onSubmit, onStop, status],
|
||||
);
|
||||
return (
|
||||
<PromptInput
|
||||
className={cn(
|
||||
"bg-background/50 rounded-2xl drop-shadow-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
|
||||
"focus-within:bg-background/85 h-48 translate-y-14 overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
globalDrop
|
||||
multiple
|
||||
onSubmit={handleSubmit}
|
||||
{...props}
|
||||
>
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
className={cn("size-full")}
|
||||
placeholder="How can I assist you today?"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="flex">
|
||||
<div></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PromptInputSubmit
|
||||
className="rounded-full"
|
||||
variant="outline"
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
17
frontend/src/components/workspace/overscroll.tsx
Normal file
17
frontend/src/components/workspace/overscroll.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function Overscroll({
|
||||
behavior,
|
||||
overflow = "hidden",
|
||||
}: {
|
||||
behavior: "none" | "contain" | "auto";
|
||||
overflow?: "hidden" | "auto" | "scroll";
|
||||
}) {
|
||||
useEffect(() => {
|
||||
document.documentElement.style.overflow = overflow;
|
||||
document.documentElement.style.overscrollBehavior = behavior;
|
||||
}, [behavior, overflow]);
|
||||
return null;
|
||||
}
|
||||
107
frontend/src/components/workspace/recent-chat-list.tsx
Normal file
107
frontend/src/components/workspace/recent-chat-list.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { MoreHorizontal, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useDeleteThread, useThreads } from "@/core/api";
|
||||
import { pathOfThread, titleOfThread } from "@/core/thread/utils";
|
||||
|
||||
export function RecentChatList() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { mutate: deleteThread } = useDeleteThread();
|
||||
const handleDelete = useCallback(
|
||||
(threadId: string) => {
|
||||
deleteThread({ threadId });
|
||||
if (threadId === threadIdFromPath) {
|
||||
const threadIndex = threads.findIndex((t) => t.thread_id === threadId);
|
||||
let nextThreadId = "new";
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadId = threads[threadIndex + 1]!.thread_id;
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadId = threads[threadIndex - 1]!.thread_id;
|
||||
}
|
||||
}
|
||||
void router.push(`/workspace/chats/${nextThreadId}`);
|
||||
}
|
||||
},
|
||||
[deleteThread, router, threadIdFromPath, threads],
|
||||
);
|
||||
if (threads.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Recents</SidebarGroupLabel>
|
||||
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{threads.map((thread) => {
|
||||
const isActive = pathOfThread(thread, false) === pathname;
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={thread.thread_id}
|
||||
className="group/side-menu-item"
|
||||
>
|
||||
<SidebarMenuButton isActive={isActive} asChild>
|
||||
<div>
|
||||
<Link
|
||||
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{titleOfThread(thread)}
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="bg-background/50 hover:bg-background"
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48 rounded-lg"
|
||||
side={"right"}
|
||||
align={"start"}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleDelete(thread.thread_id)}
|
||||
>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/workspace/streaming-indicator.tsx
Normal file
34
frontend/src/components/workspace/streaming-indicator.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function StreamingIndicator({
|
||||
className,
|
||||
size = "normal",
|
||||
}: {
|
||||
className?: string;
|
||||
size?: "normal" | "sm";
|
||||
}) {
|
||||
const dotSize = size === "sm" ? "w-1.5 h-1.5 mx-0.5" : "w-2 h-2 mx-1";
|
||||
|
||||
return (
|
||||
<div className={cn("flex", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
dotSize,
|
||||
"animate-bouncing rounded-full bg-[#a3a1a1] opacity-100",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
dotSize,
|
||||
"animate-bouncing rounded-full bg-[#a3a1a1] opacity-100 [animation-delay:0.2s]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
dotSize,
|
||||
"animate-bouncing rounded-full bg-[#a3a1a1] opacity-100 [animation-delay:0.4s]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/workspace/tooltip.tsx
Normal file
23
frontend/src/components/workspace/tooltip.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Tooltip as TooltipPrimitive,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export function Tooltip({
|
||||
children,
|
||||
content,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
content?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TooltipPrimitive {...props}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent className="shadow">{content}</TooltipContent>
|
||||
</TooltipPrimitive>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/workspace/workspace-container.tsx
Normal file
146
frontend/src/components/workspace/workspace-container.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { GithubIcon } from "./github-icon";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
export function WorkspaceContainer({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("flex h-screen w-full flex-col", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceHeader({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"header">) {
|
||||
const pathname = usePathname();
|
||||
const segments = useMemo(() => {
|
||||
const parts = pathname?.split("/") || [];
|
||||
if (parts.length > 0) {
|
||||
return parts.slice(1, 3);
|
||||
}
|
||||
}, [pathname]);
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"top-0 right-0 left-0 z-20 flex h-16 shrink-0 items-center justify-between gap-2 border-b backdrop-blur-sm transition-[width,height] ease-out group-has-data-[collapsible=icon]/sidebar-wrapper:h-12",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{segments?.[0] && (
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={`/${segments[0]}`}>
|
||||
{nameOfSegment(segments[0])}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
{segments?.[1] && (
|
||||
<>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
{segments.length >= 2 ? (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={`/${segments[0]}/${segments[1]}`}>
|
||||
{nameOfSegment(segments[1])}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPage>
|
||||
{nameOfSegment(segments[1])}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
{children && (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="pr-4">
|
||||
<Tooltip content="DeerFlow on Github">
|
||||
<a
|
||||
href="https://github.com/bytedance/deer-flow"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="opacity-75 transition hover:opacity-100"
|
||||
>
|
||||
<GithubIcon className="size-6" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceBody({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
"relative flex min-h-0 w-full flex-1 flex-col items-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center">{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceFooter({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"footer">) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 left-0 z-30 flex justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function nameOfSegment(segment: string | undefined) {
|
||||
if (!segment) return "Home";
|
||||
return segment[0]?.toUpperCase() + segment.slice(1);
|
||||
}
|
||||
30
frontend/src/components/workspace/workspace-header.tsx
Normal file
30
frontend/src/components/workspace/workspace-header.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function WorkspaceHeader({ className }: { className?: string }) {
|
||||
const { state } = useSidebar();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/workspace-header flex h-15 flex-col justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{state === "collapsed" ? (
|
||||
<div className="flex w-full cursor-pointer items-center justify-center group-has-data-[collapsible=icon]/sidebar-wrapper:-translate-y-[6px]">
|
||||
<h1 className="text-primary block font-serif group-hover/workspace-header:hidden">
|
||||
DF
|
||||
</h1>
|
||||
<SidebarTrigger className="hidden pl-2 group-hover/workspace-header:block" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-primary ml-2 font-serif">DeerFlow</h1>
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/workspace/workspace-nav-menu.tsx
Normal file
41
frontend/src/components/workspace/workspace-nav-menu.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquarePlus, MessagesSquare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function WorkspaceNavMenu() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/workspace/chats/new"}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-muted-foreground" href="/workspace/chats/new">
|
||||
<MessageSquarePlus size={16} />
|
||||
<span>New chat</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={pathname === "/workspace/chats"} asChild>
|
||||
<Link className="text-muted-foreground" href="/workspace/chats">
|
||||
<MessagesSquare />
|
||||
<span>Chats</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/workspace/workspace-sidebar.tsx
Normal file
29
frontend/src/components/workspace/workspace-sidebar.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarHeader,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar variant="sidebar" collapsible="icon" {...props}>
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavMenu />
|
||||
<RecentChatList />
|
||||
</SidebarContent>
|
||||
<SidebarFooter></SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user