mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-19 12:24:46 +08:00
feat: support subtasks
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||||
|
import { SubtasksProvider } from "@/core/tasks/context";
|
||||||
|
|
||||||
export default function ChatLayout({
|
export default function ChatLayout({
|
||||||
children,
|
children,
|
||||||
@@ -9,8 +10,10 @@ export default function ChatLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ArtifactsProvider>
|
<SubtasksProvider>
|
||||||
<PromptInputProvider>{children}</PromptInputProvider>
|
<ArtifactsProvider>
|
||||||
</ArtifactsProvider>
|
<PromptInputProvider>{children}</PromptInputProvider>
|
||||||
|
</ArtifactsProvider>
|
||||||
|
</SubtasksProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,8 @@ export default function ChatPage() {
|
|||||||
threadContext: {
|
threadContext: {
|
||||||
...settings.context,
|
...settings.context,
|
||||||
thinking_enabled: settings.context.mode !== "flash",
|
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",
|
subagent_enabled: settings.context.mode === "ultra",
|
||||||
},
|
},
|
||||||
afterSubmit() {
|
afterSubmit() {
|
||||||
|
|||||||
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) => (
|
{files.map((file) => (
|
||||||
<Card
|
<Card
|
||||||
key={file}
|
key={file}
|
||||||
className="cursor-pointer p-3"
|
className="relative cursor-pointer p-3"
|
||||||
onClick={() => handleClick(file)}
|
onClick={() => handleClick(file)}
|
||||||
>
|
>
|
||||||
<CardHeader className="pr-2 pl-1">
|
<CardHeader className="pr-2 pl-1">
|
||||||
|
|||||||
@@ -206,9 +206,7 @@ export function InputBox({
|
|||||||
{context.mode === "pro" && (
|
{context.mode === "pro" && (
|
||||||
<GraduationCapIcon className="size-3" />
|
<GraduationCapIcon className="size-3" />
|
||||||
)}
|
)}
|
||||||
{context.mode === "ultra" && (
|
{context.mode === "ultra" && <RocketIcon className="size-3" />}
|
||||||
<RocketIcon className="size-3" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs font-normal">
|
<div className="text-xs font-normal">
|
||||||
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
||||||
@@ -324,7 +322,8 @@ export function InputBox({
|
|||||||
<RocketIcon
|
<RocketIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
context.mode === "ultra" && "text-accent-foreground",
|
context.mode === "ultra" &&
|
||||||
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{t.inputBox.ultraMode}
|
{t.inputBox.ultraMode}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function MessageGroup({
|
|||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
return (
|
return (
|
||||||
<ChainOfThought
|
<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}
|
open={true}
|
||||||
>
|
>
|
||||||
{aboveLastToolCallSteps.length > 0 && (
|
{aboveLastToolCallSteps.length > 0 && (
|
||||||
@@ -120,7 +120,10 @@ export function MessageGroup({
|
|||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
key={step.id}
|
key={step.id}
|
||||||
label={
|
label={
|
||||||
<MessageResponse remarkPlugins={streamdownPlugins.remarkPlugins} rehypePlugins={rehypePlugins}>
|
<MessageResponse
|
||||||
|
remarkPlugins={streamdownPlugins.remarkPlugins}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
>
|
||||||
{parseCitations(step.reasoning ?? "").cleanContent}
|
{parseCitations(step.reasoning ?? "").cleanContent}
|
||||||
</MessageResponse>
|
</MessageResponse>
|
||||||
}
|
}
|
||||||
@@ -170,8 +173,14 @@ export function MessageGroup({
|
|||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
key={lastReasoningStep.id}
|
key={lastReasoningStep.id}
|
||||||
label={
|
label={
|
||||||
<MessageResponse remarkPlugins={streamdownPlugins.remarkPlugins} rehypePlugins={rehypePlugins}>
|
<MessageResponse
|
||||||
{parseCitations(lastReasoningStep.reasoning ?? "").cleanContent}
|
remarkPlugins={streamdownPlugins.remarkPlugins}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
parseCitations(lastReasoningStep.reasoning ?? "")
|
||||||
|
.cleanContent
|
||||||
|
}
|
||||||
</MessageResponse>
|
</MessageResponse>
|
||||||
}
|
}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
@@ -208,7 +217,10 @@ function ToolCall({
|
|||||||
|
|
||||||
// Move useMemo to top level to comply with React Hooks rules
|
// Move useMemo to top level to comply with React Hooks rules
|
||||||
const fileContent = typeof args.content === "string" ? args.content : "";
|
const fileContent = typeof args.content === "string" ? args.content : "";
|
||||||
const { citations } = useMemo(() => parseCitations(fileContent), [fileContent]);
|
const { citations } = useMemo(
|
||||||
|
() => parseCitations(fileContent),
|
||||||
|
[fileContent],
|
||||||
|
);
|
||||||
|
|
||||||
if (name === "web_search") {
|
if (name === "web_search") {
|
||||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||||
@@ -369,9 +381,12 @@ function ToolCall({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a markdown file with citations
|
// 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 hasCitationsBlock = fileContent.includes("<citations>");
|
||||||
const showCitationsLoading = isMarkdown && threadIsLoading && hasCitationsBlock && isLast;
|
const showCitationsLoading =
|
||||||
|
isMarkdown && threadIsLoading && hasCitationsBlock && isLast;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -398,7 +413,7 @@ function ToolCall({
|
|||||||
)}
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
{showCitationsLoading && (
|
{showCitationsLoading && (
|
||||||
<div className="ml-8 mt-2">
|
<div className="mt-2 ml-8">
|
||||||
<CitationsLoadingIndicator citations={citations} />
|
<CitationsLoadingIndicator citations={citations} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -491,6 +506,9 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
|||||||
steps.push(step);
|
steps.push(step);
|
||||||
}
|
}
|
||||||
for (const tool_call of message.tool_calls ?? []) {
|
for (const tool_call of message.tool_calls ?? []) {
|
||||||
|
if (tool_call.name === "task") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const step: CoTToolCallStep = {
|
const step: CoTToolCallStep = {
|
||||||
id: tool_call.id,
|
id: tool_call.id,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ import {
|
|||||||
ConversationContent,
|
ConversationContent,
|
||||||
} from "@/components/ai-elements/conversation";
|
} from "@/components/ai-elements/conversation";
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
extractPresentFilesFromMessage,
|
extractPresentFilesFromMessage,
|
||||||
|
extractTextFromMessage,
|
||||||
groupMessages,
|
groupMessages,
|
||||||
hasContent,
|
hasContent,
|
||||||
hasPresentFiles,
|
hasPresentFiles,
|
||||||
|
hasReasoning,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
|
import type { Subtask } from "@/core/tasks";
|
||||||
|
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||||
import type { AgentThreadState } from "@/core/threads";
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -22,6 +27,7 @@ import { StreamingIndicator } from "../streaming-indicator";
|
|||||||
import { MessageGroup } from "./message-group";
|
import { MessageGroup } from "./message-group";
|
||||||
import { MessageListItem } from "./message-list-item";
|
import { MessageListItem } from "./message-list-item";
|
||||||
import { MessageListSkeleton } from "./skeleton";
|
import { MessageListSkeleton } from "./skeleton";
|
||||||
|
import { SubtaskCard } from "./subtask-card";
|
||||||
|
|
||||||
export function MessageList({
|
export function MessageList({
|
||||||
className,
|
className,
|
||||||
@@ -34,7 +40,9 @@ export function MessageList({
|
|||||||
thread: UseStream<AgentThreadState>;
|
thread: UseStream<AgentThreadState>;
|
||||||
paddingBottom?: number;
|
paddingBottom?: number;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
|
const updateSubtask = useUpdateSubtask();
|
||||||
if (thread.isThreadLoading) {
|
if (thread.isThreadLoading) {
|
||||||
return <MessageListSkeleton />;
|
return <MessageListSkeleton />;
|
||||||
}
|
}
|
||||||
@@ -42,7 +50,7 @@ export function MessageList({
|
|||||||
<Conversation
|
<Conversation
|
||||||
className={cn("flex size-full flex-col justify-center", className)}
|
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) => {
|
{groupMessages(thread.messages, (group) => {
|
||||||
if (group.type === "human" || group.type === "assistant") {
|
if (group.type === "human" || group.type === "assistant") {
|
||||||
return (
|
return (
|
||||||
@@ -52,8 +60,7 @@ export function MessageList({
|
|||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
} else if (group.type === "assistant:clarification") {
|
||||||
if (group.type === "assistant:clarification") {
|
|
||||||
const message = group.messages[0];
|
const message = group.messages[0];
|
||||||
if (message && hasContent(message)) {
|
if (message && hasContent(message)) {
|
||||||
return (
|
return (
|
||||||
@@ -63,8 +70,7 @@ export function MessageList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
} else if (group.type === "assistant:present-files") {
|
||||||
if (group.type === "assistant:present-files") {
|
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
for (const message of group.messages) {
|
for (const message of group.messages) {
|
||||||
if (hasPresentFiles(message)) {
|
if (hasPresentFiles(message)) {
|
||||||
@@ -85,6 +91,92 @@ export function MessageList({
|
|||||||
<ArtifactFileList files={files} threadId={threadId} />
|
<ArtifactFileList files={files} threadId={threadId} />
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<MessageGroup
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -209,6 +209,16 @@ export const enUS: Translations = {
|
|||||||
skillInstallTooltip: "Install skill and make it available to DeerFlow",
|
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
|
||||||
settings: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
|
|||||||
@@ -155,6 +155,15 @@ export interface Translations {
|
|||||||
skillInstallTooltip: string;
|
skillInstallTooltip: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Subtasks
|
||||||
|
subtasks: {
|
||||||
|
subtask: string;
|
||||||
|
executing: (count: number) => string;
|
||||||
|
running: string;
|
||||||
|
completed: string;
|
||||||
|
failed: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ export const zhCN: Translations = {
|
|||||||
proMode: "专业",
|
proMode: "专业",
|
||||||
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
|
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
|
||||||
ultraMode: "超级",
|
ultraMode: "超级",
|
||||||
ultraModeDescription: "专业模式加子代理,适用于复杂的多步骤任务,功能最强大",
|
ultraModeDescription:
|
||||||
|
"专业模式加子代理,适用于复杂的多步骤任务,功能最强大",
|
||||||
searchModels: "搜索模型...",
|
searchModels: "搜索模型...",
|
||||||
surpriseMe: "小惊喜",
|
surpriseMe: "小惊喜",
|
||||||
surpriseMePrompt: "给我一个小惊喜吧",
|
surpriseMePrompt: "给我一个小惊喜吧",
|
||||||
@@ -203,6 +204,14 @@ export const zhCN: Translations = {
|
|||||||
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
subtasks: {
|
||||||
|
subtask: "子任务",
|
||||||
|
executing: (count: number) => `并行执行 ${count} 个子任务`,
|
||||||
|
running: "子任务运行中",
|
||||||
|
completed: "子任务已完成",
|
||||||
|
failed: "子任务失败",
|
||||||
|
},
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
title: "设置",
|
title: "设置",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
interface GenericMessageGroup<T = string> {
|
interface GenericMessageGroup<T = string> {
|
||||||
type: T;
|
type: T;
|
||||||
@@ -16,12 +16,15 @@ interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:pres
|
|||||||
|
|
||||||
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
||||||
|
|
||||||
|
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||||
|
|
||||||
type MessageGroup =
|
type MessageGroup =
|
||||||
| HumanMessageGroup
|
| HumanMessageGroup
|
||||||
| AssistantProcessingGroup
|
| AssistantProcessingGroup
|
||||||
| AssistantMessageGroup
|
| AssistantMessageGroup
|
||||||
| AssistantPresentFilesGroup
|
| AssistantPresentFilesGroup
|
||||||
| AssistantClarificationGroup;
|
| AssistantClarificationGroup
|
||||||
|
| AssistantSubagentGroup;
|
||||||
|
|
||||||
export function groupMessages<T>(
|
export function groupMessages<T>(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
@@ -78,6 +81,12 @@ export function groupMessages<T>(
|
|||||||
type: "assistant:present-files",
|
type: "assistant:present-files",
|
||||||
messages: [message],
|
messages: [message],
|
||||||
});
|
});
|
||||||
|
} else if (hasSubagent(message)) {
|
||||||
|
groups.push({
|
||||||
|
id: message.id,
|
||||||
|
type: "assistant:subagent",
|
||||||
|
messages: [message],
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if (lastGroup?.type !== "assistant:processing") {
|
if (lastGroup?.type !== "assistant:processing") {
|
||||||
groups.push({
|
groups.push({
|
||||||
@@ -232,6 +241,15 @@ export function extractPresentFilesFromMessage(message: Message) {
|
|||||||
return files;
|
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[]) {
|
export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.type === "tool" && message.tool_call_id === toolCallId) {
|
if (message.type === "tool" && message.tool_call_id === toolCallId) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { useSubagentStates } from "./hooks";
|
|
||||||
export { SubagentContext, useSubagentContext } from "./context";
|
|
||||||
46
frontend/src/core/tasks/context.tsx
Normal file
46
frontend/src/core/tasks/context.tsx
Normal 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;
|
||||||
|
}
|
||||||
1
frontend/src/core/tasks/index.ts
Normal file
1
frontend/src/core/tasks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./types";
|
||||||
9
frontend/src/core/tasks/types.ts
Normal file
9
frontend/src/core/tasks/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ export function useThreadStream({
|
|||||||
threadId: isNewThread ? undefined : threadId,
|
threadId: isNewThread ? undefined : threadId,
|
||||||
reconnectOnMount: true,
|
reconnectOnMount: true,
|
||||||
fetchStateHistory: true,
|
fetchStateHistory: true,
|
||||||
|
onCustomEvent(event) {
|
||||||
|
console.info(event);
|
||||||
|
},
|
||||||
onFinish(state) {
|
onFinish(state) {
|
||||||
onFinish?.(state.values);
|
onFinish?.(state.values);
|
||||||
// void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
// void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
|
|||||||
Reference in New Issue
Block a user