Skip to Content
Руководство для разработчиковDaemon UIDaemon UI SDK — Руководство разработчика

Daemon UI SDK — Руководство разработчика

Подпуть @qwen-code/sdk/daemon предоставляет общие UI-примитивы для daemon-клиентов. На данный момент целевым потребителем является веб-чат и веб-терминал; нативные локальные TUI, каналы и IDE-интеграции сохраняют свои текущие пути по умолчанию, пока контракт daemon UI стабилизируется. В этом руководстве описывается API, представленный в PR #4353 (объединённое продолжение общего UI-слоя транскриптов из PR #4328).

Трёхуровневая модель

Daemon SSE wire (NDJSON конверты) normalizeDaemonEvent(envelope) → DaemonUiEvent[] reduceDaemonTranscriptEvents(state, events) → DaemonTranscriptState │ { blocks, currentToolCallId, │ approvalMode, toolProgress, ... } daemonBlockToMarkdown(block) / ToHtml / ToPlainText ← здесь подключаются ваши рендеры
  • Нормализатор: принимает сырые SSE-конверты daemon, возвращает типизированные UI-события
  • Редуктор: накапливает события в конечный автомат транскрипта
  • Вспомогательные функции рендера: проецируют блоки состояния в строки для рендеринга

Быстрый старт

import { DaemonSessionClient, createDaemonTranscriptStore, normalizeDaemonEvent, daemonBlockToMarkdown, selectCurrentTool, selectApprovalMode, } from '@qwen-code/sdk/daemon'; const session = await DaemonSessionClient.createOrAttach(client, { workspaceCwd, }); const store = createDaemonTranscriptStore(); for await (const envelope of session.events({ signal })) { const events = normalizeDaemonEvent(envelope, { clientId: session.clientId, suppressOwnUserEcho: true, }); store.dispatch(events); } // Чтение состояния из любого подписчика store.subscribe(() => { const state = store.getSnapshot(); const currentTool = selectCurrentTool(state); const mode = selectApprovalMode(state); const markdown = state.blocks.map(daemonBlockToMarkdown).join('\n\n'); myRenderer.render({ markdown, currentTool, mode }); });

Таксономия событий (28+ типов)

DaemonUiEvent — это размеченное объединение всех UI-событий:

События чат-потока

СобытиеКогда
user.text.deltaФрагмент сообщения пользователя пришёл от daemon
assistant.text.deltaСтриминг-фрагмент ассистента
assistant.doneЗавершение промпта (из resolve sendPrompt)
thought.text.deltaФрагмент рассуждений агента
tool.updateЖизненный цикл вызова инструмента (запущен / завершён / отменён)
shell.outputstdout/stderr фрагмент от shell-инструмента
permission.requestИнструменту требуется авторизация пользователя
permission.resolvedРешение по разрешению поступило
model.changedМодель сессии переключена
status / debug / errorБлоки статуса / отладки / ошибки

События метаданных сессии (PR-A)

СобытиеКогда
session.metadata.changedОбновлён заголовок / отображаемое имя сессии
session.approval_mode.changedРежим одобрения переключён (plan / default / yolo / auto-edit)
session.available_commandsСписок слэш-команд обновлён

События рабочего пространства (PR-A, Волна 3-4)

СобытиеКогда
workspace.memory.changedQWEN.md / memory файл изменён
workspace.agent.changedСаб-агент создан / обновлён / удалён
workspace.tool.toggledВстроенный инструмент включён / выключен
workspace.initializedqwen init завершён
workspace.mcp.budget_warningКоличество дочерних процессов MCP приближается к лимиту
workspace.mcp.child_refusedMCP сервер отказан из-за достижения бюджета
workspace.mcp.server_restartedРучная перезагрузка MCP выполнена успешно
workspace.mcp.server_restart_refusedРучная перезагрузка заблокирована

События OAuth Device Flow (PR-A, Волна 4 OAuth)

auth.device_flow.{started,throttled,authorized,failed,cancelled}

Каждое содержит deviceFlowId от daemon. События с ошибкой содержат закрытое перечисление errorKind (closed enum — см. KNOWN_DEVICE_FLOW_ERROR_KINDS из @qwen-code/sdk/daemon для канонического списка; на данный момент: expired_token / access_denied / invalid_grant / upstream_error / persist_failed / not_found_or_evicted).

Контракт рендера (PR-D)

Три вспомогательные функции проецирования, одна функция для предпросмотра. Все различаются по block.kind или preview.kind:

