mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 10:26:33 +02:00
Enables users to edit tool call arguments before execution in human-in-the-loop workflows. Adds edit mode UI with form fields, grayscale styling, and subtle pulse animations for pending approvals. Backend stub enhanced to verify edited arguments are correctly passed through.
315 lines
8.6 KiB
TypeScript
315 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
import { makeAssistantToolUI } from "@assistant-ui/react";
|
|
import { AlertTriangleIcon, CheckIcon, FileTextIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
interface InterruptResult {
|
|
__interrupt__: true;
|
|
__decided__?: "approve" | "reject" | "edit";
|
|
action_requests: Array<{
|
|
name: string;
|
|
args: Record<string, unknown>;
|
|
description?: string;
|
|
}>;
|
|
review_configs: Array<{
|
|
action_name: string;
|
|
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
|
}>;
|
|
}
|
|
|
|
interface SuccessResult {
|
|
status: string;
|
|
page_id: string;
|
|
title: string;
|
|
url: string;
|
|
content_preview?: string;
|
|
content_length?: number;
|
|
message?: string;
|
|
}
|
|
|
|
type CreateNotionPageResult = InterruptResult | SuccessResult;
|
|
|
|
function isInterruptResult(result: unknown): result is InterruptResult {
|
|
return (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
"__interrupt__" in result &&
|
|
(result as InterruptResult).__interrupt__ === true
|
|
);
|
|
}
|
|
|
|
function ApprovalCard({
|
|
args,
|
|
interruptData,
|
|
onDecision,
|
|
}: {
|
|
args: Record<string, unknown>;
|
|
interruptData: InterruptResult;
|
|
onDecision: (decision: { type: "approve" | "reject" | "edit"; message?: string; edited_action?: { name: string; args: Record<string, unknown> } }) => void;
|
|
}) {
|
|
const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>(
|
|
interruptData.__decided__ ?? null
|
|
);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>(args);
|
|
|
|
const reviewConfig = interruptData.review_configs[0];
|
|
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
|
const canEdit = allowedDecisions.includes("edit");
|
|
|
|
return (
|
|
<div className={`my-4 max-w-full overflow-hidden rounded-xl transition-all duration-300 ${
|
|
decided
|
|
? "border border-border bg-card shadow-sm"
|
|
: "border-2 border-foreground/20 bg-muted/30 dark:bg-muted/10 shadow-lg animate-pulse-subtle"
|
|
}`}>
|
|
<div className={`flex items-center gap-3 border-b ${
|
|
decided
|
|
? "border-border bg-card"
|
|
: "border-foreground/15 bg-muted/40 dark:bg-muted/20"
|
|
} px-4 py-3`}>
|
|
<div className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
|
|
decided
|
|
? "bg-muted"
|
|
: "bg-muted animate-pulse"
|
|
}`}>
|
|
<AlertTriangleIcon className={`size-4 ${
|
|
decided
|
|
? "text-muted-foreground"
|
|
: "text-foreground"
|
|
}`} />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`text-sm font-medium ${
|
|
decided
|
|
? "text-foreground"
|
|
: "text-foreground"
|
|
}`}>Create Notion Page</p>
|
|
<p className={`truncate text-xs ${
|
|
decided
|
|
? "text-muted-foreground"
|
|
: "text-muted-foreground"
|
|
}`}>
|
|
{isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Display mode - show args as read-only */}
|
|
{!isEditing && (
|
|
<div className="space-y-2 px-4 py-3 bg-card">
|
|
{args.title != null && (
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground">Title</p>
|
|
<p className="text-sm text-foreground">{String(args.title)}</p>
|
|
</div>
|
|
)}
|
|
{args.content != null && (
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground">Content</p>
|
|
<p className="line-clamp-4 text-sm whitespace-pre-wrap text-foreground">{String(args.content)}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit mode - show editable form fields */}
|
|
{isEditing && (
|
|
<div className="space-y-3 px-4 py-3 bg-card">
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
|
Title
|
|
</label>
|
|
<Input
|
|
value={String(editedArgs.title ?? "")}
|
|
onChange={(e) => setEditedArgs({ ...editedArgs, title: e.target.value })}
|
|
placeholder="Enter page title"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
|
Content
|
|
</label>
|
|
<Textarea
|
|
value={String(editedArgs.content ?? "")}
|
|
onChange={(e) => setEditedArgs({ ...editedArgs, content: e.target.value })}
|
|
placeholder="Enter page content"
|
|
rows={6}
|
|
className="resize-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action buttons */}
|
|
<div className={`flex items-center gap-2 border-t ${
|
|
decided
|
|
? "border-border bg-card"
|
|
: "border-foreground/15 bg-muted/20 dark:bg-muted/10"
|
|
} px-4 py-3`}>
|
|
{decided ? (
|
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
{decided === "approve" || decided === "edit" ? (
|
|
<>
|
|
<CheckIcon className="size-3.5 text-green-500" />
|
|
{decided === "edit" ? "Approved with Changes" : "Approved"}
|
|
</>
|
|
) : (
|
|
<>
|
|
<XIcon className="size-3.5 text-destructive" />
|
|
Rejected
|
|
</>
|
|
)}
|
|
</p>
|
|
) : isEditing ? (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setDecided("edit");
|
|
onDecision({
|
|
type: "edit",
|
|
edited_action: {
|
|
name: interruptData.action_requests[0].name,
|
|
args: editedArgs,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<CheckIcon />
|
|
Approve with Changes
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setIsEditing(false);
|
|
setEditedArgs(args); // Reset to original args
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
{allowedDecisions.includes("approve") && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setDecided("approve");
|
|
onDecision({ type: "approve" });
|
|
}}
|
|
>
|
|
<CheckIcon />
|
|
Approve
|
|
</Button>
|
|
)}
|
|
{canEdit && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setIsEditing(true)}
|
|
>
|
|
<PencilIcon />
|
|
Edit
|
|
</Button>
|
|
)}
|
|
{allowedDecisions.includes("reject") && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setDecided("reject");
|
|
onDecision({ type: "reject", message: "User rejected the action." });
|
|
}}
|
|
>
|
|
<XIcon />
|
|
Reject
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SuccessCard({ result }: { result: SuccessResult }) {
|
|
return (
|
|
<div className="my-4 max-w-md overflow-hidden rounded-xl border border-border bg-card">
|
|
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
|
|
<CheckIcon className="size-4 text-green-500" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium">{result.title}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{result.message || "Notion page created successfully"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Show details to verify the arguments were used */}
|
|
<div className="space-y-2 px-4 py-3 text-xs">
|
|
<div>
|
|
<span className="font-medium text-muted-foreground">Page ID: </span>
|
|
<span className="font-mono">{result.page_id}</span>
|
|
</div>
|
|
{result.content_length != null && (
|
|
<div>
|
|
<span className="font-medium text-muted-foreground">Content: </span>
|
|
<span>{result.content_length} characters</span>
|
|
</div>
|
|
)}
|
|
{result.content_preview && (
|
|
<div>
|
|
<span className="font-medium text-muted-foreground">Preview: </span>
|
|
<span className="text-muted-foreground italic">{result.content_preview}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const CreateNotionPageToolUI = makeAssistantToolUI<
|
|
{ title: string; content: string },
|
|
CreateNotionPageResult
|
|
>({
|
|
toolName: "create_notion_page",
|
|
render: function CreateNotionPageUI({ args, result, status }) {
|
|
if (status.type === "running") {
|
|
return (
|
|
<div className="my-4 flex max-w-md items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
|
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground">Preparing Notion page...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
if (isInterruptResult(result)) {
|
|
return (
|
|
<ApprovalCard
|
|
args={args}
|
|
interruptData={result}
|
|
onDecision={(decision) => {
|
|
const event = new CustomEvent("hitl-decision", {
|
|
detail: { decisions: [decision] },
|
|
});
|
|
window.dispatchEvent(event);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <SuccessCard result={result as SuccessResult} />;
|
|
},
|
|
});
|