diff --git a/examples/form-generator/src/generated/system-prompt.txt b/examples/form-generator/src/generated/system-prompt.txt index 99070f4d5..5dd4c7f02 100644 --- a/examples/form-generator/src/generated/system-prompt.txt +++ b/examples/form-generator/src/generated/system-prompt.txt @@ -15,15 +15,13 @@ You are an AI assistant that responds using openui-lang, a declarative UI langua ## Component Signatures Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming. -Props typed `ActionExpression` accept an Action([@steps...]) expression. See the Action section for available steps (@ToAssistant, @OpenUrl). -Props marked `$binding` accept a `$variable` reference for two-way binding. ### Buttons Button(label: string, action?: {type: "open_url", url: string} | {type: "continue_conversation", context?: string}, variant?: "primary" | "secondary" | "tertiary" | "outline" | "ghost" | "danger") — Clickable button Buttons(buttons: Button[]) — Row of Button components ### Forms -Form(name: string, title: string, description?: string, buttons: Buttons, fields) — Form container with title, optional description, fields, and action buttons +Form(name: string, title: string, description?: string, buttons: Buttons, fields?: (FormControl | FormRow)[]) — Form container with title, optional description, fields, and action buttons FormRow(fields: FormControl[]) — Horizontal row that places multiple FormControl fields side-by-side, each sharing equal width. Use for short paired fields like First Name / Last Name or City / State / ZIP. FormControl(label: string, input: Input | TextArea | Select | Slider | CheckBoxGroup | RadioGroup | SwitchGroup | NumberField, hint?: string) — Field with label, input component, and optional hint text Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) — Single-line text input @@ -39,50 +37,6 @@ RadioItem(label: string, description: string, value: string) — Option for Radi SwitchGroup(name: string, items: SwitchItem[]) — Group of switch toggles SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle -## Built-in Functions - -Data functions prefixed with `@` to distinguish from components. These are the ONLY functions available — do NOT invent new ones. -Use @-prefixed built-in functions (@Count, @Sum, @Avg, @Min, @Max, @Round) on Query results — do NOT hardcode computed values. - -@Count(array) → number — Returns array length -@First(array) → element — Returns first element of array -@Last(array) → element — Returns last element of array -@Sum(numbers[]) → number — Sum of numeric array -@Avg(numbers[]) → number — Average of numeric array -@Min(numbers[]) → number — Minimum value in array -@Max(numbers[]) → number — Maximum value in array -@Sort(array, field, direction?) → sorted array — Sort array by field. Direction: "asc" (default) or "desc" -@Filter(array, field, operator: "==" | "!=" | ">" | "<" | ">=" | "<=" | "contains", value) → filtered array — Filter array by field value -@Round(number, decimals?) → number — Round to N decimal places (default 0) -@Abs(number) → number — Absolute value -@Floor(number) → number — Round down to nearest integer -@Ceil(number) → number — Round up to nearest integer -@Each(array, varName, template) — Evaluate template for each element. varName is the loop variable — use it ONLY inside the template expression (inline). Do NOT create a separate statement for the template. - -Builtins compose — output of one is input to the next: -`@Count(@Filter(data.rows, "field", "==", "val"))` for KPIs/chart values, `@Round(@Avg(data.rows.score), 1)`, `@Each(data.rows, "item", Comp(item.field))` for per-item rendering. -Array pluck: `data.rows.field` extracts a field from every row → use with @Sum, @Avg, charts, tables. - -IMPORTANT @Each rule: The loop variable (e.g. "item") is ONLY available inside the @Each template expression. Always inline the template — do NOT extract it to a separate statement. -CORRECT: `Col("Actions", @Each(rows, "t", Button("Edit", Action([@Set($id, t.id)]))))` -WRONG: `myBtn = Button("Edit", Action([@Set($id, t.id)]))` then `Col("Actions", @Each(rows, "t", myBtn))` — t is undefined in myBtn. - -## Action — Button Behavior - -Action([@steps...]) wires button clicks to operations. Steps are @-prefixed built-in actions. Steps execute in order. -Buttons without an explicit Action prop automatically send their label to the assistant (equivalent to Action([@ToAssistant(label)])). - -Available steps: -- @ToAssistant("message") — Send a message to the assistant (for conversational buttons like "Tell me more", "Explain this") -- @OpenUrl("https://...") — Navigate to a URL - -Example — simple nav: -``` -viewBtn = Button("View", Action([@OpenUrl("https://example.com")])) -``` - -- Action can be assigned to a variable or inlined: Button("Go", onSubmit) and Button("Go", Action([...])) both work - ## Hoisting & Streaming (CRITICAL) openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed. @@ -91,10 +45,8 @@ During streaming, the output is re-parsed on every chunk. Undefined references a **Recommended statement order for optimal streaming:** 1. `root = Form(...)` — UI shell appears immediately -2. $variable declarations — state ready for bindings -3. Query statements — defaults resolve immediately so components render with data -4. Component definitions — fill in with data already available -5. Data values — leaf content last +2. Component definitions — fill in as they stream +3. Data values — leaf content last Always write the root = Form(...) statement first so the UI shell appears immediately, even before child data has streamed in. ## Important Rules diff --git a/examples/openui-artifact-demo/src/generated/system-prompt.txt b/examples/openui-artifact-demo/src/generated/system-prompt.txt index d82d6b3f2..f7263f410 100644 --- a/examples/openui-artifact-demo/src/generated/system-prompt.txt +++ b/examples/openui-artifact-demo/src/generated/system-prompt.txt @@ -32,7 +32,7 @@ ArtifactCodeBlock(language: string, title: string, codeString: string) — Code ### Tables Table(columns: Col[]) — Data table — column-oriented. Each Col holds its own data array. -Col(label: string, data, type?: "string" | "number" | "action") — Column definition — holds label + data array +Col(label: string, data: any, type?: "string" | "number" | "action") — Column definition — holds label + data array ### Charts (2D) BarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Vertical bars; use for comparing values across categories with one or more series @@ -54,20 +54,20 @@ ScatterSeries(name: string, points: Point[]) — Named dataset Point(x: number, y: number, z?: number) — Data point with numeric coordinates ### Forms -Form(name: string, buttons: Buttons, fields) — Form container with fields and explicit action buttons +Form(name: string, buttons: Buttons, fields?: FormControl[]) — Form container with fields and explicit action buttons FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string) — Field with label, input component, and optional hint text Label(text: string) — Text label Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) SelectItem(value: string, label: string) — Option for Select -DatePicker(name: string, mode?: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?) +DatePicker(name: string, mode?: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) — Numeric slider input; supports continuous and discrete (stepped) variants -CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?) +CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding>) CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean) RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) RadioItem(label: string, description: string, value: string) -SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk", value?) — Group of switch toggles +SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk", value?: $binding>) — Group of switch toggles SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle - Define EACH FormControl as its own reference — do NOT inline all controls in one array. - NEVER nest Form inside Form. diff --git a/examples/openui-chat/src/generated/system-prompt.txt b/examples/openui-chat/src/generated/system-prompt.txt index af8c67770..e98309971 100644 --- a/examples/openui-chat/src/generated/system-prompt.txt +++ b/examples/openui-chat/src/generated/system-prompt.txt @@ -31,7 +31,7 @@ Separator(orientation?: "horizontal" | "vertical", decorative?: boolean) — Vis ### Tables Table(columns: Col[]) — Data table — column-oriented. Each Col holds its own data array. -Col(label: string, data, type?: "string" | "number" | "action") — Column definition — holds label + data array +Col(label: string, data: any, type?: "string" | "number" | "action") — Column definition — holds label + data array ### Charts (2D) BarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Vertical bars; use for comparing values across categories with one or more series @@ -53,20 +53,20 @@ ScatterSeries(name: string, points: Point[]) — Named dataset Point(x: number, y: number, z?: number) — Data point with numeric coordinates ### Forms -Form(name: string, buttons: Buttons, fields) — Form container with fields and explicit action buttons +Form(name: string, buttons: Buttons, fields?: FormControl[]) — Form container with fields and explicit action buttons FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string) — Field with label, input component, and optional hint text Label(text: string) — Text label Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) SelectItem(value: string, label: string) — Option for Select -DatePicker(name: string, mode?: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?) +DatePicker(name: string, mode?: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) — Numeric slider input; supports continuous and discrete (stepped) variants -CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?) +CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding>) CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean) RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) RadioItem(label: string, description: string, value: string) -SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk", value?) — Group of switch toggles +SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk", value?: $binding>) — Group of switch toggles SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle - Define EACH FormControl as its own reference — do NOT inline all controls in one array. - NEVER nest Form inside Form. diff --git a/examples/openui-dashboard/src/generated/component-spec.json b/examples/openui-dashboard/src/generated/component-spec.json index c10423427..a1001e93c 100644 --- a/examples/openui-dashboard/src/generated/component-spec.json +++ b/examples/openui-dashboard/src/generated/component-spec.json @@ -46,7 +46,7 @@ "description": "Data table — column-oriented. Each Col holds its own data array." }, "Col": { - "signature": "Col(label: string, data, type?: \"string\" | \"number\" | \"action\")", + "signature": "Col(label: string, data: any, type?: \"string\" | \"number\" | \"action\")", "description": "Column definition — holds label + data array" }, "BarChart": { @@ -102,7 +102,7 @@ "description": "Data point with numeric coordinates" }, "Form": { - "signature": "Form(name: string, buttons: Buttons, fields)", + "signature": "Form(name: string, buttons: Buttons, fields?: FormControl[])", "description": "Form container with fields and explicit action buttons" }, "FormControl": { @@ -130,7 +130,7 @@ "description": "Option for Select" }, "DatePicker": { - "signature": "DatePicker(name: string, mode?: \"single\" | \"range\", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?)", + "signature": "DatePicker(name: string, mode?: \"single\" | \"range\", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)", "description": "" }, "Slider": { @@ -138,7 +138,7 @@ "description": "Numeric slider input; supports continuous and discrete (stepped) variants" }, "CheckBoxGroup": { - "signature": "CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?)", + "signature": "CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding>)", "description": "" }, "CheckBoxItem": { @@ -154,7 +154,7 @@ "description": "" }, "SwitchGroup": { - "signature": "SwitchGroup(name: string, items: SwitchItem[], variant?: \"clear\" | \"card\" | \"sunk\", value?)", + "signature": "SwitchGroup(name: string, items: SwitchItem[], variant?: \"clear\" | \"card\" | \"sunk\", value?: $binding>)", "description": "Group of switch toggles" }, "SwitchItem": { @@ -170,7 +170,7 @@ "description": "Group of Button components. direction: \"row\" (default) | \"column\"." }, "Stack": { - "signature": "Stack([children], direction?: \"row\" | \"column\", gap?: \"none\" | \"xs\" | \"s\" | \"m\" | \"l\" | \"xl\" | \"2xl\", align?: \"start\" | \"center\" | \"end\" | \"stretch\" | \"baseline\", justify?: \"start\" | \"center\" | \"end\" | \"between\" | \"around\" | \"evenly\", wrap?: boolean)", + "signature": "Stack(children: any[], direction?: \"row\" | \"column\", gap?: \"none\" | \"xs\" | \"s\" | \"m\" | \"l\" | \"xl\" | \"2xl\", align?: \"start\" | \"center\" | \"end\" | \"stretch\" | \"baseline\", justify?: \"start\" | \"center\" | \"end\" | \"between\" | \"around\" | \"evenly\", wrap?: boolean)", "description": "Flex container. direction: \"row\"|\"column\" (default \"column\"). gap: \"none\"|\"xs\"|\"s\"|\"m\"|\"l\"|\"xl\"|\"2xl\" (default \"m\"). align: \"start\"|\"center\"|\"end\"|\"stretch\"|\"baseline\". justify: \"start\"|\"center\"|\"end\"|\"between\"|\"around\"|\"evenly\"." }, "Tabs": { diff --git a/packages/lang-core/package.json b/packages/lang-core/package.json index 5efcbe58f..ea0b8b2f2 100644 --- a/packages/lang-core/package.json +++ b/packages/lang-core/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/lang-core", - "version": "0.2.0", + "version": "0.2.1", "description": "Framework-agnostic core for OpenUI Lang: parser, prompt generation, validation, and type definitions", "license": "MIT", "type": "module", diff --git a/packages/lang-core/src/library.ts b/packages/lang-core/src/library.ts index fdbe46238..17aacf468 100644 --- a/packages/lang-core/src/library.ts +++ b/packages/lang-core/src/library.ts @@ -108,17 +108,18 @@ function getZodType(schema: unknown): string | undefined { } function isOptionalType(schema: unknown): boolean { - return getZodType(schema) === "optional"; -} - -function unwrapOptional(schema: unknown): unknown { - const def = getZodDef(schema); - if (def?.type === "optional") return def.innerType; - return schema; + const type = getZodType(schema); + return type === "optional" || type === "default" || type === "nullable"; } function unwrap(schema: unknown): unknown { - return unwrapOptional(schema); + let s = schema; + let def = getZodDef(s); + while (def?.type === "optional" || def?.type === "default" || def?.type === "nullable") { + s = def.innerType; + def = getZodDef(s); + } + return s; } function isArrayType(schema: unknown): boolean { @@ -204,6 +205,14 @@ function resolveBaseType(inner: unknown): string | undefined { if (zodType === "string") return "string"; if (zodType === "number") return "number"; if (zodType === "boolean") return "boolean"; + if (zodType === "any") return "any"; + + if (zodType === "record") { + const def = getZodDef(inner); + const keyType = resolveTypeAnnotation(def?.keyType) ?? "string"; + const valueType = resolveTypeAnnotation(def?.valueType) ?? "any"; + return `Record<${keyType}, ${valueType}>`; + } const enumVals = getEnumValues(inner); if (enumVals) return enumVals.map((v) => `"${v}"`).join(" | "); @@ -226,7 +235,9 @@ function resolveBaseType(inner: unknown): string | undefined { return `{${fields.join(", ")}}`; } - return undefined; + // Fallback for unrecognized Zod types (z.tuple, z.date, etc.) + // "any" is safer than undefined — the LLM sees `param: any` instead of bare `param` + return "any"; } // ─── Field analysis & signature generation ──────────────────────────────────