Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/web-adapter.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions apps/docs/adapters.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
slack,
teams,
telegram,
web,
whatsapp,
} from "@/lib/logos";

Expand All @@ -32,6 +33,7 @@ const iconMap: Record<
"google-chat": gchat,
discord,
github,
web,
linear,
telegram,
redis,
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/docs/adapters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Conversation>` from `ai-elements` out of the box.

Ready to build your own? Follow the [building](/docs/contributing/building) guide.

## Feature matrix
Expand Down
17 changes: 17 additions & 0 deletions apps/docs/lib/logos.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import type { ComponentProps } from "react";

export const web = (props: ComponentProps<"svg">) => (
<svg
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.5" />
<path d="M3 12h18" stroke="currentColor" strokeWidth="1.5" />
<path
d="M12 3a13.5 13.5 0 0 1 0 18M12 3a13.5 13.5 0 0 0 0 18"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
);

export const github = (props: ComponentProps<"svg">) => (
<svg
fill="none"
Expand Down
11 changes: 8 additions & 3 deletions examples/nextjs-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,33 @@
"recording:export": "tsx src/lib/recorder.ts"
},
"dependencies": {
"@ai-sdk/react": "^3.0.176",
"@chat-adapter/discord": "workspace:*",
"@chat-adapter/gchat": "workspace:*",
"@chat-adapter/github": "workspace:*",
"@chat-adapter/linear": "workspace:*",
"@chat-adapter/slack": "workspace:*",
"@chat-adapter/state-memory": "workspace:*",
"@chat-adapter/state-redis": "workspace:*",
"@chat-adapter/telegram": "workspace:*",
"@chat-adapter/teams": "workspace:*",
"@chat-adapter/telegram": "workspace:*",
"@chat-adapter/web": "workspace:*",
"@chat-adapter/whatsapp": "workspace:*",
"ai": "^6.0.5",
"@tailwindcss/postcss": "^4.1.18",
"ai": "^6.0.174",
"chat": "workspace:*",
"next": "^16.2.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"redis": "^5.11.0"
"redis": "^5.11.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@types/node": "^25.3.2",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"dotenv": "^17.2.3",
"postcss": "^8.5.10",
"tsx": "^4.21.0",
"typescript": "^5.7.2"
}
Expand Down
5 changes: 5 additions & 0 deletions examples/nextjs-chat/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
17 changes: 17 additions & 0 deletions examples/nextjs-chat/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { after } from "next/server";
import { bot } from "@/lib/bot";

/**
* Web chat endpoint — receives `useChat` requests and streams responses
* using the AI SDK UI message stream protocol. The same `bot` instance
* handles Slack, Teams, etc., so any handler registered there fires here too.
*/
export async function POST(request: Request): Promise<Response> {
const handler = bot.webhooks.web;
if (!handler) {
return new Response("Web adapter not configured", { status: 500 });
}
return handler(request, {
waitUntil: (task) => after(() => task),
});
}
177 changes: 177 additions & 0 deletions examples/nextjs-chat/src/app/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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<HTMLFormElement>) => {
event?.preventDefault();
const text = input.trim();
if (!text || busy) {
return;
}
sendMessage({ text });
setInput("");
};

const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSubmit();
}
};

return (
<main className="mx-auto flex h-screen max-w-2xl flex-col px-4">
<div
aria-live="polite"
className="hide-scrollbar flex-1 space-y-5 overflow-y-auto pt-12 pb-4"
ref={scrollRef}
>
{messages.length === 0 && !busy ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
What can I help you with?
</p>
</div>
) : (
<>
{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 (
<div key={message.id}>
{isUser ? (
<div
className="rounded-lg px-3.5 py-3 text-sm"
style={{ background: "var(--muted)", opacity: 0.8 }}
>
{text}
</div>
) : (
<div
className="whitespace-pre-wrap py-2 text-sm leading-relaxed"
style={{ color: "var(--foreground)" }}
>
{text}
{isStreaming && !text && (
<span
className="inline-block h-4 w-0.5"
style={{
background: "var(--muted-foreground)",
animation: "blink 1s ease-in-out infinite",
}}
/>
)}
</div>
)}
</div>
);
})}

{status === "submitted" && (
<div
className="py-2 text-sm"
style={{
color: "var(--muted-foreground)",
animation: "breathe 2s ease-in-out infinite",
}}
>
Thinking...
</div>
)}
</>
)}
</div>

{error && (
<div
className="mb-3 rounded-lg px-3.5 py-2.5 text-sm"
style={{
background: "rgba(239, 68, 68, 0.08)",
color: "#f87171",
border: "1px solid rgba(239, 68, 68, 0.15)",
}}
>
{error.message}
</div>
)}

<div className="pt-2 pb-6">
<form
className="relative overflow-hidden rounded-xl"
onSubmit={onSubmit}
style={{
background: "var(--muted)",
border: "1px solid var(--border)",
}}
>
<textarea
aria-label="Message"
className="w-full resize-none bg-transparent px-3.5 pt-3.5 pb-12 text-sm outline-none disabled:opacity-50"
disabled={busy}
onChange={(event) => setInput(event.target.value)}
onKeyDown={onKeyDown}
placeholder={
messages.length === 0 ? "What can I help you with?" : "Reply..."
}
rows={1}
style={{
color: "var(--foreground)",
minHeight: messages.length === 0 ? "120px" : "56px",
maxHeight: "200px",
}}
value={input}
/>
<div className="absolute right-3 bottom-3 flex items-center gap-2">
{busy ? (
<button
className="rounded-lg px-3 py-1.5 text-xs transition-colors hover:opacity-80"
onClick={() => stop()}
style={{
background: "var(--border)",
color: "var(--muted-foreground)",
}}
type="button"
>
Stop
</button>
) : (
<button
className="rounded-lg px-3 py-1.5 font-medium text-xs transition-opacity disabled:opacity-30"
disabled={!input.trim()}
style={{
background: "var(--foreground)",
color: "var(--background)",
}}
type="submit"
>
Send
</button>
)}
</div>
</form>
</div>
</main>
);
}
37 changes: 37 additions & 0 deletions examples/nextjs-chat/src/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
@import "tailwindcss";

:root {
--background: #0a0a0a;
--foreground: #fafafa;
--muted: #1a1a1a;
--muted-foreground: #a1a1a1;
--border: #262626;
}

.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}

@keyframes breathe {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 0.7;
}
}

@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
9 changes: 8 additions & 1 deletion examples/nextjs-chat/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import "./globals.css";

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
<body
className="antialiased"
style={{ background: "var(--background)", color: "var(--foreground)" }}
>
{children}
</body>
</html>
);
}
Loading