From deee2b54493044a4c936e39668d547f1f8a5608f Mon Sep 17 00:00:00 2001 From: Aryan-Verma-999 Date: Thu, 16 Jul 2026 18:03:18 +0530 Subject: [PATCH 1/6] feat(layout-editor): add AI content preview to composer toolbar and fix surface selector layout alignment --- packages/ai-adapter/package.json | 4 + packages/ai-adapter/src/BaseAIAdapter.ts | 5 + .../ai-adapter/src/adapters/GeminiAdapter.ts | 8 + .../ai-adapter/src/adapters/MockAdapter.ts | 150 +++++ .../ai-adapter/src/adapters/OllamaAdapter.ts | 37 ++ .../ai-adapter/src/adapters/OpenAIAdapter.ts | 66 +++ packages/ai-adapter/src/index.ts | 6 + packages/ai-adapter/src/types.ts | 6 + packages/ai-adapter/src/utils/validation.ts | 270 +++++++++ packages/layout_editor/package.json | 1 + .../src/components/PreviewErrorBoundary.jsx | 46 ++ .../src/store/aiGeneratedBlocksStore.js | 15 + .../src/store/chatInputItemsStore.js | 2 +- .../src/store/messageItemsStore.js | 1 + .../src/views/ChatInput/ChatInputToolbar.jsx | 69 ++- .../src/views/Message/Message.jsx | 8 +- .../src/views/Message/MessageToolbox.jsx | 71 ++- .../src/views/ThemeLab/AICodePanel.jsx | 512 ++++++++++++++++++ .../src/views/ThemeLab/AICodePanel.styles.js | 241 +++++++++ .../src/views/ThemeLab/ThemeSetting.jsx | 2 + packages/layout_editor/vite.config.ts | 10 + 21 files changed, 1523 insertions(+), 7 deletions(-) create mode 100644 packages/ai-adapter/src/adapters/MockAdapter.ts create mode 100644 packages/ai-adapter/src/utils/validation.ts create mode 100644 packages/layout_editor/src/components/PreviewErrorBoundary.jsx create mode 100644 packages/layout_editor/src/store/aiGeneratedBlocksStore.js create mode 100644 packages/layout_editor/src/views/ThemeLab/AICodePanel.jsx create mode 100644 packages/layout_editor/src/views/ThemeLab/AICodePanel.styles.js diff --git a/packages/ai-adapter/package.json b/packages/ai-adapter/package.json index ec0ff25dd3..74dd55c57a 100644 --- a/packages/ai-adapter/package.json +++ b/packages/ai-adapter/package.json @@ -35,5 +35,9 @@ "rollup-plugin-dts": "^6.0.1", "rollup-plugin-esbuild": "^5.0.0", "typescript": "^5.0.0" + }, + "dependencies": { + "@rocket.chat/ui-kit": "^0.31.25", + "ajv": "^8.12.0" } } 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..84ebdd7fc2 --- /dev/null +++ b/packages/ai-adapter/src/adapters/MockAdapter.ts @@ -0,0 +1,150 @@ +// 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"; + +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 { blocks: blocks as LayoutBlock[], 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..040de23d10 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,37 @@ 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..fb95c50f52 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,66 @@ 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..d6c1b781a3 --- /dev/null +++ b/packages/ai-adapter/src/utils/validation.ts @@ -0,0 +1,270 @@ +import { LayoutBlock } from "@rocket.chat/ui-kit"; +import Ajv from "ajv"; + +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 const UI_KIT_JSON_SCHEMA = { + type: "object", + properties: { + componentType: { + type: "string", + enum: ["form", "profile", "gallery", "cta", "info"], + }, + blocks: { + type: "array", + items: { + anyOf: [ + { + type: "object", + properties: { + type: { type: "string", const: "divider" }, + }, + required: ["type"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { type: "string", const: "image" }, + imageUrl: { type: "string" }, + altText: { type: "string" }, + }, + required: ["type", "imageUrl", "altText"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { type: "string", const: "section" }, + text: { + type: "object", + properties: { + type: { type: "string", enum: ["plain_text", "mrkdwn"] }, + text: { type: "string" }, + }, + required: ["type", "text"], + additionalProperties: false, + }, + accessory: { + anyOf: [ + { type: "null" }, + { + type: "object", + properties: { + type: { type: "string", const: "button" }, + text: { + type: "object", + properties: { + type: { type: "string", const: "plain_text" }, + text: { type: "string" }, + }, + required: ["type", "text"], + additionalProperties: false, + }, + actionId: { type: "string" }, + value: { type: "string" }, + }, + required: ["type", "text", "actionId", "value"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { type: "string", const: "image" }, + imageUrl: { type: "string" }, + altText: { type: "string" }, + }, + required: ["type", "imageUrl", "altText"], + additionalProperties: false, + }, + ], + }, + }, + required: ["type", "text", "accessory"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { type: "string", const: "actions" }, + elements: { + type: "array", + items: { + type: "object", + properties: { + type: { type: "string", const: "button" }, + text: { + type: "object", + properties: { + type: { type: "string", const: "plain_text" }, + text: { type: "string" }, + }, + required: ["type", "text"], + additionalProperties: false, + }, + actionId: { type: "string" }, + value: { type: "string" }, + }, + required: ["type", "text", "actionId", "value"], + additionalProperties: false, + }, + }, + }, + required: ["type", "elements"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { type: "string", const: "input" }, + element: { + type: "object", + properties: { + type: { type: "string", const: "plain_text_input" }, + actionId: { type: "string" }, + placeholder: { + anyOf: [ + { type: "null" }, + { + type: "object", + properties: { + type: { type: "string", const: "plain_text" }, + text: { type: "string" }, + }, + required: ["type", "text"], + additionalProperties: false, + }, + ], + }, + }, + required: ["type", "actionId", "placeholder"], + additionalProperties: false, + }, + label: { + type: "object", + properties: { + type: { type: "string", const: "plain_text" }, + text: { type: "string" }, + }, + required: ["type", "text"], + additionalProperties: false, + }, + }, + required: ["type", "element", "label"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { type: "string", const: "context" }, + elements: { + type: "array", + items: { + anyOf: [ + { + type: "object", + properties: { + type: { + type: "string", + enum: ["plain_text", "mrkdwn"], + }, + text: { type: "string" }, + }, + required: ["type", "text"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { type: "string", const: "image" }, + imageUrl: { type: "string" }, + altText: { type: "string" }, + }, + required: ["type", "imageUrl", "altText"], + additionalProperties: false, + }, + ], + }, + }, + }, + required: ["type", "elements"], + additionalProperties: false, + }, + ], + }, + }, + }, + required: ["blocks", "componentType"], + additionalProperties: false, +}; + +const ajv = new Ajv(); +const validate = ajv.compile(UI_KIT_JSON_SCHEMA); + +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}. Response was: ${text}` + ); + } + + 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 = validate(parsed); + if (!valid) { + const errorText = ajv.errorsText(validate.errors); + throw new Error( + `AI layout response schema validation failed: ${errorText}. Response was: ${text}` + ); + } + + return { + blocks: parsed.blocks, + componentType: parsed.componentType || "info", + }; +} 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/components/PreviewErrorBoundary.jsx b/packages/layout_editor/src/components/PreviewErrorBoundary.jsx new file mode 100644 index 0000000000..de805f1341 --- /dev/null +++ b/packages/layout_editor/src/components/PreviewErrorBoundary.jsx @@ -0,0 +1,46 @@ +import React from 'react'; + +class PreviewErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('[PreviewErrorBoundary] caught rendering error:', error, errorInfo); + } + + componentDidUpdate(prevProps) { + if (prevProps.children !== this.props.children) { + this.setState({ hasError: false, error: null }); + } + } + + render() { + if (this.state.hasError) { + return ( +
+ Preview Render Error: {this.state.error?.message || String(this.state.error)} +
+ ); + } + + return this.props.children; + } +} + +export default PreviewErrorBoundary; diff --git a/packages/layout_editor/src/store/aiGeneratedBlocksStore.js b/packages/layout_editor/src/store/aiGeneratedBlocksStore.js new file mode 100644 index 0000000000..0604fe5056 --- /dev/null +++ b/packages/layout_editor/src/store/aiGeneratedBlocksStore.js @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +const useAiGeneratedBlocksStore = create((set) => ({ + publishedBlocks: [], + publishedSurface: 'message', // 'message' | 'contextualBar' | 'modal' + publishedComponentType: 'info', // 'form' | 'profile' | 'gallery' | 'cta' | 'info' + publishBlocks: (blocks, surface, componentType = 'info') => + set({ + publishedBlocks: blocks, + publishedSurface: surface, + publishedComponentType: componentType, + }), +})); + +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..e00be019eb 100644 --- a/packages/layout_editor/src/views/ChatInput/ChatInputToolbar.jsx +++ b/packages/layout_editor/src/views/ChatInput/ChatInputToolbar.jsx @@ -5,6 +5,9 @@ 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 PreviewErrorBoundary from '../../components/PreviewErrorBoundary'; +import { UiKitMessage, UiKitModal, UiKitContextualBar } from '@embeddedchat/ui-kit'; import { DndContext, closestCenter, @@ -17,7 +20,15 @@ import { import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable'; import { createPortal } from 'react-dom'; -const ChatInputToolbar = () => { +const componentTypeIconMap = { + form: 'edit', + profile: 'user', + gallery: 'file', + cta: 'star', + info: 'info', +}; + +const ChatInputToolbar = ({ messageRef, inputRef }) => { const styles = getChatInputToolbarStyles(useTheme()); const { surfaceItems, setSurfaceItems, formatters, setFormatters } = useChatInputItemsStore((state) => ({ @@ -29,6 +40,15 @@ const ChatInputToolbar = () => { const [activeSurfaceItem, setActiveSurfaceItem] = useState(null); const [formattersVisible, setFormattersVisible] = useState(false); + const [aiOpen, setAiOpen] = useState(false); + + const { publishedBlocks, publishedSurface, publishedComponentType } = useAiGeneratedBlocksStore( + (state) => ({ + publishedBlocks: state.publishedBlocks, + publishedSurface: state.publishedSurface, + publishedComponentType: state.publishedComponentType, + }) + ); const placeholderSurfaceItem = 'placeholder-surface'; @@ -78,8 +98,17 @@ const ChatInputToolbar = () => { iconName: 'format-text', visible: true, }, + ai: { + label: 'AI-Generated Content', + id: 'ai', + onClick: () => { + setAiOpen((prev) => !prev); + }, + iconName: componentTypeIconMap[publishedComponentType] || 'info', + visible: true, + }, }; - }, []); + }, [setAiOpen, publishedComponentType]); const sensors = useSensors( useSensor(PointerSensor, { @@ -174,6 +203,42 @@ const ChatInputToolbar = () => { onRemove={removeSurfaceItem} /> )} + {aiOpen && ( + +
+ AI Generated Component + +
+ + {publishedBlocks && publishedBlocks.length > 0 ? ( + publishedSurface === 'contextualBar' + ? UiKitContextualBar(publishedBlocks) + : publishedSurface === 'modal' + ? UiKitModal(publishedBlocks) + : UiKitMessage(publishedBlocks) + ) : ( +
+ No AI content published yet. +
+ )} +
+
+ )} {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..c2ac31e53b 100644 --- a/packages/layout_editor/src/views/Message/MessageToolbox.jsx +++ b/packages/layout_editor/src/views/Message/MessageToolbox.jsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { Box, useTheme } from '@embeddedchat/ui-elements'; +import { Box, useTheme, ActionButton, Tooltip } from '@embeddedchat/ui-elements'; import { Menu } from '../../components/SortableMenu'; import { getMessageToolboxStyles } from './Message.styles'; import SurfaceMenu from '../../components/SurfaceMenu/SurfaceMenu'; @@ -17,6 +17,17 @@ import { import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable'; import { createPortal } from 'react-dom'; import useMessageItemsStore from '../../store/messageItemsStore'; +import useAiGeneratedBlocksStore from '../../store/aiGeneratedBlocksStore'; +import PreviewErrorBoundary from '../../components/PreviewErrorBoundary'; +import { UiKitMessage, UiKitModal, UiKitContextualBar } from '@embeddedchat/ui-kit'; + +const componentTypeIconMap = { + form: 'edit', + profile: 'user', + gallery: 'file', + cta: 'star', + info: 'info', +}; export const MessageToolbox = ({ variantStyles = {}, ...props }) => { const styles = getMessageToolboxStyles(useTheme()); @@ -29,6 +40,15 @@ export const MessageToolbox = ({ variantStyles = {}, ...props }) => { })); const [activeSurfaceItem, setActiveSurfaceItem] = useState(null); const [activeMenuItem, setActiveMenuItem] = useState(null); + const [openSection, setOpenSection] = useState(null); + const [aiOpen, setAiOpen] = useState(false); + const { publishedBlocks, publishedSurface, publishedComponentType } = useAiGeneratedBlocksStore( + (state) => ({ + publishedBlocks: state.publishedBlocks, + publishedSurface: state.publishedSurface, + publishedComponentType: state.publishedComponentType, + }) + ); const placeholderSurfaceItem = 'placeholder-surface'; const placeholderMenuItem = 'placeholder-menu'; @@ -164,8 +184,17 @@ export const MessageToolbox = ({ variantStyles = {}, ...props }) => { visible: true, type: 'destructive', }, + ai: { + label: 'AI-Generated Content', + id: 'ai', + onClick: () => { + setAiOpen((prev) => !prev); + }, + iconName: componentTypeIconMap[publishedComponentType] || 'info', + visible: true, + }, }), - [] + [setAiOpen, publishedComponentType] ); const menuOptions = @@ -222,7 +251,7 @@ export const MessageToolbox = ({ variantStyles = {}, ...props }) => { onDragEnd={handleDragEnd} onDragStart={handleDragStart} > - + {surfaceOptions?.length > 0 && ( { onRemove={removeMenuItem} /> )} + {aiOpen && ( + +
+ AI Generated Component + +
+ + {publishedBlocks && publishedBlocks.length > 0 ? ( + publishedSurface === 'contextualBar' + ? UiKitContextualBar(publishedBlocks) + : publishedSurface === 'modal' + ? UiKitModal(publishedBlocks) + : UiKitMessage(publishedBlocks) + ) : ( +
+ No AI content published yet. +
+ )} +
+
+ )}
{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..94122ef202 --- /dev/null +++ b/packages/layout_editor/src/views/ThemeLab/AICodePanel.jsx @@ -0,0 +1,512 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + Box, + useTheme, + useToastBarDispatch, + Icon, +} from '@embeddedchat/ui-elements'; +import { UiKitMessage, UiKitModal, UiKitContextualBar } 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 useLayoutStore from '../../store/layoutStore'; +import useAiGeneratedBlocksStore from '../../store/aiGeneratedBlocksStore'; +import PreviewErrorBoundary from '../../components/PreviewErrorBoundary'; + +const AICodePanel = ({ onSaveLayout }) => { + 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 [errorMsg, setErrorMsg] = useState(null); + const [tab, setTab] = useState('preview'); + const [surface, setSurface] = useState('message'); + + const publishBlocks = useAiGeneratedBlocksStore((state) => state.publishBlocks); + + // Load draft from storage on mount + useEffect(() => { + const saved = localStorage.getItem('ec_draft_blocks'); + if (saved) { + try { + setDraftBlocks(JSON.parse(saved)); + } catch (e) { + console.error('Failed to parse saved draft blocks', e); + } + } + const savedType = localStorage.getItem('ec_draft_component_type'); + if (savedType) { + setDraftComponentType(savedType); + } + }, []); + + 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(() => { + if (!draftBlocks || draftBlocks.length === 0) return; + publishBlocks(draftBlocks, surface, draftComponentType); + dispatchToastMessage({ + type: 'success', + message: `Layout published as ${surface}!`, + }); + }, [draftBlocks, surface, draftComponentType, publishBlocks, dispatchToastMessage]); + + + const handleCopyConfig = useCallback(() => { + const jsonStr = JSON.stringify(draftBlocks, null, 2); + const indentedJson = jsonStr.replace(/\n/g, '\n '); + const jsxSnippet = ` {\n // TODO: handle interaction — interaction.type is 'blockAction' (button clicks) or 'stateUpdate' (input changes)\n console.log(interaction);\n },\n }}\n/>`; + + navigator.clipboard + .writeText(jsxSnippet) + .then(() => { + dispatchToastMessage({ + type: 'success', + message: 'JSX configuration copied to clipboard.', + }); + }) + .catch((err) => { + console.error('Copy config failed', err); + dispatchToastMessage({ + type: 'error', + message: 'Failed to copy JSX configuration.', + }); + }); + }, [draftBlocks, dispatchToastMessage]); + + const handleReset = useCallback(() => { + setDraftBlocks([]); + setDraftComponentType('info'); + setErrorMsg(null); + localStorage.removeItem('ec_draft_blocks'); + localStorage.removeItem('ec_draft_component_type'); + 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)} + role="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 +