enВойти в Senler

Собственный интерфейс чата

Сайт может сам рисовать список диалогов, сообщения и поле ввода. SDK обслуживает авторизацию посетителя, отправку, историю и подписки. Сначала подключите совместимый пакет и загрузчик; справочник методов содержит их параметры.

Режим и подключение

headless: true отключает плавающую кнопку и готовый интерфейс чата. Невидимый iframe продолжает работать: это не HTTP-клиент без iframe. open() и toggle() такой экземпляр не показывают. Параметр задаётся при запуске, несовместим с button_only и embedded.

import {
  createSenlerWidget,
  mergeWidgetMessages,
  type SenlerWidgetMessage,
  type SenlerWidgetDialogState,
  type SenlerWidgetWelcomePreview,
} from "@senlerio/widget";

const widget = await createSenlerWidget({
  src: "LOADER_URL_FROM_CABINET",
  channelId: "CHANNEL_ID",
  headless: true,
  lang: "ru",
});

Замените адрес загрузчика и ID канала значениями из кода встраивания. Методы истории и подписок доступны и с обычным интерфейсом: для него уберите headless. В React используйте провайдер и хуки.

Выбор диалога

const dialogs = await widget.getDialogs({ limit: 20 });
const initialDialogId = dialogs.items[0]?.id;

async function createDialog() {
  connectDialog(await widget.startNewDialog());
}

Пример выбирает первый видимый диалог. Если его нет, показывает приветствие и создаёт диалог при первом вопросе — этот шаг приведён ниже. Все блоки TypeScript в статье составляют один пример; подключите обработчики формы после его инициализации. Для выбора другого диалога сайт вызывает connectDialog(id), для явного создания пустого — createDialog() отдельной кнопкой. Это функции примера на стороне сайта.

Для повторных служебных запросов используйте getOrCreateDialog, затем передавайте поле id результата как dialogId. Например, отдельный обработчик быстрых правок может вызывать такую функцию:

function getEditingDialog() {
  return widget.getOrCreateDialog({
    ref: "text-edit",
    showInWidgetHistory: false,
  });
}

Параметр showInWidgetHistory применяется только при создании: найденный диалог сохраняет свою видимость. Новый диалог с showInWidgetHistory: false скрыт из истории виджета, но остаётся в кабинете.

Страница списка содержит items, hasMore, nextCursor и при поиске nextOffset. Каждый элемент имеет id, title, необязательный avatarUrl, число непрочитанных unreadCount и версию revision.

Обычную следующую страницу запрашивайте через before: dialogs.nextCursor; при поиске передавайте тот же query и offset: dialogs.nextOffset. Поисковая строка содержит от 2 до 500 символов; limit — от 1 до 100. Скрытые диалоги в список не входят. Обновляйте список по событию dialogsChanged.

История и новые сообщения

Добавьте на страницу элементы для примера:

<ol id="messages"></ol>
<p id="chat-status" role="status"></p>
<button id="retry-subscription" type="button" hidden>Повторить подключение</button>

Подписка сразу получает начальную историю и дальнейшие сообщения, включая сообщения посетителя из других вкладок. Не загружайте начальную историю отдельным запросом перед подпиской: между этими операциями можно пропустить сообщение.

const list = document.querySelector<HTMLOListElement>("#messages")!;
const status = document.querySelector<HTMLElement>("#chat-status")!;
const retry = document.querySelector<HTMLButtonElement>("#retry-subscription")!;
let dialogId: string | undefined;
let messages: SenlerWidgetMessage[] = [];
let state: SenlerWidgetDialogState | undefined;
let olderCursor: string | undefined;
let snapshotVersion = 0;
let generation = 0;
let ready = false;
let disposed = false;
let unsubscribe: (() => void) | undefined;

function render() {
  list.replaceChildren(...messages.map(message => {
    const item = document.createElement("li");
    item.textContent = message.deleted
      ? "Сообщение удалено"
      : message.kind === "error"
        ? message.error?.message ?? "Ошибка сообщения"
        : message.text || "Сообщение с вложением или кнопками";
    return item;
  }));
}

