mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 09:12:40 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/e2e-testing
This commit is contained in:
commit
e5ae97af55
292 changed files with 19479 additions and 379 deletions
|
|
@ -73,24 +73,24 @@ import { createStreamFlushHelpers } from "@/lib/chat/stream-flush";
|
|||
import {
|
||||
consumeSseEvents,
|
||||
hasPersistableContent,
|
||||
markInterruptsCompleted,
|
||||
processSharedStreamEvent,
|
||||
} from "@/lib/chat/stream-pipeline";
|
||||
import {
|
||||
applyInterruptRequestToContentParts,
|
||||
applyTurnIdToAssistantMessageList,
|
||||
markInterruptDecisionOnContentParts,
|
||||
mergeChatTurnIdIntoMessage,
|
||||
mergeEditedInterruptAction,
|
||||
readStreamedChatTurnId,
|
||||
readStreamedMessageId,
|
||||
} from "@/lib/chat/stream-side-effects";
|
||||
import {
|
||||
addToolCall,
|
||||
buildContentForPersistence,
|
||||
buildContentForUI,
|
||||
type ContentPartsState,
|
||||
type FrameBatchedUpdater,
|
||||
type ThinkingStepData,
|
||||
type ToolUIGate,
|
||||
updateToolCall,
|
||||
} from "@/lib/chat/streaming-state";
|
||||
import {
|
||||
appendMessage,
|
||||
|
|
@ -107,6 +107,7 @@ import {
|
|||
type NewChatUserImagePayload,
|
||||
} from "@/lib/chat/user-turn-api-parts";
|
||||
import { NotFoundError } from "@/lib/error";
|
||||
import { type BundleSubmit, HitlBundleProvider } from "@/lib/hitl";
|
||||
import {
|
||||
trackChatBlocked,
|
||||
trackChatCreated,
|
||||
|
|
@ -138,6 +139,62 @@ const MobileReportPanel = dynamic(
|
|||
{ ssr: false }
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate a synthetic ``toolCallId`` for an action_request that has no
|
||||
* 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 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>,
|
||||
contentParts: Array<{
|
||||
type: string;
|
||||
toolName?: string;
|
||||
result?: unknown;
|
||||
}>,
|
||||
actionRequests: ReadonlyArray<{ name: string }>
|
||||
): Array<string | null> {
|
||||
const claimed = new Set<string>();
|
||||
const paired: Array<string | null> = [];
|
||||
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];
|
||||
if (!part || part.type !== "tool-call" || part.toolName !== action.name) continue;
|
||||
const result = part.result as Record<string, unknown> | undefined | null;
|
||||
if (result == null || (result.__interrupt__ === true && !result.__decided__)) {
|
||||
matched = tcId;
|
||||
claimed.add(tcId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
paired.push(matched);
|
||||
}
|
||||
return paired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema for mentioned document info (for type-safe parsing)
|
||||
*/
|
||||
|
|
@ -209,6 +266,7 @@ export default function NewChatPage() {
|
|||
threadId: number;
|
||||
assistantMsgId: string;
|
||||
interruptData: Record<string, unknown>;
|
||||
bundleToolCallIds: string[];
|
||||
} | null>(null);
|
||||
const toolsWithUI = TOOLS_WITH_UI_ALL;
|
||||
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
||||
|
|
@ -1068,7 +1126,39 @@ export default function NewChatPage() {
|
|||
case "data-interrupt-request": {
|
||||
wasInterrupted = true;
|
||||
const interruptData = parsed.data as Record<string, unknown>;
|
||||
applyInterruptRequestToContentParts(contentPartsState, toolsWithUI, interruptData);
|
||||
const actionRequests = (interruptData.action_requests ?? []) as Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
const paired = pairBundleToolCallIds(
|
||||
contentPartsState.toolCallIndices,
|
||||
contentPartsState.contentParts,
|
||||
actionRequests
|
||||
);
|
||||
const bundleToolCallIds: string[] = [];
|
||||
for (let i = 0; i < actionRequests.length; i++) {
|
||||
const action = actionRequests[i];
|
||||
let targetTcId = paired[i];
|
||||
if (!targetTcId) {
|
||||
targetTcId = freshSynthToolCallId(
|
||||
contentPartsState.toolCallIndices,
|
||||
action.name,
|
||||
i
|
||||
);
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
targetTcId,
|
||||
action.name,
|
||||
action.args,
|
||||
true
|
||||
);
|
||||
}
|
||||
updateToolCall(contentPartsState, targetTcId, {
|
||||
result: { __interrupt__: true, ...interruptData },
|
||||
});
|
||||
bundleToolCallIds.push(targetTcId);
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
|
|
@ -1081,6 +1171,7 @@ export default function NewChatPage() {
|
|||
threadId: currentThreadId,
|
||||
assistantMsgId,
|
||||
interruptData,
|
||||
bundleToolCallIds,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
|
@ -1127,10 +1218,7 @@ export default function NewChatPage() {
|
|||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === oldUserMsgId
|
||||
? mergeChatTurnIdIntoMessage(
|
||||
{ ...m, id: newUserMsgId },
|
||||
parsedMsg.turnId
|
||||
)
|
||||
? mergeChatTurnIdIntoMessage({ ...m, id: newUserMsgId }, parsedMsg.turnId)
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
|
@ -1172,10 +1260,7 @@ export default function NewChatPage() {
|
|||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === oldAssistantMsgId
|
||||
? mergeChatTurnIdIntoMessage(
|
||||
{ ...m, id: newAssistantMsgId },
|
||||
parsedMsg.turnId
|
||||
)
|
||||
? mergeChatTurnIdIntoMessage({ ...m, id: newAssistantMsgId }, parsedMsg.turnId)
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
|
@ -1342,13 +1427,38 @@ export default function NewChatPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// Merge edited args if present to fix race condition
|
||||
if (decisions.length > 0 && decisions[0].type === "edit") {
|
||||
mergeEditedInterruptAction(contentParts, decisions[0].edited_action);
|
||||
// Apply each decision to its own card by toolCallId so mixed
|
||||
// bundles (approve/edit/reject) and multi-edit bundles do not
|
||||
// collapse onto ``decisions[0]``. Cards outside the bundle are
|
||||
// untouched. Mirrors the host ``hitl-decision`` handler.
|
||||
const decisionByTcId = new Map<string, (typeof decisions)[number]>();
|
||||
const tcIds = pendingInterrupt.bundleToolCallIds;
|
||||
if (decisions.length === tcIds.length) {
|
||||
for (let i = 0; i < tcIds.length; i++) decisionByTcId.set(tcIds[i], decisions[i]);
|
||||
}
|
||||
if (decisionByTcId.size > 0) {
|
||||
for (const part of contentParts) {
|
||||
if (part.type !== "tool-call") continue;
|
||||
const tcId = part.toolCallId as string | undefined;
|
||||
const d = tcId ? decisionByTcId.get(tcId) : undefined;
|
||||
if (!d) continue;
|
||||
if (typeof part.result !== "object" || part.result === null) continue;
|
||||
if (!("__interrupt__" in (part.result as Record<string, unknown>))) continue;
|
||||
const decided = d.type as "approve" | "reject" | "edit";
|
||||
if (decided === "edit" && d.edited_action) {
|
||||
const mergedArgs = { ...part.args, ...d.edited_action.args };
|
||||
part.args = mergedArgs;
|
||||
// Sync argsText so the rendered card shows the
|
||||
// edited inputs (assistant-ui prefers it over
|
||||
// JSON.stringify(args)).
|
||||
part.argsText = JSON.stringify(mergedArgs, null, 2);
|
||||
}
|
||||
part.result = {
|
||||
...(part.result as Record<string, unknown>),
|
||||
__decided__: decided,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const decisionType = decisions[0]?.type as "approve" | "reject" | undefined;
|
||||
markInterruptDecisionOnContentParts(contentParts, decisionType);
|
||||
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
|
|
@ -1365,6 +1475,7 @@ export default function NewChatPage() {
|
|||
body: JSON.stringify({
|
||||
search_space_id: searchSpaceId,
|
||||
decisions,
|
||||
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
|
||||
filesystem_mode: selection.filesystem_mode,
|
||||
client_platform: selection.client_platform,
|
||||
local_filesystem_mounts: selection.local_filesystem_mounts,
|
||||
|
|
@ -1413,7 +1524,39 @@ export default function NewChatPage() {
|
|||
switch (parsed.type) {
|
||||
case "data-interrupt-request": {
|
||||
const interruptData = parsed.data as Record<string, unknown>;
|
||||
applyInterruptRequestToContentParts(contentPartsState, toolsWithUI, interruptData);
|
||||
const actionRequests = (interruptData.action_requests ?? []) as Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
const paired = pairBundleToolCallIds(
|
||||
contentPartsState.toolCallIndices,
|
||||
contentPartsState.contentParts,
|
||||
actionRequests
|
||||
);
|
||||
const bundleToolCallIds: string[] = [];
|
||||
for (let i = 0; i < actionRequests.length; i++) {
|
||||
const action = actionRequests[i];
|
||||
let targetTcId = paired[i];
|
||||
if (!targetTcId) {
|
||||
targetTcId = freshSynthToolCallId(
|
||||
contentPartsState.toolCallIndices,
|
||||
action.name,
|
||||
i
|
||||
);
|
||||
addToolCall(
|
||||
contentPartsState,
|
||||
toolsWithUI,
|
||||
targetTcId,
|
||||
action.name,
|
||||
action.args,
|
||||
true
|
||||
);
|
||||
}
|
||||
updateToolCall(contentPartsState, targetTcId, {
|
||||
result: { __interrupt__: true, ...interruptData },
|
||||
});
|
||||
bundleToolCallIds.push(targetTcId);
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
|
|
@ -1425,6 +1568,7 @@ export default function NewChatPage() {
|
|||
threadId: resumeThreadId,
|
||||
assistantMsgId,
|
||||
interruptData,
|
||||
bundleToolCallIds,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
@ -1470,10 +1614,7 @@ export default function NewChatPage() {
|
|||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === oldAssistantMsgId
|
||||
? mergeChatTurnIdIntoMessage(
|
||||
{ ...m, id: newAssistantMsgId },
|
||||
parsedMsg.turnId
|
||||
)
|
||||
? mergeChatTurnIdIntoMessage({ ...m, id: newAssistantMsgId }, parsedMsg.turnId)
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
|
@ -1510,6 +1651,7 @@ export default function NewChatPage() {
|
|||
messages,
|
||||
searchSpaceId,
|
||||
localFilesystemEnabled,
|
||||
disabledTools,
|
||||
queryClient,
|
||||
tokenUsageStore,
|
||||
fetchWithTurnCancellingRetry,
|
||||
|
|
@ -1526,57 +1668,119 @@ export default function NewChatPage() {
|
|||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}>;
|
||||
};
|
||||
if (detail?.decisions && pendingInterrupt) {
|
||||
const decision = detail.decisions[0];
|
||||
const decisionType = decision?.type as "approve" | "reject" | "edit";
|
||||
if (!detail?.decisions || !pendingInterrupt) return;
|
||||
const incoming = detail.decisions;
|
||||
if (incoming.length === 0) return;
|
||||
const tcIds = pendingInterrupt.bundleToolCallIds;
|
||||
const N = tcIds.length;
|
||||
|
||||
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.type === "tool-call" &&
|
||||
typeof part.result === "object" &&
|
||||
part.result !== null &&
|
||||
"__interrupt__" in part.result
|
||||
) {
|
||||
// For edit decisions, also update the displayed args
|
||||
if (decisionType === "edit" && decision.edited_action) {
|
||||
return {
|
||||
...part,
|
||||
args: decision.edited_action.args, // Update displayed args
|
||||
// Sync argsText so the rendered card shows
|
||||
// the edited inputs — assistant-ui prefers
|
||||
// caller-supplied argsText over
|
||||
// JSON.stringify(args).
|
||||
argsText: JSON.stringify(decision.edited_action.args, null, 2),
|
||||
result: {
|
||||
...(part.result as Record<string, unknown>),
|
||||
__decided__: decisionType,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...part,
|
||||
result: {
|
||||
...(part.result as Record<string, unknown>),
|
||||
__decided__: decisionType,
|
||||
},
|
||||
};
|
||||
}
|
||||
return part;
|
||||
});
|
||||
return { ...m, content: newContent as unknown as ThreadMessageLike["content"] };
|
||||
})
|
||||
// Bundles must submit exactly one decision per action_request.
|
||||
// Refuse rather than silently broadcast a single decision across
|
||||
// the bundle (would mis-apply rejects/edits and diverge from
|
||||
// what handleResume sends to /resume).
|
||||
if (N > 1 && incoming.length !== N) {
|
||||
toast.error(
|
||||
`Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
|
||||
);
|
||||
handleResume(detail.decisions);
|
||||
return;
|
||||
}
|
||||
|
||||
const byTcId = new Map<string, (typeof incoming)[number]>();
|
||||
for (let i = 0; i < tcIds.length; i++) byTcId.set(tcIds[i], incoming[i]);
|
||||
const submittedDecisions = tcIds.map((id) => byTcId.get(id)!);
|
||||
|
||||
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) => {
|
||||
const tcId = part.toolCallId as string | undefined;
|
||||
const d = tcId ? byTcId.get(tcId) : undefined;
|
||||
if (!d || 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 = d.type as "approve" | "reject" | "edit";
|
||||
if (decided === "edit" && d.edited_action) {
|
||||
return {
|
||||
...part,
|
||||
args: d.edited_action.args,
|
||||
// Sync argsText so the card renders the edited
|
||||
// inputs (assistant-ui prefers it over JSON.stringify).
|
||||
argsText: JSON.stringify(d.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"] };
|
||||
})
|
||||
);
|
||||
handleResume(submittedDecisions);
|
||||
};
|
||||
window.addEventListener("hitl-decision", handler);
|
||||
return () => window.removeEventListener("hitl-decision", handler);
|
||||
}, [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)
|
||||
const convertMessage = useCallback(
|
||||
(message: ThreadMessageLike): ThreadMessageLike => message,
|
||||
|
|
@ -1860,10 +2064,7 @@ export default function NewChatPage() {
|
|||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === oldUserMsgId
|
||||
? mergeChatTurnIdIntoMessage(
|
||||
{ ...m, id: newUserMsgId },
|
||||
parsedMsg.turnId
|
||||
)
|
||||
? mergeChatTurnIdIntoMessage({ ...m, id: newUserMsgId }, parsedMsg.turnId)
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
|
@ -1889,10 +2090,7 @@ export default function NewChatPage() {
|
|||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === oldAssistantMsgId
|
||||
? mergeChatTurnIdIntoMessage(
|
||||
{ ...m, id: newAssistantMsgId },
|
||||
parsedMsg.turnId
|
||||
)
|
||||
? mergeChatTurnIdIntoMessage({ ...m, id: newAssistantMsgId }, parsedMsg.turnId)
|
||||
: m
|
||||
)
|
||||
);
|
||||
|
|
@ -2081,6 +2279,12 @@ export default function NewChatPage() {
|
|||
[handleRegenerate, messages, agentActionItems]
|
||||
);
|
||||
|
||||
const handleBundleSubmit = useCallback<BundleSubmit>((orderedDecisions) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: orderedDecisions } })
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleEditDialogChoice = useCallback(
|
||||
async (choice: EditMessageDialogChoice) => {
|
||||
const pending = editDialogState;
|
||||
|
|
@ -2151,14 +2355,19 @@ export default function NewChatPage() {
|
|||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<ThinkingStepsDataUI />
|
||||
<StepSeparatorDataUI />
|
||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread />
|
||||
<HitlBundleProvider
|
||||
toolCallIds={pendingInterrupt?.bundleToolCallIds ?? null}
|
||||
onSubmit={handleBundleSubmit}
|
||||
>
|
||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread />
|
||||
</div>
|
||||
<MobileReportPanel />
|
||||
<MobileEditorPanel />
|
||||
<MobileHitlEditPanel />
|
||||
</div>
|
||||
<MobileReportPanel />
|
||||
<MobileEditorPanel />
|
||||
<MobileHitlEditPanel />
|
||||
</div>
|
||||
</HitlBundleProvider>
|
||||
<EditMessageDialog
|
||||
open={editDialogState !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -12,23 +13,133 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { FinalizeCheckoutResponse } from "@/contracts/types/stripe.types";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
|
||||
type FinalizeState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "completed"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "pending"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "still_pending"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "failed"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "no_session" };
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_ATTEMPTS = 15; // ~30s total before falling back to the still_pending state
|
||||
|
||||
export default function PurchaseSuccessPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const searchSpaceId = String(params.search_space_id ?? "");
|
||||
const sessionId = searchParams.get("session_id");
|
||||
|
||||
const [state, setState] = useState<FinalizeState>(
|
||||
sessionId ? { kind: "loading" } : { kind: "no_session" }
|
||||
);
|
||||
// Tracks active polling so component unmount cancels it
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
cancelledRef.current = false;
|
||||
|
||||
const poll = async (attempt: number): Promise<void> => {
|
||||
if (cancelledRef.current) return;
|
||||
try {
|
||||
const data = await stripeApiService.finalizeCheckout(sessionId);
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
if (data.status === "completed") {
|
||||
setState({ kind: "completed", data });
|
||||
return;
|
||||
}
|
||||
if (data.status === "failed") {
|
||||
setState({ kind: "failed", data });
|
||||
return;
|
||||
}
|
||||
|
||||
// Status is "pending" - either the user paid via async
|
||||
// payment method (Klarna, ACH) or webhook + finalize both
|
||||
// raced and lost. Keep polling up to MAX_POLL_ATTEMPTS,
|
||||
// then fall back to a friendlier message that explains
|
||||
// fulfilment may complete asynchronously.
|
||||
if (attempt < MAX_POLL_ATTEMPTS) {
|
||||
setState({ kind: "pending", data });
|
||||
setTimeout(() => poll(attempt + 1), POLL_INTERVAL_MS);
|
||||
} else {
|
||||
setState({ kind: "still_pending", data });
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return;
|
||||
const message = err instanceof Error ? err.message : "Unable to finalize checkout.";
|
||||
setState({ kind: "error", message });
|
||||
}
|
||||
};
|
||||
|
||||
void poll(1);
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
|
||||
<CardTitle className="text-2xl">Purchase complete</CardTitle>
|
||||
<CardDescription>Your purchase is being applied to your account now.</CardDescription>
|
||||
{state.kind === "loading" || state.kind === "pending" ? (
|
||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-primary" />
|
||||
) : state.kind === "completed" ? (
|
||||
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
|
||||
) : (
|
||||
<AlertCircle className="mx-auto h-10 w-10 text-amber-500" />
|
||||
)}
|
||||
<CardTitle className="text-2xl">
|
||||
{state.kind === "loading" && "Confirming payment…"}
|
||||
{state.kind === "pending" && "Processing your payment…"}
|
||||
{state.kind === "still_pending" && "Payment still processing"}
|
||||
{state.kind === "completed" && "Purchase complete"}
|
||||
{state.kind === "failed" && "Purchase failed"}
|
||||
{state.kind === "error" && "Couldn't confirm payment"}
|
||||
{state.kind === "no_session" && "Purchase complete"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{state.kind === "loading" && "We're verifying your payment with Stripe."}
|
||||
{state.kind === "pending" &&
|
||||
"Your bank is taking a moment to confirm. This usually takes 5–30 seconds."}
|
||||
{state.kind === "still_pending" &&
|
||||
"Your payment is still being processed by your bank. We'll apply your purchase as soon as it clears — usually within a few minutes. You can safely close this page."}
|
||||
{state.kind === "completed" &&
|
||||
(state.data.purchase_type === "page_packs"
|
||||
? `Added ${formatNumber(state.data.pages_granted ?? 0)} pages to your account.`
|
||||
: `Added ${formatCredit(state.data.premium_credit_micros_granted ?? 0)} of premium credit to your account.`)}
|
||||
{state.kind === "failed" &&
|
||||
"Stripe reported the checkout as failed or expired. Your card was not charged."}
|
||||
{state.kind === "error" &&
|
||||
"Don't worry — if your card was charged, your purchase will still apply within a minute or two."}
|
||||
{state.kind === "no_session" &&
|
||||
"Your purchase is being applied to your account."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your usage meters should refresh automatically in a moment.
|
||||
</p>
|
||||
{state.kind === "completed" && state.data.purchase_type === "page_packs" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
New balance: {formatNumber(state.data.pages_limit ?? 0)} total pages
|
||||
{typeof state.data.pages_used === "number"
|
||||
? ` (${formatNumber((state.data.pages_limit ?? 0) - state.data.pages_used)} remaining)`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
{state.kind === "completed" && state.data.purchase_type === "premium_tokens" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
New premium credit balance: {formatCredit(state.data.premium_credit_micros_limit ?? 0)}
|
||||
</p>
|
||||
)}
|
||||
{state.kind === "error" && (
|
||||
<p className="text-sm text-muted-foreground">{state.message}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button asChild className="w-full">
|
||||
|
|
@ -42,3 +153,16 @@ export default function PurchaseSuccessPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return new Intl.NumberFormat("en-US").format(n);
|
||||
}
|
||||
|
||||
function formatCredit(micros: number): string {
|
||||
const dollars = micros / 1_000_000;
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(dollars);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
|||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
|
||||
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 {
|
||||
openSafeNavigationHref,
|
||||
|
|
@ -502,6 +503,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 isMobile = !useMediaQuery("(min-width: 768px)");
|
||||
|
||||
|
|
@ -513,47 +559,8 @@ const AssistantMessageInner: FC = () => {
|
|||
Text: MarkdownText,
|
||||
Reasoning: ReasoningMessagePart,
|
||||
tools: {
|
||||
by_name: {
|
||||
generate_report: GenerateReportToolUI,
|
||||
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,
|
||||
by_name: TOOLS_BY_NAME,
|
||||
Fallback: TOOLS_FALLBACK,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
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>;
|
||||
}
|
||||
|
|
@ -73,6 +73,19 @@ export const getTokenPurchasesResponse = z.object({
|
|||
purchases: z.array(tokenPurchase),
|
||||
});
|
||||
|
||||
// Response from /stripe/finalize-checkout. Either page or token fields
|
||||
// are populated depending on purchase_type.
|
||||
export const finalizeCheckoutResponse = z.object({
|
||||
purchase_type: z.enum(["page_packs", "premium_tokens"]),
|
||||
status: pagePurchaseStatusEnum,
|
||||
pages_limit: z.number().nullable().optional(),
|
||||
pages_used: z.number().nullable().optional(),
|
||||
pages_granted: z.number().nullable().optional(),
|
||||
premium_credit_micros_limit: z.number().nullable().optional(),
|
||||
premium_credit_micros_used: z.number().nullable().optional(),
|
||||
premium_credit_micros_granted: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type PagePurchaseStatus = z.infer<typeof pagePurchaseStatusEnum>;
|
||||
export type CreateCheckoutSessionRequest = z.infer<typeof createCheckoutSessionRequest>;
|
||||
export type CreateCheckoutSessionResponse = z.infer<typeof createCheckoutSessionResponse>;
|
||||
|
|
@ -85,3 +98,4 @@ export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse
|
|||
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
|
||||
export type TokenPurchase = z.infer<typeof tokenPurchase>;
|
||||
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;
|
||||
export type FinalizeCheckoutResponse = z.infer<typeof finalizeCheckoutResponse>;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
type CreateTokenCheckoutSessionResponse,
|
||||
createCheckoutSessionResponse,
|
||||
createTokenCheckoutSessionResponse,
|
||||
type FinalizeCheckoutResponse,
|
||||
finalizeCheckoutResponse,
|
||||
type GetPagePurchasesResponse,
|
||||
type GetTokenPurchasesResponse,
|
||||
getPagePurchasesResponse,
|
||||
|
|
@ -54,6 +56,20 @@ class StripeApiService {
|
|||
getTokenPurchases = async (): Promise<GetTokenPurchasesResponse> => {
|
||||
return baseApiService.get("/api/v1/stripe/token-purchases", getTokenPurchasesResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronously fulfil a checkout session from the success page.
|
||||
*
|
||||
* Solves the race where the user lands on /purchase-success before
|
||||
* Stripe's checkout.session.completed webhook arrives. Idempotent —
|
||||
* safe to call concurrently with the webhook.
|
||||
*/
|
||||
finalizeCheckout = async (sessionId: string): Promise<FinalizeCheckoutResponse> => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/stripe/finalize-checkout?session_id=${encodeURIComponent(sessionId)}`,
|
||||
finalizeCheckoutResponse
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const stripeApiService = new StripeApiService();
|
||||
|
|
|
|||
|
|
@ -1,91 +1,4 @@
|
|||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||
import {
|
||||
addToolCall,
|
||||
type ContentPartsState,
|
||||
type ToolUIGate,
|
||||
updateToolCall,
|
||||
} from "@/lib/chat/streaming-state";
|
||||
|
||||
type InterruptActionRequest = {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type EditedInterruptAction = {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function readInterruptActions(interruptData: Record<string, unknown>): InterruptActionRequest[] {
|
||||
return (interruptData.action_requests ?? []) as InterruptActionRequest[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an interrupt request payload to tool-call parts. Existing tool cards
|
||||
* are updated in-place; missing ones are upserted so approval UI always shows.
|
||||
*/
|
||||
export function applyInterruptRequestToContentParts(
|
||||
contentPartsState: ContentPartsState,
|
||||
toolsWithUI: ToolUIGate,
|
||||
interruptData: Record<string, unknown>
|
||||
): void {
|
||||
const { contentParts, toolCallIndices } = contentPartsState;
|
||||
const actionRequests = readInterruptActions(interruptData);
|
||||
for (const action of actionRequests) {
|
||||
const existingEntry = Array.from(toolCallIndices.entries()).find(([, idx]) => {
|
||||
const part = contentParts[idx];
|
||||
return part?.type === "tool-call" && part.toolName === action.name;
|
||||
});
|
||||
|
||||
if (existingEntry) {
|
||||
updateToolCall(contentPartsState, existingEntry[0], {
|
||||
result: { __interrupt__: true, ...interruptData },
|
||||
});
|
||||
} else {
|
||||
const toolCallId = `interrupt-${action.name}`;
|
||||
addToolCall(contentPartsState, toolsWithUI, toolCallId, action.name, action.args, true);
|
||||
updateToolCall(contentPartsState, toolCallId, {
|
||||
result: { __interrupt__: true, ...interruptData },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeEditedInterruptAction(
|
||||
contentParts: ContentPartsState["contentParts"],
|
||||
editedAction: EditedInterruptAction | undefined
|
||||
): void {
|
||||
if (!editedAction) return;
|
||||
for (const part of contentParts) {
|
||||
if (part.type === "tool-call" && part.toolName === editedAction.name) {
|
||||
const mergedArgs = { ...part.args, ...editedAction.args };
|
||||
part.args = mergedArgs;
|
||||
// assistant-ui prefers argsText over JSON.stringify(args)
|
||||
part.argsText = JSON.stringify(mergedArgs, null, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markInterruptDecisionOnContentParts(
|
||||
contentParts: ContentPartsState["contentParts"],
|
||||
decisionType: "approve" | "reject" | undefined
|
||||
): void {
|
||||
if (!decisionType) return;
|
||||
for (const part of contentParts) {
|
||||
if (
|
||||
part.type === "tool-call" &&
|
||||
typeof part.result === "object" &&
|
||||
part.result !== null &&
|
||||
"__interrupt__" in (part.result as Record<string, unknown>)
|
||||
) {
|
||||
part.result = {
|
||||
...(part.result as Record<string, unknown>),
|
||||
__decided__: decisionType,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a streamed message is persisted, the backend returns the durable
|
||||
|
|
|
|||
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 {
|
||||
HitlDecision,
|
||||
InterruptActionRequest,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,41 @@
|
|||
/**
|
||||
* Shared hook for dispatching HITL decisions.
|
||||
*
|
||||
* All tool-ui components that handle approve/reject/edit should use this
|
||||
* instead of manually constructing `CustomEvent("hitl-decision", ...)`.
|
||||
* Tool-ui cards always call ``dispatch([decision])``. When a multi-card bundle
|
||||
* 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 { useHitlBundle, useToolCallIdContext } from "./bundle-context";
|
||||
import type { HitlDecision } from "./types";
|
||||
|
||||
export function useHitlDecision() {
|
||||
const dispatch = useCallback((decisions: HitlDecision[]) => {
|
||||
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
|
||||
}, []);
|
||||
const bundle = useHitlBundle();
|
||||
const toolCallId = useToolCallIdContext();
|
||||
|
||||
const dispatch = useCallback(
|
||||
(decisions: HitlDecision[]) => {
|
||||
if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) {
|
||||
if (decisions.length > 1 && process.env.NODE_ENV !== "production") {
|
||||
// Tool-ui cards stage one decision per call; a multi-decision
|
||||
// dispatch into an active bundle would silently drop tail entries.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[hitl] dispatch received %d decisions inside an active bundle; only [0] will be staged for %s",
|
||||
decisions.length,
|
||||
toolCallId
|
||||
);
|
||||
}
|
||||
bundle.stage(toolCallId, decisions[0]);
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
|
||||
},
|
||||
[bundle, toolCallId]
|
||||
);
|
||||
|
||||
return { dispatch };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "surfsense_web",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.22",
|
||||
"private": true,
|
||||
"description": "SurfSense Frontend",
|
||||
"scripts": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue