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
61 changes: 52 additions & 9 deletions packages/vue-lang/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ import { Renderer } from "@openuidev/vue-lang";
| `onStateUpdate` | `(state: Record<string, any>) => void` | Callback when form field values change |
| `initialState` | `Record<string, any>` | Initial form state for hydration |
| `onParseResult` | `(result: ParseResult \| null) => void` | Callback when the parse result changes |
| `toolProvider` | `Record<string, Function> \| 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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
<template>
<Renderer
:response="response"
:library="library"
:tool-provider="toolProvider"
:query-loader="SpinnerComponent"
:on-error="handleErrors"
/>
</template>

<script setup lang="ts">
import { Renderer } from "@openuidev/vue-lang";
import SpinnerComponent from "./Spinner.vue";

const toolProvider = {
async get_server_health(args: Record<string, unknown>) {
const res = await fetch(`/api/health`);
return res.json();
},
async create_ticket(args: Record<string, unknown>) {
const res = await fetch(`/api/tickets`, {
method: "POST",
body: JSON.stringify(args)
});
return res.json();
}
};

function handleErrors(errors: any[]) {
console.error("OpenUI Errors:", errors);
}
</script>
```

## JSON Schema Output

Libraries can also produce a JSON Schema representation of their components:
Expand Down
192 changes: 95 additions & 97 deletions packages/vue-lang/src/Renderer.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
<script setup lang="ts">
import type { ActionEvent, ElementNode, ParseResult } from "@openuidev/lang-core";
import { BuiltinActionType, createParser } from "@openuidev/lang-core";
import { computed, h, ref, toRef, watch } from "vue";
import type { ActionConfig } from "./context.js";
import type {
ActionEvent,
ElementNode,
ParseResult,
OpenUIError,
McpClientLike,
ToolProvider,
} from "@openuidev/lang-core";
import { BuiltinActionType, extractToolResult, ToolNotFoundError } from "@openuidev/lang-core";
import { computed, h, toRef, watch, type Component, type VNode } from "vue";
import { provideOpenUIContext } from "./context.js";
import type { Library, RenderNodeResult } from "./library.js";
import RenderNode from "./RenderNode.vue";
import { useOpenUIState } from "./state.js";

interface RendererProps {
/** Raw response text (openui-lang code). */
Expand All @@ -28,101 +35,47 @@ interface RendererProps {
initialState?: Record<string, any>;
/** Called whenever the parse result changes. */
onParseResult?: (result: ParseResult | null) => void;
/** Tool provider for Query()/Mutation() calls. */
toolProvider?:
| Record<string, (args: Record<string, unknown>) => Promise<unknown>>
| McpClientLike
| null;
/** Custom loading indicator shown while queries are fetching. */
queryLoader?: Component | VNode | null;
/** Called with structured errors. */
onError?: (errors: OpenUIError[]) => void;
}

const props = withDefaults(defineProps<RendererProps>(), {
isStreaming: false,
});

// ─── Parser (created once from the library's JSON schema) ───
// Intentional: parser is created once, not reactive on library changes (matches react-lang).
const parser = createParser(props.library.toJSONSchema());

// ─── Parse result (derived from response) ───
const result = computed<ParseResult | null>(() => {
if (!props.response) return null;
try {
return parser.parse(props.response);
} catch (e) {
console.error("[openui] Parse error:", e);
return null;
}
});

// ─── Form state ───
const formState = ref<Record<string, any>>(props.initialState ?? {});

// Sync if initialState changes (e.g. loading a different message)
watch(
() => props.initialState,
(newVal, oldVal) => {
if (newVal !== oldVal) {
formState.value = newVal ?? {};
// Stable ToolProvider wrapper — identity never changes. callTool() reads the
// latest input from props.toolProvider on every call, so function map updates
// are always observed without triggering re-creation.
const stableToolProvider: ToolProvider = {
async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
const current = props.toolProvider;
if (current == null) throw new Error("[openui] toolProvider is null");
if (typeof (current as any).callTool === "function") {
const result = await (current as any).callTool({
name: toolName,
arguments: args,
});
return extractToolResult(result);
}
const map = current as Record<string, (a: Record<string, unknown>) => Promise<unknown>>;
const fn = map[toolName];
if (!fn) throw new ToolNotFoundError(toolName, Object.keys(map));
return fn(args);
},
);

// ─── Notify on parse result change ───
watch(
result,
(r) => {
props.onParseResult?.(r);
},
{ immediate: true },
);

// ─── Form state functions ───

function getFieldValue(formName: string | undefined, name: string): any {
return formName ? formState.value[formName]?.[name]?.value : formState.value[name]?.value;
}

function setFieldValue(
formName: string | undefined,
componentType: string | undefined,
name: string,
value: any,
shouldTriggerSaveCallback: boolean = true,
): void {
if (formName) {
formState.value[formName] = {
...formState.value[formName],
[name]: { value, componentType },
};
} else {
formState.value[name] = { value, componentType };
}

if (shouldTriggerSaveCallback && props.onStateUpdate) {
props.onStateUpdate({ ...formState.value });
}
}

function triggerAction(userMessage: string, formName?: string, action?: ActionConfig): void {
const actionType = action?.type || BuiltinActionType.ContinueConversation;
const actionParams = action?.params;
};

// Collect relevant form state
let relevantState: Record<string, any> | undefined;
if (formName && formState.value[formName]) {
relevantState = { [formName]: formState.value[formName] };
} else if (Object.keys(formState.value).length > 0) {
relevantState = formState.value;
}

if (!props.onAction) return;

props.onAction({
type: actionType,
params: actionParams || {},
humanFriendlyMessage: userMessage,
formState: relevantState,
formName,
});
}
const resolvedToolProvider = computed<ToolProvider | null>(() => {
return props.toolProvider != null ? stableToolProvider : null;
});

// ─── Render node function ───

function renderNode(value: unknown): RenderNodeResult {
if (value == null) return null;
if (typeof value === "string") return value;
Expand All @@ -139,17 +92,62 @@ function renderNode(value: unknown): RenderNodeResult {
return null;
}

// ─── Provide context ───
provideOpenUIContext({
library: props.library,
const { result, parseResult, contextValue, isQueryLoading } = useOpenUIState(
{
response: toRef(props, "response"),
library: props.library,
isStreaming: toRef(props, "isStreaming"),
onAction: (e) => props.onAction?.(e),
onStateUpdate: (s) => props.onStateUpdate?.(s),
initialState: props.initialState,
toolProvider: resolvedToolProvider,
onError: (errs) => props.onError?.(errs),
},
renderNode,
triggerAction,
isStreaming: toRef(props, "isStreaming"),
getFieldValue,
setFieldValue,
});
);

// ─── Notify on parse result change ───
watch(
parseResult,
(r) => {
props.onParseResult?.(r);
},
{ immediate: true },
);

// ─── Provide context ───
provideOpenUIContext(contextValue.value);
</script>

<template>
<RenderNode v-if="result?.root" :node="result.root" />
<div style="position: relative">
<div v-if="isQueryLoading" style="position: absolute; top: 8px; right: 8px; z-index: 10">
<component v-if="props.queryLoader" :is="props.queryLoader" />
<div
v-else
style="
width: 16px;
height: 16px;
border: 2px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: openui-spin 0.6s linear infinite;
"
/>
</div>
<div :style="{ opacity: isQueryLoading ? 0.7 : 1, transition: 'opacity 0.2s ease' }">
<RenderNode v-if="result?.root" :node="result.root" />
</div>
</div>
</template>

<style>
@keyframes openui-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
Loading
Loading