diff --git a/apps/backend/hub_platform/ai/limits.py b/apps/backend/hub_platform/ai/limits.py index e9ae220..1d1a23e 100644 --- a/apps/backend/hub_platform/ai/limits.py +++ b/apps/backend/hub_platform/ai/limits.py @@ -25,6 +25,8 @@ def assert_within_limits(channel, agent) -> None: global_limit = settings.HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS if global_limit and daily_cost_micros() >= global_limit: raise LimitExceeded("Global daily AI cost limit reached") - channel_limit = (agent.limits or {}).get("dailyCostMicros") - if channel_limit and daily_cost_micros(channel) >= channel_limit: + # Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается + # в micro-USD. 1 цент = 10 000 micro-USD. + channel_limit = (agent.limits or {}).get("dailyCostUsd") + if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000: raise LimitExceeded("Channel daily AI cost limit reached") diff --git a/apps/backend/hub_platform/ai/services.py b/apps/backend/hub_platform/ai/services.py index c358aed..c486856 100644 --- a/apps/backend/hub_platform/ai/services.py +++ b/apps/backend/hub_platform/ai/services.py @@ -34,6 +34,20 @@ class AgentCreateInput: knowledge_document_ids: list[int] +# Единственный поддерживаемый лимит агента — дневной бюджет в целых центах USD +# (dailyCostUsd). Прочие исторические ключи (dailyCostMicros, dailyBudgetRub, +# dailyDialogs, maxMessagesPerDialog) бэкендом не используются и отбрасываются. +def _normalize_limits(raw: dict | None) -> dict: + if not isinstance(raw, dict): + return {} + value = raw.get("dailyCostUsd") + try: + cents = int(value) + except (TypeError, ValueError): + return {} + return {"dailyCostUsd": cents} if cents > 0 else {} + + START_PROMPTS: tuple[tuple[str, str, str], ...] = ( ("system", "Системный prompt", PromptCategory.SYSTEM), ("qualification", "Квалификация", PromptCategory.QUALIFICATION), @@ -122,7 +136,7 @@ def update_agent(*, agent: AIAgent, data: AgentInput) -> AIAgent: agent.model = data.model agent.model_params = data.model_params agent.allowed_tools = data.allowed_tools - agent.limits = data.limits + agent.limits = _normalize_limits(data.limits) agent.save(update_fields=["name", "model", "model_params", "allowed_tools", "limits", "updated_at"]) return agent diff --git a/apps/backend/hub_platform/ai/tests.py b/apps/backend/hub_platform/ai/tests.py index d25bda3..6b5fe80 100644 --- a/apps/backend/hub_platform/ai/tests.py +++ b/apps/backend/hub_platform/ai/tests.py @@ -450,10 +450,11 @@ class ChatInvocationTests(TestCase): from hub_platform.ai.models import LlmInvocation, LlmInvocationStatus from hub_platform.ai.provider.base import ChatMessage - self.agent.limits = {"dailyCostMicros": 1} + self.agent.limits = {"dailyCostUsd": 1} self.agent.save(update_fields=["limits"]) + # 1 цент = 10 000 micro-USD; лимит превышен расходом в 10_001 micros. LlmInvocation.objects.create( - channel=self.channel, purpose="seed", operation="chat", model="x", cost_micros=10, + channel=self.channel, purpose="seed", operation="chat", model="x", cost_micros=10_001, status=LlmInvocationStatus.SUCCESS, ) diff --git a/apps/backend/hub_platform/conversations/ingest.py b/apps/backend/hub_platform/conversations/ingest.py index f9ec309..5c9f791 100644 --- a/apps/backend/hub_platform/conversations/ingest.py +++ b/apps/backend/hub_platform/conversations/ingest.py @@ -12,6 +12,7 @@ import logging from django.db import IntegrityError, transaction from django.utils import timezone +from hub_platform.ai.limits import LimitExceeded from hub_platform.ai.provider.base import ProviderError from hub_platform.ai.runtime import HANDOFF_TOKEN from hub_platform.channels.runtime import run_channel_turn @@ -142,9 +143,10 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: try: result = run_channel_turn(channel=channel, message=inbound.text, history=_history(conversation)) - except ProviderError as error: - # Сбой AI не должен «терять» сообщение: переводим диалог в очередь к - # оператору, уведомляем и отвечаем клиенту понятным fallback. + except (ProviderError, LimitExceeded) as error: + # Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны + # «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и + # отвечаем клиенту понятным fallback. logger.warning("AI turn failed for conversation %s: %s", conversation.id, error) conversation.control_mode = ControlMode.PAUSED conversation.expected_responder = ExpectedResponder.OPERATOR diff --git a/apps/backend/hub_platform/conversations/tests.py b/apps/backend/hub_platform/conversations/tests.py new file mode 100644 index 0000000..0042ff0 --- /dev/null +++ b/apps/backend/hub_platform/conversations/tests.py @@ -0,0 +1,66 @@ +from unittest import mock + +from django.test import TestCase + +from hub_platform.ai.limits import LimitExceeded +from hub_platform.ai.models import AIAgent +from hub_platform.channels.models import Channel +from hub_platform.conversations.models import ControlMode, ExpectedResponder, Message, MessageAuthor +from hub_platform.conversations.transports.base import InboundMessage +from hub_platform.identity.bootstrap import bootstrap_edevs_owner +from hub_platform.identity.models import Organization +from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider + + +def _messenger_connection(channel): + return Integration.objects.create( + organization=channel.organization, + kind=IntegrationKind.MESSENGER, + provider=IntegrationProvider.TELEGRAM, + name="test-bot", + channel=channel, + ) + + +class IngestLimitHandlingTests(TestCase): + """При срабатывании дневного лимита стоимости (LimitExceeded) диалог не должен + «зависать»: его передают оператору с fallback-ответом клиенту (как при сбое + провайдера). См. ingest.ingest_inbound. + """ + + def setUp(self) -> None: + bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") + self.organization = Organization.objects.get(slug="edevs") + self.channel = Channel.objects.create(organization=self.organization, code="foxray-sales", name="FoxRay — продажи") + AIAgent.objects.create(channel=self.channel, name="FoxRay Agent", model="openai/gpt-4o-mini") + self.integration = _messenger_connection(self.channel) + self.inbound = InboundMessage( + external_id="ext-1", user_id="user-1", chat_id="chat-1", text="Здравствуйте", display_name="Гость" + ) + + def test_limit_exceeded_hands_off_to_operator(self) -> None: + from hub_platform.conversations.ingest import ingest_inbound + + with ( + mock.patch( + "hub_platform.conversations.ingest.run_channel_turn", + side_effect=LimitExceeded("Channel daily AI cost limit reached"), + ), + mock.patch("hub_platform.conversations.ingest.transports.send_reply", return_value=True) as send, + ): + ingest_inbound(self.integration, self.inbound) + + conversation = self.channel.conversations.get() + # Диалог передан оператору, ответчик — оператор. + self.assertEqual(conversation.control_mode, ControlMode.PAUSED) + self.assertEqual(conversation.expected_responder, ExpectedResponder.OPERATOR) + + # Сохранены: входящее сообщение, системная отметка и fallback ИИ. + authors = list(self.channel.conversations.get().messages.values_list("author_type", flat=True)) + self.assertIn(MessageAuthor.CONTACT, authors) + self.assertIn(MessageAuthor.SYSTEM, authors) + self.assertIn(MessageAuthor.AI, authors) + + # Fallback отправлен в канал. + send.assert_called_once() + self.assertIn("специалисту", send.call_args.kwargs["text"]) diff --git a/apps/internal-ui/src/features/ai/AgentsTable.tsx b/apps/internal-ui/src/features/ai/AgentsTable.tsx index 8738e71..f3fde19 100644 --- a/apps/internal-ui/src/features/ai/AgentsTable.tsx +++ b/apps/internal-ui/src/features/ai/AgentsTable.tsx @@ -1,9 +1,8 @@ import { Dropdown } from "antd"; -import type { RouteKey } from "../../types"; import { Icon } from "../../shared/icons"; import { StatusPill } from "../../shared/ui"; -import { Button, MonoLink, ToneBadge } from "../../shared/ui-controls"; +import { MonoLink, ToneBadge } from "../../shared/ui-controls"; import { productAccent } from "../../shared/utils"; import { publishedRelease, releaseName, type AiAgent, type AiRelease } from "./model"; @@ -13,7 +12,6 @@ export function AgentsTable({ menuId, setMenuId, toggleActive, - setRoute, openAgent, openRelease, }: { @@ -22,7 +20,6 @@ export function AgentsTable({ menuId: number | null; setMenuId: (id: number | null) => void; toggleActive: (agent: AiAgent) => void; - setRoute: (route: RouteKey) => void; openAgent: (agentId: number) => void; openRelease: (releaseId: number) => void; }) { @@ -84,7 +81,6 @@ export function AgentsTable({ —
- void; setRoute: (route: RouteKey) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openRelease: (releaseId: number) => void }) { +export function AiAgentsPage({ agents, releases, reload, openAgentCreate, openAgent, openRelease }: { agents: AiAgent[]; releases: AiRelease[]; reload: () => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openRelease: (releaseId: number) => void }) { const [menuId, setMenuId] = useState(null); async function toggleActive(agent: AiAgent) { @@ -22,7 +21,7 @@ export function AiAgentsPage({ agents, releases, reload, setRoute, openAgentCrea
openAgentCreate(null)}>Создать агента} /> - +
); } diff --git a/apps/internal-ui/src/features/ai/AiSubnav.tsx b/apps/internal-ui/src/features/ai/AiSubnav.tsx index 73059bf..70f46d4 100644 --- a/apps/internal-ui/src/features/ai/AiSubnav.tsx +++ b/apps/internal-ui/src/features/ai/AiSubnav.tsx @@ -3,7 +3,6 @@ import { UnderlineTabs } from "../../shared/ui-controls"; const ITEMS: Array<{ key: RouteKey; label: string; disabled?: boolean }> = [ { key: "aiAgents", label: "AI-агенты" }, - { key: "aiTestChat", label: "Тестовый чат" }, { key: "aiUsage", label: "Использование AI", disabled: true }, ]; diff --git a/apps/internal-ui/src/features/ai/detail/AgentEditForm.tsx b/apps/internal-ui/src/features/ai/detail/AgentEditForm.tsx index e13fe34..e54ec21 100644 --- a/apps/internal-ui/src/features/ai/detail/AgentEditForm.tsx +++ b/apps/internal-ui/src/features/ai/detail/AgentEditForm.tsx @@ -10,23 +10,25 @@ import type { AiAgentDetail } from "./model"; // Известные инструменты системы (services.py: operator-handoff). Хранятся как string[]. const TOOLS: Array<[string, string]> = [["operator-handoff", "Передача оператору"]]; -// Фиксированный набор лимитов (release/model.ts limitLabel). Числовые поля. -const LIMIT_FIELDS: Array<[string, string]> = [ - ["dailyDialogs", "Диалогов в день"], - ["maxMessagesPerDialog", "Макс. сообщений / диалог"], - ["dailyBudgetRub", "Бюджет в день, ₽"], - ["dailyCostMicros", "Бюджет в день, micros"], -]; +// Дневной бюджет хранится в целых центах USD (dailyCostUsd); в поле вводим доллары. +function budgetFromCents(limits: Record): string { + const cents = limits?.dailyCostUsd; + if (typeof cents !== "number" || cents <= 0) return ""; + return (cents / 100).toString(); +} -function isNumber(value: string): boolean { - return value === "" || /^\d+$/.test(value.trim()); +// Допускаются неотрицательные доллары: до 2 знаков после запятой, разделитель «.» или «,». +function isBudget(value: string): boolean { + const trimmed = value.trim(); + if (trimmed === "") return true; + return /^\d+([.,]\d{1,2})?$/.test(trimmed); } export function AgentEditForm({ agent, onClose, onSaved }: { agent: AiAgentDetail; onClose: () => void; onSaved: () => void }) { const [name, setName] = useState(agent.name); const [model, setModel] = useState(agent.model); const [tools, setTools] = useState(Array.isArray(agent.allowedTools) ? (agent.allowedTools as string[]) : []); - const [limits, setLimits] = useState>({ ...(agent.limits as Record) }); + const [budget, setBudget] = useState(budgetFromCents(agent.limits as Record)); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -34,30 +36,18 @@ export function AgentEditForm({ agent, onClose, onSaved }: { agent: AiAgentDetai setTools((current) => (current.includes(code) ? current.filter((item) => item !== code) : [...current, code])); } - function setLimit(key: string, value: string) { - setLimits((current) => { - const next = { ...current }; - const trimmed = value.trim(); - next[key] = trimmed === "" ? Number.NaN : Number(trimmed); - return next; - }); - } - - const limitsValid = Object.values(limits).every((value) => Number.isFinite(value)); - const ready = name.trim().length > 0 && limitsValid; + const ready = name.trim().length > 0 && isBudget(budget); async function submit() { if (!ready) return; setSubmitting(true); setError(null); - const cleanLimits: Record = {}; - for (const [key, value] of Object.entries(limits)) { - if (Number.isFinite(value)) cleanLimits[key] = value; - } + const dollars = parseFloat(budget.trim().replace(",", ".")); + const limits: Record = Number.isFinite(dollars) && dollars > 0 ? { dailyCostUsd: Math.round(dollars * 100) } : {}; try { await api(`/api/v1/ai/agents/${agent.id}/update/`, { method: "PATCH", - body: JSON.stringify({ name: name.trim(), model, allowedTools: tools, limits: cleanLimits }), + body: JSON.stringify({ name: name.trim(), model, allowedTools: tools, limits }), }); onSaved(); } catch (caught) { @@ -86,18 +76,17 @@ export function AgentEditForm({ agent, onClose, onSaved }: { agent: AiAgentDetai
Лимиты
- {LIMIT_FIELDS.map(([key, label]) => ( - - ))} +
{error &&
{error}
} diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx index 9dd8dcb..3e39bff 100644 --- a/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx +++ b/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx @@ -2,20 +2,17 @@ import { Icon } from "../../../shared/icons"; import { StatusPill } from "../../../shared/ui"; import { Button } from "../../../shared/ui-controls"; import { productAccent } from "../../../shared/utils"; -import type { RouteKey } from "../../../types"; import { dailyBudget, publishedRelease, releaseDisplayName, type AiAgentDetail, type AiReleaseFull } from "./model"; export function AiAgentDetailHeader({ agent, releases, - setRoute, openRelease, createRelease, onEditChannel, }: { agent: AiAgentDetail; releases: AiReleaseFull[]; - setRoute: (route: RouteKey) => void; openRelease: (releaseId: number) => void; createRelease: () => void; onEditChannel: () => void; @@ -40,7 +37,6 @@ export function AiAgentDetailHeader({
-
diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx index 459744c..11aca0d 100644 --- a/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx +++ b/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx @@ -4,7 +4,6 @@ import { api } from "../../../api/client"; import { Icon } from "../../../shared/icons"; import { EmptyState, LoadingState } from "../../../shared/ui"; import { Button, UnderlineTabs } from "../../../shared/ui-controls"; -import type { RouteKey } from "../../../types"; import { AiAgentDetailHeader } from "./AiAgentDetailHeader"; import { AgentEditForm } from "./AgentEditForm"; import { ChannelEditForm } from "./ChannelEditForm"; @@ -16,7 +15,7 @@ import { AiAgentReleasesTab } from "./AiAgentReleasesTab"; import { agentTabs, type AgentTab, type AiReleaseFull } from "./model"; import { useAiAgentDetail } from "./useAiAgentDetail"; -export function AiAgentDetailPage({ agentId, setRoute, openRelease, onAgentLoaded }: { agentId: number | null; setRoute: (route: RouteKey) => void; openRelease: (releaseId: number) => void; onAgentLoaded: (name: string | null) => void }) { +export function AiAgentDetailPage({ agentId, openRelease, onAgentLoaded }: { agentId: number | null; openRelease: (releaseId: number) => void; onAgentLoaded: (name: string | null) => void }) { const { agent, releases, knowledge, prompts, loading, error, reload } = useAiAgentDetail(agentId); const [tab, setTab] = useState("overview"); const [editOpen, setEditOpen] = useState(false); @@ -50,7 +49,7 @@ export function AiAgentDetailPage({ agentId, setRoute, openRelease, onAgentLoade return (
- setChannelEdit(true)} /> + setChannelEdit(true)} /> {(tab === "instructions" || tab === "knowledge") && (
diff --git a/apps/internal-ui/src/features/ai/detail/model.ts b/apps/internal-ui/src/features/ai/detail/model.ts index eceede4..0d66c4b 100644 --- a/apps/internal-ui/src/features/ai/detail/model.ts +++ b/apps/internal-ui/src/features/ai/detail/model.ts @@ -128,9 +128,9 @@ export function releaseDisplayName(release: AiReleaseFull): string { return `REL-${prefix.toUpperCase()}-v${release.version}`; } -// agent.limits — свободный JSON; пока бэкенд хранит только дневной лимит стоимости (micro-USD). +// agent.limits — единый поддерживаемый лимит: дневной бюджет в целых центах USD (dailyCostUsd). export function dailyBudget(limits: Record): string { - const micros = limits?.dailyCostMicros; - if (typeof micros !== "number" || micros <= 0) return "—"; - return `$${(micros / 1_000_000).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} / день`; + const cents = limits?.dailyCostUsd; + if (typeof cents !== "number" || cents <= 0) return "—"; + return `$${(cents / 100).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} / день`; } diff --git a/apps/internal-ui/src/features/ai/release/ProductAIReleaseHeader.tsx b/apps/internal-ui/src/features/ai/release/ProductAIReleaseHeader.tsx index 48a3fc4..aadd185 100644 --- a/apps/internal-ui/src/features/ai/release/ProductAIReleaseHeader.tsx +++ b/apps/internal-ui/src/features/ai/release/ProductAIReleaseHeader.tsx @@ -1,6 +1,5 @@ import { Icon } from "../../../shared/icons"; import { Button } from "../../../shared/ui-controls"; -import type { RouteKey } from "../../../types"; import type { AiReleaseFull } from "../detail/model"; import { formatDateTime, releaseLabel, releaseState, type ReleaseCheck } from "./model"; @@ -8,13 +7,11 @@ export function ProductAIReleaseHeader({ canPublish, checks, release, - setRoute, startPublish, }: { canPublish: boolean; checks: ReleaseCheck[]; release: AiReleaseFull; - setRoute: (route: RouteKey) => void; startPublish: () => void; }) { const state = releaseState(checks, release.status === "PUBLISHED"); @@ -38,7 +35,6 @@ export function ProductAIReleaseHeader({
- diff --git a/apps/internal-ui/src/features/ai/release/ProductAIReleasePage.tsx b/apps/internal-ui/src/features/ai/release/ProductAIReleasePage.tsx index 0bf55d8..875deb6 100644 --- a/apps/internal-ui/src/features/ai/release/ProductAIReleasePage.tsx +++ b/apps/internal-ui/src/features/ai/release/ProductAIReleasePage.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react"; import { api } from "../../../api/client"; import { EmptyState, LoadingState } from "../../../shared/ui"; -import type { RouteKey } from "../../../types"; import { ProductAIReleaseHeader } from "./ProductAIReleaseHeader"; import { PublishReleaseModal } from "./PublishReleaseModal"; import { ReleaseComposition } from "./ReleaseComposition"; @@ -13,11 +12,9 @@ import { useProductAIRelease } from "./useProductAIRelease"; export function ProductAIReleasePage({ onReleaseLoaded, releaseId, - setRoute, }: { onReleaseLoaded: (name: string | null) => void; releaseId: number | null; - setRoute: (route: RouteKey) => void; }) { const { release, releases, knowledge, prompts, loading, error, reload } = useProductAIRelease(releaseId); const [modalOpen, setModalOpen] = useState(false); @@ -54,7 +51,7 @@ export function ProductAIReleasePage({ return (
- setModalOpen(true)} /> + setModalOpen(true)} />
diff --git a/apps/internal-ui/src/features/ai/release/ReleaseComposition.tsx b/apps/internal-ui/src/features/ai/release/ReleaseComposition.tsx index c3f9b69..ea0f1c9 100644 --- a/apps/internal-ui/src/features/ai/release/ReleaseComposition.tsx +++ b/apps/internal-ui/src/features/ai/release/ReleaseComposition.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from "react"; import { Icon } from "../../../shared/icons"; -import type { AiReleaseFull, KnowledgeDoc, PromptDoc } from "../detail/model"; -import { formatParam, isPromptChanged, knowledgeTitle, limitLabel, promptTitle, promptVersionDate, toolLabel } from "./model"; +import { dailyBudget, type AiReleaseFull, type KnowledgeDoc, type PromptDoc } from "../detail/model"; +import { formatParam, isPromptChanged, knowledgeTitle, promptTitle, promptVersionDate, toolLabel } from "./model"; function ReleaseCard({ children, className = "", title, icon }: { children: ReactNode; className?: string; icon?: ReactNode; title: string }) { return ( @@ -30,7 +30,7 @@ function VersionRow({ changed, date, title, version }: { changed?: boolean; date export function ReleaseComposition({ knowledge, prompts, published, release }: { knowledge: KnowledgeDoc[]; prompts: PromptDoc[]; published?: AiReleaseFull; release: AiReleaseFull }) { const modelParams = release.modelParams ?? {}; const tools = release.allowedTools.map(toolLabel).filter((tool) => tool !== "—"); - const limitRows = Object.entries(release.limits ?? {}); + const budget = dailyBudget(release.limits ?? {}); return (
@@ -86,15 +86,13 @@ export function ReleaseComposition({ knowledge, prompts, published, release }: { )} - {limitRows.length === 0 ? ( + {budget === "—" ? ( ) : ( - limitRows.map(([key, value]) => ( -
- {limitLabel(key)} - {formatParam(value)} -
- )) +
+ Бюджет в день + {budget} +
)}
diff --git a/apps/internal-ui/src/features/ai/release/model.test.ts b/apps/internal-ui/src/features/ai/release/model.test.ts index 4cad5e0..00bddaa 100644 --- a/apps/internal-ui/src/features/ai/release/model.test.ts +++ b/apps/internal-ui/src/features/ai/release/model.test.ts @@ -14,7 +14,7 @@ function buildRelease(overrides: Partial = {}): AiReleaseFull { model: "anthropic/claude-sonnet-4.6", modelParams: {}, allowedTools: [{ name: "handoff" }], - limits: { maxMessagesPerDialog: 12 }, + limits: { dailyCostUsd: 500 }, notes: "", retrievalIndexVersion: "", knowledgeVersions: [{ document: "overview", version: 1 }], diff --git a/apps/internal-ui/src/features/ai/release/model.ts b/apps/internal-ui/src/features/ai/release/model.ts index 1ef383e..f94e012 100644 --- a/apps/internal-ui/src/features/ai/release/model.ts +++ b/apps/internal-ui/src/features/ai/release/model.ts @@ -79,20 +79,18 @@ export function canPublishRelease(release: AiReleaseFull, checks: ReleaseCheck[] return release.status === "DRAFT" && checks.every((check) => check.tone === "ok"); } -export function validationBanner(checks: ReleaseCheck[], tested = true) { +export function validationBanner(checks: ReleaseCheck[]) { const summary = checksSummary(checks); if (summary.tone === "error") return { title: "Найдены ошибки валидации", note: "Исправьте отмеченные пункты в составе версии, затем перезапустите проверку.", tone: "error" as const }; if (summary.tone === "warn") return { title: "Черновик версии неполный", note: "Заполните обязательные разделы, чтобы перейти к проверке.", tone: "warn" as const }; - if (!tested) return { title: "Готово к тестированию", note: "Проверки пройдены. Запустите тест-чат перед публикацией.", tone: "ok" as const }; - return { title: "Готово к публикации", note: "Все проверки пройдены, протестировано в тест-чате. Можно публиковать.", tone: "ok" as const }; + return { title: "Готово к публикации", note: "Все проверки пройдены. Можно публиковать.", tone: "ok" as const }; } -export function releaseState(checks: ReleaseCheck[], published: boolean, tested = true): { className: string; label: string } { +export function releaseState(checks: ReleaseCheck[], published: boolean): { className: string; label: string } { const summary = checksSummary(checks); if (published) return { className: "published", label: "Опубликован" }; if (summary.tone === "error") return { className: "errors", label: "Ошибки валидации" }; if (summary.tone === "warn") return { className: "incomplete", label: "Неполный черновик" }; - if (!tested) return { className: "ready-test", label: "Готов к тестированию" }; return { className: "ready-publish", label: "Готов к публикации" }; } @@ -133,13 +131,3 @@ export function isPromptChanged(published: AiReleaseFull | undefined, code: stri export function isKnowledgeChanged(published: AiReleaseFull | undefined, code: string, version: number): boolean { return published ? !published.knowledgeVersions.some((item) => item.document === code && item.version === version) : true; } - -export function limitLabel(key: string): string { - const labels: Record = { - dailyBudgetRub: "Бюджет в день", - dailyCostMicros: "Бюджет в день", - dailyDialogs: "Диалогов в день", - maxMessagesPerDialog: "Макс. сообщений / диалог", - }; - return labels[key] ?? key; -} diff --git a/apps/internal-ui/src/features/ai/release/styles.css b/apps/internal-ui/src/features/ai/release/styles.css index 5194c6c..d39d36c 100644 --- a/apps/internal-ui/src/features/ai/release/styles.css +++ b/apps/internal-ui/src/features/ai/release/styles.css @@ -86,16 +86,6 @@ background: #52c41a; } -.release-status-badge.ready-test { - color: #0958d9; - background: #e6f4ff; - border-color: #91caff; -} - -.release-status-badge.ready-test span { - background: #1677ff; -} - .release-status-badge.incomplete { color: #8c8c8c; background: #f5f5f5; diff --git a/apps/internal-ui/src/features/ai/styles.css b/apps/internal-ui/src/features/ai/styles.css index 4a2e3c7..0ed486a 100644 --- a/apps/internal-ui/src/features/ai/styles.css +++ b/apps/internal-ui/src/features/ai/styles.css @@ -101,13 +101,6 @@ gap: 7px; } -.ai-test-chat-btn { - height: 32px; - padding: 0 12px; - font-size: 12.5px; - white-space: nowrap; -} - .ai-table-footer { display: flex; align-items: center; diff --git a/apps/internal-ui/src/features/ai/test-chat/AiTestChatPage.tsx b/apps/internal-ui/src/features/ai/test-chat/AiTestChatPage.tsx deleted file mode 100644 index 71440b7..0000000 --- a/apps/internal-ui/src/features/ai/test-chat/AiTestChatPage.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useState } from "react"; - -import type { TestChannel } from "./model"; -import { TestChatDiagnostics } from "./TestChatDiagnostics"; -import { TestChatSidebar } from "./TestChatSidebar"; -import { TestConversation } from "./TestConversation"; - -export function AiTestChatPage() { - const [channel, setChannel] = useState("MAX"); - const [scenario, setScenario] = useState("box"); - - return ( -
- - - -
- ); -} diff --git a/apps/internal-ui/src/features/ai/test-chat/TestChatDiagnostics.tsx b/apps/internal-ui/src/features/ai/test-chat/TestChatDiagnostics.tsx deleted file mode 100644 index 99eaa18..0000000 --- a/apps/internal-ui/src/features/ai/test-chat/TestChatDiagnostics.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Icon } from "../../../shared/icons"; -import { diagnosticChunks } from "./model"; - -export function TestChatDiagnostics() { - return ( -
-
-

Диагностика

- Последний ответ AI -
-
-
-
Tokens1 284in 940 · out 344
-
Cost₽0,42latency 3,1с
-
- - ИСПОЛЬЗОВАННЫЕ ФРАГМЕНТЫ -
- {diagnosticChunks.map((chunk) => ( -
-
{chunk.source}{chunk.score}
-

{chunk.text}

-
- ))} -
- - ВЫЗВАННЫЕ ИНСТРУМЕНТЫ -
- - -
- - ПЕРЕДАЧА ОПЕРАТОРУ -
Передачи оператору не было — сценарий в рамках агента.
-
-
- ); -} - -function PanelTitle({ children }: { children: string }) { - return
{children}
; -} - -function ToolCall({ meta, name, time }: { meta: string; name: string; time: string }) { - return ( -
- -
{name}{meta}
- {time} -
- ); -} diff --git a/apps/internal-ui/src/features/ai/test-chat/TestChatSidebar.tsx b/apps/internal-ui/src/features/ai/test-chat/TestChatSidebar.tsx deleted file mode 100644 index e0f698f..0000000 --- a/apps/internal-ui/src/features/ai/test-chat/TestChatSidebar.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Icon } from "../../../shared/icons"; -import { testChannels, testScenarios, type TestChannel } from "./model"; - -export function TestChatSidebar({ - channel, - scenario, - setChannel, - setScenario, -}: { - channel: TestChannel; - scenario: string; - setChannel: (channel: TestChannel) => void; - setScenario: (scenario: string) => void; -}) { - return ( -
-
-
ЧЕРНОВИК ВЕРСИИ
- -
- - FirePage Sales · gpt-4o-mini · temp 0.4 -
- -
КАНАЛ СИМУЛЯЦИИ
-
- {testChannels.map((item) => ( - - ))} -
- -
ГОТОВЫЕ СЦЕНАРИИ
-
- {testScenarios.map((item) => ( - - ))} -
-
-
- -
-
- ); -} diff --git a/apps/internal-ui/src/features/ai/test-chat/TestConversation.tsx b/apps/internal-ui/src/features/ai/test-chat/TestConversation.tsx deleted file mode 100644 index d4cac3a..0000000 --- a/apps/internal-ui/src/features/ai/test-chat/TestConversation.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useLayoutEffect, useRef, useState, type ChangeEvent, type ReactNode } from "react"; - -import { Icon } from "../../../shared/icons"; -import type { TestChannel } from "./model"; - -const channelLabel: Record = { - MAX: "MAX", - TG: "Telegram", - WEB: "Web Chat", -}; - -export function TestConversation({ channel }: { channel: TestChannel }) { - const [message, setMessage] = useState(""); - const textareaRef = useRef(null); - - const resizeMessageInput = () => { - const textarea = textareaRef.current; - if (!textarea) return; - const styles = window.getComputedStyle(textarea); - const lineHeight = Number.parseFloat(styles.lineHeight); - const paddingTop = Number.parseFloat(styles.paddingTop); - const paddingBottom = Number.parseFloat(styles.paddingBottom); - const maxHeight = lineHeight * 12 + paddingTop + paddingBottom; - - textarea.style.height = "auto"; - const nextHeight = Math.min(textarea.scrollHeight, maxHeight); - textarea.style.height = `${nextHeight}px`; - textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden"; - }; - - useLayoutEffect(() => { - resizeMessageInput(); - }, [message]); - - const handleMessageChange = (event: ChangeEvent) => { - setMessage(event.target.value); - }; - - return ( -
-
-
- -
- Test conversation - Симуляция клиента · сообщений: 4 -
-
- Не создаёт Contact, Order или Payment -
- -
-
Сценарий: «Покупка коробки» · канал {channelLabel[channel]}
- - - Здравствуйте! FirePage — это готовый нишевой сайт под ключ. Коробка стоит ₽4 900 единоразово. Дополнительно можно подключить годовую поддержку за ₽1 470. Под визитку отлично подходит — оформить? - - - - Отлично! Подготовил оформление коробки FirePage за ₽4 900. Вызываю оформление покупки — в реальном диалоге клиент получил бы ссылку на оплату. - -
- -
-
- - -
-
-
- - - -
-

Диагностика

Последний ответ AI
-
- - -
-
Tokens
1 284
in 940 · out 344
-
Cost
₽0,42
latency 3,1с
-
- - -
ИСПОЛЬЗОВАННЫЕ CHUNKS
-
- -
-
{{ c.source }}{{ c.score }}
-
{{ c.text }}
-
-
-
- - -
ВЫЗВАННЫЕ TOOLS
-
-
get_offers
product=firepage → 2 offers
120мс
-
create_checkout
offer=OFR-FP-BOX · ₽4 900 (симуляция)
88мс
-
- - -
HANDOFF
-
Передачи оператору не было — сценарий в рамках агента.
- -
-
- -
-
-
- - - -