```ts daemonBlockToMarkdown(block, { sanitizeUrls?, maxFieldLength?, locale? }) daemonBlockToHtml(block, { sanitizer?, ...renderOpts }) daemonBlockToPlainText(block, renderOpts) daemonToolPreviewToMarkdown(preview, renderOpts)

Рецепт: рендеринг транскрипта в markdown

const markdown = state.blocks .map((b) => daemonBlockToMarkdown(b, { sanitizeUrls: true })) .join('\n\n');

Рецепт: рендеринг в санитизированный HTML для SSR

import DOMPurify from 'dompurify'; import MarkdownIt from 'markdown-it'; const md = new MarkdownIt(); const html = state.blocks .map((b) => { // Two-stage pipeline: markdown → HTML → DOMPurify const rawHtml = md.render(daemonBlockToMarkdown(b)); return DOMPurify.sanitize(rawHtml); }) .join('\n');

Или используйте встроенный консервативный HTML-рендерер (без парсинга markdown, только HTML-экранирование):

const html = state.blocks .map((b) => daemonBlockToHtml(b, { sanitizer: DOMPurify.sanitize })) .join('\n');

Рецепт: копирование простого текста

const plain = state.blocks.map(daemonBlockToPlainText).join('\n'); navigator.clipboard.writeText(plain);

Таксономия превью инструментов (13 видов)

ТипОтображение
ask_user_questionВопрос с множественным выбором и вариантами
commandКоманда в стиле Bash + текущая директория
file_diffРедактирование файла с oldText/newText или патч
file_readПуть + опциональный диапазон строк
web_fetchURL + HTTP-метод
mcp_invocationMCP-сервер + инструмент + сводка аргументов
code_blockФрагмент кода с указанием языка
searchЗапрос + количество результатов + топ результатов
tabularКолонки + строки (макс. 50, помечено усечение)
image_generationПромпт + опциональный URL миниатюры
subagent_delegationИмя агента + задача
key_valueОбобщённые строки «ключ–значение»
genericРезервная сводка

У каждого есть проекция daemonToolPreviewToMarkdown. Пользовательские рендереры могут диспетчеризовать по preview.kind для богатого отображения каждого типа (разница файлов с подсветкой синтаксиса, значок MCP-сервера, миниатюра изображения и т.д.).

Селекторы состояния (PR-E)

selectCurrentTool(state); // → DaemonToolTranscriptBlock | undefined selectApprovalMode(state); // → 'plan' | 'default' | 'auto-edit' | 'yolo' | undefined selectToolProgress(state, toolCallId); // → { ratio?, step? } | undefined selectPendingPermissionBlocks(state); // → ReadonlyArray<DaemonPermissionTranscriptBlock> selectTranscriptBlocks(state); // → ReadonlyArray<DaemonTranscriptBlock> selectTranscriptBlocksOrderedByEventId(state); // sorted by daemon-monotonic id // PR-K — вложенность под-агентов selectSubagentChildBlocks(state, parentToolCallId); // direct children only isSubagentChildBlock(block); // type guard: was this tool invoked inside a sub-agent?

currentToolCallId автоматически поддерживается редьюсером:

  • Устанавливается, когда инструмент переходит в статус выполнения (running / in_progress / pending / confirming)
  • Сбрасывается, когда инструмент переходит в конечный статус (completed / failed / cancelled / и т.д.)
  • Неизвестные статусы оставляют его без изменений (совместимость вперёд)

Распространение отмены (PR-E)

Когда assistant.done.reason === 'cancelled', редьюсер проходит по всем выполняющимся блокам инструментов и принудительно устанавливает их статус в 'cancelled'. Демон не гарантирует финальное tool_call_update для каждого выполняющегося инструмента при отмене родительского промпта — такое распространение предотвращает бесконечное вращение спиннеров в UI.

Дочерние элементы под-агентов отменяются вместе с родителем, так как отмена перебирает все выполняющиеся блоки инструментов в toolBlockByCallId, а не только текущий указатель.

Вложенные под-агенты (PR-K)

Когда главный агент делегирует задачу под-агенту (инструмент Task или аналог), демон проставляет parentToolCallId и subagentType в дочерние вызовы инструментов через tool_call._meta. Редьюсер читает оба поля и:

  • Копирует parentToolCallId + subagentType в DaemonToolTranscriptBlock
  • Разрешает parentBlockId (идентификатор id родительского блока транскрипта), когда родительский блок уже есть в состоянии; в противном случае оставляет undefined и заполняет позже, когда родительский блок появляется

Обработка прихода в неправильном порядке (дочерний элемент раньше родителя) выполняется прозрачно. Дочерний элемент, чей родитель был обрезан maxBlocks, сохраняет parentToolCallId для запросов селектора, но parentBlockId обнуляется (повисший идентификатор больше не разрешается через blockIndexById).

import { selectSubagentChildBlocks, isSubagentChildBlock, } from '@qwen-code/sdk/daemon'; // Render a parent tool block, then walk children: function renderToolBlock(state, block) { if (block.kind !== 'tool') return renderOther(block); const children = selectSubagentChildBlocks(state, block.toolCallId); return ( <ToolBlock block={block}> {children.length > 0 && ( <Indent> {children.map((c) => renderToolBlock(state, c))} </Indent> )} </ToolBlock> ); } // Or filter top-level vs. nested at render time: const topLevel = state.blocks.filter((b) => !isSubagentChildBlock(b));

selectSubagentChildBlocks возвращает только прямые дочерние элементы. Обходите рекурсивно, чтобы отобразить вложенные под-агенты (под-агент внутри под-агента). Демон не генерирует циклы, но рендеры, проходящие вверх через parentBlockId, всё равно должны их обнаруживать защитно (например, ограничение глубины или множество посещённых).

Самоссылки (parentToolCallId === toolCallId) отбрасываются нормализатором до того, как достигают редьюсера.

Временна́я семантика (PR-B)

interface DaemonTranscriptBlockBase { eventId?: number; // PRIMARY sort key — daemon-monotonic serverTimestamp?: number; // PREFERRED display — daemon-authoritative clientReceivedAt: number; // FALLBACK — local clock createdAt: number; // @deprecated alias for clientReceivedAt }

Всегда сортируйте по eventId (используйте selectTranscriptBlocksOrderedByEventId) при отображении длинных сессий. Монотонный курсор демона сохраняется при повторном воспроизведении SSE после переподключения; клиентские часы — нет.

Всегда форматируйте отображаемые временные метки из serverTimestamp (с откатом на clientReceivedAt). Несколько клиентов, просматривающих одну и ту же сессию, видят одинаковое «5 минут назад» только при чтении с часов демона.

import { formatBlockTimestamp } from '@qwen-code/sdk/daemon'; const label = formatBlockTimestamp(block, { locale: 'zh-CN', timeZone: 'Asia/Shanghai', timeStyle: 'short', });

Соответствие адаптера (PR-G)

Проверьте, что ваш адаптер проецирует эталонный корпус SDK на семантически эквивалентный вывод:

import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon'; it('my adapter conforms to daemon UI corpus', () => { const result = runAdapterConformanceSuite({ reduce: (events) => myReducer(events), renderToText: (state) => myRenderer(state), }); expect(result.failed).toEqual([]); });

Корпус фикстур (DAEMON_UI_CONFORMANCE_FIXTURES) охватывает чат, жизненный цикл инструментов, правки файлов, MCP, разрешения, предупреждения о превышении бюджета MCP, отмену, редактирование некорректных полезных нагрузок, OAuth, обновления команд и вложенность под-агентов. (Количество можно получить во время выполнения — прочитайте DAEMON_UI_CONFORMANCE_FIXTURES.length.)

Формат-независимый — ваш адаптер может рендерить в ANSI / HTML / Markdown / JSX; фреймворк проверяет только семантическое содержимое через expectedContains и expectedAbsent.

Категоризация ошибок (PR-A)

DaemonUiErrorEvent.errorKind — это закрытое перечисление, распространяемое из таксономии типизированных ошибок демона (когда демон его помечает):

import type { DaemonErrorKind } from '@qwen-code/sdk/daemon'; // 'missing_binary' | 'blocked_egress' | 'auth_env_error' | 'init_timeout' // | 'protocol_error' | 'missing_file' | 'parse_error' | 'budget_exhausted'

Рендеры должны ветвиться по errorKind для предоставления действенных вариантов поведения (affordances):

function errorAffordance(errorKind?: DaemonErrorKind): React.ReactNode { switch (errorKind) { case 'auth_env_error': return <button>Re-authenticate</button>; case 'missing_file': return <button>Choose file</button>; case 'blocked_egress': return <span>Network blocked — check proxy</span>; default: return null; } }

Диспетчеризация происхождения инструментов (PR-A)

DaemonUiToolUpdateEvent.provenance — закрытое перечисление (builtin / mcp / subagent / unknown). С serverId?: string, когда mcp. Используйте его для диспетчеризации иконок и значков-бейджей:

function toolIcon(event: DaemonUiToolUpdateEvent): React.ReactNode { switch (event.provenance) { case 'mcp': return <McpIcon server={event.serverId} />; case 'subagent': return <SubagentIcon />; case 'builtin': return <BuiltinIcon name={event.toolName} />; default: return <GenericIcon />; } }

В SDK есть эвристика отката по именованию mcp__<server>__<tool> — даже когда демон явно не указывает происхождение, инструменты MCP можно обнаружить.

Принципы прямой совместимости

Каждый слой в daemon UI SDK следует принципу прямой совместимости: неизвестные значения НЕ вызывают исключений; они деградируют корректно.

  • Неизвестные типы событий демона → событие debug с сырым именем типа
  • Неизвестный статус инструмента → currentToolCallId остаётся нетронутым (без сброса)
  • Неизвестный тип ошибки → errorKind не определён (рендер откатывается к тексту)
  • Отсутствует serverTimestamp → откат к clientReceivedAt
  • Неизвестная форма предпросмотра → тип generic с summary

Это означает, что SDK может поставляться раньше эмиссии демона. Эвристика происхождения инструментов из PR-A, извлечение временных меток из трёх мест из PR-B и сохранение неизвестного статуса из PR-E — все это примеры «готово, когда демон отправляет; безопасно, когда нет».

Перекрёстные ссылки

  • PR #4328  — основной PR с общим уровнем транскриптов UI
  • PR #4353  — этот PR (унифицированный follow-up для полноты)
  • Issue #3803  — предложение режима демона
  • Issue #4175  — трекер реализации Mode B v0.16
Last updated on