mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 01:02:39 +02:00
chat-messages: add hitl module with types, hooks, bundle, approval cards, and edit panel.
This commit is contained in:
parent
d9ad9ca5cb
commit
9e451a5907
17 changed files with 1444 additions and 0 deletions
|
|
@ -0,0 +1,157 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
|
||||
import type { HitlDecision } from "../types";
|
||||
|
||||
export type BundleSubmit = (orderedDecisions: HitlDecision[]) => void;
|
||||
|
||||
export interface HitlBundleAPI {
|
||||
toolCallIds: readonly string[];
|
||||
currentStep: number;
|
||||
stagedCount: number;
|
||||
isInBundle: (toolCallId: string) => boolean;
|
||||
isCurrentStep: (toolCallId: string) => boolean;
|
||||
getStaged: (toolCallId: string) => HitlDecision | undefined;
|
||||
stage: (toolCallId: string, decision: HitlDecision) => void;
|
||||
goToStep: (i: number) => void;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
const HitlBundleContext = createContext<HitlBundleAPI | null>(null);
|
||||
const ToolCallIdContext = createContext<string | null>(null);
|
||||
|
||||
export function useHitlBundle(): HitlBundleAPI | null {
|
||||
return useContext(HitlBundleContext);
|
||||
}
|
||||
|
||||
export function useToolCallIdContext(): string | null {
|
||||
return useContext(ToolCallIdContext);
|
||||
}
|
||||
|
||||
export function ToolCallIdProvider({
|
||||
toolCallId,
|
||||
children,
|
||||
}: {
|
||||
toolCallId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <ToolCallIdContext.Provider value={toolCallId}>{children}</ToolCallIdContext.Provider>;
|
||||
}
|
||||
|
||||
interface HitlBundleProviderProps {
|
||||
toolCallIds: readonly string[] | null;
|
||||
onSubmit: BundleSubmit;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates N pending HITL decisions into ONE ordered submission.
|
||||
*
|
||||
* Active only when ``toolCallIds`` has 2+ entries (parallel interrupts);
|
||||
* single-card interrupts bypass the bundle entirely (``useHitlDecision``
|
||||
* fires the ``hitl-decision`` window event directly).
|
||||
*
|
||||
* Pager UX: ``tool-call-item.tsx`` reads ``isInBundle`` + ``isCurrentStep``
|
||||
* to render only the current-step card; ``timeline.tsx`` mounts
|
||||
* ``<PagerChrome />`` once when this Provider is active. Submission is
|
||||
* user-initiated via the pager's "Submit decisions" button (calls
|
||||
* ``submit()``); not auto.
|
||||
*/
|
||||
export function HitlBundleProvider({ toolCallIds, onSubmit, children }: HitlBundleProviderProps) {
|
||||
const active = toolCallIds !== null && toolCallIds.length >= 2;
|
||||
const ids = useMemo(() => (active ? [...toolCallIds] : []), [active, toolCallIds]);
|
||||
const bundleKey = ids.join("|");
|
||||
|
||||
const [prevBundleKey, setPrevBundleKey] = useState(bundleKey);
|
||||
const [staged, setStaged] = useState<Map<string, HitlDecision>>(() => new Map());
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
if (bundleKey !== prevBundleKey) {
|
||||
setPrevBundleKey(bundleKey);
|
||||
setStaged(new Map());
|
||||
setCurrentStep(0);
|
||||
}
|
||||
|
||||
const isInBundle = useCallback((tcId: string) => ids.includes(tcId), [ids]);
|
||||
const isCurrentStep = useCallback(
|
||||
(tcId: string) => active === true && ids[currentStep] === tcId,
|
||||
[active, ids, currentStep]
|
||||
);
|
||||
const getStaged = useCallback((tcId: string) => staged.get(tcId), [staged]);
|
||||
const stage = useCallback(
|
||||
(tcId: string, decision: HitlDecision) => {
|
||||
if (!active || !ids.includes(tcId)) return;
|
||||
setStaged((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(tcId, decision);
|
||||
return next;
|
||||
});
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-stage", { detail: { toolCallId: tcId, decision } })
|
||||
);
|
||||
const idx = ids.indexOf(tcId);
|
||||
if (idx >= 0 && idx < ids.length - 1) {
|
||||
setCurrentStep(idx + 1);
|
||||
}
|
||||
},
|
||||
[active, ids]
|
||||
);
|
||||
const goToStep = useCallback(
|
||||
(i: number) => {
|
||||
if (i < 0 || i >= ids.length) return;
|
||||
setCurrentStep(i);
|
||||
},
|
||||
[ids.length]
|
||||
);
|
||||
const next = useCallback(() => {
|
||||
setCurrentStep((s) => Math.min(s + 1, Math.max(0, ids.length - 1)));
|
||||
}, [ids.length]);
|
||||
const prev = useCallback(() => {
|
||||
setCurrentStep((s) => Math.max(s - 1, 0));
|
||||
}, []);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (!active) return;
|
||||
if (staged.size !== ids.length) return;
|
||||
const ordered: HitlDecision[] = [];
|
||||
for (const tcId of ids) {
|
||||
const d = staged.get(tcId);
|
||||
if (!d) return;
|
||||
ordered.push(d);
|
||||
}
|
||||
onSubmit(ordered);
|
||||
}, [active, ids, staged, onSubmit]);
|
||||
|
||||
const value = useMemo<HitlBundleAPI | null>(() => {
|
||||
if (!active) return null;
|
||||
return {
|
||||
toolCallIds: ids,
|
||||
currentStep,
|
||||
stagedCount: staged.size,
|
||||
isInBundle,
|
||||
isCurrentStep,
|
||||
getStaged,
|
||||
stage,
|
||||
goToStep,
|
||||
next,
|
||||
prev,
|
||||
submit,
|
||||
};
|
||||
}, [
|
||||
active,
|
||||
ids,
|
||||
currentStep,
|
||||
staged,
|
||||
isInBundle,
|
||||
isCurrentStep,
|
||||
getStaged,
|
||||
stage,
|
||||
goToStep,
|
||||
next,
|
||||
prev,
|
||||
submit,
|
||||
]);
|
||||
|
||||
return <HitlBundleContext.Provider value={value}>{children}</HitlBundleContext.Provider>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue