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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/ai-adapter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
*.log
149 changes: 149 additions & 0 deletions packages/ai-adapter/README.md
Original file line number Diff line number Diff line change
@@ -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 });

<EmbeddedChat
host="https://chat.example.com"
roomId="GENERAL"
aiAdapter={adapter}
/>
```

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<AIResponse> {
const reply = await myAIService.chat(message);
return { text: reply };
}

async isAvailable(): Promise<boolean> {
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<AIResponse>;
getSuggestions?(conversation: Message[]): Promise<string[]>;
isAvailable(): Promise<boolean>;
}

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
39 changes: 39 additions & 0 deletions packages/ai-adapter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"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.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",
"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"
}
}
32 changes: 32 additions & 0 deletions packages/ai-adapter/rollup.config.js
Original file line number Diff line number Diff line change
@@ -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(/\.(?:c?js)$/, '');

const bundle = (config) => ({
...config,
input: 'src/index.ts',
external: (id) => id[0] !== '.' && !path.isAbsolute(id),
});

export default [
bundle({
plugins: [esbuild()],
output: [
{ file: `${name}.cjs`, format: 'cjs', sourcemap: true },
{ file: `${name}.mjs`, format: 'es', sourcemap: true },
],
}),
bundle({
plugins: [dts()],
output: { file: `${name}.d.ts`, format: 'es' },
}),
];
98 changes: 98 additions & 0 deletions packages/ai-adapter/src/BaseAIAdapter.ts
Original file line number Diff line number Diff line change
@@ -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<AIResponse>;
abstract isAvailable(): Promise<boolean>;

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<string[]> {
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<string> {
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;
}
}
Loading