import { useContext, useEffect, useState } from "react"; import Modal from "@moduletrace/twui/elements/Modal"; import H2 from "@moduletrace/twui/layout/H2"; import Span from "@moduletrace/twui/layout/Span"; import Stack from "@moduletrace/twui/layout/Stack"; import Input from "@moduletrace/twui/form/Input"; import Link from "@moduletrace/twui/layout/Link"; import { FolderOpen } from "lucide-react"; import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; import type { APIResponseObject } from "@moduletrace/bunext/types"; import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; import type { BotWithLastMessage } from "@/src/types"; import HeaderIconButton from "@/src/components/general/header-icon-button"; import { AppContext } from "@/src/pages/__root"; import useDebouncedValue from "@/src/hooks/use-debounced-value"; import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import useBotFiles from "../(hooks)/use-bot-files"; import FileRow from "./file-row"; import HeaderMenuItem from "./header-menu-item"; type Props = { bot: BotWithLastMessage; workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null; /** * A row in the conversation header's menu instead of an icon beside it. */ menu?: boolean; on_open?: () => void; }; /** * The files a conversation works with: a bot's own folder, or the * workspace's shared one. Each can be opened or deleted (a crew manager * only). The list loads when the modal opens, and search (by name or path, * as the user types) goes through the same endpoint, so the page props * stay small. */ export default function BotFilesModal({ bot, workspace, menu, on_open }: Props) { const { pageProps } = useContext(AppContext); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const query = useDebouncedValue({ value: search.trim() }); const [busy_id, setBusyId] = useState(); const [status, setStatus] = useState(); const { data, loading, error, reload } = useBotFiles({ bot_id: Number(bot.id), workspace_id: workspace?.id ? Number(workspace.id) : null, enabled: open, search: query, }); const can_manage = canManageCrew({ user_types: pageProps?.user_types }); const name = bot.name || "This bot"; const title = workspace?.id ? `${workspace.name || "Workspace"} files` : `${name}'s files`; useEffect(() => { if (open) on_open?.(); }, [open]); const run = async ({ url, payload, file_id, done, }: { url: string; payload: Record; file_id: number; done: string; }) => { setBusyId(file_id); setStatus(undefined); const res = await fetchApi<{}, APIResponseObject>(url, { method: "POST", body: payload, }).catch(() => null); setBusyId(undefined); setStatus(res?.success ? done : res?.msg || `That didn't work.`); if (res?.success) await reload({ search: query }); }; const files = data?.files || []; const usage = data?.usage; return ( ) : ( ) } targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }} open={open} setOpen={setOpen} className="max-w-xl bg-paper p-6" >

{title}

{workspace?.id ? `Files every bot here can find and read. They're kept with the app, not on a computer.` : `What ${name} has saved: documents you sent, files it wrote and images it made. They're kept with the app, not on a computer.${can_manage ? " Deleting one removes it for good." : ""}`} {usage ? ( {`${usage.files} file(s), ${(usage.bytes / (1024 * 1024)).toFixed(1)} MB of ${ data?.quota_mb || 0 } MB used in this folder.`} ) : null} Browse this folder in Files
setSearch(event.target.value)} clearInputProps={{ onClick: () => setSearch("") }} /> {files.length ? ( files.map((file) => ( run({ url: `/api/admin/bots/delete-file`, payload: { file_id: file.id }, file_id: file.id, done: `${file.name} is deleted.`, }) } /> )) ) : ( {loading ? "Loading files..." : error || (query ? `Nothing matches "${query}".` : "No files yet.")} )} {status ? ( {status} ) : null}
); }