mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +02:00
feat: updated agent harness
This commit is contained in:
parent
9ec9b64348
commit
31a372bb84
139 changed files with 12583 additions and 1111 deletions
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Activity } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { openActionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface ActionLogButtonProps {
|
||||
threadId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header button that opens the agent action log sheet for the current
|
||||
* thread. Renders nothing when:
|
||||
* - the action log feature flag is off (graceful no-op for older
|
||||
* deployments), OR
|
||||
* - there is no active thread (lazy-created chats haven't started).
|
||||
*/
|
||||
export function ActionLogButton({ threadId }: ActionLogButtonProps) {
|
||||
const { data: flags } = useAtomValue(agentFlagsAtom);
|
||||
const open = useSetAtom(openActionLogSheetAtom);
|
||||
|
||||
const enabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (threadId !== null) open(threadId);
|
||||
}, [open, threadId]);
|
||||
|
||||
if (!enabled || threadId === null) return null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-8 p-0"
|
||||
aria-label="Open agent action log"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Activity className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Agent actions</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
215
surfsense_web/components/agent-action-log/action-log-item.tsx
Normal file
215
surfsense_web/components/agent-action-log/action-log-item.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight, RotateCcw, ShieldOff, Undo2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
interface ActionLogItemProps {
|
||||
action: AgentAction;
|
||||
threadId: number;
|
||||
onRevertSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const isAlreadyReverted = action.reverted_by_action_id !== null;
|
||||
const isRevertAction = action.is_revert_action;
|
||||
const hasError = action.error !== null && action.error !== undefined;
|
||||
|
||||
const Icon = getToolIcon(action.tool_name);
|
||||
const displayName = formatToolName(action.tool_name);
|
||||
|
||||
const argsPreview = action.args ? JSON.stringify(action.args, null, 2) : null;
|
||||
const truncatedArgs =
|
||||
argsPreview && argsPreview.length > 600 ? `${argsPreview.slice(0, 600)}…` : argsPreview;
|
||||
|
||||
const canRevert = action.reversible && !isAlreadyReverted && !isRevertAction && !hasError;
|
||||
|
||||
const handleRevert = async () => {
|
||||
setIsReverting(true);
|
||||
try {
|
||||
const response = await agentActionsApiService.revert(threadId, action.id);
|
||||
toast.success(response.message || "Action reverted successfully.");
|
||||
onRevertSuccess();
|
||||
} catch (err) {
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card transition-colors",
|
||||
isAlreadyReverted && "opacity-70"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
className="flex w-full items-start gap-3 p-3 text-left hover:bg-muted/40"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
{isRevertAction ? (
|
||||
<Undo2 className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
{isRevertAction && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Revert
|
||||
</Badge>
|
||||
)}
|
||||
{hasError && (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
{!isRevertAction && action.reversible && !isAlreadyReverted && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Reversible
|
||||
</Badge>
|
||||
)}
|
||||
{isAlreadyReverted && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Reverted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeDate(action.created_at)}</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col gap-3 border-t bg-muted/20 p-3">
|
||||
{truncatedArgs && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Arguments
|
||||
</p>
|
||||
<pre className="max-h-48 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
|
||||
{truncatedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{action.error && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Error
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-destructive/10 p-2 text-[11px] text-destructive">
|
||||
{JSON.stringify(action.error, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{action.reverse_descriptor && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Reverse plan
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
|
||||
{JSON.stringify(action.reverse_descriptor, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Action ID: <span className="font-mono">{action.id}</span>
|
||||
</p>
|
||||
{canRevert ? (
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="gap-1.5">
|
||||
<RotateCcw className="size-3.5" />
|
||||
Revert
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will undo <span className="font-medium">{displayName}</span> and append a
|
||||
new audit entry. The agent's 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>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<ShieldOff className="size-3.5" />
|
||||
{isAlreadyReverted
|
||||
? "Already reverted"
|
||||
: isRevertAction
|
||||
? "Revert entry"
|
||||
: hasError
|
||||
? "Cannot revert errored action"
|
||||
: "Not reversible"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
surfsense_web/components/agent-action-log/action-log-sheet.tsx
Normal file
185
surfsense_web/components/agent-action-log/action-log-sheet.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { Activity, RefreshCcw } from "lucide-react";
|
||||
import { useCallback, useMemo } 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";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
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">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Activity className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">No actions logged yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Once the agent calls a tool in this thread, it will show up here. From the log you can
|
||||
inspect arguments and revert reversible actions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledState() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Activity className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">Action log is disabled</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This deployment hasn't enabled the agent action log. An admin can flip
|
||||
<code className="ml-1 rounded bg-muted px-1 text-[10px]">
|
||||
SURFSENSE_ENABLE_ACTION_LOG
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SKELETON_KEYS = ["s1", "s2", "s3", "s4"] as const;
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{SKELETON_KEYS.map((key) => (
|
||||
<Skeleton key={key} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionLogSheet() {
|
||||
const [state, setState] = useAtom(actionLogSheetAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: flags } = useAtomValue(agentFlagsAtom);
|
||||
const actionLogEnabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
|
||||
const revertEnabled = !!flags?.enable_revert_route && !flags?.disable_new_agent_stack;
|
||||
|
||||
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 handleRevertSuccess = useCallback(() => {
|
||||
if (threadId !== null) {
|
||||
queryClient.invalidateQueries({ queryKey: actionLogQueryKey(threadId) });
|
||||
}
|
||||
}, [queryClient, threadId]);
|
||||
|
||||
const items = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
return (
|
||||
<Sheet open={state.open} onOpenChange={(open) => setState((s) => ({ ...s, open }))}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-md"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="size-4 text-muted-foreground" />
|
||||
<SheetTitle className="text-base font-semibold">Agent actions</SheetTitle>
|
||||
{data?.total !== undefined && data.total > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{data.total}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching || !actionLogEnabled}
|
||||
className="size-8 p-0"
|
||||
aria-label="Refresh action log"
|
||||
>
|
||||
<RefreshCcw className={isFetching ? "size-3.5 animate-spin" : "size-3.5"} />
|
||||
</Button>
|
||||
</div>
|
||||
<SheetDescription className="text-xs text-muted-foreground">
|
||||
Audit trail of every tool call the agent made in this thread.
|
||||
{revertEnabled
|
||||
? " Reversible actions can be undone in place."
|
||||
: " Reverts are read-only on this deployment."}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-thin">
|
||||
{!actionLogEnabled ? (
|
||||
<DisabledState />
|
||||
) : threadId === null ? (
|
||||
<EmptyState />
|
||||
) : isLoading ? (
|
||||
<LoadingState />
|
||||
) : isError ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<p className="text-sm font-medium text-destructive">Failed to load actions</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error instanceof Error ? error.message : "Unknown error"}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{items.map((action) => (
|
||||
<ActionLogItem
|
||||
key={action.id}
|
||||
action={action}
|
||||
threadId={threadId}
|
||||
onRevertSuccess={handleRevertSuccess}
|
||||
/>
|
||||
))}
|
||||
{data?.has_more && (
|
||||
<p className="py-2 text-center text-[11px] text-muted-foreground">
|
||||
Showing {items.length} of {data.total}. Older actions are paginated.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -85,10 +85,13 @@ function preprocessMarkdown(content: string): string {
|
|||
}
|
||||
);
|
||||
|
||||
// All math forms are normalised to $$...$$ so we can disable single-dollar
|
||||
// inline math in remark-math (otherwise currency like "$3,120.00 and $0.00"
|
||||
// gets parsed as a LaTeX expression).
|
||||
// 1. Block math: \[...\] → $$...$$
|
||||
content = content.replace(/\\\[([\s\S]*?)\\\]/g, (_, inner) => `$$${inner}$$`);
|
||||
// 2. Inline math: \(...\) → $...$
|
||||
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_, inner) => `$${inner}$`);
|
||||
// 2. Inline math: \(...\) → $$...$$
|
||||
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_, inner) => `$$${inner}$$`);
|
||||
// 3. Block: \begin{equation}...\end{equation} → $$...$$
|
||||
content = content.replace(
|
||||
/\\begin\{equation\}([\s\S]*?)\\end\{equation\}/g,
|
||||
|
|
@ -99,8 +102,11 @@ function preprocessMarkdown(content: string): string {
|
|||
/\\begin\{displaymath\}([\s\S]*?)\\end\{displaymath\}/g,
|
||||
(_, inner) => `$$${inner}$$`
|
||||
);
|
||||
// 5. Inline: \begin{math}...\end{math} → $...$
|
||||
content = content.replace(/\\begin\{math\}([\s\S]*?)\\end\{math\}/g, (_, inner) => `$${inner}$`);
|
||||
// 5. Inline: \begin{math}...\end{math} → $$...$$
|
||||
content = content.replace(
|
||||
/\\begin\{math\}([\s\S]*?)\\end\{math\}/g,
|
||||
(_, inner) => `$$${inner}$$`
|
||||
);
|
||||
// 6. Strip backtick wrapping around math: `$$...$$` → $$...$$ and `$...$` → $...$
|
||||
content = content.replace(/`(\${1,2})((?:(?!\1).)+)\1`/g, "$1$2$1");
|
||||
|
||||
|
|
@ -180,7 +186,7 @@ const MarkdownTextImpl = () => {
|
|||
return (
|
||||
<MarkdownTextPrimitive
|
||||
smooth={false}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
className="aui-md"
|
||||
components={defaultComponents}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
DoomLoopApprovalToolUI,
|
||||
isDoomLoopInterrupt,
|
||||
} from "@/components/tool-ui/doom-loop-approval";
|
||||
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { isInterruptResult } from "@/lib/hitl";
|
||||
|
|
@ -150,6 +154,9 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
|||
|
||||
export const ToolFallback: ToolCallMessagePartComponent = (props) => {
|
||||
if (isInterruptResult(props.result)) {
|
||||
if (isDoomLoopInterrupt(props.result)) {
|
||||
return <DoomLoopApprovalToolUI {...props} />;
|
||||
}
|
||||
return <GenericHitlApprovalToolUI {...props} />;
|
||||
}
|
||||
return <DefaultToolFallbackInner {...props} />;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
|
||||
import { TeamDialog } from "@/components/settings/team-dialog";
|
||||
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet";
|
||||
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -909,6 +910,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
<UserSettingsDialog />
|
||||
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
|
||||
{/* Agent action log + revert sheet */}
|
||||
<ActionLogSheet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
|||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeTabAtom, tabsAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { ActionLogButton } from "@/components/agent-action-log/action-log-button";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
|
@ -69,6 +70,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
|
||||
{/* Right side - Actions */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasThread && <ActionLogButton threadId={currentThreadState.id} />}
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ const code = createCodePlugin({
|
|||
});
|
||||
|
||||
const math = createMathPlugin({
|
||||
singleDollarTextMath: true,
|
||||
// Disabled so currency like "$3,120.00 and ... $0.00" isn't parsed as
|
||||
// inline LaTeX. convertLatexDelimiters() below normalises any genuine
|
||||
// inline math (\(...\), $...$ starting with a LaTeX command, etc.) to
|
||||
// $$...$$, so this flip doesn't lose any math rendering.
|
||||
singleDollarTextMath: false,
|
||||
});
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
Activity,
|
||||
Brain,
|
||||
CircleUser,
|
||||
Globe,
|
||||
|
|
@ -9,6 +10,7 @@ import {
|
|||
KeyRound,
|
||||
Monitor,
|
||||
ReceiptText,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
|
@ -74,6 +76,20 @@ const MemoryContent = dynamic(
|
|||
),
|
||||
{ ssr: false }
|
||||
);
|
||||
const AgentPermissionsContent = dynamic(
|
||||
() =>
|
||||
import(
|
||||
"@/app/dashboard/[search_space_id]/user-settings/components/AgentPermissionsContent"
|
||||
).then((m) => ({ default: m.AgentPermissionsContent })),
|
||||
{ ssr: false }
|
||||
);
|
||||
const AgentStatusContent = dynamic(
|
||||
() =>
|
||||
import("@/app/dashboard/[search_space_id]/user-settings/components/AgentStatusContent").then(
|
||||
(m) => ({ default: m.AgentStatusContent })
|
||||
),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export function UserSettingsDialog() {
|
||||
const t = useTranslations("userSettings");
|
||||
|
|
@ -103,6 +119,16 @@ export function UserSettingsDialog() {
|
|||
label: "Memory",
|
||||
icon: <Brain className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "agent-permissions",
|
||||
label: "Agent Permissions",
|
||||
icon: <ShieldCheck className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "agent-status",
|
||||
label: "Agent Status",
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "purchases",
|
||||
label: "Purchase History",
|
||||
|
|
@ -141,6 +167,8 @@ export function UserSettingsDialog() {
|
|||
{state.initialTab === "prompts" && <PromptsContent />}
|
||||
{state.initialTab === "community-prompts" && <CommunityPromptsContent />}
|
||||
{state.initialTab === "memory" && <MemoryContent />}
|
||||
{state.initialTab === "agent-permissions" && <AgentPermissionsContent />}
|
||||
{state.initialTab === "agent-status" && <AgentStatusContent />}
|
||||
{state.initialTab === "purchases" && <PurchaseHistoryContent />}
|
||||
{state.initialTab === "desktop" && <DesktopContent />}
|
||||
{state.initialTab === "desktop-shortcuts" && <DesktopShortcutsContent />}
|
||||
|
|
|
|||
187
surfsense_web/components/tool-ui/doom-loop-approval.tsx
Normal file
187
surfsense_web/components/tool-ui/doom-loop-approval.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CornerDownLeftIcon, OctagonAlert } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
/**
|
||||
* Specialized HITL card for ``DoomLoopMiddleware`` interrupts. The
|
||||
* backend signals these by setting ``context.permission === "doom_loop"``
|
||||
* on the ``permission_ask`` interrupt.
|
||||
*
|
||||
* The card replaces the generic "approve/reject" framing with a
|
||||
* "continue/stop" affordance that better matches the user's mental
|
||||
* model: the agent is stuck repeating itself, not asking permission
|
||||
* for a destructive action.
|
||||
*/
|
||||
function DoomLoopCard({
|
||||
toolName,
|
||||
args,
|
||||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
|
||||
const context = (interruptData.context ?? {}) as Record<string, unknown>;
|
||||
const threshold = typeof context.threshold === "number" ? context.threshold : 3;
|
||||
const stuckTool = (typeof context.tool === "string" && context.tool) || toolName;
|
||||
const recentSignatures = Array.isArray(context.recent_signatures)
|
||||
? (context.recent_signatures as string[])
|
||||
: [];
|
||||
const displayName = stuckTool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const argPreview = useMemo(() => {
|
||||
if (!args || Object.keys(args).length === 0) return null;
|
||||
try {
|
||||
const json = JSON.stringify(args, null, 2);
|
||||
return json.length > 600 ? `${json.slice(0, 600)}…` : json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [args]);
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
if (phase !== "pending") return;
|
||||
setProcessing();
|
||||
onDecision({ type: "approve" });
|
||||
}, [phase, setProcessing, onDecision]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (phase !== "pending") return;
|
||||
setRejected();
|
||||
onDecision({ type: "reject", message: "Doom loop: user requested stop." });
|
||||
}, [phase, setRejected, onDecision]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (phase !== "pending") return;
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleStop();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [phase, handleStop]);
|
||||
|
||||
const isResolved = phase === "complete" || phase === "rejected";
|
||||
|
||||
return (
|
||||
<Alert variant={phase === "rejected" ? "default" : "destructive"} className="my-4 max-w-lg">
|
||||
<OctagonAlert className="size-4" />
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
<span>
|
||||
{phase === "rejected"
|
||||
? "Stopped"
|
||||
: phase === "processing"
|
||||
? "Continuing…"
|
||||
: phase === "complete"
|
||||
? "Continued"
|
||||
: "I might be stuck"}
|
||||
</span>
|
||||
{!isResolved && (
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
doom-loop
|
||||
</Badge>
|
||||
)}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3">
|
||||
{phase === "processing" ? (
|
||||
<TextShimmerLoader text="Resuming…" size="sm" />
|
||||
) : phase === "rejected" ? (
|
||||
<p className="text-xs">
|
||||
I stopped retrying <span className="font-medium">{displayName}</span> as you asked.
|
||||
</p>
|
||||
) : phase === "complete" ? (
|
||||
<p className="text-xs">
|
||||
Continuing to call <span className="font-medium">{displayName}</span> as you asked.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs">
|
||||
I called <span className="font-medium">{displayName}</span> {threshold} times in a row
|
||||
with similar arguments. Should I keep going or stop and rethink?
|
||||
</p>
|
||||
)}
|
||||
|
||||
{argPreview && phase === "pending" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Last arguments
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-muted/50 p-2 text-[11px] text-foreground/80">
|
||||
{argPreview}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recentSignatures.length > 0 && phase === "pending" && (
|
||||
<details className="text-[11px] text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">
|
||||
Show repeated signatures ({recentSignatures.length})
|
||||
</summary>
|
||||
<ul className="mt-1 ml-4 list-disc">
|
||||
{recentSignatures.map((sig) => (
|
||||
<li key={sig} className="font-mono break-all">
|
||||
{sig}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{phase === "pending" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="rounded-lg gap-1.5" onClick={handleStop}>
|
||||
Stop and rethink
|
||||
<CornerDownLeftIcon className="size-3 opacity-60" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleContinue}>
|
||||
Continue anyway
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export const DoomLoopApprovalToolUI: ToolCallMessagePartComponent = ({
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
}) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result || !isInterruptResult(result)) return null;
|
||||
|
||||
return (
|
||||
<DoomLoopCard
|
||||
toolName={toolName}
|
||||
args={args as Record<string, unknown>}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function isDoomLoopInterrupt(result: unknown): boolean {
|
||||
if (!isInterruptResult(result)) return false;
|
||||
const ctx = (result.context ?? {}) as Record<string, unknown>;
|
||||
return ctx.permission === "doom_loop";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue