A2UI v0.8
中文:A2UI v0.8
v0.8 is the compatibility protocol: commands have no version field, and the first update implicitly creates a Surface. Prefer A2UI v0.9 for new work.
The eight demos below mirror the React @ant-design/x-card 2.10 v0.8 showcase set: coffee booking, progressive loading, streaming recommendations, file browser, multi-card cart, filter search, multi-step registration, and action-context resolution. Each scenario uses native v0.8 commands (surfaceUpdate / dataModelUpdate / beginRendering).
Examples
Basic usage
Coffee booking: choose a date and coffee, then confirm the order. Commands run in surfaceUpdate → dataModelUpdate → beginRendering order; on success the booking Surface is deleted and a result card is rendered.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
ActionButton,
BookForm,
CoffeeList,
CoffeeResultCard,
DateTimeInput,
TextBlock,
} from './_shared';
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_8[]>([]);
const headerText = ref('');
const confirmation = ref('');
const sessionKey = ref(0);
let textTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const createBookingCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'booking',
components: [
{
id: 'title',
component: {
Text: {
text: { literalString: 'Coffee Shop Order' },
variant: { literalString: 'h1' },
},
},
},
{
id: 'datetime',
component: {
DateTimeInput: {
status: { path: '/status' },
action: {
name: 'select_date',
context: [{ key: 'time', value: { path: '/res/time' } }],
},
},
},
},
{
id: 'coffee_list',
component: {
CoffeeList: {
description: { literalString: 'Please select your favorite coffee' },
status: { path: '/status' },
action: {
name: 'select_coffee',
context: [{ key: 'coffee', value: { path: '/res/coffee' } }],
},
},
},
},
{
id: 'status-text',
component: {
Text: {
status: { path: '/status' },
variant: { literalString: 'success' },
},
},
},
{
id: 'submit-text',
component: { Text: { text: { literalString: 'Confirm Order' } } },
},
{
id: 'submit-btn',
component: {
ActionButton: {
child: 'submit-text',
variant: { literalString: 'primary' },
status: { path: '/status' },
res: { path: '/res' },
action: {
name: 'confirm_booking',
context: [
{ key: 'status', value: { path: '/status' } },
{ key: 'res', value: { path: '/res' } },
],
},
},
},
},
{
id: 'root',
component: {
BookForm: {
children: {
explicitList: ['title', 'datetime', 'coffee_list', 'status-text', 'submit-btn'],
},
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'booking',
contents: [
{
key: 'res',
valueMap: [{ key: 'time', valueString: new Date().toISOString() }],
},
],
},
},
{ beginRendering: { surfaceId: 'booking', root: 'root' } },
];
const createResultCommands = (res: any): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'result',
components: [
{
id: 'result-card',
component: {
CoffeeResultCard: {
name: { literalString: res?.coffee?.name ?? '' },
description: { literalString: res?.coffee?.description ?? '' },
price: { literalString: String(res?.coffee?.price ?? '') },
tag: { literalString: res?.coffee?.tag ?? '' },
date: { literalString: res?.time ?? '' },
},
},
},
{
id: 'root',
component: { BookForm: { child: 'result-card' } },
},
],
},
},
{ beginRendering: { surfaceId: 'result', root: 'root' } },
];
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(
{ deleteSurface: { surfaceId: 'booking' } },
...createResultCommands(res),
);
};
const reload = () => {
if (textTimer) window.clearInterval(textTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
confirmation.value = '';
headerText.value = '';
append(
{ deleteSurface: { surfaceId: 'booking' } },
{ 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">v0.8 surfaceUpdate + dataModelUpdate + beginRendering</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
Two coordinated Surfaces: loading-indicator shows progress while the product Surface appends two products per batch through repeated surfaceUpdate calls.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
ALL_PROGRESS_PRODUCTS,
LoadingIndicator,
ProductCard,
ProductContainer,
PROGRESS_PRODUCT_BATCHES,
type Product,
} from './_shared';
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_8[]>([]);
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_8[]) => {
commands.value = [...commands.value, ...next];
};
const updateLoading = (progress: number, loading: boolean): XAgentCommand_v0_8 => ({
surfaceUpdate: {
surfaceId: 'loading-indicator',
components: [
{
id: 'root',
component: {
LoadingIndicator: {
progress: { literalString: String(progress) },
total: { literalString: String(ALL_PROGRESS_PRODUCTS.length) },
loading: { literalString: String(loading) },
},
},
},
],
},
});
const updateProducts = (products: Product[]): XAgentCommand_v0_8 => ({
surfaceUpdate: {
surfaceId: 'progressive-demo',
components: [
...products.map((product, index) => ({
id: `product-${product.id}`,
component: {
ProductCard: {
name: { literalString: product.name },
price: { literalString: String(product.price) },
category: { literalString: product.category },
stock: { literalString: String(product.stock) },
tag: product.tag ? { literalString: product.tag } : undefined,
index: { literalString: String(index) },
},
},
})),
{
id: 'root',
component: {
ProductContainer: {
children: { explicitList: products.map((product) => `product-${product.id}`) },
},
},
},
],
},
});
const createInitialCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'loading-indicator',
components: [
{
id: 'root',
component: {
LoadingIndicator: {
progress: { literalString: '0' },
total: { literalString: String(ALL_PROGRESS_PRODUCTS.length) },
loading: { literalString: 'true' },
},
},
},
],
},
},
{ beginRendering: { surfaceId: 'loading-indicator', root: 'root' } },
updateProducts([]),
{ beginRendering: { surfaceId: 'progressive-demo', root: 'root' } },
];
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 startHeader = () => {
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(...createInitialCommands());
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(
{ deleteSurface: { surfaceId: 'loading-indicator' } },
{ deleteSurface: { surfaceId: 'progressive-demo' } },
);
reloadTimer = window.setTimeout(() => {
commands.value = [];
sessionKey.value += 1;
startHeader();
}, 50);
};
onMounted(startHeader);
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">v0.8 surfaceUpdate 渐进更新两个 Surface</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
Food recommendations. Intro text streams first, then dataModelUpdate writes progress and restaurant lists incrementally so cards appear as progress advances.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
LoadingProgress,
RESTAURANT_DATA,
RestaurantCard,
RestaurantList,
StreamContainer,
TextBlock,
type RestaurantItem,
} from './_shared';
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_8[]>([]);
const introText = ref('');
const sessionKey = ref(0);
let textTimer: number | undefined;
let progressTimer: number | undefined;
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const progressUpdate = (percent: number): XAgentCommand_v0_8 => ({
dataModelUpdate: {
surfaceId: 'recommendation',
contents: [{ key: 'progress', valueString: String(percent) }],
},
});
const restaurantsUpdate = (restaurants: RestaurantItem[]): XAgentCommand_v0_8 => ({
dataModelUpdate: {
surfaceId: 'recommendation',
contents: [{ key: 'restaurants', valueString: JSON.stringify(restaurants) }],
},
});
const createInitialCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'recommendation',
components: [
{
id: 'title',
component: {
Text: {
text: { literalString: 'AI Food Recommendations' },
variant: { literalString: 'h1' },
},
},
},
{
id: 'progress',
component: {
LoadingProgress: {
percent: { path: '/progress' },
status: { literalString: 'active' },
},
},
},
{
id: 'restaurant-list',
component: {
RestaurantList: {
restaurants: { path: '/restaurants' },
loadingProgress: { path: '/progress' },
isStreaming: { path: '/isStreaming' },
},
},
},
{
id: 'root',
component: {
Container: {
children: { explicitList: ['title', 'progress', 'restaurant-list'] },
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'recommendation',
contents: [
{ key: 'progress', valueString: '0' },
{ key: 'isStreaming', valueString: 'true' },
{ key: 'restaurants', valueString: '[]' },
],
},
},
{ beginRendering: { surfaceId: 'recommendation', root: 'root' } },
];
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(restaurantsUpdate(RESTAURANT_DATA.slice(0, visibleCount)));
}
append(progressUpdate(progress));
if (progress >= 100) {
if (progressTimer) window.clearInterval(progressTimer);
append({
dataModelUpdate: {
surfaceId: 'recommendation',
contents: [{ key: 'isStreaming', valueString: 'false' }],
},
});
}
}, 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(...createInitialCommands());
startProgress();
}
}, 24);
};
const reload = () => {
if (textTimer) window.clearInterval(textTimer);
if (progressTimer) window.clearInterval(progressTimer);
if (reloadTimer) window.clearTimeout(reloadTimer);
introText.value = '';
append({ 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"
>
Refresh Recommendations
</button>
<span style="color: #8c8c8c; font-size: 13px">v0.8 dataModelUpdate 驱动进度与推荐列表</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. Selecting a file writes selectedFile into the dataModel; the detail card follows the same path, while panel data is stored as JSON strings in valueMap.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
AccordionPanel,
FileDetailCard,
FileTree,
MainContainer,
PANEL_DATA,
TextBlock,
} from './_shared';
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_8[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const createBrowserCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'browser',
components: [
{
id: 'title',
component: {
Text: {
text: { literalString: 'Project File Browser' },
variant: { literalString: 'h1' },
},
},
},
{
id: 'file-tree',
component: {
FileTree: {
action: {
name: 'select_file',
context: [{ key: 'selectedFile', value: { path: '/browser/selectedFile' } }],
},
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: {
explicitList: ['title', 'file-tree', 'file-detail', 'accordion-section'],
},
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'browser',
contents: [
{
key: 'panels',
valueMap: PANEL_DATA.map((panel) => ({
key: panel.id,
valueString: JSON.stringify(panel),
})),
},
],
},
},
{ beginRendering: { surfaceId: 'browser', root: 'root' } },
];
const handleAction = (payload: ActionPayload) => {
if (payload.name !== 'select_file') return;
const selectedFile = (payload.context as Record<string, any>)?.selectedFile;
if (!selectedFile) return;
append({
dataModelUpdate: {
surfaceId: 'browser',
contents: [
{
key: 'selectedFile',
valueMap: Object.entries(selectedFile).map(([key, value]) => ({
key,
valueString: String(value),
})),
},
],
},
});
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
append({ 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">v0.8 文件树、详情卡与手风琴面板嵌套交互</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
Product list, cart, and order summary share /cart. Add, quantity changes, removal, and checkout all update the dataModel, then switch to a success Surface.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
CartCard,
CheckoutSuccessCard,
ColWrapper,
MultiCardContainer,
OrderSummaryCard,
ProductListCard,
SHOP_PRODUCTS,
type CartItem,
type Product,
} from './_shared';
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_8[]>([]);
const cart = ref<CartItem[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const cartUpdate = (): XAgentCommand_v0_8 => ({
dataModelUpdate: {
surfaceId: 'shop',
contents: [{ key: 'cart', valueString: JSON.stringify(cart.value) }],
},
});
const createShopCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'shop',
components: [
{
id: 'product-list-card',
component: {
ProductListCard: {
products: { path: '/products' },
cart: { path: '/cart' },
action: {
name: 'add_to_cart',
context: [{ key: 'product', value: { path: '/addedProduct' } }],
},
},
},
},
{
id: 'cart-card',
component: {
CartCard: {
cart: { path: '/cart' },
updateAction: {
name: 'update_quantity',
context: [
{ key: 'productId', value: { path: '/updateProductId' } },
{ key: 'quantity', value: { path: '/updateQuantity' } },
],
},
removeAction: {
name: 'remove_from_cart',
context: [{ key: 'productId', value: { path: '/removeProductId' } }],
},
},
},
},
{
id: 'order-summary-card',
component: {
OrderSummaryCard: {
cart: { path: '/cart' },
checkoutAction: {
name: 'checkout',
context: [{ key: 'orderData', value: { path: '/orderData' } }],
},
},
},
},
{
id: 'col-products',
component: {
ColWrapper: { span: { literalString: '8' }, child: 'product-list-card' },
},
},
{
id: 'col-cart',
component: {
ColWrapper: { span: { literalString: '8' }, child: 'cart-card' },
},
},
{
id: 'col-summary',
component: {
ColWrapper: { span: { literalString: '8' }, child: 'order-summary-card' },
},
},
{
id: 'root',
component: {
MultiCardContainer: {
children: {
explicitList: ['col-products', 'col-cart', 'col-summary'],
},
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'shop',
contents: [
{ key: 'products', valueString: JSON.stringify(SHOP_PRODUCTS) },
{ key: 'cart', valueString: '[]' },
],
},
},
{ beginRendering: { surfaceId: 'shop', root: 'root' } },
];
const createCheckoutSuccess = (total: number, itemCount: number): XAgentCommand_v0_8[] => {
const orderNumber = `ORD-${Date.now().toString(36).toUpperCase()}`;
return [
{
surfaceUpdate: {
surfaceId: 'checkout-success',
components: [
{
id: 'success-card',
component: {
CheckoutSuccessCard: {
total: { literalString: String(total) },
itemCount: { literalString: String(itemCount) },
orderNumber: { literalString: orderNumber },
},
},
},
],
},
},
{ beginRendering: { surfaceId: 'checkout-success', root: 'success-card' } },
];
};
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 }];
append(cartUpdate());
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,
);
append(cartUpdate());
break;
}
case 'remove_from_cart': {
const { productId } = context;
if (!productId) return;
cart.value = cart.value.filter((item) => item.product.id !== productId);
append(cartUpdate());
break;
}
case 'checkout': {
const total = Number(context.total) || 0;
const itemCount = Number(context.itemCount) || 0;
cart.value = [];
append(
{ deleteSurface: { surfaceId: 'shop' } },
...createCheckoutSuccess(total, itemCount),
);
break;
}
case 'restart_demo': {
reload();
break;
}
}
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
cart.value = [];
append(
{ deleteSurface: { surfaceId: 'shop' } },
{ 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">v0.8 JSON 字符串 + dataModelUpdate 同步三张卡片</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
Keyword, categories, price range, and minimum rating are written to /filters. The panel and result list read the same data and refresh after a short delay.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import {
ColWrapper,
FilterContainer,
FilterPanel,
FilterProductList,
type FilterState,
} from './_shared';
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_8[]>([]);
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const createFilterCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'filter',
components: [
{
id: 'filter-panel',
component: {
FilterPanel: {
selectedCategories: { path: '/filters/categories' },
priceRange: { path: '/filters/priceRange' },
minRating: { path: '/filters/minRating' },
searchKeyword: { path: '/filters/searchKeyword' },
action: {
name: 'update_filters',
context: [{ key: 'filters', value: { path: '/filters' } }],
},
},
},
},
{
id: 'product-list',
component: {
ProductList: { filters: { path: '/filters' } },
},
},
{
id: 'col-filter',
component: {
ColWrapper: { span: { literalString: '8' }, child: 'filter-panel' },
},
},
{
id: 'col-products',
component: {
ColWrapper: { span: { literalString: '16' }, child: 'product-list' },
},
},
{
id: 'root',
component: {
FilterContainer: {
children: { explicitList: ['col-filter', 'col-products'] },
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'filter',
contents: [
{
key: 'filters',
valueMap: [
{ key: 'categories', valueString: '[]' },
{ key: 'priceRange', valueString: '[0,1500]' },
{ key: 'minRating', valueString: '0' },
{ key: 'searchKeyword', valueString: '' },
],
},
],
},
},
{ beginRendering: { surfaceId: 'filter', root: 'root' } },
];
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({
dataModelUpdate: {
surfaceId: 'filter',
contents: [
{
key: 'filters',
valueMap: [
{ key: 'categories', valueString: JSON.stringify(filters.categories || []) },
{ key: 'priceRange', valueString: JSON.stringify(filters.priceRange || [0, 1500]) },
{ key: 'minRating', valueString: String(filters.minRating || 0) },
{ key: 'searchKeyword', valueString: filters.searchKeyword || '' },
],
},
],
},
});
};
const reload = () => {
if (reloadTimer) window.clearTimeout(reloadTimer);
append({ 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">v0.8 将 filters 以 valueMap JSON 字符串写回</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,
ColWrapper,
}"
:on-action="handleAction"
>
<XCard.Card id="filter" />
</XCard.Box>
</div>
</template>
Form validation
Multi-step registration: basic info → security → result. Fields validate locally and write formData through the action context; the registration Surface is replaced by the result card.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import { RegistrationForm, SuccessCard } from './_shared';
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_8[]>([]);
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_8[]) => {
commands.value = [...commands.value, ...next];
};
const createRegistrationCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'registration',
components: [
{
id: 'registration-form',
component: {
RegistrationForm: {
step: { literalString: '0' },
status: { path: '/status' },
errorMessage: { path: '/errorMessage' },
action: {
name: 'submit_step',
context: [{ key: 'formData', value: { path: '/formData' } }],
},
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'registration',
contents: [
{ key: 'status', valueString: '' },
{ key: 'errorMessage', valueString: '' },
],
},
},
{ beginRendering: { surfaceId: 'registration', root: 'registration-form' } },
];
const createResultCommands = (formData: any): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'result',
components: [
{
id: 'success-card',
component: {
SuccessCard: {
username: { literalString: formData?.username ?? '' },
email: { literalString: formData?.email ?? '' },
accountType: { literalString: formData?.accountType ?? 'personal' },
},
},
},
],
},
},
{ beginRendering: { surfaceId: 'result', root: 'success-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(...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({ 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(
{ deleteSurface: { surfaceId: 'registration' } },
{ 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">v0.8 分步校验 + 结果卡片</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
暂无日志,请填写表单后提交
v0.8 action.context is { key, value: { path } }[]. Input writes the dataModel; an empty-context submit is resolved by Card.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
XCard,
type ActionPayload,
type XAgentCommand_v0_8,
} from '@ant-design-x-vue/x-card';
import { FormCard, FormInput, SubmitButton, Title } from './_shared';
const commands = ref<XAgentCommand_v0_8[]>([]);
const logs = ref<string[]>([]);
const resolvedTip = ref('');
const sessionKey = ref(0);
let reloadTimer: number | undefined;
const append = (...next: XAgentCommand_v0_8[]) => {
commands.value = [...commands.value, ...next];
};
const createFormCommands = (): XAgentCommand_v0_8[] => [
{
surfaceUpdate: {
surfaceId: 'form',
components: [
{
id: 'title',
component: {
Title: {
text: { literalString: '用户注册表单' },
variant: { literalString: 'h2' },
},
},
},
{
id: 'username-input',
component: {
FormInput: {
label: { literalString: '用户名' },
action: {
name: 'agent:send_context_text',
context: [{ key: 'username', value: { path: '/form/username' } }],
},
},
},
},
{
id: 'email-input',
component: {
FormInput: {
label: { literalString: '邮箱' },
action: {
name: 'agent:send_context_text',
context: [{ key: 'email', value: { path: '/form/email' } }],
},
},
},
},
{
id: 'submit-btn',
component: {
SubmitButton: {
text: { literalString: '提交表单' },
action: {
name: 'agent:send_context_text',
context: [
{ key: 'username', value: { path: '/form/username' } },
{ key: 'email', value: { path: '/form/email' } },
],
},
},
},
},
{
id: 'root',
component: {
FormCard: {
children: {
explicitList: ['title', 'username-input', 'email-input', 'submit-btn'],
},
},
},
},
],
},
},
{
dataModelUpdate: {
surfaceId: 'form',
contents: [
{
key: 'form',
valueMap: [
{ key: 'username', valueString: '' },
{ key: 'email', valueString: '' },
],
},
],
},
},
{ beginRendering: { surfaceId: 'form', root: 'root' } },
];
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({ 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">
v0.8 数组格式 action.context,由 Card 解析 { path } 引用
</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
XCard.Box / XCard.Card are shared across protocol versions. The tables below document the v0.8 command shapes. 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 resolves path references according to the protocol versiononDataChange(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 (v0.8)
v0.8 action configs commonly use an array to describe path bindings:
{
"id": "submit-btn",
"component": {
"Button": {
"action": {
"name": "agent:send_context_text",
"context": [
{ "key": "username", "value": { "path": "/form/username" } },
{ "key": "email", "value": { "path": "/form/email" } }
]
}
}
}
}Resolution result
After the user clicks the button, onAction receives:
{
"username": { "value": "John" },
"email": { "value": "john@example.com" }
}Note:
- Only values that are themselves
{ path: "xxx" }are converted - Actual values passed by the component are not rewritten
XAgentCommand_v0_8
XAgentCommand_v0_8 is an alias of A2UICommand_v0_8 and supports the following commands.
SurfaceUpdateCommand
Update components on a Surface.
| Property | Description | Type | Default |
|---|---|---|---|
| surfaceUpdate.surfaceId | Surface ID | string | - |
| surfaceUpdate.components | Component list | ComponentWrapper_v0_8[] | - |
ComponentWrapper_v0_8
interface ComponentWrapper_v0_8 {
id: string
component: {
[componentType: string]: {
child?: string
children?: string[] | ExplicitList
[key: string]: any | PathValue | LiteralStringValue
}
}
}ExplicitList
interface ExplicitList {
explicitList: string[]
}DataModelUpdateCommand
Update the data model (v0.8 format).
| Property | Description | Type | Default |
|---|---|---|---|
| dataModelUpdate.surfaceId | Surface ID | string | - |
| dataModelUpdate.contents | Data content list | Array<{ key: string; valueString?: string; valueMap?: Array<{ key: string; valueString: string }> }> | - |
BeginRenderingCommand
Begin rendering.
| Property | Description | Type | Default |
|---|---|---|---|
| beginRendering.surfaceId | Surface ID | string | - |
| beginRendering.root | Root component ID | string | - |
DeleteSurfaceCommand
Delete a Surface.
| Property | Description | Type | Default |
|---|---|---|---|
| deleteSurface.surfaceId | Surface ID | string | - |
PathValue
interface PathValue {
path: string
}LiteralStringValue
Literal string value (v0.8 specific).
interface LiteralStringValue {
literalString: string
}