diff --git a/admin/src/app/dashboard/placement/TestDetailSheet.tsx b/admin/src/app/dashboard/placement/TestDetailSheet.tsx index e3812d955..51b3e27d2 100644 --- a/admin/src/app/dashboard/placement/TestDetailSheet.tsx +++ b/admin/src/app/dashboard/placement/TestDetailSheet.tsx @@ -185,7 +185,8 @@ function scoreTone(score: number): Tone { function SummaryGrid({ counts }: { counts: PlacementCounts }) { const tabs = counts.promotions + counts.other; const cells: { label: string; value: number; sub?: string; tone?: Tone }[] = [ - { label: "Delivered", value: counts.delivered, sub: `of ${counts.total}` }, + { label: "Resolved (legacy basis)", value: counts.delivered, sub: `includes timeouts, of ${counts.total}` }, + { label: "Observed receipts", value: counts.observed_receipts ?? counts.inbox + counts.promotions + counts.other + counts.spam, sub: `${counts.unknown ?? 0} unknown, ${counts.archive ?? 0} archive, ${counts.custom ?? 0} custom` }, { label: "Primary inbox", value: counts.inbox, sub: pct(counts.inbox_rate), tone: "success" }, { label: "Gmail tabs", value: tabs, sub: pct(counts.tabs_rate), tone: "info" }, { label: "Spam", value: counts.spam, sub: pct(counts.spam_rate), tone: counts.spam > 0 ? "danger" : undefined }, @@ -237,7 +238,7 @@ function FamiliesTable({ families }: { families: { family: string; label: string Provider - Delivered + Resolved (legacy) Inbox Tabs Spam diff --git a/admin/src/app/dashboard/placement/badges.tsx b/admin/src/app/dashboard/placement/badges.tsx index 2858e94de..20678a335 100644 --- a/admin/src/app/dashboard/placement/badges.tsx +++ b/admin/src/app/dashboard/placement/badges.tsx @@ -26,6 +26,9 @@ const FOLDER_LABEL: Record = { other: "Other tab", spam: "Spam", missing: "Missing", + unknown: "Folder unknown", + archive: "Archive", + custom: "Custom folder", failed: "Failed", cancelled: "Cancelled", }; @@ -37,6 +40,9 @@ const FOLDER_TONE: Record = { other: "info", spam: "danger", missing: "warning", + unknown: "neutral", + archive: "neutral", + custom: "neutral", failed: "danger", cancelled: "neutral", }; diff --git a/admin/src/app/dashboard/warmup-content/LibraryPage.tsx b/admin/src/app/dashboard/warmup-content/LibraryPage.tsx index 65c8b213f..b770214b6 100644 --- a/admin/src/app/dashboard/warmup-content/LibraryPage.tsx +++ b/admin/src/app/dashboard/warmup-content/LibraryPage.tsx @@ -153,6 +153,12 @@ export default function LibraryPage() { ), csv: (c) => (c.lint_passed ? "pass" : "fail"), }, + { + id: "review", + header: "Semantic review", + cell: (c) => {c.semantic_review ?? "legacy_unknown"}, + csv: (c) => c.semantic_review ?? "legacy_unknown", + }, { id: "status", header: "Status", @@ -188,7 +194,7 @@ export default function LibraryPage() { size="xs" variant="ghost" onClick={() => unarchive.mutate(c.id)} - disabled={unarchive.isPending} + disabled={unarchive.isPending || (c.semantic_review !== undefined && !["passed", "legacy_unknown"].includes(c.semantic_review))} > Restore @@ -363,6 +369,9 @@ function ConversationDialog({ lint {c.lint_passed ? "pass" : "fail"} + review: {c.semantic_review ?? "legacy_unknown"} + {c.scenario_version && scenario: {c.scenario_version}} + {c.rendering_version && rendering: {c.rendering_version}} {c.segment && segment: {c.segment}} source: {c.source || "—"} diff --git a/admin/src/app/dashboard/warmup-content/OverviewPage.tsx b/admin/src/app/dashboard/warmup-content/OverviewPage.tsx index 511859a65..e3e29efb8 100644 --- a/admin/src/app/dashboard/warmup-content/OverviewPage.tsx +++ b/admin/src/app/dashboard/warmup-content/OverviewPage.tsx @@ -3,8 +3,13 @@ // library stock vs the scheduler's targets, today's generation budget, // headline counts, and the content-source vs spam-placement A/B comparison. -import { useMemo, type ReactNode } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { useAdminPerm } from "@/hooks/useAdminPerm"; +import { AdminPerm } from "@/lib/auth/permissions"; import { Archive, CheckCircle2, @@ -30,6 +35,8 @@ import { cn } from "@/lib/utils"; import { getWarmupContentAb, getWarmupContentOverview, + putWarmupGenerationSettings, + type WarmupGenerationSettings, type WarmupContentOverview, } from "@/lib/api/client/admin/warmupContent"; import { Td, Th } from "./components"; @@ -41,19 +48,16 @@ interface PipelineStep { detail: string; } -// The controller is always enabled. The only external dependency is an AI -// provider; without one, reviewed static content keeps sends operational. +// Cached-content selection and new generation have independent controls. function pipelineSteps(d: WarmupContentOverview): PipelineStep[] { const totalTarget = d.stock.reduce((n, s) => n + s.target, 0); const totalStock = d.stock.reduce((n, s) => n + Math.min(s.active, s.target), 0); const stocked = totalTarget > 0 && totalStock >= totalTarget; return [ { - label: "AI client configured", - ok: d.ai_configured, - detail: d.ai_configured - ? "OPENAI_API_KEY is set on the backend" - : "Set OPENAI_API_KEY on the backend, then restart it", + label: "Generation enabled", + ok: d.ai_configured && d.generation_enabled !== false, + detail: !d.ai_configured ? "No compatible generation client configured" : d.generation_enabled === false ? "New jobs stopped; existing batches drain" : "Configured model checked before submission", }, { label: "AI content enabled", @@ -100,7 +104,7 @@ function AutomationPanel({ data }: { data: WarmupContentOverview }) { const firstGap = steps.findIndex((s) => !s.ok); const capped = data.daily_generation_cap > 0; const budgetUsed = capped - ? Math.min(100, Math.round((data.generated_today / data.daily_generation_cap) * 100)) + ? Math.min(100, Math.round(((data.reserved_today ?? data.generated_today) / data.daily_generation_cap) * 100)) : 0; return ( @@ -108,7 +112,7 @@ function AutomationPanel({ data }: { data: WarmupContentOverview }) { title="Automatic extension" actions={ - Autopilot + {data.generation_enabled === false ? "Generation stopped" : "Generation control"} } bodyClassName="p-0" @@ -116,7 +120,7 @@ function AutomationPanel({ data }: { data: WarmupContentOverview }) {

{allOk ? data.refresh_enabled - ? "The library extends itself: every run generates new threads, humanizes and lints them, and recycles the most-used ones so fresh content keeps flowing indefinitely." + ? "Scheduled generation preserves canonical content and reviews complete rendered diagnostic threads before activating them." : "The library tops itself up to the target. Continuous refresh is off, so generation pauses once the target is reached." : "Not fully automatic yet. Fix the first amber step below and the library will keep itself stocked without manual runs."}

@@ -148,8 +152,8 @@ function AutomationPanel({ data }: { data: WarmupContentOverview }) { {capped ? ( - {data.generated_today.toLocaleString()} / {data.daily_generation_cap.toLocaleString()}{" "} - threads + {(data.reserved_today ?? data.generated_today).toLocaleString()} / {data.daily_generation_cap.toLocaleString()}{" "} + requests reserved today = 100 ? "warning" : "success"} className="w-24" /> @@ -179,6 +183,44 @@ function AutomationPanel({ data }: { data: WarmupContentOverview }) { ); } +function GenerationSettingsPanel({ data }: { data: WarmupContentOverview }) { + const canManage = useAdminPerm(AdminPerm.ManageSettings); + const qc = useQueryClient(); + const [draft, setDraft] = useState(null); + const save = useMutation({ + mutationFn: putWarmupGenerationSettings, + onSuccess: () => { + setDraft(null); + void qc.invalidateQueries({ queryKey: ["admin", "warmup-content"] }); + toast.success("Generation settings saved"); + }, + onError: (err: Error) => toast.error(err.message || "Failed to save generation settings"), + }); + if (!data.effective_settings) return This backend does not expose generation controls. Upgrade the backend before changing settings.; + const text = draft ?? JSON.stringify(data.effective_settings, null, 2); + const submit = () => { + try { + const settings: WarmupGenerationSettings = JSON.parse(text); + if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new Error("Expected a settings object"); + save.mutate(settings); + } catch { + toast.error("Enter a valid JSON settings object"); + } + }; + return ( + +

Stopping generation blocks new jobs without deleting credentials. Already submitted batches continue polling and ingest safely; cancel them explicitly in Jobs. Cached-content use is controlled separately by enabled. A stop is effective only after all backends are upgraded.

+

{data.provider_capability}

+