Merge branch 'fix/ai-limit-units' into main

This commit is contained in:
Andrey committed 2026-07-10 14:35:22 +03:00
commit fdabc3588a
34 files changed
+147 -1113

No files matched your search

+4 -2
View File
@@ -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")
+15 -1
View File
@@ -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
+3 -2
View File
@@ -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,
)
@@ -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
@@ -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"])
@@ -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({
<td className="numeric"><span className="product-empty-value">—</span></td>
<td className="row-actions">
<div className="ai-row-actions">
<Button variant="secondary" className="ai-test-chat-btn" icon="message" iconSize={14} onClick={() => setRoute("aiTestChat")}>Тест-чат</Button>
<Dropdown
menu={{ items: menuItems }}
open={menuId === agent.id}
@@ -1,14 +1,13 @@
import { useState } from "react";
import { api } from "../../api/client";
import type { RouteKey } from "../../types";
import { PageHeader } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { AgentsKpiStrip } from "./AgentsKpiStrip";
import { AgentsTable } from "./AgentsTable";
import type { AiAgent, AiRelease } from "./model";
export function AiAgentsPage({ agents, releases, reload, setRoute, openAgentCreate, openAgent, openRelease }: { agents: AiAgent[]; releases: AiRelease[]; reload: () => 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<number | null>(null);
async function toggleActive(agent: AiAgent) {
@@ -22,7 +21,7 @@ export function AiAgentsPage({ agents, releases, reload, setRoute, openAgentCrea
<div className="ai-page">
<PageHeader title="AI-агенты" text="Sales-агенты продуктов · один агент на продукт" action={<Button variant="primary" icon="plus" onClick={() => openAgentCreate(null)}>Создать агента</Button>} />
<AgentsKpiStrip agents={agents} />
<AgentsTable agents={agents} releases={releases} menuId={menuId} setMenuId={setMenuId} toggleActive={toggleActive} setRoute={setRoute} openAgent={openAgent} openRelease={openRelease} />
<AgentsTable agents={agents} releases={releases} menuId={menuId} setMenuId={setMenuId} toggleActive={toggleActive} openAgent={openAgent} openRelease={openRelease} />
</div>
);
}
@@ -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 },
];
@@ -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, unknown>): 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<string[]>(Array.isArray(agent.allowedTools) ? (agent.allowedTools as string[]) : []);
const [limits, setLimits] = useState<Record<string, number>>({ ...(agent.limits as Record<string, number>) });
const [budget, setBudget] = useState(budgetFromCents(agent.limits as Record<string, unknown>));
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<string, number> = {};
for (const [key, value] of Object.entries(limits)) {
if (Number.isFinite(value)) cleanLimits[key] = value;
}
const dollars = parseFloat(budget.trim().replace(",", "."));
const limits: Record<string, number> = 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
<div className="ai-edit-limits">
<span className="ai-edit-label">Лимиты</span>
<div className="ai-edit-limit-grid">
{LIMIT_FIELDS.map(([key, label]) => (
<label key={key} className="ai-edit-limit-field">
<span>{label}</span>
<input
type="text"
inputMode="numeric"
className={isNumber(String(limits[key] ?? "")) ? "" : "invalid"}
value={limits[key] === undefined || Number.isNaN(limits[key]) ? "" : String(limits[key])}
onChange={(event) => setLimit(key, event.target.value)}
/>
</label>
))}
<label className="ai-edit-limit-field">
<span>Бюджет в день, $</span>
<input
type="text"
inputMode="decimal"
className={isBudget(budget) ? "" : "invalid"}
value={budget}
placeholder="0.00"
onChange={(event) => setBudget(event.target.value)}
/>
</label>
</div>
</div>
{error && <div className="integration-form-error">{error}</div>}
@@ -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({
</div>
</div>
<div className="ai-agent-header-actions">
<Button variant="secondary" icon="message" onClick={() => setRoute("aiTestChat")}>Тест-чат</Button>
<Button variant="primary" icon="plus" onClick={createRelease}>Создать черновик версии</Button>
</div>
</section>
@@ -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<AgentTab>("overview");
const [editOpen, setEditOpen] = useState(false);
@@ -50,7 +49,7 @@ export function AiAgentDetailPage({ agentId, setRoute, openRelease, onAgentLoade
return (
<div className="ai-agent-page">
<AiAgentDetailHeader agent={agent} releases={releases} setRoute={setRoute} openRelease={openRelease} createRelease={createRelease} onEditChannel={() => setChannelEdit(true)} />
<AiAgentDetailHeader agent={agent} releases={releases} openRelease={openRelease} createRelease={createRelease} onEditChannel={() => setChannelEdit(true)} />
<UnderlineTabs className="ai-agent-tabs" items={agentTabs} value={tab} onChange={setTab} />
{(tab === "instructions" || tab === "knowledge") && (
<div className="ai-notice ai-golive-notice">
@@ -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, unknown>): 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 })} / день`;
}
@@ -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({
</div>
<div className="release-actions">
<Button variant="secondary" icon="expand">Сравнить с опубликованной</Button>
<Button variant="secondary" icon="message" onClick={() => setRoute("aiTestChat")}>Тест-чат</Button>
<Button variant="primary" icon="send" disabled={!canPublish} onClick={startPublish}>
{release.status === "PUBLISHED" ? "Опубликована" : "Опубликовать"}
</Button>
@@ -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 (
<div className="ai-release-page">
<ProductAIReleaseHeader canPublish={canPublish} checks={checks} release={release} setRoute={setRoute} startPublish={() => setModalOpen(true)} />
<ProductAIReleaseHeader canPublish={canPublish} checks={checks} release={release} startPublish={() => setModalOpen(true)} />
<div className="ai-release-body">
<ReleaseValidationBanner checks={checks} runChecks={reload} />
<div className="release-layout">
@@ -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 (
<div className="release-composition">
@@ -86,15 +86,13 @@ export function ReleaseComposition({ knowledge, prompts, published, release }: {
)}
</ReleaseCard>
<ReleaseCard title="Лимиты">
{limitRows.length === 0 ? (
{budget === "—" ? (
<EmptyRow compact />
) : (
limitRows.map(([key, value]) => (
<div className="release-limit-line" key={key}>
<span>{limitLabel(key)}</span>
<b>{formatParam(value)}</b>
</div>
))
<div className="release-limit-line">
<span>Бюджет в день</span>
<b>{budget}</b>
</div>
)}
</ReleaseCard>
</div>
@@ -14,7 +14,7 @@ function buildRelease(overrides: Partial<AiReleaseFull> = {}): AiReleaseFull {
model: "anthropic/claude-sonnet-4.6",
modelParams: {},
allowedTools: [{ name: "handoff" }],
limits: { maxMessagesPerDialog: 12 },
limits: { dailyCostUsd: 500 },
notes: "",
retrievalIndexVersion: "",
knowledgeVersions: [{ document: "overview", version: 1 }],
@@ -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<string, string> = {
dailyBudgetRub: "Бюджет в день",
dailyCostMicros: "Бюджет в день",
dailyDialogs: "Диалогов в день",
maxMessagesPerDialog: "Макс. сообщений / диалог",
};
return labels[key] ?? key;
}
@@ -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;
@@ -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;
@@ -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<TestChannel>("MAX");
const [scenario, setScenario] = useState("box");
return (
<div className="ai-test-chat-page">
<TestChatSidebar channel={channel} scenario={scenario} setChannel={setChannel} setScenario={setScenario} />
<TestConversation channel={channel} />
<TestChatDiagnostics />
</div>
);
}
@@ -1,52 +0,0 @@
import { Icon } from "../../../shared/icons";
import { diagnosticChunks } from "./model";
export function TestChatDiagnostics() {
return (
<section className="test-diagnostics">
<div className="test-diagnostics-head">
<h3>Диагностика</h3>
<span>Последний ответ AI</span>
</div>
<div className="test-diagnostics-scroll">
<div className="test-cost-grid">
<div><span>Tokens</span><strong>1 284</strong><small>in 940 · out 344</small></div>
<div><span>Cost</span><strong>₽0,42</strong><small>latency 3,1с</small></div>
</div>
<PanelTitle>ИСПОЛЬЗОВАННЫЕ ФРАГМЕНТЫ</PanelTitle>
<div className="test-diagnostic-list">
{diagnosticChunks.map((chunk) => (
<div className="test-chunk" key={chunk.source}>
<div><strong>{chunk.source}</strong><b>{chunk.score}</b></div>
<p>{chunk.text}</p>
</div>
))}
</div>
<PanelTitle>ВЫЗВАННЫЕ ИНСТРУМЕНТЫ</PanelTitle>
<div className="test-diagnostic-list">
<ToolCall name="get_offers" meta="product=firepage → 2 предложения" time="120мс" />
<ToolCall name="create_checkout" meta="offer=OFR-FP-BOX · ₽4 900 (симуляция)" time="88мс" />
</div>
<PanelTitle>ПЕРЕДАЧА ОПЕРАТОРУ</PanelTitle>
<div className="test-handoff"><Icon name="check" size={16} />Передачи оператору не было — сценарий в рамках агента.</div>
</div>
</section>
);
}
function PanelTitle({ children }: { children: string }) {
return <div className="test-panel-title">{children}</div>;
}
function ToolCall({ meta, name, time }: { meta: string; name: string; time: string }) {
return (
<div className="test-tool-call">
<Icon name="check" size={15} />
<div><strong>{name}</strong><span>{meta}</span></div>
<small>{time}</small>
</div>
);
}
@@ -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 (
<section className="test-chat-sidebar">
<div className="test-chat-sidebar-scroll">
<div className="test-chat-label">ЧЕРНОВИК ВЕРСИИ</div>
<label className="test-release-select">
<select defaultValue="REL-FP-v5">
<option value="REL-FP-v5">REL-FP-v5 · Черновик версии</option>
<option value="REL-FP-v4">REL-FP-v4 · Опубликована</option>
</select>
<Icon name="chevron" size={15} />
</label>
<div className="test-agent-chip">
<Icon name="robot" size={15} />
<span>FirePage Sales · gpt-4o-mini · temp 0.4</span>
</div>
<div className="test-chat-label">КАНАЛ СИМУЛЯЦИИ</div>
<div className="test-channel-row">
{testChannels.map((item) => (
<button className={channel === item.key ? "active" : ""} type="button" onClick={() => setChannel(item.key)} key={item.key}>
<span style={{ background: item.dot }} />
{item.label}
</button>
))}
</div>
<div className="test-chat-label">ГОТОВЫЕ СЦЕНАРИИ</div>
<div className="test-scenarios">
{testScenarios.map((item) => (
<button className={scenario === item.id ? "active" : ""} type="button" onClick={() => setScenario(item.id)} key={item.id}>
<strong>{item.title}</strong>
<span>{item.desc}</span>
</button>
))}
</div>
</div>
<div className="test-chat-sidebar-footer">
<button type="button"><Icon name="trash" size={15} />Очистить тестовую сессию</button>
</div>
</section>
);
}
@@ -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<TestChannel, string> = {
MAX: "MAX",
TG: "Telegram",
WEB: "Web Chat",
};
export function TestConversation({ channel }: { channel: TestChannel }) {
const [message, setMessage] = useState("");
const textareaRef = useRef<HTMLTextAreaElement | null>(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<HTMLTextAreaElement>) => {
setMessage(event.target.value);
};
return (
<section className="test-conversation">
<div className="test-conversation-head">
<div className="test-conversation-title">
<span><Icon name="robot" size={18} /></span>
<div>
<strong>Test conversation</strong>
<small>Симуляция клиента · сообщений: 4</small>
</div>
</div>
<em><Icon name="eyeOff" size={13} />Не создаёт Contact, Order или Payment</em>
</div>
<div className="test-messages">
<div className="test-scenario-pill">Сценарий: «Покупка коробки» · канал {channelLabel[channel]}</div>
<ClientMessage text="Здравствуйте, интересует FirePage для сайта-визитки. Сколько стоит?" note="тестовый клиент" />
<AiMessage>
Здравствуйте! FirePage — это готовый нишевой сайт под ключ. Коробка стоит <b>₽4 900</b> единоразово. Дополнительно можно подключить годовую поддержку за ₽1 470. Под визитку отлично подходит — оформить?
</AiMessage>
<ClientMessage text="Да, давайте оформим коробку." />
<AiMessage tool="create_checkout">
Отлично! Подготовил оформление коробки FirePage за ₽4 900. Вызываю оформление покупки — в реальном диалоге клиент получил бы ссылку на оплату.
</AiMessage>
</div>
<div className="test-composer">
<div>
<textarea ref={textareaRef} rows={1} value={message} placeholder="Сообщение от лица тестового клиента…" onChange={handleMessageChange} />
<button type="button">Отправить<Icon name="send" size={15} /></button>
</div>
</div>
</section>
);
}
function ClientMessage({ note, text }: { note?: string; text: string }) {
return (
<div className="test-message client">
<span className="test-client-avatar">Т</span>
<div>
<p>{text}</p>
{note && <small>{note}</small>}
</div>
</div>
);
}
function AiMessage({ children, tool }: { children: ReactNode; tool?: string }) {
return (
<div className="test-message ai">
<span className="test-ai-avatar"><Icon name="robot" size={16} /></span>
<div>
<strong>AI · REL-FP-v5</strong>
<p>{children}</p>
{tool && <em>tool: {tool}</em>}
</div>
</div>
);
}
@@ -1,201 +0,0 @@
.test-conversation-head {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 11px 20px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.test-conversation-title {
display: flex;
align-items: center;
gap: 11px;
}
.test-conversation-title > span,
.test-ai-avatar {
display: flex;
align-items: center;
justify-content: center;
background: #f9f0ff;
border: 1px solid #efdbff;
color: #722ed1;
}
.test-conversation-title > span {
width: 34px;
height: 34px;
border-radius: 9px;
}
.test-conversation-title strong {
display: block;
color: #1f1f1f;
font-size: 14px;
}
.test-conversation-title small,
.test-conversation-head em {
color: #8c8c8c;
font-size: 11.5px;
font-style: normal;
}
.test-conversation-head em {
display: inline-flex;
align-items: center;
gap: 5px;
color: #bfbfbf;
}
.test-messages {
flex: 1;
overflow-y: auto;
padding: 18px 24px 8px;
}
.test-messages::before,
.test-messages::after {
display: table;
content: "";
}
.test-scenario-pill {
width: max-content;
margin: 0 auto 16px;
padding: 4px 12px;
color: #8c8c8c;
background: #eef0f2;
border-radius: 20px;
font-size: 11.5px;
}
.test-message {
display: flex;
gap: 10px;
max-width: 680px;
margin: 0 auto 14px;
}
.test-message.ai {
flex-direction: row-reverse;
}
.test-client-avatar,
.test-ai-avatar {
width: 30px;
height: 30px;
flex: none;
border-radius: 50%;
}
.test-client-avatar {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: #8c8c8c;
font-size: 11px;
font-weight: 600;
}
.test-message > div {
max-width: 78%;
}
.test-message p {
margin: 0;
padding: 10px 14px;
color: #262626;
background: #fff;
border: 1px solid #eee;
border-radius: 4px 14px 14px 14px;
font-size: 13.5px;
line-height: 1.5;
}
.test-message.ai p {
background: #f9f0ff;
border-color: #efdbff;
border-radius: 14px 4px 14px 14px;
}
.test-message small {
display: block;
margin: 4px 0 0 4px;
color: #bfbfbf;
font-size: 11px;
}
.test-message.ai strong {
display: block;
margin-bottom: 4px;
color: #722ed1;
font-size: 11px;
text-align: right;
}
.test-message.ai em {
display: inline-flex;
float: right;
margin: 6px 4px 0 0;
padding: 2px 8px;
color: #0958d9;
background: #e6f4ff;
border-radius: 5px;
font-family: "SF Mono", ui-monospace, Menlo, monospace;
font-size: 10.5px;
font-style: normal;
font-weight: 600;
}
.test-composer {
flex: none;
padding: 14px 24px 16px;
background: #fff;
border-top: 1px solid #f0f0f0;
}
.test-composer > div {
max-width: 680px;
display: flex;
align-items: flex-end;
gap: 10px;
margin: 0 auto;
padding: 8px 8px 8px 14px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 12px;
}
.test-composer textarea {
flex: 1;
min-height: 38px;
min-width: 0;
padding: 9px 0;
overflow-y: hidden;
resize: none;
border: 0;
outline: 0;
color: #262626;
font: inherit;
font-size: 13.5px;
line-height: 1.5;
}
.test-composer button {
height: 38px;
display: inline-flex;
align-items: center;
gap: 7px;
flex: none;
padding: 0 16px;
color: #fff;
background: #1677ff;
border: 0;
border-radius: 9px;
font-size: 13px;
font-weight: 600;
}
@@ -1,155 +0,0 @@
.test-diagnostics {
width: 340px;
flex: none;
display: flex;
flex-direction: column;
min-height: 0;
background: #fff;
border-left: 1px solid #f0f0f0;
}
.test-diagnostics-head {
flex: none;
padding: 15px 18px 12px;
border-bottom: 1px solid #f0f0f0;
}
.test-diagnostics-head h3 {
margin: 0;
color: #1f1f1f;
font-size: 14px;
}
.test-diagnostics-head span {
display: block;
margin-top: 2px;
color: #bfbfbf;
font-size: 11.5px;
}
.test-cost-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
margin-bottom: 18px;
overflow: hidden;
background: #f0f0f0;
border: 1px solid #f0f0f0;
border-radius: 9px;
}
.test-cost-grid div {
padding: 12px 14px;
background: #fff;
}
.test-cost-grid span,
.test-cost-grid small {
display: block;
color: #8c8c8c;
font-size: 11px;
}
.test-cost-grid strong {
display: block;
margin-top: 3px;
color: #262626;
font-size: 17px;
}
.test-cost-grid small {
margin-top: 1px;
color: #bfbfbf;
font-size: 10.5px;
}
.test-diagnostic-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.test-chunk,
.test-tool-call,
.test-handoff {
border: 1px solid #f0f0f0;
border-radius: 9px;
}
.test-chunk {
padding: 10px 12px;
}
.test-chunk div,
.test-tool-call {
display: flex;
align-items: center;
gap: 10px;
}
.test-chunk div {
justify-content: space-between;
gap: 8px;
}
.test-chunk strong,
.test-tool-call strong {
color: #262626;
font-size: 12px;
}
.test-chunk b {
padding: 1px 6px;
color: #389e0d;
background: #f6ffed;
border-radius: 5px;
font-size: 10.5px;
}
.test-chunk p,
.test-tool-call span,
.test-handoff {
color: #8c8c8c;
font-size: 11.5px;
line-height: 1.45;
}
.test-chunk p {
margin: 4px 0 0;
}
.test-tool-call {
padding: 10px 12px;
}
.test-tool-call svg,
.test-handoff svg {
flex: none;
color: #389e0d;
}
.test-tool-call div {
flex: 1;
min-width: 0;
}
.test-tool-call strong,
.test-tool-call span {
display: block;
font-family: "SF Mono", ui-monospace, Menlo, monospace;
}
.test-tool-call small {
flex: none;
color: #bfbfbf;
font-size: 10.5px;
}
.test-handoff {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 13px;
background: #fafafa;
}
@@ -1,18 +0,0 @@
export type TestChannel = "MAX" | "TG" | "WEB";
export const testChannels: Array<{ key: TestChannel; label: string; dot: string }> = [
{ key: "MAX", label: "MAX", dot: "#6b5be0" },
{ key: "TG", label: "TG", dot: "#2f8fd0" },
{ key: "WEB", label: "Web", dot: "#0f9b8e" },
];
export const testScenarios = [
{ id: "box", title: "Покупка коробки", desc: "Клиент хочет купить FirePage под визитку." },
{ id: "support", title: "Вопрос про поддержку", desc: "Уточняет условия годовой поддержки." },
{ id: "handoff", title: "Запрос человека", desc: "Просит соединить с менеджером." },
];
export const diagnosticChunks = [
{ source: "Что входит в коробку FirePage", score: "0.91", text: "Коробка FirePage — готовый нишевой сайт под ключ, единоразовая оплата ₽4 900." },
{ source: "Условия годовой поддержки", score: "0.84", text: "Годовая поддержка — 30% от цены коробки, ₽1 470 в год, продление по желанию." },
];
@@ -1,167 +0,0 @@
.ai-test-chat-page {
height: calc(100vh - 56px);
display: flex;
overflow: hidden;
}
.test-chat-sidebar {
width: 300px;
flex: none;
display: flex;
flex-direction: column;
min-height: 0;
background: #fff;
border-right: 1px solid #f0f0f0;
}
.test-chat-sidebar-scroll,
.test-diagnostics-scroll {
flex: 1;
overflow-y: auto;
padding: 18px;
}
.test-chat-label,
.test-panel-title {
margin-bottom: 10px;
color: #bfbfbf;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
}
.test-release-select {
position: relative;
display: block;
margin-bottom: 8px;
}
.test-release-select select {
width: 100%;
height: 40px;
padding: 0 32px 0 12px;
color: #262626;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 9px;
appearance: none;
font: inherit;
font-size: 13px;
font-weight: 500;
}
.test-release-select svg {
position: absolute;
top: 13px;
right: 11px;
color: #8c8c8c;
pointer-events: none;
}
.test-agent-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
margin-bottom: 18px;
color: #722ed1;
background: #f9f0ff;
border: 1px solid #efdbff;
border-radius: 8px;
font-size: 11.5px;
}
.test-channel-row {
display: flex;
gap: 6px;
margin-bottom: 18px;
}
.test-channel-row button {
height: 32px;
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #595959;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
}
.test-channel-row button.active {
color: #0958d9;
background: #e6f4ff;
border-color: #1677ff;
font-weight: 600;
}
.test-channel-row span {
width: 6px;
height: 6px;
border-radius: 50%;
}
.test-scenarios {
display: flex;
flex-direction: column;
gap: 8px;
}
.test-scenarios button {
padding: 11px 13px;
text-align: left;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 10px;
}
.test-scenarios button.active {
background: #f7fbff;
border-color: #1677ff;
}
.test-scenarios strong {
display: block;
color: #262626;
font-size: 13px;
}
.test-scenarios span {
display: block;
margin-top: 2px;
color: #8c8c8c;
font-size: 11.5px;
line-height: 1.4;
}
.test-chat-sidebar-footer {
padding: 12px 18px;
border-top: 1px solid #f5f5f5;
}
.test-chat-sidebar-footer button {
width: 100%;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #595959;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 9px;
font-size: 13px;
font-weight: 500;
}
.test-conversation {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
background: #f7f8fa;
}
+1 -1
View File
@@ -93,7 +93,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId,
const isSalesOrders = route === "salesOrderDetail" || route === "salesOrders";
// Подменю AI показываем только там, где оно есть в baseline.
const isAiSection = route === "aiAgents" || route === "aiUsage";
const isAiFullWidth = route === "aiAgentCreate" || route === "aiRelease" || route === "aiTestChat";
const isAiFullWidth = route === "aiAgentCreate" || route === "aiRelease";
// OPERATOR работает в пространстве своего отдела (SPEC-HUB-0004 §9 + §0010 §10):
// sales operator → sales-sidebar, support operator → support-sidebar.
const showSalesSidebar = isSalesWorkspace || (user.role === "OPERATOR" && user.department !== "support" && !isSupportWorkspace);
@@ -2,7 +2,6 @@ import { AiAgentsPage } from "../features/ai/AiAgentsPage";
import { AiAgentCreatePage } from "../features/ai/create/AiAgentCreatePage";
import { AiAgentDetailPage } from "../features/ai/detail/AiAgentDetailPage";
import { ProductAIReleasePage } from "../features/ai/release/ProductAIReleasePage";
import { AiTestChatPage } from "../features/ai/test-chat/AiTestChatPage";
import { CommandCenter } from "../features/command/CommandCenter";
import { IntegrationsPage } from "../features/integrations/IntegrationsPage";
import { DepartmentsPage } from "../features/departments/DepartmentsPage";
@@ -40,11 +39,10 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct
{route === "salesDialogs" && <SalesDialogsPage initialConversationId={selectedConversationId} />}
{route === "salesOrderDetail" && <SalesOrderDetailPage orderId={selectedOrderId} setRoute={setRoute} />}
{route === "salesOrders" && <SalesOrdersPage setRoute={setRoute} openOrder={openOrder} />}
{route === "aiAgents" && <AiAgentsPage agents={data.agents} releases={data.releases} reload={reload} setRoute={setRoute} openAgentCreate={openAgentCreate} openAgent={openAgent} openRelease={openRelease} />}
{route === "aiAgents" && <AiAgentsPage agents={data.agents} releases={data.releases} reload={reload} openAgentCreate={openAgentCreate} openAgent={openAgent} openRelease={openRelease} />}
{route === "aiAgentCreate" && <AiAgentCreatePage selectedProductCode={selectedProductCode} reload={reload} setRoute={setRoute} openAgent={openAgent} openRelease={openRelease} />}
{route === "aiAgentDetail" && <AiAgentDetailPage agentId={selectedAgentId} setRoute={setRoute} openRelease={openRelease} onAgentLoaded={onAgentLoaded} />}
{route === "aiRelease" && <ProductAIReleasePage releaseId={selectedReleaseId} setRoute={setRoute} onReleaseLoaded={onAgentLoaded} />}
{route === "aiTestChat" && <AiTestChatPage />}
{route === "aiAgentDetail" && <AiAgentDetailPage agentId={selectedAgentId} openRelease={openRelease} onAgentLoaded={onAgentLoaded} />}
{route === "aiRelease" && <ProductAIReleasePage releaseId={selectedReleaseId} onReleaseLoaded={onAgentLoaded} />}
{route === "integrations" && <IntegrationsPage />}
</>
);
-2
View File
@@ -53,7 +53,6 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
const id = Number(path.split("/")[3]);
return Number.isInteger(id) && id > 0 ? { ...base, route: "aiRelease", releaseId: id } : { route: "aiAgents", ...base };
}
if (path === "/ai/test-chat") return { route: "aiTestChat", ...base };
if (path === "/ai/usage") return { route: "aiUsage", ...base };
if (path === "/integrations") return { route: "integrations", ...base };
if (path === "/profile") return { route: "profile", ...base };
@@ -77,7 +76,6 @@ export function pathFromRoute(route: RouteKey, entityId: number | null = null, p
if (route === "productDetail") return entityId ? `/products/${entityId}` : "/products";
if (route === "aiAgents") return "/ai/agents";
if (route === "aiAgentCreate") return productCode ? `/ai/agents/new?product=${encodeURIComponent(productCode)}` : "/ai/agents/new";
if (route === "aiTestChat") return "/ai/test-chat";
if (route === "aiUsage") return "/ai/usage";
if (route === "aiAgentDetail") return entityId ? `/ai/agents/${entityId}` : "/ai/agents";
if (route === "aiRelease") return entityId ? `/ai/releases/${entityId}` : "/ai/agents";
-1
View File
@@ -20,7 +20,6 @@ export const routes: Record<RouteKey, string> = {
aiAgentCreate: "Создание агента",
aiAgentDetail: "Карточка агента",
aiRelease: "Версия продукта",
aiTestChat: "Тестовый чат",
aiUsage: "Использование AI",
integrations: "Интеграции",
};
-3
View File
@@ -24,9 +24,6 @@
@import "./features/ai/release/composition.css";
@import "./features/ai/release/side.css";
@import "./features/ai/release/modal.css";
@import "./features/ai/test-chat/styles.css";
@import "./features/ai/test-chat/conversation.css";
@import "./features/ai/test-chat/diagnostics.css";
@import "./features/integrations/styles.css";
@import "./features/notifications/styles.css";
@import "./shared/state.css";
+1 -1
View File
@@ -92,7 +92,7 @@ export type ProductOffer = {
prices: ProductPrice[];
};
export type RouteKey = "command" | "departments" | "employeeDetail" | "employees" | "productDetail" | "products" | "profile" | "salesClientDetail" | "salesClients" | "salesDialogs" | "salesOrderDetail" | "salesOrders" | "salesOverview" | "supportOverview" | "supportDialogs" | "aiAgents" | "aiAgentCreate" | "aiAgentDetail" | "aiRelease" | "aiTestChat" | "aiUsage" | "integrations";
export type RouteKey = "command" | "departments" | "employeeDetail" | "employees" | "productDetail" | "products" | "profile" | "salesClientDetail" | "salesClients" | "salesDialogs" | "salesOrderDetail" | "salesOrders" | "salesOverview" | "supportOverview" | "supportDialogs" | "aiAgents" | "aiAgentCreate" | "aiAgentDetail" | "aiRelease" | "aiUsage" | "integrations";
export type AppData = {
employees: Employee[];
@@ -1,218 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<style>
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
::-webkit-scrollbar{width:8px;height:8px;}
::-webkit-scrollbar-thumb{background:#dcdcdc;border-radius:8px;border:2px solid transparent;background-clip:content-box;}
::-webkit-scrollbar-thumb:hover{background:#bfbfbf;background-clip:content-box;}
</style>
</helmet>
<div style="display:flex;height:100vh;overflow:hidden;background:#f0f2f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#262626;-webkit-font-smoothing:antialiased;min-width:1180px;">
<!-- ============ SIDEBAR (уровень компании) ============ -->
<aside style="width:248px;flex:none;background:#ffffff;border-right:1px solid #f0f0f0;display:flex;flex-direction:column;height:100vh;">
<div style="height:56px;flex:none;display:flex;align-items:center;gap:11px;padding:0 20px;border-bottom:1px solid #f5f5f5;">
<div style="width:30px;height:30px;border-radius:8px;background:#1677ff;display:flex;align-items:center;justify-content:center;flex:none;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l2 6 4-14 2 8h6"/></svg></div>
<div style="display:flex;flex-direction:column;line-height:1.15;"><span style="font-size:14px;font-weight:600;color:#1f1f1f;letter-spacing:-0.01em;">Edevs Hub</span><span style="font-size:11px;color:#8c8c8c;">Уровень компании</span></div>
</div>
<nav style="flex:1;overflow-y:auto;padding:12px 12px 8px;">
<a href="Командный центр.dc.html" style="display:flex;align-items:center;gap:11px;padding:9px 12px;border-radius:8px;color:#595959;font-size:13.5px;font-weight:500;text-decoration:none;margin-bottom:2px;" style-hover="background:#f5f5f5;color:#262626;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.4"/><rect x="14" y="3" width="7" height="5" rx="1.4"/><rect x="14" y="12" width="7" height="9" rx="1.4"/><rect x="3" y="16" width="7" height="5" rx="1.4"/></svg>Командный центр</a>
<div style="font-size:11px;font-weight:600;letter-spacing:0.06em;color:#bfbfbf;padding:14px 12px 6px;">КОМПАНИЯ</div>
<a href="Отделы.dc.html" style="display:flex;align-items:center;gap:11px;padding:9px 12px;border-radius:8px;color:#595959;font-size:13.5px;font-weight:500;text-decoration:none;margin-bottom:2px;" style-hover="background:#f5f5f5;color:#262626;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16"/><path d="M9 8h1.5M13.5 8H15M9 12h1.5M13.5 12H15M9 16h1.5M13.5 16H15"/></svg>Отделы</a>
<a href="Сотрудники.dc.html" style="display:flex;align-items:center;gap:11px;padding:9px 12px;border-radius:8px;color:#595959;font-size:13.5px;font-weight:500;text-decoration:none;margin-bottom:2px;" style-hover="background:#f5f5f5;color:#262626;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Сотрудники</a>
<a href="Продукты.dc.html" style="display:flex;align-items:center;gap:11px;padding:9px 12px;border-radius:8px;color:#595959;font-size:13.5px;font-weight:500;text-decoration:none;margin-bottom:2px;" style-hover="background:#f5f5f5;color:#262626;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>Продукты</a>
<div style="font-size:11px;font-weight:600;letter-spacing:0.06em;color:#bfbfbf;padding:14px 12px 6px;">ПЛАТФОРМА</div>
<a href="AI-агенты.dc.html" style="display:flex;align-items:center;gap:11px;padding:9px 12px;border-radius:8px;background:#e6f4ff;color:#0958d9;font-size:13.5px;font-weight:600;text-decoration:none;margin-bottom:2px;position:relative;"><span style="position:absolute;left:0;top:8px;bottom:8px;width:3px;border-radius:0 3px 3px 0;background:#1677ff;"></span><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2.5"/><path d="M12 8V4.5"/><circle cx="12" cy="3.5" r="1.2"/><circle cx="9" cy="13.5" r="1"/><circle cx="15" cy="13.5" r="1"/><path d="M2 13.5h2M20 13.5h2"/></svg>AI</a>
<a href="#" style="display:flex;align-items:center;gap:11px;padding:9px 12px;border-radius:8px;color:#595959;font-size:13.5px;font-weight:500;text-decoration:none;margin-bottom:2px;" style-hover="background:#f5f5f5;color:#262626;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 8V2.5M15 8V2.5M18 8v5.5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/><path d="M12 17.5V22"/></svg>Интеграции</a>
<div style="height:1px;background:#f5f5f5;margin:14px 12px 10px;"></div>
<a href="#" style="display:flex;align-items:center;gap:11px;padding:9px 12px;border-radius:8px;color:#595959;font-size:13.5px;font-weight:500;text-decoration:none;margin-bottom:2px;" style-hover="background:#f5f5f5;color:#262626;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="6" x2="9" y2="6"/><line x1="3" y1="6" x2="5" y2="6"/><circle cx="7" cy="6" r="2"/><line x1="21" y1="12" x2="13" y2="12"/><line x1="3" y1="12" x2="9" y2="12"/><circle cx="11" cy="12" r="2"/><line x1="21" y1="18" x2="15" y2="18"/><line x1="3" y1="18" x2="11" y2="18"/><circle cx="13" cy="18" r="2"/></svg>Настройки</a>
</nav>
<div style="flex:none;border-top:1px solid #f5f5f5;padding:10px 12px;">
<a href="#" style="display:flex;align-items:center;gap:11px;padding:8px 10px;border-radius:8px;text-decoration:none;" style-hover="background:#f5f5f5;"><div style="width:32px;height:32px;border-radius:50%;background:#1677ff;color:#fff;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;flex:none;">ИП</div><div style="display:flex;flex-direction:column;line-height:1.2;"><span style="font-size:13px;font-weight:600;color:#262626;">Иван Петров</span><span style="font-size:11px;color:#8c8c8c;">OWNER</span></div></a>
</div>
</aside>
<!-- ============ MAIN ============ -->
<div style="flex:1;min-width:0;display:flex;flex-direction:column;height:100vh;">
<header style="height:56px;flex:none;background:#ffffff;border-bottom:1px solid #f0f0f0;display:flex;align-items:center;justify-content:space-between;padding:0 24px;">
<div style="display:flex;align-items:center;gap:8px;font-size:13px;color:#8c8c8c;"><span style="font-weight:500;color:#595959;">Edevs</span><span style="color:#d9d9d9;">/</span><a href="AI-агенты.dc.html" style="font-weight:500;color:#595959;text-decoration:none;" style-hover="color:#1677ff;">AI</a><span style="color:#d9d9d9;">/</span><span style="font-weight:600;color:#262626;">Тестовый чат</span></div>
<div style="display:flex;align-items:center;gap:12px;">
<span style="display:inline-flex;align-items:center;gap:6px;padding:4px 11px;border-radius:6px;background:#fff7e6;border:1px solid #ffe58f;font-size:11.5px;font-weight:600;color:#d48806;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76Z"/></svg>Тестовая песочница</span>
<div style="width:1px;height:24px;background:#f0f0f0;"></div>
<div style="width:28px;height:28px;border-radius:50%;background:#1677ff;color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;">ИП</div>
</div>
</header>
<!-- 3 panels -->
<div style="flex:1;min-height:0;display:flex;overflow:hidden;">
<!-- ---- LEFT: scenarios / settings ---- -->
<section style="width:300px;flex:none;background:#fff;border-right:1px solid #f0f0f0;display:flex;flex-direction:column;min-height:0;">
<div style="flex:1;overflow-y:auto;padding:18px;">
<div style="font-size:11px;font-weight:600;letter-spacing:0.05em;color:#bfbfbf;margin-bottom:10px;">ЧЕРНОВИК ВЕРСИИ</div>
<div style="position:relative;margin-bottom:8px;">
<select style="width:100%;height:40px;padding:0 32px 0 12px;border:1px solid #e8e8e8;border-radius:9px;background:#fff;font-size:13px;color:#262626;font-family:inherit;cursor:pointer;appearance:none;-webkit-appearance:none;font-weight:500;"><option>REL-FP-v5 · Черновик версии</option><option>REL-FP-v4 · Опубликована</option></select>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#8c8c8c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:absolute;right:11px;top:13px;pointer-events:none;"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div style="display:flex;align-items:center;gap:8px;padding:9px 12px;border-radius:8px;background:#f9f0ff;border:1px solid #efdbff;margin-bottom:18px;">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#722ed1" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2.5"/><path d="M12 8V4.5"/><circle cx="12" cy="3.5" r="1.2"/></svg>
<span style="font-size:11.5px;color:#722ed1;">FirePage Sales · gpt-4o-mini · temp 0.4</span>
</div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.05em;color:#bfbfbf;margin-bottom:10px;">КАНАЛ СИМУЛЯЦИИ</div>
<div style="display:flex;gap:6px;margin-bottom:18px;">
<button onClick="{{ onChMax }}" style="{{ chMax }}"><span style="width:6px;height:6px;border-radius:50%;background:#6b5be0;"></span>MAX</button>
<button onClick="{{ onChTg }}" style="{{ chTg }}"><span style="width:6px;height:6px;border-radius:50%;background:#2f8fd0;"></span>TG</button>
<button onClick="{{ onChWeb }}" style="{{ chWeb }}"><span style="width:6px;height:6px;border-radius:50%;background:#0f9b8e;"></span>Web</button>
</div>
<div style="font-size:11px;font-weight:600;letter-spacing:0.05em;color:#bfbfbf;margin-bottom:10px;">ГОТОВЫЕ СЦЕНАРИИ</div>
<div style="display:flex;flex-direction:column;gap:8px;">
<sc-for list="{{ scenarios }}" as="s" hint-placeholder-count="3">
<button onClick="{{ s.onPick }}" style="{{ s.style }}">
<div style="font-size:13px;font-weight:600;color:#262626;">{{ s.title }}</div>
<div style="font-size:11.5px;color:#8c8c8c;margin-top:2px;line-height:1.4;">{{ s.desc }}</div>
</button>
</sc-for>
</div>
</div>
<div style="flex:none;border-top:1px solid #f5f5f5;padding:12px 18px;">
<button onClick="{{ onClear }}" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px;height:38px;border-radius:9px;border:1px solid #e8e8e8;background:#fff;color:#595959;font-size:13px;font-weight:500;cursor:pointer;" style-hover="border-color:#ff4d4f;color:#cf1322;background:#fff8f7;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>Очистить тестовую сессию</button>
</div>
</section>
<!-- ---- CENTER: test conversation ---- -->
<section style="flex:1;min-width:0;display:flex;flex-direction:column;background:#f7f8fa;min-height:0;">
<div style="flex:none;background:#fff;border-bottom:1px solid #f0f0f0;padding:11px 20px;display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:11px;">
<div style="width:34px;height:34px;border-radius:9px;background:#f9f0ff;display:flex;align-items:center;justify-content:center;flex:none;"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#722ed1" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2.5"/><path d="M12 8V4.5"/><circle cx="12" cy="3.5" r="1.2"/><circle cx="9" cy="13.5" r="1"/><circle cx="15" cy="13.5" r="1"/></svg></div>
<div><div style="font-size:14px;font-weight:700;color:#1f1f1f;">Test conversation</div><div style="font-size:11.5px;color:#8c8c8c;">Симуляция клиента · сообщений: 4</div></div>
</div>
<span style="font-size:11.5px;color:#bfbfbf;display:inline-flex;align-items:center;gap:5px;"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="#bfbfbf" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.5 13.5 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" y1="2" x2="22" y2="22"/></svg>Не создаёт Contact, Order или Payment</span>
</div>
<div style="flex:1;overflow-y:auto;padding:18px 24px 8px;">
<div style="max-width:680px;margin:0 auto;">
<div style="text-align:center;margin-bottom:16px;"><span style="display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:20px;background:#eef0f2;font-size:11.5px;color:#8c8c8c;">Сценарий: «Покупка коробки» · канал {{ channelLabel }}</span></div>
<!-- client -->
<div style="display:flex;gap:10px;margin-bottom:14px;"><div style="width:30px;height:30px;border-radius:50%;background:#8c8c8c;color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex:none;">Т</div><div style="max-width:78%;"><div style="background:#fff;border:1px solid #eee;border-radius:4px 14px 14px 14px;padding:10px 14px;font-size:13.5px;line-height:1.5;color:#262626;">Здравствуйте, интересует FirePage для сайта-визитки. Сколько стоит?</div><div style="font-size:11px;color:#bfbfbf;margin:4px 0 0 4px;">тестовый клиент</div></div></div>
<!-- AI -->
<div style="display:flex;flex-direction:row-reverse;gap:10px;margin-bottom:14px;"><div style="width:30px;height:30px;border-radius:50%;background:#f9f0ff;border:1px solid #efdbff;display:flex;align-items:center;justify-content:center;flex:none;"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#722ed1" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2.5"/><path d="M12 8V4.5"/><circle cx="12" cy="3.5" r="1.2"/></svg></div><div style="max-width:78%;"><div style="display:flex;align-items:center;gap:6px;justify-content:flex-end;margin-bottom:4px;"><span style="font-size:11px;font-weight:600;color:#722ed1;">AI · REL-FP-v5</span></div><div style="background:#f9f0ff;border:1px solid #efdbff;border-radius:14px 4px 14px 14px;padding:10px 14px;font-size:13.5px;line-height:1.5;color:#262626;">Здравствуйте! FirePage — это готовый нишевой сайт под ключ. Коробка стоит <b>₽4 900</b> единоразово. Дополнительно можно подключить годовую поддержку за ₽1 470. Под визитку отлично подходит — оформить?</div><div style="font-size:11px;color:#bfbfbf;margin:4px 4px 0 0;text-align:right;">debug ниже →</div></div></div>
<!-- client -->
<div style="display:flex;gap:10px;margin-bottom:14px;"><div style="width:30px;height:30px;border-radius:50%;background:#8c8c8c;color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex:none;">Т</div><div style="max-width:78%;"><div style="background:#fff;border:1px solid #eee;border-radius:4px 14px 14px 14px;padding:10px 14px;font-size:13.5px;line-height:1.5;color:#262626;">Да, давайте оформим коробку.</div></div></div>
<!-- AI with tool -->
<div style="display:flex;flex-direction:row-reverse;gap:10px;margin-bottom:8px;"><div style="width:30px;height:30px;border-radius:50%;background:#f9f0ff;border:1px solid #efdbff;display:flex;align-items:center;justify-content:center;flex:none;"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#722ed1" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2.5"/><path d="M12 8V4.5"/><circle cx="12" cy="3.5" r="1.2"/></svg></div><div style="max-width:78%;"><div style="background:#f9f0ff;border:1px solid #efdbff;border-radius:14px 4px 14px 14px;padding:10px 14px;font-size:13.5px;line-height:1.5;color:#262626;">Отлично! Подготовил оформление коробки FirePage за ₽4 900. Вызываю checkout — в реальном диалоге клиент получил бы ссылку на оплату.</div><div style="display:inline-flex;align-items:center;gap:5px;margin:6px 4px 0 0;float:right;clear:both;"><span style="padding:2px 8px;border-radius:5px;background:#e6f4ff;color:#0958d9;font-size:10.5px;font-weight:600;font-family:'SF Mono',ui-monospace,Menlo,monospace;">tool: create_checkout</span></div></div></div>
</div>
</div>
<!-- composer -->
<div style="flex:none;background:#fff;border-top:1px solid #f0f0f0;padding:14px 24px 16px;">
<div style="max-width:680px;margin:0 auto;">
<div style="display:flex;align-items:flex-end;gap:10px;border:1px solid #e8e8e8;border-radius:12px;padding:8px 8px 8px 14px;background:#fff;" style-focus="border-color:#1677ff;">
<textarea rows="1" placeholder="Сообщение от лица тестового клиента…" style="flex:1;border:none;outline:none;resize:none;font-size:13.5px;line-height:1.5;color:#262626;font-family:inherit;padding:7px 0;max-height:120px;"></textarea>
<button style="display:flex;align-items:center;gap:7px;height:38px;padding:0 16px;border-radius:9px;border:none;background:#1677ff;color:#fff;font-size:13px;font-weight:600;cursor:pointer;flex:none;" style-hover="background:#0958d9;">Отправить<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg></button>
</div>
</div>
</div>
</section>
<!-- ---- RIGHT: diagnostics ---- -->
<section style="width:340px;flex:none;background:#fff;border-left:1px solid #f0f0f0;display:flex;flex-direction:column;min-height:0;">
<div style="flex:none;padding:15px 18px 12px;border-bottom:1px solid #f0f0f0;"><h3 style="margin:0;font-size:14px;font-weight:700;color:#1f1f1f;">Диагностика</h3><div style="font-size:11.5px;color:#bfbfbf;margin-top:2px;">Последний ответ AI</div></div>
<div style="flex:1;overflow-y:auto;padding:16px 18px;">
<!-- tokens / cost -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1px;background:#f0f0f0;border:1px solid #f0f0f0;border-radius:9px;overflow:hidden;margin-bottom:18px;">
<div style="background:#fff;padding:12px 14px;"><div style="font-size:11px;color:#8c8c8c;">Tokens</div><div style="font-size:17px;font-weight:700;color:#262626;margin-top:3px;">1 284</div><div style="font-size:10.5px;color:#bfbfbf;margin-top:1px;">in 940 · out 344</div></div>
<div style="background:#fff;padding:12px 14px;"><div style="font-size:11px;color:#8c8c8c;">Cost</div><div style="font-size:17px;font-weight:700;color:#262626;margin-top:3px;">₽0,42</div><div style="font-size:10.5px;color:#bfbfbf;margin-top:1px;">latency 3,1с</div></div>
</div>
<!-- used chunks -->
<div style="font-size:11px;font-weight:600;letter-spacing:0.05em;color:#bfbfbf;margin-bottom:9px;">ИСПОЛЬЗОВАННЫЕ CHUNKS</div>
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:20px;">
<sc-for list="{{ chunks }}" as="c" hint-placeholder-count="2">
<div style="border:1px solid #f0f0f0;border-radius:9px;padding:10px 12px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;"><span style="font-size:12px;font-weight:600;color:#262626;">{{ c.source }}</span><span style="font-size:10.5px;font-weight:600;color:#389e0d;background:#f6ffed;padding:1px 6px;border-radius:5px;">{{ c.score }}</span></div>
<div style="font-size:11.5px;color:#8c8c8c;line-height:1.45;">{{ c.text }}</div>
</div>
</sc-for>
</div>
<!-- tools called -->
<div style="font-size:11px;font-weight:600;letter-spacing:0.05em;color:#bfbfbf;margin-bottom:9px;">ВЫЗВАННЫЕ TOOLS</div>
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:10px;border:1px solid #f0f0f0;border-radius:9px;padding:10px 12px;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#389e0d" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="M20 6 9 17l-5-5"/></svg><div style="flex:1;min-width:0;"><div style="font-size:12px;font-weight:600;color:#262626;font-family:'SF Mono',ui-monospace,Menlo,monospace;">get_offers</div><div style="font-size:11px;color:#8c8c8c;">product=firepage → 2 offers</div></div><span style="font-size:10.5px;color:#bfbfbf;">120мс</span></div>
<div style="display:flex;align-items:center;gap:10px;border:1px solid #f0f0f0;border-radius:9px;padding:10px 12px;"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#389e0d" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="M20 6 9 17l-5-5"/></svg><div style="flex:1;min-width:0;"><div style="font-size:12px;font-weight:600;color:#262626;font-family:'SF Mono',ui-monospace,Menlo,monospace;">create_checkout</div><div style="font-size:11px;color:#8c8c8c;">offer=OFR-FP-BOX · ₽4 900 <span style="color:#d48806;">(симуляция)</span></div></div><span style="font-size:10.5px;color:#bfbfbf;">88мс</span></div>
</div>
<!-- handoff -->
<div style="font-size:11px;font-weight:600;letter-spacing:0.05em;color:#bfbfbf;margin-bottom:9px;">HANDOFF</div>
<div style="display:flex;align-items:center;gap:10px;border:1px solid #f0f0f0;border-radius:9px;padding:11px 13px;background:#fafafa;"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#52c41a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex:none;"><path d="M20 6 9 17l-5-5"/></svg><span style="font-size:12px;color:#595959;">Передачи оператору не было — сценарий в рамках агента.</span></div>
</div>
</section>
</div>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{
&quot;$preview&quot;: { &quot;width&quot;: 1440, &quot;height&quot;: 900 }
}">
class Component extends DCLogic {
state = { channel: 'MAX', scenario: 'box' };
renderVals() {
const { channel, scenario } = this.state;
const channelLabel = { MAX: 'MAX', TG: 'Telegram', WEB: 'Web Chat' }[channel];
const chOff = 'display:inline-flex;align-items:center;gap:6px;flex:1;justify-content:center;height:32px;border:1px solid #e8e8e8;border-radius:8px;background:#fff;color:#595959;font-size:12px;font-weight:500;cursor:pointer;';
const chOn = 'display:inline-flex;align-items:center;gap:6px;flex:1;justify-content:center;height:32px;border:1px solid #1677ff;border-radius:8px;background:#e6f4ff;color:#0958d9;font-size:12px;font-weight:600;cursor:pointer;';
const scenData = [
{ id: 'box', title: 'Покупка коробки', desc: 'Клиент хочет купить FirePage под визитку.' },
{ id: 'support', title: 'Вопрос про поддержку', desc: 'Уточняет условия годовой поддержки.' },
{ id: 'handoff', title: 'Запрос человека', desc: 'Просит соединить с менеджером (handoff).' },
];
const scOff = 'text-align:left;border:1px solid #e8e8e8;border-radius:10px;padding:11px 13px;background:#fff;cursor:pointer;';
const scOn = 'text-align:left;border:1px solid #1677ff;border-radius:10px;padding:11px 13px;background:#f7fbff;cursor:pointer;';
const scenarios = scenData.map((s) => ({
title: s.title, desc: s.desc,
style: scenario === s.id ? scOn : scOff,
onPick: () => this.setState({ scenario: s.id }),
}));
const chunks = [
{ source: 'Что входит в коробку FirePage', score: '0.91', text: '«Коробка FirePage — готовый нишевой сайт под ключ, единоразовая оплата ₽4 900…»' },
{ source: 'Условия годовой поддержки', score: '0.84', text: '«Годовая поддержка — 30% от цены коробки, ₽1 470/год, продление по желанию…»' },
];
return {
channelLabel, scenarios, chunks,
chMax: channel === 'MAX' ? chOn : chOff,
chTg: channel === 'TG' ? chOn : chOff,
chWeb: channel === 'WEB' ? chOn : chOff,
onChMax: () => this.setState({ channel: 'MAX' }),
onChTg: () => this.setState({ channel: 'TG' }),
onChWeb: () => this.setState({ channel: 'WEB' }),
onClear: () => {},
};
}
}
</script>
</body>
</html>