From 99664f1886530d6312b43cea4d628dcaa8a06256 Mon Sep 17 00:00:00 2001 From: Hamza Agar Date: Thu, 30 Jul 2026 13:08:14 +0300 Subject: [PATCH 1/2] feat(genui): server-defined components, and buttons that say who they're for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of mekik PROTOCOL.md §10, on the client side. Server-defined components: the backend announces a catalog on connect and `@chativa/genui` turns each definition into a custom element, so a `{ type: "ui", component: "order-card" }` chunk mounts a widget nobody compiled into the page. The catalog is cached in localStorage under the server URL and validated by a hash riding along on the `hello` frame, so a returning user's widgets render before the server answers. The other half is what a button on one of those widgets does. `` bound exactly one attribute — `data-event` — and the backend had to guess who the click was for. Now the attribute is the answer: component-event="" → scope "component" the widget's own conversation with the node that mounted it mekik-event="" → scope "graph" the application data-event="" → no scope the backend decides Most specific wins; an empty value is not a trigger; forms carry the scope too. `scope` and the originating `component` ride to the backend as optional fields on the `genui_event` frame, threaded as one optional `GenUIEventOptions` argument from `sendEvent` through `GenUIMessage`, `ChatWidget`/`AgentPanel`, `ChatEngine` and `IConnector` to `createGenUIEventFrame`. Optional at every hop on purpose: `data-event` still makes the two-argument `sendEvent` call it always made, and a frame with no routing metadata is byte-for-byte the frame backends see today. The component name is stamped by `GenUIMessage`, not by the component — only the chunk knows which widget an interaction came from, and asking components to report it themselves is asking for it to be wrong. MekikConnector: an `interrupt` frame carrying `data.event` renders nothing. That pause is waiting for a widget already on screen, so default Approve/Cancel chips would be a dead end — tapping Approve would send a `resume` the node is not waiting for. Verified against the real backend, not by inspection: chativa's `createGenUIEventFrame` output fed straight into a live mekik app resolves the node parked on `onEvent` for a component-event, reaches the graph handler with the component name for a mekik-event, and is rejected as `bad_request` for an unknown scope. 570 tests pass; 13 packages typecheck. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 79 +++-- docs/genui/built-in.md | 1 + docs/genui/custom-component.md | 302 ++++++++++++++--- docs/genui/streaming.md | 62 +++- packages/connector-http/src/HttpConnector.ts | 15 +- .../src/__tests__/MekikConnector.test.ts | 157 +++++++++ packages/connector-mekik/src/index.ts | 94 +++++- .../connector-signalr/src/SignalRConnector.ts | 15 +- packages/connector-sse/src/SseConnector.ts | 15 +- .../src/WebSocketConnector.ts | 15 +- packages/core/src/application/ChatEngine.ts | 58 +++- packages/core/src/application/EventBus.ts | 3 + .../src/application/GenUIDefinitionStore.ts | 65 ++++ .../application/__tests__/ChatEngine.test.ts | 27 +- .../__tests__/GenUIChunkMerge.test.ts | 134 ++++++++ .../__tests__/GenUIDefinitionStore.test.ts | 76 +++++ packages/core/src/application/index.ts | 1 + .../core/src/domain/entities/ChatFrame.ts | 68 +++- packages/core/src/domain/entities/GenUI.ts | 94 ++++++ .../domain/entities/GenUIComponentCache.ts | 133 ++++++++ .../__tests__/GenUIComponentCache.test.ts | 97 ++++++ packages/core/src/domain/index.ts | 4 +- packages/core/src/domain/ports/IConnector.ts | 30 +- packages/core/src/frames.ts | 11 + packages/core/src/index.ts | 5 + packages/core/src/ui/GenUIElement.ts | 147 +++++++++ packages/core/src/ui/GenUIHtmlElement.ts | 239 ++++++++++++++ .../src/ui/__tests__/GenUIElement.test.ts | 118 +++++++ .../src/ui/__tests__/GenUIHtmlElement.test.ts | 239 ++++++++++++++ .../ui/__tests__/defineGenUIComponent.test.ts | 158 +++++++++ .../core/src/ui/__tests__/template.test.ts | 136 ++++++++ packages/core/src/ui/defineGenUIComponent.ts | 126 ++++++++ packages/core/src/ui/template.ts | 171 ++++++++++ packages/genui/src/components/GenUIMessage.ts | 33 +- .../__tests__/GenUIHtmlChunk.test.ts | 96 ++++++ packages/genui/src/index.ts | 29 ++ .../__tests__/serverComponents.test.ts | 143 ++++++++ .../genui/src/registry/serverComponents.ts | 97 ++++++ packages/genui/src/types.ts | 35 +- packages/ui/src/chat-ui/AgentPanel.ts | 10 +- packages/ui/src/chat-ui/ChatWidget.ts | 8 +- website/docs/genui/built-in.md | 1 + website/docs/genui/custom-component.md | 304 +++++++++++++++--- website/docs/genui/streaming.md | 67 +++- 44 files changed, 3518 insertions(+), 200 deletions(-) create mode 100644 packages/core/src/application/GenUIDefinitionStore.ts create mode 100644 packages/core/src/application/__tests__/GenUIChunkMerge.test.ts create mode 100644 packages/core/src/application/__tests__/GenUIDefinitionStore.test.ts create mode 100644 packages/core/src/domain/entities/GenUIComponentCache.ts create mode 100644 packages/core/src/domain/entities/__tests__/GenUIComponentCache.test.ts create mode 100644 packages/core/src/ui/GenUIElement.ts create mode 100644 packages/core/src/ui/GenUIHtmlElement.ts create mode 100644 packages/core/src/ui/__tests__/GenUIElement.test.ts create mode 100644 packages/core/src/ui/__tests__/GenUIHtmlElement.test.ts create mode 100644 packages/core/src/ui/__tests__/defineGenUIComponent.test.ts create mode 100644 packages/core/src/ui/__tests__/template.test.ts create mode 100644 packages/core/src/ui/defineGenUIComponent.ts create mode 100644 packages/core/src/ui/template.ts create mode 100644 packages/genui/src/components/__tests__/GenUIHtmlChunk.test.ts create mode 100644 packages/genui/src/registry/__tests__/serverComponents.test.ts create mode 100644 packages/genui/src/registry/serverComponents.ts diff --git a/AGENTS.md b/AGENTS.md index 69976dc..13d4cc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,7 +200,14 @@ export class MyConnector implements IConnector { this.genUICallback = callback; } - receiveComponentEvent(streamId: string, eventType: string, payload: unknown): void { } + // `opts` carries the interaction's routing metadata (`scope`, `component`) for + // protocols that model it; ignoring it is fine. + receiveComponentEvent( + streamId: string, + eventType: string, + payload: unknown, + opts?: GenUIEventOptions, + ): void { } } ``` @@ -288,54 +295,47 @@ MessageTypeRegistry.register("my-type", MyMessage); ## When Creating a New GenUI Component 1. Create `packages/genui/src/components/[Name].ts` -2. Extend `LitElement` (not ChatbotMixin — GenUI components are standalone) -3. Declare optional `GenUIComponentAPI` properties to receive injected methods -4. Self-register in `GenUIRegistry` -5. Add tests +2. Extend `GenUIElement` from `@chativa/core` (not ChatbotMixin — GenUI components are standalone) +3. Self-register in `GenUIRegistry` +4. Add tests + +`GenUIElement` extends `ChativaElement` (i18n + auto re-render on locale switch) and implements the full +`GenUIComponentAPI` with working defaults, so `this.sendEvent(...)`, `this.listenEvent(...)` and +`this.tFn(...)` are always callable. `GenUIMessage` shadows them with message-scoped versions at mount +time. Do NOT redeclare those four as class fields — that shadows the defaults with `undefined`. **Template:** ```ts -import { LitElement, html, css } from "lit"; +import { html, css } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { GenUIElement } from "@chativa/core"; import { GenUIRegistry } from "../registry/GenUIRegistry"; -import type { GenUIComponentAPI } from "../types"; @customElement("my-genui-widget") -export class MyGenUIWidget extends LitElement { - // Injected by GenUIMessage at render time - sendEvent?: GenUIComponentAPI["sendEvent"]; - listenEvent?: GenUIComponentAPI["listenEvent"]; - tFn?: GenUIComponentAPI["tFn"]; - onLangChange?: GenUIComponentAPI["onLangChange"]; +export class MyGenUIWidget extends GenUIElement { + static override styles = css` + :host { display: block; } + `; @property({ type: String }) title = ""; - private _unsubLang?: () => void; - - connectedCallback() { + override connectedCallback() { super.connectedCallback(); - this._unsubLang = this.onLangChange?.(() => this.requestUpdate()); - } - - disconnectedCallback() { - super.disconnectedCallback(); - this._unsubLang?.(); + // Server-pushed event chunks targeting this component + this.listenEvent("widget_updated", (payload) => { + this.title = (payload as { title: string }).title; + }); } - static styles = css` - :host { display: block; } - `; - private _handleAction() { - this.sendEvent?.("widget_action", { title: this.title }); + this.sendEvent("widget_action", { title: this.title }); } - render() { - const label = this.tFn?.("widget.submit", "Submit") ?? "Submit"; + override render() { return html`

