Compare commits

..
4 Commits
Author SHA1 Message Date
Andrey 089dd6ddc2 🔖 release: 1.10.0 2026-09-15 08:45:54 +03:00
AndreyandClaude Opus 5 ae46508d22 ✨ feat(ai): у агента своя модель для текста и своя для расшифровки
Ключ провайдера один на организацию, а агентов на нём несколько, и модель им нужна разная. Раньше модель принадлежала интеграции и копировалась на агента при каждом сохранении — выбрать её агенту было нельзя. Теперь на карточке агента две пары «провайдер + модель»: чем отвечать и чем расшифровывать голосовые. Пустое поле модели означает «как в интеграции», и подсказка в нём показывает её значение, поэтому агент, которому модель не выбирали, по-прежнему следует за настройкой ключа.

Миграция освобождает поле у тех агентов, чья модель совпадает с моделью их интеграции: такие агенты продолжают следовать за ней, а не фиксируют старое значение.

Проверено: тесты маршрутизации моделей и провайдеров, карточки агента, расшифровки, списка агентов и каталога переводов; ruff и проверка типов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:40:04 +03:00
Andrey bd3b9309bf 🔖 release: 1.9.1 2026-09-15 08:10:11 +03:00
AndreyandClaude Opus 5 e35133b159 🐛 fix(integrations): продукт представляется своим именем, отказ объясняется словами
Исходящие запросы уходили с умолчанием urllib — «Python-urllib/3.12». Защита перед чужим API такой запрос банит раньше, чем его увидит сам провайдер: на боевом стенде Groq через рабочий прокси отвечал 403 «error code: 1010», а с обычным User-Agent тот же запрос и тот же прокси проходят. Теперь opener представляется как «Chatballs/<версия>», свой User-Agent конкретного запроса он не перебивает.

Проверка подключения больше не показывает голый код. Причину, если провайдер её назвал, видно сразу («Invalid API Key»), HTML-страница защиты сжимается в строку, молчаливый 401/403 отправляет смотреть ключ и доступность провайдера из сети. Ответ целиком уходит в журнал.

