Проектирование фронтенда AG-UI: потоки событий, реестр виджетов и локальные LLM
Перестаньте просить модели о JSX. Передавайте типизированный текст, события инструментов/виджетов, проверяйте параметры, регистрируйте React-виджеты и сохраняйте цикл отрисовки закрытым для изменений.
Источники: Architecting an AG-UI Frontend in React: Event-Driven Widgets, Design Tokens & Ollama
Демо-чат-боты, которые используют только Markdown, кажутся совершенными, пока кто-то не попросит таблицу, график, календарь или форму. В этот момент продукт перестаёт быть просто оболочкой для чата и превращается в приложение, а Markdown оказывается несовместимым форматом для таких приложений.
После нескольких тупиков появилось более эффективное решение: перестать просить модель создавать интерфейс. Нужно попросить её выбирать действия. Фронтенд уже знает, как отображать контент; бэкенд уже знает, где хранятся данные. Модель должна решать, что происходит дальше.
Благодаря такому разделению был создан ассистент, который отображает шестнадцать готовых компонентов React, поддерживает интерфейс, формируемый бэкендом, работает с локальным большим языковым моделем, не добавляет новых зависимостей фронтенда и расширяется путём регистрации одного компонента.
Кратко говоря: следует отказаться от маркировки, создаваемой моделью. Пусть бэкенд генерирует последовательный список типизированных событий — прозы, информации о статусе инструментов и намерений виджетов, — в то время как клиент ведёт реестр и отображает эти события в правильном порядке. Единственная задача модели — определить, какая функция бэкенда должна ответить. Это обеспечивает более стабильный вывод, возможность повторного использования компонентов и путь отрисовки, который не меняется по мере роста каталога.
Проблемы с интерфейсами, созданными ИИ
Многие приложения на основе ИИ следуют узкому пути:
User
↓
LLM
↓
Markdown
↓
React renders Markdown
Этот подход прост, но ограничен. Пользователи со временем просят увидеть активных участников, сравнить месячные доходы, вывести список встреч или открыть профиль. Эти ответы представляют собой интерфейсы, а не абзацы текста.
Попытки попросить модель генерировать HTML или JSX проваливаются, когда она выдумывает отсутствующие компоненты, удаляет необходимые атрибуты или называет элементом тот, кого на самом деле нет. В результате фронтенд выходит из строя из-за вымышленной разметки. Генерируемая разметка также нельзя вводить вручную, тестировать в виде единичных тестов, использовать вне чата или рассматривать в пул-реквесте. Гарантии библиотеки компонентов исчезают; вместо этого ставится цель детерминизма.
Основная идея: модель выбирает действия, а не компоненты
Бэкенд никогда не возвращает HTML, JSX или имена компонентов React. Каждый ответ представляет собой упорядоченный поток событий:
{
"events": [
{ "type": "tool", "tool": "list_members", "status": "completed" },
{ "type": "widget", "widget": "table", "props": { "rows": [] } },
{ "type": "text", "text": "I found 24 active members." }
]
}
Бэкенд передаёт намерение; фронтенд определяет способ отображения. Обязанности остаются чёткими: ИИ решает, что показывать, React — как это сделать, а визуальные элементы — как выглядеть. Строка "table" является семантическим ключом, а не именем класса — на ПК можно виртуализировать сетку, а на мобильных укладывать карточки, при этом бэкенду всё равно.
Протокол событий
Контракт остаётся достаточно маленьким, чтобы поместиться в рабочей памяти. Структурированные события обеспечивают его безопасность:
type TextEvent = {
type: 'text';
text: string;
};
type ToolEvent = {
type: 'tool';
tool: string;
status: 'running' | 'completed' | 'failed';
error?: string;
};type WidgetEvent = {
type: 'widget';
widget: WidgetName; // union of registered keys
props: Record<string, unknown>; // validated at the boundary
};export type AssistantEvent = TextEvent | ToolEvent | WidgetEvent;
Три вида событий охватывают ответы ассистента. Порядок имеет значение: чип инструмента может появиться раньше вижета, который он создал, а затем может следовать краткое описание. Транскрипт напоминает описанный пошагово рабочий процесс.
Один поток обработки, отсутствие операторов переключения
Каждое сообщение проходит по одному пути:
AiDialog
↓
ChatMessages
↓
ChatMessage
↓
MessagePart
↓
WidgetRenderer
↓
widgetRegistry[widget]
В структуре не присутствуют специальные операторы переключения. Один обработчик отвечает за каждый тип события: текст преобразуется в текст чата, инструмент — в информацию о прогрессе, вижет — в компонент React. Добавление новых функций не требует переписывания всего потока обработки — достаточно зарегистрировать новый вижет. Обработчик, который ранее обрабатывал первый вижет, продолжает обрабатывать и шестнадцатый.
Открыт для расширения, закрыт для изменений
Регистрируются новые виджеты; поток остается закрытым. Это принцип открытости/закрытости, применённый к интерфейсу чата. Рост продукта становится проблемой каталога, а не проблемой переписывания кода.
Никогда не доверяйте модели: проверяйте параметры на границе
Результаты работы модели считаются ненадёжным входным данными. Перед отображением виджета параметры проходят проверку согласно схеме (в примере используется Zod). Недействительные данные приводят к отображению безопасного интерфейса ошибок, а не к исключениям во время выполнения программы глубоко в структуре.
const registry = {
table: {
schema: z.object({
columns: z.array(z.object({ key: z.string(), label: z.string() })),
rows: z.array(z.record(z.unknown())).default([]),
}),
component: TableWidget,
},
// ...
};
function WidgetRenderer({ widget, props }: WidgetEvent) {
const entry = registry[widget];
if (!entry) return <UnknownWidget name={widget} />; const parsed = entry.schema.safeParse(props);
if (!parsed.success) return <WidgetError name={widget} issues={parsed.error.issues} />; const Widget = entry.component;
return <Widget {...parsed.data} />;
}
Проверка на границе предотвращает сбои в листовых компонентах и позволяет операторам чётко понимать, почему модель искажает данные.
Создание настоящей библиотеки виджетов
Виджеты хранятся в реестре, ключи которого формируются на основе семантических названий:
export const widgetRegistry = {
table,
profile,
chart,
timeline,
calendar,
markdown,
json,
stats,
progress,
alert,
fileList,
} satisfies Record<string, ComponentType<any>>;
export type WidgetName = keyof typeof widgetRegistry;
WidgetName берется из реестра, поэтому TypeScript отклоняет неизвестные ключи. Таблицы, профили, диаграммы и формы превращаются в обычные компоненты, которые остальная часть приложения может повторно использовать вне чата.
Почему я не добавил никаких зависимостей
Шелл чата использовал существующую систему дизайна и стек React. В package.json не появилось никаких новых UI-китов. Это ограничение заставило ассистента использовать те же кнопки, таблицы и элементы управления, которые уже входят в состав продукта — таким образом экраны чата и нечатовых функций сохраняют визуальную согласованность.
Интерактивные виджеты без обратных вызовов React
Виджеты не должны импортировать шелл чата. Они генерируют DOM-события CustomEvents для таких действий, как обновление данных или открытие профиля:
// Inside a widget — it knows nothing about the app
element.dispatchEvent(
new CustomEvent('ai:action', {
bubbles: true,
detail: { action: 'open_member', payload: { id: member.id } },
})
);
Widget
↓
CustomEvent
↓
AiDialog
↓
AI Agent
Диалоговое окно прослушивает сигналы один раз, соотносит события с вызовами инструментов и обеспечивает портативность вижуал-элементов. Одна и та же таблица может находиться как на странице участников, так и в чате без необходимости использования специфических для чата параметров.
Искусственный интеллект не определяет интерфейс
Модель никогда не выбирает цвета, макеты или структуру компонентов. Она выбирает инструменты. Бэкенд-оркестрация преобразует результаты работы инструментов в события вижуал-элементов с проверенными параметрами. Дизайн остается в ведении инженеров фронтенда.
Замена жестко заданных конечных точек каталогом инструментов
В ранних прототипах URL-адреса были заданы жестко. В окончательной версии предоставляется каталог инструментов, который хост может обнаружить:
{
"message": "Show me all active members",
"tools": [
{ "name": "list_members", "endpoint": "/api/v1/members", "method": "GET" },
{ "name": "get_member", "endpoint": "/api/v1/members/{id}", "method": "GET" },
{ "name": "member_stats", "endpoint": "/api/v1/members/stats", "method": "GET" }
]
}
Каждый инструмент указывает конечную точку и схему данных. Модель выбирает инструмент; бэкенд его запускает; события вижуал-элементов передают полученный результат. Изменение инфраструктуры становится просто редактированием каталога, а не переписыванием запросов.
Стриминг без нарушения условий работы
Стриминг по-прежнему генерирует те же типы событий. Статус инструмента может измениться с running на completed по мере поступления текстовых токенов. Рендерер добавляет или обновляет события, не создавая нового протокола. Пользователи видят индикаторы прогресса до появления окончательных элементов интерфейса.
Создание современного чат-интерфейса
Страница-хост остается обычным списком. Ассистент скрыт за интерфейсом чата с управлением через ИИ и отвечает, используя те же данные и компоненты, что и на странице. Продолжительность работы важнее отдельного дизайна «продукта ИИ».
Значимость флагов функций
Один флаг контролирует работу интерфейса:
VITE_ENABLE_AI_CHAT
Флаги позволяют командам тестировать диалог в темном режиме, отключать его при высокой нагрузке или делать его доступным только сотрудникам, не перезагружая код элементов интерфейса.
Тестирование фреймворка элементов интерфейса
Тесты охватывают три уровня: проверка схемы отклоняет некорректные параметры, поиск в реестре находит известные виджеты, а упорядочивание событий обеспечивает отображение элементов в порядке tool → widget → text согласно ожиданиям. Юнит-тесты виджетов выполняются вне чата. Тесты конца-концу открывают диалог, запрашивают информацию о участниках и проверяют появление таблицы.
Когда такая архитектура избыточна
Чистые боты для FAQ, которым нужны только абзацы, могут оставаться в формате Markdown. Протокол событий становится необходимым, когда ответы представляют собой интерфейсы, когда виджеты должны использоваться вне чата, и когда команда не позволяет модели создавать деревья компонентов.
Принципы проектирования, лежащие в основе каждого решения
- Модель выбирает действия, а не маркировку.
- Один поток отрисовки для каждого сообщения.
- Виджеты регистрируются; поток отрисовки не дублируется.
- Проверяются параметры перед отрисовкой.
- Виджеты взаимодействуют через CustomEvents, а не через импорты из чата.
Результат
Ассистент транслирует введенные события, отображает шестнадцать виджетов через один поток обработки, проверяет каждое свойство и расширяется путем регистрации. Локальные бэкенды больших языков моделирования остаются заменяемыми, поскольку контракт основан на событиях, а не на SDK-пакетах поставщиков в интерфейсе.
Заключение
Обучение моделей рисовать интерфейсы — это борьба с неверным противником. Обучение их выбирать необходимые функции и предоставление возможности дисциплинированному фронтенду отображать эти функции позволяет сохранить системы дизайна, возможности тестирования и потенциал развития. Чат становится еще одним инструментом для использования тех же компонентов, которым уже доверяет продукт.
Экран с элементами списка в примере остается обычной страницей продукта. Ассистент не является отдельным продуктом; он находится за интерфейсом чата с управлением через ИИ и отвечает с использованием той же структуры данных и тех же компонентов, что и сама страница. Такая непрерывность — целенаправленный выбор. Когда чат создает собственный визуальный язык, операторы перестают ему доверять. Когда чат и страница используют общие виджеты, любые улучшения таблицы отражаются в обоих местах.
Порядок элементов в массиве событий является частью языка продукта. Событие tool со статусом running может появляться первым, чтобы пользователь мог увидеть начало работы. За ним может следовать событие widget с строками данных. В конце может быть событие text с однострочным резюме. Изменение порядка этих событий влияет на восприятие диалога без необходимости изменять какие-либо компоненты React. Рассматривайте массив как сценарий, который разрабатывают специалисты бэкенда и реализуют на фронтенде.
Проверка схемы — это не просто формальность. Модели могут опускать поля, преобразовывать числа в строки и иногда менять имена ключей. Инструменты вроде Zod (или аналогичные) на границе регистра превращают эти ошибки в управляемые сообщения об ошибках, а не в белый экран. Логирование сбоев проверки вместе с исходными данными позволяет быстрее исправлять проблемы с помощью инструментов, чем догадываться на основе отчета пользователя.
CustomEvents помогают четко определять владельцев элементов интерфейса. Виджет, импортирующий хуки чата, нельзя повторно использовать на статической странице. Виджет, который отправляет только сигналы member:open или table:refresh, может использоваться где угодно. Диалоговое окно выступает в роли адаптера, преобразующего эти события в вызовы инструментов. Именно этот адаптер знает о сессии помощника.
Стриминг подчиняется тому же контракту. По мере поступления данных о токенах и статусах инструментов клиентская программа добавляет или корректирует соответствующие события. Другого «формата стриминга» не существует. Если инструмент выходит из строя в процессе работы, его статус меняется с running на failed, а дополнительное текстовое событие объясняет причину сбоя. Пользователи видят непрерывность работы, а не бесшумно прекращающийся процесс загрузки.
Флаги функций обеспечивают операционную безопасность. Использование шестнадцати виджетов под управлением флага VITE_ENABLE_AI_CHAT позволяет командам отключать диалоговые окна в условиях высокой нагрузки без необходимости изменения кода. Сочетание этого флага с авторизацией по отдельным инструментам гарантирует, что каталог с ограниченными правами доступа будет продолжать работать, в то время как инструменты с разрушительным эффектом останутся недоступными.
Стратегия тестирования соответствует архитектуре. Юнит-тесты покрывают схемы и процесс отрисовки чистых виджетов. Интеграционные тесты подают фиксированный массив событий в рендерер и создают его копию для сравнения. Тесты конца-концу запрашивают у реального или имитированного бэкенда данные о пользователях и проверяют, что виджет таблицы формируется с ожидаемыми столбцами. Поскольку виджеты являются обычными компонентами, существующие наборы тестов на визуальные отклонения могут включать их без специальных инструментов на основе ИИ.
Тем не менее существуют случаи чрезмерности. Если продукт отвечает только на определенные вопросы краткими абзацами, Markdown остается более экономичным решением. Архитектура событий оправдывает себя, когда ответы представляют собой интерфейсы, когда те же виджеты используются вне чата, и когда команда не позволяет модели создавать деревья компонентов. Эти три условия обычно возникают одновременно по мере созревания внутренних инструментальных продуктов.
Отсутствие новых зависимостей фронтенда было преднамеренным ограничением, а не соревнованием в чистоте кода. Добавление ещё одной библиотеки диаграмм только для чата привело бы к разделению системы дизайна. Использование существующих пакетов для диаграмм и таблиц заставило ассистента использовать визуальный стиль продукта. Ценой этого стало необходимость время от времени писать код-адаптеры; однако преимуществом стал единый визуальный стиль и единые стандарты доступности.
Поддержка локальных LLM важна для сценариев работы, где критична конфиденциальность данных. Поскольку интерфейс понимает только события, замена облачной модели на локальную реализацию относится к задачам бэкенда. Реестр, валидаторы и шина CustomEvent остаются неизменными. Именно такое разделение позволяет использовать ту же демо-версию фронтенда с Ollama в процессе разработки и с хостинговой моделью в продакшене, не изменяя структуру чат-интерфейса.
Расширение системы намеренно скучное. Нужно добавить файл с вижетом, зарегистрировать его по схеме, связать результат работы инструмента с параметрами и написать тест. Никаких изменений в рендерере. Новые типы событий не требуются, если только продукту действительно не понадобится четвертый тип — а пока трех оказалось достаточно. Скучность здесь — это преимущество: она позволяет младшим сотрудникам вносить вклад в создание вижетов, не изучая специализированные фреймворки для интерфейсов ИИ.
Конечный результат больше похож не на чат-бота, привязанного к продукту, а на конверсационный маршрутизатор, ведущий к существующим возможностям продукта. В этом и заключается архитектурный успех. Модель становится диспетчером. Система дизайна остается источником истины для отображения информации. Бэкенд остается источником истины для данных и разрешений.
С точки зрения эксплуатации необходимо вести журнал изменений регистрации виджетов наряду с изменениями каталога инструментов. Когда формат ответа инструмента меняется, схема соответствующего виджета также должна измениться в том же пул-риквесте. Такое сопоставление предотвращает классическую проблему, при которой агент по-прежнему правильно отвечает в текстовом формате, в то время как виджет молча отображает пустые колонки. Рецензенты должны отклонять PR-запросы инструментов, в которых игнорируется сопутствующая схема.
Доступность обеспечивается за счёт повторного использования готовых виджетов. Если таблица элементов уже поддерживает навигацию с помощью клавиатуры и метки для скрин-ридеров, таблица, размещённая в чате, наследует эту функциональность. Генерация специального HTML на основе модели приведёт к потере этих усилий. Следовательно, архитектура событий является не только способом обеспечения надёжности, но и стратегией сохранения доступности.
Основные выводы
- Предпочтительнее использовать потоки событий (текст, инструменты, виджеты) вместо HTML/JSX, сгенерированных моделью.
Вместе поток событий, реестр, инструменты проверки и шина CustomEvent образуют небольшую, но полноценную платформу для интерфейсов продуктов с поддержкой ИИ, не отдавая контроль над дизайном модели. Продолжайте совершенствовать виджеты, а не временные элементы интерфейса, чтобы ассистент оставался удобным в обслуживании по мере роста продукта.