Проєктування фронтенду AG-UI: потоки подій, реєстр віджетів та локальні LLM
Припиніть просити моделі надавати JSX. Транслюйте події тексту/інструменту/віджета з типизацією, перевіряйте параметри, реєструйте React-віджети та зберігайте ланцюг відображення закритим для змін.
Джерела: Architecting an AG-UI Frontend in React: Event-Driven Widgets, Design Tokens & Ollama
Демо-чат-боти, які використовують лише Markdown, виглядають професійно, поки хтось не попросить таблицю, діаграму, календар чи форму. У цей момент продукт перестає бути просто обгорткою для чату та стає справжнім додатком, а Markdown виявляється недостатнім форматом для таких додатків.
Після кількох невдалих спроб з’явилась краща концепція: не просити модель створювати інтерфейс користувача, а наказувати їй обирати дії. Фронтенд вже знає, як відображати інформацію; бекенд вже знає, де знаходяться дані. Модель має вирішувати, що робити далі.
Цей підхід дозволив створити асистента, який відображає шістнадцять готових React-віджетів, підтримує інтерфейс, створений бекендом, працює з локальним LLM, не додає нових залежностей фронтенду та розширюється шляхом реєстрації одного компонента.
Коротко кажучи: відмовтеся від маркування, створеного моделлю. Нехай бекенд генерує послідовний список типованих подій — прози, статусу інструментів та намірів віджетів — тоді як клієнт буде вести реєстр та відображати ці події у правильному порядку. Єдиною функцією моделі є вибір того, яка можливість бекенду має відповісти. Це забезпечує більш стабільний вихідний результат, компоненти, які можна повторно використовувати, та шлях відображення, який не змінюється з ростом каталогу.
Проблема інтерфейсів, створених ШІ
Багато додатків на основі ШІ слідують вузькому шляху:
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. Це обмеження змусило асистента використовувати ті самі кнопки, таблиці та токени, які вже є у продукті — таким чином екрани чату та нечатові залишаються візуально узгодженими.
Інтерактивні віджети без калебувань 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 під час надходження текстових токенів. Рендерер додає або оновлює події, не створюючи нового протоколу. Користувачі бачать індикатори прогресу ще до того, як з’являться остаточні елементи інтерфейсу.
Створення чат-інтерфейсу сучасного вигляду
Сторінка-хост залишається звичайним списком. Асистент ховається за інтерфейсом Chat with AI та відповідає, використовуючи ті самі дані та компоненти, що й на сторінці. Продовжуваність роботи важливіша, ніж окремий дизайн „AI-продукту“.
Флаги функцій мають значення
Один флаг контролює роботу інтерфейсу:
VITE_ENABLE_AI_CHAT
Флаги дозволяють командам тестувати діалог у темному режимі, вимикати його під великим навантаженням або робити його доступним лише для співробітників, не перезавантажуючи код віджета.
Тестування фреймворку віджетів
Тести охоплюють три рівні: перевірка схеми відкидає некоректні параметри, пошук у реєстрі знаходить відомі віджети, а порядок подій забезпечує відображення інструменту → віджета → тексту так, як очікується. Юніт-тести віджетів виконуються поза чатом. Тести кінця-до-кінця відкривають діалог, запитують інформацію про учасників та перевіряють, чи відображається таблиця.
Коли ця архітектура є зайвою
Чисті боти FAQ, яким потрібні лише абзаци, можуть залишатися у форматі Markdown. Протокол подій стає корисним тоді, коли відповіді є інтерфейсами, коли віджети потрібно повторно використовувати поза чатом, та коли команда не дозволяє моделі створювати дерева компонентів.
Принципи проектування, які керували кожним рішенням
- Модель обирає дії, а не маркап.
- Один потік відображення для кожного повідомлення.
- Віджети реєструються; потік не роздваюється.
- Перевіряйте параметри перед відображенням.
- Віджети комунікують через CustomEvents, а не через імпорти з чату.
Результат
Асистент транслює введені події, генерує шістнадцять віджетів через один потік обробки, перевіряє кожну властивість та розширює функціонал шляхом реєстрації. Локальні бекенди LLM залишаються замінними, оскільки контракт ґрунтується на подіях, а не на SDK-пакетах постачальників у інтерфейсі.
Заключні міркування
Навчання моделей створювати інтерфейси — це боротьба з неправильним ворогом. Краще навчати їх обирати необхідні функції та дозволяти дисциплінованому фронтенду їх візуалізувати — це допомагає зберегти системи дизайну, можливість тестування та потенціал для розвитку. Чат стає ще одним інтерфейсом для тих самих компонентів, яким продукт вже довіряє.
Екран членів у зразку залишається звичайною сторінкою продукту. Асистент — це не окремий продукт; він знаходиться позаду елемента керування „Чат з ШІ“ та відповідає за допомогою того ж рівня даних та тих самих компонентів, які вже використовується на цій сторінці. Ця послідовність є навмисною. Коли чат створює паралельну візуальну мову, оператори перестають йому довіряти. Коли чат та сторінка діляться віджетами, кожне покращення таблиці впливає на обидві частини.
Впорядкування елементів у масиві подій є частиною мови продукту. Подія tool зі статусом running може з’явитися першою, щоб користувач побачив початок роботи. Після неї може йти подія widget з рядками даних. На завершення може йти подія text з однорядковим підсумком. Зміна порядку цих подій змінює враження від розмови, не змінюючи жодних компонентів React. Розглядайте цей масив як сценарій, який розробники бекенду створюють, а фронтенд реалізує.
Перевірка схеми — це не додаткова формальність. Моделі пропускають поля, перетворюють числа на рядки та іноді змінюють назви ключів. Zod (або еквівалентний інструмент) на межі реєстру перетворює ці помилки на контрольовані елементи відображення помилок замість білого екрана. Журналювання невдач перевірки разом із первинним даними дозволяє швидше виправити проблему за допомогою інструментів, ніж здогадуватися на основі звіту користувача.
CustomEvents забезпечують чітке визначення власників. Віджет, який імпортує хуки чату, не може використовуватися на статичній сторінці. Віджет, який відправляє лише сигнали member:open або table:refresh, може бути використаний будь-де. Діалогове вікно стає адаптером, який перетворює ці події на виклики інструментів. Цей адаптер — єдине місце, яке знає про сесію асистента.
Стрімінг підпорядковується тому самому контракту. У міру надходження інформації про токени та статуси інструментів клієнтське додатку додає або оновлює відповідні події на місці. Іншого „формату стрімінгу“ не існує. Якщо інструмент зазнає збою під час роботи, статус failed замінює running, а необов’язкова текстова подія пояснює причину збою. Користувачі бачать безперервність роботи, а не просто індикатор завантаження, який мовчки припиняє свою роботу.
Флаги функцій забезпечують оперативну безпеку. Використання шістнадцяти віджетів під керуванням VITE_ENABLE_AI_CHAT дозволяє командам вимикати цей діалог під час пікового навантаження, не змінюючи код. Поєднайте цей флаг із авторизацією для кожного окремого інструменту, щоб каталог з правами лише на читання залишався активним, тоді як інструменти з руйнівним впливом були вимкнені.
Стратегія тестування відповідає архітектурі. Юніт-тести охоплюють схеми та процес відображення простих віджетів. Інтеграційні тести надсилають фіксований масив подій до рендерера та створюють знімок його структури. Тести кінець-кінець запитують справжній або імітований бекенд про дані та перевіряють, чи віджет таблиці створюється з очікуваними колонками. Оскільки віджети є звичайними компонентами, існуючі набори тестів на візуальну регресію можуть включати їх без спеціальних інструментів на основі ШІ.
Проте все ще існують випадки надмірності. Якщо продукт відповідає лише на определенчні запитання короткими абзацами, Markdown залишається дешевшим варіантом. Архітектура подій оплачує себе тоді, коли відповіді мають форму інтерфейсів, коли ті самі віджети з’являються поза чатом, та коли команда відмовляється дозволяти моделі створювати дерева компонентів. Ці три умови зазвичай поєднуються одночасно, коли продукт внутрішніх інструментів досягає зрілості.
Відсутність нових залежностей фронтенду була свідомою обмеженням, а не змаганням у чистоті коду. Додавання ще однієї бібліотеки для графіків лише для чату призвело б до розщеплення системи дизайну. Повторне використання існуючих пакетів для графіків та таблиць змусило асистента використовувати візуальний стиль продукту. Це мало за наслідок необхідність іноді писати код-адаптери; проте перевагою була єдність вигляду та базові стандарти доступності.
Підтримка локальних LLM має велике значення для сценаріїв роботи, де важлива конфіденційність. Оскільки інтерфейс розуміє лише події, заміна хмарної моделі на локальну є проблемою бекенду. Реєстр, валідатори та шина CustomEvent залишаються незмінними. Саме це розділення дозволяє використовувати однаковий фронтенд для демонстрації з Ollama під час розробки та для хостованої моделі в продакшені, не розщеплюючи чат-інтерфейс.
Розширення системи навмисно є нудним. Достатньо додати файл віджета, зареєструвати його за схемою, прив’язати результат інструменту до властивостей та написати тест. Жодних змін у рендерері. Жодних нових типів подій, якщо тільки продукт справді не потребує четвертого типу — а поки що трьох виявилося достатньо. Нудність тут є перевагою: це означає, що молодші співробітники можуть допомагати з віджетами, не вивчаючи спеціалізовану рамку інтерфейсу для ШІ.
Кінцевий результат більше нагадує конверсаційний маршрутизатор до існуючих можливостей продукту, а не чат-бота, прикріпленого до нього. Це архітектурна перевага. Модель стає диспетчером. Система дизайну залишається джерелом істини для візуалізації. Бекенд залишається джерелом істини для даних та прав доступу.
З точки зору експлуатації необхідно вести журнал змін щодо реєстрації віджетів разом із змінами каталогу інструментів. Коли змінюється формат відповіді інструменту, схема відповідного віджета також мусить змінитися у тому самому запиті на оновлення. Таке поєднання запобігає класичній проблемі, коли агент все ще правильно відповідає у текстовому форматі, тоді як віджет мовчки відображає порожні стовпці. Рецензенти повинні відхилити запити на оновлення інструментів, у яких не врахована супутня схема.
Доступність досягається завдяки повторному використанню готових віджетів. Якщо таблиця членів вже підтримує навігацію за допомогою клавіатури та мітки для скринь-читачів, таблиця, розміщена у чаті, успадковує ці функції. Створення специфічного HTML на основі моделі змарнує ці зусилля. Тому архітектура подій є не лише вибором для забезпечення надійності, а й стратегією збереження доступності.
Ключові висновки
- Віддавайте перевагу потокам подій (текст, інструменти, віджети) перед HTML/JSX, створеними на основі моделей.
Разом потік подій, реєстр, механізми перевірки та шина CustomEvent створюють невелику, але повну фреймворк для інтерфейсу продукту з підтримкою ШІ, не віддаючи контроль над дизайном моделі. Продовжуйте вдосконалювати віджети, а не одноразові запити до форматування, щоб асистент залишався зручним для підтримки у міру розвитку продукту.