feat(automations): implement model eligibility checks for automation creation

- Added model eligibility checks to ensure automations can only use billable models (premium or BYOK).
- Introduced new API endpoint to report model eligibility status for search spaces.
- Updated frontend components to display eligibility alerts and disable creation options when models are not billable.
- Enhanced automation creation forms to reflect model eligibility, preventing users from submitting invalid configurations.
- Implemented server-side logic to capture and preserve model preferences across automation edits, ensuring consistent behavior during execution.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-29 03:13:46 -07:00
parent 5d90fbe99f
commit 409fec94c3
32 changed files with 1451 additions and 67 deletions

View file

@ -1,6 +1,8 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { useAutomationModelEligibility } from "@/hooks/use-automation-model-eligibility";
import { useAutomations } from "@/hooks/use-automations";
import { AutomationModelGateAlert } from "./components/automation-model-gate-alert";
import { AutomationsEmptyState } from "./components/automations-empty-state";
import { AutomationsHeader } from "./components/automations-header";
import { AutomationsTable } from "./components/automations-table";
@ -22,6 +24,18 @@ interface AutomationsContentProps {
export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
const { automations, total, loading, error } = useAutomations();
const perms = useAutomationPermissions();
// Gate creation on billable models (premium/BYOK). Only meaningful for
// users who can create; the eligibility query loads in parallel.
const { data: eligibility, isLoading: eligibilityLoading } = useAutomationModelEligibility(
perms.canCreate ? searchSpaceId : undefined
);
const modelViolations = eligibility?.violations ?? [];
// Disable create CTAs while loading (avoid a flash of enabled buttons) and
// when the resolved models aren't billable.
const createDisabled = perms.canCreate && (eligibilityLoading || modelViolations.length > 0);
const disabledReason = eligibilityLoading
? "Checking model eligibility…"
: modelViolations[0]?.reason;
if (perms.loading) {
// Permissions gate the entire page; defer everything until we know.
@ -77,7 +91,11 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
canCreate={perms.canCreate}
showCreateCta={false}
/>
<AutomationsEmptyState searchSpaceId={searchSpaceId} canCreate={perms.canCreate} />
<AutomationsEmptyState
searchSpaceId={searchSpaceId}
canCreate={perms.canCreate}
modelViolations={modelViolations}
/>
</>
);
}
@ -89,7 +107,12 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
total={total}
loading={loading}
canCreate={perms.canCreate}
createDisabled={createDisabled}
disabledReason={disabledReason}
/>
{modelViolations.length > 0 && (
<AutomationModelGateAlert searchSpaceId={searchSpaceId} violations={modelViolations} />
)}
<AutomationsTable
automations={automations}
searchSpaceId={searchSpaceId}

View file