Проверено на боевом стенде: без User-Agent — 403 «error code: 1010», с любым User-Agent — ответ самого Groq. Пять новых тестов, тесты интеграций, транспортов, провайдеров и i18n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:07:33 +03:00
20 changed files with 407 additions and 29 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.9.0
1.10.0
+21
View File
@@ -108,6 +108,13 @@ def knowledge_total_for_organization(organization_id: int) -> int:
)
def _integration_model(integration, key: str) -> str:
"""Модель, заданная в интеграции: подсказка в поле модели на карточке."""
if integration is None:
return ""
return str((integration.config or {}).get(key) or "")
def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) -> dict[str, object]:
agent: AIAgent = channel.ai_agent
connections = _connections_payload(channel)
@@ -125,7 +132,15 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
# Цвет группы задаётся в настройках — точка у названия (кадры G1/G3).
"groupColor": channel.group.color if channel.group_id else "",
"aiStatus": agent.status,
# Модели агента: пустая строка означает «как в интеграции», и тогда
# карточка показывает модель интеграции подсказкой в поле.
"model": agent.model,
"transcriptionModel": agent.transcription_model,
"providerModel": _integration_model(agent.provider_integration, "default_model"),
"transcriptionProviderModel": _integration_model(
agent.transcription_integration or agent.provider_integration,
"transcription_model",
),
"providerIntegrationId": agent.provider_integration_id,
# Чем расшифровывать голосовые; пусто — тем же провайдером, что отвечает.
"transcriptionIntegrationId": agent.transcription_integration_id,
@@ -233,6 +248,8 @@ def update_agent_card(
ai_fields = {
"providerIntegrationId",
"transcriptionIntegrationId",
"model",
"transcriptionModel",
"modelParams",
"persona",
"tone",
@@ -276,6 +293,10 @@ def update_agent_card(
name=channel.name,
provider_integration_id=provider_integration_id,
transcription_integration_id=transcription_integration_id,
model=str(body.get("model", agent.model)),
transcription_model=str(
body.get("transcriptionModel", agent.transcription_model)
),
model_params=model_params,
allowed_tools=agent.allowed_tools,
persona=str(body.get("persona", agent.persona)),
@@ -0,0 +1,23 @@
# Generated by Django 5.2.16 on 2026-09-15 05:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0020_aiagent_transcription_integration'),
]
operations = [
migrations.AddField(
model_name='aiagent',
name='transcription_model',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AlterField(
model_name='aiagent',
name='model',
field=models.CharField(blank=True, default='', max_length=128),
),
]
@@ -0,0 +1,34 @@
"""Агент перестаёт дублировать модель интеграции.
Раньше модель копировалась на агента при каждом сохранении, и поле означало
«то же, что у интеграции». Теперь заполненное поле означает выбор человека:
агент отвечает именно этой моделью, даже если у интеграции другая по
умолчанию. Чтобы смена настройки провайдера не перестала доезжать до агентов,
которым модель никто не выбирал, совпадающее значение очищается — такие агенты
продолжают следовать за интеграцией.
"""
from django.db import migrations
def release_copied_models(apps, schema_editor):
AIAgent = apps.get_model("ai", "AIAgent")
updated = []
for agent in AIAgent.objects.select_related("provider_integration").exclude(model=""):
integration = agent.provider_integration
default_model = str((integration.config or {}).get("default_model") or "") if integration else ""
if agent.model == default_model:
agent.model = ""
updated.append(agent)
AIAgent.objects.bulk_update(updated, ["model"])
class Migration(migrations.Migration):
dependencies = [
("ai", "0021_aiagent_transcription_model_alter_aiagent_model"),
]
operations = [
migrations.RunPython(release_copied_models, migrations.RunPython.noop),
]
+7 -1
View File
@@ -384,7 +384,13 @@ class AIAgent(TenantRelationModel):
lifecycle_version = models.PositiveIntegerField(default=0)
model = models.CharField(max_length=128, default=DEFAULT_AI_MODEL)
# Модель ответов. Пусто — берётся модель по умолчанию из интеграции; так
# агент следует за настройкой провайдера. Заполнено — решает агент: на одном
# ключе живут разные агенты, и дорогая модель нужна не каждому.
model = models.CharField(max_length=128, blank=True, default="")
# Модель расшифровки голосовых. Пусто — модель из интеграции, которая
# расшифровывает, а если и там пусто — whisper-1.
transcription_model = models.CharField(max_length=128, blank=True, default="")
model_params = models.JSONField(default=dict, blank=True)
+17 -2
View File
@@ -57,6 +57,17 @@ def resolve_provider_and_model(channel, *, fallback_model: str) -> tuple[LLMProv
def resolve_model(channel, *, fallback_model: str) -> str:
"""Модель ответов: выбранная на карточке агента, иначе модель интеграции.
Порядок именно такой: ключ провайдера один на организацию, а агентов на нём
несколько, и модель — свойство агента, а не ключа. Пустое поле на карточке
означает «как у интеграции», поэтому агент, которому модель не назначали,
продолжает следовать за настройкой провайдера.
"""
agent = getattr(channel, "ai_agent", None)
chosen = str(getattr(agent, "model", "") or "").strip()
if chosen:
return chosen
integration = _channel_integration(channel)
return str(integration.config.get("default_model") or "").strip() or fallback_model
@@ -85,8 +96,12 @@ def resolve_transcription_provider(channel) -> LLMProvider:
def resolve_transcription_model(channel) -> str:
"""Модель расшифровки из настроек той интеграции, которая расшифровывает
(поле «Модель расшифровки голосовых»); по умолчанию whisper-1."""
"""Модель расшифровки: выбранная на карточке агента, иначе модель той
интеграции, которая расшифровывает, иначе whisper-1."""
agent = getattr(channel, "ai_agent", None)
chosen = str(getattr(agent, "transcription_model", "") or "").strip()
if chosen:
return chosen
integration = _transcription_integration(channel)
return str(integration.config.get("transcription_model") or "").strip() or DEFAULT_TRANSCRIPTION_MODEL
+12 -4
View File
@@ -24,6 +24,9 @@ class AgentInput:
provider_integration_id: int | None
# Чем расшифровывать голосовые; None — тем же провайдером, что и отвечает.
transcription_integration_id: int | None
# Модели агента; пустая строка — «как в интеграции».
model: str
transcription_model: str
model_params: dict
allowed_tools: list
persona: str
@@ -95,7 +98,9 @@ def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
channel=channel,
name=f"{channel.name} Agent",
status=AIAgentStatus.DRAFT,
model=selection.model,
# Модель новой карточки не фиксируется: агент следует за интеграцией,
# пока человек не выберет свою.
model="",
provider_integration=selection.integration,
persona=data.persona,
tone=data.tone,
@@ -129,9 +134,11 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
context=context,
integration_id=data.provider_integration_id,
)
# Модель принадлежит интеграции; без провайдера прежняя модель сохраняется,
# чтобы PATCH инструкций не стирал её у черновика.
locked.model = selection.model if selection.integration else locked.model
# Модель выбирают на карточке агента: на одном ключе провайдера живут разные
# агенты, и модель им нужна разная. Пустое поле означает «как в интеграции»
# и разрешается в момент вызова (ai.provider.routing).
locked.model = data.model.strip()[:128]
locked.transcription_model = data.transcription_model.strip()[:128]
# Провайдер живёт на агенте: канал больше не изменяется при сохранении агента.
locked.provider_integration = selection.integration
locked.transcription_integration = configure_agent_transcription(
@@ -148,6 +155,7 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
update_fields=[
"name",
"model",
"transcription_model",
"provider_integration",
"transcription_integration",
"model_params",
@@ -341,8 +341,10 @@ class AgentCardActivationTests(AgentCardTestCase):
content_type="application/json",
)
self.assertEqual(patched.status_code, 200)
# Модель принадлежит интеграции: агент получает её default_model.
self.assertEqual(patched.json()["agent"]["model"], "byok-model")
# Своей модели у агента нет — он следует за интеграцией, и карточка
# показывает её модель подсказкой.
self.assertEqual(patched.json()["agent"]["model"], "")
self.assertEqual(patched.json()["agent"]["providerModel"], "byok-model")
activated = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/")
self.assertEqual(activated.status_code, 200)
@@ -58,7 +58,10 @@ class ProviderModeTests(TestCase):
)
agent = self.channel.ai_agent
agent.provider_integration = integration
agent.save(update_fields=["provider_integration"])
# Пустая модель на агенте означает «как в интеграции» — именно так живёт
# агент, которому модель не выбирали на карточке.
agent.model = ""
agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
return integration
@@ -199,7 +202,9 @@ class AgentProviderOwnershipTests(TestCase):
self.channel.provider_integration = integration
self.channel.save(update_fields=["provider_integration"])
self.agent.provider_integration = None
self.agent.save(update_fields=["provider_integration"])
# Агент следует за интеграцией: своей модели у него нет.
self.agent.model = ""
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
@@ -220,9 +225,23 @@ class AgentProviderOwnershipTests(TestCase):
),
)
self.agent.provider_integration = current
self.agent.save(update_fields=["provider_integration"])
self.agent.model = ""
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
resolve_model(self.channel, fallback_model="agent-model"), "current-model"
)
def test_model_chosen_on_the_card_wins_over_the_integration(self) -> None:
# Ключ провайдера один на организацию, агентов на нём несколько: модель
# выбирают агенту, и она не должна теряться при смене настройки ключа.
integration = self._integration(default_model="integration-model")
self.agent.provider_integration = integration
self.agent.model = "own-model"
self.agent.save(update_fields=["provider_integration", "model"])
self.channel.refresh_from_db()
self.assertEqual(
resolve_model(self.channel, fallback_model="agent-model"), "own-model"
)
@@ -15,6 +15,7 @@ from django.test import TestCase
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.custom import CustomProvider
from chatballs.ai.provider.routing import (
resolve_model,
resolve_transcription_model,
resolve_transcription_provider,
)
@@ -100,6 +101,62 @@ class TranscriptionRoutingTests(TestCase):
self.assertEqual(resolve_transcription_model(self.channel), "whisper-1")
class TranscriptionModelTests(TranscriptionRoutingTests):
"""Модель расшифровки тоже выбирается на агенте, а не только в интеграции."""
def test_model_from_the_card_wins(self) -> None:
whisper = self._integration(
name="Whisper",
base_url="https://whisper.example.test/v1",
transcription_model="whisper-large-v3",
)
self.agent.provider_integration = whisper
self.agent.transcription_integration = whisper
self.agent.transcription_model = "gpt-4o-mini-transcribe"
self.agent.save(
update_fields=[
"provider_integration",
"transcription_integration",
"transcription_model",
]
)
self.channel.refresh_from_db()
self.assertEqual(
resolve_transcription_model(self.channel), "gpt-4o-mini-transcribe"
)
def test_text_and_voice_models_are_independent(self) -> None:
answering = self._integration(
name="Ответы", base_url="https://answers.example.test/v1"
)
whisper = self._integration(
name="Whisper", base_url="https://whisper.example.test/v1"
)
self.agent.provider_integration = answering
self.agent.transcription_integration = whisper
self.agent.model = "yandexgpt/rc"
self.agent.transcription_model = "whisper-large-v3-turbo"
self.agent.save(
update_fields=[
"provider_integration",
"transcription_integration",
"model",
"transcription_model",
]
)
self.channel.refresh_from_db()
self.assertEqual(resolve_model(self.channel, fallback_model=""), "yandexgpt/rc")
self.assertEqual(
resolve_transcription_model(self.channel), "whisper-large-v3-turbo"
)
self.assertEqual(
resolve_transcription_provider(self.channel).base_url,
"https://whisper.example.test/v1",
)
class TranscriptionErrorTextTests(TestCase):
"""Оператору — фраза, провайдеру — журнал: сырого ответа API в ленте нет."""
@@ -503,6 +503,9 @@ MESSAGES: dict[str, object] = {
"notifications.type_dialog_assigned": "A conversation was assigned to me",
"notifications.type_dialog_waiting_long": "A conversation has been waiting a long time",
"notifications.type_ai_stopped": "AI stopped by an error",
"integrations.check_rejected": "The provider refused the check ({status})",
"integrations.check_rejected_access": "The provider refused the check ({status}): check the key and whether the provider is reachable from your network — region, proxy",
"integrations.check_rejected_reason": "The provider refused the check ({status}): {reason}",
"integrations.check_no_connection": "No connection: {error}",
"integrations.check_api_key_missing": "The API key is not set",
"integrations.check_base_url_missing": "Base URL is not set",
@@ -507,6 +507,9 @@ MESSAGES: dict[str, object] = {
"notifications.type_dialog_assigned": "Диалог назначили на меня",
"notifications.type_dialog_waiting_long": "Диалог долго ждёт человека",
"notifications.type_ai_stopped": "AI остановлен ошибкой",
"integrations.check_rejected": "Провайдер отклонил проверку ({status})",
"integrations.check_rejected_access": "Провайдер отклонил проверку ({status}): проверьте ключ и доступность провайдера из вашей сети — регион, прокси",
"integrations.check_rejected_reason": "Провайдер отклонил проверку ({status}): {reason}",
"integrations.check_no_connection": "Нет связи: {error}",
"integrations.check_api_key_missing": "Не указан API-ключ",
"integrations.check_base_url_missing": "Не указан Base URL",
+54 -1
View File
@@ -16,6 +16,8 @@ from __future__ import annotations
import imaplib
import json
import logging
import re
import smtplib
import urllib.error
import urllib.request
@@ -25,6 +27,8 @@ from django.conf import settings
from chatballs.i18n import t, tn
from chatballs.integrations.proxy import build_opener
logger = logging.getLogger(__name__)
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# platform-api2.max.ru отдаёт неполную цепочку сертификата (verify failed);
# рабочий и с валидным сертификатом — platform-api.max.ru.
@@ -46,11 +50,60 @@ def _get(url: str, *, headers: dict[str, str] | None = None, proxy_url: str = ""
return response.status, data
def _error_reason(body: str) -> str:
"""Короткая причина из ответа провайдера.
Ответ бывает и JSON'ом провайдера, и HTML-страницей защиты перед ним —
человеку нужна одна фраза, а не то и другое целиком.
"""
try:
payload = json.loads(body)
except (json.JSONDecodeError, TypeError):
payload = None
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict):
return str(error.get("message") or "")[:160]
for key in ("message", "detail", "error_description"):
if payload.get(key):
return str(payload[key])[:160]
if isinstance(error, str):
return error[:160]
text = re.sub(r"<[^>]+>", " ", body)
text = " ".join(text.split())
return text[:160]
def _http_failure(error: urllib.error.HTTPError) -> str:
"""Отказ провайдера словами, а не кодом.
Голый «HTTP 403» не говорит ничего: так отвечают и на чужой ключ, и на
запрос из закрытого региона, и на блокировку самого прокси. Причину, если
провайдер её назвал, показываем сразу; ответ целиком уходит в журнал.
"""
try:
body = error.read().decode("utf-8", "replace")
except (OSError, ValueError):
body = ""
logger.warning(
"Integration check rejected: HTTP %s %s — %s",
error.code,
getattr(error, "url", ""),
body[:500],
)
reason = _error_reason(body)
if reason:
return t("integrations.check_rejected_reason", status=error.code, reason=reason)
if error.code in (401, 403):
return t("integrations.check_rejected_access", status=error.code)
return t("integrations.check_rejected", status=error.code)
def _safe(fn) -> CheckResult:
try:
return fn()
except urllib.error.HTTPError as error:
return False, f"HTTP {error.code}: {error.reason}", {}
return False, _http_failure(error), {}
except (urllib.error.URLError, TimeoutError, OSError) as error:
return False, t("integrations.check_no_connection", error=error), {}
+26 -4
View File
@@ -18,6 +18,8 @@ import ssl
import urllib.parse
import urllib.request
from django.conf import settings
from chatballs.i18n import t
from chatballs.integrations.outbound import OutboundUrlRejected
@@ -85,6 +87,24 @@ def _blocked_scheme_handlers() -> list[urllib.request.BaseHandler]:
return [_RefusedFileHandler(), _RefusedFTPHandler(), _RefusedDataHandler()]
def user_agent() -> str:
"""Чем продукт представляется чужим API.
Умолчание urllib — «Python-urllib/3.x», и защита перед API (Cloudflare)
банит такой запрос до того, как его увидит сам провайдер: на бою это
выглядело как 403 «error code: 1010» у провайдера, который через тот же
прокси прекрасно отвечает браузеру. С обычным именем клиента запрос
проходит. Заголовок ставится на opener, поэтому свой User-Agent
конкретного запроса он не перебивает.
"""
return f"Chatballs/{getattr(settings, 'CHATBALLS_VERSION', 'dev')}"
def _named(opener):
opener.addheaders = [("User-Agent", user_agent())]
return opener
def build_opener(proxy_url: str, *, validate_redirect=None):
"""urllib opener, проксирующий http/https/socks5 запросы.
@@ -96,12 +116,14 @@ def build_opener(proxy_url: str, *, validate_redirect=None):
if validate_redirect is not None:
blocked.append(_GuardedRedirectHandler(validate_redirect))
if not proxy_url:
return urllib.request.build_opener(*blocked)
return _named(urllib.request.build_opener(*blocked))
scheme = urllib.parse.urlparse(proxy_url).scheme.lower()
if scheme in SOCKS_SCHEMES:
return urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked)
return urllib.request.build_opener(
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
return _named(urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked))
return _named(
urllib.request.build_opener(
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
)
)
@@ -423,6 +423,81 @@ class CheckProxyTransportTests(TestCase):
class OutboundUserAgentTests(TestCase):
"""Продукт представляется своим именем: «Python-urllib» защита перед чужим
API банит до самого API (на бою — Cloudflare «error code: 1010»)."""
def test_opener_introduces_the_product(self) -> None:
from chatballs.integrations.proxy import build_opener, user_agent
for proxy in ("", "http://proxy:8080"):
with self.subTest(proxy=proxy or "без прокси"):
agents = dict(build_opener(proxy).addheaders)
self.assertEqual(agents["User-Agent"], user_agent())
self.assertNotIn("urllib", agents["User-Agent"])
def test_request_keeps_its_own_agent(self) -> None:
import urllib.request
from chatballs.integrations.proxy import build_opener
request = urllib.request.Request(
"https://api.example.test/v1/models", headers={"User-Agent": "Mine/1.0"}
)
opener = build_opener("")
# urllib добавляет заголовки opener'а только к тем, которых нет в запросе.
self.assertEqual(request.get_header("User-agent"), "Mine/1.0")
self.assertTrue(any(name == "User-Agent" for name, _ in opener.addheaders))
class CheckFailureTextTests(TestCase):
"""Отказ провайдера объясняется словами: голый код ничего не говорит."""
def _reject(self, code: int, body: bytes):
import urllib.error
from io import BytesIO
error = urllib.error.HTTPError(
"https://api.example.test/v1/models", code, "Forbidden", {}, BytesIO(body)
)
return mock.patch(
"chatballs.integrations.checks.build_opener",
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
)
def test_reason_from_the_provider_reaches_the_screen(self) -> None:
body = json.dumps(
{"error": {"message": "Your API key is invalid"}}
).encode()
with self._reject(403, body):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertIn("Your API key is invalid", detail)
self.assertIn("403", detail)
def test_html_block_page_is_squeezed_into_one_line(self) -> None:
body = b"<html><head><title>Access denied</title></head><body><h1>Sorry, you have been blocked</h1></body></html>"
with self._reject(403, body):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertNotIn("<", detail)
self.assertIn("blocked", detail.lower())
def test_silent_refusal_tells_where_to_look(self) -> None:
with self._reject(403, b""):
ok, detail, _meta = checks.check_custom(
secret="sk-test", base_url="https://api.example.test/v1"
)
self.assertFalse(ok)
self.assertIn("регион", detail)
class OpenRouterProviderProxyTests(TestCase):
def test_provider_routes_through_proxy_handler(self) -> None:
@@ -5,7 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
import { api, ApiError } from "../../api/client";
import { ChannelGlyph } from "../../shared/badges";
import { SelectField } from "../../shared/form-controls";
import { FormField, SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
@@ -535,6 +535,12 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
const missingProvider = card.providerIntegrationId === null;
const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? "";
const transcriptionName = providers.find((item) => item.id === card.transcriptionIntegrationId)?.name ?? "";
// Поля моделей редактируются свободно и уходят на сервер по потере фокуса:
// сохранять каждую букву — это запрос на символ.
const [modelDraft, setModelDraft] = useState(card.model);
const [transcriptionDraft, setTranscriptionDraft] = useState(card.transcriptionModel);
useEffect(() => { setModelDraft(card.model); }, [card.model]);
useEffect(() => { setTranscriptionDraft(card.transcriptionModel); }, [card.transcriptionModel]);
return (
<section className="agent-card is-side">
@@ -551,13 +557,18 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<label className="agent-field is-model">
<span>{t("common.model")}</span>
<span className="agent-field-control">
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? t("ai.pick_provider") : card.model}</span>
<Icon name="search" size={14} strokeWidth={1.8} />
</span>
</label>
{/* Ключ провайдера один на организацию, а агентов на нём несколько:
модель принадлежит агенту. Пустое поле — «как в интеграции», и
подсказкой в нём стоит её модель. */}
<FormField
disabled={busy || !canManage}
label={t("common.model")}
mono
placeholder={missingProvider ? t("ai.pick_provider") : card.providerModel || t("ai.model_of_integration")}
value={modelDraft}
onChange={setModelDraft}
onBlur={() => { if (modelDraft !== card.model) void apply({ model: modelDraft }); }}
/>
{/* Речь в текст умеет не всякая модель, которой агент отвечает: у части
провайдеров аудио-эндпоинта нет вовсе. Поэтому выбор отдельный. */}
<SelectField
@@ -569,6 +580,15 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
onChange={(next) => void apply({ transcriptionIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.same_as_answers")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<FormField
disabled={busy || !canManage}
label={t("ai.transcription_model")}
mono
placeholder={card.transcriptionProviderModel || t("ai.model_of_integration")}
value={transcriptionDraft}
onChange={setTranscriptionDraft}
onBlur={() => { if (transcriptionDraft !== card.transcriptionModel) void apply({ transcriptionModel: transcriptionDraft }); }}
/>
</div>
</section>
);
+11 -2
View File
@@ -35,6 +35,11 @@ export type AgentCard = {
groupColor: string;
aiStatus: AgentAiStatus;
model: string;
/** Модель ответов; пусто — как в интеграции. */
transcriptionModel: string;
/** Модели, заданные в интеграциях: подсказка в пустом поле. */
providerModel: string;
transcriptionProviderModel: string;
providerIntegrationId: number | null;
/** Чем расшифровывать голосовые; null — тем же провайдером, что отвечает. */
transcriptionIntegrationId: number | null;
@@ -60,6 +65,8 @@ export type AgentPatch = Partial<{
isActive: boolean;
providerIntegrationId: number | null;
transcriptionIntegrationId: number | null;
model: string;
transcriptionModel: string;
answerLanguage: string;
persona: string;
tone: string;
@@ -158,9 +165,11 @@ export function agentTile(card: Pick<AgentCard, "id" | "isActive">): { color: st
return { color, background: `color-mix(in srgb, ${color} 12%, var(--surface-card))` };
}
/** Модель в списке: пока провайдер не выбран, показывать нечего (кадр G1). */
/** Модель в списке: своя модель агента, иначе та, что стоит в интеграции.
* Пока провайдер не выбран, показывать нечего (кадр G1). */
export function agentModelLabel(card: AgentCard): string {
return card.providerIntegrationId ? card.model : "—";
if (!card.providerIntegrationId) return "—";
return card.model || card.providerModel || "—";
}
export function agentTint(provider: string): { color: string; bg: string; full: string } {
+2
View File
@@ -384,6 +384,7 @@ export const en: Record<MessageKey, Message> = {
"ai.manage": "Manage",
"ai.material_import": "Material import",
"ai.move": "Move",
"ai.model_of_integration": "Same as in the integration",
"ai.move_category": "Move to a category",
"ai.name_group_all_takes_start": "A name and a group are all it takes to start. Instructions, knowledge and connections are set later on the card.",
"ai.name_unique_within_its_parent": "The name is unique within its parent.",
@@ -491,6 +492,7 @@ export const en: Record<MessageKey, Message> = {
"ai.up_25_mb": "· up to 25 MB",
"ai.update": "update",
"ai.updated": "· Updated:",
"ai.transcription_model": "Transcription model",
"ai.transcription_provider": "Voice transcription",
"ai.updated_2": "UPDATED",
"ai.updated_by_at": "updated by {name} · {date}",
+2
View File
@@ -385,6 +385,7 @@ export const ru = {
"ai.manage": "Управлять",
"ai.material_import": "Импорт материалов",
"ai.move": "Переместить",
"ai.model_of_integration": "Как в интеграции",
"ai.move_category": "Переместить в категорию",
"ai.name_group_all_takes_start": "Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.",
"ai.name_unique_within_its_parent": "Имя уникально внутри родителя.",
@@ -492,6 +493,7 @@ export const ru = {
"ai.up_25_mb": "· до 25 МБ",
"ai.update": "обновить",
"ai.updated": "· Обновлено:",
"ai.transcription_model": "Модель расшифровки",
"ai.transcription_provider": "Расшифровка голосовых",
"ai.updated_2": "ОБНОВЛЕНО",
"ai.updated_by_at": "обновил {name} · {date}",
@@ -9,6 +9,9 @@ type FormFieldProps = {
label: string;
mono?: boolean;
onChange?: (value: string) => void;
/** Значение уходит на сервер по потере фокуса: запрос на каждую букву — это
* запрос на каждую букву. */
onBlur?: () => void;
placeholder?: string;
type?: "password" | "text";
value: string;
@@ -21,6 +24,7 @@ export function FormField({
label,
mono = false,
onChange,
onBlur,
placeholder = "",
type = "text",
value,
@@ -39,7 +43,7 @@ export function FormField({
return (
<label className={className}>
<span>{label}</span>
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} />
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} onBlur={onBlur} />
{error && <small className="form-field-error" role="alert">{error}</small>}
</label>
);