diff --git a/apps/backend/chatballs/conversations/services.py b/apps/backend/chatballs/conversations/services.py index 47b2789..eec4b19 100644 --- a/apps/backend/chatballs/conversations/services.py +++ b/apps/backend/chatballs/conversations/services.py @@ -14,10 +14,15 @@ from chatballs.conversations.models import ( SystemEvent, ) from chatballs.conversations.queue import QUEUE_FIELDS, enter_queue, leave_queue +from chatballs.conversations.realtime import notify_conversation_changed from chatballs.i18n import customer_language, t from chatballs.identity.models import EmployeeRole from chatballs.integrations.models import IntegrationProvider -from chatballs.notifications.models import NotificationAudience, NotificationType +from chatballs.notifications.models import ( + Notification, + NotificationAudience, + NotificationType, +) from chatballs.notifications.services import notify from chatballs.tenancy.context import TenantContext @@ -463,3 +468,41 @@ def assign_operator(*, context: TenantContext, conversation_id: int, assignee) - dedup_key=f"assign:{conversation.id}:{assignee.pk}", ) return conversation + + +@transaction.atomic +def delete_conversation(*, context: TenantContext, conversation: Conversation) -> None: + """Удалить диалог насовсем: переписку, вложения, звонки и оклики о нём. + + Раньше «удалить» означало архив: диалог пропадал из списков, но продолжал + жить — в него приходили сообщения из канала, он поднимал уведомления и + возвращался клиенту в виджете как ни в чём не бывало. Решение владельца + 2026-09-14: удалён — значит удалён. Права на это есть только у владельца и + администратора (проверяет представление). + + Файлы удаляются явно: `FileField` при удалении строки оставляет их на + диске, а «удалён» не должно означать «лежит в media». + """ + conversation_id = conversation.id + organization_id = conversation.organization_id + for message in conversation.messages.exclude(audio="", attachment=""): + if message.audio: + message.audio.delete(save=False) + if message.attachment: + message.attachment.delete(save=False) + # Оклик ведёт в диалог, которого больше нет: и в списке уведомлений, и в + # мессенджере сотрудника такая строка — тупик. + Notification.objects.filter( + organization_id=organization_id, + source_type="Conversation", + source_id=str(conversation_id), + ).delete() + # Звонки держат диалог внешним ключом PROTECT; история звонка без самого + # диалога ничего не значит, поэтому уходит вместе с ним. + from chatballs.calls.models import CallSession + + CallSession.objects.filter(conversation_id=conversation_id).delete() + conversation.delete() + # Открытые рабочие места узнают об этом событием: у того, кто держал диалог + # открытым, он должен закрыться, а не висеть мёртвой карточкой. + notify_conversation_changed(conversation_id, organization_id=organization_id) diff --git a/apps/backend/chatballs/conversations/test_conversation_delete.py b/apps/backend/chatballs/conversations/test_conversation_delete.py new file mode 100644 index 0000000..78f31a2 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_conversation_delete.py @@ -0,0 +1,105 @@ +"""Удаление диалога: право, полнота и последствия для виджета. + +Решение владельца 2026-09-14: «удалить» означает удалить, а не отправить в +архив. Удалённый диалог не принимает сообщений, не окликает уведомлениями и +обнуляет переписку в виджете клиента. +""" + +import json + +from django.test import TestCase + +from chatballs.channels.models import Channel +from chatballs.conversations.models import ( + Contact, + Conversation, + Message, + MessageAuthor, +) +from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.notifications.models import Notification, NotificationType +from chatballs.testing import TenantAPIClient as APIClient + + +class ConversationDeleteTests(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Chat", slug="chat-delete") + self.owner = self._member("owner@delete.test", EmployeeRole.OWNER) + self.employee = self._member("employee@delete.test", EmployeeRole.EMPLOYEE) + group = EmployeeGroup.objects.create( + organization=self.organization, name="Операторы" + ) + EmployeeGroupMember.objects.create( + organization=self.organization, group=group, employee=self.employee + ) + self.channel = Channel.objects.create( + organization=self.organization, code="line", name="Линия" + ) + contact = Contact.objects.create(organization=self.organization, name="Иван") + self.conversation = Conversation.objects.create( + organization=self.organization, channel=self.channel, contact=contact + ) + self.message = Message.objects.create( + conversation=self.conversation, + author_type=MessageAuthor.CONTACT, + text="Здравствуйте", + ) + self.owner_client = APIClient() + self.owner_client.force_authenticate(self.owner.user) + self.employee_client = APIClient() + self.employee_client.force_authenticate(self.employee.user) + + def _member(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, + organization=self.organization, + role=role, + position_title="Specialist", + ) + + def _url(self) -> str: + return f"/api/v1/conversations/{self.conversation.id}/" + + def test_operator_cannot_delete(self) -> None: + response = self.employee_client.delete(self._url()) + self.assertEqual(response.status_code, 403) + self.assertTrue(Conversation.objects.filter(id=self.conversation.id).exists()) + + def test_owner_deletes_conversation_with_history(self) -> None: + response = self.owner_client.delete(self._url()) + self.assertEqual(response.status_code, 204) + self.assertFalse(Conversation.objects.filter(id=self.conversation.id).exists()) + self.assertFalse(Message.objects.filter(id=self.message.id).exists()) + + def test_notifications_about_conversation_are_gone(self) -> None: + Notification.objects.create( + organization=self.organization, + type=NotificationType.NEW_DIALOG, + title="Новый диалог", + target_route="chat", + target_id=str(self.conversation.id), + source_type="Conversation", + source_id=str(self.conversation.id), + ) + self.owner_client.delete(self._url()) + self.assertEqual(Notification.objects.count(), 0) + + def test_deleted_conversation_cannot_be_written_to(self) -> None: + self.owner_client.delete(self._url()) + response = self.owner_client.post( + f"{self._url()}messages/", + data=json.dumps({"text": "ещё раз"}), + content_type="application/json", + ) + self.assertEqual(response.status_code, 404) + + def test_missing_conversation_is_not_found(self) -> None: + self.owner_client.delete(self._url()) + self.assertEqual(self.owner_client.delete(self._url()).status_code, 404) diff --git a/apps/backend/chatballs/conversations/views.py b/apps/backend/chatballs/conversations/views.py index 76ce43f..2f8e9ed 100644 --- a/apps/backend/chatballs/conversations/views.py +++ b/apps/backend/chatballs/conversations/views.py @@ -33,6 +33,7 @@ from chatballs.conversations.services import ( assign_operator, claim_conversation, close_conversation, + delete_conversation, mark_conversation_as_spam, post_operator_message, release_to_ai, @@ -187,6 +188,28 @@ class ConversationDetailView(ConversationViewBase): } ) + def delete(self, request: Request, conversation_id: int) -> Response: + """Удалить диалог вместе с перепиской — насовсем. + + Право есть только у владельца и администратора: оператор закрывает + диалог и помечает спам, но не стирает историю организации. + """ + try: + conversation = self._conversation( + request, conversation_id, "conversations.operate" + ) + except Conversation.DoesNotExist: + return Response({"detail": t("conversations.not_found")}, status=404) + if not can_administer_access(request.tenant_context.membership): + return Response( + {"detail": t("conversations.delete_admin_only")}, status=403 + ) + # Запись журнала — до удаления: после него у диалога уже нет ни строки, + # ни идентификатора, который можно было бы записать. + self._audit(request, "deleted", conversation) + delete_conversation(context=request.tenant_context, conversation=conversation) + return Response(status=204) + class ConversationClaimView(ConversationViewBase): required_capability = "conversations.operate" diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index 8779bd4..383ea2a 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -53,6 +53,7 @@ MESSAGES: dict[str, object] = { "conversations.not_a_voice_message": "This is not a voice message", "conversations.not_found": "Conversation not found", "conversations.note_too_long": "The note is longer than 4000 characters", + "conversations.delete_admin_only": "Only the owner or an administrator can delete a conversation", "conversations.restore_admin_only": "Only an administrator can restore a conversation", "conversations.system.assigned_to": "Conversation assigned to {operator}", "conversations.system.assignment_expired": "{operator} did not pick the conversation up — it is back in the queue", @@ -197,6 +198,7 @@ MESSAGES: dict[str, object] = { "audit.action_contacts_unmerged": "Contacts unmerged", "audit.action_conversation_contact_updated": "Conversation contact changed", "audit.action_conversations_archived": "Conversation moved to the archive", + "audit.action_conversations_deleted": "Conversation deleted", "audit.action_conversations_assignee_changed": "Conversation assignee changed", "audit.action_conversations_claimed": "Conversation taken", "audit.action_conversations_closed": "Conversation closed", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index 1d898b9..6644325 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -57,6 +57,7 @@ MESSAGES: dict[str, object] = { "conversations.not_a_voice_message": "Это не голосовое сообщение", "conversations.not_found": "Диалог не найден", "conversations.note_too_long": "Заметка длиннее 4000 символов", + "conversations.delete_admin_only": "Удалить диалог может владелец или администратор", "conversations.restore_admin_only": "Восстановить диалог может только администратор", "conversations.system.ai_handed_over": "AI передал диалог оператору", "conversations.system.assigned_to": "Диалог назначен на {operator}", @@ -201,6 +202,7 @@ MESSAGES: dict[str, object] = { "audit.action_contacts_unmerged": "Разъединение контактов", "audit.action_conversation_contact_updated": "Изменён контакт диалога", "audit.action_conversations_archived": "Диалог отправлен в архив", + "audit.action_conversations_deleted": "Диалог удалён", "audit.action_conversations_assignee_changed": "Изменён ответственный за диалог", "audit.action_conversations_claimed": "Диалог взят в работу", "audit.action_conversations_closed": "Диалог закрыт", diff --git a/apps/backend/chatballs/identity/audit_catalog.py b/apps/backend/chatballs/identity/audit_catalog.py index 70a2b78..7d2e332 100644 --- a/apps/backend/chatballs/identity/audit_catalog.py +++ b/apps/backend/chatballs/identity/audit_catalog.py @@ -148,6 +148,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = { "conversations.group_changed": "audit.action_conversations_group_changed", "conversations.priority_changed": "audit.action_conversations_priority_changed", "conversations.closed": "audit.action_conversations_closed", + "conversations.deleted": "audit.action_conversations_deleted", "conversations.archived": "audit.action_conversations_archived", "conversations.unarchived": "audit.action_conversations_unarchived", "conversations.marked_spam": "audit.action_conversations_marked_spam", diff --git a/apps/backend/chatballs/webchat/services.py b/apps/backend/chatballs/webchat/services.py index d0e5aad..ceed676 100644 --- a/apps/backend/chatballs/webchat/services.py +++ b/apps/backend/chatballs/webchat/services.py @@ -15,6 +15,7 @@ from chatballs.conversations.models import ( ControlMode, Conversation, LifecycleState, + Message, MessageKind, ) from chatballs.conversations.transports.base import InboundMessage @@ -311,6 +312,21 @@ def _call_payload(session: WebSession) -> dict | None: return public_invite_payload(call, call.invite.expires_at) +def _thread_is_gone(session: WebSession, since: int) -> bool: + """Переписка, которую показывает виджет, перестала существовать. + + Диалог могли удалить в рабочем месте — вместе со всеми сообщениями. Тогда + сообщения, до которого досчитал виджет, больше нет, и показывать клиенту + переписку, которой не существует, нельзя. Закрытый диалог под это правило + не подпадает: его сообщения на месте. + """ + return since > 0 and not Message.objects.filter( + id=since, + conversation__contact_id=session.identity.contact_id, + conversation__channel_id=session.connection.channel_id, + ).exists() + + def messages_payload(session: WebSession, since: int) -> dict: conversation = ( Conversation.objects.filter( @@ -319,10 +335,21 @@ def messages_payload(session: WebSession, since: int) -> dict: .order_by("-last_activity_at") .first() ) + reset = _thread_is_gone(session, since) + if reset: + # Лента виджета начинается заново: то, что осталось, отдаётся целиком. + since = 0 if conversation is None: - return {"state": "ai", "lifecycle": LifecycleState.OPEN, "messages": [], "call": None} + return { + "state": "ai", + "lifecycle": LifecycleState.OPEN, + "messages": [], + "call": None, + "reset": reset, + } items = conversation.messages.filter(id__gt=since).order_by("created_at") return { + "reset": reset, "state": _STATE.get(conversation.control_mode, "ai"), "lifecycle": conversation.lifecycle, "messages": [ diff --git a/apps/backend/chatballs/webchat/tests.py b/apps/backend/chatballs/webchat/tests.py index 6ff6e52..35110ac 100644 --- a/apps/backend/chatballs/webchat/tests.py +++ b/apps/backend/chatballs/webchat/tests.py @@ -187,6 +187,41 @@ class PublicWebChatWidgetTests(TestCase): conversation.refresh_from_db() self.assertEqual(conversation.control_mode, ControlMode.PAUSED) + def test_deleted_conversation_resets_widget_thread(self) -> None: + # Диалог удалили в рабочем месте: виджет должен начать с чистого листа, + # а не показывать клиенту переписку, которой больше нет. + from chatballs.conversations.models import Conversation + from chatballs.conversations.services import delete_conversation + from chatballs.tenancy.context import TenantContext + + widget = create_web_widget(self.channel, name="Виджет") + token = self._session(widget.public_key).json()["token"] + self.client.post( + "/api/v1/webchat/messages/", + data=json.dumps({"text": "Здравствуйте"}), + content_type="application/json", + headers={"Authorization": f"Bearer {token}"}, + ) + payload = self.client.get( + "/api/v1/webchat/messages/?since=0", + headers={"Authorization": f"Bearer {token}"}, + ).json() + self.assertFalse(payload["reset"]) + last_id = max(message["id"] for message in payload["messages"]) + + conversation = Conversation.objects.get(channel=self.channel) + delete_conversation( + context=TenantContext.for_resource(self.organization), + conversation=conversation, + ) + + after = self.client.get( + f"/api/v1/webchat/messages/?since={last_id}", + headers={"Authorization": f"Bearer {token}"}, + ).json() + self.assertTrue(after["reset"]) + self.assertEqual(after["messages"], []) + def test_widget_origin_policy_is_scoped_per_widget(self) -> None: allowed = create_web_widget( self.channel, diff --git a/apps/internal-ui/src/features/agents/AgentKnowledgeDialog.tsx b/apps/internal-ui/src/features/agents/AgentKnowledgeDialog.tsx index baceb7c..96764c5 100644 --- a/apps/internal-ui/src/features/agents/AgentKnowledgeDialog.tsx +++ b/apps/internal-ui/src/features/agents/AgentKnowledgeDialog.tsx @@ -1,39 +1,32 @@ import { Modal } from "antd"; import { useEffect, useMemo, useState } from "react"; -import { Icon } from "../../shared/icons"; import { LoadingState } from "../../shared/ui"; import { Button, SearchInput } from "../../shared/ui-controls"; -import { shortDate } from "../../shared/utils"; -import { fetchKnowledgeList } from "../ai/knowledge/api"; -import { listPortalArticles, listSupportPortals } from "../support-portals/api"; +import { AgentKnowledgeGroup } from "./AgentKnowledgeGroup"; +import { filterGroups, loadChoiceGroups, pickedIds, pickedInGroup, type ChoiceGroup } from "./agentKnowledgeGroups"; import type { AgentCard } from "./model"; import { t } from "../../i18n"; -// «Выбрать» в блоке «Знания» карточки агента (кадры G3/G5): один список из -// материалов библиотеки и опубликованных статей порталов. Агент отвечает -// только по явно выбранным знаниям (ADR-CHATBALLS-0041 §8). - -type Choice = { - key: string; - kind: "knowledge" | "article"; - id: number; - title: string; - icon: "doc" | "globe"; - meta: string; - disabled: boolean; -}; +// «Выбрать» в блоке «Знания» карточки агента (кадры G3/G5): материалы +// библиотеки и опубликованные статьи порталов, разложенные по категориям и +// порталам. Агент отвечает только по явно выбранным знаниям +// (ADR-CHATBALLS-0041 §8). +// +// Развёрнуты сразу только те группы, где уже что-то выбрано: так видно, что +// агент читает сейчас, а остальное дерево не занимает окно целиком. export function AgentKnowledgeDialog({ card, onClose, onSave }: { card: AgentCard; onClose: () => void; onSave: (selection: { knowledgeIds: number[]; articleIds: number[] }) => Promise; }) { - const [choices, setChoices] = useState(null); + const [groups, setGroups] = useState(null); const [failed, setFailed] = useState(false); const [search, setSearch] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); + const [closed, setClosed] = useState>(() => new Set()); const [picked, setPicked] = useState>( () => new Set([ ...card.knowledge.map((item) => `k${item.id}`), @@ -43,67 +36,38 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: { useEffect(() => { let cancelled = false; - async function load() { - try { - const library = await fetchKnowledgeList(); - const portals = await listSupportPortals(); - const articleLists = await Promise.all(portals.items.map((portal) => listPortalArticles(portal.id))); + loadChoiceGroups() + .then((loaded) => { if (cancelled) return; - const articles: Choice[] = portals.items.flatMap((portal, index) => articleLists[index].items - .filter((article) => article.status === "PUBLISHED") - .map((article) => ({ - key: `a${article.id}`, - kind: "article" as const, - id: article.id, - title: article.publishedRevision?.title ?? article.slug, - icon: "globe" as const, - meta: t("ai.portal_named", { name: portal.name }), - disabled: false, - }))); - setChoices([ - ...library.items.map((item): Choice => ({ - key: `k${item.id}`, - kind: "knowledge", - id: item.id, - title: item.title, - icon: "doc", - meta: item.isEnabled ? t("ai.updated_on", { date: shortDate(item.updatedAt) }) : t("common.off"), - disabled: false, - })), - ...articles, - ]); - } catch { + setGroups(loaded); + setClosed(new Set(loaded.filter((group) => pickedInGroup(group, picked) === 0).map((group) => group.key))); + }) + .catch(() => { if (!cancelled) setFailed(true); - } - } - void load(); + }); return () => { cancelled = true; }; + // Набор выбранного на момент загрузки берётся из карточки и до ответа + // сервера не меняется — перезапускать загрузку на каждый клик незачем. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const visible = useMemo(() => { - const needle = search.trim().toLowerCase(); - if (!needle || choices === null) return choices ?? []; - return choices.filter((choice) => choice.title.toLowerCase().includes(needle)); - }, [choices, search]); + const visible = useMemo(() => filterGroups(groups ?? [], search), [groups, search]); + const searching = Boolean(search.trim()); - function toggle(key: string) { + function toggle(keys: string[], pick: boolean) { setPicked((current) => { const next = new Set(current); - if (next.has(key)) next.delete(key); - else next.add(key); + keys.forEach((key) => (pick ? next.add(key) : next.delete(key))); return next; }); } async function submit() { - if (choices === null) return; + if (groups === null) return; setSaving(true); setError(""); try { - await onSave({ - knowledgeIds: choices.filter((choice) => choice.kind === "knowledge" && picked.has(choice.key)).map((choice) => choice.id), - articleIds: choices.filter((choice) => choice.kind === "article" && picked.has(choice.key)).map((choice) => choice.id), - }); + await onSave(pickedIds(groups, picked)); onClose(); } catch (caught) { setError(caught instanceof Error ? caught.message : t("ai.could_not_save_selection")); @@ -112,28 +76,39 @@ export function AgentKnowledgeDialog({ card, onClose, onSave }: { } return ( - +