${this.title}

- +
`; } @@ -345,6 +345,25 @@ export class MyGenUIWidget extends LitElement { GenUIRegistry.register("my-widget", MyGenUIWidget); ``` +### Backend-authored components — `` + +When the markup should come from the backend instead of a compiled widget, stream the built-in +`GenUIHtmlElement` (registered as both `genui-html` and `html`): + +```json +{ "type": "ui", "component": "html", + "props": { "html": "", + "css": "button { border-radius: 8px; }" } } +``` + +Markup is sanitized by default (`sanitizeHtml` — drops `")).toBe("
ok
"); + }); + + it("drops iframes and other document-loading tags", () => { + const out = sanitizeHtml("

keep

"); + expect(out).toBe("

keep

"); + }); + + it("strips inline event handlers", () => { + const out = sanitizeHtml(``); + expect(out).toBe(""); + }); + + it("strips javascript: URLs but keeps safe ones", () => { + expect(sanitizeHtml(`x`)).toBe("x"); + expect(sanitizeHtml(`x`)).toBe("x"); + expect(sanitizeHtml(`x`)).toContain(`href="https://ok.test"`); + expect(sanitizeHtml(`x`)).toContain(`href="mailto:a@b.c"`); + }); + + it("sees through whitespace/control-char obfuscation of javascript:", () => { + expect(sanitizeHtml(`x`)).toBe("x"); + expect(sanitizeHtml(`x`)).toBe("x"); + }); + + it("keeps relative URLs", () => { + expect(sanitizeHtml(``)).toContain(`src="images/a.png"`); + expect(sanitizeHtml(`x`)).toContain(`href="/local"`); + expect(sanitizeHtml(`x`)).toContain(`href="../up"`); + expect(sanitizeHtml(`x`)).toContain(`href="#anchor"`); + expect(sanitizeHtml(``)).toContain(`srcset="a.png 1x, b.png 2x"`); + }); + + it("allows data: URLs for images only", () => { + expect(sanitizeHtml(``)).toContain("data:image/png"); + expect(sanitizeHtml(`x`)).not.toContain("data:text/html"); + }); + + it("keeps structure, classes and inline styles", () => { + const markup = `

