Flow UI поверх A2A
Интерфейс flow — это обычный A2A 1.0 без расширений. Дизайн-время описывает FlowUIDocument (страницы и блоки грамматики), рантайм идёт в Task: агент отправляет вниз артефакты state, текст и ui, клиент отправляет вверх обычное Message. Всё, что нужно клиенту, — четыре mediaType вниз, одно Message вверх и три GET.
Примеры JSON на этой странице совпадают с conformance-фикстурами tests/fixtures/flow_ui/*.json; их читают и pytest, и JS unit-тесты, поэтому браузерный рантайм, Python SDK и сервер видят протокол одинаково.
Вниз: артефакты Task¶
Каждый артефакт различается по artifactId (системные state, ui) или по name (текстовые потоки). Клиент разбирает Part по mediaType.
artifactId / name |
mediaType Part |
Что несёт |
|---|---|---|
state |
application/json |
Снимок проекции state — первый Part артефакта в Task |
state |
application/json-patch+json |
RFC 6902 add/replace/remove поверх предыдущего снимка |
ui |
application/vnd.platform.ui+json |
Событие render (блоки в slot_id) или navigate (page_id) |
response, reasoning, авторское имя |
текстовый Part | Поток текста; чанки одного имени склеиваются |
Проекция state содержит только пути, на которые ссылается документ: {path} в шаблонах, field input-блоков, path условий visible_when, fields и data-bind/data-show блока html, а также пути блоков, отрисованных в ленту conversation. Системные поля ExecutionState в проекцию не попадают и в документе запрещены — их список отдаёт GET /flow-ui/schema в x-flow-ui-frozen-state-fields.
Parts артефакта state для одного Task в порядке появления, затем два события ui и текст:
[
{"data": {"email": "a@b.c", "total": 1200}, "mediaType": "application/json"},
{"data": [{"op": "replace", "path": "/total", "value": 1500}], "mediaType": "application/json-patch+json"},
{"data": {"kind": "render", "slot_id": "result", "blocks": [{"block_id": "summary", "type": "text", "markdown": "Заявка **{email}** принята"}]}, "mediaType": "application/vnd.platform.ui+json"},
{"data": {"kind": "navigate", "page_id": "done"}, "mediaType": "application/vnd.platform.ui+json"},
{"text": "Готово"}
]
Событие render в slot страницы заменяет содержимое слота; render в conversation добавляет карточку в ленту. Любой другой kind или mediaType у data Part — ошибка протокола на обеих сторонах.
Патч state всегда выражается через add/replace/remove по JSON Pointer; сегменты с / и ~ экранируются как ~1 и ~0:
[
{"op": "replace", "path": "/status", "value": "sent"},
{"op": "remove", "path": "/customer/phone"},
{"op": "add", "path": "/customer/email", "value": "a@b.c"},
{"op": "replace", "path": "/items", "value": [1, 2, 3]}
]
Вверх: одно Message¶
Ход пользователя — обычное Message с role: ROLE_USER, contextId и, при продолжении Task, taskId. Вариантов два:
textPart (плюс файлыraw/urlParts) — реплика в ленту;- ровно один
dataPartapplication/vnd.platform.ui-action+json— действие интерактивного блока.
{
"data": {
"action": "submit",
"source": "send_button",
"fields": {"email": "a@b.c", "agree": true},
"payload": {"plan": "pro"}
},
"mediaType": "application/vnd.platform.ui-action+json"
}
action — имя действия из action.name блока, source — block_id блока-источника, fields — значения input-блоков страницы по их field, payload — данные действия. Сервер проверяет fields по документу (обязательность, pattern, варианты select), записывает их в state по тем же путям и кладёт действие в state.ui_action:
{"name": "submit", "source": "send_button", "payload": {"plan": "pro"}}
Message с двумя ui-action Parts или с ui-action рядом с текстом отклоняется.
Три GET¶
| Метод и путь | Что возвращает |
|---|---|
GET /flows/api/v1/{flow_id}/interface?branch_id=default |
FlowInterfaceResponse: document, viewer (права), capabilities (cancel/files/voice), flow_identity, look для embed. Для встраивания — GET /flows/api/v1/embed/{embed_id}/interface |
GET /flows/api/v1/flow-ui/schema |
JSON Schema FlowUIDocument (палитра и свойства редактора, structured output LLM) плюс x-flow-ui-frozen-state-fields |
GET /flows/api/v1/flow-ui/templates |
Галерея стартовых документов (OffsetPage[FlowUITemplate]) |
A2A JSON-RPC (SendMessage, SendStreamingMessage, GetTask, ListTasks, SubscribeToTask, CancelTask) принимается на POST /flows/api/v1/{flow_id}; ветка выбирается metadata.branch, переменные запуска — metadata.variables. Подробности A2A — на странице Flows A2A API.
Грамматика блоков¶
Единственная спецификация — Pydantic-модели core/flow_ui/document.py; JSON Schema из GET /flow-ui/schema — их проекция. Документ:
{
"start_page_id": "main",
"pages": [
{
"page_id": "main",
"title": "Заявка",
"blocks": [
{"block_id": "title", "type": "heading", "text": "Заявка {?customer.name}", "level": 2},
{"block_id": "email", "type": "text_input", "field": "email", "label": "Email", "required": true},
{"block_id": "send_button", "type": "button", "label": "Отправить", "action": {"name": "submit", "payload": {"plan": "pro"}}},
{"block_id": "result", "type": "slot", "title": "Результат"}
]
},
{"page_id": "done", "title": "Готово", "blocks": [{"block_id": "done_text", "type": "text", "markdown": "Спасибо, {email}"}]}
]
}
Группы блоков (поле type):
- раскладка —
section,columns,tabs; - контент —
heading,text,image,video,audio,code,badge,alert,metric,progress,status,empty_state,divider; - ввод —
text_input,number_input,select,checkbox,switch,slider,date_input,datetime_input,file_upload,voice_input; у каждогоfield— путь state,required,error_message, опциональноеaction; - действия —
button(action),link(page_idилиurl); - данные —
data_table,chart,json_view,file_card,file_list; - платформенные —
conversation,slot,agent_identity,avatar,run_trace,tool_activity,browser_preview,skill_activity,action_preview,action_result,reflection,activity,suggested_prompts; - свой код —
html.
У любого блока есть block_id и visible_when: {path, equals} — блок показывается, когда значение по path равно equals; без equals — когда значение непустое (не null, "", false, 0 и не пустой массив).
Шаблоны {path}¶
Текстовые свойства блоков — шаблоны над state: {path} подставляет значение (объекты и массивы — как JSON), {?path} даёт пустую строку вместо отсутствующего значения, {path|запасной текст} подставляет запасной текст, когда значение отсутствует или равно пустой строке; запасной текст сам может быть шаблоном, \{ — литеральная скобка. Пути — snake_case с точками: customer.name. Шаблон с отсутствующим обязательным {path} не рендерится, пока значение не появится.
{"name": "nested_path", "template": "Привет, {customer.name}!", "state": {"customer": {"name": "Анна"}}, "paths": ["customer.name"], "text": "Привет, Анна!"}
Блок html¶
source — HTML/CSS/JS автора, исполняется в iframe с opaque origin: атрибут sandbox="allow-scripts" и фиксированный CSP в srcdoc:
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: https:; font-src data: https:; media-src https:
Внутрь приходит проекция state только по fields блока и путям data-bind/data-show из разметки; наружу уходят сообщения моста. API window.flowUi:
| Вызов | Действие |
|---|---|
flowUi.state |
Текущая проекция state |
flowUi.onChange(cb) |
Подписка на новые проекции; возвращает функцию отписки |
flowUi.send(action, payload) |
Ход ui-action во flow от имени блока (source = block_id) |
flowUi.setValue(value) |
Локальное значение поля field блока — уйдёт в fields следующего действия |
flowUi.resize() |
Пересчёт высоты при height: "auto" |
flowUi.openUrl(url) |
Открыть http(s) ссылку в новой вкладке |
Путь, который читает только скрипт (без data-bind/data-show), перечисляется в fields блока — иначе его нет в проекции. Тема хоста приходит CSS-переменными --flow-ui-* (text, heading, muted, border, panel, surface, input, accent, accent-hover, on-accent, accent-muted, radius, radius-md, page-background) и обновляется без перезагрузки iframe. Новый блок в редакторе создаётся со стартовым исходником, где каждый приём (data-bind, data-show, flowUi.onChange, flowUi.send, flowUi.setValue, flowUi.openUrl, токены темы) показан и прокомментирован; полноэкранный редактор блока держит рядом живой sandbox-предпросмотр с тестовым state, журнал моста и шпаргалку API.
Элементы с data-bind="path" получают текст значения, data-show="path" скрывает элемент при пустом значении:
{"name": "bind_and_show_in_order", "source": "<section data-show=\"order\"><b data-bind=\"order.total\"></b><i data-bind='customer.name'></i></section>", "paths": ["order", "order.total", "customer.name"]}
Как агент меняет интерфейс¶
В llm_node доступны тулы ui_render(slot_id, blocks), ui_navigate(page_id) и state_set(path, value); code-ноды меняют state напрямую. Runtime после каждого шага публикует накопленные события ui, затем разницу проекции state — клиенту не нужно ничего запрашивать.
SDK¶
Браузер — core/frontend/static/lib/flow-ui/runtime/flow-ui-client.js (тот же модуль использует <platform-flow-ui>):
import { FlowUIClient } from '/static/core/lib/flow-ui/runtime/flow-ui-client.js';
const client = new FlowUIClient({ baseUrl: 'https://acme.linnex.io/flows', flowId: 'lead_form' });
client.on('state', (state) => console.log(state.total));
client.on('ui', (event) => console.log(event.kind));
await client.open();
await client.send({ action: { action: 'submit', source: 'send_button', fields: { email: 'a@b.c' }, payload: {} } });
Python — core.clients.flow_ui_client.FlowUIClient поверх A2AClient:
from core.clients.flow_ui_client import FlowUIClient
from core.flow_ui.protocol import FlowUIActionMessage
client = FlowUIClient("https://acme.linnex.io/flows", flow_id="lead_form")
interface = await client.open()
result = await client.send(action=FlowUIActionMessage(action="submit", source="send_button", fields={"email": "a@b.c"}))
print(client.state["total"], [event.kind for event in client.events()])