mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-06 22:32:39 +02:00
Stabilize HITL bundle UX and resume.
This commit is contained in:
parent
972650909c
commit
0af2c28a8d
12 changed files with 553 additions and 184 deletions
|
|
@ -22,7 +22,12 @@ def hitlrequest_action_count(pending_value: Any) -> int:
|
||||||
|
|
||||||
|
|
||||||
def fan_out_decisions_to_match(resume_value: Any, expected_count: int) -> Any:
|
def fan_out_decisions_to_match(resume_value: Any, expected_count: int) -> Any:
|
||||||
"""Pad a single-decision resume to N entries so an ``action_requests=N`` bundle accepts it."""
|
"""Legacy fallback: pad a 1-decision resume to N for an ``action_requests=N`` bundle.
|
||||||
|
|
||||||
|
Modern frontend submits N decisions per bundle (one per action_request) so
|
||||||
|
this is a no-op; kept for backwards compatibility with old in-flight
|
||||||
|
threads or non-bundle clients that send a single decision.
|
||||||
|
"""
|
||||||
if expected_count <= 1:
|
if expected_count <= 1:
|
||||||
return resume_value
|
return resume_value
|
||||||
if not isinstance(resume_value, dict):
|
if not isinstance(resume_value, dict):
|
||||||
|
|
|
||||||
|
|
@ -139,13 +139,6 @@ def build_task_tool_with_parent_config(
|
||||||
if resume_value is not None:
|
if resume_value is not None:
|
||||||
expected = hitlrequest_action_count(pending_value)
|
expected = hitlrequest_action_count(pending_value)
|
||||||
resume_value = fan_out_decisions_to_match(resume_value, expected)
|
resume_value = fan_out_decisions_to_match(resume_value, expected)
|
||||||
logger.info(
|
|
||||||
"Forwarding surfsense_resume_value into subagent %r "
|
|
||||||
"(action_requests=%d, targeted_id=%s)",
|
|
||||||
subagent_type,
|
|
||||||
expected,
|
|
||||||
pending_id is not None,
|
|
||||||
)
|
|
||||||
result = subagent.invoke(
|
result = subagent.invoke(
|
||||||
build_resume_command(resume_value, pending_id),
|
build_resume_command(resume_value, pending_id),
|
||||||
config=sub_config,
|
config=sub_config,
|
||||||
|
|
@ -211,13 +204,6 @@ def build_task_tool_with_parent_config(
|
||||||
if resume_value is not None:
|
if resume_value is not None:
|
||||||
expected = hitlrequest_action_count(pending_value)
|
expected = hitlrequest_action_count(pending_value)
|
||||||
resume_value = fan_out_decisions_to_match(resume_value, expected)
|
resume_value = fan_out_decisions_to_match(resume_value, expected)
|
||||||
logger.info(
|
|
||||||
"Forwarding surfsense_resume_value into subagent %r "
|
|
||||||
"(action_requests=%d, targeted_id=%s)",
|
|
||||||
subagent_type,
|
|
||||||
expected,
|
|
||||||
pending_id is not None,
|
|
||||||
)
|
|
||||||
result = await subagent.ainvoke(
|
result = await subagent.ainvoke(
|
||||||
build_resume_command(resume_value, pending_id),
|
build_resume_command(resume_value, pending_id),
|
||||||
config=sub_config,
|
config=sub_config,
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,15 @@ later in this system prompt, after citation rules).
|
||||||
|
|
||||||
Do not treat live third-party state as if it were already in the indexed knowledge
|
Do not treat live third-party state as if it were already in the indexed knowledge
|
||||||
base; reach it via **task**.
|
base; reach it via **task**.
|
||||||
|
|
||||||
|
Never emit more than one **task** tool call in the same turn. Bundle related work
|
||||||
|
for the same specialist into a single **task** invocation (the subagent itself can
|
||||||
|
call its own tools in parallel inside that one run). Parallel **task** calls would
|
||||||
|
fan out into multiple concurrent subagent runs whose human-approval interrupts
|
||||||
|
cannot be coordinated; one **task** at a time is required.
|
||||||
</tool_routing>
|
</tool_routing>
|
||||||
|
|
||||||
|
<!-- TODO: lift the single-task constraint once the runtime supports parallel task
|
||||||
|
interrupts end-to-end (multi-interrupt SSE + interrupt-id-keyed Command(resume)
|
||||||
|
+ keyed surfsense_resume_value side-channel). Until then this nudge is the only
|
||||||
|
guard; the parent graph's resume cannot address multiple pending interrupts. -->
|
||||||
|
|
@ -2758,7 +2758,6 @@ async def stream_resume_chat(
|
||||||
fs_mode,
|
fs_mode,
|
||||||
fs_platform,
|
fs_platform,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.services.token_tracking_service import start_turn
|
from app.services.token_tracking_service import start_turn
|
||||||
|
|
||||||
accumulator = start_turn()
|
accumulator = start_turn()
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ import {
|
||||||
type NewChatUserImagePayload,
|
type NewChatUserImagePayload,
|
||||||
} from "@/lib/chat/user-turn-api-parts";
|
} from "@/lib/chat/user-turn-api-parts";
|
||||||
import { NotFoundError } from "@/lib/error";
|
import { NotFoundError } from "@/lib/error";
|
||||||
|
import { type BundleSubmit, HitlBundleProvider } from "@/lib/hitl";
|
||||||
import {
|
import {
|
||||||
trackChatCreated,
|
trackChatCreated,
|
||||||
trackChatError,
|
trackChatError,
|
||||||
|
|
@ -147,28 +148,59 @@ function markInterruptsCompleted(contentParts: Array<{ type: string; result?: un
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Most recent pending tool-call card with this name, so a new HITL interrupt
|
* Generate a synthetic ``toolCallId`` for an action_request that has no
|
||||||
* does not overwrite an already-approved card with the same tool name.
|
* matching streamed tool-call card (HITL-blocked subagent calls don't surface
|
||||||
|
* as tool-call events). Suffixes a counter when the base id is already taken
|
||||||
|
* — sequential interrupts for the same tool name otherwise collide on
|
||||||
|
* ``interrupt-${name}-${i}`` and crash assistant-ui with a duplicate-key error.
|
||||||
*/
|
*/
|
||||||
function findHitlTargetToolCallId(
|
function freshSynthToolCallId(
|
||||||
|
toolCallIndices: Map<string, number>,
|
||||||
|
toolName: string,
|
||||||
|
index: number
|
||||||
|
): string {
|
||||||
|
const base = `interrupt-${toolName}-${index}`;
|
||||||
|
if (!toolCallIndices.has(base)) return base;
|
||||||
|
let n = 1;
|
||||||
|
while (toolCallIndices.has(`${base}-${n}`)) n++;
|
||||||
|
return `${base}-${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair each ``action_request`` to a unique pending tool-call card, preserving
|
||||||
|
* order so ``decisions[i]`` lines up with ``action_requests[i]`` on the wire.
|
||||||
|
*
|
||||||
|
* Same-name bundles (e.g. three ``create_jira_issue``) used to collapse onto
|
||||||
|
* one card because the matcher keyed by name; this consumes each card via the
|
||||||
|
* ``claimed`` set and walks forward in DOM order.
|
||||||
|
*/
|
||||||
|
function pairBundleToolCallIds(
|
||||||
toolCallIndices: Map<string, number>,
|
toolCallIndices: Map<string, number>,
|
||||||
contentParts: Array<{
|
contentParts: Array<{
|
||||||
type: string;
|
type: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
}>,
|
}>,
|
||||||
toolName: string
|
actionRequests: ReadonlyArray<{ name: string }>
|
||||||
): string | null {
|
): Array<string | null> {
|
||||||
const entries = Array.from(toolCallIndices.entries());
|
const claimed = new Set<string>();
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
const paired: Array<string | null> = [];
|
||||||
const [tcId, idx] = entries[i];
|
for (const action of actionRequests) {
|
||||||
|
let matched: string | null = null;
|
||||||
|
for (const [tcId, idx] of toolCallIndices) {
|
||||||
|
if (claimed.has(tcId)) continue;
|
||||||
const part = contentParts[idx];
|
const part = contentParts[idx];
|
||||||
if (!part || part.type !== "tool-call" || part.toolName !== toolName) continue;
|
if (!part || part.type !== "tool-call" || part.toolName !== action.name) continue;
|
||||||
const result = part.result as Record<string, unknown> | undefined | null;
|
const result = part.result as Record<string, unknown> | undefined | null;
|
||||||
if (result == null) return tcId;
|
if (result == null || (result.__interrupt__ === true && !result.__decided__)) {
|
||||||
if (result.__interrupt__ === true && !result.__decided__) return tcId;
|
matched = tcId;
|
||||||
|
claimed.add(tcId);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return null;
|
}
|
||||||
|
paired.push(matched);
|
||||||
|
}
|
||||||
|
return paired;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -249,6 +281,7 @@ export default function NewChatPage() {
|
||||||
threadId: number;
|
threadId: number;
|
||||||
assistantMsgId: string;
|
assistantMsgId: string;
|
||||||
interruptData: Record<string, unknown>;
|
interruptData: Record<string, unknown>;
|
||||||
|
bundleToolCallIds: string[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const toolsWithUI = TOOLS_WITH_UI_ALL;
|
const toolsWithUI = TOOLS_WITH_UI_ALL;
|
||||||
|
|
||||||
|
|
@ -973,23 +1006,26 @@ export default function NewChatPage() {
|
||||||
name: string;
|
name: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
for (const action of actionRequests) {
|
const paired = pairBundleToolCallIds(toolCallIndices, contentParts, actionRequests);
|
||||||
const targetTcId = findHitlTargetToolCallId(
|
const bundleToolCallIds: string[] = [];
|
||||||
toolCallIndices,
|
for (let i = 0; i < actionRequests.length; i++) {
|
||||||
contentParts,
|
const action = actionRequests[i];
|
||||||
action.name
|
let targetTcId = paired[i];
|
||||||
|
if (!targetTcId) {
|
||||||
|
targetTcId = freshSynthToolCallId(toolCallIndices, action.name, i);
|
||||||
|
addToolCall(
|
||||||
|
contentPartsState,
|
||||||
|
toolsWithUI,
|
||||||
|
targetTcId,
|
||||||
|
action.name,
|
||||||
|
action.args,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
if (targetTcId) {
|
}
|
||||||
updateToolCall(contentPartsState, targetTcId, {
|
updateToolCall(contentPartsState, targetTcId, {
|
||||||
result: { __interrupt__: true, ...interruptData },
|
result: { __interrupt__: true, ...interruptData },
|
||||||
});
|
});
|
||||||
} else {
|
bundleToolCallIds.push(targetTcId);
|
||||||
const tcId = `interrupt-${action.name}`;
|
|
||||||
addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true);
|
|
||||||
updateToolCall(contentPartsState, tcId, {
|
|
||||||
result: { __interrupt__: true, ...interruptData },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
|
|
@ -1003,6 +1039,7 @@ export default function NewChatPage() {
|
||||||
threadId: currentThreadId,
|
threadId: currentThreadId,
|
||||||
assistantMsgId,
|
assistantMsgId,
|
||||||
interruptData,
|
interruptData,
|
||||||
|
bundleToolCallIds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -1414,29 +1451,26 @@ export default function NewChatPage() {
|
||||||
name: string;
|
name: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
for (const action of actionRequests) {
|
const paired = pairBundleToolCallIds(toolCallIndices, contentParts, actionRequests);
|
||||||
const targetTcId = findHitlTargetToolCallId(
|
const bundleToolCallIds: string[] = [];
|
||||||
toolCallIndices,
|
for (let i = 0; i < actionRequests.length; i++) {
|
||||||
contentParts,
|
const action = actionRequests[i];
|
||||||
action.name
|
let targetTcId = paired[i];
|
||||||
|
if (!targetTcId) {
|
||||||
|
targetTcId = freshSynthToolCallId(toolCallIndices, action.name, i);
|
||||||
|
addToolCall(
|
||||||
|
contentPartsState,
|
||||||
|
toolsWithUI,
|
||||||
|
targetTcId,
|
||||||
|
action.name,
|
||||||
|
action.args,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
if (targetTcId) {
|
|
||||||
updateToolCall(contentPartsState, targetTcId, {
|
|
||||||
result: {
|
|
||||||
__interrupt__: true,
|
|
||||||
...interruptData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const tcId = `interrupt-${action.name}`;
|
|
||||||
addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true);
|
|
||||||
updateToolCall(contentPartsState, tcId, {
|
|
||||||
result: {
|
|
||||||
__interrupt__: true,
|
|
||||||
...interruptData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
updateToolCall(contentPartsState, targetTcId, {
|
||||||
|
result: { __interrupt__: true, ...interruptData },
|
||||||
|
});
|
||||||
|
bundleToolCallIds.push(targetTcId);
|
||||||
}
|
}
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
|
|
@ -1449,6 +1483,7 @@ export default function NewChatPage() {
|
||||||
threadId: resumeThreadId,
|
threadId: resumeThreadId,
|
||||||
assistantMsgId,
|
assistantMsgId,
|
||||||
interruptData,
|
interruptData,
|
||||||
|
bundleToolCallIds,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1538,53 +1573,45 @@ export default function NewChatPage() {
|
||||||
edited_action?: { name: string; args: Record<string, unknown> };
|
edited_action?: { name: string; args: Record<string, unknown> };
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
if (detail?.decisions && pendingInterrupt) {
|
if (!detail?.decisions || !pendingInterrupt) return;
|
||||||
const decision = detail.decisions[0];
|
const incoming = detail.decisions;
|
||||||
const decisionType = decision?.type as "approve" | "reject" | "edit";
|
if (incoming.length === 0) return;
|
||||||
|
const tcIds = pendingInterrupt.bundleToolCallIds;
|
||||||
|
const N = tcIds.length;
|
||||||
|
|
||||||
// Fan a single click out to N decisions when the backend bundled
|
// Build a per-card decision map. Bundle path: one decision per
|
||||||
// N tool calls into one HITLRequest (one Approve/Reject covers
|
// action_request in order. Legacy single-click on a multi-card
|
||||||
// the whole batch until per-card decisions land).
|
// interrupt: replay the last decision across the bundle.
|
||||||
const interruptData = pendingInterrupt.interruptData as
|
const byTcId = new Map<string, (typeof incoming)[number]>();
|
||||||
| { action_requests?: unknown[] }
|
if (incoming.length === N) {
|
||||||
| undefined;
|
for (let i = 0; i < N; i++) byTcId.set(tcIds[i], incoming[i]);
|
||||||
const expectedCount = Array.isArray(interruptData?.action_requests)
|
} else {
|
||||||
? interruptData.action_requests.length
|
const fallback = incoming[incoming.length - 1];
|
||||||
: detail.decisions.length;
|
for (const tcId of tcIds) byTcId.set(tcId, fallback);
|
||||||
const submittedDecisions =
|
}
|
||||||
detail.decisions.length >= expectedCount || expectedCount <= 1
|
const submittedDecisions = tcIds.map((id) => byTcId.get(id)!);
|
||||||
? detail.decisions
|
|
||||||
: [
|
|
||||||
...detail.decisions,
|
|
||||||
...Array.from({ length: expectedCount - detail.decisions.length }, () => ({
|
|
||||||
...detail.decisions[detail.decisions.length - 1],
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) => {
|
prev.map((m) => {
|
||||||
if (m.id !== pendingInterrupt.assistantMsgId) return m;
|
if (m.id !== pendingInterrupt.assistantMsgId) return m;
|
||||||
const parts = m.content as unknown as Array<Record<string, unknown>>;
|
const parts = m.content as unknown as Array<Record<string, unknown>>;
|
||||||
const newContent = parts.map((part) => {
|
const newContent = parts.map((part) => {
|
||||||
if (
|
const tcId = part.toolCallId as string | undefined;
|
||||||
part.type === "tool-call" &&
|
const d = tcId ? byTcId.get(tcId) : undefined;
|
||||||
typeof part.result === "object" &&
|
if (!d || part.type !== "tool-call") return part;
|
||||||
part.result !== null &&
|
if (typeof part.result !== "object" || part.result === null) return part;
|
||||||
"__interrupt__" in part.result
|
if (!("__interrupt__" in (part.result as Record<string, unknown>))) return part;
|
||||||
) {
|
const decided = d.type as "approve" | "reject" | "edit";
|
||||||
// For edit decisions, also update the displayed args
|
if (decided === "edit" && d.edited_action) {
|
||||||
if (decisionType === "edit" && decision.edited_action) {
|
|
||||||
return {
|
return {
|
||||||
...part,
|
...part,
|
||||||
args: decision.edited_action.args, // Update displayed args
|
args: d.edited_action.args,
|
||||||
// Sync argsText so the rendered card shows
|
// Sync argsText so the card renders the edited
|
||||||
// the edited inputs — assistant-ui prefers
|
// inputs (assistant-ui prefers it over JSON.stringify).
|
||||||
// caller-supplied argsText over
|
argsText: JSON.stringify(d.edited_action.args, null, 2),
|
||||||
// JSON.stringify(args).
|
|
||||||
argsText: JSON.stringify(decision.edited_action.args, null, 2),
|
|
||||||
result: {
|
result: {
|
||||||
...(part.result as Record<string, unknown>),
|
...(part.result as Record<string, unknown>),
|
||||||
__decided__: decisionType,
|
__decided__: decided,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1592,22 +1619,70 @@ export default function NewChatPage() {
|
||||||
...part,
|
...part,
|
||||||
result: {
|
result: {
|
||||||
...(part.result as Record<string, unknown>),
|
...(part.result as Record<string, unknown>),
|
||||||
__decided__: decisionType,
|
__decided__: decided,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
|
||||||
return part;
|
|
||||||
});
|
});
|
||||||
return { ...m, content: newContent as unknown as ThreadMessageLike["content"] };
|
return { ...m, content: newContent as unknown as ThreadMessageLike["content"] };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
handleResume(submittedDecisions);
|
handleResume(submittedDecisions);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
window.addEventListener("hitl-decision", handler);
|
window.addEventListener("hitl-decision", handler);
|
||||||
return () => window.removeEventListener("hitl-decision", handler);
|
return () => window.removeEventListener("hitl-decision", handler);
|
||||||
}, [handleResume, pendingInterrupt]);
|
}, [handleResume, pendingInterrupt]);
|
||||||
|
|
||||||
|
// Mirror staged bundle decisions onto the cards visually so prev/next nav
|
||||||
|
// reflects past choices instead of re-prompting. Submit's ``hitl-decision``
|
||||||
|
// handler still runs the actual resume.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail as {
|
||||||
|
toolCallId: string;
|
||||||
|
decision: {
|
||||||
|
type: string;
|
||||||
|
message?: string;
|
||||||
|
edited_action?: { name: string; args: Record<string, unknown> };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (!detail?.toolCallId || !detail?.decision || !pendingInterrupt) return;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) => {
|
||||||
|
if (m.id !== pendingInterrupt.assistantMsgId) return m;
|
||||||
|
const parts = m.content as unknown as Array<Record<string, unknown>>;
|
||||||
|
const newContent = parts.map((part) => {
|
||||||
|
if (part.toolCallId !== detail.toolCallId) return part;
|
||||||
|
if (part.type !== "tool-call") return part;
|
||||||
|
if (typeof part.result !== "object" || part.result === null) return part;
|
||||||
|
if (!("__interrupt__" in (part.result as Record<string, unknown>))) return part;
|
||||||
|
const decided = detail.decision.type as "approve" | "reject" | "edit";
|
||||||
|
if (decided === "edit" && detail.decision.edited_action) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
args: detail.decision.edited_action.args,
|
||||||
|
argsText: JSON.stringify(detail.decision.edited_action.args, null, 2),
|
||||||
|
result: {
|
||||||
|
...(part.result as Record<string, unknown>),
|
||||||
|
__decided__: decided,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
result: {
|
||||||
|
...(part.result as Record<string, unknown>),
|
||||||
|
__decided__: decided,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { ...m, content: newContent as unknown as ThreadMessageLike["content"] };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
window.addEventListener("hitl-stage", handler);
|
||||||
|
return () => window.removeEventListener("hitl-stage", handler);
|
||||||
|
}, [pendingInterrupt]);
|
||||||
|
|
||||||
// Convert message (pass through since already in correct format)
|
// Convert message (pass through since already in correct format)
|
||||||
const convertMessage = useCallback(
|
const convertMessage = useCallback(
|
||||||
(message: ThreadMessageLike): ThreadMessageLike => message,
|
(message: ThreadMessageLike): ThreadMessageLike => message,
|
||||||
|
|
@ -2151,6 +2226,12 @@ export default function NewChatPage() {
|
||||||
[handleRegenerate, messages, agentActionItems]
|
[handleRegenerate, messages, agentActionItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleBundleSubmit = useCallback<BundleSubmit>((orderedDecisions) => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("hitl-decision", { detail: { decisions: orderedDecisions } })
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleEditDialogChoice = useCallback(
|
const handleEditDialogChoice = useCallback(
|
||||||
async (choice: EditMessageDialogChoice) => {
|
async (choice: EditMessageDialogChoice) => {
|
||||||
const pending = editDialogState;
|
const pending = editDialogState;
|
||||||
|
|
@ -2220,6 +2301,10 @@ export default function NewChatPage() {
|
||||||
<AssistantRuntimeProvider runtime={runtime}>
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
<ThinkingStepsDataUI />
|
<ThinkingStepsDataUI />
|
||||||
<StepSeparatorDataUI />
|
<StepSeparatorDataUI />
|
||||||
|
<HitlBundleProvider
|
||||||
|
toolCallIds={pendingInterrupt?.bundleToolCallIds ?? null}
|
||||||
|
onSubmit={handleBundleSubmit}
|
||||||
|
>
|
||||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
<Thread />
|
<Thread />
|
||||||
|
|
@ -2228,6 +2313,7 @@ export default function NewChatPage() {
|
||||||
<MobileEditorPanel />
|
<MobileEditorPanel />
|
||||||
<MobileHitlEditPanel />
|
<MobileHitlEditPanel />
|
||||||
</div>
|
</div>
|
||||||
|
</HitlBundleProvider>
|
||||||
<EditMessageDialog
|
<EditMessageDialog
|
||||||
open={editDialogState !== null}
|
open={editDialogState !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
|
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
|
||||||
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
|
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
|
||||||
|
import { withBundleStep } from "@/components/hitl-bundle-pager";
|
||||||
import type { SerializableCitation } from "@/components/tool-ui/citation";
|
import type { SerializableCitation } from "@/components/tool-ui/citation";
|
||||||
import {
|
import {
|
||||||
openSafeNavigationHref,
|
openSafeNavigationHref,
|
||||||
|
|
@ -484,6 +485,51 @@ const MessageInfoDropdown: FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wrap each tool-ui card with ``withBundleStep`` so multi-card HITL bundles
|
||||||
|
// page through them and stage decisions instead of firing one resume per card.
|
||||||
|
const TOOLS_BY_NAME = {
|
||||||
|
generate_report: withBundleStep(GenerateReportToolUI),
|
||||||
|
generate_resume: withBundleStep(GenerateResumeToolUI),
|
||||||
|
generate_podcast: withBundleStep(GeneratePodcastToolUI),
|
||||||
|
generate_video_presentation: withBundleStep(GenerateVideoPresentationToolUI),
|
||||||
|
display_image: withBundleStep(GenerateImageToolUI),
|
||||||
|
generate_image: withBundleStep(GenerateImageToolUI),
|
||||||
|
update_memory: withBundleStep(UpdateMemoryToolUI),
|
||||||
|
execute: withBundleStep(SandboxExecuteToolUI),
|
||||||
|
execute_code: withBundleStep(SandboxExecuteToolUI),
|
||||||
|
create_notion_page: withBundleStep(CreateNotionPageToolUI),
|
||||||
|
update_notion_page: withBundleStep(UpdateNotionPageToolUI),
|
||||||
|
delete_notion_page: withBundleStep(DeleteNotionPageToolUI),
|
||||||
|
create_linear_issue: withBundleStep(CreateLinearIssueToolUI),
|
||||||
|
update_linear_issue: withBundleStep(UpdateLinearIssueToolUI),
|
||||||
|
delete_linear_issue: withBundleStep(DeleteLinearIssueToolUI),
|
||||||
|
create_google_drive_file: withBundleStep(CreateGoogleDriveFileToolUI),
|
||||||
|
delete_google_drive_file: withBundleStep(DeleteGoogleDriveFileToolUI),
|
||||||
|
create_onedrive_file: withBundleStep(CreateOneDriveFileToolUI),
|
||||||
|
delete_onedrive_file: withBundleStep(DeleteOneDriveFileToolUI),
|
||||||
|
create_dropbox_file: withBundleStep(CreateDropboxFileToolUI),
|
||||||
|
delete_dropbox_file: withBundleStep(DeleteDropboxFileToolUI),
|
||||||
|
create_calendar_event: withBundleStep(CreateCalendarEventToolUI),
|
||||||
|
update_calendar_event: withBundleStep(UpdateCalendarEventToolUI),
|
||||||
|
delete_calendar_event: withBundleStep(DeleteCalendarEventToolUI),
|
||||||
|
create_gmail_draft: withBundleStep(CreateGmailDraftToolUI),
|
||||||
|
update_gmail_draft: withBundleStep(UpdateGmailDraftToolUI),
|
||||||
|
send_gmail_email: withBundleStep(SendGmailEmailToolUI),
|
||||||
|
trash_gmail_email: withBundleStep(TrashGmailEmailToolUI),
|
||||||
|
create_jira_issue: withBundleStep(CreateJiraIssueToolUI),
|
||||||
|
update_jira_issue: withBundleStep(UpdateJiraIssueToolUI),
|
||||||
|
delete_jira_issue: withBundleStep(DeleteJiraIssueToolUI),
|
||||||
|
create_confluence_page: withBundleStep(CreateConfluencePageToolUI),
|
||||||
|
update_confluence_page: withBundleStep(UpdateConfluencePageToolUI),
|
||||||
|
delete_confluence_page: withBundleStep(DeleteConfluencePageToolUI),
|
||||||
|
web_search: () => null,
|
||||||
|
link_preview: () => null,
|
||||||
|
multi_link_preview: () => null,
|
||||||
|
scrape_webpage: () => null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const TOOLS_FALLBACK = withBundleStep(ToolFallback);
|
||||||
|
|
||||||
const AssistantMessageInner: FC = () => {
|
const AssistantMessageInner: FC = () => {
|
||||||
const isMobile = !useMediaQuery("(min-width: 768px)");
|
const isMobile = !useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
|
|
@ -495,47 +541,8 @@ const AssistantMessageInner: FC = () => {
|
||||||
Text: MarkdownText,
|
Text: MarkdownText,
|
||||||
Reasoning: ReasoningMessagePart,
|
Reasoning: ReasoningMessagePart,
|
||||||
tools: {
|
tools: {
|
||||||
by_name: {
|
by_name: TOOLS_BY_NAME,
|
||||||
generate_report: GenerateReportToolUI,
|
Fallback: TOOLS_FALLBACK,
|
||||||
generate_resume: GenerateResumeToolUI,
|
|
||||||
generate_podcast: GeneratePodcastToolUI,
|
|
||||||
generate_video_presentation: GenerateVideoPresentationToolUI,
|
|
||||||
display_image: GenerateImageToolUI,
|
|
||||||
generate_image: GenerateImageToolUI,
|
|
||||||
update_memory: UpdateMemoryToolUI,
|
|
||||||
execute: SandboxExecuteToolUI,
|
|
||||||
execute_code: SandboxExecuteToolUI,
|
|
||||||
create_notion_page: CreateNotionPageToolUI,
|
|
||||||
update_notion_page: UpdateNotionPageToolUI,
|
|
||||||
delete_notion_page: DeleteNotionPageToolUI,
|
|
||||||
create_linear_issue: CreateLinearIssueToolUI,
|
|
||||||
update_linear_issue: UpdateLinearIssueToolUI,
|
|
||||||
delete_linear_issue: DeleteLinearIssueToolUI,
|
|
||||||
create_google_drive_file: CreateGoogleDriveFileToolUI,
|
|
||||||
delete_google_drive_file: DeleteGoogleDriveFileToolUI,
|
|
||||||
create_onedrive_file: CreateOneDriveFileToolUI,
|
|
||||||
delete_onedrive_file: DeleteOneDriveFileToolUI,
|
|
||||||
create_dropbox_file: CreateDropboxFileToolUI,
|
|
||||||
delete_dropbox_file: DeleteDropboxFileToolUI,
|
|
||||||
create_calendar_event: CreateCalendarEventToolUI,
|
|
||||||
update_calendar_event: UpdateCalendarEventToolUI,
|
|
||||||
delete_calendar_event: DeleteCalendarEventToolUI,
|
|
||||||
create_gmail_draft: CreateGmailDraftToolUI,
|
|
||||||
update_gmail_draft: UpdateGmailDraftToolUI,
|
|
||||||
send_gmail_email: SendGmailEmailToolUI,
|
|
||||||
trash_gmail_email: TrashGmailEmailToolUI,
|
|
||||||
create_jira_issue: CreateJiraIssueToolUI,
|
|
||||||
update_jira_issue: UpdateJiraIssueToolUI,
|
|
||||||
delete_jira_issue: DeleteJiraIssueToolUI,
|
|
||||||
create_confluence_page: CreateConfluencePageToolUI,
|
|
||||||
update_confluence_page: UpdateConfluencePageToolUI,
|
|
||||||
delete_confluence_page: DeleteConfluencePageToolUI,
|
|
||||||
web_search: () => null,
|
|
||||||
link_preview: () => null,
|
|
||||||
multi_link_preview: () => null,
|
|
||||||
scrape_webpage: () => null,
|
|
||||||
},
|
|
||||||
Fallback: ToolFallback,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
2
surfsense_web/components/hitl-bundle-pager/index.ts
Normal file
2
surfsense_web/components/hitl-bundle-pager/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { PagerChrome } from "./pager-chrome";
|
||||||
|
export { withBundleStep } from "./with-bundle-step";
|
||||||
61
surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx
Normal file
61
surfsense_web/components/hitl-bundle-pager/pager-chrome.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useHitlBundle } from "@/lib/hitl";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prev/next nav and Submit for the current step of an active HITL bundle.
|
||||||
|
* Submission is gated on every action_request having a staged decision.
|
||||||
|
*/
|
||||||
|
export function PagerChrome() {
|
||||||
|
const bundle = useHitlBundle();
|
||||||
|
if (!bundle) return null;
|
||||||
|
|
||||||
|
const total = bundle.toolCallIds.length;
|
||||||
|
const step = bundle.currentStep;
|
||||||
|
const allStaged = bundle.stagedCount === total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex items-center gap-2 rounded-md border border-border bg-muted/40 p-2 text-sm">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={bundle.prev}
|
||||||
|
disabled={step === 0}
|
||||||
|
aria-label="Previous approval"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{step + 1} / {total}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{bundle.stagedCount} of {total} decided
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={bundle.next}
|
||||||
|
disabled={step >= total - 1}
|
||||||
|
aria-label="Next approval"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={bundle.submit}
|
||||||
|
disabled={!allStaged}
|
||||||
|
title={allStaged ? "Submit decisions" : "Decide every action first"}
|
||||||
|
>
|
||||||
|
Submit decisions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
import { ToolCallIdProvider, useHitlBundle } from "@/lib/hitl";
|
||||||
|
import { PagerChrome } from "./pager-chrome";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a tool-ui card so that, when a multi-card HITL bundle is active:
|
||||||
|
* - cards belonging to the bundle but not the current step render ``null``;
|
||||||
|
* - the current-step card renders normally and is followed by ``PagerChrome``.
|
||||||
|
*
|
||||||
|
* Cards stay completely unchanged — the wrapper provides the
|
||||||
|
* ``ToolCallIdContext`` that ``useHitlDecision`` reads to stage decisions
|
||||||
|
* against the right ``toolCallId`` instead of firing the global event.
|
||||||
|
*/
|
||||||
|
export function withBundleStep<P extends ToolCallMessagePartProps<any, any>>(
|
||||||
|
Component: ComponentType<P>
|
||||||
|
): ComponentType<P> {
|
||||||
|
function BundleStepWrapped(props: P) {
|
||||||
|
const bundle = useHitlBundle();
|
||||||
|
const toolCallId = props.toolCallId;
|
||||||
|
const inBundle = bundle?.isInBundle(toolCallId) ?? false;
|
||||||
|
const isStep = bundle?.isCurrentStep(toolCallId) ?? false;
|
||||||
|
|
||||||
|
if (bundle && inBundle && !isStep) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolCallIdProvider toolCallId={toolCallId}>
|
||||||
|
<Component {...props} />
|
||||||
|
{bundle && isStep ? <PagerChrome /> : null}
|
||||||
|
</ToolCallIdProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
BundleStepWrapped.displayName = `withBundleStep(${Component.displayName ?? Component.name ?? "ToolUI"})`;
|
||||||
|
return BundleStepWrapped as ComponentType<P>;
|
||||||
|
}
|
||||||
153
surfsense_web/lib/hitl/bundle-context.tsx
Normal file
153
surfsense_web/lib/hitl/bundle-context.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activates only when ``toolCallIds`` has 2+ entries; single-card interrupts
|
||||||
|
* keep their direct ``window`` dispatch path so N=1 UX is unchanged.
|
||||||
|
*/
|
||||||
|
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("|");
|
||||||
|
|
||||||
|
// Derived-state-from-props: reset staging + step when the bundle changes.
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
// Mirror the staged decision onto the card immediately so prev/next
|
||||||
|
// nav doesn't re-show approve/reject buttons for already-decided cards.
|
||||||
|
// Submit's ``hitl-decision`` event re-applies these (no-op) and runs
|
||||||
|
// the actual resume.
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
|
export {
|
||||||
|
type BundleSubmit,
|
||||||
|
type HitlBundleAPI,
|
||||||
|
HitlBundleProvider,
|
||||||
|
ToolCallIdProvider,
|
||||||
|
useHitlBundle,
|
||||||
|
useToolCallIdContext,
|
||||||
|
} from "./bundle-context";
|
||||||
export type {
|
export type {
|
||||||
HitlDecision,
|
HitlDecision,
|
||||||
InterruptActionRequest,
|
InterruptActionRequest,
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,31 @@
|
||||||
/**
|
/**
|
||||||
* Shared hook for dispatching HITL decisions.
|
* Shared hook for dispatching HITL decisions.
|
||||||
*
|
*
|
||||||
* All tool-ui components that handle approve/reject/edit should use this
|
* Tool-ui cards always call ``dispatch([decision])``. When a multi-card bundle
|
||||||
* instead of manually constructing `CustomEvent("hitl-decision", ...)`.
|
* is active (``HitlBundleProvider``), the dispatch is intercepted and staged
|
||||||
|
* against this card's ``toolCallId`` so the orchestrator can submit one
|
||||||
|
* ordered N-decision payload. With no bundle active (N=1 path), it falls back
|
||||||
|
* to the legacy ``window`` event the host listens for in ``page.tsx``.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useHitlBundle, useToolCallIdContext } from "./bundle-context";
|
||||||
import type { HitlDecision } from "./types";
|
import type { HitlDecision } from "./types";
|
||||||
|
|
||||||
export function useHitlDecision() {
|
export function useHitlDecision() {
|
||||||
const dispatch = useCallback((decisions: HitlDecision[]) => {
|
const bundle = useHitlBundle();
|
||||||
|
const toolCallId = useToolCallIdContext();
|
||||||
|
|
||||||
|
const dispatch = useCallback(
|
||||||
|
(decisions: HitlDecision[]) => {
|
||||||
|
if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) {
|
||||||
|
bundle.stage(toolCallId, decisions[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
|
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
|
||||||
}, []);
|
},
|
||||||
|
[bundle, toolCallId]
|
||||||
|
);
|
||||||
|
|
||||||
return { dispatch };
|
return { dispatch };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue