feat: add realtime subagent status report

This commit is contained in:
Henry Li
2026-02-08 22:43:51 +08:00
parent 808e028338
commit 010aba1e28
8 changed files with 91 additions and 10 deletions

View File

@@ -17,13 +17,17 @@ 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 { hasToolCalls } from "@/core/messages/utils";
import {
streamdownPlugins,
streamdownPluginsWithWordAnimation,
} from "@/core/streamdown";
import { useSubtask } from "@/core/tasks/context";
import { explainLastToolCall } from "@/core/tools/utils";
import { cn } from "@/lib/utils";
import { FlipDisplay } from "../flip-display";
export function SubtaskCard({
className,
taskId,
@@ -84,7 +88,16 @@ export function SubtaskCard({
)}
>
{icon}
{t.subtasks[task.status]}
<FlipDisplay
className="pb-1"
uniqueKey={task.latestMessage?.id ?? ""}
>
{task.status === "in_progress" &&
task.latestMessage &&
hasToolCalls(task.latestMessage)
? explainLastToolCall(task.latestMessage, t)
: t.subtasks[task.status]}
</FlipDisplay>
</div>
)}
<ChevronUp
@@ -107,6 +120,16 @@ export function SubtaskCard({
}
></ChainOfThoughtStep>
)}
{task.status === "in_progress" &&
task.latestMessage &&
hasToolCalls(task.latestMessage) && (
<ChainOfThoughtStep
label={t.subtasks.in_progress}
icon={<Loader2Icon className="size-4 animate-spin" />}
>
{explainLastToolCall(task.latestMessage, t)}
</ChainOfThoughtStep>
)}
{task.status === "completed" && (
<>
<ChainOfThoughtStep

View File

@@ -195,6 +195,7 @@ export const enUS: Translations = {
presentFiles: "Present files",
needYourHelp: "Need your help",
useTool: (toolName: string) => `Use "${toolName}" tool`,
searchFor: (query: string) => `Search for "${query}"`,
searchForRelatedInfo: "Search for related information",
searchForRelatedImages: "Search for related images",
searchForRelatedImagesFor: (query: string) =>

View File

@@ -144,6 +144,7 @@ export interface Translations {
useTool: (toolName: string) => string;
searchForRelatedInfo: string;
searchForRelatedImages: string;
searchFor: (query: string) => string;
searchForRelatedImagesFor: (query: string) => string;
searchOnWebFor: (query: string) => string;
viewWebPage: string;

View File

@@ -191,6 +191,7 @@ export const zhCN: Translations = {
presentFiles: "展示文件",
needYourHelp: "需要你的协助",
useTool: (toolName: string) => `使用 “${toolName}” 工具`,
searchFor: (query: string) => `搜索 “${query}`,
searchForRelatedInfo: "搜索相关信息",
searchForRelatedImages: "搜索相关图片",
searchForRelatedImagesFor: (query: string) => `搜索相关图片 “${query}`,

View File

@@ -3,17 +3,21 @@ import { createContext, useCallback, useContext, useState } from "react";
import type { Subtask } from "./types";
export interface SubtaskContextValue {
tasks: Map<string, Subtask>;
tasks: Record<string, Subtask>;
setTasks: (tasks: Record<string, Subtask>) => void;
}
export const SubtaskContext = createContext<SubtaskContextValue>({
tasks: new Map(),
tasks: {},
setTasks: () => {
/* noop */
},
});
export function SubtasksProvider({ children }: { children: React.ReactNode }) {
const [tasks] = useState<Map<string, Subtask>>(new Map());
const [tasks, setTasks] = useState<Record<string, Subtask>>({});
return (
<SubtaskContext.Provider value={{ tasks }}>
<SubtaskContext.Provider value={{ tasks, setTasks }}>
{children}
</SubtaskContext.Provider>
);
@@ -31,16 +35,19 @@ export function useSubtaskContext() {
export function useSubtask(id: string) {
const { tasks } = useSubtaskContext();
return tasks.get(id);
return tasks[id];
}
export function useUpdateSubtask() {
const { tasks } = useSubtaskContext();
const { tasks, setTasks } = useSubtaskContext();
const updateSubtask = useCallback(
(task: Partial<Subtask> & { id: string }) => {
tasks.set(task.id, { ...tasks.get(task.id), ...task } as Subtask);
tasks[task.id] = { ...tasks[task.id], ...task } as Subtask;
if (task.latestMessage) {
setTasks({ ...tasks });
}
},
[tasks],
[tasks, setTasks],
);
return updateSubtask;
}

View File

@@ -1,8 +1,11 @@
import type { AIMessage } from "@langchain/langgraph-sdk";
export interface Subtask {
id: string;
status: "in_progress" | "completed" | "failed";
subagent_type: string;
description: string;
latestMessage?: AIMessage;
prompt: string;
result?: string;
error?: string;

View File

@@ -1,4 +1,5 @@
import type { HumanMessage } from "@langchain/core/messages";
import type { AIMessage } from "@langchain/langgraph-sdk";
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import { useStream, type UseStream } from "@langchain/langgraph-sdk/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -7,6 +8,7 @@ import { useCallback } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api";
import { useUpdateSubtask } from "../tasks/context";
import { uploadFiles } from "../uploads";
import type {
@@ -25,14 +27,28 @@ export function useThreadStream({
onFinish?: (state: AgentThreadState) => void;
}) {
const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask();
const thread = useStream<AgentThreadState>({
client: getAPIClient(),
assistantId: "lead_agent",
threadId: isNewThread ? undefined : threadId,
reconnectOnMount: true,
fetchStateHistory: true,
onCustomEvent(event) {
onCustomEvent(event: unknown) {
console.info(event);
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "task_running"
) {
const e = event as {
type: "task_running";
task_id: string;
message: AIMessage;
};
updateSubtask({ id: e.task_id, latestMessage: e.message });
}
},
onFinish(state) {
onFinish?.(state.values);

View File

@@ -0,0 +1,29 @@
import type { ToolCall } from "@langchain/core/messages";
import type { AIMessage } from "@langchain/langgraph-sdk";
import type { Translations } from "../i18n";
import { hasToolCalls } from "../messages/utils";
export function explainLastToolCall(message: AIMessage, t: Translations) {
if (hasToolCalls(message)) {
const lastToolCall = message.tool_calls![message.tool_calls!.length - 1]!;
return explainToolCall(lastToolCall, t);
}
return t.common.thinking;
}
export function explainToolCall(toolCall: ToolCall, t: Translations) {
if (toolCall.name === "web_search" || toolCall.name === "image_search") {
return t.toolCalls.searchFor(toolCall.args.query);
} else if (toolCall.name === "web_fetch") {
return t.toolCalls.viewWebPage;
} else if (toolCall.name === "present_files") {
return t.toolCalls.presentFiles;
} else if (toolCall.name === "write_todos") {
return t.toolCalls.writeTodos;
} else if (toolCall.args.description) {
return toolCall.args.description;
} else {
return t.toolCalls.useTool(toolCall.name);
}
}