✨ 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:
AndreyandClaude Opus 5 committed 2026-09-14 23:59:28 +03:00
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",
+28 -1
View File
@@ -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": [
+35
View File
@@ -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);
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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>
);
}
+10 -1
View File
@@ -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")}
+16
View File
@@ -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 };
}
+9
View File
@@ -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;
+5 -4
View File
@@ -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;
+117
View File
@@ -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>
);
}
+3 -1
View File
@@ -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 };
+3
View File
@@ -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",
+3
View 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": "Убрать файл",