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
32 changes: 32 additions & 0 deletions packages/ai-adapter/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
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(/\.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' },
}),
];
58 changes: 58 additions & 0 deletions packages/ai-adapter/src/BaseAIAdapter.ts
Original file line number Diff line number Diff line change
@@ -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<AIResponse>;
abstract isAvailable(): Promise<boolean>;

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;
}
}
114 changes: 114 additions & 0 deletions packages/ai-adapter/src/adapters/GeminiAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { BaseAIAdapter } from "../BaseAIAdapter";
import { AIContext, AIResponse } from "../types";

interface GeminiConfig {
apiKey?: string;
model?: string;
baseUrl?: string;
headers?: Record<string, string>;
assistantUsername?: string;
}

export class GeminiAdapter extends BaseAIAdapter {
name = "Gemini";
private config: Required<GeminiConfig>;

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<AIResponse> {
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<boolean> {
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;
}
}
}
Loading