Add dedicated bot, workspace and user settings pages
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+120
@@ -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>
|
||||
);
|
||||
}
|
||||
+53
@@ -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>
|
||||
);
|
||||
}
|
||||
+126
@@ -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>
|
||||
);
|
||||
}
|
||||
+60
@@ -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>
|
||||
);
|
||||
}
|
||||
+109
@@ -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>
|
||||
);
|
||||
}
|
||||
+57
@@ -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" });
|
||||
Reference in New Issue
Block a user