prev/next A2UI surface and better dismissal
This commit is contained in:
104
src/a2ui.rs
104
src/a2ui.rs
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user