mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat: удаление диалога, дерево знаний и перетаскиваемый онбординг
«Удалить диалог» удаляет: переписку, вложения, звонки и уведомления о нём. Право только у владельца и администратора, писать в удалённый диалог некуда, лента виджета у клиента обнуляется по признаку reset в поллинге. База знаний: категории слева сворачиваются и разворачиваются (свёрнутое помнит браузер), в подвале появился выбор «На странице» 20/50/100 — общий для всех списков компонент. Окно «Знания» карточки агента разложено по категориям и порталам со счётчиками и «Выбрать все»; набор материалов грузится целиком, а не первой страницей. Пилюля онбординга перетаскивается и помнит место. Голосовое в веб-чате нарисовано плеером оператора без расшифровки, рисунок волны вынесен в общий пакет. Убрана горизонтальная полоса прокрутки в меню меток. Проверено: typecheck internal-ui и web-chat, backend-тесты диалогов, виджета и i18n. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5dbc062b1e
commit
733b44f186
44 files changed
+1263
-207
No files matched your search
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Диалог закрыт",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>;
|
||||
}) {
|
||||
const [choices, setChoices] = useState<Choice[] | null>(null);
|
||||
const [groups, setGroups] = useState<ChoiceGroup[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [closed, setClosed] = useState<Set<string>>(() => new Set());
|
||||
const [picked, setPicked] = useState<Set<string>>(
|
||||
() => 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 (
|
||||
<Modal className="agent-knowledge-modal" open width={560} title={t("ai.select_knowledge")} onCancel={onClose} footer={null} destroyOnClose>
|
||||
<Modal className="agent-knowledge-modal" open width={620} title={t("ai.select_knowledge")} onCancel={onClose} footer={null} destroyOnClose>
|
||||
<p className="agent-create-lead">{t("ai.agent_answers_only_from_selected")}</p>
|
||||
<SearchInput className="agent-knowledge-search" placeholder={t("ai.search_by_title")} value={search} onChange={setSearch} />
|
||||
{choices === null && !failed && <LoadingState />}
|
||||
{groups === null && !failed && <LoadingState />}
|
||||
{failed && <div className="agent-form-error">{t("ai.could_not_load_material_list")}</div>}
|
||||
{choices !== null && (
|
||||
<div className="agent-knowledge-choices">
|
||||
{visible.length === 0 && <p className="agent-knowledge-nothing">{t("common.nothing_found")}</p>}
|
||||
{visible.map((choice) => (
|
||||
<label className={`agent-knowledge-choice ${picked.has(choice.key) ? "is-picked" : ""}`} key={choice.key}>
|
||||
<input type="checkbox" checked={picked.has(choice.key)} onChange={() => toggle(choice.key)} />
|
||||
<Icon name={choice.icon} size={15} strokeWidth={1.9} />
|
||||
<strong>{choice.title}</strong>
|
||||
<small>{choice.meta}</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{groups !== null && (
|
||||
<>
|
||||
<div className="agent-knowledge-summary">{t("common.selected_count", { count: picked.size })}</div>
|
||||
<div className="agent-knowledge-choices">
|
||||
{visible.length === 0 && <p className="agent-knowledge-nothing">{t("common.nothing_found")}</p>}
|
||||
{visible.map((group) => (
|
||||
<AgentKnowledgeGroup
|
||||
group={group}
|
||||
key={group.key}
|
||||
open={searching || !closed.has(group.key)}
|
||||
picked={picked}
|
||||
onToggleAll={toggle}
|
||||
onToggleChoice={(key) => 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;
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && <div className="agent-form-error">{error}</div>}
|
||||
<div className="agent-create-actions">
|
||||
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button variant="primary" disabled={choices === null || saving} onClick={() => void submit()}>
|
||||
<Button variant="primary" disabled={groups === null || saving} onClick={() => void submit()}>
|
||||
{saving ? t("ai.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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<string>;
|
||||
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 (
|
||||
<div className="agent-knowledge-group">
|
||||
<div className="agent-knowledge-group-head">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className={`agent-knowledge-group-toggle${open ? " is-open" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggleOpen}
|
||||
>
|
||||
<Icon name="chevronRight" size={13} strokeWidth={2.2} />
|
||||
<Icon name={group.icon} size={14} strokeWidth={1.8} />
|
||||
<strong>{group.title}</strong>
|
||||
<small>{inGroup > 0 ? `${inGroup} / ${group.choices.length}` : group.choices.length}</small>
|
||||
</button>
|
||||
<button
|
||||
className="link is-muted"
|
||||
type="button"
|
||||
onClick={() => onToggleAll(group.choices.map((choice) => choice.key), !allPicked)}
|
||||
>
|
||||
{allPicked ? t("ai.clear_selection") : t("ai.select_all")}
|
||||
</button>
|
||||
</div>
|
||||
{open && group.choices.map((choice) => (
|
||||
<label className={`agent-knowledge-choice ${picked.has(choice.key) ? "is-picked" : ""}`} key={choice.key}>
|
||||
<input type="checkbox" checked={picked.has(choice.key)} onChange={() => onToggleChoice(choice.key)} />
|
||||
<strong>{choice.title}</strong>
|
||||
<small>{choice.meta}</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ChoiceGroup[]> {
|
||||
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<number, KnowledgeChoice[]>();
|
||||
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<string>): number {
|
||||
return group.choices.filter((choice) => picked.has(choice.key)).length;
|
||||
}
|
||||
|
||||
/** Идентификаторы выбранного по видам — то, что уходит на сервер. */
|
||||
export function pickedIds(groups: ChoiceGroup[], picked: ReadonlySet<string>) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && <div className="knowledge-form-error">{error}</div>}
|
||||
|
||||
<div className="knowledge-library">
|
||||
<aside className="knowledge-sections">
|
||||
<header>
|
||||
<span>{t("ai.categories")}</span>
|
||||
{canManage && <button type="button" onClick={() => setRoute("knowledgeCategories")}>{t("ai.manage")}</button>}
|
||||
</header>
|
||||
<nav>
|
||||
<button
|
||||
className={`knowledge-section-row${selectedCategory === undefined ? " is-active" : ""}`}
|
||||
style={{ paddingLeft: 9 }}
|
||||
type="button"
|
||||
onClick={() => library.updateFilter("category", undefined)}
|
||||
>
|
||||
<Icon name="folder" size={15} strokeWidth={1.8} />
|
||||
<span>{t("ai.all_knowledge")}</span>
|
||||
<small>{library.categories.filter((item) => item.parentId === null).reduce((total, item) => total + (item.knowledgeCount ?? 0), 0)}</small>
|
||||
</button>
|
||||
{rows.map(({ category, depth, count }) => (
|
||||
<button
|
||||
className={`knowledge-section-row${selectedCategory === category.id ? " is-active" : ""}`}
|
||||
key={category.id}
|
||||
style={{ paddingLeft: 9 + depth * 16 }}
|
||||
type="button"
|
||||
onClick={() => library.updateFilter("category", category.id)}
|
||||
>
|
||||
<span>{category.name}</span>
|
||||
<small>{count}</small>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{canManage && (
|
||||
<div className="knowledge-sections-foot">
|
||||
<button type="button" onClick={() => setRoute("knowledgeCategories")}>
|
||||
<Icon name="plus" size={14} strokeWidth={2} />{t("ai.add_category")}</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<KnowledgeSections
|
||||
canManage={canManage}
|
||||
categories={library.categories}
|
||||
selected={selectedCategory}
|
||||
onManage={() => setRoute("knowledgeCategories")}
|
||||
onSelect={(categoryId) => library.updateFilter("category", categoryId)}
|
||||
/>
|
||||
|
||||
<div className="knowledge-library-main">
|
||||
<div className="knowledge-library-inner">
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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<number> {
|
||||
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<number>): 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<Set<number>>(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 (
|
||||
<aside className="knowledge-sections">
|
||||
<header>
|
||||
<span>{t("ai.categories")}</span>
|
||||
{canManage && <button type="button" onClick={onManage}>{t("ai.manage")}</button>}
|
||||
</header>
|
||||
<nav>
|
||||
<div className={`knowledge-section-row${selected === undefined ? " is-active" : ""}`}>
|
||||
<i className="knowledge-section-twist" />
|
||||
<button className="knowledge-section-pick" type="button" onClick={() => onSelect(undefined)}>
|
||||
<Icon name="folder" size={15} strokeWidth={1.8} />
|
||||
<span>{t("ai.all_knowledge")}</span>
|
||||
<small>{total}</small>
|
||||
</button>
|
||||
</div>
|
||||
{visible.map(({ category, depth, count, hasChildren }) => {
|
||||
const open = !collapsed.has(category.id);
|
||||
return (
|
||||
<div
|
||||
className={`knowledge-section-row${selected === category.id ? " is-active" : ""}`}
|
||||
key={category.id}
|
||||
style={{ paddingLeft: depth * 16 }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-label={open
|
||||
? t("ai.collapse_category", { name: category.name })
|
||||
: t("ai.expand_category", { name: category.name })}
|
||||
className={`knowledge-section-twist${open ? " is-open" : ""}`}
|
||||
type="button"
|
||||
onClick={() => toggle(category.id)}
|
||||
>
|
||||
<Icon name="chevronRight" size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
) : (
|
||||
<i className="knowledge-section-twist" />
|
||||
)}
|
||||
<button className="knowledge-section-pick" type="button" onClick={() => onSelect(category.id)}>
|
||||
<span>{category.name}</span>
|
||||
<small>{count}</small>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{canManage && (
|
||||
<div className="knowledge-sections-foot">
|
||||
<button type="button" onClick={onManage}>
|
||||
<Icon name="plus" size={14} strokeWidth={2} />{t("ai.add_category")}</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -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<PagedPayload<KnowledgeItem>>(knowledgeListPath(filters, page));
|
||||
export function fetchKnowledgeList(filters: KnowledgeListFilters = {}, page = 1, pageSize?: number) {
|
||||
return api<PagedPayload<KnowledgeItem>>(knowledgeListPath(filters, page, pageSize));
|
||||
}
|
||||
|
||||
// Потолок страницы на сервере (`api/pagination.py`, MAX_PAGE_SIZE).
|
||||
const FULL_LIST_PAGE_SIZE = 100;
|
||||
|
||||
/** Вся библиотека целиком, страницами по сотне: диалогу выбора знаний агента
|
||||
* нужен весь набор, а не первая страница списка. */
|
||||
export function fetchAllKnowledge(filters: KnowledgeListFilters = {}): Promise<KnowledgeItem[]> {
|
||||
return allPages((page) => fetchKnowledgeList(filters, page, FULL_LIST_PAGE_SIZE));
|
||||
}
|
||||
|
||||
export function fetchKnowledgeItem(id: number) {
|
||||
|
||||
@@ -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<number>,
|
||||
): 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<number> {
|
||||
const branch = new Set<number>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Set<number>>(() => 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,
|
||||
|
||||
@@ -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 (
|
||||
<ConversationWorkspace
|
||||
isOwner={user.role === "OWNER"}
|
||||
canDelete={isManager(user)}
|
||||
initialConversationId={initialConversationId}
|
||||
scope={scope}
|
||||
setScope={setScope}
|
||||
|
||||
@@ -6,23 +6,29 @@ import { Icon } from "../../shared/icons";
|
||||
import { Button } from "../../shared/ui-controls";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Меню «⋯» над перепиской: вернуть в очередь, закрыть, спам, удалить.
|
||||
// Удаление стирает диалог вместе с перепиской и доступно только владельцу и
|
||||
// администратору — у оператора этого пункта в меню нет.
|
||||
|
||||
export function ConversationActions({
|
||||
open,
|
||||
canReturnQueue,
|
||||
canDelete,
|
||||
onClose,
|
||||
onSpam,
|
||||
onReturnQueue,
|
||||
onArchive,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
canReturnQueue: boolean;
|
||||
canDelete: boolean;
|
||||
onClose: () => void;
|
||||
onSpam: () => Promise<boolean>;
|
||||
onReturnQueue: () => void;
|
||||
onArchive: () => Promise<boolean>;
|
||||
onDelete: () => Promise<boolean>;
|
||||
}) {
|
||||
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: <button className="danger" type="button" onClick={() => setConfirmSpam(true)}><Icon name="warning" size={15} />{t("conversations.mark_as_spam")}</button>,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: <button className="danger" type="button" onClick={() => setConfirmArchive(true)}><Icon name="trash" size={15} />{t("conversations.delete_conversation")}</button>,
|
||||
},
|
||||
...(canDelete
|
||||
? [{
|
||||
key: "delete",
|
||||
label: <button className="danger" type="button" onClick={() => setConfirmDelete(true)}><Icon name="trash" size={15} />{t("conversations.delete_conversation")}</button>,
|
||||
}]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -85,15 +93,15 @@ export function ConversationActions({
|
||||
</>}
|
||||
/>
|
||||
<DecisionDialog
|
||||
open={confirmArchive}
|
||||
onClose={() => !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={<>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => setConfirmArchive(false)}>{t("common.cancel")}</Button>
|
||||
<Button variant="danger-outline" icon="trash" disabled={busy} onClick={() => void confirmArchiveAction()}>{t("common.delete")}</Button>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => setConfirmDelete(false)}>{t("common.cancel")}</Button>
|
||||
<Button variant="danger-outline" icon="trash" disabled={busy} onClick={() => void confirmDeleteAction()}>{t("common.delete")}</Button>
|
||||
</>}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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<boolean>; onReturnQueue: () => void; onArchive: () => Promise<boolean>; 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<boolean>; onReturnQueue: () => void; canDelete?: boolean; onDelete: () => Promise<boolean>; onToggleContext?: () => void; onMobileBack?: () => void; onExpandList?: () => void; viewerId?: number | null }) {
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const messages = history.messages;
|
||||
// Лента держит низ при новых репликах и догружает предыдущие при подходе к
|
||||
@@ -71,7 +71,7 @@ export function ConversationThread({ controlMode, dialog, detail, history, isOwn
|
||||
{controlMode === "human" && <button className="sales-secondary-action" onClick={onRelease}>{t("conversations.hand_back_ai")}</button>}
|
||||
{/* Кадр S2: на узком экране контекст-панель — выдвижная, кнопка в шапке. */}
|
||||
{onToggleContext && <IconButton icon="user" label={t("conversations.conversation_context")} className="ctx-toggle" onClick={onToggleContext} />}
|
||||
<ConversationActions open={detail?.lifecycle === "OPEN"} canReturnQueue={controlMode === "human"} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} />
|
||||
<ConversationActions open={detail?.lifecycle === "OPEN"} canReturnQueue={controlMode === "human"} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} canDelete={canDelete} onDelete={onDelete} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="sales-timeline" ref={timelineRef} onScroll={onScroll}>
|
||||
|
||||
@@ -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 && (
|
||||
<section className="sales-conversation enter-surface" key={selectedDialog.id}>
|
||||
{ctxOpen && <button className="ctx-backdrop" type="button" aria-label={t("admin.close_panel")} onClick={() => setCtxOpen(false)} />}
|
||||
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} history={history} isOwner={isOwner} onExpandList={listCollapsed ? () => setListCollapsed(false) : undefined} viewerId={viewerId} onClaim={onClaim} onRelease={onRelease} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} />
|
||||
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} history={history} isOwner={isOwner} onExpandList={listCollapsed ? () => setListCollapsed(false) : undefined} viewerId={viewerId} onClaim={onClaim} onRelease={onRelease} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} canDelete={canDelete} onDelete={onDelete} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} />
|
||||
{(detailError || actionError || history.errorText) && <div className="sales-conversation-error">{detailError || actionError || history.errorText}</div>}
|
||||
<CallOverlay
|
||||
open={callController.open}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
agentColorOf,
|
||||
controlModeOf,
|
||||
groupColorOf,
|
||||
setConversationArchived,
|
||||
setConversationAssignee,
|
||||
setConversationGroup,
|
||||
setConversationNote,
|
||||
@@ -198,19 +197,3 @@ export function startedLabel(iso: string): string {
|
||||
return fmt.shortDateTime(date);
|
||||
}
|
||||
|
||||
export function archiveConversationAction(
|
||||
detail: ApiConversation,
|
||||
applyConversation: (updated: ApiConversation) => void,
|
||||
onError: (text: string) => void,
|
||||
): Promise<boolean> {
|
||||
return setConversationArchived(detail.id, true)
|
||||
.then((updated) => {
|
||||
applyConversation(updated);
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error instanceof Error ? error.message : t("conversations.could_not_delete_conversation"));
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { voiceWaveHeights } from "@chatballs/ui";
|
||||
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { resolveApiUrl } from "../../api/client";
|
||||
import { transcribeMessage, type ApiMessage } from "./model";
|
||||
@@ -13,18 +15,6 @@ export function formatDuration(totalSeconds: number): string {
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Детерминированная волна из id: реальная амплитуда не хранится, рисунок
|
||||
// стабилен между рендерами (как в макете).
|
||||
function waveHeights(seed: number, bars = 32): 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;
|
||||
}
|
||||
|
||||
export function VoiceMessage({ message: incoming }: { message: ApiMessage }) {
|
||||
// Локальная копия: расшифровка приходит из POST-ответа сразу, а фоновый
|
||||
// поллинг detail затем подтверждает её с сервера.
|
||||
@@ -38,7 +28,7 @@ export function VoiceMessage({ message: incoming }: { message: ApiMessage }) {
|
||||
const [transcribing, setTranscribing] = useState(false);
|
||||
const [showTranscript, setShowTranscript] = useState(true);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const bars = waveHeights(message.id);
|
||||
const bars = voiceWaveHeights(message.id);
|
||||
|
||||
useEffect(() => () => {
|
||||
audioRef.current?.pause();
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/* Меню меток: список готовых меток и строка «новая метка».
|
||||
|
||||
Меню antd — вертикальное, а значит с `overflow-y: auto`; по правилам CSS
|
||||
вторая ось тогда тоже перестаёт быть `visible` и любой лишний пиксель строки
|
||||
ввода превращается в горизонтальную полосу прокрутки под полем. Полосы здесь
|
||||
быть не должно: строка обязана вписываться в ширину меню. */
|
||||
.app-dropdown.ctx-labels-menu .ant-dropdown-menu {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-dropdown.ctx-labels-menu .ant-dropdown-menu-title-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-dropdown.ctx-labels-menu .ctx-new-label {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Keep the inline add button from inheriting full-width menu item styles. */
|
||||
.app-dropdown.ctx-labels-menu .ctx-new-label button {
|
||||
width: 30px;
|
||||
|
||||
@@ -351,6 +351,9 @@ export const setConversationLabels = (id: number, labelIds: number[]) =>
|
||||
conversationAction(id, "labels", { labelIds });
|
||||
export const setConversationArchived = (id: number, archived: boolean) =>
|
||||
conversationAction(id, "archive", { archived });
|
||||
/** Удалить диалог вместе с перепиской. Право — у владельца и администратора. */
|
||||
export const deleteConversation = (id: number) =>
|
||||
api<void>(`/api/v1/conversations/${id}/`, { method: "DELETE" });
|
||||
export const setConversationGroup = (id: number, groupId: number | null) =>
|
||||
conversationAction(id, "group", { groupId });
|
||||
export const setConversationAssignee = (id: number, userId: number | null) =>
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { useDraggablePosition } from "../../shared/useDraggablePosition";
|
||||
import { t } from "../../i18n";
|
||||
import { useOnboarding } from "./useOnboarding";
|
||||
|
||||
// Пилюля возврата в правом нижнем углу рабочей области. Появляется, когда
|
||||
// окно закрыто, тур не идёт, а настроено ещё не всё: закрыть онбординг можно
|
||||
// одним кликом, но дорогу назад стоит оставить на виду.
|
||||
//
|
||||
// Пилюлю перетаскивают: в правом нижнем углу она закрывает то кнопку отправки,
|
||||
// то последнее сообщение ленты. Место, куда её отвели, помнит браузер.
|
||||
|
||||
export function OnboardingLauncher() {
|
||||
const onboarding = useOnboarding();
|
||||
const drag = useDraggablePosition<HTMLButtonElement>("onboarding-launcher", { right: 24, bottom: 24 });
|
||||
if (!onboarding?.showLauncher) return null;
|
||||
return (
|
||||
<button className="ob-launcher" type="button" onClick={onboarding.open}>
|
||||
<button
|
||||
className={`ob-launcher${drag.dragging ? " is-dragging" : ""}`}
|
||||
ref={drag.ref}
|
||||
style={drag.style}
|
||||
type="button"
|
||||
onPointerDown={drag.onPointerDown}
|
||||
onClick={() => { if (!drag.wasDragged()) onboarding.open(); }}
|
||||
>
|
||||
<Icon name="sparkles" size={17} strokeWidth={1.9} />
|
||||
{t("onboarding.title")}
|
||||
<small>{t("onboarding.progress_short", { done: onboarding.doneCount, total: onboarding.steps.length })}</small>
|
||||
|
||||
@@ -887,11 +887,16 @@
|
||||
|
||||
/* ── Точки возврата ─────────────────────────────────────────────────────── */
|
||||
|
||||
/* Положение задаётся инлайном (`useDraggablePosition`): пилюлю перетаскивают,
|
||||
и её место помнит браузер. Значения в CSS остаются запасными — до первого
|
||||
рендера и на случай пустого хранилища. */
|
||||
.ob-launcher {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 20;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -910,6 +915,11 @@
|
||||
|
||||
.ob-launcher:hover { background: var(--primary-bg); }
|
||||
|
||||
.ob-launcher.is-dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 14px 34px -8px rgba(16, 18, 22, 0.42);
|
||||
}
|
||||
|
||||
.ob-launcher small {
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-pill);
|
||||
|
||||
@@ -254,9 +254,11 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.cannot_delete_named": "“{name}” cannot be deleted: {reason}. Move its contents out first.",
|
||||
"ai.categories": "CATEGORIES",
|
||||
"ai.categories_do_not_nest_deeper": "Categories do not nest deeper than three levels",
|
||||
"ai.collapse_category": "Collapse “{name}”",
|
||||
"ai.category": "Category",
|
||||
"ai.category_cannot_moved_inside_itself": "A category cannot be moved inside itself.",
|
||||
"ai.category_empty_will_delete": "The category “{name}” is empty and will be deleted.",
|
||||
"ai.expand_category": "Expand “{name}”",
|
||||
"ai.category_file_matches_current_one": "the category in the file matches the current one",
|
||||
"ai.category_has_no_items": "The category “{name}” has no knowledge yet",
|
||||
"ai.category_named": "category “{name}”",
|
||||
@@ -420,6 +422,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.pick_file": "Pick a file",
|
||||
"ai.pick_provider": "pick a provider",
|
||||
"ai.pick_them_from_disk": "pick them from disk",
|
||||
"ai.other_knowledge": "Other knowledge",
|
||||
"ai.portal_named": "Portal · {name}",
|
||||
"ai.preview": "Preview",
|
||||
"ai.preview_of": "PREVIEW · {what}",
|
||||
@@ -437,6 +440,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.search_by_title": "Search by title",
|
||||
"ai.search_by_title_description": "Search by title and description",
|
||||
"ai.select": "Select",
|
||||
"ai.select_all": "Select all",
|
||||
"ai.select_every_knowledge_item_page": "Select every knowledge item on the page",
|
||||
"ai.select_item": "Select “{title}”",
|
||||
"ai.select_knowledge": "Select knowledge",
|
||||
@@ -648,11 +652,11 @@ export const en: Record<MessageKey, Message> = {
|
||||
"conversations.connecting_call": "Connecting the call",
|
||||
"conversations.conversation": "Conversation",
|
||||
"conversations.conversation_actions": "Conversation actions",
|
||||
"conversations.conversation_and_history_gone_forever": "The conversation and the whole history will be deleted for good. The customer sees an empty chat and its notifications disappear.",
|
||||
"conversations.conversation_closed": "The conversation is closed",
|
||||
"conversations.conversation_context": "Conversation context",
|
||||
"conversations.conversation_has_ended": "The conversation has ended.",
|
||||
"conversations.conversation_list_width": "Conversation list width",
|
||||
"conversations.conversation_moves_archive_leaves_lists": "The conversation moves to the archive and leaves the lists. Only administrators see the archive.",
|
||||
"conversations.conversation_will_closed_replies_stay": "The conversation will be closed for replies and stay in history marked “Spam”.",
|
||||
"conversations.conversation_without_group_visible_every": "A conversation without a group is visible to every operator.",
|
||||
"conversations.conversations_appear_once_customers_write": "Conversations appear once customers write to your agent",
|
||||
@@ -1664,6 +1668,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"shared.new_knowledge_item": "New knowledge item",
|
||||
"shared.next_page": "Next page",
|
||||
"shared.orange": "Orange",
|
||||
"shared.per_page": "Per page:",
|
||||
"shared.pick_agent": "Pick an agent",
|
||||
"shared.portal_settings": "Portal settings",
|
||||
"shared.previous_page": "Previous page",
|
||||
|
||||
@@ -255,9 +255,11 @@ export const ru = {
|
||||
"ai.cannot_delete_named": "«{name}» удалить нельзя: {reason}. Сначала перенесите содержимое.",
|
||||
"ai.categories": "КАТЕГОРИИ",
|
||||
"ai.categories_do_not_nest_deeper": "Глубже трёх уровней категории не вкладываются",
|
||||
"ai.collapse_category": "Свернуть «{name}»",
|
||||
"ai.category": "Категория",
|
||||
"ai.category_cannot_moved_inside_itself": "Категорию нельзя перенести внутрь себя.",
|
||||
"ai.category_empty_will_delete": "Категория «{name}» пуста и будет удалена.",
|
||||
"ai.expand_category": "Развернуть «{name}»",
|
||||
"ai.category_file_matches_current_one": "категория в файле совпадает с текущей",
|
||||
"ai.category_has_no_items": "В категории «{name}» пока нет знаний",
|
||||
"ai.category_named": "категория «{name}»",
|
||||
@@ -421,6 +423,7 @@ export const ru = {
|
||||
"ai.pick_file": "Выбрать файл",
|
||||
"ai.pick_provider": "выберите провайдера",
|
||||
"ai.pick_them_from_disk": "выберите на диске",
|
||||
"ai.other_knowledge": "Прочие знания",
|
||||
"ai.portal_named": "Портал · {name}",
|
||||
"ai.preview": "Просмотр",
|
||||
"ai.preview_of": "ПРЕДПРОСМОТР · {what}",
|
||||
@@ -438,6 +441,7 @@ export const ru = {
|
||||
"ai.search_by_title": "Поиск по названию",
|
||||
"ai.search_by_title_description": "Поиск по заголовку и описанию",
|
||||
"ai.select": "Выбрать",
|
||||
"ai.select_all": "Выбрать все",
|
||||
"ai.select_every_knowledge_item_page": "Выбрать все знания на странице",
|
||||
"ai.select_item": "Выбрать «{title}»",
|
||||
"ai.select_knowledge": "Выбрать знания",
|
||||
@@ -649,11 +653,11 @@ export const ru = {
|
||||
"conversations.connecting_call": "Соединяем звонок",
|
||||
"conversations.conversation": "Диалог",
|
||||
"conversations.conversation_actions": "Действия диалога",
|
||||
"conversations.conversation_and_history_gone_forever": "Диалог и вся переписка будут удалены безвозвратно. Клиент увидит пустой чат, уведомления по нему исчезнут.",
|
||||
"conversations.conversation_closed": "Диалог закрыт",
|
||||
"conversations.conversation_context": "Контекст диалога",
|
||||
"conversations.conversation_has_ended": "Разговор завершён.",
|
||||
"conversations.conversation_list_width": "Ширина списка диалогов",
|
||||
"conversations.conversation_moves_archive_leaves_lists": "Диалог уйдёт в архив и исчезнет из списков. Архив видят только администраторы.",
|
||||
"conversations.conversation_will_closed_replies_stay": "Диалог будет закрыт для ответов и останется в истории со статусом «Спам».",
|
||||
"conversations.conversation_without_group_visible_every": "Диалог без группы видят все сотрудники.",
|
||||
"conversations.conversations_appear_once_customers_write": "Диалоги появятся, когда клиенты напишут вашему агенту",
|
||||
@@ -1665,6 +1669,7 @@ export const ru = {
|
||||
"shared.new_knowledge_item": "Новое знание",
|
||||
"shared.next_page": "Следующая страница",
|
||||
"shared.orange": "Оранжевый",
|
||||
"shared.per_page": "На странице:",
|
||||
"shared.pick_agent": "Выберите агента",
|
||||
"shared.portal_settings": "Настройки портала",
|
||||
"shared.previous_page": "Предыдущая страница",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Icon } from "./icons";
|
||||
import { SelectMenu } from "./ui-controls";
|
||||
import { PAGE_SIZE_OPTIONS } from "./usePageSize";
|
||||
import { t } from "../i18n";
|
||||
|
||||
// «На странице: 20» в подвале списка. Отдельный селект приложения заводить не
|
||||
// надо: это тот же `SelectMenu`, только с компактным триггером подвала.
|
||||
|
||||
export function PageSizeSelect({ value, onChange, options = PAGE_SIZE_OPTIONS }: {
|
||||
value: number;
|
||||
onChange: (pageSize: number) => void;
|
||||
options?: number[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<SelectMenu
|
||||
open={open}
|
||||
options={options.map((size) => ({ value: String(size), label: String(size) }))}
|
||||
overlayClassName="app-dropdown is-pager-size"
|
||||
selected={[String(value)]}
|
||||
onOpenChange={setOpen}
|
||||
onSelect={(next) => {
|
||||
setOpen(false);
|
||||
onChange(Number(next));
|
||||
}}
|
||||
>
|
||||
<button className="pager-size" type="button">
|
||||
<i>{t("shared.per_page")}</i>
|
||||
{value}
|
||||
<Icon name="chevron" size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
</SelectMenu>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`pager ${className}`.trim()}>
|
||||
<span className="pager-note">{note}</span>
|
||||
<span className="pager-note">
|
||||
{note}
|
||||
{pageSize !== undefined && onPageSize && <PageSizeSelect value={pageSize} onChange={onPageSize} />}
|
||||
</span>
|
||||
<span className="pager-pages">
|
||||
<button
|
||||
aria-label={t("shared.previous_page")}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PagedPayload } from "./usePagedResource";
|
||||
|
||||
// Весь набор постранично. Нужен там, где интерфейс работает не со страницей, а
|
||||
// с полным списком: выбор знаний агента, выгрузка. Обычные списки так грузить
|
||||
// нельзя — это ровно то, от чего уходили, когда вводили страницы.
|
||||
|
||||
export async function allPages<T>(load: (page: number) => Promise<PagedPayload<T>>): Promise<T[]> {
|
||||
const items: T[] = [];
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
const payload = await load(page);
|
||||
items.push(...payload.items);
|
||||
if (payload.items.length === 0 || payload.page >= payload.pageCount) return items;
|
||||
page = payload.page + 1;
|
||||
}
|
||||
}
|
||||
@@ -69,3 +69,36 @@
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* «На странице: 20» — выбор размера страницы рядом с подписью подвала. */
|
||||
.pager-size {
|
||||
display: inline-flex;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
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;
|
||||
}
|
||||
|
||||
.pager-size:hover {
|
||||
background: var(--n-9);
|
||||
}
|
||||
|
||||
.pager-size i {
|
||||
color: var(--n-5);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.pager-size > svg:last-child {
|
||||
color: var(--n-5);
|
||||
}
|
||||
|
||||
.app-dropdown.is-pager-size .ant-dropdown-menu {
|
||||
width: 108px;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
// Плавающий элемент, который человек перетаскивает мышью: положение считается
|
||||
// от правого нижнего угла окна и живёт в localStorage этого браузера.
|
||||
//
|
||||
// Отступ от края — от правого и нижнего, а не от левого и верхнего: элемент
|
||||
// прижат к этому углу и при изменении размера окна должен оставаться там же,
|
||||
// а не уезжать за границу вместе с координатой.
|
||||
|
||||
export type EdgeOffset = { right: number; bottom: number };
|
||||
|
||||
const STORAGE_PREFIX = "chatballs.ui.position.";
|
||||
// Ближе к краю элемент не подводим: кнопка, прижатая вплотную, читается как
|
||||
// обрезанная, а на краю окна её ещё и трудно поймать курсором.
|
||||
const EDGE_GAP = 8;
|
||||
// Сдвиг меньше этого — не перетаскивание, а дрожание руки при клике.
|
||||
const DRAG_THRESHOLD = 3;
|
||||
|
||||
function clamp(value: number, max: number): number {
|
||||
return Math.min(Math.max(value, EDGE_GAP), Math.max(EDGE_GAP, max));
|
||||
}
|
||||
|
||||
function readStored(key: string, fallback: EdgeOffset): EdgeOffset {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_PREFIX + key);
|
||||
if (!raw) return fallback;
|
||||
const stored = JSON.parse(raw) as Partial<EdgeOffset>;
|
||||
return Number.isFinite(stored.right) && Number.isFinite(stored.bottom)
|
||||
? { right: Number(stored.right), bottom: Number(stored.bottom) }
|
||||
: fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function useDraggablePosition<T extends HTMLElement>(key: string, fallback: EdgeOffset) {
|
||||
const [offset, setOffset] = useState<EdgeOffset>(() => readStored(key, fallback));
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const ref = useRef<T | null>(null);
|
||||
// Клик по элементу приходит и после перетаскивания: без этой отметки
|
||||
// отпущенная кнопка тут же срабатывала бы как нажатая.
|
||||
const moved = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(offset));
|
||||
} catch {
|
||||
/* без сохранения — положение держится до перезагрузки */
|
||||
}
|
||||
}, [key, offset]);
|
||||
|
||||
const fit = useCallback((next: EdgeOffset): EdgeOffset => {
|
||||
const box = ref.current?.getBoundingClientRect();
|
||||
const width = box?.width ?? 0;
|
||||
const height = box?.height ?? 0;
|
||||
return {
|
||||
right: clamp(next.right, window.innerWidth - width - EDGE_GAP),
|
||||
bottom: clamp(next.bottom, window.innerHeight - height - EDGE_GAP),
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Окно уменьшили — элемент возвращается в видимую область, иначе он остался
|
||||
// бы за краем и человек больше не смог бы до него дотянуться.
|
||||
useEffect(() => {
|
||||
const onResize = () => setOffset((current) => fit(current));
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [fit]);
|
||||
|
||||
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
const start = { x: event.clientX, y: event.clientY, ...offset };
|
||||
moved.current = false;
|
||||
setDragging(true);
|
||||
const move = (moveEvent: PointerEvent) => {
|
||||
const shiftX = start.x - moveEvent.clientX;
|
||||
const shiftY = start.y - moveEvent.clientY;
|
||||
if (Math.abs(shiftX) > DRAG_THRESHOLD || Math.abs(shiftY) > DRAG_THRESHOLD) moved.current = true;
|
||||
setOffset(fit({ right: start.right + shiftX, bottom: start.bottom + shiftY }));
|
||||
};
|
||||
const up = () => {
|
||||
setDragging(false);
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
document.body.style.userSelect = "none";
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
}, [fit, offset]);
|
||||
|
||||
const style: CSSProperties = { right: offset.right, bottom: offset.bottom };
|
||||
return { dragging, onPointerDown, ref, style, wasDragged: () => moved.current };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
// Сколько записей показывать на странице. Выбор принадлежит человеку и его
|
||||
// браузеру: у одного список на ноутбуке, у другого — на широком мониторе, и
|
||||
// навязывать обоим одно число незачем. Верхняя граница — потолок сервера
|
||||
// (`api/pagination.py`, MAX_PAGE_SIZE).
|
||||
|
||||
export const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
const STORAGE_PREFIX = "chatballs.ui.pageSize.";
|
||||
|
||||
function readStored(key: string, fallback: number): number {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_PREFIX + key);
|
||||
const value = raw ? Number(raw) : NaN;
|
||||
return PAGE_SIZE_OPTIONS.includes(value) ? value : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function usePageSize(key: string, fallback = DEFAULT_PAGE_SIZE) {
|
||||
const [pageSize, setStored] = useState(() => readStored(key, fallback));
|
||||
|
||||
const setPageSize = useCallback((next: number) => {
|
||||
setStored(next);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_PREFIX + key, String(next));
|
||||
} catch {
|
||||
/* без сохранения — выбор держится до перезагрузки */
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
return { pageSize, setPageSize };
|
||||
}
|
||||
@@ -78,6 +78,15 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
function ingestPoll(data: Poll, notify = true) {
|
||||
// Диалог удалили на стороне поддержки — переписка обнуляется, и виджет
|
||||
// продолжает жить как только что открытый.
|
||||
if (data.reset) {
|
||||
lastId.current = 0;
|
||||
setMessages([]);
|
||||
setPending([]);
|
||||
setAwaiting(false);
|
||||
setContactSent(false);
|
||||
}
|
||||
setState(data.state);
|
||||
setCall(data.call?.callId === openedCallId.current ? null : (data.call ?? null));
|
||||
if (!data.messages.length) return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
import { isVideoCall, type CallInfo, type WebConfig, type WebMessage } from "./api";
|
||||
import { VoiceMessage } from "./VoiceMessage";
|
||||
import type { useVoiceRecorder } from "./useVoiceRecorder";
|
||||
import { fmt, t } from "./i18n";
|
||||
|
||||
@@ -56,7 +57,7 @@ export function ChatBody({ bodyRef, config, unavailable, accepted, accent, lette
|
||||
{config.greeting && <Bubble author="ai" text={config.greeting} accent={accent} />}
|
||||
{messages.map((message) => message.author === "system"
|
||||
? <SystemMessage key={message.id} text={message.text} />
|
||||
: <div key={message.id}><Bubble author={message.author} text={message.hasAudio ? "" : message.text || (message.kind === "voice" ? t("chat.voice_message") : "")} accent={accent} time={message.createdAt} audioUrl={message.hasAudio && audioUrlFor ? audioUrlFor(message.id) : undefined} attachment={message.kind === "file" && message.attachment ? { ...message.attachment, url: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, false) : "", inlineUrl: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, true) : "" } : undefined} />{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && <PhoneForm accent={accent} onSubmit={onSubmitContact} />}</div>)}
|
||||
: <div key={message.id}><Bubble author={message.author} text={message.hasAudio ? "" : message.text || (message.kind === "voice" ? t("chat.voice_message") : "")} accent={accent} time={message.createdAt} audioUrl={message.hasAudio && audioUrlFor ? audioUrlFor(message.id) : undefined} voice={{ messageId: message.id, durationSeconds: message.durationSeconds }} attachment={message.kind === "file" && message.attachment ? { ...message.attachment, url: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, false) : "", inlineUrl: message.attachment.available && attachmentUrlFor ? attachmentUrlFor(message.id, true) : "" } : undefined} />{message.kind === "contact_request" && message.id === lastContactRequestId && showPhoneForm && <PhoneForm accent={accent} onSubmit={onSubmitContact} />}</div>)}
|
||||
{pending.map((text, index) => <Bubble key={`p${index}`} author="client" text={text} accent={accent} pendingState />)}
|
||||
{awaiting && <Typing />}
|
||||
</>
|
||||
@@ -202,10 +203,10 @@ function AttachmentContent({ attachment, text, light }: { attachment: BubbleAtta
|
||||
);
|
||||
}
|
||||
|
||||
export function Bubble({ author, text, accent, time, pendingState, audioUrl, attachment }: { author: "client" | "ai" | "operator"; text: string; accent: string; time?: string; pendingState?: boolean; audioUrl?: string; attachment?: BubbleAttachment }) {
|
||||
export function Bubble({ author, text, accent, time, pendingState, audioUrl, voice, attachment }: { author: "client" | "ai" | "operator"; text: string; accent: string; time?: string; pendingState?: boolean; audioUrl?: string; voice?: { messageId: number; durationSeconds?: number }; attachment?: BubbleAttachment }) {
|
||||
const at = formatTime(time);
|
||||
const content = audioUrl
|
||||
? <audio controls preload="none" src={audioUrl} style={{ width: 216, height: 36, display: "block" }} />
|
||||
const content = audioUrl && voice
|
||||
? <VoiceMessage accent={accent} durationSeconds={voice.durationSeconds} light={author === "client"} messageId={voice.messageId} url={audioUrl} />
|
||||
: attachment
|
||||
? <AttachmentContent attachment={attachment} text={text} light={author === "client"} />
|
||||
: text;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { voiceWaveHeights } from "@chatballs/ui";
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
// Голосовое сообщение в виджете: тот же плеер, что видит оператор в своём чате
|
||||
// (дизайн-базлайн v2, кадр H) — кнопка воспроизведения, волна и длительность.
|
||||
// Расшифровки здесь нет: это инструмент оператора, клиент слушает свою же
|
||||
// запись и в пересказе не нуждается.
|
||||
|
||||
function formatSeconds(total: number): string {
|
||||
const seconds = Math.max(0, Math.round(total));
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const PLAY_ICON = <svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" stroke="none"><polygon points="7 4 20 12 7 20" /></svg>;
|
||||
const PAUSE_ICON = <svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" stroke="none"><rect x="7" y="4" width="3.6" height="16" rx="1" /><rect x="13.4" y="4" width="3.6" height="16" rx="1" /></svg>;
|
||||
|
||||
export function VoiceMessage({ messageId, url, durationSeconds, accent, light }: {
|
||||
messageId: number;
|
||||
url: string;
|
||||
durationSeconds?: number;
|
||||
accent: string;
|
||||
/** Пузырь клиента — на акцентной заливке; плеер на ней рисуется белым. */
|
||||
light: boolean;
|
||||
}) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const bars = voiceWaveHeights(messageId);
|
||||
const played = Math.round(progress * bars.length);
|
||||
|
||||
useEffect(() => () => {
|
||||
audioRef.current?.pause();
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
if (!audioRef.current) {
|
||||
const audio = new Audio(url);
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
if (audio.duration) setProgress(audio.currentTime / audio.duration);
|
||||
});
|
||||
audio.addEventListener("ended", () => {
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
});
|
||||
audioRef.current = audio;
|
||||
}
|
||||
if (playing) {
|
||||
audioRef.current.pause();
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
void audioRef.current.play().catch(() => {
|
||||
setFailed(true);
|
||||
setPlaying(false);
|
||||
});
|
||||
setPlaying(true);
|
||||
}
|
||||
|
||||
const idle = light ? "rgba(255,255,255,0.45)" : "#d9d9d9";
|
||||
const done = light ? "#ffffff" : accent;
|
||||
return (
|
||||
<div style={{ minWidth: 216 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label={playing ? t("chat.pause") : t("chat.play")}
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
flex: "none",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
background: light ? "rgba(255,255,255,0.22)" : accent,
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{playing ? PAUSE_ICON : PLAY_ICON}
|
||||
</button>
|
||||
<span style={{ flex: 1, display: "flex", alignItems: "center", gap: 2, height: 24, minWidth: 110 }}>
|
||||
{bars.map((height, index) => (
|
||||
<i
|
||||
key={index}
|
||||
style={{
|
||||
display: "block",
|
||||
width: 3,
|
||||
height,
|
||||
flex: "none",
|
||||
borderRadius: 1.5,
|
||||
background: index < played ? done : idle,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span style={{
|
||||
flex: "none",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
color: light ? "rgba(255,255,255,0.8)" : "#8c8c8c",
|
||||
}}>{formatSeconds(durationSeconds ?? 0)}</span>
|
||||
</div>
|
||||
{failed && (
|
||||
<div style={{ marginTop: 6, fontSize: 11.5, color: light ? "rgba(255,255,255,0.9)" : "#cf1322" }}>
|
||||
{t("chat.could_not_play")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ export const en: Record<ChatMessageKey, Message> = {
|
||||
"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",
|
||||
|
||||
@@ -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": "Убрать файл",
|
||||
|
||||
Reference in new issue
Block a user