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.