Compare commits

...
8 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5 c8b8246b35 🔖 release: 1.13.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:32:54 +03:00
AndreyandClaude Opus 5 b95a4edf6e 🐛 fix(settings): адрес установки помнит порт, ссылки ведут на него
Мастер первого запуска и «Настройки» отбрасывали порт из адреса. На
установке, опубликованной как ip:8081, ссылки на файлы, звонки, приглашения
и сброс пароля уходили без порта и вели в пустоту.

Порт хранится отдельно от хоста (InstanceSettings.public_port): по хосту
проверяются входящие Host, строятся домены порталов и адреса TURN, и порт им
чужой. В ссылки он добавляется, когда не совпадает с портом схемы. В поле
адреса его вписывают через двоеточие; смена одного порта прежний адрес не
сдвигает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:32:46 +03:00
AndreyandClaude Opus 5 19448eb05a ✨ feat(ai): размер контекста диалога задаётся в карточке агента
Модель получала последние 20 сообщений диалога — число было зашито в
ingest.py, и поменять его можно было только правкой кода, которую стирало
первое же обновление. Длинному разговору 20 мало, локальной модели с
маленьким окном — бывает много.

Теперь это поле «Контекст диалога, сообщений» в блоке «Модель» карточки
агента: от 1 до 200, по умолчанию 20. Проверочный чат карточки видит то же
окно, что и живой диалог.

Попутно история выбирается в базе с конца и с ограничением: раньше диалог
целиком поднимался в память ради последних сообщений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:32:46 +03:00
AndreyandClaude Opus 5 a321109a8f 🐛 fix(gateway): установка на нестандартном порту принимает формы
Шлюз передавал дальше Host без порта ({host} в Caddy, $host в nginx). Браузер
при этом шлёт Origin с портом, и CSRF-проверка Django отвергала любой POST на
установке, опубликованной как ip:8081: «Origin checking failed».

Теперь Host уходит с портом. X-Forwarded-Proto и X-Forwarded-For Caddy
принимает от прокси из частных сетей — установку часто ставят за прокси
панели, который снимает TLS и ходит к шлюзу по http; без этого CSRF падал бы
снова, как только перед установкой появлялся https.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:32:25 +03:00
Andrey e1cda4dd8b 🔖 release: 1.12.0 2026-09-17 12:29:02 +03:00
AndreyandClaude Opus 5 fed079e02d ✨ feat(notifications): открытый диалог гасит свои уведомления
Прочтением считалось только нажатие на строку в шторке. Сотрудник мог уже
отвечать клиенту, а оклик «клиент ждёт» по этому же диалогу продолжал висеть
непрочитанным и держать счётчик в шапке. Счётчик переставал что-либо значить:
он звал туда, где человек уже был.

Теперь при открытии диалога гасятся все уведомления о нём — не только те,
что клиент успел загрузить в шторку: в неё помещается полсотни последних, и
гасить по загруженному списку означало бы оставлять хвост. Отбор идёт по
адресу уведомления, включая маршруты, которыми на диалог ссылались старые
строки в базе.

Отметка прочтения теперь шлёт сотруднику событие: счётчик живёт в шапке
каждой открытой вкладки, и без события они разъезжались до следующего опроса.

Проверено: тесты уведомлений (гасится только свой диалог, гасятся все его
уведомления, старый маршрут узнаётся, нечисловой идентификатор отклоняется),
рассылка событий, каталоги переводов, ruff, проверка типов рабочего места.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:55:01 +03:00
AndreyandClaude Opus 5 89c44db2bc 🐛 fix(chat): точка режима держится на аватаре в любой строке диалога
Строка списка — flex, и её ячейки по умолчанию растягиваются на всю высоту.
Обёртка аватара растягивалась вместе с ними, а точка режима привязана к её
низу — в строке с метками или таймером ожидания обёртка вырастала с 38 до 62
пикселей, и точка уезжала под аватар на четверть сантиметра. Чем больше меток
у диалога, тем дальше.

Обёртка прижата к началу поперечной оси и снова ростом с аватар, поэтому
точка стоит в его углу независимо от того, что показано в строке ниже.

Проверено на короткой строке и на строке с меткой группы и таймером: высота
обёртки 38 в обеих, точка на 1 пиксель за краем аватара.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:43:00 +03:00
AndreyandClaude Opus 5 ca23cd285f 🐛 fix(vk): марка ВКонтакте вместо чужого значка в ленте и карточке
Канал ВКонтакте появился, а марки у него не было: в ленте диалогов и в
карточке контакта рисовался общий пузырь сообщения, а в строке логина —
значок отправки, который для Telegram выглядел уместно только по
совпадению. Клиент из ВКонтакте выглядел пришедшим непонятно откуда.

Теперь `ChannelGlyph` знает ВКонтакте и отдаёт тот же официальный
монохромный глиф, что стоит на плитке подключения, а строка логина берёт
марку своего канала вместо общего значка: Telegram, MAX и ВКонтакте —
каждый свой.

Проверено: глифы на 13-18px в тёмной теме рядом с Telegram и MAX, лента
диалогов, строка логина и плитки каналов в карточке контакта; tsc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:38:40 +03:00
33 changed files with 439 additions and 58 deletions

No files matched your search

