Add bots files handling logic
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -5,6 +5,7 @@ import sanitizeUserBio from "@/src/functions/backend/db/users/sanitize-user-bio"
|
||||
import grabUserPasswordHash from "@/src/functions/backend/auth/grab-user-password-hash";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
|
||||
|
||||
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
|
||||
|
||||
@@ -40,6 +41,7 @@ export default async function grabSettingsServerProps({ req }: Params) {
|
||||
props: {
|
||||
person: sanitizeUserBio({ user: user_res.singleRes }),
|
||||
has_password,
|
||||
storage_quota: await grabStorageQuotaOverview().catch(() => null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { StorageQuotaOverview } from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
|
||||
import SettingsSaveBar from "../../(partials)/settings-save-bar";
|
||||
|
||||
type Props = {
|
||||
storage?: StorageQuotaOverview | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The per-folder storage quota for what bots save (a bot's own folder and
|
||||
* each workspace's shared one). Saved on its own, without a reload.
|
||||
*/
|
||||
export default function StorageQuotaForm({ storage }: Props) {
|
||||
const [overview, setOverview] = useState(storage || undefined);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [value, setValue] = useState(String(storage?.quota_mb ?? ""));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<{ msg?: string; error?: boolean; success?: boolean }>();
|
||||
|
||||
const save = async () => {
|
||||
setLoading(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await fetchApi<{}, APIResponseObject<StorageQuotaOverview>>(
|
||||
"/api/admin/settings/storage-quota",
|
||||
{ method: "POST", body: { quota_mb: Number(value) || 0 } },
|
||||
).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setStatus({ error: true, msg: res?.msg || `Couldn't save the quota.` });
|
||||
return;
|
||||
}
|
||||
|
||||
setOverview(res.singleRes);
|
||||
setValue(String(res.singleRes.quota_mb));
|
||||
setDirty(false);
|
||||
setStatus({ success: true, msg: `Saved: ${res.singleRes.quota_mb} MB per folder.` });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
save().catch(() => {});
|
||||
}}
|
||||
className="w-full gap-3! items-stretch"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<Input
|
||||
label="Megabytes per folder"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
setDirty(true);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
info="A bot's own folder and each workspace's folder each get this much. A write past it is refused, so the bot clears space first."
|
||||
/>
|
||||
{overview ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{`${overview.total_files} file(s) in ${overview.folders} folder(s), ${(overview.total_bytes / (1024 * 1024)).toFixed(1)} MB in total.`}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
<SettingsSaveBar
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
status={status}
|
||||
save_label="Save quota"
|
||||
onDiscard={() => {
|
||||
setValue(String(overview?.quota_mb ?? ""));
|
||||
setDirty(false);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import StorageQuotaForm from "../(partials)/storage-quota-form";
|
||||
|
||||
/**
|
||||
* What bots keep on disk: the quota each of their folders gets. Only a
|
||||
* crew manager sees it, since it's an install-wide limit.
|
||||
*/
|
||||
export default function StorageSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
if (!canManageCrew({ user_types: pageProps?.user_types })) return null;
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Bot files Quota"
|
||||
description="Bots keep the documents, images and reports they make in folders on the app's data volume, never on a computer. This is how much each folder may hold."
|
||||
id="storage"
|
||||
>
|
||||
<StorageQuotaForm storage={pageProps?.storage_quota} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppearanceSection from "./(sections)/appearance-section";
|
||||
import StorageSection from "./(sections)/storage-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
@@ -9,6 +10,7 @@ export default function AdminSettingsGeneralPage() {
|
||||
description="How the app looks and behaves."
|
||||
>
|
||||
<AppearanceSection />
|
||||
<StorageSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
|
||||
import deleteBotFile from "@/src/functions/backend/db/bot-files/delete-bot-file";
|
||||
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
||||
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { DeleteBotFileRequestSchema } from "@/src/schemas/bot-file-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { StorageScope } from "@/src/utils/storage-access";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Deletes one of a conversation's files. A crew manager only, and never a
|
||||
* read-only user.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") throw new AppError(`Method not allowed`);
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.id) return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
if (!canManageCrew({ user_types: user_types || [] })) {
|
||||
throw new AppError(`Only a crew manager can delete files.`);
|
||||
}
|
||||
|
||||
const { file_id } = parseWithSchema({ schema: DeleteBotFileRequestSchema, data: body });
|
||||
|
||||
const file = await grabBotFile({
|
||||
scopes: await reachableScopes({ user_id: user.id, file_id }),
|
||||
file_id,
|
||||
});
|
||||
|
||||
if (!file?.id) throw new AppError(`That file isn't here.`);
|
||||
|
||||
await deleteBotFile({
|
||||
scopes: [{ scope: file.scope as any, owner_id: Number(file.owner_id) }],
|
||||
file_id: file.id,
|
||||
});
|
||||
|
||||
await logEvent({
|
||||
title: "Bot file deleted",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} deleted ${file.path} from ${file.scope} ${file.owner_id}.`,
|
||||
metadata: { file_id: file.id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, msg: `${file.name || "The file"} is deleted.` };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The directories the user reaches: every worker's for a crew manager,
|
||||
* the coordinator's for the super admin, and every workspace it's in.
|
||||
*/
|
||||
async function reachableScopes({
|
||||
user_id,
|
||||
file_id,
|
||||
}: {
|
||||
user_id: number;
|
||||
file_id: number;
|
||||
}): Promise<StorageScope[]> {
|
||||
const { default: BunSQLite } = await import("@moduletrace/bun-sqlite");
|
||||
|
||||
const res = await BunSQLite.sql<{ scope: string; owner_id: number }>({
|
||||
sql: `SELECT scope, owner_id FROM bot_files WHERE id = ? LIMIT 1`,
|
||||
values: [file_id],
|
||||
});
|
||||
|
||||
const file = res.payload?.[0];
|
||||
|
||||
if (!file?.owner_id) return [];
|
||||
|
||||
if (file.scope == "workspace") {
|
||||
const workspace = await grabUserWorkspace({ workspace_id: file.owner_id, user_id });
|
||||
|
||||
return workspace?.id ? [{ scope: "workspace", owner_id: Number(file.owner_id) }] : [];
|
||||
}
|
||||
|
||||
const bot = await grabUserBot({ bot_id: file.owner_id, user_id });
|
||||
|
||||
return bot?.id ? [{ scope: "bot", owner_id: Number(file.owner_id) }] : [];
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
|
||||
import grabBotFileQuotaBytes, {
|
||||
grabBotFileQuotaMb,
|
||||
} from "@/src/functions/backend/db/bot-files/grab-bot-file-quota-bytes";
|
||||
import grabStorageUsage from "@/src/functions/backend/db/bot-files/grab-storage-usage";
|
||||
import listBotFiles, { listBotFolders } from "@/src/functions/backend/db/bot-files/list-bot-files";
|
||||
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
||||
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
|
||||
import type { BotFileScopesPayload } from "@/src/types";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { BotFileScope } from "@/src/dict/bot-file-dict";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* A conversation's files: the bot's own, or a workspace's shared ones.
|
||||
* `?file_id=` returns one (for a message's file chip), else a folder's
|
||||
* files. Only a user who reaches the bot or workspace sees them.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<BotFileScopesPayload>> = async ({
|
||||
req,
|
||||
}) => {
|
||||
try {
|
||||
if (req.method != "GET") {
|
||||
return { success: false, msg: `Method not allowed` };
|
||||
}
|
||||
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const bot_id = Number(url.searchParams.get("bot_id"));
|
||||
const workspace_id = Number(url.searchParams.get("workspace_id"));
|
||||
const file_id = Number(url.searchParams.get("file_id"));
|
||||
const folder = url.searchParams.get("folder") || "";
|
||||
const search = url.searchParams.get("search") || "";
|
||||
|
||||
const scope: { scope: BotFileScope; owner_id: number } | null = workspace_id
|
||||
? (await grabUserWorkspace({ workspace_id, user_id: user.id }))
|
||||
? { scope: "workspace", owner_id: workspace_id }
|
||||
: null
|
||||
: bot_id
|
||||
? (await grabUserBot({ bot_id, user_id: user.id }))
|
||||
? { scope: "bot", owner_id: bot_id }
|
||||
: null
|
||||
: null;
|
||||
|
||||
if (!scope) {
|
||||
return { success: false, msg: `Not found.` };
|
||||
}
|
||||
|
||||
const scopes = [scope];
|
||||
|
||||
if (file_id) {
|
||||
const file = await grabBotFile({ scopes, file_id });
|
||||
|
||||
if (!file?.id) return { success: false, msg: `Not found.` };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: {
|
||||
files: [toPublicFile({ file })],
|
||||
folders: [],
|
||||
usage: { bytes: 0, files: 0 },
|
||||
quota_bytes: 0,
|
||||
quota_mb: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [files, folders, usage, quota_bytes, quota_mb] = await Promise.all([
|
||||
listBotFiles({ scopes, folder, search }),
|
||||
listBotFolders({ scopes }),
|
||||
grabStorageUsage(scope),
|
||||
grabBotFileQuotaBytes(),
|
||||
grabBotFileQuotaMb(),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: {
|
||||
files: files.map((file) => toPublicFile({ file })),
|
||||
folders,
|
||||
usage,
|
||||
quota_bytes,
|
||||
quota_mb,
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
|
||||
function toPublicFile({ file }: { file: BotFileWithMediaLite }) {
|
||||
const { media_url } = grabMediaURL({ media: { ...(file.media || {}), id: file.media_id } as any });
|
||||
|
||||
return {
|
||||
id: Number(file.id),
|
||||
name: file.name || "",
|
||||
path: file.path || "",
|
||||
size: Number(file.size) || 0,
|
||||
media_type: file.media_type || "document",
|
||||
purpose: file.purpose || "writing",
|
||||
scope: file.scope || "bot",
|
||||
owner_id: Number(file.owner_id) || 0,
|
||||
ingested_at: file.ingested_at ? Number(file.ingested_at) : undefined,
|
||||
ingest_error: file.ingest_error || undefined,
|
||||
created_at: Number(file.created_at) || 0,
|
||||
url: media_url || "",
|
||||
};
|
||||
}
|
||||
|
||||
type BotFileWithMediaLite = {
|
||||
id?: number | "";
|
||||
media_id?: number | "";
|
||||
name?: string;
|
||||
path?: string;
|
||||
size?: number | "";
|
||||
media_type?: string;
|
||||
purpose?: string;
|
||||
scope?: string;
|
||||
owner_id?: number | "";
|
||||
ingested_at?: number | "";
|
||||
ingest_error?: string;
|
||||
created_at?: number | "";
|
||||
media?: Record<string, unknown> | null;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
|
||||
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
||||
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||
import { IngestBotFileRequestSchema } from "@/src/schemas/bot-file-schema";
|
||||
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { StorageScope } from "@/src/utils/storage-access";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Queues a conversation's file to be read again and re-embedded, for when
|
||||
* a search misses something the user knows is in it.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") throw new AppError(`Method not allowed`);
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.id) return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
if (!canManageCrew({ user_types: user_types || [] })) {
|
||||
throw new AppError(`Only a crew manager can re-read files.`);
|
||||
}
|
||||
|
||||
const { file_id, read_media } = parseWithSchema({
|
||||
schema: IngestBotFileRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const scopes = await reachableScopes({ user_id: user.id, file_id });
|
||||
|
||||
const file = await grabBotFile({ scopes, file_id });
|
||||
|
||||
if (!file?.id) throw new AppError(`That file isn't here.`);
|
||||
|
||||
await enqueueFileIngest({ file_id: file.id });
|
||||
|
||||
return { success: true, msg: `${file.name || "The file"} will be read again shortly.` };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The directories the user reaches for this file.
|
||||
*/
|
||||
async function reachableScopes({
|
||||
user_id,
|
||||
file_id,
|
||||
}: {
|
||||
user_id: number;
|
||||
file_id: number;
|
||||
}): Promise<StorageScope[]> {
|
||||
const { default: BunSQLite } = await import("@moduletrace/bun-sqlite");
|
||||
|
||||
const res = await BunSQLite.sql<{ scope: string; owner_id: number }>({
|
||||
sql: `SELECT scope, owner_id FROM bot_files WHERE id = ? LIMIT 1`,
|
||||
values: [file_id],
|
||||
});
|
||||
|
||||
const file = res.payload?.[0];
|
||||
|
||||
if (!file?.owner_id) return [];
|
||||
|
||||
if (file.scope == "workspace") {
|
||||
const workspace = await grabUserWorkspace({ workspace_id: file.owner_id, user_id });
|
||||
|
||||
return workspace?.id ? [{ scope: "workspace", owner_id: Number(file.owner_id) }] : [];
|
||||
}
|
||||
|
||||
const bot = await grabUserBot({ bot_id: file.owner_id, user_id });
|
||||
|
||||
return bot?.id ? [{ scope: "bot", owner_id: Number(file.owner_id) }] : [];
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot
|
||||
import insertUserMessage from "@/src/functions/backend/db/bots/insert-user-message";
|
||||
import grabUserBotMessage from "@/src/functions/backend/db/bots/grab-user-bot-message";
|
||||
import grabAttachableMedia from "@/src/functions/backend/db/bots/grab-attachable-media";
|
||||
import storeChatAttachments from "@/src/functions/backend/db/bot-files/store-chat-attachments";
|
||||
import enqueueBotReply from "@/src/functions/backend/db/bots-queue/enqueue-bot-reply";
|
||||
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||
import wakeBotsQueue from "@/src/bots-queue/wake-bots-queue";
|
||||
@@ -93,6 +94,17 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
media,
|
||||
});
|
||||
|
||||
/**
|
||||
* The files join the conversation's folder, so a later run can
|
||||
* search what the user sent now.
|
||||
*/
|
||||
await storeChatAttachments({
|
||||
bot_id: bot.id,
|
||||
workspace_id: conversation.workspace_id,
|
||||
user_id: user.id,
|
||||
media,
|
||||
});
|
||||
|
||||
await enqueueBotReply({
|
||||
user_id: user.id,
|
||||
bot_id: bot.id,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabStorageQuotaOverview, {
|
||||
type StorageQuotaOverview,
|
||||
} from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
|
||||
import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { BotFileQuotaSettingKey } from "@/src/dict/bot-file-dict";
|
||||
import { SaveStorageQuotaRequestSchema } from "@/src/schemas/bot-file-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: the per-folder quota for what bots save, in MB. Zero
|
||||
* puts the code's default back.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<StorageQuotaOverview>> = async ({
|
||||
req,
|
||||
body,
|
||||
}) => {
|
||||
try {
|
||||
if (req.method != "POST") throw new AppError(`Method not allowed`);
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) throw new AppError(`Only a super admin can change the storage quota.`);
|
||||
|
||||
const { quota_mb } = parseWithSchema({
|
||||
schema: SaveStorageQuotaRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
await saveAppSetting({
|
||||
key: BotFileQuotaSettingKey,
|
||||
value: quota_mb > 0 ? String(quota_mb) : null,
|
||||
});
|
||||
|
||||
await logEvent({
|
||||
title: "Bot file quota changed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} set the bot file quota to ${quota_mb} MB per folder.`,
|
||||
metadata: { quota_mb },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, singleRes: await grabStorageQuotaOverview() };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user