Title

`; + expect(sanitizeHtml(markup)).toBe(markup); + }); + + it("keeps data-event / data-payload hooks intact", () => { + const markup = ``; + expect(sanitizeHtml(markup)).toContain(`data-event="track"`); + }); +}); + +describe("GenUIHtmlElement", () => { + it("is defined as ", () => { + expect(customElements.get("chativa-html")).toBe(GenUIHtmlElement); + }); + + it("renders sanitized markup from the html prop", async () => { + const el = await mount({ html: `

hi

` }); + expect(shadowHtml(el)).toContain(`

hi

`); + expect(shadowHtml(el)).not.toContain("script"); + }); + + it("renders markup verbatim when unsafe is set", async () => { + const el = await mount({ html: `hi`, unsafe: true }); + expect(shadowHtml(el)).toContain("onclick"); + }); + + it("injects the css prop into the shadow root", async () => { + const el = await mount({ html: "

hi

", css: ".x { color: red; }" }); + const style = el.shadowRoot!.querySelector("style"); + expect(style).not.toBeNull(); + expect(style!.textContent).toContain(".x { color: red; }"); + }); + + it("falls back to a slot when no html prop is set", async () => { + const el = await mount({}, "child"); + expect(shadowHtml(el)).toContain(""); + }); + + it("re-renders when the backend streams new markup", async () => { + const el = await mount({ html: "

first

" }); + el.html = "

second

"; + await el.updateComplete; + expect(shadowHtml(el)).toContain("second"); + expect(shadowHtml(el)).not.toContain("first"); + }); + + it("sends data-event clicks through sendEvent", async () => { + const el = await mount({ html: `` }); + const sent = vi.fn(); + (el as unknown as Record)["sendEvent"] = sent; + + el.shadowRoot!.querySelector("button")!.click(); + + expect(sent).toHaveBeenCalledWith("track_order", { id: 123 }); + }); + + it("finds the data-event ancestor when an inner node is clicked", async () => { + const el = await mount({ html: `
inner
` }); + const sent = vi.fn(); + (el as unknown as Record)["sendEvent"] = sent; + + el.shadowRoot!.querySelector("span")!.dispatchEvent(new MouseEvent("click", { bubbles: true, composed: true })); + + expect(sent).toHaveBeenCalledWith("card_click", {}); + }); + + it("passes a non-JSON data-payload through as a raw string", async () => { + const el = await mount({ html: `` }); + const sent = vi.fn(); + (el as unknown as Record)["sendEvent"] = sent; + + el.shadowRoot!.querySelector("button")!.click(); + + expect(sent).toHaveBeenCalledWith("pick", "blue"); + }); + + it("ignores clicks on elements without data-event", async () => { + const el = await mount({ html: `` }); + const sent = vi.fn(); + (el as unknown as Record)["sendEvent"] = sent; + + el.shadowRoot!.querySelector("button")!.click(); + + expect(sent).not.toHaveBeenCalled(); + }); + + it("submits form fields merged over data-payload", async () => { + const el = await mount({ + html: `
+ + +
`, + }); + const sent = vi.fn(); + (el as unknown as Record)["sendEvent"] = sent; + + const form = el.shadowRoot!.querySelector("form")!; + const evt = new Event("submit", { bubbles: true, cancelable: true, composed: true }); + form.dispatchEvent(evt); + + expect(evt.defaultPrevented).toBe(true); + expect(sent).toHaveBeenCalledWith("lead_form", { source: "chat", name: "Hamza", email: "a@b.c" }); + }); + + it("emits the fallback DOM event when no host injected sendEvent", async () => { + const el = await mount({ html: `` }); + const spy = vi.fn(); + document.body.addEventListener("genui-component-event", spy as EventListener); + + el.shadowRoot!.querySelector("button")!.click(); + + expect(spy).toHaveBeenCalledOnce(); + const e = spy.mock.calls[0]![0] as CustomEvent; + expect(e.detail).toEqual({ eventType: "say_hi", payload: {} }); + + document.body.removeEventListener("genui-component-event", spy as EventListener); + }); + + // ── event scope: who the interaction is addressed to ────────────────────── + + describe("event scope", () => { + /** Click the only button and return the args `sendEvent` was called with. */ + async function clickWith(markup: string): Promise { + const el = await mount({ html: markup }); + const sent = vi.fn(); + (el as unknown as Record)["sendEvent"] = sent; + el.shadowRoot!.querySelector("button")!.click(); + return sent.mock.calls[0] ?? []; + } + + it("sends scope 'component' for a component-event attribute", async () => { + expect(await clickWith(``)) + .toEqual(["rate_delivery", { stars: 5 }, { scope: "component" }]); + }); + + it("sends scope 'graph' for a mekik-event attribute", async () => { + expect(await clickWith(``)) + .toEqual(["track_order", { id: "ORD-42" }, { scope: "graph" }]); + }); + + it("sends no scope for a plain data-event — the backend decides", async () => { + expect(await clickWith(``)) + .toEqual(["track_order", {}]); + }); + + it("prefers the most specific attribute when an element carries several", async () => { + expect(await clickWith(``)) + .toEqual(["mine", {}, { scope: "component" }]); + }); + + it("treats an empty event name as no trigger at all", async () => { + expect(await clickWith(``)).toEqual([]); + }); + + it("carries the scope through a form submit too", async () => { + const el = await mount({ + html: `
`, + }); + const sent = vi.fn(); + (el as unknown as Record)["sendEvent"] = sent; + + const form = el.shadowRoot!.querySelector("form")!; + const evt = new Event("submit", { bubbles: true, cancelable: true, composed: true }); + form.dispatchEvent(evt); + + expect(evt.defaultPrevented).toBe(true); + expect(sent).toHaveBeenCalledWith("rate_delivery", { stars: "5" }, { scope: "component" }); + }); + + it("puts the scope on the fallback DOM event's detail", async () => { + const el = await mount({ html: `` }); + const spy = vi.fn(); + document.body.addEventListener("genui-component-event", spy as EventListener); + + el.shadowRoot!.querySelector("button")!.click(); + + const e = spy.mock.calls[0]![0] as CustomEvent; + expect(e.detail).toEqual({ eventType: "track_order", payload: {}, scope: "graph" }); + + document.body.removeEventListener("genui-component-event", spy as EventListener); + }); + }); +}); diff --git a/packages/core/src/ui/__tests__/defineGenUIComponent.test.ts b/packages/core/src/ui/__tests__/defineGenUIComponent.test.ts new file mode 100644 index 0000000..e13730d --- /dev/null +++ b/packages/core/src/ui/__tests__/defineGenUIComponent.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { GenUIComponentDefinition } from "../../domain/entities/GenUI"; +import { defineGenUIComponent, clearDefinedGenUIComponents } from "../defineGenUIComponent"; +import { GenUIHtmlElement } from "../GenUIHtmlElement"; + +const ORDER_CARD: GenUIComponentDefinition = { + name: "order-card", + template: `

