⚡ perf(knowledge): библиотека знаний — страница и фильтры на сервере

Список знаний отдавался целиком, ветку категорий и фильтр по агенту отбирал
браузер, а число прикреплённых агентов считалось перебором всех карточек
агентов организации — из-за этого приходилось грузить и их.

- список знаний отдаётся страницей; ветка категорий, агент, состояние и поиск
  применяются до среза;
- счётчики знания считаются подзапросами: фильтр по агенту идёт по той же
  связи и больше не урезает «прикреплено к N агентам»;
- материал несёт agentIds, карточка — список агентов с состоянием AI: диалогу
  прикрепления и рейке карточки больше не нужен весь набор агентов;
- /agents/directory/ — компактный справочник для выпадающих выборов с именем,
  состоянием AI и числом материалов;
- четвёртый подвал со страницами (KnowledgeTableFooter) заменён общим;
- подписи «Показано N из M» в подвалах сотрудников и агентов убраны: таких
  строк нет в макетах, а счётчик уже стоит в шапке экрана.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-09 01:30:02 +03:00
1 parent e6a6bf77b6
commit 776bc89514
28 files changed
+427 -284

No files matched your search

@@ -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("<int:agent_id>/", views.AgentCardDetailView.as_view(), name="agent-card-detail"),
path(
"<int:agent_id>/activate/",
+36 -1
View File
@@ -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"}
@@ -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,
)
+56 -7
View File
@@ -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()
+3
View File
@@ -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(),
@@ -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"])
@@ -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:
+34 -8
View File
@@ -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:
+4 -4
View File
@@ -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 {
@@ -180,7 +180,6 @@ export function AgentsPage({
))}
{agents.pageCount > 1 && (
<Pagination
note={`Показано ${agents.items.length} из ${agents.total}`}
page={agents.page}
pageCount={agents.pageCount}
onPage={agents.setPage}
+17 -6
View File
@@ -75,12 +75,23 @@ export function fetchAgentsPage(
return api<PagedPayload<AgentCard>>(`/api/v1/agents/?${params.toString()}`);
}
/** Полный набор карточек для экранов, которым нужны связи агентов со знаниями
* (библиотека знаний считает по ним прикрепления). Ограничен одной страницей
* предельного размера: этим экранам нужен серверный контракт со счётчиками,
* пока он не сделан — держим потолок явным, а не молчаливым. */
export function fetchAllAgents(): Promise<{ items: AgentCard[] }> {
return api<PagedPayload<AgentCard>>("/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 }> {
@@ -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,
}));
}
@@ -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;
@@ -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;
@@ -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
@@ -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<number[]>([]);
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)}
>
<Icon name="folder" size={15} strokeWidth={1.8} />
<span>Все знания</span>
@@ -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)}
>
<span>{category.name}</span>
<small>{count}</small>
@@ -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)}
/>
<FilterDropdown
caption="В ответах:"
@@ -251,7 +228,6 @@ export function KnowledgeLibraryPage({
selected={library.filters.isEnabled === undefined ? [] : [String(library.filters.isEnabled)]}
onOpenChange={setAnswerOpen}
onSelect={(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]);
}}
/>
<span className="knowledge-library-toolbar-gap" />
@@ -277,7 +252,7 @@ export function KnowledgeLibraryPage({
{library.itemsLoading ? (
<LoadingState />
) : visible.length === 0 ? (
) : library.items.length === 0 ? (
<div className="knowledge-empty">
<div>
<span className="knowledge-empty-icon"><Icon name="book" size={25} strokeWidth={1.7} /></span>
@@ -326,11 +301,12 @@ export function KnowledgeLibraryPage({
</tr>
</thead>
<tbody>
{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: <button type="button" onClick={() => openKnowledge(item.id)}><Icon name="book" size={15} strokeWidth={1.8} />Открыть знание</button> },
...(canManage ? [
@@ -404,11 +380,11 @@ export function KnowledgeLibraryPage({
</tbody>
</table>
<KnowledgeTableFooter
note={`${pluralRu(filtered.length, KNOWLEDGE_FORMS)} · показаны ${visible.length} · агент отвечает только по включённым и прикреплённым`}
page={currentPage}
pageCount={pageCount}
onPageChange={setPage}
<Pagination
note={`${pluralRu(library.total, KNOWLEDGE_FORMS)} · показаны ${library.items.length} · агент отвечает только по включённым и прикреплённым`}
page={library.page}
pageCount={library.pageCount}
onPage={library.setPage}
/>
</>
)}
@@ -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 (
<div className="knowledge-table-footer">
<span>{note}</span>
<span className="knowledge-pages">
<button
aria-label="Предыдущая страница"
className="knowledge-page-step"
disabled={page <= 1}
type="button"
onClick={() => onPageChange(page - 1)}
>
<Icon name="chevronLeft" size={14} strokeWidth={2} />
</button>
{pages.map((item) => (item === page
? <b key={item}>{item}</b>
: <button className="knowledge-page-number" key={item} type="button" onClick={() => onPageChange(item)}>{item}</button>))}
<button
aria-label="Следующая страница"
className="knowledge-page-step"
disabled={page >= pageCount}
type="button"
onClick={() => onPageChange(page + 1)}
>
<Icon name="chevronRight" size={14} strokeWidth={2} />
</button>
</span>
</div>
);
}
@@ -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",
});
});
@@ -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<PagedPayload<KnowledgeItem>>(knowledgeListPath(filters, page));
}
export function fetchKnowledgeItem(id: number) {
@@ -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",
@@ -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;
@@ -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 = {
@@ -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<KnowledgeItem[]>([]);
const [categories, setCategories] = useState<KnowledgeCategory[]>([]);
const [filters, setFilters] = useState<KnowledgeLibraryFilterState>({ search: "" });
const [debouncedSearch, setDebouncedSearch] = useState("");
const [itemsLoading, setItemsLoading] = useState(true);
const [filters, setFilters] = useState<KnowledgeLibraryFilterState>(EMPTY_FILTERS);
const [categoriesLoading, setCategoriesLoading] = useState(true);
const [itemsError, setItemsError] = useState(false);
const [categoriesError, setCategoriesError] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => 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(<TKey extends keyof KnowledgeLibraryFilterState>(
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,
@@ -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<AgentCard[]>([]);
const [agents, setAgents] = useState<AgentRef[]>([]);
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);
@@ -93,7 +93,6 @@ export function EmployeesPage({ groups, openEmployee, user }: {
{employees.pageCount > 1 && (
<Pagination
className="employees-pagination"
note={`Показано ${employees.items.length} из ${employees.total}`}
page={employees.page}
pageCount={employees.pageCount}
onPage={employees.setPage}
@@ -49,7 +49,7 @@ export function SalesClientsPage({ openClient, openIntegrations }: { openClient:
<SalesClientsEmpty onOpenIntegrations={openIntegrations} />
) : (
<>
<SalesClientsFilters clients={clients} agents={agents.map((agent) => ({ id: agent.id, code: agent.code, name: agent.name }))} />
<SalesClientsFilters clients={clients} agents={agents.map((agent) => ({ id: agent.id, name: agent.name }))} />
<SalesClientsTable clients={clients} openClient={openClient} />
</>
)}
@@ -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 (
<div className="sales-clients-filterbar">
<SearchInput
+2 -2
View File
@@ -1,4 +1,4 @@
import type { AgentCard } from "./features/agents/model";
import type { AgentRef } from "./features/agents/model";
// Роли SPEC-CHATBALLS-0031 §3: OWNER и ADMIN идентичны (владельца нельзя удалить),
// EMPLOYEE работает только в чате; видимость диалогов — по группам (ADR-CHATBALLS-0043).
@@ -105,5 +105,5 @@ export type RouteKey = "administrationAudit" | "employeeDetail" | "employees" |
export type AppData = {
groups: EmployeeGroup[];
agents: AgentCard[];
agents: AgentRef[];
};