mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
089dd6ddc2 | ||
|
|
ae46508d22 |
No files matched your search
@@ -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)),
|
||||
|
||||
+23
@@ -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),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 в ленте нет."""
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user