{{title}}

+ {{#each lines}}

{{this.label}}

{{/each}} +
`, + css: ".card { padding: 12px; }", + props: { id: "", title: "", lines: [] }, +}; + +async function mount(Ctor: typeof GenUIHtmlElement, props: Record = {}) { + const el = new Ctor(); + Object.assign(el, props); + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +beforeEach(() => clearDefinedGenUIComponents()); + +describe("defineGenUIComponent", () => { + it("renders the template against the props", async () => { + const el = await mount(defineGenUIComponent(ORDER_CARD), { + id: "A1", + title: "Order #123", + lines: [{ label: "Tea" }, { label: "Cake" }], + }); + + const shadow = el.shadowRoot!.innerHTML; + expect(shadow).toContain("Order #123"); + expect(shadow).toContain("

Tea

"); + expect(shadow).toContain("

Cake

"); + el.remove(); + }); + + it("applies the definition's css and defaults", async () => { + const el = await mount(defineGenUIComponent({ + name: "greet", + template: "

{{greeting}}

", + css: "p { color: red; }", + props: { greeting: "Merhaba" }, + })); + + expect(el.shadowRoot!.querySelector("style")!.textContent).toContain("color: red"); + expect(el.shadowRoot!.innerHTML).toContain("Merhaba"); + el.remove(); + }); + + it("re-renders in place when a declared prop changes", async () => { + const Ctor = defineGenUIComponent({ + name: "counter", + template: "

{{count}}

", + props: { count: 0 }, + }); + const el = await mount(Ctor, { count: 1 }); + expect(el.shadowRoot!.innerHTML).toContain("

1

"); + + (el as unknown as Record)["count"] = 2; + await el.updateComplete; + expect(el.shadowRoot!.innerHTML).toContain("

2

"); + el.remove(); + }); + + it("keeps object defaults per-instance instead of aliasing them", async () => { + const Ctor = defineGenUIComponent({ + name: "listy", + template: "{{#each items}}x{{/each}}", + props: { items: [] }, + }); + const a = new Ctor() as unknown as Record; + const b = new Ctor() as unknown as Record; + (a["items"] as unknown[]).push(1); + + expect(a["items"]).toHaveLength(1); + expect(b["items"]).toHaveLength(0); + }); + + it("sanitizes template output — a definition cannot ship a script", async () => { + const el = await mount(defineGenUIComponent({ + name: "sneaky", + template: `

ok

x`, + })); + + expect(el.shadowRoot!.innerHTML).toContain("

ok

"); + expect(el.shadowRoot!.innerHTML).not.toContain("script>"); + expect(el.shadowRoot!.querySelector("a")!.hasAttribute("href")).toBe(false); + el.remove(); + }); + + it("escapes prop values, so props cannot inject markup either", async () => { + const el = await mount(defineGenUIComponent({ + name: "escapey", + template: "

{{text}}

", + props: { text: "" }, + }), { text: "" }); + + expect(el.shadowRoot!.querySelector("img")).toBeNull(); + expect(el.shadowRoot!.innerHTML).toContain("<img"); + el.remove(); + }); + + it("routes data-event clicks like any other GenUI component", async () => { + const el = await mount(defineGenUIComponent(ORDER_CARD), { id: "A1" }); + const sent = vi.fn(); + (el as unknown as Record)["sendEvent"] = sent; + + el.shadowRoot!.querySelector("button")!.click(); + + expect(sent).toHaveBeenCalledWith("track_order", { id: "A1" }); + el.remove(); + }); + + it("returns the same class for the same name@version", () => { + expect(defineGenUIComponent(ORDER_CARD)).toBe(defineGenUIComponent({ ...ORDER_CARD })); + }); + + it("builds a new class when the version changes", () => { + const v1 = defineGenUIComponent({ ...ORDER_CARD, version: "1" }); + const v2 = defineGenUIComponent({ ...ORDER_CARD, version: "2" }); + expect(v2).not.toBe(v1); + }); + + it("defines a distinct custom element tag per definition", () => { + defineGenUIComponent({ name: "tagged", template: "

1

" }); + expect(customElements.get("chativa-genui-tagged")).toBeDefined(); + + clearDefinedGenUIComponents(); + defineGenUIComponent({ name: "tagged", template: "

2

", 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("

{{text}}

", { text: "" }); + expect(out).toBe("

<script>alert(1)</script>

"); + }); + + it("escapes quotes so an attribute cannot be broken out of", () => { + const out = renderTemplate(`x`, { t: `" onmouseover="alert(1)` }); + expect(out).not.toContain(`onmouseover="`); + expect(out).toContain("""); + }); +}); + +describe("renderTemplate — #if", () => { + it("renders the body when truthy", () => { + expect(renderTemplate("{{#if ok}}yes{{/if}}", { ok: true })).toBe("yes"); + }); + + it("skips the body when falsy", () => { + expect(renderTemplate("{{#if ok}}yes{{/if}}", { ok: false })).toBe(""); + expect(renderTemplate("{{#if ok}}yes{{/if}}", { ok: "" })).toBe(""); + expect(renderTemplate("{{#if ok}}yes{{/if}}", {})).toBe(""); + }); + + it("treats an empty array as falsy", () => { + expect(renderTemplate("{{#if items}}has{{/if}}", { items: [] })).toBe(""); + expect(renderTemplate("{{#if items}}has{{/if}}", { items: [1] })).toBe("has"); + }); + + it("supports else", () => { + const tpl = "{{#if ok}}yes{{else}}no{{/if}}"; + expect(renderTemplate(tpl, { ok: true })).toBe("yes"); + expect(renderTemplate(tpl, { ok: false })).toBe("no"); + }); + + it("nests", () => { + const tpl = "{{#if a}}A{{#if b}}B{{/if}}{{/if}}"; + expect(renderTemplate(tpl, { a: true, b: true })).toBe("AB"); + expect(renderTemplate(tpl, { a: true, b: false })).toBe("A"); + expect(renderTemplate(tpl, { a: false, b: true })).toBe(""); + }); +}); + +describe("renderTemplate — #each", () => { + it("iterates primitives with {{this}}", () => { + expect(renderTemplate("{{#each xs}}[{{this}}]{{/each}}", { xs: ["a", "b"] })).toBe("[a][b]"); + }); + + it("iterates objects with field access", () => { + const out = renderTemplate("{{#each rows}}

{{this.label}}:{{price}}

{{/each}}", { + rows: [{ label: "Tea", price: 20 }, { label: "Cake", price: 35 }], + }); + expect(out).toBe("

Tea:20

Cake:35

"); + }); + + it("exposes @index", () => { + expect(renderTemplate("{{#each xs}}{{@index}}{{/each}}", { xs: ["a", "b", "c"] })).toBe("012"); + }); + + it("can still see the parent scope", () => { + const out = renderTemplate("{{#each xs}}{{currency}}{{this}} {{/each}}", { + currency: "₺", + xs: [10, 20], + }); + expect(out).toBe("₺10 ₺20 "); + }); + + it("renders nothing when the value is not an array", () => { + expect(renderTemplate("{{#each xs}}x{{/each}}", { xs: "nope" })).toBe(""); + expect(renderTemplate("{{#each xs}}x{{/each}}", {})).toBe(""); + }); + + it("nests inside #if", () => { + const tpl = "{{#if rows}}
    {{#each rows}}
  • {{this}}
  • {{/each}}
{{else}}empty{{/if}}"; + expect(renderTemplate(tpl, { rows: ["a"] })).toBe("
  • a
"); + expect(renderTemplate(tpl, { rows: [] })).toBe("empty"); + }); +}); + +describe("renderTemplate — robustness", () => { + it("never throws on an unbalanced block", () => { + expect(() => renderTemplate("{{#if a}}open", { a: true })).not.toThrow(); + expect(renderTemplate("{{#if a}}open", { a: true })).toBe("open"); + }); + + it("ignores a stray closing tag", () => { + expect(renderTemplate("a{{/if}}b", {})).toBe("ab"); + }); + + it("keeps an unknown block keyword visible instead of swallowing it", () => { + expect(renderTemplate("{{#wat x}}body{{/wat}}", {})).toContain("{{#wat x}}"); + }); + + it("returns an empty string for an empty template", () => { + expect(renderTemplate("", { a: 1 })).toBe(""); + }); + + it("leaves markup without tags untouched", () => { + expect(renderTemplate("

plain

", {})).toBe("

plain

"); + }); +}); 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("

step one

")) as HTMLElement & { + messageData: unknown; + updateComplete: Promise; + }; + const first = mountedHtmlElement(msg); + + msg.messageData = streamState("

step two

"); + 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 `
` | ## Visual reference diff --git a/website/docs/genui/custom-component.md b/website/docs/genui/custom-component.md index 1bd52ad..59da313 100644 --- a/website/docs/genui/custom-component.md +++ b/website/docs/genui/custom-component.md @@ -1,94 +1,304 @@ --- sidebar_position: 4 title: Custom component -description: Build your own GenUI component — the GenUIComponentAPI injection (sendEvent, listenEvent, tFn, onLangChange). +description: Build your own GenUI component — extend GenUIElement, or stream raw markup from the backend with . --- # Custom GenUI component -Anything you can build as a LitElement can be a GenUI component. Register it once; bots can stream it forever. +Two ways to get your own UI into a chat bubble: -```ts -import { LitElement, html, css } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { GenUIRegistry, type GenUIComponentAPI } from "@chativa/genui"; - -@customElement("weather-widget") -export class WeatherWidget extends LitElement { - // Injected by GenUIMessage at mount time: - sendEvent?: GenUIComponentAPI["sendEvent"]; - listenEvent?: GenUIComponentAPI["listenEvent"]; - tFn?: GenUIComponentAPI["tFn"]; - onLangChange?: GenUIComponentAPI["onLangChange"]; +| | Use when | +|---|---| +| **Extend `GenUIElement`** | You ship a compiled widget with the frontend. Full LitElement power, typed props. | +| **Stream ``** | The markup comes from the backend, a CMS, or a React app. No client-side build step, no redeploy to add a widget. | +| **Let the server define it** (mekik) | The backend owns the whole widget and announces it on connect. Adding a widget is a server deploy. | - @property({ type: String }) city = ""; - @property({ type: Number }) temp = 0; - @property({ type: String }) condition = ""; +Both live in `@chativa/core`, so a widget package doesn't have to depend on `@chativa/genui`. - private _unsubLang?: () => void; +## 1. Extend `GenUIElement` - connectedCallback() { - super.connectedCallback(); - // Re-render on locale switch so tFn returns the new language - this._unsubLang = this.onLangChange?.(() => this.requestUpdate()); - } +`GenUIElement` extends `ChativaElement` (i18n + auto re-render on locale switch) and implements the whole `GenUIComponentAPI` with working defaults. You call `this.sendEvent(...)` / `this.listenEvent(...)` / `this.tFn(...)` directly — no optional chaining, no injected-property boilerplate. - disconnectedCallback() { - super.disconnectedCallback(); - this._unsubLang?.(); - } +```ts +import { GenUIElement } from "@chativa/core"; +import { GenUIRegistry } from "@chativa/genui"; +import { html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; - static styles = css` +@customElement("weather-widget") +export class WeatherWidget extends GenUIElement { + static override styles = css` :host { display: block; } - .card { - border: 1px solid var(--chativa-border-color); - border-radius: 12px; padding: 12px; - } + .card { border: 1px solid var(--chativa-border-color); border-radius: 12px; padding: 12px; } `; - private _refresh() { - // Echo back to the connector — connector receives this via receiveComponentEvent - this.sendEvent?.("refresh_weather", { city: this.city }); + @property({ type: String }) city = ""; + @property({ type: Number }) temp = 0; + + override connectedCallback() { + super.connectedCallback(); + // Server-pushed event chunks targeting this component + this.listenEvent("weather_updated", (p) => { + this.temp = (p as { temp: number }).temp; + }); } - render() { - const refresh = this.tFn?.("widget.refresh", "Refresh") ?? "Refresh"; + override render() { return html`

${this.city}

-

${this.temp}°C · ${this.condition}

- +

${this.temp}°C

+
`; } } -// Side-effect register GenUIRegistry.register("weather", WeatherWidget); ``` -Then a connector can stream it as `{ type: "ui", component: "weather", props: { city, temp, condition } }`. +The connector then streams `{ type: "ui", component: "weather", props: { city, temp } }`. + +**Don't redeclare** `sendEvent` / `listenEvent` / `tFn` / `onLangChange` as class fields — that shadows the base implementations with `undefined`. The old pattern (extending `LitElement` and declaring the four optional fields) still works; it's just boilerplate you no longer need. + +## 2. Stream raw markup with `` + +Registered out of the box under `genui-html` and `html`, so a backend can ship the markup itself: + +```json +{ + "type": "ui", + "component": "html", + "props": { + "html": "

Order #123

", + "css": ".card { border: 1px solid #e2e8f0; border-radius: 12px; padding: 12px; }" + } +} +``` + +The click reaches your connector as `receiveComponentEvent(streamId, "track_order", { id: 123 })` — the same round trip a compiled component gets. + +### Props + +| Prop | Meaning | +|---|---| +| `html` | Markup to render. Sanitized unless `unsafe` is set. When empty, light-DOM children are rendered instead. | +| `css` | CSS injected into the element's shadow root — scoped, so it can't leak into the page. | +| `unsafe` | Skip sanitization. Only for markup you fully control. | + +### Interaction contract + +A click — or a form submit — on an element carrying an event attribute sends that +event. Which attribute you use says **who the interaction is addressed to**, for +backends that model the distinction (mekik `PROTOCOL.md` §10.4): + +| attribute | `scope` sent | meaning | +| --- | --- | --- | +| `component-event=""` | `"component"` | the widget's own conversation with the graph node that mounted it — only a node waiting for that event name receives it | +| `mekik-event=""` | `"graph"` | the application, which may start a new turn on it | +| `data-event=""` | none | let the backend decide (the original form, unchanged) | + +Only the most specific attribute on an element is used, in the order above; an +empty value is not a trigger. Backends that don't model routing ignore `scope`, so +`data-event` keeps behaving exactly as it did. + +- `data-payload=''` → parsed and sent as the payload; invalid JSON is sent as the raw string. +- `` (or `mekik-event` / `data-event`) → the submit is intercepted and the named fields are sent, merged over `data-payload`. + +The frame that reaches the backend also carries `component` — the registry name of +the widget the interaction came from. `GenUIMessage` fills that in from the chunk, +so a component never sets it itself. + +### Safety + +Markup is sanitized by default: `