From 2e572d781855292aa58981417ade345bb4f6fe21 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 28 May 2026 01:32:04 +0200 Subject: [PATCH] feat(web): create_automation HITL approval card in chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../tools/create_automation/description.md | 11 +- .../main_agent/tools/automation/create.py | 8 +- .../components/trigger-card.tsx | 2 +- .../automation-triggers-summary.tsx | 2 +- .../automation/automation-draft-preview.tsx | 183 ++++++++++ .../tool-ui/automation/create-automation.tsx | 328 ++++++++++++++++++ .../components/tool-ui/automation/index.ts | 1 + surfsense_web/components/tool-ui/index.ts | 1 + surfsense_web/contracts/enums/toolIcons.tsx | 5 + .../timeline/tool-registry/registry.ts | 6 + .../lib => lib/automations}/describe-cron.ts | 5 +- 11 files changed, 541 insertions(+), 11 deletions(-) create mode 100644 surfsense_web/components/tool-ui/automation/automation-draft-preview.tsx create mode 100644 surfsense_web/components/tool-ui/automation/create-automation.tsx create mode 100644 surfsense_web/components/tool-ui/automation/index.ts rename surfsense_web/{app/dashboard/[search_space_id]/automations/lib => lib/automations}/describe-cron.ts (88%) diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/create_automation/description.md b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/create_automation/description.md index 25b4eec47..ce6562c97 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/create_automation/description.md +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/system_prompt/prompts/tools/create_automation/description.md @@ -1,7 +1,7 @@ - `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 - automation JSON; the user reviews and edits it on an approval card; on - approval it's saved. All three phases happen in a single tool call. + automation JSON; the user sees a preview on an approval card and chooses + 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 recurring or scheduled ("every morning…", "each Monday…", "weekly recap…"). @@ -17,13 +17,16 @@ explicitly ("the Notion parent page id was not specified") so the drafter leaves a placeholder. - 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: - `{status: "saved", automation_id, name}` — confirm briefly to the user ("Saved as automation #N — runs ."). Don't dump JSON back. - `{status: "rejected", message}` — the user declined on the card. 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 before the card was shown. Read the issues, refine your `intent` with the missing details, call again. diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py index 78fedde22..07b579f3b 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py @@ -66,9 +66,11 @@ def create_create_automation_tool( names, …) it needs. The tool drafts the full automation JSON internally, shows the user - an approval card for review, and persists on approval. Do NOT - prompt the user to confirm before calling — the card IS the - confirmation. The user can edit any field there. + a structured preview on an approval card, and persists on approval. + The card supports approve/reject only — if the user wants edits + 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: intent: Concrete restatement of the user's request. Include diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx index 0caaf968f..afadf589a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx @@ -6,8 +6,8 @@ import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutat import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import type { Trigger } from "@/contracts/types/automation.types"; +import { describeCron } from "@/lib/automations/describe-cron"; import { formatRelativeDate } from "@/lib/format-date"; -import { describeCron } from "../../lib/describe-cron"; import { DeleteTriggerDialog } from "./delete-trigger-dialog"; interface TriggerCardProps { diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx index 8b61a1e02..270a1f844 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx @@ -1,7 +1,7 @@ "use client"; import { CalendarClock, Pause } from "lucide-react"; import type { Trigger } from "@/contracts/types/automation.types"; -import { describeCron } from "../lib/describe-cron"; +import { describeCron } from "@/lib/automations/describe-cron"; interface AutomationTriggersSummaryProps { triggers: Trigger[]; diff --git a/surfsense_web/components/tool-ui/automation/automation-draft-preview.tsx b/surfsense_web/components/tool-ui/automation/automation-draft-preview.tsx new file mode 100644 index 000000000..b0b5c8f78 --- /dev/null +++ b/surfsense_web/components/tool-ui/automation/automation-draft-preview.tsx @@ -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; + static_inputs: Record; + 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; +} + +/** + * 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 ( +
+
+

{draft.name}

+ {draft.description &&

{draft.description}

} +
+ + {draft.definition.goal && ( +
+

{draft.definition.goal}

+
+ )} + +
+ {draft.triggers.length === 0 ? ( +

+ No triggers — automation will need one before it can run. +

+ ) : ( +
    + {draft.triggers.map((trigger) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+
    + {draft.definition.plan.map((step, idx) => ( +
  1. + + {idx + 1} + +
    + {step.step_id} + + {step.action} + {step.when && when {step.when}} +
    +
  2. + ))} +
+
+ + + {showRaw && ( +
+					{JSON.stringify(raw, null, 2)}
+				
+ )} +
+ ); +} + +/** + * 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 ( +
+
+ {human} + · {tz} + {!trigger.enabled && ( + + Disabled + + )} +
+ {cron && {cron}} + {staticKeys.length > 0 && ( +

+ Static inputs: {staticKeys.join(", ")} +

+ )} +
+ ); + } + return {trigger.type}; +} + +function Section({ + icon: Icon, + label, + children, +}: { + icon: typeof Target; + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ + {label} +
+ {children} +
+ ); +} diff --git a/surfsense_web/components/tool-ui/automation/create-automation.tsx b/surfsense_web/components/tool-ui/automation/create-automation.tsx new file mode 100644 index 000000000..713c5fd46 --- /dev/null +++ b/surfsense_web/components/tool-ui/automation/create-automation.tsx @@ -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 + | 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; + interruptData: InterruptResult; + 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 ( +
+
+ +
+

+ {phase === "rejected" + ? "Automation cancelled" + : phase === "processing" + ? "Saving automation" + : phase === "complete" + ? "Automation saved" + : "Create automation"} +

+ {phase === "processing" ? ( + + ) : phase === "complete" ? ( +

+ Automation created from this draft +

+ ) : phase === "rejected" ? ( +

+ No automation was saved — ask in chat to refine and try again. +

+ ) : ( +

+ Review and approve to save. To change anything, reply in chat — I'll redraft. +

+ )} +
+
+ +
+
+ +
+ + {phase === "pending" && ( + <> +
+
+ {canApprove && ( + + )} + {canReject && ( + + )} +
+ + )} +
+ ); +} + +// ---------------------------------------------------------------------------- +// Terminal result cards. +// ---------------------------------------------------------------------------- + +function SavedCard({ result }: { result: SavedResult }) { + const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + const detailHref = searchSpaceId + ? `/dashboard/${searchSpaceId}/automations/${result.automation_id}` + : null; + + return ( +
+
+ +
+

Automation saved

+

{result.name}

+
+
+ {detailHref && ( + <> +
+
+ + + Open automation #{result.automation_id} + +
+ + )} +
+ ); +} + +function InvalidCard({ result }: { result: InvalidResult }) { + return ( +
+
+

Couldn't draft this automation

+

+ The drafter produced output that didn't validate. I'll refine and retry. +

+
+ {result.issues.length > 0 && ( + <> +
+
    + {result.issues.map((issue) => ( +
  • {issue}
  • + ))} +
+ + )} +
+ ); +} + +function ErrorCard({ result }: { result: ErrorResult }) { + return ( +
+
+

Failed to create automation

+
+
+
+

{result.message}

+
+
+ ); +} + +// ---------------------------------------------------------------------------- +// 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 ( + } + interruptData={result as InterruptResult} + onDecision={(decision) => dispatch([decision])} + /> + ); + } + + if (hasStatus(result, "rejected")) return null; + if (hasStatus(result, "saved")) return ; + if (hasStatus(result, "invalid")) return ; + if (hasStatus(result, "error")) return ; + + 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) { + const definition = (args.definition ?? {}) as Record; + const planSteps = Array.isArray(definition.plan) + ? (definition.plan as Array>).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>).map((trigger) => ({ + type: String(trigger.type ?? "schedule"), + params: (trigger.params ?? {}) as Record, + static_inputs: (trigger.static_inputs ?? {}) as Record, + 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, + }; +} diff --git a/surfsense_web/components/tool-ui/automation/index.ts b/surfsense_web/components/tool-ui/automation/index.ts new file mode 100644 index 000000000..50cf1a478 --- /dev/null +++ b/surfsense_web/components/tool-ui/automation/index.ts @@ -0,0 +1 @@ +export { CreateAutomationToolUI } from "./create-automation"; diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index 4d885a38c..ee5072dad 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -7,6 +7,7 @@ */ export { Audio } from "./audio"; +export { CreateAutomationToolUI } from "./automation"; export { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "./dropbox"; export { type GenerateImageArgs, diff --git a/surfsense_web/contracts/enums/toolIcons.tsx b/surfsense_web/contracts/enums/toolIcons.tsx index bb87be0ba..668cb51cd 100644 --- a/surfsense_web/contracts/enums/toolIcons.tsx +++ b/surfsense_web/contracts/enums/toolIcons.tsx @@ -25,6 +25,7 @@ import { SearchCheck, Send, Trash2, + Workflow, Wrench, } from "lucide-react"; @@ -47,6 +48,8 @@ const TOOL_ICONS: Record = { scrape_webpage: ScanLine, web_search: Globe, search_surfsense_docs: BookOpen, + // Automations + create_automation: Workflow, // Memory update_memory: Brain, // Filesystem (built-in deepagent + middleware) @@ -150,6 +153,8 @@ const TOOL_DISPLAY_NAMES: Record = { scrape_webpage: "Read webpage", web_search: "Search the web", search_surfsense_docs: "Search knowledge base", + // Automations + create_automation: "Create automation", // Memory update_memory: "Update memory", // Calendar diff --git a/surfsense_web/features/chat-messages/timeline/tool-registry/registry.ts b/surfsense_web/features/chat-messages/timeline/tool-registry/registry.ts index 8acc6b4fa..c4cfe7cd3 100644 --- a/surfsense_web/features/chat-messages/timeline/tool-registry/registry.ts +++ b/surfsense_web/features/chat-messages/timeline/tool-registry/registry.ts @@ -17,6 +17,11 @@ const UpdateMemoryToolUI = dynamic( () => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.UpdateMemoryToolUI })), { ssr: false } ); +const CreateAutomationToolUI = dynamic( + () => + import("@/components/tool-ui/automation").then((m) => ({ default: m.CreateAutomationToolUI })), + { ssr: false } +); const SandboxExecuteToolUI = dynamic( () => import("@/components/tool-ui/sandbox-execute").then((m) => ({ @@ -184,6 +189,7 @@ const NullTimelineBody: TimelineToolComponent = () => null; */ const TOOLS_BY_NAME = { task: NullTimelineBody, + create_automation: CreateAutomationToolUI, update_memory: UpdateMemoryToolUI, execute: SandboxExecuteToolUI, execute_code: SandboxExecuteToolUI, diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/lib/describe-cron.ts b/surfsense_web/lib/automations/describe-cron.ts similarity index 88% rename from surfsense_web/app/dashboard/[search_space_id]/automations/lib/describe-cron.ts rename to surfsense_web/lib/automations/describe-cron.ts index e10a99a44..19f7ff991 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/lib/describe-cron.ts +++ b/surfsense_web/lib/automations/describe-cron.ts @@ -4,8 +4,9 @@ * to the raw expression when unrecognized so the user still sees something * honest instead of a guess. * - * Lives in the automations slice because it's a UI display concern with no - * consumers outside it. If reuse grows, lift to ``lib/cron-describe.ts``. + * Lives under ``lib/automations/`` because both the dashboard slice and the + * 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"];