Add dedicated bot, workspace and user settings pages
This commit is contained in:
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
-2
@@ -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>
|
||||
);
|
||||
|
||||
+55
-11
@@ -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
-2
@@ -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) })}
|
||||
+2
-1
@@ -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."
|
||||
>
|
||||
+2
-2
@@ -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."
|
||||
>
|
||||
+1
@@ -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."
|
||||
>
|
||||
+1
-1
@@ -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
-1
@@ -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;
|
||||
+15
-20
@@ -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.`,
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]!,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user