@ -0,0 +1,61 @@
"use client";
import { Lock } from "lucide-react";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { ModelEligibilityViolation } from "@/contracts/types/automation.types";
interface AutomationModelGateAlertProps {
searchSpaceId: number;
violations: ModelEligibilityViolation[];
className?: string;
}
// Model selection for automations is managed under the roles tab, so every
// blocked slot deep-links there (the label still names which slot to fix).
const SETTINGS_TAB = "roles";
const KIND_LABEL: Record<ModelEligibilityViolation["kind"], string> = {
llm: "Agent model",
image: "Image model",
vision: "Vision model",
};
/**
* Warns that the search space's models aren't billable for automations.
*
* Automations may only use premium global models or your own (BYOK) models
* free models and Auto mode are blocked so every run is metered in premium
* credits. Surfaced wherever a user can start creating an automation.
*/
export function AutomationModelGateAlert({
searchSpaceId,
violations,
className,
}: AutomationModelGateAlertProps) {
if (violations.length === 0) return null;
return (
<Alert variant="warning" className={className}>
<Lock aria-hidden />
<AlertTitle>Automations need a premium or your own model</AlertTitle>
<AlertDescription>
<p>
Automations run unattended, so every run must use a premium model or your own (BYOK)
model. Update these in your model settings, then create your automation.
</p>
<ul className="mt-1 list-inside list-disc">
{violations.map((violation) => (
<li key={violation.kind}>
<Link
href={`/dashboard/${searchSpaceId}/search-space-settings/${SETTINGS_TAB}`}
className="font-medium text-foreground underline underline-offset-2 hover:no-underline"
>
{KIND_LABEL[violation.kind]}
</Link>
<span className="text-muted-foreground"> {violation.reason}</span>
</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}

View file

@ -2,10 +2,14 @@
import { MessageSquarePlus, SquarePen, Workflow } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import type { ModelEligibilityViolation } from "@/contracts/types/automation.types";
import { AutomationModelGateAlert } from "./automation-model-gate-alert";
interface AutomationsEmptyStateProps {
searchSpaceId: number;
canCreate: boolean;
/** Model slots that block creation (free/Auto). Empty when eligible. */
modelViolations?: ModelEligibilityViolation[];
}
/**
@ -14,7 +18,13 @@ interface AutomationsEmptyStateProps {
* "new automation" form. We surface the chat path explicitly so users
* don't go hunting for an "add" button that doesn't exist.
*/
export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) {
export function AutomationsEmptyState({
searchSpaceId,
canCreate,
modelViolations = [],
}: AutomationsEmptyStateProps) {
const modelsBlocked = modelViolations.length > 0;
return (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
@ -26,20 +36,26 @@ export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsE
SurfSense drafts the automation for your approval.
</p>
{canCreate ? (
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
<Button asChild>
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Create via chat
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<SquarePen className="mr-2 h-4 w-4" />
Create manually
</Link>
</Button>
</div>
modelsBlocked ? (
<div className="mt-6 mx-auto max-w-md text-left">
<AutomationModelGateAlert searchSpaceId={searchSpaceId} violations={modelViolations} />
</div>
) : (
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
<Button asChild>
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Create via chat
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<SquarePen className="mr-2 h-4 w-4" />
Create manually
</Link>
</Button>
</div>
)
) : (
<p className="mt-6 text-xs text-muted-foreground">
You don't have permission to create automations in this search space.

View file

@ -1,7 +1,9 @@
"use client";
import { MessageSquarePlus, SquarePen } from "lucide-react";
import Link from "next/link";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface AutomationsHeaderProps {
searchSpaceId: number;
@ -14,8 +16,18 @@ interface AutomationsHeaderProps {
* there to avoid a duplicate button.
*/
showCreateCta?: boolean;
/**
* Disable the create CTAs when the search space's models aren't billable
* for automations (free/Auto). When set, a tooltip explains why and the
* buttons render disabled rather than as links.
*/
createDisabled?: boolean;
disabledReason?: string;
}
const DEFAULT_DISABLED_REASON =
"Automations need a premium or your own (BYOK) model. Update your model settings to enable.";
/**
* Page header: title + count + "Create via chat" CTA. Creation is intent-driven
* (the create_automation tool runs inside chat with a HITL approval card), so
@ -27,6 +39,8 @@ export function AutomationsHeader({
loading,
canCreate,
showCreateCta = true,
createDisabled = false,
disabledReason,
}: AutomationsHeaderProps) {
return (
<div className="flex items-center justify-between gap-4 flex-wrap">
@ -40,20 +54,70 @@ export function AutomationsHeader({
</div>
{canCreate && showCreateCta && (
<div className="flex items-center gap-2">
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<SquarePen className="mr-2 h-4 w-4" />
Create manually
</Link>
</Button>
<Button asChild size="sm">
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Create via chat
</Link>
</Button>
{createDisabled ? (
<>
<DisabledCta
variant="outline"
icon={<SquarePen className="mr-2 h-4 w-4" />}
label="Create manually"
reason={disabledReason ?? DEFAULT_DISABLED_REASON}
/>
<DisabledCta
icon={<MessageSquarePlus className="mr-2 h-4 w-4" />}
label="Create via chat"
reason={disabledReason ?? DEFAULT_DISABLED_REASON}
/>
</>
) : (
<>
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<SquarePen className="mr-2 h-4 w-4" />
Create manually
</Link>
</Button>
<Button asChild size="sm">
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Create via chat
</Link>
</Button>
</>
)}
</div>
)}
</div>
);
}
function DisabledCta({
icon,
label,
reason,
variant,
}: {
icon: ReactNode;
label: string;
reason: string;
variant?: "outline";
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
{/* aria-disabled (not `disabled`) keeps the button focusable so the
tooltip is reachable by hover and keyboard; onClick is a no-op. */}
<Button
size="sm"
variant={variant}
aria-disabled
className="cursor-not-allowed opacity-50"
onClick={(event) => event.preventDefault()}
>
{icon}
{label}
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{reason}</TooltipContent>
</Tooltip>
);
}

View file

@ -15,6 +15,7 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
type Automation,
automationCreateRequest,
@ -45,6 +46,12 @@ interface AutomationBuilderFormProps {
searchSpaceId: number;
/** Required in edit mode; seeds the form and trigger reconciliation. */
automation?: Automation;
/**
* When set (create mode only), the search space's models aren't billable
* for automations. Submit is disabled with this reason as the tooltip; the
* orchestrator also renders the full gate alert above the form.
*/
submitDisabledReason?: string;
}
type Mode = "form" | "json";
@ -66,6 +73,7 @@ export function AutomationBuilderForm({
mode,
searchSpaceId,
automation,
submitDisabledReason,
}: AutomationBuilderFormProps) {
const router = useRouter();
const { mutateAsync: createAutomation } = useAtomValue(createAutomationMutationAtom);
@ -273,6 +281,8 @@ export function AutomationBuilderForm({
}
const submitLabel = mode === "edit" ? "Save changes" : "Create automation";
// Only gate creation; editing an existing automation isn't blocked here.
const submitBlocked = mode === "create" && !!submitDisabledReason;
return (
<div className="space-y-4">
@ -390,15 +400,39 @@ export function AutomationBuilderForm({
<Button asChild type="button" variant="ghost" size="sm">
<Link href={cancelHref}>Cancel</Link>
</Button>
<Button
type="button"
size="sm"
disabled={submitting}
onClick={() => (activeMode === "json" ? submitJson() : submitForm())}
>
{submitting ? <Spinner size="xs" className="mr-2" /> : <Save className="mr-2 h-4 w-4" />}
{submitLabel}
</Button>
{submitBlocked ? (
<Tooltip>
<TooltipTrigger asChild>
{/* aria-disabled keeps the button focusable so the tooltip is
reachable by hover and keyboard; onClick is a no-op. */}
<Button
type="button"
size="sm"
aria-disabled
className="cursor-not-allowed opacity-50"
onClick={(event) => event.preventDefault()}
>
<Save className="mr-2 h-4 w-4" />
{submitLabel}
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{submitDisabledReason}</TooltipContent>
</Tooltip>
) : (
<Button
type="button"
size="sm"
disabled={submitting}
onClick={() => (activeMode === "json" ? submitJson() : submitForm())}
>
{submitting ? (
<Spinner size="xs" className="mr-2" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{submitLabel}
</Button>
)}
</div>
</div>
);

View file

@ -1,5 +1,7 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { useAutomationModelEligibility } from "@/hooks/use-automation-model-eligibility";
import { AutomationModelGateAlert } from "../components/automation-model-gate-alert";
import { AutomationBuilderForm } from "../components/builder/automation-builder-form";
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
import { AutomationNewHeader } from "./components/automation-new-header";
@ -16,6 +18,9 @@ interface AutomationNewContentProps {
*/
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
const perms = useAutomationPermissions();
const { data: eligibility, isLoading: eligibilityLoading } = useAutomationModelEligibility(
perms.canCreate ? searchSpaceId : undefined
);
if (perms.loading) {
return <div className="h-32 rounded-md border border-border/60 bg-muted/10 animate-pulse" />;
@ -33,10 +38,22 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
);
}
const modelViolations = eligibility?.violations ?? [];
const submitDisabledReason = eligibilityLoading
? "Checking model eligibility…"
: modelViolations[0]?.reason;
return (
<>
<AutomationNewHeader searchSpaceId={searchSpaceId} />
<AutomationBuilderForm mode="create" searchSpaceId={searchSpaceId} />
{modelViolations.length > 0 && (
<AutomationModelGateAlert searchSpaceId={searchSpaceId} violations={modelViolations} />
)}
<AutomationBuilderForm
mode="create"
searchSpaceId={searchSpaceId}
submitDisabledReason={submitDisabledReason}
/>
</>
);
}