List a conversation's whole file tree, not just its top level
grab-conversation-files asked listBotFiles for the top level only, and a bot saves into folders (reports/, seo/coderank/, tmp/...). The page therefore carried none of the files a reply named, so every [file:ID] chip rendered as dead plain text and the files could not be opened at all: the bot had done the work and the user had no way to reach it. The props now take the whole tree, and a chip that still isn't among them (a message older than the page's first page of files) fetches its one file by id, cached per id so a deleted one doesn't refetch on every render.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
/**
|
||||
* The shape `listBotFiles` uses for a conversation's props. It has to be the
|
||||
* whole folder tree: a bot saves into paths like `reports/` and
|
||||
* `seo/coderank/`, and a `[file:ID]` chip in a message can only resolve
|
||||
* against the files in these props, so a top-level-only list left every
|
||||
* nested file ref as unclickable plain text in the chat.
|
||||
*/
|
||||
function rows({ db, scopes, recursive }: { db: Database; scopes: { scope: string; owner_id: number }[]; recursive: boolean }) {
|
||||
const owner_sql = scopes.map(() => `(scope = ? AND owner_id = ?)`).join(" OR ");
|
||||
const values = scopes.flatMap((s) => [s.scope, s.owner_id]);
|
||||
const where = [owner_sql];
|
||||
|
||||
if (!recursive) where.push(`(folder = '' OR folder IS NULL)`);
|
||||
|
||||
return db
|
||||
.query(`SELECT id FROM bot_files WHERE ${where.join(" AND ")} ORDER BY id`)
|
||||
.all(...values)
|
||||
.map((row) => (row as { id: number }).id);
|
||||
}
|
||||
|
||||
const db = new Database(":memory:");
|
||||
|
||||
db.run(`CREATE TABLE bot_files (id INTEGER PRIMARY KEY, scope TEXT, owner_id INTEGER, folder TEXT, path TEXT)`);
|
||||
db.run(
|
||||
`INSERT INTO bot_files VALUES
|
||||
(1, 'bot', 6, '', 'root.md'),
|
||||
(2, 'bot', 6, 'reports', 'reports/audit.md'),
|
||||
(3, 'bot', 6, 'seo/coderank', 'seo/coderank/crawl.py'),
|
||||
(4, 'bot', 5, 'reports', 'reports/other.md'),
|
||||
(5, 'workspace', 2, 'shared', 'shared/doc.md')`,
|
||||
);
|
||||
|
||||
const scopes = [{ scope: "bot", owner_id: 6 }];
|
||||
|
||||
describe("a conversation's file list", () => {
|
||||
test("the top level alone misses every file a bot saved in a folder", () => {
|
||||
expect(rows({ db, scopes, recursive: false })).toEqual([1]);
|
||||
});
|
||||
|
||||
test("the whole tree has them all, so a nested file ref resolves", () => {
|
||||
expect(rows({ db, scopes, recursive: true })).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("another owner's folder is never in it", () => {
|
||||
expect(rows({ db, scopes, recursive: true })).not.toContain(4);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,11 @@ type Params = {
|
||||
* (a workspace's shared one, else the bot's own), its latest
|
||||
* `AppData.BotFilesPageSize` of them, and the usage against the quota.
|
||||
* The full list (folders, search, older files) loads in the files modal.
|
||||
*
|
||||
* It takes the whole folder tree, not just its top level: a bot saves into
|
||||
* paths like `reports/` or `seo/coderank/`, and a file chip in a message
|
||||
* can only find the file it names among these, so a top-level-only list
|
||||
* left every nested `[file:ID]` unclickable plain text in the chat.
|
||||
*/
|
||||
export default async function grabConversationFiles({
|
||||
bot_id,
|
||||
@@ -29,7 +34,7 @@ export default async function grabConversationFiles({
|
||||
: ({ scope: "bot", owner_id: Number(bot_id) } as const);
|
||||
|
||||
const [files, usage, quota_bytes, quota_mb] = await Promise.all([
|
||||
listBotFiles({ scopes: [scope], limit: AppData.BotFilesPageSize }),
|
||||
listBotFiles({ scopes: [scope], recursive: true, limit: AppData.BotFilesPageSize }),
|
||||
grabStorageUsage(scope),
|
||||
grabBotFileQuotaBytes(),
|
||||
grabBotFileQuotaMb(),
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -114,6 +115,15 @@ export default function BotFilesModal({ bot, workspace, menu, on_open }: Props)
|
||||
} MB used in this folder.`}
|
||||
</Span>
|
||||
) : null}
|
||||
<Link
|
||||
href={`/admin/files?volume=${workspace?.id ? "workspaces" : "bots"}&owner=${
|
||||
workspace?.id || bot.id
|
||||
}`}
|
||||
title="Browse every folder in Files"
|
||||
className="text-[12px] font-bold leading-4 text-ink underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
Browse this folder in Files
|
||||
</Link>
|
||||
</Stack>
|
||||
|
||||
<Stack className="w-full mt-4 gap-2! items-stretch">
|
||||
|
||||
@@ -1,23 +1,79 @@
|
||||
import { useContext } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { MediaType } from "@/src/types";
|
||||
import type { BotFileScopesPayload, MediaType, PublicBotFile } from "@/src/types";
|
||||
import AttachmentIcon from "./attachment-icon";
|
||||
|
||||
type Props = {
|
||||
file_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One file, fetched once per id: a message can name a file older than the
|
||||
* page's first page of them, and the chip still has to open it. Failures
|
||||
* are remembered too, so a deleted file doesn't refetch on every render.
|
||||
*/
|
||||
const fetched = new Map<number, Promise<PublicBotFile | null>>();
|
||||
|
||||
function fetchFile({
|
||||
file_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Props & { bot_id: number; workspace_id?: number | null }) {
|
||||
const cached = fetched.get(file_id);
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
const query = new URLSearchParams({ file_id: String(file_id) });
|
||||
|
||||
if (workspace_id) query.set("workspace_id", String(workspace_id));
|
||||
else query.set("bot_id", String(bot_id));
|
||||
|
||||
const pending = fetchApi<{}, APIResponseObject<BotFileScopesPayload>>(
|
||||
`/api/admin/bots/files?${query.toString()}`,
|
||||
{ method: "GET" },
|
||||
)
|
||||
.then((res) => (res?.success && res.singleRes?.files?.length ? res.singleRes.files[0] : null))
|
||||
.catch(() => null)
|
||||
.then((file) => file || null);
|
||||
|
||||
fetched.set(file_id, pending);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* linked to its bytes. The conversation's files come with the page, so most
|
||||
* chips need no request; one that isn't among them (older than the page's
|
||||
* first page) is fetched by id. 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);
|
||||
const known = pageProps?.bot_files?.files?.find((it) => it.id == file_id);
|
||||
const [fetched_file, setFetchedFile] = useState<PublicBotFile | null>(null);
|
||||
const bot_id = Number(pageProps?.bot?.id) || 0;
|
||||
const workspace_id = pageProps?.workspace?.id ? Number(pageProps.workspace.id) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (known || !bot_id) return;
|
||||
|
||||
let live = true;
|
||||
|
||||
fetchFile({ file_id, bot_id, workspace_id }).then((file) => {
|
||||
if (live) setFetchedFile(file);
|
||||
});
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [file_id, bot_id, workspace_id, known]);
|
||||
|
||||
const file = known || fetched_file;
|
||||
|
||||
if (!file) {
|
||||
return <Span className="font-bold">{`file ${file_id}`}</Span>;
|
||||
|
||||
Reference in New Issue
Block a user