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
|
|
@ -18,6 +18,11 @@ def build_handler(ctx: ActionContext) -> ActionHandler:
|
||||||
ctx=ctx,
|
ctx=ctx,
|
||||||
query=validated.query,
|
query=validated.query,
|
||||||
auto_approve_all=validated.auto_approve_all,
|
auto_approve_all=validated.auto_approve_all,
|
||||||
|
mentioned_document_ids=validated.mentioned_document_ids,
|
||||||
|
mentioned_folder_ids=validated.mentioned_folder_ids,
|
||||||
|
mentioned_connector_ids=validated.mentioned_connector_ids,
|
||||||
|
mentioned_connectors=validated.mentioned_connectors,
|
||||||
|
mentioned_documents=validated.mentioned_documents,
|
||||||
)
|
)
|
||||||
|
|
||||||
return handle
|
return handle
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,13 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage
|
||||||
from langgraph.types import Command
|
from langgraph.types import Command
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.agents.multi_agent_chat import create_multi_agent_chat_deep_agent
|
from app.agents.multi_agent_chat import create_multi_agent_chat_deep_agent
|
||||||
|
from app.agents.new_chat.context import SurfSenseContextSchema
|
||||||
|
from app.agents.new_chat.mention_resolver import resolve_mentions, substitute_in_text
|
||||||
from app.db import ChatVisibility, async_session_maker
|
from app.db import ChatVisibility, async_session_maker
|
||||||
|
from app.schemas.new_chat import MentionedDocumentInfo
|
||||||
|
|
||||||
from ..types import ActionContext
|
from ..types import ActionContext
|
||||||
from .auto_decide import build_auto_decisions
|
from .auto_decide import build_auto_decisions
|
||||||
|
|
@ -22,17 +26,118 @@ from .finalize import extract_final_assistant_message
|
||||||
_MAX_RESUMES = 50
|
_MAX_RESUMES = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _build_connector_block(connectors: list[dict[str, Any]]) -> str | None:
|
||||||
|
"""Render the ``<mentioned_connectors>`` context block (same shape as chat).
|
||||||
|
|
||||||
|
Mirrors ``stream_new_chat`` so the agent gets the exact connector accounts
|
||||||
|
the user picked. Returns ``None`` when nothing renders.
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
for connector in connectors:
|
||||||
|
connector_id = connector.get("id")
|
||||||
|
connector_type = connector.get("connector_type") or connector.get(
|
||||||
|
"document_type"
|
||||||
|
)
|
||||||
|
account_name = connector.get("account_name") or connector.get("title")
|
||||||
|
if connector_id is None or connector_type is None:
|
||||||
|
continue
|
||||||
|
lines.append(
|
||||||
|
f' - connector_id={connector_id}, connector_type="{connector_type}", '
|
||||||
|
f'account_name="{account_name or ""}"'
|
||||||
|
)
|
||||||
|
if not lines:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
"<mentioned_connectors>\n"
|
||||||
|
"The user selected these exact connector accounts with @. "
|
||||||
|
"These entries are selection metadata, not retrieved connector content. "
|
||||||
|
"When a connector-backed tool needs an account, use the matching "
|
||||||
|
"connector_id from this list if the tool supports connector_id:\n"
|
||||||
|
+ "\n".join(lines)
|
||||||
|
+ "\n</mentioned_connectors>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_mention_context(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
search_space_id: int,
|
||||||
|
query: str,
|
||||||
|
mentioned_document_ids: list[int] | None,
|
||||||
|
mentioned_folder_ids: list[int] | None,
|
||||||
|
mentioned_connector_ids: list[int] | None,
|
||||||
|
mentioned_connectors: list[MentionedDocumentInfo] | None,
|
||||||
|
mentioned_documents: list[MentionedDocumentInfo] | None,
|
||||||
|
) -> tuple[str, SurfSenseContextSchema | None]:
|
||||||
|
"""Resolve @-mentions into a rewritten query + per-invocation context.
|
||||||
|
|
||||||
|
Automation always runs in cloud filesystem mode, so we mirror the chat
|
||||||
|
``new_chat`` flow: substitute ``@title`` tokens with canonical
|
||||||
|
``/documents/...`` paths, prepend a ``<mentioned_connectors>`` block, and
|
||||||
|
build a ``SurfSenseContextSchema`` that ``KnowledgePriorityMiddleware``
|
||||||
|
reads via ``runtime.context``. Returns ``(query, None)`` unchanged when
|
||||||
|
there are no mentions.
|
||||||
|
"""
|
||||||
|
has_mentions = bool(
|
||||||
|
mentioned_document_ids
|
||||||
|
or mentioned_folder_ids
|
||||||
|
or mentioned_connector_ids
|
||||||
|
or mentioned_connectors
|
||||||
|
or mentioned_documents
|
||||||
|
)
|
||||||
|
if not has_mentions:
|
||||||
|
return query, None
|
||||||
|
|
||||||
|
resolved = await resolve_mentions(
|
||||||
|
session,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
mentioned_documents=mentioned_documents,
|
||||||
|
mentioned_document_ids=mentioned_document_ids,
|
||||||
|
mentioned_folder_ids=mentioned_folder_ids,
|
||||||
|
)
|
||||||
|
agent_query = substitute_in_text(query, resolved.token_to_path)
|
||||||
|
|
||||||
|
# ``SurfSenseContextSchema.mentioned_connectors`` is typed ``list[dict]`` and
|
||||||
|
# the connector block reads dicts, so dump the pydantic chips once.
|
||||||
|
connector_dicts = [c.model_dump() for c in (mentioned_connectors or [])]
|
||||||
|
connector_block = _build_connector_block(connector_dicts)
|
||||||
|
if connector_block:
|
||||||
|
agent_query = f"{connector_block}\n\n<user_query>{agent_query}</user_query>"
|
||||||
|
|
||||||
|
runtime_context = SurfSenseContextSchema(
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
mentioned_document_ids=list(
|
||||||
|
resolved.mentioned_document_ids or (mentioned_document_ids or [])
|
||||||
|
),
|
||||||
|
mentioned_folder_ids=list(
|
||||||
|
resolved.mentioned_folder_ids or (mentioned_folder_ids or [])
|
||||||
|
),
|
||||||
|
mentioned_connector_ids=list(mentioned_connector_ids or []),
|
||||||
|
mentioned_connectors=connector_dicts,
|
||||||
|
)
|
||||||
|
return agent_query, runtime_context
|
||||||
|
|
||||||
|
|
||||||
async def run_agent_task(
|
async def run_agent_task(
|
||||||
*,
|
*,
|
||||||
ctx: ActionContext,
|
ctx: ActionContext,
|
||||||
query: str,
|
query: str,
|
||||||
auto_approve_all: bool,
|
auto_approve_all: bool,
|
||||||
|
mentioned_document_ids: list[int] | None = None,
|
||||||
|
mentioned_folder_ids: list[int] | None = None,
|
||||||
|
mentioned_connector_ids: list[int] | None = None,
|
||||||
|
mentioned_connectors: list[MentionedDocumentInfo] | None = None,
|
||||||
|
mentioned_documents: list[MentionedDocumentInfo] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Invoke multi_agent_chat for one rendered query and return its outcome.
|
"""Invoke multi_agent_chat for one rendered query and return its outcome.
|
||||||
|
|
||||||
Opens its own DB session so the executor's bookkeeping session isn't tied
|
Opens its own DB session so the executor's bookkeeping session isn't tied
|
||||||
up for the entire invocation. The LangGraph ``thread_id`` (a fresh UUID)
|
up for the entire invocation. The LangGraph ``thread_id`` (a fresh UUID)
|
||||||
is returned as ``agent_session_id`` for later inspection.
|
is returned as ``agent_session_id`` for later inspection.
|
||||||
|
|
||||||
|
@-mentions (files / folders / connectors) chosen in the task input are
|
||||||
|
resolved the same way the chat flow does and forwarded to the agent via the
|
||||||
|
per-invocation ``context`` so they actually scope retrieval.
|
||||||
"""
|
"""
|
||||||
agent_session_id = str(uuid.uuid4())
|
agent_session_id = str(uuid.uuid4())
|
||||||
user_id = str(ctx.creator_user_id) if ctx.creator_user_id else None
|
user_id = str(ctx.creator_user_id) if ctx.creator_user_id else None
|
||||||
|
|
@ -55,12 +160,24 @@ async def run_agent_task(
|
||||||
agent_config=deps.agent_config,
|
agent_config=deps.agent_config,
|
||||||
firecrawl_api_key=deps.firecrawl_api_key,
|
firecrawl_api_key=deps.firecrawl_api_key,
|
||||||
thread_visibility=ChatVisibility.PRIVATE,
|
thread_visibility=ChatVisibility.PRIVATE,
|
||||||
|
mentioned_document_ids=mentioned_document_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
agent_query, runtime_context = await _resolve_mention_context(
|
||||||
|
agent_session,
|
||||||
|
search_space_id=ctx.search_space_id,
|
||||||
|
query=query,
|
||||||
|
mentioned_document_ids=mentioned_document_ids,
|
||||||
|
mentioned_folder_ids=mentioned_folder_ids,
|
||||||
|
mentioned_connector_ids=mentioned_connector_ids,
|
||||||
|
mentioned_connectors=mentioned_connectors,
|
||||||
|
mentioned_documents=mentioned_documents,
|
||||||
)
|
)
|
||||||
|
|
||||||
request_id = f"automation:{ctx.run_id}:{ctx.step_id}"
|
request_id = f"automation:{ctx.run_id}:{ctx.step_id}"
|
||||||
turn_id = f"{request_id}:{int(time.time() * 1000)}"
|
turn_id = f"{request_id}:{int(time.time() * 1000)}"
|
||||||
input_state: dict[str, Any] = {
|
input_state: dict[str, Any] = {
|
||||||
"messages": [HumanMessage(content=query)],
|
"messages": [HumanMessage(content=agent_query)],
|
||||||
"search_space_id": ctx.search_space_id,
|
"search_space_id": ctx.search_space_id,
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"turn_id": turn_id,
|
"turn_id": turn_id,
|
||||||
|
|
@ -73,8 +190,17 @@ async def run_agent_task(
|
||||||
},
|
},
|
||||||
"recursion_limit": 10_000,
|
"recursion_limit": 10_000,
|
||||||
}
|
}
|
||||||
|
if runtime_context is not None:
|
||||||
|
runtime_context.request_id = request_id
|
||||||
|
runtime_context.turn_id = turn_id
|
||||||
|
|
||||||
result = await agent.ainvoke(input_state, config=config)
|
# The compiled graph declares ``context_schema=SurfSenseContextSchema``;
|
||||||
|
# mentions only reach ``KnowledgePriorityMiddleware`` via ``context=``.
|
||||||
|
invoke_kwargs: dict[str, Any] = {"config": config}
|
||||||
|
if runtime_context is not None:
|
||||||
|
invoke_kwargs["context"] = runtime_context
|
||||||
|
|
||||||
|
result = await agent.ainvoke(input_state, **invoke_kwargs)
|
||||||
|
|
||||||
resumes = 0
|
resumes = 0
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -87,7 +213,7 @@ async def run_agent_task(
|
||||||
)
|
)
|
||||||
lg_resume_map, routed = build_auto_decisions(state, decision)
|
lg_resume_map, routed = build_auto_decisions(state, decision)
|
||||||
config["configurable"]["surfsense_resume_value"] = routed
|
config["configurable"]["surfsense_resume_value"] = routed
|
||||||
result = await agent.ainvoke(Command(resume=lg_resume_map), config=config)
|
result = await agent.ainvoke(Command(resume=lg_resume_map), **invoke_kwargs)
|
||||||
resumes += 1
|
resumes += 1
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.schemas.new_chat import MentionedDocumentInfo
|
||||||
|
|
||||||
|
|
||||||
class AgentTaskActionParams(BaseModel):
|
class AgentTaskActionParams(BaseModel):
|
||||||
"""Run a multi_agent_chat turn from an automation step."""
|
"""Run a multi_agent_chat turn from an automation step."""
|
||||||
|
|
@ -19,3 +21,32 @@ class AgentTaskActionParams(BaseModel):
|
||||||
default=False,
|
default=False,
|
||||||
description="If true, every HITL approval is auto-approved; otherwise rejected.",
|
description="If true, every HITL approval is auto-approved; otherwise rejected.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# @-mention references chosen in the task input. Mirror the ``new_chat``
|
||||||
|
# request fields (minus SurfSense product docs) so the run can scope
|
||||||
|
# retrieval to the user's selected files / folders / connectors. All
|
||||||
|
# optional and additive; a task with no mentions behaves as before.
|
||||||
|
mentioned_document_ids: list[int] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Knowledge-base document IDs the task references with @.",
|
||||||
|
)
|
||||||
|
mentioned_folder_ids: list[int] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Knowledge-base folder IDs the task references with @.",
|
||||||
|
)
|
||||||
|
mentioned_connector_ids: list[int] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Concrete connector account IDs the task references with @.",
|
||||||
|
)
|
||||||
|
mentioned_connectors: list[MentionedDocumentInfo] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Display/context metadata for the @-mentioned connector accounts.",
|
||||||
|
)
|
||||||
|
mentioned_documents: list[MentionedDocumentInfo] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Chip metadata (id, title, kind, ...) for every @-mention so the "
|
||||||
|
"run can resolve titles to virtual paths and substitute them in "
|
||||||
|
"the query."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
import { useAutomation } from "@/hooks/use-automation";
|
import { useAutomation } from "@/hooks/use-automation";
|
||||||
|
import { AutomationBuilderForm } from "../../components/builder/automation-builder-form";
|
||||||
import { useAutomationPermissions } from "../../hooks/use-automation-permissions";
|
import { useAutomationPermissions } from "../../hooks/use-automation-permissions";
|
||||||
import { AutomationDetailLoading } from "../components/automation-detail-loading";
|
import { AutomationDetailLoading } from "../components/automation-detail-loading";
|
||||||
import { AutomationNotFound } from "../components/automation-not-found";
|
import { AutomationNotFound } from "../components/automation-not-found";
|
||||||
import { AutomationEditForm } from "./components/automation-edit-form";
|
import { AutomationEditHeader } from "./components/automation-edit-header";
|
||||||
|
|
||||||
interface AutomationEditContentProps {
|
interface AutomationEditContentProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
|
|
@ -49,5 +50,10 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
|
||||||
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
|
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AutomationEditForm automation={automation} searchSpaceId={searchSpaceId} />;
|
return (
|
||||||
|
<>
|
||||||
|
<AutomationEditHeader automation={automation} searchSpaceId={searchSpaceId} />
|
||||||
|
<AutomationBuilderForm mode="edit" searchSpaceId={searchSpaceId} automation={automation} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
"use client";
|
||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
|
import { AutomationBuilderForm } from "../components/builder/automation-builder-form";
|
||||||
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
|
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
|
||||||
import { AutomationJsonForm } from "./components/automation-json-form";
|
|
||||||
import { AutomationNewHeader } from "./components/automation-new-header";
|
import { AutomationNewHeader } from "./components/automation-new-header";
|
||||||
|
|
||||||
interface AutomationNewContentProps {
|
interface AutomationNewContentProps {
|
||||||
|
|
@ -9,10 +9,10 @@ interface AutomationNewContentProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestrator for the raw-JSON create route. Gates on
|
* Orchestrator for the create route. Gates on ``automations:create`` so users
|
||||||
* ``automations:create`` so users who can't create don't even see the
|
* who can't create don't even see the form; same panel as the detail page's
|
||||||
* form; same panel as the detail page's access-denied state for
|
* access-denied state for consistency. The builder defaults to the friendly
|
||||||
* consistency.
|
* form with a raw-JSON escape hatch.
|
||||||
*/
|
*/
|
||||||
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
|
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
|
||||||
const perms = useAutomationPermissions();
|
const perms = useAutomationPermissions();
|
||||||
|
|
@ -36,7 +36,7 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AutomationNewHeader searchSpaceId={searchSpaceId} />
|
<AutomationNewHeader searchSpaceId={searchSpaceId} />
|
||||||
<AutomationJsonForm searchSpaceId={searchSpaceId} />
|
<AutomationBuilderForm mode="create" searchSpaceId={searchSpaceId} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">
|
<h1 className="text-xl md:text-2xl font-semibold text-foreground">New automation</h1>
|
||||||
New automation · raw JSON
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||||
Paste an ``AutomationCreate`` payload and submit. Validated against the schema before
|
Set up a task and a schedule. Prefer natural language? Use chat instead.
|
||||||
save. Prefer natural language? Use chat instead.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,13 @@ interface DocumentMentionPickerProps {
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
initialSelectedDocuments?: MentionedDocumentInfo[];
|
initialSelectedDocuments?: MentionedDocumentInfo[];
|
||||||
externalSearch?: string;
|
externalSearch?: string;
|
||||||
|
/**
|
||||||
|
* Whether to surface the "SurfSense Docs" (product documentation) branch
|
||||||
|
* and include those docs in search results. Defaults to ``true`` so the
|
||||||
|
* chat composer is unchanged; callers like the automation task input pass
|
||||||
|
* ``false`` to reference only the user's own knowledge base + connectors.
|
||||||
|
*/
|
||||||
|
includeSurfsenseDocs?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
@ -228,7 +235,14 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
DocumentMentionPickerRef,
|
DocumentMentionPickerRef,
|
||||||
DocumentMentionPickerProps
|
DocumentMentionPickerProps
|
||||||
>(function DocumentMentionPicker(
|
>(function DocumentMentionPicker(
|
||||||
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
|
{
|
||||||
|
searchSpaceId,
|
||||||
|
onSelectionChange,
|
||||||
|
onDone,
|
||||||
|
initialSelectedDocuments = [],
|
||||||
|
externalSearch = "",
|
||||||
|
includeSurfsenseDocs = true,
|
||||||
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const search = externalSearch;
|
const search = externalSearch;
|
||||||
|
|
@ -307,7 +321,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
|
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
|
||||||
staleTime: 3 * 60 * 1000,
|
staleTime: 3 * 60 * 1000,
|
||||||
enabled: !hasSearch || isSearchValid,
|
enabled: includeSurfsenseDocs && (!hasSearch || isSearchValid),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -324,7 +338,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
if (currentPage !== 0) return;
|
if (currentPage !== 0) return;
|
||||||
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
|
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
|
||||||
|
|
||||||
if (surfsenseDocs?.items) {
|
if (includeSurfsenseDocs && surfsenseDocs?.items) {
|
||||||
for (const doc of surfsenseDocs.items) {
|
for (const doc of surfsenseDocs.items) {
|
||||||
combinedDocs.push({
|
combinedDocs.push({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
|
|
@ -340,7 +354,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
}
|
}
|
||||||
|
|
||||||
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
|
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
|
||||||
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
|
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm, includeSurfsenseDocs]);
|
||||||
|
|
||||||
const loadNextPage = useCallback(async () => {
|
const loadNextPage = useCallback(async () => {
|
||||||
if (isLoadingMore || !hasMore) return;
|
if (isLoadingMore || !hasMore) return;
|
||||||
|
|
@ -449,7 +463,7 @@ export const DocumentMentionPicker = forwardRef<
|
||||||
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
|
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
|
||||||
[initialSelectedDocuments]
|
[initialSelectedDocuments]
|
||||||
);
|
);
|
||||||
const showSurfsenseDocsRoot = surfsenseDocsList.length > 0;
|
const showSurfsenseDocsRoot = includeSurfsenseDocs && surfsenseDocsList.length > 0;
|
||||||
|
|
||||||
const selectMention = useCallback(
|
const selectMention = useCallback(
|
||||||
(mention: MentionedDocumentInfo) => {
|
(mention: MentionedDocumentInfo) => {
|
||||||
|
|
|
||||||
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