mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-14 18:54:46 +08:00
feat: support subtasks
This commit is contained in:
63
frontend/src/components/ui/shine-border.tsx
Normal file
63
frontend/src/components/ui/shine-border.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Width of the border in pixels
|
||||
* @default 1
|
||||
*/
|
||||
borderWidth?: number
|
||||
/**
|
||||
* Duration of the animation in seconds
|
||||
* @default 14
|
||||
*/
|
||||
duration?: number
|
||||
/**
|
||||
* Color of the border, can be a single color or an array of colors
|
||||
* @default "#000000"
|
||||
*/
|
||||
shineColor?: string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Shine Border
|
||||
*
|
||||
* An animated background border effect component with configurable properties.
|
||||
*/
|
||||
export function ShineBorder({
|
||||
borderWidth = 1,
|
||||
duration = 14,
|
||||
shineColor = "#000000",
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: ShineBorderProps) {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--border-width": `${borderWidth}px`,
|
||||
"--duration": `${duration}s`,
|
||||
backgroundImage: `radial-gradient(transparent,transparent, ${
|
||||
Array.isArray(shineColor) ? shineColor.join(",") : shineColor
|
||||
},transparent,transparent)`,
|
||||
backgroundSize: "300% 300%",
|
||||
mask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
|
||||
WebkitMask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
|
||||
WebkitMaskComposite: "xor",
|
||||
maskComposite: "exclude",
|
||||
padding: "var(--border-width)",
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"motion-safe:animate-shine pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function ArtifactFileList({
|
||||
{files.map((file) => (
|
||||
<Card
|
||||
key={file}
|
||||
className="cursor-pointer p-3"
|
||||
className="relative cursor-pointer p-3"
|
||||
onClick={() => handleClick(file)}
|
||||
>
|
||||
<CardHeader className="pr-2 pl-1">
|
||||
|
||||
@@ -206,9 +206,7 @@ export function InputBox({
|
||||
{context.mode === "pro" && (
|
||||
<GraduationCapIcon className="size-3" />
|
||||
)}
|
||||
{context.mode === "ultra" && (
|
||||
<RocketIcon className="size-3" />
|
||||
)}
|
||||
{context.mode === "ultra" && <RocketIcon className="size-3" />}
|
||||
</div>
|
||||
<div className="text-xs font-normal">
|
||||
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
||||
@@ -324,7 +322,8 @@ export function InputBox({
|
||||
<RocketIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
context.mode === "ultra" && "text-accent-foreground",
|
||||
context.mode === "ultra" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
{t.inputBox.ultraMode}
|
||||
|
||||
@@ -83,7 +83,7 @@ export function MessageGroup({
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
return (
|
||||
<ChainOfThought
|
||||
className={cn("w-full gap-2 rounded-lg border py-0", className)}
|
||||
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
|
||||
open={true}
|
||||
>
|
||||
{aboveLastToolCallSteps.length > 0 && (
|
||||
@@ -120,7 +120,10 @@ export function MessageGroup({
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
label={
|
||||
<MessageResponse remarkPlugins={streamdownPlugins.remarkPlugins} rehypePlugins={rehypePlugins}>
|
||||
<MessageResponse
|
||||
remarkPlugins={streamdownPlugins.remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{parseCitations(step.reasoning ?? "").cleanContent}
|
||||
</MessageResponse>
|
||||
}
|
||||
@@ -170,8 +173,14 @@ export function MessageGroup({
|
||||
<ChainOfThoughtStep
|
||||
key={lastReasoningStep.id}
|
||||
label={
|
||||
<MessageResponse remarkPlugins={streamdownPlugins.remarkPlugins} rehypePlugins={rehypePlugins}>
|
||||
{parseCitations(lastReasoningStep.reasoning ?? "").cleanContent}
|
||||
<MessageResponse
|
||||
remarkPlugins={streamdownPlugins.remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{
|
||||
parseCitations(lastReasoningStep.reasoning ?? "")
|
||||
.cleanContent
|
||||
}
|
||||
</MessageResponse>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -208,7 +217,10 @@ function ToolCall({
|
||||
|
||||
// Move useMemo to top level to comply with React Hooks rules
|
||||
const fileContent = typeof args.content === "string" ? args.content : "";
|
||||
const { citations } = useMemo(() => parseCitations(fileContent), [fileContent]);
|
||||
const { citations } = useMemo(
|
||||
() => parseCitations(fileContent),
|
||||
[fileContent],
|
||||
);
|
||||
|
||||
if (name === "web_search") {
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
@@ -369,9 +381,12 @@ function ToolCall({
|
||||
}
|
||||
|
||||
// Check if this is a markdown file with citations
|
||||
const isMarkdown = path?.toLowerCase().endsWith(".md") || path?.toLowerCase().endsWith(".markdown");
|
||||
const isMarkdown =
|
||||
path?.toLowerCase().endsWith(".md") ||
|
||||
path?.toLowerCase().endsWith(".markdown");
|
||||
const hasCitationsBlock = fileContent.includes("<citations>");
|
||||
const showCitationsLoading = isMarkdown && threadIsLoading && hasCitationsBlock && isLast;
|
||||
const showCitationsLoading =
|
||||
isMarkdown && threadIsLoading && hasCitationsBlock && isLast;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -398,7 +413,7 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
{showCitationsLoading && (
|
||||
<div className="ml-8 mt-2">
|
||||
<div className="mt-2 ml-8">
|
||||
<CitationsLoadingIndicator citations={citations} />
|
||||
</div>
|
||||
)}
|
||||
@@ -491,6 +506,9 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
steps.push(step);
|
||||
}
|
||||
for (const tool_call of message.tool_calls ?? []) {
|
||||
if (tool_call.name === "task") {
|
||||
continue;
|
||||
}
|
||||
const step: CoTToolCallStep = {
|
||||
id: tool_call.id,
|
||||
messageId: message.id,
|
||||
|
||||
@@ -5,14 +5,19 @@ import {
|
||||
ConversationContent,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractPresentFilesFromMessage,
|
||||
extractTextFromMessage,
|
||||
groupMessages,
|
||||
hasContent,
|
||||
hasPresentFiles,
|
||||
hasReasoning,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -22,6 +27,7 @@ import { StreamingIndicator } from "../streaming-indicator";
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
import { MessageListSkeleton } from "./skeleton";
|
||||
import { SubtaskCard } from "./subtask-card";
|
||||
|
||||
export function MessageList({
|
||||
className,
|
||||
@@ -34,7 +40,9 @@ export function MessageList({
|
||||
thread: UseStream<AgentThreadState>;
|
||||
paddingBottom?: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
if (thread.isThreadLoading) {
|
||||
return <MessageListSkeleton />;
|
||||
}
|
||||
@@ -42,7 +50,7 @@ export function MessageList({
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-10 pt-12">
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12">
|
||||
{groupMessages(thread.messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return (
|
||||
@@ -52,8 +60,7 @@ export function MessageList({
|
||||
isLoading={thread.isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (group.type === "assistant:clarification") {
|
||||
} else if (group.type === "assistant:clarification") {
|
||||
const message = group.messages[0];
|
||||
if (message && hasContent(message)) {
|
||||
return (
|
||||
@@ -63,8 +70,7 @@ export function MessageList({
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (group.type === "assistant:present-files") {
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
if (hasPresentFiles(message)) {
|
||||
@@ -85,6 +91,92 @@ export function MessageList({
|
||||
<ArtifactFileList files={files} threadId={threadId} />
|
||||
</div>
|
||||
);
|
||||
} else if (group.type === "assistant:subagent") {
|
||||
const tasks: Subtask[] = [];
|
||||
for (const message of group.messages) {
|
||||
if (message.type === "ai") {
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
if (toolCall.name === "task") {
|
||||
updateSubtask({
|
||||
id: toolCall.id!,
|
||||
subagent_type: toolCall.args.subagent_type,
|
||||
description: toolCall.args.description,
|
||||
prompt: toolCall.args.prompt,
|
||||
status: "in_progress",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (message.type === "tool") {
|
||||
const taskId = message.tool_call_id;
|
||||
if (taskId) {
|
||||
const result = extractTextFromMessage(message);
|
||||
if (result.startsWith("Task Succeeded. Result:")) {
|
||||
updateSubtask({
|
||||
id: taskId,
|
||||
status: "completed",
|
||||
result: result
|
||||
.split("Task Succeeded. Result:")[1]
|
||||
?.trim(),
|
||||
});
|
||||
} else if (result.startsWith("Task failed.")) {
|
||||
updateSubtask({
|
||||
id: taskId,
|
||||
status: "failed",
|
||||
error: result.split("Task failed.")[1]?.trim(),
|
||||
});
|
||||
} else {
|
||||
updateSubtask({
|
||||
id: taskId,
|
||||
status: "in_progress",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const results: React.ReactNode[] = [];
|
||||
for (const message of group.messages.filter(
|
||||
(message) => message.type === "ai",
|
||||
)) {
|
||||
if (hasReasoning(message)) {
|
||||
results.push(
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (tasks.length > 1) {
|
||||
results.push(
|
||||
<div
|
||||
key="subtask-count"
|
||||
className="text-muted-foreground font-norma pt-2 text-sm"
|
||||
>
|
||||
{t.subtasks.executing(tasks.length)}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
const taskIds = message.tool_calls?.map(
|
||||
(toolCall) => toolCall.id,
|
||||
);
|
||||
for (const taskId of taskIds ?? []) {
|
||||
results.push(
|
||||
<SubtaskCard
|
||||
key={"task-group-" + taskId}
|
||||
taskId={taskId!}
|
||||
isLoading={thread.isLoading}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={"subtask-group-" + group.id}
|
||||
className="relative z-1 flex flex-col gap-2"
|
||||
>
|
||||
{results}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageGroup
|
||||
|
||||
127
frontend/src/components/workspace/messages/subtask-card.tsx
Normal file
127
frontend/src/components/workspace/messages/subtask-card.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronUp,
|
||||
ClipboardListIcon,
|
||||
Loader2Icon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
ChainOfThoughtStep,
|
||||
} from "@/components/ai-elements/chain-of-thought";
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShineBorder } from "@/components/ui/shine-border";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { useSubtask } from "@/core/tasks/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SubtaskCard({
|
||||
className,
|
||||
taskId,
|
||||
}: {
|
||||
className?: string;
|
||||
taskId: string;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const task = useSubtask(taskId)!;
|
||||
const icon = useMemo(() => {
|
||||
if (task.status === "completed") {
|
||||
return <CheckCircleIcon className="size-3" />;
|
||||
} else if (task.status === "failed") {
|
||||
return <XCircleIcon className="size-3 text-red-500" />;
|
||||
} else if (task.status === "in_progress") {
|
||||
return <Loader2Icon className="size-3 animate-spin" />;
|
||||
}
|
||||
}, [task.status]);
|
||||
return (
|
||||
<ChainOfThought
|
||||
className={cn("relative w-full gap-2 rounded-lg border py-0", className)}
|
||||
open={!collapsed}
|
||||
>
|
||||
{task.status === "in_progress" && (
|
||||
<ShineBorder
|
||||
borderWidth={1.5}
|
||||
shineColor={["#A07CFE", "#FE8FB5", "#FFBE7B"]}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full items-center justify-between p-0.5">
|
||||
<Button
|
||||
className="w-full items-start justify-start text-left"
|
||||
variant="ghost"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<ChainOfThoughtStep
|
||||
className="font-normal"
|
||||
label={
|
||||
task.status === "in_progress" ? (
|
||||
<Shimmer duration={3} spread={3}>
|
||||
{task.description}
|
||||
</Shimmer>
|
||||
) : (
|
||||
task.description
|
||||
)
|
||||
}
|
||||
icon={<ClipboardListIcon />}
|
||||
></ChainOfThoughtStep>
|
||||
<div className="flex items-center gap-1">
|
||||
{collapsed && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-1 text-xs font-normal",
|
||||
task.status === "failed" ? "text-red-500 opacity-67" : "",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{t.subtasks[task.status]}
|
||||
</div>
|
||||
)}
|
||||
<ChevronUp
|
||||
className={cn(
|
||||
"text-muted-foreground size-4",
|
||||
!collapsed ? "" : "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<ChainOfThoughtContent className="px-4 pb-4">
|
||||
{task.prompt && (
|
||||
<ChainOfThoughtStep
|
||||
label={
|
||||
<Streamdown {...streamdownPlugins}>{task.prompt}</Streamdown>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
<>
|
||||
<ChainOfThoughtStep
|
||||
label={t.subtasks.completed}
|
||||
icon={<CheckCircleIcon className="size-4" />}
|
||||
></ChainOfThoughtStep>
|
||||
<ChainOfThoughtStep
|
||||
label={
|
||||
<Streamdown {...streamdownPlugins}>{task.result}</Streamdown>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
</>
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<ChainOfThoughtStep
|
||||
label={<div className="text-red-500">{task.error}</div>}
|
||||
icon={<XCircleIcon className="size-4 text-red-500" />}
|
||||
></ChainOfThoughtStep>
|
||||
)}
|
||||
</ChainOfThoughtContent>
|
||||
</ChainOfThought>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user