mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-02 12:22:40 +02:00
Refactored the context types for create, delete, and update functionalities across multiple tools including Confluence, Dropbox, Gmail, Google Calendar, Jira, Linear, Notion, and OneDrive to utilize a consistent type definition. This change enhances code clarity and maintains uniformity in handling user approvals by integrating the useHitlDecision hook for decision dispatching.
411 lines
11 KiB
TypeScript
411 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
|
import { useSetAtom } from "jotai";
|
|
import { CornerDownLeftIcon, Pen } from "lucide-react";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
|
import { PlateEditor } from "@/components/editor/plate-editor";
|
|
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
|
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
|
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
|
|
|
type NotionUpdatePageContext = {
|
|
account?: {
|
|
id: number;
|
|
name: string;
|
|
workspace_id: string | null;
|
|
workspace_name: string;
|
|
workspace_icon: string;
|
|
};
|
|
page_id?: string;
|
|
current_title?: string;
|
|
document_id?: number;
|
|
indexed_at?: string;
|
|
error?: string;
|
|
}
|
|
|
|
interface SuccessResult {
|
|
status: "success";
|
|
page_id: string;
|
|
title: string;
|
|
url: string;
|
|
content_preview?: string;
|
|
content_length?: number;
|
|
message?: string;
|
|
}
|
|
|
|
interface ErrorResult {
|
|
status: "error";
|
|
message: string;
|
|
}
|
|
|
|
interface InfoResult {
|
|
status: "not_found";
|
|
message: string;
|
|
}
|
|
|
|
interface AuthErrorResult {
|
|
status: "auth_error";
|
|
message: string;
|
|
connector_id?: number;
|
|
connector_type: string;
|
|
}
|
|
|
|
type UpdateNotionPageResult =
|
|
| InterruptResult<NotionUpdatePageContext>
|
|
| SuccessResult
|
|
| ErrorResult
|
|
| InfoResult
|
|
| AuthErrorResult;
|
|
|
|
function isErrorResult(result: unknown): result is ErrorResult {
|
|
return (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
"status" in result &&
|
|
(result as ErrorResult).status === "error"
|
|
);
|
|
}
|
|
|
|
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
|
|
return (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
"status" in result &&
|
|
(result as AuthErrorResult).status === "auth_error"
|
|
);
|
|
}
|
|
|
|
function isInfoResult(result: unknown): result is InfoResult {
|
|
return (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
"status" in result &&
|
|
(result as InfoResult).status === "not_found"
|
|
);
|
|
}
|
|
|
|
function ApprovalCard({
|
|
args,
|
|
interruptData,
|
|
onDecision,
|
|
}: {
|
|
args: Record<string, unknown>;
|
|
interruptData: InterruptResult<NotionUpdatePageContext>;
|
|
onDecision: (decision: HitlDecision) => void;
|
|
}) {
|
|
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
|
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
|
const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom);
|
|
const [pendingEdits, setPendingEdits] = useState<{ content: string } | null>(null);
|
|
|
|
const account = interruptData.context?.account;
|
|
const currentTitle = interruptData.context?.current_title;
|
|
|
|
const reviewConfig = interruptData.review_configs[0];
|
|
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
|
const canEdit = allowedDecisions.includes("edit");
|
|
|
|
const handleApprove = useCallback(() => {
|
|
if (phase !== "pending") return;
|
|
if (isPanelOpen) return;
|
|
if (!allowedDecisions.includes("approve")) return;
|
|
const isEdited = pendingEdits !== null;
|
|
setProcessing();
|
|
onDecision({
|
|
type: isEdited ? "edit" : "approve",
|
|
edited_action: {
|
|
name: interruptData.action_requests[0].name,
|
|
args: {
|
|
page_id: args.page_id,
|
|
content: pendingEdits?.content ?? args.content,
|
|
connector_id: account?.id,
|
|
},
|
|
},
|
|
});
|
|
}, [
|
|
phase,
|
|
isPanelOpen,
|
|
allowedDecisions,
|
|
setProcessing,
|
|
onDecision,
|
|
interruptData,
|
|
args,
|
|
account?.id,
|
|
pendingEdits,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
|
handleApprove();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handler);
|
|
return () => window.removeEventListener("keydown", handler);
|
|
}, [handleApprove]);
|
|
|
|
return (
|
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
|
|
<div>
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{phase === "rejected"
|
|
? "Notion Page Update Rejected"
|
|
: phase === "processing" || phase === "complete"
|
|
? "Notion Page Update Approved"
|
|
: "Update Notion Page"}
|
|
</p>
|
|
{phase === "processing" ? (
|
|
<TextShimmerLoader
|
|
text={pendingEdits ? "Updating page with your changes" : "Updating page"}
|
|
size="sm"
|
|
/>
|
|
) : phase === "complete" ? (
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{pendingEdits ? "Page updated with your changes" : "Page updated"}
|
|
</p>
|
|
) : phase === "rejected" ? (
|
|
<p className="text-xs text-muted-foreground mt-0.5">Page update was cancelled</p>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Requires your approval to proceed
|
|
</p>
|
|
)}
|
|
</div>
|
|
{phase === "pending" && canEdit && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
|
|
onClick={() => {
|
|
setIsPanelOpen(true);
|
|
openHitlEditPanel({
|
|
title: currentTitle ?? "",
|
|
content: pendingEdits?.content ?? String(args.content ?? ""),
|
|
toolName: "Notion Page",
|
|
onSave: (_, newContent) => {
|
|
setIsPanelOpen(false);
|
|
setPendingEdits({ content: newContent });
|
|
},
|
|
onClose: () => setIsPanelOpen(false),
|
|
});
|
|
}}
|
|
>
|
|
<Pen className="size-3.5" />
|
|
Edit
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Context section — real UI in pending/processing/complete */}
|
|
{phase !== "rejected" && interruptData.context && (
|
|
<>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4 space-y-4 select-none">
|
|
{interruptData.context.error ? (
|
|
<p className="text-sm text-destructive">{interruptData.context.error}</p>
|
|
) : (
|
|
<>
|
|
{account && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Notion Account</p>
|
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
|
{account.workspace_name}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentTitle && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">Current Page</p>
|
|
<div className="w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm">
|
|
{currentTitle}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Content preview */}
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 pt-3">
|
|
{(pendingEdits?.content ?? args.content) != null ? (
|
|
<div
|
|
className="max-h-[7rem] overflow-hidden text-sm"
|
|
style={{
|
|
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
|
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
|
}}
|
|
>
|
|
<PlateEditor
|
|
markdown={String(pendingEdits?.content ?? args.content)}
|
|
readOnly
|
|
preset="readonly"
|
|
editorVariant="none"
|
|
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground italic pb-3">No content update specified</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action buttons - only shown when pending */}
|
|
{phase === "pending" && (
|
|
<>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4 flex items-center gap-2 select-none">
|
|
{allowedDecisions.includes("approve") && (
|
|
<Button
|
|
size="sm"
|
|
className="rounded-lg gap-1.5"
|
|
onClick={handleApprove}
|
|
disabled={isPanelOpen}
|
|
>
|
|
Approve
|
|
<CornerDownLeftIcon className="size-3 opacity-60" />
|
|
</Button>
|
|
)}
|
|
{allowedDecisions.includes("reject") && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="rounded-lg text-muted-foreground"
|
|
disabled={isPanelOpen}
|
|
onClick={() => {
|
|
setRejected();
|
|
onDecision({ type: "reject", message: "User rejected the action." });
|
|
}}
|
|
>
|
|
Reject
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
|
|
return (
|
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
|
<div className="px-5 pt-5 pb-4">
|
|
<p className="text-sm font-semibold text-destructive">Notion authentication expired</p>
|
|
</div>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4">
|
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ErrorCard({ result }: { result: ErrorResult }) {
|
|
return (
|
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
|
<div className="px-5 pt-5 pb-4">
|
|
<p className="text-sm font-semibold text-destructive">Failed to update Notion page</p>
|
|
</div>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4">
|
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoCard({ result }: { result: InfoResult }) {
|
|
return (
|
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
|
<div className="px-5 pt-5 pb-4">
|
|
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">Page not found</p>
|
|
</div>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4">
|
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SuccessCard({ result }: { result: SuccessResult }) {
|
|
return (
|
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
|
<div className="px-5 pt-5 pb-4">
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{result.message || "Notion page updated successfully"}
|
|
</p>
|
|
</div>
|
|
<div className="mx-5 h-px bg-border/50" />
|
|
<div className="px-5 py-4 space-y-2 text-xs">
|
|
<div>
|
|
<span className="font-medium text-muted-foreground">Title: </span>
|
|
<span>{result.title}</span>
|
|
</div>
|
|
{result.url && (
|
|
<div>
|
|
<a
|
|
href={result.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline"
|
|
>
|
|
Open in Notion
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const UpdateNotionPageToolUI = ({
|
|
args,
|
|
result,
|
|
}: ToolCallMessagePartProps<{ page_title: string; content: string }, UpdateNotionPageResult>) => {
|
|
const { dispatch } = useHitlDecision();
|
|
|
|
if (!result) return null;
|
|
|
|
if (isInterruptResult(result)) {
|
|
return (
|
|
<ApprovalCard
|
|
args={args}
|
|
interruptData={result as InterruptResult<NotionUpdatePageContext>}
|
|
onDecision={(decision) => dispatch([decision])}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (
|
|
typeof result === "object" &&
|
|
result !== null &&
|
|
"status" in result &&
|
|
(result as { status: string }).status === "rejected"
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
if (isInfoResult(result)) {
|
|
return <InfoCard result={result} />;
|
|
}
|
|
|
|
if (isAuthErrorResult(result)) {
|
|
return <AuthErrorCard result={result} />;
|
|
}
|
|
|
|
if (isErrorResult(result)) {
|
|
return <ErrorCard result={result} />;
|
|
}
|
|
|
|
return <SuccessCard result={result as SuccessResult} />;
|
|
};
|