From 2d85177c5942a020083f7869e3b6aa9837181092 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 25 Oct 2026 13:17:32 +0100 Subject: [PATCH] Let crew managers edit a bot's workspace instructions where they work with it A bot's instructions and memory for one workspace were only reachable from the workspace's settings. The editor is now shared and also opens from the conversation menu in that workspace ("Instructions here", loaded on open through api/admin/workspaces/workspace-bot) and from the workspace rows on the bot's settings page. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/components/general/workspace-bot-form.tsx | 86 +++++++++++++++++++ .../general/workspace-bot-modal.tsx | 58 +++++++++++++ .../fetch-workspace-bot-instructions.ts | 14 +++ .../(hooks)/use-workspace-bot-instructions.ts | 52 +++++++++++ .../[bot_id]/(partials)/bot-profile-modal.tsx | 2 +- .../bot-workspace-instructions-modal.tsx | 79 +++++++++++++++++ .../(partials)/conversation-header-menu.tsx | 18 +++- .../[bot_id]/(partials)/bot-workspace-row.tsx | 16 +++- .../(partials)/bot-workspaces-group.tsx | 38 +++++++- .../(partials)/workspace-bot-modal.tsx | 80 ----------------- .../(partials)/workspace-bots-group.tsx | 7 +- .../api/admin/workspaces/workspace-bot.ts | 57 ++++++++++++ src/types/index.ts | 9 ++ 14 files changed, 431 insertions(+), 87 deletions(-) create mode 100644 src/components/general/workspace-bot-form.tsx create mode 100644 src/components/general/workspace-bot-modal.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(functions)/fetch-workspace-bot-instructions.ts create mode 100644 src/pages/admin/bots/[bot_id]/(hooks)/use-workspace-bot-instructions.ts create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/bot-workspace-instructions-modal.tsx delete mode 100644 src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx create mode 100644 src/pages/api/admin/workspaces/workspace-bot.ts diff --git a/AGENTS.md b/AGENTS.md index 2e7bd01..5ee02d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,7 @@ Server push only. `pushBotNotice` (`server:bot-message`, `server:bot-updated`), ### Workspaces -`workspaces` (`description`, shared `system_prompt`, default `computer_id` (not an FK), `archived`), `workspace_bots` (per-bot instructions and memory there; coordinator is an implicit member), `workspace_computers`. First computer linked becomes the default. Workspace models win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` are access-checked; structural ones are crew-manager only. +`workspaces` (`description`, shared `system_prompt`, default `computer_id` (not an FK), `archived`), `workspace_bots` (per-bot instructions and memory there; coordinator is an implicit member), `workspace_computers`. First computer linked becomes the default. Workspace models win over a bot's own, then the defaults. A bot's instructions and memory there (`WorkspaceBotModal`/`WorkspaceBotForm`, crew managers) open from the workspace's settings, the bot's settings and the conversation menu's "Instructions here" (loaded on open). Helpers in `src/functions/backend/db/workspaces/` are access-checked; structural ones are crew-manager only. ### Computers diff --git a/src/components/general/workspace-bot-form.tsx b/src/components/general/workspace-bot-form.tsx new file mode 100644 index 0000000..9d69f93 --- /dev/null +++ b/src/components/general/workspace-bot-form.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import Form from "@moduletrace/twui/form/Form"; +import CodeEditor from "@/src/components/general/code-editor"; +import type { WorkspaceBot } from "@/src/types"; +import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; +import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions"; + +type Props = { + workspace_id: number; + bot_id: number; + bot_name?: string | null; + /** + * What the bot has for this workspace now. + */ + system_prompt?: string | null; + memory?: string | null; + onCancel: () => void; + /** + * After the save, with the workspace's crew as it is now. + */ + onSaved: (bots: WorkspaceBot[]) => void; +}; + +/** + * The form for what one bot has for a workspace alone: its instructions + * there, on top of its own, and its memory there + * (`api/admin/workspaces/update-bot`, crew managers). It asks first. + */ +export default function WorkspaceBotForm({ + workspace_id, + bot_id, + bot_name, + system_prompt: initial_prompt, + memory: initial_memory, + onCancel, + onSaved, +}: Props) { + const [system_prompt, setSystemPrompt] = useState(initial_prompt || ""); + const [memory, setMemory] = useState(initial_memory || ""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + const submit = async () => { + if (loading) return; + if (!window.confirm(`Save ${bot_name || "this bot"}'s instructions and memory here?`)) return; + + setLoading(true); + setError(undefined); + + const res = await postAdminApi({ + path: "workspaces/update-bot", + body: { workspace_id, bot_id, system_prompt, memory }, + }); + + setLoading(false); + + if (!res.success) { + setError(res.msg || "Couldn't save."); + return; + } + + onSaved(res.payload || []); + }; + + return ( +
+ setSystemPrompt(value)} + /> + setMemory(value)} + /> + + + ); +} diff --git a/src/components/general/workspace-bot-modal.tsx b/src/components/general/workspace-bot-modal.tsx new file mode 100644 index 0000000..505f168 --- /dev/null +++ b/src/components/general/workspace-bot-modal.tsx @@ -0,0 +1,58 @@ +import type { WorkspaceBot } from "@/src/types"; +import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell"; +import WorkspaceBotForm from "./workspace-bot-form"; + +type Props = { + workspace_id: number; + workspace_name?: string | null; + bot_id: number; + bot_name?: string | null; + /** + * What the bot has for this workspace now. + */ + system_prompt?: string | null; + memory?: string | null; + onClose: () => void; + onSaved: (bots: WorkspaceBot[]) => void; +}; + +/** + * What one bot has for this workspace alone: instructions on top of its + * own, and its memory here. The bot rewrites both as it learns; the + * coordinator can set the instructions too. Opened from the workspace's + * settings and from the bot's. + */ +export default function WorkspaceBotModal({ + workspace_id, + workspace_name, + bot_id, + bot_name, + system_prompt, + memory, + onClose, + onSaved, +}: Props) { + const name = bot_name || "This bot"; + + return ( + !open && onClose()} + title={`${name} in ${workspace_name || "this workspace"}`} + description={`${name} keeps its own instructions and memory everywhere. These apply only here, and it updates them itself as it learns how this workspace works.`} + > + { + onSaved(bots); + onClose(); + }} + /> + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(functions)/fetch-workspace-bot-instructions.ts b/src/pages/admin/bots/[bot_id]/(functions)/fetch-workspace-bot-instructions.ts new file mode 100644 index 0000000..06ea68c --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(functions)/fetch-workspace-bot-instructions.ts @@ -0,0 +1,14 @@ +import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import type { WorkspaceBotRequest } from "@/src/schemas/workspace-schema"; +import type { WorkspaceBotInstructions } from "@/src/types"; + +/** + * A bot's instructions and memory for one workspace, for its editor. + */ +export default async function fetchWorkspaceBotInstructions({ workspace_id, bot_id }: WorkspaceBotRequest) { + return await fetchApi>( + `/api/admin/workspaces/workspace-bot`, + { method: "POST", body: { workspace_id, bot_id } }, + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-workspace-bot-instructions.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-workspace-bot-instructions.ts new file mode 100644 index 0000000..ed27fc4 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-workspace-bot-instructions.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import type { WorkspaceBotInstructions } from "@/src/types"; +import fetchWorkspaceBotInstructions from "../(functions)/fetch-workspace-bot-instructions"; + +type Params = { + workspace_id: number; + bot_id: number; + /** + * Loads each time this turns true (the editor opens), so it starts from + * what was last saved, the bot's own edits included. + */ + enabled: boolean; +}; + +/** + * The bot's instructions and memory for this workspace, loaded when their + * editor opens: page props leave them out. + */ +export default function useWorkspaceBotInstructions({ workspace_id, bot_id, enabled }: Params) { + const [instructions, setInstructions] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + /** Cleared on close, so the next open never shows an old copy. */ + setInstructions(undefined); + setError(undefined); + + if (!enabled || !workspace_id || !bot_id) return; + + let cancelled = false; + + fetchWorkspaceBotInstructions({ workspace_id, bot_id }) + .then((res) => { + if (cancelled) return; + + if (res.success && res.singleRes) { + setInstructions(res.singleRes); + } else { + setError(res.msg || "Couldn't load the instructions."); + } + }) + .catch(() => { + if (!cancelled) setError("Couldn't load the instructions."); + }); + + return () => { + cancelled = true; + }; + }, [enabled, workspace_id, bot_id]); + + return { instructions, error }; +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-profile-modal.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-profile-modal.tsx index 22b47ea..6678b19 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-profile-modal.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-profile-modal.tsx @@ -70,7 +70,7 @@ export default function BotProfileModal({ bot }: Props) { {bot.kind == "coordinator" ? "The coordinator keeps its name. Give it a picture, or change its core instructions." - : `${name}'s core instructions apply everywhere. What it does in one workspace is set on that workspace's page.`} + : `${name}'s core instructions apply everywhere. What it does in one workspace is under Instructions here in the menu of its conversation there.`} {!open ? null : system_prompt != undefined ? ( diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-workspace-instructions-modal.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-workspace-instructions-modal.tsx new file mode 100644 index 0000000..b04b546 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-workspace-instructions-modal.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from "react"; +import Modal from "@moduletrace/twui/elements/Modal"; +import Loading from "@moduletrace/twui/elements/Loading"; +import H2 from "@moduletrace/twui/layout/H2"; +import Span from "@moduletrace/twui/layout/Span"; +import Stack from "@moduletrace/twui/layout/Stack"; +import { NotebookPen } from "lucide-react"; +import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; +import type { BotWithLastMessage } from "@/src/types"; +import WorkspaceBotForm from "@/src/components/general/workspace-bot-form"; +import useWorkspaceBotInstructions from "../(hooks)/use-workspace-bot-instructions"; +import HeaderMenuItem from "./header-menu-item"; + +type Props = { + bot: BotWithLastMessage; + workspace: BUN_SQLITE_COBBLE_WORKSPACES; + on_open?: () => void; +}; + +/** + * The header menu's row, in a workspace conversation, that opens what the + * bot has for this workspace alone: its instructions here and its memory + * here (crew managers). Both load when it opens, so it shows the bot's + * own latest edits. + */ +export default function BotWorkspaceInstructionsModal({ bot, workspace, on_open }: Props) { + const [open, setOpen] = useState(false); + const name = bot.name || "This bot"; + const place = workspace.name || "this workspace"; + const { instructions, error } = useWorkspaceBotInstructions({ + workspace_id: Number(workspace.id), + bot_id: Number(bot.id), + enabled: open, + }); + + useEffect(() => { + if (open) on_open?.(); + }, [open]); + + return ( + + } + targetWrapperProps={{ className: "w-full" }} + open={open} + setOpen={setOpen} + className="max-w-lg bg-paper p-6" + > + +

