Merge pull request #1450 from MODSetter/dev_mod

feat: updated automations UI
This commit is contained in:
Rohan Verma 2026-05-29 20:35:50 -07:00 committed by GitHub
commit b1b51ada89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1155 additions and 307 deletions

View file

@ -32,8 +32,7 @@ from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated impo
)
from app.automations.schemas.api import AutomationCreate
from app.automations.services.automation import AutomationService
from app.automations.services.model_policy import get_automation_model_eligibility
from app.db import SearchSpace, User, async_session_maker
from app.db import User, async_session_maker
from app.utils.content_utils import extract_text_content
from .prompt import build_draft_prompt
@ -99,26 +98,11 @@ def create_create_automation_tool(
declined. Acknowledge once and stop do NOT retry or pitch
variants without a fresh user request.
"""
# --- 0. Eligibility gate (fail fast, before drafting + HITL) ---
# Automations may only use premium or BYOK models. Check up front so we
# don't make the user draft + approve a card that can't be saved.
async with async_session_maker() as session:
search_space = await session.get(SearchSpace, search_space_id)
if search_space is None:
return {
"status": "error",
"message": "search space not found in this session",
}
eligibility = get_automation_model_eligibility(search_space)
if not eligibility["allowed"]:
reasons = " ".join(v["reason"] for v in eligibility["violations"])
return {
"status": "error",
"message": (
f"{reasons} Update the search space's model settings to a "
"premium or your own (BYOK) model, then try again."
),
}
# Models are chosen per-automation on the approval card (premium/BYOK
# selectors) and validated when persisted by ``AutomationService.create``
# — so there's no fail-fast search-space eligibility gate here. The
# search space's current chat/role model selection no longer constrains
# whether an automation can be drafted or saved.
# --- 1. Draft via sub-LLM ---
prompt = build_draft_prompt(search_space_id=search_space_id, intent=intent)

View file

@ -22,6 +22,7 @@ from app.automations.schemas.definition.envelope import AutomationModels
from app.automations.services.model_policy import (
AutomationModelPolicyError,
assert_automation_models_billable,
assert_models_billable,
get_automation_model_eligibility,
)
from app.automations.triggers import get_trigger
@ -43,11 +44,18 @@ class AutomationService:
await self._authorize(
payload.search_space_id, Permission.AUTOMATIONS_CREATE.value
)
search_space = await self._assert_models_billable(payload.search_space_id)
# Snapshot the search space's current (already-validated) model prefs onto
# the definition so runs are insulated from later chat/search-space model
# changes. Captured ids are guaranteed billable by the check above.
# Capture the model profile onto the definition so runs are insulated
# from later chat/search-space model changes. Two sources:
# 1. Explicit per-automation selection in ``payload.definition.models``
# (manual builder + chat approval card). Validate the chosen ids.
# 2. Fallback (no selection): snapshot the search space's current prefs.
# Either way the captured ids are guaranteed billable (premium/BYOK).
selected_models = payload.definition.models
if selected_models is not None:
self._assert_selected_models_billable(selected_models)
else:
search_space = await self._assert_models_billable(payload.search_space_id)
payload.definition.models = AutomationModels(
agent_llm_id=search_space.agent_llm_id or 0,
image_generation_config_id=search_space.image_generation_config_id or 0,
@ -122,13 +130,22 @@ class AutomationService:
automation.status = data["status"]
if "definition" in data:
new_def = patch.definition.model_dump(mode="json", by_alias=True)
# Preserve the captured model snapshot across edits so a definition
# change never silently re-binds the automation to the current chat
# model selection. Backend-managed; survives whether or not the
# client round-trips ``models``.
# Model snapshot handling on edit:
# * absent in the patch -> preserve the captured snapshot
# (a non-model definition change never silently re-binds the
# automation to the current chat/search-space selection).
# * unchanged from the snapshot -> keep as-is, no re-validation
# (so editing an automation whose captured model later drifted
# out of premium isn't blocked by an unrelated name/schedule edit).
# * genuinely changed -> validate the new selection (422 on a
# non-billable pick), then accept it.
existing_models = (automation.definition or {}).get("models")
provided_models = new_def.get("models")
if provided_models is None:
if existing_models is not None:
new_def["models"] = existing_models
elif provided_models != existing_models:
self._assert_selected_models_billable(patch.definition.models)
automation.definition = new_def
automation.version += 1
@ -199,6 +216,22 @@ class AutomationService:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return search_space
def _assert_selected_models_billable(self, models: AutomationModels) -> None:
"""Reject creation when an explicitly selected model isn't billable.
Used when the client supplies ``definition.models`` (per-automation
selection from the builder or chat approval card). Same policy as the
search-space path: premium global or BYOK only, no free/Auto.
"""
try:
assert_models_billable(
agent_llm_id=models.agent_llm_id,
image_generation_config_id=models.image_generation_config_id,
vision_llm_config_id=models.vision_llm_config_id,
)
except AutomationModelPolicyError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
async def _authorize(self, search_space_id: int, permission: str) -> None:
await check_permission(
self.session,

View file

@ -16,7 +16,10 @@ from fastapi import HTTPException
import app.automations.services.automation as automation_mod
from app.automations.schemas.api import AutomationCreate, AutomationUpdate
from app.automations.schemas.definition.envelope import AutomationDefinition
from app.automations.schemas.definition.envelope import (
AutomationDefinition,
AutomationModels,
)
from app.automations.schemas.definition.plan_step import PlanStep
from app.automations.services.automation import AutomationService
from app.automations.services.model_policy import AutomationModelPolicyError
@ -175,6 +178,100 @@ async def test_create_treats_unset_prefs_as_auto_zero(
}
async def test_create_honors_selected_models_when_provided(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the payload carries ``definition.models`` they are validated + kept.
The search-space snapshot path is bypassed entirely (no
``assert_automation_models_billable`` call).
"""
def _fail_snapshot(_ss):
raise AssertionError("snapshot path should not run when models are provided")
monkeypatch.setattr(
automation_mod, "assert_automation_models_billable", _fail_snapshot
)
validated: dict[str, Any] = {}
def _assert_ok(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
validated["ids"] = (
agent_llm_id,
image_generation_config_id,
vision_llm_config_id,
)
monkeypatch.setattr(automation_mod, "assert_models_billable", _assert_ok)
async def _noop_authorize(self, *_a, **_k):
return None
async def _return_added(self, _aid):
return self.session.added[-1]
monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
service = _service(SimpleNamespace(agent_llm_id=-99))
payload = AutomationCreate(
search_space_id=1,
name="A",
definition=_definition(
models=AutomationModels(
agent_llm_id=-1,
image_generation_config_id=7,
vision_llm_config_id=-2,
)
),
)
automation = await service.create(payload)
assert validated["ids"] == (-1, 7, -2)
assert automation.definition["models"] == {
"agent_llm_id": -1,
"image_generation_config_id": 7,
"vision_llm_config_id": -2,
}
async def test_create_rejects_unbillable_selected_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A non-billable explicit selection maps the policy error to HTTP 422."""
def _raise(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
raise AutomationModelPolicyError(
[{"kind": "llm", "config_id": -3, "reason": "free model"}]
)
monkeypatch.setattr(automation_mod, "assert_models_billable", _raise)
async def _noop_authorize(self, *_a, **_k):
return None
monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
service = _service(SimpleNamespace(agent_llm_id=-3))
payload = AutomationCreate(
search_space_id=1,
name="A",
definition=_definition(
models=AutomationModels(
agent_llm_id=-3,
image_generation_config_id=7,
vision_llm_config_id=-2,
)
),
)
with pytest.raises(HTTPException) as exc_info:
await service.create(payload)
assert exc_info.value.status_code == 422
async def test_update_preserves_captured_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -211,6 +308,166 @@ async def test_update_preserves_captured_models(
assert result.version == 4
async def test_update_honors_changed_models_when_valid(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A definition edit with a *changed* models block validates + keeps it."""
existing = SimpleNamespace(
search_space_id=1,
definition={
"name": "A",
"plan": [],
"models": {
"agent_llm_id": -1,
"image_generation_config_id": 5,
"vision_llm_config_id": -1,
},
},
version=3,
)
validated: dict[str, Any] = {}
def _assert_ok(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
validated["ids"] = (
agent_llm_id,
image_generation_config_id,
vision_llm_config_id,
)
monkeypatch.setattr(automation_mod, "assert_models_billable", _assert_ok)
async def _noop_authorize(self, *_a, **_k):
return None
async def _return_existing(self, _aid):
return existing
monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
monkeypatch.setattr(
AutomationService, "_get_with_triggers_or_raise", _return_existing
)
service = _service(SimpleNamespace())
patch = AutomationUpdate(
definition=_definition(
models=AutomationModels(
agent_llm_id=-2,
image_generation_config_id=9,
vision_llm_config_id=-2,
)
)
)
result = await service.update(7, patch)
assert validated["ids"] == (-2, 9, -2)
assert result.definition["models"] == {
"agent_llm_id": -2,
"image_generation_config_id": 9,
"vision_llm_config_id": -2,
}
assert result.version == 4
async def test_update_rejects_changed_unbillable_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A *changed* non-billable models block is rejected with HTTP 422."""
existing = SimpleNamespace(
search_space_id=1,
definition={
"name": "A",
"plan": [],
"models": {
"agent_llm_id": -1,
"image_generation_config_id": 5,
"vision_llm_config_id": -1,
},
},
version=3,
)
def _raise(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
raise AutomationModelPolicyError(
[{"kind": "llm", "config_id": -7, "reason": "free model"}]
)
monkeypatch.setattr(automation_mod, "assert_models_billable", _raise)
async def _noop_authorize(self, *_a, **_k):
return None
async def _return_existing(self, _aid):
return existing
monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
monkeypatch.setattr(
AutomationService, "_get_with_triggers_or_raise", _return_existing
)
service = _service(SimpleNamespace())
patch = AutomationUpdate(
definition=_definition(
models=AutomationModels(
agent_llm_id=-7,
image_generation_config_id=5,
vision_llm_config_id=-1,
)
)
)
with pytest.raises(HTTPException) as exc_info:
await service.update(7, patch)
assert exc_info.value.status_code == 422
async def test_update_keeps_unchanged_models_without_revalidation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An unchanged models block is kept as-is and is NOT re-validated.
Lets users edit an automation whose captured model later drifted out of
premium without an unrelated edit tripping the policy check.
"""
captured = {
"agent_llm_id": -1,
"image_generation_config_id": 5,
"vision_llm_config_id": -1,
}
existing = SimpleNamespace(
search_space_id=1,
definition={"name": "A", "plan": [], "models": captured},
version=3,
)
def _fail(*_a, **_k):
raise AssertionError("unchanged models must not be re-validated")
monkeypatch.setattr(automation_mod, "assert_models_billable", _fail)
async def _noop_authorize(self, *_a, **_k):
return None
async def _return_existing(self, _aid):
return existing
monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
monkeypatch.setattr(
AutomationService, "_get_with_triggers_or_raise", _return_existing
)
service = _service(SimpleNamespace())
patch = AutomationUpdate(
definition=_definition(models=AutomationModels(**captured))
)
result = await service.update(7, patch)
assert result.definition["models"] == captured
assert result.version == 4
async def test_model_eligibility_authorizes_and_returns_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -1,8 +1,24 @@
"use client";
import { AlertCircle, FileOutput, GitCommitHorizontal, Package, Settings2 } from "lucide-react";
import {
AlertCircle,
ChevronDown,
FileOutput,
GitCommitHorizontal,
Package,
Settings2,
} from "lucide-react";
import { useState } from "react";
import { JsonView } from "@/components/json-view";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import type { RunStepResult } from "@/contracts/types/automation.types";
import { useAutomationRun } from "@/hooks/use-automation-runs";
import { cn } from "@/lib/utils";
import { RunStepResultCard } from "./run-step-result-card";
interface RunDetailsPanelProps {
automationId: number;
@ -13,10 +29,11 @@ interface RunDetailsPanelProps {
* Expanded view of a single run. Fetches lazily the parent only renders
* this once the row is opened, so the list view stays cheap.
*
* We surface the four most actionable sections (error first when present,
* then output, step results, artifacts, inputs). The full
* ``definition_snapshot`` is omitted because it usually mirrors the live
* definition surfacing it would dominate the panel without informing
* We surface the run outcome readably: a run-level error first (when
* present), then per-step cards that render the agent's markdown
* ``final_message`` directly, and finally the structural artifacts/inputs.
* The full ``definition_snapshot`` is omitted because it usually mirrors the
* live definition surfacing it would dominate the panel without informing
* what the user is trying to learn ("did this work? what did it do?").
*/
export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
@ -24,7 +41,7 @@ export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
if (isLoading) {
return (
<div className="space-y-3 p-4 bg-muted/20 border-t border-border/60">
<div className="flex flex-col gap-3 border-t border-border/60 bg-muted/20 p-4">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-24 w-full" />
</div>
@ -33,53 +50,104 @@ export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
if (error || !run) {
return (
<div className="p-4 bg-muted/20 border-t border-border/60 text-xs text-muted-foreground">
<div className="border-t border-border/60 bg-muted/20 p-4 text-xs text-muted-foreground">
Couldn't load run details{error?.message ? `: ${error.message}` : "."}
</div>
);
}
const hasError = run.error && Object.keys(run.error).length > 0;
const runError = run.error && Object.keys(run.error).length > 0 ? run.error : null;
const hasOutput = run.output && Object.keys(run.output).length > 0;
const hasInputs = Object.keys(run.inputs ?? {}).length > 0;
const steps = run.step_results as RunStepResult[];
const hasDiagnostics = run.artifacts.length > 0 || hasInputs;
return (
<div className="space-y-4 p-4 bg-muted/20 border-t border-border/60">
{hasError && (
<Section icon={AlertCircle} label="Error" tone="destructive">
<JsonBlock value={run.error} />
</Section>
)}
<div className="flex flex-col gap-4 border-t border-border/60 bg-muted/20 p-4">
{runError ? <RunErrorSection error={runError} /> : null}
{hasOutput && (
{hasOutput ? (
<Section icon={FileOutput} label="Output">
<JsonBlock value={run.output} />
</Section>
)}
) : null}
<Section icon={GitCommitHorizontal} label={`Step results · ${run.step_results.length}`}>
{run.step_results.length === 0 ? (
<Section icon={GitCommitHorizontal} label={`Step results · ${steps.length}`}>
{steps.length === 0 ? (
<p className="text-xs text-muted-foreground">No steps recorded.</p>
) : (
<JsonBlock value={run.step_results} />
<div className="flex flex-col gap-2">
{steps.map((step, index) => (
<RunStepResultCard key={step.step_id ?? index} step={step} />
))}
</div>
)}
</Section>
{run.artifacts.length > 0 && (
{hasDiagnostics ? <Separator className="bg-border/60" /> : null}
{run.artifacts.length > 0 ? (
<Section icon={Package} label={`Artifacts · ${run.artifacts.length}`}>
<JsonBlock value={run.artifacts} />
</Section>
)}
) : null}
{hasInputs && (
{hasInputs ? (
<Section icon={Settings2} label="Resolved inputs">
<JsonBlock value={run.inputs} />
</Section>
)}
) : null}
</div>
);
}
/**
* Run-level error: a readable destructive alert when a message is present,
* with the full structured error available behind a raw toggle.
*/
function RunErrorSection({ error }: { error: Record<string, unknown> }) {
const [rawOpen, setRawOpen] = useState(false);
const message = typeof error.message === "string" ? error.message : null;
const type = typeof error.type === "string" ? error.type : "Run failed";
return (
<Section icon={AlertCircle} label="Error" tone="destructive">
{message ? (
<Alert variant="destructive">
<AlertCircle aria-hidden />
<AlertTitle>{type}</AlertTitle>
<AlertDescription className="wrap-break-word">{message}</AlertDescription>
</Alert>
) : null}
<Collapsible open={rawOpen} onOpenChange={setRawOpen} className="mt-2">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-fit px-2 text-xs text-muted-foreground"
aria-expanded={rawOpen}
>
<ChevronDown
className={cn(
"transition-transform motion-reduce:transition-none",
rawOpen && "rotate-180"
)}
aria-hidden
/>
{rawOpen ? "Hide raw" : "View raw"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="mt-2 max-h-64 rounded-md bg-muted/40 px-3 py-2">
<JsonView src={error} collapsed={1} />
</ScrollArea>
</CollapsibleContent>
</Collapsible>
</Section>
);
}
function Section({
icon: Icon,
label,
@ -92,15 +160,14 @@ function Section({
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<div className="flex flex-col gap-1.5">
<div
className={
tone === "destructive"
? "flex items-center gap-1.5 text-[11px] font-medium text-destructive uppercase tracking-wider"
: "flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wider"
}
className={cn(
"flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider",
tone === "destructive" ? "text-destructive" : "text-muted-foreground"
)}
>
<Icon className="h-3 w-3" aria-hidden />
<Icon className="size-3" aria-hidden />
{label}
</div>
{children}
@ -110,8 +177,8 @@ function Section({
function JsonBlock({ value }: { value: unknown }) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2 max-h-64 overflow-auto">
<ScrollArea className="max-h-64 rounded-md bg-muted/40 px-3 py-2">
<JsonView src={value} collapsed={1} />
</div>
</ScrollArea>
);
}

View file

@ -2,6 +2,7 @@
import { ChevronDown, ChevronRight, Hand } from "lucide-react";
import { useState } from "react";
import type { RunSummary } from "@/contracts/types/automation.types";
import { formatDuration } from "@/lib/automations/run-duration";
import { formatRelativeDate } from "@/lib/format-date";
import { RunDetailsPanel } from "./run-details-panel";
import { RunStatusBadge } from "./run-status-badge";
@ -18,7 +19,7 @@ interface RunRowProps {
*/
export function RunRow({ run, automationId }: RunRowProps) {
const [open, setOpen] = useState(false);
const duration = computeDuration(run.started_at, run.finished_at);
const duration = formatDuration(run.started_at, run.finished_at);
const startedLabel = run.started_at
? formatRelativeDate(run.started_at)
: formatRelativeDate(run.created_at);
@ -62,14 +63,3 @@ function TriggerSource({ triggerId }: { triggerId: number | null }) {
}
return <span>via trigger #{triggerId}</span>;
}
function computeDuration(started: string | null | undefined, finished: string | null | undefined) {
if (!started || !finished) return null;
const ms = new Date(finished).getTime() - new Date(started).getTime();
if (!Number.isFinite(ms) || ms < 0) return null;
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}

View file

@ -0,0 +1,123 @@
"use client";
import { CheckCircle2, ChevronDown, MinusCircle, XCircle } from "lucide-react";
import { memo, useState } from "react";
import { JsonView } from "@/components/json-view";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { RunStepResult } from "@/contracts/types/automation.types";
import { formatDuration } from "@/lib/automations/run-duration";
import { cn } from "@/lib/utils";
type BadgeVariant = React.ComponentProps<typeof Badge>["variant"];
const STATUS_BADGE: Record<
string,
{ label: string; variant: BadgeVariant; icon: typeof CheckCircle2 }
> = {
succeeded: { label: "Succeeded", variant: "outline", icon: CheckCircle2 },
failed: { label: "Failed", variant: "destructive", icon: XCircle },
skipped: { label: "Skipped", variant: "secondary", icon: MinusCircle },
};
function StepStatusBadge({ status }: { status: string }) {
const meta = STATUS_BADGE[status] ?? {
label: status,
variant: "outline" as const,
icon: MinusCircle,
};
const Icon = meta.icon;
return (
<Badge variant={meta.variant} className="shrink-0">
<Icon aria-hidden />
{meta.label}
</Badge>
);
}
/**
* One step from a run's ``step_results``. Surfaces the agent's markdown
* ``final_message`` first-class (rendered, not raw), shows step errors as a
* readable alert, and keeps the full structured payload behind a "View raw"
* collapsible escape hatch.
*/
export const RunStepResultCard = memo(function RunStepResultCard({
step,
}: {
step: RunStepResult;
}) {
const [rawOpen, setRawOpen] = useState(false);
const duration = formatDuration(step.started_at, step.finished_at);
const attempts = step.attempts ?? 0;
const finalMessage =
typeof step.result?.final_message === "string" ? step.result.final_message : null;
const errorMessage = step.error?.message;
const hasMeta = Boolean(duration) || attempts > 1;
return (
<Card className="border-border/60 shadow-none">
<CardHeader className="gap-2 space-y-0 p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs font-medium">{step.action}</span>
<span className="truncate text-xs text-muted-foreground">{step.step_id}</span>
</div>
<StepStatusBadge status={step.status} />
</div>
{hasMeta ? (
<div className="flex items-center gap-3 text-[11px] text-muted-foreground tabular-nums">
{duration ? <span>{duration}</span> : null}
{attempts > 1 ? <span>{attempts} attempts</span> : null}
</div>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-3 p-3 pt-0">
{errorMessage ? (
<Alert variant="destructive">
<XCircle aria-hidden />
<AlertTitle>{step.error?.type ?? "Error"}</AlertTitle>
<AlertDescription className="wrap-break-word">{errorMessage}</AlertDescription>
</Alert>
) : null}
{finalMessage ? (
<div className="min-w-0 wrap-break-word rounded-md border border-border/60 bg-background px-3 py-2">
<MarkdownViewer content={finalMessage} />
</div>
) : null}
<Collapsible open={rawOpen} onOpenChange={setRawOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-fit px-2 text-xs text-muted-foreground"
aria-expanded={rawOpen}
>
<ChevronDown
className={cn(
"transition-transform motion-reduce:transition-none",
rawOpen && "rotate-180"
)}
aria-hidden
/>
{rawOpen ? "Hide raw" : "View raw"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="mt-2 max-h-64 rounded-md bg-muted/40 px-3 py-2">
<JsonView src={step} collapsed={1} />
</ScrollArea>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
});

View file

@ -1,8 +1,6 @@
"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";
@ -24,18 +22,6 @@ 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.
@ -91,11 +77,7 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
canCreate={perms.canCreate}
showCreateCta={false}
/>
<AutomationsEmptyState
searchSpaceId={searchSpaceId}
canCreate={perms.canCreate}
modelViolations={modelViolations}
/>
<AutomationsEmptyState searchSpaceId={searchSpaceId} canCreate={perms.canCreate} />
</>
);
}
@ -107,12 +89,7 @@ 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

@ -1,61 +0,0 @@
"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,14 +2,10 @@
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[];
}
/**
@ -18,13 +14,7 @@ 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,
modelViolations = [],
}: AutomationsEmptyStateProps) {
const modelsBlocked = modelViolations.length > 0;
export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) {
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">
@ -36,11 +26,6 @@ export function AutomationsEmptyState({
SurfSense drafts the automation for your approval.
</p>
{canCreate ? (
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`}>
@ -55,7 +40,6 @@ export function AutomationsEmptyState({
</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,9 +1,7 @@
"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;
@ -16,22 +14,13 @@ 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
* the CTA links to a new chat rather than opening a form.
* the CTA links to a new chat rather than opening a form. Model eligibility is
* handled per-automation in the builder + approval card, not gated here.
*/
export function AutomationsHeader({
searchSpaceId,
@ -39,8 +28,6 @@ export function AutomationsHeader({
loading,
canCreate,
showCreateCta = true,
createDisabled = false,
disabledReason,
}: AutomationsHeaderProps) {
return (
<div className="flex items-center justify-between gap-4 flex-wrap">
@ -54,22 +41,6 @@ export function AutomationsHeader({
</div>
{canCreate && showCreateCta && (
<div className="flex items-center gap-2">
{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" />
@ -82,42 +53,8 @@ export function AutomationsHeader({
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

@ -21,8 +21,10 @@ import {
automationCreateRequest,
automationUpdateRequest,
} from "@/contracts/types/automation.types";
import { useAutomationEligibleModels } from "@/hooks/use-automation-eligible-models";
import {
type BuilderForm,
type BuilderModels,
buildCreatePayload,
builderFormSchema,
buildScheduleTrigger,
@ -30,10 +32,12 @@ import {
createEmptyForm,
formFromAutomation,
type HydratableTrigger,
hasResolvedModels,
hydrateForm,
} from "@/lib/automations/builder-schema";
import { cn } from "@/lib/utils";
import { AdvancedSection } from "./advanced-section";
import { AutomationModelFields } from "./automation-model-fields";
import { BasicsSection } from "./basics-section";
import { BuilderSummary } from "./builder-summary";
import { JsonModePanel } from "./json-mode-panel";
@ -47,9 +51,9 @@ interface AutomationBuilderFormProps {
/** 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.
* Optional extra create-mode block reason (composed with the form's own
* model-eligibility gate). Shown as the submit button's tooltip. Model
* eligibility itself is now owned by the in-form pickers.
*/
submitDisabledReason?: string;
}
@ -117,15 +121,46 @@ export function AutomationBuilderForm({
? `/dashboard/${searchSpaceId}/automations/${automation.id}`
: `/dashboard/${searchSpaceId}/automations`;
// Eligible models + the search-space-seeded defaults. Models are chosen per
// automation on create; in edit mode the backend preserves the captured
// snapshot, so the picker is create-only.
const eligibleModels = useAutomationEligibleModels();
// Resolve each slot during render: an explicit (non-zero) pick wins,
// otherwise fall back to the eligible default. No effect copies async hook
// data into state, so there's no flicker/loop and the user's pick is sticky.
const resolvedModels = useMemo<BuilderModels>(
() => ({
agentLlmId: form.models.agentLlmId || eligibleModels.llm.defaultId || 0,
imageConfigId: form.models.imageConfigId || eligibleModels.image.defaultId || 0,
visionConfigId: form.models.visionConfigId || eligibleModels.vision.defaultId || 0,
}),
[
form.models,
eligibleModels.llm.defaultId,
eligibleModels.image.defaultId,
eligibleModels.vision.defaultId,
]
);
// The form with resolved models folded in — what every payload builder reads.
const formForPayload = useMemo<BuilderForm>(
() => ({ ...form, models: resolvedModels }),
[form, resolvedModels]
);
function patchForm(patch: Partial<BuilderForm>) {
setForm((prev) => ({ ...prev, ...patch }));
}
function jsonFromCurrentForm(): Record<string, unknown> {
if (mode === "edit" && automation) {
return { ...buildUpdatePayload(form), status: automation.status };
return { ...buildUpdatePayload(formForPayload), status: automation.status };
}
const { search_space_id: _ignored, ...rest } = buildCreatePayload(form, searchSpaceId);
const { search_space_id: _ignored, ...rest } = buildCreatePayload(
formForPayload,
searchSpaceId
);
return rest;
}
@ -223,7 +258,7 @@ export function AutomationBuilderForm({
setSubmitting(true);
try {
if (mode === "edit" && automation) {
const payload = buildUpdatePayload(form);
const payload = buildUpdatePayload(formForPayload);
const parsed = automationUpdateRequest.safeParse(payload);
if (!parsed.success) {
setRootError(zodIssueList(parsed.error).join("; "));
@ -233,7 +268,7 @@ export function AutomationBuilderForm({
await reconcileTriggers(automation.id);
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
} else {
const payload = buildCreatePayload(form, searchSpaceId);
const payload = buildCreatePayload(formForPayload, searchSpaceId);
const parsed = automationCreateRequest.safeParse(payload);
if (!parsed.success) {
setRootError(zodIssueList(parsed.error).join("; "));
@ -281,8 +316,18 @@ export function AutomationBuilderForm({
}
const submitLabel = mode === "edit" ? "Save changes" : "Create automation";
// Block creation until every model slot resolves to an eligible id. The
// per-field Alert already explains *why* a slot is empty; this just guards
// submit. `submitDisabledReason` (from the caller) still composes in.
const modelsUnresolved =
mode === "create" && !eligibleModels.isLoading && !hasResolvedModels(resolvedModels);
const effectiveDisabledReason =
submitDisabledReason ??
(modelsUnresolved
? "Set up a premium or your own (BYOK) agent, image, and vision model in role settings before creating an automation."
: undefined);
// Only gate creation; editing an existing automation isn't blocked here.
const submitBlocked = mode === "create" && !!submitDisabledReason;
const submitBlocked = mode === "create" && !!effectiveDisabledReason;
return (
<div className="space-y-4">
@ -364,6 +409,19 @@ export function AutomationBuilderForm({
</CardContent>
</Card>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Models</CardTitle>
</CardHeader>
<CardContent>
<AutomationModelFields
searchSpaceId={searchSpaceId}
value={resolvedModels}
onChange={(patch) => patchForm({ models: { ...form.models, ...patch } })}
/>
</CardContent>
</Card>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Settings</CardTitle>
@ -416,7 +474,7 @@ export function AutomationBuilderForm({
{submitLabel}
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{submitDisabledReason}</TooltipContent>
<TooltipContent className="max-w-xs">{effectiveDisabledReason}</TooltipContent>
</Tooltip>
) : (
<Button

View file

@ -0,0 +1,198 @@
"use client";
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
import { memo, useId } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
type EligibleModelKind,
type EligibleModelOption,
useAutomationEligibleModels,
} from "@/hooks/use-automation-eligible-models";
import { getProviderIcon } from "@/lib/provider-icons";
import { Field } from "./form-field";
export interface AutomationModelSelection {
agentLlmId: number;
imageConfigId: number;
visionConfigId: number;
}
interface AutomationModelFieldsProps {
/** Resolved (effective) ids — never `0` once defaults are seeded. */
value: AutomationModelSelection;
onChange: (patch: Partial<AutomationModelSelection>) => void;
searchSpaceId: number;
errors?: Partial<Record<keyof AutomationModelSelection, string>>;
}
/**
* Three eligible-only model pickers (Agent LLM / Image / Vision) for the
* automation builder + chat approval card. Options come from
* {@link useAutomationEligibleModels} (premium globals + BYOK only); selection
* is validated + snapshotted onto `definition.models` at create time.
*/
export function AutomationModelFields({
value,
onChange,
searchSpaceId,
errors,
}: AutomationModelFieldsProps) {
const { llm, image, vision, isLoading } = useAutomationEligibleModels();
const rolesHref = `/dashboard/${searchSpaceId}/search-space-settings/roles`;
return (
<div className="flex flex-col gap-4">
<ModelSelectField
label="Agent model"
kind={llm}
value={value.agentLlmId}
isLoading={isLoading}
rolesHref={rolesHref}
error={errors?.agentLlmId}
onChange={(id) => onChange({ agentLlmId: id })}
/>
<ModelSelectField
label="Image model"
kind={image}
value={value.imageConfigId}
isLoading={isLoading}
rolesHref={rolesHref}
error={errors?.imageConfigId}
onChange={(id) => onChange({ imageConfigId: id })}
/>
<ModelSelectField
label="Vision model"
kind={vision}
value={value.visionConfigId}
isLoading={isLoading}
rolesHref={rolesHref}
error={errors?.visionConfigId}
onChange={(id) => onChange({ visionConfigId: id })}
/>
</div>
);
}
interface ModelSelectFieldProps {
label: string;
kind: EligibleModelKind;
value: number;
isLoading: boolean;
rolesHref: string;
error?: string;
onChange: (id: number) => void;
}
const ModelSelectField = memo(function ModelSelectField({
label,
kind,
value,
isLoading,
rolesHref,
error,
onChange,
}: ModelSelectFieldProps) {
const triggerId = useId();
if (isLoading) {
return (
<Field label={label}>
<Skeleton className="h-9 w-full" />
</Field>
);
}
if (kind.options.length === 0) {
return (
<Field label={label}>
<Alert>
<TriangleAlert aria-hidden />
<AlertTitle>No eligible models</AlertTitle>
<AlertDescription>
Automations need a premium or your own (BYOK) model. Set one up in{" "}
<Link href={rolesHref} className="font-medium underline underline-offset-2">
role settings
</Link>
.
</AlertDescription>
</Alert>
</Field>
);
}
const premium = kind.options.filter((o) => !o.isBYOK);
const byok = kind.options.filter((o) => o.isBYOK);
const selected = value ? kind.byId.get(value) : undefined;
return (
<Field label={label} htmlFor={triggerId} error={error}>
<Select value={value ? String(value) : undefined} onValueChange={(v) => onChange(Number(v))}>
<SelectTrigger
id={triggerId}
aria-label={label}
aria-invalid={error ? true : undefined}
className="w-full"
>
{selected ? (
<span className="flex items-center gap-2">
{getProviderIcon(selected.provider)}
<span className="truncate">{selected.name}</span>
</span>
) : (
<SelectValue placeholder="Select a model" />
)}
</SelectTrigger>
<SelectContent>
{premium.length > 0 ? (
<SelectGroup>
<SelectLabel>Premium</SelectLabel>
{premium.map((option) => (
<ModelOption key={option.id} option={option} badge="Premium" />
))}
</SelectGroup>
) : null}
{premium.length > 0 && byok.length > 0 ? <SelectSeparator /> : null}
{byok.length > 0 ? (
<SelectGroup>
<SelectLabel>Your models</SelectLabel>
{byok.map((option) => (
<ModelOption key={option.id} option={option} badge="BYOK" />
))}
</SelectGroup>
) : null}
</SelectContent>
</Select>
</Field>
);
});
function ModelOption({
option,
badge,
}: {
option: EligibleModelOption;
badge: "Premium" | "BYOK";
}) {
return (
<SelectItem value={String(option.id)} description={option.modelName}>
<span className="flex items-center gap-2">
{getProviderIcon(option.provider)}
<span className="truncate">{option.name}</span>
<Badge variant={badge === "Premium" ? "secondary" : "outline"}>{badge}</Badge>
</span>
</SelectItem>
);
}

View file

@ -1,7 +1,5 @@
"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";
@ -15,12 +13,13 @@ interface AutomationNewContentProps {
* who can't create don't even see the form; same panel as the detail page's
* access-denied state for consistency. The builder defaults to the friendly
* form with a raw-JSON escape hatch.
*
* Model eligibility is no longer gated here the builder's own model pickers
* list eligible (premium/BYOK) models, surface a per-slot notice when none
* exist, and block submit until each slot resolves.
*/
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" />;
@ -38,22 +37,10 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
);
}
const modelViolations = eligibility?.violations ?? [];
const submitDisabledReason = eligibilityLoading
? "Checking model eligibility…"
: modelViolations[0]?.reason;
return (
<>
<AutomationNewHeader searchSpaceId={searchSpaceId} />
{modelViolations.length > 0 && (
<AutomationModelGateAlert searchSpaceId={searchSpaceId} violations={modelViolations} />
)}
<AutomationBuilderForm
mode="create"
searchSpaceId={searchSpaceId}
submitDisabledReason={submitDisabledReason}
/>
<AutomationBuilderForm mode="create" searchSpaceId={searchSpaceId} />
</>
);
}

View file

@ -5,6 +5,10 @@ import { useAtomValue } from "jotai";
import { AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil, Workflow } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AutomationModelFields,
type AutomationModelSelection,
} from "@/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { JsonView } from "@/components/json-view";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
@ -12,6 +16,7 @@ import { Button } from "@/components/ui/button";
import { automationCreateRequest } from "@/contracts/types/automation.types";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
import { useAutomationEligibleModels } from "@/hooks/use-automation-eligible-models";
import { AutomationDraftPreview } from "./automation-draft-preview";
const editArgsSchema = automationCreateRequest.omit({ search_space_id: true });
@ -94,17 +99,71 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
const effectiveArgs = pendingEdits ?? args;
const draft = useMemo(() => extractDraft(effectiveArgs), [effectiveArgs]);
// Per-automation model selection. The card always supplies models (chosen
// here, not snapshotted from the search space), so Approve dispatches an
// `edit` decision carrying `definition.models`.
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const eligibleModels = useAutomationEligibleModels();
const [modelSelection, setModelSelection] = useState<AutomationModelSelection>({
agentLlmId: 0,
imageConfigId: 0,
visionConfigId: 0,
});
// Resolve each slot during render: an explicit pick wins, else the eligible
// default. No effect seeds async hook data into state.
const resolvedModels = useMemo<AutomationModelSelection>(
() => ({
agentLlmId: modelSelection.agentLlmId || eligibleModels.llm.defaultId || 0,
imageConfigId: modelSelection.imageConfigId || eligibleModels.image.defaultId || 0,
visionConfigId: modelSelection.visionConfigId || eligibleModels.vision.defaultId || 0,
}),
[
modelSelection,
eligibleModels.llm.defaultId,
eligibleModels.image.defaultId,
eligibleModels.vision.defaultId,
]
);
const modelsResolved =
resolvedModels.agentLlmId !== 0 &&
resolvedModels.imageConfigId !== 0 &&
resolvedModels.visionConfigId !== 0;
const handleApprove = useCallback(() => {
if (phase !== "pending" || !canApprove || isEditing) return;
if (phase !== "pending" || !canApprove || isEditing || !modelsResolved) return;
setProcessing();
const baseArgs = pendingEdits ?? args;
const baseDefinition = (baseArgs.definition ?? {}) as Record<string, unknown>;
const mergedArgs = {
...baseArgs,
definition: {
...baseDefinition,
models: {
agent_llm_id: resolvedModels.agentLlmId,
image_generation_config_id: resolvedModels.imageConfigId,
vision_llm_config_id: resolvedModels.visionConfigId,
},
},
};
onDecision({
type: pendingEdits ? "edit" : "approve",
type: "edit",
edited_action: {
name: interruptData.action_requests[0]?.name ?? "create_automation",
args: pendingEdits ?? args,
args: mergedArgs,
},
});
}, [phase, canApprove, isEditing, setProcessing, onDecision, interruptData, args, pendingEdits]);
}, [
phase,
canApprove,
isEditing,
modelsResolved,
setProcessing,
onDecision,
interruptData,
args,
pendingEdits,
resolvedModels,
]);
const handleReject = useCallback(() => {
if (phase !== "pending" || !canReject || isEditing) return;
@ -193,10 +252,24 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
{phase === "pending" && !isEditing && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="mb-3 text-xs font-medium text-foreground">Models</p>
<AutomationModelFields
searchSpaceId={Number(searchSpaceId)}
value={resolvedModels}
onChange={(patch) => setModelSelection((prev) => ({ ...prev, ...patch }))}
/>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
{canApprove && (
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
<Button
size="sm"
className="rounded-lg gap-1.5"
disabled={!modelsResolved}
onClick={handleApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>

View file

@ -190,6 +190,31 @@ export const run = runSummary.extend({
});
export type Run = z.infer<typeof run>;
/**
* Typed view over a single entry in {@link Run.step_results}. The Zod schema
* keeps step results as opaque records (the backend emits action-specific
* payloads), so this interface exists purely for safe field access in the UI
* and does not perform runtime validation.
*
* Mirrors `_result()` in
* `surfsense_backend/app/automations/runtime/step.py`. For the `agent_task`
* action, `result` carries the markdown `final_message` produced by the agent.
*/
export interface RunStepResult {
step_id: string;
action: string;
status: "succeeded" | "failed" | "skipped" | string;
started_at?: string;
finished_at?: string;
attempts?: number;
result?: {
final_message?: string;
agent_session_id?: string;
resumes?: unknown;
} & Record<string, unknown>;
error?: { message?: string; type?: string };
}
export const runListResponse = z.object({
items: z.array(runSummary),
total: z.number(),

View file

@ -0,0 +1,150 @@
"use client";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import {
globalImageGenConfigsAtom,
imageGenConfigsAtom,
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import {
globalVisionLLMConfigsAtom,
visionLLMConfigsAtom,
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
/**
* A single model the user may pick for an automation slot.
*/
export interface EligibleModelOption {
id: number;
name: string;
/** Underlying model identifier (e.g. `gpt-4o`); shown as secondary text. */
modelName: string;
provider: string;
/** `true` for user BYOK configs (positive ids), `false` for premium globals. */
isBYOK: boolean;
}
export interface EligibleModelKind {
options: EligibleModelOption[];
/** Default selection: the search-space pref when eligible, else first option. */
defaultId: number | null;
/** O(1) id → option lookup for trigger labels (avoids per-render `.find()`). */
byId: Map<number, EligibleModelOption>;
}
export interface AutomationEligibleModels {
llm: EligibleModelKind;
image: EligibleModelKind;
vision: EligibleModelKind;
isLoading: boolean;
}
interface GlobalConfigLike {
id: number;
name: string;
model_name: string;
provider: string;
is_premium?: boolean;
is_auto_mode?: boolean;
}
interface UserConfigLike {
id: number;
name: string;
model_name: string;
provider: string;
}
/**
* Build the eligible option list for one model kind: premium globals
* (`is_premium === true`, never Auto mode) followed by all BYOK configs.
*/
function buildKind(
globals: GlobalConfigLike[] | undefined,
byok: UserConfigLike[] | undefined,
prefId: number | null | undefined
): EligibleModelKind {
const premiumGlobals: EligibleModelOption[] = (globals ?? [])
.filter((c) => c.is_premium === true && !c.is_auto_mode)
.map((c) => ({
id: c.id,
name: c.name,
modelName: c.model_name,
provider: c.provider,
isBYOK: false,
}));
const byokOptions: EligibleModelOption[] = (byok ?? []).map((c) => ({
id: c.id,
name: c.name,
modelName: c.model_name,
provider: c.provider,
isBYOK: true,
}));
const options = [...premiumGlobals, ...byokOptions];
const byId = new Map<number, EligibleModelOption>(options.map((o) => [o.id, o]));
let defaultId: number | null = null;
if (prefId != null && byId.has(prefId)) {
defaultId = prefId;
} else if (options.length > 0) {
defaultId = options[0].id;
}
return { options, defaultId, byId };
}
/**
* Lists the LLM / image / vision models that are eligible for automations
* (premium globals + user BYOK never free globals or Auto mode), with a
* default selection seeded from the search space's role preferences.
*
* Everything is derived during render from the existing config query atoms;
* there are no effects, so option lists/maps keep stable references.
*/
export function useAutomationEligibleModels(): AutomationEligibleModels {
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
const { data: llmGlobalConfigs, isLoading: llmGlobalLoading } =
useAtomValue(globalNewLLMConfigsAtom);
const { data: preferences, isLoading: prefsLoading } = useAtomValue(llmPreferencesAtom);
const { data: imageGlobalConfigs, isLoading: imageGlobalLoading } =
useAtomValue(globalImageGenConfigsAtom);
const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom);
const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = useAtomValue(
globalVisionLLMConfigsAtom
);
const { data: visionUserConfigs, isLoading: visionUserLoading } =
useAtomValue(visionLLMConfigsAtom);
const llm = useMemo(
() => buildKind(llmGlobalConfigs, llmUserConfigs, preferences?.agent_llm_id),
[llmGlobalConfigs, llmUserConfigs, preferences?.agent_llm_id]
);
const image = useMemo(
() => buildKind(imageGlobalConfigs, imageUserConfigs, preferences?.image_generation_config_id),
[imageGlobalConfigs, imageUserConfigs, preferences?.image_generation_config_id]
);
const vision = useMemo(
() => buildKind(visionGlobalConfigs, visionUserConfigs, preferences?.vision_llm_config_id),
[visionGlobalConfigs, visionUserConfigs, preferences?.vision_llm_config_id]
);
const isLoading =
llmUserLoading ||
llmGlobalLoading ||
prefsLoading ||
imageGlobalLoading ||
imageUserLoading ||
visionGlobalLoading ||
visionUserLoading;
return useMemo(() => ({ llm, image, vision, isLoading }), [llm, image, vision, isLoading]);
}

View file

@ -66,6 +66,19 @@ export const builderExecutionSchema = z.object({
});
export type BuilderExecution = z.infer<typeof builderExecutionSchema>;
/**
* Per-automation model selection. ``0`` means "unset" the builder resolves it
* to the eligible default during render, and the resolved (non-zero) ids are
* written onto ``definition.models`` at submit so the run is insulated from
* later chat/search-space model changes.
*/
export const builderModelsSchema = z.object({
agentLlmId: z.number().int(),
imageConfigId: z.number().int(),
visionConfigId: z.number().int(),
});
export type BuilderModels = z.infer<typeof builderModelsSchema>;
export const builderFormSchema = z.object({
name: z.string().trim().min(1, "Give your automation a name").max(200),
description: z.string().trim().max(2000).nullable(),
@ -77,6 +90,8 @@ export const builderFormSchema = z.object({
tags: z.array(z.string()),
/** Carried through from an edited definition so we don't drop it. */
goal: z.string().nullable(),
/** Selected agent/image/vision models (``0`` = use the eligible default). */
models: builderModelsSchema,
});
export type BuilderForm = z.infer<typeof builderFormSchema>;
@ -132,6 +147,7 @@ export function createEmptyForm(): BuilderForm {
},
tags: [],
goal: null,
models: { agentLlmId: 0, imageConfigId: 0, visionConfigId: 0 },
};
}
@ -218,9 +234,26 @@ function buildDefinition(form: BuilderForm): AutomationDefinition {
on_failure: [],
},
metadata: { tags: form.tags },
// Only emit models when fully resolved (the builder seeds non-zero
// defaults before submit). A zero/unset triple is omitted so the
// backend falls back to the search-space snapshot.
...(hasResolvedModels(form.models)
? {
models: {
agent_llm_id: form.models.agentLlmId,
image_generation_config_id: form.models.imageConfigId,
vision_llm_config_id: form.models.visionConfigId,
},
}
: {}),
} as unknown as AutomationDefinition;
}
/** True once every model slot holds a concrete (non-zero) id. */
export function hasResolvedModels(models: BuilderModels): boolean {
return models.agentLlmId !== 0 && models.imageConfigId !== 0 && models.visionConfigId !== 0;
}
/** The desired schedule trigger for this form, or ``null`` if none. */
export function buildScheduleTrigger(form: BuilderForm): TriggerCreateRequest | null {
if (!form.schedule) return null;
@ -434,6 +467,8 @@ export function hydrateForm(
? metadata.tags.filter((tag): tag is string => typeof tag === "string")
: [];
const models = modelsFromDefinition(d.models);
return {
formable: true,
form: {
@ -455,10 +490,22 @@ export function hydrateForm(
},
tags,
goal: typeof d.goal === "string" ? d.goal : null,
models,
},
};
}
/** Read a captured ``definition.models`` snapshot into the form's model slots. */
function modelsFromDefinition(raw: unknown): BuilderModels {
const m = asRecord(raw);
const num = (value: unknown) => (typeof value === "number" ? value : 0);
return {
agentLlmId: num(m.agent_llm_id),
imageConfigId: num(m.image_generation_config_id),
visionConfigId: num(m.vision_llm_config_id),
};
}
/**
* Project an existing automation into the builder form for editing.
*/

View file

@ -0,0 +1,19 @@
/**
* Format the wall-clock duration between a run/step's start and finish
* timestamps into a compact, human-readable label (e.g. `850ms`, `4.2s`,
* `1m 30s`). Returns `null` when either bound is missing or the delta is
* negative/non-finite, so callers can simply omit the label.
*/
export function formatDuration(
started: string | null | undefined,
finished: string | null | undefined
): string | null {
if (!started || !finished) return null;
const ms = new Date(finished).getTime() - new Date(started).getTime();
if (!Number.isFinite(ms) || ms < 0) return null;
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}