prev/next A2UI surface and better dismissal

This commit is contained in:
Georg Bauer
2026-07-27 11:52:15 +02:00
parent c9c2d8efd5
commit 4a309091b6
14 changed files with 466 additions and 61 deletions

View File

@@ -71,7 +71,7 @@ const FUNCTIONS: &[&str] = &[
pub(crate) const SYSTEM_PROMPT: &str = r#"A2UI local-chat rendering is enabled. Use the newest A2UI v1.0 protocol. You may present interactive native UI by emitting newline-delimited messages inside a fenced `a2ui` block. Keep ordinary prose outside the block. Use catalogId `https://ds4server.local/a2ui/v1_0/catalog.json`.
Every line must be one JSON object with `version":"v1.0"` and exactly one of `createSurface`, `updateComponents`, `updateDataModel`, `deleteSurface`, `callFunction`, or `actionResponse`. Create a surface before updating it. Components are a flat adjacency list and the root component has id `root`. Compose complete UIs by combining basic components through container child ids; include every referenced child, tab child, and list template component. Reuse a surfaceId to update it incrementally; never recreate an existing surface. `createSurface` may include initial `components`, `dataModel`, and `surfaceProperties`. For `actionResponse`, put `actionId` beside `version` and put only `value` or `error` inside `actionResponse`.
Every line must be one JSON object with `version":"v1.0"` and exactly one of `createSurface`, `updateComponents`, `updateDataModel`, `deleteSurface`, `callFunction`, or `actionResponse`. Create a surface before updating it. Treat the current client metadata as authoritative: if its surfaces object is empty, prior surfaces in chat history were dismissed and you must create a new surface instead of updating them. Components are a flat adjacency list and the root component has id `root`. Compose complete UIs by combining basic components through container child ids; include every referenced child, tab child, and list template component. Reuse the active surfaceId to update it incrementally; never recreate an existing surface. `createSurface` may include initial `components`, `dataModel`, and `surfaceProperties`. For `actionResponse`, put `actionId` beside `version` and put only `value` or `error` inside `actionResponse`.
Catalog components:
- Basic: Text(text Markdown,variant), Image(url,description,fit,variant including avatar), Icon(name), Video(url,posterUrl), AudioPlayer(url,description), Divider(axis), Row/Column(children,justify start|center|end|spaceBetween|spaceAround|spaceEvenly|stretch,align start|center|end|stretch), List(children,direction,align), Card(child), Modal(trigger,content), Tabs(tabs[{title,child}]), Button(child,variant,action:{event:{name,context,wantResponse}}), TextField(label,value:{path},variant shortText|longText|number|obscured,placeholder), CheckBox(label,value:{path}), Slider(value:{path},min,max), DateTimeInput(label,value:{path},enableDate,enableTime,min,max), ChoicePicker(label,options[{label,value}],value:{path},variant multipleSelection|mutuallyExclusive,displayStyle checkbox|chips,filterable). Put numeric weight on direct Row/Column children to distribute available space.
@@ -98,6 +98,7 @@ pub(crate) struct Surface {
#[derive(Clone, Default)]
pub(crate) struct Store {
surfaces: BTreeMap<String, Surface>,
surface_order: Vec<String>,
pending_actions: BTreeMap<String, (String, Option<String>)>,
next_action_id: u64,
}
@@ -115,6 +116,25 @@ pub(crate) struct Applied {
pub(crate) open_url: Option<String>,
}
pub(crate) fn replay_epochs<'a>(
records: impl IntoIterator<Item = (i32, i32, bool, &'a str)>,
) -> (Vec<Store>, Store, Vec<String>) {
let mut history = Vec::new();
let mut active = Store::default();
let mut errors = Vec::new();
for (record_id, message_id, dismissed, raw) in records {
if dismissed {
if active.active_surface().is_some() {
history.push(active.clone());
}
active.clear();
} else if let Err(error) = active.apply_raw(raw, message_id) {
errors.push(format!("A2UI message {record_id}: {error}"));
}
}
(history, active, errors)
}
pub(crate) fn validate_surface_composition(surface: &Surface) -> Result<BTreeSet<String>, String> {
fn visit(
surface: &Surface,
@@ -188,16 +208,11 @@ pub(crate) fn validate_surface_composition(surface: &Surface) -> Result<BTreeSet
impl Store {
pub(crate) fn clear(&mut self) {
self.surfaces.clear();
self.surface_order.clear();
self.pending_actions.clear();
self.next_action_id = 0;
}
pub(crate) fn surfaces_for_message(&self, message_id: i32) -> impl Iterator<Item = &Surface> {
self.surfaces
.values()
.filter(move |surface| surface.owner_message_id == message_id)
}
pub(crate) fn surfaces(&self) -> impl Iterator<Item = &Surface> {
self.surfaces.values()
}
@@ -206,8 +221,14 @@ impl Store {
self.surfaces.get(id)
}
pub(crate) fn active_surface(&self) -> Option<&Surface> {
self.surface_order
.last()
.and_then(|id| self.surfaces.get(id))
}
pub(crate) fn image_urls(&self) -> impl Iterator<Item = String> + '_ {
self.surfaces.values().flat_map(|surface| {
self.active_surface().into_iter().flat_map(|surface| {
surface.components.values().filter_map(|component| {
let component = component.as_object()?;
let field = match component.get("component").and_then(Value::as_str) {
@@ -342,6 +363,7 @@ impl Store {
owner_message_id,
},
);
self.surface_order.push(id.to_owned());
let surface = self.surfaces.get_mut(id).unwrap();
if let Some(data) = payload.get("dataModel") {
if !data.is_object() {
@@ -419,6 +441,7 @@ impl Store {
if self.surfaces.remove(id).is_none() {
return Err(format!("surface `{id}` has not been created"));
}
self.surface_order.retain(|surface_id| surface_id != id);
}
"callFunction" => {
let call_id = required_string(envelope, "functionCallId")?;
@@ -595,8 +618,8 @@ impl Store {
pub(crate) fn client_metadata(&self) -> Value {
let surfaces = self
.surfaces
.values()
.active_surface()
.into_iter()
.filter(|surface| surface.send_data_model)
.map(|surface| (surface.id.clone(), surface.data.clone()))
.collect::<Map<_, _>>();
@@ -608,12 +631,10 @@ impl Store {
}
}
});
if !surfaces.is_empty() {
metadata["a2uiClientDataModel"] = json!({
"version": VERSION,
"surfaces": surfaces,
});
}
metadata["a2uiClientDataModel"] = json!({
"version": VERSION,
"surfaces": surfaces,
});
metadata
}
}
@@ -1981,6 +2002,57 @@ mod tests {
assert!(store.surface("s").is_none());
}
#[test]
fn latest_created_surface_is_the_active_surface() {
let mut store = Store::default();
for id in ["first", "second"] {
apply(
&mut store,
json!({"version":VERSION,"createSurface":{"surfaceId":id,"catalogId":CATALOG_ID,"sendDataModel":true,"dataModel":{"id":id}}}),
)
.unwrap();
}
assert_eq!(store.active_surface().unwrap().id, "second");
let metadata = store.client_metadata();
assert_eq!(
metadata.pointer("/a2uiClientDataModel/surfaces/second/id"),
Some(&json!("second"))
);
assert!(
metadata
.pointer("/a2uiClientDataModel/surfaces/first")
.is_none()
);
apply(
&mut store,
json!({"version":VERSION,"deleteSurface":{"surfaceId":"second"}}),
)
.unwrap();
assert_eq!(store.active_surface().unwrap().id, "first");
}
#[test]
fn dismissal_boundaries_replay_history_and_a_fresh_active_epoch() {
let first = json!({"version":VERSION,"createSurface":{"surfaceId":"first","catalogId":CATALOG_ID,"components":[{"id":"root","component":"Text","text":"First"}]}}).to_string();
let update = json!({"version":VERSION,"updateComponents":{"surfaceId":"first","components":[{"id":"root","component":"Text","text":"First final"}]}}).to_string();
let second = json!({"version":VERSION,"createSurface":{"surfaceId":"second","catalogId":CATALOG_ID,"components":[{"id":"root","component":"Text","text":"Second"}]}}).to_string();
let records = [
(1, 10, false, first.as_str()),
(2, 11, false, update.as_str()),
(3, 12, true, "{}"),
(4, 13, false, second.as_str()),
];
let (history, active, errors) = replay_epochs(records);
assert!(errors.is_empty());
assert_eq!(history.len(), 1);
assert_eq!(history[0].active_surface().unwrap().id, "first");
assert_eq!(
history[0].active_surface().unwrap().components["root"]["text"],
"First final"
);
assert_eq!(active.active_surface().unwrap().id, "second");
}
#[test]
fn catalog_validation_and_actions_use_current_local_data() {
let mut store = Store::default();