Merge remote-tracking branch 'upstream/dev' into feat/whatsapp-gateway-integration

This commit is contained in:
Anish Sarkar 2026-06-02 00:29:32 +05:30
commit e3de7c4667
465 changed files with 29171 additions and 6994 deletions

View file

@ -13,6 +13,27 @@ import type { Announcement } from "@/contracts/types/announcement.types";
* This file can be replaced with an API call in the future.
*/
export const announcements: Announcement[] = [
{
id: "2026-05-31-ai-automations",
title: "Introducing AI Automations",
description:
"Turn prompts into hands-off AI agent workflows. Describe an automation in plain English and SurfSense builds it, run it on a schedule, or trigger it the moment a document lands in a folder. Automations work across Notion, Slack, Google Drive, Gmail, GitHub, Linear, Jira and more.",
category: "feature",
date: "2026-05-31T00:00:00Z",
startTime: "2026-05-31T00:00:00Z",
endTime: "2026-07-15T00:00:00Z",
audience: "users",
isImportant: true,
spotlight: true,
image: {
src: "/announcements/automations.png",
alt: "Connector tiles flowing into a central AI core that triggers scheduled and event-driven automations.",
},
link: {
label: "See what's new",
url: "/changelog",
},
},
{
id: "announcement-1",
title: "Introducing What's New",

View file

@ -0,0 +1,110 @@
import {
type AutomationCreateRequest,
type AutomationListParams,
type AutomationUpdateRequest,
automation,
automationCreateRequest,
automationListResponse,
automationUpdateRequest,
modelEligibility,
type RunListParams,
run,
runListResponse,
type TriggerCreateRequest,
type TriggerUpdateRequest,
trigger,
triggerCreateRequest,
triggerUpdateRequest,
} from "@/contracts/types/automation.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
const BASE = "/api/v1/automations";
function rejectIfInvalid<T>(
parsed: { success: true; data: T } | { success: false; error: { issues: { message: string }[] } }
): T {
if (!parsed.success) {
throw new ValidationError(
`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`
);
}
return parsed.data;
}
class AutomationsApiService {
// ---- Automations ---------------------------------------------------------
listAutomations = async (params: AutomationListParams) => {
const qs = new URLSearchParams({
search_space_id: String(params.search_space_id),
limit: String(params.limit),
offset: String(params.offset),
});
return baseApiService.get(`${BASE}?${qs.toString()}`, automationListResponse);
};
getAutomation = async (automationId: number) => {
return baseApiService.get(`${BASE}/${automationId}`, automation);
};
createAutomation = async (request: AutomationCreateRequest) => {
const data = rejectIfInvalid(automationCreateRequest.safeParse(request));
return baseApiService.post(BASE, automation, { body: data });
};
updateAutomation = async (automationId: number, request: AutomationUpdateRequest) => {
const data = rejectIfInvalid(automationUpdateRequest.safeParse(request));
return baseApiService.patch(`${BASE}/${automationId}`, automation, { body: data });
};
// Server returns 204; baseApiService now resolves to null and skips schema validation.
deleteAutomation = async (automationId: number) => {
return baseApiService.delete(`${BASE}/${automationId}`);
};
// Whether the search space's models are billable for automations (premium
// global or BYOK). Used to gate creation surfaces before submit.
getModelEligibility = async (searchSpaceId: number) => {
const qs = new URLSearchParams({ search_space_id: String(searchSpaceId) });
return baseApiService.get(`${BASE}/model-eligibility?${qs.toString()}`, modelEligibility);
};
// ---- Triggers (sub-resource) --------------------------------------------
addTrigger = async (automationId: number, request: TriggerCreateRequest) => {
const data = rejectIfInvalid(triggerCreateRequest.safeParse(request));
return baseApiService.post(`${BASE}/${automationId}/triggers`, trigger, { body: data });
};
updateTrigger = async (
automationId: number,
triggerId: number,
request: TriggerUpdateRequest
) => {
const data = rejectIfInvalid(triggerUpdateRequest.safeParse(request));
return baseApiService.patch(`${BASE}/${automationId}/triggers/${triggerId}`, trigger, {
body: data,
});
};
removeTrigger = async (automationId: number, triggerId: number) => {
return baseApiService.delete(`${BASE}/${automationId}/triggers/${triggerId}`);
};
// ---- Runs (sub-resource, read-only) -------------------------------------
listRuns = async (automationId: number, params: RunListParams) => {
const qs = new URLSearchParams({
limit: String(params.limit),
offset: String(params.offset),
});
return baseApiService.get(`${BASE}/${automationId}/runs?${qs.toString()}`, runListResponse);
};
getRun = async (automationId: number, runId: number) => {
return baseApiService.get(`${BASE}/${automationId}/runs/${runId}`, run);
};
}
export const automationsApiService = new AutomationsApiService();

View file

@ -1,4 +1,5 @@
import type { ZodType } from "zod";
import { BACKEND_URL } from "@/lib/env-config";
import { getClientPlatform } from "../agent-filesystem";
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
import {
@ -9,7 +10,7 @@ import {
NetworkError,
NotFoundError,
} from "../error";
import { BACKEND_URL } from "@/lib/env-config";
enum ResponseType {
JSON = "json",
TEXT = "text",
@ -122,8 +123,9 @@ class BaseApiService {
if (contentType === "application/json" && typeof mergedOptions.body === "object") {
fetchOptions.body = JSON.stringify(mergedOptions.body);
} else {
// Pass body as-is for other content types (e.g., form data, already stringified)
fetchOptions.body = mergedOptions.body;
// Pass body as-is for other content types (form data, already stringified).
// Caller is responsible for passing a real BodyInit when Content-Type is not JSON.
fetchOptions.body = mergedOptions.body as BodyInit;
}
}
@ -210,32 +212,39 @@ class BaseApiService {
let data;
const responseType = mergedOptions.responseType;
try {
switch (responseType) {
case ResponseType.JSON:
data = await response.json();
break;
case ResponseType.TEXT:
data = await response.text();
break;
case ResponseType.BLOB:
data = await response.blob();
break;
case ResponseType.ARRAY_BUFFER:
data = await response.arrayBuffer();
break;
// Add more cases as needed
default:
data = await response.json();
if (response.status === 204) {
// 204 No Content has no body; .json() would throw SyntaxError.
// Leave data as null and skip schema validation below so endpoints
// that opt out of bodies (REST-style DELETE) don't error on success.
data = null;
} else {
try {
switch (responseType) {
case ResponseType.JSON:
data = await response.json();
break;
case ResponseType.TEXT:
data = await response.text();
break;
case ResponseType.BLOB:
data = await response.blob();
break;
case ResponseType.ARRAY_BUFFER:
data = await response.arrayBuffer();
break;
// Add more cases as needed
default:
data = await response.json();
}
} catch (error) {
console.error("Failed to parse response as JSON:", error);
throw new AppError("Failed to parse response", response.status, response.statusText);
}
} catch (error) {
console.error("Failed to parse response as JSON:", error);
throw new AppError("Failed to parse response", response.status, response.statusText);
}
// Validate response
if (responseType === ResponseType.JSON) {
if (!responseSchema) {
if (!responseSchema || response.status === 204) {
return data;
}
const parsedData = responseSchema.safeParse(data);

View file

@ -12,7 +12,6 @@ import {
type GetDocumentsRequest,
type GetDocumentsStatusRequest,
type GetDocumentTypeCountsRequest,
type GetSurfsenseDocsRequest,
getDocumentByChunkRequest,
getDocumentByChunkResponse,
getDocumentChunksRequest,
@ -25,9 +24,6 @@ import {
getDocumentsStatusResponse,
getDocumentTypeCountsRequest,
getDocumentTypeCountsResponse,
getSurfsenseDocsByChunkResponse,
getSurfsenseDocsRequest,
getSurfsenseDocsResponse,
type SearchDocumentsRequest,
type SearchDocumentTitlesRequest,
searchDocumentsRequest,
@ -363,48 +359,6 @@ class DocumentsApiService {
);
};
/**
* Get Surfsense documentation by chunk ID
* Used for resolving [citation:doc-XXX] citations
*/
getSurfsenseDocByChunk = async (chunkId: number) => {
return baseApiService.get(
`/api/v1/surfsense-docs/by-chunk/${chunkId}`,
getSurfsenseDocsByChunkResponse
);
};
/**
* List all Surfsense documentation documents
* @param request - The request with query params
* @param signal - Optional AbortSignal for request cancellation
*/
getSurfsenseDocs = async (request: GetSurfsenseDocsRequest, signal?: AbortSignal) => {
const parsedRequest = getSurfsenseDocsRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
// Transform query params to be string values
const transformedQueryParams = parsedRequest.data.queryParams
? Object.fromEntries(
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
)
: undefined;
const queryParams = transformedQueryParams
? new URLSearchParams(transformedQueryParams).toString()
: "";
const url = `/api/v1/surfsense-docs?${queryParams}`;
return baseApiService.get(url, getSurfsenseDocsResponse, { signal });
};
/**
* Update a document
*/

View file

@ -2,6 +2,7 @@
* Authentication utilities for handling token expiration and redirects
*/
import { BACKEND_URL } from "@/lib/env-config";
const REDIRECT_PATH_KEY = "surfsense_redirect_path";
const BEARER_TOKEN_KEY = "surfsense_bearer_token";
const REFRESH_TOKEN_KEY = "surfsense_refresh_token";

View file

@ -0,0 +1,519 @@
/**
* 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>;
/**
* Per-automation model selection. ``0`` means "unset" the builder resolves it
* to the eligible default during render, and the resolved (non-zero) ids are
* written onto ``definition.models`` at submit so the run is insulated from
* later chat/search-space model changes.
*/
export const builderModelsSchema = z.object({
agentLlmId: z.number().int(),
imageConfigId: z.number().int(),
visionConfigId: z.number().int(),
});
export type BuilderModels = z.infer<typeof builderModelsSchema>;
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(),
/** Selected agent/image/vision models (``0`` = use the eligible default). */
models: builderModelsSchema,
});
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,
models: { agentLlmId: 0, imageConfigId: 0, visionConfigId: 0 },
};
}
/** 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 doc/folder chip metadata (so the run can
* resolve titles to paths); connectors live only in ``mentioned_connectors`` /
* ``mentioned_connector_ids`` to avoid duplicating them across buckets.
*/
function mentionParams(mentions: MentionedDocumentInfo[]): Record<string, unknown> {
if (mentions.length === 0) return {};
const documentIds: number[] = [];
const folderIds: number[] = [];
const connectorIds: number[] = [];
const documents: MentionedDocumentInfo[] = [];
const connectors: MentionedDocumentInfo[] = [];
for (const mention of mentions) {
if (mention.kind === "folder") {
folderIds.push(mention.id);
documents.push(mention);
} else if (mention.kind === "connector") {
connectorIds.push(mention.id);
connectors.push(mention);
} else {
documentIds.push(mention.id);
documents.push(mention);
}
}
const out: Record<string, unknown> = {};
if (documents.length > 0) out.mentioned_documents = documents;
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 },
// Only emit models when fully resolved (the builder seeds non-zero
// defaults before submit). A zero/unset triple is omitted so the
// backend falls back to the search-space snapshot.
...(hasResolvedModels(form.models)
? {
models: {
agent_llm_id: form.models.agentLlmId,
image_generation_config_id: form.models.imageConfigId,
vision_llm_config_id: form.models.visionConfigId,
},
}
: {}),
} as unknown as AutomationDefinition;
}
/** True once every model slot holds a concrete (non-zero) id. */
export function hasResolvedModels(models: BuilderModels): boolean {
return models.agentLlmId !== 0 && models.imageConfigId !== 0 && models.visionConfigId !== 0;
}
/** 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. Doc/folder chips come from
* ``mentioned_documents``; connector chips from ``mentioned_connectors`` (kept
* in their own bucket). Returns ``null`` when the step carries mention IDs that
* aren't backed by usable chip metadata (e.g. hand-edited JSON), so the caller
* can fall back to JSON mode rather than silently dropping those IDs on save.
*/
function mentionsFromParams(params: Record<string, unknown>): MentionedDocumentInfo[] | null {
const mentions: MentionedDocumentInfo[] = [];
const docList = Array.isArray(params.mentioned_documents) ? params.mentioned_documents : [];
for (const raw of docList) {
const mention = coerceMention(raw);
// Connectors belong in their own bucket; ignore any that leak in here.
if (mention && mention.kind !== "connector") mentions.push(mention);
}
const connectorList = Array.isArray(params.mentioned_connectors)
? params.mentioned_connectors
: [];
for (const raw of connectorList) {
const mention = coerceMention(raw);
if (mention && mention.kind === "connector") 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")
: [];
const models = modelsFromDefinition(d.models);
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,
models,
},
};
}
/** Read a captured ``definition.models`` snapshot into the form's model slots. */
function modelsFromDefinition(raw: unknown): BuilderModels {
const m = asRecord(raw);
const num = (value: unknown) => (typeof value === "number" ? value : 0);
return {
agentLlmId: num(m.agent_llm_id),
imageConfigId: num(m.image_generation_config_id),
visionConfigId: num(m.vision_llm_config_id),
};
}
/**
* Project an existing automation into the builder form for editing.
*/
export function formFromAutomation(automation: Automation): HydrateResult {
return hydrateForm(
automation.name,
automation.description ?? null,
automation.definition,
automation.triggers ?? []
);
}

View file

@ -0,0 +1,67 @@
/**
* Minimal cron describer for the 5-field patterns the SurfSense drafter LLM
* actually produces (daily, weekdays, weekly, monthly, hourly). Falls back
* to the raw expression when unrecognized so the user still sees something
* honest instead of a guess.
*
* Lives under ``lib/automations/`` because both the dashboard slice and the
* chat ``create_automation`` approval card render schedule descriptions
* keeping the helper outside either feature avoids a layering violation.
*/
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export function describeCron(cron: string): string {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return cron;
const [minute, hour, dom, month, dow] = parts;
// Daily at H:MM ("0 9 * * *")
if (month === "*" && dom === "*" && dow === "*" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
return `Daily at ${formatTime(hour, minute)}`;
}
// Weekdays at H:MM ("0 9 * * 1-5")
if (month === "*" && dom === "*" && dow === "1-5" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
return `MonFri at ${formatTime(hour, minute)}`;
}
// Specific weekday(s) ("0 9 * * 1" or "0 9 * * 1,3,5")
if (
month === "*" &&
dom === "*" &&
/^\d+$/.test(minute) &&
/^\d+$/.test(hour) &&
/^[\d,]+$/.test(dow)
) {
const days = dow
.split(",")
.map((d) => DAY_NAMES[Number(d) % 7])
.filter(Boolean)
.join(", ");
if (days) return `${days} at ${formatTime(hour, minute)}`;
}
// Monthly on day N ("0 9 1 * *")
if (
month === "*" &&
dow === "*" &&
/^\d+$/.test(dom) &&
/^\d+$/.test(hour) &&
/^\d+$/.test(minute)
) {
return `Day ${dom} of each month at ${formatTime(hour, minute)}`;
}
// Hourly ("0 * * * *")
if (month === "*" && dom === "*" && dow === "*" && hour === "*" && /^\d+$/.test(minute)) {
return minute === "0" ? "Every hour" : `Every hour at :${minute.padStart(2, "0")}`;
}
return cron;
}
function formatTime(hour: string, minute: string): string {
return `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
}

View file

@ -0,0 +1,19 @@
/**
* Format the wall-clock duration between a run/step's start and finish
* timestamps into a compact, human-readable label (e.g. `850ms`, `4.2s`,
* `1m 30s`). Returns `null` when either bound is missing or the delta is
* negative/non-finite, so callers can simply omit the label.
*/
export function formatDuration(
started: string | null | undefined,
finished: string | null | undefined
): string | null {
if (!started || !finished) return null;
const ms = new Date(finished).getTime() - new Date(started).getTime();
if (!Number.isFinite(ms) || ms < 0) return null;
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}

View file

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

View file

@ -0,0 +1,66 @@
/**
* Curated example chat prompts shown on the empty new-chat screen.
*
* These mirror the homepage hero's "use case" concept but with runnable chat
* queries, grouped into a few broad categories. Bracketed slots like `[topic]`
* are intentional: clicking a prompt prefills the composer so the user can fill
* them in before sending.
*
* This is a module-scope constant so it is created once, not per render.
*/
export interface ChatExampleCategory {
/** Stable id used as the Tabs value */
id: string;
/** Short, human-readable tab label */
label: string;
/** Runnable example queries for this category */
prompts: string[];
}
export const CHAT_EXAMPLE_CATEGORIES: ChatExampleCategory[] = [
{
id: "search",
label: "Search & Summarize",
prompts: [
"Summarize the key points across all the documents in this space.",
"What do my files say about [topic]? Answer with citations.",
"Find every mention of [keyword] and list the sources.",
"Give me a cited briefing on the documents I added this week.",
"Compare these two documents and highlight the differences.",
],
},
{
id: "create",
label: "Create",
prompts: [
"Write a cited research report on [topic] from my documents.",
"Turn this folder into a two-host podcast I can listen to.",
"Create a slide deck and a narrated video overview from these sources.",
"Generate an image to illustrate [concept] for my report.",
"Tailor my resume to this job description so it gets past ATS and lands an interview.",
],
},
{
id: "automate",
label: "Automate",
prompts: [
"Email me a daily brief of new documents in my knowledge base every morning.",
"When a PDF lands in my Research folder, generate a cited AI summary.",
"Generate a weekly status report from my Slack and Gmail every Friday.",
"Build an automation that turns new meeting notes into minutes with action items.",
"Run a monthly competitor analysis report and save it to my workspace.",
],
},
{
id: "tools",
label: "Across your tools",
prompts: [
"Search across my Notion, Slack, Google Drive and Gmail for [topic].",
"Post this research summary to my Notion workspace.",
"Send these meeting action items to our team Slack channel.",
"Create a Jira ticket from this bug report.",
"Open a Linear issue from this feature request.",
],
},
];

View file

@ -221,7 +221,6 @@ export interface RegenerateParams {
content: string;
}>;
mentionedDocumentIds?: number[];
mentionedSurfsenseDocIds?: number[];
}
/**

View file

@ -25,7 +25,6 @@ export function getDocumentTypeLabel(type: string): string {
CIRCLEBACK: "Circleback",
OBSIDIAN_CONNECTOR: "Obsidian",
LOCAL_FOLDER_FILE: "Local Folder",
SURFSENSE_DOCS: "SurfSense Docs",
NOTE: "Note",
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive",
COMPOSIO_GMAIL_CONNECTOR: "Composio Gmail",

View file

@ -1,4 +1,12 @@
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import {
differenceInDays,
differenceInMinutes,
format,
isThisYear,
isToday,
isTomorrow,
isYesterday,
} from "date-fns";
/**
* Format a date string as a human-readable relative time
@ -23,6 +31,36 @@ export function formatRelativeDate(dateString: string): string {
return format(date, "MMM d, yyyy");
}
/**
* Format a future date string as a human-readable countdown.
* - < 1 min: "Any moment"
* - < 60 min: "in 15m"
* - Today: "Today, 2:30 PM"
* - Tomorrow: "Tomorrow, 2:30 PM"
* - < 7 days: "in 3d"
* - This year: "May 30, 2:30 PM"
* - Older: "Jan 15, 2027"
*
* Mirrors {@link formatRelativeDate} but for moments strictly after now.
* Falls back to the past-relative formatter if the timestamp is not in
* the future (defensive guards against stale "next_fire_at" values).
*/
export function formatRelativeFutureDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const minutesAhead = differenceInMinutes(date, now);
const daysAhead = differenceInDays(date, now);
if (minutesAhead <= 0) return formatRelativeDate(dateString);
if (minutesAhead < 1) return "Any moment";
if (minutesAhead < 60) return `in ${minutesAhead}m`;
if (isToday(date)) return `Today, ${format(date, "h:mm a")}`;
if (isTomorrow(date)) return `Tomorrow, ${format(date, "h:mm a")}`;
if (daysAhead < 7) return `in ${daysAhead}d`;
if (isThisYear(date)) return format(date, "MMM d, h:mm a");
return format(date, "MMM d, yyyy");
}
/**
* Format a thread's last-updated timestamp for the chats sidebars.
* Example: "Mar 23, 2026 at 4:30 PM"

View file

@ -1,6 +1,6 @@
import type { ConnectorTelemetryMeta } from "@/lib/connector-telemetry";
import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
import posthog from "posthog-js";
import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat-error-classifier";
import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
/**
* PostHog Analytics Event Definitions
@ -19,6 +19,7 @@ import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat
* - connector: External connector events (all lifecycle stages)
* - contact: Contact form events
* - settings: Settings changes
* - automation: Automation lifecycle (create/update/delete/trigger/chat)
* - marketing: Marketing/referral tracking
*/
@ -33,7 +34,7 @@ function safeCapture(event: string, properties?: Record<string, unknown>) {
/**
* Drop undefined values so PostHog doesn't log `"foo": undefined` noise.
*/
function compact<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
function compact<T extends object>(obj: T): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined) out[k] = v;
@ -598,6 +599,146 @@ export function trackReferralLanding(refCode: string, landingUrl: string) {
});
}
// ============================================
// AUTOMATION EVENTS
// ============================================
interface AutomationCreatedProps {
search_space_id: number;
automation_id: number;
task_count?: number;
trigger_type?: string;
has_schedule?: boolean;
agent_llm_id?: number;
image_generation_config_id?: number;
vision_llm_config_id?: number;
tags_count?: number;
}
export function trackAutomationCreated(props: AutomationCreatedProps) {
safeCapture("automation_created", compact(props));
}
export function trackAutomationCreateFailed(props: { search_space_id?: number; error?: string }) {
safeCapture("automation_create_failed", compact(props));
}
export function trackAutomationUpdated(props: {
automation_id: number;
search_space_id?: number;
has_definition_change?: boolean;
has_name_change?: boolean;
has_description_change?: boolean;
task_count?: number;
}) {
safeCapture("automation_updated", compact(props));
}
export function trackAutomationStatusChanged(props: {
automation_id: number;
search_space_id?: number;
next_status: string;
}) {
safeCapture("automation_status_changed", compact(props));
}
export function trackAutomationUpdateFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_update_failed", compact(props));
}
export function trackAutomationDeleted(props: { automation_id: number; search_space_id?: number }) {
safeCapture("automation_deleted", compact(props));
}
export function trackAutomationDeleteFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_delete_failed", compact(props));
}
export function trackAutomationTriggerAdded(props: {
automation_id: number;
trigger_id?: number;
trigger_type?: string;
enabled?: boolean;
has_cron?: boolean;
}) {
safeCapture("automation_trigger_added", compact(props));
}
export function trackAutomationTriggerAddFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_trigger_add_failed", compact(props));
}
export function trackAutomationTriggerUpdated(props: {
automation_id: number;
trigger_id: number;
change?: "enabled" | "params" | "other";
enabled?: boolean;
}) {
safeCapture("automation_trigger_updated", compact(props));
}
export function trackAutomationTriggerUpdateFailed(props: {
automation_id: number;
trigger_id: number;
error?: string;
}) {
safeCapture("automation_trigger_update_failed", compact(props));
}
export function trackAutomationTriggerRemoved(props: {
automation_id: number;
trigger_id: number;
}) {
safeCapture("automation_trigger_removed", compact(props));
}
export function trackAutomationTriggerRemoveFailed(props: {
automation_id: number;
trigger_id: number;
error?: string;
}) {
safeCapture("automation_trigger_remove_failed", compact(props));
}
interface AutomationChatDecisionProps {
search_space_id?: number;
edited?: boolean;
task_count?: number;
trigger_type?: string;
agent_llm_id?: number;
image_generation_config_id?: number;
vision_llm_config_id?: number;
}
export function trackAutomationChatApproved(props: AutomationChatDecisionProps) {
safeCapture("automation_chat_approved", compact(props));
}
export function trackAutomationChatRejected(props: { search_space_id?: number }) {
safeCapture("automation_chat_rejected", compact(props));
}
export function trackAutomationChatDraftEdited(props: { search_space_id?: number }) {
safeCapture("automation_chat_draft_edited", compact(props));
}
export function trackAutomationChatCreateSucceeded(props: {
automation_id: number;
name?: string;
search_space_id?: number;
}) {
safeCapture("automation_chat_create_succeeded", compact(props));
}
export function trackAutomationChatCreateFailed(props: {
reason: "invalid" | "error";
search_space_id?: number;
issue_count?: number;
message?: string;
}) {
safeCapture("automation_chat_create_failed", compact(props));
}
// ============================================
// USER IDENTIFICATION
// ============================================

View file

@ -30,7 +30,6 @@ export const cacheKeys = {
withQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>
["documents-with-queries", ...stableEntries(queries)] as const,
document: (documentId: string) => ["document", documentId] as const,
byChunk: (chunkId: string) => ["documents", "by-chunk", chunkId] as const,
},
logs: {
list: (searchSpaceId?: number | string) => ["logs", "list", searchSpaceId] as const,
@ -126,4 +125,16 @@ export const cacheKeys = {
batchUnreadCounts: (searchSpaceId: number | null) =>
["notifications", "unread-counts-batch", searchSpaceId] as const,
},
automations: {
// list endpoint is keyed by pagination too so distinct pages don't collide
list: (searchSpaceId: number, limit: number, offset: number) =>
["automations", "list", searchSpaceId, limit, offset] as const,
detail: (automationId: number) => ["automations", "detail", automationId] as const,
runs: (automationId: number, limit: number, offset: number) =>
["automations", "runs", automationId, limit, offset] as const,
run: (automationId: number, runId: number) =>
["automations", "runs", automationId, runId] as const,
modelEligibility: (searchSpaceId: number) =>
["automations", "model-eligibility", searchSpaceId] as const,
},
};