diff --git a/packages/vue-lang/README.md b/packages/vue-lang/README.md index 55eac4732..9533c6e53 100644 --- a/packages/vue-lang/README.md +++ b/packages/vue-lang/README.md @@ -119,6 +119,9 @@ import { Renderer } from "@openuidev/vue-lang"; | `onStateUpdate` | `(state: Record) => void` | Callback when form field values change | | `initialState` | `Record` | Initial form state for hydration | | `onParseResult` | `(result: ParseResult \| null) => void` | Callback when the parse result changes | +| `toolProvider` | `Record \| McpClientLike \| null` | Tool provider for executing `Query()` and `Mutation()` tool calls | +| `queryLoader` | `Component \| VNode \| null` | Custom loading spinner / loader component shown during query loading | +| `onError` | `(errors: OpenUIError[]) => void` | Callback triggered with structured, LLM-friendly errors | #### Errors @@ -150,15 +153,16 @@ if (result.meta.unresolved.length > 0) { Use these inside component renderers to interact with the rendering context: -| Composable | Description | -| :--------------------- | :----------------------------------- | -| `useIsStreaming()` | Whether the model is still streaming | -| `useRenderNode()` | Render child element nodes | -| `useTriggerAction()` | Trigger an action event | -| `useGetFieldValue()` | Get a form field's current value | -| `useSetFieldValue()` | Set a form field's value | -| `useSetDefaultValue()` | Set a field's default value | -| `useFormName()` | Get the current form's name | +| Composable | Description | +| :--------------------- | :------------------------------------------- | +| `useIsStreaming()` | Whether the model is still streaming | +| `useIsQueryLoading()` | Whether any Query is currently fetching data | +| `useRenderNode()` | Render child element nodes | +| `useTriggerAction()` | Trigger an action event | +| `useGetFieldValue()` | Get a form field's current value | +| `useSetFieldValue()` | Set a form field's value | +| `useSetDefaultValue()` | Set a field's default value | +| `useFormName()` | Get the current form's name | ### Form Validation @@ -190,6 +194,45 @@ import type { } from "@openuidev/vue-lang"; ``` +## Tool Provider Support (Queries & Mutations) + +OpenUI Lang connects to your backend through tools. You can register a `toolProvider` to handle data fetching (`Query()`) and updates (`Mutation()`) natively in Vue: + +```vue + + + +``` + ## JSON Schema Output Libraries can also produce a JSON Schema representation of their components: diff --git a/packages/vue-lang/src/Renderer.vue b/packages/vue-lang/src/Renderer.vue index a2393dfdb..0f71d120c 100644 --- a/packages/vue-lang/src/Renderer.vue +++ b/packages/vue-lang/src/Renderer.vue @@ -1,11 +1,18 @@ + + diff --git a/packages/vue-lang/src/__tests__/Renderer.query.test.ts b/packages/vue-lang/src/__tests__/Renderer.query.test.ts new file mode 100644 index 000000000..6a6f46efe --- /dev/null +++ b/packages/vue-lang/src/__tests__/Renderer.query.test.ts @@ -0,0 +1,345 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; +import { defineComponent as defineVueComponent, h, nextTick } from "vue"; +import { z } from "zod/v4"; +import Renderer from "../Renderer.vue"; +import { useOpenUI } from "../context.js"; +import { createLibrary, defineComponent } from "../library.js"; + +async function flushMicrotasks() { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +} + +describe("Renderer Query and Mutation Support", () => { + const TicketList = defineVueComponent({ + name: "TicketList", + props: ["props"], + setup() { + const ctx = useOpenUI(); + return { ctx }; + }, + template: ` +
+
{{ ctx.isQueryLoading.value ? 'loading' : 'idle' }}
+
+ {{ ticket.title }} +
+
+ `, + }); + + const TicketListComponent = defineComponent({ + name: "TicketList", + props: z.object({ + tickets: z.array(z.object({ id: z.string(), title: z.string() })), + }), + description: "Displays a list of tickets", + component: TicketList as any, + }); + + const TicketCreator = defineVueComponent({ + name: "TicketCreator", + props: ["props"], + setup(props) { + const ctx = useOpenUI(); + const create = () => { + ctx.triggerAction("Create Ticket", undefined, props.props.onSave); + }; + return { create }; + }, + template: ` +
+
{{ props.status }}
+ +
+ `, + }); + + const TicketCreatorComponent = defineComponent({ + name: "TicketCreator", + props: z.object({ + status: z.string().optional(), + onSave: z.any().optional(), + }), + description: "Creates tickets", + component: TicketCreator as any, + }); + + const library = createLibrary({ + components: [TicketListComponent, TicketCreatorComponent], + root: "TicketList", + }); + + it("performs query and resolves data asynchronously", async () => { + const toolProvider = { + list_tickets: vi.fn().mockResolvedValue([ + { id: "1", title: "Ticket One" }, + { id: "2", title: "Ticket Two" }, + ]), + }; + + const response = ` + tickets = Query("list_tickets", {}, []) + root = TicketList(tickets) + `; + + const wrapper = mount(Renderer, { + props: { + response, + library, + toolProvider, + }, + }); + + // Before query resolves, it should render the default value (empty list) + expect(wrapper.find("#loading").text()).toBe("loading"); + expect(wrapper.findAll(".ticket").length).toBe(0); + + // Let the async tool call resolve and Vue update DOM + await flushMicrotasks(); + await nextTick(); + + // Now it should show the loaded items and idle status + expect(wrapper.find("#loading").text()).toBe("idle"); + const tickets = wrapper.findAll(".ticket"); + expect(tickets.length).toBe(2); + expect(tickets[0]?.text()).toBe("Ticket One"); + expect(tickets[1]?.text()).toBe("Ticket Two"); + expect(toolProvider.list_tickets).toHaveBeenCalled(); + }); + + it("handles query failures and propagates errors via onError", async () => { + const onError = vi.fn(); + const toolProvider = { + list_tickets: vi.fn().mockRejectedValue(new Error("Database offline")), + }; + + const response = ` + tickets = Query("list_tickets", {}, []) + root = TicketList(tickets) + `; + + mount(Renderer, { + props: { + response, + library, + toolProvider, + onError, + }, + }); + + await flushMicrotasks(); + await nextTick(); + + expect(onError).toHaveBeenCalled(); + const calls = onError.mock.calls; + const errors = (calls[calls.length - 1] as any)?.[0] || []; + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].code).toBe("tool-error"); + expect(errors[0].message).toContain("Database offline"); + }); + + it("supports custom queryLoader component", async () => { + const CustomLoader = defineVueComponent({ + name: "CustomLoader", + template: `
Loading tickets...
`, + }); + + const toolProvider = { + list_tickets: vi.fn().mockReturnValue(new Promise(() => {})), // never resolves + }; + + const response = ` + tickets = Query("list_tickets", {}, []) + root = TicketList(tickets) + `; + + const wrapper = mount(Renderer, { + props: { + response, + library, + toolProvider, + queryLoader: CustomLoader, + }, + }); + + expect(wrapper.find("#custom-loader").exists()).toBe(true); + expect(wrapper.find("#custom-loader").text()).toBe("Loading tickets..."); + }); + + it("supports mutations, tracks status, and invalidates queries on run", async () => { + const mockListTickets = vi + .fn() + .mockResolvedValueOnce([{ id: "1", title: "Original Ticket" }]) + .mockResolvedValueOnce([ + { id: "1", title: "Original Ticket" }, + { id: "2", title: "Created Ticket" }, + ]); + + let resolveMutation: (value: any) => void; + const mutationPromise = new Promise((resolve) => { + resolveMutation = resolve; + }); + const mockCreateTicket = vi.fn().mockReturnValue(mutationPromise); + + const toolProvider = { + list_tickets: mockListTickets, + create_ticket: mockCreateTicket, + }; + + // Define a wrapper component that renders both TicketCreator and TicketList + const Container = defineVueComponent({ + name: "Container", + props: ["props", "renderNode"], + setup(props) { + return () => h("div", {}, props.renderNode(props.props.children)); + }, + }); + + const ContainerComponent = defineComponent({ + name: "Container", + props: z.object({ + children: z.any(), + }), + description: "Simple container", + component: Container as any, + }); + + const extendedLibrary = createLibrary({ + components: [TicketListComponent, TicketCreatorComponent, ContainerComponent], + root: "Container", + }); + + const response = ` + tickets = Query("list_tickets", {}, []) + createResult = Mutation("create_ticket", { title: "Created Ticket" }) + root = Container( + [ + TicketCreator(createResult.status, Action([@Run(createResult), @Run(tickets)])), + TicketList(tickets) + ] + ) + `; + + const wrapper = mount(Renderer, { + props: { + response, + library: extendedLibrary, + toolProvider, + }, + }); + + // Wait for the initial query to load + await flushMicrotasks(); + await nextTick(); + + expect(wrapper.findAll(".ticket").length).toBe(1); + expect(wrapper.find(".ticket").text()).toBe("Original Ticket"); + expect(wrapper.find("#mut-status").text()).toBe("idle"); + + // Click "Create" button to fire mutation + await wrapper.find("#create-btn").trigger("click"); + + // Let the mutation action start and update reactive state + await flushMicrotasks(); + await nextTick(); + + // While mutation is running (before we resolve the promise), status should be loading + expect(wrapper.find("#mut-status").text()).toBe("loading"); + + // Resolve the mutation tool call + resolveMutation!({ id: "2", title: "Created Ticket" }); + + // Let the mutation finish and the invalidated query resolve + await flushMicrotasks(); + await nextTick(); + + // Now status should be success and ticket list should be updated + expect(wrapper.find("#mut-status").text()).toBe("success"); + const tickets = wrapper.findAll(".ticket"); + expect(tickets.length).toBe(2); + expect(tickets[0]?.text()).toBe("Original Ticket"); + expect(tickets[1]?.text()).toBe("Created Ticket"); + + expect(mockCreateTicket).toHaveBeenCalledWith({ title: "Created Ticket" }); + }); + + it("handles mutation failures and exposes error status", async () => { + const onError = vi.fn(); + const mockCreateTicket = vi.fn().mockRejectedValue(new Error("Unauthorized")); + + const toolProvider = { + list_tickets: vi.fn().mockResolvedValue([]), + create_ticket: mockCreateTicket, + }; + + const response = ` + tickets = Query("list_tickets", {}, []) + createResult = Mutation("create_ticket", { title: "Fail Ticket" }) + root = TicketCreator(createResult.status, Action([@Run(createResult)])) + `; + + const wrapper = mount(Renderer, { + props: { + response, + library, + toolProvider, + onError, + }, + }); + + await flushMicrotasks(); + await nextTick(); + + await wrapper.find("#create-btn").trigger("click"); + await flushMicrotasks(); + await nextTick(); + + // Check mutation status is set to error + expect(wrapper.find("#mut-status").text()).toBe("error"); + expect(onError).toHaveBeenCalled(); + const calls = onError.mock.calls; + const errors = (calls[calls.length - 1] as any)?.[0] || []; + expect( + errors.some((e: any) => e.code === "tool-error" && e.message.includes("Unauthorized")), + ).toBe(true); + }); + + it("handles transition of toolProvider from null to active provider", async () => { + const toolProvider = { + list_tickets: vi.fn().mockResolvedValue([{ id: "1", title: "Async Ticket" }]), + }; + + const response = ` + tickets = Query("list_tickets", {}, []) + root = TicketList(tickets) + `; + + const wrapper = mount(Renderer, { + props: { + response, + library, + toolProvider: null, + }, + }); + + // Since toolProvider is null initially, it cannot execute, so it should be idle + expect(wrapper.find("#loading").text()).toBe("idle"); + expect(wrapper.findAll(".ticket").length).toBe(0); + + // Now set toolProvider to active provider + await wrapper.setProps({ toolProvider }); + + // Let query run and resolve + await flushMicrotasks(); + await nextTick(); + + expect(wrapper.find("#loading").text()).toBe("idle"); + const tickets = wrapper.findAll(".ticket"); + expect(tickets.length).toBe(1); + expect(tickets[0]?.text()).toBe("Async Ticket"); + expect(toolProvider.list_tickets).toHaveBeenCalled(); + }); +}); diff --git a/packages/vue-lang/src/context.ts b/packages/vue-lang/src/context.ts index 3fb3f3a19..4fe48faff 100644 --- a/packages/vue-lang/src/context.ts +++ b/packages/vue-lang/src/context.ts @@ -1,3 +1,4 @@ +import type { EvaluationContext, Store } from "@openuidev/lang-core"; import type { InjectionKey, Ref } from "vue"; import { inject, provide, watchEffect } from "vue"; import type { Library, RenderNodeResult } from "./library.js"; @@ -35,6 +36,15 @@ export interface OpenUIContextValue { /** Whether the LLM is currently streaming content. */ isStreaming: Ref; + /** Whether any Query is currently fetching data. */ + isQueryLoading: Ref; + + /** Reactive binding store for $variables and form data. */ + store: Store; + + /** AST evaluation context used by runtime expression evaluation. */ + evaluationContext: EvaluationContext; + /** Get a form field value. Returns undefined if not set. */ getFieldValue: (formName: string | undefined, name: string) => any; @@ -106,6 +116,13 @@ export function useIsStreaming(): Ref { return useOpenUI().isStreaming; } +/** + * Whether any Query is currently fetching data. + */ +export function useIsQueryLoading(): Ref { + return useOpenUI().isQueryLoading; +} + /** * Get a form field value from the form state context. */ diff --git a/packages/vue-lang/src/index.ts b/packages/vue-lang/src/index.ts index fc8111401..a0037bbab 100644 --- a/packages/vue-lang/src/index.ts +++ b/packages/vue-lang/src/index.ts @@ -16,7 +16,7 @@ export type { // ─── Renderer ─── -import type { ActionEvent, ParseResult } from "@openuidev/lang-core"; +import type { ActionEvent, McpClientLike, OpenUIError, ParseResult } from "@openuidev/lang-core"; import type { Library } from "./library.js"; export { default as Renderer } from "./Renderer.vue"; @@ -30,6 +30,12 @@ export interface RendererProps { onStateUpdate?: (state: Record) => void; initialState?: Record; onParseResult?: (result: ParseResult | null) => void; + toolProvider?: + | Record) => Promise> + | McpClientLike + | null; + queryLoader?: any; + onError?: (errors: OpenUIError[]) => void; } // ─── Context (composables for use inside component renderers) ─── @@ -39,6 +45,7 @@ export { provideOpenUIContext, useFormName, useGetFieldValue, + useIsQueryLoading, useIsStreaming, useOpenUI, useRenderNode, @@ -58,7 +65,15 @@ export type { ParsedRule, ValidatorFn } from "./validation.js"; // ─── Re-exports from lang-core (parser, types) ─── -export { BuiltinActionType } from "@openuidev/lang-core"; -export type { ActionEvent, ElementNode, ParseResult } from "@openuidev/lang-core"; +export { BuiltinActionType, extractToolResult, ToolNotFoundError } from "@openuidev/lang-core"; +export type { + ActionEvent, + ElementNode, + EvaluationContext, + McpClientLike, + OpenUIError, + ParseResult, + ToolProvider, +} from "@openuidev/lang-core"; export { createParser, createStreamingParser, type LibraryJSONSchema } from "@openuidev/lang-core"; diff --git a/packages/vue-lang/src/state.ts b/packages/vue-lang/src/state.ts new file mode 100644 index 000000000..e41a9d8c6 --- /dev/null +++ b/packages/vue-lang/src/state.ts @@ -0,0 +1,497 @@ +import { + ACTION_STEPS, + BuiltinActionType, + createQueryManager, + createStore, + createStreamingParser, + enrichErrors, + evaluate, + evaluateElementProps, + type ActionEvent, + type ActionPlan, + type EvalContext, + type EvaluationContext, + type OpenUIError, + type ParseResult, + type QueryManager, + type QuerySnapshot, + type ToolProvider, +} from "@openuidev/lang-core"; +import { + computed, + isRef, + onUnmounted, + ref, + shallowRef, + watchEffect, + type ComputedRef, + type Ref, +} from "vue"; +import type { OpenUIContextValue } from "./context.js"; + +/** Unwrap { value, componentType } wrapper from form field entries. Returns raw value. */ +function unwrapFieldValue(v: unknown): unknown { + if ( + v && + typeof v === "object" && + !Array.isArray(v) && + "value" in (v as Record) + ) { + return (v as Record)["value"]; + } + return v; +} + +export interface UseOpenUIStateOptions { + response: Ref | string | null; + library: any; + isStreaming: Ref | boolean; + onAction?: (event: ActionEvent) => void; + onStateUpdate?: (state: Record) => void; + initialState?: Record; + toolProvider?: Ref | ToolProvider | null; + onError?: (errors: OpenUIError[]) => void; +} + +export interface OpenUIState { + result: ComputedRef; + parseResult: ComputedRef; + contextValue: ComputedRef; + isQueryLoading: ComputedRef; +} + +export function useOpenUIState( + options: UseOpenUIStateOptions, + renderDeep: (value: unknown) => any, +): OpenUIState { + const responseRef = computed(() => { + return typeof options.response === "object" && + options.response !== null && + "value" in options.response + ? options.response.value + : options.response; + }); + + const isStreamingRef = computed(() => { + return typeof options.isStreaming === "object" && + options.isStreaming !== null && + "value" in options.isStreaming + ? options.isStreaming.value + : options.isStreaming; + }); + + const toolProviderRef = computed(() => { + return isRef(options.toolProvider) ? options.toolProvider.value : options.toolProvider; + }); + + // ─── Streaming parser ─── + const sp = createStreamingParser(options.library.toJSONSchema(), options.library.root); + + // ─── Parse result ─── + const parseException = ref(null); + const parseResult = computed(() => { + parseException.value = null; + const resp = responseRef.value; + if (!resp) return null; + try { + return sp.set(resp); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + parseException.value = { + source: "parser", + code: "parse-exception", + message: `Parser crashed: ${msg}`, + hint: "The response may contain syntax the parser cannot handle", + }; + return null; + } + }); + + // ─── Store ─── + const store = createStore(); + + // ─── QueryManager ─── + const queryManager = shallowRef(null); + const querySnapshot = shallowRef({ + __openui_loading: [], + __openui_refetching: [], + __openui_errors: [], + }); + + watchEffect((onCleanup) => { + const provider = toolProviderRef.value; + const qm = createQueryManager(provider ?? null); + qm.activate(); + queryManager.value = qm; + + querySnapshot.value = qm.getSnapshot(); + const unsub = qm.subscribe(() => { + querySnapshot.value = qm.getSnapshot(); + }); + + onCleanup(() => { + unsub(); + qm.dispose(); + }); + }); + + onUnmounted(() => { + store.dispose(); + }); + + // ─── Initialize Store ─── + let lastStoreInitKey = ""; + watchEffect(() => { + const res = parseResult.value; + const initial = options.initialState; + if (!res?.stateDeclarations && !initial) return; + const key = `${JSON.stringify(res?.stateDeclarations)}::${JSON.stringify(initial)}`; + if (lastStoreInitKey === key) return; + lastStoreInitKey = key; + + const bindingDefaults: Record = {}; + if (initial) { + for (const [k, v] of Object.entries(initial)) { + if (k.startsWith("$")) { + bindingDefaults[k] = v; + } else { + store.set(k, v); + } + } + } + store.initialize(res?.stateDeclarations ?? {}, bindingDefaults); + }); + + // ─── Snapshots ─── + const storeSnapshot = shallowRef(store.getSnapshot()); + + const unsubStore = store.subscribe(() => { + storeSnapshot.value = store.getSnapshot(); + }); + + onUnmounted(() => { + unsubStore(); + }); + + // ─── Build EvaluationContext ─── + const evaluationContext = computed(() => { + // Touch snapshots to reactively track changes + const _store = storeSnapshot.value; + const qm = queryManager.value; + + return { + getState: (name: string) => unwrapFieldValue(store.get(name)), + resolveRef: (name: string) => { + if (!qm) return null; + const mutResult = qm.getMutationResult(name); + if (mutResult) return mutResult; + return qm.getResult(name); + }, + }; + }); + + // ─── Evaluate and submit queries ─── + watchEffect(() => { + if (isStreamingRef.value) return; + + // Touch storeSnapshot to re-evaluate when dependencies change + const _store = storeSnapshot.value; + const qm = queryManager.value; + if (!qm) return; + + const queryStmts = parseResult.value?.queryStatements ?? []; + const evaluatedNodes = queryStmts.map((qn) => { + const relevantDeps: Record = {}; + if (qn.deps) { + for (const refName of qn.deps) { + relevantDeps[refName] = storeSnapshot.value[refName]; + } + } + return { + statementId: qn.statementId, + toolName: qn.toolAST ? (evaluate(qn.toolAST, evaluationContext.value) as string) : "", + args: qn.argsAST ? evaluate(qn.argsAST, evaluationContext.value) : null, + defaults: qn.defaultsAST ? evaluate(qn.defaultsAST, evaluationContext.value) : null, + refreshInterval: qn.refreshAST + ? (evaluate(qn.refreshAST, evaluationContext.value) as number) + : undefined, + deps: Object.keys(relevantDeps).length > 0 ? relevantDeps : undefined, + complete: qn.complete, + }; + }); + + qm.evaluateQueries(evaluatedNodes); + }); + + // ─── Register mutations ─── + watchEffect(() => { + if (isStreamingRef.value) return; + const qm = queryManager.value; + if (!qm) return; + + const mutStmts = parseResult.value?.mutationStatements ?? []; + const nodes = mutStmts.map((mn) => ({ + statementId: mn.statementId, + toolName: mn.toolAST ? (evaluate(mn.toolAST, evaluationContext.value) as string) : "", + })); + qm.registerMutations(nodes); + }); + + // ─── Fire onStateUpdate when Store changes ─── + let lastStateUpdateSnapshot = store.getSnapshot(); + const unsubStateUpdate = store.subscribe(() => { + const currentSnapshot = store.getSnapshot(); + if (currentSnapshot === lastStateUpdateSnapshot) return; + lastStateUpdateSnapshot = currentSnapshot; + options.onStateUpdate?.(currentSnapshot); + }); + onUnmounted(() => { + unsubStateUpdate(); + }); + + // ─── getFieldValue ─── + function getFieldValue(formName: string | undefined, name: string): any { + if (!formName) return unwrapFieldValue(store.get(name)); + const formData = store.get(formName); + if (!formData || typeof formData !== "object" || Array.isArray(formData)) return undefined; + return unwrapFieldValue((formData as Record)[name]); + } + + // ─── setFieldValue ─── + function setFieldValue( + formName: string | undefined, + componentType: string | undefined, + name: string, + value: unknown, + shouldTriggerSaveCallback: boolean = true, + ): void { + const wrapped = { value, componentType }; + if (!formName) { + store.set(name, wrapped); + } else { + const raw = store.get(formName); + const formData = + raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : {}; + store.set(formName, { ...formData, [name]: wrapped }); + } + if (shouldTriggerSaveCallback) { + options.onStateUpdate?.(store.getSnapshot()); + } + } + + // ─── Materialize form payload ─── + function getFormPayload(formName?: string): Record | undefined { + if (formName) { + const raw = store.get(formName); + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + return { [formName]: raw }; + } + } + return store.getSnapshot(); + } + + // ─── triggerAction ─── + async function triggerAction( + userMessage: string, + formName?: string, + action?: ActionPlan | { type?: string; params?: Record }, + ): Promise { + const formPayload = getFormPayload(formName); + const qm = queryManager.value; + + // Legacy action config path (v0.1 compat) — { type?, params? } + if (action && !("steps" in action)) { + const actionType = action.type || BuiltinActionType.ContinueConversation; + const params = { ...(action.params || {}) }; + if ((action as any).url) params["url"] = (action as any).url; + if ((action as any).context) params["context"] = (action as any).context; + options.onAction?.({ + type: actionType, + params, + humanFriendlyMessage: userMessage, + formState: formPayload, + formName, + }); + return; + } + + // ActionPlan path (v0.5) + const actionPlan = action as ActionPlan | undefined; + if (actionPlan?.steps) { + for (const step of actionPlan.steps) { + switch (step.type) { + case ACTION_STEPS.Run: { + if (step.refType === "mutation") { + const mn = parseResult.value?.mutationStatements?.find( + (m) => m.statementId === step.statementId, + ); + const evaluatedArgs = mn?.argsAST + ? (evaluate(mn.argsAST, evaluationContext.value) as Record) + : {}; + if (!qm) return; + const ok = await qm.fireMutation(step.statementId, evaluatedArgs); + if (!ok) return; // halt on failure + } else { + if (qm) qm.invalidate([step.statementId]); + } + break; + } + case ACTION_STEPS.ToAssistant: + options.onAction?.({ + type: BuiltinActionType.ContinueConversation, + params: step.context ? { context: step.context } : {}, + humanFriendlyMessage: step.message, + formState: formPayload, + formName, + }); + break; + case ACTION_STEPS.OpenUrl: + options.onAction?.({ + type: BuiltinActionType.OpenUrl, + params: { url: step.url }, + humanFriendlyMessage: "", + formState: formPayload, + formName, + }); + break; + case ACTION_STEPS.Set: { + if (!step.valueAST) break; + const value = evaluate(step.valueAST, evaluationContext.value); + store.set(step.target, value); + break; + } + case ACTION_STEPS.Reset: { + const decls = parseResult.value?.stateDeclarations ?? {}; + for (const target of step.targets) { + store.set(target, decls[target] ?? null); + } + break; + } + } + } + return; + } + + // Default + options.onAction?.({ + type: BuiltinActionType.ContinueConversation, + params: {}, + humanFriendlyMessage: userMessage, + formState: formPayload, + formName, + }); + } + + const isQueryLoading = computed(() => querySnapshot.value.__openui_loading.length > 0); + + // ─── Context value ─── + const contextValue = computed(() => ({ + library: options.library, + renderNode: renderDeep, + triggerAction, + isStreaming: isStreamingRef as any, + isQueryLoading: isQueryLoading as any, + getFieldValue, + setFieldValue, + store, + evaluationContext: evaluationContext.value, + })); + + // ─── Evaluate props ─── + const runtimeErrors = ref([]); + + const evaluatedResult = computed(() => { + const res = parseResult.value; + if (!res?.root) return res; + // Touch querySnapshot to re-evaluate props when queries/mutations resolve/fail + const _query = querySnapshot.value; + const errors: OpenUIError[] = []; + const evalCtx: EvalContext = { + ctx: evaluationContext.value, + library: options.library, + store, + errors, + }; + try { + const evaluatedRoot = evaluateElementProps(res.root, evalCtx); + runtimeErrors.value = errors; + return { ...res, root: evaluatedRoot }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + errors.push({ + source: "runtime", + code: "runtime-error", + message: `Prop evaluation failed: ${msg}`, + }); + runtimeErrors.value = errors; + return res; + } + }); + + // ─── Collect and fire onError ─── + let lastErrorKey = ""; + watchEffect(() => { + if (isStreamingRef.value) { + if (lastErrorKey !== "") { + lastErrorKey = ""; + options.onError?.([]); + } + return; + } + + const errors: OpenUIError[] = []; + + // Parser exception + if (parseException.value) { + errors.push(parseException.value); + } + + // Parse failure + const resp = responseRef.value; + const res = parseResult.value; + if (resp && !res?.root && !parseException.value) { + errors.push({ + source: "parser", + code: "parse-failed", + message: res + ? "Code parsed but produced no renderable root component" + : "Response could not be parsed as valid openui-lang", + hint: `The entire response must be valid openui-lang code starting with root = ${options.library.root ?? "Root"}(...)`, + }); + } + + // Parser validation errors + if (res?.meta?.errors?.length) { + errors.push( + ...enrichErrors( + res.meta.errors, + options.library.toJSONSchema(), + Object.keys(options.library.components), + ), + ); + } + + // Runtime eval errors + errors.push(...runtimeErrors.value); + + // Query/mutation errors + errors.push(...(querySnapshot.value.__openui_errors ?? [])); + + // Deduplicate + const key = JSON.stringify(errors); + if (key === lastErrorKey) return; + lastErrorKey = key; + + if (options.onError) { + options.onError(errors); + } else if (errors.length > 0) { + for (const e of errors) { + console.warn(`[openui] ${e.source}/${e.code}: ${e.message}`); + } + } + }); + + return { result: evaluatedResult, parseResult, contextValue, isQueryLoading }; +}