function renderState() {
  status.textContent = state?.typing.length ? "Готовится ответ…" : "";
}

function connectDialog(id: string) {
  if (disposed) return;
  const current = ++generation;
  snapshotVersion++;
  ready = false;
  unsubscribe?.();
  dialogId = id;
  messages = [];
  state = undefined;
  olderCursor = undefined;
  retry.hidden = true;
  render();
  status.textContent = "Подключение…";

  unsubscribe = widget.subscribeDialog({
    dialogId: id,
    limit: 50,
    onUpdate(update) {
      if (disposed || current !== generation) return;
      if (update.type === "snapshot") {
        snapshotVersion++;
        ready = true;
        messages = update.messages.items;
        olderCursor = update.messages.hasMore ? update.messages.olderCursor : undefined;
        state = update.state;
        render();
        renderState();
      } else if (update.type === "message") {
        messages = mergeWidgetMessages(messages, [update.message]);
        render();
      } else if (update.type === "state") {
        state = update.state;
        renderState();
      } else if (update.type === "deleted") {
        generation++;
        snapshotVersion++;
        ready = false;
        messages = [];
        state = undefined;
        olderCursor = undefined;
        render();
        status.textContent = "Диалог удалён";
      }
    },
    onError(error) {
      if (disposed || current !== generation) return;
      generation++;
      snapshotVersion++;
      ready = false;
      retry.hidden = !(error.retryable === true || error.code === "timeout");
      status.textContent = `${error.code}: ${error.message}`;
    },
  });
}

retry.onclick = () => {
  if (dialogId && !retry.hidden) connectDialog(dialogId);
};

async function send(text: string) {
  if (disposed) throw new Error("Чат закрыт");
  if (!dialogId) return sendFirstQuestion(text);
  if (!ready) throw new Error("Дождитесь подключения");
  return widget.ask({ dialogId, text });
}

function dispose() {
  disposed = true;
  ready = false;
  generation++;
  snapshotVersion++;
  retry.onclick = null;
  unsubscribe?.();
  widget.destroy();
}

Обработчик формы сайта вызывает send(text) и обрабатывает отклонение Promise. Его результат содержит ответы и статусы по правилам ask. Список сообщений выше обновляется через подписку: не добавляйте те же ответы из Promise второй раз. Пример показывает итоговые тексты; отображение файлов, кнопок и формата сайт добавляет по данным сообщения.

В обработчике onUpdate поле type определяет событие: snapshot — история и состояние, message — сохранённое сообщение или его новая версия, state — состояние диалога, deleted — диалог удалён, chunk и streamEnded — потоковая выдача. У снимка reason равен initial при первом чтении или recovery после пропуска данных. Пример перерисовывает сообщения только при изменении истории; state обновляет отдельный индикатор, а chunk и streamEnded не меняют DOM.

Страницы истории и восстановление

Для кнопки «Ранее» используйте переменные из примера:

async function loadOlder() {
  if (!ready || !dialogId || !olderCursor) return;
  const version = snapshotVersion;
  const page = await widget.getMessages({ dialogId, before: olderCursor, limit: 50 });
  if (!ready || version !== snapshotVersion) return;
  messages = mergeWidgetMessages(messages, page.items);
  olderCursor = page.hasMore ? page.olderCursor : undefined;
  render();
}

Обработчик кнопки должен обрабатывать ошибку Promise и блокировать повторную загрузку до завершения текущей. Страница содержит items, hasMore, olderCursor, newerCursor; сообщения идут от старых к новым. limit — от 1 до 200, по умолчанию 50. Для чтения более новых сообщений передайте after: page.newerCursor. before и after вместе не передаются.

При переподключении без потерь загруженные страницы сохраняются. Новый snapshot после пропуска данных заменяет историю: старые страницы нужно загрузить заново. Счётчик snapshotVersion в примере не даёт запоздалому запросу вернуть страницы от прежнего снимка. Не запрашивайте всю историю для восстановления.

