From d013617bf60e5528a384386505d31fe557be0a93 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Thu, 28 May 2026 21:26:32 -0700 Subject: [PATCH] feat(automations): added UI and improved mentions - Added support for @-mentions in agent tasks, allowing users to reference documents, folders, and connectors directly in their queries. - Updated `run_agent_task` to resolve mentions and include them in the context passed to the agent. - Introduced new parameters in `AgentTaskActionParams` for handling mentioned document and connector IDs. - Refactored the automation edit and new components to utilize the new `AutomationBuilderForm` for a more streamlined user experience. - Removed deprecated JSON forms to simplify the automation creation process. --- .../automations/actions/agent_task/factory.py | 5 + .../automations/actions/agent_task/invoke.py | 132 ++++- .../automations/actions/agent_task/params.py | 31 ++ .../edit/automation-edit-content.tsx | 10 +- .../edit/components/automation-edit-form.tsx | 118 ----- .../components/automation-edit-header.tsx | 31 ++ .../components/builder/advanced-section.tsx | 129 +++++ .../builder/automation-builder-form.tsx | 459 ++++++++++++++++++ .../components/builder/basics-section.tsx | 42 ++ .../components/builder/builder-summary.tsx | 96 ++++ .../components/builder/form-field.tsx | 42 ++ .../components/builder/json-mode-panel.tsx | 51 ++ .../components/builder/mention-task-input.tsx | 258 ++++++++++ .../components/builder/schedule-section.tsx | 275 +++++++++++ .../components/builder/task-item.tsx | 136 ++++++ .../components/builder/task-list.tsx | 65 +++ .../components/builder/timezone-combobox.tsx | 71 +++ .../components/builder/unattended-toggle.tsx | 47 ++ .../new/automation-new-content.tsx | 12 +- .../new/components/automation-json-form.tsx | 98 ---- .../new/components/automation-new-header.tsx | 7 +- .../new-chat/document-mention-picker.tsx | 24 +- .../lib/automations/builder-schema.ts | 456 +++++++++++++++++ .../lib/automations/default-template.ts | 44 -- .../lib/automations/schedule-builder.ts | 132 +++++ 25 files changed, 2490 insertions(+), 281 deletions(-) delete mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/builder-summary.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/form-field.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/json-mode-panel.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/mention-task-input.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/schedule-section.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-item.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-list.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/timezone-combobox.tsx create mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/unattended-toggle.tsx delete mode 100644 surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-json-form.tsx create mode 100644 surfsense_web/lib/automations/builder-schema.ts delete mode 100644 surfsense_web/lib/automations/default-template.ts create mode 100644 surfsense_web/lib/automations/schedule-builder.ts diff --git a/surfsense_backend/app/automations/actions/agent_task/factory.py b/surfsense_backend/app/automations/actions/agent_task/factory.py index 18a408e13..dec75dce8 100644 --- a/surfsense_backend/app/automations/actions/agent_task/factory.py +++ b/surfsense_backend/app/automations/actions/agent_task/factory.py @@ -18,6 +18,11 @@ def build_handler(ctx: ActionContext) -> ActionHandler: ctx=ctx, query=validated.query, auto_approve_all=validated.auto_approve_all, + mentioned_document_ids=validated.mentioned_document_ids, + mentioned_folder_ids=validated.mentioned_folder_ids, + mentioned_connector_ids=validated.mentioned_connector_ids, + mentioned_connectors=validated.mentioned_connectors, + mentioned_documents=validated.mentioned_documents, ) return handle diff --git a/surfsense_backend/app/automations/actions/agent_task/invoke.py b/surfsense_backend/app/automations/actions/agent_task/invoke.py index 6cc92b232..fa02d263f 100644 --- a/surfsense_backend/app/automations/actions/agent_task/invoke.py +++ b/surfsense_backend/app/automations/actions/agent_task/invoke.py @@ -8,9 +8,13 @@ from typing import Any from langchain_core.messages import HumanMessage from langgraph.types import Command +from sqlalchemy.ext.asyncio import AsyncSession from app.agents.multi_agent_chat import create_multi_agent_chat_deep_agent +from app.agents.new_chat.context import SurfSenseContextSchema +from app.agents.new_chat.mention_resolver import resolve_mentions, substitute_in_text from app.db import ChatVisibility, async_session_maker +from app.schemas.new_chat import MentionedDocumentInfo from ..types import ActionContext from .auto_decide import build_auto_decisions @@ -22,17 +26,118 @@ from .finalize import extract_final_assistant_message _MAX_RESUMES = 50 +def _build_connector_block(connectors: list[dict[str, Any]]) -> str | None: + """Render the ```` context block (same shape as chat). + + Mirrors ``stream_new_chat`` so the agent gets the exact connector accounts + the user picked. Returns ``None`` when nothing renders. + """ + lines: list[str] = [] + for connector in connectors: + connector_id = connector.get("id") + connector_type = connector.get("connector_type") or connector.get( + "document_type" + ) + account_name = connector.get("account_name") or connector.get("title") + if connector_id is None or connector_type is None: + continue + lines.append( + f' - connector_id={connector_id}, connector_type="{connector_type}", ' + f'account_name="{account_name or ""}"' + ) + if not lines: + return None + return ( + "\n" + "The user selected these exact connector accounts with @. " + "These entries are selection metadata, not retrieved connector content. " + "When a connector-backed tool needs an account, use the matching " + "connector_id from this list if the tool supports connector_id:\n" + + "\n".join(lines) + + "\n" + ) + + +async def _resolve_mention_context( + session: AsyncSession, + *, + search_space_id: int, + query: str, + mentioned_document_ids: list[int] | None, + mentioned_folder_ids: list[int] | None, + mentioned_connector_ids: list[int] | None, + mentioned_connectors: list[MentionedDocumentInfo] | None, + mentioned_documents: list[MentionedDocumentInfo] | None, +) -> tuple[str, SurfSenseContextSchema | None]: + """Resolve @-mentions into a rewritten query + per-invocation context. + + Automation always runs in cloud filesystem mode, so we mirror the chat + ``new_chat`` flow: substitute ``@title`` tokens with canonical + ``/documents/...`` paths, prepend a ```` block, and + build a ``SurfSenseContextSchema`` that ``KnowledgePriorityMiddleware`` + reads via ``runtime.context``. Returns ``(query, None)`` unchanged when + there are no mentions. + """ + has_mentions = bool( + mentioned_document_ids + or mentioned_folder_ids + or mentioned_connector_ids + or mentioned_connectors + or mentioned_documents + ) + if not has_mentions: + return query, None + + resolved = await resolve_mentions( + session, + search_space_id=search_space_id, + mentioned_documents=mentioned_documents, + mentioned_document_ids=mentioned_document_ids, + mentioned_folder_ids=mentioned_folder_ids, + ) + agent_query = substitute_in_text(query, resolved.token_to_path) + + # ``SurfSenseContextSchema.mentioned_connectors`` is typed ``list[dict]`` and + # the connector block reads dicts, so dump the pydantic chips once. + connector_dicts = [c.model_dump() for c in (mentioned_connectors or [])] + connector_block = _build_connector_block(connector_dicts) + if connector_block: + agent_query = f"{connector_block}\n\n{agent_query}" + + runtime_context = SurfSenseContextSchema( + search_space_id=search_space_id, + mentioned_document_ids=list( + resolved.mentioned_document_ids or (mentioned_document_ids or []) + ), + mentioned_folder_ids=list( + resolved.mentioned_folder_ids or (mentioned_folder_ids or []) + ), + mentioned_connector_ids=list(mentioned_connector_ids or []), + mentioned_connectors=connector_dicts, + ) + return agent_query, runtime_context + + async def run_agent_task( *, ctx: ActionContext, query: str, auto_approve_all: bool, + mentioned_document_ids: list[int] | None = None, + mentioned_folder_ids: list[int] | None = None, + mentioned_connector_ids: list[int] | None = None, + mentioned_connectors: list[MentionedDocumentInfo] | None = None, + mentioned_documents: list[MentionedDocumentInfo] | None = None, ) -> dict[str, Any]: """Invoke multi_agent_chat for one rendered query and return its outcome. Opens its own DB session so the executor's bookkeeping session isn't tied up for the entire invocation. The LangGraph ``thread_id`` (a fresh UUID) is returned as ``agent_session_id`` for later inspection. + + @-mentions (files / folders / connectors) chosen in the task input are + resolved the same way the chat flow does and forwarded to the agent via the + per-invocation ``context`` so they actually scope retrieval. """ agent_session_id = str(uuid.uuid4()) user_id = str(ctx.creator_user_id) if ctx.creator_user_id else None @@ -55,12 +160,24 @@ async def run_agent_task( agent_config=deps.agent_config, firecrawl_api_key=deps.firecrawl_api_key, thread_visibility=ChatVisibility.PRIVATE, + mentioned_document_ids=mentioned_document_ids, + ) + + agent_query, runtime_context = await _resolve_mention_context( + agent_session, + search_space_id=ctx.search_space_id, + query=query, + mentioned_document_ids=mentioned_document_ids, + mentioned_folder_ids=mentioned_folder_ids, + mentioned_connector_ids=mentioned_connector_ids, + mentioned_connectors=mentioned_connectors, + mentioned_documents=mentioned_documents, ) request_id = f"automation:{ctx.run_id}:{ctx.step_id}" turn_id = f"{request_id}:{int(time.time() * 1000)}" input_state: dict[str, Any] = { - "messages": [HumanMessage(content=query)], + "messages": [HumanMessage(content=agent_query)], "search_space_id": ctx.search_space_id, "request_id": request_id, "turn_id": turn_id, @@ -73,8 +190,17 @@ async def run_agent_task( }, "recursion_limit": 10_000, } + if runtime_context is not None: + runtime_context.request_id = request_id + runtime_context.turn_id = turn_id - result = await agent.ainvoke(input_state, config=config) + # The compiled graph declares ``context_schema=SurfSenseContextSchema``; + # mentions only reach ``KnowledgePriorityMiddleware`` via ``context=``. + invoke_kwargs: dict[str, Any] = {"config": config} + if runtime_context is not None: + invoke_kwargs["context"] = runtime_context + + result = await agent.ainvoke(input_state, **invoke_kwargs) resumes = 0 while True: @@ -87,7 +213,7 @@ async def run_agent_task( ) lg_resume_map, routed = build_auto_decisions(state, decision) config["configurable"]["surfsense_resume_value"] = routed - result = await agent.ainvoke(Command(resume=lg_resume_map), config=config) + result = await agent.ainvoke(Command(resume=lg_resume_map), **invoke_kwargs) resumes += 1 return { diff --git a/surfsense_backend/app/automations/actions/agent_task/params.py b/surfsense_backend/app/automations/actions/agent_task/params.py index b0e99a78b..ad6f35edb 100644 --- a/surfsense_backend/app/automations/actions/agent_task/params.py +++ b/surfsense_backend/app/automations/actions/agent_task/params.py @@ -4,6 +4,8 @@ from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field +from app.schemas.new_chat import MentionedDocumentInfo + class AgentTaskActionParams(BaseModel): """Run a multi_agent_chat turn from an automation step.""" @@ -19,3 +21,32 @@ class AgentTaskActionParams(BaseModel): default=False, description="If true, every HITL approval is auto-approved; otherwise rejected.", ) + + # @-mention references chosen in the task input. Mirror the ``new_chat`` + # request fields (minus SurfSense product docs) so the run can scope + # retrieval to the user's selected files / folders / connectors. All + # optional and additive; a task with no mentions behaves as before. + mentioned_document_ids: list[int] | None = Field( + default=None, + description="Knowledge-base document IDs the task references with @.", + ) + mentioned_folder_ids: list[int] | None = Field( + default=None, + description="Knowledge-base folder IDs the task references with @.", + ) + mentioned_connector_ids: list[int] | None = Field( + default=None, + description="Concrete connector account IDs the task references with @.", + ) + mentioned_connectors: list[MentionedDocumentInfo] | None = Field( + default=None, + description="Display/context metadata for the @-mentioned connector accounts.", + ) + mentioned_documents: list[MentionedDocumentInfo] | None = Field( + default=None, + description=( + "Chip metadata (id, title, kind, ...) for every @-mention so the " + "run can resolve titles to virtual paths and substitute them in " + "the query." + ), + ) diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx index 6504af5a4..2c9db217d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx @@ -1,10 +1,11 @@ "use client"; import { ShieldAlert } from "lucide-react"; import { useAutomation } from "@/hooks/use-automation"; +import { AutomationBuilderForm } from "../../components/builder/automation-builder-form"; import { useAutomationPermissions } from "../../hooks/use-automation-permissions"; import { AutomationDetailLoading } from "../components/automation-detail-loading"; import { AutomationNotFound } from "../components/automation-not-found"; -import { AutomationEditForm } from "./components/automation-edit-form"; +import { AutomationEditHeader } from "./components/automation-edit-header"; interface AutomationEditContentProps { searchSpaceId: number; @@ -49,5 +50,10 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio return ; } - return ; + return ( + <> + + + + ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx deleted file mode 100644 index 9b950608e..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-form.tsx +++ /dev/null @@ -1,118 +0,0 @@ -"use client"; -import { useAtomValue } from "jotai"; -import { AlertCircle, ArrowLeft, Save } from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms"; -import { JsonView } from "@/components/json-view"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Spinner } from "@/components/ui/spinner"; -import { type Automation, automationUpdateRequest } from "@/contracts/types/automation.types"; - -interface AutomationEditFormProps { - automation: Automation; - searchSpaceId: number; -} - -/** - * Edit-existing-automation form. Surfaces the four mutable fields - * (name, description, status, definition) as one editable JSON tree; - * triggers stay on the detail page where they have their own management - * UI. Validates with the same Zod schema the API expects, then PATCHes - * the changed shape back. - */ -export function AutomationEditForm({ automation, searchSpaceId }: AutomationEditFormProps) { - const router = useRouter(); - const { mutateAsync: updateAutomation, isPending } = useAtomValue(updateAutomationMutationAtom); - const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`; - - const [value, setValue] = useState(() => ({ - name: automation.name, - description: automation.description ?? null, - status: automation.status, - definition: automation.definition, - })); - const [issues, setIssues] = useState([]); - - async function handleSave() { - setIssues([]); - const result = automationUpdateRequest.safeParse(value); - if (!result.success) { - setIssues( - result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) - ); - return; - } - try { - await updateAutomation({ automationId: automation.id, patch: result.data }); - router.push(detailHref); - } catch (err) { - setIssues([(err as Error).message ?? "Update failed"]); - } - } - - return ( - <> -
- -
-

