Compare commits

...
4 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 f2710eb640 Release v0.11.0
CI / check (push) Waiting to run
Release image / image (release) Waiting to run
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:25:49 +01:00
tbenandClaude Opus 5.5 faa554d5e3 Open bot files in a preview modal instead of a new tab
Markdown rendered, CSV/TSV as a table, images, PDFs, audio, video and
sandboxed HTML in place, other text as code, with Download and Open in a
new tab. Used by file chips, message attachments, the files modal, task
files and the files browser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 19:25:49 +01:00
tbenandClaude Opus 5.5 521c2bbac0 Release v0.10.0
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:43:10 +01:00
tbenandClaude Opus 5.5 de28d4d9f7 Let generate_image work from reference images, with an image editing model
generate_image takes reference images: files the run reaches as
[file:ID] refs, or the user's uploads by media id, up to four, each
checked like a picture source and limited to raster images.

Calls with references go to a new Image editing harness (no automatic
picks). While it has no model or is off, image generation takes them;
with its fallback on, image generation steps in after its pick fails.
Only models that take image input are tried with references, so none
drops them unseen. OpenRouter gets them on /images as input_references;
other providers as a multipart upload to /images/edits.

The Models pages show image editing's unset state as "Same as image
generation", and the models cache version is bumped so it shows at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:43:10 +01:00
47 changed files with 1525 additions and 90 deletions
+3 -1
View File
@@ -96,6 +96,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
- **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it.
- **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`).
- **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[question:ID]` (a bot's questions), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. A ref quoted alone in a code span still links; one inside longer code stays literal (`linkComputerRefs` splits out code first). A file chip opens a file from another bot's folder for a user who reaches that folder (`userReachesFileScope`, the same rule as its bytes), so answers passed on from chatrooms keep working.
- **File preview:** every file link (chips, attachments, files modal, task files, `/admin/files`) is a `FilePreviewLink`: a plain click opens `FilePreviewModal` (markdown rendered with the message elements, CSV/TSV as a table via `parseCsv`, images/PDF/audio/video in place, HTML in a frame with `sandbox=""` (plus the media server's `sandbox` CSP), other text as code, the kind from `filePreviewKind`; Download and Open in a new tab), a modified click still opens a tab. Text is read up to `AppData.FilePreviewMaxTextBytes`. Never render a file's HTML on the app's origin or with scripts.
- **Copy chat** (header menu): the active session (the one on screen) as one markdown document (`api/admin/bots/conversation-export` -> `conversationMarkdown`, refs as names, message headings demoted). The clipboard write starts on the click with a pending `ClipboardItem` (`copyPendingText`), since Safari drops the gesture after a fetch.
- **Asking before acting:** `ask_user` (`QuestionsNote`) puts up to `AppData.BotQuestionMaxQuestions` multiple-choice questions (`single` = radios, `multiple` = checkboxes, optional typed "Other") on one card, once per run -> `bot_questions` -> `[question:ID]` chip opening `BotQuestionCard`; the message needs the user. Answers are checked against the questions (`checkBotQuestionAnswers`) and stored, then sent as the user's chat message (`botQuestionAnswersText`); Skip says so. Replies and user-started tasks only: never routines, hook calls or chatroom sessions (no one sees those).
- **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`.
@@ -198,7 +199,8 @@ Read-only browser of every volume (`src/utils/storage-volumes.ts`: bots, workspa
### Harness (the model layer, `src/harness/`)
- **Harnesses** (`src/dict/harness-dict.ts`): `text` (always on), `vision`, `transcription`, `speech`, `image`, `embedding`. One `harness_settings` row each (provider, pinned model or automatic, `fallback`, `enabled`, `options`). Transcription, speech and embedding have no automatic picks: unavailable until a model is picked.
- **Harnesses** (`src/dict/harness-dict.ts`): `text` (always on), `vision`, `transcription`, `speech`, `image`, `image_edit`, `embedding`. One `harness_settings` row each (provider, pinned model or automatic, `fallback`, `enabled`, `options`). Transcription, speech and embedding have no automatic picks: unavailable until a model is picked.
- **Images:** `generate_image` takes reference images (`references` as `[file:ID]`, `reference_media_ids` for uploads, checked like a picture source by `loadReferenceImages`). With references the call goes to `image_edit` (no automatic picks; `HarnessFallbacks` hands it to `image` while unpicked or off, and after its pick fails when `fallback` is on), and only models that take image input are tried (`orderImageCandidates`), never one that would drop them. OpenRouter takes references on `/images` (`input_references`), other providers as multipart on `image_edit_path` (`/images/edits`).
- **Providers** (`src/dict/harness-provider-dict.ts`): OpenRouter default plus OpenAI-compatible ones; one adapter today (`src/harness/providers/openai-compatible/`). Keys encrypted in private `harness_providers`, falling back to the preset env.
- **Catalog:** `harness_models`, synced hourly. OpenRouter "free" means `:free` or `openrouter/free`, never zero prices. Auto picks: `rank-auto-models`.
- **Calling:** `resolveHarnessCandidates` -> `withCandidates` with process-wide cooldowns; errors are `HarnessError`. Entry points under `text/`, `media/`, `audio/`, `image/`, `embeddings/`. `run-text-loop.ts` sticks to one model, caps steps (`grabHarnessMaxToolSteps`, `grabReplyMaxToolSteps`), trims old tool output to fit context. Models that can't take an image/audio part get it read by vision/transcription first.
+4 -4
View File
@@ -1567,7 +1567,7 @@ export type BUN_SQLITE_COBBLE_HARNESS_SETTINGS = {
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | "";
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "image_edit" | "embedding" | "";
/**
* harness_models row picked by a super admin. Null picks automatically. Not a foreign key, so removing a provider leaves the setting
*/
@@ -1607,7 +1607,7 @@ export type BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS = {
* The worker bot that overrides the default. The coordinator always uses the defaults
*/
bot_id?: number | "";
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | "";
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "image_edit" | "embedding" | "";
/**
* harness_models row picked for this bot. Null picks automatically. Not a foreign key, like harness_settings
*/
@@ -1647,7 +1647,7 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS = {
* The workspace
*/
workspace_id?: number | "";
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | "";
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "image_edit" | "embedding" | "";
/**
* harness_models row picked for this workspace. Null picks automatically. Not a foreign key, like harness_settings
*/
@@ -2421,7 +2421,7 @@ export type BUN_SQLITE_COBBLE_TOKEN_USAGES = {
/**
* What the model was used for: a chat turn, reading media, transcribing, speaking, making an image, embedding
*/
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | "";
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "image_edit" | "embedding" | "";
/**
* The provider that served it. Not a foreign key: it stays readable after the provider is deleted
*/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cobble",
"version": "0.9.0",
"version": "0.11.0",
"module": "index.ts",
"type": "module",
"private": true,
@@ -0,0 +1,61 @@
import Img from "@moduletrace/twui/layout/Img";
import Stack from "@moduletrace/twui/layout/Stack";
import type { FilePreviewKind } from "@/src/utils/file-preview-kind";
import FilePreviewNotice from "./file-preview-notice";
import FilePreviewText from "./file-preview-text";
type Props = {
kind: FilePreviewKind;
name: string;
url: string;
size?: number;
};
/**
* The preview itself, by kind. Media the browser draws itself (images,
* PDFs, audio, video) load from the file's URL; text kinds are read into
* the page (`FilePreviewText`). Nothing here runs the file: an SVG shows
* through `<img>`, which runs no script, and an HTML page in a frame
* sandboxed twice (the attribute here and the `sandbox` CSP the media
* server sends with it, `servedFileHeaders`): no script, no forms, an
* origin of its own.
*/
export default function FilePreviewBody({ kind, name, url, size }: Props) {
if (kind == "image") {
return (
<Stack className="grow min-h-0 w-full items-center justify-center p-4 overflow-auto bg-sunken">
<Img src={url} alt={name} className="max-w-full max-h-full object-contain" />
</Stack>
);
}
if (kind == "pdf") {
return <iframe src={url} title={name} className="block grow w-full border-0 bg-paper" />;
}
if (kind == "html") {
return <iframe src={url} title={name} sandbox="" referrerPolicy="no-referrer" className="block grow w-full border-0 bg-paper" />;
}
if (kind == "audio") {
return (
<Stack className="grow w-full items-center justify-center p-6">
<audio src={url} controls preload="metadata" className="w-full max-w-xl" />
</Stack>
);
}
if (kind == "video") {
return (
<Stack className="grow min-h-0 w-full items-center justify-center p-4 bg-sunken">
<video src={url} controls preload="metadata" className="max-w-full max-h-full" />
</Stack>
);
}
if (kind == "none") {
return <FilePreviewNotice message="This kind of file can't be previewed here. Download it to open it." />;
}
return <FilePreviewText kind={kind} url={url} size={size} />;
}
@@ -0,0 +1,28 @@
import CodeBlock from "@moduletrace/twui/elements/CodeBlock";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
text: string;
};
/**
* A plain text file in the preview, as it is, on the sunken surface with
* twui's copy button.
*/
export default function FilePreviewCode({ text }: Props) {
return (
<Stack className="grow min-h-0 w-full overflow-auto p-4 items-stretch">
<CodeBlock
singleBlock
backgroundColor="var(--color-sunken)"
wrapperProps={{
className:
"max-w-full! outline-hairline dark:outline-hairline [&_.twui-code-block-header_svg]:stroke-ink-secondary",
}}
className="font-mono text-[13px] leading-5 text-ink py-2 pl-2 pr-10 whitespace-pre-wrap wrap-anywhere"
>
{text}
</CodeBlock>
</Stack>
);
}
@@ -0,0 +1,52 @@
import { useState, type ReactNode } from "react";
import Link from "@moduletrace/twui/layout/Link";
import isPlainClick from "@/src/utils/is-plain-click";
import FilePreviewModal from "./file-preview-modal";
type Props = {
name: string;
url: string;
media_type?: string | null;
size?: number;
title?: string;
className?: string;
children: ReactNode;
};
/**
* A link to a file that opens it in the preview (`FilePreviewModal`) on a
* plain click. It stays a real link to the bytes, so a middle click or
* Ctrl/Cmd click still opens the file in a new tab.
*/
export default function FilePreviewLink({ name, url, media_type, size, title, className, children }: Props) {
const [open, setOpen] = useState(false);
return (
<>
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
title={title || `Preview ${name}`}
onClick={(event) => {
if (!isPlainClick({ event })) return;
event.preventDefault();
setOpen(true);
}}
className={className}
>
{children}
</Link>
{open ? (
<FilePreviewModal
name={name}
url={url}
media_type={media_type}
size={size}
onClose={() => setOpen(false)}
/>
) : null}
</>
);
}
@@ -0,0 +1,47 @@
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { twMerge } from "tailwind-merge";
import H2 from "@moduletrace/twui/layout/H2";
import H3 from "@moduletrace/twui/layout/H3";
import H4 from "@moduletrace/twui/layout/H4";
import H5 from "@moduletrace/twui/layout/H5";
import Stack from "@moduletrace/twui/layout/Stack";
import { MessageMarkdownComponents } from "@/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components";
type Props = {
text: string;
};
const RemarkPlugins = [remarkGfm];
const HeadingClassName = "my-0! tracking-normal! font-bold text-ink";
/**
* A message's markdown elements, with headings sized for a document
* rather than a chat line.
*/
const DocumentComponents: Components = {
...MessageMarkdownComponents,
h1: ({ children }) => <H2 className={twMerge(HeadingClassName, "text-2xl! leading-8! mt-2!")}>{children}</H2>,
h2: ({ children }) => <H3 className={twMerge(HeadingClassName, "text-xl! leading-7! mt-2!")}>{children}</H3>,
h3: ({ children }) => <H4 className={twMerge(HeadingClassName, "text-[17px]! leading-6! mt-1!")}>{children}</H4>,
h4: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-[15px]! leading-6!")}>{children}</H5>,
h5: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-sm! leading-5!")}>{children}</H5>,
h6: ({ children }) => <H5 className={twMerge(HeadingClassName, "text-sm! leading-5! text-ink-secondary")}>{children}</H5>,
};
/**
* A markdown file rendered in the preview, through the same elements as a
* bot's messages (raw HTML never rendered), in a readable column.
*/
export default function FilePreviewMarkdown({ text }: Props) {
return (
<Stack className="grow min-h-0 w-full overflow-auto items-center">
<Stack className="w-full max-w-[760px] gap-3! items-stretch px-6 py-5">
<Markdown remarkPlugins={RemarkPlugins} components={DocumentComponents}>
{text}
</Markdown>
</Stack>
</Stack>
);
}
@@ -0,0 +1,88 @@
import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
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 { Download, SquareArrowOutUpRight } from "lucide-react";
import filePreviewKind from "@/src/utils/file-preview-kind";
import formatBytes from "@/src/utils/format-bytes";
import FilePreviewBody from "./file-preview-body";
type Props = {
name: string;
url: string;
media_type?: string | null;
size?: number;
onClose: () => void;
};
const ActionClassName =
"shrink-0 inline-flex items-center gap-1.5 px-2 py-1.5 text-[13px] leading-[18px] text-ink-secondary hover:text-ink rounded-default hover:bg-sunken no-underline";
/**
* A file over the page: markdown rendered, a CSV as a table, an image,
* PDF, audio or video in place, other text as code, with ways to download
* it or open it in a new tab. A file it can't show offers those only.
*/
export default function FilePreviewModal({
name,
url,
media_type,
size,
onClose,
}: Props) {
const kind = filePreviewKind({ name, media_type });
const base_name = name.split("/").pop() || name;
return (
<Modal
open
setOpen={(next_open) => {
if (!next_open) onClose();
}}
className="max-w-[95vw]! w-[95vw] h-[90vh] p-0 bg-paper flex flex-col overflow-hidden"
>
<Row className="w-full gap-3! flex-nowrap items-center pl-5 pr-14 py-3 border-0 border-b border-solid border-hairline">
<Stack className="min-w-0 grow gap-0! items-start">
<H2
title={name}
className="text-base! leading-6! tracking-normal! font-bold text-ink truncate w-full"
>
{base_name}
</H2>
{size ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{formatBytes({ bytes: size })}
</Span>
) : null}
</Stack>
<Link
href={url}
download={base_name}
title={`Download ${base_name}`}
className={ActionClassName}
>
<Download size={16} aria-hidden />
<Span className="hidden sm:inline">Download</Span>
</Link>
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
title="Open in a new tab"
aria-label="Open in a new tab"
className={ActionClassName}
>
<SquareArrowOutUpRight size={16} aria-hidden />
</Link>
</Row>
<FilePreviewBody
kind={kind}
name={base_name}
url={url}
size={size}
/>
</Modal>
);
}
@@ -0,0 +1,19 @@
import P from "@moduletrace/twui/layout/P";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
message: string;
};
/**
* What the preview says instead of a file it doesn't show.
*/
export default function FilePreviewNotice({ message }: Props) {
return (
<Stack className="grow w-full items-center justify-center p-6">
<P noMargin className="text-sm leading-5 text-ink-secondary text-center max-w-md">
{message}
</P>
</Stack>
);
}
@@ -0,0 +1,20 @@
import { twMerge } from "tailwind-merge";
type Props = {
value: string;
header?: boolean;
};
/**
* One cell of a previewed CSV, its text kept to one readable width.
*/
export default function FilePreviewTableCell({ value, header }: Props) {
const className = twMerge(
"px-3 py-1.5 max-w-80 text-left align-top whitespace-pre-wrap wrap-break-word border-0 border-b border-r border-solid border-hairline",
header && "font-bold border-hairline-strong",
);
if (header) return <th className={className}>{value}</th>;
return <td className={className}>{value}</td>;
}
@@ -0,0 +1,19 @@
import FilePreviewTableCell from "./file-preview-table-cell";
type Props = {
cells: string[];
header?: boolean;
};
/**
* One row of a previewed CSV.
*/
export default function FilePreviewTableRow({ cells, header }: Props) {
return (
<tr>
{cells.map((value, index) => (
<FilePreviewTableCell key={index} value={value} header={header} />
))}
</tr>
);
}
@@ -0,0 +1,54 @@
import { useMemo } from "react";
import P from "@moduletrace/twui/layout/P";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
import parseCsv from "@/src/utils/parse-csv";
import FilePreviewNotice from "./file-preview-notice";
import FilePreviewTableRow from "./file-preview-table-row";
type Props = {
text: string;
delimiter: string;
};
/**
* A CSV or TSV file as a table: its first row the header (kept in view
* while scrolling), up to `AppData.FilePreviewMaxTableRows` rows. Cells
* are text only. Tables have no children-based twui equivalent, so they're
* styled elements, as in a message.
*/
export default function FilePreviewTable({ text, delimiter }: Props) {
const { rows, truncated } = useMemo(
() => parseCsv({ text, delimiter, max_rows: AppData.FilePreviewMaxTableRows + 1 }),
[text, delimiter],
);
if (!rows.length) return <FilePreviewNotice message="This file is empty." />;
const [header, ...body] = rows;
return (
<Stack className="grow min-h-0 w-full items-stretch gap-0!">
<Stack className="grow min-h-0 w-full overflow-auto items-start">
<table className="text-[13px] leading-5 text-ink border-separate border-spacing-0">
<thead className="sticky top-0 bg-surface">
<FilePreviewTableRow cells={header!} header />
</thead>
<tbody>
{body.map((cells, index) => (
<FilePreviewTableRow key={index} cells={cells} />
))}
</tbody>
</table>
</Stack>
<P
noMargin
className="shrink-0 px-5 py-2 text-[12px] leading-4 text-ink-secondary border-0 border-t border-solid border-hairline"
>
{truncated
? `Showing the first ${body.length} rows. Download the file for the rest.`
: `${body.length} ${body.length == 1 ? "row" : "rows"}`}
</P>
</Stack>
);
}
@@ -0,0 +1,50 @@
import Loading from "@moduletrace/twui/elements/Loading";
import Stack from "@moduletrace/twui/layout/Stack";
import useFileText from "@/src/hooks/use-file-text";
import formatBytes from "@/src/utils/format-bytes";
import { AppData } from "@/src/data/app-data";
import type { FilePreviewKind } from "@/src/utils/file-preview-kind";
import FilePreviewCode from "./file-preview-code";
import FilePreviewMarkdown from "./file-preview-markdown";
import FilePreviewNotice from "./file-preview-notice";
import FilePreviewTable from "./file-preview-table";
type Props = {
kind: FilePreviewKind;
url: string;
size?: number;
};
/**
* A text file read into the preview: markdown rendered, CSV or TSV as a
* table, anything else as code.
*/
export default function FilePreviewText({ kind, url, size }: Props) {
const { text, loading, error, too_large } = useFileText({ url, size });
if (loading) {
return (
<Stack className="grow w-full items-center justify-center">
<Loading />
</Stack>
);
}
if (too_large) {
return (
<FilePreviewNotice
message={`This file is bigger than ${formatBytes({ bytes: AppData.FilePreviewMaxTextBytes })}, too big to preview. Download it to open it.`}
/>
);
}
if (error || text == null) {
return <FilePreviewNotice message={error || "The file couldn't be read."} />;
}
if (kind == "markdown") return <FilePreviewMarkdown text={text} />;
if (kind == "csv") return <FilePreviewTable text={text} delimiter="," />;
if (kind == "tsv") return <FilePreviewTable text={text} delimiter={"\t"} />;
return <FilePreviewCode text={text} />;
}
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { AppData } from "@/src/data/app-data";
import FilePreviewBody from "./file-preview-body";
import FilePreviewLink from "./file-preview-link";
import FilePreviewMarkdown from "./file-preview-markdown";
import FilePreviewTable from "./file-preview-table";
test("a markdown file renders as a document, raw HTML left out", () => {
const html = renderToString(
<FilePreviewMarkdown text={"# Report\n\nSome **bold** text.\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n<script>alert(1)</script>"} />,
);
expect(html).toContain("Report");
expect(html).toContain("<h2");
expect(html).toContain("font-bold");
expect(html).toContain("<table");
expect(html).not.toContain("<script");
});
test("a CSV renders as a table with its header row", () => {
const html = renderToString(<FilePreviewTable text={'name,city\n"Smith, J",Lagos\n'} delimiter="," />);
expect(html).toContain("<thead");
expect(html).toContain("<th");
expect(html).toContain("Smith, J");
expect(html).toContain("1 row");
});
test("a long CSV says it was cut", () => {
const rows = Array.from({ length: AppData.FilePreviewMaxTableRows + 5 }, (_, i) => `${i}`).join("\n");
const html = renderToString(<FilePreviewTable text={`n\n${rows}`} delimiter="," />);
expect(html).toContain(`Showing the first ${AppData.FilePreviewMaxTableRows} rows`);
});
test("media previews load from the file's URL, unknown files offer a download", () => {
expect(renderToString(<FilePreviewBody kind="image" name="a.png" url="/media/1" />)).toContain('src="/media/1"');
expect(renderToString(<FilePreviewBody kind="pdf" name="a.pdf" url="/media/2" />)).toContain("<iframe");
expect(renderToString(<FilePreviewBody kind="html" name="a.html" url="/media/4" />)).toContain('sandbox=""');
expect(renderToString(<FilePreviewBody kind="none" name="a.zip" url="/media/3" />)).toContain("can&#x27;t be previewed");
});
test("a file link stays a real link to the bytes until clicked", () => {
const html = renderToString(
<FilePreviewLink name="report.md" url="/media/9">
report.md
</FilePreviewLink>,
);
expect(html).toContain('href="/media/9"');
expect(html).toContain("Preview report.md");
});
+8 -9
View File
@@ -1,9 +1,9 @@
import Row from "@moduletrace/twui/layout/Row";
import Link from "@moduletrace/twui/layout/Link";
import { FileText, Image as ImageIcon } from "lucide-react";
import Span from "@moduletrace/twui/layout/Span";
import type { TaskFile } from "@/src/types";
import fileRef from "@/src/utils/file-ref";
import FilePreviewLink from "./file-preview-link";
type Props = {
file: TaskFile;
@@ -15,8 +15,8 @@ type Props = {
};
/**
* One file on a task: its name, opening the bytes at `/media/<id>` (private,
* so only someone who reaches the task can load it), and its file ref (built by `fileRef`) when it
* One file on a task: its name, opening it in the file preview (its bytes at
* `/media/<id>`, private, so only someone who reaches the task can load them), and its file ref (built by `fileRef`) when it
* lives in a bot's or workspace's folder (so the file's real home is
* reachable too).
*/
@@ -39,15 +39,14 @@ export default function TaskFileRow({ file, onDetach, busy }: Props) {
) : (
<FileText size={16} className="shrink-0 text-ink-secondary" aria-hidden />
)}
<Link
href={`/media/${file.media_id}`}
target="_blank"
rel="noreferrer"
title={file.name || "Open the file"}
<FilePreviewLink
name={file.name || `File ${file.media_id}`}
url={`/media/${file.media_id}`}
media_type={file.media_type}
className="grow min-w-0 text-[13px] leading-[18px] text-ink truncate hover:underline"
>
{file.name || `File ${file.media_id}`}
</Link>
</FilePreviewLink>
{file.kind == "reference" ? (
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">reference</Span>
) : null}
+15
View File
@@ -353,6 +353,12 @@ export const AppData = {
* Upload cap for audio sent to transcription.
*/
HarnessMaxAudioBytes: 25 * 1024 * 1024,
/**
* Reference images one `generate_image` call passes on, and the
* largest each may be.
*/
ImageReferenceMaxCount: 4,
ImageReferenceMaxBytes: 10 * 1024 * 1024,
/**
* Connecting to an MCP server (a computer) gives up after this long.
*/
@@ -591,6 +597,15 @@ export const AppData = {
* inside its frame.
*/
BotGraphicInlineMaxHeight: 640,
/**
* Largest text file (markdown, CSV, plain text) the file preview reads
* into the page (bytes); a bigger one is only offered to download.
*/
FilePreviewMaxTextBytes: 2 * 1024 * 1024,
/**
* Most rows of a CSV the file preview draws.
*/
FilePreviewMaxTableRows: 1000,
/**
* Most questions one `ask_user` card holds.
*/
+33
View File
@@ -49,6 +49,14 @@ export const Harnesses = [
required: false,
install_wide: false,
},
{
title: "Image editing",
value: "image_edit",
automatic: false,
description: `Makes images from reference images a bot passes on: a logo to rework, a style to follow, a photo to change. Until a model is picked here (or while it's off), image generation takes them.`,
required: false,
install_wide: false,
},
{
title: "Embeddings",
value: "embedding",
@@ -68,6 +76,31 @@ export function isInstallWideHarness({ harness }: { harness: HarnessKind }) {
return Boolean(Harnesses.find((h) => h.value == harness)?.install_wide);
}
/**
* Harnesses that hand their work to another one instead of picking
* models automatically: image editing goes to image generation while it
* has no model (or is off), and after its model fails when fallback is on.
*/
export const HarnessFallbacks: Partial<Record<HarnessKind, HarnessKind>> = {
image_edit: "image",
};
/**
* The harness this one falls back to, if any.
*/
export function harnessFallsBackTo({ harness }: { harness: HarnessKind }) {
return HarnessFallbacks[harness];
}
/**
* The title of the harness this one falls back to, if any.
*/
export function harnessFallbackTitle({ harness }: { harness: HarnessKind }) {
const to = HarnessFallbacks[harness];
return to ? Harnesses.find((h) => h.value == to)?.title : undefined;
}
/**
* The `app_settings` key holding the install's own limit on model turns in
* one run (a task). Unset means `AppData.HarnessMaxToolSteps`.
+13
View File
@@ -9,6 +9,9 @@
* capabilities are guessed from the names
* - `local`: runs on the user's own hardware, so its models count as free
* - `image_path`: image generation endpoint
* - `image_edit_path`: where images made from reference images go. OpenRouter
* takes them on `image_path` (`input_references`); the others take a
* multipart upload at `/images/edits`
*/
export const HarnessProviderKinds = [
{
@@ -20,6 +23,7 @@ export const HarnessProviderKinds = [
catalog: "openrouter",
local: false,
image_path: "/images",
image_edit_path: "/images",
},
{
title: "OpenAI",
@@ -30,6 +34,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible",
local: false,
image_path: "/images/generations",
image_edit_path: "/images/edits",
},
{
title: "Anthropic",
@@ -40,6 +45,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible",
local: false,
image_path: "/images/generations",
image_edit_path: "/images/edits",
},
{
title: "Google Gemini",
@@ -50,6 +56,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible",
local: false,
image_path: "/images/generations",
image_edit_path: "/images/edits",
},
{
title: "DeepSeek",
@@ -60,6 +67,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible",
local: false,
image_path: "/images/generations",
image_edit_path: "/images/edits",
},
{
title: "Groq",
@@ -70,6 +78,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible",
local: false,
image_path: "/images/generations",
image_edit_path: "/images/edits",
},
{
title: "Mistral",
@@ -80,6 +89,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible",
local: false,
image_path: "/images/generations",
image_edit_path: "/images/edits",
},
{
title: "xAI",
@@ -90,6 +100,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible",
local: false,
image_path: "/images/generations",
image_edit_path: "/images/edits",
},
{
title: "Ollama",
@@ -100,6 +111,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible",
local: true,
image_path: "/images/generations",
image_edit_path: "/images/edits",
},
{
title: "Other (OpenAI-compatible)",
@@ -110,6 +122,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible",
local: false,
image_path: "/images/generations",
image_edit_path: "/images/edits",
},
] as const;
@@ -11,7 +11,7 @@ type Guess = Pick<
const Transcription = /whisper|transcri|(?:^|[-_/])(stt|asr)(?=$|[-_:.])|scribe/i;
const Speech = /(?:^|[-_/])tts(?=$|[-_:.])|speech|voice/i;
const ImageOut = /dall-e|gpt-image|imagen|flux|stable-diffusion|sdxl|image-gen/i;
const ImageOut = /dall-e|gpt-image|imagen|flux|stable-diffusion|sdxl|image-gen|qwen-image|kontext|seedream|image-edit/i;
const Embedding = /embed/i;
/**
* Chat families that read images.
@@ -72,4 +72,19 @@ describe("modelServesHarness", () => {
}),
).toBe(true);
});
test("image editing takes and makes images", () => {
expect(
modelServesHarness({
model: { ...chat, output_modalities: ["image"] },
harness: "image_edit",
}),
).toBe(false);
expect(
modelServesHarness({
model: { ...chat, input_modalities: ["text", "image"], output_modalities: ["image"] },
harness: "image_edit",
}),
).toBe(true);
});
});
@@ -17,6 +17,7 @@ type Params = {
* audio: free ones often drop it)
* - speech: a text-to-speech model
* - image: makes images
* - image_edit: makes images from reference images (takes and makes them)
* - embedding: turns text into vectors (never a chat model)
*/
export default function modelServesHarness({ model, harness }: Params) {
@@ -41,6 +42,9 @@ export default function modelServesHarness({ model, harness }: Params) {
case "image":
return output.has("image");
case "image_edit":
return input.has("image") && output.has("image");
case "embedding":
return output.has("embeddings");
}
+54 -5
View File
@@ -1,9 +1,12 @@
import grabHarnessSetting from "@/src/functions/backend/db/harness/grab-harness-setting";
import HarnessError from "../harness-error";
import grabHarnessAdapter from "../providers/grab-harness-adapter";
import resolveHarnessCandidates from "../resolve/resolve-harness-candidates";
import withCandidates from "../resolve/with-candidates";
import type { HarnessImageOutput, HarnessUsage } from "../types";
import buildUsageCall from "../usage/build-usage-call";
import recordHarnessUsage from "../usage/record-harness-usage";
import orderImageCandidates from "./order-image-candidates";
type Params = {
prompt: string;
@@ -25,7 +28,9 @@ type Params = {
};
/**
* Makes images with the image generation harness. Returns their bytes.
* Makes images with the image generation harness, or with image editing
* when there are reference images (`orderImageCandidates` decides who
* tries). Returns their bytes.
*/
export default async function generateImage({
prompt,
@@ -36,15 +41,36 @@ export default async function generateImage({
bot_id,
workspace_id,
}: Params) {
const candidates = await resolveHarnessCandidates({ harness: "image", bot_id, workspace_id });
const has_references = Boolean(references?.length);
const edit = has_references ? await grabEdit({ bot_id, workspace_id }) : undefined;
/**
* Image generation turned off still leaves an image editing pick to
* work with.
*/
const image = await resolveHarnessCandidates({ harness: "image", bot_id, workspace_id }).catch(
(error: unknown) => {
if (edit?.candidates.length) return [];
throw error;
},
);
const ordered = orderImageCandidates({ references: has_references, image, edit });
if (has_references && !ordered.length && image.length) {
throw new HarnessError({
message: `None of the image models here takes reference images. A super admin can pick one under Image editing in Settings, under Models.`,
code: "unavailable",
});
}
const harness_of = new Map(ordered.map(({ candidate, harness }) => [candidate.key, harness]));
const { result } = await withCandidates<{ images: HarnessImageOutput[]; usage?: HarnessUsage }>({
harness: "image",
candidates,
harness: edit?.picked ? "image_edit" : "image",
candidates: ordered.map(({ candidate }) => candidate),
on_success: async ({ candidate, result: made, failed }): Promise<void> =>
await recordHarnessUsage({
call: buildUsageCall({
harness: "image",
harness: harness_of.get(candidate.key) || "image",
candidate,
failed,
usage: made.usage,
@@ -65,3 +91,26 @@ export default async function generateImage({
return result.images;
}
/**
* Image editing's setting and candidates, or undefined while it's off.
*/
async function grabEdit({
bot_id,
workspace_id,
}: {
bot_id?: number | string;
workspace_id?: number | string | null;
}) {
const setting = await grabHarnessSetting({ harness: "image_edit", bot_id, workspace_id });
if (!setting.enabled) return undefined;
return {
picked: Boolean(setting.model_id),
fallback: setting.fallback,
candidates: setting.model_id
? await resolveHarnessCandidates({ harness: "image_edit", bot_id, workspace_id })
: [],
};
}
@@ -0,0 +1,82 @@
import { AppData } from "@/src/data/app-data";
import grabPictureSource from "@/src/functions/backend/db/profile-images/grab-picture-source";
import AppError from "@/src/utils/app-error";
import mediaFilePath from "@/src/utils/media-file-path";
import type { StorageScope } from "@/src/utils/storage-access";
/**
* Image types every image model takes as a reference.
*/
const ReferenceImageTypes = ["png", "jpeg", "jpg", "webp", "gif"];
type Params = {
user_id: number | string;
/**
* The files the run reaches, so a reference is only ever a file the
* bot could already read.
*/
scopes: StorageScope[];
/**
* Files by `[file:ID]` id.
*/
file_ids?: number[];
/**
* The user's own uploads, by media id.
*/
media_ids?: number[];
};
/**
* The reference images an image call passes on, as `data:` URLs. Each is
* checked the way a picture is (`grabPictureSource`: a file in the run's
* directories or the user's own upload), then to be a raster image within
* `AppData.ImageReferenceMaxBytes`. Throws on the first one that isn't.
*/
export default async function loadReferenceImages({
user_id,
scopes,
file_ids = [],
media_ids = [],
}: Params): Promise<string[]> {
const sources: { file_id?: number; media_id?: number }[] = [
...file_ids.map((file_id) => ({ file_id })),
...media_ids.map((media_id) => ({ media_id })),
];
if (sources.length > AppData.ImageReferenceMaxCount) {
throw new AppError(`Pass at most ${AppData.ImageReferenceMaxCount} reference images.`);
}
const urls: string[] = [];
for (const source of sources) {
const media = await grabPictureSource({ user_id, scopes, ...source });
const label = source.file_id ? `[file:${source.file_id}]` : `media id ${source.media_id}`;
const type = (media?.mime_type || "").replace(/^image\//, "").toLowerCase();
if (!media || media.media_type != "image" || !ReferenceImageTypes.includes(type)) {
throw new AppError(
`${label} can't be a reference: use a PNG, JPEG, WebP or GIF image.`,
);
}
const absolute = mediaFilePath({ write_path: media.media_write_path });
const disk = absolute ? Bun.file(absolute) : null;
if (!disk || !(await disk.exists())) {
throw new AppError(`${label} is missing on the server.`);
}
if (disk.size > AppData.ImageReferenceMaxBytes) {
throw new AppError(
`${label} is too large to pass on (at most ${Math.round(AppData.ImageReferenceMaxBytes / (1024 * 1024))} MB).`,
);
}
const base64 = Buffer.from(await disk.arrayBuffer()).toString("base64");
urls.push(`data:image/${type == "jpg" ? "jpeg" : type};base64,${base64}`);
}
return urls;
}
@@ -0,0 +1,105 @@
import { describe, expect, test } from "bun:test";
import type { HarnessCandidate } from "../types";
import orderImageCandidates from "./order-image-candidates";
function candidate({ slug, takes_images }: { slug: string; takes_images: boolean }): HarnessCandidate {
return {
key: `1:${slug}`,
provider: { id: 1, kind: "openrouter", base_url: "", name: "OpenRouter" } as HarnessCandidate["provider"],
model: {
slug,
input_modalities: takes_images ? ["text", "image"] : ["text"],
output_modalities: ["image"],
supports_tools: false,
},
source: "pinned",
};
}
const flux = candidate({ slug: "flux", takes_images: true });
const recraft = candidate({ slug: "recraft", takes_images: false });
const qwen_edit = candidate({ slug: "qwen-image-edit", takes_images: true });
function slugs(list: ReturnType<typeof orderImageCandidates>) {
return list.map(({ candidate, harness }) => `${harness}:${candidate.model.slug}`);
}
describe("orderImageCandidates", () => {
test("without references, image generation's models as they are", () => {
expect(
slugs(
orderImageCandidates({
references: false,
image: [recraft, flux],
edit: { picked: true, fallback: true, candidates: [qwen_edit] },
}),
),
).toEqual(["image:recraft", "image:flux"]);
});
test("with references and no edit pick, only image models that take images", () => {
expect(
slugs(
orderImageCandidates({
references: true,
image: [recraft, flux],
edit: { picked: false, fallback: true, candidates: [] },
}),
),
).toEqual(["image:flux"]);
});
test("image editing off goes to image generation", () => {
expect(
slugs(orderImageCandidates({ references: true, image: [recraft, flux] })),
).toEqual(["image:flux"]);
});
test("the edit pick first, then image generation with fallback on", () => {
expect(
slugs(
orderImageCandidates({
references: true,
image: [flux, recraft],
edit: { picked: true, fallback: true, candidates: [qwen_edit] },
}),
),
).toEqual(["image_edit:qwen-image-edit", "image:flux"]);
});
test("fallback off keeps to the edit pick", () => {
expect(
slugs(
orderImageCandidates({
references: true,
image: [flux],
edit: { picked: true, fallback: false, candidates: [qwen_edit] },
}),
),
).toEqual(["image_edit:qwen-image-edit"]);
});
test("an edit pick that can't run falls to image generation", () => {
expect(
slugs(
orderImageCandidates({
references: true,
image: [flux],
edit: { picked: true, fallback: false, candidates: [] },
}),
),
).toEqual(["image:flux"]);
});
test("a model in both lists is tried once, as the edit pick", () => {
expect(
slugs(
orderImageCandidates({
references: true,
image: [flux],
edit: { picked: true, fallback: true, candidates: [flux] },
}),
),
).toEqual(["image_edit:flux"]);
});
});
@@ -0,0 +1,65 @@
import type { HarnessCandidate } from "../types";
export type ImageCandidate = {
candidate: HarnessCandidate;
/**
* The harness it was picked for, for its usage row.
*/
harness: "image" | "image_edit";
};
type Params = {
/**
* Whether the call passes reference images.
*/
references: boolean;
/**
* Image generation's candidates.
*/
image: HarnessCandidate[];
/**
* Image editing's setting and candidates; undefined when it's off.
*/
edit?: {
/**
* Whether a model is picked for it.
*/
picked: boolean;
fallback: boolean;
candidates: HarnessCandidate[];
};
};
/**
* The models an image call tries, in order. Without references: image
* generation's. With them: image editing's pick, then (with fallback on)
* the image generation models that take images; only those while image
* editing has no usable model or is off. A model that can't take an image
* is never tried with references, since it would drop them unseen.
*/
export default function orderImageCandidates({ references, image, edit }: Params): ImageCandidate[] {
if (!references) {
return image.map((candidate) => ({ candidate, harness: "image" }));
}
const takes_images = image
.filter((candidate) => candidate.model.input_modalities.includes("image"))
.map((candidate) => ({ candidate, harness: "image" as const }));
const edits = edit?.picked
? edit.candidates.map((candidate) => ({ candidate, harness: "image_edit" as const }))
: [];
const ordered = !edits.length
? takes_images
: [...edits, ...(edit?.fallback ? takes_images : [])];
const seen = new Set<string>();
return ordered.filter(({ candidate }) => {
if (seen.has(candidate.key)) return false;
seen.add(candidate.key);
return true;
});
}
@@ -1,4 +1,5 @@
import { AppData } from "@/src/data/app-data";
import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
import HarnessError from "../../harness-error";
import type { HarnessAdapter, HarnessImageOutput } from "../../types";
import grabProviderPreset from "../grab-provider-preset";
@@ -6,8 +7,10 @@ import providerRequest from "./provider-request";
/**
* Image generation at the provider's `image_path` (`/images` on
* OpenRouter, `/images/generations` elsewhere). Images come back as
* base64, or as URLs that are downloaded here.
* OpenRouter, `/images/generations` elsewhere). With reference images,
* OpenRouter takes them on the same call (`input_references`); the others
* get them as a multipart upload at `image_edit_path` (`/images/edits`).
* Images come back as base64, or as URLs that are downloaded here.
*/
const generateImage: HarnessAdapter["generateImage"] = async ({
candidate,
@@ -20,29 +23,45 @@ const generateImage: HarnessAdapter["generateImage"] = async ({
const { provider, model } = candidate;
const preset = grabProviderPreset({ kind: provider.kind });
const is_openrouter = provider.kind == "openrouter";
const edits = Boolean(references?.length) && !is_openrouter;
const res = await providerRequest({
provider,
path: preset.image_path,
json: {
model: model.slug,
prompt,
...(n ? { n } : {}),
...(size ? { size } : {}),
...(is_openrouter
? references?.length
? {
input_references: references.map((url) => ({
type: "image_url",
image_url: { url },
})),
}
: {}
: { response_format: "b64_json" }),
},
signal,
candidate_key: candidate.key,
});
const res = edits
? await providerRequest({
provider,
path: preset.image_edit_path,
form: await editForm({
model: model.slug,
prompt,
n,
size,
references: references || [],
signal,
}),
signal,
candidate_key: candidate.key,
})
: await providerRequest({
provider,
path: preset.image_path,
json: {
model: model.slug,
prompt,
...(n ? { n } : {}),
...(size ? { size } : {}),
...(is_openrouter
? references?.length
? {
input_references: references.map((url) => ({
type: "image_url",
image_url: { url },
})),
}
: {}
: { response_format: "b64_json" }),
},
signal,
candidate_key: candidate.key,
});
const json = await res.json().catch(() => null);
const items: any[] = Array.isArray(json?.data) ? json.data : [];
@@ -98,3 +117,59 @@ const generateImage: HarnessAdapter["generateImage"] = async ({
};
export default generateImage;
type EditFormParams = {
model: string;
prompt: string;
n?: number;
size?: string;
references: string[];
signal: AbortSignal;
};
/**
* The `/images/edits` upload: the prompt and every reference image as a
* file (`image[]` when there are several, as gpt-image takes them).
*/
async function editForm({ model, prompt, n, size, references, signal }: EditFormParams) {
const form = new FormData();
form.append("model", model);
form.append("prompt", prompt);
if (n) form.append("n", String(n));
if (size) form.append("size", size);
const field = references.length > 1 ? "image[]" : "image";
for (let index = 0; index < references.length; index++) {
const { blob, ext } = await referenceBlob({ url: references[index]!, signal });
form.append(field, blob, `reference-${index + 1}.${ext}`);
}
return form;
}
/**
* A reference's bytes: decoded from a `data:` URL, downloaded otherwise.
*/
async function referenceBlob({ url, signal }: { url: string; signal: AbortSignal }) {
if (url.startsWith("data:")) {
const { buffer, ext } = await bufferFromBase64(url);
const type = url.slice(5, url.indexOf(";")) || "image/png";
return { blob: new Blob([new Uint8Array(buffer)], { type }), ext };
}
const download = await fetch(url, {
signal: AbortSignal.any([signal, AbortSignal.timeout(AppData.HarnessRequestTimeoutMs)]),
});
if (!download.ok) {
throw new HarnessError({ message: `A reference image couldn't be downloaded.` });
}
const type = download.headers.get("content-type")?.split(";")[0] || "image/png";
return { blob: new Blob([await download.arrayBuffer()], { type }), ext: type.split("/")[1] || "png" };
}
@@ -1,6 +1,9 @@
import { z } from "zod";
import recordBotFile from "@/src/functions/backend/db/bot-files/record-bot-file";
import { AppData } from "@/src/data/app-data";
import generateImage from "@/src/harness/image/generate-image";
import loadReferenceImages from "@/src/harness/image/load-reference-images";
import parseFileRef from "@/src/utils/parse-file-ref";
import type { HarnessTool } from "../../types";
import {
describeScope,
@@ -12,6 +15,8 @@ import {
type Args = {
prompt: string;
references?: (string | number)[];
reference_media_ids?: number[];
path?: string;
size?: string;
attach?: boolean;
@@ -19,18 +24,30 @@ type Args = {
};
/**
* Makes an image with the image generation harness and saves it in the
* bot's files, so it can be sent, shown again later or used by another
* run. The bytes stay in the app's storage, never on a computer.
* Makes an image with the image generation harness (image editing when it
* passes reference images: files the run reaches or the user's uploads)
* and saves it in the bot's files, so it can be sent, shown again later or
* used by another run. The bytes stay in the app's storage, never on a
* computer.
*/
export default function generateImageTool(context: StorageToolContext): HarnessTool<Args> {
const own = context.scopes[0];
return {
name: "generate_image",
description: `Make an image from a description and save it in your files. Describe what should be in it, in one clear prompt. The image is saved (so you can send it now and show it later) and can ride on this reply with "attach".`,
description: `Make an image from a description and save it in your files. Describe what should be in it, in one clear prompt. To work from existing images (a logo to rework, a style or palette to follow, a photo to change), pass them as "references" ([file:ID] refs from list_files) or "reference_media_ids" (images the user uploaded), up to ${AppData.ImageReferenceMaxCount}, and say in the prompt what to take from each. The image is saved (so you can send it now and show it later) and can ride on this reply with "attach".`,
schema: z.object({
prompt: z.string().min(3).max(4000),
references: z
.array(z.union([z.string().max(60), z.number().int().positive()]))
.max(AppData.ImageReferenceMaxCount)
.optional()
.describe(`Images in your files to work from, by [file:ID] ref, e.g. ["[file:12]"]`),
reference_media_ids: z
.array(z.number().int().positive())
.max(AppData.ImageReferenceMaxCount)
.optional()
.describe(`Images the user uploaded to work from, by media id`),
path: z
.string()
.max(300)
@@ -55,8 +72,22 @@ export default function generateImageTool(context: StorageToolContext): HarnessT
.join(", ")}.`;
}
const file_ids = (args.references || []).map((ref) => parseFileRef({ ref }));
if (file_ids.some((id) => !id)) {
return `Give each reference as its [file:ID] ref, e.g. "[file:12]". list_files lists them.`;
}
const references = await loadReferenceImages({
user_id: context.user_id,
scopes: context.scopes,
file_ids: file_ids as number[],
media_ids: args.reference_media_ids,
});
const images = await generateImage({
prompt: args.prompt,
references,
size: args.size,
signal,
bot_id: context.bot_id,
+1
View File
@@ -157,6 +157,7 @@ export type HarnessUsageKind =
| "transcription"
| "speech"
| "image"
| "image_edit"
| "embedding";
export type HarnessChatParams = {
+1 -1
View File
@@ -58,7 +58,7 @@ export type HarnessCallSettings = {
};
export type HarnessUsageCall = {
harness: "text" | "vision" | "transcription" | "speech" | "image" | "embedding";
harness: "text" | "vision" | "transcription" | "speech" | "image" | "image_edit" | "embedding";
provider_id?: number;
provider_name?: string;
model_id?: number;
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
import { AppData } from "@/src/data/app-data";
type Params = {
url: string;
/**
* Read nothing (a preview that isn't text).
*/
skip?: boolean;
/**
* The file's size when known, so a big one isn't fetched at all.
*/
size?: number;
};
type FileText = {
text: string | null;
loading: boolean;
error: string | null;
/**
* Bigger than `AppData.FilePreviewMaxTextBytes`: offered to download only.
*/
too_large: boolean;
};
/**
* A file's bytes read as text for the preview, once per URL, up to
* `AppData.FilePreviewMaxTextBytes`.
*/
export default function useFileText({ url, skip, size }: Params): FileText {
const max = AppData.FilePreviewMaxTextBytes;
const known_too_large = Boolean(size && size > max);
const [state, setState] = useState<FileText>({
text: null,
loading: !skip && !known_too_large,
error: null,
too_large: known_too_large,
});
useEffect(() => {
if (skip || known_too_large) return;
let live = true;
setState({ text: null, loading: true, error: null, too_large: false });
(async () => {
try {
const res = await fetch(url, { credentials: "same-origin" });
if (!res.ok) throw new Error(`The file couldn't be read (${res.status}).`);
const length = Number(res.headers.get("content-length")) || 0;
if (length > max) {
res.body?.cancel();
if (live) setState({ text: null, loading: false, error: null, too_large: true });
return;
}
const blob = await res.blob();
if (blob.size > max) {
if (live) setState({ text: null, loading: false, error: null, too_large: true });
return;
}
const text = await blob.text();
if (live) setState({ text, loading: false, error: null, too_large: false });
} catch (error: any) {
if (live) {
setState({
text: null,
loading: false,
error: error?.message || "The file couldn't be read.",
too_large: false,
});
}
}
})();
return () => {
live = false;
};
}, [url, skip, known_too_large, max]);
return state;
}
@@ -1,10 +1,10 @@
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 { BotFileScopesPayload, MediaType, PublicBotFile } from "@/src/types";
import FilePreviewLink from "@/src/components/general/file-preview-link";
import AttachmentIcon from "./attachment-icon";
type Props = {
@@ -50,8 +50,8 @@ function fetchFile({
/**
* A file a bot named in a message (`fileRef`): the name it is saved under,
* linked to its bytes at `/media/<id>` (private: only someone who reaches
* the file's folder can load it). A folder the bot named is a `folderRef`
* opening it in the file preview (its bytes at `/media/<id>`, private: only
* someone who reaches the file's folder can load them). A folder the bot named is a `folderRef`
* instead, and its chip opens the files browser. 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
@@ -85,15 +85,15 @@ export default function FileRefChip({ file_id }: Props) {
}
return (
<Link
href={file.url}
target="_blank"
rel="noopener noreferrer"
title={`Open ${file.name}`}
<FilePreviewLink
name={file.name}
url={file.url}
media_type={file.media_type}
size={file.size}
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>
</FilePreviewLink>
);
}
@@ -1,4 +1,3 @@
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";
@@ -6,6 +5,7 @@ import Button from "@moduletrace/twui/layout/Button";
import { Trash2 } from "lucide-react";
import type { MediaType, PublicBotFile } from "@/src/types";
import relativeTime from "@/src/utils/relative-time";
import FilePreviewLink from "@/src/components/general/file-preview-link";
import AttachmentIcon from "./attachment-icon";
type Props = {
@@ -29,15 +29,15 @@ export default function FileRow({ file, can_manage, busy, onDelete }: Props) {
<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}`}
<FilePreviewLink
name={file.path || file.name}
url={file.url}
media_type={file.media_type}
size={file.size}
className="text-[13px] font-bold leading-5 text-ink break-words hover:opacity-80"
>
{file.path || file.name}
</Link>
</FilePreviewLink>
<Span className="text-[12px] leading-4 text-ink-secondary">
{[
file.scope == "workspace" ? "Workspace" : "Bot files",
@@ -34,7 +34,7 @@ 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, a file it saved a chip that downloads it, a
* a link to that workspace, a file it saved a chip that previews it, a
* folder it named a chip that opens it in the files browser, an MCP server,
* tool, skill or hook it proposed a chip that opens the confirm card, a
* secret it showed a masked chip, a secret it asked for a chip that opens
@@ -1,12 +1,12 @@
import Img from "@moduletrace/twui/layout/Img";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import type { BotMessageAttachment } from "@/src/types";
import FilePreviewLink from "@/src/components/general/file-preview-link";
import AttachmentIcon from "./attachment-icon";
/**
* One file on a message: an image as a thumbnail, anything else as a
* named chip. Both open the file in a new tab.
* named chip. Both open the file in the preview.
*/
export default function MessageAttachment({
name,
@@ -16,25 +16,26 @@ export default function MessageAttachment({
}: BotMessageAttachment) {
if (media_type == "image") {
return (
<Link href={url} target="_blank" title={name} className="block">
<FilePreviewLink name={name} url={url} media_type={media_type} title={name} className="block">
<Img
src={thumbnail_url || url}
alt={name}
className="max-h-48 max-w-64 rounded-default-lg border border-solid border-hairline object-cover"
/>
</Link>
</FilePreviewLink>
);
}
return (
<Link
href={url}
target="_blank"
<FilePreviewLink
name={name}
url={url}
media_type={media_type}
title={name}
className="flex max-w-64 items-center gap-2 rounded-default-lg border border-solid border-hairline bg-surface px-3 py-2 text-[13px] leading-[18px] text-ink no-underline hover:border-hairline-strong"
>
<AttachmentIcon media_type={media_type} />
<Span className="truncate">{name}</Span>
</Link>
</FilePreviewLink>
);
}
@@ -8,6 +8,7 @@ import type { MediaType, StorageEntry } from "@/src/types";
import formatBytes from "@/src/utils/format-bytes";
import relativeTime from "@/src/utils/relative-time";
import AttachmentIcon from "@/src/pages/admin/bots/[bot_id]/(partials)/attachment-icon";
import FilePreviewLink from "@/src/components/general/file-preview-link";
type Props = {
entry: StorageEntry;
@@ -16,8 +17,8 @@ type Props = {
};
/**
* One entry in the open folder: a folder opens in place, a file opens in a
* new tab (its own media record when it is one of the bot's files, else
* One entry in the open folder: a folder opens in place, a file opens in the
* file preview (its own media record when it is one of the bot's files, else
* the browser's streaming route).
*/
export default function StorageEntryRow({ entry, selected, onOpenFolder }: Props) {
@@ -51,15 +52,15 @@ export default function StorageEntryRow({ entry, selected, onOpenFolder }: Props
{entry.name}
</Link>
) : (
<Link
href={entry.url}
target="_blank"
rel="noopener noreferrer"
title={`Open ${entry.name}`}
<FilePreviewLink
name={entry.name}
url={entry.url || ""}
media_type={entry.media_type}
size={entry.size}
className="text-[13px] font-bold leading-5 text-ink break-words hover:opacity-80"
>
{entry.name}
</Link>
</FilePreviewLink>
)}
<Span className="text-[12px] leading-4 text-ink-secondary">
{[
@@ -1,4 +1,5 @@
import Span from "@moduletrace/twui/layout/Span";
import { harnessFallbackTitle } from "@/src/dict/harness-dict";
import type { HarnessOverview } from "@/src/types";
type Props = {
@@ -9,8 +10,11 @@ type Props = {
* What the harness runs on right now, as saved, in one line.
*/
export default function HarnessCurrentModel({ harness }: Props) {
const falls_back_title = harnessFallbackTitle({ harness: harness.harness });
const text = !harness.enabled
? "Turned off."
? falls_back_title
? `Turned off: ${falls_back_title.toLowerCase()} does it.`
: "Turned off."
: harness.current
? `Now using ${harness.current.name} on ${harness.current.provider_name}${
harness.current.source == "auto" ? ", picked automatically" : ""
@@ -21,7 +25,9 @@ export default function HarnessCurrentModel({ harness }: Props) {
}`
: harness.required
? "Nothing to run on yet: no provider has a usable key."
: !harness.automatic
: falls_back_title && !harness.model_id
? `${falls_back_title} does it until a model is picked.`
: !harness.automatic
? "Not available until a model is picked."
: "No free model can do this right now. Pick one to turn it on.";
@@ -13,6 +13,7 @@ type Props = {
model_id: number | null;
provider_name?: string;
automatic: boolean;
falls_back_title?: string;
onChange: (model_id: number | null) => void;
};
@@ -26,6 +27,7 @@ export default function HarnessModelSelect({
model_id,
provider_name,
automatic,
falls_back_title,
onChange,
}: Props) {
return (
@@ -35,7 +37,13 @@ export default function HarnessModelSelect({
label="Model"
showLabel
placeholder="Search models"
options={harnessModelSelectOptions({ models, model_id, provider_name, automatic })}
options={harnessModelSelectOptions({
models,
model_id,
provider_name,
automatic,
falls_back_title,
})}
defaultValues={[model_id ? String(model_id) : AutoModelValue]}
changeHandler={(value) => {
const picked = Array.isArray(value) ? value[0] : value;
@@ -1,4 +1,5 @@
import Checkbox from "@moduletrace/twui/form/Checkbox";
import { harnessFallbackTitle } from "@/src/dict/harness-dict";
import { HarnessParameterHarnesses } from "@/src/dict/harness-parameter-dict";
import type { HarnessFormValue, HarnessOverview } from "@/src/types";
import SettingsGroup from "../../(partials)/settings-group";
@@ -40,11 +41,14 @@ export default function HarnessSettingGroup({ harness, value, onChange }: Props)
harness.harness as (typeof HarnessParameterHarnesses)[number],
);
const on_bot_page = harness.default !== undefined;
const falls_back_title = harnessFallbackTitle({ harness: harness.harness });
const default_name = harness.default?.enabled === false
? "turned off"
: harness.default?.current
? `${harness.default.current.name} on ${harness.default.current.provider_name}`
: "not available";
: falls_back_title
? `same as ${falls_back_title.toLowerCase()}`
: "not available";
return (
<SettingsGroup title={harness.title} description={harness.description}>
@@ -74,6 +78,7 @@ export default function HarnessSettingGroup({ harness, value, onChange }: Props)
model_id={value.model_id}
provider_name={provider?.name}
automatic={harness.automatic}
falls_back_title={falls_back_title}
onChange={(model_id) => onChange({ ...value, model_id, voice: undefined })}
/>
)}
@@ -90,6 +95,14 @@ export default function HarnessSettingGroup({ harness, value, onChange }: Props)
changeHandler={(fallback) => onChange({ ...value, fallback })}
/>
) : null}
{!value.inherit && value.model_id && falls_back_title ? (
<Checkbox
title={`Fall back to ${falls_back_title.toLowerCase()} if this model fails`}
label={`Fall back to ${falls_back_title.toLowerCase()} if this model fails`}
checked={value.fallback}
changeHandler={(fallback) => onChange({ ...value, fallback })}
/>
) : null}
{shows_params && params_model ? (
<HarnessParameterModal
id={id}
@@ -18,26 +18,35 @@ type Params = {
* available".
*/
automatic: boolean;
/**
* The harness that does the work while none is picked (image
* generation, for image editing): the empty choice names it.
*/
falls_back_title?: string;
};
/**
* The model picker's options: automatic (the best free model, on the
* picked provider if there is one), then every model that can do the job
* (free ones first, as the server sorts them). A harness without
* automatic picks starts with "Not available" instead.
* automatic picks starts with "Not available" instead, or with the
* harness it falls back to.
*/
export default function harnessModelSelectOptions({
models,
model_id,
provider_name,
automatic,
falls_back_title,
}: Params): TWUISelectOptionObject[] {
const has_free = models.some((model) => model.free);
return [
{
value: AutoModelValue,
title: !automatic
title: falls_back_title
? `Same as ${falls_back_title.toLowerCase()} (pick a model to use its own)`
: !automatic
? "Not available (pick a model to turn it on)"
: provider_name
? has_free
@@ -8,5 +8,6 @@ export const UsageHarnessTitles: Record<string, string> = {
transcription: "Transcription",
speech: "Speech",
image: "Image generation",
image_edit: "Image editing",
embedding: "Embeddings",
};
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test";
import filePreviewKind from "./file-preview-kind";
test("filePreviewKind reads the extension first", () => {
expect(filePreviewKind({ name: "report.md", media_type: "document" })).toBe("markdown");
expect(filePreviewKind({ name: "seo/raw/Ranks.CSV" })).toBe("csv");
expect(filePreviewKind({ name: "data.tsv" })).toBe("tsv");
expect(filePreviewKind({ name: "chart.svg", media_type: "document" })).toBe("image");
expect(filePreviewKind({ name: "paper.pdf" })).toBe("pdf");
expect(filePreviewKind({ name: "page.html" })).toBe("html");
expect(filePreviewKind({ name: "run.xml.out" })).toBe("text");
expect(filePreviewKind({ name: "notes.json" })).toBe("text");
expect(filePreviewKind({ name: "README" })).toBe("text");
});
test("filePreviewKind falls back to the media type", () => {
expect(filePreviewKind({ name: "photo", media_type: "image" })).toBe("image");
expect(filePreviewKind({ name: "clip", media_type: "video" })).toBe("video");
expect(filePreviewKind({ name: "voice", media_type: "audio" })).toBe("audio");
expect(filePreviewKind({ name: "archive.zip", media_type: "document" })).toBe("none");
expect(filePreviewKind({ name: ".md" })).toBe("none");
expect(filePreviewKind({})).toBe("none");
});
+134
View File
@@ -0,0 +1,134 @@
/**
* How the file preview shows a file.
*/
export type FilePreviewKind =
| "markdown"
| "csv"
| "tsv"
| "image"
| "pdf"
| "html"
| "audio"
| "video"
| "text"
| "none";
type Params = {
name?: string | null;
/**
* The `media` type (`image`, `video`, `document`, `audio`), when known.
*/
media_type?: string | null;
};
const ExtensionKinds: Record<string, FilePreviewKind> = {
md: "markdown",
markdown: "markdown",
mdx: "markdown",
csv: "csv",
tsv: "tsv",
tab: "tsv",
png: "image",
jpg: "image",
jpeg: "image",
gif: "image",
webp: "image",
avif: "image",
bmp: "image",
ico: "image",
svg: "image",
pdf: "pdf",
html: "html",
htm: "html",
mp3: "audio",
wav: "audio",
ogg: "audio",
oga: "audio",
m4a: "audio",
flac: "audio",
aac: "audio",
opus: "audio",
mp4: "video",
webm: "video",
mov: "video",
m4v: "video",
ogv: "video",
};
/**
* Text read as it is (shown as code, never run: XML sources too).
*/
const TextExtensions = [
"txt",
"text",
"log",
"out",
"json",
"jsonl",
"ndjson",
"yaml",
"yml",
"toml",
"ini",
"cfg",
"conf",
"env",
"xml",
"css",
"scss",
"js",
"mjs",
"cjs",
"ts",
"tsx",
"jsx",
"py",
"rb",
"php",
"go",
"rs",
"java",
"kt",
"swift",
"c",
"h",
"cpp",
"hpp",
"cs",
"sh",
"bash",
"zsh",
"sql",
"diff",
"patch",
"rst",
"tex",
"ics",
"vtt",
"srt",
];
/**
* Files often named without an extension that hold text.
*/
const TextNames = ["readme", "license", "makefile", "dockerfile", "changelog", "notes"];
/**
* What the file preview does with a file, from its name first (a bot's
* `report.md` is a document to `media`) and its media type after.
*/
export default function filePreviewKind({ name, media_type }: Params): FilePreviewKind {
const base = (name || "").trim().toLowerCase().split("/").pop() || "";
const dot = base.lastIndexOf(".");
const extension = dot > 0 ? base.slice(dot + 1) : "";
if (extension && ExtensionKinds[extension]) return ExtensionKinds[extension]!;
if (extension && TextExtensions.includes(extension)) return "text";
if (!extension && TextNames.includes(base)) return "text";
if (media_type == "image") return "image";
if (media_type == "audio") return "audio";
if (media_type == "video") return "video";
return "none";
}
+1 -1
View File
@@ -8,7 +8,7 @@ import type {
* Bumped when a cached model payload changes shape, so an older cache is
* fetched again instead of read.
*/
const CacheVersion = 1;
const CacheVersion = 2;
type CacheEnvelope<T> = {
v: number;
+12
View File
@@ -0,0 +1,12 @@
import { expect, test } from "bun:test";
import isPlainClick from "./is-plain-click";
const click = { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false };
test("isPlainClick takes only an unmodified left click", () => {
expect(isPlainClick({ event: click })).toBe(true);
expect(isPlainClick({ event: { ...click, button: 1 } })).toBe(false);
expect(isPlainClick({ event: { ...click, metaKey: true } })).toBe(false);
expect(isPlainClick({ event: { ...click, ctrlKey: true } })).toBe(false);
expect(isPlainClick({ event: { ...click, shiftKey: true } })).toBe(false);
});
+18
View File
@@ -0,0 +1,18 @@
type Params = {
event: {
button: number;
metaKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
};
};
/**
* A plain left click, with no key held: the one a link may take over (to
* open a preview); a middle click or Ctrl/Cmd/Shift click still does what
* the browser does with a link (a new tab or window).
*/
export default function isPlainClick({ event }: Params) {
return event.button == 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
}
+43
View File
@@ -0,0 +1,43 @@
import { expect, test } from "bun:test";
import parseCsv from "./parse-csv";
test("parseCsv splits rows and cells", () => {
expect(parseCsv({ text: "a,b\r\n1,2\n3,4\n" })).toEqual({
rows: [
["a", "b"],
["1", "2"],
["3", "4"],
],
truncated: false,
columns: 2,
});
});
test("parseCsv keeps quoted delimiters, line breaks and quotes", () => {
const { rows } = parseCsv({ text: 'name,note\n"Smith, J","said ""hi""\nthen left"\n' });
expect(rows).toEqual([
["name", "note"],
["Smith, J", 'said "hi"\nthen left'],
]);
});
test("parseCsv pads short rows and reads tabs", () => {
const { rows, columns } = parseCsv({ text: "a\tb\tc\n1", delimiter: "\t" });
expect(columns).toBe(3);
expect(rows).toEqual([
["a", "b", "c"],
["1", "", ""],
]);
});
test("parseCsv stops at max_rows", () => {
expect(parseCsv({ text: "h\n1\n2\n3\n", max_rows: 2 })).toEqual({
rows: [["h"], ["1"]],
truncated: true,
columns: 1,
});
expect(parseCsv({ text: "h\n1\n", max_rows: 2 }).truncated).toBe(false);
expect(parseCsv({ text: "" }).rows).toEqual([]);
});
+97
View File
@@ -0,0 +1,97 @@
type Params = {
text: string;
/**
* `,` for CSV, a tab for TSV.
*/
delimiter?: string;
/**
* Stop after this many rows (the header included).
*/
max_rows?: number;
};
export type ParsedCsv = {
rows: string[][];
/**
* Rows were left out past `max_rows`.
*/
truncated: boolean;
/**
* The widest row's cell count; every row is padded to it.
*/
columns: number;
};
/**
* Splits CSV text (RFC 4180: quoted cells may hold delimiters, line breaks
* and doubled quotes) into rows of cells. A leading BOM is dropped, `\r\n`
* and `\n` both end a row, a trailing empty line adds no row, and short
* rows are padded so every row has the same number of cells.
*/
export default function parseCsv({ text, delimiter = ",", max_rows }: Params): ParsedCsv {
const source = text.charCodeAt(0) == 0xfeff ? text.slice(1) : text;
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
let truncated = false;
let index = 0;
const endRow = () => {
row.push(cell);
rows.push(row);
row = [];
cell = "";
};
while (index < source.length) {
const char = source[index]!;
if (quoted) {
if (char == '"') {
if (source[index + 1] == '"') {
cell += '"';
index += 2;
continue;
}
quoted = false;
} else {
cell += char;
}
index++;
continue;
}
if (char == '"' && cell == "") {
quoted = true;
} else if (char == delimiter) {
row.push(cell);
cell = "";
} else if (char == "\n" || char == "\r") {
if (char == "\r" && source[index + 1] == "\n") index++;
endRow();
if (max_rows && rows.length >= max_rows) {
truncated = index + 1 < source.length;
break;
}
} else {
cell += char;
}
index++;
}
if (!truncated && (cell != "" || row.length)) endRow();
const columns = rows.reduce((widest, it) => Math.max(widest, it.length), 0);
for (const it of rows) {
while (it.length < columns) it.push("");
}
return { rows, truncated, columns };
}