mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ 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:
1 parent
07f4cd5f34
commit
774eb3215a
101 files changed
+4928
-4874
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class TenantAPIClient(APIClient):
|
||||
|
||||
tenant_namespaces = {
|
||||
"access-profiles",
|
||||
"agents",
|
||||
"ai",
|
||||
"calls",
|
||||
"channels",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
+72
-80
@@ -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) =>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
|
||||
@@ -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}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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="{"theme":{"editor":"enum","options":["light","dark"],"default":"light","tsType":"'light' | 'dark'","section":"Тема"},"accent":{"editor":"color","options":["#1677ff","#5b5bd6","#0f9b8e","#fa8c16"],"default":"#1677ff","tsType":"string","section":"Тема"}}">
|
||||
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
Reference in new issue
Block a user