subscribeDialog возвращает функцию отписки сразу. timeoutMs ограничивает только начальную синхронизацию. Кратковременные разрывы SDK восстанавливает сам; onError сообщает, что подписка завершилась. При временной ошибке или таймауте пример показывает кнопку «Повторить подключение»: она вызывает connectDialog с тем же ID, получает новый снимок и разрешает отправку после него. Вопрос повторно не отправляется. Ошибки доступа и удаление диалога требуют устранить причину или выбрать другой диалог; автоматического цикла повторов нет.

При переключении диалога connectDialog отписывается от прежнего и сбрасывает его страницы. Счётчики защищают интерфейс от старых обработчиков и запоздалой загрузки истории. destroy() нужен только при удалении всего экземпляра.

Потоковая выдача

Постепенный показ ответа зависит от настроек агента. Сайт может обрабатывать chunk или ждать сохранённый message, как в примере выше.

У chunk есть dialogId, streamId, index, text, необязательные iterationId и requestId. Храните черновики по streamId; requestId, если известен, относится к исходному SDK-запросу. Обновление message также может содержать streamId и requestId: замените соответствующий черновик сохранённым сообщением.

streamEnded завершает один поток. Поле status принимает completed, failed, skipped или scheduled; дополнительно возможны eventId, reason, silent, error с code и message. При успешном завершении уберите индикатор генерации и оставьте черновик до прихода сообщения. Завершение может прийти раньше или позже сохранённого сообщения; не создавайте черновик заново, если он уже заменён. При ошибке, пропуске, тихом завершении или отложенном ответе уберите черновик.

Чанки не восстанавливаются после перезагрузки или пропуска данных. На снимке восстановления очистите черновики, покажите актуальный набор ответа из state.typing и ждите сохранённое сообщение. Завершение потока не означает завершение всех ответов на вопрос: это определяет Promise ask.

Настройки и состояние диалога

getSettings() возвращает:

  • features: доступность fileUpload, voiceMessages, emoji;
  • canStartAgent, creditLimitExhausted, remainingCredits: возможность запуска и доступные кредиты; 10 000 серверных единиц равны 1 кредиту, null не означает ноль;
  • welcomeText и welcomeButtons: локализованный текст приветствия и начальные вопросы.

Перечитывайте настройки по settingsChanged; состояние соединения показывайте через connectionChanged. Сервер проверяет разрешения независимо от того, какие кнопки показывает сайт.

getDialogState({ dialogId, ref? }) получает то же состояние, которое приходит в подписке: dialogId, unreadCount, revision, typing, queueEnabled, pending, необязательный pausedReason (error, limit, muted). По явному ID можно работать и со скрытым диалогом. Если вопросы отправляются с ref, передавайте его и в getDialogState / subscribeDialog: сервер определяет правила очереди с учётом агента, выбранного по этому ref. Без него используется ref диалога. Выбор и создание диалога описаны в отправке сообщений.

Элемент typing содержит id, необязательные name, expiresAt и признак streaming. Для набора посетителя вызывайте setTyping({ dialogId, typing: true }), ограничивая частоту на сайте; при бездействии или потере фокуса отправляйте typing: false.

Вызывайте markRead({ dialogId }), когда сайт действительно показывает сообщения посетителю: метод отмечает прочитанными текущие сообщения диалога. Невидимый iframe не делает это за интерфейс сайта. Проверяйте видимость своей панели и вкладки.

Очередь вопросов

При queueEnabled список pending содержит ожидающие вопросы с полями id, revision, text, createdAt, sending, attachments (массив объектов с id). Отображайте очередь отдельно от сохранённых сообщений.

Пример изменения первого ожидающего вопроса использует state из подписки:

async function editFirstQuestion(text: string) {
  if (!ready || !dialogId) return;
  const question = state?.pending.find(item => !item.sending);
  if (!question) return;
  await widget.updateQueuedQuestion({
    dialogId,
    questionId: question.id,
    revision: question.revision,
    text,
  });
}

