Files
EvoScientist-WebUI/src/app/components/ActionGroup.tsx
T
m4 fe982f7f95
CI / macos-latest / Node 20 (push) Has been cancelled
CI / ubuntu-latest / Node 20 (push) Has been cancelled
CI / windows-latest / Node 20 (push) Has been cancelled
feat: add workspace isolation and administration UI
2026-07-19 12:17:18 +08:00

454 lines
14 KiB
TypeScript

"use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
AlertCircle,
CheckCircle2,
ChevronRight,
ChevronUp,
Loader2,
ShieldAlert,
} from "lucide-react";
import type { Message } from "@langchain/langgraph-sdk";
import type {
ActionRequest,
ReviewConfig,
SubAgent,
ToolCall,
} from "@/app/types/types";
import type { SubAgentStep } from "@/lib/subAgentActivity";
import {
mapActionRequestsToToolCalls,
summarizeActionGroup,
} from "@/lib/actionGrouping";
import { mergeApprovalDecision } from "@/lib/approvalDecision";
import { formatToolLabel, toolArgumentPreview } from "@/lib/toolLabel";
import { autoApprovesTools, type ReviewMode } from "@/lib/reviewMode";
import {
extractStringFromMessageContent,
extractSubAgentContent,
} from "@/app/utils/utils";
import { CompactionSummary } from "./CompactionSummary";
import { MarkdownContent } from "./MarkdownContent";
import { SubAgentIndicator } from "./SubAgentIndicator";
import { SubAgentSteps } from "./SubAgentSteps";
import { ToolCallBox } from "./ToolCallBox";
import { StreamingMarkdown } from "./StreamingMarkdown";
import { cn } from "@/lib/utils";
export interface GroupedActionItem {
message: Message;
toolCalls: ToolCall[];
showAvatar?: boolean;
}
interface ActionGroupProps {
items: GroupedActionItem[];
isStreaming: boolean;
defaultCollapsed: boolean;
isAtBottom: boolean;
lastMessageId: string | undefined;
isLoading: boolean;
actionRequests: ActionRequest[];
submittedActionRequestKeys: Set<string>;
onActionRequestSubmitted: (key: string) => void;
reviewConfigsMap: Map<string, ReviewConfig> | null;
stream?: unknown;
onResumeInterrupt: (value: unknown, nextReviewMode?: ReviewMode) => void;
graphId?: string;
autoApprove: boolean;
subAgentSteps: Record<string, SubAgentStep[]>;
ui: any[] | undefined;
compactionAnchorId: string | null;
summarizationEvent: { content: string; cutoffIndex: number } | null;
}
const compactMarkdownClass =
"text-xs leading-5 text-muted-foreground [&_blockquote]:my-2 [&_blockquote]:pl-2 [&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-sm [&_h2]:mb-2 [&_h2]:mt-3 [&_h2]:text-sm [&_h3]:mb-1.5 [&_h3]:mt-3 [&_h3]:text-xs [&_ol]:my-2 [&_ol]:pl-5 [&_p]:mb-2 [&_table]:text-[11px] [&_td]:p-1.5 [&_th]:p-1.5 [&_ul]:my-2 [&_ul]:pl-5";
function toSubAgent(toolCall: ToolCall): SubAgent | null {
if (toolCall.name !== "task") return null;
const subAgentName = toolCall.args.subagent_type;
if (typeof subAgentName !== "string" || !subAgentName.trim()) return null;
const status: SubAgent["status"] =
toolCall.status === "error"
? "error"
: toolCall.status === "completed"
? "completed"
: "pending";
return {
id: toolCall.id,
name: toolCall.name,
subAgentName,
input: toolCall.args,
output: toolCall.result ? { result: toolCall.result } : undefined,
status,
};
}
export const ActionGroup = React.memo<ActionGroupProps>(function ActionGroup({
items,
isStreaming,
defaultCollapsed,
isAtBottom,
lastMessageId,
isLoading,
actionRequests,
submittedActionRequestKeys,
onActionRequestSubmitted,
reviewConfigsMap,
stream,
onResumeInterrupt,
graphId,
autoApprove,
subAgentSteps,
ui,
compactionAnchorId,
summarizationEvent,
}) {
const [open, setOpen] = useState(() => !defaultCollapsed);
const [expandedSubAgents, setExpandedSubAgents] = useState<
Record<string, boolean>
>({});
const wasStreamingRef = useRef(isStreaming);
const summary = useMemo(() => summarizeActionGroup(items), [items]);
const currentActionToolCalls = useMemo(
() =>
items.find((item) => item.message.id === lastMessageId)?.toolCalls ?? [],
[items, lastMessageId]
);
const groupContainsLastMessage = currentActionToolCalls.length > 0;
const actionRequestByToolCallId = useMemo(
() => mapActionRequestsToToolCalls(currentActionToolCalls, actionRequests),
[actionRequests, currentActionToolCalls]
);
const pendingApprovalCalls = useMemo(
() =>
currentActionToolCalls.filter(
(toolCall) =>
actionRequestByToolCallId.has(toolCall.id) &&
!submittedActionRequestKeys.has(toolCall.id)
),
[
actionRequestByToolCallId,
currentActionToolCalls,
submittedActionRequestKeys,
]
);
const hasPendingApproval =
!autoApprove && groupContainsLastMessage && pendingApprovalCalls.length > 0;
useEffect(() => {
const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = isStreaming;
if (
wasStreaming &&
!isStreaming &&
!hasPendingApproval &&
defaultCollapsed &&
isAtBottom
) {
setOpen(false);
}
}, [defaultCollapsed, hasPendingApproval, isAtBottom, isStreaming]);
const actionRequestsKey = useMemo(
() =>
JSON.stringify(
actionRequests.map((request) => ({
name: request.name,
args: request.args,
}))
),
[actionRequests]
);
const pendingReviewDecisionsRef = useRef<Record<number, unknown>>({});
useEffect(() => {
pendingReviewDecisionsRef.current = {};
}, [actionRequestsKey]);
const handleResumeActionRequest = useCallback(
(actionIndex: number, value: any, nextReviewMode?: ReviewMode) => {
const decisions = value?.decisions;
if (!Array.isArray(decisions) || actionRequests.length === 0) {
onResumeInterrupt(value, nextReviewMode);
return;
}
if (
actionRequests.length === 1 ||
decisions.length === actionRequests.length
) {
onResumeInterrupt(value, nextReviewMode);
return;
}
const merged = mergeApprovalDecision(
actionRequests.length,
actionIndex,
decisions[0],
pendingReviewDecisionsRef.current,
nextReviewMode ? autoApprovesTools(nextReviewMode) : false
);
pendingReviewDecisionsRef.current = merged.pending;
if (!merged.decisions) return;
onResumeInterrupt({ decisions: merged.decisions }, nextReviewMode);
},
[actionRequests, onResumeInterrupt]
);
const toggleSubAgent = useCallback((id: string) => {
setExpandedSubAgents((current) => ({
...current,
[id]: !(current[id] ?? false),
}));
}, []);
const renderToolCall = useCallback(
(toolCall: ToolCall) => {
const actionRequestEntry = actionRequestByToolCallId.get(toolCall.id);
const subAgent = toSubAgent(toolCall);
if (subAgent && !actionRequestEntry) {
const expanded = expandedSubAgents[subAgent.id] ?? false;
const steps = subAgentSteps[subAgent.id] ?? [];
return (
<div
key={toolCall.id}
className="py-0.5"
>
<SubAgentIndicator
subAgent={subAgent}
onClick={() => toggleSubAgent(subAgent.id)}
isExpanded={expanded}
/>
{expanded && (
<div className="ml-2 mt-1 space-y-3 border-l border-border pl-3">
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
Input
</div>
<MarkdownContent
content={extractSubAgentContent(subAgent.input)}
className={compactMarkdownClass}
/>
</div>
{steps.length > 0 && (
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
Steps
</div>
<SubAgentSteps
steps={steps}
hideFinalText={!!subAgent.output}
compact
/>
</div>
)}
{subAgent.output && (
<div>
<div className="mb-1 text-[11px] font-semibold uppercase text-muted-foreground">
Output
</div>
<MarkdownContent
content={extractSubAgentContent(subAgent.output)}
className={compactMarkdownClass}
/>
</div>
)}
</div>
)}
</div>
);
}
const toolUiComponent = ui?.find(
(entry) => entry.metadata?.tool_call_id === toolCall.id
);
return (
<ToolCallBox
key={toolCall.id}
toolCall={toolCall}
uiComponent={toolUiComponent}
stream={stream}
graphId={graphId}
actionRequest={actionRequestEntry?.actionRequest}
actionRequestKey={actionRequestEntry ? toolCall.id : undefined}
actionRequestSubmitted={
actionRequestEntry
? submittedActionRequestKeys.has(toolCall.id)
: undefined
}
onActionRequestSubmitted={onActionRequestSubmitted}
reviewConfig={reviewConfigsMap?.get(toolCall.name)}
onResume={
actionRequestEntry
? (value, nextReviewMode) =>
handleResumeActionRequest(
actionRequestEntry.actionIndex,
value,
nextReviewMode
)
: onResumeInterrupt
}
isLoading={isLoading}
autoApprove={autoApprove}
compact
/>
);
},
[
actionRequestByToolCallId,
autoApprove,
expandedSubAgents,
graphId,
handleResumeActionRequest,
isLoading,
onActionRequestSubmitted,
onResumeInterrupt,
reviewConfigsMap,
stream,
subAgentSteps,
submittedActionRequestKeys,
toggleSubAgent,
ui,
]
);
const lastToolCall = summary.lastToolCall;
const lastToolLabel = lastToolCall
? formatToolLabel(lastToolCall.name, lastToolCall.args)
: "Working";
const lastToolPreview = lastToolCall
? toolArgumentPreview(lastToolCall.name, lastToolCall.args)
: "";
const actionNoun = summary.total === 1 ? "action" : "actions";
const primaryText = hasPendingApproval
? "Approval required"
: summary.failed > 0
? `${summary.failed} of ${summary.total} ${actionNoun} failed`
: isStreaming || summary.pending > 0 || summary.interrupted > 0
? `Working · ${summary.total} ${actionNoun}`
: `${summary.total} ${actionNoun} completed`;
const headerText = `${primaryText} · ${lastToolLabel}${
lastToolPreview ? ` · ${lastToolPreview}` : ""
}`;
return (
<section
className="my-2 overflow-hidden rounded-md border border-border bg-[var(--color-surface)]"
aria-label="Assistant work process"
>
<button
type="button"
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} ${headerText}`}
title={headerText}
onClick={() => setOpen((current) => !current)}
className="flex min-h-10 w-full items-center gap-2 px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<ChevronRight
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 transition-transform",
open && "rotate-90"
)}
/>
{hasPendingApproval ? (
<ShieldAlert
aria-hidden="true"
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
) : summary.failed > 0 ? (
<AlertCircle
aria-hidden="true"
className="size-3.5 shrink-0 text-destructive"
/>
) : isStreaming || summary.pending > 0 || summary.interrupted > 0 ? (
<Loader2
aria-hidden="true"
className="size-3.5 shrink-0 animate-spin text-[var(--brand)]"
/>
) : (
<CheckCircle2
aria-hidden="true"
className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400"
/>
)}
<span className="shrink-0 font-medium text-foreground">
{primaryText}
</span>
<span aria-hidden="true">·</span>
<span className="min-w-0 flex-1 truncate">
{lastToolLabel}
{lastToolPreview ? ` · ${lastToolPreview}` : ""}
</span>
</button>
{!open && hasPendingApproval && (
<div className="space-y-1 border-t border-border px-3 py-2">
{pendingApprovalCalls.map(renderToolCall)}
</div>
)}
{open && (
<div className="border-t border-border px-3 py-2">
<div className="space-y-1 border-l border-border pl-3">
{items.map((item, itemIndex) => {
const content = extractStringFromMessageContent(item.message);
const showCompactionBefore =
compactionAnchorId === item.message.id;
const itemIsStreaming =
isStreaming && item.message.id === lastMessageId;
return (
<React.Fragment
key={item.message.id ?? `action-item-${itemIndex}`}
>
{showCompactionBefore && summarizationEvent && (
<CompactionSummary
content={summarizationEvent.content}
summarizedCount={summarizationEvent.cutoffIndex}
/>
)}
{content.trim() && (
<div className="px-2 py-1.5">
<StreamingMarkdown
content={content}
isStreaming={itemIsStreaming}
className={compactMarkdownClass}
/>
</div>
)}
{item.toolCalls.map(renderToolCall)}
</React.Fragment>
);
})}
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="mt-1 flex min-h-8 w-full items-center justify-center gap-1.5 rounded-sm text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Collapse ${summary.total} ${actionNoun}`}
>
<ChevronUp
aria-hidden="true"
className="size-3.5"
/>
Collapse
</button>
</div>
)}
</section>
);
});
ActionGroup.displayName = "ActionGroup";