Add dedicated bot, workspace and user settings pages

This commit is contained in:
2026-10-19 13:17:17 +01:00
parent 731ea73486
commit 7667f2bc2f
74 changed files with 2183 additions and 358 deletions
+58
View File
@@ -0,0 +1,58 @@
import {
Bot,
CircleAlert,
CpuAi,
Image,
Info,
Layers,
Monitor,
Pencil,
Trash2,
type LucideIcon,
} from "lucide-react";
import type { SettingsRecordKind } from "@/src/utils/parse-settings-record-path";
export type SettingsRecordSection = {
title: string;
url: string;
Icon: LucideIcon;
};
/**
* Where the record rail's back link goes, and the sections it lists.
*/
export const SettingsRecordBack: Record<
SettingsRecordKind,
{ title: string; href: string }
> = {
bots: { title: "All bots", href: "/admin/settings/bots" },
workspaces: { title: "All workspaces", href: "/admin/settings/workspaces" },
users: { title: "All users", href: "/admin/settings/users" },
};
export const SettingsRecordSections: Record<
SettingsRecordKind,
SettingsRecordSection[]
> = {
bots: [
{ title: "Overview", url: "#overview", Icon: Info },
{ title: "Details", url: "#details", Icon: Pencil },
{ title: "Models", url: "#models", Icon: CpuAi },
{ title: "Workspaces", url: "#workspaces", Icon: Layers },
{ title: "Computers", url: "#computers", Icon: Monitor },
{ title: "Delete", url: "#delete", Icon: Trash2 },
],
workspaces: [
{ title: "Picture", url: "#picture", Icon: Image },
{ title: "Details", url: "#details", Icon: Pencil },
{ title: "Bots", url: "#bots", Icon: Bot },
{ title: "Computers", url: "#computers", Icon: Monitor },
{ title: "Models", url: "#models", Icon: CpuAi },
{ title: "Delete", url: "#delete", Icon: Trash2 },
],
users: [
{ title: "Overview", url: "#overview", Icon: Info },
{ title: "Edit", url: "#edit", Icon: Pencil },
{ title: "Danger zone", url: "#danger", Icon: CircleAlert },
],
};
@@ -0,0 +1,51 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACE_BOTS } from "@/db/types/db";
import type { BotWorkspace } from "@/src/types";
import grabUserWorkspaces from "./grab-user-workspaces";
type Params = {
user_id: number | string;
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "kind">;
};
/**
* The workspaces a bot works in, for its settings page. The coordinator
* works in every workspace; a worker only in the ones it was added to.
* Each row carries what the bot has for that workspace alone.
*/
export default async function grabBotWorkspaces({
user_id,
bot,
}: Params): Promise<BotWorkspace[]> {
const workspaces = await grabUserWorkspaces({ user_id });
if (bot.kind == "coordinator") {
return workspaces.map((workspace) => ({
...workspace,
workspace_system_prompt: null,
workspace_memory: null,
}));
}
const rows =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACE_BOTS>({
sql: `SELECT workspace_id, system_prompt, memory FROM workspace_bots WHERE bot_id = ?`,
values: [Number(bot.id)],
})
).payload || [];
const by_workspace = new Map(rows.map((row) => [Number(row.workspace_id), row]));
return workspaces
.filter((workspace) => by_workspace.has(Number(workspace.id)))
.map((workspace) => {
const row = by_workspace.get(Number(workspace.id));
return {
...workspace,
workspace_system_prompt: row?.system_prompt || null,
workspace_memory: row?.memory || null,
};
});
}
+1 -1
View File
@@ -122,7 +122,7 @@ export default function AddFlow({ children }: Props) {
const pathname = pageProps?.url?.pathname;
if (
(linked && pathname == `/admin/workspaces/${workspace?.id}`) ||
(linked && pathname == `/admin/settings/workspaces/${workspace?.id}`) ||
pathname == SettingsComputersPath
) {
window.location.reload();
@@ -7,7 +7,11 @@ type Props = {
submit_label: string;
loading?: boolean;
error?: string;
onCancel: () => void;
/**
* Left out on a form embedded in a settings page: then there is
* nothing to cancel into.
*/
onCancel?: () => void;
};
/**
@@ -19,6 +23,7 @@ export default function AddFormActions({ submit_label, loading, error, onCancel
<Stack className="gap-3! items-stretch">
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
<Row className="gap-2! flex-nowrap justify-end">
{onCancel ? (
<Button
title="Cancel"
type="button"
@@ -30,6 +35,7 @@ export default function AddFormActions({ submit_label, loading, error, onCancel
>
Cancel
</Button>
) : null}
<Button title={submit_label} type="submit" size="small" loading={loading} disabled={loading}>
{submit_label}
</Button>
@@ -49,7 +49,7 @@ export default function AddWorkspaceForm({ onCancel }: Props) {
await setProfileImage({ target: "workspace", id: workspace_id, data_url: image.data_url }).catch(() => null);
}
window.location.href = `/admin/workspaces/${workspace_id}`;
window.location.href = `/admin/settings/workspaces/${workspace_id}`;
};
return (
+27 -2
View File
@@ -20,7 +20,15 @@ type Props = {
* Edits this bot; adds a worker when not given.
*/
bot?: BUN_SQLITE_COBBLE_BOTS;
onCancel: () => void;
/**
* Left out when the form is embedded in a settings page.
*/
onCancel?: () => void;
/**
* Called with the saved bot instead of reloading. For the settings
* page, which updates in place.
*/
onSaved?: (bot: BUN_SQLITE_COBBLE_BOTS) => void;
};
/**
@@ -30,7 +38,7 @@ type Props = {
* The coordinator keeps its name and has no colour: only its picture and
* instructions change.
*/
export default function BotForm({ bot, onCancel }: Props) {
export default function BotForm({ bot, onCancel, onSaved }: Props) {
const { pageProps } = useContext(AppContext);
const workspace = pageProps?.workspace;
const crew = pageProps?.all_bots || pageProps?.bots || [];
@@ -72,6 +80,8 @@ export default function BotForm({ bot, onCancel }: Props) {
return;
}
let image_media_id: number | null | undefined;
if (image.changed) {
const image_res = await setProfileImage({
target: "bot",
@@ -84,9 +94,24 @@ export default function BotForm({ bot, onCancel }: Props) {
setError(image_res?.msg || "The bot is saved, but its picture isn't. Try another image.");
return;
}
image_media_id = image_res.singleRes?.image_media_id;
}
if (bot?.id) {
const saved = res.singleRes as BUN_SQLITE_COBBLE_BOTS | undefined;
if (onSaved && saved?.id) {
setLoading(false);
onSaved({
...saved,
...(image_media_id === undefined
? {}
: { image_media_id: image_media_id ?? undefined }),
});
return;
}
window.location.reload();
return;
}
@@ -0,0 +1,127 @@
import { useContext, type ReactNode } from "react";
import Divider from "@moduletrace/twui/layout/Divider";
import BotAvatar from "@/src/components/general/bot-avatar";
import UserAvatar from "@/src/components/general/user-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import { UserTypes } from "@/src/dict/user-types-dict";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import {
SettingsRecordBack,
SettingsRecordSections,
} from "@/src/dict/settings-record-sections-dict";
import { AppContext } from "@/src/pages/__root";
import parseSettingsRecordPath from "@/src/utils/parse-settings-record-path";
import parseUserTypes from "@/src/utils/parse-user-types";
import RailBackLink from "./rail-back-link";
import RailSettingsLink from "./rail-settings-link";
import RailSettingsList from "./rail-settings-list";
import RailSettingsRecordRow from "./rail-settings-record-row";
type Props = {
pathname?: string;
};
/**
* Settings mode of the rail. On a list page it shows the settings sub
* pages; on a bot, workspace or user record page it drills into that
* record: a back link to its list, the record, then its sections.
*/
export default function RailSettingsAside({ pathname }: Props) {
const { pageProps } = useContext(AppContext);
const record = parseSettingsRecordPath({ pathname });
if (!record) {
return <RailSettingsList pathname={pathname} />;
}
const back = SettingsRecordBack[record.kind];
const sections = SettingsRecordSections[record.kind].filter((section) => {
if (
record.kind == "bots" &&
section.url == "#delete" &&
pageProps?.bot?.kind == "coordinator"
) {
return false;
}
if (section.url == "#models") {
return record.kind == "bots"
? Boolean(pageProps?.bot_harness_overview?.harnesses?.length)
: record.kind == "workspaces"
? Boolean(
pageProps?.workspace_harness_overview?.harnesses?.length,
)
: true;
}
return true;
});
let avatar: ReactNode = null;
let title = "";
let subtitle = "";
if (record.kind == "bots") {
const bot = pageProps?.bot;
if (!bot?.id) return <RailSettingsList pathname={pathname} />;
title = bot.name || `Bot ${bot.id}`;
subtitle =
bot.role ||
(bot.kind == "coordinator"
? "Coordinates your crew"
: "Worker bot");
avatar = (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
/>
);
} else if (record.kind == "workspaces") {
const workspace = pageProps?.workspace;
if (!workspace?.id) return <RailSettingsList pathname={pathname} />;
title = workspace.name || `Workspace ${workspace.id}`;
subtitle = workspace.description || "A workspace";
avatar = (
<WorkspaceAvatar image_media_id={workspace.image_media_id} />
);
} else {
const person = pageProps?.target_person;
if (!person?.id) return <RailSettingsList pathname={pathname} />;
const types = UserTypes.filter((type) =>
parseUserTypes({ user_types: person.user_types }).includes(
type.value,
),
)
.map((type) => type.title)
.join(", ");
title =
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
person.email ||
`User ${person.id}`;
subtitle = types || person.email || "User";
avatar = <UserAvatar person={person} size="md" />;
}
return (
<>
<RailBackLink title={back.title} href={back.href} />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<RailSettingsRecordRow
avatar={avatar}
title={title}
subtitle={subtitle}
/>
{sections.map((section) => (
<RailSettingsLink key={section.url} {...section} />
))}
</>
);
}
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
avatar: ReactNode;
title: string;
subtitle?: string;
};
/**
* The record whose settings are open, at the top of its rail. Same 48px
* row as a bot, selected.
*/
export default function RailSettingsRecordRow({
avatar,
title,
subtitle,
}: Props) {
return (
<Row
aria-current="page"
className="w-full shrink-0 min-h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-3 bg-ink/5"
>
{avatar}
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{title}
</Span>
{subtitle ? (
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{subtitle}
</Span>
) : null}
</Stack>
</Row>
);
}
@@ -1,9 +1,12 @@
import { useContext } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import { twMerge } from "tailwind-merge";
import { AppContext } from "@/src/pages/__root";
import type { WorkspaceWithActivity } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
type Props = {
workspace: WorkspaceWithActivity;
@@ -15,9 +18,14 @@ type Props = {
* hue, so unread is said in words beside the description.
*/
export default function WorkspaceRow({ workspace, selected }: Props) {
const { pageProps } = useContext(AppContext);
const href = canManageCrew({ user_types: pageProps?.user_types })
? `/admin/settings/workspaces/${workspace.id}`
: `/admin/workspaces/${workspace.id}`;
return (
<Link
href={`/admin/workspaces/${workspace.id}`}
href={href}
aria-current={selected ? "page" : undefined}
title={workspace.name}
className={twMerge(
+2 -2
View File
@@ -7,7 +7,7 @@ import { RailPages } from "@/src/dict/rail-pages-dict";
import RailHeader from "../(partials)/rail-header";
import RailPagesList from "../(partials)/rail-pages-list";
import RailCrewList from "../(partials)/rail-crew-list";
import RailSettingsList from "../(partials)/rail-settings-list";
import RailSettingsAside from "../(partials)/rail-settings-aside";
import RailUserRow from "../(partials)/rail-user-row";
import RailWorkspaceList from "../(partials)/rail-workspace-list";
@@ -48,7 +48,7 @@ export default function BotsRail() {
<RailHeader show_add={!is_settings_page && !is_rail_page} />
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1 pb-3">
{is_settings_page ? (
<RailSettingsList pathname={pathname} />
<RailSettingsAside pathname={pathname} />
) : is_rail_page ? (
<RailPagesList pathname={pathname} />
) : in_workspace && live_workspace ? (
@@ -9,7 +9,7 @@ import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotHarnessForm from "./bot-harness-form";
import BotHarnessForm from "@/src/pages/admin/settings/(partials)/bot-harness-form";
import HeaderIconButton from "./header-icon-button";
type Props = {
@@ -2,15 +2,19 @@ import { useContext, useState } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import { CpuAi, Layers, Monitor } from "lucide-react";
import { CpuAi, Layers, Monitor, Settings } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputersModal from "./bot-computers-modal";
import BotModelsModal from "./bot-models-modal";
import BotProfileModal from "./bot-profile-modal";
import BotSessionsModal from "./bot-sessions-modal";
import NewSessionButton from "./new-session-button";
import HeaderIconButton from "./header-icon-button";
import HeaderMetaItem from "./header-meta-item";
type Props = {
@@ -36,9 +40,14 @@ type Props = {
export default function ConversationHeader({ bot, workspace, has_other_unread, harnesses: initial }: Props) {
const [harnesses, setHarnesses] = useState(initial);
const { computers } = useContext(BotComputersContext);
const { pageProps } = useContext(AppContext);
const model = harnesses?.find((h) => h.harness == "text")?.current;
const computer = computers[0];
const can_open_settings =
bot.kind == "coordinator"
? isPassThroughUser({ user_types: pageProps?.user_types })
: canManageCrew({ user_types: pageProps?.user_types });
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
@@ -84,6 +93,13 @@ export default function ConversationHeader({ bot, workspace, has_other_unread, h
harnesses={harnesses}
onSaved={setHarnesses}
/>
{can_open_settings ? (
<HeaderIconButton
icon={Settings}
title={`${bot.name || "This bot"}'s settings`}
href={`/admin/settings/bots/${bot.id}`}
/>
) : null}
</Row>
</Row>
);
@@ -13,13 +13,24 @@ type Props = {
* For a button that acts on its own; a modal's target leaves it out.
*/
onClick?: () => void;
/**
* For a button that opens a page.
*/
href?: string;
};
/**
* A ghost icon button on the right of the conversation header, usually a
* modal's target.
*/
export default function HeaderIconButton({ icon: Icon, title, disabled, loading, onClick }: Props) {
export default function HeaderIconButton({
icon: Icon,
title,
disabled,
loading,
onClick,
href,
}: Props) {
return (
<Button
title={title}
@@ -30,6 +41,7 @@ export default function HeaderIconButton({ icon: Icon, title, disabled, loading,
disabled={disabled}
loading={loading}
onClick={onClick}
href={href}
className="shrink-0 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 transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Icon size={18} aria-hidden />
@@ -1,25 +0,0 @@
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
import BotHarnessForm from "../../(partials)/bot-harness-form";
/**
* A worker bot's models: per harness, the default or its own pick. The
* same form as the chat header's models modal.
*/
export default function BotModelsSection() {
const { pageProps } = useContext(AppContext);
const bot = pageProps?.bot;
const overview = pageProps?.bot_harness_overview;
if (!bot?.id || !overview) return null;
return (
<SettingsShell
title={`${bot.name}'s models`}
description="Uses the default models unless you pick its own."
>
<BotHarnessForm bot={bot} harnesses={overview.harnesses} />
</SettingsShell>
);
}
@@ -1,41 +0,0 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* A worker bot's model settings, for super admins and admins. The
* coordinator always uses the defaults, so its page (and anyone else's)
* goes back to the conversation.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query;
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.id) {
return { redirect: { destination: "/auth/login" } };
}
const bots = await grabBots({ user_id: user.id });
const bot = bots.find((b) => String(b.id) === String(query.bot_id));
if (!bot?.id) {
return { redirect: { destination: "/admin/bots" } };
}
if (bot.kind == "coordinator" || !canManageCrew({ user_types })) {
return { redirect: { destination: `/admin/bots/${bot.id}` } };
}
return {
props: {
bots,
bot,
workspaces: await grabUserWorkspaces({ user_id: user.id }),
bot_harness_overview: await grabBotHarnessOverview({ bot_id: bot.id }),
},
};
};
@@ -1,17 +0,0 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import BotModelsSection from "./(sections)/bot-models-section";
export default function BotModelsPage() {
return <BotModelsSection />;
}
export const meta: BunextPageModuleMetaFn = async (params) => {
const bot = (params.serverRes?.props as PagePropsType | undefined)?.bot;
return {
title: `${bot?.name || "Bot"} models | ${SiteData["SiteName"]}`,
description: `Which models ${bot?.name || "this bot"} uses.`,
};
};
@@ -26,6 +26,10 @@ type Props = {
*/
onSaved?: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
onCancel?: () => void;
/**
* On a settings page: no Cancel, and the save updates in place.
*/
embedded?: boolean;
};
/**
@@ -33,7 +37,12 @@ type Props = {
* (bots and workspaces) and password, saved in one go through
* `users/save-user`.
*/
export default function UserForm({ person, onSaved, onCancel }: Props) {
export default function UserForm({
person,
onSaved,
onCancel,
embedded,
}: Props) {
const init = useFormInit<UserFormObject>({
existing: person
? {
@@ -63,7 +72,11 @@ export default function UserForm({ person, onSaved, onCancel }: Props) {
<UserFormUserTypes {...init} person={person} />
<UserFormAccess {...init} person={person} />
<UserFormPassword {...init} />
<UserFormAction {...init} onCancel={onCancel} />
<UserFormAction
{...init}
onCancel={onCancel}
hide_cancel={embedded}
/>
</Form>
);
}
@@ -4,15 +4,29 @@ import type { UserFormObject } from "@/src/types";
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
onCancel?: () => void;
/**
* On a settings page, where there is nothing to cancel into.
*/
hide_cancel?: boolean;
};
export default function UserFormAction({ existing_full, loading, status, onCancel }: Props) {
export default function UserFormAction({
existing_full,
loading,
status,
onCancel,
hide_cancel,
}: Props) {
return (
<AddFormActions
submit_label={existing_full?.id ? "Save" : "Add user"}
loading={loading}
error={status?.error ? status.msg : undefined}
onCancel={onCancel || (() => window.history.back())}
onCancel={
hide_cancel
? undefined
: onCancel || (() => window.history.back())
}
/>
);
}
@@ -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"]}.`,
};
};
}
@@ -2,8 +2,8 @@ import { useState } from "react";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
import postHarnessApi from "@/src/pages/admin/settings/models/(functions)/post-harness-api";
import saveBotHarnessSettings from "../models/(functions)/save-bot-harness-settings";
import saveWorkspaceHarnessSettings from "@/src/pages/admin/workspaces/[workspace_id]/(functions)/save-workspace-harness-settings";
import saveBotHarnessSettings from "@/src/functions/frontend/save-bot-harness-settings";
import saveWorkspaceHarnessSettings from "@/src/functions/frontend/save-workspace-harness-settings";
type Props = {
bot: BotWithLastMessage;
@@ -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) {
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!">
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!">
{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} />
</>
)}
</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({
@@ -86,7 +80,7 @@ export default function UsersSettingsSection() {
variant="ghost"
color="gray"
size="small"
onClick={() => setEditing(true)}
onClick={() => setCreating(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
@@ -94,13 +88,12 @@ export default function UsersSettingsSection() {
</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>
);
@@ -3,41 +3,85 @@ 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 { NotebookPen, X } from "lucide-react";
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;
href: string;
/**
* 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 conversation here, edits what it has
* for this workspace alone, or takes it out.
* 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, href, removing, onEdit, onRemove }: Props) {
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 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} />
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
/>
<Link
href={href}
title={`Talk to ${name} here`}
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-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"}
{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`}
@@ -8,7 +8,7 @@ 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 "./inline-add-select";
import InlineAddSelect from "@/src/components/general/inline-add-select";
import WorkspaceBotModal from "./workspace-bot-modal";
import WorkspaceBotRow from "./workspace-bot-row";
@@ -60,6 +60,7 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
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."
>
@@ -68,7 +69,12 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
<WorkspaceBotRow
key={bot.id}
bot={bot}
href={`/admin/workspaces/${workspace_id}/bots/${bot.id}`}
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) })}
@@ -10,7 +10,7 @@ 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 "./inline-add-select";
import InlineAddSelect from "@/src/components/general/inline-add-select";
import WorkspaceComputerRow from "./workspace-computer-row";
type Props = {
@@ -73,6 +73,7 @@ export default function WorkspaceComputersGroup({ workspace_id, computers: initi
return (
<SettingsGroup
id="computers"
title="Computers"
description="Bots here work on these. Their tasks run on the default one unless they pick another."
>
@@ -1,7 +1,6 @@
import { useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
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";
@@ -28,7 +27,7 @@ export default function WorkspaceDeleteGroup({ workspace }: Props) {
const res = await postAdminApi({ path: "workspaces/delete-workspace", body: { workspace_id: workspace.id } });
if (res.success) {
window.location.href = AdminHomePath;
window.location.href = "/admin/settings/workspaces";
return;
}
@@ -38,6 +37,7 @@ export default function WorkspaceDeleteGroup({ workspace }: Props) {
return (
<SettingsGroup
id="delete"
title="Delete"
description="Deletes the workspace and its conversations. Its bots and computers stay yours."
>
@@ -57,6 +57,7 @@ export default function WorkspaceDetailsGroup({ workspace, onSaved }: Props) {
return (
<SettingsGroup
id="details"
title="Workspace"
description="Every bot working here gets this context on top of its own instructions."
>
@@ -43,7 +43,7 @@ export default function WorkspaceImageGroup({ workspace }: Props) {
};
return (
<SettingsGroup title="Picture" description="Shown beside the workspace's name in the rail.">
<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)}
@@ -1,7 +1,7 @@
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 "../(functions)/save-workspace-harness-settings";
import saveWorkspaceHarnessSettings from "@/src/functions/frontend/save-workspace-harness-settings";
type Props = {
workspace_id: number;
@@ -1,20 +1,17 @@
import { useContext, useState } from "react";
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
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";
import WorkspaceSectionHeading from "../(partials)/workspace-section-heading";
/**
* A workspace's page beside its rail: its context, bots, computers, then
* its models, and deleting it. A member only lands here while it has no
* bots yet.
* 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);
@@ -22,27 +19,24 @@ export default function WorkspaceOverview() {
if (!workspace?.id) return null;
if (!canManageCrew({ user_types: pageProps?.user_types })) {
return (
<SettingsShell title={workspace.name || "Workspace"} description={workspace.description || "A workspace."}>
<EmptyContent title="No bots here yet. An admin adds them." />
</SettingsShell>
);
}
const workspace_id = Number(workspace.id);
const name = workspace.name || "Workspace";
const harnesses = pageProps?.workspace_harness_overview?.harnesses;
return (
<SettingsShell
title={workspace.name || "Workspace"}
description={workspace.description || "Context, bots, computers and models for this workspace."}
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={workspace.name || "this workspace"}
workspace_name={name}
bots={pageProps?.workspace_bots || []}
all_bots={pageProps?.all_bots || []}
/>
@@ -51,16 +45,17 @@ export default function WorkspaceOverview() {
computers={pageProps?.workspace_computers || []}
all_computers={pageProps?.computers || []}
/>
<WorkspaceDeleteGroup workspace={workspace} />
{harnesses?.length ? (
<>
<WorkspaceSectionHeading
<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" });
@@ -1,20 +0,0 @@
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;
};
/**
* Heads a run of settings groups on the workspace page (its models).
*/
export default function WorkspaceSectionHeading({ title, description }: Props) {
return (
<Stack className="w-full 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,16 +1,18 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* A bot's conversation inside a workspace: the bot works with the
* workspace's context, computers and models. Only the coordinator and the
* workspace's bots have one; anyone else goes back to the workspace.
* workspace's bots have one; anyone else goes back to the workspace
* settings (managers) or their bots.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query;
const { user } = await userAuth({ req: params.req });
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.id) {
return { redirect: { destination: "/auth/login" } };
@@ -23,7 +25,13 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
});
if (!props) {
return { redirect: { destination: `/admin/workspaces/${query.workspace_id}` } };
return {
redirect: {
destination: canManageCrew({ user_types })
? `/admin/settings/workspaces/${query.workspace_id}`
: "/admin/bots",
},
};
}
return { props };
@@ -1,19 +1,15 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
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 grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* A workspace's page: its details, crew, computers and models, with the
* rail showing its crew. Only super admins and admins manage it; a member
* opens its first bot instead.
* The workspace's old landing page. Management moved to
* `/admin/settings/workspaces/<id>`: managers go there, members go to the
* workspace's first bot. No page renders.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query;
@@ -23,47 +19,37 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return { redirect: { destination: "/auth/login" } };
}
const workspaces = await grabUserWorkspaces({ user_id: user.id });
const workspace = workspaces.find((w) => String(w.id) == String(query.workspace_id));
const workspace = await grabUserWorkspace({
workspace_id: String(query.workspace_id),
user_id: user.id,
});
if (!workspace?.id) {
return { redirect: { destination: AdminHomePath } };
return { redirect: { destination: AdminHomePath, permanent: false } };
}
const workspace_id = Number(workspace.id);
/**
* Members don't manage it: they go straight to its first bot.
*/
if (!canManageCrew({ user_types })) {
const workspace_bots = await grabWorkspaceBots({ user_id: user.id, workspace_id });
if (workspace_bots[0]?.id) {
return { redirect: { destination: `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}` } };
if (canManageCrew({ user_types })) {
return {
redirect: {
destination: `/admin/settings/workspaces/${workspace_id}`,
permanent: false,
},
};
}
return { props: { workspaces, workspace, bots: workspace_bots, workspace_bots } };
}
const [workspace_bots, all_bots, workspace_computers, computers, workspace_harness_overview] =
await Promise.all([
grabWorkspaceBots({ user_id: user.id, workspace_id }),
grabBots({ user_id: user.id }),
grabWorkspaceComputers({ workspace_id }),
grabUserComputers({ user_id: user.id }),
grabBotHarnessOverview({ workspace_id }).catch(() => null),
]);
const workspace_bots = await grabWorkspaceBots({
user_id: user.id,
workspace_id,
});
return {
props: {
workspaces,
workspace,
bots: workspace_bots,
workspace_bots,
all_bots,
workspace_computers,
computers,
workspace_harness_overview,
redirect: {
destination: workspace_bots[0]?.id
? `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}`
: AdminHomePath,
permanent: false,
},
};
};
@@ -1,17 +1,15 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import WorkspaceOverview from "./(sections)/workspace-overview";
/**
* Never rendered: `index.server.ts` always redirects. Workspace settings
* live at `/admin/settings/workspaces/<id>`.
*/
export default function WorkspacePage() {
return <WorkspaceOverview />;
return null;
}
export const meta: BunextPageModuleMetaFn = async (params) => {
const workspace = (params.serverRes?.props as PagePropsType | undefined)?.workspace;
return {
title: `${workspace?.name || "Workspace"} | ${SiteData["SiteName"]}`,
description: workspace?.description || `A workspace.`,
};
export const meta: BunextPageModuleMeta = {
title: `Workspaces | ${SiteData["SiteName"]}`,
description: `Your workspaces.`,
};
+11 -3
View File
@@ -2,13 +2,15 @@ import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import type { PagePropsType } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* Opens the user's first workspace, or the bots when they have none.
* Opens the user's first workspace: its settings page for managers, its
* first bot for members. The bots when they have no workspaces.
*/
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const { user } = await userAuth({ req });
const { user, user_types } = await userAuth({ req });
if (!user?.id) {
return { redirect: { destination: "/auth/login" } };
@@ -16,9 +18,15 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const first = (await grabUserWorkspaces({ user_id: user.id }))[0];
if (!first?.id) {
return { redirect: { destination: AdminHomePath } };
}
return {
redirect: {
destination: first?.id ? `/admin/workspaces/${first.id}` : AdminHomePath,
destination: canManageCrew({ user_types })
? `/admin/settings/workspaces/${first.id}`
: `/admin/workspaces/${first.id}`,
},
};
};
+67
View File
@@ -0,0 +1,67 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import addBotToComputer from "@/src/functions/backend/db/computers/add-bot-to-computer";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { BotComputerRequestSchema } from "@/src/schemas/bot-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Gives one of the session user's bots direct access to one of their
* computers. Returns the bot's computers.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
if (!canManageCrew({ user_types })) {
throw new AppError(`Only an admin can do that.`);
}
const { bot_id, computer_id } = parseWithSchema({
schema: BotComputerRequestSchema,
data: body,
});
await addBotToComputer({ user_id: user.id, bot_id, computer_id });
await logEvent({
title: "Bot computer access changed",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} gave bot #${bot_id} access to computer #${computer_id}.`,
metadata: { bot_id, computer_id },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Saved.`,
payload: await grabBotComputers({ bot_id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,71 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import removeBotFromComputer from "@/src/functions/backend/db/computers/remove-bot-from-computer";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { BotComputerRequestSchema } from "@/src/schemas/bot-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Takes away one of the session user's bots' direct access to one of
* their computers. Returns the bot's computers.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
if (!canManageCrew({ user_types })) {
throw new AppError(`Only an admin can do that.`);
}
const { bot_id, computer_id } = parseWithSchema({
schema: BotComputerRequestSchema,
data: body,
});
await removeBotFromComputer({
user_id: user.id,
bot_id,
computer_id,
});
await logEvent({
title: "Bot computer access changed",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} took bot #${bot_id}'s access to computer #${computer_id} away.`,
metadata: { bot_id, computer_id },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Saved.`,
payload: await grabBotComputers({ bot_id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+10
View File
@@ -37,6 +37,16 @@ export const SetBotComputerRequestSchema = z.object({
export type SetBotComputerRequest = z.infer<typeof SetBotComputerRequestSchema>;
/**
* Giving a bot direct access to a computer, or taking it away.
*/
export const BotComputerRequestSchema = z.object({
bot_id: z.coerce.number().int().positive("Pick a bot"),
computer_id: z.coerce.number().int().positive("Pick a computer"),
});
export type BotComputerRequest = z.infer<typeof BotComputerRequestSchema>;
export const BotSessionIdRequestSchema = z.object({
session_id: z.coerce.number().int().positive("Pick a session"),
});
+19 -1
View File
@@ -101,6 +101,15 @@ export type WorkspaceBot = BotWithLastMessage & {
workspace_memory?: string | null;
};
/**
* A workspace a bot works in, with what the bot has for that workspace
* alone.
*/
export type BotWorkspace = WorkspaceWithActivity & {
workspace_system_prompt?: string | null;
workspace_memory?: string | null;
};
/**
* The task shown in the working-on panel, with its ordered steps.
*/
@@ -346,6 +355,11 @@ export type PagePropsType = {
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
person?: BUN_SQLITE_COBBLE_USERS_JOIN | null;
persons?: BUN_SQLITE_COBBLE_USERS_JOIN[] | null;
/**
* The user a settings page manages, when it is not the session user
* (`/admin/settings/users/<id>`).
*/
target_person?: BUN_SQLITE_COBBLE_USERS_JOIN | null;
/**
* Every worker and workspace, to pick what a user reaches
* (`grabCrewOptions`).
@@ -379,10 +393,14 @@ export type PagePropsType = {
workspace_computers?: ComputerWithStatus[] | null;
computers?: ComputerWithStatus[] | null;
workspace_harness_overview?: BotHarnessOverviewPayload | null;
/**
* The workspaces a bot works in, with what it has for each alone.
*/
bot_workspaces?: BotWorkspace[] | null;
/**
* The open bot's computers, the one it's on first.
*/
bot_computers?: BUN_SQLITE_COBBLE_COMPUTERS[] | null;
bot_computers?: ComputerWithStatus[] | null;
/**
* The open bot's active task and what its job has done so far.
*/
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import parseSettingsRecordPath from "./parse-settings-record-path";
describe("parseSettingsRecordPath", () => {
test("reads a nested settings record", () => {
expect(
parseSettingsRecordPath({ pathname: "/admin/settings/bots/3" }),
).toEqual({ kind: "bots", id: "3" });
expect(
parseSettingsRecordPath({
pathname: "/admin/settings/workspaces/12/",
}),
).toEqual({ kind: "workspaces", id: "12" });
expect(
parseSettingsRecordPath({ pathname: "/admin/settings/users/8" }),
).toEqual({ kind: "users", id: "8" });
});
test("ignores settings list pages and other paths", () => {
expect(
parseSettingsRecordPath({ pathname: "/admin/settings/bots" }),
).toBeNull();
expect(
parseSettingsRecordPath({ pathname: "/admin/settings/models" }),
).toBeNull();
expect(
parseSettingsRecordPath({
pathname: "/admin/settings/bots/3/models",
}),
).toBeNull();
expect(
parseSettingsRecordPath({ pathname: "/admin/bots/3" }),
).toBeNull();
expect(parseSettingsRecordPath({ pathname: undefined })).toBeNull();
});
});
+32
View File
@@ -0,0 +1,32 @@
export type SettingsRecordKind = "bots" | "workspaces" | "users";
export type SettingsRecordPath = {
kind: SettingsRecordKind;
id: string;
};
type Params = {
pathname?: string | null;
};
/**
* The record a nested settings page manages, e.g.
* `/admin/settings/bots/3` -> `{ kind: "bots", id: "3" }`. Null on the
* settings list pages and everywhere else.
*/
export default function parseSettingsRecordPath({
pathname,
}: Params): SettingsRecordPath | null {
if (!pathname) return null;
const match = pathname
.replace(/\/$/, "")
.match(/^\/admin\/settings\/(bots|workspaces|users)\/([^/]+)$/);
if (!match) return null;
return {
kind: match[1] as SettingsRecordKind,
id: match[2]!,
};
}