Передавайте версию самого вопроса: она защищает от перезаписи изменений из другой вкладки. При конфликте обновите состояние через getDialogState и предложите пользователю проверить текст. cancelQueuedQuestion({ dialogId, questionId }) удаляет ещё не отправленный вопрос; sendQueuedQuestion({ dialogId, questionId }) выполняет «Отправить сейчас». Эти методы не останавливают уже работающего агента. Состояние очереди обновляется в подписке; команды очереди одного диалога выполняются последовательно.

Приветствие без создания диалога

getWelcome() возвращает mode (centered_text или automation_message), draftId, необязательные token, expiresAt, message. Завершите пример инициализацией: существующий диалог получает подписку, а новый посетитель пока видит только приветствие. Для centered_text текст берётся из getSettings().welcomeText.

let welcome: SenlerWidgetWelcomePreview | undefined;
let starting = false;

async function sendFirstQuestion(text: string) {
  if (starting) throw new Error("Первый вопрос уже отправляется");
  starting = true;
  const current = generation;
  const attach = (id: string | undefined) => {
    if (id && !disposed && current === generation) connectDialog(id);
  };
  try {
    if (welcome?.expiresAt && Date.parse(welcome.expiresAt) <= Date.now()) {
      welcome = await widget.getWelcome();
    }
    if (disposed || current !== generation) throw new Error("Диалог изменился");
    const result = await widget.ask({
      newDialog: true,
      text,
      welcome: welcome?.token
        ? { draftId: welcome.draftId, token: welcome.token }
        : undefined,
      onStatus: progress => attach(progress.dialogId),
    });
    attach(result.dialogId);
    return result;
  } finally {
    starting = false;
  }
}

if (initialDialogId) {
  connectDialog(initialDialogId);
} else {
  welcome = await widget.getWelcome();
  messages = welcome.message ? [welcome.message] : [];
  render();
  if (welcome.mode === "centered_text") {
    status.textContent = (await widget.getSettings()).welcomeText;
  }
}

Первый send(text) вызывает ask({ newDialog: true, ... }); для подписанного приветствия этот флаг обязателен. Как только onStatus сообщает dialogId, attach подключает подписку. Повторные статусы не пересоздают её; ID из результата служит запасным вариантом. Даже если ответ сохранился до подключения, он попадёт в начальный снимок. Следующие вопросы идут в тот же диалог. На время первого запроса блокируйте повторную отправку и смену диалога в форме сайта; после завершения проверьте итоговый статус ask, а при отклонении Promise покажите ошибку. Повтор самой отправки подчиняется правилам requestId, кнопка восстановления подписки его не выполняет.

Для callback-кнопки приветствия первый запрос передаёт text: "" и welcome: { draftId, token, buttonId: button.instanceId } с тем же подключением по onStatus. Если expiresAt истёк, обновите приветствие и его кнопки до выбора действия. Начальные вопросы из getSettings().welcomeButtons можно передавать в send(text). Кнопки уже сохранённых сообщений обрабатываются через clickButton.

Отложенные действия

getScheduledActions({ dialogId, cursor? }) возвращает enabled, items, nextCursor. У действия есть id, необязательный title, scheduledAt и status: pending или processing. Для следующей страницы передайте полученный nextCursor как cursor.

Для отмены ожидающего действия вызовите cancelScheduledAction({ dialogId, actionId }) и обновите список. Доступность и права совпадают с готовым чатом; уже выполняющееся действие не следует показывать как отменяемое.

Завершение работы

При удалении примера вызовите dispose(). При использовании дополнительных widget.on(...) сохраните и вызовите их функции отписки. Для выхода посетителя используйте resetSession, затем создайте экземпляр с данными нового посетителя.

Зарегистрированные действия сайта работают и в headless. Не запускайте их повторно при отображении истории: автоматическое выполнение обслуживает SDK. Встроенные действия навигации по готовому чату недоступны, поскольку его интерфейс не создан.