diff --git a/apps/backend/chatballs/ai/agent_card_urls.py b/apps/backend/chatballs/ai/agent_card_urls.py index 6af5b5b..478bd59 100644 --- a/apps/backend/chatballs/ai/agent_card_urls.py +++ b/apps/backend/chatballs/ai/agent_card_urls.py @@ -6,6 +6,7 @@ from chatballs.ai import agent_card_views as views urlpatterns = [ path("", views.AgentCardListView.as_view(), name="agent-card-list"), + path("directory/", views.AgentDirectoryView.as_view(), name="agent-directory"), path("/", views.AgentCardDetailView.as_view(), name="agent-card-detail"), path( "/activate/", diff --git a/apps/backend/chatballs/ai/agent_card_views.py b/apps/backend/chatballs/ai/agent_card_views.py index a585cc4..eaa0847 100644 --- a/apps/backend/chatballs/ai/agent_card_views.py +++ b/apps/backend/chatballs/ai/agent_card_views.py @@ -3,7 +3,7 @@ from __future__ import annotations from django.core.exceptions import ValidationError -from django.db.models import Q +from django.db.models import Count, Q from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -57,6 +57,41 @@ def _audit(request: Request, action: str, channel: Channel, **payload: object) - ) +# Справочник агентов для выпадающих выборов: фильтр библиотеки знаний, привязка +# материалов к агенту. Только идентификаторы и имя — карточки целиком этим +# экранам не нужны. Потолок защищает выбор от организации на тысячу агентов: +# при его достижении выбору нужен серверный поиск, а не молчаливая обрезка. +AGENT_DIRECTORY_LIMIT = 200 + + +class AgentDirectoryView(APIView): + permission_classes = [HasCapability] + required_capabilities = {"GET": "ai.view"} + + def get(self, request: Request) -> Response: + cards = agent_cards_for_context(request.tenant_context).annotate( + knowledge_count=Count("ai_agent__knowledge_items", distinct=True) + ) + query = request.query_params.get("q", "").strip() + if query: + cards = cards.filter(Q(name__icontains=query) | Q(code__icontains=query)) + items = [ + { + "id": channel.id, + "aiAgentId": channel.ai_agent.id if hasattr(channel, "ai_agent") else None, + "name": channel.name, + "groupName": channel.group.name if channel.group_id else None, + "aiStatus": channel.ai_agent.status if hasattr(channel, "ai_agent") else None, + "isActive": channel.is_active, + "knowledgeCount": channel.knowledge_count, + } + for channel in cards.order_by("name")[: AGENT_DIRECTORY_LIMIT + 1] + ] + return Response( + {"items": items[:AGENT_DIRECTORY_LIMIT], "hasMore": len(items) > AGENT_DIRECTORY_LIMIT} + ) + + class AgentCardListView(APIView): permission_classes = [HasCapability] required_capabilities = {"GET": "ai.view", "POST": "ai.manage"} diff --git a/apps/backend/chatballs/ai/knowledge_api_inputs.py b/apps/backend/chatballs/ai/knowledge_api_inputs.py index 2287d09..0c451f9 100644 --- a/apps/backend/chatballs/ai/knowledge_api_inputs.py +++ b/apps/backend/chatballs/ai/knowledge_api_inputs.py @@ -60,10 +60,14 @@ def knowledge_filters(request: Request) -> KnowledgeFilters: is_enabled = False else: raise ValidationError({"isEnabled": "Boolean required"}) + agents = tuple( + int(value) for value in request.query_params.getlist("agent") if value.isdigit() + ) return KnowledgeFilters( category_id=_optional_query_id( request.query_params.get("category"), "category" ), is_enabled=is_enabled, search=request.query_params.get("search", ""), + agent_ids=agents, ) diff --git a/apps/backend/chatballs/ai/selectors.py b/apps/backend/chatballs/ai/selectors.py index 926a131..05224d7 100644 --- a/apps/backend/chatballs/ai/selectors.py +++ b/apps/backend/chatballs/ai/selectors.py @@ -1,11 +1,12 @@ from collections.abc import Sequence from dataclasses import dataclass -from django.db.models import Count, Prefetch, Q, QuerySet +from django.db.models import Count, IntegerField, OuterRef, Prefetch, Q, QuerySet, Subquery, Value +from django.db.models.functions import Coalesce from chatballs.ai.agent_knowledge import knowledge_available_to_channel from chatballs.ai.knowledge_policy import readable_knowledge, writable_knowledge -from chatballs.ai.models import AIAgent, Knowledge, KnowledgeCategory +from chatballs.ai.models import AIAgent, Knowledge, KnowledgeCategory, KnowledgeFragment from chatballs.channels.models import Channel from chatballs.identity.models import AuditEvent from chatballs.identity.policy import has_capability_any_scope @@ -81,12 +82,32 @@ def _knowledge_base(context: TenantContext) -> QuerySet[Knowledge]: def _with_knowledge_relations(queryset: QuerySet[Knowledge]) -> QuerySet[Knowledge]: + # Счётчики — подзапросами, а не join-агрегатами: фильтр по агенту идёт по + # той же связи и урезал бы «прикреплено к N агентам» до отфильтрованных. + agents_count = Subquery( + AIAgent.objects.filter(knowledge_items=OuterRef("pk")) + .order_by() + .values("knowledge_items") + .annotate(total=Count("pk", distinct=True)) + .values("total"), + output_field=IntegerField(), + ) + fragments_count = Subquery( + KnowledgeFragment.objects.filter(knowledge_id=OuterRef("pk")) + .order_by() + .values("knowledge_id") + .annotate(total=Count("pk")) + .values("total"), + output_field=IntegerField(), + ) return ( queryset.select_related("category") - .prefetch_related("attachments") + # agents — чтобы диалог прикрепления знал, где материал уже стоит, не + # выгружая ради этого все карточки агентов организации. + .prefetch_related("attachments", "agents") .annotate( - agents_count=Count("agents", distinct=True), - fragments_count=Count("fragments", distinct=True), + agents_count=Coalesce(agents_count, Value(0)), + fragments_count=Coalesce(fragments_count, Value(0)), ) .order_by("title") ) @@ -173,13 +194,41 @@ class KnowledgeFilters: category_id: int | None = None is_enabled: bool | None = None search: str = "" + # Идентификаторы AIAgent из фильтра «Агент» (кадр KB1). + agent_ids: tuple[int, ...] = () + + +def descendant_category_ids(organization_id: int, root_id: int) -> set[int]: + """Категория и все вложенные: фильтр библиотеки охватывает ветку целиком.""" + children: dict[int | None, list[int]] = {} + for category_id, parent_id in KnowledgeCategory.objects.filter( + organization_id=organization_id + ).values_list("id", "parent_id"): + children.setdefault(parent_id, []).append(category_id) + found: set[int] = set() + pending = [root_id] + while pending: + current = pending.pop() + if current in found: + continue + found.add(current) + pending.extend(children.get(current, [])) + return found def apply_knowledge_filters( - queryset: QuerySet[Knowledge], filters: KnowledgeFilters + queryset: QuerySet[Knowledge], filters: KnowledgeFilters, *, organization_id: int ) -> QuerySet[Knowledge]: + """Фильтры библиотеки знаний целиком на сервере: ветка категорий, агент, + состояние и поиск. Раньше ветку и агента отбирал браузер по всему набору.""" if filters.category_id is not None: - queryset = queryset.filter(category_id=filters.category_id) + queryset = queryset.filter( + category_id__in=descendant_category_ids( + organization_id=organization_id, root_id=filters.category_id + ) + ) + if filters.agent_ids: + queryset = queryset.filter(agents__id__in=filters.agent_ids) if filters.is_enabled is not None: queryset = queryset.filter(is_enabled=filters.is_enabled) search = filters.search.strip() diff --git a/apps/backend/chatballs/ai/serializers.py b/apps/backend/chatballs/ai/serializers.py index ed7b029..d20809a 100644 --- a/apps/backend/chatballs/ai/serializers.py +++ b/apps/backend/chatballs/ai/serializers.py @@ -44,6 +44,9 @@ def knowledge_payload(knowledge: Knowledge, *, include_content: bool = True) -> "isEnabled": knowledge.is_enabled, "attachments": [attachment_payload(attachment) for attachment in knowledge.attachments.all()], "agentsCount": getattr(knowledge, "agents_count", None), + # Идентификаторы агентов, к которым материал прикреплён: по ним диалог + # показывает, что будет пропущено, ещё до применения. + "agentIds": [agent.id for agent in knowledge.agents.all()], "fragmentsCount": getattr(knowledge, "fragments_count", None), "createdAt": knowledge.created_at.isoformat(), "updatedAt": knowledge.updated_at.isoformat(), diff --git a/apps/backend/chatballs/ai/test_knowledge_list.py b/apps/backend/chatballs/ai/test_knowledge_list.py new file mode 100644 index 0000000..eb2ce99 --- /dev/null +++ b/apps/backend/chatballs/ai/test_knowledge_list.py @@ -0,0 +1,141 @@ +"""Библиотека знаний: страница и фильтры на сервере. + +Раньше список отдавался целиком, а ветку категорий и фильтр по агенту отбирал +браузер — значит, фильтр видел ровно то, что успело загрузиться. +""" + +from django.test import TestCase + +from chatballs.ai.knowledge_categories import create_category +from chatballs.ai.knowledge_services import KnowledgeInput, create_knowledge +from chatballs.ai.models import AIAgent, AIAgentStatus +from chatballs.channels.models import Channel +from chatballs.identity.bootstrap import bootstrap_owner +from chatballs.testing import TenantAPIClient, system_tenant_context + +PAGE_SIZE = 25 + + +class KnowledgeListPaginationTests(TestCase): + def setUp(self) -> None: + result = bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = result.organization + self.context = system_tenant_context(self.organization) + self.root = create_category(context=self.context, name="Тарифы") + self.child = create_category(context=self.context, name="Скидки", parent=self.root) + self.other = create_category(context=self.context, name="Доставка") + self.items = [ + self._knowledge( + f"Материал {index:02d}", + category=self.child if index < 4 else self.other, + enabled=index != 0, + ) + for index in range(30) + ] + channel = Channel.objects.create( + organization=self.organization, code="agent-a", name="Агент A" + ) + self.agent = AIAgent.objects.create( + channel=channel, name="Агент A", status=AIAgentStatus.ACTIVE + ) + self.attached = self.items[:3] + self.agent.knowledge_items.set(self.attached) + self.client = TenantAPIClient() + self.client.force_authenticate(result.owner) + + def _knowledge(self, title: str, *, category, enabled: bool = True): + return create_knowledge( + context=self.context, + data=KnowledgeInput( + title=title, + description="", + content=f"{title} — текст", + is_enabled=enabled, + category_id=category.id, + ), + ) + + def _page(self, query: str = "") -> dict: + response = self.client.get(f"/api/v1/ai/knowledge/{query}") + self.assertEqual(response.status_code, 200) + return response.json() + + def test_first_page_is_bounded(self) -> None: + page = self._page() + self.assertEqual(len(page["items"]), PAGE_SIZE) + self.assertEqual(page["total"], 30) + self.assertEqual(page["pageCount"], 2) + + def test_pages_cover_the_library_without_repeats(self) -> None: + seen = [item["id"] for item in self._page()["items"]] + seen += [item["id"] for item in self._page("?page=2")["items"]] + self.assertEqual(len(set(seen)), 30) + + def test_category_filter_covers_the_branch(self) -> None: + # Выбран родитель — материалы вложенного раздела тоже попадают. + self.assertEqual(self._page(f"?category={self.root.id}")["total"], 4) + + def test_agent_filter_is_applied_before_the_page(self) -> None: + page = self._page(f"?agent={self.agent.id}") + self.assertEqual(page["total"], len(self.attached)) + + def test_agent_filter_does_not_shrink_counters(self) -> None: + # Счётчик «прикреплено к N агентам» считается подзапросом: фильтр по + # агенту идёт по той же связи и не должен его урезать. + item = self._page(f"?agent={self.agent.id}")["items"][0] + self.assertEqual(item["agentsCount"], 1) + self.assertEqual(item["agentIds"], [self.agent.id]) + + def test_enabled_and_search_filters_are_applied_before_the_page(self) -> None: + self.assertEqual(self._page("?isEnabled=false")["total"], 1) + self.assertEqual(self._page("?search=Материал 07")["total"], 1) + + def test_detail_lists_attached_agents(self) -> None: + response = self.client.get(f"/api/v1/ai/knowledge/{self.attached[0].id}/") + self.assertEqual(response.status_code, 200) + agents = response.json()["knowledge"]["agents"] + self.assertEqual([agent["id"] for agent in agents], [self.agent.id]) + self.assertEqual(agents[0]["aiStatus"], AIAgentStatus.ACTIVE) + + +class AgentDirectoryTests(TestCase): + """Справочник агентов для выпадающих выборов: имена и счётчики, не карточки.""" + + def setUp(self) -> None: + result = bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = result.organization + self.context = system_tenant_context(self.organization) + category = create_category(context=self.context, name="Общее") + knowledge = create_knowledge( + context=self.context, + data=KnowledgeInput( + title="Возврат", + description="", + content="Условия возврата", + is_enabled=True, + category_id=category.id, + ), + ) + channel = Channel.objects.create( + organization=self.organization, code="agent-b", name="Агент B" + ) + agent = AIAgent.objects.create( + channel=channel, name="Агент B", status=AIAgentStatus.ACTIVE + ) + agent.knowledge_items.add(knowledge) + self.agent = agent + self.client = TenantAPIClient() + self.client.force_authenticate(result.owner) + + def test_directory_returns_compact_rows_with_counts(self) -> None: + response = self.client.get("/api/v1/agents/directory/") + self.assertEqual(response.status_code, 200) + payload = response.json() + row = next(item for item in payload["items"] if item["aiAgentId"] == self.agent.id) + self.assertEqual(row["name"], "Агент B") + self.assertEqual(row["knowledgeCount"], 1) + self.assertFalse(payload["hasMore"]) + + def test_directory_search_narrows_rows(self) -> None: + payload = self.client.get("/api/v1/agents/directory/?q=Агент B").json() + self.assertEqual([item["name"] for item in payload["items"]], ["Агент B"]) diff --git a/apps/backend/chatballs/ai/test_knowledge_selectors.py b/apps/backend/chatballs/ai/test_knowledge_selectors.py index c0cbfb5..b5ec246 100644 --- a/apps/backend/chatballs/ai/test_knowledge_selectors.py +++ b/apps/backend/chatballs/ai/test_knowledge_selectors.py @@ -19,19 +19,19 @@ class KnowledgeSelectorTests(KnowledgePolicyTestBase): base = knowledge_for_employee(context=self.admin_context) search_ids = set( apply_knowledge_filters( - base, KnowledgeFilters(search="support") + base, KnowledgeFilters(search="support"), organization_id=self.organization.id ).values_list("id", flat=True) ) self.assertEqual(search_ids, {self.support_only.id}) disabled_ids = set( apply_knowledge_filters( - base, KnowledgeFilters(is_enabled=False) + base, KnowledgeFilters(is_enabled=False), organization_id=self.organization.id ).values_list("id", flat=True) ) self.assertEqual(disabled_ids, {self.disabled.id}) category_ids = set( apply_knowledge_filters( - base, KnowledgeFilters(category_id=self.products.id) + base, KnowledgeFilters(category_id=self.products.id), organization_id=self.organization.id ).values_list("id", flat=True) ) self.assertEqual( @@ -45,7 +45,7 @@ class KnowledgeSelectorTests(KnowledgePolicyTestBase): ) employee_base = knowledge_for_employee(context=self.employee_context) self.assertEqual( - list(apply_knowledge_filters(employee_base, KnowledgeFilters())), [] + list(apply_knowledge_filters(employee_base, KnowledgeFilters(), organization_id=self.organization.id)), [] ) def test_category_counts_are_computed_after_authorization(self) -> None: diff --git a/apps/backend/chatballs/ai/views.py b/apps/backend/chatballs/ai/views.py index b67c8a6..15adaf3 100644 --- a/apps/backend/chatballs/ai/views.py +++ b/apps/backend/chatballs/ai/views.py @@ -5,6 +5,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from chatballs.ai.api_errors import validation_error_response +from chatballs.api.pagination import page_payload, paginate from chatballs.ai.knowledge_api_inputs import knowledge_filters, knowledge_input from chatballs.ai.knowledge_import import import_knowledge_documents from chatballs.ai.knowledge_policy import ( @@ -20,6 +21,9 @@ from chatballs.ai.knowledge_services import ( ) from chatballs.ai.indexing import reindex_knowledge from chatballs.ai.models import Knowledge + +# Библиотека знаний плотная — своя страница (кадр KB1). +KNOWLEDGE_PAGE_SIZE = 25 from chatballs.ai.selectors import ( apply_knowledge_filters, knowledge_editors, @@ -73,21 +77,30 @@ class KnowledgeListCreateView(_KnowledgeBaseView): filters = knowledge_filters(request) except ValidationError as error: return _validation_error(error) - items = list(apply_knowledge_filters(knowledge_for_context(request.tenant_context), filters)) + page = paginate( + apply_knowledge_filters( + knowledge_for_context(request.tenant_context), + filters, + organization_id=self._org(request).id, + ), + request.query_params, + default_size=KNOWLEDGE_PAGE_SIZE, + ) # Автор последней правки нужен в списке: он стоит под датой в колонке # «Обновлено» (кадр KB1). Один запрос на всю страницу, не N+1. editors = knowledge_editors( organization_id=self._org(request).id, - knowledge_ids=[item.id for item in items], + knowledge_ids=[item.id for item in page.items], ) - payloads = [] - for item in items: - payload = knowledge_payload(item, include_content=False) + + def payload(item): + data = knowledge_payload(item, include_content=False) editor = editors.get(item.id) if editor: - payload["updatedBy"] = editor - payloads.append(payload) - return Response({"items": payloads}) + data["updatedBy"] = editor + return data + + return Response(page_payload(page, payload)) def post(self, request: Request) -> Response: try: @@ -130,6 +143,19 @@ class KnowledgeDetailView(_KnowledgeBaseView): ).get(knowledge.id) if editor: payload["updatedBy"] = editor + # К каким агентам знание прикреплено — считает сервер: карточке больше + # не нужен весь список агентов организации, чтобы это выяснить. + payload["agents"] = [ + { + "id": agent.id, + "name": agent.channel.name, + "groupName": agent.channel.group.name if agent.channel.group_id else None, + "aiStatus": agent.status, + } + for agent in knowledge.agents.select_related("channel", "channel__group").order_by( + "channel__name" + ) + ] return Response({"knowledge": payload}) def patch(self, request: Request, knowledge_id: int) -> Response: diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 9c3a6b7..b15daa4 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -6,7 +6,7 @@ import { buildTheme } from "@chatballs/ui"; import { applyAppearance, DEFAULT_ACCENT, resolvedDark } from "./shared/appearance"; import { api, setActiveOrganization } from "./api/client"; -import { fetchAllAgents } from "./features/agents/model"; +import { fetchAgentDirectory } from "./features/agents/model"; import { canAccess, defaultRoute, isManager } from "./auth/access"; import { activateOrganization, clearOrganizationPreference } from "./auth/session"; import { AuthChangePassword, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens"; @@ -14,7 +14,7 @@ import { Shell } from "./layout/Shell"; import { pathFromRoute, routeFromPath } from "./router"; import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui"; import { useRouteNavigation } from "./useRouteNavigation"; -import type { AgentCard } from "./features/agents/model"; +import type { AgentRef } from "./features/agents/model"; import type { AppData, AuthChallenge, AuthenticatedUser, EmployeeGroup, SessionUser } from "./types"; export function App() { @@ -61,9 +61,9 @@ export function App() { const groups = manager ? await api<{ items: EmployeeGroup[] }>("/api/v1/company/groups/") : { items: [] }; - let agents: AgentCard[] = []; + let agents: AgentRef[] = []; if (user && canAccess(user, "agents")) { - agents = (await fetchAllAgents()).items; + agents = (await fetchAgentDirectory()).items; } setData({ groups: groups.items, agents }); } catch { diff --git a/apps/internal-ui/src/features/agents/AgentsPage.tsx b/apps/internal-ui/src/features/agents/AgentsPage.tsx index 2e85dbc..10725d7 100644 --- a/apps/internal-ui/src/features/agents/AgentsPage.tsx +++ b/apps/internal-ui/src/features/agents/AgentsPage.tsx @@ -180,7 +180,6 @@ export function AgentsPage({ ))} {agents.pageCount > 1 && ( >(`/api/v1/agents/?${params.toString()}`); } -/** Полный набор карточек для экранов, которым нужны связи агентов со знаниями - * (библиотека знаний считает по ним прикрепления). Ограничен одной страницей - * предельного размера: этим экранам нужен серверный контракт со счётчиками, - * пока он не сделан — держим потолок явным, а не молчаливым. */ -export function fetchAllAgents(): Promise<{ items: AgentCard[] }> { - return api>("/api/v1/agents/?pageSize=100"); +/** Справочник агентов для выпадающих выборов: фильтр библиотеки знаний, + * привязка материалов, выбор агента в диалогах. Карточки целиком этим экранам + * не нужны — им хватает имени и состояния AI. */ +export type AgentRef = { + id: number; + aiAgentId: number | null; + name: string; + groupName: string | null; + aiStatus: string | null; + isActive: boolean; + /** Сколько материалов прикреплено к агенту — считает сервер. */ + knowledgeCount: number; +}; + +export function fetchAgentDirectory(query = ""): Promise<{ items: AgentRef[]; hasMore: boolean }> { + const suffix = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ""; + return api<{ items: AgentRef[]; hasMore: boolean }>(`/api/v1/agents/directory/${suffix}`); } export function fetchAgent(agentId: number): Promise<{ agent: AgentCard }> { diff --git a/apps/internal-ui/src/features/ai/agentOptions.ts b/apps/internal-ui/src/features/ai/agentOptions.ts index 2661fbb..c6f184d 100644 --- a/apps/internal-ui/src/features/ai/agentOptions.ts +++ b/apps/internal-ui/src/features/ai/agentOptions.ts @@ -1,13 +1,16 @@ import type { AgentLinkOption } from "../../shared/content-library/AgentLinkDialog"; -import type { AgentCard } from "../agents/model"; +import type { AgentRef } from "../agents/model"; /** Агенты для массового прикрепления материалов (библиотека знаний общая, * ADR-CHATBALLS-0041 §8). Bulk-эндпоинты знаний адресуют AIAgent, поэтому id — * aiAgentId карточки. */ -export function agentLinkOptions(agents: AgentCard[]): AgentLinkOption[] { - return agents.map((agent) => ({ - id: agent.aiAgentId, - name: agent.name, - groupName: agent.groupName, - })); +export function agentLinkOptions(agents: AgentRef[]): AgentLinkOption[] { + // У канала без AI-агента прикреплять материалы не к чему. + return agents + .filter((agent): agent is AgentRef & { aiAgentId: number } => agent.aiAgentId !== null) + .map((agent) => ({ + id: agent.aiAgentId, + name: agent.name, + groupName: agent.groupName, + })); } diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeAgentDialog.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeAgentDialog.tsx index 2267081..6c68129 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeAgentDialog.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeAgentDialog.tsx @@ -5,7 +5,7 @@ import { Icon } from "../../../shared/icons"; import { Segmented } from "../../../shared/ui"; import { Button } from "../../../shared/ui-controls"; import { pluralRu } from "../../../shared/utils"; -import type { AgentCard } from "../../agents/model"; +import type { AgentRef } from "../../agents/model"; import type { KnowledgeItem } from "./types"; // Прикрепление знаний к агенту (дизайн-базлайн v2, кадр KB3). Один агент за @@ -20,24 +20,27 @@ const KNOWLEDGE_FORMS: [string, string, string] = ["знание", "знания type Skip = { item: KnowledgeItem; reason: string }; -function skipsFor(items: KnowledgeItem[], agent: AgentCard | null, mode: Mode): Skip[] { - if (!agent) return []; - const attached = new Set(agent.knowledge.map((item) => item.id)); +// Где материал уже стоит, знает сам материал: список agentIds приходит вместе +// со страницей библиотеки, а не собирается по карточкам всех агентов. +function skipsFor(items: KnowledgeItem[], agent: AgentRef | null, mode: Mode): Skip[] { + if (!agent || agent.aiAgentId === null) return []; + const agentId = agent.aiAgentId; const skips: Skip[] = []; items.forEach((item) => { + const attached = (item.agentIds ?? []).includes(agentId); if (mode === "attach") { - if (attached.has(item.id)) skips.push({ item, reason: "уже прикреплён к этому агенту" }); + if (attached) skips.push({ item, reason: "уже прикреплён к этому агенту" }); else if (!item.isEnabled) skips.push({ item, reason: "выключен в ответах" }); - } else if (!attached.has(item.id)) { + } else if (!attached) { skips.push({ item, reason: "не прикреплён к этому агенту" }); } }); return skips; } -function agentMeta(agent: AgentCard): string { +function agentMeta(agent: AgentRef): string { if (agent.aiStatus !== "ACTIVE") return "Без AI — знания не используются"; - return `AI отвечает · ${pluralRu(agent.knowledge.length, KNOWLEDGE_FORMS)} прикреплено`; + return `AI отвечает · ${pluralRu(agent.knowledgeCount, KNOWLEDGE_FORMS)} прикреплено`; } export function KnowledgeAgentDialog({ @@ -47,7 +50,7 @@ export function KnowledgeAgentDialog({ onCancel, onSubmit, }: { - agents: AgentCard[]; + agents: AgentRef[]; busy: boolean; items: KnowledgeItem[]; onCancel: () => void; diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeCardPage.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeCardPage.tsx index 051cce6..0df8ae7 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeCardPage.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeCardPage.tsx @@ -7,7 +7,7 @@ import { EmptyState, LoadingState } from "../../../shared/ui"; import { Button, CopyButton } from "../../../shared/ui-controls"; import { pluralRu, shortDateTime } from "../../../shared/utils"; import type { RouteKey } from "../../../types"; -import type { AgentCard } from "../../agents/model"; +import type { AgentRef } from "../../agents/model"; import { MarkdownContent } from "../../help-center/MarkdownContent"; import { KnowledgeAgentDialog } from "./KnowledgeAgentDialog"; import { KnowledgeCategoryDialog } from "./KnowledgeCategoryDialog"; @@ -40,7 +40,7 @@ export function KnowledgeCardPage({ reloadAgents, setRoute, }: { - agents: AgentCard[]; + agents: AgentRef[]; canManage: boolean; knowledgeId: number | null; openKnowledgeEditor: (knowledgeId: number | null) => void; @@ -93,7 +93,7 @@ export function KnowledgeCardPage({ const path = knowledgeCategoryPath(catalog.categories, item.category.id); const crumbs = path ? path.split(" / ") : [item.category.name]; - const attached = agentStates(agents, item.id); + const attached = agentStates(item.agents ?? []); const fragments = item.fragmentsCount ?? 0; const characters = (item.content ?? "").length; diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx index 2c41218..277ed45 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeEditorPage.tsx @@ -9,7 +9,7 @@ import { import { EmptyState, LoadingState, Segmented } from "../../../shared/ui"; import { Button } from "../../../shared/ui-controls"; import type { RouteKey } from "../../../types"; -import type { AgentCard } from "../../agents/model"; +import type { AgentRef } from "../../agents/model"; import { MarkdownContent } from "../../help-center/MarkdownContent"; import { agentStates, @@ -77,7 +77,7 @@ export function KnowledgeEditorPage({ reloadAgents, setRoute, }: { - agents: AgentCard[]; + agents: AgentRef[]; canManage: boolean; knowledgeId: number | null; openKnowledge: (knowledgeId: number) => void; @@ -158,10 +158,7 @@ export function KnowledgeEditorPage({ const category = catalog.categories.find((item) => item.id === Number(categoryId)) ?? null; const categoryPath = category ? knowledgeCategoryPath(catalog.categories, category.id) : ""; - const attached = useMemo( - () => (loaded ? agentStates(agents, loaded.id) : []), - [agents, loaded], - ); + const attached = useMemo(() => agentStates(loaded?.agents ?? []), [loaded]); const dirty = baseline === null ? Boolean(title.trim() || content.trim()) : baseline.title !== title diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeLibraryPage.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeLibraryPage.tsx index 0b95ad0..8f6bb04 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeLibraryPage.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeLibraryPage.tsx @@ -2,19 +2,18 @@ import { Dropdown } from "antd"; import { useMemo, useState } from "react"; import { hasCapability } from "../../../auth/access"; +import { Pagination } from "../../../shared/Pagination"; import { DecisionDialog } from "../../../shared/DecisionDialog"; import { Icon } from "../../../shared/icons"; import { LoadingState } from "../../../shared/ui"; import { Button, FilterDropdown, SearchInput } from "../../../shared/ui-controls"; import { pluralRu } from "../../../shared/utils"; import type { RouteKey, SessionUser } from "../../../types"; -import type { AgentCard } from "../../agents/model"; +import type { AgentRef } from "../../agents/model"; import { KnowledgeAgentDialog } from "./KnowledgeAgentDialog"; import { KnowledgeBulkBar } from "./KnowledgeBulkBar"; import { KnowledgeCategoryDialog } from "./KnowledgeCategoryDialog"; -import { KnowledgeTableFooter } from "./KnowledgeTableFooter"; import { - agentsOfKnowledge, answerStateLabel, categoryBranch, categoryRows, @@ -36,7 +35,6 @@ import { useKnowledgeLibrary } from "./useKnowledgeLibrary"; // состоянием индекса, дерево категорий 260px, таблица материалов. Панель // массовых операций живёт, пока живо выделение. -const PAGE_SIZE = 25; const KNOWLEDGE_FORMS: [string, string, string] = ["знание", "знания", "знаний"]; const ANSWER_FILTER = [ @@ -51,7 +49,7 @@ export function KnowledgeLibraryPage({ setRoute, user, }: { - agents: AgentCard[]; + agents: AgentRef[]; openKnowledge: (knowledgeId: number) => void; openKnowledgeEditor: (knowledgeId: number | null) => void; setRoute: (route: RouteKey) => void; @@ -59,10 +57,8 @@ export function KnowledgeLibraryPage({ }) { const library = useKnowledgeLibrary(); const canManage = hasCapability(user, "ai.manage"); - const [page, setPage] = useState(1); const [answerOpen, setAnswerOpen] = useState(false); const [agentOpen, setAgentOpen] = useState(false); - const [agentFilter, setAgentFilter] = useState([]); const [attachOpen, setAttachOpen] = useState(false); const [movingOpen, setMovingOpen] = useState(false); const [deleting, setDeleting] = useState<{ ids: number[]; text: string } | null>(null); @@ -72,38 +68,19 @@ export function KnowledgeLibraryPage({ const rows = useMemo(() => categoryRows(library.categories), [library.categories]); const selectedCategory = library.filters.category; - // Фильтр «Агент» и охват вложенных категорий считаются на клиенте: API - // знаний фильтрует по одной категории и по состоянию в ответах. - const filtered = useMemo(() => { - const branch = selectedCategory === undefined - ? null - : categoryBranch(library.categories, selectedCategory); - const attached = agentFilter.length === 0 - ? null - : new Set(agents - .filter((agent) => agentFilter.includes(agent.aiAgentId)) - .flatMap((agent) => agent.knowledge.map((item) => item.id))); - return library.items.filter((item) => ( - (branch === null || branch.has(item.category.id)) - && (attached === null || attached.has(item.id)) - )); - }, [agentFilter, agents, library.categories, library.items, selectedCategory]); - - const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); - const currentPage = Math.min(page, pageCount); - const visible = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); const selected = [...library.selectedIds]; const selectedItems = library.items.filter((item) => library.selectedIds.has(item.id)); - const allVisibleChecked = visible.length > 0 && visible.every((item) => library.selectedIds.has(item.id)); + const allVisibleChecked = library.items.length > 0 && library.items.every((item) => library.selectedIds.has(item.id)); const activeCategory = selectedCategory === undefined ? null : library.categories.find((category) => category.id === selectedCategory) ?? null; + const agentFilter = library.filters.agents; const filtersActive = Boolean(library.filters.search.trim()) || library.filters.isEnabled !== undefined || agentFilter.length > 0; const scopeNote = selected.length > 0 - ? `выбрано ${selected.length} из ${filtered.length}` + ? `выбрано ${selected.length} из ${library.total}` : activeCategory ? `категория «${activeCategory.name}»` : "показаны все категории"; @@ -206,7 +183,7 @@ export function KnowledgeLibraryPage({ className={`knowledge-section-row${selectedCategory === undefined ? " is-active" : ""}`} style={{ paddingLeft: 9 }} type="button" - onClick={() => { library.updateFilter("category", undefined); setPage(1); }} + onClick={() => library.updateFilter("category", undefined)} > Все знания @@ -218,7 +195,7 @@ export function KnowledgeLibraryPage({ key={category.id} style={{ paddingLeft: 9 + depth * 16 }} type="button" - onClick={() => { library.updateFilter("category", category.id); setPage(1); }} + onClick={() => library.updateFilter("category", category.id)} > {category.name} {count} @@ -241,7 +218,7 @@ export function KnowledgeLibraryPage({ className="knowledge-library-search" placeholder="Поиск по заголовку и описанию" value={library.filters.search} - onChange={(value) => { library.updateFilter("search", value); setPage(1); }} + onChange={(value) => library.updateFilter("search", value)} /> { - setPage(1); library.updateFilter("isEnabled", library.filters.isEnabled === (value === "true") ? undefined : value === "true"); }} /> @@ -260,15 +236,14 @@ export function KnowledgeLibraryPage({ label={agentFilter.length === 1 ? agents.find((agent) => agent.aiAgentId === agentFilter[0])?.name ?? "Любой" : "Любой"} multiple open={agentOpen} - options={agents.map((agent) => ({ value: String(agent.aiAgentId), label: agent.name }))} + options={agents.filter((agent) => agent.aiAgentId != null).map((agent) => ({ value: String(agent.aiAgentId), label: agent.name }))} selected={agentFilter.map(String)} onOpenChange={setAgentOpen} onSelect={(value) => { - setPage(1); const id = Number(value); - setAgentFilter((current) => (current.includes(id) - ? current.filter((item) => item !== id) - : [...current, id])); + library.updateFilter("agents", agentFilter.includes(id) + ? agentFilter.filter((item) => item !== id) + : [...agentFilter, id]); }} /> @@ -277,7 +252,7 @@ export function KnowledgeLibraryPage({ {library.itemsLoading ? ( - ) : visible.length === 0 ? ( + ) : library.items.length === 0 ? (
@@ -326,11 +301,12 @@ export function KnowledgeLibraryPage({ - {visible.map((item) => { + {library.items.map((item) => { const checked = library.selectedIds.has(item.id); const path = knowledgeCategoryPath(library.categories, item.category.id); const parent = path.includes(" / ") ? path.slice(0, path.lastIndexOf(" / ")) : ""; - const attachedAgents = agentsOfKnowledge(agents, item.id).length; + // Сколько агентов используют материал — считает сервер. + const attachedAgents = item.agentsCount ?? 0; const menuItems = [ { key: "open", label: }, ...(canManage ? [ @@ -404,11 +380,11 @@ export function KnowledgeLibraryPage({ - )} diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeTableFooter.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeTableFooter.tsx deleted file mode 100644 index 185cda3..0000000 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeTableFooter.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { ReactNode } from "react"; - -import { Icon } from "../../../shared/icons"; - -/** Подвал таблицы знаний: подпись слева, страницы справа (кадр KB1). - * Стрелки на границах гасятся — как в подвале библиотеки портала. */ -export function KnowledgeTableFooter({ - note, - page, - pageCount, - onPageChange, -}: { - note: ReactNode; - page: number; - pageCount: number; - onPageChange: (page: number) => void; -}) { - const pages = Array.from({ length: pageCount }, (_, index) => index + 1); - return ( -
- {note} - - - {pages.map((item) => (item === page - ? {item} - : ))} - - -
- ); -} diff --git a/apps/internal-ui/src/features/ai/knowledge/api.test.ts b/apps/internal-ui/src/features/ai/knowledge/api.test.ts index 73fbe72..f6d6a4a 100644 --- a/apps/internal-ui/src/features/ai/knowledge/api.test.ts +++ b/apps/internal-ui/src/features/ai/knowledge/api.test.ts @@ -49,15 +49,19 @@ describe("knowledge API", () => { category: 7, isEnabled: false, search: "тариф Acme", - }); + agents: [3], + }, 2); const url = new URL(fetchMock.mock.calls[0][0], "https://app.example"); expect(url.pathname).toBe( `/api/v1/organizations/${organizationPublicId}/ai/knowledge/`, ); + // Страница и фильтр по агенту тоже уходят на сервер: библиотека постраничная. expect(Object.fromEntries(url.searchParams)).toEqual({ category: "7", isEnabled: "false", + agent: "3", + page: "2", search: "тариф Acme", }); }); diff --git a/apps/internal-ui/src/features/ai/knowledge/api.ts b/apps/internal-ui/src/features/ai/knowledge/api.ts index 76abd46..8365f28 100644 --- a/apps/internal-ui/src/features/ai/knowledge/api.ts +++ b/apps/internal-ui/src/features/ai/knowledge/api.ts @@ -1,4 +1,5 @@ import { api, apiUpload } from "../../../api/client"; +import type { PagedPayload } from "../../../shared/usePagedResource"; import type { AgentCategoryKnowledgeSelectionResult, AgentLinkRequest, @@ -17,17 +18,21 @@ import type { KnowledgeUpdateRequest, } from "./types"; -function knowledgeListPath(filters: KnowledgeListFilters): string { +function knowledgeListPath(filters: KnowledgeListFilters, page?: number): string { const query = new URLSearchParams(); if (filters.category !== undefined) query.set("category", String(filters.category)); if (filters.isEnabled !== undefined) query.set("isEnabled", String(filters.isEnabled)); if (filters.search !== undefined && filters.search !== "") query.set("search", filters.search); + for (const agent of filters.agents ?? []) query.append("agent", String(agent)); + if (page !== undefined) query.set("page", String(page)); const suffix = query.toString(); return `/api/v1/ai/knowledge/${suffix ? `?${suffix}` : ""}`; } -export function fetchKnowledgeList(filters: KnowledgeListFilters = {}) { - return api<{ items: KnowledgeItem[] }>(knowledgeListPath(filters)); +// Библиотека знаний приходит страницей: фильтры и ветка категорий отрабатывают +// на сервере (кадр KB1). +export function fetchKnowledgeList(filters: KnowledgeListFilters = {}, page = 1) { + return api>(knowledgeListPath(filters, page)); } export function fetchKnowledgeItem(id: number) { diff --git a/apps/internal-ui/src/features/ai/knowledge/knowledgeLibraryModel.ts b/apps/internal-ui/src/features/ai/knowledge/knowledgeLibraryModel.ts index 0d30286..1615b0e 100644 --- a/apps/internal-ui/src/features/ai/knowledge/knowledgeLibraryModel.ts +++ b/apps/internal-ui/src/features/ai/knowledge/knowledgeLibraryModel.ts @@ -1,6 +1,5 @@ -import type { AgentCard } from "../../agents/model"; import { pluralRu, shortDateTime } from "../../../shared/utils"; -import type { KnowledgeAttachment, KnowledgeCategory, KnowledgeItem } from "./types"; +import type { KnowledgeAgentRef, KnowledgeAttachment, KnowledgeCategory, KnowledgeItem } from "./types"; // Экранная модель раздела «База знаний» (дизайн-базлайн v2, кадры KB1–KB9). // Отделы упразднены ADR-CHATBALLS-0041, поэтому доступность знания — это флаг @@ -63,20 +62,18 @@ export function libraryTotals(categories: KnowledgeCategory[], items: KnowledgeI } /** Агенты, которым знание прикреплено явно (карточка и рейка редактора). */ -export function agentsOfKnowledge(agents: AgentCard[], knowledgeId: number): AgentCard[] { - return agents.filter((agent) => agent.knowledge.some((item) => item.id === knowledgeId)); -} - export type AgentState = { - agent: AgentCard; + agent: KnowledgeAgentRef; /** «AI отвечает» / «AI выключен у агента» — подпись строки агента. */ meta: string; /** Участвует ли знание в ответах именно этого агента. */ answering: boolean; }; -export function agentStates(agents: AgentCard[], knowledgeId: number): AgentState[] { - return agentsOfKnowledge(agents, knowledgeId).map((agent) => ({ +/** Агенты материала приходят из его карточки: считать их по всем карточкам + * агентов организации больше не нужно. */ +export function agentStates(agents: KnowledgeAgentRef[]): AgentState[] { + return agents.map((agent) => ({ agent, meta: agent.aiStatus === "ACTIVE" ? "AI отвечает" : "AI выключен у агента", answering: agent.aiStatus === "ACTIVE", diff --git a/apps/internal-ui/src/features/ai/knowledge/styles-library.css b/apps/internal-ui/src/features/ai/knowledge/styles-library.css index 7e46e97..32f317b 100644 --- a/apps/internal-ui/src/features/ai/knowledge/styles-library.css +++ b/apps/internal-ui/src/features/ai/knowledge/styles-library.css @@ -507,72 +507,6 @@ /* --- Подвал таблицы --- */ -.knowledge-table-footer { - display: flex; - flex: none; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 11px 24px; - border-top: 1px solid var(--n-8); - background: var(--n-10); - color: var(--n-4); - font-size: 12.5px; -} - -.knowledge-pages { - display: flex; - align-items: center; - gap: 6px; -} - -.knowledge-pages b { - display: inline-flex; - min-width: 28px; - height: 28px; - align-items: center; - justify-content: center; - border-radius: 7px; - background: var(--primary-bg); - color: var(--primary-text); - font-size: 12.5px; - font-weight: 600; -} - -.knowledge-page-number, -.knowledge-page-step { - display: inline-flex; - min-width: 28px; - height: 28px; - align-items: center; - justify-content: center; - padding: 0 8px; - border: 1px solid var(--n-7); - border-radius: 7px; - background: var(--surface-card); - color: var(--n-3); - font-family: inherit; - font-size: 12.5px; - cursor: pointer; -} - -.knowledge-page-step { width: 28px; padding: 0; } - -.knowledge-page-number:hover, -.knowledge-page-step:not(:disabled):hover { border-color: var(--n-6); color: var(--n-2); } - -.knowledge-page-step:disabled { opacity: 0.45; cursor: default; } - -/* --- Пустая категория (кадр KB9) --- */ - -.knowledge-empty { - display: flex; - flex: 1; - align-items: center; - justify-content: center; - padding: 40px; -} - .knowledge-empty > div { width: 480px; text-align: center; diff --git a/apps/internal-ui/src/features/ai/knowledge/types.ts b/apps/internal-ui/src/features/ai/knowledge/types.ts index 42fcaa0..7107172 100644 --- a/apps/internal-ui/src/features/ai/knowledge/types.ts +++ b/apps/internal-ui/src/features/ai/knowledge/types.ts @@ -29,6 +29,11 @@ export type KnowledgeItem = { isEnabled: boolean; attachments: KnowledgeAttachment[]; agentsCount: number | null; + // К каким агентам материал прикреплён — считает сервер: диалогу прикрепления + // больше не нужен весь список карточек агентов. + agentIds?: number[]; + // Только в карточке материала: агенты с их состоянием AI. + agents?: KnowledgeAgentRef[]; createdBy?: string; // Кто последним правил знание — подпись под датой в колонке «Обновлено». updatedBy?: string; @@ -37,10 +42,19 @@ export type KnowledgeItem = { updatedAt: string; }; +export type KnowledgeAgentRef = { + id: number; + name: string; + groupName: string | null; + aiStatus: string | null; +}; + export type KnowledgeListFilters = { category?: number; isEnabled?: boolean; search?: string; + // Идентификаторы AIAgent: ветка категорий и агент отбираются на сервере. + agents?: number[]; }; export type KnowledgeCreateRequest = { diff --git a/apps/internal-ui/src/features/ai/knowledge/useKnowledgeLibrary.ts b/apps/internal-ui/src/features/ai/knowledge/useKnowledgeLibrary.ts index 30a8382..2b182ed 100644 --- a/apps/internal-ui/src/features/ai/knowledge/useKnowledgeLibrary.ts +++ b/apps/internal-ui/src/features/ai/knowledge/useKnowledgeLibrary.ts @@ -1,35 +1,34 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useDebounced } from "../../../shared/useDebounced"; +import { usePagedResource } from "../../../shared/usePagedResource"; import { fetchKnowledgeCategories, fetchKnowledgeList, type KnowledgeCategory, - type KnowledgeItem, - type KnowledgeListFilters, } from "./model"; +// Библиотека знаний: страница, ветка категорий, агент, состояние и поиск — +// всё на сервере. Раньше ветку и агента отбирал браузер по всему набору, +// поэтому список приходилось запрашивать целиком. + export type KnowledgeLibraryFilterState = { category?: number; isEnabled?: boolean; search: string; + /** Идентификаторы AIAgent из фильтра «Агент». */ + agents: number[]; }; +const EMPTY_FILTERS: KnowledgeLibraryFilterState = { search: "", agents: [] }; + export function useKnowledgeLibrary() { - const [items, setItems] = useState([]); const [categories, setCategories] = useState([]); - const [filters, setFilters] = useState({ search: "" }); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [itemsLoading, setItemsLoading] = useState(true); + const [filters, setFilters] = useState(EMPTY_FILTERS); const [categoriesLoading, setCategoriesLoading] = useState(true); - const [itemsError, setItemsError] = useState(false); const [categoriesError, setCategoriesError] = useState(false); const [selectedIds, setSelectedIds] = useState>(() => new Set()); - const requestId = useRef(0); - - useEffect(() => { - const timeout = window.setTimeout(() => setDebouncedSearch(filters.search.trim()), 250); - return () => window.clearTimeout(timeout); - }, [filters.search]); + const settledSearch = useDebounced(filters.search.trim()); const reloadCategories = useCallback(async () => { setCategoriesLoading(true); @@ -44,20 +43,17 @@ export function useKnowledgeLibrary() { } }, []); - const reloadItems = useCallback(async (activeFilters: KnowledgeListFilters) => { - const activeRequest = ++requestId.current; - setItemsLoading(true); - setItemsError(false); - try { - const payload = await fetchKnowledgeList(activeFilters); - if (activeRequest !== requestId.current) return; - setItems(payload.items); - } catch { - if (activeRequest === requestId.current) setItemsError(true); - } finally { - if (activeRequest === requestId.current) setItemsLoading(false); - } - }, []); + const request = useMemo( + () => ({ + category: filters.category, + isEnabled: filters.isEnabled, + search: settledSearch, + agents: filters.agents, + }), + [filters.agents, filters.category, filters.isEnabled, settledSearch], + ); + const load = useCallback((page: number) => fetchKnowledgeList(request, page), [request]); + const page = usePagedResource(load, request, "Не удалось загрузить знания"); useEffect(() => { void reloadCategories(); @@ -73,14 +69,6 @@ export function useKnowledgeLibrary() { } }, [categories, categoriesLoading, filters.category]); - useEffect(() => { - void reloadItems({ - category: filters.category, - isEnabled: filters.isEnabled, - search: debouncedSearch, - }); - }, [debouncedSearch, filters.category, filters.isEnabled, reloadItems]); - const updateFilter = useCallback(( key: TKey, value: KnowledgeLibraryFilterState[TKey], @@ -102,6 +90,7 @@ export function useKnowledgeLibrary() { /** Действие из меню строки работает над одним знанием: выбор заменяется. */ const selectOnly = useCallback((knowledgeId: number) => setSelectedIds(new Set([knowledgeId])), []); + const items = page.items; const toggleVisible = useCallback(() => { setSelectedIds((current) => { const visibleIds = items.map((item) => item.id); @@ -113,15 +102,8 @@ export function useKnowledgeLibrary() { }, [items]); const reload = useCallback(async () => { - await Promise.all([ - reloadCategories(), - reloadItems({ - category: filters.category, - isEnabled: filters.isEnabled, - search: debouncedSearch, - }), - ]); - }, [debouncedSearch, filters, reloadCategories, reloadItems]); + await Promise.all([reloadCategories(), page.reload()]); + }, [page, reloadCategories]); return { categories, @@ -130,8 +112,12 @@ export function useKnowledgeLibrary() { clearSelected, filters, items, - itemsError, - itemsLoading, + itemsError: Boolean(page.errorText), + itemsLoading: page.loading, + page: page.page, + pageCount: page.pageCount, + total: page.total, + setPage: page.setPage, reload, reloadCategories, selectOnly, diff --git a/apps/internal-ui/src/features/ai/useAiAgents.ts b/apps/internal-ui/src/features/ai/useAiAgents.ts index 043d2b0..2ef1361 100644 --- a/apps/internal-ui/src/features/ai/useAiAgents.ts +++ b/apps/internal-ui/src/features/ai/useAiAgents.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useState } from "react"; -import { fetchAllAgents, type AgentCard } from "../agents/model"; +import { fetchAgentDirectory, type AgentRef } from "../agents/model"; export function useAiAgents() { - const [agents, setAgents] = useState([]); + const [agents, setAgents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); @@ -11,7 +11,7 @@ export function useAiAgents() { setLoading(true); setError(false); try { - const agentsResponse = await fetchAllAgents(); + const agentsResponse = await fetchAgentDirectory(); setAgents(agentsResponse.items); } catch { setError(true); diff --git a/apps/internal-ui/src/features/employees/EmployeesListPage.tsx b/apps/internal-ui/src/features/employees/EmployeesListPage.tsx index 3d56cdd..50f7b72 100644 --- a/apps/internal-ui/src/features/employees/EmployeesListPage.tsx +++ b/apps/internal-ui/src/features/employees/EmployeesListPage.tsx @@ -93,7 +93,6 @@ export function EmployeesPage({ groups, openEmployee, user }: { {employees.pageCount > 1 && ( ) : ( <> - ({ id: agent.id, code: agent.code, name: agent.name }))} /> + ({ id: agent.id, name: agent.name }))} /> )} diff --git a/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx b/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx index a066810..fc8b14f 100644 --- a/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx +++ b/apps/internal-ui/src/features/sales/clients/SalesClientsFilters.tsx @@ -1,5 +1,8 @@ import { FilterDropdown, SearchInput } from "../../../shared/ui-controls"; -import { channelOptions, type ClientAgentRef } from "./model"; +import { channelOptions } from "./model"; + +/** Агент в фильтре: только то, что рисует выпадающий список. */ +export type AgentFilterOption = { id: number; name: string }; import type { SalesClientsState } from "./useSalesClients"; // Фильтры списка (кадры K1/K2): поиск, «Все каналы», «Все агенты», чип @@ -7,7 +10,7 @@ import type { SalesClientsState } from "./useSalesClients"; // Агенты для фильтра приходят из справочника агентов: список контактов теперь // постраничный, и собрать их по загруженной странице нельзя. -export function SalesClientsFilters({ clients, agents }: { clients: SalesClientsState; agents: ClientAgentRef[] }) { +export function SalesClientsFilters({ clients, agents }: { clients: SalesClientsState; agents: AgentFilterOption[] }) { return (