Add dedicated bot, workspace and user settings pages

This commit is contained in:
2026-10-19 13:17:17 +01:00
parent 731ea73486
commit 7667f2bc2f
74 changed files with 2183 additions and 358 deletions
@@ -19,10 +19,10 @@ export default function WorkspaceSettingsRow({ workspace, busy, onDelete }: Prop
return (
<SettingsListRow
avatar={<WorkspaceAvatar image_media_id={workspace.image_media_id} size="sm" />}
href={`/admin/settings/workspaces/${workspace.id}`}
title={name}
subtitle={workspace.description || undefined}
>
<SettingsRowAction title={`Open ${name}`} label="Open" href={`/admin/workspaces/${workspace.id}`} />
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
</SettingsListRow>
);
@@ -0,0 +1,84 @@
import { useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Textarea from "@moduletrace/twui/form/Textarea";
import type { WorkspaceBot } from "@/src/types";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
type Props = {
workspace_id: number;
workspace_name: string;
bot: WorkspaceBot;
onClose: () => void;
onSaved: (bots: WorkspaceBot[]) => void;
};
/**
* What one bot has for this workspace alone: instructions on top of its
* own, and its memory here. The bot rewrites both as it learns; the
* coordinator can set the instructions too.
*/
export default function WorkspaceBotModal({ workspace_id, workspace_name, bot, onClose, onSaved }: Props) {
const [system_prompt, setSystemPrompt] = useState(bot.workspace_system_prompt || "");
const [memory, setMemory] = useState(bot.workspace_memory || "");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const name = bot.name || "This bot";
const submit = async () => {
if (loading) return;
if (!window.confirm(`Save ${bot.name || "this bot"}'s instructions and memory here?`)) return;
setLoading(true);
setError(undefined);
const res = await postAdminApi<WorkspaceBot>({
path: "workspaces/update-bot",
body: { workspace_id, bot_id: bot.id, system_prompt, memory },
});
setLoading(false);
if (!res.success) {
setError(res.msg || "Couldn't save.");
return;
}
onSaved(res.payload || []);
onClose();
};
return (
<AddModalShell
open
setOpen={(open) => !open && onClose()}
title={`${name} in ${workspace_name}`}
description={`${name} keeps its own instructions and memory everywhere. These apply only here, and it updates them itself as it learns how this workspace works.`}
>
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Textarea
id="workspace-bot-instructions"
title="Instructions here"
label="Instructions here"
showLabel
rows={7}
defaultValue={system_prompt}
placeholder="In this workspace, write for the marketing team: short, plain, no jargon."
onChange={(e) => setSystemPrompt(e.target.value)}
/>
<Textarea
id="workspace-bot-memory"
title="Memory here"
label="Memory here"
showLabel
rows={5}
defaultValue={memory}
info="What it must remember in this workspace."
onChange={(e) => setMemory(e.target.value)}
/>
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onClose} />
</Form>
</AddModalShell>
);
}
@@ -0,0 +1,112 @@
import Button from "@moduletrace/twui/layout/Button";
import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { MessageSquare, NotebookPen, X } from "lucide-react";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { WorkspaceBot } from "@/src/types";
type Props = {
bot: WorkspaceBot;
/**
* The bot's settings page; left out for the coordinator, which only
* super admins manage.
*/
settings_href?: string;
/**
* Its conversation here.
*/
chat_href: string;
removing?: boolean;
onEdit: () => void;
onRemove: () => void;
};
/**
* A bot in the workspace: opens its settings (or, for the coordinator,
* its conversation here), chats with it here, edits what it has for this
* workspace alone, or takes it out.
*/
export default function WorkspaceBotRow({
bot,
settings_href,
chat_href,
removing,
onEdit,
onRemove,
}: Props) {
const name = bot.name || "This bot";
const has_own = Boolean(
bot.workspace_system_prompt || bot.workspace_memory,
);
const title_href = settings_href || chat_href;
return (
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
/>
<Link
href={title_href}
title={
settings_href
? `${name}'s settings`
: `Talk to ${name} here`
}
className="grow min-w-0 flex flex-col border-0 leading-normal hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink rounded-default-sm"
>
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{name}
</Span>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{has_own
? "Has instructions for this workspace"
: bot.role || "Uses its own instructions here"}
</Span>
</Link>
<Stack className="shrink-0 flex-row! gap-1!">
{settings_href ? (
<Button
title={`Talk to ${name} here`}
aria-label={`Talk to ${name} here`}
type="button"
variant="ghost"
color="text"
href={chat_href}
className="p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default"
>
<MessageSquare size={16} aria-hidden />
</Button>
) : null}
<Button
title={`${name}'s instructions and memory here`}
aria-label={`${name}'s instructions and memory here`}
type="button"
variant="ghost"
color="text"
onClick={onEdit}
className="p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default"
>
<NotebookPen size={16} aria-hidden />
</Button>
<Button
title={`Take ${name} out of this workspace`}
aria-label={`Take ${name} out of this workspace`}
type="button"
variant="ghost"
color="text"
loading={removing}
disabled={removing}
onClick={onRemove}
className="p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default"
>
<X size={16} aria-hidden />
</Button>
</Stack>
</Row>
);
}
@@ -0,0 +1,120 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Plus } from "lucide-react";
import type { BotWithLastMessage, WorkspaceBot } from "@/src/types";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import InlineAddSelect from "@/src/components/general/inline-add-select";
import WorkspaceBotModal from "./workspace-bot-modal";
import WorkspaceBotRow from "./workspace-bot-row";
type Props = {
workspace_id: number;
workspace_name: string;
/**
* The workspace's bots (the coordinator included, always a member).
*/
bots: WorkspaceBot[];
/**
* Every bot the user has.
*/
all_bots: BotWithLastMessage[];
};
/**
* Which bots work here. A bot can be in any number of workspaces; in each
* it can have instructions and memory of its own. Changes reload the
* page, so the rail shows the new crew.
*/
export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots: initial, all_bots }: Props) {
const { openForm } = useContext(AddFlowContext);
const [bots, setBots] = useState(initial);
const [busy_id, setBusyId] = useState<number | "add">();
const [editing, setEditing] = useState<WorkspaceBot>();
const [error, setError] = useState<string>();
const workers = bots.filter((bot) => bot.kind != "coordinator");
const addable = all_bots.filter((bot) => bot.kind != "coordinator" && !bots.some((b) => b.id == bot.id));
const change = async ({ path, bot_id }: { path: "add-bot" | "remove-bot"; bot_id: number }) => {
setBusyId(path == "add-bot" ? "add" : bot_id);
setError(undefined);
const res = await postAdminApi<WorkspaceBot>({
path: `workspaces/${path}`,
body: { workspace_id, bot_id },
});
if (res.success) {
window.location.reload();
return;
}
setBusyId(undefined);
setError(res.msg || "That didn't work.");
};
return (
<SettingsGroup
id="bots"
title="Bots"
description="The coordinator works in every workspace. Add the bots this one needs; each keeps its skills and follows this workspace's ways here."
>
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
{workers.map((bot) => (
<WorkspaceBotRow
key={bot.id}
bot={bot}
settings_href={
bot.kind == "coordinator"
? undefined
: `/admin/settings/bots/${bot.id}`
}
chat_href={`/admin/workspaces/${workspace_id}/bots/${bot.id}`}
removing={busy_id == bot.id}
onEdit={() => setEditing(bot)}
onRemove={() => change({ path: "remove-bot", bot_id: Number(bot.id) })}
/>
))}
{workers.length ? null : (
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">
No bots here yet. Only the coordinator works in this workspace.
</Span>
)}
</Stack>
<InlineAddSelect
id="workspace-add-bot"
noun="bot"
options={addable.map((bot) => ({ value: String(bot.id), title: bot.name || `Bot ${bot.id}` }))}
loading={busy_id == "add"}
onAdd={({ value }) => change({ path: "add-bot", bot_id: Number(value) })}
/>
<Button
title="Add a new bot to this workspace"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => openForm({ kind: "bot" })}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New bot
</Button>
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
{editing ? (
<WorkspaceBotModal
workspace_id={workspace_id}
workspace_name={workspace_name}
bot={editing}
onClose={() => setEditing(undefined)}
onSaved={setBots}
/>
) : null}
</SettingsGroup>
);
}
@@ -0,0 +1,53 @@
import ComputerIconTile from "@/src/components/general/computer-icon-tile";
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
import type { ComputerWithStatus } from "@/src/types";
import computerStatusLabel from "@/src/utils/computer-status-label";
type Props = {
computer: ComputerWithStatus;
is_default: boolean;
is_super_admin: boolean;
busy?: boolean;
onMakeDefault: () => void;
onUnlink: () => void;
onRetry: () => void;
onDelete: () => void;
};
/**
* A computer linked to the workspace: its name, state and whether it's
* the default, then what can be done with it.
*/
export default function WorkspaceComputerRow({
computer,
is_default,
is_super_admin,
busy,
onMakeDefault,
onUnlink,
onRetry,
onDelete,
}: Props) {
const name = computer.name || `Computer ${computer.id}`;
return (
<SettingsListRow
avatar={<ComputerIconTile />}
title={name}
badge={is_default ? "Default" : undefined}
subtitle={`${computerStatusLabel({ computer })}${computer.description ? ` · ${computer.description}` : ""}`}
>
{is_default ? null : (
<SettingsRowAction title={`Make ${name} the default`} label="Make default" disabled={busy} onClick={onMakeDefault} />
)}
{computer.status == "failed" && is_super_admin ? (
<SettingsRowAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
) : null}
<SettingsRowAction title={`Unlink ${name}; it stays yours`} label="Unlink" disabled={busy} onClick={onUnlink} />
{is_super_admin && computer.kind != "main" ? (
<SettingsRowAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
) : null}
</SettingsListRow>
);
}
@@ -0,0 +1,126 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { ComputerWithStatus } from "@/src/types";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import useLiveComputers from "@/src/hooks/use-live-computers";
import InlineAddSelect from "@/src/components/general/inline-add-select";
import WorkspaceComputerRow from "./workspace-computer-row";
type Props = {
workspace_id: number;
/**
* Linked to the workspace, its default first.
*/
computers: ComputerWithStatus[];
/**
* Every computer the user has.
*/
all_computers: ComputerWithStatus[];
};
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* The computers bots work on here. Tasks run on the default unless a bot
* picks another; with none, tasks needing a computer can't run. Unlinking
* keeps the computer; deleting it (super admins) keeps its files.
*/
export default function WorkspaceComputersGroup({ workspace_id, computers: initial, all_computers }: Props) {
const { pageProps } = useContext(AppContext);
const { openForm } = useContext(AddFlowContext);
const { computers, setComputers } = useLiveComputers({ computers: initial });
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<Status>();
const is_super_admin = isPassThroughUser({ user_types: pageProps?.user_types });
const default_id = computers[0]?.id;
const addable = all_computers.filter((computer) => !computers.some((c) => c.id == computer.id));
const run = async ({ path, computer_id }: { path: string; computer_id: number }) => {
setBusy(true);
setStatus(undefined);
const res = await postAdminApi<ComputerWithStatus>({
path,
body: { workspace_id, computer_id },
});
setBusy(false);
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
if (res.payload) {
setComputers(res.payload);
} else if (res.singleRes) {
const fresh = res.singleRes;
setComputers((prev) => prev.map((c) => (c.id == fresh.id ? { ...c, ...fresh } : c)));
} else {
setComputers((prev) => prev.filter((c) => c.id != computer_id));
}
setStatus(res.msg ? { success: true, msg: res.msg } : undefined);
};
return (
<SettingsGroup
id="computers"
title="Computers"
description="Bots here work on these. Their tasks run on the default one unless they pick another."
>
<SettingsList
is_empty={!computers.length}
empty="No computers yet. Bots here can chat, but tasks that need a computer can't run."
>
{computers.map((computer) => (
<WorkspaceComputerRow
key={computer.id}
computer={computer}
is_default={computer.id == default_id}
is_super_admin={is_super_admin}
busy={busy}
onMakeDefault={() => run({ path: "workspaces/set-computer", computer_id: Number(computer.id) })}
onUnlink={() => run({ path: "workspaces/remove-computer", computer_id: Number(computer.id) })}
onRetry={() => run({ path: "computers/retry-computer", computer_id: Number(computer.id) })}
onDelete={() => {
if (!window.confirm(`Delete ${computer.name || "this computer"}? Its container goes; its files stay on disk.`)) return;
run({ path: "computers/delete-computer", computer_id: Number(computer.id) });
}}
/>
))}
</SettingsList>
<InlineAddSelect
id="workspace-add-computer"
noun="computer"
options={addable.map((c) => ({ value: String(c.id), title: c.name || `Computer ${c.id}` }))}
loading={busy}
onAdd={({ value }) => run({ path: "workspaces/add-computer", computer_id: Number(value) })}
/>
{is_super_admin ? (
<Button
title="Create a new computer for this workspace"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => openForm({ kind: "computer" })}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New computer
</Button>
) : null}
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,60 @@
import { useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
type Props = {
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
};
/**
* Deletes the workspace and its conversations, after a confirm. Its bots
* and computers stay.
*/
export default function WorkspaceDeleteGroup({ workspace }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const name = workspace.name || "this workspace";
const remove = async () => {
if (!window.confirm(`Delete ${name} and every conversation in it? Its bots and computers stay.`)) return;
setLoading(true);
setError(undefined);
const res = await postAdminApi({ path: "workspaces/delete-workspace", body: { workspace_id: workspace.id } });
if (res.success) {
window.location.href = "/admin/settings/workspaces";
return;
}
setLoading(false);
setError(res.msg || "Couldn't delete the workspace.");
};
return (
<SettingsGroup
id="delete"
title="Delete"
description="Deletes the workspace and its conversations. Its bots and computers stay yours."
>
<Button
title={`Delete ${name}`}
type="button"
variant="outlined"
color="gray"
size="small"
loading={loading}
disabled={loading}
onClick={remove}
className="self-start"
>
Delete workspace
</Button>
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
</SettingsGroup>
);
}
@@ -0,0 +1,109 @@
import { useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
type Props = {
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
onSaved: (workspace: BUN_SQLITE_COBBLE_WORKSPACES) => void;
};
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* The workspace's name, what it's for, and the context every bot working
* in it gets, saved together.
*/
export default function WorkspaceDetailsGroup({ workspace, onSaved }: Props) {
const [name, setName] = useState(workspace.name || "");
const [description, setDescription] = useState(workspace.description || "");
const [system_prompt, setSystemPrompt] = useState(workspace.system_prompt || "");
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<Status>();
const dirty =
name != (workspace.name || "") ||
description != (workspace.description || "") ||
system_prompt != (workspace.system_prompt || "");
const submit = async () => {
if (!dirty || loading) return;
if (!window.confirm(`Save ${name || "this workspace"}?`)) return;
setLoading(true);
setStatus(undefined);
const res = await postAdminApi<BUN_SQLITE_COBBLE_WORKSPACES>({
path: "workspaces/update-workspace",
body: { workspace_id: workspace.id, name, description, system_prompt },
});
setLoading(false);
if (!res.success || !res.singleRes) {
setStatus({ error: true, msg: res.msg || "Couldn't save the workspace." });
return;
}
setStatus({ success: true, msg: res.msg || "Saved." });
onSaved(res.singleRes);
};
return (
<SettingsGroup
id="details"
title="Workspace"
description="Every bot working here gets this context on top of its own instructions."
>
<Form className="w-full gap-4! items-stretch" onSubmit={submit}>
<Input
id="workspace-name"
title="Name"
label="Name"
showLabel
autoComplete="off"
defaultValue={name}
onChange={(e) => setName(e.target.value)}
/>
<Input
id="workspace-description"
title="What it's for"
label="What it's for"
showLabel
autoComplete="off"
defaultValue={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Textarea
id="workspace-context"
title="Context"
label="Context"
showLabel
rows={8}
defaultValue={system_prompt}
info="Markdown. Goals, people, links, rules: whatever every bot here should know."
onChange={(e) => setSystemPrompt(e.target.value)}
/>
<Row className="w-full gap-3! flex-nowrap items-center justify-between">
<SettingsStatus status={status} />
<Button
title="Save the workspace"
type="submit"
size="small"
loading={loading}
disabled={!dirty || loading}
className="ml-auto"
>
Save
</Button>
</Row>
</Form>
</SettingsGroup>
);
}
@@ -0,0 +1,57 @@
import { useState } from "react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import ImagePickField from "@/src/components/general/image-pick-field";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import setProfileImage from "@/src/functions/frontend/set-profile-image";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
type Props = {
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
};
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* The workspace's picture, shown in the rail. Saved as soon as it's
* picked or removed.
*/
export default function WorkspaceImageGroup({ workspace }: Props) {
const [image_media_id, setImageMediaId] = useState(workspace.image_media_id || null);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<Status>();
const save = async ({ data_url }: { data_url: string | null }) => {
setLoading(true);
setStatus(undefined);
const res = await setProfileImage({
target: "workspace",
id: Number(workspace.id),
data_url,
}).catch(() => null);
setLoading(false);
if (!res?.success || !res.singleRes) {
setStatus({ error: true, msg: res?.msg || "Couldn't save the picture." });
return;
}
setImageMediaId(res.singleRes.image_media_id);
setStatus({ success: true, msg: res.msg || "Saved." });
};
return (
<SettingsGroup id="picture" title="Picture" description="Shown beside the workspace's name in the rail.">
<ImagePickField
preview={<WorkspaceAvatar size="lg" image_media_id={image_media_id} />}
has_image={Boolean(image_media_id)}
onPick={({ data_url }) => save({ data_url })}
onRemove={() => save({ data_url: null })}
loading={loading}
/>
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,42 @@
import { useState } from "react";
import type { HarnessOverview } from "@/src/types";
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
import saveWorkspaceHarnessSettings from "@/src/functions/frontend/save-workspace-harness-settings";
type Props = {
workspace_id: number;
harnesses: HarnessOverview[];
};
/**
* Which model each harness uses in the workspace. These win over a bot's
* own models; "Same as the default" leaves each bot on its own, else the
* defaults.
*/
export default function WorkspaceModels({ workspace_id, harnesses: initial }: Props) {
const [harnesses, setHarnesses] = useState(initial);
const [version, setVersion] = useState(0);
const [notice, setNotice] = useState<string>();
return (
<HarnessSettingsForm
key={version}
harnesses={harnesses}
notice={notice}
save={async ({ settings }) => {
const res = await saveWorkspaceHarnessSettings({
workspace_id,
settings: settings.map((setting) => ({ ...setting, inherit: Boolean(setting.inherit) })),
});
if (res.success && res.singleRes) {
setHarnesses(res.singleRes.harnesses);
setNotice(res.msg || "Saved.");
setVersion((prev) => prev + 1);
}
return res;
}}
/>
);
}
@@ -0,0 +1,61 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
import SettingsShell from "../../../(partials)/settings-shell";
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
import WorkspaceComputersGroup from "../(partials)/workspace-computers-group";
import WorkspaceDeleteGroup from "../(partials)/workspace-delete-group";
import WorkspaceDetailsGroup from "../(partials)/workspace-details-group";
import WorkspaceImageGroup from "../(partials)/workspace-image-group";
import WorkspaceModels from "../(partials)/workspace-models";
/**
* A workspace's settings: its picture, context, bots, computers and
* models, then deleting it. Super admins and admins only.
*/
export default function WorkspaceOverview() {
const { pageProps } = useContext(AppContext);
const [workspace, setWorkspace] = useState(pageProps?.workspace);
if (!workspace?.id) return null;
const workspace_id = Number(workspace.id);
const name = workspace.name || "Workspace";
const harnesses = pageProps?.workspace_harness_overview?.harnesses;
return (
<SettingsShell
title={name}
description={
workspace.description ||
"Context, bots, computers and models for this workspace."
}
breadcrumb_title={name}
>
<WorkspaceImageGroup workspace={workspace} />
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
<WorkspaceBotsGroup
workspace_id={workspace_id}
workspace_name={name}
bots={pageProps?.workspace_bots || []}
all_bots={pageProps?.all_bots || []}
/>
<WorkspaceComputersGroup
workspace_id={workspace_id}
computers={pageProps?.workspace_computers || []}
all_computers={pageProps?.computers || []}
/>
{harnesses?.length ? (
<>
<SettingsSectionHeading
id="models"
title="Models"
description="Every bot working here uses these, the coordinator too. Left on “Same as the default”, each bot uses its own models, else the defaults."
/>
<WorkspaceModels workspace_id={workspace_id} harnesses={harnesses} />
</>
) : null}
<WorkspaceDeleteGroup workspace={workspace} />
</SettingsShell>
);
}
@@ -0,0 +1,65 @@
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
/**
* One workspace's settings: its picture, details, bots, computers and
* models. For super admins and admins.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabCrewSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
const query: PageQueryObject = params.query;
const user_id = settings.props.person.id;
const workspaces = await grabUserWorkspaces({ user_id });
const workspace = workspaces.find(
(item) => String(item.id) == String(query.workspace_id),
);
if (!workspace?.id) {
return {
redirect: {
destination: "/admin/settings/workspaces",
permanent: false,
},
};
}
const workspace_id = Number(workspace.id);
const [
workspace_bots,
all_bots,
workspace_computers,
computers,
workspace_harness_overview,
] = await Promise.all([
grabWorkspaceBots({ user_id, workspace_id }),
grabBots({ user_id }),
grabWorkspaceComputers({ workspace_id }),
grabUserComputers({ user_id }),
grabBotHarnessOverview({ workspace_id }).catch(() => null),
]);
return {
props: {
...settings.props,
workspaces,
workspace,
bots: workspace_bots,
workspace_bots,
all_bots,
workspace_computers,
computers,
workspace_harness_overview,
},
};
};
@@ -0,0 +1,8 @@
import WorkspaceOverview from "./(sections)/workspace-overview";
import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta";
export default function AdminSettingsWorkspacePage() {
return <WorkspaceOverview />;
}
export const meta = settingsRecordPageMeta({ kind: "workspaces" });