feat: support subtasks

This commit is contained in:
Henry Li
2026-02-07 16:14:48 +08:00
parent e04e70c7a8
commit 46798c0931
19 changed files with 433 additions and 109 deletions

View File

@@ -2,6 +2,7 @@
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { SubtasksProvider } from "@/core/tasks/context";
export default function ChatLayout({
children,
@@ -9,8 +10,10 @@ export default function ChatLayout({
children: React.ReactNode;
}) {
return (
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}

View File

@@ -177,7 +177,8 @@ export default function ChatPage() {
threadContext: {
...settings.context,
thinking_enabled: settings.context.mode !== "flash",
is_plan_mode: settings.context.mode === "pro" || settings.context.mode === "ultra",
is_plan_mode:
settings.context.mode === "pro" || settings.context.mode === "ultra",
subagent_enabled: settings.context.mode === "ultra",
},
afterSubmit() {

View 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}
/>
)
}

View File

@@ -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">

View File

@@ -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}

View File

@@ -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,

View File

@@ -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

View 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>
);
}

View File

@@ -209,6 +209,16 @@ export const enUS: Translations = {
skillInstallTooltip: "Install skill and make it available to DeerFlow",
},
// Subtasks
subtasks: {
subtask: "Subtask",
executing: (count: number) =>
`Executing ${count} subtask${count === 1 ? "" : "s"} in parallel`,
running: "Running subtask",
completed: "Subtask completed",
failed: "Subtask failed",
},
// Settings
settings: {
title: "Settings",

View File

@@ -155,6 +155,15 @@ export interface Translations {
skillInstallTooltip: string;
};
// Subtasks
subtasks: {
subtask: string;
executing: (count: number) => string;
running: string;
completed: string;
failed: string;
};
// Settings
settings: {
title: string;

View File

@@ -78,7 +78,8 @@ export const zhCN: Translations = {
proMode: "专业",
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
ultraMode: "超级",
ultraModeDescription: "专业模式加子代理,适用于复杂的多步骤任务,功能最强大",
ultraModeDescription:
"专业模式加子代理,适用于复杂的多步骤任务,功能最强大",
searchModels: "搜索模型...",
surpriseMe: "小惊喜",
surpriseMePrompt: "给我一个小惊喜吧",
@@ -203,6 +204,14 @@ export const zhCN: Translations = {
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
},
subtasks: {
subtask: "子任务",
executing: (count: number) => `并行执行 ${count} 个子任务`,
running: "子任务运行中",
completed: "子任务已完成",
failed: "子任务失败",
},
// Settings
settings: {
title: "设置",

View File

@@ -1,4 +1,4 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
interface GenericMessageGroup<T = string> {
type: T;
@@ -16,12 +16,15 @@ interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:pres
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
| HumanMessageGroup
| AssistantProcessingGroup
| AssistantMessageGroup
| AssistantPresentFilesGroup
| AssistantClarificationGroup;
| AssistantClarificationGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
messages: Message[],
@@ -78,6 +81,12 @@ export function groupMessages<T>(
type: "assistant:present-files",
messages: [message],
});
} else if (hasSubagent(message)) {
groups.push({
id: message.id,
type: "assistant:subagent",
messages: [message],
});
} else {
if (lastGroup?.type !== "assistant:processing") {
groups.push({
@@ -232,6 +241,15 @@ export function extractPresentFilesFromMessage(message: Message) {
return files;
}
export function hasSubagent(message: AIMessage) {
for (const toolCall of message.tool_calls ?? []) {
if (toolCall.name === "task") {
return true;
}
}
return false;
}
export function findToolCallResult(toolCallId: string, messages: Message[]) {
for (const message of messages) {
if (message.type === "tool" && message.tool_call_id === toolCallId) {

View File

@@ -1,13 +0,0 @@
import { createContext, useContext } from "react";
import type { SubagentState } from "../threads/types";
export const SubagentContext = createContext<Map<string, SubagentState>>(new Map());
export function useSubagentContext() {
const context = useContext(SubagentContext);
if (context === undefined) {
throw new Error("useSubagentContext must be used within a SubagentContext.Provider");
}
return context;
}

View File

@@ -1,69 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { SubagentProgressEvent, SubagentState } from "../threads/types";
export function useSubagentStates() {
const [subagents, setSubagents] = useState<Map<string, SubagentState>>(new Map());
const subagentsRef = useRef<Map<string, SubagentState>>(new Map());
// 保持 ref 与 state 同步
useEffect(() => {
subagentsRef.current = subagents;
}, [subagents]);
const handleSubagentProgress = useCallback((event: SubagentProgressEvent) => {
console.log('[SubagentProgress] Received event:', event);
const { task_id, trace_id, subagent_type, event_type, result, error } = event;
setSubagents(prev => {
const newSubagents = new Map(prev);
const existingState = newSubagents.get(task_id) || {
task_id,
trace_id,
subagent_type,
status: "running" as const,
};
let newState = { ...existingState };
switch (event_type) {
case "started":
newState = {
...newState,
status: "running",
};
break;
case "completed":
newState = {
...newState,
status: "completed",
result,
};
break;
case "failed":
newState = {
...newState,
status: "failed",
error,
};
break;
}
newSubagents.set(task_id, newState);
return newSubagents;
});
}, []);
const clearSubagents = useCallback(() => {
setSubagents(new Map());
}, []);
return {
subagents,
handleSubagentProgress,
clearSubagents,
};
}

View File

@@ -1,2 +0,0 @@
export { useSubagentStates } from "./hooks";
export { SubagentContext, useSubagentContext } from "./context";

View File

@@ -0,0 +1,46 @@
import { createContext, useCallback, useContext, useState } from "react";
import type { Subtask } from "./types";
export interface SubtaskContextValue {
tasks: Map<string, Subtask>;
}
export const SubtaskContext = createContext<SubtaskContextValue>({
tasks: new Map(),
});
export function SubtasksProvider({ children }: { children: React.ReactNode }) {
const [tasks] = useState<Map<string, Subtask>>(new Map());
return (
<SubtaskContext.Provider value={{ tasks }}>
{children}
</SubtaskContext.Provider>
);
}
export function useSubtaskContext() {
const context = useContext(SubtaskContext);
if (context === undefined) {
throw new Error(
"useSubtaskContext must be used within a SubtaskContext.Provider",
);
}
return context;
}
export function useSubtask(id: string) {
const { tasks } = useSubtaskContext();
return tasks.get(id);
}
export function useUpdateSubtask() {
const { tasks } = useSubtaskContext();
const updateSubtask = useCallback(
(task: Partial<Subtask> & { id: string }) => {
tasks.set(task.id, { ...tasks.get(task.id), ...task } as Subtask);
},
[tasks],
);
return updateSubtask;
}

View File

@@ -0,0 +1 @@
export * from "./types";

View File

@@ -0,0 +1,9 @@
export interface Subtask {
id: string;
status: "in_progress" | "completed" | "failed";
subagent_type: string;
description: string;
prompt: string;
result?: string;
error?: string;
}

View File

@@ -31,6 +31,9 @@ export function useThreadStream({
threadId: isNewThread ? undefined : threadId,
reconnectOnMount: true,
fetchStateHistory: true,
onCustomEvent(event) {
console.info(event);
},
onFinish(state) {
onFinish?.(state.values);
// void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });