Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/lang-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { createLibrary, defineComponent } from "./library";
export type {
ComponentGroup,
ComponentRenderProps,
CustomActionDefinition,
DefinedComponent,
Library,
LibraryDefinition,
Expand Down
10 changes: 10 additions & 0 deletions packages/lang-core/src/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,25 @@ export interface ComponentGroup {
notes?: string[];
}

export interface CustomActionDefinition {
type: string;
description: string;
params?: Record<string, string>;
}

export interface PromptOptions {
preamble?: string;
additionalRules?: string[];
examples?: string[];
customActions?: CustomActionDefinition[];
}

// ─── Library ──────────────────────────────────────────────────────────────────

export interface Library<C = unknown> {
readonly components: Record<string, DefinedComponent<any, C>>;
readonly componentGroups: ComponentGroup[] | undefined;
readonly customActions: CustomActionDefinition[] | undefined;
readonly root: string | undefined;

prompt(options?: PromptOptions): string;
Expand All @@ -90,6 +98,7 @@ export interface Library<C = unknown> {
export interface LibraryDefinition<C = unknown> {
components: DefinedComponent<any, C>[];
componentGroups?: ComponentGroup[];
customActions?: CustomActionDefinition[];
root?: string;
}

Expand All @@ -115,6 +124,7 @@ export function createLibrary<C = unknown>(input: LibraryDefinition<C>): Library
const library: Library<C> = {
components: componentsRecord,
componentGroups: input.componentGroups,
customActions: input.customActions,
root: input.root,

prompt(options?: PromptOptions): string {
Expand Down
51 changes: 47 additions & 4 deletions packages/lang-core/src/parser/prompt.ts
Original file line number Diff line number Diff line change
@@ -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.`;

Expand Down Expand Up @@ -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<string, CustomActionDefinition>();
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) {
Expand Down Expand Up @@ -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));

Expand Down
1 change: 1 addition & 0 deletions packages/react-lang/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type {
ComponentGroup,
ComponentRenderProps,
ComponentRenderer,
CustomActionDefinition,
DefinedComponent,
Library,
LibraryDefinition,
Expand Down
7 changes: 6 additions & 1 deletion packages/react-lang/src/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -105,9 +107,11 @@ export const GenUIAssistantMessage = ({
if (typeof window !== "undefined" && url) {
window.open(url, "_blank");
}
} else {
onCustomAction?.(event);
}
},
[processMessage],
[processMessage, onCustomAction],
);

const hasToolActivity =
Expand Down
1 change: 1 addition & 0 deletions packages/react-ui/src/components/OpenUIChat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion packages/react-ui/src/components/OpenUIChat/types.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -54,15 +54,16 @@ export function withChatProvider<ExtraProps = {}>(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 }) => (
<GenUIAssistantMessage message={message} library={componentLibrary} />
<GenUIAssistantMessage message={message} library={componentLibrary} onCustomAction={onAction} />
);
}, [customAssistantMessage, componentLibrary]);
}, [customAssistantMessage, componentLibrary, onAction]);

const genUIUserMessage = useMemo(() => {
if (customUserMessage || !componentLibrary) return undefined;
Expand Down
Loading