From bf8da90659b36ef9c38bfb94c74065a84c87a689 Mon Sep 17 00:00:00 2001 From: Aryan-Verma-999 Date: Thu, 25 Jun 2026 11:56:09 +0530 Subject: [PATCH 1/2] feat(ai-adapter): add @embeddedchat/ai-adapter package --- packages/ai-adapter/.gitignore | 3 + packages/ai-adapter/README.md | 149 ++++++++++++++++++ packages/ai-adapter/package.json | 32 ++++ packages/ai-adapter/rollup.config.js | 32 ++++ packages/ai-adapter/src/BaseAIAdapter.ts | 58 +++++++ .../ai-adapter/src/adapters/GeminiAdapter.ts | 114 ++++++++++++++ .../ai-adapter/src/adapters/MockAdapter.ts | 22 +++ .../ai-adapter/src/adapters/OllamaAdapter.ts | 94 +++++++++++ .../ai-adapter/src/adapters/OpenAIAdapter.ts | 106 +++++++++++++ packages/ai-adapter/src/index.ts | 6 + packages/ai-adapter/src/types.ts | 31 ++++ packages/ai-adapter/tsconfig.json | 15 ++ 12 files changed, 662 insertions(+) create mode 100644 packages/ai-adapter/.gitignore create mode 100644 packages/ai-adapter/README.md create mode 100644 packages/ai-adapter/package.json create mode 100644 packages/ai-adapter/rollup.config.js create mode 100644 packages/ai-adapter/src/BaseAIAdapter.ts create mode 100644 packages/ai-adapter/src/adapters/GeminiAdapter.ts create mode 100644 packages/ai-adapter/src/adapters/MockAdapter.ts create mode 100644 packages/ai-adapter/src/adapters/OllamaAdapter.ts create mode 100644 packages/ai-adapter/src/adapters/OpenAIAdapter.ts create mode 100644 packages/ai-adapter/src/index.ts create mode 100644 packages/ai-adapter/src/types.ts create mode 100644 packages/ai-adapter/tsconfig.json diff --git a/packages/ai-adapter/.gitignore b/packages/ai-adapter/.gitignore new file mode 100644 index 000000000..d4d7e3f61 --- /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 000000000..018c54dd1 --- /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 000000000..8e23be582 --- /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 000000000..eedda5a44 --- /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 000000000..4c0403a24 --- /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 000000000..dfec24cc4 --- /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 000000000..b713e707e --- /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 000000000..77121a63d --- /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 000000000..778e1671b --- /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 000000000..b03277715 --- /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 000000000..7e40e9afa --- /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 000000000..c272a9b10 --- /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"] +} From ea24bcb40cbe6d546ca97257b1006bf37ffd4268 Mon Sep 17 00:00:00 2001 From: Aryan-Verma-999 Date: Thu, 25 Jun 2026 11:56:25 +0530 Subject: [PATCH 2/2] feat(react): integrate AI adapter into EmbeddedChat --- packages/react/package.json | 1 + .../src/stories/WithAIAdapter.stories.js | 36 ++++ .../react/src/views/ChatInput/ChatInput.js | 180 ++++++++++++++++-- .../src/views/ChatInput/ChatInput.styles.js | 40 ++++ packages/react/src/views/EmbeddedChat.js | 17 ++ yarn.lock | 33 ++++ 6 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 packages/react/src/stories/WithAIAdapter.stories.js diff --git a/packages/react/package.json b/packages/react/package.json index 49e21b50a..8b6a05c94 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 000000000..318737320 --- /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 0a8d0d7cd..6f9c27c5e 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 084145132..76765d13f 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 a15cdbccd..66ccebf6f 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 283b25610..b21c03ea2 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"