mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
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:
parent
c601a9b102
commit
d013617bf6
25 changed files with 2490 additions and 281 deletions
|
|
@ -1,10 +1,11 @@
|
|||
"use client";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { useAutomation } from "@/hooks/use-automation";
|
||||
import { AutomationBuilderForm } from "../../components/builder/automation-builder-form";
|
||||
import { useAutomationPermissions } from "../../hooks/use-automation-permissions";
|
||||
import { AutomationDetailLoading } from "../components/automation-detail-loading";
|
||||
import { AutomationNotFound } from "../components/automation-not-found";
|
||||
import { AutomationEditForm } from "./components/automation-edit-form";
|
||||
import { AutomationEditHeader } from "./components/automation-edit-header";
|
||||
|
||||
interface AutomationEditContentProps {
|
||||
searchSpaceId: number;
|
||||
|
|
@ -49,5 +50,10 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
|
|||
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
|
||||
}
|
||||
|
||||
return <AutomationEditForm automation={automation} searchSpaceId={searchSpaceId} />;
|
||||
return (
|
||||
<>
|
||||
<AutomationEditHeader automation={automation} searchSpaceId={searchSpaceId} />
|
||||
<AutomationBuilderForm mode="edit" searchSpaceId={searchSpaceId} automation={automation} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { AutomationBuilderForm } from "../components/builder/automation-builder-form";
|
||||
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
|
||||
import { AutomationJsonForm } from "./components/automation-json-form";
|
||||
import { AutomationNewHeader } from "./components/automation-new-header";
|
||||
|
||||
interface AutomationNewContentProps {
|
||||
|
|
@ -9,10 +9,10 @@ interface AutomationNewContentProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Orchestrator for the raw-JSON create route. Gates on
|
||||
* ``automations:create`` so users who can't create don't even see the
|
||||
* form; same panel as the detail page's access-denied state for
|
||||
* consistency.
|
||||
* Orchestrator for the create route. Gates on ``automations:create`` so users
|
||||
* who can't create don't even see the form; same panel as the detail page's
|
||||
* access-denied state for consistency. The builder defaults to the friendly
|
||||
* form with a raw-JSON escape hatch.
|
||||
*/
|
||||
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
|
||||
const perms = useAutomationPermissions();
|
||||
|
|
@ -36,7 +36,7 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
|
|||
return (
|
||||
<>
|
||||
<AutomationNewHeader searchSpaceId={searchSpaceId} />
|
||||
<AutomationJsonForm searchSpaceId={searchSpaceId} />
|
||||
<AutomationBuilderForm mode="create" searchSpaceId={searchSpaceId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,12 +22,9 @@ export function AutomationNewHeader({ searchSpaceId }: AutomationNewHeaderProps)
|
|||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">
|
||||
New automation · raw JSON
|
||||
</h1>
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">New automation</h1>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
Paste an ``AutomationCreate`` payload and submit. Validated against the schema before
|
||||
save. Prefer natural language? Use chat instead.
|
||||
Set up a task and a schedule. Prefer natural language? Use chat instead.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
|
|
|
|||
|
|
@ -57,6 +57,13 @@ interface DocumentMentionPickerProps {
|
|||
onDone: () => void;
|
||||
initialSelectedDocuments?: MentionedDocumentInfo[];
|
||||
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;
|
||||
|
|
@ -228,7 +235,14 @@ export const DocumentMentionPicker = forwardRef<
|
|||
DocumentMentionPickerRef,
|
||||
DocumentMentionPickerProps
|
||||
>(function DocumentMentionPicker(
|
||||
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
|
||||
{
|
||||
searchSpaceId,
|
||||
onSelectionChange,
|
||||
onDone,
|
||||
initialSelectedDocuments = [],
|
||||
externalSearch = "",
|
||||
includeSurfsenseDocs = true,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const search = externalSearch;
|
||||
|
|
@ -307,7 +321,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
queryFn: ({ signal }) =>
|
||||
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
|
||||
staleTime: 3 * 60 * 1000,
|
||||
enabled: !hasSearch || isSearchValid,
|
||||
enabled: includeSurfsenseDocs && (!hasSearch || isSearchValid),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
|
|
@ -324,7 +338,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
if (currentPage !== 0) return;
|
||||
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
|
||||
|
||||
if (surfsenseDocs?.items) {
|
||||
if (includeSurfsenseDocs && surfsenseDocs?.items) {
|
||||
for (const doc of surfsenseDocs.items) {
|
||||
combinedDocs.push({
|
||||
id: doc.id,
|
||||
|
|
@ -340,7 +354,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
}
|
||||
|
||||
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
|
||||
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
|
||||
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm, includeSurfsenseDocs]);
|
||||
|
||||
const loadNextPage = useCallback(async () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
|
|
@ -449,7 +463,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
|
||||
[initialSelectedDocuments]
|
||||
);
|
||||
const showSurfsenseDocsRoot = surfsenseDocsList.length > 0;
|
||||
const showSurfsenseDocsRoot = includeSurfsenseDocs && surfsenseDocsList.length > 0;
|
||||
|
||||
const selectMention = useCallback(
|
||||
(mention: MentionedDocumentInfo) => {
|
||||
|
|
|
|||
456
surfsense_web/lib/automations/builder-schema.ts
Normal file
456
surfsense_web/lib/automations/builder-schema.ts
Normal 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 ?? []
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
132
surfsense_web/lib/automations/schedule-builder.ts
Normal file
132
surfsense_web/lib/automations/schedule-builder.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue