Add bots files handling logic

This commit is contained in:
2026-10-21 17:56:09 +01:00
parent b0af4db1bf
commit d17a4a7bdd
97 changed files with 5843 additions and 41 deletions
@@ -0,0 +1,64 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useCallback, useContext, useEffect, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import type { BotFileScopesPayload } from "@/src/types";
type Params = {
bot_id: number;
workspace_id?: number | null;
/**
* Load the full list only once the modal opens (the page already has
* the first page of files).
*/
enabled?: boolean;
};
/**
* A conversation's files: the page's own copy first, then the folder and
* search the modal asks for. Saves and deletes refresh it.
*/
export default function useBotFiles({ bot_id, workspace_id, enabled = true }: Params) {
const { pageProps } = useContext(AppContext);
const [data, setData] = useState<BotFileScopesPayload | undefined>(
pageProps?.bot_files || undefined,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const load = useCallback(
async (params?: { folder?: string; search?: string }) => {
setLoading(true);
setError(undefined);
const query = new URLSearchParams();
if (workspace_id) query.set("workspace_id", String(workspace_id));
else query.set("bot_id", String(bot_id));
if (params?.folder) query.set("folder", params.folder);
if (params?.search) query.set("search", params.search);
const res = await fetchApi<{}, APIResponseObject<BotFileScopesPayload>>(
`/api/admin/bots/files?${query.toString()}`,
{ method: "GET" },
).catch(() => null);
setLoading(false);
if (!res?.success || !res.singleRes) {
setError(res?.msg || `Couldn't load the files.`);
return;
}
setData(res.singleRes);
},
[bot_id, workspace_id],
);
useEffect(() => {
if (enabled) load().catch(() => {});
}, [enabled, load]);
return { data, loading, error, reload: load, setData };
}
@@ -0,0 +1,173 @@
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 { 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 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, read again so it can be
* searched, or deleted (a crew manager only). The list loads when the
* modal opens, and search 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 [busy_id, setBusyId] = useState<number>();
const [status, setStatus] = useState<string>();
const { data, loading, error, reload } = useBotFiles({
bot_id: Number(bot.id),
workspace_id: workspace?.id ? Number(workspace.id) : null,
enabled: open,
});
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<string, unknown>;
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 });
};
const files = data?.files || [];
const usage = data?.usage;
return (
<Modal
target={
menu ? (
<HeaderMenuItem
icon={FolderOpen}
title="Files"
description={title}
loading={loading}
/>
) : (
<HeaderIconButton icon={FolderOpen} title={title} loading={loading} />
)
}
targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }}
open={open}
setOpen={setOpen}
className="max-w-xl bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">{title}</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{workspace?.id
? `Files every bot here can read and search. 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." : ""}`}
</Span>
{usage ? (
<Span className="text-[12px] leading-4 text-ink-secondary">
{`${usage.files} file(s), ${(usage.bytes / (1024 * 1024)).toFixed(1)} MB of ${
data?.quota_mb || 0
} MB used in this folder.`}
</Span>
) : null}
</Stack>
<Stack className="w-full mt-4 gap-2! items-stretch">
<Input
placeholder="Search files"
value={search}
onChange={(event) => setSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key == "Enter") reload({ search }).catch(() => {});
}}
clearInputProps={{ onClick: () => reload({}).catch(() => {}) }}
/>
</Stack>
<Stack className="w-full mt-2 gap-0! items-stretch overflow-y-auto max-h-[50vh]">
{files.length ? (
files.map((file) => (
<FileRow
key={file.id}
file={file}
can_manage={can_manage}
busy={busy_id == file.id}
onIngest={() =>
run({
url: `/api/admin/bots/ingest-file`,
payload: { file_id: file.id },
file_id: file.id,
done: `${file.name} will be read again shortly.`,
})
}
onDelete={() =>
run({
url: `/api/admin/bots/delete-file`,
payload: { file_id: file.id },
file_id: file.id,
done: `${file.name} is deleted.`,
})
}
/>
))
) : (
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
{loading
? "Loading files..."
: error || (search ? `Nothing matches "${search}".` : "No files yet.")}
</Span>
)}
</Stack>
{status ? (
<Span role="status" className="mt-3 text-[13px] leading-[18px] text-ink-secondary">
{status}
</Span>
) : null}
</Modal>
);
}
@@ -2,7 +2,7 @@ import { useContext } from "react";
import Paper from "@moduletrace/twui/elements/Paper";
import Stack from "@moduletrace/twui/layout/Stack";
import Divider from "@moduletrace/twui/layout/Divider";
import { History, Settings } from "lucide-react";
import { FolderOpen, History, Settings } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
@@ -10,6 +10,7 @@ import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotComputersModal from "./bot-computers-modal";
import BotFilesModal from "./bot-files-modal";
import BotModelsModal from "./bot-models-modal";
import HeaderMenuItem from "./header-menu-item";
import NewSessionButton from "./new-session-button";
@@ -94,6 +95,12 @@ export default function ConversationHeaderMenu({
workspace_id={workspace?.id || null}
on_open={on_open}
/>
<BotFilesModal
menu
bot={bot}
workspace={workspace}
on_open={on_open}
/>
<BotComputersModal
menu
bot={bot}
@@ -0,0 +1,38 @@
import { useContext } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import { AppContext } from "@/src/pages/__root";
import type { MediaType } from "@/src/types";
import AttachmentIcon from "./attachment-icon";
type Props = {
file_id: number;
};
/**
* A file a bot named in a message (`fileRef`): the name it is saved under,
* linked to its bytes. The conversation's files come with the page, so no
* request is made per chip; a file the user doesn't reach (or one deleted
* since) shows as its id.
*/
export default function FileRefChip({ file_id }: Props) {
const { pageProps } = useContext(AppContext);
const file = pageProps?.bot_files?.files?.find((it) => it.id == file_id);
if (!file) {
return <Span className="font-bold">{`file ${file_id}`}</Span>;
}
return (
<Link
href={file.url}
target="_blank"
rel="noopener noreferrer"
title={`Open ${file.name}`}
className="inline-flex h-6 max-w-full gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink hover:opacity-80"
>
<AttachmentIcon media_type={file.media_type as MediaType} />
<Span className="truncate">{file.name}</Span>
</Link>
);
}
@@ -0,0 +1,89 @@
import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import Button from "@moduletrace/twui/layout/Button";
import { RefreshCw, Trash2 } from "lucide-react";
import type { MediaType, PublicBotFile } from "@/src/types";
import relativeTime from "@/src/utils/relative-time";
import AttachmentIcon from "./attachment-icon";
type Props = {
file: PublicBotFile;
/**
* A crew manager may delete and re-ingest.
*/
can_manage: boolean;
busy?: boolean;
onIngest: () => void;
onDelete: () => void;
};
/**
* One file in the files modal: its type, path, size and age, and what can
* be done with it. A file that isn't searchable says why.
*/
export default function FileRow({ file, can_manage, busy, onIngest, onDelete }: Props) {
return (
<Row className="w-full gap-3! flex-nowrap items-start py-2 border-0 border-b border-solid border-hairline last:border-b-0">
<Row className="mt-0.5 text-ink-secondary">
<AttachmentIcon media_type={file.media_type as MediaType} size={16} />
</Row>
<Stack className="min-w-0 flex-1 gap-0.5!">
<Link
href={file.url}
target="_blank"
rel="noopener noreferrer"
title={`Open ${file.name}`}
className="text-[13px] font-bold leading-5 text-ink break-words hover:opacity-80"
>
{file.path || file.name}
</Link>
<Span className="text-[12px] leading-4 text-ink-secondary">
{[
file.scope == "workspace" ? "Workspace" : "Bot files",
`${Math.max(1, Math.round(file.size / 1024))} KB`,
file.created_at ? relativeTime({ at: file.created_at }) : "",
file.ingest_error
? file.ingest_error
: file.ingested_at
? "searchable"
: "not searchable yet",
]
.filter(Boolean)
.join(" · ")}
</Span>
</Stack>
{can_manage ? (
<Row className="shrink-0 gap-0!">
<Button
title="Read it again so it can be searched"
aria-label="Reingest"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={onIngest}
className="p-1.5!"
>
<RefreshCw size={14} aria-hidden />
</Button>
<Button
title="Delete it"
aria-label="Delete"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={onDelete}
className="p-1.5!"
>
<Trash2 size={14} aria-hidden />
</Button>
</Row>
) : null}
</Row>
);
}
@@ -4,11 +4,13 @@ import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-di
import {
BotRefHrefPrefix,
ComputerRefHrefPrefix,
FileRefHrefPrefix,
TakeOverRefHrefPrefix,
WorkspaceRefHrefPrefix,
} from "@/src/utils/link-computer-refs";
import BotRefChip from "./bot-ref-chip";
import ComputerRefChip from "./computer-ref-chip";
import FileRefChip from "./file-ref-chip";
import TakeOverChip from "./take-over-chip";
import WorkspaceRefChip from "./workspace-ref-chip";
@@ -16,7 +18,8 @@ import WorkspaceRefChip from "./workspace-ref-chip";
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
* becomes its switch button, a take-over request a button that opens the
* computer, a bot it named a link to that bot's conversation, a workspace
* a link to that workspace; anything else opens in a new tab.
* a link to that workspace, a file it saved a chip that opens it; anything
* else opens in a new tab.
*/
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (href?.startsWith(ComputerRefHrefPrefix)) {
@@ -52,6 +55,12 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (workspace_id) return <WorkspaceRefChip workspace_id={workspace_id} />;
}
if (href?.startsWith(FileRefHrefPrefix)) {
const file_id = Number(href.slice(FileRefHrefPrefix.length));
if (file_id) return <FileRefChip file_id={file_id} />;
}
return (
<Link
href={href}