Compare commits

...
13 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5 c8b8246b35 🔖 release: 1.13.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:32:54 +03:00
AndreyandClaude Opus 5 b95a4edf6e 🐛 fix(settings): адрес установки помнит порт, ссылки ведут на него
Мастер первого запуска и «Настройки» отбрасывали порт из адреса. На
установке, опубликованной как ip:8081, ссылки на файлы, звонки, приглашения
и сброс пароля уходили без порта и вели в пустоту.

Порт хранится отдельно от хоста (InstanceSettings.public_port): по хосту
проверяются входящие Host, строятся домены порталов и адреса TURN, и порт им
чужой. В ссылки он добавляется, когда не совпадает с портом схемы. В поле
адреса его вписывают через двоеточие; смена одного порта прежний адрес не
сдвигает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:32:46 +03:00
AndreyandClaude Opus 5 19448eb05a ✨ feat(ai): размер контекста диалога задаётся в карточке агента
Модель получала последние 20 сообщений диалога — число было зашито в
ingest.py, и поменять его можно было только правкой кода, которую стирало
первое же обновление. Длинному разговору 20 мало, локальной модели с
маленьким окном — бывает много.

Теперь это поле «Контекст диалога, сообщений» в блоке «Модель» карточки
агента: от 1 до 200, по умолчанию 20. Проверочный чат карточки видит то же
окно, что и живой диалог.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:32:46 +03:00
AndreyandClaude Opus 5 a321109a8f 🐛 fix(gateway): установка на нестандартном порту принимает формы
Шлюз передавал дальше Host без порта ({host} в Caddy, $host в nginx). Браузер
при этом шлёт Origin с портом, и CSRF-проверка Django отвергала любой POST на
установке, опубликованной как ip:8081: «Origin checking failed».

