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
8 changes: 8 additions & 0 deletions .changeset/generated-ui-hosted-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@embeddedchat/react": minor
"@embeddedchat/ui-kit": minor
"@embeddedchat/ui-elements": patch
"@embeddedchat/ai-adapter": patch
---

Support validated generated UI configurations in hosted EmbeddedChat applications, with room/message-aware action callbacks, consistent form state, accessible shared previews, and guarded asynchronous submissions. Export the JSON contract through the UI-kit package and keep local preview sync development-only.
4 changes: 4 additions & 0 deletions packages/ai-adapter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@
],
"license": "MIT",
"devDependencies": {
"@embeddedchat/ui-kit": "workspace:^",
"prettier": "^2.8.1",
"rollup": "^3.23.0",
"rollup-plugin-dts": "^6.0.1",
"rollup-plugin-esbuild": "^5.0.0",
"typescript": "^5.0.0"
},
"dependencies": {
"@rocket.chat/ui-kit": "^0.31.25"
}
}
36 changes: 22 additions & 14 deletions packages/ai-adapter/rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
import dts from 'rollup-plugin-dts';
import esbuild from 'rollup-plugin-esbuild';
import path from 'path';
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
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 packageJson = require(path.resolve(__dirname, "./package.json"));

const name = packageJson.main.replace(/\.(?:c?js)$/, '');
const name = packageJson.main.replace(/\.(?:c?js)$/, "");
const generatedUiModule = "@embeddedchat/ui-kit/generated-ui.mjs";
const generatedUiContract = {
name: "generated-ui-contract",
resolveId(id) {
return id === generatedUiModule ? require.resolve(id) : null;
},
};

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

export default [
bundle({
plugins: [esbuild()],
plugins: [generatedUiContract, esbuild()],
output: [
{ file: `${name}.cjs`, format: 'cjs', sourcemap: true },
{ file: `${name}.mjs`, format: 'es', sourcemap: true },
{ file: `${name}.cjs`, format: "cjs", sourcemap: true },
{ file: `${name}.mjs`, format: "es", sourcemap: true },
],
}),
bundle({
plugins: [dts()],
output: { file: `${name}.d.ts`, format: 'es' },
plugins: [generatedUiContract, dts()],
output: { file: `${name}.d.ts`, format: "es" },
}),
];
5 changes: 5 additions & 0 deletions packages/ai-adapter/src/BaseAIAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LayoutBlock } from "@rocket.chat/ui-kit";
import { IAIAdapter, AIContext, AIResponse, Message } from "./types";

