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
@@ -0,0 +1,51 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import type { SettingsRecordKind } from "@/src/utils/parse-settings-record-path";
type Params = {
kind: SettingsRecordKind;
};
/**
* A nested settings record page's title and description: the bot,
* workspace or user it manages.
*/
export default function settingsRecordPageMeta({
kind,
}: Params): BunextPageModuleMetaFn {
return async (params) => {
const props = params.serverRes?.props as PagePropsType | undefined;
if (kind == "bots") {
const bot = props?.bot;
return {
title: `${bot?.name || "Bot"} settings | ${SiteData["SiteName"]}`,
description: bot?.role || `Manage this bot on ${SiteData["SiteName"]}.`,
};
}
if (kind == "workspaces") {
const workspace = props?.workspace;
return {
title: `${workspace?.name || "Workspace"} settings | ${SiteData["SiteName"]}`,
description:
workspace?.description ||
`Manage this workspace on ${SiteData["SiteName"]}.`,
};
}
const person = props?.target_person;
const name =
[person?.first_name, person?.last_name].filter(Boolean).join(" ") ||
person?.email ||
"User";
return {
title: `${name} settings | ${SiteData["SiteName"]}`,
description: `Manage ${name} on ${SiteData["SiteName"]}.`,
};
};
}
@@ -0,0 +1,92 @@
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 "@/src/functions/frontend/save-bot-harness-settings";
import saveWorkspaceHarnessSettings from "@/src/functions/frontend/save-workspace-harness-settings";
type Props = {
bot: BotWithLastMessage;
/**
* In a workspace the form saves the workspace's models, for every bot
* working there.
*/
workspace_id?: number | null;
/**
* 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, workspace_id, 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 (workspace_id) {
const res = await saveWorkspaceHarnessSettings({
workspace_id,
settings: settings.map((setting) => ({
...setting,
inherit: Boolean(setting.inherit),
})),
});
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
return res;
}
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,50 @@
import { useContext } from "react";
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
import Stack from "@moduletrace/twui/layout/Stack";
import { ChevronRight } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
type Props = {
/**
* The record's name, shown where the URL has its id.
*/
title: string;
};
/**
* The hierarchy above a nested settings page: Settings / Bots / Scout.
* Built from the path, with the record's name replacing its id.
*/
export default function SettingsBreadcrumbs({ title }: Props) {
const { pageProps } = useContext(AppContext);
const pathname = pageProps?.url?.pathname;
if (!pathname) return null;
return (
<Stack className="w-full shrink-0 pt-5 pb-1">
<Breadcrumbs
pageUrl={pathname}
currentTitle={title}
skipHome
excludeRegexMatch={/^admin$/}
linkProps={{
className:
"border-0 text-[13px] leading-[18px] text-ink-secondary no-underline hover:text-ink hover:opacity-100 dark:text-ink-secondary",
}}
currentLinkProps={{
title,
className:
"border-0 text-[13px] leading-[18px] text-ink no-underline pointer-events-none dark:text-ink",
}}
divider={
<ChevronRight
size={12}
aria-hidden
className="shrink-0 text-ink-secondary"
/>
}
/>
</Stack>
);
}
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
@@ -6,6 +7,10 @@ import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
avatar: ReactNode;
title: string;
/**
* Where the title links, for rows that open a page.
*/
href?: string;
/**
* A word beside the title, e.g. "Default" or "You".
*/
@@ -21,15 +26,29 @@ type Props = {
* One row of a settings list: an avatar, a title and one line about it,
* then what can be done with it.
*/
export default function SettingsListRow({ avatar, title, badge, subtitle, children }: Props) {
export default function SettingsListRow({ avatar, title, href, badge, subtitle, children }: Props) {
const title_content = (
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{title}
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
</Span>
);
return (
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
{avatar}
<Stack className="grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{title}
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
</Span>
{href ? (
<Link
href={href}
title={title}
className="block min-w-0 border-0 leading-normal hover:opacity-100 hover:underline focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-surface rounded-default-sm"
>
{title_content}
</Link>
) : (
title_content
)}
{subtitle ? (
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{subtitle}</Span>
) : null}
@@ -0,0 +1,36 @@
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
title: string;
description: string;
/**
* An anchor, so the rail can link to this section.
*/
id?: string;
};
/**
* Heads a run of settings groups, e.g. the models form on a bot or
* workspace page.
*/
export default function SettingsSectionHeading({
title,
description,
id,
}: Props) {
return (
<Stack
id={id}
className="w-full scroll-mt-4 gap-1! pt-10 pb-2 border-0 border-b border-solid border-hairline"
>
<H2 className="text-base! leading-6! tracking-normal! font-bold text-ink mb-0!">
{title}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{description}
</Span>
</Stack>
);
}
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Stack from "@moduletrace/twui/layout/Stack";
import SettingsBreadcrumbs from "./settings-breadcrumbs";
import SettingsPaneHeader from "./settings-pane-header";
type Props = {
@@ -11,6 +12,10 @@ type Props = {
* A wider column, for tables (activity).
*/
wide?: boolean;
/**
* A nested record page's name, for its breadcrumb trail.
*/
breadcrumb_title?: string;
};
/**
@@ -22,6 +27,7 @@ export default function SettingsShell({
description,
children,
wide,
breadcrumb_title,
}: Props) {
return (
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
@@ -33,6 +39,9 @@ export default function SettingsShell({
wide && "max-w-[1200px]",
)}
>
{breadcrumb_title ? (
<SettingsBreadcrumbs title={breadcrumb_title} />
) : null}
{children}
</Stack>
</Stack>
@@ -1,32 +0,0 @@
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
import type { BotWithLastMessage } from "@/src/types";
type Props = {
/**
* Open while set.
*/
bot?: BotWithLastMessage;
onClose: () => void;
};
/**
* Edits a bot in a modal, with the same form as adding one (and as the
* chat header's avatar).
*/
export default function BotEditModal({ bot, onClose }: Props) {
return (
<AddModalShell
open={Boolean(bot)}
setOpen={(open) => !open && onClose()}
title={`Edit ${bot?.name || "the bot"}`}
description={
bot?.kind == "coordinator"
? "The coordinator keeps its name. Give it a picture, or change its core instructions."
: "Its core instructions apply everywhere. What it does in one workspace is set on that workspace's page."
}
>
{bot ? <BotForm key={bot.id} bot={bot} onCancel={onClose} /> : null}
</AddModalShell>
);
}
@@ -7,15 +7,14 @@ import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
bot: BotWithLastMessage;
busy?: boolean;
onEdit: () => void;
onDelete: () => void;
};
/**
* One bot: its stone or picture, name and job, then Chat, Edit, and for a
* worker its Models and Delete.
* One bot: its stone or picture, name and job. The title opens its
* settings page; Chat and, for a worker, Delete sit at the end.
*/
export default function BotSettingsRow({ bot, busy, onEdit, onDelete }: Props) {
export default function BotSettingsRow({ bot, busy, onDelete }: Props) {
const name = bot.name || `Bot ${bot.id}`;
const is_coordinator = bot.kind == "coordinator";
@@ -28,16 +27,13 @@ export default function BotSettingsRow({ bot, busy, onEdit, onDelete }: Props) {
face={(bot.face || "idle") as BotFace}
/>
}
href={`/admin/settings/bots/${bot.id}`}
title={name}
subtitle={bot.role || (is_coordinator ? "Coordinates your crew and hands work to it" : undefined)}
>
<SettingsRowAction title={`Chat with ${name}`} label="Chat" href={`/admin/bots/${bot.id}`} />
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy} onClick={onEdit} />
{is_coordinator ? null : (
<>
<SettingsRowAction title={`${name}'s models`} label="Models" href={`/admin/bots/${bot.id}/models`} />
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
</>
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
)}
</SettingsListRow>
);
@@ -1,25 +1,22 @@
import { useContext, useState } from "react";
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import type { BotWithLastMessage } from "@/src/types";
import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import BotEditModal from "../(partials)/bot-edit-modal";
import BotSettingsRow from "../(partials)/bot-settings-row";
/**
* The user's crew, coordinator first: chat with a bot, edit it, set its
* models, add one or delete a worker.
* The user's crew, coordinator first. A row opens that bot's settings
* page; chat with it, or delete a worker.
*/
export default function BotsSettingsSection() {
const { pageProps } = useContext(AppContext);
const { openForm } = useContext(AddFlowContext);
const { items, busy, status, remove } = useSettingsList({ items: pageProps?.bots || [] });
const [editing, setEditing] = useState<BotWithLastMessage>();
return (
<SettingsGroup
@@ -32,7 +29,6 @@ export default function BotsSettingsSection() {
key={bot.id}
bot={bot}
busy={busy}
onEdit={() => setEditing(bot)}
onDelete={() =>
remove({
id: bot.id,
@@ -57,7 +53,6 @@ export default function BotsSettingsSection() {
New bot
</Button>
<SettingsStatus status={status} />
<BotEditModal bot={editing} onClose={() => setEditing(undefined)} />
</SettingsGroup>
);
}
@@ -0,0 +1,46 @@
import ComputerIconTile from "@/src/components/general/computer-icon-tile";
import type { ComputerWithStatus } from "@/src/types";
import computerResourcesLabel from "@/src/utils/computer-resources-label";
import computerStatusLabel from "@/src/utils/computer-status-label";
import SettingsListRow from "../../../(partials)/settings-list-row";
import SettingsRowAction from "../../../(partials)/settings-row-action";
type Props = {
computer: ComputerWithStatus;
busy?: boolean;
onRemove?: () => void;
};
/**
* One computer the bot can use in its own chat.
*/
export default function BotComputerRow({
computer,
busy,
onRemove,
}: Props) {
const name = computer.name || `Computer ${computer.id}`;
return (
<SettingsListRow
avatar={<ComputerIconTile />}
title={name}
subtitle={[
computerStatusLabel({ computer }),
computerResourcesLabel({ computer }),
computer.description,
]
.filter(Boolean)
.join(" · ")}
>
{onRemove ? (
<SettingsRowAction
title={`Take ${name}'s access away`}
label="Remove"
disabled={busy}
onClick={onRemove}
/>
) : null}
</SettingsListRow>
);
}
@@ -0,0 +1,151 @@
import { useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Monitor } from "lucide-react";
import InlineAddSelect from "@/src/components/general/inline-add-select";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { BotWithLastMessage, ComputerWithStatus } from "@/src/types";
import SettingsGroup from "../../../(partials)/settings-group";
import SettingsList from "../../../(partials)/settings-list";
import SettingsStatus from "../../../(partials)/settings-status";
import BotComputerRow from "./bot-computer-row";
type Props = {
bot: BotWithLastMessage;
/**
* Computers it can use in its own chat.
*/
computers: ComputerWithStatus[];
/**
* Every computer the user has.
*/
all_computers: ComputerWithStatus[];
};
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* The bot's direct computer access. In a workspace it uses that
* workspace's computers instead; the coordinator can use every computer.
*/
export default function BotComputersGroup({
bot,
computers: initial,
all_computers,
}: Props) {
const [computers, setComputers] = useState(initial);
const [busy_id, setBusyId] = useState<number | "add">();
const [status, setStatus] = useState<Status>();
const is_coordinator = bot.kind == "coordinator";
const addable = all_computers.filter(
(computer) => !computers.some((item) => item.id == computer.id),
);
const change = async ({
path,
computer_id,
}: {
path: "add-computer" | "remove-computer";
computer_id: number;
}) => {
setBusyId(path == "add-computer" ? "add" : computer_id);
setStatus(undefined);
const res = await postAdminApi({
path: `bots/${path}`,
body: { bot_id: bot.id, computer_id },
});
setBusyId(undefined);
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
if (path == "add-computer") {
const computer = all_computers.find(
(item) => Number(item.id) == computer_id,
);
if (computer) {
setComputers((prev) => [...prev, computer]);
}
} else {
setComputers((prev) =>
prev.filter((computer) => Number(computer.id) != computer_id),
);
}
setStatus({ success: true, msg: res.msg || "Saved." });
};
return (
<SettingsGroup
id="computers"
title="Computers"
description={
is_coordinator
? "The coordinator can use every computer."
: "Direct access for its own chat. In a workspace it uses that workspace's computers instead."
}
>
<SettingsList
is_empty={!computers.length}
empty={
is_coordinator
? "No computers yet."
: "No direct computer access yet."
}
>
{computers.map((computer) => (
<BotComputerRow
key={computer.id}
computer={computer}
busy={busy_id == computer.id}
onRemove={
is_coordinator
? undefined
: () =>
change({
path: "remove-computer",
computer_id: Number(computer.id),
})
}
/>
))}
</SettingsList>
{is_coordinator ? null : (
<InlineAddSelect
id="bot-add-computer"
noun="computer"
options={addable.map((computer) => ({
value: String(computer.id),
title:
computer.name || `Computer ${computer.id}`,
}))}
loading={busy_id == "add"}
onAdd={({ value }) =>
change({
path: "add-computer",
computer_id: Number(value),
})
}
/>
)}
<Button
title="Manage your computers"
type="button"
variant="ghost"
color="gray"
size="small"
href="/admin/settings/computers"
beforeIcon={<Monitor size={14} aria-hidden />}
className="self-start gap-1!"
>
Manage computers
</Button>
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,68 @@
import { useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { BotWithLastMessage } from "@/src/types";
import SettingsGroup from "../../../(partials)/settings-group";
import SettingsStatus from "../../../(partials)/settings-status";
type Props = {
bot: BotWithLastMessage;
};
/**
* Deletes a worker with its conversations, tasks and models. The
* coordinator is never deleted.
*/
export default function BotDeleteGroup({ bot }: Props) {
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<{ error?: boolean; msg?: string }>();
const name = bot.name || "this bot";
const remove = async () => {
if (
!window.confirm(
`Delete ${name}? Its conversations, tasks and models go with it.`,
)
)
return;
setLoading(true);
setStatus(undefined);
const res = await postAdminApi({
path: "bots/delete-bot",
body: { bot_id: bot.id },
});
if (res.success) {
window.location.href = "/admin/settings/bots";
return;
}
setLoading(false);
setStatus({ error: true, msg: res.msg || "Couldn't delete the bot." });
};
return (
<SettingsGroup
id="delete"
title="Delete"
description="Deletes the bot with its conversations, tasks and models."
>
<Button
title={`Delete ${name}`}
type="button"
variant="outlined"
color="gray"
size="small"
loading={loading}
disabled={loading}
onClick={remove}
className="self-start"
>
Delete bot
</Button>
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,41 @@
import { useState } from "react";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
import type { BotWithLastMessage } from "@/src/types";
import SettingsGroup from "../../../(partials)/settings-group";
import SettingsStatus from "../../../(partials)/settings-status";
type Props = {
bot: BotWithLastMessage;
onSaved: (bot: BUN_SQLITE_COBBLE_BOTS) => void;
};
/**
* The bot's picture, name, job, colour and core instructions, edited in
* place. The same form as adding a bot, without a modal around it.
*/
export default function BotDetailsGroup({ bot, onSaved }: Props) {
const [status, setStatus] = useState<{ success?: boolean; msg?: string }>();
const is_coordinator = bot.kind == "coordinator";
return (
<SettingsGroup
id="details"
title="Details"
description={
is_coordinator
? "The coordinator keeps its name and has no colour. Its core instructions apply everywhere."
: "The bot's picture, name, job, colour and core instructions. These apply in its own chat and wherever it has no workspace instructions."
}
>
<BotForm
bot={bot}
onSaved={(saved) => {
onSaved(saved);
setStatus({ success: true, msg: "Saved." });
}}
/>
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,65 @@
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 { MessageSquare } from "lucide-react";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import SettingsGroup from "../../../(partials)/settings-group";
type Props = {
bot: BotWithLastMessage;
};
/**
* The bot at a glance: its stone or picture, name, job and status, then a
* way into its chat.
*/
export default function BotOverviewGroup({ bot }: Props) {
const name = bot.name || `Bot ${bot.id}`;
const is_coordinator = bot.kind == "coordinator";
return (
<SettingsGroup
id="overview"
title="Overview"
description="What this bot is here for."
>
<Row className="w-full gap-4! flex-nowrap items-center">
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={40}
/>
<Stack className="grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{name}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
{[
is_coordinator ? "Coordinator" : "Worker",
bot.role,
bot.status_line,
]
.filter(Boolean)
.join(" · ")}
</Span>
</Stack>
<Button
title={`Chat with ${name}`}
type="button"
variant="outlined"
color="gray"
size="small"
href={`/admin/bots/${bot.id}`}
beforeIcon={<MessageSquare size={14} aria-hidden />}
className="shrink-0"
>
Open chat
</Button>
</Row>
</SettingsGroup>
);
}
@@ -0,0 +1,52 @@
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { BotWorkspace } from "@/src/types";
import SettingsListRow from "../../../(partials)/settings-list-row";
import SettingsRowAction from "../../../(partials)/settings-row-action";
type Props = {
workspace: BotWorkspace;
busy?: boolean;
onRemove?: () => void;
};
/**
* One workspace the bot works in. Its title opens the workspace's
* settings; a worker can be taken out from here.
*/
export default function BotWorkspaceRow({
workspace,
busy,
onRemove,
}: Props) {
const name = workspace.name || `Workspace ${workspace.id}`;
const has_own = Boolean(
workspace.workspace_system_prompt || workspace.workspace_memory,
);
return (
<SettingsListRow
avatar={
<WorkspaceAvatar
image_media_id={workspace.image_media_id}
size="sm"
/>
}
href={`/admin/settings/workspaces/${workspace.id}`}
title={name}
subtitle={
has_own
? "Has instructions for this workspace"
: workspace.description || "Uses the workspace's context"
}
>
{onRemove ? (
<SettingsRowAction
title={`Take ${name} out of this workspace`}
label="Remove"
disabled={busy}
onClick={onRemove}
/>
) : null}
</SettingsListRow>
);
}
@@ -0,0 +1,146 @@
import { useState } from "react";
import type { BotWorkspace, BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
import InlineAddSelect from "@/src/components/general/inline-add-select";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import SettingsGroup from "../../../(partials)/settings-group";
import SettingsList from "../../../(partials)/settings-list";
import SettingsStatus from "../../../(partials)/settings-status";
import BotWorkspaceRow from "./bot-workspace-row";
type Props = {
bot: BotWithLastMessage;
/**
* Every workspace the user has.
*/
workspaces: WorkspaceWithActivity[];
/**
* The workspaces the bot works in.
*/
bot_workspaces: BotWorkspace[];
};
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* Where the bot works. A worker can be added to or taken out of a
* workspace; the coordinator works in every one of them.
*/
export default function BotWorkspacesGroup({
bot,
workspaces,
bot_workspaces: initial,
}: Props) {
const [bot_workspaces, setBotWorkspaces] = useState(initial);
const [busy_id, setBusyId] = useState<number | "add">();
const [status, setStatus] = useState<Status>();
const is_coordinator = bot.kind == "coordinator";
const addable = workspaces.filter(
(workspace) =>
!bot_workspaces.some((member) => member.id == workspace.id),
);
const change = async ({
path,
workspace_id,
}: {
path: "add-bot" | "remove-bot";
workspace_id: number;
}) => {
setBusyId(path == "add-bot" ? "add" : workspace_id);
setStatus(undefined);
const res = await postAdminApi({
path: `workspaces/${path}`,
body: { workspace_id, bot_id: bot.id },
});
setBusyId(undefined);
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
if (path == "add-bot") {
const workspace = workspaces.find(
(item) => Number(item.id) == workspace_id,
);
if (workspace) {
setBotWorkspaces((prev) => [
...prev,
{
...workspace,
workspace_system_prompt: null,
workspace_memory: null,
},
]);
}
} else {
setBotWorkspaces((prev) =>
prev.filter((workspace) => Number(workspace.id) != workspace_id),
);
}
setStatus({ success: true, msg: res.msg || "Saved." });
};
return (
<SettingsGroup
id="workspaces"
title="Workspaces"
description={
is_coordinator
? "The coordinator works in every workspace."
: "Where this bot works. In each, the workspace's context, computers and models apply."
}
>
<SettingsList
is_empty={!bot_workspaces.length}
empty={
is_coordinator
? "No workspaces yet."
: "This bot is not in a workspace yet."
}
>
{bot_workspaces.map((workspace) => (
<BotWorkspaceRow
key={workspace.id}
workspace={workspace}
busy={busy_id == workspace.id}
onRemove={
is_coordinator
? undefined
: () =>
change({
path: "remove-bot",
workspace_id: Number(workspace.id),
})
}
/>
))}
</SettingsList>
{is_coordinator ? null : (
<InlineAddSelect
id="bot-add-workspace"
noun="workspace"
options={addable.map((workspace) => ({
value: String(workspace.id),
title:
workspace.name ||
`Workspace ${workspace.id}`,
}))}
loading={busy_id == "add"}
onAdd={({ value }) =>
change({
path: "add-bot",
workspace_id: Number(value),
})
}
/>
)}
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,76 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import BotHarnessForm from "../../../(partials)/bot-harness-form";
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
import SettingsShell from "../../../(partials)/settings-shell";
import BotComputersGroup from "../(partials)/bot-computers-group";
import BotDeleteGroup from "../(partials)/bot-delete-group";
import BotDetailsGroup from "../(partials)/bot-details-group";
import BotOverviewGroup from "../(partials)/bot-overview-group";
import BotWorkspacesGroup from "../(partials)/bot-workspaces-group";
/**
* One bot's settings: overview, details, models, workspaces, computers,
* and deleting a worker. The same page shows the bot's information and
* edits it.
*/
export default function BotSettingsSection() {
const { pageProps } = useContext(AppContext);
const [bot, setBot] = useState<BotWithLastMessage | undefined>(
pageProps?.bot || undefined,
);
if (!bot?.id) return null;
const name = bot.name || `Bot ${bot.id}`;
const is_coordinator = bot.kind == "coordinator";
const harnesses = pageProps?.bot_harness_overview?.harnesses;
return (
<SettingsShell
title={name}
description={
bot.role ||
(is_coordinator
? "Coordinates your crew and hands work to it"
: "A worker bot.")
}
breadcrumb_title={name}
>
<BotOverviewGroup bot={bot} />
<BotDetailsGroup
bot={bot}
onSaved={(saved: BUN_SQLITE_COBBLE_BOTS) =>
setBot((prev) => ({ ...(prev || bot), ...saved }))
}
/>
{harnesses?.length ? (
<>
<SettingsSectionHeading
id="models"
title="Models"
description={
is_coordinator
? "The coordinator and every bot left on the default use these. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images."
: "Uses the default models unless you pick its own. In a workspace, that workspace's models win. The text model chats, plans and drives computers; the others read, transcribe, speak and make images."
}
/>
<BotHarnessForm bot={bot} harnesses={harnesses} />
</>
) : null}
<BotWorkspacesGroup
bot={bot}
workspaces={pageProps?.workspaces || []}
bot_workspaces={pageProps?.bot_workspaces || []}
/>
<BotComputersGroup
bot={bot}
computers={pageProps?.bot_computers || []}
all_computers={pageProps?.computers || []}
/>
{is_coordinator ? null : <BotDeleteGroup bot={bot} />}
</SettingsShell>
);
}
@@ -0,0 +1,70 @@
import type { PagePropsType, PageQueryObject, ComputerWithStatus } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
import grabBotWorkspaces from "@/src/functions/backend/db/workspaces/grab-bot-workspaces";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
/**
* One bot's settings: its details, models, workspaces and computers. For
* super admins and admins; the coordinator is the super admin's alone.
*/
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 bot = await grabUserBot({ bot_id: String(query.bot_id), user_id });
if (!bot?.id) {
return { redirect: { destination: "/admin/settings/bots", permanent: false } };
}
const [
bots,
workspaces,
bot_workspaces,
all_computers,
bot_computer_rows,
bot_harness_overview,
] = await Promise.all([
grabBots({ user_id }),
grabUserWorkspaces({ user_id }),
grabBotWorkspaces({ user_id, bot }),
grabUserComputers({ user_id }),
grabBotComputers({ bot_id: bot.id }),
(bot.kind == "coordinator"
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({ bot_id: bot.id })
).catch(() => null),
]);
const bot_computers = bot_computer_rows
.map((row) =>
all_computers.find((computer) => computer.id == row.id),
)
.filter((computer): computer is ComputerWithStatus =>
Boolean(computer?.id),
);
return {
props: {
...settings.props,
bot,
bots,
workspaces,
bot_workspaces,
bot_computers,
computers: all_computers,
bot_harness_overview,
},
};
};
@@ -0,0 +1,8 @@
import BotSettingsSection from "./(sections)/bot-settings-section";
import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta";
export default function AdminSettingsBotPage() {
return <BotSettingsSection />;
}
export const meta = settingsRecordPageMeta({ kind: "bots" });
@@ -0,0 +1,25 @@
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
type Props = {
open: boolean;
onClose: () => void;
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
};
/**
* Adds a user in a modal. Editing one happens on its settings page.
*/
export default function NewUserModal({ open, onClose, onSaved }: Props) {
return (
<AddModalShell
open={open}
setOpen={(next) => !next && onClose()}
title="Add a user"
description="They get their own coordinator and main computer, and sign in with this password."
>
{open ? <UserForm onCancel={onClose} onSaved={onSaved} /> : null}
</AddModalShell>
);
}
@@ -1,33 +0,0 @@
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
type Props = {
/**
* Edits this user; adds one when not given.
*/
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
open: boolean;
onClose: () => void;
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
};
/**
* Adds or edits a user in a modal.
*/
export default function UserEditModal({ person, open, onClose, onSaved }: Props) {
return (
<AddModalShell
open={open}
setOpen={(next) => !next && onClose()}
title={person ? `Edit ${person.first_name || "the user"}` : "Add a user"}
description={
person
? "Leave the password empty to keep theirs."
: "They get their own coordinator and main computer, and sign in with this password."
}
>
{open ? <UserForm key={person?.id || "new"} person={person} onCancel={onClose} onSaved={onSaved} /> : null}
</AddModalShell>
);
}
@@ -4,7 +4,6 @@ import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import parseUserTypes from "@/src/utils/parse-user-types";
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
import canUpdateUser from "@/src/utils/rbac/can-update-user";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
@@ -13,22 +12,20 @@ type Props = {
actor_user_id?: number | string;
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
busy?: boolean;
onEdit: () => void;
onRevoke: () => void;
onDelete: () => void;
};
/**
* One user: their photo, name, user types and email, then Edit, Revoke
* (not once revoked) and Delete where the actor may (`canUpdateUser`,
* `canRevokeUser`; never revoking or deleting yourself).
* One user: their photo, name, user types and email. The title opens
* their settings page; Revoke (not once revoked) and Delete sit at the
* end where the actor may (`canRevokeUser`; never yourself).
*/
export default function UserSettingsRow({
person,
actor_user_id,
actor_user_types,
busy,
onEdit,
onRevoke,
onDelete,
}: Props) {
@@ -42,25 +39,17 @@ export default function UserSettingsRow({
.join(", ");
const is_self = person.id == actor_user_id;
const target_user_types = user_types.map((user_type) => ({ user_type }));
const can_edit = canUpdateUser({ actor_user_types, actor_user_id, target_user_id: person.id, target_user_types });
const can_delete = !is_self && canRevokeUser({ user_types: actor_user_types, target_user_types });
const can_revoke = can_delete && !user_types.includes("revoked");
return (
<SettingsListRow
avatar={<UserAvatar person={person} size="md" />}
href={`/admin/settings/users/${person.id}`}
title={name}
badge={is_self ? "You" : undefined}
subtitle={[types_title, person.email].filter(Boolean).join(" · ")}
>
{can_edit ? (
<SettingsRowAction
title={`Edit ${name}`}
label="Edit"
disabled={busy}
onClick={onEdit}
/>
) : null}
{can_revoke ? (
<SettingsRowAction
title={`Revoke ${name}`}
@@ -8,26 +8,21 @@ import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import UserEditModal from "../(partials)/user-edit-modal";
import NewUserModal from "../(partials)/new-user-modal";
import UserSettingsRow from "../(partials)/user-settings-row";
import revokeUser from "../(functions)/revoke-user";
/**
* Everyone who can sign in (for super admins and admins): add a user
* with a password, edit one (photo, name, email, username, a new
* password, user types), revoke one, or delete one with everything
* that's theirs. What each person
* may do here is the RBAC helpers' call.
* Everyone who can sign in (for super admins and admins). A row opens
* that user's settings page; add one with a password, revoke one, or
* delete one with everything that's theirs.
*/
export default function UsersSettingsSection() {
const { pageProps } = useContext(AppContext);
const { items, setItems, busy, setBusy, status, setStatus, remove } = useSettingsList({
items: pageProps?.persons || [],
});
/**
* The user being edited, or `true` while adding one.
*/
const [editing, setEditing] = useState<BUN_SQLITE_COBBLE_USERS_JOIN | true>();
const [creating, setCreating] = useState(false);
const savePerson = (person: BUN_SQLITE_COBBLE_USERS_JOIN) =>
setItems((prev) =>
@@ -66,7 +61,6 @@ export default function UsersSettingsSection() {
actor_user_id={pageProps?.user?.id}
actor_user_types={pageProps?.user_types}
busy={busy}
onEdit={() => setEditing(person)}
onRevoke={() => revoke(person)}
onDelete={() =>
remove({
@@ -80,27 +74,26 @@ export default function UsersSettingsSection() {
))}
</SettingsList>
{canCreateUser({ user_types: pageProps?.user_types }) ? (
<Button
title="Add a user"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setEditing(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New user
</Button>
<Button
title="Add a user"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setCreating(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New user
</Button>
) : null}
<SettingsStatus status={status} />
<UserEditModal
person={editing === true ? undefined : editing}
open={Boolean(editing)}
onClose={() => setEditing(undefined)}
<NewUserModal
open={creating}
onClose={() => setCreating(false)}
onSaved={({ person, msg }) => {
savePerson(person);
setEditing(undefined);
setCreating(false);
setStatus({ success: true, msg });
}}
/>
@@ -0,0 +1,142 @@
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 type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import parseUserTypes from "@/src/utils/parse-user-types";
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
import revokeUser from "../../(functions)/revoke-user";
import SettingsGroup from "../../../(partials)/settings-group";
import SettingsStatus from "../../../(partials)/settings-status";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
actor_user_id?: number | string;
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
onUpdated: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void;
};
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* Revoking or deleting a user, where the actor may. Never yourself, and
* never the super admin.
*/
export default function UserDangerGroup({
person,
actor_user_id,
actor_user_types,
onUpdated,
}: Props) {
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<Status>();
const name = person.first_name || "this user";
const user_types = parseUserTypes({ user_types: person.user_types });
const is_self = person.id == actor_user_id;
const target_user_types = user_types.map((user_type) => ({ user_type }));
const can_delete =
!is_self &&
canRevokeUser({ user_types: actor_user_types, target_user_types });
const can_revoke = can_delete && !user_types.includes("revoked");
const revoke = async () => {
if (
busy ||
!window.confirm(
`Revoke ${name}? They can't sign in until Revoked is removed from their user types. Their crew and files are kept.`,
)
)
return;
setBusy(true);
setStatus(undefined);
const res = await revokeUser({ person });
setBusy(false);
if (!res.success || !res.singleRes?.id) {
setStatus({ error: true, msg: res.msg || "Couldn't revoke them." });
return;
}
onUpdated(res.singleRes);
setStatus({ success: true, msg: `${name} can't sign in now.` });
};
const remove = async () => {
if (
busy ||
!window.confirm(
`Delete ${name} with their bots, workspaces and computers? Their computers' files stay on disk.`,
)
)
return;
setBusy(true);
setStatus(undefined);
const res = await postAdminApi({
path: "users/delete-user",
body: { user_id: person.id },
});
if (res.success) {
window.location.href = "/admin/settings/users";
return;
}
setBusy(false);
setStatus({ error: true, msg: res.msg || "Couldn't delete them." });
};
return (
<SettingsGroup
id="danger"
title="Danger zone"
description="Revoking keeps everything they had; deleting takes it with them."
>
{is_self ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
This is you. You can't revoke or delete yourself here.
</Span>
) : !can_delete ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
Nothing can be done here.
</Span>
) : (
<Row className="w-full gap-2! flex-wrap items-center">
{can_revoke ? (
<Button
title={`Revoke ${name}`}
type="button"
variant="outlined"
color="gray"
size="small"
loading={busy}
disabled={busy}
onClick={revoke}
>
Revoke
</Button>
) : null}
<Button
title={`Delete ${name}`}
type="button"
variant="outlined"
color="gray"
size="small"
loading={busy}
disabled={busy}
onClick={remove}
>
Delete user
</Button>
</Row>
)}
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,37 @@
import { useState } from "react";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
import SettingsGroup from "../../../(partials)/settings-group";
import SettingsStatus from "../../../(partials)/settings-status";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
onSaved: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void;
};
/**
* The user's profile, photo, user types, access and password, saved
* together. The same form as adding one, embedded on the page.
*/
export default function UserEditGroup({ person, onSaved }: Props) {
const [status, setStatus] = useState<{ success?: boolean; msg?: string }>();
return (
<SettingsGroup
id="edit"
title="Edit"
description="Their photo, name, contact details, user types, what they reach and a new password."
>
<UserForm
key={`${person.id}-${person.updated_at || ""}`}
person={person}
embedded
onSaved={({ person: saved, msg }) => {
onSaved(saved);
setStatus({ success: true, msg });
}}
/>
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,98 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import UserAvatar from "@/src/components/general/user-avatar";
import { UserTypes } from "@/src/dict/user-types-dict";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseIdList from "@/src/utils/parse-id-list";
import parseUserTypes from "@/src/utils/parse-user-types";
import SettingsGroup from "../../../(partials)/settings-group";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
/**
* The session user, to say "You".
*/
actor_user_id?: number | string;
/**
* Workers and workspaces a member reaches.
*/
crew_bots: BUN_SQLITE_COBBLE_BOTS[];
crew_workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
};
/**
* A user at a glance: their photo, name, contact details, user types and
* what they reach.
*/
export default function UserOverviewGroup({
person,
actor_user_id,
crew_bots,
crew_workspaces,
}: Props) {
const name =
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
person.email ||
`User ${person.id}`;
const user_types = parseUserTypes({ user_types: person.user_types });
const is_self = person.id == actor_user_id;
const reaches_everything =
user_types.includes("super_admin") || user_types.includes("admin");
const bot_ids = parseIdList({ ids: person.bot_ids });
const workspace_ids = parseIdList({ ids: person.workspace_ids });
const bot_names = crew_bots
.filter((bot) => bot_ids.includes(Number(bot.id)))
.map((bot) => bot.name || `Bot ${bot.id}`);
const workspace_names = crew_workspaces
.filter((workspace) => workspace_ids.includes(Number(workspace.id)))
.map((workspace) => workspace.name || `Workspace ${workspace.id}`);
const access = reaches_everything
? "Every worker and workspace."
: [
bot_names.length
? `Bots: ${bot_names.join(", ")}`
: "No bots.",
workspace_names.length
? `Workspaces: ${workspace_names.join(", ")}`
: "No workspaces.",
].join(" ");
return (
<SettingsGroup
id="overview"
title="Overview"
description="Who this is, and what they can reach."
>
<Row className="w-full gap-4! flex-nowrap items-start">
<UserAvatar person={person} size="lg" />
<Stack className="grow min-w-0 gap-1!">
<Row className="gap-2! flex-nowrap items-center">
<Span className="text-sm font-bold leading-5 text-ink truncate">
{name}
</Span>
{is_self ? (
<Span className="shrink-0 text-[11px] font-bold leading-4 text-ink-secondary">
You
</Span>
) : null}
</Row>
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
{[person.email, person.username].filter(Boolean).join(" · ")}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{UserTypes.filter((type) =>
user_types.includes(type.value),
)
.map((type) => type.title)
.join(", ") || "No user types"}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{access}
</Span>
</Stack>
</Row>
</SettingsGroup>
);
}
@@ -0,0 +1,57 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import { UserTypes } from "@/src/dict/user-types-dict";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseUserTypes from "@/src/utils/parse-user-types";
import SettingsShell from "../../../(partials)/settings-shell";
import UserDangerGroup from "../(partials)/user-danger-group";
import UserEditGroup from "../(partials)/user-edit-group";
import UserOverviewGroup from "../(partials)/user-overview-group";
/**
* One user's settings: overview, the form that edits them, and revoking
* or deleting them where the actor may.
*/
export default function UserSettingsSection() {
const { pageProps } = useContext(AppContext);
const [person, setPerson] = useState<
BUN_SQLITE_COBBLE_USERS_JOIN | undefined
>(pageProps?.target_person || undefined);
if (!person?.id) return null;
const name =
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
person.email ||
`User ${person.id}`;
const user_types = parseUserTypes({ user_types: person.user_types });
const types_title = UserTypes.filter((type) =>
user_types.includes(type.value),
)
.map((type) => type.title)
.join(", ");
return (
<SettingsShell
title={name}
description={
types_title || person.email || "A user on this install."
}
breadcrumb_title={name}
>
<UserOverviewGroup
person={person}
actor_user_id={pageProps?.user?.id}
crew_bots={pageProps?.crew_bots || []}
crew_workspaces={pageProps?.crew_workspaces || []}
/>
<UserEditGroup person={person} onSaved={setPerson} />
<UserDangerGroup
person={person}
actor_user_id={pageProps?.user?.id}
actor_user_types={pageProps?.user_types}
onUpdated={setPerson}
/>
</SettingsShell>
);
}
@@ -0,0 +1,42 @@
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabCrewOptions from "@/src/functions/backend/db/access/grab-crew-options";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import sanitizeUserBio from "@/src/functions/backend/db/users/sanitize-user-bio";
import canListUsers from "@/src/utils/rbac/can-list-users";
import grabSettingsServerProps from "../../(functions)/grab-settings-server-props";
/**
* One user's settings, for those who may list users (`canListUsers`):
* their profile, user types, access and danger zone.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props) return settings;
const { user_types } = await userAuth({ req: params.req });
if (!canListUsers({ user_types })) {
return { redirect: { destination: SettingsDefaultPath, permanent: false } };
}
const query: PageQueryObject = params.query;
const target_person = (
await grabUsers({ user_id: String(query.user_id) })
).singleRes;
if (!target_person?.id) {
return { redirect: { destination: "/admin/settings/users", permanent: false } };
}
return {
props: {
...settings.props,
target_person: sanitizeUserBio({ user: target_person }),
...(await grabCrewOptions()),
},
};
};
@@ -0,0 +1,8 @@
import UserSettingsSection from "./(sections)/user-settings-section";
import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta";
export default function AdminSettingsUserPage() {
return <UserSettingsSection />;
}
export const meta = settingsRecordPageMeta({ kind: "users" });
@@ -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" });