From 6cd9564daad2b61efa2a8f26f8701e47226d0b74 Mon Sep 17 00:00:00 2001 From: Aryan-Verma-999 Date: Thu, 23 Jul 2026 01:10:49 +0530 Subject: [PATCH 1/5] feat(ai-adapter): add pluggable 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 | 98 ++++++++++++ .../ai-adapter/src/adapters/GeminiAdapter.ts | 114 ++++++++++++++ .../ai-adapter/src/adapters/MockAdapter.ts | 18 +++ .../ai-adapter/src/adapters/OllamaAdapter.ts | 72 +++++++++ .../ai-adapter/src/adapters/OpenAIAdapter.ts | 86 ++++++++++ packages/ai-adapter/src/index.ts | 6 + packages/ai-adapter/src/types.ts | 31 ++++ packages/ai-adapter/tsconfig.json | 15 ++ packages/react/package.json | 1 + yarn.lock | 33 ++++ 14 files changed, 690 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..aede6e902 --- /dev/null +++ b/packages/ai-adapter/src/BaseAIAdapter.ts @@ -0,0 +1,98 @@ +import { IAIAdapter, AIContext, AIResponse, Message } from "./types"; + +type ChatMessage = { + role: "system" | "user" | "assistant"; + content: string; +}; + +export abstract class BaseAIAdapter implements IAIAdapter { + abstract name: string; + abstract sendPrompt(context: AIContext, message: string): Promise; + abstract isAvailable(): Promise; + + protected buildChatMessages( + context: AIContext, + message: string, + systemPrompt: string, + assistantUsername = "" + ): ChatMessage[] { + const chatMessages: ChatMessage[] = [ + { role: "system", content: systemPrompt }, + ]; + + for (const item of context.history.slice(-10)) { + const role = + assistantUsername && item.u.username === assistantUsername + ? "assistant" + : "user"; + const content = `${item.u.username}: ${item.msg}`; + const lastMessage = chatMessages[chatMessages.length - 1]; + + if (lastMessage.role === role) { + lastMessage.content += `\n${content}`; + } else { + chatMessages.push({ role, content }); + } + } + + const lastMessage = chatMessages[chatMessages.length - 1]; + if (lastMessage.role === "user") { + lastMessage.content += `\n${message}`; + } else { + chatMessages.push({ role: "user", content: message }); + } + + return chatMessages; + } + + 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..28638a739 --- /dev/null +++ b/packages/ai-adapter/src/adapters/MockAdapter.ts @@ -0,0 +1,18 @@ +// For testing/demo only — returns hardcoded responses, requires no API key +import { BaseAIAdapter } from "../BaseAIAdapter"; +import { AIContext, AIResponse } 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 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..287d47dda --- /dev/null +++ b/packages/ai-adapter/src/adapters/OllamaAdapter.ts @@ -0,0 +1,72 @@ +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 chatMessages = this.buildChatMessages( + context, + message, + systemPrompt, + this.config.assistantUsername + ); + + 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..a92fda68a --- /dev/null +++ b/packages/ai-adapter/src/adapters/OpenAIAdapter.ts @@ -0,0 +1,86 @@ +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 chatMessages = this.buildChatMessages( + context, + message, + systemPrompt, + this.config.assistantUsername + ); + + const headers: Record = { + "Content-Type": "application/json", + ...this.config.headers, + }; + + if (this.config.apiKey) { + headers["Authorization"] = `Bearer ${this.config.apiKey}`; + } + + const base = this.config.baseUrl.replace(/\/$/, ""); + const res = await fetch(`${base}/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 base = this.config.baseUrl.replace(/\/$/, ""); + const res = await fetch(`${base}/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"] +} 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/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" From 38b5b5b1aefcff4d010472bff21e64e31f3acad7 Mon Sep 17 00:00:00 2001 From: Aryan-Verma-999 Date: Thu, 23 Jul 2026 01:10:57 +0530 Subject: [PATCH 2/5] feat(react): add AI composer and shared AI state --- packages/react/src/hooks/useAIComposer.js | 190 ++++++++++++++ packages/react/src/store/aiStore.js | 15 ++ packages/react/src/store/index.js | 1 + .../AIComposerToolbar/AIComposerToolbar.js | 136 ++++++++++ .../AIComposerToolbar.styles.js | 181 +++++++++++++ .../src/views/AIComposerToolbar/index.js | 1 + .../react/src/views/ChatInput/ChatInput.js | 244 ++++++++++++++++-- .../src/views/ChatInput/ChatInput.styles.js | 40 +++ packages/react/src/views/EmbeddedChat.js | 17 ++ 9 files changed, 810 insertions(+), 15 deletions(-) create mode 100644 packages/react/src/hooks/useAIComposer.js create mode 100644 packages/react/src/store/aiStore.js create mode 100644 packages/react/src/views/AIComposerToolbar/AIComposerToolbar.js create mode 100644 packages/react/src/views/AIComposerToolbar/AIComposerToolbar.styles.js create mode 100644 packages/react/src/views/AIComposerToolbar/index.js diff --git a/packages/react/src/hooks/useAIComposer.js b/packages/react/src/hooks/useAIComposer.js new file mode 100644 index 000000000..bff929608 --- /dev/null +++ b/packages/react/src/hooks/useAIComposer.js @@ -0,0 +1,190 @@ +import { useState, useCallback, useRef } from 'react'; + +// ─── Actions ────────────────────────────────────────────────────────────────── +// Split into two rows for the toolbar UI +const ACTIONS = [ + { key: 'grammar', label: '🛠️ Grammar', group: 1 }, + { key: 'spelling', label: '✏️ Spelling', group: 1 }, + { key: 'rephrase', label: '✨ Rephrase', group: 1 }, + { key: 'match_tone', label: '🎯 Match Tone', group: 1 }, + { key: 'formal', label: '💼 Formal', group: 2 }, + { key: 'casual', label: '😊 Casual', group: 2 }, + { key: 'shorten', label: '✂️ Shorten', group: 2 }, + { key: 'expand', label: '📖 Expand', group: 2 }, + { key: 'emojify', label: '😄 Add Emojis', group: 2 }, + { key: 'translate', label: '🌐 Translate', group: 2 }, +]; + +// ─── Prompt builders ─────────────────────────────────────────────────────────── +const historySnippet = (messages = []) => { + if (!messages.length) return ''; + const recent = messages + .slice(-8) + .filter((m) => m.msg) + .map((m) => `- ${m.msg}`) + .join('\n'); + return recent + ? `\n\nRecent messages in this channel for context:\n${recent}\n` + : ''; +}; + +const PROMPTS = { + grammar: (t) => + `Fix all grammar mistakes in the following text. Keep the original meaning and style. Return ONLY the corrected text, no explanation.\n\nText: ${t}`, + + spelling: (t, msgs) => + `Correct any spelling errors in the following text.${historySnippet( + msgs + )}Use the conversation context above to correctly identify technical terms, proper nouns, and domain-specific vocabulary. Return ONLY the corrected text, no explanation.\n\nText: ${t}`, + + rephrase: (t) => + `Rephrase the following for clarity and natural flow. Eliminate jargon unless it is domain-appropriate. Return ONLY the rephrased text, no explanation.\n\nText: ${t}`, + + match_tone: (t, msgs) => { + const ctx = historySnippet(msgs); + if (!ctx) { + return `Rephrase the following in a conversational, natural tone. Return ONLY the rephrased text, no explanation.\n\nText: ${t}`; + } + return `Analyze the tone, vocabulary, and writing style of the recent messages below, then rewrite the given text to match that style exactly.${ctx}Rewrite in the same tone and style. Return ONLY the rewritten text, no explanation.\n\nText: ${t}`; + }, + + formal: (t) => + `Rewrite the following in a professional and formal tone. Return ONLY the rewritten text, no explanation.\n\nText: ${t}`, + + casual: (t) => + `Rewrite the following in a friendly, conversational tone. Return ONLY the rewritten text, no explanation.\n\nText: ${t}`, + + shorten: (t) => + `Summarize the following into one concise sentence without losing the key point. Return ONLY the shortened text, no explanation.\n\nText: ${t}`, + + expand: (t) => + `Elaborate the following with more relevant detail and context. Return ONLY the expanded text, no explanation.\n\nText: ${t}`, + + emojify: (t) => + `Add relevant, expressive emojis to the following message. Place them naturally within or at the end of sentences — do not overdo it. Return ONLY the emojified text, no explanation.\n\nText: ${t}`, + + translate: (t) => + `Translate the following to English. Return ONLY the translated text, no explanation.\n\nText: ${t}`, +}; + +// ─── Response cleaner ────────────────────────────────────────────────────────── +const cleanResponse = (text) => + text + .replace( + /^(sure[!,.]?|here('s| is)[^:]*:|of course[!,.]?|absolutely[!,.]?)\s*/i, + '' + ) + .replace(/^["""'`]|["""'`]$/g, '') + .trim(); + +// ─── Hook ───────────────────────────────────────────────────────────────────── +const useAIComposer = ({ + aiAdapter, + ECOptions, + userId, + messageRef, + messages = [], +}) => { + const [showToolbar, setShowToolbar] = useState(false); + const [suggestion, setSuggestion] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [activeAction, setActiveAction] = useState(null); + const selectionRef = useRef({ start: 0, end: 0, text: '' }); + + const handleMouseUp = useCallback(() => { + if (!aiAdapter || !messageRef.current) return; + const { selectionStart, selectionEnd, value } = messageRef.current; + const selected = value.slice(selectionStart, selectionEnd).trim(); + if (selected.length < 2) { + setShowToolbar(false); + return; + } + selectionRef.current = { + start: selectionStart, + end: selectionEnd, + text: selected, + }; + setShowToolbar(true); + setSuggestion(null); + }, [aiAdapter, messageRef]); + + const handleKeyUp = useCallback(() => { + if (!aiAdapter || !messageRef.current) return; + const { selectionStart, selectionEnd, value } = messageRef.current; + const selected = value.slice(selectionStart, selectionEnd).trim(); + if (selected.length < 2) setShowToolbar(false); + }, [aiAdapter, messageRef]); + + const runAction = useCallback( + async (actionKey) => { + const { text, start, end } = selectionRef.current; + if (!text || !aiAdapter || isProcessing) return; + setIsProcessing(true); + setActiveAction(actionKey); + setShowToolbar(false); + try { + const aiContext = { + roomId: ECOptions?.roomId ?? '', + userId, + history: [], + }; + const prompt = PROMPTS[actionKey](text, messages); + const response = await aiAdapter.sendPrompt(aiContext, prompt); + if (response?.text) { + setSuggestion({ + text: cleanResponse(response.text), + selStart: start, + selEnd: end, + original: text, + actionKey, + }); + } + } catch (e) { + console.error('[AI Composer] action failed:', e); + } finally { + setIsProcessing(false); + setActiveAction(null); + } + }, + [aiAdapter, ECOptions, userId, messages, isProcessing] + ); + + const acceptSuggestion = useCallback(() => { + if (!suggestion || !messageRef.current) return; + const { value } = messageRef.current; + const newValue = + value.slice(0, suggestion.selStart) + + suggestion.text + + value.slice(suggestion.selEnd); + messageRef.current.value = newValue; + const newCursor = suggestion.selStart + suggestion.text.length; + messageRef.current.setSelectionRange(newCursor, newCursor); + messageRef.current.focus(); + setSuggestion(null); + }, [suggestion, messageRef]); + + const rejectSuggestion = useCallback(() => { + setSuggestion(null); + messageRef.current?.focus(); + }, [messageRef]); + + const dismissToolbar = useCallback(() => { + setShowToolbar(false); + }, []); + + return { + showToolbar, + suggestion, + isProcessing, + activeAction, + actions: ACTIONS, + handleMouseUp, + handleKeyUp, + runAction, + acceptSuggestion, + rejectSuggestion, + dismissToolbar, + }; +}; + +export default useAIComposer; diff --git a/packages/react/src/store/aiStore.js b/packages/react/src/store/aiStore.js new file mode 100644 index 000000000..bf5ac8be6 --- /dev/null +++ b/packages/react/src/store/aiStore.js @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +const useAiStore = create((set) => ({ + isAiTyping: false, + setIsAiTyping: (isAiTyping) => set(() => ({ isAiTyping })), + + threadSummary: '', + showThreadSummary: false, + setThreadSummary: (threadSummary) => + set(() => ({ threadSummary, showThreadSummary: true })), + closeThreadSummary: () => + set(() => ({ showThreadSummary: false, threadSummary: '' })), +})); + +export default useAiStore; diff --git a/packages/react/src/store/index.js b/packages/react/src/store/index.js index bddd0bd1d..c5f0c0018 100644 --- a/packages/react/src/store/index.js +++ b/packages/react/src/store/index.js @@ -11,3 +11,4 @@ export { default as useMentionsStore } from './mentionsStore'; export { default as usePinnedMessageStore } from './pinnedMessageStore'; export { default as useStarredMessageStore } from './starredMessageStore'; export { default as useSidebarStore } from './sidebarStore'; +export { default as useAiStore } from './aiStore'; diff --git a/packages/react/src/views/AIComposerToolbar/AIComposerToolbar.js b/packages/react/src/views/AIComposerToolbar/AIComposerToolbar.js new file mode 100644 index 000000000..e85b2bca9 --- /dev/null +++ b/packages/react/src/views/AIComposerToolbar/AIComposerToolbar.js @@ -0,0 +1,136 @@ +import React from 'react'; +import { Box, useTheme } from '@embeddedchat/ui-elements'; +import { getAIComposerStyles } from './AIComposerToolbar.styles'; + +const ACTION_LABELS = { + grammar: 'grammar', + spelling: 'spelling', + rephrase: 'rephrasing', + match_tone: 'tone matching', + formal: 'formalization', + casual: 'casual rewrite', + shorten: 'shortening', + expand: 'expanding', + emojify: 'emojification', + translate: 'translation', +}; + +const AIComposerToolbar = ({ + showToolbar, + suggestion, + isProcessing, + activeAction, + actions, + onAction, + onAccept, + onReject, +}) => { + const { theme } = useTheme(); + const styles = getAIComposerStyles(theme); + + if (!showToolbar && !isProcessing && !suggestion) return null; + + const group1 = actions.filter((a) => a.group === 1); + const group2 = actions.filter((a) => a.group === 2); + + return ( + + {/* ── Floating Action Toolbar ── */} + {showToolbar && !isProcessing && !suggestion && ( + + ✦ AI + + {/* Row 1 */} + + {group1.map(({ key, label }) => ( + + ))} + + + + + {/* Row 2 */} + + {group2.map(({ key, label }) => ( + + ))} + + + )} + + {/* ── Processing indicator ── */} + {isProcessing && ( + + + + + + AI is applying{' '} + {activeAction + ? ACTION_LABELS[activeAction] ?? activeAction + : 'changes'} + … + + + )} + + {/* ── Suggestion preview ── */} + {suggestion && !isProcessing && ( + + + + ✦ AI · {ACTION_LABELS[suggestion.actionKey] ?? 'suggestion'} + + + + + + +

{suggestion.text}

+

+ Original: “{suggestion.original}” +

+
+ )} +
+ ); +}; + +export default AIComposerToolbar; diff --git a/packages/react/src/views/AIComposerToolbar/AIComposerToolbar.styles.js b/packages/react/src/views/AIComposerToolbar/AIComposerToolbar.styles.js new file mode 100644 index 000000000..3f7073d7b --- /dev/null +++ b/packages/react/src/views/AIComposerToolbar/AIComposerToolbar.styles.js @@ -0,0 +1,181 @@ +import { css } from '@emotion/react'; + +export const getAIComposerStyles = (theme) => ({ + wrapper: css` + position: relative; + margin: 0 2rem; + `, + + toolbar: css` + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.35rem 0.5rem; + background: ${theme.colors.card}; + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radius}; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + animation: ec-ai-fadein 0.12s ease; + @keyframes ec-ai-fadein { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + `, + + toolbarLabel: css` + font-size: 0.65rem; + font-weight: 600; + color: ${theme.colors.mutedForeground}; + text-transform: uppercase; + letter-spacing: 0.06em; + display: flex; + align-items: center; + padding: 0 0.25rem; + white-space: nowrap; + `, + + actionRow: css` + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: center; + `, + + divider: css` + display: block; + width: 100%; + height: 1px; + background: ${theme.colors.border}; + margin: 0.15rem 0; + `, + + actionBtn: css` + display: inline-flex; + align-items: center; + gap: 0.2rem; + font-size: 0.75rem; + padding: 0.2rem 0.55rem; + border-radius: 0.375rem; + border: 1px solid ${theme.colors.border}; + background: transparent; + color: ${theme.colors.foreground}; + cursor: pointer; + transition: background 0.12s, color 0.12s; + white-space: nowrap; + &:hover { + background: ${theme.colors.primary}; + color: ${theme.colors.primaryForeground}; + border-color: ${theme.colors.primary}; + } + `, + + processingRow: css` + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78rem; + color: ${theme.colors.mutedForeground}; + padding: 0.3rem 0.5rem; + `, + + processingDot: css` + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: ${theme.colors.primary}; + display: inline-block; + animation: ec-pulse 1s infinite; + @keyframes ec-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.25; + } + } + `, + + suggestionBox: css` + background: ${theme.colors.card}; + border: 1px solid ${theme.colors.primary}; + border-radius: ${theme.radius}; + padding: 0.6rem 0.75rem; + font-size: 0.85rem; + line-height: 1.5; + color: ${theme.colors.foreground}; + animation: ec-ai-fadein 0.15s ease; + `, + + suggestionHeader: css` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.35rem; + `, + + suggestionLabel: css` + font-size: 0.68rem; + font-weight: 600; + color: ${theme.colors.primary}; + text-transform: uppercase; + letter-spacing: 0.05em; + `, + + suggestionActions: css` + display: flex; + gap: 0.3rem; + `, + + acceptBtn: css` + display: inline-flex; + align-items: center; + gap: 0.2rem; + font-size: 0.75rem; + padding: 0.2rem 0.6rem; + border-radius: 0.375rem; + background: ${theme.colors.primary}; + color: ${theme.colors.primaryForeground}; + border: none; + cursor: pointer; + font-weight: 600; + &:hover { + opacity: 0.9; + } + `, + + rejectBtn: css` + display: inline-flex; + align-items: center; + gap: 0.2rem; + font-size: 0.75rem; + padding: 0.2rem 0.6rem; + border-radius: 0.375rem; + background: transparent; + color: ${theme.colors.mutedForeground}; + border: 1px solid ${theme.colors.border}; + cursor: pointer; + &:hover { + background: ${theme.colors.muted}; + color: ${theme.colors.foreground}; + } + `, + + suggestionText: css` + white-space: pre-wrap; + word-break: break-word; + `, + + originalLabel: css` + font-size: 0.68rem; + color: ${theme.colors.mutedForeground}; + margin-top: 0.35rem; + font-style: italic; + `, +}); diff --git a/packages/react/src/views/AIComposerToolbar/index.js b/packages/react/src/views/AIComposerToolbar/index.js new file mode 100644 index 000000000..417f31364 --- /dev/null +++ b/packages/react/src/views/AIComposerToolbar/index.js @@ -0,0 +1 @@ +export { default } from './AIComposerToolbar'; diff --git a/packages/react/src/views/ChatInput/ChatInput.js b/packages/react/src/views/ChatInput/ChatInput.js index 0a8d0d7cd..489ac5d91 100644 --- a/packages/react/src/views/ChatInput/ChatInput.js +++ b/packages/react/src/views/ChatInput/ChatInput.js @@ -19,6 +19,7 @@ import { useLoginStore, useChannelStore, useMemberStore, + useAiStore, } from '../../store'; import ChatInputFormattingToolbar from './ChatInputFormattingToolbar'; import useAttachmentWindowStore from '../../store/attachmentwindow'; @@ -37,10 +38,13 @@ import useSearchEmoji from '../../hooks/useSearchEmoji'; import formatSelection from '../../lib/formatSelection'; import { parseEmoji } from '../../lib/emoji'; import useDropBox from '../../hooks/useDropBox'; +import useAIComposer from '../../hooks/useAIComposer'; +import AIComposerToolbar from '../AIComposerToolbar'; 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 +68,26 @@ 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 { + isAiTyping, + setIsAiTyping, + threadSummary, + showThreadSummary, + closeThreadSummary, + } = useAiStore((state) => ({ + isAiTyping: state.isAiTyping, + setIsAiTyping: state.setIsAiTyping, + threadSummary: state.threadSummary, + showThreadSummary: state.showThreadSummary, + closeThreadSummary: state.closeThreadSummary, + })); const { isUserAuthenticated, @@ -173,6 +197,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 +381,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); @@ -384,6 +441,14 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { } }; + const aiComposer = useAIComposer({ + aiAdapter, + ECOptions, + userId, + messageRef, + messages: useMessageStore.getState().messages, + }); + const sendMessage = async () => { messageRef.current.focus(); messageRef.current.style.height = '44px'; @@ -413,12 +478,81 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { handleSendNewMessage(message); scrollToBottom(); + setAiSuggestions([]); + aiComposer.rejectSuggestion(); // dismiss any pending AI suggestion // 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); + setIsAiTyping(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); + setIsAiTyping(false); + } + }; + + const handleSuggestionClick = (suggestion) => { + messageRef.current.value = suggestion; + setDisableButton(false); + setAiSuggestions([]); + messageRef.current.focus(); + }; + + const handleSummarize = async () => { + if (!aiAdapter?.summarize || isSummarizing) return; + setIsSummarizing(true); + setIsAiTyping(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); + setIsAiTyping(false); + } + }; + const sendAttachment = (event) => { const fileObj = event.target.files && event.target.files[0]; if (!fileObj) { @@ -432,7 +566,6 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { sendTypingStart(); const message = val || e.target.value; - // Don't parse emojis if user is currently typing emoji autocomplete const shouldParseEmoji = !message.match(/:([a-zA-Z0-9_+-]*?)$/); messageRef.current.value = shouldParseEmoji ? parseEmoji(message) : message; @@ -653,8 +786,36 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { /> )} - + + {/* AI Composer Toolbar — selection-based actions */} + {isAiAvailable && isUserAuthenticated && ( + + )} + {aiSuggestions.length > 0 && ( + + {aiSuggestions.map((s) => ( + + ))} + + )} { `text-align: center;`} `} onChange={onTextChange} + onMouseUp={aiComposer.handleMouseUp} + onKeyUp={aiComposer.handleKeyUp} onBlur={() => { sendTypingStop(); handleBlur(); @@ -699,11 +862,36 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { /> - + + {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 +950,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { setIsMsgLong(false)} /> - + Send it as attachment instead? @@ -765,6 +963,22 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { )} + {showThreadSummary && ( + + + 📝 Thread Summary + + + + {threadSummary} + + + + + + )} ); }; 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 237fdb5a3..d6947a31c 100644 --- a/packages/react/src/views/EmbeddedChat.js +++ b/packages/react/src/views/EmbeddedChat.js @@ -65,6 +65,7 @@ const EmbeddedChat = (props) => { dark = false, remoteOpt = false, layoutMode = 'bubble', + aiAutoReply = false, } = config; const auth = useMemo( @@ -73,6 +74,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); @@ -235,6 +238,8 @@ const EmbeddedChat = (props) => { } }, [RCInstance, remoteOpt, setIsSynced]); + const memoizedAiAdapter = useMemo(() => aiAdapter, [aiAdapter]); + const ECOptions = useMemo( () => ({ enableThreads, @@ -252,6 +257,8 @@ const EmbeddedChat = (props) => { hideHeader, anonymousMode, layoutMode, + aiAdapter: memoizedAiAdapter, + aiAutoReply, }), [ enableThreads, @@ -269,6 +276,8 @@ const EmbeddedChat = (props) => { hideHeader, anonymousMode, layoutMode, + memoizedAiAdapter, + aiAutoReply, ] ); @@ -350,6 +359,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); From ac02e2e0ae4e32155132ed8680d4c068e86bafaa Mon Sep 17 00:00:00 2001 From: Aryan-Verma-999 Date: Thu, 23 Jul 2026 01:11:03 +0530 Subject: [PATCH 3/5] feat(react): add AI thread summaries --- packages/react/src/views/Message/Message.js | 1 + .../react/src/views/Message/MessageToolbox.js | 31 +++++++++++++++++++ .../src/views/TypingUsers/TypingUsers.js | 29 ++++++++++------- .../src/components/Icon/icons/Summarize.js | 22 +++++++++++++ .../src/components/Icon/icons/index.js | 2 ++ 5 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 packages/ui-elements/src/components/Icon/icons/Summarize.js diff --git a/packages/react/src/views/Message/Message.js b/packages/react/src/views/Message/Message.js index 445c9cd4d..729f5da6a 100644 --- a/packages/react/src/views/Message/Message.js +++ b/packages/react/src/views/Message/Message.js @@ -334,6 +334,7 @@ const Message = ({ }} isThreadMessage={type === 'thread'} variantStyles={variantStyles} + aiAdapter={ECOptions?.aiAdapter ?? null} /> ) : ( <> diff --git a/packages/react/src/views/Message/MessageToolbox.js b/packages/react/src/views/Message/MessageToolbox.js index e530a458a..a1682800d 100644 --- a/packages/react/src/views/Message/MessageToolbox.js +++ b/packages/react/src/views/Message/MessageToolbox.js @@ -10,6 +10,7 @@ import { useTheme, } from '@embeddedchat/ui-elements'; import RCContext from '../../context/RCInstance'; +import { useAiStore, useUserStore } from '../../store'; import { EmojiPicker } from '../EmojiPicker'; import { getMessageToolboxStyles } from './Message.styles'; import SurfaceMenu from '../SurfaceMenu/SurfaceMenu'; @@ -40,10 +41,12 @@ export const MessageToolbox = ({ handleEditMessage, handleQuoteMessage, isEditing = false, + aiAdapter = null, optionConfig = { surfaceItems: [ 'reaction', 'reply', + 'summarize-thread', 'quote', 'star', 'copy', @@ -68,6 +71,9 @@ export const MessageToolbox = ({ const instanceHost = RCInstance.getHost(); const { theme } = useTheme(); const styles = getMessageToolboxStyles(theme); + const userId = useUserStore((state) => state.userId); + const setThreadSummary = useAiStore((state) => state.setThreadSummary); + const setIsAiTyping = useAiStore((state) => state.setIsAiTyping); const surfaceItems = configOverrides.optionConfig?.surfaceItems || optionConfig.surfaceItems; const menuItems = @@ -197,6 +203,31 @@ export const MessageToolbox = ({ visible: isAllowedToReport, type: 'destructive', }, + 'summarize-thread': { + label: 'Summarize thread', + id: 'summarize-thread', + onClick: async () => { + if (!aiAdapter?.summarize) return; + setIsAiTyping(true); + try { + const res = await RCInstance.getThreadMessages(message._id); + const threadMsgs = res?.messages ?? []; + if (threadMsgs.length === 0) return; + const summary = await aiAdapter.summarize(threadMsgs, { + roomId: message.rid, + userId, + history: threadMsgs, + }); + setThreadSummary(summary); + } catch (e) { + console.error('[AI Adapter] thread summarize failed:', e); + } finally { + setIsAiTyping(false); + } + }, + iconName: 'summarize', + visible: !!(aiAdapter?.summarize && message.tcount > 0), + }, }), [ handleOpenThread, diff --git a/packages/react/src/views/TypingUsers/TypingUsers.js b/packages/react/src/views/TypingUsers/TypingUsers.js index db05619ec..4bbdc0fdd 100644 --- a/packages/react/src/views/TypingUsers/TypingUsers.js +++ b/packages/react/src/views/TypingUsers/TypingUsers.js @@ -4,7 +4,7 @@ import React, { useContext, useEffect, useMemo, useState } from 'react'; import RCContext from '../../context/RCInstance'; import { useUserStore } from '../../store'; -export default function TypingUsers() { +export default function TypingUsers({ extraUsers = [] }) { const { RCInstance } = useContext(RCContext); const currentUserName = useUserStore((state) => state.username); const [typingUsers, setTypingUsers] = useState([]); @@ -17,38 +17,43 @@ export default function TypingUsers() { return () => RCInstance.removeTypingStatusListener(setTypingUsers); }, [RCInstance, setTypingUsers, currentUserName]); + const allTypingUsers = useMemo( + () => [...new Set([...typingUsers, ...extraUsers])], + [typingUsers, extraUsers] + ); + const typingStatusMessage = useMemo(() => { - if (typingUsers.length === 0) return ''; - if (typingUsers.length === 1) + if (allTypingUsers.length === 0) return ''; + if (allTypingUsers.length === 1) return ( - {typingUsers[0]} + {allTypingUsers[0]} {' is typing...'} ); - if (typingUsers.length === 2) + if (allTypingUsers.length === 2) return ( - {typingUsers[0]} + {allTypingUsers[0]} {' and '} - {typingUsers[1]} + {allTypingUsers[1]} {' are typing...'} ); return ( - {typingUsers[0]} + {allTypingUsers[0]} {', '} - {typingUsers[1]} - {`and ${typingUsers.length - 2} more are typing...`} + {allTypingUsers[1]} + {`and ${allTypingUsers.length - 2} more are typing...`} ); - }, [typingUsers]); + }, [allTypingUsers]); return ( ( + + + + + + + +); + +export default Summarize; diff --git a/packages/ui-elements/src/components/Icon/icons/index.js b/packages/ui-elements/src/components/Icon/icons/index.js index 1f416a020..0b7293fa7 100644 --- a/packages/ui-elements/src/components/Icon/icons/index.js +++ b/packages/ui-elements/src/components/Icon/icons/index.js @@ -66,6 +66,7 @@ import Avatar from './Avatar'; import FormatText from './FormatText'; import Cog from './Cog'; import Team from './Team'; +import Summarize from './Summarize'; const icons = { file: File, @@ -136,6 +137,7 @@ const icons = { avatar: Avatar, 'format-text': FormatText, cog: Cog, + summarize: Summarize, }; export default icons; From 35b975833e6f7dfc5208e2acda49d710d40a5180 Mon Sep 17 00:00:00 2001 From: Aryan-Verma-999 Date: Thu, 23 Jul 2026 01:11:09 +0530 Subject: [PATCH 4/5] feat(storybook): add AI adapter theme story --- .../src/stories/WithAIAdapter.stories.js | 38 +++++++ packages/react/src/theme/AITheme.js | 102 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 packages/react/src/stories/WithAIAdapter.stories.js create mode 100644 packages/react/src/theme/AITheme.js diff --git a/packages/react/src/stories/WithAIAdapter.stories.js b/packages/react/src/stories/WithAIAdapter.stories.js new file mode 100644 index 000000000..6c6c27f2a --- /dev/null +++ b/packages/react/src/stories/WithAIAdapter.stories.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { OllamaAdapter } from '@embeddedchat/ai-adapter'; +import { EmbeddedChat } from '..'; +import AITheme from '../theme/AITheme'; + +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: true, + theme: AITheme, + }, +}; diff --git a/packages/react/src/theme/AITheme.js b/packages/react/src/theme/AITheme.js new file mode 100644 index 000000000..422bae175 --- /dev/null +++ b/packages/react/src/theme/AITheme.js @@ -0,0 +1,102 @@ +const AITheme = { + radius: '0.75rem', + + commonColors: { + black: 'hsl(240, 25%, 4%)', + white: 'hsl(210, 40%, 98%)', + }, + + schemes: { + light: { + background: 'hsl(210, 40%, 98%)', + foreground: 'hsl(228, 35%, 12%)', + card: 'hsl(0, 0%, 100%)', + cardForeground: 'hsl(228, 35%, 12%)', + popover: 'hsl(0, 0%, 100%)', + popoverForeground: 'hsl(228, 35%, 12%)', + primary: 'hsl(252, 76%, 58%)', + primaryForeground: 'hsl(0, 0%, 100%)', + secondary: 'hsl(220, 35%, 94%)', + secondaryForeground: 'hsl(228, 35%, 18%)', + muted: 'hsl(220, 35%, 94%)', + mutedForeground: 'hsl(225, 18%, 42%)', + accent: 'hsl(174, 55%, 90%)', + accentForeground: 'hsl(180, 55%, 20%)', + destructive: 'hsl(0, 72%, 51%)', + destructiveForeground: 'hsl(0, 0%, 100%)', + border: 'hsl(220, 24%, 86%)', + input: 'hsl(220, 24%, 86%)', + ring: 'hsl(252, 76%, 58%)', + warning: 'hsl(38, 92%, 50%)', + warningForeground: 'hsl(48, 96%, 89%)', + success: 'hsl(160, 64%, 42%)', + successForeground: 'hsl(160, 70%, 96%)', + info: 'hsl(190, 85%, 42%)', + infoForeground: 'hsl(190, 80%, 95%)', + }, + dark: { + background: 'hsl(240, 27%, 7%)', + foreground: 'hsl(210, 40%, 96%)', + card: 'hsl(240, 24%, 10%)', + cardForeground: 'hsl(210, 40%, 96%)', + popover: 'hsl(240, 25%, 9%)', + popoverForeground: 'hsl(210, 40%, 96%)', + primary: 'hsl(252, 84%, 69%)', + primaryForeground: 'hsl(240, 30%, 10%)', + secondary: 'hsl(238, 22%, 16%)', + secondaryForeground: 'hsl(210, 40%, 96%)', + muted: 'hsl(238, 22%, 14%)', + mutedForeground: 'hsl(220, 18%, 68%)', + accent: 'hsl(180, 42%, 18%)', + accentForeground: 'hsl(174, 70%, 82%)', + destructive: 'hsl(0, 62%, 42%)', + destructiveForeground: 'hsl(210, 40%, 96%)', + border: 'hsl(240, 22%, 20%)', + input: 'hsl(240, 22%, 20%)', + ring: 'hsl(174, 70%, 62%)', + warning: 'hsl(38, 92%, 50%)', + warningForeground: 'hsl(48, 96%, 89%)', + success: 'hsl(160, 58%, 30%)', + successForeground: 'hsl(160, 70%, 90%)', + info: 'hsl(190, 65%, 32%)', + infoForeground: 'hsl(190, 80%, 90%)', + }, + }, + + contrastParams: { + light: { + saturation: 70, + luminance: 20, + }, + dark: { + saturation: 85, + luminance: 75, + }, + }, + + typography: { + default: { + fontFamily: + "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", + fontSize: 14, + fontWeightLight: 300, + fontWeightRegular: 400, + fontWeightMedium: 500, + fontWeightBold: 700, + }, + h1: { fontSize: '2.25rem', fontWeight: 800 }, + h2: { fontSize: '1.75rem', fontWeight: 750 }, + h3: { fontSize: '1.4rem', fontWeight: 650 }, + h4: { fontSize: '1.1rem', fontWeight: 600 }, + h5: { fontSize: '1rem', fontWeight: 600 }, + h6: { fontSize: '0.875rem', fontWeight: 600 }, + }, + + shadows: [ + 'none', + '0 1px 2px hsla(240, 30%, 4%, 0.28), 0 0 0 1px hsla(252, 84%, 69%, 0.04)', + '0 16px 40px hsla(240, 30%, 4%, 0.36), 0 0 32px hsla(252, 84%, 69%, 0.1)', + ], +}; + +export default AITheme; From 322918660cfc5b895c67deb908b9de00b287b6ee Mon Sep 17 00:00:00 2001 From: Aryan-Verma-999 Date: Thu, 23 Jul 2026 01:32:03 +0530 Subject: [PATCH 5/5] fix(ai-adapter): support native module imports --- packages/ai-adapter/package.json | 9 +++++++- packages/ai-adapter/rollup.config.js | 4 ++-- .../react/src/views/ChatInput/ChatInput.js | 22 ------------------- packages/react/src/views/EmbeddedChat.js | 4 ---- 4 files changed, 10 insertions(+), 29 deletions(-) diff --git a/packages/ai-adapter/package.json b/packages/ai-adapter/package.json index 8e23be582..ec0ff25dd 100644 --- a/packages/ai-adapter/package.json +++ b/packages/ai-adapter/package.json @@ -2,10 +2,17 @@ "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", + "main": "dist/index.cjs", "module": "dist/index.mjs", "types": "dist/index.d.ts", "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "rollup -c", diff --git a/packages/ai-adapter/rollup.config.js b/packages/ai-adapter/rollup.config.js index eedda5a44..449ac533c 100644 --- a/packages/ai-adapter/rollup.config.js +++ b/packages/ai-adapter/rollup.config.js @@ -9,7 +9,7 @@ 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 name = packageJson.main.replace(/\.(?:c?js)$/, ''); const bundle = (config) => ({ ...config, @@ -21,7 +21,7 @@ export default [ bundle({ plugins: [esbuild()], output: [ - { file: `${name}.js`, format: 'cjs', sourcemap: true }, + { file: `${name}.cjs`, format: 'cjs', sourcemap: true }, { file: `${name}.mjs`, format: 'es', sourcemap: true }, ], }), diff --git a/packages/react/src/views/ChatInput/ChatInput.js b/packages/react/src/views/ChatInput/ChatInput.js index 489ac5d91..c38342438 100644 --- a/packages/react/src/views/ChatInput/ChatInput.js +++ b/packages/react/src/views/ChatInput/ChatInput.js @@ -381,28 +381,6 @@ 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); diff --git a/packages/react/src/views/EmbeddedChat.js b/packages/react/src/views/EmbeddedChat.js index d6947a31c..287e5e8bf 100644 --- a/packages/react/src/views/EmbeddedChat.js +++ b/packages/react/src/views/EmbeddedChat.js @@ -65,7 +65,6 @@ const EmbeddedChat = (props) => { dark = false, remoteOpt = false, layoutMode = 'bubble', - aiAutoReply = false, } = config; const auth = useMemo( @@ -258,7 +257,6 @@ const EmbeddedChat = (props) => { anonymousMode, layoutMode, aiAdapter: memoizedAiAdapter, - aiAutoReply, }), [ enableThreads, @@ -277,7 +275,6 @@ const EmbeddedChat = (props) => { anonymousMode, layoutMode, memoizedAiAdapter, - aiAutoReply, ] ); @@ -366,7 +363,6 @@ EmbeddedChat.propTypes = { summarize: PropTypes.func, isAvailable: PropTypes.func.isRequired, }), - aiAutoReply: PropTypes.bool, }; export default memo(EmbeddedChat);