diff --git a/packages/ai-adapter/.gitignore b/packages/ai-adapter/.gitignore
new file mode 100644
index 0000000000..d4d7e3f614
--- /dev/null
+++ b/packages/ai-adapter/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+*.log
diff --git a/packages/ai-adapter/README.md b/packages/ai-adapter/README.md
new file mode 100644
index 0000000000..018c54dd13
--- /dev/null
+++ b/packages/ai-adapter/README.md
@@ -0,0 +1,149 @@
+# @embeddedchat/ai-adapter
+
+Pluggable AI adapter layer for [EmbeddedChat](https://github.com/RocketChat/EmbeddedChat). Connect any local or cloud AI provider to add smart widget features — reply suggestions, context-aware prompts, and more.
+
+## Architecture
+
+```
+Host App
+├── Config
+└── AI Adapter (optional) ──▶ AI Backend (OpenAI / Ollama / custom)
+ │
+ ▼
+ EmbeddedChat
+ ├── React UI
+ ├── API Layer ──▶ Rocket.Chat Server
+ └── Auth
+```
+
+The AI backend is **completely independent** of the Rocket.Chat server. EmbeddedChat has **zero dependency** on this package — the host app owns the entire AI integration.
+
+## Installation
+
+```bash
+npm install @embeddedchat/ai-adapter
+```
+
+## Quick Start
+
+```jsx
+import { EmbeddedChat } from '@embeddedchat/react';
+import { OpenAIAdapter } from '@embeddedchat/ai-adapter';
+
+const adapter = new OpenAIAdapter({ apiKey: process.env.OPENAI_API_KEY });
+
+
+```
+
+When `aiAdapter` is provided, a ✨ button appears in the message input toolbar. Clicking it calls `getSuggestions()` with the recent conversation history and displays clickable reply chips above the input.
+
+When `aiAdapter` is **not** provided: zero UI changes, zero bundle size impact.
+
+## Built-in Adapters
+
+### OpenAIAdapter
+
+```typescript
+import { OpenAIAdapter } from '@embeddedchat/ai-adapter';
+
+const adapter = new OpenAIAdapter({
+ apiKey: 'sk-...', // optional if using a proxy via baseUrl
+ model: 'gpt-4o', // default: 'gpt-4o'
+ maxTokens: 500, // default: 500
+ baseUrl: 'https://api.openai.com/v1', // override for proxies
+ headers: { 'X-Custom-Key': '...' }, // extra headers forwarded to every request
+ assistantUsername: 'ai-bot', // RC username of the AI — maps its messages to 'assistant' role
+});
+```
+
+### GeminiAdapter
+
+```typescript
+import { GeminiAdapter } from '@embeddedchat/ai-adapter';
+
+const adapter = new GeminiAdapter({
+ apiKey: 'AIza...', // optional if using a proxy via baseUrl
+ model: 'gemini-2.0-flash', // default
+ baseUrl: 'https://generativelanguage.googleapis.com', // override for proxies
+ headers: { 'X-Custom-Key': '...' }, // extra headers
+ assistantUsername: 'ai-bot', // RC username of the AI — maps its messages to 'model' role
+});
+```
+
+### OllamaAdapter (local / self-hosted)
+
+```typescript
+import { OllamaAdapter } from '@embeddedchat/ai-adapter';
+
+const adapter = new OllamaAdapter({
+ baseUrl: 'http://localhost:11434', // default
+ model: 'llama3', // default
+ headers: { 'X-Custom-Key': '...' }, // useful when Ollama is behind an auth proxy
+ assistantUsername: 'ai-bot', // RC username of the AI — maps its messages to 'assistant' role
+});
+```
+
+No API key required for Ollama. Runs entirely on your own hardware — ideal for privacy-conscious deployments.
+
+## Writing a Custom Adapter
+
+Implement `IAIAdapter` or extend `BaseAIAdapter`:
+
+```typescript
+import { BaseAIAdapter, AIContext, AIResponse } from '@embeddedchat/ai-adapter';
+
+export class MyCustomAdapter extends BaseAIAdapter {
+ name = 'My AI';
+
+ async sendPrompt(context: AIContext, message: string): Promise {
+ const reply = await myAIService.chat(message);
+ return { text: reply };
+ }
+
+ async isAvailable(): Promise {
+ return await myAIService.ping();
+ }
+}
+```
+
+`BaseAIAdapter` provides a default `getSuggestions()` implementation that calls `sendPrompt()`. Override it for provider-specific optimisation.
+
+## Interface
+
+```typescript
+interface IAIAdapter {
+ name: string;
+ sendPrompt(context: AIContext, message: string): Promise;
+ getSuggestions?(conversation: Message[]): Promise;
+ isAvailable(): Promise;
+}
+
+interface AIContext {
+ roomId: string;
+ userId: string;
+ history: Message[];
+ metadata?: { federated?: boolean };
+}
+
+interface AIResponse {
+ text: string;
+ suggestions?: string[];
+}
+```
+
+## Testing / Demo
+
+```typescript
+import { MockAdapter } from '@embeddedchat/ai-adapter';
+// For testing/demo only — returns hardcoded responses, no API key required
+
+const adapter = new MockAdapter();
+```
+
+## License
+
+MIT
diff --git a/packages/ai-adapter/package.json b/packages/ai-adapter/package.json
new file mode 100644
index 0000000000..8e23be5823
--- /dev/null
+++ b/packages/ai-adapter/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@embeddedchat/ai-adapter",
+ "version": "0.0.1",
+ "description": "Pluggable AI adapter layer for EmbeddedChat — connect any local or cloud AI provider",
+ "main": "dist/index.js",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.ts",
+ "type": "module",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "build": "rollup -c",
+ "dev": "rollup -c --watch",
+ "format": "prettier --write 'src/'",
+ "format:check": "prettier --check 'src/'"
+ },
+ "keywords": [
+ "embeddedchat",
+ "ai",
+ "adapter",
+ "rocketchat",
+ "openai",
+ "ollama"
+ ],
+ "license": "MIT",
+ "devDependencies": {
+ "prettier": "^2.8.1",
+ "rollup": "^3.23.0",
+ "rollup-plugin-dts": "^6.0.1",
+ "rollup-plugin-esbuild": "^5.0.0",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/packages/ai-adapter/rollup.config.js b/packages/ai-adapter/rollup.config.js
new file mode 100644
index 0000000000..eedda5a447
--- /dev/null
+++ b/packages/ai-adapter/rollup.config.js
@@ -0,0 +1,32 @@
+import dts from 'rollup-plugin-dts';
+import esbuild from 'rollup-plugin-esbuild';
+import path from 'path';
+import { createRequire } from 'module';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const require = createRequire(import.meta.url);
+const packageJson = require(path.resolve(__dirname, './package.json'));
+
+const name = packageJson.main.replace(/\.js$/, '');
+
+const bundle = (config) => ({
+ ...config,
+ input: 'src/index.ts',
+ external: (id) => id[0] !== '.' && !path.isAbsolute(id),
+});
+
+export default [
+ bundle({
+ plugins: [esbuild()],
+ output: [
+ { file: `${name}.js`, format: 'cjs', sourcemap: true },
+ { file: `${name}.mjs`, format: 'es', sourcemap: true },
+ ],
+ }),
+ bundle({
+ plugins: [dts()],
+ output: { file: `${name}.d.ts`, format: 'es' },
+ }),
+];
diff --git a/packages/ai-adapter/src/BaseAIAdapter.ts b/packages/ai-adapter/src/BaseAIAdapter.ts
new file mode 100644
index 0000000000..4c0403a244
--- /dev/null
+++ b/packages/ai-adapter/src/BaseAIAdapter.ts
@@ -0,0 +1,58 @@
+import { IAIAdapter, AIContext, AIResponse, Message } from "./types";
+
+export abstract class BaseAIAdapter implements IAIAdapter {
+ abstract name: string;
+ abstract sendPrompt(context: AIContext, message: string): Promise;
+ abstract isAvailable(): Promise;
+
+ async getSuggestions(
+ conversation: Message[],
+ context?: AIContext
+ ): Promise {
+ const lastMessages = conversation
+ .slice(-5)
+ .map((m) => `${m.u.username}: ${m.msg}`)
+ .join("\n");
+
+ const ctx: AIContext = context ?? {
+ roomId: "",
+ userId: "",
+ history: conversation,
+ };
+
+ const response = await this.sendPrompt(
+ ctx,
+ `Based on this conversation, suggest exactly 3 short reply options (one per line, no numbering, max 10 words each):\n${lastMessages}`
+ );
+
+ if (response.suggestions && response.suggestions.length > 0) {
+ return response.suggestions;
+ }
+
+ return response.text
+ .split("\n")
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .slice(0, 3);
+ }
+
+ async summarize(messages: Message[], context?: AIContext): Promise {
+ const truncated = messages.slice(-100);
+ const content = truncated
+ .map((m) => `${m.u.username}: ${m.msg}`)
+ .join("\n");
+
+ const ctx: AIContext = context ?? {
+ roomId: "",
+ userId: "",
+ history: truncated,
+ };
+
+ const response = await this.sendPrompt(
+ ctx,
+ `Summarize this conversation concisely in 3-5 sentences:\n${content}`
+ );
+
+ return response.text;
+ }
+}
diff --git a/packages/ai-adapter/src/adapters/GeminiAdapter.ts b/packages/ai-adapter/src/adapters/GeminiAdapter.ts
new file mode 100644
index 0000000000..dfec24cc40
--- /dev/null
+++ b/packages/ai-adapter/src/adapters/GeminiAdapter.ts
@@ -0,0 +1,114 @@
+import { BaseAIAdapter } from "../BaseAIAdapter";
+import { AIContext, AIResponse } from "../types";
+
+interface GeminiConfig {
+ apiKey?: string;
+ model?: string;
+ baseUrl?: string;
+ headers?: Record;
+ assistantUsername?: string;
+}
+
+export class GeminiAdapter extends BaseAIAdapter {
+ name = "Gemini";
+ private config: Required;
+
+ constructor(config: GeminiConfig) {
+ super();
+ this.config = {
+ apiKey: "",
+ model: "gemini-2.0-flash",
+ baseUrl: "https://generativelanguage.googleapis.com",
+ headers: {},
+ assistantUsername: "",
+ ...config,
+ };
+ }
+
+ private get endpoint() {
+ const keyParam = this.config.apiKey ? `?key=${this.config.apiKey}` : "";
+ const base = this.config.baseUrl.replace(/\/$/, "");
+ return `${base}/v1beta/models/${this.config.model}:generateContent${keyParam}`;
+ }
+
+ async sendPrompt(context: AIContext, message: string): Promise {
+ const history = context.history.slice(-10);
+ const contents: Array<{
+ role: "user" | "model";
+ parts: Array<{ text: string }>;
+ }> = [];
+
+ for (const m of history) {
+ const role =
+ this.config.assistantUsername &&
+ m.u.username === this.config.assistantUsername
+ ? "model"
+ : "user";
+ const text = `${m.u.username}: ${m.msg}`;
+
+ const lastContent = contents[contents.length - 1];
+ if (lastContent && lastContent.role === role) {
+ lastContent.parts[0].text += `\n${text}`;
+ } else {
+ contents.push({
+ role,
+ parts: [{ text }],
+ });
+ }
+ }
+
+ const currentRole = "user";
+ const lastContent = contents[contents.length - 1];
+ if (lastContent && lastContent.role === currentRole) {
+ lastContent.parts[0].text += `\n${message}`;
+ } else {
+ contents.push({
+ role: currentRole,
+ parts: [{ text: message }],
+ });
+ }
+
+ const res = await fetch(this.endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...this.config.headers,
+ },
+ body: JSON.stringify({
+ contents,
+ systemInstruction: {
+ parts: [
+ {
+ text: `You are a helpful assistant inside a chat room. Keep responses concise and relevant.${
+ context.metadata?.federated
+ ? " This is a federated Matrix room."
+ : ""
+ }`,
+ },
+ ],
+ },
+ }),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Gemini API error: ${res.status}`);
+ }
+
+ const data = await res.json();
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
+ return { text };
+ }
+
+ async isAvailable(): Promise {
+ try {
+ const keyParam = this.config.apiKey ? `?key=${this.config.apiKey}` : "";
+ const base = this.config.baseUrl.replace(/\/$/, "");
+ const res = await fetch(`${base}/v1beta/models${keyParam}`, {
+ headers: this.config.headers,
+ });
+ return res.ok;
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/packages/ai-adapter/src/adapters/MockAdapter.ts b/packages/ai-adapter/src/adapters/MockAdapter.ts
new file mode 100644
index 0000000000..b713e707e0
--- /dev/null
+++ b/packages/ai-adapter/src/adapters/MockAdapter.ts
@@ -0,0 +1,22 @@
+// For testing/demo only — returns hardcoded responses, requires no API key
+import { BaseAIAdapter } from "../BaseAIAdapter";
+import { AIContext, AIResponse, Message } from "../types";
+
+export class MockAdapter extends BaseAIAdapter {
+ name = "Mock (Demo)";
+
+ async sendPrompt(_context: AIContext, message: string): Promise {
+ return {
+ text: `Mock response to: "${message}"`,
+ suggestions: ["Sure!", "Let me check", "Can you tell me more?"],
+ };
+ }
+
+ async getSuggestions(_conversation: Message[]): Promise {
+ return ["Sure!", "Let me check that", "Can you tell me more?"];
+ }
+
+ async isAvailable(): Promise {
+ return true;
+ }
+}
diff --git a/packages/ai-adapter/src/adapters/OllamaAdapter.ts b/packages/ai-adapter/src/adapters/OllamaAdapter.ts
new file mode 100644
index 0000000000..77121a63de
--- /dev/null
+++ b/packages/ai-adapter/src/adapters/OllamaAdapter.ts
@@ -0,0 +1,94 @@
+import { BaseAIAdapter } from "../BaseAIAdapter";
+import { AIContext, AIResponse } from "../types";
+
+interface OllamaConfig {
+ baseUrl?: string;
+ model?: string;
+ headers?: Record;
+ assistantUsername?: string;
+}
+
+export class OllamaAdapter extends BaseAIAdapter {
+ name = "Ollama";
+ private config: Required;
+
+ constructor(config: OllamaConfig = {}) {
+ super();
+ this.config = {
+ baseUrl: "http://localhost:11434",
+ model: "llama3",
+ headers: {},
+ assistantUsername: "",
+ ...config,
+ };
+ }
+
+ async sendPrompt(context: AIContext, message: string): Promise {
+ const systemPrompt = `You are a helpful assistant in a chat room.${
+ context.metadata?.federated ? " This is a federated Matrix room." : ""
+ } Keep responses concise.`;
+
+ const history = context.history.slice(-10);
+ const chatMessages: Array<{
+ role: "system" | "user" | "assistant";
+ content: string;
+ }> = [{ role: "system", content: systemPrompt }];
+
+ for (const m of history) {
+ const role =
+ this.config.assistantUsername &&
+ m.u.username === this.config.assistantUsername
+ ? "assistant"
+ : "user";
+ const content = `${m.u.username}: ${m.msg}`;
+
+ const lastMsg = chatMessages[chatMessages.length - 1];
+ if (lastMsg && lastMsg.role === role) {
+ lastMsg.content += `\n${content}`;
+ } else {
+ chatMessages.push({ role, content });
+ }
+ }
+
+ const lastMsg = chatMessages[chatMessages.length - 1];
+ if (lastMsg && lastMsg.role === "user") {
+ lastMsg.content += `\n${message}`;
+ } else {
+ chatMessages.push({ role: "user", content: message });
+ }
+
+ const base = this.config.baseUrl.replace(/\/$/, "");
+ const res = await fetch(`${base}/api/chat`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...this.config.headers,
+ },
+ body: JSON.stringify({
+ model: this.config.model,
+ messages: chatMessages,
+ stream: false,
+ }),
+ });
+
+ if (!res.ok) {
+ throw new Error(`Ollama API error: ${res.status}`);
+ }
+
+ const data = await res.json();
+ const text = data.message?.content ?? "";
+ return { text };
+ }
+
+ async isAvailable(): Promise {
+ try {
+ const base = this.config.baseUrl.replace(/\/$/, "");
+ const res = await fetch(`${base}/api/tags`, {
+ headers: this.config.headers,
+ });
+ return res.ok;
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/packages/ai-adapter/src/adapters/OpenAIAdapter.ts b/packages/ai-adapter/src/adapters/OpenAIAdapter.ts
new file mode 100644
index 0000000000..778e1671b3
--- /dev/null
+++ b/packages/ai-adapter/src/adapters/OpenAIAdapter.ts
@@ -0,0 +1,106 @@
+import { BaseAIAdapter } from "../BaseAIAdapter";
+import { AIContext, AIResponse } from "../types";
+
+interface OpenAIConfig {
+ apiKey?: string;
+ model?: string;
+ maxTokens?: number;
+ baseUrl?: string;
+ headers?: Record;
+ assistantUsername?: string;
+}
+
+export class OpenAIAdapter extends BaseAIAdapter {
+ name = "OpenAI";
+ private config: Required;
+
+ constructor(config: OpenAIConfig) {
+ super();
+ this.config = {
+ apiKey: "",
+ model: "gpt-4o",
+ maxTokens: 500,
+ baseUrl: "https://api.openai.com/v1",
+ headers: {},
+ assistantUsername: "",
+ ...config,
+ };
+ }
+
+ async sendPrompt(context: AIContext, message: string): Promise {
+ const systemPrompt = `You are a helpful assistant in a chat room.${
+ context.metadata?.federated ? " This is a federated Matrix room." : ""
+ } Keep responses concise.`;
+
+ const history = context.history.slice(-10);
+ const chatMessages: Array<{
+ role: "system" | "user" | "assistant";
+ content: string;
+ }> = [{ role: "system", content: systemPrompt }];
+
+ for (const m of history) {
+ const role =
+ this.config.assistantUsername &&
+ m.u.username === this.config.assistantUsername
+ ? "assistant"
+ : "user";
+ const content = `${m.u.username}: ${m.msg}`;
+
+ const lastMsg = chatMessages[chatMessages.length - 1];
+ if (lastMsg && lastMsg.role === role) {
+ lastMsg.content += `\n${content}`;
+ } else {
+ chatMessages.push({ role, content });
+ }
+ }
+
+ const lastMsg = chatMessages[chatMessages.length - 1];
+ if (lastMsg && lastMsg.role === "user") {
+ lastMsg.content += `\n${message}`;
+ } else {
+ chatMessages.push({ role: "user", content: message });
+ }
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...this.config.headers,
+ };
+
+ if (this.config.apiKey) {
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
+ }
+
+ const res = await fetch(`${this.config.baseUrl}/chat/completions`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ model: this.config.model,
+ messages: chatMessages,
+ max_tokens: this.config.maxTokens,
+ }),
+ });
+
+ if (!res.ok) {
+ throw new Error(`OpenAI API error: ${res.status}`);
+ }
+
+ const data = await res.json();
+ const text = data.choices[0]?.message?.content ?? "";
+ return { text };
+ }
+
+ async isAvailable(): Promise {
+ try {
+ const headers: Record = {
+ ...this.config.headers,
+ };
+ if (this.config.apiKey) {
+ headers["Authorization"] = `Bearer ${this.config.apiKey}`;
+ }
+ const res = await fetch(`${this.config.baseUrl}/models`, { headers });
+ return res.ok;
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/packages/ai-adapter/src/index.ts b/packages/ai-adapter/src/index.ts
new file mode 100644
index 0000000000..b032777157
--- /dev/null
+++ b/packages/ai-adapter/src/index.ts
@@ -0,0 +1,6 @@
+export type { IAIAdapter, AIContext, AIResponse, Message } from "./types";
+export { BaseAIAdapter } from "./BaseAIAdapter";
+export { OpenAIAdapter } from "./adapters/OpenAIAdapter";
+export { OllamaAdapter } from "./adapters/OllamaAdapter";
+export { GeminiAdapter } from "./adapters/GeminiAdapter";
+export { MockAdapter } from "./adapters/MockAdapter";
diff --git a/packages/ai-adapter/src/types.ts b/packages/ai-adapter/src/types.ts
new file mode 100644
index 0000000000..7e40e9afa3
--- /dev/null
+++ b/packages/ai-adapter/src/types.ts
@@ -0,0 +1,31 @@
+export interface Message {
+ _id: string;
+ msg: string;
+ u: { _id: string; username: string; name?: string };
+ ts: Date;
+}
+
+export interface AIContext {
+ roomId: string;
+ userId: string;
+ history: Message[];
+ metadata?: {
+ federated?: boolean;
+ };
+}
+
+export interface AIResponse {
+ text: string;
+ suggestions?: string[];
+}
+
+export interface IAIAdapter {
+ name: string;
+ sendPrompt(context: AIContext, message: string): Promise;
+ getSuggestions?(
+ conversation: Message[],
+ context?: AIContext
+ ): Promise;
+ summarize?(messages: Message[], context?: AIContext): Promise;
+ isAvailable(): Promise;
+}
diff --git a/packages/ai-adapter/tsconfig.json b/packages/ai-adapter/tsconfig.json
new file mode 100644
index 0000000000..c272a9b104
--- /dev/null
+++ b/packages/ai-adapter/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "declaration": true,
+ "declarationDir": "dist",
+ "outDir": "dist",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/react/package.json b/packages/react/package.json
index 49e21b50ad..8b6a05c941 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -31,6 +31,7 @@
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
+ "@embeddedchat/ai-adapter": "workspace:*",
"@emotion/babel-plugin": "^11.11.0",
"@open-wc/building-rollup": "^3.0.2",
"@rollup/plugin-babel": "^5.3.1",
diff --git a/packages/react/src/stories/WithAIAdapter.stories.js b/packages/react/src/stories/WithAIAdapter.stories.js
new file mode 100644
index 0000000000..3187373203
--- /dev/null
+++ b/packages/react/src/stories/WithAIAdapter.stories.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import { OllamaAdapter } from '@embeddedchat/ai-adapter';
+import { EmbeddedChat } from '..';
+
+const OLLAMA_BASE_URL =
+ process.env.STORYBOOK_OLLAMA_URL || 'http://localhost:11434';
+const OLLAMA_MODEL = process.env.STORYBOOK_OLLAMA_MODEL || 'llama3.2:1b';
+
+export default {
+ title: 'EmbeddedChat/WithAIAdapter',
+ component: EmbeddedChat,
+};
+
+export const WithAIAdapter = {
+ loaders: [
+ async () => ({
+ adapter: new OllamaAdapter({
+ baseUrl: OLLAMA_BASE_URL,
+ model: OLLAMA_MODEL,
+ }),
+ }),
+ ],
+ render: (args, { loaded }) =>
+ React.createElement(EmbeddedChat, { ...args, aiAdapter: loaded.adapter }),
+ args: {
+ host: process.env.STORYBOOK_RC_HOST || 'http://localhost:3000',
+ roomId: process.env.RC_ROOM_ID || 'GENERAL',
+ channelName: 'general',
+ anonymousMode: false,
+ toastBarPosition: 'bottom right',
+ showRoles: true,
+ enableThreads: true,
+ auth: { flow: 'PASSWORD' },
+ dark: false,
+ },
+};
diff --git a/packages/react/src/views/ChatInput/ChatInput.js b/packages/react/src/views/ChatInput/ChatInput.js
index 0a8d0d7cd8..6f9c27c5e7 100644
--- a/packages/react/src/views/ChatInput/ChatInput.js
+++ b/packages/react/src/views/ChatInput/ChatInput.js
@@ -41,6 +41,7 @@ import useDropBox from '../../hooks/useDropBox';
const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
const { styleOverrides, classNames } = useComponentOverrides('ChatInput');
const { RCInstance, ECOptions } = useRCContext();
+ const aiAdapter = ECOptions?.aiAdapter ?? null;
const { theme } = useTheme();
const styles = getChatInputStyles(theme);
@@ -64,6 +65,12 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
const [emojiIndex, setEmojiIndex] = useState(-1);
const [startReadEmoji, setStartReadEmoji] = useState(false);
const [isMsgLong, setIsMsgLong] = useState(false);
+ const [aiSuggestions, setAiSuggestions] = useState([]);
+ const [isFetchingSuggestions, setIsFetchingSuggestions] = useState(false);
+ const [summary, setSummary] = useState('');
+ const [showSummary, setShowSummary] = useState(false);
+ const [isSummarizing, setIsSummarizing] = useState(false);
+ const [isAiAvailable, setIsAiAvailable] = useState(false);
const {
isUserAuthenticated,
@@ -173,6 +180,17 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
.catch(console.error);
}, [RCInstance, isUserAuthenticated, isChannelPrivate, setMembersHandler]);
+ useEffect(() => {
+ if (!aiAdapter) {
+ setIsAiAvailable(false);
+ return;
+ }
+ aiAdapter
+ .isAvailable()
+ .then(setIsAiAvailable)
+ .catch(() => setIsAiAvailable(false));
+ }, [aiAdapter]);
+
useEffect(() => {
if (editMessage.attachments) {
messageRef.current.value =
@@ -346,6 +364,28 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
if (res?.success) {
clearQuoteMessages();
replaceMessage(pendingMessage._id, res.message);
+
+ if (aiAdapter && ECOptions.aiAutoReply) {
+ const { messages: currentMessages } = useMessageStore.getState();
+ const aiContext = {
+ roomId: ECOptions.roomId,
+ userId,
+ history: currentMessages.slice(-20),
+ };
+ aiAdapter
+ .sendPrompt(aiContext, pendingMessage.msg)
+ .then((response) => {
+ if (response?.text) {
+ RCInstance.sendMessage(
+ { msg: response.text },
+ ECOptions.enableThreads ? threadId : undefined
+ ).catch(() => {});
+ }
+ })
+ .catch((e) => {
+ console.error('[AI Adapter] sendPrompt failed:', e);
+ });
+ }
} else {
// If REST send failed, remove the pending message so it doesn't stay grey
removeMessage(pendingMessage._id);
@@ -413,12 +453,76 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
handleSendNewMessage(message);
scrollToBottom();
+ setAiSuggestions([]);
// Clear unread divider when user sends a message
if (clearUnreadDividerRef?.current) {
clearUnreadDividerRef.current();
}
};
+ useEffect(() => {
+ if (!isUserAuthenticated) {
+ setAiSuggestions([]);
+ setSummary('');
+ setShowSummary(false);
+ }
+ }, [isUserAuthenticated]);
+
+ const handleGetSuggestions = async () => {
+ if (!aiAdapter || isFetchingSuggestions) return;
+ setIsFetchingSuggestions(true);
+ try {
+ const { messages } = useMessageStore.getState();
+ const aiContext = {
+ roomId: ECOptions.roomId,
+ userId,
+ history: messages.slice(-10),
+ };
+ const suggestions = aiAdapter.getSuggestions
+ ? await aiAdapter.getSuggestions(messages.slice(-10), aiContext)
+ : [];
+ setAiSuggestions(suggestions);
+ } catch (e) {
+ console.error('[AI Adapter] getSuggestions failed:', e);
+ dispatchToastMessage({
+ type: 'error',
+ message: 'Failed to generate suggestions. Please check your settings.',
+ });
+ } finally {
+ setIsFetchingSuggestions(false);
+ }
+ };
+
+ const handleSuggestionClick = (suggestion) => {
+ messageRef.current.value = suggestion;
+ setDisableButton(false);
+ setAiSuggestions([]);
+ messageRef.current.focus();
+ };
+
+ const handleSummarize = async () => {
+ if (!aiAdapter?.summarize || isSummarizing) return;
+ setIsSummarizing(true);
+ try {
+ const { messages } = useMessageStore.getState();
+ const result = await aiAdapter.summarize(messages, {
+ roomId: ECOptions.roomId,
+ userId,
+ history: messages.slice(-20),
+ });
+ setSummary(result);
+ setShowSummary(true);
+ } catch (e) {
+ console.error('[AI Adapter] summarize failed:', e);
+ dispatchToastMessage({
+ type: 'error',
+ message: 'Failed to generate summary. Please check your settings.',
+ });
+ } finally {
+ setIsSummarizing(false);
+ }
+ };
+
const sendAttachment = (event) => {
const fileObj = event.target.files && event.target.files[0];
if (!fileObj) {
@@ -655,6 +759,21 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
+ {aiSuggestions.length > 0 && (
+
+ {aiSuggestions.map((s) => (
+
+ ))}
+
+ )}
{
/>
-
+
+ {isAiAvailable && isUserAuthenticated && !isChannelArchived && (
+
+ {isFetchingSuggestions ? : '\u2728'}
+
+ )}
+ {isAiAvailable &&
+ aiAdapter?.summarize &&
+ isUserAuthenticated &&
+ !isChannelArchived && (
+
+ {isSummarizing ? : '\ud83d\udcdd'}
+
+ )}
{isUserAuthenticated ? (
!isChannelArchived ? (
{
/>
)}
+ {showSummary && (
+ setShowSummary(false)}>
+
+ 📝 Chat Summary
+ setShowSummary(false)} />
+
+
+ {summary}
+
+
+
+
+
+ )}
{isMsgLong && (
setIsMsgLong(false)}
>
@@ -748,11 +906,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
setIsMsgLong(false)} />
-
+
Send it as attachment instead?
diff --git a/packages/react/src/views/ChatInput/ChatInput.styles.js b/packages/react/src/views/ChatInput/ChatInput.styles.js
index 0841451324..76765d13f8 100644
--- a/packages/react/src/views/ChatInput/ChatInput.styles.js
+++ b/packages/react/src/views/ChatInput/ChatInput.styles.js
@@ -66,6 +66,46 @@ export const getChatInputStyles = (theme) => {
max-height: 300px;
overflow: scroll;
`,
+
+ aiSuggestionsContainer: css`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ padding: 0.4rem 1rem 0;
+ `,
+
+ aiSuggestionChip: css`
+ font-size: 0.8rem;
+ padding: 0.2rem 0.6rem;
+ border-radius: 1rem;
+ cursor: pointer;
+ `,
+
+ aiActionButton: css`
+ font-size: 1rem;
+ `,
+
+ actionButtonsContainer: css`
+ padding: 0.25rem;
+ `,
+
+ summaryModal: css`
+ padding: 1em;
+ `,
+
+ summaryModalContent: css`
+ margin: 1em;
+ white-space: pre-wrap;
+ line-height: 1.6;
+ `,
+
+ longMessageModal: css`
+ padding: 1em;
+ `,
+
+ longMessageModalContent: css`
+ margin: 1em;
+ `,
};
return styles;
diff --git a/packages/react/src/views/EmbeddedChat.js b/packages/react/src/views/EmbeddedChat.js
index a15cdbccd1..66ccebf6f5 100644
--- a/packages/react/src/views/EmbeddedChat.js
+++ b/packages/react/src/views/EmbeddedChat.js
@@ -58,6 +58,7 @@ const EmbeddedChat = (props) => {
secure = false,
dark = false,
remoteOpt = false,
+ aiAutoReply = false,
} = config;
const auth = useMemo(
@@ -66,6 +67,8 @@ const EmbeddedChat = (props) => {
[authProp?.flow, authProp?.credentials]
);
+ const aiAdapter = props.aiAdapter ?? null;
+
const hasMounted = useRef(false);
const { classNames, styleOverrides } = useComponentOverrides('EmbeddedChat');
const [fullScreen, setFullScreen] = useState(false);
@@ -227,6 +230,8 @@ const EmbeddedChat = (props) => {
}
}, [RCInstance, remoteOpt, setIsSynced]);
+ const memoizedAiAdapter = useMemo(() => aiAdapter, [aiAdapter]);
+
const ECOptions = useMemo(
() => ({
enableThreads,
@@ -243,6 +248,8 @@ const EmbeddedChat = (props) => {
showUsername,
hideHeader,
anonymousMode,
+ aiAdapter: memoizedAiAdapter,
+ aiAutoReply,
}),
[
enableThreads,
@@ -259,6 +266,8 @@ const EmbeddedChat = (props) => {
showUsername,
hideHeader,
anonymousMode,
+ memoizedAiAdapter,
+ aiAutoReply,
]
);
@@ -333,6 +342,14 @@ EmbeddedChat.propTypes = {
style: PropTypes.object,
hideHeader: PropTypes.bool,
dark: PropTypes.bool,
+ aiAdapter: PropTypes.shape({
+ name: PropTypes.string,
+ sendPrompt: PropTypes.func.isRequired,
+ getSuggestions: PropTypes.func,
+ summarize: PropTypes.func,
+ isAvailable: PropTypes.func.isRequired,
+ }),
+ aiAutoReply: PropTypes.bool,
};
export default memo(EmbeddedChat);
diff --git a/yarn.lock b/yarn.lock
index 283b25610e..b21c03ea22 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2437,6 +2437,18 @@ __metadata:
languageName: node
linkType: hard
+"@embeddedchat/ai-adapter@workspace:*, @embeddedchat/ai-adapter@workspace:packages/ai-adapter":
+ version: 0.0.0-use.local
+ resolution: "@embeddedchat/ai-adapter@workspace:packages/ai-adapter"
+ dependencies:
+ prettier: ^2.8.1
+ rollup: ^3.23.0
+ rollup-plugin-dts: ^6.0.1
+ rollup-plugin-esbuild: ^5.0.0
+ typescript: ^5.0.0
+ languageName: unknown
+ linkType: soft
+
"@embeddedchat/api@workspace:^, @embeddedchat/api@workspace:packages/api":
version: 0.0.0-use.local
resolution: "@embeddedchat/api@workspace:packages/api"
@@ -2600,6 +2612,7 @@ __metadata:
"@babel/plugin-proposal-private-property-in-object": ^7.21.11
"@babel/preset-env": ^7.16.11
"@babel/preset-react": ^7.16.7
+ "@embeddedchat/ai-adapter": "workspace:*"
"@embeddedchat/api": "workspace:^"
"@embeddedchat/markups": "workspace:^"
"@embeddedchat/ui-elements": "workspace:^"
@@ -29735,6 +29748,16 @@ __metadata:
languageName: node
linkType: hard
+"typescript@npm:^5.0.0":
+ version: 5.9.3
+ resolution: "typescript@npm:5.9.3"
+ bin:
+ tsc: bin/tsc
+ tsserver: bin/tsserver
+ checksum: 0d0ffb84f2cd072c3e164c79a2e5a1a1f4f168e84cb2882ff8967b92afe1def6c2a91f6838fb58b168428f9458c57a2ba06a6737711fdd87a256bbe83e9a217f
+ languageName: node
+ linkType: hard
+
"typescript@npm:^5.1.3":
version: 5.3.2
resolution: "typescript@npm:5.3.2"
@@ -29775,6 +29798,16 @@ __metadata:
languageName: node
linkType: hard
+"typescript@patch:typescript@^5.0.0#~builtin":
+ version: 5.9.3
+ resolution: "typescript@patch:typescript@npm%3A5.9.3#~builtin::version=5.9.3&hash=29ae49"
+ bin:
+ tsc: bin/tsc
+ tsserver: bin/tsserver
+ checksum: 8bb8d86819ac86a498eada254cad7fb69c5f74778506c700c2a712daeaff21d3a6f51fd0d534fe16903cb010d1b74f89437a3d02d4d0ff5ca2ba9a4660de8497
+ languageName: node
+ linkType: hard
+
"typescript@patch:typescript@^5.1.3#~builtin":
version: 5.3.2
resolution: "typescript@patch:typescript@npm%3A5.3.2#~builtin::version=5.3.2&hash=29ae49"