diff --git a/.changeset/web-adapter.md b/.changeset/web-adapter.md new file mode 100644 index 00000000..98888724 --- /dev/null +++ b/.changeset/web-adapter.md @@ -0,0 +1,36 @@ +--- +"@chat-adapter/web": minor +"chat": minor +--- + +Add **`@chat-adapter/web`** — a new platform adapter that lets a chat-sdk bot serve a browser chat UI alongside Slack/Teams/Discord, without writing any client-side glue. + +The adapter speaks the [AI SDK UI message stream protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol), so [`@ai-sdk/react`](https://www.npmjs.com/package/@ai-sdk/react)'s `useChat` and the [`ai-elements`](https://elements.ai-sdk.dev/) component library work out of the box. The same `bot.onDirectMessage(...)` handler fires for both web and other platforms — including stream-based replies via `thread.post(stream)`. + +Two subpath exports: + +- `@chat-adapter/web` — server-side `createWebAdapter({ userName, getUser })` that produces an `Adapter` for the `Chat` constructor. +- `@chat-adapter/web/react` — thin client wrapper exposing `useChat()` preconfigured with `DefaultChatTransport`. Re-exports `UIMessage` and `UseChatHelpers` types. + +```ts +// server +const bot = new Chat({ + userName: "mybot", + adapters: { + web: createWebAdapter({ + userName: "mybot", + getUser: (req) => ({ id: getUserIdFromCookie(req) }), + }), + }, + state: createMemoryState(), +}); +export const POST = bot.webhooks.web; +``` + +```tsx +// client +import { useChat } from "@chat-adapter/web/react"; +const { messages, sendMessage, status } = useChat(); +``` + +v1 covers text + markdown, native streaming, DM-style routing (`isDM: true`), persisted message history (`persistMessageHistory: true` by default — required for `channel.messages` since web has no platform history API), and abort propagation via `request.signal`. Out of scope for v1: cards/JSX rendering, reactions, modals, file uploads, edit/delete, and multi-tab proactive push. diff --git a/apps/docs/adapters.json b/apps/docs/adapters.json index 5e25d4cc..45ed15f6 100644 --- a/apps/docs/adapters.json +++ b/apps/docs/adapters.json @@ -79,6 +79,16 @@ "beta": true, "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-whatsapp" }, + { + "name": "Web", + "slug": "web", + "type": "platform", + "description": "Serve a browser chat UI from the same bot using the AI SDK useChat protocol — works out of the box with @ai-sdk/react and ai-elements.", + "packageName": "@chat-adapter/web", + "icon": "web", + "beta": true, + "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-web" + }, { "name": "Redis", "slug": "redis", diff --git a/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx b/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx index 1d3c8f5c..c6204cdf 100644 --- a/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx +++ b/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx @@ -20,6 +20,7 @@ import { slack, teams, telegram, + web, whatsapp, } from "@/lib/logos"; @@ -32,6 +33,7 @@ const iconMap: Record< "google-chat": gchat, discord, github, + web, linear, telegram, redis, diff --git a/apps/docs/content/docs/adapters.mdx b/apps/docs/content/docs/adapters.mdx index 28de8219..3177b40c 100644 --- a/apps/docs/content/docs/adapters.mdx +++ b/apps/docs/content/docs/adapters.mdx @@ -8,6 +8,8 @@ prerequisites: Adapters handle webhook verification, message parsing, and API calls for each platform. Install only the adapters you need. Browse all available adapters — including community-built ones — on the [Adapters](/adapters) page. +Need a browser chat UI? See the [Web adapter](/adapters/web) — it speaks the AI SDK `useChat` protocol so the same bot serves Slack, Teams, **and** a `` from `ai-elements` out of the box. + Ready to build your own? Follow the [building](/docs/contributing/building) guide. ## Feature matrix diff --git a/apps/docs/lib/logos.tsx b/apps/docs/lib/logos.tsx index 805ac5cc..ac2255af 100644 --- a/apps/docs/lib/logos.tsx +++ b/apps/docs/lib/logos.tsx @@ -1,5 +1,22 @@ import type { ComponentProps } from "react"; +export const web = (props: ComponentProps<"svg">) => ( + + + + + +); + export const github = (props: ComponentProps<"svg">) => ( { + const handler = bot.webhooks.web; + if (!handler) { + return new Response("Web adapter not configured", { status: 500 }); + } + return handler(request, { + waitUntil: (task) => after(() => task), + }); +} diff --git a/examples/nextjs-chat/src/app/chat/page.tsx b/examples/nextjs-chat/src/app/chat/page.tsx new file mode 100644 index 00000000..ce8ae458 --- /dev/null +++ b/examples/nextjs-chat/src/app/chat/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useChat } from "@chat-adapter/web/react"; +import { type FormEvent, useEffect, useRef, useState } from "react"; + +export default function ChatPage() { + const { messages, sendMessage, status, stop, error } = useChat(); + const [input, setInput] = useState(""); + const scrollRef = useRef(null); + + const busy = status === "submitted" || status === "streaming"; + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages and status changes + useEffect(() => { + scrollRef.current?.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior: "smooth", + }); + }, [messages.length, status]); + + const onSubmit = (event?: FormEvent) => { + event?.preventDefault(); + const text = input.trim(); + if (!text || busy) { + return; + } + sendMessage({ text }); + setInput(""); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + onSubmit(); + } + }; + + return ( +
+
+ {messages.length === 0 && !busy ? ( +
+

+ What can I help you with? +

+
+ ) : ( + <> + {messages.map((message) => { + const text = message.parts + .filter((part) => part.type === "text") + .map((part) => (part as { text: string }).text) + .join(""); + const isUser = message.role === "user"; + const isLastAssistant = !isUser && message === messages.at(-1); + const isStreaming = isLastAssistant && status === "streaming"; + + return ( +
+ {isUser ? ( +
+ {text} +
+ ) : ( +
+ {text} + {isStreaming && !text && ( + + )} +
+ )} +
+ ); + })} + + {status === "submitted" && ( +
+ Thinking... +
+ )} + + )} +
+ + {error && ( +
+ {error.message} +
+ )} + +
+
+