diff --git a/frontend/src/app/workspace/chats/[thread_id]/layout.tsx b/frontend/src/app/workspace/chats/[thread_id]/layout.tsx index 098d24f..8771037 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/layout.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/layout.tsx @@ -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 ( - - {children} - + + + {children} + + ); } diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 926334c..0c3f932 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -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() { diff --git a/frontend/src/components/ui/shine-border.tsx b/frontend/src/components/ui/shine-border.tsx new file mode 100644 index 0000000..1a55bfc --- /dev/null +++ b/frontend/src/components/ui/shine-border.tsx @@ -0,0 +1,63 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +interface ShineBorderProps extends React.HTMLAttributes { + /** + * 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 ( +
+ ) +} diff --git a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx index afcde02..99b2e37 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-list.tsx @@ -76,7 +76,7 @@ export function ArtifactFileList({ {files.map((file) => ( handleClick(file)} > diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 7efc54a..569dbbe 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -206,9 +206,7 @@ export function InputBox({ {context.mode === "pro" && ( )} - {context.mode === "ultra" && ( - - )} + {context.mode === "ultra" && }
{(context.mode === "flash" && t.inputBox.flashMode) || @@ -324,7 +322,8 @@ export function InputBox({ {t.inputBox.ultraMode} diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index f5d7650..a0782bb 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -83,7 +83,7 @@ export function MessageGroup({ const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); return ( {aboveLastToolCallSteps.length > 0 && ( @@ -120,7 +120,10 @@ export function MessageGroup({ + {parseCitations(step.reasoning ?? "").cleanContent} } @@ -170,8 +173,14 @@ export function MessageGroup({ - {parseCitations(lastReasoningStep.reasoning ?? "").cleanContent} + + { + parseCitations(lastReasoningStep.reasoning ?? "") + .cleanContent + } } > @@ -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(""); - const showCitationsLoading = isMarkdown && threadIsLoading && hasCitationsBlock && isLast; + const showCitationsLoading = + isMarkdown && threadIsLoading && hasCitationsBlock && isLast; return ( <> @@ -398,7 +413,7 @@ function ToolCall({ )} {showCitationsLoading && ( -
+
)} @@ -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, diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index e018335..e798a7c 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -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; paddingBottom?: number; }) { + const { t } = useI18n(); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); + const updateSubtask = useUpdateSubtask(); if (thread.isThreadLoading) { return ; } @@ -42,7 +50,7 @@ export function MessageList({ - + {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({
); + } 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( + , + ); + } + if (tasks.length > 1) { + results.push( +
+ {t.subtasks.executing(tasks.length)} +
, + ); + } + const taskIds = message.tool_calls?.map( + (toolCall) => toolCall.id, + ); + for (const taskId of taskIds ?? []) { + results.push( + , + ); + } + } + return ( +
+ {results} +
+ ); } return ( { + if (task.status === "completed") { + return ; + } else if (task.status === "failed") { + return ; + } else if (task.status === "in_progress") { + return ; + } + }, [task.status]); + return ( + + {task.status === "in_progress" && ( + + )} +
+ +
+ + {task.prompt && ( + {task.prompt} + } + > + )} + {task.status === "completed" && ( + <> + } + > + {task.result} + } + > + + )} + {task.status === "failed" && ( + {task.error}
} + icon={} + > + )} + + + ); +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index c6b8687..be98582 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index fb69501..079e575 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 0242fc9..3000190 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "设置", diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index 50bb3af..d73417d 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -1,4 +1,4 @@ -import type { Message } from "@langchain/langgraph-sdk"; +import type { AIMessage, Message } from "@langchain/langgraph-sdk"; interface GenericMessageGroup { 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( messages: Message[], @@ -78,6 +81,12 @@ export function groupMessages( 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) { diff --git a/frontend/src/core/subagents/context.ts b/frontend/src/core/subagents/context.ts deleted file mode 100644 index da5d35d..0000000 --- a/frontend/src/core/subagents/context.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext, useContext } from "react"; - -import type { SubagentState } from "../threads/types"; - -export const SubagentContext = createContext>(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; -} \ No newline at end of file diff --git a/frontend/src/core/subagents/hooks.ts b/frontend/src/core/subagents/hooks.ts deleted file mode 100644 index c2b1133..0000000 --- a/frontend/src/core/subagents/hooks.ts +++ /dev/null @@ -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>(new Map()); - const subagentsRef = useRef>(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, - }; -} \ No newline at end of file diff --git a/frontend/src/core/subagents/index.ts b/frontend/src/core/subagents/index.ts deleted file mode 100644 index ef14e57..0000000 --- a/frontend/src/core/subagents/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useSubagentStates } from "./hooks"; -export { SubagentContext, useSubagentContext } from "./context"; \ No newline at end of file diff --git a/frontend/src/core/tasks/context.tsx b/frontend/src/core/tasks/context.tsx new file mode 100644 index 0000000..887013e --- /dev/null +++ b/frontend/src/core/tasks/context.tsx @@ -0,0 +1,46 @@ +import { createContext, useCallback, useContext, useState } from "react"; + +import type { Subtask } from "./types"; + +export interface SubtaskContextValue { + tasks: Map; +} + +export const SubtaskContext = createContext({ + tasks: new Map(), +}); + +export function SubtasksProvider({ children }: { children: React.ReactNode }) { + const [tasks] = useState>(new Map()); + return ( + + {children} + + ); +} + +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 & { id: string }) => { + tasks.set(task.id, { ...tasks.get(task.id), ...task } as Subtask); + }, + [tasks], + ); + return updateSubtask; +} diff --git a/frontend/src/core/tasks/index.ts b/frontend/src/core/tasks/index.ts new file mode 100644 index 0000000..eea524d --- /dev/null +++ b/frontend/src/core/tasks/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/frontend/src/core/tasks/types.ts b/frontend/src/core/tasks/types.ts new file mode 100644 index 0000000..cf256ab --- /dev/null +++ b/frontend/src/core/tasks/types.ts @@ -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; +} diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 3ecd464..fe9ac7e 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -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"] });