mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-03 21:02:40 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/split-auto-free-premium
This commit is contained in:
commit
872065f90d
15 changed files with 1857 additions and 545 deletions
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { Activity, RefreshCcw } from "lucide-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { actionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -17,15 +17,12 @@ import {
|
|||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import {
|
||||
agentActionsQueryKey,
|
||||
useAgentActionsQuery,
|
||||
} from "@/hooks/use-agent-actions-query";
|
||||
import { ActionLogItem } from "./action-log-item";
|
||||
|
||||
const ACTION_LOG_PAGE_SIZE = 50;
|
||||
|
||||
function actionLogQueryKey(threadId: number) {
|
||||
return ["agent-actions", threadId] as const;
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
|
|
@ -85,25 +82,17 @@ export function ActionLogSheet() {
|
|||
|
||||
const threadId = state.threadId;
|
||||
|
||||
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
|
||||
queryKey: threadId !== null ? actionLogQueryKey(threadId) : ["agent-actions", "none"],
|
||||
queryFn: () =>
|
||||
agentActionsApiService.listForThread(threadId as number, {
|
||||
page: 0,
|
||||
pageSize: ACTION_LOG_PAGE_SIZE,
|
||||
}),
|
||||
enabled: state.open && threadId !== null && actionLogEnabled,
|
||||
staleTime: 15 * 1000,
|
||||
});
|
||||
const { data, items, isLoading, isFetching, isError, error, refetch } = useAgentActionsQuery(
|
||||
threadId,
|
||||
{ enabled: state.open && actionLogEnabled }
|
||||
);
|
||||
|
||||
const handleRevertSuccess = useCallback(() => {
|
||||
if (threadId !== null) {
|
||||
queryClient.invalidateQueries({ queryKey: actionLogQueryKey(threadId) });
|
||||
queryClient.invalidateQueries({ queryKey: agentActionsQueryKey(threadId) });
|
||||
}
|
||||
}, [queryClient, threadId]);
|
||||
|
||||
const items = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
return (
|
||||
<Sheet open={state.open} onOpenChange={(open) => setState((s) => ({ ...s, open }))}>
|
||||
<SheetContent
|
||||
|
|
|
|||
|
|
@ -4,26 +4,22 @@
|
|||
* "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 button reads from the unified ``useAgentActionsQuery`` cache
|
||||
* (the SAME react-query cache the agent-actions sheet and the inline
|
||||
* Revert button consume) filtered by ``chat_turn_id``. 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 { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
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,
|
||||
|
|
@ -38,6 +34,10 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
|
||||
import {
|
||||
applyRevertTurnResultsToCache,
|
||||
useAgentActionsQuery,
|
||||
} from "@/hooks/use-agent-actions-query";
|
||||
import {
|
||||
agentActionsApiService,
|
||||
type RevertTurnActionResult,
|
||||
|
|
@ -49,49 +49,33 @@ interface RevertTurnButtonProps {
|
|||
chatTurnId: string | null | undefined;
|
||||
}
|
||||
|
||||
// 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 threadId = session?.threadId ?? null;
|
||||
const queryClient = useQueryClient();
|
||||
const { findByChatTurnId } = useAgentActionsQuery(threadId);
|
||||
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 actions = useMemo(() => findByChatTurnId(chatTurnId), [findByChatTurnId, chatTurnId]);
|
||||
|
||||
const reversibleCount = useMemo(
|
||||
() =>
|
||||
actions.filter(
|
||||
(a) => a.reversible && a.revertedByActionId === null && !a.isRevertAction && !a.error
|
||||
(a) =>
|
||||
a.reversible &&
|
||||
(a.reverted_by_action_id === null || a.reverted_by_action_id === undefined) &&
|
||||
!a.is_revert_action &&
|
||||
(a.error === null || a.error === undefined)
|
||||
).length,
|
||||
[actions]
|
||||
);
|
||||
const totalCount = useMemo(() => actions.filter((a) => !a.isRevertAction).length, [actions]);
|
||||
const totalCount = useMemo(() => actions.filter((a) => !a.is_revert_action).length, [actions]);
|
||||
|
||||
if (!chatTurnId) return null;
|
||||
if (reversibleCount === 0) return null;
|
||||
const threadId = session?.threadId;
|
||||
if (!threadId) return null;
|
||||
|
||||
const handleRevertTurn = async () => {
|
||||
|
|
@ -103,7 +87,7 @@ export function RevertTurnButton({ chatTurnId }: RevertTurnButtonProps) {
|
|||
.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 });
|
||||
applyRevertTurnResultsToCache(queryClient, threadId, revertedEntries);
|
||||
}
|
||||
if (response.status === "ok") {
|
||||
toast.success(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import type { ToolCallMessagePartComponent } from "@assistant-ui/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";
|
||||
type ToolCallMessagePartComponent,
|
||||
useAuiState,
|
||||
} from "@assistant-ui/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CheckIcon, ChevronDownIcon, RotateCcw, XCircleIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||
import {
|
||||
DoomLoopApprovalToolUI,
|
||||
|
|
@ -24,8 +24,17 @@ import {
|
|||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getToolDisplayName, getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
|
||||
import {
|
||||
markActionRevertedInCache,
|
||||
useAgentActionsQuery,
|
||||
} from "@/hooks/use-agent-actions-query";
|
||||
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { isInterruptResult } from "@/lib/hitl";
|
||||
|
|
@ -34,31 +43,128 @@ import { cn } from "@/lib/utils";
|
|||
/**
|
||||
* 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).
|
||||
*
|
||||
* Reads from the unified ``useAgentActionsQuery`` cache — the SAME
|
||||
* react-query cache the agent-actions sheet consumes. SSE events
|
||||
* (``data-action-log`` / ``data-action-log-updated``) and
|
||||
* ``POST /threads/{id}/revert/{id}`` responses both flow through the
|
||||
* cache via ``setQueryData`` helpers, so the card and the sheet stay
|
||||
* in lockstep on every code path: page reload, navigation, live
|
||||
* stream, post-stream reversibility flip, and explicit revert clicks.
|
||||
*
|
||||
* Match key (in priority order):
|
||||
* 1. ``a.tool_call_id === toolCallId`` — direct hit in parity_v2 when
|
||||
* the model streamed ``tool_call_chunks`` so the card's synthetic
|
||||
* id IS the LangChain id.
|
||||
* 2. ``a.tool_call_id === langchainToolCallId`` — legacy mode (or
|
||||
* parity_v2 with provider-side chunk emission) where the card's
|
||||
* synthetic id is ``call_<run_id>`` and the LangChain id is
|
||||
* backfilled onto the part by ``tool-output-available``.
|
||||
* 3. ``(chat_turn_id, tool_name, position-within-turn)`` — fallback
|
||||
* for cards whose synthetic id is ``call_<run_id>`` AND whose
|
||||
* ``langchainToolCallId`` never got backfilled (provider emitted
|
||||
* the tool_call as a single payload with no chunks AND streaming
|
||||
* pre-dated the ``tool-output-available langchainToolCallId``
|
||||
* backfill, e.g. older threads). Reads the parent message's
|
||||
* ``chatTurnId`` and ``content`` via ``useAuiState`` so we can
|
||||
* match position-by-tool-name within the turn against the
|
||||
* action_log rows the server returned in ``created_at`` order.
|
||||
*/
|
||||
function ToolCardRevertButton({ toolCallId }: { toolCallId: string }) {
|
||||
function ToolCardRevertButton({
|
||||
toolCallId,
|
||||
toolName,
|
||||
langchainToolCallId,
|
||||
}: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
langchainToolCallId?: string;
|
||||
}) {
|
||||
const session = useAtomValue(chatSessionStateAtom);
|
||||
const actionMap = useAtomValue(agentActionByToolCallIdAtom);
|
||||
const markReverted = useSetAtom(markAgentActionRevertedAtom);
|
||||
const action = actionMap.get(toolCallId);
|
||||
const threadId = session?.threadId ?? null;
|
||||
const queryClient = useQueryClient();
|
||||
const { findByToolCallId, findByChatTurnAndTool } = useAgentActionsQuery(threadId);
|
||||
|
||||
// Parent message metadata, read via the narrowest possible
|
||||
// selectors so this card doesn't re-render on every text-delta of
|
||||
// every other part in the same message during streaming.
|
||||
//
|
||||
// IMPORTANT — ``useAuiState`` re-renders the component whenever the
|
||||
// returned slice's identity changes. Returning ``message?.content``
|
||||
// (an array) would re-render on every token because the runtime
|
||||
// rebuilds the parts array. Returning a PRIMITIVE (the position
|
||||
// number) lets ``useAuiState``'s ``Object.is`` check short-circuit
|
||||
// when the position hasn't actually moved — which is the common
|
||||
// case during text streaming, when only ``text``/``reasoning``
|
||||
// parts are mutating and the same-toolName tool-call ordering is
|
||||
// stable. (See Vercel React rule ``rerender-defer-reads``.)
|
||||
const chatTurnId = useAuiState(({ message }) => {
|
||||
const meta = message?.metadata as { custom?: { chatTurnId?: string } } | undefined;
|
||||
return meta?.custom?.chatTurnId ?? null;
|
||||
});
|
||||
const positionInTurn = useAuiState(({ message }) => {
|
||||
const content = message?.content;
|
||||
if (!Array.isArray(content)) return -1;
|
||||
let n = -1;
|
||||
for (const part of content) {
|
||||
if (
|
||||
part &&
|
||||
typeof part === "object" &&
|
||||
(part as { type?: string }).type === "tool-call" &&
|
||||
(part as { toolName?: string }).toolName === toolName
|
||||
) {
|
||||
n += 1;
|
||||
if ((part as { toolCallId?: string }).toolCallId === toolCallId) return n;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
|
||||
const action = useMemo(() => {
|
||||
// Tier 1 + 2: O(1) Map-backed direct id match. Covers
|
||||
// ~all parity_v2 streams and any legacy stream that backfilled
|
||||
// ``langchainToolCallId`` via ``tool-output-available``.
|
||||
const direct =
|
||||
findByToolCallId(toolCallId) ?? findByToolCallId(langchainToolCallId);
|
||||
if (direct) return direct;
|
||||
// Tier 3: position-within-turn fallback. Only kicks in when the
|
||||
// card has a synthetic ``call_<run_id>`` id AND no
|
||||
// ``langchainToolCallId`` was ever backfilled — i.e. the tool
|
||||
// was emitted as a single non-chunked payload AND streaming
|
||||
// pre-dated the on_tool_end backfill.
|
||||
if (!chatTurnId || positionInTurn < 0) return null;
|
||||
const turnSameTool = findByChatTurnAndTool(chatTurnId, toolName);
|
||||
return turnSameTool[positionInTurn] ?? null;
|
||||
}, [
|
||||
findByToolCallId,
|
||||
findByChatTurnAndTool,
|
||||
toolCallId,
|
||||
langchainToolCallId,
|
||||
chatTurnId,
|
||||
toolName,
|
||||
positionInTurn,
|
||||
]);
|
||||
|
||||
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 (action.reverted_by_action_id !== null && action.reverted_by_action_id !== undefined)
|
||||
return null;
|
||||
if (action.is_revert_action) return null;
|
||||
if (action.error !== null && action.error !== undefined) return null;
|
||||
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 });
|
||||
markActionRevertedInCache(
|
||||
queryClient,
|
||||
threadId,
|
||||
action.id,
|
||||
response.new_action_id ?? null
|
||||
);
|
||||
toast.success(response.message || "Action reverted.");
|
||||
} catch (err) {
|
||||
// 503 means revert is gated off on this deployment — hide the
|
||||
|
|
@ -91,8 +197,17 @@ function ToolCardRevertButton({ toolCallId }: { toolCallId: string }) {
|
|||
e.stopPropagation();
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
disabled={isReverting}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{isReverting ? (
|
||||
// Spinner's typed props don't accept ``data-icon`` and
|
||||
// it renders an <output>, not an <svg>, so Button's
|
||||
// auto-sizing rule doesn't apply. Bare spinner +
|
||||
// Button's gap handle layout.
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<RotateCcw data-icon="inline-start" />
|
||||
)}
|
||||
Revert
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
|
@ -101,7 +216,7 @@ function ToolCardRevertButton({ toolCallId }: { toolCallId: string }) {
|
|||
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will undo{" "}
|
||||
<span className="font-medium">{getToolDisplayName(action.toolName)}</span> and add a
|
||||
<span className="font-medium">{getToolDisplayName(action.tool_name)}</span> and add a
|
||||
new entry to the history. Your chat is preserved — only the changes the agent made to
|
||||
your knowledge base or connected apps will be rolled back where possible.
|
||||
</AlertDialogDescription>
|
||||
|
|
@ -114,8 +229,10 @@ function ToolCardRevertButton({ toolCallId }: { toolCallId: string }) {
|
|||
handleRevert();
|
||||
}}
|
||||
disabled={isReverting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{isReverting ? "Reverting…" : "Revert"}
|
||||
{isReverting && <Spinner size="xs" />}
|
||||
Revert
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -123,18 +240,49 @@ function ToolCardRevertButton({ toolCallId }: { toolCallId: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
||||
toolCallId,
|
||||
toolName,
|
||||
argsText,
|
||||
result,
|
||||
status,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
/**
|
||||
* Compact tool-call card.
|
||||
*
|
||||
* shadcn composition note: we intentionally use ``Card`` as a visual
|
||||
* frame WITHOUT ``CardHeader / CardContent``. The full composition's
|
||||
* ``p-6`` padding doesn't fit a compact collapsible header that IS the
|
||||
* trigger; using ``Card`` alone preserves the rounded border, shadow,
|
||||
* and ``bg-card`` token (semantic colors) without forcing a layout
|
||||
* that doesn't fit. All status colors use semantic tokens — no manual
|
||||
* dark-mode overrides, no raw hex.
|
||||
*/
|
||||
const DefaultToolFallbackInner: ToolCallMessagePartComponent = (props) => {
|
||||
const { toolCallId, toolName, argsText, result, status } = props;
|
||||
// ``langchainToolCallId`` is a SurfSense-specific extension the
|
||||
// streaming pipeline attaches to the tool-call content part so
|
||||
// the Revert button can resolve its ``AgentActionLog`` row even
|
||||
// when only the LC id is known. assistant-ui's
|
||||
// ``ToolCallMessagePartProps`` doesn't list it, but the runtime
|
||||
// spreads ``{...part}`` so the prop reaches us at runtime.
|
||||
const langchainToolCallId = (props as { langchainToolCallId?: string }).langchainToolCallId;
|
||||
|
||||
const isCancelled = status?.type === "incomplete" && status.reason === "cancelled";
|
||||
const isError = status?.type === "incomplete" && status.reason === "error";
|
||||
const isRunning = status?.type === "running" || status?.type === "requires-action";
|
||||
|
||||
/*
|
||||
Per-card expansion state. Initial value is ``isRunning`` so a
|
||||
card streaming in mounts already-expanded (no flash of
|
||||
collapsed → expanded on first paint), while a card loaded from
|
||||
history (status="complete") mounts collapsed. The useEffect
|
||||
below keeps this in lockstep with this card's own ``isRunning``
|
||||
when it transitions: false → true auto-expands (e.g. a tool
|
||||
that re-runs after edit), true → false auto-collapses once the
|
||||
tool finishes. Because the dep is per-card ``isRunning`` and
|
||||
not the chat-level streaming flag, sibling cards on the same
|
||||
assistant turn each manage their own expansion independently.
|
||||
Once ``isRunning`` is false the user controls expansion via
|
||||
``onOpenChange``.
|
||||
*/
|
||||
const [isExpanded, setIsExpanded] = useState(isRunning);
|
||||
useEffect(() => {
|
||||
setIsExpanded(isRunning);
|
||||
}, [isRunning]);
|
||||
const errorData = status?.type === "incomplete" ? status.error : undefined;
|
||||
const serializedError = useMemo(
|
||||
() => (errorData && typeof errorData !== "string" ? JSON.stringify(errorData) : null),
|
||||
|
|
@ -160,108 +308,207 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
|||
: serializedError
|
||||
: null;
|
||||
|
||||
const Icon = getToolIcon(toolName);
|
||||
const displayName = getToolDisplayName(toolName);
|
||||
const subtitle = errorReason ?? cancelledReason;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
className={cn(
|
||||
"my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none",
|
||||
"my-4 max-w-lg overflow-hidden",
|
||||
isCancelled && "opacity-60",
|
||||
isError && "border-destructive/20 bg-destructive/5"
|
||||
isError && "border-destructive/30"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
|
||||
{/*
|
||||
``group`` lets the chevron (rendered as a sibling of the
|
||||
main trigger button) read the Collapsible Root's
|
||||
``data-[state=open]`` for rotation. The Collapsible is
|
||||
fully controlled via ``isExpanded`` — the useEffect
|
||||
above syncs it to ``isRunning`` so the card auto-opens
|
||||
while a tool streams in and auto-collapses once it
|
||||
finishes. We deliberately DON'T pass ``disabled`` so
|
||||
both triggers stay clickable; ``onOpenChange`` is wired
|
||||
to a setter that no-ops while ``isRunning`` (see
|
||||
``handleOpenChange`` below) which keeps the card pinned
|
||||
open mid-stream without losing keyboard / pointer
|
||||
affordance the moment streaming ends.
|
||||
*/}
|
||||
<Collapsible
|
||||
className="group"
|
||||
open={isExpanded}
|
||||
onOpenChange={(next) => {
|
||||
// Block manual collapse while the tool is still
|
||||
// streaming — otherwise a stray click on either
|
||||
// trigger would close the card and hide the live
|
||||
// ``argsText`` panel mid-run. After streaming the
|
||||
// user has full control again.
|
||||
if (isRunning) return;
|
||||
setIsExpanded(next);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-lg",
|
||||
isError ? "bg-destructive/10" : isCancelled ? "bg-muted" : "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{isError ? (
|
||||
<XCircleIcon className="size-4 text-destructive" />
|
||||
) : isCancelled ? (
|
||||
<XCircleIcon className="size-4 text-muted-foreground" />
|
||||
) : isRunning ? (
|
||||
<Icon className="size-4 text-primary animate-pulse" />
|
||||
) : (
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
Header row: main trigger on the left (icon + title
|
||||
col), Revert + chevron-trigger on the right as
|
||||
siblings of the main trigger. The chevron is wrapped
|
||||
in its OWN ``CollapsibleTrigger`` (Radix supports
|
||||
multiple triggers per Root) so clicking the chevron
|
||||
toggles the same state as clicking the title row.
|
||||
The Revert button stays a separate AlertDialog
|
||||
trigger and stops propagation in its onClick so it
|
||||
doesn't toggle the collapsible while opening the
|
||||
confirm dialog. Keeping these as flat siblings —
|
||||
rather than nesting Revert / chevron inside the
|
||||
title trigger — avoids invalid HTML
|
||||
(button-in-button) and lets the Revert button
|
||||
render in BOTH the collapsed and expanded states.
|
||||
*/}
|
||||
<div className="flex items-stretch transition-colors hover:bg-muted/50">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 min-w-0 items-center gap-3 py-4 pl-5 pr-2 text-left",
|
||||
// Inset ring — Card's ``overflow-hidden`` would
|
||||
// clip an ``offset-2`` ring; ``ring-inset``
|
||||
// paints inside the button box.
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
||||
"disabled:cursor-default"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-lg",
|
||||
isError ? "bg-destructive/10" : isCancelled ? "bg-muted" : "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
{isError ? (
|
||||
<XCircleIcon className="size-4 text-destructive" />
|
||||
) : isCancelled ? (
|
||||
<XCircleIcon className="size-4 text-muted-foreground" />
|
||||
) : isRunning ? (
|
||||
<Spinner size="sm" className="text-primary" />
|
||||
) : (
|
||||
<CheckIcon className="size-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
isError
|
||||
? "text-destructive"
|
||||
: isCancelled
|
||||
? "text-muted-foreground line-through"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{isRunning
|
||||
? displayName
|
||||
: isCancelled
|
||||
? `Cancelled: ${displayName}`
|
||||
: isError
|
||||
? `Failed: ${displayName}`
|
||||
: displayName}
|
||||
</p>
|
||||
{isRunning && <p className="text-xs text-muted-foreground mt-0.5">Working…</p>}
|
||||
{cancelledReason && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{cancelledReason}</p>
|
||||
)}
|
||||
{errorReason && (
|
||||
<p className="text-xs text-destructive/80 mt-0.5 truncate">{errorReason}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-semibold truncate",
|
||||
isCancelled && "text-muted-foreground line-through",
|
||||
isError && "text-destructive"
|
||||
)}
|
||||
>
|
||||
{displayName}
|
||||
</p>
|
||||
{isRunning && <Badge variant="secondary">Running</Badge>}
|
||||
{isError && <Badge variant="destructive">Failed</Badge>}
|
||||
{isCancelled && <Badge variant="outline">Cancelled</Badge>}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs truncate",
|
||||
isError ? "text-destructive/80" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{!isRunning && (
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronUpIcon className="size-4" />
|
||||
)}
|
||||
{/*
|
||||
Right-side controls. The Revert button is
|
||||
visible whenever the matching action is
|
||||
reversible — including the collapsed state —
|
||||
but ``ToolCardRevertButton`` itself returns
|
||||
``null`` while a tool is still running because
|
||||
no action-log row exists yet, so it doesn't
|
||||
need an explicit ``isRunning`` gate here.
|
||||
*/}
|
||||
<div className="flex shrink-0 items-center gap-2 pl-2 pr-5">
|
||||
<ToolCardRevertButton
|
||||
toolCallId={toolCallId}
|
||||
toolName={toolName}
|
||||
langchainToolCallId={langchainToolCallId}
|
||||
/>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? "Collapse details" : "Expand details"}
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
||||
"disabled:cursor-default"
|
||||
)}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform duration-200",
|
||||
"group-data-[state=open]:rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && !isRunning && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-3 space-y-3">
|
||||
{argsText && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Inputs</p>
|
||||
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all">
|
||||
{argsText}
|
||||
</pre>
|
||||
{/*
|
||||
CollapsibleContent body — auto-open while streaming
|
||||
(see ``open`` prop above) so the live ``argsText``
|
||||
streams into the Inputs panel directly, no need for
|
||||
a separate "Live input" panel. Native
|
||||
``overflow-auto`` instead of ``ScrollArea`` because
|
||||
Radix's Viewport can let content bleed past
|
||||
``max-h-*`` in dynamic flex layouts. ``min-w-0`` on
|
||||
the column wrappers guarantees ``break-all`` wraps
|
||||
correctly within the bounded ``max-w-lg`` Card.
|
||||
*/}
|
||||
<CollapsibleContent>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-3 px-5 py-3">
|
||||
{(argsText || isRunning) && (
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
|
||||
<div className="max-h-48 overflow-auto rounded-md bg-muted/40">
|
||||
{argsText ? (
|
||||
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
|
||||
{argsText}
|
||||
</pre>
|
||||
) : (
|
||||
// Bridges the brief gap between
|
||||
// ``tool-input-start`` (creates the
|
||||
// card, ``argsText`` undefined) and
|
||||
// the first ``tool-input-delta``.
|
||||
<p className="px-3 py-2 text-xs italic text-muted-foreground">
|
||||
Waiting for input…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isCancelled && result !== undefined && (
|
||||
<>
|
||||
<div className="h-px bg-border/30" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Result</p>
|
||||
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all">
|
||||
{typeof result === "string" ? result : serializedResult}
|
||||
</pre>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">Result</p>
|
||||
<div className="max-h-64 overflow-auto rounded-md bg-muted/40">
|
||||
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
|
||||
{typeof result === "string" ? result : serializedResult}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<ToolCardRevertButton toolCallId={toolCallId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
addToolCall,
|
||||
appendReasoning,
|
||||
appendText,
|
||||
appendToolInputDelta,
|
||||
buildContentForUI,
|
||||
type ContentPartsState,
|
||||
endReasoning,
|
||||
|
|
@ -188,6 +189,10 @@ export function FreeChatPage() {
|
|||
);
|
||||
};
|
||||
const scheduleFlush = () => batcher.schedule(flushMessages);
|
||||
const forceFlush = () => {
|
||||
scheduleFlush();
|
||||
batcher.flush();
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const parsed of readSSEStream(response)) {
|
||||
|
|
@ -225,13 +230,20 @@ export function FreeChatPage() {
|
|||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
batcher.flush();
|
||||
forceFlush();
|
||||
break;
|
||||
|
||||
case "tool-input-available":
|
||||
case "tool-input-delta":
|
||||
appendToolInputDelta(contentPartsState, parsed.toolCallId, parsed.inputTextDelta);
|
||||
scheduleFlush();
|
||||
break;
|
||||
|
||||
case "tool-input-available": {
|
||||
const finalArgsText = JSON.stringify(parsed.input ?? {}, null, 2);
|
||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
args: parsed.input || {},
|
||||
argsText: finalArgsText,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
} else {
|
||||
|
|
@ -244,16 +256,20 @@ export function FreeChatPage() {
|
|||
false,
|
||||
parsed.langchainToolCallId
|
||||
);
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
argsText: finalArgsText,
|
||||
});
|
||||
}
|
||||
batcher.flush();
|
||||
forceFlush();
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool-output-available":
|
||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||
result: parsed.output,
|
||||
langchainToolCallId: parsed.langchainToolCallId,
|
||||
});
|
||||
batcher.flush();
|
||||
forceFlush();
|
||||
break;
|
||||
|
||||
case "data-thinking-step": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue