mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +02:00
feat: improved agent streaming
This commit is contained in:
parent
afb4b09cde
commit
c110f5b955
60 changed files with 8068 additions and 303 deletions
|
|
@ -33,6 +33,8 @@ import {
|
|||
useAllCitationMetadata,
|
||||
} from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
|
||||
import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button";
|
||||
import { useTokenUsage } from "@/components/assistant-ui/token-usage-context";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
|
|
@ -491,6 +493,7 @@ const AssistantMessageInner: FC = () => {
|
|||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
Reasoning: ReasoningMessagePart,
|
||||
tools: {
|
||||
by_name: {
|
||||
generate_report: GenerateReportToolUI,
|
||||
|
|
@ -699,6 +702,13 @@ const AssistantActionBar: FC = () => {
|
|||
const isLast = useAuiState((s) => s.message.isLast);
|
||||
const aui = useAui();
|
||||
const api = useElectronAPI();
|
||||
// Surface the persisted ``chat_turn_id`` so the per-turn revert
|
||||
// affordance can scope to just this message's actions. Streamed
|
||||
// turns get their id once the assistant message is hydrated/finalised.
|
||||
const chatTurnId = useAuiState(({ message }) => {
|
||||
const meta = message?.metadata as { custom?: { chatTurnId?: string | null } } | undefined;
|
||||
return meta?.custom?.chatTurnId ?? null;
|
||||
});
|
||||
|
||||
const isQuickAssist = !!api?.replaceText && IS_QUICK_ASSIST_WINDOW;
|
||||
|
||||
|
|
@ -743,6 +753,9 @@ const AssistantActionBar: FC = () => {
|
|||
</TooltipIconButton>
|
||||
)}
|
||||
<MessageInfoDropdown />
|
||||
<div className="ml-auto">
|
||||
<RevertTurnButton chatTurnId={chatTurnId} />
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
106
surfsense_web/components/assistant-ui/edit-message-dialog.tsx
Normal file
106
surfsense_web/components/assistant-ui/edit-message-dialog.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Confirmation dialog shown when the user edits a message that has
|
||||
* reversible downstream actions. Three buttons:
|
||||
*
|
||||
* • "Revert all & resubmit" — POST regenerate with revert_actions=true
|
||||
* • "Continue without revert" — POST regenerate with revert_actions=false
|
||||
* • "Cancel" — abort the edit entirely
|
||||
*
|
||||
* The dialog is auto-skipped when zero reversible downstream actions
|
||||
* exist (the caller checks first via ``downstreamReversibleCount``).
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type EditMessageDialogChoice = "revert" | "continue" | "cancel";
|
||||
|
||||
export interface EditMessageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
downstreamReversibleCount: number;
|
||||
downstreamTotalCount: number;
|
||||
onChoose: (choice: EditMessageDialogChoice) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function EditMessageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
downstreamReversibleCount,
|
||||
downstreamTotalCount,
|
||||
onChoose,
|
||||
}: EditMessageDialogProps) {
|
||||
const [busy, setBusy] = useState<EditMessageDialogChoice | null>(null);
|
||||
|
||||
// The parent's ``handleEditDialogChoice`` calls
|
||||
// ``setEditDialogState(null)`` BEFORE awaiting ``handleRegenerate``.
|
||||
// That collapses the dialog (Radix unmounts it) while ``onChoose``
|
||||
// is still awaiting the long-running stream. Without this guard,
|
||||
// the ``finally { setBusy(null) }`` below ran after unmount and
|
||||
// produced a "state update on unmounted component" dev warning.
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handle = async (choice: EditMessageDialogChoice) => {
|
||||
setBusy(choice);
|
||||
try {
|
||||
await onChoose(choice);
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Edit this message?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This edit drops {downstreamTotalCount} downstream message
|
||||
{downstreamTotalCount === 1 ? "" : "s"} from the thread. {downstreamReversibleCount}{" "}
|
||||
action
|
||||
{downstreamReversibleCount === 1 ? "" : "s"} (e.g. file writes, connector changes) can
|
||||
be rolled back. Pick how to handle them before regenerating.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button variant="default" disabled={busy !== null} onClick={() => handle("revert")}>
|
||||
{busy === "revert"
|
||||
? "Reverting & resubmitting…"
|
||||
: `Revert ${downstreamReversibleCount} action${
|
||||
downstreamReversibleCount === 1 ? "" : "s"
|
||||
} & resubmit`}
|
||||
</Button>
|
||||
<Button variant="outline" disabled={busy !== null} onClick={() => handle("continue")}>
|
||||
{busy === "continue" ? "Resubmitting…" : "Continue without reverting"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter className="sm:justify-start">
|
||||
<AlertDialogCancel disabled={busy !== null} onClick={() => handle("cancel")}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import type { ReasoningMessagePartComponent } from "@assistant-ui/react";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Renders the structured `reasoning` part emitted by the backend's
|
||||
* stream-parity v2 path (A1).
|
||||
*
|
||||
* Behaviour mirrors the existing `ThinkingStepsDisplay`:
|
||||
* - collapsed by default;
|
||||
* - auto-expanded while the part is still `running`;
|
||||
* - auto-collapsed once status flips to `complete`.
|
||||
*
|
||||
* The component is registered via the `Reasoning` slot on
|
||||
* `MessagePrimitive.Parts` in `assistant-message.tsx` so it lives at the
|
||||
* exact ordinal position of the reasoning block in the message content
|
||||
* array (i.e. above the assistant text that follows it).
|
||||
*/
|
||||
export const ReasoningMessagePart: ReasoningMessagePartComponent = ({ text, status }) => {
|
||||
const isRunning = status?.type === "running";
|
||||
const [isOpen, setIsOpen] = useState(() => isRunning);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
setIsOpen(true);
|
||||
} else if (status?.type === "complete") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [isRunning, status?.type]);
|
||||
|
||||
const headerLabel = useMemo(() => {
|
||||
if (isRunning) return "Thinking";
|
||||
if (status?.type === "incomplete") return "Thinking interrupted";
|
||||
return "Thought";
|
||||
}, [isRunning, status?.type]);
|
||||
|
||||
if (!text || text.length === 0) {
|
||||
if (!isRunning) return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
|
||||
<div className="rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{isRunning ? (
|
||||
<TextShimmerLoader text={headerLabel} size="sm" />
|
||||
) : (
|
||||
<span>{headerLabel}</span>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-2 border-l border-muted-foreground/30 pl-3 text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap wrap-break-word">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
232
surfsense_web/components/assistant-ui/revert-turn-button.tsx
Normal file
232
surfsense_web/components/assistant-ui/revert-turn-button.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* "Revert turn" button rendered at the bottom of every completed
|
||||
* assistant turn that has at least one reversible action.
|
||||
*
|
||||
* The button reads the action map keyed by ``chat_turn_id`` from the
|
||||
* SSE side-channel (``data-action-log`` events). It shows a confirmation
|
||||
* dialog summarising "N reversible / M total" and, on confirm, calls
|
||||
* ``POST /threads/{id}/revert-turn/{chat_turn_id}``.
|
||||
*
|
||||
* The route returns a per-action result list and never collapses the
|
||||
* batch into a 4xx — so we render any failed/not_reversible rows inline
|
||||
* with their messages.
|
||||
*/
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import { CheckIcon, RotateCcw, XCircleIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type AgentActionLite,
|
||||
agentActionsByChatTurnIdAtom,
|
||||
markAgentActionsRevertedBatchAtom,
|
||||
} from "@/atoms/chat/agent-actions.atom";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
agentActionsApiService,
|
||||
type RevertTurnActionResult,
|
||||
} from "@/lib/apis/agent-actions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RevertTurnButtonProps {
|
||||
chatTurnId: string | null | undefined;
|
||||
}
|
||||
|
||||
function formatToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// Empty-array sentinel so the per-turn ``selectAtom`` slice returns a
|
||||
// stable reference when the turn has no recorded actions yet. Without
|
||||
// this every render allocates a fresh ``[]`` and Jotai's
|
||||
// equality check would re-render the button on unrelated turn updates.
|
||||
const EMPTY_ACTIONS: readonly AgentActionLite[] = Object.freeze([]);
|
||||
|
||||
export function RevertTurnButton({ chatTurnId }: RevertTurnButtonProps) {
|
||||
const session = useAtomValue(chatSessionStateAtom);
|
||||
const markRevertedBatch = useSetAtom(markAgentActionsRevertedBatchAtom);
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [resultsOpen, setResultsOpen] = useState(false);
|
||||
const [results, setResults] = useState<RevertTurnActionResult[]>([]);
|
||||
|
||||
// Subscribe ONLY to the slice of the global action map that belongs
|
||||
// to ``chatTurnId``. Previously the button read the whole
|
||||
// ``agentActionsByChatTurnIdAtom``, which meant every action
|
||||
// upsert (one per tool call) re-rendered every Revert button on
|
||||
// the page. With ``selectAtom`` we re-render only when our turn's
|
||||
// list reference changes — and the upsert/mark atoms produce a
|
||||
// fresh list reference for the affected turn only.
|
||||
const sliceAtom = useMemo(
|
||||
() =>
|
||||
selectAtom(
|
||||
agentActionsByChatTurnIdAtom,
|
||||
(turnIndex) => (chatTurnId ? turnIndex.get(chatTurnId) : undefined) ?? EMPTY_ACTIONS
|
||||
),
|
||||
[chatTurnId]
|
||||
);
|
||||
const actions = useAtomValue(sliceAtom);
|
||||
|
||||
const reversibleCount = useMemo(
|
||||
() =>
|
||||
actions.filter(
|
||||
(a) => a.reversible && a.revertedByActionId === null && !a.isRevertAction && !a.error
|
||||
).length,
|
||||
[actions]
|
||||
);
|
||||
const totalCount = useMemo(() => actions.filter((a) => !a.isRevertAction).length, [actions]);
|
||||
|
||||
if (!chatTurnId) return null;
|
||||
if (reversibleCount === 0) return null;
|
||||
const threadId = session?.threadId;
|
||||
if (!threadId) return null;
|
||||
|
||||
const handleRevertTurn = async () => {
|
||||
setIsReverting(true);
|
||||
try {
|
||||
const response = await agentActionsApiService.revertTurn(threadId, chatTurnId);
|
||||
setResults(response.results);
|
||||
const revertedEntries = response.results
|
||||
.filter((r) => r.status === "reverted" || r.status === "already_reverted")
|
||||
.map((r) => ({ id: r.action_id, newActionId: r.new_action_id ?? null }));
|
||||
if (revertedEntries.length > 0) {
|
||||
markRevertedBatch({ entries: revertedEntries });
|
||||
}
|
||||
if (response.status === "ok") {
|
||||
toast.success(
|
||||
response.reverted === 1 ? "Reverted 1 action." : `Reverted ${response.reverted} actions.`
|
||||
);
|
||||
} else {
|
||||
// Every "not undone" bucket counts as a failure for the
|
||||
// user-facing summary. ``skipped`` rows are batch
|
||||
// artefacts (revert rows themselves) and intentionally
|
||||
// excluded from the failure tally.
|
||||
const failureCount =
|
||||
response.failed + response.not_reversible + (response.permission_denied ?? 0);
|
||||
toast.warning(
|
||||
`Reverted ${response.reverted} of ${response.total}. ${failureCount} could not be undone.`
|
||||
);
|
||||
setResultsOpen(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AppError && err.status === 503) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
err instanceof AppError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to revert turn.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsReverting(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground gap-1.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
<span>Revert turn</span>
|
||||
<span className="text-xs tabular-nums opacity-70">
|
||||
{reversibleCount}/{totalCount}
|
||||
</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revert this turn?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will undo {reversibleCount} of {totalCount} action
|
||||
{totalCount === 1 ? "" : "s"} from this turn in reverse order. The chat history and
|
||||
any read-only actions are preserved. Some rows may not be reversible — partial success
|
||||
is normal.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRevertTurn();
|
||||
}}
|
||||
disabled={isReverting}
|
||||
>
|
||||
{isReverting ? "Reverting…" : "Revert turn"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={resultsOpen} onOpenChange={setResultsOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revert results</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Some actions could not be reverted. Review per-row outcomes below.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<ul className="max-h-72 overflow-y-auto space-y-2 text-sm">
|
||||
{results.map((r) => (
|
||||
<RevertResultRow key={r.action_id} result={r} />
|
||||
))}
|
||||
</ul>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => setResultsOpen(false)}>Close</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RevertResultRow({ result }: { result: RevertTurnActionResult }) {
|
||||
const isOk = result.status === "reverted" || result.status === "already_reverted";
|
||||
const Icon = isOk ? CheckIcon : XCircleIcon;
|
||||
return (
|
||||
<li className="flex items-start gap-2 rounded-md border bg-muted/30 px-3 py-2">
|
||||
<Icon
|
||||
className={cn("size-4 mt-0.5 shrink-0", isOk ? "text-emerald-500" : "text-destructive")}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">
|
||||
{formatToolName(result.tool_name)}{" "}
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
{result.status.replace(/_/g, " ")}
|
||||
</span>
|
||||
</p>
|
||||
{(result.message || result.error) && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{result.error ?? result.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
27
surfsense_web/components/assistant-ui/step-separator.tsx
Normal file
27
surfsense_web/components/assistant-ui/step-separator.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantDataUI } from "@assistant-ui/react";
|
||||
|
||||
/**
|
||||
* Renders a thin horizontal divider between model steps within a single
|
||||
* assistant turn. The data part is pushed by `addStepSeparator` in
|
||||
* `streaming-state.ts` whenever a `start-step` SSE event arrives after
|
||||
* the message already has non-step content.
|
||||
*
|
||||
* Today the backend emits one `start-step` / `finish-step` pair per turn,
|
||||
* so most messages won't contain a separator. The renderer is wired up so
|
||||
* the planned per-model-step refactor (A2 follow-up) can light up without
|
||||
* touching the persistence path.
|
||||
*/
|
||||
function StepSeparatorDataRenderer() {
|
||||
return (
|
||||
<div className="mx-auto my-3 w-full max-w-(--thread-max-width) px-2">
|
||||
<div className="border-t border-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const StepSeparatorDataUI = makeAssistantDataUI({
|
||||
name: "step-separator",
|
||||
render: StepSeparatorDataRenderer,
|
||||
});
|
||||
|
|
@ -1,12 +1,33 @@
|
|||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, RotateCcw, XCircleIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
agentActionByToolCallIdAtom,
|
||||
markAgentActionRevertedAtom,
|
||||
} from "@/atoms/chat/agent-actions.atom";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import {
|
||||
DoomLoopApprovalToolUI,
|
||||
isDoomLoopInterrupt,
|
||||
} from "@/components/tool-ui/doom-loop-approval";
|
||||
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { isInterruptResult } from "@/lib/hitl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -14,7 +35,99 @@ function formatToolName(name: string): string {
|
|||
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Revert button rendered on a tool card when the matching
|
||||
* ``AgentActionLog`` row is reversible and hasn't been reverted yet.
|
||||
* Reads from the SSE side-channel atom keyed by the synthetic
|
||||
* ``toolCallId`` so it lights up even when ``GET /threads/.../actions``
|
||||
* is gated behind ``SURFSENSE_ENABLE_ACTION_LOG=False`` (503).
|
||||
*/
|
||||
function ToolCardRevertButton({ toolCallId }: { toolCallId: string }) {
|
||||
const session = useAtomValue(chatSessionStateAtom);
|
||||
const actionMap = useAtomValue(agentActionByToolCallIdAtom);
|
||||
const markReverted = useSetAtom(markAgentActionRevertedAtom);
|
||||
const action = actionMap.get(toolCallId);
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
if (!action) return null;
|
||||
if (!action.reversible) return null;
|
||||
if (action.revertedByActionId !== null) return null;
|
||||
if (action.isRevertAction) return null;
|
||||
if (action.error) return null;
|
||||
const threadId = session?.threadId;
|
||||
if (!threadId) return null;
|
||||
|
||||
const handleRevert = async () => {
|
||||
setIsReverting(true);
|
||||
try {
|
||||
const response = await agentActionsApiService.revert(threadId, action.id);
|
||||
markReverted({ id: action.id, newActionId: response.new_action_id ?? null });
|
||||
toast.success(response.message || "Action reverted.");
|
||||
} catch (err) {
|
||||
// 503 means revert is gated off on this deployment — hide the
|
||||
// button silently rather than nagging the user. Any other error
|
||||
// is surfaced as a toast so the operator can investigate.
|
||||
if (err instanceof AppError && err.status === 503) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
err instanceof AppError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to revert action.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsReverting(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Revert
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will undo <span className="font-medium">{formatToolName(action.toolName)}</span>{" "}
|
||||
and append a new audit entry. Chat history is preserved — only the tool's effects on
|
||||
your knowledge base or connectors will be reversed where possible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRevert();
|
||||
}}
|
||||
disabled={isReverting}
|
||||
>
|
||||
{isReverting ? "Reverting…" : "Revert"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
||||
toolCallId,
|
||||
toolName,
|
||||
argsText,
|
||||
result,
|
||||
|
|
@ -145,6 +258,9 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<ToolCardRevertButton toolCallId={toolCallId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue