diff --git a/apps/backend/hub_backend/urls_app.py b/apps/backend/hub_backend/urls_app.py index 43b889d..8e229ad 100644 --- a/apps/backend/hub_backend/urls_app.py +++ b/apps/backend/hub_backend/urls_app.py @@ -24,6 +24,10 @@ urlpatterns = [ include("hub_platform.identity.employee_urls"), ), path("api/v1/organizations//ai/", include("hub_platform.ai.urls")), + path( + "api/v1/organizations//agents/", + include("hub_platform.ai.agent_card_urls"), + ), path( "api/v1/organizations//integrations/", include("hub_platform.integrations.urls"), diff --git a/apps/backend/hub_platform/ai/agent_card.py b/apps/backend/hub_platform/ai/agent_card.py new file mode 100644 index 0000000..5cc422d --- /dev/null +++ b/apps/backend/hub_platform/ai/agent_card.py @@ -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) diff --git a/apps/backend/hub_platform/ai/agent_card_urls.py b/apps/backend/hub_platform/ai/agent_card_urls.py new file mode 100644 index 0000000..123d8a0 --- /dev/null +++ b/apps/backend/hub_platform/ai/agent_card_urls.py @@ -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("/", views.AgentCardDetailView.as_view(), name="agent-card-detail"), + path( + "/activate/", + views.AgentCardActivateView.as_view(), + name="agent-card-activate", + ), + path( + "/deactivate/", + views.AgentCardDeactivateView.as_view(), + name="agent-card-deactivate", + ), + path( + "/connections/", + views.AgentCardConnectionsView.as_view(), + name="agent-card-connections", + ), + path( + "/connections//", + views.AgentCardConnectionDetailView.as_view(), + name="agent-card-connection-detail", + ), + # Тестовый чат исполняет агента канала; вьюха принимает channel_id — id + # карточки и канала совпадают по построению. + path( + "/test-chat/", + ChannelTestChatView.as_view(), + name="agent-card-test-chat", + ), +] diff --git a/apps/backend/hub_platform/ai/agent_card_views.py b/apps/backend/hub_platform/ai/agent_card_views.py new file mode 100644 index 0000000..1bddebb --- /dev/null +++ b/apps/backend/hub_platform/ai/agent_card_views.py @@ -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))}) diff --git a/apps/backend/hub_platform/ai/migrations/0015_backfill_channel_agents.py b/apps/backend/hub_platform/ai/migrations/0015_backfill_channel_agents.py new file mode 100644 index 0000000..ed74c54 --- /dev/null +++ b/apps/backend/hub_platform/ai/migrations/0015_backfill_channel_agents.py @@ -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), + ] diff --git a/apps/backend/hub_platform/ai/test_agent_cards.py b/apps/backend/hub_platform/ai/test_agent_cards.py new file mode 100644 index 0000000..ae06b50 --- /dev/null +++ b/apps/backend/hub_platform/ai/test_agent_cards.py @@ -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"]) diff --git a/apps/backend/hub_platform/notifications/services.py b/apps/backend/hub_platform/notifications/services.py index 437bcd1..18e0dbe 100644 --- a/apps/backend/hub_platform/notifications/services.py +++ b/apps/backend/hub_platform/notifications/services.py @@ -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"}, } diff --git a/apps/backend/hub_platform/testing.py b/apps/backend/hub_platform/testing.py index dee5a4b..0227fc0 100644 --- a/apps/backend/hub_platform/testing.py +++ b/apps/backend/hub_platform/testing.py @@ -32,6 +32,7 @@ class TenantAPIClient(APIClient): tenant_namespaces = { "access-profiles", + "agents", "ai", "calls", "channels", diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index cb5955f..788f72c 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -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) ? ( navigate(defaultRoute(user), null, true)} /> ) : ( - 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} /> + 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} /> )} ); diff --git a/apps/internal-ui/src/auth/access.test.ts b/apps/internal-ui/src/auth/access.test.ts index e527594..0e14e06 100644 --- a/apps/internal-ui/src/auth/access.test.ts +++ b/apps/internal-ui/src/auth/access.test.ts @@ -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"); }); diff --git a/apps/internal-ui/src/features/agents/AgentDetailPage.tsx b/apps/internal-ui/src/features/agents/AgentDetailPage.tsx new file mode 100644 index 0000000..421176a --- /dev/null +++ b/apps/internal-ui/src/features/agents/AgentDetailPage.tsx @@ -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(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(null); + const [deleting, setDeleting] = useState(false); + const [providers, setProviders] = useState([]); + const [messengers, setMessengers] = useState([]); + + 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 ; + if (failed) return void reload()} />; + if (!card) return ; + + 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 ( +
+
+
+
+

