mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc370fafe1 |
No files matched your search
@@ -33,7 +33,7 @@ UI / дизайн:
|
||||
- Перед тем как написать свой элемент, искать существующий в `shared/` и в соседних фичах. Второй стандарт того же элемента — это дефект, а не свобода реализации.
|
||||
|
||||
Никаких служебных записок в продукте:
|
||||
- В продуктовом UI не должно быть служебной лексики. Запрещены на экране: коды спек и решений (SPEC-*, ADR-*, DG-*), номера правил (P1-P5 и любые другие), слова «инвариант», «констрейнт», «миграция», «scope» в техническом смысле, имена таблиц, полей и enum-значений БД и API (`allowCheckoutActions`, `Conversation.channel`, `PROTECT`, `OK`/`ERROR` как есть), ссылки на внутренние документы и любые пометки для разработчика.
|
||||
- В продуктовом UI не должно быть служебной лексики. Запрещены на экране: коды спек и решений (SPEC-HUB-*, ADR-HUB-*, DG-*), номера правил (P1-P5 и любые другие), слова «инвариант», «констрейнт», «миграция», «scope» в техническом смысле, имена таблиц, полей и enum-значений БД и API (`allowCheckoutActions`, `Conversation.channel`, `PROTECT`, `OK`/`ERROR` как есть), ссылки на внутренние документы и любые пометки для разработчика.
|
||||
- Пользователю показывается следствие и способ исправить, а не внутреннее правило. «Коммерческие действия недоступны непродуктовому каналу. Назначьте продукт» — да. «Запрещено инвариантом P1» — нет.
|
||||
- Технические коды остаются в коде, комментариях, логах и API-ответах, но не в текстах интерфейса.
|
||||
- Это относится и к baseline-макетам: служебный текст, попавший в макет, не является основанием выводить его на экран. Макет реализуется, дополняется по указанию владельца, служебная лексика в реализацию не переносится.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-CHATBALLS-0028 §gateway).
|
||||
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-HUB-0028 §gateway).
|
||||
# Подставляется в release bundle и монтируется в контейнер gateway.
|
||||
#
|
||||
# Свежая установка не знает своего домена: человек поднимает докер на сервере и
|
||||
|
||||
@@ -22,7 +22,7 @@ COPY apps/backend /app/apps/backend
|
||||
COPY deploy/secrets/generate-instance-secrets.sh /usr/local/bin/chatballs-generate-secrets.sh
|
||||
RUN chmod 0755 /usr/local/bin/chatballs-generate-secrets.sh
|
||||
|
||||
# collectstatic в образе (ADR-CHATBALLS-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
|
||||
# collectstatic в образе (ADR-HUB-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
|
||||
# Build-time secret нужен только чтобы settings загрузились в production-режиме;
|
||||
# collectstatic не обращается к БД/Redis/S3. whitenoise раздаёт static в runtime.
|
||||
# Build-time dummy values satisfy C04 runtime guards (distinct DB users in
|
||||
|
||||
@@ -228,7 +228,7 @@ class AgentCardDeactivateView(_AgentCardStatusView):
|
||||
|
||||
class AgentCardTestChatView(APIView):
|
||||
permission_classes = [HasCapability]
|
||||
# Исполняет агента, а не изменяет канал: остаётся на ai.manage.
|
||||
# Исполняет агента, а не изменяет канал: остаётся на ai.manage (ADR-HUB-0037 §9).
|
||||
required_capability = "ai.manage"
|
||||
|
||||
def post(self, request: Request, agent_id: int) -> Response:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Историческое поле режима доступа к AI; удалено в 0016.
|
||||
# Generated for CustoAI / BYOK credential mode (ADR-HUB-0033 §4, SPEC-CHATBALLS-0024 §2).
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -14,8 +14,8 @@ class Migration(migrations.Migration):
|
||||
model_name='aiagent',
|
||||
name='credential_mode',
|
||||
field=models.CharField(
|
||||
choices=[('BYOK', 'BYOK')],
|
||||
default='BYOK',
|
||||
choices=[('CUSTOAI', 'CustoAI (Managed)'), ('BYOK', 'BYOK')],
|
||||
default='CUSTOAI',
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Провайдер LLM переезжает с канала на агента.
|
||||
# SPEC-HUB-0027 §9, ADR-HUB-0037 §8 — этап 5, шаги 1-2.
|
||||
#
|
||||
# BYOK-секрет переезжает с канала на агента. До сих пор credential_mode и model
|
||||
# жили на AIAgent, а provider_integration — на Channel: одно решение было
|
||||
|
||||
@@ -15,7 +15,7 @@ def backfill_agents(apps, schema_editor):
|
||||
channel=channel,
|
||||
name=channel.name,
|
||||
status="DRAFT",
|
||||
credential_mode="BYOK",
|
||||
credential_mode="CUSTOAI",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# ADR-CHATBALLS-0042 §3: BYOK — единственный режим; поле credential_mode больше не нужно.
|
||||
# ADR-CHATBALLS-0042 §3: managed-режим CustoAI удалён вместе с тарифным контуром.
|
||||
# BYOK — единственный режим; поле credential_mode больше не нужно.
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from pgvector.django import VectorField
|
||||
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
# Один основной агент на канал обработки (ADR-CHATBALLS-0023).
|
||||
# Один основной агент на канал обработки (ADR-HUB-0019, ADR-CHATBALLS-0023).
|
||||
|
||||
DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
|
||||
@@ -40,6 +40,8 @@ class AIAgentStatus(models.TextChoices):
|
||||
|
||||
|
||||
|
||||
# Managed-режим CustoAI удалён вместе с тарифным контуром (ADR-CHATBALLS-0042 §3):
|
||||
|
||||
# AI работает только через провайдера организации (AIAgent.provider_integration).
|
||||
|
||||
|
||||
@@ -149,7 +151,7 @@ class KnowledgeAttachment(TenantRelationModel):
|
||||
|
||||
# Непредсказуемый идентификатор публичной ссылки скачивания (ADR-CHATBALLS-0023):
|
||||
|
||||
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации установки.
|
||||
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации Hub.
|
||||
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
|
||||
@@ -191,7 +193,7 @@ class KnowledgeAttachment(TenantRelationModel):
|
||||
|
||||
# Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому
|
||||
|
||||
# строится от публичного адреса установки, а не от request.
|
||||
# строится от публичного адреса Hub, а не от request.
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -341,7 +343,7 @@ class AIAgent(TenantRelationModel):
|
||||
|
||||
channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
|
||||
|
||||
# BYOK-секрет организации. Раньше жил на Channel, из-за
|
||||
# BYOK-секрет организации (SPEC-HUB-0027 §9). Раньше жил на Channel, из-за
|
||||
|
||||
# чего credential_mode и model были на агенте, а секрет — на канале: одно
|
||||
|
||||
@@ -492,7 +494,7 @@ class LlmInvocation(TenantRelationModel):
|
||||
|
||||
tenant_relation_fields = ("channel",)
|
||||
|
||||
# Учёт по каналу.
|
||||
# Учёт по каналу (ADR-HUB-0019).
|
||||
|
||||
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations")
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-CHATBALLS-0034 §3).
|
||||
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-HUB-0033 §7,
|
||||
|
||||
ADR-CHATBALLS-0034 §3).
|
||||
|
||||
|
||||
|
||||
The OpenRouter and generic Custom providers both
|
||||
The OpenRouter, generic Custom and CustoAI (Yandex AI Studio) providers all
|
||||
|
||||
speak the same Chat Completions shape:
|
||||
|
||||
@@ -18,7 +20,7 @@ speak the same Chat Completions shape:
|
||||
|
||||
|
||||
|
||||
This module owns the HTTP transport and response parsing so the adapters
|
||||
This module owns the HTTP transport and response parsing so the three adapters
|
||||
|
||||
do not duplicate it. Adapters stay responsible for their own product semantics
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ class OpenRouterProvider(LLMProvider):
|
||||
|
||||
OpenAI Chat Completions shape with usage.include=true (returns the actual
|
||||
USD cost in usage.cost). Delegates HTTP/parsing to the shared openai_http
|
||||
layer (ADR-CHATBALLS-0034 §3); this adapter only carries the
|
||||
layer (ADR-HUB-0033 §7, ADR-CHATBALLS-0034 §3); this adapter only carries the
|
||||
OpenRouter product semantics (cost reporting). Exercised with a real key;
|
||||
tests use the LocalProvider.
|
||||
"""
|
||||
|
||||
@@ -5,7 +5,7 @@ Resolves an LLM provider and the effective model from the channel agent's
|
||||
own credentials, the integration is selected explicitly on `AIAgent`, and
|
||||
managed AI credits are not consumed.
|
||||
|
||||
Источник провайдера переехал с канала на агента. Один
|
||||
Источник провайдера переехал с канала на агента (SPEC-HUB-0027 §9). Один
|
||||
релиз резолвер падает на `Channel.provider_integration` для записей, не
|
||||
попавших в data-миграцию; после удаления поля канала fallback уходит.
|
||||
|
||||
@@ -14,7 +14,7 @@ the owner's choice is forbidden (ADR-CHATBALLS-0020:45). The integration MUST be
|
||||
the one the agent points at.
|
||||
|
||||
This module also closes the as-built gap where the OpenRouter «Модель по
|
||||
умолчанию» field was decorative (SPEC-CHATBALLS-0024 §4.3, §6):
|
||||
умолчанию» field was decorative (SPEC-HUB-0005:388, SPEC-CHATBALLS-0024 §4.3, §6):
|
||||
for OpenRouter and Custom integrations the configured `default_model` is read
|
||||
at runtime and overrides `AIAgent.model`.
|
||||
"""
|
||||
@@ -114,7 +114,7 @@ def _channel_integration(channel) -> Integration:
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
integration = getattr(agent, "provider_integration", None) if agent else None
|
||||
if integration is None:
|
||||
# Переходный fallback на один релиз: записи,
|
||||
# Переходный fallback на один релиз (SPEC-HUB-0027 §9 шаг 3): записи,
|
||||
# не попавшие в data-миграцию, продолжают работать через канал.
|
||||
integration = getattr(channel, "provider_integration", None)
|
||||
if integration is None or not integration.secret:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Выбор LLM-провайдера агента (ADR-CHATBALLS-0042 §3).
|
||||
"""Выбор LLM-провайдера агента (SPEC-HUB-0027 §9, ADR-CHATBALLS-0042 §3).
|
||||
|
||||
Агент работает только через интеграцию организации (BYOK,
|
||||
ADR-CHATBALLS-0034). Функция ничего не пишет:
|
||||
Managed-режим CustoAI удалён вместе с тарифным контуром: агент работает только
|
||||
через интеграцию организации (BYOK, ADR-CHATBALLS-0034). Функция ничего не пишет:
|
||||
возвращает разрешённую интеграцию и модель, вызывающий сервис ставит их на
|
||||
агента в одной транзакции. Агент без интеграции — валидное состояние черновика;
|
||||
активация без провайдера запрещена в set_agent_active.
|
||||
|
||||
@@ -130,7 +130,7 @@ class ProviderModeTests(TestCase):
|
||||
|
||||
|
||||
class AgentProviderOwnershipTests(TestCase):
|
||||
"""Провайдер живёт на агенте, канал не изменяется."""
|
||||
"""SPEC-HUB-0027 §9 — провайдер живёт на агенте, канал не изменяется."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
|
||||
@@ -16,7 +16,7 @@ _MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
|
||||
|
||||
|
||||
def make_channel_with_agent(organization, *, code, name, model="openai/gpt-4o-mini"):
|
||||
"""Канал обработки + его агент (ADR-CHATBALLS-0023). Bootstrap не создаёт
|
||||
"""Канал обработки + его агент (ADR-HUB-0019/0023). Bootstrap не создаёт
|
||||
каналы/агентов — в тестах их собирает этот helper."""
|
||||
channel = Channel.objects.create(organization=organization, code=code, name=name)
|
||||
agent = AIAgent.objects.create(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Проверка инвариантов политики канала.
|
||||
# SPEC-HUB-0027 §3.2/§12, ADR-HUB-0037 §7 — этап 3.
|
||||
#
|
||||
# Приводит существующие каналы в соответствие P1-P2 и меняет дефолты модели,
|
||||
# которые сами по себе их нарушали: attribution и checkout были включены по
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.db import models
|
||||
|
||||
# Канал обработки — якорь AI-контекста. Группа видимости и
|
||||
# Канал обработки — якорь AI-контекста (ADR-HUB-0019). Группа видимости и
|
||||
# ссылка на провайдер-интеграцию. Поведение AI (модель, инструкции, знания)
|
||||
# живёт на агенте канала (ADR-CHATBALLS-0023).
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from chatballs.i18n import t
|
||||
|
||||
|
||||
def run_channel_turn(*, channel, message: str, history: list[dict] | None = None) -> ChatResult:
|
||||
"""One AI turn for a processing channel (ADR-CHATBALLS-0023).
|
||||
"""One AI turn for a processing channel (ADR-HUB-0019/0023).
|
||||
|
||||
AI-поведение канала целиком определяет его агент. Без активного агента
|
||||
AI-ответа нет: вызывающий код (ingest/support) обрабатывает ProviderError
|
||||
|
||||
@@ -13,7 +13,8 @@ def _with_relations(queryset: QuerySet[Channel]) -> QuerySet[Channel]:
|
||||
).prefetch_related(
|
||||
Prefetch("connections", queryset=Integration.objects.order_by("id"))
|
||||
).annotate(
|
||||
# Один агрегат на весь список: N+1 запросов на счётчик не допускается.
|
||||
# Один агрегат на весь список: N+1 запросов на счётчик не допускается
|
||||
# (SPEC-HUB-0027 §6.1).
|
||||
open_conversations_count=Count(
|
||||
"conversations",
|
||||
filter=Q(conversations__lifecycle=LifecycleState.OPEN),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Операции над каналом обработки."""
|
||||
"""Операции над каналом обработки (SPEC-HUB-0027 §6, §7)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -42,7 +42,7 @@ class ChannelHasReferences(Exception):
|
||||
|
||||
|
||||
class ConnectionAlreadyBound(Exception):
|
||||
"""Подключение принадлежит ровно одному каналу."""
|
||||
"""Подключение принадлежит ровно одному каналу (ADR-HUB-0019)."""
|
||||
|
||||
def __init__(self, *, integration: Integration) -> None:
|
||||
self.integration = integration
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils import timezone
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
# Минимальный домен диалогов (ADR-CHATBALLS-0002/0003/0006). Состояние диалога
|
||||
# Минимальный домен диалогов (ADR-HUB-0001/0002/0003/0006). Состояние диалога
|
||||
# разделено на независимые оси; перехват оператором — атомарный.
|
||||
|
||||
|
||||
|
||||
@@ -267,10 +267,10 @@ class VkOutboundTests(SimpleTestCase):
|
||||
with mock.patch.object(vk_send.vk_api, "call", api):
|
||||
with mock.patch.object(vk_send, "customer_language", return_value="ru"):
|
||||
sent = vk_send.send_call_invite(
|
||||
_integration(), chat_id="77", user_id="77", text="Звонок", url="https://chatballs.test/calls/abc"
|
||||
_integration(), chat_id="77", user_id="77", text="Звонок", url="https://hub.test/calls/abc"
|
||||
)
|
||||
self.assertTrue(sent)
|
||||
self.assertIn("https://chatballs.test/calls/abc", api.params("messages.send")["keyboard"])
|
||||
self.assertIn("https://hub.test/calls/abc", api.params("messages.send")["keyboard"])
|
||||
|
||||
def test_rejected_send_is_reported_as_failure(self) -> None:
|
||||
api = _Api(**{"messages.send": VkRejected("ВКонтакте отклонил запрос (7): access denied")})
|
||||
|
||||
@@ -119,7 +119,7 @@ def multipart_body(
|
||||
"""Ручной multipart/form-data (в репо urllib, без requests)."""
|
||||
import uuid
|
||||
|
||||
boundary = "----chatballs" + uuid.uuid4().hex
|
||||
boundary = "----hub" + uuid.uuid4().hex
|
||||
crlf = "\r\n"
|
||||
parts: list[bytes] = []
|
||||
for name, value in fields.items():
|
||||
|
||||
@@ -110,7 +110,7 @@ class Command(BaseCommand):
|
||||
role = str(options["role"])
|
||||
does_events = role in (ROLE_ALL, ROLE_EVENTS)
|
||||
does_polling = role in (ROLE_ALL, ROLE_POLLER)
|
||||
self.stdout.write(f"Chatballs worker started (role={role})")
|
||||
self.stdout.write(f"Hub worker started (role={role})")
|
||||
last_poll = 0.0
|
||||
last_maintenance = 0.0
|
||||
last_call_sweep = 0.0
|
||||
|
||||
@@ -81,7 +81,7 @@ class InvitationRegisterView(APIView):
|
||||
|
||||
|
||||
class InvitationAcceptView(APIView):
|
||||
"""Accept an organization invitation: OWNER or employee.
|
||||
"""Accept an organization invitation: OWNER (SPEC-HUB-0021 §8.2) or employee.
|
||||
|
||||
Authenticated endpoint: the caller must already have a HumanUser account
|
||||
(created through sign-up / password setup). The token is read from the body;
|
||||
|
||||
@@ -41,7 +41,7 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
|
||||
support_group, _ = EmployeeGroup.objects.get_or_create(
|
||||
organization=organization, name="Поддержка"
|
||||
)
|
||||
# Каналы обработки и их агенты создаются через API каналов.
|
||||
# Каналы обработки и их агенты (ADR-HUB-0019) создаются через API каналов.
|
||||
|
||||
owner, created_owner = HumanUser.objects.get_or_create(
|
||||
email=HumanUser.objects.normalize_email(email),
|
||||
|
||||
@@ -30,7 +30,7 @@ from chatballs.tenancy.ingress import invitation_route
|
||||
# Приглашение существующего пользователя в организацию: письмо отправляет
|
||||
# воркер по этому событию (identity.event_handlers).
|
||||
MEMBERSHIP_INVITATION_REQUESTED = "identity.membership_invitation_requested"
|
||||
# Приглашение владельца из платформенного провижининга:
|
||||
# Приглашение владельца из платформенного провижининга (SPEC-HUB-0021 §8.2):
|
||||
# учётной записи может ещё не быть, тогда человек создаёт её по ссылке.
|
||||
OWNER_INVITATION_REQUESTED = "organization.owner_invitation_requested"
|
||||
MEMBERSHIP_INVITATION_TTL = timedelta(days=7)
|
||||
@@ -240,7 +240,7 @@ def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitati
|
||||
|
||||
@transaction.atomic
|
||||
def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
|
||||
"""Принять приглашение: владельца или сотрудника.
|
||||
"""Принять приглашение: владельца (SPEC-HUB-0021 §8.2) или сотрудника.
|
||||
|
||||
Членство собирается из полей приглашения; для владельца организация ещё
|
||||
и активируется. Идемпотентно: повторное принятие того же токена не создаёт
|
||||
@@ -337,7 +337,7 @@ def _already_accepted_for(
|
||||
token: str, user: HumanUser
|
||||
) -> AcceptedInvitation | None:
|
||||
"""Idempotent re-accept: if this token was already accepted by the same user,
|
||||
return the existing result instead of raising."""
|
||||
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
|
||||
invitation = _invitation_for_token(token, accepted=True)
|
||||
if invitation is None:
|
||||
return None
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
# Роли, должности и отделы сотрудников: additive schema + backfill.
|
||||
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M1-M2 (этап 1): additive schema + backfill.
|
||||
# Роли OWNER/ADMIN/EMPLOYEE, обязательная должность (пока nullable), основной отдел
|
||||
# и размещение владельца на уровне компании. Доступ существующих сотрудников не
|
||||
# расширяется: OPERATOR → EMPLOYEE, права сохраняются compatibility-адаптером.
|
||||
@@ -8,7 +8,7 @@ from django.db import migrations, models
|
||||
|
||||
def operator_to_employee(apps, schema_editor):
|
||||
EmployeeProfile = apps.get_model("identity", "EmployeeProfile")
|
||||
# OPERATOR больше не является системной ролью.
|
||||
# OPERATOR больше не является системной ролью (ADR-HUB-0027).
|
||||
EmployeeProfile.objects.filter(role="OPERATOR").update(role="EMPLOYEE")
|
||||
# Владелец всегда на уровне компании: снимаем основной отдел.
|
||||
EmployeeProfile.objects.filter(role="OWNER").exclude(primary_department__isnull=True).update(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# После явного заполнения должностей
|
||||
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M2 (этап 1): после явного заполнения должностей
|
||||
# включается обязательность. Оставшиеся незаполненные значения нормализуются в "";
|
||||
# непустая должность гарантируется application contract (SPEC-CHATBALLS-0016 §5).
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated for tenant provisioning.
|
||||
# Generated for C06 tenant provisioning (SPEC-HUB-0021 §6/§8).
|
||||
# Adds Organization.status and backfills existing organizations to ACTIVE.
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Права на управление каналами.
|
||||
# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.
|
||||
#
|
||||
# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям
|
||||
# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в
|
||||
|
||||
@@ -102,7 +102,7 @@ class HumanUser(AbstractUser):
|
||||
return self.email
|
||||
|
||||
class OrganizationStatus(models.TextChoices):
|
||||
# PENDING_OWNER до принятия OWNER invitation, ACTIVE после.
|
||||
# SPEC-HUB-0021 §6/§8: PENDING_OWNER до принятия OWNER invitation, ACTIVE после.
|
||||
ACTIVE = "ACTIVE", "Active"
|
||||
PENDING_OWNER = "PENDING_OWNER", "Pending owner"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
Запись идёт по основному соединению процесса. Роль app вправе вставить
|
||||
организацию только пока их нет (политика tenancy/0032); дальше создавать
|
||||
организации может только роль platform. Так пароль
|
||||
организации может только роль platform (SPEC-HUB-0021 §10). Так пароль
|
||||
platform-роли не нужен процессу backend-app.
|
||||
"""
|
||||
|
||||
|
||||
@@ -770,7 +770,7 @@ class ThrottlingTests(TestCase):
|
||||
|
||||
|
||||
class EmployeeModelInvariantTests(TestCase):
|
||||
"""SPEC-CHATBALLS-0016 §5,§7 — инварианты модели сотрудника после
|
||||
"""ADR-HUB-0027 / SPEC-CHATBALLS-0016 §5,§7 — инварианты модели сотрудника после
|
||||
миграции этапа 1: роли OWNER/ADMIN/EMPLOYEE, обязательная должность,
|
||||
размещение владельца на уровне компании и ровно один владелец на организацию."""
|
||||
|
||||
@@ -823,7 +823,7 @@ class EmployeeModelInvariantTests(TestCase):
|
||||
|
||||
|
||||
class EmployeeGovernanceTests(TestCase):
|
||||
"""SPEC-CHATBALLS-0016 §8,§12: административная иерархия
|
||||
"""ADR-HUB-0027 этап 2 / SPEC-CHATBALLS-0016 §8,§12: административная иерархия
|
||||
OWNER/ADMIN/EMPLOYEE, target-aware управление и передача владения."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.db import models
|
||||
from chatballs.identity.crypto import EncryptedCharField
|
||||
|
||||
# Интеграции: провайдеры (LLM) и подключения (боты/виджеты). ADR-CHATBALLS-0020.
|
||||
# Привязка подключения к каналу обработки появляется в M1.
|
||||
# Привязка подключения к каналу обработки появляется в M1 (ADR-HUB-0019).
|
||||
|
||||
|
||||
class IntegrationKind(models.TextChoices):
|
||||
@@ -63,7 +63,7 @@ class Integration(models.Model):
|
||||
voice_messages_enabled = models.BooleanField(default=True)
|
||||
audio_calls_enabled = models.BooleanField(default=True)
|
||||
video_calls_enabled = models.BooleanField(default=True)
|
||||
# Подключение (бот/виджет) привязано к каналу обработки.
|
||||
# Подключение (бот/виджет) привязано к каналу обработки (ADR-HUB-0019, M2).
|
||||
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="connections")
|
||||
# Курсор Long Polling (marker MAX / offset Telegram).
|
||||
poll_marker = models.CharField(max_length=64, blank=True)
|
||||
|
||||
@@ -30,7 +30,7 @@ class IntegrationInput:
|
||||
name: str
|
||||
secret: str | None = None # None = не менять при update
|
||||
config: dict = field(default_factory=dict)
|
||||
channel_id: int | None = None # канал обработки для подключения
|
||||
channel_id: int | None = None # канал обработки для подключения (ADR-HUB-0019)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ def _normalized_config(provider: str, config: dict) -> dict:
|
||||
except OutboundUrlRejected as error:
|
||||
raise ValidationError({"config": t("settings.proxy_url_rejected", error=error)}) from error
|
||||
# LLM-провайдеры (OpenRouter, Custom) хранят модель по умолчанию свободным текстом.
|
||||
# Для OpenRouter поле исторически декоративно; для Custom оно
|
||||
# Для OpenRouter поле исторически декоративно (SPEC-HUB-0005:388); для Custom оно
|
||||
# читается в рантайме (ADR-CHATBALLS-0034 §4). Версионирование модели — дорожка ADR-0034.
|
||||
if provider in (IntegrationProvider.OPENROUTER, IntegrationProvider.CUSTOM, IntegrationProvider.DEMO):
|
||||
default_model = str(config.get("defaultModel", config.get("default_model", ""))).strip()
|
||||
|
||||
@@ -510,7 +510,7 @@ class OpenRouterProviderProxyTests(TestCase):
|
||||
|
||||
provider = OpenRouterProvider(api_key="sk-test", base_url="https://openrouter.ai/api/v1", proxy_url="http://proxy:8080")
|
||||
|
||||
# После рефакторинга общий HTTP-слой живёт в openai_http:
|
||||
# После рефакторинга общий HTTP-слой живёт в openai_http (ADR-HUB-0033 §7):
|
||||
|
||||
# мокаем именно его build_opener.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ def provisioning_result_payload(
|
||||
owner_state: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Response shape for POST /api/v1/organizations. Never includes secrets,
|
||||
tokens or invitation plaintext."""
|
||||
tokens or invitation plaintext (SPEC-HUB-0021 §12/§13)."""
|
||||
return {
|
||||
"organization": {
|
||||
"publicId": str(organization.public_id),
|
||||
|
||||
@@ -26,7 +26,7 @@ class ProvisioningCommand:
|
||||
tax_profile: dict[str, Any] | None = None
|
||||
|
||||
def significant_fields(self) -> dict[str, Any]:
|
||||
"""Canonical normalized values for request_hash:
|
||||
"""Canonical normalized values for request_hash (SPEC-HUB-0021 §11):
|
||||
transport metadata is excluded. Keys are sorted for a stable hash."""
|
||||
data = asdict(self)
|
||||
data["owner_email"] = data["owner_email"].strip().lower()
|
||||
@@ -53,7 +53,7 @@ class ProvisioningResult:
|
||||
|
||||
def is_replay(provisioning: OrganizationProvisioning, command: ProvisioningCommand) -> bool:
|
||||
"""COMPLETED/WAITING_FOR_OWNER with a matching request hash is an idempotent
|
||||
replay; a differing hash is a conflict."""
|
||||
replay; a differing hash is a conflict (SPEC-HUB-0021 §11)."""
|
||||
return provisioning.request_hash == command.request_hash()
|
||||
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ class ProvisioningStatus(models.TextChoices):
|
||||
|
||||
|
||||
class OrganizationProvisioning(models.Model):
|
||||
"""Platform-owned process record. Idempotency key is
|
||||
"""Platform-owned process record (SPEC-HUB-0021 §6). Idempotency key is
|
||||
globally unique; the same key + payload returns the existing result, while a
|
||||
differing payload is a conflict. failure_details_safe must never contain
|
||||
secrets, tokens or provider credentials."""
|
||||
secrets, tokens or provider credentials (SPEC-HUB-0021 §6/§13)."""
|
||||
|
||||
idempotency_key = models.CharField(max_length=160, unique=True)
|
||||
request_hash = models.CharField(max_length=128)
|
||||
|
||||
@@ -46,7 +46,7 @@ def provision_organization(
|
||||
command: ProvisioningCommand,
|
||||
operator: PlatformOperator,
|
||||
) -> ProvisioningResult:
|
||||
"""Single write boundary for tenant provisioning.
|
||||
"""Single write boundary for tenant provisioning (SPEC-HUB-0021 §4).
|
||||
|
||||
Coordinates identity, audit and outbox in one
|
||||
transaction (тариф удалён, ADR-CHATBALLS-0042 §2: организация создаётся без подписки). Tenant-owned rows are written under set_local_tenant(new_org.id)
|
||||
@@ -201,7 +201,7 @@ def _provision_pending_owner(
|
||||
command: ProvisioningCommand,
|
||||
context: TenantContext,
|
||||
) -> None:
|
||||
# No password/dummy user is created. An OWNER invitation
|
||||
# No password/dummy user is created (SPEC-HUB-0021 §8.2). An OWNER invitation
|
||||
# is issued; the organization stays PENDING_OWNER until it is accepted.
|
||||
issued = issue_invitation(
|
||||
organization=org,
|
||||
|
||||
@@ -16,7 +16,7 @@ _REQUIRED_FIELDS = (
|
||||
def parse_provisioning_body(
|
||||
body: Any, *, idempotency_key: str, source: str
|
||||
) -> tuple[ProvisioningCommand, str | None]:
|
||||
"""Map the API body to a ProvisioningCommand. Returns
|
||||
"""Map the API body (SPEC-HUB-0021 §12) to a ProvisioningCommand. Returns
|
||||
(command, error_message). Validation is intentionally explicit (no DRF
|
||||
serializers), mirroring identity/access_payloads.py."""
|
||||
if not isinstance(body, dict):
|
||||
|
||||
@@ -19,7 +19,7 @@ _IDEMPOTENCY_HEADER = "Idempotency-Key"
|
||||
|
||||
|
||||
class OrganizationProvisionView(APIView):
|
||||
"""POST <platform-host>/api/v1/organizations.
|
||||
"""POST <platform-host>/api/v1/organizations (SPEC-HUB-0021 §12).
|
||||
|
||||
The first Platform API adapter: machine-to-machine provisioning via an
|
||||
opaque platform token. Does not reuse tenant HasCapability (no membership
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# C06 tenant provisioning DB boundary (ADR-CHATBALLS-0029 §6).
|
||||
# C06 tenant provisioning DB boundary (SPEC-HUB-0021 §10, ADR-CHATBALLS-0029 §6).
|
||||
#
|
||||
# Provisioning runs on the platform connection (chatballs_runtime_platform) and must
|
||||
# both INSERT a new Organization and create tenant-owned rows (departments, the
|
||||
@@ -27,7 +27,7 @@ PLATFORM_POLICY = "chatballs_platform_tenant_provisioning"
|
||||
def apply_grants(apps, schema_editor):
|
||||
# 1. Platform role may create and update organizations (no RLS on this table;
|
||||
# it has no organization_id column). DELETE stays with the schema/migration
|
||||
# role (hard delete as compensation is forbidden).
|
||||
# role per SPEC-HUB-0021 §14 (hard delete as compensation is forbidden).
|
||||
schema_editor.execute(
|
||||
"GRANT INSERT, UPDATE ON identity_organization TO chatballs_runtime_platform"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Реестр демо-данных (identity_demodataset, identity_demorecord): tenant-таблицы
|
||||
# с organization_id, RLS и гранты по образцу tenancy/0021. Платформенная роль
|
||||
# получает те же права: мастер первого запуска ставит демо-набор в очередь на
|
||||
# соединении platform.
|
||||
# соединении platform (SPEC-HUB-0021 §10).
|
||||
from django.db import migrations
|
||||
|
||||
TABLES = ("identity_demodataset", "identity_demorecord")
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
# после tenancy/0003, и ни одна миграция не выдала на них прав runtime-роли
|
||||
# platform. В деплое backend-platform работает ролью chatballs_platform, поэтому
|
||||
# уже проверка токена падала «permission denied for table platform_platformtoken»,
|
||||
# а вместе с ней — весь POST /api/v1/organizations. Тесты
|
||||
# а вместе с ней — весь POST /api/v1/organizations (SPEC-HUB-0021 §12). Тесты
|
||||
# этого не видели: они ходят в базу владельцем кластера.
|
||||
#
|
||||
# Здесь роль platform получает DML на платформенные таблицы (без DELETE: токены
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# нет»: проверку делает SECURITY DEFINER-функция от chatballs_schema (иначе
|
||||
# политика ссылалась бы на свою же таблицу и Postgres отказал бы за рекурсию).
|
||||
# После первой организации INSERT для app закрыт навсегда — создавать следующие
|
||||
# по-прежнему может только роль platform.
|
||||
# по-прежнему может только роль platform (SPEC-HUB-0021 §10).
|
||||
from django.db import migrations
|
||||
|
||||
INGRESS_VIEWS = (
|
||||
|
||||
@@ -57,7 +57,7 @@ class OrganizationStorageUsage(models.Model):
|
||||
"""Authoritative storage_bytes usage counter for one organization.
|
||||
|
||||
``bytes_used`` is the committed total; ``reserved_bytes`` covers in-flight
|
||||
uploads whose final size is not yet known (reserve/finalize).
|
||||
uploads whose final size is not yet known (SPEC-HUB-0022 §10 reserve/finalize).
|
||||
The effective usage against the quota is ``bytes_used + reserved_bytes``.
|
||||
"""
|
||||
|
||||
@@ -88,7 +88,7 @@ class OrganizationStorageUsage(models.Model):
|
||||
|
||||
class StorageReservation(models.Model):
|
||||
"""An in-flight storage_bytes reservation keyed by an idempotency token, so a
|
||||
multi-step upload can reserve the expected size, finalize
|
||||
multi-step upload (SPEC-HUB-0022 §10) can reserve the expected size, finalize
|
||||
the actual size once the object is persisted, and release the reservation on
|
||||
failure. A single reservation tracks one upload lifecycle.
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Принудительный выбор соединения БД для блока кода.
|
||||
|
||||
Алиас ``platform`` (роль platform, права на создание
|
||||
организаций) есть только у процессов, которым он нужен: у платформенной
|
||||
Алиас ``platform`` (роль platform, права на создание организаций —
|
||||
SPEC-HUB-0021) есть только у процессов, которым он нужен: у платформенной
|
||||
поверхности и у воркера, захватывающего outbox всех организаций
|
||||
(см. ``settings_database.build_databases``). У backend-app его нет: каталоги
|
||||
входа и мастер первого запуска работают ролью app (tenancy/0032).
|
||||
|
||||
@@ -13,7 +13,7 @@ from chatballs.testing import system_tenant_context
|
||||
|
||||
|
||||
class StorageReserveFinalizeReleaseTests(TestCase):
|
||||
"""Reserve/finalize/release — идемпотентный технический
|
||||
"""SPEC-HUB-0022 §10: reserve/finalize/release — идемпотентный технический
|
||||
учёт занятого места. Лимитов больше нет (ADR-CHATBALLS-0042 §2): сервис никогда
|
||||
не отказывает по квоте."""
|
||||
|
||||
|
||||
@@ -208,6 +208,9 @@ CHATBALLS_AI_TURN_DEADLINE_SECONDS = int(
|
||||
)
|
||||
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
|
||||
|
||||
# Managed-провайдер CustoAI удалён (ADR-CHATBALLS-0042 §3): AI — только через
|
||||
# интеграцию организации (BYOK).
|
||||
|
||||
# Long-poll hold-time мессенджеров (сек). Держим малым: опрос идёт по
|
||||
# подключениям последовательно в одном процессе, и hold-time каждого из них
|
||||
# складывается в задержку приёма у остальных. Ответы AI от этого больше не
|
||||
@@ -264,7 +267,7 @@ CHATBALLS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings(
|
||||
testing=TESTING,
|
||||
)
|
||||
|
||||
# Публичный адрес установки: абсолютные ссылки, уходящие клиентам (download вложений).
|
||||
# Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений).
|
||||
CHATBALLS_PUBLIC_BASE_URL = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "http://localhost:8000")
|
||||
|
||||
# Публичные порталы поддержки размещаются на отдельных хостах. Базовый домен
|
||||
@@ -320,7 +323,7 @@ if (
|
||||
or CHATBALLS_CALL_CONNECT_GRACE_SECONDS <= 0
|
||||
or CHATBALLS_CALL_RECONNECT_GRACE_SECONDS <= 0
|
||||
):
|
||||
raise ImproperlyConfigured("Call token TTL values must be positive")
|
||||
raise ImproperlyConfigured("HUB call token TTL values must be positive")
|
||||
|
||||
# ICE-серверы для WebRTC (SPEC-CHATBALLS-0013 §10): direct-first через STUN, TURN как
|
||||
# fallback. Формат URL через запятую (stun:host:port / turn:host:3478?transport=udp).
|
||||
|
||||
@@ -633,7 +633,7 @@
|
||||
background: var(--n-9);
|
||||
}
|
||||
|
||||
/* Блокер «провайдер не выбран» (кадр G5). */
|
||||
/* Блокер «провайдер не выбран» (кадр G5, ADR-HUB-0017). */
|
||||
|
||||
.agent-blocker {
|
||||
display: flex;
|
||||
|
||||
@@ -53,7 +53,7 @@ import { useDialogKeyboardNav } from "./useDialogKeyboardNav";
|
||||
import { useIncomingMessageSound } from "./useIncomingMessageSound";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
// Общий workspace диалогов. Видимость inbox решает
|
||||
// Общий workspace диалогов (SPEC-HUB-0010 §8.2). Видимость inbox решает
|
||||
// backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком,
|
||||
// placeholder поиска и правой панелью через render-prop. Список и история —
|
||||
// серверные окна: ни то, ни другое целиком не запрашивается.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Общие типы workspace диалогов (sales + support):
|
||||
// Общие типы workspace диалогов (sales + support). SPEC-HUB-0010 §8.2:
|
||||
// общий conversation workspace, не отдельная реализация под каждый отдел.
|
||||
|
||||
export type DialogMode = "ai" | "closed" | "operator" | "wait";
|
||||
|
||||
@@ -10,11 +10,11 @@ describe("webWidgetSnippet", () => {
|
||||
});
|
||||
|
||||
it("builds the embed snippet from the current origin and channel code", async () => {
|
||||
vi.stubGlobal("window", { location: { origin: "https://chatballs.example.com" } });
|
||||
vi.stubGlobal("window", { location: { origin: "https://hub.example.com" } });
|
||||
const { webWidgetSnippet } = await import("./model");
|
||||
|
||||
expect(webWidgetSnippet("wgt_demo")).toBe(
|
||||
`<script src="https://chatballs.example.com/chat-widget.js" data-widget-key="wgt_demo" async></script>`,
|
||||
`<script src="https://hub.example.com/chat-widget.js" data-widget-key="wgt_demo" async></script>`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,11 +28,11 @@ describe("webWidgetSnippet", () => {
|
||||
});
|
||||
|
||||
it("uses the origin verbatim with a custom port", async () => {
|
||||
vi.stubGlobal("window", { location: { origin: "https://chatballs.example.com:8443" } });
|
||||
vi.stubGlobal("window", { location: { origin: "https://hub.example.com:8443" } });
|
||||
const { webWidgetSnippet } = await import("./model");
|
||||
|
||||
expect(webWidgetSnippet("wgt_demo")).toBe(
|
||||
`<script src="https://chatballs.example.com:8443/chat-widget.js" data-widget-key="wgt_demo" async></script>`,
|
||||
`<script src="https://hub.example.com:8443/chat-widget.js" data-widget-key="wgt_demo" async></script>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ export const fetchLlmProviders = () =>
|
||||
response.items.filter((item) => item.kind === "LLM_PROVIDER")
|
||||
);
|
||||
|
||||
// Разрешённые домены Web-виджета. Пустой список в проде
|
||||
// Разрешённые домены Web-виджета (SPEC-HUB-0010 §7.1). Пустой список в проде
|
||||
// запрещает все origin'ы, поэтому домены вводятся руками и обязательны.
|
||||
// Принимаем три формы, которые понимает backend (webchat.services.origin_allowed):
|
||||
// `example.com`, `*.example.com` и `https://example.com:8443`; `localhost` — для разработки.
|
||||
@@ -135,7 +135,7 @@ export const formatAllowedOrigins = (origins: string[]): string => origins.join(
|
||||
export const invalidAllowedOrigin = (origins: string[]): string | undefined =>
|
||||
origins.find((item) => !ORIGIN_RULE.test(item));
|
||||
|
||||
// Публичный домен установки для встраивания Web-виджета (SPEC-CHATBALLS-0003 §3).
|
||||
// Публичный домен Hub для встраивания Web-виджета (SPEC-CHATBALLS-0003 §3).
|
||||
// Один frontend-образ работает на любом домене (ADR-CHATBALLS-0028 §runtime frontend):
|
||||
// сниппет генерируется от текущего origin в рантайме, а не от build-time аргумента.
|
||||
export function webWidgetSnippet(widgetKey: string): string {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { t } from "../i18n";
|
||||
|
||||
Общий словарь для всего приложения — раньше он лежал в демо-данных диалогов,
|
||||
и продуктовые экраны каналов зависели от файла с примерами.
|
||||
Провайдеры различаются точкой и коротким лейблом на своих токенах,
|
||||
а не бренд-логотипами. */
|
||||
Провайдеры различаются точкой и коротким лейблом на своих токенах
|
||||
(ADR-HUB-0013), а не бренд-логотипами. */
|
||||
|
||||
export type ProviderKey = "EMAIL" | "MAX" | "TG" | "VK" | "WEB";
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ body,
|
||||
На основной странице виджета (встраиваемого) эти переменные не нужны, но экран
|
||||
аудиозвонка из @chatballs/ui (audio-call.css) использует var(--font-sans) и радиусы,
|
||||
которых в web-chat нет. Значения — системный font stack Ant Design и токены
|
||||
радиусов из baseline tokens/typography.css + tokens/effects.css (внешние
|
||||
веб-шрифты не подключаются).
|
||||
радиусов из baseline tokens/typography.css + tokens/effects.css (ADR-HUB-0013:
|
||||
внешние веб-шрифты не подключаются).
|
||||
*/
|
||||
:root {
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [react()],
|
||||
// Панель отдаётся с домена установки под путём /chat/ (nginx).
|
||||
// Панель отдаётся с домена hub под путём /chat/ (nginx).
|
||||
base: "/chat/",
|
||||
build: {
|
||||
rollupOptions: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# chatballs — единый deployment entrypoint Chatballs (ADR-CHATBALLS-0028 §CLI).
|
||||
# chatballs — единый deployment entrypoint Chatballs (ADR-HUB-0028 §CLI).
|
||||
# Команды этапа 1: doctor, deploy, status, logs.
|
||||
# (install/setup-url/update/backup/restore/rollback — этап 2.)
|
||||
#
|
||||
|
||||
@@ -14,7 +14,7 @@ COPY apps/internal-ui ./apps/internal-ui
|
||||
COPY apps/web-chat ./apps/web-chat
|
||||
COPY packages ./packages
|
||||
|
||||
# Build-time домен не привязывается (ADR-CHATBALLS-0028 §10): один образ работает на любом
|
||||
# Build-time домен не привязывается (ADR-HUB-0028 §10): один образ работает на любом
|
||||
# домене; сниппет/WS/API выводятся от текущего origin в рантайме.
|
||||
ARG VITE_API_BASE_URL=
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Клиент P2P-звонка: собственный WebSocket signaling + RTCPeerConnection
|
||||
* Клиент P2P-звонка: собственный WebSocket signaling Hub + RTCPeerConnection
|
||||
* (SPEC-CHATBALLS-0013 §9–10). Общий для internal-ui (оператор) и web-chat (клиент).
|
||||
*
|
||||
* Роли детерминированы: offer всегда создаёт STAFF (инициатор), CUSTOMER
|
||||
|
||||
Reference in new issue
Block a user