mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat(web): create_automation HITL approval card in chat
Closes the create loop in chat: the agent describes user intent → the drafter sub-LLM produces an AutomationCreate JSON → this card surfaces a structured preview → approve persists; reject cancels. Edits flow through chat refinement (re-call with a refined intent), not in-card, so the card stays simple and the multi-turn checkpointer carries the context. Tool UI (components/tool-ui/automation/): - create-automation.tsx — entry dispatcher + ApprovalCard chrome (pending/processing/complete/rejected via useHitlPhase) + SavedCard (links to the detail page) + InvalidCard (lists drafter validation issues) + ErrorCard (verbatim message). Rejection result is hidden because the approval card itself shows the rejected phase inline. - automation-draft-preview.tsx — structured preview body: name + description + goal, triggers (humanised cron + tz + static-input keys), plan steps (step_id → action), and a collapsible raw JSON for power users. Wiring: - components/tool-ui/index.ts — re-export. - features/chat-messages/timeline/tool-registry/registry.ts — register create_automation → CreateAutomationToolUI (dynamic import, same pattern as other connector tools). - contracts/enums/toolIcons.tsx — Workflow icon + "Create automation" display name so fallback chrome (and timeline headers) are honest. Shared util: - lib/automations/describe-cron.ts — lifted from the route slice's lib/ folder since both the dashboard slice and the new approval card now render schedule descriptions. Slice imports updated; the now- empty slice lib/ folder is gone. Backend prompt fragments: - main_agent/system_prompt/.../create_automation/description.md and the tool's docstring no longer promise in-card edits. They make the refinement path explicit: if the user wants changes after seeing the draft, they reply in chat and the agent calls the tool again with a refined intent. v1 deliberately excludes: - In-card edit form / right-side edit panel — defer until we see real demand. The chat refinement loop covers the common case. - approve_always / persistent allow rules — automations are a single artifact, not a repeated mutation, so the "trust this kind of call" affordance doesn't apply.
This commit is contained in:
parent
c0a9ea368f
commit
2e572d7818
11 changed files with 541 additions and 11 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
- `create_automation` — Draft and author a new automation. You describe the
|
- `create_automation` — Draft and author a new automation. You describe the
|
||||||
user's intent; a focused drafter inside the tool turns it into the full
|
user's intent; a focused drafter inside the tool turns it into the full
|
||||||
automation JSON; the user reviews and edits it on an approval card; on
|
automation JSON; the user sees a preview on an approval card and chooses
|
||||||
approval it's saved. All three phases happen in a single tool call.
|
approve or reject. All three phases happen in a single tool call.
|
||||||
- Call when the user wants SurfSense to do something on its own: anything
|
- Call when the user wants SurfSense to do something on its own: anything
|
||||||
recurring or scheduled ("every morning…", "each Monday…", "weekly
|
recurring or scheduled ("every morning…", "each Monday…", "weekly
|
||||||
recap…").
|
recap…").
|
||||||
|
|
@ -17,13 +17,16 @@
|
||||||
explicitly ("the Notion parent page id was not specified") so the
|
explicitly ("the Notion parent page id was not specified") so the
|
||||||
drafter leaves a placeholder.
|
drafter leaves a placeholder.
|
||||||
- Do NOT prompt the user to confirm before calling — the approval card
|
- Do NOT prompt the user to confirm before calling — the approval card
|
||||||
IS the confirmation. The user can edit any field on the card.
|
IS the confirmation. The card shows a structured preview plus the raw
|
||||||
|
JSON; it offers approve/reject only. If the user wants changes after
|
||||||
|
seeing the draft, they reply in chat and you call this tool again with
|
||||||
|
a refined `intent` — that's the edit path.
|
||||||
- Returns:
|
- Returns:
|
||||||
- `{status: "saved", automation_id, name}` — confirm briefly to the
|
- `{status: "saved", automation_id, name}` — confirm briefly to the
|
||||||
user ("Saved as automation #N — runs <when>."). Don't dump JSON back.
|
user ("Saved as automation #N — runs <when>."). Don't dump JSON back.
|
||||||
- `{status: "rejected", message}` — the user declined on the card.
|
- `{status: "rejected", message}` — the user declined on the card.
|
||||||
Acknowledge once ("Understood, I didn't create it.") and stop. Do
|
Acknowledge once ("Understood, I didn't create it.") and stop. Do
|
||||||
NOT retry or pitch variants.
|
NOT retry or pitch variants without a fresh user request.
|
||||||
- `{status: "invalid", issues, raw?}` — drafting/validation failed
|
- `{status: "invalid", issues, raw?}` — drafting/validation failed
|
||||||
before the card was shown. Read the issues, refine your `intent`
|
before the card was shown. Read the issues, refine your `intent`
|
||||||
with the missing details, call again.
|
with the missing details, call again.
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,11 @@ def create_create_automation_tool(
|
||||||
names, …) it needs.
|
names, …) it needs.
|
||||||
|
|
||||||
The tool drafts the full automation JSON internally, shows the user
|
The tool drafts the full automation JSON internally, shows the user
|
||||||
an approval card for review, and persists on approval. Do NOT
|
a structured preview on an approval card, and persists on approval.
|
||||||
prompt the user to confirm before calling — the card IS the
|
The card supports approve/reject only — if the user wants edits
|
||||||
confirmation. The user can edit any field there.
|
after seeing the draft, they say so in chat and you call this tool
|
||||||
|
again with a refined intent. Do NOT prompt the user to confirm
|
||||||
|
before calling — the card IS the confirmation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
intent: Concrete restatement of the user's request. Include
|
intent: Concrete restatement of the user's request. Include
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutat
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import type { Trigger } from "@/contracts/types/automation.types";
|
import type { Trigger } from "@/contracts/types/automation.types";
|
||||||
|
import { describeCron } from "@/lib/automations/describe-cron";
|
||||||
import { formatRelativeDate } from "@/lib/format-date";
|
import { formatRelativeDate } from "@/lib/format-date";
|
||||||
import { describeCron } from "../../lib/describe-cron";
|
|
||||||
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
||||||
|
|
||||||
interface TriggerCardProps {
|
interface TriggerCardProps {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { CalendarClock, Pause } from "lucide-react";
|
import { CalendarClock, Pause } from "lucide-react";
|
||||||
import type { Trigger } from "@/contracts/types/automation.types";
|
import type { Trigger } from "@/contracts/types/automation.types";
|
||||||
import { describeCron } from "../lib/describe-cron";
|
import { describeCron } from "@/lib/automations/describe-cron";
|
||||||
|
|
||||||
interface AutomationTriggersSummaryProps {
|
interface AutomationTriggersSummaryProps {
|
||||||
triggers: Trigger[];
|
triggers: Trigger[];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
"use client";
|
||||||
|
import { CalendarClock, ChevronDown, ChevronRight, ListOrdered, Target } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { describeCron } from "@/lib/automations/describe-cron";
|
||||||
|
|
||||||
|
interface DraftTrigger {
|
||||||
|
type: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
static_inputs: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraftPlanStep {
|
||||||
|
step_id: string;
|
||||||
|
action: string;
|
||||||
|
when?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutomationDraft {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
definition: {
|
||||||
|
goal?: string | null;
|
||||||
|
plan: DraftPlanStep[];
|
||||||
|
};
|
||||||
|
triggers: DraftTrigger[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutomationDraftPreviewProps {
|
||||||
|
draft: AutomationDraft;
|
||||||
|
/** Full unmodified args dict — surfaced as the "raw JSON" escape hatch. */
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured preview of a drafted automation rendered inside the chat
|
||||||
|
* approval card.
|
||||||
|
*
|
||||||
|
* Three layers, top to bottom:
|
||||||
|
* 1. Name + description (and goal when present).
|
||||||
|
* 2. Triggers — humanised cron string + timezone + static_inputs hint.
|
||||||
|
* 3. Plan steps — ordered list of ``step_id → action``.
|
||||||
|
*
|
||||||
|
* A "View raw JSON" toggle reveals the full payload for power users who
|
||||||
|
* want to inspect every field; it's collapsed by default so the card
|
||||||
|
* stays scannable for the common case.
|
||||||
|
*/
|
||||||
|
export function AutomationDraftPreview({ draft, raw }: AutomationDraftPreviewProps) {
|
||||||
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium text-foreground">{draft.name}</p>
|
||||||
|
{draft.description && <p className="text-xs text-muted-foreground">{draft.description}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft.definition.goal && (
|
||||||
|
<Section icon={Target} label="Goal">
|
||||||
|
<p className="text-xs text-foreground">{draft.definition.goal}</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section icon={CalendarClock} label={`Triggers · ${draft.triggers.length}`}>
|
||||||
|
{draft.triggers.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No triggers — automation will need one before it can run.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{draft.triggers.map((trigger) => (
|
||||||
|
<li
|
||||||
|
key={triggerKey(trigger)}
|
||||||
|
className="rounded-md border border-border/60 bg-background/50 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<TriggerLine trigger={trigger} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
icon={ListOrdered}
|
||||||
|
label={`Plan · ${draft.definition.plan.length} step${draft.definition.plan.length === 1 ? "" : "s"}`}
|
||||||
|
>
|
||||||
|
<ol className="space-y-1 text-xs">
|
||||||
|
{draft.definition.plan.map((step, idx) => (
|
||||||
|
<li key={step.step_id} className="flex items-start gap-2">
|
||||||
|
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground shrink-0 mt-0.5">
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-medium text-foreground">{step.step_id}</span>
|
||||||
|
<span className="text-muted-foreground"> → </span>
|
||||||
|
<code className="font-mono text-muted-foreground">{step.action}</code>
|
||||||
|
{step.when && <span className="ml-2 text-muted-foreground">when {step.when}</span>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRaw((value) => !value)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showRaw ? (
|
||||||
|
<ChevronDown className="h-3 w-3" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3" aria-hidden />
|
||||||
|
)}
|
||||||
|
{showRaw ? "Hide raw JSON" : "View raw JSON"}
|
||||||
|
</button>
|
||||||
|
{showRaw && (
|
||||||
|
<pre className="rounded-md bg-muted/40 px-3 py-2 text-[11px] font-mono text-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-72">
|
||||||
|
{JSON.stringify(raw, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable key derived from the trigger's identifying fields. Drafts are
|
||||||
|
* static snapshots so collisions only happen if the LLM emits two literally
|
||||||
|
* identical triggers — harmless in practice.
|
||||||
|
*/
|
||||||
|
function triggerKey(trigger: DraftTrigger): string {
|
||||||
|
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : "";
|
||||||
|
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "";
|
||||||
|
return `${trigger.type}|${cron}|${tz}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TriggerLine({ trigger }: { trigger: DraftTrigger }) {
|
||||||
|
if (trigger.type === "schedule") {
|
||||||
|
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
|
||||||
|
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
|
||||||
|
const human = cron ? describeCron(cron) : "Schedule";
|
||||||
|
const staticKeys = Object.keys(trigger.static_inputs ?? {});
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-foreground">{human}</span>
|
||||||
|
<span className="text-muted-foreground">· {tz}</span>
|
||||||
|
{!trigger.enabled && (
|
||||||
|
<span className="rounded-md border border-border/60 px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{cron && <code className="font-mono text-muted-foreground">{cron}</code>}
|
||||||
|
{staticKeys.length > 0 && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Static inputs: <span className="text-foreground">{staticKeys.join(", ")}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="capitalize text-foreground">{trigger.type}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: typeof Target;
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Icon className="h-3 w-3" aria-hidden />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { CornerDownLeftIcon, ExternalLink, Workflow } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
|
||||||
|
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
|
||||||
|
import { AutomationDraftPreview } from "./automation-draft-preview";
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Result discrimination — mirrors the backend return shapes in
|
||||||
|
// app/agents/multi_agent_chat/main_agent/tools/automation/create.py.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type AutomationCreateContext = {
|
||||||
|
search_space_id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SavedResult {
|
||||||
|
status: "saved";
|
||||||
|
automation_id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RejectedResult {
|
||||||
|
status: "rejected";
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvalidResult {
|
||||||
|
status: "invalid";
|
||||||
|
issues: string[];
|
||||||
|
raw?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorResult {
|
||||||
|
status: "error";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateAutomationResult =
|
||||||
|
| InterruptResult<AutomationCreateContext>
|
||||||
|
| SavedResult
|
||||||
|
| RejectedResult
|
||||||
|
| InvalidResult
|
||||||
|
| ErrorResult;
|
||||||
|
|
||||||
|
function hasStatus(value: unknown, status: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"status" in value &&
|
||||||
|
(value as { status: unknown }).status === status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Approval card — pending → processing → complete / rejected.
|
||||||
|
//
|
||||||
|
// v1 deliberately supports only approve/reject. The drafted JSON is complex
|
||||||
|
// (full plan + triggers) and we already have a multi-turn refinement path via
|
||||||
|
// chat ("make it run at 10am instead" → the agent re-calls the tool with a
|
||||||
|
// refined intent). An in-card edit form would duplicate that flow and add UX
|
||||||
|
// surface area we don't need yet — leave it for the raw-JSON path on the
|
||||||
|
// detail page.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ApprovalCardProps {
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
interruptData: InterruptResult<AutomationCreateContext>;
|
||||||
|
onDecision: (decision: HitlDecision) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
|
||||||
|
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||||
|
|
||||||
|
const reviewConfig = interruptData.review_configs[0];
|
||||||
|
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
||||||
|
const canApprove = allowedDecisions.includes("approve");
|
||||||
|
const canReject = allowedDecisions.includes("reject");
|
||||||
|
|
||||||
|
const draft = useMemo(() => extractDraft(args), [args]);
|
||||||
|
|
||||||
|
const handleApprove = useCallback(() => {
|
||||||
|
if (phase !== "pending" || !canApprove) return;
|
||||||
|
setProcessing();
|
||||||
|
onDecision({
|
||||||
|
type: "approve",
|
||||||
|
edited_action: {
|
||||||
|
name: interruptData.action_requests[0]?.name ?? "create_automation",
|
||||||
|
args,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [phase, canApprove, setProcessing, onDecision, interruptData, args]);
|
||||||
|
|
||||||
|
const handleReject = useCallback(() => {
|
||||||
|
if (phase !== "pending" || !canReject) return;
|
||||||
|
setRejected();
|
||||||
|
onDecision({ type: "reject", message: "User rejected the automation draft." });
|
||||||
|
}, [phase, canReject, setRejected, onDecision]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
|
handleApprove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [handleApprove]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
|
||||||
|
<div className="flex items-start gap-3 px-5 pt-5 pb-4 select-none">
|
||||||
|
<Workflow className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" aria-hidden />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
{phase === "rejected"
|
||||||
|
? "Automation cancelled"
|
||||||
|
: phase === "processing"
|
||||||
|
? "Saving automation"
|
||||||
|
: phase === "complete"
|
||||||
|
? "Automation saved"
|
||||||
|
: "Create automation"}
|
||||||
|
</p>
|
||||||
|
{phase === "processing" ? (
|
||||||
|
<TextShimmerLoader text="Saving automation" size="sm" />
|
||||||
|
) : phase === "complete" ? (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Automation created from this draft
|
||||||
|
</p>
|
||||||
|
) : phase === "rejected" ? (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
No automation was saved — ask in chat to refine and try again.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Review and approve to save. To change anything, reply in chat — I'll redraft.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<AutomationDraftPreview draft={draft} raw={args} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase === "pending" && (
|
||||||
|
<>
|
||||||
|
<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}>
|
||||||
|
Approve
|
||||||
|
<CornerDownLeftIcon className="size-3 opacity-60" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canReject && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-lg text-muted-foreground"
|
||||||
|
onClick={handleReject}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Terminal result cards.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SavedCard({ result }: { result: SavedResult }) {
|
||||||
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
|
const detailHref = searchSpaceId
|
||||||
|
? `/dashboard/${searchSpaceId}/automations/${result.automation_id}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
|
<div className="flex items-start gap-3 px-5 pt-5 pb-4">
|
||||||
|
<Workflow className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" aria-hidden />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-foreground">Automation saved</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{result.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{detailHref && (
|
||||||
|
<>
|
||||||
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
|
<div className="px-5 py-3">
|
||||||
|
<Link
|
||||||
|
href={detailHref}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
Open automation #{result.automation_id}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvalidCard({ result }: { result: InvalidResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
|
<div className="px-5 pt-5 pb-4">
|
||||||
|
<p className="text-sm font-semibold text-destructive">Couldn't draft this automation</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
The drafter produced output that didn't validate. I'll refine and retry.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{result.issues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
|
<ul className="px-5 py-3 space-y-1 text-xs text-muted-foreground list-disc list-inside">
|
||||||
|
{result.issues.map((issue) => (
|
||||||
|
<li key={issue}>{issue}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({ result }: { result: ErrorResult }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
|
||||||
|
<div className="px-5 pt-5 pb-4">
|
||||||
|
<p className="text-sm font-semibold text-destructive">Failed to create automation</p>
|
||||||
|
</div>
|
||||||
|
<div className="mx-5 h-px bg-border/50" />
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Entry — dispatches between the approval card and terminal result cards.
|
||||||
|
//
|
||||||
|
// Rejection is special: we hide the standalone "rejected" card because the
|
||||||
|
// approval card itself already transitions to a "rejected" phase inline. A
|
||||||
|
// second message in the timeline would be noisy.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const CreateAutomationToolUI = ({
|
||||||
|
args,
|
||||||
|
result,
|
||||||
|
}: ToolCallMessagePartProps<{ intent: string }, CreateAutomationResult>) => {
|
||||||
|
const { dispatch } = useHitlDecision();
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
if (isInterruptResult(result)) {
|
||||||
|
return (
|
||||||
|
<ApprovalCard
|
||||||
|
args={args as unknown as Record<string, unknown>}
|
||||||
|
interruptData={result as InterruptResult<AutomationCreateContext>}
|
||||||
|
onDecision={(decision) => dispatch([decision])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasStatus(result, "rejected")) return null;
|
||||||
|
if (hasStatus(result, "saved")) return <SavedCard result={result as SavedResult} />;
|
||||||
|
if (hasStatus(result, "invalid")) return <InvalidCard result={result as InvalidResult} />;
|
||||||
|
if (hasStatus(result, "error")) return <ErrorCard result={result as ErrorResult} />;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Helpers.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project raw args into the shape ``AutomationDraftPreview`` expects.
|
||||||
|
*
|
||||||
|
* The args dict is the full ``AutomationCreate`` payload (minus
|
||||||
|
* ``search_space_id`` which is injected server-side), so we trust the
|
||||||
|
* top-level fields but defend against missing nested defaults.
|
||||||
|
*/
|
||||||
|
function extractDraft(args: Record<string, unknown>) {
|
||||||
|
const definition = (args.definition ?? {}) as Record<string, unknown>;
|
||||||
|
const planSteps = Array.isArray(definition.plan)
|
||||||
|
? (definition.plan as Array<Record<string, unknown>>).map((step) => ({
|
||||||
|
step_id: String(step.step_id ?? "(unnamed)"),
|
||||||
|
action: String(step.action ?? ""),
|
||||||
|
when: typeof step.when === "string" ? step.when : null,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const triggers = Array.isArray(args.triggers)
|
||||||
|
? (args.triggers as Array<Record<string, unknown>>).map((trigger) => ({
|
||||||
|
type: String(trigger.type ?? "schedule"),
|
||||||
|
params: (trigger.params ?? {}) as Record<string, unknown>,
|
||||||
|
static_inputs: (trigger.static_inputs ?? {}) as Record<string, unknown>,
|
||||||
|
enabled: trigger.enabled !== false,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: String(args.name ?? "(unnamed automation)"),
|
||||||
|
description: typeof args.description === "string" ? args.description : null,
|
||||||
|
definition: {
|
||||||
|
goal: typeof definition.goal === "string" ? definition.goal : null,
|
||||||
|
plan: planSteps,
|
||||||
|
},
|
||||||
|
triggers,
|
||||||
|
};
|
||||||
|
}
|
||||||
1
surfsense_web/components/tool-ui/automation/index.ts
Normal file
1
surfsense_web/components/tool-ui/automation/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { CreateAutomationToolUI } from "./create-automation";
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { Audio } from "./audio";
|
export { Audio } from "./audio";
|
||||||
|
export { CreateAutomationToolUI } from "./automation";
|
||||||
export { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "./dropbox";
|
export { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "./dropbox";
|
||||||
export {
|
export {
|
||||||
type GenerateImageArgs,
|
type GenerateImageArgs,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
SearchCheck,
|
SearchCheck,
|
||||||
Send,
|
Send,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Workflow,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -47,6 +48,8 @@ const TOOL_ICONS: Record<string, LucideIcon> = {
|
||||||
scrape_webpage: ScanLine,
|
scrape_webpage: ScanLine,
|
||||||
web_search: Globe,
|
web_search: Globe,
|
||||||
search_surfsense_docs: BookOpen,
|
search_surfsense_docs: BookOpen,
|
||||||
|
// Automations
|
||||||
|
create_automation: Workflow,
|
||||||
// Memory
|
// Memory
|
||||||
update_memory: Brain,
|
update_memory: Brain,
|
||||||
// Filesystem (built-in deepagent + middleware)
|
// Filesystem (built-in deepagent + middleware)
|
||||||
|
|
@ -150,6 +153,8 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||||
scrape_webpage: "Read webpage",
|
scrape_webpage: "Read webpage",
|
||||||
web_search: "Search the web",
|
web_search: "Search the web",
|
||||||
search_surfsense_docs: "Search knowledge base",
|
search_surfsense_docs: "Search knowledge base",
|
||||||
|
// Automations
|
||||||
|
create_automation: "Create automation",
|
||||||
// Memory
|
// Memory
|
||||||
update_memory: "Update memory",
|
update_memory: "Update memory",
|
||||||
// Calendar
|
// Calendar
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ const UpdateMemoryToolUI = dynamic(
|
||||||
() => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.UpdateMemoryToolUI })),
|
() => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.UpdateMemoryToolUI })),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
);
|
);
|
||||||
|
const CreateAutomationToolUI = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/tool-ui/automation").then((m) => ({ default: m.CreateAutomationToolUI })),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
const SandboxExecuteToolUI = dynamic(
|
const SandboxExecuteToolUI = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import("@/components/tool-ui/sandbox-execute").then((m) => ({
|
import("@/components/tool-ui/sandbox-execute").then((m) => ({
|
||||||
|
|
@ -184,6 +189,7 @@ const NullTimelineBody: TimelineToolComponent = () => null;
|
||||||
*/
|
*/
|
||||||
const TOOLS_BY_NAME = {
|
const TOOLS_BY_NAME = {
|
||||||
task: NullTimelineBody,
|
task: NullTimelineBody,
|
||||||
|
create_automation: CreateAutomationToolUI,
|
||||||
update_memory: UpdateMemoryToolUI,
|
update_memory: UpdateMemoryToolUI,
|
||||||
execute: SandboxExecuteToolUI,
|
execute: SandboxExecuteToolUI,
|
||||||
execute_code: SandboxExecuteToolUI,
|
execute_code: SandboxExecuteToolUI,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
* to the raw expression when unrecognized so the user still sees something
|
* to the raw expression when unrecognized so the user still sees something
|
||||||
* honest instead of a guess.
|
* honest instead of a guess.
|
||||||
*
|
*
|
||||||
* Lives in the automations slice because it's a UI display concern with no
|
* Lives under ``lib/automations/`` because both the dashboard slice and the
|
||||||
* consumers outside it. If reuse grows, lift to ``lib/cron-describe.ts``.
|
* chat ``create_automation`` approval card render schedule descriptions —
|
||||||
|
* keeping the helper outside either feature avoids a layering violation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
Loading…
Add table
Add a link
Reference in a new issue