From f689f5fa5b5ba0ad6a6e26193bbc6e2e9a4a34f6 Mon Sep 17 00:00:00 2001 From: Ritvik Budhiraja Date: Wed, 1 Apr 2026 13:19:27 +0530 Subject: [PATCH] Adding custom action support Made-with: Cursor --- packages/lang-core/src/index.ts | 1 + packages/lang-core/src/library.ts | 10 ++++ packages/lang-core/src/parser/prompt.ts | 51 +++++++++++++++++-- packages/react-lang/src/index.ts | 1 + packages/react-lang/src/library.ts | 7 ++- .../OpenUIChat/GenUIAssistantMessage.tsx | 6 ++- .../src/components/OpenUIChat/index.ts | 1 + .../src/components/OpenUIChat/types.ts | 10 +++- .../OpenUIChat/withChatProvider.tsx | 7 +-- 9 files changed, 84 insertions(+), 10 deletions(-) diff --git a/packages/lang-core/src/index.ts b/packages/lang-core/src/index.ts index f0696373d..e8c6dd734 100644 --- a/packages/lang-core/src/index.ts +++ b/packages/lang-core/src/index.ts @@ -3,6 +3,7 @@ export { createLibrary, defineComponent } from "./library"; export type { ComponentGroup, ComponentRenderProps, + CustomActionDefinition, DefinedComponent, Library, LibraryDefinition, diff --git a/packages/lang-core/src/library.ts b/packages/lang-core/src/library.ts index 8d0a4b18c..42d334009 100644 --- a/packages/lang-core/src/library.ts +++ b/packages/lang-core/src/library.ts @@ -70,10 +70,17 @@ export interface ComponentGroup { notes?: string[]; } +export interface CustomActionDefinition { + type: string; + description: string; + params?: Record; +} + export interface PromptOptions { preamble?: string; additionalRules?: string[]; examples?: string[]; + customActions?: CustomActionDefinition[]; } // ─── Library ────────────────────────────────────────────────────────────────── @@ -81,6 +88,7 @@ export interface PromptOptions { export interface Library { readonly components: Record>; readonly componentGroups: ComponentGroup[] | undefined; + readonly customActions: CustomActionDefinition[] | undefined; readonly root: string | undefined; prompt(options?: PromptOptions): string; @@ -90,6 +98,7 @@ export interface Library { export interface LibraryDefinition { components: DefinedComponent[]; componentGroups?: ComponentGroup[]; + customActions?: CustomActionDefinition[]; root?: string; } @@ -115,6 +124,7 @@ export function createLibrary(input: LibraryDefinition): Library const library: Library = { components: componentsRecord, componentGroups: input.componentGroups, + customActions: input.customActions, root: input.root, prompt(options?: PromptOptions): string { diff --git a/packages/lang-core/src/parser/prompt.ts b/packages/lang-core/src/parser/prompt.ts index 11119d678..a51acb793 100644 --- a/packages/lang-core/src/parser/prompt.ts +++ b/packages/lang-core/src/parser/prompt.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { DefinedComponent, Library, PromptOptions } from "../library"; +import type { CustomActionDefinition, DefinedComponent, Library, PromptOptions } from "../library"; const PREAMBLE = `You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code — no markdown, no explanations, just openui-lang.`; @@ -239,14 +239,57 @@ function buildComponentLine(componentName: string, def: DefinedComponent): strin return sig; } +// ─── Action description ─── + +function mergeCustomActions( + libraryActions?: CustomActionDefinition[], + optionsActions?: CustomActionDefinition[], +): CustomActionDefinition[] { + if (!libraryActions?.length && !optionsActions?.length) return []; + const merged = new Map(); + for (const a of libraryActions ?? []) merged.set(a.type, a); + for (const a of optionsActions ?? []) merged.set(a.type, a); + return Array.from(merged.values()); +} + +function generateActionDescription( + libraryActions?: CustomActionDefinition[], + optionsActions?: CustomActionDefinition[], +): string { + const customActions = mergeCustomActions(libraryActions, optionsActions); + + const builtinLine = + "The `action` prop type accepts: ContinueConversation (sends message to LLM), OpenUrl (navigates to URL)."; + + if (customActions.length === 0) return builtinLine; + + const lines: string[] = [builtinLine, "", "Custom actions available:"]; + for (const action of customActions) { + let line = `- "${action.type}": ${action.description}`; + if (action.params && Object.keys(action.params).length > 0) { + const paramList = Object.entries(action.params) + .map(([name, desc]) => `${name} (${desc})`) + .join(", "); + line += ` -- params: ${paramList}`; + } + lines.push(line); + } + lines.push(""); + lines.push( + 'Use custom actions in Button like: Button("Download PDF", { type: "download_report", params: { format: "pdf" } }, "primary")', + ); + + return lines.join("\n"); +} + // ─── Prompt assembly ─── -function generateComponentSignatures(library: Library): string { +function generateComponentSignatures(library: Library, options?: PromptOptions): string { const lines: string[] = [ "## Component Signatures", "", "Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming.", - "The `action` prop type accepts: ContinueConversation (sends message to LLM), OpenUrl (navigates to URL), or Custom (app-defined).", + generateActionDescription(library.customActions, options?.customActions), ]; if (library.componentGroups?.length) { @@ -308,7 +351,7 @@ export function generatePrompt(library: Library, options?: PromptOptions): strin parts.push(""); parts.push(syntaxRules(rootName)); parts.push(""); - parts.push(generateComponentSignatures(library)); + parts.push(generateComponentSignatures(library, options)); parts.push(""); parts.push(streamingRules(rootName)); diff --git a/packages/react-lang/src/index.ts b/packages/react-lang/src/index.ts index 9ef8bdce5..78db498f1 100644 --- a/packages/react-lang/src/index.ts +++ b/packages/react-lang/src/index.ts @@ -4,6 +4,7 @@ export type { ComponentGroup, ComponentRenderProps, ComponentRenderer, + CustomActionDefinition, DefinedComponent, Library, LibraryDefinition, diff --git a/packages/react-lang/src/library.ts b/packages/react-lang/src/library.ts index e0cdc8109..e749c4ccc 100644 --- a/packages/react-lang/src/library.ts +++ b/packages/react-lang/src/library.ts @@ -10,7 +10,12 @@ import type { ReactNode } from "react"; import { z } from "zod"; // Re-export framework-agnostic types unchanged -export type { ComponentGroup, PromptOptions, SubComponentOf } from "@openuidev/lang-core"; +export type { + ComponentGroup, + CustomActionDefinition, + PromptOptions, + SubComponentOf, +} from "@openuidev/lang-core"; // ─── React-specific types ─────────────────────────────────────────────────── diff --git a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx index 2a3728137..6160075ee 100644 --- a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx +++ b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx @@ -13,9 +13,11 @@ import { ToolResult } from "../ToolResult"; export const GenUIAssistantMessage = ({ message, library, + onCustomAction, }: { message: AssistantMessage; library: Library; + onCustomAction?: (event: ActionEvent) => void; }) => { const messages = useThread((s) => s.messages); const isRunning = useThread((s) => s.isRunning); @@ -105,9 +107,11 @@ export const GenUIAssistantMessage = ({ if (typeof window !== "undefined" && url) { window.open(url, "_blank"); } + } else { + onCustomAction?.(event); } }, - [processMessage], + [processMessage, onCustomAction], ); const hasToolActivity = diff --git a/packages/react-ui/src/components/OpenUIChat/index.ts b/packages/react-ui/src/components/OpenUIChat/index.ts index 2afe1c22f..1e56248c2 100644 --- a/packages/react-ui/src/components/OpenUIChat/index.ts +++ b/packages/react-ui/src/components/OpenUIChat/index.ts @@ -2,6 +2,7 @@ export { BottomTray } from "./ComposedBottomTray"; export { Copilot } from "./ComposedCopilot"; export { FullScreen } from "./ComposedStandalone"; export { GenUIUserMessage } from "./GenUIUserMessage"; +export type { ActionEvent } from "@openuidev/react-lang"; export type { AssistantMessageComponent, ComposerComponent, diff --git a/packages/react-ui/src/components/OpenUIChat/types.ts b/packages/react-ui/src/components/OpenUIChat/types.ts index ec4a5d21c..0aa785b0f 100644 --- a/packages/react-ui/src/components/OpenUIChat/types.ts +++ b/packages/react-ui/src/components/OpenUIChat/types.ts @@ -1,4 +1,4 @@ -import type { Library } from "@openuidev/react-lang"; +import type { ActionEvent, Library } from "@openuidev/react-lang"; import { ReactNode } from "react"; import { ScrollVariant } from "../../hooks/useScrollToBottom"; import { ConversationStarterProps } from "../../types/ConversationStarter"; @@ -106,4 +106,12 @@ export interface SharedChatUIProps { * provided, `assistantMessage` takes priority. */ componentLibrary?: Library; + /** + * Callback for custom action events from GenUI components. + * Called when a component triggers an action type other than the built-in + * ContinueConversation or OpenUrl. Requires `componentLibrary` to be set. + * When a custom `assistantMessage` is provided, that component is responsible + * for its own action handling and this callback is not wired. + */ + onAction?: (event: ActionEvent) => void; } diff --git a/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx b/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx index 714334433..e22f18bf7 100644 --- a/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx +++ b/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx @@ -1,6 +1,6 @@ import type { AssistantMessage, ChatProviderProps, UserMessage } from "@openuidev/react-headless"; import { ChatProvider } from "@openuidev/react-headless"; -import type { Library } from "@openuidev/react-lang"; +import type { ActionEvent, Library } from "@openuidev/react-lang"; import { useMemo } from "react"; import { ThemeProps, ThemeProvider } from "../ThemeProvider"; import { GenUIAssistantMessage } from "./GenUIAssistantMessage"; @@ -54,15 +54,16 @@ export function withChatProvider(WrappedComponent: React.Compon } const componentLibrary = innerProps["componentLibrary"] as Library | undefined; + const onAction = innerProps["onAction"] as ((event: ActionEvent) => void) | undefined; const customAssistantMessage = innerProps["assistantMessage"]; const customUserMessage = innerProps["userMessage"]; const genUIAssistantMessage = useMemo(() => { if (customAssistantMessage || !componentLibrary) return undefined; return ({ message }: { message: AssistantMessage }) => ( - + ); - }, [customAssistantMessage, componentLibrary]); + }, [customAssistantMessage, componentLibrary, onAction]); const genUIUserMessage = useMemo(() => { if (customUserMessage || !componentLibrary) return undefined;