Skip to content

Instantly share code, notes, and snippets.

@RayyanNafees
Last active May 19, 2026 10:52
Show Gist options
  • Select an option

  • Save RayyanNafees/c717ccac45851b46b70535a0da322dc0 to your computer and use it in GitHub Desktop.

Select an option

Save RayyanNafees/c717ccac45851b46b70535a0da322dc0 to your computer and use it in GitHub Desktop.
Bespokible AI chatbot UI snippets

Files included

  • /components/ai-chat.tsx - frontend for the chatbot
  • /components/app-sidebar.tsx - Right sidebar for the chatbot , add as <AppSidebar side="right" /> in <SidebarProvider>
  • /app/api/chat/route.ts - backend route connecting the MCP servers
  • package.json - match the needed libs
  • .env.local - match the environment vars (will be sent personally)
"use client";
import {
Attachment,
AttachmentPreview,
AttachmentRemove,
Attachments,
} from "@/components/ai-elements/attachments";
import {
PromptInput,
PromptInputActionAddAttachments,
PromptInputActionAddScreenshot,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputBody,
PromptInputButton,
PromptInputHeader,
type PromptInputMessage,
PromptInputSelect,
PromptInputSelectContent,
PromptInputSelectItem,
PromptInputSelectTrigger,
PromptInputSelectValue,
PromptInputSubmit,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
usePromptInputAttachments,
} from "@/components/ai-elements/prompt-input";
import { GlobeIcon } from "lucide-react";
import { useState, useEffect } from "react";
import { useChat } from "@ai-sdk/react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithToolCalls,
} from "ai";
const PromptInputAttachmentsDisplay = () => {
const attachments = usePromptInputAttachments();
if (attachments.files.length === 0) {
return null;
}
return (
<Attachments variant="inline">
{attachments.files.map((attachment) => (
<Attachment
data={attachment}
key={attachment.id}
onRemove={() => attachments.remove(attachment.id)}
>
<AttachmentPreview />
<AttachmentRemove />
</Attachment>
))}
</Attachments>
);
};
const models = [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "claude-opus-4-20250514", name: "Claude 4 Opus" },
];
const InputDemo = () => {
const [text, setText] = useState<string>("");
const [model, setModel] = useState<string>(models[0].id);
const [useWebSearch, setUseWebSearch] = useState<boolean>(false);
// const [apiKey, setApiKey] = useState<string>("");
// useEffect(() => {
// if (process.env.NEXT_PUBLIC_BESPOKIBLE_API_KEY) {
// setApiKey(process.env.NEXT_PUBLIC_BESPOKIBLE_API_KEY);
// }
// }, []);
// console.log(process.env.NEXT_PUBLIC_BESPOKIBLE_API_URL);
const { messages, status, sendMessage, addToolOutput } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
async onToolCall({ toolCall }) {
// Check if it's a dynamic tool first for proper type narrowing
if (toolCall.dynamic) {
return;
}
// const cities = ["New York", "Los Angeles", "Chicago", "San Francisco"];
// No await - avoids potential deadlocks
addToolOutput({
tool: toolCall.toolName,
toolCallId: toolCall.toolCallId,
output: toolCall.input,
});
},
// transport: new DefaultChatTransport({
// api: process.env.NEXT_PUBLIC_BESPOKIBLE_API_URL as string, // "https://mcp.bespokible.com/chat",
// headers: {
// "X-API-KEY": process.env.NEXT_PUBLIC_BESPOKIBLE_API_KEY as string,
// },
// }),
});
useEffect(() => {
document.body.scrollBy(1000, 1000);
}, [messages]);
const handleSubmit = (message: PromptInputMessage) => {
const hasText = Boolean(message.text);
const hasAttachments = Boolean(message.files?.length);
if (!(hasText || hasAttachments)) {
return;
}
sendMessage(
{
text: message.text || "Sent with attachments",
files: message.files,
},
{
body: {
model: model,
webSearch: useWebSearch,
},
},
);
setText("");
};
return (
<div className="max-w-4xl mx-auto p-6 relative size-full rounded-lg border min-h-150">
<div className="flex flex-col h-full">
<Conversation>
<ConversationContent>
{messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent>
{message.parts.map((part, i) => {
switch (part.type) {
case "text":
return (
<MessageResponse key={`${message.id}-${i}`}>
{part.text}
</MessageResponse>
);
default:
return null;
}
})}
</MessageContent>
</Message>
))}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput
onSubmit={handleSubmit}
// className="mt-auto absolute bottom-10"
globalDrop
multiple
>
<PromptInputHeader>
<PromptInputAttachmentsDisplay />
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
onChange={(e) => setText(e.target.value)}
value={text}
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
<PromptInputActionAddScreenshot />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{/*<PromptInputButton
onClick={() => setUseWebSearch(!useWebSearch)}
tooltip={{ content: "Search the web", shortcut: "⌘K" }}
variant={useWebSearch ? "default" : "ghost"}
>
<GlobeIcon size={16} />
<span>Search</span>
</PromptInputButton>*/}
<PromptInputSelect
onValueChange={(value) => {
setModel(value as string);
}}
value={model}
>
<PromptInputSelectTrigger>
<PromptInputSelectValue />
</PromptInputSelectTrigger>
<PromptInputSelectContent>
{models.map((model) => (
<PromptInputSelectItem key={model.id} value={model.id}>
{model.name}
</PromptInputSelectItem>
))}
</PromptInputSelectContent>
</PromptInputSelect>
</PromptInputTools>
<PromptInputSubmit disabled={!text && !status} status={status} />
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
};
export default InputDemo;
import React from "react";
import AIchat from "@/components/ai-chat";
import { Sidebar } from "@/components/ui/sidebar";
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar {...props}>
<AIchat />
</Sidebar>
);
}
import { AppSidebar } from "@/components/app-sidebar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
export default function Page() {
return (
<SidebarProvider>
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">Build Your Application</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<SidebarTrigger className="-mr-1 ml-auto rotate-180" />
</header>
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
</div>
<div className="min-h-screen flex-1 rounded-xl bg-muted/50 md:min-h-min" />
</div>
</SidebarInset>
<AppSidebar side="right" />
</SidebarProvider>
)
}
"use client";
import {
Attachment,
AttachmentPreview,
AttachmentRemove,
Attachments,
} from "@/components/ai-elements/attachments";
import {
PromptInput,
PromptInputActionAddAttachments,
PromptInputActionAddScreenshot,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputBody,
PromptInputButton,
PromptInputHeader,
type PromptInputMessage,
PromptInputSelect,
PromptInputSelectContent,
PromptInputSelectItem,
PromptInputSelectTrigger,
PromptInputSelectValue,
PromptInputSubmit,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
usePromptInputAttachments,
} from "@/components/ai-elements/prompt-input";
import { GlobeIcon } from "lucide-react";
import { useState, useEffect } from "react";
import { useChat } from "@ai-sdk/react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithToolCalls,
} from "ai";
// import { getMe } from "@/apis/me.api.ts";
const PromptInputAttachmentsDisplay = () => {
const attachments = usePromptInputAttachments();
if (attachments.files.length === 0) {
return null;
}
return (
<Attachments variant="inline">
{attachments.files.map((attachment) => (
<Attachment
data={attachment}
key={attachment.id}
onRemove={() => attachments.remove(attachment.id)}
>
<AttachmentPreview />
<AttachmentRemove />
</Attachment>
))}
</Attachments>
);
};
const models = [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "claude-opus-4-20250514", name: "Claude 4 Opus" },
];
const InputDemo = () => {
const [text, setText] = useState<string>("");
const [model, setModel] = useState<string>(models[0].id);
const [useWebSearch, setUseWebSearch] = useState<boolean>(false);
const [apiKey, setApiKey] = useState<string>(
localStorage.getItem("apiKey") || "",
);
useEffect(() => {
// getMe().then((me) => {
// if (!apiKey) {
// localStorage.setItem("apiKey", me.data.userSlug?.[0]);
// setApiKey(me.data.userSlug?.[0] || "");
// }
// });
}, []);
// console.log(process.env.NEXT_PUBLIC_BESPOKIBLE_API_URL);
const { messages, status, sendMessage, addToolOutput } = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
headers: {
"X-API-KEY": apiKey,
},
}),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
async onToolCall({ toolCall }) {
// Check if it's a dynamic tool first for proper type narrowing
if (toolCall.dynamic) {
return;
}
// const cities = ["New York", "Los Angeles", "Chicago", "San Francisco"];
// No await - avoids potential deadlocks
addToolOutput({
tool: toolCall.toolName,
toolCallId: toolCall.toolCallId,
output: toolCall.input,
});
},
// transport: new DefaultChatTransport({
// api: process.env.NEXT_PUBLIC_BESPOKIBLE_API_URL as string, // "https://mcp.bespokible.com/chat",
// headers: {
// "X-API-KEY": process.env.NEXT_PUBLIC_BESPOKIBLE_API_KEY as string,
// },
// }),
});
useEffect(() => {
document.body.scrollBy(1000, 1000);
}, [messages]);
const handleSubmit = (message: PromptInputMessage) => {
const hasText = Boolean(message.text);
const hasAttachments = Boolean(message.files?.length);
if (!(hasText || hasAttachments)) {
return;
}
sendMessage(
{
text: message.text || "Sent with attachments",
files: message.files,
},
{
body: {
model: model,
webSearch: useWebSearch,
},
},
);
setText("");
};
return (
<div className="max-w-4xl mx-auto p-6 relative size-full rounded-lg border min-h-150">
<div className="flex flex-col h-full">
<Conversation>
<ConversationContent>
{messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent>
{message.parts.map((part, i) => {
switch (part.type) {
case "text":
return (
<MessageResponse key={`${message.id}-${i}`}>
{part.text}
</MessageResponse>
);
default:
return null;
}
})}
</MessageContent>
</Message>
))}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput
onSubmit={handleSubmit}
// className="mt-auto absolute bottom-10"
globalDrop
multiple
>
<PromptInputHeader>
<PromptInputAttachmentsDisplay />
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
onChange={(e) => setText(e.target.value)}
value={text}
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
<PromptInputActionAddScreenshot />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{/*<PromptInputButton
onClick={() => setUseWebSearch(!useWebSearch)}
tooltip={{ content: "Search the web", shortcut: "⌘K" }}
variant={useWebSearch ? "default" : "ghost"}
>
<GlobeIcon size={16} />
<span>Search</span>
</PromptInputButton>*/}
<PromptInputSelect
onValueChange={(value) => {
setModel(value as string);
}}
value={model}
>
<PromptInputSelectTrigger>
<PromptInputSelectValue />
</PromptInputSelectTrigger>
<PromptInputSelectContent>
{models.map((model) => (
<PromptInputSelectItem key={model.id} value={model.id}>
{model.name}
</PromptInputSelectItem>
))}
</PromptInputSelectContent>
</PromptInputSelect>
</PromptInputTools>
<PromptInputSubmit disabled={!text && !status} status={status} />
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
};
export default InputDemo;
import { convertToModelMessages, streamText, UIMessage } from "ai";
import { google } from "@ai-sdk/google";
import { createMCPClient } from "@ai-sdk/mcp";
import { headers } from "next/headers";
// Allow streaming responses up to 30 seconds
// export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const headersList = await headers();
const apiKey = headersList.get("X-API-KEY");
const [artisanMCP, apiMCP] = await Promise.all([
createMCPClient({
clientName: "Bespokible Artisan MCP server",
transport: {
type: "http",
url: process.env.BESPOKIBLE_ARTISAN_MCP as string,
headers: {
"X-API-KEY": apiKey,
},
},
}),
createMCPClient({
clientName: "Bespokible OpenAPI MCP server",
transport: {
type: "http",
url: process.env.BESPOKIBLE_API_MCP as string,
headers: {
"X-API-KEY": apiKey,
},
},
}),
]);
const [artisanTools, apiTools] = await Promise.all([artisanMCP.tools(),apiMCP.tools()]);
const result = streamText({
model: google("gemini-2.5-flash"),
system: `You are a helpful assistant, that passes user queries to the Bespokible MCP server.
- Whatever the agent and tool of the MCP server outputs, you present its summarry to the user
- The bespokible Artisan Server deals soecifically with Artisans and their timesheet entries
- While in genrate, the Bespokbile OpenAPI mcp server can call any route to the bespokible backend to server the user query,
when you can't find a suitable tool in the Artisan MCP server, you can use the OpenAPI MCP server to search & call any route to the backend`,
tools: {
...artisanTools,
...apiTools
},
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}
{
"name": "bespokible-ai",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "biome check",
"format": "biome format --write",
"dev:vinext": "vinext dev --port 3001",
"build:vinext": "vinext build",
"start:vinext": "vinext start"
},
"dependencies": {
"@ai-sdk/google": "^3.0.75",
"@ai-sdk/mcp": "^1.0.42",
"@ai-sdk/openai": "^3.0.64",
"@ai-sdk/react": "^3.0.186",
"@base-ui/react": "^1.4.1",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"ai": "^6.0.184",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^1.16.0",
"nanoid": "^5.1.11",
"next": "16.2.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"shadcn": "^4.7.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-rsc": "^0.5.26",
"react-server-dom-webpack": "^19.2.6",
"tailwindcss": "^4",
"typescript": "^5",
"vinext": "^0.0.50",
"vite": "^8.0.13"
},
"ignoreScripts": [
"sharp",
"unrs-resolver"
],
"trustedDependencies": [
"sharp",
"unrs-resolver"
],
"type": "module"
}
import { convertToModelMessages, streamText, UIMessage } from "ai";
import { google } from "@ai-sdk/google";
import { createMCPClient } from "@ai-sdk/mcp";
const artisanMCP = await createMCPClient({
clientName: "Bespokible Artisan MCP server",
transport: {
type: "http",
url: process.env.BESPOKIBLE_ARTISAN_MCP as string,
headers: {
"X-API-KEY": process.env.BESPOKIBLE_API_KEY as string,
},
},
});
const apiMCP = await createMCPClient({
clientName: "Bespokible OpenAPI MCP server",
transport: {
type: "http",
url: process.env.BESPOKIBLE_API_MCP as string,
headers: {
"X-API-KEY": process.env.BESPOKIBLE_API_KEY as string,
},
},
});
const tools = { ...(await artisanMCP.tools()), ...(await apiMCP.tools()) };
// Allow streaming responses up to 30 seconds
// export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: google("gemini-2.5-flash"),
system: `You are a helpful assistant, that passes user queries to the Bespokible MCP server.
- Whatever the agent and tool of the MCP server outputs, you present its summarry to the user
- The bespokible Artisan Server deals soecifically with Artisans and their timesheet entries
- While in genrate, the Bespokbile OpenAPI mcp server can call any route to the bespokible backend to server the user query,
when you can't find a suitable tool in the Artisan MCP server, you can use the OpenAPI MCP server to search & call any route to the backend`,
tools,
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment