mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
⚡ 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:
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/",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
Reference in new issue
Block a user