A2UI v0.9
中文:A2UI v0.9
v0.9 is the recommended protocol: every command carries version: 'v0.9' and uses explicit createSurface / updateComponents / updateDataModel / deleteSurface. See the Introduction for the protocol overview.
The eight demos below use v0.9 commands and mirror the interaction scenarios, data, and state transitions of React @ant-design/x-card 2.10. They keep the Vue XCard API (:on-action, useXCardField, registerCatalog) instead of copying React's Bubble + XMarkdown marketing shell.
Examples
Basic usage
Coffee booking: register a local catalog, choose a date and coffee, then confirm. Success deletes the booking Surface and creates a result card with the coffee, date, and price.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
ActionButton,
BookForm,
CATALOGS,
CoffeeList,
CoffeeResultCard,
COFFEE_LIST,
DateTimeInput,
TextBlock,
} from './_shared';
registerCatalog(CATALOGS.coffee);
const HEADER_TEXT =
'Hello! Welcome to our online booking service 🎉\n\nPlease select your preferred date and time, and we will arrange the best seat for you. We look forward to seeing you!';
const ORDER_CONFIRMATION =
'✅ Booking confirmed! Your order has been confirmed. We look forward to seeing you!';
const commands = ref<XAgentCommand_v0_9[]>([]);
const headerText = ref('');
const confirmation = ref('');
const sessionKey = ref(0);
let textTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createBookingCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'booking',
catalogId: 'local://coffee_booking_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'booking',
components: [
{ id: 'title', component: 'Text', text: 'Coffee Shop Order', variant: 'h1' },
{
id: 'datetime',
component: 'DateTimeInput',
status: { path: '/booking/status' },
action: {
event: {
name: 'select_date',
context: { time: { path: '/booking/res/time' } },
},
},
},
{
id: 'coffee_list',
component: 'CoffeeList',
list: { path: '/booking/list/data' },
description: { path: '/booking/list/description' },
status: { path: '/booking/status' },
action: {
event: {
name: 'select_coffee',
context: { coffee: { path: '/booking/res/coffee' } },
},
},
},
{ id: 'status-text', component: 'Text', status: { path: '/booking/status' }, variant: 'success' },
{ id: 'submit-text', component: 'Text', text: 'Confirm Order' },
{
id: 'submit-btn',
component: 'ActionButton',
child: 'submit-text',
variant: 'primary',
status: { path: '/booking/status' },
res: { path: '/booking/res' },
action: {
event: {
name: 'confirm_booking',
context: {
status: { path: '/booking/status' },
res: { path: '/booking/res' },
},
},
},
},
{
id: 'root',
component: 'BookForm',
children: ['title', 'datetime', 'coffee_list', 'status-text', 'submit-btn'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'booking',
path: '/booking',
value: {
res: { time: new Date().toISOString() },
list: {
description: 'Coffee List',
data: COFFEE_LIST,
},
},
},
},
];
const createResultCommand = (res: any): XAgentCommand_v0_9 => ({
version: 'v0.9',
updateComponents: {
surfaceId: 'result',
components: [
{
id: 'result-card',
component: 'CoffeeResultCard',
name: res?.coffee?.name,
description: res?.coffee?.description,
price: res?.coffee?.price,
tag: res?.coffee?.tag,
date: res?.time,
},
{ id: 'root', component: 'BookForm', children: ['result-card'] },
],
},
});
const startHeader = () => {
if (textTimer) window.clearInterval(textTimer);
headerText.value = '';
let index = 0;
textTimer = window.setInterval(() => {
index = Math.min(index + 3, HEADER_TEXT.length);
headerText.value = HEADER_TEXT.slice(0, index);
if (index >= HEADER_TEXT.length) {
if (textTimer) window.clearInterval(textTimer);
append(...createBookingCommands());
}
}, 32);
};
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'confirm_booking') return;
const { res, status } = (payload.context || {}) as Record<string, any>;
if (status !== 'success' || !res) return;
confirmation.value = ORDER_CONFIRMATION;
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'booking' } },
{
version: 'v0.9',
createSurface: {
surfaceId: 'result',
catalogId: 'local://coffee_booking_catalog.json',
},
},
createResultCommand(res),
);
};
const reload = () => {
if (textTimer) window.clearInterval(textTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
confirmation.value = '';
headerText.value = '';
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'booking' } },
{ version: 'v0.9', deleteSurface: { surfaceId: 'result' } },
);
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
startHeader();
}, 50);
};
onMounted(startHeader);
onUnmounted(() => {
if (textTimer) window.clearInterval(textTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">选择日期和咖啡后确认订单</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959">{{ headerText }}</div>
<p v-if="confirmation" style="margin: 10px 0; color: #52c41a; font-weight: 600">{{ confirmation }}</p>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{ Text: TextBlock, DateTimeInput, BookForm, ActionButton, CoffeeList, CoffeeResultCard }"
:on-action="handleAction"
>
<XCard.Card id="booking" />
<XCard.Card id="result" />
</XCard.Box>
</div>
</template>
Progressive
After the welcome text streams in, eight products load in four batches. A loading-indicator Surface tracks progress while repeated updateComponents calls expand the product Surface.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
ALL_PROGRESS_PRODUCTS,
CATALOGS,
LoadingIndicator,
ProductCard,
ProductContainer,
PROGRESS_PRODUCT_BATCHES,
type Product,
} from './_shared';
registerCatalog(CATALOGS.progressive);
const WELCOME_TEXT =
'🎉 Welcome to the progressive product showcase!\n\nProduct data will be loaded in batches, 2 products at a time. You can observe how components are displayed progressively.';
const COMPLETE_TEXT = '✅ All products loaded! Total 8 products loaded.';
const commands = ref<XAgentCommand_v0_9[]>([]);
const headerText = ref('');
const footerText = ref('');
const sessionKey = ref(0);
let headerTimer: number | undefined;
let footerTimer: number | undefined;
let batchTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createLoadingCommands = (progress: number, loading: boolean): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'loading-indicator',
catalogId: 'local://coffee_booking_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'loading-indicator',
components: [
{
id: 'root',
component: 'LoadingIndicator',
progress,
total: ALL_PROGRESS_PRODUCTS.length,
loading,
},
],
},
},
];
const updateLoading = (progress: number, loading: boolean): XAgentCommand_v0_9 => ({
version: 'v0.9',
updateComponents: {
surfaceId: 'loading-indicator',
components: [
{
id: 'root',
component: 'LoadingIndicator',
progress,
total: ALL_PROGRESS_PRODUCTS.length,
loading,
},
],
},
});
const updateProducts = (products: Product[]): XAgentCommand_v0_9 => ({
version: 'v0.9',
updateComponents: {
surfaceId: 'progressive-demo',
components: [
...products.map((product, index) => ({
id: `product-${product.id}`,
component: 'ProductCard',
name: product.name,
price: product.price,
category: product.category,
stock: product.stock,
tag: product.tag,
index,
})),
{
id: 'root',
component: 'ProductContainer',
children: products.map((product) => `product-${product.id}`),
},
],
},
});
const streamFooter = () => {
if (footerTimer) window.clearInterval(footerTimer);
footerText.value = '';
let index = 0;
footerTimer = window.setInterval(() => {
index = Math.min(index + 2, COMPLETE_TEXT.length);
footerText.value = COMPLETE_TEXT.slice(0, index);
if (index >= COMPLETE_TEXT.length && footerTimer) window.clearInterval(footerTimer);
}, 28);
};
const startBatches = () => {
let batchIndex = 0;
const loaded: Product[] = [];
const loadNext = () => {
if (batchIndex >= PROGRESS_PRODUCT_BATCHES.length) {
append(updateLoading(ALL_PROGRESS_PRODUCTS.length, false));
streamFooter();
return;
}
loaded.push(...PROGRESS_PRODUCT_BATCHES[batchIndex]);
append(updateLoading(loaded.length, batchIndex < PROGRESS_PRODUCT_BATCHES.length - 1));
batchTimer = window.setTimeout(() => {
append(updateProducts([...loaded]));
batchIndex += 1;
batchTimer = window.setTimeout(loadNext, 550);
}, 50);
};
loadNext();
};
const startFlow = () => {
if (headerTimer) window.clearInterval(headerTimer);
headerText.value = '';
let index = 0;
headerTimer = window.setInterval(() => {
index = Math.min(index + 3, WELCOME_TEXT.length);
headerText.value = WELCOME_TEXT.slice(0, index);
if (index >= WELCOME_TEXT.length) {
if (headerTimer) window.clearInterval(headerTimer);
append(
...createLoadingCommands(0, true),
{
version: 'v0.9',
createSurface: {
surfaceId: 'progressive-demo',
catalogId: 'local://coffee_booking_catalog.json',
},
},
);
startBatches();
}
}, 32);
};
const reload = () => {
if (headerTimer) window.clearInterval(headerTimer);
if (batchTimer) window.clearTimeout(batchTimer);
if (footerTimer) window.clearInterval(footerTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
footerText.value = '';
headerText.value = '';
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'loading-indicator' } },
{ version: 'v0.9', deleteSurface: { surfaceId: 'progressive-demo' } },
);
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
startFlow();
}, 50);
};
onMounted(startFlow);
onUnmounted(() => {
if (headerTimer) window.clearInterval(headerTimer);
if (footerTimer) window.clearInterval(footerTimer);
if (batchTimer) window.clearTimeout(batchTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">每批 2 个商品,通过 updateComponents 渐进追加</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959">{{ headerText }}</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{ ProductCard, ProductContainer, LoadingIndicator }"
>
<XCard.Card id="loading-indicator" />
<XCard.Card id="progressive-demo" />
</XCard.Box>
<div
v-if="footerText"
style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #52c41a; margin-top: 10px"
>
{{ footerText }}
</div>
</div>
</template>
Streaming
AI food recommendations: intro text streams first, then progress and four restaurants are written into the dataModel. Completion turns streaming off and shows the final summary.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
CATALOGS,
LoadingProgress,
RESTAURANT_DATA,
RestaurantCard,
RestaurantList,
StreamContainer,
TextBlock,
type RestaurantItem,
} from './_shared';
registerCatalog(CATALOGS.streaming);
const INTRO_TEXT = `Hello! I'm your food recommendation assistant 🍽️
Based on your location and preferences, I'm selecting the best restaurants nearby for you...
Here are my recommendation criteria:
1. **Distance First**: Prioritizing restaurants within 15 minutes walking distance
2. **Quality Guaranteed**: Selecting only restaurants with ratings above 4.5
3. **Diverse Cuisines**: Covering Chinese, Japanese, Western, and more
Generating personalized recommendations for you...`;
const commands = ref<XAgentCommand_v0_9[]>([]);
const introText = ref('');
const sessionKey = ref(0);
let textTimer: number | undefined;
let progressTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createSurfaceCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'recommendation',
catalogId: 'local://restaurant_streaming_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'recommendation',
components: [
{ id: 'title', component: 'Text', text: 'AI Food Recommendations', variant: 'h1' },
{
id: 'progress',
component: 'LoadingProgress',
percent: { path: '/progress' },
status: { path: '/progressStatus' },
},
{
id: 'restaurant-list',
component: 'RestaurantList',
restaurants: { path: '/restaurants' },
loadingProgress: { path: '/progress' },
isStreaming: { path: '/isStreaming' },
},
{
id: 'root',
component: 'Container',
children: ['title', 'progress', 'restaurant-list'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'recommendation',
path: '/',
value: {
progress: 0,
progressStatus: 'active',
restaurants: [] as RestaurantItem[],
isStreaming: true,
},
},
},
];
const startProgress = () => {
let progress = 0;
let visibleCount = 0;
if (progressTimer) window.clearInterval(progressTimer);
progressTimer = window.setInterval(() => {
progress = Math.min(100, progress + 5);
const nextVisible = Math.ceil((progress / 100) * RESTAURANT_DATA.length);
if (nextVisible !== visibleCount) {
visibleCount = nextVisible;
append({
version: 'v0.9',
updateDataModel: {
surfaceId: 'recommendation',
path: '/restaurants',
value: RESTAURANT_DATA.slice(0, visibleCount),
},
});
}
append({
version: 'v0.9',
updateDataModel: {
surfaceId: 'recommendation',
path: '/progress',
value: progress,
},
});
if (progress >= 100) {
if (progressTimer) window.clearInterval(progressTimer);
append(
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'recommendation',
path: '/isStreaming',
value: false,
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'recommendation',
path: '/progressStatus',
value: 'success',
},
},
);
}
}, 85);
};
const startIntro = () => {
if (textTimer) window.clearInterval(textTimer);
introText.value = '';
let index = 0;
textTimer = window.setInterval(() => {
index = Math.min(index + 3, INTRO_TEXT.length);
introText.value = INTRO_TEXT.slice(0, index);
if (index >= INTRO_TEXT.length) {
if (textTimer) window.clearInterval(textTimer);
append(...createSurfaceCommands());
startProgress();
}
}, 24);
};
const reload = () => {
if (textTimer) window.clearInterval(textTimer);
if (progressTimer) window.clearInterval(progressTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
introText.value = '';
append({ version: 'v0.9', deleteSurface: { surfaceId: 'recommendation' } });
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
startIntro();
}, 50);
};
onMounted(startIntro);
onUnmounted(() => {
if (textTimer) window.clearInterval(textTimer);
if (progressTimer) window.clearInterval(progressTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Recommend Again
</button>
<span style="color: #8c8c8c; font-size: 13px">文本流式输出后,推荐卡片按进度逐步出现</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959">{{ introText }}</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{
Text: TextBlock,
LoadingProgress,
RestaurantCard,
RestaurantList,
Container: StreamContainer,
}"
>
<XCard.Card id="recommendation" />
</XCard.Box>
</div>
</template>
Nested interaction
File tree, file detail, and accordion panels share one Surface. Selecting a file writes /browser/selectedFile; details and the selected state update together, and panel content is read from /browser/panels.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
AccordionPanel,
CATALOGS,
FileDetailCard,
FileTree,
MainContainer,
PANEL_DATA,
TextBlock,
} from './_shared';
registerCatalog(CATALOGS.nested);
const HEADER_TEXT =
'Welcome to Project File Browser! Browse the project structure below. Click on files to view details, expand folders to explore nested contents.';
const commands = ref<XAgentCommand_v0_9[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createBrowserCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'browser',
catalogId: 'local://nested_interaction_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'browser',
components: [
{ id: 'title', component: 'Text', text: 'Project File Browser', variant: 'h1' },
{
id: 'file-tree',
component: 'FileTree',
selectedFile: { path: '/browser/selectedFile' },
action: {
event: {
name: 'select_file',
context: { selectedFile: { path: '/browser/selectedFile' } },
},
},
},
{ id: 'file-detail', component: 'FileDetailCard', file: { path: '/browser/selectedFile' } },
{ id: 'accordion-section', component: 'AccordionPanel', panels: { path: '/browser/panels' } },
{
id: 'root',
component: 'MainContainer',
children: ['title', 'file-tree', 'file-detail', 'accordion-section'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'browser',
path: '/browser',
value: { panels: PANEL_DATA },
},
},
];
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'select_file') return;
const selectedFile = (payload.context as Record<string, any>)?.selectedFile;
if (!selectedFile) return;
append({
version: 'v0.9',
updateDataModel: {
surfaceId: 'browser',
path: '/browser/selectedFile',
value: selectedFile,
},
});
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
append({ version: 'v0.9', deleteSurface: { surfaceId: 'browser' } });
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
append(...createBrowserCommands());
}, 50);
};
onMounted(() => append(...createBrowserCommands()));
onUnmounted(() => {
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">展开目录、选择文件,详情与手风琴面板共享 dataModel</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959; margin-bottom: 10px">
{{ HEADER_TEXT }}
</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{
Text: TextBlock,
FileTree,
FileDetailCard,
AccordionPanel,
MainContainer,
}"
:on-action="handleAction"
>
<XCard.Card id="browser" />
</XCard.Box>
</div>
</template>
Multi-card sync
A three-column product list, cart, and order summary share /cart. Add, quantity changes, removal, discounts, shipping, and checkout are all supported; checkout swaps in a success card.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
CartCard,
CATALOGS,
CheckoutSuccessCard,
ColWrapper,
MultiCardContainer,
OrderSummaryCard,
ProductListCard,
SHOP_PRODUCTS,
type CartItem,
type Product,
} from './_shared';
registerCatalog(CATALOGS.cart);
const HEADER_TEXT =
'Welcome to Smart Shopping Cart! 🛒\n\nBrowse products, add them to your cart, and see real-time price calculations. Experience the power of multi-surface collaboration!';
const commands = ref<XAgentCommand_v0_9[]>([]);
const cart = ref<CartItem[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createShopCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'shop',
catalogId: 'local://cart_demo_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'shop',
components: [
{
id: 'product-list-card',
component: 'ProductListCard',
products: { path: '/products' },
cart: { path: '/cart' },
action: {
event: { name: 'add_to_cart', context: { product: {} } },
},
},
{
id: 'cart-card',
component: 'CartCard',
cart: { path: '/cart' },
updateAction: {
event: { name: 'update_quantity', context: { productId: {}, quantity: {} } },
},
removeAction: {
event: { name: 'remove_from_cart', context: { productId: {} } },
},
},
{
id: 'order-summary-card',
component: 'OrderSummaryCard',
cart: { path: '/cart' },
checkoutAction: {
event: { name: 'checkout', context: { orderData: {} } },
},
},
{ id: 'col-products', component: 'ColWrapper', span: 8, children: ['product-list-card'] },
{ id: 'col-cart', component: 'ColWrapper', span: 8, children: ['cart-card'] },
{ id: 'col-summary', component: 'ColWrapper', span: 8, children: ['order-summary-card'] },
{
id: 'root',
component: 'MultiCardContainer',
children: ['col-products', 'col-cart', 'col-summary'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'shop',
path: '/',
value: { products: SHOP_PRODUCTS, cart: [] },
},
},
];
const createCheckoutSuccess = (total: number, itemCount: number): XAgentCommand_v0_9[] => {
const orderNumber = `ORD-${Date.now().toString(36).toUpperCase()}`;
return [
{
version: 'v0.9',
createSurface: {
surfaceId: 'checkout-success',
catalogId: 'local://cart_demo_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'checkout-success',
components: [
{
id: 'root',
component: 'CheckoutSuccessCard',
total,
itemCount,
orderNumber,
},
],
},
},
];
};
const syncCart = () => {
append({
version: 'v0.9',
updateDataModel: {
surfaceId: 'shop',
path: '/cart',
value: cart.value,
},
});
};
const handleAction = (payload: ActionPayload) => {
const context = (payload.context || {}) as Record<string, any>;
switch (payload.name) {
case 'add_to_cart': {
const product = context.product as Product | undefined;
if (!product) return;
const existing = cart.value.find((item) => item.product.id === product.id);
cart.value = existing
? cart.value.map((item) =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item,
)
: [...cart.value, { product, quantity: 1 }];
syncCart();
break;
}
case 'update_quantity': {
const { productId, quantity } = context;
if (!productId || !quantity) return;
cart.value = cart.value.map((item) =>
item.product.id === productId ? { ...item, quantity } : item,
);
syncCart();
break;
}
case 'remove_from_cart': {
const { productId } = context;
if (!productId) return;
cart.value = cart.value.filter((item) => item.product.id !== productId);
syncCart();
break;
}
case 'checkout': {
const total = Number(context.total) || 0;
const itemCount = Number(context.itemCount) || 0;
cart.value = [];
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'shop' } },
...createCheckoutSuccess(total, itemCount),
);
break;
}
case 'restart_demo': {
reload();
break;
}
}
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
cart.value = [];
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'shop' } },
{ version: 'v0.9', deleteSurface: { surfaceId: 'checkout-success' } },
);
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
append(...createShopCommands());
}, 50);
};
onMounted(() => append(...createShopCommands()));
onUnmounted(() => {
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">商品、购物车、订单摘要共享同一份 /cart 数据</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959; margin-bottom: 10px">
{{ HEADER_TEXT }}
</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{
ProductListCard,
CartCard,
OrderSummaryCard,
ColWrapper,
MultiCardContainer,
CheckoutSuccessCard,
}"
:on-action="handleAction"
>
<XCard.Card id="shop" />
<XCard.Card id="checkout-success" />
</XCard.Box>
</div>
</template>
Filter search
Ten products support keyword, multi-category, price-range, and minimum-rating filters. Filters are written to /filters; the result list refreshes from the same data after 300ms.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
CATALOGS,
FilterContainer,
FilterPanel,
FilterProductList,
type FilterState,
} from './_shared';
registerCatalog(CATALOGS.filter);
const HEADER_TEXT =
'Welcome to Product Search! 🔍\n\nFind the perfect product by filtering by category, price, and rating. Results will update in real-time as you adjust filters.';
const commands = ref<XAgentCommand_v0_9[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createFilterCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'filter',
catalogId: 'local://filter_search_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'filter',
components: [
{
id: 'filter-panel',
component: 'FilterPanel',
selectedCategories: { path: '/filter/filters/categories' },
priceRange: { path: '/filter/filters/priceRange' },
minRating: { path: '/filter/filters/minRating' },
searchKeyword: { path: '/filter/filters/searchKeyword' },
action: {
event: {
name: 'update_filters',
context: { filters: { path: '/filter/filters' } },
},
},
},
{
id: 'product-list',
component: 'ProductList',
filters: { path: '/filter/filters' },
},
{
id: 'root',
component: 'FilterContainer',
children: ['filter-panel', 'product-list'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'filter',
path: '/filter',
value: {
filters: {
categories: [],
priceRange: [0, 1500],
minRating: 0,
searchKeyword: '',
},
},
},
},
];
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'update_filters') return;
const filters = (payload.context as Record<string, any>)?.filters as
| FilterState
| undefined;
if (!filters) return;
append({
version: 'v0.9',
updateDataModel: {
surfaceId: 'filter',
path: '/filter/filters',
value: filters,
},
});
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
append({ version: 'v0.9', deleteSurface: { surfaceId: 'filter' } });
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
append(...createFilterCommands());
}, 50);
};
onMounted(() => append(...createFilterCommands()));
onUnmounted(() => {
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">搜索、分类、价格区间和评分共同写入 /filter/filters</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959; margin-bottom: 10px">
{{ HEADER_TEXT }}
</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{
FilterPanel,
ProductList: FilterProductList,
FilterContainer,
}"
:on-action="handleAction"
>
<XCard.Card id="filter" />
</XCard.Box>
</div>
</template>
Form validation
Multi-step registration: basic info → security → result. Fields use useXCardField and write through the injected onDataChange; final submission replaces the Surface with a success card.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import { CATALOGS, RegistrationForm, SuccessCard } from './_shared';
registerCatalog(CATALOGS.form);
const HEADER_TEXT =
'Welcome to register! 🎉\n\nPlease fill in your information to create an account. We will verify your information step by step.';
const commands = ref<XAgentCommand_v0_9[]>([]);
const headerText = ref('');
const notice = ref('');
const sessionKey = ref(0);
let textTimer: number | undefined;
let resultTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createRegistrationCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'registration',
catalogId: 'local://form_validation_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'registration',
components: [
{
id: 'root',
component: 'RegistrationForm',
step: 0,
status: { path: '/registration/status' },
errorMessage: { path: '/registration/errorMessage' },
action: {
event: {
name: 'submit_step',
context: { formData: { path: '/registration/formData' } },
},
},
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'registration',
path: '/registration',
value: { status: '', errorMessage: '' },
},
},
];
const createResultCommands = (formData: any): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'result',
catalogId: 'local://form_validation_catalog.json',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'result',
components: [
{
id: 'root',
component: 'SuccessCard',
username: formData?.username,
email: formData?.email,
accountType: formData?.accountType ?? 'personal',
},
],
},
},
];
const startHeader = () => {
if (textTimer) window.clearInterval(textTimer);
headerText.value = '';
let index = 0;
textTimer = window.setInterval(() => {
index = Math.min(index + 3, HEADER_TEXT.length);
headerText.value = HEADER_TEXT.slice(0, index);
if (index >= HEADER_TEXT.length) {
if (textTimer) window.clearInterval(textTimer);
append(...createRegistrationCommands());
}
}, 32);
};
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'submit_step') return;
const formData = (payload.context as Record<string, any>)?.formData;
if (!formData) return;
if (!formData.submit) {
notice.value = `Step ${formData.step} completed`;
return;
}
notice.value = 'Registration successful!';
append({ version: 'v0.9', deleteSurface: { surfaceId: 'registration' } });
if (resultTimer) window.clearTimeout(resultTimer);
resultTimer = window.setTimeout(() => {
append(...createResultCommands(formData.values));
}, 300);
};
const reload = () => {
if (textTimer) window.clearInterval(textTimer);
if (resultTimer) window.clearTimeout(resultTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
notice.value = '';
headerText.value = '';
append(
{ version: 'v0.9', deleteSurface: { surfaceId: 'registration' } },
{ version: 'v0.9', deleteSurface: { surfaceId: 'result' } },
);
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
startHeader();
}, 50);
};
onMounted(startHeader);
onUnmounted(() => {
if (textTimer) window.clearInterval(textTimer);
if (resultTimer) window.clearTimeout(resultTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #1677ff; color: #fff; cursor: pointer"
@click="reload"
>
Reload
</button>
<span style="color: #8c8c8c; font-size: 13px">逐步校验信息,最后生成注册结果卡片</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: #595959">{{ headerText }}</div>
<p v-if="notice" style="margin: 10px 0; color: #1677ff; font-size: 13px">{{ notice }}</p>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{ RegistrationForm, SuccessCard }"
:on-action="handleAction"
>
<XCard.Card id="registration" />
<XCard.Card id="result" />
</XCard.Box>
</div>
</template>
Action context resolve
暂无日志,请填写表单后提交
Typing writes into the dataModel. A submit button may send an empty context; Card resolves { path } entries in action.event.context. The log keeps the latest four callbacks so the resolved values are visible.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
registerCatalog,
XCard,
type ActionPayload,
type XAgentCommand_v0_9,
} from '@ant-design-x-vue/x-card';
import {
CATALOGS,
FormCard,
FormInput,
SubmitButton,
Title,
} from './_shared';
registerCatalog(CATALOGS.action);
const commands = ref<XAgentCommand_v0_9[]>([]);
const logs = ref<string[]>([]);
const resolvedTip = ref('');
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_9[]) => {
commands.value = [...commands.value, ...next];
};
const createFormCommands = (): XAgentCommand_v0_9[] => [
{
version: 'v0.9',
createSurface: {
surfaceId: 'form',
catalogId: 'local://action_resolve_catalog',
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: 'form',
components: [
{ id: 'title', component: 'Title', text: '用户注册表单', variant: 'h2' },
{
id: 'username-input',
component: 'FormInput',
label: '用户名',
action: {
event: {
name: 'agent:send_context_text',
context: {
username: { path: '/form/username', label: '用户名' },
},
},
},
},
{
id: 'email-input',
component: 'FormInput',
label: '邮箱',
action: {
event: {
name: 'agent:send_context_text',
context: {
email: { path: '/form/email', label: '邮箱' },
},
},
},
},
{
id: 'submit-btn',
component: 'SubmitButton',
text: '提交表单',
action: {
event: {
name: 'agent:send_context_text',
context: {
username: { path: '/form/username', label: '用户名' },
email: { path: '/form/email', label: '邮箱' },
},
},
},
},
{
id: 'root',
component: 'FormCard',
children: ['title', 'username-input', 'email-input', 'submit-btn'],
},
],
},
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: 'form',
path: '/form',
value: { username: '', email: '' },
},
},
];
const handleAction = (payload: ActionPayload) => {
const log = `[${payload.name}]\n${JSON.stringify(payload.context, null, 2)}`;
logs.value = [log, ...logs.value].slice(0, 4);
resolvedTip.value = Object.values(payload.context || {}).some(
(value) => value && typeof value === 'object' && 'value' in value,
)
? 'Path 引用已自动解析为实际值'
: '';
};
const reset = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
logs.value = [];
resolvedTip.value = '';
append({ version: 'v0.9', deleteSurface: { surfaceId: 'form' } });
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
append(...createFormCommands());
}, 50);
};
onMounted(() => append(...createFormCommands()));
onUnmounted(() => {
if (reloadTimer) window.clearTimeout(reloadTimer);
});
</script>
<template>
<div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px">
<button
type="button"
style="padding: 7px 13px; border: 0; border-radius: 7px; background: #fff; border: 1px solid #d9d9d9; cursor: pointer"
@click="reset"
>
重置
</button>
<span style="color: #8c8c8c; font-size: 13px">填写表单后点击提交,观察 onAction 回调中 context 的变化</span>
</div>
<XCard.Box
:key="sessionKey"
:commands="commands"
:components="{ FormCard, Title, FormInput, SubmitButton }"
:on-action="handleAction"
>
<XCard.Card id="form" />
</XCard.Box>
<p v-if="resolvedTip" style="margin: 12px 0 0; color: #52c41a; font-size: 13px">
{{ resolvedTip }}
</p>
<div style="margin-top: 16px">
<strong style="font-size: 13px">onAction 回调日志(path 引用已解析):</strong>
<div
v-if="logs.length"
style="margin-top: 8px; max-height: 220px; overflow: auto; border: 1px solid #f0f0f0; border-radius: 8px"
>
<pre
v-for="(log, index) in logs"
:key="index"
style="margin: 0; padding: 10px 12px; border-bottom: 1px solid #f5f5f5; font-size: 12px; white-space: pre-wrap"
>{{ log }}</pre>
</div>
<p v-else style="color: #8c8c8c; font-size: 13px">暂无日志,请填写表单后提交</p>
</div>
</div>
</template>
API
Bind :on-action in templates — it is a prop, not an emit.
XCard.Box
Box is the container that owns Card instances and dispatches commands. Place XCard.Card in the default slot.
| Prop | Description | Type | Default |
|---|---|---|---|
| commands | A2UI command objects | (A2UICommand_v0_9 | XAgentCommand_v0_8)[] | [] |
| components | Implementation map; names must start with an uppercase letter | Record<string, Component> | - |
| onAction | Fired when a card triggers an action | (payload: ActionPayload) => void | - |
XCard.Card
Renders a single Surface.
| Prop | Description | Type | Default |
|---|---|---|---|
| id | Surface ID, matching surfaceId in commands | string | - |
Custom components receive:
onAction(name, context): Card then runs versionedextractDataUpdatesand resolves path refs in the action contextonDataChange(path, value): writes the current surface dataModel directly
ActionPayload
| Property | Description | Type | Default |
|---|---|---|---|
| name | Event name | string | - |
| surfaceId | The surfaceId that triggered the action | string | - |
| context | Context from the component, with path references resolved | Record<string, any> | - |
Action context path resolution
When a component triggers an action, X Card resolves path references in the context to real values.
Path reference format
Use { path: string } to bind dataModel values in the action config:
{
"id": "submit-btn",
"component": "Button",
"action": {
"event": {
"name": "agent:send_context_text",
"context": {
"username": { "path": "/form/username", "label": "Username" },
"email": { "path": "/form/email", "label": "Email" }
}
}
}
}Resolution result
{
"username": { "value": "John", "label": "Username" },
"email": { "value": "john@example.com", "label": "Email" }
}Note:
- Only values that are themselves
{ path: "xxx" }are converted - Actual values passed by the component (for example
{ value: "actual_value" }) are not rewritten - Other properties (such as
label) are kept; onlypathis replaced withvalue
A2UICommand_v0_9
XAgentCommand_v0_9 is an alias of A2UICommand_v0_9.
CreateSurfaceCommand
Create a new Surface.
| Property | Description | Type | Default |
|---|---|---|---|
| version | Version number | 'v0.9' | - |
| createSurface.surfaceId | Surface ID | string | - |
| createSurface.catalogId | Component catalog URL or local identifier | string | - |
UpdateComponentsCommand
Update components on a Surface.
| Property | Description | Type | Default |
|---|---|---|---|
| version | Version number | 'v0.9' | - |
| updateComponents.surfaceId | Surface ID | string | - |
| updateComponents.components | Component list | BaseComponent_v0_9[] | - |
BaseComponent_v0_9
interface BaseComponent_v0_9 {
id: string
component: string
child?: string
children?: string[]
[key: string]: any | PathValue
}UpdateDataModelCommand
Update the data model.
| Property | Description | Type | Default |
|---|---|---|---|
| version | Version number | 'v0.9' | - |
| updateDataModel.surfaceId | Surface ID | string | - |
| updateDataModel.path | Data path | string | - |
| updateDataModel.value | Data value | any | - |
DeleteSurfaceCommand
Delete a Surface.
| Property | Description | Type | Default |
|---|---|---|---|
| version | Version number | 'v0.9' | - |
| deleteSurface.surfaceId | Surface ID | string | - |
PathValue
interface PathValue {
path: string
}Catalog
interface Catalog {
$schema?: string
$id?: string
title?: string
description?: string
catalogId?: string
components?: Record<string, CatalogComponent>
functions?: Record<string, any>
$defs?: Record<string, any>
}CatalogComponent
interface CatalogComponent {
type: 'object'
allOf?: any[]
properties?: Record<string, any>
required?: string[]
[key: string]: any
}Catalog helpers
registerCatalog(catalog: Catalog): void
loadCatalog(catalogId: string): Promise<Catalog>
validateComponent(catalog: Catalog, componentName: string, componentProps: Record<string, any>): boolean
clearCatalogCache(): voiduseXCardField
A Vue-only composable for field write-back and validation (used by the form-validation demo).
const field = useXCardField({
path: '/form/email',
onDataChange, // injected by Card
rules: [{ required: true }, { pattern: /.+@.+/, message: 'Invalid email' }],
})
field.setValue(next)
field.validate()
field.error.valuevalidateFieldValue(value, rules) is also exported.