diff --git a/.changeset/generated-ui-hosted-runtime.md b/.changeset/generated-ui-hosted-runtime.md new file mode 100644 index 0000000000..22cea55fad --- /dev/null +++ b/.changeset/generated-ui-hosted-runtime.md @@ -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. diff --git a/packages/ai-adapter/package.json b/packages/ai-adapter/package.json index ec0ff25dd3..cca6814ce3 100644 --- a/packages/ai-adapter/package.json +++ b/packages/ai-adapter/package.json @@ -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" } } diff --git a/packages/ai-adapter/rollup.config.js b/packages/ai-adapter/rollup.config.js index 449ac533c2..0f3ce9cc17 100644 --- a/packages/ai-adapter/rollup.config.js +++ b/packages/ai-adapter/rollup.config.js @@ -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" }, }), ]; diff --git a/packages/ai-adapter/src/BaseAIAdapter.ts b/packages/ai-adapter/src/BaseAIAdapter.ts index 117c8ef6a9..f934d8ef0b 100644 --- a/packages/ai-adapter/src/BaseAIAdapter.ts +++ b/packages/ai-adapter/src/BaseAIAdapter.ts @@ -1,3 +1,4 @@ +import { LayoutBlock } from "@rocket.chat/ui-kit"; import { IAIAdapter, AIContext, AIResponse, Message } from "./types"; type ChatMessage = { @@ -8,6 +9,10 @@ type ChatMessage = { export abstract class BaseAIAdapter implements IAIAdapter { abstract name: string; abstract sendPrompt(context: AIContext, message: string): Promise; + abstract generateUIBlocks( + prompt: string, + existingBlocks?: LayoutBlock[] + ): Promise<{ blocks: LayoutBlock[]; componentType: string }>; abstract isAvailable(): Promise; protected buildChatMessages( diff --git a/packages/ai-adapter/src/adapters/GeminiAdapter.ts b/packages/ai-adapter/src/adapters/GeminiAdapter.ts index 88459280aa..b75de880b6 100644 --- a/packages/ai-adapter/src/adapters/GeminiAdapter.ts +++ b/packages/ai-adapter/src/adapters/GeminiAdapter.ts @@ -1,3 +1,4 @@ +import { LayoutBlock } from "@rocket.chat/ui-kit"; import { BaseAIAdapter } from "../BaseAIAdapter"; import { AIContext, AIResponse, AITaskConfigs } from "../types"; @@ -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 { try { const keyParam = this.config.apiKey ? `?key=${this.config.apiKey}` : ""; diff --git a/packages/ai-adapter/src/adapters/MockAdapter.ts b/packages/ai-adapter/src/adapters/MockAdapter.ts new file mode 100644 index 0000000000..4b000d5842 --- /dev/null +++ b/packages/ai-adapter/src/adapters/MockAdapter.ts @@ -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 { + 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 { + return true; + } +} diff --git a/packages/ai-adapter/src/adapters/OllamaAdapter.ts b/packages/ai-adapter/src/adapters/OllamaAdapter.ts index 3c55d465f1..6d2abe739a 100644 --- a/packages/ai-adapter/src/adapters/OllamaAdapter.ts +++ b/packages/ai-adapter/src/adapters/OllamaAdapter.ts @@ -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; @@ -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 { try { const base = this.config.baseUrl.replace(/\/$/, ""); diff --git a/packages/ai-adapter/src/adapters/OpenAIAdapter.ts b/packages/ai-adapter/src/adapters/OpenAIAdapter.ts index c224e401a9..6153328a0c 100644 --- a/packages/ai-adapter/src/adapters/OpenAIAdapter.ts +++ b/packages/ai-adapter/src/adapters/OpenAIAdapter.ts @@ -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 OpenAIConfig { apiKey?: string; @@ -71,6 +77,73 @@ export class OpenAIAdapter extends BaseAIAdapter { return { text }; } + private buildResponseFormat(model: string): Record { + const strictCapableModels = [ + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "gpt-4", + "gpt-3.5-turbo", + ]; + + if ( + !strictCapableModels.some((candidate) => + model.toLowerCase().includes(candidate) + ) + ) { + return { type: "json_object" }; + } + + return { + type: "json_schema", + json_schema: { + name: "ui_kit_blocks", + strict: true, + schema: UI_KIT_JSON_SCHEMA, + }, + }; + } + + async generateUIBlocks( + prompt: string, + existingBlocks?: LayoutBlock[] + ): Promise<{ blocks: LayoutBlock[]; componentType: string }> { + const headers: Record = { + "Content-Type": "application/json", + ...this.config.headers, + }; + if (this.config.apiKey) + headers.Authorization = `Bearer ${this.config.apiKey}`; + + const requestBody = { + 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 ?? [] + )}`, + }, + ], + max_tokens: Math.max(this.config.maxTokens, 2000), + response_format: this.buildResponseFormat(this.config.model), + }; + const base = this.config.baseUrl.replace(/\/$/, ""); + const res = await fetch(`${base}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + }); + + if (!res.ok) { + throw new Error(`OpenAI API error: ${res.status}`); + } + + const data = await res.json(); + return validateAndExtractBlocks(data.choices?.[0]?.message?.content ?? ""); + } + async isAvailable(): Promise { try { const headers: Record = { diff --git a/packages/ai-adapter/src/index.ts b/packages/ai-adapter/src/index.ts index 57f289adcd..020419993b 100644 --- a/packages/ai-adapter/src/index.ts +++ b/packages/ai-adapter/src/index.ts @@ -11,3 +11,9 @@ export { BaseAIAdapter } from "./BaseAIAdapter"; export { OpenAIAdapter } from "./adapters/OpenAIAdapter"; export { OllamaAdapter } from "./adapters/OllamaAdapter"; export { GeminiAdapter } from "./adapters/GeminiAdapter"; +export { MockAdapter } from "./adapters/MockAdapter"; +export { + UI_KIT_GENERATION_SYSTEM_PROMPT, + UI_KIT_JSON_SCHEMA, + validateAndExtractBlocks, +} from "./utils/validation"; diff --git a/packages/ai-adapter/src/types.ts b/packages/ai-adapter/src/types.ts index e9b510036d..b1c0b1b990 100644 --- a/packages/ai-adapter/src/types.ts +++ b/packages/ai-adapter/src/types.ts @@ -1,3 +1,5 @@ +import { LayoutBlock } from "@rocket.chat/ui-kit"; + export interface Message { _id: string; msg: string; @@ -39,5 +41,9 @@ export interface IAIAdapter { context?: AIContext ): Promise; summarize?(messages: Message[], context?: AIContext): Promise; + generateUIBlocks( + prompt: string, + existingBlocks?: LayoutBlock[] + ): Promise<{ blocks: LayoutBlock[]; componentType: string }>; isAvailable(): Promise; } diff --git a/packages/ai-adapter/src/utils/validation.ts b/packages/ai-adapter/src/utils/validation.ts new file mode 100644 index 0000000000..0183f626d6 --- /dev/null +++ b/packages/ai-adapter/src/utils/validation.ts @@ -0,0 +1,75 @@ +import { LayoutBlock } from "@rocket.chat/ui-kit"; +import { + UI_KIT_JSON_SCHEMA, + validateGeneratedUiBlocks, +} from "@embeddedchat/ui-kit/generated-ui.mjs"; + +export { UI_KIT_JSON_SCHEMA }; + +export const UI_KIT_GENERATION_SYSTEM_PROMPT = `You are a Rocket.Chat UI-Kit Block generator. +Your goal is to generate or modify a list of UI-Kit layout blocks (JSON) according to the user's instructions. +You must return a JSON object with two root keys: +1. "blocks": an array of layout blocks. Each block in the array must be a direct JSON object, NOT a JSON-encoded string. +2. "componentType": a string from the enum ["form", "profile", "gallery", "cta", "info"] representing the category of the generated component. + +Allowed block types: +1. "section": for displaying text with an optional accessory (like a button or image). +2. "actions": for holding interactive elements (like buttons). +3. "input": for forms (labels with plain_text_input element). +4. "divider": simple horizontal rule. +5. "image": block containing an image. +6. "context": small text/image elements for metadata. + +Use a section's 'accessory' field only when a button or image is directly attached to that specific line of text. For standalone action buttons, always use a separate top-level 'actions' block. + +Every 'section' block must include an explicit 'accessory' field — set it to null if there is no button or image accessory. Every 'plain_text_input' element must include an explicit 'placeholder' field — set it to null if no placeholder is needed. + +When using 'mrkdwn' type text, only use Slack-style markdown syntax (*bold*, _italic_, ~strikethrough~, \`code\`) — do not use standard Markdown headings. + +Field naming is camelCase throughout. For image elements and blocks use 'imageUrl' and 'altText'. For buttons and inputs use 'actionId'. + +When an image needs a placeholder photo, use https://picsum.photos/seed/{unique-word}/400/400 with a unique seed per image. + +If asked for something with no direct schema match, represent it using the closest available primitive. Never attempt a block type not explicitly listed above. + +If existingBlocks is provided, update, add to, or modify that list based on the prompt.`; + +export interface UIBlocksAndType { + blocks: LayoutBlock[]; + componentType: string; +} + +export function validateAndExtractBlocks(text: string): UIBlocksAndType { + let parsed: any; + try { + parsed = JSON.parse(text); + } catch (e: any) { + throw new Error(`Failed to parse AI layout response: ${e.message}`); + } + + if (parsed && Array.isArray(parsed.blocks)) { + parsed.blocks = parsed.blocks.map((block: any) => { + if (typeof block === "string") { + try { + return JSON.parse(block); + } catch (_) { + return block; + } + } + return block; + }); + } + + const { valid, errors } = validateGeneratedUiBlocks(parsed); + if (!valid) { + const errorText = errors.join(" "); + throw new Error( + `AI layout response schema validation failed: ${errorText}` + ); + } + + return { + blocks: parsed.blocks, + componentType: parsed.componentType || "info", + }; +} diff --git a/packages/layout_editor/README.md b/packages/layout_editor/README.md index dbb8b8ed62..64bc2f5aca 100644 --- a/packages/layout_editor/README.md +++ b/packages/layout_editor/README.md @@ -93,6 +93,26 @@ Ollama is asked for a narrow JSON schema: The generator only allows local Ollama URLs (`localhost`, `127.0.0.1`, or `::1`). Follow-up prompts include prior instructions, but patch only the explicitly requested tokens—refining corner radius or typography cannot regenerate the palette. Select **Deterministic fallback** in the adapter selector to use the offline parser instead. The browser validates the response, checks text contrast, and presents a draft before applying or exporting the final JSON. +### AI Component Generator integration + +The AI Component Generator creates validated Rocket.Chat UI-Kit blocks. It exports a versioned JSON configuration for the public `generatedUi` prop in `@embeddedchat/react`; it does not publish to, or modify, an npm installation. + +Generate a component, choose its display surface and **Open from** placement, then open **Export** and select **Download JSON** or **Copy Config**. Store the resulting JSON in the host application's repository, CMS, or backend. The host application fetches or loads that JSON and passes it to `EmbeddedChat`, along with an `onGeneratedUiAction` callback for buttons and form values. + +The optional **Dev mode** sync action is only a local contributor aid. It writes a Storybook fixture through the Layout Editor's Vite development server so contributors can exercise the same public package contract without manually pasting generated JSON. It is excluded from production builds and is not part of the npm integration path. + +**Apply to editor** updates the Layout Editor's own chat preview; it is not deployment. The editor and EmbeddedChat use the same renderer and validation contract. Copy Config contains the entire configuration, not just `blocks`: store it as a `.json` file in your application. If manually replacing the local JavaScript fixture, keep its `export const generatedUiPreview = ...;` wrapper around that object. + +For local contributor testing: + +1. Build the workspaces with `yarn build` from the repository root. +2. Start the editor with `yarn workspace layout_editor dev` and React Storybook with `yarn workspace @embeddedchat/react storybook` in separate terminals. +3. Open the editor using `localhost` or `127.0.0.1`, generate a component, choose its surface and placement, and enable **Dev mode**. +4. Select **Sync to EmbeddedChat preview**. This replaces only `packages/layout_editor/src/fixtures/generatedUiPreview.js`; Storybook's **EmbeddedChat/WithGenUi** story imports that fixture and reloads it. Use your usual Rocket.Chat host/room/auth Storybook controls to test in a connected chat. +5. Open the generated icon in the chosen toolbar. Form edits and actions are local preview behavior unless your host supplies an action callback; sync does not post anything to Rocket.Chat. + +Sync accepts only same-origin JSON POSTs on the local development server, validates the payload, caps requests at 256 KiB, and serializes atomic fixture writes. It is unavailable on deployed/static editors and non-loopback hosts. Hosted consumers should follow the [Generated UI integration guide](../react/README.md#generated-ui) instead. + ### Development Clone the repo, navigate to `packages/layout_editor`, then run: diff --git a/packages/layout_editor/generatedUiSync.mjs b/packages/layout_editor/generatedUiSync.mjs new file mode 100644 index 0000000000..e760509dba --- /dev/null +++ b/packages/layout_editor/generatedUiSync.mjs @@ -0,0 +1,132 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, rename, writeFile, unlink } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + MAX_GENERATED_UI_BYTES, + validateGeneratedUiConfiguration, +} from '@embeddedchat/ui-kit/generated-ui.mjs'; + +const fixturePath = fileURLToPath( + new URL('./src/fixtures/generatedUiPreview.js', import.meta.url) +); +const sendJson = (response, status, body) => { + response.statusCode = status; + response.setHeader('Content-Type', 'application/json'); + response.setHeader('Cache-Control', 'no-store'); + response.end(JSON.stringify(body)); +}; + +export const writePreviewFixture = async ( + configuration, + destination = fixturePath +) => { + const temporaryPath = `${destination}.${randomUUID()}.tmp`; + try { + await mkdir(dirname(destination), { recursive: true }); + await writeFile( + temporaryPath, + `// Automatically synced from the Layout Editor component generator.\nexport const generatedUiPreview = ${JSON.stringify( + configuration, + null, + 2 + )};\n`, + 'utf8' + ); + await rename(temporaryPath, destination); + } finally { + await unlink(temporaryPath).catch((error) => { + if (error.code !== 'ENOENT') throw error; + }); + } +}; + +export const createGeneratedUiSyncMiddleware = ( + writePreview = writePreviewFixture +) => { + // Serialize writes so two successful sync requests cannot finish out of order. + let writes = Promise.resolve(); + return async (request, response, next) => { + if ((request.url || '/').split('?')[0] !== '/__generated-ui-preview') + return next(); + if (request.method !== 'POST') { + response.setHeader('Allow', 'POST'); + return sendJson(response, 405, { error: 'Use POST to sync a preview.' }); + } + const protocol = request.socket?.encrypted ? 'https:' : 'http:'; + const expectedOrigin = `${protocol}//${request.headers.host}`; + let localOrigin = false; + try { + localOrigin = ['localhost', '127.0.0.1', '[::1]'].includes( + new URL(expectedOrigin).hostname + ); + } catch { + /* Invalid Host. */ + } + if ( + !localOrigin || + !request.headers.origin || + request.headers.origin !== expectedOrigin || + request.headers['sec-fetch-site'] === 'cross-site' + ) { + return sendJson(response, 403, { + error: 'Preview sync requires a same-origin request on localhost.', + }); + } + if ( + request.headers['content-type']?.split(';')[0].trim().toLowerCase() !== + 'application/json' + ) { + return sendJson(response, 415, { + error: 'Preview sync requires application/json.', + }); + } + if (Number(request.headers['content-length']) > MAX_GENERATED_UI_BYTES) + return sendJson(response, 413, { + error: 'Preview payload is too large.', + }); + try { + const chunks = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_GENERATED_UI_BYTES) + return sendJson(response, 413, { + error: 'Preview payload is too large.', + }); + chunks.push(buffer); + } + let configuration; + try { + configuration = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + return sendJson(response, 400, { + error: 'Preview payload must be valid JSON.', + }); + } + const { errors } = validateGeneratedUiConfiguration(configuration); + if (errors.length) + return sendJson(response, 400, { error: errors.join(' ') }); + const write = writes.then(() => writePreview(configuration)); + writes = write.catch(() => {}); + await write; + return sendJson(response, 200, { ok: true }); + } catch { + if (!response.headersSent) + return sendJson(response, 500, { + error: + 'Unable to write the local preview fixture. Check filesystem permissions and retry.', + }); + return undefined; + } + }; +}; + +export const generatedUiPreviewSyncPlugin = () => ({ + name: 'generated-ui-preview-sync', + apply: 'serve', + configureServer(server) { + server.middlewares.use(createGeneratedUiSyncMiddleware()); + }, +}); diff --git a/packages/layout_editor/package.json b/packages/layout_editor/package.json index a568719023..bb401ba5bb 100644 --- a/packages/layout_editor/package.json +++ b/packages/layout_editor/package.json @@ -16,6 +16,7 @@ "@embeddedchat/ai-adapter": "workspace:*", "@embeddedchat/markups": "workspace:^", "@embeddedchat/ui-elements": "workspace:^", + "@embeddedchat/ui-kit": "workspace:^", "react": "^19.0.0", "react-color": "^2.19.3", "react-dom": "^19.0.0", diff --git a/packages/layout_editor/src/fixtures/generatedUiPreview.js b/packages/layout_editor/src/fixtures/generatedUiPreview.js new file mode 100644 index 0000000000..9b95730097 --- /dev/null +++ b/packages/layout_editor/src/fixtures/generatedUiPreview.js @@ -0,0 +1,41 @@ +// Automatically synced from the Layout Editor component generator. +export const generatedUiPreview = { + "version": 1, + "id": "generated-ui", + "title": "Generated UI", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Username:* JohnDoe" + }, + "accessory": null + }, + { + "type": "image", + "imageUrl": "https://picsum.photos/seed/profilePic/200/200", + "altText": "Profile picture" + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Message" + }, + "actionId": "message_user", + "value": "JohnDoe" + } + ] + } + ], + "surface": "message", + "componentType": "profile", + "placements": [ + "composer", + "messageToolbox" + ] +}; diff --git a/packages/layout_editor/src/store/aiGeneratedBlocksStore.js b/packages/layout_editor/src/store/aiGeneratedBlocksStore.js new file mode 100644 index 0000000000..cf494d6632 --- /dev/null +++ b/packages/layout_editor/src/store/aiGeneratedBlocksStore.js @@ -0,0 +1,9 @@ +import { create } from 'zustand'; + +const useAiGeneratedBlocksStore = create((set) => ({ + publishedConfiguration: null, + publishConfiguration: (publishedConfiguration) => + set({ publishedConfiguration }), +})); + +export default useAiGeneratedBlocksStore; diff --git a/packages/layout_editor/src/store/chatInputItemsStore.js b/packages/layout_editor/src/store/chatInputItemsStore.js index 7d140d26f0..f8651def41 100644 --- a/packages/layout_editor/src/store/chatInputItemsStore.js +++ b/packages/layout_editor/src/store/chatInputItemsStore.js @@ -1,7 +1,7 @@ import { create } from 'zustand'; const useChatInputItemsStore = create((set) => ({ - surfaceItems: ['emoji', 'formatter', 'link', 'audio', 'video', 'file'], + surfaceItems: ['emoji', 'formatter', 'link', 'audio', 'video', 'file', 'ai'], formatters: ['bold', 'italic', 'strike', 'code', 'multiline'], setSurfaceItems: (items) => set({ surfaceItems: items }), setFormatters: (items) => set({ formatters: items }), diff --git a/packages/layout_editor/src/store/messageItemsStore.js b/packages/layout_editor/src/store/messageItemsStore.js index f89ac0e880..9869e33551 100644 --- a/packages/layout_editor/src/store/messageItemsStore.js +++ b/packages/layout_editor/src/store/messageItemsStore.js @@ -10,6 +10,7 @@ const useMessageItemsStore = create((set) => ({ 'edit', 'delete', 'report', + 'ai', ], menuItems: [], diff --git a/packages/layout_editor/src/views/ChatInput/ChatInputToolbar.jsx b/packages/layout_editor/src/views/ChatInput/ChatInputToolbar.jsx index 9d5550c718..09a66ad5c1 100644 --- a/packages/layout_editor/src/views/ChatInput/ChatInputToolbar.jsx +++ b/packages/layout_editor/src/views/ChatInput/ChatInputToolbar.jsx @@ -1,10 +1,12 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useRef } from 'react'; import { Box, useTheme } from '@embeddedchat/ui-elements'; import { getChatInputToolbarStyles } from './ChatInput.styles'; import SurfaceMenu from '../../components/SurfaceMenu/SurfaceMenu'; import SurfaceItem from '../../components/SurfaceMenu/SurfaceItem'; import Formatters from './Formatters'; import useChatInputItemsStore from '../../store/chatInputItemsStore'; +import useAiGeneratedBlocksStore from '../../store/aiGeneratedBlocksStore'; +import { GeneratedUiSurface, getGeneratedUiIcon } from '@embeddedchat/ui-kit'; import { DndContext, closestCenter, @@ -29,6 +31,12 @@ const ChatInputToolbar = () => { const [activeSurfaceItem, setActiveSurfaceItem] = useState(null); const [formattersVisible, setFormattersVisible] = useState(false); + const [aiOpen, setAiOpen] = useState(false); + + const publishedConfiguration = useAiGeneratedBlocksStore( + (state) => state.publishedConfiguration + ); + const generatedUiAnchor = useRef(null); const placeholderSurfaceItem = 'placeholder-surface'; @@ -68,7 +76,7 @@ const ChatInputToolbar = () => { onClick: () => {}, iconName: 'attachment', visible: true, - }, + }, formatter: { label: 'Formatter', id: 'formatter', @@ -78,8 +86,19 @@ const ChatInputToolbar = () => { iconName: 'format-text', visible: true, }, + ai: { + label: 'AI-Generated Content', + id: 'ai', + onClick: () => { + setAiOpen((prev) => !prev); + }, + iconName: getGeneratedUiIcon(publishedConfiguration?.componentType), + visible: Boolean( + publishedConfiguration?.placements.includes('composer') + ), + }, }; - }, []); + }, [setAiOpen, publishedConfiguration]); const sensors = useSensors( useSensor(PointerSensor, { @@ -166,7 +185,11 @@ const ChatInputToolbar = () => { onDragEnd={handleDragEnd} onDragStart={handleDragStart} > - + {surfaceOptions.length > 0 && ( { onRemove={removeSurfaceItem} /> )} + {aiOpen && publishedConfiguration?.placements.includes('composer') && ( + setAiOpen(false)} + /> + )} {createPortal( diff --git a/packages/layout_editor/src/views/Message/Message.jsx b/packages/layout_editor/src/views/Message/Message.jsx index fccf15b1eb..491387cd6a 100644 --- a/packages/layout_editor/src/views/Message/Message.jsx +++ b/packages/layout_editor/src/views/Message/Message.jsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { format } from 'date-fns'; import { Box, useTheme } from '@embeddedchat/ui-elements'; +import { UiKitMessage } from '@embeddedchat/ui-kit'; import { Markdown } from '../Markdown'; import MessageHeader from './MessageHeader'; import { MessageBody } from './MessageBody'; @@ -50,7 +51,6 @@ const Message = ({ })} /> )} - {!message.t ? ( <> + {message.blocks && ( +
+ {UiKitMessage(message.blocks)} +
+ )} + {!message.t && message._id === '62vhmKJGNoxgvLL7M' ? ( ) : ( diff --git a/packages/layout_editor/src/views/Message/MessageToolbox.jsx b/packages/layout_editor/src/views/Message/MessageToolbox.jsx index fbad6209ca..edbba8bffa 100644 --- a/packages/layout_editor/src/views/Message/MessageToolbox.jsx +++ b/packages/layout_editor/src/views/Message/MessageToolbox.jsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, useRef } from 'react'; import { Box, useTheme } from '@embeddedchat/ui-elements'; import { Menu } from '../../components/SortableMenu'; import { getMessageToolboxStyles } from './Message.styles'; @@ -17,6 +17,8 @@ import { import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable'; import { createPortal } from 'react-dom'; import useMessageItemsStore from '../../store/messageItemsStore'; +import useAiGeneratedBlocksStore from '../../store/aiGeneratedBlocksStore'; +import { GeneratedUiSurface, getGeneratedUiIcon } from '@embeddedchat/ui-kit'; export const MessageToolbox = ({ variantStyles = {}, ...props }) => { const styles = getMessageToolboxStyles(useTheme()); @@ -29,6 +31,11 @@ export const MessageToolbox = ({ variantStyles = {}, ...props }) => { })); const [activeSurfaceItem, setActiveSurfaceItem] = useState(null); const [activeMenuItem, setActiveMenuItem] = useState(null); + const [aiOpen, setAiOpen] = useState(false); + const publishedConfiguration = useAiGeneratedBlocksStore( + (state) => state.publishedConfiguration + ); + const generatedUiAnchor = useRef(null); const placeholderSurfaceItem = 'placeholder-surface'; const placeholderMenuItem = 'placeholder-menu'; @@ -164,8 +171,19 @@ export const MessageToolbox = ({ variantStyles = {}, ...props }) => { visible: true, type: 'destructive', }, + ai: { + label: 'AI-Generated Content', + id: 'ai', + onClick: () => { + setAiOpen((prev) => !prev); + }, + iconName: getGeneratedUiIcon(publishedConfiguration?.componentType), + visible: Boolean( + publishedConfiguration?.placements.includes('messageToolbox') + ), + }, }), - [] + [setAiOpen, publishedConfiguration] ); const menuOptions = @@ -222,7 +240,13 @@ export const MessageToolbox = ({ variantStyles = {}, ...props }) => { onDragEnd={handleDragEnd} onDragStart={handleDragStart} > - + {surfaceOptions?.length > 0 && ( { onRemove={removeMenuItem} /> )} + {aiOpen && + publishedConfiguration?.placements.includes('messageToolbox') && ( + setAiOpen(false)} + /> + )} {createPortal( diff --git a/packages/layout_editor/src/views/ThemeLab/AICodePanel.jsx b/packages/layout_editor/src/views/ThemeLab/AICodePanel.jsx new file mode 100644 index 0000000000..412a617b93 --- /dev/null +++ b/packages/layout_editor/src/views/ThemeLab/AICodePanel.jsx @@ -0,0 +1,695 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + Box, + useTheme, + useToastBarDispatch, + Icon, +} from '@embeddedchat/ui-elements'; +import { + GeneratedUiContent, + createGeneratedUiConfiguration, + GENERATED_UI_PLACEMENTS, +} from '@embeddedchat/ui-kit'; +import { + OllamaAdapter, + OpenAIAdapter, + MockAdapter, +} from '@embeddedchat/ai-adapter'; +import { getAICodePanelStyles } from './AICodePanel.styles'; +import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { dracula } from 'react-syntax-highlighter/dist/esm/styles/hljs'; +import useAiGeneratedBlocksStore from '../../store/aiGeneratedBlocksStore'; +import { useGeneratedUiExport } from './useGeneratedUiExport'; +import GeneratedUiActions from './GeneratedUiActions'; + +const AICodePanel = () => { + const { theme } = useTheme(); + const styles = getAICodePanelStyles(theme); + const dispatchToastMessage = useToastBarDispatch(); + + const [open, setOpen] = useState(false); + const [provider, setProvider] = useState(() => { + const savedProvider = localStorage.getItem('ec_ai_provider'); + return ['mock', 'ollama', 'openai'].includes(savedProvider) + ? savedProvider + : 'mock'; + }); + + // Ollama states + const [baseUrl, setBaseUrl] = useState('http://localhost:11434'); + const [modelName, setModelName] = useState('qwen2.5:3b'); + + // OpenAI states + const [openAIKey, setOpenAIKey] = useState( + () => localStorage.getItem('ec_openai_key') || '' + ); + const [openAIModel, setOpenAIModel] = useState( + () => localStorage.getItem('ec_openai_model') || 'gpt-4o' + ); + const [openAIBaseUrl, setOpenAIBaseUrl] = useState( + 'https://api.openai.com/v1' + ); + + // Generation & Layout states + const [prompt, setPrompt] = useState(''); + const [isGenerating, setIsGenerating] = useState(false); + const [draftBlocks, setDraftBlocks] = useState([]); + const [draftComponentType, setDraftComponentType] = useState('info'); + const [componentId, setComponentId] = useState( + () => localStorage.getItem('ec_draft_component_id') || 'generated-ui' + ); + const [componentTitle, setComponentTitle] = useState( + () => localStorage.getItem('ec_draft_component_title') || 'Generated UI' + ); + const [errorMsg, setErrorMsg] = useState(null); + const [tab, setTab] = useState('preview'); + const [surface, setSurface] = useState('message'); + const [placements, setPlacements] = useState(['composer']); + + const publishConfiguration = useAiGeneratedBlocksStore( + (state) => state.publishConfiguration + ); + + // Load draft from storage on mount + useEffect(() => { + const saved = localStorage.getItem('ec_draft_blocks'); + if (saved) { + try { + const parsed = JSON.parse(saved); + createGeneratedUiConfiguration({ blocks: parsed }); + setDraftBlocks(parsed); + } catch (e) { + setErrorMsg( + 'The saved draft is invalid. Reset it or generate a new component.' + ); + } + } + const savedType = localStorage.getItem('ec_draft_component_type'); + if (savedType) { + setDraftComponentType(savedType); + } + const savedPlacements = localStorage.getItem('ec_draft_placements'); + if (savedPlacements) { + try { + const parsedPlacements = JSON.parse(savedPlacements); + const validPlacements = Array.isArray(parsedPlacements) + ? Array.from( + new Set( + parsedPlacements.filter((placement) => + GENERATED_UI_PLACEMENTS.includes(placement) + ) + ) + ) + : []; + if (validPlacements.length > 0) { + setPlacements(validPlacements); + } + } catch (e) { + console.error('Failed to parse saved generated UI placements', e); + } + } + }, []); + + const { + configuration, + exportError, + getGeneratedUiConfiguration, + isDevMode, + toggleDevMode, + isSyncingPreview, + handleSyncToEmbeddedChat, + handleCopyGeneratedUiConfiguration, + handleDownloadGeneratedUiConfiguration, + } = useGeneratedUiExport({ + componentId, + componentTitle, + draftBlocks, + surface, + draftComponentType, + placements, + dispatchToastMessage, + }); + + const handleGenerate = useCallback(async () => { + if (!prompt.trim() || isGenerating) return; + + if (provider === 'openai' && !openAIKey.trim()) { + dispatchToastMessage({ + type: 'error', + message: 'OpenAI API Key is required.', + }); + return; + } + + setIsGenerating(true); + setErrorMsg(null); + + try { + let adapter; + if (provider === 'ollama') { + adapter = new OllamaAdapter({ + baseUrl, + model: modelName, + }); + } else if (provider === 'openai') { + adapter = new OpenAIAdapter({ + apiKey: openAIKey, + model: openAIModel, + baseUrl: openAIBaseUrl || undefined, + }); + } else { + adapter = new MockAdapter(); + } + + const { blocks: updatedBlocks, componentType: updatedType } = + await adapter.generateUIBlocks(prompt, draftBlocks); + setDraftBlocks(updatedBlocks); + setDraftComponentType(updatedType); + localStorage.setItem('ec_draft_blocks', JSON.stringify(updatedBlocks)); + localStorage.setItem('ec_draft_component_type', updatedType); + setPrompt(''); + + dispatchToastMessage({ + type: 'success', + message: 'UI Blocks updated successfully!', + }); + } catch (e) { + console.error('[AI Code Panel]', e); + setErrorMsg(e.message || String(e)); + dispatchToastMessage({ + type: 'error', + message: `Generation failed: ${e.message || String(e)}`, + }); + } finally { + setIsGenerating(false); + } + }, [ + prompt, + provider, + baseUrl, + modelName, + openAIKey, + openAIModel, + openAIBaseUrl, + draftBlocks, + isGenerating, + dispatchToastMessage, + ]); + + const handlePublish = useCallback(() => { + try { + publishConfiguration(getGeneratedUiConfiguration()); + dispatchToastMessage({ + type: 'success', + message: 'Component applied to the editor preview.', + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error.message }); + } + }, [publishConfiguration, getGeneratedUiConfiguration, dispatchToastMessage]); + + const togglePlacement = useCallback((placement) => { + setPlacements((currentPlacements) => { + const isSelected = currentPlacements.includes(placement); + const nextPlacements = isSelected + ? currentPlacements.length === 1 + ? currentPlacements + : currentPlacements.filter( + (currentPlacement) => currentPlacement !== placement + ) + : [...currentPlacements, placement]; + + localStorage.setItem( + 'ec_draft_placements', + JSON.stringify(nextPlacements) + ); + return nextPlacements; + }); + }, []); + + const handleReset = useCallback(() => { + setDraftBlocks([]); + setDraftComponentType('info'); + setErrorMsg(null); + localStorage.removeItem('ec_draft_blocks'); + localStorage.removeItem('ec_draft_component_type'); + localStorage.removeItem('ec_draft_placements'); + localStorage.removeItem('ec_draft_component_id'); + localStorage.removeItem('ec_draft_component_title'); + setComponentId('generated-ui'); + setComponentTitle('Generated UI'); + setPlacements(['composer']); + dispatchToastMessage({ + type: 'success', + message: 'Draft blocks cleared.', + }); + }, [dispatchToastMessage]); + + const handleCopyJSON = useCallback(() => { + const jsonStr = JSON.stringify(draftBlocks, null, 2); + navigator.clipboard + .writeText(jsonStr) + .then(() => { + dispatchToastMessage({ + type: 'success', + message: 'JSON blocks copied to clipboard.', + }); + }) + .catch((err) => { + console.error('Copy failed', err); + dispatchToastMessage({ + type: 'error', + message: 'Failed to copy JSON blocks.', + }); + }); + }, [draftBlocks, dispatchToastMessage]); + + return ( + + {/* Header */} + setOpen((o) => !o)} + is="button" + type="button" + aria-expanded={open} + > + + + AI Component Generator + UI-Kit + + {open ? '▲' : '▼'} + + + {/* Body */} + {open && ( + + {/* AI Provider selector */} + + AI Provider + + + + {provider === 'ollama' && ( + <> + + Ollama URL + setBaseUrl(e.target.value)} + placeholder="http://localhost:11434" + /> + + + Model + setModelName(e.target.value)} + placeholder="qwen2.5:3b" + /> + + + )} + + {provider === 'openai' && ( + <> + + OpenAI API Key + { + setOpenAIKey(e.target.value); + localStorage.setItem('ec_openai_key', e.target.value); + }} + placeholder="sk-..." + aria-label="OpenAI API Key" + /> + + + OpenAI Model + { + setOpenAIModel(e.target.value); + localStorage.setItem('ec_openai_model', e.target.value); + }} + placeholder="gpt-4o" + aria-label="OpenAI Model" + /> + + + OpenAI Base URL + setOpenAIBaseUrl(e.target.value)} + placeholder="https://api.openai.com/v1" + aria-label="OpenAI Base URL" + /> + + + )} + + {/* Prompt */} + + Describe UI Component / Changes +