Add bot types with a permission matrix, and workspace admin bots
A bot's powers came from bots.kind alone, so only the coordinator could hand work to other bots. bot_types now works like user_types: a row with no workspace holds everywhere (the coordinator), one with a workspace only there (a workspace admin), and no row is a worker. bots.kind stays the legacy marker of the one coordinator for who sees it. The matrices in src/dict/bot-types-dict.ts say what each type may do, whom it may direct and who grants it, read by one generic interpreter (rbac/type-assignment.ts) that userTypeAssignment now wraps too. Every run reads its powers once (grabBotRunPowers) and the chat tools, task runs and task tools gate on them instead of kind; a run's powers are its type's and its user's together. In the workspace it leads, an admin hands tasks to the workers there (results relayed back to it), sets their instructions, adds tasks to their lists and keeps the workspace's context when its user may. Super admins and admins grant it from the workspace's settings or the chat header's menu, and the coordinator with set_bot_type. Admins are marked with a filled green shield beside their name across the workspace, kept live by pushBotUpdated's admin_workspace_ids. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import HeaderMenuItem from "./header-menu-item";
|
||||
|
||||
type Props = {
|
||||
bot_id: number;
|
||||
bot_name?: string | null;
|
||||
workspace_id: number;
|
||||
workspace_name?: string | null;
|
||||
/**
|
||||
* Whether the bot is this workspace's admin now.
|
||||
*/
|
||||
is_admin: boolean;
|
||||
/**
|
||||
* After a change saved: the header keeps the new state, so the menu
|
||||
* shows it when it opens again.
|
||||
*/
|
||||
on_changed: (is_admin: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The header menu's row that makes the bot this workspace's admin, or
|
||||
* takes that away (super admins and admins; `api/admin/bots/set-bot-type`).
|
||||
* It asks first. The bot gets or loses the admin's tools from its next
|
||||
* reply here.
|
||||
*/
|
||||
export default function BotAdminMenuItem({
|
||||
bot_id,
|
||||
bot_name,
|
||||
workspace_id,
|
||||
workspace_name,
|
||||
is_admin,
|
||||
on_changed,
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const name = bot_name || "this bot";
|
||||
const place = workspace_name || "this workspace";
|
||||
|
||||
const toggle = async () => {
|
||||
const given = !is_admin;
|
||||
|
||||
if (
|
||||
!window.confirm(
|
||||
given
|
||||
? `Make ${name} the admin of ${place}? It will hand tasks to the other bots here, set their instructions here, run the workspace's tasks and keep its context.`
|
||||
: `Take away ${name}'s admin role in ${place}?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const res = await postAdminApi({
|
||||
path: "bots/set-bot-type",
|
||||
body: { bot_id, workspace_id, bot_type: "admin", given },
|
||||
}).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (res?.success) {
|
||||
on_changed(given);
|
||||
return;
|
||||
}
|
||||
|
||||
window.alert(res?.msg || "That didn't work.");
|
||||
};
|
||||
|
||||
return (
|
||||
<HeaderMenuItem
|
||||
icon={ShieldCheck}
|
||||
icon_class_name={
|
||||
is_admin
|
||||
? "fill-success text-on-ink dark:fill-success-dark"
|
||||
: undefined
|
||||
}
|
||||
title={is_admin ? "Remove as admin" : "Make workspace admin"}
|
||||
hint={is_admin ? "Admin" : undefined}
|
||||
description={
|
||||
is_admin
|
||||
? `${name} leads the bots in ${place}: take that away`
|
||||
: `Make ${name} the admin of ${place}: it leads the other bots here`
|
||||
}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={toggle}
|
||||
icon_size={18}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { CrewBotsContext } from "../(data)/crew-bots-context";
|
||||
import AdminShield from "@/src/components/general/admin-shield";
|
||||
|
||||
type Props = {
|
||||
bot_id: number;
|
||||
@@ -44,6 +45,7 @@ export default function BotRefChip({ bot_id }: Props) {
|
||||
size={18}
|
||||
/>
|
||||
{bot.name}
|
||||
{workspace_id && bot.is_admin ? <AdminShield size={13} /> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import BotFilesModal from "./bot-files-modal";
|
||||
import BotTasksModal from "./bot-tasks-modal";
|
||||
import BotRoutinesModal from "./bot-routines-modal";
|
||||
import BotModelsModal from "./bot-models-modal";
|
||||
import BotAdminMenuItem from "./bot-admin-menu-item";
|
||||
import HeaderMenuItem from "./header-menu-item";
|
||||
import NewSessionButton from "./new-session-button";
|
||||
import StopBotButton from "./stop-bot-button";
|
||||
@@ -45,11 +46,17 @@ type Props = {
|
||||
* A row is opening something: the menu closes, leaving only that.
|
||||
*/
|
||||
on_open: () => void;
|
||||
/**
|
||||
* In a workspace: whether the bot is its admin, kept by the header.
|
||||
*/
|
||||
is_admin?: boolean;
|
||||
on_admin_changed?: (is_admin: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The conversation header's menu: the sessions, a new session, the bot's
|
||||
* computer, its models, its settings and, while it works, stopping it. A
|
||||
* computer, its models, in a workspace making it the admin there, its
|
||||
* settings and, while it works, stopping it. A
|
||||
* row that opens a modal closes the menu first, so the modal never sits
|
||||
* behind an open menu.
|
||||
*/
|
||||
@@ -61,12 +68,23 @@ export default function ConversationHeaderMenu({
|
||||
model_name,
|
||||
on_model_saved,
|
||||
on_open,
|
||||
is_admin,
|
||||
on_admin_changed,
|
||||
}: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const can_open_settings =
|
||||
bot.kind == "coordinator"
|
||||
? isPassThroughUser({ user_types: pageProps?.user_types })
|
||||
: canManageCrew({ user_types: pageProps?.user_types });
|
||||
/**
|
||||
* Admin is per workspace, so only in one, for a worker, and only for
|
||||
* those who hand out roles.
|
||||
*/
|
||||
const can_set_admin = Boolean(
|
||||
workspace?.id &&
|
||||
bot.kind != "coordinator" &&
|
||||
canManageCrew({ user_types: pageProps?.user_types }),
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -137,6 +155,16 @@ export default function ConversationHeaderMenu({
|
||||
on_open={on_open}
|
||||
onSaved={on_model_saved}
|
||||
/>
|
||||
{can_set_admin && workspace?.id ? (
|
||||
<BotAdminMenuItem
|
||||
bot_id={Number(bot.id)}
|
||||
bot_name={bot.name}
|
||||
workspace_id={Number(workspace.id)}
|
||||
workspace_name={workspace.name}
|
||||
is_admin={Boolean(is_admin)}
|
||||
on_changed={(value) => on_admin_changed?.(value)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
|
||||
<StopBotButton menu bot_name={bot.name} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
@@ -17,6 +17,7 @@ import BotProfileModal from "./bot-profile-modal";
|
||||
import ConversationHeaderMenu from "./conversation-header-menu";
|
||||
import HeaderMetaItem from "./header-meta-item";
|
||||
import BackButton from "@/src/components/general/back-button";
|
||||
import AdminShield from "@/src/components/general/admin-shield";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -43,6 +44,15 @@ export default function ConversationHeader({
|
||||
const { computers } = useContext(BotComputersContext);
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [model, setModel] = useState(pageProps?.bot_text_model);
|
||||
/**
|
||||
* The menu's toggle shows its change at once; a push (here or from
|
||||
* another tab) brings the live bot's.
|
||||
*/
|
||||
const [is_admin, setIsAdmin] = useState(Boolean(bot.is_admin));
|
||||
|
||||
useEffect(() => {
|
||||
setIsAdmin(Boolean(bot.is_admin));
|
||||
}, [bot.is_admin]);
|
||||
const { open: open_menu, setOpen: set_open_menu } = useStatus();
|
||||
const computer = computers[0];
|
||||
const computer_name = computer?.id
|
||||
@@ -57,9 +67,14 @@ export default function ConversationHeader({
|
||||
{is_workspace ? <BackButton /> : null}
|
||||
<BotProfileModal bot={bot} />
|
||||
<Stack className="min-w-0 gap-0!">
|
||||
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full line-clamp-1">
|
||||
{bot.name}
|
||||
</H1>
|
||||
<Row className="w-full min-w-0 gap-1.5! flex-nowrap">
|
||||
<H1 className="min-w-0 text-lg! leading-6! tracking-normal! font-bold text-ink truncate line-clamp-1">
|
||||
{bot.name}
|
||||
</H1>
|
||||
{workspace?.id && is_admin ? (
|
||||
<AdminShield size={16} label={`Admin of ${workspace.name || "this workspace"}`} />
|
||||
) : null}
|
||||
</Row>
|
||||
|
||||
<Row className="flex-nowrap w-full">
|
||||
{workspace?.id ? (
|
||||
@@ -138,6 +153,8 @@ export default function ConversationHeader({
|
||||
);
|
||||
}}
|
||||
on_open={() => set_open_menu(false)}
|
||||
is_admin={is_admin}
|
||||
on_admin_changed={setIsAdmin}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Row>
|
||||
|
||||
@@ -3,11 +3,17 @@ import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
const RowClassName = "w-full h-9 gap-2.5! flex-nowrap items-center px-3";
|
||||
|
||||
type Props = {
|
||||
icon: LucideIcon;
|
||||
/**
|
||||
* Classes for the icon, e.g. a filled state.
|
||||
*/
|
||||
icon_class_name?: string;
|
||||
/**
|
||||
* The row's label.
|
||||
*/
|
||||
@@ -27,6 +33,7 @@ type Props = {
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
icon_size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -35,6 +42,7 @@ type Props = {
|
||||
*/
|
||||
export default function HeaderMenuItem({
|
||||
icon: Icon,
|
||||
icon_class_name,
|
||||
title,
|
||||
hint,
|
||||
description,
|
||||
@@ -42,13 +50,18 @@ export default function HeaderMenuItem({
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
icon_size = AppData["DefaultIconSize"],
|
||||
}: Props) {
|
||||
const content = (
|
||||
<>
|
||||
{loading ? (
|
||||
<Loading size="small" className="shrink-0" />
|
||||
) : (
|
||||
<Icon size={16} aria-hidden className="shrink-0" />
|
||||
<Icon
|
||||
size={icon_size}
|
||||
aria-hidden
|
||||
className={twMerge("shrink-0", icon_class_name)}
|
||||
/>
|
||||
)}
|
||||
<Span className="min-w-0 grow truncate text-sm leading-5">
|
||||
{title}
|
||||
|
||||
@@ -15,6 +15,10 @@ type Props = {
|
||||
* A word beside the title, e.g. "Default" or "You".
|
||||
*/
|
||||
badge?: string;
|
||||
/**
|
||||
* A mark right after the title, e.g. a workspace admin's shield.
|
||||
*/
|
||||
title_icon?: ReactNode;
|
||||
subtitle?: string;
|
||||
/**
|
||||
* Its actions, at the end of the row.
|
||||
@@ -26,10 +30,11 @@ 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, href, badge, subtitle, children }: Props) {
|
||||
export default function SettingsListRow({ avatar, title, href, badge, title_icon, subtitle, children }: Props) {
|
||||
const title_content = (
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{title}
|
||||
{title_icon ? <Span className="ml-1.5 inline-flex align-middle">{title_icon}</Span> : null}
|
||||
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
|
||||
</Span>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import AdminShield from "@/src/components/general/admin-shield";
|
||||
import type { BotWorkspace } from "@/src/types";
|
||||
import SettingsListRow from "../../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../../(partials)/settings-row-action";
|
||||
@@ -33,6 +34,7 @@ export default function BotWorkspaceRow({
|
||||
}
|
||||
href={`/admin/settings/workspaces/${workspace.id}`}
|
||||
title={name}
|
||||
title_icon={workspace.is_admin ? <AdminShield label="Admin of this workspace" /> : undefined}
|
||||
subtitle={
|
||||
has_own
|
||||
? "Has instructions for this workspace"
|
||||
|
||||
+38
-6
@@ -3,8 +3,10 @@ import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { MessageSquare, NotebookPen, X } from "lucide-react";
|
||||
import { MessageSquare, NotebookPen, ShieldCheck, X } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import AdminShield from "@/src/components/general/admin-shield";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { WorkspaceBot } from "@/src/types";
|
||||
@@ -21,22 +23,30 @@ type Props = {
|
||||
*/
|
||||
chat_href: string;
|
||||
removing?: boolean;
|
||||
/**
|
||||
* Its admin role here is being changed.
|
||||
*/
|
||||
toggling_admin?: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
onToggleAdmin: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot in the workspace: opens its settings (or, for the coordinator,
|
||||
* its conversation here), chats with it here, edits what it has for this
|
||||
* workspace alone, or takes it out.
|
||||
* its conversation here), chats with it here, makes it the workspace's
|
||||
* admin (or takes that away), edits what it has for this workspace alone,
|
||||
* or takes it out.
|
||||
*/
|
||||
export default function WorkspaceBotRow({
|
||||
bot,
|
||||
settings_href,
|
||||
chat_href,
|
||||
removing,
|
||||
toggling_admin,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onToggleAdmin,
|
||||
}: Props) {
|
||||
const name = bot.name || "This bot";
|
||||
const has_own = Boolean(
|
||||
@@ -60,10 +70,14 @@ export default function WorkspaceBotRow({
|
||||
}
|
||||
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>
|
||||
<Row className="w-full min-w-0 gap-1! flex-nowrap">
|
||||
<Span className="min-w-0 text-sm font-bold leading-5 text-ink truncate">
|
||||
{name}
|
||||
</Span>
|
||||
{bot.is_admin ? <AdminShield /> : null}
|
||||
</Row>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{bot.is_admin ? "Admin: leads the bots here · " : ""}
|
||||
{has_own
|
||||
? "Has instructions for this workspace"
|
||||
: bot.role || "Uses its own instructions here"}
|
||||
@@ -83,6 +97,24 @@ export default function WorkspaceBotRow({
|
||||
<MessageSquare size={16} aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
title={bot.is_admin ? `${name} is this workspace's admin: take that away` : `Make ${name} this workspace's admin`}
|
||||
aria-label={bot.is_admin ? `Take away ${name}'s admin role here` : `Make ${name} this workspace's admin`}
|
||||
aria-pressed={Boolean(bot.is_admin)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
loading={toggling_admin}
|
||||
disabled={toggling_admin}
|
||||
onClick={onToggleAdmin}
|
||||
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"
|
||||
>
|
||||
<ShieldCheck
|
||||
size={16}
|
||||
aria-hidden
|
||||
className={twMerge(bot.is_admin && "fill-success text-on-ink dark:fill-success-dark")}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
title={`${name}'s instructions and memory here`}
|
||||
aria-label={`${name}'s instructions and memory here`}
|
||||
|
||||
+39
-1
@@ -34,6 +34,7 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
|
||||
const { openForm } = useContext(AddFlowContext);
|
||||
const [bots, setBots] = useState(initial);
|
||||
const [busy_id, setBusyId] = useState<number | "add">();
|
||||
const [admin_busy_id, setAdminBusyId] = useState<number>();
|
||||
const [editing, setEditing] = useState<WorkspaceBot>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
@@ -58,11 +59,46 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
|
||||
setError(res.msg || "That didn't work.");
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a bot this workspace's admin, or takes that away, in place.
|
||||
*/
|
||||
const toggleAdmin = async ({ bot }: { bot: WorkspaceBot }) => {
|
||||
const given = !bot.is_admin;
|
||||
const name = bot.name || "this bot";
|
||||
|
||||
if (
|
||||
!window.confirm(
|
||||
given
|
||||
? `Make ${name} the admin of ${workspace_name}? It will hand tasks to the other bots here, set their instructions here, run the workspace's tasks and keep its context.`
|
||||
: `Take away ${name}'s admin role in ${workspace_name}?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAdminBusyId(Number(bot.id));
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<WorkspaceBot>({
|
||||
path: "bots/set-bot-type",
|
||||
body: { workspace_id, bot_id: bot.id, bot_type: "admin", given },
|
||||
});
|
||||
|
||||
setAdminBusyId(undefined);
|
||||
|
||||
if (res.success && res.payload) {
|
||||
setBots(res.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(res.msg || "That didn't work.");
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
id="bots"
|
||||
title="Bots"
|
||||
description="The coordinator works in every workspace. Add the bots this one needs; each keeps its skills and follows this workspace's ways here."
|
||||
description="The coordinator works in every workspace. Add the bots this one needs; each keeps its skills and follows this workspace's ways here. Make one the admin to have it lead the others here."
|
||||
>
|
||||
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
|
||||
{workers.map((bot) => (
|
||||
@@ -76,6 +112,8 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
|
||||
}
|
||||
chat_href={`/admin/workspaces/${workspace_id}/bots/${bot.id}`}
|
||||
removing={busy_id == bot.id}
|
||||
toggling_admin={admin_busy_id == bot.id}
|
||||
onToggleAdmin={() => toggleAdmin({ bot })}
|
||||
onEdit={() => setEditing(bot)}
|
||||
onRemove={() => change({ path: "remove-bot", bot_id: Number(bot.id) })}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import AdminShield from "@/src/components/general/admin-shield";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
@@ -30,6 +31,7 @@ export default function WorkspaceDashboardBotRow({ workspace_id, bot }: Props) {
|
||||
title={name}
|
||||
href={`/admin/workspaces/${workspace_id}/bots/${bot.id}`}
|
||||
badge={is_coordinator ? "Coordinator" : undefined}
|
||||
title_icon={bot.is_admin ? <AdminShield /> : undefined}
|
||||
subtitle={
|
||||
has_own
|
||||
? "Has instructions for this workspace"
|
||||
|
||||
Reference in New Issue
Block a user