type ChatMessage = {
Expand All @@ -8,6 +9,10 @@ type ChatMessage = {
export abstract class BaseAIAdapter implements IAIAdapter {
abstract name: string;
abstract sendPrompt(context: AIContext, message: string): Promise<AIResponse>;
abstract generateUIBlocks(
prompt: string,
existingBlocks?: LayoutBlock[]
): Promise<{ blocks: LayoutBlock[]; componentType: string }>;
abstract isAvailable(): Promise<boolean>;

protected buildChatMessages(
Expand Down
8 changes: 8 additions & 0 deletions packages/ai-adapter/src/adapters/GeminiAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LayoutBlock } from "@rocket.chat/ui-kit";
import { BaseAIAdapter } from "../BaseAIAdapter";
import { AIContext, AIResponse, AITaskConfigs } from "../types";

Expand Down Expand Up @@ -107,6 +108,13 @@ export class GeminiAdapter extends BaseAIAdapter {
return { text };
}

async generateUIBlocks(
prompt: string,
existingBlocks?: LayoutBlock[]
): Promise<{ blocks: LayoutBlock[]; componentType: string }> {
throw new Error("generateUIBlocks not implemented for Gemini adapter");
}

async isAvailable(): Promise<boolean> {
try {
const keyParam = this.config.apiKey ? `?key=${this.config.apiKey}` : "";
Expand Down
169 changes: 169 additions & 0 deletions packages/ai-adapter/src/adapters/MockAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// For testing/demo only — returns hardcoded responses, requires no API key
import { LayoutBlock } from "@rocket.chat/ui-kit";
import { BaseAIAdapter } from "../BaseAIAdapter";
import { AIContext, AIResponse } from "../types";
import { validateAndExtractBlocks } from "../utils/validation";

export class MockAdapter extends BaseAIAdapter {
name = "Mock (Demo)";

async sendPrompt(_context: AIContext, message: string): Promise<AIResponse> {
return {
text: `Mock response to: "${message}"`,
suggestions: ["Sure!", "Let me check", "Can you tell me more?"],
};
}

async generateUIBlocks(
_prompt: string,
_existingBlocks?: LayoutBlock[]
): Promise<{ blocks: LayoutBlock[]; componentType: string }> {
const lowerPrompt = _prompt.toLowerCase();
let componentType = "info";
let blocks: any[] = [];

if (lowerPrompt.includes("form") || lowerPrompt.includes("login")) {
componentType = "form";
blocks = [
{
type: "section",
text: {
type: "plain_text",
text: "Mock AI Login Form",
},
},
{
type: "input",
element: {
type: "plain_text_input",
actionId: "username",
placeholder: {
type: "plain_text",
text: "Enter your username",
},
},
label: {
type: "plain_text",
text: "Username",
},
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "Login",
},
actionId: "login_btn",
value: "login",
},
],
},
];
} else if (
lowerPrompt.includes("profile") ||
lowerPrompt.includes("user")
) {
componentType = "profile";
blocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: "*John Doe* @jdoe",
},
accessory: {
type: "image",
imageUrl: "https://picsum.photos/seed/john/400/400",
altText: "Profile picture",
},
},
];
} else if (
lowerPrompt.includes("gallery") ||
lowerPrompt.includes("media") ||
lowerPrompt.includes("images")
) {
componentType = "gallery";
blocks = [
{
type: "section",
text: {
type: "plain_text",
text: "Photo Gallery",
},
},
{
type: "context",
elements: [
{
type: "image",
imageUrl: "https://picsum.photos/seed/photo1/400/400",
altText: "Gallery image 1",
},
{
type: "image",
imageUrl: "https://picsum.photos/seed/photo2/400/400",
altText: "Gallery image 2",
},
],
},
];
} else if (
lowerPrompt.includes("cta") ||
lowerPrompt.includes("action") ||
lowerPrompt.includes("button")
) {
componentType = "cta";
blocks = [
{
type: "section",
text: {
type: "plain_text",
text: "Ready to get started?",
},
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "Sign Up Now",
},
actionId: "signup_btn",
value: "signup",
},
],
},
];
} else {
componentType = "info";
blocks = [
{
type: "section",
text: {
type: "plain_text",
text: "Mock AI Component generated successfully.",
},
},
];
}

return validateAndExtractBlocks(
JSON.stringify({
blocks: blocks.map((block) =>
block.type === "section" ? { accessory: null, ...block } : block
),
componentType,
})
);
}

async isAvailable(): Promise<boolean> {
return true;
}
}
39 changes: 39 additions & 0 deletions packages/ai-adapter/src/adapters/OllamaAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { LayoutBlock } from "@rocket.chat/ui-kit";
import { BaseAIAdapter } from "../BaseAIAdapter";
import { AIContext, AIResponse, AITaskConfigs } from "../types";
import {
UI_KIT_GENERATION_SYSTEM_PROMPT,
UI_KIT_JSON_SCHEMA,
validateAndExtractBlocks,
} from "../utils/validation";

interface OllamaConfig {
baseUrl?: string;
Expand Down Expand Up @@ -62,6 +68,39 @@ export class OllamaAdapter extends BaseAIAdapter {
return { text };
}

async generateUIBlocks(
prompt: string,
existingBlocks?: LayoutBlock[]
): Promise<{ blocks: LayoutBlock[]; componentType: string }> {
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: [
{ role: "system", content: UI_KIT_GENERATION_SYSTEM_PROMPT },
{
role: "user",
content: `Prompt: "${prompt}"\n\nExisting Blocks:\n${JSON.stringify(
existingBlocks ?? []
)}`,
},
],
stream: false,
format: UI_KIT_JSON_SCHEMA,
}),
});

if (!res.ok) throw new Error(`Ollama API error: ${res.status}`);

const data = await res.json();
return validateAndExtractBlocks(data.message?.content ?? "");
}

async isAvailable(): Promise<boolean> {
try {
const base = this.config.baseUrl.replace(/\/$/, "");
Expand Down
Loading