{card.name}

+ +
+
+ {card.groupName ?? "Без группы"} + · + {card.code} +
+
+ {canManage && ( +
+ +
+ )} +
+ + {feedback &&
{feedback.text}
} + + + + + + item.channel === null)} + openIntegrations={openIntegrations} + bind={bind} + unbind={unbind} + /> + + {canManage && ( +
+ + +
+ )} + + {deleting && ( + void removeAgent()} + onCancel={() => setDeleting(false)} + > +

Агент «{card.name}» будет удалён безвозвратно. Диалоги и подключения не дают удалить агента.

+
+ )} +
+ ); +} + +function AssignmentSection({ + card, groups, canManage, busy, apply, +}: { + card: AgentCard; + groups: EmployeeGroup[]; + canManage: boolean; + busy: boolean; + apply: (patch: AgentPatch) => Promise; +}) { + const [name, setName] = useState(card.name); + useEffect(() => setName(card.name), [card.name]); + const dirty = name.trim() !== card.name && name.trim().length > 0; + + return ( +
+
+

Назначение

+ Группа определяет, какие сотрудники видят диалоги агента; без группы диалоги видны всем +
+
+ {canManage ? ( + + ) : ( + + )} + {canManage ? ( + void apply({ groupId: value ? Number(value) : null })} + options={[["", "Без группы"], ...groups.map((item) => [String(item.id), item.name] as [string, string])]} + /> + ) : ( + + )} + {card.code}} /> + +
+ {canManage && dirty && ( +
+ + +
+ )} +
+ ); +} + +function InstructionsSection({ + card, canManage, busy, apply, +}: { + card: AgentCard; + canManage: boolean; + busy: boolean; + apply: (patch: AgentPatch) => Promise; +}) { + 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 ( +
+

Инструкции

+ {card.persona || card.tone || card.instructions ? ( +
+ {card.persona && } + {card.tone && } + {card.instructions && } +
+ ) : ( +

Инструкции не заданы.

+ )} +
+ ); + } + + return ( +
+
+

Инструкции

+ Системный промпт собирается из трёх частей в этом порядке +
+
+ + + +
+ {dirty && ( +
+ + +
+ )} +
+ ); +} + +function ModelSection({ + card, providers, canManage, busy, apply, +}: { + card: AgentCard; + providers: Integration[]; + canManage: boolean; + busy: boolean; + apply: (patch: AgentPatch) => Promise; +}) { + return ( +
+
+

Модель и провайдер

+ BYOK — ключ вашей организации из раздела «Интеграции» +
+
+ {card.model}} /> + {canManage ? ( + { + 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} + /> + ) : ( + + )} + {card.credentialMode === "BYOK" && ( + canManage ? ( + { + if (value) void apply({ providerIntegrationId: Number(value) }); + }} + options={[["", "Выберите интеграцию"], ...providers.map((item) => [String(item.id), item.name] as [string, string])]} + /> + ) : ( + + ) + )} +
+ {busy &&

Сохранение…

} +
+ ); +} + +function KnowledgeSection({ + card, openKnowledge, setRoute, +}: { + card: AgentCard; + openKnowledge: (knowledgeId: number) => void; + setRoute: (route: RouteKey) => void; +}) { + const total = card.knowledge.length + card.portalArticles.length; + return ( +
+
+

Знания

+ +
+ {total === 0 ? ( +

Знания не прикреплены — выберите их в разделе «Знания» или статьи на портале поддержки.

+ ) : ( + <> + {card.knowledge.length > 0 && ( +
    + {card.knowledge.map((item) => ( +
  • + + {!item.isEnabled && Выключено} +
  • + ))} +
+ )} + {card.portalArticles.length > 0 && ( + + )} + + )} +
+ ); +} + +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 ( +
+
+

Подключения

+ {canManage && ( + + )} +
+ {card.connections.length === 0 ? ( +

Подключений нет. Добавьте Telegram-бота или веб-виджет в разделе «Интеграции» и привяжите к агенту.

+ ) : ( +
+ {card.connections.map((connection) => ( +
+ +
+ {connection.name} + {providerLabel(connection.provider)} +
+ + {canManage && ( + + )} +
+ ))} +
+ )} + {canManage && available.length > 0 && ( +
+ {available.map((integration) => ( +
+ +
+ {integration.name} + Свободное подключение +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/agents/AgentsPage.tsx b/apps/internal-ui/src/features/agents/AgentsPage.tsx new file mode 100644 index 0000000..18a76f4 --- /dev/null +++ b/apps/internal-ui/src/features/agents/AgentsPage.tsx @@ -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(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + +
+ + setGroupId(value ? Number(value) : null)} + options={[["", "Без группы — диалоги видны всем"], ...groups.map((item) => [String(item.id), item.name] as [string, string])]} + /> + {error &&
{error}
} +
+ + +
+
+
+ ); +} + +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(null); + + async function toggleAi(card: AgentCard) { + setMenuId(null); + await setAgentAiActive(card.id, card.aiStatus !== "ACTIVE").catch(() => undefined); + reload(); + } + + return ( +
+ setCreating(true)}>Создать агента} + /> + {agents.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {agents.map((card) => { + const status = agentStatusLabel(card); + const menuItems = [ + { key: "open", label: }, + { type: "divider" as const }, + { + key: "toggle", + label: ( + + ), + }, + ]; + return ( + + + + + + + + + ); + })} + +
АГЕНТГРУППАСТАТУСПОДКЛЮЧЕНИЯОТКРЫТЫЕ ДИАЛОГИ +
+ + {card.groupName ?? Без группы} + {card.connections.length === 0 ? ( + — + ) : ( + + {card.connections.map((connection) => ( + + ))} + + )} + {card.counters.openConversations || —} + setMenuId(open ? card.id : null)} + trigger={["click"]} + overlayClassName="app-dropdown" + > + + +
+
+ )} + {creating && ( + setCreating(false)} + onCreated={(agentId) => { + setCreating(false); + reload(); + openAgent(agentId); + }} + /> + )} +
+ ); +} diff --git a/apps/internal-ui/src/features/agents/connection-status.ts b/apps/internal-ui/src/features/agents/connection-status.ts new file mode 100644 index 0000000..eee2f2f --- /dev/null +++ b/apps/internal-ui/src/features/agents/connection-status.ts @@ -0,0 +1,13 @@ +import type { StatusPillKey } from "../../shared/ui"; + +/** Состояние подключения приходит строкой — на экран идёт словарь, не энум. */ +const CONNECTION_STATUS: Record = { + OK: "healthy", + ERROR: "error", + PENDING: "pending", + UNCHECKED: "unchecked", +}; + +export function connectionStatus(status: string): StatusPillKey { + return CONNECTION_STATUS[status] ?? "unchecked"; +} diff --git a/apps/internal-ui/src/features/agents/model.ts b/apps/internal-ui/src/features/agents/model.ts new file mode 100644 index 0000000..2851118 --- /dev/null +++ b/apps/internal-ui/src/features/agents/model.ts @@ -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; + limits: Record; + 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; +}>; + +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 { + return api(`/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" }; +} diff --git a/apps/internal-ui/src/features/channels/styles-detail.css b/apps/internal-ui/src/features/agents/styles.css similarity index 50% rename from apps/internal-ui/src/features/channels/styles-detail.css rename to apps/internal-ui/src/features/agents/styles.css index 73fd884..49af9df 100644 --- a/apps/internal-ui/src/features/channels/styles-detail.css +++ b/apps/internal-ui/src/features/agents/styles.css @@ -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; +} diff --git a/apps/internal-ui/src/features/ai/AgentsKpiStrip.tsx b/apps/internal-ui/src/features/ai/AgentsKpiStrip.tsx deleted file mode 100644 index ed08216..0000000 --- a/apps/internal-ui/src/features/ai/AgentsKpiStrip.tsx +++ /dev/null @@ -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 ( -
- {cards.map((card) => ( -
-
{card.label}
-
{card.value}
-
- ))} -
- ); -} diff --git a/apps/internal-ui/src/features/ai/AgentsTable.tsx b/apps/internal-ui/src/features/ai/AgentsTable.tsx deleted file mode 100644 index 44c90b2..0000000 --- a/apps/internal-ui/src/features/ai/AgentsTable.tsx +++ /dev/null @@ -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 ( -
-
- - - - - - - - - - - - - - {agents.map((agent) => { - const accent = productAccent(agent.channel.code); - const menuItems = [ - { key: "open", label: }, - { type: "divider" as const }, - { - key: "toggle", - label: ( - - ), - }, - ]; - return ( - - - - - - - - - - - ); - })} - -
АГЕНТКАНАЛМОДЕЛЬЗНАНИЯСТАТУСДИАЛОГИ СЕЙЧАСКОНВЕРСИЯ -
-
- - - - {agent.channel.code} - -
-
{agent.channel.name}{agent.model}{agent.knowledge.length > 0 ? agent.knowledge.length : —}—— -
- setMenuId(open ? agent.id : null)} - trigger={["click"]} - overlayClassName="app-dropdown" - > - - -
-
-
-
- {agents.length} агента - Один агент на канал обработки -
-
- ); -} diff --git a/apps/internal-ui/src/features/ai/AiAgentsPage.tsx b/apps/internal-ui/src/features/ai/AiAgentsPage.tsx deleted file mode 100644 index 4c8d0eb..0000000 --- a/apps/internal-ui/src/features/ai/AiAgentsPage.tsx +++ /dev/null @@ -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(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 ( -
- openAgentCreate(null)}>Создать агента} /> - - -
- ); -} diff --git a/apps/internal-ui/src/features/ai/create/AiAgentCreatePage.tsx b/apps/internal-ui/src/features/ai/create/AiAgentCreatePage.tsx deleted file mode 100644 index 6f7fb9f..0000000 --- a/apps/internal-ui/src/features/ai/create/AiAgentCreatePage.tsx +++ /dev/null @@ -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([]); - const [channelsLoading, setChannelsLoading] = useState(true); - const [channelCode, setChannelCode] = useState(selectedProductCode); - const [credentialMode, setCredentialMode] = useState("CUSTOAI"); - const [providerIntegrationId, setProviderIntegrationId] = useState(null); - const [integrations, setIntegrations] = useState([]); - 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("/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
; - - return ( -
-
-
-

Создание AI-агента

-

Один агент на канал обработки. Агент создаётся для канала без агента; начнёт отвечать после запуска.

-
-
- - {credentialMode === "CUSTOAI" ? "CustoAI · platform credential" : "BYOK · интеграция канала"} -
- {error &&
Не удалось создать агента. Проверьте выбранный канал.
} - - -
- setRoute("aiAgents")} onSubmit={submit} /> -
- ); -} diff --git a/apps/internal-ui/src/features/ai/create/CreateAgentFooter.tsx b/apps/internal-ui/src/features/ai/create/CreateAgentFooter.tsx deleted file mode 100644 index 9ec97c6..0000000 --- a/apps/internal-ui/src/features/ai/create/CreateAgentFooter.tsx +++ /dev/null @@ -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 ( -
-
- {summary} -
- - -
-
-
- ); -} diff --git a/apps/internal-ui/src/features/ai/create/CreateAgentStepCard.tsx b/apps/internal-ui/src/features/ai/create/CreateAgentStepCard.tsx deleted file mode 100644 index 906132c..0000000 --- a/apps/internal-ui/src/features/ai/create/CreateAgentStepCard.tsx +++ /dev/null @@ -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 ( -
-
- {number} -

{title}

-
-

{text}

- {children} -
- ); -} diff --git a/apps/internal-ui/src/features/ai/create/ModelStep.tsx b/apps/internal-ui/src/features/ai/create/ModelStep.tsx deleted file mode 100644 index 923dc77..0000000 --- a/apps/internal-ui/src/features/ai/create/ModelStep.tsx +++ /dev/null @@ -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 ( - -
- setCredentialMode(value as CredentialMode)} - options={CREDENTIAL_MODE_OPTIONS} - /> - {credentialMode === "BYOK" && ( - setProviderIntegrationId(value ? Number(value) : null)} - options={integrationOptions} - /> - )} -
- {credentialMode === "CUSTOAI" - ? "CustoAI использует настроенную платформой модель и лимит организации." - : "Модель берётся из выбранной OpenRouter или Custom-интеграции."} -
-
-
- ); -} diff --git a/apps/internal-ui/src/features/ai/create/ProductChoiceStep.tsx b/apps/internal-ui/src/features/ai/create/ProductChoiceStep.tsx deleted file mode 100644 index 14b4687..0000000 --- a/apps/internal-ui/src/features/ai/create/ProductChoiceStep.tsx +++ /dev/null @@ -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 ( - -
- {channels.map((channel) => { - const selected = selectedChannelCode === channel.code; - const accent = productAccent(channel.code); - return ( - - ); - })} -
- {channelsWithAgents.length > 0 && ( -
- У каналов {channelsWithAgents.join(", ")} агент уже есть — для них доступен переход в существующую карточку, а не создание второго. -
- )} -
- ); -} diff --git a/apps/internal-ui/src/features/ai/create/footer.css b/apps/internal-ui/src/features/ai/create/footer.css deleted file mode 100644 index b680b96..0000000 --- a/apps/internal-ui/src/features/ai/create/footer.css +++ /dev/null @@ -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; -} diff --git a/apps/internal-ui/src/features/ai/create/form.css b/apps/internal-ui/src/features/ai/create/form.css deleted file mode 100644 index 944c5ac..0000000 --- a/apps/internal-ui/src/features/ai/create/form.css +++ /dev/null @@ -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; -} diff --git a/apps/internal-ui/src/features/ai/create/layout.css b/apps/internal-ui/src/features/ai/create/layout.css deleted file mode 100644 index 3c74bd2..0000000 --- a/apps/internal-ui/src/features/ai/create/layout.css +++ /dev/null @@ -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; -} diff --git a/apps/internal-ui/src/features/ai/create/model.ts b/apps/internal-ui/src/features/ai/create/model.ts deleted file mode 100644 index 0ee3f63..0000000 --- a/apps/internal-ui/src/features/ai/create/model.ts +++ /dev/null @@ -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(); -} diff --git a/apps/internal-ui/src/features/ai/create/product-choice.css b/apps/internal-ui/src/features/ai/create/product-choice.css deleted file mode 100644 index 62e2d84..0000000 --- a/apps/internal-ui/src/features/ai/create/product-choice.css +++ /dev/null @@ -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; -} diff --git a/apps/internal-ui/src/features/ai/create/styles.css b/apps/internal-ui/src/features/ai/create/styles.css deleted file mode 100644 index b36ed19..0000000 --- a/apps/internal-ui/src/features/ai/create/styles.css +++ /dev/null @@ -1,4 +0,0 @@ -@import "./layout.css"; -@import "./product-choice.css"; -@import "./form.css"; -@import "./footer.css"; diff --git a/apps/internal-ui/src/features/ai/detail/AgentEditForm.tsx b/apps/internal-ui/src/features/ai/detail/AgentEditForm.tsx deleted file mode 100644 index 6984e95..0000000 --- a/apps/internal-ui/src/features/ai/detail/AgentEditForm.tsx +++ /dev/null @@ -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 { - 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(agent.credentialMode); - const [providerIntegrationId, setProviderIntegrationId] = useState(agent.providerIntegrationId); - const [integrations, setIntegrations] = useState([]); - const [tools, setTools] = useState(Array.isArray(agent.allowedTools) ? (agent.allowedTools as string[]) : []); - const [budget, setBudget] = useState(budgetFromCents(agent.limits as Record)); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(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 = 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 ( - -
- - setCredentialMode(value as CredentialMode)} - options={CREDENTIAL_MODE_OPTIONS} - /> - {credentialMode === "BYOK" && ( - setProviderIntegrationId(value ? Number(value) : null)} - options={[["", "Выберите интеграцию"], ...integrations.map((item) => [String(item.id), item.name] as [string, string])]} - /> - )} -
- Инструменты -
- {TOOLS.map(([code, label]) => ( - - ))} -
-
-
- Лимиты -
- -
-
- {error &&
{error}
} -
- - -
-
-
- ); -} diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx deleted file mode 100644 index 78521b6..0000000 --- a/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx +++ /dev/null @@ -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 ( -
-
- -
-
-

{agent.name}

- -
-
-
Канал
-
Модель{agent.model}
-
Знания{agent.knowledge.length > 0 ? `${agent.knowledge.length} выбрано` : "—"}
-
Лимиты{dailyBudget(agent.limits)}
-
-
-
-
- ); -} diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx deleted file mode 100644 index 3dba52f..0000000 --- a/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx +++ /dev/null @@ -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("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
; - if (error || !agent) return
; - - return ( -
- openChannel(agent.channel.id)} /> - - {tab === "overview" && setEditOpen(true)} />} - {tab === "instructions" && } - {tab === "knowledge" && } - {tab === "metrics" && } - {editOpen && setEditOpen(false)} onSaved={() => { setEditOpen(false); reload(); }} />} -
- ); -} diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx deleted file mode 100644 index fdedc3e..0000000 --- a/apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx +++ /dev/null @@ -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(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 ( -
- {FIELDS.map((field, index) => ( -
-
- {index + 1} -

{field.title}

- {field.hint} -
-