mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-11 16:52:38 +02:00
Render HITL approval cards inline in the thinking-steps timeline.
This commit is contained in:
parent
4b2c9f07cd
commit
a8417e3c45
4 changed files with 158 additions and 14 deletions
|
|
@ -59,6 +59,7 @@ import { DropdownMenuLabel } from "@/components/ui/dropdown-menu";
|
|||
import { useComments } from "@/hooks/use-comments";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { withHitlInTimeline } from "@/lib/hitl";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -508,12 +509,22 @@ const MessageInfoDropdown: FC = () => {
|
|||
// page through them and stage decisions instead of firing one resume per card.
|
||||
// ``withDelegationSpanIndent`` wraps every entry (including Fallback) so delegated
|
||||
// subagent tools don't bypass span indentation via a named ``by_name`` UI.
|
||||
// ``withHitlInTimeline`` is the OUTERMOST wrapper so a body render with an
|
||||
// interrupt result returns ``null`` immediately — no inner wrappers paint
|
||||
// — while a timeline render (under ``HitlRenderTargetProvider value="timeline"``
|
||||
// inside ``ThinkingStepsDisplay``) passes through to the real component.
|
||||
const bundleTool = (Component: ToolCallMessagePartComponent) =>
|
||||
withBundleStep(withDelegationSpanIndent(Component));
|
||||
withHitlInTimeline(withBundleStep(withDelegationSpanIndent(Component)));
|
||||
|
||||
const NullToolUi: ToolCallMessagePartComponent = () => null;
|
||||
|
||||
const TOOLS_BY_NAME = {
|
||||
/**
|
||||
* Tool-call UI registry. Exported so ``ThinkingStepsDisplay`` can mount
|
||||
* the SAME wrapped components inline under a step row when the card's
|
||||
* result is an HITL interrupt. The wrappers handle ``ToolCallIdProvider``
|
||||
* and bundle paging consistently across both render targets.
|
||||
*/
|
||||
export const TOOLS_BY_NAME = {
|
||||
generate_report: bundleTool(GenerateReportToolUI),
|
||||
generate_resume: bundleTool(GenerateResumeToolUI),
|
||||
generate_podcast: bundleTool(GeneratePodcastToolUI),
|
||||
|
|
@ -554,7 +565,7 @@ const TOOLS_BY_NAME = {
|
|||
scrape_webpage: NullToolUi,
|
||||
} as const;
|
||||
|
||||
const TOOLS_FALLBACK = bundleTool(ToolFallback);
|
||||
export const TOOLS_FALLBACK = bundleTool(ToolFallback);
|
||||
|
||||
const AssistantMessageInner: FC = () => {
|
||||
const isMobile = !useMediaQuery("(min-width: 768px)");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { makeAssistantDataUI, useAuiState } from "@assistant-ui/react";
|
||||
import {
|
||||
makeAssistantDataUI,
|
||||
type ToolCallMessagePartComponent,
|
||||
useAuiState,
|
||||
} from "@assistant-ui/react";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { TOOLS_BY_NAME, TOOLS_FALLBACK } from "@/components/assistant-ui/assistant-message";
|
||||
import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { HitlRenderTargetProvider, isInterruptResult } from "@/lib/hitl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ThinkingStep {
|
||||
|
|
@ -24,20 +30,33 @@ export interface ThinkingStep {
|
|||
* Per-step info joined from the assistant message ``tool-call`` parts via
|
||||
* the shared ``metadata.thinkingStepId`` correlation
|
||||
* (set on the server in ``AgentEventRelayState.tool_activity_metadata``).
|
||||
*
|
||||
* Carries enough of the part to:
|
||||
* - identify the opening ``task`` step and substitute the subagent display
|
||||
* name on the parent header (uses ``toolName`` and ``args``);
|
||||
* - render the matching tool component inline under the step row when the
|
||||
* card's result is an HITL interrupt (uses ``toolCallId``, ``argsText``,
|
||||
* ``result``, ``langchainToolCallId``).
|
||||
*/
|
||||
interface StepToolInfo {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
argsText?: string;
|
||||
result?: unknown;
|
||||
langchainToolCallId?: string;
|
||||
}
|
||||
|
||||
export type ThinkingStepToolInfoMap = ReadonlyMap<string, StepToolInfo>;
|
||||
|
||||
/**
|
||||
* Build ``thinkingStepId → {toolName, args}`` from message content. Used to
|
||||
* Build ``thinkingStepId → StepToolInfo`` from message content. Used to
|
||||
* - identify the opening ``task`` step (parent header, never indents) without
|
||||
* relying on the human-readable title;
|
||||
* - render the parent's display title from ``args.subagent_type`` instead of
|
||||
* the generic "Task" copy.
|
||||
* the generic "Task" copy;
|
||||
* - mount the matching tool-call card inline under a step row when the
|
||||
* result is an HITL interrupt (see ``TimelineHitlCard``).
|
||||
*/
|
||||
export function buildThinkingStepToolInfo(
|
||||
content: readonly unknown[] | undefined
|
||||
|
|
@ -48,14 +67,25 @@ export function buildThinkingStepToolInfo(
|
|||
if (!part || typeof part !== "object") continue;
|
||||
const o = part as {
|
||||
type?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
args?: Record<string, unknown>;
|
||||
argsText?: string;
|
||||
result?: unknown;
|
||||
langchainToolCallId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
if (o.type !== "tool-call" || !o.toolName) continue;
|
||||
if (o.type !== "tool-call" || !o.toolName || !o.toolCallId) continue;
|
||||
const tid = o.metadata?.thinkingStepId;
|
||||
if (typeof tid === "string" && tid.trim().length > 0) {
|
||||
m.set(tid, { toolName: o.toolName, args: o.args ?? {} });
|
||||
m.set(tid, {
|
||||
toolCallId: o.toolCallId,
|
||||
toolName: o.toolName,
|
||||
args: o.args ?? {},
|
||||
argsText: o.argsText,
|
||||
result: o.result,
|
||||
langchainToolCallId: o.langchainToolCallId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return m;
|
||||
|
|
@ -159,6 +189,47 @@ const StepBody: FC<{
|
|||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Mount the same tool-call UI used in the message body, but inside the
|
||||
* chain-of-thought timeline. The body copy returns ``null`` (see
|
||||
* ``withHitlInTimeline`` in ``lib/hitl/render-target``), so the card
|
||||
* effectively moves from the body to the timeline for the lifetime of the
|
||||
* interrupt (pending → processing → complete / rejected).
|
||||
*
|
||||
* ``metadata`` is intentionally omitted from the props we forward — the
|
||||
* step row already provides any indentation it needs, so we don't want
|
||||
* ``withDelegationSpanIndent`` to add a second indent + border on top.
|
||||
*
|
||||
* ``status`` is a placeholder (HITL UIs read only ``args`` + ``result``)
|
||||
* so we don't need to mirror assistant-ui's runtime status object here.
|
||||
*/
|
||||
const TimelineHitlCard: FC<{ info: StepToolInfo }> = ({ info }) => {
|
||||
const Comp =
|
||||
(TOOLS_BY_NAME as Record<string, ToolCallMessagePartComponent | undefined>)[info.toolName] ??
|
||||
TOOLS_FALLBACK;
|
||||
const props = {
|
||||
toolCallId: info.toolCallId,
|
||||
toolName: info.toolName,
|
||||
args: info.args,
|
||||
argsText: info.argsText,
|
||||
result: info.result,
|
||||
langchainToolCallId: info.langchainToolCallId,
|
||||
status: { type: "complete" } as const,
|
||||
};
|
||||
return (
|
||||
<HitlRenderTargetProvider value="timeline">
|
||||
{/* biome-ignore lint/suspicious/noExplicitAny: ToolCallMessagePartProps requires
|
||||
runtime-only fields (addResult, resume, MessagePartState) we don't have when
|
||||
re-rendering manually; HITL components only read args + result. */}
|
||||
<Comp {...(props as any)} />
|
||||
</HitlRenderTargetProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function hitlInterruptInfo(info: StepToolInfo | undefined): StepToolInfo | undefined {
|
||||
return info && isInterruptResult(info.result) ? info : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain of thought display component - single collapsible dropdown design.
|
||||
*
|
||||
|
|
@ -291,17 +362,25 @@ export const ThinkingStepsDisplay: FC<{
|
|||
displayTitle={parentTitle}
|
||||
/>
|
||||
|
||||
{(() => {
|
||||
const hitl = hitlInterruptInfo(parentInfo);
|
||||
return hitl ? <TimelineHitlCard info={hitl} /> : null;
|
||||
})()}
|
||||
|
||||
{hasChildren && (
|
||||
<div className="mt-2 ml-3 space-y-2">
|
||||
{group.children.map((child) => {
|
||||
const childInfo = toolInfo.get(child.id);
|
||||
const childHitl = hitlInterruptInfo(childInfo);
|
||||
return (
|
||||
<StepBody
|
||||
key={child.id}
|
||||
step={child}
|
||||
status={getEffectiveStatus(child)}
|
||||
displayTitle={resolveDisplayTitle(child, childInfo)}
|
||||
/>
|
||||
<div key={child.id}>
|
||||
<StepBody
|
||||
step={child}
|
||||
status={getEffectiveStatus(child)}
|
||||
displayTitle={resolveDisplayTitle(child, childInfo)}
|
||||
/>
|
||||
{childHitl && <TimelineHitlCard info={childHitl} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ export {
|
|||
useHitlBundle,
|
||||
useToolCallIdContext,
|
||||
} from "./bundle-context";
|
||||
export {
|
||||
type HitlRenderTarget,
|
||||
HitlRenderTargetProvider,
|
||||
useHitlRenderTarget,
|
||||
withHitlInTimeline,
|
||||
} from "./render-target";
|
||||
export type {
|
||||
HitlDecision,
|
||||
InterruptActionRequest,
|
||||
|
|
|
|||
48
surfsense_web/lib/hitl/render-target.tsx
Normal file
48
surfsense_web/lib/hitl/render-target.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { isInterruptResult } from "./types";
|
||||
|
||||
/**
|
||||
* Where this tool-call card is currently rendering.
|
||||
*
|
||||
* - ``"body"`` (default) — assistant-ui's ``MessagePrimitive.Parts`` renders
|
||||
* the card inside the message bubble.
|
||||
* - ``"timeline"`` — ``ThinkingStepsDisplay`` renders the SAME component
|
||||
* inline under the matching step row so the HITL approval lives in the
|
||||
* chain-of-thought instead of as a standalone card in the message body.
|
||||
*
|
||||
* The two render targets share one component implementation; the context
|
||||
* lets the body render skip itself when the timeline copy will show the
|
||||
* card, avoiding a double-render.
|
||||
*/
|
||||
export type HitlRenderTarget = "body" | "timeline";
|
||||
|
||||
const HitlRenderTargetContext = createContext<HitlRenderTarget>("body");
|
||||
|
||||
export const HitlRenderTargetProvider = HitlRenderTargetContext.Provider;
|
||||
|
||||
export function useHitlRenderTarget(): HitlRenderTarget {
|
||||
return useContext(HitlRenderTargetContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the body render of a tool-call whose result is a HITL interrupt.
|
||||
* The same component is mounted again inside ``ThinkingStepsDisplay``
|
||||
* with ``HitlRenderTargetProvider value="timeline"`` — that copy renders
|
||||
* normally, so the card "moves" from the message body to the timeline.
|
||||
*
|
||||
* Pure pass-through for non-HITL results AND for the timeline render.
|
||||
*/
|
||||
export function withHitlInTimeline(
|
||||
Component: ToolCallMessagePartComponent
|
||||
): ToolCallMessagePartComponent {
|
||||
const Wrapped: ToolCallMessagePartComponent = (props) => {
|
||||
const target = useHitlRenderTarget();
|
||||
if (target === "body" && isInterruptResult(props.result)) return null;
|
||||
return <Component {...props} />;
|
||||
};
|
||||
Wrapped.displayName = `withHitlInTimeline(${Component.displayName ?? Component.name ?? "ToolUI"})`;
|
||||
return Wrapped;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue