diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index c15bcfd..d99fe86 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -21,18 +21,21 @@ export function App() { const [route, setRoute] = useState(initialRoute.route); const [selectedEmployeeId, setSelectedEmployeeId] = useState(initialRoute.employeeId); const [selectedProductId, setSelectedProductId] = useState(initialRoute.productId); + const [selectedAgentId, setSelectedAgentId] = useState(initialRoute.agentId); const [data, setData] = useState({ employees: [], departments: [], products: [] }); const [dataError, setDataError] = useState(false); const navigate = useCallback((nextRoute: RouteKey, entityId: number | null = null, replace = false) => { const nextEmployeeId = nextRoute === "employeeDetail" ? entityId : null; const nextProductId = nextRoute === "productDetail" ? entityId : null; + const nextAgentId = nextRoute === "aiAgentDetail" ? entityId : null; const nextPath = pathFromRoute(nextRoute, entityId); setRoute(nextRoute); setSelectedEmployeeId(nextEmployeeId); setSelectedProductId(nextProductId); + setSelectedAgentId(nextAgentId); if (window.location.pathname !== nextPath) { - const state = { route: nextRoute, employeeId: nextEmployeeId, productId: nextProductId }; + const state = { route: nextRoute, employeeId: nextEmployeeId, productId: nextProductId, agentId: nextAgentId }; if (replace) { window.history.replaceState(state, "", nextPath); } else { @@ -71,6 +74,7 @@ export function App() { setRoute(nextRoute.route); setSelectedEmployeeId(nextRoute.employeeId); setSelectedProductId(nextRoute.productId); + setSelectedAgentId(nextRoute.agentId); }; window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); @@ -122,7 +126,7 @@ export function App() { ) : !canAccess(user.role, route) ? ( navigate(defaultRoute(user.role), null, true)} /> ) : ( - navigate(nextRoute)} selectedEmployeeId={selectedEmployeeId} selectedProductId={selectedProductId} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} user={user} data={data} reload={loadData} onUserUpdated={setUser} onLogout={logout} /> + navigate(nextRoute)} selectedEmployeeId={selectedEmployeeId} selectedProductId={selectedProductId} selectedAgentId={selectedAgentId} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} user={user} data={data} reload={loadData} onUserUpdated={setUser} onLogout={logout} /> )} ); diff --git a/apps/internal-ui/src/features/ai/AgentsTable.tsx b/apps/internal-ui/src/features/ai/AgentsTable.tsx index 69bc257..76d56bf 100644 --- a/apps/internal-ui/src/features/ai/AgentsTable.tsx +++ b/apps/internal-ui/src/features/ai/AgentsTable.tsx @@ -14,6 +14,7 @@ export function AgentsTable({ setMenuId, toggleActive, setRoute, + openAgent, }: { agents: AiAgent[]; releases: AiRelease[]; @@ -21,6 +22,7 @@ export function AgentsTable({ setMenuId: (id: number | null) => void; toggleActive: (agent: AiAgent) => void; setRoute: (route: RouteKey) => void; + openAgent: (agentId: number) => void; }) { return (
@@ -43,7 +45,7 @@ export function AgentsTable({ const accent = productAccent(agent.product.code); const release = publishedRelease(releases, agent.product.code); const menuItems = [ - { key: "open", disabled: true, label: }, + { key: "open", label: }, { key: "release", disabled: true, label: }, { type: "divider" as const }, { diff --git a/apps/internal-ui/src/features/ai/AiAgentsPage.tsx b/apps/internal-ui/src/features/ai/AiAgentsPage.tsx index 16a37be..2fff4c3 100644 --- a/apps/internal-ui/src/features/ai/AiAgentsPage.tsx +++ b/apps/internal-ui/src/features/ai/AiAgentsPage.tsx @@ -8,7 +8,7 @@ import { AgentsTable } from "./AgentsTable"; import type { AiAgent } from "./model"; import { useAiAgents } from "./useAiAgents"; -export function AiAgentsPage({ setRoute }: { setRoute: (route: RouteKey) => void }) { +export function AiAgentsPage({ setRoute, openAgent }: { setRoute: (route: RouteKey) => void; openAgent: (agentId: number) => void }) { const { agents, releases, loading, error, reload } = useAiAgents(); const [menuId, setMenuId] = useState(null); @@ -29,7 +29,7 @@ export function AiAgentsPage({ setRoute }: { setRoute: (route: RouteKey) => void ) : ( <> - + )}
diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx new file mode 100644 index 0000000..c328f19 --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/AiAgentDetailHeader.tsx @@ -0,0 +1,42 @@ +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, type AiAgentDetail, type AiReleaseFull } from "./model"; + +export function AiAgentDetailHeader({ + agent, + releases, + setRoute, +}: { + agent: AiAgentDetail; + releases: AiReleaseFull[]; + setRoute: (route: RouteKey) => void; +}) { + const accent = productAccent(agent.product.code); + const release = publishedRelease(releases); + return ( +
+
+ +
+
+

{agent.name}

+ +
+
+
Продукт
+
Модель{agent.model}
+
Текущий release{release ? {`v${release.version}`} : —}
+
Лимиты{dailyBudget(agent.limits)}
+
+
+
+
+ + +
+
+ ); +} diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx new file mode 100644 index 0000000..134ace1 --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/AiAgentDetailPage.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; + +import { api } from "../../../api/client"; +import { EmptyState } from "../../../shared/ui"; +import { UnderlineTabs } from "../../../shared/ui-controls"; +import type { RouteKey } from "../../../types"; +import { AiAgentDetailHeader } from "./AiAgentDetailHeader"; +import { AiAgentInstructionsTab } from "./AiAgentInstructionsTab"; +import { AiAgentKnowledgeTab } from "./AiAgentKnowledgeTab"; +import { AiAgentMetricsTab } from "./AiAgentMetricsTab"; +import { AiAgentOverviewTab } from "./AiAgentOverviewTab"; +import { AiAgentReleasesTab } from "./AiAgentReleasesTab"; +import { agentTabs, type AgentTab } from "./model"; +import { useAiAgentDetail } from "./useAiAgentDetail"; + +export function AiAgentDetailPage({ agentId, setRoute, onAgentLoaded }: { agentId: number | null; setRoute: (route: RouteKey) => void; onAgentLoaded: (name: string | null) => void }) { + const { agent, releases, knowledge, prompts, loading, error, reload } = useAiAgentDetail(agentId); + const [tab, setTab] = useState("overview"); + + useEffect(() => { + onAgentLoaded(agent?.name ?? null); + return () => onAgentLoaded(null); + }, [agent, onAgentLoaded]); + + async function toggleActive() { + if (!agent) return; + const action = agent.isActive ? "deactivate" : "activate"; + await api(`/api/v1/ai/agents/${agent.id}/${action}/`, { method: "POST" }).catch(() => undefined); + reload(); + } + + if (loading) return
; + if (error || !agent) return
; + + return ( +
+ + + {tab === "overview" && } + {tab === "instructions" && } + {tab === "knowledge" && } + {tab === "releases" && } + {tab === "metrics" && } +
+ ); +} diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx new file mode 100644 index 0000000..c1da6a9 --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/AiAgentInstructionsTab.tsx @@ -0,0 +1,30 @@ +import { Icon } from "../../../shared/icons"; +import { EmptyState } from "../../../shared/ui"; +import { promptCategoryLabel, publishedVersion, type PromptDoc } from "./model"; + +export function AiAgentInstructionsTab({ prompts }: { prompts: PromptDoc[] }) { + if (prompts.length === 0) { + return ; + } + return ( +
+
+ + Инструкции редактируются в составе release candidate. Активный release published и доступен только для чтения — изменения применяются после публикации новой версии. +
+ {prompts.map((prompt, index) => { + const version = publishedVersion(prompt.versions); + return ( +
+
+ {index + 1} +

{prompt.title}

+ {promptCategoryLabel(prompt.category)} +
+
{version?.content?.trim() || "—"}
+
+ ); + })} +
+ ); +} diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentKnowledgeTab.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentKnowledgeTab.tsx new file mode 100644 index 0000000..290cc7e --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/AiAgentKnowledgeTab.tsx @@ -0,0 +1,29 @@ +import { Icon } from "../../../shared/icons"; +import { EmptyState } from "../../../shared/ui"; +import { formatDate } from "../../../shared/utils"; +import { knowledgeCategoryLabel, publishedVersion, type KnowledgeDoc } from "./model"; + +export function AiAgentKnowledgeTab({ knowledge, productName }: { knowledge: KnowledgeDoc[]; productName: string }) { + if (knowledge.length === 0) { + return ; + } + return ( +
+
+

Обязательные знания release

+
Retrieval index · {productName} KB · {knowledge.length} материалов
+
+ {knowledge.map((doc) => { + const version = publishedVersion(doc.versions); + const meta = [knowledgeCategoryLabel(doc.category), version ? `v${version.version}` : null, `обновлено ${formatDate(doc.updatedAt)}`].filter(Boolean).join(" · "); + return ( +
+ +
{doc.title}
{meta}
+ {doc.isEnabled ? "В индексе" : "Отключено"} +
+ ); + })} +
+ ); +} diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentMetricsTab.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentMetricsTab.tsx new file mode 100644 index 0000000..1057b5c --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/AiAgentMetricsTab.tsx @@ -0,0 +1,22 @@ +import { EmptyState } from "../../../shared/ui"; + +const KPIS = ["Диалоги · 30 дней", "Продажи · 30 дней", "Конверсия", "Стоимость / продажа"]; + +export function AiAgentMetricsTab() { + return ( +
+
+ {KPIS.map((label) => ( +
+
{label}
+
—
+
+ ))} +
+
+

Причины передачи оператору · 30 дней

+ +
+
+ ); +} diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentOverviewTab.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentOverviewTab.tsx new file mode 100644 index 0000000..1521520 --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/AiAgentOverviewTab.tsx @@ -0,0 +1,40 @@ +import { Icon } from "../../../shared/icons"; +import { dailyBudget, type AiAgentDetail } from "./model"; + +export function AiAgentOverviewTab({ agent, toggleActive }: { agent: AiAgentDetail; toggleActive: () => void }) { + return ( +
+
+
+

Назначение агента

+

—

+
+
+

Runtime сейчас

+
+
Активные диалоги—
+
В очереди на ответ—
+
Ср. время ответа—
+
+
+
+
+
+

Лимиты

+
Диалогов в день—
+
+
Бюджет в день{dailyBudget(agent.limits)}
+
+
+
+

Управление

+ + +
+
+
+ ); +} diff --git a/apps/internal-ui/src/features/ai/detail/AiAgentReleasesTab.tsx b/apps/internal-ui/src/features/ai/detail/AiAgentReleasesTab.tsx new file mode 100644 index 0000000..6fa6494 --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/AiAgentReleasesTab.tsx @@ -0,0 +1,43 @@ +import { EmptyState } from "../../../shared/ui"; +import { ToneBadge } from "../../../shared/ui-controls"; +import { formatDate } from "../../../shared/utils"; +import { releaseTone, type AiReleaseFull } from "./model"; + +export function AiAgentReleasesTab({ releases }: { releases: AiReleaseFull[] }) { + if (releases.length === 0) { + return ; + } + return ( +
+
+ + + + + + + + + + + + + {releases.map((release) => { + const tone = releaseTone(release.status); + return ( + + + + + + + + + ); + })} + +
RELEASEСТАТУСДАТААВТОРДИАЛОГИКОНВЕРСИЯ
{`v${release.version}`}{tone.label}{formatDate(release.publishedAt ?? release.createdAt)}———
+
+
+ ); +} diff --git a/apps/internal-ui/src/features/ai/detail/model.ts b/apps/internal-ui/src/features/ai/detail/model.ts new file mode 100644 index 0000000..5a7dd66 --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/model.ts @@ -0,0 +1,120 @@ +export type AiAgentDetail = { + id: number; + product: { code: string; name: string }; + name: string; + isActive: boolean; + model: string; + modelParams: Record; + allowedTools: unknown[]; + limits: Record; + createdAt: string; + updatedAt: string; +}; + +export type DocVersion = { + id: number; + version: number; + status: "DRAFT" | "PUBLISHED" | "ARCHIVED"; + content: string; + createdBy: number | null; + createdAt: string; +}; + +export type KnowledgeDoc = { + id: number; + product: { code: string; name: string }; + code: string; + title: string; + category: string; + isEnabled: boolean; + inclusionMode: "MANDATORY" | "RETRIEVAL"; + versions: DocVersion[]; + createdAt: string; + updatedAt: string; +}; + +export type PromptDoc = { + id: number; + product: { code: string; name: string }; + code: string; + title: string; + category: string; + isEnabled: boolean; + versions: DocVersion[]; + createdAt: string; + updatedAt: string; +}; + +export type AiReleaseFull = { + id: number; + product: { code: string; name: string }; + version: number; + status: "DRAFT" | "PUBLISHED" | "ARCHIVED"; + model: string; + notes: string; + retrievalIndexVersion: string; + knowledgeVersions: Array<{ document: string; version: number }>; + promptVersions: Array<{ document: string; version: number }>; + createdAt: string; + publishedAt: string | null; +}; + +export type AgentTab = "overview" | "instructions" | "knowledge" | "releases" | "metrics"; + +export const agentTabs: Array<{ key: AgentTab; label: string }> = [ + { key: "overview", label: "Обзор" }, + { key: "instructions", label: "Инструкции" }, + { key: "knowledge", label: "Знания" }, + { key: "releases", label: "История releases" }, + { key: "metrics", label: "Метрики" }, +]; + +const RELEASE_TONE: Record = { + PUBLISHED: { bg: "#f6ffed", color: "#389e0d", label: "Published" }, + DRAFT: { bg: "#fff7e6", color: "#d48806", label: "Candidate" }, + ARCHIVED: { bg: "#f5f5f5", color: "#8c8c8c", label: "Archived" }, +}; + +export function releaseTone(status: AiReleaseFull["status"]) { + return RELEASE_TONE[status]; +} + +const KNOWLEDGE_CATEGORY: Record = { + OVERVIEW: "Обзор продукта", + AUDIENCE: "Целевая аудитория", + COMMERCIAL: "Коммерческая модель", + TECHNICAL: "Техническая информация", + FAQ: "FAQ", + OBJECTIONS: "Возражения", + LIMITATIONS: "Ограничения", +}; + +const PROMPT_CATEGORY: Record = { + SYSTEM: "Системный промпт", + QUALIFICATION: "Квалификация", + SALES_BEHAVIOR: "Поведение в продаже", + OPERATOR_HANDOFF: "Передача оператору", +}; + +export function knowledgeCategoryLabel(category: string): string { + return KNOWLEDGE_CATEGORY[category] ?? category; +} + +export function promptCategoryLabel(category: string): string { + return PROMPT_CATEGORY[category] ?? category; +} + +export function publishedVersion(versions: DocVersion[]): DocVersion | undefined { + return versions.find((version) => version.status === "PUBLISHED") ?? versions[0]; +} + +export function publishedRelease(releases: AiReleaseFull[]): AiReleaseFull | undefined { + return releases.find((release) => release.status === "PUBLISHED"); +} + +// agent.limits — свободный JSON; пока бэкенд хранит только дневной лимит стоимости (micro-USD). +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 })} / день`; +} diff --git a/apps/internal-ui/src/features/ai/detail/styles.css b/apps/internal-ui/src/features/ai/detail/styles.css new file mode 100644 index 0000000..2d1c0fe --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/styles.css @@ -0,0 +1,415 @@ +/* AI agent card (AI-02): per-screen styling on shared components. */ + +.ai-agent-page { + max-width: 1080px; +} + +/* --- Header card --- */ +.ai-agent-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 20px 22px; + margin-bottom: 16px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.ai-agent-heading { + display: flex; + align-items: flex-start; + gap: 16px; + min-width: 0; +} + +.ai-agent-icon { + width: 52px; + height: 52px; + border-radius: 13px; + display: flex; + align-items: center; + justify-content: center; + flex: none; +} + +.ai-agent-title h1 { + margin: 0; + font-size: 21px; + font-weight: 700; + letter-spacing: -0.01em; + color: #1f1f1f; +} + +.ai-agent-title-row { + display: flex; + align-items: center; + gap: 11px; + flex-wrap: wrap; +} + +.ai-agent-meta { + display: flex; + align-items: center; + margin-top: 12px; + flex-wrap: wrap; +} + +.ai-agent-meta > div { + padding-right: 18px; + margin-right: 18px; + border-right: 1px solid #f0f0f0; +} + +.ai-agent-meta > div:last-child { + padding-right: 0; + margin-right: 0; + border-right: 0; +} + +.ai-agent-meta span { + display: block; + font-size: 11px; + color: #8c8c8c; +} + +.ai-agent-meta b, +.ai-agent-meta code { + display: block; + margin-top: 2px; + font-size: 13.5px; + font-weight: 600; + color: #262626; +} + +.ai-agent-meta-empty { + color: #bfbfbf !important; +} + +.ai-agent-meta-link { + margin-top: 2px; + padding: 0; + background: none; + border: 0; + font-size: 13.5px; + font-weight: 600; + color: #1677ff; + cursor: pointer; +} + +.ai-agent-header-actions { + display: flex; + align-items: center; + gap: 9px; + flex: none; +} + +/* --- Tabs --- */ +.ai-agent-tabs { + gap: 2px; + margin-bottom: 18px; + border-bottom: 1px solid #e8e8e8; + overflow-x: auto; +} + +.ai-agent-tabs button { + padding: 11px 15px; + color: #595959; + font-size: 14px; + font-weight: 500; +} + +.ai-agent-tabs button.active { + color: #1677ff; + font-weight: 600; +} + +/* --- Generic card --- */ +.ai-card { + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + padding: 18px 20px; +} + +.ai-card--flush { + padding: 0; + overflow: hidden; +} + +.ai-card h3 { + margin: 0 0 13px; + font-size: 14px; + font-weight: 700; + color: #1f1f1f; +} + +/* --- Overview --- */ +.ai-agent-overview { + display: flex; + align-items: flex-start; + gap: 18px; +} + +.ai-agent-overview-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.ai-agent-overview-side { + width: 316px; + flex: none; + display: flex; + flex-direction: column; + gap: 16px; +} + +.ai-agent-purpose { + margin: 0; + font-size: 13px; + color: #595959; + line-height: 1.6; +} + +.ai-runtime-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1px; + background: #f0f0f0; + border: 1px solid #f0f0f0; + border-radius: 9px; + overflow: hidden; +} + +.ai-runtime-grid > div { + background: #fff; + padding: 13px 15px; +} + +.ai-runtime-grid span { + font-size: 11.5px; + color: #8c8c8c; +} + +.ai-runtime-grid strong { + display: block; + margin-top: 4px; + font-size: 19px; + font-weight: 700; + color: #262626; +} + +.ai-limit-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 7px 0; +} + +.ai-limit-row span { + font-size: 12.5px; + color: #8c8c8c; +} + +.ai-limit-row b { + font-size: 12.5px; + font-weight: 600; + color: #262626; +} + +.ai-limit-bar { + height: 6px; + border-radius: 999px; + background: #f0f0f0; + overflow: hidden; + margin: 4px 0 10px; +} + +.ai-limit-bar i { + display: block; + height: 6px; + border-radius: 999px; + background: #1677ff; +} + +.ai-manage-btn { + width: 100%; + display: flex; + align-items: center; + gap: 9px; + height: 38px; + padding: 0 13px; + border-radius: 9px; + border: 1px solid #e8e8e8; + background: #fff; + color: #262626; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} + +.ai-manage-btn + .ai-manage-btn { + margin-top: 8px; +} + +.ai-manage-btn:disabled { + color: #bfbfbf; + cursor: not-allowed; +} + +.ai-manage-btn.warning { + border-color: #ffe58f; + background: #fffbe6; + color: #d48806; +} + +/* --- Instructions --- */ +.ai-instructions { + display: flex; + flex-direction: column; + gap: 16px; +} + +.ai-notice { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 16px; + background: #fffbe6; + border: 1px solid #ffe58f; + border-radius: 10px; + color: #7c5a00; + font-size: 12.5px; +} + +.ai-notice svg { + flex: none; + color: #d48806; +} + +.ai-instruction-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.ai-instruction-head h3 { + margin: 0; +} + +.ai-instruction-head small { + color: #bfbfbf; + font-size: 11.5px; +} + +.ai-instruction-index { + width: 24px; + height: 24px; + border-radius: 6px; + background: #f0f2f5; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: #595959; + flex: none; +} + +.ai-prompt-block { + margin: 0; + background: #fafafa; + border: 1px solid #f0f0f0; + border-radius: 9px; + padding: 13px 15px; + font-size: 12.5px; + color: #595959; + line-height: 1.65; + font-family: "SF Mono", ui-monospace, Menlo, monospace; + white-space: pre-wrap; + word-break: break-word; +} + +/* --- Knowledge --- */ +.ai-knowledge-head { + padding: 15px 18px 12px; + border-bottom: 1px solid #f5f5f5; +} + +.ai-knowledge-head h3 { + margin: 0; +} + +.ai-knowledge-sub { + margin-top: 3px; + font-size: 11.5px; + color: #bfbfbf; +} + +.ai-knowledge-row { + display: flex; + align-items: center; + gap: 13px; + padding: 13px 18px; + border-bottom: 1px solid #f5f5f5; +} + +.ai-knowledge-row:last-child { + border-bottom: 0; +} + +.ai-knowledge-icon { + width: 32px; + height: 32px; + border-radius: 8px; + background: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + flex: none; + color: #8c8c8c; +} + +.ai-knowledge-info { + flex: 1; + min-width: 0; +} + +.ai-knowledge-title { + font-size: 13px; + font-weight: 500; + color: #262626; +} + +.ai-knowledge-meta { + margin-top: 1px; + font-size: 11.5px; + color: #bfbfbf; +} + +.ai-knowledge-status { + font-size: 11.5px; + font-weight: 500; +} + +.ai-knowledge-status.on { + color: #389e0d; +} + +.ai-knowledge-status.off { + color: #bfbfbf; +} + +/* --- Metrics --- */ +.ai-agent-metrics { + display: flex; + flex-direction: column; + gap: 16px; +} diff --git a/apps/internal-ui/src/features/ai/detail/useAiAgentDetail.ts b/apps/internal-ui/src/features/ai/detail/useAiAgentDetail.ts new file mode 100644 index 0000000..24939c1 --- /dev/null +++ b/apps/internal-ui/src/features/ai/detail/useAiAgentDetail.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from "react"; + +import { api } from "../../../api/client"; +import type { AiAgentDetail, AiReleaseFull, KnowledgeDoc, PromptDoc } from "./model"; + +type AgentDetailData = { + agent: AiAgentDetail | null; + releases: AiReleaseFull[]; + knowledge: KnowledgeDoc[]; + prompts: PromptDoc[]; +}; + +export function useAiAgentDetail(agentId: number | null) { + const [data, setData] = useState({ agent: null, releases: [], knowledge: [], prompts: [] }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const load = useCallback(async () => { + if (!agentId) { + setError(true); + setLoading(false); + return; + } + setLoading(true); + setError(false); + try { + const { agent } = await api<{ agent: AiAgentDetail }>(`/api/v1/ai/agents/${agentId}/`); + const product = encodeURIComponent(agent.product.code); + const [releases, knowledge, prompts] = await Promise.all([ + api<{ items: AiReleaseFull[] }>(`/api/v1/ai/releases/?product=${product}`), + api<{ items: KnowledgeDoc[] }>(`/api/v1/ai/knowledge/?product=${product}`), + api<{ items: PromptDoc[] }>(`/api/v1/ai/prompts/?product=${product}`), + ]); + setData({ agent, releases: releases.items, knowledge: knowledge.items, prompts: prompts.items }); + } catch { + setError(true); + } finally { + setLoading(false); + } + }, [agentId]); + + useEffect(() => { + void load(); + }, [load]); + + return { ...data, loading, error, reload: load }; +} diff --git a/apps/internal-ui/src/features/ai/styles.css b/apps/internal-ui/src/features/ai/styles.css index 4b4de11..f14cb3a 100644 --- a/apps/internal-ui/src/features/ai/styles.css +++ b/apps/internal-ui/src/features/ai/styles.css @@ -10,6 +10,7 @@ } .ai-subnav-tabs { + gap: 2px; width: min(1200px, 100%); max-width: 1200px; margin: 0 auto; @@ -17,11 +18,9 @@ .ai-subnav button { padding: 13px 14px; - margin-bottom: -1px; color: #595959; font-size: 13.5px; font-weight: 500; - cursor: pointer; } .ai-subnav button.active { @@ -31,7 +30,6 @@ .ai-subnav button:disabled { color: #bfbfbf; - cursor: not-allowed; } .ai-page { diff --git a/apps/internal-ui/src/layout/Shell.tsx b/apps/internal-ui/src/layout/Shell.tsx index 94d8482..c589bee 100644 --- a/apps/internal-ui/src/layout/Shell.tsx +++ b/apps/internal-ui/src/layout/Shell.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; + import type { AppData, Employee, Product, RouteKey, SessionUser } from "../types"; import { AiSubnav } from "../features/ai/AiSubnav"; import { Sidebar } from "./Sidebar"; @@ -5,7 +7,8 @@ import { SalesSidebar } from "./SalesSidebar"; import { ShellRouteContent } from "./ShellRouteContent"; import { TopBar } from "./TopBar"; -export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, openEmployeeRoute, openProductRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { +export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedAgentId, openEmployeeRoute, openProductRoute, openAgentRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedAgentId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentRoute: (agentId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { + const [agentName, setAgentName] = useState(null); const currentEmployee = route === "employeeDetail" ? data.employees.find((employee) => employee.id === selectedEmployeeId) ?? null : data.employees.find((employee) => employee.email === "a.kotova@edevs.tech") ?? data.employees[0] ?? null; @@ -22,17 +25,19 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, const isSalesClientDetail = route === "salesClientDetail"; const isSalesOrderDetail = route === "salesOrderDetail"; const isSalesOrders = route === "salesOrderDetail" || route === "salesOrders"; + // Подменю AI показываем только на разделах AI-секции, не на карточке агента. + const isAiSection = route === "aiAgents" || route === "aiTestChat" || route === "aiUsage"; // OPERATOR работает только в пространстве продаж, поэтому всегда видит sales-sidebar (SPEC-HUB-0004 §9). const showSalesSidebar = isSalesWorkspace || user.role === "OPERATOR"; return (
{showSalesSidebar ? : }
- - {route.startsWith("ai") && } + + {isAiSection && }
- +
diff --git a/apps/internal-ui/src/layout/ShellRouteContent.tsx b/apps/internal-ui/src/layout/ShellRouteContent.tsx index d24a186..354fc3f 100644 --- a/apps/internal-ui/src/layout/ShellRouteContent.tsx +++ b/apps/internal-ui/src/layout/ShellRouteContent.tsx @@ -1,4 +1,5 @@ import { AiAgentsPage } from "../features/ai/AiAgentsPage"; +import { AiAgentDetailPage } from "../features/ai/detail/AiAgentDetailPage"; import { CommandCenter } from "../features/command/CommandCenter"; import { DepartmentsPage } from "../features/departments/DepartmentsPage"; import { EmployeeDetailPage, EmployeesPage } from "../features/employees/EmployeesPage"; @@ -13,7 +14,7 @@ import { SalesOrdersPage } from "../features/sales/orders/SalesOrdersPage"; import { SalesOverviewPage } from "../features/sales/SalesOverviewPage"; import type { AppData, Employee, Product, RouteKey, SessionUser } from "../types"; -export function ShellRouteContent({ route, data, currentEmployee, currentProduct, openEmployee, openProduct, reload, setRoute, user, onUserUpdated, onLogout }: { route: RouteKey; data: AppData; currentEmployee: Employee | null; currentProduct: Product | null; openEmployee: (employee: Employee) => void; openProduct: (product: Product) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { +export function ShellRouteContent({ route, data, currentEmployee, currentProduct, selectedAgentId, openEmployee, openProduct, openAgent, onAgentLoaded, reload, setRoute, user, onUserUpdated, onLogout }: { route: RouteKey; data: AppData; currentEmployee: Employee | null; currentProduct: Product | null; selectedAgentId: number | null; openEmployee: (employee: Employee) => void; openProduct: (product: Product) => void; openAgent: (agentId: number) => void; onAgentLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) { return ( <> {route === "command" && } @@ -31,7 +32,8 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct {route === "salesDialogs" && } {route === "salesOrderDetail" && } {route === "salesOrders" && } - {route === "aiAgents" && } + {route === "aiAgents" && } + {route === "aiAgentDetail" && } ); } diff --git a/apps/internal-ui/src/layout/TopBar.tsx b/apps/internal-ui/src/layout/TopBar.tsx index ab04d0f..f715857 100644 --- a/apps/internal-ui/src/layout/TopBar.tsx +++ b/apps/internal-ui/src/layout/TopBar.tsx @@ -3,10 +3,11 @@ import { routes } from "../routes"; import type { Employee, Product, RouteKey, SessionUser } from "../types"; import { Icon } from "../shared/icons"; -export function TopBar({ route, user, currentEmployee, currentProduct, setRoute }: { route: RouteKey; user: SessionUser; currentEmployee?: Employee | null; currentProduct?: Product | null; setRoute: (route: RouteKey) => void }) { +export function TopBar({ route, user, currentEmployee, currentProduct, currentAgentName, setRoute }: { route: RouteKey; user: SessionUser; currentEmployee?: Employee | null; currentProduct?: Product | null; currentAgentName?: string | null; setRoute: (route: RouteKey) => void }) { const st = commandCenterModel("today").st; const isCommand = route === "command"; - const isAi = route.startsWith("ai"); + const isAiDetail = route === "aiAgentDetail" || route === "aiRelease"; + const isAi = route.startsWith("ai") && !isAiDetail; const isSalesWorkspace = route === "salesOverview" || route === "salesClientDetail" || route === "salesClients" || route === "salesDialogs" || route === "salesOrderDetail" || route === "salesOrders"; return (
@@ -19,7 +20,8 @@ export function TopBar({ route, user, currentEmployee, currentProduct, setRoute {route === "salesClientDetail" && <>//{routes[route]}} {route === "salesOrderDetail" && <>//{routes[route]}} {isAi && <>/{routes[route]}} - {route !== "employeeDetail" && route !== "productDetail" && !isSalesWorkspace && !isAi && {routes[route]}} + {isAiDetail && <>//{currentAgentName || routes[route]}} + {route !== "employeeDetail" && route !== "productDetail" && !isSalesWorkspace && !isAi && !isAiDetail && {routes[route]}}
{isCommand && {st.label}} diff --git a/apps/internal-ui/src/router.test.ts b/apps/internal-ui/src/router.test.ts index 9eec642..69317f6 100644 --- a/apps/internal-ui/src/router.test.ts +++ b/apps/internal-ui/src/router.test.ts @@ -4,10 +4,20 @@ import { pathFromRoute, routeFromPath } from "./router"; describe("product routes", () => { it("parses a product detail URL", () => { - expect(routeFromPath("/products/42")).toEqual({ route: "productDetail", employeeId: null, productId: 42 }); + expect(routeFromPath("/products/42")).toEqual({ route: "productDetail", employeeId: null, productId: 42, agentId: null }); }); it("creates a product detail URL", () => { expect(pathFromRoute("productDetail", 42)).toBe("/products/42"); }); }); + +describe("ai agent routes", () => { + it("parses an AI agent detail URL", () => { + expect(routeFromPath("/ai/agents/7")).toEqual({ route: "aiAgentDetail", employeeId: null, productId: null, agentId: 7 }); + }); + + it("creates an AI agent detail URL", () => { + expect(pathFromRoute("aiAgentDetail", 7)).toBe("/ai/agents/7"); + }); +}); diff --git a/apps/internal-ui/src/router.ts b/apps/internal-ui/src/router.ts index e91b4be..cca549d 100644 --- a/apps/internal-ui/src/router.ts +++ b/apps/internal-ui/src/router.ts @@ -4,11 +4,12 @@ export type RouteState = { route: RouteKey; employeeId: number | null; productId: number | null; + agentId: number | null; }; export function routeFromPath(pathname: string): RouteState { const path = pathname.replace(/\/+$/, "") || "/"; - const base = { employeeId: null, productId: null }; + const base = { employeeId: null, productId: null, agentId: null }; if (path === "/" || path === "/command") return { route: "command", ...base }; if (path === "/departments") return { route: "departments", ...base }; if (path === "/departments/sales") return { route: "salesOverview", ...base }; @@ -20,14 +21,18 @@ export function routeFromPath(pathname: string): RouteState { if (path === "/employees") return { route: "employees", ...base }; if (path.startsWith("/employees/")) { const id = Number(path.split("/")[2]); - return Number.isInteger(id) && id > 0 ? { route: "employeeDetail", employeeId: id, productId: null } : { route: "employees", ...base }; + return Number.isInteger(id) && id > 0 ? { ...base, route: "employeeDetail", employeeId: id } : { route: "employees", ...base }; } if (path === "/products") return { route: "products", ...base }; if (path.startsWith("/products/")) { const id = Number(path.split("/")[2]); - return Number.isInteger(id) && id > 0 ? { route: "productDetail", employeeId: null, productId: id } : { route: "products", ...base }; + return Number.isInteger(id) && id > 0 ? { ...base, route: "productDetail", productId: id } : { route: "products", ...base }; } if (path === "/ai" || path === "/ai/agents") return { route: "aiAgents", ...base }; + if (path.startsWith("/ai/agents/")) { + const id = Number(path.split("/")[3]); + return Number.isInteger(id) && id > 0 ? { ...base, route: "aiAgentDetail", agentId: id } : { route: "aiAgents", ...base }; + } if (path === "/ai/test-chat") return { route: "aiTestChat", ...base }; if (path === "/ai/usage") return { route: "aiUsage", ...base }; if (path === "/profile") return { route: "profile", ...base }; diff --git a/apps/internal-ui/src/shared/utils.ts b/apps/internal-ui/src/shared/utils.ts index 03f6395..1ccb564 100644 --- a/apps/internal-ui/src/shared/utils.ts +++ b/apps/internal-ui/src/shared/utils.ts @@ -9,3 +9,7 @@ export function productAccent(code: string): { bg: string; color: string } { if (code === "foxray") return { bg: "#f9f0ff", color: "#722ed1" }; return { bg: "#e6f4ff", color: "#0958d9" }; } + +export function formatDate(value: string): string { + return new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "long", year: "numeric" }).format(new Date(value)); +} diff --git a/apps/internal-ui/src/styles.css b/apps/internal-ui/src/styles.css index 474d695..c62c64a 100644 --- a/apps/internal-ui/src/styles.css +++ b/apps/internal-ui/src/styles.css @@ -16,4 +16,5 @@ @import "./features/employees/styles.css"; @import "./features/profile/styles.css"; @import "./features/ai/styles.css"; +@import "./features/ai/detail/styles.css"; @import "./shared/state.css";