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.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-28 21:26:32 -07:00
parent c601a9b102
commit d013617bf6
25 changed files with 2490 additions and 281 deletions

View file

@ -18,6 +18,11 @@ def build_handler(ctx: ActionContext) -> ActionHandler:
ctx=ctx, ctx=ctx,
query=validated.query, query=validated.query,
auto_approve_all=validated.auto_approve_all, 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 return handle

View file

@ -8,9 +8,13 @@ from typing import Any
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
from langgraph.types import Command 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.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.db import ChatVisibility, async_session_maker
from app.schemas.new_chat import MentionedDocumentInfo
from ..types import ActionContext from ..types import ActionContext
from .auto_decide import build_auto_decisions from .auto_decide import build_auto_decisions
@ -22,17 +26,118 @@ from .finalize import extract_final_assistant_message
_MAX_RESUMES = 50 _MAX_RESUMES = 50
def _build_connector_block(connectors: list[dict[str, Any]]) -> str | None:
"""Render the ``<mentioned_connectors>`` 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 (
"<mentioned_connectors>\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</mentioned_connectors>"
)
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 ``<mentioned_connectors>`` 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<user_query>{agent_query}</user_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( async def run_agent_task(
*, *,
ctx: ActionContext, ctx: ActionContext,
query: str, query: str,
auto_approve_all: bool, 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]: ) -> dict[str, Any]:
"""Invoke multi_agent_chat for one rendered query and return its outcome. """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 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) up for the entire invocation. The LangGraph ``thread_id`` (a fresh UUID)
is returned as ``agent_session_id`` for later inspection. 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()) agent_session_id = str(uuid.uuid4())
user_id = str(ctx.creator_user_id) if ctx.creator_user_id else None 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, agent_config=deps.agent_config,
firecrawl_api_key=deps.firecrawl_api_key, firecrawl_api_key=deps.firecrawl_api_key,
thread_visibility=ChatVisibility.PRIVATE, 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}" request_id = f"automation:{ctx.run_id}:{ctx.step_id}"
turn_id = f"{request_id}:{int(time.time() * 1000)}" turn_id = f"{request_id}:{int(time.time() * 1000)}"
input_state: dict[str, Any] = { input_state: dict[str, Any] = {
"messages": [HumanMessage(content=query)], "messages": [HumanMessage(content=agent_query)],
"search_space_id": ctx.search_space_id, "search_space_id": ctx.search_space_id,
"request_id": request_id, "request_id": request_id,
"turn_id": turn_id, "turn_id": turn_id,
@ -73,8 +190,17 @@ async def run_agent_task(
}, },
"recursion_limit": 10_000, "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 resumes = 0
while True: while True:
@ -87,7 +213,7 @@ async def run_agent_task(
) )
lg_resume_map, routed = build_auto_decisions(state, decision) lg_resume_map, routed = build_auto_decisions(state, decision)
config["configurable"]["surfsense_resume_value"] = routed 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 resumes += 1
return { return {

View file

@ -4,6 +4,8 @@ from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.schemas.new_chat import MentionedDocumentInfo
class AgentTaskActionParams(BaseModel): class AgentTaskActionParams(BaseModel):
"""Run a multi_agent_chat turn from an automation step.""" """Run a multi_agent_chat turn from an automation step."""
@ -19,3 +21,32 @@ class AgentTaskActionParams(BaseModel):
default=False, default=False,
description="If true, every HITL approval is auto-approved; otherwise rejected.", 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."
),
)

View file

@ -1,10 +1,11 @@
"use client"; "use client";
import { ShieldAlert } from "lucide-react"; import { ShieldAlert } from "lucide-react";
import { useAutomation } from "@/hooks/use-automation"; import { useAutomation } from "@/hooks/use-automation";
import { AutomationBuilderForm } from "../../components/builder/automation-builder-form";
import { useAutomationPermissions } from "../../hooks/use-automation-permissions"; import { useAutomationPermissions } from "../../hooks/use-automation-permissions";
import { AutomationDetailLoading } from "../components/automation-detail-loading"; import { AutomationDetailLoading } from "../components/automation-detail-loading";
import { AutomationNotFound } from "../components/automation-not-found"; import { AutomationNotFound } from "../components/automation-not-found";
import { AutomationEditForm } from "./components/automation-edit-form"; import { AutomationEditHeader } from "./components/automation-edit-header";
interface AutomationEditContentProps { interface AutomationEditContentProps {
searchSpaceId: number; searchSpaceId: number;
@ -49,5 +50,10 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />; return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
} }
return <AutomationEditForm automation={automation} searchSpaceId={searchSpaceId} />; return (
<>
<AutomationEditHeader automation={automation} searchSpaceId={searchSpaceId} />
<AutomationBuilderForm mode="edit" searchSpaceId={searchSpaceId} automation={automation} />
</>
);
} }

View file

@ -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<string[]>([]);
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 (
<>
<div className="space-y-3">
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
<Link href={detailHref} className="text-xs text-muted-foreground">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Back to automation
</Link>
</Button>
<div>
<h1 className="text-xl md:text-2xl font-semibold text-foreground break-words">
Edit automation
</h1>
<p className="text-sm text-muted-foreground mt-1">{automation.name}</p>
</div>
</div>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold">Definition</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[36rem] overflow-auto">
<JsonView
src={value}
editable
onChange={(next) => setValue(next as typeof value)}
collapsed={false}
/>
</div>
{issues.length > 0 && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive mb-1.5">
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
</div>
<ul className="space-y-0.5 text-xs text-destructive list-disc list-inside">
{issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button asChild type="button" variant="ghost" size="sm">
<Link href={detailHref}>Cancel</Link>
</Button>
<Button type="button" onClick={handleSave} disabled={isPending} size="sm">
{isPending ? (
<Spinner size="xs" className="mr-2" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save changes
</Button>
</div>
</CardContent>
</Card>
</>
);
}

View file

@ -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 (
<div className="space-y-3">
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
<Link href={detailHref} className="text-xs text-muted-foreground">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Back to automation
</Link>
</Button>
<div>
<h1 className="text-xl md:text-2xl font-semibold text-foreground wrap-break-word">
Edit automation
</h1>
<p className="text-sm text-muted-foreground mt-1">{automation.name}</p>
</div>
</div>
);
}

View file

@ -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<BuilderExecution>) => 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 (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Timeout (seconds)" hint="Wall-clock cap for the whole run.">
<Input
type="number"
min={1}
value={execution.timeoutSeconds}
onChange={(e) =>
onExecutionChange({ timeoutSeconds: clampInt(e.target.value, 1, 600) })
}
/>
</Field>
<Field label="Max retries" hint="Per-step retry budget.">
<Input
type="number"
min={0}
value={execution.maxRetries}
onChange={(e) => onExecutionChange({ maxRetries: clampInt(e.target.value, 0, 2) })}
/>
</Field>
<Field label="Retry backoff">
<Select
value={execution.retryBackoff}
onValueChange={(value) =>
onExecutionChange({ retryBackoff: value as BuilderExecution["retryBackoff"] })
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BACKOFF_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="If already running">
<Select
value={execution.concurrency}
onValueChange={(value) =>
onExecutionChange({ concurrency: value as BuilderExecution["concurrency"] })
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONCURRENCY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<Field label="Tags" hint="Comma-separated. Optional.">
<Input
value={tagsText}
placeholder="research, weekly"
onChange={(e) => setTagsText(e.target.value)}
onBlur={(e) => commitTags(e.target.value)}
/>
</Field>
</div>
);
}

View file

@ -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<string, string> {
const out: Record<string, string> = {};
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<Mode>(initial.mode);
const [form, setForm] = useState<BuilderForm>(initial.form);
const [errors, setErrors] = useState<Record<string, string>>({});
const [rootError, setRootError] = useState<string | null>(null);
const [jsonValue, setJsonValue] = useState<Record<string, unknown>>(() =>
initial.mode === "json" ? jsonFromAutomation(automation) : {}
);
const [jsonIssues, setJsonIssues] = useState<string[]>([]);
const [jsonNotice, setJsonNotice] = useState<string | undefined>(initial.notice);
const [submitting, setSubmitting] = useState(false);
const cancelHref =
mode === "edit" && automation
? `/dashboard/${searchSpaceId}/automations/${automation.id}`
: `/dashboard/${searchSpaceId}/automations`;
function patchForm(patch: Partial<BuilderForm>) {
setForm((prev) => ({ ...prev, ...patch }));
}
function jsonFromCurrentForm(): Record<string, unknown> {
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<string, string> | 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 (
<div className="space-y-4">
<div className="flex items-center justify-end">
<div className="inline-flex rounded-md border border-border/60 p-0.5">
<ModeButton
active={activeMode === "form"}
icon={LayoutList}
label="Form"
onClick={() => (activeMode === "form" ? undefined : switchToForm())}
/>
<ModeButton
active={activeMode === "json"}
icon={Code2}
label="Edit as JSON"
onClick={() => (activeMode === "json" ? undefined : switchToJson())}
/>
</div>
</div>
{activeMode === "json" ? (
<Card className="border-border/60 bg-accent">
<CardContent className="pt-6">
<JsonModePanel
value={jsonValue}
issues={jsonIssues}
notice={jsonNotice}
onChange={setJsonValue}
/>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Basics</CardTitle>
</CardHeader>
<CardContent>
<BasicsSection
name={form.name}
description={form.description}
errors={errors}
onChange={patchForm}
/>
</CardContent>
</Card>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Tasks</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<TaskList
tasks={form.tasks}
errors={errors}
searchSpaceId={searchSpaceId}
onChange={(tasks) => patchForm({ tasks })}
/>
<UnattendedToggle
checked={form.unattended}
onChange={(unattended) => patchForm({ unattended })}
/>
</CardContent>
</Card>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Schedule</CardTitle>
</CardHeader>
<CardContent>
<ScheduleSection
schedule={form.schedule}
timezone={form.timezone}
errors={errors}
onScheduleChange={(schedule) => patchForm({ schedule })}
onTimezoneChange={(timezone) => patchForm({ timezone })}
/>
</CardContent>
</Card>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Settings</CardTitle>
</CardHeader>
<CardContent>
<AdvancedSection
execution={form.execution}
tags={form.tags}
onExecutionChange={(patch) =>
patchForm({ execution: { ...form.execution, ...patch } })
}
onTagsChange={(tags) => patchForm({ tags })}
/>
</CardContent>
</Card>
</div>
<div className="lg:col-span-1">
<Card className="border-border/60 bg-accent lg:sticky lg:top-4">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Summary</CardTitle>
</CardHeader>
<CardContent>
<BuilderSummary form={form} />
</CardContent>
</Card>
</div>
</div>
)}
{rootError && <p className="text-right text-xs text-destructive">{rootError}</p>}
<div className="flex items-center justify-end gap-2">
<Button asChild type="button" variant="ghost" size="sm">
<Link href={cancelHref}>Cancel</Link>
</Button>
<Button
type="button"
size="sm"
disabled={submitting}
onClick={() => (activeMode === "json" ? submitJson() : submitForm())}
>
{submitting ? <Spinner size="xs" className="mr-2" /> : <Save className="mr-2 h-4 w-4" />}
{submitLabel}
</Button>
</div>
</div>
);
}
function ModeButton({
active,
icon: Icon,
label,
onClick,
}: {
active: boolean;
icon: typeof Code2;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
);
}
function extractTriggers(raw: unknown): HydratableTrigger[] {
if (!Array.isArray(raw)) return [];
return raw.map((entry) => {
const obj = entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
return {
type: typeof obj.type === "string" ? obj.type : "",
params:
obj.params && typeof obj.params === "object" ? (obj.params as Record<string, unknown>) : {},
};
});
}
function zodIssueList(error: z.ZodError): string[] {
return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`);
}
function jsonFromAutomation(automation: Automation | undefined): Record<string, unknown> {
if (!automation) return {};
return {
name: automation.name,
description: automation.description ?? null,
status: automation.status,
definition: automation.definition,
};
}

View file

@ -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<string, string>;
onChange: (patch: { name?: string; description?: string | null }) => void;
}
export function BasicsSection({ name, description, errors, onChange }: BasicsSectionProps) {
return (
<div className="space-y-4">
<Field label="Name" htmlFor="automation-name" required error={errors.name}>
<Input
id="automation-name"
value={name}
maxLength={200}
placeholder="Weekly competitor digest"
onChange={(e) => onChange({ name: e.target.value })}
/>
</Field>
<Field
label="Description"
htmlFor="automation-description"
hint="Optional. A short note about what this automation is for."
error={errors.description}
>
<Textarea
id="automation-description"
value={description ?? ""}
rows={2}
placeholder="Summarize what changed and email me the highlights."
onChange={(e) => onChange({ description: e.target.value })}
/>
</Field>
</div>
);
}

View file

@ -0,0 +1,96 @@
"use client";
import { CalendarClock, CheckCircle2, ListOrdered, type LucideIcon, XCircle } from "lucide-react";
import { type BuilderForm, scheduleToCron } from "@/lib/automations/builder-schema";
import { describeCron } from "@/lib/automations/describe-cron";
interface BuilderSummaryProps {
form: BuilderForm;
}
/**
* Live, read-only mirror of what will be created. Mirrors the layout of the
* chat ``AutomationDraftPreview`` so the two creation paths feel consistent.
*/
export function BuilderSummary({ form }: BuilderSummaryProps) {
const scheduleLabel = form.schedule
? `${describeCron(scheduleToCron(form.schedule))} · ${form.timezone}`
: "No schedule — won't run automatically";
return (
<div className="space-y-4 text-sm">
<div className="space-y-1">
<p className="font-medium text-foreground">{form.name.trim() || "Untitled automation"}</p>
{form.description?.trim() && (
<p className="text-xs text-muted-foreground">{form.description.trim()}</p>
)}
</div>
<Section icon={CalendarClock} label="Schedule">
<p className="text-xs text-foreground">{scheduleLabel}</p>
</Section>
<Section
icon={ListOrdered}
label={`Tasks · ${form.tasks.length} step${form.tasks.length === 1 ? "" : "s"}`}
>
<ol className="space-y-1.5 text-xs">
{form.tasks.map((task, index) => (
<li key={task.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">
{index + 1}
</span>
<span className="min-w-0 flex-1 space-y-1">
<span className="block text-foreground line-clamp-2">
{task.query.trim() || (
<span className="text-muted-foreground">No instructions yet</span>
)}
</span>
{task.mentions.length > 0 && (
<span className="flex flex-wrap gap-1">
{task.mentions.map((mention) => (
<span
key={`${mention.kind}:${mention.id}`}
className="inline-flex max-w-[140px] items-center truncate rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary/70"
>
@{mention.title}
</span>
))}
</span>
)}
</span>
</li>
))}
</ol>
</Section>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{form.unattended ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" aria-hidden />
) : (
<XCircle className="h-3.5 w-3.5" aria-hidden />
)}
{form.unattended ? "Runs without approval prompts" : "Will reject approval prompts"}
</div>
</div>
);
}
function Section({
icon: Icon,
label,
children,
}: {
icon: LucideIcon;
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>
);
}

View file

@ -0,0 +1,42 @@
"use client";
import { AlertCircle } from "lucide-react";
import type { ReactNode } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface FieldProps {
label?: string;
htmlFor?: string;
hint?: string;
error?: string;
required?: boolean;
className?: string;
children: ReactNode;
}
/**
* Label + control + (hint | inline error) stack shared by every builder
* section. Keeps spacing and error styling consistent so individual sections
* stay focused on their inputs.
*/
export function Field({ label, htmlFor, hint, error, required, className, children }: FieldProps) {
return (
<div className={cn("space-y-1.5", className)}>
{label && (
<Label htmlFor={htmlFor} className="text-xs font-medium text-foreground">
{label}
{required && <span className="text-muted-foreground">*</span>}
</Label>
)}
{children}
{error ? (
<p className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3 w-3 shrink-0" aria-hidden />
{error}
</p>
) : hint ? (
<p className="text-xs text-muted-foreground">{hint}</p>
) : null}
</div>
);
}

View file

@ -0,0 +1,51 @@
"use client";
import { AlertCircle } from "lucide-react";
import { JsonView } from "@/components/json-view";
interface JsonModePanelProps {
value: Record<string, unknown>;
issues: string[];
notice?: string;
onChange: (next: Record<string, unknown>) => void;
}
/**
* Raw-JSON escape hatch. Edits the same payload the form produces; the
* orchestrator validates it against the contract schema on submit. Shown when
* the user opts into "Edit as JSON" or when an existing definition uses
* features the form can't represent.
*/
export function JsonModePanel({ value, issues, notice, onChange }: JsonModePanelProps) {
return (
<div className="space-y-4">
{notice && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
{notice}
</div>
)}
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-144 overflow-auto">
<JsonView
src={value}
editable
onChange={(next) => onChange(next as Record<string, unknown>)}
collapsed={false}
/>
</div>
{issues.length > 0 && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive mb-1.5">
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
</div>
<ul className="space-y-0.5 text-xs text-destructive list-disc list-inside">
{issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,258 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
import {
InlineMentionEditor,
type InlineMentionEditorRef,
type MentionChipInput,
type MentionedDocument,
type SuggestionAnchorRect,
type SuggestionTriggerInfo,
} from "@/components/assistant-ui/inline-mention-editor";
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
import {
DocumentMentionPicker,
type DocumentMentionPickerRef,
} from "@/components/new-chat/document-mention-picker";
import { Popover, PopoverAnchor } from "@/components/ui/popover";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
interface MentionTaskInputProps {
searchSpaceId: number;
value: string;
mentions: MentionedDocumentInfo[];
onChange: (text: string, mentions: MentionedDocumentInfo[]) => void;
placeholder?: string;
disabled?: boolean;
}
type AnchorPoint = { left: number; top: number };
// Mirror of thread.tsx's getComposerSuggestionAnchorPoint -- kept local so the
// chat composer stays untouched.
function getAnchorPoint(rect: SuggestionAnchorRect | null): AnchorPoint | null {
if (!rect) return null;
return { left: rect.left, top: rect.bottom };
}
/** Project the editor's chip shape into the canonical mention info union. */
function toMentionInfo(doc: MentionedDocument): MentionedDocumentInfo {
if (doc.kind === "connector") {
return {
id: doc.id,
title: doc.title,
kind: "connector",
connector_type: doc.connector_type ?? "UNKNOWN",
account_name: doc.account_name ?? doc.title,
};
}
if (doc.kind === "folder") {
return { id: doc.id, title: doc.title, kind: "folder" };
}
return {
id: doc.id,
title: doc.title,
document_type: doc.document_type ?? "UNKNOWN",
kind: "doc",
};
}
/** Project a mention info into the editor's chip-insertion shape. */
function toChipInput(mention: MentionedDocumentInfo): MentionChipInput {
if (mention.kind === "connector") {
return {
id: mention.id,
title: mention.title,
kind: "connector",
connector_type: mention.connector_type,
account_name: mention.account_name,
};
}
if (mention.kind === "folder") {
return { id: mention.id, title: mention.title, kind: "folder" };
}
return {
id: mention.id,
title: mention.title,
kind: "doc",
document_type: mention.document_type,
};
}
function removeFirstToken(text: string, token: string): string {
const index = text.indexOf(token);
if (index === -1) return text;
return text.slice(0, index) + text.slice(index + token.length);
}
/**
* Task input that reuses the chat ``@`` mention experience -- the same
* ``InlineMentionEditor`` + ``DocumentMentionPicker`` as the composer, minus
* SurfSense product docs. The editor is the source of truth while mounted;
* ``onChange`` reports both the plain text (chips rendered as ``@Title``) and
* the structured mention list so the builder can persist IDs for the run.
*/
export function MentionTaskInput({
searchSpaceId,
value,
mentions,
onChange,
placeholder,
disabled,
}: MentionTaskInputProps) {
const editorRef = useRef<InlineMentionEditorRef>(null);
const pickerRef = useRef<DocumentMentionPickerRef>(null);
const [showPopover, setShowPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [anchorPoint, setAnchorPoint] = useState<AnchorPoint | null>(null);
// One-shot hydration of existing mentions into real chips. ``initialText``
// seeds the literal ``@Title`` text; here we strip those tokens and
// re-insert them as chips so the editor reports the structured docs (and
// editing can't silently drop the mention IDs). Position isn't preserved
// on re-hydration -- chips append after the remaining prose.
const didHydrateRef = useRef(false);
useEffect(() => {
if (didHydrateRef.current) return;
didHydrateRef.current = true;
if (mentions.length === 0) return;
const editor = editorRef.current;
if (!editor) return;
let baseText = value;
for (const mention of mentions) {
baseText = removeFirstToken(baseText, `@${mention.title}`);
}
baseText = baseText.replace(/[ \t]{2,}/g, " ").trim();
editor.setText(baseText);
for (const mention of mentions) {
editor.insertMentionChip(toChipInput(mention), { removeTriggerText: false });
}
}, [mentions, value]);
const closePopover = useCallback(() => {
setShowPopover(false);
setMentionQuery("");
setAnchorPoint(null);
}, []);
const handleEditorChange = useCallback(
(text: string, docs: MentionedDocument[]) => {
onChange(text, docs.map(toMentionInfo));
},
[onChange]
);
const handleMentionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
const point = getAnchorPoint(trigger.anchorRect);
if (!point) {
setShowPopover(false);
setMentionQuery("");
setAnchorPoint(null);
return;
}
setAnchorPoint((current) => current ?? point);
setShowPopover(true);
setMentionQuery(trigger.query);
}, []);
const handleMentionClose = useCallback(() => {
setShowPopover((open) => {
if (open) {
setMentionQuery("");
setAnchorPoint(null);
}
return false;
});
}, []);
const handlePopoverOpenChange = useCallback((open: boolean) => {
setShowPopover(open);
if (!open) {
setMentionQuery("");
setAnchorPoint(null);
}
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!showPopover) return;
if (e.key === "ArrowDown") {
e.preventDefault();
pickerRef.current?.moveDown();
} else if (e.key === "ArrowUp") {
e.preventDefault();
pickerRef.current?.moveUp();
} else if (e.key === "Enter") {
e.preventDefault();
pickerRef.current?.selectHighlighted();
} else if (e.key === "Escape") {
e.preventDefault();
if (pickerRef.current?.goBack()) return;
closePopover();
}
},
[showPopover, closePopover]
);
const handleSelection = useCallback(
(picked: MentionedDocumentInfo[]) => {
const editor = editorRef.current;
const existing = new Set(
(editor?.getMentionedDocuments() ?? []).map((doc) => getMentionDocKey(doc))
);
for (const mention of picked) {
const key = getMentionDocKey(mention);
if (existing.has(key)) continue;
editor?.insertMentionChip(toChipInput(mention));
existing.add(key);
}
closePopover();
},
[closePopover]
);
return (
<div
className={cn(
"border-popover-border focus-within:border-ring focus-within:ring-ring/50 dark:bg-input/30 min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]",
disabled && "cursor-not-allowed opacity-50"
)}
>
<Popover open={showPopover} onOpenChange={handlePopoverOpenChange}>
{anchorPoint ? (
<>
<PopoverAnchor
className="pointer-events-none fixed size-0"
style={{ left: anchorPoint.left, top: anchorPoint.top }}
/>
<ComposerSuggestionPopoverContent side="bottom">
<DocumentMentionPicker
ref={pickerRef}
searchSpaceId={searchSpaceId}
includeSurfsenseDocs={false}
onSelectionChange={handleSelection}
onDone={closePopover}
initialSelectedDocuments={mentions}
externalSearch={mentionQuery}
/>
</ComposerSuggestionPopoverContent>
</>
) : null}
</Popover>
<InlineMentionEditor
ref={editorRef}
initialText={value}
placeholder={placeholder ?? "Type @ to reference files, folders, or connectors"}
disabled={disabled}
onChange={handleEditorChange}
onMentionTrigger={handleMentionTrigger}
onMentionClose={handleMentionClose}
onKeyDown={handleKeyDown}
/>
</div>
);
}

View file

@ -0,0 +1,275 @@
"use client";
import { CalendarClock, CalendarOff, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { type BuilderSchedule, scheduleToCron } from "@/lib/automations/builder-schema";
import { describeCron } from "@/lib/automations/describe-cron";
import {
DEFAULT_SCHEDULE,
FREQUENCY_OPTIONS,
fromCron,
type ScheduleFrequency,
type ScheduleModel,
toCron,
WEEKDAY_OPTIONS,
} from "@/lib/automations/schedule-builder";
import { cn } from "@/lib/utils";
import { Field } from "./form-field";
import { TimezoneCombobox } from "./timezone-combobox";
interface ScheduleSectionProps {
schedule: BuilderSchedule | null;
timezone: string;
errors: Record<string, string>;
onScheduleChange: (schedule: BuilderSchedule | null) => void;
onTimezoneChange: (timezone: string) => void;
}
function pad(value: number): string {
return value.toString().padStart(2, "0");
}
export function ScheduleSection({
schedule,
timezone,
errors,
onScheduleChange,
onTimezoneChange,
}: ScheduleSectionProps) {
if (schedule === null) {
return (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-6 text-center">
<CalendarOff className="mx-auto h-7 w-7 text-muted-foreground" aria-hidden />
<p className="mt-2 text-sm text-foreground">No schedule</p>
<p className="mt-0.5 text-xs text-muted-foreground">
This automation won't run automatically until you add one.
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={() => onScheduleChange({ mode: "preset", model: { ...DEFAULT_SCHEDULE } })}
>
<Plus className="mr-1.5 h-4 w-4" />
Add a schedule
</Button>
</div>
);
}
const cron = scheduleToCron(schedule);
const label = describeCron(cron);
return (
<div className="space-y-3">
<div className="flex items-start justify-between gap-3 rounded-md border border-border/60 bg-background px-3 py-2">
<div className="flex items-center gap-2 text-sm min-w-0">
<CalendarClock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="font-medium text-foreground truncate">{label}</span>
<span className="text-muted-foreground shrink-0">· {timezone}</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
aria-label="Remove schedule"
onClick={() => onScheduleChange(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
{schedule.mode === "preset" ? (
<PresetEditor
model={schedule.model}
onChange={(model) => onScheduleChange({ mode: "preset", model })}
onSwitchToCron={() => onScheduleChange({ mode: "cron", cron: toCron(schedule.model) })}
/>
) : (
<CronEditor
cron={schedule.cron}
error={errors.schedule}
onChange={(value) => onScheduleChange({ mode: "cron", cron: value })}
onSwitchToPreset={() =>
onScheduleChange({
mode: "preset",
model: fromCron(schedule.cron) ?? { ...DEFAULT_SCHEDULE },
})
}
/>
)}
<Field label="Timezone">
<TimezoneCombobox value={timezone} onChange={onTimezoneChange} />
</Field>
</div>
);
}
interface PresetEditorProps {
model: ScheduleModel;
onChange: (model: ScheduleModel) => void;
onSwitchToCron: () => void;
}
function PresetEditor({ model, onChange, onSwitchToCron }: PresetEditorProps) {
const weeklyNoDays = model.frequency === "weekly" && model.daysOfWeek.length === 0;
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Frequency">
<Select
value={model.frequency}
onValueChange={(value) => onChange({ ...model, frequency: value as ScheduleFrequency })}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{model.frequency === "hourly" ? (
<Field label="At minute">
<Input
type="number"
min={0}
max={59}
value={model.minute}
onChange={(e) => onChange({ ...model, minute: clampInt(e.target.value, 0, 59) })}
/>
</Field>
) : (
<Field label="At time">
<Input
type="time"
value={`${pad(model.hour)}:${pad(model.minute)}`}
onChange={(e) => {
const [h, m] = e.target.value.split(":");
onChange({
...model,
hour: clampInt(h, 0, 23),
minute: clampInt(m, 0, 59),
});
}}
/>
</Field>
)}
</div>
{model.frequency === "weekly" && (
<Field label="On days" error={weeklyNoDays ? "Pick at least one day" : undefined}>
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_OPTIONS.map((day) => {
const active = model.daysOfWeek.includes(day.value);
return (
<button
key={day.value}
type="button"
aria-pressed={active}
onClick={() =>
onChange({ ...model, daysOfWeek: toggleDay(model.daysOfWeek, day.value) })
}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border/60 bg-background text-muted-foreground hover:bg-muted"
)}
>
{day.short}
</button>
);
})}
</div>
</Field>
)}
{model.frequency === "monthly" && (
<Field label="Day of month" hint={"1\u201331."}>
<Input
type="number"
min={1}
max={31}
value={model.dayOfMonth}
onChange={(e) => onChange({ ...model, dayOfMonth: clampInt(e.target.value, 1, 31) })}
className="w-24"
/>
</Field>
)}
<button
type="button"
onClick={onSwitchToCron}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Advanced: enter a schedule expression
</button>
</div>
);
}
interface CronEditorProps {
cron: string;
error?: string;
onChange: (cron: string) => void;
onSwitchToPreset: () => void;
}
function CronEditor({ cron, error, onChange, onSwitchToPreset }: CronEditorProps) {
const trimmed = cron.trim();
const label = trimmed ? describeCron(trimmed) : null;
return (
<div className="space-y-2">
<Field
label="Schedule expression"
hint="Five-field cron, e.g. 0 9 * * 1-5 (minute hour day month weekday)."
error={error}
>
<Input
value={cron}
placeholder="0 9 * * 1-5"
className="font-mono"
onChange={(e) => onChange(e.target.value)}
/>
</Field>
{label && label !== trimmed && <p className="text-xs text-muted-foreground">Runs: {label}</p>}
<button
type="button"
onClick={onSwitchToPreset}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Use the simple picker
</button>
</div>
);
}
function clampInt(raw: string, min: number, max: number): number {
const value = Number.parseInt(raw, 10);
if (Number.isNaN(value)) return min;
return Math.min(max, Math.max(min, value));
}
function toggleDay(days: number[], value: number): number[] {
return days.includes(value)
? days.filter((day) => day !== value)
: [...days, value].sort((a, b) => a - b);
}

View file

@ -0,0 +1,136 @@
"use client";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { BuilderTask } from "@/lib/automations/builder-schema";
import { Field } from "./form-field";
import { MentionTaskInput } from "./mention-task-input";
interface TaskItemProps {
index: number;
total: number;
task: BuilderTask;
searchSpaceId: number;
error?: string;
onChange: (patch: Partial<BuilderTask>) => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}
function parseOptionalInt(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === "") return null;
const value = Number.parseInt(trimmed, 10);
return Number.isNaN(value) ? null : value;
}
export function TaskItem({
index,
total,
task,
searchSpaceId,
error,
onChange,
onMoveUp,
onMoveDown,
onRemove,
}: TaskItemProps) {
return (
<div className="rounded-lg border border-border/60 bg-background p-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-foreground">
{index + 1}
</span>
Task {index + 1}
</span>
<div className="flex items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
disabled={index === 0}
aria-label="Move task up"
onClick={onMoveUp}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
disabled={index === total - 1}
aria-label="Move task down"
onClick={onMoveDown}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={total === 1}
aria-label="Remove task"
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<Field
error={error}
hint="Type @ to reference files, folders, or connectors for extra context."
>
<MentionTaskInput
searchSpaceId={searchSpaceId}
value={task.query}
mentions={task.mentions}
placeholder="What should the agent do? e.g. Summarize new docs in @Marketing since the last run."
onChange={(query, mentions) => onChange({ query, mentions })}
/>
</Field>
<Accordion type="single" collapsible>
<AccordionItem value="advanced" className="border-b-0">
<AccordionTrigger className="py-1.5 text-xs text-muted-foreground hover:no-underline">
Advanced
</AccordionTrigger>
<AccordionContent className="pb-1">
<div className="grid grid-cols-2 gap-3">
<Field label="Max retries" hint="Leave blank to use the default.">
<Input
type="number"
min={0}
max={10}
value={task.maxRetries ?? ""}
placeholder="default"
onChange={(e) => onChange({ maxRetries: parseOptionalInt(e.target.value) })}
/>
</Field>
<Field label="Timeout (seconds)" hint="Leave blank to use the default.">
<Input
type="number"
min={1}
value={task.timeoutSeconds ?? ""}
placeholder="default"
onChange={(e) => onChange({ timeoutSeconds: parseOptionalInt(e.target.value) })}
/>
</Field>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View file

@ -0,0 +1,65 @@
"use client";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { type BuilderTask, emptyTask } from "@/lib/automations/builder-schema";
import { TaskItem } from "./task-item";
interface TaskListProps {
tasks: BuilderTask[];
errors: Record<string, string>;
searchSpaceId: number;
onChange: (tasks: BuilderTask[]) => void;
}
/**
* Ordered list of agent tasks. Steps run sequentially in the order shown.
* Reordering is done with up/down buttons to avoid a drag-and-drop dependency.
*/
export function TaskList({ tasks, errors, searchSpaceId, onChange }: TaskListProps) {
function updateAt(index: number, patch: Partial<BuilderTask>) {
onChange(tasks.map((task, i) => (i === index ? { ...task, ...patch } : task)));
}
function removeAt(index: number) {
onChange(tasks.filter((_, i) => i !== index));
}
function move(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= tasks.length) return;
const next = [...tasks];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
}
return (
<div className="space-y-3">
{tasks.map((task, index) => (
<TaskItem
key={task.id}
index={index}
total={tasks.length}
task={task}
searchSpaceId={searchSpaceId}
error={errors[`tasks.${index}.query`]}
onChange={(patch) => updateAt(index, patch)}
onMoveUp={() => move(index, -1)}
onMoveDown={() => move(index, 1)}
onRemove={() => removeAt(index)}
/>
))}
{errors.tasks && <p className="text-xs text-destructive">{errors.tasks}</p>}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onChange([...tasks, emptyTask()])}
>
<Plus className="mr-1.5 h-4 w-4" />
Add task
</Button>
</div>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { Check, ChevronsUpDown } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { getTimezones } from "@/lib/automations/builder-schema";
import { cn } from "@/lib/utils";
interface TimezoneComboboxProps {
value: string;
onChange: (value: string) => void;
}
/**
* Searchable IANA timezone picker. The full ``Intl.supportedValuesOf`` list is
* long, so it lives behind a Command search instead of a flat Select.
*/
export function TimezoneCombobox({ value, onChange }: TimezoneComboboxProps) {
const [open, setOpen] = useState(false);
const timezones = useMemo(() => getTimezones(), []);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
<span className="truncate">{value || "Select timezone"}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search timezone..." />
<CommandList>
<CommandEmpty>No timezone found.</CommandEmpty>
<CommandGroup>
{timezones.map((tz) => (
<CommandItem
key={tz}
value={tz}
onSelect={() => {
onChange(tz);
setOpen(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", value === tz ? "opacity-100" : "opacity-0")}
/>
{tz}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import { Info } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface UnattendedToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
}
/**
* Maps to ``auto_approve_all`` on every agent task. Automations run with no one
* watching, so this defaults ON; turning it off means any approval prompt the
* agent raises is rejected and the step can stall.
*/
export function UnattendedToggle({ checked, onChange }: UnattendedToggleProps) {
return (
<div className="flex items-start justify-between gap-3 rounded-lg border border-border/60 bg-background px-3 py-3">
<div className="space-y-0.5 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground">
Run without asking for approvals
</span>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" aria-label="More info" className="text-muted-foreground">
<Info className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Automations run unattended. With this off, any approval the agent asks for is
rejected, which can stall a step.
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
Auto-approve actions the agent would normally pause to confirm.
</p>
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
aria-label="Run without asking for approvals"
/>
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { ShieldAlert } from "lucide-react"; import { ShieldAlert } from "lucide-react";
import { AutomationBuilderForm } from "../components/builder/automation-builder-form";
import { useAutomationPermissions } from "../hooks/use-automation-permissions"; import { useAutomationPermissions } from "../hooks/use-automation-permissions";
import { AutomationJsonForm } from "./components/automation-json-form";
import { AutomationNewHeader } from "./components/automation-new-header"; import { AutomationNewHeader } from "./components/automation-new-header";
interface AutomationNewContentProps { interface AutomationNewContentProps {
@ -9,10 +9,10 @@ interface AutomationNewContentProps {
} }
/** /**
* Orchestrator for the raw-JSON create route. Gates on * Orchestrator for the create route. Gates on ``automations:create`` so users
* ``automations:create`` so users who can't create don't even see the * who can't create don't even see the form; same panel as the detail page's
* form; same panel as the detail page's access-denied state for * access-denied state for consistency. The builder defaults to the friendly
* consistency. * form with a raw-JSON escape hatch.
*/ */
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) { export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
const perms = useAutomationPermissions(); const perms = useAutomationPermissions();
@ -36,7 +36,7 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
return ( return (
<> <>
<AutomationNewHeader searchSpaceId={searchSpaceId} /> <AutomationNewHeader searchSpaceId={searchSpaceId} />
<AutomationJsonForm searchSpaceId={searchSpaceId} /> <AutomationBuilderForm mode="create" searchSpaceId={searchSpaceId} />
</> </>
); );
} }

View file

@ -1,98 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, FileJson, Save } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createAutomationMutationAtom } 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 { automationCreateRequest } from "@/contracts/types/automation.types";
import { DEFAULT_AUTOMATION_TEMPLATE } from "@/lib/automations/default-template";
interface AutomationJsonFormProps {
searchSpaceId: number;
}
/**
* Raw-JSON create form. Lets power users skip the chat drafter when they
* already know the shape they want. Flow:
* edit tree inject search_space_id Zod validate POST navigate
*
* ``search_space_id`` is injected here rather than required in the edited
* tree the user shouldn't have to know their numeric id, and it keeps
* the template copy-paste-friendly across search spaces.
*/
export function AutomationJsonForm({ searchSpaceId }: AutomationJsonFormProps) {
const router = useRouter();
const { mutateAsync: createAutomation, isPending } = useAtomValue(createAutomationMutationAtom);
const [value, setValue] = useState<Record<string, unknown>>(
() => DEFAULT_AUTOMATION_TEMPLATE as Record<string, unknown>
);
const [issues, setIssues] = useState<string[]>([]);
async function handleSubmit() {
setIssues([]);
const payload = { ...value, search_space_id: searchSpaceId };
const result = automationCreateRequest.safeParse(payload);
if (!result.success) {
setIssues(
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
);
return;
}
try {
const created = await createAutomation(result.data);
router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
} catch (err) {
setIssues([(err as Error).message ?? "Submit failed"]);
}
}
const hasIssues = issues.length > 0;
return (
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold inline-flex items-center gap-2">
<FileJson className="h-4 w-4 text-muted-foreground" aria-hidden />
Definition + triggers
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[32rem] overflow-auto">
<JsonView
src={value}
editable
onChange={(next) => setValue(next as Record<string, unknown>)}
collapsed={false}
/>
</div>
{hasIssues && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive mb-1.5">
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
</div>
<ul className="space-y-0.5 text-xs text-destructive list-disc list-inside">
{issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button type="button" onClick={handleSubmit} disabled={isPending} size="sm">
{isPending ? <Spinner size="xs" className="mr-2" /> : <Save className="mr-2 h-4 w-4" />}
Create automation
</Button>
</div>
</CardContent>
</Card>
);
}

View file

@ -22,12 +22,9 @@ export function AutomationNewHeader({ searchSpaceId }: AutomationNewHeaderProps)
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-xl md:text-2xl font-semibold text-foreground"> <h1 className="text-xl md:text-2xl font-semibold text-foreground">New automation</h1>
New automation · raw JSON
</h1>
<p className="text-sm text-muted-foreground max-w-2xl"> <p className="text-sm text-muted-foreground max-w-2xl">
Paste an ``AutomationCreate`` payload and submit. Validated against the schema before Set up a task and a schedule. Prefer natural language? Use chat instead.
save. Prefer natural language? Use chat instead.
</p> </p>
</div> </div>
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">

View file

@ -57,6 +57,13 @@ interface DocumentMentionPickerProps {
onDone: () => void; onDone: () => void;
initialSelectedDocuments?: MentionedDocumentInfo[]; initialSelectedDocuments?: MentionedDocumentInfo[];
externalSearch?: string; externalSearch?: string;
/**
* Whether to surface the "SurfSense Docs" (product documentation) branch
* and include those docs in search results. Defaults to ``true`` so the
* chat composer is unchanged; callers like the automation task input pass
* ``false`` to reference only the user's own knowledge base + connectors.
*/
includeSurfsenseDocs?: boolean;
} }
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -228,7 +235,14 @@ export const DocumentMentionPicker = forwardRef<
DocumentMentionPickerRef, DocumentMentionPickerRef,
DocumentMentionPickerProps DocumentMentionPickerProps
>(function DocumentMentionPicker( >(function DocumentMentionPicker(
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" }, {
searchSpaceId,
onSelectionChange,
onDone,
initialSelectedDocuments = [],
externalSearch = "",
includeSurfsenseDocs = true,
},
ref ref
) { ) {
const search = externalSearch; const search = externalSearch;
@ -307,7 +321,7 @@ export const DocumentMentionPicker = forwardRef<
queryFn: ({ signal }) => queryFn: ({ signal }) =>
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal), documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
staleTime: 3 * 60 * 1000, staleTime: 3 * 60 * 1000,
enabled: !hasSearch || isSearchValid, enabled: includeSurfsenseDocs && (!hasSearch || isSearchValid),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
@ -324,7 +338,7 @@ export const DocumentMentionPicker = forwardRef<
if (currentPage !== 0) return; if (currentPage !== 0) return;
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = []; const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
if (surfsenseDocs?.items) { if (includeSurfsenseDocs && surfsenseDocs?.items) {
for (const doc of surfsenseDocs.items) { for (const doc of surfsenseDocs.items) {
combinedDocs.push({ combinedDocs.push({
id: doc.id, id: doc.id,
@ -340,7 +354,7 @@ export const DocumentMentionPicker = forwardRef<
} }
setAccumulatedDocuments(filterBySearchTerm(combinedDocs)); setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]); }, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm, includeSurfsenseDocs]);
const loadNextPage = useCallback(async () => { const loadNextPage = useCallback(async () => {
if (isLoadingMore || !hasMore) return; if (isLoadingMore || !hasMore) return;
@ -449,7 +463,7 @@ export const DocumentMentionPicker = forwardRef<
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))), () => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
[initialSelectedDocuments] [initialSelectedDocuments]
); );
const showSurfsenseDocsRoot = surfsenseDocsList.length > 0; const showSurfsenseDocsRoot = includeSurfsenseDocs && surfsenseDocsList.length > 0;
const selectMention = useCallback( const selectMention = useCallback(
(mention: MentionedDocumentInfo) => { (mention: MentionedDocumentInfo) => {

View file

@ -0,0 +1,456 @@
/**
* The form builder's own data model plus the mappers that bridge it to the
* backend contract (``automation.types.ts``).
*
* The builder deliberately exposes a *subset* of the full automation
* definition: a name, one or more natural-language agent tasks, a single
* schedule, and a few execution knobs. Anything richer (goal, per-step
* ``when`` predicates, ``inputs`` schema, ``on_failure`` steps, multiple or
* non-schedule triggers, custom metadata) is not representable here, so on
* edit we detect it and bounce the user to raw-JSON mode rather than silently
* dropping their data. ``goal`` is the one exception: it is carried through
* invisibly so the common drafter-produced automation stays form-editable.
*/
import { z } from "zod";
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
import {
type Automation,
type AutomationCreateRequest,
type AutomationDefinition,
type AutomationUpdateRequest,
execution as executionContract,
type TriggerCreateRequest,
} from "@/contracts/types/automation.types";
import { DEFAULT_SCHEDULE, fromCron, type ScheduleModel, toCron } from "./schedule-builder";
const EXECUTION_DEFAULTS = executionContract.parse({});
// ---------------------------------------------------------------------------
// Form model
// ---------------------------------------------------------------------------
export const builderTaskSchema = z.object({
/** Client-side identity for stable React keys across reorder; not persisted. */
id: z.string(),
query: z.string().trim().min(1, "Describe what the agent should do"),
/**
* Files / folders / connectors @-mentioned in the query. Mirrors the chat
* composer's mention list and is forwarded to the run as step params so the
* agent scopes retrieval to them. The query text already carries ``@Title``
* for each; this is the structured side-channel of IDs.
*/
mentions: z.array(z.custom<MentionedDocumentInfo>()),
maxRetries: z.number().int().min(0).max(10).nullable(),
timeoutSeconds: z.number().int().positive().max(86_400).nullable(),
});
export type BuilderTask = z.infer<typeof builderTaskSchema>;
export const builderScheduleSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("preset"),
model: z.custom<ScheduleModel>(),
}),
z.object({
mode: z.literal("cron"),
cron: z.string().trim().min(1, "Enter a schedule expression"),
}),
]);
export type BuilderSchedule = z.infer<typeof builderScheduleSchema>;
export const builderExecutionSchema = z.object({
timeoutSeconds: z.number().int().positive().max(86_400),
maxRetries: z.number().int().min(0).max(10),
retryBackoff: z.enum(["exponential", "linear", "none"]),
concurrency: z.enum(["drop_if_running", "queue", "always"]),
});
export type BuilderExecution = z.infer<typeof builderExecutionSchema>;
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(),
tasks: z.array(builderTaskSchema).min(1, "Add at least one task"),
unattended: z.boolean(),
schedule: builderScheduleSchema.nullable(),
timezone: z.string().min(1),
execution: builderExecutionSchema,
tags: z.array(z.string()),
/** Carried through from an edited definition so we don't drop it. */
goal: z.string().nullable(),
});
export type BuilderForm = z.infer<typeof builderFormSchema>;
// ---------------------------------------------------------------------------
// Defaults / construction
// ---------------------------------------------------------------------------
export function getDefaultTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
}
export function getTimezones(): string[] {
try {
const supported = (
Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
).supportedValuesOf?.("timeZone");
if (supported && supported.length > 0) return supported;
} catch {
// fall through
}
return ["UTC", getDefaultTimezone()];
}
function newId(): string {
try {
return crypto.randomUUID();
} catch {
return `task_${Math.random().toString(36).slice(2)}`;
}
}
export function emptyTask(): BuilderTask {
return { id: newId(), query: "", mentions: [], maxRetries: null, timeoutSeconds: null };
}
export function createEmptyForm(): BuilderForm {
return {
name: "",
description: null,
tasks: [emptyTask()],
unattended: true,
schedule: { mode: "preset", model: { ...DEFAULT_SCHEDULE } },
timezone: getDefaultTimezone(),
execution: {
timeoutSeconds: EXECUTION_DEFAULTS.timeout_seconds,
maxRetries: EXECUTION_DEFAULTS.max_retries,
retryBackoff: EXECUTION_DEFAULTS.retry_backoff,
concurrency: EXECUTION_DEFAULTS.concurrency,
},
tags: [],
goal: null,
};
}
/** The cron string a schedule resolves to, regardless of preset/raw mode. */
export function scheduleToCron(schedule: BuilderSchedule): string {
return schedule.mode === "preset" ? toCron(schedule.model) : schedule.cron.trim();
}
// ---------------------------------------------------------------------------
// Form -> contract payloads
// ---------------------------------------------------------------------------
/**
* Project a task's @-mentions into the ``agent_task`` param fields the backend
* understands (the same names the chat ``new_chat`` request uses, minus
* SurfSense docs). Returns an empty object when there are no mentions so the
* params stay clean. ``mentioned_documents`` carries full chip metadata so the
* run can resolve titles/paths and the form can round-trip the chips back.
*/
function mentionParams(mentions: MentionedDocumentInfo[]): Record<string, unknown> {
if (mentions.length === 0) return {};
const documentIds: number[] = [];
const folderIds: number[] = [];
const connectorIds: number[] = [];
const connectors: MentionedDocumentInfo[] = [];
for (const mention of mentions) {
if (mention.kind === "folder") {
folderIds.push(mention.id);
} else if (mention.kind === "connector") {
connectorIds.push(mention.id);
connectors.push(mention);
} else {
documentIds.push(mention.id);
}
}
const out: Record<string, unknown> = { mentioned_documents: mentions };
if (documentIds.length > 0) out.mentioned_document_ids = documentIds;
if (folderIds.length > 0) out.mentioned_folder_ids = folderIds;
if (connectorIds.length > 0) {
out.mentioned_connector_ids = connectorIds;
out.mentioned_connectors = connectors;
}
return out;
}
function buildPlan(form: BuilderForm) {
return form.tasks.map((task, index) => {
const step: Record<string, unknown> = {
step_id: `step_${index + 1}`,
action: "agent_task",
params: {
query: task.query.trim(),
auto_approve_all: form.unattended,
...mentionParams(task.mentions),
},
};
if (task.maxRetries !== null) step.max_retries = task.maxRetries;
if (task.timeoutSeconds !== null) step.timeout_seconds = task.timeoutSeconds;
return step;
});
}
function buildDefinition(form: BuilderForm): AutomationDefinition {
return {
schema_version: "1.0",
name: form.name.trim(),
goal: form.goal,
// Triggers are attached at the top level of the create payload, not in
// the definition; the in-definition list stays empty.
triggers: [],
plan: buildPlan(form),
execution: {
timeout_seconds: form.execution.timeoutSeconds,
max_retries: form.execution.maxRetries,
retry_backoff: form.execution.retryBackoff,
concurrency: form.execution.concurrency,
on_failure: [],
},
metadata: { tags: form.tags },
} as unknown as AutomationDefinition;
}
/** The desired schedule trigger for this form, or ``null`` if none. */
export function buildScheduleTrigger(form: BuilderForm): TriggerCreateRequest | null {
if (!form.schedule) return null;
return {
type: "schedule",
params: { cron: scheduleToCron(form.schedule), timezone: form.timezone },
static_inputs: {},
enabled: true,
};
}
export function buildCreatePayload(
form: BuilderForm,
searchSpaceId: number
): AutomationCreateRequest {
const trigger = buildScheduleTrigger(form);
return {
search_space_id: searchSpaceId,
name: form.name.trim(),
description: form.description?.trim() ? form.description.trim() : null,
definition: buildDefinition(form),
triggers: trigger ? [trigger] : [],
};
}
export function buildUpdatePayload(form: BuilderForm): AutomationUpdateRequest {
return {
name: form.name.trim(),
description: form.description?.trim() ? form.description.trim() : null,
definition: buildDefinition(form),
};
}
// ---------------------------------------------------------------------------
// Contract -> form (edit hydration with safe fallback)
// ---------------------------------------------------------------------------
export type HydrateResult =
| { formable: true; form: BuilderForm }
| { formable: false; reason: string };
/** A trigger as seen by the hydrator: both ``Trigger`` and ``TriggerCreateRequest`` fit. */
export interface HydratableTrigger {
type: string;
params: Record<string, unknown>;
}
const BACKOFF_VALUES = ["exponential", "linear", "none"] as const;
const CONCURRENCY_VALUES = ["drop_if_running", "queue", "always"] as const;
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
/** Best-effort projection of a stored ``mentioned_documents`` entry into a chip. */
function coerceMention(raw: unknown): MentionedDocumentInfo | null {
const o = asRecord(raw);
if (typeof o.id !== "number" || typeof o.title !== "string") return null;
if (o.kind === "folder") {
return { id: o.id, title: o.title, kind: "folder" };
}
if (o.kind === "connector") {
if (typeof o.connector_type !== "string" || typeof o.account_name !== "string") return null;
return {
id: o.id,
title: o.title,
kind: "connector",
connector_type: o.connector_type,
account_name: o.account_name,
};
}
return {
id: o.id,
title: o.title,
kind: "doc",
document_type: typeof o.document_type === "string" ? o.document_type : "UNKNOWN",
};
}
/**
* Rebuild a task's mention chips from step params. Returns ``null`` when the
* step carries mention IDs that aren't backed by usable ``mentioned_documents``
* metadata (e.g. hand-edited JSON), so the caller can fall back to JSON mode
* rather than silently dropping those IDs on the next save.
*/
function mentionsFromParams(params: Record<string, unknown>): MentionedDocumentInfo[] | null {
const rawList = Array.isArray(params.mentioned_documents) ? params.mentioned_documents : [];
const mentions: MentionedDocumentInfo[] = [];
for (const raw of rawList) {
const mention = coerceMention(raw);
if (mention) mentions.push(mention);
}
const haveByKind = {
doc: new Set(mentions.filter((m) => m.kind === "doc").map((m) => m.id)),
folder: new Set(mentions.filter((m) => m.kind === "folder").map((m) => m.id)),
connector: new Set(mentions.filter((m) => m.kind === "connector").map((m) => m.id)),
};
const idChecks: Array<[unknown, Set<number>]> = [
[params.mentioned_document_ids, haveByKind.doc],
[params.mentioned_folder_ids, haveByKind.folder],
[params.mentioned_connector_ids, haveByKind.connector],
];
for (const [arr, have] of idChecks) {
if (!Array.isArray(arr)) continue;
for (const id of arr) {
if (typeof id === "number" && !have.has(id)) return null;
}
}
return mentions;
}
/**
* Core projection of a definition + triggers into the builder form. Returns
* ``formable: false`` whenever something can't be represented, so the caller
* can drop into raw-JSON mode without losing data. Shared by the edit
* hydrator and the JSON-mode round-trip.
*
* The definition is read defensively (``unknown``) so a partially edited JSON
* tree can still round-trip into the form; completeness is enforced by the
* form's own validation at submit time, not here.
*/
export function hydrateForm(
name: string,
description: string | null,
def: unknown,
triggers: HydratableTrigger[]
): HydrateResult {
const d = asRecord(def);
if (d.inputs) {
return { formable: false, reason: "uses an inputs schema" };
}
const exec = asRecord(d.execution);
const onFailure = Array.isArray(exec.on_failure) ? exec.on_failure : [];
if (onFailure.length > 0) {
return { formable: false, reason: "has on-failure steps" };
}
const metadata = asRecord(d.metadata);
const extraMetadataKeys = Object.keys(metadata).filter((key) => key !== "tags");
if (extraMetadataKeys.length > 0) {
return { formable: false, reason: "has custom metadata" };
}
const plan = Array.isArray(d.plan) ? d.plan : [];
const tasks: BuilderTask[] = [];
let unattended = true;
for (const rawStep of plan) {
const step = asRecord(rawStep);
if (step.action !== "agent_task") {
return { formable: false, reason: `uses the "${String(step.action)}" action` };
}
if (step.when) {
return { formable: false, reason: "uses conditional steps" };
}
const params = asRecord(step.params);
const query = typeof params.query === "string" ? params.query : "";
// auto_approve_all is a single global toggle in the form; if any step is
// explicitly false we surface the toggle as off.
if (params.auto_approve_all === false) unattended = false;
const mentions = mentionsFromParams(params);
if (mentions === null) {
return { formable: false, reason: "references mentions without metadata" };
}
tasks.push({
id: newId(),
query,
mentions,
maxRetries: typeof step.max_retries === "number" ? step.max_retries : null,
timeoutSeconds: typeof step.timeout_seconds === "number" ? step.timeout_seconds : null,
});
}
if (tasks.length === 0) {
return { formable: false, reason: "has no steps" };
}
if (triggers.length > 1) {
return { formable: false, reason: "has multiple triggers" };
}
const trigger = triggers[0];
let schedule: BuilderSchedule | null = null;
let timezone = getDefaultTimezone();
if (trigger) {
if (trigger.type !== "schedule") {
return { formable: false, reason: `has a "${trigger.type}" trigger` };
}
const cron = typeof trigger.params?.cron === "string" ? trigger.params.cron : "";
timezone = typeof trigger.params?.timezone === "string" ? trigger.params.timezone : timezone;
const model = fromCron(cron);
schedule = model ? { mode: "preset", model } : { mode: "cron", cron };
}
const retryBackoff = BACKOFF_VALUES.includes(exec.retry_backoff as never)
? (exec.retry_backoff as BuilderExecution["retryBackoff"])
: EXECUTION_DEFAULTS.retry_backoff;
const concurrency = CONCURRENCY_VALUES.includes(exec.concurrency as never)
? (exec.concurrency as BuilderExecution["concurrency"])
: EXECUTION_DEFAULTS.concurrency;
const tags = Array.isArray(metadata.tags)
? metadata.tags.filter((tag): tag is string => typeof tag === "string")
: [];
return {
formable: true,
form: {
name,
description: description ?? null,
tasks,
unattended,
schedule,
timezone,
execution: {
timeoutSeconds:
typeof exec.timeout_seconds === "number"
? exec.timeout_seconds
: EXECUTION_DEFAULTS.timeout_seconds,
maxRetries:
typeof exec.max_retries === "number" ? exec.max_retries : EXECUTION_DEFAULTS.max_retries,
retryBackoff,
concurrency,
},
tags,
goal: typeof d.goal === "string" ? d.goal : null,
},
};
}
/**
* Project an existing automation into the builder form for editing.
*/
export function formFromAutomation(automation: Automation): HydrateResult {
return hydrateForm(
automation.name,
automation.description ?? null,
automation.definition,
automation.triggers ?? []
);
}

View file

@ -1,44 +0,0 @@
/**
* Minimal valid ``AutomationCreate`` skeleton used to seed the raw-JSON
* create form. ``search_space_id`` is omitted on purpose the form
* injects it from the route so users never have to know their id.
*
* The shape matches the Pydantic ``AutomationCreate`` model less the
* search_space_id field; Zod validates the merged payload before submit.
*/
export const DEFAULT_AUTOMATION_TEMPLATE = {
name: "My automation",
description: null,
definition: {
name: "My automation",
goal: null,
plan: [
{
step_id: "step_1",
action: "agent_task",
params: {
query: "Summarize new docs added to folder 12 since the last run.",
},
},
],
execution: {
timeout_seconds: 600,
max_retries: 2,
retry_backoff: "exponential",
concurrency: "drop_if_running",
on_failure: [],
},
metadata: { tags: [] },
},
triggers: [
{
type: "schedule",
params: {
cron: "0 9 * * 1-5",
timezone: "UTC",
},
static_inputs: {},
enabled: true,
},
],
} as const;

View file

@ -0,0 +1,132 @@
/**
* Bidirectional bridge between a friendly schedule model and the 5-field cron
* expression the backend ``schedule`` trigger expects (see
* ``app/automations/triggers/schedule/params.py``).
*
* The form builder never asks users to type cron. They pick a frequency + time
* (+ days), which ``toCron`` compiles. On edit we ``fromCron`` an existing
* expression back into the model; anything we don't recognize returns ``null``
* so the caller can fall back to a raw-cron escape hatch instead of silently
* losing the user's schedule.
*
* The recognized patterns are intentionally the same family that
* ``describe-cron.ts`` humanizes, keeping the picker and the label in sync.
*/
export type ScheduleFrequency = "hourly" | "daily" | "weekdays" | "weekly" | "monthly";
export interface ScheduleModel {
frequency: ScheduleFrequency;
/** 0-23. Ignored for ``hourly``. */
hour: number;
/** 0-59. */
minute: number;
/** 0 (Sun) - 6 (Sat). Used by ``weekly``. */
daysOfWeek: number[];
/** 1-31. Used by ``monthly``. */
dayOfMonth: number;
}
/** Sunday-first, matching cron's 0-6 day-of-week numbering. */
export const WEEKDAY_OPTIONS: ReadonlyArray<{ value: number; short: string; long: string }> = [
{ value: 1, short: "Mon", long: "Monday" },
{ value: 2, short: "Tue", long: "Tuesday" },
{ value: 3, short: "Wed", long: "Wednesday" },
{ value: 4, short: "Thu", long: "Thursday" },
{ value: 5, short: "Fri", long: "Friday" },
{ value: 6, short: "Sat", long: "Saturday" },
{ value: 0, short: "Sun", long: "Sunday" },
];
export const FREQUENCY_OPTIONS: ReadonlyArray<{ value: ScheduleFrequency; label: string }> = [
{ value: "hourly", label: "Every hour" },
{ value: "daily", label: "Every day" },
{ value: "weekdays", label: "Every weekday (Mon\u2013Fri)" },
{ value: "weekly", label: "Specific days of the week" },
{ value: "monthly", label: "Once a month" },
];
export const DEFAULT_SCHEDULE: ScheduleModel = {
frequency: "weekdays",
hour: 9,
minute: 0,
daysOfWeek: [1],
dayOfMonth: 1,
};
function isInt(value: string): boolean {
return /^\d+$/.test(value);
}
function clamp(value: number, min: number, max: number): number {
if (Number.isNaN(value)) return min;
return Math.min(max, Math.max(min, value));
}
/** Compile a schedule model into a 5-field cron expression. */
export function toCron(model: ScheduleModel): string {
const minute = clamp(model.minute, 0, 59);
const hour = clamp(model.hour, 0, 23);
switch (model.frequency) {
case "hourly":
return `${minute} * * * *`;
case "daily":
return `${minute} ${hour} * * *`;
case "weekdays":
return `${minute} ${hour} * * 1-5`;
case "weekly": {
const days = [...new Set(model.daysOfWeek)].sort((a, b) => a - b);
// Guard against an empty selection producing an invalid cron.
const dow = days.length > 0 ? days.join(",") : "1";
return `${minute} ${hour} * * ${dow}`;
}
case "monthly":
return `${minute} ${hour} ${clamp(model.dayOfMonth, 1, 31)} * *`;
}
}
/**
* Parse a 5-field cron expression back into a schedule model. Returns ``null``
* for anything outside the recognized pattern family so callers can fall back
* to the raw-cron field.
*/
export function fromCron(cron: string): ScheduleModel | null {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minute, hour, dom, month, dow] = parts;
// Hourly: "M * * * *"
if (month === "*" && dom === "*" && dow === "*" && hour === "*" && isInt(minute)) {
return { ...DEFAULT_SCHEDULE, frequency: "hourly", minute: Number(minute) };
}
// Everything below requires concrete minute + hour.
if (!isInt(minute) || !isInt(hour)) return null;
const base = { hour: Number(hour), minute: Number(minute) };
// Daily: "M H * * *"
if (month === "*" && dom === "*" && dow === "*") {
return { ...DEFAULT_SCHEDULE, ...base, frequency: "daily" };
}
// Weekdays: "M H * * 1-5"
if (month === "*" && dom === "*" && dow === "1-5") {
return { ...DEFAULT_SCHEDULE, ...base, frequency: "weekdays" };
}
// Weekly: "M H * * 1,3,5"
if (month === "*" && dom === "*" && /^[0-6](,[0-6])*$/.test(dow)) {
const daysOfWeek = [...new Set(dow.split(",").map(Number))].sort((a, b) => a - b);
return { ...DEFAULT_SCHEDULE, ...base, frequency: "weekly", daysOfWeek };
}
// Monthly: "M H D * *"
if (month === "*" && dow === "*" && isInt(dom)) {
return { ...DEFAULT_SCHEDULE, ...base, frequency: "monthly", dayOfMonth: Number(dom) };
}
return null;
}