Compare commits

..
18 Commits
Author SHA1 Message Date
Andrey e1cda4dd8b 🔖 release: 1.12.0 2026-09-17 12:29:02 +03:00
AndreyandClaude Opus 5 fed079e02d ✨ feat(notifications): открытый диалог гасит свои уведомления
Прочтением считалось только нажатие на строку в шторке. Сотрудник мог уже
отвечать клиенту, а оклик «клиент ждёт» по этому же диалогу продолжал висеть
непрочитанным и держать счётчик в шапке. Счётчик переставал что-либо значить:
он звал туда, где человек уже был.

Теперь при открытии диалога гасятся все уведомления о нём — не только те,
что клиент успел загрузить в шторку: в неё помещается полсотни последних, и
гасить по загруженному списку означало бы оставлять хвост. Отбор идёт по
адресу уведомления, включая маршруты, которыми на диалог ссылались старые
строки в базе.

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

Проверено: тесты уведомлений (гасится только свой диалог, гасятся все его
уведомления, старый маршрут узнаётся, нечисловой идентификатор отклоняется),
рассылка событий, каталоги переводов, ruff, проверка типов рабочего места.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:55:01 +03:00
AndreyandClaude Opus 5 89c44db2bc 🐛 fix(chat): точка режима держится на аватаре в любой строке диалога
Строка списка — flex, и её ячейки по умолчанию растягиваются на всю высоту.
Обёртка аватара растягивалась вместе с ними, а точка режима привязана к её
низу — в строке с метками или таймером ожидания обёртка вырастала с 38 до 62
пикселей, и точка уезжала под аватар на четверть сантиметра. Чем больше меток
у диалога, тем дальше.

Обёртка прижата к началу поперечной оси и снова ростом с аватар, поэтому
точка стоит в его углу независимо от того, что показано в строке ниже.

Проверено на короткой строке и на строке с меткой группы и таймером: высота
обёртки 38 в обеих, точка на 1 пиксель за краем аватара.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:43:00 +03:00
AndreyandClaude Opus 5 ca23cd285f 🐛 fix(vk): марка ВКонтакте вместо чужого значка в ленте и карточке
Канал ВКонтакте появился, а марки у него не было: в ленте диалогов и в
карточке контакта рисовался общий пузырь сообщения, а в строке логина —
значок отправки, который для Telegram выглядел уместно только по
совпадению. Клиент из ВКонтакте выглядел пришедшим непонятно откуда.

Теперь `ChannelGlyph` знает ВКонтакте и отдаёт тот же официальный
монохромный глиф, что стоит на плитке подключения, а строка логина берёт
марку своего канала вместо общего значка: Telegram, MAX и ВКонтакте —
каждый свой.

Проверено: глифы на 13-18px в тёмной теме рядом с Telegram и MAX, лента
диалогов, строка логина и плитки каналов в карточке контакта; tsc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:38:40 +03:00
Andrey d34aea8a8a 🔖 release: 1.11.0 2026-09-17 11:02:20 +03:00
AndreyandClaude Opus 5 eb98b9b781 ✨ feat(settings): у каждого подключения ссылка на статью справки
Токен бота, ключ доступа сообщества, пароль ящика — всё это выдаёт не наша
система, и человек уходит искать инструкцию в поиске, хотя она написана и
лежит в Центре помощи. Теперь под выбором типа стоит ссылка на статью
именно про этот тип: Telegram, MAX, ВКонтакте, почтовый ящик, веб-виджет.

Адрес Центра помощи один на продукт — это документация Chatballs, а не
портал организации, поэтому он лежит константой в shared/help и от
установки не зависит. Статья у типа подключения необязательна: у
LLM-провайдеров ссылки пока нет, и форма просто её не рисует.

Ссылка оформлена общим классом `.link` в роли второстепенной и стоит
подписью к полю «Тип» — своего стиля у неё нет.

Проверено: tsc, тесты каталога переводов и модели подключений, вид формы
на изолированном макете со штатными стилями.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:02:20 +03:00
AndreyandClaude Opus 5 31985fc35c ✨ feat(vk): канал ВКонтакте — сообщество как точка входа
Сообщения сообщества ВКонтакте принимаются через Bots Long Poll: тот же
способ, что у Telegram и MAX, поэтому подключению не нужен ни публичный
адрес, ни доступ извне — установка за NAT работает наравне с остальными.

Позиция потока живёт в poll_marker, адрес сервера и ключ — в памяти
процесса: их выдают на несколько часов, и колонка под них означала бы
запись в базу на каждом цикле опроса. Ответы failed 1-3 восстанавливаются
в том же цикле, иначе подключение висело бы с протухшим ключом до
перезапуска воркера.

Идентификатор сообщества владелец не вводит: его называет сам ключ
доступа, и проверка подключения кладёт его в конфигурацию — как имя бота
у Telegram и MAX. Проверка заодно смотрит настройки сообщества: без Long
Poll и события о входящем сообщении приём невозможен, и об этом честнее
сказать сразу, а не молчать зелёным статусом. Настройки чужого сообщества
при этом не меняются. Нехватка прав у ключа объясняется словами: ВКонтакте
отвечает на неё английским «no access», из которого не видно, что включить.

Имя, логин и фото отправителя ВКонтакте в апдейте не присылает — их
забирает один users.get на пачку сообщений, а не на каждое: на оживлённом
сообществе запрос на реплику упёрся бы в частоту обращений.

Телефона и кнопки «поделиться контактом» у ВКонтакте нет, поэтому просьба
уходит обычным сообщением, как и почтой. Голосовые принимаются, но не
отправляются: провайдер ждёт ogg/opus, а композер пишет webm.

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

Проверено на живом сообществе: проверка подключения, опрос Long Poll,
приём текста, голосового и фото с подстановкой имени, логина и аватара.
Тесты транспорта и проверки подключения, каталоги переводов, ruff из
корня, tsc и vitest по internal-ui.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:02:20 +03:00
Andrey Ermolaev 1ea0faf1c6 Update README.md 2026-09-17 09:38:32 +03:00
AndreyandClaude Opus 5 a41b23a5e2 docs: обложка и ссылки в README, design/ вне гита
Дизайн-хендоффы (design/) — рабочие материалы, а не часть продукта:
сняты с отслеживания и добавлены в .gitignore.

В обоих README перед оглавлением — обложка рабочего места (1024px,
сжата из исходных 3840px) и строка ссылок: телеграм-канал, сайт,
центр помощи.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:03:27 +03:00
Andrey 089dd6ddc2 🔖 release: 1.10.0 2026-09-15 08:45:54 +03:00
AndreyandClaude Opus 5 ae46508d22 ✨ feat(ai): у агента своя модель для текста и своя для расшифровки
Ключ провайдера один на организацию, а агентов на нём несколько, и модель им нужна разная. Раньше модель принадлежала интеграции и копировалась на агента при каждом сохранении — выбрать её агенту было нельзя. Теперь на карточке агента две пары «провайдер + модель»: чем отвечать и чем расшифровывать голосовые. Пустое поле модели означает «как в интеграции», и подсказка в нём показывает её значение, поэтому агент, которому модель не выбирали, по-прежнему следует за настройкой ключа.

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

Проверено: тесты маршрутизации моделей и провайдеров, карточки агента, расшифровки, списка агентов и каталога переводов; ruff и проверка типов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:40:04 +03:00
Andrey bd3b9309bf 🔖 release: 1.9.1 2026-09-15 08:10:11 +03:00
AndreyandClaude Opus 5 e35133b159 🐛 fix(integrations): продукт представляется своим именем, отказ объясняется словами
Исходящие запросы уходили с умолчанием urllib — «Python-urllib/3.12». Защита перед чужим API такой запрос банит раньше, чем его увидит сам провайдер: на боевом стенде Groq через рабочий прокси отвечал 403 «error code: 1010», а с обычным User-Agent тот же запрос и тот же прокси проходят. Теперь opener представляется как «Chatballs/<версия>», свой User-Agent конкретного запроса он не перебивает.

Проверка подключения больше не показывает голый код. Причину, если провайдер её назвал, видно сразу («Invalid API Key»), HTML-страница защиты сжимается в строку, молчаливый 401/403 отправляет смотреть ключ и доступность провайдера из сети. Ответ целиком уходит в журнал.

Проверено на боевом стенде: без User-Agent — 403 «error code: 1010», с любым User-Agent — ответ самого Groq. Пять новых тестов, тесты интеграций, транспортов, провайдеров и i18n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:07:33 +03:00
Andrey e9b64b6afc 🔖 release: 1.9.0 2026-09-15 07:33:57 +03:00
AndreyandClaude Opus 5 65f7de6bc3 ✨ feat(ai): расшифровка голосовых отдельным провайдером
Модель, которой агент отвечает, не обязана уметь речь в текст: у Anthropic и Yandex Foundation Models эндпоинта /audio/transcriptions нет вовсе, и голосовые у такого агента расшифровать было нечем. На карточке агента появился выбор «Расшифровка голосовых»: по умолчанию «Как у ответов», иначе любая другая интеграция организации — модель берётся из её поля «Модель расшифровки голосовых».

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:33:11 +03:00
Andrey 290b4ccea9 🔖 release: 1.8.1 2026-09-15 07:14:10 +03:00
AndreyandClaude Opus 5 ca1b892e11 🐛 fix(calls): отбой клиента закрывает окно звонка у оператора
Клиент кладёт трубку, сервер переводит звонок в терминал, а peer connection у оператора узнаёт об этом позже — или не узнаёт вовсе. Проверка терминального статуса в аудиозвонке стояла последней, после состояния соединения, поэтому у оператора оставался экран активного разговора с кнопкой «Завершить», которой нечего было завершать: она молча выходила по той же проверке. Теперь терминальный статус решает первым (как в видеозвонке), а «Завершить» на уже завершённом звонке просто закрывает окно.

Проверено: четыре теста разбора фазы аудиозвонка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:13:59 +03:00
AndreyandClaude Opus 5 e5d3e98ce3 🎨 style: порядок импортов по конфигу репозитория
Ruff в CI читает pyproject.toml из корня и сортирует импорты; локальный прогон
из каталога backend этого правила не видел.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:56:38 +03:00
132 changed files with 2264 additions and 33739 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+3
View File
@@ -32,3 +32,6 @@ Chatballs.zip
# Служебный каталог агента: локальные настройки запуска, а не часть продукта.
.claude/
# Дизайн-хендоффы: рабочие материалы, а не часть продукта.
design/
+20 -6
View File
@@ -4,7 +4,7 @@
<h1 align="center">Chatballs</h1>
<p align="center"><strong>AI customer support platform</strong></p>
<p align="center"><strong>Self-hosted AI customer support platform</strong></p>
<p align="center">
An AI platform that talks to your customers for you: it answers in messengers, email and web chat, and hands your team only the hard questions.
@@ -23,6 +23,20 @@
---
<p align="center">
<img src=".github/assets/cover.jpg" alt="Chatballs workspace: dialogs, conversation and contact card" width="1024">
</p>
<p align="center">
<a href="https://t.me/chat_balls">Telegram channel</a>
&nbsp;&middot;&nbsp;
<a href="https://chatballs.ru">Website</a>
&nbsp;&middot;&nbsp;
<a href="https://chatballs.com.edevs.tech/">Help center</a>
</p>
---
## Table of contents
- [Overview](#overview)
@@ -43,7 +57,7 @@
## Overview
Chatballs takes over the first line of customer conversations. An AI agent answers from your knowledge base in Telegram, MAX, email and the chat on your website. When the agent is not confident or the customer asks for a person, the conversation goes to your team together with a notification.
Chatballs takes over the first line of customer conversations. An AI agent answers from your knowledge base in Telegram, MAX, VK, email and the chat on your website. When the agent is not confident or the customer asks for a person, the conversation goes to your team together with a notification.
The platform installs on your own server with a single command. Customer data stays with you. You connect the AI model with your own key.
@@ -100,7 +114,7 @@ Everything else is done in **Settings**.
| Section | What to do |
|---|---|
| **Platform** | Set the installation domain. The gateway issues a Let's Encrypt certificate on its own and switches to HTTPS. Outgoing SMTP mail is configured here as well: it is needed for employee invitations and password recovery. |
| **Integrations** | Connect an AI model provider: OpenRouter, any OpenAI-compatible service or a local model. A demo provider that needs no key is available for a first look. Then connect entry points: a Telegram bot, a MAX bot, a mailbox over IMAP/SMTP or a web widget for your site. |
| **Integrations** | Connect an AI model provider: OpenRouter, any OpenAI-compatible service or a local model. A demo provider that needs no key is available for a first look. Then connect entry points: a Telegram bot, a MAX bot, a VK community, a mailbox over IMAP/SMTP or a web widget for your site. |
| **Agents** | Create an AI agent: who it is, how it speaks, what rules it follows. Choose the model. Attach articles from the knowledge base. |
| **Employees** | Invite your team by email, assign roles and groups. |
@@ -157,7 +171,7 @@ When the agent cannot find an answer or the customer asks for a real person, the
### All channels in one window
Telegram, MAX, email and website chat land in a single conversation list. The employee sees where the customer came from and replies in the same channel.
Telegram, MAX, VK, email and website chat land in a single conversation list. The employee sees where the customer came from and replies in the same channel.
### Knowledge base with semantic search
@@ -173,7 +187,7 @@ The widget is installed with a single line of code and runs in an isolated windo
### Audio and video calls from the chat
The customer and the employee call each other straight from the conversation without third-party services. Works in the web widget, Telegram and MAX. A relay is available for difficult networks.
The customer and the employee call each other straight from the conversation without third-party services. Works in the web widget, Telegram, MAX and VK. A relay is available for difficult networks.
### Operator workspace
@@ -255,7 +269,7 @@ Open the integration and run the check. For bots the usual cause is a wrong toke
</details>
<details>
<summary><strong>Messages from Telegram or MAX do not arrive</strong></summary>
<summary><strong>Messages from Telegram, MAX or VK do not arrive</strong></summary>
The background worker polls the bots. Make sure it is running:
+19 -5
View File
@@ -23,6 +23,20 @@
---
<p align="center">
<img src=".github/assets/cover.jpg" alt="Рабочее место Chatballs: список диалогов, переписка и карточка контакта" width="1024">
</p>
<p align="center">
<a href="https://t.me/chat_balls">Телеграм-канал</a>
&nbsp;&middot;&nbsp;
<a href="https://chatballs.ru">Сайт</a>
&nbsp;&middot;&nbsp;
<a href="https://chatballs.com.edevs.tech/">Центр помощи</a>
</p>
---
## Содержание
- [Что это](#что-это)
@@ -43,7 +57,7 @@
## Что это
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, ВКонтакте, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу.
@@ -100,7 +114,7 @@ docker compose up -d --wait
| Раздел | Что сделать |
|---|---|
| **Платформа** | Укажите домен установки. Шлюз сам выпустит сертификат Let's Encrypt и переведёт работу на HTTPS. Здесь же задаётся исходящая почта по SMTP: она нужна для приглашений сотрудников и восстановления паролей. |
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, сообщество ВКонтакте, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель. Прикрепите статьи из базы знаний. |
| **Сотрудники** | Пригласите команду по почте, распределите роли и группы. |
@@ -157,7 +171,7 @@ docker compose pull && docker compose up -d --wait
### Все каналы в одном окне
Telegram, MAX, электронная почта и чат на сайте попадают в единый список диалогов. Сотрудник видит, откуда пришёл клиент, и отвечает в том же канале.
Telegram, MAX, ВКонтакте, электронная почта и чат на сайте попадают в единый список диалогов. Сотрудник видит, откуда пришёл клиент, и отвечает в том же канале.
### База знаний с семантическим поиском
@@ -173,7 +187,7 @@ Telegram, MAX, электронная почта и чат на сайте по
### Аудио- и видеозвонки из чата
Клиент и сотрудник созваниваются прямо из диалога без сторонних сервисов. Работает в веб-виджете, Telegram и MAX. Для сложных сетей есть relay.
Клиент и сотрудник созваниваются прямо из диалога без сторонних сервисов. Работает в веб-виджете, Telegram, MAX и ВКонтакте. Для сложных сетей есть relay.
### Рабочее место оператора
@@ -255,7 +269,7 @@ docker compose logs gateway
</details>
<details>
<summary><strong>Сообщения из Telegram или MAX не приходят</strong></summary>
<summary><strong>Сообщения из Telegram, MAX или ВКонтакте не приходят</strong></summary>
Фоновый воркер опрашивает ботов. Проверьте, что он запущен:
+1 -1
View File
@@ -1 +1 @@
1.8.0
1.12.0
+34
View File
@@ -108,6 +108,13 @@ def knowledge_total_for_organization(organization_id: int) -> int:
)
def _integration_model(integration, key: str) -> str:
"""Модель, заданная в интеграции: подсказка в поле модели на карточке."""
if integration is None:
return ""
return str((integration.config or {}).get(key) or "")
def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) -> dict[str, object]:
agent: AIAgent = channel.ai_agent
connections = _connections_payload(channel)
@@ -125,8 +132,18 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
# Цвет группы задаётся в настройках — точка у названия (кадры G1/G3).
"groupColor": channel.group.color if channel.group_id else "",
"aiStatus": agent.status,
# Модели агента: пустая строка означает «как в интеграции», и тогда
# карточка показывает модель интеграции подсказкой в поле.
"model": agent.model,
"transcriptionModel": agent.transcription_model,
"providerModel": _integration_model(agent.provider_integration, "default_model"),
"transcriptionProviderModel": _integration_model(
agent.transcription_integration or agent.provider_integration,
"transcription_model",
),
"providerIntegrationId": agent.provider_integration_id,
# Чем расшифровывать голосовые; пусто — тем же провайдером, что отвечает.
"transcriptionIntegrationId": agent.transcription_integration_id,
"modelParams": agent.model_params,
"answerLanguage": agent.answer_language,
"persona": agent.persona,
@@ -230,6 +247,9 @@ def update_agent_card(
ai_fields = {
"providerIntegrationId",
"transcriptionIntegrationId",
"model",
"transcriptionModel",
"modelParams",
"persona",
"tone",
@@ -256,6 +276,15 @@ def update_agent_card(
provider_integration_id, int
):
raise ValidationError({"providerIntegrationId": t("api.integer_id_required")})
transcription_integration_id = body.get(
"transcriptionIntegrationId", agent.transcription_integration_id
)
if transcription_integration_id is not None and not isinstance(
transcription_integration_id, int
):
raise ValidationError(
{"transcriptionIntegrationId": t("api.integer_id_required")}
)
update_agent(
context=context,
agent=agent,
@@ -263,6 +292,11 @@ def update_agent_card(
# Имя агента следует за именем карточки: сущность одна.
name=channel.name,
provider_integration_id=provider_integration_id,
transcription_integration_id=transcription_integration_id,
model=str(body.get("model", agent.model)),
transcription_model=str(
body.get("transcriptionModel", agent.transcription_model)
),
model_params=model_params,
allowed_tools=agent.allowed_tools,
persona=str(body.get("persona", agent.persona)),
@@ -0,0 +1,20 @@
# Generated by Django 5.2.16 on 2026-09-15 04:24
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0019_drop_llm_cost_accounting'),
('integrations', '0008_encrypted_column_width'),
]
operations = [
migrations.AddField(
model_name='aiagent',
name='transcription_integration',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transcribing_agents', to='integrations.integration'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.16 on 2026-09-15 05:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0020_aiagent_transcription_integration'),
]
operations = [
migrations.AddField(
model_name='aiagent',
name='transcription_model',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AlterField(
model_name='aiagent',
name='model',
field=models.CharField(blank=True, default='', max_length=128),
),
]
@@ -0,0 +1,34 @@
"""Агент перестаёт дублировать модель интеграции.
Раньше модель копировалась на агента при каждом сохранении, и поле означало
«то же, что у интеграции». Теперь заполненное поле означает выбор человека:
агент отвечает именно этой моделью, даже если у интеграции другая по
умолчанию. Чтобы смена настройки провайдера не перестала доезжать до агентов,
которым модель никто не выбирал, совпадающее значение очищается — такие агенты
продолжают следовать за интеграцией.
"""
from django.db import migrations
def release_copied_models(apps, schema_editor):
AIAgent = apps.get_model("ai", "AIAgent")
updated = []
for agent in AIAgent.objects.select_related("provider_integration").exclude(model=""):
integration = agent.provider_integration
default_model = str((integration.config or {}).get("default_model") or "") if integration else ""
if agent.model == default_model:
agent.model = ""
updated.append(agent)
AIAgent.objects.bulk_update(updated, ["model"])
class Migration(migrations.Migration):
dependencies = [
("ai", "0021_aiagent_transcription_model_alter_aiagent_model"),
]
operations = [
migrations.RunPython(release_copied_models, migrations.RunPython.noop),
]
+20 -2
View File
@@ -334,7 +334,7 @@ class KnowledgeFragment(TenantRelationModel):
class AIAgent(TenantRelationModel):
tenant_relation_fields = ("channel", "provider_integration")
tenant_relation_fields = ("channel", "provider_integration", "transcription_integration")
channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
@@ -358,6 +358,18 @@ class AIAgent(TenantRelationModel):
)
# Чем расшифровывать голосовые. Обычно это тот же провайдер, что и отвечает,
# но не всегда: модель, которая пишет ответы, может не уметь речь в текст
# (у Anthropic и Yandex Foundation Models аудио-эндпоинта нет вовсе).
# Пусто — расшифровка идёт к провайдеру ответов, как было.
transcription_integration = models.ForeignKey(
"integrations.Integration",
on_delete=models.PROTECT,
related_name="transcribing_agents",
null=True,
blank=True,
)
name = models.CharField(max_length=255)
status = models.CharField(
@@ -372,7 +384,13 @@ class AIAgent(TenantRelationModel):
lifecycle_version = models.PositiveIntegerField(default=0)
model = models.CharField(max_length=128, default=DEFAULT_AI_MODEL)
# Модель ответов. Пусто — берётся модель по умолчанию из интеграции; так
# агент следует за настройкой провайдера. Заполнено — решает агент: на одном
# ключе живут разные агенты, и дорогая модель нужна не каждому.
model = models.CharField(max_length=128, blank=True, default="")
# Модель расшифровки голосовых. Пусто — модель из интеграции, которая
# расшифровывает, а если и там пусто — whisper-1.
transcription_model = models.CharField(max_length=128, blank=True, default="")
model_params = models.JSONField(default=dict, blank=True)
@@ -30,3 +30,16 @@ def get_provider(*, channel=None) -> LLMProvider:
t("ai.provider_not_configured")
)
return routing.resolve_provider(channel)
def get_transcription_provider(*, channel=None) -> LLMProvider:
"""Провайдер расшифровки голосовых.
Отличается от `get_provider` одним: агент может расшифровывать другим
провайдером, чем отвечает (chatballs.ai.provider.routing).
"""
if settings.CHATBALLS_AI_PROVIDER == "test":
return _test_provider()
if channel is None:
raise ProviderError(t("ai.provider_not_configured"))
return routing.resolve_transcription_provider(channel)
@@ -36,7 +36,12 @@ class OpenRouterProvider(LLMProvider):
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
# OpenAI-совместимый POST /audio/transcriptions (whisper). Формат ответа
# {"text": "..."}; ошибки транслируются в ProviderError.
#
# Наружу уходит фраза для человека, а не ответ провайдера: оператору
# в ленте сообщений нечего делать с JSON чужого API. Сам ответ пишется
# в журнал — по нему разбирают настройку.
import json
import logging
import urllib.error
import urllib.request
@@ -44,6 +49,8 @@ class OpenRouterProvider(LLMProvider):
from chatballs.conversations.transports.base import multipart_body
from chatballs.integrations.proxy import build_opener
logger = logging.getLogger(__name__)
body, body_type = multipart_body(
{"model": model},
file_field="file",
@@ -65,9 +72,24 @@ class OpenRouterProvider(LLMProvider):
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", "replace")[:300]
raise ProviderError(t("ai.transcription_failed_http", code=error.code, detail=detail)) from error
logger.warning(
"Transcription rejected by %s: HTTP %s %s (model=%s)",
self.base_url,
error.code,
detail,
model,
)
# 401/403 — ключ или доступ; 404 — у провайдера нет эндпоинта
# расшифровки (так отвечают Anthropic и Yandex Foundation Models);
# остальное — временный отказ, который лечится повтором.
if error.code in (401, 403):
raise ProviderError(t("ai.transcription_denied")) from error
if error.code == 404:
raise ProviderError(t("ai.transcription_unsupported")) from error
raise ProviderError(t("ai.transcription_failed")) from error
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
raise ProviderError(t("ai.transcription_failed", error=error)) from error
logger.warning("Transcription request to %s failed: %s", self.base_url, error)
raise ProviderError(t("ai.transcription_unreachable")) from error
text = str(payload.get("text") or "").strip()
if not text:
raise ProviderError(t("ai.empty_transcript"))
+38 -3
View File
@@ -57,6 +57,17 @@ def resolve_provider_and_model(channel, *, fallback_model: str) -> tuple[LLMProv
def resolve_model(channel, *, fallback_model: str) -> str:
"""Модель ответов: выбранная на карточке агента, иначе модель интеграции.
Порядок именно такой: ключ провайдера один на организацию, а агентов на нём
несколько, и модель — свойство агента, а не ключа. Пустое поле на карточке
означает «как у интеграции», поэтому агент, которому модель не назначали,
продолжает следовать за настройкой провайдера.
"""
agent = getattr(channel, "ai_agent", None)
chosen = str(getattr(agent, "model", "") or "").strip()
if chosen:
return chosen
integration = _channel_integration(channel)
return str(integration.config.get("default_model") or "").strip() or fallback_model
@@ -64,10 +75,34 @@ def resolve_model(channel, *, fallback_model: str) -> str:
DEFAULT_TRANSCRIPTION_MODEL = "whisper-1"
def _transcription_integration(channel) -> Integration:
"""Чем расшифровывать голосовые.
Обычно тем же провайдером, что и отвечает, но выбор отдельный: модель
ответов может не уметь речь в текст. У Anthropic и Yandex Foundation Models
эндпоинта `/audio/transcriptions` нет вовсе, и без отдельного выбора
голосовые у такого агента расшифровать было нечем.
"""
agent = getattr(channel, "ai_agent", None)
integration = getattr(agent, "transcription_integration", None) if agent else None
if integration is None or not integration.secret:
return _channel_integration(channel)
return integration
def resolve_transcription_provider(channel) -> LLMProvider:
"""Провайдер расшифровки: отдельная интеграция агента либо провайдер ответов."""
return _provider_from_integration(_transcription_integration(channel))
def resolve_transcription_model(channel) -> str:
"""Модель расшифровки голосовых из настроек AI-провайдера («Настройки →
AI-провайдер», поле «Модель расшифровки»); по умолчанию whisper-1."""
integration = _channel_integration(channel)
"""Модель расшифровки: выбранная на карточке агента, иначе модель той
интеграции, которая расшифровывает, иначе whisper-1."""
agent = getattr(channel, "ai_agent", None)
chosen = str(getattr(agent, "transcription_model", "") or "").strip()
if chosen:
return chosen
integration = _transcription_integration(channel)
return str(integration.config.get("transcription_model") or "").strip() or DEFAULT_TRANSCRIPTION_MODEL
@@ -56,3 +56,31 @@ def configure_agent_provider(
{"providerIntegrationId": t("ai.integration_model_required")}
)
return ProviderSelection(model, integration)
def configure_agent_transcription(
*, context: TenantContext, integration_id: int | None
) -> Integration | None:
"""Интеграция, которой агент расшифровывает голосовые.
Пусто — расшифровка идёт к провайдеру ответов. Модель для неё живёт в самой
интеграции («Модель расшифровки голосовых»), поэтому здесь проверяется
только, что интеграция принадлежит организации и умеет быть провайдером.
"""
if integration_id is None:
return None
try:
return Integration.objects.get(
id=integration_id,
organization_id=context.organization_id,
kind=IntegrationKind.LLM_PROVIDER,
provider__in=[
IntegrationProvider.OPENROUTER,
IntegrationProvider.CUSTOM,
IntegrationProvider.DEMO,
],
)
except (Integration.DoesNotExist, TypeError, ValueError) as error:
raise ValidationError(
{"transcriptionIntegrationId": t("ai.unknown_provider_integration")}
) from error
+23 -5
View File
@@ -9,7 +9,10 @@ from chatballs.ai.models import (
AIAgentStatus,
Knowledge,
)
from chatballs.ai.provider_selection import configure_agent_provider
from chatballs.ai.provider_selection import (
configure_agent_provider,
configure_agent_transcription,
)
from chatballs.channels.models import Channel
from chatballs.i18n import t
from chatballs.tenancy.context import TenantContext
@@ -19,6 +22,11 @@ from chatballs.tenancy.context import TenantContext
class AgentInput:
name: str
provider_integration_id: int | None
# Чем расшифровывать голосовые; None — тем же провайдером, что и отвечает.
transcription_integration_id: int | None
# Модели агента; пустая строка — «как в интеграции».
model: str
transcription_model: str
model_params: dict
allowed_tools: list
persona: str
@@ -90,7 +98,9 @@ def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
channel=channel,
name=f"{channel.name} Agent",
status=AIAgentStatus.DRAFT,
model=selection.model,
# Модель новой карточки не фиксируется: агент следует за интеграцией,
# пока человек не выберет свою.
model="",
provider_integration=selection.integration,
persona=data.persona,
tone=data.tone,
@@ -124,11 +134,17 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
context=context,
integration_id=data.provider_integration_id,
)
# Модель принадлежит интеграции; без провайдера прежняя модель сохраняется,
# чтобы PATCH инструкций не стирал её у черновика.
locked.model = selection.model if selection.integration else locked.model
# Модель выбирают на карточке агента: на одном ключе провайдера живут разные
# агенты, и модель им нужна разная. Пустое поле означает «как в интеграции»
# и разрешается в момент вызова (ai.provider.routing).
locked.model = data.model.strip()[:128]
locked.transcription_model = data.transcription_model.strip()[:128]
# Провайдер живёт на агенте: канал больше не изменяется при сохранении агента.
locked.provider_integration = selection.integration
locked.transcription_integration = configure_agent_transcription(
context=context,
integration_id=data.transcription_integration_id,
)
locked.model_params = data.model_params
locked.allowed_tools = data.allowed_tools
locked.persona = data.persona
@@ -139,7 +155,9 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
update_fields=[
"name",
"model",
"transcription_model",
"provider_integration",
"transcription_integration",
"model_params",
"allowed_tools",
"persona",
+53 -2
View File
@@ -274,6 +274,55 @@ class AgentCardActivationTests(AgentCardTestCase):
),
)
def test_transcription_integration_is_chosen_separately(self) -> None:
# Модель ответов не обязана уметь речь в текст: у части провайдеров
# аудио-эндпоинта нет вовсе, поэтому расшифровку можно увести к другому.
from chatballs.integrations.models import IntegrationProvider
from chatballs.integrations.services import IntegrationInput, create_integration
from chatballs.testing import system_tenant_context
answering = self._byok_integration()
whisper = create_integration(
context=system_tenant_context(self.organization),
data=IntegrationInput(
provider=IntegrationProvider.CUSTOM,
name="Whisper",
secret="sk-whisper",
config={
"baseUrl": "https://api.groq.com/openai/v1",
"defaultModel": "any",
"transcriptionModel": "whisper-large-v3",
},
),
)
patched = self.client.patch(
f"/api/v1/agents/{self.card['id']}/",
data=json.dumps(
{
"providerIntegrationId": answering.id,
"transcriptionIntegrationId": whisper.id,
}
),
content_type="application/json",
)
self.assertEqual(patched.status_code, 200)
self.assertEqual(
patched.json()["agent"]["transcriptionIntegrationId"], whisper.id
)
agent = AIAgent.objects.get(id=self.card["aiAgentId"])
self.assertEqual(agent.transcription_integration_id, whisper.id)
self.assertEqual(agent.provider_integration_id, answering.id)
cleared = self.client.patch(
f"/api/v1/agents/{self.card['id']}/",
data=json.dumps({"transcriptionIntegrationId": None}),
content_type="application/json",
)
self.assertEqual(cleared.status_code, 200)
self.assertIsNone(cleared.json()["agent"]["transcriptionIntegrationId"])
def test_activation_without_provider_integration_is_rejected(self) -> None:
# Активация требует выбранного провайдера организации (ADR-CHATBALLS-0042 §2);
# деактивация свободна.
@@ -292,8 +341,10 @@ class AgentCardActivationTests(AgentCardTestCase):
content_type="application/json",
)
self.assertEqual(patched.status_code, 200)
# Модель принадлежит интеграции: агент получает её default_model.
self.assertEqual(patched.json()["agent"]["model"], "byok-model")
# Своей модели у агента нет — он следует за интеграцией, и карточка
# показывает её модель подсказкой.
self.assertEqual(patched.json()["agent"]["model"], "")
self.assertEqual(patched.json()["agent"]["providerModel"], "byok-model")
activated = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/")
self.assertEqual(activated.status_code, 200)
@@ -58,7 +58,10 @@ class ProviderModeTests(TestCase):
)
agent = self.channel.ai_agent
agent.provider_integration = integration
agent.save(update_fields=["provider_integration"])
# Пустая модель на агенте означает «как в интеграции» — именно так живёт
# агент, которому модель не выбирали на карточке.
agent.model = ""
agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
return integration
@@ -199,7 +202,9 @@ class AgentProviderOwnershipTests(TestCase):
self.channel.provider_integration = integration
self.channel.save(update_fields=["provider_integration"])
self.agent.provider_integration = None
self.agent.save(update_fields=["provider_integration"])
# Агент следует за интеграцией: своей модели у него нет.
self.agent.model = ""
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
@@ -220,9 +225,23 @@ class AgentProviderOwnershipTests(TestCase):
),
)
self.agent.provider_integration = current
self.agent.save(update_fields=["provider_integration"])
self.agent.model = ""
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
resolve_model(self.channel, fallback_model="agent-model"), "current-model"
)
def test_model_chosen_on_the_card_wins_over_the_integration(self) -> None:
# Ключ провайдера один на организацию, агентов на нём несколько: модель
# выбирают агенту, и она не должна теряться при смене настройки ключа.
integration = self._integration(default_model="integration-model")
self.agent.provider_integration = integration
self.agent.model = "own-model"
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
resolve_model(self.channel, fallback_model="agent-model"), "own-model"
)
@@ -0,0 +1,200 @@
"""Расшифровка голосовых может идти не к тому провайдеру, который отвечает.
Модель ответов часто не умеет речь в текст: у Anthropic и Yandex Foundation
Models эндпоинта `/audio/transcriptions` нет вовсе. Поэтому интеграция для
расшифровки выбирается на агенте отдельно.
"""
import json
import urllib.error
from io import BytesIO
from unittest import mock
from django.test import TestCase
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.custom import CustomProvider
from chatballs.ai.provider.routing import (
resolve_model,
resolve_transcription_model,
resolve_transcription_provider,
)
from chatballs.ai.tests import make_channel_with_agent
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import IntegrationProvider
from chatballs.integrations.services import IntegrationInput, create_integration
from chatballs.testing import system_tenant_context
class TranscriptionRoutingTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.context = system_tenant_context(self.organization)
self.channel, self.agent = make_channel_with_agent(
self.organization, code="voice-agent", name="Голосовой агент"
)
def _integration(self, *, name: str, base_url: str, transcription_model: str = ""):
config = {"baseUrl": base_url, "defaultModel": "answer-model"}
if transcription_model:
config["transcriptionModel"] = transcription_model
return create_integration(
context=self.context,
data=IntegrationInput(
provider=IntegrationProvider.CUSTOM,
name=name,
secret="sk-key",
config=config,
),
)
def test_without_a_choice_transcription_goes_to_the_answering_provider(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.save(update_fields=["provider_integration"])
self.channel.refresh_from_db()
provider = resolve_transcription_provider(self.channel)
self.assertIsInstance(provider, CustomProvider)
self.assertEqual(provider.base_url, "https://answers.example.test/v1")
def test_chosen_integration_takes_the_voice(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
whisper = self._integration(
name="Whisper",
base_url="https://whisper.example.test/v1",
transcription_model="whisper-large-v3",
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.save(
update_fields=["provider_integration", "transcription_integration"]
)
self.channel.refresh_from_db()
provider = resolve_transcription_provider(self.channel)
self.assertEqual(provider.base_url, "https://whisper.example.test/v1")
self.assertEqual(resolve_transcription_model(self.channel), "whisper-large-v3")
def test_model_defaults_to_whisper_of_the_chosen_integration(self) -> None:
answering = self._integration(
name="Ответы",
base_url="https://answers.example.test/v1",
transcription_model="answer-side-model",
)
whisper = self._integration(
name="Whisper", base_url="https://whisper.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.save(
update_fields=["provider_integration", "transcription_integration"]
)
self.channel.refresh_from_db()
self.assertEqual(resolve_transcription_model(self.channel), "whisper-1")
class TranscriptionModelTests(TranscriptionRoutingTests):
"""Модель расшифровки тоже выбирается на агенте, а не только в интеграции."""
def test_model_from_the_card_wins(self) -> None:
whisper = self._integration(
name="Whisper",
base_url="https://whisper.example.test/v1",
transcription_model="whisper-large-v3",
)
self.agent.provider_integration = whisper
self.agent.transcription_integration = whisper
self.agent.transcription_model = "gpt-4o-mini-transcribe"
self.agent.save(
update_fields=[
"provider_integration",
"transcription_integration",
"transcription_model",
]
)
self.channel.refresh_from_db()
self.assertEqual(
resolve_transcription_model(self.channel), "gpt-4o-mini-transcribe"
)
def test_text_and_voice_models_are_independent(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
whisper = self._integration(
name="Whisper", base_url="https://whisper.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.model = "yandexgpt/rc"
self.agent.transcription_model = "whisper-large-v3-turbo"
self.agent.save(
update_fields=[
"provider_integration",
"transcription_integration",
"model",
"transcription_model",
]
)
self.channel.refresh_from_db()
self.assertEqual(resolve_model(self.channel, fallback_model=""), "yandexgpt/rc")
self.assertEqual(
resolve_transcription_model(self.channel), "whisper-large-v3-turbo"
)
self.assertEqual(
resolve_transcription_provider(self.channel).base_url,
"https://whisper.example.test/v1",
)
class TranscriptionErrorTextTests(TestCase):
"""Оператору — фраза, провайдеру — журнал: сырого ответа API в ленте нет."""
def _provider(self) -> CustomProvider:
return CustomProvider(
api_key="sk-key", base_url="https://api.example.test/v1", timeout=5
)
def _fail_with(self, code: int, body: bytes):
error = urllib.error.HTTPError(
"https://api.example.test/v1/audio/transcriptions",
code,
"error",
{},
BytesIO(body),
)
return mock.patch(
"chatballs.integrations.proxy.build_opener",
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
)
def _transcribe(self):
return self._provider().transcribe(
audio=b"0" * 16, filename="voice.ogg", content_type="audio/ogg", model="m"
)
def test_denied_request_does_not_leak_the_provider_answer(self) -> None:
body = json.dumps(
{"error": {"message": "Subscription is not supported for service accounts"}}
).encode()
with self._fail_with(403, body), self.assertRaises(ProviderError) as caught:
self._transcribe()
message = str(caught.exception)
self.assertNotIn("Subscription", message)
self.assertNotIn("403", message)
self.assertIn("ключ", message)
def test_missing_endpoint_tells_where_to_look(self) -> None:
with self._fail_with(404, b"not found"), self.assertRaises(ProviderError) as caught:
self._transcribe()
self.assertIn("расшифров", str(caught.exception).lower())
@@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlparse
from django.utils import timezone
from chatballs.calls.event_handlers import CallInviteDeliveryError, handle_call_invite_send
from chatballs.calls.lifecycle import transition_call
from chatballs.calls.models import (
CallEndedBy,
CallInvite,
@@ -16,7 +17,6 @@ from chatballs.calls.models import (
CallStatus,
InviteDeliveryStatus,
)
from chatballs.calls.lifecycle import transition_call
from chatballs.calls.services import (
decline_call_for_identity,
open_call_for_identity,
@@ -18,6 +18,7 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
@@ -26,7 +27,6 @@ from chatballs.conversations.models import (
Conversation,
LifecycleState,
)
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.i18n import t
from chatballs.identity.audit_catalog import (
audit_action_label,
@@ -40,6 +40,7 @@ from chatballs.identity.models import AuditEvent
PROVIDER_CODE = {
"MAX": "MAX",
"TELEGRAM": "TG",
"VK": "VK",
"WEB": "WEB",
"EMAIL": "EMAIL",
}
@@ -100,7 +100,7 @@ class TranscriptionJob:
def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
"""Шаг в транзакции: провайдер организации, модель и байты аудио."""
from chatballs.ai.provider.factory import get_provider
from chatballs.ai.provider.factory import get_transcription_provider
from chatballs.ai.provider.routing import (
DEFAULT_TRANSCRIPTION_MODEL,
resolve_transcription_model,
@@ -108,7 +108,7 @@ def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
if not message.audio:
return None
provider = get_provider(channel=channel)
provider = get_transcription_provider(channel=channel)
try:
model = resolve_transcription_model(channel)
except ProviderError:
@@ -1,5 +1,6 @@
from django.db.models import Count, Max, Q
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.conversations.models import (
ConnectionIdentity,
Conversation,
@@ -8,7 +9,6 @@ from chatballs.conversations.models import (
MessageKind,
)
from chatballs.i18n import t
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.identity.avatars import user_avatar_url_in
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import IntegrationProvider
@@ -0,0 +1,303 @@
"""Транспорт ВКонтакте: разбор апдейтов, курсор Long Poll, отправка.
Тесты идут без базы: транспорту нужны только поля подключения, поэтому вместо
записи в базу здесь простая заглушка. Сеть закрыта подменой вызова API и
опроса сервера событий.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest import mock
from django.test import SimpleTestCase
from chatballs.conversations.transports import vk, vk_media, vk_send
from chatballs.conversations.transports.errors import PollFailed
from chatballs.integrations.checks import VkRejected
SERVER = {"server": "https://lp.vk.com/wh1", "key": "lp-key", "ts": "100"}
PROFILE = {"id": 77, "first_name": "Иван", "last_name": "Петров", "screen_name": "ivan", "photo_100": "https://vk.com/ivan.jpg"}
def _integration(**config):
return SimpleNamespace(
id=1,
secret="vk-community-token",
poll_marker="",
config={"bot_id": "42", **config},
organization=SimpleNamespace(language="ru"),
)
def _update(**message):
payload = {"from_id": 77, "peer_id": 77, "id": 500, "text": "Здравствуйте", **message}
return {"type": "message_new", "object": {"message": payload}}
class _Api:
"""Подмена вызова API: ответ на метод и журнал обращений."""
def __init__(self, **responses):
self.responses = {"users.get": [PROFILE], "groups.getLongPollServer": SERVER, **responses}
self.calls: list[tuple[str, dict]] = []
def __call__(self, integration, method, params=None, *, post=False):
self.calls.append((method, dict(params or {})))
response = self.responses.get(method)
if isinstance(response, Exception):
raise response
return response
def methods(self) -> list[str]:
return [method for method, _params in self.calls]
def params(self, method: str) -> dict:
return next(params for name, params in self.calls if name == method)
class VkInboundTests(SimpleTestCase):
def setUp(self) -> None:
vk.reset()
self.addCleanup(vk.reset)
def _poll(self, check_result, *, api=None, integration=None):
api = api or _Api()
integration = integration or _integration()
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", return_value=check_result) as request:
messages, marker = vk.poll_updates(integration)
return messages, marker, api, request
def test_message_becomes_inbound_with_profile_from_one_request(self) -> None:
api = _Api()
messages, marker, api, _request = self._poll(
{"ts": "101", "updates": [_update(), _update(id=501, text="Ещё вопрос")]},
api=api,
)
self.assertEqual(marker, "101")
self.assertEqual([message.text for message in messages], ["Здравствуйте", "Ещё вопрос"])
self.assertEqual(messages[0].external_id, "500")
self.assertEqual(messages[0].user_id, "77")
self.assertEqual(messages[0].chat_id, "77")
self.assertEqual(messages[0].display_name, "Иван Петров")
self.assertEqual(messages[0].username, "ivan")
self.assertEqual(messages[0].avatar_url, "https://vk.com/ivan.jpg")
# Профиль спрашивается один раз на пачку, а не на каждое сообщение.
self.assertEqual(api.methods().count("users.get"), 1)
self.assertEqual(api.params("users.get")["user_ids"], "77")
def test_community_own_message_and_other_events_are_ignored(self) -> None:
messages, _marker, _api, _request = self._poll(
{
"ts": "102",
"updates": [
_update(from_id=-42),
{"type": "group_join", "object": {"user_id": 77}},
],
}
)
self.assertEqual(messages, [])
def test_profile_failure_does_not_lose_the_message(self) -> None:
api = _Api(**{"users.get": VkRejected("VK отклонил запрос")})
messages, _marker, _api, _request = self._poll({"ts": "103", "updates": [_update()]}, api=api)
self.assertEqual([message.text for message in messages], ["Здравствуйте"])
self.assertEqual(messages[0].display_name, "")
def test_first_cycle_takes_position_from_vk_and_next_one_from_the_marker(self) -> None:
api = _Api()
integration = _integration()
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", return_value={"ts": "101", "updates": []}) as request:
vk.poll_updates(integration)
integration.poll_marker = "101"
vk.poll_updates(integration)
self.assertIn("ts=100", request.call_args_list[0].args[0])
self.assertIn("ts=101", request.call_args_list[1].args[0])
# Адрес Long Poll выдаётся один раз и живёт в памяти процесса.
self.assertEqual(api.methods().count("groups.getLongPollServer"), 1)
def test_zero_position_of_a_fresh_community_is_kept(self) -> None:
# Сообществу, которому ещё не писали, ВКонтакте отдаёт ts=0. Пустая
# позиция в опросе возвращает ts=-1 — курсор, с которого поток уже не
# читается, и первое же сообщение клиента прошло бы мимо.
api = _Api()
api.responses["groups.getLongPollServer"] = {**SERVER, "ts": 0}
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", return_value={"ts": "0", "updates": []}) as request:
_messages, marker = vk.poll_updates(_integration())
self.assertIn("ts=0", request.call_args_list[0].args[0])
self.assertEqual(marker, "0")
def test_outdated_position_is_retried_with_the_one_vk_returned(self) -> None:
api = _Api()
integration = _integration()
integration.poll_marker = "90"
answers = [{"failed": 1, "ts": "100"}, {"ts": "101", "updates": [_update()]}]
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", side_effect=answers) as request:
messages, marker = vk.poll_updates(integration)
self.assertEqual(marker, "101")
self.assertEqual(len(messages), 1)
self.assertIn("ts=100", request.call_args_list[1].args[0])
def test_expired_key_takes_a_new_one_and_keeps_the_position(self) -> None:
api = _Api()
api.responses["groups.getLongPollServer"] = {**SERVER, "key": "fresh-key", "ts": "900"}
integration = _integration()
integration.poll_marker = "90"
answers = [{"failed": 2}, {"ts": "91", "updates": []}]
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", side_effect=answers) as request:
_messages, marker = vk.poll_updates(integration)
self.assertEqual(marker, "91")
retry_url = request.call_args_list[1].args[0]
self.assertIn("key=fresh-key", retry_url)
self.assertIn("ts=90", retry_url)
def test_lost_history_takes_both_the_key_and_the_position(self) -> None:
api = _Api()
api.responses["groups.getLongPollServer"] = {**SERVER, "key": "fresh-key", "ts": "900"}
integration = _integration()
integration.poll_marker = "90"
answers = [{"failed": 3}, {"ts": "901", "updates": []}]
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", side_effect=answers) as request:
_messages, marker = vk.poll_updates(integration)
self.assertEqual(marker, "901")
self.assertIn("ts=900", request.call_args_list[1].args[0])
def test_broken_connection_is_a_poll_failure_and_forgets_the_session(self) -> None:
api = _Api()
integration = _integration()
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", side_effect=TimeoutError("timed out")):
with self.assertRaises(PollFailed):
vk.poll_updates(integration)
self.assertEqual(vk._sessions, {})
def test_token_never_leaks_into_the_failure_text(self) -> None:
api = _Api()
leak = OSError("HTTP Error 401: https://api.vk.com/method/users.get?access_token=vk1.a.SECRET&v=5.199")
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", side_effect=leak):
with self.assertRaises(PollFailed) as failure:
vk.poll_updates(_integration())
self.assertNotIn("vk1.a.SECRET", str(failure.exception))
self.assertIn("access_token=***", str(failure.exception))
def test_group_is_asked_once_when_the_connection_was_never_checked(self) -> None:
api = _Api(**{"groups.getById": {"groups": [{"id": 42, "name": "Acme"}]}})
integration = _integration()
integration.config.pop("bot_id")
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", return_value={"ts": "101", "updates": []}):
vk.poll_updates(integration)
self.assertEqual(api.params("groups.getLongPollServer")["group_id"], "42")
class VkAttachmentTests(SimpleTestCase):
def setUp(self) -> None:
vk.reset()
self.addCleanup(vk.reset)
def test_photo_takes_the_largest_size(self) -> None:
attachment = {
"type": "photo",
"photo": {"sizes": [
{"url": "https://vk.com/small.jpg", "width": 75, "height": 75},
{"url": "https://vk.com/large.jpg", "width": 1280, "height": 960},
]},
}
files = vk_media.file_attachments({"attachments": [attachment]})
self.assertEqual(len(files), 1)
self.assertEqual(files[0].url, "https://vk.com/large.jpg")
self.assertTrue(files[0].is_image)
def test_document_keeps_its_name_and_size(self) -> None:
attachment = {"type": "doc", "doc": {"url": "https://vk.com/f.pdf", "title": "Счёт.pdf", "size": 2048}}
files = vk_media.file_attachments({"attachments": [attachment]})
self.assertEqual(files[0].name, "Счёт.pdf")
self.assertEqual(files[0].content_type, "application/pdf")
self.assertEqual(files[0].size, 2048)
self.assertFalse(files[0].is_image)
def test_voice_message_is_taken_in_ogg(self) -> None:
attachment = {
"type": "audio_message",
"audio_message": {"duration": 7, "link_ogg": "https://vk.com/v.ogg", "link_mp3": "https://vk.com/v.mp3"},
}
url, duration, mime, unavailable = vk_media.voice_attachment({"attachments": [attachment]})
self.assertEqual((url, duration, mime, unavailable), ("https://vk.com/v.ogg", 7, "audio/ogg", False))
def test_voice_without_a_link_still_reaches_the_operator(self) -> None:
_url, _duration, _mime, unavailable = vk_media.voice_attachment(
{"attachments": [{"type": "audio_message", "audio_message": {"duration": 3}}]}
)
self.assertTrue(unavailable)
def test_attachment_the_channel_cannot_show_does_not_swallow_the_message(self) -> None:
update = _update(text="", attachments=[{"type": "video", "video": {"id": 1}}])
with mock.patch.object(vk, "customer_language", return_value="ru"):
message = vk._normalize(_integration(), update)
self.assertIsNotNone(message)
self.assertTrue(message.text)
class VkOutboundTests(SimpleTestCase):
def test_text_goes_out_by_post_with_a_deduplication_id(self) -> None:
api = _Api(**{"messages.send": {"response": 1}})
posts = []
def record(integration, method, params=None, *, post=False):
posts.append(post)
return api(integration, method, params, post=post)
with mock.patch.object(vk_send.vk_api, "call", record):
self.assertTrue(vk_send.send_text(_integration(), chat_id="77", user_id="77", text="Ответ"))
params = api.params("messages.send")
self.assertEqual(params["peer_id"], "77")
self.assertEqual(params["message"], "Ответ")
self.assertTrue(params["random_id"])
self.assertEqual(posts, [True])
def test_call_invite_carries_a_link_button(self) -> None:
api = _Api(**{"messages.send": {"response": 1}})
with mock.patch.object(vk_send.vk_api, "call", api):
with mock.patch.object(vk_send, "customer_language", return_value="ru"):
sent = vk_send.send_call_invite(
_integration(), chat_id="77", user_id="77", text="Звонок", url="https://hub.test/calls/abc"
)
self.assertTrue(sent)
self.assertIn("https://hub.test/calls/abc", api.params("messages.send")["keyboard"])
def test_rejected_send_is_reported_as_failure(self) -> None:
api = _Api(**{"messages.send": VkRejected("ВКонтакте отклонил запрос (7): access denied")})
with mock.patch.object(vk_send.vk_api, "call", api):
self.assertFalse(vk_send.send_text(_integration(), chat_id="77", user_id="", text="Ответ"))
def test_photo_is_uploaded_and_attached(self) -> None:
api = _Api(
**{
"photos.getMessagesUploadServer": {"upload_url": "https://upload.vk.com/1"},
"photos.saveMessagesPhoto": [{"owner_id": 5, "id": 9}],
"messages.send": {"response": 1},
}
)
with mock.patch.object(vk_send.vk_api, "call", api):
with mock.patch.object(
vk_media, "request_json_multipart", return_value={"server": "1", "photo": "[]", "hash": "h"}
):
sent = vk_send.send_file(
_integration(),
chat_id="77",
user_id="77",
content=b"binary",
filename="photo.jpg",
content_type="image/jpeg",
caption="Схема",
)
self.assertTrue(sent)
self.assertEqual(api.params("messages.send")["attachment"], "photo5_9")
self.assertEqual(api.params("messages.send")["message"], "Схема")
@@ -2,6 +2,8 @@ from chatballs.conversations.transports import backoff
from chatballs.conversations.transports import email as _email
from chatballs.conversations.transports import max as _max
from chatballs.conversations.transports import telegram as _telegram
from chatballs.conversations.transports import vk as _vk
from chatballs.conversations.transports import vk_send as _vk_send
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import t
from chatballs.integrations.models import IntegrationProvider
@@ -9,6 +11,7 @@ from chatballs.integrations.models import IntegrationProvider
_POLL = {
IntegrationProvider.MAX: _max.poll_updates,
IntegrationProvider.TELEGRAM: _telegram.poll_updates,
IntegrationProvider.VK: _vk.poll_updates,
IntegrationProvider.EMAIL: _email.poll_updates,
}
def _web_noop(integration, *, chat_id: str, user_id: str, text: str) -> bool:
@@ -19,6 +22,7 @@ def _web_noop(integration, *, chat_id: str, user_id: str, text: str) -> bool:
_SEND = {
IntegrationProvider.MAX: _max.send_text,
IntegrationProvider.TELEGRAM: _telegram.send_text,
IntegrationProvider.VK: _vk_send.send_text,
IntegrationProvider.WEB: _web_noop,
IntegrationProvider.EMAIL: _email.send_text,
}
@@ -28,6 +32,9 @@ _SEND = {
_CONTACT_REQUEST = {
IntegrationProvider.MAX: _max.send_contact_request,
IntegrationProvider.TELEGRAM: _telegram.send_contact_request,
# ВКонтакте телефон не отдаёт и кнопки для этого не имеет — просьба
# уходит обычным сообщением, как и почтой.
IntegrationProvider.VK: _vk_send.send_text,
IntegrationProvider.WEB: _web_noop,
# Email: кнопок нет — просьба уходит обычным письмом.
IntegrationProvider.EMAIL: _email.send_text,
@@ -37,6 +44,7 @@ _CONTACT_REQUEST = {
_CONTACT_ACK = {
IntegrationProvider.MAX: _max.send_text,
IntegrationProvider.TELEGRAM: _telegram.send_contact_ack,
IntegrationProvider.VK: _vk_send.send_text,
IntegrationProvider.WEB: _web_noop,
IntegrationProvider.EMAIL: _email.send_text,
}
@@ -46,6 +54,7 @@ _CONTACT_ACK = {
_CALL_INVITE = {
IntegrationProvider.MAX: _max.send_call_invite,
IntegrationProvider.TELEGRAM: _telegram.send_call_invite,
IntegrationProvider.VK: _vk_send.send_call_invite,
}
# Провайдеры-мессенджеры, у которых есть транспорт приёма/отправки.
@@ -119,6 +128,7 @@ def _web_file_noop(integration, *, chat_id: str, user_id: str, content: bytes, f
_FILE_SEND = {
IntegrationProvider.TELEGRAM: _telegram.send_file,
IntegrationProvider.MAX: _max.send_file,
IntegrationProvider.VK: _vk_send.send_file,
IntegrationProvider.EMAIL: _email.send_file,
IntegrationProvider.WEB: _web_file_noop,
}
@@ -132,6 +142,8 @@ def download_file(integration, inbound_file) -> tuple[bytes, str]:
return content, inbound_file.content_type or guessed
if integration.provider == IntegrationProvider.MAX and inbound_file.url:
return _max.download_file(integration, inbound_file.url, inbound_file.content_type)
if integration.provider == IntegrationProvider.VK and inbound_file.url:
return _vk.download_file(integration, inbound_file.url, inbound_file.content_type)
raise ValueError(t("conversations.file_download_unsupported"))
@@ -181,6 +193,9 @@ def download_avatar(integration, inbound) -> tuple[bytes, str] | None:
if integration.provider == IntegrationProvider.MAX and inbound.avatar_url:
content, _content_type = _max.download_file(integration, inbound.avatar_url, "")
return (content, source) if content else None
if integration.provider == IntegrationProvider.VK and inbound.avatar_url:
content, _content_type = _vk.download_file(integration, inbound.avatar_url, "")
return (content, source) if content else None
return None
@@ -191,6 +206,8 @@ def download_voice(integration, inbound) -> tuple[bytes, str]:
return _telegram.download_voice(integration, inbound.voice_file_id)
if integration.provider == IntegrationProvider.MAX and inbound.voice_url:
return _max.download_voice(integration, inbound.voice_url)
if integration.provider == IntegrationProvider.VK and inbound.voice_url:
return _vk.download_voice(integration, inbound.voice_url, inbound.voice_mime)
raise ValueError(t("conversations.voice_download_unsupported"))
@@ -0,0 +1,236 @@
"""VK community transport: приём сообщений (Bots Long Poll API).
``groups.getLongPollServer`` отдаёт адрес сервера, ключ и позицию в потоке
событий, дальше сервер опрашивается ``act=a_check``. Отправка живёт в
``vk_send``, разбор вложений — в ``vk_media``, доступ к API — в ``vk_api``.
Позиция потока хранится в ``poll_marker``, а адрес сервера и ключ — в памяти
процесса: их выдают на несколько часов, и колонка под них означала бы запись в
базу на каждом цикле опроса (``transports.backoff`` устроен так же).
"""
from __future__ import annotations
import logging
import urllib.parse
from dataclasses import dataclass, replace
from django.conf import settings
from chatballs.conversations.transports import vk_api, vk_media
from chatballs.conversations.transports.base import InboundMessage, first, request_json
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import vk_group
from chatballs.integrations.outbound import mask_url_secrets
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class _Session:
server: str
key: str
group_id: str
_sessions: dict[int, _Session] = {}
def reset() -> None:
"""Забыть выданные адреса Long Poll (тесты, смена токена подключения)."""
_sessions.clear()
def _ts(value: object, fallback: str = "") -> str:
"""Позиция потока строкой.
Ноль — это тоже позиция: у сообщества, которому ещё не писали, ВКонтакте
отдаёт ``ts: 0``. Обычное ``or`` приняло бы её за отсутствие значения, а
опрос с пустой позицией возвращает ``ts: -1`` — курсор, с которого поток
уже не читается.
"""
return fallback if value is None or value == "" else str(value)
def _group_id(integration) -> str:
"""Идентификатор сообщества: из настроек подключения или у самого ВКонтакте.
Проверка подключения кладёт его в конфигурацию, но опрос обязан работать и
до первой проверки: иначе подключение, созданное в обход кнопки
«Проверить», молча не принимало бы сообщения.
"""
configured = str(integration.config.get("bot_id") or "")
if configured:
return configured
group_id = str(vk_group(vk_api.call(integration, "groups.getById")).get("id") or "")
if not group_id:
raise PollFailed(t("conversations.vk_no_group"))
return group_id
def _open_session(integration, group_id: str = "") -> tuple[_Session, str]:
"""Новый адрес Long Poll; вторым значением — позиция потока от ВКонтакте."""
group = group_id or _group_id(integration)
response = vk_api.call(integration, "groups.getLongPollServer", {"group_id": group}) or {}
session = _Session(
server=str(response.get("server") or ""),
key=str(response.get("key") or ""),
group_id=group,
)
if not session.server or not session.key:
raise PollFailed(t("conversations.vk_longpoll_unavailable"))
_sessions[integration.id] = session
return session, _ts(response.get("ts"))
def _check(integration, session: _Session, ts: str) -> dict:
query = urllib.parse.urlencode(
{
"act": "a_check",
"key": session.key,
"ts": ts,
"wait": settings.CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS,
}
)
return request_json(f"{session.server}?{query}", proxy_url=vk_api.proxy(integration))
def _recover(integration, session: _Session, ts: str, data: dict) -> tuple[dict, str]:
"""Ответ ``failed``: позиция устарела (1) либо ключ и история потеряны (2, 3).
Восстанавливаемся тут же, а не на следующем цикле: иначе подключение висело
бы с протухшим ключом, а поллер всё это время считал бы, что сообщений
просто нет.
"""
failed = int(data.get("failed") or 0)
if failed == 1:
ts = _ts(data.get("ts"), ts)
else:
session, fresh_ts = _open_session(integration, session.group_id)
# Потеря истории (3) обесценивает и позицию: со старой сервер не отдаст
# ничего. Протухший ключ (2) позицию не трогает.
ts = ts if failed == 2 else fresh_ts
retried = _check(integration, session, ts)
if retried.get("failed"):
raise PollFailed(t("conversations.vk_longpoll_failed", code=retried.get("failed")))
return retried, ts
def poll_updates(integration) -> tuple[list[InboundMessage], str]:
if not integration.secret:
return [], integration.poll_marker
try:
session = _sessions.get(integration.id)
ts = integration.poll_marker
if session is None or not ts:
session, fresh_ts = _open_session(integration, session.group_id if session else "")
ts = ts or fresh_ts
data = _check(integration, session, ts)
if data.get("failed"):
data, ts = _recover(integration, session, ts, data)
messages = _messages(integration, data.get("updates") or [])
except vk_api.CALL_ERRORS as error:
# Сессия могла протухнуть вместе со связью — следующая попытка начнёт с
# нового адреса, а не с сохранённого мёртвого.
_sessions.pop(integration.id, None)
raise PollFailed(mask_url_secrets(error)) from error
return messages, _ts(data.get("ts"), ts)
def _messages(integration, updates: list) -> list[InboundMessage]:
inbound = [message for message in (_normalize(integration, u) for u in updates) if message is not None]
return _with_profiles(integration, inbound)
def _normalize(integration, update: dict) -> InboundMessage | None:
if update.get("type") != "message_new":
return None
payload = update.get("object") or {}
# С версии 5.103 сообщение лежит в object.message, до неё — прямо в object.
message = payload.get("message") or payload
from_id = message.get("from_id")
peer_id = message.get("peer_id")
external_id = first(message, "id", "conversation_message_id")
if from_id is None or peer_id is None or external_id is None:
return None
if int(from_id) < 0:
# Сообщение самого сообщества (ответ из диалогов ВКонтакте) — не входящее.
return None
voice_url, voice_duration, voice_mime, voice_unavailable = vk_media.voice_attachment(message)
files = vk_media.file_attachments(message)
text = _text(integration, message, has_content=bool(files or voice_url or voice_unavailable))
if not text and not files and not voice_url and not voice_unavailable:
return None
return InboundMessage(
external_id=str(external_id),
user_id=str(from_id),
chat_id=str(peer_id),
text=text,
display_name="",
voice_url=voice_url,
voice_duration=voice_duration,
voice_mime=voice_mime,
voice_unavailable=voice_unavailable,
files=files,
)
def _text(integration, message: dict, *, has_content: bool) -> str:
"""Текст реплики; для непоказуемого вложения — след вместо пустоты."""
text = str(message.get("text") or "")
unsupported = vk_media.unsupported_attachments(message)
if unsupported:
logger.info("VK attachment types not shown to the operator: %s", ", ".join(unsupported))
if text or has_content or not unsupported:
return text
# Вложение, которое канал показать не может: пустой текст обернулся бы
# потерей реплики, поэтому оператор видит хотя бы её след.
return t("conversations.attachment_unsupported", language=customer_language(integration.organization))
def _with_profiles(integration, messages: list[InboundMessage]) -> list[InboundMessage]:
"""Имя, логин и фото отправителей — одним запросом на пачку.
В апдейте ВКонтакте профиля нет, его отдаёт ``users.get``. Спрашивать его на
каждое сообщение значило бы упереться в частоту обращений на оживлённом
сообществе, поэтому запрос один на цикл опроса.
"""
user_ids = sorted({message.user_id for message in messages if message.user_id.isdigit()})
if not user_ids:
return messages
try:
response = vk_api.call(
integration,
"users.get",
{"user_ids": ",".join(user_ids), "fields": "photo_100,screen_name"},
)
except vk_api.CALL_ERRORS as error:
# Без профиля сообщение всё равно доезжает: имя контакта уточнится на
# следующем сообщении, а терять реплику из-за справки нельзя.
logger.warning("VK users.get failed for integration %s: %s", integration.id, mask_url_secrets(error))
return messages
profiles = {str(user.get("id")): user for user in (response or []) if isinstance(user, dict)}
return [_with_profile(message, profiles.get(message.user_id)) for message in messages]
def _with_profile(message: InboundMessage, profile: dict | None) -> InboundMessage:
if not profile:
return message
name = " ".join(part for part in (profile.get("first_name"), profile.get("last_name")) if part)
screen_name = str(profile.get("screen_name") or "")
return replace(
message,
display_name=str(name or screen_name),
username=screen_name,
avatar_url=str(profile.get("photo_100") or ""),
)
def download_file(integration, url: str, content_type: str) -> tuple[bytes, str]:
"""Скачивание вложения по прямому адресу из апдейта."""
return vk_api.download(integration, url), content_type or "application/octet-stream"
def download_voice(integration, url: str, content_type: str = "") -> tuple[bytes, str]:
return vk_api.download(integration, url), content_type or "audio/ogg"
@@ -0,0 +1,50 @@
"""Доступ к API ВКонтакте: адрес подключения, прокси, вызов метода, скачивание.
Ключ доступа сообщества уходит параметром запроса — заголовка авторизации у
ВКонтакте нет. Поэтому ни один адрес не печатается как есть: и журнал, и статус
подключения получают его через ``mask_url_secrets``.
"""
from __future__ import annotations
import http.client
import json
import urllib.error
from chatballs.conversations.transports.base import download_bytes
from chatballs.integrations.checks import DEFAULT_VK_BASE_URL, VkRejected, vk_call
from chatballs.integrations.outbound import host_of
NETWORK_ERRORS = (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError)
# Отказ провайдера и обрыв связи обрабатываются одинаково: подключение на этом
# цикле не работает, а чем именно — видно из текста ошибки.
CALL_ERRORS = (VkRejected, *NETWORK_ERRORS)
def base(integration) -> str:
return (integration.config.get("base_url") or DEFAULT_VK_BASE_URL).rstrip("/")
def proxy(integration) -> str:
return integration.config.get("proxy_url", "")
def call(integration, method: str, params: dict | None = None, *, post: bool = False):
"""Метод API ВКонтакте от имени подключения; возвращает содержимое response."""
return vk_call(
base_url=base(integration),
method=method,
secret=integration.secret,
params=params,
proxy_url=proxy(integration),
post=post,
)
def download(integration, url: str) -> bytes:
"""Скачивание по прямому адресу из ответа провайдера.
Хост из ``base_url`` подключения владелец назвал сам, поэтому он остаётся
разрешённым, даже если ведёт внутрь сети (chatballs.integrations.outbound).
"""
return download_bytes(url, proxy_url=proxy(integration), allowed_host=host_of(base(integration)))
@@ -0,0 +1,167 @@
"""Вложения ВКонтакте: разбор входящих и загрузка исходящих.
Разбор здесь чистый — на вход словарь сообщения, на выход значения для
``InboundMessage``. Загрузка идёт в три шага (получить адрес загрузки, залить
файл, сохранить его у провайдера), поэтому наружу она принимает вызов API и
прокси подключения, а не само подключение: так модуль не зависит от транспорта
и проверяется без базы.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from chatballs.conversations.transports.base import (
InboundFile,
first,
guess_content_type,
request_json_multipart,
safe_filename,
)
logger = logging.getLogger(__name__)
# Вложения, которые канал умеет показать оператору. Остальные (видео, товар,
# запись на стене) ВКонтакте отдаёт только по отдельному запросу с правами,
# которых у сообщества нет.
KNOWN_ATTACHMENTS = ("photo", "doc", "audio_message", "sticker", "graffiti")
def photo_url(photo: dict) -> str:
"""Самый крупный размер фотографии.
Набор размеров зависит от исходника, поэтому выбираем по площади, а не по
буквенному типу: у маленьких снимков крупных типов просто нет.
"""
sizes = [size for size in (photo.get("sizes") or []) if size.get("url")]
if not sizes:
return ""
largest = max(sizes, key=lambda size: int(size.get("width") or 0) * int(size.get("height") or 0))
return str(largest.get("url") or "")
def voice_attachment(message: dict) -> tuple[str, int, str, bool]:
"""Голосовое сообщение: адрес, длительность, mime и признак «нечего скачать».
Последнее значение означает, что голосовое в сообщении было, а адреса в нём
нет. Такая реплика всё равно доезжает до оператора заглушкой: молча терять
сказанное клиентом нельзя.
"""
for attachment in message.get("attachments") or []:
if attachment.get("type") != "audio_message":
continue
payload = attachment.get("audio_message") or {}
ogg = str(payload.get("link_ogg") or "")
url = ogg or str(payload.get("link_mp3") or "")
duration = int(payload.get("duration") or 0)
return url, duration, ("audio/ogg" if ogg else "audio/mpeg") if url else "", not url
return "", 0, "", False
def file_attachments(message: dict) -> tuple[InboundFile, ...]:
"""Фото, документы, стикеры и граффити — каждый становится своей репликой."""
files: list[InboundFile] = []
for attachment in message.get("attachments") or []:
kind = attachment.get("type")
if kind == "photo":
url = photo_url(attachment.get("photo") or {})
if url:
files.append(InboundFile(name="photo.jpg", content_type="image/jpeg", url=url, is_image=True))
elif kind in ("sticker", "graffiti"):
url = _image_from_list(attachment.get(kind) or {})
if url:
files.append(InboundFile(name=f"{kind}.png", content_type="image/png", url=url, is_image=True))
elif kind == "doc":
document = attachment.get("doc") or {}
url = str(document.get("url") or "")
if not url:
continue
name = safe_filename(document.get("title") or "", "document")
mime = guess_content_type(name)
files.append(
InboundFile(
name=name,
content_type=mime,
size=int(document.get("size") or 0),
url=url,
is_image=mime.startswith("image/"),
)
)
return tuple(files)
def unsupported_attachments(message: dict) -> tuple[str, ...]:
"""Типы вложений, которые разобрать не удалось, — для журнала."""
return tuple(
str(attachment.get("type") or "")
for attachment in message.get("attachments") or []
if attachment.get("type") not in KNOWN_ATTACHMENTS
)
def _image_from_list(payload: dict) -> str:
"""Самая крупная картинка стикера или граффити."""
images = [image for image in (payload.get("images") or []) if image.get("url")]
if not images:
return str(payload.get("url") or "")
largest = max(images, key=lambda image: int(image.get("width") or 0) * int(image.get("height") or 0))
return str(largest.get("url") or "")
def upload_attachment(
*,
api: Callable[..., object],
proxy_url: str,
peer_id: str,
content: bytes,
filename: str,
content_type: str,
) -> str:
"""Загрузить файл и вернуть строку attachment для messages.send.
Фото и документы у ВКонтакте загружаются разными парами методов, но шаги
одинаковы: получить адрес загрузки, отправить файл на него, сохранить
результат. Пустая строка — загрузка не удалась, отправку продолжать нечем.
"""
as_photo = content_type in ("image/jpeg", "image/png", "image/gif", "image/webp")
if as_photo:
server = api("photos.getMessagesUploadServer", {"peer_id": peer_id})
uploaded = _upload(server, proxy_url=proxy_url, field="photo", filename=filename, content=content, content_type=content_type)
if not uploaded.get("photo"):
return ""
saved = api(
"photos.saveMessagesPhoto",
{"server": uploaded.get("server", ""), "photo": uploaded.get("photo", ""), "hash": uploaded.get("hash", "")},
post=True,
)
item = (saved or [{}])[0] if isinstance(saved, list) else {}
return _attachment_id("photo", item)
server = api("docs.getMessagesUploadServer", {"type": "doc", "peer_id": peer_id})
uploaded = _upload(server, proxy_url=proxy_url, field="file", filename=filename, content=content, content_type=content_type)
if not uploaded.get("file"):
return ""
saved = api("docs.save", {"file": uploaded.get("file", "")}, post=True)
item = (saved or {}).get("doc") or {} if isinstance(saved, dict) else {}
return _attachment_id("doc", item)
def _upload(server: object, *, proxy_url: str, field: str, filename: str, content: bytes, content_type: str) -> dict:
upload_url = str((server or {}).get("upload_url") or "") if isinstance(server, dict) else ""
if not upload_url:
return {}
return request_json_multipart(
upload_url,
fields={},
file_field=field,
filename=filename,
content=content,
content_type=content_type,
proxy_url=proxy_url,
)
def _attachment_id(kind: str, item: dict) -> str:
owner_id = first(item, "owner_id", default="")
item_id = first(item, "id", default="")
return f"{kind}{owner_id}_{item_id}" if owner_id != "" and item_id != "" else ""
@@ -0,0 +1,84 @@
"""VK community transport: отправка сообщений (messages.send).
Текст, приглашение на звонок кнопкой-ссылкой и файл оператора. Голосовых здесь
нет намеренно: ВКонтакте принимает голосовое только в ogg/opus, а композер
записывает webm/opus, поэтому VK не зарегистрирован в ``_VOICE_SEND`` и
интерфейс не предлагает записать голосовое в этом канале.
"""
from __future__ import annotations
import functools
import json
import logging
import random
from chatballs.conversations.transports import vk_api, vk_media
from chatballs.i18n import customer_language, t
from chatballs.integrations.outbound import mask_url_secrets
logger = logging.getLogger(__name__)
def _send(integration, *, chat_id: str, user_id: str, params: dict) -> bool:
target = chat_id or user_id
if not integration.secret or not target:
return False
body = {
"peer_id": target,
# random_id обязателен: по нему ВКонтакте отбрасывает повторы. Отправку
# мы не повторяем, поэтому значение своё на каждый вызов.
"random_id": random.getrandbits(31),
**params,
}
try:
# POST: ответ агента длиннее, чем разумно укладывать в адрес запроса.
vk_api.call(integration, "messages.send", body, post=True)
return True
except vk_api.CALL_ERRORS as error:
logger.warning("VK send failed for integration %s: %s", integration.id, mask_url_secrets(error))
return False
def send_text(integration, *, chat_id: str, user_id: str, text: str) -> bool:
return _send(integration, chat_id=chat_id, user_id=user_id, params={"message": text})
def _caption(integration, key: str) -> str:
"""Подпись кнопки читает клиент — язык организации, а не язык запроса."""
return t(key, language=customer_language(integration.organization))
def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url: str) -> bool:
# Приглашение на онлайн-звонок: кнопка-ссылка под сообщением.
button = {"action": {"type": "open_link", "link": url, "label": _caption(integration, "conversations.button_join_call")}}
keyboard = {"inline": True, "buttons": [[button]]}
return _send(
integration,
chat_id=chat_id,
user_id=user_id,
params={"message": text, "keyboard": json.dumps(keyboard, ensure_ascii=False)},
)
def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filename: str, content_type: str, caption: str = "") -> bool:
"""Файл оператора: загрузка у провайдера, затем сообщение со ссылкой на неё."""
target = chat_id or user_id
if not integration.secret or not target:
return False
try:
attachment = vk_media.upload_attachment(
api=functools.partial(vk_api.call, integration),
proxy_url=vk_api.proxy(integration),
peer_id=target,
content=content,
filename=filename,
content_type=content_type,
)
except vk_api.CALL_ERRORS as error:
logger.warning("VK upload failed for integration %s: %s", integration.id, mask_url_secrets(error))
return False
if not attachment:
return False
params = {"attachment": attachment, **({"message": caption[:4000]} if caption else {})}
return _send(integration, chat_id=chat_id, user_id=user_id, params=params)
+17 -2
View File
@@ -357,6 +357,7 @@ MESSAGES: dict[str, object] = {
"identity.role_conflict": "User already has a different role in this organization",
"identity.token_required": "token is required",
"identity.totp_challenge_inactive": "TOTP challenge is not active",
"notifications.conversation_id_number": "conversationId must be a number",
"notifications.ids_list_or_all": "ids must be a list or use all=true",
"notifications.push_types_list": "pushTypes must be a list",
"notifications.enabled_bool": "enabled must be true or false",
@@ -373,8 +374,10 @@ MESSAGES: dict[str, object] = {
"conversations.activity_started": "Conversation · {channel}",
"conversations.field_too_long": "Field {field}: no longer than {limit} characters",
"ai.provider_no_transcription": "The {provider} provider does not support audio transcription",
"ai.transcription_failed": "Transcription failed: {error}",
"ai.transcription_failed_http": "Transcription failed: HTTP {code} {detail}",
"ai.transcription_denied": "The provider refused the transcription request: check the key and the access to the transcription model in the integration settings",
"ai.transcription_failed": "The provider could not transcribe the recording. Try again",
"ai.transcription_unreachable": "The transcription provider is unavailable. Try again",
"ai.transcription_unsupported": "This provider cannot transcribe speech. Pick another integration for transcription on the agent card",
"api.expected_integer": "{name}: an integer is expected",
"api.expected_positive": "{name}: a number greater than zero is expected",
"api.expected_record_id": "{name}: a record identifier is expected",
@@ -472,6 +475,10 @@ MESSAGES: dict[str, object] = {
"conversations.file_download_unsupported": "File download is not supported for this provider",
"conversations.telegram_getfile_failed": "Telegram getFile failed",
"conversations.voice_download_unsupported": "Voice download is not supported for this provider",
"conversations.attachment_unsupported": "An attachment this channel cannot show",
"conversations.vk_no_group": "Could not identify the VK community — check the connection",
"conversations.vk_longpoll_unavailable": "VK did not return an address for receiving messages",
"conversations.vk_longpoll_failed": "VK interrupted message delivery (code {code})",
"portals.article_object_required": "Article must be an object",
"portals.locale_string": "Locale must be a string",
"portals.summary_string": "Summary must be a string",
@@ -501,6 +508,9 @@ MESSAGES: dict[str, object] = {
"notifications.type_dialog_assigned": "A conversation was assigned to me",
"notifications.type_dialog_waiting_long": "A conversation has been waiting a long time",
"notifications.type_ai_stopped": "AI stopped by an error",
"integrations.check_rejected": "The provider refused the check ({status})",
"integrations.check_rejected_access": "The provider refused the check ({status}): check the key and whether the provider is reachable from your network — region, proxy",
"integrations.check_rejected_reason": "The provider refused the check ({status}): {reason}",
"integrations.check_no_connection": "No connection: {error}",
"integrations.check_api_key_missing": "The API key is not set",
"integrations.check_base_url_missing": "Base URL is not set",
@@ -511,6 +521,11 @@ MESSAGES: dict[str, object] = {
"integrations.check_endpoint_answered": "The endpoint answered {status}",
"integrations.check_key_accepted": "the key was accepted",
"integrations.check_bot_connected": "the bot is connected",
"integrations.check_vk_rejected": "VK rejected the request ({code}): {reason}",
"integrations.check_vk_scopes": "The access key lacks the required permissions: issue it with the community messages and community management scopes",
"integrations.check_vk_no_group": "The key does not belong to a community: a community access key is required, not a personal one",
"integrations.check_vk_longpoll_off": "Event delivery is off in the community: turn on the Long Poll API in the community management",
"integrations.check_vk_message_event_off": "The incoming message event is not enabled in the Long Poll API settings",
"integrations.check_demo": "Demo provider: answers from the agent's knowledge, with no external calls or keys",
"integrations.check_unsupported": "Checking is not supported for this connection type",
"integrations.check_web_not_bound": "The connection is not bound to a channel — the widget is inactive",
+17 -2
View File
@@ -361,6 +361,7 @@ MESSAGES: dict[str, object] = {
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
"identity.token_required": "Нужен токен",
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
"notifications.conversation_id_number": "conversationId должен быть числом",
"notifications.ids_list_or_all": "ids — список, либо all=true",
"notifications.push_types_list": "pushTypes — список",
"notifications.enabled_bool": "enabled — да или нет",
@@ -377,8 +378,10 @@ MESSAGES: dict[str, object] = {
"conversations.activity_started": "Диалог · {channel}",
"conversations.field_too_long": "Поле {field}: не длиннее {limit} символов",
"ai.provider_no_transcription": "Провайдер {provider} не поддерживает расшифровку аудио",
"ai.transcription_failed": "Расшифровка не удалась: {error}",
"ai.transcription_failed_http": "Расшифровка не удалась: HTTP {code} {detail}",
"ai.transcription_denied": "Провайдер не принял запрос на расшифровку: проверьте ключ и доступ к модели расшифровки в настройках интеграции",
"ai.transcription_failed": "Провайдер не смог расшифровать запись. Попробуйте ещё раз",
"ai.transcription_unreachable": "Провайдер расшифровки недоступен. Попробуйте ещё раз",
"ai.transcription_unsupported": "Этот провайдер не умеет расшифровывать речь. Выберите на карточке агента другую интеграцию для расшифровки",
"api.expected_integer": "{name}: ожидается целое число",
"api.expected_positive": "{name}: ожидается число больше нуля",
"api.expected_record_id": "{name}: ожидается идентификатор записи",
@@ -476,6 +479,10 @@ MESSAGES: dict[str, object] = {
"conversations.file_download_unsupported": "Этот канал не поддерживает скачивание файлов",
"conversations.telegram_getfile_failed": "Telegram не отдал файл",
"conversations.voice_download_unsupported": "Этот канал не поддерживает скачивание голосовых",
"conversations.attachment_unsupported": "Вложение, которое канал не может показать",
"conversations.vk_no_group": "Не удалось определить сообщество ВКонтакте — проверьте подключение",
"conversations.vk_longpoll_unavailable": "ВКонтакте не выдал адрес для приёма сообщений",
"conversations.vk_longpoll_failed": "ВКонтакте прервал приём сообщений (код {code})",
"portals.article_object_required": "Статья должна быть объектом",
"portals.locale_string": "Поле «locale» — строка",
"portals.summary_string": "Поле «summary» — строка",
@@ -505,6 +512,9 @@ MESSAGES: dict[str, object] = {
"notifications.type_dialog_assigned": "Диалог назначили на меня",
"notifications.type_dialog_waiting_long": "Диалог долго ждёт человека",
"notifications.type_ai_stopped": "AI остановлен ошибкой",
"integrations.check_rejected": "Провайдер отклонил проверку ({status})",
"integrations.check_rejected_access": "Провайдер отклонил проверку ({status}): проверьте ключ и доступность провайдера из вашей сети — регион, прокси",
"integrations.check_rejected_reason": "Провайдер отклонил проверку ({status}): {reason}",
"integrations.check_no_connection": "Нет связи: {error}",
"integrations.check_api_key_missing": "Не указан API-ключ",
"integrations.check_base_url_missing": "Не указан Base URL",
@@ -515,6 +525,11 @@ MESSAGES: dict[str, object] = {
"integrations.check_endpoint_answered": "Эндпоинт ответил {status}",
"integrations.check_key_accepted": "ключ принят",
"integrations.check_bot_connected": "бот подключён",
"integrations.check_vk_rejected": "ВКонтакте отклонил запрос ({code}): {reason}",
"integrations.check_vk_scopes": "У ключа доступа нет нужных прав: выпустите его с правами «Сообщения сообщества» и «Управление сообществом»",
"integrations.check_vk_no_group": "Ключ не принадлежит сообществу: нужен ключ доступа сообщества, а не пользователя",
"integrations.check_vk_longpoll_off": "В сообществе выключен приём событий: включите Long Poll API в управлении сообществом",
"integrations.check_vk_message_event_off": "В настройках Long Poll API не включено событие «Входящее сообщение»",
"integrations.check_demo": "Демо-провайдер: отвечает по знаниям агента, без внешних запросов и ключей",
"integrations.check_unsupported": "Проверка для этого типа подключения не поддерживается",
"integrations.check_web_not_bound": "Подключение не привязано к каналу — виджет не активен",
+180 -2
View File
@@ -7,6 +7,9 @@ identity method are not interchangeable:
- Telegram: GET {base}/bot<token>/getMe (token in the path).
- MAX: GET {base}/me, header `Authorization: <token>` (raw token; the
query-param access_token is no longer supported).
- VK: GET {base}/groups.getById + groups.getLongPollSettings, token in
the query string (VK has no auth header); errors come back with
HTTP 200 and an `error` body.
Each check returns (ok, detail, meta) and never raises; meta may carry
{"bot_username": ...} parsed from the provider's identity response.
@@ -16,20 +19,32 @@ from __future__ import annotations
import imaplib
import json
import logging
import re
import smtplib
import urllib.error
import urllib.request
from urllib.parse import urlencode
from django.conf import settings
from chatballs.i18n import t, tn
from chatballs.integrations.outbound import mask_url_secrets
from chatballs.integrations.proxy import build_opener
logger = logging.getLogger(__name__)
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# platform-api2.max.ru отдаёт неполную цепочку сертификата (verify failed);
# рабочий и с валидным сертификатом — platform-api.max.ru.
DEFAULT_MAX_BASE_URL = "https://platform-api.max.ru"
DEFAULT_TELEGRAM_BASE_URL = "https://api.telegram.org"
DEFAULT_VK_BASE_URL = "https://api.vk.com/method"
# Версия API ВКонтакте: параметр обязателен в каждом запросе.
VK_API_VERSION = "5.199"
# Метод недоступен ключу с такими правами: Bots Long Poll требует прав
# «Сообщения сообщества» и «Управление сообществом».
VK_ACCESS_DENIED = 15
CheckResult = tuple[bool, str, dict]
@@ -46,13 +61,80 @@ def _get(url: str, *, headers: dict[str, str] | None = None, proxy_url: str = ""
return response.status, data
def _post_form(url: str, body: str, *, proxy_url: str = "") -> tuple[int, dict]:
"""POST application/x-www-form-urlencoded — форма, которую ждёт ВКонтакте."""
opener = build_opener(proxy_url)
request = urllib.request.Request(
url,
data=body.encode("utf-8"),
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with opener.open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
payload = response.read().decode("utf-8")
try:
data = json.loads(payload) if payload else {}
except json.JSONDecodeError:
data = {}
return response.status, data
def _error_reason(body: str) -> str:
"""Короткая причина из ответа провайдера.
Ответ бывает и JSON'ом провайдера, и HTML-страницей защиты перед ним —
человеку нужна одна фраза, а не то и другое целиком.
"""
try:
payload = json.loads(body)
except (json.JSONDecodeError, TypeError):
payload = None
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict):
return str(error.get("message") or "")[:160]
for key in ("message", "detail", "error_description"):
if payload.get(key):
return str(payload[key])[:160]
if isinstance(error, str):
return error[:160]
text = re.sub(r"<[^>]+>", " ", body)
text = " ".join(text.split())
return text[:160]
def _http_failure(error: urllib.error.HTTPError) -> str:
"""Отказ провайдера словами, а не кодом.
Голый «HTTP 403» не говорит ничего: так отвечают и на чужой ключ, и на
запрос из закрытого региона, и на блокировку самого прокси. Причину, если
провайдер её назвал, показываем сразу; ответ целиком уходит в журнал.
"""
try:
body = error.read().decode("utf-8", "replace")
except (OSError, ValueError):
body = ""
logger.warning(
"Integration check rejected: HTTP %s %s — %s",
error.code,
mask_url_secrets(getattr(error, "url", "")),
body[:500],
)
reason = _error_reason(body)
if reason:
return t("integrations.check_rejected_reason", status=error.code, reason=reason)
if error.code in (401, 403):
return t("integrations.check_rejected_access", status=error.code)
return t("integrations.check_rejected", status=error.code)
def _safe(fn) -> CheckResult:
try:
return fn()
except urllib.error.HTTPError as error:
return False, f"HTTP {error.code}: {error.reason}", {}
return False, _http_failure(error), {}
except (urllib.error.URLError, TimeoutError, OSError) as error:
return False, t("integrations.check_no_connection", error=error), {}
return False, t("integrations.check_no_connection", error=mask_url_secrets(error)), {}
def check_openrouter(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult:
@@ -189,6 +271,102 @@ def check_telegram(*, secret: str, base_url: str, proxy_url: str = "") -> CheckR
return _safe(run)
class VkRejected(Exception):
"""ВКонтакте отклонил запрос: текст уже пригоден для показа человеку."""
def __init__(self, message: str, code: int = 0) -> None:
super().__init__(message)
self.code = code
def vk_call(*, base_url: str, method: str, secret: str, params: dict | None = None, proxy_url: str = "", post: bool = False):
"""Вызов метода API ВКонтакте; возвращает содержимое поля response.
Токен уходит параметром запроса — заголовка авторизации у ВКонтакте нет.
Поэтому адрес нигде не печатается как есть: и журнал, и статус подключения
получают его через ``mask_url_secrets``.
Ошибку ВКонтакте отдаёт кодом 200 и телом ``error``, так что проверять
статус недостаточно: отозванный токен выглядел бы успешной проверкой.
"""
base = (base_url or DEFAULT_VK_BASE_URL).rstrip("/")
query = urlencode({**(params or {}), "access_token": secret, "v": VK_API_VERSION})
if post:
status, data = _post_form(f"{base}/{method}", query, proxy_url=proxy_url)
else:
status, data = _get(f"{base}/{method}?{query}", proxy_url=proxy_url)
error = data.get("error")
if isinstance(error, dict):
raise VkRejected(
t(
"integrations.check_vk_rejected",
code=error.get("error_code", ""),
reason=str(error.get("error_msg") or "")[:160],
),
code=int(error.get("error_code") or 0),
)
if status != 200:
raise VkRejected(t("integrations.check_provider_answered", provider="ВКонтакте", status=status))
return data.get("response")
def vk_group(response) -> dict:
"""Сообщество из ответа groups.getById.
Форма ответа зависит от версии API: до 5.199 это список, дальше объект с
полем groups. Подключение переживает обе.
"""
items = response.get("groups") if isinstance(response, dict) else response
first_group = (items or [None])[0] if isinstance(items, list) else None
return first_group if isinstance(first_group, dict) else {}
def check_vk(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult:
"""Сообщество ВКонтакте: кто мы и включён ли приём сообщений.
Ключ доступа сообщества сам называет сообщество, поэтому его идентификатор
владельцу вводить не нужно — как имя бота у Telegram и MAX, он попадает в
конфигурацию результатом проверки.
Выключенный Long Poll — это ошибка подключения: принимать сообщения в таком
состоянии невозможно. Включаем не мы: настройки чужого сообщества меняет
его владелец.
"""
if not secret:
return False, t("integrations.check_bot_token_missing"), {}
def run() -> CheckResult:
try:
group = vk_group(vk_call(base_url=base_url, method="groups.getById", secret=secret, proxy_url=proxy_url))
if not group.get("id"):
return False, t("integrations.check_vk_no_group"), {}
group_id = str(group["id"])
long_poll = vk_call(
base_url=base_url,
method="groups.getLongPollSettings",
secret=secret,
params={"group_id": group_id},
proxy_url=proxy_url,
)
except VkRejected as error:
# 15 — метод недоступен ключу с такими правами. Без подсказки
# владелец видел бы английское «no access» и не знал, что чинить.
if error.code == VK_ACCESS_DENIED:
return False, t("integrations.check_vk_scopes"), {}
return False, str(error), {}
name = str(group.get("name") or "")
screen_name = str(group.get("screen_name") or "")
meta = {"bot_id": group_id, "bot_username": screen_name, "bot_name": name or screen_name}
settings_payload = long_poll if isinstance(long_poll, dict) else {}
if not settings_payload.get("is_enabled"):
return False, t("integrations.check_vk_longpoll_off"), meta
if not (settings_payload.get("events") or {}).get("message_new"):
return False, t("integrations.check_vk_message_event_off"), meta
return True, f"ВКонтакте: {name or screen_name}", meta
return _safe(run)
def check_demo(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult:
"""Демо-провайдер не ходит в сеть — всегда готов."""
return True, t("integrations.check_demo"), {}
@@ -1,15 +1,21 @@
"""Функции точки входа: что разрешено клиенту и оператору в диалогах через
эту интеграцию — голосовые сообщения и онлайн-звонки (аудио/видео).
Звонки технически возможны там, где есть доставка приглашения: Telegram, MAX
(кнопка-ссылка) и Web-виджет (баннер в виджете). Почта звонки не поддерживает.
Звонки технически возможны там, где есть доставка приглашения: Telegram, MAX,
ВКонтакте (кнопка-ссылка) и Web-виджет (баннер в виджете). Почта звонки не
поддерживает.
"""
from __future__ import annotations
from chatballs.integrations.models import Integration, IntegrationProvider
CALL_PROVIDERS = (IntegrationProvider.TELEGRAM, IntegrationProvider.MAX, IntegrationProvider.WEB)
CALL_PROVIDERS = (
IntegrationProvider.TELEGRAM,
IntegrationProvider.MAX,
IntegrationProvider.VK,
IntegrationProvider.WEB,
)
def supports_calls(integration: Integration | None) -> bool:
@@ -0,0 +1,29 @@
"""Подключение-сообщество ВКонтакте: новое значение provider (choices)."""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("integrations", "0008_encrypted_column_width"),
]
operations = [
migrations.AlterField(
model_name="integration",
name="provider",
field=models.CharField(
choices=[
("OPENROUTER", "OpenRouter"),
("CUSTOM", "Custom (OpenAI-compatible)"),
("DEMO", "Демо-провайдер (без ключа)"),
("MAX", "MAX"),
("TELEGRAM", "Telegram"),
("VK", "ВКонтакте"),
("WEB", "Web-виджет"),
("EMAIL", "Email (IMAP/SMTP)"),
],
max_length=16,
),
),
]
@@ -18,6 +18,7 @@ class IntegrationProvider(models.TextChoices):
DEMO = "DEMO", "Демо-провайдер (без ключа)"
MAX = "MAX", "MAX"
TELEGRAM = "TELEGRAM", "Telegram"
VK = "VK", "ВКонтакте"
WEB = "WEB", "Web-виджет"
EMAIL = "EMAIL", "Email (IMAP/SMTP)"
@@ -36,6 +37,8 @@ PROVIDER_KIND = {
IntegrationProvider.DEMO: IntegrationKind.LLM_PROVIDER,
IntegrationProvider.MAX: IntegrationKind.MESSENGER,
IntegrationProvider.TELEGRAM: IntegrationKind.MESSENGER,
# Сообщество ВКонтакте: приём через Bots Long Poll, отправка messages.send.
IntegrationProvider.VK: IntegrationKind.MESSENGER,
IntegrationProvider.WEB: IntegrationKind.MESSENGER,
# Email-ящик — транспорт диалогов наравне с ботами (ADR-CHATBALLS-0035).
IntegrationProvider.EMAIL: IntegrationKind.MESSENGER,
@@ -19,6 +19,7 @@ OpenAI-совместимый сервер или собственный Bot API
from __future__ import annotations
import ipaddress
import re
import socket
from urllib.parse import urlsplit
@@ -27,6 +28,23 @@ from chatballs.i18n import t
HTTP_SCHEMES = frozenset({"http", "https"})
PROXY_SCHEMES = frozenset({"http", "https", "socks5", "socks5h"})
# Параметры запроса, значение которых нельзя показывать ни в журнале, ни в
# статусе подключения. У части провайдеров (ВКонтакте) секрет иначе не
# передать: заголовка авторизации у них нет, токен уходит строкой запроса.
_SECRET_QUERY_RE = re.compile(
r"(access_token|api_key|key|password|token)=([^&\s'\"]+)", re.IGNORECASE
)
def mask_url_secrets(text: object) -> str:
"""Строка с адресом без значений секретных параметров запроса.
Сообщение об ошибке от urllib несёт в себе сам адрес, а адрес — токен.
Без этой замены токен сообщества оседал бы в журнале установки и в поле
последней ошибки подключения, которое видно в интерфейсе.
"""
return _SECRET_QUERY_RE.sub(lambda match: f"{match.group(1)}=***", str(text or ""))
class OutboundUrlRejected(ValueError):
"""Адрес не разрешён политикой исходящих запросов."""
+26 -4
View File
@@ -18,6 +18,8 @@ import ssl
import urllib.parse
import urllib.request
from django.conf import settings
from chatballs.i18n import t
from chatballs.integrations.outbound import OutboundUrlRejected
@@ -85,6 +87,24 @@ def _blocked_scheme_handlers() -> list[urllib.request.BaseHandler]:
return [_RefusedFileHandler(), _RefusedFTPHandler(), _RefusedDataHandler()]
def user_agent() -> str:
"""Чем продукт представляется чужим API.
Умолчание urllib — «Python-urllib/3.x», и защита перед API (Cloudflare)
банит такой запрос до того, как его увидит сам провайдер: на бою это
выглядело как 403 «error code: 1010» у провайдера, который через тот же
прокси прекрасно отвечает браузеру. С обычным именем клиента запрос
проходит. Заголовок ставится на opener, поэтому свой User-Agent
конкретного запроса он не перебивает.
"""
return f"Chatballs/{getattr(settings, 'CHATBALLS_VERSION', 'dev')}"
def _named(opener):
opener.addheaders = [("User-Agent", user_agent())]
return opener
def build_opener(proxy_url: str, *, validate_redirect=None):
"""urllib opener, проксирующий http/https/socks5 запросы.
@@ -96,12 +116,14 @@ def build_opener(proxy_url: str, *, validate_redirect=None):
if validate_redirect is not None:
blocked.append(_GuardedRedirectHandler(validate_redirect))
if not proxy_url:
return urllib.request.build_opener(*blocked)
return _named(urllib.request.build_opener(*blocked))
scheme = urllib.parse.urlparse(proxy_url).scheme.lower()
if scheme in SOCKS_SCHEMES:
return urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked)
return urllib.request.build_opener(
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
return _named(urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked))
return _named(
urllib.request.build_opener(
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
)
)
@@ -236,6 +236,7 @@ _CHECKS = {
IntegrationProvider.DEMO: checks.check_demo,
IntegrationProvider.MAX: checks.check_max,
IntegrationProvider.TELEGRAM: checks.check_telegram,
IntegrationProvider.VK: checks.check_vk,
}
@@ -0,0 +1,103 @@
"""Проверка подключения ВКонтакте: кто мы и включён ли приём сообщений.
Сеть закрыта подменой HTTP-вызова: проверка обязана разбирать ответ, а не
ходить наружу. База здесь не нужна — проверка работает с секретом и адресом.
"""
from __future__ import annotations
import urllib.error
from unittest import mock
from django.test import SimpleTestCase
from chatballs.integrations import checks
GROUP = {"groups": [{"id": 42, "name": "Acme Support", "screen_name": "acme"}]}
LONG_POLL_ON = {"is_enabled": True, "events": {"message_new": 1}, "api_version": "5.199"}
def _answers(*responses):
"""Ответы VK по порядку вызовов: (status, payload)."""
return mock.patch.object(checks, "_get", side_effect=[(200, response) for response in responses])
class VkCheckTests(SimpleTestCase):
def test_community_is_recognized_and_remembered(self) -> None:
with _answers({"response": GROUP}, {"response": LONG_POLL_ON}) as get:
ok, detail, meta = checks.check_vk(secret="community-token", base_url="")
self.assertTrue(ok)
self.assertIn("Acme Support", detail)
self.assertEqual(meta, {"bot_id": "42", "bot_username": "acme", "bot_name": "Acme Support"})
# Идентификатор сообщества владелец не вводит: его называет сам токен.
self.assertIn("groups.getById", get.call_args_list[0].args[0])
self.assertIn("group_id=42", get.call_args_list[1].args[0])
def test_older_api_shape_is_accepted(self) -> None:
with _answers({"response": [{"id": 42, "name": "Acme Support"}]}, {"response": LONG_POLL_ON}):
ok, _detail, meta = checks.check_vk(secret="community-token", base_url="")
self.assertTrue(ok)
self.assertEqual(meta["bot_id"], "42")
def test_missing_token_is_reported_before_any_request(self) -> None:
with mock.patch.object(checks, "_get") as get:
ok, detail, _meta = checks.check_vk(secret="", base_url="")
self.assertFalse(ok)
self.assertTrue(detail)
get.assert_not_called()
def test_rejected_token_is_an_error_even_with_http_200(self) -> None:
# ВКонтакте отвечает на отозванный ключ кодом 200 и телом error.
rejection = {"error": {"error_code": 5, "error_msg": "User authorization failed"}}
with _answers(rejection):
ok, detail, meta = checks.check_vk(secret="revoked", base_url="")
self.assertFalse(ok)
self.assertIn("User authorization failed", detail)
self.assertEqual(meta, {})
def test_key_without_the_long_poll_permissions_says_what_to_fix(self) -> None:
# ВКонтакте отвечает на нехватку прав кодом 15 и английским «no access»:
# владельцу из него не видно, какое право включить.
denied = {"error": {"error_code": 15, "error_msg": "Access denied: no access to call this method."}}
with _answers({"response": GROUP}, denied):
ok, detail, _meta = checks.check_vk(secret="messages-only", base_url="")
self.assertFalse(ok)
self.assertIn("Управление сообществом", detail)
def test_personal_token_without_a_community_is_rejected(self) -> None:
with _answers({"response": {"groups": []}}):
ok, detail, _meta = checks.check_vk(secret="user-token", base_url="")
self.assertFalse(ok)
self.assertTrue(detail)
def test_disabled_long_poll_is_an_error(self) -> None:
with _answers({"response": GROUP}, {"response": {"is_enabled": False, "events": {}}}):
ok, detail, meta = checks.check_vk(secret="community-token", base_url="")
self.assertFalse(ok)
self.assertIn("Long Poll", detail)
# Сообщество уже опознано — идентификатор пригодится следующей проверке.
self.assertEqual(meta["bot_id"], "42")
def test_long_poll_without_the_message_event_is_an_error(self) -> None:
with _answers({"response": GROUP}, {"response": {"is_enabled": True, "events": {"message_new": 0}}}):
ok, detail, _meta = checks.check_vk(secret="community-token", base_url="")
self.assertFalse(ok)
self.assertTrue(detail)
def test_broken_connection_does_not_raise(self) -> None:
with mock.patch.object(checks, "_get", side_effect=urllib.error.URLError("no route")):
ok, detail, _meta = checks.check_vk(secret="community-token", base_url="")
self.assertFalse(ok)
self.assertIn("no route", detail)
def test_token_never_leaks_into_the_status(self) -> None:
failure = urllib.error.HTTPError(
"https://api.vk.com/method/groups.getById?access_token=vk1.a.SECRET&v=5.199",
403,
"Forbidden",
{},
None,
)
with mock.patch.object(checks, "_get", side_effect=failure):
_ok, detail, _meta = checks.check_vk(secret="vk1.a.SECRET", base_url="")
self.assertNotIn("vk1.a.SECRET", detail)
@@ -423,6 +423,81 @@ class CheckProxyTransportTests(TestCase):
class OutboundUserAgentTests(TestCase):
"""Продукт представляется своим именем: «Python-urllib» защита перед чужим
API банит до самого API (на бою — Cloudflare «error code: 1010»)."""
def test_opener_introduces_the_product(self) -> None:
from chatballs.integrations.proxy import build_opener, user_agent
for proxy in ("", "http://proxy:8080"):
with self.subTest(proxy=proxy or "без прокси"):
agents = dict(build_opener(proxy).addheaders)
self.assertEqual(agents["User-Agent"], user_agent())
self.assertNotIn("urllib", agents["User-Agent"])
def test_request_keeps_its_own_agent(self) -> None:
import urllib.request
from chatballs.integrations.proxy import build_opener
request = urllib.request.Request(
"https://api.example.test/v1/models", headers={"User-Agent": "Mine/1.0"}
)
opener = build_opener("")
# urllib добавляет заголовки opener'а только к тем, которых нет в запросе.
self.assertEqual(request.get_header("User-agent"), "Mine/1.0")
self.assertTrue(any(name == "User-Agent" for name, _ in opener.addheaders))
class CheckFailureTextTests(TestCase):
"""Отказ провайдера объясняется словами: голый код ничего не говорит."""
def _reject(self, code: int, body: bytes):
import urllib.error
from io import BytesIO
error = urllib.error.HTTPError(
"https://api.example.test/v1/models", code, "Forbidden", {}, BytesIO(body)
)
return mock.patch(
"chatballs.integrations.checks.build_opener",
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
)
def test_reason_from_the_provider_reaches_the_screen(self) -> None:
body = json.dumps(
{"error": {"message": "Your API key is invalid"}}
).encode()
with self._reject(403, body):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertIn("Your API key is invalid", detail)
self.assertIn("403", detail)
def test_html_block_page_is_squeezed_into_one_line(self) -> None:
body = b"<html><head><title>Access denied</title></head><body><h1>Sorry, you have been blocked</h1></body></html>"
with self._reject(403, body):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertNotIn("<", detail)
self.assertIn("blocked", detail.lower())
def test_silent_refusal_tells_where_to_look(self) -> None:
with self._reject(403, b""):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertIn("регион", detail)
class OpenRouterProviderProxyTests(TestCase):
def test_provider_routes_through_proxy_handler(self) -> None:
@@ -12,6 +12,7 @@ from chatballs.notifications.models import (
NotificationRead,
NotificationType,
)
from chatballs.notifications.realtime import notify_notifications_changed
from chatballs.notifications.selectors import unread_for
# Реестр типов: дефолтный уровень и маршрут диплинка. Новый тип события —
@@ -131,9 +132,32 @@ def notify_management(*, context, dedup_key: str = "", **notification_data) -> i
return created
def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False) -> int:
# Маршруты, которыми уведомление ссылается на диалог. Кроме нынешнего «chat»
# здесь старые значения: они лежат в уже созданных строках, и открытый диалог
# обязан гасить и их тоже.
CONVERSATION_ROUTES = ("chat", "salesDialogs", "conversations")
def mark_read(
*,
context,
ids: list[int] | None = None,
all_unread: bool = False,
conversation_id: int | None = None,
) -> int:
"""Отметить уведомления прочитанными: перечисленные, все или про диалог.
Про диалог — потому что открытый диалог и есть прочтение: оклик «клиент
ждёт» бессмысленно висеть непрочитанным, когда сотрудник уже в переписке.
Гасятся все уведомления об этом диалоге, а не только те, что клиент успел
загрузить в шторку.
"""
queryset = unread_for(context)
if not all_unread:
if conversation_id is not None:
queryset = queryset.filter(
target_route__in=CONVERSATION_ROUTES, target_id=str(conversation_id)
)
elif not all_unread:
queryset = queryset.filter(id__in=ids or [])
rows = [
NotificationRead(
@@ -144,4 +168,8 @@ def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False
for notification in queryset
]
NotificationRead.objects.bulk_create(rows, ignore_conflicts=True)
if rows:
# Счётчик непрочитанных живёт в шапке каждой открытой вкладки: без
# события они разъезжаются до следующего опроса.
notify_notifications_changed([context.actor_user.id])
return len(rows)
+80 -1
View File
@@ -13,10 +13,11 @@ from chatballs.notifications.delivery import NOTIFICATION_CREATED
from chatballs.notifications.models import (
MessengerBinding,
MessengerBindingCode,
Notification,
NotificationAudience,
NotificationType,
)
from chatballs.notifications.selectors import visible_for
from chatballs.notifications.selectors import unread_for, visible_for
from chatballs.notifications.services import notify
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.testing import tenant_context_for
@@ -217,6 +218,84 @@ class PollerSelectionTests(NotifierTestBase):
self.assertNotIn("inactive-channel-client", polled_names)
class OpenedConversationReadTests(NotifierTestBase):
"""Открытый диалог гасит свои уведомления.
Раньше оклик «клиент ждёт» оставался непрочитанным, пока по нему не нажали
в шторке: сотрудник мог отвечать в переписке, а счётчик в шапке продолжал
звать его туда же.
"""
def setUp(self) -> None:
super().setUp()
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def _notify(self, *, target_id: str, type=NotificationType.OPERATOR_REQUESTED):
return notify(
context=self.context,
type=type,
audience=NotificationAudience.OPERATORS,
title="Клиент ждёт",
target_id=target_id,
)
def test_opened_conversation_marks_only_its_own_notifications(self) -> None:
opened = self._notify(target_id="17")
another = self._notify(target_id="18")
response = self.client.post(
"/api/v1/notifications/read/",
data={"conversationId": 17},
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["unreadCount"], 1)
unread = list(unread_for(self.context).values_list("id", flat=True))
self.assertEqual(unread, [another.id])
self.assertNotIn(opened.id, unread)
def test_every_notification_about_the_conversation_is_marked(self) -> None:
# В шторку помещается не всё: гасим по диалогу, а не по тому, что успел
# загрузить клиент.
waiting = self._notify(target_id="17")
message = self._notify(target_id="17", type=NotificationType.DIALOG_NEW_MESSAGE)
self.client.post(
"/api/v1/notifications/read/",
data={"conversationId": 17},
content_type="application/json",
)
read = set(unread_for(self.context).values_list("id", flat=True))
self.assertNotIn(waiting.id, read)
self.assertNotIn(message.id, read)
def test_legacy_route_of_old_notifications_is_recognized(self) -> None:
old = self._notify(target_id="17")
Notification.objects.filter(id=old.id).update(target_route="salesDialogs")
self.client.post(
"/api/v1/notifications/read/",
data={"conversationId": 17},
content_type="application/json",
)
self.assertEqual(unread_for(self.context).count(), 0)
def test_broken_conversation_id_is_rejected(self) -> None:
self._notify(target_id="17")
response = self.client.post(
"/api/v1/notifications/read/",
data={"conversationId": "17"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(unread_for(self.context).count(), 1)
class BindingApiTests(NotifierTestBase):
def setUp(self) -> None:
super().setUp()
@@ -129,8 +129,14 @@ class NotificationReadView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
conversation_id = request.data.get("conversationId")
if request.data.get("all"):
mark_read(context=request.tenant_context, all_unread=True)
elif conversation_id is not None:
# Сотрудник открыл диалог — окликать по нему больше нечем.
if not isinstance(conversation_id, int) or isinstance(conversation_id, bool):
return Response({"detail": t("notifications.conversation_id_number")}, status=400)
mark_read(context=request.tenant_context, conversation_id=conversation_id)
else:
ids = request.data.get("ids")
if not isinstance(ids, list):
@@ -5,7 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
import { api, ApiError } from "../../api/client";
import { ChannelGlyph } from "../../shared/badges";
import { SelectField } from "../../shared/form-controls";
import { FormField, SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
@@ -534,6 +534,13 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
}) {
const missingProvider = card.providerIntegrationId === null;
const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? "";
const transcriptionName = providers.find((item) => item.id === card.transcriptionIntegrationId)?.name ?? "";
// Поля моделей редактируются свободно и уходят на сервер по потере фокуса:
// сохранять каждую букву — это запрос на символ.
const [modelDraft, setModelDraft] = useState(card.model);
const [transcriptionDraft, setTranscriptionDraft] = useState(card.transcriptionModel);
useEffect(() => { setModelDraft(card.model); }, [card.model]);
useEffect(() => { setTranscriptionDraft(card.transcriptionModel); }, [card.transcriptionModel]);
return (
<section className="agent-card is-side">
@@ -550,13 +557,38 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<label className="agent-field is-model">
<span>{t("common.model")}</span>
<span className="agent-field-control">
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? t("ai.pick_provider") : card.model}</span>
<Icon name="search" size={14} strokeWidth={1.8} />
</span>
</label>
{/* Ключ провайдера один на организацию, а агентов на нём несколько:
модель принадлежит агенту. Пустое поле — «как в интеграции», и
подсказкой в нём стоит её модель. */}
<FormField
disabled={busy || !canManage}
label={t("common.model")}
mono
placeholder={missingProvider ? t("ai.pick_provider") : card.providerModel || t("ai.model_of_integration")}
value={modelDraft}
onChange={setModelDraft}
onBlur={() => { if (modelDraft !== card.model) void apply({ model: modelDraft }); }}
/>
{/* Речь в текст умеет не всякая модель, которой агент отвечает: у части
провайдеров аудио-эндпоинта нет вовсе. Поэтому выбор отдельный. */}
<SelectField
disabled={busy}
label={t("ai.transcription_provider")}
readOnly={!canManage}
readOnlyText={transcriptionName || t("ai.same_as_answers")}
value={card.transcriptionIntegrationId ? String(card.transcriptionIntegrationId) : ""}
onChange={(next) => void apply({ transcriptionIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.same_as_answers")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<FormField
disabled={busy || !canManage}
label={t("ai.transcription_model")}
mono
placeholder={card.transcriptionProviderModel || t("ai.model_of_integration")}
value={transcriptionDraft}
onChange={setTranscriptionDraft}
onBlur={() => { if (transcriptionDraft !== card.transcriptionModel) void apply({ transcriptionModel: transcriptionDraft }); }}
/>
</div>
</section>
);
+14 -2
View File
@@ -35,7 +35,14 @@ export type AgentCard = {
groupColor: string;
aiStatus: AgentAiStatus;
model: string;
/** Модель ответов; пусто — как в интеграции. */
transcriptionModel: string;
/** Модели, заданные в интеграциях: подсказка в пустом поле. */
providerModel: string;
transcriptionProviderModel: string;
providerIntegrationId: number | null;
/** Чем расшифровывать голосовые; null — тем же провайдером, что отвечает. */
transcriptionIntegrationId: number | null;
modelParams: Record<string, unknown>;
// Режим языка ответов: MIRROR, ORGANIZATION или код языка.
answerLanguage: string;
@@ -57,6 +64,9 @@ export type AgentPatch = Partial<{
groupId: number | null;
isActive: boolean;
providerIntegrationId: number | null;
transcriptionIntegrationId: number | null;
model: string;
transcriptionModel: string;
answerLanguage: string;
persona: string;
tone: string;
@@ -155,9 +165,11 @@ export function agentTile(card: Pick<AgentCard, "id" | "isActive">): { color: st
return { color, background: `color-mix(in srgb, ${color} 12%, var(--surface-card))` };
}
/** Модель в списке: пока провайдер не выбран, показывать нечего (кадр G1). */
/** Модель в списке: своя модель агента, иначе та, что стоит в интеграции.
* Пока провайдер не выбран, показывать нечего (кадр G1). */
export function agentModelLabel(card: AgentCard): string {
return card.providerIntegrationId ? card.model : "—";
if (!card.providerIntegrationId) return "—";
return card.model || card.providerModel || "—";
}
export function agentTint(provider: string): { color: string; bg: string; full: string } {
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { resolveAudioMode } from "./AudioCallOverlay";
import type { ApiCall } from "./model";
// Экран аудиозвонка у оператора: что он показывает в каждой фазе.
function call(status: ApiCall["status"]): ApiCall {
return { id: "c1", conversationId: 1, status, kind: "AUDIO", connectedAt: null, endedBy: null, durationSeconds: null } as ApiCall;
}
describe("resolveAudioMode", () => {
it("показывает разговор, пока звонок идёт", () => {
expect(resolveAudioMode(call("ACTIVE"), "", "connected", "none")).toBe("active");
});
it("считает звонок завершённым, даже когда соединение ещё считает себя живым", () => {
// Клиент положил трубку: сервер уже в терминале, а peer connection узнаёт
// об этом позже — или не узнаёт вовсе, если сокет мёртв.
expect(resolveAudioMode(call("ENDED"), "", "connected", "none")).toBe("status");
expect(resolveAudioMode(call("DECLINED"), "", "connected", "none")).toBe("status");
expect(resolveAudioMode(call("FAILED"), "", "connected", "none")).toBe("status");
});
it("до ответа клиента звонок исходящий", () => {
expect(resolveAudioMode(call("RINGING"), "", "idle", "none")).toBe("ringing");
});
it("принятый звонок без соединения — соединение", () => {
expect(resolveAudioMode(call("ACCEPTED"), "", "idle", "none")).toBe("connecting");
});
});
@@ -67,7 +67,13 @@ export function AudioCallOverlay(props: Props) {
// состоянии (карточку звонка могли открыть из списка), а сам запрос может
// упасть — тогда звонок заканчивается вторым путём, по сессии оператора.
const finish = async () => {
if (!call || isTerminalCallStatus(call.status)) return;
if (!call) return;
// Звонок уже закончил кто-то другой — завершать нечего, окно закрывается.
if (isTerminalCallStatus(call.status)) {
rtc.stop();
props.onClose();
return;
}
const token = props.access?.accessToken;
try {
if (token) props.onCallChange(await endCallByAccess(token));
@@ -123,16 +129,21 @@ export function AudioCallOverlay(props: Props) {
);
}
function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode {
export function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode {
if (errorText || !call) return "status";
if (mediaIssue === "devices" || mediaIssue === "unsupported") return "status";
// Завершённый звонок завершён, чем бы ни было занято RTC-соединение. Клиент
// кладёт трубку, сервер переводит звонок в терминал, а peer connection у
// оператора об этом узнаёт позже — а если сокет уже мёртв, то и не узнаёт
// вовсе. Пока эта проверка стояла последней, у оператора оставался экран
// активного разговора с кнопкой «Завершить», которой нечего было завершать.
if (isTerminalCallStatus(call.status)) return "status";
// Звонок всегда инициирует оператор, поэтому до ответа клиента это исходящий.
if (call.status === "REQUESTED" || call.status === "RINGING") return "ringing";
if (call.status === "ACCEPTED") return connection === "connected" ? "active" : "connecting";
if (connection === "reconnecting") return "reconnecting";
if (connection === "failed") return "status";
if (call.status === "ACTIVE" || connection === "connected") return "active";
if (isTerminalCallStatus(call.status)) return "status";
return "connecting";
}
@@ -46,6 +46,7 @@ import type { ConversationListItem, ListSort, ListTab } from "./types";
import { useConversationCall } from "./useConversationCall";
import { useConversationEvents } from "./useConversationEvents";
import { useDebounced } from "../../shared/useDebounced";
import { useOpenedConversationRead } from "../notifications/useOpenedConversationRead";
import { useConversationHistory } from "./useConversationHistory";
import { useConversationList } from "./useConversationList";
import { useDialogKeyboardNav } from "./useDialogKeyboardNav";
@@ -148,6 +149,8 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
setCtxOpen(false);
}, [selectedId]);
useOpenedConversationRead(selectedId);
useEffect(() => {
if (selectedId == null) return;
setDetail(null);
@@ -69,7 +69,13 @@ export function VideoCallOverlay(props: Props) {
// «Завершить» обязано сработать всегда: нет токена доступа или запрос не
// прошёл — звонок заканчивается вторым путём, по сессии оператора.
const finish = async () => {
if (!call || TERMINAL[call.status]) return;
if (!call) return;
// Звонок уже закончил кто-то другой — завершать нечего, окно закрывается.
if (TERMINAL[call.status]) {
rtc.stop();
props.onClose();
return;
}
const token = props.access?.accessToken;
try {
if (token) props.onCallChange(await endCallByAccess(token));
@@ -34,7 +34,7 @@ export const isImageAttachment = (message: Pick<ApiMessage, "attachmentContentTy
export type HistoryItem = {
id: number;
channelName: string;
provider: "EMAIL" | "MAX" | "TELEGRAM" | "WEB" | null;
provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB" | null;
lifecycle: "OPEN" | "CLOSED" | "SPAM";
createdAt: string;
lastActivityAt: string;
@@ -69,7 +69,7 @@ export type ApiConversation = {
id: number;
channel: { id: number; code: string; name: string };
// voiceMessages/audioCalls/videoCalls — что разрешено в точке входа («Настройки → Голосовые и звонки»).
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
// Контакт — единственный источник identity диалога.
// phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме.
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string } | null;
@@ -145,6 +145,7 @@ const PROVIDER_CHANNEL: Record<string, ChannelKey> = {
EMAIL: "EMAIL",
MAX: "MAX",
TELEGRAM: "TG",
VK: "VK",
WEB: "WEB",
};
@@ -10,7 +10,7 @@ export type ListTab = "all" | "mine" | "queue" | "onMe";
// Порядок инбокса. Считает его сервер: список приходит окном, и сортировать в
// браузере было бы нечего.
export type ListSort = "activity" | "waiting";
export type ChannelKey = "EMAIL" | "MAX" | "TG" | "WEB";
export type ChannelKey = "EMAIL" | "MAX" | "TG" | "VK" | "WEB";
// Элемент списка диалогов (бывш. SalesDialog). Полностью generic.
export type ConversationListItem = {
@@ -2,6 +2,7 @@ import { Modal } from "antd";
import { useEffect, useState } from "react";
import { api } from "../../api/client";
import { helpArticleUrl } from "../../shared/help";
import { Icon } from "../../shared/icons";
import { FormField, SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
@@ -124,6 +125,12 @@ export function IntegrationForm({ initial, kind, onClose, onSaved }: { initial:
) : (
<SelectField label={t("settings.type")} value={provider} onChange={(value) => setProvider(value as IntegrationProvider)} options={options} />
)}
{meta.helpSlug && (
<a className="link is-muted has-icon integration-form-help" href={helpArticleUrl(meta.helpSlug)} target="_blank" rel="noopener noreferrer">
{t("settings.how_to_connect")}
<Icon name="external" size={13} strokeWidth={2.2} />
</a>
)}
<FormField label={t("common.title")} value={name} onChange={setName} placeholder={isEmail ? t("settings.e_g_support_mailbox") : t("settings.e_g_openrouter_primary")} />
{isEmail && <EmailFields value={emailConfig} onChange={setEmailConfig} />}
{meta.testable && (
@@ -1,7 +1,7 @@
import { api } from "../../api/client";
import { t } from "../../i18n";
export type IntegrationProvider = "OPENROUTER" | "CUSTOM" | "DEMO" | "MAX" | "TELEGRAM" | "WEB" | "EMAIL";
export type IntegrationProvider = "OPENROUTER" | "CUSTOM" | "DEMO" | "MAX" | "TELEGRAM" | "VK" | "WEB" | "EMAIL";
export type IntegrationKind = "LLM_PROVIDER" | "MESSENGER";
export type IntegrationStatus = "UNCHECKED" | "OK" | "ERROR";
export type WebChatWidgetSummary = {
@@ -67,6 +67,8 @@ type ProviderMeta = {
// (у Web-виджета секрета нет, но backend проверяет привязку к каналу).
testable: boolean;
checkable: boolean;
// Статья Центра помощи про этот тип подключения (shared/help).
helpSlug?: string;
};
export const PROVIDERS: Record<IntegrationProvider, ProviderMeta> = {
@@ -76,11 +78,14 @@ export const PROVIDERS: Record<IntegrationProvider, ProviderMeta> = {
CUSTOM: { label: "Custom (OpenAI-compatible)", kind: "LLM_PROVIDER", secretLabel: t("settings.api_key"), defaultBaseUrl: "", hasModel: true, testable: true, checkable: true },
// Демо-провайдер — живой AI без ключей и сети для знакомства с системой: отвечает по знаниям агента.
DEMO: { label: t("settings.demo_provider_no_key"), kind: "LLM_PROVIDER", secretLabel: "", defaultBaseUrl: "", hasModel: false, testable: false, checkable: true },
MAX: { label: "MAX", kind: "MESSENGER", secretLabel: t("settings.bot_token"), defaultBaseUrl: "https://platform-api.max.ru", hasModel: false, testable: true, checkable: true },
TELEGRAM: { label: "Telegram", kind: "MESSENGER", secretLabel: t("settings.bot_token"), defaultBaseUrl: "https://api.telegram.org", hasModel: false, testable: true, checkable: true },
WEB: { label: t("common.web_widget"), kind: "MESSENGER", secretLabel: "", defaultBaseUrl: "", hasModel: false, testable: false, checkable: true },
MAX: { label: "MAX", kind: "MESSENGER", secretLabel: t("settings.bot_token"), defaultBaseUrl: "https://platform-api.max.ru", hasModel: false, testable: true, checkable: true, helpSlug: "max" },
TELEGRAM: { label: "Telegram", kind: "MESSENGER", secretLabel: t("settings.bot_token"), defaultBaseUrl: "https://api.telegram.org", hasModel: false, testable: true, checkable: true, helpSlug: "telegram" },
// ВКонтакте — сообщество: секрет это ключ доступа сообщества, идентификатор
// сообщества подставляет проверка подключения (ADR-CHATBALLS-0020).
VK: { label: "ВКонтакте", kind: "MESSENGER", secretLabel: t("settings.vk_community_key"), defaultBaseUrl: "https://api.vk.com/method", hasModel: false, testable: true, checkable: true, helpSlug: "vkontakte" },
WEB: { label: t("common.web_widget"), kind: "MESSENGER", secretLabel: "", defaultBaseUrl: "", hasModel: false, testable: false, checkable: true, helpSlug: "veb-vidzhet" },
// Email — подключение-ящик IMAP/SMTP (ADR-CHATBALLS-0035); секрет — пароль приложения.
EMAIL: { label: "Email (IMAP/SMTP)", kind: "MESSENGER", secretLabel: t("common.password"), defaultBaseUrl: "", hasModel: false, testable: true, checkable: true },
EMAIL: { label: "Email (IMAP/SMTP)", kind: "MESSENGER", secretLabel: t("common.password"), defaultBaseUrl: "", hasModel: false, testable: true, checkable: true, helpSlug: "pochtovyj-yashchik" },
};
export const STATUS_META: Record<IntegrationStatus, { label: string; bg: string; color: string }> = {
@@ -1,7 +1,7 @@
import { Dropdown } from "antd";
import { useState } from "react";
import { Icon, MaxLogo, TelegramLogo } from "../../shared/icons";
import { Icon, MaxLogo, TelegramLogo, VkLogo } from "../../shared/icons";
import { Button, ToneBadge } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
import { PROVIDERS, STATUS_META, type Integration, type IntegrationProvider } from "./model";
@@ -12,6 +12,7 @@ import { t } from "../../i18n";
const TILE_CLASS: Partial<Record<IntegrationProvider, string>> = {
TELEGRAM: "integration-tile--telegram",
MAX: "integration-tile--max",
VK: "integration-tile--vk",
WEB: "integration-tile--web",
EMAIL: "integration-tile--email",
};
@@ -31,6 +32,7 @@ export function ConnectionIcon({ provider }: { provider: IntegrationProvider })
<span className={`product-icon integration-tile ${tile}`}>
{provider === "TELEGRAM" && <TelegramLogo size={20} />}
{provider === "MAX" && <MaxLogo size={20} />}
{provider === "VK" && <VkLogo size={20} />}
{provider === "WEB" && <Icon name="message" size={20} strokeWidth={1.9} />}
{provider === "EMAIL" && <Icon name="mail" size={20} strokeWidth={1.9} />}
</span>
@@ -70,6 +70,11 @@
background: color-mix(in srgb, #5b5bd6 14%, var(--surface-card));
}
.integration-tile--vk {
color: #0077ff;
background: color-mix(in srgb, #0077ff 14%, var(--surface-card));
}
.integration-tile--web {
color: var(--primary-text);
background: var(--primary-bg);
@@ -133,6 +138,14 @@
font-size: 11.5px;
}
/* Ссылка на статью Центра помощи про выбранный тип подключения: стоит под
полем «Тип» подписью к нему, поэтому подтянута к нему вплотную. */
.integration-form-help {
align-self: flex-start;
margin-top: -10px;
font-size: 12.5px;
}
/* Секции Email-формы: IMAP / SMTP (SPEC-CHATBALLS-0025 §3.2). */
.integration-email-section {
display: flex;
@@ -24,6 +24,10 @@ export const LEVEL_META: Record<NotificationLevel, { color: string; icon: "bell"
export const fetchNotifications = () => api<{ items: AppNotification[]; unreadCount: number }>("/api/v1/notifications/");
export const markRead = (ids: number[]) => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ ids }) });
export const markAllRead = () => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ all: true }) });
// Открытый диалог — тоже прочтение: гасит все уведомления о нём, а не только
// те, что попали в шторку.
export const markConversationRead = (conversationId: number) =>
api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ conversationId }) });
export type NotificationTransport = "BROWSER" | "MESSENGER";
@@ -0,0 +1,19 @@
import { useEffect } from "react";
import { markConversationRead } from "./model";
/** Открытый диалог гасит свои уведомления.
*
* Раньше оклик «клиент ждёт» оставался непрочитанным, даже когда сотрудник уже
* отвечал в этой переписке: прочтением считалось только нажатие на строку в
* шторке. Счётчик в шапке жил своей жизнью и переставал что-либо значить.
*
* Список уведомлений после этого перезапрашивает шапка — по событию, которое
* шлёт сервер тому же сотруднику: вкладок у него может быть несколько.
*/
export function useOpenedConversationRead(conversationId: number | null): void {
useEffect(() => {
if (conversationId == null) return;
void markConversationRead(conversationId).catch(() => undefined);
}, [conversationId]);
}
@@ -9,8 +9,8 @@ import { t } from "../../../i18n";
export type ClientDetailTab = "overview" | "dialogs" | "ids" | "audit";
const PROVIDER_TO_CHANNEL: Record<string, ClientChannelCode> = { EMAIL: "EMAIL", MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" };
const PROVIDER_LABEL: Record<string, string> = { EMAIL: "Email", MAX: "MAX", TELEGRAM: "Telegram", WEB: t("common.web_widget") };
const PROVIDER_TO_CHANNEL: Record<string, ClientChannelCode> = { EMAIL: "EMAIL", MAX: "MAX", TELEGRAM: "TG", VK: "VK", WEB: "WEB" };
const PROVIDER_LABEL: Record<string, string> = { EMAIL: "Email", MAX: "MAX", TELEGRAM: "Telegram", VK: "ВКонтакте", WEB: t("common.web_widget") };
// Подпись и цвет режима диалога — те же, что в чате и в списке контактов.
const MODE_META: Record<ClientDialogMode, { label: string; color: string; bg: string; dot: string }> = {
@@ -9,7 +9,7 @@ import { fmt, t } from "../../../i18n";
export { channelMap };
export type ClientChannelCode = "EMAIL" | "MAX" | "TG" | "WEB";
export type ClientChannelCode = "EMAIL" | "MAX" | "TG" | "VK" | "WEB";
export type ClientSortKey = "last" | "open";
export type ClientDropdown = "agents" | "channels";
export type ClientAgentRef = { id: number; code: string; name: string };
@@ -49,6 +49,7 @@ export const channelOptions: Array<{ code: ClientChannelCode; name: string; colo
{ code: "EMAIL", name: "Email", color: channelMap.EMAIL.color },
{ code: "MAX", name: "MAX", color: channelMap.MAX.color },
{ code: "TG", name: "Telegram", color: channelMap.TG.color },
{ code: "VK", name: "ВКонтакте", color: channelMap.VK.color },
{ code: "WEB", name: t("common.web_widget"), color: channelMap.WEB.color },
];
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { ChannelGlyph } from "../../../../shared/badges";
import { Icon } from "../../../../shared/icons";
import { CopyButton } from "../../../../shared/ui-controls";
import { providerMeta } from "../../../../shared/providers";
@@ -73,10 +74,12 @@ export function ClientContext({
}
}
const fields: Array<{ key: string; icon: Parameters<typeof Icon>[0]["name"]; text: string; copy?: string; muted?: boolean }> = [];
// Логин показывается маркой своего канала: общий значок отправки годился,
// пока каналов с логином было два, и врал уже на третьем.
const fields: Array<{ key: string; icon: Parameters<typeof Icon>[0]["name"]; glyph?: ReactNode; text: string; copy?: string; muted?: boolean }> = [];
if (phone) fields.push({ key: "phone", icon: "phone", text: phone, copy: phone });
if (dialog.channel === "EMAIL" && email) fields.push({ key: "email", icon: "mail", text: email, copy: email });
if (username) fields.push({ key: "username", icon: "send", text: `@${username} · ${channel.label}`, copy: `@${username}` });
if (username) fields.push({ key: "username", icon: "send", glyph: <ChannelGlyph provider={dialog.channel} size={15} />, text: `@${username} · ${channel.label}`, copy: `@${username}` });
if (isGuest) fields.push({ key: "guest", icon: "message", text: t("sales.anonymous_session", { channel: channel.label, connection: detail?.connection?.name ?? t("sales.widget") }), muted: true });
if (fields.length === 0 && detail?.connection) fields.push({ key: "connection", icon: "plug", text: `${channel.label} · ${detail.connection.name}`, muted: true });
// Компания и город — из карточки контакта (решение 5), без «копировать».
@@ -109,7 +112,7 @@ export function ClientContext({
<div className="ctx-contact-fields">
{fields.map((field) => (
<div className={`ctx-contact-field ${field.muted ? "is-muted" : ""}`} key={field.key}>
<span><Icon name={field.icon} size={15} /></span>
<span>{field.glyph ?? <Icon name={field.icon} size={15} />}</span>
<span>{field.text}</span>
{field.copy && <CopyButton value={field.copy} />}
</div>
@@ -199,6 +199,11 @@
.sales-dialog-avatar-wrap {
position: relative;
/* Обёртка обязана быть ростом с аватар. Строка — flex, и по умолчанию её
ячейки растягиваются на всю высоту: у строки с метками обёртка вырастала
вдвое, а точка режима, привязанная к её низу, уезжала под аватар. */
align-self: flex-start;
display: inline-flex;
flex: none;
margin-top: 20px;
}
+6
View File
@@ -384,6 +384,7 @@ export const en: Record<MessageKey, Message> = {
"ai.manage": "Manage",
"ai.material_import": "Material import",
"ai.move": "Move",
"ai.model_of_integration": "Same as in the integration",
"ai.move_category": "Move to a category",
"ai.name_group_all_takes_start": "A name and a group are all it takes to start. Instructions, knowledge and connections are set later on the card.",
"ai.name_unique_within_its_parent": "The name is unique within its parent.",
@@ -436,6 +437,7 @@ export const en: Record<MessageKey, Message> = {
"ai.replies_2": "IN REPLIES",
"ai.rules": "Rules",
"ai.save_reindex": "Save and reindex",
"ai.same_as_answers": "Same as answers",
"ai.saving": "Saving…",
"ai.search_by_title": "Search by title",
"ai.search_by_title_description": "Search by title and description",
@@ -490,6 +492,8 @@ export const en: Record<MessageKey, Message> = {
"ai.up_25_mb": "· up to 25 MB",
"ai.update": "update",
"ai.updated": "· Updated:",
"ai.transcription_model": "Transcription model",
"ai.transcription_provider": "Voice transcription",
"ai.updated_2": "UPDATED",
"ai.updated_by_at": "updated by {name} · {date}",
"ai.updated_on": "updated {date}",
@@ -1477,6 +1481,8 @@ export const en: Record<MessageKey, Message> = {
"settings.back_section_list": "Back to the section list",
"settings.bot": "Bot",
"settings.bot_token": "Bot token",
"settings.vk_community_key": "Community access key",
"settings.how_to_connect": "How to connect",
"settings.bots_email_web_widget_entry": "Bots, email and the web widget are the entry points for conversations. Each is bound to the agent that answers first.",
"settings.bucket": "Bucket",
"settings.check": "Check",
+6
View File
@@ -385,6 +385,7 @@ export const ru = {
"ai.manage": "Управлять",
"ai.material_import": "Импорт материалов",
"ai.move": "Переместить",
"ai.model_of_integration": "Как в интеграции",
"ai.move_category": "Переместить в категорию",
"ai.name_group_all_takes_start": "Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.",
"ai.name_unique_within_its_parent": "Имя уникально внутри родителя.",
@@ -437,6 +438,7 @@ export const ru = {
"ai.replies_2": "В ОТВЕТАХ",
"ai.rules": "Правила",
"ai.save_reindex": "Сохранить и переиндексировать",
"ai.same_as_answers": "Как у ответов",
"ai.saving": "Сохранение…",
"ai.search_by_title": "Поиск по названию",
"ai.search_by_title_description": "Поиск по заголовку и описанию",
@@ -491,6 +493,8 @@ export const ru = {
"ai.up_25_mb": "· до 25 МБ",
"ai.update": "обновить",
"ai.updated": "· Обновлено:",
"ai.transcription_model": "Модель расшифровки",
"ai.transcription_provider": "Расшифровка голосовых",
"ai.updated_2": "ОБНОВЛЕНО",
"ai.updated_by_at": "обновил {name} · {date}",
"ai.updated_on": "обновлено {date}",
@@ -1478,6 +1482,8 @@ export const ru = {
"settings.back_section_list": "К списку разделов",
"settings.bot": "Бот",
"settings.bot_token": "Токен бота",
"settings.vk_community_key": "Ключ доступа сообщества",
"settings.how_to_connect": "Как подключить",
"settings.bots_email_web_widget_entry": "Боты, почта и Web-виджет — точки входа диалогов. Каждая привязана к агенту, который отвечает первым.",
"settings.bucket": "Бакет",
"settings.check": "Проверить",
+4 -3
View File
@@ -1,12 +1,13 @@
import { Icon, MaxLogo, TelegramLogo } from "./icons";
import { Icon, MaxLogo, TelegramLogo, VkLogo } from "./icons";
import { providerKey } from "./providers";
/** Иконка канала без подложки (дизайн-базлайн v2, решение 4): Telegram/MAX —
* фирменные глифы, Email/Web — линейные. */
/** Иконка канала без подложки (дизайн-базлайн v2, решение 4): Telegram, MAX и
* ВКонтакте — фирменные глифы, Email/Web — линейные. */
export function ChannelGlyph({ provider, size = 13 }: { provider: string; size?: number }) {
const key = providerKey(provider);
if (key === "TG") return <TelegramLogo size={size} />;
if (key === "MAX") return <MaxLogo size={size} />;
if (key === "VK") return <VkLogo size={size} />;
if (key === "EMAIL") return <Icon name="mail" size={size} />;
return <Icon name="message" size={size} />;
}
@@ -9,6 +9,9 @@ type FormFieldProps = {
label: string;
mono?: boolean;
onChange?: (value: string) => void;
/** Значение уходит на сервер по потере фокуса: запрос на каждую букву — это
* запрос на каждую букву. */
onBlur?: () => void;
placeholder?: string;
type?: "password" | "text";
value: string;
@@ -21,6 +24,7 @@ export function FormField({
label,
mono = false,
onChange,
onBlur,
placeholder = "",
type = "text",
value,
@@ -39,7 +43,7 @@ export function FormField({
return (
<label className={className}>
<span>{label}</span>
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} />
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} onBlur={onBlur} />
{error && <small className="form-field-error" role="alert">{error}</small>}
</label>
);
+12
View File
@@ -0,0 +1,12 @@
/* Ссылки на Центр помощи продукта.
Центр помощи один на продукт и живёт на домене разработчика: это
документация Chatballs, а не портал организации, поэтому адрес здесь
постоянный и от установки не зависит. Порталы самой организации
настраиваются в разделе «Порталы» и к этим ссылкам отношения не имеют. */
const HELP_CENTER_URL = "https://chatballs.com.edevs.tech";
export function helpArticleUrl(slug: string): string {
return `${HELP_CENTER_URL}/articles/${slug}`;
}
+8
View File
@@ -57,6 +57,14 @@ export function MaxLogo({ size = 24 }: { size?: number }) {
);
}
export function VkLogo({ size = 24 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor">
<path d="m9.489.004.729-.003h3.564l.73.003.914.01.433.007.418.011.403.014.388.016.374.021.36.025.345.03.333.033c1.74.196 2.933.616 3.833 1.516.9.9 1.32 2.092 1.516 3.833l.034.333.029.346.025.36.02.373.025.588.012.41.013.644.009.915.004.98-.001 3.313-.003.73-.01.914-.007.433-.011.418-.014.403-.016.388-.021.374-.025.36-.03.345-.033.333c-.196 1.74-.616 2.933-1.516 3.833-.9.9-2.092 1.32-3.833 1.516l-.333.034-.346.029-.36.025-.373.02-.588.025-.41.012-.644.013-.915.009-.98.004-3.313-.001-.73-.003-.914-.01-.433-.007-.418-.011-.403-.014-.388-.016-.374-.021-.36-.025-.345-.03-.333-.033c-1.74-.196-2.933-.616-3.833-1.516-.9-.9-1.32-2.092-1.516-3.833l-.034-.333-.029-.346-.025-.36-.02-.373-.025-.588-.012-.41-.013-.644-.009-.915-.004-.98.001-3.313.003-.73.01-.914.007-.433.011-.418.014-.403.016-.388.021-.374.025-.36.03-.345.033-.333c.196-1.74.616-2.933 1.516-3.833.9-.9 2.092-1.32 3.833-1.516l.333-.034.346-.029.36-.025.373-.02.588-.025.41-.012.644-.013.915-.009ZM6.79 7.3H4.05c.13 6.24 3.25 9.99 8.72 9.99h.31v-3.57c2.01.2 3.53 1.67 4.14 3.57h2.84c-.78-2.84-2.83-4.41-4.11-5.01 1.28-.74 3.08-2.54 3.51-4.98h-2.58c-.56 1.98-2.22 3.78-3.8 3.95V7.3H10.5v6.92c-1.6-.4-3.62-2.34-3.71-6.92Z" />
</svg>
);
}
export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "bellOff" | "chevron" | "chevronLeft" | "chevronRight" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "alert" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "close" | "folder" | "grip" | "reply" | "sort" | "collapseLeft" | "mic" | "text" | "pin" | "smile" | "globe" | "doc" | "sparkles" | "database" | "sun" | "sunny" | "moon" | "monitor" | "laptop" | "smartphone" | "arrowDown" | "link" | "paint" | "widget" | "danger" | "image" | "file" | "bold" | "italic" | "code" | "numlist" | "quote" | "table" | "attach" | "undo" | "upload" | "import" | "thumbUp" | "thumbDown" | "book" | "move"; size?: number; strokeWidth?: number }) {
const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth, strokeLinecap: "round", strokeLinejoin: "round" } as const;
const paths: Record<typeof name, ReactNode> = {
+5 -2
View File
@@ -1,18 +1,19 @@
import { t } from "../i18n";
/* Провайдеры подключений: MAX / Telegram / Web Chat / Email.
/* Провайдеры подключений: MAX / Telegram / ВКонтакте / Web Chat / Email.
Общий словарь для всего приложения — раньше он лежал в демо-данных диалогов,
и продуктовые экраны каналов зависели от файла с примерами.
Провайдеры различаются точкой и коротким лейблом на своих токенах
(ADR-HUB-0013), а не бренд-логотипами. */
export type ProviderKey = "EMAIL" | "MAX" | "TG" | "WEB";
export type ProviderKey = "EMAIL" | "MAX" | "TG" | "VK" | "WEB";
export const providerMeta: Record<ProviderKey, { label: string; short: string; color: string; bg: string }> = {
EMAIL: { label: "Email", short: "Email", color: "#d48806", bg: "#fff7e6" },
MAX: { label: "MAX", short: "MAX", color: "#6b5be0", bg: "#f2f0ff" },
TG: { label: "Telegram", short: "TG", color: "#2f8fd0", bg: "#eaf6fd" },
VK: { label: "ВКонтакте", short: "VK", color: "#0077ff", bg: "#e8f2ff" },
WEB: { label: "Web Chat", short: "Web", color: "#0f9b8e", bg: "#e8f7f4" },
};
@@ -22,6 +23,7 @@ const BY_CODE: Record<string, ProviderKey> = {
MAX: "MAX",
TELEGRAM: "TG",
TG: "TG",
VK: "VK",
WEB: "WEB",
WEBCHAT: "WEB",
};
@@ -37,6 +39,7 @@ export const channelMap = {
EMAIL: { full: "Email", color: "#d48806", bg: "color-mix(in srgb, #fa8c16 14%, var(--surface-card))" },
MAX: { full: "MAX", color: "#5b5bd6", bg: "color-mix(in srgb, #5b5bd6 14%, var(--surface-card))" },
TG: { full: "Telegram", color: "#229ed9", bg: "color-mix(in srgb, #229ed9 14%, var(--surface-card))" },
VK: { full: "ВКонтакте", color: "#0077ff", bg: "color-mix(in srgb, #0077ff 14%, var(--surface-card))" },
WEB: { full: t("common.web_widget"), color: "#0f9b8e", bg: "color-mix(in srgb, #0f9b8e 14%, var(--surface-card))" },
} satisfies Record<ProviderKey, { full: string; color: string; bg: string }>;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

@@ -1,508 +0,0 @@
{
"plugins": [
"react",
"import"
],
"rules": {
"react/forbid-elements": [
"warn",
{
"forbid": []
}
],
"no-restricted-imports": [
"warn",
{
"patterns": [
{
"group": [
"components/cards/**",
"components/core/**",
"components/inbox/**",
"components/status/**",
"ui_kits/checkout/**",
"ui_kits/internal-hub/**",
"ui_kits/web-chat/**"
],
"message": "Import design-system components from 'index.js', not component internals."
}
]
}
],
"no-restricted-syntax": [
"warn",
{
"selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]",
"message": "Raw hex color — use a design-system color token via var()."
},
{
"selector": "Literal[value=/\\b\\d+px\\b/]",
"message": "Raw px value — use a design-system spacing token via var()."
},
{
"selector": "JSXOpeningElement[name.name='ActorBadge'] > JSXAttribute > JSXIdentifier[name!=/^(?:actor|initials|color|key|ref|className|style|children)$/]",
"message": "<ActorBadge> doesn't accept that prop. Declared props: actor, initials, color."
},
{
"selector": "JSXOpeningElement[name.name='ActorBadge'] > JSXAttribute[name.name='actor'] > Literal[value!=/^(?:ai|human)$/]",
"message": "<ActorBadge> actor must be one of 'ai' | 'human'."
},
{
"selector": "JSXOpeningElement[name.name='AttentionStatus'] > JSXAttribute > JSXIdentifier[name!=/^(?:level|label|key|ref|className|style|children)$/]",
"message": "<AttentionStatus> doesn't accept that prop. Declared props: level, label."
},
{
"selector": "JSXOpeningElement[name.name='AttentionStatus'] > JSXAttribute[name.name='level'] > Literal[value!=/^(?:ok|attention|critical)$/]",
"message": "<AttentionStatus> level must be one of 'ok' | 'attention' | 'critical'."
},
{
"selector": "JSXOpeningElement[name.name='Avatar'] > JSXAttribute > JSXIdentifier[name!=/^(?:initials|color|size|key|ref|className|style|children)$/]",
"message": "<Avatar> doesn't accept that prop. Declared props: initials, color, size."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|size|disabled|onClick|type|key|ref|className|style|children)$/]",
"message": "<Button> doesn't accept that prop. Declared props: children, variant, size, disabled, onClick, type."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|secondary|ghost|danger)$/]",
"message": "<Button> variant must be one of 'primary' | 'secondary' | 'ghost' | 'danger'."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md|lg)$/]",
"message": "<Button> size must be one of 'sm' | 'md' | 'lg'."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='type'] > Literal[value!=/^(?:button|submit)$/]",
"message": "<Button> type must be one of 'button' | 'submit'."
},
{
"selector": "JSXOpeningElement[name.name='ChannelBadge'] > JSXAttribute > JSXIdentifier[name!=/^(?:channel|title|key|ref|className|style|children)$/]",
"message": "<ChannelBadge> doesn't accept that prop. Declared props: channel, title."
},
{
"selector": "JSXOpeningElement[name.name='ChannelBadge'] > JSXAttribute[name.name='channel'] > Literal[value!=/^(?:max|telegram|webchat)$/]",
"message": "<ChannelBadge> channel must be one of 'max' | 'telegram' | 'webchat'."
},
{
"selector": "JSXOpeningElement[name.name='Checkbox'] > JSXAttribute > JSXIdentifier[name!=/^(?:checked|onChange|label|size|key|ref|className|style|children)$/]",
"message": "<Checkbox> doesn't accept that prop. Declared props: checked, onChange, label, size."
},
{
"selector": "JSXOpeningElement[name.name='Checkbox'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
"message": "<Checkbox> size must be one of 'sm' | 'md'."
},
{
"selector": "JSXOpeningElement[name.name='CommerceStatusTimeline'] > JSXAttribute > JSXIdentifier[name!=/^(?:steps|key|ref|className|style|children)$/]",
"message": "<CommerceStatusTimeline> doesn't accept that prop. Declared props: steps."
},
{
"selector": "JSXOpeningElement[name.name='ConversationInboxItem'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|preview|channel|lastActor|initials|color|time|waiting|active|onClick|key|ref|className|style|children)$/]",
"message": "<ConversationInboxItem> doesn't accept that prop. Declared props: name, preview, channel, lastActor, initials, color, time, waiting, active, onClick."
},
{
"selector": "JSXOpeningElement[name.name='ConversationInboxItem'] > JSXAttribute[name.name='channel'] > Literal[value!=/^(?:max|telegram|webchat)$/]",
"message": "<ConversationInboxItem> channel must be one of 'max' | 'telegram' | 'webchat'."
},
{
"selector": "JSXOpeningElement[name.name='ConversationInboxItem'] > JSXAttribute[name.name='lastActor'] > Literal[value!=/^(?:ai|human)$/]",
"message": "<ConversationInboxItem> lastActor must be one of 'ai' | 'human'."
},
{
"selector": "JSXOpeningElement[name.name='DepartmentCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|meta|level|summary|icon|openLabel|onOpen|children|key|ref|className|style|children)$/]",
"message": "<DepartmentCard> doesn't accept that prop. Declared props: name, meta, level, summary, icon, openLabel, onOpen, children."
},
{
"selector": "JSXOpeningElement[name.name='DepartmentCard'] > JSXAttribute[name.name='level'] > Literal[value!=/^(?:ok|attention|critical)$/]",
"message": "<DepartmentCard> level must be one of 'ok' | 'attention' | 'critical'."
},
{
"selector": "JSXOpeningElement[name.name='Icon'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|size|color|strokeWidth|key|ref|className|style|children)$/]",
"message": "<Icon> doesn't accept that prop. Declared props: name, size, color, strokeWidth."
},
{
"selector": "JSXOpeningElement[name.name='Icon'] > JSXAttribute[name.name='name'] > Literal[value!=/^(?:dashboard|departments|employees|products|ai|integrations|settings|search|chevronDown|chevronRight|moreVertical|check|checkCircle|alertTriangle|alertCircle|xCircle|clock|refresh|arrowRight|arrowLeft|mail|lock|eye|eyeOff|card|sbp|shield|download|columns|bell|trash|edit|merge|bolt|cart|history|team|package2)$/]",
"message": "<Icon> name must be one of 'dashboard' | 'departments' | 'employees' | 'products' | 'ai' | 'integrations' | 'settings' | 'search' | 'chevronDown' | 'chevronRight' | 'moreVertical' | 'check' | 'checkCircle' | 'alertTriangle' | 'alertCircle' | 'xCircle' | 'clock' | 'refresh' | 'arrowRight' | 'arrowLeft' | 'mail' | 'lock' | 'eye' | 'eyeOff' | 'card' | 'sbp' | 'shield' | 'download' | 'columns' | 'bell' | 'trash' | 'edit' | 'merge' | 'bolt' | 'cart' | 'history' | 'team' | 'package2'."
},
{
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|size|badge|bare|label|onClick|key|ref|className|style|children)$/]",
"message": "<IconButton> doesn't accept that prop. Declared props: children, size, badge, bare, label, onClick."
},
{
"selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|onChange|placeholder|label|required|type|disabled|error|hint|icon|suffix|fullWidth|key|ref|className|style|children)$/]",
"message": "<Input> doesn't accept that prop. Declared props: value, onChange, placeholder, label, required, type, disabled, error, hint, icon, suffix, fullWidth."
},
{
"selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute[name.name='type'] > Literal[value!=/^(?:text|email|password|tel)$/]",
"message": "<Input> type must be one of 'text' | 'email' | 'password' | 'tel'."
},
{
"selector": "JSXOpeningElement[name.name='IntegrationHealthCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|group|status|key|ref|className|style|children)$/]",
"message": "<IntegrationHealthCard> doesn't accept that prop. Declared props: name, group, status."
},
{
"selector": "JSXOpeningElement[name.name='IntegrationHealthCard'] > JSXAttribute[name.name='status'] > Literal[value!=/^(?:connected|degraded|error)$/]",
"message": "<IntegrationHealthCard> status must be one of 'connected' | 'degraded' | 'error'."
},
{
"selector": "JSXOpeningElement[name.name='KnowledgeDocumentEditor'] > JSXAttribute > JSXIdentifier[name!=/^(?:title|onTitleChange|docId|body|onBodyChange|rows|savedLabel|key|ref|className|style|children)$/]",
"message": "<KnowledgeDocumentEditor> doesn't accept that prop. Declared props: title, onTitleChange, docId, body, onBodyChange, rows, savedLabel."
},
{
"selector": "JSXOpeningElement[name.name='MetricCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|value|valueColor|dotColor|key|ref|className|style|children)$/]",
"message": "<MetricCard> doesn't accept that prop. Declared props: label, value, valueColor, dotColor."
},
{
"selector": "JSXOpeningElement[name.name='ProductAIReleaseCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:productName|productInitial|releaseId|live|iconBg|iconColor|stats|key|ref|className|style|children)$/]",
"message": "<ProductAIReleaseCard> doesn't accept that prop. Declared props: productName, productInitial, releaseId, live, iconBg, iconColor, stats."
},
{
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|tone|pill|dot|dotColor|key|ref|className|style|children)$/]",
"message": "<Tag> doesn't accept that prop. Declared props: children, tone, pill, dot, dotColor."
},
{
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|primary|success|warning|error|ai)$/]",
"message": "<Tag> tone must be one of 'neutral' | 'primary' | 'success' | 'warning' | 'error' | 'ai'."
}
]
},
"overrides": [
{
"files": [
"**/index.js"
],
"rules": {
"no-restricted-imports": "off"
}
}
],
"x-omelette": {
"components": {
"ActorBadge": {
"replaces": []
},
"AttentionStatus": {
"replaces": []
},
"Avatar": {
"replaces": []
},
"Button": {
"replaces": []
},
"ChannelBadge": {
"replaces": []
},
"Checkbox": {
"replaces": []
},
"CommerceStatusTimeline": {
"replaces": []
},
"ConversationInboxItem": {
"replaces": []
},
"DepartmentCard": {
"replaces": []
},
"Icon": {
"replaces": []
},
"IconButton": {
"replaces": []
},
"Input": {
"replaces": []
},
"IntegrationHealthCard": {
"replaces": []
},
"KnowledgeDocumentEditor": {
"replaces": []
},
"MetricCard": {
"replaces": []
},
"ProductAIReleaseCard": {
"replaces": []
},
"Tag": {
"replaces": []
}
},
"tokens": [
"--ai",
"--ai-bg",
"--ai-light",
"--ai-lighter",
"--border-default",
"--border-input",
"--border-strong",
"--channel-max",
"--channel-max-bg",
"--channel-telegram",
"--channel-telegram-bg",
"--channel-webchat",
"--channel-webchat-bg",
"--content-max",
"--duration-base",
"--duration-fast",
"--ease-standard",
"--error",
"--error-bg",
"--error-border",
"--error-text",
"--font-mono",
"--font-sans",
"--h-control",
"--h-control-2xl",
"--h-control-lg",
"--h-control-md",
"--h-control-sm",
"--h-control-xl",
"--h-topbar",
"--human",
"--info",
"--leading-loose",
"--leading-normal",
"--leading-relaxed",
"--leading-snug",
"--leading-tight",
"--n-1",
"--n-10",
"--n-11",
"--n-2",
"--n-3",
"--n-4",
"--n-5",
"--n-6",
"--n-7",
"--n-8",
"--n-9",
"--n-white",
"--primary",
"--primary-bg",
"--primary-border",
"--primary-hover",
"--primary-shadow",
"--product-firepage",
"--product-firepage-bg",
"--product-foxray",
"--product-foxray-bg",
"--radius-2xl",
"--radius-3xl",
"--radius-4xl",
"--radius-circle",
"--radius-lg",
"--radius-md",
"--radius-pill",
"--radius-sm",
"--radius-xl",
"--radius-xs",
"--shadow-lg",
"--shadow-md",
"--shadow-primary",
"--shadow-sm",
"--shadow-sticky",
"--shadow-xs",
"--sidebar-width",
"--space-1",
"--space-10",
"--space-11",
"--space-12",
"--space-13",
"--space-14",
"--space-15",
"--space-16",
"--space-17",
"--space-2",
"--space-3",
"--space-4",
"--space-5",
"--space-6",
"--space-7",
"--space-8",
"--space-9",
"--success",
"--success-bg",
"--success-bg-strong",
"--success-border",
"--success-text",
"--surface-card",
"--surface-chip",
"--surface-page",
"--surface-sunken",
"--text-2xl",
"--text-2xs",
"--text-2xs-tight",
"--text-base",
"--text-base-lg",
"--text-body",
"--text-disabled",
"--text-heading",
"--text-inverse",
"--text-lg",
"--text-link",
"--text-md",
"--text-secondary",
"--text-sm",
"--text-sm-lg",
"--text-sm-tight",
"--text-tertiary",
"--text-xl",
"--text-xs",
"--text-xs-tight",
"--tracking-tight",
"--tracking-tight-sm",
"--tracking-wide",
"--tracking-wider",
"--warning",
"--warning-bg",
"--warning-bg-strong",
"--warning-border",
"--warning-text",
"--weight-black",
"--weight-bold",
"--weight-medium",
"--weight-regular",
"--weight-semibold"
],
"tokenKinds": {
"--n-1": "color",
"--n-2": "color",
"--n-3": "color",
"--n-4": "color",
"--n-5": "color",
"--n-6": "color",
"--n-7": "color",
"--n-8": "color",
"--n-9": "color",
"--n-10": "color",
"--n-11": "color",
"--n-white": "color",
"--primary": "color",
"--primary-hover": "color",
"--primary-bg": "color",
"--primary-border": "color",
"--primary-shadow": "shadow",
"--success": "color",
"--success-text": "font",
"--success-bg": "color",
"--success-bg-strong": "color",
"--success-border": "color",
"--warning": "color",
"--warning-text": "font",
"--warning-bg": "color",
"--warning-bg-strong": "color",
"--warning-border": "color",
"--error": "color",
"--error-text": "font",
"--error-bg": "color",
"--error-border": "color",
"--info": "color",
"--ai": "color",
"--ai-light": "color",
"--ai-lighter": "color",
"--ai-bg": "color",
"--human": "color",
"--channel-max": "color",
"--channel-max-bg": "color",
"--channel-telegram": "color",
"--channel-telegram-bg": "color",
"--channel-webchat": "color",
"--channel-webchat-bg": "color",
"--product-firepage": "color",
"--product-firepage-bg": "color",
"--product-foxray": "color",
"--product-foxray-bg": "color",
"--surface-page": "color",
"--surface-card": "color",
"--surface-sunken": "color",
"--surface-chip": "color",
"--text-heading": "font",
"--text-body": "font",
"--text-secondary": "font",
"--text-tertiary": "font",
"--text-disabled": "font",
"--text-inverse": "font",
"--text-link": "font",
"--border-default": "color",
"--border-input": "color",
"--border-strong": "color",
"--font-sans": "font",
"--font-mono": "font",
"--text-2xl": "font",
"--text-xl": "font",
"--text-lg": "font",
"--text-md": "font",
"--text-base-lg": "font",
"--text-base": "font",
"--text-sm-lg": "font",
"--text-sm": "font",
"--text-sm-tight": "font",
"--text-xs": "font",
"--text-xs-tight": "font",
"--text-2xs": "font",
"--text-2xs-tight": "font",
"--weight-regular": "font",
"--weight-medium": "font",
"--weight-semibold": "font",
"--weight-bold": "font",
"--weight-black": "font",
"--tracking-tight": "font",
"--tracking-tight-sm": "font",
"--tracking-wide": "font",
"--tracking-wider": "font",
"--leading-tight": "font",
"--leading-snug": "font",
"--leading-normal": "font",
"--leading-relaxed": "font",
"--leading-loose": "font",
"--space-1": "spacing",
"--space-2": "spacing",
"--space-3": "spacing",
"--space-4": "spacing",
"--space-5": "spacing",
"--space-6": "spacing",
"--space-7": "spacing",
"--space-8": "spacing",
"--space-9": "spacing",
"--space-10": "spacing",
"--space-11": "spacing",
"--space-12": "spacing",
"--space-13": "spacing",
"--space-14": "spacing",
"--space-15": "spacing",
"--space-16": "spacing",
"--space-17": "spacing",
"--h-control-sm": "spacing",
"--h-control-md": "spacing",
"--h-control": "spacing",
"--h-control-lg": "spacing",
"--h-control-xl": "spacing",
"--h-control-2xl": "spacing",
"--h-topbar": "spacing",
"--sidebar-width": "spacing",
"--content-max": "spacing",
"--radius-xs": "radius",
"--radius-sm": "radius",
"--radius-md": "radius",
"--radius-lg": "radius",
"--radius-xl": "radius",
"--radius-2xl": "radius",
"--radius-3xl": "radius",
"--radius-4xl": "radius",
"--radius-pill": "radius",
"--radius-circle": "radius",
"--shadow-xs": "shadow",
"--shadow-sm": "shadow",
"--shadow-md": "shadow",
"--shadow-lg": "shadow",
"--shadow-sticky": "shadow",
"--shadow-primary": "shadow",
"--ease-standard": "other",
"--duration-fast": "other",
"--duration-base": "other"
},
"fontFamilies": []
}
}
@@ -1 +0,0 @@
{"namespace":"ChatballsDesignSystem_e4c9df","components":[{"name":"CommerceStatusTimeline","sourcePath":"components/cards/CommerceStatusTimeline.jsx"},{"name":"DepartmentCard","sourcePath":"components/cards/DepartmentCard.jsx"},{"name":"IntegrationHealthCard","sourcePath":"components/cards/IntegrationHealthCard.jsx"},{"name":"MetricCard","sourcePath":"components/cards/MetricCard.jsx"},{"name":"ProductAIReleaseCard","sourcePath":"components/cards/ProductAIReleaseCard.jsx"},{"name":"Avatar","sourcePath":"components/core/Avatar.jsx"},{"name":"Button","sourcePath":"components/core/Button.jsx"},{"name":"Checkbox","sourcePath":"components/core/Checkbox.jsx"},{"name":"Icon","sourcePath":"components/core/Icon.jsx"},{"name":"ICON_NAMES","sourcePath":"components/core/Icon.jsx"},{"name":"IconButton","sourcePath":"components/core/IconButton.jsx"},{"name":"Input","sourcePath":"components/core/Input.jsx"},{"name":"Tag","sourcePath":"components/core/Tag.jsx"},{"name":"ConversationInbox","sourcePath":"components/inbox/ConversationInbox.jsx"},{"name":"KnowledgeDocumentEditor","sourcePath":"components/inbox/KnowledgeDocumentEditor.jsx"},{"name":"ActorBadge","sourcePath":"components/status/ActorBadge.jsx"},{"name":"AttentionStatus","sourcePath":"components/status/AttentionStatus.jsx"},{"name":"ChannelBadge","sourcePath":"components/status/ChannelBadge.jsx"}],"startingPoints":[{"name":"CommerceStatusTimeline","path":"components/cards/CommerceStatusTimeline.jsx","previewPath":"components/cards/cards.card.html","kind":"component","section":"Cards","subtitle":"Payment vs delivery step timeline for checkout/orders","viewport":"700x160"},{"name":"DepartmentCard","path":"components/cards/DepartmentCard.jsx","previewPath":"components/cards/cards.card.html","kind":"component","section":"Cards","subtitle":"Command Center department summary card","viewport":"900x360"},{"name":"IntegrationHealthCard","path":"components/cards/IntegrationHealthCard.jsx","previewPath":"components/cards/cards.card.html","kind":"component","section":"Cards","subtitle":"Single integration row (connected/degraded/error)","viewport":"700x90"},{"name":"MetricCard","path":"components/cards/MetricCard.jsx","previewPath":"components/cards/cards.card.html","kind":"component","section":"Cards","subtitle":"Single KPI cell for metric grids","viewport":"700x120"},{"name":"ProductAIReleaseCard","path":"components/cards/ProductAIReleaseCard.jsx","previewPath":"components/cards/cards.card.html","kind":"component","section":"Cards","subtitle":"AI knowledge/prompt release snapshot for one product","viewport":"700x180"},{"name":"Button","path":"components/core/Button.jsx","previewPath":"components/core/core.card.html","kind":"component","section":"Core","subtitle":"Primary/secondary/ghost/danger button","viewport":"700x220"},{"name":"IconButton","path":"components/core/IconButton.jsx","previewPath":"components/core/core.card.html","kind":"component","section":"Core","subtitle":"Square icon-only button with optional badge","viewport":"700x150"},{"name":"Input","path":"components/core/Input.jsx","previewPath":"components/core/core.card.html","kind":"component","section":"Core","subtitle":"Text field with label, icon and error state","viewport":"700x260"},{"name":"Tag","path":"components/core/Tag.jsx","previewPath":"components/core/core.card.html","kind":"component","section":"Core","subtitle":"Status pill and rectangular label tag","viewport":"700x150"},{"name":"KnowledgeDocumentEditor","path":"components/inbox/KnowledgeDocumentEditor.jsx","previewPath":"components/inbox/inbox.card.html","kind":"component","section":"Inbox","subtitle":"Plain-text knowledge base document editor","viewport":"700x380"},{"name":"ActorBadge","path":"components/status/ActorBadge.jsx","previewPath":"components/status/status.card.html","kind":"component","section":"Status","subtitle":"AI vs human avatar for conversation timelines","viewport":"700x110"},{"name":"AttentionStatus","path":"components/status/AttentionStatus.jsx","previewPath":"components/status/status.card.html","kind":"component","section":"Status","subtitle":"Company/department health chip (ok/attention/critical)","viewport":"700x120"},{"name":"ChannelBadge","path":"components/status/ChannelBadge.jsx","previewPath":"components/status/status.card.html","kind":"component","section":"Status","subtitle":"MAX / Telegram / Web Chat channel chip","viewport":"700x100"}],"cards":[{"path":"guidelines/logo-mark.card.html","group":"Brand","viewport":"700x160","subtitle":"Only mark found in the codebase — a blue rounded square with a bar-chart glyph","name":"Logo Mark"},{"path":"guidelines/radii-shadows.card.html","group":"Brand","viewport":"700x180","subtitle":"Soft, low-opacity shadows only — no glassmorphism","name":"Radii & Shadows"},{"path":"ui_kits/checkout/index.html","group":"Checkout","viewport":"440x760"},{"path":"guidelines/colors-ai-channels.card.html","group":"Colors","viewport":"700x160","subtitle":"AI/human split, MAX/Telegram/Web CLine truncated
@@ -1,4 +0,0 @@
@import "./tokens/colors.css";
@import "./tokens/typography.css";
@import "./tokens/spacing.css";
@import "./tokens/effects.css";
@@ -1,88 +0,0 @@
/* Chatballs — color tokens
Source: packages/ui/src/theme.ts (Ant Design ThemeConfig), apps/internal-ui/src/styles/base.css,
and design/baseline/*.dc.html (approved HTML baseline). Ant Design's default light palette,
customized with a single accent (#1677ff) per ADR-HUB-0013. */
:root {
/* ---- base neutrals (Ant Design neutral scale as used in the baseline) ---- */
--n-1: #1f1f1f; /* page/heading titles */
--n-2: #262626; /* primary body text */
--n-3: #595959; /* secondary text */
--n-4: #8c8c8c; /* tertiary text / captions */
--n-5: #bfbfbf; /* disabled / placeholder / faint */
--n-6: #d9d9d9; /* strong border, control border */
--n-7: #e8e8e8; /* input border */
--n-8: #f0f0f0; /* hairline border */
--n-9: #f5f5f5; /* hover surface */
--n-10: #fafafa; /* table header surface */
--n-11: #f0f2f5; /* app/page background */
--n-white: #ffffff;
/* ---- primary (blue-6 / blue-7) ---- */
--primary: #1677ff;
--primary-hover: #0958d9;
--primary-bg: #e6f4ff;
--primary-border: #91caff;
--primary-shadow: rgba(22, 119, 255, 0.3);
/* ---- semantic: success / warning / error / info ---- */
--success: #52c41a;
--success-text: #389e0d;
--success-bg: #f6ffed;
--success-bg-strong: #f1f9ec;
--success-border: #b7eb8f;
--warning: #faad14;
--warning-text: #d48806;
--warning-bg: #fffbe6;
--warning-bg-strong: #fff7e6;
--warning-border: #ffe58f;
--error: #ff4d4f;
--error-text: #cf1322;
--error-bg: #fff2f0;
--error-border: #ffccc7;
--info: #1677ff;
/* ---- role accents: AI vs human ---- */
--ai: #722ed1;
--ai-light: #9254de;
--ai-lighter: #b37feb;
--ai-bg: #f9f0ff;
--human: #1677ff;
/* ---- channel accents (MAX / Telegram / Web Chat) ---- */
--channel-max: #6b5be0;
--channel-max-bg: #f2f0ff;
--channel-telegram: #2f8fd0;
--channel-telegram-bg: #eaf6fd;
--channel-webchat: #0f9b8e;
--channel-webchat-bg: #e8f7f4;
/* ---- product accents (as tagged in tables/lists) ---- */
--product-firepage: #0958d9;
--product-firepage-bg: #e6f4ff;
--product-foxray: #722ed1;
--product-foxray-bg: #f9f0ff;
/* ---- surfaces ---- */
--surface-page: var(--n-11);
--surface-card: var(--n-white);
--surface-sunken: #fafafa;
--surface-chip: #f7f8fa;
/* ---- text aliases ---- */
--text-heading: var(--n-1);
--text-body: var(--n-2);
--text-secondary: var(--n-3);
--text-tertiary: var(--n-4);
--text-disabled: var(--n-5);
--text-inverse: var(--n-white);
--text-link: var(--primary);
/* ---- borders ---- */
--border-default: var(--n-8);
--border-input: var(--n-7);
--border-strong: var(--n-6);
}
@@ -1,40 +0,0 @@
/* Chatballs — radii, shadows, motion. "Премиальный минимализм": no gradients,
no glassmorphism/blur, no neon — shadows stay soft and near-black at low opacity. */
:root {
--radius-xs: 5px;
--radius-sm: 7px;
--radius-md: 8px;
--radius-lg: 9px;
--radius-xl: 10px;
--radius-2xl: 12px;
--radius-3xl: 13px;
--radius-4xl: 14px;
--radius-pill: 999px;
--radius-circle: 50%;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.03);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
--shadow-md: 0 2px 12px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 6px 20px rgba(0, 0, 0, 0.12);
--shadow-sticky: 0 -4px 16px rgba(0, 0, 0, 0.04);
--shadow-primary: 0 1px 2px rgba(22, 119, 255, 0.3);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1); /* @kind other */
--duration-fast: 0.15s; /* @kind other */
--duration-base: 0.2s; /* @kind other */
}
@keyframes hub-typing {
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-2px); }
}
@keyframes hub-spin {
to { transform: rotate(360deg); }
}
@keyframes hub-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@@ -1,36 +0,0 @@
/* Chatballs — spacing tokens. Compact-enterprise density (ADR-HUB-0013):
"большие пустые поля и oversized-карточки запрещены". Values are the exact
paddings/gaps observed in the baseline, not snapped to a rounder grid. */
:root {
--space-1: 2px;
--space-2: 4px;
--space-3: 6px;
--space-4: 8px;
--space-5: 9px;
--space-6: 10px;
--space-7: 11px;
--space-8: 12px;
--space-9: 14px;
--space-10: 16px;
--space-11: 18px;
--space-12: 20px;
--space-13: 22px;
--space-14: 24px;
--space-15: 28px;
--space-16: 32px;
--space-17: 36px;
/* control heights */
--h-control-sm: 30px;
--h-control-md: 34px;
--h-control: 36px;
--h-control-lg: 42px;
--h-control-xl: 44px;
--h-control-2xl: 48px;
--h-topbar: 56px;
/* layout */
--sidebar-width: 248px;
--content-max: 1340px;
}
@@ -1,42 +0,0 @@
/* Chatballs — typography tokens
The internal Hub uses the Ant Design default system font stack — no bespoke webfont
is loaded (see ADR-HUB-0013: "используется системный font stack Ant Design без
обязательной внешней зависимости от загружаемого шрифта"). Technical identifiers
(order/release/event IDs, emails) use a monospace stack. */
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
/* type scale, taken verbatim from the baseline (px, not snapped to a 4/8 grid) */
--text-2xl: 26px; /* page hero title (Command Center) */
--text-xl: 24px; /* page title */
--text-lg: 20px; /* auth title */
--text-md: 18px; /* card title */
--text-base-lg: 16px;
--text-base: 14.5px; /* buttons, primary body */
--text-sm-lg: 13.5px;
--text-sm: 13px; /* table body */
--text-sm-tight: 12.5px;
--text-xs: 12px; /* meta */
--text-xs-tight: 11.5px;
--text-2xs: 11px; /* table header / eyebrow */
--text-2xs-tight: 10.5px;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
--weight-black: 800;
--tracking-tight: -0.02em; /* large headings */
--tracking-tight-sm: -0.01em;
--tracking-wide: 0.03em; /* table header eyebrows */
--tracking-wider: 0.06em; /* nav section labels */
--leading-tight: 1.15;
--leading-snug: 1.2;
--leading-normal: 1.4;
--leading-relaxed: 1.5;
--leading-loose: 1.55;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,352 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="_ds/design-system/tokens/colors.css">
<link rel="stylesheet" href="_ds/design-system/tokens/typography.css">
<link rel="stylesheet" href="_ds/design-system/tokens/spacing.css">
<link rel="stylesheet" href="_ds/design-system/tokens/effects.css">
<link rel="stylesheet" href="_ds/design-system/styles.css">
<script src="_ds/design-system/_ds_bundle.js"></script>
<style>
body { margin: 0; background: #0b0b12; }
@keyframes edv-ring2 { 0% { transform: scale(1); opacity: .5; } 100% { transform: scale(1.9); opacity: 0; } }
@keyframes edv-drift { 0% { transform: translate(0,0) scale(1); } 50% { transform: translate(4%,-3%) scale(1.08); } 100% { transform: translate(0,0) scale(1); } }
@keyframes edv-drift2 { 0% { transform: translate(0,0) scale(1.05); } 50% { transform: translate(-5%,4%) scale(1); } 100% { transform: translate(0,0) scale(1.05); } }
@keyframes edv-spin { to { transform: rotate(360deg); } }
</style>
</helmet>
<div style="min-height:100vh; box-sizing:border-box; display:flex; flex-direction:column; align-items:center; gap:18px; padding:28px 20px 48px; font-family:var(--font-sans);">
<div style="display:flex; flex-wrap:wrap; gap:6px; background:#16161f; border:1px solid rgba(255,255,255,.08); padding:5px; border-radius:var(--radius-lg);">
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="4">
<button style="{{ t.style }}" onClick="{{ t.onClick }}">{{ t.label }}</button>
</sc-for>
</div>
<div style="position:relative; width:100%; max-width:400px; aspect-ratio:9/16; max-height:760px; border-radius:var(--radius-4xl); overflow:hidden; box-shadow:0 24px 70px rgba(0,0,0,.55); background:#0d0d16;">
<div style="position:absolute; inset:0; z-index:0; overflow:hidden;">
<div style="position:absolute; inset:0; background:linear-gradient(150deg,#0958d9 0%,#722ed1 52%,#c41d7f 100%); opacity:{{ bgOpacity }}; transition:opacity .6s;"></div>
<div style="position:absolute; width:75%; height:55%; left:-12%; top:-10%; border-radius:50%; background:radial-gradient(circle,#597ef7,transparent 68%); filter:blur(18px); opacity:.75; animation:edv-drift 9s ease-in-out infinite;"></div>
<div style="position:absolute; width:70%; height:52%; right:-14%; bottom:-8%; border-radius:50%; background:radial-gradient(circle,#c41d7f,transparent 68%); filter:blur(20px); opacity:.6; animation:edv-drift2 11s ease-in-out infinite;"></div>
<div style="position:absolute; inset:0; background:linear-gradient(180deg,rgba(11,11,18,.15) 0%,rgba(11,11,18,.05) 45%,rgba(11,11,18,.55) 100%);"></div>
</div>
<div style="position:relative; z-index:2; height:100%; box-sizing:border-box; display:flex; flex-direction:column; padding:26px 24px 30px; color:#fff;">
<div style="display:flex; align-items:center; gap:9px;">
<span style="font-size:13px; font-weight:600; letter-spacing:.02em; color:rgba(255,255,255,.9);">{{ statusLabel }}</span>
<sc-if value="{{ showChannel }}" hint-placeholder-val="{{ true }}">
<span style="display:inline-flex; align-items:center; gap:5px; padding:3px 9px; border-radius:var(--radius-pill); background:rgba(255,255,255,.16); backdrop-filter:blur(6px); font-size:11.5px; font-weight:500; color:#fff;">{{ channelLabel }}</span>
</sc-if>
<div style="flex:1;"></div>
<sc-if value="{{ isActive }}" hint-placeholder-val="{{ false }}">
<span style="font-size:14px; font-weight:600; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:#fff; letter-spacing:.04em;">{{ timer }}</span>
</sc-if>
</div>
<div style="flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:22px;">
<sc-if value="{{ showAvatarCenter }}" hint-placeholder-val="{{ true }}">
<div style="position:relative; display:flex; align-items:center; justify-content:center;">
<sc-if value="{{ ringsVisible }}" hint-placeholder-val="{{ true }}">
<span style="position:absolute; width:126px; height:126px; border-radius:50%; border:2px solid rgba(255,255,255,.45); animation:edv-ring2 2s ease-out infinite;"></span>
<span style="position:absolute; width:126px; height:126px; border-radius:50%; border:2px solid rgba(255,255,255,.45); animation:edv-ring2 2s ease-out infinite; animation-delay:1s;"></span>
</sc-if>
<div style="width:126px; height:126px; border-radius:50%; background:rgba(255,255,255,.14); backdrop-filter:blur(10px); border:1px solid rgba(255,255,255,.28); display:flex; align-items:center; justify-content:center; font-size:42px; font-weight:600; color:#fff; box-shadow:0 8px 30px rgba(0,0,0,.3);">{{ initials }}</div>
</div>
<div style="text-align:center;">
<div style="font-size:25px; font-weight:600; letter-spacing:-0.01em; color:#fff;">{{ peerName }}</div>
<div style="font-size:14px; color:rgba(255,255,255,.72); margin-top:6px;">{{ subCaption }}</div>
</div>
<div style="height:56px; width:100%; max-width:260px; display:flex; align-items:center; justify-content:center;">
<sc-if value="{{ showWave }}" hint-placeholder-val="{{ false }}">
<canvas ref="{{ waveRef }}" width="520" height="112" style="width:100%; height:56px;"></canvas>
</sc-if>
<sc-if value="{{ showEndSummary }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; align-items:center; gap:8px; padding:8px 16px; border-radius:var(--radius-pill); background:rgba(255,255,255,.12); font-size:13.5px; color:#fff;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
Длительность {{ finalDuration }}
</div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ showStatusCenter }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; gap:18px; text-align:center;">
<div style="{{ statusIconTile }}">{{ statusIcon }}</div>
<div>
<div style="font-size:23px; font-weight:600; letter-spacing:-0.01em; color:#fff; line-height:1.25;">{{ statusTitle }}</div>
<div style="font-size:14px; color:rgba(255,255,255,.72); margin-top:8px; line-height:1.5; max-width:280px;">{{ statusCaption }}</div>
</div>
</div>
</sc-if>
</div>
<sc-if value="{{ barIncoming }}" hint-placeholder-val="{{ true }}">
<div style="display:flex; justify-content:center; gap:64px;">
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ decline }}" aria-label="Отклонить" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(255,77,79,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:rgba(255,255,255,.75);">Отклонить</span>
</div>
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ accept }}" aria-label="Принять" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--success); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(82,196,26,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:rgba(255,255,255,.75);">Принять</span>
</div>
</div>
</sc-if>
<sc-if value="{{ barActive }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; gap:18px;">
<div style="display:flex; justify-content:center; gap:40px;">
<div style="display:flex; flex-direction:column; align-items:center; gap:8px;">
<button onClick="{{ toggleMic }}" style="{{ micStyle }}" aria-label="Микрофон">{{ micIcon }}</button>
<span style="font-size:11.5px; color:rgba(255,255,255,.72);">Микрофон</span>
</div>
<div style="display:flex; flex-direction:column; align-items:center; gap:8px;">
<button onClick="{{ toggleSpk }}" style="{{ spkStyle }}" aria-label="Динамик">{{ spkIcon }}</button>
<span style="font-size:11.5px; color:rgba(255,255,255,.72);">Динамик</span>
</div>
</div>
<div style="display:flex; justify-content:center;">
<button onClick="{{ end }}" aria-label="Завершить" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(255,77,79,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
</div>
</div>
</sc-if>
<sc-if value="{{ barRinging }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ end }}" aria-label="Отменить" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(255,77,79,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:rgba(255,255,255,.75);">Отменить</span>
</div>
</sc-if>
<sc-if value="{{ barEnded }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; gap:12px;">
<button onClick="{{ toIncoming }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:1px solid rgba(255,255,255,.3); background:rgba(255,255,255,.08); color:#fff; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Закрыть</button>
<button onClick="{{ callAgain }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:none; background:#fff; color:#141414; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Позвонить снова</button>
</div>
</sc-if>
<sc-if value="{{ barRetrySingle }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; justify-content:center;">
<button onClick="{{ callAgain }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:none; background:#fff; color:#141414; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Позвонить снова</button>
</div>
</sc-if>
<sc-if value="{{ barRetryClose }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; gap:12px;">
<button onClick="{{ toIncoming }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:1px solid rgba(255,255,255,.3); background:rgba(255,255,255,.08); color:#fff; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Закрыть</button>
<button onClick="{{ retry }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:none; background:#fff; color:#141414; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Повторить</button>
</div>
</sc-if>
<sc-if value="{{ barReconnect }}" hint-placeholder-val="{{ false }}">
<div style="position:relative;">
<div style="position:absolute; bottom:calc(100% + 14px); left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:9px; padding:9px 16px; border-radius:var(--radius-pill); background:rgba(255,255,255,.12); backdrop-filter:blur(8px); border:1px solid rgba(255,255,255,.22); font-size:13px; font-weight:500; color:#fff; white-space:nowrap;">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="animation:edv-spin .9s linear infinite;"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg>
Восстанавливаем связь…
</div>
<div style="display:flex; justify-content:center;">
<button onClick="{{ toIncoming }}" aria-label="Завершить" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(255,77,79,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
</div>
</div>
</sc-if>
<sc-if value="{{ barRetryCheck }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; gap:12px;">
<button onClick="{{ toIncoming }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:1px solid rgba(255,255,255,.3); background:rgba(255,255,255,.08); color:#fff; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Закрыть</button>
<button onClick="{{ retry }}" style="flex:1; height:48px; border-radius:var(--radius-lg); border:none; background:#fff; color:#141414; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;">Повторить проверку</button>
</div>
</sc-if>
<sc-if value="{{ barConnecting }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ toIncoming }}" aria-label="Отменить" style="width:64px; height:64px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 6px 20px rgba(255,77,79,.45);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:rgba(255,255,255,.75);">Отменить</span>
</div>
</sc-if>
</div>
</div>
<div style="font-size:12.5px; color:var(--text-tertiary); text-align:center; max-width:380px; line-height:1.5;">Полноэкранный аудиозвонок · тёмная тема, живая осциллограмма собеседника. Переключайте состояния выше.</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:440,&quot;height&quot;:900},&quot;clientName&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;Анна Ковалёва&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;},&quot;channel&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;default&quot;:&quot;webchat&quot;,&quot;options&quot;:[&quot;webchat&quot;,&quot;telegram&quot;,&quot;max&quot;],&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;}}">
class Component extends DCLogic {
state = { stateName: 'incoming', micOn: true, spkOn: true, seconds: 0, endedAt: 0 };
componentDidMount() {
this._waveRef = React.createRef();
this._t = setInterval(() => {
if (this.state.stateName === 'active') this.setState(s => ({ seconds: s.seconds + 1 }));
}, 1000);
this._raf = requestAnimationFrame(this._draw);
}
componentWillUnmount() { clearInterval(this._t); cancelAnimationFrame(this._raf); }
_draw = (ts) => {
this._raf = requestAnimationFrame(this._draw);
const cv = this._waveRef && this._waveRef.current;
if (!cv) return;
const ctx = cv.getContext('2d');
const W = cv.width, H = cv.height, mid = H / 2;
ctx.clearRect(0, 0, W, H);
const active = this.state.stateName === 'active';
const speaking = active && this.state.spkOn;
const t = ts / 1000;
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(255,255,255,0.92)';
ctx.beginPath();
for (let x = 0; x <= W; x += 4) {
const p = x / W;
const env = Math.sin(p * Math.PI); // taper ends
const base = speaking ? 0.5 : 0.12;
const amp = env * H * 0.36 * base * (0.6 + 0.4 * Math.sin(t * 2.1 + p * 3));
const y = mid + Math.sin(p * 22 + t * 6) * amp + Math.sin(p * 9 - t * 3.3) * amp * 0.5;
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
};
goto(st) {
this.setState(s => {
const next = { stateName: st };
if (st === 'active') next.seconds = 0;
if (st === 'ended' || st === 'declined' || st === 'missed' || st === 'expired' || st === 'failed') next.endedAt = s.seconds || 184;
return next;
});
}
statusIcon(name) {
const h = React.createElement;
const svg = (children, extra) => h('svg', Object.assign({ viewBox: '0 0 24 24', width: 34, height: 34, fill: 'none', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round' }, extra || {}), children);
switch (name) {
case 'spinner': return svg([h('path', { key: 1, d: 'M21 12a9 9 0 1 1-6.219-8.56' })], { strokeWidth: 2, style: { animation: 'edv-spin .9s linear infinite' } });
case 'declined': return svg([h('path', { key: 1, transform: 'rotate(135 12 12)', d: 'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z' })]);
case 'missed': return svg([h('path', { key: 1, d: 'M23 7l-8 8-4-4-9 9' }), h('polyline', { key: 2, points: '17 7 23 7 23 13' })]);
case 'clock': return svg([h('circle', { key: 1, cx: 12, cy: 12, r: 9 }), h('path', { key: 2, d: 'M12 7v5l3 2' })]);
case 'alert': return svg([h('path', { key: 1, d: 'M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }), h('line', { key: 2, x1: 12, y1: 9, x2: 12, y2: 13 }), h('line', { key: 3, x1: 12, y1: 17, x2: 12.01, y2: 17 })]);
case 'nodevice': return svg([h('line', { key: 1, x1: 2, y1: 2, x2: 22, y2: 22 }), h('path', { key: 2, d: 'M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2' }), h('path', { key: 3, d: 'M10 5h4a2 2 0 0 1 2 2v3l4-3v9' })]);
case 'unsupported': return svg([h('rect', { key: 1, x: 2, y: 3, width: 20, height: 14, rx: 2 }), h('line', { key: 2, x1: 8, y1: 21, x2: 16, y2: 21 }), h('line', { key: 3, x1: 12, y1: 17, x2: 12, y2: 21 }), h('line', { key: 4, x1: 9, y1: 8, x2: 15, y2: 14 }), h('line', { key: 5, x1: 15, y1: 8, x2: 9, y2: 14 })]);
default: return null;
}
}
fmt(sec) { return String(Math.floor(sec / 60)).padStart(2, '0') + ':' + String(sec % 60).padStart(2, '0'); }
micIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 23, height: 23, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.micOn
? svg([h('rect', { key: 1, x: 9, y: 2, width: 6, height: 12, rx: 3 }), h('path', { key: 2, d: 'M19 10v1a7 7 0 0 1-14 0v-1' }), h('line', { key: 3, x1: 12, y1: 18, x2: 12, y2: 22 })])
: svg([h('line', { key: 1, x1: 3, y1: 3, x2: 21, y2: 21 }), h('path', { key: 2, d: 'M9 9v2a3 3 0 0 0 5.1 2.1' }), h('path', { key: 3, d: 'M15 9.3V5a3 3 0 0 0-5.9-.7' }), h('path', { key: 4, d: 'M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1' }), h('line', { key: 5, x1: 12, y1: 18, x2: 12, y2: 22 })]);
}
spkIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 23, height: 23, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.spkOn
? svg([h('polygon', { key: 1, points: '11 5 6 9 2 9 2 15 6 15 11 19 11 5' }), h('path', { key: 2, d: 'M15.5 8.5a5 5 0 0 1 0 7' }), h('path', { key: 3, d: 'M19 5a9 9 0 0 1 0 14' })])
: svg([h('polygon', { key: 1, points: '11 5 6 9 2 9 2 15 6 15 11 19 11 5' }), h('line', { key: 2, x1: 22, y1: 9, x2: 16, y2: 15 }), h('line', { key: 3, x1: 16, y1: 9, x2: 22, y2: 15 })]);
}
renderVals() {
const s = this.state;
const st = s.stateName;
const clientName = this.props.clientName ?? 'Анна Ковалёва';
const channel = this.props.channel ?? 'webchat';
const initials = (clientName.trim()[0] || 'К').toUpperCase() + ((clientName.split(' ')[1] || '')[0] || '').toUpperCase();
const channelLabel = channel === 'webchat' ? 'Веб-виджет' : channel === 'telegram' ? 'Telegram' : 'MAX';
const isIncoming = st === 'incoming', isRinging = st === 'ringing', isActive = st === 'active', isEnded = st === 'ended', isConnecting = st === 'connecting';
const isReconnecting = st === 'reconnecting', isDeclined = st === 'declined', isMissed = st === 'missed', isExpired = st === 'expired', isFailed = st === 'failed', isNoDevice = st === 'nodevice', isUnsupported = st === 'unsupported';
const statusLabel = isIncoming ? 'Входящий звонок' : isRinging ? 'Исходящий звонок' : isActive ? 'Аудиозвонок' : isConnecting ? 'Соединение' : isReconnecting ? 'Переподключение' : isDeclined ? 'Отклонён' : isMissed ? 'Пропущенный' : isExpired ? 'Истёк' : isFailed ? 'Ошибка' : isNoDevice ? 'Нет микрофона' : isUnsupported ? 'Не поддерживается' : 'Звонок завершён';
const subCaption = isIncoming ? 'Аудиозвонок · ' + channelLabel : isRinging ? 'Вызываем…' : isActive ? (s.micOn ? 'Говорите' : 'Ваш микрофон выключен') : isEnded ? 'Звонок завершён' : '';
// Status-center для процессов/ошибок/терминалов (вместо аватара/волны).
const msg = {
connecting: { icon: 'spinner', title: 'Соединяем', caption: 'Устанавливаем защищённое соединение…' },
reconnecting: { icon: 'spinner', title: 'Связь прервана', caption: 'Восстанавливаем соединение…' },
declined: { icon: 'declined', title: 'Звонок отклонён', caption: clientName + ' отклонил(а) вызов.' },
missed: { icon: 'missed', title: 'Пропущенный звонок', caption: clientName + ' не ответил(а) на вызов.' },
expired: { icon: 'clock', title: 'Время ожидания истекло', caption: 'Никто не ответил вовремя. Попробуйте позвонить снова.' },
failed: { icon: 'alert', title: 'Не удалось соединиться', caption: 'Проверьте интернет-соединение и попробуйте снова.' },
nodevice: { icon: 'nodevice', title: 'Нет доступа к микрофону', caption: 'Разрешите доступ к микрофону в настройках браузера и повторите.' },
unsupported: { icon: 'unsupported', title: 'Аудиозвонки не поддерживаются', caption: 'Обновите браузер или откройте ссылку в Chrome, Safari или Edge.' },
};
const cur = msg[st];
const showStatusCenter = !!cur;
const showAvatarCenter = !showStatusCenter;
const toneColor = { spinner: 'rgba(255,255,255,.16)', declined: 'rgba(255,255,255,.12)', missed: 'rgba(250,173,20,.18)', clock: 'rgba(250,173,20,.18)', alert: 'rgba(255,77,79,.2)', nodevice: 'rgba(255,77,79,.2)', unsupported: 'rgba(255,77,79,.2)' };
const toneFg = { spinner: '#fff', declined: '#fff', missed: '#ffc53d', clock: '#ffc53d', alert: '#ff7875', nodevice: '#ff7875', unsupported: '#ff7875' };
const tc = cur ? (toneColor[cur.icon] || 'rgba(255,255,255,.16)') : 'rgba(255,255,255,.16)';
const tfg = cur ? (toneFg[cur.icon] || '#fff') : '#fff';
const statusIconTile = { width: 92, height: 92, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: tc, color: tfg, border: '1px solid rgba(255,255,255,.22)', backdropFilter: 'blur(10px)', boxShadow: '0 8px 30px rgba(0,0,0,.3)' };
const round = (on, accent) => ({ width: 60, height: 60, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flex: 'none', transition: 'all .15s', border: '1px solid rgba(255,255,255,.28)', background: on ? 'rgba(255,255,255,.16)' : '#fff', color: on ? '#fff' : '#141414', backdropFilter: 'blur(8px)' });
const tabBase = { padding: '7px 14px', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', fontSize: 13, fontFamily: 'var(--font-sans)', whiteSpace: 'nowrap' };
const tabAct = { ...tabBase, fontWeight: 600, background: 'rgba(255,255,255,.14)', color: '#fff' };
const tabIdle = { ...tabBase, fontWeight: 500, background: 'transparent', color: 'rgba(255,255,255,.55)' };
const tabs = [['incoming', 'Входящий'], ['ringing', 'Дозвон'], ['connecting', 'Соединение'], ['active', 'Активный'], ['reconnecting', 'Связь'], ['declined', 'Отклонён'], ['missed', 'Пропущен'], ['expired', 'Истёк'], ['failed', 'Ошибка'], ['nodevice', 'Нет микрофона'], ['unsupported', 'Не поддерж.'], ['ended', 'Завершение']].map(([id, label]) => ({ label, style: st === id ? tabAct : tabIdle, onClick: () => this.goto(id) }));
// Длительность показываем для состояний, где звонок реально состоялся.
const dur = (st === 'ended' || st === 'failed') && s.endedAt ? ('Длительность ' + this.fmt(s.endedAt) + '. ') : '';
const statusCaption = cur ? (dur + cur.caption) : '';
return {
tabs, initials, peerName: clientName, channelLabel, showChannel: !isEnded,
statusLabel, subCaption,
isActive, ringsVisible: isIncoming || isRinging,
timer: this.fmt(s.seconds),
showWave: isActive, showEndSummary: isEnded,
showAvatarCenter, showStatusCenter,
statusIcon: cur ? this.statusIcon(cur.icon) : null, statusIconTile,
statusTitle: cur ? cur.title : '', statusCaption,
finalDuration: this.fmt(s.endedAt),
waveRef: this._waveRef,
micIcon: this.micIcon(), spkIcon: this.spkIcon(),
micStyle: round(s.micOn), spkStyle: round(s.spkOn),
bgOpacity: isEnded ? 0.55 : isFailed ? 0.5 : isReconnecting ? 0.5 : isActive ? 0.85 : 1,
barIncoming: isIncoming, barRinging: isRinging, barActive: isActive, barEnded: isEnded, barConnecting: isConnecting,
// Перезвонить (без «Закрыть») — пропущенный/истёк.
barRetrySingle: isMissed || isExpired,
// Повторить + Закрыть — ошибка соединения.
barRetryClose: isFailed,
// Переподключение: нижняя кнопка завершения + плавающий статус сверху.
barReconnect: isReconnecting,
// Повторить проверку + Закрыть — нет микрофона / не поддерживается.
barRetryCheck: isNoDevice || isUnsupported,
accept: () => this.goto('active'), decline: () => this.goto('incoming'),
end: () => this.goto('ended'), toIncoming: () => this.goto('incoming'), callAgain: () => this.goto('ringing'), retry: () => this.goto('reconnecting'),
toggleMic: () => this.setState(x => ({ micOn: !x.micOn })),
toggleSpk: () => this.setState(x => ({ spkOn: !x.spkOn })),
};
}
}
</script>
</body>
</html>
@@ -1,135 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="_ds/design-system/tokens/colors.css">
<link rel="stylesheet" href="_ds/design-system/tokens/typography.css">
<link rel="stylesheet" href="_ds/design-system/tokens/spacing.css">
<link rel="stylesheet" href="_ds/design-system/tokens/effects.css">
<link rel="stylesheet" href="_ds/design-system/styles.css">
<script src="_ds/design-system/_ds_bundle.js"></script>
<style>
body { margin: 0; background: var(--surface-page); }
a { color: var(--primary); text-decoration: none; }
a:hover { color: var(--primary-hover); }
@keyframes edv-ring3 { 0% { transform: scale(1); opacity: .45; } 100% { transform: scale(1.7); opacity: 0; } }
</style>
</helmet>
<div style="min-height:100vh; box-sizing:border-box; display:flex; flex-direction:column; font-family:var(--font-sans); color:var(--text-body); background:var(--surface-page);">
<header style="display:flex; align-items:center; gap:10px; padding:18px 28px; border-bottom:1px solid var(--border-default);">
<div style="width:30px; height:30px; border-radius:8px; background:var(--primary); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:16px;">E</div>
<span style="font-size:15px; font-weight:600; color:var(--text-heading);">Chatballs</span>
<div style="flex:1;"></div>
<span style="font-size:12.5px; color:var(--text-tertiary);">Защищённое соединение</span>
</header>
<div style="flex:1; display:flex; align-items:center; justify-content:center; padding:40px 24px;">
<div style="width:100%; max-width:460px; background:var(--surface-card); border:1px solid var(--border-default); border-radius:var(--radius-4xl); box-shadow:var(--shadow-lg); overflow:hidden;">
<div style="position:relative; padding:40px 32px 30px; text-align:center; background:linear-gradient(155deg,#0958d9 0%,#722ed1 60%,#c41d7f 100%); color:#fff; overflow:hidden;">
<div style="position:absolute; width:70%; height:90%; left:-10%; top:-20%; border-radius:50%; background:radial-gradient(circle,rgba(89,126,247,.6),transparent 65%); filter:blur(16px);"></div>
<div style="position:relative; display:inline-flex; align-items:center; justify-content:center; margin-bottom:18px;">
<span style="position:absolute; width:96px; height:96px; border-radius:50%; border:2px solid rgba(255,255,255,.5); animation:edv-ring3 2.1s ease-out infinite;"></span>
<span style="position:absolute; width:96px; height:96px; border-radius:50%; border:2px solid rgba(255,255,255,.5); animation:edv-ring3 2.1s ease-out infinite; animation-delay:1.05s;"></span>
<div style="width:96px; height:96px; border-radius:50%; background:rgba(255,255,255,.16); backdrop-filter:blur(8px); border:1px solid rgba(255,255,255,.3); display:flex; align-items:center; justify-content:center; font-size:34px; font-weight:600;">{{ initials }}</div>
</div>
<div style="position:relative; font-size:22px; font-weight:600; letter-spacing:-0.01em;">{{ orgName }}</div>
<div style="position:relative; font-size:14px; color:rgba(255,255,255,.78); margin-top:6px;">приглашает вас на видеозвонок</div>
</div>
<div style="padding:26px 32px 32px;">
<div style="display:flex; align-items:center; gap:12px; padding:13px 15px; border:1px solid var(--border-default); border-radius:var(--radius-xl); background:var(--surface-subtle); margin-bottom:22px;">
<div style="width:40px; height:40px; border-radius:50%; background:#1677ff; color:#fff; display:flex; align-items:center; justify-content:center; font-size:15px; font-weight:600; flex:none;">{{ agentInitials }}</div>
<div style="min-width:0;">
<div style="font-size:14px; font-weight:600; color:var(--text-heading);">{{ agentName }}</div>
<div style="font-size:12.5px; color:var(--text-secondary);">{{ agentRole }} · {{ orgName }}</div>
</div>
<div style="flex:1;"></div>
<span style="display:inline-flex; align-items:center; gap:6px; padding:5px 10px; border-radius:var(--radius-pill); background:var(--success-bg); color:var(--success-text); font-size:12px; font-weight:600;"><span style="width:6px; height:6px; border-radius:50%; background:var(--success);"></span>Онлайн</span>
</div>
<div style="text-align:center; margin-bottom:16px;">
<div style="font-size:15px; font-weight:600; color:var(--text-heading);">Готовы присоединиться?</div>
<div style="font-size:13px; color:var(--text-secondary); margin-top:5px; line-height:1.5;">Проверьте камеру и микрофон перед входом. Мы не записываем видео без предупреждения.</div>
</div>
<div style="display:flex; justify-content:center; gap:14px; margin-bottom:22px;">
<div style="display:flex; flex-direction:column; align-items:center; gap:7px;">
<button onClick="{{ toggleMic }}" style="{{ micStyle }}" aria-label="Микрофон">{{ micIcon }}</button>
<span style="font-size:11.5px; color:var(--text-tertiary);">Микрофон</span>
</div>
<div style="display:flex; flex-direction:column; align-items:center; gap:7px;">
<button onClick="{{ toggleCam }}" style="{{ camStyle }}" aria-label="Камера">{{ camIcon }}</button>
<span style="font-size:11.5px; color:var(--text-tertiary);">Камера</span>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:11px;">
<input value="{{ nameValue }}" onChange="{{ onName }}" placeholder="Ваше имя" style="height:46px; padding:0 15px; border:1px solid var(--border-strong); border-radius:var(--radius-lg); font-size:14px; font-family:var(--font-sans); color:var(--text-body); background:var(--surface-card); box-sizing:border-box; outline:none;" style-focus="border-color:var(--primary);" />
<button onClick="{{ join }}" style="height:50px; border-radius:var(--radius-lg); border:none; background:var(--primary); color:#fff; font-size:15px; font-weight:600; font-family:var(--font-sans); cursor:pointer; box-shadow:var(--shadow-primary);" style-hover="background:var(--primary-hover);">Присоединиться к звонку</button>
</div>
<div style="display:flex; align-items:center; justify-content:center; gap:6px; margin-top:18px; font-size:12px; color:var(--text-tertiary);">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
Звонок защищён сквозным шифрованием
</div>
</div>
</div>
</div>
<footer style="text-align:center; padding:18px; font-size:12px; color:var(--text-tertiary);">Работает на платформе Chatballs</footer>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:900,&quot;height&quot;:820},&quot;orgName&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;Магазин «Северный»&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;},&quot;agentName&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;Иван Петров&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;},&quot;agentRole&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;Менеджер поддержки&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;}}">
class Component extends DCLogic {
state = { micOn: true, camOn: true, name: '' };
micIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 22, height: 22, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.micOn
? svg([h('rect', { key: 1, x: 9, y: 2, width: 6, height: 12, rx: 3 }), h('path', { key: 2, d: 'M19 10v1a7 7 0 0 1-14 0v-1' }), h('line', { key: 3, x1: 12, y1: 18, x2: 12, y2: 22 })])
: svg([h('line', { key: 1, x1: 3, y1: 3, x2: 21, y2: 21 }), h('path', { key: 2, d: 'M9 9v2a3 3 0 0 0 5.1 2.1' }), h('path', { key: 3, d: 'M15 9.3V5a3 3 0 0 0-5.9-.7' }), h('path', { key: 4, d: 'M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1' }), h('line', { key: 5, x1: 12, y1: 18, x2: 12, y2: 22 })]);
}
camIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 22, height: 22, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.camOn
? svg([h('path', { key: 1, d: 'M23 7l-7 5 7 5V7z' }), h('rect', { key: 2, x: 1, y: 5, width: 15, height: 14, rx: 2.5 })])
: svg([h('line', { key: 1, x1: 2, y1: 2, x2: 22, y2: 22 }), h('path', { key: 2, d: 'M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2' }), h('path', { key: 3, d: 'M10 5h4a2 2 0 0 1 2 2v3l4-3v9' })]);
}
renderVals() {
const s = this.state;
const orgName = this.props.orgName ?? 'Магазин «Северный»';
const agentName = this.props.agentName ?? 'Иван Петров';
const agentRole = this.props.agentRole ?? 'Менеджер поддержки';
const initials = (orgName.replace(/[«»"]/g, '').trim()[0] || 'E').toUpperCase();
const agentInitials = (agentName.trim()[0] || 'A').toUpperCase() + ((agentName.split(' ')[1] || '')[0] || '').toUpperCase();
const ctrl = (on) => ({ width: 52, height: 52, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flex: 'none', transition: 'all .15s', border: '1px solid ' + (on ? 'var(--border-strong)' : 'var(--error-border)'), background: on ? 'var(--surface-card)' : 'var(--error-bg)', color: on ? 'var(--text-body)' : 'var(--error-text)' });
return {
orgName, agentName, agentRole, initials, agentInitials,
micIcon: this.micIcon(), camIcon: this.camIcon(),
micStyle: ctrl(s.micOn), camStyle: ctrl(s.camOn),
nameValue: s.name,
onName: (e) => this.setState({ name: e.target.value }),
toggleMic: () => this.setState(x => ({ micOn: !x.micOn })),
toggleCam: () => this.setState(x => ({ camOn: !x.camOn })),
join: () => {},
};
}
}
</script>
</body>
</html>
@@ -1,410 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="_ds/design-system/tokens/colors.css">
<link rel="stylesheet" href="_ds/design-system/tokens/typography.css">
<link rel="stylesheet" href="_ds/design-system/tokens/spacing.css">
<link rel="stylesheet" href="_ds/design-system/tokens/effects.css">
<link rel="stylesheet" href="_ds/design-system/styles.css">
<script src="_ds/design-system/_ds_bundle.js"></script>
<style>
body { margin: 0; background: var(--surface-page); }
a { color: var(--primary); text-decoration: none; }
a:hover { color: var(--primary-hover); }
@keyframes edv-ring { 0% { transform: scale(1); opacity: .55; } 100% { transform: scale(2.3); opacity: 0; } }
@keyframes edv-spin { to { transform: rotate(360deg); } }
</style>
</helmet>
<div style="min-height:100vh; box-sizing:border-box; display:flex; flex-direction:column; align-items:center; gap:20px; padding:36px 24px 60px; font-family:var(--font-sans); color:var(--text-body);">
<div style="text-align:center; max-width:560px;">
<div style="font-size:20px; font-weight:600; letter-spacing:-0.01em; color:var(--text-heading);">Экран звонка</div>
<div style="font-size:13.5px; color:var(--text-secondary); margin-top:5px; line-height:1.5;">Встраиваемый компонент видеозвонка оператора и клиента — для модалки или страницы Hub. Полный жизненный цикл вызова.</div>
</div>
<div style="display:flex; flex-direction:column; gap:12px; background:var(--surface-card); border:1px solid var(--border-default); border-radius:var(--radius-2xl); padding:14px 18px; box-shadow:var(--shadow-sm); max-width:640px;">
<div style="display:flex; flex-wrap:wrap; gap:20px; align-items:flex-start;">
<div style="display:flex; flex-direction:column; gap:7px;">
<span style="font-size:10.5px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:var(--text-tertiary);">Сторона</span>
<div style="display:flex; gap:3px; background:var(--n-9); padding:3px; border-radius:var(--radius-md);">
<button style="{{ segOp }}" onClick="{{ setOp }}">Оператор</button>
<button style="{{ segCl }}" onClick="{{ setCl }}">Клиент</button>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:7px;">
<span style="font-size:10.5px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:var(--text-tertiary);">Основные состояния</span>
<div style="display:flex; flex-wrap:wrap; gap:6px;">
<sc-for list="{{ mainStates }}" as="st" hint-placeholder-count="5">
<button style="{{ st.style }}" onClick="{{ st.onClick }}">{{ st.label }}</button>
</sc-for>
</div>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:7px;">
<span style="font-size:10.5px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:var(--text-tertiary);">Ошибки и завершение</span>
<div style="display:flex; flex-wrap:wrap; gap:6px;">
<sc-for list="{{ errorStates }}" as="st" hint-placeholder-count="7">
<button style="{{ st.style }}" onClick="{{ st.onClick }}">{{ st.label }}</button>
</sc-for>
</div>
</div>
<sc-if value="{{ liveCall }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-wrap:wrap; gap:14px; align-items:center; padding-top:11px; border-top:1px solid var(--border-subtle);">
<span style="font-size:10.5px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:var(--text-tertiary);">Собеседник</span>
<button style="{{ remoteMicChip }}" onClick="{{ toggleRemoteMic }}">Микрофон: {{ remoteMicLabel }}</button>
<button style="{{ remoteCamChip }}" onClick="{{ toggleRemoteCam }}">Камера: {{ remoteCamLabel }}</button>
</div>
</sc-if>
</div>
<div style="width:100%; max-width:428px; background:var(--surface-card); border:1px solid var(--border-default); border-radius:var(--radius-4xl); box-shadow:var(--shadow-lg); overflow:hidden;">
<div style="display:flex; align-items:center; gap:11px; padding:12px 15px; border-bottom:1px solid var(--border-default);">
<div style="{{ smallAvatarStyle }}">{{ peerInitials }}</div>
<div style="display:flex; flex-direction:column; gap:2px; min-width:0;">
<div style="display:flex; align-items:center; gap:7px;">
<span style="font-size:14px; font-weight:600; color:var(--text-heading); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ peerName }}</span>
<sc-if value="{{ showChannel }}" hint-placeholder-val="{{ true }}">
<x-import component-from-global-scope="ChatballsDesignSystem_e4c9df.ChannelBadge" channel="{{ channel }}" hint-size="auto,16px"></x-import>
</sc-if>
</div>
<span style="font-size:12px; color:var(--text-secondary);">{{ subtitle }}</span>
</div>
<div style="flex:1;"></div>
<sc-if value="{{ showTimer }}" hint-placeholder-val="{{ false }}">
<div style="{{ timerStyle }}">
<span style="{{ timerDot }}"></span>
{{ timer }}
</div>
</sc-if>
<button onClick="{{ close }}" aria-label="Закрыть" style="width:34px; height:34px; border-radius:var(--radius-md); border:1px solid var(--border-default); background:var(--surface-card); color:var(--text-secondary); display:flex; align-items:center; justify-content:center; cursor:pointer; flex:none;" style-hover="background:var(--n-9); color:var(--text-body);">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div style="position:relative; aspect-ratio:16/9; background:#141414; overflow:hidden;">
<sc-if value="{{ videoVisible }}" hint-placeholder-val="{{ false }}">
<x-import component-from-global-scope="image-slot" from="./image-slot.js" id="chatballs-call-main" placeholder="Кадр с камеры" shape="rect" style="position:absolute; inset:0; width:100%; height:100%;" hint-size="100%,100%"></x-import>
</sc-if>
<sc-if value="{{ showMainAvatar }}" hint-placeholder-val="{{ true }}">
<div style="position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:16px; z-index:3;">
<div style="position:relative; display:flex; align-items:center; justify-content:center;">
<sc-if value="{{ ringsVisible }}" hint-placeholder-val="{{ true }}">
<span style="position:absolute; width:88px; height:88px; border-radius:50%; border:2px solid rgba(255,255,255,.6); animation:edv-ring 1.9s ease-out infinite;"></span>
<span style="position:absolute; width:88px; height:88px; border-radius:50%; border:2px solid rgba(255,255,255,.6); animation:edv-ring 1.9s ease-out infinite; animation-delay:.95s;"></span>
</sc-if>
<div style="{{ bigAvatarStyle }}">{{ mainInitials }}</div>
</div>
<div style="text-align:center;">
<div style="font-size:15px; font-weight:600; color:#fff;">{{ mainName }}</div>
<div style="font-size:12.5px; color:rgba(255,255,255,.6); margin-top:3px;">{{ mainCaption }}</div>
</div>
</div>
</sc-if>
<sc-if value="{{ showReconnBanner }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; top:0; left:0; right:0; display:flex; align-items:center; justify-content:center; gap:9px; padding:9px 12px; background:rgba(20,20,20,.82); color:#fff; font-size:12.5px; font-weight:500; z-index:6;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="animation:edv-spin .9s linear infinite;"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg>
Связь прервана · восстанавливаем соединение…
</div>
</sc-if>
<sc-if value="{{ showStatus }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:22px 26px; text-align:center; background:#1a1a1a; z-index:7;">
<div style="{{ statusIconTile }}">{{ statusIcon }}</div>
<div>
<div style="font-size:15.5px; font-weight:600; color:#fff; line-height:1.3;">{{ statusTitle }}</div>
<div style="font-size:12.5px; color:rgba(255,255,255,.6); margin-top:6px; line-height:1.5; max-width:280px;">{{ statusCaption }}</div>
</div>
<sc-if value="{{ hasStatusActions }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; gap:9px; margin-top:2px;">
<sc-for list="{{ statusActions }}" as="act" hint-placeholder-count="2">
<button style="{{ act.style }}" onClick="{{ act.onClick }}">{{ act.label }}</button>
</sc-for>
</div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ showNameLabel }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; left:12px; bottom:12px; display:flex; align-items:center; gap:6px; padding:4px 10px; border-radius:var(--radius-md); background:rgba(0,0,0,.5); z-index:5; pointer-events:none;">
<sc-if value="{{ remoteMuted }}" hint-placeholder-val="{{ false }}">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#ff7875" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"></line><path d="M9 9v2a3 3 0 0 0 5.1 2.1"></path><path d="M15 9.3V5a3 3 0 0 0-5.9-.7"></path><path d="M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1"></path><line x1="12" y1="18" x2="12" y2="22"></line></svg>
</sc-if>
<span style="color:#fff; font-size:12px; font-weight:500;">{{ peerName }}</span>
</div>
</sc-if>
<sc-if value="{{ showPip }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; right:12px; bottom:12px; width:100px; height:66px; border-radius:10px; overflow:hidden; background:#1f1f1f; border:2px solid rgba(255,255,255,.9); box-shadow:0 4px 14px rgba(0,0,0,.4); z-index:4;">
<sc-if value="{{ camOn }}" hint-placeholder-val="{{ true }}">
<x-import component-from-global-scope="image-slot" from="./image-slot.js" id="chatballs-call-pip" placeholder="Вы" shape="rect" style="position:absolute; inset:0; width:100%; height:100%;" hint-size="100%,100%"></x-import>
</sc-if>
<sc-if value="{{ camOff }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center;">
<div style="width:34px; height:34px; border-radius:50%; background:var(--n-3); color:#fff; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:600;">ВЫ</div>
</div>
</sc-if>
<span style="position:absolute; left:5px; bottom:4px; font-size:10px; font-weight:600; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.6); pointer-events:none;">Вы</span>
</div>
</sc-if>
</div>
<sc-if value="{{ barIncoming }}" hint-placeholder-val="{{ true }}">
<div style="display:flex; justify-content:center; gap:52px; padding:18px 16px 22px;">
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ decline }}" aria-label="Отклонить" style="width:58px; height:58px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 2px 8px rgba(255,77,79,.35);">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:var(--text-secondary);">Отклонить</span>
</div>
<div style="display:flex; flex-direction:column; align-items:center; gap:9px;">
<button onClick="{{ accept }}" aria-label="Принять" style="width:58px; height:58px; border-radius:50%; border:none; background:var(--success); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 2px 8px rgba(82,196,26,.35);">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:var(--text-secondary);">Принять</span>
</div>
</div>
</sc-if>
<sc-if value="{{ barRinging }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; align-items:center; gap:9px; padding:18px 16px 22px;">
<button onClick="{{ close }}" aria-label="Отменить вызов" style="width:58px; height:58px; border-radius:50%; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 2px 8px rgba(255,77,79,.35);">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<span style="font-size:12.5px; color:var(--text-secondary);">Отменить</span>
</div>
</sc-if>
<sc-if value="{{ barPrep }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; flex-direction:column; gap:15px; padding:16px 16px 18px;">
<div style="display:flex; justify-content:center; gap:32px;">
<div style="display:flex; flex-direction:column; align-items:center; gap:8px;">
<button onClick="{{ toggleMic }}" style="{{ micStyle }}" aria-label="Микрофон">{{ micIcon }}</button>
<span style="font-size:12px; color:var(--text-secondary);">Микрофон</span>
</div>
<div style="display:flex; flex-direction:column; align-items:center; gap:8px;">
<button onClick="{{ toggleCam }}" style="{{ camStyle }}" aria-label="Камера">{{ camIcon }}</button>
<span style="font-size:12px; color:var(--text-secondary);">Камера</span>
</div>
</div>
<div style="display:flex; gap:10px;">
<button onClick="{{ close }}" style="flex:1; height:44px; border-radius:var(--radius-lg); border:1px solid var(--n-6); background:var(--surface-card); color:var(--text-body); font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer;" style-hover="border-color:var(--primary); color:var(--primary);">Отмена</button>
<button onClick="{{ join }}" style="flex:1; height:44px; border-radius:var(--radius-lg); border:none; background:var(--primary); color:#fff; font-size:14px; font-weight:600; font-family:var(--font-sans); cursor:pointer; box-shadow:var(--shadow-primary);" style-hover="background:var(--primary-hover);">Присоединиться</button>
</div>
</div>
</sc-if>
<sc-if value="{{ barActive }}" hint-placeholder-val="{{ false }}">
<div style="display:flex; justify-content:center; align-items:center; gap:14px; padding:16px;">
<button onClick="{{ toggleMic }}" style="{{ micStyle }}" aria-label="Микрофон">{{ micIcon }}</button>
<button onClick="{{ toggleCam }}" style="{{ camStyle }}" aria-label="Камера">{{ camIcon }}</button>
<button aria-label="Полный экран" style="width:54px; height:54px; border-radius:50%; border:1px solid var(--n-7); background:var(--n-9); color:var(--n-3); display:flex; align-items:center; justify-content:center; cursor:pointer;">
<svg viewBox="0 0 24 24" width="21" height="21" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"></path><path d="M21 8V5a2 2 0 0 0-2-2h-3"></path><path d="M3 16v3a2 2 0 0 0 2 2h3"></path><path d="M16 21h3a2 2 0 0 0 2-2v-3"></path></svg>
</button>
<button onClick="{{ end }}" aria-label="Завершить" style="width:60px; height:54px; border-radius:27px; border:none; background:var(--error); color:#fff; display:flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 2px 8px rgba(255,77,79,.35);">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(135deg);"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
</div>
</sc-if>
</div>
<div style="font-size:12.5px; color:var(--text-tertiary); text-align:center; max-width:420px; line-height:1.5;">Переключайте сторону и состояние выше. В активном звонке доступны тумблеры «Собеседник», чтобы проверить индикаторы удалённого микрофона и камеры.</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:700,&quot;height&quot;:820},&quot;clientName&quot;:{&quot;editor&quot;:&quot;text&quot;,&quot;default&quot;:&quot;Анна Ковалёва&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;},&quot;channel&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;default&quot;:&quot;webchat&quot;,&quot;options&quot;:[&quot;webchat&quot;,&quot;telegram&quot;,&quot;max&quot;],&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Контент&quot;}}">
class Component extends DCLogic {
state = { perspective: 'operator', stateName: 'incoming', micOn: true, camOn: true, remoteMicOn: true, remoteCamOn: true, seconds: 0 };
componentDidMount() {
this._t = setInterval(() => {
if (this.state.stateName === 'active') this.setState(s => ({ seconds: s.seconds + 1 }));
}, 1000);
}
componentWillUnmount() { clearInterval(this._t); }
goto(st) { if (st) this.setState(s => ({ stateName: st, seconds: (st === 'active' && s.stateName !== 'reconnecting') ? 0 : s.seconds })); }
icon(name) {
const h = React.createElement;
const svg = (children, extra) => h('svg', Object.assign({ viewBox: '0 0 24 24', width: 26, height: 26, fill: 'none', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round', strokeLinejoin: 'round' }, extra || {}), children);
switch (name) {
case 'spinner': return svg([h('path', { key: 1, d: 'M21 12a9 9 0 1 1-6.219-8.56' })], { strokeWidth: 2, style: { animation: 'edv-spin .9s linear infinite' } });
case 'alert': return svg([h('path', { key: 1, d: 'M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z' }), h('line', { key: 2, x1: 12, y1: 9, x2: 12, y2: 13 }), h('line', { key: 3, x1: 12, y1: 17, x2: 12.01, y2: 17 })]);
case 'declined': return svg([h('path', { key: 1, transform: 'rotate(135 12 12)', d: 'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z' })]);
case 'missed': return svg([h('path', { key: 1, d: 'M23 7l-8 8-4-4-9 9' }), h('polyline', { key: 2, points: '17 7 23 7 23 13' })]);
case 'clock': return svg([h('circle', { key: 1, cx: 12, cy: 12, r: 9 }), h('path', { key: 2, d: 'M12 7v5l3 2' })]);
case 'nodevice': return svg([h('line', { key: 1, x1: 2, y1: 2, x2: 22, y2: 22 }), h('path', { key: 2, d: 'M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2' }), h('path', { key: 3, d: 'M10 5h4a2 2 0 0 1 2 2v3l4-3v9' })]);
case 'unsupported': return svg([h('rect', { key: 1, x: 2, y: 3, width: 20, height: 14, rx: 2 }), h('line', { key: 2, x1: 8, y1: 21, x2: 16, y2: 21 }), h('line', { key: 3, x1: 12, y1: 17, x2: 12, y2: 21 }), h('line', { key: 4, x1: 9, y1: 8, x2: 15, y2: 14 }), h('line', { key: 5, x1: 15, y1: 8, x2: 9, y2: 14 })]);
default: return null;
}
}
micIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 22, height: 22, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.micOn
? svg([h('rect', { key: 1, x: 9, y: 2, width: 6, height: 12, rx: 3 }), h('path', { key: 2, d: 'M19 10v1a7 7 0 0 1-14 0v-1' }), h('line', { key: 3, x1: 12, y1: 18, x2: 12, y2: 22 })])
: svg([h('line', { key: 1, x1: 3, y1: 3, x2: 21, y2: 21 }), h('path', { key: 2, d: 'M9 9v2a3 3 0 0 0 5.1 2.1' }), h('path', { key: 3, d: 'M15 9.3V5a3 3 0 0 0-5.9-.7' }), h('path', { key: 4, d: 'M19 10v1a7 7 0 0 1-1.4 4.2M12 18a7 7 0 0 1-7-7v-1' }), h('line', { key: 5, x1: 12, y1: 18, x2: 12, y2: 22 })]);
}
camIcon() {
const h = React.createElement;
const svg = (c) => h('svg', { viewBox: '0 0 24 24', width: 22, height: 22, fill: 'none', stroke: 'currentColor', strokeWidth: 1.85, strokeLinecap: 'round', strokeLinejoin: 'round' }, c);
return this.state.camOn
? svg([h('path', { key: 1, d: 'M23 7l-7 5 7 5V7z' }), h('rect', { key: 2, x: 1, y: 5, width: 15, height: 14, rx: 2.5 })])
: svg([h('line', { key: 1, x1: 2, y1: 2, x2: 22, y2: 22 }), h('path', { key: 2, d: 'M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2' }), h('path', { key: 3, d: 'M10 5h4a2 2 0 0 1 2 2v3l4-3v9' })]);
}
renderVals() {
const s = this.state;
const st = s.stateName;
const isOperator = s.perspective === 'operator';
const clientName = this.props.clientName ?? 'Анна Ковалёва';
const channel = this.props.channel ?? 'webchat';
const isIncoming = st === 'incoming';
const isRinging = st === 'ringing';
const isPrep = st === 'prep';
const isActive = st === 'active';
const isReconnecting = st === 'reconnecting';
const liveCall = isActive || isReconnecting;
const peer = isOperator
? { name: clientName, initials: (clientName.trim()[0] || 'К').toUpperCase() + ((clientName.split(' ')[1] || '')[0] || '').toUpperCase(), color: '#722ed1' }
: { name: 'Оператор', initials: 'ОЭ', color: '#1677ff' };
const self = { name: 'Вы', initials: 'ВЫ', color: '#595959' };
const P = peer.name;
const mm = String(Math.floor(s.seconds / 60)).padStart(2, '0');
const ss = String(s.seconds % 60).padStart(2, '0');
// ---- status message states ----
const chanLabel = channel === 'webchat' ? 'Веб-виджет' : channel === 'telegram' ? 'Telegram' : 'MAX';
const msg = {
connecting: { icon: 'spinner', tone: 'neutral', title: 'Соединяем звонок', caption: 'Устанавливаем защищённое соединение…', actions: [{ label: 'Отменить', kind: 'ghost', to: 'incoming' }] },
failed: { icon: 'alert', tone: 'error', title: 'Не удалось соединиться', caption: 'Проверьте интернет-соединение и попробуйте снова.', actions: [{ label: 'Повторить', kind: 'primary', to: 'connecting' }, { label: 'Закрыть', kind: 'secondary', to: 'incoming' }] },
declined: { icon: 'declined', tone: 'neutral', title: 'Звонок отклонён', caption: P + ' отклонил(а) вызов.', actions: [{ label: 'Позвонить снова', kind: 'primary', to: 'ringing' }, { label: 'Закрыть', kind: 'secondary', to: 'incoming' }] },
missed: { icon: 'missed', tone: 'warn', title: 'Пропущенный звонок', caption: P + ' не ответил(а) на вызов.', actions: [{ label: 'Перезвонить', kind: 'primary', to: 'ringing' }, { label: 'Закрыть', kind: 'secondary', to: 'incoming' }] },
expired: { icon: 'clock', tone: 'warn', title: 'Время ожидания истекло', caption: 'Никто не ответил вовремя. Попробуйте позвонить снова.', actions: [{ label: 'Позвонить снова', kind: 'primary', to: 'ringing' }, { label: 'Закрыть', kind: 'secondary', to: 'incoming' }] },
nodevice: { icon: 'nodevice', tone: 'error', title: 'Нет доступа к камере и микрофону', caption: 'Разрешите доступ к устройствам в настройках браузера и повторите.', actions: [{ label: 'Повторить проверку', kind: 'primary', to: 'prep' }, { label: 'Без видео', kind: 'secondary', to: 'connecting' }] },
unsupported: { icon: 'unsupported', tone: 'error', title: 'Видеозвонки не поддерживаются', caption: 'Обновите браузер или откройте ссылку в Chrome, Safari или Edge.', actions: [{ label: 'Скопировать ссылку', kind: 'secondary', to: null }] },
};
const cur = msg[st];
const showStatus = !!cur;
const toneColor = { neutral: { bg: 'rgba(255,255,255,.08)', fg: '#fff' }, error: { bg: 'rgba(255,77,79,.16)', fg: '#ff7875' }, warn: { bg: 'rgba(250,173,20,.16)', fg: '#ffc53d' } };
const tc = cur ? toneColor[cur.tone] : toneColor.neutral;
const statusIconTile = { width: 56, height: 56, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: tc.bg, color: tc.fg };
const actBtn = (kind) => {
const base = { height: 38, padding: '0 16px', borderRadius: 'var(--radius-lg)', fontSize: 13.5, fontWeight: 600, fontFamily: 'var(--font-sans)', cursor: 'pointer' };
if (kind === 'primary') return { ...base, border: 'none', background: 'var(--primary)', color: '#fff' };
if (kind === 'secondary') return { ...base, border: '1px solid rgba(255,255,255,.28)', background: 'transparent', color: '#fff' };
return { ...base, border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)' };
};
const statusActions = (cur ? cur.actions : []).map(a => ({ label: a.label, style: actBtn(a.kind), onClick: a.to ? () => this.goto(a.to) : () => {} }));
// ---- main preview subject / caption ----
const remoteCamOff = liveCall && !s.remoteCamOn;
const mainSubject = isPrep ? self : peer;
let mainCaption;
if (isIncoming) mainCaption = isOperator ? ('Входящий · ' + chanLabel) : 'Входящий видеозвонок';
else if (isRinging) mainCaption = 'Вызываем…';
else if (isPrep) mainCaption = 'Камера выключена';
else mainCaption = 'Камера собеседника выключена';
let subtitle;
if (isIncoming) subtitle = isOperator ? 'Входящий звонок' : 'Входящий видеозвонок';
else if (isRinging) subtitle = 'Ожидание ответа';
else if (isPrep) subtitle = 'Проверьте камеру и микрофон';
else if (isReconnecting) subtitle = 'Переподключение';
else if (isActive) subtitle = 'На связи';
else if (cur) subtitle = cur.title;
else subtitle = '';
// ---- control styles ----
const roundBase = { width: 54, height: 54, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flex: 'none', transition: 'background .15s, border-color .15s, color .15s' };
const neutralRound = { ...roundBase, border: '1px solid var(--n-7)', background: 'var(--n-9)', color: 'var(--n-3)' };
const mutedRound = { ...roundBase, border: '1px solid var(--error-border)', background: 'var(--error-bg)', color: 'var(--error-text)' };
const smallAvatarStyle = { width: 38, height: 38, borderRadius: '50%', background: peer.color, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, fontWeight: 600, flex: 'none' };
const bigAvatarStyle = { width: 88, height: 88, borderRadius: '50%', background: mainSubject.color, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 30, fontWeight: 600, position: 'relative', zIndex: 2, boxShadow: '0 4px 18px rgba(0,0,0,.35)' };
// ---- selectors ----
const segBase = { display: 'flex', alignItems: 'center', padding: '7px 13px', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', fontSize: 13, fontFamily: 'var(--font-sans)', whiteSpace: 'nowrap' };
const segActive = { ...segBase, fontWeight: 600, background: 'var(--surface-card)', color: 'var(--primary)', boxShadow: '0 1px 2px rgba(0,0,0,.09)' };
const segIdle = { ...segBase, fontWeight: 500, background: 'transparent', color: 'var(--text-secondary)' };
const chip = (active) => ({ padding: '6px 12px', borderRadius: 'var(--radius-pill)', border: '1px solid ' + (active ? 'var(--primary)' : 'var(--border-strong)'), background: active ? 'var(--primary-bg)' : 'var(--surface-card)', color: active ? 'var(--primary)' : 'var(--text-secondary)', fontSize: 12.5, fontWeight: active ? 600 : 500, fontFamily: 'var(--font-sans)', cursor: 'pointer', whiteSpace: 'nowrap' });
const mkStates = (arr) => arr.map(([id, label]) => ({ label, style: chip(st === id), onClick: () => this.goto(id) }));
const mainStates = mkStates([['incoming', 'Входящий'], ['ringing', 'Ожидание'], ['prep', 'Подготовка'], ['connecting', 'Соединение'], ['active', 'Активный']]);
const errorStates = mkStates([['reconnecting', 'Переподключение'], ['declined', 'Отклонён'], ['missed', 'Пропущен'], ['expired', 'Истёк'], ['failed', 'Ошибка'], ['nodevice', 'Нет устройств'], ['unsupported', 'Не поддерж.']]);
const remoteChip = (on) => ({ padding: '5px 11px', borderRadius: 'var(--radius-pill)', border: '1px solid ' + (on ? 'var(--border-strong)' : 'var(--error-border)'), background: on ? 'var(--surface-card)' : 'var(--error-bg)', color: on ? 'var(--text-secondary)' : 'var(--error-text)', fontSize: 12, fontWeight: 500, fontFamily: 'var(--font-sans)', cursor: 'pointer' });
const showTimer = liveCall;
const timerBg = isReconnecting ? { bg: 'var(--warning-bg)', fg: 'var(--warning-text)', dot: 'var(--warning)' } : { bg: 'var(--error-bg)', fg: 'var(--error-text)', dot: 'var(--error)' };
return {
isOperator, channel, showChannel: isOperator, liveCall,
peerName: peer.name, peerInitials: peer.initials, subtitle,
mainName: mainSubject.name, mainInitials: mainSubject.initials, mainCaption,
timer: mm + ':' + ss, showTimer,
timerStyle: { display: 'flex', alignItems: 'center', gap: 6, padding: '4px 9px', borderRadius: 'var(--radius-pill)', background: timerBg.bg, color: timerBg.fg, fontSize: 12.5, fontWeight: 600, fontFamily: 'ui-monospace,SFMono-Regular,Menlo,monospace' },
timerDot: { width: 6, height: 6, borderRadius: '50%', background: timerBg.dot, animation: 'hub-pulse 1.6s ease-in-out infinite' },
videoVisible: (liveCall && s.remoteCamOn) || (isPrep && s.camOn),
showMainAvatar: isIncoming || isRinging || (isPrep && !s.camOn) || remoteCamOff,
ringsVisible: isIncoming || isRinging,
showReconnBanner: isReconnecting,
showStatus, statusIcon: cur ? this.icon(cur.icon) : null, statusIconTile,
statusTitle: cur ? cur.title : '', statusCaption: cur ? cur.caption : '',
hasStatusActions: statusActions.length > 0, statusActions,
showNameLabel: liveCall && !showStatus,
remoteMuted: liveCall && !s.remoteMicOn,
showPip: liveCall && !showStatus,
micOn: s.micOn, camOn: s.camOn, camOff: !s.camOn,
micIcon: this.micIcon(), camIcon: this.camIcon(),
micStyle: s.micOn ? neutralRound : mutedRound,
camStyle: s.camOn ? neutralRound : mutedRound,
smallAvatarStyle, bigAvatarStyle,
barIncoming: isIncoming, barRinging: isRinging, barPrep: isPrep, barActive: liveCall && !showStatus,
mainStates, errorStates,
segOp: isOperator ? segActive : segIdle, segCl: !isOperator ? segActive : segIdle,
setOp: () => this.setState({ perspective: 'operator' }),
setCl: () => this.setState({ perspective: 'client' }),
remoteMicChip: remoteChip(s.remoteMicOn), remoteCamChip: remoteChip(s.remoteCamOn),
remoteMicLabel: s.remoteMicOn ? 'вкл' : 'выкл', remoteCamLabel: s.remoteCamOn ? 'вкл' : 'выкл',
toggleRemoteMic: () => this.setState(x => ({ remoteMicOn: !x.remoteMicOn })),
toggleRemoteCam: () => this.setState(x => ({ remoteCamOn: !x.remoteCamOn })),
close: () => this.goto('incoming'),
accept: () => this.goto('active'),
decline: () => this.goto('incoming'),
join: () => this.goto('active'),
end: () => this.goto('incoming'),
toggleMic: () => this.setState(x => ({ micOn: !x.micOn })),
toggleCam: () => this.setState(x => ({ camOn: !x.camOn })),
};
}
}
</script>
</body>
</html>
@@ -1,195 +0,0 @@
# Chatbolls — design baseline (SPEC-HUB-0031)
Два макета: чат сотрудника (роль EMPLOYEE) и оболочка владельца/админа (OWNER = ADMIN). Основания: SPEC-HUB-0031 (§3–6, §8.1, §9), ADR-HUB-0002/0003 (режимы диалога, перехват), ADR-HUB-0041/0043 (контакт-центр, группы, ответственный, видимость).
## Как открыть
Любой `*.dc.html` открывается в браузере напрямую (нужен `support.js` рядом). Тема и акцент задаются через Tweaks (`theme`, `accent`) или правкой `default` в `data-props`.
## Состав
```
Чат сотрудника Baseline.dc.html — EMPLOYEE: кадры H, A–G, S1–S2, M1–M2
Админ Baseline.dc.html — OWNER/ADMIN: кадры A1–A2
Настройки Baseline.dc.html — OWNER/ADMIN: кадры N1–N7, M
Профиль Baseline.dc.html — все роли: кадры P1–P2, M
Контакты Baseline.dc.html — OWNER/ADMIN: кадры K1–K5, S1
Агенты Baseline.dc.html — OWNER/ADMIN: кадры G1–G5, S1
Сотрудники Baseline.dc.html — OWNER/ADMIN: кадры E1–E10
Порталы Baseline.dc.html — OWNER/ADMIN: кадры PT1–PT8
_ds/ — токены дизайн-системы
assets/logo-mark.svg — знак
assets/av-1..4.png — аватары (Дмитрий Орлов · Анна Ким · Сергей Крылов · Елена Кузнецова)
support.js — рантайм макетов
```
## Админ Baseline — карта кадров
- **A1 Владелец · Чат** — стартовый экран. Сайдбар: логотип + переключатель организации; навигация ровно 6 пунктов плоским списком (Чат с бейджем очереди · Контакты · Агенты · Сотрудники · Доска · Настройки); блок «Запуск» (3 шага, автоотметка, прогресс, primary на текущий шаг, «Скрыть»); профиль с ролью внизу. В списке диалогов заголовок — **переключатель охвата** (поповер: Все диалоги · Группы · Агенты · Ответственный) — дерево из сайдбара сотрудника у админа переезжает сюда, чтобы навигация осталась плоской (§4.1). Inline-`Hint` первого визита под вкладками.
- **A2 Админ · рабочий режим** — «Запуск» завершён и скрыт, подсказка закрыта, охват «Операторы». От владельца отличается только подписью роли (§3).
## Настройки Baseline — карта кадров
Оболочка та же (сайдбар 220, «Настройки» активен). Вместо сплошной ленты из девяти секций — **субменю разделов 240px** и один раздел на экране; ниже субменю — переходы на отдельные экраны «База знаний» и «Аудит действий». Контент 820px, табличные разделы — 1000px.
- **N1 Организация** — логотип (загрузить/убрать), название, часовой пояс, валюта; «Сохранить» с датой последнего сохранения.
- **N2 Группы** — строки: цветная точка · имя · состав · диалоги · изменить/удалить; создание — одно поле внизу списка. Состав группы — на карточке сотрудника.
- **N3 AI-провайдер** — таблица: название · секрет (маска) · конфиг (endpoint + модель) · статус · «Проверить» · ⋯. Primary «Добавить провайдера» в шапке раздела.
- **N4 Интеграции** — таблица: название (фирменная плитка) · агент · статус · действия. Ошибка почты — красный бейдж и точка-маркер у пункта субменю.
- **N5 Голосовые и звонки** — матрица точка входа × (Голосовые · Аудиозвонки · Видеозвонки), переключатели сохраняются сразу; почта — «недоступно», выключенная точка входа приглушена.
- **N6 Хранилище файлов** — сегмент «На диске установки / Внешнее S3», поля S3 (секреты маской), статус проверки/переноса, «Перенести · Проверить доступ · Сохранить».
- **N7 Демо-данные** — статус, счётчик записей, учётки демо-сотрудников (email/пароль моноширинно), «Удалить демо-данные» danger-outline.
- **M** — мобильный: список разделов ↔ раздел; поля 44px, primary в липком футере.
### Решения по настройкам
1. **Профиль — не настройки.** Тема, акцент, аватар, пароль, 2FA, сессии — отдельная страница «Профиль» из меню пользователя (`SidebarUserMenu`). Из «Настроек» убран; SPEC-HUB-0031 §8.6 в части «Профиль» требует уточнения.
2. **Субменю вместо ленты** — отклонение от §8.6 («один экран с вертикальными секциями») по решению владельца: девять секций не помещаются на экран.
3. **OWNER == ADMIN** (§3): макет один для обеих ролей, никаких `canManage`-разветвлений в UI.
4. Порядок разделов и их состав — как в `features/settings/SettingsPage.tsx`; таблицы — `table-card`/`baseline-table`, матрица — `communication-matrix`.
## Профиль Baseline — карта кадров
Отдельная страница из меню пользователя (`SidebarUserMenu` → «Профиль»). Шапка: аватар 72 · имя + бейдж роли · email, дата вступления, группы · «Выйти». Две колонки: слева «кто я и как выглядит интерфейс» (Личные данные + Фото · Внешний вид · Уведомления в мессенджер), справа «безопасность» (Смена пароля · 2FA · Активные сессии).
- **P1 Владелец** — меню пользователя открыто (Уведомления · Профиль · Выйти), навигация видна; Telegram привязан с типами событий (ADR-0015), MAX — «Привязать бота»; TOTP включена; три сессии, текущая отмечена.
- **P2 Сотрудник** — сайдбар без навигации (§3): логотип, организация, профиль. Та же страница; TOTP выключена — жёлтое предупреждение.
- **M** — мобильный: личные данные и внешний вид сразу; уведомления, пароль, 2FA, сессии — список подэкранов.
Решения: тема — сегмент Светлая / Тёмная / Как в системе (§7.2); акцент — 4 пресета из `shared/appearance.ts` + HEX (§7.3); всё на странице принадлежит учётной записи, а не организации (ADR-0029) — владелец не меняет это у других. Поле «Роль» из формы убрано — роль показана бейджем в шапке.
## Контакты Baseline — карта кадров
Источник — `features/sales/clients` и `client-detail` (раздел в коде всё ещё называется `salesClients`). Из текущей таблицы убраны колонка «Продукты» и фильтр по продуктам (ADR-0041, §8.2 «без коммерции»), добавлен фильтр по агенту. Из карточки убраны вкладка «Согласия» (заглушка в коде) и блок «Связанные продукты»; добавлены «Ответственный» и «Заметка» — те же поля, что в контекст-панели чата.
- **K1 Список** — колонки: Контакт (аватар · имя · CID) · Как связаться (телефон/email + логин) · Каналы (плитки-глифы) · Последний диалог (точка режима · кто ведёт · когда, сортируемая) · Открытые · ⋯. Фильтры: поиск, Каналы, Агенты, чип «С открытым диалогом»; «Экспорт CSV»; пагинация в футере карточки.
- **K2 Список · фильтр** — чип активен, счётчик «6 из 128», «Сбросить».
- **K3 Карточка · Обзор** — шапка (аватар 64 · имя · CID · карандаш · описание · телефон/email/компания/город с «копировать» · бейджи каналов · primary «Открыть диалог»); `UnderlineTabs` Обзор · Диалоги · Идентификаторы · Аудит; сводка 4 числа · лента активности; справа — текущий диалог, ответственный, заметка.
- **K4 Карточка · Диалоги** — тема + превью · агент · канал · группа · статус · дата; клик — в чат.
- **K5 Карточка · Идентификаторы** — идентичности по подключениям со статусом подтверждения; справа — «Возможный дубликат» с «Сравнить и объединить» (ручное объединение с причиной и аудитом, ADR-0006).
- **S1 Пустой список** — «Контакты появятся, когда клиенты напишут вашему агенту» + «Настроить подключения».
Мобильной версии нет — §9: админ-разделы с телефона в первой итерации не адаптируются. Вкладка «Аудит» — таблица Время · Действие · Объект · Актор · Результат как в коде, отдельно не перерисована.
## Агенты Baseline — карта кадров
Источник — `features/agents` (AgentsPage, AgentDetailPage). Агент = канал + AI-конфигурация (ADR-0041 §4); изменения применяются сразу, без релизов (ADR-0023). Вертикальная лента секций из кода пересобрана в две колонки: слева «что он знает и как говорит» (Инструкции · Знания), справа «как работает» (Назначение · Модель · Подключения).
- **G1 Список** — агент (плитка-бот в цвете агента · имя · код) · группа · статус (AI отвечает / Без AI / Выключен) · модель · подключения (плитки с красным маркером ошибки) · открытые · ⋯. Колонка «Модель» добавлена к §8.3.
- **G2 Создание** — модалка одного шага: название + группа (§4.3).
- **G3 Карточка · Консультант** — рабочий агент. Шапка: плитка · имя · статус · группа · код · открытые · «Остановить AI». Инструкции — три поля (кто он · как говорит · правила). Знания — список с чипами «Выключено» / «Портал» и кнопкой «Выбрать». Подключения: привязанные со статусом и «Отвязать», свободные с «Привязать».
- **G4 Карточка · Поддержка сайта** — виджет привязан: блок «Код вставки виджета» с «Копировать» и подсказкой «вставьте перед </body>» (§8.3); почта в ошибке.
- **G5 Карточка · новый агент** — blocker «провайдер не выбран» с переходом в настройки (ADR-0017), плейсхолдеры инструкций, строка «несохранённые изменения — применятся сразу», «Запустить AI».
- **S1 Пустой список** — «Агент отвечает клиентам первым. Создайте первого» + «Создать агента» (§5.2).
Решения: лимит дневной стоимости (`dailyCostUsd`, ADR-0023) вынесен в блок «Модель» — в коде поля нет, требует реализации. «Выключить агента» / «Удалить» — вторичные кнопки внизу карточки, как в коде.
## Порталы Baseline — карта кадров
Источник — `features/support-portals` + SPEC-HUB-0028 (§1.1–1.3, §3, §6). Переделаны три больные точки: модалка настроек, публичная ссылка и редактор статьи с файлами.
- **PT1 Список** — таблица: портал (плитка-глобус · имя · язык/дата) · **публичный адрес** (моношрифт + «копировать» + бейдж «свой домен») · статус · материалы (разделы · статьи) · обновлён · ⋯. Меню строки открыто: Открыть материалы · Настройки портала · Копировать ссылку · Открыть портал · Перенести в архив. Архивный портал приглушён и уходит в конец. Поиск и фильтр статуса в шапке карточки, счётчик и пагинация в футере.
- **PT2 Пусто** — «Создайте первый портал поддержки» + «Создать портал» и «Как это выглядит у клиента»; создание доступно всегда (§1.1).
- **PT3 Карточка · материалы** — шапка: хлебные крошки, имя, статус, «6 разделов · 48 статей»; **блок публичного адреса** — глобус в цвете статуса, домен, бейдж «свой домен · подтверждён», кнопки «копировать» и «открыть», рядом резервный hosted-адрес. Действия: «Настройки» (переход на PT4, не модалка), primary «Новая статья», ⋯ (публикация/архив). Ниже — библиотека без вкладок: дерево разделов 260px с вложенностью и счётчиками + тулбар (поиск, язык, статус, «Импорт статей») + таблица Статья · Раздел · Язык · Редакция · Статус · Обновлено · ⋯ (скрепка = файлы в статье).
- **PT4 Настройки · Основные** — субменю 250px (Основные · Адрес и домен · Оформление · Веб-виджет · Публикация и архив), контент 820px: название, **адрес на этой установке** (поле `{portalKey}` + суффикс базового домена инсталляции), основной язык, карточка «Итоговая ссылка» с «Копировать/Открыть».
- **PT5 Настройки · Адрес и домен** — поле домена + статус «Домен ведёт сюда · проверено», одна A-запись на IP установки с копированием, «Сохранить домен · Проверить DNS», «Отключить домен» danger-outline.
- **PT6 Настройки · Оформление** — две темы из каталога установки (`features/help-center/themes/`: `classic` «Классическая», `ocean` «Ocean») карточками с мини-превью на цветах их `manifest.preview`; сегмент схемы Светлая / Тёмная / Как в системе (`manifest.schemes`), «Предпросмотр портала», дата сохранения. Названия и описания тем в UI не придумываются — только из манифеста; отсутствующая в сборке тема показывается как недоступная.
- **PT7 Редактор статьи** — полноэкранный, без модалок. Шапка: «Материалы» назад, крошки, бейдж «черновая редакция 8 · не опубликована», сегмент Текст / Вместе / Просмотр, «Сохранить черновик», primary «Опубликовать». Тело: слева заголовок-инпут + адрес + панель Markdown (H1/H2 · B/I/ссылка/код · списки/цитата/таблица · изображение/файл) + монополе текста + статусная строка (знаки, позиция курсора, автосохранение); в центре живой предпросмотр в теме портала («Как у посетителя»); справа рейка 300px: раздел, язык, краткое описание, **файлы статьи** (drop-зона + список с «вставить в текст» и «копировать Markdown»), **версии** (неизменяемые, опубликованная отмечена).
- **PT8 Редактор · перетаскивание файла** — drop-оверлей поверх текста: «Отпустите, чтобы вставить в текст», подпись про типы и лимит 25 МБ; в рейке новый файл с прогрессом загрузки.
### Решения по порталам
1. **Настройки — страница, не модалка.** `Modal width={960}` с пятью секциями подряд заменён маршрутом `/portals/{id}/settings` с субменю разделов; §1.2 в части «modal surface» требует уточнения.
2. **Публичная ссылка — постоянный элемент шапки**, а не текст `<code>` в подзаголовке: копирование, открытие, статус домена и резервный hosted-адрес видны всегда, в том числе у черновика.
3. **Редактор статьи** — split-режим с предпросмотром GFM и панелью Markdown вместо `textarea rows={10}` в карточке. Версии и файлы — в рейке, публикация редакции — из того же экрана.
4. **Файлы статьи требуют API.** В коде вложений у статей портала нет (есть только у `ai/knowledge`): нужны `portals/{id}/articles/{articleId}/attachments/` с возвратом публичного URL для подстановки в Markdown. Импорт YAML остаётся отдельным действием «Импорт статей» в тулбаре.
5. **Hosted-адрес — домен установки, не домен продукта.** Продукт ставится на любой инфраструктуре, базовый домен берётся из `CHATBALLS_HELP_BASE_DOMAIN` (`support_portals/addressing.py`, `portal_views.py` → `address.baseDomain`). В макете он показан как «адрес на этой установке» с примером `help.nord-crm.ru` — в UI нельзя зашивать ни `help.example.com`, ни домен вендора; суффикс и итоговый URL всегда из ответа API.
6. **Никакого подтверждения владения доменом.** Продукт self-hosted: домен и установка у одного владельца — доказывать права самому себе нечего. Остаётся одна A-запись на IP установки и техническая проверка «ведёт ли домен сюда» (нужна для выписки TLS). TXT-запись `_chatballs.…`, `customDomainVerification` и `customDomainVerifiedAt` из UI убраны — их стоит выключить и в коде либо оставить только для мультитенантного SaaS-режима с общим ingress.
7. **Мобильной версии нет** — §9: админ-разделы с телефона в первой итерации не адаптируются.
## База знаний Baseline — карта кадров
Источник — `features/ai/knowledge` + SPEC-HUB-0026 §9 (иерархия категорий) и SPEC-HUB-0029 §7 (прикрепление к агенту). Раздел сверстан как карточка портала: та же шапка с состоянием, то же дерево слева, тот же split-редактор. Отделы упразднены ADR-HUB-0041, поэтому вместо `visibility` (`ORGANIZATION`/`DEPARTMENTS`) — состояние «участвует в ответах» и явный список агентов.
- **KB1 Библиотека** — шапка: «База знаний», бейдж «Индекс актуален», «9 категорий · 84 знания · 1 240 фрагментов», действия «Импорт YAML» и primary «Добавить знание». Ниже — дерево категорий 260px со счётчиками и вложенностью + тулбар (поиск, «В ответах», «Агент») + таблица: чекбокс · знание (иконка типа · заголовок · описание · скрепка вложений) · категория (с родителем) · фрагменты · агенты · в ответах · обновлено (кто) · ⋯. Меню строки: Открыть · Редактировать · Прикрепить к агенту · Переместить · Выключить в ответах · Переиндексировать · Удалить.
- **KB2 Массовый выбор** — тёмная панель операций по центру снизу: «Выбрано 3» + Прикрепить к агенту (primary) · Переместить · Включить · Выключить · Удалить · снять выделение. Выбранные строки подсвечены акцентом.
- **KB3 Прикрепить к агенту** — диалог 560px: сегмент Прикрепить / Открепить, выбор **одного** агента (радио, у агента без AI — статус «без AI»), предупреждение «2 из 3 будут пропущены» с причиной по каждому, примечание про атомарность и `ai.manage`, primary «Прикрепить 1».
- **KB4 Карточка знания** — шапка как у портала: крошки «База знаний / Регламенты / Пошив», заголовок, бейдж «В ответах агента», чипы «12 фрагментов · переиндексировано» и «3 агента», автор правки; действия «Прикрепить к агенту», primary «Редактировать», ⋯. Тело: рендер содержимого (≤720px) + рейка 320px: категория, тумблер «Участвует в ответах», вложения с копированием публичной ссылки, агенты со статусами, блок «Индексация» с «Переиндексировать».
- **KB5 Редактор знания** — тот же полноэкранный split, что PT7: слева заголовок + панель Markdown + монополе + статусная строка (знаки, ≈фрагменты, автосохранение); в центре предпросмотр «внутренний материал» и «Как ответит агент»; справа рейка 300px: категория, краткое описание, тумблер ответов, вложения (drop-зона + список), агенты (только чтение). Primary — «Сохранить и переиндексировать».
- **KB6 Редактор · перетаскивание файла** — drop-оверлей «Отпустите, чтобы приложить к знанию» (файл = вложение + ссылка в тексте), в рейке загрузка с прогрессом, в шапке бейдж «Есть несохранённые правки».
- **KB7 Категории** — дерево строками с ручкой перетаскивания, счётчиками, инлайн-переименованием, «+ вложенная»; удаление недоступно у системной и непустой категории (тайтл объясняет причину), справа карточка правил и предупреждение о непустой категории.
- **KB8 Импорт YAML** — разбор до применения: файл, сводка «создать 5 / обновить 2», таблица документ · `categoryPath` · действие · замечание; недостающие уровни категорий создаются автоматически с пояснением в замечании; primary «Импортировать 7 документов».
- **KB9 Пустая категория** — «В категории «Обучение» пока нет знаний» + «Добавить знание» / «Импорт YAML»; счётчики не подменяются нулями из другой категории.
### Решения по базе знаний
1. **Раздел вынесен в сайдбар** — пункт «База знаний» сразу после «Порталов»: внутренние материалы и публичные порталы — соседние сущности, а не вложенность в «Агентов».
2. **`visibility` заменена состоянием «участвует в ответах»** — после ADR-0041 отделов нет, скрывать знание от части организации нечем. Доступность = прикреплённые агенты + флаг участия; в UI это одна колонка и один тумблер вместо матрицы отделов.
3. **Фрагменты показываются как цифра, а не абстракция** — в строке и карточке видно число фрагментов и состояние пересборки: это единственный способ объяснить, почему агент отвечает старым текстом.
4. **Прикрепление — только массовым действием** (SPEC-0029 §6: операция атомарна и журналируется). В карточке и редакторе список агентов — только чтение, изменение уходит в диалог KB3, который честно показывает пропуски.
5. **Импорт разбирается до применения.** Спецификация запрещает неявное создание категорий, поэтому ошибка пути должна быть видна до записи — таблица предпросмотра с построчным действием.
6. **Вложения знания уже есть в API** (`ai/knowledge` attachments), в отличие от статей портала — поэтому drop-зона в редакторе знания реализуема без новых эндпоинтов.
## Сайдбар — актуально по коду (`layout/Sidebar.tsx`)
Во всех макетах обновлено 2026-09-06:
- **Знак Chatballs** — инлайн-SVG из `shared/icons.tsx` (`LogoIcon`): два круга и перемычка с вырезами сверху и снизу, 26px, `currentColor` по `--c-n1`, без подложки. Файл `assets/logo-mark.svg` больше не используется.
- **Пункты** — Чат (badge очереди) · Контакты · Агенты · Сотрудники · **Порталы** · **База знаний** · Настройки. Пункт «Доска» из ранних макетов — ошибка: в коде это `supportPortals` с иконкой `globe`.
- **Иконки** — ровно из `Icon`: `message` · `user` · `robot` · `team` · `globe` · `settings`, stroke-width 1.8.
- **Ширина тянется** — `pane-resizer` на правом краю: невидимая полоса 6px, подсветка акцентом 2px при наведении и перетаскивании; 180–320px, по умолчанию 220, запоминается в браузере; двойной клик — сброс. На ≤1024px ручка скрыта.
- **Шапка** — 56px: знак + переключатель организации (32px, имя + шеврон).
- **Рейка 60px** — только ≤1024px: знак · чат · «развернуть» · аватар; полный сайдбар выезжает поверх контента (кадр E7).
- **Блок «Запуск»** — три шага с автоотметкой, прогресс, primary текущего шага, «×» — скрыть (localStorage).
- **Меню пользователя** — аватар · имя + email · точка непрочитанных + ⋯ (Уведомления · Профиль · Выйти).
- У сотрудника вместо навигации — дерево «Диалоги» (Все · Группы · Агенты); на странице «Профиль» сайдбар без навигации вообще (кадр P2).
## Сотрудники Baseline — карта кадров
Источник — `features/employees`. Роли: OWNER = ADMIN по правам, EMPLOYEE — только чат; группа задаёт только видимость диалогов (ADR-0043).
- **E1 Список** — таблица сотрудник · роль · должность · группы · доступ · статус · последний вход; фильтры поиск/роль/группа, «Добавить сотрудника»; в футере счётчик и напоминание про группы. Здесь же показан сайдбар целиком: блок «Запуск» и подсвеченная ручка ширины.
- **E2 Список · меню строки** — фильтр «Администратор»; действия: карточка · завершить сессии · сбросить пароль · заблокировать. Передачи владения в меню нет.
- **E3 Карточка сотрудника** — Основные данные · Должность и группы · Системная роль (три варианта, владелец замкнут) · Статус и безопасность · Опасная зона (блокировка); справа Учётная запись и Аудит.
- **E4 Карточка владельца** — «Только просмотр», баннер про правило роли, все роли замкнуты; единственное опасное действие — **Передача владения** в опасной зоне.
- **E5 Новый сотрудник** — выезжающая панель 520px, четыре шага: учётные данные · роль (Сотрудник / Администратор) · должность и группы · **пароль первичного доступа**: отправить на рабочую почту (по умолчанию) либо сгенерировать и показать.
- **E6 Новый сотрудник · пароль показать** — выбран второй вариант (на случай проблем с почтой); подпись футера меняется на «Пароль покажем сразу после создания».
- **E7 Пароль показан** — диалог после создания: пароль моноширинно, «Копировать», маскирование глазом, «Отправить на почту вместо этого», предупреждение о единственном показе, «Скопировал, закрыть». Действие в аудите.
- **E8 Сброс пароля** — тот же диалог для сброса владельцем или админом (из карточки сотрудника или меню строки): сессии завершены, при первом входе сотрудник задаёт свой пароль.
- **E9 Передача владения** — открывается только из опасной зоны карточки владельца. Кандидаты — только активные администраторы (чтобы передать сотруднику, его сначала делают админом): выбор, стрелка перехода, новая роль прежнего владельца, предупреждение, чекбокс подтверждения.
- **E10 Сайдбар · рейка 1024** — состояние ≤1024px.
Решение по паролю: сервер отдаёт пароль в ответе один раз и в открытом виде не хранит; UI показывает его в диалоге с копированием. Требует поддержки в API (`employees/{id}/reset-password/` и создание сотрудника должны возвращать сгенерированный пароль при выборе режима «показать»).
## Чат сотрудника — карта кадров
- **A** — рабочий экран, диалог ведёт AI. Композер активен; подсказка над полем: отправка перехватит диалог. Primary «Взять диалог» в строке действий.
- **B** — диалог ждёт человека (AI передал). Фильтр «Ждут человека» активен, системное событие в ленте, подсказка над композером.
- **C** — ведёте вы. Строка действий: «Вернуть AI» · ⋯. Меню ⋯ открыто: Вернуть в очередь · Закрыть диалог · Пометить как спам · Удалить диалог.
- **D** — ведёт другой сотрудник. Композер заблокирован (замок, кто ведёт), вторичное действие «Взять диалог».
- **E** — закрытый диалог. Вкладка «Мои», звонки в карточке и ⋯ скрыты, композер заменён сообщением о закрытии.
- **F** — контекст-панель, вкладка «История»: диалоги контакта, текущий выделен.
- **G** — фильтр по группе: в сайдбаре выбрана «Поддержка», список отфильтрован. В контекст-панели открыто меню переноса в группу.
- **H** (первый кадр) — голосовые: в списке превью «Голосовое сообщение · 0:14» с микрофоном; в ленте плеер (play 30 · волна 2px · длительность) и три состояния расшифровки под волной: текст + «Расшифровка AI · Скрыть» · «Расшифровываем…» · кнопка «Расшифровать». Расшифровка доступна и для своих голосовых. Композер в режиме записи: корзина · красный таймер · живая волна · пауза · Отправить (Esc — отмена, Enter — отправить).
- **S1** — пустой список: «Диалоги появятся, когда клиенты напишут вашему агенту», без кнопки (сотрудник).
- **S2** — ширина 1024: сайдбар сворачивается в рейку 60px, контекст-панель — выезжающая, открывается кнопкой «Контакт» в шапке ленты.
- **M1 / M2** — мобильный: список ↔ диалог. Контакт — по кнопке в шапке.
## Зафиксированные решения
1. **Сайдбар 220px с деревом «Диалоги».** У сотрудника нет разделов (§3), поэтому сайдбар — это фильтр списка: Все диалоги · Группы (с цветной точкой и счётчиком) · Агенты. Внизу — профиль. Верхней панели нет. На 1024 сворачивается в рейку.
2. **Одно действие взятия.** «Взять диалог» одинаково для очереди и AI — атомарный перехват (ADR-0003). Композер активен всегда: первое отправленное сообщение выполняет тот же перехват; над полем — одна строка-предупреждение.
3. **Фильтры.** Статусы — `UnderlineTabs` над списком (Все · Мои · Ждут оператора; закрытые попадают в Все/Мои); Группа и Агент — пункты дерева в сайдбаре (v1 чипы под поиском убраны). Заголовок списка = выбранный пункт дерева.
4. **Строка диалога** (ориентир — Chatwoot): иконка канала (Telegram/MAX — фирменные глифы из `shared/icons.tsx`, Email/Web — линейные) + имя агента в одну строку серым (`--c-n4`) · индикатор приоритета справа → имя · время → превью (↩ если последнее сообщение наше) · непрочитанные → бейджи: таймер ожидания (иконка + время, без слова «ждёт»), группа, метки. Активная строка — фон `--c-n9`, без акцентной полосы.
4a. **Сообщения**: у клиента (слева) аватара нет — он в шапке; фон входящих `--c-msg-in` (светлее/темнее подложки по теме), без обводки. Исходящие справа: аватар AI/сотрудника (в диалоге по очереди могут отвечать AI и разные люди), фон — акцент 22% на подложке ленты, AI — `--c-ai` 16%.
5. **Карточка контакта**: аватар 64 · канал · имя + карандаш · описание · поля с иконками и «копировать» (мессенджер/почта, телефон, компания, город). Кнопки «Позвонить» · «Видеозвонок» — в карточке контакта под полями (не в шапке ленты). Блок «Диалог»: Ответственный, Группа, Приоритет (Высокий/Средний/Низкий/Не задан, индикатор-столбики) — полноширинные селекты, Агент и Режим — read-only в две колонки, Метки — чипы с «+ Добавить», Начат. Заметка — жёлтая карточка.
6. **Метки и Приоритет диалога** — по образцу конкурентов; в SPEC-HUB-0031 этих полей нет. Требует решения: добавить в модель или убрать из макета.
6a. Агент везде подписан иконкой бота в цвете агента (Консультант — фиолетовый, Поддержка сайта — бирюзовый). Скроллбары скрыты.
7. **Ссылка «Открыть профиль контакта»** сотруднику не показывается — раздела «Контакты» у него нет.
8. Подсказки `Hint` сотруднику не показываются (§6).
9. Композер: поле сверху, строка инструментов снизу (эмодзи · вложение · голосовое · Шаблоны «/») и кнопка отправки с ⏎.
## Технические примечания
- Инлайн-стили поверх переменных темы `--c-*`, задаваемых на корне из Tweaks: **Тема** (light/dark) и **Акцент** (синий · индиго · бирюзовый · оранжевый). Полутона (фон акцента, предупреждений, AI) считаются через `color-mix`, поэтому одинаково работают в обеих темах. Данные — нейтральные примеры («Ателье Норд»).
- Статусные цвета режимов: AI `--ai`, ждёт `--warning`, человек `--primary`, закрыт `--n-5` — как в текущем `conversations/data.ts`.
@@ -1,4 +0,0 @@
@import "./tokens/colors.css";
@import "./tokens/typography.css";
@import "./tokens/spacing.css";
@import "./tokens/effects.css";
@@ -1,88 +0,0 @@
/* Chatballs — color tokens
Source: packages/ui/src/theme.ts (Ant Design ThemeConfig), apps/internal-ui/src/styles/base.css,
and design/baseline/*.dc.html (approved HTML baseline). Ant Design's default light palette,
customized with a single accent (#1677ff) per ADR-HUB-0013. */
:root {
/* ---- base neutrals (Ant Design neutral scale as used in the baseline) ---- */
--n-1: #1f1f1f; /* page/heading titles */
--n-2: #262626; /* primary body text */
--n-3: #595959; /* secondary text */
--n-4: #8c8c8c; /* tertiary text / captions */
--n-5: #bfbfbf; /* disabled / placeholder / faint */
--n-6: #d9d9d9; /* strong border, control border */
--n-7: #e8e8e8; /* input border */
--n-8: #f0f0f0; /* hairline border */
--n-9: #f5f5f5; /* hover surface */
--n-10: #fafafa; /* table header surface */
--n-11: #f0f2f5; /* app/page background */
--n-white: #ffffff;
/* ---- primary (blue-6 / blue-7) ---- */
--primary: #1677ff;
--primary-hover: #0958d9;
--primary-bg: #e6f4ff;
--primary-border: #91caff;
--primary-shadow: rgba(22, 119, 255, 0.3);
/* ---- semantic: success / warning / error / info ---- */
--success: #52c41a;
--success-text: #389e0d;
--success-bg: #f6ffed;
--success-bg-strong: #f1f9ec;
--success-border: #b7eb8f;
--warning: #faad14;
--warning-text: #d48806;
--warning-bg: #fffbe6;
--warning-bg-strong: #fff7e6;
--warning-border: #ffe58f;
--error: #ff4d4f;
--error-text: #cf1322;
--error-bg: #fff2f0;
--error-border: #ffccc7;
--info: #1677ff;
/* ---- role accents: AI vs human ---- */
--ai: #722ed1;
--ai-light: #9254de;
--ai-lighter: #b37feb;
--ai-bg: #f9f0ff;
--human: #1677ff;
/* ---- channel accents (MAX / Telegram / Web Chat) ---- */
--channel-max: #6b5be0;
--channel-max-bg: #f2f0ff;
--channel-telegram: #2f8fd0;
--channel-telegram-bg: #eaf6fd;
--channel-webchat: #0f9b8e;
--channel-webchat-bg: #e8f7f4;
/* ---- product accents (as tagged in tables/lists) ---- */
--product-firepage: #0958d9;
--product-firepage-bg: #e6f4ff;
--product-foxray: #722ed1;
--product-foxray-bg: #f9f0ff;
/* ---- surfaces ---- */
--surface-page: var(--n-11);
--surface-card: var(--n-white);
--surface-sunken: #fafafa;
--surface-chip: #f7f8fa;
/* ---- text aliases ---- */
--text-heading: var(--n-1);
--text-body: var(--n-2);
--text-secondary: var(--n-3);
--text-tertiary: var(--n-4);
--text-disabled: var(--n-5);
--text-inverse: var(--n-white);
--text-link: var(--primary);
/* ---- borders ---- */
--border-default: var(--n-8);
--border-input: var(--n-7);
--border-strong: var(--n-6);
}
@@ -1,40 +0,0 @@
/* Chatballs — radii, shadows, motion. "Премиальный минимализм": no gradients,
no glassmorphism/blur, no neon — shadows stay soft and near-black at low opacity. */
:root {
--radius-xs: 5px;
--radius-sm: 7px;
--radius-md: 8px;
--radius-lg: 9px;
--radius-xl: 10px;
--radius-2xl: 12px;
--radius-3xl: 13px;
--radius-4xl: 14px;
--radius-pill: 999px;
--radius-circle: 50%;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.03);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
--shadow-md: 0 2px 12px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 6px 20px rgba(0, 0, 0, 0.12);
--shadow-sticky: 0 -4px 16px rgba(0, 0, 0, 0.04);
--shadow-primary: 0 1px 2px rgba(22, 119, 255, 0.3);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1); /* @kind other */
--duration-fast: 0.15s; /* @kind other */
--duration-base: 0.2s; /* @kind other */
}
@keyframes hub-typing {
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-2px); }
}
@keyframes hub-spin {
to { transform: rotate(360deg); }
}
@keyframes hub-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@@ -1,36 +0,0 @@
/* Chatballs — spacing tokens. Compact-enterprise density (ADR-HUB-0013):
"большие пустые поля и oversized-карточки запрещены". Values are the exact
paddings/gaps observed in the baseline, not snapped to a rounder grid. */
:root {
--space-1: 2px;
--space-2: 4px;
--space-3: 6px;
--space-4: 8px;
--space-5: 9px;
--space-6: 10px;
--space-7: 11px;
--space-8: 12px;
--space-9: 14px;
--space-10: 16px;
--space-11: 18px;
--space-12: 20px;
--space-13: 22px;
--space-14: 24px;
--space-15: 28px;
--space-16: 32px;
--space-17: 36px;
/* control heights */
--h-control-sm: 30px;
--h-control-md: 34px;
--h-control: 36px;
--h-control-lg: 42px;
--h-control-xl: 44px;
--h-control-2xl: 48px;
--h-topbar: 56px;
/* layout */
--sidebar-width: 248px;
--content-max: 1340px;
}
@@ -1,42 +0,0 @@
/* Chatballs — typography tokens
The internal Hub uses the Ant Design default system font stack — no bespoke webfont
is loaded (see ADR-HUB-0013: "используется системный font stack Ant Design без
обязательной внешней зависимости от загружаемого шрифта"). Technical identifiers
(order/release/event IDs, emails) use a monospace stack. */
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
/* type scale, taken verbatim from the baseline (px, not snapped to a 4/8 grid) */
--text-2xl: 26px; /* page hero title (Command Center) */
--text-xl: 24px; /* page title */
--text-lg: 20px; /* auth title */
--text-md: 18px; /* card title */
--text-base-lg: 16px;
--text-base: 14.5px; /* buttons, primary body */
--text-sm-lg: 13.5px;
--text-sm: 13px; /* table body */
--text-sm-tight: 12.5px;
--text-xs: 12px; /* meta */
--text-xs-tight: 11.5px;
--text-2xs: 11px; /* table header / eyebrow */
--text-2xs-tight: 10.5px;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
--weight-black: 800;
--tracking-tight: -0.02em; /* large headings */
--tracking-tight-sm: -0.01em;
--tracking-wide: 0.03em; /* table header eyebrows */
--tracking-wider: 0.06em; /* nav section labels */
--leading-tight: 1.15;
--leading-snug: 1.2;
--leading-normal: 1.4;
--leading-relaxed: 1.5;
--leading-loose: 1.55;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#1677ff"></rect>
<path d="M7 16h4l2 6 4-14 2 8h6" fill="none" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

Before

Width:  |  Height:  |  Size: 266 B

File diff suppressed because it is too large. Load diff
@@ -1,316 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
a{color:inherit;text-decoration:none;}
a:hover{opacity:0.85;}
*{scrollbar-width:none;}
*::-webkit-scrollbar{display:none;width:0;height:0;}
</style>
</helmet>
<section style="{{ themeVars }}min-height:100vh;background:var(--c-canvas);padding:32px 40px 60px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:var(--c-n2);-webkit-font-smoothing:antialiased;">
<div style="display:flex;align-items:baseline;gap:14px;margin-bottom:6px;">
<h1 style="margin:0;font-size:22px;font-weight:700;letter-spacing:-0.02em;color:var(--c-n1);">Chatbolls · Агенты</h1>
<span style="font-size:13px;color:var(--c-n4);">design baseline · OWNER = ADMIN · SPEC-HUB-0031 §4.3, §8.3; ADR-0017, ADR-0023, ADR-0041</span>
</div>
<p style="margin:0 0 28px;font-size:13px;color:var(--c-n3);max-width:960px;line-height:1.55;">Агент — главный объект настройки: один экран с именем и статусом, группой, инструкциями из трёх частей, моделью, знаниями и подключениями. Изменения применяются сразу, без публикации (ADR-0023). Создание — мастер одного шага: имя и группа. Код вставки виджета копируется в один клик прямо в блоке подключений.</p>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start;">
<sc-for list="{{ frames }}" as="f" hint-placeholder-count="1">
<div id="{{ f.id }}" style="flex:none;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11.5px;font-weight:700;letter-spacing:0.02em;">{{ f.badge }}</span>
<strong style="font-size:13.5px;color:var(--c-n1);">{{ f.title }}</strong>
<span style="font-size:12px;color:var(--c-n4);">{{ f.note }}</span>
</div>
<div style="width:1440px;height:{{ f.h }};display:flex;overflow:hidden;background:var(--c-surface);border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);position:relative;">
<aside style="position:relative;width:220px;flex:none;background:var(--c-surface2);border-right:1px solid var(--c-n8);display:flex;flex-direction:column;min-height:0;">
<div title="Потяните, двойной клик — сбросить" style="position:absolute;top:0;right:-3px;bottom:0;width:6px;z-index:5;cursor:col-resize;"><i style="position:absolute;top:0;bottom:0;left:2px;width:2px;background:var(--c-accent);opacity:0;"></i></div>
<div style="flex:none;height:56px;display:flex;align-items:center;gap:10px;padding:14px 14px 10px;">
<span style="width:28px;height:28px;flex:none;display:flex;align-items:center;justify-content:center;border-radius:8px;color:var(--c-n1);"><svg viewBox="0 0 100 100" width="26" height="26" fill="currentColor"><defs><mask id="lg-ag"><rect width="100" height="100" fill="#fff"/><circle cx="50" cy="24.54" r="9" fill="#000"/><circle cx="50" cy="75.46" r="9" fill="#000"/></mask></defs><g mask="url(#lg-ag)"><circle cx="29" cy="50" r="24"/><circle cx="71" cy="50" r="24"/><rect x="44.2" y="31.4" width="11.6" height="37.2"/></g></svg></span>
<button style="flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:6px;height:32px;padding:0 6px 0 8px;border:none;border-radius:8px;background:transparent;color:var(--c-n1);font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Ателье Норд</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
</div>
<nav style="flex:none;padding:4px 10px 8px;display:flex;flex-direction:column;gap:2px;">
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="6">
<button style="display:flex;align-items:center;gap:10px;height:36px;padding:0 10px;border:none;border-radius:8px;background:{{ n.bg }};color:{{ n.color }};font-size:13.5px;font-weight:{{ n.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n8);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ n.icon }}"/></svg><span style="flex:1;">{{ n.label }}</span><sc-if value="{{ n.badge }}" hint-placeholder-val="{{ false }}"><b style="min-width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;padding:0 6px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);color:var(--c-warn-text);font-size:11px;font-weight:700;">{{ n.badge }}</b></sc-if></button>
</sc-for>
</nav>
<div style="flex:1;"></div>
<div style="flex:none;display:flex;align-items:center;gap:10px;padding:12px 14px;border-top:1px solid var(--c-n8);">
<span style="position:relative;flex:none;display:flex;"><span style="width:34px;height:34px;border-radius:50%;background:url(assets/av-4.png) center/cover;"></span><i style="position:absolute;right:-1px;bottom:-1px;width:10px;height:10px;border:2px solid var(--c-surface2);border-radius:50%;background:#52c41a;"></i></span>
<div style="flex:1;min-width:0;line-height:1.2;"><strong style="display:block;font-size:13px;font-weight:600;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Елена Кузнецова</strong><small style="display:block;font-size:11.5px;color:var(--c-n4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Владелец · e.kuznetsova@atelie-nord.ru</small></div>
<button style="width:28px;height:28px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</aside>
<section style="flex:1;min-width:0;min-height:0;overflow-y:auto;background:var(--c-feed);position:relative;">
<!-- LIST -->
<sc-if value="{{ f.isList }}" hint-placeholder-val="{{ true }}">
<div style="padding:26px 32px 40px;display:flex;flex-direction:column;gap:16px;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;">
<div><h2 style="margin:0;font-size:20px;font-weight:700;letter-spacing:-0.01em;color:var(--c-n1);">Агенты</h2><p style="margin:5px 0 0;font-size:13px;color:var(--c-n4);">Агент отвечает клиентам первым и собирает диалоги в группу</p></div>
<button style="height:34px;padding:0 14px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;" style-hover="background:var(--c-accent-hover);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Создать агента</button>
</div>
<sc-if value="{{ f.hasRows }}" hint-placeholder-val="{{ true }}">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;overflow:hidden;">
<div style="display:grid;grid-template-columns:minmax(0,1.7fr) minmax(0,0.9fr) minmax(0,1fr) minmax(0,1.8fr) minmax(0,1.1fr) 90px 44px;gap:16px;align-items:center;padding:10px 20px;border-bottom:1px solid var(--c-n8);background:var(--c-surface2);">
<sc-for list="{{ listHead }}" as="h" hint-placeholder-count="6"><span style="font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.05em;text-transform:uppercase;text-align:{{ h.align }};">{{ h.label }}</span></sc-for>
</div>
<sc-for list="{{ agents }}" as="a" hint-placeholder-count="3">
<div style="display:grid;grid-template-columns:minmax(0,1.7fr) minmax(0,0.9fr) minmax(0,1fr) minmax(0,1.8fr) minmax(0,1.1fr) 90px 44px;gap:16px;align-items:center;height:62px;padding:0 20px;border-bottom:1px solid var(--c-n9);cursor:pointer;opacity:{{ a.opacity }};" style-hover="background:var(--c-n10);">
<div style="display:flex;align-items:center;gap:12px;min-width:0;"><span style="width:36px;height:36px;flex:none;border-radius:9px;background:{{ a.tileBg }};color:{{ a.color }};display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01"/></svg></span><span style="min-width:0;"><strong style="display:block;font-size:13.5px;font-weight:600;color:var(--c-n1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ a.name }}</strong><small style="display:block;margin-top:2px;font-size:11.5px;color:var(--c-n4);font-family:'SF Mono',ui-monospace,Menlo,monospace;">{{ a.code }}</small></span></div>
<span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:{{ a.groupColor }};"><sc-if value="{{ a.groupDot }}" hint-placeholder-val="{{ true }}"><i style="width:7px;height:7px;border-radius:50%;background:{{ a.groupDot }};"></i></sc-if>{{ a.group }}</span>
<b style="display:inline-flex;align-items:center;gap:6px;height:22px;padding:0 8px;border-radius:6px;background:{{ a.stBg }};color:{{ a.stColor }};font-size:11.5px;font-weight:600;justify-self:start;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>{{ a.status }}</b>
<span style="font-size:12.5px;color:var(--c-n3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:'SF Mono',ui-monospace,Menlo,monospace;">{{ a.model }}</span>
<div style="display:flex;gap:6px;"><sc-for list="{{ a.conns }}" as="c" hint-placeholder-count="1"><span title="{{ c.full }}" style="position:relative;width:26px;height:26px;border-radius:7px;background:{{ c.bg }};color:{{ c.color }};display:inline-flex;align-items:center;justify-content:center;"><svg viewBox="{{ c.view }}" width="14" height="14" fill="{{ c.fill }}" stroke="{{ c.stroke }}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ c.path }}"/></svg><sc-if value="{{ c.err }}" hint-placeholder-val="{{ false }}"><i style="position:absolute;right:-3px;top:-3px;width:9px;height:9px;border-radius:50%;background:#ff4d4f;border:2px solid var(--c-surface);"></i></sc-if></span></sc-for><sc-if value="{{ a.noConns }}" hint-placeholder-val="{{ false }}"><span style="font-size:13px;color:var(--c-n5);">—</span></sc-if></div>
<div style="text-align:right;font-size:14px;font-weight:600;color:{{ a.openColor }};">{{ a.open }}</div>
<button title="Действия" style="width:30px;height:30px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;justify-self:end;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ f.isEmpty }}" hint-placeholder-val="{{ false }}">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:80px 24px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;">
<span style="width:48px;height:48px;border-radius:14px;background:color-mix(in srgb,var(--c-ai) 12%,var(--c-surface));color:var(--c-ai);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01"/></svg></span>
<div><strong style="display:block;font-size:15px;color:var(--c-n1);">Агент отвечает клиентам первым. Создайте первого</strong><p style="margin:6px 0 0;font-size:13px;color:var(--c-n4);line-height:1.5;max-width:420px;">Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.</p></div>
<button style="height:34px;padding:0 16px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Создать агента</button>
</div>
</sc-if>
</div>
<!-- CREATE MODAL -->
<sc-if value="{{ f.modal }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;">
<div style="width:440px;background:var(--c-surface);border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,0.3);padding:22px 24px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;"><h3 style="margin:0;font-size:16px;font-weight:700;color:var(--c-n1);">Создать агента</h3><button style="width:28px;height:28px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
<p style="margin:0 0 18px;font-size:12.5px;color:var(--c-n4);line-height:1.5;">Один шаг. Инструкции, модель, знания и подключения — потом, на карточке агента.</p>
<div style="display:flex;flex-direction:column;gap:14px;">
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Название</span><span style="display:flex;align-items:center;height:36px;padding:0 12px;border:1px solid var(--c-accent);border-radius:8px;background:var(--c-surface);font-size:13.5px;color:var(--c-n1);box-shadow:0 0 0 3px var(--c-accent-bg);">Приёмная<i style="width:1px;height:16px;background:var(--c-n1);margin-left:1px;"></i></span></label>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Группа</span><span style="display:flex;align-items:center;justify-content:space-between;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13.5px;color:var(--c-n1);">Без группы — диалоги видны всем<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span></label>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:22px;">
<button style="height:34px;padding:0 14px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;">Отмена</button>
<button style="height:34px;padding:0 16px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;">Создать</button>
</div>
</div>
</div>
</sc-if>
</sc-if>
<!-- CARD -->
<sc-if value="{{ f.isCard }}" hint-placeholder-val="{{ false }}">
<div style="padding:22px 32px 40px;display:flex;flex-direction:column;gap:16px;max-width:1160px;">
<button style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 8px;margin-left:-8px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);font-size:12.5px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 6 9 12 15 18"/></svg>Агенты</button>
<div style="display:flex;align-items:center;gap:16px;">
<span style="width:52px;height:52px;flex:none;border-radius:14px;background:{{ f.c.tileBg }};color:{{ f.c.color }};display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01"/></svg></span>
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:10px;"><h2 style="margin:0;font-size:22px;font-weight:700;letter-spacing:-0.01em;color:var(--c-n1);">{{ f.c.name }}</h2><b style="display:inline-flex;align-items:center;gap:6px;height:22px;padding:0 8px;border-radius:6px;background:{{ f.c.stBg }};color:{{ f.c.stColor }};font-size:11.5px;font-weight:600;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>{{ f.c.status }}</b></div>
<p style="display:flex;align-items:center;gap:7px;margin:5px 0 0;font-size:13px;color:var(--c-n4);"><span style="display:inline-flex;align-items:center;gap:5px;"><i style="width:7px;height:7px;border-radius:50%;background:{{ f.c.groupDot }};"></i>{{ f.c.group }}</span><i style="width:3px;height:3px;border-radius:50%;background:var(--c-n6);"></i><code style="font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:12px;">{{ f.c.code }}</code><i style="width:3px;height:3px;border-radius:50%;background:var(--c-n6);"></i>{{ f.c.openLine }}<i style="width:3px;height:3px;border-radius:50%;background:var(--c-n6);"></i>создан {{ f.c.created }}</p>
</div>
<div style="display:flex;gap:8px;flex:none;">
<button style="height:34px;padding:0 14px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="{{ f.c.toggleIcon }}"/></svg>{{ f.c.toggle }}</button>
<button title="Действия" style="width:34px;height:34px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</div>
<sc-if value="{{ f.c.blocker }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;border-radius:10px;background:var(--c-err-bg);border:1px solid var(--c-err-border);">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="var(--c-err-text)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg>
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13px;color:var(--c-err-text);">AI не может отвечать: провайдер не выбран</strong><small style="display:block;margin-top:2px;font-size:12.5px;color:var(--c-n3);">Добавьте ключ в «Настройки → AI-провайдер» и выберите провайдера в блоке «Модель». До этого диалоги агента ждут человека.</small></div>
<button style="height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--c-err-border);background:var(--c-surface);color:var(--c-err-text);font-size:12.5px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px;font-family:inherit;white-space:nowrap;">Открыть настройки<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7M8 7h9v9"/></svg></button>
</div>
</sc-if>
<div style="display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:16px;align-items:start;">
<div style="display:flex;flex-direction:column;gap:16px;min-width:0;">
<!-- INSTRUCTIONS -->
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 24px;">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px;"><h3 style="margin:0;font-size:14px;font-weight:700;color:var(--c-n1);">Инструкции</h3><small style="font-size:12px;color:var(--c-n4);">Системный промпт собирается из трёх частей в этом порядке</small></div>
<div style="display:flex;flex-direction:column;gap:14px;">
<sc-for list="{{ f.c.instr }}" as="i" hint-placeholder-count="3">
<label style="display:flex;flex-direction:column;gap:6px;"><span style="display:flex;align-items:baseline;gap:8px;font-size:12.5px;font-weight:600;color:var(--c-n3);">{{ i.label }}<small style="font-weight:400;color:var(--c-n5);">{{ i.hint }}</small></span><span style="display:block;min-height:{{ i.h }};padding:9px 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13px;line-height:1.55;color:{{ i.color }};white-space:pre-wrap;">{{ i.value }}</span></label>
</sc-for>
</div>
<sc-if value="{{ f.c.dirty }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:16px;padding-top:14px;border-top:1px solid var(--c-n9);"><small style="flex:1;font-size:12px;color:var(--c-warn-text);">Есть несохранённые изменения — применятся в runtime сразу</small><button style="height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;">Отменить</button><button style="height:32px;padding:0 14px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;">Сохранить</button></div>
</sc-if>
</div>
<!-- KNOWLEDGE -->
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 24px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;"><div style="display:flex;align-items:baseline;gap:8px;"><h3 style="margin:0;font-size:14px;font-weight:700;color:var(--c-n1);">Знания</h3><small style="font-size:12px;color:var(--c-n4);">{{ f.c.knowLine }}</small></div><button style="height:30px;padding:0 10px;border-radius:7px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Выбрать</button></div>
<sc-if value="{{ f.c.hasKnow }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;flex-direction:column;">
<sc-for list="{{ knowledge }}" as="k" hint-placeholder-count="4">
<div style="display:flex;align-items:center;gap:10px;height:42px;border-top:1px solid var(--c-n9);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--c-n4)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ k.icon }}"/></svg><strong style="flex:1;min-width:0;font-size:13px;font-weight:500;color:var(--c-n1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ k.title }}</strong><sc-if value="{{ k.chip }}" hint-placeholder-val="{{ false }}"><small style="height:20px;padding:0 7px;border-radius:5px;background:{{ k.chipBg }};color:{{ k.chipColor }};font-size:11px;font-weight:600;display:inline-flex;align-items:center;">{{ k.chip }}</small></sc-if><small style="font-size:11.5px;color:var(--c-n5);">{{ k.meta }}</small><button title="Убрать" style="width:24px;height:24px;border:none;border-radius:6px;background:transparent;color:var(--c-n5);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ f.c.noKnow }}" hint-placeholder-val="{{ false }}"><p style="margin:0;padding:14px 0 4px;border-top:1px solid var(--c-n9);font-size:12.5px;color:var(--c-n4);line-height:1.5;">Знания не прикреплены — агент отвечает только по инструкциям. Выберите статьи из библиотеки или портала поддержки.</p></sc-if>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px;min-width:0;">
<!-- ASSIGNMENT -->
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 22px;">
<h3 style="margin:0 0 4px;font-size:14px;font-weight:700;color:var(--c-n1);">Назначение</h3>
<p style="margin:0 0 14px;font-size:12px;color:var(--c-n4);line-height:1.5;">Группа определяет, кто из сотрудников видит диалоги агента; без группы — видны всем.</p>
<div style="display:flex;flex-direction:column;gap:12px;">
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Название</span><span style="display:flex;align-items:center;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13.5px;color:var(--c-n1);">{{ f.c.name }}</span></label>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Группа</span><span style="display:flex;align-items:center;justify-content:space-between;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13.5px;color:var(--c-n1);"><span style="display:inline-flex;align-items:center;gap:7px;"><i style="width:8px;height:8px;border-radius:50%;background:{{ f.c.groupDot }};"></i>{{ f.c.group }}</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span></label>
</div>
</div>
<!-- MODEL -->
<div style="order:3;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 22px;">
<h3 style="margin:0 0 4px;font-size:14px;font-weight:700;color:var(--c-n1);">Модель</h3>
<p style="margin:0 0 14px;font-size:12px;color:var(--c-n4);line-height:1.5;">Ключ провайдера — в «Настройки → AI-провайдер». Модель выбирается из каталога провайдера.</p>
<div style="display:flex;flex-direction:column;gap:12px;">
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Провайдер</span><span style="display:flex;align-items:center;justify-content:space-between;height:36px;padding:0 12px;border:1px solid {{ f.c.provBorder }};border-radius:8px;background:var(--c-n10);font-size:13.5px;color:{{ f.c.provColor }};">{{ f.c.provider }}<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span></label>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Модель</span><span style="display:flex;align-items:center;justify-content:space-between;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13px;color:{{ f.c.modelColor }};font-family:'SF Mono',ui-monospace,Menlo,monospace;">{{ f.c.model }}<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n5)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span></label>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Лимит в день</span><span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--c-n1);"><span style="display:inline-flex;align-items:center;height:30px;padding:0 10px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-n10);font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:12.5px;">{{ f.c.limit }}</span><small style="color:var(--c-n4);">USD</small></span></div>
</div>
</div>
<!-- CONNECTIONS -->
<div style="order:2;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 22px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:4px;"><h3 style="margin:0;font-size:14px;font-weight:700;color:var(--c-n1);">Подключения</h3><button style="display:inline-flex;align-items:center;gap:4px;border:none;background:transparent;color:var(--c-accent-text);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;padding:0;">Интеграции<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7M8 7h9v9"/></svg></button></div>
<p style="margin:0 0 10px;font-size:12px;color:var(--c-n4);line-height:1.5;">Через них клиенты попадают к агенту. Одно подключение — один агент.</p>
<sc-for list="{{ f.c.conns }}" as="c" hint-placeholder-count="2">
<div style="display:flex;align-items:center;gap:10px;padding:10px 0;border-top:1px solid var(--c-n9);">
<span style="width:30px;height:30px;flex:none;border-radius:8px;background:{{ c.bg }};color:{{ c.color }};display:flex;align-items:center;justify-content:center;"><svg viewBox="{{ c.view }}" width="16" height="16" fill="{{ c.fill }}" stroke="{{ c.stroke }}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ c.path }}"/></svg></span>
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13px;color:var(--c-n1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ c.name }}</strong><small style="display:block;margin-top:1px;font-size:11.5px;color:var(--c-n4);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ c.sub }}</small></div>
<b style="display:inline-flex;align-items:center;height:20px;padding:0 7px;border-radius:5px;background:{{ c.stBg }};color:{{ c.stColor }};font-size:11px;font-weight:600;white-space:nowrap;">{{ c.status }}</b>
<button style="height:28px;padding:0 10px;border-radius:7px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap;" style-hover="background:var(--c-n9);">{{ c.action }}</button>
</div>
</sc-for>
<sc-if value="{{ f.c.widget }}" hint-placeholder-val="{{ true }}">
<div style="margin-top:10px;padding:12px 12px 10px;border-radius:9px;background:var(--c-n10);border:1px solid var(--c-n8);">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;"><small style="font-size:12px;font-weight:600;color:var(--c-n3);">Код вставки виджета</small><button style="height:26px;padding:0 9px;border-radius:6px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:11.5px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:5px;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Копировать</button></div>
<code style="display:block;font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.5;color:var(--c-n2);white-space:pre-wrap;word-break:break-all;">&lt;script src="https://chat.atelie-nord.ru/widget.js" data-key="wk_9f3a12c4"&gt;&lt;/script&gt;</code>
<small style="display:block;margin-top:6px;font-size:11.5px;color:var(--c-n4);">Вставьте перед &lt;/body&gt; на сайте</small>
</div>
</sc-if>
</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;padding-top:6px;">
<button style="height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--c-n7);background:transparent;color:var(--c-n3);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Выключить агента</button>
<button style="height:32px;padding:0 12px;border-radius:8px;border:1px solid #ff4d4f;background:transparent;color:#ff4d4f;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:color-mix(in srgb,#ff4d4f 8%,var(--c-surface));">Удалить</button>
<small style="font-size:12px;color:var(--c-n5);">Удалить можно только без диалогов и подключений</small>
</div>
</div>
</sc-if>
</section>
</div>
</div>
</sc-for>
</div>
</section>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'light' | 'dark'&quot;,&quot;section&quot;:&quot;Тема&quot;},&quot;accent&quot;:{&quot;editor&quot;:&quot;color&quot;,&quot;options&quot;:[&quot;#1677ff&quot;,&quot;#6d5dfc&quot;,&quot;#0f9b8e&quot;,&quot;#e8590c&quot;],&quot;default&quot;:&quot;#1677ff&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Тема&quot;}}">
class Component extends DCLogic {
renderVals() {
const dark = (this.props.theme ?? "light") === "dark";
const accent = this.props.accent ?? "#1677ff";
const T = dark
? { canvas: "#101113", surface: "#1b1c1f", surface2: "#181a1d", feed: "#15161a", n1: "#d6d7db", n2: "#c3c5ca", n3: "#9a9ca3", n4: "#767881", n5: "#5b5d66", n6: "#45474f", n7: "#37393f", n8: "#2d2f35", n9: "#27292e", n10: "#212327", ai: "#a98be0", warnText: "#d9a441", okText: "#5fbf7a", errText: "#ff7875", accentText: `color-mix(in srgb,${accent} 62%,white)`, accentHover: `color-mix(in srgb,${accent} 92%,white)`, accentBgPct: 16, tintPct: 12, accentMuted: `color-mix(in srgb,${accent} 84%,#1b1c1f)` }
: { canvas: "#e4e6ea", surface: "#ffffff", surface2: "#fafafa", feed: "#f5f6f8", n1: "#1f1f1f", n2: "#262626", n3: "#595959", n4: "#8c8c8c", n5: "#bfbfbf", n6: "#d9d9d9", n7: "#e8e8e8", n8: "#f0f0f0", n9: "#f5f5f5", n10: "#fafafa", ai: "#722ed1", warnText: "#d48806", okText: "#237a3f", errText: "#cf1322", accentText: `color-mix(in srgb,${accent} 82%,black)`, accentHover: `color-mix(in srgb,${accent} 88%,black)`, accentBgPct: 10, tintPct: 11, accentMuted: accent };
const themeVars = `--c-canvas:${T.canvas};--c-surface:${T.surface};--c-surface2:${T.surface2};--c-feed:${T.feed};--c-n1:${T.n1};--c-n2:${T.n2};--c-n3:${T.n3};--c-n4:${T.n4};--c-n5:${T.n5};--c-n6:${T.n6};--c-n7:${T.n7};--c-n8:${T.n8};--c-n9:${T.n9};--c-n10:${T.n10};--c-ai:${T.ai};--c-accent:${T.accentMuted};--c-accent-text:${T.accentText};--c-accent-hover:${T.accentHover};--c-accent-bg:color-mix(in srgb,${accent} ${T.accentBgPct}%,${T.surface});--c-warn-bg:color-mix(in srgb,#faad14 ${T.tintPct}%,${T.surface});--c-warn-border:color-mix(in srgb,#faad14 ${T.tintPct + 25}%,${T.surface});--c-warn-text:${T.warnText};--c-ok-bg:color-mix(in srgb,#52c41a ${T.tintPct}%,${T.surface});--c-ok-text:${T.okText};--c-err-bg:color-mix(in srgb,#ff4d4f ${T.tintPct}%,${T.surface});--c-err-border:color-mix(in srgb,#ff4d4f ${T.tintPct + 25}%,${T.surface});--c-err-text:${T.errText};`;
const ICON = {
chat: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z",
contacts: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0z",
agents: "M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01",
team: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
portals: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",
knowledge: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5z",
settings: "M21 6H9M3 6h2M9 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 12h-8M3 12h6M13 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 18h-6M3 18h8M15 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0z",
mail: "M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 7l9 6 9-6",
doc: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M8 13h8M8 17h5",
globe: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20",
pause: "M10 4H6v16h4zM18 4h-4v16h4z",
bolt: "M13 2 3 14h9l-1 8 10-12h-9z",
};
const nav = [
{ key: "chat", label: "Чат", icon: ICON.chat, badge: 2 }, { key: "contacts", label: "Контакты", icon: ICON.contacts }, { key: "agents", label: "Агенты", icon: ICON.agents },
{ key: "employees", label: "Сотрудники", icon: ICON.team }, { key: "portals", label: "Порталы", icon: ICON.portals }, { key: "knowledge", label: "База знаний", icon: ICON.knowledge }, { key: "settings", label: "Настройки", icon: ICON.settings },
].map((n) => ({ ...n, bg: n.key === "agents" ? "var(--c-accent-bg)" : "transparent", color: n.key === "agents" ? "var(--c-accent-text)" : "var(--c-n2)", weight: n.key === "agents" ? 600 : 500 }));
const TG = "M512 256C512 114.62 397.38 0 256 0S0 114.62 0 256s114.62 256 256 256 256-114.62 256-256zm-396.12-2.7c74.63-32.52 124.39-53.95 149.29-64.31 71.1-29.57 85.87-34.71 95.5-34.88 2.12-.03 6.85.49 9.92 2.98 2.59 2.1 3.3 4.94 3.64 6.93.34 2 .77 6.53.43 10.08-3.85 40.48-20.52 138.71-29 184.05-3.59 19.19-10.66 25.62-17.5 26.25-14.86 1.37-26.15-9.83-40.55-19.27-22.53-14.76-35.26-23.96-57.13-38.37-25.28-16.66-8.89-25.81 5.51-40.77 3.77-3.92 69.27-63.5 70.54-68.9.16-.68.31-3.2-1.19-4.53s-3.71-.87-5.3-.51c-2.26.51-38.25 24.3-107.98 71.37-10.22 7.02-19.48 10.43-27.77 10.26-9.14-.2-26.72-5.17-39.79-9.42-16.03-5.21-28.77-7.97-27.66-16.82.57-4.61 6.92-9.32 19.04-14.14z";
const MAX = "M350.4,9.6C141.8,20.5,4.1,184.1,12.8,390.4c3.8,90.3,40.1,168,48.7,253.7,2.2,22.2-4.2,49.6,21.4,59.3,31.5,11.9,79.8-8.1,106.2-26.4,9-6.1,17.6-13.2,24.2-22,27.3,18.1,53.2,35.6,85.7,43.4,143.1,34.3,299.9-44.2,369.6-170.3C799.6,291.2,622.5-4.6,350.4,9.6h0ZM269.4,504c-11.3,8.8-22.2,20.8-34.7,27.7-18.1,9.7-23.7-.4-30.5-16.4-21.4-50.9-24-137.6-11.5-190.9,16.8-72.5,72.9-136.3,150-143.1,78-6.9,150.4,32.7,183.1,104.2,72.4,159.1-112.9,316.2-256.4,218.6h0Z";
const CH = {
TG: { full: "Telegram", view: "0 0 512 512", fill: "currentColor", stroke: "none", path: TG, color: "#229ED9", bg: "color-mix(in srgb,#229ED9 14%,var(--c-surface))" },
MAX: { full: "MAX", view: "0 0 720 720", fill: "currentColor", stroke: "none", path: MAX, color: "#5b5bd6", bg: "color-mix(in srgb,#5b5bd6 14%,var(--c-surface))" },
EMAIL: { full: "Email", view: "0 0 24 24", fill: "none", stroke: "currentColor", path: ICON.mail, color: "#d48806", bg: "color-mix(in srgb,#fa8c16 14%,var(--c-surface))" },
WEB: { full: "Web-виджет", view: "0 0 24 24", fill: "none", stroke: "currentColor", path: ICON.chat, color: "#0f9b8e", bg: "color-mix(in srgb,#0f9b8e 14%,var(--c-surface))" },
};
const ST = {
active: ["AI отвечает", "color-mix(in srgb,var(--c-ai) 14%,var(--c-surface))", "var(--c-ai)"],
paused: ["Без AI", "var(--c-warn-bg)", "var(--c-warn-text)"],
disabled: ["Выключен", "var(--c-n9)", "var(--c-n4)"],
};
const CS = { OK: ["Подключено", "var(--c-ok-bg)", "var(--c-ok-text)"], ERROR: ["Ошибка", "var(--c-err-bg)", "var(--c-err-text)"], UNCHECKED: ["Не проверено", "var(--c-n9)", "var(--c-n4)"] };
const GROUP = { "Операторы": "var(--c-accent)", "Поддержка": "#2aa876" };
const listHead = [["Агент"], ["Группа"], ["Статус"], ["Модель"], ["Подключения"], ["Открытые", "right"], [""]].map(([label, align]) => ({ label, align: align || "left" }));
const agentRow = (a) => ({ ...a, tileBg: `color-mix(in srgb,${a.color} 12%,var(--c-surface))`, group: a.group || "Без группы", groupDot: GROUP[a.group] || "", groupColor: a.group ? "var(--c-n2)" : "var(--c-n5)", status: ST[a.st][0], stBg: ST[a.st][1], stColor: ST[a.st][2], conns: a.ch.map((k) => ({ ...CH[k.split(":")[0]], err: k.endsWith(":err") })), noConns: a.ch.length === 0, openColor: a.open ? "var(--c-warn-text)" : "var(--c-n5)", open: a.open || "—", opacity: a.st === "disabled" ? 0.6 : 1 });
const agents = [
agentRow({ name: "Консультант", code: "consultant", color: "var(--c-ai)", group: "Операторы", st: "active", model: "anthropic/claude-sonnet-4", ch: ["TG", "MAX"], open: 5 }),
agentRow({ name: "Поддержка сайта", code: "site-support", color: "#0f9b8e", group: "Поддержка", st: "active", model: "anthropic/claude-sonnet-4", ch: ["WEB", "EMAIL:err"], open: 2 }),
agentRow({ name: "Приёмная", code: "reception", color: "#e8590c", group: "", st: "paused", model: "—", ch: [], open: 0 }),
agentRow({ name: "Архив · Летняя акция", code: "summer-promo", color: "#8c8c8c", group: "Операторы", st: "disabled", model: "openai/gpt-4o-mini", ch: [], open: 0 }),
];
const knowledge = [
{ title: "Прайс на пошив и ремонт 2026", icon: ICON.doc, meta: "обновлено 3 сен" },
{ title: "Сроки, доставка и примерки", icon: ICON.doc, meta: "обновлено 28 авг" },
{ title: "Возвраты и гарантия", icon: ICON.doc, chip: "Выключено", chipBg: "var(--c-n9)", chipColor: "var(--c-n4)", meta: "обновлено 12 июл" },
{ title: "Как ухаживать за шерстью", icon: ICON.globe, chip: "Портал · Помощь", chipBg: "var(--c-accent-bg)", chipColor: "var(--c-accent-text)", meta: "статья" },
];
const instr = (persona, tone, rules, empty) => [
{ label: "Кто он и что делает", hint: "персонализация", value: persona, h: "60px" },
{ label: "Как он должен говорить", hint: "тон", value: tone, h: "44px" },
{ label: "Правила работы", hint: "инструкции", value: rules, h: "88px" },
].map((i) => ({ ...i, color: empty ? "var(--c-n5)" : "var(--c-n1)" }));
const conn = (k, name, sub, st, action) => ({ ...CH[k], name, sub, status: CS[st][0], stBg: CS[st][1], stColor: CS[st][2], action });
const cardBase = { toggle: "Остановить AI", toggleIcon: ICON.pause, dirty: false, blocker: false, provBorder: "var(--c-n7)", provColor: "var(--c-n1)", modelColor: "var(--c-n1)", limit: "25.00" };
const consultant = { ...cardBase, name: "Консультант", code: "consultant", color: "var(--c-ai)", tileBg: "color-mix(in srgb,var(--c-ai) 12%,var(--c-surface))", group: "Операторы", groupDot: GROUP["Операторы"], status: ST.active[0], stBg: ST.active[1], stColor: ST.active[2], openLine: "5 открытых диалогов", created: "12 мар 2026",
instr: instr("Ты — консультант ателье «Норд». Помогаешь клиентам выбрать услугу, записаться на примерку и узнать статус заказа.", "Дружелюбно и по делу, на «вы». Короткие абзацы, без канцелярита и восклицаний.", "Цены называй только из знаний. Если вопрос про возврат или жалобу — передай оператору. Не обещай сроки короче указанных в знаниях. Статус заказа уточняй по номеру.", false),
provider: "OpenRouter · основной", model: "anthropic/claude-sonnet-4", knowLine: "4 из 18 · 3 знания + 1 статья", hasKnow: true, noKnow: false,
conns: [conn("TG", "Бот Ателье Норд", "Telegram · @atelie_nord_bot", "OK", "Отвязать"), conn("MAX", "MAX · Норд", "MAX", "OK", "Отвязать"), { ...CH.WEB, name: "Виджет · лендинг", sub: "Свободное подключение", status: "Свободно", stBg: "var(--c-n9)", stColor: "var(--c-n4)", action: "Привязать" }], widget: false };
const reception = { ...cardBase, name: "Приёмная", code: "reception", color: "#e8590c", tileBg: "color-mix(in srgb,#e8590c 12%,var(--c-surface))", group: "Без группы", groupDot: "var(--c-n5)", status: ST.paused[0], stBg: ST.paused[1], stColor: ST.paused[2], openLine: "нет диалогов", created: "сегодня", toggle: "Запустить AI", toggleIcon: ICON.bolt, blocker: true, dirty: true,
instr: instr("Ты — администратор приёмной ателье «Норд». Отвечаешь на первые вопросы и записываешь на визит.", "", "", false).map((i, n) => n ? { ...i, value: n === 1 ? "Например: спокойно и вежливо, на «вы»" : "Например: не называй цены, направляй к консультанту", color: "var(--c-n5)" } : i),
provider: "Не выбран", provBorder: "var(--c-err-border)", provColor: "var(--c-err-text)", model: "выберите провайдера", modelColor: "var(--c-n5)", knowLine: "ничего не выбрано", hasKnow: false, noKnow: true, limit: "10.00",
conns: [{ ...CH.WEB, name: "Виджет · лендинг", sub: "Свободное подключение", status: "Свободно", stBg: "var(--c-n9)", stColor: "var(--c-n4)", action: "Привязать" }], widget: false };
const support = { ...cardBase, name: "Поддержка сайта", code: "site-support", color: "#0f9b8e", tileBg: "color-mix(in srgb,#0f9b8e 12%,var(--c-surface))", group: "Поддержка", groupDot: GROUP["Поддержка"], status: ST.active[0], stBg: ST.active[1], stColor: ST.active[2], openLine: "2 открытых диалога", created: "20 мая 2026",
instr: instr("Ты — поддержка сайта ателье «Норд»: помогаешь с личным кабинетом, оплатой и письмами.", "Спокойно, коротко, без эмодзи.", "Проблемы с оплатой — сразу оператору. Не запрашивай данные карты.", false),
provider: "OpenRouter · основной", model: "anthropic/claude-sonnet-4", knowLine: "2 из 18", hasKnow: true, noKnow: false,
conns: [conn("WEB", "Виджет на сайте", "atelie-nord.ru", "OK", "Отвязать"), conn("EMAIL", "Почта поддержки", "help@atelie-nord.ru", "ERROR", "Отвязать")], widget: true };
const frames = [
{ id: "g1", badge: "G1", title: "Список", note: "строки: агент · группа · статус · модель · подключения (маркер ошибки) · открытые", isList: true, hasRows: true, isEmpty: false, modal: false },
{ id: "g2", badge: "G2", title: "Создание", note: "мастер одного шага (§4.3): имя и группа", isList: true, hasRows: true, isEmpty: false, modal: true },
{ id: "g3", badge: "G3", title: "Карточка · Консультант", note: "рабочий агент: инструкции · знания · назначение · модель · подключения со свободным для привязки", isCard: true, c: consultant },
{ id: "g4", badge: "G4", title: "Карточка · Поддержка сайта", note: "виджет привязан — код вставки с копированием; почта в ошибке", isCard: true, c: support },
{ id: "g5", badge: "G5", title: "Карточка · новый агент", note: "blocker: провайдер не выбран (ADR-0017) · плейсхолдеры инструкций · несохранённые изменения", isCard: true, c: reception },
{ id: "s1", badge: "S1", title: "Пустой список", note: "§5.2", isList: true, hasRows: false, isEmpty: true, modal: false },
];
for (const f of frames) f.h = f.isCard ? "1160px" : "880px";
return { themeVars, nav, listHead, agents, knowledge, frames };
}
}
</script>
</body>
</html>
@@ -1,276 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
a{color:inherit;text-decoration:none;}
a:hover{opacity:0.85;}
*{scrollbar-width:none;}
*::-webkit-scrollbar{display:none;width:0;height:0;}
</style>
</helmet>
<section style="{{ themeVars }}min-height:100vh;background:var(--c-canvas);padding:32px 40px 60px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:var(--c-n2);-webkit-font-smoothing:antialiased;">
<div style="display:flex;align-items:baseline;gap:14px;margin-bottom:6px;">
<h1 style="margin:0;font-size:22px;font-weight:700;letter-spacing:-0.02em;color:var(--c-n1);">Chatbolls · Владелец / Админ</h1>
<span style="font-size:13px;color:var(--c-n4);">design baseline · роли OWNER = ADMIN · SPEC-HUB-0031 §3–6, ADR-0043</span>
</div>
<p style="margin:0 0 28px;font-size:13px;color:var(--c-n3);max-width:960px;line-height:1.55;">Та же оболочка, что у сотрудника, плюс навигация: ровно 6 пунктов плоским списком, у «Чат» — бейдж очереди. Дерево «Все диалоги · Группы · Агенты» из сайдбара сотрудника у админа переезжает в заголовок списка как переключатель охвата — сайдбар остаётся плоским. Блок «Запуск» показывается до выполнения трёх шагов. Владелец от админа отличается только бейджем роли.</p>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start;">
<sc-for list="{{ frames }}" as="f" hint-placeholder-count="1">
<div id="{{ f.id }}" style="flex:none;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11.5px;font-weight:700;letter-spacing:0.02em;">{{ f.badge }}</span>
<strong style="font-size:13.5px;color:var(--c-n1);">{{ f.title }}</strong>
<span style="font-size:12px;color:var(--c-n4);">{{ f.note }}</span>
</div>
<div style="width:1440px;height:880px;display:flex;overflow:hidden;background:var(--c-surface);border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);position:relative;">
<!-- SIDEBAR: admin nav -->
<aside style="position:relative;width:220px;flex:none;background:var(--c-surface2);border-right:1px solid var(--c-n8);display:flex;flex-direction:column;min-height:0;">
<div title="Потяните, двойной клик — сбросить" style="position:absolute;top:0;right:-3px;bottom:0;width:6px;z-index:5;cursor:col-resize;"><i style="position:absolute;top:0;bottom:0;left:2px;width:2px;background:var(--c-accent);opacity:0;"></i></div>
<div style="flex:none;height:56px;display:flex;align-items:center;gap:10px;padding:14px 14px 10px;">
<span style="width:28px;height:28px;flex:none;display:flex;align-items:center;justify-content:center;border-radius:8px;color:var(--c-n1);"><svg viewBox="0 0 100 100" width="26" height="26" fill="currentColor"><defs><mask id="lg-adm"><rect width="100" height="100" fill="#fff"/><circle cx="50" cy="24.54" r="9" fill="#000"/><circle cx="50" cy="75.46" r="9" fill="#000"/></mask></defs><g mask="url(#lg-adm)"><circle cx="29" cy="50" r="24"/><circle cx="71" cy="50" r="24"/><rect x="44.2" y="31.4" width="11.6" height="37.2"/></g></svg></span>
<button title="Переключить организацию" style="flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:6px;height:32px;padding:0 6px 0 8px;border:none;border-radius:8px;background:transparent;color:var(--c-n1);font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Ателье Норд</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
</div>
<nav style="flex:none;padding:4px 10px 8px;display:flex;flex-direction:column;gap:2px;">
<sc-for list="{{ f.nav }}" as="n" hint-placeholder-count="6">
<button style="display:flex;align-items:center;gap:10px;height:36px;padding:0 10px;border:none;border-radius:8px;background:{{ n.bg }};color:{{ n.color }};font-size:13.5px;font-weight:{{ n.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n8);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ n.icon }}"/></svg><span style="flex:1;">{{ n.label }}</span><sc-if value="{{ n.badge }}" hint-placeholder-val="{{ false }}"><b style="min-width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;padding:0 6px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);color:var(--c-warn-text);font-size:11px;font-weight:700;">{{ n.badge }}</b></sc-if></button>
</sc-for>
</nav>
<!-- ONBOARDING -->
<sc-if value="{{ f.showLaunch }}" hint-placeholder-val="{{ true }}">
<div style="flex:none;margin:6px 10px 0;padding:12px 12px 10px;border-radius:10px;background:var(--c-surface);border:1px solid var(--c-n8);">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px;">
<div style="display:flex;align-items:baseline;gap:7px;"><strong style="font-size:13px;font-weight:700;color:var(--c-n1);">Запуск</strong><small style="font-size:11.5px;color:var(--c-n4);font-weight:500;">2 из 3</small></div>
<button title="Скрыть" style="width:22px;height:22px;border:none;border-radius:6px;background:transparent;color:var(--c-n5);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div style="height:3px;border-radius:2px;background:var(--c-n8);margin-bottom:12px;overflow:hidden;"><i style="display:block;width:66%;height:100%;background:var(--c-accent);"></i></div>
<div style="display:flex;flex-direction:column;gap:8px;">
<div style="display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--c-n4);"><span style="width:16px;height:16px;flex:none;border-radius:50%;background:var(--c-accent);color:#fff;display:inline-flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span><span style="text-decoration:line-through;">Создать агента</span></div>
<div style="display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--c-n4);"><span style="width:16px;height:16px;flex:none;border-radius:50%;background:var(--c-accent);color:#fff;display:inline-flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span><span style="text-decoration:line-through;">Подключить точку входа</span></div>
<div style="display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--c-n1);font-weight:600;"><span style="width:16px;height:16px;flex:none;border-radius:50%;border:1.5px solid var(--c-n6);display:inline-flex;"></span><span>Пригласить сотрудников</span></div>
</div>
<button style="margin-top:11px;width:100%;height:30px;border-radius:7px;border:none;background:var(--c-accent);color:#fff;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;justify-content:center;gap:6px;" style-hover="background:var(--c-accent-hover);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>Пригласить</button>
</div>
</sc-if>
<div style="flex:1;"></div>
<div style="flex:none;display:flex;align-items:center;gap:10px;padding:12px 14px;border-top:1px solid var(--c-n8);">
<span style="position:relative;flex:none;display:flex;"><span style="width:34px;height:34px;border-radius:50%;background:{{ f.userPhoto }};"></span><i style="position:absolute;right:-1px;bottom:-1px;width:10px;height:10px;border:2px solid var(--c-surface2);border-radius:50%;background:#52c41a;"></i></span>
<div style="flex:1;min-width:0;line-height:1.2;"><strong style="display:block;font-size:13px;font-weight:600;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ f.userName }}</strong><small style="display:block;font-size:11.5px;color:var(--c-n4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ f.role }}</small></div>
<button title="Профиль" style="width:28px;height:28px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</aside>
<!-- LIST -->
<section style="width:324px;flex:none;display:flex;flex-direction:column;min-height:0;background:var(--c-surface);border-right:1px solid var(--c-n8);position:relative;">
<div style="flex:none;padding:13px 16px 10px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px;">
<button style="display:flex;align-items:center;gap:6px;height:30px;padding:0 8px 0 6px;margin-left:-6px;border:none;border-radius:8px;background:{{ f.scopeBg }};color:var(--c-n1);cursor:pointer;font-family:inherit;min-width:0;" style-hover="background:var(--c-n9);"><h2 style="margin:0;font-size:16px;font-weight:700;white-space:nowrap;">{{ f.scope }}</h2><span style="font-size:12px;color:var(--c-n4);white-space:nowrap;font-weight:500;">{{ f.total }}</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<div style="display:flex;gap:4px;flex:none;">
<button title="Сортировка" style="width:28px;height:28px;border-radius:7px;border:none;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 16-4 4-4-4"/><path d="M17 20V4"/><path d="m3 8 4-4 4 4"/><path d="M7 4v16"/></svg></button>
<button title="Скрыть список" style="width:28px;height:28px;border-radius:7px;border:none;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h4"/><path d="M9 3v18"/><path d="m14 9 3 3-3 3"/></svg></button>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;height:34px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--c-n5)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span style="flex:1;font-size:13px;color:var(--c-n5);">Поиск по контакту, сообщению…</span>
<kbd style="font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--c-n4);background:var(--c-surface);border:1px solid var(--c-n7);border-radius:4px;padding:1px 5px;line-height:1.4;">/</kbd>
</div>
</div>
<!-- SCOPE POPOVER -->
<sc-if value="{{ f.scopeOpen }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;left:12px;top:46px;z-index:6;width:250px;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.14);padding:6px;">
<sc-for list="{{ f.scopes }}" as="g" hint-placeholder-count="3">
<sc-if value="{{ g.head }}" hint-placeholder-val="{{ false }}"><div style="padding:8px 10px 4px;font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.04em;text-transform:uppercase;">{{ g.head }}</div></sc-if>
<sc-if value="{{ g.divider }}" hint-placeholder-val="{{ false }}"><div style="height:1px;background:var(--c-n9);margin:4px 6px;"></div></sc-if>
<sc-for list="{{ g.items }}" as="s" hint-placeholder-count="2">
<button style="width:100%;display:flex;align-items:center;gap:9px;height:32px;padding:0 10px;border:none;border-radius:7px;background:{{ s.bg }};color:{{ s.color }};font-size:13px;font-weight:{{ s.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);"><sc-if value="{{ s.dot }}" hint-placeholder-val="{{ false }}"><i style="width:8px;height:8px;border-radius:50%;background:{{ s.dot }};flex:none;"></i></sc-if><sc-if value="{{ s.robot }}" hint-placeholder-val="{{ false }}"><span style="width:18px;height:18px;flex:none;border-radius:5px;background:{{ s.avBg }};color:{{ s.avColor }};display:inline-flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2z"/><path d="M12 2v2"/><path d="M9 12v9"/><path d="M15 12v9"/><path d="M9 18h6"/></svg></span></sc-if><sc-if value="{{ s.photo }}" hint-placeholder-val="{{ false }}"><span style="width:18px;height:18px;flex:none;border-radius:50%;background:{{ s.photo }};"></span></sc-if><sc-if value="{{ s.inbox }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg></sc-if><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ s.label }}</span><small style="font-size:11.5px;color:{{ s.countColor }};font-weight:600;">{{ s.count }}</small></button>
</sc-for>
</sc-for>
</div>
</sc-if>
<div style="flex:none;display:flex;gap:2px;padding:0 10px;border-bottom:1px solid var(--c-n8);">
<sc-for list="{{ f.tabs }}" as="t" hint-placeholder-count="3">
<button style="flex:none;display:inline-flex;align-items:center;gap:5px;padding:9px 8px 8px;margin-bottom:-1px;border:none;border-bottom:2px solid {{ t.line }};background:transparent;color:{{ t.color }};font-size:12.5px;font-weight:{{ t.weight }};cursor:pointer;white-space:nowrap;">{{ t.label }}<sc-if value="{{ t.count }}" hint-placeholder-val="{{ false }}"><b style="padding:0 5px;border-radius:8px;background:var(--c-warn-bg);color:var(--c-warn-text);font-size:11px;font-weight:700;">{{ t.count }}</b></sc-if></button>
</sc-for>
</div>
<!-- HINT (inline, first visit) -->
<sc-if value="{{ f.showHint }}" hint-placeholder-val="{{ true }}">
<div style="flex:none;display:flex;align-items:flex-start;gap:9px;margin:10px 12px 2px;padding:9px 10px 9px 12px;border-radius:8px;background:var(--c-accent-bg);border:1px solid var(--c-accent-border);">
<p style="flex:1;margin:0;font-size:12.5px;line-height:1.45;color:var(--c-n2);">Вы видите все диалоги организации. Сотрудники видят только диалоги своих групп, без группы и те, где они ответственные.</p>
<button title="Понятно" style="width:20px;height:20px;flex:none;border:none;border-radius:5px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:rgba(0,0,0,0.06);"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
</sc-if>
<div style="flex:1;min-height:0;overflow-y:auto;padding-top:6px;">
<sc-for list="{{ f.dialogs }}" as="d" hint-placeholder-count="6">
<button style="position:relative;width:100%;display:flex;gap:11px;padding:11px 14px 11px 16px;border:none;border-bottom:1px solid var(--c-n9);background:{{ d.bg }};text-align:left;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n10);">
<span style="position:relative;flex:none;display:flex;align-self:flex-start;margin-top:20px;"><span style="width:38px;height:38px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:{{ d.avatarBg }};color:#fff;font-size:13px;font-weight:600;">{{ d.initials }}</span><i style="position:absolute;right:-1px;bottom:-1px;width:12px;height:12px;border:2px solid var(--c-surface);border-radius:50%;background:{{ d.dot }};"></i></span>
<span style="min-width:0;flex:1;display:flex;flex-direction:column;gap:3px;">
<span style="display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0;">
<small style="display:inline-flex;align-items:center;gap:6px;color:var(--c-n4);font-size:11.5px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;"><svg viewBox="{{ d.chView }}" width="13" height="13" fill="{{ d.chFill }}" stroke="{{ d.chStroke }}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ d.chPath }}"/></svg><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ d.agent }}</span></small>
<sc-if value="{{ d.prioIcon }}" hint-placeholder-val="{{ false }}"><span title="{{ d.prioLabel }}" style="flex:none;display:inline-flex;align-items:flex-end;gap:1.5px;height:11px;"><i style="width:3px;height:5px;border-radius:1px;background:{{ d.pb1 }};"></i><i style="width:3px;height:8px;border-radius:1px;background:{{ d.pb2 }};"></i><i style="width:3px;height:11px;border-radius:1px;background:{{ d.pb3 }};"></i></span></sc-if>
</span>
<span style="display:flex;align-items:center;gap:6px;"><strong style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c-n1);font-size:14px;font-weight:600;">{{ d.name }}</strong><em style="flex:none;color:var(--c-n5);font-size:11px;font-style:normal;">{{ d.time }}</em></span>
<span style="display:flex;align-items:center;gap:6px;"><sc-if value="{{ d.fromUs }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg></sc-if><small style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:{{ d.previewColor }};font-size:12.5px;font-weight:{{ d.previewWeight }};">{{ d.preview }}</small><sc-if value="{{ d.unread }}" hint-placeholder-val="{{ false }}"><b style="min-width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;padding:0 5px;border-radius:9px;background:var(--c-accent);color:#fff;font-size:11px;font-weight:700;flex:none;">{{ d.unread }}</b></sc-if></span>
<sc-if value="{{ d.hasBadges }}" hint-placeholder-val="{{ true }}">
<span style="display:flex;align-items:center;gap:5px;flex-wrap:wrap;margin-top:3px;">
<sc-if value="{{ d.waitShort }}" hint-placeholder-val="{{ false }}"><b style="display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px;border-radius:5px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);color:var(--c-warn-text);font-size:11px;font-weight:600;"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>{{ d.waitShort }}</b></sc-if>
<sc-if value="{{ d.group }}" hint-placeholder-val="{{ true }}"><b style="display:inline-flex;align-items:center;gap:5px;height:20px;padding:0 7px;border-radius:5px;background:var(--c-surface);border:1px solid var(--c-n8);color:var(--c-n3);font-size:11px;font-weight:500;"><i style="width:7px;height:7px;border-radius:50%;background:{{ d.groupDot }};"></i>{{ d.group }}</b></sc-if>
<sc-for list="{{ d.labels }}" as="l" hint-placeholder-count="1"><b style="display:inline-flex;align-items:center;gap:5px;height:20px;padding:0 7px;border-radius:5px;background:var(--c-surface);border:1px solid var(--c-n8);color:var(--c-n3);font-size:11px;font-weight:500;"><i style="width:7px;height:7px;border-radius:2px;background:{{ l.color }};"></i>{{ l.text }}</b></sc-for>
</span>
</sc-if>
</span>
</button>
</sc-for>
</div>
</section>
<!-- FEED -->
<section style="flex:1;min-width:0;display:flex;flex-direction:column;min-height:0;background:var(--c-feed);position:relative;">
<div style="flex:none;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:11px 20px;background:var(--c-surface);border-bottom:1px solid var(--c-n8);">
<div style="display:flex;align-items:center;gap:12px;min-width:0;">
<span style="position:relative;flex:none;display:flex;"><span style="width:40px;height:40px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:#eb6f4b;color:#fff;font-size:14px;font-weight:600;">МС</span><i style="position:absolute;right:-1px;bottom:-1px;width:13px;height:13px;border:2px solid var(--c-surface);border-radius:50%;background:var(--c-ai);"></i></span>
<div style="min-width:0;">
<strong style="display:block;font-size:15px;font-weight:700;color:var(--c-n1);">Мария Соколова</strong>
<p style="display:flex;align-items:center;gap:7px;margin:3px 0 0;font-size:12px;color:var(--c-n4);white-space:nowrap;"><span style="color:var(--c-ai);font-weight:600;">AI отвечает</span><i style="width:3px;height:3px;border-radius:50%;background:var(--c-n6);"></i><span>Telegram</span><i style="width:3px;height:3px;border-radius:50%;background:var(--c-n6);"></i><span style="display:inline-flex;align-items:center;gap:4px;color:var(--c-ai);font-weight:500;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2z"/><path d="M12 2v2"/><path d="M9 12v9"/><path d="M15 12v9"/><path d="M9 18h6"/><path d="M10 8v.01"/><path d="M14 8v.01"/></svg>Консультант</span><i style="width:3px;height:3px;border-radius:50%;background:var(--c-n6);"></i><span style="display:inline-flex;align-items:center;gap:5px;"><i style="width:7px;height:7px;border-radius:50%;background:var(--c-accent);"></i>Операторы</span></p>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;flex:none;">
<button style="height:36px;padding:0 16px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px;" style-hover="background:var(--c-accent-hover);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>Взять диалог</button>
<button title="Контакт" style="width:36px;height:36px;border-radius:8px;border:1px solid var(--c-n8);background:var(--c-surface);color:var(--c-n3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button title="Действия диалога" style="width:36px;height:36px;border-radius:8px;border:1px solid var(--c-n8);background:var(--c-surface);color:var(--c-n3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</div>
<div style="flex:1;min-height:0;overflow-y:auto;padding:18px 24px 8px;">
<div style="max-width:760px;margin:0 auto;">
<div style="display:flex;align-items:center;gap:12px;margin:0 0 16px;color:var(--c-n5);font-size:11.5px;font-weight:500;"><span style="flex:1;height:1px;background:var(--c-n8);"></span>Сегодня<span style="flex:1;height:1px;background:var(--c-n8);"></span></div>
<sc-for list="{{ f.messages }}" as="m" hint-placeholder-count="4">
<div style="display:flex;gap:10px;margin-bottom:14px;flex-direction:{{ m.dir }};">
<sc-if value="{{ m.showAv }}" hint-placeholder-val="{{ false }}"><div style="flex:none;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:{{ m.avBg }};border:1px solid {{ m.avBorder }};color:{{ m.avColor }};"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2z"/><path d="M12 2v2"/><path d="M9 12v9"/><path d="M15 12v9"/><path d="M5 16l4-2"/><path d="M15 14l4 2"/><path d="M9 18h6"/><path d="M10 8v.01"/><path d="M14 8v.01"/></svg></div></sc-if>
<div style="max-width:74%;text-align:{{ m.align }};">
<sc-if value="{{ m.actor }}" hint-placeholder-val="{{ false }}"><strong style="display:block;margin-bottom:4px;font-size:11px;font-weight:600;color:var(--c-ai);">{{ m.actor }}</strong></sc-if>
<div style="padding:10px 14px;background:{{ m.bg }};border-radius:{{ m.radius }};color:var(--c-n2);font-size:13.5px;line-height:1.5;text-align:left;">{{ m.text }}</div>
<small style="display:inline-flex;align-items:center;gap:4px;margin:4px 4px 0;color:var(--c-n5);font-size:11px;">{{ m.time }}<sc-if value="{{ m.read }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/></svg></sc-if></small>
</div>
</div>
</sc-for>
</div>
</div>
<div style="flex:none;padding:10px 24px 16px;">
<div style="max-width:760px;margin:0 auto;">
<p style="display:flex;align-items:center;gap:6px;margin:0 0 8px 4px;font-size:12px;color:var(--c-ai);font-weight:500;"><i style="width:7px;height:7px;border-radius:50%;background:var(--c-ai);"></i>AI ведёт диалог. Ваше сообщение перехватит его — AI перестанет отвечать</p>
<div style="display:flex;align-items:flex-end;gap:8px;padding:8px 8px 8px 12px;border:1px solid var(--c-n7);border-radius:12px;background:var(--c-surface);">
<button title="Вложение" style="width:32px;height:32px;flex:none;border:none;border-radius:8px;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button>
<span style="flex:1;min-height:32px;display:flex;align-items:center;font-size:13.5px;color:var(--c-n5);">Написать сообщение…</span>
<button title="Голосовое" style="width:32px;height:32px;flex:none;border:none;border-radius:8px;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg></button>
<button style="height:32px;padding:0 14px;flex:none;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Отправить</button>
</div>
</div>
</div>
</section>
</div>
</div>
</sc-for>
</div>
</section>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'light' | 'dark'&quot;,&quot;section&quot;:&quot;Тема&quot;},&quot;accent&quot;:{&quot;editor&quot;:&quot;color&quot;,&quot;options&quot;:[&quot;#1677ff&quot;,&quot;#5b5bd6&quot;,&quot;#0f9b8e&quot;,&quot;#fa8c16&quot;],&quot;default&quot;:&quot;#1677ff&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Тема&quot;}}">
class Component extends DCLogic {
renderVals() {
const dark = (this.props.theme ?? "light") === "dark";
const accent = this.props.accent ?? "#1677ff";
const T = dark
? { canvas: "#101113", surface: "#1b1c1f", surface2: "#181a1d", feed: "#15161a", n1: "#d6d7db", n2: "#c3c5ca", n3: "#9a9ca3", n4: "#767881", n5: "#5b5d66", n6: "#45474f", n7: "#37393f", n8: "#2d2f35", n9: "#27292e", n10: "#212327", ai: "#a98be0", warnText: "#d9a441", accentText: `color-mix(in srgb,${accent} 62%,white)`, accentHover: `color-mix(in srgb,${accent} 92%,white)`, accentBgPct: 16, accentBorderPct: 40, tintPct: 12, accentMuted: `color-mix(in srgb,${accent} 84%,#1b1c1f)`, msgIn: "#1e2025" }
: { canvas: "#e4e6ea", surface: "#ffffff", surface2: "#fafafa", feed: "#f5f6f8", n1: "#1f1f1f", n2: "#262626", n3: "#595959", n4: "#8c8c8c", n5: "#bfbfbf", n6: "#d9d9d9", n7: "#e8e8e8", n8: "#f0f0f0", n9: "#f5f5f5", n10: "#fafafa", ai: "#722ed1", warnText: "#d48806", accentText: `color-mix(in srgb,${accent} 82%,black)`, accentHover: `color-mix(in srgb,${accent} 88%,black)`, accentBgPct: 10, accentBorderPct: 40, tintPct: 11, accentMuted: accent, msgIn: "#ffffff" };
const themeVars = `--c-canvas:${T.canvas};--c-surface:${T.surface};--c-surface2:${T.surface2};--c-feed:${T.feed};--c-msg-in:${T.msgIn};--c-n1:${T.n1};--c-n2:${T.n2};--c-n3:${T.n3};--c-n4:${T.n4};--c-n5:${T.n5};--c-n6:${T.n6};--c-n7:${T.n7};--c-n8:${T.n8};--c-n9:${T.n9};--c-n10:${T.n10};--c-ai:${T.ai};--c-accent:${T.accentMuted};--c-accent-text:${T.accentText};--c-accent-hover:${T.accentHover};--c-accent-bg:color-mix(in srgb,${accent} ${T.accentBgPct}%,${T.surface});--c-accent-border:color-mix(in srgb,${accent} ${T.accentBorderPct}%,${T.surface});--c-warn-bg:color-mix(in srgb,#faad14 ${T.tintPct}%,${T.surface});--c-warn-border:color-mix(in srgb,#faad14 ${T.tintPct + 25}%,${T.surface});--c-warn-text:${T.warnText};`;
const ICON = {
chat: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z",
contacts: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0z",
agents: "M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01",
team: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
portals: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",
knowledge: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5z",
settings: "M21 6H9M3 6h2M9 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 12h-8M3 12h6M13 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 18h-6M3 18h8M15 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0z",
};
const nav = (active) => [
{ key: "chat", label: "Чат", icon: ICON.chat, badge: 2 }, { key: "contacts", label: "Контакты", icon: ICON.contacts }, { key: "agents", label: "Агенты", icon: ICON.agents },
{ key: "employees", label: "Сотрудники", icon: ICON.team }, { key: "portals", label: "Порталы", icon: ICON.portals }, { key: "knowledge", label: "База знаний", icon: ICON.knowledge }, { key: "settings", label: "Настройки", icon: ICON.settings },
].map((n) => ({ ...n, bg: n.key === active ? "var(--c-accent-bg)" : "transparent", color: n.key === active ? "var(--c-accent-text)" : "var(--c-n2)", weight: n.key === active ? 600 : 500 }));
const CH_ICON = {
TG: { chView: "0 0 512 512", chFill: "currentColor", chStroke: "none", chPath: "M512 256C512 114.62 397.38 0 256 0S0 114.62 0 256s114.62 256 256 256 256-114.62 256-256zm-396.12-2.7c74.63-32.52 124.39-53.95 149.29-64.31 71.1-29.57 85.87-34.71 95.5-34.88 2.12-.03 6.85.49 9.92 2.98 2.59 2.1 3.3 4.94 3.64 6.93.34 2 .77 6.53.43 10.08-3.85 40.48-20.52 138.71-29 184.05-3.59 19.19-10.66 25.62-17.5 26.25-14.86 1.37-26.15-9.83-40.55-19.27-22.53-14.76-35.26-23.96-57.13-38.37-25.28-16.66-8.89-25.81 5.51-40.77 3.77-3.92 69.27-63.5 70.54-68.9.16-.68.31-3.2-1.19-4.53s-3.71-.87-5.3-.51c-2.26.51-38.25 24.3-107.98 71.37-10.22 7.02-19.48 10.43-27.77 10.26-9.14-.2-26.72-5.17-39.79-9.42-16.03-5.21-28.77-7.97-27.66-16.82.57-4.61 6.92-9.32 19.04-14.14z" },
MAX: { chView: "0 0 720 720", chFill: "currentColor", chStroke: "none", chPath: "M350.4,9.6C141.8,20.5,4.1,184.1,12.8,390.4c3.8,90.3,40.1,168,48.7,253.7,2.2,22.2-4.2,49.6,21.4,59.3,31.5,11.9,79.8-8.1,106.2-26.4,9-6.1,17.6-13.2,24.2-22,27.3,18.1,53.2,35.6,85.7,43.4,143.1,34.3,299.9-44.2,369.6-170.3C799.6,291.2,622.5-4.6,350.4,9.6h0ZM269.4,504c-11.3,8.8-22.2,20.8-34.7,27.7-18.1,9.7-23.7-.4-30.5-16.4-21.4-50.9-24-137.6-11.5-190.9,16.8-72.5,72.9-136.3,150-143.1,78-6.9,150.4,32.7,183.1,104.2,72.4,159.1-112.9,316.2-256.4,218.6h0Z" },
EMAIL: { chView: "0 0 24 24", chFill: "none", chStroke: "currentColor", chPath: "M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 7l9 6 9-6" },
WEB: { chView: "0 0 24 24", chFill: "none", chStroke: "currentColor", chPath: ICON.chat },
};
const DOT = { ai: "var(--c-ai)", wait: "#faad14", human: "var(--c-accent)", closed: "var(--c-n5)" };
const GROUP_DOT = { "Операторы": "var(--c-accent)", "Поддержка": "#2aa876", "": "var(--c-n5)" };
const LABEL = { "доставка": "var(--c-accent)", "возврат": "#eb2f96", "кабинет": "#13c2c2", "почта": "var(--c-ai)", "заказ 4390": "#52c41a", "срочно": "#f5222d" };
const PRIO = { high: ["#ff4d4f", "#ff4d4f", "#ff4d4f", "Высокий"], mid: ["#fa8c16", "#fa8c16", "var(--c-n7)", "Средний"], low: ["var(--c-accent)", "var(--c-n7)", "var(--c-n7)", "Низкий"] };
const base = [
{ name: "Мария Соколова", initials: "МС", avatarBg: "#eb6f4b", ch: "TG", agent: "Консультант", group: "Операторы", mode: "ai", time: "17:10", preview: "Хорошо, жду", unread: 2, labels: ["доставка", "срочно"], prio: "high", active: true },
{ name: "Дмитрий Орлов", initials: "", avatarBg: "url(assets/av-1.png) center/cover", ch: "WEB", agent: "Консультант", group: "Операторы", mode: "wait", time: "17:04", preview: "Хочу поговорить с человеком", unread: 1, labels: [], waitShort: "6 мин", prio: "mid" },
{ name: "Ирина Левина", initials: "ИЛ", avatarBg: "#8c6ad1", ch: "EMAIL", agent: "Поддержка сайта", group: "Поддержка", mode: "human", time: "16:48", preview: "Проверил, заказ уже передан в службу доставки…", unread: 0, fromUs: true, labels: ["заказ 4390", "почта"], prio: "high" },
{ name: "Артём Гусев", initials: "АГ", avatarBg: "#2aa876", ch: "MAX", agent: "Консультант", group: "", mode: "ai", time: "16:31", preview: "Спасибо, понял", unread: 0, labels: [] },
{ name: "Гость · 8f3a12", initials: "Г", avatarBg: "var(--c-n4)", ch: "WEB", agent: "Поддержка сайта", group: "Поддержка", mode: "human", time: "15:57", preview: "Анна Ким: Сейчас уточню у коллег", unread: 0, fromUs: true, labels: ["кабинет"], prio: "low" },
{ name: "Ольга Мельник", initials: "ОМ", avatarBg: "#d1548c", ch: "TG", agent: "Консультант", group: "Операторы", mode: "wait", time: "15:20", preview: "Голосовое сообщение · 0:14", unread: 3, labels: ["возврат"], waitShort: "1 ч 50 мин" },
{ name: "Сергей Крылов", initials: "", avatarBg: "url(assets/av-3.png) center/cover", ch: "TG", agent: "Консультант", group: "Операторы", mode: "closed", time: "вчера", preview: "Готово, адрес обновлён. Хорошего дня!", unread: 0, fromUs: true, labels: [] },
];
const dialogs = base.map((d) => {
const p = PRIO[d.prio];
return { ...d, ...CH_ICON[d.ch], dot: DOT[d.mode], groupDot: GROUP_DOT[d.group], labels: d.labels.map((text) => ({ text, color: LABEL[text] || "var(--c-n4)" })),
hasBadges: !!(d.group || d.labels.length || d.waitShort), bg: d.active ? "var(--c-n9)" : "transparent",
previewColor: d.unread ? "var(--c-n2)" : "var(--c-n4)", previewWeight: d.unread ? 500 : 400,
prioIcon: !!p, prioLabel: p ? p[3] : "", pb1: p ? p[0] : "", pb2: p ? p[1] : "", pb3: p ? p[2] : "" };
});
const tabs = [{ label: "Все", on: true }, { label: "Мои" }, { label: "Ждут оператора", count: 2 }].map((t) => ({ ...t, line: t.on ? "var(--c-accent-text)" : "transparent", color: t.on ? "var(--c-accent-text)" : "var(--c-n4)", weight: t.on ? 600 : 500 }));
const item = (label, count, extra, on) => ({ label, count, ...extra, bg: on ? "var(--c-accent-bg)" : "transparent", color: on ? "var(--c-accent-text)" : "var(--c-n2)", weight: on ? 600 : 500, countColor: on ? "var(--c-accent-text)" : "var(--c-n5)" });
const scopes = [
{ items: [item("Все диалоги", 7, { inbox: true }, true)] },
{ head: "Группы", items: [item("Операторы", 4, { dot: GROUP_DOT["Операторы"] }), item("Поддержка", 2, { dot: GROUP_DOT["Поддержка"] }), item("Без группы", 1, { dot: GROUP_DOT[""] })] },
{ head: "Агенты", items: [item("Консультант", 5, { robot: true, avBg: "color-mix(in srgb,var(--c-ai) 14%,var(--c-surface))", avColor: "var(--c-ai)" }), item("Поддержка сайта", 2, { robot: true, avBg: "color-mix(in srgb,#0f9b8e 14%,var(--c-surface))", avColor: "#0f9b8e" })] },
{ head: "Ответственный", items: [item("Елена Кузнецова · вы", 3, { photo: "url(assets/av-4.png) center/cover" }), item("Анна Ким", 1, { photo: "url(assets/av-2.png) center/cover" })] },
];
const clientBubble = (text, time) => ({ text, time, dir: "row", align: "left", showAv: false, bg: "var(--c-msg-in)", radius: "4px 14px 14px 14px" });
const aiBubble = (text, time) => ({ text, time, dir: "row-reverse", align: "right", showAv: true, read: true, actor: "AI · Консультант", avBg: "color-mix(in srgb,var(--c-ai) 10%,var(--c-surface))", avBorder: "color-mix(in srgb,var(--c-ai) 28%,var(--c-surface))", avColor: "var(--c-ai)", bg: "color-mix(in srgb,var(--c-ai) 16%,var(--c-feed))", radius: "14px 4px 14px 14px" });
const messages = [
clientBubble("Здравствуйте! Заказ 4471 — когда доставка? Обещали до среды.", "16:58"),
aiBubble("Здравствуйте, Мария. Заказ 4471 собран и передан курьеру, доставка сегодня до 20:00. Курьер позвонит за час.", "16:59"),
clientBubble("А можно перенести на завтра утро? Сегодня меня не будет дома.", "17:06"),
aiBubble("Конечно. Перенесла доставку на завтра, 10:00–13:00. Подтвердите, пожалуйста, что интервал подходит.", "17:07"),
clientBubble("Хорошо, жду", "17:10"),
];
const frames = [
{ id: "a1", badge: "A1", title: "Владелец · Чат", note: "стартовый экран; навигация 6 пунктов; блок «Запуск» 2 из 3; переключатель охвата открыт; inline-подсказка первого визита", role: "Владелец · e.kuznetsova@atelie-nord.ru", userName: "Елена Кузнецова", userPhoto: "url(assets/av-4.png) center/cover", nav: nav("chat"), showLaunch: true, scope: "Все диалоги", total: "7", scopeOpen: true, scopeBg: "var(--c-n9)", scopes, showHint: true, tabs, dialogs, messages },
{ id: "a2", badge: "A2", title: "Админ · Чат, рабочий режим", note: "запуск завершён и скрыт, подсказка закрыта; от владельца отличается только подписью роли", role: "Администратор · a.kim@atelie-nord.ru", userName: "Анна Ким", userPhoto: "url(assets/av-2.png) center/cover", nav: nav("chat"), showLaunch: false, scope: "Операторы", total: "4", scopeOpen: false, scopeBg: "transparent", scopes, showHint: false, tabs, dialogs: dialogs.filter((d) => d.group === "Операторы"), messages },
];
return { themeVars, frames };
}
}
</script>
</body>
</html>
@@ -1,879 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
a{color:inherit;text-decoration:none;}
a:hover{opacity:0.85;}
*{scrollbar-width:none;}
*::-webkit-scrollbar{display:none;width:0;height:0;}
</style>
</helmet>
<section style="{{ themeVars }}min-height:100vh;background:var(--c-canvas);padding:32px 40px 60px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:var(--c-n1);">
<div style="display:flex;align-items:baseline;gap:14px;margin-bottom:6px;">
<h1 style="margin:0;font-size:22px;font-weight:700;letter-spacing:-0.02em;color:var(--c-n1);">Chatbolls · База знаний</h1>
<span style="font-size:13px;color:var(--c-n4);">design baseline · OWNER = ADMIN · SPEC-HUB-0026 §9, SPEC-HUB-0029 §7</span>
</div>
<p style="margin:0 0 28px;font-size:13px;color:var(--c-n3);max-width:1040px;line-height:1.55;">Внутренняя база знаний агентов сверстана как карточка портала: дерево категорий слева, таблица материалов справа, шапка с состоянием индекса. Редактор знания — тот же полноэкранный split, что и у статьи портала. Отделы упразднены (ADR-0041), поэтому область доступности заменена состоянием «в ответах агента» и явным списком агентов.</p>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start;">
<sc-for list="{{ frames }}" as="f" hint-placeholder-count="9">
<div id="{{ f.id }}" style="flex:none;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11px;font-weight:700;">{{ f.id }}</span>
<strong style="font-size:13.5px;color:var(--c-n1);">{{ f.title }}</strong>
<span style="font-size:12px;color:var(--c-n4);">{{ f.note }}</span>
</div>
<div data-screen-label="{{ f.id }}" style="width:1440px;height:880px;display:flex;overflow:hidden;background:var(--c-surface);border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);position:relative;">
<aside style="position:relative;width:220px;flex:none;background:var(--c-surface2);border-right:1px solid var(--c-n8);display:flex;flex-direction:column;min-height:0;">
<div style="flex:none;height:56px;display:flex;align-items:center;gap:10px;padding:14px 14px 10px;">
<span style="width:28px;height:28px;flex:none;display:flex;align-items:center;justify-content:center;border-radius:8px;color:var(--c-n1);"><svg viewBox="0 0 100 100" width="26" height="26" fill="currentColor"><defs><mask id="lg-kb"><rect width="100" height="100" fill="#fff"/><circle cx="50" cy="24.54" r="9" fill="#000"/><circle cx="50" cy="75.46" r="9" fill="#000"/></mask></defs><g mask="url(#lg-kb)"><circle cx="29" cy="50" r="24"/><circle cx="71" cy="50" r="24"/><rect x="44.2" y="31.4" width="11.6" height="37.2"/></g></svg></span>
<button style="flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:6px;height:32px;padding:0 6px 0 8px;border:none;border-radius:8px;background:transparent;color:var(--c-n1);font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Ателье Норд</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
</div>
<nav style="flex:none;padding:4px 10px 8px;display:flex;flex-direction:column;gap:2px;">
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="7">
<button style="display:flex;align-items:center;gap:10px;height:36px;padding:0 10px;border:none;border-radius:8px;background:{{ n.bg }};color:{{ n.color }};font-size:13.5px;font-weight:{{ n.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n8);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ n.icon }}"/></svg><span style="flex:1;">{{ n.label }}</span><sc-if value="{{ n.badge }}" hint-placeholder-val="{{ false }}"><b style="min-width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;padding:0 6px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);color:var(--c-warn-text);font-size:11px;font-weight:700;">{{ n.badge }}</b></sc-if></button>
</sc-for>
</nav>
<div style="flex:1;"></div>
<div style="flex:none;display:flex;align-items:center;gap:10px;padding:12px 14px;border-top:1px solid var(--c-n8);">
<span style="position:relative;flex:none;display:flex;"><span style="width:34px;height:34px;border-radius:50%;background:url(assets/av-4.png) center/cover;"></span><i style="position:absolute;right:-1px;bottom:-1px;width:10px;height:10px;border:2px solid var(--c-surface2);border-radius:50%;background:#52c41a;"></i></span>
<div style="flex:1;min-width:0;line-height:1.2;"><strong style="display:block;font-size:13px;font-weight:600;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Елена Кузнецова</strong><small style="display:block;font-size:11.5px;color:var(--c-n4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Владелец · e.kuznetsova@atelie-nord.ru</small></div>
<button title="Меню пользователя" style="width:28px;height:28px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</aside>
<!-- ===== БИБЛИОТЕКА (дерево + таблица) ===== -->
<sc-if value="{{ f.isLibrary }}" hint-placeholder-val="{{ false }}">
<section style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;background:var(--c-feed);position:relative;">
<div style="flex:none;padding:18px 28px 16px;background:var(--c-surface);border-bottom:1px solid var(--c-n8);">
<div style="max-width:1500px;display:flex;align-items:flex-start;gap:20px;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:11px;margin-bottom:8px;">
<h2 style="margin:0;font-size:21px;font-weight:700;letter-spacing:-0.015em;color:var(--c-n1);">База знаний</h2>
<span style="display:inline-flex;align-items:center;gap:6px;height:23px;padding:0 9px;border-radius:6px;background:var(--c-ok-bg);color:var(--c-ok-text);font-size:11.5px;font-weight:600;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>Индекс актуален</span>
<span style="font-size:12.5px;color:var(--c-n4);">9 категорий · 84 знания · 1 240 фрагментов</span>
</div>
<p style="margin:0;font-size:13px;color:var(--c-n3);max-width:760px;line-height:1.5;">Материалы для AI-агентов: регламенты, прайсы, скрипты. Агент отвечает только по включённым знаниям, которые к нему прикреплены. Клиент их не видит — публичное живёт в порталах.</p>
</div>
<div style="flex:none;display:flex;align-items:center;gap:8px;padding-top:4px;">
<button style="display:flex;align-items:center;gap:7px;height:34px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-n6);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M7 10l5 5 5-5M4 20h16"/></svg>Импорт YAML</button>
<button style="display:flex;align-items:center;gap:7px;height:34px;padding:0 14px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Добавить знание</button>
<button title="Ещё" style="width:34px;height:34px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="border-color:var(--c-n6);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</div>
</div>
<div style="flex:1;min-height:0;display:flex;">
<div style="width:260px;flex:none;display:flex;flex-direction:column;min-height:0;background:var(--c-surface);border-right:1px solid var(--c-n8);">
<div style="flex:none;display:flex;align-items:center;justify-content:space-between;padding:15px 14px 8px;"><span style="font-size:11.5px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);">КАТЕГОРИИ</span><button style="border:none;background:transparent;padding:0;color:var(--c-accent-text);font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;">Управлять</button></div>
<nav style="flex:1;min-height:0;overflow-y:auto;padding:0 8px 12px;display:flex;flex-direction:column;gap:1px;">
<sc-for list="{{ f.cats }}" as="c" hint-placeholder-count="9">
<button style="display:flex;align-items:center;gap:8px;min-height:32px;padding:0 9px 0 {{ c.pad }};border:none;border-radius:8px;background:{{ c.bg }};color:{{ c.color }};font-size:13px;font-weight:{{ c.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);">
<sc-if value="{{ c.icon }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ c.icon }}"/></svg></sc-if>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ c.label }}</span>
<small style="font-size:11.5px;font-weight:600;color:{{ c.countColor }};">{{ c.count }}</small>
</button>
</sc-for>
</nav>
<div style="flex:none;padding:10px 14px;border-top:1px solid var(--c-n8);">
<button style="display:flex;align-items:center;gap:7px;width:100%;height:32px;padding:0 10px;border:1px dashed var(--c-n6);border-radius:8px;background:transparent;color:var(--c-n3);font-size:12.5px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-accent);color:var(--c-accent-text);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Добавить категорию</button>
</div>
</div>
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow-y:auto;">
<div style="width:100%;max-width:1240px;margin:0 auto;display:flex;flex-direction:column;min-height:100%;">
<div style="flex:none;display:flex;align-items:center;gap:10px;padding:14px 24px;border-bottom:1px solid var(--c-n8);background:var(--c-surface);">
<label style="display:flex;align-items:center;gap:8px;width:280px;height:34px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><span style="font-size:13px;color:var(--c-n5);">Поиск по заголовку и описанию</span></label>
<label style="display:flex;align-items:center;gap:7px;height:34px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;font-size:13px;color:var(--c-n2);"><span style="color:var(--c-n4);">В ответах:</span>Все<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg></label>
<label style="display:flex;align-items:center;gap:7px;height:34px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;font-size:13px;color:var(--c-n2);"><span style="color:var(--c-n4);">Агент:</span>Любой<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg></label>
<span style="flex:1;"></span>
<span style="font-size:12.5px;color:var(--c-n4);white-space:nowrap;">{{ f.scopeNote }}</span>
</div>
<sc-if value="{{ f.isEmptyCat }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:40px;">
<div style="width:480px;text-align:center;">
<span style="width:52px;height:52px;margin:0 auto 15px;border-radius:15px;background:var(--c-n9);color:var(--c-n4);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="25" height="25" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5z"/></svg></span>
<h3 style="margin:0 0 7px;font-size:16.5px;font-weight:700;color:var(--c-n1);">В категории «Обучение» пока нет знаний</h3>
<p style="margin:0 0 18px;font-size:13.5px;color:var(--c-n3);line-height:1.6;">Добавьте материал вручную или загрузите пачкой из YAML. Пустую категорию без вложенных можно удалить в «Управлять».</p>
<div style="display:flex;gap:10px;justify-content:center;">
<button style="height:34px;padding:0 14px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;">Добавить знание</button>
<button style="height:34px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;">Импорт YAML</button>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ f.rows }}" hint-placeholder-val="{{ true }}">
<table style="width:100%;border-collapse:collapse;background:var(--c-surface);">
<thead><tr style="background:var(--c-n10);">
<th style="width:42px;padding:9px 0 9px 24px;border-bottom:1px solid var(--c-n7);"><span style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border:1.5px solid {{ f.allBorder }};border-radius:4px;background:{{ f.allBg }};color:#fff;"><sc-if value="{{ f.allChecked }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round"><path d="m4 12 5 5L20 6"/></svg></sc-if></span></th>
<th style="text-align:left;padding:9px 14px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">ЗНАНИЕ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">КАТЕГОРИЯ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">ФРАГМЕНТЫ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">АГЕНТЫ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">В ОТВЕТАХ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">ОБНОВЛЕНО</th>
<th style="width:44px;border-bottom:1px solid var(--c-n7);"></th>
</tr></thead>
<tbody>
<sc-for list="{{ f.rows }}" as="k" hint-placeholder-count="7">
<tr style="border-bottom:1px solid var(--c-n8);background:{{ k.rowBg }};opacity:{{ k.opacity }};">
<td style="padding:11px 0 11px 24px;vertical-align:top;"><span style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;margin-top:2px;border:1.5px solid {{ k.cbBorder }};border-radius:4px;background:{{ k.cbBg }};color:#fff;"><sc-if value="{{ k.checked }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round"><path d="m4 12 5 5L20 6"/></svg></sc-if></span></td>
<td style="padding:11px 14px;">
<span style="display:flex;align-items:flex-start;gap:10px;">
<i style="width:28px;height:28px;flex:none;border-radius:7px;background:var(--c-n9);color:var(--c-n4);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="{{ k.icon }}"/></svg></i>
<span style="min-width:0;line-height:1.4;">
<strong style="display:block;font-size:13.5px;font-weight:600;color:var(--c-n1);">{{ k.title }}</strong>
<small style="display:block;font-size:11.5px;color:var(--c-n4);max-width:420px;">{{ k.desc }}</small>
</span>
<sc-if value="{{ k.attach }}" hint-placeholder-val="{{ false }}"><small title="Вложения" style="display:inline-flex;align-items:center;gap:3px;margin-top:6px;font-size:11px;color:var(--c-n4);"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.5 12.5 21a4.95 4.95 0 0 1-7-7l8-8a3.5 3.5 0 0 1 5 5l-8 8a1.95 1.95 0 0 1-3-3l7-7"/></svg>{{ k.attach }}</small></sc-if>
</span>
</td>
<td style="padding:11px 12px;font-size:13px;color:var(--c-n2);">{{ k.cat }}<sc-if value="{{ k.catParent }}" hint-placeholder-val="{{ false }}"><small style="display:block;font-size:11px;color:var(--c-n5);">{{ k.catParent }}</small></sc-if></td>
<td style="padding:11px 12px;font-size:13px;color:var(--c-n3);">{{ k.frags }}<sc-if value="{{ k.fragNote }}" hint-placeholder-val="{{ false }}"><small style="display:block;font-size:11px;color:{{ k.fragNoteColor }};">{{ k.fragNote }}</small></sc-if></td>
<td style="padding:11px 12px;"><sc-if value="{{ k.agents }}" hint-placeholder-val="{{ true }}"><span style="display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 8px;border-radius:6px;background:var(--c-n9);color:var(--c-n2);font-size:11.5px;font-weight:600;"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6"/></svg>{{ k.agents }}</span></sc-if><sc-if value="{{ k.noAgents }}" hint-placeholder-val="{{ false }}"><small style="font-size:12px;color:var(--c-n5);">не прикреплено</small></sc-if></td>
<td style="padding:11px 12px;"><span style="display:inline-flex;align-items:center;gap:6px;height:22px;padding:0 9px;border-radius:6px;background:{{ k.stBg }};color:{{ k.stColor }};font-size:11.5px;font-weight:600;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>{{ k.status }}</span></td>
<td style="padding:11px 12px;font-size:12.5px;color:var(--c-n4);">{{ k.updated }}<small style="display:block;font-size:11px;color:var(--c-n5);">{{ k.author }}</small></td>
<td style="padding:11px 12px;position:relative;vertical-align:top;">
<button title="Действия" style="width:28px;height:28px;border:none;border-radius:7px;background:{{ k.menuBg }};color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
<sc-if value="{{ k.menuOpen }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;top:38px;right:14px;z-index:20;width:244px;padding:5px;background:var(--c-surface);border:1px solid var(--c-n7);border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,0.16);">
<sc-for list="{{ rowMenu }}" as="m" hint-placeholder-count="6">
<sc-if value="{{ m.divider }}" hint-placeholder-val="{{ false }}"><div style="height:1px;background:var(--c-n8);margin:5px 4px;"></div></sc-if>
<sc-if value="{{ m.label }}" hint-placeholder-val="{{ true }}">
<button style="display:flex;align-items:center;gap:9px;width:100%;height:32px;padding:0 9px;border:none;border-radius:7px;background:transparent;color:{{ m.color }};font-size:13px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ m.icon }}"/></svg>{{ m.label }}</button>
</sc-if>
</sc-for>
</div>
</sc-if>
</td>
</tr>
</sc-for>
</tbody>
</table>
<div style="flex:none;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:11px 24px;background:var(--c-n10);border-top:1px solid var(--c-n8);">
<span style="font-size:12.5px;color:var(--c-n4);">84 знания · показаны 7 · агент отвечает только по включённым и прикреплённым</span>
<span style="display:flex;align-items:center;gap:6px;">
<b style="min-width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;background:var(--c-accent-bg);color:var(--c-accent-text);font-size:12.5px;">1</b>
<button style="min-width:28px;height:28px;padding:0 8px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-n3);font-size:12.5px;cursor:pointer;font-family:inherit;">2</button>
<button style="min-width:28px;height:28px;padding:0 8px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-n3);font-size:12.5px;cursor:pointer;font-family:inherit;">3</button>
<button style="width:28px;height:28px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-n3);display:flex;align-items:center;justify-content:center;cursor:pointer;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg></button>
</span>
</div>
</sc-if>
</div>
</div>
</div>
<sc-if value="{{ f.isBulk }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;left:50%;transform:translateX(-50%);bottom:22px;z-index:15;display:flex;align-items:center;gap:8px;padding:9px 11px 9px 15px;background:var(--c-n1);border-radius:12px;box-shadow:0 12px 34px rgba(0,0,0,0.28);">
<span style="display:flex;align-items:baseline;gap:7px;margin-right:5px;"><strong style="font-size:13.5px;font-weight:700;color:var(--c-surface);">Выбрано 3</strong><small style="font-size:11.5px;color:var(--c-n5);">знания</small></span>
<sc-for list="{{ bulkActions }}" as="b" hint-placeholder-count="5">
<button style="display:flex;align-items:center;gap:7px;height:32px;padding:0 12px;border:1px solid {{ b.border }};border-radius:8px;background:{{ b.bg }};color:{{ b.color }};font-size:12.5px;font-weight:{{ b.weight }};cursor:pointer;font-family:inherit;white-space:nowrap;" style-hover="opacity:0.88;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ b.icon }}"/></svg>{{ b.label }}</button>
</sc-for>
<i style="width:1px;height:22px;background:rgba(255,255,255,0.18);margin:0 3px;"></i>
<button title="Снять выделение" style="width:30px;height:30px;border:none;border-radius:8px;background:transparent;color:var(--c-n5);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:rgba(255,255,255,0.1);color:var(--c-surface);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
</div>
</sc-if>
<sc-if value="{{ f.isDialog }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;inset:0;z-index:30;background:rgba(15,17,20,0.42);display:flex;align-items:center;justify-content:center;padding:40px;">
<div style="width:560px;background:var(--c-surface);border-radius:14px;box-shadow:0 24px 60px rgba(0,0,0,0.3);overflow:hidden;">
<div style="padding:20px 22px 14px;border-bottom:1px solid var(--c-n8);">
<div style="display:flex;align-items:flex-start;gap:12px;">
<div style="flex:1;min-width:0;"><h3 style="margin:0 0 4px;font-size:16.5px;font-weight:700;color:var(--c-n1);">Прикрепить к агенту</h3><p style="margin:0;font-size:12.5px;color:var(--c-n3);line-height:1.5;">Выбрано 3 знания. Один агент за операцию — так видно, что именно изменилось.</p></div>
<button title="Закрыть" style="width:28px;height:28px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
</div>
<span style="display:inline-flex;gap:2px;margin-top:13px;padding:2px;border-radius:9px;background:var(--c-n9);">
<sc-for list="{{ attachModes }}" as="m" hint-placeholder-count="2">
<button style="height:30px;padding:0 15px;border:none;border-radius:7px;background:{{ m.bg }};color:{{ m.color }};font-size:12.5px;font-weight:{{ m.weight }};cursor:pointer;font-family:inherit;box-shadow:{{ m.shadow }};">{{ m.label }}</button>
</sc-for>
</span>
</div>
<div style="padding:14px 22px 6px;">
<span style="display:block;margin-bottom:8px;font-size:11.5px;font-weight:600;letter-spacing:0.03em;color:var(--c-n4);">АГЕНТ</span>
<div style="display:flex;flex-direction:column;gap:5px;">
<sc-for list="{{ agentPicks }}" as="a" hint-placeholder-count="3">
<button style="display:flex;align-items:center;gap:11px;padding:9px 11px;border:1.5px solid {{ a.border }};border-radius:10px;background:{{ a.bg }};text-align:left;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-accent);">
<i style="width:16px;height:16px;flex:none;border-radius:50%;border:1.5px solid {{ a.radioBorder }};display:flex;align-items:center;justify-content:center;"><b style="width:8px;height:8px;border-radius:50%;background:{{ a.radioDot }};"></b></i>
<span style="flex:1;min-width:0;line-height:1.35;"><strong style="display:block;font-size:13.5px;font-weight:600;color:var(--c-n1);">{{ a.name }}</strong><small style="font-size:11.5px;color:var(--c-n4);">{{ a.meta }}</small></span>
<small style="height:20px;padding:0 8px;display:inline-flex;align-items:center;border-radius:6px;background:{{ a.tagBg }};color:{{ a.tagColor }};font-size:11px;font-weight:600;white-space:nowrap;">{{ a.tag }}</small>
</button>
</sc-for>
</div>
</div>
<div style="margin:14px 22px 0;padding:11px 13px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);display:flex;gap:10px;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--c-warn-text)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex:none;margin-top:1px;"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16v.01"/></svg>
<span style="font-size:12px;line-height:1.5;color:var(--c-n2);"><b style="font-weight:600;">2 из 3 будут пропущены:</b> «Прайс-лист 2026» уже прикреплён к этому агенту, «Скрипт: недовольная посадка» выключен в ответах. Состав знаний агента не изменится по этим двум.</span>
</div>
<div style="display:flex;align-items:center;gap:9px;padding:16px 22px 18px;">
<span style="font-size:11.5px;color:var(--c-n4);flex:1;line-height:1.45;">Операция атомарна: при недоступном идентификаторе не меняется ничего. Требуется право <code style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">ai.manage</code>.</span>
<button style="height:34px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;">Отмена</button>
<button style="height:34px;padding:0 15px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Прикрепить 1</button>
</div>
</div>
</div>
</sc-if>
</section>
</sc-if>
<!-- ===== КАРТОЧКА ЗНАНИЯ ===== -->
<sc-if value="{{ f.isCard }}" hint-placeholder-val="{{ false }}">
<section style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;background:var(--c-feed);">
<div style="flex:none;padding:18px 28px 16px;background:var(--c-surface);border-bottom:1px solid var(--c-n8);">
<div style="max-width:1500px;">
<div style="display:flex;align-items:center;gap:7px;margin-bottom:9px;font-size:12.5px;color:var(--c-n4);">
<button style="border:none;background:transparent;padding:0;color:var(--c-accent-text);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;">База знаний</button><span>/</span><span>Регламенты</span><span>/</span><span style="color:var(--c-n2);">Пошив</span>
</div>
<div style="display:flex;align-items:flex-start;gap:20px;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:11px;margin-bottom:9px;">
<h2 style="margin:0;font-size:21px;font-weight:700;letter-spacing:-0.015em;color:var(--c-n1);">Срок пошива и переносы примерок</h2>
<span style="display:inline-flex;align-items:center;gap:6px;height:23px;padding:0 9px;border-radius:6px;background:var(--c-ok-bg);color:var(--c-ok-text);font-size:11.5px;font-weight:600;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>В ответах агента</span>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="display:flex;align-items:center;gap:9px;height:32px;padding:0 11px;border:1px solid var(--c-n7);border-radius:9px;background:var(--c-surface2);font-size:12.5px;color:var(--c-n2);">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8 12 3 3 8v8l9 5 9-5zM3 8l9 5 9-5M12 13v8"/></svg>
12 фрагментов · переиндексировано сегодня, 11:24
</span>
<span style="display:flex;align-items:center;gap:9px;height:32px;padding:0 11px;border:1px solid var(--c-n7);border-radius:9px;background:var(--c-surface2);font-size:12.5px;color:var(--c-n2);">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6"/></svg>
3 агента
</span>
<span style="font-size:12px;color:var(--c-n4);">обновила Анна Ким · 5 сентября, 11:20</span>
</div>
</div>
<div style="flex:none;display:flex;align-items:center;gap:8px;padding-top:4px;">
<button style="display:flex;align-items:center;gap:7px;height:34px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-n6);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6"/></svg>Прикрепить к агенту</button>
<button style="display:flex;align-items:center;gap:7px;height:34px;padding:0 14px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>Редактировать</button>
<button title="Ещё" style="width:34px;height:34px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="border-color:var(--c-n6);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</div>
</div>
</div>
<div style="flex:1;min-height:0;display:flex;">
<div style="flex:1;min-width:0;min-height:0;overflow-y:auto;padding:26px 34px 44px;">
<div style="max-width:720px;">
<p style="margin:0 0 20px;font-size:14.5px;line-height:1.7;color:var(--c-n3);">Как агент отвечает на вопросы о сроках: базовые сроки по типам изделий, правила переноса примерки и что делать при задержке мастера.</p>
<h3 style="margin:0 0 10px;font-size:17px;font-weight:700;color:var(--c-n1);">Базовые сроки</h3>
<table style="width:100%;border-collapse:collapse;font-size:13.5px;color:var(--c-n2);margin-bottom:22px;">
<thead><tr><th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--c-n7);font-weight:600;">Изделие</th><th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--c-n7);font-weight:600;">Срок</th><th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--c-n7);font-weight:600;">Примерок</th></tr></thead>
<tbody>
<tr><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">Рубашка</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">10 рабочих дней</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">1</td></tr>
<tr><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">Пальто</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">25 рабочих дней</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">2</td></tr>
<tr><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">Костюм-тройка</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">35 рабочих дней</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">3</td></tr>
</tbody>
</table>
<h3 style="margin:0 0 10px;font-size:17px;font-weight:700;color:var(--c-n1);">Перенос примерки</h3>
<ol style="margin:0 0 20px;padding-left:22px;font-size:14.5px;line-height:1.75;color:var(--c-n2);">
<li>Клиент предупредил за сутки — переносим бесплатно, срок сдвигается на те же дни.</li>
<li>Меньше суток или не пришёл — предлагаем ближайшее окно, срок пересчитывает мастер.</li>
<li>Третий перенос подряд — заказ ставится на паузу до звонка администратора.</li>
</ol>
<blockquote style="margin:0 0 22px;padding:12px 16px;border-left:3px solid var(--c-accent);border-radius:0 8px 8px 0;background:var(--c-accent-bg);font-size:14px;line-height:1.65;color:var(--c-n2);">Агент не называет точную дату сдачи — только диапазон из таблицы и ближайшее окно примерки. Дату подтверждает мастер.</blockquote>
<h3 style="margin:0 0 10px;font-size:17px;font-weight:700;color:var(--c-n1);">Если срок сорван</h3>
<ul style="margin:0;padding-left:22px;font-size:14.5px;line-height:1.75;color:var(--c-n2);">
<li>Извиняемся и называем новый срок одной фразой, без объяснения внутренних причин.</li>
<li>Задержка больше 5 дней — скидка 10 % и передача администратору.</li>
</ul>
</div>
</div>
<div style="width:320px;flex:none;display:flex;flex-direction:column;min-height:0;background:var(--c-surface);border-left:1px solid var(--c-n8);overflow-y:auto;">
<div style="padding:16px 16px 12px;display:flex;flex-direction:column;gap:12px;border-bottom:1px solid var(--c-n8);">
<div><span style="display:block;margin-bottom:5px;font-size:11.5px;font-weight:600;color:var(--c-n4);">КАТЕГОРИЯ</span><span style="display:inline-flex;align-items:center;gap:7px;height:28px;padding:0 10px;border-radius:8px;background:var(--c-n9);font-size:12.5px;color:var(--c-n2);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>Регламенты / Пошив</span></div>
<div style="display:flex;align-items:center;gap:11px;padding:10px 12px;border-radius:10px;background:var(--c-n10);border:1px solid var(--c-n8);">
<span style="flex:1;min-width:0;line-height:1.4;"><strong style="display:block;font-size:12.5px;font-weight:600;color:var(--c-n1);">Участвует в ответах</strong><small style="font-size:11px;color:var(--c-n4);">выключенное знание не попадает в retrieval</small></span>
<span style="flex:none;width:38px;height:22px;border-radius:11px;background:var(--c-accent);position:relative;"><i style="position:absolute;top:2px;right:2px;width:18px;height:18px;border-radius:50%;background:#fff;"></i></span>
</div>
</div>
<div style="padding:14px 16px 12px;border-bottom:1px solid var(--c-n8);">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;"><span style="font-size:11.5px;font-weight:600;color:var(--c-n4);">ВЛОЖЕНИЯ</span><span style="font-size:11.5px;color:var(--c-n5);">ссылки уходят в ответ агента</span></div>
<div style="display:flex;flex-direction:column;gap:6px;">
<sc-for list="{{ cardFiles }}" as="file" hint-placeholder-count="2">
<div style="display:flex;align-items:center;gap:9px;padding:8px 9px;border:1px solid var(--c-n8);border-radius:9px;">
<i style="width:28px;height:28px;flex:none;border-radius:7px;background:var(--c-n9);color:var(--c-n3);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="{{ file.icon }}"/></svg></i>
<span style="flex:1;min-width:0;line-height:1.3;"><strong style="display:block;font-size:12.5px;font-weight:600;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ file.name }}</strong><small style="font-size:11px;color:var(--c-n4);">{{ file.meta }}</small></span>
<button title="Копировать публичную ссылку" style="width:26px;height:26px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></svg></button>
</div>
</sc-for>
</div>
</div>
<div style="padding:14px 16px 12px;border-bottom:1px solid var(--c-n8);">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;"><span style="font-size:11.5px;font-weight:600;color:var(--c-n4);">АГЕНТЫ</span><button style="border:none;background:transparent;padding:0;color:var(--c-accent-text);font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;">Изменить</button></div>
<div style="display:flex;flex-direction:column;gap:5px;">
<sc-for list="{{ cardAgents }}" as="a" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:9px;padding:7px 9px;border-radius:9px;background:var(--c-n10);">
<i style="width:26px;height:26px;flex:none;border-radius:7px;background:{{ a.tileBg }};color:{{ a.tileColor }};display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6"/></svg></i>
<span style="flex:1;min-width:0;line-height:1.3;"><strong style="display:block;font-size:12.5px;font-weight:600;color:var(--c-n1);">{{ a.name }}</strong><small style="font-size:11px;color:var(--c-n4);">{{ a.meta }}</small></span>
<small style="height:19px;padding:0 7px;display:inline-flex;align-items:center;border-radius:5px;background:{{ a.tagBg }};color:{{ a.tagColor }};font-size:10.5px;font-weight:600;">{{ a.tag }}</small>
</div>
</sc-for>
</div>
</div>
<div style="padding:14px 16px 20px;">
<span style="display:block;margin-bottom:8px;font-size:11.5px;font-weight:600;color:var(--c-n4);">ИНДЕКСАЦИЯ</span>
<div style="padding:11px 12px;border-radius:10px;background:var(--c-n10);border:1px solid var(--c-n8);">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:6px;"><strong style="font-size:19px;font-weight:700;color:var(--c-n1);">12</strong><span style="font-size:12px;color:var(--c-n3);">фрагментов · 4 280 знаков</span></div>
<p style="margin:0 0 9px;font-size:11.5px;line-height:1.5;color:var(--c-n4);">Фрагменты пересобираются при сохранении. Пока идёт пересборка, агент отвечает по прежней версии.</p>
<button style="display:flex;align-items:center;gap:7px;height:30px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-n6);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3"/></svg>Переиндексировать</button>
</div>
</div>
</div>
</div>
</section>
</sc-if>
<!-- ===== РЕДАКТОР ЗНАНИЯ ===== -->
<sc-if value="{{ f.isEditor }}" hint-placeholder-val="{{ false }}">
<section style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;background:var(--c-feed);">
<div style="flex:none;display:flex;align-items:center;gap:14px;height:54px;padding:0 20px;background:var(--c-surface);border-bottom:1px solid var(--c-n8);">
<button style="display:flex;align-items:center;gap:7px;height:30px;padding:0 10px 0 7px;border:none;border-radius:7px;background:transparent;color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>База знаний</button>
<span style="font-size:12.5px;color:var(--c-n4);white-space:nowrap;">Регламенты / Пошив</span>
<span style="flex:1;"></span>
<span style="display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 9px;border-radius:6px;background:{{ f.indexBg }};color:{{ f.indexColor }};font-size:11.5px;font-weight:600;white-space:nowrap;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>{{ f.indexLabel }}</span>
<span style="display:flex;align-items:center;gap:2px;padding:2px;border-radius:8px;background:var(--c-n9);">
<sc-for list="{{ f.modes }}" as="m" hint-placeholder-count="3">
<button title="{{ m.title }}" style="display:flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:none;border-radius:6px;background:{{ m.bg }};color:{{ m.color }};font-size:12.5px;font-weight:{{ m.weight }};cursor:pointer;font-family:inherit;box-shadow:{{ m.shadow }};white-space:nowrap;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ m.icon }}"/></svg>{{ m.label }}</button>
</sc-for>
</span>
<button style="height:32px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-n6);">Отменить правки</button>
<button style="display:flex;align-items:center;gap:7px;height:32px;padding:0 14px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="m4 12 5 5L20 6"/></svg>Сохранить и переиндексировать</button>
</div>
<div style="flex:1;min-height:0;display:flex;">
<div style="flex:1;min-width:0;display:flex;flex-direction:column;border-right:1px solid var(--c-n8);background:var(--c-surface);">
<div style="flex:none;padding:18px 22px 10px;">
<input value="Срок пошива и переносы примерок" style="width:100%;border:none;outline:none;background:transparent;padding:0;font-family:inherit;font-size:21px;font-weight:700;letter-spacing:-0.01em;color:var(--c-n1);">
<div style="display:flex;align-items:center;gap:8px;margin-top:7px;font-size:12px;color:var(--c-n4);"><span>Заголовок цитируется агентом в ответе клиенту — пишите его как ответ, не как папку.</span></div>
</div>
<div style="flex:none;display:flex;align-items:center;gap:2px;padding:6px 14px;border-top:1px solid var(--c-n8);border-bottom:1px solid var(--c-n8);background:var(--c-surface2);">
<sc-for list="{{ mdTools }}" as="t" hint-placeholder-count="12">
<sc-if value="{{ t.divider }}" hint-placeholder-val="{{ false }}"><i style="width:1px;height:20px;background:var(--c-n7);margin:0 5px;"></i></sc-if>
<sc-if value="{{ t.title }}" hint-placeholder-val="{{ true }}">
<button title="{{ t.title }}" style="min-width:30px;height:30px;padding:0 7px;border:none;border-radius:7px;background:{{ t.bg }};color:{{ t.color }};display:flex;align-items:center;justify-content:center;gap:5px;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n8);">
<sc-if value="{{ t.text }}" hint-placeholder-val="{{ false }}"><span>{{ t.text }}</span></sc-if>
<sc-if value="{{ t.icon }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ t.icon }}"/></svg></sc-if>
</button>
</sc-if>
</sc-for>
<span style="flex:1;"></span>
<span style="font-size:11.5px;color:var(--c-n4);white-space:nowrap;">GFM</span>
</div>
<div style="flex:1;min-height:0;overflow:hidden;position:relative;">
<pre style="margin:0;height:100%;max-width:900px;padding:16px 22px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;line-height:1.75;color:var(--c-n2);white-space:pre-wrap;">{{ mdSource }}</pre>
<sc-if value="{{ f.dropping }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;inset:10px;border:2px dashed var(--c-accent);border-radius:12px;background:var(--c-accent-bg);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;">
<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="var(--c-accent-text)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
<strong style="font-size:15px;color:var(--c-accent-text);">Отпустите, чтобы приложить к знанию</strong>
<span style="font-size:12.5px;color:var(--c-n3);">Файл станет вложением и ссылкой в тексте — агент отдаёт её клиенту. PNG, JPG, PDF, DOCX, XLSX · до 25 МБ</span>
</div>
</sc-if>
</div>
<div style="flex:none;display:flex;align-items:center;gap:14px;padding:8px 22px;border-top:1px solid var(--c-n8);background:var(--c-surface2);font-size:11.5px;color:var(--c-n4);">
<span>4 280 знаков · ≈12 фрагментов</span><span>Строка 22, столбец 18</span><span style="flex:1;"></span><span>{{ f.saveNote }}</span>
</div>
</div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;background:var(--c-feed);">
<div style="flex:none;display:flex;align-items:center;gap:10px;height:37px;padding:0 18px;border-bottom:1px solid var(--c-n8);background:var(--c-surface2);">
<span style="font-size:11.5px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);">ПРЕДПРОСМОТР · ВНУТРЕННИЙ МАТЕРИАЛ</span>
<span style="flex:1;"></span>
<button title="Как это цитирует агент" style="display:flex;align-items:center;gap:6px;height:24px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--c-n3);font-size:12px;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>Как ответит агент</button>
</div>
<div style="flex:1;min-height:0;overflow-y:auto;padding:26px 34px 40px;">
<div style="max-width:620px;margin:0 auto;">
<h1 style="margin:0 0 8px;font-size:24px;font-weight:700;letter-spacing:-0.02em;line-height:1.25;color:var(--c-n1);">Срок пошива и переносы примерок</h1>
<p style="margin:0 0 20px;font-size:14.5px;line-height:1.7;color:var(--c-n3);">Базовые сроки по типам изделий, правила переноса примерки и что делать при задержке.</p>
<h2 style="margin:0 0 10px;font-size:18px;font-weight:700;color:var(--c-n1);">Базовые сроки</h2>
<table style="width:100%;border-collapse:collapse;font-size:13.5px;color:var(--c-n2);margin-bottom:20px;">
<thead><tr><th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--c-n7);font-weight:600;">Изделие</th><th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--c-n7);font-weight:600;">Срок</th><th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--c-n7);font-weight:600;">Примерок</th></tr></thead>
<tbody>
<tr><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">Рубашка</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">10 рабочих дней</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">1</td></tr>
<tr><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">Пальто</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">25 рабочих дней</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">2</td></tr>
</tbody>
</table>
<h2 style="margin:0 0 10px;font-size:18px;font-weight:700;color:var(--c-n1);">Перенос примерки</h2>
<ol style="margin:0 0 20px;padding-left:22px;font-size:14.5px;line-height:1.75;color:var(--c-n2);">
<li>Предупредил за сутки — переносим бесплатно.</li>
<li>Меньше суток — ближайшее свободное окно.</li>
<li>Третий перенос — пауза до звонка администратора.</li>
</ol>
<blockquote style="margin:0 0 20px;padding:12px 16px;border-left:3px solid var(--c-accent);border-radius:0 8px 8px 0;background:var(--c-accent-bg);font-size:14px;line-height:1.65;color:var(--c-n2);">Агент не называет точную дату сдачи — только диапазон и ближайшее окно примерки.</blockquote>
<div style="display:flex;align-items:center;gap:10px;padding:11px 13px;border:1px solid var(--c-n8);border-radius:10px;background:var(--c-surface);">
<i style="width:30px;height:30px;flex:none;border-radius:8px;background:var(--c-n9);color:var(--c-n3);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5"/></svg></i>
<span style="flex:1;min-width:0;line-height:1.35;"><strong style="display:block;font-size:13px;font-weight:600;color:var(--c-n1);">Регламент сроков 2026.pdf</strong><small style="font-size:11.5px;color:var(--c-n4);">вложение · ссылку агент отдаёт клиенту</small></span>
</div>
</div>
</div>
</div>
<div style="width:300px;flex:none;display:flex;flex-direction:column;min-height:0;background:var(--c-surface);border-left:1px solid var(--c-n8);overflow-y:auto;">
<div style="padding:16px 16px 10px;display:flex;flex-direction:column;gap:12px;">
<label style="display:block;"><span style="display:block;margin-bottom:5px;font-size:11.5px;font-weight:600;color:var(--c-n4);">Категория</span><span style="display:flex;align-items:center;justify-content:space-between;height:34px;padding:0 10px;border:1px solid var(--c-n7);border-radius:8px;font-size:13px;color:var(--c-n1);">Регламенты / Пошив<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg></span></label>
<label style="display:block;"><span style="display:block;margin-bottom:5px;font-size:11.5px;font-weight:600;color:var(--c-n4);">Краткое описание <small style="font-weight:500;color:var(--c-n5);">· помогает поиску и агенту</small></span><span style="display:block;min-height:58px;padding:8px 10px;border:1px solid var(--c-n7);border-radius:8px;font-size:12.5px;line-height:1.5;color:var(--c-n2);">Сроки по типам изделий, правила переноса примерки, действия при задержке.</span></label>
<div style="display:flex;align-items:center;gap:11px;padding:10px 12px;border-radius:10px;background:var(--c-n10);border:1px solid var(--c-n8);">
<span style="flex:1;min-width:0;line-height:1.4;"><strong style="display:block;font-size:12.5px;font-weight:600;color:var(--c-n1);">Участвует в ответах</strong><small style="font-size:11px;color:var(--c-n4);">можно выключить, не удаляя</small></span>
<span style="flex:none;width:38px;height:22px;border-radius:11px;background:var(--c-accent);position:relative;"><i style="position:absolute;top:2px;right:2px;width:18px;height:18px;border-radius:50%;background:#fff;"></i></span>
</div>
</div>
<div style="padding:6px 16px 10px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:7px;"><span style="font-size:11.5px;font-weight:600;color:var(--c-n4);">ВЛОЖЕНИЯ</span><button style="border:none;background:transparent;padding:0;color:var(--c-accent-text);font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;">Загрузить</button></div>
<div style="border:1px dashed {{ f.dropBorder }};border-radius:10px;background:{{ f.dropBg }};padding:12px;text-align:center;">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="{{ f.dropIcon }}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
<p style="margin:5px 0 2px;font-size:12.5px;font-weight:600;color:var(--c-n2);">Перетащите файлы</p>
<p style="margin:0;font-size:11.5px;color:var(--c-n4);">или <span style="color:var(--c-accent-text);font-weight:600;">выберите на диске</span> · до 25 МБ</p>
</div>
<div style="display:flex;flex-direction:column;gap:6px;margin-top:10px;">
<sc-for list="{{ f.files }}" as="file" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:9px;padding:8px 9px;border:1px solid {{ file.border }};border-radius:9px;background:{{ file.bg }};">
<i style="width:28px;height:28px;flex:none;border-radius:7px;background:var(--c-n9);color:var(--c-n3);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="{{ file.icon }}"/></svg></i>
<span style="flex:1;min-width:0;line-height:1.3;"><strong style="display:block;font-size:12.5px;font-weight:600;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ file.name }}</strong><small style="font-size:11px;color:{{ file.metaColor }};">{{ file.meta }}</small>
<sc-if value="{{ file.progress }}" hint-placeholder-val="{{ false }}"><span style="display:block;height:3px;margin-top:5px;border-radius:2px;background:var(--c-n8);overflow:hidden;"><i style="display:block;height:3px;width:{{ file.progress }};background:var(--c-accent);"></i></span></sc-if>
</span>
<sc-if value="{{ file.done }}" hint-placeholder-val="{{ true }}">
<button title="Вставить ссылку в текст" style="width:26px;height:26px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-accent-text);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-accent-bg);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
<button title="Копировать Markdown" style="width:26px;height:26px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></svg></button>
</sc-if>
</div>
</sc-for>
</div>
</div>
<div style="padding:6px 16px 20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:7px;"><span style="font-size:11.5px;font-weight:600;color:var(--c-n4);">АГЕНТЫ</span><small style="font-size:11.5px;color:var(--c-n5);">меняется массовым действием</small></div>
<div style="display:flex;flex-direction:column;gap:5px;">
<sc-for list="{{ cardAgents }}" as="a" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:9px;padding:7px 9px;border-radius:9px;background:var(--c-n10);">
<i style="width:24px;height:24px;flex:none;border-radius:7px;background:{{ a.tileBg }};color:{{ a.tileColor }};display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6"/></svg></i>
<span style="flex:1;min-width:0;line-height:1.25;"><strong style="display:block;font-size:12px;font-weight:600;color:var(--c-n1);">{{ a.name }}</strong><small style="font-size:10.5px;color:var(--c-n4);">{{ a.meta }}</small></span>
</div>
</sc-for>
</div>
<p style="margin:9px 0 0;font-size:11.5px;line-height:1.5;color:var(--c-n4);">Прикрепление и открепление — только через «Прикрепить к агенту»: операция атомарна и пишется в журнал.</p>
</div>
</div>
</div>
</section>
</sc-if>
<!-- ===== УПРАВЛЕНИЕ КАТЕГОРИЯМИ ===== -->
<sc-if value="{{ f.isCats }}" hint-placeholder-val="{{ false }}">
<section style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;background:var(--c-feed);">
<div style="flex:none;padding:18px 28px 16px;background:var(--c-surface);border-bottom:1px solid var(--c-n8);">
<div style="display:flex;align-items:center;gap:7px;margin-bottom:8px;font-size:12.5px;color:var(--c-n4);"><button style="border:none;background:transparent;padding:0;color:var(--c-accent-text);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;">База знаний</button><span>/</span><span style="color:var(--c-n2);">Категории</span></div>
<h2 style="margin:0 0 5px;font-size:20px;font-weight:700;color:var(--c-n1);">Категории</h2>
<p style="margin:0;font-size:13px;color:var(--c-n3);">Дерево до трёх уровней. Перетащите строку, чтобы сменить родителя или порядок.</p>
</div>
<div style="flex:1;min-height:0;overflow-y:auto;padding:24px 34px 40px;">
<div style="display:flex;gap:22px;align-items:flex-start;max-width:1180px;">
<div style="flex:1;min-width:0;background:var(--c-surface);border:1px solid var(--c-n7);border-radius:10px;overflow:hidden;">
<sc-for list="{{ catRows }}" as="c" hint-placeholder-count="9">
<div style="display:flex;align-items:center;gap:10px;padding:0 12px 0 {{ c.pad }};height:44px;border-bottom:1px solid var(--c-n8);background:{{ c.bg }};">
<span title="Перетащить" style="width:18px;flex:none;color:var(--c-n5);display:flex;cursor:grab;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 6h.01M9 12h.01M9 18h.01M15 6h.01M15 12h.01M15 18h.01"/></svg></span>
<sc-if value="{{ c.editing }}" hint-placeholder-val="{{ false }}">
<input value="Возвраты и переделки" style="flex:1;min-width:0;height:30px;padding:0 10px;border:1.5px solid var(--c-accent);border-radius:7px;background:var(--c-surface);font-family:inherit;font-size:13.5px;color:var(--c-n1);outline:none;">
<button style="height:30px;padding:0 12px;border:none;border-radius:7px;background:var(--c-accent);color:#fff;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;">Готово</button>
<button style="height:30px;padding:0 11px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-n3);font-size:12.5px;cursor:pointer;font-family:inherit;">Отмена</button>
</sc-if>
<sc-if value="{{ c.name }}" hint-placeholder-val="{{ true }}">
<span style="flex:1;min-width:0;display:flex;align-items:center;gap:8px;">
<strong style="font-size:13.5px;font-weight:{{ c.weight }};color:var(--c-n1);">{{ c.name }}</strong>
<sc-if value="{{ c.tag }}" hint-placeholder-val="{{ false }}"><small style="height:19px;padding:0 7px;display:inline-flex;align-items:center;border-radius:5px;background:var(--c-n9);color:var(--c-n4);font-size:10.5px;font-weight:600;">{{ c.tag }}</small></sc-if>
</span>
<small style="font-size:12px;color:var(--c-n4);white-space:nowrap;">{{ c.count }}</small>
<span style="display:flex;align-items:center;gap:3px;">
<button title="Добавить вложенную" style="width:28px;height:28px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
<button title="Переименовать" style="width:28px;height:28px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg></button>
<button title="{{ c.delTitle }}" disabled="{{ c.delDisabled }}" style="width:28px;height:28px;border:none;border-radius:7px;background:transparent;color:{{ c.delColor }};display:flex;align-items:center;justify-content:center;cursor:{{ c.delCursor }};"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/></svg></button>
</span>
</sc-if>
</div>
</sc-for>
<div style="padding:11px 12px;">
<button style="display:flex;align-items:center;gap:7px;height:32px;padding:0 12px;border:1px dashed var(--c-n6);border-radius:8px;background:transparent;color:var(--c-n3);font-size:12.5px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-accent);color:var(--c-accent-text);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Категория верхнего уровня</button>
</div>
</div>
<div style="width:320px;flex:none;display:flex;flex-direction:column;gap:12px;">
<div style="padding:14px 16px;background:var(--c-surface);border:1px solid var(--c-n7);border-radius:10px;">
<strong style="display:block;margin-bottom:8px;font-size:13px;font-weight:700;color:var(--c-n1);">Правила</strong>
<ul style="margin:0;padding-left:18px;font-size:12.5px;line-height:1.65;color:var(--c-n3);">
<li>Имя уникально внутри родителя.</li>
<li>Категорию нельзя перенести внутрь себя.</li>
<li>Удаление — только для пустой категории без вложенных.</li>
<li>«Без категории» системная: не переименовывается и не удаляется.</li>
</ul>
</div>
<div style="padding:12px 14px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);border-radius:10px;display:flex;gap:10px;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--c-warn-text)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex:none;margin-top:1px;"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16v.01"/></svg>
<span style="font-size:12px;line-height:1.5;color:var(--c-n2);">«Прайсы и оплата» удалить нельзя: 12 знаний и 2 вложенные категории. Сначала перенесите содержимое.</span>
</div>
</div>
</div>
</div>
</section>
</sc-if>
<!-- ===== ИМПОРТ YAML ===== -->
<sc-if value="{{ f.isImport }}" hint-placeholder-val="{{ false }}">
<section style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;background:var(--c-feed);">
<div style="flex:none;padding:18px 28px 16px;background:var(--c-surface);border-bottom:1px solid var(--c-n8);">
<div style="display:flex;align-items:center;gap:7px;margin-bottom:8px;font-size:12.5px;color:var(--c-n4);"><button style="border:none;background:transparent;padding:0;color:var(--c-accent-text);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;">База знаний</button><span>/</span><span style="color:var(--c-n2);">Импорт YAML</span></div>
<h2 style="margin:0 0 5px;font-size:20px;font-weight:700;color:var(--c-n1);">Импорт материалов</h2>
<p style="margin:0;font-size:13px;color:var(--c-n3);">Файл разбирается до применения: видно, что создастся, что обновится и где ошибка.</p>
</div>
<div style="flex:1;min-height:0;overflow-y:auto;padding:24px 34px 40px;">
<div style="max-width:1180px;display:flex;flex-direction:column;gap:16px;">
<div style="display:flex;align-items:center;gap:14px;padding:14px 16px;background:var(--c-surface);border:1px solid var(--c-n7);border-radius:10px;">
<i style="width:38px;height:38px;flex:none;border-radius:10px;background:var(--c-n9);color:var(--c-n3);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5"/></svg></i>
<span style="flex:1;min-width:0;line-height:1.4;"><strong style="display:block;font-size:13.5px;font-weight:600;color:var(--c-n1);">knowledge-atelie-2026.yaml</strong><small style="font-size:12px;color:var(--c-n4);">42 КБ · 7 документов · разобран без ошибок формата</small></span>
<button style="height:32px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:500;cursor:pointer;font-family:inherit;">Выбрать другой файл</button>
</div>
<div style="background:var(--c-surface);border:1px solid var(--c-n7);border-radius:10px;overflow:hidden;">
<div style="display:flex;align-items:center;gap:14px;padding:12px 16px;border-bottom:1px solid var(--c-n8);">
<strong style="font-size:13px;font-weight:700;color:var(--c-n1);">Что произойдёт</strong>
<span style="display:flex;align-items:center;gap:8px;">
<sc-for list="{{ importStats }}" as="s" hint-placeholder-count="3">
<small style="display:inline-flex;align-items:center;gap:6px;height:22px;padding:0 9px;border-radius:6px;background:{{ s.bg }};color:{{ s.color }};font-size:11.5px;font-weight:600;">{{ s.label }}</small>
</sc-for>
</span>
</div>
<table style="width:100%;border-collapse:collapse;">
<thead><tr style="background:var(--c-n10);">
<th style="text-align:left;padding:9px 16px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">ДОКУМЕНТ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">CATEGORYPATH</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">ДЕЙСТВИЕ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">ЗАМЕЧАНИЕ</th>
</tr></thead>
<tbody>
<sc-for list="{{ importRows }}" as="d" hint-placeholder-count="7">
<tr style="border-bottom:1px solid var(--c-n8);background:{{ d.rowBg }};">
<td style="padding:10px 16px;"><code style="font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--c-n1);">{{ d.title }}</code></td>
<td style="padding:10px 12px;font-size:12.5px;color:var(--c-n3);">{{ d.path }}</td>
<td style="padding:10px 12px;"><span style="display:inline-flex;align-items:center;gap:6px;height:22px;padding:0 9px;border-radius:6px;background:{{ d.actBg }};color:{{ d.actColor }};font-size:11.5px;font-weight:600;">{{ d.action }}</span></td>
<td style="padding:10px 12px;font-size:12px;line-height:1.45;color:{{ d.noteColor }};max-width:420px;">{{ d.note }}</td>
</tr>
</sc-for>
</tbody>
</table>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<span style="flex:1;font-size:12px;line-height:1.5;color:var(--c-n4);max-width:640px;">Недостающие категории из файла создаются автоматически. Без указания пути новый документ попадает в «Без категории», существующий сохраняет свою категорию.</span>
<button style="height:34px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;">Отмена</button>
<button style="height:34px;padding:0 15px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Импортировать 7 документов</button>
</div>
</div>
</div>
</section>
</sc-if>
</div>
</div>
</sc-for>
</div>
</section>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'light' | 'dark'&quot;,&quot;section&quot;:&quot;Тема&quot;},&quot;accent&quot;:{&quot;editor&quot;:&quot;color&quot;,&quot;options&quot;:[&quot;#1677ff&quot;,&quot;#5b5bd6&quot;,&quot;#0f9b8e&quot;,&quot;#fa8c16&quot;],&quot;default&quot;:&quot;#1677ff&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Тема&quot;}}">
class Component extends DCLogic {
renderVals() {
const dark = (this.props.theme ?? "light") === "dark";
const accent = this.props.accent ?? "#1677ff";
const T = dark
? { canvas: "#101113", surface: "#1b1c1f", surface2: "#181a1d", feed: "#15161a", n1: "#d6d7db", n2: "#c3c5ca", n3: "#9a9ca3", n4: "#767881", n5: "#5b5d66", n6: "#45474f", n7: "#37393f", n8: "#2d2f35", n9: "#27292e", n10: "#212327", ai: "#a98be0", warnText: "#d9a441", okText: "#5fbf7a", errText: "#ff7875", accentText: `color-mix(in srgb,${accent} 62%,white)`, accentHover: `color-mix(in srgb,${accent} 92%,white)`, accentBgPct: 16, tintPct: 12, accentMuted: `color-mix(in srgb,${accent} 84%,#1b1c1f)` }
: { canvas: "#e4e6ea", surface: "#ffffff", surface2: "#fafafa", feed: "#f5f6f8", n1: "#1f1f1f", n2: "#262626", n3: "#595959", n4: "#8c8c8c", n5: "#bfbfbf", n6: "#d9d9d9", n7: "#e8e8e8", n8: "#f0f0f0", n9: "#f5f5f5", n10: "#fafafa", ai: "#722ed1", warnText: "#d48806", okText: "#237a3f", errText: "#cf1322", accentText: `color-mix(in srgb,${accent} 82%,black)`, accentHover: `color-mix(in srgb,${accent} 88%,black)`, accentBgPct: 10, tintPct: 11, accentMuted: accent };
const themeVars = `--c-canvas:${T.canvas};--c-surface:${T.surface};--c-surface2:${T.surface2};--c-feed:${T.feed};--c-n1:${T.n1};--c-n2:${T.n2};--c-n3:${T.n3};--c-n4:${T.n4};--c-n5:${T.n5};--c-n6:${T.n6};--c-n7:${T.n7};--c-n8:${T.n8};--c-n9:${T.n9};--c-n10:${T.n10};--c-ai:${T.ai};--c-accent:${T.accentMuted};--c-accent-text:${T.accentText};--c-accent-hover:${T.accentHover};--c-accent-bg:color-mix(in srgb,${accent} ${T.accentBgPct}%,${T.surface});--c-warn-bg:color-mix(in srgb,#faad14 ${T.tintPct}%,${T.surface});--c-warn-border:color-mix(in srgb,#faad14 ${T.tintPct + 25}%,${T.surface});--c-warn-text:${T.warnText};--c-ok-bg:color-mix(in srgb,#52c41a ${T.tintPct}%,${T.surface});--c-ok-border:color-mix(in srgb,#52c41a ${T.tintPct + 20}%,${T.surface});--c-ok-text:${T.okText};--c-err-bg:color-mix(in srgb,#ff4d4f ${T.tintPct}%,${T.surface});--c-err-text:${T.errText};`;
const ICON = {
chat: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z",
contacts: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0z",
agents: "M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01",
team: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
portals: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",
knowledge: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5z",
settings: "M21 6H9M3 6h2M9 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 12h-8M3 12h6M13 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 18h-6M3 18h8M15 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0z",
folder: "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z",
file: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5",
image: "M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM8.5 10a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zM4 17l5-5 4 4 3-3 4 4",
doc: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5z",
script: "M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-3M9 8h7M9 12h5M16 3l5 5-8 8-5 1 1-5z",
price: "M12 2v20M17 6H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6",
box: "M21 8 12 3 3 8v8l9 5 9-5zM3 8l9 5 9-5M12 13v8",
trash: "M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13",
copy: "M11 9h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2zM5 15V5a2 2 0 0 1 2-2h8",
edit: "M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z",
eye: "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z",
eyeOff: "M3 3l18 18M10.6 10.6a3 3 0 0 0 4.2 4.2M6.6 6.6C4 8.3 2 12 2 12s3.6 7 10 7c1.7 0 3.2-.4 4.5-1.1M14 5.3A9.7 9.7 0 0 1 22 12s-.7 1.4-2 3",
split: "M4 4h16v16H4zM12 4v16",
code: "m9 18-6-6 6-6M15 6l6 6-6 6",
bold: "M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z",
italic: "M15 5h-5M14 19H9M14.5 5 10 19",
link: "M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1",
list: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
numlist: "M10 6h11M10 12h11M10 18h11M4 5h1v4M4 15h2v1H4v2h2",
quote: "M7 7H4v5h3l-1 5M17 7h-3v5h3l-1 5",
table: "M3 5h18v14H3zM3 10h18M9 10v9",
attach: "M21 12.5 12.5 21a4.95 4.95 0 0 1-7-7l8-8a3.5 3.5 0 0 1 5 5l-8 8a1.95 1.95 0 0 1-3-3l7-7",
reindex: "M9 14 4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3",
move: "M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20",
external: "M7 17 17 7M8 7h9v9",
};
const nav = [
{ key: "chat", label: "Чат", icon: ICON.chat, badge: 2 }, { key: "contacts", label: "Контакты", icon: ICON.contacts }, { key: "agents", label: "Агенты", icon: ICON.agents },
{ key: "employees", label: "Сотрудники", icon: ICON.team }, { key: "portals", label: "Порталы", icon: ICON.portals }, { key: "knowledge", label: "База знаний", icon: ICON.knowledge }, { key: "settings", label: "Настройки", icon: ICON.settings },
].map((n) => ({ ...n, bg: n.key === "knowledge" ? "var(--c-accent-bg)" : "transparent", color: n.key === "knowledge" ? "var(--c-accent-text)" : "var(--c-n2)", weight: n.key === "knowledge" ? 600 : 500 }));
// ---- дерево категорий сайдбара библиотеки
const catRow = (label, count, depth, active, icon) => ({
label, count, icon, pad: `${9 + depth * 16}px`,
bg: active ? "var(--c-accent-bg)" : "transparent",
color: active ? "var(--c-accent-text)" : "var(--c-n2)",
weight: active ? 600 : 500,
countColor: active ? "var(--c-accent-text)" : "var(--c-n5)",
});
const catsFor = (activeLabel) => [
["Все знания", 84, 0, ICON.folder], ["Без категории", 3, 0, null],
["Регламенты", 18, 0, null], ["Пошив", 7, 1, null], ["Возвраты", 5, 1, null],
["Прайсы и оплата", 12, 0, null], ["Скрипты общения", 9, 0, null],
["Продукты", 24, 0, null], ["FoxRay", 11, 1, null], ["Обучение", 0, 0, null],
].map(([l, c, d, i]) => catRow(l, c, d, l === activeLabel, i));
// ---- строки таблицы знаний
const S = {
on: { status: "Включено", stBg: "var(--c-ok-bg)", stColor: "var(--c-ok-text)" },
off: { status: "Выключено", stBg: "var(--c-n9)", stColor: "var(--c-n4)" },
idx: { status: "Индексируется", stBg: "var(--c-warn-bg)", stColor: "var(--c-warn-text)" },
};
const K = [
{ title: "Срок пошива и переносы примерок", desc: "Сроки по типам изделий, правила переноса, действия при задержке", icon: ICON.doc, cat: "Пошив", catParent: "Регламенты", frags: 12, agents: 3, ...S.on, updated: "5 сен, 11:20", author: "Анна Ким", attach: 2 },
{ title: "Прайс-лист 2026", desc: "Базовые цены, надбавки за ткань и срочность", icon: ICON.price, cat: "Прайсы и оплата", frags: 24, agents: 4, ...S.on, updated: "4 сен, 18:02", author: "Елена Кузнецова", attach: 1 },
{ title: "Скрипт: клиент недоволен посадкой", desc: "Что спросить, что предложить, когда звать мастера", icon: ICON.script, cat: "Скрипты общения", frags: 8, agents: 2, ...S.off, updated: "4 сен, 10:05", author: "Анна Ким" },
{ title: "Условия рассрочки и возврата денег", desc: "Порядок оплаты частями, сроки возврата, документы", icon: ICON.doc, cat: "Возвраты", catParent: "Регламенты", frags: 15, agents: 3, ...S.on, updated: "2 сен, 12:30", author: "Елена Кузнецова", attach: 1 },
{ title: "FoxRay — лицензирование", desc: "Типы лицензий, продление, перенос на другое устройство", icon: ICON.box, cat: "FoxRay", catParent: "Продукты", frags: 19, agents: 2, ...S.idx, fragNote: "пересборка 60 %", fragNoteColor: "var(--c-warn-text)", updated: "1 сен, 19:12", author: "Импорт YAML" },
{ title: "Уход за шерстью и кашемиром", desc: "Стирка, сушка, хранение; что отвечать про катышки", icon: ICON.doc, cat: "Без категории", frags: 6, noAgents: true, ...S.on, updated: "20 авг, 08:55", author: "Анна Ким" },
{ title: "Договор оказания услуг (шаблон)", desc: "Актуальная редакция для физлиц; агент даёт ссылку на PDF", icon: ICON.file, cat: "Регламенты", frags: 4, agents: 1, ...S.on, updated: "12 авг, 14:40", author: "Елена Кузнецова", attach: 1 },
];
const rows = (opts = {}) => K.map((k, i) => {
const checked = (opts.checked || []).includes(i);
return {
...k,
opacity: k.status === "Выключено" ? 0.72 : 1,
checked,
cbBorder: checked ? "var(--c-accent)" : "var(--c-n6)",
cbBg: checked ? "var(--c-accent)" : "transparent",
rowBg: checked ? "var(--c-accent-bg)" : (i === 0 && opts.menu ? "var(--c-n10)" : "transparent"),
menuOpen: opts.menu === i,
menuBg: opts.menu === i ? "var(--c-n9)" : "transparent",
};
});
const rowMenu = [
{ label: "Открыть знание", icon: ICON.doc, color: "var(--c-n2)" },
{ label: "Редактировать", icon: ICON.edit, color: "var(--c-n2)" },
{ divider: true },
{ label: "Прикрепить к агенту", icon: ICON.agents, color: "var(--c-n2)" },
{ label: "Переместить в категорию", icon: ICON.move, color: "var(--c-n2)" },
{ label: "Выключить в ответах", icon: ICON.eyeOff, color: "var(--c-n2)" },
{ label: "Переиндексировать", icon: ICON.reindex, color: "var(--c-n2)" },
{ divider: true },
{ label: "Удалить знание", icon: ICON.trash, color: "var(--c-err-text)" },
];
const bulkActions = [
{ label: "Прикрепить к агенту", icon: ICON.agents, bg: "var(--c-surface)", color: "var(--c-n1)", border: "var(--c-surface)", weight: 600 },
{ label: "Переместить", icon: ICON.move, bg: "transparent", color: "var(--c-surface)", border: "rgba(255,255,255,0.22)", weight: 500 },
{ label: "Включить", icon: ICON.eye, bg: "transparent", color: "var(--c-surface)", border: "rgba(255,255,255,0.22)", weight: 500 },
{ label: "Выключить", icon: ICON.eyeOff, bg: "transparent", color: "var(--c-surface)", border: "rgba(255,255,255,0.22)", weight: 500 },
{ label: "Удалить", icon: ICON.trash, bg: "transparent", color: "#ff9c9e", border: "rgba(255,156,158,0.35)", weight: 500 },
];
const seg = (items, activeKey) => items.map((i) => ({
...i,
bg: i.key === activeKey ? "var(--c-surface)" : "transparent",
color: i.key === activeKey ? "var(--c-n1)" : "var(--c-n4)",
weight: i.key === activeKey ? 600 : 500,
shadow: i.key === activeKey ? "0 1px 3px rgba(0,0,0,0.12)" : "none",
}));
const attachModes = seg([{ key: "attach", label: "Прикрепить" }, { key: "detach", label: "Открепить" }], "attach");
const modes = (active) => seg([
{ key: "edit", label: "Текст", title: "Только редактор", icon: ICON.edit },
{ key: "split", label: "Вместе", title: "Текст и предпросмотр", icon: ICON.split },
{ key: "view", label: "Просмотр", title: "Только предпросмотр", icon: ICON.eye },
], active);
const agentPicks = [
{ name: "Поддержка · Telegram", meta: "AI отвечает · 18 знаний прикреплено", tag: "выбран", picked: true },
{ name: "Ателье · WhatsApp", meta: "AI отвечает · 11 знаний", tag: "доступен" },
{ name: "Продажи · Сайт", meta: "Без AI — знания не используются", tag: "без AI", muted: true },
].map((a) => ({
...a,
border: a.picked ? "var(--c-accent)" : "var(--c-n7)",
bg: a.picked ? "var(--c-accent-bg)" : "var(--c-surface)",
radioBorder: a.picked ? "var(--c-accent)" : "var(--c-n6)",
radioDot: a.picked ? "var(--c-accent)" : "transparent",
tagBg: a.picked ? "var(--c-accent-bg)" : "var(--c-n9)",
tagColor: a.picked ? "var(--c-accent-text)" : "var(--c-n4)",
}));
const cardAgents = [
{ name: "Поддержка · Telegram", meta: "AI отвечает", tag: "активен", tagBg: "var(--c-ok-bg)", tagColor: "var(--c-ok-text)", tileBg: "var(--c-accent-bg)", tileColor: "var(--c-accent-text)" },
{ name: "Ателье · WhatsApp", meta: "AI отвечает", tag: "активен", tagBg: "var(--c-ok-bg)", tagColor: "var(--c-ok-text)", tileBg: "var(--c-n9)", tileColor: "var(--c-n4)" },
{ name: "Продажи · Сайт", meta: "AI выключен у агента", tag: "не в ответах", tagBg: "var(--c-n9)", tagColor: "var(--c-n4)", tileBg: "var(--c-n9)", tileColor: "var(--c-n4)" },
];
const cardFiles = [
{ name: "Регламент сроков 2026.pdf", meta: "PDF · 1,1 МБ · ссылка в тексте", icon: ICON.file },
{ name: "tablica-srokov.png", meta: "PNG · 240 КБ", icon: ICON.image },
];
const mdTools = [
{ title: "Заголовок 1", text: "H1", bg: "transparent", color: "var(--c-n3)" },
{ title: "Заголовок 2", text: "H2", bg: "transparent", color: "var(--c-n3)" },
{ divider: true },
{ title: "Полужирный", icon: ICON.bold, bg: "transparent", color: "var(--c-n3)" },
{ title: "Курсив", icon: ICON.italic, bg: "transparent", color: "var(--c-n3)" },
{ title: "Ссылка", icon: ICON.link, bg: "transparent", color: "var(--c-n3)" },
{ title: "Код", icon: ICON.code, bg: "transparent", color: "var(--c-n3)" },
{ divider: true },
{ title: "Список", icon: ICON.list, bg: "transparent", color: "var(--c-n3)" },
{ title: "Нумерованный список", icon: ICON.numlist, bg: "transparent", color: "var(--c-n3)" },
{ title: "Цитата", icon: ICON.quote, bg: "transparent", color: "var(--c-n3)" },
{ title: "Таблица", icon: ICON.table, bg: "transparent", color: "var(--c-n3)" },
{ divider: true },
{ title: "Прикрепить файл", icon: ICON.attach, bg: "var(--c-accent-bg)", color: "var(--c-accent-text)" },
];
const mdSource = [
"# Срок пошива и переносы примерок",
"",
"Базовые сроки по типам изделий, правила переноса примерки",
"и что делать при задержке.",
"",
"## Базовые сроки",
"",
"| Изделие | Срок | Примерок |",
"| -------------- | ----------------- | -------- |",
"| Рубашка | 10 рабочих дней | 1 |",
"| Пальто | 25 рабочих дней | 2 |",
"| Костюм-тройка | 35 рабочих дней | 3 |",
"",
"## Перенос примерки",
"",
"1. Предупредил за сутки — переносим бесплатно.",
"2. Меньше суток — ближайшее свободное окно.",
"3. Третий перенос — пауза до звонка администратора.",
"",
"> Агент не называет точную дату сдачи — только диапазон",
"> и ближайшее окно примерки.",
"",
"[Регламент сроков 2026.pdf](files/reglament-srokov-2026.pdf)",
].join("\n");
const fileRow = (name, meta, icon, opts = {}) => ({
name, meta, icon,
done: opts.progress ? false : true,
progress: opts.progress || false,
metaColor: opts.progress ? "var(--c-accent-text)" : "var(--c-n4)",
border: opts.fresh ? "var(--c-accent)" : "var(--c-n8)",
bg: opts.fresh ? "var(--c-accent-bg)" : "var(--c-surface)",
});
const filesCalm = [
fileRow("Регламент сроков 2026.pdf", "PDF · 1,1 МБ · ссылка в тексте", ICON.file),
fileRow("tablica-srokov.png", "PNG · 240 КБ", ICON.image),
];
const filesDrop = [
fileRow("primerka-okna.xlsx", "загружаем… 68 %", ICON.file, { progress: "68%", fresh: true }),
fileRow("Регламент сроков 2026.pdf", "PDF · 1,1 МБ · ссылка в тексте", ICON.file),
fileRow("tablica-srokov.png", "PNG · 240 КБ", ICON.image),
];
const catRows = [
{ name: "Без категории", count: "3 знания", tag: "системная", depth: 0, locked: true },
{ name: "Регламенты", count: "18 знаний · 2 вложенные", depth: 0, locked: true, delNote: "есть вложенные категории" },
{ name: "Пошив", count: "7 знаний", depth: 1, locked: true, delNote: "категория не пуста" },
{ name: "Возвраты", count: "5 знаний", depth: 1, editing: true },
{ name: "Прайсы и оплата", count: "12 знаний", depth: 0, locked: true, delNote: "категория не пуста" },
{ name: "Скрипты общения", count: "9 знаний", depth: 0, locked: true, delNote: "категория не пуста" },
{ name: "Продукты", count: "24 знания · 1 вложенная", depth: 0, locked: true, delNote: "есть вложенные категории" },
{ name: "FoxRay", count: "11 знаний", depth: 1, locked: true, delNote: "категория не пуста" },
{ name: "Обучение", count: "пусто", depth: 0 },
].map((c) => ({
...c,
pad: `${12 + c.depth * 22}px`,
weight: c.depth === 0 ? 600 : 500,
bg: c.editing ? "var(--c-accent-bg)" : "transparent",
delDisabled: c.locked ? "disabled" : false,
delColor: c.locked ? "var(--c-n6)" : "var(--c-err-text)",
delCursor: c.locked ? "not-allowed" : "pointer",
delTitle: c.locked ? `Удалить нельзя: ${c.delNote || "системная категория"}` : "Удалить категорию",
}));
const importStats = [
{ label: "создать 5", bg: "var(--c-ok-bg)", color: "var(--c-ok-text)" },
{ label: "обновить 2", bg: "var(--c-accent-bg)", color: "var(--c-accent-text)" },
];
const IA = {
new: { action: "создать", actBg: "var(--c-ok-bg)", actColor: "var(--c-ok-text)" },
upd: { action: "обновить", actBg: "var(--c-accent-bg)", actColor: "var(--c-accent-text)" },
err: { action: "ошибка", actBg: "var(--c-err-bg)", actColor: "var(--c-err-text)" },
};
const importRows = [
{ title: "srok-poshiva", path: "Регламенты / Пошив", ...IA.upd, note: "совпал заголовок — заменим текст и пересоберём фрагменты" },
{ title: "perenos-primerki", path: "Регламенты / Пошив", ...IA.new, note: "" },
{ title: "price-2026", path: "Прайсы и оплата", ...IA.upd, note: "категория в файле совпадает с текущей" },
{ title: "foxray-licensing", path: "Продукты / FoxRay / Лицензирование", ...IA.new, note: "Недостающие категории будут созданы автоматически." },
{ title: "foxray-update", path: "Продукты / FoxRay", ...IA.new, note: "" },
{ title: "uhod-za-shelkom", path: "не указана", ...IA.new, note: "попадёт в «Без категории»" },
{ title: "script-otkaz", path: "Скрипты общения", ...IA.new, note: "" },
].map((d) => ({
...d,
rowBg: d.err ? "var(--c-err-bg)" : "transparent",
noteColor: d.err ? "var(--c-err-text)" : "var(--c-n4)",
}));
const dropCalm = { dropBorder: "var(--c-n6)", dropBg: "transparent", dropIcon: "var(--c-n4)" };
const dropHot = { dropBorder: "var(--c-accent)", dropBg: "var(--c-accent-bg)", dropIcon: "var(--c-accent-text)" };
const allBox = (checked) => ({ allChecked: checked, allBorder: checked ? "var(--c-accent)" : "var(--c-n6)", allBg: checked ? "var(--c-accent)" : "transparent" });
const idxOk = { indexLabel: "Индекс актуален", indexBg: "var(--c-ok-bg)", indexColor: "var(--c-ok-text)" };
const idxDirty = { indexLabel: "Есть несохранённые правки", indexBg: "var(--c-warn-bg)", indexColor: "var(--c-warn-text)" };
const frames = [
{ id: "KB1", title: "База знаний · библиотека", note: "дерево категорий + таблица; вложения, агенты и фрагменты в строке", isLibrary: true, cats: catsFor("Все знания"), rows: rows({ menu: 0 }), scopeNote: "показаны все категории", ...allBox(false) },
{ id: "KB2", title: "Библиотека · массовый выбор", note: "панель операций, пока выделение живо (SPEC-0029 §7)", isLibrary: true, isBulk: true, cats: catsFor("Все знания"), rows: rows({ checked: [0, 1, 2] }), scopeNote: "выбрано 3 из 84", ...allBox(false) },
{ id: "KB3", title: "Прикрепить к агенту", note: "один агент за операцию + честный список пропусков", isLibrary: true, isBulk: true, isDialog: true, cats: catsFor("Все знания"), rows: rows({ checked: [0, 1, 2] }), scopeNote: "выбрано 3 из 84", ...allBox(false) },
{ id: "KB4", title: "Карточка знания", note: "шапка как у портала: состояние, фрагменты, агенты; рейка справа", isCard: true },
{ id: "KB5", title: "Редактор знания", note: "тот же split, что у статьи портала; вложения и агенты в рейке", isEditor: true, modes: modes("split"), files: filesCalm, saveNote: "Сохранено автоматически в 11:24", ...idxOk, ...dropCalm },
{ id: "KB6", title: "Редактор · перетаскивание файла", note: "drop прямо в текст: файл становится вложением и ссылкой", isEditor: true, dropping: true, modes: modes("split"), files: filesDrop, saveNote: "Правки не сохранены — фрагменты пересоберутся после «Сохранить»", ...idxDirty, ...dropHot },
{ id: "KB7", title: "Категории", note: "инлайн-переименование, reorder, безопасное удаление", isCats: true },
{ id: "KB8", title: "Импорт YAML", note: "разбор до применения: создать / обновить / ошибка пути", isImport: true },
{ id: "KB9", title: "Пустая категория", note: "состояние без материалов и без ложных цифр", isLibrary: true, isEmptyCat: true, cats: catsFor("Обучение"), scopeNote: "категория «Обучение»", ...allBox(false) },
];
return { themeVars, nav, frames, rowMenu, bulkActions, attachModes, agentPicks, cardAgents, cardFiles, mdTools, mdSource, catRows, importStats, importRows };
}
}
</script>
</body>
</html>
@@ -1,326 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
a{color:inherit;text-decoration:none;}
a:hover{opacity:0.85;}
*{scrollbar-width:none;}
*::-webkit-scrollbar{display:none;width:0;height:0;}
</style>
</helmet>
<section style="{{ themeVars }}min-height:100vh;background:var(--c-canvas);padding:32px 40px 60px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:var(--c-n2);-webkit-font-smoothing:antialiased;">
<div style="display:flex;align-items:baseline;gap:14px;margin-bottom:6px;">
<h1 style="margin:0;font-size:22px;font-weight:700;letter-spacing:-0.02em;color:var(--c-n1);">Chatbolls · Контакты</h1>
<span style="font-size:13px;color:var(--c-n4);">design baseline · OWNER = ADMIN · SPEC-HUB-0031 §8.2, ADR-0006, ADR-0041</span>
</div>
<p style="margin:0 0 28px;font-size:13px;color:var(--c-n3);max-width:960px;line-height:1.55;">Одна плотная таблица без коммерции: контакт · как связаться · каналы · последний диалог · открытые. Колонка «Продукты», фильтр по продуктам и статусы «лид/клиент» из текущего кода убраны (ADR-0041). Карточка контакта — шапка с теми же полями, что в контекст-панели чата, и `UnderlineTabs`: Обзор · Диалоги · Идентификаторы · Аудит. Вкладка «Согласия» убрана — журнал согласий не ведётся отдельной сущностью.</p>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start;">
<sc-for list="{{ frames }}" as="f" hint-placeholder-count="1">
<div id="{{ f.id }}" style="flex:none;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11.5px;font-weight:700;letter-spacing:0.02em;">{{ f.badge }}</span>
<strong style="font-size:13.5px;color:var(--c-n1);">{{ f.title }}</strong>
<span style="font-size:12px;color:var(--c-n4);">{{ f.note }}</span>
</div>
<div style="width:1440px;height:880px;display:flex;overflow:hidden;background:var(--c-surface);border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);position:relative;">
<aside style="position:relative;width:220px;flex:none;background:var(--c-surface2);border-right:1px solid var(--c-n8);display:flex;flex-direction:column;min-height:0;">
<div title="Потяните, двойной клик — сбросить" style="position:absolute;top:0;right:-3px;bottom:0;width:6px;z-index:5;cursor:col-resize;"><i style="position:absolute;top:0;bottom:0;left:2px;width:2px;background:var(--c-accent);opacity:0;"></i></div>
<div style="flex:none;height:56px;display:flex;align-items:center;gap:10px;padding:14px 14px 10px;">
<span style="width:28px;height:28px;flex:none;display:flex;align-items:center;justify-content:center;border-radius:8px;color:var(--c-n1);"><svg viewBox="0 0 100 100" width="26" height="26" fill="currentColor"><defs><mask id="lg-cont"><rect width="100" height="100" fill="#fff"/><circle cx="50" cy="24.54" r="9" fill="#000"/><circle cx="50" cy="75.46" r="9" fill="#000"/></mask></defs><g mask="url(#lg-cont)"><circle cx="29" cy="50" r="24"/><circle cx="71" cy="50" r="24"/><rect x="44.2" y="31.4" width="11.6" height="37.2"/></g></svg></span>
<button style="flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:6px;height:32px;padding:0 6px 0 8px;border:none;border-radius:8px;background:transparent;color:var(--c-n1);font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Ателье Норд</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
</div>
<nav style="flex:none;padding:4px 10px 8px;display:flex;flex-direction:column;gap:2px;">
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="6">
<button style="display:flex;align-items:center;gap:10px;height:36px;padding:0 10px;border:none;border-radius:8px;background:{{ n.bg }};color:{{ n.color }};font-size:13.5px;font-weight:{{ n.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n8);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ n.icon }}"/></svg><span style="flex:1;">{{ n.label }}</span><sc-if value="{{ n.badge }}" hint-placeholder-val="{{ false }}"><b style="min-width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;padding:0 6px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);color:var(--c-warn-text);font-size:11px;font-weight:700;">{{ n.badge }}</b></sc-if></button>
</sc-for>
</nav>
<div style="flex:1;"></div>
<div style="flex:none;display:flex;align-items:center;gap:10px;padding:12px 14px;border-top:1px solid var(--c-n8);">
<span style="position:relative;flex:none;display:flex;"><span style="width:34px;height:34px;border-radius:50%;background:url(assets/av-4.png) center/cover;"></span><i style="position:absolute;right:-1px;bottom:-1px;width:10px;height:10px;border:2px solid var(--c-surface2);border-radius:50%;background:#52c41a;"></i></span>
<div style="flex:1;min-width:0;line-height:1.2;"><strong style="display:block;font-size:13px;font-weight:600;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Елена Кузнецова</strong><small style="display:block;font-size:11.5px;color:var(--c-n4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Владелец · e.kuznetsova@atelie-nord.ru</small></div>
<button style="width:28px;height:28px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</aside>
<section style="flex:1;min-width:0;min-height:0;overflow-y:auto;background:var(--c-feed);">
<!-- LIST -->
<sc-if value="{{ f.isList }}" hint-placeholder-val="{{ true }}">
<div style="padding:26px 32px 40px;display:flex;flex-direction:column;gap:16px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
<div style="display:flex;align-items:baseline;gap:10px;"><h2 style="margin:0;font-size:20px;font-weight:700;letter-spacing:-0.01em;color:var(--c-n1);">Контакты</h2><span style="font-size:13px;color:var(--c-n4);">{{ f.countLine }}</span></div>
<button style="height:34px;padding:0 14px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:{{ f.exportColor }};font-size:13px;font-weight:600;cursor:{{ f.exportCursor }};display:inline-flex;align-items:center;gap:7px;font-family:inherit;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>Экспорт CSV</button>
</div>
<sc-if value="{{ f.hasRows }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;align-items:center;gap:8px;">
<div style="display:flex;align-items:center;gap:8px;width:360px;height:34px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--c-n5)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><span style="flex:1;font-size:13px;color:var(--c-n5);">Имя, email, телефон или логин…</span><kbd style="font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:10.5px;color:var(--c-n4);background:var(--c-n9);border:1px solid var(--c-n7);border-radius:4px;padding:1px 5px;line-height:1.4;">/</kbd></div>
<button style="height:34px;padding:0 12px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>Все каналы<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<button style="height:34px;padding:0 12px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6"/></svg>Все агенты<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<button style="height:34px;padding:0 12px;border-radius:8px;border:1px solid {{ f.chipBorder }};background:{{ f.chipBg }};color:{{ f.chipColor }};font-size:13px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;"><i style="width:8px;height:8px;border-radius:50%;background:var(--c-warn-text);"></i>С открытым диалогом</button>
<div style="flex:1;"></div>
<sc-if value="{{ f.filtered }}" hint-placeholder-val="{{ false }}"><button style="height:34px;padding:0 10px;border:none;border-radius:8px;background:transparent;color:var(--c-n4);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);color:var(--c-n2);">Сбросить</button></sc-if>
</div>
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;overflow:hidden;">
<div style="display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1.6fr) minmax(0,1fr) minmax(0,1.6fr) 80px 44px;gap:16px;align-items:center;padding:10px 20px;border-bottom:1px solid var(--c-n8);background:var(--c-surface2);">
<span style="font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.05em;text-transform:uppercase;">Контакт</span>
<span style="font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.05em;text-transform:uppercase;">Как связаться</span>
<span style="font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.05em;text-transform:uppercase;">Каналы</span>
<button style="display:inline-flex;align-items:center;gap:4px;padding:0;border:none;background:transparent;font-size:11px;font-weight:600;color:var(--c-n2);letter-spacing:0.05em;text-transform:uppercase;cursor:pointer;font-family:inherit;text-align:left;">Последний диалог<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M19 12l-7 7-7-7"/></svg></button>
<span style="font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.05em;text-transform:uppercase;text-align:right;">Открытые</span>
<span></span>
</div>
<sc-for list="{{ f.rows }}" as="r" hint-placeholder-count="8">
<div style="display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1.6fr) minmax(0,1fr) minmax(0,1.6fr) 80px 44px;gap:16px;align-items:center;height:60px;padding:0 20px;border-bottom:1px solid var(--c-n9);cursor:pointer;" style-hover="background:var(--c-n10);">
<div style="display:flex;align-items:center;gap:12px;min-width:0;"><span style="width:36px;height:36px;flex:none;border-radius:50%;background:{{ r.avBg }};color:#fff;display:flex;align-items:center;justify-content:center;font-size:12.5px;font-weight:600;">{{ r.initials }}</span><span style="min-width:0;"><strong style="display:block;font-size:13.5px;font-weight:600;color:var(--c-n1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ r.name }}</strong><small style="display:block;margin-top:2px;font-size:11.5px;color:var(--c-n4);font-family:'SF Mono',ui-monospace,Menlo,monospace;">{{ r.cid }}</small></span></div>
<div style="min-width:0;"><span style="display:block;font-size:13px;color:{{ r.c1Color }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ r.c1 }}</span><small style="display:block;margin-top:2px;font-size:11.5px;color:var(--c-n4);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ r.c2 }}</small></div>
<div style="display:flex;gap:6px;"><sc-for list="{{ r.channels }}" as="c" hint-placeholder-count="1"><span title="{{ c.full }}" style="width:26px;height:26px;border-radius:7px;background:{{ c.bg }};color:{{ c.color }};display:inline-flex;align-items:center;justify-content:center;"><svg viewBox="{{ c.view }}" width="14" height="14" fill="{{ c.fill }}" stroke="{{ c.stroke }}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ c.path }}"/></svg></span></sc-for></div>
<div style="display:flex;align-items:center;gap:8px;min-width:0;"><i style="width:8px;height:8px;flex:none;border-radius:50%;background:{{ r.dot }};"></i><span style="min-width:0;"><span style="display:block;font-size:13px;color:var(--c-n2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ r.lastWho }}</span><small style="display:block;margin-top:2px;font-size:11.5px;color:var(--c-n4);white-space:nowrap;">{{ r.lastWhen }}</small></span></div>
<div style="text-align:right;font-size:14px;font-weight:600;color:{{ r.openColor }};">{{ r.open }}</div>
<button title="Действия" style="width:30px;height:30px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;justify-self:end;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</sc-for>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 20px;background:var(--c-surface2);">
<small style="font-size:12px;color:var(--c-n4);">{{ f.pageLine }}</small>
<sc-if value="{{ f.paged }}" hint-placeholder-val="{{ true }}"><div style="display:flex;align-items:center;gap:4px;">
<button style="width:28px;height:28px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-n5);display:inline-flex;align-items:center;justify-content:center;cursor:default;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 6 9 12 15 18"/></svg></button>
<sc-for list="{{ pages }}" as="p" hint-placeholder-count="4"><button style="min-width:28px;height:28px;padding:0 6px;border:1px solid {{ p.border }};border-radius:7px;background:{{ p.bg }};color:{{ p.color }};font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;">{{ p.n }}</button></sc-for>
<button style="width:28px;height:28px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-n2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg></button>
</div></sc-if>
</div>
</div>
</sc-if>
<sc-if value="{{ f.isEmpty }}" hint-placeholder-val="{{ false }}">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:80px 24px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;">
<span style="width:48px;height:48px;border-radius:14px;background:var(--c-n9);color:var(--c-n4);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0z"/></svg></span>
<div><strong style="display:block;font-size:15px;color:var(--c-n1);">Контакты появятся, когда клиенты напишут вашему агенту</strong><p style="margin:6px 0 0;font-size:13px;color:var(--c-n4);line-height:1.5;max-width:420px;">Каждый, кто написал через Telegram, MAX, почту или виджет, становится контактом с историей диалогов.</p></div>
<button style="height:34px;padding:0 16px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Настроить подключения</button>
</div>
</sc-if>
</div>
</sc-if>
<!-- CARD -->
<sc-if value="{{ f.isCard }}" hint-placeholder-val="{{ false }}">
<div style="padding:22px 32px 40px;display:flex;flex-direction:column;gap:16px;">
<button style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 8px;margin-left:-8px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);font-size:12.5px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 6 9 12 15 18"/></svg>Контакты</button>
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 24px;display:flex;align-items:flex-start;gap:18px;">
<span style="width:64px;height:64px;flex:none;border-radius:50%;background:#eb6f4b;color:#fff;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:600;">МС</span>
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:10px;"><h2 style="margin:0;font-size:20px;font-weight:700;letter-spacing:-0.01em;color:var(--c-n1);">Мария Соколова</h2><code style="font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:11.5px;color:var(--c-n4);background:var(--c-n9);padding:2px 6px;border-radius:5px;">C-1042</code><button title="Редактировать" style="width:26px;height:26px;border:none;border-radius:6px;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></button></div>
<p style="margin:4px 0 10px;font-size:13px;color:var(--c-n3);">Постоянная клиентка, заказывает пошив 2–3 раза в год. Предпочитает Telegram.</p>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px 18px;font-size:13px;color:var(--c-n2);">
<sc-for list="{{ meta }}" as="m" hint-placeholder-count="4"><span style="display:inline-flex;align-items:center;gap:6px;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ m.icon }}"/></svg><span style="font-family:{{ m.font }};">{{ m.value }}</span><button title="Копировать" style="width:20px;height:20px;border:none;border-radius:5px;background:transparent;color:var(--c-n5);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></span></sc-for>
<span style="display:inline-flex;gap:6px;"><sc-for list="{{ cardChannels }}" as="c" hint-placeholder-count="2"><span title="{{ c.full }}" style="height:24px;padding:0 8px 0 6px;border-radius:6px;background:{{ c.bg }};color:{{ c.color }};display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600;"><svg viewBox="{{ c.view }}" width="13" height="13" fill="{{ c.fill }}" stroke="{{ c.stroke }}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ c.path }}"/></svg>{{ c.full }}</span></sc-for></span>
</div>
</div>
<div style="display:flex;gap:8px;flex:none;">
<button style="height:34px;padding:0 14px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;" style-hover="background:var(--c-accent-hover);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>Открыть диалог</button>
<button title="Действия" style="width:34px;height:34px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</div>
<div style="display:flex;gap:2px;border-bottom:1px solid var(--c-n8);">
<sc-for list="{{ f.tabs }}" as="t" hint-placeholder-count="4"><button style="display:inline-flex;align-items:center;gap:6px;padding:9px 10px 8px;margin-bottom:-1px;border:none;border-bottom:2px solid {{ t.line }};background:transparent;color:{{ t.color }};font-size:13px;font-weight:{{ t.weight }};cursor:pointer;font-family:inherit;">{{ t.label }}<sc-if value="{{ t.count }}" hint-placeholder-val="{{ false }}"><small style="font-size:11.5px;color:var(--c-n4);font-weight:600;">{{ t.count }}</small></sc-if></button></sc-for>
</div>
<!-- OVERVIEW -->
<sc-if value="{{ f.tabOverview }}" hint-placeholder-val="{{ true }}">
<div style="display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:16px;align-items:start;">
<div style="display:flex;flex-direction:column;gap:16px;min-width:0;">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--c-n8);border:1px solid var(--c-n8);border-radius:12px;overflow:hidden;">
<sc-for list="{{ summary }}" as="s" hint-placeholder-count="4"><div style="background:var(--c-surface);padding:14px 18px;"><small style="display:block;font-size:11.5px;color:var(--c-n4);">{{ s.label }}</small><strong style="display:block;margin-top:4px;font-size:{{ s.size }};font-weight:700;color:{{ s.color }};">{{ s.value }}</strong></div></sc-for>
</div>
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:18px 22px;">
<h3 style="margin:0 0 14px;font-size:14px;font-weight:700;color:var(--c-n1);">Последняя активность</h3>
<sc-for list="{{ activity }}" as="a" hint-placeholder-count="5">
<div style="display:flex;gap:12px;">
<span style="display:flex;flex-direction:column;align-items:center;width:10px;flex:none;"><i style="width:8px;height:8px;border-radius:50%;background:{{ a.color }};margin-top:5px;flex:none;"></i><sc-if value="{{ a.more }}" hint-placeholder-val="{{ true }}"><i style="flex:1;width:1px;background:var(--c-n8);margin:4px 0;"></i></sc-if></span>
<div style="flex:1;min-width:0;padding-bottom:14px;"><span style="display:block;font-size:13px;color:var(--c-n2);line-height:1.4;">{{ a.title }}</span><time style="display:block;margin-top:2px;font-size:11.5px;color:var(--c-n4);">{{ a.time }}</time></div>
</div>
</sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px;min-width:0;">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:18px 22px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px;"><h3 style="margin:0;font-size:14px;font-weight:700;color:var(--c-n1);">Текущий диалог</h3><span style="display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--c-ai);"><i style="width:7px;height:7px;border-radius:50%;background:var(--c-ai);"></i>AI отвечает</span></div>
<p style="margin:0 0 4px;font-size:13px;color:var(--c-n2);line-height:1.5;">«Заказ 4471 — когда доставка?»</p>
<p style="display:flex;align-items:center;gap:7px;margin:0 0 14px;font-size:12px;color:var(--c-n4);"><span style="display:inline-flex;align-items:center;gap:4px;color:var(--c-ai);font-weight:500;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6"/></svg>Консультант</span><i style="width:3px;height:3px;border-radius:50%;background:var(--c-n6);"></i>Telegram<i style="width:3px;height:3px;border-radius:50%;background:var(--c-n6);"></i><span style="display:inline-flex;align-items:center;gap:5px;"><i style="width:7px;height:7px;border-radius:50%;background:var(--c-accent);"></i>Операторы</span><i style="width:3px;height:3px;border-radius:50%;background:var(--c-n6);"></i>17:10</p>
<button style="width:100%;height:32px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Открыть диалог</button>
</div>
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:18px 22px;">
<h3 style="margin:0 0 10px;font-size:14px;font-weight:700;color:var(--c-n1);">Ответственный</h3>
<div style="display:flex;align-items:center;gap:10px;"><span style="width:30px;height:30px;border-radius:50%;background:url(assets/av-2.png) center/cover;flex:none;"></span><div style="flex:1;min-width:0;"><strong style="display:block;font-size:13px;color:var(--c-n1);">Анна Ким</strong><small style="display:block;font-size:11.5px;color:var(--c-n4);">по текущему диалогу</small></div></div>
</div>
<div style="background:var(--c-warn-bg);border:1px solid var(--c-warn-border);border-radius:12px;padding:16px 20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;"><h3 style="margin:0;font-size:13px;font-weight:700;color:var(--c-n1);">Заметка</h3><small style="font-size:11.5px;color:var(--c-n4);">Анна Ким · 2 сен</small></div>
<p style="margin:0;font-size:13px;color:var(--c-n2);line-height:1.5;">Звонить после 18:00. Доставку всегда просит на утро.</p>
</div>
</div>
</div>
</sc-if>
<!-- IDENTITIES -->
<sc-if value="{{ f.tabIds }}" hint-placeholder-val="{{ false }}">
<div style="display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:16px;align-items:start;">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;overflow:hidden;">
<sc-for list="{{ identities }}" as="i" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:12px;height:60px;padding:0 20px;border-bottom:1px solid var(--c-n9);">
<span style="width:34px;height:34px;flex:none;border-radius:9px;background:{{ i.bg }};color:{{ i.color }};display:flex;align-items:center;justify-content:center;"><svg viewBox="{{ i.view }}" width="18" height="18" fill="{{ i.fill }}" stroke="{{ i.stroke }}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ i.path }}"/></svg></span>
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13.5px;color:var(--c-n1);">{{ i.name }}</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);font-family:'SF Mono',ui-monospace,Menlo,monospace;">{{ i.value }}</small></div>
<small style="font-size:12px;color:var(--c-n4);">{{ i.since }}</small>
<b style="display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:6px;background:{{ i.stBg }};color:{{ i.stColor }};font-size:11.5px;font-weight:600;">{{ i.status }}</b>
</div>
</sc-for>
<p style="margin:0;padding:12px 20px;font-size:12px;color:var(--c-n4);line-height:1.5;background:var(--c-surface2);">Идентичности разных подключений не объединяются автоматически (ADR-0006). Подтверждённой считается только та, что подключение отдало с проверенным телефоном.</p>
</div>
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:18px 22px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;"><h3 style="margin:0;font-size:14px;font-weight:700;color:var(--c-n1);">Возможный дубликат</h3><b style="display:inline-flex;align-items:center;height:20px;padding:0 7px;border-radius:5px;background:var(--c-warn-bg);color:var(--c-warn-text);font-size:11px;font-weight:600;">предложение</b></div>
<p style="margin:0 0 14px;font-size:12.5px;color:var(--c-n4);line-height:1.5;">Совпадает подтверждённый телефон. Объединение перенесёт идентичности и диалоги; операция аудируется и требует причины.</p>
<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--c-n8);border-radius:10px;margin-bottom:14px;"><span style="width:32px;height:32px;flex:none;border-radius:50%;background:var(--c-n4);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;">Г</span><div style="flex:1;min-width:0;"><strong style="display:block;font-size:13px;color:var(--c-n1);">Гость · 8f3a12</strong><small style="display:block;font-size:11.5px;color:var(--c-n4);">Web-виджет · 1 диалог · +7 916 ••• 44-10</small></div></div>
<div style="display:flex;gap:8px;"><button style="flex:1;height:32px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Сравнить и объединить</button><button style="height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Не то</button></div>
</div>
</div>
</sc-if>
<!-- DIALOGS -->
<sc-if value="{{ f.tabDialogs }}" hint-placeholder-val="{{ false }}">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;overflow:hidden;">
<sc-for list="{{ dialogs }}" as="d" hint-placeholder-count="5">
<button style="width:100%;display:grid;grid-template-columns:minmax(0,2.2fr) minmax(0,1.4fr) minmax(0,1fr) 110px 90px;gap:16px;align-items:center;height:60px;padding:0 20px;border:none;border-bottom:1px solid var(--c-n9);background:transparent;text-align:left;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n10);">
<div style="display:flex;align-items:center;gap:10px;min-width:0;"><i style="width:8px;height:8px;flex:none;border-radius:50%;background:{{ d.dot }};"></i><span style="min-width:0;"><strong style="display:block;font-size:13.5px;font-weight:600;color:var(--c-n1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ d.title }}</strong><small style="display:block;margin-top:2px;font-size:11.5px;color:var(--c-n4);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ d.preview }}</small></span></div>
<span style="display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:{{ d.agentColor }};font-weight:500;white-space:nowrap;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6"/></svg>{{ d.agent }}<span style="color:var(--c-n4);font-weight:400;">· {{ d.channel }}</span></span>
<span style="display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:var(--c-n3);"><i style="width:7px;height:7px;border-radius:50%;background:{{ d.groupDot }};"></i>{{ d.group }}</span>
<b style="display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:6px;background:{{ d.stBg }};color:{{ d.stColor }};font-size:11.5px;font-weight:600;justify-self:start;">{{ d.status }}</b>
<small style="font-size:12px;color:var(--c-n4);text-align:right;">{{ d.time }}</small>
</button>
</sc-for>
</div>
</sc-if>
</div>
</sc-if>
</section>
</div>
</div>
</sc-for>
</div>
</section>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'light' | 'dark'&quot;,&quot;section&quot;:&quot;Тема&quot;},&quot;accent&quot;:{&quot;editor&quot;:&quot;color&quot;,&quot;options&quot;:[&quot;#1677ff&quot;,&quot;#6d5dfc&quot;,&quot;#0f9b8e&quot;,&quot;#e8590c&quot;],&quot;default&quot;:&quot;#1677ff&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Тема&quot;}}">
class Component extends DCLogic {
renderVals() {
const dark = (this.props.theme ?? "light") === "dark";
const accent = this.props.accent ?? "#1677ff";
const T = dark
? { canvas: "#101113", surface: "#1b1c1f", surface2: "#181a1d", feed: "#15161a", n1: "#d6d7db", n2: "#c3c5ca", n3: "#9a9ca3", n4: "#767881", n5: "#5b5d66", n6: "#45474f", n7: "#37393f", n8: "#2d2f35", n9: "#27292e", n10: "#212327", ai: "#a98be0", warnText: "#d9a441", okText: "#5fbf7a", accentText: `color-mix(in srgb,${accent} 62%,white)`, accentHover: `color-mix(in srgb,${accent} 92%,white)`, accentBgPct: 16, tintPct: 12, accentMuted: `color-mix(in srgb,${accent} 84%,#1b1c1f)` }
: { canvas: "#e4e6ea", surface: "#ffffff", surface2: "#fafafa", feed: "#f5f6f8", n1: "#1f1f1f", n2: "#262626", n3: "#595959", n4: "#8c8c8c", n5: "#bfbfbf", n6: "#d9d9d9", n7: "#e8e8e8", n8: "#f0f0f0", n9: "#f5f5f5", n10: "#fafafa", ai: "#722ed1", warnText: "#d48806", okText: "#237a3f", accentText: `color-mix(in srgb,${accent} 82%,black)`, accentHover: `color-mix(in srgb,${accent} 88%,black)`, accentBgPct: 10, tintPct: 11, accentMuted: accent };
const themeVars = `--c-canvas:${T.canvas};--c-surface:${T.surface};--c-surface2:${T.surface2};--c-feed:${T.feed};--c-n1:${T.n1};--c-n2:${T.n2};--c-n3:${T.n3};--c-n4:${T.n4};--c-n5:${T.n5};--c-n6:${T.n6};--c-n7:${T.n7};--c-n8:${T.n8};--c-n9:${T.n9};--c-n10:${T.n10};--c-ai:${T.ai};--c-accent:${T.accentMuted};--c-accent-text:${T.accentText};--c-accent-hover:${T.accentHover};--c-accent-bg:color-mix(in srgb,${accent} ${T.accentBgPct}%,${T.surface});--c-warn-bg:color-mix(in srgb,#faad14 ${T.tintPct}%,${T.surface});--c-warn-border:color-mix(in srgb,#faad14 ${T.tintPct + 25}%,${T.surface});--c-warn-text:${T.warnText};--c-ok-bg:color-mix(in srgb,#52c41a ${T.tintPct}%,${T.surface});--c-ok-text:${T.okText};`;
const ICON = {
chat: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z",
contacts: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0z",
agents: "M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01",
team: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
portals: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",
knowledge: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5z",
settings: "M21 6H9M3 6h2M9 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 12h-8M3 12h6M13 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 18h-6M3 18h8M15 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0z",
mail: "M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 7l9 6 9-6",
phone: "M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8.1 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7A2 2 0 0 1 22 16.9z",
building: "M3 21h18M5 21V7l7-4 7 4v14M9 21v-6h6v6M9 10h.01M15 10h.01",
pin: "M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
};
const nav = [
{ key: "chat", label: "Чат", icon: ICON.chat, badge: 2 }, { key: "contacts", label: "Контакты", icon: ICON.contacts }, { key: "agents", label: "Агенты", icon: ICON.agents },
{ key: "employees", label: "Сотрудники", icon: ICON.team }, { key: "portals", label: "Порталы", icon: ICON.portals }, { key: "knowledge", label: "База знаний", icon: ICON.knowledge }, { key: "settings", label: "Настройки", icon: ICON.settings },
].map((n) => ({ ...n, bg: n.key === "contacts" ? "var(--c-accent-bg)" : "transparent", color: n.key === "contacts" ? "var(--c-accent-text)" : "var(--c-n2)", weight: n.key === "contacts" ? 600 : 500 }));
const TG = "M512 256C512 114.62 397.38 0 256 0S0 114.62 0 256s114.62 256 256 256 256-114.62 256-256zm-396.12-2.7c74.63-32.52 124.39-53.95 149.29-64.31 71.1-29.57 85.87-34.71 95.5-34.88 2.12-.03 6.85.49 9.92 2.98 2.59 2.1 3.3 4.94 3.64 6.93.34 2 .77 6.53.43 10.08-3.85 40.48-20.52 138.71-29 184.05-3.59 19.19-10.66 25.62-17.5 26.25-14.86 1.37-26.15-9.83-40.55-19.27-22.53-14.76-35.26-23.96-57.13-38.37-25.28-16.66-8.89-25.81 5.51-40.77 3.77-3.92 69.27-63.5 70.54-68.9.16-.68.31-3.2-1.19-4.53s-3.71-.87-5.3-.51c-2.26.51-38.25 24.3-107.98 71.37-10.22 7.02-19.48 10.43-27.77 10.26-9.14-.2-26.72-5.17-39.79-9.42-16.03-5.21-28.77-7.97-27.66-16.82.57-4.61 6.92-9.32 19.04-14.14z";
const MAX = "M350.4,9.6C141.8,20.5,4.1,184.1,12.8,390.4c3.8,90.3,40.1,168,48.7,253.7,2.2,22.2-4.2,49.6,21.4,59.3,31.5,11.9,79.8-8.1,106.2-26.4,9-6.1,17.6-13.2,24.2-22,27.3,18.1,53.2,35.6,85.7,43.4,143.1,34.3,299.9-44.2,369.6-170.3C799.6,291.2,622.5-4.6,350.4,9.6h0ZM269.4,504c-11.3,8.8-22.2,20.8-34.7,27.7-18.1,9.7-23.7-.4-30.5-16.4-21.4-50.9-24-137.6-11.5-190.9,16.8-72.5,72.9-136.3,150-143.1,78-6.9,150.4,32.7,183.1,104.2,72.4,159.1-112.9,316.2-256.4,218.6h0Z";
const CH = {
TG: { full: "Telegram", view: "0 0 512 512", fill: "currentColor", stroke: "none", path: TG, color: "#229ED9", bg: "color-mix(in srgb,#229ED9 14%,var(--c-surface))" },
MAX: { full: "MAX", view: "0 0 720 720", fill: "currentColor", stroke: "none", path: MAX, color: "#5b5bd6", bg: "color-mix(in srgb,#5b5bd6 14%,var(--c-surface))" },
EMAIL: { full: "Email", view: "0 0 24 24", fill: "none", stroke: "currentColor", path: ICON.mail, color: "#d48806", bg: "color-mix(in srgb,#fa8c16 14%,var(--c-surface))" },
WEB: { full: "Web-виджет", view: "0 0 24 24", fill: "none", stroke: "currentColor", path: ICON.chat, color: "#0f9b8e", bg: "color-mix(in srgb,#0f9b8e 14%,var(--c-surface))" },
};
const DOT = { ai: "var(--c-ai)", wait: "#faad14", human: "var(--c-accent)", closed: "var(--c-n5)" };
const MODE = { ai: "AI отвечает", wait: "Ждёт человека", human: "Ведёт", closed: "Закрыт" };
const AG = { "Консультант": "var(--c-ai)", "Поддержка сайта": "#0f9b8e" };
const base = [
{ name: "Мария Соколова", initials: "МС", avBg: "#eb6f4b", cid: "C-1042", c1: "+7 916 204-11-87", c2: "@msokolova", ch: ["TG"], mode: "ai", who: "AI · Консультант", when: "17:10 · сегодня", open: 1 },
{ name: "Дмитрий Орлов", initials: "ДО", avBg: "#3b82c4", cid: "C-1041", c1: "d.orlov@mail.ru", c2: "", ch: ["WEB"], mode: "wait", who: "Ждёт человека · 6 мин", when: "17:04 · сегодня", open: 1 },
{ name: "Ирина Левина", initials: "ИЛ", avBg: "#8c6ad1", cid: "C-0987", c1: "irina.levina@gmail.com", c2: "+7 903 114-70-02", ch: ["EMAIL", "TG"], mode: "human", who: "Сергей Крылов", when: "16:48 · сегодня", open: 1 },
{ name: "Артём Гусев", initials: "АГ", avBg: "#2aa876", cid: "C-0975", c1: "+7 925 330-18-64", c2: "", ch: ["MAX"], mode: "ai", who: "AI · Консультант", when: "16:31 · сегодня", open: 1 },
{ name: "Гость · 8f3a12", initials: "Г", avBg: "var(--c-n4)", cid: "C-1043", c1: "—", c2: "анонимная сессия виджета", ch: ["WEB"], mode: "human", who: "Анна Ким", when: "15:57 · сегодня", open: 1, anon: true },
{ name: "Ольга Мельник", initials: "ОМ", avBg: "#d1548c", cid: "C-0812", c1: "+7 985 601-22-90", c2: "@olga_m", ch: ["TG"], mode: "wait", who: "Ждёт человека · 1 ч 50 мин", when: "15:20 · сегодня", open: 1 },
{ name: "Сергей Крылов", initials: "СК", avBg: "#13a8a8", cid: "C-0640", c1: "s.krylov@yandex.ru", c2: "@skrylov", ch: ["TG", "EMAIL"], mode: "closed", who: "Закрыт · Елена Кузнецова", when: "вчера, 18:02", open: 0 },
{ name: "Наталья Фёдорова", initials: "НФ", avBg: "#d4860b", cid: "C-0518", c1: "+7 926 774-05-31", c2: "", ch: ["MAX"], mode: "closed", who: "Закрыт · AI", when: "3 сен", open: 0 },
{ name: "Павел Зайцев", initials: "ПЗ", avBg: "#4c6ef0", cid: "C-0490", c1: "p.zaitsev@corp.ru", c2: "", ch: ["EMAIL"], mode: "closed", who: "Закрыт · Анна Ким", when: "1 сен", open: 0 },
{ name: "Виктория Лапина", initials: "ВЛ", avBg: "#c4456b", cid: "C-0311", c1: "+7 915 000-41-19", c2: "@vlapina", ch: ["TG"], mode: "closed", who: "Закрыт · AI", when: "28 авг", open: 0 },
];
const rows = base.map((r) => ({ ...r, c1Color: r.anon ? "var(--c-n5)" : "var(--c-n1)", channels: r.ch.map((k) => CH[k]), dot: DOT[r.mode], lastWho: r.who, lastWhen: r.when, openColor: r.open ? "var(--c-warn-text)" : "var(--c-n5)" }));
const pages = [1, 2, 3, "…", 13].map((n, i) => ({ n, border: i === 0 ? "var(--c-accent)" : "var(--c-n7)", bg: i === 0 ? "var(--c-accent-bg)" : "var(--c-surface)", color: i === 0 ? "var(--c-accent-text)" : "var(--c-n2)" }));
const meta = [
{ icon: ICON.phone, value: "+7 916 204-11-87", font: "inherit" }, { icon: ICON.mail, value: "m.sokolova@gmail.com", font: "inherit" },
{ icon: ICON.building, value: "ИП Соколова", font: "inherit" }, { icon: ICON.pin, value: "Москва", font: "inherit" },
];
const cardChannels = [CH.TG, CH.EMAIL];
const summary = [
{ label: "Диалогов", value: "14", size: "20px", color: "var(--c-n1)" }, { label: "Открытых", value: "1", size: "20px", color: "var(--c-warn-text)" },
{ label: "Первый контакт", value: "12 мар 2026", size: "14px", color: "var(--c-n1)" }, { label: "Последняя активность", value: "сегодня, 17:10", size: "14px", color: "var(--c-n1)" },
];
const activity = [
{ title: "Мария: «Хорошо, жду»", time: "сегодня, 17:10", color: "var(--c-n5)" },
{ title: "AI · Консультант перенёс доставку заказа 4471 на завтра", time: "сегодня, 17:07", color: "var(--c-ai)" },
{ title: "Новый диалог из Telegram → группа «Операторы»", time: "сегодня, 16:58", color: "var(--c-accent)" },
{ title: "Анна Ким закрыла диалог «Примерка платья»", time: "2 сен, 19:40", color: "var(--c-n5)" },
{ title: "Анна Ким добавила заметку", time: "2 сен, 19:38", color: "#faad14", more: false },
].map((a, i, arr) => ({ ...a, more: i < arr.length - 1 }));
const identities = [
{ ...CH.TG, name: "Telegram", value: "user_id 2048113377 · @msokolova", since: "с 12 мар 2026", status: "Подтверждён телефоном", stBg: "var(--c-ok-bg)", stColor: "var(--c-ok-text)" },
{ ...CH.EMAIL, name: "Email", value: "m.sokolova@gmail.com", since: "с 20 мая 2026", status: "Не подтверждён", stBg: "var(--c-n9)", stColor: "var(--c-n4)" },
];
const ST = { ai: ["AI отвечает", "color-mix(in srgb,var(--c-ai) 14%,var(--c-surface))", "var(--c-ai)"], wait: ["Ждёт", "var(--c-warn-bg)", "var(--c-warn-text)"], human: ["Ведёт человек", "var(--c-accent-bg)", "var(--c-accent-text)"], closed: ["Закрыт", "var(--c-n9)", "var(--c-n4)"] };
const dlg = (title, preview, agent, channel, group, mode, time) => ({ title, preview, agent, channel, group, time, agentColor: AG[agent], groupDot: group === "Операторы" ? "var(--c-accent)" : "#2aa876", dot: DOT[mode], status: ST[mode][0], stBg: ST[mode][1], stColor: ST[mode][2] });
const dialogs = [
dlg("Заказ 4471 — когда доставка?", "Мария: Хорошо, жду", "Консультант", "Telegram", "Операторы", "ai", "сегодня, 17:10"),
dlg("Примерка платья", "Анна Ким: Ждём вас в четверг к 18:00", "Консультант", "Telegram", "Операторы", "closed", "2 сен"),
dlg("Счёт за пошив жакета", "Сергей Крылов: Отправил повторно, проверьте спам", "Поддержка сайта", "Email", "Поддержка", "closed", "14 авг"),
dlg("Ткань для пальто", "AI: Подобрала три варианта шерсти…", "Консультант", "Telegram", "Операторы", "closed", "3 июл"),
dlg("Первое обращение", "Мария: Здравствуйте! Шьёте ли вы на заказ?", "Консультант", "Telegram", "Операторы", "closed", "12 мар"),
];
const tabs = (on) => [["overview", "Обзор"], ["dialogs", "Диалоги", 14], ["ids", "Идентификаторы", 2], ["audit", "Аудит"]].map(([k, label, count]) => ({ label, count, line: k === on ? "var(--c-accent-text)" : "transparent", color: k === on ? "var(--c-accent-text)" : "var(--c-n4)", weight: k === on ? 600 : 500 }));
const card = (id, badge, title, note, tab) => ({ id, badge, title, note, isList: false, isCard: true, tabs: tabs(tab), tabOverview: tab === "overview", tabIds: tab === "ids", tabDialogs: tab === "dialogs" });
const frames = [
{ id: "k1", badge: "K1", title: "Список", note: "таблица без коммерции · фильтры: поиск, каналы, агенты, чип «С открытым диалогом» · сортировка по последнему диалогу", isList: true, hasRows: true, isEmpty: false, countLine: "128", pageLine: "1–10 из 128", chipBorder: "var(--c-n7)", chipBg: "var(--c-surface)", chipColor: "var(--c-n2)", filtered: false, rows },
{ id: "k2", badge: "K2", title: "Список · фильтр", note: "чип «С открытым диалогом» активен, счётчик и «Сбросить»", isList: true, hasRows: true, isEmpty: false, countLine: "6 из 128", pageLine: "1–6 из 6", chipBorder: "var(--c-accent)", chipBg: "var(--c-accent-bg)", chipColor: "var(--c-accent-text)", filtered: true, rows: rows.filter((r) => r.open) },
card("k3", "K3", "Карточка · Обзор", "шапка как в контекст-панели чата · сводка · активность · текущий диалог, ответственный, заметка", "overview"),
card("k4", "K4", "Карточка · Диалоги", "все диалоги контакта: тема, агент · канал, группа, статус", "dialogs"),
card("k5", "K5", "Карточка · Идентификаторы", "идентичности по подключениям (ADR-0006) · предложение объединения для владельца", "ids"),
{ id: "s1", badge: "S1", title: "Пустой список", note: "§5.2: что это, зачем, что нажать", isList: true, hasRows: false, isEmpty: true, countLine: "0", rows: [] },
];
for (const f of frames) { if (f.isList) { f.paged = f.id === "k1"; f.exportColor = f.isEmpty ? "var(--c-n5)" : "var(--c-n2)"; f.exportCursor = f.isEmpty ? "default" : "pointer"; } }
return { themeVars, nav, pages, meta, cardChannels, summary, activity, identities, dialogs, frames };
}
}
</script>
</body>
</html>
@@ -1,369 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
a{color:inherit;text-decoration:none;}
a:hover{opacity:0.85;}
*{scrollbar-width:none;}
*::-webkit-scrollbar{display:none;width:0;height:0;}
</style>
</helmet>
<section style="{{ themeVars }}min-height:100vh;background:var(--c-canvas);padding:32px 40px 60px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:var(--c-n2);-webkit-font-smoothing:antialiased;">
<div style="display:flex;align-items:baseline;gap:14px;margin-bottom:6px;">
<h1 style="margin:0;font-size:22px;font-weight:700;letter-spacing:-0.02em;color:var(--c-n1);">Chatbolls · Настройки</h1>
<span style="font-size:13px;color:var(--c-n4);">design baseline · OWNER = ADMIN · SPEC-HUB-0031 §8.6 (уточнено), ADR-0043</span>
</div>
<p style="margin:0 0 28px;font-size:13px;color:var(--c-n3);max-width:960px;line-height:1.55;">Вместо сплошной ленты из девяти секций — субменю разделов слева и один раздел на экране. Порядок: Организация · Группы · AI-провайдер · Интеграции · Голосовые и звонки · Хранилище файлов · Демо-данные; ниже — переходы «База знаний» и «Аудит действий». Профиль пользователя (тема, акцент, безопасность) в настройки не входит — это отдельная страница из меню пользователя. Владелец и админ видят одно и то же.</p>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start;">
<sc-for list="{{ frames }}" as="f" hint-placeholder-count="1">
<div id="{{ f.id }}" style="flex:none;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11.5px;font-weight:700;letter-spacing:0.02em;">{{ f.badge }}</span>
<strong style="font-size:13.5px;color:var(--c-n1);">{{ f.title }}</strong>
<span style="font-size:12px;color:var(--c-n4);">{{ f.note }}</span>
</div>
<!-- DESKTOP FRAME -->
<sc-if value="{{ f.desktop }}" hint-placeholder-val="{{ true }}">
<div style="width:1440px;height:880px;display:flex;overflow:hidden;background:var(--c-surface);border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);position:relative;">
<aside style="position:relative;width:220px;flex:none;background:var(--c-surface2);border-right:1px solid var(--c-n8);display:flex;flex-direction:column;min-height:0;">
<div title="Потяните, двойной клик — сбросить" style="position:absolute;top:0;right:-3px;bottom:0;width:6px;z-index:5;cursor:col-resize;"><i style="position:absolute;top:0;bottom:0;left:2px;width:2px;background:var(--c-accent);opacity:0;"></i></div>
<div style="flex:none;height:56px;display:flex;align-items:center;gap:10px;padding:14px 14px 10px;">
<span style="width:28px;height:28px;flex:none;display:flex;align-items:center;justify-content:center;border-radius:8px;color:var(--c-n1);"><svg viewBox="0 0 100 100" width="26" height="26" fill="currentColor"><defs><mask id="lg-set"><rect width="100" height="100" fill="#fff"/><circle cx="50" cy="24.54" r="9" fill="#000"/><circle cx="50" cy="75.46" r="9" fill="#000"/></mask></defs><g mask="url(#lg-set)"><circle cx="29" cy="50" r="24"/><circle cx="71" cy="50" r="24"/><rect x="44.2" y="31.4" width="11.6" height="37.2"/></g></svg></span>
<button style="flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:6px;height:32px;padding:0 6px 0 8px;border:none;border-radius:8px;background:transparent;color:var(--c-n1);font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Ателье Норд</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
</div>
<nav style="flex:none;padding:4px 10px 8px;display:flex;flex-direction:column;gap:2px;">
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="6">
<button style="display:flex;align-items:center;gap:10px;height:36px;padding:0 10px;border:none;border-radius:8px;background:{{ n.bg }};color:{{ n.color }};font-size:13.5px;font-weight:{{ n.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n8);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ n.icon }}"/></svg><span style="flex:1;">{{ n.label }}</span><sc-if value="{{ n.badge }}" hint-placeholder-val="{{ false }}"><b style="min-width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;padding:0 6px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);color:var(--c-warn-text);font-size:11px;font-weight:700;">{{ n.badge }}</b></sc-if></button>
</sc-for>
</nav>
<div style="flex:1;"></div>
<div style="flex:none;display:flex;align-items:center;gap:10px;padding:12px 14px;border-top:1px solid var(--c-n8);">
<span style="position:relative;flex:none;display:flex;"><span style="width:34px;height:34px;border-radius:50%;background:url(assets/av-4.png) center/cover;"></span><i style="position:absolute;right:-1px;bottom:-1px;width:10px;height:10px;border:2px solid var(--c-surface2);border-radius:50%;background:#52c41a;"></i></span>
<div style="flex:1;min-width:0;line-height:1.2;"><strong style="display:block;font-size:13px;font-weight:600;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Елена Кузнецова</strong><small style="display:block;font-size:11.5px;color:var(--c-n4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Владелец · e.kuznetsova@atelie-nord.ru</small></div>
<button title="Меню пользователя" style="width:28px;height:28px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</aside>
<!-- SETTINGS SUBNAV -->
<section style="width:240px;flex:none;display:flex;flex-direction:column;min-height:0;background:var(--c-surface);border-right:1px solid var(--c-n8);">
<div style="flex:none;padding:17px 18px 10px;"><h2 style="margin:0;font-size:16px;font-weight:700;color:var(--c-n1);">Настройки</h2></div>
<nav style="flex:1;padding:4px 10px;display:flex;flex-direction:column;gap:1px;">
<sc-for list="{{ f.sub }}" as="s" hint-placeholder-count="7">
<button style="display:flex;align-items:center;gap:10px;min-height:34px;padding:0 10px;border:none;border-radius:8px;background:{{ s.bg }};color:{{ s.color }};font-size:13px;font-weight:{{ s.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ s.icon }}"/></svg><span style="flex:1;">{{ s.label }}</span><sc-if value="{{ s.count }}" hint-placeholder-val="{{ false }}"><small style="font-size:11.5px;font-weight:600;color:{{ s.countColor }};">{{ s.count }}</small></sc-if><sc-if value="{{ s.warn }}" hint-placeholder-val="{{ false }}"><i title="Есть ошибка" style="width:7px;height:7px;border-radius:50%;background:#ff4d4f;flex:none;"></i></sc-if></button>
</sc-for>
<div style="height:1px;background:var(--c-n8);margin:8px 6px;"></div>
<div style="padding:2px 10px 6px;font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.04em;text-transform:uppercase;">Отдельные экраны</div>
<sc-for list="{{ links }}" as="l" hint-placeholder-count="2">
<button style="display:flex;align-items:center;gap:10px;height:34px;padding:0 10px;border:none;border-radius:8px;background:transparent;color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ l.icon }}"/></svg><span style="flex:1;">{{ l.label }}</span><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n5)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7M8 7h9v9"/></svg></button>
</sc-for>
</nav>
</section>
<!-- CONTENT -->
<section style="flex:1;min-width:0;min-height:0;overflow-y:auto;background:var(--c-feed);">
<div style="max-width:{{ f.maxW }};padding:28px 40px 48px;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:22px;">
<div><h2 style="margin:0;font-size:20px;font-weight:700;letter-spacing:-0.01em;color:var(--c-n1);">{{ f.heading }}</h2><p style="margin:5px 0 0;font-size:13px;color:var(--c-n4);line-height:1.5;max-width:560px;">{{ f.lead }}</p></div>
<sc-if value="{{ f.headAction }}" hint-placeholder-val="{{ false }}"><button style="flex:none;height:34px;padding:0 14px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;" style-hover="background:var(--c-accent-hover);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>{{ f.headAction }}</button></sc-if>
</div>
<!-- ORGANIZATION -->
<sc-if value="{{ f.isOrg }}" hint-placeholder-val="{{ true }}">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:22px 24px;">
<div style="display:flex;align-items:center;gap:16px;padding-bottom:20px;margin-bottom:20px;border-bottom:1px solid var(--c-n9);">
<span style="width:64px;height:64px;flex:none;border-radius:14px;background:var(--c-n9);border:1px dashed var(--c-n6);display:flex;align-items:center;justify-content:center;color:var(--c-n1);font-size:22px;font-weight:700;">АН</span>
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13.5px;color:var(--c-n1);">Логотип</strong><small style="display:block;margin-top:3px;font-size:12px;color:var(--c-n4);line-height:1.45;">PNG или SVG, квадрат от 128 px. Виден в сайдбаре, виджете и письмах.</small></div>
<div style="display:flex;gap:8px;flex:none;">
<button style="height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Загрузить</button>
<button style="height:32px;padding:0 12px;border-radius:8px;border:none;background:transparent;color:var(--c-n4);font-size:12.5px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);color:var(--c-n2);">Убрать</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px 20px;">
<label style="grid-column:1 / -1;display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Название организации</span><span style="display:flex;align-items:center;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13.5px;color:var(--c-n1);">Ателье Норд</span></label>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Часовой пояс</span><span style="display:flex;align-items:center;justify-content:space-between;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13.5px;color:var(--c-n1);">Europe/Moscow · UTC+3<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span></label>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Валюта</span><span style="display:flex;align-items:center;justify-content:space-between;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13.5px;color:var(--c-n1);">Российский рубль (RUB)<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span></label>
</div>
<div style="display:flex;align-items:center;justify-content:flex-end;gap:12px;margin-top:22px;padding-top:18px;border-top:1px solid var(--c-n9);">
<small style="font-size:12px;color:var(--c-n4);">Сохранено 2 сен, 14:12</small>
<button style="height:34px;padding:0 16px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Сохранить</button>
</div>
</div>
</sc-if>
<!-- GROUPS -->
<sc-if value="{{ f.isGroups }}" hint-placeholder-val="{{ false }}">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;overflow:hidden;">
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:12px;height:52px;padding:0 20px;border-bottom:1px solid var(--c-n9);" style-hover="background:var(--c-n10);">
<i style="width:10px;height:10px;border-radius:50%;background:{{ g.color }};flex:none;"></i>
<strong style="flex:1;font-size:13.5px;font-weight:600;color:var(--c-n1);">{{ g.name }}</strong>
<small style="font-size:12.5px;color:var(--c-n4);">{{ g.members }}</small>
<small style="font-size:12.5px;color:var(--c-n4);min-width:90px;text-align:right;">{{ g.dialogs }}</small>
<div style="display:flex;gap:2px;margin-left:8px;">
<button title="Изменить" style="width:28px;height:28px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></button>
<button title="Удалить" style="width:28px;height:28px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:#ff4d4f;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6"/></svg></button>
</div>
</div>
</sc-for>
<div style="display:flex;align-items:center;gap:10px;padding:14px 20px;background:var(--c-surface2);">
<span style="flex:1;display:flex;align-items:center;height:34px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);font-size:13px;color:var(--c-n5);">Название новой группы</span>
<button style="height:34px;padding:0 14px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Создать</button>
</div>
</div>
<p style="margin:12px 4px 0;font-size:12.5px;color:var(--c-n4);line-height:1.5;">Состав группы редактируется на карточке сотрудника. Диалоги без группы видят все сотрудники.</p>
</sc-if>
<!-- TABLE (AI providers / integrations) -->
<sc-if value="{{ f.isTable }}" hint-placeholder-val="{{ false }}">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;overflow:hidden;">
<div style="display:grid;grid-template-columns:{{ f.cols }};gap:16px;align-items:center;padding:10px 20px;border-bottom:1px solid var(--c-n8);background:var(--c-surface2);">
<sc-for list="{{ f.head }}" as="h" hint-placeholder-count="4"><span style="font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.05em;text-transform:uppercase;">{{ h }}</span></sc-for>
</div>
<sc-for list="{{ f.rows }}" as="r" hint-placeholder-count="3">
<div style="display:grid;grid-template-columns:{{ f.cols }};gap:16px;align-items:center;min-height:62px;padding:10px 20px;border-bottom:1px solid var(--c-n9);opacity:{{ r.opacity }};" style-hover="background:var(--c-n10);">
<div style="display:flex;align-items:center;gap:12px;min-width:0;">
<span style="width:36px;height:36px;flex:none;border-radius:9px;background:{{ r.tileBg }};color:{{ r.tileColor }};display:flex;align-items:center;justify-content:center;"><svg viewBox="{{ r.view }}" width="20" height="20" fill="{{ r.fill }}" stroke="{{ r.stroke }}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ r.path }}"/></svg></span>
<span style="min-width:0;"><strong style="display:block;font-size:13.5px;font-weight:600;color:var(--c-n1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ r.name }}</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ r.sub }}</small></span>
</div>
<div style="min-width:0;font-size:13px;color:var(--c-n2);"><sc-if value="{{ r.robot }}" hint-placeholder-val="{{ false }}"><span style="display:inline-flex;align-items:center;gap:6px;color:{{ r.agentColor }};font-weight:500;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6"/></svg>{{ r.c2 }}</span></sc-if><sc-if value="{{ r.mono }}" hint-placeholder-val="{{ false }}"><code style="font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:12.5px;color:var(--c-n3);">{{ r.c2 }}</code></sc-if><sc-if value="{{ r.plain }}" hint-placeholder-val="{{ false }}"><span style="color:var(--c-n5);">{{ r.c2 }}</span></sc-if></div>
<sc-if value="{{ r.c3 }}" hint-placeholder-val="{{ false }}"><div style="min-width:0;font-size:12.5px;color:var(--c-n3);line-height:1.4;"><span style="display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ r.c3 }}</span><sc-if value="{{ r.c3sub }}" hint-placeholder-val="{{ false }}"><small style="display:block;font-size:11.5px;color:var(--c-n4);">{{ r.c3sub }}</small></sc-if></div></sc-if>
<div style="display:flex;flex-direction:column;gap:3px;align-items:flex-start;min-width:0;"><b style="display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:6px;background:{{ r.stBg }};color:{{ r.stColor }};font-size:11.5px;font-weight:600;white-space:nowrap;">{{ r.status }}</b><small style="font-size:11.5px;color:var(--c-n4);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;">{{ r.checked }}</small></div>
<div style="display:flex;align-items:center;justify-content:flex-end;gap:4px;">
<button style="height:30px;padding:0 10px;border-radius:7px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px;font-family:inherit;white-space:nowrap;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4L21 8"/><path d="M21 3v5h-5"/></svg>Проверить</button>
<button title="Действия" style="width:30px;height:30px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</div>
</sc-for>
</div>
<sc-if value="{{ f.tableNote }}" hint-placeholder-val="{{ false }}"><p style="margin:12px 4px 0;font-size:12.5px;color:var(--c-n4);line-height:1.5;">{{ f.tableNote }}</p></sc-if>
</sc-if>
<!-- COMMUNICATION MATRIX -->
<sc-if value="{{ f.isComm }}" hint-placeholder-val="{{ false }}">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;overflow:hidden;">
<div style="display:grid;grid-template-columns:1fr 120px 120px 120px;gap:12px;align-items:center;padding:10px 20px;border-bottom:1px solid var(--c-n8);background:var(--c-surface2);">
<span style="font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.05em;text-transform:uppercase;">Точка входа</span>
<span style="font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.05em;text-transform:uppercase;text-align:center;">Голосовые</span>
<span style="font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.05em;text-transform:uppercase;text-align:center;">Аудиозвонки</span>
<span style="font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.05em;text-transform:uppercase;text-align:center;">Видеозвонки</span>
</div>
<sc-for list="{{ comm }}" as="c" hint-placeholder-count="4">
<div style="display:grid;grid-template-columns:1fr 120px 120px 120px;gap:12px;align-items:center;height:56px;padding:0 20px;border-bottom:1px solid var(--c-n9);opacity:{{ c.opacity }};">
<div style="display:flex;align-items:center;gap:10px;min-width:0;white-space:nowrap;"><svg viewBox="{{ c.view }}" width="16" height="16" fill="{{ c.fill }}" stroke="{{ c.stroke }}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;color:var(--c-n3);"><path d="{{ c.path }}"/></svg><strong style="font-size:13.5px;font-weight:600;color:var(--c-n1);">{{ c.name }}</strong><small style="font-size:12px;color:var(--c-n4);overflow:hidden;text-overflow:ellipsis;">{{ c.sub }}</small></div>
<sc-for list="{{ c.flags }}" as="fl" hint-placeholder-count="3">
<div style="display:flex;justify-content:center;">
<sc-if value="{{ fl.na }}" hint-placeholder-val="{{ false }}"><small style="font-size:12px;color:var(--c-n5);">недоступно</small></sc-if>
<sc-if value="{{ fl.sw }}" hint-placeholder-val="{{ true }}"><span style="position:relative;width:36px;height:20px;border-radius:10px;background:{{ fl.bg }};display:inline-block;cursor:pointer;"><i style="position:absolute;top:2px;left:{{ fl.knob }};width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,0.25);"></i></span></sc-if>
</div>
</sc-for>
</div>
</sc-for>
</div>
<p style="margin:12px 4px 0;font-size:12.5px;color:var(--c-n4);line-height:1.5;">Голосовые, присланные клиентом из мессенджера, принимаются всегда. Почта звонки не поддерживает. Изменения применяются сразу.</p>
</sc-if>
<!-- STORAGE -->
<sc-if value="{{ f.isStorage }}" hint-placeholder-val="{{ false }}">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:22px 24px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding-bottom:18px;border-bottom:1px solid var(--c-n9);">
<div><strong style="display:block;font-size:13.5px;color:var(--c-n1);">Где хранить файлы</strong><small style="display:block;margin-top:3px;font-size:12px;color:var(--c-n4);">Вложения знаний, голосовые, фото и логотипы</small></div>
<div style="display:inline-flex;padding:3px;border-radius:9px;background:var(--c-n9);">
<button style="height:30px;padding:0 14px;border-radius:7px;border:none;background:transparent;color:var(--c-n3);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;">На диске установки</button>
<button style="height:30px;padding:0 14px;border-radius:7px;border:none;background:var(--c-surface);color:var(--c-n1);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;box-shadow:0 1px 2px rgba(0,0,0,0.08);">Внешнее S3</button>
</div>
</div>
<p style="margin:16px 0 18px;font-size:12.5px;color:var(--c-n3);line-height:1.5;">Новые файлы будут записываться в бакет под префиксами организаций. Уже загруженные остаются доступны с диска, пока не перенесены.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px 20px;">
<sc-for list="{{ s3 }}" as="x" hint-placeholder-count="6">
<label style="grid-column:{{ x.span }};display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">{{ x.label }}</span><span style="display:flex;align-items:center;justify-content:space-between;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13px;color:{{ x.color }};font-family:{{ x.font }};">{{ x.value }}<sc-if value="{{ x.select }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></sc-if></span></label>
</sc-for>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-top:18px;padding:10px 12px;border-radius:8px;background:var(--c-ok-bg);border:1px solid var(--c-ok-border);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--c-ok-text)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><span style="font-size:12.5px;color:var(--c-ok-text);font-weight:500;">Доступ проверен 5 сен, 09:41 · перенос завершён: 1 284 файла</span></div>
<div style="display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:20px;padding-top:18px;border-top:1px solid var(--c-n9);">
<button style="height:34px;padding:0 14px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Перенести файлы с диска</button>
<button style="height:34px;padding:0 14px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Проверить доступ</button>
<button style="height:34px;padding:0 16px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Сохранить</button>
</div>
</div>
</sc-if>
<!-- DEMO -->
<sc-if value="{{ f.isDemo }}" hint-placeholder-val="{{ false }}">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:22px 24px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
<div style="display:flex;align-items:center;gap:10px;"><strong style="font-size:13.5px;color:var(--c-n1);">Ателье «Норд»</strong><b style="display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:6px;background:var(--c-ok-bg);color:var(--c-ok-text);font-size:11.5px;font-weight:600;">Установлены</b><small style="font-size:12px;color:var(--c-n4);">3 412 записей · 1 сен</small></div>
<button style="height:32px;padding:0 12px;border-radius:8px;border:1px solid #ff4d4f;background:transparent;color:#ff4d4f;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:color-mix(in srgb,#ff4d4f 8%,var(--c-surface));">Удалить демо-данные</button>
</div>
<p style="margin:12px 0 18px;font-size:12.5px;color:var(--c-n3);line-height:1.5;">Сотрудники и группы, агенты с подключениями, знания, диалоги во всех состояниях, голосовые, шаблоны, портал поддержки, звонки и уведомления. Удаляются целиком — ваши данные не затрагиваются.</p>
<div style="border-top:1px solid var(--c-n9);padding-top:16px;"><small style="display:block;margin-bottom:10px;font-size:12px;color:var(--c-n4);">Войдите под демо-сотрудником в приватном окне, чтобы увидеть систему его глазами:</small>
<div style="display:grid;grid-template-columns:1fr auto auto;gap:8px 20px;align-items:center;">
<sc-for list="{{ demoAccounts }}" as="a" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:10px;min-width:0;"><span style="width:28px;height:28px;flex:none;border-radius:50%;background:{{ a.av }};"></span><span style="min-width:0;"><strong style="display:block;font-size:13px;color:var(--c-n1);">{{ a.name }}</strong><small style="display:block;font-size:11.5px;color:var(--c-n4);">{{ a.role }}</small></span></div>
<code style="font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:12px;color:var(--c-n2);background:var(--c-n9);padding:3px 8px;border-radius:5px;">{{ a.email }}</code>
<code style="font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:12px;color:var(--c-n2);background:var(--c-n9);padding:3px 8px;border-radius:5px;">{{ a.pass }}</code>
</sc-for>
</div>
</div>
</div>
</sc-if>
</div>
</section>
</div>
</sc-if>
<!-- MOBILE FRAMES -->
<sc-if value="{{ f.mobile }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;gap:24px;">
<div style="width:390px;height:844px;display:flex;flex-direction:column;overflow:hidden;background:var(--c-surface);border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);">
<div style="flex:none;display:flex;align-items:center;gap:10px;padding:14px 16px 10px;border-bottom:1px solid var(--c-n8);"><button style="width:32px;height:32px;border:none;border-radius:8px;background:transparent;color:var(--c-n2);display:flex;align-items:center;justify-content:center;cursor:pointer;"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></button><h2 style="margin:0;flex:1;font-size:17px;font-weight:700;color:var(--c-n1);">Настройки</h2></div>
<div style="flex:1;overflow-y:auto;padding:8px 12px;">
<sc-for list="{{ f.sub }}" as="s" hint-placeholder-count="7">
<button style="width:100%;display:flex;align-items:center;gap:12px;height:48px;padding:0 10px;border:none;border-radius:10px;background:transparent;color:var(--c-n1);font-size:15px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;border-bottom:1px solid var(--c-n9);"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="var(--c-n3)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ s.icon }}"/></svg><span style="flex:1;">{{ s.label }}</span><sc-if value="{{ s.count }}" hint-placeholder-val="{{ false }}"><small style="font-size:12.5px;color:var(--c-n4);">{{ s.count }}</small></sc-if><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--c-n5)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg></button>
</sc-for>
<div style="padding:16px 10px 6px;font-size:11px;font-weight:600;color:var(--c-n4);letter-spacing:0.04em;text-transform:uppercase;">Отдельные экраны</div>
<sc-for list="{{ links }}" as="l" hint-placeholder-count="2">
<button style="width:100%;display:flex;align-items:center;gap:12px;height:48px;padding:0 10px;border:none;border-radius:10px;background:transparent;color:var(--c-n1);font-size:15px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="var(--c-n3)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ l.icon }}"/></svg><span style="flex:1;">{{ l.label }}</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n5)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7M8 7h9v9"/></svg></button>
</sc-for>
</div>
</div>
<div style="width:390px;height:844px;display:flex;flex-direction:column;overflow:hidden;background:var(--c-feed);border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);">
<div style="flex:none;display:flex;align-items:center;gap:6px;padding:14px 12px 10px;background:var(--c-surface);border-bottom:1px solid var(--c-n8);"><button style="width:32px;height:32px;border:none;border-radius:8px;background:transparent;color:var(--c-n2);display:flex;align-items:center;justify-content:center;cursor:pointer;"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 6 9 12 15 18"/></svg></button><h2 style="margin:0;flex:1;font-size:17px;font-weight:700;color:var(--c-n1);">Организация</h2></div>
<div style="flex:1;overflow-y:auto;padding:16px;">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:18px 16px;display:flex;flex-direction:column;gap:16px;">
<div style="display:flex;align-items:center;gap:14px;"><span style="width:56px;height:56px;flex:none;border-radius:12px;background:var(--c-n9);border:1px dashed var(--c-n6);display:flex;align-items:center;justify-content:center;color:var(--c-n1);font-size:19px;font-weight:700;">АН</span><div style="flex:1;"><strong style="display:block;font-size:14px;color:var(--c-n1);">Логотип</strong><button style="margin-top:6px;height:30px;padding:0 12px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;">Загрузить</button></div></div>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Название организации</span><span style="display:flex;align-items:center;height:44px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:15px;color:var(--c-n1);">Ателье Норд</span></label>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Часовой пояс</span><span style="display:flex;align-items:center;justify-content:space-between;height:44px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:15px;color:var(--c-n1);">Europe/Moscow<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span></label>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Валюта</span><span style="display:flex;align-items:center;justify-content:space-between;height:44px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:15px;color:var(--c-n1);">Российский рубль (RUB)<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span></label>
</div>
</div>
<div style="flex:none;padding:12px 16px 20px;background:var(--c-surface);border-top:1px solid var(--c-n8);"><button style="width:100%;height:46px;border-radius:10px;border:none;background:var(--c-accent);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">Сохранить</button></div>
</div>
</div>
</sc-if>
</div>
</sc-for>
</div>
</section>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'light' | 'dark'&quot;,&quot;section&quot;:&quot;Тема&quot;},&quot;accent&quot;:{&quot;editor&quot;:&quot;color&quot;,&quot;options&quot;:[&quot;#1677ff&quot;,&quot;#5b5bd6&quot;,&quot;#0f9b8e&quot;,&quot;#fa8c16&quot;],&quot;default&quot;:&quot;#1677ff&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Тема&quot;}}">
class Component extends DCLogic {
renderVals() {
const dark = (this.props.theme ?? "light") === "dark";
const accent = this.props.accent ?? "#1677ff";
const T = dark
? { canvas: "#101113", surface: "#1b1c1f", surface2: "#181a1d", feed: "#15161a", n1: "#d6d7db", n2: "#c3c5ca", n3: "#9a9ca3", n4: "#767881", n5: "#5b5d66", n6: "#45474f", n7: "#37393f", n8: "#2d2f35", n9: "#27292e", n10: "#212327", ai: "#a98be0", warnText: "#d9a441", okText: "#5fbf7a", errText: "#ff7875", accentText: `color-mix(in srgb,${accent} 62%,white)`, accentHover: `color-mix(in srgb,${accent} 92%,white)`, accentBgPct: 16, tintPct: 12, accentMuted: `color-mix(in srgb,${accent} 84%,#1b1c1f)` }
: { canvas: "#e4e6ea", surface: "#ffffff", surface2: "#fafafa", feed: "#f5f6f8", n1: "#1f1f1f", n2: "#262626", n3: "#595959", n4: "#8c8c8c", n5: "#bfbfbf", n6: "#d9d9d9", n7: "#e8e8e8", n8: "#f0f0f0", n9: "#f5f5f5", n10: "#fafafa", ai: "#722ed1", warnText: "#d48806", okText: "#237a3f", errText: "#cf1322", accentText: `color-mix(in srgb,${accent} 82%,black)`, accentHover: `color-mix(in srgb,${accent} 88%,black)`, accentBgPct: 10, tintPct: 11, accentMuted: accent };
const themeVars = `--c-canvas:${T.canvas};--c-surface:${T.surface};--c-surface2:${T.surface2};--c-feed:${T.feed};--c-n1:${T.n1};--c-n2:${T.n2};--c-n3:${T.n3};--c-n4:${T.n4};--c-n5:${T.n5};--c-n6:${T.n6};--c-n7:${T.n7};--c-n8:${T.n8};--c-n9:${T.n9};--c-n10:${T.n10};--c-ai:${T.ai};--c-accent:${T.accentMuted};--c-accent-text:${T.accentText};--c-accent-hover:${T.accentHover};--c-accent-bg:color-mix(in srgb,${accent} ${T.accentBgPct}%,${T.surface});--c-warn-bg:color-mix(in srgb,#faad14 ${T.tintPct}%,${T.surface});--c-warn-border:color-mix(in srgb,#faad14 ${T.tintPct + 25}%,${T.surface});--c-warn-text:${T.warnText};--c-ok-bg:color-mix(in srgb,#52c41a ${T.tintPct}%,${T.surface});--c-ok-border:color-mix(in srgb,#52c41a ${T.tintPct + 20}%,${T.surface});--c-ok-text:${T.okText};--c-err-bg:color-mix(in srgb,#ff4d4f ${T.tintPct}%,${T.surface});--c-err-text:${T.errText};`;
const ICON = {
chat: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z",
contacts: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0z",
agents: "M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01",
team: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
portals: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",
knowledge: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5z",
settings: "M21 6H9M3 6h2M9 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 12h-8M3 12h6M13 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 18h-6M3 18h8M15 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0z",
org: "M3 21h18M5 21V7l7-4 7 4v14M9 21v-6h6v6M9 10h.01M15 10h.01",
groups: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
ai: "M12 3l1.8 4.6L18.4 9.4 13.8 11.2 12 15.8 10.2 11.2 5.6 9.4 10.2 7.6zM19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8zM5 14l.6 1.6L7.2 16.2 5.6 16.8 5 18.4l-.6-1.6L2.8 16.2l1.6-.6z",
plug: "M12 22v-5M9 8V2M15 8V2M18 8v5a6 6 0 0 1-12 0V8z",
voice: "M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3ZM19 10v2a7 7 0 0 1-14 0v-2M12 19v3",
storage: "M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zM4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3",
demo: "M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8",
folder: "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z",
list: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
mail: "M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 7l9 6 9-6",
};
const nav = [
{ key: "chat", label: "Чат", icon: ICON.chat, badge: 2 }, { key: "contacts", label: "Контакты", icon: ICON.contacts }, { key: "agents", label: "Агенты", icon: ICON.agents },
{ key: "employees", label: "Сотрудники", icon: ICON.team }, { key: "portals", label: "Порталы", icon: ICON.portals }, { key: "knowledge", label: "База знаний", icon: ICON.knowledge }, { key: "settings", label: "Настройки", icon: ICON.settings },
].map((n) => ({ ...n, bg: n.key === "settings" ? "var(--c-accent-bg)" : "transparent", color: n.key === "settings" ? "var(--c-accent-text)" : "var(--c-n2)", weight: n.key === "settings" ? 600 : 500 }));
const SECTIONS = [
{ key: "org", label: "Организация", icon: ICON.org },
{ key: "groups", label: "Группы", icon: ICON.groups, count: 2 },
{ key: "ai", label: "AI-провайдер", icon: ICON.ai, count: 2 },
{ key: "int", label: "Интеграции", icon: ICON.plug, count: 4, warn: true },
{ key: "comm", label: "Голосовые и звонки", icon: ICON.voice },
{ key: "storage", label: "Хранилище файлов", icon: ICON.storage },
{ key: "demo", label: "Демо-данные", icon: ICON.demo },
];
const sub = (active) => SECTIONS.map((s) => ({ ...s, bg: s.key === active ? "var(--c-accent-bg)" : "transparent", color: s.key === active ? "var(--c-accent-text)" : "var(--c-n2)", weight: s.key === active ? 600 : 500, countColor: s.key === active ? "var(--c-accent-text)" : "var(--c-n5)", warn: s.warn && s.key !== active }));
const links = [{ label: "База знаний", icon: ICON.folder }, { label: "Аудит действий", icon: ICON.list }];
const groups = [
{ name: "Операторы", color: "var(--c-accent)", members: "4 чел.", dialogs: "4 диалога" },
{ name: "Поддержка", color: "#2aa876", members: "2 чел.", dialogs: "2 диалога" },
];
const TG = { view: "0 0 512 512", fill: "currentColor", stroke: "none", path: "M512 256C512 114.62 397.38 0 256 0S0 114.62 0 256s114.62 256 256 256 256-114.62 256-256zm-396.12-2.7c74.63-32.52 124.39-53.95 149.29-64.31 71.1-29.57 85.87-34.71 95.5-34.88 2.12-.03 6.85.49 9.92 2.98 2.59 2.1 3.3 4.94 3.64 6.93.34 2 .77 6.53.43 10.08-3.85 40.48-20.52 138.71-29 184.05-3.59 19.19-10.66 25.62-17.5 26.25-14.86 1.37-26.15-9.83-40.55-19.27-22.53-14.76-35.26-23.96-57.13-38.37-25.28-16.66-8.89-25.81 5.51-40.77 3.77-3.92 69.27-63.5 70.54-68.9.16-.68.31-3.2-1.19-4.53s-3.71-.87-5.3-.51c-2.26.51-38.25 24.3-107.98 71.37-10.22 7.02-19.48 10.43-27.77 10.26-9.14-.2-26.72-5.17-39.79-9.42-16.03-5.21-28.77-7.97-27.66-16.82.57-4.61 6.92-9.32 19.04-14.14z" };
const MAX = { view: "0 0 720 720", fill: "currentColor", stroke: "none", path: "M350.4,9.6C141.8,20.5,4.1,184.1,12.8,390.4c3.8,90.3,40.1,168,48.7,253.7,2.2,22.2-4.2,49.6,21.4,59.3,31.5,11.9,79.8-8.1,106.2-26.4,9-6.1,17.6-13.2,24.2-22,27.3,18.1,53.2,35.6,85.7,43.4,143.1,34.3,299.9-44.2,369.6-170.3C799.6,291.2,622.5-4.6,350.4,9.6h0ZM269.4,504c-11.3,8.8-22.2,20.8-34.7,27.7-18.1,9.7-23.7-.4-30.5-16.4-21.4-50.9-24-137.6-11.5-190.9,16.8-72.5,72.9-136.3,150-143.1,78-6.9,150.4,32.7,183.1,104.2,72.4,159.1-112.9,316.2-256.4,218.6h0Z" };
const LINE = (path) => ({ view: "0 0 24 24", fill: "none", stroke: "currentColor", path });
const ST = {
ok: { status: "Подключено", stBg: "var(--c-ok-bg)", stColor: "var(--c-ok-text)" },
err: { status: "Ошибка", stBg: "var(--c-err-bg)", stColor: "var(--c-err-text)" },
un: { status: "Не проверено", stBg: "var(--c-n9)", stColor: "var(--c-n4)" },
off: { status: "Отключено", stBg: "var(--c-n9)", stColor: "var(--c-n4)" },
};
const row = (r) => ({ opacity: r.off ? 0.55 : 1, ...ST[r.st], ...r });
const aiRows = [
row({ name: "OpenRouter · основной", sub: "OpenRouter", ...LINE(ICON.ai), tileBg: "color-mix(in srgb,var(--c-ai) 12%,var(--c-surface))", tileColor: "var(--c-ai)", mono: true, c2: "sk-or-••••••••", c3: "https://openrouter.ai/api/v1", c3sub: "anthropic/claude-sonnet-4", st: "ok", checked: "5 сен, 09:40" }),
row({ name: "Свой endpoint", sub: "Custom (OpenAI-compatible)", ...LINE(ICON.ai), tileBg: "var(--c-n9)", tileColor: "var(--c-n3)", mono: true, c2: "••••••••", c3: "https://llm.atelie-nord.ru/v1", c3sub: "qwen2.5-32b-instruct", st: "un", checked: "ещё не проверялось" }),
];
const agentColor = { "Консультант": "var(--c-ai)", "Поддержка сайта": "#0f9b8e" };
const intRows = [
row({ name: "Бот Ателье Норд", sub: "Telegram · @atelie_nord_bot", ...TG, tileBg: "color-mix(in srgb,#229ED9 14%,var(--c-surface))", tileColor: "#229ED9", robot: true, c2: "Консультант", agentColor: agentColor["Консультант"], st: "ok", checked: "5 сен, 09:41" }),
row({ name: "MAX · Норд", sub: "MAX · Ателье Норд", ...MAX, tileBg: "color-mix(in srgb,#5b5bd6 14%,var(--c-surface))", tileColor: "#5b5bd6", robot: true, c2: "Консультант", agentColor: agentColor["Консультант"], st: "ok", checked: "5 сен, 09:41" }),
row({ name: "Виджет на сайте", sub: "Web-виджет · atelie-nord.ru", ...LINE(ICON.chat), tileBg: "var(--c-accent-bg)", tileColor: "var(--c-accent-text)", robot: true, c2: "Поддержка сайта", agentColor: agentColor["Поддержка сайта"], st: "ok", checked: "5 сен, 09:41" }),
row({ name: "Почта поддержки", sub: "Email (IMAP/SMTP) · help@atelie-nord.ru", ...LINE(ICON.mail), tileBg: "color-mix(in srgb,#fa8c16 14%,var(--c-surface))", tileColor: "#fa8c16", robot: true, c2: "Поддержка сайта", agentColor: agentColor["Поддержка сайта"], st: "err", checked: "IMAP: неверный пароль приложения" }),
];
const sw = (on) => ({ sw: true, bg: on ? "var(--c-accent)" : "var(--c-n6)", knob: on ? "18px" : "2px" });
const NA = { na: true };
const comm = [
{ name: "Бот Ателье Норд", sub: "Консультант", ...TG, flags: [sw(true), sw(true), sw(false)], opacity: 1 },
{ name: "MAX · Норд", sub: "Консультант", ...MAX, flags: [sw(true), sw(false), sw(false)], opacity: 1 },
{ name: "Виджет на сайте", sub: "Поддержка сайта", ...LINE(ICON.chat), flags: [sw(true), sw(true), sw(true)], opacity: 1 },
{ name: "Почта поддержки", sub: "Поддержка сайта · выключена", ...LINE(ICON.mail), flags: [sw(false), NA, NA], opacity: 0.55 },
];
const fld = (label, value, o = {}) => ({ label, value, span: o.wide ? "1 / -1" : "auto", select: !!o.select, color: o.ph ? "var(--c-n5)" : "var(--c-n1)", font: o.mono ? "'SF Mono',ui-monospace,Menlo,monospace" : "inherit" });
const s3 = [fld("Бакет", "chatballs-files"), fld("Регион", "ru-central1"), fld("Endpoint (для не-AWS провайдеров)", "https://storage.yandexcloud.net", { wide: true }), fld("Адресация", "path (bucket в пути)", { select: true }), fld("Access Key", "YCAJ••••••••••7Qx", { mono: true }), fld("Secret Key", "•••••••• (сохранён)", { mono: true, ph: true })];
const demoAccounts = [
{ name: "Анна Ким", role: "Администратор", email: "admin@nord.demo", pass: "nord-demo-1", av: "url(assets/av-2.png) center/cover" },
{ name: "Дмитрий Орлов", role: "Сотрудник · Операторы", email: "operator@nord.demo", pass: "nord-demo-2", av: "url(assets/av-1.png) center/cover" },
{ name: "Сергей Крылов", role: "Сотрудник · Поддержка", email: "support@nord.demo", pass: "nord-demo-3", av: "url(assets/av-3.png) center/cover" },
];
const frame = (id, badge, title, note, key, heading, lead, extra) => ({ id, badge, title, note, desktop: true, sub: sub(key), heading, lead, maxW: (extra.isTable || extra.isComm) ? "1000px" : "820px", isOrg: false, isGroups: false, isTable: false, isComm: false, isStorage: false, isDemo: false, headAction: "", tableNote: "", ...extra });
const frames = [
frame("n1", "N1", "Организация", "субменю разделов · один раздел на экране · без «Профиля»", "org", "Организация", "Название, логотип и региональные параметры. Видны сотрудникам, в виджете и письмах.", { isOrg: true }),
frame("n2", "N2", "Группы", "список с составом и охватом · создание — одно поле", "groups", "Группы", "Делят диалоги между сотрудниками: сотрудник видит диалоги своих групп, без группы и те, где он ответственный.", { isGroups: true }),
frame("n3", "N3", "AI-провайдер", "BYOK · таблица провайдеров", "ai", "AI-провайдер", "Ключи вашей организации для ответов агентов и расшифровки голосовых. Модель выбирается на карточке агента.", { isTable: true, headAction: "Добавить провайдера", cols: "minmax(0,1.7fr) auto minmax(0,1.6fr) minmax(0,1fr) auto", head: ["Название", "Секрет", "Конфиг", "Статус", ""], rows: aiRows }),
frame("n4", "N4", "Интеграции", "точки входа диалогов · ошибка почты · маркер в субменю", "int", "Интеграции", "Боты, почта и Web-виджет — точки входа диалогов. Каждая привязана к агенту, который отвечает первым.", { isTable: true, headAction: "Добавить подключение", cols: "minmax(0,1.8fr) minmax(0,1.1fr) minmax(0,1.3fr) auto", head: ["Название", "Агент", "Статус", ""], rows: intRows, tableNote: "Код вставки виджета копируется на карточке агента «Поддержка сайта»." }),
frame("n5", "N5", "Голосовые и звонки", "матрица точка входа × функция · переключатели сохраняются сразу", "comm", "Голосовые и звонки", "Где клиент и сотрудник могут записывать голосовые и начинать аудио- и видеозвонки.", { isComm: true }),
frame("n6", "N6", "Хранилище файлов", "сегмент Диск / S3 · поля S3 · проверка и перенос", "storage", "Хранилище файлов", "Вложения, голосовые, фото и логотипы — на диске установки или во внешнем S3-совместимом хранилище.", { isStorage: true }),
frame("n7", "N7", "Демо-данные", "установлены · учётки демо-сотрудников · удаление", "demo", "Демо-данные", "Посмотреть систему в работе на вымышленной организации.", { isDemo: true }),
{ id: "m", badge: "M", title: "Мобильный", note: "список разделов ↔ раздел; поля 44px", desktop: false, mobile: true, sub: sub("") },
];
return { themeVars, nav, links, groups, comm, s3, demoAccounts, frames };
}
}
</script>
</body>
</html>
@@ -1,764 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
a{color:inherit;text-decoration:none;}
a:hover{opacity:0.85;}
*{scrollbar-width:none;}
*::-webkit-scrollbar{display:none;width:0;height:0;}
</style>
</helmet>
<section style="{{ themeVars }}min-height:100vh;background:var(--c-canvas);padding:32px 40px 60px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:var(--c-n1);">
<div style="display:flex;align-items:baseline;gap:14px;margin-bottom:6px;">
<h1 style="margin:0;font-size:22px;font-weight:700;letter-spacing:-0.02em;color:var(--c-n1);">Chatbolls · Порталы</h1>
<span style="font-size:13px;color:var(--c-n4);">design baseline · OWNER = ADMIN · SPEC-HUB-0028 §1.2–1.3, §3, §6</span>
</div>
<p style="margin:0 0 28px;font-size:13px;color:var(--c-n3);max-width:1000px;line-height:1.55;">Модалка настроек разобрана на отдельную страницу с субменю разделов. Публичный адрес — постоянный блок в шапке карточки: копировать, открыть, статус домена. Редактор статьи — полноэкранный split: слева текст с панелью Markdown, справа живой предпросмотр, файлы и версии — в правой рейке.</p>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start;">
<sc-for list="{{ frames }}" as="f" hint-placeholder-count="8">
<div id="{{ f.id }}" style="flex:none;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11px;font-weight:700;">{{ f.id }}</span>
<strong style="font-size:13.5px;color:var(--c-n1);">{{ f.title }}</strong>
<span style="font-size:12px;color:var(--c-n4);">{{ f.note }}</span>
</div>
<div style="width:1440px;height:880px;display:flex;overflow:hidden;background:var(--c-surface);border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);position:relative;">
<aside style="position:relative;width:220px;flex:none;background:var(--c-surface2);border-right:1px solid var(--c-n8);display:flex;flex-direction:column;min-height:0;">
<div title="Потяните, двойной клик — сбросить" style="position:absolute;top:0;right:-3px;bottom:0;width:6px;z-index:5;cursor:col-resize;"><i style="position:absolute;top:0;bottom:0;left:2px;width:2px;background:var(--c-accent);opacity:0;"></i></div>
<div style="flex:none;height:56px;display:flex;align-items:center;gap:10px;padding:14px 14px 10px;">
<span style="width:28px;height:28px;flex:none;display:flex;align-items:center;justify-content:center;border-radius:8px;color:var(--c-n1);"><svg viewBox="0 0 100 100" width="26" height="26" fill="currentColor"><defs><mask id="lg-pt"><rect width="100" height="100" fill="#fff"/><circle cx="50" cy="24.54" r="9" fill="#000"/><circle cx="50" cy="75.46" r="9" fill="#000"/></mask></defs><g mask="url(#lg-pt)"><circle cx="29" cy="50" r="24"/><circle cx="71" cy="50" r="24"/><rect x="44.2" y="31.4" width="11.6" height="37.2"/></g></svg></span>
<button style="flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:6px;height:32px;padding:0 6px 0 8px;border:none;border-radius:8px;background:transparent;color:var(--c-n1);font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Ателье Норд</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
</div>
<nav style="flex:none;padding:4px 10px 8px;display:flex;flex-direction:column;gap:2px;">
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="7">
<button style="display:flex;align-items:center;gap:10px;height:36px;padding:0 10px;border:none;border-radius:8px;background:{{ n.bg }};color:{{ n.color }};font-size:13.5px;font-weight:{{ n.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n8);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ n.icon }}"/></svg><span style="flex:1;">{{ n.label }}</span><sc-if value="{{ n.badge }}" hint-placeholder-val="{{ false }}"><b style="min-width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;padding:0 6px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);color:var(--c-warn-text);font-size:11px;font-weight:700;">{{ n.badge }}</b></sc-if></button>
</sc-for>
</nav>
<div style="flex:1;"></div>
<div style="flex:none;display:flex;align-items:center;gap:10px;padding:12px 14px;border-top:1px solid var(--c-n8);">
<span style="position:relative;flex:none;display:flex;"><span style="width:34px;height:34px;border-radius:50%;background:url(assets/av-4.png) center/cover;"></span><i style="position:absolute;right:-1px;bottom:-1px;width:10px;height:10px;border:2px solid var(--c-surface2);border-radius:50%;background:#52c41a;"></i></span>
<div style="flex:1;min-width:0;line-height:1.2;"><strong style="display:block;font-size:13px;font-weight:600;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Елена Кузнецова</strong><small style="display:block;font-size:11.5px;color:var(--c-n4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Владелец · e.kuznetsova@atelie-nord.ru</small></div>
<button title="Меню пользователя" style="width:28px;height:28px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</aside>
<!-- ===== СПИСОК ПОРТАЛОВ ===== -->
<sc-if value="{{ f.isList }}" hint-placeholder-val="{{ false }}">
<section style="flex:1;min-width:0;min-height:0;overflow-y:auto;background:var(--c-feed);">
<div style="padding:28px 40px 48px;max-width:1560px;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:22px;">
<div><h2 style="margin:0 0 5px;font-size:20px;font-weight:700;color:var(--c-n1);">Порталы</h2><p style="margin:0;font-size:13px;color:var(--c-n3);">Публичные базы знаний для клиентов. Каждый портал — свой адрес, оформление и материалы.</p></div>
<button style="flex:none;display:flex;align-items:center;gap:7px;height:36px;padding:0 15px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Создать портал</button>
</div>
<div style="background:var(--c-surface);border:1px solid var(--c-n7);border-radius:10px;overflow:hidden;">
<div style="display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--c-n8);">
<label style="display:flex;align-items:center;gap:8px;width:300px;height:34px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><span style="font-size:13px;color:var(--c-n5);">Поиск по названию и адресу</span></label>
<label style="display:flex;align-items:center;gap:7px;height:34px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;font-size:13px;color:var(--c-n2);"><span style="color:var(--c-n4);">Статус:</span>Все<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg></label>
</div>
<table style="width:100%;border-collapse:collapse;">
<thead><tr style="background:var(--c-n10);">
<th style="text-align:left;padding:9px 16px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">ПОРТАЛ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">ПУБЛИЧНЫЙ АДРЕС</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">СТАТУС</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">МАТЕРИАЛЫ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">ОБНОВЛЁН</th>
<th style="width:44px;border-bottom:1px solid var(--c-n7);"></th>
</tr></thead>
<tbody>
<sc-for list="{{ portals }}" as="p" hint-placeholder-count="4">
<tr style="border-bottom:1px solid var(--c-n8);background:{{ p.rowBg }};opacity:{{ p.opacity }};">
<td style="padding:11px 16px;">
<span style="display:flex;align-items:center;gap:11px;">
<i style="width:34px;height:34px;flex:none;border-radius:9px;background:{{ p.tileBg }};color:{{ p.tileColor }};display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/></svg></i>
<span style="min-width:0;line-height:1.35;"><strong style="display:block;font-size:13.5px;font-weight:600;color:var(--c-n1);">{{ p.name }}</strong><small style="font-size:11.5px;color:var(--c-n4);">{{ p.locale }}</small></span>
</span>
</td>
<td style="padding:11px 12px;">
<span style="display:flex;align-items:center;gap:7px;">
<code style="font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--c-accent-text);">{{ p.url }}</code>
<button title="Копировать адрес" style="width:24px;height:24px;flex:none;border:none;border-radius:6px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></svg></button>
<sc-if value="{{ p.domainBadge }}" hint-placeholder-val="{{ false }}"><small style="display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px;border-radius:6px;background:var(--c-ok-bg);color:var(--c-ok-text);font-size:11px;font-weight:600;"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="m4 12 5 5L20 6"/></svg>{{ p.domainBadge }}</small></sc-if>
</span>
</td>
<td style="padding:11px 12px;"><span style="display:inline-flex;align-items:center;gap:6px;height:22px;padding:0 9px;border-radius:6px;background:{{ p.stBg }};color:{{ p.stColor }};font-size:11.5px;font-weight:600;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>{{ p.status }}</span></td>
<td style="padding:11px 12px;font-size:13px;color:var(--c-n2);">{{ p.content }}</td>
<td style="padding:11px 12px;font-size:12.5px;color:var(--c-n4);">{{ p.updated }}</td>
<td style="padding:11px 12px;position:relative;">
<button title="Действия" style="width:28px;height:28px;border:none;border-radius:7px;background:{{ p.menuBg }};color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
<sc-if value="{{ p.menuOpen }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;top:38px;right:16px;z-index:20;width:236px;padding:5px;background:var(--c-surface);border:1px solid var(--c-n7);border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,0.16);">
<sc-for list="{{ rowMenu }}" as="m" hint-placeholder-count="5">
<sc-if value="{{ m.divider }}" hint-placeholder-val="{{ false }}"><div style="height:1px;background:var(--c-n8);margin:5px 4px;"></div></sc-if>
<sc-if value="{{ m.label }}" hint-placeholder-val="{{ true }}">
<button style="display:flex;align-items:center;gap:9px;width:100%;height:32px;padding:0 9px;border:none;border-radius:7px;background:transparent;color:{{ m.color }};font-size:13px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ m.icon }}"/></svg>{{ m.label }}</button>
</sc-if>
</sc-for>
</div>
</sc-if>
</td>
</tr>
</sc-for>
</tbody>
</table>
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:11px 16px;background:var(--c-n10);border-top:1px solid var(--c-n8);">
<span style="font-size:12.5px;color:var(--c-n4);">4 портала · архивные показываются последними</span>
<span style="display:flex;align-items:center;gap:6px;">
<button disabled style="width:28px;height:28px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-n6);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg></button>
<b style="min-width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;background:var(--c-accent-bg);color:var(--c-accent-text);font-size:12.5px;">1</b>
<button disabled style="width:28px;height:28px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-n6);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg></button>
</span>
</div>
</div>
</div>
</section>
</sc-if>
<!-- ===== ПУСТОЙ СПИСОК ===== -->
<sc-if value="{{ f.isEmpty }}" hint-placeholder-val="{{ false }}">
<section style="flex:1;min-width:0;display:flex;flex-direction:column;background:var(--c-feed);">
<div style="padding:28px 40px 0;display:flex;align-items:flex-start;justify-content:space-between;gap:20px;">
<div><h2 style="margin:0 0 5px;font-size:20px;font-weight:700;color:var(--c-n1);">Порталы</h2><p style="margin:0;font-size:13px;color:var(--c-n3);">Публичные базы знаний для клиентов.</p></div>
<button style="flex:none;display:flex;align-items:center;gap:7px;height:36px;padding:0 15px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13.5px;font-weight:600;cursor:pointer;font-family:inherit;"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Создать портал</button>
</div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:40px;">
<div style="width:520px;text-align:center;">
<span style="width:56px;height:56px;margin:0 auto 16px;border-radius:16px;background:var(--c-accent-bg);color:var(--c-accent-text);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="27" height="27" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/></svg></span>
<h3 style="margin:0 0 8px;font-size:17px;font-weight:700;color:var(--c-n1);">Создайте первый портал поддержки</h3>
<p style="margin:0 0 18px;font-size:13.5px;color:var(--c-n3);line-height:1.6;">Портал — публичная страница с инструкциями и ответами на частые вопросы. Клиент читает статью и, если не нашёл ответ, пишет в чат прямо со страницы.</p>
<div style="display:flex;gap:10px;justify-content:center;">
<button style="display:flex;align-items:center;gap:7px;height:36px;padding:0 15px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13.5px;font-weight:600;cursor:pointer;font-family:inherit;">Создать портал</button>
<button style="display:flex;align-items:center;gap:7px;height:36px;padding:0 15px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13.5px;font-weight:500;cursor:pointer;font-family:inherit;">Как это выглядит у клиента</button>
</div>
</div>
</div>
</section>
</sc-if>
<!-- ===== КАРТОЧКА ПОРТАЛА / НАСТРОЙКИ / РЕДАКТОР — общая шапка ===== -->
<sc-if value="{{ f.hasPortal }}" hint-placeholder-val="{{ false }}">
<section style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;background:var(--c-feed);">
<sc-if value="{{ f.isEditor }}" hint-placeholder-val="{{ false }}">
<!-- ---- РЕДАКТОР СТАТЬИ ---- -->
<div style="flex:none;display:flex;align-items:center;gap:14px;height:54px;padding:0 20px;background:var(--c-surface);border-bottom:1px solid var(--c-n8);">
<button style="display:flex;align-items:center;gap:7px;height:30px;padding:0 10px 0 7px;border:none;border-radius:7px;background:transparent;color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>Материалы</button>
<span style="font-size:12.5px;color:var(--c-n4);white-space:nowrap;">Ателье и мерки</span>
<span style="flex:1;"></span>
<span style="display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 9px;border-radius:6px;background:var(--c-warn-bg);color:var(--c-warn-text);font-size:11.5px;font-weight:600;white-space:nowrap;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>Редакция 8 · черновик</span>
<span style="display:flex;align-items:center;gap:2px;padding:2px;border-radius:8px;background:var(--c-n9);">
<sc-for list="{{ f.modes }}" as="m" hint-placeholder-count="3">
<button title="{{ m.title }}" style="display:flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:none;border-radius:6px;background:{{ m.bg }};color:{{ m.color }};font-size:12.5px;font-weight:{{ m.weight }};cursor:pointer;font-family:inherit;box-shadow:{{ m.shadow }};white-space:nowrap;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ m.icon }}"/></svg>{{ m.label }}</button>
</sc-for>
</span>
<button style="display:flex;align-items:center;gap:7px;height:32px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-n6);">Сохранить черновик</button>
<button style="display:flex;align-items:center;gap:7px;height:32px;padding:0 14px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="m4 12 5 5L20 6"/></svg>Опубликовать</button>
</div>
<div style="flex:1;min-height:0;display:flex;">
<!-- editor column -->
<div style="flex:1;min-width:0;display:flex;flex-direction:column;border-right:1px solid var(--c-n8);background:var(--c-surface);position:relative;">
<div style="flex:none;padding:18px 22px 10px;">
<input value="Как снять мерки: пошаговая инструкция" style="width:100%;border:none;outline:none;background:transparent;padding:0;font-family:inherit;font-size:21px;font-weight:700;letter-spacing:-0.01em;color:var(--c-n1);">
<div style="display:flex;align-items:center;gap:8px;margin-top:7px;font-size:12px;color:var(--c-n4);"><span>Адрес:</span><code style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--c-n3);">/articles/kak-snyat-merki</code><button style="border:none;background:transparent;color:var(--c-accent-text);font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;padding:0;">изменить</button></div>
</div>
<div style="flex:none;display:flex;align-items:center;gap:2px;padding:6px 14px;border-top:1px solid var(--c-n8);border-bottom:1px solid var(--c-n8);background:var(--c-surface2);">
<sc-for list="{{ mdTools }}" as="t" hint-placeholder-count="10">
<sc-if value="{{ t.divider }}" hint-placeholder-val="{{ false }}"><i style="width:1px;height:20px;background:var(--c-n7);margin:0 5px;"></i></sc-if>
<sc-if value="{{ t.title }}" hint-placeholder-val="{{ true }}">
<button title="{{ t.title }}" style="min-width:30px;height:30px;padding:0 7px;border:none;border-radius:7px;background:{{ t.bg }};color:{{ t.color }};display:flex;align-items:center;justify-content:center;gap:5px;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n8);">
<sc-if value="{{ t.text }}" hint-placeholder-val="{{ false }}"><span>{{ t.text }}</span></sc-if>
<sc-if value="{{ t.icon }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ t.icon }}"/></svg></sc-if>
</button>
</sc-if>
</sc-for>
<span style="flex:1;"></span>
<span style="font-size:11.5px;color:var(--c-n4);white-space:nowrap;">GFM</span>
</div>
<div style="flex:1;min-height:0;overflow:hidden;position:relative;">
<pre style="margin:0;height:100%;max-width:900px;padding:16px 22px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;line-height:1.75;color:var(--c-n2);white-space:pre-wrap;">{{ mdSource }}</pre>
<sc-if value="{{ f.dropping }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;inset:10px;border:2px dashed var(--c-accent);border-radius:12px;background:var(--c-accent-bg);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;">
<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="var(--c-accent-text)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
<strong style="font-size:15px;color:var(--c-accent-text);">Отпустите, чтобы вставить в текст</strong>
<span style="font-size:12.5px;color:var(--c-n3);">Изображения вставятся картинкой, документы — ссылкой. PNG, JPG, WebP, PDF, DOCX · до 25 МБ</span>
</div>
</sc-if>
</div>
<div style="flex:none;display:flex;align-items:center;gap:14px;padding:8px 22px;border-top:1px solid var(--c-n8);background:var(--c-surface2);font-size:11.5px;color:var(--c-n4);">
<span>4 280 знаков · 12 абзацев</span><span>Строка 18, столбец 24</span><span style="flex:1;"></span><span>Черновик сохранён автоматически в 11:24</span>
</div>
</div>
<!-- preview column -->
<div style="flex:1;min-width:0;display:flex;flex-direction:column;background:var(--c-feed);">
<div style="flex:none;display:flex;align-items:center;gap:10px;height:37px;padding:0 18px;border-bottom:1px solid var(--c-n8);background:var(--c-surface2);">
<span style="font-size:11.5px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);">ПРЕДПРОСМОТР · ТЕМА ПОРТАЛА CLASSIC</span>
<span style="flex:1;"></span>
<button title="Открыть как посетитель" style="display:flex;align-items:center;gap:6px;height:24px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--c-n3);font-size:12px;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7M8 7h9v9"/></svg>Как у посетителя</button>
</div>
<div style="flex:1;min-height:0;overflow-y:auto;padding:26px 34px 40px;">
<div style="max-width:620px;margin:0 auto;">
<div style="display:flex;align-items:center;gap:7px;margin-bottom:14px;font-size:12px;color:var(--c-n4);"><span>Помощь</span><span>/</span><span>Ателье и мерки</span></div>
<h1 style="margin:0 0 8px;font-size:26px;font-weight:700;letter-spacing:-0.02em;line-height:1.25;color:var(--c-n1);">Как снять мерки</h1>
<p style="margin:0 0 20px;font-size:14.5px;line-height:1.7;color:var(--c-n3);">Понадобится сантиметровая лента и помощник. Мерки снимают по белью: лента ложится плотно, но не стягивает.</p>
<div style="height:150px;border-radius:10px;background:var(--c-n9);border:1px solid var(--c-n8);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;color:var(--c-n5);margin-bottom:8px;">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="m4 19 5.5-5.5 4 4 3-3L20 19"/></svg>
<small style="font-size:11.5px;">measure-points.png</small>
</div>
<p style="margin:0 0 22px;font-size:12px;color:var(--c-n4);">Точки замера: грудь, талия, бёдра</p>
<h2 style="margin:0 0 10px;font-size:18px;font-weight:700;color:var(--c-n1);">Обхват груди</h2>
<ol style="margin:0 0 20px;padding-left:22px;font-size:14.5px;line-height:1.75;color:var(--c-n2);">
<li>Лента идёт по самым выступающим точкам.</li>
<li>Сзади — горизонтально, без перекосов.</li>
<li>Запишите значение с точностью до 0,5 см.</li>
</ol>
<blockquote style="margin:0 0 22px;padding:12px 16px;border-left:3px solid var(--c-accent);border-radius:0 8px 8px 0;background:var(--c-accent-bg);font-size:14px;line-height:1.65;color:var(--c-n2);">Если вы между двумя размерами — выбирайте больший: ушить проще, чем расставить.</blockquote>
<table style="width:100%;border-collapse:collapse;font-size:13.5px;color:var(--c-n2);">
<thead><tr><th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--c-n7);font-weight:600;">Мерка</th><th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--c-n7);font-weight:600;">Обозначение</th><th style="text-align:left;padding:8px 10px;border-bottom:1px solid var(--c-n7);font-weight:600;">Точность</th></tr></thead>
<tbody>
<tr><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">Обхват груди</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">ОГ</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">0,5 см</td></tr>
<tr><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">Обхват талии</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">ОТ</td><td style="padding:8px 10px;border-bottom:1px solid var(--c-n8);">0,5 см</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- rail -->
<div style="width:300px;flex:none;display:flex;flex-direction:column;min-height:0;background:var(--c-surface);border-left:1px solid var(--c-n8);overflow-y:auto;">
<div style="padding:16px 16px 8px;display:flex;flex-direction:column;gap:12px;">
<label style="display:block;"><span style="display:block;margin-bottom:5px;font-size:11.5px;font-weight:600;color:var(--c-n4);">Раздел</span><span style="display:flex;align-items:center;justify-content:space-between;height:34px;padding:0 10px;border:1px solid var(--c-n7);border-radius:8px;font-size:13px;color:var(--c-n1);">Ателье и мерки<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg></span></label>
<label style="display:block;"><span style="display:block;margin-bottom:5px;font-size:11.5px;font-weight:600;color:var(--c-n4);">Язык</span><span style="display:flex;align-items:center;justify-content:space-between;height:34px;padding:0 10px;border:1px solid var(--c-n7);border-radius:8px;font-size:13px;color:var(--c-n1);">Русский<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg></span></label>
<label style="display:block;"><span style="display:block;margin-bottom:5px;font-size:11.5px;font-weight:600;color:var(--c-n4);">Краткое описание <small style="font-weight:500;color:var(--c-n5);">· показывается в списке и поиске</small></span><span style="display:block;min-height:58px;padding:8px 10px;border:1px solid var(--c-n7);border-radius:8px;font-size:12.5px;line-height:1.5;color:var(--c-n2);">Что понадобится и как правильно снять три основные мерки.</span></label>
</div>
<div style="padding:6px 16px 10px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:7px;"><span style="font-size:11.5px;font-weight:600;color:var(--c-n4);">ФАЙЛЫ СТАТЬИ</span><button style="border:none;background:transparent;padding:0;color:var(--c-accent-text);font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;">Загрузить</button></div>
<div style="border:1px dashed {{ f.dropBorder }};border-radius:10px;background:{{ f.dropBg }};padding:12px;text-align:center;">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="{{ f.dropIcon }}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>
<p style="margin:5px 0 2px;font-size:12.5px;font-weight:600;color:var(--c-n2);">Перетащите файлы</p>
<p style="margin:0;font-size:11.5px;color:var(--c-n4);">или <span style="color:var(--c-accent-text);font-weight:600;">выберите на диске</span> · до 25 МБ</p>
</div>
<div style="display:flex;flex-direction:column;gap:6px;margin-top:10px;">
<sc-for list="{{ f.files }}" as="file" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:9px;padding:8px 9px;border:1px solid {{ file.border }};border-radius:9px;background:{{ file.bg }};">
<i style="width:28px;height:28px;flex:none;border-radius:7px;background:var(--c-n9);color:var(--c-n3);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="{{ file.icon }}"/></svg></i>
<span style="flex:1;min-width:0;line-height:1.3;"><strong style="display:block;font-size:12.5px;font-weight:600;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ file.name }}</strong><small style="font-size:11px;color:{{ file.metaColor }};">{{ file.meta }}</small>
<sc-if value="{{ file.progress }}" hint-placeholder-val="{{ false }}"><span style="display:block;height:3px;margin-top:5px;border-radius:2px;background:var(--c-n8);overflow:hidden;"><i style="display:block;height:3px;width:{{ file.progress }};background:var(--c-accent);"></i></span></sc-if>
</span>
<sc-if value="{{ file.done }}" hint-placeholder-val="{{ true }}">
<button title="Вставить в текст" style="width:26px;height:26px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-accent-text);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-accent-bg);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button>
<button title="Копировать Markdown" style="width:26px;height:26px;flex:none;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></svg></button>
</sc-if>
</div>
</sc-for>
</div>
</div>
<div style="padding:6px 16px 20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:7px;"><span style="font-size:11.5px;font-weight:600;color:var(--c-n4);">ВЕРСИИ</span><span style="font-size:11.5px;color:var(--c-n5);">неизменяемые</span></div>
<div style="display:flex;flex-direction:column;gap:1px;">
<sc-for list="{{ revisions }}" as="r" hint-placeholder-count="4">
<button style="display:flex;align-items:center;gap:9px;padding:7px 9px;border:none;border-radius:8px;background:{{ r.bg }};text-align:left;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">
<i style="width:7px;height:7px;flex:none;border-radius:50%;background:{{ r.dot }};"></i>
<span style="flex:1;min-width:0;line-height:1.3;"><strong style="display:block;font-size:12.5px;font-weight:600;color:var(--c-n1);">{{ r.label }}</strong><small style="font-size:11px;color:var(--c-n4);">{{ r.meta }}</small></span>
<sc-if value="{{ r.tag }}" hint-placeholder-val="{{ false }}"><small style="height:19px;padding:0 7px;display:inline-flex;align-items:center;border-radius:5px;background:{{ r.tagBg }};color:{{ r.tagColor }};font-size:10.5px;font-weight:600;">{{ r.tag }}</small></sc-if>
</button>
</sc-for>
</div>
<p style="margin:9px 0 0;font-size:11.5px;line-height:1.5;color:var(--c-n4);">Публикация выбирает одну редакцию. Посетители видят только опубликованную.</p>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ f.isCard }}" hint-placeholder-val="{{ false }}">
<!-- ---- ШАПКА ПОРТАЛА ---- -->
<div style="flex:none;padding:18px 28px 16px;background:var(--c-surface);border-bottom:1px solid var(--c-n8);">
<div style="max-width:1500px;">
<div style="display:flex;align-items:center;gap:7px;margin-bottom:9px;font-size:12.5px;color:var(--c-n4);">
<button style="border:none;background:transparent;padding:0;color:var(--c-accent-text);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;">Порталы</button><span>/</span><span>Помощь Ателье Норд</span>
<sc-if value="{{ f.isSettings }}" hint-placeholder-val="{{ false }}"><span>/</span><span style="color:var(--c-n2);">Настройки</span></sc-if>
</div>
<div style="display:flex;align-items:flex-start;gap:20px;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:11px;margin-bottom:10px;">
<h2 style="margin:0;font-size:21px;font-weight:700;letter-spacing:-0.015em;color:var(--c-n1);">Помощь Ателье Норд</h2>
<span style="display:inline-flex;align-items:center;gap:6px;height:23px;padding:0 9px;border-radius:6px;background:var(--c-ok-bg);color:var(--c-ok-text);font-size:11.5px;font-weight:600;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>Опубликован</span>
<span style="font-size:12.5px;color:var(--c-n4);">6 разделов · 48 статей · русский</span>
</div>
<!-- НОРМАЛЬНАЯ ССЫЛКА НА ПОРТАЛ -->
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="display:flex;align-items:center;gap:9px;height:34px;padding:0 5px 0 11px;border:1px solid var(--c-n7);border-radius:9px;background:var(--c-surface2);">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--c-ok-text)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/></svg>
<code style="font-size:13px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--c-n1);">help.atelie-nord.ru</code>
<span style="display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px;border-radius:5px;background:var(--c-ok-bg);color:var(--c-ok-text);font-size:10.5px;font-weight:600;">свой домен · работает</span>
<button title="Копировать ссылку" style="width:26px;height:26px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></svg></button>
<a title="Открыть портал в новой вкладке" style="width:26px;height:26px;border-radius:7px;background:transparent;color:var(--c-accent-text);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-accent-bg);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7M8 7h9v9"/></svg></a>
</span>
<span style="font-size:12px;color:var(--c-n4);white-space:nowrap;">адрес на этой установке <code style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--c-n3);">nord.help.nord-crm.ru</code></span>
</div>
</div>
<div style="flex:none;display:flex;align-items:center;gap:8px;padding-top:4px;">
<button style="display:flex;align-items:center;gap:7px;height:34px;padding:0 13px;border:1px solid {{ f.settingsBorder }};border-radius:8px;background:{{ f.settingsBg }};color:{{ f.settingsColor }};font-size:13px;font-weight:{{ f.settingsWeight }};cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-n6);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 6H9M3 6h2M9 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 12h-8M3 12h6M13 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 18h-6M3 18h8M15 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0z"/></svg>Настройки</button>
<button style="display:flex;align-items:center;gap:7px;height:34px;padding:0 14px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Новая статья</button>
<button title="Ещё" style="width:34px;height:34px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="border-color:var(--c-n6);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</div>
</div>
</div>
</sc-if>
<!-- ---- БИБЛИОТЕКА МАТЕРИАЛОВ ---- -->
<sc-if value="{{ f.isLibrary }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;min-height:0;display:flex;">
<div style="width:260px;flex:none;display:flex;flex-direction:column;min-height:0;background:var(--c-surface);border-right:1px solid var(--c-n8);">
<div style="flex:none;display:flex;align-items:center;justify-content:space-between;padding:15px 14px 8px;"><span style="font-size:11.5px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);">РАЗДЕЛЫ</span><button style="display:flex;align-items:center;gap:5px;border:none;background:transparent;padding:0;color:var(--c-accent-text);font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;">Управлять</button></div>
<nav style="flex:1;min-height:0;overflow-y:auto;padding:0 8px 12px;display:flex;flex-direction:column;gap:1px;">
<sc-for list="{{ cats }}" as="c" hint-placeholder-count="8">
<button style="display:flex;align-items:center;gap:8px;min-height:32px;padding:0 9px 0 {{ c.pad }};border:none;border-radius:8px;background:{{ c.bg }};color:{{ c.color }};font-size:13px;font-weight:{{ c.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);">
<sc-if value="{{ c.icon }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ c.icon }}"/></svg></sc-if>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ c.label }}</span>
<small style="font-size:11.5px;font-weight:600;color:{{ c.countColor }};">{{ c.count }}</small>
</button>
</sc-for>
</nav>
<div style="flex:none;padding:10px 14px;border-top:1px solid var(--c-n8);">
<button style="display:flex;align-items:center;gap:7px;width:100%;height:32px;padding:0 10px;border:1px dashed var(--c-n6);border-radius:8px;background:transparent;color:var(--c-n3);font-size:12.5px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-accent);color:var(--c-accent-text);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Добавить раздел</button>
</div>
</div>
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow-y:auto;">
<div style="width:100%;max-width:1240px;margin:0 auto;display:flex;flex-direction:column;min-height:100%;">
<div style="flex:none;display:flex;align-items:center;gap:10px;padding:14px 24px;border-bottom:1px solid var(--c-n8);background:var(--c-surface);">
<label style="display:flex;align-items:center;gap:8px;width:280px;height:34px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><span style="font-size:13px;color:var(--c-n5);">Поиск по статьям</span></label>
<label style="display:flex;align-items:center;gap:7px;height:34px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;font-size:13px;color:var(--c-n2);"><span style="color:var(--c-n4);">Язык:</span>Все<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg></label>
<label style="display:flex;align-items:center;gap:7px;height:34px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;font-size:13px;color:var(--c-n2);"><span style="color:var(--c-n4);">Статус:</span>Все<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg></label>
<span style="flex:1;"></span>
<button style="display:flex;align-items:center;gap:7px;height:34px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-n6);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M7 10l5 5 5-5M4 20h16"/></svg>Импорт статей</button>
</div>
<table style="width:100%;border-collapse:collapse;background:var(--c-surface);">
<thead><tr style="background:var(--c-n10);">
<th style="text-align:left;padding:9px 24px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">СТАТЬЯ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">РАЗДЕЛ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">ЯЗЫК</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">РЕДАКЦИЯ</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">СТАТУС</th>
<th style="text-align:left;padding:9px 12px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n4);border-bottom:1px solid var(--c-n7);">ОБНОВЛЕНО</th>
<th style="width:44px;border-bottom:1px solid var(--c-n7);"></th>
</tr></thead>
<tbody>
<sc-for list="{{ articles }}" as="a" hint-placeholder-count="6">
<tr style="border-bottom:1px solid var(--c-n8);opacity:{{ a.opacity }};">
<td style="padding:11px 24px;">
<span style="display:flex;align-items:center;gap:10px;">
<i style="width:28px;height:28px;flex:none;border-radius:7px;background:var(--c-n9);color:var(--c-n4);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5M9 13h6M9 17h4"/></svg></i>
<span style="min-width:0;line-height:1.35;"><strong style="display:block;font-size:13.5px;font-weight:600;color:var(--c-n1);">{{ a.title }}</strong><small style="font-size:11.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--c-n4);">{{ a.slug }}</small></span>
<sc-if value="{{ a.attach }}" hint-placeholder-val="{{ false }}"><small title="Файлы в статье" style="display:inline-flex;align-items:center;gap:3px;font-size:11px;color:var(--c-n4);"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.5 12.5 21a4.95 4.95 0 0 1-7-7l8-8a3.5 3.5 0 0 1 5 5l-8 8a1.95 1.95 0 0 1-3-3l7-7"/></svg>{{ a.attach }}</small></sc-if>
</span>
</td>
<td style="padding:11px 12px;font-size:13px;color:var(--c-n2);">{{ a.cat }}</td>
<td style="padding:11px 12px;font-size:13px;color:var(--c-n3);">{{ a.lang }}</td>
<td style="padding:11px 12px;font-size:13px;color:var(--c-n2);"><span>{{ a.rev }}</span><sc-if value="{{ a.revNote }}" hint-placeholder-val="{{ false }}"><small style="display:block;font-size:11px;color:var(--c-warn-text);">{{ a.revNote }}</small></sc-if></td>
<td style="padding:11px 12px;"><span style="display:inline-flex;align-items:center;gap:6px;height:22px;padding:0 9px;border-radius:6px;background:{{ a.stBg }};color:{{ a.stColor }};font-size:11.5px;font-weight:600;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>{{ a.status }}</span></td>
<td style="padding:11px 12px;font-size:12.5px;color:var(--c-n4);">{{ a.updated }}</td>
<td style="padding:11px 12px;"><button title="Действия" style="width:28px;height:28px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button></td>
</tr>
</sc-for>
</tbody>
</table>
<div style="flex:none;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:11px 24px;background:var(--c-n10);border-top:1px solid var(--c-n8);">
<span style="font-size:12.5px;color:var(--c-n4);">48 статей · показаны 6 · агент отвечает только по опубликованным</span>
<span style="display:flex;align-items:center;gap:6px;">
<b style="min-width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;background:var(--c-accent-bg);color:var(--c-accent-text);font-size:12.5px;">1</b>
<button style="min-width:28px;height:28px;padding:0 8px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-n3);font-size:12.5px;cursor:pointer;font-family:inherit;">2</button>
<button style="width:28px;height:28px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-n3);display:flex;align-items:center;justify-content:center;cursor:pointer;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg></button>
</span>
</div>
</div>
</div>
</div>
</sc-if>
<!-- ---- НАСТРОЙКИ ПОРТАЛА ---- -->
<sc-if value="{{ f.isSettings }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;min-height:0;display:flex;">
<nav style="width:250px;flex:none;padding:14px 10px;background:var(--c-surface);border-right:1px solid var(--c-n8);display:flex;flex-direction:column;gap:1px;">
<sc-for list="{{ f.sub }}" as="s" hint-placeholder-count="6">
<button style="display:flex;align-items:center;gap:10px;min-height:34px;padding:0 10px;border:none;border-radius:8px;background:{{ s.bg }};color:{{ s.color }};font-size:13px;font-weight:{{ s.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ s.icon }}"/></svg><span style="flex:1;">{{ s.label }}</span><sc-if value="{{ s.hint }}" hint-placeholder-val="{{ false }}"><small style="font-size:11px;font-weight:600;color:{{ s.hintColor }};">{{ s.hint }}</small></sc-if></button>
<sc-if value="{{ s.divider }}" hint-placeholder-val="{{ false }}"><div style="height:1px;background:var(--c-n8);margin:8px 6px;"></div></sc-if>
</sc-for>
<span style="flex:1;"></span>
<p style="margin:0;padding:0 10px;font-size:11.5px;line-height:1.5;color:var(--c-n4);">Изменения применяются к публичным страницам сразу после сохранения.</p>
</nav>
<div style="flex:1;min-width:0;min-height:0;overflow-y:auto;">
<div style="max-width:820px;padding:26px 34px 48px;">
<sc-if value="{{ f.isBasics }}" hint-placeholder-val="{{ false }}">
<div style="margin-bottom:20px;"><h3 style="margin:0 0 4px;font-size:17px;font-weight:700;color:var(--c-n1);">Основные</h3><p style="margin:0;font-size:13px;color:var(--c-n3);">Название, публичный адрес и язык материалов портала.</p></div>
<div style="background:var(--c-surface);border:1px solid var(--c-n7);border-radius:10px;padding:20px 22px;display:flex;flex-direction:column;gap:18px;">
<label style="display:block;"><span style="display:block;margin-bottom:6px;font-size:12.5px;font-weight:600;color:var(--c-n2);">Название портала</span><input value="Помощь Ателье Норд" style="width:100%;height:36px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);font-family:inherit;font-size:13.5px;color:var(--c-n1);outline:none;"><small style="display:block;margin-top:6px;font-size:11.5px;color:var(--c-n4);">Показывается в шапке публичных страниц и в заголовке браузера.</small></label>
<div>
<span style="display:block;margin-bottom:6px;font-size:12.5px;font-weight:600;color:var(--c-n2);">Адрес на этой установке</span>
<div style="display:flex;align-items:stretch;max-width:520px;">
<input value="nord" style="width:180px;height:36px;padding:0 11px;border:1px solid var(--c-n7);border-right:none;border-radius:8px 0 0 8px;background:var(--c-surface);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;color:var(--c-n1);outline:none;">
<span style="display:flex;align-items:center;padding:0 11px;border:1px solid var(--c-n7);border-radius:0 8px 8px 0;background:var(--c-n9);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;color:var(--c-n4);">.help.nord-crm.ru</span>
</div>
<small style="display:block;margin-top:6px;font-size:11.5px;color:var(--c-n4);">Строчные латинские буквы, цифры и дефис. Базовый домен <b style="font-weight:600;color:var(--c-n3);">help.nord-crm.ru</b> задан администратором при установке и одинаков для всех порталов этой инсталляции. Адрес входит в ссылки на статьи — после смены старые ссылки перестанут работать.</small>
</div>
<label style="display:block;max-width:320px;"><span style="display:block;margin-bottom:6px;font-size:12.5px;font-weight:600;color:var(--c-n2);">Основной язык</span><span style="display:flex;align-items:center;justify-content:space-between;height:36px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;font-size:13.5px;color:var(--c-n1);">Русский<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg></span><small style="display:block;margin-top:6px;font-size:11.5px;color:var(--c-n4);">Язык, который подставляется новым статьям. Статьи на других языках остаются как есть.</small></label>
<div style="display:flex;align-items:center;gap:11px;padding:12px 14px;border-radius:9px;background:var(--c-n10);border:1px solid var(--c-n8);">
<span style="flex:1;min-width:0;line-height:1.4;"><small style="display:block;font-size:11.5px;color:var(--c-n4);">Итоговая ссылка</small><code style="font-size:13px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--c-n1);">https://nord.help.nord-crm.ru/</code></span>
<button style="display:flex;align-items:center;gap:6px;height:30px;padding:0 11px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:500;cursor:pointer;font-family:inherit;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></svg>Копировать</button>
<button style="display:flex;align-items:center;gap:6px;height:30px;padding:0 11px;border:1px solid var(--c-n7);border-radius:7px;background:var(--c-surface);color:var(--c-accent-text);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7M8 7h9v9"/></svg>Открыть</button>
</div>
</div>
</sc-if>
<sc-if value="{{ f.isDomain }}" hint-placeholder-val="{{ false }}">
<div style="margin-bottom:20px;"><h3 style="margin:0 0 4px;font-size:17px;font-weight:700;color:var(--c-n1);">Свой домен</h3><p style="margin:0;font-size:13px;color:var(--c-n3);">Портал будет открываться по адресу вашей компании. Адрес установки продолжит работать.</p></div>
<div style="background:var(--c-surface);border:1px solid var(--c-n7);border-radius:10px;padding:20px 22px;display:flex;flex-direction:column;gap:18px;">
<div style="display:flex;align-items:flex-end;gap:10px;">
<label style="flex:1;"><span style="display:block;margin-bottom:6px;font-size:12.5px;font-weight:600;color:var(--c-n2);">Домен</span><input value="help.atelie-nord.ru" style="width:100%;height:36px;padding:0 11px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;color:var(--c-n1);outline:none;"></label>
<span style="display:inline-flex;align-items:center;gap:6px;height:36px;padding:0 11px;border-radius:8px;background:var(--c-ok-bg);color:var(--c-ok-text);font-size:12.5px;font-weight:600;white-space:nowrap;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="m4 12 5 5L20 6"/></svg>Домен ведёт сюда · проверено 3 сентября</span>
</div>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;"><strong style="font-size:12.5px;color:var(--c-n2);">Запись у регистратора домена</strong><span style="font-size:11.5px;color:var(--c-n4);">обновление DNS занимает до 24 часов</span></div>
<table style="width:100%;border-collapse:collapse;border:1px solid var(--c-n8);border-radius:8px;overflow:hidden;">
<thead><tr style="background:var(--c-n10);"><th style="text-align:left;padding:8px 12px;font-size:11px;font-weight:600;color:var(--c-n4);">ТИП</th><th style="text-align:left;padding:8px 12px;font-size:11px;font-weight:600;color:var(--c-n4);">ИМЯ</th><th style="text-align:left;padding:8px 12px;font-size:11px;font-weight:600;color:var(--c-n4);">ЗНАЧЕНИЕ</th><th style="width:40px;"></th></tr></thead>
<tbody>
<sc-for list="{{ dns }}" as="d" hint-placeholder-count="2">
<tr style="border-top:1px solid var(--c-n8);">
<td style="padding:9px 12px;font-size:12.5px;font-weight:600;color:var(--c-n2);">{{ d.type }}</td>
<td style="padding:9px 12px;"><code style="font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--c-n2);">{{ d.name }}</code></td>
<td style="padding:9px 12px;"><code style="font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--c-n2);">{{ d.value }}</code></td>
<td style="padding:9px 12px;"><button title="Копировать значение" style="width:26px;height:26px;border:none;border-radius:7px;background:transparent;color:var(--c-n4);display:flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n2);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></svg></button></td>
</tr>
</sc-for>
</tbody>
</table>
<p style="margin:8px 0 0;font-size:11.5px;line-height:1.5;color:var(--c-n4);">Больше ничего добавлять не нужно: домен ваш, установка ваша — проверка лишь смотрит, ведёт ли запись на этот сервер, и выписывает сертификат.</p>
</div>
<div style="display:flex;align-items:center;gap:9px;">
<button style="height:34px;padding:0 14px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;">Сохранить домен</button>
<button style="height:34px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;">Проверить DNS</button>
<span style="flex:1;"></span>
<button style="height:34px;padding:0 13px;border:1px solid color-mix(in srgb,#ff4d4f 35%,var(--c-surface));border-radius:8px;background:transparent;color:var(--c-err-text);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;">Отключить домен</button>
</div>
</div>
</sc-if>
<sc-if value="{{ f.isTheme }}" hint-placeholder-val="{{ false }}">
<div style="margin-bottom:20px;"><h3 style="margin:0 0 4px;font-size:17px;font-weight:700;color:var(--c-n1);">Оформление</h3><p style="margin:0;font-size:13px;color:var(--c-n3);">Тема публичных страниц и цветовая схема. Применяется до первого кадра — посетитель не видит смены оформления. Список тем — из сборки установки.</p></div>
<div style="background:var(--c-surface);border:1px solid var(--c-n7);border-radius:10px;padding:20px 22px;display:flex;flex-direction:column;gap:20px;">
<div>
<span style="display:block;margin-bottom:10px;font-size:12.5px;font-weight:600;color:var(--c-n2);">Тема</span>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;max-width:520px;">
<sc-for list="{{ themes }}" as="t" hint-placeholder-count="2">
<button style="display:block;padding:10px;border:1.5px solid {{ t.border }};border-radius:11px;background:{{ t.bg }};text-align:left;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-accent);">
<span style="display:block;height:104px;border-radius:8px;background:{{ t.canvas }};border:1px solid var(--c-n8);padding:9px;overflow:hidden;">
<span style="display:block;height:9px;width:{{ t.barW }};border-radius:3px;background:{{ t.ink }};opacity:0.75;margin-bottom:7px;"></span>
<span style="display:block;height:5px;width:80%;border-radius:3px;background:{{ t.ink }};opacity:0.28;margin-bottom:4px;"></span>
<span style="display:block;height:5px;width:62%;border-radius:3px;background:{{ t.ink }};opacity:0.28;margin-bottom:9px;"></span>
<span style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">
<i style="display:block;height:26px;border-radius:{{ t.radius }};background:{{ t.accent }};opacity:0.14;"></i>
<i style="display:block;height:26px;border-radius:{{ t.radius }};background:{{ t.accent }};opacity:0.14;"></i>
</span>
</span>
<span style="display:flex;align-items:center;gap:7px;margin-top:9px;"><strong style="font-size:13px;font-weight:600;color:var(--c-n1);">{{ t.name }}</strong><sc-if value="{{ t.tag }}" hint-placeholder-val="{{ false }}"><small style="height:18px;padding:0 6px;display:inline-flex;align-items:center;border-radius:5px;background:{{ t.tagBg }};color:{{ t.tagColor }};font-size:10.5px;font-weight:600;">{{ t.tag }}</small></sc-if></span>
<small style="display:block;margin-top:3px;font-size:11.5px;line-height:1.45;color:var(--c-n4);">{{ t.desc }}</small>
</button>
</sc-for>
</div>
</div>
<div>
<span style="display:block;margin-bottom:8px;font-size:12.5px;font-weight:600;color:var(--c-n2);">Цветовая схема</span>
<span style="display:inline-flex;gap:2px;padding:2px;border-radius:9px;background:var(--c-n9);">
<sc-for list="{{ schemes }}" as="s" hint-placeholder-count="3">
<button style="height:32px;padding:0 15px;border:none;border-radius:7px;background:{{ s.bg }};color:{{ s.color }};font-size:13px;font-weight:{{ s.weight }};cursor:pointer;font-family:inherit;box-shadow:{{ s.shadow }};">{{ s.label }}</button>
</sc-for>
</span>
</div>
<div style="display:flex;align-items:center;gap:9px;padding-top:2px;">
<button style="height:34px;padding:0 14px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;">Сохранить оформление</button>
<button style="display:flex;align-items:center;gap:7px;height:34px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>Предпросмотр портала</button>
<span style="flex:1;"></span>
<span style="font-size:12px;color:var(--c-n4);">сохранено 5 сентября, 11:04</span>
</div>
</div>
</sc-if>
</div>
</div>
</div>
</sc-if>
</section>
</sc-if>
</div>
</div>
</sc-for>
</div>
</section>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'light' | 'dark'&quot;,&quot;section&quot;:&quot;Тема&quot;},&quot;accent&quot;:{&quot;editor&quot;:&quot;color&quot;,&quot;options&quot;:[&quot;#1677ff&quot;,&quot;#5b5bd6&quot;,&quot;#0f9b8e&quot;,&quot;#fa8c16&quot;],&quot;default&quot;:&quot;#1677ff&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Тема&quot;}}">
class Component extends DCLogic {
renderVals() {
const dark = (this.props.theme ?? "light") === "dark";
const accent = this.props.accent ?? "#1677ff";
const T = dark
? { canvas: "#101113", surface: "#1b1c1f", surface2: "#181a1d", feed: "#15161a", n1: "#d6d7db", n2: "#c3c5ca", n3: "#9a9ca3", n4: "#767881", n5: "#5b5d66", n6: "#45474f", n7: "#37393f", n8: "#2d2f35", n9: "#27292e", n10: "#212327", ai: "#a98be0", warnText: "#d9a441", okText: "#5fbf7a", errText: "#ff7875", accentText: `color-mix(in srgb,${accent} 62%,white)`, accentHover: `color-mix(in srgb,${accent} 92%,white)`, accentBgPct: 16, tintPct: 12, accentMuted: `color-mix(in srgb,${accent} 84%,#1b1c1f)` }
: { canvas: "#e4e6ea", surface: "#ffffff", surface2: "#fafafa", feed: "#f5f6f8", n1: "#1f1f1f", n2: "#262626", n3: "#595959", n4: "#8c8c8c", n5: "#bfbfbf", n6: "#d9d9d9", n7: "#e8e8e8", n8: "#f0f0f0", n9: "#f5f5f5", n10: "#fafafa", ai: "#722ed1", warnText: "#d48806", okText: "#237a3f", errText: "#cf1322", accentText: `color-mix(in srgb,${accent} 82%,black)`, accentHover: `color-mix(in srgb,${accent} 88%,black)`, accentBgPct: 10, tintPct: 11, accentMuted: accent };
const themeVars = `--c-canvas:${T.canvas};--c-surface:${T.surface};--c-surface2:${T.surface2};--c-feed:${T.feed};--c-n1:${T.n1};--c-n2:${T.n2};--c-n3:${T.n3};--c-n4:${T.n4};--c-n5:${T.n5};--c-n6:${T.n6};--c-n7:${T.n7};--c-n8:${T.n8};--c-n9:${T.n9};--c-n10:${T.n10};--c-ai:${T.ai};--c-accent:${T.accentMuted};--c-accent-text:${T.accentText};--c-accent-hover:${T.accentHover};--c-accent-bg:color-mix(in srgb,${accent} ${T.accentBgPct}%,${T.surface});--c-warn-bg:color-mix(in srgb,#faad14 ${T.tintPct}%,${T.surface});--c-warn-border:color-mix(in srgb,#faad14 ${T.tintPct + 25}%,${T.surface});--c-warn-text:${T.warnText};--c-ok-bg:color-mix(in srgb,#52c41a ${T.tintPct}%,${T.surface});--c-ok-border:color-mix(in srgb,#52c41a ${T.tintPct + 20}%,${T.surface});--c-ok-text:${T.okText};--c-err-bg:color-mix(in srgb,#ff4d4f ${T.tintPct}%,${T.surface});--c-err-text:${T.errText};`;
const ICON = {
chat: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z",
contacts: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0z",
agents: "M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01",
team: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
portals: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",
knowledge: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V2H6.5A2.5 2.5 0 0 0 4 4.5z",
settings: "M21 6H9M3 6h2M9 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 12h-8M3 12h6M13 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 18h-6M3 18h8M15 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0z",
link: "M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1",
globe: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",
paint: "M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 2-2 2 2 0 0 1 2-2h1a3 3 0 0 0 3-3 9 9 0 0 0-9-9zM8 9h.01M12 7h.01M16 9h.01",
widget: "M4 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-7l-5 4z",
box: "M21 8 12 3 3 8v8l9 5 9-5zM3 8l9 5 9-5M12 13v8",
danger: "M12 3 2 20h20zM12 9v5M12 17.5v.01",
folder: "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z",
file: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5",
image: "M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM8.5 10a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zM4 17l5-5 4 4 3-3 4 4",
trash: "M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13",
copy: "M11 9h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2zM5 15V5a2 2 0 0 1 2-2h8",
external: "M7 17 17 7M8 7h9v9",
edit: "M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z",
eye: "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z",
split: "M4 4h16v16H4zM12 4v16",
code: "m9 18-6-6 6-6M15 6l6 6-6 6",
bold: "M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z",
italic: "M15 5h-5M14 19H9M14.5 5 10 19",
list: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
numlist: "M10 6h11M10 12h11M10 18h11M4 5h1v4M4 15h2v1H4v2h2",
quote: "M7 7H4v5h3l-1 5M17 7h-3v5h3l-1 5",
table: "M3 5h18v14H3zM3 10h18M9 10v9",
attach: "M21 12.5 12.5 21a4.95 4.95 0 0 1-7-7l8-8a3.5 3.5 0 0 1 5 5l-8 8a1.95 1.95 0 0 1-3-3l7-7",
undo: "M9 14 4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3",
};
const nav = [
{ key: "chat", label: "Чат", icon: ICON.chat, badge: 2 }, { key: "contacts", label: "Контакты", icon: ICON.contacts }, { key: "agents", label: "Агенты", icon: ICON.agents },
{ key: "employees", label: "Сотрудники", icon: ICON.team }, { key: "portals", label: "Порталы", icon: ICON.portals }, { key: "knowledge", label: "База знаний", icon: ICON.knowledge }, { key: "settings", label: "Настройки", icon: ICON.settings },
].map((n) => ({ ...n, bg: n.key === "portals" ? "var(--c-accent-bg)" : "transparent", color: n.key === "portals" ? "var(--c-accent-text)" : "var(--c-n2)", weight: n.key === "portals" ? 600 : 500 }));
const ST = {
pub: { status: "Опубликован", stBg: "var(--c-ok-bg)", stColor: "var(--c-ok-text)" },
draft: { status: "Черновик", stBg: "var(--c-warn-bg)", stColor: "var(--c-warn-text)" },
arch: { status: "В архиве", stBg: "var(--c-n9)", stColor: "var(--c-n4)" },
};
const portals = [
{ name: "Помощь Ателье Норд", locale: "русский · создан 12 июля", url: "help.atelie-nord.ru", domainBadge: "свой домен", ...ST.pub, content: "6 разделов · 48 статей", updated: "5 сен, 11:20", menuOpen: true, tileBg: "var(--c-accent-bg)", tileColor: "var(--c-accent-text)", rowBg: "var(--c-n10)", menuBg: "var(--c-n9)", opacity: 1 },
{ name: "База знаний для дизайнеров", locale: "русский · создан 2 сентября", url: "designers.help.nord-crm.ru", ...ST.draft, content: "3 раздела · 7 статей", updated: "4 сен, 18:02", tileBg: "var(--c-n9)", tileColor: "var(--c-n4)", rowBg: "transparent", menuBg: "transparent", opacity: 1 },
{ name: "Norda Shop · FAQ", locale: "русский, английский", url: "shop.help.nord-crm.ru", ...ST.pub, content: "4 раздела · 21 статья", updated: "2 сен, 09:15", tileBg: "var(--c-n9)", tileColor: "var(--c-n4)", rowBg: "transparent", menuBg: "transparent", opacity: 1 },
{ name: "Старый help (до переноса)", locale: "русский", url: "old.help.nord-crm.ru", ...ST.arch, content: "5 разделов · 33 статьи", updated: "12 авг, 14:40", tileBg: "var(--c-n9)", tileColor: "var(--c-n5)", rowBg: "transparent", menuBg: "transparent", opacity: 0.6 },
];
const rowMenu = [
{ label: "Открыть материалы", icon: ICON.folder, color: "var(--c-n2)" },
{ label: "Настройки портала", icon: ICON.settings, color: "var(--c-n2)" },
{ divider: true },
{ label: "Копировать публичную ссылку", icon: ICON.copy, color: "var(--c-n2)" },
{ label: "Открыть портал", icon: ICON.external, color: "var(--c-n2)" },
{ divider: true },
{ label: "Перенести в архив", icon: ICON.trash, color: "var(--c-err-text)" },
];
const catRow = (label, count, depth, active, icon) => ({
label, count, icon, pad: `${9 + depth * 16}px`,
bg: active ? "var(--c-accent-bg)" : "transparent",
color: active ? "var(--c-accent-text)" : "var(--c-n2)",
weight: active ? 600 : 500,
countColor: active ? "var(--c-accent-text)" : "var(--c-n5)",
});
const cats = [
catRow("Все материалы", 48, 0, false, ICON.folder),
catRow("Начало работы", 8, 0, false),
catRow("Заказ и оплата", 12, 0, false),
catRow("Оплата", 5, 1, false),
catRow("Доставка", 4, 1, false),
catRow("Ателье и мерки", 9, 0, true),
catRow("Уход за изделием", 6, 0, false),
catRow("Гарантия и возврат", 7, 0, false),
catRow("Частые вопросы", 6, 0, false),
];
const A = {
pub: { status: "Опубликована", stBg: "var(--c-ok-bg)", stColor: "var(--c-ok-text)" },
draft: { status: "Черновик", stBg: "var(--c-warn-bg)", stColor: "var(--c-warn-text)" },
arch: { status: "В архиве", stBg: "var(--c-n9)", stColor: "var(--c-n4)" },
};
const articles = [
{ title: "Как снять мерки: пошаговая инструкция", slug: "/kak-snyat-merki", cat: "Ателье и мерки", lang: "RU", rev: "7", revNote: "черновая 8", ...A.pub, updated: "5 сен, 11:20", attach: 2, opacity: 1 },
{ title: "Подгонка по фигуре после примерки", slug: "/podgonka", cat: "Ателье и мерки", lang: "RU", rev: "3", ...A.pub, updated: "4 сен, 16:48", attach: 1, opacity: 1 },
{ title: "Оплата частями", slug: "/oplata-chastyami", cat: "Оплата", lang: "RU", rev: "2", revNote: "не опубликована", ...A.draft, updated: "4 сен, 10:05", opacity: 1 },
{ title: "Возврат изделия, сшитого по меркам", slug: "/vozvrat", cat: "Гарантия и возврат", lang: "RU", rev: "5", ...A.pub, updated: "2 сен, 12:30", opacity: 1 },
{ title: "How to measure yourself", slug: "/how-to-measure", cat: "Ателье и мерки", lang: "EN", rev: "1", ...A.draft, updated: "1 сен, 19:12", opacity: 1 },
{ title: "Уход за шерстью", slug: "/uhod-za-sherstyu", cat: "Уход за изделием", lang: "RU", rev: "4", ...A.arch, updated: "20 авг, 08:55", opacity: 0.6 },
];
const seg = (items, activeKey) => items.map((i) => ({
...i,
bg: i.key === activeKey ? "var(--c-surface)" : "transparent",
color: i.key === activeKey ? "var(--c-n1)" : "var(--c-n4)",
weight: i.key === activeKey ? 600 : 500,
shadow: i.key === activeKey ? "0 1px 3px rgba(0,0,0,0.12)" : "none",
}));
const modes = (active) => seg([
{ key: "edit", label: "Текст", title: "Только редактор", icon: ICON.edit },
{ key: "split", label: "Вместе", title: "Текст и предпросмотр", icon: ICON.split },
{ key: "view", label: "Просмотр", title: "Только предпросмотр", icon: ICON.eye },
], active);
const schemes = seg([
{ key: "light", label: "Светлая" }, { key: "dark", label: "Тёмная" }, { key: "auto", label: "Как в системе" },
], "light");
const mdTools = [
{ title: "Заголовок 1", text: "H1", bg: "transparent", color: "var(--c-n3)" },
{ title: "Заголовок 2", text: "H2", bg: "transparent", color: "var(--c-n3)" },
{ divider: true },
{ title: "Полужирный", icon: ICON.bold, bg: "transparent", color: "var(--c-n3)" },
{ title: "Курсив", icon: ICON.italic, bg: "transparent", color: "var(--c-n3)" },
{ title: "Ссылка", icon: ICON.link, bg: "transparent", color: "var(--c-n3)" },
{ title: "Код", icon: ICON.code, bg: "transparent", color: "var(--c-n3)" },
{ divider: true },
{ title: "Список", icon: ICON.list, bg: "transparent", color: "var(--c-n3)" },
{ title: "Нумерованный список", icon: ICON.numlist, bg: "transparent", color: "var(--c-n3)" },
{ title: "Цитата", icon: ICON.quote, bg: "transparent", color: "var(--c-n3)" },
{ title: "Таблица", icon: ICON.table, bg: "transparent", color: "var(--c-n3)" },
{ divider: true },
{ title: "Вставить изображение", icon: ICON.image, bg: "var(--c-accent-bg)", color: "var(--c-accent-text)" },
{ title: "Прикрепить файл", icon: ICON.attach, bg: "transparent", color: "var(--c-n3)" },
];
const mdSource = [
"# Как снять мерки",
"",
"Понадобится сантиметровая лента и помощник. Мерки снимают",
"по белью: лента ложится плотно, но не стягивает.",
"",
"![Точки замера: грудь, талия, бёдра](files/measure-points.png)",
"",
"## Обхват груди",
"",
"1. Лента идёт по самым выступающим точкам.",
"2. Сзади — горизонтально, без перекосов.",
"3. Запишите значение с точностью до 0,5 см.",
"",
"> Если вы между двумя размерами — выбирайте больший:",
"> ушить проще, чем расставить.",
"",
"| Мерка | Обозначение | Точность |",
"| ------------- | ----------- | -------- |",
"| Обхват груди | ОГ | 0,5 см |",
"| Обхват талии | ОТ | 0,5 см |",
"",
"Не уверены в результате — [запишитесь на примерку](/articles/primerka),",
"мастер снимет мерки сам.",
].join("\n");
const fileRow = (name, meta, icon, opts = {}) => ({
name, meta, icon,
done: opts.progress ? false : true,
progress: opts.progress || false,
metaColor: opts.progress ? "var(--c-accent-text)" : "var(--c-n4)",
border: opts.fresh ? "var(--c-accent)" : "var(--c-n8)",
bg: opts.fresh ? "var(--c-accent-bg)" : "var(--c-surface)",
});
const filesCalm = [
fileRow("measure-points.png", "PNG · 240 КБ · вставлен в текст", ICON.image),
fileRow("size-table.pdf", "PDF · 1,1 МБ · ссылка в тексте", ICON.file),
];
const filesDrop = [
fileRow("fitting-room.jpg", "загружаем… 68 %", ICON.image, { progress: "68%", fresh: true }),
fileRow("measure-points.png", "PNG · 240 КБ · вставлен в текст", ICON.image),
fileRow("size-table.pdf", "PDF · 1,1 МБ · ссылка в тексте", ICON.file),
];
const revisions = [
{ label: "Редакция 8", meta: "черновик · сегодня, 11:24 · Елена Кузнецова", tag: "текущая", tagBg: "var(--c-accent-bg)", tagColor: "var(--c-accent-text)", dot: "var(--c-warn-text)", bg: "var(--c-n9)" },
{ label: "Редакция 7", meta: "5 сентября, 11:20 · Анна Ким", tag: "опубликована", tagBg: "var(--c-ok-bg)", tagColor: "var(--c-ok-text)", dot: "var(--c-ok-text)", bg: "transparent" },
{ label: "Редакция 6", meta: "28 августа, 15:02 · Анна Ким", dot: "var(--c-n6)", bg: "transparent" },
{ label: "Редакция 5", meta: "14 августа, 09:31 · Елена Кузнецова", dot: "var(--c-n6)", bg: "transparent" },
];
// Каталог тем — features/help-center/themes/*/manifest.ts (автообнаружение).
// Названия, описания и preview-цвета берутся из манифеста, а не придумываются в UI.
const themes = [
{ id: "classic", name: "Классическая", desc: "Базовое оформление Help Center: светлая подложка, чернильный акцент.", tag: "выбрана", tagBg: "var(--c-accent-bg)", tagColor: "var(--c-accent-text)", border: "var(--c-accent)", bg: "var(--c-accent-bg)", canvas: "#ffffff", ink: "#1f1f1f", accent: "#1f1f1f", radius: "5px", barW: "58%" },
{ id: "ocean", name: "Ocean", desc: "Мягкое океаническое оформление: синий акцент, крупные скругления.", border: "var(--c-n7)", bg: "var(--c-surface)", canvas: "#ffffff", ink: "#10283d", accent: "#1b5e8f", radius: "9px", barW: "58%" },
];
// Одна запись: домен и установка принадлежат одному владельцу,
// подтверждать владение самому себе не нужно (см. README, решение 6).
const dns = [
{ type: "A", name: "help.atelie-nord.ru", value: "185.12.44.19 · IP этой установки" },
];
const SUB = [
{ key: "basics", label: "Основные", icon: ICON.settings },
{ key: "domain", label: "Адрес и домен", icon: ICON.globe, hint: "работает", hintColorKey: "ok" },
{ key: "theme", label: "Оформление", icon: ICON.paint },
{ key: "widget", label: "Веб-виджет", icon: ICON.widget },
{ key: "danger", label: "Публикация и архив", icon: ICON.danger, divider: true },
];
const sub = (active) => SUB.map((s) => ({
...s,
bg: s.key === active ? "var(--c-accent-bg)" : "transparent",
color: s.key === active ? "var(--c-accent-text)" : "var(--c-n2)",
weight: s.key === active ? 600 : 500,
hintColor: s.hintColorKey === "ok" ? "var(--c-ok-text)" : "var(--c-n5)",
}));
const settingsBtn = (active) => ({
settingsBg: active ? "var(--c-accent-bg)" : "var(--c-surface)",
settingsColor: active ? "var(--c-accent-text)" : "var(--c-n2)",
settingsBorder: active ? "var(--c-accent)" : "var(--c-n7)",
settingsWeight: active ? 600 : 500,
});
const dropCalm = { dropBorder: "var(--c-n6)", dropBg: "transparent", dropIcon: "var(--c-n4)" };
const dropHot = { dropBorder: "var(--c-accent)", dropBg: "var(--c-accent-bg)", dropIcon: "var(--c-accent-text)" };
const frames = [
{ id: "PT1", title: "Порталы · список", note: "адрес — колонка со ссылкой и копированием; действия в ⋯", isList: true },
{ id: "PT2", title: "Список · пусто", note: "создание доступно всегда (§1.1)", isEmpty: true },
{ id: "PT3", title: "Карточка портала · материалы", note: "шапка со ссылкой + библиотека без вкладок (§1.2–1.3)", hasPortal: true, isCard: true, isLibrary: true, ...settingsBtn(false) },
{ id: "PT4", title: "Настройки · Основные", note: "вместо модалки — страница с субменю разделов", hasPortal: true, isCard: true, isSettings: true, isBasics: true, sub: sub("basics"), ...settingsBtn(true) },
{ id: "PT5", title: "Настройки · Адрес и домен", note: "DNS-записи с копированием и статусом проверки", hasPortal: true, isCard: true, isSettings: true, isDomain: true, sub: sub("domain"), ...settingsBtn(true) },
{ id: "PT6", title: "Настройки · Оформление", note: "тема выбирается превью-карточкой (§6, ADR-0044)", hasPortal: true, isCard: true, isSettings: true, isTheme: true, sub: sub("theme"), ...settingsBtn(true) },
{ id: "PT7", title: "Редактор статьи · текст и предпросмотр", note: "панель Markdown, живой предпросмотр, версии и файлы в рейке", hasPortal: true, isEditor: true, modes: modes("split"), files: filesCalm, ...dropCalm },
{ id: "PT8", title: "Редактор · перетаскивание файла", note: "drop прямо в текст: изображение — картинкой, документ — ссылкой", hasPortal: true, isEditor: true, dropping: true, modes: modes("split"), files: filesDrop, ...dropHot },
];
return { themeVars, nav, frames, portals, rowMenu, cats, articles, mdTools, mdSource, revisions, themes, dns, schemes };
}
}
</script>
</body>
</html>
@@ -1,279 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
a{color:inherit;text-decoration:none;}
a:hover{opacity:0.85;}
*{scrollbar-width:none;}
*::-webkit-scrollbar{display:none;width:0;height:0;}
</style>
</helmet>
<section style="{{ themeVars }}min-height:100vh;background:var(--c-canvas);padding:32px 40px 60px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:var(--c-n2);-webkit-font-smoothing:antialiased;">
<div style="display:flex;align-items:baseline;gap:14px;margin-bottom:6px;">
<h1 style="margin:0;font-size:22px;font-weight:700;letter-spacing:-0.02em;color:var(--c-n1);">Chatbolls · Профиль пользователя</h1>
<span style="font-size:13px;color:var(--c-n4);">design baseline · все роли · SPEC-HUB-0031 §3, §7; ADR-0015, ADR-0029</span>
</div>
<p style="margin:0 0 28px;font-size:13px;color:var(--c-n3);max-width:960px;line-height:1.55;">Отдельная страница из меню пользователя (аватар в сайдбаре → «Профиль»), не раздел настроек. Слева — кто вы и как выглядит ваш интерфейс: личные данные с фото, тема и акцент, уведомления в мессенджер. Справа — безопасность: пароль, двухфакторная аутентификация, сессии. Всё здесь принадлежит учётной записи, а не организации (ADR-0029): владелец не меняет это у других.</p>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start;">
<sc-for list="{{ frames }}" as="f" hint-placeholder-count="1">
<div id="{{ f.id }}" style="flex:none;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<span style="display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:22px;padding:0 7px;border-radius:6px;background:var(--c-n1);color:var(--c-surface);font-size:11.5px;font-weight:700;letter-spacing:0.02em;">{{ f.badge }}</span>
<strong style="font-size:13.5px;color:var(--c-n1);">{{ f.title }}</strong>
<span style="font-size:12px;color:var(--c-n4);">{{ f.note }}</span>
</div>
<sc-if value="{{ f.desktop }}" hint-placeholder-val="{{ true }}">
<div style="width:1440px;height:880px;display:flex;overflow:hidden;background:var(--c-surface);border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);position:relative;">
<aside style="position:relative;width:220px;flex:none;background:var(--c-surface2);border-right:1px solid var(--c-n8);display:flex;flex-direction:column;min-height:0;">
<div title="Потяните, двойной клик — сбросить" style="position:absolute;top:0;right:-3px;bottom:0;width:6px;z-index:5;cursor:col-resize;"><i style="position:absolute;top:0;bottom:0;left:2px;width:2px;background:var(--c-accent);opacity:0;"></i></div>
<div style="flex:none;height:56px;display:flex;align-items:center;gap:10px;padding:14px 14px 10px;">
<span style="width:28px;height:28px;flex:none;display:flex;align-items:center;justify-content:center;border-radius:8px;color:var(--c-n1);"><svg viewBox="0 0 100 100" width="26" height="26" fill="currentColor"><defs><mask id="lg-prof"><rect width="100" height="100" fill="#fff"/><circle cx="50" cy="24.54" r="9" fill="#000"/><circle cx="50" cy="75.46" r="9" fill="#000"/></mask></defs><g mask="url(#lg-prof)"><circle cx="29" cy="50" r="24"/><circle cx="71" cy="50" r="24"/><rect x="44.2" y="31.4" width="11.6" height="37.2"/></g></svg></span>
<button style="flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:6px;height:32px;padding:0 6px 0 8px;border:none;border-radius:8px;background:transparent;color:var(--c-n1);font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Ателье Норд</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
</div>
<sc-if value="{{ f.showNav }}" hint-placeholder-val="{{ true }}">
<nav style="flex:none;padding:4px 10px 8px;display:flex;flex-direction:column;gap:2px;">
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="6">
<button style="display:flex;align-items:center;gap:10px;height:36px;padding:0 10px;border:none;border-radius:8px;background:transparent;color:var(--c-n2);font-size:13.5px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n8);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="{{ n.icon }}"/></svg><span style="flex:1;">{{ n.label }}</span><sc-if value="{{ n.badge }}" hint-placeholder-val="{{ false }}"><b style="min-width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;padding:0 6px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);color:var(--c-warn-text);font-size:11px;font-weight:700;">{{ n.badge }}</b></sc-if></button>
</sc-for>
</nav>
</sc-if>
<div style="flex:1;"></div>
<!-- user menu open -->
<div style="position:relative;flex:none;">
<div style="position:absolute;left:10px;bottom:64px;width:200px;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.14);padding:6px;z-index:5;">
<button style="width:100%;display:flex;align-items:center;gap:9px;height:32px;padding:0 10px;border:none;border-radius:7px;background:transparent;color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0"/></svg><span style="flex:1;">Уведомления</span><b style="min-width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;padding:0 5px;border-radius:9px;background:var(--c-accent);color:#fff;font-size:11px;font-weight:700;">3</b></button>
<button style="width:100%;display:flex;align-items:center;gap:9px;height:32px;padding:0 10px;border:none;border-radius:7px;background:var(--c-accent-bg);color:var(--c-accent-text);font-size:13px;font-weight:600;cursor:pointer;text-align:left;font-family:inherit;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Профиль</button>
<div style="height:1px;background:var(--c-n9);margin:4px 6px;"></div>
<button style="width:100%;display:flex;align-items:center;gap:9px;height:32px;padding:0 10px;border:none;border-radius:7px;background:transparent;color:#ff4d4f;font-size:13px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>Выйти</button>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:12px 14px;border-top:1px solid var(--c-n8);background:var(--c-n9);">
<span style="position:relative;flex:none;display:flex;"><span style="width:34px;height:34px;border-radius:50%;background:{{ f.photo }};"></span><i style="position:absolute;right:-1px;bottom:-1px;width:10px;height:10px;border:2px solid var(--c-n9);border-radius:50%;background:#52c41a;"></i></span>
<div style="flex:1;min-width:0;line-height:1.2;"><strong style="display:block;font-size:13px;font-weight:600;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ f.name }}</strong><small style="display:block;font-size:11.5px;color:var(--c-n4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ f.roleLine }}</small></div>
<button style="width:28px;height:28px;flex:none;border:none;border-radius:7px;background:var(--c-n8);color:var(--c-n2);display:flex;align-items:center;justify-content:center;cursor:pointer;"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>
</div>
</div>
</aside>
<section style="flex:1;min-width:0;min-height:0;overflow-y:auto;background:var(--c-feed);">
<div style="max-width:1120px;padding:28px 40px 48px;">
<!-- header -->
<div style="display:flex;align-items:center;gap:18px;margin-bottom:24px;">
<span style="width:72px;height:72px;flex:none;border-radius:50%;background:{{ f.photo }};box-shadow:0 0 0 3px var(--c-surface);"></span>
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:10px;"><h2 style="margin:0;font-size:22px;font-weight:700;letter-spacing:-0.01em;color:var(--c-n1);">{{ f.name }}</h2><b style="display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:6px;background:{{ f.roleBg }};color:{{ f.roleColor }};font-size:11.5px;font-weight:600;">{{ f.role }}</b></div>
<p style="margin:5px 0 0;font-size:13px;color:var(--c-n4);">{{ f.email }} · в организации с {{ f.since }}<sc-if value="{{ f.groups }}" hint-placeholder-val="{{ false }}"> · группы: {{ f.groups }}</sc-if></p>
</div>
<button style="flex:none;height:34px;padding:0 14px;border-radius:8px;border:1px solid #ff4d4f;background:transparent;color:#ff4d4f;font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;" style-hover="background:color-mix(in srgb,#ff4d4f 8%,var(--c-surface));"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>Выйти</button>
</div>
<div style="display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:20px;align-items:start;">
<!-- LEFT -->
<div style="display:flex;flex-direction:column;gap:20px;min-width:0;">
<!-- Личные данные -->
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 24px;">
<h3 style="margin:0 0 16px;font-size:14px;font-weight:700;color:var(--c-n1);">Личные данные</h3>
<div style="display:flex;align-items:center;gap:16px;padding-bottom:18px;margin-bottom:18px;border-bottom:1px solid var(--c-n9);">
<span style="width:56px;height:56px;flex:none;border-radius:50%;background:{{ f.photo }};"></span>
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13.5px;color:var(--c-n1);">Фото</strong><small style="display:block;margin-top:3px;font-size:12px;color:var(--c-n4);line-height:1.45;">PNG, JPEG или WebP · до 2 МБ. Видно коллегам в чате, подписях сообщений и выборе ответственного.</small></div>
<div style="display:flex;gap:8px;flex:none;">
<button style="height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Заменить</button>
<button style="height:32px;padding:0 12px;border-radius:8px;border:none;background:transparent;color:var(--c-n4);font-size:12.5px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);color:#ff4d4f;">Удалить</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px 20px;">
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Имя</span><span style="display:flex;align-items:center;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13.5px;color:var(--c-n1);">{{ f.name }}</span></label>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Email · используется для входа</span><span style="display:flex;align-items:center;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13px;color:var(--c-n1);font-family:'SF Mono',ui-monospace,Menlo,monospace;">{{ f.email }}</span></label>
</div>
<div style="display:flex;align-items:center;justify-content:flex-end;gap:12px;margin-top:18px;"><button style="height:34px;padding:0 16px;border-radius:8px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Сохранить</button></div>
</div>
<!-- Внешний вид -->
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 24px;">
<h3 style="margin:0 0 4px;font-size:14px;font-weight:700;color:var(--c-n1);">Внешний вид</h3>
<p style="margin:0 0 18px;font-size:12.5px;color:var(--c-n4);line-height:1.5;">Настройка личная и хранится в учётной записи: применяется на всех ваших устройствах, без перезагрузки.</p>
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 0;border-top:1px solid var(--c-n9);">
<span style="font-size:13px;font-weight:600;color:var(--c-n2);">Тема</span>
<div style="display:inline-flex;padding:3px;border-radius:9px;background:var(--c-n9);">
<sc-for list="{{ f.themes }}" as="t" hint-placeholder-count="3"><button style="height:30px;padding:0 14px;border-radius:7px;border:none;background:{{ t.bg }};color:{{ t.color }};font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;box-shadow:{{ t.shadow }};display:inline-flex;align-items:center;gap:6px;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="{{ t.icon }}"/></svg>{{ t.label }}</button></sc-for>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 0 4px;border-top:1px solid var(--c-n9);">
<span style="font-size:13px;font-weight:600;color:var(--c-n2);">Акцентный цвет</span>
<div style="display:flex;align-items:center;gap:10px;">
<sc-for list="{{ f.accents }}" as="a" hint-placeholder-count="4"><button title="{{ a.label }}" style="width:26px;height:26px;border-radius:50%;border:none;background:{{ a.value }};cursor:pointer;box-shadow:{{ a.ring }};display:inline-flex;align-items:center;justify-content:center;color:#fff;"><sc-if value="{{ a.on }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></sc-if></button></sc-for>
<span style="width:1px;height:22px;background:var(--c-n8);margin:0 2px;"></span>
<span style="display:flex;align-items:center;gap:6px;height:32px;padding:0 4px 0 10px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);"><i style="width:14px;height:14px;border-radius:4px;background:var(--c-n6);"></i><span style="font-size:12.5px;color:var(--c-n5);font-family:'SF Mono',ui-monospace,Menlo,monospace;width:64px;">#1677ff</span><button style="height:24px;padding:0 8px;border-radius:6px;border:none;background:var(--c-n8);color:var(--c-n3);font-size:11.5px;font-weight:600;cursor:pointer;font-family:inherit;">Применить</button></span>
</div>
</div>
</div>
<!-- Уведомления -->
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 24px;">
<h3 style="margin:0 0 4px;font-size:14px;font-weight:700;color:var(--c-n1);">Уведомления в мессенджер</h3>
<p style="margin:0 0 14px;font-size:12.5px;color:var(--c-n4);line-height:1.5;">Рабочие события приходят и при закрытом браузере. Один мессенджер: привязка нового заменяет текущий.</p>
<sc-for list="{{ bots }}" as="b" hint-placeholder-count="2">
<div style="border-top:1px solid var(--c-n9);padding:14px 0 12px;">
<div style="display:flex;align-items:center;gap:12px;">
<span style="width:34px;height:34px;flex:none;border-radius:9px;background:{{ b.tileBg }};color:{{ b.tileColor }};display:flex;align-items:center;justify-content:center;"><svg viewBox="{{ b.view }}" width="19" height="19" fill="currentColor"><path d="{{ b.path }}"/></svg></span>
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13.5px;color:var(--c-n1);">{{ b.name }}</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);">{{ b.sub }}</small></div>
<sc-if value="{{ b.bound }}" hint-placeholder-val="{{ true }}"><b style="display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:6px;background:var(--c-ok-bg);color:var(--c-ok-text);font-size:11.5px;font-weight:600;">Подключено</b><button style="height:30px;padding:0 12px;border-radius:7px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-n9);">Отключить</button></sc-if>
<sc-if value="{{ b.unbound }}" hint-placeholder-val="{{ false }}"><a href="#" style="height:30px;padding:0 12px;border-radius:7px;background:var(--c-accent);color:#fff;font-size:12.5px;font-weight:600;display:inline-flex;align-items:center;gap:6px;">Привязать бота<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17 17 7M8 7h9v9"/></svg></a></sc-if>
</div>
<sc-if value="{{ b.bound }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;flex-wrap:wrap;gap:8px 18px;margin:12px 0 0 46px;">
<sc-for list="{{ pushTypes }}" as="p" hint-placeholder-count="4"><label style="display:inline-flex;align-items:center;gap:7px;font-size:12.5px;color:var(--c-n2);cursor:pointer;"><span style="width:16px;height:16px;border-radius:4px;border:1px solid {{ p.border }};background:{{ p.bg }};display:inline-flex;align-items:center;justify-content:center;color:#fff;"><sc-if value="{{ p.on }}" hint-placeholder-val="{{ true }}"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></sc-if></span>{{ p.label }}</label></sc-for>
</div>
</sc-if>
</div>
</sc-for>
<p style="margin:6px 0 0;font-size:12px;color:var(--c-n4);line-height:1.5;">Нажмите «Привязать бота» и в открывшемся чате — «Начать»: привязка подтвердится сама.</p>
</div>
</div>
<!-- RIGHT: security -->
<div style="display:flex;flex-direction:column;gap:20px;min-width:0;">
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 24px;">
<h3 style="margin:0 0 16px;font-size:14px;font-weight:700;color:var(--c-n1);">Смена пароля</h3>
<div style="display:flex;flex-direction:column;gap:12px;">
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Текущий пароль</span><span style="display:flex;align-items:center;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13.5px;color:var(--c-n1);letter-spacing:0.15em;">••••••••••</span></label>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Новый пароль</span><span style="display:flex;align-items:center;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13px;color:var(--c-n5);">мин. 10 символов</span></label>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Повторите новый пароль</span><span style="display:flex;align-items:center;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:13px;color:var(--c-n5);"></span></label>
</div>
<div style="display:flex;justify-content:flex-end;margin-top:16px;"><button style="height:34px;padding:0 14px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n4);font-size:13px;font-weight:600;cursor:default;font-family:inherit;">Обновить пароль</button></div>
</div>
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 24px;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;">
<div style="min-width:0;"><h3 style="margin:0 0 4px;font-size:14px;font-weight:700;color:var(--c-n1);">Двухфакторная аутентификация</h3><p style="margin:0;font-size:12.5px;color:var(--c-n4);line-height:1.5;">{{ f.totpText }}</p></div>
<span style="position:relative;flex:none;width:44px;height:24px;border-radius:12px;background:{{ f.totpBg }};display:inline-block;cursor:pointer;margin-top:2px;"><i style="position:absolute;top:2px;left:{{ f.totpKnob }};width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,0.25);"></i></span>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-top:14px;padding:10px 12px;border-radius:8px;background:{{ f.totpNoteBg }};border:1px solid {{ f.totpNoteBorder }};"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="{{ f.totpNoteColor }}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="{{ f.totpNoteIcon }}"/></svg><span style="font-size:12.5px;color:{{ f.totpNoteColor }};font-weight:500;">{{ f.totpNote }}</span></div>
</div>
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:20px 24px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;"><h3 style="margin:0;font-size:14px;font-weight:700;color:var(--c-n1);">Активные сессии</h3><button style="height:30px;padding:0 12px;border-radius:7px;border:1px solid #ff4d4f;background:transparent;color:#ff4d4f;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap;" style-hover="background:color-mix(in srgb,#ff4d4f 8%,var(--c-surface));">Завершить другие</button></div>
<sc-for list="{{ sessions }}" as="s" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-top:1px solid var(--c-n9);">
<span style="width:32px;height:32px;flex:none;border-radius:8px;background:var(--c-n9);color:var(--c-n3);display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="{{ s.icon }}"/></svg></span>
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13px;color:var(--c-n1);">{{ s.device }}</strong><small style="display:block;margin-top:2px;font-size:11.5px;color:var(--c-n4);">{{ s.where }}</small></div>
<sc-if value="{{ s.current }}" hint-placeholder-val="{{ true }}"><b style="display:inline-flex;align-items:center;height:20px;padding:0 7px;border-radius:5px;background:var(--c-accent-bg);color:var(--c-accent-text);font-size:11px;font-weight:600;">текущая</b></sc-if>
<sc-if value="{{ s.time }}" hint-placeholder-val="{{ false }}"><small style="font-size:11.5px;color:var(--c-n5);">{{ s.time }}</small></sc-if>
</div>
</sc-for>
</div>
</div>
</div>
</div>
</section>
</div>
</sc-if>
<!-- MOBILE -->
<sc-if value="{{ f.mobile }}" hint-placeholder-val="{{ false }}">
<div style="width:390px;height:844px;display:flex;flex-direction:column;overflow:hidden;background:var(--c-feed);border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);">
<div style="flex:none;display:flex;align-items:center;gap:6px;padding:14px 12px 10px;background:var(--c-surface);border-bottom:1px solid var(--c-n8);"><button style="width:32px;height:32px;border:none;border-radius:8px;background:transparent;color:var(--c-n2);display:flex;align-items:center;justify-content:center;cursor:pointer;"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 6 9 12 15 18"/></svg></button><h2 style="margin:0;flex:1;font-size:17px;font-weight:700;color:var(--c-n1);">Профиль</h2><button style="height:32px;padding:0 10px;border:none;border-radius:8px;background:transparent;color:#ff4d4f;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;">Выйти</button></div>
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:14px;">
<div style="display:flex;align-items:center;gap:14px;padding:4px 0 6px;"><span style="width:60px;height:60px;flex:none;border-radius:50%;background:url(assets/av-1.png) center/cover;"></span><div style="min-width:0;"><div style="display:flex;align-items:center;gap:8px;"><strong style="font-size:18px;font-weight:700;color:var(--c-n1);">Дмитрий Орлов</strong><b style="display:inline-flex;align-items:center;height:20px;padding:0 7px;border-radius:5px;background:var(--c-n9);color:var(--c-n3);font-size:11px;font-weight:600;">Сотрудник</b></div><small style="display:block;margin-top:3px;font-size:12.5px;color:var(--c-n4);">d.orlov@atelie-nord.ru</small></div></div>
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:14px;">
<h3 style="margin:0;font-size:14px;font-weight:700;color:var(--c-n1);">Личные данные</h3>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Имя</span><span style="display:flex;align-items:center;height:44px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:15px;color:var(--c-n1);">Дмитрий Орлов</span></label>
<label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:12.5px;font-weight:600;color:var(--c-n3);">Email</span><span style="display:flex;align-items:center;height:44px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-n10);font-size:14px;color:var(--c-n1);font-family:'SF Mono',ui-monospace,Menlo,monospace;">d.orlov@atelie-nord.ru</span></label>
<button style="height:44px;border-radius:10px;border:none;background:var(--c-accent);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">Сохранить</button>
</div>
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:14px;">
<h3 style="margin:0;font-size:14px;font-weight:700;color:var(--c-n1);">Внешний вид</h3>
<div style="display:flex;padding:3px;border-radius:9px;background:var(--c-n9);"><sc-for list="{{ themesM }}" as="t" hint-placeholder-count="3"><button style="flex:1;height:36px;border-radius:7px;border:none;background:{{ t.bg }};color:{{ t.color }};font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;box-shadow:{{ t.shadow }};">{{ t.label }}</button></sc-for></div>
<div style="display:flex;align-items:center;justify-content:space-between;"><span style="font-size:13px;font-weight:600;color:var(--c-n2);">Акцентный цвет</span><div style="display:flex;gap:12px;"><sc-for list="{{ accentsM }}" as="a" hint-placeholder-count="4"><button style="width:32px;height:32px;border-radius:50%;border:none;background:{{ a.value }};box-shadow:{{ a.ring }};cursor:pointer;color:#fff;display:inline-flex;align-items:center;justify-content:center;"><sc-if value="{{ a.on }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></sc-if></button></sc-for></div></div>
</div>
<div style="background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;overflow:hidden;">
<button style="width:100%;display:flex;align-items:center;gap:12px;height:52px;padding:0 16px;border:none;border-bottom:1px solid var(--c-n9);background:transparent;color:var(--c-n1);font-size:15px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;"><span style="flex:1;">Уведомления в мессенджер</span><small style="font-size:12.5px;color:var(--c-ok-text);">Telegram</small><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--c-n5)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg></button>
<button style="width:100%;display:flex;align-items:center;gap:12px;height:52px;padding:0 16px;border:none;border-bottom:1px solid var(--c-n9);background:transparent;color:var(--c-n1);font-size:15px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;"><span style="flex:1;">Смена пароля</span><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--c-n5)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg></button>
<button style="width:100%;display:flex;align-items:center;gap:12px;height:52px;padding:0 16px;border:none;border-bottom:1px solid var(--c-n9);background:transparent;color:var(--c-n1);font-size:15px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;"><span style="flex:1;">Двухфакторная аутентификация</span><small style="font-size:12.5px;color:var(--c-n4);">Выкл</small><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--c-n5)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg></button>
<button style="width:100%;display:flex;align-items:center;gap:12px;height:52px;padding:0 16px;border:none;background:transparent;color:var(--c-n1);font-size:15px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;"><span style="flex:1;">Активные сессии</span><small style="font-size:12.5px;color:var(--c-n4);">3</small><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--c-n5)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg></button>
</div>
</div>
</div>
</sc-if>
</div>
</sc-for>
</div>
</section>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'light' | 'dark'&quot;,&quot;section&quot;:&quot;Тема&quot;},&quot;accent&quot;:{&quot;editor&quot;:&quot;color&quot;,&quot;options&quot;:[&quot;#1677ff&quot;,&quot;#6d5dfc&quot;,&quot;#0f9b8e&quot;,&quot;#e8590c&quot;],&quot;default&quot;:&quot;#1677ff&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Тема&quot;}}">
class Component extends DCLogic {
renderVals() {
const dark = (this.props.theme ?? "light") === "dark";
const accent = this.props.accent ?? "#1677ff";
const T = dark
? { canvas: "#101113", surface: "#1b1c1f", surface2: "#181a1d", feed: "#15161a", n1: "#d6d7db", n2: "#c3c5ca", n3: "#9a9ca3", n4: "#767881", n5: "#5b5d66", n6: "#45474f", n7: "#37393f", n8: "#2d2f35", n9: "#27292e", n10: "#212327", ai: "#a98be0", warnText: "#d9a441", okText: "#5fbf7a", accentText: `color-mix(in srgb,${accent} 62%,white)`, accentHover: `color-mix(in srgb,${accent} 92%,white)`, accentBgPct: 16, tintPct: 12, accentMuted: `color-mix(in srgb,${accent} 84%,#1b1c1f)` }
: { canvas: "#e4e6ea", surface: "#ffffff", surface2: "#fafafa", feed: "#f5f6f8", n1: "#1f1f1f", n2: "#262626", n3: "#595959", n4: "#8c8c8c", n5: "#bfbfbf", n6: "#d9d9d9", n7: "#e8e8e8", n8: "#f0f0f0", n9: "#f5f5f5", n10: "#fafafa", ai: "#722ed1", warnText: "#d48806", okText: "#237a3f", accentText: `color-mix(in srgb,${accent} 82%,black)`, accentHover: `color-mix(in srgb,${accent} 88%,black)`, accentBgPct: 10, tintPct: 11, accentMuted: accent };
const themeVars = `--c-canvas:${T.canvas};--c-surface:${T.surface};--c-surface2:${T.surface2};--c-feed:${T.feed};--c-n1:${T.n1};--c-n2:${T.n2};--c-n3:${T.n3};--c-n4:${T.n4};--c-n5:${T.n5};--c-n6:${T.n6};--c-n7:${T.n7};--c-n8:${T.n8};--c-n9:${T.n9};--c-n10:${T.n10};--c-ai:${T.ai};--c-accent:${T.accentMuted};--c-accent-text:${T.accentText};--c-accent-hover:${T.accentHover};--c-accent-bg:color-mix(in srgb,${accent} ${T.accentBgPct}%,${T.surface});--c-warn-bg:color-mix(in srgb,#faad14 ${T.tintPct}%,${T.surface});--c-warn-border:color-mix(in srgb,#faad14 ${T.tintPct + 25}%,${T.surface});--c-warn-text:${T.warnText};--c-ok-bg:color-mix(in srgb,#52c41a ${T.tintPct}%,${T.surface});--c-ok-border:color-mix(in srgb,#52c41a ${T.tintPct + 20}%,${T.surface});--c-ok-text:${T.okText};`;
const ICON = {
chat: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z",
contacts: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0z",
agents: "M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2zM12 2v2M9 12v9M15 12v9M9 18h6M10 8v.01M14 8v.01",
team: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
portals: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM2 12h20M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",
settings: "M21 6H9M3 6h2M9 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 12h-8M3 12h6M13 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zM21 18h-6M3 18h8M15 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0z",
sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4",
moon: "M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z",
monitor: "M2 4h20v12H2zM8 20h8M12 16v4",
laptop: "M4 5h16v11H4zM2 19h20",
phone: "M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zM12 18h.01",
check: "M20 6 9 17l-5-5",
alert: "M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z",
};
const nav = [
{ label: "Чат", icon: ICON.chat, badge: 2 }, { label: "Контакты", icon: ICON.contacts }, { label: "Агенты", icon: ICON.agents },
{ label: "Сотрудники", icon: ICON.team }, { label: "Порталы", icon: ICON.portals }, { label: "Настройки", icon: ICON.settings },
];
const THEMES = [["LIGHT", "Светлая", ICON.sun], ["DARK", "Тёмная", ICON.moon], ["SYSTEM", "Как в системе", ICON.monitor]];
const themes = (on) => THEMES.map(([k, label, icon]) => ({ label, icon, bg: k === on ? "var(--c-surface)" : "transparent", color: k === on ? "var(--c-n1)" : "var(--c-n3)", shadow: k === on ? "0 1px 2px rgba(0,0,0,0.08)" : "none" }));
const ACCENTS = [["#1677ff", "Синий"], ["#6d5dfc", "Индиго"], ["#0f9b8e", "Бирюзовый"], ["#e8590c", "Оранжевый"]];
const accents = (on) => ACCENTS.map(([value, label]) => ({ value, label, on: value === on, ring: value === on ? `0 0 0 2px var(--c-surface), 0 0 0 4px ${value}` : "none" }));
const TG = "M512 256C512 114.62 397.38 0 256 0S0 114.62 0 256s114.62 256 256 256 256-114.62 256-256zm-396.12-2.7c74.63-32.52 124.39-53.95 149.29-64.31 71.1-29.57 85.87-34.71 95.5-34.88 2.12-.03 6.85.49 9.92 2.98 2.59 2.1 3.3 4.94 3.64 6.93.34 2 .77 6.53.43 10.08-3.85 40.48-20.52 138.71-29 184.05-3.59 19.19-10.66 25.62-17.5 26.25-14.86 1.37-26.15-9.83-40.55-19.27-22.53-14.76-35.26-23.96-57.13-38.37-25.28-16.66-8.89-25.81 5.51-40.77 3.77-3.92 69.27-63.5 70.54-68.9.16-.68.31-3.2-1.19-4.53s-3.71-.87-5.3-.51c-2.26.51-38.25 24.3-107.98 71.37-10.22 7.02-19.48 10.43-27.77 10.26-9.14-.2-26.72-5.17-39.79-9.42-16.03-5.21-28.77-7.97-27.66-16.82.57-4.61 6.92-9.32 19.04-14.14z";
const MAX = "M350.4,9.6C141.8,20.5,4.1,184.1,12.8,390.4c3.8,90.3,40.1,168,48.7,253.7,2.2,22.2-4.2,49.6,21.4,59.3,31.5,11.9,79.8-8.1,106.2-26.4,9-6.1,17.6-13.2,24.2-22,27.3,18.1,53.2,35.6,85.7,43.4,143.1,34.3,299.9-44.2,369.6-170.3C799.6,291.2,622.5-4.6,350.4,9.6h0ZM269.4,504c-11.3,8.8-22.2,20.8-34.7,27.7-18.1,9.7-23.7-.4-30.5-16.4-21.4-50.9-24-137.6-11.5-190.9,16.8-72.5,72.9-136.3,150-143.1,78-6.9,150.4,32.7,183.1,104.2,72.4,159.1-112.9,316.2-256.4,218.6h0Z";
const bots = [
{ name: "Telegram", sub: "@nord_notify_bot · сервисный бот уведомлений", view: "0 0 512 512", path: TG, tileBg: "color-mix(in srgb,#229ED9 14%,var(--c-surface))", tileColor: "#229ED9", bound: true, unbound: false },
{ name: "MAX", sub: "Норд · уведомления", view: "0 0 720 720", path: MAX, tileBg: "color-mix(in srgb,#5b5bd6 14%,var(--c-surface))", tileColor: "#5b5bd6", bound: false, unbound: true },
];
const pushTypes = [["Клиент запросил оператора", true], ["Новое сообщение в моём диалоге", true], ["Превышено время ожидания", true], ["AI остановлен ошибкой или лимитом", false], ["Интеграция недоступна", false]].map(([label, on]) => ({ label, on, bg: on ? "var(--c-accent)" : "var(--c-surface)", border: on ? "var(--c-accent)" : "var(--c-n6)" }));
const sessions = [
{ device: "Chrome · macOS", where: "Москва · 91.108.•.• · сейчас", icon: ICON.laptop, current: true },
{ device: "Safari · iPhone", where: "Москва · мобильная сеть", icon: ICON.phone, time: "2 ч назад" },
{ device: "Firefox · Windows", where: "Санкт-Петербург", icon: ICON.monitor, time: "3 дня назад" },
];
const totp = (on) => on
? { totpText: "Включена. Код из приложения-аутентификатора запрашивается при каждом входе.", totpBg: "var(--c-accent)", totpKnob: "22px", totpNote: "Аутентификатор подключён · последний код принят 5 мин назад", totpNoteBg: "var(--c-ok-bg)", totpNoteBorder: "var(--c-ok-border)", totpNoteColor: "var(--c-ok-text)", totpNoteIcon: ICON.check }
: { totpText: "Отключена. Для владельца и администраторов настоятельно рекомендуется включить.", totpBg: "var(--c-n6)", totpKnob: "2px", totpNote: "Без второго фактора доступ к организации защищён только паролем", totpNoteBg: "var(--c-warn-bg)", totpNoteBorder: "var(--c-warn-border)", totpNoteColor: "var(--c-warn-text)", totpNoteIcon: ICON.alert };
const frames = [
{ id: "p1", badge: "P1", title: "Владелец", note: "меню пользователя открыто, «Профиль» активен; навигация видна; TOTP включена", desktop: true, showNav: true, name: "Елена Кузнецова", email: "e.kuznetsova@atelie-nord.ru", role: "Владелец", roleBg: "var(--c-accent-bg)", roleColor: "var(--c-accent-text)", roleLine: "Владелец · e.kuznetsova@atelie-nord.ru", photo: "url(assets/av-4.png) center/cover", since: "янв 2026", groups: "", themes: themes("LIGHT"), accents: accents(accent), ...totp(true) },
{ id: "p2", badge: "P2", title: "Сотрудник", note: "сайдбар без навигации (§3): логотип, организация, профиль; тема «Как в системе», бирюзовый акцент; TOTP выключена", desktop: true, showNav: false, name: "Дмитрий Орлов", email: "d.orlov@atelie-nord.ru", role: "Сотрудник", roleBg: "var(--c-n9)", roleColor: "var(--c-n3)", roleLine: "Сотрудник · d.orlov@atelie-nord.ru", photo: "url(assets/av-1.png) center/cover", since: "мар 2026", groups: "Операторы", themes: themes("SYSTEM"), accents: accents("#0f9b8e"), ...totp(false) },
{ id: "m", badge: "M", title: "Мобильный", note: "личные данные и внешний вид сразу; безопасность и уведомления — подэкранами", desktop: false, mobile: true },
];
return { themeVars, nav, bots, pushTypes, sessions, themesM: themes("SYSTEM"), accentsM: accents("#0f9b8e"), frames };
}
}
</script>
</body>
</html>
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more