Теперь Host уходит с портом. X-Forwarded-Proto и X-Forwarded-For Caddy
принимает от прокси из частных сетей — установку часто ставят за прокси
панели, который снимает TLS и ходит к шлюзу по http; без этого CSRF падал бы
снова, как только перед установкой появлялся https.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:32:25 +03:00
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
127 changed files with 1717 additions and 33741 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/
+18 -8
View File
@@ -16,18 +16,29 @@
on_demand_tls {
ask http://backend-platform:8000/api/v1/gateway/help-domain/
}
# Установку часто ставят за прокси панели (aaPanel, nginx хоста), который
# снимает TLS и ходит сюда по http. Его X-Forwarded-Proto/For принимаются
# только из частных сетей — оттуда, где такой прокси и стоит; клиент из
# интернета подделать их не может.
servers {
trusted_proxies static private_ranges
}
}
(surfaces) {
# Host уходит с портом: браузер шлёт Origin с портом (http://ip:8081), и
# без него CSRF-проверка Django отвергала любой POST на нестандартном
# порту. X-Forwarded-Proto Caddy ставит сам: {scheme}, а за доверенным
# прокси — то, что прислал прокси (https, если TLS снят перед нами).
# Платформенная поверхность живёт на своём домене; пока он не задан,
# матчер намеренно не совпадает ни с чем.
@platform host {$CHATBALLS_PLATFORM_DOMAIN:platform.invalid}
handle @platform {
reverse_proxy backend-platform:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up Host {hostport}
header_up X-Real-IP {client_ip}
header_up X-Forwarded-For {client_ip}
}
}
@@ -35,10 +46,9 @@
# frontend-контейнера.
handle {
reverse_proxy frontend:80 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up Host {hostport}
header_up X-Real-IP {client_ip}
header_up X-Forwarded-For {client_ip}
}
}
}
+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.10.0
1.13.0
+16 -1
View File
@@ -12,7 +12,7 @@ from django.db import transaction
from django.db.models import Case, Count, IntegerField, Q, QuerySet, Value, When
from django.utils.text import slugify
from chatballs.ai.models import AIAgent, AIAgentStatus, AnswerLanguage
from chatballs.ai.models import HISTORY_LIMIT_MAX, AIAgent, AIAgentStatus, AnswerLanguage
from chatballs.ai.serializers import agent_portal_article_payload
from chatballs.channels.models import Channel
from chatballs.channels.services import (
@@ -146,6 +146,7 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
"transcriptionIntegrationId": agent.transcription_integration_id,
"modelParams": agent.model_params,
"answerLanguage": agent.answer_language,
"historyLimit": agent.history_limit,
"persona": agent.persona,
"tone": agent.tone,
"instructions": agent.instructions,
@@ -255,6 +256,7 @@ def update_agent_card(
"tone",
"instructions",
"answerLanguage",
"historyLimit",
"knowledgeIds",
}
if ai_fields & set(body):
@@ -305,6 +307,9 @@ def update_agent_card(
answer_language=_clean_answer_language(
body.get("answerLanguage", agent.answer_language)
),
history_limit=_clean_history_limit(
body.get("historyLimit", agent.history_limit)
),
knowledge_ids=knowledge_ids,
),
)
@@ -326,6 +331,16 @@ def _clean_answer_language(value: object) -> str:
raise ValidationError({"answerLanguage": t("ai.unknown_answer_language")})
def _clean_history_limit(value: object) -> int:
"""Окно истории агента: целое число сообщений от 1 до HISTORY_LIMIT_MAX."""
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= HISTORY_LIMIT_MAX:
raise ValidationError(
{"historyLimit": t("ai.history_limit_out_of_range", max=HISTORY_LIMIT_MAX)}
)
return value
def agent_deletion_blockers(channel: Channel) -> list[dict[str, object]]:
"""Агент удаляется вместе с каналом; блокируют только внешние связи."""
counts = (
@@ -246,6 +246,10 @@ class AgentCardTestChatView(APIView):
history = request.data.get("history") or []
if not isinstance(history, list):
return Response({"detail": t("ai.history_must_be_list")}, status=400)
# Проверочный чат видит то же окно истории, что и живой диалог.
agent = getattr(channel, "ai_agent", None)
if agent is not None:
history = history[-agent.history_limit:]
try:
result = run_channel_turn(channel=channel, message=message, history=history)
except ProviderError as error:
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ai", "0022_release_agent_model_from_integration"),
]
operations = [
migrations.AddField(
model_name="aiagent",
name="history_limit",
field=models.PositiveSmallIntegerField(default=20),
),
]
+9
View File
@@ -14,6 +14,11 @@ DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
# Границы окна истории агента (AIAgent.history_limit).
HISTORY_LIMIT_DEFAULT = 20
HISTORY_LIMIT_MAX = 200
class AnswerLanguage(models.TextChoices):
"""Режимы поля ``AIAgent.answer_language``, кроме кодов самих языков."""
@@ -393,6 +398,10 @@ class AIAgent(TenantRelationModel):
transcription_model = models.CharField(max_length=128, blank=True, default="")
model_params = models.JSONField(default=dict, blank=True)
# Сколько последних сообщений диалога уходит модели вместе с новым. Больше —
# агент помнит длинный разговор, но каждый ответ дороже, а у локальной
# модели с малым окном контекста хвост просто обрежется на её стороне.
history_limit = models.PositiveSmallIntegerField(default=HISTORY_LIMIT_DEFAULT)
# Язык ответов клиенту. По умолчанию агент отвечает на языке, на котором
# к нему обратились: сигнал точный, лежит прямо в сообщении и не требует
+3
View File
@@ -33,6 +33,7 @@ class AgentInput:
tone: str
instructions: str
answer_language: str
history_limit: int
knowledge_ids: list[int] | None # None -> выбор знаний не меняется
@@ -151,6 +152,7 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
locked.tone = data.tone
locked.instructions = data.instructions
locked.answer_language = data.answer_language
locked.history_limit = data.history_limit
locked.save(
update_fields=[
"name",
@@ -164,6 +166,7 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
"tone",
"instructions",
"answer_language",
"history_limit",
"updated_at",
]
)
@@ -253,6 +253,16 @@ class AgentCardUpdateTests(AgentCardTestCase):
self.patch(knowledgeIds=[])
self.assertEqual(agent.knowledge_items.count(), 0)
def test_history_limit_is_saved_and_validated(self) -> None:
self.assertEqual(self.card["historyLimit"], 20)
saved = self.patch(historyLimit=100)
self.assertEqual(saved.status_code, 200)
self.assertEqual(saved.json()["agent"]["historyLimit"], 100)
for wrong in (0, 201, "50", 12.5, True, None):
with self.subTest(value=wrong):
self.assertEqual(self.patch(historyLimit=wrong).status_code, 400)
self.assertEqual(AIAgent.objects.get(id=self.card["aiAgentId"]).history_limit, 100)
class AgentCardActivationTests(AgentCardTestCase):
def setUp(self) -> None:
@@ -40,6 +40,7 @@ from chatballs.identity.models import AuditEvent
PROVIDER_CODE = {
"MAX": "MAX",
"TELEGRAM": "TG",
"VK": "VK",
"WEB": "WEB",
"EMAIL": "EMAIL",
}
+14 -5
View File
@@ -13,6 +13,7 @@ from dataclasses import dataclass
from django.db import IntegrityError, transaction
from django.utils import timezone
from chatballs.ai.models import HISTORY_LIMIT_DEFAULT
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.runtime import HANDOFF_TOKEN
from chatballs.channels.runtime import run_channel_turn
@@ -41,7 +42,6 @@ from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
_HISTORY_LIMIT = 20
_ROLE = {
MessageAuthor.CONTACT: "user",
MessageAuthor.AI: "assistant",
@@ -74,9 +74,12 @@ def _already_processed(context: TenantContext, source: str, external_id: str, te
return True
def _history(conversation: Conversation) -> list[dict]:
messages = list(conversation.messages.order_by("created_at"))
prior = messages[:-1][-_HISTORY_LIMIT:] # без только что сохранённого входящего
def _history(conversation: Conversation, limit: int) -> list[dict]:
# С конца и с ограничением в базе: длинный диалог не поднимается в память
# целиком ради последних сообщений. Самое новое — только что сохранённое
# входящее, оно уходит модели отдельно.
latest = conversation.messages.order_by("-created_at", "-id")[: limit + 1]
prior = list(reversed(latest))[:-1]
# Голосовые попадают в контекст стенограммой.
return [{"role": _ROLE.get(m.author_type, "user"), "content": m.text or m.transcript} for m in prior if m.text or m.transcript]
@@ -406,7 +409,13 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
return
try:
result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation))
result = run_channel_turn(
channel=channel,
message=ai_input,
history=_history(
conversation, agent.history_limit if agent else HISTORY_LIMIT_DEFAULT
),
)
except ProviderError as error:
# Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
# оператору, уведомляем и отвечаем клиенту понятным fallback.
@@ -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"], "Схема")
@@ -69,9 +69,23 @@ class VoiceAiReplyTests(TestCase):
self._ingest({"return_value": "Первый вопрос"}, {"return_value": mock.Mock(text="Ответ")})
conversation = self.channel.conversations.get()
conversation.messages.create(author_type=MessageAuthor.CONTACT, text="Второй")
roles = [(h["role"], h["content"]) for h in _history(conversation)]
roles = [(h["role"], h["content"]) for h in _history(conversation, 20)]
self.assertEqual(roles, [("user", "Первый вопрос"), ("assistant", "Ответ")])
def test_ai_history_window_follows_agent_setting(self) -> None:
self.channel.ai_agent.history_limit = 3
self.channel.ai_agent.save(update_fields=["history_limit"])
self._ingest({"return_value": "Первый вопрос"}, {"return_value": mock.Mock(text="Ответ")})
conversation = self.channel.conversations.get()
for number in range(1, 6):
conversation.messages.create(author_type=MessageAuthor.CONTACT, text=f"Сообщение {number}")
self.inbound = InboundMessage(external_id="t-2", user_id="u-1", chat_id="c-1", text="Последнее", display_name="Ольга")
_, run = self._ingest({"return_value": ""}, {"return_value": mock.Mock(text="Ответ")})
history = [item["content"] for item in run.call_args.kwargs["history"]]
# Три сообщения перед новым; само новое уходит модели отдельно.
self.assertEqual(history, ["Сообщение 3", "Сообщение 4", "Сообщение 5"])
self.assertEqual(run.call_args.kwargs["message"], "Последнее")
class CommunicationSettingsTests(TestCase):
def setUp(self) -> None:
@@ -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)
@@ -146,6 +146,7 @@ MESSAGES: dict[str, object] = {
"settings.http_or_https": "http or https",
"settings.integration_not_found": "Integration not found",
"settings.invalid_address": "Invalid address",
"settings.invalid_port": "Port must be a number from 1 to 65535",
"settings.items_list_expected": "A list of items is expected",
"settings.language_unsupported": "This language is not supported",
"settings.migration_running": "A migration is already running",
@@ -357,6 +358,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",
@@ -474,6 +476,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",
@@ -516,6 +522,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",
@@ -570,4 +581,5 @@ MESSAGES: dict[str, object] = {
"ai.demo_handover": "I'll check this with my colleagues and hand the dialog to a member of staff — they will answer during working hours.",
"ai.demo_handover_suffix": " I'm handing the dialog to a member of staff — they will help from here.",
"ai.unknown_answer_language": "Unknown agent answer language",
"ai.history_limit_out_of_range": "Context size must be a whole number from 1 to {max}",
}
@@ -150,6 +150,7 @@ MESSAGES: dict[str, object] = {
"settings.http_or_https": "http или https",
"settings.integration_not_found": "Интеграция не найдена",
"settings.invalid_address": "Некорректный адрес",
"settings.invalid_port": "Порт — число от 1 до 65535",
"settings.items_list_expected": "Ожидается список items",
"settings.language_unsupported": "Этот язык не поддерживается",
"settings.migration_running": "Перенос уже идёт",
@@ -361,6 +362,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 — да или нет",
@@ -478,6 +480,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» — строка",
@@ -520,6 +526,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": "Подключение не привязано к каналу — виджет не активен",
@@ -574,4 +585,5 @@ MESSAGES: dict[str, object] = {
"ai.demo_handover": "Уточню этот вопрос у коллег и передам диалог сотруднику — он ответит в рабочее время.",
"ai.demo_handover_suffix": " Передаю диалог сотруднику — он поможет дальше.",
"ai.unknown_answer_language": "Неизвестный язык ответов агента",
"ai.history_limit_out_of_range": "Размер контекста — целое число от 1 до {max}",
}
@@ -22,8 +22,14 @@ from chatballs.support_portals.addressing import normalize_domain
class InstanceSettings(models.Model):
SINGLETON_PK = 1
# Хост без схемы и порта: «crm.example.com» или «203.0.113.10».
# Хост без схемы и порта: «crm.example.com» или «203.0.113.10». Именно
# хост, а не адрес: по нему проверяются входящие Host, строятся домены
# порталов и адреса TURN — порт им чужой.
public_host = models.CharField(max_length=253, blank=True, default="")
# Порт, если установку открывают не на стандартном для схемы: шлюз
# опубликован как 8081, а 80-й занят панелью или другим сервисом. Пусто —
# порт схемы (80/443), и в ссылках его нет.
public_port = models.PositiveIntegerField(null=True, blank=True)
# Предыдущий адрес: остаётся принятым, чтобы смена адреса не выбрасывала
# того, кто её делает. Владелец меняет адрес заранее — до того, как домен
# начал резолвиться и получил сертификат, — и сидит при этом на старом.
@@ -179,18 +185,49 @@ def remember_default_language(language: str) -> None:
row.save(update_fields=["default_language", "updated_at"])
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан."""
_DEFAULT_PORTS = {"http": 80, "https": 443}
host = normalize_domain(raw_host.partition(":")[0])
if not host:
def split_address(raw: str, scheme: str) -> tuple[str, int | None] | None:
"""Хост и порт из «host[:port]»; None — адрес не разобрать.
Порт схемы (80 у http, 443 у https) отбрасывается: в ссылке он лишний, а
хранить его значило бы различать два одинаковых адреса.
"""
host, separator, port_text = raw.strip().rpartition(":")
if not separator:
return normalize_domain(port_text), None
host = normalize_domain(host)
if not port_text.isdigit() or not 1 <= int(port_text) <= 65535:
return None
port = int(port_text)
return host, None if port == _DEFAULT_PORTS.get(scheme) else port
def format_address(host: str, port: int | None) -> str:
"""Адрес для ссылок и для поля в «Настройках»: хост и порт, если он не схемы."""
return f"{host}:{port}" if host and port else host
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан.
Порт запоминается вместе с хостом: установку, открытую на ``ip:8081``,
дальше открывают там же, и ссылки без порта вели бы в пустоту.
"""
scheme = "https" if scheme == "https" else "http"
parsed = split_address(raw_host, scheme)
if parsed is None or not parsed[0]:
return
row = InstanceSettings.load()
if row.public_host:
return
row.public_host = host
row.public_scheme = "https" if scheme == "https" else "http"
row.save(update_fields=["public_host", "public_scheme", "updated_at"])
row.public_host, row.public_port = parsed
row.public_scheme = scheme
row.save(update_fields=["public_host", "public_port", "public_scheme", "updated_at"])
def public_base_url() -> str:
@@ -215,7 +252,7 @@ def public_base_url() -> str:
row = None
if row is not None and row.public_host:
scheme = row.public_scheme or "http"
return f"{scheme}://{row.public_host}"
return f"{scheme}://{format_address(row.public_host, row.public_port)}"
return str(settings.CHATBALLS_PUBLIC_BASE_URL).rstrip("/")
@@ -20,17 +20,20 @@ from chatballs.identity.instance_settings import (
default_turn_urls,
email_connection,
email_from_address,
format_address,
invalidate_cache,
public_base_url,
split_address,
)
from chatballs.support_portals.addressing import normalize_domain, validate_domain
from chatballs.support_portals.addressing import validate_domain
SCHEMES = ("http", "https")
def instance_payload(row: InstanceSettings) -> dict:
return {
"publicHost": row.public_host,
# Адрес целиком, с портом, если он не схемы: так его и вписывают.
"publicHost": format_address(row.public_host, row.public_port),
"publicScheme": row.public_scheme or "http",
"publicUrl": public_base_url(),
# Язык экранов, где организации ещё нет: логин, сброс пароля, мастер.
@@ -77,12 +80,22 @@ class InstanceAddressView(APIView):
body = request.data if isinstance(request.data, dict) else {}
errors: dict[str, str] = {}
# Владелец может вставить и целый URL из адресной строки — берём хост.
raw_host = str(body.get("publicHost", row.public_host)).strip()
scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower()
if scheme not in SCHEMES:
errors["publicScheme"] = t("settings.http_or_https")
# Владелец может вставить и целый URL из адресной строки — берём хост
# и порт, путь отбрасываем.
raw_host = str(
body.get("publicHost", format_address(row.public_host, row.public_port))
).strip()
if "//" in raw_host:
raw_host = raw_host.split("//", 1)[1]
host = normalize_domain(raw_host.split("/", 1)[0].split(":", 1)[0])
if not host:
parsed = split_address(raw_host.split("/", 1)[0], scheme)
host, port = parsed if parsed is not None else ("", None)
if parsed is None:
errors["publicHost"] = t("settings.invalid_port")
elif not host:
errors["publicHost"] = t("settings.address_required")
else:
try:
@@ -92,10 +105,6 @@ class InstanceAddressView(APIView):
except ValidationError:
errors["publicHost"] = t("settings.invalid_address")
scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower()
if scheme not in SCHEMES:
errors["publicScheme"] = t("settings.http_or_https")
raw_language = str(body.get("defaultLanguage", row.default_language)).strip()
language = normalize_language(raw_language)
if raw_language and not language:
@@ -108,6 +117,7 @@ class InstanceAddressView(APIView):
fields = [
"public_host",
"public_port",
"public_scheme",
"previous_public_host",
"default_language",
@@ -118,6 +128,7 @@ class InstanceAddressView(APIView):
# сидя на старом, и не должен выпасть из установки в тот же миг.
row.previous_public_host = row.public_host
row.public_host = host
row.public_port = port
row.public_scheme = scheme
row.default_language = language or DEFAULT_LANGUAGE
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0039_remove_organization_currency"),
]
operations = [
migrations.AddField(
model_name="instancesettings",
name="public_port",
field=models.PositiveIntegerField(blank=True, null=True),
),
]
@@ -88,6 +88,33 @@ class InstanceAddressChangeTests(TestCase):
self.assertEqual(set(accepted_hosts()), {"crm.example.test", "203.0.113.10"})
def test_port_is_kept_apart_from_the_host(self) -> None:
"""Шлюз на 8081: порт идёт в ссылки, но не в хост — по хосту
проверяются входящие Host и строятся домены порталов."""
response = self._patch("http://203.0.113.10:8081/login", scheme="http")
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()["instance"]
self.assertEqual(payload["publicHost"], "203.0.113.10:8081")
self.assertEqual(payload["publicUrl"], "http://203.0.113.10:8081")
row = InstanceSettings.load()
self.assertEqual((row.public_host, row.public_port), ("203.0.113.10", 8081))
# Смена одного порта — не смена адреса: прежний хост не сдвигается.
self.assertEqual(row.previous_public_host, "")
def test_scheme_port_is_dropped(self) -> None:
self._patch("crm.example.test:8081")
response = self._patch("crm.example.test:443")
self.assertEqual(response.json()["instance"]["publicUrl"], "https://crm.example.test")
self.assertIsNone(InstanceSettings.load().public_port)
def test_bad_port_is_rejected(self) -> None:
for address in ("203.0.113.10:0", "203.0.113.10:70000", "203.0.113.10:web", "203.0.113.10:"):
with self.subTest(address=address):
self.assertEqual(self._patch(address, scheme="http").status_code, 400)
self.assertEqual(InstanceSettings.load().public_host, "203.0.113.10")
class PortalDomainCollisionTests(TestCase):
def setUp(self) -> None:
@@ -54,6 +54,25 @@ class SetupWizardTests(TestCase):
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(InstanceSettings.load().public_host, "203.0.113.10")
def test_setup_on_a_custom_port_keeps_the_port_for_links(self) -> None:
"""Шлюз опубликован на 8081: ссылки без порта вели бы в пустоту."""
invalidate_cache()
response = self.complete(HTTP_HOST="203.0.113.10:8081")
self.assertEqual(response.status_code, 201, response.content)
row = InstanceSettings.load()
self.assertEqual((row.public_host, row.public_port), ("203.0.113.10", 8081))
invalidate_cache()
self.assertEqual(public_base_url(), "http://203.0.113.10:8081")
def test_setup_on_the_scheme_port_stores_no_port(self) -> None:
invalidate_cache()
self.complete(HTTP_HOST="crm.example.test:80")
self.assertIsNone(InstanceSettings.load().public_port)
def test_remembered_address_keeps_working_after_setup(self) -> None:
self.complete(HTTP_HOST="crm.example.test")
invalidate_cache()
+127 -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.
@@ -21,10 +24,12 @@ 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__)
@@ -34,6 +39,12 @@ DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# рабочий и с валидным сертификатом — 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]
@@ -50,6 +61,24 @@ 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:
"""Короткая причина из ответа провайдера.
@@ -88,7 +117,7 @@ def _http_failure(error: urllib.error.HTTPError) -> str:
logger.warning(
"Integration check rejected: HTTP %s %s — %s",
error.code,
getattr(error, "url", ""),
mask_url_secrets(getattr(error, "url", "")),
body[:500],
)
reason = _error_reason(body)
@@ -105,7 +134,7 @@ def _safe(fn) -> CheckResult:
except urllib.error.HTTPError as error:
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:
@@ -242,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):
"""Адрес не разрешён политикой исходящих запросов."""
@@ -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)
@@ -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):
@@ -30,6 +30,7 @@ import {
patchAgent,
setAgentAiActive,
unbindAgentConnection,
HISTORY_LIMIT_MAX,
type AgentCard,
type AgentConnection,
type AgentPatch,
@@ -541,6 +542,10 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
const [transcriptionDraft, setTranscriptionDraft] = useState(card.transcriptionModel);
useEffect(() => { setModelDraft(card.model); }, [card.model]);
useEffect(() => { setTranscriptionDraft(card.transcriptionModel); }, [card.transcriptionModel]);
const [historyDraft, setHistoryDraft] = useState(String(card.historyLimit));
useEffect(() => { setHistoryDraft(String(card.historyLimit)); }, [card.historyLimit]);
const historyValue = Number(historyDraft);
const historyValid = /^\d+$/.test(historyDraft.trim()) && historyValue >= 1 && historyValue <= HISTORY_LIMIT_MAX;
return (
<section className="agent-card is-side">
@@ -569,6 +574,18 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
onChange={setModelDraft}
onBlur={() => { if (modelDraft !== card.model) void apply({ model: modelDraft }); }}
/>
{/* Сколько последних сообщений диалога модель получает вместе с новым.
Больше — агент помнит длинный разговор, но ответ дороже, а у
локальной модели с малым окном хвост обрежется на её стороне. */}
<FormField
disabled={busy || !canManage}
error={historyValid ? undefined : t("ai.history_limit_invalid", { max: HISTORY_LIMIT_MAX })}
label={t("ai.history_limit")}
type="number"
value={historyDraft}
onChange={setHistoryDraft}
onBlur={() => { if (historyValid && historyValue !== card.historyLimit) void apply({ historyLimit: historyValue }); }}
/>
{/* Речь в текст умеет не всякая модель, которой агент отвечает: у части
провайдеров аудио-эндпоинта нет вовсе. Поэтому выбор отдельный. */}
<SelectField
@@ -46,6 +46,8 @@ export type AgentCard = {
modelParams: Record<string, unknown>;
// Режим языка ответов: MIRROR, ORGANIZATION или код языка.
answerLanguage: string;
/** Сколько последних сообщений диалога агент видит вместе с новым. */
historyLimit: number;
persona: string;
tone: string;
instructions: string;
@@ -59,6 +61,9 @@ export type AgentCard = {
updatedAt: string;
};
/** Границы окна истории — те же, что проверяет сервер (ai.models). */
export const HISTORY_LIMIT_MAX = 200;
export type AgentPatch = Partial<{
name: string;
groupId: number | null;
@@ -68,6 +73,7 @@ export type AgentPatch = Partial<{
model: string;
transcriptionModel: string;
answerLanguage: string;
historyLimit: number;
persona: string;
tone: string;
instructions: string;
@@ -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);
@@ -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;
}
+5 -1
View File
@@ -493,6 +493,8 @@ export const en: Record<MessageKey, Message> = {
"ai.update": "update",
"ai.updated": "· Updated:",
"ai.transcription_model": "Transcription model",
"ai.history_limit": "Conversation context, messages",
"ai.history_limit_invalid": "A whole number from 1 to {max}",
"ai.transcription_provider": "Voice transcription",
"ai.updated_2": "UPDATED",
"ai.updated_by_at": "updated by {name} · {date}",
@@ -1481,6 +1483,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",
@@ -1511,7 +1515,7 @@ export const en: Record<MessageKey, Message> = {
"settings.demo_provider_no_key": "Demo provider (no key)",
"settings.demo_records_count": "{count} records",
"settings.disabled": "Disabled",
"settings.domain_or_ip": "Domain or IP",
"settings.domain_or_ip": "Domain or IP, with a port if needed",
"settings.e_g_openrouter_primary": "e.g. OpenRouter · primary",
"settings.e_g_support_mailbox": "e.g. Support mailbox",
"settings.edit_integration": "Edit the integration",
+5 -1
View File
@@ -494,6 +494,8 @@ export const ru = {
"ai.update": "обновить",
"ai.updated": "· Обновлено:",
"ai.transcription_model": "Модель расшифровки",
"ai.history_limit": "Контекст диалога, сообщений",
"ai.history_limit_invalid": "Целое число от 1 до {max}",
"ai.transcription_provider": "Расшифровка голосовых",
"ai.updated_2": "ОБНОВЛЕНО",
"ai.updated_by_at": "обновил {name} · {date}",
@@ -1482,6 +1484,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": "Проверить",
@@ -1512,7 +1516,7 @@ export const ru = {
"settings.demo_provider_no_key": "Демо-провайдер (без ключа)",
"settings.demo_records_count": "{count} записей",
"settings.disabled": "Отключено",
"settings.domain_or_ip": "Домен или IP",
"settings.domain_or_ip": "Домен или IP, при необходимости с портом",
"settings.e_g_openrouter_primary": "например, OpenRouter · основной",
"settings.e_g_support_mailbox": "например, Почта поддержки",
"settings.edit_integration": "Изменить интеграцию",
+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} />;
}
@@ -13,7 +13,7 @@ type FormFieldProps = {
* запрос на каждую букву. */
onBlur?: () => void;
placeholder?: string;
type?: "password" | "text";
type?: "number" | "password" | "text";
value: string;
wide?: boolean;
};
+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 }>;
+3 -3
View File
@@ -25,7 +25,7 @@ server {
location = /chat-widget.js {
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
@@ -42,7 +42,7 @@ server {
location /api/ {
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
@@ -52,7 +52,7 @@ server {
location /ws/ {
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
+7 -7
View File
@@ -29,7 +29,7 @@ server {
location /api/ {
proxy_pass http://$backend_app;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
@@ -37,7 +37,7 @@ server {
location /ws/ {
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
@@ -45,14 +45,14 @@ server {
location = /chat-widget.js {
proxy_pass http://$backend_app;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
}
location /chat/ {
# Vite React Refresh injects an inline preamble in local development.
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always;
proxy_pass http://$web_chat;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
@@ -64,7 +64,7 @@ server {
add_header Permissions-Policy "camera=(self), microphone=(self)" always;
rewrite ^ /chat/call.html break;
proxy_pass http://$web_chat;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
}
location / {
@@ -74,7 +74,7 @@ server {
add_header X-Frame-Options SAMEORIGIN always;
proxy_pass http://$frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -90,7 +90,7 @@ server {
location /api/ {
proxy_pass http://$backend_platform;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
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>
@@ -1,433 +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, §8.4; ADR-0027, ADR-0029, ADR-0043</span>
</div>
<p style="margin:0 0 28px;font-size:13px;color:var(--c-n3);max-width:960px;line-height:1.55;">Таблица: человек · роль · должность · группы · доступ · статус · последний вход. Роль решает права: владелец и админ идентичны, сотрудник работает только в чате; группа задаёт лишь видимость диалогов. Роль владельца меняется единственным способом — передачей владения. Сайдбар приведён к коду: знак Chatballs (два круга с перемычкой), пункты Чат · Контакты · Агенты · Сотрудники · Порталы · Настройки, ручка изменения ширины на правом крае (180–320px), блок «Запуск», меню пользователя.</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:{{ f.w }};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;">
<!-- RAIL 60px (≤1024) -->
<sc-if value="{{ f.rail }}" hint-placeholder-val="{{ false }}">
<aside style="width:60px;flex:none;background:var(--c-surface2);border-right:1px solid var(--c-n8);display:flex;flex-direction:column;align-items:center;gap:8px;padding:14px 0 12px;">
<span style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:var(--c-n1);"><svg viewBox="0 0 100 100" width="26" height="26" fill="currentColor"><defs><mask id="lg-rail"><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-rail)"><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="width:36px;height:36px;border:none;border-radius:9px;background:transparent;color:var(--c-n3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n1);"><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="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:36px;height:36px;border:none;border-radius:9px;background:transparent;color:var(--c-n3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-n9);color:var(--c-n1);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(-90deg);"><polyline points="6 9 12 15 18 9"/></svg></button>
<div style="flex:1;"></div>
<span title="Елена Кузнецова" style="width:34px;height:34px;border-radius:50%;background:url(assets/av-4.png) center/cover;"></span>
</aside>
</sc-if>
<!-- FULL SIDEBAR -->
<sc-if value="{{ f.sidebar }}" hint-placeholder-val="{{ true }}">
<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;">
<!-- pane-resizer: правый край, 6px, подсветка акцентом -->
<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:{{ f.resizerOpacity }};"></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="{{ f.maskId }}"><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(#{{ f.maskUrl }})"><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;text-align:left;" 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="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><polyline points="6 9 12 15 18 9"/></svg></button>
</div>
<nav style="flex:1;min-height:0;overflow-y:auto;padding:4px 10px 8px;display:flex;flex-direction:column;gap:2px;">
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="6">
<button style="position:relative;width:100%;display:flex;align-items:center;flex:none;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.8" stroke-linecap="round" stroke-linejoin="round" style="flex:none;">
<sc-for list="{{ n.paths }}" as="p" hint-placeholder-count="1"><path d="{{ p }}"/></sc-for>
<sc-for list="{{ n.circles }}" as="c" hint-placeholder-count="0"><circle cx="{{ c.cx }}" cy="{{ c.cy }}" r="{{ c.r }}"/></sc-for>
</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 value="{{ f.launch }}" hint-placeholder-val="{{ false }}">
<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;font-size:15px;line-height:1;font-family:inherit;" style-hover="background:var(--c-n9);color:var(--c-n2);">×</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="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="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"/></svg>Пригласить</button>
</div>
</sc-if>
<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.3;"><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>
<span style="display:flex;align-items:center;gap:5px;flex:none;"><i title="Непрочитанные уведомления" style="width:7px;height:7px;border-radius:50%;background:var(--c-accent);"></i><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--c-n4)" stroke-width="1.8" 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></span>
</div>
</aside>
</sc-if>
<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);">Роли и группы · показано <b style="color:var(--c-n2);">{{ f.shown }}</b> из {{ f.total }}</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="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="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"/></svg>Добавить сотрудника</button>
</div>
<div style="display:flex;align-items:center;gap:14px;">
<div 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-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></div>
<div style="display:flex;align-items:center;gap:8px;"><span style="font-size:12.5px;color:var(--c-n4);">Роль</span><div style="display:inline-flex;padding:3px;border-radius:9px;background:var(--c-n9);"><sc-for list="{{ f.roleTabs }}" as="t" hint-placeholder-count="4"><button style="height:28px;padding:0 11px;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 }};">{{ t.label }}</button></sc-for></div></div>
<div style="display:flex;align-items:center;gap:8px;"><span style="font-size:12.5px;color:var(--c-n4);">Группа</span><span style="display:inline-flex;align-items:center;justify-content:space-between;gap:8px;height:34px;padding:0 10px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);font-size:13px;color:var(--c-n2);min-width:120px;">{{ f.groupFilter }}<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"><polyline points="6 9 12 15 18 9"/></svg></span></div>
<div style="flex:1;"></div>
<button style="height:34px;padding:0 10px;border:none;border-radius:8px;background:transparent;color:{{ f.resetColor }};font-size:13px;font-weight:500;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;overflow:hidden;">
<div style="display:grid;grid-template-columns:{{ cols }};gap:14px;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="8"><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="5">
<div style="display:grid;grid-template-columns:{{ cols }};gap:14px;align-items:center;height:62px;padding:0 20px;border-bottom:1px solid var(--c-n9);cursor:pointer;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: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);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ r.email }}</small></span></div>
<b style="display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:6px;background:{{ r.roleBg }};color:{{ r.roleColor }};font-size:11.5px;font-weight:600;justify-self:start;white-space:nowrap;">{{ r.role }}</b>
<span style="font-size:13px;color:var(--c-n2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ r.position }}</span>
<div style="display:flex;gap:5px;flex-wrap:wrap;min-width:0;"><sc-for list="{{ r.groups }}" as="g" hint-placeholder-count="1"><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:500;white-space:nowrap;"><i style="width:7px;height:7px;border-radius:50%;background:{{ g.dot }};"></i>{{ g.name }}</span></sc-for><sc-if value="{{ r.noGroups }}" hint-placeholder-val="{{ false }}"><span style="font-size:12.5px;color:var(--c-n5);">Без группы</span></sc-if></div>
<span style="font-size:12.5px;color:{{ r.accessColor }};white-space:nowrap;">{{ r.access }}</span>
<b style="display:inline-flex;align-items:center;gap:6px;height:22px;padding:0 8px;border-radius:6px;background:{{ r.stBg }};color:{{ r.stColor }};font-size:11.5px;font-weight:600;justify-self:start;white-space:nowrap;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>{{ r.status }}</b>
<span style="font-size:12.5px;color:var(--c-n4);white-space:nowrap;">{{ r.login }}</span>
<button title="Действия" style="width:30px;height:30px;border:none;border-radius:7px;background:{{ r.menuBg }};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="1.8" 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:11px 20px;background:var(--c-surface2);">
<small style="font-size:12px;color:var(--c-n4);">Показано {{ f.shown }} из {{ f.total }}</small>
<small style="font-size:12px;color:var(--c-n4);">Группа задаёт только видимость диалогов и не выдаёт прав</small>
</div>
</div>
</div>
<sc-if value="{{ f.menu }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;right:36px;top:300px;width:246px;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,0.18);padding:6px;z-index:6;">
<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-n9);margin:4px 6px;"></div></sc-if>
<sc-if value="{{ m.label }}" hint-placeholder-val="{{ true }}">
<button style="width:100%;display:flex;align-items:center;gap:9px;height:32px;padding:0 10px;border:none;border-radius:7px;background:transparent;color:{{ m.color }};font-size:13px;font-weight:500;cursor:{{ m.cursor }};text-align:left;font-family:inherit;opacity:{{ m.opacity }};" style-hover="background:var(--c-n9);"><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="{{ m.icon }}"/></svg>{{ m.label }}</button>
</sc-if>
</sc-for>
</div>
</sc-if>
</sc-if>
<!-- DETAIL -->
<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="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(180deg);"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>Все сотрудники</button>
<div style="display:flex;align-items:center;gap:16px;">
<span style="width:60px;height:60px;flex:none;border-radius:50%;background:{{ f.p.avBg }};color:#fff;display:flex;align-items:center;justify-content:center;font-size:19px;font-weight:600;">{{ f.p.initials }}</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.p.name }}</h2><b style="display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:6px;background:{{ f.p.roleBg }};color:{{ f.p.roleColor }};font-size:11.5px;font-weight:600;">{{ f.p.role }}</b><b style="display:inline-flex;align-items:center;gap:6px;height:22px;padding:0 8px;border-radius:6px;background:{{ f.p.stBg }};color:{{ f.p.stColor }};font-size:11.5px;font-weight:600;"><i style="width:6px;height:6px;border-radius:50%;background:currentColor;"></i>{{ f.p.status }}</b></div>
<p style="margin:5px 0 0;font-size:13px;color:var(--c-n4);"><span style="color:var(--c-n2);">{{ f.p.email }}</span> · {{ f.p.position }} · {{ f.p.groupLine }}</p>
</div>
<div style="display:flex;align-items:center;gap:8px;flex:none;">
<sc-if value="{{ f.p.readonly }}" hint-placeholder-val="{{ false }}"><span style="display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:var(--c-n4);"><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="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>Только просмотр</span></sc-if>
<sc-if value="{{ f.p.editable }}" hint-placeholder-val="{{ true }}"><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: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="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Сохранить</button></sc-if>
</div>
</div>
<sc-if value="{{ f.p.banner }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;gap:10px;padding:11px 14px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--c-warn-text)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><span style="font-size:12.5px;color:var(--c-n2);line-height:1.5;">Владельца нельзя удалить, заблокировать или сменить ему роль — единственный путь изменения роли владельца это передача владения.</span></div>
</sc-if>
<div style="display:grid;grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);gap:16px;align-items:start;">
<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:20px 24px;">
<h3 style="margin:0 0 14px;font-size:14px;font-weight:700;color:var(--c-n1);">Основные данные</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px 18px;">
<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:{{ f.p.fieldBg }};font-size:13.5px;color:{{ f.p.fieldColor }};">{{ f.p.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;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:{{ f.p.fieldBg }};font-size:13.5px;color:{{ f.p.fieldColor }};">{{ f.p.phone }}</span></label>
<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);">Email · используется для входа</span><span style="display:flex;align-items:center;height:36px;padding:0 12px;border:1px solid var(--c-n7);border-radius:8px;background:{{ f.p.fieldBg }};font-size:13px;color:{{ f.p.fieldColor }};font-family:'SF Mono',ui-monospace,Menlo,monospace;">{{ f.p.email }}</span></label>
</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:12px;color:var(--c-n4);line-height:1.5;">Группа задаёт только видимость диалогов и не выдаёт прав.</p>
<label style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;"><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:{{ f.p.fieldBg }};font-size:13.5px;color:{{ f.p.fieldColor }};">{{ f.p.position }}</span></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<sc-for list="{{ f.p.groupPicks }}" as="g" hint-placeholder-count="2">
<button style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid {{ g.border }};border-radius:9px;background:{{ g.bg }};cursor:pointer;text-align:left;font-family:inherit;"><i style="width:16px;height:16px;flex:none;border-radius:5px;border:1px solid {{ g.markBorder }};background:{{ g.markBg }};display:inline-flex;align-items:center;justify-content:center;color:#fff;"><sc-if value="{{ g.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></i><span style="min-width:0;"><strong style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--c-n1);"><i style="width:7px;height:7px;border-radius:50%;background:{{ g.dot }};"></i>{{ g.name }}</strong><small style="display:block;margin-top:2px;font-size:11.5px;color:var(--c-n4);">{{ g.count }}</small></span></button>
</sc-for>
</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:12px;color:var(--c-n4);line-height:1.5;">{{ f.p.roleNote }}</p>
<div style="display:flex;flex-direction:column;gap:8px;">
<sc-for list="{{ f.p.roleOptions }}" as="o" hint-placeholder-count="3">
<button style="display:flex;align-items:flex-start;gap:11px;padding:11px 13px;border:1px solid {{ o.border }};border-radius:10px;background:{{ o.bg }};cursor:{{ o.cursor }};text-align:left;font-family:inherit;opacity:{{ o.opacity }};"><i style="width:16px;height:16px;flex:none;margin-top:1px;border-radius:50%;border:1.5px solid {{ o.dotBorder }};display:inline-flex;align-items:center;justify-content:center;"><sc-if value="{{ o.on }}" hint-placeholder-val="{{ false }}"><span style="width:8px;height:8px;border-radius:50%;background:var(--c-accent);"></span></sc-if></i><span style="min-width:0;"><strong style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--c-n1);">{{ o.label }}<sc-if value="{{ o.locked }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="var(--c-n4)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg></sc-if></strong><small style="display:block;margin-top:3px;font-size:12px;color:var(--c-n4);line-height:1.45;">{{ o.desc }}</small></span></button>
</sc-for>
</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 12px;font-size:14px;font-weight:700;color:var(--c-n1);">Статус и безопасность</h3>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-top:1px solid var(--c-n9);"><div><strong style="display:block;font-size:13px;color:var(--c-n1);">Пароль</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);">Последняя смена: {{ f.p.pwd }} · новый можно отправить письмом или показать и скопировать</small></div><button style="height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:{{ f.p.secColor }};font-size:12.5px;font-weight:600;cursor:{{ f.p.secCursor }};font-family:inherit;white-space:nowrap;">Сбросить пароль</button></div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-top:1px solid var(--c-n9);"><div><strong style="display:block;font-size:13px;color:var(--c-n1);">Двухфакторная аутентификация (TOTP)</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);">{{ f.p.totpText }}</small></div><span style="position:relative;flex:none;width:44px;height:24px;border-radius:12px;background:{{ f.p.totpBg }};display:inline-block;cursor:pointer;"><i style="position:absolute;top:2px;left:{{ f.p.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;justify-content:space-between;gap:12px;padding:12px 0 0;border-top:1px solid var(--c-n9);"><div><strong style="display:block;font-size:13px;color:var(--c-n1);">Активные сессии</strong><small style="display:block;margin-top:2px;font-size:12px;color:var(--c-n4);">{{ f.p.sessions }}</small></div><button style="height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--c-n7);background:var(--c-surface);color:{{ f.p.secColor }};font-size:12.5px;font-weight:600;cursor:{{ f.p.secCursor }};font-family:inherit;">Завершить все</button></div>
</div>
<sc-if value="{{ f.p.danger }}" hint-placeholder-val="{{ true }}">
<div style="background:var(--c-err-bg);border:1px solid var(--c-err-border);border-radius:12px;padding:18px 22px;">
<h3 style="margin:0 0 12px;font-size:14px;font-weight:700;color:var(--c-err-text);">Опасная зона</h3>
<sc-for list="{{ f.p.dangerActions }}" as="d" hint-placeholder-count="1">
<div style="padding:12px 0;border-top:1px solid var(--c-err-border);">
<strong style="display:block;margin-bottom:5px;font-size:13px;color:var(--c-n1);">{{ d.title }}</strong>
<p style="margin:0 0 12px;font-size:12.5px;color:var(--c-n2);line-height:1.5;">{{ d.text }}</p>
<button style="height:32px;padding:0 14px;border-radius:8px;border:1px solid #ff4d4f;background:transparent;color:#ff4d4f;font-size:12.5px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;"><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="{{ d.icon }}"/></svg>{{ d.action }}</button>
<sc-if value="{{ d.note }}" hint-placeholder-val="{{ false }}"><small style="display:block;margin-top:8px;font-size:12px;color:var(--c-n4);line-height:1.45;">{{ d.note }}</small></sc-if>
</div>
</sc-for>
</div>
</sc-if>
</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;">
<h3 style="margin:0 0 12px;font-size:14px;font-weight:700;color:var(--c-n1);">Учётная запись</h3>
<sc-for list="{{ f.p.account }}" as="a" hint-placeholder-count="4">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 0;border-top:1px solid var(--c-n9);"><small style="font-size:12.5px;color:var(--c-n4);">{{ a.label }}</small><span style="font-size:13px;color:var(--c-n1);font-weight:500;">{{ a.value }}</span></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 12px;font-size:14px;font-weight:700;color:var(--c-n1);">Аудит</h3>
<sc-for list="{{ f.p.audit }}" as="a" hint-placeholder-count="4">
<div style="display:flex;gap:10px;padding:8px 0;"><i style="width:8px;height:8px;flex:none;margin-top:5px;border-radius:50%;background:{{ a.dot }};"></i><div style="min-width:0;"><strong style="display:block;font-size:12.5px;font-weight:600;color:var(--c-n1);line-height:1.4;">{{ a.text }}</strong><small style="display:block;margin-top:2px;font-size:11px;color:var(--c-n4);font-family:'SF Mono',ui-monospace,Menlo,monospace;">{{ a.code }} · {{ a.time }}</small></div></div>
</sc-for>
</div>
</div>
</div>
</div>
</sc-if>
<!-- CREATE DRAWER -->
<sc-if value="{{ f.drawer }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.4);"></div>
<aside style="position:absolute;top:0;right:0;bottom:0;width:520px;background:var(--c-surface);border-left:1px solid var(--c-n8);box-shadow:-12px 0 40px rgba(0,0,0,0.18);display:flex;flex-direction:column;z-index:7;">
<header style="flex:none;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:18px 22px;border-bottom:1px solid var(--c-n8);"><div><strong style="display:block;font-size:16px;font-weight:700;color:var(--c-n1);">Новый сотрудник</strong><span style="display:block;margin-top:3px;font-size:12.5px;color:var(--c-n4);">Учётные данные · роль · группы</span></div><button style="width:30px;height:30px;border:none;border-radius:8px;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="19" height="19" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg></button></header>
<div style="flex:1;min-height:0;overflow-y:auto;padding:20px 22px;display:flex;flex-direction:column;gap:22px;">
<section>
<h3 style="display:flex;align-items:center;gap:9px;margin:0 0 12px;font-size:13.5px;font-weight:700;color:var(--c-n1);"><i style="width:20px;height:20px;border-radius:50%;background:var(--c-accent-bg);color:var(--c-accent-text);display:inline-flex;align-items:center;justify-content:center;font-size:11.5px;font-style:normal;font-weight:700;">1</i>Учётные данные</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px;">
<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);">Ольга Титова</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:13.5px;color:var(--c-n5);">+7 …</span></label>
<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);">Рабочий 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;">o.titova@atelie-nord.ru</span></label>
</div>
</section>
<section>
<h3 style="display:flex;align-items:center;gap:9px;margin:0 0 12px;font-size:13.5px;font-weight:700;color:var(--c-n1);"><i style="width:20px;height:20px;border-radius:50%;background:var(--c-accent-bg);color:var(--c-accent-text);display:inline-flex;align-items:center;justify-content:center;font-size:11.5px;font-style:normal;font-weight:700;">2</i>Роль</h3>
<div style="display:flex;flex-direction:column;gap:8px;">
<sc-for list="{{ drawerRoles }}" as="o" hint-placeholder-count="2">
<button style="display:flex;align-items:flex-start;gap:11px;padding:11px 13px;border:1px solid {{ o.border }};border-radius:10px;background:{{ o.bg }};cursor:pointer;text-align:left;font-family:inherit;"><i style="width:16px;height:16px;flex:none;margin-top:1px;border-radius:50%;border:1.5px solid {{ o.dotBorder }};display:inline-flex;align-items:center;justify-content:center;"><sc-if value="{{ o.on }}" hint-placeholder-val="{{ false }}"><span style="width:8px;height:8px;border-radius:50%;background:var(--c-accent);"></span></sc-if></i><span style="min-width:0;"><strong style="display:block;font-size:13px;font-weight:600;color:var(--c-n1);">{{ o.label }}</strong><small style="display:block;margin-top:3px;font-size:12px;color:var(--c-n4);line-height:1.45;">{{ o.desc }}</small></span></button>
</sc-for>
</div>
</section>
<section>
<h3 style="display:flex;align-items:center;gap:9px;margin:0 0 12px;font-size:13.5px;font-weight:700;color:var(--c-n1);"><i style="width:20px;height:20px;border-radius:50%;background:var(--c-accent-bg);color:var(--c-accent-text);display:inline-flex;align-items:center;justify-content:center;font-size:11.5px;font-style:normal;font-weight:700;">3</i>Должность и группы</h3>
<label style="display:flex;flex-direction:column;gap:6px;margin-bottom:12px;"><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>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<sc-for list="{{ drawerGroups }}" as="g" hint-placeholder-count="2">
<button style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid {{ g.border }};border-radius:9px;background:{{ g.bg }};cursor:pointer;text-align:left;font-family:inherit;"><i style="width:16px;height:16px;flex:none;border-radius:5px;border:1px solid {{ g.markBorder }};background:{{ g.markBg }};display:inline-flex;align-items:center;justify-content:center;color:#fff;"><sc-if value="{{ g.on }}" hint-placeholder-val="{{ false }}"><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></i><span style="min-width:0;"><strong style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--c-n1);"><i style="width:7px;height:7px;border-radius:50%;background:{{ g.dot }};"></i>{{ g.name }}</strong><small style="display:block;margin-top:2px;font-size:11.5px;color:var(--c-n4);">{{ g.count }}</small></span></button>
</sc-for>
</div>
<p style="margin:10px 0 0;font-size:12px;color:var(--c-n4);line-height:1.5;">Без групп сотрудник видит только диалоги без группы. Группы можно изменить позже.</p>
</section>
<section>
<h3 style="display:flex;align-items:center;gap:9px;margin:0 0 12px;font-size:13.5px;font-weight:700;color:var(--c-n1);"><i style="width:20px;height:20px;border-radius:50%;background:var(--c-accent-bg);color:var(--c-accent-text);display:inline-flex;align-items:center;justify-content:center;font-size:11.5px;font-style:normal;font-weight:700;">4</i>Пароль первичного доступа</h3>
<div style="display:flex;flex-direction:column;gap:8px;">
<sc-for list="{{ f.pwdModes }}" as="o" hint-placeholder-count="2">
<button style="display:flex;align-items:flex-start;gap:11px;padding:11px 13px;border:1px solid {{ o.border }};border-radius:10px;background:{{ o.bg }};cursor:pointer;text-align:left;font-family:inherit;"><i style="width:16px;height:16px;flex:none;margin-top:1px;border-radius:50%;border:1.5px solid {{ o.dotBorder }};display:inline-flex;align-items:center;justify-content:center;"><sc-if value="{{ o.on }}" hint-placeholder-val="{{ false }}"><span style="width:8px;height:8px;border-radius:50%;background:var(--c-accent);"></span></sc-if></i><span style="min-width:0;"><strong style="display:block;font-size:13px;font-weight:600;color:var(--c-n1);">{{ o.label }}</strong><small style="display:block;margin-top:3px;font-size:12px;color:var(--c-n4);line-height:1.45;">{{ o.desc }}</small></span></button>
</sc-for>
</div>
</section>
</div>
<footer style="flex:none;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 22px;border-top:1px solid var(--c-n8);background:var(--c-surface2);"><span style="font-size:12px;color:var(--c-n4);">{{ f.drawerFooter }}</span><div style="display:flex;gap:8px;"><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></footer>
</aside>
</sc-if>
<!-- PASSWORD DIALOG -->
<sc-if value="{{ f.pwdDialog }}" 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;z-index:8;">
<section style="width:520px;background:var(--c-surface);border-radius:14px;box-shadow:0 24px 70px rgba(0,0,0,0.32);overflow:hidden;">
<header style="display:flex;align-items:flex-start;gap:14px;padding:20px 24px;border-bottom:1px solid var(--c-n8);"><i style="width:40px;height:40px;flex:none;border-radius:11px;background:var(--c-accent-bg);color:var(--c-accent-text);display:inline-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"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg></i><div><h2 style="margin:0;font-size:17px;font-weight:700;color:var(--c-n1);">{{ f.pwd.title }}</h2><p style="margin:4px 0 0;font-size:12.5px;color:var(--c-n4);line-height:1.5;">{{ f.pwd.sub }}</p></div></header>
<div style="padding:20px 24px;display:flex;flex-direction:column;gap:14px;">
<div style="display:flex;align-items:center;gap:10px;padding:12px 14px;border:1px solid var(--c-n8);border-radius:10px;background:var(--c-n10);"><span style="width:32px;height:32px;flex:none;border-radius:50%;background:{{ f.pwd.avBg }};color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;">{{ f.pwd.initials }}</span><span style="min-width:0;"><strong style="display:block;font-size:13px;color:var(--c-n1);">{{ f.pwd.name }}</strong><small style="display:block;font-size:11.5px;color:var(--c-n4);font-family:'SF Mono',ui-monospace,Menlo,monospace;">{{ f.pwd.email }}</small></span></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;gap:8px;height:44px;padding:0 8px 0 14px;border:1px solid var(--c-accent);border-radius:9px;background:var(--c-accent-bg);"><code style="flex:1;font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:16px;font-weight:600;letter-spacing:0.06em;color:var(--c-n1);">{{ f.pwd.value }}</code><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="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z"/><circle cx="12" cy="12" r="3"/></svg></button><button style="height:30px;padding:0 11px;border-radius:7px;border:none;background:var(--c-accent);color:#fff;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-accent-hover);"><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="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></label>
<div style="display:flex;gap:10px;padding:12px 14px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="var(--c-warn-text)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;margin-top:1px;"><path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg><p style="margin:0;font-size:12.5px;color:var(--c-n2);line-height:1.5;">Пароль показывается один раз и на сервере не хранится в открытом виде. Скопируйте и передайте сотруднику лично — при первом входе он сменит его сам. Если окно закрыть, останется только сбросить пароль заново.</p></div>
<button style="display:inline-flex;align-items:center;gap:7px;align-self:flex-start;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);"><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="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"/></svg>Отправить на почту вместо этого</button>
</div>
<footer style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 24px;border-top:1px solid var(--c-n8);background:var(--c-surface2);"><small style="font-size:12px;color:var(--c-n4);">Действие записано в аудит</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;">Скопировал, закрыть</button></footer>
</section>
</div>
</sc-if>
<!-- OWNERSHIP TRANSFER -->
<sc-if value="{{ f.transfer }}" 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;z-index:8;">
<section style="width:560px;background:var(--c-surface);border-radius:14px;box-shadow:0 24px 70px rgba(0,0,0,0.32);overflow:hidden;">
<header style="display:flex;align-items:flex-start;gap:14px;padding:20px 24px;border-bottom:1px solid var(--c-n8);"><i style="width:40px;height:40px;flex:none;border-radius:11px;background:var(--c-warn-bg);color:var(--c-warn-text);display:inline-flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="21" height="21" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h7m11 0h-7M7 8l-4 4 4 4m10-8 4 4-4 4"/></svg></i><div><h2 style="margin:0;font-size:17px;font-weight:700;color:var(--c-n1);">Передача владения</h2><p style="margin:4px 0 0;font-size:12.5px;color:var(--c-n4);">Атомарная операция · в организации остаётся ровно один владелец</p></div></header>
<div style="padding:20px 24px;display:flex;flex-direction:column;gap:16px;">
<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="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span><small style="font-size:12px;color:var(--c-n4);line-height:1.45;">В списке только активные администраторы. Чтобы передать владение сотруднику, сначала сделайте его администратором.</small></label>
<div style="display:flex;align-items:center;gap:16px;padding:14px;border:1px solid var(--c-n8);border-radius:10px;background:var(--c-n10);">
<div style="flex:1;display:flex;align-items:center;gap:10px;min-width:0;"><span style="width:34px;height:34px;flex:none;border-radius:50%;background:url(assets/av-4.png) center/cover;"></span><span style="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></span></div>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="var(--c-n4)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
<div style="flex:1;display:flex;align-items:center;gap:10px;min-width:0;"><span style="width:34px;height:34px;flex:none;border-radius:50%;background:url(assets/av-2.png) center/cover;"></span><span style="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-accent-text);font-weight:600;">→ Владелец</small></span></div>
</div>
<div><span style="display:block;margin-bottom:8px;font-size:12.5px;font-weight:600;color:var(--c-n3);">Новая роль прежнего владельца</span><div style="display:flex;gap:8px;"><button style="flex:1;display:flex;align-items:center;gap:9px;padding:10px 12px;border:1px solid var(--c-accent);border-radius:9px;background:var(--c-accent-bg);cursor:pointer;font-size:13px;font-weight:600;color:var(--c-n1);font-family:inherit;"><i style="width:15px;height:15px;border-radius:50%;border:1.5px solid var(--c-accent);display:inline-flex;align-items:center;justify-content:center;"><span style="width:7px;height:7px;border-radius:50%;background:var(--c-accent);"></span></i>Администратор</button><button style="flex:1;display:flex;align-items:center;gap:9px;padding:10px 12px;border:1px solid var(--c-n7);border-radius:9px;background:var(--c-surface);cursor:pointer;font-size:13px;font-weight:500;color:var(--c-n2);font-family:inherit;"><i style="width:15px;height:15px;border-radius:50%;border:1.5px solid var(--c-n6);display:inline-flex;"></i>Сотрудник</button></div></div>
<div style="display:flex;gap:10px;padding:12px 14px;border-radius:10px;background:var(--c-warn-bg);border:1px solid var(--c-warn-border);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="var(--c-warn-text)" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="flex:none;margin-top:1px;"><path d="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg><p style="margin:0;font-size:12.5px;color:var(--c-n2);line-height:1.5;">Новый владелец получит полный доступ. Вы потеряете права владельца, включая передачу владения и управление администраторами. Операция аудируется и необратима без повторной передачи.</p></div>
<button style="display:flex;align-items:flex-start;gap:10px;padding:0;border:none;background:transparent;cursor:pointer;text-align:left;font-family:inherit;"><i style="width:17px;height:17px;flex:none;margin-top:1px;border-radius:5px;border:1px solid var(--c-accent);background:var(--c-accent);display:inline-flex;align-items:center;justify-content:center;color:#fff;"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></i><span style="font-size:12.5px;color:var(--c-n2);line-height:1.5;">Я понимаю последствия и подтверждаю передачу владения организацией.</span></button>
</div>
<footer style="display:flex;justify-content:flex-end;gap:8px;padding:14px 24px;border-top:1px solid var(--c-n8);background:var(--c-surface2);"><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:#ff4d4f;color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;">Передать владение</button></footer>
</section>
</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};`;
// Иконки сайдбара — ровно из shared/icons.tsx (Icon: message · user · robot · team · globe · settings).
const nav = [
{ key: "chat", label: "Чат", badge: 2, paths: ["M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"], circles: [] },
{ key: "contacts", label: "Контакты", paths: ["M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"], circles: [{ cx: 12, cy: 7, r: 4 }] },
{ key: "agents", label: "Агенты", paths: ["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"], circles: [] },
{ key: "employees", label: "Сотрудники", paths: ["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"], circles: [] },
{ key: "portals", label: "Порталы", paths: ["M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20", "M2 12h20"], circles: [{ cx: 12, cy: 12, r: 10 }] },
{ key: "knowledge", label: "База знаний", paths: ["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"], circles: [] },
{ key: "settings", label: "Настройки", paths: ["M21 6H9M3 6h2", "M21 12h-8M3 12h6", "M21 18h-6M3 18h8"], circles: [{ cx: 7, cy: 6, r: 2 }, { cx: 11, cy: 12, r: 2 }, { cx: 13, cy: 18, r: 2 }] },
].map((n) => ({ ...n, bg: n.key === "employees" ? "var(--c-accent-bg)" : "transparent", color: n.key === "employees" ? "var(--c-accent-text)" : "var(--c-n2)", weight: n.key === "employees" ? 600 : 500 }));
const listHead = ["Сотрудник", "Роль", "Должность", "Группы", "Доступ", "Статус", "Последний вход", ""];
const cols = "minmax(0,1.9fr) minmax(0,1fr) minmax(0,1.1fr) minmax(0,1.5fr) minmax(0,0.9fr) minmax(0,1fr) minmax(0,1fr) 44px";
const ROLE = { OWNER: ["Владелец", "var(--c-accent-bg)", "var(--c-accent-text)"], ADMIN: ["Администратор", "color-mix(in srgb,var(--c-ai) 14%,var(--c-surface))", "var(--c-ai)"], EMPLOYEE: ["Сотрудник", "var(--c-n9)", "var(--c-n3)"] };
const STATUS = { active: ["Активен", "var(--c-ok-bg)", "var(--c-ok-text)"], invited: ["Приглашён", "var(--c-warn-bg)", "var(--c-warn-text)"], blocked: ["Заблокирован", "var(--c-err-bg)", "var(--c-err-text)"] };
const GD = { "Операторы": "var(--c-accent)", "Поддержка": "#2aa876" };
const row = (r) => ({ ...r, role: ROLE[r.r][0], roleBg: ROLE[r.r][1], roleColor: ROLE[r.r][2], status: STATUS[r.st][0], stBg: STATUS[r.st][1], stColor: STATUS[r.st][2], groups: (r.g || []).map((name) => ({ name, dot: GD[name] })), noGroups: !(r.g || []).length, access: r.r === "EMPLOYEE" ? "Только чат" : "Полный доступ", accessColor: r.r === "EMPLOYEE" ? "var(--c-n4)" : "var(--c-n2)", opacity: r.st === "blocked" ? 0.6 : 1, menuBg: r.menuOpen ? "var(--c-n9)" : "transparent" });
const rows = [
row({ name: "Елена Кузнецова", email: "e.kuznetsova@atelie-nord.ru", initials: "ЕК", avBg: "#c4456b", r: "OWNER", position: "Директор", g: [], st: "active", login: "сейчас" }),
row({ name: "Анна Ким", email: "a.kim@atelie-nord.ru", initials: "АК", avBg: "#4c6ef0", r: "ADMIN", position: "Старший оператор", g: ["Операторы", "Поддержка"], st: "active", login: "12 мин назад", menuOpen: true }),
row({ name: "Дмитрий Орлов", email: "d.orlov@atelie-nord.ru", initials: "ДО", avBg: "#3b82c4", r: "EMPLOYEE", position: "Оператор", g: ["Операторы"], st: "active", login: "2 ч назад" }),
row({ name: "Сергей Крылов", email: "s.krylov@atelie-nord.ru", initials: "СК", avBg: "#13a8a8", r: "EMPLOYEE", position: "Специалист поддержки", g: ["Поддержка"], st: "active", login: "3 дн. назад" }),
row({ name: "Ольга Титова", email: "o.titova@atelie-nord.ru", initials: "ОТ", avBg: "#d4860b", r: "EMPLOYEE", position: "Оператор", g: [], st: "invited", login: "не входил" }),
row({ name: "Павел Зайцев", email: "p.zaitsev@atelie-nord.ru", initials: "ПЗ", avBg: "#8c8c8c", r: "EMPLOYEE", position: "Оператор", g: ["Операторы"], st: "blocked", login: "12 авг 2026" }),
];
const roleTabs = (on) => [["all", "Все"], ["OWNER", "Владелец"], ["ADMIN", "Администратор"], ["EMPLOYEE", "Сотрудник"]].map(([k, label]) => ({ label, 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 mi = (label, icon, o = {}) => ({ label, icon, color: o.color || "var(--c-n2)", cursor: o.off ? "default" : "pointer", opacity: o.off ? 0.45 : 1 });
const rowMenu = [
mi("Открыть карточку", "M7 17 17 7M8 7h9v9"),
mi("Завершить сессии", "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"),
mi("Сбросить пароль", "M4 10h16v10H4zM8 10V7a4 4 0 0 1 8 0v3"),
{ divider: true },
mi("Заблокировать", "M4 10h16v10H4zM8 10V7a4 4 0 0 1 8 0v3", { color: "#ff4d4f" }),
];
const gpick = (name, count, on) => ({ name, count, on, dot: GD[name], border: on ? "var(--c-accent)" : "var(--c-n7)", bg: on ? "var(--c-accent-bg)" : "var(--c-surface)", markBorder: on ? "var(--c-accent)" : "var(--c-n6)", markBg: on ? "var(--c-accent)" : "transparent" });
const ropt = (label, desc, on, o = {}) => ({ label, desc, on, locked: !!o.locked, cursor: o.locked ? "default" : "pointer", opacity: o.locked ? 0.55 : 1, border: on ? "var(--c-accent)" : "var(--c-n7)", bg: on ? "var(--c-accent-bg)" : "var(--c-surface)", dotBorder: on ? "var(--c-accent)" : "var(--c-n6)" });
const OWNER_DESC = "Полный доступ. Ровно один в организации. Изменяется только через передачу владения.";
const ADMIN_DESC = "Полный доступ, идентичен владельцу. Единственное отличие владельца — его нельзя удалить.";
const EMP_DESC = "Работает в чате. Видит диалоги своих групп, диалоги без группы и назначенные ему.";
const drawerRoles = [ropt("Сотрудник", EMP_DESC, true), ropt("Администратор", ADMIN_DESC, false)];
const drawerGroups = [gpick("Операторы", "4 сотр.", true), gpick("Поддержка", "2 сотр.", false)];
const audit = (items) => items.map(([text, code, time, denied]) => ({ text, code, time, dot: denied ? "#ff4d4f" : "var(--c-accent)" }));
const person = (p) => ({
readonly: !p.editable, editable: !!p.editable, danger: !!p.danger, banner: !!p.banner,
role: ROLE[p.r][0], roleBg: ROLE[p.r][1], roleColor: ROLE[p.r][2],
status: STATUS[p.st][0], stBg: STATUS[p.st][1], stColor: STATUS[p.st][2],
fieldBg: p.editable ? "var(--c-n10)" : "var(--c-n9)", fieldColor: p.editable ? "var(--c-n1)" : "var(--c-n4)",
secColor: p.editable ? "var(--c-n2)" : "var(--c-n5)", secCursor: p.editable ? "pointer" : "default",
...p,
});
const orlov = person({
name: "Дмитрий Орлов", email: "d.orlov@atelie-nord.ru", initials: "ДО", avBg: "#3b82c4", phone: "+7 916 330-88-04",
position: "Оператор", groupLine: "Операторы", r: "EMPLOYEE", st: "active", editable: true, danger: true,
dangerActions: [{ title: "Блокировка доступа", text: "Немедленно завершит все сессии и прекратит HTTP и realtime actions. Аудит и авторство сохраняются. Требует подтверждения.", action: "Заблокировать сотрудника", icon: "M4 10h16v10H4zM8 10V7a4 4 0 0 1 8 0v3" }],
groupPicks: [gpick("Операторы", "4 сотр.", true), gpick("Поддержка", "2 сотр.", false)],
roleNote: "OWNER и ADMIN идентичны по правам; EMPLOYEE работает только в чате.",
roleOptions: [ropt("Владелец", OWNER_DESC, false, { locked: true }), ropt("Администратор", ADMIN_DESC, false), ropt("Сотрудник", EMP_DESC, true)],
pwd: "14 авг 2026", totpText: "Отключена для этого сотрудника", totpBg: "var(--c-n6)", totpKnob: "2px", sessions: "2 устройств",
account: [{ label: "Статус", value: "Активен" }, { label: "Роль", value: "Сотрудник" }, { label: "Создан", value: "18 мар 2026" }, { label: "Последний вход", value: "2 ч назад" }],
audit: audit([["Изменены группы сотрудника", "identity.employee_groups_changed", "3 дн. назад"], ["Активные сессии завершены", "identity.employee_sessions_terminated", "12 авг 2026"], ["Пароль сотрудника сброшен", "identity.employee_password_reset", "14 авг 2026"], ["Сотрудник создан", "identity.employee_created", "18 мар 2026"]]),
});
const owner = person({
name: "Елена Кузнецова", email: "e.kuznetsova@atelie-nord.ru", initials: "ЕК", avBg: "#c4456b", phone: "+7 985 210-64-77",
position: "Директор", groupLine: "Без группы", r: "OWNER", st: "active", editable: false, danger: true, banner: true,
dangerActions: [{ title: "Передача владения", text: "Владение передаётся только администратору: у него уже полный доступ, и после передачи он получит защищённые действия владельца. Операция атомарна, аудируется и требует подтверждения.", action: "Передать владение", icon: "M3 12h7m11 0h-7M7 8l-4 4 4 4m10-8 4 4-4 4", note: "Владельца нельзя заблокировать или удалить, поэтому других опасных действий на его карточке нет." }],
groupPicks: [gpick("Операторы", "4 сотр.", false), gpick("Поддержка", "2 сотр.", false)],
roleNote: "Роль владельца меняется только через передачу владения.",
roleOptions: [ropt("Владелец", OWNER_DESC, true, { locked: true }), ropt("Администратор", ADMIN_DESC, false, { locked: true }), ropt("Сотрудник", EMP_DESC, false, { locked: true })],
pwd: "—", totpText: "Включена · требуется при каждом входе", totpBg: "var(--c-accent)", totpKnob: "22px", sessions: "3 устройств",
account: [{ label: "Статус", value: "Активен" }, { label: "Роль", value: "Владелец" }, { label: "Создан", value: "12 янв 2026" }, { label: "Последний вход", value: "сейчас" }],
audit: audit([["Передано владение организацией", "identity.ownership_transferred", "12 янв 2026"], ["Изменена системная роль", "identity.employee_role_changed", "12 янв 2026", true]]),
});
const pwdMode = (on) => [
ropt("Отправить на рабочую почту", "Сотрудник получит письмо со ссылкой первого входа. Подходит, когда почта организации настроена и работает.", on === "mail"),
ropt("Сгенерировать и показать мне", "Пароль покажется один раз после создания — скопируйте и передайте лично. Нужен, когда письма не доходят или ящик ещё не работает.", on === "show"),
];
const listFrame = (o) => ({ isList: true, isCard: false, sidebar: true, rail: false, w: "1440px", h: "880px", shown: rows.length, total: rows.length, roleTabs: roleTabs("all"), groupFilter: "Все", resetColor: "var(--c-n4)", resizerOpacity: 0, launch: false, menu: false, drawer: false, transfer: false, pwdDialog: false, drawerFooter: "Пароль первичного доступа отправится на o.titova@atelie-nord.ru", rows, ...o });
const cardFrame = (o) => ({ isList: false, isCard: true, sidebar: true, rail: false, w: "1440px", h: "1560px", resizerOpacity: 0, launch: false, drawer: false, transfer: false, menu: false, pwdDialog: false, ...o });
const frames = [
listFrame({ id: "e1", badge: "E1", title: "Список", note: "сайдбар по коду: знак Chatballs, 6 пунктов (Порталы вместо «Доски»), блок «Запуск», ручка ширины подсвечена", launch: true, resizerOpacity: 1 }),
listFrame({ id: "e2", badge: "E2", title: "Список · меню строки", note: "фильтр «Администратор» · действия: карточка, сессии, пароль, блокировка — передачи владения здесь нет", menu: true, rows: rows.filter((r) => r.r === "ADMIN" || r.r === "OWNER"), shown: 2, roleTabs: roleTabs("ADMIN"), resetColor: "var(--c-accent-text)" }),
cardFrame({ id: "e3", badge: "E3", title: "Карточка сотрудника", note: "основные данные · должность и группы · системная роль · безопасность · опасная зона; справа учётная запись и аудит", p: orlov }),
cardFrame({ id: "e4", badge: "E4", title: "Карточка владельца", note: "только просмотр: блокировки нет; передача владения — в опасной зоне, кандидаты только администраторы", p: owner }),
listFrame({ id: "e5", badge: "E5", title: "Новый сотрудник", note: "четыре шага: учётные данные · роль · должность и группы · пароль (по умолчанию письмом)", drawer: true, pwdModes: pwdMode("mail"), h: "1040px" }),
listFrame({ id: "e6", badge: "E6", title: "Новый сотрудник · пароль показать", note: "выбран второй вариант — на случай проблем с почтой; подпись футера меняется", drawer: true, pwdModes: pwdMode("show"), drawerFooter: "Пароль покажем сразу после создания — скопируйте его", h: "1040px" }),
listFrame({ id: "e7", badge: "E7", title: "Пароль показан", note: "после создания или сброса: один раз, с копированием, маскированием и переходом на письмо", pwdDialog: true, pwd: { title: "Сотрудник создан", sub: "Пароль сгенерирован и показан один раз — скопируйте его сейчас.", name: "Ольга Титова", email: "o.titova@atelie-nord.ru", initials: "ОТ", avBg: "#d4860b", value: "kv7-Rt94-Xmz2" } }),
listFrame({ id: "e8", badge: "E8", title: "Сброс пароля", note: "владелец и админ сбрасывают пароль сотруднику из карточки или меню строки и копируют новый", pwdDialog: true, pwd: { title: "Пароль сброшен", sub: "Активные сессии сотрудника завершены, при первом входе он задаст свой пароль.", name: "Дмитрий Орлов", email: "d.orlov@atelie-nord.ru", initials: "ДО", avBg: "#3b82c4", value: "qa5-Lb27-Wnd8" } }),
listFrame({ id: "e9", badge: "E9", title: "Передача владения", note: "открывается из опасной зоны карточки владельца; кандидаты — только администраторы", transfer: true }),
listFrame({ id: "e10", badge: "E10", title: "Сайдбар · рейка 1024", note: "≤1024px: рейка 60px (знак · чат · развернуть · аватар), полный сайдбар выезжает поверх", sidebar: false, rail: true, w: "1024px" }),
];
for (const f of frames) { f.maskId = `lg-${f.id}`; f.maskUrl = f.maskId; }
return { themeVars, nav, listHead, cols, rowMenu, drawerRoles, drawerGroups, frames };
}
}
</script>
</body>
</html>
@@ -1,673 +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">
<link rel="stylesheet" href="_ds/tokens/colors.css">
<link rel="stylesheet" href="_ds/tokens/typography.css">
<link rel="stylesheet" href="_ds/tokens/spacing.css">
<link rel="stylesheet" href="_ds/tokens/effects.css">
<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 v2 · роль EMPLOYEE · единственный экран</span>
</div>
<p style="margin:0 0 28px;font-size:13px;color:var(--c-n3);max-width:900px;line-height:1.55;">Сотрудник открывается сразу в чат. Слева сайдбар с деревом «Диалоги»: Все диалоги · Группы · Агенты — это и есть фильтр списка. Статусы — вкладки над списком. Три рабочие колонки: список · лента · контекст (Контакт | История). Композер активен всегда — первое отправленное сообщение перехватывает диалог.</p>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start;">
<sc-for list="{{ frames }}" as="f" hint-placeholder-count="2">
<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>
<!-- ARTBOARD -->
<div style="width:{{ f.width }};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 -->
<sc-if value="{{ f.sideWide }}" hint-placeholder-val="{{ true }}">
<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-chat"><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-chat)"><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:1;min-height:0;overflow-y:auto;padding:4px 10px 12px;display:flex;flex-direction:column;gap:2px;">
<div style="display:flex;align-items:center;gap:9px;height:34px;padding:0 8px;color:var(--c-n1);font-size:13.5px;font-weight:700;"><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 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg><span style="flex:1;">Диалоги</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></div>
<sc-for list="{{ f.side.top }}" as="s" hint-placeholder-count="1">
<button style="display:flex;align-items:center;gap:9px;height:34px;padding:0 10px 0 12px;margin-left:16px;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-n8);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><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><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>
<div style="display:flex;align-items:center;gap:8px;height:30px;padding:0 10px 0 12px;margin:6px 0 0 16px;color:var(--c-n4);font-size:12.5px;font-weight:600;"><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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg><span style="flex:1;">Группы</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"><polyline points="18 15 12 9 6 15"/></svg></div>
<sc-for list="{{ f.side.groups }}" as="s" hint-placeholder-count="3">
<button style="display:flex;align-items:center;gap:9px;height:32px;padding:0 10px 0 12px;margin-left:34px;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-n8);"><i style="width:8px;height:8px;border-radius:50%;background:{{ s.dot }};flex:none;"></i><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>
<div style="display:flex;align-items:center;gap:8px;height:30px;padding:0 10px 0 12px;margin:6px 0 0 16px;color:var(--c-n4);font-size:12.5px;font-weight:600;"><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-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 style="flex:1;">Агенты</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"><polyline points="18 15 12 9 6 15"/></svg></div>
<sc-for list="{{ f.side.agents }}" as="s" hint-placeholder-count="2">
<button style="display:flex;align-items:center;gap:9px;height:32px;padding:0 10px 0 12px;margin-left:34px;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-n8);"><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><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>
</nav>
<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:var(--c-accent);color:#fff;font-size:12px;font-weight:600;display:flex;align-items:center;justify-content:center;">ЕК</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>
<sc-if value="{{ f.sideRail }}" hint-placeholder-val="{{ false }}">
<aside style="width:60px;flex:none;background:var(--c-surface2);border-right:1px solid var(--c-n8);display:flex;flex-direction:column;align-items:center;padding:14px 0 12px;gap:8px;">
<span style="width:28px;height:28px;flex:none;display:flex;align-items:center;justify-content:center;color:var(--c-n1);"><svg viewBox="0 0 100 100" width="26" height="26" fill="currentColor"><defs><mask id="lg-chat-rail"><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-chat-rail)"><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="margin-top:8px;width:38px;height:38px;border-radius:9px;border:none;background:var(--c-accent-bg);color:var(--c-accent-text);display:flex;align-items:center;justify-content:center;cursor:pointer;"><svg viewBox="0 0 24 24" width="18" height="18" 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>
<button title="Развернуть" style="width:38px;height:38px;border-radius:9px;border:none;background:transparent;color:var(--c-n4);display: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"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg></button>
<div style="flex:1;"></div>
<button title="Елена Кузнецова · Сотрудник" style="width:34px;height:34px;border-radius:50%;border:none;background:var(--c-accent);color:#fff;font-size:12px;font-weight:600;display:flex;align-items:center;justify-content:center;cursor:pointer;">ЕК</button>
</aside>
</sc-if>
<!-- 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);">
<div style="flex:none;padding:13px 16px 10px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px;">
<div style="display:flex;align-items:baseline;gap:8px;min-width:0;"><h2 style="margin:0;font-size:16px;font-weight:700;color:var(--c-n1);white-space:nowrap;">{{ f.side.scope }}</h2><span style="font-size:12px;color:var(--c-n4);white-space:nowrap;">{{ f.total }}</span></div>
<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>
<div style="flex:none;display:flex;gap:2px;padding:0 10px;border-bottom:1px solid var(--c-n8);overflow-x:auto;">
<sc-for list="{{ f.tabs }}" as="t" hint-placeholder-count="5">
<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:{{ t.countBg }};color:{{ t.countColor }};font-size:11px;font-weight:700;">{{ t.count }}</b></sc-if></button>
</sc-for>
</div>
<div style="flex:1;min-height:0;overflow-y:auto;">
<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><sc-if value="{{ d.voice }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="{{ d.previewColor }}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><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></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.waitText }}" 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>
<sc-if value="{{ f.emptyList }}" hint-placeholder-val="{{ false }}">
<div style="padding:64px 28px;text-align:center;">
<div style="width:44px;height:44px;border-radius:50%;background:var(--c-n9);display:inline-flex;align-items:center;justify-content:center;margin-bottom:12px;color:var(--c-n5);"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" 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></div>
<div style="font-size:13.5px;font-weight:600;color:var(--c-n3);line-height:1.45;">Диалоги появятся, когда клиенты напишут вашему агенту</div>
</div>
</sc-if>
</div>
</section>
<!-- FEED -->
<section style="flex:1;min-width:0;display:flex;flex-direction:column;min-height:0;background:var(--c-feed);position:relative;">
<sc-if value="{{ f.emptyList }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--c-n5);font-size:13px;">Выберите диалог</div>
</sc-if>
<sc-if value="{{ f.hasSel }}" hint-placeholder-val="{{ true }}">
<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:{{ f.sel.avatarBg }};color:#fff;font-size:14px;font-weight:600;">{{ f.sel.initials }}</span><i style="position:absolute;right:-1px;bottom:-1px;width:13px;height:13px;border:2px solid var(--c-surface);border-radius:50%;background:{{ f.sel.stDot }};"></i></span>
<div style="min-width:0;">
<strong style="display:block;font-size:15px;font-weight:700;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ f.sel.name }}</strong>
<p style="display:flex;align-items:center;gap:7px;margin:3px 0 0;font-size:12px;color:var(--c-n4);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"><span style="display:inline-flex;align-items:center;gap:5px;color:{{ f.sel.stColor }};font-weight:600;">{{ f.sel.stLabel }}<sc-if value="{{ f.sel.waitText }}" hint-placeholder-val="{{ false }}"><span style="opacity:0.8;">· {{ f.sel.waitShort }}</span></sc-if></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;"><em style="width:5px;height:5px;border-radius:50%;background:{{ f.sel.chColor }};"></em>{{ f.sel.chLabel }}</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:{{ f.sel.agentColor }};font-weight:500;flex:none;"><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>{{ f.sel.agent }}</span></p>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;flex:none;">
<sc-if value="{{ f.showTake }}" hint-placeholder-val="{{ true }}"><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;box-shadow:0 1px 2px var(--c-accent-shadow);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></sc-if>
<sc-if value="{{ f.showReturnAi }}" hint-placeholder-val="{{ false }}"><button style="height:36px;padding:0 14px;border-radius:8px;border:1px solid color-mix(in srgb,var(--c-ai) 45%,var(--c-surface));background:var(--c-surface);color:var(--c-ai);font-size:13px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px;" style-hover="background:color-mix(in srgb,var(--c-ai) 10%,var(--c-surface));"><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="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>Вернуть AI</button></sc-if>
<sc-if value="{{ f.ctxToggle }}" hint-placeholder-val="{{ false }}"><button title="Контакт" style="width:36px;height:36px;border-radius:8px;border:1px solid var(--c-accent-border);background:var(--c-accent-bg);color:var(--c-accent-hover);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;"><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></sc-if>
<sc-if value="{{ f.showMore }}" hint-placeholder-val="{{ true }}"><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></sc-if>
</div>
<sc-if value="{{ f.showMoreMenu }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;right:20px;top:54px;z-index:5;width:220px;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,0.12);padding:5px;">
<button style="width:100%;display:flex;align-items:center;gap:9px;padding:8px 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="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>Вернуть в очередь</button>
<button style="width:100%;display:flex;align-items:center;gap:9px;padding:8px 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="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>Закрыть диалог</button>
<div style="height:1px;background:var(--c-n9);margin:4px 8px;"></div>
<button style="width:100%;display:flex;align-items:center;gap:9px;padding:8px 10px;border:none;border-radius:7px;background:transparent;color:var(--c-err-text);font-size:13px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-err-bg);"><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="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Пометить как спам</button>
<button style="width:100%;display:flex;align-items:center;gap:9px;padding:8px 10px;border:none;border-radius:7px;background:transparent;color:var(--c-err-text);font-size:13px;font-weight:500;cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-err-bg);"><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="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>Удалить диалог</button>
</div>
</sc-if>
</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>{{ f.day }}<span style="flex:1;height:1px;background:var(--c-n8);"></span></div>
<sc-for list="{{ f.messages }}" as="m" hint-placeholder-count="5">
<sc-if value="{{ m.system }}" hint-placeholder-val="{{ false }}">
<div style="width:fit-content;display:flex;align-items:center;gap:6px;margin:0 auto 16px;padding:4px 12px;border-radius:20px;background:{{ m.sysBg }};border:1px solid {{ m.sysBorder }};color:{{ m.sysColor }};font-size:11.5px;font-weight:500;"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>{{ m.text }} · {{ m.time }}</div>
</sc-if>
<sc-if value="{{ m.voice }}" hint-placeholder-val="{{ false }}">
<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 }};font-size:11px;font-weight:600;">{{ m.initials }}</div></sc-if>
<div style="max-width:74%;width:{{ m.width }};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:{{ m.actorColor }};">{{ m.actor }}</strong></sc-if>
<div style="padding:8px 12px 8px 8px;background:{{ m.bg }};border-radius:{{ m.radius }};text-align:left;">
<div style="display:flex;align-items:center;gap:10px;">
<button title="Воспроизвести" style="width:36px;height:36px;flex:none;border-radius:50%;border:none;background:{{ m.playBg }};color:#fff;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="opacity:0.9;"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round" style="display:block;"><polygon points="6.5 4.5 19.5 12 6.5 19.5"/></svg></button>
<span style="flex:1;display:flex;align-items:center;gap:2px;height:24px;"><sc-for list="{{ m.bars }}" as="b" hint-placeholder-count="30"><i style="display:block;width:3px;height:{{ b.h }}px;border-radius:1.5px;background:{{ b.color }};flex:none;"></i></sc-for></span>
<sc-if value="{{ m.canTranscribe }}" hint-placeholder-val="{{ false }}"><button title="Расшифровать" style="width:26px;height:26px;flex:none;border-radius:7px;border:none;background:transparent;color:{{ m.linkColor }};display:inline-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="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg></button></sc-if>
<sc-if value="{{ m.transcribing }}" hint-placeholder-val="{{ false }}"><span title="Расшифровываем…" style="width:26px;height:26px;flex:none;display:inline-flex;align-items:center;justify-content:center;gap:2px;"><i style="width:4px;height:4px;border-radius:50%;background:{{ m.linkColor }};"></i><i style="width:4px;height:4px;border-radius:50%;background:{{ m.linkColor }};opacity:0.55;"></i><i style="width:4px;height:4px;border-radius:50%;background:{{ m.linkColor }};opacity:0.25;"></i></span></sc-if>
<span style="flex:none;font-size:12px;font-weight:500;color:{{ m.durColor }};font-variant-numeric:tabular-nums;">{{ m.dur }}</span>
</div>
<sc-if value="{{ m.transcript }}" hint-placeholder-val="{{ false }}">
<p style="margin:8px 0 0 46px;font-size:13.5px;line-height:1.5;color:var(--c-n2);">{{ m.transcript }}</p>
</sc-if>
</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-if>
<sc-if value="{{ m.bubble }}" hint-placeholder-val="{{ true }}">
<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 }};font-size:11px;font-weight:600;"><sc-if value="{{ m.isAi }}" hint-placeholder-val="{{ false }}"><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></sc-if>{{ m.initials }}</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:{{ m.actorColor }};">{{ 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-if>
</sc-for>
</div>
</div>
<!-- COMPOSER -->
<div style="flex:none;padding:10px 24px 16px;">
<div style="max-width:760px;margin:0 auto;">
<sc-if value="{{ f.composer.active }}" hint-placeholder-val="{{ true }}">
<sc-if value="{{ f.composer.hint }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;align-items:center;gap:7px;margin:0 0 7px 4px;font-size:12px;color:{{ f.composer.hintColor }};"><i style="width:6px;height:6px;border-radius:50%;background:{{ f.composer.hintDot }};"></i>{{ f.composer.hint }}</div>
</sc-if>
<div style="background:var(--c-surface);border:1px solid var(--c-n7);border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.04);" style-focus="border-color:var(--c-accent);">
<div style="padding:12px 14px 6px;min-height:52px;font-size:13.5px;color:var(--c-n5);line-height:1.45;">{{ f.composer.placeholder }}</div>
<div style="display:flex;align-items:center;gap:2px;padding:4px 8px 8px;">
<button title="Эмодзи" style="width:32px;height:32px;flex:none;border-radius:8px;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="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg></button>
<button title="Прикрепить" style="width:32px;height:32px;flex:none;border-radius:8px;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="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" 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>
<button title="Голосовое" style="width:32px;height:32px;flex:none;border-radius:8px;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="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" 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 title="Шаблоны ответов · /" style="height:32px;padding:0 9px;flex:none;border-radius:8px;border:none;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;gap:6px;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="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>Шаблоны</button>
<span style="flex:1;"></span>
<button style="height:34px;padding:0 14px;flex:none;border-radius:8px;border:none;background:{{ f.composer.sendBg }};color:#fff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;gap:7px;cursor:pointer;" style-hover="background:var(--c-accent-hover);">{{ f.composer.send }}<kbd style="font-family:inherit;font-size:11px;font-weight:500;opacity:0.75;">⏎</kbd></button>
</div>
</div>
</sc-if>
<sc-if value="{{ f.composer.recording }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;gap:7px;margin:0 0 7px 4px;font-size:12px;color:var(--c-n4);"><i style="width:6px;height:6px;border-radius:50%;background:#f5222d;"></i>Идёт запись. Esc — отменить, Enter — отправить</div>
<div style="display:flex;align-items:center;gap:10px;background:var(--c-surface);border:1px solid var(--c-err-border);border-radius:12px;padding:8px 8px 8px 10px;box-shadow:0 0 0 3px var(--c-err-bg);">
<button title="Отменить запись" style="width:36px;height:36px;flex:none;border-radius:9px;border:none;background:transparent;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="background:var(--c-err-bg);color:var(--c-err-text);"><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="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
<span style="display:inline-flex;align-items:center;gap:7px;flex:none;padding:0 10px;height:30px;border-radius:15px;background:var(--c-err-bg);color:var(--c-err-text);font-size:12.5px;font-weight:700;font-family:'SF Mono',ui-monospace,Menlo,monospace;"><i style="width:8px;height:8px;border-radius:50%;background:#f5222d;"></i>{{ f.composer.recTime }}</span>
<span style="flex:1;display:flex;align-items:center;justify-content:flex-end;gap:2px;height:30px;overflow:hidden;"><sc-for list="{{ f.composer.recBars }}" as="b" hint-placeholder-count="40"><i style="display:block;width:3px;height:{{ b.h }}px;border-radius:1.5px;background:{{ b.color }};flex:none;"></i></sc-for></span>
<button title="Пауза" style="width:36px;height:36px;flex:none;border-radius:9px;border:1px solid var(--c-n7);background:var(--c-surface);color:var(--c-n2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="border-color:var(--c-accent);color:var(--c-accent);"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" stroke="none"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg></button>
<button style="height:36px;padding:0 14px;flex:none;border-radius:9px;border:none;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;gap:7px;cursor:pointer;" 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"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Отправить</button>
</div>
</sc-if>
<sc-if value="{{ f.composer.locked }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;gap:14px;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:12px;padding:14px 18px;">
<span style="width:36px;height:36px;flex:none;border-radius:9px;background:var(--c-n9);color:var(--c-n4);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"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg></span>
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:13.5px;font-weight:600;color:var(--c-n2);">{{ f.composer.lockedTitle }}</strong><p style="margin:2px 0 0;font-size:12.5px;color:var(--c-n4);">{{ f.composer.lockedText }}</p></div>
<sc-if value="{{ f.composer.lockedAction }}" hint-placeholder-val="{{ false }}"><button style="height:34px;padding:0 14px;flex:none;border-radius:8px;border:1px solid var(--c-n6);background:var(--c-surface);color:var(--c-n3);font-size:13px;font-weight:500;cursor:pointer;" style-hover="border-color:var(--c-accent);color:var(--c-accent);">{{ f.composer.lockedAction }}</button></sc-if>
</div>
</sc-if>
</div>
</div>
</sc-if>
<sc-if value="{{ f.ctxDrawer }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.18);"></div>
</sc-if>
</section>
<!-- CONTEXT PANEL -->
<sc-if value="{{ f.showCtx }}" hint-placeholder-val="{{ true }}">
<aside style="{{ f.ctxStyle }}">
<div style="display:flex;flex:none;padding:0 8px;border-bottom:1px solid var(--c-n8);">
<button style="flex:1;margin-bottom:-1px;padding:13px 0;border:none;border-bottom:2px solid {{ f.ctx.contactLine }};background:transparent;color:{{ f.ctx.contactColor }};font-size:13px;font-weight:{{ f.ctx.contactWeight }};cursor:pointer;">Контакт</button>
<button style="flex:1;margin-bottom:-1px;padding:13px 0;border:none;border-bottom:2px solid {{ f.ctx.historyLine }};background:transparent;color:{{ f.ctx.historyColor }};font-size:13px;font-weight:{{ f.ctx.historyWeight }};cursor:pointer;">История</button>
<sc-if value="{{ f.ctxDrawer }}" hint-placeholder-val="{{ false }}"><button title="Закрыть" style="width:36px;flex:none;border:none;background:transparent;color:var(--c-n4);cursor:pointer;display:flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button></sc-if>
</div>
<div style="flex:1;min-height:0;overflow-y:auto;">
<sc-if value="{{ f.ctx.isContact }}" hint-placeholder-val="{{ true }}">
<!-- contact card -->
<div style="padding:20px 18px 16px;border-bottom:1px solid var(--c-n9);">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px;">
<span style="width:64px;height:64px;flex:none;display:flex;align-items:center;justify-content:center;border-radius:50%;background:{{ f.sel.avatarBg }};color:#fff;font-size:22px;font-weight:600;">{{ f.sel.initials }}</span>
<span style="display:inline-flex;align-items:center;gap:5px;margin-top:2px;height:24px;padding:0 9px;border-radius:6px;background:{{ f.sel.chBg }};color:{{ f.sel.chColor }};font-size:11.5px;font-weight:600;"><i style="width:6px;height:6px;border-radius:50%;background:{{ f.sel.chColor }};"></i>{{ f.sel.chLabel }}</span>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-top:12px;"><strong style="font-size:20px;font-weight:700;color:var(--c-n1);letter-spacing:-0.015em;line-height:1.2;">{{ f.sel.name }}</strong><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="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button></div>
<p style="margin:6px 0 0;font-size:13px;line-height:1.5;color:{{ f.sel.aboutColor }};">{{ f.sel.about }}</p>
<div style="display:flex;flex-direction:column;gap:2px;margin-top:14px;">
<sc-for list="{{ f.sel.fields }}" as="c" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:10px;height:32px;margin:0 -6px;padding:0 6px;border-radius:7px;" style-hover="background:var(--c-n10);">
<span style="width:16px;flex:none;color:var(--c-n4);display:inline-flex;align-items:center;justify-content:center;">
<sc-if value="{{ c.isMail }}" 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"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg></sc-if>
<sc-if value="{{ c.isPhone }}" 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="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.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92Z"/></svg></sc-if>
<sc-if value="{{ c.isOrg }}" 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"><rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01"/><path d="M16 6h.01"/><path d="M12 6h.01"/><path d="M12 10h.01"/><path d="M12 14h.01"/><path d="M16 10h.01"/><path d="M16 14h.01"/><path d="M8 10h.01"/><path d="M8 14h.01"/></svg></sc-if>
<sc-if value="{{ c.isPin }}" 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="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></sc-if>
<sc-if value="{{ c.isChat }}" hint-placeholder-val="{{ true }}"><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="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg></sc-if>
<sc-if value="{{ c.isWeb }}" 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"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></sc-if>
</span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:{{ c.size }};font-weight:{{ c.weight }};color:{{ c.color }};font-family:{{ c.font }};">{{ c.value }}</span>
<sc-if value="{{ c.copy }}" hint-placeholder-val="{{ true }}"><button title="Скопировать" style="width:22px;height:22px;flex:none;border:none;border-radius:5px;background:transparent;color:var(--c-n5);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;" style-hover="color:var(--c-accent);"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></button></sc-if>
</div>
</sc-for>
</div>
<sc-if value="{{ f.showCalls }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;gap:8px;margin-top:14px;">
<button style="flex:1;height:36px;display:inline-flex;align-items:center;justify-content:center;gap:7px;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="border-color:var(--c-accent);color:var(--c-accent);background:var(--c-accent-bg);"><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="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.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92Z"/></svg>Позвонить</button>
<button style="flex:1;height:36px;display:inline-flex;align-items:center;justify-content:center;gap:7px;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="border-color:var(--c-accent);color:var(--c-accent);background:var(--c-accent-bg);"><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="M15 10l4.553-2.276A1 1 0 0 1 21 8.618v6.764a1 1 0 0 1-1.447.894L15 14"/><path d="M3 6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Видеозвонок</button>
</div>
</sc-if>
</div>
<!-- dialog actions -->
<section style="padding:14px 18px 16px;border-bottom:1px solid var(--c-n9);position:relative;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;"><h4 style="margin:0;font-size:13px;font-weight:700;color:var(--c-n1);">Диалог</h4><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);"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg></button></div>
<label style="display:block;font-size:12px;font-weight:500;color:var(--c-n4);margin-bottom:5px;">Ответственный</label>
<button style="width:100%;display:flex;align-items:center;gap:8px;height:36px;padding:0 10px 0 8px;margin-bottom: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;font-family:inherit;" style-hover="border-color:var(--c-accent);"><sc-if value="{{ f.sel.assigneeInitials }}" hint-placeholder-val="{{ true }}"><span style="width:22px;height:22px;border-radius:50%;background:{{ f.sel.assigneeBg }};color:#fff;font-size:9.5px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;flex:none;">{{ f.sel.assigneeText }}</span></sc-if><sc-if value="{{ f.sel.assigneeEmpty }}" hint-placeholder-val="{{ false }}"><span style="width:22px;height:22px;border-radius:50%;border:1px dashed var(--c-n6);display:inline-flex;flex:none;"></span></sc-if><span style="flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:{{ f.sel.assigneeColor }};">{{ f.sel.assigneeLabel }}</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<div style="position:relative;margin-bottom:12px;">
<label style="display:block;font-size:12px;font-weight:500;color:var(--c-n4);margin-bottom:5px;">Группа</label>
<button style="width:100%;display:flex;align-items:center;gap:8px;height:36px;padding:0 10px 0 10px;border-radius:8px;border:1px solid {{ f.ctx.groupBorder }};background:{{ f.ctx.groupBg }};color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-accent);"><i style="width:8px;height:8px;border-radius:50%;background:{{ f.sel.groupDot }};flex:none;"></i><span style="flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ f.sel.groupLabel }}</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<sc-if value="{{ f.showGroupMenu }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:6;background:var(--c-surface);border:1px solid var(--c-n8);border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,0.12);padding:5px;">
<div style="padding:6px 10px 4px;font-size:11px;font-weight:600;letter-spacing:0.04em;color:var(--c-n5);">ПЕРЕНЕСТИ В ГРУППУ</div>
<sc-for list="{{ groupOptions }}" as="g" hint-placeholder-count="4">
<button style="width:100%;display:flex;align-items:center;gap:9px;padding:8px 10px;border:none;border-radius:7px;background:{{ g.bg }};color:{{ g.color }};font-size:13px;font-weight:{{ g.weight }};cursor:pointer;text-align:left;font-family:inherit;" style-hover="background:var(--c-n9);"><i style="width:8px;height:8px;border-radius:50%;background:{{ g.dot }};flex:none;"></i><span style="flex:1;">{{ g.label }}</span><sc-if value="{{ g.current }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></sc-if></button>
</sc-for>
<div style="padding:8px 10px 6px;font-size:11.5px;color:var(--c-n4);line-height:1.4;border-top:1px solid var(--c-n9);margin-top:4px;">Диалог без группы видят все сотрудники.</div>
</div>
</sc-if>
</div>
<label style="display:block;font-size:12px;font-weight:500;color:var(--c-n4);margin-bottom:5px;">Приоритет</label>
<button style="width:100%;display:flex;align-items:center;gap:9px;height:36px;padding:0 10px;margin-bottom: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;font-family:inherit;" style-hover="border-color:var(--c-accent);"><span style="display:inline-flex;align-items:flex-end;gap:2px;height:12px;flex:none;"><i style="width:3px;height:5px;border-radius:1px;background:{{ f.sel.prio.b1 }};"></i><i style="width:3px;height:8px;border-radius:1px;background:{{ f.sel.prio.b2 }};"></i><i style="width:3px;height:12px;border-radius:1px;background:{{ f.sel.prio.b3 }};"></i></span><span style="flex:1;text-align:left;color:{{ f.sel.prio.color }};font-weight:{{ f.sel.prio.weight }};">{{ f.sel.prio.label }}</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--c-n4)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;">
<div><label style="display:block;font-size:12px;font-weight:500;color:var(--c-n4);margin-bottom:5px;">Агент</label><div style="display:flex;align-items:center;gap:7px;height:36px;padding:0 10px;border-radius:8px;background:var(--c-n10);border:1px solid var(--c-n9);font-size:13px;color:{{ f.sel.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" style="flex:none;"><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 style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ f.sel.agent }}</span></div></div>
<div><label style="display:block;font-size:12px;font-weight:500;color:var(--c-n4);margin-bottom:5px;">Режим</label><div style="display:flex;align-items:center;gap:7px;height:36px;padding:0 10px;border-radius:8px;background:{{ f.sel.stBg }};border:1px solid {{ f.sel.stBorder }};font-size:13px;color:{{ f.sel.stColor }};font-weight:600;"><i style="width:7px;height:7px;border-radius:50%;background:{{ f.sel.stDot }};flex:none;"></i><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ f.sel.stLabel }}</span></div></div>
</div>
<label style="display:block;font-size:12px;font-weight:500;color:var(--c-n4);margin-bottom:6px;">Метки</label>
<div style="display:flex;flex-wrap:wrap;gap:6px;">
<button style="display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 9px 0 7px;border-radius:6px;border:1px dashed var(--c-n6);background:var(--c-surface);color:var(--c-n3);font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;" style-hover="border-color:var(--c-accent);color:var(--c-accent);"><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="M5 12h14"/><path d="M12 5v14"/></svg>Добавить</button>
<sc-for list="{{ f.sel.labels }}" as="l" hint-placeholder-count="1"><span style="display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 5px 0 8px;border-radius:6px;background:var(--c-surface);border:1px solid var(--c-n8);color:var(--c-n2);font-size:12px;font-weight:500;"><i style="width:8px;height:8px;border-radius:2px;background:{{ l.color }};"></i>{{ l.text }}<button title="Снять метку" style="width:18px;height:18px;border:none;border-radius:4px;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="11" height="11" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button></span></sc-for>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:14px;font-size:12px;color:var(--c-n4);"><span>Начат</span><span style="color:var(--c-n3);">{{ f.sel.started }}</span></div>
</section>
<section style="padding:14px 18px 20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;"><h4 style="margin:0;font-size:13px;font-weight:700;color:var(--c-n1);">Заметка</h4><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="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button></div>
<div style="padding:11px 13px;border:1px solid var(--c-n8);border-radius:9px;background:var(--c-warn-bg);border-color:var(--c-warn-border);font-size:12.5px;line-height:1.55;color:{{ f.sel.noteColor }};">{{ f.sel.note }}</div>
</section>
</sc-if>
<sc-if value="{{ f.ctx.isHistory }}" hint-placeholder-val="{{ false }}">
<div style="padding:18px 18px 24px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;"><h4 style="margin:0;font-size:13px;font-weight:700;color:var(--c-n1);">Диалоги контакта</h4><span style="font-size:11.5px;color:var(--c-n4);">{{ f.sel.historyCount }}</span></div>
<sc-for list="{{ f.sel.history }}" as="h" hint-placeholder-count="4">
<div style="padding:12px 13px;margin-bottom:10px;border:1px solid {{ h.border }};border-radius:10px;background:{{ h.bg }};">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:4px;"><strong style="font-size:13px;font-weight:600;color:var(--c-n2);">{{ h.title }}</strong><span style="font-size:11px;color:{{ h.dateColor }};font-weight:{{ h.dateWeight }};">{{ h.date }}</span></div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;"><b style="display:inline-flex;align-items:center;gap:4px;padding:1px 7px;border-radius:5px;background:{{ h.chBg }};color:{{ h.chColor }};font-size:10.5px;font-weight:600;"><i style="width:5px;height:5px;border-radius:50%;background:{{ h.chColor }};"></i>{{ h.chLabel }}</b><small style="font-size:11px;color:var(--c-n4);">{{ h.meta }}</small></div>
<p style="margin:0;font-size:12px;line-height:1.45;color:var(--c-n4);">{{ h.summary }}</p>
</div>
</sc-for>
</div>
</sc-if>
</div>
</aside>
</sc-if>
</div>
</div>
</sc-for>
<sc-if value="{{ showMobile }}" hint-placeholder-val="{{ true }}">
<!-- MOBILE: list -->
<div id="m1" 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;">M1</span><strong style="font-size:13.5px;color:var(--c-n1);">Мобильный · список</strong><span style="font-size:12px;color:var(--c-n4);">390 · дерево «Диалоги» — в выезжающем меню</span></div>
<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:12px 12px 8px 8px;">
<button title="Меню: Диалоги · Группы · Агенты" style="width:36px;height:36px;border:none;border-radius:8px;background:transparent;color:var(--c-n2);display:flex;align-items:center;justify-content:center;cursor:pointer;flex:none;"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="17" x2="20" y2="17"/></svg></button>
<button style="flex:1;min-width:0;display:flex;align-items:center;gap:6px;border:none;background:transparent;padding:0;cursor:pointer;font-family:inherit;text-align:left;"><div style="display:flex;flex-direction:column;line-height:1.15;min-width:0;"><strong style="font-size:15px;font-weight:700;color:var(--c-n1);display:inline-flex;align-items:center;gap:5px;">Все диалоги<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></strong><span style="font-size:11px;color:var(--c-n4);">Ателье Норд · 7 диалогов</span></div></button>
<button style="width:32px;height:32px;border-radius:50%;border:none;background:var(--c-accent);color:#fff;font-size:11px;font-weight:600;cursor:pointer;flex:none;">ЕК</button>
</div>
<div style="flex:none;padding:0 16px 10px;">
<div style="display:flex;align-items:center;gap:8px;height:36px;padding:0 11px;border:1px solid var(--c-n7);border-radius:9px;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="font-size:13.5px;color:var(--c-n5);">Поиск</span></div>
</div>
<div style="flex:none;display:flex;gap:2px;padding:0 10px;border-bottom:1px solid var(--c-n8);overflow-x:auto;">
<sc-for list="{{ mobileTabs }}" as="t" hint-placeholder-count="5">
<button style="flex:none;padding:10px 9px 9px;margin-bottom:-1px;border:none;border-bottom:2px solid {{ t.line }};background:transparent;color:{{ t.color }};font-size:12.5px;font-weight:{{ t.weight }};white-space:nowrap;display:inline-flex;align-items:center;gap:5px;">{{ t.label }}<sc-if value="{{ t.count }}" hint-placeholder-val="{{ false }}"><b style="padding:0 5px;border-radius:8px;background:{{ t.countBg }};color:{{ t.countColor }};font-size:11px;font-weight:700;">{{ t.count }}</b></sc-if></button>
</sc-for>
</div>
<div style="flex:1;min-height:0;overflow-y:auto;">
<sc-for list="{{ mobileDialogs }}" as="d" hint-placeholder-count="6">
<div style="display:flex;gap:11px;padding:12px 16px;border-bottom:1px solid var(--c-n9);">
<span style="position:relative;flex:none;display:flex;align-self:flex-start;margin-top:18px;"><span style="width:42px;height:42px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:{{ d.avatarBg }};color:#fff;font-size:14px;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:14.5px;font-weight:600;">{{ d.name }}</strong><em style="color:var(--c-n5);font-size:11.5px;font-style:normal;">{{ d.time }}</em></span>
<span style="display:flex;align-items:center;gap:6px;"><sc-if value="{{ d.voice }}" hint-placeholder-val="{{ false }}"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="{{ d.previewColor }}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><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></sc-if><small style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:{{ d.previewColor }};font-size:13px;font-weight:{{ d.previewWeight }};">{{ d.preview }}</small><sc-if value="{{ d.unread }}" hint-placeholder-val="{{ false }}"><b style="min-width:19px;height:19px;display:inline-flex;align-items:center;justify-content:center;padding:0 5px;border-radius:10px;background:var(--c-accent);color:#fff;font-size:11px;font-weight:700;">{{ 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.waitText }}" 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>
</div>
</sc-for>
</div>
</div>
</div>
<!-- MOBILE: dialog -->
<div id="m2" 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;">M2</span><strong style="font-size:13.5px;color:var(--c-n1);">Мобильный · диалог</strong><span style="font-size:12px;color:var(--c-n4);">контакт — по кнопке в шапке</span></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:10px;padding:10px 12px 10px 8px;background:var(--c-surface);border-bottom:1px solid var(--c-n8);">
<button style="width:36px;height:36px;border:none;background:transparent;color:var(--c-n3);display:flex;align-items:center;justify-content:center;cursor:pointer;flex:none;"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg></button>
<span style="width:36px;height:36px;flex:none;display:flex;align-items:center;justify-content:center;border-radius:50%;background:{{ md.avatarBg }};color:#fff;font-size:13px;font-weight:600;">{{ md.initials }}</span>
<div style="flex:1;min-width:0;"><strong style="display:block;font-size:14.5px;font-weight:700;color:var(--c-n1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ md.name }}</strong><span style="display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:{{ md.stColor }};font-weight:600;"><i style="width:6px;height:6px;border-radius:50%;background:{{ md.stDot }};"></i>{{ md.stLabel }}</span></div>
<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;flex:none;"><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;flex:none;"><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 style="flex:1;min-height:0;overflow-y:auto;padding:16px 14px 8px;">
<div style="display:flex;align-items:center;gap:12px;margin:0 0 14px;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="{{ md.messages }}" as="m" hint-placeholder-count="5">
<sc-if value="{{ m.system }}" hint-placeholder-val="{{ false }}"><div style="width:fit-content;display:flex;align-items:center;gap:6px;margin:0 auto 14px;padding:4px 12px;border-radius:20px;background:{{ m.sysBg }};border:1px solid {{ m.sysBorder }};color:{{ m.sysColor }};font-size:11.5px;font-weight:500;">{{ m.text }} · {{ m.time }}</div></sc-if>
<sc-if value="{{ m.bubble }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-direction:{{ m.dir }};">
<sc-if value="{{ m.showAv }}" hint-placeholder-val="{{ false }}"><div style="flex:none;width:26px;height:26px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:{{ m.avBg }};border:1px solid {{ m.avBorder }};color:{{ m.avColor }};font-size:10px;font-weight:600;"><sc-if value="{{ m.isAi }}" hint-placeholder-val="{{ false }}"><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="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></sc-if>{{ m.initials }}</div></sc-if>
<div style="max-width:84%;text-align:{{ m.align }};">
<sc-if value="{{ m.actor }}" hint-placeholder-val="{{ false }}"><strong style="display:block;margin-bottom:3px;font-size:11px;font-weight:600;color:{{ m.actorColor }};">{{ m.actor }}</strong></sc-if>
<div style="padding:10px 13px;background:{{ m.bg }};border-radius:{{ m.radius }};color:var(--c-n2);font-size:14px;line-height:1.45;text-align:left;">{{ m.text }}</div>
<small style="display:block;margin:3px 4px 0;color:var(--c-n5);font-size:11px;">{{ m.time }}</small>
</div>
</div>
</sc-if>
</sc-for>
</div>
<div style="flex:none;padding:8px 12px 14px;background:var(--c-feed);">
<div style="display:flex;align-items:center;gap:7px;margin:0 0 6px 6px;font-size:11.5px;color:var(--c-ai);"><i style="width:6px;height:6px;border-radius:50%;background:var(--c-ai);"></i>Отправка сообщения возьмёт диалог на вас</div>
<div style="display:flex;align-items:flex-end;gap:8px;background:var(--c-surface);border:1px solid var(--c-n7);border-radius:14px;padding:6px 6px 6px 14px;">
<span style="flex:1;padding:8px 0;font-size:14px;color:var(--c-n5);">Сообщение…</span>
<button style="width:38px;height:38px;flex:none;border-radius:10px;border:none;background:var(--c-accent);color:#fff;display:inline-flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg></button>
</div>
</div>
</div>
</div>
</sc-if>
</div>
</section>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;default&quot;:&quot;light&quot;,&quot;options&quot;:[&quot;light&quot;,&quot;dark&quot;],&quot;tsType&quot;:&quot;'light' | 'dark'&quot;,&quot;section&quot;:&quot;Тема&quot;},&quot;accent&quot;:{&quot;editor&quot;:&quot;color&quot;,&quot;default&quot;:&quot;#1677ff&quot;,&quot;options&quot;:[&quot;#1677ff&quot;,&quot;#6d5dfc&quot;,&quot;#0f9b8e&quot;,&quot;#e8590c&quot;],&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Тема&quot;},&quot;frame&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 {
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", errText: "#d9736f", 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)` }
: { 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", errText: "#cf1322", accentText: `color-mix(in srgb,${accent} 82%,black)`, accentHover: `color-mix(in srgb,${accent} 88%,black)`, accentBgPct: 10, accentBorderPct: 40, tintPct: 11, accentMuted: accent };
const themeVars = `--c-canvas:${T.canvas};--c-surface:${T.surface};--c-surface2:${T.surface2};--c-feed:${T.feed};--c-msg-in:${dark ? "#1e2025" : "#ffffff"};--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-accent-shadow:color-mix(in srgb,${accent} ${dark ? 0 : 30}%,transparent);--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-err-bg:color-mix(in srgb,#f5222d ${T.tintPct}%,${T.surface});--c-err-border:color-mix(in srgb,#f5222d 40%,${T.surface});--c-err-text:${T.errText};`;
const CH = {
TG: { chLabel: "Telegram", chColor: "#2f8fd0", chBg: "color-mix(in srgb,#2f8fd0 14%,var(--c-surface))" },
WEB: { chLabel: "Web Chat", chColor: "#0f9b8e", chBg: "color-mix(in srgb,#0f9b8e 14%,var(--c-surface))" },
EMAIL: { chLabel: "Email", chColor: "var(--c-warn-text)", chBg: "var(--c-warn-bg)" },
MAX: { chLabel: "MAX", chColor: "#6b5be0", chBg: "color-mix(in srgb,#6b5be0 14%,var(--c-surface))" },
};
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: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" },
};
const PHOTO = { "ЕК": "url(assets/av-4.png) center/cover", "АК": "url(assets/av-2.png) center/cover" };
const DOT = { ai: "var(--c-ai)", wait: "#faad14", human: "var(--c-accent)", closed: "var(--c-n5)" };
const MODE_TITLE = { ai: "AI отвечает", wait: "Ждёт человека", human: "Ведёт сотрудник", closed: "Закрыт" };
const GROUP_DOT = { "Операторы": "var(--c-accent)", "Поддержка": "#2aa876", "VIP-клиенты": "#eb2f96", "": "var(--c-n5)" };
const LABEL = { "доставка": "var(--c-accent)", "возврат": "#eb2f96", "кабинет": "#13c2c2", "адрес": "#fa8c16", "почта": "var(--c-ai)", "заказ 4390": "#52c41a", "срочно": "#f5222d" };
const AGENT_COLOR = { "Консультант": "var(--c-ai)", "Поддержка сайта": "#0f9b8e" };
const PRIO = {
high: { label: "Высокий", color: "var(--c-err-text)", weight: 600, b1: "#ff4d4f", b2: "#ff4d4f", b3: "#ff4d4f" },
mid: { label: "Средний", color: "#d46b08", weight: 600, b1: "#fa8c16", b2: "#fa8c16", b3: "var(--c-n7)" },
low: { label: "Низкий", color: "var(--c-accent-text)", weight: 600, b1: "var(--c-accent)", b2: "var(--c-n7)", b3: "var(--c-n7)" },
none: { label: "Не задан", color: "var(--c-n4)", weight: 500, b1: "var(--c-n6)", b2: "var(--c-n6)", b3: "var(--c-n6)" },
};
const lbl = (arr) => arr.map((text) => ({ text, color: LABEL[text] || "var(--c-n4)" }));
const ST = {
ai: { stLabel: "AI отвечает", stColor: "var(--c-ai)", stBg: "color-mix(in srgb,var(--c-ai) 10%,var(--c-surface))", stBorder: "color-mix(in srgb,var(--c-ai) 28%,var(--c-surface))", stDot: "var(--c-ai)" },
wait: { stLabel: "Ждёт человека", stColor: "var(--c-warn-text)", stBg: "var(--c-warn-bg)", stBorder: "var(--c-warn-border)", stDot: "#faad14" },
mine: { stLabel: "Ведёте вы", stColor: "var(--c-accent-text)", stBg: "var(--c-accent-bg)", stBorder: "var(--c-accent-border)", stDot: "var(--c-accent)" },
other: { stLabel: "Ведёт Анна Ким", stColor: "var(--c-n3)", stBg: "var(--c-surface2)", stBorder: "var(--c-n6)", stDot: "var(--c-n4)" },
closed: { stLabel: "Закрыт", stColor: "var(--c-n3)", stBg: "var(--c-n9)", stBorder: "var(--c-n6)", stDot: "var(--c-n5)" },
};
const base = [
{ key: "maria", num: 4471, name: "Мария Соколова", initials: "МС", avatarBg: "#eb6f4b", ch: "TG", agent: "Консультант", group: "Операторы", mode: "ai", time: "17:10", preview: "Хорошо, жду", unread: 2, labels: ["доставка", "срочно"], prio: "high", about: "Заказ 4471 — комплект штор. Постоянный клиент с августа, предпочитает Telegram.", phone: "+7 917 204-42-18", city: "Казань" },
{ key: "dmitry", num: 4470, name: "Дмитрий Орлов", initials: "", avatarBg: "url(assets/av-1.png) center/cover", ch: "WEB", agent: "Консультант", group: "Операторы", mode: "wait", time: "17:04", preview: "Хочу поговорить с человеком", unread: 1, labels: [], waitText: "ждёт 6 мин", prio: "mid", about: "" },
{ key: "irina", num: 4390, name: "Ирина Левина", initials: "ИЛ", avatarBg: "#8c6ad1", ch: "EMAIL", agent: "Поддержка сайта", group: "Поддержка", mode: "human", time: "16:48", preview: "Проверил, заказ уже передан в службу доставки…", unread: 0, fromUs: true, labels: ["заказ 4390", "почта"], prio: "high", about: "Закупщик текстиля для гостиницы. Просила писать на почту, не звонить.", phone: "+7 495 118-10-07", org: "ООО «Дом текстиля»", city: "Москва" },
{ key: "artem", num: 4468, name: "Артём Гусев", initials: "АГ", avatarBg: "#2aa876", ch: "MAX", agent: "Консультант", group: "", mode: "ai", time: "16:31", preview: "Спасибо, понял", unread: 0, labels: [], about: "" },
{ key: "guest", num: 4467, name: "Гость · 8f3a12", initials: "Г", avatarBg: "var(--c-n4)", ch: "WEB", agent: "Поддержка сайта", group: "Поддержка", mode: "human", time: "15:57", preview: "Анна Ким: Сейчас уточню у коллег", unread: 0, fromUs: true, labels: ["кабинет"], prio: "low", about: "" },
{ key: "olga", num: 4466, name: "Ольга Мельник", initials: "ОМ", avatarBg: "#d1548c", ch: "TG", agent: "Консультант", group: "Операторы", mode: "wait", time: "15:20", preview: "Голосовое сообщение · 0:14", voice: true, unread: 3, labels: ["возврат"], waitText: "ждёт 1 ч 50 мин", about: "", phone: "+7 903 655-77-31" },
{ key: "sergey", num: 4402, name: "Сергей Крылов", initials: "", avatarBg: "url(assets/av-3.png) center/cover", ch: "TG", agent: "Консультант", group: "Операторы", mode: "closed", time: "вчера", preview: "Готово, адрес обновлён. Хорошего дня!", unread: 0, fromUs: true, labels: ["адрес"], about: "", city: "Казань" },
];
const row = (d, active) => ({
...d, ...CH[d.ch], ...CH_ICON[d.ch], dot: DOT[d.mode], modeTitle: MODE_TITLE[d.mode], waitShort: (d.waitText || "").replace("ждёт ", ""),
prioIcon: !!(d.prio && d.prio !== "none"), prioLabel: (PRIO[d.prio] || PRIO.none).label, pb1: (PRIO[d.prio] || PRIO.none).b1, pb2: (PRIO[d.prio] || PRIO.none).b2, pb3: (PRIO[d.prio] || PRIO.none).b3, groupDot: GROUP_DOT[d.group], labels: lbl(d.labels), agentColor: AGENT_COLOR[d.agent] || "var(--c-n4)",
hasBadges: !!(d.group || d.labels.length || d.waitText),
bg: active ? "var(--c-n9)" : "transparent",
previewColor: d.unread ? "var(--c-n2)" : "var(--c-n4)", previewWeight: d.unread ? 500 : 400,
});
const tabs = (activeKey, waitCount) => [
{ key: "all", label: "Все" }, { key: "mine", label: "Мои" }, { key: "wait", label: "Ждут оператора", count: waitCount },
].map((t) => ({ ...t, line: t.key === activeKey ? "var(--c-accent-text)" : "transparent", color: t.key === activeKey ? "var(--c-accent-text)" : "var(--c-n4)", weight: t.key === activeKey ? 600 : 500, countBg: "var(--c-warn-bg)", countColor: "var(--c-warn-text)" }));
const side = (active) => {
const item = (key, label, count, extra) => ({ key, label, count, ...extra, bg: key === active ? "var(--c-accent-bg)" : "transparent", color: key === active ? "var(--c-accent-text)" : "var(--c-n2)", weight: key === active ? 600 : 500, countColor: key === active ? "var(--c-accent-text)" : "var(--c-n5)" });
const all = [item("all", "Все диалоги", 7, {})];
const groups = [item("g:op", "Операторы", 4, { dot: GROUP_DOT["Операторы"] }), item("g:sup", "Поддержка", 2, { dot: GROUP_DOT["Поддержка"] }), item("g:none", "Без группы", 1, { dot: GROUP_DOT[""] })];
const agents = [item("a:cons", "Консультант", 5, { avBg: "color-mix(in srgb,var(--c-ai) 14%,var(--c-surface))", avColor: "var(--c-ai)" }), item("a:site", "Поддержка сайта", 2, { avBg: "color-mix(in srgb,#0f9b8e 14%,var(--c-surface))", avColor: "#0f9b8e" })];
const cur = [...all, ...groups, ...agents].find((i) => i.key === active);
return { top: all, groups, agents, scope: cur ? cur.label : "Все диалоги" };
};
const msg = (who, text, time, extra) => {
if (who === "sys") return { system: true, bubble: false, text, time, sysBg: extra?.tone === "claimed" ? "var(--c-accent-bg)" : extra?.tone === "warning" ? "var(--c-warn-bg)" : "var(--c-n9)", sysBorder: extra?.tone === "claimed" ? "var(--c-accent-border)" : extra?.tone === "warning" ? "var(--c-warn-border)" : "transparent", sysColor: extra?.tone === "claimed" ? "var(--c-accent-text)" : extra?.tone === "warning" ? "var(--c-warn-text)" : "var(--c-n4)" };
const client = who === "client";
const ai = who === "ai";
return {
bubble: true, system: false, text, time, isAi: ai, read: !client, showAv: !client,
dir: client ? "row" : "row-reverse", align: client ? "left" : "right",
initials: client ? extra.initials : ai ? "" : (PHOTO[extra.initials] ? "" : extra.initials),
avBg: client ? extra.avatarBg : ai ? "color-mix(in srgb,var(--c-ai) 10%,var(--c-surface))" : (PHOTO[extra.initials] || "var(--c-accent)"), avBorder: ai ? "color-mix(in srgb,var(--c-ai) 28%,var(--c-surface))" : "transparent", avColor: ai ? "var(--c-ai)" : "#fff",
actor: client ? "" : ai ? "AI · " + extra.agent : extra.actor, actorColor: ai ? "var(--c-ai)" : "var(--c-accent-text)",
bg: client ? "var(--c-msg-in)" : ai ? "color-mix(in srgb,var(--c-ai) 16%,var(--c-feed))" : "color-mix(in srgb,var(--c-accent) 22%,var(--c-feed))",
radius: client ? "4px 14px 14px 14px" : "14px 4px 14px 14px",
};
};
const WAVE = [4, 7, 12, 18, 14, 9, 6, 10, 16, 22, 17, 11, 7, 5, 9, 14, 20, 15, 10, 6, 4, 8, 13, 19, 16, 11, 7, 5, 10, 15, 21, 17, 12, 8, 5, 4, 9, 13, 18, 14, 9, 6, 4, 7, 11, 16, 12, 8, 5, 4, 8, 12, 17, 13, 9, 6];
const wave = (n, offset, played, on, off) => Array.from({ length: n }, (_, i) => ({ h: WAVE[(i + offset) % WAVE.length], color: i < played ? on : off }));
const voice = (who, dur, state, transcript, time, extra) => {
const m = msg(who, "", time, extra);
const client = who === "client";
const n = client ? 40 : 36;
return { ...m, bubble: false, voice: true, dur, width: "360px",
transcript: state === "done" ? transcript : "", transcribing: state === "busy", canTranscribe: state === "none",
bars: wave(n, client ? 0 : 19, client ? 15 : 0, client ? "var(--c-accent)" : "var(--c-accent)", client ? "var(--c-n6)" : "color-mix(in srgb,var(--c-accent) 50%,var(--c-surface))"),
playBg: "var(--c-accent)", durColor: client ? "var(--c-n4)" : "var(--c-accent-text)", linkColor: "var(--c-accent-text)" };
};
const maria = base[0];
const mariaMsgs = [
msg("client", "Здравствуйте! Заказала у вас комплект штор, номер заказа 4471. Подскажите, сколько занимает доставка в Казань?", "17:02", maria),
msg("ai", "Здравствуйте, Мария. Доставка в Казань занимает 3–5 рабочих дней с момента отправки. Заказ 4471 сейчас на этапе пошива — отправим ориентировочно в четверг.", "17:02", maria),
msg("client", "А можно ускорить? Нужно к субботе.", "17:09", maria),
msg("ai", "Понимаю. Ускоренная доставка возможна, но её условия лучше уточнить у специалиста — передаю вопрос коллегам.", "17:09", maria),
msg("client", "Хорошо, жду", "17:10", maria),
];
const MONO = "'SF Mono',ui-monospace,Menlo,monospace";
const field = (type, value, opts) => ({ isMail: type === "mail", isPhone: type === "phone", isOrg: type === "org", isPin: type === "pin", isChat: type === "chat", isWeb: type === "web", value, copy: opts?.copy !== false, color: opts?.muted ? "var(--c-n4)" : type === "phone" ? "var(--c-n1)" : "var(--c-n2)", font: "inherit", size: type === "phone" ? "15.5px" : "13px", weight: type === "phone" ? 600 : 400 });
const fields = (d) => {
const out = [];
if (d.phone) out.push(field("phone", d.phone));
if (d.ch === "TG") out.push(field("chat", "@" + d.key + "_s · Telegram"));
if (d.ch === "MAX") out.push(field("chat", "MAX · id 5810 2277"));
if (d.ch === "WEB") out.push(field("web", "Web Chat · atelie-nord.ru, анонимная сессия", { copy: false, muted: true }));
if (d.ch === "EMAIL") out.push(field("mail", d.key + "@domtextil.ru"));
if (d.org) out.push(field("org", d.org));
if (d.city) out.push(field("pin", d.city + ", Россия", { copy: false }));
return out;
};
const history = (d) => {
const h = (title, date, current, meta, summary) => ({ title, date, current, meta, summary, ...CH[d.ch], bg: current ? "var(--c-accent-bg)" : "var(--c-surface)", border: current ? "var(--c-accent-border)" : "var(--c-n8)", dateColor: current ? "var(--c-warn-text)" : "var(--c-n5)", dateWeight: current ? 600 : 400 });
return [
h("Текущий диалог", "открыт", true, d.agent + " · " + (d.group || "без группы"), "Сроки доставки заказа 4471, просьба ускорить к субботе."),
h("Замер и расчёт", "12 авг", false, d.agent + " · закрыт · вела Анна Ким", "Уточняла размеры окон, получила расчёт стоимости, оформила заказ."),
h("Первое обращение", "3 авг", false, d.agent + " · закрыт · AI", "Спрашивала о тканях и сроках пошива. Передан каталог."),
];
};
const sel = (d, st, extra) => ({
...d, ...CH[d.ch], ...ST[st], groupLabel: d.group || "Без группы", groupDot: GROUP_DOT[d.group], labels: lbl(d.labels), agentColor: AGENT_COLOR[d.agent] || "var(--c-n4)", prio: PRIO[d.prio || "none"], fields: fields(d), history: history(d), historyCount: "3 диалога", started: "сегодня, 17:02",
waitShort: (d.waitText || "").replace("ждёт ", ""),
about: d.about || "Описания нет", aboutColor: d.about ? "var(--c-n3)" : "var(--c-n5)",
assigneeInitials: extra?.assigneeInitials || "", assigneeText: PHOTO[extra?.assigneeInitials] ? "" : (extra?.assigneeInitials || ""), assigneeEmpty: !extra?.assigneeInitials, assigneeBg: PHOTO[extra?.assigneeInitials] || extra?.assigneeBg || "var(--c-accent)", assigneeLabel: extra?.assigneeLabel || "Не назначен", assigneeColor: extra?.assigneeInitials ? "var(--c-n2)" : "var(--c-n4)",
note: extra?.note || "Заметок нет", noteColor: extra?.note ? "var(--c-n3)" : "var(--c-n5)", ...extra,
});
const ctx = (tab, groupOpen) => ({
isContact: tab === "contact", isHistory: tab === "history",
contactLine: tab === "contact" ? "var(--c-accent-text)" : "transparent", contactColor: tab === "contact" ? "var(--c-accent-text)" : "var(--c-n4)", contactWeight: tab === "contact" ? 600 : 500,
historyLine: tab === "history" ? "var(--c-accent-text)" : "transparent", historyColor: tab === "history" ? "var(--c-accent-text)" : "var(--c-n4)", historyWeight: tab === "history" ? 600 : 500,
groupBorder: groupOpen ? "var(--c-accent)" : "var(--c-n7)", groupBg: groupOpen ? "var(--c-accent-bg)" : "var(--c-surface)",
});
const composerActive = (hint, hintColor, hintDot, send) => ({ active: true, locked: false, recording: false, hint, hintColor, hintDot, placeholder: "Введите сообщение… Shift+Enter — перенос строки, «/» — шаблон ответа", send, sendBg: "var(--c-accent)" });
const composerLocked = (lockedTitle, lockedText, lockedAction) => ({ active: false, locked: true, recording: false, lockedTitle, lockedText, lockedAction });
const ctxPanel = "width:320px;flex:none;display:flex;flex-direction:column;min-height:0;background:var(--c-surface);border-left:1px solid var(--c-n8);";
const ctxDrawer = "position:absolute;right:0;top:0;bottom:0;width:320px;display:flex;flex-direction:column;min-height:0;background:var(--c-surface);border-left:1px solid var(--c-n8);box-shadow:-8px 0 24px rgba(0,0,0,0.10);z-index:4;";
const frame = (o) => ({
width: "1440px", total: "7 диалогов", side: side("all"), sideWide: true, sideRail: false, tabs: tabs("all", 2), dialogs: base.map((d) => row(d, d.key === o.selKey)),
hasSel: true, emptyList: false, showTake: false, showReturnAi: false, showCalls: true, showMore: true, showMoreMenu: false, ctxToggle: false, ctxDrawer: false,
showCtx: true, ctxStyle: ctxPanel, ctx: ctx("contact", false), showGroupMenu: false, day: "Сегодня", ...o,
});
const irinaSel = sel(base[2], "mine", { assigneeInitials: "ЕК", assigneeLabel: "Елена Кузнецова · вы", note: "Постоянный клиент, заказ 4390. Просила писать на почту, не звонить." });
const frames = [
frame({ id: "h", badge: "H", title: "Голосовые сообщения", note: "три состояния: расшифровано · расшифровывается · кнопка «Расшифровать»; композер — запись", selKey: "olga",
sel: sel(base[5], "mine", { assigneeInitials: "ЕК", assigneeLabel: "Елена Кузнецова · вы" }), showReturnAi: true,
messages: [
msg("client", "Здравствуйте! Получила шторы, но цвет не тот, что на сайте.", "15:12", base[5]),
msg("ai", "Здравствуйте, Ольга. Сожалею. Возврат возможен в течение 14 дней — передаю вопрос сотруднику.", "15:12", base[5]),
voice("client", "0:14", "done", "Можно оформить возврат? Я бы хотела заменить на бежевый, если он есть в наличии, а не возвращать деньги.", "15:20", base[5]),
msg("sys", "Елена Кузнецова взяла диалог", "15:24", { tone: "claimed" }),
voice("op", "0:31", "none", "", "15:26", { initials: "ЕК", actor: "Елена Кузнецова · вы" }),
voice("client", "0:09", "busy", "", "15:29", base[5]),
],
composer: { active: false, locked: false, recording: true, recTime: "0:07", recBars: wave(56, 7, 56, "#f5222d", "#f5222d") } }),
frame({ id: "a", badge: "A", title: "Рабочий экран: диалог ведёт AI", note: "сайдбар «Диалоги» = фильтр; композер активен, отправка перехватит диалог", selKey: "maria",
sel: sel(maria, "ai"), showTake: true, messages: mariaMsgs,
composer: composerActive("AI ведёт диалог. Ваше сообщение перехватит его — AI перестанет отвечать", "var(--c-ai)", "var(--c-ai)", "Отправить") }),
frame({ id: "b", badge: "B", title: "Ждёт человека", note: "AI передал диалог; вкладка «Ждут оператора», таймер ожидания", selKey: "dmitry", tabs: tabs("wait", 2),
dialogs: base.filter((d) => d.mode === "wait").map((d) => row(d, d.key === "dmitry")), total: "2 диалога",
sel: sel(base[1], "wait"), showTake: true,
messages: [
msg("client", "Добрый день. У вас можно заказать шторы по своим размерам?", "16:58", base[1]),
msg("ai", "Добрый день! Да, шьём по индивидуальным размерам. Подскажите ширину и высоту окна — рассчитаю стоимость.", "16:58", base[1]),
msg("client", "Хочу поговорить с человеком", "17:04", base[1]),
msg("sys", "AI передал диалог: клиент попросил сотрудника", "17:04", { tone: "warning" }),
],
composer: composerActive("Клиент ждёт. Ваше сообщение возьмёт диалог на вас", "var(--c-warn-text)", "#faad14", "Отправить") }),
frame({ id: "c", badge: "C", title: "Ведёте вы", note: "карточка контакта с полями; ⋯ меню открыто", selKey: "irina",
sel: irinaSel, showReturnAi: true, showMoreMenu: true,
messages: [
msg("client", "Здравствуйте. Заказ 4390 оплачен неделю назад, статус не меняется. Что происходит?", "16:20", base[2]),
msg("ai", "Здравствуйте, Ирина. Вижу заказ 4390 в статусе «В производстве». Уточню сроки у коллег — передаю вопрос.", "16:20", base[2]),
msg("sys", "Елена Кузнецова взяла диалог", "16:31", { tone: "claimed" }),
msg("op", "Ирина, добрый день. Проверил: заказ уже передан в службу доставки, трек-номер придёт на почту сегодня до 19:00.", "16:48", { initials: "ЕК", actor: "Елена Кузнецова · вы" }),
],
composer: composerActive("", "", "", "Отправить") }),
frame({ id: "d", badge: "D", title: "Ведёт другой сотрудник", note: "композер заблокирован; взять можно явно", selKey: "guest",
sel: sel(base[4], "other", { assigneeInitials: "АК", assigneeBg: "#2aa876", assigneeLabel: "Анна Ким" }),
messages: [
msg("client", "Не приходит письмо для сброса пароля в личном кабинете", "15:40", base[4]),
msg("ai", "Проверьте папку «Спам». Если письма нет, передам запрос сотруднику.", "15:40", base[4]),
msg("client", "Нет ничего", "15:52", base[4]),
msg("sys", "Анна Ким взяла диалог", "15:55", { tone: "claimed" }),
msg("op", "Сейчас уточню у коллег, какой адрес указан в кабинете.", "15:57", { initials: "АК", actor: "Анна Ким" }),
],
composer: composerLocked("Диалог ведёт Анна Ким", "Отвечать может ответственный. Возьмите диалог, чтобы продолжить самостоятельно.", "Взять диалог") }),
frame({ id: "e", badge: "E", title: "Закрытый диалог", note: "вкладка «Мои»; новое сообщение клиента создаст новый диалог", selKey: "sergey", tabs: tabs("mine", 2),
dialogs: base.filter((d) => d.mode === "closed").map((d) => row(d, true)), total: "1 диалог", day: "Вчера",
sel: sel(base[6], "closed", { assigneeInitials: "ЕК", assigneeLabel: "Елена Кузнецова · вы", started: "вчера, 11:05" }), showCalls: false, showMore: false,
messages: [
msg("client", "Здравствуйте, хочу изменить адрес доставки по заказу 4402", "11:05", base[6]),
msg("sys", "Елена Кузнецова взяла диалог", "11:06", { tone: "claimed" }),
msg("op", "Здравствуйте, Сергей. Напишите новый адрес — поменяю.", "11:07", { initials: "ЕК", actor: "Елена Кузнецова · вы" }),
msg("client", "Казань, ул. Баумана 12, кв. 40", "11:09", base[6]),
msg("op", "Готово, адрес обновлён. Хорошего дня!", "11:11", { initials: "ЕК", actor: "Елена Кузнецова · вы" }),
msg("sys", "Диалог закрыт", "11:12"),
],
composer: composerLocked("Диалог закрыт", "История сохранена. Новое обращение клиента создаст новый диалог.", "") }),
frame({ id: "f", badge: "F", title: "Контекст-панель: История", note: "прошлые диалоги контакта, текущий выделен", selKey: "maria",
sel: sel(maria, "ai"), showTake: true, messages: mariaMsgs, ctx: ctx("history", false),
composer: composerActive("AI ведёт диалог. Ваше сообщение перехватит его — AI перестанет отвечать", "var(--c-ai)", "var(--c-ai)", "Отправить") }),
frame({ id: "g", badge: "G", title: "Фильтр по группе · перенос в группу", note: "в сайдбаре выбрана группа «Поддержка»; меню групп открыто", selKey: "irina",
side: side("g:sup"), dialogs: base.filter((d) => d.group === "Поддержка").map((d) => row(d, d.key === "irina")), total: "2 диалога",
sel: irinaSel, showReturnAi: true, ctx: ctx("contact", true), showGroupMenu: true,
messages: [
msg("client", "Здравствуйте. Заказ 4390 оплачен неделю назад, статус не меняется. Что происходит?", "16:20", base[2]),
msg("sys", "Елена Кузнецова взяла диалог", "16:31", { tone: "claimed" }),
msg("op", "Ирина, добрый день. Проверил: заказ уже передан в службу доставки, трек-номер придёт на почту сегодня до 19:00.", "16:48", { initials: "ЕК", actor: "Елена Кузнецова · вы" }),
],
composer: composerActive("", "", "", "Отправить") }),
frame({ id: "s1", badge: "S1", title: "Пустой список", note: "пустое состояние сотрудника — без кнопки", selKey: "", dialogs: [], total: "", hasSel: false, emptyList: true, showCtx: false, messages: [], composer: { active: false, locked: false },
side: { ...side("all"), top: [{ key: "all", label: "Все диалоги", count: "", bg: "var(--c-accent-bg)", color: "var(--c-accent-text)", weight: 600, countColor: "var(--c-accent-text)" }], groups: [], agents: [] } }),
frame({ id: "s2", badge: "S2", title: "Ширина 1024", note: "сайдбар свёрнут в рейку; контекст-панель — выезжающая (открыта)", width: "1024px", selKey: "maria",
sideWide: false, sideRail: true, sel: sel(maria, "ai"), showTake: true, showCalls: false, ctxToggle: true, ctxDrawer: true, ctxStyle: ctxDrawer, messages: mariaMsgs,
composer: composerActive("AI ведёт диалог. Ваше сообщение перехватит его — AI перестанет отвечать", "var(--c-ai)", "var(--c-ai)", "Отправить") }),
];
const groupOptions = ["Без группы", "Операторы", "Поддержка", "VIP-клиенты"].map((label) => ({ label, dot: GROUP_DOT[label === "Без группы" ? "" : label], current: label === "Поддержка", bg: label === "Поддержка" ? "var(--c-accent-bg)" : "transparent", color: label === "Поддержка" ? "var(--c-accent-text)" : "var(--c-n2)", weight: label === "Поддержка" ? 600 : 500 }));
const only = (this.props.frame ?? "").trim().toLowerCase();
return {
themeVars, frames: only ? frames.filter((f) => f.id === only) : frames, showMobile: !only, groupOptions,
mobileTabs: tabs("all", 2),
mobileDialogs: base.map((d) => row(d, false)),
md: { ...maria, ...CH.TG, ...ST.ai, messages: mariaMsgs },
};
}
}
</script>
</body>
</html>
@@ -1,667 +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">
<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>
* { box-sizing: border-box; }
body { margin: 0; background: #e9ebee; }
a { color: var(--primary); text-decoration: none; }
a:hover { color: var(--primary-hover); }
.kb-scroll::-webkit-scrollbar { width: 10px; height: 10px; }
.kb-scroll::-webkit-scrollbar-thumb { background: #d0d3d8; border-radius: 8px; border: 3px solid transparent; background-clip: content-box; }
</style>
</helmet>
<div style="font-family: var(--font-sans); color: var(--text-body); padding: 40px 48px 80px; min-width: 1480px; display: flex; flex-direction: column; gap: 40px;">
<!-- DOC HEADER -->
<div style="max-width: 1340px;">
<div style="display: inline-flex; align-items: center; gap: 8px; padding: 4px 11px; border-radius: var(--radius-pill); background: var(--ai-bg); color: var(--ai); font-size: 11px; font-weight: 600; letter-spacing: 0.04em; margin-bottom: 14px;">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
DG-01 · DESIGN BASELINE
</div>
<h1 style="margin: 0 0 8px; font-size: 26px; font-weight: 700; letter-spacing: -0.02em; color: var(--text-heading);">База знаний AI-агентов — иерархия и доступность по отделам</h1>
<p style="margin: 0; font-size: 14px; line-height: 1.55; color: var(--text-secondary); max-width: 820px;">Утверждаемый макет раздела <b>AI → Знания</b> по SPEC-HUB-0026 / ADR-HUB-0036: дерево категорий, список с фильтрами и массовыми операциями, карточка знания с областью доступности, управление категориями и конфликтные состояния при смене scope. Все данные — примеры.</p>
<div style="display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap;">
<a href="#A" style="font-size: 12.5px; padding: 5px 12px; border: 1px solid var(--n-6); border-radius: var(--radius-pill); background: #fff;">A · Библиотека</a>
<a href="#B" style="font-size: 12.5px; padding: 5px 12px; border: 1px solid var(--n-6); border-radius: var(--radius-pill); background: #fff;">B · Массовый выбор</a>
<a href="#C" style="font-size: 12.5px; padding: 5px 12px; border: 1px solid var(--n-6); border-radius: var(--radius-pill); background: #fff;">C · Карточка знания</a>
<a href="#D" style="font-size: 12.5px; padding: 5px 12px; border: 1px solid var(--n-6); border-radius: var(--radius-pill); background: #fff;">D · Управление категориями</a>
<a href="#E" style="font-size: 12.5px; padding: 5px 12px; border: 1px solid var(--n-6); border-radius: var(--radius-pill); background: #fff;">E · Конфликт</a>
<a href="#S" style="font-size: 12.5px; padding: 5px 12px; border: 1px solid var(--n-6); border-radius: var(--radius-pill); background: #fff;">Состояния</a>
</div>
</div>
<!-- ============ FRAME A — FULL SHELL, LIBRARY ============ -->
<section id="A" style="display: flex; flex-direction: column; gap: 12px;">
<div style="display: flex; align-items: baseline; gap: 12px;">
<span style="font-size: 12px; font-weight: 700; color: #fff; background: var(--text-heading); padding: 3px 9px; border-radius: 6px;">A</span>
<h2 style="margin: 0; font-size: 17px; font-weight: 600; color: var(--text-heading);">Библиотека знаний — основной вид</h2>
<span style="font-size: 12.5px; color: var(--text-tertiary);">дерево категорий · поиск и фильтры · таблица с областью доступности</span>
</div>
<div style="width: 1340px; height: 860px; background: var(--surface-page); border: 1px solid var(--n-6); border-radius: var(--radius-2xl); box-shadow: var(--shadow-md); overflow: hidden; display: flex;">
<!-- SIDEBAR -->
<aside style="width: 248px; flex: none; background: #fff; border-right: 1px solid var(--n-8); display: flex; flex-direction: column;">
<div style="height: 56px; flex: none; display: flex; align-items: center; gap: 11px; padding: 0 20px; border-bottom: 1px solid var(--n-9);">
<img src="assets/chatballs-mark.svg" alt="Chatballs" style="width: 30px; height: 30px; border-radius: 8px; flex: none;">
<div style="display: flex; flex-direction: column; line-height: 1.15;">
<span style="font-size: 14px; font-weight: 600; color: var(--text-heading); letter-spacing: -0.01em;">Chatballs</span>
<span style="font-size: 11px; color: var(--text-tertiary);">Управление компанией</span>
</div>
</div>
<nav style="flex: 1; overflow-y: auto; padding: 12px 12px 8px;">
<a href="#A" style="display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: 8px; margin-bottom: 2px; font-size: 13.5px; font-weight: 500; color: var(--text-secondary);"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.4"/><rect x="14" y="3" width="7" height="5" rx="1.4"/><rect x="14" y="12" width="7" height="9" rx="1.4"/><rect x="3" y="16" width="7" height="5" rx="1.4"/></svg>Обзор</a>
<div style="font-size: 11px; font-weight: 600; letter-spacing: 0.06em; color: var(--n-5); padding: 14px 12px 6px;">КОМПАНИЯ</div>
<a href="#A" style="display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: 8px; margin-bottom: 2px; font-size: 13.5px; font-weight: 500; color: var(--text-secondary);"><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="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 8h1.5M13.5 8H15M9 12h1.5M13.5 12H15M9 16h1.5M13.5 16H15"/></svg>Отделы</a>
<a href="#A" style="display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: 8px; margin-bottom: 2px; font-size: 13.5px; font-weight: 500; color: var(--text-secondary);"><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="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Сотрудники</a>
<a href="#A" style="display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: 8px; margin-bottom: 2px; font-size: 13.5px; font-weight: 500; color: var(--text-secondary);"><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="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>Продукты</a>
<div style="font-size: 11px; font-weight: 600; letter-spacing: 0.06em; color: var(--n-5); padding: 14px 12px 6px;">ПЛАТФОРМА</div>
<a href="#A" style="display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: 8px; margin-bottom: 2px; position: relative; font-size: 13.5px; font-weight: 600; background: var(--primary-bg); color: #0958d9;"><span style="position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--primary);"></span><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2.5"/><path d="M12 8V4.5"/><circle cx="12" cy="3.5" r="1.2"/><circle cx="9" cy="13.5" r="1"/><circle cx="15" cy="13.5" r="1"/></svg>AI</a>
<div style="display: flex; flex-direction: column; padding: 2px 0 2px 30px; gap: 2px;">
<a href="#A" style="padding: 6px 12px; border-radius: 7px; font-size: 13px; color: var(--text-secondary);">AI-агенты</a>
<a href="#A" style="padding: 6px 12px; border-radius: 7px; font-size: 13px; font-weight: 600; color: #0958d9; background: var(--primary-bg);">Знания</a>
<a href="#A" style="padding: 6px 12px; border-radius: 7px; font-size: 13px; color: var(--text-secondary);">Использование AI</a>
</div>
<a href="#A" style="display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: 8px; margin-bottom: 2px; font-size: 13.5px; font-weight: 500; color: var(--text-secondary);"><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="M9 8V2.5M15 8V2.5M18 8v5.5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/><path d="M12 17.5V22"/></svg>Интеграции</a>
<div style="height: 1px; background: var(--n-8); margin: 10px 12px;"></div>
<span style="display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: 8px; font-size: 13.5px; font-weight: 500; color: var(--n-5); cursor: not-allowed;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="6" x2="9" y2="6"/><line x1="3" y1="6" x2="5" y2="6"/><circle cx="7" cy="6" r="2"/><line x1="21" y1="12" x2="13" y2="12"/><line x1="3" y1="12" x2="9" y2="12"/><circle cx="11" cy="12" r="2"/><line x1="21" y1="18" x2="15" y2="18"/><line x1="3" y1="18" x2="11" y2="18"/><circle cx="13" cy="18" r="2"/></svg>Администрирование</span>
</nav>
<div style="flex: none; border-top: 1px solid var(--n-9); padding: 10px 12px;">
<div style="display: flex; align-items: center; gap: 11px; padding: 8px 10px; border-radius: 8px;">
<div style="width: 34px; height: 34px; border-radius: 50%; background: var(--ai-bg); color: var(--ai); display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 13px; flex: none;">ИП</div>
<div style="display: flex; flex-direction: column; line-height: 1.2; flex: 1;">
<span style="font-size: 13px; font-weight: 600;">Иван Петров</span>
<span style="font-size: 11px; color: var(--text-tertiary);">OWNER</span>
</div>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--text-tertiary)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</div>
</div>
</aside>
<!-- MAIN -->
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column;">
<header style="height: 56px; flex: none; background: #fff; border-bottom: 1px solid var(--n-8); display: flex; align-items: center; justify-content: space-between; padding: 0 24px;">
<div style="display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-tertiary);">
<span style="font-weight: 500; color: var(--text-secondary);">ООО «Эдевс»</span><span style="color: var(--n-6);">/</span>
<span style="font-weight: 500; color: var(--text-secondary);">AI</span><span style="color: var(--n-6);">/</span>
<span style="font-weight: 600; color: var(--text-body);">Знания</span>
</div>
<div style="display: flex; align-items: center; gap: 14px;">
<div style="position: relative; width: 36px; height: 36px; border-radius: 8px; border: 1px solid var(--n-8); background: #fff; display: flex; align-items: center; justify-content: center; color: var(--text-secondary);"><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="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg><b style="position: absolute; top: -5px; right: -5px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--error); color: #fff; font-size: 10px; font-weight: 600; display: flex; align-items: center; justify-content: center; border: 1.5px solid #fff;">3</b></div>
</div>
</header>
<main class="kb-scroll" style="flex: 1; overflow-y: auto; padding: 22px 26px 32px;">
<!-- Page header -->
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 18px;">
<div>
<h1 style="margin: 0 0 4px; font-size: 24px; font-weight: 700; letter-spacing: -0.02em; color: var(--text-heading);">Знания</h1>
<p style="margin: 0; font-size: 13.5px; color: var(--text-secondary);">Иерархическая библиотека · категория задаёт размещение, область доступности — какие отделы могут использовать знание</p>
</div>
<div style="display: flex; gap: 10px; flex: none;">
<button style="display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px; border-radius: var(--radius-lg); border: 1px solid var(--n-6); background: #fff; font-size: 13px; font-weight: 600; color: var(--text-body); cursor: pointer;"><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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Импорт YAML</button>
<button style="display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px; border-radius: var(--radius-lg); border: 1px solid transparent; background: var(--primary); color: #fff; font-size: 13px; font-weight: 600; box-shadow: var(--shadow-primary); cursor: pointer;"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Создать знание</button>
</div>
</div>
<!-- Two-pane: tree + list -->
<div style="display: grid; grid-template-columns: 288px 1fr; gap: 18px; align-items: start;">
<!-- CATEGORY TREE -->
<div style="background: #fff; border: 1px solid var(--n-8); border-radius: var(--radius-2xl); box-shadow: var(--shadow-sm); overflow: hidden;">
<div style="display: flex; align-items: center; justify-content: space-between; padding: 13px 14px; border-bottom: 1px solid var(--n-8);">
<span style="font-size: 11px; font-weight: 600; letter-spacing: 0.06em; color: var(--text-tertiary);">КАТЕГОРИИ</span>
<button style="display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 8px; border-radius: 7px; border: 1px solid var(--n-7); background: #fff; font-size: 12px; font-weight: 600; color: var(--text-secondary); cursor: pointer;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Управление</button>
</div>
<div style="padding: 8px 8px 12px;">
<!-- All -->
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; background: var(--primary-bg); color: #0958d9; font-weight: 600; font-size: 13px; margin-bottom: 2px; cursor: pointer;">
<span style="position: relative; left: 0; top: 0; width: 3px; height: 15px; border-radius: 3px; background: var(--primary);"></span>
<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="M4 20a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2Z"/></svg>
<span style="flex: 1;">Все знания</span>
<span style="font-size: 12px; font-weight: 600; color: #0958d9;">55</span>
</div>
<!-- Без категории (system) -->
<div style="display: flex; align-items: center; gap: 8px; padding: 7px 10px 7px 13px; border-radius: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer;">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--n-5)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2Z"/></svg>
<span style="flex: 1;">Без категории</span>
<span style="font-size: 10px; font-weight: 600; letter-spacing: 0.04em; color: var(--n-5); background: var(--n-9); padding: 1px 6px; border-radius: var(--radius-pill);">СИСТ.</span>
<span style="font-size: 12px; color: var(--text-tertiary); min-width: 16px; text-align: right;">4</span>
</div>
<!-- Продукты (expanded) -->
<div style="display: flex; align-items: center; gap: 6px; padding: 7px 10px; border-radius: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer;">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<span style="flex: 1; font-weight: 500;">Продукты</span>
<span style="font-size: 12px; color: var(--text-tertiary);">14</span>
</div>
<!-- FoxRay -->
<div style="display: flex; align-items: center; gap: 6px; padding: 7px 10px 7px 26px; border-radius: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer;">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<span style="flex: 1;">FoxRay</span>
<span style="width: 6px; height: 6px; border-radius: 50%; background: var(--product-foxray);"></span>
<span style="font-size: 12px; color: var(--text-tertiary);">8</span>
</div>
<div style="display: flex; align-items: center; gap: 6px; padding: 6px 10px 6px 46px; border-radius: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer;"><span style="flex: 1;">Лицензирование</span><span style="font-size: 12px; color: var(--text-tertiary);">3</span></div>
<div style="display: flex; align-items: center; gap: 6px; padding: 6px 10px 6px 46px; border-radius: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer;"><span style="flex: 1;">Клинические кейсы</span><span style="font-size: 12px; color: var(--text-tertiary);">5</span></div>
<!-- FirePage -->
<div style="display: flex; align-items: center; gap: 6px; padding: 7px 10px 7px 26px; border-radius: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer;">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<span style="flex: 1;">FirePage</span>
<span style="width: 6px; height: 6px; border-radius: 50%; background: var(--product-firepage);"></span>
<span style="font-size: 12px; color: var(--text-tertiary);">6</span>
</div>
<!-- Продажи -->
<div style="display: flex; align-items: center; gap: 6px; padding: 7px 10px; border-radius: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer;">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<span style="flex: 1; font-weight: 500;">Продажи</span>
<span style="width: 6px; height: 6px; border-radius: 50%; background: var(--primary);"></span>
<span style="font-size: 12px; color: var(--text-tertiary);">9</span>
</div>
<!-- Поддержка -->
<div style="display: flex; align-items: center; gap: 6px; padding: 7px 10px; border-radius: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer;">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<span style="flex: 1; font-weight: 500;">Поддержка</span>
<span style="width: 6px; height: 6px; border-radius: 50%; background: var(--channel-webchat);"></span>
<span style="font-size: 12px; color: var(--text-tertiary);">14</span>
</div>
<!-- Общие -->
<div style="display: flex; align-items: center; gap: 8px; padding: 7px 10px 7px 13px; border-radius: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer;">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--text-tertiary)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2Z"/></svg>
<span style="flex: 1;">Общие</span>
<span style="font-size: 12px; color: var(--text-tertiary);">5</span>
</div>
</div>
</div>
<!-- LIST SIDE -->
<div style="display: flex; flex-direction: column; gap: 12px;">
<!-- Toolbar: search + filters -->
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<div style="flex: 1; min-width: 240px; display: flex; align-items: center; gap: 9px; height: 36px; padding: 0 13px; border: 1px solid var(--border-input); border-radius: var(--radius-lg); background: #fff;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--text-tertiary)" 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: 13.5px; color: var(--text-tertiary);">Поиск по заголовку и описанию</span>
</div>
<div style="display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 12px; border: 1px solid var(--n-6); border-radius: var(--radius-lg); background: #fff; font-size: 13px; color: var(--text-secondary); cursor: pointer;">Отдел: <b style="color: var(--text-body);">Все</b><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></div>
<div style="display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 12px; border: 1px solid var(--n-6); border-radius: var(--radius-lg); background: #fff; font-size: 13px; color: var(--text-secondary); cursor: pointer;">Доступность: <b style="color: var(--text-body);">Все</b><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></div>
<div style="display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 12px; border: 1px solid var(--n-6); border-radius: var(--radius-lg); background: #fff; font-size: 13px; color: var(--text-secondary); cursor: pointer;">Статус: <b style="color: var(--text-body);">Все</b><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></div>
</div>
<!-- Table card -->
<div style="background: #fff; border: 1px solid var(--n-8); border-radius: var(--radius-2xl); box-shadow: var(--shadow-sm); overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="background: var(--n-10); text-align: left;">
<th style="width: 38px; padding: 10px 0 10px 16px;"><span style="display: inline-block; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--n-6); background: #fff;"></span></th>
<th style="padding: 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary);">ЗНАНИЕ</th>
<th style="padding: 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary);">КАТЕГОРИЯ</th>
<th style="padding: 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary);">ДОСТУПНОСТЬ</th>
<th style="padding: 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary); text-align: right;">ВЛОЖ.</th>
<th style="padding: 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary); text-align: right;">АГЕНТЫ</th>
<th style="padding: 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary);">СТАТУС</th>
<th style="padding: 10px 16px 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary);">ОБНОВЛЕНО</th>
</tr>
</thead>
<tbody>
<!-- Row 1 -->
<tr style="border-top: 1px solid var(--n-8); cursor: pointer;">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-block; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--n-6); background: #fff;"></span></td>
<td style="padding: 12px;"><a href="#C" style="font-weight: 600; color: var(--text-body);">FoxRay — правила лицензирования</a><div style="font-size: 12px; color: var(--text-tertiary); margin-top: 2px;">Условия лицензии, продление, ограничения на клинику</div></td>
<td style="padding: 12px; color: var(--text-secondary);">FoxRay / Лицензирование</td>
<td style="padding: 12px;"><span style="display: inline-flex; gap: 5px; flex-wrap: wrap;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--n-9); font-size: 11.5px; font-weight: 600; color: var(--text-secondary);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--primary);"></span>Продажи</span><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--n-9); font-size: 11.5px; font-weight: 600; color: var(--text-secondary);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--channel-webchat);"></span>Поддержка</span></span></td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">2</td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">3</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">14 июл</td>
</tr>
<!-- Row 2 -->
<tr style="border-top: 1px solid var(--n-8); cursor: pointer;">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-block; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--n-6); background: #fff;"></span></td>
<td style="padding: 12px;"><a href="#C" style="font-weight: 600; color: var(--text-body);">FoxRay — интерпретация ТРГ</a><div style="font-size: 12px; color: var(--text-tertiary); margin-top: 2px;">Разбор частых артефактов и трассировки</div></td>
<td style="padding: 12px; color: var(--text-secondary);">FoxRay / Клинические кейсы</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--n-9); font-size: 11.5px; font-weight: 600; color: var(--text-secondary);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--channel-webchat);"></span>Поддержка</span></td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">1</td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">2</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">12 июл</td>
</tr>
<!-- Row 3 -->
<tr style="border-top: 1px solid var(--n-8); cursor: pointer;">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-block; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--n-6); background: #fff;"></span></td>
<td style="padding: 12px;"><a href="#C" style="font-weight: 600; color: var(--text-body);">FirePage — тарифы и лицензия</a><div style="font-size: 12px; color: var(--text-tertiary); margin-top: 2px;">Стоимость разовой лицензии, что входит</div></td>
<td style="padding: 12px; color: var(--text-secondary);">FirePage / Тарифы</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--primary-bg); font-size: 11.5px; font-weight: 600; color: #0958d9;">Организация</span></td>
<td style="padding: 12px; text-align: right; color: var(--n-5); font-variant-numeric: tabular-nums;">—</td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">4</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">15 июл</td>
</tr>
<!-- Row 4 -->
<tr style="border-top: 1px solid var(--n-8); cursor: pointer;">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-block; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--n-6); background: #fff;"></span></td>
<td style="padding: 12px;"><a href="#C" style="font-weight: 600; color: var(--text-body);">FirePage — установка и настройка CMS</a><div style="font-size: 12px; color: var(--text-tertiary); margin-top: 2px;">Пошаговая инструкция по развёртыванию</div></td>
<td style="padding: 12px; color: var(--text-secondary);">FirePage / CMS и шаблоны</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--n-9); font-size: 11.5px; font-weight: 600; color: var(--text-secondary);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--channel-webchat);"></span>Поддержка</span></td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">3</td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">1</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">10 июл</td>
</tr>
<!-- Row 5 -->
<tr style="border-top: 1px solid var(--n-8); cursor: pointer;">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-block; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--n-6); background: #fff;"></span></td>
<td style="padding: 12px;"><a href="#C" style="font-weight: 600; color: var(--text-body);">Скрипт первичного контакта</a><div style="font-size: 12px; color: var(--text-tertiary); margin-top: 2px;">Как начать диалог и квалифицировать запрос</div></td>
<td style="padding: 12px; color: var(--text-secondary);">Продажи / Скрипты</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--n-9); font-size: 11.5px; font-weight: 600; color: var(--text-secondary);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--primary);"></span>Продажи</span></td>
<td style="padding: 12px; text-align: right; color: var(--n-5); font-variant-numeric: tabular-nums;">—</td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">2</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">13 июл</td>
</tr>
<!-- Row 6 disabled -->
<tr style="border-top: 1px solid var(--n-8); cursor: pointer; background: #fcfcfc;">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-block; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--n-6); background: #fff;"></span></td>
<td style="padding: 12px;"><a href="#C" style="font-weight: 600; color: var(--text-tertiary);">Работа с возражением «дорого»</a><div style="font-size: 12px; color: var(--n-5); margin-top: 2px;">Черновик · не используется агентами</div></td>
<td style="padding: 12px; color: var(--text-tertiary);">Продажи / Скрипты</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--n-9); font-size: 11.5px; font-weight: 600; color: var(--n-5);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--primary); opacity: .5;"></span>Продажи</span></td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">1</td>
<td style="padding: 12px; text-align: right; color: var(--n-5); font-variant-numeric: tabular-nums;">—</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-tertiary);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--n-5);"></span>Выключено</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">09 июл</td>
</tr>
<!-- Row 7 -->
<tr style="border-top: 1px solid var(--n-8); cursor: pointer;">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-block; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--n-6); background: #fff;"></span></td>
<td style="padding: 12px;"><a href="#C" style="font-weight: 600; color: var(--text-body);">Возврат и гарантия</a><div style="font-size: 12px; color: var(--text-tertiary); margin-top: 2px;">Общая политика для всех продуктов</div></td>
<td style="padding: 12px; color: var(--text-secondary);">Общие</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--primary-bg); font-size: 11.5px; font-weight: 600; color: #0958d9;">Организация</span></td>
<td style="padding: 12px; text-align: right; color: var(--n-5); font-variant-numeric: tabular-nums;">—</td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">6</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">15 июл</td>
</tr>
<!-- Row 8 -->
<tr style="border-top: 1px solid var(--n-8); cursor: pointer;">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-block; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--n-6); background: #fff;"></span></td>
<td style="padding: 12px;"><a href="#C" style="font-weight: 600; color: var(--text-body);">Реквизиты и договор оферты</a><div style="font-size: 12px; color: var(--text-tertiary); margin-top: 2px;">ИНН, ОГРН, публичная оферта</div></td>
<td style="padding: 12px; color: var(--text-tertiary);">Без категории</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--primary-bg); font-size: 11.5px; font-weight: 600; color: #0958d9;">Организация</span></td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">1</td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary); font-variant-numeric: tabular-nums;">5</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">08 июл</td>
</tr>
</tbody>
</table>
<div style="display: flex; align-items: center; justify-content: space-between; padding: 11px 16px; border-top: 1px solid var(--n-8); font-size: 12.5px; color: var(--text-tertiary);">
<span>8 из 55 знаний</span>
<span>Изменения знаний применяются к агентам сразу</span>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
</section>
<!-- ============ FRAME B — BULK SELECTION ============ -->
<section id="B" style="display: flex; flex-direction: column; gap: 12px;">
<div style="display: flex; align-items: baseline; gap: 12px;">
<span style="font-size: 12px; font-weight: 700; color: #fff; background: var(--text-heading); padding: 3px 9px; border-radius: 6px;">B</span>
<h2 style="margin: 0; font-size: 17px; font-weight: 600; color: var(--text-heading);">Массовый выбор — панель операций</h2>
<span style="font-size: 12.5px; color: var(--text-tertiary);">выбор нескольких знаний · переместить / изменить доступность / заменить отделы (атомарно)</span>
</div>
<div style="width: 1180px; background: var(--surface-page); border: 1px solid var(--n-6); border-radius: var(--radius-2xl); box-shadow: var(--shadow-md); overflow: hidden; padding: 22px 24px 26px;">
<!-- Selection bar replaces the toolbar -->
<div style="display: flex; align-items: center; gap: 12px; height: 46px; padding: 0 10px 0 14px; background: var(--primary-bg); border: 1px solid var(--primary-border); border-radius: var(--radius-lg); margin-bottom: 12px;">
<span style="display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 5px; background: var(--primary);"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<b style="font-size: 13.5px; color: #0958d9;">Выбрано 3 знания</b>
<span style="width: 1px; height: 22px; background: var(--primary-border);"></span>
<button style="display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--primary-border); background: #fff; font-size: 12.5px; font-weight: 600; color: #0958d9; cursor: pointer;"><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="M4 20a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2Z"/></svg>Переместить в категорию</button>
<button style="display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--primary-border); background: #fff; font-size: 12.5px; font-weight: 600; color: #0958d9; cursor: pointer;"><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="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>Изменить доступность</button>
<button style="display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--primary-border); background: #fff; font-size: 12.5px; font-weight: 600; color: #0958d9; cursor: pointer;"><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="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 8h1.5M13.5 8H15M9 12h1.5M13.5 12H15"/></svg>Заменить отделы</button>
<span style="flex: 1;"></span>
<button style="height: 32px; padding: 0 12px; border-radius: 8px; border: none; background: transparent; font-size: 12.5px; font-weight: 600; color: var(--text-secondary); cursor: pointer;">Снять выбор</button>
</div>
<div style="background: #fff; border: 1px solid var(--n-8); border-radius: var(--radius-2xl); box-shadow: var(--shadow-sm); overflow: hidden;">
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="background: var(--n-10); text-align: left;">
<th style="width: 38px; padding: 10px 0 10px 16px;"><span style="display: inline-flex; align-items: center; justify-content: center; width: 17px; height: 17px; border-radius: 5px; background: var(--primary);"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12l4 4 8-9"/></svg></span></th>
<th style="padding: 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary);">ЗНАНИЕ</th>
<th style="padding: 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary);">КАТЕГОРИЯ</th>
<th style="padding: 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary);">ДОСТУПНОСТЬ</th>
<th style="padding: 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary);">СТАТУС</th>
<th style="padding: 10px 16px 10px 12px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-tertiary);">ОБНОВЛЕНО</th>
</tr>
</thead>
<tbody>
<!-- checked -->
<tr style="border-top: 1px solid var(--n-8); background: #f4f9ff;">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-flex; align-items: center; justify-content: center; width: 17px; height: 17px; border-radius: 5px; background: var(--primary);"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span></td>
<td style="padding: 12px;"><span style="font-weight: 600; color: var(--text-body);">FoxRay — правила лицензирования</span></td>
<td style="padding: 12px; color: var(--text-secondary);">FoxRay / Лицензирование</td>
<td style="padding: 12px;"><span style="display: inline-flex; gap: 5px;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--n-9); font-size: 11.5px; font-weight: 600; color: var(--text-secondary);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--primary);"></span>Продажи</span><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--n-9); font-size: 11.5px; font-weight: 600; color: var(--text-secondary);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--channel-webchat);"></span>Поддержка</span></span></td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">14 июл</td>
</tr>
<tr style="border-top: 1px solid var(--n-8); background: #f4f9ff;">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-flex; align-items: center; justify-content: center; width: 17px; height: 17px; border-radius: 5px; background: var(--primary);"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span></td>
<td style="padding: 12px;"><span style="font-weight: 600; color: var(--text-body);">FoxRay — интерпретация ТРГ</span></td>
<td style="padding: 12px; color: var(--text-secondary);">FoxRay / Клинические кейсы</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--n-9); font-size: 11.5px; font-weight: 600; color: var(--text-secondary);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--channel-webchat);"></span>Поддержка</span></td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">12 июл</td>
</tr>
<tr style="border-top: 1px solid var(--n-8);">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-block; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--n-6); background: #fff;"></span></td>
<td style="padding: 12px;"><span style="font-weight: 600; color: var(--text-body);">FirePage — тарифы и лицензия</span></td>
<td style="padding: 12px; color: var(--text-secondary);">FirePage / Тарифы</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--primary-bg); font-size: 11.5px; font-weight: 600; color: #0958d9;">Организация</span></td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">15 июл</td>
</tr>
<tr style="border-top: 1px solid var(--n-8); background: #f4f9ff;">
<td style="padding: 12px 0 12px 16px;"><span style="display: inline-flex; align-items: center; justify-content: center; width: 17px; height: 17px; border-radius: 5px; background: var(--primary);"><svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span></td>
<td style="padding: 12px;"><span style="font-weight: 600; color: var(--text-body);">Скрипт первичного контакта</span></td>
<td style="padding: 12px; color: var(--text-secondary);">Продажи / Скрипты</td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--n-9); font-size: 11.5px; font-weight: 600; color: var(--text-secondary);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--primary);"></span>Продажи</span></td>
<td style="padding: 12px;"><span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span></td>
<td style="padding: 12px 16px 12px 12px; color: var(--text-tertiary); white-space: nowrap;">13 июл</td>
</tr>
</tbody>
</table>
</div>
<p style="margin: 12px 2px 0; font-size: 12px; color: var(--text-tertiary);">Операция атомарна: при одной недопустимой записи или конфликтующем агенте ничего не изменяется. Выбор снимается только после успеха.</p>
</div>
</section>
<!-- ============ FRAME C — KNOWLEDGE CARD ============ -->
<section id="C" style="display: flex; flex-direction: column; gap: 12px;">
<div style="display: flex; align-items: baseline; gap: 12px;">
<span style="font-size: 12px; font-weight: 700; color: #fff; background: var(--text-heading); padding: 3px 9px; border-radius: 6px;">C</span>
<h2 style="margin: 0; font-size: 17px; font-weight: 600; color: var(--text-heading);">Карточка знания</h2>
<span style="font-size: 12.5px; color: var(--text-tertiary);">категория · область доступности · отделы · содержимое · вложения · агенты</span>
</div>
<div style="width: 1180px; background: var(--surface-page); border: 1px solid var(--n-6); border-radius: var(--radius-2xl); box-shadow: var(--shadow-md); overflow: hidden; padding: 22px 24px 28px;">
<!-- breadcrumb + head -->
<div style="display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-tertiary); margin-bottom: 14px;">
<a href="#A" style="color: var(--text-secondary); font-weight: 500;">Знания</a><span style="color: var(--n-6);">/</span><span style="color: var(--text-secondary);">FoxRay / Лицензирование</span><span style="color: var(--n-6);">/</span><span style="color: var(--text-body); font-weight: 600;">Правила лицензирования</span>
</div>
<div style="display: grid; grid-template-columns: 1fr 320px; gap: 18px; align-items: start;">
<!-- LEFT: form -->
<div style="display: flex; flex-direction: column; gap: 16px;">
<section style="background: #fff; border: 1px solid var(--n-8); border-radius: var(--radius-2xl); box-shadow: var(--shadow-sm); padding: 20px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
<h3 style="margin: 0; margin-right: auto; font-size: 18px; font-weight: 600; color: var(--text-heading);">Знание</h3>
<span style="display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--success-text);"><span style="width: 7px; height: 7px; border-radius: 50%; background: var(--success);"></span>Активно</span>
<span style="font-size: 12px; color: var(--text-tertiary);">обновлено 14 июл</span>
</div>
<div style="display: flex; flex-direction: column; gap: 14px;">
<label style="display: flex; flex-direction: column; gap: 5px;"><span style="font-size: 12.5px; font-weight: 500; color: var(--text-secondary);">Заголовок</span><div style="height: 42px; display: flex; align-items: center; padding: 0 13px; border: 1px solid var(--border-input); border-radius: var(--radius-lg); background: #fff; font-size: 14px; color: var(--text-body);">FoxRay — правила лицензирования</div></label>
<label style="display: flex; flex-direction: column; gap: 5px;"><span style="font-size: 12.5px; font-weight: 500; color: var(--text-secondary);">Краткое описание</span><div style="height: 42px; display: flex; align-items: center; padding: 0 13px; border: 1px solid var(--border-input); border-radius: var(--radius-lg); background: #fff; font-size: 14px; color: var(--text-body);">Условия лицензии, продление, ограничения на клинику</div></label>
<!-- category + visibility row -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 14px;">
<label style="display: flex; flex-direction: column; gap: 5px;"><span style="font-size: 12.5px; font-weight: 500; color: var(--text-secondary);">Категория</span>
<div style="height: 42px; display: flex; align-items: center; gap: 8px; padding: 0 13px; border: 1px solid var(--border-input); border-radius: var(--radius-lg); background: #fff; font-size: 14px; color: var(--text-body); cursor: pointer;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--text-tertiary)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2Z"/></svg><span style="flex: 1;">FoxRay / Лицензирование</span><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></div>
</label>
<div style="display: flex; flex-direction: column; gap: 5px;"><span style="font-size: 12.5px; font-weight: 500; color: var(--text-secondary);">Область доступности</span>
<div style="display: flex; height: 42px; border: 1px solid var(--n-6); border-radius: var(--radius-lg); background: var(--n-9); padding: 3px;">
<span style="flex: 1; display: flex; align-items: center; justify-content: center; border-radius: 7px; font-size: 13px; font-weight: 500; color: var(--text-secondary);">Организация</span>
<span style="flex: 1; display: flex; align-items: center; justify-content: center; border-radius: 7px; font-size: 13px; font-weight: 600; color: var(--text-body); background: #fff; box-shadow: var(--shadow-xs);">Отделы</span>
</div>
</div>
</div>
<!-- departments multi-select (visible because Отделы) -->
<label style="display: flex; flex-direction: column; gap: 6px;"><span style="font-size: 12.5px; font-weight: 500; color: var(--text-secondary);">Отделы <span style="color: var(--text-tertiary); font-weight: 400;">— знание доступно агентам этих отделов</span></span>
<div style="display: flex; align-items: center; gap: 7px; flex-wrap: wrap; min-height: 42px; padding: 6px 10px; border: 1px solid var(--border-input); border-radius: var(--radius-lg); background: #fff;">
<span style="display: inline-flex; align-items: center; gap: 7px; padding: 4px 8px 4px 10px; border-radius: var(--radius-pill); background: var(--primary-bg); font-size: 12.5px; font-weight: 600; color: #0958d9;"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--primary);"></span>Продажи<span style="display: inline-flex; cursor: pointer; color: #0958d9; opacity: .7;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span></span>
<span style="display: inline-flex; align-items: center; gap: 7px; padding: 4px 8px 4px 10px; border-radius: var(--radius-pill); background: var(--channel-webchat-bg); font-size: 12.5px; font-weight: 600; color: var(--channel-webchat);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--channel-webchat);"></span>Поддержка<span style="display: inline-flex; cursor: pointer; opacity: .7;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span></span>
<span style="display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: var(--radius-pill); border: 1px dashed var(--n-6); font-size: 12.5px; font-weight: 600; color: var(--text-tertiary); cursor: pointer;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Добавить отдел</span>
</div>
</label>
<label style="display: flex; flex-direction: column; gap: 6px;"><span style="font-size: 12.5px; font-weight: 500; color: var(--text-secondary);">Содержимое (Markdown)</span>
<div style="min-height: 168px; padding: 12px 13px; border: 1px solid var(--border-input); border-radius: var(--radius-lg); background: #fff; font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-body); white-space: pre-wrap;">## Лицензия FoxRay
Лицензия привязана к клинике и рабочему месту. Продление
годовое, скидка при оплате за 12 месяцев вперёд.
Ограничения: не более 3 одновременных сессий анализа ТРГ.
Подробные тарифы — см. вложение foxray-pricing.pdf.</div>
</label>
</div>
<div style="display: flex; gap: 10px; margin-top: 18px;">
<button style="display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px; border-radius: var(--radius-lg); border: 1px solid transparent; background: var(--primary); color: #fff; font-size: 13px; font-weight: 600; box-shadow: var(--shadow-primary); cursor: pointer;"><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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Сохранить</button>
<button style="display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px; border-radius: var(--radius-lg); border: 1px solid var(--n-6); background: #fff; font-size: 13px; font-weight: 600; color: var(--text-body); cursor: pointer;"><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="M9.9 4.2A9.1 9.1 0 0 1 12 4c7 0 10 8 10 8a13 13 0 0 1-1.7 2.7M6.6 6.6A13 13 0 0 0 2 12s3 8 10 8a9 9 0 0 0 3.4-.6"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/><line x1="2" y1="2" x2="22" y2="22"/></svg>Выключить</button>
<span style="flex: 1;"></span>
<button style="display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px; border-radius: var(--radius-lg); border: 1px solid var(--n-6); background: #fff; font-size: 13px; font-weight: 600; color: var(--error-text); cursor: pointer;"><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="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0-1 14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2L4 6"/></svg>Удалить</button>
</div>
</section>
<!-- attachments -->
<section style="background: #fff; border: 1px solid var(--n-8); border-radius: var(--radius-2xl); box-shadow: var(--shadow-sm); padding: 20px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<h3 style="margin: 0; margin-right: auto; font-size: 15px; font-weight: 600; color: var(--text-heading);">Вложения</h3>
<button style="display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 12px; border-radius: var(--radius-lg); border: 1px solid var(--n-6); background: #fff; font-size: 12.5px; font-weight: 600; color: var(--text-body); cursor: pointer;"><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="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95L9.88 18.5a1.5 1.5 0 0 1-2.12-2.12l8.49-8.49"/></svg>Загрузить файл</button>
</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; align-items: center; gap: 9px; padding: 9px 11px; border: 1px solid var(--n-8); border-radius: var(--radius-md); color: var(--text-secondary);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--text-tertiary)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95L9.88 18.5a1.5 1.5 0 0 1-2.12-2.12l8.49-8.49"/></svg><a href="#C" style="font-weight: 600;">foxray-pricing.pdf</a><small style="margin-right: auto; color: var(--text-tertiary);">210 КБ · текст в поиске</small><span style="color: var(--text-tertiary); cursor: pointer;"><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="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0-1 14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2L4 6"/></svg></span></div>
<div style="display: flex; align-items: center; gap: 9px; padding: 9px 11px; border: 1px solid var(--n-8); border-radius: var(--radius-md); color: var(--text-secondary);"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--text-tertiary)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95L9.88 18.5a1.5 1.5 0 0 1-2.12-2.12l8.49-8.49"/></svg><a href="#C" style="font-weight: 600;">license-terms.docx</a><small style="margin-right: auto; color: var(--text-tertiary);">130 КБ · текст в поиске</small><span style="color: var(--text-tertiary); cursor: pointer;"><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="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0-1 14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2L4 6"/></svg></span></div>
</div>
</section>
</div>
<!-- RIGHT rail -->
<div style="display: flex; flex-direction: column; gap: 16px;">
<section style="background: #fff; border: 1px solid var(--n-8); border-radius: var(--radius-2xl); box-shadow: var(--shadow-sm); padding: 18px;">
<h4 style="margin: 0 0 4px; font-size: 13px; font-weight: 600; color: var(--text-heading);">Агенты, использующие знание</h4>
<p style="margin: 0 0 14px; font-size: 11.5px; color: var(--text-tertiary); line-height: 1.5;">Выбор знания хранится у агента явно. Смена области может затронуть этих агентов.</p>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div style="display: flex; align-items: center; gap: 10px;"><span style="width: 30px; height: 30px; border-radius: 8px; background: var(--ai-bg); color: var(--ai); 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.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2.5"/><path d="M12 8V4.5"/><circle cx="12" cy="3.5" r="1.2"/><circle cx="9" cy="13.5" r="1"/><circle cx="15" cy="13.5" r="1"/></svg></span><div style="flex: 1; line-height: 1.3;"><div style="font-size: 13px; font-weight: 600; color: var(--text-body);">Продажник FoxRay</div><div style="font-size: 11.5px; color: var(--text-tertiary);">Web Chat · Продажи</div></div></div>
<div style="display: flex; align-items: center; gap: 10px;"><span style="width: 30px; height: 30px; border-radius: 8px; background: var(--ai-bg); color: var(--ai); 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.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2.5"/><path d="M12 8V4.5"/><circle cx="12" cy="3.5" r="1.2"/><circle cx="9" cy="13.5" r="1"/><circle cx="15" cy="13.5" r="1"/></svg></span><div style="flex: 1; line-height: 1.3;"><div style="font-size: 13px; font-weight: 600; color: var(--text-body);">Поддержка FoxRay</div><div style="font-size: 11.5px; color: var(--text-tertiary);">Telegram · Поддержка</div></div></div>
<div style="display: flex; align-items: center; gap: 10px;"><span style="width: 30px; height: 30px; border-radius: 8px; background: var(--ai-bg); color: var(--ai); 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.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2.5"/><path d="M12 8V4.5"/><circle cx="12" cy="3.5" r="1.2"/><circle cx="9" cy="13.5" r="1"/><circle cx="15" cy="13.5" r="1"/></svg></span><div style="flex: 1; line-height: 1.3;"><div style="font-size: 13px; font-weight: 600; color: var(--text-body);">MAX-консультант</div><div style="font-size: 11.5px; color: var(--text-tertiary);">MAX · Продажи</div></div></div>
</div>
</section>
<section style="background: #fff; border: 1px solid var(--n-8); border-radius: var(--radius-2xl); box-shadow: var(--shadow-sm); padding: 18px;">
<h4 style="margin: 0 0 12px; font-size: 13px; font-weight: 600; color: var(--text-heading);">Сведения</h4>
<div style="display: flex; flex-direction: column; gap: 10px; font-size: 12.5px;">
<div style="display: flex; justify-content: space-between;"><span style="color: var(--text-tertiary);">ID</span><span style="font-family: var(--font-mono); color: var(--text-body);">KN-00042</span></div>
<div style="display: flex; justify-content: space-between;"><span style="color: var(--text-tertiary);">Фрагментов в индексе</span><span style="color: var(--text-body);">18</span></div>
<div style="display: flex; justify-content: space-between;"><span style="color: var(--text-tertiary);">Создано</span><span style="color: var(--text-body);">02 июл 2026</span></div>
<div style="display: flex; justify-content: space-between;"><span style="color: var(--text-tertiary);">Автор</span><span style="color: var(--text-body);">Иван Петров</span></div>
</div>
</section>
</div>
</div>
</div>
</section>
<!-- ============ FRAME D — CATEGORY MANAGEMENT ============ -->
<section id="D" style="display: flex; flex-direction: column; gap: 12px;">
<div style="display: flex; align-items: baseline; gap: 12px;">
<span style="font-size: 12px; font-weight: 700; color: #fff; background: var(--text-heading); padding: 3px 9px; border-radius: 6px;">D</span>
<h2 style="margin: 0; font-size: 17px; font-weight: 600; color: var(--text-heading);">Управление категориями</h2>
<span style="font-size: 12.5px; color: var(--text-tertiary);">создать · переименовать (инлайн) · переместить (перетаскивание) · безопасное удаление</span>
</div>
<div style="width: 1180px; height: 640px; position: relative; background: var(--surface-page); border: 1px solid var(--n-6); border-radius: var(--radius-2xl); box-shadow: var(--shadow-md); overflow: hidden;">
<div style="position: absolute; inset: 0; background: rgba(15,20,30,0.28);"></div>
<!-- Modal -->
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 560px; background: #fff; border-radius: var(--radius-4xl); box-shadow: var(--shadow-lg); overflow: hidden;">
<div style="display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid var(--n-8);">
<div><h3 style="margin: 0 0 2px; font-size: 16px; font-weight: 600; color: var(--text-heading);">Управление категориями</h3><p style="margin: 0; font-size: 12px; color: var(--text-tertiary);">Категория определяет только размещение, не доступ</p></div>
<span style="width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: var(--text-tertiary); cursor: pointer;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
</div>
<div style="padding: 12px 12px 8px; max-height: 400px; overflow-y: auto;">
<!-- system -->
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; font-size: 13px; color: var(--text-secondary);">
<span style="width: 14px;"></span>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--n-5)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2Z"/></svg>
<span style="flex: 1;">Без категории</span>
<span style="font-size: 10px; font-weight: 600; color: var(--n-5); background: var(--n-9); padding: 1px 6px; border-radius: var(--radius-pill);">СИСТ.</span>
<span style="width: 26px; height: 26px; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: var(--n-5); cursor: not-allowed;" title="Системная категория — нельзя изменить"><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="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0-1 14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2L4 6"/></svg></span>
</div>
<!-- Продукты with hover actions -->
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; font-size: 13px; color: var(--text-body); background: var(--n-9);">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--n-5)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></svg>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<span style="flex: 1; font-weight: 500;">Продукты</span>
<span style="font-size: 11.5px; color: var(--text-tertiary); margin-right: 4px;">14</span>
<span style="width: 26px; height: 26px; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); background: #fff; border: 1px solid var(--n-7); cursor: pointer;"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></span>
<span style="width: 26px; height: 26px; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); background: #fff; border: 1px solid var(--n-7); cursor: pointer;"><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="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg></span>
<span style="width: 26px; height: 26px; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: var(--n-5); cursor: not-allowed;" title="Нельзя удалить: есть вложенные категории"><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="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0-1 14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2L4 6"/></svg></span>
</div>
<!-- FoxRay (child) -->
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 10px 8px 30px; border-radius: 8px; font-size: 13px; color: var(--text-secondary);">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
<span style="flex: 1;">FoxRay</span>
<span style="width: 6px; height: 6px; border-radius: 50%; background: var(--product-foxray);"></span>
<span style="font-size: 11.5px; color: var(--text-tertiary);">8</span>
</div>
<!-- Лицензирование in RENAME mode -->
<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px 6px 50px; border-radius: 8px;">
<div style="flex: 1; display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px; border: 1px solid var(--primary); border-radius: 8px; background: #fff; box-shadow: 0 0 0 3px var(--primary-bg);"><span style="flex: 1; font-size: 13px; color: var(--text-body);">Лицензирование<span style="display: inline-block; width: 1px; height: 15px; background: var(--primary); vertical-align: middle; margin-left: 1px;"></span></span></div>
<button style="height: 32px; padding: 0 12px; border-radius: 8px; border: 1px solid transparent; background: var(--primary); color: #fff; font-size: 12.5px; font-weight: 600; cursor: pointer;">Сохранить</button>
<button style="height: 32px; padding: 0 10px; border-radius: 8px; border: 1px solid var(--n-6); background: #fff; font-size: 12.5px; font-weight: 600; color: var(--text-secondary); cursor: pointer;">Отмена</button>
</div>
<!-- Клинические кейсы (empty leaf, deletable) -->
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 10px 8px 50px; border-radius: 8px; font-size: 13px; color: var(--text-secondary);">
<span style="flex: 1;">Клинические кейсы</span>
<span style="font-size: 11.5px; color: var(--text-tertiary);">5</span>
</div>
<!-- Продажи -->
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; font-size: 13px; color: var(--text-secondary);">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<span style="flex: 1; font-weight: 500;">Продажи</span>
<span style="width: 6px; height: 6px; border-radius: 50%; background: var(--primary);"></span>
<span style="font-size: 11.5px; color: var(--text-tertiary);">9</span>
</div>
<!-- Поддержка -->
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; font-size: 13px; color: var(--text-secondary);">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--text-tertiary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<span style="flex: 1; font-weight: 500;">Поддержка</span>
<span style="width: 6px; height: 6px; border-radius: 50%; background: var(--channel-webchat);"></span>
<span style="font-size: 11.5px; color: var(--text-tertiary);">14</span>
</div>
<!-- drop indicator hint -->
<div style="height: 2px; background: var(--primary); border-radius: 2px; margin: 2px 10px;"></div>
<!-- Общие -->
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; font-size: 13px; color: var(--text-secondary); opacity: .7;">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="var(--text-tertiary)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2Z"/></svg>
<span style="flex: 1;">Общие</span>
</div>
</div>
<div style="display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--n-8);">
<button style="display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px; border-radius: var(--radius-lg); border: 1px solid var(--n-6); background: #fff; font-size: 13px; font-weight: 600; color: var(--text-body); cursor: pointer;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Создать категорию</button>
<span style="flex: 1;"></span>
<button style="height: 36px; padding: 0 18px; border-radius: var(--radius-lg); border: 1px solid transparent; background: var(--primary); color: #fff; font-size: 13px; font-weight: 600; box-shadow: var(--shadow-primary); cursor: pointer;">Готово</button>
</div>
</div>
</div>
</section>
<!-- ============ FRAME E — CONFLICT ============ -->
<section id="E" style="display: flex; flex-direction: column; gap: 12px;">
<div style="display: flex; align-items: baseline; gap: 12px;">
<span style="font-size: 12px; font-weight: 700; color: #fff; background: var(--text-heading); padding: 3px 9px; border-radius: 6px;">E</span>
<h2 style="margin: 0; font-size: 17px; font-weight: 600; color: var(--text-heading);">Конфликт при смене доступности</h2>
<span style="font-size: 12.5px; color: var(--text-tertiary);">операция блокируется · список затронутых агентов · переход к явной правке</span>
</div>
<div style="width: 1180px; height: 600px; position: relative; background: var(--surface-page); border: 1px solid var(--n-6); border-radius: var(--radius-2xl); box-shadow: var(--shadow-md); overflow: hidden;">
<div style="position: absolute; inset: 0; background: rgba(15,20,30,0.28);"></div>
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 580px; background: #fff; border-radius: var(--radius-4xl); box-shadow: var(--shadow-lg); overflow: hidden;">
<div style="padding: 22px 24px 18px; border-bottom: 1px solid var(--n-8); display: flex; gap: 14px;">
<span style="flex: none; width: 40px; height: 40px; border-radius: 10px; background: var(--warning-bg); color: var(--warning-text); 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="m21.7 18-8-14a2 2 0 0 0-3.5 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
<div>
<h3 style="margin: 0 0 5px; font-size: 16px; font-weight: 600; color: var(--text-heading);">Нельзя ограничить доступность до отделов</h3>
<p style="margin: 0; font-size: 13px; line-height: 1.55; color: var(--text-secondary);">Знание «FoxRay — правила лицензирования» используют агенты вне отделов <b>Продажи</b> и <b>Поддержка</b>. Смена области отключила бы у них это знание. Изменение заблокировано — сначала скорректируйте наборы знаний этих агентов.</p>
</div>
</div>
<div style="padding: 16px 24px;">
<div style="font-size: 11px; font-weight: 600; letter-spacing: 0.05em; color: var(--text-tertiary); margin-bottom: 10px;">КОНФЛИКТУЮЩИЕ АГЕНТЫ · 2</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid var(--n-8); border-radius: var(--radius-lg);">
<span style="width: 32px; height: 32px; border-radius: 8px; background: var(--ai-bg); color: var(--ai); display: flex; align-items: center; justify-content: center;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2.5"/><path d="M12 8V4.5"/><circle cx="12" cy="3.5" r="1.2"/><circle cx="9" cy="13.5" r="1"/><circle cx="15" cy="13.5" r="1"/></svg></span>
<div style="flex: 1; line-height: 1.35;"><div style="font-size: 13.5px; font-weight: 600; color: var(--text-body);">MAX-консультант</div><div style="font-size: 12px; color: var(--text-tertiary);">MAX · без отдела · <span style="font-family: var(--font-mono);">AG-0007</span></div></div>
<a href="#C" style="display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; font-weight: 600; color: var(--primary);">Открыть<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></a>
</div>
<div style="display: flex; align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid var(--n-8); border-radius: var(--radius-lg);">
<span style="width: 32px; height: 32px; border-radius: 8px; background: var(--ai-bg); color: var(--ai); display: flex; align-items: center; justify-content: center;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2.5"/><path d="M12 8V4.5"/><circle cx="12" cy="3.5" r="1.2"/><circle cx="9" cy="13.5" r="1"/><circle cx="15" cy="13.5" r="1"/></svg></span>
<div style="flex: 1; line-height: 1.35;"><div style="font-size: 13.5px; font-weight: 600; color: var(--text-body);">Маркетинг-бот</div><div style="font-size: 12px; color: var(--text-tertiary);">Web Chat · Маркетинг · <span style="font-family: var(--font-mono);">AG-0011</span></div></div>
<a href="#C" style="display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; font-weight: 600; color: var(--primary);">Открыть<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></a>
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 10px; padding: 14px 24px; border-top: 1px solid var(--n-8);">
<span style="flex: 1;"></span>
<button style="height: 36px; padding: 0 16px; border-radius: var(--radius-lg); border: 1px solid var(--n-6); background: #fff; font-size: 13px; font-weight: 600; color: var(--text-body); cursor: pointer;">Отмена</button>
<button style="height: 36px; padding: 0 16px; border-radius: var(--radius-lg); border: 1px solid transparent; background: var(--primary); color: #fff; font-size: 13px; font-weight: 600; box-shadow: var(--shadow-primary); cursor: pointer;">Перейти к правке агентов</button>
</div>
</div>
</div>
</section>
<!-- ============ STATES STRIP ============ -->
<section id="S" style="display: flex; flex-direction: column; gap: 12px;">
<div style="display: flex; align-items: baseline; gap: 12px;">
<span style="font-size: 12px; font-weight: 700; color: #fff; background: var(--text-heading); padding: 3px 9px; border-radius: 6px;">S</span>
<h2 style="margin: 0; font-size: 17px; font-weight: 600; color: var(--text-heading);">Служебные состояния</h2>
<span style="font-size: 12.5px; color: var(--text-tertiary);">пустая категория · ограниченный доступ · выключенное выбранное знание</span>
</div>
<div style="display: flex; gap: 18px; flex-wrap: wrap;">
<!-- empty -->
<div style="width: 372px; background: #fff; border: 1px solid var(--n-8); border-radius: var(--radius-2xl); box-shadow: var(--shadow-sm); padding: 40px 24px; text-align: center;">
<div style="width: 46px; height: 46px; border-radius: 12px; background: var(--n-9); color: var(--n-5); display: flex; align-items: center; justify-content: center; margin: 0 auto 14px;"><svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2Z"/></svg></div>
<div style="font-size: 14px; font-weight: 600; color: var(--text-body); margin-bottom: 5px;">В категории пока нет знаний</div>
<p style="margin: 0 0 16px; font-size: 12.5px; color: var(--text-tertiary); line-height: 1.5;">Создайте знание в этой категории или переместите существующие.</p>
<button style="height: 34px; padding: 0 14px; border-radius: var(--radius-lg); border: 1px solid transparent; background: var(--primary); color: #fff; font-size: 12.5px; font-weight: 600; box-shadow: var(--shadow-primary); cursor: pointer;">Создать знание</button>
</div>
<!-- permission -->
<div style="width: 372px; background: #fff; border: 1px solid var(--n-8); border-radius: var(--radius-2xl); box-shadow: var(--shadow-sm); padding: 40px 24px; text-align: center;">
<div style="width: 46px; height: 46px; border-radius: 12px; background: var(--warning-bg); color: var(--warning-text); display: flex; align-items: center; justify-content: center; margin: 0 auto 14px;"><svg viewBox="0 0 24 24" width="23" height="23" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
<div style="font-size: 14px; font-weight: 600; color: var(--text-body); margin-bottom: 5px;">Доступ ограничен отделом</div>
<p style="margin: 0; font-size: 12.5px; color: var(--text-tertiary); line-height: 1.5;">Ваш доступ <span style="font-family: var(--font-mono);">ai.view</span> охватывает отдел «Поддержка». Знания других отделов не показываются и недоступны для изменения.</p>
</div>
<!-- disabled selected -->
<div style="width: 372px; background: #fff; border: 1px solid var(--n-8); border-radius: var(--radius-2xl); box-shadow: var(--shadow-sm); padding: 24px;">
<div style="font-size: 11px; font-weight: 600; letter-spacing: 0.05em; color: var(--text-tertiary); margin-bottom: 12px;">ВЫКЛЮЧЕННОЕ ВЫБРАННОЕ ЗНАНИЕ</div>
<div style="display: flex; align-items: center; gap: 10px; padding: 12px; border: 1px dashed var(--n-6); border-radius: var(--radius-lg); background: #fcfcfc;">
<span style="display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 5px; background: var(--n-5);"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
<div style="flex: 1; line-height: 1.35;"><div style="font-size: 13px; font-weight: 600; color: var(--text-tertiary);">Работа с возражением «дорого»</div><div style="font-size: 11.5px; color: var(--n-5);">Продажи / Скрипты</div></div>
<span style="display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: var(--radius-pill); background: var(--n-9); font-size: 11px; font-weight: 600; color: var(--text-tertiary);"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--n-5);"></span>Выключено</span>
</div>
<p style="margin: 12px 0 0; font-size: 12px; color: var(--text-tertiary); line-height: 1.5;">Знание выбрано агентом, но выключено — в runtime не используется. Остаётся видимым в карточке агента с явным статусом.</p>
</div>
</div>
</section>
</div>
</x-dc>
</body>
</html>
-38
View File
@@ -1,38 +0,0 @@
# База знаний AI-агентов — design baseline (DG-01)
Макет раздела **AI → Знания** (Chatballs / ООО «Эдевс») по SPEC-HUB-0026 / ADR-HUB-0036.
## Состав
```
Knowledge Library Baseline.dc.html — макет (6 кадров: A–E + состояния)
support.js — рантайм Design Component (шаблон + логика)
_ds/chatballs-hub-design-system-…/ — дизайн-система: токены, styles.css, _ds_bundle.js
assets/chatballs-mark.svg — логотип Chatballs
```
## Запуск
Открыть `Knowledge Library Baseline.dc.html` через локальный HTTP-сервер
(из-за загрузки внешних файлов `file://` не подойдёт):
```bash
python3 -m http.server 8080
# → http://localhost:8080/Knowledge%20Library%20Baseline.dc.html
```
## Кадры
- **A** — библиотека: дерево категорий, поиск, фильтры (отдел / доступность / статус), таблица.
- **B** — массовый выбор: панель операций (переместить / сменить доступность / заменить отделы).
- **C** — карточка знания: категория, область доступности (Организация / Отделы), вложения, агенты.
- **D** — управление категориями: инлайн-переименование, reorder, безопасное удаление.
- **E** — конфликт при смене scope: блокировка + список затронутых агентов.
- **S** — состояния: пустая категория, ограниченный доступ, выключенное выбранное знание.
## Технические примечания
- Вёрстка — инлайн-стили поверх токенов дизайн-системы (`var(--*)` из `_ds/.../tokens`).
Цвета/типографика/отступы менять только через токены.
- Данные в макете — примеры; авторизация и доступность вычисляются на backend
(frontend не источник прав), см. SPEC-HUB-0026 §6.
@@ -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": []
}
}
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more