mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 17:31:33 +08:00
hitl on canvas
This commit is contained in:
@ -0,0 +1,50 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { InteractiveContent } from "@/types/chat";
|
||||
import { useHitlStore } from "@/stores/hitlStore";
|
||||
import HumanInputNodeBadge from "../index";
|
||||
|
||||
jest.mock("@/components/core/chatComponents/HumanInputCard", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="hitl-card" />,
|
||||
}));
|
||||
jest.mock("@/components/common/genericIconComponent", () => ({
|
||||
__esModule: true,
|
||||
default: () => <span />,
|
||||
}));
|
||||
jest.mock("@/components/ui/popover", () => ({
|
||||
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const _content: InteractiveContent = {
|
||||
type: "human_input",
|
||||
kind: "node_input",
|
||||
request_id: "HumanInput-abc:job-1",
|
||||
prompt: "Approve?",
|
||||
options: [{ action_id: "approve", label: "Approve" }],
|
||||
allowed_decisions: ["approve"],
|
||||
job_id: "job-1",
|
||||
};
|
||||
|
||||
describe("HumanInputNodeBadge", () => {
|
||||
afterEach(() => useHitlStore.getState().clear());
|
||||
|
||||
it("renders nothing when no node is awaiting input", () => {
|
||||
render(<HumanInputNodeBadge nodeId="HumanInput-abc" />);
|
||||
expect(screen.queryByTestId("human-input-node-badge")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the badge + card on the node whose id matches the pending request", () => {
|
||||
useHitlStore.getState().setPending({ nodeId: "HumanInput-abc", content: _content });
|
||||
render(<HumanInputNodeBadge nodeId="HumanInput-abc" />);
|
||||
expect(screen.getByTestId("human-input-node-badge")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("hitl-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing on a node that is not the one awaiting input", () => {
|
||||
useHitlStore.getState().setPending({ nodeId: "HumanInput-abc", content: _content });
|
||||
render(<HumanInputNodeBadge nodeId="ChatOutput-xyz" />);
|
||||
expect(screen.queryByTestId("human-input-node-badge")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
||||
import HumanInputCard from "@/components/core/chatComponents/HumanInputCard";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { useHitlStore } from "@/stores/hitlStore";
|
||||
import { usePlaygroundStore } from "@/stores/playgroundStore";
|
||||
import { cn } from "@/utils/utils";
|
||||
|
||||
/**
|
||||
* Canvas affordance for a paused Human Input node (LE-1603): a pulsing "awaiting input"
|
||||
* badge that doubles as the anchor for a decision popover. The popover reuses the chat
|
||||
* `HumanInputCard` (which self-resumes), so the human can resolve the pause from the
|
||||
* canvas. Only the node whose id matches the pending request renders anything.
|
||||
*/
|
||||
export default function HumanInputNodeBadge({ nodeId }: { nodeId: string }) {
|
||||
const pending = useHitlStore((state) =>
|
||||
state.pending?.nodeId === nodeId ? state.pending : null,
|
||||
);
|
||||
// The Playground renders its own card; its anchored popover would otherwise portal
|
||||
// over the open Playground. Only surface the canvas affordance when it is closed.
|
||||
const playgroundOpen = usePlaygroundStore((state) => state.isOpen);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Auto-open when this node starts awaiting; dismissing keeps the badge to reopen.
|
||||
useEffect(() => {
|
||||
if (pending && !playgroundOpen) setOpen(true);
|
||||
}, [pending, playgroundOpen]);
|
||||
|
||||
if (!pending || playgroundOpen) return null;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="human-input-node-badge"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full border border-accent-amber-foreground/40",
|
||||
"bg-accent-amber/20 px-2 py-0.5 text-xs font-medium text-accent-amber-foreground",
|
||||
)}
|
||||
>
|
||||
<ForwardedIconComponent
|
||||
name="CircleHelp"
|
||||
className="h-3 w-3 animate-pulse"
|
||||
/>
|
||||
Awaiting input
|
||||
</button>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-96 p-0"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<HumanInputCard content={pending.content} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@ -31,6 +31,7 @@ import { processNodeAdvancedFields } from "../helpers/process-node-advanced-fiel
|
||||
import useUpdateNodeCode from "../hooks/use-update-node-code";
|
||||
import NodeDescription from "./components/NodeDescription";
|
||||
import NodeLegacyComponent from "./components/NodeLegacyComponent";
|
||||
import HumanInputNodeBadge from "./components/HumanInputNodeBadge";
|
||||
import NodeName from "./components/NodeName";
|
||||
import NodeOutputs from "./components/NodeOutputParameter/NodeOutputs";
|
||||
import NodeUpdateComponent from "./components/NodeUpdateComponent";
|
||||
@ -605,6 +606,7 @@ function GenericNode({
|
||||
setHasChangedNodeDescription={setHasChangedNodeDescription}
|
||||
/>
|
||||
</div>
|
||||
<HumanInputNodeBadge nodeId={data.id} />
|
||||
</div>
|
||||
{!showNode && (
|
||||
<>
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
import { updateMessage } from "@/components/core/playgroundComponent/chat-view/utils/message-utils";
|
||||
import { queryClient } from "@/contexts";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import { useHitlStore } from "@/stores/hitlStore";
|
||||
import { useMessagesStore } from "@/stores/messagesStore";
|
||||
import type { ContentBlock, InteractiveContent } from "@/types/chat";
|
||||
import type { Message } from "@/types/messages";
|
||||
@ -150,5 +151,7 @@ export function injectHumanInputCard(
|
||||
// playground reads useMessagesStore. Write both so the card renders either way.
|
||||
updateMessage(message);
|
||||
useMessagesStore.getState().addMessage(message);
|
||||
// Canvas: surface the pause on the node that requested it (request_id = node_id:run_id).
|
||||
useHitlStore.getState().setPending({ nodeId: content.request_id.split(":")[0], content });
|
||||
useFlowStore.getState().setAwaitingInput(true);
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import { handleMessageEvent } from "@/components/core/playgroundComponent/chat-v
|
||||
import { BuildStatus } from "@/constants/enums";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import { useHitlStore } from "@/stores/hitlStore";
|
||||
import type {
|
||||
ChatInputType,
|
||||
ChatOutputType,
|
||||
@ -394,6 +395,8 @@ export async function consumeBackgroundEvents(
|
||||
// isBuilding, so it must clear on suspend too — only awaitingInput differs.
|
||||
flowStore.setIsBuilding(false);
|
||||
flowStore.setAwaitingInput(suspended);
|
||||
// The run ended (not parked at a pause): drop the canvas awaiting-input badge.
|
||||
if (!suspended) useHitlStore.getState().clear();
|
||||
flowStore.revertBuiltStatusFromBuilding();
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGetMessagesQuery } from "@/controllers/API/queries/messages";
|
||||
import { useHitlStore } from "@/stores/hitlStore";
|
||||
import type { Message } from "@/types/messages";
|
||||
import { findHumanInputContent } from "./human-input-card";
|
||||
|
||||
/**
|
||||
* Restore the canvas awaiting-input badge after a reload (LE-1603 reconnect): the
|
||||
* pending state lives in memory and is lost on F5, but the pause is persisted as a
|
||||
* chat message. Find the latest unanswered human_input card for the flow and re-arm
|
||||
* the badge from it (no extra job-status query needed).
|
||||
*/
|
||||
export function useRestoreCanvasHitl(flowId: string | undefined): void {
|
||||
const { data } = useGetMessagesQuery(
|
||||
{ id: flowId ?? "", mode: "union" },
|
||||
{ enabled: Boolean(flowId) },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const rows =
|
||||
(data as { rows?: { data?: Message[] } } | undefined)?.rows?.data ?? [];
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const content = findHumanInputContent(rows[i].content_blocks);
|
||||
if (content && !content.submitted_action && content.request_id) {
|
||||
useHitlStore.getState().setPending({
|
||||
nodeId: content.request_id.split(":")[0],
|
||||
content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
}
|
||||
@ -33,6 +33,7 @@ import {
|
||||
FlowSidebarComponent,
|
||||
} from "./components/flowSidebarComponent";
|
||||
import MemoriesMainContent from "./components/MemoriesMainContent";
|
||||
import { useRestoreCanvasHitl } from "@/controllers/API/agui/use-restore-canvas-hitl";
|
||||
import Page from "./components/PageComponent";
|
||||
import { FlowInsightsContent } from "./components/TraceComponent/FlowInsightsContent";
|
||||
|
||||
@ -98,6 +99,9 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
|
||||
const flows = useFlowsManagerStore((state) => state.flows);
|
||||
const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId);
|
||||
|
||||
// Restore the Human Input awaiting-input badge after a reload (LE-1603 reconnect).
|
||||
useRestoreCanvasHitl(currentFlowId);
|
||||
|
||||
const updatedAt = currentSavedFlow?.updated_at;
|
||||
const autoSaving = useFlowsManagerStore((state) => state.autoSaving);
|
||||
const stopBuilding = useFlowStore((state) => state.stopBuilding);
|
||||
|
||||
25
src/frontend/src/stores/hitlStore.ts
Normal file
25
src/frontend/src/stores/hitlStore.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { create } from "zustand";
|
||||
import type { InteractiveContent } from "@/types/chat";
|
||||
|
||||
/**
|
||||
* Canvas-side human-in-the-loop state (LE-1603): the single node currently awaiting
|
||||
* a human decision, so the Human Input node can show an awaiting badge + an anchored
|
||||
* decision popover. Single slot — the default single-node slice pauses one node at a
|
||||
* time; the badge component matches on `nodeId`.
|
||||
*/
|
||||
interface HitlPending {
|
||||
nodeId: string;
|
||||
content: InteractiveContent;
|
||||
}
|
||||
|
||||
interface HitlStoreType {
|
||||
pending: HitlPending | null;
|
||||
setPending: (pending: HitlPending) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useHitlStore = create<HitlStoreType>((set) => ({
|
||||
pending: null,
|
||||
setPending: (pending) => set({ pending }),
|
||||
clear: () => set({ pending: null }),
|
||||
}));
|
||||
Reference in New Issue
Block a user