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:
@@ -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) })}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user