", version: "2" });
+ // The first tag is taken, so the second definition gets its own.
+ expect(customElements.get("chativa-genui-tagged-1")).toBeDefined();
+ });
+
+ it("honours an explicit tag", () => {
+ defineGenUIComponent({ name: "custom-tag", template: "
x
", tag: "my-server-widget" });
+ expect(customElements.get("my-server-widget")).toBeDefined();
+ });
+
+ it("refuses to let a definition prop take over html/css/unsafe", async () => {
+ const el = await mount(defineGenUIComponent({
+ name: "hijack",
+ template: "
safe
",
+ props: { unsafe: true, html: "" },
+ }));
+
+ expect(el.unsafe).toBe(false);
+ expect(el.shadowRoot!.innerHTML).not.toContain("script>");
+ el.remove();
+ });
+
+ it("exposes the source definition on the class", () => {
+ const Ctor = defineGenUIComponent(ORDER_CARD) as unknown as { definition: GenUIComponentDefinition };
+ expect(Ctor.definition.name).toBe("order-card");
+ });
+});
diff --git a/packages/core/src/ui/__tests__/template.test.ts b/packages/core/src/ui/__tests__/template.test.ts
new file mode 100644
index 0000000..94aa80f
--- /dev/null
+++ b/packages/core/src/ui/__tests__/template.test.ts
@@ -0,0 +1,136 @@
+import { describe, it, expect } from "vitest";
+import { renderTemplate, escapeHtml } from "../template";
+
+describe("escapeHtml", () => {
+ it("escapes the characters that could break out of text or an attribute", () => {
+ expect(escapeHtml(`&"'`)).toBe("<b>&"'");
+ });
+
+ it("renders null/undefined as empty", () => {
+ expect(escapeHtml(null)).toBe("");
+ expect(escapeHtml(undefined)).toBe("");
+ });
+
+ it("stringifies numbers and booleans", () => {
+ expect(escapeHtml(0)).toBe("0");
+ expect(escapeHtml(false)).toBe("false");
+ });
+});
+
+describe("renderTemplate — interpolation", () => {
+ it("substitutes a top-level value", () => {
+ expect(renderTemplate("
{{title}}
", { title: "Order" })).toBe("
Order
");
+ });
+
+ it("walks a dotted path", () => {
+ expect(renderTemplate("{{user.name}}", { user: { name: "Hamza" } })).toBe("Hamza");
+ });
+
+ it("tolerates whitespace inside the tag", () => {
+ expect(renderTemplate("{{ title }}", { title: "x" })).toBe("x");
+ });
+
+ it("renders an unknown path as empty", () => {
+ expect(renderTemplate("[{{nope.deep}}]", { title: "x" })).toBe("[]");
+ });
+
+ it("escapes interpolated values — a prop cannot inject markup", () => {
+ const out = renderTemplate("
");
+ });
+});
diff --git a/packages/core/src/ui/defineGenUIComponent.ts b/packages/core/src/ui/defineGenUIComponent.ts
new file mode 100644
index 0000000..928f5c6
--- /dev/null
+++ b/packages/core/src/ui/defineGenUIComponent.ts
@@ -0,0 +1,126 @@
+import type { PropertyDeclaration } from "lit";
+import type { GenUIComponentDefinition } from "../domain/entities/GenUI";
+import { GenUIHtmlElement } from "./GenUIHtmlElement";
+import { renderTemplate } from "./template";
+
+/** Definition-derived classes, keyed by `name@version`, so a re-announced catalog reuses them. */
+const _defined = new Map();
+
+/** `Order Card` → `order-card`; the result always contains a dash (custom elements require one). */
+function slugify(name: string): string {
+ const slug = name
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return slug || "component";
+}
+
+/** A tag nobody has claimed yet — a redefined component can't reuse its old tag. */
+function freeTag(preferred: string): string {
+ let tag = preferred;
+ let n = 1;
+ while (customElements.get(tag)) tag = `${preferred}-${n++}`;
+ return tag;
+}
+
+/** Attribute conversion follows the declared default, so plain-HTML usage works too. */
+function declarationFor(value: unknown): PropertyDeclaration {
+ if (typeof value === "number") return { type: Number };
+ if (typeof value === "boolean") return { type: Boolean };
+ if (value !== null && typeof value === "object") return { type: Object };
+ return { type: String };
+}
+
+/**
+ * Turn a server-supplied {@link GenUIComponentDefinition} into a registered
+ * custom element class.
+ *
+ * The returned class extends `GenUIHtmlElement`, so it inherits the sanitizer,
+ * the `data-event` round trip and the whole `GenUIComponentAPI` — it only swaps
+ * *where the markup comes from*: the definition's template, rendered against the
+ * component's declared props.
+ *
+ * Calling it twice with the same `name@version` returns the same class, so a
+ * reconnect that re-announces the catalog is a no-op instead of a leak of
+ * custom-element definitions.
+ *
+ * @example
+ * ```ts
+ * const OrderCard = defineGenUIComponent({
+ * name: "order-card",
+ * template: `
{{title}}
+ * {{#each lines}}
{{this.label}} — {{this.price}}
{{/each}}
+ *
`,
+ * css: `.card { border: 1px solid #e2e8f0; border-radius: 12px; padding: 12px; }`,
+ * props: { id: "", title: "", lines: [] },
+ * });
+ *
+ * GenUIRegistry.register("order-card", OrderCard); // done for you by @chativa/genui
+ * ```
+ */
+export function defineGenUIComponent(
+ definition: GenUIComponentDefinition
+): typeof GenUIHtmlElement {
+ const key = `${definition.name}@${definition.version ?? "1"}`;
+ const cached = _defined.get(key);
+ if (cached) return cached;
+
+ const declaredProps = definition.props ?? {};
+ const propNames = Object.keys(declaredProps);
+
+ const properties: Record = {};
+ for (const [name, value] of Object.entries(declaredProps)) {
+ // `html` / `css` / `unsafe` belong to the base element — a definition prop
+ // may not take them over, or a template could turn its own sanitizer off.
+ if (name === "html" || name === "css" || name === "unsafe") continue;
+ properties[name] = declarationFor(value);
+ }
+
+ class DefinedGenUIComponent extends GenUIHtmlElement {
+ static override properties = properties;
+
+ /** The metadata this class was built from — handy when debugging a catalog. */
+ static readonly definition: GenUIComponentDefinition = definition;
+
+ constructor() {
+ super();
+ // Defaults from the definition, so a chunk may send only what changed.
+ for (const name of propNames) {
+ if (name === "html" || name === "css" || name === "unsafe") continue;
+ (this as unknown as Record)[name] = structuredCloneish(declaredProps[name]);
+ }
+ this.css = definition.css ?? "";
+ }
+
+ protected override markup(): string {
+ const scope: Record = {};
+ for (const name of propNames) {
+ scope[name] = (this as unknown as Record)[name];
+ }
+ return renderTemplate(definition.template, scope);
+ }
+ }
+
+ const tag = freeTag(definition.tag ?? `chativa-genui-${slugify(definition.name)}`);
+ customElements.define(tag, DefinedGenUIComponent);
+
+ _defined.set(key, DefinedGenUIComponent);
+ return DefinedGenUIComponent;
+}
+
+/** Defaults must not be shared between instances — an `each` list would alias. */
+function structuredCloneish(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(structuredCloneish);
+ if (value !== null && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value as Record).map(([k, v]) => [k, structuredCloneish(v)])
+ );
+ }
+ return value;
+}
+
+/** Forget every derived class — tests only. */
+export function clearDefinedGenUIComponents(): void {
+ _defined.clear();
+}
diff --git a/packages/core/src/ui/template.ts b/packages/core/src/ui/template.ts
new file mode 100644
index 0000000..245b126
--- /dev/null
+++ b/packages/core/src/ui/template.ts
@@ -0,0 +1,171 @@
+/**
+ * The tiny template language backend-authored GenUI components are written in.
+ *
+ * A server-registered component ships markup, not code, so it needs just enough
+ * templating to be useful without becoming an expression evaluator on the
+ * client. Deliberately a strict subset — no arbitrary JS, no property calls, no
+ * filters:
+ *
+ * - `{{path.to.value}}` — HTML-escaped interpolation
+ * - `{{#if path}} … {{else}} … {{/if}}` — truthiness (empty string/array/0 is false)
+ * - `{{#each path}} … {{/each}}` — iteration, with `{{this}}`, `{{this.field}}`,
+ * `{{@index}}`, and parent scope still reachable by name
+ *
+ * Everything is escaped, so a value can never inject markup; the output is then
+ * sanitized again by `` before it reaches the DOM.
+ */
+
+/** Escape a value for safe use in both text and attribute positions. */
+export function escapeHtml(value: unknown): string {
+ if (value === null || value === undefined) return "";
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+// ── AST ───────────────────────────────────────────────────────────────────────
+
+interface TextNode { kind: "text"; value: string }
+interface VarNode { kind: "var"; path: string }
+interface IfNode { kind: "if"; path: string; then: Node[]; else: Node[] }
+interface EachNode { kind: "each"; path: string; body: Node[] }
+type Node = TextNode | VarNode | IfNode | EachNode;
+
+/** Lexical scope chain — an `each` body can still see the props above it. */
+interface Scope {
+ data: unknown;
+ index?: number;
+ parent?: Scope;
+}
+
+const TAG = /\{\{\s*([#/]?)([^}]*?)\s*\}\}/g;
+
+function parse(template: string): Node[] {
+ const root: Node[] = [];
+ // Each frame is the list new nodes go into, plus what closes it.
+ const stack: { nodes: Node[]; block?: IfNode | EachNode }[] = [{ nodes: root }];
+ let last = 0;
+
+ const push = (node: Node) => stack[stack.length - 1]!.nodes.push(node);
+ const text = (value: string) => { if (value) push({ kind: "text", value }); };
+
+ TAG.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = TAG.exec(template)) !== null) {
+ text(template.slice(last, m.index));
+ last = m.index + m[0].length;
+
+ const sigil = m[1];
+ const body = m[2]!.trim();
+
+ if (sigil === "#") {
+ const [keyword, ...rest] = body.split(/\s+/);
+ const path = rest.join(" ").trim();
+ if (keyword === "if") {
+ const node: IfNode = { kind: "if", path, then: [], else: [] };
+ push(node);
+ stack.push({ nodes: node.then, block: node });
+ } else if (keyword === "each") {
+ const node: EachNode = { kind: "each", path, body: [] };
+ push(node);
+ stack.push({ nodes: node.body, block: node });
+ } else {
+ // Unknown block keyword: keep the literal so the mistake is visible
+ // in the rendered widget instead of silently vanishing.
+ text(m[0]);
+ }
+ continue;
+ }
+
+ if (sigil === "/") {
+ // Closing tag — only pop when there is a block to close, so a stray
+ // `{{/if}}` can't unwind past the root.
+ if (stack.length > 1) stack.pop();
+ continue;
+ }
+
+ if (body === "else") {
+ const frame = stack[stack.length - 1];
+ if (frame?.block?.kind === "if") {
+ stack[stack.length - 1] = { nodes: frame.block.else, block: frame.block };
+ }
+ continue;
+ }
+
+ push({ kind: "var", path: body });
+ }
+
+ text(template.slice(last));
+ return root;
+}
+
+/** Walk a dotted path from the innermost scope outwards. */
+function resolve(path: string, scope: Scope): unknown {
+ if (!path) return undefined;
+ if (path === "@index") {
+ for (let s: Scope | undefined = scope; s; s = s.parent) {
+ if (s.index !== undefined) return s.index;
+ }
+ return undefined;
+ }
+ if (path === "this" || path === ".") return scope.data;
+
+ const segments = path.replace(/^this\./, "").split(".");
+ for (let s: Scope | undefined = scope; s; s = s.parent) {
+ let current: unknown = s.data;
+ let ok = true;
+ for (const segment of segments) {
+ if (current === null || current === undefined || typeof current !== "object") { ok = false; break; }
+ if (!(segment in (current as Record))) { ok = false; break; }
+ current = (current as Record)[segment];
+ }
+ if (ok) return current;
+ }
+ return undefined;
+}
+
+function truthy(value: unknown): boolean {
+ if (Array.isArray(value)) return value.length > 0;
+ return Boolean(value);
+}
+
+function renderNodes(nodes: Node[], scope: Scope): string {
+ let out = "";
+ for (const node of nodes) {
+ switch (node.kind) {
+ case "text":
+ out += node.value;
+ break;
+ case "var":
+ out += escapeHtml(resolve(node.path, scope));
+ break;
+ case "if":
+ out += renderNodes(truthy(resolve(node.path, scope)) ? node.then : node.else, scope);
+ break;
+ case "each": {
+ const list = resolve(node.path, scope);
+ if (!Array.isArray(list)) break;
+ list.forEach((item, index) => {
+ out += renderNodes(node.body, { data: item, index, parent: scope });
+ });
+ break;
+ }
+ }
+ }
+ return out;
+}
+
+/**
+ * Render a component template against its props.
+ *
+ * Never throws: an unknown path renders as an empty string, an unbalanced block
+ * closes at the end of the template. A broken widget is a bad look; a crash
+ * inside a chat bubble is worse.
+ */
+export function renderTemplate(template: string, props: Record): string {
+ if (!template) return "";
+ return renderNodes(parse(template), { data: props });
+}
diff --git a/packages/genui/src/components/GenUIMessage.ts b/packages/genui/src/components/GenUIMessage.ts
index 22bcb90..98ac0cb 100644
--- a/packages/genui/src/components/GenUIMessage.ts
+++ b/packages/genui/src/components/GenUIMessage.ts
@@ -1,6 +1,13 @@
import { html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
-import type { GenUIStreamState, AIChunk, AIChunkText, AIChunkUI, AIChunkEvent } from "@chativa/core";
+import type {
+ GenUIStreamState,
+ AIChunk,
+ AIChunkText,
+ AIChunkUI,
+ AIChunkEvent,
+ GenUIEventOptions,
+} from "@chativa/core";
import { ChativaElement, MessageTypeRegistry, chatStore, i18next, t } from "@chativa/core";
import { GenUIRegistry } from "../registry/GenUIRegistry";
import { bubbleStyles } from "../styles/bubble";
@@ -176,7 +183,12 @@ export class GenUIMessage extends ChativaElement {
// ── Event bus API (injected into child components) ────────────────────────
- private _sendEvent = (type: string, payload: unknown, sourceId?: number): void => {
+ private _sendEvent = (
+ type: string,
+ payload: unknown,
+ sourceId?: number,
+ opts?: GenUIEventOptions
+ ): void => {
// 1. Deliver to internal listeners within this message (e.g. form_success → form component)
const listeners = this._listeners.get(type);
if (listeners) {
@@ -189,7 +201,7 @@ export class GenUIMessage extends ChativaElement {
// 2. Bubble up to ChatWidget so it can be forwarded to the connector
this.dispatchEvent(
new CustomEvent("genui-send-event", {
- detail: { msgId: this.messageId, eventType: type, payload, sourceId },
+ detail: { msgId: this.messageId, eventType: type, payload, sourceId, ...opts },
bubbles: true,
composed: true,
})
@@ -240,8 +252,15 @@ export class GenUIMessage extends ChativaElement {
}
}
- // Retrieve or create the element instance
+ // Retrieve or create the element instance. A cached instance of a different
+ // class means the stream reused this id for another component — keep the id
+ // (so the element stays in place) but rebuild it, rather than assigning one
+ // component's props onto another's element.
let el = this._instances.get(chunk.id);
+ if (el && !(el instanceof entry.component)) {
+ this._instances.delete(chunk.id);
+ el = undefined;
+ }
if (!el) {
el = new entry.component() as HTMLElement;
this._instances.set(chunk.id, el);
@@ -249,8 +268,10 @@ export class GenUIMessage extends ChativaElement {
// Inject scoped event + i18n API so custom components
// don't need to depend on i18next directly.
const elAny = el as unknown as Record;
- elAny["sendEvent"] = (type: string, payload: unknown) =>
- this._sendEvent(type, payload, chunk.id);
+ // The component name is stamped here, not by the component: an interaction
+ // should always say which widget it came from, and only the chunk knows.
+ elAny["sendEvent"] = (type: string, payload: unknown, opts?: GenUIEventOptions) =>
+ this._sendEvent(type, payload, chunk.id, { component: chunk.component, ...opts });
elAny["listenEvent"] = (type: string, cb: (p: unknown) => void) =>
this._listenEvent(type, cb, chunk.id);
elAny["tFn"] = (key: string, fallback?: string) =>
diff --git a/packages/genui/src/components/__tests__/GenUIHtmlChunk.test.ts b/packages/genui/src/components/__tests__/GenUIHtmlChunk.test.ts
new file mode 100644
index 0000000..4ec06d4
--- /dev/null
+++ b/packages/genui/src/components/__tests__/GenUIHtmlChunk.test.ts
@@ -0,0 +1,96 @@
+import { describe, it, expect, vi } from "vitest";
+import type { GenUIStreamState } from "@chativa/core";
+import { GenUIHtmlElement } from "@chativa/core";
+import { GenUIRegistry } from "../../registry/GenUIRegistry";
+import "../../index";
+
+/**
+ * End-to-end path for backend-authored components: a connector streams a
+ * `{ type: "ui", component: "html" }` chunk, `GenUIMessage` mounts
+ * ``, and a `data-event` click travels back out as
+ * `genui-send-event` (which ChatWidget forwards to
+ * `IConnector.receiveComponentEvent`).
+ */
+
+const streamState = (html: string): GenUIStreamState => ({
+ chunks: [{ type: "ui", component: "html", props: { html }, id: 1 }],
+ streamingComplete: true,
+});
+
+async function mountMessage(state: GenUIStreamState) {
+ const el = document.createElement("genui-message") as HTMLElement & {
+ messageData: unknown;
+ messageId: string;
+ updateComplete: Promise;
+ };
+ el.messageId = "msg-1";
+ el.messageData = state;
+ document.body.appendChild(el);
+ await el.updateComplete;
+ return el;
+}
+
+const mountedHtmlElement = (msg: HTMLElement): GenUIHtmlElement =>
+ msg.shadowRoot!.querySelector("chativa-html") as GenUIHtmlElement;
+
+describe("backend-authored HTML chunk", () => {
+ it("registers the raw-markup component under both names", () => {
+ expect(GenUIRegistry.resolve("html")?.component).toBe(GenUIHtmlElement);
+ expect(GenUIRegistry.resolve("genui-html")?.component).toBe(GenUIHtmlElement);
+ });
+
+ it("mounts and renders the streamed markup", async () => {
+ const msg = await mountMessage(streamState(`
Order #123
`));
+ const el = mountedHtmlElement(msg);
+
+ expect(el).not.toBeNull();
+ await el.updateComplete;
+ expect(el.shadowRoot!.innerHTML).toContain("Order #123");
+
+ msg.remove();
+ });
+
+ it("routes a data-event click back out as genui-send-event", async () => {
+ const msg = await mountMessage(
+ streamState(``)
+ );
+ const el = mountedHtmlElement(msg);
+ await el.updateComplete;
+
+ const spy = vi.fn();
+ document.body.addEventListener("genui-send-event", spy as EventListener);
+ el.shadowRoot!.querySelector("button")!.click();
+
+ expect(spy).toHaveBeenCalledOnce();
+ const detail = (spy.mock.calls[0]![0] as CustomEvent).detail;
+ expect(detail).toMatchObject({
+ msgId: "msg-1",
+ eventType: "track_order",
+ payload: { id: 123 },
+ sourceId: 1,
+ });
+
+ document.body.removeEventListener("genui-send-event", spy as EventListener);
+ msg.remove();
+ });
+
+ it("updates the same instance when the backend streams new markup", async () => {
+ const msg = await mountMessage(streamState("
");
+ await msg.updateComplete;
+ const second = mountedHtmlElement(msg);
+ await second.updateComplete;
+
+ // Same element instance (keyed by chunk id), new content.
+ expect(second).toBe(first);
+ expect(second.shadowRoot!.innerHTML).toContain("step two");
+ expect(second.shadowRoot!.innerHTML).not.toContain("step one");
+
+ msg.remove();
+ });
+});
diff --git a/packages/genui/src/index.ts b/packages/genui/src/index.ts
index 65085d8..376437d 100644
--- a/packages/genui/src/index.ts
+++ b/packages/genui/src/index.ts
@@ -6,10 +6,26 @@ import "./i18n/index";
// ── Registry ──────────────────────────────────────────────────────────────────
export { GenUIRegistry } from "./registry/GenUIRegistry";
export type { GenUIEntry, GenUISchema } from "./registry/GenUIRegistry";
+export {
+ registerServerComponent,
+ subscribeServerComponents,
+ clearServerComponents,
+ setServerComponentPolicy,
+ getServerComponentPolicy,
+} from "./registry/serverComponents";
+export type { ServerComponentPolicy } from "./registry/serverComponents";
// ── Shared types ──────────────────────────────────────────────────────────────
export type { GenUIComponentAPI } from "./types";
+// ── Base class for custom components ──────────────────────────────────────────
+// Lives in @chativa/core (so core owns the contract); re-exported here so a
+// custom component only needs one import.
+export { GenUIElement, GENUI_COMPONENT_EVENT } from "@chativa/core";
+export type { GenUIComponentEventDetail } from "@chativa/core";
+export { GenUIHtmlElement, sanitizeHtml, defineGenUIComponent, renderTemplate, genUIDefinitionStore } from "@chativa/core";
+export type { GenUIComponentDefinition } from "@chativa/core";
+
// ── Components (side-effects: registers custom elements) ──────────────────────
export { GenUIMessage } from "./components/GenUIMessage";
export { GenUITextBlock } from "./components/GenUITextBlock";
@@ -56,6 +72,8 @@ export type {
// ── Register built-in components in GenUIRegistry ─────────────────────────────
// These are available out-of-the-box without any additional registration.
import { GenUIRegistry } from "./registry/GenUIRegistry";
+import { GenUIHtmlElement } from "@chativa/core";
+import { subscribeServerComponents } from "./registry/serverComponents";
import { GenUITextBlock } from "./components/GenUITextBlock";
import { GenUICard } from "./components/GenUICard";
import { GenUIForm } from "./components/GenUIForm";
@@ -83,3 +101,14 @@ GenUIRegistry.register("genui-date-picker", GenUIDatePicker as unknown as type
GenUIRegistry.register("genui-chart", GenUIChart as unknown as typeof HTMLElement);
GenUIRegistry.register("genui-steps", GenUISteps as unknown as typeof HTMLElement);
GenUIRegistry.register("genui-image-gallery", GenUIImageGallery as unknown as typeof HTMLElement);
+
+// Raw-markup escape hatch: the backend ships the HTML itself, no client build
+// step. Registered under both names — `html` is what most bots will send.
+GenUIRegistry.register("genui-html", GenUIHtmlElement as unknown as typeof HTMLElement);
+GenUIRegistry.register("html", GenUIHtmlElement as unknown as typeof HTMLElement);
+
+// ── Server-defined components ─────────────────────────────────────────────────
+// A backend that authors its own components announces them on connect; this
+// subscription turns each definition into a custom element and registers it.
+// Replays anything published before this bundle loaded.
+subscribeServerComponents();
diff --git a/packages/genui/src/registry/__tests__/serverComponents.test.ts b/packages/genui/src/registry/__tests__/serverComponents.test.ts
new file mode 100644
index 0000000..c3628d6
--- /dev/null
+++ b/packages/genui/src/registry/__tests__/serverComponents.test.ts
@@ -0,0 +1,143 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { genUIDefinitionStore, clearDefinedGenUIComponents, type GenUIComponentDefinition } from "@chativa/core";
+import { GenUIRegistry } from "../GenUIRegistry";
+import {
+ registerServerComponent,
+ subscribeServerComponents,
+ clearServerComponents,
+ setServerComponentPolicy,
+ getServerComponentPolicy,
+} from "../serverComponents";
+import "../../index";
+
+const CARD: GenUIComponentDefinition = {
+ name: "order-card",
+ template: `
{{title}}
`,
+ props: { title: "" },
+};
+
+beforeEach(() => {
+ clearServerComponents();
+ clearDefinedGenUIComponents();
+ genUIDefinitionStore.clear();
+});
+
+afterEach(() => vi.restoreAllMocks());
+
+describe("registerServerComponent", () => {
+ it("registers a definition under its name", () => {
+ expect(registerServerComponent(CARD)).toBe(true);
+ expect(GenUIRegistry.has("order-card")).toBe(true);
+ });
+
+ it("produces a constructible element that renders the template", async () => {
+ registerServerComponent(CARD);
+ const Ctor = GenUIRegistry.resolve("order-card")!.component;
+
+ const el = new Ctor() as HTMLElement & { title: string; updateComplete: Promise };
+ el.title = "Order #7";
+ document.body.appendChild(el);
+ await el.updateComplete;
+
+ expect(el.shadowRoot!.innerHTML).toContain("Order #7");
+ el.remove();
+ });
+
+ it("rejects a definition without a name or template", () => {
+ expect(registerServerComponent({ name: "", template: "
x
" })).toBe(false);
+ expect(registerServerComponent({ name: "x" } as GenUIComponentDefinition)).toBe(false);
+ });
+
+ it("refuses to overwrite a built-in", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const before = GenUIRegistry.resolve("genui-form")!.component;
+
+ expect(registerServerComponent({ name: "genui-form", template: "
hijacked
" })).toBe(false);
+ expect(GenUIRegistry.resolve("genui-form")!.component).toBe(before);
+ expect(warn).toHaveBeenCalled();
+ });
+
+ it("lets the server replace a component it defined itself", () => {
+ registerServerComponent(CARD);
+ const first = GenUIRegistry.resolve("order-card")!.component;
+
+ expect(registerServerComponent({ ...CARD, version: "2", template: "
v2
" })).toBe(true);
+ expect(GenUIRegistry.resolve("order-card")!.component).not.toBe(first);
+ });
+});
+
+describe("subscribeServerComponents", () => {
+ it("registers definitions published to the store", () => {
+ const unsub = subscribeServerComponents();
+ genUIDefinitionStore.publish([CARD]);
+
+ expect(GenUIRegistry.has("order-card")).toBe(true);
+ unsub();
+ });
+
+ it("picks up definitions published before it subscribed", () => {
+ genUIDefinitionStore.publish([CARD]);
+ const unsub = subscribeServerComponents();
+
+ expect(GenUIRegistry.has("order-card")).toBe(true);
+ unsub();
+ });
+
+ it("stops registering after unsubscribe", () => {
+ subscribeServerComponents()();
+ genUIDefinitionStore.publish([CARD]);
+
+ expect(GenUIRegistry.has("order-card")).toBe(false);
+ });
+});
+
+describe("name collisions with a locally registered component", () => {
+ /** What the sandbox does: register a component under a name the server also uses. */
+ const registerLocal = (name: string) =>
+ GenUIRegistry.register(name, class extends HTMLElement {} as unknown as typeof HTMLElement);
+
+ it("defaults to app-wins", () => {
+ expect(getServerComponentPolicy()).toBe("app-wins");
+ });
+
+ it("keeps the local component and says why", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ registerLocal("order-card");
+ const local = GenUIRegistry.resolve("order-card")!.component;
+
+ expect(registerServerComponent({ ...CARD, name: "order-card" })).toBe(false);
+ expect(GenUIRegistry.resolve("order-card")!.component).toBe(local);
+ // The blank-widget failure mode is only debuggable if the warning names the
+ // component and the way out.
+ const message = String(warn.mock.calls[0]![0]);
+ expect(message).toContain("order-card");
+ expect(message).toContain("server-wins");
+
+ GenUIRegistry.unregister("order-card");
+ });
+
+ it("lets the server win when the policy says so", () => {
+ registerLocal("order-card");
+ const local = GenUIRegistry.resolve("order-card")!.component;
+ setServerComponentPolicy("server-wins");
+
+ expect(registerServerComponent({ ...CARD, name: "order-card" })).toBe(true);
+ expect(GenUIRegistry.resolve("order-card")!.component).not.toBe(local);
+
+ GenUIRegistry.unregister("order-card");
+ });
+
+ it("still refuses to clobber a built-in under app-wins", () => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ const before = GenUIRegistry.resolve("genui-form")!.component;
+
+ expect(registerServerComponent({ name: "genui-form", template: "
x
" })).toBe(false);
+ expect(GenUIRegistry.resolve("genui-form")!.component).toBe(before);
+ });
+
+ it("clearServerComponents resets the policy", () => {
+ setServerComponentPolicy("server-wins");
+ clearServerComponents();
+ expect(getServerComponentPolicy()).toBe("app-wins");
+ });
+});
diff --git a/packages/genui/src/registry/serverComponents.ts b/packages/genui/src/registry/serverComponents.ts
new file mode 100644
index 0000000..43b233c
--- /dev/null
+++ b/packages/genui/src/registry/serverComponents.ts
@@ -0,0 +1,97 @@
+import {
+ defineGenUIComponent,
+ genUIDefinitionStore,
+ type GenUIComponentDefinition,
+} from "@chativa/core";
+import { GenUIRegistry } from "./GenUIRegistry";
+
+/** Names that came from the server, so a catalog can replace its own earlier version. */
+const _fromServer = new Set();
+
+/**
+ * Who wins when a server definition names a component the app already registered.
+ *
+ * - `"app-wins"` (default) — the local component stays. A backend must not be
+ * able to redefine `genui-form`, or the checkout widget you shipped, under
+ * your feet.
+ * - `"server-wins"` — the definition replaces the local registration. Choose
+ * this when the server is the source of truth for widgets and the local
+ * registrations are only fallbacks.
+ */
+export type ServerComponentPolicy = "app-wins" | "server-wins";
+
+let _policy: ServerComponentPolicy = "app-wins";
+
+/**
+ * Decide what happens on a name collision. Call before connecting.
+ *
+ * @example
+ * ```ts
+ * import { setServerComponentPolicy } from "@chativa/genui";
+ *
+ * setServerComponentPolicy("server-wins");
+ * ```
+ */
+export function setServerComponentPolicy(policy: ServerComponentPolicy): void {
+ _policy = policy;
+}
+
+/** The policy in force. */
+export function getServerComponentPolicy(): ServerComponentPolicy {
+ return _policy;
+}
+
+/**
+ * Register one server-defined component.
+ *
+ * A definition always replaces an earlier definition of the same name from the
+ * server itself (a new version of the same widget). A collision with a *local*
+ * registration is resolved by {@link setServerComponentPolicy}.
+ *
+ * @returns whether the component was registered.
+ */
+export function registerServerComponent(definition: GenUIComponentDefinition): boolean {
+ const { name } = definition;
+ if (!name || typeof definition.template !== "string") return false;
+
+ const collidesWithLocal = GenUIRegistry.has(name) && !_fromServer.has(name);
+ if (collidesWithLocal && _policy === "app-wins") {
+ // Silence here is the worst outcome: the local component renders with the
+ // server's props, which it was never written for, and the widget comes out
+ // blank. Say exactly what happened and what the two ways out are.
+ console.warn(
+ `[GenUI] The server defines a component named "${name}", but "${name}" is already ` +
+ `registered locally — keeping the local one (policy: "app-wins").\n` +
+ ` • the widget will render with the SERVER's props, which the local component may not expect\n` +
+ ` • to let the server win: setServerComponentPolicy("server-wins")\n` +
+ ` • or rename one of them so both can coexist.`
+ );
+ return false;
+ }
+
+ GenUIRegistry.register(name, defineGenUIComponent(definition) as unknown as typeof HTMLElement);
+ _fromServer.add(name);
+ return true;
+}
+
+/**
+ * Subscribe the registry to server-announced component catalogs.
+ *
+ * Called once as a side-effect of importing `@chativa/genui`. Subscribing
+ * replays whatever the connector already published, so it doesn't matter
+ * whether the socket connected before or after this bundle loaded.
+ *
+ * @returns an unsubscribe function (tests; apps never need it).
+ */
+export function subscribeServerComponents(): () => void {
+ return genUIDefinitionStore.subscribe((definitions) => {
+ for (const definition of definitions) registerServerComponent(definition);
+ });
+}
+
+/** Forget which names came from the server, and reset the policy — tests only. */
+export function clearServerComponents(): void {
+ for (const name of _fromServer) GenUIRegistry.unregister(name);
+ _fromServer.clear();
+ _policy = "app-wins";
+}
diff --git a/packages/genui/src/types.ts b/packages/genui/src/types.ts
index 2985c73..bc64208 100644
--- a/packages/genui/src/types.ts
+++ b/packages/genui/src/types.ts
@@ -1,34 +1,17 @@
/**
- * API injected by `GenUIMessage` into every registered custom component instance.
+ * The GenUI component contract now lives in `@chativa/core` next to the
+ * `GenUIElement` base class that implements it. Re-exported here so existing
+ * `import type { GenUIComponentAPI } from "@chativa/genui"` keeps working.
*
- * Use this interface in your custom components to type the injected properties:
+ * Prefer extending `GenUIElement` — it implements the whole API with working
+ * defaults, so you don't declare the injected properties yourself:
*
* ```ts
- * import type { GenUIComponentAPI } from "@chativa/genui";
+ * import { GenUIElement } from "@chativa/core";
*
- * class MyWidget extends LitElement implements Partial {
- * sendEvent?: GenUIComponentAPI["sendEvent"];
- * listenEvent?: GenUIComponentAPI["listenEvent"];
- * tFn?: GenUIComponentAPI["tFn"];
- * onLangChange?: GenUIComponentAPI["onLangChange"];
+ * class MyWidget extends GenUIElement {
+ * private _submit() { this.sendEvent("my_submit", { ok: true }); }
* }
* ```
*/
-export interface GenUIComponentAPI {
- /** Send an event to the connector (e.g. `"form_submit"`, `"rating_submit"`). */
- sendEvent(type: string, payload: unknown): void;
- /** Listen for a server-originated event within this message scope. */
- listenEvent(type: string, cb: (payload: unknown) => void): void;
- /**
- * Translate a key using the shared i18next instance.
- * Falls back to `fallback` if the key is not found.
- * Named `tFn` (not `translate`) to avoid conflict with the native
- * `HTMLElement.translate` boolean attribute.
- */
- tFn(key: string, fallback?: string): string;
- /**
- * Subscribe to locale changes so you can call `requestUpdate()`.
- * Returns an unsubscribe function — call it in `disconnectedCallback`.
- */
- onLangChange(cb: () => void): () => void;
-}
+export type { GenUIComponentAPI } from "@chativa/core";
diff --git a/packages/ui/src/chat-ui/AgentPanel.ts b/packages/ui/src/chat-ui/AgentPanel.ts
index 5105b02..889af9f 100644
--- a/packages/ui/src/chat-ui/AgentPanel.ts
+++ b/packages/ui/src/chat-ui/AgentPanel.ts
@@ -6,6 +6,7 @@ import {
messageStore,
conversationStore,
createOutgoingMessage,
+ type GenUIEventOptions,
} from "@chativa/core";
import { registerCommand } from "../commands/index";
import "./ConversationList";
@@ -219,13 +220,10 @@ export class AgentPanel extends LitElement {
};
private _onGenUISendEvent = (
- e: CustomEvent<{ msgId: string; eventType: string; payload: unknown }>
+ e: CustomEvent<{ msgId: string; eventType: string; payload: unknown } & GenUIEventOptions>
) => {
- this._engine.chatEngine.receiveComponentEvent(
- e.detail.msgId,
- e.detail.eventType,
- e.detail.payload
- );
+ const { msgId, eventType, payload, scope, component } = e.detail;
+ this._engine.chatEngine.receiveComponentEvent(msgId, eventType, payload, { scope, component });
};
// ── Render ────────────────────────────────────────────────────────────
diff --git a/packages/ui/src/chat-ui/ChatWidget.ts b/packages/ui/src/chat-ui/ChatWidget.ts
index 0f27e68..9ecc748 100644
--- a/packages/ui/src/chat-ui/ChatWidget.ts
+++ b/packages/ui/src/chat-ui/ChatWidget.ts
@@ -11,6 +11,7 @@ import {
createOutgoingMessage,
applyGlobalSettings,
type EndOfConversationSurveyConfig,
+ type GenUIEventOptions,
type SurveyPayload,
} from "@chativa/core";
import { ChatbotMixin } from "../mixins/ChatbotMixin";
@@ -465,8 +466,11 @@ export class ChatWidget extends ChatbotMixin(LitElement) {
.catch((err: unknown) => console.error("[ChatWidget] loadHistory failed:", err));
};
- private _onGenUISendEvent = (e: CustomEvent<{ msgId: string; eventType: string; payload: unknown }>) => {
- this._engine.receiveComponentEvent(e.detail.msgId, e.detail.eventType, e.detail.payload);
+ private _onGenUISendEvent = (
+ e: CustomEvent<{ msgId: string; eventType: string; payload: unknown } & GenUIEventOptions>
+ ) => {
+ const { msgId, eventType, payload, scope, component } = e.detail;
+ this._engine.receiveComponentEvent(msgId, eventType, payload, { scope, component });
};
// ── Multi-conversation handlers ───────────────────────────────────────
diff --git a/website/docs/genui/built-in.md b/website/docs/genui/built-in.md
index 917d502..d03e332 100644
--- a/website/docs/genui/built-in.md
+++ b/website/docs/genui/built-in.md
@@ -24,6 +24,7 @@ Auto-registered by importing `@chativa/genui`.
| `genui-steps` | Vertical step list | `steps: { label, status, description? }[]` | — |
| `genui-image-gallery` | Grid of images | `columns?, images: { src, alt?, caption? }[]` | — |
| `genui-appointment-form` | Specialised appointment form | `fields[]` | `form_submit` |
+| `genui-html` / `html` | Backend-authored raw markup ([details](./custom-component.md)) | `html: string, css?: string, unsafe?: boolean` | any `data-event` on a clicked element or submitted `