Add the harness, envs, per-bot models and chat attachments

- Harness: providers, model catalog synced hourly, automatic free picks,
  cooldowns, tool loop with context trimming, MCP computer tools
- Bots queue runs replies and tasks through the harness; jobs wait
  (attempt refunded) while every model is resting
- Envs: Settings > Environment, scoped general/workspace/computer,
  written to computers and redacted from tool output
- Models: Settings > Models, per-harness provider select defaulting to
  OpenRouter, and a models modal on each bot's chat (the coordinator's
  edits the defaults)
- Chat: thinking row while a reply runs, file attachments (upload,
  paste, drop) that replies read as images, audio and document text
- Fix media uploads failing after a user's first file, and private
  files being served from the public media path

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 03:38:38 +01:00
co-authored by Claude Opus 5.5
parent d49f524fe8
commit 6a2665dae9
223 changed files with 12342 additions and 162 deletions
@@ -1,17 +1,18 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { SendBotMessageRequest } from "@/src/schemas/bot-schema";
import type { BotMessageWithAttachments } from "@/src/types";
export default async function sendBotMessage({
bot_id,
text,
media_ids,
}: SendBotMessageRequest) {
return await fetchApi<
SendBotMessageRequest,
APIResponseObject<BUN_SQLITE_COBBLE_BOT_MESSAGES>
APIResponseObject<BotMessageWithAttachments>
>(`/api/admin/bots/send-message`, {
method: "POST",
body: { bot_id, text },
body: { bot_id, text, media_ids },
});
}
@@ -0,0 +1,39 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import fileInputToBase64 from "@moduletrace/twui/utils/form/fileInputToBase64";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { AppData } from "@/src/data/app-data";
import type { UploadBotAttachmentRequest } from "@/src/schemas/bot-schema";
import type { BotMessageAttachment } from "@/src/types";
type Params = {
file: File;
};
/**
* Uploads one file for the next message. Files over the size limit are
* refused here, before they're read.
*/
export default async function uploadBotAttachment({
file,
}: Params): Promise<APIResponseObject<BotMessageAttachment>> {
if (file.size > AppData.BotAttachmentMaxBytes) {
return {
success: false,
msg: `Too big: files can be up to ${Math.round(AppData.BotAttachmentMaxBytes / 1024 / 1024)} MB.`,
};
}
const { fileBase64Full } = await fileInputToBase64({ inputFile: file });
if (!fileBase64Full) {
return { success: false, msg: `Couldn't read the file.` };
}
return await fetchApi<
UploadBotAttachmentRequest,
APIResponseObject<BotMessageAttachment>
>(`/api/admin/bots/upload-attachment`, {
method: "POST",
body: { data_url: fileBase64Full, name: file.name || "file" },
});
}
@@ -0,0 +1,64 @@
import { useState } from "react";
import { AppData } from "@/src/data/app-data";
import type { BotMessageAttachment } from "@/src/types";
import uploadBotAttachment from "../(functions)/upload-bot-attachment";
export type PendingAttachment = {
key: string;
name: string;
status: "uploading" | "ready" | "failed";
attachment?: BotMessageAttachment;
error?: string;
};
/**
* The files picked for the next message: each uploads as soon as it's
* added, so sending only waits for ones still going up.
*/
export default function useComposerAttachments() {
const [pending, setPending] = useState<PendingAttachment[]>([]);
const update = ({ key, ...fields }: Partial<PendingAttachment> & { key: string }) =>
setPending((prev) => prev.map((item) => (item.key == key ? { ...item, ...fields } : item)));
const add = ({ files }: { files: File[] }) => {
const room = AppData.BotAttachmentMaxFiles - pending.length;
const picked = files.slice(0, Math.max(room, 0));
const added = picked.map((file) => ({
file,
item: {
key: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: file.name || "file",
status: "uploading" as const,
},
}));
setPending((prev) => [...prev, ...added.map(({ item }) => item)]);
for (const { file, item } of added) {
uploadBotAttachment({ file })
.then((res) =>
res.success && res.singleRes
? update({ key: item.key, status: "ready", attachment: res.singleRes })
: update({ key: item.key, status: "failed", error: res.msg || "Upload failed" }),
)
.catch(() => update({ key: item.key, status: "failed", error: "Upload failed" }));
}
};
const remove = ({ key }: { key: string }) =>
setPending((prev) => prev.filter((item) => item.key != key));
return {
pending,
add,
remove,
clear: () => setPending([]),
uploading: pending.some((item) => item.status == "uploading"),
media_ids: pending.flatMap((item) =>
item.status == "ready" && item.attachment ? [item.attachment.id] : [],
),
full: pending.length >= AppData.BotAttachmentMaxFiles,
};
}
@@ -0,0 +1,23 @@
import { FileText, Film, Image as ImageIcon, Mic } from "lucide-react";
import type { MediaType } from "@/src/types";
type Props = {
media_type?: MediaType;
size?: number;
};
/**
* The icon for a file's type.
*/
export default function AttachmentIcon({ media_type, size = 14 }: Props) {
const Icon =
media_type == "image"
? ImageIcon
: media_type == "audio"
? Mic
: media_type == "video"
? Film
: FileText;
return <Icon size={size} aria-hidden className="shrink-0" />;
}
@@ -0,0 +1,72 @@
import { useState } from "react";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
import postHarnessApi from "@/src/pages/admin/settings/models/(functions)/post-harness-api";
import saveBotHarnessSettings from "../models/(functions)/save-bot-harness-settings";
type Props = {
bot: BotWithLastMessage;
/**
* A worker's `grabBotHarnessOverview`; for the coordinator, the
* defaults (`grabHarnessOverview`).
*/
harnesses: HarnessOverview[];
/**
* Gets the fresh harnesses after each save.
*/
onSaved?: (harnesses: HarnessOverview[]) => void;
};
/**
* The Models settings form (`HarnessSettingsForm`) for one bot. A worker
* saves its own picks ("same as the default" drops one). The coordinator
* runs on the defaults, so it saves the defaults themselves, which only a
* super admin can.
*/
export default function BotHarnessForm({ bot, harnesses: initial, onSaved }: Props) {
const [harnesses, setHarnesses] = useState(initial);
/**
* Remounts the form when the data changes under it.
*/
const [version, setVersion] = useState(0);
const [notice, setNotice] = useState<string>();
const saved = (fresh: HarnessOverview[], msg?: string) => {
setHarnesses(fresh);
setNotice(msg || "Saved.");
setVersion((prev) => prev + 1);
onSaved?.(fresh);
};
return (
<HarnessSettingsForm
key={version}
harnesses={harnesses}
notice={notice}
save={async ({ settings }) => {
if (bot.kind == "coordinator") {
const res = await postHarnessApi({
path: "save-settings",
body: { settings },
});
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
return res;
}
const res = await saveBotHarnessSettings({
bot_id: Number(bot.id),
settings: settings.map((setting) => ({
...setting,
inherit: Boolean(setting.inherit),
})),
});
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
return res;
}}
/>
);
}
@@ -0,0 +1,97 @@
import { useContext, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { ChevronDown, CpuAi } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotHarnessForm from "./bot-harness-form";
type Props = {
bot: BotWithLastMessage;
harnesses?: HarnessOverview[] | null;
};
/**
* The header's model button: shows the model the bot thinks with and
* opens every model it uses (text, media reading, transcription, speech,
* images) in the Models settings form. On the coordinator that form is the
* defaults, for super admins only; everyone else gets the button read only.
*/
export default function BotModelsModal({ bot, harnesses: initial }: Props) {
const { pageProps } = useContext(AppContext);
const [harnesses, setHarnesses] = useState(initial);
const [open, setOpen] = useState(false);
if (!harnesses?.length || !bot.id) return null;
const is_coordinator = bot.kind == "coordinator";
const can_edit =
!is_coordinator ||
isPassThroughUser({ user_types: pageProps?.user_types });
const name = bot.name || "This bot";
const current =
harnesses.find((h) => h.harness == "text")?.current?.name ||
"No model set up";
const target = (
<Button
title={
can_edit
? `${name}'s models`
: `${name} uses the default models. A super admin can change them here or in Settings, under Models.`
}
type="button"
variant="outlined"
color="gray"
size="small"
disabled={!can_edit}
className="max-w-full min-w-0 gap-2! flex-nowrap text-[13px] text-ink-secondary hover:text-ink"
>
<CpuAi size={16} aria-hidden className="shrink-0 -mt-0.5" />
<Span className="truncate">{current}</Span>
{can_edit ? (
<ChevronDown size={14} aria-hidden className="shrink-0" />
) : null}
</Button>
);
if (!can_edit) {
return (
<Stack className="ml-auto max-w-[40%] min-w-0 shrink">
{target}
</Stack>
);
}
return (
<Modal
target={target}
targetWrapperProps={{
className: "ml-auto max-w-[40%] min-w-0 shrink",
}}
open={open}
setOpen={setOpen}
className="max-w-3xl bg-paper px-6 pt-6 pb-0"
>
<Stack className="w-full gap-1! items-stretch pb-2">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
{is_coordinator ? "Default models" : `${name}'s models`}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{is_coordinator
? `${name} and every bot left on the default use these. Providers are in Settings, under Models.`
: `${name} uses the default models unless you pick its own.`}
</Span>
</Stack>
<BotHarnessForm
bot={bot}
harnesses={harnesses}
onSaved={setHarnesses}
/>
</Modal>
);
}
@@ -0,0 +1,34 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import BotStone from "@/src/components/general/bot-stone";
import type { BotHue } from "@/src/dict/bot-dict";
import { BotReplyJobTitle, BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
import type { BotWithLastMessage } from "@/src/types";
type Props = {
bot: BotWithLastMessage;
};
/**
* Under the last message while the bot is working on a reply (or waiting
* for a model to come back), so a slow reply doesn't look like none.
*/
export default function BotThinkingRow({ bot }: Props) {
const thinking = bot.face == "working" && bot.status_line == BotReplyJobTitle;
const waiting = bot.face == "asleep" && bot.status_line == BotWaitingStatusLine;
if (!thinking && !waiting) return null;
return (
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
<BotStone
hue={(bot.hue || null) as BotHue | null}
face={thinking ? "working" : "asleep"}
size={28}
/>
<Span className="text-sm leading-5 text-ink-secondary animate-pulse motion-reduce:animate-none">
{thinking ? `${bot.name || "The bot"} is thinking…` : `${BotWaitingStatusLine}…`}
</Span>
</Row>
);
}
@@ -1,17 +1,30 @@
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppData } from "@/src/data/app-data";
import pickFiles from "../(utils)/pick-files";
type Props = {
disabled?: boolean;
onFiles: (params: { files: File[] }) => void;
};
/**
* Attachments are not designed yet; the control is present but inert.
* Opens the file picker for the next message's attachments.
*/
export default function ComposerAttachButton() {
export default function ComposerAttachButton({ disabled, onFiles }: Props) {
return (
<Button
title="Attachments are not available yet"
title={
disabled
? `Up to ${AppData.BotAttachmentMaxFiles} files per message`
: "Attach files"
}
type="button"
variant="ghost"
color="text"
aria-disabled="true"
className="shrink-0 p-1! -m-1 text-ink dark:text-ink cursor-not-allowed rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
disabled={disabled}
onClick={() => pickFiles({ onFiles })}
className="shrink-0 p-1! -m-1 text-ink dark:text-ink rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
>
<Plus size={16} aria-hidden />
</Button>
@@ -0,0 +1,48 @@
import Button from "@moduletrace/twui/layout/Button";
import Loading from "@moduletrace/twui/elements/Loading";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { X } from "lucide-react";
import type { PendingAttachment } from "../(hooks)/use-composer-attachments";
import AttachmentIcon from "./attachment-icon";
type Props = Omit<PendingAttachment, "key"> & {
onRemove: () => void;
};
/**
* A file waiting to go with the next message: uploading, ready, or failed
* (with why, on hover). Removable until sent.
*/
export default function ComposerAttachmentChip({
name,
status,
attachment,
error,
onRemove,
}: Props) {
return (
<Row
title={status == "failed" ? `${name}: ${error}` : name}
className={`max-w-56 gap-2! flex-nowrap items-center rounded-default-lg border border-solid bg-surface pl-3 pr-1 py-1 text-[13px] leading-[18px] ${status == "failed" ? "border-ink text-ink-secondary line-through" : "border-hairline text-ink"}`}
>
{status == "uploading" ? (
<Loading size="smaller" />
) : (
<AttachmentIcon media_type={attachment?.media_type} />
)}
<Span className="truncate">{name}</Span>
<Button
title={`Remove ${name}`}
type="button"
variant="ghost"
color="gray"
size="small"
className="shrink-0 p-1!"
onClick={onRemove}
>
<X size={14} aria-hidden />
</Button>
</Row>
);
}
@@ -0,0 +1,30 @@
import Row from "@moduletrace/twui/layout/Row";
import type { PendingAttachment } from "../(hooks)/use-composer-attachments";
import ComposerAttachmentChip from "./composer-attachment-chip";
type Props = {
pending: PendingAttachment[];
onRemove: (params: { key: string }) => void;
};
/**
* The next message's files, just above the composer rule.
*/
export default function ComposerAttachments({ pending, onRemove }: Props) {
if (!pending.length) return null;
return (
<Row className="gap-2! px-6 pb-3">
{pending.map((item) => (
<ComposerAttachmentChip
key={item.key}
name={item.name}
status={item.status}
attachment={item.attachment}
error={item.error}
onRemove={() => onRemove({ key: item.key })}
/>
))}
</Row>
);
}
@@ -1,27 +1,32 @@
import { useState, type KeyboardEvent } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Textarea from "@moduletrace/twui/form/Textarea";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import type { BotMessageWithAttachments, BotWithLastMessage } from "@/src/types";
import sendBotMessage from "../(functions)/send-bot-message";
import useComposerAttachments from "../(hooks)/use-composer-attachments";
import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments";
import ComposerSendButton from "./composer-send-button";
type Props = {
bot: BotWithLastMessage;
onSent: (params: { message: BUN_SQLITE_COBBLE_BOT_MESSAGES }) => void;
onSent: (params: { message: BotMessageWithAttachments }) => void;
};
/**
* Enter sends, Shift+Enter adds a line. Grows to five lines, then scrolls.
* Its top rule lines up with the rail's user-row rule.
* Its top rule lines up with the rail's user-row rule; picked files sit
* just above it. Files can also be pasted or dropped in.
*/
export default function Composer({ bot, onSent }: Props) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
const files = useComposerAttachments();
const can_send = Boolean(text.trim()) && !sending;
const can_send =
Boolean(text.trim() || files.media_ids.length) && !sending && !files.uploading;
async function send() {
if (!can_send || !bot.id) return;
@@ -29,7 +34,7 @@ export default function Composer({ bot, onSent }: Props) {
setSending(true);
setError(undefined);
const res = await sendBotMessage({ bot_id: bot.id, text });
const res = await sendBotMessage({ bot_id: bot.id, text, media_ids: files.media_ids });
setSending(false);
@@ -39,6 +44,7 @@ export default function Composer({ bot, onSent }: Props) {
}
setText("");
files.clear();
onSent({ message: res.singleRes });
}
@@ -50,26 +56,48 @@ export default function Composer({ bot, onSent }: Props) {
}
return (
<Row className="h-[88px] shrink-0 gap-4! flex-nowrap items-center px-6 border-0 border-t border-solid border-hairline">
<ComposerAttachButton />
<Textarea
name="message"
aria-label={`Message ${bot.name}`}
placeholder={`Message ${bot.name}`}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
aria-invalid={error ? true : undefined}
title={error}
className="field-sizing-content max-h-[100px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
wrapperWrapperProps={{ className: "grow min-w-0" }}
wrapperProps={{
className:
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
}}
/>
<ComposerSendButton disabled={!can_send} loading={sending} onClick={send} />
</Row>
<Stack
className="shrink-0 gap-0! items-stretch"
onDragOver={(e) => {
if (e.dataTransfer.types.includes("Files")) e.preventDefault();
}}
onDrop={(e) => {
if (!e.dataTransfer.files.length) return;
e.preventDefault();
files.add({ files: [...e.dataTransfer.files] });
}}
>
<ComposerAttachments pending={files.pending} onRemove={files.remove} />
<Row className="h-[88px] shrink-0 gap-4! flex-nowrap items-center px-6 border-0 border-t border-solid border-hairline">
<ComposerAttachButton disabled={files.full} onFiles={files.add} />
<Textarea
name="message"
aria-label={`Message ${bot.name}`}
placeholder={`Message ${bot.name}`}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
const pasted = [...e.clipboardData.files];
if (!pasted.length) return;
e.preventDefault();
files.add({ files: pasted });
}}
aria-invalid={error ? true : undefined}
title={error}
className="field-sizing-content max-h-[100px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
wrapperWrapperProps={{ className: "grow min-w-0" }}
wrapperProps={{
className:
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
}}
/>
<ComposerSendButton disabled={!can_send} loading={sending || files.uploading} onClick={send} />
</Row>
</Stack>
);
}
@@ -4,18 +4,25 @@ import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span";
import BotStone from "@/src/components/general/bot-stone";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import { crewStatusSentence } from "@/src/utils/crew-status";
import BotModelsModal from "./bot-models-modal";
type Props = {
bot: BotWithLastMessage;
bots: BotWithLastMessage[];
/**
* The bot's models (the defaults for the coordinator).
*/
harnesses?: HarnessOverview[] | null;
};
/**
* 64px header. The coordinator's status is the crew sentence.
* 64px header. The coordinator's status is the crew sentence. On the
* right, the model button that opens the bot's models (the defaults on
* the coordinator, for super admins).
*/
export default function ConversationHeader({ bot, bots }: Props) {
export default function ConversationHeader({ bot, bots, harnesses }: Props) {
const status =
bot.kind === "coordinator"
? crewStatusSentence({ bots })
@@ -36,6 +43,7 @@ export default function ConversationHeader({ bot, bots }: Props) {
{status}
</Span>
</Stack>
<BotModelsModal key={bot.id} bot={bot} harnesses={harnesses} />
</Row>
);
}
@@ -0,0 +1,40 @@
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 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.
*/
export default function MessageAttachment({
name,
media_type,
url,
thumbnail_url,
}: BotMessageAttachment) {
if (media_type == "image") {
return (
<Link href={url} target="_blank" 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>
);
}
return (
<Link
href={url}
target="_blank"
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>
);
}
@@ -0,0 +1,22 @@
import Row from "@moduletrace/twui/layout/Row";
import type { BotMessageAttachment } from "@/src/types";
import MessageAttachment from "./message-attachment";
type Props = {
attachments?: BotMessageAttachment[];
};
/**
* A message's files, right-aligned above its bubble.
*/
export default function MessageAttachments({ attachments }: Props) {
if (!attachments?.length) return null;
return (
<Row className="max-w-[80%] gap-2! justify-end">
{attachments.map((attachment) => (
<MessageAttachment key={attachment.id} {...attachment} />
))}
</Row>
);
}
@@ -1,17 +1,18 @@
import Stack from "@moduletrace/twui/layout/Stack";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import type { BotMessageWithAttachments, BotWithLastMessage } from "@/src/types";
import groupMessages from "../(utils)/group-messages";
import BotThinkingRow from "./bot-thinking-row";
import MessageGroupBlock from "./message-group-block";
type Props = {
bot: BotWithLastMessage;
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
messages: BotMessageWithAttachments[];
};
/**
* Bottom-anchored: the stack sits 24px above the composer rule and grows
* upward. Empty space belongs at the top. New messages announce politely.
* While the bot works on a reply, a thinking row sits under the last one.
*/
export default function MessageThread({ bot, messages }: Props) {
const groups = groupMessages({ messages });
@@ -27,6 +28,7 @@ export default function MessageThread({ bot, messages }: Props) {
{groups.map((group) => (
<MessageGroupBlock key={group.key} bot={bot} group={group} />
))}
<BotThinkingRow bot={bot} />
</Stack>
</Stack>
);
@@ -1,16 +1,17 @@
import Stack from "@moduletrace/twui/layout/Stack";
import type { MessageGroup } from "../(utils)/group-messages";
import UserBubble from "./user-bubble";
import UserMessage from "./user-message";
import GroupTimestamp from "./group-timestamp";
/**
* Right-aligned ink bubbles, one timestamp under the group.
* Right-aligned ink bubbles (each message's files above it), one
* timestamp under the group.
*/
export default function UserMessageGroup({ group }: { group: MessageGroup }) {
return (
<Stack className="w-full gap-1! items-end">
{group.messages.map((message) => (
<UserBubble key={message.id} text={message.text || ""} />
<UserMessage key={message.id} {...message} />
))}
<GroupTimestamp at={group.at} />
</Stack>
@@ -0,0 +1,16 @@
import type { BotMessageWithAttachments } from "@/src/types";
import MessageAttachments from "./message-attachments";
import UserBubble from "./user-bubble";
/**
* One user message: its files, then its text (a file-only message has no
* bubble).
*/
export default function UserMessage({ text, attachments }: BotMessageWithAttachments) {
return (
<>
<MessageAttachments attachments={attachments} />
{text ? <UserBubble text={text} /> : null}
</>
);
}
@@ -1,7 +1,10 @@
import { useContext, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotMessageWithAttachments } from "@/src/types";
import { AppContext } from "@/src/pages/__root";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import useLiveBots from "@/src/hooks/use-live-bots";
import useServerEvent from "@/src/hooks/use-server-event";
import ConversationHeader from "../(partials)/conversation-header";
import MessageThread from "../(partials)/message-thread";
import Composer from "../(partials)/composer";
@@ -13,20 +16,47 @@ export default function Conversation() {
const { pageProps } = useContext(AppContext);
const bot = pageProps?.bot;
const [messages, setMessages] = useState<BUN_SQLITE_COBBLE_BOT_MESSAGES[]>(
const [messages, setMessages] = useState<BotMessageWithAttachments[]>(
pageProps?.bot_messages || [],
);
if (!bot?.id) return null;
const bots = useLiveBots({ bots: pageProps?.bots || [], open_bot_id: bot?.id || undefined });
const live_bot = bots.find((b) => b.id == bot?.id) || bot;
useServerEvent({
event: "server:bot-message",
onData: (message: BotMessageWithAttachments) => {
if (message.bot_id != bot?.id) return;
setMessages((prev) =>
prev.some((m) => m.id == message.id) ? prev : [...prev, message],
);
if (message.author == "bot") {
fetchApi(`/api/admin/bots/mark-read`, {
method: "POST",
body: { bot_id: message.bot_id },
}).catch(() => {});
}
},
});
if (!bot?.id || !live_bot) return null;
return (
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<ConversationHeader bot={bot} bots={pageProps?.bots || []} />
<MessageThread bot={bot} messages={messages} />
<ConversationHeader
bot={live_bot}
bots={bots}
harnesses={pageProps?.bot_harness_overview?.harnesses}
/>
<MessageThread bot={live_bot} messages={messages} />
<Composer
bot={bot}
onSent={({ message }) =>
setMessages((prev) => [...prev, message])
setMessages((prev) =>
prev.some((m) => m.id == message.id) ? prev : [...prev, message],
)
}
/>
</Stack>
@@ -1,17 +1,28 @@
import { useContext } from "react";
import { useContext, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import H2 from "@moduletrace/twui/layout/H2";
import { AppContext } from "@/src/pages/__root";
import useServerEvent from "@/src/hooks/use-server-event";
import type { BotUpdatedData } from "@/src/types";
import TaskStepRow from "../(partials)/task-step-row";
import OpenComputerButton from "../(partials)/open-computer-button";
/**
* 280px panel listing the steps the bot is taking. Hidden below 1280px.
* 280px panel listing the steps the bot is taking, kept current by the
* active task each `server:bot-updated` push carries. Hidden below 1280px.
*/
export default function WorkingOnPanel() {
const { pageProps } = useContext(AppContext);
const task = pageProps?.bot_task;
const bot_id = pageProps?.bot?.id;
const [task, setTask] = useState(pageProps?.bot_task);
useServerEvent({
event: "server:bot-updated",
onData: (data: BotUpdatedData) => {
if (data.id == bot_id) setTask(data.task);
},
});
return (
<Stack
@@ -1,9 +1,9 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotMessageWithAttachments } from "@/src/types";
export type MessageGroup = {
key: string;
author: "bot" | "user";
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
messages: BotMessageWithAttachments[];
/**
* Time of the group's last message, shown once per group.
*/
@@ -11,7 +11,7 @@ export type MessageGroup = {
};
type Params = {
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
messages: BotMessageWithAttachments[];
/**
* Consecutive messages from one author within this gap share a group.
*/
@@ -0,0 +1,21 @@
type Params = {
onFiles: (params: { files: File[] }) => void;
};
/**
* Opens the browser's file picker (several files allowed). The input is
* made on the spot rather than rendered: twui's `Input` writes its value
* back into the element, which a file input refuses.
*/
export default function pickFiles({ onFiles }: Params) {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.addEventListener("change", () => {
const files = [...(input.files || [])];
if (files.length) onFiles({ files });
});
input.click();
}
+12 -1
View File
@@ -3,6 +3,8 @@ import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
@@ -34,9 +36,17 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
bot.unread = 0;
}
const [bot_messages, bot_task] = await Promise.all([
/**
* The header's models modal: a worker's own settings, or for the
* coordinator the defaults it runs on (without the providers).
*/
const [bot_messages, bot_task, bot_harness_overview] = await Promise.all([
grabBotMessages({ bot_id: bot.id }),
grabActiveBotTask({ bot_id: bot.id }),
(bot.kind == "coordinator"
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({ bot_id: bot.id })
).catch(() => null),
]);
return {
@@ -45,6 +55,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
bot,
bot_messages,
bot_task,
bot_harness_overview,
},
};
};
@@ -0,0 +1,19 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { SaveBotHarnessSettingsRequest } from "@/src/schemas/harness-schema";
import type { BotHarnessOverviewPayload } from "@/src/types";
/**
* Saves a worker bot's own model settings and returns the fresh page data.
*/
export default async function saveBotHarnessSettings(
body: SaveBotHarnessSettingsRequest,
) {
return await fetchApi<
SaveBotHarnessSettingsRequest,
APIResponseObject<BotHarnessOverviewPayload>
>(`/api/admin/bots/save-harness-settings`, {
method: "POST",
body,
});
}
@@ -0,0 +1,25 @@
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
import BotHarnessForm from "../../(partials)/bot-harness-form";
/**
* A worker bot's models: per harness, the default or its own pick. The
* same form as the chat header's models modal.
*/
export default function BotModelsSection() {
const { pageProps } = useContext(AppContext);
const bot = pageProps?.bot;
const overview = pageProps?.bot_harness_overview;
if (!bot?.id || !overview) return null;
return (
<SettingsShell
title={`${bot.name}'s models`}
description="Uses the default models unless you pick its own."
>
<BotHarnessForm bot={bot} harnesses={overview.harnesses} />
</SettingsShell>
);
}
@@ -0,0 +1,37 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* A worker bot's model settings. The coordinator always uses the defaults,
* so its page goes back to its conversation.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query;
const { user } = await userAuth({ req: params.req });
if (!user?.id) {
return { redirect: { destination: "/auth/login" } };
}
const bots = await grabBots({ user_id: user.id });
const bot = bots.find((b) => String(b.id) === String(query.bot_id));
if (!bot?.id) {
return { redirect: { destination: "/admin/bots" } };
}
if (bot.kind == "coordinator") {
return { redirect: { destination: `/admin/bots/${bot.id}` } };
}
return {
props: {
bots,
bot,
bot_harness_overview: await grabBotHarnessOverview({ bot_id: bot.id }),
},
};
};
@@ -0,0 +1,17 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import BotModelsSection from "./(sections)/bot-models-section";
export default function BotModelsPage() {
return <BotModelsSection />;
}
export const meta: BunextPageModuleMetaFn = async (params) => {
const bot = (params.serverRes?.props as PagePropsType | undefined)?.bot;
return {
title: `${bot?.name || "Bot"} models | ${SiteData["SiteName"]}`,
description: `Which models ${bot?.name || "this bot"} uses.`,
};
};