diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 62407bbf7..24f04dbd1 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -69,6 +69,7 @@ import { DeleteNotionPageToolUI, UpdateNotionPageToolUI, } from "@/components/tool-ui/notion"; +import { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "@/components/tool-ui/dropbox"; import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive"; import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute"; import { @@ -261,6 +262,8 @@ const AssistantMessageInner: FC = () => { delete_google_drive_file: DeleteGoogleDriveFileToolUI, create_onedrive_file: CreateOneDriveFileToolUI, delete_onedrive_file: DeleteOneDriveFileToolUI, + create_dropbox_file: CreateDropboxFileToolUI, + delete_dropbox_file: DeleteDropboxFileToolUI, create_calendar_event: CreateCalendarEventToolUI, update_calendar_event: UpdateCalendarEventToolUI, delete_calendar_event: DeleteCalendarEventToolUI, diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 3826f8a80..c86be6f13 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -1345,6 +1345,12 @@ const TOOL_GROUPS: ToolGroup[] = [ connectorIcon: "onedrive", tooltip: "Create and delete files in OneDrive.", }, + { + label: "Dropbox", + tools: ["create_dropbox_file", "delete_dropbox_file"], + connectorIcon: "dropbox", + tooltip: "Create and delete files in Dropbox.", + }, { label: "Notion", tools: ["create_notion_page", "update_notion_page", "delete_notion_page"], diff --git a/surfsense_web/components/tool-ui/dropbox/create-file.tsx b/surfsense_web/components/tool-ui/dropbox/create-file.tsx new file mode 100644 index 000000000..e694666d7 --- /dev/null +++ b/surfsense_web/components/tool-ui/dropbox/create-file.tsx @@ -0,0 +1,478 @@ +"use client"; + +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { useSetAtom } from "jotai"; +import { CornerDownLeftIcon, FileIcon, Pen } from "lucide-react"; +import { useCallback, useEffect, useMemo, 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useHitlPhase } from "@/hooks/use-hitl-phase"; + +interface DropboxAccount { + id: number; + name: string; + user_email?: string; + auth_expired?: boolean; +} + +interface SupportedType { + value: string; + label: string; +} + +interface InterruptResult { + __interrupt__: true; + __decided__?: "approve" | "reject" | "edit"; + __completed__?: boolean; + action_requests: Array<{ name: string; args: Record }>; + review_configs: Array<{ + action_name: string; + allowed_decisions: Array<"approve" | "edit" | "reject">; + }>; + context?: { + accounts?: DropboxAccount[]; + parent_folders?: Record>; + supported_types?: SupportedType[]; + error?: string; + }; +} + +interface SuccessResult { + status: "success"; + file_id: string; + name: string; + web_url?: string; + message?: string; +} + +interface ErrorResult { + status: "error"; + message: string; +} + +interface AuthErrorResult { + status: "auth_error"; + message: string; + connector_type?: string; +} + +type CreateDropboxFileResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult; + +function isInterruptResult(result: unknown): result is InterruptResult { + return ( + typeof result === "object" && + result !== null && + "__interrupt__" in result && + (result as InterruptResult).__interrupt__ === true + ); +} + +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 ApprovalCard({ + args, + interruptData, + onDecision, +}: { + args: { name: string; file_type?: string; content?: string }; + interruptData: InterruptResult; + onDecision: (decision: { + type: "approve" | "reject" | "edit"; + message?: string; + edited_action?: { name: string; args: Record }; + }) => void; +}) { + const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); + const [isPanelOpen, setIsPanelOpen] = useState(false); + const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); + const [pendingEdits, setPendingEdits] = useState<{ name: string; content: string } | null>(null); + + const accounts = interruptData.context?.accounts ?? []; + const validAccounts = accounts.filter((a) => !a.auth_expired); + const expiredAccounts = accounts.filter((a) => a.auth_expired); + const supportedTypes = interruptData.context?.supported_types ?? [ + { value: "paper", label: "Dropbox Paper (.paper)" }, + { value: "docx", label: "Word Document (.docx)" }, + ]; + + const defaultAccountId = useMemo(() => { + if (validAccounts.length === 1) return String(validAccounts[0].id); + return ""; + }, [validAccounts]); + + const [selectedAccountId, setSelectedAccountId] = useState(defaultAccountId); + const [parentFolderPath, setParentFolderPath] = useState("__root__"); + const [selectedFileType, setSelectedFileType] = useState(args.file_type ?? "paper"); + + const parentFolders = interruptData.context?.parent_folders ?? {}; + const availableParentFolders = useMemo(() => { + if (!selectedAccountId) return []; + return parentFolders[Number(selectedAccountId)] ?? []; + }, [selectedAccountId, parentFolders]); + + const handleAccountChange = useCallback((value: string) => { + setSelectedAccountId(value); + setParentFolderPath("__root__"); + }, []); + + const isNameValid = useMemo(() => { + const name = pendingEdits?.name ?? args.name; + return name && typeof name === "string" && name.trim().length > 0; + }, [pendingEdits?.name, args.name]); + + const canApprove = !!selectedAccountId && isNameValid; + const reviewConfig = interruptData.review_configs?.[0]; + const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; + const canEdit = allowedDecisions.includes("edit"); + + const fileTypeLabel = supportedTypes.find((t) => t.value === selectedFileType)?.label ?? selectedFileType; + + const handleApprove = useCallback(() => { + if (phase !== "pending" || isPanelOpen || !canApprove) return; + if (!allowedDecisions.includes("approve")) return; + const isEdited = pendingEdits !== null || selectedFileType !== (args.file_type ?? "paper"); + setProcessing(); + onDecision({ + type: isEdited ? "edit" : "approve", + edited_action: { + name: interruptData.action_requests[0].name, + args: { + ...args, + ...(pendingEdits && { name: pendingEdits.name, content: pendingEdits.content }), + file_type: selectedFileType, + connector_id: selectedAccountId ? Number(selectedAccountId) : null, + parent_folder_path: parentFolderPath === "__root__" ? null : parentFolderPath, + }, + }, + }); + }, [ + phase, + setProcessing, + isPanelOpen, + canApprove, + allowedDecisions, + onDecision, + interruptData, + args, + selectedAccountId, + parentFolderPath, + pendingEdits, + selectedFileType, + ]); + + 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 ( +
+
+
+

+ {phase === "rejected" + ? "Dropbox File Rejected" + : phase === "processing" || phase === "complete" + ? "Dropbox File Approved" + : "Create Dropbox File"} +

+ {phase === "processing" ? ( + + ) : phase === "complete" ? ( +

+ {pendingEdits ? "File created with your changes" : "File created"} +

+ ) : phase === "rejected" ? ( +

File creation was cancelled

+ ) : ( +

+ Requires your approval to proceed +

+ )} +
+ {phase === "pending" && canEdit && ( + + )} +
+ + {phase === "pending" && interruptData.context && ( + <> +
+
+ {interruptData.context.error ? ( +

{interruptData.context.error}

+ ) : ( + <> + {accounts.length > 0 && ( +
+

+ Dropbox Account * +

+ +
+ )} + +
+

File Type

+ +
+ + {selectedAccountId && ( +
+

Parent Folder

+ + {availableParentFolders.length === 0 && ( +

+ No folders found. File will be created at Dropbox root. +

+ )} +
+ )} + + )} +
+ + )} + +
+
+ {(pendingEdits?.name ?? args.name) != null && ( +

+ {String(pendingEdits?.name ?? args.name)} +

+ )} + {(pendingEdits?.content ?? args.content) != null && ( +
+ +
+ )} +
+ + {phase === "pending" && ( + <> +
+
+ {allowedDecisions.includes("approve") && ( + + )} + {allowedDecisions.includes("reject") && ( + + )} +
+ + )} +
+ ); +} + +function ErrorCard({ result }: { result: ErrorResult }) { + return ( +
+
+

Failed to create Dropbox file

+
+
+
+

{result.message}

+
+
+ ); +} + +function AuthErrorCard({ result }: { result: AuthErrorResult }) { + return ( +
+
+

Dropbox authentication expired

+
+
+
+

{result.message}

+
+
+ ); +} + +function SuccessCard({ result }: { result: SuccessResult }) { + return ( +
+
+

+ {result.message || "Dropbox file created successfully"} +

+
+
+
+
+ + {result.name} +
+ {result.web_url && ( + + )} +
+
+ ); +} + +export const CreateDropboxFileToolUI = ({ + args, + result, +}: ToolCallMessagePartProps<{ name: string; file_type?: string; content?: string }, CreateDropboxFileResult>) => { + if (!result) return null; + if (isInterruptResult(result)) { + return ( + { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) + ); + }} + /> + ); + } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) + return null; + if (isAuthErrorResult(result)) return ; + if (isErrorResult(result)) return ; + return ; +}; diff --git a/surfsense_web/components/tool-ui/dropbox/index.ts b/surfsense_web/components/tool-ui/dropbox/index.ts new file mode 100644 index 000000000..5103cfa44 --- /dev/null +++ b/surfsense_web/components/tool-ui/dropbox/index.ts @@ -0,0 +1,2 @@ +export { CreateDropboxFileToolUI } from "./create-file"; +export { DeleteDropboxFileToolUI } from "./trash-file"; diff --git a/surfsense_web/components/tool-ui/dropbox/trash-file.tsx b/surfsense_web/components/tool-ui/dropbox/trash-file.tsx new file mode 100644 index 000000000..50207d997 --- /dev/null +++ b/surfsense_web/components/tool-ui/dropbox/trash-file.tsx @@ -0,0 +1,331 @@ +"use client"; + +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { CornerDownLeftIcon, InfoIcon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { TextShimmerLoader } from "@/components/prompt-kit/loader"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useHitlPhase } from "@/hooks/use-hitl-phase"; + +interface DropboxAccount { + id: number; + name: string; + user_email?: string; + auth_expired?: boolean; +} + +interface DropboxFile { + file_id: string; + file_path: string; + name: string; + document_id?: number; +} + +interface InterruptResult { + __interrupt__: true; + __decided__?: "approve" | "reject"; + __completed__?: boolean; + action_requests: Array<{ name: string; args: Record }>; + review_configs: Array<{ action_name: string; allowed_decisions: Array<"approve" | "reject"> }>; + context?: { account?: DropboxAccount; file?: DropboxFile; error?: string }; +} + +interface SuccessResult { + status: "success"; + file_id: string; + message?: string; + deleted_from_kb?: boolean; +} +interface ErrorResult { + status: "error"; + message: string; +} +interface NotFoundResult { + status: "not_found"; + message: string; +} +interface AuthErrorResult { + status: "auth_error"; + message: string; + connector_type?: string; +} + +type DeleteDropboxFileResult = + | InterruptResult + | SuccessResult + | ErrorResult + | NotFoundResult + | AuthErrorResult; + +function isInterruptResult(result: unknown): result is InterruptResult { + return ( + typeof result === "object" && + result !== null && + "__interrupt__" in result && + (result as InterruptResult).__interrupt__ === true + ); +} +function isErrorResult(result: unknown): result is ErrorResult { + return ( + typeof result === "object" && + result !== null && + "status" in result && + (result as ErrorResult).status === "error" + ); +} +function isNotFoundResult(result: unknown): result is NotFoundResult { + return ( + typeof result === "object" && + result !== null && + "status" in result && + (result as NotFoundResult).status === "not_found" + ); +} +function isAuthErrorResult(result: unknown): result is AuthErrorResult { + return ( + typeof result === "object" && + result !== null && + "status" in result && + (result as AuthErrorResult).status === "auth_error" + ); +} + +function ApprovalCard({ + interruptData, + onDecision, +}: { + interruptData: InterruptResult; + onDecision: (decision: { + type: "approve" | "reject"; + message?: string; + edited_action?: { name: string; args: Record }; + }) => void; +}) { + const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); + const [deleteFromKb, setDeleteFromKb] = useState(false); + + const context = interruptData.context; + const account = context?.account; + const file = context?.file; + + const handleApprove = useCallback(() => { + if (phase !== "pending") return; + setProcessing(); + onDecision({ + type: "approve", + edited_action: { + name: interruptData.action_requests[0].name, + args: { file_path: file?.file_path, connector_id: account?.id, delete_from_kb: deleteFromKb }, + }, + }); + }, [phase, setProcessing, onDecision, interruptData, file?.file_path, account?.id, deleteFromKb]); + + 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 ( +
+
+
+

+ {phase === "rejected" + ? "Dropbox File Deletion Rejected" + : phase === "processing" || phase === "complete" + ? "Dropbox File Deletion Approved" + : "Delete Dropbox File"} +

+ {phase === "processing" ? ( + + ) : phase === "complete" ? ( +

File deleted

+ ) : phase === "rejected" ? ( +

File deletion was cancelled

+ ) : ( +

+ Requires your approval to proceed +

+ )} +
+
+ + {phase !== "rejected" && context && ( + <> +
+
+ {context.error ? ( +

{context.error}

+ ) : ( + <> + {account && ( +
+

Dropbox Account

+
+ {account.name} +
+
+ )} + {file && ( +
+

File to Delete

+
+
{file.name}
+ {file.file_path && ( +
{file.file_path}
+ )} +
+
+ )} + + )} +
+ + )} + + {phase === "pending" && ( + <> +
+
+

+ The file will be permanently deleted from Dropbox. +

+
+ setDeleteFromKb(v === true)} + className="shrink-0" + /> + +
+
+ + )} + + {phase === "pending" && ( + <> +
+
+ + +
+ + )} +
+ ); +} + +function ErrorCard({ result }: { result: ErrorResult }) { + return ( +
+
+

Failed to delete file

+
+
+
+

{result.message}

+
+
+ ); +} + +function NotFoundCard({ result }: { result: NotFoundResult }) { + return ( +
+
+ +

{result.message}

+
+
+ ); +} + +function AuthErrorCard({ result }: { result: AuthErrorResult }) { + return ( +
+
+

Dropbox authentication expired

+
+
+
+

{result.message}

+
+
+ ); +} + +function SuccessCard({ result }: { result: SuccessResult }) { + return ( +
+
+

+ {result.message || "File deleted from Dropbox"} +

+
+ {result.deleted_from_kb && ( + <> +
+
+ + Also removed from knowledge base + +
+ + )} +
+ ); +} + +export const DeleteDropboxFileToolUI = ({ + result, +}: ToolCallMessagePartProps< + { file_name: string; delete_from_kb?: boolean }, + DeleteDropboxFileResult +>) => { + if (!result) return null; + if (isInterruptResult(result)) { + return ( + { + window.dispatchEvent( + new CustomEvent("hitl-decision", { detail: { decisions: [decision] } }) + ); + }} + /> + ); + } + if ( + typeof result === "object" && + result !== null && + "status" in result && + (result as { status: string }).status === "rejected" + ) + return null; + if (isAuthErrorResult(result)) return ; + if (isNotFoundResult(result)) return ; + if (isErrorResult(result)) return ; + return ; +}; diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index 517a8a290..855f50620 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -32,6 +32,7 @@ export { UpdateLinearIssueToolUI, } from "./linear"; export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion"; +export { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "./dropbox"; export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive"; export { Plan,