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
133 changes: 117 additions & 16 deletions components/Interface-Chatbot/Messages/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,46 @@ import React from "react";
import ReactMarkdown from "react-markdown";
import ImageWithFallback from "./ImageWithFallback";
import "./Message.css";
import RenderNode from "../../richUI/RenderNode";
import { componentRegistry } from "../../richUI/componentRegistry";
import { resolveNode } from "../../../utils/templateEngine";
const remarkGfm = dynamic(() => import('remark-gfm'), { ssr: false });

/**
* Helper function to detect if content contains HTML tags
*/
const applyAiResponsePaths = (baseNode: any, ai: Record<string, any> = {}) => {
console.log("base Node ", baseNode)
console.log("ai ", ai)
Comment on lines +27 to +29
const out = JSON.parse(JSON.stringify(baseNode || {}));

const setByPath = (obj: any, path: string, value: any) => {
const keys: Array<string | number> = [];
path.replace(/([^[.\]]+)|\[(\d+)\]/g, (_m, prop, idx) => {
keys.push(idx !== undefined ? Number(idx) : prop);
return "";
});

if (!keys.length) return;
let cur = obj;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
const nk = keys[i + 1];
if (cur[k] === undefined || cur[k] === null) {
cur[k] = typeof nk === "number" ? [] : {};
}
cur = cur[k];
}
cur[keys[keys.length - 1]] = value;
Comment on lines +32 to +49
};

Object.entries(ai || {}).forEach(([k, v]) => {
if (k === "widget_id") return;
setByPath(out, k, v);
});

return out;
};
function isHTMLContent(content: string): boolean {
if (!content || typeof content !== 'string') return false;
// Check for common HTML tags
Expand Down Expand Up @@ -84,11 +119,22 @@ const AssistantMessageCard = React.memo(
}: any) => {
const [isCopied, setIsCopied] = React.useState(false);
const handleCopy = () => {
copy(message?.chatbot_message || message?.content);
const raw = message?.chatbot_message ?? message?.content;

let textToCopy = "";

if (typeof raw === "string") {
textToCopy = raw;
} else if (raw && typeof raw === "object") {
// Rich UI JSON/object case
textToCopy = JSON.stringify(raw, null, 2);
} else {
textToCopy = String(raw ?? "");
}

copy(textToCopy);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 1500);
setTimeout(() => setIsCopied(false), 1500);
};

