diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index e173cfdf2..c2bc096b2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -39,7 +39,6 @@ import { Thread } from "@/components/assistant-ui/thread"; import { MobileEditorPanel } from "@/components/editor-panel/editor-panel"; import { MobileHitlEditPanel } from "@/components/hitl-edit-panel/hitl-edit-panel"; import { MobileReportPanel } from "@/components/report-panel/report-panel"; -import { Skeleton } from "@/components/ui/skeleton"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useMessagesSync } from "@/hooks/use-messages-sync"; import { documentsApiService } from "@/lib/apis/documents-api.service"; @@ -143,6 +142,8 @@ const TOOLS_WITH_UI = new Set([ "delete_linear_issue", "create_google_drive_file", "delete_google_drive_file", + "create_onedrive_file", + "delete_onedrive_file", "create_calendar_event", "update_calendar_event", "delete_calendar_event", @@ -886,6 +887,7 @@ export default function NewChatPage() { currentThread, currentUser, disabledTools, + updateChatTabTitle, ] ); diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 9fefecb1c..7be3932af 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -39,6 +39,10 @@ import { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI, } from "@/components/tool-ui/google-drive"; +import { + CreateOneDriveFileToolUI, + DeleteOneDriveFileToolUI, +} from "@/components/tool-ui/onedrive"; import { CreateJiraIssueToolUI, DeleteJiraIssueToolUI, @@ -96,6 +100,8 @@ const AssistantMessageInner: FC = () => { delete_linear_issue: DeleteLinearIssueToolUI, create_google_drive_file: CreateGoogleDriveFileToolUI, delete_google_drive_file: DeleteGoogleDriveFileToolUI, + create_onedrive_file: CreateOneDriveFileToolUI, + delete_onedrive_file: DeleteOneDriveFileToolUI, create_calendar_event: CreateCalendarEventToolUI, update_calendar_event: UpdateCalendarEventToolUI, delete_calendar_event: DeleteCalendarEventToolUI, diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index f6cdad692..2e4ea82ef 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -17,6 +17,7 @@ export { export { GeneratePodcastToolUI } from "./generate-podcast"; export { GenerateReportToolUI } from "./generate-report"; export { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "./google-drive"; +export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive"; export { Image, ImageErrorBoundary, diff --git a/surfsense_web/components/tool-ui/onedrive/create-file.tsx b/surfsense_web/components/tool-ui/onedrive/create-file.tsx new file mode 100644 index 000000000..5af1c3d94 --- /dev/null +++ b/surfsense_web/components/tool-ui/onedrive/create-file.tsx @@ -0,0 +1,298 @@ +"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 OneDriveAccount { + id: number; + name: string; + user_email?: string; + auth_expired?: boolean; +} + +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?: OneDriveAccount[]; + 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 CreateOneDriveFileResult = 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; 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 defaultAccountId = useMemo(() => { + if (validAccounts.length === 1) return String(validAccounts[0].id); + return ""; + }, [validAccounts]); + + const [selectedAccountId, setSelectedAccountId] = useState(defaultAccountId); + + 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 handleApprove = useCallback(() => { + if (phase !== "pending" || isPanelOpen || !canApprove) 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: { + ...args, + ...(pendingEdits && { name: pendingEdits.name, content: pendingEdits.content }), + connector_id: selectedAccountId ? Number(selectedAccountId) : null, + }, + }, + }); + }, [phase, isPanelOpen, canApprove, allowedDecisions, pendingEdits, setProcessing, onDecision, interruptData, args, selectedAccountId]); + + 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" ? "OneDrive File Rejected" : phase === "processing" || phase === "complete" ? "OneDrive File Approved" : "Create OneDrive 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 ? ( +
+

OneDrive Account *

+ +
+ ) : null} +
+ + )} + +
+
+ {(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 OneDrive file

+
+
+

{result.message}

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

OneDrive authentication expired

+
+
+

{result.message}

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

{result.message || "OneDrive file created successfully"}

+
+
+
+
+ + {result.name} +
+ {result.web_url && ( + + )} +
+
+ ); +} + +export const CreateOneDriveFileToolUI = ({ args, result }: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => { + 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/onedrive/index.ts b/surfsense_web/components/tool-ui/onedrive/index.ts new file mode 100644 index 000000000..4872112ba --- /dev/null +++ b/surfsense_web/components/tool-ui/onedrive/index.ts @@ -0,0 +1,2 @@ +export { CreateOneDriveFileToolUI } from "./create-file"; +export { DeleteOneDriveFileToolUI } from "./trash-file"; diff --git a/surfsense_web/components/tool-ui/onedrive/trash-file.tsx b/surfsense_web/components/tool-ui/onedrive/trash-file.tsx new file mode 100644 index 000000000..b5efd4fab --- /dev/null +++ b/surfsense_web/components/tool-ui/onedrive/trash-file.tsx @@ -0,0 +1,219 @@ +"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 OneDriveAccount { + id: number; + name: string; + user_email?: string; + auth_expired?: boolean; +} + +interface OneDriveFile { + file_id: string; + name: string; + document_id?: number; + web_url?: string; +} + +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?: OneDriveAccount; file?: OneDriveFile; 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 DeleteOneDriveFileResult = 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_id: file?.file_id, connector_id: account?.id, delete_from_kb: deleteFromKb }, + }, + }); + }, [phase, setProcessing, onDecision, interruptData, file?.file_id, 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" ? "OneDrive File Deletion Rejected" : phase === "processing" || phase === "complete" ? "OneDrive File Deletion Approved" : "Delete OneDrive File"} +

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

File trashed

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

File deletion was cancelled

+ ) : ( +

Requires your approval to proceed

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

{context.error}

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

OneDrive Account

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

File to Delete

+
+
{file.name}
+ {file.web_url && ( + Open in OneDrive + )} +
+
+ )} + + )} +
+ + )} + + {phase === "pending" && ( + <> +
+
+

The file will be moved to the OneDrive recycle bin. You can restore it within 93 days.

+
+ 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 ( +
+

OneDrive authentication expired

+
+

{result.message}

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

{result.message || "File moved to recycle bin"}

+ {result.deleted_from_kb && ( + <> +
+
Also removed from knowledge base
+ + )} +
+ ); +} + +export const DeleteOneDriveFileToolUI = ({ result }: ToolCallMessagePartProps<{ file_name: string; delete_from_kb?: boolean }, DeleteOneDriveFileResult>) => { + 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 ; +};