✨ feat(agents)!: агент и канал — одна сущность (этап 3 пивота, ADR-HUB-0041 §4)

- новый агрегированный API /api/v1/agents/: мастер одного шага (имя+группа,
  код из имени), объединённый PATCH канала и AI-конфигурации, удаление с
  блокерами, активация AI, привязка/отвязка подключений, тест-чат;
- data-миграция ai/0015: DRAFT-агент каждому каналу без агента;
- UI: разделы «Каналы» и «AI-агенты» слиты в «Агенты» (список + карточка-центр
  администрирования); legacy-URL редиректят; форма интеграции выбирает агента;
- чистка: features/channels и features/ai/{list,create,detail} удалены,
  битые @import styles.css (этапы 1-2) починены — прод-сборка снова собирается;
- дизайн-базлайн v2 владельца добавлен в design/baseline/Chatbolls Baseline v2/.

Backend 582 теста OK (докер, миграции с нуля), frontend tsc + vitest 69 OK, build OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5 committed 2026-09-04 19:14:52 +03:00
1 parent 07f4cd5f34
commit 774eb3215a
101 files changed
+4928 -4874

No files matched your search

+4
View File
@@ -24,6 +24,10 @@ urlpatterns = [
include("hub_platform.identity.employee_urls"),
),
path("api/v1/organizations/<uuid:organization_public_id>/ai/", include("hub_platform.ai.urls")),
path(
"api/v1/organizations/<uuid:organization_public_id>/agents/",
include("hub_platform.ai.agent_card_urls"),
),
path(
"api/v1/organizations/<uuid:organization_public_id>/integrations/",
include("hub_platform.integrations.urls"),
+262
View File
@@ -0,0 +1,262 @@
"""Агент как единая сущность (ADR-HUB-0041 §4, SPEC-HUB-0031 §4.3).
Для администратора существует только «Агент»: имя, группа, инструкции, знания,
подключения, активность. Физически карточка агрегирует Channel (несущая ось
диалогов/подключений) и AIAgent (конфигурация AI) 1:1; id карточки — id канала.
"""
from __future__ import annotations
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Count, Q, QuerySet
from django.utils.text import slugify
from hub_platform.ai.models import AIAgent, AIAgentStatus, CredentialMode
from hub_platform.ai.provider_selection import configure_agent_provider
from hub_platform.ai.serializers import agent_portal_article_payload
from hub_platform.channels.models import Channel
from hub_platform.channels.services import (
CODE_MAX_LENGTH,
UNSET,
ChannelUpdate,
update_channel,
)
from hub_platform.conversations.models import LifecycleState
from hub_platform.tenancy.context import TenantContext
def agent_cards_for_context(context: TenantContext) -> QuerySet[Channel]:
return (
Channel.objects.filter(organization_id=context.organization_id)
.select_related("group", "ai_agent", "ai_agent__provider_integration")
.prefetch_related("connections", "ai_agent__knowledge_items", "ai_agent__portal_articles")
.annotate(
open_conversations_count=Count(
"conversations",
filter=Q(conversations__lifecycle=LifecycleState.OPEN),
distinct=True,
)
)
.order_by("name")
)
def agent_card_for_context(*, context: TenantContext, agent_id: int) -> Channel:
return agent_cards_for_context(context).get(id=agent_id)
def _connections_payload(channel: Channel) -> list[dict[str, object]]:
return [
{
"id": connection.id,
"provider": connection.provider,
"name": connection.name,
"status": connection.status,
}
for connection in sorted(channel.connections.all(), key=lambda item: item.id)
]
def agent_card_payload(channel: Channel) -> dict[str, object]:
agent: AIAgent = channel.ai_agent
connections = _connections_payload(channel)
open_count = getattr(channel, "open_conversations_count", None)
if open_count is None:
open_count = channel.conversations.filter(lifecycle=LifecycleState.OPEN).count()
return {
"id": channel.id,
"aiAgentId": agent.id,
"code": channel.code,
"name": channel.name,
"isActive": channel.is_active,
"groupId": channel.group_id,
"groupName": channel.group.name if channel.group_id else None,
"aiStatus": agent.status,
"model": agent.model,
"credentialMode": agent.credential_mode,
"providerIntegrationId": agent.provider_integration_id,
"modelParams": agent.model_params,
"limits": agent.limits,
"persona": agent.persona,
"tone": agent.tone,
"instructions": agent.instructions,
"knowledge": [
{"id": item.id, "title": item.title, "isEnabled": item.is_enabled}
for item in agent.knowledge_items.all()
],
"portalArticles": [
agent_portal_article_payload(article)
for article in agent.portal_articles.all()
],
"connections": connections,
"counters": {
"openConversations": open_count,
"connections": len(connections),
},
"createdAt": channel.created_at.isoformat(),
"updatedAt": channel.updated_at.isoformat(),
}
def _unique_agent_code(organization_id: int, name: str) -> str:
base = slugify(name)[: CODE_MAX_LENGTH - 8].strip("-") or "agent"
taken = set(
Channel.objects.filter(
organization_id=organization_id, code__startswith=base
).values_list("code", flat=True)
)
if base not in taken:
return base
for suffix in range(2, 1000):
candidate = f"{base}-{suffix}"
if candidate not in taken:
return candidate
raise ValidationError({"name": "Не удалось подобрать уникальный код агента"})
def ensure_channel_agent(channel: Channel) -> AIAgent:
"""AIAgent обязателен для каждого канала: DRAFT = AI не отвечает."""
agent = getattr(channel, "ai_agent", None)
if agent is not None:
return agent
agent = AIAgent.objects.create(
channel=channel,
name=channel.name,
status=AIAgentStatus.DRAFT,
credential_mode=CredentialMode.CUSTOAI,
)
# Обновляем кеш select_related, чтобы payload не перечитывал канал.
channel.ai_agent = agent
return agent
@transaction.atomic
def create_agent_card(
*, context: TenantContext, name: object, group_id: int | None
) -> Channel:
"""Мастер одного шага (SPEC-HUB-0031 §4.3): имя и необязательная группа.
Канал создаётся без продукта с безопасной операторской политикой (дефолты
модели удовлетворяют P1-P5); код генерируется из имени и неизменен.
"""
from hub_platform.channels import authorization
from hub_platform.channels.services import _clean_name, _group_for_channel
authorization.require_organization_manage(context, operation="Создание агента")
clean_name = _clean_name(name)
group = _group_for_channel(context=context, group_id=group_id)
channel = Channel.objects.create(
organization_id=context.organization_id,
code=_unique_agent_code(context.organization_id, clean_name),
name=clean_name,
group=group,
)
ensure_channel_agent(channel)
return channel
@transaction.atomic
def update_agent_card(
*, context: TenantContext, channel: Channel, body: dict[str, object]
) -> Channel:
"""PATCH одной карточки: канальные и AI-поля в одной транзакции."""
agent = ensure_channel_agent(channel)
update = ChannelUpdate(
name=body["name"] if "name" in body else UNSET,
group_id=body["groupId"] if "groupId" in body else UNSET,
is_active=body["isActive"] if "isActive" in body else UNSET,
)
channel = update_channel(context=context, channel=channel, update=update)
ai_fields = {
"credentialMode",
"providerIntegrationId",
"modelParams",
"limits",
"persona",
"tone",
"instructions",
"knowledgeIds",
}
if ai_fields & set(body):
from hub_platform.ai.services import AgentInput, update_agent
knowledge_ids = body.get("knowledgeIds")
if knowledge_ids is not None and (
not isinstance(knowledge_ids, list)
or not all(isinstance(item, int) for item in knowledge_ids)
):
raise ValidationError({"knowledgeIds": "List of ids required"})
model_params = body.get("modelParams", agent.model_params)
limits = body.get("limits", agent.limits)
if not isinstance(model_params, dict):
raise ValidationError({"modelParams": "Object required"})
if not isinstance(limits, dict):
raise ValidationError({"limits": "Object required"})
provider_integration_id = body.get(
"providerIntegrationId", agent.provider_integration_id
)
if provider_integration_id is not None and not isinstance(
provider_integration_id, int
):
raise ValidationError({"providerIntegrationId": "Integer id required"})
update_agent(
context=context,
agent=agent,
data=AgentInput(
# Имя агента следует за именем карточки: сущность одна.
name=channel.name,
credential_mode=str(
body.get("credentialMode", agent.credential_mode)
).strip(),
provider_integration_id=provider_integration_id,
model_params=model_params,
allowed_tools=agent.allowed_tools,
limits=limits,
persona=str(body.get("persona", agent.persona)),
tone=str(body.get("tone", agent.tone)),
instructions=str(body.get("instructions", agent.instructions)),
knowledge_ids=knowledge_ids,
),
)
elif update.name is not UNSET:
agent.name = channel.name
agent.save(update_fields=["name", "updated_at"])
return agent_card_for_context(context=context, agent_id=channel.id)
def agent_deletion_blockers(channel: Channel) -> list[dict[str, object]]:
"""Агент удаляется вместе с каналом; блокируют только внешние связи."""
counts = (
("conversations", channel.conversations.count()),
("connections", channel.connections.count()),
("supportContracts", channel.allowed_support_contracts.count()),
("llmInvocations", channel.ai_invocations.count()),
)
return [{"type": name, "count": count} for name, count in counts if count]
@transaction.atomic
def delete_agent_card(*, context: TenantContext, channel: Channel) -> None:
from hub_platform.channels import authorization
from hub_platform.channels.services import ChannelHasReferences
authorization.require_organization_manage(context, operation="Удаление агента")
blockers = agent_deletion_blockers(channel)
if blockers:
raise ChannelHasReferences(blockers)
AIAgent.objects.filter(channel=channel).delete()
channel.delete()
def set_agent_card_active(
*, context: TenantContext, channel: Channel, is_active: bool
) -> Channel:
"""Активность AI: включает/выключает автоответы, канал остаётся живым."""
from hub_platform.ai.services import set_agent_active
agent = ensure_channel_agent(channel)
set_agent_active(context=context, agent=agent, is_active=is_active)
return agent_card_for_context(context=context, agent_id=channel.id)
@@ -0,0 +1,38 @@
"""Роуты единой сущности «Агент» (ADR-HUB-0041 §4)."""
from django.urls import path
from hub_platform.ai import agent_card_views as views
from hub_platform.channels.views import ChannelTestChatView
urlpatterns = [
path("", views.AgentCardListView.as_view(), name="agent-card-list"),
path("<int:agent_id>/", views.AgentCardDetailView.as_view(), name="agent-card-detail"),
path(
"<int:agent_id>/activate/",
views.AgentCardActivateView.as_view(),
name="agent-card-activate",
),
path(
"<int:agent_id>/deactivate/",
views.AgentCardDeactivateView.as_view(),
name="agent-card-deactivate",
),
path(
"<int:agent_id>/connections/",
views.AgentCardConnectionsView.as_view(),
name="agent-card-connections",
),
path(
"<int:agent_id>/connections/<int:integration_id>/",
views.AgentCardConnectionDetailView.as_view(),
name="agent-card-connection-detail",
),
# Тестовый чат исполняет агента канала; вьюха принимает channel_id — id
# карточки и канала совпадают по построению.
path(
"<int:channel_id>/test-chat/",
ChannelTestChatView.as_view(),
name="agent-card-test-chat",
),
]
@@ -0,0 +1,261 @@
"""HTTP-слой карточек агентов (/api/v1/agents/, ADR-HUB-0041 §4)."""
from __future__ import annotations
from django.core.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.ai.agent_card import (
agent_card_for_context,
agent_card_payload,
agent_cards_for_context,
create_agent_card,
delete_agent_card,
set_agent_card_active,
update_agent_card,
)
from hub_platform.api.permissions import HasCapability
from hub_platform.channels import services as channel_services
from hub_platform.channels.models import Channel
from hub_platform.identity.audit import record_audit_event
from hub_platform.subscriptions.errors import (
PolicyUnavailable,
QuotaExceeded,
SubscriptionDomainError,
)
AGENT_NOT_FOUND = {"detail": "Агент не найден"}
def _validation_detail(error: ValidationError) -> str:
if hasattr(error, "message_dict"):
return "; ".join(
message
for messages in error.message_dict.values()
for message in messages
)
return "; ".join(error.messages)
def _load(request: Request, agent_id: int) -> Channel:
return agent_card_for_context(
context=request.tenant_context, agent_id=agent_id
)
def _audit(request: Request, action: str, channel: Channel, **payload: object) -> None:
record_audit_event(
action=action,
actor=request.user,
organization=request.tenant_context.organization,
object_type="Agent",
object_id=str(channel.id),
payload=payload or None,
request=request,
)
class AgentCardListView(APIView):
permission_classes = [HasCapability]
required_capabilities = {"GET": "ai.view", "POST": "ai.manage"}
def get(self, request: Request) -> Response:
cards = agent_cards_for_context(request.tenant_context)
group = request.query_params.get("group")
if group == "none":
cards = cards.filter(group__isnull=True)
elif group:
try:
cards = cards.filter(group_id=int(group))
except ValueError:
return Response({"detail": "group must be an id or none"}, status=400)
from hub_platform.ai.agent_card import ensure_channel_agent
items = []
for channel in cards:
# Страховка для каналов, созданных в обход мастера.
ensure_channel_agent(channel)
items.append(agent_card_payload(channel))
return Response({"items": items})
def post(self, request: Request) -> Response:
data = request.data if isinstance(request.data, dict) else {}
group_id = data.get("groupId")
if group_id is not None and (
isinstance(group_id, bool) or not isinstance(group_id, int)
):
return Response({"detail": "groupId must be an integer or null"}, status=400)
try:
channel = create_agent_card(
context=request.tenant_context,
name=data.get("name"),
group_id=group_id,
)
except ValidationError as error:
return Response({"detail": _validation_detail(error)}, status=400)
_audit(request, "ai.agent_created", channel, name=channel.name)
return Response(
{"agent": agent_card_payload(_load(request, channel.id))}, status=201
)
class AgentCardDetailView(APIView):
permission_classes = [HasCapability]
required_capabilities = {
"GET": "ai.view",
"PATCH": "ai.manage",
"DELETE": "ai.manage",
}
def get(self, request: Request, agent_id: int) -> Response:
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
from hub_platform.ai.agent_card import ensure_channel_agent
ensure_channel_agent(channel)
return Response({"agent": agent_card_payload(channel)})
def patch(self, request: Request, agent_id: int) -> Response:
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
body = request.data if isinstance(request.data, dict) else {}
try:
channel = update_agent_card(
context=request.tenant_context, channel=channel, body=body
)
except ValidationError as error:
return Response({"detail": _validation_detail(error)}, status=400)
_audit(request, "ai.agent_updated", channel, fields=sorted(body.keys()))
return Response({"agent": agent_card_payload(channel)})
def delete(self, request: Request, agent_id: int) -> Response:
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
name = channel.name
try:
delete_agent_card(context=request.tenant_context, channel=channel)
except channel_services.ChannelHasReferences as error:
return Response(error.payload(), status=409)
_audit(request, "ai.agent_deleted", channel, name=name)
return Response(status=204)
class _AgentCardStatusView(APIView):
permission_classes = [HasCapability]
required_capability = "ai.manage"
target_active: bool
def post(self, request: Request, agent_id: int) -> Response:
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
try:
channel = set_agent_card_active(
context=request.tenant_context,
channel=channel,
is_active=self.target_active,
)
except QuotaExceeded as error:
return Response(
{
"code": error.code,
"resource": error.resource,
"limit": error.limit,
"used": error.used,
"requested": error.requested,
"period_ends_at": (
error.period_ends_at.isoformat() if error.period_ends_at else None
),
},
status=409,
)
except PolicyUnavailable as error:
return Response({"code": error.code}, status=503)
except SubscriptionDomainError as error:
return Response({"code": error.code}, status=409)
return Response({"agent": agent_card_payload(channel)})
class AgentCardActivateView(_AgentCardStatusView):
target_active = True
class AgentCardDeactivateView(_AgentCardStatusView):
target_active = False
class AgentCardConnectionsView(APIView):
permission_classes = [HasCapability]
required_capability = "integrations.manage"
def post(self, request: Request, agent_id: int) -> Response:
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
data = request.data if isinstance(request.data, dict) else {}
integration_id = data.get("integrationId")
if isinstance(integration_id, bool) or not isinstance(integration_id, int):
return Response({"detail": "integrationId must be an integer"}, status=400)
try:
integration, previous_channel_id = channel_services.bind_connection(
context=request.tenant_context,
channel=channel,
integration_id=integration_id,
force=bool(data.get("force")),
)
except channel_services.ConnectionAlreadyBound as error:
return Response(error.payload(), status=409)
except ValidationError as error:
return Response({"detail": _validation_detail(error)}, status=400)
if previous_channel_id is not None:
_audit(
request,
"channels.connection_moved",
channel,
integrationId=integration.id,
fromChannelId=previous_channel_id,
)
else:
_audit(
request,
"channels.connection_bound",
channel,
integrationId=integration.id,
)
return Response({"agent": agent_card_payload(_load(request, channel.id))})
class AgentCardConnectionDetailView(APIView):
permission_classes = [HasCapability]
required_capability = "integrations.manage"
def delete(self, request: Request, agent_id: int, integration_id: int) -> Response:
try:
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
try:
channel_services.unbind_connection(
context=request.tenant_context,
channel=channel,
integration_id=integration_id,
)
except ValidationError as error:
return Response({"detail": _validation_detail(error)}, status=400)
_audit(
request,
"channels.connection_unbound",
channel,
integrationId=integration_id,
)
return Response({"agent": agent_card_payload(_load(request, channel.id))})
@@ -0,0 +1,29 @@
# ADR-HUB-0041 §4: агент и канал — одна сущность. Каждому каналу без AIAgent
# создаётся DRAFT-агент (AI не отвечает, поведение канала не меняется), чтобы
# карточка агента существовала для всех исторических каналов.
from django.db import migrations
def backfill_agents(apps, schema_editor):
Channel = apps.get_model("channels", "Channel")
AIAgent = apps.get_model("ai", "AIAgent")
for channel in Channel.objects.filter(ai_agent__isnull=True).iterator():
AIAgent.objects.create(
# Историческая модель не наследует автоустановку organization из
# TenantRelationModel.save — ключ проставляется явно.
organization_id=channel.organization_id,
channel=channel,
name=channel.name,
status="DRAFT",
credential_mode="CUSTOAI",
)
class Migration(migrations.Migration):
dependencies = [
("ai", "0014_remove_knowledgedepartment_department_and_more"),
("channels", "0007_channel_group"),
]
operations = [
migrations.RunPython(backfill_agents, migrations.RunPython.noop),
]
@@ -0,0 +1,251 @@
import json
from django.test import TestCase
from hub_platform.testing import TenantAPIClient as APIClient
from hub_platform.ai.models import AIAgent, AIAgentStatus
from hub_platform.channels.models import Channel
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
)
class AgentCardTestCase(TestCase):
"""Единая сущность «Агент» = канал + AI-конфигурация (ADR-HUB-0041 §4)."""
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.operators = self.organization.employee_groups.get(name="Операторы")
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
def create_agent(self, name: str = "Приёмная", group_id: int | None = None):
return self.client.post(
"/api/v1/agents/",
data=json.dumps({"name": name, "groupId": group_id}),
content_type="application/json",
)
class AgentCardCreateTests(AgentCardTestCase):
def test_one_step_wizard_creates_channel_and_draft_agent(self) -> None:
response = self.create_agent(group_id=self.operators.id)
self.assertEqual(response.status_code, 201)
card = response.json()["agent"]
self.assertEqual(card["name"], "Приёмная")
self.assertEqual(card["groupId"], self.operators.id)
self.assertEqual(card["aiStatus"], AIAgentStatus.DRAFT)
self.assertTrue(card["isActive"])
self.assertEqual(card["connections"], [])
channel = Channel.objects.get(id=card["id"])
self.assertEqual(channel.ai_agent.id, card["aiAgentId"])
self.assertEqual(channel.ai_agent.name, "Приёмная")
def test_codes_are_generated_and_unique(self) -> None:
first = self.create_agent(name="Support line")
second = self.create_agent(name="Support line!")
self.assertEqual(first.status_code, 201)
self.assertEqual(second.status_code, 201)
codes = {first.json()["agent"]["code"], second.json()["agent"]["code"]}
self.assertEqual(codes, {"support-line", "support-line-2"})
def test_cyrillic_name_gets_fallback_code(self) -> None:
response = self.create_agent(name="Продажи")
self.assertEqual(response.json()["agent"]["code"], "agent")
def test_empty_name_is_rejected(self) -> None:
response = self.create_agent(name=" ")
self.assertEqual(response.status_code, 400)
def test_foreign_group_is_rejected(self) -> None:
from hub_platform.identity.group_models import EmployeeGroup
other = Organization.objects.create(slug="other-cards", name="Other")
foreign = EmployeeGroup.objects.create(organization=other, name="Чужая")
response = self.create_agent(group_id=foreign.id)
self.assertEqual(response.status_code, 400)
class AgentCardListDetailTests(AgentCardTestCase):
def setUp(self) -> None:
super().setUp()
self.card_id = self.create_agent(group_id=self.operators.id).json()["agent"]["id"]
def test_list_returns_cards_with_group_filter(self) -> None:
self.create_agent(name="Без группы")
listed = self.client.get("/api/v1/agents/")
self.assertEqual(listed.status_code, 200)
self.assertEqual(len(listed.json()["items"]), 2)
grouped = self.client.get(f"/api/v1/agents/?group={self.operators.id}")
self.assertEqual(
[item["id"] for item in grouped.json()["items"]], [self.card_id]
)
ungrouped = self.client.get("/api/v1/agents/?group=none")
self.assertEqual(
[item["name"] for item in ungrouped.json()["items"]], ["Без группы"]
)
def test_legacy_channel_without_agent_gets_draft_agent_in_list(self) -> None:
channel = Channel.objects.create(
organization=self.organization, code="legacy", name="Legacy"
)
response = self.client.get(f"/api/v1/agents/{channel.id}/")
self.assertEqual(response.status_code, 200)
card = response.json()["agent"]
self.assertEqual(card["aiStatus"], AIAgentStatus.DRAFT)
self.assertTrue(AIAgent.objects.filter(channel=channel).exists())
def test_employee_is_denied(self) -> None:
user = HumanUser.objects.create_user(
email="employee@edevs.tech", password="employee-password"
)
OrganizationMembership.objects.create(
user=user,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
position_title="Оператор",
)
client = APIClient()
client.login(username="employee@edevs.tech", password="employee-password")
self.assertEqual(client.get("/api/v1/agents/").status_code, 403)
def test_other_organization_card_is_not_found(self) -> None:
other = Organization.objects.create(slug="other-detail", name="Other")
foreign = Channel.objects.create(organization=other, code="f", name="F")
response = self.client.get(f"/api/v1/agents/{foreign.id}/")
self.assertEqual(response.status_code, 404)
class AgentCardUpdateTests(AgentCardTestCase):
def setUp(self) -> None:
super().setUp()
self.card = self.create_agent(group_id=self.operators.id).json()["agent"]
def patch(self, **body):
return self.client.patch(
f"/api/v1/agents/{self.card['id']}/",
data=json.dumps(body),
content_type="application/json",
)
def test_rename_updates_channel_and_agent_together(self) -> None:
response = self.patch(name="Новая приёмная")
self.assertEqual(response.status_code, 200)
card = response.json()["agent"]
self.assertEqual(card["name"], "Новая приёмная")
agent = AIAgent.objects.get(id=card["aiAgentId"])
self.assertEqual(agent.name, "Новая приёмная")
# Код неизменен: он входит в embed-URL виджета.
self.assertEqual(card["code"], self.card["code"])
def test_moves_between_groups_and_detaches(self) -> None:
support = self.organization.employee_groups.get(name="Поддержка")
moved = self.patch(groupId=support.id)
self.assertEqual(moved.json()["agent"]["groupId"], support.id)
detached = self.patch(groupId=None)
self.assertIsNone(detached.json()["agent"]["groupId"])
def test_updates_instructions_without_touching_channel(self) -> None:
response = self.patch(persona="Помощник", instructions="Отвечай кратко")
card = response.json()["agent"]
self.assertEqual(card["persona"], "Помощник")
self.assertEqual(card["instructions"], "Отвечай кратко")
self.assertEqual(card["name"], self.card["name"])
def test_deactivates_channel(self) -> None:
response = self.patch(isActive=False)
self.assertFalse(response.json()["agent"]["isActive"])
def test_unknown_knowledge_id_rejects_whole_patch(self) -> None:
response = self.patch(persona="X", knowledgeIds=[999999])
self.assertEqual(response.status_code, 400)
agent = AIAgent.objects.get(id=self.card["aiAgentId"])
self.assertEqual(agent.persona, "")
class AgentCardDeleteTests(AgentCardTestCase):
def test_deletes_card_with_agent(self) -> None:
card = self.create_agent().json()["agent"]
response = self.client.delete(f"/api/v1/agents/{card['id']}/")
self.assertEqual(response.status_code, 204)
self.assertFalse(Channel.objects.filter(id=card["id"]).exists())
self.assertFalse(AIAgent.objects.filter(id=card["aiAgentId"]).exists())
def test_connections_block_deletion(self) -> None:
card = self.create_agent().json()["agent"]
Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="Bot",
channel_id=card["id"],
)
response = self.client.delete(f"/api/v1/agents/{card['id']}/")
self.assertEqual(response.status_code, 409)
blockers = {item["type"] for item in response.json()["blockers"]}
self.assertEqual(blockers, {"connections"})
self.assertTrue(Channel.objects.filter(id=card["id"]).exists())
class AgentCardConnectionTests(AgentCardTestCase):
def setUp(self) -> None:
super().setUp()
self.card = self.create_agent().json()["agent"]
self.integration = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="Telegram Bot",
)
def test_binds_and_unbinds_connection_from_card(self) -> None:
bound = self.client.post(
f"/api/v1/agents/{self.card['id']}/connections/",
data=json.dumps({"integrationId": self.integration.id}),
content_type="application/json",
)
self.assertEqual(bound.status_code, 200)
self.assertEqual(len(bound.json()["agent"]["connections"]), 1)
unbound = self.client.delete(
f"/api/v1/agents/{self.card['id']}/connections/{self.integration.id}/"
)
self.assertEqual(unbound.status_code, 200)
self.assertEqual(unbound.json()["agent"]["connections"], [])
self.integration.refresh_from_db()
self.assertIsNone(self.integration.channel_id)
def test_move_between_agents_requires_force(self) -> None:
other = self.create_agent(name="Вторая линия").json()["agent"]
self.client.post(
f"/api/v1/agents/{self.card['id']}/connections/",
data=json.dumps({"integrationId": self.integration.id}),
content_type="application/json",
)
conflict = self.client.post(
f"/api/v1/agents/{other['id']}/connections/",
data=json.dumps({"integrationId": self.integration.id}),
content_type="application/json",
)
self.assertEqual(conflict.status_code, 409)
forced = self.client.post(
f"/api/v1/agents/{other['id']}/connections/",
data=json.dumps({"integrationId": self.integration.id, "force": True}),
content_type="application/json",
)
self.assertEqual(forced.status_code, 200)
self.integration.refresh_from_db()
self.assertEqual(self.integration.channel_id, other["id"])
@@ -19,7 +19,7 @@ from hub_platform.notifications.selectors import unread_for
TYPE_META: dict[str, dict] = {
NotificationType.DIALOG_WAITING: {"level": NotificationLevel.WARNING, "route": "salesDialogs"},
NotificationType.DIALOG_NEW_MESSAGE: {"level": NotificationLevel.INFO, "route": "salesDialogs"},
NotificationType.RELEASE_PUBLISHED: {"level": NotificationLevel.SUCCESS, "route": "aiAgents"},
NotificationType.RELEASE_PUBLISHED: {"level": NotificationLevel.SUCCESS, "route": "agents"},
NotificationType.INTEGRATION_ERROR: {"level": NotificationLevel.CRITICAL, "route": "integrations"},
NotificationType.LIMIT_REACHED: {"level": NotificationLevel.WARNING, "route": "command"},
}
+1
View File
@@ -32,6 +32,7 @@ class TenantAPIClient(APIClient):
tenant_namespaces = {
"access-profiles",
"agents",
"ai",
"calls",
"channels",
+5 -5
View File
@@ -11,7 +11,7 @@ import { Shell } from "./layout/Shell";
import { pathFromRoute, routeFromPath } from "./router";
import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui";
import { useRouteNavigation } from "./useRouteNavigation";
import type { AiAgent } from "./features/ai/model";
import type { AgentCard } from "./features/agents/model";
import type { AppData, AuthChallenge, AuthenticatedUser, Employee, EmployeeGroup, Product, SessionUser } from "./types";
export function App() {
@@ -52,9 +52,9 @@ export function App() {
? api<{ items: Product[] }>("/api/v1/company/products/")
: Promise.resolve({ items: [] }),
]);
let agents: AiAgent[] = [];
if (user && canAccess(user, "aiAgents")) {
const agentsResponse = await api<{ items: AiAgent[] }>("/api/v1/ai/agents/");
let agents: AgentCard[] = [];
if (user && canAccess(user, "agents")) {
const agentsResponse = await api<{ items: AgentCard[] }>("/api/v1/agents/");
agents = agentsResponse.items;
}
setData({ employees: employees.items, groups: groups.items, products: products.items, agents });
@@ -152,7 +152,7 @@ export function App() {
) : !canAccess(user, navigation.route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("channelDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={(productCode) => navigate("aiAgentCreate", null, false, productCode)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} />
<Shell route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={() => navigate("agents")} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} />
)}
</ConfigProvider>
);
+3 -3
View File
@@ -31,7 +31,7 @@ describe("role-based navigation (SPEC-HUB-0031 §3)", () => {
expect(isManager(user)).toBe(true);
expect(canAccess(user, "command")).toBe(true);
expect(canAccess(user, "employees")).toBe(true);
expect(canAccess(user, "channels")).toBe(true);
expect(canAccess(user, "agents")).toBe(true);
expect(canAccess(user, "aiKnowledge")).toBe(true);
expect(canAccess(user, "administrationOrganization")).toBe(true);
expect(defaultRoute(user)).toBe("command");
@@ -46,8 +46,8 @@ describe("role-based navigation (SPEC-HUB-0031 §3)", () => {
expect(canAccess(user, "profile")).toBe(true);
expect(canAccess(user, "settings")).toBe(true);
expect(canAccess(user, "employees")).toBe(false);
expect(canAccess(user, "channels")).toBe(false);
expect(canAccess(user, "aiAgents")).toBe(false);
expect(canAccess(user, "agents")).toBe(false);
expect(canAccess(user, "aiKnowledge")).toBe(false);
expect(canAccess(user, "salesClients")).toBe(false);
expect(defaultRoute(user)).toBe("salesDialogs");
});
@@ -0,0 +1,534 @@
import { Modal } from "antd";
import { useCallback, useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import { ChannelBadge } from "../../shared/badges";
import { FormField, KeyValue, SelectField, TextAreaField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { providerLabel } from "../../shared/providers";
import { EmptyState, ErrorScreen, LoadingState, StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { formatDate } from "../../shared/utils";
import type { EmployeeGroup, RouteKey } from "../../types";
import { api } from "../../api/client";
import { CREDENTIAL_MODE_OPTIONS, type CredentialMode } from "../ai/model";
import { fetchLlmProviders, type Integration } from "../integrations/model";
import { connectionStatus } from "./connection-status";
import {
bindAgentConnection,
deleteAgent,
fetchAgent,
patchAgent,
setAgentAiActive,
unbindAgentConnection,
type AgentCard,
type AgentPatch,
} from "./model";
type Feedback = { kind: "error" | "warning"; text: string } | null;
function useAgentCard(agentId: number | null) {
const [card, setCard] = useState<AgentCard | null>(null);
const [missing, setMissing] = useState(false);
const [failed, setFailed] = useState(false);
const load = useCallback(async () => {
if (!agentId) {
setMissing(true);
return;
}
try {
const payload = await fetchAgent(agentId);
setCard(payload.agent);
} catch (caught) {
if (caught instanceof ApiError && caught.status === 404) setMissing(true);
else setFailed(true);
}
}, [agentId]);
useEffect(() => {
void load();
}, [load]);
return { card, setCard, missing, failed, reload: load };
}
export function AgentDetailPage({
agentId,
groups,
canManage,
canManageConnections,
openAgents,
openKnowledge,
openIntegrations,
setRoute,
onLoaded,
}: {
agentId: number | null;
groups: EmployeeGroup[];
canManage: boolean;
canManageConnections: boolean;
openAgents: () => void;
openKnowledge: (knowledgeId: number) => void;
openIntegrations: () => void;
setRoute: (route: RouteKey) => void;
onLoaded: (name: string | null) => void;
}) {
const { card, setCard, missing, failed, reload } = useAgentCard(agentId);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<Feedback>(null);
const [deleting, setDeleting] = useState(false);
const [providers, setProviders] = useState<Integration[]>([]);
const [messengers, setMessengers] = useState<Integration[]>([]);
const loadMessengers = useCallback(async () => {
try {
const payload = await api<{ items: Integration[] }>("/api/v1/integrations/");
setMessengers(payload.items.filter((item) => item.kind === "MESSENGER" && !item.config.purpose));
} catch {
setMessengers([]);
}
}, []);
useEffect(() => {
onLoaded(card?.name ?? null);
return () => onLoaded(null);
}, [card?.name, onLoaded]);
useEffect(() => {
if (canManage) fetchLlmProviders().then(setProviders).catch(() => setProviders([]));
}, [canManage]);
useEffect(() => {
if (canManageConnections) void loadMessengers();
}, [canManageConnections, loadMessengers]);
if (missing) return <EmptyState title="Агент не найден" />;
if (failed) return <ErrorScreen retry={() => void reload()} />;
if (!card) return <LoadingState />;
async function apply(patch: AgentPatch) {
setBusy(true);
setFeedback(null);
try {
const saved = await patchAgent(card!.id, patch);
setCard(saved.agent);
return true;
} catch (caught) {
setFeedback({
kind: "error",
text: caught instanceof ApiError ? caught.payload.detail ?? "Не удалось сохранить" : "Не удалось сохранить",
});
return false;
} finally {
setBusy(false);
}
}
async function toggleAi() {
setBusy(true);
setFeedback(null);
try {
const saved = await setAgentAiActive(card!.id, card!.aiStatus !== "ACTIVE");
setCard(saved.agent);
} catch {
setFeedback({ kind: "error", text: "Не удалось изменить статус AI" });
} finally {
setBusy(false);
}
}
async function removeAgent() {
setBusy(true);
try {
await deleteAgent(card!.id);
openAgents();
} catch (caught) {
setDeleting(false);
setFeedback({
kind: "warning",
text:
caught instanceof ApiError && caught.status === 409
? "Агента нельзя удалить: есть диалоги или подключения. Отвяжите подключения и закройте диалоги."
: "Не удалось удалить агента",
});
} finally {
setBusy(false);
}
}
async function unbind(integrationId: number) {
setBusy(true);
try {
const saved = await unbindAgentConnection(card!.id, integrationId);
setCard(saved.agent);
void loadMessengers();
} catch {
setFeedback({ kind: "error", text: "Не удалось отвязать подключение" });
} finally {
setBusy(false);
}
}
async function bind(integrationId: number) {
setBusy(true);
setFeedback(null);
try {
const saved = await bindAgentConnection(card!.id, integrationId);
setCard(saved.agent);
void loadMessengers();
} catch (caught) {
setFeedback({
kind: "error",
text:
caught instanceof ApiError && caught.status === 409
? "Подключение уже привязано к другому агенту"
: "Не удалось привязать подключение",
});
} finally {
setBusy(false);
}
}
const status = card.aiStatus === "ACTIVE" ? "active" : card.aiStatus === "DRAFT" ? "draft" : "disabled";
return (
<div className="agent-detail">
<header className="agent-detail-header">
<div>
<div className="agent-detail-title">
<h1>{card.name}</h1>
<StatusPill status={card.isActive ? status : "archived"} />
</div>
<div className="agent-detail-meta">
<span>{card.groupName ?? "Без группы"}</span>
<span>·</span>
<code>{card.code}</code>
</div>
</div>
{canManage && (
<div className="agent-detail-actions">
<Button variant="secondary" icon={card.aiStatus === "ACTIVE" ? "pause" : "bolt"} disabled={busy} onClick={() => void toggleAi()}>
{card.aiStatus === "ACTIVE" ? "Остановить AI" : "Запустить AI"}
</Button>
</div>
)}
</header>
{feedback && <div className={`agent-feedback is-${feedback.kind}`}>{feedback.text}</div>}
<AssignmentSection card={card} groups={groups} canManage={canManage} busy={busy} apply={apply} />
<InstructionsSection card={card} canManage={canManage} busy={busy} apply={apply} />
<ModelSection card={card} providers={providers} canManage={canManage} busy={busy} apply={apply} />
<KnowledgeSection card={card} openKnowledge={openKnowledge} setRoute={setRoute} />
<ConnectionsSection
card={card}
canManage={canManageConnections}
busy={busy}
available={messengers.filter((item) => item.channel === null)}
openIntegrations={openIntegrations}
bind={bind}
unbind={unbind}
/>
{canManage && (
<footer className="agent-danger-actions">
<Button
variant="secondary"
icon="pause"
disabled={busy}
onClick={() => void apply({ isActive: !card.isActive })}
>
{card.isActive ? "Выключить агента" : "Включить агента"}
</Button>
<Button variant="danger-outline" icon="trash" disabled={busy} onClick={() => setDeleting(true)}>
Удалить
</Button>
</footer>
)}
{deleting && (
<Modal
open
title="Удалить агента?"
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true, disabled: busy }}
onOk={() => void removeAgent()}
onCancel={() => setDeleting(false)}
>
<p>Агент «{card.name}» будет удалён безвозвратно. Диалоги и подключения не дают удалить агента.</p>
</Modal>
)}
</div>
);
}
function AssignmentSection({
card, groups, canManage, busy, apply,
}: {
card: AgentCard;
groups: EmployeeGroup[];
canManage: boolean;
busy: boolean;
apply: (patch: AgentPatch) => Promise<boolean>;
}) {
const [name, setName] = useState(card.name);
useEffect(() => setName(card.name), [card.name]);
const dirty = name.trim() !== card.name && name.trim().length > 0;
return (
<section className="agent-card-section">
<header>
<h3>Назначение</h3>
<span>Группа определяет, какие сотрудники видят диалоги агента; без группы диалоги видны всем</span>
</header>
<div className="agent-assignment">
{canManage ? (
<FormField label="Название" value={name} onChange={setName} />
) : (
<KeyValue label="Название" value={card.name} />
)}
{canManage ? (
<SelectField
label="Группа"
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(value) => void apply({ groupId: value ? Number(value) : null })}
options={[["", "Без группы"], ...groups.map((item) => [String(item.id), item.name] as [string, string])]}
/>
) : (
<KeyValue label="Группа" value={card.groupName ?? "Без группы"} />
)}
<KeyValue label="Код" value={<code>{card.code}</code>} />
<KeyValue label="Создан" value={formatDate(card.createdAt)} />
</div>
{canManage && dirty && (
<div className="agent-section-actions">
<Button variant="secondary" disabled={busy} onClick={() => setName(card.name)}>Отменить</Button>
<Button variant="primary" disabled={busy} onClick={() => void apply({ name: name.trim() })}>Сохранить</Button>
</div>
)}
</section>
);
}
function InstructionsSection({
card, canManage, busy, apply,
}: {
card: AgentCard;
canManage: boolean;
busy: boolean;
apply: (patch: AgentPatch) => Promise<boolean>;
}) {
const [persona, setPersona] = useState(card.persona);
const [tone, setTone] = useState(card.tone);
const [instructions, setInstructions] = useState(card.instructions);
useEffect(() => {
setPersona(card.persona);
setTone(card.tone);
setInstructions(card.instructions);
}, [card.persona, card.tone, card.instructions]);
const dirty = persona !== card.persona || tone !== card.tone || instructions !== card.instructions;
if (!canManage) {
return (
<section className="agent-card-section">
<header><h3>Инструкции</h3></header>
{card.persona || card.tone || card.instructions ? (
<div className="agent-instructions-grid">
{card.persona && <KeyValue label="Кто он" value={card.persona} />}
{card.tone && <KeyValue label="Как говорит" value={card.tone} />}
{card.instructions && <KeyValue label="Правила" value={card.instructions} />}
</div>
) : (
<p className="agent-muted">Инструкции не заданы.</p>
)}
</section>
);
}
return (
<section className="agent-card-section">
<header>
<h3>Инструкции</h3>
<span>Системный промпт собирается из трёх частей в этом порядке</span>
</header>
<div className="agent-instructions-grid">
<TextAreaField label="Кто он и что делает" value={persona} onChange={setPersona} />
<TextAreaField label="Как он должен говорить" value={tone} onChange={setTone} />
<TextAreaField label="Правила работы" value={instructions} onChange={setInstructions} />
</div>
{dirty && (
<div className="agent-section-actions">
<Button variant="secondary" disabled={busy} onClick={() => { setPersona(card.persona); setTone(card.tone); setInstructions(card.instructions); }}>
Отменить
</Button>
<Button variant="primary" disabled={busy} onClick={() => void apply({ persona, tone, instructions })}>
Сохранить
</Button>
</div>
)}
</section>
);
}
function ModelSection({
card, providers, canManage, busy, apply,
}: {
card: AgentCard;
providers: Integration[];
canManage: boolean;
busy: boolean;
apply: (patch: AgentPatch) => Promise<boolean>;
}) {
return (
<section className="agent-card-section">
<header>
<h3>Модель и провайдер</h3>
<span>BYOK — ключ вашей организации из раздела «Интеграции»</span>
</header>
<div className="agent-assignment">
<KeyValue label="Модель" value={<code>{card.model}</code>} />
{canManage ? (
<SelectField
label="Режим AI"
value={card.credentialMode}
onChange={(value) => {
const mode = value as CredentialMode;
if (mode === "CUSTOAI") void apply({ credentialMode: mode, providerIntegrationId: null });
else if (providers.length > 0) void apply({ credentialMode: mode, providerIntegrationId: providers[0].id });
}}
options={CREDENTIAL_MODE_OPTIONS}
/>
) : (
<KeyValue label="Режим AI" value={card.credentialMode} />
)}
{card.credentialMode === "BYOK" && (
canManage ? (
<SelectField
label="Интеграция"
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(value) => {
if (value) void apply({ providerIntegrationId: Number(value) });
}}
options={[["", "Выберите интеграцию"], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
) : (
<KeyValue label="Интеграция" value={String(card.providerIntegrationId ?? "—")} />
)
)}
</div>
{busy && <p className="agent-muted">Сохранение…</p>}
</section>
);
}
function KnowledgeSection({
card, openKnowledge, setRoute,
}: {
card: AgentCard;
openKnowledge: (knowledgeId: number) => void;
setRoute: (route: RouteKey) => void;
}) {
const total = card.knowledge.length + card.portalArticles.length;
return (
<section className="agent-card-section">
<header>
<h3>Знания</h3>
<button className="link has-icon" type="button" onClick={() => setRoute("aiKnowledge")}>
Перейти в знания
<Icon name="arrow" size={14} />
</button>
</header>
{total === 0 ? (
<p className="agent-muted">Знания не прикреплены — выберите их в разделе «Знания» или статьи на портале поддержки.</p>
) : (
<>
{card.knowledge.length > 0 && (
<ul className="agent-knowledge-list is-readonly">
{card.knowledge.map((item) => (
<li key={item.id}>
<button className="link is-strong is-neutral agent-knowledge-text" type="button" onClick={() => openKnowledge(item.id)}>
{item.title}
</button>
{!item.isEnabled && <span className="ai-doc-chip off">Выключено</span>}
</li>
))}
</ul>
)}
{card.portalArticles.length > 0 && (
<ul className="agent-knowledge-list is-readonly">
{card.portalArticles.map((article) => (
<li key={article.id}>
<a className="link is-strong is-neutral agent-knowledge-text" href={article.publicUrl} rel="noreferrer" target="_blank">
{article.title}
</a>
<span className="ai-doc-chip plain">{article.portal.name}</span>
</li>
))}
</ul>
)}
</>
)}
</section>
);
}
function ConnectionsSection({
card, canManage, busy, available, openIntegrations, bind, unbind,
}: {
card: AgentCard;
canManage: boolean;
busy: boolean;
available: Integration[];
openIntegrations: () => void;
bind: (integrationId: number) => void;
unbind: (integrationId: number) => void;
}) {
return (
<section className="agent-card-section">
<header>
<h3>Подключения</h3>
{canManage && (
<button className="link has-icon" type="button" onClick={openIntegrations}>
Открыть интеграции
<Icon name="arrow" size={14} />
</button>
)}
</header>
{card.connections.length === 0 ? (
<p className="agent-muted">Подключений нет. Добавьте Telegram-бота или веб-виджет в разделе «Интеграции» и привяжите к агенту.</p>
) : (
<div className="agent-connections">
{card.connections.map((connection) => (
<div className="agent-connection" key={connection.id}>
<ChannelBadge provider={connection.provider} />
<div className="agent-connection-text">
<strong>{connection.name}</strong>
<span>{providerLabel(connection.provider)}</span>
</div>
<StatusPill status={connectionStatus(connection.status)} />
{canManage && (
<Button variant="secondary" disabled={busy} onClick={() => unbind(connection.id)}>Отвязать</Button>
)}
</div>
))}
</div>
)}
{canManage && available.length > 0 && (
<div className="agent-connections" style={{ marginTop: 8 }}>
{available.map((integration) => (
<div className="agent-connection" key={integration.id}>
<ChannelBadge provider={integration.provider} />
<div className="agent-connection-text">
<strong>{integration.name}</strong>
<span>Свободное подключение</span>
</div>
<Button variant="secondary" disabled={busy} onClick={() => bind(integration.id)}>Привязать</Button>
</div>
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,164 @@
import { Dropdown, Modal } from "antd";
import { useState } from "react";
import { Icon } from "../../shared/icons";
import { ChannelBadge } from "../../shared/badges";
import { FormField, SelectField } from "../../shared/form-controls";
import { EmptyState, PageHeader, StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import type { EmployeeGroup } from "../../types";
import { agentStatusLabel, createAgent, setAgentAiActive, type AgentCard } from "./model";
const STATUS_PILL = { active: "active", paused: "draft", disabled: "disabled" } as const;
/** Мастер одного шага (SPEC-HUB-0031 §4.3): имя и необязательная группа. */
function CreateAgentModal({ groups, onClose, onCreated }: { groups: EmployeeGroup[]; onClose: () => void; onCreated: (agentId: number) => void }) {
const [name, setName] = useState("");
const [groupId, setGroupId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
if (!name.trim()) return;
setSubmitting(true);
setError(null);
try {
const created = await createAgent({ name: name.trim(), groupId });
onCreated(created.agent.id);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось создать агента");
setSubmitting(false);
}
}
return (
<Modal open title="Создать агента" onCancel={onClose} footer={null} destroyOnClose>
<div className="integration-form">
<FormField label="Название" value={name} onChange={setName} placeholder="Например: Приёмная" />
<SelectField
label="Группа"
value={groupId === null ? "" : String(groupId)}
onChange={(value) => setGroupId(value ? Number(value) : null)}
options={[["", "Без группы — диалоги видны всем"], ...groups.map((item) => [String(item.id), item.name] as [string, string])]}
/>
{error && <div className="integration-form-error">{error}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={!name.trim() || submitting} onClick={() => void submit()}>
{submitting ? "Создание…" : "Создать"}
</Button>
</div>
</div>
</Modal>
);
}
export function AgentsPage({
agents,
groups,
reload,
openAgent,
}: {
agents: AgentCard[];
groups: EmployeeGroup[];
reload: () => void;
openAgent: (agentId: number) => void;
}) {
const [creating, setCreating] = useState(false);
const [menuId, setMenuId] = useState<number | null>(null);
async function toggleAi(card: AgentCard) {
setMenuId(null);
await setAgentAiActive(card.id, card.aiStatus !== "ACTIVE").catch(() => undefined);
reload();
}
return (
<div className="ai-page">
<PageHeader
title="Агенты"
text="Агент отвечает клиентам и собирает диалоги в группу"
action={<Button variant="primary" icon="plus" onClick={() => setCreating(true)}>Создать агента</Button>}
/>
{agents.length === 0 ? (
<EmptyState title="Агент отвечает клиентам первым. Создайте первого" />
) : (
<div className="table-card">
<table className="baseline-table">
<thead>
<tr>
<th>АГЕНТ</th>
<th>ГРУППА</th>
<th>СТАТУС</th>
<th>ПОДКЛЮЧЕНИЯ</th>
<th className="numeric">ОТКРЫТЫЕ ДИАЛОГИ</th>
<th />
</tr>
</thead>
<tbody>
{agents.map((card) => {
const status = agentStatusLabel(card);
const menuItems = [
{ key: "open", label: <button type="button" onClick={() => { setMenuId(null); openAgent(card.id); }}><Icon name="external" size={15} />Открыть карточку</button> },
{ type: "divider" as const },
{
key: "toggle",
label: (
<button type="button" className={card.aiStatus === "ACTIVE" ? "warning" : ""} onClick={() => void toggleAi(card)}>
<Icon name={card.aiStatus === "ACTIVE" ? "pause" : "bolt"} size={15} />
{card.aiStatus === "ACTIVE" ? "Остановить AI" : "Запустить AI"}
</button>
),
},
];
return (
<tr key={card.id}>
<td>
<button className="link is-strong is-neutral" type="button" onClick={() => openAgent(card.id)}>{card.name}</button>
</td>
<td>{card.groupName ?? <span className="agent-muted">Без группы</span>}</td>
<td><StatusPill status={STATUS_PILL[status.tone]} /></td>
<td>
{card.connections.length === 0 ? (
<span className="agent-muted">—</span>
) : (
<span style={{ display: "inline-flex", gap: 6 }}>
{card.connections.map((connection) => (
<ChannelBadge key={connection.id} provider={connection.provider} />
))}
</span>
)}
</td>
<td className="numeric">{card.counters.openConversations || <span className="agent-muted">—</span>}</td>
<td className="row-actions">
<Dropdown
menu={{ items: menuItems }}
open={menuId === card.id}
onOpenChange={(open) => setMenuId(open ? card.id : null)}
trigger={["click"]}
overlayClassName="app-dropdown"
>
<button className="row-menu-button" type="button" aria-label="Действия агента"><Icon name="more" /></button>
</Dropdown>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{creating && (
<CreateAgentModal
groups={groups}
onClose={() => setCreating(false)}
onCreated={(agentId) => {
setCreating(false);
reload();
openAgent(agentId);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,13 @@
import type { StatusPillKey } from "../../shared/ui";
/** Состояние подключения приходит строкой — на экран идёт словарь, не энум. */
const CONNECTION_STATUS: Record<string, StatusPillKey> = {
OK: "healthy",
ERROR: "error",
PENDING: "pending",
UNCHECKED: "unchecked",
};
export function connectionStatus(status: string): StatusPillKey {
return CONNECTION_STATUS[status] ?? "unchecked";
}
@@ -0,0 +1,93 @@
// Единая сущность «Агент» = канал + AI-конфигурация (ADR-HUB-0041 §4).
// Источник данных — агрегированный API /api/v1/agents/.
import { api } from "../../api/client";
import type { AgentKnowledgeRef, AgentPortalArticleRef, CredentialMode } from "../ai/model";
export type AgentConnection = {
id: number;
provider: string;
name: string;
status: string;
};
export type AgentAiStatus = "DRAFT" | "ACTIVE" | "DISABLED";
export type AgentCard = {
id: number;
aiAgentId: number;
code: string;
name: string;
isActive: boolean;
groupId: number | null;
groupName: string | null;
aiStatus: AgentAiStatus;
model: string;
credentialMode: CredentialMode;
providerIntegrationId: number | null;
modelParams: Record<string, unknown>;
limits: Record<string, unknown>;
persona: string;
tone: string;
instructions: string;
knowledge: AgentKnowledgeRef[];
portalArticles: AgentPortalArticleRef[];
connections: AgentConnection[];
counters: { openConversations: number; connections: number };
createdAt: string;
updatedAt: string;
};
export type AgentPatch = Partial<{
name: string;
groupId: number | null;
isActive: boolean;
credentialMode: CredentialMode;
providerIntegrationId: number | null;
persona: string;
tone: string;
instructions: string;
knowledgeIds: number[];
limits: Record<string, unknown>;
}>;
export function fetchAgents(): Promise<{ items: AgentCard[] }> {
return api<{ items: AgentCard[] }>("/api/v1/agents/");
}
export function fetchAgent(agentId: number): Promise<{ agent: AgentCard }> {
return api<{ agent: AgentCard }>(`/api/v1/agents/${agentId}/`);
}
export function createAgent(input: { name: string; groupId: number | null }): Promise<{ agent: AgentCard }> {
return api<{ agent: AgentCard }>("/api/v1/agents/", { method: "POST", body: JSON.stringify(input) });
}
export function patchAgent(agentId: number, patch: AgentPatch): Promise<{ agent: AgentCard }> {
return api<{ agent: AgentCard }>(`/api/v1/agents/${agentId}/`, { method: "PATCH", body: JSON.stringify(patch) });
}
export function deleteAgent(agentId: number): Promise<void> {
return api<void>(`/api/v1/agents/${agentId}/`, { method: "DELETE" });
}
export function setAgentAiActive(agentId: number, active: boolean): Promise<{ agent: AgentCard }> {
return api<{ agent: AgentCard }>(`/api/v1/agents/${agentId}/${active ? "activate" : "deactivate"}/`, { method: "POST" });
}
export function bindAgentConnection(agentId: number, integrationId: number, force = false): Promise<{ agent: AgentCard }> {
return api<{ agent: AgentCard }>(`/api/v1/agents/${agentId}/connections/`, {
method: "POST",
body: JSON.stringify({ integrationId, force }),
});
}
export function unbindAgentConnection(agentId: number, integrationId: number): Promise<{ agent: AgentCard }> {
return api<{ agent: AgentCard }>(`/api/v1/agents/${agentId}/connections/${integrationId}/`, { method: "DELETE" });
}
// Статус карточки для списка: канал выключен > AI активен > черновик.
export function agentStatusLabel(card: AgentCard): { text: string; tone: "active" | "paused" | "disabled" } {
if (!card.isActive) return { text: "Выключен", tone: "disabled" };
if (card.aiStatus === "ACTIVE") return { text: "AI отвечает", tone: "active" };
return { text: "Без AI", tone: "paused" };
}
@@ -1,41 +1,44 @@
.channel-detail {
/* Раздел «Агенты» (SPEC-HUB-0031 §8.3, §4.3). Токены и паттерны — из DS;
классы agent-* повторяют вид карточных секций каналов, чтобы раздел жил
после удаления features/channels. */
.agent-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
.channel-detail-title,
.channel-detail-meta,
.channel-detail-actions,
.channel-section-actions,
.channel-danger-actions {
.agent-detail-title,
.agent-detail-meta,
.agent-detail-actions,
.agent-section-actions,
.agent-danger-actions {
display: flex;
align-items: center;
gap: 10px;
}
.channel-detail-title {
.agent-detail-title {
margin-bottom: 5px;
}
.channel-detail-meta {
.agent-detail-meta {
color: var(--text-tertiary);
font-size: 12.5px;
}
.channel-detail-meta code,
.channel-agent-summary code {
.agent-detail-meta code {
font-family: var(--font-mono, ui-monospace, monospace);
}
.channel-detail-header {
.agent-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.channel-detail-header h1 {
.agent-detail-header h1 {
margin: 0;
color: var(--text-heading);
font-size: 22px;
@@ -43,143 +46,132 @@
letter-spacing: -0.02em;
}
.channel-detail-actions {
.agent-detail-actions {
flex: none;
}
.channel-detail-actions .secondary-button { height: 36px; padding-inline: 14px; font-weight: 600; }
.channel-detail-title > .status-pill.active {
padding: 5px 11px;
border-color: var(--success-border) !important;
background: var(--success-bg) !important;
color: var(--success-text) !important;
border-radius: var(--radius-pill);
font-weight: 600;
}
.channel-danger-actions {
.agent-danger-actions {
justify-content: flex-end;
margin-top: 8px;
padding-top: 20px;
border-top: 1px solid var(--border-default);
}
.channel-card-section > header .secondary-button { height: 32px; padding-inline: 12px; font-size: 12.5px; font-weight: 600; }
.channel-feedback,
.channel-notice {
.agent-feedback {
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
line-height: 1.5;
}
.channel-notice {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.channel-notice strong {
display: block;
}
.channel-notice p {
margin: 4px 0 0;
}
.channel-feedback.is-error {
.agent-feedback.is-error {
border: 1px solid var(--error-border);
background: var(--error-bg);
color: var(--error-text);
}
.channel-feedback.is-warning,
.channel-notice {
.agent-feedback.is-warning {
border: 1px solid var(--warning-border);
background: var(--warning-bg-strong);
color: var(--warning-text);
}
.channel-card-section,
.channel-card-aside {
.agent-card-section {
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
background: #fff;
box-shadow: var(--shadow-sm);
}
.channel-card-section {
padding: 20px;
}
.channel-card-section > header {
.agent-card-section > header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.channel-card-section > header h3 {
.agent-card-section > header h3 {
margin: 0 auto 0 0;
color: var(--text-heading);
font-size: 15px;
font-weight: 600;
}
.channel-card-section > header span {
.agent-card-section > header span {
color: var(--text-tertiary);
font-size: 12px;
}
.channel-section-actions {
.agent-section-actions {
justify-content: flex-end;
margin-top: 16px;
}
.channel-section-eyebrow {
margin-bottom: 12px;
color: var(--text-tertiary);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
}
.channel-assignment {
.agent-assignment {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 24px;
font-size: 13px;
}
.channel-assignment .key-value,
.channel-card-aside .key-value {
.agent-assignment .key-value {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--n-9);
color: var(--text-body);
font-size: 12.5px;
}
.channel-assignment .key-value {
padding: 8px 0;
border-bottom: 1px solid var(--n-9);
}
.channel-card-aside .key-value + .key-value {
margin-top: 10px;
}
.channel-assignment .key-value > span,
.channel-card-aside .key-value > span {
.agent-assignment .key-value > span {
color: var(--text-tertiary);
}
.channel-assignment .key-value > strong,
.channel-card-aside .key-value > strong {
.agent-assignment .key-value > strong {
font-weight: 500;
text-align: right;
}
.agent-muted {
color: var(--text-tertiary);
font-size: 13px;
}
.agent-connections {
display: flex;
flex-direction: column;
gap: 8px;
}
.agent-connection {
display: flex;
align-items: center;
gap: 11px;
padding: 11px 12px;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
}
.agent-connection-text {
flex: 1;
line-height: 1.3;
}
.agent-connection-text strong {
display: block;
color: var(--text-body);
font-size: 13px;
}
.agent-connection-text span {
color: var(--text-tertiary);
font-size: 11.5px;
}
.agent-instructions-grid {
display: grid;
gap: 12px;
}
@@ -1,22 +0,0 @@
import type { AiAgent } from "./model";
export function AgentsKpiStrip({ agents }: { agents: AiAgent[] }) {
const active = agents.filter((agent) => agent.isActive).length;
// Метрики диалогов/конверсии/стоимости появятся с контуром коммуникаций (E05) — пока degraded.
const cards: Array<{ label: string; value: string }> = [
{ label: "Агентов активно", value: `${active} / ${agents.length}` },
{ label: "Диалогов на AI сейчас", value: "—" },
{ label: "Конверсия AI · сегодня", value: "—" },
{ label: "Стоимость AI · сегодня", value: "—" },
];
return (
<div className="ai-kpi-strip">
{cards.map((card) => (
<div className="ai-kpi-card" key={card.label}>
<div className="ai-kpi-label">{card.label}</div>
<div className="ai-kpi-value">{card.value}</div>
</div>
))}
</div>
);
}
@@ -1,96 +0,0 @@
import { Dropdown } from "antd";
import { Icon } from "../../shared/icons";
import { StatusPill } from "../../shared/ui";
import { ToneBadge } from "../../shared/ui-controls";
import { productAccent } from "../../shared/utils";
import type { AiAgent } from "./model";
export function AgentsTable({
agents,
menuId,
setMenuId,
toggleActive,
openAgent,
}: {
agents: AiAgent[];
menuId: number | null;
setMenuId: (id: number | null) => void;
toggleActive: (agent: AiAgent) => void;
openAgent: (agentId: number) => void;
}) {
return (
<div className="table-card">
<div className="ai-agents-scroll">
<table className="baseline-table">
<thead>
<tr>
<th>АГЕНТ</th>
<th>КАНАЛ</th>
<th>МОДЕЛЬ</th>
<th className="numeric">ЗНАНИЯ</th>
<th>СТАТУС</th>
<th className="numeric">ДИАЛОГИ СЕЙЧАС</th>
<th className="numeric">КОНВЕРСИЯ</th>
<th />
</tr>
</thead>
<tbody>
{agents.map((agent) => {
const accent = productAccent(agent.channel.code);
const menuItems = [
{ key: "open", label: <button type="button" onClick={() => { setMenuId(null); openAgent(agent.id); }}><Icon name="external" size={15} />Открыть агента</button> },
{ type: "divider" as const },
{
key: "toggle",
label: (
<button type="button" className={agent.isActive ? "warning" : ""} onClick={() => toggleActive(agent)}>
<Icon name={agent.isActive ? "pause" : "bolt"} size={15} />
{agent.isActive ? "Остановить агента" : "Запустить агента"}
</button>
),
},
];
return (
<tr key={agent.id}>
<td>
<div className="product-cell">
<span className="product-icon" style={{ background: accent.bg, color: accent.color }}><Icon name="robot" size={21} /></span>
<span>
<button className="link is-strong is-neutral" type="button" onClick={() => openAgent(agent.id)}>{agent.name}</button>
<small>{agent.channel.code}</small>
</span>
</div>
</td>
<td><ToneBadge bg={accent.bg} color={accent.color}>{agent.channel.name}</ToneBadge></td>
<td><code className="ai-mono">{agent.model}</code></td>
<td className="numeric">{agent.knowledge.length > 0 ? agent.knowledge.length : <span className="product-empty-value">—</span>}</td>
<td><StatusPill status={agent.isActive ? "active" : "disabled"} /></td>
<td className="numeric"><span className="product-empty-value">—</span></td>
<td className="numeric"><span className="product-empty-value">—</span></td>
<td className="row-actions">
<div className="ai-row-actions">
<Dropdown
menu={{ items: menuItems }}
open={menuId === agent.id}
onOpenChange={(open) => setMenuId(open ? agent.id : null)}
trigger={["click"]}
overlayClassName="app-dropdown"
>
<button className="row-menu-button" type="button" aria-label="Действия агента"><Icon name="more" /></button>
</Dropdown>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="ai-table-footer">
<span>{agents.length} агента</span>
<span>Один агент на канал обработки</span>
</div>
</div>
);
}
@@ -1,27 +0,0 @@
import { useState } from "react";
import { api } from "../../api/client";
import { PageHeader } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { AgentsKpiStrip } from "./AgentsKpiStrip";
import { AgentsTable } from "./AgentsTable";
import type { AiAgent } from "./model";
export function AiAgentsPage({ agents, reload, openAgentCreate, openAgent }: { agents: AiAgent[]; reload: () => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void }) {
const [menuId, setMenuId] = useState<number | null>(null);
async function toggleActive(agent: AiAgent) {
const action = agent.isActive ? "deactivate" : "activate";
await api(`/api/v1/ai/agents/${agent.id}/${action}/`, { method: "POST" }).catch(() => undefined);
setMenuId(null);
reload();
}
return (
<div className="ai-page">
<PageHeader title="AI-агенты" text="Один агент на канал обработки · изменения применяются сразу" action={<Button variant="primary" icon="plus" onClick={() => openAgentCreate(null)}>Создать агента</Button>} />
<AgentsKpiStrip agents={agents} />
<AgentsTable agents={agents} menuId={menuId} setMenuId={setMenuId} toggleActive={toggleActive} openAgent={openAgent} />
</div>
);
}
@@ -1,107 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../../../api/client";
import { Icon } from "../../../shared/icons";
import { LoadingState } from "../../../shared/ui";
import type { RouteKey } from "../../../types";
import { fetchLlmProviders, type Integration } from "../../integrations/model";
import type { CredentialMode } from "../model";
import { useAiAgents } from "../useAiAgents";
import { CreateAgentFooter } from "./CreateAgentFooter";
import { ModelStep } from "./ModelStep";
import { ProductChoiceStep } from "./ProductChoiceStep";
import { startInstructions, startPersona, startTone, type ChannelOption, type CreateAgentResponse } from "./model";
export function AiAgentCreatePage({ selectedProductCode, reload, setRoute, openAgent }: { selectedProductCode: string | null; reload: () => void; setRoute: (route: RouteKey) => void; openAgent: (agentId: number) => void }) {
const { agents, loading: agentsLoading, reload: reloadAgents } = useAiAgents();
const [channels, setChannels] = useState<ChannelOption[]>([]);
const [channelsLoading, setChannelsLoading] = useState(true);
const [channelCode, setChannelCode] = useState<string | null>(selectedProductCode);
const [credentialMode, setCredentialMode] = useState<CredentialMode>("CUSTOAI");
const [providerIntegrationId, setProviderIntegrationId] = useState<number | null>(null);
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
api<{ items: ChannelOption[] }>("/api/v1/channels/")
.then((payload) => setChannels(payload.items))
.catch(() => setChannels([]))
.finally(() => setChannelsLoading(false));
}, []);
useEffect(() => {
fetchLlmProviders().then(setIntegrations).catch(() => setIntegrations([]));
}, []);
const agentChannelCodes = useMemo(() => new Set(agents.map((agent) => agent.channel.code)), [agents]);
const availableChannels = channels.filter((channel) => !agentChannelCodes.has(channel.code));
const channelsWithAgents = channels.filter((channel) => agentChannelCodes.has(channel.code)).map((channel) => channel.name);
const selectedChannel = availableChannels.find((channel) => channel.code === channelCode) ?? null;
const ready = !!selectedChannel && (credentialMode === "CUSTOAI" || providerIntegrationId !== null);
useEffect(() => {
if (!channelCode || agentChannelCodes.has(channelCode)) setChannelCode(availableChannels[0]?.code ?? null);
}, [agentChannelCodes, availableChannels, channelCode]);
async function submit() {
if (!ready || !selectedChannel) return;
setSubmitting(true);
setError(false);
try {
// Мастер только создаёт агента: инструкции уходят стартовыми, знания не
// выбираются — и то, и другое настраивается в карточке агента.
const response = await api<CreateAgentResponse>("/api/v1/ai/agents/", {
method: "POST",
body: JSON.stringify({
channel: selectedChannel.code,
credentialMode,
providerIntegrationId,
persona: startPersona,
tone: startTone,
instructions: startInstructions,
knowledgeIds: [],
}),
});
reload();
reloadAgents();
if (response.agent) openAgent(response.agent.id);
else setRoute("aiAgents");
} catch {
setError(true);
} finally {
setSubmitting(false);
}
}
const summary = ready && selectedChannel
? `Будет создан агент для канала «${selectedChannel.name}» · ${credentialMode === "CUSTOAI" ? "CustoAI" : "BYOK"}. Агент создаётся выключенным — инструкции и знания настройте в его карточке.`
: "Выберите канал без агента, чтобы продолжить.";
if (agentsLoading || channelsLoading) return <div className="ai-create-page"><LoadingState /></div>;
return (
<div className="ai-create-page">
<div className="ai-create-container">
<div className="ai-create-header">
<h1>Создание AI-агента</h1>
<p>Один агент на канал обработки. Агент создаётся для канала без агента; начнёт отвечать после запуска.</p>
</div>
<div className="ai-create-provider">
<Icon name="check" size={16} />
{credentialMode === "CUSTOAI" ? "CustoAI · platform credential" : "BYOK · интеграция канала"}
</div>
{error && <div className="ai-create-error">Не удалось создать агента. Проверьте выбранный канал.</div>}
<ProductChoiceStep channels={availableChannels} selectedChannelCode={channelCode} channelsWithAgents={channelsWithAgents} onSelect={setChannelCode} />
<ModelStep
credentialMode={credentialMode}
setCredentialMode={setCredentialMode}
providerIntegrationId={providerIntegrationId}
setProviderIntegrationId={setProviderIntegrationId}
integrations={integrations}
/>
</div>
<CreateAgentFooter summary={summary} ready={ready} submitting={submitting} onCancel={() => setRoute("aiAgents")} onSubmit={submit} />
</div>
);
}
@@ -1,15 +0,0 @@
import { Button } from "../../../shared/ui-controls";
export function CreateAgentFooter({ summary, ready, submitting, onCancel, onSubmit }: { summary: string; ready: boolean; submitting: boolean; onCancel: () => void; onSubmit: () => void }) {
return (
<div className="ai-create-footer">
<div className="ai-create-footer-inner">
<span>{summary}</span>
<div>
<Button variant="secondary" onClick={onCancel}>Отмена</Button>
<Button variant="primary" icon="plus" disabled={!ready || submitting} onClick={onSubmit}>Создать агента</Button>
</div>
</div>
</div>
);
}
@@ -1,14 +0,0 @@
import type { ReactNode } from "react";
export function CreateAgentStepCard({ number, title, text, children }: { number: number; title: string; text: string; children: ReactNode }) {
return (
<section className="ai-create-step">
<div className="ai-create-step-title">
<span>{number}</span>
<h3>{title}</h3>
</div>
<p>{text}</p>
{children}
</section>
);
}
@@ -1,48 +0,0 @@
import { SelectField } from "../../../shared/form-controls";
import type { Integration } from "../../integrations/model";
import { CREDENTIAL_MODE_OPTIONS, type CredentialMode } from "../model";
import { CreateAgentStepCard } from "./CreateAgentStepCard";
export function ModelStep({
credentialMode,
setCredentialMode,
providerIntegrationId,
setProviderIntegrationId,
integrations,
}: {
credentialMode: CredentialMode;
setCredentialMode: (mode: CredentialMode) => void;
providerIntegrationId: number | null;
setProviderIntegrationId: (id: number | null) => void;
integrations: Integration[];
}) {
const integrationOptions: Array<[string, string]> = [
["", "Выберите интеграцию"],
...integrations.map((item) => [String(item.id), item.name] as [string, string]),
];
return (
<CreateAgentStepCard number={2} title="AI-провайдер" text="CustoAI готов к работе сразу. При необходимости подключите собственный OpenRouter или Custom-провайдер.">
<div className="ai-create-field-offset">
<SelectField
label="Режим AI"
value={credentialMode}
onChange={(value) => setCredentialMode(value as CredentialMode)}
options={CREDENTIAL_MODE_OPTIONS}
/>
{credentialMode === "BYOK" && (
<SelectField
label="Интеграция"
value={providerIntegrationId ? String(providerIntegrationId) : ""}
onChange={(value) => setProviderIntegrationId(value ? Number(value) : null)}
options={integrationOptions}
/>
)}
<div className="ai-create-help">
{credentialMode === "CUSTOAI"
? "CustoAI использует настроенную платформой модель и лимит организации."
: "Модель берётся из выбранной OpenRouter или Custom-интеграции."}
</div>
</div>
</CreateAgentStepCard>
);
}
@@ -1,28 +0,0 @@
import { productAccent } from "../../../shared/utils";
import { channelDescription, channelMark, type ChannelOption } from "./model";
import { CreateAgentStepCard } from "./CreateAgentStepCard";
export function ProductChoiceStep({ channels, selectedChannelCode, channelsWithAgents, onSelect }: { channels: ChannelOption[]; selectedChannelCode: string | null; channelsWithAgents: string[]; onSelect: (channelCode: string) => void }) {
return (
<CreateAgentStepCard number={1} title="Канал обработки" text="Доступны только каналы без агента. У канала может быть не более одного агента.">
<div className="ai-create-product-list">
{channels.map((channel) => {
const selected = selectedChannelCode === channel.code;
const accent = productAccent(channel.code);
return (
<button className={selected ? "is-selected" : ""} type="button" onClick={() => onSelect(channel.code)} key={channel.code}>
<span className="ai-create-product-mark" style={{ background: accent.bg, color: accent.color }}>{channelMark(channel)}</span>
<span><strong>{channel.name}</strong><small>{channelDescription(channel)}</small></span>
<i />
</button>
);
})}
</div>
{channelsWithAgents.length > 0 && (
<div className="ai-create-note">
У каналов <b>{channelsWithAgents.join(", ")}</b> агент уже есть — для них доступен переход в существующую карточку, а не создание второго.
</div>
)}
</CreateAgentStepCard>
);
}
@@ -1,29 +0,0 @@
.ai-create-footer {
position: sticky;
bottom: 0;
margin: 18px -28px 0;
padding: 14px 28px;
background: #fff;
border-top: 1px solid #f0f0f0;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.03);
}
.ai-create-footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.ai-create-footer-inner > span {
color: #8c8c8c;
font-size: 12px;
line-height: 1.4;
}
.ai-create-footer-inner > div {
display: flex;
flex: none;
align-items: center;
gap: 10px;
}
@@ -1,77 +0,0 @@
.ai-create-field-offset {
padding-left: 34px;
}
.ai-create-model {
width: 100%;
max-width: 380px;
height: 42px;
padding: 0 12px;
color: #262626;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 9px;
font-family: "SF Mono", ui-monospace, Menlo, monospace;
font-size: 13.5px;
}
.ai-create-help {
margin-top: 8px;
color: #9a9a9a;
font-size: 11.5px;
}
.ai-create-label {
display: block;
margin-bottom: 6px;
color: #595959;
font-size: 11.5px;
font-weight: 600;
}
.ai-create-field-offset textarea {
width: 100%;
padding: 12px 14px;
color: #262626;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 9px;
font-size: 12.5px;
line-height: 1.6;
resize: vertical;
}
.ai-create-field-offset textarea:focus,
.ai-create-model:focus {
border-color: #1677ff;
outline: none;
}
.ai-create-chips {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 11px;
}
.ai-create-chips span {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
color: #595959;
background: #f5f5f5;
border-radius: 7px;
font-size: 11.5px;
}
.ai-create-chips svg {
color: #389e0d;
stroke-width: 2.4;
}
.ai-create-chips small {
align-self: center;
color: #bfbfbf;
font-size: 11.5px;
}
@@ -1,100 +0,0 @@
.ai-create-page {
min-height: 100%;
padding: 24px 28px 0;
}
.ai-create-container,
.ai-create-footer-inner {
max-width: 760px;
margin: 0 auto;
}
.ai-create-header {
margin-bottom: 6px;
}
.ai-create-header h1 {
margin: 0;
color: #1f1f1f;
font-size: 24px;
font-weight: 700;
letter-spacing: 0;
}
.ai-create-header p {
margin: 7px 0 0;
color: #8c8c8c;
font-size: 13.5px;
line-height: 1.5;
}
.ai-create-provider,
.ai-create-error {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 15px;
margin: 16px 0 22px;
color: #3f6b32;
background: #f6ffed;
border: 1px solid #d9f0c8;
border-radius: 10px;
font-size: 12.5px;
}
.ai-create-provider svg {
color: #3f8f3f;
}
.ai-create-provider b {
font-family: "SF Mono", ui-monospace, Menlo, monospace;
}
.ai-create-error {
color: #cf1322;
background: #fff1f0;
border-color: #ffccc7;
}
.ai-create-step {
padding: 20px 22px;
margin-bottom: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.ai-create-step-title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 5px;
}
.ai-create-step-title span {
width: 24px;
height: 24px;
display: flex;
flex: none;
align-items: center;
justify-content: center;
color: #fff;
background: #1677ff;
border-radius: 7px;
font-size: 12px;
font-weight: 700;
}
.ai-create-step-title h3 {
margin: 0;
color: #1f1f1f;
font-size: 15px;
}
.ai-create-step > p {
margin: 0 0 14px;
padding-left: 34px;
color: #8c8c8c;
font-size: 12.5px;
}
@@ -1,19 +0,0 @@
import type { AiAgent } from "../model";
export type ChannelOption = { id: number; code: string; name: string; product: { code: string; name: string } | null };
export type CreateAgentResponse = {
agent?: AiAgent;
};
export const startPersona = "Ты — AI-ассистент канала обработки. Представляй компанию и помогай клиенту разобраться в продукте.";
export const startTone = "Отвечай дружелюбно и по делу, на русском, простым текстом без markdown-разметки, короткими абзацами.";
export const startInstructions = "Квалифицируй потребность клиента и помогай довести до результата. Не обещай возможности вне базы знаний. При запросе человека или нестандартной ситуации — передавай диалог оператору.";
export function channelDescription(channel: ChannelOption): string {
return channel.product ? `Без агента · продукт ${channel.product.name}` : "Без агента · без продукта";
}
export function channelMark(channel: ChannelOption): string {
return (channel.name[0] || channel.code[0] || "").toUpperCase();
}
@@ -1,81 +0,0 @@
.ai-create-product-list {
display: flex;
flex-direction: column;
gap: 9px;
}
.ai-create-product-list button {
display: flex;
align-items: center;
gap: 13px;
width: 100%;
padding: 12px 14px;
background: #fff;
border: 1.5px solid #ececec;
border-radius: 11px;
text-align: left;
}
.ai-create-product-list button.is-selected {
border-color: #1677ff;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.08);
}
.ai-create-product-mark {
width: 38px;
height: 38px;
display: flex;
flex: none;
align-items: center;
justify-content: center;
border-radius: 10px;
font-size: 15px;
font-weight: 700;
}
.ai-create-product-list button > span:nth-child(2) {
flex: 1;
min-width: 0;
}
.ai-create-product-list strong {
display: block;
color: #262626;
font-size: 14px;
font-weight: 600;
}
.ai-create-product-list small {
display: block;
margin-top: 1px;
color: #9a9a9a;
font-size: 11.5px;
}
.ai-create-product-list i {
width: 20px;
height: 20px;
flex: none;
background: #fff;
border: 2px solid #d9d9d9;
border-radius: 50%;
}
.ai-create-product-list .is-selected i {
border: 6px solid #1677ff;
}
.ai-create-note {
margin-top: 14px;
padding: 10px 13px;
color: #9a9a9a;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 9px;
font-size: 11.5px;
line-height: 1.5;
}
.ai-create-note b {
color: #595959;
}
@@ -1,4 +0,0 @@
@import "./layout.css";
@import "./product-choice.css";
@import "./form.css";
@import "./footer.css";
@@ -1,120 +0,0 @@
import { Modal } from "antd";
import { useEffect, useState } from "react";
import { api } from "../../../api/client";
import { FormField, SelectField } from "../../../shared/form-controls";
import { Button } from "../../../shared/ui-controls";
import { CREDENTIAL_MODE_OPTIONS, type CredentialMode } from "../model";
import { fetchLlmProviders, type Integration } from "../../integrations/model";
import type { AiAgentDetail } from "./model";
// Известные инструменты системы (services.py: operator-handoff). Хранятся как string[].
const TOOLS: Array<[string, string]> = [["operator-handoff", "Передача оператору"]];
// Дневной бюджет хранится в целых центах USD (dailyCostUsd); в поле вводим доллары.
function budgetFromCents(limits: Record<string, unknown>): string {
const cents = limits?.dailyCostUsd;
if (typeof cents !== "number" || cents <= 0) return "";
return (cents / 100).toString();
}
// Допускаются неотрицательные доллары: до 2 знаков после запятой, разделитель «.» или «,».
function isBudget(value: string): boolean {
const trimmed = value.trim();
if (trimmed === "") return true;
return /^\d+([.,]\d{1,2})?$/.test(trimmed);
}
export function AgentEditForm({ agent, onClose, onSaved }: { agent: AiAgentDetail; onClose: () => void; onSaved: () => void }) {
const [name, setName] = useState(agent.name);
const [credentialMode, setCredentialMode] = useState<CredentialMode>(agent.credentialMode);
const [providerIntegrationId, setProviderIntegrationId] = useState<number | null>(agent.providerIntegrationId);
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [tools, setTools] = useState<string[]>(Array.isArray(agent.allowedTools) ? (agent.allowedTools as string[]) : []);
const [budget, setBudget] = useState(budgetFromCents(agent.limits as Record<string, unknown>));
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
function toggleTool(code: string) {
setTools((current) => (current.includes(code) ? current.filter((item) => item !== code) : [...current, code]));
}
useEffect(() => {
fetchLlmProviders().then(setIntegrations).catch(() => setIntegrations([]));
}, []);
const ready = name.trim().length > 0 && isBudget(budget) && (credentialMode === "CUSTOAI" || providerIntegrationId !== null);
async function submit() {
if (!ready) return;
setSubmitting(true);
setError(null);
const dollars = parseFloat(budget.trim().replace(",", "."));
const limits: Record<string, number> = Number.isFinite(dollars) && dollars > 0 ? { dailyCostUsd: Math.round(dollars * 100) } : {};
try {
await api(`/api/v1/ai/agents/${agent.id}/update/`, {
method: "PATCH",
body: JSON.stringify({ name: name.trim(), credentialMode, providerIntegrationId, allowedTools: tools, limits }),
});
onSaved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось сохранить");
} finally {
setSubmitting(false);
}
}
return (
<Modal open title="Изменить агента" onCancel={onClose} footer={null} destroyOnClose>
<div className="integration-form">
<FormField label="Название" value={name} onChange={setName} placeholder="название агента" />
<SelectField
label="Режим AI"
value={credentialMode}
onChange={(value) => setCredentialMode(value as CredentialMode)}
options={CREDENTIAL_MODE_OPTIONS}
/>
{credentialMode === "BYOK" && (
<SelectField
label="Интеграция"
value={providerIntegrationId ? String(providerIntegrationId) : ""}
onChange={(value) => setProviderIntegrationId(value ? Number(value) : null)}
options={[["", "Выберите интеграцию"], ...integrations.map((item) => [String(item.id), item.name] as [string, string])]}
/>
)}
<div className="ai-edit-tools">
<span className="ai-edit-label">Инструменты</span>
<div className="ai-edit-checks">
{TOOLS.map(([code, label]) => (
<label key={code} className="ai-edit-check">
<input type="checkbox" checked={tools.includes(code)} onChange={() => toggleTool(code)} />
{label}
</label>
))}
</div>
</div>
<div className="ai-edit-limits">
<span className="ai-edit-label">Лимиты</span>
<div className="ai-edit-limit-grid">
<label className="ai-edit-limit-field">
<span>Бюджет в день, $</span>
<input
type="text"
inputMode="decimal"
className={isBudget(budget) ? "" : "invalid"}
value={budget}
placeholder="0.00"
onChange={(event) => setBudget(event.target.value)}
/>
</label>
</div>
</div>
{error && <div className="integration-form-error">{error}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={!ready || submitting} onClick={submit}>{submitting ? "Сохранение…" : "Сохранить"}</Button>
</div>
</div>
</Modal>
);
}
@@ -1,27 +0,0 @@
import { Icon } from "../../../shared/icons";
import { StatusPill } from "../../../shared/ui";
import { productAccent } from "../../../shared/utils";
import { dailyBudget, type AiAgentDetail } from "./model";
export function AiAgentDetailHeader({ agent, onOpenChannel }: { agent: AiAgentDetail; onOpenChannel: () => void }) {
const accent = productAccent(agent.channel.code);
return (
<section className="ai-agent-header">
<div className="ai-agent-heading">
<span className="ai-agent-icon" style={{ background: accent.bg, color: accent.color }}><Icon name="robot" size={27} /></span>
<div className="ai-agent-title">
<div className="ai-agent-title-row">
<h1>{agent.name}</h1>
<StatusPill status={agent.isActive ? "active" : "disabled"} />
</div>
<div className="ai-agent-meta">
<div><span>Канал</span><button type="button" className="link" onClick={onOpenChannel}>{agent.channel.name}</button></div>
<div><span>Модель</span><code className="ai-mono">{agent.model}</code></div>
<div><span>Знания</span><b>{agent.knowledge.length > 0 ? `${agent.knowledge.length} выбрано` : "—"}</b></div>
<div><span>Лимиты</span><b>{dailyBudget(agent.limits)}</b></div>
</div>
</div>
</div>
</section>
);
}
@@ -1,47 +0,0 @@
import { useEffect, useState } from "react";
import { api } from "../../../api/client";
import { EmptyState, LoadingState } from "../../../shared/ui";
import type { RouteKey } from "../../../types";
import { UnderlineTabs } from "../../../shared/ui-controls";
import { AiAgentDetailHeader } from "./AiAgentDetailHeader";
import { AgentEditForm } from "./AgentEditForm";
import { AiAgentInstructionsTab } from "./AiAgentInstructionsTab";
import { AiAgentKnowledgeTab } from "./AiAgentKnowledgeTab";
import { AiAgentMetricsTab } from "./AiAgentMetricsTab";
import { AiAgentOverviewTab } from "./AiAgentOverviewTab";
import { agentTabs, type AgentTab } from "./model";
import { useAiAgentDetail } from "./useAiAgentDetail";
export function AiAgentDetailPage({ agentId, openKnowledge, openChannel, onAgentLoaded, setRoute }: { agentId: number | null; openKnowledge: (knowledgeId: number) => void; openChannel: (channelId: number) => void; onAgentLoaded: (name: string | null) => void; setRoute: (route: RouteKey) => void }) {
const { agent, loading, error, reload } = useAiAgentDetail(agentId);
const [tab, setTab] = useState<AgentTab>("overview");
const [editOpen, setEditOpen] = useState(false);
useEffect(() => {
onAgentLoaded(agent?.name ?? null);
return () => onAgentLoaded(null);
}, [agent, onAgentLoaded]);
async function toggleActive() {
if (!agent) return;
const action = agent.isActive ? "deactivate" : "activate";
await api(`/api/v1/ai/agents/${agent.id}/${action}/`, { method: "POST" }).catch(() => undefined);
reload();
}
if (loading) return <div className="ai-page"><LoadingState /></div>;
if (error || !agent) return <div className="ai-page"><EmptyState title="Не удалось загрузить агента" /></div>;
return (
<div className="ai-agent-page">
<AiAgentDetailHeader agent={agent} onOpenChannel={() => openChannel(agent.channel.id)} />
<UnderlineTabs className="ai-agent-tabs" items={agentTabs} value={tab} onChange={setTab} />
{tab === "overview" && <AiAgentOverviewTab agent={agent} toggleActive={toggleActive} onEdit={() => setEditOpen(true)} />}
{tab === "instructions" && <AiAgentInstructionsTab agent={agent} onChanged={reload} />}
{tab === "knowledge" && <AiAgentKnowledgeTab agent={agent} openKnowledge={openKnowledge} setRoute={setRoute} />}
{tab === "metrics" && <AiAgentMetricsTab />}
{editOpen && <AgentEditForm agent={agent} onClose={() => setEditOpen(false)} onSaved={() => { setEditOpen(false); reload(); }} />}
</div>
);
}
@@ -1,57 +0,0 @@
import { useState } from "react";
import { api } from "../../../api/client";
import { Button } from "../../../shared/ui-controls";
import type { AiAgentDetail } from "./model";
const FIELDS: Array<{ key: "persona" | "tone" | "instructions"; title: string; hint: string }> = [
{ key: "persona", title: "Персонализация", hint: "Кто он и что он: имя, роль, продукт, рамки ответственности." },
{ key: "tone", title: "Тон общения", hint: "Как он должен говорить: стиль, формат, язык." },
{ key: "instructions", title: "Инструкции", hint: "Правила работы: что делать, чего не делать, когда передавать оператору." },
];
export function AiAgentInstructionsTab({ agent, onChanged }: { agent: AiAgentDetail; onChanged: () => void }) {
const [values, setValues] = useState({ persona: agent.persona, tone: agent.tone, instructions: agent.instructions });
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirty = values.persona !== agent.persona || values.tone !== agent.tone || values.instructions !== agent.instructions;
async function save() {
setBusy(true);
setError(null);
try {
await api(`/api/v1/ai/agents/${agent.id}/update/`, { method: "PATCH", body: JSON.stringify(values) });
onChanged();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось сохранить");
} finally {
setBusy(false);
}
}
return (
<div className="ai-instructions">
{FIELDS.map((field, index) => (
<section className="ai-card ai-doc-card" key={field.key}>
<div className="ai-instruction-head">
<span className="ai-instruction-index">{index + 1}</span>
<h3>{field.title}</h3>
<small>{field.hint}</small>
</div>
<textarea
className="ai-doc-textarea"
rows={field.key === "instructions" ? 10 : 5}
value={values[field.key]}
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
/>
</section>
))}
{error && <div className="ai-doc-error">{error}</div>}
<div className="ai-doc-actions">
<Button variant="primary" icon="save" disabled={!dirty || busy} onClick={save}>{busy ? "Сохранение…" : "Сохранить инструкции"}</Button>
<span className="ai-doc-updated">Изменения применяются к следующему ответу агента сразу после сохранения.</span>
</div>
</div>
);
}
@@ -1,61 +0,0 @@
import { Icon } from "../../../shared/icons";
import { EmptyState } from "../../../shared/ui";
import { Button } from "../../../shared/ui-controls";
import type { RouteKey } from "../../../types";
import type { AiAgentDetail } from "./model";
/**
* Список знаний агента — только чтение. Состав меняется массовыми действиями в
* разделах «Знания» и «Поддержка»: один способ управления вместо двух.
*/
export function AiAgentKnowledgeTab({ agent, openKnowledge, setRoute }: { agent: AiAgentDetail; openKnowledge: (knowledgeId: number) => void; setRoute: (route: RouteKey) => void }) {
const total = agent.knowledge.length + agent.portalArticles.length;
return (
<div className="ai-instructions">
<div className="ai-knowledge-headline">
<span>Знания агента · {agent.channel.name} · всего {total}</span>
<Button variant="secondary" icon="external" onClick={() => setRoute("aiKnowledge")}>Перейти в знания</Button>
</div>
{total === 0 && (
<EmptyState title="Знания не прикреплены — выберите их в разделе «Знания» или статьи на портале поддержки" />
)}
{agent.knowledge.length > 0 && (
<section className="ai-card">
<h4 className="agent-knowledge-group">Знания библиотеки</h4>
<ul className="agent-knowledge-list is-readonly">
{agent.knowledge.map((item) => (
<li key={item.id}>
<button className="link is-strong is-neutral agent-knowledge-text" type="button" onClick={() => openKnowledge(item.id)}>
{item.title}
</button>
{!item.isEnabled && <span className="ai-doc-chip off">Выключено</span>}
<button type="button" className="row-menu-button" aria-label={`Открыть знание ${item.title}`} onClick={() => openKnowledge(item.id)}><Icon name="external" size={15} /></button>
</li>
))}
</ul>
</section>
)}
{agent.portalArticles.length > 0 && (
<section className="ai-card">
<h4 className="agent-knowledge-group">Статьи поддержки</h4>
<ul className="agent-knowledge-list is-readonly">
{agent.portalArticles.map((article) => (
<li key={article.id}>
<a className="link is-strong is-neutral agent-knowledge-text" href={article.publicUrl} rel="noreferrer" target="_blank">
{article.title}
</a>
<span className="ai-doc-chip plain">{article.portal.name}</span>
{article.status !== "PUBLISHED" && <span className="ai-doc-chip off">{article.status === "ARCHIVED" ? "Архив" : "Черновик"}</span>}
<a className="row-menu-button" aria-label={`Открыть статью ${article.title}`} href={article.publicUrl} rel="noreferrer" target="_blank"><Icon name="external" size={15} /></a>
</li>
))}
</ul>
</section>
)}
{total > 0 && (
<p className="ai-doc-updated">Агент использует только включённые знания и опубликованные статьи.</p>
)}
</div>
);
}
@@ -1,22 +0,0 @@
import { EmptyState } from "../../../shared/ui";
const KPIS = ["Диалоги · 30 дней", "Продажи · 30 дней", "Конверсия", "Стоимость / продажа"];
export function AiAgentMetricsTab() {
return (
<div className="ai-agent-metrics">
<div className="ai-kpi-strip">
{KPIS.map((label) => (
<div className="ai-kpi-card" key={label}>
<div className="ai-kpi-label">{label}</div>
<div className="ai-kpi-value">—</div>
</div>
))}
</div>
<section className="ai-card">
<h3>Причины передачи оператору · 30 дней</h3>
<EmptyState title="Данных пока нет" />
</section>
</div>
);
}
@@ -1,38 +0,0 @@
import { Icon } from "../../../shared/icons";
import { dailyBudget, type AiAgentDetail } from "./model";
export function AiAgentOverviewTab({ agent, toggleActive, onEdit }: { agent: AiAgentDetail; toggleActive: () => void; onEdit: () => void }) {
return (
<div className="ai-agent-overview">
<div className="ai-agent-overview-main">
<section className="ai-card">
<h3>Персонализация</h3>
<p className="ai-agent-purpose">{agent.persona.trim() || "—"}</p>
</section>
<section className="ai-card">
<h3>Runtime сейчас</h3>
<div className="ai-runtime-grid">
<div><span>Активные диалоги</span><strong>—</strong></div>
<div><span>В очереди на ответ</span><strong>—</strong></div>
<div><span>Ср. время ответа</span><strong>—</strong></div>
</div>
</section>
</div>
<div className="ai-agent-overview-side">
<section className="ai-card">
<h3>Лимиты</h3>
<div className="ai-limit-row"><span>Бюджет в день</span><b>{dailyBudget(agent.limits)}</b></div>
<div className="ai-limit-bar"><i style={{ width: "0%" }} /></div>
</section>
<section className="ai-card">
<h3>Управление</h3>
<button type="button" className="ai-manage-btn" onClick={onEdit}><Icon name="edit" size={16} />Изменить агента</button>
<button type="button" className="ai-manage-btn warning" onClick={toggleActive}>
<Icon name={agent.isActive ? "pause" : "bolt"} size={16} />
{agent.isActive ? "Остановить агента" : "Запустить агента"}
</button>
</section>
</div>
</div>
);
}
@@ -1,22 +0,0 @@
import type { AiAgent } from "../model";
export type AiAgentDetail = AiAgent & {
createdAt: string;
updatedAt: string;
};
export type AgentTab = "overview" | "instructions" | "knowledge" | "metrics";
export const agentTabs: Array<{ key: AgentTab; label: string }> = [
{ key: "overview", label: "Обзор" },
{ key: "instructions", label: "Инструкции" },
{ key: "knowledge", label: "Знания" },
{ key: "metrics", label: "Метрики" },
];
// agent.limits — единый поддерживаемый лимит: дневной бюджет в целых центах USD (dailyCostUsd).
export function dailyBudget(limits: Record<string, unknown>): string {
const cents = limits?.dailyCostUsd;
if (typeof cents !== "number" || cents <= 0) return "—";
return `$${(cents / 100).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} / день`;
}
@@ -1,657 +0,0 @@
/* AI agent card (AI-02): per-screen styling on shared components. */
.ai-agent-page {
max-width: 1080px;
}
/* --- Header card --- */
.ai-agent-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 20px 22px;
margin-bottom: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.ai-agent-heading {
display: flex;
align-items: flex-start;
gap: 16px;
min-width: 0;
}
.ai-agent-icon {
width: 52px;
height: 52px;
border-radius: 13px;
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
.ai-agent-title h1 {
margin: 0;
font-size: 21px;
font-weight: 700;
letter-spacing: -0.01em;
color: #1f1f1f;
}
.ai-agent-title-row {
display: flex;
align-items: center;
gap: 11px;
flex-wrap: wrap;
}
.ai-agent-meta {
display: flex;
align-items: center;
margin-top: 12px;
flex-wrap: wrap;
}
.ai-agent-meta > div {
padding-right: 18px;
margin-right: 18px;
border-right: 1px solid #f0f0f0;
}
.ai-agent-meta > div:last-child {
padding-right: 0;
margin-right: 0;
border-right: 0;
}
.ai-agent-meta span {
display: block;
font-size: 11px;
color: #8c8c8c;
}
.ai-agent-meta b,
.ai-agent-meta code {
display: block;
margin-top: 2px;
font-size: 13.5px;
font-weight: 600;
color: #262626;
}
.ai-agent-meta-empty {
color: #bfbfbf !important;
}
.ai-agent-header-actions {
display: flex;
align-items: center;
gap: 9px;
flex: none;
}
/* --- Tabs --- */
.ai-agent-tabs {
gap: 2px;
margin-bottom: 18px;
border-bottom: 1px solid #e8e8e8;
overflow-x: auto;
}
.ai-agent-tabs button {
padding: 11px 15px;
color: #595959;
font-size: 14px;
font-weight: 500;
}
.ai-agent-tabs button.active {
color: #1677ff;
font-weight: 600;
}
/* --- Generic card --- */
.ai-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
padding: 18px 20px;
}
.ai-card--flush {
padding: 0;
overflow: hidden;
}
.ai-card h3 {
margin: 0 0 13px;
font-size: 14px;
font-weight: 700;
color: #1f1f1f;
}
/* --- Overview --- */
.ai-agent-overview {
display: flex;
align-items: flex-start;
gap: 18px;
}
.ai-agent-overview-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-agent-overview-side {
width: 316px;
flex: none;
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-agent-purpose {
margin: 0;
font-size: 13px;
color: #595959;
line-height: 1.6;
}
.ai-runtime-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: #f0f0f0;
border: 1px solid #f0f0f0;
border-radius: 9px;
overflow: hidden;
}
.ai-runtime-grid > div {
background: #fff;
padding: 13px 15px;
}
.ai-runtime-grid span {
font-size: 11.5px;
color: #8c8c8c;
}
.ai-runtime-grid strong {
display: block;
margin-top: 4px;
font-size: 19px;
font-weight: 700;
color: #262626;
}
.ai-limit-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 0;
}
.ai-limit-row span {
font-size: 12.5px;
color: #8c8c8c;
}
.ai-limit-row b {
font-size: 12.5px;
font-weight: 600;
color: #262626;
}
.ai-limit-bar {
height: 6px;
border-radius: 999px;
background: #f0f0f0;
overflow: hidden;
margin: 4px 0 10px;
}
.ai-limit-bar i {
display: block;
height: 6px;
border-radius: 999px;
background: #1677ff;
}
.ai-manage-btn {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
height: 38px;
padding: 0 13px;
border-radius: 9px;
border: 1px solid #e8e8e8;
background: #fff;
color: #262626;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.ai-manage-btn + .ai-manage-btn {
margin-top: 8px;
}
.ai-manage-btn:disabled {
color: #bfbfbf;
cursor: not-allowed;
}
.ai-manage-btn.warning {
border-color: #ffe58f;
background: #fffbe6;
color: #d48806;
}
/* --- Instructions --- */
.ai-instructions {
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-notice {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 16px;
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 10px;
color: #7c5a00;
font-size: 12.5px;
}
.ai-notice svg {
flex: none;
color: #d48806;
}
.ai-instruction-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.ai-instruction-head h3 {
margin: 0;
}
.ai-instruction-head small {
color: #bfbfbf;
font-size: 11.5px;
}
.ai-instruction-index {
width: 24px;
height: 24px;
border-radius: 6px;
background: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #595959;
flex: none;
}
.ai-prompt-block {
margin: 0;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 9px;
padding: 13px 15px;
font-size: 12.5px;
color: #595959;
line-height: 1.65;
font-family: "SF Mono", ui-monospace, Menlo, monospace;
white-space: pre-wrap;
word-break: break-word;
}
/* --- Knowledge --- */
.ai-knowledge-head {
padding: 15px 18px 12px;
border-bottom: 1px solid #f5f5f5;
}
.ai-knowledge-head h3 {
margin: 0;
}
.ai-knowledge-sub {
margin-top: 3px;
font-size: 11.5px;
color: #bfbfbf;
}
.ai-knowledge-row {
display: flex;
align-items: center;
gap: 13px;
padding: 13px 18px;
border-bottom: 1px solid #f5f5f5;
}
.ai-knowledge-row:last-child {
border-bottom: 0;
}
.ai-knowledge-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
flex: none;
color: #8c8c8c;
}
.ai-knowledge-info {
flex: 1;
min-width: 0;
}
.ai-knowledge-title {
font-size: 13px;
font-weight: 500;
color: #262626;
}
.ai-knowledge-meta {
margin-top: 1px;
font-size: 11.5px;
color: #bfbfbf;
}
.ai-knowledge-status {
font-size: 11.5px;
font-weight: 500;
}
.ai-knowledge-status.on {
color: #389e0d;
}
.ai-knowledge-status.off {
color: #bfbfbf;
}
/* --- Editable docs (instructions / knowledge) --- */
.ai-golive-notice {
gap: 12px;
margin-bottom: 16px;
}
.ai-golive-notice span {
flex: 1;
}
.ai-golive-notice .primary-button {
flex: none;
}
/* Значок-предупреждение баннера амберовый (.ai-notice svg), но иконка кнопки
должна наследовать белый цвет текста кнопки. */
.ai-golive-notice .primary-button svg {
color: inherit;
}
.ai-knowledge-headline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
font-weight: 600;
color: #8c8c8c;
}
.ai-doc-card .ai-instruction-head {
flex-wrap: wrap;
}
.ai-doc-status {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
flex-wrap: wrap;
}
.ai-doc-chip {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
background: #f5f5f5;
color: #8c8c8c;
}
.ai-doc-chip.on {
background: #f6ffed;
color: #389e0d;
}
.ai-doc-chip.draft {
background: #fff7e6;
color: #d48806;
}
.ai-doc-chip.off {
background: #fff1f0;
color: #cf1322;
}
.ai-doc-chip.scope-global {
background: #f9f0ff;
color: #722ed1;
}
.ai-doc-chip.scope-product {
background: #e6f4ff;
color: #1677ff;
}
.ai-doc-chip.plain {
background: #f5f5f5;
color: #8c8c8c;
}
.ai-doc-textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
border: 1px solid #d9d9d9;
border-radius: 9px;
padding: 12px 14px;
font-size: 12.5px;
line-height: 1.6;
font-family: "SF Mono", ui-monospace, Menlo, monospace;
color: #262626;
}
.ai-doc-textarea:focus {
outline: none;
border-color: #1677ff;
}
.ai-doc-actions {
display: flex;
align-items: center;
gap: 9px;
margin-top: 12px;
flex-wrap: wrap;
}
.ai-doc-updated {
margin-left: auto;
font-size: 11.5px;
color: #bfbfbf;
}
.ai-doc-error {
margin-top: 10px;
font-size: 12px;
color: #cf1322;
}
.ai-doc-add {
display: flex;
}
.ai-doc-form-row {
display: flex;
gap: 14px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.ai-doc-form-row label {
flex: 1;
min-width: 220px;
display: flex;
flex-direction: column;
gap: 6px;
}
.ai-doc-form-row label span {
font-size: 12px;
font-weight: 600;
color: #595959;
}
.ai-doc-form-row input,
.ai-doc-form-row select {
height: 38px;
border: 1px solid #d9d9d9;
border-radius: 9px;
padding: 0 12px;
font-size: 13px;
color: #262626;
background: #fff;
}
.ai-doc-form-row input:focus,
.ai-doc-form-row select:focus {
outline: none;
border-color: #1677ff;
}
/* --- Metrics --- */
.ai-agent-metrics {
display: flex;
flex-direction: column;
gap: 16px;
}
/* --- Agent edit form (tools/limits) --- */
.ai-edit-label {
font-size: 13px;
color: #595959;
}
.ai-edit-checks,
.ai-edit-limit-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 6px;
}
.ai-edit-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #262626;
}
.ai-edit-limit-field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 1 140px;
}
.ai-edit-limit-field span {
font-size: 12px;
color: #8c8c8c;
}
.ai-edit-limit-field input {
height: 36px;
border: 1px solid #d9d9d9;
border-radius: 9px;
padding: 0 10px;
font-size: 13px;
}
.ai-edit-limit-field input.invalid {
border-color: #cf1322;
}
/* --- Doc import (YAML) --- */
.ai-doc-import-preview,
.ai-doc-import-summary {
font-size: 13px;
color: #262626;
}
.ai-doc-import-report {
display: flex;
flex-direction: column;
gap: 8px;
}
.ai-doc-import-failed {
margin: 0;
padding-left: 18px;
color: #cf1322;
font-size: 12px;
}
/* Знания агента (ADR-HUB-0023): нередактируемый список источников. Состав
меняется массовыми действиями в разделах «Знания» и «Поддержка». */
.agent-knowledge-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.agent-knowledge-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.agent-knowledge-group {
margin: 0 0 10px;
color: #8c8c8c;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.agent-knowledge-text {
flex: 1;
text-align: left;
}
@@ -1,34 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../../../api/client";
import type { AiAgentDetail } from "./model";
export function useAiAgentDetail(agentId: number | null) {
const [agent, setAgent] = useState<AiAgentDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const load = useCallback(async () => {
if (!agentId) {
setError(true);
setLoading(false);
return;
}
setLoading(true);
setError(false);
try {
const payload = await api<{ agent: AiAgentDetail }>(`/api/v1/ai/agents/${agentId}/`);
setAgent(payload.agent);
} catch {
setError(true);
} finally {
setLoading(false);
}
}, [agentId]);
useEffect(() => {
void load();
}, [load]);
return { agent, loading, error, reload: load };
}
@@ -1,7 +1,7 @@
import { Modal } from "antd";
import { useCallback, useEffect, useState } from "react";
import type { AiAgent } from "../model";
import type { AgentCard } from "../../agents/model";
import { Button } from "../../../shared/ui-controls";
import { EmptyState, LoadingState } from "../../../shared/ui";
import type { RouteKey } from "../../../types";
@@ -28,7 +28,7 @@ export function KnowledgeDetailPage({
openAgent,
setRoute,
}: {
agents: AiAgent[];
agents: AgentCard[];
canManage: boolean;
knowledgeId: number | null;
onLoaded: (title: string | null) => void;
@@ -1,4 +1,4 @@
import type { AiAgent } from "../model";
import type { AgentCard } from "../../agents/model";
import { Icon } from "../../../shared/icons";
import { formatDate } from "../../../shared/utils";
import type { KnowledgeItem } from "./types";
@@ -8,7 +8,7 @@ export function KnowledgeDetailSidebar({
item,
openAgent,
}: {
agents: AiAgent[];
agents: AgentCard[];
item: KnowledgeItem;
openAgent: (agentId: number) => void;
}) {
@@ -23,7 +23,7 @@ export function KnowledgeDetailSidebar({
{assignedAgents.map((agent) => (
<button type="button" onClick={() => openAgent(agent.id)} key={agent.id}>
<span><Icon name="robot" size={16} /></span>
<div><strong>{agent.name}</strong><small>{agent.channel.name}</small></div>
<div><strong>{agent.name}</strong><small>{agent.groupName ?? "Без группы"}</small></div>
</button>
))}
</div>
@@ -1,53 +0,0 @@
import { Icon } from "../../shared/icons";
import { StatusPill } from "../../shared/ui";
import { agentStatus } from "./model";
import type { Channel } from "./types";
/** Одна строка сводки и переход наружу: редактор агента остаётся на его странице. */
export function ChannelAgentSection({
channel,
canOpenAgent,
openAgent,
openAgentCreate,
}: {
channel: Channel;
canOpenAgent: boolean;
openAgent: (agentId: number) => void;
openAgentCreate: () => void;
}) {
return (
<section className="channel-card-section channel-agent-section">
<div className="channel-section-eyebrow">AI-АГЕНТ</div>
{channel.agent ? (
<div className="channel-agent-row">
<span className="channel-agent-mark"><Icon name="robot" size={18} /></span>
<div className="channel-agent-summary">
<b>{channel.agent.name}</b>
<span>·</span>
<StatusPill status={agentStatus(channel.agent.status)} />
<span>·</span>
<code>{channel.agent.model}</code>
</div>
{canOpenAgent && (
<button className="link has-icon" type="button" onClick={() => openAgent(channel.agent!.id)}>
Открыть агента
<Icon name="arrow" size={14} />
</button>
)}
</div>
) : (
<div className="channel-agent-none">
<span className="channel-agent-mark"><Icon name="robot" size={22} /></span>
<strong>Агента нет, канал ведут операторы</strong>
<p>Это рабочее состояние. При создании AI-агента нужный канал выбирается в разделе AI.</p>
{canOpenAgent && (
<button className="link has-icon" type="button" onClick={openAgentCreate}>
Создать агента в разделе AI
<Icon name="arrow" size={14} />
</button>
)}
</div>
)}
</section>
);
}
@@ -1,15 +0,0 @@
import { StatusPill } from "../../shared/ui";
/** Архивный канал: что это значит и как вернуть в работу. */
export function ChannelArchivedNotice({ name, code }: { name: string; code: string }) {
return (
<section className="channel-archived-state">
<div className="channel-section-eyebrow">АРХИВНЫЙ КАНАЛ</div>
<div className="channel-archived-summary">
<div><strong>{name}</strong><code>{code}</code></div>
<StatusPill status="archived" />
</div>
<p>Не принимает новые диалоги и недоступен для выбора в настройках подключений. История диалогов сохранена и доступна.</p>
</section>
);
}
@@ -1,69 +0,0 @@
import { FormField, KeyValue, SelectField } from "../../shared/form-controls";
import { formatDate } from "../../shared/utils";
import type { EmployeeGroup, Product } from "../../types";
import type { Channel } from "./types";
/**
* Группа и продукт канала.
*
* По умолчанию секция read-only: правка включается кнопкой «Изменить» в шапке
* и применяется одним сохранением. Так смена продукта не уходит на сервер от
* случайного выбора в списке — она меняет доступные флаги политики.
*/
export function ChannelAssignmentSection({
channel,
groups,
products,
editing,
canEditName,
canEditGroup,
canEditProduct,
name,
groupId,
productId,
onNameChange,
onGroupChange,
onProductChange,
}: {
channel: Channel;
groups: EmployeeGroup[];
products: Product[];
editing: boolean;
canEditName: boolean;
canEditGroup: boolean;
canEditProduct: boolean;
name: string;
groupId: number | null;
productId: number | null;
onNameChange: (value: string) => void;
onGroupChange: (value: number | null) => void;
onProductChange: (value: number | null) => void;
}) {
return (
<section className="channel-card-section">
<header>
<h3>Назначение</h3>
<span>Группа определяет, какие сотрудники видят диалоги канала; без группы диалоги видны всем</span>
</header>
{editing ? (
<div className="channel-assignment">
{canEditName && <FormField label="Название" value={name} onChange={onNameChange} />}
{canEditGroup ? (
<SelectField label="Группа" value={groupId === null ? "" : String(groupId)} onChange={(value) => onGroupChange(value ? Number(value) : null)} options={[["", "Без группы"] as [string, string], ...groups.map((item) => [String(item.id), item.name] as [string, string])]} />
) : <KeyValue label="Группа" value={channel.groupName ?? "Без группы"} />}
{canEditProduct ? (
<SelectField label="Продукт" value={productId === null ? "" : String(productId)} onChange={(value) => onProductChange(value ? Number(value) : null)} options={[["", "— непродуктовый"], ...products.map((item) => [String(item.id), item.name] as [string, string])]} />
) : <KeyValue label="Продукт" value={channel.product?.name ?? "— непродуктовый"} />}
</div>
) : (
<div className="channel-assignment">
<KeyValue label="Группа" value={channel.groupName ?? "Без группы"} />
<KeyValue label="Продукт" value={channel.product?.name ?? "— непродуктовый"} />
<KeyValue label="Код" value={<code>{channel.code}</code>} />
<KeyValue label="Создан" value={formatDate(channel.createdAt)} />
</div>
)}
</section>
);
}
@@ -1,28 +0,0 @@
import { Icon } from "../../shared/icons";
import { ChannelConnectionsTable } from "./ChannelConnectionsTable";
import type { Channel } from "./types";
export function ChannelConnectionsSection({
channel,
canOpenIntegrations,
openIntegrations,
}: {
channel: Channel;
canOpenIntegrations: boolean;
openIntegrations: () => void;
}) {
return (
<section className="channel-card-section">
<header>
<h3>Подключения</h3>
{canOpenIntegrations && (
<button className="link has-icon" type="button" onClick={openIntegrations}>
Открыть интеграции
<Icon name="arrow" size={14} />
</button>
)}
</header>
<ChannelConnectionsTable channel={channel} />
</section>
);
}
@@ -1,20 +0,0 @@
import { ChannelBadge } from "../../shared/badges";
import { providerLabel } from "../../shared/providers";
import { StatusPill } from "../../shared/ui";
import { connectionStatus } from "./model";
import type { Channel } from "./types";
export function ChannelConnectionsTable({ channel }: { channel: Channel }) {
if (channel.connections.length === 0) return <p className="channel-muted">Подключений нет.</p>;
return (
<div className="channel-connections">
{channel.connections.map((connection) => (
<div className="channel-connection" key={connection.id}>
<ChannelBadge provider={connection.provider} />
<div className="channel-connection-text"><strong>{connection.name}</strong><span>{providerLabel(connection.provider)}</span></div>
<StatusPill status={connectionStatus(connection.status)} />
</div>
))}
</div>
);
}
@@ -1,23 +0,0 @@
import { MetricBox } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import type { Channel } from "./types";
export function ChannelCountersSection({ channel, canOpenDialogs, openDialogs }: { channel: Channel; canOpenDialogs: boolean; openDialogs: () => void }) {
return (
<section className="channel-card-section">
<header>
<h3>Счётчики</h3>
{canOpenDialogs && (
<button className="link has-icon" type="button" onClick={openDialogs}>
Открыть диалоги канала
<Icon name="arrow" size={14} />
</button>
)}
</header>
<div className="channel-counters">
<MetricBox label="ОТКРЫТЫЕ ДИАЛОГИ" value={channel.counters.openConversations} />
<MetricBox label="ПОДКЛЮЧЕНИЯ" value={channel.counters.connections} />
</div>
</section>
);
}
@@ -1,161 +0,0 @@
import { useState } from "react";
import { ApiError } from "../../api/client";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import type { EmployeeGroup, Product } from "../../types";
import { createChannel } from "./api";
import { OPERATOR_POLICY, PRESETS, slugify } from "./model";
import { ChannelWizardAssignmentStep } from "./ChannelWizardAssignmentStep";
import { ChannelWizardChannelStep } from "./ChannelWizardChannelStep";
import { ChannelWizardSummary } from "./ChannelWizardSummary";
import type { Channel, PolicyPreset, PolicyViolation } from "./types";
const STEPS = ["Канал", "Назначение"];
export function ChannelCreateWizard({
groups,
products,
openChannel,
openChannels,
openAgentCreate,
openIntegrations,
}: {
groups: EmployeeGroup[];
products: Product[];
openChannel: (channelId: number) => void;
openChannels: () => void;
openAgentCreate: () => void;
openIntegrations: () => void;
}) {
const [step, setStep] = useState(0);
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [codeTouched, setCodeTouched] = useState(false);
const [groupId, setGroupId] = useState<number | null>(null);
const [productId, setProductId] = useState<number | null>(null);
const [preset, setPreset] = useState<PolicyPreset>("CUSTOM");
const [created, setCreated] = useState<Channel | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const effectiveCode = codeTouched ? code : slugify(name);
const policy = preset === "CUSTOM" ? OPERATOR_POLICY : PRESETS[preset];
const groupName = groups.find((item) => item.id === groupId)?.name ?? "Без группы";
const productName = products.find((item) => item.id === productId)?.name ?? "— непродуктовый";
async function submit() {
setBusy(true);
setError(null);
try {
const response = await createChannel({
code: effectiveCode,
name: name.trim(),
groupId,
productId,
policyPreset: preset,
policy: preset === "CUSTOM" ? OPERATOR_POLICY : undefined,
});
setCreated(response.channel);
} catch (submitError) {
if (submitError instanceof ApiError) {
const violations = (submitError.payload as { violations?: PolicyViolation[] }).violations;
setError(violations?.length
? violations.map((item) => item.detail).join("; ")
: submitError.message);
} else {
setError("Не удалось создать канал. Попробуйте ещё раз.");
}
} finally {
setBusy(false);
}
}
return (
<div className="channel-wizard">
<h1>Создание канала</h1>
<p className="channel-wizard-lead">
Два шага: основные данные и назначение. AI-агент и подключения настраиваются
отдельно в соответствующих разделах.
</p>
<div className="channel-stepper">
{STEPS.map((label, index) => {
const done = created !== null || index < step;
return (
<div className={`channel-step ${done ? "is-done" : ""} ${index === step && !created ? "is-current" : ""}`} key={label}>
<span>{done ? <Icon name="check" size={14} /> : index + 1}</span>
{label}
</div>
);
})}
</div>
{error && <div className="channel-feedback is-error">{error}</div>}
{created ? (
<div className="channel-wizard-final">
<section className="channel-card-section channel-wizard-done">
<span className="channel-wizard-check"><Icon name="check" size={28} /></span>
<h3>Канал создан</h3>
<p>
<b>{created.name}</b> — операторский канал: AI-агента нет, диалоги ведут операторы.
Подключения настраиваются отдельно в разделе «Интеграции».
</p>
<div className="channel-wizard-actions is-centered">
<Button variant="primary" onClick={() => openChannel(created.id)}>Открыть карточку канала</Button>
<Button variant="secondary" onClick={openChannels}>К списку каналов</Button>
</div>
</section>
<ChannelWizardSummary
name={created.name}
code={created.code}
groupName={created.groupName ?? "Без группы"}
productName={created.product?.name ?? "— непродуктовый"}
showDestinations
openAgentCreate={openAgentCreate}
openIntegrations={openIntegrations}
/>
</div>
) : (
<div className="channel-wizard-grid">
{step === 0 ? (
<ChannelWizardChannelStep
name={name}
code={effectiveCode}
groupId={groupId}
groups={groups}
onNameChange={setName}
onCodeChange={(value) => {
setCodeTouched(true);
setCode(value);
}}
onGroupChange={setGroupId}
onCancel={openChannels}
onNext={() => setStep(1)}
/>
) : (
<ChannelWizardAssignmentStep
productId={productId}
products={products}
preset={preset}
policy={policy}
busy={busy}
onProductChange={setProductId}
onPresetChange={setPreset}
onBack={() => setStep(0)}
onSubmit={() => void submit()}
/>
)}
<ChannelWizardSummary
name={name.trim()}
code={effectiveCode}
groupName={groupName}
productName={productName}
openAgentCreate={openAgentCreate}
openIntegrations={openIntegrations}
/>
</div>
)}
</div>
);
}
@@ -1,28 +0,0 @@
import { Button } from "../../shared/ui-controls";
export function ChannelDangerActions({
active,
busy,
dirty,
onToggleActive,
onDelete,
}: {
active: boolean;
busy: boolean;
dirty: boolean;
onToggleActive: () => void;
onDelete: () => void;
}) {
const lifecycleTitle = dirty ? "Сначала сохраните или отмените изменения" : undefined;
return (
<footer className="channel-danger-actions">
<Button variant="secondary" icon="pause" title={lifecycleTitle} disabled={busy || dirty} onClick={onToggleActive}>
{active ? "Деактивировать" : "Активировать"}
</Button>
<Button variant="danger-outline" icon="trash" title={lifecycleTitle} disabled={busy || dirty} onClick={onDelete}>
Удалить
</Button>
</footer>
);
}
@@ -1,34 +0,0 @@
import { Modal } from "antd";
import { Button } from "../../shared/ui-controls";
import type { Channel } from "./types";
export function ChannelDeactivationDialog({
channel,
open,
busy,
onConfirm,
onOpenAgent,
onClose,
}: {
channel: Channel;
open: boolean;
busy: boolean;
onConfirm: () => void;
onOpenAgent: () => void;
onClose: () => void;
}) {
return (
<Modal open={open} onCancel={onClose} footer={null} title="Деактивировать канал?" destroyOnHidden>
<p className="channel-modal-lead">
Канал <b>«{channel.name}»</b> перестанет принимать новые диалоги. Активный AI-агент продолжит занимать слот,
пока вы не остановите его отдельно.
</p>
<div className="channel-modal-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="secondary" onClick={onOpenAgent}>Открыть агента</Button>
<Button variant="primary" disabled={busy} onClick={onConfirm}>Деактивировать</Button>
</div>
</Modal>
);
}
@@ -1,65 +0,0 @@
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Button } from "../../shared/ui-controls";
import { BLOCKER_LABELS } from "./model";
import type { Channel, DeletionBlocker } from "./types";
/**
* Удаление канала: подтверждение, а при связанных записях — их список.
*
* Backend отвечает блокерами вместо удаления, поэтому диалог не закрывается,
* а переключается на объяснение и предлагает деактивацию.
*/
export function ChannelDeleteDialog({
channel,
open,
blockers,
busy,
onConfirm,
onDeactivate,
onClose,
}: {
channel: Channel;
open: boolean;
blockers: DeletionBlocker[] | null;
busy: boolean;
onConfirm: () => void;
onDeactivate: () => void;
onClose: () => void;
}) {
return (
<DecisionDialog
open={open}
onClose={onClose}
tone="danger"
icon="trash"
title={blockers ? "Канал нельзя удалить" : "Удалить канал?"}
description={blockers
? "Есть связанные записи. Вместо удаления используйте деактивацию — история сохранится."
: <>Канал <b>«{channel.name}»</b> будет удалён безвозвратно.</>}
width={566}
actions={blockers ? <>
<Button variant="secondary" onClick={onClose}>Закрыть</Button>
{channel.isActive && <Button className="channel-warning-action" variant="secondary" disabled={busy} onClick={onDeactivate}>Деактивировать вместо удаления</Button>}
</> : <>
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="danger-outline" icon="trash" disabled={busy} onClick={onConfirm}>Удалить канал</Button>
</>}
>
{blockers ? (
<>
<div className="channel-section-eyebrow">БЛОКИРУЮЩИЕ СВЯЗИ</div>
<div className="channel-blockers-list">
{blockers.map((item) => (
<div key={item.type}>
<span>{BLOCKER_LABELS[item.type] ?? "Связанная запись"}</span>
<b>{item.count}</b>
</div>
))}
</div>
</>
) : (
<p className="channel-delete-note">Если у канала есть диалоги, заказы или подключения, удаление не выполнится. Деактивация сохраняет историю и отключает канал от новых диалогов.</p>
)}
</DecisionDialog>
);
}
@@ -1,50 +0,0 @@
import { ProductMark, StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import type { Channel } from "./types";
export function ChannelDetailHeader({
channel,
canEdit,
editing,
busy,
onEdit,
onSave,
onCancel,
}: {
channel: Channel;
canEdit: boolean;
editing: boolean;
busy: boolean;
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
}) {
return (
<div className="channel-detail-header">
<div>
<div className="channel-detail-title">
<StatusPill status={channel.isActive ? "active" : "archived"} />
<h1>{channel.name}</h1>
</div>
<div className="channel-detail-meta">
<code>{channel.code}</code>
<span>·</span>
{channel.groupName ?? "Без группы"}
<span>·</span>
{channel.product ? <ProductMark product={channel.product} /> : "— непродуктовый"}
</div>
</div>
<div className="channel-detail-actions">
{canEdit && !editing && (
<Button variant="secondary" icon="edit" onClick={onEdit}>Изменить</Button>
)}
{editing && (
<>
<Button variant="secondary" disabled={busy} onClick={onCancel}>Отмена</Button>
<Button variant="primary" disabled={busy} onClick={onSave}>Сохранить</Button>
</>
)}
</div>
</div>
);
}
@@ -1,157 +0,0 @@
import { useEffect } from "react";
import { hasCapability } from "../../auth/access";
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
import type { EmployeeGroup, Product, RouteKey, SessionUser } from "../../types";
import { ChannelAgentSection } from "./ChannelAgentSection";
import { ChannelArchivedNotice } from "./ChannelArchivedNotice";
import { ChannelAssignmentSection } from "./ChannelAssignmentSection";
import { ChannelConnectionsSection } from "./ChannelConnectionsSection";
import { ChannelCountersSection } from "./ChannelCountersSection";
import { ChannelDangerActions } from "./ChannelDangerActions";
import { ChannelDeactivationDialog } from "./ChannelDeactivationDialog";
import { ChannelDeleteDialog } from "./ChannelDeleteDialog";
import { ChannelDetailHeader } from "./ChannelDetailHeader";
import { ChannelPolicySection } from "./ChannelPolicySection";
import { channelDraftRequest } from "./channel-editor";
import type { PolicyFlag } from "./types";
import { useChannelEditor } from "./useChannelEditor";
import { useChannelDetail } from "./useChannelDetail";
export function ChannelDetailPage({
channelId, groups, products, user, setRoute, openAgent, openChannels, onChannelLoaded,
}: {
channelId: number | null;
groups: EmployeeGroup[];
products: Product[];
user: SessionUser;
setRoute: (route: RouteKey) => void;
openAgent: (agentId: number) => void;
openChannels: () => void;
onChannelLoaded: (name: string | null) => void;
}) {
const detail = useChannelDetail(channelId, openChannels);
const editor = useChannelEditor(detail.channel);
useEffect(() => {
onChannelLoaded(detail.channel?.name ?? null);
return () => onChannelLoaded(null);
}, [detail.channel?.name, onChannelLoaded]);
if (detail.missing) return <EmptyState title="Канал не найден" />;
if (detail.loadFailed) return <ErrorScreen retry={() => void detail.reload()} />;
if (!detail.channel) return <LoadingState />;
const { channel } = detail;
if (!editor.draft) return <LoadingState />;
const canEdit = hasCapability(user, "channels.manage");
const canOpenAgent = hasCapability(user, "ai.view");
const canOpenDialogs = hasCapability(user, "conversations.view");
const editAccess = {
name: canEdit,
group: canEdit,
product: canEdit,
policy: canEdit,
};
const draftGroup = groups.find((item) => item.id === editor.draft!.groupId);
const draftProduct = products.find((item) => item.id === editor.draft!.productId);
async function saveDraft() {
if (!editor.draft) return;
const saved = await detail.patch(channelDraftRequest(editor.draft, editAccess));
if (saved) editor.finish();
}
return (
<div className="channel-detail">
<ChannelDetailHeader
channel={channel}
canEdit={canEdit}
editing={editor.editing}
busy={detail.busy}
onEdit={editor.begin}
onSave={() => void saveDraft()}
onCancel={editor.cancel}
/>
{detail.feedback && <div className={`channel-feedback is-${detail.feedback.kind}`}>{detail.feedback.text}</div>}
{!channel.isActive && <ChannelArchivedNotice name={channel.name} code={channel.code} />}
<ChannelAssignmentSection
channel={channel}
groups={groups}
products={products}
editing={editor.editing}
canEditName={canEdit}
canEditGroup={canEdit}
canEditProduct={canEdit}
name={editor.draft.name}
groupId={editor.draft.groupId}
productId={editor.draft.productId}
onNameChange={editor.setName}
onGroupChange={editor.setGroupId}
onProductChange={editor.setProductId}
/>
<ChannelPolicySection
policy={editor.draft.policy}
hasProduct={editor.draft.productId !== null}
productName={draftProduct?.name ?? "— непродуктовый"}
groupName={draftGroup?.name ?? "Без группы"}
editing={editor.editing}
canManage={canEdit}
busy={detail.busy}
onToggle={(flag: PolicyFlag, value) => editor.setPolicy(flag, value)}
/>
{!editor.editing && (
<>
<ChannelConnectionsSection
channel={channel}
canOpenIntegrations={hasCapability(user, "integrations.view")}
openIntegrations={() => setRoute("integrations")}
/>
<ChannelAgentSection channel={channel} canOpenAgent={canOpenAgent} openAgent={openAgent} openAgentCreate={() => setRoute("aiAgentCreate")} />
<ChannelCountersSection channel={channel} canOpenDialogs={canOpenDialogs} openDialogs={() => setRoute("salesDialogs")} />
</>
)}
{editor.editing && canEdit && (
<ChannelDangerActions
active={channel.isActive}
busy={detail.busy}
dirty={editor.dirty}
onToggleActive={detail.requestToggleActive}
onDelete={() => detail.setDeleting(true)}
/>
)}
<ChannelDeleteDialog
channel={channel}
open={detail.deleting}
blockers={detail.blockers}
busy={detail.busy}
onConfirm={() => void detail.remove()}
onDeactivate={() => {
detail.closeDeleteDialog();
detail.requestToggleActive();
}}
onClose={detail.closeDeleteDialog}
/>
{channel.agent && (
<ChannelDeactivationDialog
channel={channel}
open={detail.confirmingDeactivation}
busy={detail.busy}
onConfirm={async () => {
detail.setConfirmingDeactivation(false);
await detail.patch({ isActive: false });
}}
onOpenAgent={() => openAgent(channel.agent!.id)}
onClose={() => detail.setConfirmingDeactivation(false)}
/>
)}
</div>
);
}
@@ -1,130 +0,0 @@
import { KeyValue, SwitchButton } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { POLICY_FLAGS, POLICY_LABELS, PRESET_LABELS, flagLock, presetOf } from "./model";
import type { Channel, PolicyFlag } from "./types";
/**
* Пять флагов с однострочным пояснением следствия каждого (SPEC-HUB-0027 §11.2).
*
* Недоступный при текущем продукте флаг показан выключенным и заблокированным
* с причиной и способом её устранить — он не игнорируется молча при сохранении.
*/
export function ChannelPolicySection({
policy,
hasProduct,
productName,
groupName,
editing,
canManage,
busy,
onToggle,
}: {
policy: Channel["policy"];
hasProduct: boolean;
productName: string;
groupName: string;
editing: boolean;
canManage: boolean;
busy: boolean;
onToggle: (flag: PolicyFlag, value: boolean) => void;
}) {
const preset = PRESET_LABELS[presetOf(policy)];
if (hasProduct) {
return (
<section className="channel-card-section channel-policy-section">
<header>
<h3>Политика</h3>
<span>Пресет: {preset}</span>
</header>
<PolicyRows
policy={policy}
hasProduct
editing={editing}
canManage={canManage}
busy={busy}
switchFirst={false}
onToggle={onToggle}
/>
</section>
);
}
return (
<section className="channel-policy-scene" aria-label="Политика">
<div className="channel-policy-flags-card">
<div className="channel-policy-eyebrow">Политика</div>
<PolicyRows
policy={policy}
hasProduct={false}
editing={editing}
canManage={canManage}
busy={busy}
switchFirst
onToggle={onToggle}
/>
</div>
<aside className="channel-policy-context">
<div className="channel-card-aside">
<h4>Контекст политики</h4>
<KeyValue label="Продукт" value={productName} />
<KeyValue label="Группа" value={groupName} />
<KeyValue label="Пресет" value={preset} />
</div>
<p className="channel-policy-note">
Настройки сохраняются целиком: если хотя бы одна недоступна, не сохранится ни одна.
Чтобы включить коммерческие настройки, сначала назначьте продукт в секции «Назначение».
</p>
</aside>
</section>
);
}
function PolicyRows({
policy,
hasProduct,
editing,
canManage,
busy,
switchFirst,
onToggle,
}: {
policy: Channel["policy"];
hasProduct: boolean;
editing: boolean;
canManage: boolean;
busy: boolean;
switchFirst: boolean;
onToggle: (flag: PolicyFlag, value: boolean) => void;
}) {
return (
<div className="channel-policy-flags">
{POLICY_FLAGS.map((flag) => {
const lock = flagLock(flag, policy, hasProduct);
const emphasized = lock !== null && flag === "allowCheckoutActions";
const control = (
<SwitchButton
className="ui-switch"
checked={policy[flag]}
disabled={!editing || !canManage || lock !== null || busy}
label={POLICY_LABELS[flag].title}
onClick={() => onToggle(flag, !policy[flag])}
/>
);
return (
<div className={`channel-policy-row ${lock ? "is-locked" : ""} ${emphasized ? "is-emphasized" : ""}`.trim()} key={flag}>
{switchFirst && control}
<div className="channel-policy-text">
<div className="channel-policy-title">
{POLICY_LABELS[flag].title}
{lock && <span className="channel-lock-badge"><Icon name="lock" size={11} />Недоступно</span>}
</div>
<p>{lock ? `${lock.reason}. ${lock.fix}` : POLICY_LABELS[flag].hint}</p>
</div>
{!switchFirst && control}
</div>
);
})}
</div>
);
}
@@ -1,157 +0,0 @@
import { Dropdown } from "antd";
import { ChannelBadge } from "../../shared/badges";
import { Icon } from "../../shared/icons";
import { ProductTag, StatusPill } from "../../shared/ui";
import { agentStatus, deletionHint } from "./model";
import type { Channel } from "./types";
function AgentCell({ channel, canOpenAgent, openAgent }: { channel: Channel; canOpenAgent: boolean; openAgent: (agentId: number) => void }) {
if (!channel.isActive) {
return <StatusPill status="archived" />;
}
if (!channel.agent) {
// Канал без агента — валидное операторское состояние, а не ошибка.
return (
<div className="channel-agent-empty">
<span>Нет агента</span>
<em>операторский канал</em>
</div>
);
}
return (
<div className="channel-agent-cell">
{canOpenAgent ? (
<button className="link" type="button" onClick={(event) => { event.stopPropagation(); openAgent(channel.agent!.id); }}>
{channel.agent.name}
</button>
) : <strong>{channel.agent.name}</strong>}
<StatusPill status={agentStatus(channel.agent.status)} />
</div>
);
}
export function ChannelRow({
channel,
canManageLifecycle,
canOpenAgent,
menuId,
setMenuId,
openChannel,
openAgent,
requestToggleActive,
onDelete,
}: {
channel: Channel;
canManageLifecycle: boolean;
canOpenAgent: boolean;
menuId: number | null;
setMenuId: (channelId: number | null) => void;
openChannel: (channelId: number) => void;
openAgent: (agentId: number) => void;
requestToggleActive: (channel: Channel) => void;
onDelete: (channel: Channel) => void;
}) {
const blocked = deletionHint(channel);
const openItem = {
key: "open",
label: (
<button type="button" onClick={() => openChannel(channel.id)}>
<Icon name="external" size={15} />
Открыть
</button>
),
};
const menuItems = channel.isActive ? [
openItem,
...(canManageLifecycle
? [
{ type: "divider" as const },
{
key: "status",
label: (
<button className="warning" type="button" onClick={() => requestToggleActive(channel)}>
<Icon name="pause" size={15} />
Деактивировать
</button>
),
},
{
key: "delete",
disabled: blocked !== null,
label: (
<button className="danger" type="button" onClick={() => onDelete(channel)}>
<Icon name="trash" size={15} />
<span>Удалить{blocked && <small>{blocked}</small>}</span>
</button>
),
},
]
: []),
] : [
...(canManageLifecycle
? [{
key: "status",
label: (
<button type="button" onClick={() => requestToggleActive(channel)}>
<Icon name="refresh" size={15} />
Активировать
</button>
),
}]
: [openItem]),
];
return (
<tr className={channel.isActive ? "" : "is-archived"} onClick={() => openChannel(channel.id)}>
<td>
<button
className="link is-neutral is-strong"
type="button"
onClick={(event) => {
event.stopPropagation();
openChannel(channel.id);
}}
>
{channel.name}
</button>
<div className="channel-sub">
<code>{channel.code}</code>
{" · "}
{channel.groupName ?? <span className="channel-badge channel-badge--plain">Без группы</span>}
</div>
</td>
<td>
{channel.product ? <ProductTag product={channel.product} /> : <span className="channel-muted">— непродуктовый</span>}
</td>
<td>
{channel.connections.length ? (
<span className="channel-badges">
{channel.connections.map((connection) => (
<ChannelBadge key={connection.id} provider={connection.provider} />
))}
</span>
) : (
<span className="channel-muted">—</span>
)}
</td>
<td>
<AgentCell channel={channel} canOpenAgent={canOpenAgent} openAgent={openAgent} />
</td>
<td className="numeric">{channel.counters.openConversations}</td>
<td className="row-actions" onClick={(event) => event.stopPropagation()}>
<Dropdown
menu={{ items: menuItems }}
open={menuId === channel.id}
onOpenChange={(open) => setMenuId(open ? channel.id : null)}
trigger={["click"]}
overlayClassName="app-dropdown is-channels-menu"
>
<button className="row-menu-button" type="button" aria-label={`Действия: ${channel.name}`}>
<Icon name="more" />
</button>
</Dropdown>
</td>
</tr>
);
}
@@ -1,46 +0,0 @@
import { SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import type { Product } from "../../types";
import { POLICY_FLAGS, POLICY_LABELS, PRESET_LABELS } from "./model";
import type { ChannelPolicy, PolicyPreset } from "./types";
export function ChannelWizardAssignmentStep({
productId,
products,
preset,
policy,
busy,
onProductChange,
onPresetChange,
onBack,
onSubmit,
}: {
productId: number | null;
products: Product[];
preset: PolicyPreset;
policy: ChannelPolicy;
busy: boolean;
onProductChange: (value: number | null) => void;
onPresetChange: (value: PolicyPreset) => void;
onBack: () => void;
onSubmit: () => void;
}) {
const needsProduct = preset === "SALES" || preset === "SUPPORT";
return (
<section className="channel-card-section">
<header><h3>Назначение</h3></header>
<div className="channel-wizard-fields">
<SelectField label="Продукт" value={productId === null ? "" : String(productId)} onChange={(value) => onProductChange(value ? Number(value) : null)} options={[["", "— непродуктовый"], ...products.map((item) => [String(item.id), item.name] as [string, string])]} />
<SelectField label="Пресет политики" value={preset} onChange={(value) => onPresetChange(value as PolicyPreset)} options={(Object.keys(PRESET_LABELS) as PolicyPreset[]).map((item) => [item, PRESET_LABELS[item]])} />
{needsProduct && productId === null && <p className="channel-field-hint is-warning">Пресет «{PRESET_LABELS[preset]}» работает только с продуктом — выберите продукт.</p>}
</div>
<div className="channel-wizard-policy">
{POLICY_FLAGS.map((flag) => <span key={flag} className={policy[flag] ? "is-on" : ""}>{POLICY_LABELS[flag].title}</span>)}
</div>
<div className="channel-wizard-actions">
<Button variant="secondary" onClick={onBack}>Назад</Button>
<Button variant="primary" disabled={busy || (needsProduct && productId === null)} onClick={onSubmit}>Создать канал</Button>
</div>
</section>
);
}
@@ -1,47 +0,0 @@
import { FormField, SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import type { EmployeeGroup } from "../../types";
export function ChannelWizardChannelStep({
name,
code,
groupId,
groups,
onNameChange,
onCodeChange,
onGroupChange,
onCancel,
onNext,
}: {
name: string;
code: string;
groupId: number | null;
groups: EmployeeGroup[];
onNameChange: (value: string) => void;
onCodeChange: (value: string) => void;
onGroupChange: (value: number | null) => void;
onCancel: () => void;
onNext: () => void;
}) {
return (
<section className="channel-card-section">
<header><h3>Канал</h3></header>
<div className="channel-wizard-fields">
<FormField label="Название" value={name} onChange={onNameChange} placeholder="Партнёрская линия" />
<FormField label="Код" value={code} mono placeholder="partners" onChange={onCodeChange} />
<p className="channel-field-hint">Используется в адресе виджета и не меняется после создания.</p>
<SelectField
label="Группа"
value={groupId === null ? "" : String(groupId)}
onChange={(value) => onGroupChange(value ? Number(value) : null)}
options={[["", "Без группы"], ...groups.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<p className="channel-field-hint">Группа определяет, какие сотрудники видят диалоги канала; без группы диалоги видны всем.</p>
</div>
<div className="channel-wizard-actions">
<Button variant="secondary" onClick={onCancel}>Отмена</Button>
<Button variant="primary" disabled={!name.trim() || !code} onClick={onNext}>Далее</Button>
</div>
</section>
);
}
@@ -1,75 +0,0 @@
import { KeyValue } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
export function ChannelWizardSummary({
name,
code,
groupName,
productName,
showDestinations = false,
openAgentCreate,
openIntegrations,
}: {
name: string;
code: string;
groupName: string;
productName: string;
showDestinations?: boolean;
openAgentCreate: () => void;
openIntegrations: () => void;
}) {
return (
<aside className="channel-wizard-aside">
<div className="channel-card-aside">
<h4>Сводка канала</h4>
<KeyValue label="Название" value={name || "—"} />
<KeyValue label="Код" value={code ? <code>{code}</code> : "—"} />
<KeyValue label="Группа" value={groupName} />
<KeyValue label="Продукт" value={productName} />
</div>
{showDestinations && (
<>
<DestinationCard
tone="ai"
title="Нужен AI-агент?"
text="Создайте его отдельным действием в разделе AI и выберите этот канал."
action="Перейти в раздел AI"
onClick={openAgentCreate}
/>
<DestinationCard
tone="integration"
title="Нужно подключение?"
text="Создайте или измените подключение в разделе «Интеграции» и выберите этот канал."
action="Перейти в интеграции"
onClick={openIntegrations}
/>
</>
)}
</aside>
);
}
function DestinationCard({
tone,
title,
text,
action,
onClick,
}: {
tone: "ai" | "integration";
title: string;
text: string;
action: string;
onClick: () => void;
}) {
return (
<section className={`channel-wizard-destination is-${tone}`}>
<p><b>{title}</b> {text}</p>
<button className="link has-icon" type="button" onClick={onClick}>
{action}
<Icon name="arrow" size={14} />
</button>
</section>
);
}
@@ -1,110 +0,0 @@
import { hasCapability } from "../../auth/access";
import { SwitchButton } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { ContentState, ErrorScreen, LoadingState, PageHeader } from "../../shared/ui";
import { Button, SearchInput, UnderlineTabs } from "../../shared/ui-controls";
import type { EmployeeGroup, SessionUser } from "../../types";
import { ChannelDeactivationDialog } from "./ChannelDeactivationDialog";
import { ChannelDeleteDialog } from "./ChannelDeleteDialog";
import { ChannelsTable } from "./ChannelsTable";
import { archivedCountLabel, channelCountLabel } from "./model";
import type { Channel } from "./types";
import { useChannelsPage } from "./useChannelsPage";
export function ChannelsPage({
user, groups, openChannel, openChannelCreate, openAgent,
}: {
user: SessionUser;
groups: EmployeeGroup[];
openChannel: (channelId: number) => void;
openChannelCreate: () => void;
openAgent: (agentId: number) => void;
}) {
const page = useChannelsPage(groups);
const canManageLifecycle = hasCapability(user, "channels.manage");
const canOpenAgent = (_channel: Channel) => hasCapability(user, "ai.view");
const openChannelCard = (channelId: number) => {
if (window.location.hash) {
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
}
openChannel(channelId);
};
if (page.failed) return <ErrorScreen retry={() => void page.reload()} />;
if (!page.channels) return <LoadingState />;
return (
<>
<PageHeader
title="Каналы"
text="Точка маршрутизации диалогов: связывает группу, продукт, подключения и — необязательно — AI-агента. Группа определяет, какие сотрудники видят диалоги канала; без группы диалоги видны всем"
action={canManageLifecycle ? <Button className="channel-create-action" variant="primary" icon="plus" onClick={openChannelCreate}>Создать канал</Button> : undefined}
/>
{page.feedback && <div className="channel-feedback is-error">{page.feedback}</div>}
{page.channels.length === 0 ? (
<ContentState
icon={<Icon name="route" size={24} />}
title="Создайте первый канал"
text="Канал маршрутизирует диалоги от подключений к группе, продукту и — при необходимости — к AI-агенту. Группа определяет, какие сотрудники видят диалоги канала; без группы диалоги видны всем."
action={canManageLifecycle ? <Button className="channel-state-action" variant="primary" onClick={openChannelCreate}>Создать канал</Button> : undefined}
/>
) : (
<>
<div className="channels-toolbar">
<UnderlineTabs className="channels-tabs" items={page.tabs} value={page.tab} onChange={page.setTab} />
<div className="channels-toolbar-tools">
<SearchInput className="channels-search" placeholder="Поиск по имени и коду" value={page.search} onChange={page.setSearch} />
<div className="channels-archive-toggle">
<SwitchButton className="ui-switch" checked={page.showArchived} label="Показывать архивные" onClick={() => page.setShowArchived(!page.showArchived)} />
<span>Показывать архивные</span>
</div>
</div>
</div>
<ChannelsTable
channels={page.visible}
canManageLifecycle={canManageLifecycle}
canOpenAgent={canOpenAgent}
openChannel={openChannelCard}
openAgent={openAgent}
requestToggleActive={page.requestToggleActive}
onDelete={page.setDeleteTarget}
footer={`${channelCountLabel(page.channels.length)}${page.archived ? ` · ${archivedCountLabel(page.archived)}` : ""}`}
/>
</>
)}
{page.deleteTarget && (
<ChannelDeleteDialog
channel={page.deleteTarget}
open
blockers={page.blockers}
busy={page.busy}
onConfirm={() => void page.removeChannel(page.deleteTarget!)}
onDeactivate={() => {
const target = page.deleteTarget!;
page.closeDelete();
page.requestToggleActive(target);
}}
onClose={page.closeDelete}
/>
)}
{page.deactivationTarget?.agent && (
<ChannelDeactivationDialog
channel={page.deactivationTarget}
open
busy={page.busy}
onConfirm={async () => {
const target = page.deactivationTarget!;
page.setDeactivationTarget(null);
await page.toggleActive(target);
}}
onOpenAgent={() => openAgent(page.deactivationTarget!.agent!.id)}
onClose={() => page.setDeactivationTarget(null)}
/>
)}
</>
);
}
@@ -1,63 +0,0 @@
import { useState } from "react";
import { ChannelRow } from "./ChannelRow";
import type { Channel } from "./types";
export function ChannelsTable({
channels,
canManageLifecycle,
canOpenAgent,
openChannel,
openAgent,
requestToggleActive,
onDelete,
footer,
}: {
channels: Channel[];
canManageLifecycle: boolean;
canOpenAgent: (channel: Channel) => boolean;
openChannel: (channelId: number) => void;
openAgent: (agentId: number) => void;
requestToggleActive: (channel: Channel) => void;
onDelete: (channel: Channel) => void;
footer: string;
}) {
const [menuId, setMenuId] = useState<number | null>(null);
return (
<div className="table-card channels-table-card">
<table className="baseline-table channels-table">
<thead>
<tr>
<th>КАНАЛ</th>
<th>ПРОДУКТ</th>
<th>ПОДКЛЮЧЕНИЯ</th>
<th>AI-АГЕНТ</th>
<th className="numeric">ДИАЛОГИ</th>
<th aria-label="Действия" />
</tr>
</thead>
<tbody>
{channels.map((channel) => (
<ChannelRow
key={channel.id}
channel={channel}
canManageLifecycle={canManageLifecycle}
canOpenAgent={canOpenAgent(channel)}
menuId={menuId}
setMenuId={setMenuId}
openChannel={openChannel}
openAgent={openAgent}
requestToggleActive={requestToggleActive}
onDelete={onDelete}
/>
))}
</tbody>
</table>
<div className="channels-table-footer">
<span>{footer}</span>
<span>Счётчик «Диалоги» — открытые диалоги канала</span>
</div>
</div>
);
}
@@ -1,60 +0,0 @@
import { api } from "../../api/client";
import type { Channel, ChannelPolicy, PolicyPreset } from "./types";
export type ChannelCreateInput = {
code: string;
name: string;
groupId: number | null;
productId: number | null;
policyPreset: PolicyPreset;
policy?: ChannelPolicy;
};
export type ChannelUpdateInput = {
name?: string;
groupId?: number | null;
productId?: number | null;
isActive?: boolean;
policy?: Partial<ChannelPolicy>;
};
export type ChannelResponse = {
channel: Channel;
warnings?: { code: string; agentId: number }[];
};
export function listChannels(): Promise<{ items: Channel[] }> {
return api("/api/v1/channels/");
}
export function loadChannel(channelId: number): Promise<{ channel: Channel }> {
return api(`/api/v1/channels/${channelId}/`);
}
export function createChannel(input: ChannelCreateInput): Promise<{ channel: Channel }> {
// policy передаётся только для CUSTOM: пресет и явная политика
// взаимоисключающи (SPEC-HUB-0027 §6.3).
const body: Record<string, unknown> = {
code: input.code,
name: input.name,
groupId: input.groupId,
productId: input.productId,
policyPreset: input.policyPreset,
};
if (input.policyPreset === "CUSTOM") body.policy = input.policy;
return api("/api/v1/channels/", { method: "POST", body: JSON.stringify(body) });
}
export function updateChannel(
channelId: number,
input: ChannelUpdateInput,
): Promise<ChannelResponse> {
return api(`/api/v1/channels/${channelId}/`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deleteChannel(channelId: number): Promise<void> {
return api(`/api/v1/channels/${channelId}/`, { method: "DELETE" });
}
@@ -1,66 +0,0 @@
import { describe, expect, it } from "vitest";
import {
channelDraft,
channelDraftChanged,
channelDraftRequest,
channelDraftWithProduct,
} from "./channel-editor";
import type { Channel } from "./types";
const channel: Channel = {
id: 1,
code: "partners",
name: "Партнёрская линия",
isActive: true,
product: { id: 7, code: "foxray", name: "FoxRay" },
groupId: 2,
groupName: "Продажи",
agent: null,
connections: [],
policy: {
requiresAuthenticatedProductIdentity: true,
allowAnonymousSessions: true,
allowSelfReportedContact: true,
allowSalesAttribution: true,
allowCheckoutActions: true,
},
counters: { openConversations: 0, connections: 0 },
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
describe("channel editor", () => {
it("creates an independent draft", () => {
const draft = channelDraft(channel);
draft.policy.allowAnonymousSessions = false;
expect(channel.policy.allowAnonymousSessions).toBe(true);
});
it("turns off product-only policy when product is removed", () => {
const draft = channelDraftWithProduct(channelDraft(channel), null);
expect(draft.policy.requiresAuthenticatedProductIdentity).toBe(false);
expect(draft.policy.allowSalesAttribution).toBe(false);
expect(draft.policy.allowCheckoutActions).toBe(false);
expect(draft.policy.allowAnonymousSessions).toBe(true);
});
it("builds one request according to edit access", () => {
expect(channelDraftRequest(channelDraft(channel), {
name: true,
group: true,
product: false,
policy: false,
})).toEqual({
name: "Партнёрская линия",
groupId: 2,
});
});
it("detects draft changes", () => {
const draft = channelDraft(channel);
expect(channelDraftChanged(channel, draft)).toBe(false);
draft.name = "Новое название";
expect(channelDraftChanged(channel, draft)).toBe(true);
});
});
@@ -1,59 +0,0 @@
import type { ChannelUpdateInput } from "./api";
import type { Channel, ChannelPolicy, PolicyFlag } from "./types";
export type ChannelEditDraft = {
name: string;
groupId: number | null;
productId: number | null;
policy: ChannelPolicy;
};
export type ChannelEditAccess = {
name: boolean;
group: boolean;
product: boolean;
policy: boolean;
};
const PRODUCT_REQUIRED_FLAGS: PolicyFlag[] = [
"requiresAuthenticatedProductIdentity",
"allowSalesAttribution",
"allowCheckoutActions",
];
export function channelDraft(channel: Channel): ChannelEditDraft {
return {
name: channel.name,
groupId: channel.groupId,
productId: channel.product?.id ?? null,
policy: { ...channel.policy },
};
}
export function channelDraftWithProduct(
draft: ChannelEditDraft,
productId: number | null,
): ChannelEditDraft {
if (productId !== null) return { ...draft, productId };
const policy = { ...draft.policy };
PRODUCT_REQUIRED_FLAGS.forEach((flag) => {
policy[flag] = false;
});
return { ...draft, productId, policy };
}
export function channelDraftRequest(
draft: ChannelEditDraft,
access: ChannelEditAccess,
): ChannelUpdateInput {
return {
...(access.name ? { name: draft.name.trim() } : {}),
...(access.group ? { groupId: draft.groupId } : {}),
...(access.product ? { productId: draft.productId } : {}),
...(access.policy ? { policy: draft.policy } : {}),
};
}
export function channelDraftChanged(channel: Channel, draft: ChannelEditDraft): boolean {
return JSON.stringify(channelDraft(channel)) !== JSON.stringify(draft);
}
@@ -1,157 +0,0 @@
import { describe, expect, it } from "vitest";
import {
OPERATOR_POLICY,
PRESETS,
agentStatus,
archivedCountLabel,
blockerSummary,
channelCountLabel,
connectionStatus,
deletionHint,
filterChannels,
groupTabs,
flagLock,
presetOf,
slugify,
} from "./model";
import type { Channel } from "./types";
function channel(overrides: Partial<Channel>): Channel {
return {
id: 1,
code: "code",
name: "Канал",
isActive: true,
product: null,
groupId: null,
groupName: null,
agent: null,
connections: [],
policy: OPERATOR_POLICY,
counters: { openConversations: 0, connections: 0 },
createdAt: "2026-07-01T00:00:00+00:00",
updatedAt: "2026-07-01T00:00:00+00:00",
...overrides,
};
}
describe("channel policy invariants", () => {
it("locks commercial flags on a non-product channel", () => {
expect(flagLock("allowCheckoutActions", OPERATOR_POLICY, false)?.rule).toBe("P1");
expect(flagLock("allowSalesAttribution", OPERATOR_POLICY, false)?.rule).toBe("P2");
expect(flagLock("requiresAuthenticatedProductIdentity", OPERATOR_POLICY, false)?.rule).toBe("P3");
});
it("unlocks commercial flags once a product is assigned", () => {
for (const flag of ["allowCheckoutActions", "allowSalesAttribution"] as const) {
expect(flagLock(flag, OPERATOR_POLICY, true)).toBeNull();
}
});
it("locks anonymous flags while authenticated identity is required", () => {
expect(flagLock("allowAnonymousSessions", PRESETS.SUPPORT, true)?.rule).toBe("P4");
expect(flagLock("allowSelfReportedContact", PRESETS.SUPPORT, true)?.rule).toBe("P5");
// Без обязательной идентичности те же флаги свободны.
expect(flagLock("allowAnonymousSessions", PRESETS.SALES, true)).toBeNull();
});
it("names the preset behind a flag combination", () => {
expect(presetOf(PRESETS.SALES)).toBe("SALES");
expect(presetOf(PRESETS.SUPPORT)).toBe("SUPPORT");
expect(presetOf(OPERATOR_POLICY)).toBe("CUSTOM");
});
});
describe("channel presentation dictionaries", () => {
it("maps backend connection states to product statuses", () => {
expect(connectionStatus("OK")).toBe("healthy");
expect(connectionStatus("ERROR")).toBe("error");
expect(connectionStatus("UNKNOWN_VALUE")).toBe("unchecked");
});
it("maps every agent lifecycle state without exposing its code", () => {
expect(agentStatus("ACTIVE")).toBe("active");
expect(agentStatus("DISABLED")).toBe("disabled");
expect(agentStatus("ARCHIVED")).toBe("archived");
expect(agentStatus("DRAFT")).toBe("draft");
});
it("does not expose an unknown blocker code", () => {
expect(blockerSummary([{ type: "unknown_table", count: 2 }])).toBe("связанные записи: 2");
});
});
describe("channel code generation", () => {
it("transliterates a russian name into an embeddable slug", () => {
expect(slugify("Партнёрская линия")).toBe("partnerskaya-liniya");
expect(slugify("FoxRay — продажи")).toBe("foxray-prodazhi");
});
it("never produces leading, trailing or repeated separators", () => {
expect(slugify(" ??? Канал !!! ")).toBe("kanal");
});
});
describe("channel list filtering", () => {
const channels = [
channel({ id: 1, code: "foxray-sales", name: "FoxRay — продажи", groupId: 1, groupName: "Продажи" }),
channel({ id: 2, code: "foxray-support", name: "FoxRay — поддержка", groupId: 2, groupName: "Поддержка" }),
channel({ id: 3, code: "partners", name: "Партнёрская линия" }),
channel({ id: 4, code: "old", name: "Старый", groupId: 1, groupName: "Продажи", isActive: false }),
];
it("builds a tab per group plus «Без группы»", () => {
expect(groupTabs(channels, [{ id: 1, name: "Продажи" }, { id: 2, name: "Поддержка" }]).map((tab) => [tab.key, tab.label, tab.count])).toEqual([
["all", "Все", 4],
["1", "Продажи", 2],
["2", "Поддержка", 1],
["none", "Без группы", 1],
]);
});
it("formats channel counters with russian plural forms", () => {
expect(channelCountLabel(1)).toBe("1 канал");
expect(channelCountLabel(2)).toBe("2 канала");
expect(channelCountLabel(5)).toBe("5 каналов");
expect(archivedCountLabel(1)).toBe("1 архивный");
expect(archivedCountLabel(2)).toBe("2 архивных");
});
it("formats deletion blockers with russian plural forms", () => {
expect(deletionHint(channel({
counters: { openConversations: 1, connections: 1 },
}))).toBe("Нельзя: 1 диалог, 1 подключение");
});
it("keeps «Без группы» visible when the count is zero", () => {
const assigned = channels.filter((item) => item.groupId !== null);
expect(groupTabs(assigned, [{ id: 1, name: "Продажи" }]).at(-1)).toEqual({
key: "none",
label: "Без группы",
count: 0,
});
});
it("hides archived channels until the toggle is on", () => {
const visible = filterChannels(channels, { tab: "all", search: "", showArchived: false });
expect(visible.map((item) => item.code)).toEqual(["foxray-sales", "foxray-support", "partners"]);
const withArchived = filterChannels(channels, { tab: "all", search: "", showArchived: true });
expect(withArchived).toHaveLength(4);
});
it("filters by group tab including channels without one", () => {
const orphans = filterChannels(channels, { tab: "none", search: "", showArchived: false });
expect(orphans.map((item) => item.code)).toEqual(["partners"]);
});
it("searches by name and code", () => {
expect(
filterChannels(channels, { tab: "all", search: "support", showArchived: false }).map((i) => i.code),
).toEqual(["foxray-support"]);
expect(
filterChannels(channels, { tab: "all", search: "партнёр", showArchived: false }).map((i) => i.code),
).toEqual(["partners"]);
});
});
@@ -1,255 +0,0 @@
import type { Channel, ChannelPolicy, PolicyFlag, PolicyPreset } from "./types";
import type { StatusPillKey } from "../../shared/ui";
import { formatRussianCount } from "../../shared/text";
export const POLICY_FLAGS: PolicyFlag[] = [
"requiresAuthenticatedProductIdentity",
"allowAnonymousSessions",
"allowSelfReportedContact",
"allowSalesAttribution",
"allowCheckoutActions",
];
/** Однострочное пояснение следствия каждого флага (SPEC-HUB-0027 §11.2). */
export const POLICY_LABELS: Record<PolicyFlag, { title: string; hint: string }> = {
requiresAuthenticatedProductIdentity: {
title: "Обязательная продуктовая идентичность",
hint: "Диалог требует авторизации как владелец продукта.",
},
allowAnonymousSessions: {
title: "Анонимные сессии",
hint: "Разрешает диалоги без идентификации пользователя.",
},
allowSelfReportedContact: {
title: "Самозаявленный контакт",
hint: "Позволяет пользователю указать контакт вручную.",
},
allowSalesAttribution: {
title: "Связь продаж с диалогами",
hint: "Связывает продажу с диалогом канала.",
},
allowCheckoutActions: {
title: "Оформление заказа в диалоге",
hint: "Разрешает оформление заказа в диалоге.",
},
};
export const PRESETS: Record<Exclude<PolicyPreset, "CUSTOM">, ChannelPolicy> = {
SALES: {
requiresAuthenticatedProductIdentity: false,
allowAnonymousSessions: true,
allowSelfReportedContact: true,
allowSalesAttribution: true,
allowCheckoutActions: true,
},
SUPPORT: {
requiresAuthenticatedProductIdentity: true,
allowAnonymousSessions: false,
allowSelfReportedContact: false,
allowSalesAttribution: false,
allowCheckoutActions: false,
},
};
export const OPERATOR_POLICY: ChannelPolicy = {
requiresAuthenticatedProductIdentity: false,
allowAnonymousSessions: true,
allowSelfReportedContact: true,
allowSalesAttribution: false,
allowCheckoutActions: false,
};
/** Состояние подключения приходит строкой — на экран идёт словарь, не энум. */
export const CONNECTION_STATUS = {
OK: "healthy",
ERROR: "error",
PENDING: "pending",
UNCHECKED: "unchecked",
} as const;
export type ConnectionStatusKey = typeof CONNECTION_STATUS[keyof typeof CONNECTION_STATUS];
export function connectionStatus(status: string): ConnectionStatusKey {
return CONNECTION_STATUS[status as keyof typeof CONNECTION_STATUS] ?? "unchecked";
}
const AGENT_STATUS: Record<string, StatusPillKey> = {
ACTIVE: "active",
ARCHIVED: "archived",
DISABLED: "disabled",
DRAFT: "draft",
};
export function agentStatus(status: string): StatusPillKey {
return AGENT_STATUS[status] ?? "draft";
}
/**
* Причина недоступности флага.
*
* `needsProduct` включает ссылку-починку «Назначить продукт» — причина без
* способа её устранить бесполезна пользователю.
*/
export type FlagLock = { rule: string; reason: string; fix: string; needsProduct: boolean } | null;
/**
* Причина, по которой флаг запрещён инвариантом при текущем состоянии канала.
*
* Выводится из продукта и обязательной идентичности, а не приходит с сервера:
* правила P1-P5 детерминированы, а источником прав остаётся backend — он
* отклоняет запрос независимо от того, что показал интерфейс. Показать причину
* обязательно: флаг не должен молча игнорироваться при сохранении (§11.2).
*/
export function flagLock(flag: PolicyFlag, policy: ChannelPolicy, hasProduct: boolean): FlagLock {
if (!hasProduct) {
if (flag === "allowCheckoutActions") {
return {
rule: "P1",
reason: "Коммерческие действия недоступны непродуктовому каналу",
fix: "Назначьте продукт каналу, чтобы включить.",
needsProduct: true,
};
}
if (flag === "allowSalesAttribution") {
return {
rule: "P2",
reason: "Связь продаж с диалогами недоступна непродуктовому каналу",
fix: "Свяжите канал с продуктом, чтобы включить.",
needsProduct: true,
};
}
if (flag === "requiresAuthenticatedProductIdentity") {
return {
rule: "P3",
reason: "Продуктовая идентичность требует продукта",
fix: "Назначьте продукт каналу, чтобы включить.",
needsProduct: true,
};
}
}
if (policy.requiresAuthenticatedProductIdentity) {
if (flag === "allowAnonymousSessions") {
return {
rule: "P4",
reason: "Анонимные сессии несовместимы с обязательной идентичностью",
fix: "Выключите обязательную идентичность, чтобы включить.",
needsProduct: false,
};
}
if (flag === "allowSelfReportedContact") {
return {
rule: "P5",
reason: "Самозаявленный контакт несовместим с обязательной идентичностью",
fix: "Выключите обязательную идентичность, чтобы включить.",
needsProduct: false,
};
}
}
return null;
}
/** Пресет, которому соответствует текущий набор флагов, иначе «Свой». */
export function presetOf(policy: ChannelPolicy): PolicyPreset {
const same = (preset: ChannelPolicy) =>
POLICY_FLAGS.every((flag) => preset[flag] === policy[flag]);
if (same(PRESETS.SALES)) return "SALES";
if (same(PRESETS.SUPPORT)) return "SUPPORT";
return "CUSTOM";
}
export const PRESET_LABELS: Record<PolicyPreset, string> = {
SALES: "Продажи",
SUPPORT: "Поддержка",
CUSTOM: "Свой",
};
/** `code` попадает в embed-URL виджета, поэтому только латиница, цифры и дефис. */
export function slugify(name: string): string {
const map: Record<string, string> = {
а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z",
и: "i", й: "i", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r",
с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "c", ч: "ch", ш: "sh", щ: "sch",
ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya",
};
return name
.toLowerCase()
.split("")
.map((char) => map[char] ?? char)
.join("")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
}
export const BLOCKER_LABELS: Record<string, string> = {
conversations: "Диалоги",
orders: "Заказы",
attributionTokens: "Связи продаж с диалогами",
connections: "Подключения",
supportContracts: "Договоры поддержки",
agent: "Агент",
llmInvocations: "История работы AI",
};
/**
* Почему канал нельзя удалить — по данным строки списка.
*
* Это подсказка интерфейса, а не решение: удаление всё равно проверяет
* backend. Она нужна, чтобы недоступный пункт меню объяснял себя.
*/
export function deletionHint(channel: Channel): string | null {
const parts: string[] = [];
if (channel.counters.openConversations > 0) {
parts.push(formatRussianCount(channel.counters.openConversations, "диалог", "диалога", "диалогов"));
}
if (channel.counters.connections > 0) {
parts.push(formatRussianCount(channel.counters.connections, "подключение", "подключения", "подключений"));
}
if (channel.agent) parts.push("агент");
return parts.length ? `Нельзя: ${parts.join(", ")}` : null;
}
export function blockerSummary(blockers: { type: string; count: number }[]): string {
return blockers
.map((item) => `${(BLOCKER_LABELS[item.type] ?? "связанные записи").toLowerCase()}: ${item.count}`)
.join(", ");
}
export type GroupTab = { key: string; label: string; count: number };
/** Табы идут в порядке групп компании; «Без группы» присутствует даже при нуле. */
export function groupTabs(
channels: Channel[],
groups: Array<{ id: number; name: string }>,
): GroupTab[] {
const countOf = (groupId: number | null) =>
channels.filter((channel) => channel.groupId === groupId).length;
return [
{ key: "all", label: "Все", count: channels.length },
...groups.map((group) => ({
key: String(group.id),
label: group.name,
count: countOf(group.id),
})),
{ key: "none", label: "Без группы", count: countOf(null) },
];
}
export const channelCountLabel = (value: number) => formatRussianCount(value, "канал", "канала", "каналов");
export const archivedCountLabel = (value: number) => formatRussianCount(value, "архивный", "архивных", "архивных");
export function filterChannels(
channels: Channel[],
{ tab, search, showArchived }: { tab: string; search: string; showArchived: boolean },
): Channel[] {
const query = search.trim().toLowerCase();
return channels.filter((channel) => {
if (!showArchived && !channel.isActive) return false;
if (tab === "none" && channel.groupId !== null) return false;
if (tab !== "all" && tab !== "none" && String(channel.groupId) !== tab) return false;
if (!query) return true;
return (
channel.name.toLowerCase().includes(query) || channel.code.toLowerCase().includes(query)
);
});
}
@@ -1,187 +0,0 @@
.channels-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
border-bottom: 1px solid var(--border-default);
}
.channels-toolbar-tools {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 6px;
}
.channels-search {
width: 240px;
height: 34px;
padding: 0 12px;
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
background: #fff;
color: var(--text-tertiary);
font-size: 13px;
}
.channels-search:focus-within {
border-color: var(--primary);
}
.channels-search input {
color: var(--text-body);
font-size: 13px;
}
.channels-search input::placeholder {
color: var(--text-tertiary);
}
.channels-tabs button {
height: 38px;
gap: 7px;
margin: 0 8px;
padding: 0 4px;
color: var(--text-secondary);
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
}
.channels-tabs button:first-child {
margin-left: 0;
}
.channels-tabs button.active {
color: var(--primary-hover);
font-weight: 600;
}
.channels-tabs button span {
padding: 0 6px;
border-radius: 999px;
background: var(--n-9);
color: var(--text-tertiary);
font-size: 11px;
font-weight: 600;
}
.channels-tabs button.active span {
background: var(--primary-bg);
color: var(--primary-hover);
}
.channels-archive-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 12.5px;
}
.channels-table-card {
overflow: visible;
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
background: #fff;
box-shadow: var(--shadow-sm);
}
.channels-table td {
vertical-align: middle;
}
.channels-table tr.is-archived {
background: #fcfcfc;
}
.channels-table tr.is-archived .link {
color: var(--text-tertiary);
}
.channel-sub {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
color: var(--text-tertiary);
font-size: 12px;
}
.channel-sub code {
font-family: var(--font-mono, ui-monospace, monospace);
}
.channel-muted {
color: var(--text-tertiary);
font-size: 12.5px;
}
.channel-badges {
display: inline-flex;
flex-wrap: wrap;
gap: 5px;
}
.channel-badge--plain { background: var(--n-9); color: var(--text-tertiary); }
.channel-agent-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.channel-agent-empty span {
color: var(--text-tertiary);
font-size: 12.5px;
}
.channel-agent-empty em {
display: block;
margin-top: 2px;
color: var(--text-disabled);
font-size: 11px;
font-style: normal;
}
.channels-table-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 11px 16px;
border-top: 1px solid var(--border-default);
color: var(--text-tertiary);
font-size: 12.5px;
}
.channels-scope-state { margin: 0 auto 16px; }
.channels-archive-toggle .ui-switch {
width: 34px;
height: 20px;
}
.channels-archive-toggle .ui-switch i {
width: 16px;
height: 16px;
}
.channel-state-action { min-height: 34px; padding-inline: 14px; font-size: 12.5px; }
.channel-create-action { min-height: 36px; padding-inline: 14px; font-size: 13px; }
.channels-table .row-menu-button { width: 28px; height: 28px; }
.channels-table .row-menu-button[aria-expanded="true"] {
border: 1px solid var(--primary-border);
background: var(--primary-bg);
color: var(--primary-hover);
}
.channels-table tr.is-archived .product-tag {
background: var(--n-9) !important;
color: var(--text-disabled) !important;
}
.channels-table tr.is-archived .product-tag i { background: var(--text-disabled) !important; }
@@ -1,175 +0,0 @@
.channel-policy-scene {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 18px;
align-items: start;
}
.channel-policy-section { padding: 20px; }
.channel-policy-section > header { margin-bottom: 0; }
.channel-policy-flags-card {
min-width: 0;
padding: 8px 20px 12px;
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
background: #fff;
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.channel-policy-eyebrow {
padding: 12px 0 4px;
color: var(--text-tertiary);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
}
.channel-policy-flags {
min-width: 0;
}
.channel-policy-row {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 16px 0;
border-top: 1px solid var(--n-9);
}
.channel-policy-section .channel-policy-row {
align-items: center;
padding: 12px 0;
}
.channel-policy-row .ui-switch {
width: 40px;
height: 22px;
margin-top: 1px;
flex: none;
}
.channel-policy-section .channel-policy-row .ui-switch {
margin-top: 0;
margin-left: auto;
}
.channel-policy-row .ui-switch i { width: 18px; height: 18px; }
.channel-policy-row.is-locked {
color: var(--text-tertiary);
}
.channel-policy-row.is-emphasized {
margin: 0 -20px;
padding-right: 20px;
padding-left: 20px;
border-radius: 9px;
background: var(--warning-bg-strong);
}
.channel-policy-text {
flex: 1;
min-width: 0;
}
.channel-policy-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-body);
font-size: 13.5px;
font-weight: 600;
}
.channel-policy-row.is-locked .channel-policy-title {
color: var(--text-tertiary);
}
.channel-policy-text p {
margin: 3px 0 0;
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.5;
}
.channel-policy-row.is-emphasized .channel-policy-text p {
color: var(--warning-text);
}
.channel-lock-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 7px;
border: 1px solid var(--warning-border);
border-radius: var(--radius-pill);
background: var(--warning-bg);
color: var(--warning-text);
font-size: 10.5px;
font-weight: 600;
}
.channel-policy-row.is-emphasized .channel-lock-badge { background: #fff; }
.channel-policy-context {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.channel-card-aside {
padding: 18px;
}
.channel-card-aside h4 {
margin: 0 0 12px;
color: var(--text-heading);
font-size: 13px;
}
.channel-policy-note {
margin: 0;
padding: 16px 18px;
border: 1px solid var(--primary-border);
border-radius: var(--radius-2xl);
background: var(--primary-bg);
color: var(--primary-hover);
font-size: 12px;
line-height: 1.5;
}
.channel-archived-state {
width: 372px;
padding: 24px;
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
background: #fff;
box-shadow: var(--shadow-sm);
}
.channel-archived-summary {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: #fcfcfc;
}
.channel-archived-summary > div { flex: 1; line-height: 1.35; }
.channel-archived-summary strong { display: block; color: var(--text-tertiary); font-size: 13.5px; }
.channel-archived-summary code { color: var(--text-disabled); font: 11.5px var(--font-mono); }
.channel-archived-state > p { margin: 12px 0 14px; color: var(--text-tertiary); font-size: 12px; line-height: 1.5; }
.channel-compact-action { height: 32px; padding-inline: 14px; font-size: 12.5px; }
@media (max-width: 900px) {
.channel-policy-scene {
grid-template-columns: 1fr;
}
}
@@ -1,140 +0,0 @@
.channel-connections {
display: flex;
flex-direction: column;
gap: 8px;
}
.channel-connection {
display: flex;
align-items: center;
gap: 11px;
padding: 11px 12px;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
}
.channel-connection-text {
flex: 1;
line-height: 1.3;
}
.channel-connection-text strong {
display: block;
color: var(--text-body);
font-size: 13px;
}
.channel-connection-text span {
color: var(--text-tertiary);
font-size: 11.5px;
}
.channel-agent-row,
.channel-agent-summary {
display: flex;
align-items: center;
gap: 12px;
}
.channel-agent-mark {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
background: var(--ai-bg);
color: var(--ai);
flex: none;
}
.channel-agent-summary {
flex: 1;
min-width: 0;
font-size: 13.5px;
}
.channel-agent-summary code {
color: var(--text-tertiary);
font-size: 12px;
}
.channel-agent-none {
padding: 18px 8px;
text-align: center;
}
.channel-agent-none .channel-agent-mark {
width: 44px;
height: 44px;
margin: 0 auto 12px;
border-radius: 11px;
}
.channel-agent-none strong {
display: block;
margin-bottom: 5px;
color: var(--text-body);
font-size: 14px;
}
.channel-agent-none p {
margin: 0 0 16px;
color: var(--text-tertiary);
font-size: 12.5px;
line-height: 1.5;
}
.channel-counters {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.channel-counters > div {
padding: 16px 18px;
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
background: var(--n-10);
}
.channel-counters span {
display: block;
margin-bottom: 8px;
color: var(--text-tertiary);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
}
.channel-counters strong {
color: var(--text-heading);
font-size: 26px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.channel-blockers-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.channel-blockers-list > div {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 12px;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
font-size: 12.5px;
}
.channel-delete-note {
margin: 0;
color: var(--text-secondary);
font-size: 12.5px;
line-height: 1.5;
}
.channel-warning-action { color: var(--warning-text); }
@@ -1,204 +0,0 @@
.channel-wizard h1 {
margin: 0 0 4px;
color: var(--text-heading);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
}
.channel-wizard-lead {
margin: 0 0 20px;
color: var(--text-secondary);
font-size: 13.5px;
}
.channel-stepper {
display: flex;
align-items: center;
gap: 0;
max-width: 720px;
margin-bottom: 24px;
}
.channel-step {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-tertiary);
font-size: 13px;
font-weight: 600;
}
.channel-step + .channel-step::before {
min-width: 48px;
flex: 1;
height: 2px;
margin: 0 12px;
background: var(--border-default);
content: "";
}
.channel-step + .channel-step { flex: 1; }
.channel-step.is-done + .channel-step::before { background: var(--success); }
.channel-step span {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--border-default);
color: var(--text-tertiary);
font-size: 12px;
flex: none;
}
.channel-step.is-current,
.channel-step.is-done {
color: var(--text-body);
}
.channel-step.is-current span {
background: var(--primary);
color: #fff;
}
.channel-step.is-done span {
background: var(--success);
color: #fff;
}
.channel-wizard-fields {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 520px;
}
.channel-field-hint {
color: var(--text-tertiary);
font-size: 11.5px;
font-style: normal;
}
.channel-field-hint.is-warning {
color: var(--warning-text);
}
.channel-wizard-policy {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
}
.channel-wizard-policy span {
padding: 2px 8px;
border-radius: 999px;
background: var(--n-9);
color: var(--text-tertiary);
font-size: 10.5px;
}
.channel-wizard-policy span.is-on {
background: var(--primary-bg);
color: var(--primary-hover);
font-weight: 600;
}
.channel-wizard-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.channel-wizard-actions.is-centered {
justify-content: center;
}
.channel-wizard-grid,
.channel-wizard-final {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
align-items: start;
gap: 18px;
}
.channel-wizard-aside {
display: flex;
flex-direction: column;
gap: 16px;
}
.channel-wizard-destination {
padding: 16px 18px;
border: 1px solid;
border-radius: var(--radius-2xl);
}
.channel-wizard-destination.is-ai {
border-color: var(--ai-border);
background: var(--ai-bg);
color: var(--ai);
}
.channel-wizard-destination.is-integration {
border-color: var(--primary-border);
background: var(--primary-bg);
color: var(--primary-hover);
}
.channel-wizard-destination p {
margin: 0 0 12px;
color: inherit;
font-size: 12.5px;
line-height: 1.55;
}
.channel-wizard-destination .link {
color: inherit;
font-size: 12.5px;
font-weight: 600;
}
.channel-wizard-done {
padding: 32px 28px;
text-align: center;
}
.channel-wizard-check {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
margin: 0 auto 16px;
border-radius: 14px;
background: var(--success-bg);
color: var(--success-text);
}
.channel-wizard-done h3 {
margin: 0 0 6px;
color: var(--text-heading);
font-size: 18px;
font-weight: 700;
}
.channel-wizard-done p {
max-width: 440px;
margin: 0 auto 20px;
color: var(--text-secondary);
font-size: 13.5px;
line-height: 1.55;
}
@media (max-width: 900px) {
.channel-wizard-grid,
.channel-wizard-final {
grid-template-columns: 1fr;
}
}
@@ -1,46 +0,0 @@
export type ChannelPolicy = {
requiresAuthenticatedProductIdentity: boolean;
allowAnonymousSessions: boolean;
allowSelfReportedContact: boolean;
allowSalesAttribution: boolean;
allowCheckoutActions: boolean;
};
export type PolicyFlag = keyof ChannelPolicy;
export type PolicyPreset = "SALES" | "SUPPORT" | "CUSTOM";
/** Сводка агента для перехода на его страницу. Конфигурации агента здесь нет. */
export type ChannelAgent = {
id: number;
name: string;
status: string;
model: string;
};
export type ChannelConnection = {
id: number;
provider: string;
name: string;
status: string;
};
export type Channel = {
id: number;
code: string;
name: string;
isActive: boolean;
product: { id: number; code: string; name: string } | null;
groupId: number | null;
groupName: string | null;
agent: ChannelAgent | null;
connections: ChannelConnection[];
policy: ChannelPolicy;
counters: { openConversations: number; connections: number };
createdAt: string;
updatedAt: string;
};
export type DeletionBlocker = { type: string; count: number };
export type PolicyViolation = { rule: string; field: string; detail: string };
@@ -1,103 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import { deleteChannel, loadChannel, updateChannel } from "./api";
import type { Channel, DeletionBlocker, PolicyViolation } from "./types";
export type ChannelFeedback = { kind: "error" | "warning"; text: string } | null;
export function useChannelDetail(channelId: number | null, openChannels: () => void) {
const [channel, setChannel] = useState<Channel | null>(null);
const [missing, setMissing] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [feedback, setFeedback] = useState<ChannelFeedback>(null);
const [busy, setBusy] = useState(false);
const [deleting, setDeleting] = useState(false);
const [confirmingDeactivation, setConfirmingDeactivation] = useState(false);
const [blockers, setBlockers] = useState<DeletionBlocker[] | null>(null);
const reload = useCallback(async () => {
if (channelId === null) return;
try {
const response = await loadChannel(channelId);
setChannel(response.channel);
setMissing(false);
setLoadFailed(false);
} catch (error) {
if (error instanceof ApiError && error.status === 404) setMissing(true);
else setLoadFailed(true);
}
}, [channelId]);
useEffect(() => {
setChannel(null);
setMissing(false);
setLoadFailed(false);
void reload();
}, [reload]);
async function patch(body: Parameters<typeof updateChannel>[1]) {
if (!channel) return false;
setFeedback(null);
setBusy(true);
try {
const response = await updateChannel(channel.id, body);
setChannel(response.channel);
if (response.warnings?.some((item) => item.code === "agent_still_active")) {
setFeedback({
kind: "warning",
text: "Канал деактивирован, но агент остаётся активным и продолжает занимать слот. Остановите его на странице агента.",
});
}
return true;
} catch (error) {
if (error instanceof ApiError) {
const violations = (error.payload as { violations?: PolicyViolation[] }).violations;
setFeedback({
kind: "error",
text: violations?.length ? violations.map((item) => item.detail).join("; ") : error.message,
});
} else setFeedback({ kind: "error", text: "Не удалось сохранить канал." });
return false;
} finally {
setBusy(false);
}
}
async function remove() {
if (!channel) return;
setBusy(true);
try {
await deleteChannel(channel.id);
openChannels();
} catch (error) {
if (error instanceof ApiError) {
const payload = error.payload as { blockers?: DeletionBlocker[] };
if (payload.blockers) setBlockers(payload.blockers);
else {
setDeleting(false);
setFeedback({ kind: "error", text: error.message });
}
} else setFeedback({ kind: "error", text: "Не удалось удалить канал." });
} finally {
setBusy(false);
}
}
function closeDeleteDialog() {
setDeleting(false);
setBlockers(null);
}
function requestToggleActive() {
if (!channel) return;
if (channel.isActive && channel.agent?.status === "ACTIVE") setConfirmingDeactivation(true);
else void patch({ isActive: !channel.isActive });
}
return {
blockers, busy, channel, confirmingDeactivation, deleting, feedback, loadFailed, missing,
closeDeleteDialog, patch, reload, remove, requestToggleActive, setChannel, setConfirmingDeactivation,
setDeleting,
};
}
@@ -1,59 +0,0 @@
import { useEffect, useState } from "react";
import {
channelDraft,
channelDraftChanged,
channelDraftWithProduct,
type ChannelEditDraft,
} from "./channel-editor";
import type { Channel, PolicyFlag } from "./types";
export function useChannelEditor(channel: Channel | null) {
const [editing, setEditing] = useState(false);
const [storedDraft, setStoredDraft] = useState<ChannelEditDraft | null>(null);
const draft = editing ? storedDraft : channel ? channelDraft(channel) : null;
useEffect(() => {
setEditing(false);
setStoredDraft(null);
}, [channel?.id]);
function begin() {
if (!channel) return;
setStoredDraft(channelDraft(channel));
setEditing(true);
}
function cancel() {
setEditing(false);
setStoredDraft(null);
}
function update(patch: Partial<ChannelEditDraft>) {
setStoredDraft((current) => current ? { ...current, ...patch } : current);
}
function setProductId(productId: number | null) {
setStoredDraft((current) => current ? channelDraftWithProduct(current, productId) : current);
}
function setPolicy(flag: PolicyFlag, value: boolean) {
setStoredDraft((current) => current ? {
...current,
policy: { ...current.policy, [flag]: value },
} : current);
}
return {
draft,
dirty: Boolean(editing && channel && storedDraft && channelDraftChanged(channel, storedDraft)),
editing,
begin,
cancel,
finish: cancel,
setGroupId: (groupId: number | null) => update({ groupId }),
setName: (name: string) => update({ name }),
setPolicy,
setProductId,
};
}
@@ -1,88 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import { deleteChannel, listChannels, updateChannel } from "./api";
import { groupTabs, filterChannels } from "./model";
import type { Channel, DeletionBlocker } from "./types";
export function useChannelsPage(groups: Array<{ id: number; name: string }>) {
const [channels, setChannels] = useState<Channel[] | null>(null);
const [failed, setFailed] = useState(false);
const [tab, setTab] = useState("all");
const [search, setSearch] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Channel | null>(null);
const [deactivationTarget, setDeactivationTarget] = useState<Channel | null>(null);
const [blockers, setBlockers] = useState<DeletionBlocker[] | null>(null);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const reload = useCallback(async () => {
try {
const response = await listChannels();
setChannels(response.items);
setFailed(false);
} catch {
setFailed(true);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const tabs = useMemo(() => groupTabs(channels ?? [], groups), [channels, groups]);
const visible = useMemo(
() => filterChannels(channels ?? [], { tab, search, showArchived }),
[channels, tab, search, showArchived],
);
async function toggleActive(channel: Channel) {
setBusy(true);
setFeedback(null);
try {
await updateChannel(channel.id, { isActive: !channel.isActive });
await reload();
} catch (error) {
setFeedback(error instanceof ApiError ? error.message : "Не удалось изменить состояние канала.");
} finally {
setBusy(false);
}
}
function requestToggleActive(channel: Channel) {
if (channel.isActive && channel.agent?.status === "ACTIVE") setDeactivationTarget(channel);
else void toggleActive(channel);
}
async function removeChannel(channel: Channel) {
setBusy(true);
setFeedback(null);
try {
await deleteChannel(channel.id);
closeDelete();
await reload();
} catch (error) {
const payload = (error as { payload?: { blockers?: DeletionBlocker[] } }).payload;
if (payload?.blockers) setBlockers(payload.blockers);
else {
closeDelete();
setFeedback(error instanceof ApiError ? error.message : "Не удалось удалить канал.");
}
} finally {
setBusy(false);
}
}
function closeDelete() {
setDeleteTarget(null);
setBlockers(null);
}
return {
archived: channels?.filter((channel) => !channel.isActive).length ?? 0,
blockers, busy, channels, deactivationTarget, deleteTarget, failed, feedback, search, showArchived, tab, tabs, visible,
closeDelete, reload, removeChannel, requestToggleActive, setDeactivationTarget, setDeleteTarget,
setSearch, setShowArchived, setTab, toggleActive,
};
}
@@ -86,7 +86,7 @@ export function commandCenterModel(data: ApiCommandOverview) {
name: department.name,
route: department.route,
icon: department.name === "Поддержка" ? "wrench" : "shop",
subtitle: `Сотрудники: ${department.employees} · AI-агенты: ${department.aiAgents}`,
subtitle: `Сотрудники: ${department.employees} · Агенты: ${department.aiAgents}`,
status: d.waiting > 0 ? STATUS_META.attention : STATUS_META.ok,
summary: d.waiting > 0 ? `В очереди ${d.waiting} — нужен оператор.` : "Очередь оператора пуста.",
dialogItems: [
@@ -160,12 +160,12 @@ export function IntegrationForm({ initial, kind, onClose, onSaved }: { initial:
{isMessenger && !isWeb && !isEmail && (
<label className="integration-notifier-toggle">
<input type="checkbox" checked={isNotifier} onChange={(event) => setIsNotifier(event.target.checked)} />
Бот уведомлений для сотрудников (не участвует в продажах, привязка в профиле)
Бот уведомлений для сотрудников (сервисный, клиентам не отвечает; привязка в профиле)
</label>
)}
{isMessenger && !isNotifier && (
<SelectField
label="Канал обработки"
label="Агент"
value={channelId}
onChange={setChannelId}
options={[["", "— не привязан —"], ...channels.map((c) => [String(c.id), c.name] as [string, string])]}
@@ -91,7 +91,8 @@ export const KIND_LABEL: Record<IntegrationKind, string> = {
export type ChannelOption = { id: number; code: string; name: string };
export const fetchChannels = () => api<{ items: ChannelOption[] }>("/api/v1/channels/").then((r) => r.items);
// Привязка подключения выбирает агента; id карточки агента = id канала.
export const fetchChannels = () => api<{ items: ChannelOption[] }>("/api/v1/agents/").then((r) => r.items);
export const fetchLlmProviders = () =>
api<{ items: Integration[] }>("/api/v1/integrations/").then((response) =>
+1 -1
View File
@@ -83,7 +83,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductCode
const isDialogsWorkspace = isSalesDialogs || isSupportDialogs;
const isSalesClients = route === "salesClients";
const isSalesClientDetail = route === "salesClientDetail";
const isAiFullWidth = route === "aiAgentCreate";
const isAiFullWidth = false;
const isKnowledgeLibrary = route === "aiKnowledge";
const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
return (
@@ -1,15 +1,11 @@
import { lazy, Suspense } from "react";
import { AiAgentsPage } from "../features/ai/AiAgentsPage";
import { AiAgentCreatePage } from "../features/ai/create/AiAgentCreatePage";
import { AiAgentDetailPage } from "../features/ai/detail/AiAgentDetailPage";
import { AgentsPage } from "../features/agents/AgentsPage";
import { AgentDetailPage } from "../features/agents/AgentDetailPage";
import { KnowledgeDetailPage } from "../features/ai/knowledge/KnowledgeDetailPage";
import { KnowledgeCreatePage } from "../features/ai/knowledge/KnowledgeCreatePage";
import { KnowledgePage } from "../features/ai/knowledge/KnowledgePage";
import { CommandCenter } from "../features/command/CommandCenter";
import { ChannelsPage } from "../features/channels/ChannelsPage";
import { ChannelDetailPage } from "../features/channels/ChannelDetailPage";
import { ChannelCreateWizard } from "../features/channels/ChannelCreateWizard";
import { IntegrationsPage } from "../features/integrations/IntegrationsPage";
import { EmployeeDetailPage, EmployeesPage } from "../features/employees/EmployeesPage";
import { ProfilePage } from "../features/profile/ProfilePage";
@@ -54,15 +50,23 @@ export function ShellRouteContent({ route, data, currentEmployee, selectedProduc
{route === "supportOverview" && <SupportOverviewPage />}
{route === "supportDialogs" && <SupportDialogsPage initialConversationId={selectedConversationId} user={user} />}
{route === "salesDialogs" && <SalesDialogsPage initialConversationId={selectedConversationId} user={user} />}
{route === "aiAgents" && <AiAgentsPage agents={data.agents} reload={reload} openAgentCreate={openAgentCreate} openAgent={openAgent} />}
{route === "aiAgentCreate" && <AiAgentCreatePage selectedProductCode={selectedProductCode} reload={reload} setRoute={setRoute} openAgent={openAgent} />}
{route === "aiAgentDetail" && <AiAgentDetailPage agentId={selectedAgentId} openKnowledge={openKnowledge} openChannel={openChannel} onAgentLoaded={onAgentLoaded} setRoute={setRoute} />}
{route === "agents" && <AgentsPage agents={data.agents} groups={data.groups} reload={reload} openAgent={openAgent} />}
{route === "agentDetail" && (
<AgentDetailPage
agentId={selectedAgentId}
groups={data.groups}
canManage={hasCapability(user, "ai.manage")}
canManageConnections={hasCapability(user, "integrations.manage")}
openAgents={() => setRoute("agents")}
openKnowledge={openKnowledge}
openIntegrations={() => setRoute("integrations")}
setRoute={setRoute}
onLoaded={onAgentLoaded}
/>
)}
{route === "aiKnowledge" && <KnowledgePage openAgent={openAgent} openKnowledge={openKnowledge} setRoute={setRoute} user={user} />}
{route === "aiKnowledgeCreate" && <KnowledgeCreatePage openKnowledge={openKnowledge} setRoute={setRoute} />}
{route === "aiKnowledgeDetail" && <KnowledgeDetailPage agents={data.agents} canManage={hasCapability(user, "ai.manage")} knowledgeId={selectedKnowledgeId} openAgent={openAgent} setRoute={setRoute} onLoaded={onAgentLoaded} />}
{route === "channels" && <ChannelsPage user={user} groups={data.groups} openChannel={openChannel} openChannelCreate={() => setRoute("channelCreate")} openAgent={openAgent} />}
{route === "channelDetail" && <ChannelDetailPage channelId={selectedChannelId} groups={data.groups} products={data.products} user={user} setRoute={setRoute} openAgent={openAgent} openChannels={() => setRoute("channels")} onChannelLoaded={onChannelLoaded} />}
{route === "channelCreate" && <ChannelCreateWizard groups={data.groups} products={data.products} openChannel={openChannel} openChannels={() => setRoute("channels")} openAgentCreate={() => openAgentCreate(null)} openIntegrations={() => setRoute("integrations")} />}
{route === "integrations" && <IntegrationsPage />}
{route === "supportPortals" && <Suspense fallback={<LoadingState />}><SupportPortalsPage user={user} openPortal={openSupportPortal} /></Suspense>}
{route === "supportPortalDetail" && <Suspense fallback={<LoadingState />}><SupportPortalDetailPage portalId={selectedSupportPortalId} products={data.products} user={user} openPortals={() => setRoute("supportPortals")} /></Suspense>}
+1 -2
View File
@@ -42,7 +42,6 @@ const SUPPORT_ITEMS: SidebarNavSectionItem[] = [
];
const AI_ITEMS: SidebarNavSectionItem[] = [
{ activeRoutes: ["aiAgents", "aiAgentCreate", "aiAgentDetail"], key: "aiAgents", label: "AI-агенты" },
{ activeRoutes: ["aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail"], key: "aiKnowledge", label: "Знания" },
{ activeRoutes: ["aiUsage"], disabled: true, key: "aiUsage", label: "Использование AI" },
];
@@ -98,7 +97,7 @@ export function Sidebar({ route, user, setRoute, onLogout, waitingCount = 0 }: {
<SidebarNavSection icon="shop" items={salesItems} label="Клиенты" route={route} setRoute={setRoute} storageKey={sectionStorageKey("sales")} />
<SidebarNavSection icon="wrench" items={supportItems} label="Поддержка" route={route} setRoute={setRoute} storageKey={sectionStorageKey("support")} />
{canAccess(user, "employees") && <SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label="Сотрудники" route={route} routeKey="employees" setRoute={setRoute} />}
{canAccess(user, "channels") && <SidebarLink activeRoutes={["channels", "channelCreate", "channelDetail"]} icon="route" label="Каналы" route={route} routeKey="channels" setRoute={setRoute} />}
{canAccess(user, "agents") && <SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label="Агенты" route={route} routeKey="agents" setRoute={setRoute} />}
<div className="hub-nav-group">ПЛАТФОРМА</div>
<SidebarNavSection icon="robot" items={aiItems} label="AI" route={route} setRoute={setRoute} storageKey={sectionStorageKey("ai")} />
{canAccess(user, "integrations") && <SidebarLink icon="plug" label="Интеграции" route={route} routeKey="integrations" setRoute={setRoute} />}
+7 -10
View File
@@ -4,8 +4,8 @@ import { Icon } from "../shared/icons";
export function TopBar({ route, user, currentEmployee, currentAgentName, currentChannelName, setRoute, unreadCount = 0, onOpenNotifications }: { route: RouteKey; user: SessionUser; currentEmployee?: Employee | null; currentAgentName?: string | null; currentChannelName?: string | null; setRoute: (route: RouteKey) => void; unreadCount?: number; onOpenNotifications?: () => void }) {
const isCommand = route === "command";
const isAiDetail = route === "aiAgentDetail" || route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
const isAi = route.startsWith("ai") && !isAiDetail && route !== "aiAgentCreate";
const isAiDetail = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
const isAi = route.startsWith("ai") && !isAiDetail;
const isSalesWorkspace = route === "salesClientDetail" || route === "salesClients" || route === "salesDialogs";
const isSupportWorkspace = route === "supportOverview" || route === "supportDialogs" || route === "supportPortals" || route === "supportPortalDetail";
return (
@@ -14,18 +14,15 @@ export function TopBar({ route, user, currentEmployee, currentAgentName, current
<button type="button" onClick={() => setRoute("command")}>{user.organizationName}</button>
<i>/</i>
{route === "employeeDetail" && <><button type="button" onClick={() => setRoute("employees")}>Сотрудники</button><i>/</i><strong>{currentEmployee?.fullName || currentEmployee?.email || routes[route]}</strong></>}
{route === "channelDetail" && <><button type="button" onClick={() => setRoute("channels")}>Каналы</button><i>/</i><strong>{currentChannelName || routes[route]}</strong></>}
{route === "channelCreate" && <><button type="button" onClick={() => setRoute("channels")}>Каналы</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "agentDetail" && <><button type="button" onClick={() => setRoute("agents")}>Агенты</button><i>/</i><strong>{currentAgentName || routes[route]}</strong></>}
{isSalesWorkspace && route !== "salesClientDetail" && <><button type="button" onClick={() => setRoute("salesDialogs")}>Клиенты</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "salesClientDetail" && <><button type="button" onClick={() => setRoute("salesDialogs")}>Клиенты</button><i>/</i><button type="button" onClick={() => setRoute("salesClients")}>Контакты</button><i>/</i><strong>{routes[route]}</strong></>}
{isSupportWorkspace && route !== "supportPortalDetail" && <><button type="button" onClick={() => setRoute("supportOverview")}>Поддержка</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "supportPortalDetail" && <><button type="button" onClick={() => setRoute("supportOverview")}>Поддержка</button><i>/</i><button type="button" onClick={() => setRoute("supportPortals")}>Порталы</button><i>/</i><strong>{routes[route]}</strong></>}
{isAi && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "aiAgentCreate" && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiAgents")}>AI-агенты</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "aiAgentDetail" && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiAgents")}>AI-агенты</button><i>/</i><strong>{currentAgentName || routes[route]}</strong></>}
{route === "aiKnowledgeDetail" && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiKnowledge")}>Знания</button><i>/</i><strong>{currentAgentName || routes[route]}</strong></>}
{route === "aiKnowledgeCreate" && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiKnowledge")}>Знания</button><i>/</i><strong>{routes[route]}</strong></>}
{route !== "employeeDetail" && route !== "channelDetail" && route !== "channelCreate" && route !== "supportPortalDetail" && route !== "aiAgentCreate" && !isSalesWorkspace && !isSupportWorkspace && !isAi && !isAiDetail && <strong>{routes[route]}</strong>}
{isAi && <><button type="button" onClick={() => setRoute("aiKnowledge")}>AI</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "aiKnowledgeDetail" && <><button type="button" onClick={() => setRoute("aiKnowledge")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiKnowledge")}>Знания</button><i>/</i><strong>{currentAgentName || routes[route]}</strong></>}
{route === "aiKnowledgeCreate" && <><button type="button" onClick={() => setRoute("aiKnowledge")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiKnowledge")}>Знания</button><i>/</i><strong>{routes[route]}</strong></>}
{route !== "employeeDetail" && route !== "agentDetail" && route !== "supportPortalDetail" && !isSalesWorkspace && !isSupportWorkspace && !isAi && !isAiDetail && <strong>{routes[route]}</strong>}
</div>
<div className="topbar-actions">
<button className="icon-button" aria-label="Уведомления" onClick={onOpenNotifications}><Icon name="bell" size={18} />{unreadCount > 0 && <b>{unreadCount > 99 ? "99+" : unreadCount}</b>}</button>
+13 -24
View File
@@ -23,21 +23,19 @@ describe("sales detail routes", () => {
});
});
describe("ai agent routes", () => {
it("parses an AI agent detail URL", () => {
expect(routeFromPath("/ai/agents/7")).toEqual({ route: "aiAgentDetail", ...empty, agentId: 7 });
describe("agent routes", () => {
it("parses an agent detail URL", () => {
expect(routeFromPath("/agents/7")).toEqual({ route: "agentDetail", ...empty, agentId: 7 });
});
it("creates an AI agent detail URL", () => {
expect(pathFromRoute("aiAgentDetail", 7)).toBe("/ai/agents/7");
it("creates an agent detail URL", () => {
expect(pathFromRoute("agentDetail", 7)).toBe("/agents/7");
expect(pathFromRoute("agentDetail", null)).toBe("/agents");
});
it("parses an AI agent creation URL", () => {
expect(routeFromPath("/ai/agents/new", "?product=academy")).toEqual({ route: "aiAgentCreate", ...empty, productCode: "academy" });
});
it("creates an AI agent creation URL", () => {
expect(pathFromRoute("aiAgentCreate", null, "academy")).toBe("/ai/agents/new?product=academy");
it("redirects legacy ai agent URLs to the unified section", () => {
expect(routeFromPath("/ai/agents/7")).toEqual({ route: "agents", ...empty });
expect(routeFromPath("/ai/agents/new")).toEqual({ route: "agents", ...empty });
});
});
@@ -127,25 +125,16 @@ describe("organization routes", () => {
describe("channel routes", () => {
it("parses the list, wizard and card URLs", () => {
expect(routeFromPath("/channels")).toEqual({ route: "channels", ...empty });
expect(routeFromPath("/channels/new")).toEqual({ route: "channelCreate", ...empty });
expect(routeFromPath("/channels/12")).toEqual({ route: "channelDetail", ...empty, channelId: 12 });
expect(routeFromPath("/channels")).toEqual({ route: "agents", ...empty });
expect(routeFromPath("/channels/12")).toEqual({ route: "agentDetail", ...empty, agentId: 12 });
});
it("falls back to the list for a malformed channel id", () => {
expect(routeFromPath("/channels/abc")).toEqual({ route: "channels", ...empty });
});
it("builds channel paths", () => {
expect(pathFromRoute("channels")).toBe("/channels");
expect(pathFromRoute("channelCreate")).toBe("/channels/new");
expect(pathFromRoute("channelDetail", 12)).toBe("/channels/12");
expect(pathFromRoute("channelDetail", null)).toBe("/channels");
expect(routeFromPath("/channels/abc")).toEqual({ route: "agents", ...empty });
});
it("keeps AI routes untouched", () => {
expect(routeFromPath("/ai/agents")).toEqual({ route: "aiAgents", ...empty });
expect(routeFromPath("/ai/agents")).toEqual({ route: "agents", ...empty });
expect(routeFromPath("/ai/knowledge")).toEqual({ route: "aiKnowledge", ...empty });
expect(pathFromRoute("aiAgentDetail", 7)).toBe("/ai/agents/7");
});
});
+12 -18
View File
@@ -39,21 +39,19 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
const id = Number(path.split("/")[4]);
return Number.isInteger(id) && id > 0 ? { ...state, route: "supportPortalDetail", supportPortalId: id } : { route: "supportPortals", ...state };
}
if (path === "/channels") return { route: "channels", ...state };
if (path === "/channels/new") return { route: "channelCreate", ...state };
// Устаревшие адреса каналов и AI-агентов ведут в объединённый раздел.
if (path === "/agents" || path === "/channels" || path === "/ai" || path === "/ai/agents") {
return { route: "agents", ...state };
}
if (path.startsWith("/agents/")) {
const id = Number(path.split("/")[2]);
return Number.isInteger(id) && id > 0 ? { ...state, route: "agentDetail", agentId: id } : { route: "agents", ...state };
}
if (path.startsWith("/channels/")) {
const id = Number(path.split("/")[2]);
return Number.isInteger(id) && id > 0 ? { ...state, route: "channelDetail", channelId: id } : { route: "channels", ...state };
}
if (path === "/ai" || path === "/ai/agents") return { route: "aiAgents", ...state };
if (path === "/ai/agents/new") {
const productCode = new URLSearchParams(search).get("product");
return { route: "aiAgentCreate", ...state, productCode };
}
if (path.startsWith("/ai/agents/")) {
const id = Number(path.split("/")[3]);
return Number.isInteger(id) && id > 0 ? { ...state, route: "aiAgentDetail", agentId: id } : { route: "aiAgents", ...state };
return Number.isInteger(id) && id > 0 ? { ...state, route: "agentDetail", agentId: id } : { route: "agents", ...state };
}
if (path.startsWith("/ai/agents/")) return { route: "agents", ...state };
if (path === "/ai/knowledge") return { route: "aiKnowledge", ...state };
if (path === "/ai/knowledge/new") return { route: "aiKnowledgeCreate", ...state };
if (path.startsWith("/ai/knowledge/")) {
@@ -88,16 +86,12 @@ export function pathFromRoute(route: RouteKey, entityId: number | null = null, p
if (route === "employeeDetail") return entityId ? `${prefix}/employees/${entityId}` : `${prefix}/employees`;
if (route === "supportPortals") return `${prefix}/departments/support/portals`;
if (route === "supportPortalDetail") return entityId ? `${prefix}/departments/support/portals/${entityId}` : `${prefix}/departments/support/portals`;
if (route === "aiAgents") return `${prefix}/ai/agents`;
if (route === "aiAgentCreate") return productCode ? `${prefix}/ai/agents/new?product=${encodeURIComponent(productCode)}` : `${prefix}/ai/agents/new`;
if (route === "agents") return `${prefix}/agents`;
if (route === "agentDetail") return entityId ? `${prefix}/agents/${entityId}` : `${prefix}/agents`;
if (route === "aiUsage") return `${prefix}/ai/usage`;
if (route === "aiAgentDetail") return entityId ? `${prefix}/ai/agents/${entityId}` : `${prefix}/ai/agents`;
if (route === "aiKnowledge") return `${prefix}/ai/knowledge`;
if (route === "aiKnowledgeCreate") return `${prefix}/ai/knowledge/new`;
if (route === "aiKnowledgeDetail") return entityId ? `${prefix}/ai/knowledge/${entityId}` : `${prefix}/ai/knowledge`;
if (route === "channels") return `${prefix}/channels`;
if (route === "channelCreate") return `${prefix}/channels/new`;
if (route === "channelDetail") return entityId ? `${prefix}/channels/${entityId}` : `${prefix}/channels`;
if (route === "integrations") return `${prefix}/integrations`;
if (route === "administrationOrganization") return `${prefix}/administration/organization`;
if (route === "administrationSubscription") return `${prefix}/administration/subscription`;
+2 -6
View File
@@ -16,15 +16,11 @@ export const routes: Record<RouteKey, string> = {
supportDialogs: "Диалоги",
supportPortals: "Порталы",
supportPortalDetail: "Портал поддержки",
aiAgents: "AI-агенты",
aiAgentCreate: "Создание агента",
aiAgentDetail: "Карточка агента",
agents: "Агенты",
agentDetail: "Карточка агента",
aiKnowledge: "Знания",
aiKnowledgeCreate: "Создание знания",
aiKnowledgeDetail: "Знание",
aiUsage: "Использование AI",
integrations: "Интеграции",
channels: "Каналы",
channelCreate: "Создание канала",
channelDetail: "Карточка канала",
};
@@ -23,7 +23,7 @@ function outcomeText(outcome: AgentLinkOutcome, forms: [string, string, string])
const verb = outcome.action === "attach" ? "Прикреплено" : "Откреплено";
const main = `${verb}: ${outcome.changed} ${pluralize(outcome.changed, forms)}.`;
if (outcome.skipped === 0) return main;
return `${main} Пропущено: ${outcome.skipped} — отдел агента не совпадает с областью доступности.`;
return `${main} Пропущено: ${outcome.skipped}.`;
}
/**
+1 -14
View File
@@ -7,34 +7,21 @@
@import "./layout/topbar.css";
@import "./layout/page.css";
@import "./features/command/center/styles.css";
@import "./features/sales/overview/styles.css";
@import "./features/sales/clients/styles.css";
@import "./features/sales/client-detail/styles.css";
@import "./features/sales/dialogs/styles.css";
@import "./features/sales/order-detail/styles.css";
@import "./features/sales/orders/styles.css";
@import "./features/sales/registry/styles.css";
@import "./features/sales/sale-detail/styles.css";
@import "./features/departments/styles.css";
@import "./shared/table-controls.css";
@import "./shared/menu.css";
@import "./shared/links.css";
@import "./features/products/styles.css";
@import "./features/employees/styles.css";
@import "./features/profile/styles.css";
@import "./features/ai/styles.css";
@import "./features/ai/create/styles.css";
@import "./features/ai/detail/styles.css";
@import "./features/agents/styles.css";
@import "./features/ai/knowledge/styles.css";
@import "./features/ai/knowledge/styles-editor.css";
@import "./features/ai/knowledge/styles-bulk.css";
@import "./features/ai/knowledge/styles-library.css";
@import "./features/ai/knowledge/styles-category.css";
@import "./features/channels/styles-list.css";
@import "./features/channels/styles-detail.css";
@import "./features/channels/styles-policy.css";
@import "./features/channels/styles-sections.css";
@import "./features/channels/styles-wizard.css";
@import "./features/integrations/styles.css";
@import "./features/administration/styles.css";
@import "./features/notifications/styles.css";
+3 -3
View File
@@ -1,4 +1,4 @@
import type { AiAgent } from "./features/ai/model";
import type { AgentCard } from "./features/agents/model";
// Роли SPEC-HUB-0031 §3: OWNER и ADMIN идентичны (владельца нельзя удалить),
// EMPLOYEE работает только в чате; видимость диалогов — по группам (ADR-HUB-0043).
@@ -113,11 +113,11 @@ export type Product = {
updatedAt: string;
};
export type RouteKey = "administrationOrganization" | "administrationSubscription" | "administrationAudit" | "command" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "salesDialogs" | "supportOverview" | "supportDialogs" | "supportPortals" | "supportPortalDetail" | "aiAgents" | "aiAgentCreate" | "aiAgentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage" | "integrations" | "channels" | "channelCreate" | "channelDetail";
export type RouteKey = "administrationOrganization" | "administrationSubscription" | "administrationAudit" | "command" | "employeeDetail" | "employees" | "profile" | "settings" | "salesClientDetail" | "salesClients" | "salesDialogs" | "supportOverview" | "supportDialogs" | "supportPortals" | "supportPortalDetail" | "agents" | "agentDetail" | "aiKnowledge" | "aiKnowledgeCreate" | "aiKnowledgeDetail" | "aiUsage" | "integrations";
export type AppData = {
employees: Employee[];
groups: EmployeeGroup[];
products: Product[];
agents: AiAgent[];
agents: AgentCard[];
};
+3 -3
View File
@@ -40,11 +40,11 @@ export function useRouteNavigation(
organizationPublicId: organizationId,
route: nextRoute,
employeeId: nextRoute === "employeeDetail" ? entityId : null,
productCode: nextRoute === "aiAgentCreate" ? productCode : null,
agentId: nextRoute === "aiAgentDetail" ? entityId : null,
productCode,
agentId: nextRoute === "agentDetail" ? entityId : null,
knowledgeId: nextRoute === "aiKnowledgeDetail" ? entityId : null,
clientId: nextRoute === "salesClientDetail" ? entityId : null,
channelId: nextRoute === "channelDetail" ? entityId : null,
channelId: null,
supportPortalId: nextRoute === "supportPortalDetail" ? entityId : null,
};
applyRouteState(nextState);
@@ -0,0 +1,56 @@
# 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
_ds/ — токены дизайн-системы
assets/logo-mark.svg — знак
assets/av-1..4.png — аватары (Дмитрий Орлов · Анна Ким · Сергей Крылов · Елена Кузнецова)
support.js — рантайм макетов
```
## Админ Baseline — карта кадров
- **A1 Владелец · Чат** — стартовый экран. Сайдбар: логотип + переключатель организации; навигация ровно 6 пунктов плоским списком (Чат с бейджем очереди · Контакты · Агенты · Сотрудники · Доска · Настройки); блок «Запуск» (3 шага, автоотметка, прогресс, primary на текущий шаг, «Скрыть»); профиль с ролью внизу. В списке диалогов заголовок — **переключатель охвата** (поповер: Все диалоги · Группы · Агенты · Ответственный) — дерево из сайдбара сотрудника у админа переезжает сюда, чтобы навигация осталась плоской (§4.1). Inline-`Hint` первого визита под вкладками.
- **A2 Админ · рабочий режим** — «Запуск» завершён и скрыт, подсказка закрыта, охват «Операторы». От владельца отличается только подписью роли (§3).
## Чат сотрудника — карта кадров
- **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`.
@@ -0,0 +1,4 @@
@import "./tokens/colors.css";
@import "./tokens/typography.css";
@import "./tokens/spacing.css";
@import "./tokens/effects.css";
@@ -0,0 +1,88 @@
/* Edevs Hub — 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);
}
@@ -0,0 +1,40 @@
/* Edevs Hub — 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; }
}
@@ -0,0 +1,36 @@
/* Edevs Hub — 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;
}
@@ -0,0 +1,42 @@
/* Edevs Hub — 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.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 266 B

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,274 @@
<!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="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;display:flex;align-items:center;gap:10px;padding:14px 14px 10px;">
<img src="assets/logo-mark.svg" width="28" height="28" style="display:block;border-radius:8px;" alt="">
<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",
board: "M4 4h16a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM3 9h18M9 9v11",
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: "team", label: "Сотрудники", icon: ICON.team }, { key: "board", label: "Доска", icon: ICON.board }, { 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>
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more