Add MCP servers and custom tools for bots
Two new settings pages, MCPs and Tools. Super admins define them; super
admins and admins attach them to bots, to workspaces (every bot there),
or to every bot. Bots get them in chat replies and tasks.
- Custom tools: a JSON-schema of arguments plus an HTTP request, a script
on the bot's computer, or a prompt, templated with {{args.x}} and
{{env.KEY}}, secrets redacted from results.
- MCP servers: remote (URL and headers) or stdio commands run on the host
with a setup command and an isolated env, in a per-process pool. Tools
are listed into a cache and connect on first call.
- Add, edit, access and try open in modals.
- Markdown, JSON and command fields use twui's AceEditor (CodeEditor),
its scripts loaded on demand.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { ExtensionAccess, ExtensionTargets } from "@/src/types";
|
||||
|
||||
type Props<T> = {
|
||||
/**
|
||||
* The MCP server's or tool's id and name.
|
||||
*/
|
||||
id: number;
|
||||
name: string;
|
||||
access: ExtensionAccess;
|
||||
targets: ExtensionTargets;
|
||||
/**
|
||||
* Under `/api/admin/`.
|
||||
*/
|
||||
path: "mcps/set-mcp-access" | "tools/set-tool-access";
|
||||
onSaved: (overview: T, msg: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Who gets an MCP server or tool: every bot (super admins only), or the
|
||||
* bots and workspaces picked here. A workspace gives it to every bot in
|
||||
* it, there.
|
||||
*/
|
||||
export default function ExtensionAccessModal<T extends { [k: string]: any }>({
|
||||
id,
|
||||
name,
|
||||
access,
|
||||
targets,
|
||||
path,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: Props<T>) {
|
||||
const [all_bots, setAllBots] = useState(access.all_bots);
|
||||
const [bot_ids, setBotIds] = useState(access.bot_ids);
|
||||
const [workspace_ids, setWorkspaceIds] = useState(access.workspace_ids);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const submit = async () => {
|
||||
if (loading || !window.confirm(`Save who gets ${name}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<T>({
|
||||
path,
|
||||
body: { id, bot_ids, workspace_ids, all_bots: targets.can_edit ? all_bots : undefined },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save who gets it.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(res.singleRes, res.msg || "Saved.");
|
||||
};
|
||||
|
||||
const ids = (value: unknown) => (Array.isArray(value) ? value : value ? [value] : []).map(Number);
|
||||
|
||||
return (
|
||||
<AddModalShell
|
||||
open
|
||||
setOpen={(next) => !next && onCancel()}
|
||||
title={`Who gets ${name}`}
|
||||
description={"A bot gets it everywhere it works; a workspace gives it to every bot in it, there."}
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
{targets.can_edit ? (
|
||||
<Checkbox
|
||||
title="Every bot"
|
||||
label="Every bot, the coordinator included"
|
||||
checked={all_bots}
|
||||
changeHandler={setAllBots}
|
||||
/>
|
||||
) : access.all_bots ? (
|
||||
<Checkbox title="Every bot" label="Every bot (only a super admin changes this)" checked readOnly />
|
||||
) : null}
|
||||
{all_bots ? null : (
|
||||
<>
|
||||
<SearchSelect
|
||||
id={`${path}-${id}-bots`}
|
||||
title="Bots"
|
||||
label="Bots"
|
||||
showLabel
|
||||
multiple
|
||||
placeholder="Pick the bots that get it"
|
||||
options={targets.bots.map((bot) => ({
|
||||
value: String(bot.id),
|
||||
title: bot.role ? `${bot.name} (${bot.role})` : bot.name,
|
||||
}))}
|
||||
defaultValues={bot_ids.map(String)}
|
||||
info="They get it everywhere they work."
|
||||
changeHandler={(value) => setBotIds(ids(value))}
|
||||
/>
|
||||
<SearchSelect
|
||||
id={`${path}-${id}-workspaces`}
|
||||
title="Workspaces"
|
||||
label="Workspaces"
|
||||
showLabel
|
||||
multiple
|
||||
placeholder="Pick the workspaces that get it"
|
||||
options={targets.workspaces.map((workspace) => ({
|
||||
value: String(workspace.id),
|
||||
title: workspace.name,
|
||||
}))}
|
||||
defaultValues={workspace_ids.map(String)}
|
||||
info="Every bot in these gets it, there."
|
||||
changeHandler={(value) => setWorkspaceIds(ids(value))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onCancel} />
|
||||
</Form>
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ExtensionAccess } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
access: ExtensionAccess;
|
||||
};
|
||||
|
||||
/**
|
||||
* Who gets an MCP server or tool, in a few words: "Every bot",
|
||||
* "2 bots · 1 workspace", or "No bots yet".
|
||||
*/
|
||||
export default function extensionAccessLabel({ access }: Params) {
|
||||
if (access.all_bots) return "Every bot";
|
||||
|
||||
const parts = [
|
||||
access.bot_ids.length ? `${access.bot_ids.length} ${access.bot_ids.length == 1 ? "bot" : "bots"}` : "",
|
||||
access.workspace_ids.length
|
||||
? `${access.workspace_ids.length} ${access.workspace_ids.length == 1 ? "workspace" : "workspaces"}`
|
||||
: "",
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join(" · ") || "No bots yet";
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useState } from "react";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { McpTransports, type McpTransport } from "@/src/dict/mcp-dict";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { McpServersOverviewPayload, PublicMcpServer } from "@/src/types";
|
||||
import parseTemplateLines, { templateLines } from "@/src/utils/parse-template-lines";
|
||||
import McpToolToggle from "./mcp-tool-toggle";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Edits this server; adds one when not given.
|
||||
*/
|
||||
server?: PublicMcpServer;
|
||||
busy?: boolean;
|
||||
/**
|
||||
* Runs a stdio server's setup again.
|
||||
*/
|
||||
onReinstall?: () => void;
|
||||
onSaved: (overview: McpServersOverviewPayload, msg: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits an MCP server (super admins): a remote one by URL and
|
||||
* headers, or a stdio one by the command that starts it on this server,
|
||||
* the setup that installs what it needs, and the env it gets. Values can
|
||||
* use `{{env.KEY}}`. Saving connects and lists its tools; each can be
|
||||
* left out.
|
||||
*/
|
||||
export default function McpServerModal({ server, busy, onReinstall, onSaved, onCancel }: Props) {
|
||||
const [name, setName] = useState(server?.name || "");
|
||||
const [description, setDescription] = useState(server?.description || "");
|
||||
const [transport, setTransport] = useState<McpTransport>(server?.transport || "remote");
|
||||
const [url, setUrl] = useState(server?.url || "");
|
||||
const [headers, setHeaders] = useState(templateLines({ map: server?.headers, separator: ":" }));
|
||||
const [command, setCommand] = useState(server?.command || "");
|
||||
const [setup_command, setSetupCommand] = useState(server?.setup_command || "");
|
||||
const [env, setEnv] = useState(templateLines({ map: server?.env, separator: "=" }));
|
||||
const [excluded, setExcluded] = useState(server?.excluded_tools || []);
|
||||
const [enabled, setEnabled] = useState(server?.enabled ?? true);
|
||||
const [all_bots, setAllBots] = useState(server?.access.all_bots ?? false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const id = `mcp-${server?.id || "new"}`;
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
if (!window.confirm(server ? `Save ${server.name}?` : `Add ${name || "this MCP server"}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<McpServersOverviewPayload>({
|
||||
path: "mcps/save-mcp",
|
||||
body: {
|
||||
mcp_server_id: server?.id,
|
||||
name,
|
||||
description,
|
||||
transport,
|
||||
url,
|
||||
headers: parseTemplateLines({ text: headers, separator: ":" }),
|
||||
command,
|
||||
setup_command,
|
||||
env: parseTemplateLines({ text: env, separator: "=" }),
|
||||
excluded_tools: excluded,
|
||||
enabled,
|
||||
all_bots,
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the MCP server.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(res.singleRes, res.msg || "Saved.");
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModalShell
|
||||
open
|
||||
setOpen={(next) => !next && onCancel()}
|
||||
title={server ? `Edit ${server.name}` : "Add an MCP server"}
|
||||
description={"Bots get its tools once it's attached to them. Saving connects and lists them."}
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Input
|
||||
id={`${id}-name`}
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
placeholder="GitHub"
|
||||
defaultValue={name}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}-description`}
|
||||
title="Description"
|
||||
label="Description"
|
||||
showLabel
|
||||
placeholder="Issues and pull requests in the company's repositories"
|
||||
defaultValue={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
id={`${id}-transport`}
|
||||
title="How to reach it"
|
||||
label="How to reach it"
|
||||
showLabel
|
||||
options={McpTransports.map((t) => ({ value: t.value, title: t.title, default: t.value == transport }))}
|
||||
changeHandler={(value) => setTransport(String(value) as McpTransport)}
|
||||
/>
|
||||
{transport == "remote" ? (
|
||||
<>
|
||||
<Input
|
||||
id={`${id}-url`}
|
||||
title="URL"
|
||||
label="URL"
|
||||
showLabel
|
||||
placeholder="https://mcp.example.com/mcp"
|
||||
defaultValue={url}
|
||||
autoComplete="off"
|
||||
info="Streamable HTTP, or SSE for older servers."
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<CodeEditor
|
||||
mode="properties"
|
||||
className="h-24"
|
||||
title="Headers"
|
||||
placeholder={"Authorization: Bearer {{env.GITHUB_TOKEN}}"}
|
||||
content={headers}
|
||||
info="One per line. Put secrets in Settings, under Environment, and use them as {{env.KEY}}."
|
||||
onChange={(value) => setHeaders(value)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CodeEditor
|
||||
title="Command"
|
||||
mode="sh"
|
||||
className="h-24"
|
||||
placeholder="bunx -y @modelcontextprotocol/server-github"
|
||||
content={command}
|
||||
info="Runs on this server with its shell, in the server's own folder, as the app's user. Only add commands you trust."
|
||||
onChange={setCommand}
|
||||
/>
|
||||
<CodeEditor
|
||||
title="Setup command"
|
||||
mode="sh"
|
||||
className="h-32"
|
||||
placeholder="bun add @modelcontextprotocol/server-github"
|
||||
content={setup_command}
|
||||
info="Installs what the command needs, in the same folder (kept across updates). Runs before the first start and whenever it changes. Prefer installs into the folder: system packages go when the app is updated."
|
||||
onChange={setSetupCommand}
|
||||
/>
|
||||
<CodeEditor
|
||||
mode="properties"
|
||||
className="h-24"
|
||||
title="Env"
|
||||
placeholder={"GITHUB_PERSONAL_ACCESS_TOKEN={{env.GITHUB_TOKEN}}"}
|
||||
content={env}
|
||||
info="One KEY=value per line: the only envs it gets, besides PATH and HOME."
|
||||
onChange={(value) => setEnv(value)}
|
||||
/>
|
||||
{server?.id && onReinstall ? (
|
||||
<Button
|
||||
title="Run the setup command again"
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
className="self-start"
|
||||
disabled={busy || loading}
|
||||
onClick={onReinstall}
|
||||
>
|
||||
Reinstall
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{server?.status == "error" && server.last_error ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words">
|
||||
{server.last_error}
|
||||
</Span>
|
||||
) : null}
|
||||
{server?.tools.length ? (
|
||||
<Stack className="gap-2! items-stretch">
|
||||
<Span className="text-[13px] font-bold leading-5 text-ink">Tools bots get</Span>
|
||||
{server.tools.map((tool) => (
|
||||
<McpToolToggle
|
||||
key={tool.name}
|
||||
tool={tool}
|
||||
on={!excluded.includes(tool.name)}
|
||||
onChange={(on) =>
|
||||
setExcluded((prev) =>
|
||||
on ? prev.filter((name) => name != tool.name) : [...prev, tool.name],
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
<Checkbox title="On" label="On: bots get its tools" checked={enabled} changeHandler={setEnabled} />
|
||||
<Checkbox
|
||||
title="Every bot"
|
||||
label="Every bot, the coordinator included"
|
||||
checked={all_bots}
|
||||
changeHandler={setAllBots}
|
||||
/>
|
||||
<AddFormActions
|
||||
submit_label={server ? "Save and connect" : "Add and connect"}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Form>
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Plug } from "lucide-react";
|
||||
import IconTile from "@/src/components/general/icon-tile";
|
||||
import { McpTransports } from "@/src/dict/mcp-dict";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { ExtensionTargets, McpServersOverviewPayload, PublicMcpServer } from "@/src/types";
|
||||
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
|
||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||
import extensionAccessLabel from "../../(utils)/extension-access-label";
|
||||
import McpServerModal from "./mcp-server-modal";
|
||||
|
||||
type Props = {
|
||||
server: PublicMcpServer;
|
||||
targets: ExtensionTargets;
|
||||
onChanged: (overview: McpServersOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One MCP server: how it's reached, whether it connected, and who gets
|
||||
* it. Edit and Access open in a modal.
|
||||
*/
|
||||
export default function McpServerRow({ server, targets, onChanged, onError }: Props) {
|
||||
const [open, setOpen] = useState<"edit" | "access">();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const post = async ({ path, body, confirm }: { path: string; body: Record<string, unknown>; confirm?: string }) => {
|
||||
if (busy || (confirm && !window.confirm(confirm))) return;
|
||||
|
||||
setBusy(true);
|
||||
const res = await postAdminApi<McpServersOverviewPayload>({ path, body });
|
||||
setBusy(false);
|
||||
|
||||
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Done.");
|
||||
else onError(res.msg || "That didn't work.");
|
||||
};
|
||||
|
||||
const saved = (overview: McpServersOverviewPayload, msg: string) => {
|
||||
setOpen(undefined);
|
||||
onChanged(overview, msg);
|
||||
};
|
||||
|
||||
const status =
|
||||
server.status == "ok"
|
||||
? `${server.tools.length} ${server.tools.length == 1 ? "tool" : "tools"}`
|
||||
: server.status == "error"
|
||||
? `Error: ${server.last_error}`
|
||||
: "Not connected yet";
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<SettingsListRow
|
||||
avatar={<IconTile Icon={Plug} />}
|
||||
title={server.name}
|
||||
badge={server.enabled ? undefined : "Off"}
|
||||
subtitle={[
|
||||
McpTransports.find((t) => t.value == server.transport)?.title,
|
||||
status,
|
||||
extensionAccessLabel({ access: server.access }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
>
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction
|
||||
title={`Edit ${server.name}`}
|
||||
label="Edit"
|
||||
disabled={busy}
|
||||
onClick={() => setOpen(open == "edit" ? undefined : "edit")}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsRowAction
|
||||
title={`Choose who gets ${server.name}`}
|
||||
label="Access"
|
||||
disabled={busy}
|
||||
onClick={() => setOpen(open == "access" ? undefined : "access")}
|
||||
/>
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction
|
||||
title={`List ${server.name}'s tools again`}
|
||||
label={busy ? "Connecting…" : "Refresh"}
|
||||
disabled={busy || !server.enabled}
|
||||
onClick={() => post({ path: "mcps/sync-mcp", body: { mcp_server_id: server.id } })}
|
||||
/>
|
||||
) : null}
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction
|
||||
title={`Delete ${server.name}`}
|
||||
label="Delete"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
post({
|
||||
path: "mcps/delete-mcp",
|
||||
body: { mcp_server_id: server.id },
|
||||
confirm: `Delete ${server.name}? Bots lose its tools at once.`,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
{open == "edit" ? (
|
||||
<McpServerModal
|
||||
server={server}
|
||||
busy={busy}
|
||||
onReinstall={() =>
|
||||
post({
|
||||
path: "mcps/sync-mcp",
|
||||
body: { mcp_server_id: server.id, reinstall: true },
|
||||
confirm: `Run ${server.name}'s setup command again?`,
|
||||
})
|
||||
}
|
||||
onSaved={saved}
|
||||
onCancel={() => setOpen(undefined)}
|
||||
/>
|
||||
) : null}
|
||||
{open == "access" ? (
|
||||
<ExtensionAccessModal<McpServersOverviewPayload>
|
||||
id={server.id}
|
||||
name={server.name}
|
||||
access={server.access}
|
||||
targets={targets}
|
||||
path="mcps/set-mcp-access"
|
||||
onSaved={saved}
|
||||
onCancel={() => setOpen(undefined)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import type { McpToolInfo } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
tool: McpToolInfo;
|
||||
on: boolean;
|
||||
onChange: (on: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One of a server's tools, ticked when bots get it.
|
||||
*/
|
||||
export default function McpToolToggle({ tool, on, onChange }: Props) {
|
||||
return (
|
||||
<Checkbox
|
||||
title={tool.description}
|
||||
label={`${tool.name}: ${tool.description.split("\n")[0]?.slice(0, 120) || ""}`}
|
||||
checked={on}
|
||||
changeHandler={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { McpServersOverviewPayload } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsList from "../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import McpServerModal from "../(partials)/mcp-server-modal";
|
||||
import McpServerRow from "../(partials)/mcp-server-row";
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* Every MCP server with who gets it. Super admins add, edit, refresh and
|
||||
* delete them; admins choose which bots and workspaces get each one.
|
||||
*/
|
||||
export default function McpsSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [overview, setOverview] = useState(pageProps?.mcps_overview);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
/**
|
||||
* Remounts the rows when the data changes under them.
|
||||
*/
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
if (!overview) return null;
|
||||
|
||||
const changed = (next: McpServersOverviewPayload, msg: string) => {
|
||||
setOverview(next);
|
||||
setStatus({ success: true, msg });
|
||||
setVersion((prev) => prev + 1);
|
||||
};
|
||||
const failed = (msg: string) => setStatus({ error: true, msg });
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="MCP servers"
|
||||
description="A server's tools go to the bots and workspaces it's attached to, or to every bot. Bots only connect when they call one."
|
||||
>
|
||||
{adding ? (
|
||||
<McpServerModal
|
||||
onSaved={(next, msg) => {
|
||||
setAdding(false);
|
||||
changed(next, msg);
|
||||
}}
|
||||
onCancel={() => setAdding(false)}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsList is_empty={!overview.servers.length} empty="No MCP servers yet.">
|
||||
{overview.servers.map((server) => (
|
||||
<McpServerRow
|
||||
key={`${server.id}-${version}`}
|
||||
server={server}
|
||||
targets={overview}
|
||||
onChanged={changed}
|
||||
onError={failed}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
{overview.can_edit && !adding ? (
|
||||
<Button
|
||||
title="Add an MCP server"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setAdding(true)}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New MCP server
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
|
||||
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||
|
||||
/**
|
||||
* Super admins and admins: admins only choose who gets each server.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabCrewSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
mcps_overview: await grabMcpsOverview({ user_id: settings.props.person.id }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import McpsSettingsSection from "./(sections)/mcps-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsMcpsPage() {
|
||||
return (
|
||||
<SettingsShell title="MCPs" description="MCP servers whose tools your bots can use.">
|
||||
<McpsSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "MCPs" });
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CustomToolHttpConfig, CustomToolPromptConfig, CustomToolScriptConfig } from "@/src/types";
|
||||
|
||||
/**
|
||||
* What a new tool's arguments schema starts as.
|
||||
*/
|
||||
export const DefaultToolParameters = JSON.stringify(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string", description: "The city to look up" },
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
/**
|
||||
* What a tool's config starts as for each kind.
|
||||
*/
|
||||
export const DefaultToolConfigs: {
|
||||
http: CustomToolHttpConfig;
|
||||
script: CustomToolScriptConfig;
|
||||
prompt: CustomToolPromptConfig;
|
||||
} = {
|
||||
http: { method: "GET", url: "", headers: {}, body: "" },
|
||||
script: {
|
||||
language: "python",
|
||||
code: `import json, os\n\nargs = json.loads(os.environ["COBBLE_ARGS"])\nprint(f"Hello from {args.get('city')}")\n`,
|
||||
},
|
||||
prompt: { prompt: "" },
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { CustomToolKind } from "@/src/dict/custom-tool-dict";
|
||||
import type { CustomToolHttpConfig, CustomToolPromptConfig, CustomToolScriptConfig } from "@/src/types";
|
||||
import HttpToolFields from "./http-tool-fields";
|
||||
import PromptToolFields from "./prompt-tool-fields";
|
||||
import ScriptToolFields from "./script-tool-fields";
|
||||
|
||||
export type ToolConfigs = {
|
||||
http: CustomToolHttpConfig;
|
||||
script: CustomToolScriptConfig;
|
||||
prompt: CustomToolPromptConfig;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
kind: CustomToolKind;
|
||||
configs: ToolConfigs;
|
||||
onChange: (configs: ToolConfigs) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The fields of the tool's kind. Each kind keeps its own config, so
|
||||
* switching kinds back and forth loses nothing.
|
||||
*/
|
||||
export default function CustomToolKindFields({ id, kind, configs, onChange }: Props) {
|
||||
switch (kind) {
|
||||
case "http":
|
||||
return <HttpToolFields id={id} config={configs.http} onChange={(http) => onChange({ ...configs, http })} />;
|
||||
case "script":
|
||||
return (
|
||||
<ScriptToolFields id={id} config={configs.script} onChange={(script) => onChange({ ...configs, script })} />
|
||||
);
|
||||
case "prompt":
|
||||
return (
|
||||
<PromptToolFields id={id} config={configs.prompt} onChange={(prompt) => onChange({ ...configs, prompt })} />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from "react";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import { CustomToolKinds, type CustomToolKind } from "@/src/dict/custom-tool-dict";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { CustomToolsOverviewPayload, PublicCustomTool } from "@/src/types";
|
||||
import { DefaultToolConfigs, DefaultToolParameters } from "../(data)/tool-defaults";
|
||||
import CustomToolKindFields, { type ToolConfigs } from "./custom-tool-kind-fields";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Edits this tool; adds one when not given.
|
||||
*/
|
||||
tool?: PublicCustomTool;
|
||||
onSaved: (overview: CustomToolsOverviewPayload, msg: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits a custom tool (super admins): the name and description
|
||||
* the model reads, its arguments as a JSON schema, and what it does (an
|
||||
* HTTP request, a script, or a prompt).
|
||||
*/
|
||||
export default function CustomToolModal({ tool, onSaved, onCancel }: Props) {
|
||||
const [name, setName] = useState(tool?.name || "");
|
||||
const [description, setDescription] = useState(tool?.description || "");
|
||||
const [kind, setKind] = useState<CustomToolKind>(tool?.kind || "http");
|
||||
const [parameters, setParameters] = useState(
|
||||
tool?.parameters ? JSON.stringify(tool.parameters, null, 2) : DefaultToolParameters,
|
||||
);
|
||||
const [configs, setConfigs] = useState<ToolConfigs>({
|
||||
...DefaultToolConfigs,
|
||||
...(tool?.config ? { [tool.kind]: tool.config } : {}),
|
||||
});
|
||||
const [enabled, setEnabled] = useState(tool?.enabled ?? true);
|
||||
const [all_bots, setAllBots] = useState(tool?.access.all_bots ?? false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const id = `tool-${tool?.id || "new"}`;
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(parameters);
|
||||
} catch {
|
||||
setError("The arguments schema isn't valid JSON.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(tool ? `Save ${tool.name}?` : `Add ${name || "this tool"}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<CustomToolsOverviewPayload>({
|
||||
path: "tools/save-tool",
|
||||
body: {
|
||||
custom_tool_id: tool?.id,
|
||||
name,
|
||||
description,
|
||||
kind,
|
||||
parameters: parsed,
|
||||
config: configs[kind],
|
||||
enabled,
|
||||
all_bots,
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the tool.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(res.singleRes, res.msg || "Saved.");
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModalShell
|
||||
open
|
||||
setOpen={(next) => !next && onCancel()}
|
||||
title={tool ? `Edit ${tool.name}` : "Add a tool"}
|
||||
description={"Bots get it once it's attached to them."}
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Input
|
||||
id={`${id}-name`}
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
placeholder="get_weather"
|
||||
defaultValue={name}
|
||||
autoComplete="off"
|
||||
info="What bots call it: lowercase letters, numbers and underscores."
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id={`${id}-description`}
|
||||
title="Description"
|
||||
label="Description"
|
||||
showLabel
|
||||
rows={3}
|
||||
placeholder="Gets the current weather for a city. Use it when the user asks about the weather."
|
||||
defaultValue={description}
|
||||
info="Bots read this to decide when to call it, so say what it does and when to use it."
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<CodeEditor
|
||||
mode="json"
|
||||
className="h-52"
|
||||
title="Arguments"
|
||||
content={parameters}
|
||||
info='An object schema. Describe each argument: bots fill them in from it. {"type": "object", "properties": {}} takes none.'
|
||||
onChange={(value) => setParameters(value)}
|
||||
/>
|
||||
<Select
|
||||
id={`${id}-kind`}
|
||||
title="What it does"
|
||||
label="What it does"
|
||||
showLabel
|
||||
options={CustomToolKinds.map((k) => ({ value: k.value, title: k.title, default: k.value == kind }))}
|
||||
changeHandler={(value) => setKind(String(value) as CustomToolKind)}
|
||||
/>
|
||||
<CustomToolKindFields key={kind} id={id} kind={kind} configs={configs} onChange={setConfigs} />
|
||||
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
|
||||
<Checkbox
|
||||
title="Every bot"
|
||||
label="Every bot, the coordinator included"
|
||||
checked={all_bots}
|
||||
changeHandler={setAllBots}
|
||||
/>
|
||||
<AddFormActions submit_label={tool ? "Save" : "Add tool"} loading={loading} error={error} onCancel={onCancel} />
|
||||
</Form>
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Wrench } from "lucide-react";
|
||||
import IconTile from "@/src/components/general/icon-tile";
|
||||
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { CustomToolsOverviewPayload, ExtensionTargets, PublicCustomTool } from "@/src/types";
|
||||
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
|
||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||
import extensionAccessLabel from "../../(utils)/extension-access-label";
|
||||
import CustomToolModal from "./custom-tool-modal";
|
||||
import CustomToolTryModal from "./custom-tool-try-modal";
|
||||
|
||||
type Props = {
|
||||
tool: PublicCustomTool;
|
||||
targets: ExtensionTargets;
|
||||
onChanged: (overview: CustomToolsOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One custom tool: its name, kind and who gets it. Edit, Access and Try
|
||||
* open in a modal.
|
||||
*/
|
||||
export default function CustomToolRow({ tool, targets, onChanged, onError }: Props) {
|
||||
const [open, setOpen] = useState<"edit" | "access" | "try">();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const toggle = (panel: "edit" | "access" | "try") => setOpen(open == panel ? undefined : panel);
|
||||
|
||||
const saved = (overview: CustomToolsOverviewPayload, msg: string) => {
|
||||
setOpen(undefined);
|
||||
onChanged(overview, msg);
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (busy || !window.confirm(`Delete ${tool.name}? Bots lose it at once.`)) return;
|
||||
|
||||
setBusy(true);
|
||||
const res = await postAdminApi<CustomToolsOverviewPayload>({
|
||||
path: "tools/delete-tool",
|
||||
body: { custom_tool_id: tool.id },
|
||||
});
|
||||
setBusy(false);
|
||||
|
||||
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Deleted.");
|
||||
else onError(res.msg || "Couldn't delete the tool.");
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<SettingsListRow
|
||||
avatar={<IconTile Icon={Wrench} />}
|
||||
title={tool.name}
|
||||
badge={tool.enabled ? undefined : "Off"}
|
||||
subtitle={[
|
||||
CustomToolKinds.find((k) => k.value == tool.kind)?.title,
|
||||
extensionAccessLabel({ access: tool.access }),
|
||||
tool.description,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
>
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction title={`Edit ${tool.name}`} label="Edit" disabled={busy} onClick={() => toggle("edit")} />
|
||||
) : null}
|
||||
<SettingsRowAction
|
||||
title={`Choose who gets ${tool.name}`}
|
||||
label="Access"
|
||||
disabled={busy}
|
||||
onClick={() => toggle("access")}
|
||||
/>
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction title={`Try ${tool.name}`} label="Try" disabled={busy} onClick={() => toggle("try")} />
|
||||
) : null}
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction title={`Delete ${tool.name}`} label="Delete" disabled={busy} onClick={remove} />
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
{open == "edit" ? (
|
||||
<CustomToolModal tool={tool} onSaved={saved} onCancel={() => setOpen(undefined)} />
|
||||
) : null}
|
||||
{open == "access" ? (
|
||||
<ExtensionAccessModal<CustomToolsOverviewPayload>
|
||||
id={tool.id}
|
||||
name={tool.name}
|
||||
access={tool.access}
|
||||
targets={targets}
|
||||
path="tools/set-tool-access"
|
||||
onSaved={saved}
|
||||
onCancel={() => setOpen(undefined)}
|
||||
/>
|
||||
) : null}
|
||||
{open == "try" ? <CustomToolTryModal tool={tool} onClose={() => setOpen(undefined)} /> : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { CustomToolTestPayload, PublicCustomTool } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
tool: PublicCustomTool;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs a saved tool once with sample arguments and shows what a bot would
|
||||
* read. A script runs on the main computer, with general envs.
|
||||
*/
|
||||
export default function CustomToolTryModal({ tool, onClose }: Props) {
|
||||
const [args, setArgs] = useState("{}");
|
||||
const [output, setOutput] = useState<string>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(args || "{}");
|
||||
} catch {
|
||||
setError("The arguments aren't valid JSON.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<CustomToolTestPayload>({
|
||||
path: "tools/test-tool",
|
||||
body: { custom_tool_id: tool.id, args: parsed },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't run the tool.");
|
||||
return;
|
||||
}
|
||||
|
||||
setOutput(res.singleRes.output);
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModalShell
|
||||
open
|
||||
setOpen={(next) => !next && onClose()}
|
||||
title={`Try ${tool.name}`}
|
||||
description={"Runs it once and shows what a bot would read. A script runs on the main computer, with general envs."}
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<CodeEditor
|
||||
mode="json"
|
||||
className="h-28"
|
||||
title="Arguments"
|
||||
content={args}
|
||||
info="Saved changes only: save the tool first."
|
||||
onChange={(value) => setArgs(value)}
|
||||
/>
|
||||
{output !== undefined ? (
|
||||
<Span className="block font-mono text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words max-h-96 overflow-auto rounded-default-sm border border-solid border-hairline bg-paper p-3">
|
||||
{output}
|
||||
</Span>
|
||||
) : null}
|
||||
<AddFormActions submit_label="Run it" loading={loading} error={error} onCancel={onClose} />
|
||||
</Form>
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import { CustomToolHttpMethods, type CustomToolHttpMethod } from "@/src/dict/custom-tool-dict";
|
||||
import type { CustomToolHttpConfig } from "@/src/types";
|
||||
import parseTemplateLines, { templateLines } from "@/src/utils/parse-template-lines";
|
||||
import TimeoutField from "./timeout-field";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
config: CustomToolHttpConfig;
|
||||
onChange: (config: CustomToolHttpConfig) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* An HTTP tool's request: method, URL, headers and body, all templates.
|
||||
*/
|
||||
export default function HttpToolFields({ id, config, onChange }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
id={`${id}-method`}
|
||||
title="Method"
|
||||
label="Method"
|
||||
showLabel
|
||||
options={CustomToolHttpMethods.map((method) => ({
|
||||
value: method,
|
||||
title: method,
|
||||
default: method == config.method,
|
||||
}))}
|
||||
changeHandler={(value) => onChange({ ...config, method: String(value) as CustomToolHttpMethod })}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}-url`}
|
||||
title="URL"
|
||||
label="URL"
|
||||
showLabel
|
||||
placeholder="https://api.example.com/weather?city={{args.city}}"
|
||||
defaultValue={config.url}
|
||||
autoComplete="off"
|
||||
info="{{args.name}} fills in an argument (URL-encoded), {{env.KEY}} an env."
|
||||
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||
/>
|
||||
<CodeEditor
|
||||
mode="properties"
|
||||
className="h-24"
|
||||
title="Headers"
|
||||
placeholder={"Authorization: Bearer {{env.WEATHER_KEY}}"}
|
||||
content={templateLines({ map: config.headers, separator: ":" })}
|
||||
info="One per line. Put secrets in Settings, under Environment: bots never see them."
|
||||
onChange={(value) =>
|
||||
onChange({ ...config, headers: parseTemplateLines({ text: value, separator: ":" }) })
|
||||
}
|
||||
/>
|
||||
{config.method == "GET" ? null : (
|
||||
<CodeEditor
|
||||
mode="json"
|
||||
className="h-28"
|
||||
title="Body"
|
||||
placeholder={'{"city": "{{args.city}}"}'}
|
||||
content={config.body || ""}
|
||||
info="Empty sends the arguments as JSON."
|
||||
onChange={(value) => onChange({ ...config, body: value })}
|
||||
/>
|
||||
)}
|
||||
<TimeoutField id={id} timeout_ms={config.timeout_ms} onChange={(timeout_ms) => onChange({ ...config, timeout_ms })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import type { CustomToolPromptConfig } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
config: CustomToolPromptConfig;
|
||||
onChange: (config: CustomToolPromptConfig) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A prompt tool's prompt, answered by the bot's text model.
|
||||
*/
|
||||
export default function PromptToolFields({ id, config, onChange }: Props) {
|
||||
return (
|
||||
<CodeEditor
|
||||
mode="markdown"
|
||||
className="h-52"
|
||||
title="Prompt"
|
||||
placeholder={"Summarise this contract for a busy founder. List the risks first.\n\n{{args.text}}"}
|
||||
content={config.prompt}
|
||||
info="{{args.name}} fills in an argument. The bot's text model answers it in one turn, without tools."
|
||||
onChange={(value) => onChange({ ...config, prompt: value })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import { CustomToolScriptLanguages, type CustomToolScriptLanguage } from "@/src/dict/custom-tool-dict";
|
||||
import type { CustomToolScriptConfig } from "@/src/types";
|
||||
import TimeoutField from "./timeout-field";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
config: CustomToolScriptConfig;
|
||||
onChange: (config: CustomToolScriptConfig) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A script tool's code and language. It runs on the bot's computer.
|
||||
*/
|
||||
export default function ScriptToolFields({ id, config, onChange }: Props) {
|
||||
const language =
|
||||
CustomToolScriptLanguages.find((l) => l.value == config.language) || CustomToolScriptLanguages[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
id={`${id}-language`}
|
||||
title="Language"
|
||||
label="Language"
|
||||
showLabel
|
||||
options={CustomToolScriptLanguages.map((language) => ({
|
||||
value: language.value,
|
||||
title: language.title,
|
||||
default: language.value == config.language,
|
||||
}))}
|
||||
changeHandler={(value) => onChange({ ...config, language: String(value) as CustomToolScriptLanguage })}
|
||||
/>
|
||||
<CodeEditor
|
||||
title="Code"
|
||||
mode={language.editor_mode}
|
||||
className="h-72"
|
||||
content={config.code}
|
||||
info="Runs on the bot's current computer (the task's in a task) with its envs set. The arguments come as JSON in $COBBLE_ARGS and on stdin; what it prints goes back to the bot."
|
||||
onChange={(code) => onChange({ ...config, code })}
|
||||
/>
|
||||
<TimeoutField id={id} timeout_ms={config.timeout_ms} onChange={(timeout_ms) => onChange({ ...config, timeout_ms })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
timeout_ms?: number;
|
||||
onChange: (timeout_ms?: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* How long a tool may run, in seconds. Empty uses the default.
|
||||
*/
|
||||
export default function TimeoutField({ id, timeout_ms, onChange }: Props) {
|
||||
return (
|
||||
<Input
|
||||
id={`${id}-timeout`}
|
||||
title="Timeout (seconds)"
|
||||
label="Timeout (seconds)"
|
||||
showLabel
|
||||
type="number"
|
||||
min={1}
|
||||
max={AppData.CustomToolMaxTimeoutMs / 1000}
|
||||
placeholder={String(AppData.CustomToolDefaultTimeoutMs / 1000)}
|
||||
defaultValue={timeout_ms ? String(timeout_ms / 1000) : ""}
|
||||
onChange={(e) => {
|
||||
const seconds = Number(e.target.value);
|
||||
onChange(seconds > 0 ? Math.round(seconds * 1000) : undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { CustomToolsOverviewPayload } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsList from "../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import CustomToolModal from "../(partials)/custom-tool-modal";
|
||||
import CustomToolRow from "../(partials)/custom-tool-row";
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* Every custom tool with who gets it. Super admins add, edit, try and
|
||||
* delete them; admins choose which bots and workspaces get each one.
|
||||
*/
|
||||
export default function ToolsSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [overview, setOverview] = useState(pageProps?.tools_overview);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
/**
|
||||
* Remounts the rows when the data changes under them.
|
||||
*/
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
if (!overview) return null;
|
||||
|
||||
const changed = (next: CustomToolsOverviewPayload, msg: string) => {
|
||||
setOverview(next);
|
||||
setStatus({ success: true, msg });
|
||||
setVersion((prev) => prev + 1);
|
||||
};
|
||||
const failed = (msg: string) => setStatus({ error: true, msg });
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Tools"
|
||||
description="An API call, a script on the bot's computer, or a prompt. A tool goes to the bots and workspaces it's attached to, or to every bot."
|
||||
>
|
||||
{adding ? (
|
||||
<CustomToolModal
|
||||
onSaved={(next, msg) => {
|
||||
setAdding(false);
|
||||
changed(next, msg);
|
||||
}}
|
||||
onCancel={() => setAdding(false)}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsList is_empty={!overview.tools.length} empty="No tools yet.">
|
||||
{overview.tools.map((tool) => (
|
||||
<CustomToolRow
|
||||
key={`${tool.id}-${version}`}
|
||||
tool={tool}
|
||||
targets={overview}
|
||||
onChanged={changed}
|
||||
onError={failed}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
{overview.can_edit && !adding ? (
|
||||
<Button
|
||||
title="Add a tool"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setAdding(true)}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New tool
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
|
||||
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||
|
||||
/**
|
||||
* Super admins and admins: admins only choose who gets each tool.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabCrewSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
tools_overview: await grabToolsOverview({ user_id: settings.props.person.id }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import ToolsSettingsSection from "./(sections)/tools-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsToolsPage() {
|
||||
return (
|
||||
<SettingsShell title="Tools" description="Tools of your own your bots can call.">
|
||||
<ToolsSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Tools" });
|
||||
+11
-15
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
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";
|
||||
@@ -57,25 +57,21 @@ export default function WorkspaceBotModal({ workspace_id, workspace_name, bot, o
|
||||
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.`}
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Textarea
|
||||
id="workspace-bot-instructions"
|
||||
<CodeEditor
|
||||
mode="markdown"
|
||||
className="h-44"
|
||||
title="Instructions here"
|
||||
label="Instructions here"
|
||||
showLabel
|
||||
rows={7}
|
||||
defaultValue={system_prompt}
|
||||
content={system_prompt}
|
||||
placeholder="In this workspace, write for the marketing team: short, plain, no jargon."
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
onChange={(value) => setSystemPrompt(value)}
|
||||
/>
|
||||
<Textarea
|
||||
id="workspace-bot-memory"
|
||||
<CodeEditor
|
||||
mode="markdown"
|
||||
className="h-32"
|
||||
title="Memory here"
|
||||
label="Memory here"
|
||||
showLabel
|
||||
rows={5}
|
||||
defaultValue={memory}
|
||||
content={memory}
|
||||
info="What it must remember in this workspace."
|
||||
onChange={(e) => setMemory(e.target.value)}
|
||||
onChange={(value) => setMemory(value)}
|
||||
/>
|
||||
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onClose} />
|
||||
</Form>
|
||||
|
||||
+6
-8
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
@@ -80,15 +80,13 @@ export default function WorkspaceDetailsGroup({ workspace, onSaved }: Props) {
|
||||
defaultValue={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id="workspace-context"
|
||||
<CodeEditor
|
||||
mode="markdown"
|
||||
className="h-52"
|
||||
title="Context"
|
||||
label="Context"
|
||||
showLabel
|
||||
rows={8}
|
||||
defaultValue={system_prompt}
|
||||
content={system_prompt}
|
||||
info="Markdown. Goals, people, links, rules: whatever every bot here should know."
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
onChange={(value) => setSystemPrompt(value)}
|
||||
/>
|
||||
<Row className="w-full gap-3! flex-nowrap items-center justify-between">
|
||||
<SettingsStatus status={status} />
|
||||
|
||||
Reference in New Issue
Block a user