@ykurilov/agent-chat-react
v0.75.0
Published
Поведение чата с агентом для React 19, без внешнего вида. Всё видимое рисует скин — объект компонентов. Это низкоуровневый API: `skin` обязателен.
Readme
@ykurilov/agent-chat-react
Поведение чата с агентом для React 19, без внешнего вида. Всё видимое рисует скин —
объект компонентов. Это низкоуровневый API: skin обязателен.
Стандартный экран SDK - AgentChat, ChatWorkspace, AgentPanel из @ykurilov/agent-chat-skin-oduvanchik/chat: без skin используется Одуванчик, явный скин имеет приоритет. Подключение CSS и переход. Существующий импорт из React сам не меняет оформление приложения.
import { AgentChat, ChatLabelsProvider, russianLabels } from "@ykurilov/agent-chat-react";
<ChatLabelsProvider labels={{ ...russianLabels, retry: "Ещё раз" }}>
<AgentChat api="/api/chat" chatId={chatId} skin={{ ...tandemSkin, ToolCallRow: MyToolCallRow }} />
</ChatLabelsProvider>;AgentChat— лента, поле ввода, остановка, «повторить», переподключение к идущему ответу, переключатель вариантов «‹ 2/3 ›», «Переделать ответ» и правка отправленного сообщения (вид рисует скин:BranchSwitcher,MessageEditor).skin— компоненты по договоруChatSkin; любой можно заменить своим.liveness— сроки живой связи (см. ниже); обычно не нужен.Надписи — в словаре:
russianLabels,ChatLabelsProvider,useChatLabels().Пока модель думает, надпись живая: фраза из
labels.thinkingPhrasesсменяется каждые 3–4 с, рядом время (labels.thinkingElapsed). Свой скин берёт их изuseThinkingStatus(); диктору — постояннаяreasoningRunning, меняющиеся фразы ему скрывать.Выбор модели:
AgentChatсам грузит список (GET …/models) и передаёт скинуComposerсвойствоmodelChoice(меню провайдеров и моделей, уровни рассуждений). Сохраняет выборPUT …/:id/model. Отдельно — хукuseModelChoice.voice— голосовой ввод из@ykurilov/agent-chat-voice; без него кнопки микрофона нет. СкинуComposerуходитvoice(VoiceInputView), слова диктовки дописываются в поле, ручная правка остаётся.spendingLimits— на сервере включены лимиты расходов (@ykurilov/agent-chat-limits): чат читаетGET …/spending, показывает скинуSpendingNotice(«Использовано 80% лимита на месяц», «Лимит на месяц исчерпан, обновится 1 ноября») и при исчерпании останавливает отправку (Composer.sendingBlocked). Отказ 429 переподключений не запускает: текст возвращается в поле (при правке редактор остаётся открытым, надпись —editErrors.spendingLimit). ХукuseAgentChat({ spendingLimits: true })отдаётspending.Подтверждение действий: карточка запроса приходит в ленту сама; скину
ToolCallRowуходятtitleиapproval(ToolApprovalView), кнопки —approval.onDecide("allow" | "deny" | "always"); «Всегда» рисовать только приapproval.canAlways, причину отказа брать изapproval.failureText. Новое сообщение вместо ответа считается отказом (договор скина версии 9).Ответ, который не дошёл до человека (сервер пометил
markUndelivered), приходит скину сundelivered: true: серая отметкаlabels.answerUndelivered(«Не доставлено»). Договор скина версии 24, скин версии 23 работает без правок — docs/skin-v24.md.Реплика живого сотрудника (сервер:
appendStaffMessageилиflow.noteStaffReply) приходит скину вStaffMessage(text,staffName) — не пузырём человека. Договор скина версии 25; скин версии 24 работает без правок: реплика показывается ответом агента с подписьюlabels.notices.staffSaid(«Сотрудник Ольга») — docs/skin-v25.md.Картинка библиотеки (
show_image, пакет@ykurilov/agent-chat-image-library) приходит вGeneratedImagesс необязательнымиwide(по ширине ответа со своей пропорцией) иcaption(подпись под картинкой). Договор скина версии 26; скин версии 25 работает без правок: плитка ближайшей обычной пропорции без подписи — docs/skin-v26.md.Служебные пометки приложения (
kind: "app") — серой строкой перед ответом агента: «Агент написал первым — человек молчал 1 ч 7 мин». Нужны право чатаappNoticesна сервере (по умолчанию выключено),noticeLinesуAgentChat(имя пометки → строка; для сценариев —flowNoticeLines()) иServiceLineу скина. Договор скина версии 27; скин версии 26 работает без правок: пометок не видно — docs/skin-v27.md.ChatWorkspace— список чатов и открытый чат вместе: создать, переименовать, удалить (нуженchatList: trueна сервере). Хук —useChatList. Скину дополнительно нужныChatWorkspaceиChatList(ChatListSkin, договор версии 7).AgentMemory— экран долгой памяти агента: записи по разделам, добавить, изменить, удалить, «Стереть всю память» (нужна опцияmemoryна сервере, пакет@ykurilov/agent-chat-memory).api—createMemoryApi({ api: "/api/chat" }), создавать один раз;skin—MemorySkin(tandemMemorySkin). Хук —useMemoryNotes. Без памяти на сервере экран пишет, что память не включена. Договор скина версии 11 — отдельныйMemorySkin,ChatSkinне менялся (contracts/memory.md).Панель агента: вход
@ykurilov/agent-chat-react/panel; основной вход его не подтягивает.AgentPanelProviderставится в корневой макет выше страниц (api,userKey,browserTools,mode—overlayилиpush,width,onDataChanged,fetch— свойfetchтолько для списка чатов),AgentPanelрисует кнопку, рамку и чат через скин. Хуки:useAgentPanel(open,openChat,openNewChat({ draft }),pageInset— отступ страницы в режимеpush, на экране ≤ 640 px он 0,busy),useAgentPlace({ description, data? })(где человек; модель видит только описание,data— только инструменты),useAgentDataChanges(tag, handler)(страница узнаёт, что инструмент изменил данные). Браузерные инструменты — функции вbrowserToolsс теми же именами, что описания на сервере (BrowserToolImplementations); нет функции — модель получает «Инструмент недоступен на этой странице» (надписьagentPanelToolUnavailable). Подробно — docs/getting-started.md. Общие части:AgentAppBridge(связь чата с приложением;appBridgeвAgentChat,useAgentChat,ChatWorkspace),initialDraft(текст в поле ввода при открытии чата),rememberKeyвuseChatList(свой ключ запомненного чата). Договор скина версии 12 — отдельныйAgentPanelSkin(AgentPanelLauncher,AgentPanel), надписиagentPanel…в словаре;ChatSkin,ChatListSkinиMemorySkinне менялись (contracts/skin.md).messageActions— свои кнопки под ответами агента (AgentChat,ChatWorkspace,AgentPanel):{ id, label, icon?, visible?, onRun }. Иконка — готовый элемент приложения (<Heart />), скин кладёт её в кнопку как есть.visible({ message, status, last })— показывать ли у этого ответа (нет — у каждого законченного).onRun({ chatId, messageId, message, text }): пока обещание не выполнено, кнопка «занята» и второй раз не жмётся; ошибка — в консоль, не в чат. Пока ответ пишется, кнопок у него нет. Массив держите постоянным (вне компонента или вuseMemo), иначе лента перерисовывает все ответы. СкинуAgentMessageуходитactions(MessageActionView[], договор версии 17; скин версии 16 их просто не рисует).const actions: MessageAction[] = [ { id: "like", label: "Нравится", icon: <Heart />, visible: ({ status }) => status === "complete", onRun: ({ messageId }) => api.like(messageId) }, ]; <AgentChat api="/api/chat" chatId={chatId} skin={tandemSkin} messageActions={actions} />;Поправка на лету (включена у скина с
steering: true, у Тандема — да): пока агент отвечает, поле свободно, поправка встаёт в ленту с пометкой «Агент учтёт на следующем шаге» (steeringPendingв словаре) и уходит наPOST …/steer.steering={false}— выключить. УuseAgentChat— опцияsteering: true(по умолчанию выключено),canSteerиcorrections(ждущие поправки, рисовать послеmessages);submit()во время ответа шлёт поправку. Скину —ChatSkin.steering,Composer.canSteerиUserMessage.pending(договор v21, docs/skin-v21.md).liveDemos— живые демонстрации: блок```html-demoв ответе запускается в изолированной рамке во всю ширину ответа (на сервере —agent.liveDemos: true). Скину —AgentText.renderDemoиChatSkin.demoColors(договор v22, docs/live-demos.md).Значки шагов и заголовки раздумий (с 0.69, договор скина v28, docs/skin-v28.md): в простом виде ряд значков у «Сделано N шагов» и список «значок + фраза»; значок своему инструменту —
iconрядом с фразамиstepPhrases(StepIcon), без него —guessStepIconпо имени. Заголовки раздумий приходят с сервера (thoughtTitles) и показываются сами — в простом и подробном виде (docs/work-view.md).Английский интерфейс:
<ChatLabelsProvider labels={englishLabels}>— полный словарь (см. docs/getting-started.md, «Надписи»).onTurnFinished({ chatId, messageId, status })— ход закончен на экране: один раз на ход, когда ответ получил итог, включая остановку и ошибку. Ответ, закрытый ради поправки на лету, получает своё событие перед ответом на поправку. Открытие чата с готовой историей события не даёт. На сервере то же —onTurnFinishedуcreateChatHandler.Агент внутри продукта (docs/in-product-agent.md):
subject="lesson:42"— чат объекта: id чата берётся у сервера (POST …/subject). Вместе сchatIdне задавать.onTurnRefused({ kind, code, message })— отказ приложения (turn_refused), в доступе (access_refused), закрытый чат (chat_closed), запрещённое действие (action_forbidden,code— право) или отказ в новом чате (chat_creation_refused); текст возвращается в поле.- Права и закрытый чат: кнопки по
capabilitiesиз истории чат прячет сам, закрытый чат блокирует поле и показывает текст закрытия (скину —ChatClosedNotice, договор v20, docs/skin-v20.md).onChatAccessChange({ capabilities, closed })сообщает о каждой смене; уuseAgentChat—capabilitiesиclosed.ChatWorkspaceприchatList: falseпоказывает только чат: список появляется после первой истории и при запрете больше не запрашивается. После 403action_forbiddenправа перечитываются. Закрытый чат в списке помечен (ChatListEntry.closed, надписьchatListClosed). В закрытом чате правка, «Переделать» и стрелки веток доступны по правам: они уводят с точки закрытия. - Состояние чата (
model.state):onStateChange(state)уAgentChat— после загрузки и при каждой смене (запись в потоке ответа, ветка, правка, событиеstate-changedиз другой вкладки); внутри ленты —useChatState(), уuseAgentChat—chatState. Служебные ключи ($…) не показываются. - Отказ в новом чате (
beforeCreateChatна сервере):useChatList—creationRefusalиonCreateRefused,ChatWorkspaceотдаёт его вonTurnRefusedс видомchat_creation_refused, текст отказа виден в списке. reloadKey(AgentChat,useAgentChat,ChatWorkspace): новое значение — история перечитывается с сервера. Нужно, когда чат изменили не через этот экран, например открыли снова из кода.onChatUnavailableуAgentChat— служебный, дляChatWorkspace.appEvents={{ type: View }}рисует события приложения внутри ответа,onAppEvent(event, { live, messageId? })сообщает о каждом.composerActions— свои кнопки у поля: узел или(composer) => …сvalue,setValue,send(text?),addFiles,busy,blocked,canSend. Скину —Composer.actions(договор v19, docs/skin-v19.md); скин v18 работает без кнопок.
useAgentChat— то же поведение без готовой сборки ленты, если нужна своя. Для веток:forks,selectBranch(messageId),canRegenerate,regenerate(),canEdit,edit(messageId, text)(отвечаетPromise<EditOutcome>: правка началась, не началась или сервер отказал).
Пакет не зависит от дизайн-системы и не импортирует стилей. Договор скина (версия 12: панель агента, отдельный AgentPanelSkin, см. выше; версия 11: экран памяти, отдельный MemorySkin, см. выше; версия 9: подтверждение действий — contracts/approvals.md; версия 8: необязательные SpendingNotice (с id, состояние none) и Composer.sendingBlocked с sendingBlockedReasonId; версия 6: ветки и правка — contracts/branches-and-edit.md) —
contracts/skin.md (дополняет версию 2), словарь —
contracts/labels.md и
дополнение к нему.
Лента рисует только видимое
Длинный чат открывается сразу внизу: на странице стоят только сообщения у экрана (не больше
40 даже в чате из 2000 сообщений). Для этого пакет берёт библиотеку
virtua, версия закреплена точно (0.52.8) и импортируется
только в src/message-feed.tsx. Ставить её отдельно не нужно.
Из-за этого прокруткой управляет чат, а не скин. Что нужно от своего скина:
LayoutвешаетscrollRefна прокручиваемый элемент и кладётfeedвнутрь; лента внутриrole="log"сaria-live="off";announcement— в скрытый элемент сaria-live="polite";- промежутки между сообщениями —
paddingкорня, неgapи неmargin; ReasoningиToolCallRowне хранят раскрытие сами, а показываютopenи зовутonOpenChange: раскрытое остаётся раскрытым, даже если сообщение уходило за край экрана;- стили не зависят от «последний ли элемент» (
:last-child,:has()).
Живая связь
Сервер шлёт пинги, вкладка замечает тишину и сама переподключается без повторов и пропусков.
Состояние — connection: "online" | "reconnecting" | "offline" у useAgentChat, для скина —
компонент ConnectionNotice. Сроки — проп liveness:
<AgentChat
api="/api/chat"
chatId={chatId}
skin={tandemSkin}
liveness={{
silenceTimeoutMs: 30_000, // сколько тишины терпеть в потоке ответа
reconnectDelaysMs: [0, 2000, 5000], // паузы перед попытками; после трёх неудач — «Нет связи с сервером»
requestTimeoutMs: 10_000, // срок одной попытки и любого запроса без потока
}}
/>Срок тишины никогда не меньше трёх пингов сервера (heartbeatIntervalMs, по умолчанию 10 с).
Отказ сервера — не обрыв связи. Вход истёк (401) — надпись «Вход истёк. Войдите снова» с «Повторить», без попыток
переподключения. У человека уже идут ответы в других чатах (429 too_many_turns) — своя надпись, текст возвращается в поле.
Сервер не признал страницу своей (403 forbidden_origin: фронт и API на разных адресах, а адреса фронта нет в allowedOrigins
сервера) — надпись «Сервер чата не принимает сообщения с этой страницы», текст возвращается в поле; в режиме разработки
(process.env.NODE_ENV !== "production") в консоли подсказка, какой адрес вписать.
Состояние — refusal: "signed_out" | "too_many_turns" | "forbidden_origin" | null у useAgentChat; скин получает их в ErrorNotice как kind
(текст — labels.errors[kind]).
Сбой ответа подписывается labels.errors[kind]. Своя подпись — функция labels.errorText(error, plain): error — вид и,
если сервер их отдал (право errorDetails), подробности details (providerId, modelId, statusCode, reason,
resetsAt); plain — errors[kind]. Текст уходит скину в ErrorNotice как text и диктору. Функция бросила
исключение — подпись plain и одна запись в консоль. Например, админу:
const labels = {
...russianLabels,
errorText: (error, plain) =>
error.details?.resetsAt ? `${plain}. Предел снимут ${new Date(error.details.resetsAt).toLocaleDateString("ru")}` : plain,
};Ответ брошен упавшей копией сервера — экран сам перечитывает историю раз в 3 с и через 30 с показывает «прерван» с «Повторить»; обновлять страницу не нужно. Подробности — contracts/liveness.md.
Подключение целиком: docs/getting-started.md.
Журнал изменений и что делать при обновлении: CHANGELOG.md. Все пакеты SDK выходят вместе и с одной версией; обновление — pnpm update "@ykurilov/agent-chat-*" --latest.
