mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8b8246b35 | ||
|
|
b95a4edf6e | ||
|
|
19448eb05a | ||
|
|
a321109a8f |
No files matched your search
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
# Язык ответов клиенту. По умолчанию агент отвечает на языке, на котором
|
||||
# к нему обратились: сигнал точный, лежит прямо в сообщении и не требует
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
@@ -580,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": "Перенос уже идёт",
|
||||
@@ -584,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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Изменить интеграцию",
|
||||
|
||||
@@ -13,7 +13,7 @@ type FormFieldProps = {
|
||||
* запрос на каждую букву. */
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
type?: "password" | "text";
|
||||
type?: "number" | "password" | "text";
|
||||
value: string;
|
||||
wide?: boolean;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user