const themePalette = {
Expand Down Expand Up @@ -202,17 +248,20 @@ const AssistantMessageCard = React.memo(
)
}
{(() => {
const parsedContent = isJSONString(
isError
? message?.error
: message?.chatbot_message || message?.content
)
? JSON.parse(
isError
? message.error
: message?.chatbot_message || message?.content
)
: null;
const rawContent = isError
? message?.error
: message?.chatbot_message || message?.content;

let parsedContent: any = null;
if (typeof rawContent === "object" && rawContent !== null) {
parsedContent = rawContent;
} else if (isJSONString(rawContent)) {
try {
parsedContent = JSON.parse(rawContent as string);
} catch (e) {
// ignore
}
}

if (
parsedContent &&
Expand Down Expand Up @@ -251,6 +300,58 @@ const AssistantMessageCard = React.memo(
? message?.chatbot_message || message?.content
: message.error;

// Check if it's Rich UI JSON
if (
parsedContent &&
parsedContent.type &&
componentRegistry[parsedContent.type]
) {
const finalContent = message?.ai_response
? applyAiResponsePaths(parsedContent, message.ai_response)
: parsedContent;
return (
<div className="mt-4 richui-container w-full">
<RenderNode
node={finalContent}
onAction={(action: any) => {
if (action?.type === "reply" && action?.text) {
if (typeof window !== "undefined") {
window.postMessage({ type: "askAi", data: action.text }, "*");
}
}
}}
/>
</div>
);
}

// Check if it's an array of Rich UI nodes
if (
Array.isArray(parsedContent) &&
parsedContent.length > 0 &&
parsedContent[0] &&
parsedContent[0].type &&
componentRegistry[parsedContent[0].type]
) {
const finalContent = message?.ai_response
? resolveNode(parsedContent, message.ai_response)
: parsedContent;
return (
<div className="mt-4 richui-container w-full">
<RenderNode
node={finalContent}
onAction={(action: any) => {
if (action?.type === "reply" && action?.text) {
if (typeof window !== "undefined") {
window.postMessage({ type: "askAi", data: action.text }, "*");
}
}
}}
/>
</div>
);
}

if (isHTMLContent(messageContent)) {
return (
<div
Expand All @@ -270,7 +371,7 @@ const AssistantMessageCard = React.memo(
a: Anchor,
}}
>
{messageContent}
{typeof messageContent === "string" ? messageContent : JSON.stringify(messageContent)}
</ReactMarkdown>
);
})()}
Expand Down
71 changes: 71 additions & 0 deletions components/richUI/RenderNode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";
import React from "react";
import { componentRegistry } from "./componentRegistry";

/**
* RenderNode — recursive renderer for the richUI JSON tree.
*
* Each node is a plain JSON object with at minimum a `type` field.
* The matching React component is looked up from componentRegistry and rendered,
* receiving all node props plus:
* - onAction (action callback for buttons/clickable elements)
* - renderNode (so layout components can recursively render children)
* - actionDefs (resolved action_definitions map, forwarded to buttons)
*
* @param {Object} props.node - A richUI JSON node (or array of nodes)
* @param {Function} props.onAction - Callback: (action) => void, called by ButtonComponent
* @param {Object} props.actionDefs - Map of named action definitions (from widget doc)
*/
export default function RenderNode({ node, onAction, actionDefs }) {
if (!node) return null;

const wrappedOnAction = (action) => {
if (onAction) onAction(action);
};

// Support top-level arrays (e.g. when {{items}} resolves to an array of nodes)
if (Array.isArray(node)) {
return (
<>
{node.map((item, i) => (
<RenderNode
key={item?.id ?? i}
node={item}
onAction={onAction} // Keep passing original to children, they will wrap it
actionDefs={actionDefs}
/>
))}
</>
);
}

if (typeof node !== "object") return null;

const Component = componentRegistry[node.type];

if (!Component) {
// Render children anyway so the tree isn't silently swallowed
if (Array.isArray(node.children) && node.children.length > 0) {
return (
<>
{node.children.map((child, i) => (
<RenderNode
key={child?.id ?? i}
node={child}
onAction={onAction} // pass down original
actionDefs={actionDefs}
/>
))}
</>
);
}
return null;
}

// Destructure `key` and `type` out — React requires key passed directly; type is not a valid DOM prop
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { key, type, ...nodeProps } = node;
return (
<Component key={key} {...nodeProps} onAction={wrappedOnAction} actionDefs={actionDefs} renderNode={RenderNode} />
);
}
48 changes: 48 additions & 0 deletions components/richUI/componentRegistry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";
/**
* componentRegistry.js
*
* Maps richUI JSON node `type` strings to their corresponding React components.
* Import this from RenderNode.js and anywhere else that needs type → component resolution.
*/

import CardComponent from "./components/CardComponent";
import RowComponent from "./components/RowComponent";
import ColComponent from "./components/ColComponent";
import TextComponent from "./components/TextComponent";
import TitleComponent from "./components/TitleComponent";
import ButtonComponent from "./components/ButtonComponent";
import ImageComponent from "./components/ImageComponent";
import DividerComponent from "./components/DividerComponent";
import SpacerComponent from "./components/SpacerComponent";
import BoxComponent from "./components/BoxComponent";
import IconComponent from "./components/IconComponent";
import ListViewComponent from "./components/ListViewComponent";
import ListViewItemComponent from "./components/ListViewItemComponent";
import BadgeComponent from "./components/BadgeComponent";
import DatePickerComponent from "./components/DatePickerComponent";
import SelectComponent from "./components/SelectComponent";
import CaptionComponent from "./components/CaptionComponent";
import TableComponent from "./components/TableComponent";

export const componentRegistry = {
Card: CardComponent,
Row: RowComponent,
Col: ColComponent,
Text: TextComponent,
Title: TitleComponent,
Button: ButtonComponent,
Image: ImageComponent,
Divider: DividerComponent,
Spacer: SpacerComponent,
Box: BoxComponent,
Icon: IconComponent,
ListView: ListViewComponent,
ListViewItem: ListViewItemComponent,
Badge: BadgeComponent,
DatePicker: DatePickerComponent,
DateRangePicker: (props) => <DatePickerComponent {...props} range={true} />,
Select: SelectComponent,
Caption: CaptionComponent,
Table: TableComponent,
};
40 changes: 40 additions & 0 deletions components/richUI/components/BadgeComponent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";
/**
* BadgeComponent — small status pill/chip
* { type: "Badge", label: "New", variant?: "primary|secondary|accent|error|warning|success|ghost|outline|neutral",
* size?: "xs|sm|md|lg", dot?: false, className?: "" }
*/
export default function BadgeComponent({
label = "",
variant = "primary",
size = "sm",
dot = false,
className = "",
style,
}) {
const safeStyle = style && typeof style === "object" ? style : {};

const variantMap = {
primary: "badge-primary",
secondary: "badge-secondary",
accent: "badge-accent",
ghost: "badge-ghost",
outline: "badge-outline",
error: "badge-error",
warning: "badge-warning",
success: "badge-success",
neutral: "badge-neutral",
};

const sizeMap = { xs: "badge-xs text-[10px]", sm: "badge-sm", md: "", lg: "badge-lg" };

return (
<span
className={`badge ${variantMap[variant] ?? "badge-primary"} ${sizeMap[size] ?? ""} font-medium gap-1 ${className}`}
style={safeStyle}
>
{dot && <span className="inline-block w-1.5 h-1.5 rounded-full bg-current opacity-80" />}
{label}
</span>
);
}
Loading
Loading