# Ассистент в Android и iOS SDK

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

**Вам нужно сделать три части интерфейса:** кнопку входа, товарную карусель и обработчик переходов. Экран чата, отправку сообщений, хранение текущего диалога и отправку событий ассистента берёт на себя Gravity SDK.

!!!info С чего начать
Если Gravity SDK уже установлен, начните с [настройки двух кампаний](#шаг-2-настройте-две-api-кампании). Если устанавливаете его впервые, сначала выполните шаг 1. [Сигнатуры методов](#методы-sdk) и подробные модели расположены после пошагового сценария.
!!!

## Шаг 1. Подготовьте SDK и раздел магазина

До написания кода ассистента проверьте следующие условия. Настройки кабинета может подготовить ваша команда маркетинга или менеджер Gravity Field; разработчику нужны их значения и опубликованные кампании.

| Что должно быть готово | Что получить или проверить | Инструкция |
| --- | --- | --- |
| Базовый SDK | SDK установлен и инициализирован один раз; известны API-ключ и `section` магазина. | [Android SDK](./sdk_android.md), [iOS SDK](./sdk_ios.md), [API-ключи](../api_integration/manage_api_keys.md) |
| Идентификация | Передаются просмотры экранов и события входа пользователя; SDK получает `user.uid` и сессию. | [Идентификация пользователей](../identification.md) и раздел идентификации в руководстве вашей платформы |
| Shopping Assistant | Подключён **тот же раздел**, чей ID указан в `section` SDK; сервис допускает обращения мобильного приложения. | [Что требуется для Shopping Assistant](../../shopping_assistant/shopping_assistant.md) |
| Товарный каталог | Фид синхронизирован, SKU и данные карточек корректны. | [Требования к товарному фиду](../products_catalogues/general_reqs.md) |
| Ответы ассистента | Тестовый вопрос в кабинете возвращает ожидаемые ответы и товары. | [Превью ассистента](../../shopping_assistant/personal_account/preview.md) |

Отдельно задавать пользователя для чата не нужно. Используйте обычную идентификацию Gravity SDK. Правила обмена `section`, `uid` и `threadId` описаны [ниже](#подключение-к-shopping-api).

## Шаг 2. Настройте две API-кампании

Кампания возвращает настройки интерфейса, которые SDK получает по **selector** — строковому имени кампании. В этой интеграции используются два разных selector:

| Кампания | Selector в примерах | Что управляется из кабинета | Кто читает JSON |
| --- | --- | --- | --- |
| Кнопка входа | `assistant-entry` | Показывать ли кнопку на экране и как её подписать. | Приложение через `getContentBySelector`. |
| Ассистент | `shopping-assistant` | Включён ли чат, его заголовок, приветствие и стартовые подсказки. | SDK внутри `prepareAssistant`. |

Имена в таблице — пример. Задайте свои имена в кабинете и передайте **те же строки** в приложение, без изменения регистра или пробелов.

### Создайте кампании в кабинете

Для каждой строки таблицы:

1. Откройте **Campaigns → API Campaigns** и создайте кампанию типа **Custom JSON**.
2. Укажите её **API-селектор**.
3. Добавьте **Experience** — сценарий с условиями показа. Для первого теста выберите условия, которым соответствует ваш тестовый пользователь и экран.
4. Создайте вариацию и вставьте подходящий JSON из примеров ниже в редактор её содержимого.
5. Активируйте и сценарий, и кампанию, затем нажмите **Опубликовать**.

Пошаговые экраны кабинета: [Создание API-кампании](../../personalization/Campaigns/api_campaigns.md). Если терминология незнакома: [кампания → сценарий → вариация](../../personalization/Campaigns/campaign_structure.md). Условия выбора настраиваются в [таргетинге и расписании](../../personalization/Campaigns/targeting_schedule.md); редактируемые тексты — через [переменные вариаций](../../personalization/Campaigns/variables.md).

!!!warning Одного сохранения JSON недостаточно
Неопубликованная кампания, неактивный сценарий или несовпадающий таргетинг могут дать пустой ответ. Проверьте публикацию **обеих** кампаний для того `PageContext`, с которым тестируете приложение.
!!!

### JSON кнопки входа

Для `assistant-entry`:

```json
{ "enabled": true, "title": "Спросите помощника" }
```

- `enabled: true` разрешает приложению подготовить ассистента на этом экране.
- `title` — подпись кнопки приложения. Её положение, иконку и внешний вид задаёте вы.
- При `enabled: false`, отсутствии данных, некорректном JSON или ошибке запроса кнопка остаётся скрытой.

### JSON ассистента

Для `shopping-assistant`:

```json
{
  "enabled": true,
  "title": "Помощник магазина",
  "subtitle": "Помогу выбрать подходящий товар.",
  "suggests": ["Помогите выбрать подарок"]
}
```

| Поле | Назначение | Если не задано |
| --- | --- | --- |
| `enabled` | Включает ассистента; тип Boolean. | Обязательное поле. |
| `title` | Заголовок внутри чата; заданная строка должна быть непустой. | `"Помощник"` |
| `subtitle` | Приветствие на стартовом экране. | Пустая строка. |
| `suggests` | Массив непустых строк. Нажатие отправляет выбранную строку как сообщение пользователя. | Пустой массив. |

При `enabled: false` остальные поля не используются, результат подготовки — `unavailable(disabled)`. Неизвестные поля игнорируются. Несколько разных конфигураций для одного selector — `invalid_configuration`.

В редактор вставляется **JSON-объект из примера**, без внешней обёртки `custom`. В ответе SDK этот JSON находится строкой в `CampaignContent.custom.json`. Стартовые подсказки приходят из этой кампании; подсказки после ответа — из `response.ui.suggests`. Подготовка не отправляет скрытое сообщение ассистенту.

## Шаг 3. Подключите товарный блок и переходы

До первой подготовки зарегистрируйте два объекта приложения:

| Что реализовать | Минимальная обязанность | Контракт |
| --- | --- | --- |
| `AssistantProductBlockBuilder` | Нарисовать заголовок и горизонтальную карусель на переданную ширину; сообщать SDK о видимости и нажатии товара. | [Товарный блок](#товарный-блок-приложения) |
| `AssistantActionHandler` | Проверить поддержку действия и открыть карточку товара, поддержку или URL средствами приложения. | [Действия и навигация](#действия-и-навигация) |

+++ Android

```kotlin
GravitySDK.instance.setAssistantProductBlockBuilder(productBlockBuilder)
GravitySDK.instance.setAssistantActionHandler(actionHandler)
```

+++ iOS

```swift
GravitySDK.instance.setAssistantProductBlockBuilder(productBlockBuilder)
GravitySDK.instance.setAssistantActionHandler(actionHandler)
```

+++

Здесь `productBlockBuilder` и `actionHandler` — ваши реализации интерфейсов, приведённых ниже. В builder можно использовать существующий компонент карточки магазина. Он получает товары от SDK: отдельный запрос каталога для заполнения карусели не требуется.

**При нажатии карточка сообщает `tap` SDK.** Затем SDK учитывает клик, закрывает чат и вызывает ваш handler. В handler выполните переход по SKU или URL. Не делайте второй переход прямо из карточки. Для поддержки обработайте `OpenSupportChat`, для ссылки — `FollowUrl`; неподдержанное действие возвращает `false` из `canHandle`.

!!!info Что SDK делает сам
SDK создаёт экран чата, показывает ввод и ответы, управляет вертикальной прокруткой и кнопкой «Новый чат». Ваша карусель управляет только горизонтальной прокруткой. При отсутствии builder или handler подготовка возвращает `invalid_configuration`.
!!!

## Шаг 4. Покажите кнопку после подготовки

На каждом экране, где нужен ассистент:

1. Сразу скройте кнопку предыдущего экрана и передайте `trackView(pageContext)`.
2. Вызовите `getContentBySelector("assistant-entry", pageContext)`.
3. Прочитайте JSON точки входа. Если она включена, вызовите `prepareAssistant("shopping-assistant", pageContext)`.
4. Только после `ready` для **текущего экрана** покажите кнопку с `entry.title`.
5. При нажатии вызовите `openAssistant` с видимой Activity или UIViewController.

`PageContext` должен описывать экран, с которого открывается чат: например, `PRODUCT` и SKU для карточки товара или `CATEGORY` и данные категории для каталога. Поля и примеры: [PageContext Android](./sdk_android.md#pagecontext), [PageContext iOS](./sdk_ios.md#pagecontext), [правила контекста API V2](../api_integration/v2/context.md). Не меняйте регистр SKU, категорий и региона относительно фида.

`prepareAssistant` не заменяет `trackView` и не отправляет его повторно. Получение данных кампании само по себе не показывает кнопку: отрисовка точки входа остаётся в приложении.

### Как прочитать настройку точки входа

Ответ `getContentBySelector` содержит выбранный контент по пути:

```text
ContentResponse.data[]
  → campaign.payload[]
    → variation.contents[]
      → content.custom.json  // строка с JSON, которую нужно разобрать
```

Для точки входа используйте один выбранный контент своей кампании. Не объединяйте вариации разных кампаний. `parseEntry` в примере ниже — функция приложения: она разбирает эту строку и возвращает `enabled` и `title`; отсутствие или ошибка разбора означает, что кнопка не показывается. JSON второго selector приложение не разбирает — это делает `prepareAssistant`.

### Полный порядок с защитой от смены экрана

Это псевдокод управления экраном. `generation`, задача экрана и видимость кнопки принадлежат приложению. В Kotlin используйте `lifecycleScope`/`Job`, в Swift — `Task` на `MainActor`. Поколение меняется **до** запроса точки входа, а не только перед подготовкой.

```text
entrySelector = "assistant-entry"
assistantSelector = "shopping-assistant"

onScreenShown(pageContext):
    generation += 1
    cancel(previousScreenTask)
    myGeneration = generation
    hideEntryButton()
    sdk.trackView(pageContext)

    previousScreenTask = launch:
        entryResponse = await sdk.getContentBySelector(entrySelector, pageContext)
        if taskCancelled or myGeneration != generation: return
        entry = parseEntry(entryResponse) // custom.json; ошибка/нет данных -> disabled
        if not entry.enabled: return

        result = await sdk.prepareAssistant(assistantSelector, pageContext, timeout = 10s)
        if taskCancelled or myGeneration != generation: return
        if result == ready: showEntryButton(entry.title)

onScreenHiddenOrIdentityChanged:
    generation += 1
    cancel(previousScreenTask)
    hideEntryButton()

onEntryTap:
    result = sdk.openAssistant(currentPresenter)
    if result == not_ready or result == invalid_presenter:
        hideEntryButton()
```

Любая ошибка обоих запросов оставляет кнопку скрытой. Отмена coroutine сохраняет стандартную семантику Kotlin `CancellationException`; не перехватывайте её как сетевую ошибку. Swift Task дополнительно проверяет `Task.isCancelled`. `currentPresenter` — видимая `ComponentActivity` либо `UIViewController`, готовый к презентации.

## Шаг 5. Проверьте первый запуск

| Проверка | Ожидаемый результат |
| --- | --- |
| Открыли тестовый экран, обе кампании опубликованы и включены. | После успешной подготовки появилась кнопка с подписью из кампании. |
| Нажали кнопку. | Открылся полноэкранный чат с заголовком, приветствием и подсказками. |
| Отправили вопрос о товаре. | SDK показал ответ и передал подборку вашему builder. |
| Нажали на товар. | Чат закрылся, handler открыл карточку в приложении один раз. |
| Закрыли и снова открыли чат. | Переписка сохранилась в памяти приложения. |
| Выключили точку входа и опубликовали изменение. | При следующем запросе точки входа кнопка не появилась. |

### Если кнопка или чат не появились

| Симптом | Что проверить |
| --- | --- |
| `getContentBySelector` не вернул контент | Значения `section` и selector, публикацию кампании и Experience, [таргетинг](../../personalization/Campaigns/targeting_schedule.md) для текущего `PageContext`. |
| Контент есть, кнопки нет | Разбор `custom.json`, `enabled: true`, результат `prepareAssistant`; не успел ли пользователь уйти с экрана. |
| `unavailable` | Нет подходящей настройки ассистента либо она выключена. Проверьте вторую кампанию, а не только кнопку. |
| `invalid_configuration` | Зарегистрированы ли builder/handler, корректен ли JSON, непустой ли selector. |
| `unauthorized` | API-ключ/раздел базового SDK и доступ раздела к Shopping API из мобильной сети. |
| `not_ready` при открытии | Кнопку показали раньше `ready` или подготовка уже устарела после смены экрана/пользователя. |
| Товары есть, перехода нет | `canHandle(OpenProduct)` возвращает `true`, карточка передаёт `tap` с `product.ref`, обработчик действительно выполняет навигацию. |

Для проверки запросов платформы используйте [API-логи](../api_integration/api_logs.md). Все результаты и ошибки разобраны [в справочнике ниже](#результаты-и-ошибки).

### Дополнительные сценарии перед выпуском

- [ ] При отключённой или отсутствующей кампании кнопки нет; при медленной подготовке не появляется неработающая кнопка.
- [ ] Быстрые переходы между страницами не показывают кнопку или подсказки предыдущей страницы.
- [ ] Ошибка повторной подготовки не удаляет активный диалог; «Новый чат» не показывает старый стартовый экран.
- [ ] Поворот, большие шрифты и пустые подборки не создают нулевую или бесконечную высоту блока.
- [ ] Карточки за пределами горизонтального viewport не дают impression; перерисовки не создают дубли.
- [ ] SKU без URL открывается; товар без обоих остаётся видимым без нажатия.
- [ ] Товар и кнопка поддержки закрывают чат до перехода; быстрый двойной tap не открывает два экрана.
- [ ] Чужая блокировка UI-кампаний сохраняется после закрытия ассистента.
- [ ] После смены пользователя не видны старые сообщения, товары и поздние ответы.

---

## Методы SDK

Сигнатуры для регистрации компонентов, подготовки и управления экраном ассистента.

+++ Android

Методы вызываются у `GravitySDK.instance`. Асинхронная подготовка — `suspend`-метод; действия с UI выполняются в главном потоке.

```kotlin
suspend fun prepareAssistant(
    assistantSelector: String,
    pageContext: PageContext,
    timeoutMillis: Long = 10_000,
): AssistantPrepareResult

fun openAssistant(presenter: ComponentActivity): AssistantOpenResult
fun closeAssistant()
fun setAssistantProductBlockBuilder(builder: AssistantProductBlockBuilder)
fun setAssistantActionHandler(handler: AssistantActionHandler)
fun performAssistantProductAction(
    product: AssistantProductRef,
    action: AssistantProductAction,
): AssistantInteractionResult
```

+++ iOS

Методы доступны у `GravitySDK.instance`. Подготовка использует Swift concurrency; UI API и обработчики работают на `MainActor`.

```swift
@MainActor
func prepareAssistant(
    assistantSelector: String,
    pageContext: PageContext,
    timeout: TimeInterval = 10
) async -> AssistantPrepareResult

@MainActor func openAssistant(presenter: UIViewController) -> AssistantOpenResult
@MainActor func closeAssistant()
@MainActor func setAssistantProductBlockBuilder(_ builder: any AssistantProductBlockBuilder)
@MainActor func setAssistantActionHandler(_ handler: any AssistantActionHandler)
@MainActor func performAssistantProductAction(
    product: AssistantProductRef,
    action: AssistantProductAction
) -> AssistantInteractionResult
```

+++

## Товарный блок приложения

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

```kotlin
interface AssistantProductBlockBuilder {
    @Composable
    fun Build(block: AssistantProductBlock, layout: AssistantProductLayout)
}

data class AssistantProductLayout(
    val availableWidth: Dp,
    val isInteractionEnabled: Boolean,
)
```

```swift
@MainActor
protocol AssistantProductBlockBuilder {
    func build(block: AssistantProductBlock, layout: AssistantProductLayout) -> AnyView
}

struct AssistantProductLayout {
    let availableWidth: CGFloat
    let isInteractionEnabled: Bool
}
```

`availableWidth` — полезная ширина внутри чата после safe area и внутренних отступов, в dp на Android и points на iOS. Блок занимает ровно эту ширину и возвращает конечную измеряемую высоту по содержимому. Не используйте бесконечную высоту, `fillMaxHeight`, вертикальный `ScrollView` или `GeometryReader` как единственный источник высоты.

На Android корневому контейнеру задают `Modifier.width(layout.availableWidth)`; Compose измеряет высоту содержимого. На iOS применяют `.frame(width: layout.availableWidth)` и `.fixedSize(horizontal: false, vertical: true)`; SwiftUI сообщает высоту контейнеру SDK. При повороте, изменении размера окна, масштаба текста или загрузке изображения layout пересчитывается. Используйте соотношение сторон/placeholder для изображения, чтобы высота была определена ещё до загрузки. Для UIKit/Android View обёртка должна участвовать в нативном измерении высоты.

### Модель данных

| `AssistantProductBlock` | Тип | Значение |
| --- | --- | --- |
| `blockId` | `String` | Уникальный ключ блока; не используйте только `shelf_id` как глобальный ключ. |
| `title` | `String?` | Заголовок подборки. |
| `products` | `[AssistantProduct]` / `List<AssistantProduct>` | Товары в порядке ответа. Пустой список допустим. |

| `AssistantProduct` | Тип | Значение |
| --- | --- | --- |
| `itemId` | `String` | Уникальное появление товара в ответе. Одинаковый SKU в двух местах — разные itemId. |
| `ref` | `AssistantProductRef` | Непрозрачная ссылка SDK для событий. Не создавать самостоятельно и не сохранять между открытиями чата. |
| `name` | `String` | Название. |
| `sku`, `url`, `imageUrl` | `String?` | SKU, URL товара и изображения. `url` не является URL аналитики. |
| `price` | `Double?` | Цена без форматирования. Валюту определяет приложение. |
| `brand`, `features` | `String?` | Дополнительные сведения. |
| `categoryId` | `String?` | Значение `category_id` из Shopping API; не название и не путь категории. |
| `isStm` | `Bool?` / `Boolean?` | Признак СТМ. |
| `canOpen` | `Bool` / `Boolean` | Есть SKU или допустимый URL, зарегистрирован handler и его `canHandle(OpenProduct(product))` возвращает `true`. |

Поля товара, кроме `itemId`, `ref`, `name` и `canOpen`, могут отсутствовать. Не удаляйте товар из-за отсутствующей картинки, цены или категории. Показывайте placeholder и форматируйте цену по правилам магазина. Пустой блок показывает пустое состояние. События аналитики хранятся внутри SDK и не требуются builder.

### Видимость и нажатия

Приложение вызывает публичный метод SDK; это не callback, который SDK вызывает в приложении:

```kotlin
GravitySDK.instance.performAssistantProductAction(
    product = product.ref,
    action = AssistantProductAction.VisibleImpression,
)
GravitySDK.instance.performAssistantProductAction(
    product = product.ref,
    action = AssistantProductAction.Tap,
)
```

```swift
GravitySDK.instance.performAssistantProductAction(product: product.ref, action: .visibleImpression)
GravitySDK.instance.performAssistantProductAction(product: product.ref, action: .tap)
```

`AssistantProductAction` имеет только `visibleImpression` и `tap`. `AssistantInteractionResult`: `accepted`, `duplicate`, `stale_reference`, `not_actionable`. На Kotlin это `VisibleImpression`, `Tap`, `Accepted`, `Duplicate`, `StaleReference`, `NotActionable`; в Swift — одноимённые case в lowerCamelCase.

- Отправляйте `visibleImpression`, когда не менее 50% площади карточки видно непрерывно одну секунду одновременно внутри карусели, видимой области чата и окна приложения. Фоновое приложение и закрытый чат не считаются видимостью. При потере видимости таймер сбрасывают.
- Отправляйте `tap` один раз на физическое нажатие, только при `layout.isInteractionEnabled && product.canOpen`.
- Builder обязан применить это условие к кликам и accessibility actions. SDK не может изменить обработчики произвольной host view. SDK дополнительно отклоняет недопустимое действие на своей границе.
- Повторное создание view не создаёт новые показы: SDK принимает один `visibleImpression` для одного появления товара за одно открытие чата. При новом открытии выдаются новые `ref`.
- Старые ссылки после закрытия, нового чата или смены идентичности возвращают `stale_reference` без tracking и навигации. Повторный tap в уже начатом переходе возвращает `duplicate`.

## Действия и навигация

Единый `AssistantActionHandler` обслуживает и товары, и действия ответа: например, `open_support_chat`. Он должен быть зарегистрирован до подготовки. Отсутствие handler или builder — `invalid_configuration`, поэтому интеграция не доходит до чата с неработающими товарами.

```kotlin
interface AssistantActionHandler {
    fun canHandle(action: AssistantAction): Boolean
    fun handle(action: AssistantAction)
}

sealed interface AssistantAction {
    data class OpenProduct(val product: AssistantProduct) : AssistantAction
    data class OpenSupportChat(val payload: Map<String, JsonValue>) : AssistantAction
    data class FollowUrl(val url: String) : AssistantAction
    data class Custom(val type: String, val payload: Map<String, JsonValue>) : AssistantAction
}
```

```swift
@MainActor
protocol AssistantActionHandler {
    func canHandle(_ action: AssistantAction) -> Bool
    func handle(_ action: AssistantAction)
}

enum AssistantAction {
    case openProduct(AssistantProduct)
    case openSupportChat(payload: [String: JSONValue])
    case followUrl(URL)
    case custom(type: String, payload: [String: JSONValue])
}
```

`JsonValue`/`JSONValue` — JSON-значение: null, boolean, number, string, array или object. `payload` содержит исходный объект серверной кнопки (`type`, `value`, `action` и прочие полученные поля) без переименования ключей; отдельного серверного поля `payload` не требуется. Серверное `action: "follow_url"` с корректным HTTP(S) URL преобразуется в `FollowUrl`/`.followUrl`; `open_support_chat` — в `OpenSupportChat`/`.openSupportChat`. `canHandle` синхронен, не выполняет сеть и не запускает навигацию. Для неизвестного типа возвращайте `false`; SDK оставляет такое действие недоступным и фиксирует диагностику, не ломая весь ответ.

Для товара SDK проверяет действие, ставит click tracking на отправку, закрывает чат и **после завершения закрытия** вызывает `handle`. Для `open_support_chat`, `follow_url` и поддержанных custom-действий порядок закрытия и вызова такой же. Сам переход — ответственность приложения. При обработке `tap` не выполняйте второй переход напрямую из builder и не отправляйте отдельный `sendProductEngagement`.

В `handle` откройте карточку по SKU либо URL, чат поддержки либо другой экран приложения. Обработчик выполняется один раз для принятого нажатия. Асинхронная аналитика не задерживает навигацию. Если приложение не может завершить переход после передачи управления, оно показывает собственную ошибку навигации.

## Подготовка, контекст и диалог

Подготовка и диалог — независимые состояния. Подготовка бывает `idle`, `preparing`, `ready`, `unavailable` или `failed`. Диалог бывает `empty`, `active` или `sending`. Подготовка никогда не отправляет скрытое сообщение от имени пользователя.

Каждая новая подготовка сразу делает предыдущий стартовый экран недействительным. Даже при равных аргументах действует последний вызов. Поздний ответ, отмена или timeout старого запроса не меняют результат нового.

| Событие | Поведение |
| --- | --- |
| Первое открытие после `ready` | Приветствие и подсказки актуальной подготовки. |
| Успешная повторная подготовка того же ассистента | Обновляет данные для следующего нового чата. Уже начатый диалог сохраняется. |
| Подготовка не завершена или закончилась ошибкой | Старый стартовый экран не используется. Уже открытый активный диалог сохраняется; новый чат недоступен. |
| Новый экран приложения | `trackView` инвалидирует стартовую подготовку при изменении контекста. Приложение скрывает кнопку и повторяет выбор точки входа и подготовку. |
| Закрытие чата | Закрывает только UI; сообщения и черновик сохраняются в памяти. |
| Повторное открытие | Показывает активный диалог; если диалога нет, требует актуальный `ready`. |
| «Новый чат» при `ready` | Атомарно прекращает ожидание старого ответа в UI, создаёт новый ID диалога, очищает сообщения и показывает актуальный стартовый экран. |
| «Новый чат» во время подготовки или после её ошибки | Кнопка недоступна. SDK показывает статус подготовки/ошибку с повтором; старый диалог не удаляется. |
| Смена пользователя, сессии или раздела SDK | Закрывает чат, очищает диалог и подготовку, отменяет запросы и инвалидирует ссылки на товары. |
| Другой `assistantSelector` или tenant | Предыдущий диалог не переносится в другого ассистента. Для него нужна новая успешная подготовка. |
| Перезапуск приложения | Новый диалог. Автоматического восстановления предыдущего чата нет. |

Для кнопки приложения достаточно результата подготовки; отдельный `hasAssistantActiveDialog` не нужен. При каждой новой странице кнопку показывают после `ready`, даже если в памяти есть диалог. Уже открытый чат при этом не закрывается из-за обычной ошибки подготовки.

Контекст активного диалога фиксируется при его старте. Переход на другую страницу не переписывает контекст незавершённого запроса. Новый контекст применяется к следующему новому чату. Значения SKU, категорий и региона передаются без изменения регистра, согласно [PageContext Android](./sdk_android.md#pagecontext) и [PageContext iOS](./sdk_ios.md#pagecontext).

SDK не допускает параллельной отправки двух сообщений в одном диалоге и блокирует повторное нажатие отправки. Во время запроса виден индикатор. Таймаут запроса ответа — 120 секунд; он независим от десятисекундной подготовки. При сетевой ошибке текст сохраняется; SDK не отправляет его автоматически повторно. Если ответ сервера мог быть сохранён до обрыва связи, SDK сначала проверяет историю текущего диалога. Повтор использует тот же ID сообщения и исходный запрос, включая контекст; отредактированный текст является новым сообщением. Повтор после неопределённого сетевого исхода не даёт серверной гарантии exactly-once.

Для сообщений `message_conflict` соответствует HTTP 409 `MESSAGE_ID_CONFLICT`: SDK не создаёт новый ID автоматически и не перезаписывает ответ. Для истории `history_unavailable` соответствует HTTP 404 `SHOPPING_HISTORY_NOT_FOUND`: это не разрешение стереть локальные сообщения. Ошибки истории или генерации сохраняют текущий диалог. Остальные сетевые ошибки используют те же коды, что перечислены выше. Оценки ответа отправляет SDK по `assistantMessageId` из генерации либо `id` сообщения ассистента из истории; приложение не отправляет их повторно. Пока корректного ID ответа нет, отправка оценки недоступна.

Закрытие UI не отменяет уже отправленное сообщение: успешный ответ продолжает обрабатываться в текущем диалоге. «Новый чат», смена идентичности и смена ассистента инвалидируют старые ответы; они не попадают в новый диалог. Уже отправленный запрос может завершиться и сохраниться в старом серверном диалоге.

## Результаты и ошибки

Имена результатов ниже записаны в общем формате. В Kotlin используются `Ready`, `Unavailable(reason)`, `Failure(error)`, `Cancelled`, `Superseded`; в Swift — `.ready`, `.unavailable(reason)`, `.failure(error)`, `.cancelled`, `.superseded`. Результаты подготовки и их вложенные типы сравнимы по значению (`Equatable` в Swift).

| Результат подготовки | Значение |
| --- | --- |
| `ready` | Для последнего контекста загружена и проверена конфигурация стартового экрана. |
| `unavailable(no_matching_configuration)` | Выбор кампании успешно завершён, подходящего контента нет. Это может быть отсутствие кампании, условия таргетинга или непустой selector, который ни с чем не совпал. SDK не угадывает конкретную причину. |
| `unavailable(disabled)` | Выбранная конфигурация ассистента явно отключена. |
| `failure(error)` | Не удалось получить или проверить конфигурацию; подробности в `AssistantError`. |
| `cancelled` | Подготовка отменена экраном приложения или сменой идентичности. |
| `superseded` | Подготовку заменил более поздний вызов; её ответ не меняет состояние. |

`AssistantError` содержит `code`, диагностическое `message` и необязательный `httpStatus`. Не показывайте диагностический текст пользователю напрямую.

| `code` | Точное условие | Действие приложения |
| --- | --- | --- |
| `invalid_configuration` | Пустой selector, неположительный timeout, некорректный JSON выбранной конфигурации, отсутствие обязательных настроек подключения либо незарегистрированный builder/handler. | Исправить интеграцию или кампанию. |
| `unauthorized` | Сервис вернул HTTP 401 или 403. | Проверить доступ; не повторять запрос циклически. |
| `network` | Нет соединения, DNS/TLS/transport failure. | Предложить повтор при восстановлении сети. |
| `timeout` | Истёк общий срок подготовки, включая ожидание всех необходимых запросов. | Оставить кнопку скрытой; повторить подготовку явно. |
| `rate_limited` | HTTP 429. | Повторить позже с учётом `Retry-After`, если он есть. |
| `server` | HTTP 5xx. | Повторить позже. |
| `invalid_response` | Успешный HTTP-ответ не соответствует ожидаемому протоколу, либо иной непредусмотренный HTTP 4xx. | Зафиксировать диагностику. |

Опечатка в непустом selector, давшая пустой ответ, — `unavailable`, а не доказанная `invalid_configuration`. `ready` подтверждает готовность стартового экрана; доступность следующего запроса сообщения проверяется при его отправке.

`AssistantOpenResult`: `opened`, `already_open`, `not_ready`, `invalid_presenter`. Повторное открытие не создаёт второй экран. `invalid_presenter` означает, что Activity/UIViewController уже закрыт, не виден или занят несовместимой презентацией.

## Аналитика

События жизненного цикла SDK отправляет через обычный канал custom events Gravity Field. Приложение не дублирует их:

| Событие | Когда SDK отправляет |
| --- | --- |
| `chat_dialog_open` | После завершения каждого открытия UI. |
| `assistant_message_sent` | Один раз при принятии нового пользовательского сообщения; сетевой повтор с тем же ID не является новым сообщением. |
| `assistant_response_received` | При первом отображении полученного ответа, включая отображение после повторного открытия. |
| `assistant_product_selection_received` | Вместе с первым отображением ответа, если в нём есть хотя бы один непустой товарный блок. |
| `chat_error` | При показе ошибки отправки, истории или подготовки в открытом чате. Отменённые и вытесненные запросы не считаются ошибками. |
| `chat_dialog_close` | После закрытия UI, включая переход через handler. |
| `chat_dialog_end` | При явном «Новом чате» или сбросе идентичности/ассистента. Простое закрытие не завершает диалог. |

События содержат идентификаторы диалога, сообщения и кампании, где применимо, без текста переписки. SDK сохраняет привязку события к исходному пользователю; событие старого диалога не отправляется от имени нового аккаунта. Завершение процесса не гарантирует доставку `chat_dialog_close` или `chat_dialog_end`.

SDK использует события `click` и `visible_impression` из `product.events` ответа Shopping API. Для каждого принятого действия он отправляет все допустимые URL соответствующего типа; одинаковые URL внутри одного действия отправляются один раз. Отсутствие событий не блокирует отображение товара и навигацию, но аналитика этого действия через данный механизм отсутствует.

Приложение не вызывает tracking URL, не создаёт их из SKU и не отправляет одновременно обычный `sendProductEngagement`. События товаров ассистента и engagement обычных кампаний — разные механизмы. События уровня ответа не подменяют показы конкретных карточек. SDK обрабатывает поддержанные события ответа отдельно при соответствующем событии жизненного цикла ответа.

Очередь уже принятых кликов не отменяется закрытием чата; событие сохраняет исходные товар и идентичность. SDK не обещает доставку после завершения процесса приложения или exactly-once доставку по сети.

Кнопки `open_support_chat` и `follow_url` сами по себе не содержат товарных tracking URL. Вызов action handler не означает отправку product click и не подменяет аналитику приложения для этих действий.

## Другие UI-кампании

На время презентации и показа ассистента SDK блокирует автоматические modal/in-app показы и переходы шагов таких кампаний. Блокировка устанавливается до открытия и снимается после полного закрытия, включая системный Back и swipe-dismiss.

Ассистент снимает только собственную блокировку. Если приложение уже вызвало `lockPresentation`, закрытие чата не отменяет блокировку приложения. Ошибка открытия и уничтожение экрана также освобождают только блокировку ассистента.

Получение кампаний и отправка событий продолжаются. Подавленные кампании автоматически после закрытия чата не воспроизводятся; следующие показы определяются последующими обычными событиями приложения. Inline-контент на экранах приложения не становится модальным и не блокируется этим правилом.

## Подключение к Shopping API

SDK использует `https://shopping-assistant-api.gravityfield.ai`. `runtimeContext.tenant_id` равен `section`, указанному при инициализации Gravity SDK. Этот раздел должен быть подключён к Shopping Assistant; selector выбирает сценарий внутри раздела и не переключает tenant. `resourceId` и `runtimeContext.uid` — канонический `user.uid` Gravity Field, полученный базовым SDK. Собственный ID из `setUser` не подставляется вместо него напрямую.

Подготовка дожидается готовности базового SDK и разрешения `user.uid` в пределах своего общего timeout. До этого `ready` не возвращается; случайный локальный ID вместо `user.uid` не создаётся. `threadId` — отдельный UUID чата, а не `user.ses`.

Выбор кампании идёт через обычный Gravity API V2 с ключом базового SDK. Запросы к публичному Shopping API передают `Content-Type: application/json`; ключ API V2 и серверные ключи коннекторов в эти запросы и в `custom.json` не добавляют. Отдельный логин пользователя в ассистент не нужен. На стороне сервиса раздел должен допускать обращения из мобильной сети; HTTP 403, в том числе `IP_NOT_ALLOWED`, означает отказ в доступе и возвращается как `unauthorized`. Настройки доступа не меняются методами SDK.

Подробный транспортный контракт: [интеграция Shopping Assistant по API](../../shopping_assistant/integration.md). Приложение не вызывает эти запросы параллельно с SDK.

## Связанные разделы

[!ref](../../personalization/Campaigns/api_campaigns.md)
[!ref](../../personalization/Campaigns/targeting_schedule.md)
[!ref](../../shopping_assistant/personal_account/preview.md)
[!ref](../../shopping_assistant/tracking.md)
[!ref](../../shopping_assistant/api_reference.md)
