Give bots tools and MCP servers per bot and workspace, with status and tests
- Bots and workspaces get tools and MCP servers from their settings pages; bots also from chips under the chat composer, removable there - "Every bot" now gives one to every bot now and every new bot; each can still drop it (attach-every-bot-extensions.ts moves old installs over) - Status lines: MCP servers record whether they connect, tools whether their last run worked - Test connection in the MCP form and a Test panel in the tool form check the form as it is, without saving - Hide Google sign-in on the login form Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Plus } from "lucide-react";
|
||||
import ExtensionAddSelect from "@/src/components/general/extension-add-select";
|
||||
import type { BotExtensionsPayload } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
bot_name: string;
|
||||
addable: BotExtensionsPayload["addable"];
|
||||
loading?: boolean;
|
||||
onAdd: (params: { kind: "mcp" | "tool"; id: number }) => Promise<boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The "Add" chip under the composer: picks an MCP server or tool to give
|
||||
* the bot, everywhere it works.
|
||||
*/
|
||||
export default function ComposerAddToolModal({ bot_name, addable, loading, onAdd }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!addable.length) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<Button
|
||||
title={`Give ${bot_name} a tool or MCP server`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
beforeIcon={<Plus size={12} aria-hidden />}
|
||||
className="gap-1! px-2! py-0.5! text-[12px] leading-4"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
}
|
||||
targetWrapperProps={{ className: "shrink-0" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-lg bg-paper p-6"
|
||||
>
|
||||
<Stack className="w-full gap-4! items-stretch">
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">Add a tool</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{`${bot_name} can use it everywhere it works, for everyone it works for.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
<ExtensionAddSelect
|
||||
id="composer-add-extension"
|
||||
addable={addable}
|
||||
loading={loading}
|
||||
onAdd={async (picked) => {
|
||||
if (await onAdd(picked)) setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { CircleAlert, X } from "lucide-react";
|
||||
import extensionIcon from "@/src/components/general/extension-icon";
|
||||
import type { BotExtension } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
item: BotExtension;
|
||||
busy?: boolean;
|
||||
/**
|
||||
* Takes it from the bot; not given for a workspace's own or when the
|
||||
* user can't.
|
||||
*/
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One MCP server or tool the bot can use here, under the composer. One
|
||||
* that isn't working shows an alert icon, one that's off is struck out.
|
||||
*/
|
||||
export default function ComposerToolChip({ item, busy, onRemove }: Props) {
|
||||
const Icon = item.state == "error" ? CircleAlert : extensionIcon({ kind: item.kind });
|
||||
const notes = [
|
||||
item.detail,
|
||||
item.source == "workspace" ? "From this workspace" : "",
|
||||
!item.enabled ? "Off for every bot" : item.state == "error" ? "Not working" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Row
|
||||
title={`${item.name}: ${notes.join(" · ")}${item.description ? `\n${item.description}` : ""}`}
|
||||
className={`max-w-48 gap-1.5! flex-nowrap items-center rounded-default-lg border border-solid border-hairline bg-surface pl-2 py-0.5 text-[12px] leading-4 ${onRemove ? "pr-0.5" : "pr-2"} ${item.enabled ? "text-ink" : "text-ink-secondary line-through"}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden className="shrink-0" />
|
||||
<Span className="truncate">{item.name}</Span>
|
||||
{onRemove ? (
|
||||
<Button
|
||||
title={`Remove ${item.name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
className="shrink-0 p-0.5!"
|
||||
disabled={busy}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X size={12} aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import useExtensions from "@/src/hooks/use-extensions";
|
||||
import type { BotExtensionsPayload, BotWithLastMessage } from "@/src/types";
|
||||
import ComposerAddToolModal from "./composer-add-tool-modal";
|
||||
import ComposerToolChip from "./composer-tool-chip";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
workspace_id?: number | null;
|
||||
initial?: BotExtensionsPayload | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The MCP servers and tools the bot can use here, under the composer.
|
||||
* Super admins and admins remove any it was given (even one every bot
|
||||
* started with) and add more; a workspace's own stay.
|
||||
*/
|
||||
export default function ComposerTools({ bot, workspace_id, initial }: Props) {
|
||||
const extensions = useExtensions({
|
||||
owner: { bot_id: bot.id || 0, workspace_id },
|
||||
owner_name: bot.name || "this bot",
|
||||
initial,
|
||||
});
|
||||
|
||||
if (!extensions.attached.length && !(extensions.can_manage && extensions.addable.length)) return null;
|
||||
|
||||
return (
|
||||
<Row className="w-full max-w-container mx-auto gap-1.5! items-center px-6 pb-3 -mt-3">
|
||||
<Span className="text-[12px] leading-4 text-ink-secondary">Tools</Span>
|
||||
{extensions.attached.map((item) => (
|
||||
<ComposerToolChip
|
||||
key={`${item.kind}:${item.id}`}
|
||||
item={item}
|
||||
busy={extensions.busy == `${item.kind}:${item.id}`}
|
||||
onRemove={
|
||||
extensions.can_manage && item.source == "bot"
|
||||
? () => extensions.remove({ kind: item.kind, id: item.id, name: item.name })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{extensions.can_manage ? (
|
||||
<ComposerAddToolModal
|
||||
bot_name={bot.name || "This bot"}
|
||||
addable={extensions.addable}
|
||||
loading={Boolean(extensions.busy)}
|
||||
onAdd={extensions.add}
|
||||
/>
|
||||
) : null}
|
||||
{extensions.status?.error ? (
|
||||
<Span role="alert" className="text-[12px] leading-4 text-ink-secondary">
|
||||
{extensions.status.msg}
|
||||
</Span>
|
||||
) : null}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import type {
|
||||
BotExtensionsPayload,
|
||||
BotMessageWithAttachments,
|
||||
BotWithLastMessage,
|
||||
} from "@/src/types";
|
||||
@@ -12,6 +13,7 @@ import useComposerDraft from "../(hooks)/use-composer-draft";
|
||||
import ComposerAttachButton from "./composer-attach-button";
|
||||
import ComposerAttachments from "./composer-attachments";
|
||||
import ComposerSendButton from "./composer-send-button";
|
||||
import ComposerTools from "./composer-tools";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -19,6 +21,10 @@ type Props = {
|
||||
* The workspace the conversation is in, if any.
|
||||
*/
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* Its MCP servers and tools, listed under the input.
|
||||
*/
|
||||
extensions?: BotExtensionsPayload | null;
|
||||
onSent: (params: { message: BotMessageWithAttachments }) => void;
|
||||
};
|
||||
|
||||
@@ -26,9 +32,10 @@ type Props = {
|
||||
* Enter sends, Shift+Enter adds a line. Grows to five lines, then scrolls.
|
||||
* Its top rule lines up with the rail's user-row rule; picked files sit
|
||||
* just above it. Files can also be pasted or dropped in. Unsent text is
|
||||
* kept per conversation across reloads, with its uploaded files.
|
||||
* kept per conversation across reloads, with its uploaded files. The
|
||||
* bot's MCP servers and tools are listed under the input.
|
||||
*/
|
||||
export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
export default function Composer({ bot, workspace_id, extensions, onSent }: Props) {
|
||||
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -128,6 +135,7 @@ export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
onClick={send}
|
||||
/>
|
||||
</Row>
|
||||
<ComposerTools bot={bot} workspace_id={workspace_id} initial={extensions} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function Conversation() {
|
||||
<Composer
|
||||
bot={bot}
|
||||
workspace_id={workspace_id}
|
||||
extensions={pageProps?.bot_extensions}
|
||||
onSent={({ message }) => {
|
||||
if (startsNewSession(message) && message.session_id != session_id) {
|
||||
setSessionId(Number(message.session_id));
|
||||
|
||||
Reference in New Issue
Block a user