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:
@@ -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();
|
||||
}
|
||||
@@ -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.`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { EnvsOverviewPayload } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Under `/api/admin/envs/`.
|
||||
*/
|
||||
path: "save-env" | "delete-env";
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Posts to an env API route. Every route answers with the fresh page data
|
||||
* in `singleRes`, so the page updates in place.
|
||||
*/
|
||||
export default async function postEnvApi({ path, body }: Params) {
|
||||
return await fetchApi<Record<string, unknown>, APIResponseObject<EnvsOverviewPayload>>(
|
||||
`/api/admin/envs/${path}`,
|
||||
{ method: "POST", body },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { EnvScope, EnvsOverviewPayload, KnownEnvStatus, PublicEnv } from "@/src/types";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import postEnvApi from "../(functions)/post-env-api";
|
||||
|
||||
type Props = {
|
||||
scope: EnvScope;
|
||||
/**
|
||||
* Edits this env; adds one when not given.
|
||||
*/
|
||||
env?: PublicEnv;
|
||||
/**
|
||||
* Sets this app setting: its key, description and flags are fixed.
|
||||
*/
|
||||
known?: KnownEnvStatus;
|
||||
onSaved: (overview: EnvsOverviewPayload, msg: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits an env in one scope. A saved secret is never shown; typing
|
||||
* a new value replaces it, leaving it empty keeps it.
|
||||
*/
|
||||
export default function EnvForm({ scope, env, known, onSaved, onCancel }: Props) {
|
||||
const is_known = Boolean(known || env?.known);
|
||||
const [key, setKey] = useState(env?.key || known?.key || "");
|
||||
const [value, setValue] = useState(env && !env.secret ? env.value || "" : "");
|
||||
const [description, setDescription] = useState(env?.description || "");
|
||||
const [secret, setSecret] = useState(env?.secret ?? known?.secret ?? true);
|
||||
const [expose, setExpose] = useState(env?.expose_to_computers ?? true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const id = `env-${env?.id || known?.key || "new"}`;
|
||||
const keeps_secret = Boolean(env?.secret);
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postEnvApi({
|
||||
path: "save-env",
|
||||
body: {
|
||||
env_id: env?.id,
|
||||
key,
|
||||
value: keeps_secret && !value ? undefined : value,
|
||||
description: is_known ? undefined : description,
|
||||
paradigm: scope.paradigm,
|
||||
workspace_id: scope.paradigm == "workspace" ? scope.id : null,
|
||||
computer_id: scope.paradigm == "computer" ? scope.id : null,
|
||||
secret,
|
||||
expose_to_computers: expose,
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the env.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(res.singleRes, res.msg || "Saved.");
|
||||
};
|
||||
|
||||
return (
|
||||
<Form className="w-full gap-4! items-stretch py-4" onSubmit={submit}>
|
||||
<Input
|
||||
id={`${id}-key`}
|
||||
title="Key"
|
||||
label="Key"
|
||||
showLabel
|
||||
placeholder="GITHUB_TOKEN"
|
||||
defaultValue={key}
|
||||
disabled={Boolean(env) || is_known}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}-value`}
|
||||
title="Value"
|
||||
label="Value"
|
||||
showLabel
|
||||
type={secret ? "password" : "text"}
|
||||
autoComplete="off"
|
||||
defaultValue={value}
|
||||
placeholder={keeps_secret ? "Saved. Type a new value to replace it." : ""}
|
||||
info="Encrypted before it's stored."
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
{is_known ? null : (
|
||||
<>
|
||||
<Input
|
||||
id={`${id}-description`}
|
||||
title="Description"
|
||||
label="Description"
|
||||
showLabel
|
||||
placeholder="A token for the company GitHub"
|
||||
defaultValue={description}
|
||||
info="Bots see the key and this description, never the value."
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<Checkbox
|
||||
title="Secret"
|
||||
label="Secret: hidden once saved, and blanked out of everything bots read"
|
||||
checked={secret}
|
||||
changeHandler={setSecret}
|
||||
/>
|
||||
<Checkbox
|
||||
title="Set on computers"
|
||||
label="Set on computers, for bots' commands and code"
|
||||
checked={expose}
|
||||
changeHandler={setExpose}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Stack className="gap-3! items-stretch">
|
||||
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
|
||||
<Row className="gap-2! flex-nowrap justify-end">
|
||||
<Button
|
||||
title="Cancel"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={loading}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
title={env ? "Save env" : "Add env"}
|
||||
type="submit"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{env ? "Save env" : "Add env"}
|
||||
</Button>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
/**
|
||||
* "set" for an app setting that isn't saved yet.
|
||||
*/
|
||||
mode: "edit" | "set";
|
||||
busy?: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit (or Set) and Remove for one env row.
|
||||
*/
|
||||
export default function EnvRowActions({ name, mode, busy, onEdit, onRemove }: Props) {
|
||||
return (
|
||||
<Row className="gap-1! flex-nowrap shrink-0">
|
||||
<Button
|
||||
title={mode == "set" ? `Set ${name}` : `Edit ${name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={onEdit}
|
||||
>
|
||||
{mode == "set" ? <Plus size={16} aria-hidden /> : <Pencil size={16} aria-hidden />}
|
||||
</Button>
|
||||
{onRemove ? (
|
||||
<Button
|
||||
title={`Remove ${name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={busy}
|
||||
disabled={busy}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { EnvScope, EnvsOverviewPayload, KnownEnvStatus, PublicEnv } from "@/src/types";
|
||||
import postEnvApi from "../(functions)/post-env-api";
|
||||
import EnvForm from "./env-form";
|
||||
import EnvRowActions from "./env-row-actions";
|
||||
|
||||
type Props = {
|
||||
scope: EnvScope;
|
||||
/**
|
||||
* The saved env, if any. An app setting may have none yet.
|
||||
*/
|
||||
env?: PublicEnv;
|
||||
known?: KnownEnvStatus;
|
||||
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One env: its key, description and value (or "secret"), with Edit and
|
||||
* Remove. Edit opens the form in place.
|
||||
*/
|
||||
export default function EnvRow({ scope, env, known, onChanged, onError }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const key = env?.key || known?.key || "";
|
||||
|
||||
const remove = async () => {
|
||||
if (!env || busy) return;
|
||||
if (!window.confirm(`Remove ${key}? Computers drop it on their next run.`)) return;
|
||||
|
||||
setBusy(true);
|
||||
const res = await postEnvApi({ path: "delete-env", body: { env_id: env.id } });
|
||||
setBusy(false);
|
||||
|
||||
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Removed.");
|
||||
else onError(res.msg || "Couldn't remove the env.");
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<EnvForm
|
||||
scope={scope}
|
||||
env={env}
|
||||
known={known}
|
||||
onSaved={(overview, msg) => {
|
||||
setEditing(false);
|
||||
onChanged(overview, msg);
|
||||
}}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const status = env
|
||||
? env.secret
|
||||
? "Secret, saved"
|
||||
: env.value || "Empty"
|
||||
: known?.from_process
|
||||
? "From the server's environment"
|
||||
: "Not set";
|
||||
|
||||
const flags = env && !env.known && !env.expose_to_computers ? " · not on computers" : "";
|
||||
|
||||
return (
|
||||
<Row className="w-full gap-3! flex-nowrap items-start justify-between py-3 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<Stack className="gap-0.5! min-w-0">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate">{key}</Span>
|
||||
{env?.description || known?.description ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary break-words">
|
||||
{known?.description || env?.description}
|
||||
</Span>
|
||||
) : null}
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary break-all">
|
||||
{status}
|
||||
{flags}
|
||||
</Span>
|
||||
</Stack>
|
||||
<EnvRowActions
|
||||
name={key}
|
||||
mode={env ? "edit" : "set"}
|
||||
busy={busy}
|
||||
onEdit={() => setEditing(true)}
|
||||
onRemove={env ? remove : undefined}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import type { EnvScope } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import scopeValue from "../(utils)/scope-value";
|
||||
|
||||
type Props = {
|
||||
scopes: EnvScope[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
status?: { error?: boolean; success?: boolean; msg?: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the envs below apply: general, a workspace or a computer. Also
|
||||
* shows the last change's status.
|
||||
*/
|
||||
export default function EnvScopeSelect({ scopes, value, onChange, status }: Props) {
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Where"
|
||||
description="A computer's own envs beat its workspace's, which beat general ones."
|
||||
>
|
||||
<Select
|
||||
id="env-scope"
|
||||
title="Where"
|
||||
label="Where"
|
||||
showLabel
|
||||
options={scopes.map((scope) => ({
|
||||
value: scopeValue({ scope }),
|
||||
title: scope.title,
|
||||
default: scopeValue({ scope }) == value,
|
||||
}))}
|
||||
changeHandler={(next) => onChange(String(next))}
|
||||
/>
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { EnvScope, EnvsOverviewPayload, PublicEnv } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import EnvForm from "./env-form";
|
||||
import EnvRow from "./env-row";
|
||||
|
||||
type Props = {
|
||||
scope: EnvScope;
|
||||
/**
|
||||
* The scope's own envs (app settings left out).
|
||||
*/
|
||||
envs: PublicEnv[];
|
||||
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
const Descriptions: Record<EnvScope["paradigm"], string> = {
|
||||
general: "Set on every computer. A workspace or computer can set the same key to override it.",
|
||||
workspace: "Set on this workspace's computers when its bots use them. Beats general, loses to a computer's own.",
|
||||
computer: "Set on this computer only. Beats workspace and general.",
|
||||
};
|
||||
|
||||
/**
|
||||
* The envs bots and computers use, in one scope, with an add form.
|
||||
*/
|
||||
export default function EnvsGroup({ scope, envs, onChanged, onError }: Props) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
return (
|
||||
<SettingsGroup title="Envs" description={Descriptions[scope.paradigm]}>
|
||||
<Stack className="w-full gap-0! items-stretch">
|
||||
{envs.map((env) => (
|
||||
<EnvRow
|
||||
key={env.id}
|
||||
scope={scope}
|
||||
env={env}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
{envs.length ? null : (
|
||||
<Span className="text-[13px] leading-5 text-ink-secondary py-3">
|
||||
Nothing set here yet.
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
{adding ? (
|
||||
<EnvForm
|
||||
scope={scope}
|
||||
onSaved={(overview, msg) => {
|
||||
setAdding(false);
|
||||
onChanged(overview, msg);
|
||||
}}
|
||||
onCancel={() => setAdding(false)}
|
||||
/>
|
||||
) : (
|
||||
<Row className="gap-3! items-center justify-end">
|
||||
<Button
|
||||
title="Add an env"
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
<Plus size={16} aria-hidden />
|
||||
Add an env
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { EnvScope, EnvsOverviewPayload, PublicEnv } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import EnvRow from "./env-row";
|
||||
|
||||
type Props = {
|
||||
scope: EnvScope;
|
||||
overview: EnvsOverviewPayload;
|
||||
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The app settings: envs the app itself reads. General only, never on
|
||||
* computers.
|
||||
*/
|
||||
export default function KnownEnvsGroup({ scope, overview, onChanged, onError }: Props) {
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="App settings"
|
||||
description="What the app itself reads. A value here wins over the server's environment. These never reach computers or bots."
|
||||
>
|
||||
<Stack className="w-full gap-0! items-stretch">
|
||||
{overview.known.map((known) => (
|
||||
<EnvRow
|
||||
key={known.key}
|
||||
scope={scope}
|
||||
known={known}
|
||||
env={findGeneral({ envs: overview.envs, key: known.key })}
|
||||
onChanged={onChanged}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function findGeneral({ envs, key }: { envs: PublicEnv[]; key: string }) {
|
||||
return envs.find((env) => env.paradigm == "general" && env.key == key);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { EnvsOverviewPayload } from "@/src/types";
|
||||
import EnvScopeSelect from "../(partials)/env-scope-select";
|
||||
import EnvsGroup from "../(partials)/envs-group";
|
||||
import KnownEnvsGroup from "../(partials)/known-envs-group";
|
||||
import scopeValue, { envInScope } from "../(utils)/scope-value";
|
||||
|
||||
/**
|
||||
* Pick where (general, a workspace, a computer), then that scope's envs.
|
||||
* General also lists the app settings. Every change saves at once and
|
||||
* refreshes the page data.
|
||||
*/
|
||||
export default function EnvironmentSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [overview, setOverview] = useState(pageProps?.envs_overview);
|
||||
const [selected, setSelected] = useState("general");
|
||||
const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg?: string }>();
|
||||
/**
|
||||
* Remounts the lists when the data changes under them.
|
||||
*/
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
if (!overview) return null;
|
||||
|
||||
const scope =
|
||||
overview.scopes.find((s) => scopeValue({ scope: s }) == selected) || overview.scopes[0]!;
|
||||
|
||||
const changed = (next: EnvsOverviewPayload, msg: string) => {
|
||||
setOverview(next);
|
||||
setStatus({ success: true, msg });
|
||||
setVersion((prev) => prev + 1);
|
||||
};
|
||||
const failed = (msg: string) => setStatus({ error: true, msg });
|
||||
|
||||
return (
|
||||
<>
|
||||
<EnvScopeSelect
|
||||
scopes={overview.scopes}
|
||||
value={scopeValue({ scope })}
|
||||
status={status}
|
||||
onChange={(next) => {
|
||||
setSelected(next);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
/>
|
||||
{scope.paradigm == "general" ? (
|
||||
<KnownEnvsGroup
|
||||
key={`known-${version}`}
|
||||
scope={scope}
|
||||
overview={overview}
|
||||
onChanged={changed}
|
||||
onError={failed}
|
||||
/>
|
||||
) : null}
|
||||
<EnvsGroup
|
||||
key={`${scopeValue({ scope })}-${version}`}
|
||||
scope={scope}
|
||||
envs={overview.envs.filter((env) => !env.known && envInScope({ env, scope }))}
|
||||
onChanged={changed}
|
||||
onError={failed}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { PublicEnv } from "@/src/types";
|
||||
import scopeValue, { envInScope } from "./scope-value";
|
||||
|
||||
const env = (fields: Partial<PublicEnv>): PublicEnv => ({
|
||||
id: 1,
|
||||
key: "K",
|
||||
description: "",
|
||||
paradigm: "general",
|
||||
workspace_id: null,
|
||||
computer_id: null,
|
||||
secret: true,
|
||||
expose_to_computers: true,
|
||||
known: false,
|
||||
...fields,
|
||||
});
|
||||
|
||||
describe("scopeValue", () => {
|
||||
test("names each scope", () => {
|
||||
expect(scopeValue({ scope: { paradigm: "general", id: null } })).toBe("general");
|
||||
expect(scopeValue({ scope: { paradigm: "computer", id: 4 } })).toBe("computer:4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("envInScope", () => {
|
||||
test("matches the paradigm and its id", () => {
|
||||
const scope = { paradigm: "workspace" as const, id: 2, title: "" };
|
||||
|
||||
expect(envInScope({ env: env({ paradigm: "workspace", workspace_id: 2 }), scope })).toBe(true);
|
||||
expect(envInScope({ env: env({ paradigm: "workspace", workspace_id: 3 }), scope })).toBe(false);
|
||||
expect(envInScope({ env: env({}), scope })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EnvScope, PublicEnv } from "@/src/types";
|
||||
|
||||
/**
|
||||
* A scope as one select value: `general`, `workspace:3`, `computer:1`.
|
||||
*/
|
||||
export default function scopeValue({ scope }: { scope: Pick<EnvScope, "paradigm" | "id"> }) {
|
||||
return scope.paradigm == "general" ? "general" : `${scope.paradigm}:${scope.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an env is set in this scope.
|
||||
*/
|
||||
export function envInScope({ env, scope }: { env: PublicEnv; scope: EnvScope }) {
|
||||
if (env.paradigm != scope.paradigm) return false;
|
||||
if (scope.paradigm == "workspace") return env.workspace_id == scope.id;
|
||||
if (scope.paradigm == "computer") return env.computer_id == scope.id;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabEnvsOverview from "@/src/functions/backend/db/envs/grab-envs-overview";
|
||||
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||
|
||||
/**
|
||||
* Super admins only; everyone else lands on the general settings.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
|
||||
if (!settings.props) return settings;
|
||||
|
||||
const user = await grabSuperAdminUser({ req: params.req });
|
||||
|
||||
if (!user?.id) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: SettingsDefaultPath,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
envs_overview: await grabEnvsOverview({ user_id: Number(user.id) }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import EnvironmentSettingsSection from "./(sections)/environment-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsEnvironmentPage() {
|
||||
return (
|
||||
<SettingsShell
|
||||
title="Environment"
|
||||
description="Keys and settings your bots and computers use."
|
||||
>
|
||||
<EnvironmentSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Environment" });
|
||||
@@ -0,0 +1,25 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { HarnessOverviewPayload } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Under `/api/admin/harness/`, e.g. `save-provider`.
|
||||
*/
|
||||
path: "save-provider" | "sync-provider" | "delete-provider" | "save-settings";
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Posts to a harness API route. Every route answers with the fresh page
|
||||
* data in `singleRes`, so the page updates in place.
|
||||
*/
|
||||
export default async function postHarnessApi({ path, body }: Params) {
|
||||
return await fetchApi<
|
||||
Record<string, unknown>,
|
||||
APIResponseObject<HarnessOverviewPayload>
|
||||
>(`/api/admin/harness/${path}`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { HarnessOverview } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
harness: HarnessOverview;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the harness runs on right now, as saved, in one line.
|
||||
*/
|
||||
export default function HarnessCurrentModel({ harness }: Props) {
|
||||
const text = !harness.enabled
|
||||
? "Turned off."
|
||||
: harness.current
|
||||
? `Now using ${harness.current.name} on ${harness.current.provider_name}${
|
||||
harness.current.source == "auto" ? ", picked automatically" : ""
|
||||
}.${
|
||||
harness.candidate_count > 1
|
||||
? ` ${harness.candidate_count - 1} more to fall back on.`
|
||||
: ""
|
||||
}`
|
||||
: harness.required
|
||||
? "Nothing to run on yet: no provider has a usable key."
|
||||
: "No free model can do this right now. Pick one to turn it on.";
|
||||
|
||||
return (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{text}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
|
||||
import type { HarnessModelOption } from "@/src/types";
|
||||
import harnessModelSelectOptions, {
|
||||
AutoModelValue,
|
||||
} from "../(utils)/harness-model-select-options";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
/**
|
||||
* Already narrowed to the picked provider.
|
||||
*/
|
||||
models: HarnessModelOption[];
|
||||
model_id: number | null;
|
||||
provider_name?: string;
|
||||
onChange: (model_id: number | null) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Searchable model picker: automatic, or any model that can do the job.
|
||||
*/
|
||||
export default function HarnessModelSelect({
|
||||
id,
|
||||
models,
|
||||
model_id,
|
||||
provider_name,
|
||||
onChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<SearchSelect
|
||||
id={id}
|
||||
title="Model"
|
||||
label="Model"
|
||||
showLabel
|
||||
placeholder="Search models"
|
||||
options={harnessModelSelectOptions({ models, model_id, provider_name })}
|
||||
defaultValues={[model_id ? String(model_id) : AutoModelValue]}
|
||||
changeHandler={(value) => {
|
||||
const picked = Array.isArray(value) ? value[0] : value;
|
||||
|
||||
onChange(!picked || picked == AutoModelValue ? null : Number(picked));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import type { HarnessProviderChoice } from "@/src/types";
|
||||
|
||||
export const InheritProviderValue = "inherit";
|
||||
export const AnyProviderValue = "any";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
providers: HarnessProviderChoice[];
|
||||
provider_id: number | null;
|
||||
/**
|
||||
* On a bot's page: offer the default, titled this.
|
||||
*/
|
||||
inherit_title?: string;
|
||||
inherit?: boolean;
|
||||
onChange: (value: { provider_id: number | null; inherit: boolean }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a harness gets its models: any provider, or one of them. On a
|
||||
* bot's page it can also use the default setting instead.
|
||||
*/
|
||||
export default function HarnessProviderSelect({
|
||||
id,
|
||||
providers,
|
||||
provider_id,
|
||||
inherit_title,
|
||||
inherit,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const value = inherit
|
||||
? InheritProviderValue
|
||||
: provider_id
|
||||
? String(provider_id)
|
||||
: AnyProviderValue;
|
||||
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
title="Provider"
|
||||
label="Provider"
|
||||
showLabel
|
||||
value={value}
|
||||
options={[
|
||||
...(inherit_title ? [{ value: InheritProviderValue, title: inherit_title }] : []),
|
||||
{ value: AnyProviderValue, title: "Any provider" },
|
||||
...providers.map((provider) => ({
|
||||
value: String(provider.id),
|
||||
title: provider.name,
|
||||
})),
|
||||
]}
|
||||
changeHandler={(picked) =>
|
||||
onChange({
|
||||
inherit: picked == InheritProviderValue,
|
||||
provider_id:
|
||||
picked == InheritProviderValue || picked == AnyProviderValue
|
||||
? null
|
||||
: Number(picked),
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import type { HarnessFormValue, HarnessOverview } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import HarnessCurrentModel from "./harness-current-model";
|
||||
import HarnessModelSelect from "./harness-model-select";
|
||||
import HarnessProviderSelect from "./harness-provider-select";
|
||||
import HarnessVoiceSelect from "./harness-voice-select";
|
||||
|
||||
type Props = {
|
||||
harness: HarnessOverview;
|
||||
value: HarnessFormValue;
|
||||
onChange: (value: HarnessFormValue) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One harness: its provider, then its model (narrowed to that provider),
|
||||
* whether it falls back to the automatic picks, whether it's on, and its
|
||||
* voice for speech. On a bot's page the provider select can also use the
|
||||
* default, which hides the rest.
|
||||
*/
|
||||
export default function HarnessSettingGroup({ harness, value, onChange }: Props) {
|
||||
const id = `harness-${harness.harness}`;
|
||||
const provider = harness.providers.find((p) => p.id == value.provider_id);
|
||||
const models = value.provider_id
|
||||
? harness.models.filter((model) => model.provider_id == value.provider_id)
|
||||
: harness.models;
|
||||
const picked = models.find((model) => model.id == value.model_id);
|
||||
const voices = (picked || harness.current)?.voices;
|
||||
const on_bot_page = harness.default !== undefined;
|
||||
const default_name = harness.default?.enabled === false
|
||||
? "turned off"
|
||||
: harness.default?.current
|
||||
? `${harness.default.current.name} on ${harness.default.current.provider_name}`
|
||||
: "nothing available";
|
||||
|
||||
return (
|
||||
<SettingsGroup title={harness.title} description={harness.description}>
|
||||
<HarnessProviderSelect
|
||||
id={`${id}-provider`}
|
||||
providers={harness.providers}
|
||||
provider_id={value.provider_id}
|
||||
inherit={value.inherit}
|
||||
inherit_title={on_bot_page ? `Same as the default (now ${default_name})` : undefined}
|
||||
onChange={(next) =>
|
||||
onChange({
|
||||
...value,
|
||||
...next,
|
||||
model_id:
|
||||
next.provider_id && picked?.provider_id != next.provider_id
|
||||
? null
|
||||
: value.model_id,
|
||||
voice: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{value.inherit ? null : (
|
||||
<HarnessModelSelect
|
||||
key={value.provider_id || "any"}
|
||||
id={`${id}-model`}
|
||||
models={models}
|
||||
model_id={value.model_id}
|
||||
provider_name={provider?.name}
|
||||
onChange={(model_id) => onChange({ ...value, model_id, voice: undefined })}
|
||||
/>
|
||||
)}
|
||||
{value.inherit && harness.default ? (
|
||||
<HarnessCurrentModel harness={{ ...harness, ...harness.default }} />
|
||||
) : (
|
||||
<HarnessCurrentModel harness={harness} />
|
||||
)}
|
||||
{!value.inherit && value.model_id ? (
|
||||
<Checkbox
|
||||
title="Fall back to the automatic picks if this model fails"
|
||||
label={`Fall back to the automatic picks${provider ? ` on ${provider.name}` : ""} if this model fails`}
|
||||
checked={value.fallback}
|
||||
changeHandler={(fallback) => onChange({ ...value, fallback })}
|
||||
/>
|
||||
) : null}
|
||||
{!value.inherit && harness.harness == "speech" && voices?.length ? (
|
||||
<HarnessVoiceSelect
|
||||
id={`${id}-voice`}
|
||||
voices={voices}
|
||||
voice={value.voice}
|
||||
onChange={(voice) => onChange({ ...value, voice: voice || undefined })}
|
||||
/>
|
||||
) : null}
|
||||
{value.inherit || harness.required ? null : (
|
||||
<Checkbox
|
||||
title={`Turn ${harness.title.toLowerCase()} on`}
|
||||
label="Turned on"
|
||||
checked={value.enabled}
|
||||
changeHandler={(enabled) => onChange({ ...value, enabled })}
|
||||
/>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import useFormInit from "@/src/hooks/use-form-init";
|
||||
import type { HarnessOverview, HarnessSettingsFormObject } from "@/src/types";
|
||||
import SettingsSaveBar from "../../(partials)/settings-save-bar";
|
||||
import changedHarnessSettings from "../(utils)/changed-harness-settings";
|
||||
import toHarnessForm from "../(utils)/to-harness-form";
|
||||
import HarnessSettingGroup from "./harness-setting-group";
|
||||
|
||||
type Props = {
|
||||
harnesses: HarnessOverview[];
|
||||
/**
|
||||
* Sends the changed settings. The page refreshes its own data on
|
||||
* success (which remounts this form).
|
||||
*/
|
||||
save: (params: {
|
||||
settings: ReturnType<typeof changedHarnessSettings>;
|
||||
}) => Promise<{ success: boolean; msg?: string }>;
|
||||
/**
|
||||
* The last save's message, kept by the page across the remount.
|
||||
*/
|
||||
notice?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which model each harness uses, saved together from one save bar. Used
|
||||
* for the defaults and, with "same as the default", for a worker bot.
|
||||
*/
|
||||
export default function HarnessSettingsForm({
|
||||
harnesses,
|
||||
save,
|
||||
notice: saved_notice,
|
||||
}: Props) {
|
||||
const saved = toHarnessForm({ harnesses });
|
||||
/**
|
||||
* Inputs keep their own state; bumping this remounts them on Discard.
|
||||
*/
|
||||
const [form_key, setFormKey] = useState(0);
|
||||
/**
|
||||
* Kept here: `useFormInit` clears `status` whenever the form changes.
|
||||
*/
|
||||
const [notice, setNotice] = useState<string | undefined>(saved_notice);
|
||||
|
||||
const { form, setForm, loading, setLoading, status, setStatus } =
|
||||
useFormInit<HarnessSettingsFormObject>({ existing: saved });
|
||||
|
||||
const changed = changedHarnessSettings({ form, saved });
|
||||
const dirty = changed.length > 0;
|
||||
|
||||
const discard = () => {
|
||||
setForm(saved);
|
||||
setStatus({});
|
||||
setNotice(undefined);
|
||||
setFormKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!dirty || loading) return;
|
||||
|
||||
setLoading(true);
|
||||
setStatus({});
|
||||
|
||||
const res = await save({ settings: changed });
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success) {
|
||||
setStatus({ error: true, msg: res.msg || "Couldn't save the model settings." });
|
||||
return;
|
||||
}
|
||||
|
||||
setNotice(res.msg || "Saved.");
|
||||
};
|
||||
|
||||
return (
|
||||
<Form key={form_key} className="w-full gap-0! items-stretch" onSubmit={submit}>
|
||||
{harnesses.map((harness) => (
|
||||
<HarnessSettingGroup
|
||||
key={harness.harness}
|
||||
harness={harness}
|
||||
value={form[harness.harness] || saved[harness.harness]!}
|
||||
onChange={(value) => {
|
||||
setNotice(undefined);
|
||||
setForm((prev) => ({ ...prev, [harness.harness]: value }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SettingsSaveBar
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
status={
|
||||
status?.msg
|
||||
? status
|
||||
: !dirty && notice
|
||||
? { success: true, msg: notice }
|
||||
: undefined
|
||||
}
|
||||
onDiscard={discard}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
voices: string[];
|
||||
voice?: string;
|
||||
onChange: (voice: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Voice for the speech harness, from the model's own voices.
|
||||
*/
|
||||
export default function HarnessVoiceSelect({ id, voices, voice, onChange }: Props) {
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
title="Voice"
|
||||
label="Voice"
|
||||
showLabel
|
||||
options={[
|
||||
{ value: "", title: "The model's default", default: !voice },
|
||||
...voices.map((name) => ({
|
||||
value: name,
|
||||
title: name,
|
||||
default: name == voice,
|
||||
})),
|
||||
]}
|
||||
changeHandler={(value) => onChange(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import {
|
||||
HarnessProviderKinds,
|
||||
type HarnessProviderKind,
|
||||
} from "@/src/dict/harness-provider-dict";
|
||||
import type { HarnessOverviewPayload, PublicHarnessProvider } from "@/src/types";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import postHarnessApi from "../(functions)/post-harness-api";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Edits this provider; adds one when not given.
|
||||
*/
|
||||
provider?: PublicHarnessProvider;
|
||||
onSaved: (overview: HarnessOverviewPayload, msg: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits a provider. A saved key is never shown; typing a new one
|
||||
* replaces it.
|
||||
*/
|
||||
export default function ProviderForm({ provider, onSaved, onCancel }: Props) {
|
||||
const [kind, setKind] = useState<HarnessProviderKind>(
|
||||
provider?.kind || "openrouter",
|
||||
);
|
||||
const preset = HarnessProviderKinds.find((p) => p.value == kind)!;
|
||||
const [name, setName] = useState(provider?.name || "");
|
||||
const [base_url, setBaseUrl] = useState(provider?.base_url || "");
|
||||
const [api_key, setApiKey] = useState("");
|
||||
const [clear_key, setClearKey] = useState(false);
|
||||
const [enabled, setEnabled] = useState(provider?.enabled ?? true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const id = `provider-${provider?.id || "new"}`;
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postHarnessApi({
|
||||
path: "save-provider",
|
||||
body: {
|
||||
provider_id: provider?.id,
|
||||
kind,
|
||||
name,
|
||||
base_url,
|
||||
api_key: api_key || undefined,
|
||||
clear_key: clear_key || undefined,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the provider.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(res.singleRes, res.msg || "Saved.");
|
||||
};
|
||||
|
||||
return (
|
||||
<Form className="w-full gap-4! items-stretch" onSubmit={submit}>
|
||||
{provider ? null : (
|
||||
<Select
|
||||
id={`${id}-kind`}
|
||||
title="Provider"
|
||||
label="Provider"
|
||||
showLabel
|
||||
options={HarnessProviderKinds.map((option) => ({
|
||||
value: option.value,
|
||||
title: option.title,
|
||||
default: option.value == kind,
|
||||
}))}
|
||||
changeHandler={(value) => {
|
||||
setKind(value as HarnessProviderKind);
|
||||
setBaseUrl("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
id={`${id}-name`}
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
placeholder={preset.title}
|
||||
defaultValue={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}-base-url`}
|
||||
key={`${id}-base-url-${kind}`}
|
||||
title="API address"
|
||||
label="API address"
|
||||
showLabel
|
||||
placeholder={preset.base_url || "http://localhost:8000/v1"}
|
||||
defaultValue={base_url}
|
||||
info={preset.base_url ? `Leave empty for ${preset.base_url}` : "Required: the server's OpenAI-compatible base URL."}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}-key`}
|
||||
title="API key"
|
||||
label="API key"
|
||||
showLabel
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={provider?.has_key ? "Saved. Type a new key to replace it." : "Paste the key"}
|
||||
info={
|
||||
preset.key_env
|
||||
? `Encrypted before it's stored. If empty, ${preset.key_env} from the environment is used.`
|
||||
: "Encrypted before it's stored. Optional for this provider."
|
||||
}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
{provider?.has_key ? (
|
||||
<Checkbox
|
||||
title="Remove the saved key"
|
||||
label="Remove the saved key"
|
||||
checked={clear_key}
|
||||
changeHandler={setClearKey}
|
||||
/>
|
||||
) : null}
|
||||
<Checkbox
|
||||
title="Turn this provider on"
|
||||
label="Turned on"
|
||||
checked={enabled}
|
||||
changeHandler={setEnabled}
|
||||
/>
|
||||
<Stack className="gap-3! items-stretch">
|
||||
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
|
||||
<Row className="gap-2! flex-nowrap justify-end">
|
||||
<Button
|
||||
title="Cancel"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={loading}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
title={provider ? "Save provider" : "Add provider"}
|
||||
type="submit"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{provider ? "Save provider" : "Add provider"}
|
||||
</Button>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
import grabProviderPreset from "@/src/harness/providers/grab-provider-preset";
|
||||
import type { HarnessOverviewPayload, PublicHarnessProvider } from "@/src/types";
|
||||
import postHarnessApi from "../(functions)/post-harness-api";
|
||||
import ProviderForm from "./provider-form";
|
||||
import ProviderStatus from "./provider-status";
|
||||
|
||||
type Props = {
|
||||
provider: PublicHarnessProvider;
|
||||
onChanged: (overview: HarnessOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One provider: name, key and sync status, and its actions. Edit opens the
|
||||
* form in place.
|
||||
*/
|
||||
export default function ProviderRow({ provider, onChanged, onError }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState<"sync" | "delete">();
|
||||
|
||||
const act = async (action: "sync" | "delete") => {
|
||||
if (busy) return;
|
||||
|
||||
if (
|
||||
action == "delete" &&
|
||||
!window.confirm(`Remove ${provider.name}? Harnesses set to its models go back to automatic.`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(action);
|
||||
|
||||
const res = await postHarnessApi({
|
||||
path: action == "sync" ? "sync-provider" : "delete-provider",
|
||||
body: { provider_id: provider.id },
|
||||
});
|
||||
|
||||
setBusy(undefined);
|
||||
|
||||
if (res.singleRes) {
|
||||
onChanged(res.singleRes, res.msg || "Done.");
|
||||
}
|
||||
|
||||
if (!res.success) {
|
||||
onError(res.msg || "That didn't work.");
|
||||
}
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch py-4 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<ProviderForm
|
||||
provider={provider}
|
||||
onSaved={(overview, msg) => {
|
||||
setEditing(false);
|
||||
onChanged(overview, msg);
|
||||
}}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className="w-full gap-3! flex-nowrap items-start justify-between py-3 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<Stack className="gap-0.5! min-w-0">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate">
|
||||
{provider.name}
|
||||
{provider.name != grabProviderPreset({ kind: provider.kind }).title ? (
|
||||
<Span className="font-normal text-ink-secondary">
|
||||
{" "}
|
||||
({grabProviderPreset({ kind: provider.kind }).title})
|
||||
</Span>
|
||||
) : null}
|
||||
</Span>
|
||||
<ProviderStatus provider={provider} />
|
||||
</Stack>
|
||||
<Row className="gap-1! flex-nowrap shrink-0">
|
||||
<Button
|
||||
title={`Sync ${provider.name} models now`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={busy == "sync"}
|
||||
disabled={Boolean(busy) || !provider.enabled}
|
||||
onClick={() => act("sync")}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
title={`Edit ${provider.name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<Pencil size={16} aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
title={`Remove ${provider.name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={busy == "delete"}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => act("delete")}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden />
|
||||
</Button>
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { PublicHarnessProvider } from "@/src/types";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
|
||||
type Props = {
|
||||
provider: PublicHarnessProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the provider's key comes from and how its last model sync went.
|
||||
*/
|
||||
export default function ProviderStatus({ provider }: Props) {
|
||||
const key = provider.has_key
|
||||
? "Key saved"
|
||||
: provider.has_env_key
|
||||
? `Key from ${provider.key_env}`
|
||||
: provider.needs_key
|
||||
? "Needs a key"
|
||||
: "No key needed";
|
||||
|
||||
const sync = !provider.enabled
|
||||
? "Turned off"
|
||||
: provider.sync_error
|
||||
? `Last sync failed: ${provider.sync_error}`
|
||||
: provider.last_synced_at
|
||||
? `Models synced ${relativeTime({ at: provider.last_synced_at }) == "now" ? "just now" : `${relativeTime({ at: provider.last_synced_at })} ago`}`
|
||||
: "Models not synced yet";
|
||||
|
||||
return (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary break-words">
|
||||
{key} · {sync}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { HarnessOverviewPayload, PublicHarnessProvider } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import ProviderForm from "./provider-form";
|
||||
import ProviderRow from "./provider-row";
|
||||
|
||||
type Props = {
|
||||
providers: PublicHarnessProvider[];
|
||||
onChanged: (overview: HarnessOverviewPayload) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where models come from. Each change saves right away and refreshes the
|
||||
* whole page's data.
|
||||
*/
|
||||
export default function ProvidersGroup({ providers, onChanged }: Props) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg?: string }>();
|
||||
|
||||
const changed = (overview: HarnessOverviewPayload, msg: string) => {
|
||||
setStatus({ success: true, msg });
|
||||
onChanged(overview);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Providers"
|
||||
description="Where models come from. Keys are encrypted and never shown again. Model lists refresh every hour."
|
||||
>
|
||||
<Stack className="w-full gap-0! items-stretch">
|
||||
{providers.map((provider) => (
|
||||
<ProviderRow
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
onChanged={changed}
|
||||
onError={(msg) => setStatus({ error: true, msg })}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{adding ? (
|
||||
<ProviderForm
|
||||
onSaved={(overview, msg) => {
|
||||
setAdding(false);
|
||||
changed(overview, msg);
|
||||
}}
|
||||
onCancel={() => setAdding(false)}
|
||||
/>
|
||||
) : (
|
||||
<Row className="gap-3! items-center justify-between">
|
||||
<SettingsStatus status={status} />
|
||||
<Button
|
||||
title="Add a provider"
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
setStatus(undefined);
|
||||
setAdding(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={16} aria-hidden />
|
||||
Add a provider
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import HarnessSettingsForm from "../(partials)/harness-settings-form";
|
||||
import ProvidersGroup from "../(partials)/providers-group";
|
||||
import postHarnessApi from "../(functions)/post-harness-api";
|
||||
|
||||
/**
|
||||
* Providers first (where models come from), then what each harness uses.
|
||||
* Both update from the fresh data every save returns.
|
||||
*/
|
||||
export default function ModelsSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [overview, setOverview] = useState(pageProps?.harness_overview);
|
||||
/**
|
||||
* Remounts the harness form when the data changes under it.
|
||||
*/
|
||||
const [version, setVersion] = useState(0);
|
||||
const [notice, setNotice] = useState<string>();
|
||||
|
||||
if (!overview) return null;
|
||||
|
||||
const update = (next: typeof overview) => {
|
||||
setOverview(next);
|
||||
setNotice(undefined);
|
||||
setVersion((prev) => prev + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProvidersGroup providers={overview.providers} onChanged={update} />
|
||||
<HarnessSettingsForm
|
||||
key={version}
|
||||
harnesses={overview.harnesses}
|
||||
notice={notice}
|
||||
save={async ({ settings }) => {
|
||||
const res = await postHarnessApi({
|
||||
path: "save-settings",
|
||||
body: { settings },
|
||||
});
|
||||
|
||||
if (res.success && res.singleRes) {
|
||||
update(res.singleRes);
|
||||
setNotice(res.msg || "Saved.");
|
||||
}
|
||||
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import changedHarnessSettings from "./changed-harness-settings";
|
||||
|
||||
const saved = {
|
||||
text: { model_id: null, provider_id: null, fallback: true, enabled: true },
|
||||
speech: { model_id: 4, provider_id: null, fallback: true, enabled: true, voice: "george" },
|
||||
};
|
||||
|
||||
describe("changedHarnessSettings", () => {
|
||||
test("is empty when nothing changed", () => {
|
||||
expect(
|
||||
changedHarnessSettings({ form: structuredClone(saved), saved }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("lists only the harnesses that changed", () => {
|
||||
expect(
|
||||
changedHarnessSettings({
|
||||
form: {
|
||||
...saved,
|
||||
speech: { ...saved.speech, voice: "sarah" },
|
||||
},
|
||||
saved,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
harness: "speech",
|
||||
model_id: 4,
|
||||
provider_id: null,
|
||||
fallback: true,
|
||||
enabled: true,
|
||||
voice: "sarah",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("lists a provider change", () => {
|
||||
expect(
|
||||
changedHarnessSettings({
|
||||
form: { ...saved, text: { ...saved.text, provider_id: 2 } },
|
||||
saved,
|
||||
}),
|
||||
).toEqual([{ harness: "text", model_id: null, provider_id: 2, fallback: true, enabled: true }]);
|
||||
});
|
||||
|
||||
test("treats an empty voice as no voice", () => {
|
||||
expect(
|
||||
changedHarnessSettings({
|
||||
form: { text: { ...saved.text, voice: "" } },
|
||||
saved,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import type {
|
||||
HarnessFormValue,
|
||||
HarnessSettingsFormObject,
|
||||
} from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
form: HarnessSettingsFormObject;
|
||||
saved: HarnessSettingsFormObject;
|
||||
};
|
||||
|
||||
/**
|
||||
* The harness settings that differ from what is saved, ready to send.
|
||||
*/
|
||||
export default function changedHarnessSettings({ form, saved }: Params) {
|
||||
return (Object.keys(form) as HarnessKind[]).flatMap((harness) => {
|
||||
const value = form[harness];
|
||||
|
||||
if (!value || isSame({ a: value, b: saved[harness] })) return [];
|
||||
|
||||
return [{ harness, ...value }];
|
||||
});
|
||||
}
|
||||
|
||||
function isSame({ a, b }: { a: HarnessFormValue; b?: HarnessFormValue }) {
|
||||
return (
|
||||
Boolean(b) &&
|
||||
Boolean(a.inherit) == Boolean(b!.inherit) &&
|
||||
a.model_id == b!.model_id &&
|
||||
(a.provider_id ?? null) == (b!.provider_id ?? null) &&
|
||||
a.fallback == b!.fallback &&
|
||||
a.enabled == b!.enabled &&
|
||||
(a.voice || "") == (b!.voice || "")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { TWUISelectOptionObject } from "@moduletrace/twui/form/Select";
|
||||
import type { HarnessModelOption } from "@/src/types";
|
||||
|
||||
export const AutoModelValue = "auto";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The models to offer, already narrowed to the picked provider.
|
||||
*/
|
||||
models: HarnessModelOption[];
|
||||
model_id: number | null;
|
||||
/**
|
||||
* The picked provider's name; undefined for any provider.
|
||||
*/
|
||||
provider_name?: 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).
|
||||
*/
|
||||
export default function harnessModelSelectOptions({
|
||||
models,
|
||||
model_id,
|
||||
provider_name,
|
||||
}: Params): TWUISelectOptionObject[] {
|
||||
const has_free = models.some((model) => model.free);
|
||||
|
||||
return [
|
||||
{
|
||||
value: AutoModelValue,
|
||||
title: provider_name
|
||||
? has_free
|
||||
? `Automatic (best free model on ${provider_name})`
|
||||
: `Automatic (no free models on ${provider_name}: pick one)`
|
||||
: "Automatic (best free model)",
|
||||
default: !model_id,
|
||||
},
|
||||
...models.map((model) => ({
|
||||
value: String(model.id),
|
||||
title: `${model.name}${provider_name ? "" : ` · ${model.provider_name}`}${model.free && !/free/i.test(model.name) ? " · free" : ""}`,
|
||||
default: model.id == model_id,
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
HarnessOverview,
|
||||
HarnessSettingsFormObject,
|
||||
} from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
harnesses: HarnessOverview[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The saved harness settings as form values.
|
||||
*/
|
||||
export default function toHarnessForm({
|
||||
harnesses,
|
||||
}: Params): HarnessSettingsFormObject {
|
||||
return Object.fromEntries(
|
||||
harnesses.map((harness) => [
|
||||
harness.harness,
|
||||
{
|
||||
inherit: harness.inherit,
|
||||
model_id: harness.model_id ?? null,
|
||||
provider_id: harness.provider_id ?? null,
|
||||
fallback: harness.fallback,
|
||||
enabled: harness.enabled,
|
||||
voice:
|
||||
typeof harness.options.voice == "string"
|
||||
? harness.options.voice
|
||||
: undefined,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import ensureDefaultHarnessProvider from "@/src/functions/backend/db/harness/ensure-default-harness-provider";
|
||||
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
|
||||
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||
|
||||
/**
|
||||
* Super admins only; everyone else lands on the general settings.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
|
||||
if (!settings.props) return settings;
|
||||
|
||||
if (!(await grabSuperAdminUser({ req: params.req }))) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: SettingsDefaultPath,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await ensureDefaultHarnessProvider();
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
harness_overview: await grabHarnessOverview(),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import ModelsSettingsSection from "./(sections)/models-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsModelsPage() {
|
||||
return (
|
||||
<SettingsShell
|
||||
title="Models"
|
||||
description="What your bots think, read, hear and speak with."
|
||||
>
|
||||
<ModelsSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Models" });
|
||||
Reference in New Issue
Block a user