mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 01:02:39 +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 { useComments } from "@/hooks/use-comments";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { useElectronAPI } from "@/hooks/use-platform";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
import { withHitlInTimeline } from "@/lib/hitl";
|
||||||
import { getProviderIcon } from "@/lib/provider-icons";
|
import { getProviderIcon } from "@/lib/provider-icons";
|
||||||
import { cn } from "@/lib/utils";
|
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.
|
// page through them and stage decisions instead of firing one resume per card.
|
||||||
// ``withDelegationSpanIndent`` wraps every entry (including Fallback) so delegated
|
// ``withDelegationSpanIndent`` wraps every entry (including Fallback) so delegated
|
||||||
// subagent tools don't bypass span indentation via a named ``by_name`` UI.
|
// 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) =>
|
const bundleTool = (Component: ToolCallMessagePartComponent) =>
|
||||||
withBundleStep(withDelegationSpanIndent(Component));
|
withHitlInTimeline(withBundleStep(withDelegationSpanIndent(Component)));
|
||||||
|
|
||||||
const NullToolUi: ToolCallMessagePartComponent = () => null;
|
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_report: bundleTool(GenerateReportToolUI),
|
||||||
generate_resume: bundleTool(GenerateResumeToolUI),
|
generate_resume: bundleTool(GenerateResumeToolUI),
|
||||||
generate_podcast: bundleTool(GeneratePodcastToolUI),
|
generate_podcast: bundleTool(GeneratePodcastToolUI),
|
||||||
|
|
@ -554,7 +565,7 @@ const TOOLS_BY_NAME = {
|
||||||
scrape_webpage: NullToolUi,
|
scrape_webpage: NullToolUi,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const TOOLS_FALLBACK = bundleTool(ToolFallback);
|
export const TOOLS_FALLBACK = bundleTool(ToolFallback);
|
||||||
|
|
||||||
const AssistantMessageInner: FC = () => {
|
const AssistantMessageInner: FC = () => {
|
||||||
const isMobile = !useMediaQuery("(min-width: 768px)");
|
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 { ChevronRightIcon } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } 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 { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
|
||||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||||
|
import { HitlRenderTargetProvider, isInterruptResult } from "@/lib/hitl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface ThinkingStep {
|
export interface ThinkingStep {
|
||||||
|
|
@ -24,20 +30,33 @@ export interface ThinkingStep {
|
||||||
* Per-step info joined from the assistant message ``tool-call`` parts via
|
* Per-step info joined from the assistant message ``tool-call`` parts via
|
||||||
* the shared ``metadata.thinkingStepId`` correlation
|
* the shared ``metadata.thinkingStepId`` correlation
|
||||||
* (set on the server in ``AgentEventRelayState.tool_activity_metadata``).
|
* (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 {
|
interface StepToolInfo {
|
||||||
|
toolCallId: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
|
argsText?: string;
|
||||||
|
result?: unknown;
|
||||||
|
langchainToolCallId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ThinkingStepToolInfoMap = ReadonlyMap<string, StepToolInfo>;
|
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
|
* - identify the opening ``task`` step (parent header, never indents) without
|
||||||
* relying on the human-readable title;
|
* relying on the human-readable title;
|
||||||
* - render the parent's display title from ``args.subagent_type`` instead of
|
* - 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(
|
export function buildThinkingStepToolInfo(
|
||||||
content: readonly unknown[] | undefined
|
content: readonly unknown[] | undefined
|
||||||
|
|
@ -48,14 +67,25 @@ export function buildThinkingStepToolInfo(
|
||||||
if (!part || typeof part !== "object") continue;
|
if (!part || typeof part !== "object") continue;
|
||||||
const o = part as {
|
const o = part as {
|
||||||
type?: string;
|
type?: string;
|
||||||
|
toolCallId?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, unknown>;
|
||||||
|
argsText?: string;
|
||||||
|
result?: unknown;
|
||||||
|
langchainToolCallId?: string;
|
||||||
metadata?: Record<string, unknown>;
|
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;
|
const tid = o.metadata?.thinkingStepId;
|
||||||
if (typeof tid === "string" && tid.trim().length > 0) {
|
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;
|
return m;
|
||||||
|
|
@ -159,6 +189,47 @@ const StepBody: FC<{
|
||||||
</div>
|
</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.
|
* Chain of thought display component - single collapsible dropdown design.
|
||||||
*
|
*
|
||||||
|
|
@ -291,17 +362,25 @@ export const ThinkingStepsDisplay: FC<{
|
||||||
displayTitle={parentTitle}
|
displayTitle={parentTitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const hitl = hitlInterruptInfo(parentInfo);
|
||||||
|
return hitl ? <TimelineHitlCard info={hitl} /> : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
{hasChildren && (
|
{hasChildren && (
|
||||||
<div className="mt-2 ml-3 space-y-2">
|
<div className="mt-2 ml-3 space-y-2">
|
||||||
{group.children.map((child) => {
|
{group.children.map((child) => {
|
||||||
const childInfo = toolInfo.get(child.id);
|
const childInfo = toolInfo.get(child.id);
|
||||||
|
const childHitl = hitlInterruptInfo(childInfo);
|
||||||
return (
|
return (
|
||||||
<StepBody
|
<div key={child.id}>
|
||||||
key={child.id}
|
<StepBody
|
||||||
step={child}
|
step={child}
|
||||||
status={getEffectiveStatus(child)}
|
status={getEffectiveStatus(child)}
|
||||||
displayTitle={resolveDisplayTitle(child, childInfo)}
|
displayTitle={resolveDisplayTitle(child, childInfo)}
|
||||||
/>
|
/>
|
||||||
|
{childHitl && <TimelineHitlCard info={childHitl} />}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ export {
|
||||||
useHitlBundle,
|
useHitlBundle,
|
||||||
useToolCallIdContext,
|
useToolCallIdContext,
|
||||||
} from "./bundle-context";
|
} from "./bundle-context";
|
||||||
|
export {
|
||||||
|
type HitlRenderTarget,
|
||||||
|
HitlRenderTargetProvider,
|
||||||
|
useHitlRenderTarget,
|
||||||
|
withHitlInTimeline,
|
||||||
|
} from "./render-target";
|
||||||
export type {
|
export type {
|
||||||
HitlDecision,
|
HitlDecision,
|
||||||
InterruptActionRequest,
|
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