+18 -8
View File
@@ -16,18 +16,29 @@
on_demand_tls {
ask http://backend-platform:8000/api/v1/gateway/help-domain/
}
# Установку часто ставят за прокси панели (aaPanel, nginx хоста), который
# снимает TLS и ходит сюда по http. Его X-Forwarded-Proto/For принимаются
# только из частных сетей — оттуда, где такой прокси и стоит; клиент из
# интернета подделать их не может.
servers {
trusted_proxies static private_ranges
}
}
(surfaces) {
# Host уходит с портом: браузер шлёт Origin с портом (http://ip:8081), и
# без него CSRF-проверка Django отвергала любой POST на нестандартном
# порту. X-Forwarded-Proto Caddy ставит сам: {scheme}, а за доверенным
# прокси — то, что прислал прокси (https, если TLS снят перед нами).
# Платформенная поверхность живёт на своём домене; пока он не задан,
# матчер намеренно не совпадает ни с чем.
@platform host {$CHATBALLS_PLATFORM_DOMAIN:platform.invalid}
handle @platform {
reverse_proxy backend-platform:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up Host {hostport}
header_up X-Real-IP {client_ip}
header_up X-Forwarded-For {client_ip}
}
}
@@ -35,10 +46,9 @@
# frontend-контейнера.
handle {
reverse_proxy frontend:80 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up Host {hostport}
header_up X-Real-IP {client_ip}
header_up X-Forwarded-For {client_ip}
}
}
}
+1 -1
View File
@@ -1 +1 @@
1.11.0
1.13.0
+16 -1
View File
@@ -12,7 +12,7 @@ from django.db import transaction
from django.db.models import Case, Count, IntegerField, Q, QuerySet, Value, When
from django.utils.text import slugify
from chatballs.ai.models import AIAgent, AIAgentStatus, AnswerLanguage
from chatballs.ai.models import HISTORY_LIMIT_MAX, AIAgent, AIAgentStatus, AnswerLanguage
from chatballs.ai.serializers import agent_portal_article_payload
from chatballs.channels.models import Channel
from chatballs.channels.services import (
@@ -146,6 +146,7 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
"transcriptionIntegrationId": agent.transcription_integration_id,
"modelParams": agent.model_params,
"answerLanguage": agent.answer_language,
"historyLimit": agent.history_limit,
"persona": agent.persona,
"tone": agent.tone,
"instructions": agent.instructions,
@@ -255,6 +256,7 @@ def update_agent_card(
"tone",
"instructions",
"answerLanguage",
"historyLimit",
"knowledgeIds",
}
if ai_fields & set(body):
@@ -305,6 +307,9 @@ def update_agent_card(
answer_language=_clean_answer_language(
body.get("answerLanguage", agent.answer_language)
),
history_limit=_clean_history_limit(
body.get("historyLimit", agent.history_limit)
),
knowledge_ids=knowledge_ids,
),
)
@@ -326,6 +331,16 @@ def _clean_answer_language(value: object) -> str:
raise ValidationError({"answerLanguage": t("ai.unknown_answer_language")})
def _clean_history_limit(value: object) -> int:
"""Окно истории агента: целое число сообщений от 1 до HISTORY_LIMIT_MAX."""
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= HISTORY_LIMIT_MAX:
raise ValidationError(
{"historyLimit": t("ai.history_limit_out_of_range", max=HISTORY_LIMIT_MAX)}
)
return value
def agent_deletion_blockers(channel: Channel) -> list[dict[str, object]]:
"""Агент удаляется вместе с каналом; блокируют только внешние связи."""
counts = (
@@ -246,6 +246,10 @@ class AgentCardTestChatView(APIView):
history = request.data.get("history") or []
if not isinstance(history, list):
return Response({"detail": t("ai.history_must_be_list")}, status=400)
# Проверочный чат видит то же окно истории, что и живой диалог.
agent = getattr(channel, "ai_agent", None)
if agent is not None:
history = history[-agent.history_limit:]
try:
result = run_channel_turn(channel=channel, message=message, history=history)
except ProviderError as error:
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ai", "0022_release_agent_model_from_integration"),
]
operations = [
migrations.AddField(
model_name="aiagent",
name="history_limit",
field=models.PositiveSmallIntegerField(default=20),
),
]
+9
View File
@@ -14,6 +14,11 @@ DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
# Границы окна истории агента (AIAgent.history_limit).
HISTORY_LIMIT_DEFAULT = 20
HISTORY_LIMIT_MAX = 200
class AnswerLanguage(models.TextChoices):
"""Режимы поля ``AIAgent.answer_language``, кроме кодов самих языков."""
@@ -393,6 +398,10 @@ class AIAgent(TenantRelationModel):
transcription_model = models.CharField(max_length=128, blank=True, default="")
model_params = models.JSONField(default=dict, blank=True)
# Сколько последних сообщений диалога уходит модели вместе с новым. Больше —
# агент помнит длинный разговор, но каждый ответ дороже, а у локальной
# модели с малым окном контекста хвост просто обрежется на её стороне.
history_limit = models.PositiveSmallIntegerField(default=HISTORY_LIMIT_DEFAULT)
# Язык ответов клиенту. По умолчанию агент отвечает на языке, на котором
# к нему обратились: сигнал точный, лежит прямо в сообщении и не требует
+3
View File
@@ -33,6 +33,7 @@ class AgentInput:
tone: str
instructions: str
answer_language: str
history_limit: int
knowledge_ids: list[int] | None # None -> выбор знаний не меняется
@@ -151,6 +152,7 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
locked.tone = data.tone
locked.instructions = data.instructions
locked.answer_language = data.answer_language
locked.history_limit = data.history_limit
locked.save(
update_fields=[
"name",
@@ -164,6 +166,7 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
"tone",
"instructions",
"answer_language",
"history_limit",
"updated_at",
]
)
@@ -253,6 +253,16 @@ class AgentCardUpdateTests(AgentCardTestCase):
self.patch(knowledgeIds=[])
self.assertEqual(agent.knowledge_items.count(), 0)
def test_history_limit_is_saved_and_validated(self) -> None:
self.assertEqual(self.card["historyLimit"], 20)
saved = self.patch(historyLimit=100)
self.assertEqual(saved.status_code, 200)
self.assertEqual(saved.json()["agent"]["historyLimit"], 100)
for wrong in (0, 201, "50", 12.5, True, None):
with self.subTest(value=wrong):
self.assertEqual(self.patch(historyLimit=wrong).status_code, 400)
self.assertEqual(AIAgent.objects.get(id=self.card["aiAgentId"]).history_limit, 100)
class AgentCardActivationTests(AgentCardTestCase):
def setUp(self) -> None:
+14 -5
View File
@@ -13,6 +13,7 @@ from dataclasses import dataclass
from django.db import IntegrityError, transaction
from django.utils import timezone
from chatballs.ai.models import HISTORY_LIMIT_DEFAULT
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.runtime import HANDOFF_TOKEN
from chatballs.channels.runtime import run_channel_turn
@@ -41,7 +42,6 @@ from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
_HISTORY_LIMIT = 20
_ROLE = {
MessageAuthor.CONTACT: "user",
MessageAuthor.AI: "assistant",
@@ -74,9 +74,12 @@ def _already_processed(context: TenantContext, source: str, external_id: str, te
return True
def _history(conversation: Conversation) -> list[dict]:
messages = list(conversation.messages.order_by("created_at"))
prior = messages[:-1][-_HISTORY_LIMIT:] # без только что сохранённого входящего
def _history(conversation: Conversation, limit: int) -> list[dict]:
# С конца и с ограничением в базе: длинный диалог не поднимается в память
# целиком ради последних сообщений. Самое новое — только что сохранённое
# входящее, оно уходит модели отдельно.
latest = conversation.messages.order_by("-created_at", "-id")[: limit + 1]
prior = list(reversed(latest))[:-1]
# Голосовые попадают в контекст стенограммой.
return [{"role": _ROLE.get(m.author_type, "user"), "content": m.text or m.transcript} for m in prior if m.text or m.transcript]
@@ -406,7 +409,13 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
return
try:
result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation))
result = run_channel_turn(
channel=channel,
message=ai_input,
history=_history(
conversation, agent.history_limit if agent else HISTORY_LIMIT_DEFAULT
),
)
except ProviderError as error:
# Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
# оператору, уведомляем и отвечаем клиенту понятным fallback.
@@ -69,9 +69,23 @@ class VoiceAiReplyTests(TestCase):
self._ingest({"return_value": "Первый вопрос"}, {"return_value": mock.Mock(text="Ответ")})
conversation = self.channel.conversations.get()
conversation.messages.create(author_type=MessageAuthor.CONTACT, text="Второй")
roles = [(h["role"], h["content"]) for h in _history(conversation)]
roles = [(h["role"], h["content"]) for h in _history(conversation, 20)]
self.assertEqual(roles, [("user", "Первый вопрос"), ("assistant", "Ответ")])
def test_ai_history_window_follows_agent_setting(self) -> None:
self.channel.ai_agent.history_limit = 3
self.channel.ai_agent.save(update_fields=["history_limit"])
self._ingest({"return_value": "Первый вопрос"}, {"return_value": mock.Mock(text="Ответ")})
conversation = self.channel.conversations.get()
for number in range(1, 6):
conversation.messages.create(author_type=MessageAuthor.CONTACT, text=f"Сообщение {number}")
self.inbound = InboundMessage(external_id="t-2", user_id="u-1", chat_id="c-1", text="Последнее", display_name="Ольга")
_, run = self._ingest({"return_value": ""}, {"return_value": mock.Mock(text="Ответ")})
history = [item["content"] for item in run.call_args.kwargs["history"]]
# Три сообщения перед новым; само новое уходит модели отдельно.
self.assertEqual(history, ["Сообщение 3", "Сообщение 4", "Сообщение 5"])
self.assertEqual(run.call_args.kwargs["message"], "Последнее")
class CommunicationSettingsTests(TestCase):
def setUp(self) -> None:
@@ -146,6 +146,7 @@ MESSAGES: dict[str, object] = {
"settings.http_or_https": "http or https",
"settings.integration_not_found": "Integration not found",
"settings.invalid_address": "Invalid address",
"settings.invalid_port": "Port must be a number from 1 to 65535",
"settings.items_list_expected": "A list of items is expected",
"settings.language_unsupported": "This language is not supported",
"settings.migration_running": "A migration is already running",
@@ -357,6 +358,7 @@ MESSAGES: dict[str, object] = {
"identity.role_conflict": "User already has a different role in this organization",
"identity.token_required": "token is required",
"identity.totp_challenge_inactive": "TOTP challenge is not active",
"notifications.conversation_id_number": "conversationId must be a number",
"notifications.ids_list_or_all": "ids must be a list or use all=true",
"notifications.push_types_list": "pushTypes must be a list",
"notifications.enabled_bool": "enabled must be true or false",
@@ -579,4 +581,5 @@ MESSAGES: dict[str, object] = {
"ai.demo_handover": "I'll check this with my colleagues and hand the dialog to a member of staff — they will answer during working hours.",
"ai.demo_handover_suffix": " I'm handing the dialog to a member of staff — they will help from here.",
"ai.unknown_answer_language": "Unknown agent answer language",
"ai.history_limit_out_of_range": "Context size must be a whole number from 1 to {max}",
}
@@ -150,6 +150,7 @@ MESSAGES: dict[str, object] = {
"settings.http_or_https": "http или https",
"settings.integration_not_found": "Интеграция не найдена",
"settings.invalid_address": "Некорректный адрес",
"settings.invalid_port": "Порт — число от 1 до 65535",
"settings.items_list_expected": "Ожидается список items",
"settings.language_unsupported": "Этот язык не поддерживается",
"settings.migration_running": "Перенос уже идёт",
@@ -361,6 +362,7 @@ MESSAGES: dict[str, object] = {
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
"identity.token_required": "Нужен токен",
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
"notifications.conversation_id_number": "conversationId должен быть числом",
"notifications.ids_list_or_all": "ids — список, либо all=true",
"notifications.push_types_list": "pushTypes — список",
"notifications.enabled_bool": "enabled — да или нет",
@@ -583,4 +585,5 @@ MESSAGES: dict[str, object] = {
"ai.demo_handover": "Уточню этот вопрос у коллег и передам диалог сотруднику — он ответит в рабочее время.",
"ai.demo_handover_suffix": " Передаю диалог сотруднику — он поможет дальше.",
"ai.unknown_answer_language": "Неизвестный язык ответов агента",
"ai.history_limit_out_of_range": "Размер контекста — целое число от 1 до {max}",
}
@@ -22,8 +22,14 @@ from chatballs.support_portals.addressing import normalize_domain
class InstanceSettings(models.Model):
SINGLETON_PK = 1
# Хост без схемы и порта: «crm.example.com» или «203.0.113.10».
# Хост без схемы и порта: «crm.example.com» или «203.0.113.10». Именно
# хост, а не адрес: по нему проверяются входящие Host, строятся домены
# порталов и адреса TURN — порт им чужой.
public_host = models.CharField(max_length=253, blank=True, default="")
# Порт, если установку открывают не на стандартном для схемы: шлюз
# опубликован как 8081, а 80-й занят панелью или другим сервисом. Пусто —
# порт схемы (80/443), и в ссылках его нет.
public_port = models.PositiveIntegerField(null=True, blank=True)
# Предыдущий адрес: остаётся принятым, чтобы смена адреса не выбрасывала
# того, кто её делает. Владелец меняет адрес заранее — до того, как домен
# начал резолвиться и получил сертификат, — и сидит при этом на старом.
@@ -179,18 +185,49 @@ def remember_default_language(language: str) -> None:
row.save(update_fields=["default_language", "updated_at"])
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан."""
_DEFAULT_PORTS = {"http": 80, "https": 443}
host = normalize_domain(raw_host.partition(":")[0])
if not host:
def split_address(raw: str, scheme: str) -> tuple[str, int | None] | None:
"""Хост и порт из «host[:port]»; None — адрес не разобрать.
Порт схемы (80 у http, 443 у https) отбрасывается: в ссылке он лишний, а
хранить его значило бы различать два одинаковых адреса.
"""
host, separator, port_text = raw.strip().rpartition(":")
if not separator:
return normalize_domain(port_text), None
host = normalize_domain(host)
if not port_text.isdigit() or not 1 <= int(port_text) <= 65535:
return None
port = int(port_text)
return host, None if port == _DEFAULT_PORTS.get(scheme) else port
def format_address(host: str, port: int | None) -> str:
"""Адрес для ссылок и для поля в «Настройках»: хост и порт, если он не схемы."""
return f"{host}:{port}" if host and port else host
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан.
Порт запоминается вместе с хостом: установку, открытую на ``ip:8081``,
дальше открывают там же, и ссылки без порта вели бы в пустоту.
"""
scheme = "https" if scheme == "https" else "http"
parsed = split_address(raw_host, scheme)
if parsed is None or not parsed[0]:
return
row = InstanceSettings.load()
if row.public_host:
return
row.public_host = host
row.public_scheme = "https" if scheme == "https" else "http"
row.save(update_fields=["public_host", "public_scheme", "updated_at"])
row.public_host, row.public_port = parsed
row.public_scheme = scheme
row.save(update_fields=["public_host", "public_port", "public_scheme", "updated_at"])
def public_base_url() -> str:
@@ -215,7 +252,7 @@ def public_base_url() -> str:
row = None
if row is not None and row.public_host:
scheme = row.public_scheme or "http"
return f"{scheme}://{row.public_host}"
return f"{scheme}://{format_address(row.public_host, row.public_port)}"
return str(settings.CHATBALLS_PUBLIC_BASE_URL).rstrip("/")
@@ -20,17 +20,20 @@ from chatballs.identity.instance_settings import (
default_turn_urls,
email_connection,
email_from_address,
format_address,
invalidate_cache,
public_base_url,
split_address,
)
from chatballs.support_portals.addressing import normalize_domain, validate_domain
from chatballs.support_portals.addressing import validate_domain
SCHEMES = ("http", "https")
def instance_payload(row: InstanceSettings) -> dict:
return {
"publicHost": row.public_host,
# Адрес целиком, с портом, если он не схемы: так его и вписывают.
"publicHost": format_address(row.public_host, row.public_port),
"publicScheme": row.public_scheme or "http",
"publicUrl": public_base_url(),
# Язык экранов, где организации ещё нет: логин, сброс пароля, мастер.
@@ -77,12 +80,22 @@ class InstanceAddressView(APIView):
body = request.data if isinstance(request.data, dict) else {}
errors: dict[str, str] = {}
# Владелец может вставить и целый URL из адресной строки — берём хост.
raw_host = str(body.get("publicHost", row.public_host)).strip()
scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower()
if scheme not in SCHEMES:
errors["publicScheme"] = t("settings.http_or_https")
# Владелец может вставить и целый URL из адресной строки — берём хост
# и порт, путь отбрасываем.
raw_host = str(
body.get("publicHost", format_address(row.public_host, row.public_port))
).strip()
if "//" in raw_host:
raw_host = raw_host.split("//", 1)[1]
host = normalize_domain(raw_host.split("/", 1)[0].split(":", 1)[0])
if not host:
parsed = split_address(raw_host.split("/", 1)[0], scheme)
host, port = parsed if parsed is not None else ("", None)
if parsed is None:
errors["publicHost"] = t("settings.invalid_port")
elif not host:
errors["publicHost"] = t("settings.address_required")
else:
try:
@@ -92,10 +105,6 @@ class InstanceAddressView(APIView):
except ValidationError:
errors["publicHost"] = t("settings.invalid_address")
scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower()
if scheme not in SCHEMES:
errors["publicScheme"] = t("settings.http_or_https")
raw_language = str(body.get("defaultLanguage", row.default_language)).strip()
language = normalize_language(raw_language)
if raw_language and not language:
@@ -108,6 +117,7 @@ class InstanceAddressView(APIView):
fields = [
"public_host",
"public_port",
"public_scheme",
"previous_public_host",
"default_language",
@@ -118,6 +128,7 @@ class InstanceAddressView(APIView):
# сидя на старом, и не должен выпасть из установки в тот же миг.
row.previous_public_host = row.public_host
row.public_host = host
row.public_port = port
row.public_scheme = scheme
row.default_language = language or DEFAULT_LANGUAGE
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0039_remove_organization_currency"),
]
operations = [
migrations.AddField(
model_name="instancesettings",
name="public_port",
field=models.PositiveIntegerField(blank=True, null=True),
),
]
@@ -88,6 +88,33 @@ class InstanceAddressChangeTests(TestCase):
self.assertEqual(set(accepted_hosts()), {"crm.example.test", "203.0.113.10"})
def test_port_is_kept_apart_from_the_host(self) -> None:
"""Шлюз на 8081: порт идёт в ссылки, но не в хост — по хосту
проверяются входящие Host и строятся домены порталов."""
response = self._patch("http://203.0.113.10:8081/login", scheme="http")
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()["instance"]
self.assertEqual(payload["publicHost"], "203.0.113.10:8081")
self.assertEqual(payload["publicUrl"], "http://203.0.113.10:8081")
row = InstanceSettings.load()
self.assertEqual((row.public_host, row.public_port), ("203.0.113.10", 8081))
# Смена одного порта — не смена адреса: прежний хост не сдвигается.
self.assertEqual(row.previous_public_host, "")
def test_scheme_port_is_dropped(self) -> None:
self._patch("crm.example.test:8081")
response = self._patch("crm.example.test:443")
self.assertEqual(response.json()["instance"]["publicUrl"], "https://crm.example.test")
self.assertIsNone(InstanceSettings.load().public_port)
def test_bad_port_is_rejected(self) -> None:
for address in ("203.0.113.10:0", "203.0.113.10:70000", "203.0.113.10:web", "203.0.113.10:"):
with self.subTest(address=address):
self.assertEqual(self._patch(address, scheme="http").status_code, 400)
self.assertEqual(InstanceSettings.load().public_host, "203.0.113.10")
class PortalDomainCollisionTests(TestCase):
def setUp(self) -> None:
@@ -54,6 +54,25 @@ class SetupWizardTests(TestCase):
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(InstanceSettings.load().public_host, "203.0.113.10")
def test_setup_on_a_custom_port_keeps_the_port_for_links(self) -> None:
"""Шлюз опубликован на 8081: ссылки без порта вели бы в пустоту."""
invalidate_cache()
response = self.complete(HTTP_HOST="203.0.113.10:8081")
self.assertEqual(response.status_code, 201, response.content)
row = InstanceSettings.load()
self.assertEqual((row.public_host, row.public_port), ("203.0.113.10", 8081))
invalidate_cache()
self.assertEqual(public_base_url(), "http://203.0.113.10:8081")
def test_setup_on_the_scheme_port_stores_no_port(self) -> None:
invalidate_cache()
self.complete(HTTP_HOST="crm.example.test:80")
self.assertIsNone(InstanceSettings.load().public_port)
def test_remembered_address_keeps_working_after_setup(self) -> None:
self.complete(HTTP_HOST="crm.example.test")
invalidate_cache()
@@ -12,6 +12,7 @@ from chatballs.notifications.models import (
NotificationRead,
NotificationType,
)
from chatballs.notifications.realtime import notify_notifications_changed
from chatballs.notifications.selectors import unread_for
# Реестр типов: дефолтный уровень и маршрут диплинка. Новый тип события —
@@ -131,9 +132,32 @@ def notify_management(*, context, dedup_key: str = "", **notification_data) -> i
return created
def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False) -> int:
# Маршруты, которыми уведомление ссылается на диалог. Кроме нынешнего «chat»
# здесь старые значения: они лежат в уже созданных строках, и открытый диалог
# обязан гасить и их тоже.
CONVERSATION_ROUTES = ("chat", "salesDialogs", "conversations")
def mark_read(
*,
context,
ids: list[int] | None = None,
all_unread: bool = False,
conversation_id: int | None = None,
) -> int:
"""Отметить уведомления прочитанными: перечисленные, все или про диалог.
Про диалог — потому что открытый диалог и есть прочтение: оклик «клиент
ждёт» бессмысленно висеть непрочитанным, когда сотрудник уже в переписке.
Гасятся все уведомления об этом диалоге, а не только те, что клиент успел
загрузить в шторку.
"""
queryset = unread_for(context)
if not all_unread:
if conversation_id is not None:
queryset = queryset.filter(
target_route__in=CONVERSATION_ROUTES, target_id=str(conversation_id)
)
elif not all_unread:
queryset = queryset.filter(id__in=ids or [])
rows = [
NotificationRead(
@@ -144,4 +168,8 @@ def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False
for notification in queryset
]
NotificationRead.objects.bulk_create(rows, ignore_conflicts=True)
if rows:
# Счётчик непрочитанных живёт в шапке каждой открытой вкладки: без
# события они разъезжаются до следующего опроса.
notify_notifications_changed([context.actor_user.id])
return len(rows)
+80 -1
View File
@@ -13,10 +13,11 @@ from chatballs.notifications.delivery import NOTIFICATION_CREATED
from chatballs.notifications.models import (
MessengerBinding,
MessengerBindingCode,
Notification,
NotificationAudience,
NotificationType,
)
from chatballs.notifications.selectors import visible_for
from chatballs.notifications.selectors import unread_for, visible_for
from chatballs.notifications.services import notify
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.testing import tenant_context_for
@@ -217,6 +218,84 @@ class PollerSelectionTests(NotifierTestBase):
self.assertNotIn("inactive-channel-client", polled_names)
class OpenedConversationReadTests(NotifierTestBase):
"""Открытый диалог гасит свои уведомления.
Раньше оклик «клиент ждёт» оставался непрочитанным, пока по нему не нажали
в шторке: сотрудник мог отвечать в переписке, а счётчик в шапке продолжал
звать его туда же.
"""
def setUp(self) -> None:
super().setUp()
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def _notify(self, *, target_id: str, type=NotificationType.OPERATOR_REQUESTED):
return notify(
context=self.context,
type=type,
audience=NotificationAudience.OPERATORS,
title="Клиент ждёт",
target_id=target_id,
)
def test_opened_conversation_marks_only_its_own_notifications(self) -> None:
opened = self._notify(target_id="17")
another = self._notify(target_id="18")
response = self.client.post(
"/api/v1/notifications/read/",
data={"conversationId": 17},
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["unreadCount"], 1)
unread = list(unread_for(self.context).values_list("id", flat=True))
self.assertEqual(unread, [another.id])
self.assertNotIn(opened.id, unread)
def test_every_notification_about_the_conversation_is_marked(self) -> None:
# В шторку помещается не всё: гасим по диалогу, а не по тому, что успел
# загрузить клиент.
waiting = self._notify(target_id="17")
message = self._notify(target_id="17", type=NotificationType.DIALOG_NEW_MESSAGE)
self.client.post(
"/api/v1/notifications/read/",
data={"conversationId": 17},
content_type="application/json",
)
read = set(unread_for(self.context).values_list("id", flat=True))
self.assertNotIn(waiting.id, read)
self.assertNotIn(message.id, read)
def test_legacy_route_of_old_notifications_is_recognized(self) -> None:
old = self._notify(target_id="17")
Notification.objects.filter(id=old.id).update(target_route="salesDialogs")
self.client.post(
"/api/v1/notifications/read/",
data={"conversationId": 17},
content_type="application/json",
)
self.assertEqual(unread_for(self.context).count(), 0)
def test_broken_conversation_id_is_rejected(self) -> None:
self._notify(target_id="17")
response = self.client.post(
"/api/v1/notifications/read/",
data={"conversationId": "17"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(unread_for(self.context).count(), 1)
class BindingApiTests(NotifierTestBase):
def setUp(self) -> None:
super().setUp()
@@ -129,8 +129,14 @@ class NotificationReadView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
conversation_id = request.data.get("conversationId")
if request.data.get("all"):
mark_read(context=request.tenant_context, all_unread=True)
elif conversation_id is not None:
# Сотрудник открыл диалог — окликать по нему больше нечем.
if not isinstance(conversation_id, int) or isinstance(conversation_id, bool):
return Response({"detail": t("notifications.conversation_id_number")}, status=400)
mark_read(context=request.tenant_context, conversation_id=conversation_id)
else:
ids = request.data.get("ids")
if not isinstance(ids, list):
@@ -30,6 +30,7 @@ import {
patchAgent,
setAgentAiActive,
unbindAgentConnection,
HISTORY_LIMIT_MAX,
type AgentCard,
type AgentConnection,
type AgentPatch,
@@ -541,6 +542,10 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
const [transcriptionDraft, setTranscriptionDraft] = useState(card.transcriptionModel);
useEffect(() => { setModelDraft(card.model); }, [card.model]);
useEffect(() => { setTranscriptionDraft(card.transcriptionModel); }, [card.transcriptionModel]);
const [historyDraft, setHistoryDraft] = useState(String(card.historyLimit));
useEffect(() => { setHistoryDraft(String(card.historyLimit)); }, [card.historyLimit]);
const historyValue = Number(historyDraft);
const historyValid = /^\d+$/.test(historyDraft.trim()) && historyValue >= 1 && historyValue <= HISTORY_LIMIT_MAX;
return (
<section className="agent-card is-side">
@@ -569,6 +574,18 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
onChange={setModelDraft}
onBlur={() => { if (modelDraft !== card.model) void apply({ model: modelDraft }); }}
/>
{/* Сколько последних сообщений диалога модель получает вместе с новым.
Больше — агент помнит длинный разговор, но ответ дороже, а у
локальной модели с малым окном хвост обрежется на её стороне. */}
<FormField
disabled={busy || !canManage}
error={historyValid ? undefined : t("ai.history_limit_invalid", { max: HISTORY_LIMIT_MAX })}
label={t("ai.history_limit")}
type="number"
value={historyDraft}
onChange={setHistoryDraft}
onBlur={() => { if (historyValid && historyValue !== card.historyLimit) void apply({ historyLimit: historyValue }); }}
/>
{/* Речь в текст умеет не всякая модель, которой агент отвечает: у части
провайдеров аудио-эндпоинта нет вовсе. Поэтому выбор отдельный. */}
<SelectField
@@ -46,6 +46,8 @@ export type AgentCard = {
modelParams: Record<string, unknown>;
// Режим языка ответов: MIRROR, ORGANIZATION или код языка.
answerLanguage: string;
/** Сколько последних сообщений диалога агент видит вместе с новым. */
historyLimit: number;
persona: string;
tone: string;
instructions: string;
@@ -59,6 +61,9 @@ export type AgentCard = {
updatedAt: string;
};
/** Границы окна истории — те же, что проверяет сервер (ai.models). */
export const HISTORY_LIMIT_MAX = 200;
export type AgentPatch = Partial<{
name: string;
groupId: number | null;
@@ -68,6 +73,7 @@ export type AgentPatch = Partial<{
model: string;
transcriptionModel: string;
answerLanguage: string;
historyLimit: number;
persona: string;
tone: string;
instructions: string;
@@ -46,6 +46,7 @@ import type { ConversationListItem, ListSort, ListTab } from "./types";
import { useConversationCall } from "./useConversationCall";
import { useConversationEvents } from "./useConversationEvents";
import { useDebounced } from "../../shared/useDebounced";
import { useOpenedConversationRead } from "../notifications/useOpenedConversationRead";
import { useConversationHistory } from "./useConversationHistory";
import { useConversationList } from "./useConversationList";
import { useDialogKeyboardNav } from "./useDialogKeyboardNav";
@@ -148,6 +149,8 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
setCtxOpen(false);
}, [selectedId]);
useOpenedConversationRead(selectedId);
useEffect(() => {
if (selectedId == null) return;
setDetail(null);
@@ -24,6 +24,10 @@ export const LEVEL_META: Record<NotificationLevel, { color: string; icon: "bell"
export const fetchNotifications = () => api<{ items: AppNotification[]; unreadCount: number }>("/api/v1/notifications/");
export const markRead = (ids: number[]) => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ ids }) });
export const markAllRead = () => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ all: true }) });
// Открытый диалог — тоже прочтение: гасит все уведомления о нём, а не только
// те, что попали в шторку.
export const markConversationRead = (conversationId: number) =>
api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ conversationId }) });
export type NotificationTransport = "BROWSER" | "MESSENGER";
@@ -0,0 +1,19 @@
import { useEffect } from "react";
import { markConversationRead } from "./model";
/** Открытый диалог гасит свои уведомления.
*
* Раньше оклик «клиент ждёт» оставался непрочитанным, даже когда сотрудник уже
* отвечал в этой переписке: прочтением считалось только нажатие на строку в
* шторке. Счётчик в шапке жил своей жизнью и переставал что-либо значить.
*
* Список уведомлений после этого перезапрашивает шапка — по событию, которое
* шлёт сервер тому же сотруднику: вкладок у него может быть несколько.
*/
export function useOpenedConversationRead(conversationId: number | null): void {
useEffect(() => {
if (conversationId == null) return;
void markConversationRead(conversationId).catch(() => undefined);
}, [conversationId]);
}
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { ChannelGlyph } from "../../../../shared/badges";
import { Icon } from "../../../../shared/icons";
import { CopyButton } from "../../../../shared/ui-controls";
import { providerMeta } from "../../../../shared/providers";
@@ -73,10 +74,12 @@ export function ClientContext({
}
}
const fields: Array<{ key: string; icon: Parameters<typeof Icon>[0]["name"]; text: string; copy?: string; muted?: boolean }> = [];
// Логин показывается маркой своего канала: общий значок отправки годился,
// пока каналов с логином было два, и врал уже на третьем.
const fields: Array<{ key: string; icon: Parameters<typeof Icon>[0]["name"]; glyph?: ReactNode; text: string; copy?: string; muted?: boolean }> = [];
if (phone) fields.push({ key: "phone", icon: "phone", text: phone, copy: phone });
if (dialog.channel === "EMAIL" && email) fields.push({ key: "email", icon: "mail", text: email, copy: email });
if (username) fields.push({ key: "username", icon: "send", text: `@${username} · ${channel.label}`, copy: `@${username}` });
if (username) fields.push({ key: "username", icon: "send", glyph: <ChannelGlyph provider={dialog.channel} size={15} />, text: `@${username} · ${channel.label}`, copy: `@${username}` });
if (isGuest) fields.push({ key: "guest", icon: "message", text: t("sales.anonymous_session", { channel: channel.label, connection: detail?.connection?.name ?? t("sales.widget") }), muted: true });
if (fields.length === 0 && detail?.connection) fields.push({ key: "connection", icon: "plug", text: `${channel.label} · ${detail.connection.name}`, muted: true });
// Компания и город — из карточки контакта (решение 5), без «копировать».
@@ -109,7 +112,7 @@ export function ClientContext({
<div className="ctx-contact-fields">
{fields.map((field) => (
<div className={`ctx-contact-field ${field.muted ? "is-muted" : ""}`} key={field.key}>
<span><Icon name={field.icon} size={15} /></span>
<span>{field.glyph ?? <Icon name={field.icon} size={15} />}</span>
<span>{field.text}</span>
{field.copy && <CopyButton value={field.copy} />}
</div>
@@ -199,6 +199,11 @@
.sales-dialog-avatar-wrap {
position: relative;
/* Обёртка обязана быть ростом с аватар. Строка — flex, и по умолчанию её
ячейки растягиваются на всю высоту: у строки с метками обёртка вырастала
вдвое, а точка режима, привязанная к её низу, уезжала под аватар. */
align-self: flex-start;
display: inline-flex;
flex: none;
margin-top: 20px;
}
+3 -1
View File
@@ -493,6 +493,8 @@ export const en: Record<MessageKey, Message> = {
"ai.update": "update",
"ai.updated": "· Updated:",
"ai.transcription_model": "Transcription model",
"ai.history_limit": "Conversation context, messages",
"ai.history_limit_invalid": "A whole number from 1 to {max}",
"ai.transcription_provider": "Voice transcription",
"ai.updated_2": "UPDATED",
"ai.updated_by_at": "updated by {name} · {date}",
@@ -1513,7 +1515,7 @@ export const en: Record<MessageKey, Message> = {
"settings.demo_provider_no_key": "Demo provider (no key)",
"settings.demo_records_count": "{count} records",
"settings.disabled": "Disabled",
"settings.domain_or_ip": "Domain or IP",
"settings.domain_or_ip": "Domain or IP, with a port if needed",
"settings.e_g_openrouter_primary": "e.g. OpenRouter · primary",
"settings.e_g_support_mailbox": "e.g. Support mailbox",
"settings.edit_integration": "Edit the integration",
+3 -1
View File
@@ -494,6 +494,8 @@ export const ru = {
"ai.update": "обновить",
"ai.updated": "· Обновлено:",
"ai.transcription_model": "Модель расшифровки",
"ai.history_limit": "Контекст диалога, сообщений",
"ai.history_limit_invalid": "Целое число от 1 до {max}",
"ai.transcription_provider": "Расшифровка голосовых",
"ai.updated_2": "ОБНОВЛЕНО",
"ai.updated_by_at": "обновил {name} · {date}",
@@ -1514,7 +1516,7 @@ export const ru = {
"settings.demo_provider_no_key": "Демо-провайдер (без ключа)",
"settings.demo_records_count": "{count} записей",
"settings.disabled": "Отключено",
"settings.domain_or_ip": "Домен или IP",
"settings.domain_or_ip": "Домен или IP, при необходимости с портом",
"settings.e_g_openrouter_primary": "например, OpenRouter · основной",
"settings.e_g_support_mailbox": "например, Почта поддержки",
"settings.edit_integration": "Изменить интеграцию",
+4 -3
View File
@@ -1,12 +1,13 @@
import { Icon, MaxLogo, TelegramLogo } from "./icons";
import { Icon, MaxLogo, TelegramLogo, VkLogo } from "./icons";
import { providerKey } from "./providers";
/** Иконка канала без подложки (дизайн-базлайн v2, решение 4): Telegram/MAX —
* фирменные глифы, Email/Web — линейные. */
/** Иконка канала без подложки (дизайн-базлайн v2, решение 4): Telegram, MAX и
* ВКонтакте — фирменные глифы, Email/Web — линейные. */
export function ChannelGlyph({ provider, size = 13 }: { provider: string; size?: number }) {
const key = providerKey(provider);
if (key === "TG") return <TelegramLogo size={size} />;
if (key === "MAX") return <MaxLogo size={size} />;
if (key === "VK") return <VkLogo size={size} />;
if (key === "EMAIL") return <Icon name="mail" size={size} />;
return <Icon name="message" size={size} />;
}
@@ -13,7 +13,7 @@ type FormFieldProps = {
* запрос на каждую букву. */
onBlur?: () => void;
placeholder?: string;
type?: "password" | "text";
type?: "number" | "password" | "text";
value: string;
wide?: boolean;
};
+3 -3
View File
@@ -25,7 +25,7 @@ server {
location = /chat-widget.js {
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
@@ -42,7 +42,7 @@ server {
location /api/ {
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
@@ -52,7 +52,7 @@ server {
location /ws/ {
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
+7 -7
View File
@@ -29,7 +29,7 @@ server {
location /api/ {
proxy_pass http://$backend_app;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
@@ -37,7 +37,7 @@ server {
location /ws/ {
proxy_pass http://$backend_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
@@ -45,14 +45,14 @@ server {
location = /chat-widget.js {
proxy_pass http://$backend_app;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
}
location /chat/ {
# Vite React Refresh injects an inline preamble in local development.
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always;
proxy_pass http://$web_chat;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
@@ -64,7 +64,7 @@ server {
add_header Permissions-Policy "camera=(self), microphone=(self)" always;
rewrite ^ /chat/call.html break;
proxy_pass http://$web_chat;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
}
location / {
@@ -74,7 +74,7 @@ server {
add_header X-Frame-Options SAMEORIGIN always;
proxy_pass http://$frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -90,7 +90,7 @@ server {
location /api/ {
proxy_pass http://$backend_platform;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}