{`${name} in ${place}`}

+ + {`On top of ${name}'s core instructions, which apply everywhere. These apply only in ${place}, and ${name} updates them itself as it learns how this workspace works.`} + +
+ {!open ? null : instructions ? ( + setOpen(false)} + onSaved={() => setOpen(false)} + /> + ) : error ? ( + {error} + ) : ( + + + + )} +
+ ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx index e3d17f2..f491add 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx @@ -15,6 +15,7 @@ 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 BotWorkspaceInstructionsModal from "./bot-workspace-instructions-modal"; import HeaderMenuItem from "./header-menu-item"; import NewSessionButton from "./new-session-button"; import CopyChatMenuItem from "./copy-chat-menu-item"; @@ -56,7 +57,8 @@ type Props = { /** * The conversation header's menu: the sessions, a new session, copying - * the session, the bot's computer, its models, in a workspace making it the admin there, its + * the session, the bot's computer, its models, in a workspace its + * instructions there and 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. @@ -77,6 +79,13 @@ export default function ConversationHeaderMenu({ bot.kind == "coordinator" ? isPassThroughUser({ user_types: pageProps?.user_types }) : canManageCrew({ user_types: pageProps?.user_types }); + /** + * A bot's instructions for one workspace, only there, for crew + * managers (the coordinator's too: only the super admin sees it). + */ + const can_edit_here = Boolean( + workspace?.id && 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. @@ -161,6 +170,13 @@ export default function ConversationHeaderMenu({ on_open={on_open} onSaved={on_model_saved} /> + {can_edit_here && workspace?.id ? ( + + ) : null} {can_set_admin && workspace?.id ? ( void; onRemove?: () => void; }; /** * One workspace the bot works in. Its title opens the workspace's - * settings; a worker can be taken out from here. + * settings; its instructions and memory there are edited from here, and a + * worker can be taken out. */ export default function BotWorkspaceRow({ workspace, busy, + onEdit, onRemove, }: Props) { const name = workspace.name || `Workspace ${workspace.id}`; @@ -41,6 +47,14 @@ export default function BotWorkspaceRow({ : workspace.description || "Uses the workspace's context" } > + {onEdit ? ( + + ) : null} {onRemove ? ( (); const [status, setStatus] = useState(); + const [editing, setEditing] = useState(); const is_coordinator = bot.kind == "coordinator"; const addable = workspaces.filter( @@ -86,6 +88,27 @@ export default function BotWorkspacesGroup({ setStatus({ success: true, msg: res.msg || "Saved." }); }; + /** + * The save answers with the workspace's crew: this bot's row there + * carries what it has for the workspace now. + */ + const saved = ({ workspace_id, bots }: { workspace_id: number; bots: WorkspaceBot[] }) => { + const member = bots.find((item) => item.id == bot.id); + + setBotWorkspaces((prev) => + prev.map((workspace) => + Number(workspace.id) == workspace_id + ? { + ...workspace, + workspace_system_prompt: member?.workspace_system_prompt || null, + workspace_memory: member?.workspace_memory || null, + } + : workspace, + ), + ); + setStatus({ success: true, msg: "Saved." }); + }; + return ( setEditing(workspace)} onRemove={ is_coordinator ? undefined @@ -141,6 +165,18 @@ export default function BotWorkspacesGroup({ /> )} + {editing ? ( + setEditing(undefined)} + onSaved={(bots) => saved({ workspace_id: Number(editing.id), bots })} + /> + ) : null} ); } diff --git a/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx b/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx deleted file mode 100644 index a885d8d..0000000 --- a/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useState } from "react"; -import Form from "@moduletrace/twui/form/Form"; -import CodeEditor from "@/src/components/general/code-editor"; -import type { WorkspaceBot } from "@/src/types"; -import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; -import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions"; -import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell"; - -type Props = { - workspace_id: number; - workspace_name: string; - bot: WorkspaceBot; - onClose: () => void; - onSaved: (bots: WorkspaceBot[]) => void; -}; - -/** - * What one bot has for this workspace alone: instructions on top of its - * own, and its memory here. The bot rewrites both as it learns; the - * coordinator can set the instructions too. - */ -export default function WorkspaceBotModal({ workspace_id, workspace_name, bot, onClose, onSaved }: Props) { - const [system_prompt, setSystemPrompt] = useState(bot.workspace_system_prompt || ""); - const [memory, setMemory] = useState(bot.workspace_memory || ""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - const name = bot.name || "This bot"; - - const submit = async () => { - if (loading) return; - if (!window.confirm(`Save ${bot.name || "this bot"}'s instructions and memory here?`)) return; - - setLoading(true); - setError(undefined); - - const res = await postAdminApi({ - path: "workspaces/update-bot", - body: { workspace_id, bot_id: bot.id, system_prompt, memory }, - }); - - setLoading(false); - - if (!res.success) { - setError(res.msg || "Couldn't save."); - return; - } - - onSaved(res.payload || []); - onClose(); - }; - - return ( - !open && onClose()} - title={`${name} in ${workspace_name}`} - description={`${name} keeps its own instructions and memory everywhere. These apply only here, and it updates them itself as it learns how this workspace works.`} - > -
- setSystemPrompt(value)} - /> - setMemory(value)} - /> - - -
- ); -} diff --git a/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bots-group.tsx b/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bots-group.tsx index e89f81c..535cac3 100644 --- a/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bots-group.tsx +++ b/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bots-group.tsx @@ -9,7 +9,7 @@ import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group"; import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status"; import InlineAddSelect from "@/src/components/general/inline-add-select"; -import WorkspaceBotModal from "./workspace-bot-modal"; +import WorkspaceBotModal from "@/src/components/general/workspace-bot-modal"; import WorkspaceBotRow from "./workspace-bot-row"; type Props = { @@ -148,7 +148,10 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots: setEditing(undefined)} onSaved={setBots} /> diff --git a/src/pages/api/admin/workspaces/workspace-bot.ts b/src/pages/api/admin/workspaces/workspace-bot.ts new file mode 100644 index 0000000..45416aa --- /dev/null +++ b/src/pages/api/admin/workspaces/workspace-bot.ts @@ -0,0 +1,57 @@ +import userAuth from "@/src/functions/backend/auth/user-auth"; +import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots"; +import { WorkspaceBotRequestSchema } from "@/src/schemas/workspace-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 safeErrorMessage from "@/src/utils/safe-error-message"; +import type { WorkspaceBotInstructions } from "@/src/types"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; + +/** + * A bot's instructions and memory for one workspace, for the editor in its + * conversation there, which loads them when it opens (page props leave + * them out). For super admins and admins; only the super admin reaches + * the coordinator. + */ +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 (!canManageCrew({ user_types })) { + throw new AppError(`Only an admin can do that.`); + } + + const { workspace_id, bot_id } = parseWithSchema({ schema: WorkspaceBotRequestSchema, data: body }); + const bot = (await grabWorkspaceBots({ user_id: user.id, workspace_id })).find( + (member) => member.id == bot_id, + ); + + if (!bot?.id) { + throw new AppError(`That bot isn't in this workspace.`); + } + + return { + success: true, + singleRes: { + system_prompt: bot.workspace_system_prompt || "", + memory: bot.workspace_memory || "", + }, + }; + } catch (error: unknown) { + return { success: false, msg: safeErrorMessage({ error }) }; + } +}; diff --git a/src/types/index.ts b/src/types/index.ts index 4a10752..27ea589 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -206,6 +206,15 @@ export type WorkspaceBot = BotWithLastMessage & { workspace_memory?: string | null; }; +/** + * A bot's instructions and memory for one workspace, as its editor loads + * them (`api/admin/workspaces/workspace-bot`). + */ +export type WorkspaceBotInstructions = { + system_prompt: string; + memory: string; +}; + /** * A workspace a bot works in, with what the bot has for that workspace * alone.