{t("ai.agent_answers_only_from_selected")}

- {choices === null && !failed && } + {groups === null && !failed && } {failed &&
{t("ai.could_not_load_material_list")}
} - {choices !== null && ( -
- {visible.length === 0 &&

{t("common.nothing_found")}

} - {visible.map((choice) => ( - - ))} -
+ {groups !== null && ( + <> +
{t("common.selected_count", { count: picked.size })}
+
+ {visible.length === 0 &&

{t("common.nothing_found")}

} + {visible.map((group) => ( + toggle([key], !picked.has(key))} + onToggleOpen={() => setClosed((current) => { + const next = new Set(current); + if (next.has(group.key)) next.delete(group.key); + else next.add(group.key); + return next; + })} + /> + ))} +
+ )} {error &&
{error}
}
-
diff --git a/apps/internal-ui/src/features/agents/AgentKnowledgeGroup.tsx b/apps/internal-ui/src/features/agents/AgentKnowledgeGroup.tsx new file mode 100644 index 0000000..e5de3a6 --- /dev/null +++ b/apps/internal-ui/src/features/agents/AgentKnowledgeGroup.tsx @@ -0,0 +1,49 @@ +import { Icon } from "../../shared/icons"; +import { pickedInGroup, type ChoiceGroup } from "./agentKnowledgeGroups"; +import { t } from "../../i18n"; + +// Одна категория (или портал) в диалоге «Знания» карточки агента: заголовок со +// счётчиком выбранного, «выбрать все» и список материалов. + +export function AgentKnowledgeGroup({ group, picked, open, onToggleOpen, onToggleChoice, onToggleAll }: { + group: ChoiceGroup; + picked: ReadonlySet; + open: boolean; + onToggleOpen: () => void; + onToggleChoice: (key: string) => void; + onToggleAll: (keys: string[], pick: boolean) => void; +}) { + const inGroup = pickedInGroup(group, picked); + const allPicked = inGroup === group.choices.length; + return ( +
+
+ + +
+ {open && group.choices.map((choice) => ( + + ))} +
+ ); +} diff --git a/apps/internal-ui/src/features/agents/agentKnowledgeGroups.ts b/apps/internal-ui/src/features/agents/agentKnowledgeGroups.ts new file mode 100644 index 0000000..c1a9131 --- /dev/null +++ b/apps/internal-ui/src/features/agents/agentKnowledgeGroups.ts @@ -0,0 +1,116 @@ +import { allPages } from "../../shared/allPages"; +import { shortDate } from "../../shared/utils"; +import { fetchAllKnowledge, fetchKnowledgeCategories } from "../ai/knowledge/api"; +import { categoryRows } from "../ai/knowledge/knowledgeLibraryModel"; +import { knowledgeCategoryPath } from "../ai/knowledge/knowledgeTree"; +import { listPortalArticles, listSupportPortals } from "../support-portals/api"; +import { t } from "../../i18n"; + +// Что можно выдать агенту: материалы библиотеки и опубликованные статьи +// порталов. Набор разложен по категориям библиотеки и по порталам — сплошным +// списком в организации с сотней материалов ничего не найти. + +export type KnowledgeChoice = { + key: string; + kind: "knowledge" | "article"; + id: number; + title: string; + meta: string; +}; + +export type ChoiceGroup = { + key: string; + title: string; + icon: "folder" | "globe"; + choices: KnowledgeChoice[]; +}; + +/** Все материалы и статьи, разложенные по категориям и порталам. */ +export async function loadChoiceGroups(): Promise { + const [categories, items] = await Promise.all([fetchKnowledgeCategories(), fetchAllKnowledge()]); + // Порталы и статьи тоже приходят страницами: диалогу нужен весь набор, + // иначе статья со второй страницы портала агенту недоступна. + const portals = await allPages((page) => listSupportPortals(undefined, page)); + const articleLists = await Promise.all( + portals.map((portal) => allPages((page) => listPortalArticles(portal.id, undefined, page))), + ); + + const byCategory = new Map(); + items.forEach((item) => { + const choices = byCategory.get(item.category.id) ?? []; + choices.push({ + key: `k${item.id}`, + kind: "knowledge", + id: item.id, + title: item.title, + meta: item.isEnabled ? t("ai.updated_on", { date: shortDate(item.updatedAt) }) : t("common.off"), + }); + byCategory.set(item.category.id, choices); + }); + + // Порядок категорий — тот же, что в дереве библиотеки; название группы — + // полный путь, иначе две «Общие» в разных ветках не различить. + const groups: ChoiceGroup[] = categoryRows(categories.items) + .filter(({ category }) => (byCategory.get(category.id) ?? []).length > 0) + .map(({ category }) => ({ + key: `c${category.id}`, + title: knowledgeCategoryPath(categories.items, category.id) || category.name, + icon: "folder" as const, + choices: byCategory.get(category.id) ?? [], + })); + + // Категория материала могла не приехать в справочник (её удалили из-под + // открытой карточки) — такие материалы всё равно должны быть видны. + const shown = new Set(groups.flatMap((group) => group.choices.map((choice) => choice.key))); + const orphans = items + .filter((item) => !shown.has(`k${item.id}`)) + .map((item): KnowledgeChoice => ({ + key: `k${item.id}`, + kind: "knowledge", + id: item.id, + title: item.title, + meta: item.category.name, + })); + if (orphans.length > 0) { + groups.push({ key: "c0", title: t("ai.other_knowledge"), icon: "folder", choices: orphans }); + } + + portals.forEach((portal, index) => { + const choices = articleLists[index] + .filter((article) => article.status === "PUBLISHED") + .map((article): KnowledgeChoice => ({ + key: `a${article.id}`, + kind: "article", + id: article.id, + title: article.publishedRevision?.title ?? article.slug, + meta: t("ai.portal_named", { name: portal.name }), + })); + if (choices.length > 0) { + groups.push({ key: `p${portal.id}`, title: portal.name, icon: "globe", choices }); + } + }); + + return groups; +} + +/** Группы с материалами, подходящими под запрос; пустые группы уходят. */ +export function filterGroups(groups: ChoiceGroup[], search: string): ChoiceGroup[] { + const needle = search.trim().toLowerCase(); + if (!needle) return groups; + return groups + .map((group) => ({ ...group, choices: group.choices.filter((choice) => choice.title.toLowerCase().includes(needle)) })) + .filter((group) => group.choices.length > 0); +} + +export function pickedInGroup(group: ChoiceGroup, picked: ReadonlySet): number { + return group.choices.filter((choice) => picked.has(choice.key)).length; +} + +/** Идентификаторы выбранного по видам — то, что уходит на сервер. */ +export function pickedIds(groups: ChoiceGroup[], picked: ReadonlySet) { + const chosen = groups.flatMap((group) => group.choices).filter((choice) => picked.has(choice.key)); + return { + knowledgeIds: chosen.filter((choice) => choice.kind === "knowledge").map((choice) => choice.id), + articleIds: chosen.filter((choice) => choice.kind === "article").map((choice) => choice.id), + }; +} diff --git a/apps/internal-ui/src/features/agents/styles.css b/apps/internal-ui/src/features/agents/styles.css index b398c9b..8e692c7 100644 --- a/apps/internal-ui/src/features/agents/styles.css +++ b/apps/internal-ui/src/features/agents/styles.css @@ -391,13 +391,86 @@ margin-bottom: 10px; } +.agent-knowledge-summary { + margin: 0 0 6px; + color: var(--n-4); + font-size: 12px; + font-weight: 600; +} + .agent-knowledge-choices { display: flex; - max-height: 320px; + max-height: 380px; flex-direction: column; overflow-y: auto; } +/* Группа: категория библиотеки или портал. */ +.agent-knowledge-group + .agent-knowledge-group { + margin-top: 4px; +} + +.agent-knowledge-group-head { + display: flex; + align-items: center; + gap: 8px; + height: 34px; + padding-right: 2px; + border-top: 1px solid var(--n-9); +} + +.agent-knowledge-group-toggle { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 8px; + height: 30px; + padding: 0 6px 0 2px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--n-4); + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.agent-knowledge-group-toggle:hover { + background: var(--n-9); +} + +.agent-knowledge-group-toggle > svg:first-child { + flex: none; + transition: transform 140ms ease; +} + +.agent-knowledge-group-toggle.is-open > svg:first-child { + transform: rotate(90deg); +} + +.agent-knowledge-group-toggle strong { + min-width: 0; + flex: 1; + overflow: hidden; + color: var(--n-1); + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; +} + +.agent-knowledge-group-toggle small { + flex: none; + color: var(--n-5); + font-size: 11.5px; + font-weight: 600; +} + +.agent-knowledge-group .agent-knowledge-choice { + padding-left: 22px; +} + .agent-knowledge-choice { display: flex; align-items: center; diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeLibraryPage.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeLibraryPage.tsx index 66cdbe3..382a084 100644 --- a/apps/internal-ui/src/features/ai/knowledge/KnowledgeLibraryPage.tsx +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeLibraryPage.tsx @@ -1,5 +1,5 @@ import { Dropdown } from "antd"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { hasCapability } from "../../../auth/access"; import { Pagination } from "../../../shared/Pagination"; @@ -14,11 +14,10 @@ import { KnowledgeBulkBar } from "./KnowledgeBulkBar"; import { KnowledgeCategoryDialog } from "./KnowledgeCategoryDialog"; import { answerStateLabel, - categoryBranch, - categoryRows, knowledgeUpdatedAt, libraryTotals, } from "./knowledgeLibraryModel"; +import { KnowledgeSections } from "./KnowledgeSections"; import { knowledgeCategoryPath } from "./knowledgeTree"; import { bulkMoveKnowledge, @@ -64,7 +63,6 @@ export function KnowledgeLibraryPage({ const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - const rows = useMemo(() => categoryRows(library.categories), [library.categories]); const selectedCategory = library.filters.category; const selected = [...library.selectedIds]; @@ -166,42 +164,13 @@ export function KnowledgeLibraryPage({ {error &&
{error}
}
- + setRoute("knowledgeCategories")} + onSelect={(categoryId) => library.updateFilter("category", categoryId)} + />
@@ -376,7 +345,9 @@ export function KnowledgeLibraryPage({ note={t("ai.library_note", { count: tn("plural.knowledge", library.total), shown: library.items.length })} page={library.page} pageCount={library.pageCount} + pageSize={library.pageSize} onPage={library.setPage} + onPageSize={library.setPageSize} /> )} diff --git a/apps/internal-ui/src/features/ai/knowledge/KnowledgeSections.tsx b/apps/internal-ui/src/features/ai/knowledge/KnowledgeSections.tsx new file mode 100644 index 0000000..43890be --- /dev/null +++ b/apps/internal-ui/src/features/ai/knowledge/KnowledgeSections.tsx @@ -0,0 +1,112 @@ +import { useMemo, useState } from "react"; + +import { Icon } from "../../../shared/icons"; +import { t } from "../../../i18n"; +import { categoryRows, visibleCategoryRows } from "./knowledgeLibraryModel"; +import type { KnowledgeCategory } from "./model"; + +// Левая панель библиотеки знаний (кадр KB1): дерево категорий. +// +// Ветку с вложенными категориями сворачивают шевроном — у организации с +// трёхуровневым деревом список иначе занимает панель целиком. Свёрнутое +// помнит браузер: человек возвращается в раздел к тому же виду, что оставил. + +const STORAGE_KEY = "chatballs.ui.knowledge.collapsed"; + +function readCollapsed(): Set { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + const stored = raw ? (JSON.parse(raw) as unknown) : null; + return new Set(Array.isArray(stored) ? stored.filter((item): item is number => typeof item === "number") : []); + } catch { + return new Set(); + } +} + +function rememberCollapsed(collapsed: Set): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...collapsed])); + } catch { + /* без хранилища дерево просто не помнит свёрнутое между заходами */ + } +} + +export function KnowledgeSections({ categories, selected, canManage, onSelect, onManage }: { + categories: KnowledgeCategory[]; + selected: number | undefined; + canManage: boolean; + onSelect: (categoryId: number | undefined) => void; + onManage: () => void; +}) { + const [collapsed, setCollapsed] = useState>(readCollapsed); + const rows = useMemo(() => categoryRows(categories), [categories]); + const visible = useMemo(() => visibleCategoryRows(rows, collapsed), [rows, collapsed]); + const total = categories + .filter((category) => category.parentId === null) + .reduce((sum, category) => sum + (category.knowledgeCount ?? 0), 0); + + function toggle(categoryId: number) { + setCollapsed((current) => { + const next = new Set(current); + if (next.has(categoryId)) next.delete(categoryId); + else next.add(categoryId); + rememberCollapsed(next); + return next; + }); + } + + return ( + + ); +} diff --git a/apps/internal-ui/src/features/ai/knowledge/api.ts b/apps/internal-ui/src/features/ai/knowledge/api.ts index 8365f28..d4eb51a 100644 --- a/apps/internal-ui/src/features/ai/knowledge/api.ts +++ b/apps/internal-ui/src/features/ai/knowledge/api.ts @@ -1,3 +1,4 @@ +import { allPages } from "../../../shared/allPages"; import { api, apiUpload } from "../../../api/client"; import type { PagedPayload } from "../../../shared/usePagedResource"; import type { @@ -18,21 +19,31 @@ import type { KnowledgeUpdateRequest, } from "./types"; -function knowledgeListPath(filters: KnowledgeListFilters, page?: number): string { +function knowledgeListPath(filters: KnowledgeListFilters, page?: number, pageSize?: 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)); + if (pageSize !== undefined) query.set("pageSize", String(pageSize)); const suffix = query.toString(); return `/api/v1/ai/knowledge/${suffix ? `?${suffix}` : ""}`; } // Библиотека знаний приходит страницей: фильтры и ветка категорий отрабатывают // на сервере (кадр KB1). -export function fetchKnowledgeList(filters: KnowledgeListFilters = {}, page = 1) { - return api>(knowledgeListPath(filters, page)); +export function fetchKnowledgeList(filters: KnowledgeListFilters = {}, page = 1, pageSize?: number) { + return api>(knowledgeListPath(filters, page, pageSize)); +} + +// Потолок страницы на сервере (`api/pagination.py`, MAX_PAGE_SIZE). +const FULL_LIST_PAGE_SIZE = 100; + +/** Вся библиотека целиком, страницами по сотне: диалогу выбора знаний агента + * нужен весь набор, а не первая страница списка. */ +export function fetchAllKnowledge(filters: KnowledgeListFilters = {}): Promise { + return allPages((page) => fetchKnowledgeList(filters, page, FULL_LIST_PAGE_SIZE)); } export function fetchKnowledgeItem(id: number) { diff --git a/apps/internal-ui/src/features/ai/knowledge/knowledgeLibraryModel.ts b/apps/internal-ui/src/features/ai/knowledge/knowledgeLibraryModel.ts index b51f9f9..14a9114 100644 --- a/apps/internal-ui/src/features/ai/knowledge/knowledgeLibraryModel.ts +++ b/apps/internal-ui/src/features/ai/knowledge/knowledgeLibraryModel.ts @@ -10,6 +10,8 @@ export type CategoryRow = { category: KnowledgeCategory; depth: number; count: number; + /** Есть ли вложенные категории: только такую строку можно свернуть. */ + hasChildren: boolean; }; /** Категории в порядке дерева с глубиной вложенности (левая панель KB1). */ @@ -26,7 +28,12 @@ export function categoryRows(categories: KnowledgeCategory[]): CategoryRow[] { const rows: CategoryRow[] = []; const walk = (parentId: number | null, depth: number) => { (byParent.get(parentId) ?? []).forEach((category) => { - rows.push({ category, depth, count: category.knowledgeCount ?? 0 }); + rows.push({ + category, + depth, + count: category.knowledgeCount ?? 0, + hasChildren: (byParent.get(category.id) ?? []).length > 0, + }); walk(category.id, depth + 1); }); }; @@ -34,6 +41,24 @@ export function categoryRows(categories: KnowledgeCategory[]): CategoryRow[] { return rows; } +/** Строки дерева без содержимого свёрнутых веток. + * Строки идут обходом в глубину, поэтому потомки свёрнутой категории — это + * всё, что следует за ней глубже её уровня, до первой строки того же уровня. */ +export function visibleCategoryRows( + rows: CategoryRow[], + collapsed: ReadonlySet, +): CategoryRow[] { + const visible: CategoryRow[] = []; + let hiddenBelow: number | null = null; + rows.forEach((row) => { + if (hiddenBelow !== null && row.depth > hiddenBelow) return; + hiddenBelow = null; + visible.push(row); + if (row.hasChildren && collapsed.has(row.category.id)) hiddenBelow = row.depth; + }); + return visible; +} + /** Все потомки категории вместе с ней — выбор в дереве включает вложенные. */ export function categoryBranch(categories: KnowledgeCategory[], categoryId: number): Set { const branch = new Set(); diff --git a/apps/internal-ui/src/features/ai/knowledge/styles-library.css b/apps/internal-ui/src/features/ai/knowledge/styles-library.css index 7349794..185ca42 100644 --- a/apps/internal-ui/src/features/ai/knowledge/styles-library.css +++ b/apps/internal-ui/src/features/ai/knowledge/styles-library.css @@ -202,23 +202,16 @@ overflow-y: auto; } +/* Строка дерева: слева шеврон ветки, дальше — кнопка выбора категории. + Кнопка в кнопку вложить нельзя, поэтому строка — контейнер, а фон выделения + и наведения принадлежит ей, а не кнопке выбора. */ .knowledge-section-row { display: flex; min-height: 32px; align-items: center; - gap: 8px; - padding-top: 0; - padding-bottom: 0; - padding-right: 9px; - border: 0; border-radius: 8px; background: transparent; color: var(--n-2); - font-family: inherit; - font-size: 13px; - font-weight: 500; - text-align: left; - cursor: pointer; } .knowledge-section-row:hover { background: var(--n-9); } @@ -226,10 +219,55 @@ .knowledge-section-row.is-active { background: var(--primary-bg); color: var(--primary-text); - font-weight: 600; } -.knowledge-section-row > span { +/* У категории без вложенных на месте шеврона пустое место той же ширины: + иначе названия соседних строк разъезжались бы по левому краю. */ +.knowledge-section-twist { + display: inline-flex; + width: 18px; + height: 18px; + flex: none; + align-items: center; + justify-content: center; + margin-left: 3px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--n-5); + cursor: pointer; + transition: transform 140ms ease; +} + +button.knowledge-section-twist:hover { + background: var(--n-8); + color: var(--n-3); +} + +.knowledge-section-twist.is-open { transform: rotate(90deg); } + +.knowledge-section-pick { + display: flex; + min-width: 0; + min-height: 32px; + flex: 1; + align-items: center; + gap: 8px; + padding: 0 9px 0 6px; + border: 0; + border-radius: 8px; + background: transparent; + color: inherit; + font-family: inherit; + font-size: 13px; + font-weight: 500; + text-align: left; + cursor: pointer; +} + +.knowledge-section-row.is-active .knowledge-section-pick { font-weight: 600; } + +.knowledge-section-pick > span { min-width: 0; flex: 1; overflow: hidden; @@ -237,13 +275,13 @@ white-space: nowrap; } -.knowledge-section-row small { +.knowledge-section-pick small { color: var(--n-5); font-size: 11.5px; font-weight: 600; } -.knowledge-section-row.is-active small { color: var(--primary-text); } +.knowledge-section-row.is-active .knowledge-section-pick small { color: var(--primary-text); } .knowledge-sections-foot { flex: none; diff --git a/apps/internal-ui/src/features/ai/knowledge/useKnowledgeLibrary.ts b/apps/internal-ui/src/features/ai/knowledge/useKnowledgeLibrary.ts index ca10939..ea5ceab 100644 --- a/apps/internal-ui/src/features/ai/knowledge/useKnowledgeLibrary.ts +++ b/apps/internal-ui/src/features/ai/knowledge/useKnowledgeLibrary.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useDebounced } from "../../../shared/useDebounced"; import { usePagedResource } from "../../../shared/usePagedResource"; +import { usePageSize } from "../../../shared/usePageSize"; import { fetchKnowledgeCategories, fetchKnowledgeList, @@ -30,6 +31,10 @@ export function useKnowledgeLibrary() { const [categoriesError, setCategoriesError] = useState(false); const [selectedIds, setSelectedIds] = useState>(() => new Set()); const settledSearch = useDebounced(filters.search.trim()); + // Размер страницы — часть набора: при его смене список возвращается на + // первую страницу, иначе седьмая страница по двадцать превратилась бы в + // седьмую по сто и оказалась бы за концом набора. + const { pageSize, setPageSize } = usePageSize("knowledge"); const reloadCategories = useCallback(async () => { setCategoriesLoading(true); @@ -50,10 +55,14 @@ export function useKnowledgeLibrary() { isEnabled: filters.isEnabled, search: settledSearch, agents: filters.agents, + pageSize, }), - [filters.agents, filters.category, filters.isEnabled, settledSearch], + [filters.agents, filters.category, filters.isEnabled, pageSize, settledSearch], + ); + const load = useCallback( + (page: number) => fetchKnowledgeList(request, page, request.pageSize), + [request], ); - const load = useCallback((page: number) => fetchKnowledgeList(request, page), [request]); const page = usePagedResource(load, request, t("ai.could_not_load_knowledge")); useEffect(() => { @@ -117,6 +126,8 @@ export function useKnowledgeLibrary() { itemsLoading: page.loading, page: page.page, pageCount: page.pageCount, + pageSize: page.pageSize || pageSize, + setPageSize, total: page.total, setPage: page.setPage, reload, diff --git a/apps/internal-ui/src/features/chat/ChatPage.tsx b/apps/internal-ui/src/features/chat/ChatPage.tsx index 413ae72..94b4eda 100644 --- a/apps/internal-ui/src/features/chat/ChatPage.tsx +++ b/apps/internal-ui/src/features/chat/ChatPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, type ReactNode } from "react"; +import { isManager } from "../../auth/access"; import { ChatMobileHeader } from "../../layout/ChatMobileHeader"; import { Hint } from "../../shared/Hint"; import { Icon } from "../../shared/icons"; @@ -49,6 +50,7 @@ export function ChatPage({ return ( void; onSpam: () => Promise; onReturnQueue: () => void; - onArchive: () => Promise; + onDelete: () => Promise; }) { const [confirmSpam, setConfirmSpam] = useState(false); - const [confirmArchive, setConfirmArchive] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); const [busy, setBusy] = useState(false); if (!open) return null; @@ -36,10 +42,10 @@ export function ConversationActions({ } } - async function confirmArchiveAction() { + async function confirmDeleteAction() { setBusy(true); try { - if (await onArchive()) setConfirmArchive(false); + if (await onDelete()) setConfirmDelete(false); } finally { setBusy(false); } @@ -61,10 +67,12 @@ export function ConversationActions({ key: "spam", label: , }, - { - key: "archive", - label: , - }, + ...(canDelete + ? [{ + key: "delete", + label: , + }] + : []), ]; return ( @@ -85,15 +93,15 @@ export function ConversationActions({ } /> !busy && setConfirmArchive(false)} + open={confirmDelete} + onClose={() => !busy && setConfirmDelete(false)} tone="danger" icon="trash" title={t("conversations.delete_conversation_2")} - description={t("conversations.conversation_moves_archive_leaves_lists")} + description={t("conversations.conversation_and_history_gone_forever")} actions={<> - - + + } /> diff --git a/apps/internal-ui/src/features/conversations/ConversationThread.tsx b/apps/internal-ui/src/features/conversations/ConversationThread.tsx index c177d79..9718d31 100644 --- a/apps/internal-ui/src/features/conversations/ConversationThread.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationThread.tsx @@ -19,7 +19,7 @@ function fmtTime(value: string): string { return fmt.time(value); } -export function ConversationThread({ controlMode, dialog, detail, history, isOwner = false, onClaim, onRelease, onClose, onSpam, onReturnQueue, onArchive, onToggleContext, onMobileBack, onExpandList, viewerId = null }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; history: ConversationHistory; isOwner?: boolean; onClaim: () => void; onRelease: () => void; onClose: () => void; onSpam: () => Promise; onReturnQueue: () => void; onArchive: () => Promise; onToggleContext?: () => void; onMobileBack?: () => void; onExpandList?: () => void; viewerId?: number | null }) { +export function ConversationThread({ controlMode, dialog, detail, history, isOwner = false, onClaim, onRelease, onClose, onSpam, onReturnQueue, canDelete = false, onDelete, onToggleContext, onMobileBack, onExpandList, viewerId = null }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; history: ConversationHistory; isOwner?: boolean; onClaim: () => void; onRelease: () => void; onClose: () => void; onSpam: () => Promise; onReturnQueue: () => void; canDelete?: boolean; onDelete: () => Promise; onToggleContext?: () => void; onMobileBack?: () => void; onExpandList?: () => void; viewerId?: number | null }) { const timelineRef = useRef(null); const messages = history.messages; // Лента держит низ при новых репликах и догружает предыдущие при подходе к @@ -71,7 +71,7 @@ export function ConversationThread({ controlMode, dialog, detail, history, isOwn {controlMode === "human" && } {/* Кадр S2: на узком экране контекст-панель — выдвижная, кнопка в шапке. */} {onToggleContext && } - +
diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index 99fe719..92ef56f 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { ApiError } from "../../api/client"; import { CallOverlay } from "./CallOverlay"; import { Composer } from "./Composer"; import { ConversationThread } from "./ConversationThread"; @@ -9,10 +10,10 @@ import { claimConversation, closeConversation, controlModeOf, + deleteConversation, fetchConversation, markConversationAsSpam, releaseConversation, - setConversationArchived, returnToQueue, toConversationListItem, type ApiConversation, @@ -55,8 +56,10 @@ import { t } from "../../i18n"; // backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком, // placeholder поиска и правой панелью через render-prop. Список и история — // серверные окна: ни то, ни другое целиком не запрашивается. -export function ConversationWorkspace({ isOwner = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: { +export function ConversationWorkspace({ isOwner = false, canDelete = false, viewerId = null, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: { isOwner?: boolean; + /** Удалять диалоги могут владелец и администратор (то же проверяет сервер). */ + canDelete?: boolean; listTitle?: string; searchPlaceholder?: string; renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void; startCall: ((kind: "AUDIO" | "VIDEO") => void) | null; closeContext: () => void; assignmentTimeoutMinutes?: number }) => ReactNode; @@ -119,8 +122,17 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi setDetail(loaded); setDetailError(""); } - } catch { - if (selectedIdRef.current === id) setDetailError(t("conversations.could_not_load_conversation")); + } catch (error) { + if (selectedIdRef.current !== id) return; + // Диалог удалили — возможно, другим администратором. Карточки больше + // нет, и держать выбор не на чем; список обновит событие инбокса. + if (error instanceof ApiError && error.status === 404) { + setSelectedId(null); + setDetail(null); + setDetailError(""); + return; + } + setDetailError(t("conversations.could_not_load_conversation")); } }, []); @@ -193,11 +205,21 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi const onReturnQueue = () => { void updateConversation(returnToQueue); }; const onClose = () => { void updateConversation(closeConversation); }; const onSpam = () => updateConversation(markConversationAsSpam); - const onArchive = async () => { - const done = await updateConversation((id) => setConversationArchived(id, true)); - // Архивный диалог исчезает из списка — снимаем выбор. - if (done) setSelectedId(null); - return done; + // Удаление — не действие над диалогом, а его конец: обновлять нечего, из + // списка он уходит вместе с перепиской. + const onDelete = async () => { + if (selectedId == null) return false; + setActionError(""); + try { + await deleteConversation(selectedId); + setDetail(null); + setSelectedId(null); + void list.refresh(); + return true; + } catch (error) { + setActionError(error instanceof Error ? error.message : t("conversations.could_not_delete_conversation")); + return false; + } }; return ( @@ -246,7 +268,7 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi {selectedDialog && (
{ctxOpen && + + ); +} diff --git a/apps/internal-ui/src/shared/Pagination.tsx b/apps/internal-ui/src/shared/Pagination.tsx index e0d4fb5..350de50 100644 --- a/apps/internal-ui/src/shared/Pagination.tsx +++ b/apps/internal-ui/src/shared/Pagination.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { Icon } from "./icons"; +import { PageSizeSelect } from "./PageSizeSelect"; import { paginationItems } from "./ui-controls"; import "./pagination.css"; import { t } from "../i18n"; @@ -15,6 +16,8 @@ export function Pagination({ onPage, note, className = "", + pageSize, + onPageSize, }: { page: number; pageCount: number; @@ -22,10 +25,16 @@ export function Pagination({ /** Подпись слева: «Показано 20 из 45», подсказка про архив и т.п. */ note?: ReactNode; className?: string; + /** Размер страницы; вместе с `onPageSize` включает выбор «На странице». */ + pageSize?: number; + onPageSize?: (pageSize: number) => void; }) { return (
- {note} + + {note} + {pageSize !== undefined && onPageSize && } + + + {bars.map((height, index) => ( + + ))} + + {formatSeconds(durationSeconds ?? 0)} +
+ {failed && ( +
+ {t("chat.could_not_play")} +
+ )} +
+ ); +} diff --git a/apps/web-chat/src/api.ts b/apps/web-chat/src/api.ts index d47fd33..ed9aae1 100644 --- a/apps/web-chat/src/api.ts +++ b/apps/web-chat/src/api.ts @@ -46,7 +46,9 @@ export const isVideoCall = (call: CallInfo | null | undefined): boolean => callK export type CallBootstrap = { call: CallInfo; accessToken: string; iceServers: RTCIceServer[] }; export type CallStateEnvelope = { call: CallInfo; iceServers: RTCIceServer[] }; -export type Poll = { state: "ai" | "operator" | "waiting"; lifecycle: string; messages: WebMessage[]; call?: CallInfo | null }; +// reset — диалог, который показывал виджет, удалили в рабочем месте: ленту +// надо начать с чистого листа, иначе клиент видит переписку, которой нет. +export type Poll = { state: "ai" | "operator" | "waiting"; lifecycle: string; messages: WebMessage[]; call?: CallInfo | null; reset?: boolean }; export type WidgetEntry = { widgetKey?: string; channel?: string }; diff --git a/apps/web-chat/src/i18n/en.ts b/apps/web-chat/src/i18n/en.ts index c29dcfe..3527c57 100644 --- a/apps/web-chat/src/i18n/en.ts +++ b/apps/web-chat/src/i18n/en.ts @@ -23,6 +23,9 @@ export const en: Record = { "chat.message_placeholder": "Write a message…", "chat.operator_answering": "A specialist is answering", "chat.passing_to_operator": "Passing to an operator", + "chat.pause": "Pause", + "chat.play": "Play", + "chat.could_not_play": "Could not play the recording", "chat.record_voice": "Record a voice message", "chat.recording": "Recording", "chat.remove_file": "Remove the file", diff --git a/apps/web-chat/src/i18n/ru.ts b/apps/web-chat/src/i18n/ru.ts index 69ded7f..0dc0b4b 100644 --- a/apps/web-chat/src/i18n/ru.ts +++ b/apps/web-chat/src/i18n/ru.ts @@ -24,6 +24,9 @@ export const ru = { "chat.message_placeholder": "Напишите сообщение…", "chat.operator_answering": "Отвечает специалист", "chat.passing_to_operator": "Передаём оператору", + "chat.pause": "Пауза", + "chat.play": "Воспроизвести", + "chat.could_not_play": "Не удалось воспроизвести запись", "chat.record_voice": "Записать голосовое", "chat.recording": "Идёт запись", "chat.remove_file": "Убрать файл", diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 95899ee..7ff2466 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -5,3 +5,4 @@ export { type AudioBarKind, type AudioCallMode, type AudioCallStatus, buildAudio export { useCallRtcSession, type CallMediaIssue } from "./call/useCallRtcSession"; export { useAudioCue, useLoopingAudio } from "./audio/useAudio"; export { Loader } from "./loader/Loader"; +export { VOICE_WAVE_BARS, voiceWaveHeights } from "./voice/voiceWave"; diff --git a/packages/ui/src/voice/voiceWave.ts b/packages/ui/src/voice/voiceWave.ts new file mode 100644 index 0000000..f4f8899 --- /dev/null +++ b/packages/ui/src/voice/voiceWave.ts @@ -0,0 +1,18 @@ +// Волна голосового сообщения. +// +// Реальная амплитуда записи не хранится, поэтому рисунок считается из +// идентификатора сообщения: он стабилен между рендерами и одинаков в чате +// оператора и в виджете клиента — одно и то же сообщение выглядит одинаково с +// обеих сторон разговора. + +export const VOICE_WAVE_BARS = 32; + +export function voiceWaveHeights(seed: number, bars = VOICE_WAVE_BARS): number[] { + const heights: number[] = []; + let value = seed || 1; + for (let index = 0; index < bars; index += 1) { + value = (value * 1103515245 + 12345) % 2147483648; + heights.push(4 + (value % 17)); + } + return heights; +}