Compare commits

..
2 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5.5 f57b1ee54a 🔖 release: 1.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:05:44 +03:00
AndreyandClaude Opus 5.5 64266dcc9c ✨ feat(settings): шаблоны ответов в настройках и переменные в тексте
Раздел «Настройки → Шаблоны ответов»: список по образцу «Групп», создание
кнопкой в шапке раздела, правка и удаление окнами, как у интеграций. Без права
на настройки список только читается.

В тексте шаблона можно использовать {{client_name}}, {{operator_name}} и
{{company}}; кнопка «Вставить переменную» ставит код в позицию курсора.
Композер подставляет значения при выборе шаблона. Переменная без значения
остаётся в тексте, над полем — предупреждение, отправка заблокирована. Гость
виджета распознаётся по подписи сессии (isGuest в карточке диалога), его
«Гость · код» за имя не считается. Сервер отклоняет неизвестные переменные.

Переименование шаблона в занятое название теперь отвечает 409, а не падает
на ограничении базы. Логика шаблонов вынесена из Composer в
useComposerTemplates и ComposerTemplatesMenu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:05:32 +03:00
26 changed files with 588 additions and 65 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.14.3
1.15.0
@@ -7,6 +7,8 @@
from __future__ import annotations
import re
from django.db.models import Count, Q
from django.utils import timezone
from rest_framework.request import Request
@@ -38,6 +40,26 @@ def _label_payload(label: ConversationLabel) -> dict[str, object]:
return {"id": label.id, "name": label.name, "color": label.color}
# Переменные шаблонов ответов: подставляет их интерфейс оператора при вставке
# шаблона (internal-ui, conversations/templateVariables.ts — тот же список).
TEMPLATE_VARIABLES = frozenset({"client_name", "operator_name", "company"})
_TEMPLATE_TOKEN = re.compile(r"\{\{\s*(\w+)\s*\}\}")
def _template_text_error(text: str) -> Response | None:
if not text:
return Response({"detail": t("conversations.template_text_required")}, status=400)
unknown = sorted(
{match.group(0) for match in _TEMPLATE_TOKEN.finditer(text) if match.group(1) not in TEMPLATE_VARIABLES}
)
if unknown:
return Response(
{"detail": t("conversations.template_unknown_variables", names=", ".join(unknown))},
status=400,
)
return None
def _template_payload(template: ReplyTemplate) -> dict[str, object]:
return {
"id": template.id,
@@ -443,8 +465,8 @@ class ReplyTemplateListView(APIView):
text = str(request.data.get("text", "")).strip()
if not title or len(title) > 120:
return Response({"detail": t("conversations.template_name_length")}, status=400)
if not text:
return Response({"detail": t("conversations.template_text_required")}, status=400)
if error := _template_text_error(text):
return error
if ReplyTemplate.objects.filter(
organization_id=request.tenant_context.organization_id, title__iexact=title
).exists():
@@ -475,11 +497,15 @@ class ReplyTemplateDetailView(APIView):
title = str(request.data.get("title", "")).strip()
if not title or len(title) > 120:
return Response({"detail": t("conversations.template_name_length")}, status=400)
if ReplyTemplate.objects.filter(
organization_id=template.organization_id, title__iexact=title
).exclude(id=template.id).exists():
return Response({"detail": t("conversations.template_name_taken")}, status=409)
template.title = title
if "text" in request.data:
text = str(request.data.get("text", "")).strip()
if not text:
return Response({"detail": t("conversations.template_text_required")}, status=400)
if error := _template_text_error(text):
return error
template.text = text
template.save()
return Response({"template": _template_payload(template)})
@@ -157,15 +157,28 @@ def _history_item(conversation: Conversation) -> dict[str, object]:
}
def _contact_username(conversation: Conversation) -> str:
# Username живёт на identity подключения (у контакта их может быть несколько).
# Только в detail-режиме — в списках это лишний запрос на каждый диалог.
def _connection_identity(conversation: Conversation) -> ConnectionIdentity | None:
# Username и подпись гостя живут на identity подключения (у контакта их
# может быть несколько). Только в detail-режиме — в списках это лишний
# запрос на каждый диалог.
if not conversation.connection_id:
return ""
identity = ConnectionIdentity.objects.filter(
return None
return ConnectionIdentity.objects.filter(
connection_id=conversation.connection_id, contact_id=conversation.contact_id
).first()
return identity.username if identity else ""
def _contact_is_guest(contact, identity: ConnectionIdentity | None) -> bool:
# Гость виджета получает имя «Гость · <код сессии>» на языке организации;
# та же подпись записана в display_name его identity. Пока имя не сменили,
# настоящего имени у контакта нет — подставлять его в ответ нельзя.
if not contact.name:
return True
return bool(
identity
and identity.display_name == contact.name
and identity.external_user_id[:6] in contact.name
)
def _contact_email(conversation: Conversation) -> str:
@@ -206,6 +219,7 @@ def conversation_payload(
# pending_counts_for); поштучный расчёт остаётся для одиночных ответов.
last = None if detailed else (last_message or _last_message(conversation))
channel = conversation.channel
identity = _connection_identity(conversation) if detailed and conversation.contact_id else None
payload = {
"id": conversation.id,
"channel": {
@@ -236,7 +250,9 @@ def conversation_payload(
"company": conversation.contact.company,
"city": conversation.contact.city,
"email": _contact_email(conversation),
"username": _contact_username(conversation) if detailed else "",
"username": identity.username if identity else "",
# Для переменной {{client_name}} шаблонов ответов.
**({"isGuest": _contact_is_guest(conversation.contact, identity)} if detailed else {}),
}
if conversation.contact_id
else None
@@ -1,10 +1,11 @@
import json
from django.test import TestCase
from django.test import SimpleTestCase, TestCase
from django.utils import timezone
from chatballs.channels.models import Channel
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
ControlMode,
Conversation,
@@ -13,6 +14,7 @@ from chatballs.conversations.models import (
LifecycleState,
ReplyTemplate,
)
from chatballs.conversations.serializers import _contact_is_guest
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
from chatballs.identity.models import (
EmployeeRole,
@@ -339,6 +341,65 @@ class ReplyTemplateTests(ChatExtrasTestCase):
self.assertEqual(deleted.status_code, 204)
self.assertFalse(ReplyTemplate.objects.exists())
def test_rename_to_taken_title_conflicts(self) -> None:
ReplyTemplate.objects.create(
organization_id=self.organization.id, title="Приветствие", text="Здравствуйте!"
)
other = ReplyTemplate.objects.create(
organization_id=self.organization.id, title="Прощание", text="До свидания!"
)
taken = self.admin_client.patch(
f"/api/v1/conversations/templates/{other.id}/",
data=json.dumps({"title": "приветствие"}),
content_type="application/json",
)
self.assertEqual(taken.status_code, 409)
other.refresh_from_db()
self.assertEqual(other.title, "Прощание")
# Смена регистра собственного названия — не конфликт.
recased = self.admin_client.patch(
f"/api/v1/conversations/templates/{other.id}/",
data=json.dumps({"title": "ПРОЩАНИЕ"}),
content_type="application/json",
)
self.assertEqual(recased.status_code, 200)
self.assertEqual(recased.json()["template"]["title"], "ПРОЩАНИЕ")
def test_template_variables_are_checked(self) -> None:
created = self.admin_client.post(
"/api/v1/conversations/templates/",
data=json.dumps({"title": "Приветствие", "text": "Здравствуйте, {{client_name}}! Я {{ operator_name }}, {дата}."}),
content_type="application/json",
)
self.assertEqual(created.status_code, 201)
typo = self.admin_client.post(
"/api/v1/conversations/templates/",
data=json.dumps({"title": "Опечатка", "text": "Здравствуйте, {{clent_name}}!"}),
content_type="application/json",
)
self.assertEqual(typo.status_code, 400)
self.assertIn("{{clent_name}}", typo.json()["detail"])
patched = self.admin_client.patch(
f"/api/v1/conversations/templates/{created.json()['template']['id']}/",
data=json.dumps({"text": "{{order_id}}"}),
content_type="application/json",
)
self.assertEqual(patched.status_code, 400)
class ContactGuestTests(SimpleTestCase):
def test_widget_guest_label_is_not_a_name(self) -> None:
identity = ConnectionIdentity(external_user_id="3ffa7b0c9d", display_name="Гость · 3ffa7b")
self.assertTrue(_contact_is_guest(Contact(name="Гость · 3ffa7b"), identity))
self.assertTrue(_contact_is_guest(Contact(name=""), None))
# Гость представился — имя уже настоящее.
self.assertFalse(_contact_is_guest(Contact(name="Дмитрий Орлов"), identity))
self.assertFalse(_contact_is_guest(Contact(name="Иван"), None))
class OnboardingTests(TestCase):
def setUp(self) -> None:
@@ -76,6 +76,7 @@ MESSAGES: dict[str, object] = {
"conversations.template_name_taken": "A template with this name already exists",
"conversations.template_not_found": "Template not found",
"conversations.template_text_required": "The template text is required",
"conversations.template_unknown_variables": "Unknown variables: {names}. Use the “Insert a variable” button to add variables.",
"conversations.unknown_label": "Unknown label",
"conversations.unknown_priority": "Unknown priority",
"conversations.voice_off_entry_point": "Voice messages are switched off for this entry point",
@@ -80,6 +80,7 @@ MESSAGES: dict[str, object] = {
"conversations.template_name_taken": "Шаблон с таким названием уже есть",
"conversations.template_not_found": "Шаблон не найден",
"conversations.template_text_required": "Текст шаблона обязателен",
"conversations.template_unknown_variables": "Неизвестные переменные: {names}. Вставляйте переменные кнопкой «Вставить переменную».",
"conversations.unknown_label": "Неизвестная метка",
"conversations.unknown_priority": "Неизвестный приоритет",
"conversations.voice_off_entry_point": "Голосовые отключены для этой точки входа",
@@ -189,7 +189,7 @@
"replyTemplates": [
{
"title": "Greeting",
"text": "Hello! My name is {name}, I am a consultant at Nord Atelier. Could you tell me the track width and the window drop — I will work out a quote."
"text": "Hello! My name is {{operator_name}}, I am a consultant at Nord Atelier. Could you tell me the track width and the window drop — I will work out a quote."
},
{
"title": "Lead times",
@@ -189,7 +189,7 @@
"replyTemplates": [
{
"title": "Приветствие",
"text": "Здравствуйте! Меня зовут {имя}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт."
"text": "Здравствуйте! Меня зовут {{operator_name}}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт."
},
{
"title": "Сроки пошива",
@@ -61,6 +61,7 @@ export function ChatPage({
? <Hint id="chat-visibility">{t("admin.see_every_conversation_organization_operators")}</Hint>
: undefined}
viewerId={user.id}
sender={{ operatorName: user.fullName, company: user.organizationName }}
renderContextPanel={({ dialog, detail, applyConversation, startCall, closeContext, assignmentTimeoutMinutes }) => (
<ChatContextPanel
rightTab={rightTab}
@@ -1,24 +1,25 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Icon, LogoSpinner } from "../../shared/icons";
import { ComposerTemplatesMenu } from "./ComposerTemplatesMenu";
import { EmojiPicker } from "./EmojiPicker";
import { useMediaQuery } from "../../shared/useMediaQuery";
import { fetchReplyTemplates, sendFileMessage, sendOperatorMessage, sendVoiceMessage, type ReplyTemplateRef } from "./model";
import { sendFileMessage, sendOperatorMessage, sendVoiceMessage } from "./model";
import { formatSize } from "../ai/knowledge/model";
import { formatDuration } from "./VoiceMessage";
import { TEMPLATE_VARIABLE_LABEL, unfilledVariables, type TemplateValues } from "./templateVariables";
import { useComposerTemplates } from "./useComposerTemplates";
import { useVoiceRecorder } from "./useVoiceRecorder";
import type { ChannelKey, ControlMode } from "./types";
import { t } from "../../i18n";
const MAX_FILE_BYTES = 20 * 1024 * 1024;
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
export function Composer({ mode, loaded, assignedOperatorName, conversationId, channel, voiceAllowed = true, templateValues = {}, onClaim, onRelease, onReturnQueue, onClose, onSent }: { mode: ControlMode; loaded: boolean; assignedOperatorName?: string; conversationId: number | null; channel?: ChannelKey; voiceAllowed?: boolean; templateValues?: TemplateValues; onClaim: () => void; onRelease: () => void; onReturnQueue: () => void; onClose: () => void; onSent: () => void }) {
const [text, setText] = useState("");
const compact = useMediaQuery("(max-width: 768px)");
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState("");
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
const [templatesOpen, setTemplatesOpen] = useState(false);
const [attachment, setAttachment] = useState<File | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -35,25 +36,10 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
// Сброс черновика вложения при смене диалога.
useEffect(() => { setAttachment(null); setSendError(""); }, [conversationId]);
useEffect(() => {
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
}, []);
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
// продолжение ввода фильтрует по названию.
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
const visibleTemplates = useMemo(() => {
if (templates.length === 0) return [];
if (slashQuery === null) return templates;
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
}, [templates, slashQuery]);
const menuOpen = templatesOpen || (slashQuery !== null && visibleTemplates.length > 0);
function applyTemplate(template: ReplyTemplateRef) {
setText(template.text);
setTemplatesOpen(false);
textareaRef.current?.focus();
}
const templates = useComposerTemplates({ text, setText, values: templateValues, textareaRef });
// Переменная шаблона без значения (у гостя нет имени) остаётся в тексте:
// пока оператор её не заполнит, ответ не уходит.
const unfilled = unfilledVariables(text);
// Запись голосового: во всех каналах (TG/MAX sendVoice, почта — вложением, Web — поллингом).
const recorder = useVoiceRecorder({
@@ -111,7 +97,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
async function send() {
const value = text.trim();
if ((!value && !attachment) || conversationId == null || sending) return;
if ((!value && !attachment) || conversationId == null || sending || unfilled.length > 0) return;
setSending(true);
setSendError("");
try {
@@ -163,7 +149,9 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
// Кадры A–C: композер активен всегда (решение 2) — первое сообщение
// перехватывает диалог; над полем одна строка-предупреждение.
const warning = mode === "ai"
const warning = unfilled.length > 0
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.fill_template_variables", { names: unfilled.map((name) => t(TEMPLATE_VARIABLE_LABEL[name]).toLocaleLowerCase()).join(", ") }) }
: mode === "ai"
? { color: "var(--ai)", text: t("conversations.ai_handling_conversation_message_takes") }
: mode === "waiting"
? { color: "var(--warning-text)", dot: "var(--warning)", text: t("conversations.customer_waiting_message_assigns_conversation") }
@@ -174,17 +162,7 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
<div className="composer-wrap">
{warning && <div className="composer-warning" style={{ color: warning.color }}><i style={{ background: warning.dot ?? warning.color }} />{warning.text}</div>}
<div className="composer-box">
{menuOpen && (
<div className="composer-templates-menu">
{visibleTemplates.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
{visibleTemplates.map((template) => (
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); applyTemplate(template); }}>
<strong>{template.title}</strong>
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
</button>
))}
</div>
)}
{templates.menuOpen && <ComposerTemplatesMenu items={templates.visible} onPick={templates.apply} />}
{attachment && (
<div className="composer-attachment">
<Icon name="paperclip" size={14} />
@@ -202,14 +180,14 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape" && menuOpen) { setTemplatesOpen(false); if (slashQuery !== null) setText(""); return; }
if (event.key === "Escape" && templates.menuOpen) { templates.close(); if (templates.slashQuery !== null) setText(""); return; }
if (event.key === "Enter" && !event.shiftKey) {
if (slashQuery !== null && visibleTemplates.length > 0) { event.preventDefault(); applyTemplate(visibleTemplates[0]); return; }
if (templates.slashQuery !== null && templates.visible.length > 0) { event.preventDefault(); templates.apply(templates.visible[0]); return; }
event.preventDefault();
void send();
}
}}
onBlur={() => setTemplatesOpen(false)}
onBlur={templates.close}
onPaste={(event) => {
const file = Array.from(event.clipboardData?.files ?? [])[0];
if (file) { event.preventDefault(); pickFile(file); }
@@ -226,12 +204,12 @@ export function Composer({ mode, loaded, assignedOperatorName, conversationId, c
<Icon name="mic" size={17} />
</button>
)}
{templates.length > 0 && (
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={() => setTemplatesOpen((open) => !open)}>
{templates.available && (
<button className="composer-tool is-labeled" title={t("conversations.reply_templates")} type="button" onClick={templates.toggle}>
<Icon name="text" size={16} />{t("conversations.templates")}</button>
)}
<span className="composer-spacer" />
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment)}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
<button className="composer-send" type="button" onClick={() => void send()} disabled={sending || (!text.trim() && !attachment) || unfilled.length > 0}><span>{t("conversations.send")}</span><kbd>⏎</kbd><Icon name="send" size={17} /></button>
</div>
</div>
</div>
@@ -0,0 +1,16 @@
import type { ReplyTemplateRef } from "./model";
import { t } from "../../i18n";
export function ComposerTemplatesMenu({ items, onPick }: { items: ReplyTemplateRef[]; onPick: (template: ReplyTemplateRef) => void }) {
return (
<div className="composer-templates-menu">
{items.length === 0 && <p>{t("conversations.no_matching_templates")}</p>}
{items.map((template) => (
<button key={template.id} type="button" onMouseDown={(event) => { event.preventDefault(); onPick(template); }}>
<strong>{template.title}</strong>
<small>{template.text.replace(/\s+/g, " ").slice(0, 80)}</small>
</button>
))}
</div>
);
}
@@ -57,7 +57,7 @@ import { t } from "../../i18n";
// backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком,
// placeholder поиска и правой панелью через render-prop. Список и история —
// серверные окна: ни то, ни другое целиком не запрашивается.
export function ConversationWorkspace({ isOwner = false, canDelete = 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, sender }: {
isOwner?: boolean;
/** Удалять диалоги могут владелец и администратор (то же проверяет сервер). */
canDelete?: boolean;
@@ -74,6 +74,8 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
setScope: (scope: DialogScope) => void;
counters: ConversationCounters | null;
showScopeSwitcher?: boolean;
/** Кто отвечает — для переменных шаблонов ответов. */
sender?: { operatorName: string; company: string };
}) {
const [listTab, setListTab] = useState<ListTab>("all");
const [sort, setSort] = useState<ListSort>("activity");
@@ -289,6 +291,11 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
mode={controlMode}
channel={selectedDialog?.channel}
voiceAllowed={detail?.connection?.voiceMessages ?? false}
templateValues={{
client_name: detail?.contact?.isGuest ? "" : detail?.contact?.name,
operator_name: sender?.operatorName,
company: sender?.company,
}}
loaded={detailLoaded}
assignedOperatorName={detail?.assignedOperator?.name}
conversationId={selectedId}
@@ -72,7 +72,8 @@ export type ApiConversation = {
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
// Контакт — единственный источник identity диалога.
// phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме.
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string } | null;
// isGuest — только в detail-режиме: имя гостя виджета — подпись «Гость · код», а не имя.
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string; isGuest?: boolean } | null;
lifecycle: "OPEN" | "CLOSED" | "SPAM";
controlMode: "AI" | "HUMAN" | "PAUSED";
expectedResponder: string;
@@ -371,6 +372,18 @@ export const createConversationLabel = (name: string, color = "") =>
}).then((r) => r.label);
export const fetchReplyTemplates = () =>
api<{ items: ReplyTemplateRef[] }>("/api/v1/conversations/templates/").then((r) => r.items);
export const createReplyTemplate = (title: string, text: string) =>
api<{ template: ReplyTemplateRef }>("/api/v1/conversations/templates/", {
method: "POST",
body: JSON.stringify({ title, text }),
}).then((r) => r.template);
export const updateReplyTemplate = (id: number, title: string, text: string) =>
api<{ template: ReplyTemplateRef }>(`/api/v1/conversations/templates/${id}/`, {
method: "PATCH",
body: JSON.stringify({ title, text }),
}).then((r) => r.template);
export const deleteReplyTemplate = (id: number) =>
api<void>(`/api/v1/conversations/templates/${id}/`, { method: "DELETE" });
export const transcribeMessage = (messageId: number) =>
api<{ message: ApiMessage }>(`/api/v1/conversations/messages/${messageId}/transcribe/`, { method: "POST" }).then((r) => r.message);
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { renderTemplate, unfilledVariables } from "./templateVariables";
describe("переменные шаблона ответа", () => {
it("подставляет известные значения", () => {
const text = renderTemplate("Здравствуйте, {{client_name}}! Меня зовут {{ operator_name }}, {{company}}.", {
client_name: "Дмитрий",
operator_name: "Анна",
company: "Ателье Норд",
});
expect(text).toBe("Здравствуйте, Дмитрий! Меня зовут Анна, Ателье Норд.");
});
it("оставляет переменную без значения и чужие скобки как есть", () => {
const text = renderTemplate("Здравствуйте, {{client_name}}! {{unknown}}", { client_name: " ", company: "Ателье Норд" });
expect(text).toBe("Здравствуйте, {{client_name}}! {{unknown}}");
});
it("находит незаполненные переменные без повторов", () => {
expect(unfilledVariables("{{client_name}}, {{client_name}} и {{company}}, {{unknown}}")).toEqual(["client_name", "company"]);
expect(unfilledVariables("Готовый текст")).toEqual([]);
});
});
@@ -0,0 +1,41 @@
import type { MessageKey } from "../../i18n/ru";
// Переменные шаблонов ответов. В тексте шаблона хранится код, а не слово на
// языке интерфейса: язык организации меняется, код — нет. Тот же список
// проверяет сервер при сохранении (conversations/chat_extras_views.py).
export const TEMPLATE_VARIABLES = ["client_name", "operator_name", "company"] as const;
export type TemplateVariable = (typeof TEMPLATE_VARIABLES)[number];
export type TemplateValues = Partial<Record<TemplateVariable, string>>;
export const TEMPLATE_VARIABLE_LABEL: Record<TemplateVariable, MessageKey> = {
client_name: "conversations.variable_client_name",
operator_name: "conversations.variable_operator_name",
company: "conversations.variable_company",
};
const TOKEN = /\{\{\s*(\w+)\s*\}\}/g;
export function variableToken(variable: TemplateVariable): string {
return `{{${variable}}}`;
}
function isVariable(name: string): name is TemplateVariable {
return (TEMPLATE_VARIABLES as readonly string[]).includes(name);
}
/** Подставляет известные значения. Переменная без значения (у гостя нет
* имени) остаётся в тексте как есть — её заполнит оператор. */
export function renderTemplate(text: string, values: TemplateValues): string {
return text.replace(TOKEN, (token, name: string) => {
const value = isVariable(name) ? values[name]?.trim() : "";
return value || token;
});
}
/** Переменные, которые остались в тексте незаполненными, без повторов. */
export function unfilledVariables(text: string): TemplateVariable[] {
const found = [...text.matchAll(TOKEN)].map((match) => match[1]).filter(isVariable);
return [...new Set(found)];
}
@@ -0,0 +1,45 @@
import { useEffect, useMemo, useState, type RefObject } from "react";
import { fetchReplyTemplates, type ReplyTemplateRef } from "./model";
import { renderTemplate, type TemplateValues } from "./templateVariables";
// Шаблоны «/» (дизайн-базлайн v2 §9): ввод «/» в начале открывает список,
// продолжение ввода фильтрует по названию. Выбранный шаблон заменяет текст
// поля, переменные подставляются сразу — оператор видит готовый ответ.
export function useComposerTemplates({ text, setText, values, textareaRef }: {
text: string;
setText: (text: string) => void;
values: TemplateValues;
textareaRef: RefObject<HTMLTextAreaElement | null>;
}) {
const [templates, setTemplates] = useState<ReplyTemplateRef[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
fetchReplyTemplates().then(setTemplates).catch(() => setTemplates([]));
}, []);
const slashQuery = text.startsWith("/") ? text.slice(1).trim().toLowerCase() : null;
const visible = useMemo(() => {
if (templates.length === 0) return [];
if (slashQuery === null) return templates;
return templates.filter((template) => template.title.toLowerCase().includes(slashQuery));
}, [templates, slashQuery]);
function apply(template: ReplyTemplateRef) {
setText(renderTemplate(template.text, values));
setOpen(false);
textareaRef.current?.focus();
}
return {
available: templates.length > 0,
visible,
slashQuery,
menuOpen: open || (slashQuery !== null && visible.length > 0),
toggle: () => setOpen((current) => !current),
close: () => setOpen(false),
apply,
};
}
@@ -0,0 +1,66 @@
import { Modal } from "antd";
import { useRef, useState } from "react";
import { FormField, TextAreaField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import { createReplyTemplate, updateReplyTemplate, type ReplyTemplateRef } from "../conversations/model";
import { variableToken, type TemplateVariable } from "../conversations/templateVariables";
import { TemplateVariableMenu } from "./TemplateVariableMenu";
import { t } from "../../i18n";
// Окно шаблона ответа: название и текст. Та же форма, что у интеграций и
// переименования группы; ошибки (занятое название, пустой текст) — от сервера.
export function ReplyTemplateForm({ initial, onClose, onSaved }: {
initial: ReplyTemplateRef | null;
onClose: () => void;
onSaved: () => void;
}) {
const [title, setTitle] = useState(initial?.title ?? "");
const [text, setText] = useState(initial?.text ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const textRef = useRef<HTMLTextAreaElement>(null);
function insertVariable(variable: TemplateVariable) {
const token = variableToken(variable);
const area = textRef.current;
const start = area?.selectionStart ?? text.length;
const end = area?.selectionEnd ?? text.length;
setText(text.slice(0, start) + token + text.slice(end));
window.requestAnimationFrame(() => {
if (!area) return;
area.focus();
area.setSelectionRange(start + token.length, start + token.length);
});
}
async function save() {
setSaving(true);
setError("");
try {
if (initial) await updateReplyTemplate(initial.id, title.trim(), text.trim());
else await createReplyTemplate(title.trim(), text.trim());
onSaved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : t("settings.could_not_save_template"));
} finally {
setSaving(false);
}
}
return (
<Modal open title={initial ? t("settings.edit_template") : t("settings.new_template")} onCancel={onClose} footer={null} destroyOnClose>
<div className="integration-form">
<FormField label={t("common.title")} value={title} onChange={setTitle} />
<TextAreaField label={t("settings.template_text")} value={text} onChange={setText} inputRef={textRef} />
<TemplateVariableMenu onPick={insertVariable} />
{error && <div className="integration-form-error">{error}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={saving || !title.trim() || !text.trim()} onClick={() => void save()}>{t("common.save")}</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,73 @@
import { Modal } from "antd";
import { useState } from "react";
import { EmptyState } from "../../shared/ui";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { deleteReplyTemplate, type ReplyTemplateRef } from "../conversations/model";
import { fmt, t } from "../../i18n";
// Раздел «Шаблоны ответов» (согласован по образцу кадра N2 «Группы», своего
// кадра в базлайне нет): строка — название · начало текста · дата изменения ·
// изменить/удалить. Создание — primary-кнопкой в шапке раздела, правка —
// окном, как у интеграций. Без права на настройки список только читается.
export function ReplyTemplatesCard({ items, canManage, reload, onEdit }: {
items: ReplyTemplateRef[];
canManage: boolean;
reload: () => void;
onEdit: (template: ReplyTemplateRef) => void;
}) {
const [deleting, setDeleting] = useState<ReplyTemplateRef | null>(null);
const [deleteError, setDeleteError] = useState("");
async function remove() {
if (!deleting) return;
setDeleteError("");
try {
await deleteReplyTemplate(deleting.id);
setDeleting(null);
reload();
} catch (caught) {
setDeleteError(caught instanceof Error ? caught.message : t("settings.could_not_delete"));
}
}
if (items.length === 0) return <EmptyState title={t("settings.no_templates_yet")} />;
return (
<>
<div className="table-card settings-groups-card">
{items.map((template) => (
<div
className={`settings-group-row settings-template-row ${canManage ? "is-clickable" : ""}`}
key={template.id}
onClick={canManage ? () => onEdit(template) : undefined}
>
<strong>{template.title}</strong>
<span className="settings-template-preview">{template.text.replace(/\s+/g, " ")}</span>
<small>{fmt.shortDate(template.updatedAt)}</small>
{canManage && (
<div className="settings-group-actions" onClick={(event) => event.stopPropagation()}>
<button aria-label={t("common.edit")} title={t("common.edit")} type="button" onClick={() => onEdit(template)}><Icon name="edit" size={14} /></button>
<button aria-label={t("common.delete")} title={t("common.delete")} className="is-danger" type="button" onClick={() => { setDeleteError(""); setDeleting(template); }}><Icon name="trash" size={14} /></button>
</div>
)}
</div>
))}
</div>
{deleting && (
<Modal open title={t("settings.delete_template")} onCancel={() => setDeleting(null)} footer={null} destroyOnClose>
<div className="integration-form">
<p>{t("settings.template_will_be_deleted", { name: deleting.title })}</p>
{deleteError && <div className="integration-form-error">{deleteError}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={() => setDeleting(null)}>{t("common.cancel")}</Button>
<Button variant="danger-outline" onClick={() => void remove()}>{t("common.delete")}</Button>
</div>
</div>
</Modal>
)}
</>
);
}
@@ -18,8 +18,12 @@ import { PlatformSettingsCard } from "./PlatformSettingsCard";
import { StorageSettingsCard } from "./StorageSettingsCard";
import { GroupsSettingsCard } from "./GroupsSettingsCard";
import { QueuePolicyCard } from "./QueuePolicyCard";
import { ReplyTemplateForm } from "./ReplyTemplateForm";
import { ReplyTemplatesCard } from "./ReplyTemplatesCard";
import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections";
import { useIntegrations } from "./useIntegrations";
import { useReplyTemplates } from "./useReplyTemplates";
import type { ReplyTemplateRef } from "../conversations/model";
import { useOnboarding } from "../onboarding/useOnboarding";
import type { TourTarget } from "../onboarding/steps";
import { t } from "../../i18n";
@@ -50,6 +54,8 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
const manager = isManager(user);
const integrations = useIntegrations(manager);
const [form, setForm] = useState<{ kind: IntegrationKind; initial: Integration | null } | null>(null);
const templates = useReplyTemplates(manager);
const [templateForm, setTemplateForm] = useState<{ initial: ReplyTemplateRef | null } | null>(null);
// Кадр M: на узком экране субменю и раздел — два отдельных экрана.
const mobile = useMediaQuery("(max-width: 900px)");
@@ -60,6 +66,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
const connectionsFailed = connections.some((item) => item.isActive && item.status === "ERROR");
const counts: Partial<Record<SettingsSectionKey, number>> = {
groups: groups.length,
templates: templates.items.length,
ai: providers.length,
integrations: connections.length,
};
@@ -121,7 +128,9 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
? <Button variant="primary" icon="plus" onClick={() => setForm({ kind: "LLM_PROVIDER", initial: null })}>{t("settings.add_provider")}</Button>
: current?.key === "integrations"
? <Button variant="primary" icon="plus" onClick={() => setForm({ kind: "MESSENGER", initial: null })}>{t("settings.add_connection")}</Button>
: null;
: current?.key === "templates" && canManageSettings(user)
? <Button variant="primary" icon="plus" onClick={() => setTemplateForm({ initial: null })}>{t("settings.add_template")}</Button>
: null;
return (
<div className="settings-layout">
@@ -151,6 +160,8 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
providers={providers}
connections={connections}
onEditIntegration={(item) => setForm({ kind: item.kind, initial: item })}
templates={templates}
onEditTemplate={(item) => setTemplateForm({ initial: item })}
/>
</div>
</section>
@@ -163,11 +174,18 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
onSaved={() => { setForm(null); integrations.reload(); }}
/>
)}
{templateForm && (
<ReplyTemplateForm
initial={templateForm.initial}
onClose={() => setTemplateForm(null)}
onSaved={() => { setTemplateForm(null); templates.reload(); }}
/>
)}
</div>
);
}
function SectionBody({ section, user, onUserUpdated, reload, groups, integrations, providers, connections, onEditIntegration }: {
function SectionBody({ section, user, onUserUpdated, reload, groups, integrations, providers, connections, onEditIntegration, templates, onEditTemplate }: {
section: SettingsSectionKey;
user: SessionUser;
onUserUpdated: (user: SessionUser) => void;
@@ -177,6 +195,8 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
providers: Integration[];
connections: Integration[];
onEditIntegration: (integration: Integration) => void;
templates: ReturnType<typeof useReplyTemplates>;
onEditTemplate: (template: ReplyTemplateRef) => void;
}): ReactNode {
if (section === "organization") return <OrganizationSection user={user} onUserUpdated={onUserUpdated} />;
if (section === "groups") return <GroupsSettingsCard groups={groups} reload={reload} />;
@@ -185,6 +205,11 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
if (section === "platform") return <PlatformSettingsCard canManage={user.isInstanceAdmin} />;
if (section === "storage") return <StorageSettingsCard canManage={user.isInstanceAdmin} />;
if (section === "demo") return <DemoDataCard reload={reload} />;
if (section === "templates") {
if (templates.loading) return <LoadingState />;
if (templates.failed) return <EmptyState title={t("settings.could_not_load_templates")} />;
return <ReplyTemplatesCard items={templates.items} canManage={canManageSettings(user)} reload={templates.reload} onEdit={onEditTemplate} />;
}
if (integrations.loading) return <LoadingState />;
if (integrations.failed) return <EmptyState title={t("settings.could_not_load_integrations")} />;
return (
@@ -0,0 +1,23 @@
import { Dropdown } from "antd";
import { Icon } from "../../shared/icons";
import { TEMPLATE_VARIABLES, TEMPLATE_VARIABLE_LABEL, type TemplateVariable } from "../conversations/templateVariables";
import { t } from "../../i18n";
// «Вставить переменную» в окне шаблона ответа: коды переменных не нужно
// помнить — пункт меню вставляет его в позицию курсора.
export function TemplateVariableMenu({ onPick }: { onPick: (variable: TemplateVariable) => void }) {
const items = TEMPLATE_VARIABLES.map((variable) => ({
key: variable,
label: t(TEMPLATE_VARIABLE_LABEL[variable]),
onClick: () => onPick(variable),
}));
return (
<Dropdown menu={{ items }} trigger={["click"]} placement="bottomLeft" overlayClassName="app-dropdown">
<button className="link has-icon template-variable-trigger" type="button">
<Icon name="plus" size={13} strokeWidth={2.2} />{t("settings.insert_variable")}
</button>
</Dropdown>
);
}
@@ -10,6 +10,7 @@ export type SettingsSectionKey =
| "organization"
| "groups"
| "queue"
| "templates"
| "ai"
| "integrations"
| "communication"
@@ -51,6 +52,14 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
heading: t("settings.when_to_call_for_help"),
lead: t("settings.queue_section_lead"),
},
{
// Своего кадра в базлайне нет: раздел согласован по образцу «Групп» (N2).
key: "templates",
label: t("settings.reply_templates"),
icon: "text",
heading: t("settings.reply_templates"),
lead: t("settings.reply_templates_lead"),
},
{
key: "ai",
label: t("settings.ai_provider"),
@@ -386,6 +386,36 @@
margin: 10px 0 4px;
}
/* --- Шаблоны ответов: строка «Групп», вместо состава — начало текста --- */
.settings-template-row.is-clickable {
cursor: pointer;
}
.settings-template-row strong {
flex: 0 1 auto;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* «Вставить переменную» — ссылка под полем текста, не во всю ширину формы. */
.template-variable-trigger {
align-self: flex-start;
margin-top: -6px;
}
.settings-template-preview {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--n-4);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- N3/N4 Таблицы: шапка 11px капсом на подложке, строки 62px --- */
.integrations-section {
@@ -0,0 +1,34 @@
import { useCallback, useEffect, useState } from "react";
import { fetchReplyTemplates, type ReplyTemplateRef } from "../conversations/model";
// Шаблоны грузятся на весь экран «Настроек», как интеграции: субменю
// показывает их число, а раздел — список.
export type ReplyTemplatesState = {
items: ReplyTemplateRef[];
loading: boolean;
failed: boolean;
reload: () => void;
};
export function useReplyTemplates(enabled: boolean): ReplyTemplatesState {
const [items, setItems] = useState<ReplyTemplateRef[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const reload = useCallback(() => {
if (!enabled) return;
setFailed(false);
fetchReplyTemplates()
.then(setItems)
.catch(() => setFailed(true))
.finally(() => setLoading(false));
}, [enabled]);
useEffect(() => {
reload();
}, [reload]);
return { items, loading, failed, reload };
}
+16
View File
@@ -754,6 +754,10 @@ export const en: Record<MessageKey, Message> = {
"conversations.started": "Started",
"conversations.take_conversation": "Take the conversation",
"conversations.templates": "Templates",
"conversations.variable_client_name": "Customer name",
"conversations.variable_operator_name": "Operator name",
"conversations.variable_company": "Organization name",
"conversations.fill_template_variables": "Fill in the text: {names}",
"conversations.transcribe": "Transcribe",
"conversations.transcribing": "Transcribing…",
"conversations.type_message_shift_enter_line": "Type a message… Shift+Enter for a line break, “/” for a reply template",
@@ -1474,6 +1478,18 @@ export const en: Record<MessageKey, Message> = {
"settings.queue_restore_defaults": "Restore the usual delays",
"settings.queue_policy_unavailable": "Could not load the delays",
"settings.queue_delivery_warning": "Reminders arrive where the employee will see them: the browser and the linked messenger. If a group has neither, only the counter in the conversation list is left.",
"settings.reply_templates": "Reply templates",
"settings.reply_templates_lead": "Ready-made replies for operators. In a conversation, type “/” in the message field to insert one.",
"settings.add_template": "Add a template",
"settings.no_templates_yet": "No templates yet. Add the first one — operators will call it up with “/”.",
"settings.could_not_load_templates": "Could not load the templates",
"settings.new_template": "New template",
"settings.edit_template": "Edit the template",
"settings.template_text": "Text",
"settings.insert_variable": "Insert a variable",
"settings.could_not_save_template": "Could not save the template",
"settings.delete_template": "Delete the template?",
"settings.template_will_be_deleted": "The template “{name}” will be deleted — operators will no longer be able to insert it.",
"settings.ai_provider": "AI provider",
"settings.allowed_domains": "Allowed domains",
"settings.answers_from_agent_s_knowledge": "Answers from the agent's knowledge with no external requests or keys. Answer quality is limited — for real work connect OpenRouter or a compatible provider.",
+16
View File
@@ -755,6 +755,10 @@ export const ru = {
"conversations.started": "Начат",
"conversations.take_conversation": "Взять диалог",
"conversations.templates": "Шаблоны",
"conversations.variable_client_name": "Имя клиента",
"conversations.variable_operator_name": "Имя оператора",
"conversations.variable_company": "Название организации",
"conversations.fill_template_variables": "Заполните в тексте: {names}",
"conversations.transcribe": "Расшифровать",
"conversations.transcribing": "Расшифровываем…",
"conversations.type_message_shift_enter_line": "Введите сообщение… Shift+Enter — перенос строки, «/» — шаблон ответа",
@@ -1475,6 +1479,18 @@ export const ru = {
"settings.queue_restore_defaults": "Вернуть обычные сроки",
"settings.queue_policy_unavailable": "Не удалось загрузить сроки",
"settings.queue_delivery_warning": "Напоминания приходят туда, где сотрудник их получит: в браузер и в привязанный мессенджер. Если у группы не настроен ни один способ, останется только счётчик в списке диалогов.",
"settings.reply_templates": "Шаблоны ответов",
"settings.reply_templates_lead": "Готовые ответы для операторов. В диалоге шаблон вставляется, если набрать «/» в поле сообщения.",
"settings.add_template": "Добавить шаблон",
"settings.no_templates_yet": "Шаблонов пока нет. Добавьте первый — операторы вызовут его через «/».",
"settings.could_not_load_templates": "Не удалось загрузить шаблоны",
"settings.new_template": "Новый шаблон",
"settings.edit_template": "Изменить шаблон",
"settings.template_text": "Текст",
"settings.insert_variable": "Вставить переменную",
"settings.could_not_save_template": "Не удалось сохранить шаблон",
"settings.delete_template": "Удалить шаблон?",
"settings.template_will_be_deleted": "Шаблон «{name}» будет удалён — операторы больше не смогут его вставить.",
"settings.ai_provider": "AI-провайдер",
"settings.allowed_domains": "Разрешённые домены",
"settings.answers_from_agent_s_knowledge": "Отвечает по знаниям агента без внешних запросов и ключей. Качество ответов ограничено — для реальной работы подключите OpenRouter или совместимый провайдер.",
@@ -1,4 +1,4 @@
import { useRef, useState, type ReactNode } from "react";
import { useRef, useState, type ReactNode, type RefObject } from "react";
import { Icon } from "./icons";
import { SelectMenu } from "./ui-controls";
@@ -121,12 +121,13 @@ export function SelectField({ adornment, disabled = false, invalid = false, labe
);
}
export function TextAreaField({ disabled = false, label, value, onChange }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void }) {
/** `inputRef` — для вставки в позицию курсора (переменные шаблона ответа). */
export function TextAreaField({ disabled = false, label, value, onChange, inputRef }: { disabled?: boolean; label: string; value: string; onChange: (value: string) => void; inputRef?: RefObject<HTMLTextAreaElement | null> }) {
const className = ["readonly-field", "form-field", "wide", disabled ? "is-readonly is-disabled" : "is-editable"].join(" ");
return (
<label className={className}>
<span>{label}</span>
<textarea value={value} disabled={disabled} readOnly={disabled} onChange={(event) => onChange(event.target.value)} />
<textarea ref={inputRef} value={value} disabled={disabled} readOnly={disabled} onChange={(event) => onChange(event.target.value)} />
</label>
);
}