- Edit automation -

-

{automation.name}

-
-
- - - - Definition - - -
- setValue(next as typeof value)} - collapsed={false} - /> -
- - {issues.length > 0 && ( -
-
- - {issues.length === 1 ? "1 issue" : `${issues.length} issues`} -
-
    - {issues.map((issue) => ( -
  • {issue}
  • - ))} -
-
- )} - -
- - -
-
-
- - ); -} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx new file mode 100644 index 000000000..6b2a31822 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx @@ -0,0 +1,31 @@ +"use client"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import type { Automation } from "@/contracts/types/automation.types"; + +interface AutomationEditHeaderProps { + automation: Automation; + searchSpaceId: number; +} + +export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEditHeaderProps) { + const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`; + + return ( +
+ +
+

+ Edit automation +

+

{automation.name}

+
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx new file mode 100644 index 000000000..740f199af --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx @@ -0,0 +1,129 @@ +"use client"; +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { BuilderExecution } from "@/lib/automations/builder-schema"; +import { Field } from "./form-field"; + +interface AdvancedSectionProps { + execution: BuilderExecution; + tags: string[]; + onExecutionChange: (patch: Partial) => void; + onTagsChange: (tags: string[]) => void; +} + +const BACKOFF_OPTIONS: ReadonlyArray<{ value: BuilderExecution["retryBackoff"]; label: string }> = [ + { value: "exponential", label: "Exponential" }, + { value: "linear", label: "Linear" }, + { value: "none", label: "None" }, +]; + +const CONCURRENCY_OPTIONS: ReadonlyArray<{ + value: BuilderExecution["concurrency"]; + label: string; +}> = [ + { value: "drop_if_running", label: "Skip if already running" }, + { value: "queue", label: "Queue the next run" }, + { value: "always", label: "Always run" }, +]; + +function clampInt(raw: string, min: number, fallback: number): number { + const value = Number.parseInt(raw, 10); + if (Number.isNaN(value)) return fallback; + return Math.max(min, value); +} + +export function AdvancedSection({ + execution, + tags, + onExecutionChange, + onTagsChange, +}: AdvancedSectionProps) { + const [tagsText, setTagsText] = useState(tags.join(", ")); + + function commitTags(text: string) { + const next = text + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); + onTagsChange(next); + } + + return ( +
+
+ + + onExecutionChange({ timeoutSeconds: clampInt(e.target.value, 1, 600) }) + } + /> + + + onExecutionChange({ maxRetries: clampInt(e.target.value, 0, 2) })} + /> + + + + + + + +
+ + + setTagsText(e.target.value)} + onBlur={(e) => commitTags(e.target.value)} + /> + +
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx new file mode 100644 index 000000000..1fd37cd3d --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx @@ -0,0 +1,459 @@ +"use client"; +import { useAtomValue } from "jotai"; +import { Code2, LayoutList, Save } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import type { z } from "zod"; +import { + addTriggerMutationAtom, + createAutomationMutationAtom, + removeTriggerMutationAtom, + updateAutomationMutationAtom, + updateTriggerMutationAtom, +} from "@/atoms/automations/automations-mutation.atoms"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { + type Automation, + automationCreateRequest, + automationUpdateRequest, +} from "@/contracts/types/automation.types"; +import { + type BuilderForm, + buildCreatePayload, + builderFormSchema, + buildScheduleTrigger, + buildUpdatePayload, + createEmptyForm, + formFromAutomation, + type HydratableTrigger, + hydrateForm, +} from "@/lib/automations/builder-schema"; +import { cn } from "@/lib/utils"; +import { AdvancedSection } from "./advanced-section"; +import { BasicsSection } from "./basics-section"; +import { BuilderSummary } from "./builder-summary"; +import { JsonModePanel } from "./json-mode-panel"; +import { ScheduleSection } from "./schedule-section"; +import { TaskList } from "./task-list"; +import { UnattendedToggle } from "./unattended-toggle"; + +interface AutomationBuilderFormProps { + mode: "create" | "edit"; + searchSpaceId: number; + /** Required in edit mode; seeds the form and trigger reconciliation. */ + automation?: Automation; +} + +type Mode = "form" | "json"; + +function mapFormErrors(error: z.ZodError): Record { + const out: Record = {}; + for (const issue of error.issues) { + const path = issue.path; + let key: string; + if (path[0] === "tasks" && typeof path[1] === "number") key = `tasks.${path[1]}.query`; + else if (path[0] === "schedule") key = "schedule"; + else key = String(path[0] ?? "_root"); + if (!out[key]) out[key] = issue.message; + } + return out; +} + +export function AutomationBuilderForm({ + mode, + searchSpaceId, + automation, +}: AutomationBuilderFormProps) { + const router = useRouter(); + const { mutateAsync: createAutomation } = useAtomValue(createAutomationMutationAtom); + const { mutateAsync: updateAutomation } = useAtomValue(updateAutomationMutationAtom); + const { mutateAsync: addTrigger } = useAtomValue(addTriggerMutationAtom); + const { mutateAsync: updateTrigger } = useAtomValue(updateTriggerMutationAtom); + const { mutateAsync: removeTrigger } = useAtomValue(removeTriggerMutationAtom); + + // Initial state: create starts empty in form mode; edit hydrates, falling + // back to JSON mode when the definition can't be represented in the form. + const initial = useMemo(() => { + if (mode === "edit" && automation) { + const result = formFromAutomation(automation); + if (result.formable) { + return { mode: "form" as Mode, form: result.form, notice: undefined }; + } + return { + mode: "json" as Mode, + form: createEmptyForm(), + notice: `This automation ${result.reason}, which the form can't show. Edit it as JSON below.`, + }; + } + return { mode: "form" as Mode, form: createEmptyForm(), notice: undefined }; + }, [mode, automation]); + + const [activeMode, setActiveMode] = useState(initial.mode); + const [form, setForm] = useState(initial.form); + const [errors, setErrors] = useState>({}); + const [rootError, setRootError] = useState(null); + + const [jsonValue, setJsonValue] = useState>(() => + initial.mode === "json" ? jsonFromAutomation(automation) : {} + ); + const [jsonIssues, setJsonIssues] = useState([]); + const [jsonNotice, setJsonNotice] = useState(initial.notice); + + const [submitting, setSubmitting] = useState(false); + + const cancelHref = + mode === "edit" && automation + ? `/dashboard/${searchSpaceId}/automations/${automation.id}` + : `/dashboard/${searchSpaceId}/automations`; + + function patchForm(patch: Partial) { + setForm((prev) => ({ ...prev, ...patch })); + } + + function jsonFromCurrentForm(): Record { + if (mode === "edit" && automation) { + return { ...buildUpdatePayload(form), status: automation.status }; + } + const { search_space_id: _ignored, ...rest } = buildCreatePayload(form, searchSpaceId); + return rest; + } + + function switchToJson() { + setJsonValue(jsonFromCurrentForm()); + setJsonIssues([]); + setJsonNotice(undefined); + setActiveMode("json"); + } + + function switchToForm() { + const result = tryJsonToForm(); + if (result.ok) { + setForm(result.form); + setErrors({}); + setRootError(null); + setActiveMode("form"); + return; + } + setJsonIssues(result.issues); + setJsonNotice(result.notice); + } + + function tryJsonToForm(): + | { ok: true; form: BuilderForm } + | { ok: false; issues: string[]; notice?: string } { + // Read the raw tree defensively rather than strict-validating: an + // incomplete JSON edit should still round-trip into the form, where the + // form's own validation enforces completeness on submit. + const definition = jsonValue.definition; + if (!definition || typeof definition !== "object") { + return { ok: false, issues: [], notice: "Add a definition before switching to the form." }; + } + + const name = + typeof jsonValue.name === "string" + ? jsonValue.name + : mode === "edit" && automation + ? automation.name + : ""; + const description = typeof jsonValue.description === "string" ? jsonValue.description : null; + const triggers = + mode === "edit" && automation + ? (automation.triggers ?? []) + : extractTriggers(jsonValue.triggers); + + const h = hydrateForm(name, description, definition, triggers); + return h.formable + ? { ok: true, form: h.form } + : { ok: false, issues: [], notice: `Can't show in the form: it ${h.reason}.` }; + } + + function validateForm(): Record | null { + const result = builderFormSchema.safeParse(form); + const next = result.success ? {} : mapFormErrors(result.error); + + // The schedule model fields aren't deeply validated by the schema. + if (form.schedule?.mode === "preset") { + const m = form.schedule.model; + if (m.frequency === "weekly" && m.daysOfWeek.length === 0) { + next.schedule = "Pick at least one day for the weekly schedule"; + } + } else if (form.schedule?.mode === "cron" && !form.schedule.cron.trim()) { + next.schedule = "Enter a schedule expression"; + } + + return Object.keys(next).length > 0 ? next : null; + } + + async function reconcileTriggers(automationId: number) { + const desired = buildScheduleTrigger(form); + const existing = (automation?.triggers ?? [])[0]; + if (!existing && desired) { + await addTrigger({ automationId, payload: desired }); + } else if (existing && !desired) { + await removeTrigger({ automationId, triggerId: existing.id }); + } else if (existing && desired) { + await updateTrigger({ + automationId, + triggerId: existing.id, + patch: { params: desired.params, enabled: desired.enabled }, + }); + } + } + + async function submitForm() { + setRootError(null); + const formErrors = validateForm(); + if (formErrors) { + setErrors(formErrors); + return; + } + setErrors({}); + + setSubmitting(true); + try { + if (mode === "edit" && automation) { + const payload = buildUpdatePayload(form); + const parsed = automationUpdateRequest.safeParse(payload); + if (!parsed.success) { + setRootError(zodIssueList(parsed.error).join("; ")); + return; + } + await updateAutomation({ automationId: automation.id, patch: parsed.data }); + await reconcileTriggers(automation.id); + router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`); + } else { + const payload = buildCreatePayload(form, searchSpaceId); + const parsed = automationCreateRequest.safeParse(payload); + if (!parsed.success) { + setRootError(zodIssueList(parsed.error).join("; ")); + return; + } + const created = await createAutomation(parsed.data); + router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`); + } + } catch (err) { + setRootError((err as Error).message ?? "Submit failed"); + } finally { + setSubmitting(false); + } + } + + async function submitJson() { + setJsonIssues([]); + setSubmitting(true); + try { + if (mode === "edit" && automation) { + const parsed = automationUpdateRequest.safeParse(jsonValue); + if (!parsed.success) { + setJsonIssues(zodIssueList(parsed.error)); + return; + } + await updateAutomation({ automationId: automation.id, patch: parsed.data }); + router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`); + } else { + const parsed = automationCreateRequest.safeParse({ + ...jsonValue, + search_space_id: searchSpaceId, + }); + if (!parsed.success) { + setJsonIssues(zodIssueList(parsed.error)); + return; + } + const created = await createAutomation(parsed.data); + router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`); + } + } catch (err) { + setJsonIssues([(err as Error).message ?? "Submit failed"]); + } finally { + setSubmitting(false); + } + } + + const submitLabel = mode === "edit" ? "Save changes" : "Create automation"; + + return ( +
+
+
+ (activeMode === "form" ? undefined : switchToForm())} + /> + (activeMode === "json" ? undefined : switchToJson())} + /> +
+
+ + {activeMode === "json" ? ( + + + + + + ) : ( +
+
+ + + Basics + + + + + + + + + Tasks + + + patchForm({ tasks })} + /> + patchForm({ unattended })} + /> + + + + + + Schedule + + + patchForm({ schedule })} + onTimezoneChange={(timezone) => patchForm({ timezone })} + /> + + + + + + Settings + + + + patchForm({ execution: { ...form.execution, ...patch } }) + } + onTagsChange={(tags) => patchForm({ tags })} + /> + + +
+ +
+ + + Summary + + + + + +
+
+ )} + + {rootError &&

{rootError}

} + +
+ + +
+
+ ); +} + +function ModeButton({ + active, + icon: Icon, + label, + onClick, +}: { + active: boolean; + icon: typeof Code2; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function extractTriggers(raw: unknown): HydratableTrigger[] { + if (!Array.isArray(raw)) return []; + return raw.map((entry) => { + const obj = entry && typeof entry === "object" ? (entry as Record) : {}; + return { + type: typeof obj.type === "string" ? obj.type : "", + params: + obj.params && typeof obj.params === "object" ? (obj.params as Record) : {}, + }; + }); +} + +function zodIssueList(error: z.ZodError): string[] { + return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`); +} + +function jsonFromAutomation(automation: Automation | undefined): Record { + if (!automation) return {}; + return { + name: automation.name, + description: automation.description ?? null, + status: automation.status, + definition: automation.definition, + }; +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx new file mode 100644 index 000000000..fdc9f4526 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/basics-section.tsx @@ -0,0 +1,42 @@ +"use client"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Field } from "./form-field"; + +interface BasicsSectionProps { + name: string; + description: string | null; + errors: Record; + onChange: (patch: { name?: string; description?: string | null }) => void; +} + +export function BasicsSection({ name, description, errors, onChange }: BasicsSectionProps) { + return ( +
+ + onChange({ name: e.target.value })} + /> + + + +