ai / frontend /src /lib /components /automations /AutomationEditor.svelte
fdrah
initial-commit
9e93b10
Raw
History Blame Contribute Delete
15 kB
<script lang="ts">
import { onMount, getContext } from "svelte";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { REXPRO_NAME, showSidebar } from "$lib/stores";
import {
updateAutomationById,
toggleAutomationById,
runAutomationById,
deleteAutomationById,
getAutomationRuns,
type AutomationForm,
type AutomationResponse,
type AutomationRunModel,
} from "$lib/apis/automations";
import Spinner from "$lib/components/common/Spinner.svelte";
import Tooltip from "$lib/components/common/Tooltip.svelte";
import DeleteConfirmDialog from "$lib/components/common/ConfirmDialog.svelte";
import GarbageBin from "$lib/components/icons/GarbageBin.svelte";
import ChevronLeft from "$lib/components/icons/ChevronLeft.svelte";
import ScheduleDropdown from "$lib/components/automations/ScheduleDropdown.svelte";
import ModelDropdown from "$lib/components/automations/ModelDropdown.svelte";
dayjs.extend(relativeTime);
dayjs.extend(localizedFormat);
const i18n = getContext("i18n");
export let automation: AutomationResponse;
let name = "";
let prompt = "";
let model_id = "";
let is_active = true;
let loading = false;
let saving = false;
let showDeleteConfirm = false;
let runs: AutomationRunModel[] = [];
let runsLoading = false;
let hasMoreRuns = true;
let runsPage = 0;
let isDirty = false;
let scheduleDropdown: ScheduleDropdown;
const formatRunTime = (ts: number): string => {
const now = Date.now();
const diff = now - ts / 1_000_000;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const years = Math.floor(days / 365);
if (years > 0)
return $i18n.t("{{COUNT}}y", { COUNT: years, context: "time_ago" });
if (weeks > 0)
return $i18n.t("{{COUNT}}w", { COUNT: weeks, context: "time_ago" });
if (days > 0)
return $i18n.t("{{COUNT}}d", { COUNT: days, context: "time_ago" });
if (hours > 0)
return $i18n.t("{{COUNT}}h", { COUNT: hours, context: "time_ago" });
if (minutes > 0)
return $i18n.t("{{COUNT}}m", {
COUNT: minutes,
context: "time_ago",
});
return $i18n.t("1m", { context: "time_ago" });
};
const formatNextRun = (ts: number | null): string => {
if (!ts) return $i18n.t("Not scheduled");
const d = dayjs(ts / 1_000_000);
if (d.isSame(dayjs(), "day"))
return `${$i18n.t("Today at")} ${d.format("LT")}`;
return d.format("L LT");
};
const saveHandler = async () => {
if (!name.trim() || !prompt.trim() || !model_id.trim()) {
toast.error($i18n.t("Name, prompt, and model are required"));
return;
}
saving = true;
try {
const form: AutomationForm = {
name: name.trim(),
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
rrule: scheduleDropdown.buildRrule(),
},
is_active,
};
const updated = await updateAutomationById(
localStorage.token,
automation.id,
form,
);
if (updated) {
automation = updated;
isDirty = false;
toast.success($i18n.t("Automation updated"));
}
} catch (e: any) {
toast.error(e?.detail ?? `${e}` ?? "Failed to save");
} finally {
saving = false;
}
};
const toggleHandler = async () => {
const res = await toggleAutomationById(
localStorage.token,
automation.id,
).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
is_active = res.is_active;
automation = res;
}
};
const runNowHandler = async () => {
loading = true;
const res = await runAutomationById(
localStorage.token,
automation.id,
).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
toast.success($i18n.t("Automation triggered"));
setTimeout(() => loadRuns(false), 2000);
}
loading = false;
};
const deleteHandler = async () => {
const res = await deleteAutomationById(
localStorage.token,
automation.id,
).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
toast.success(
$i18n.t(`Deleted {{name}}`, { name: automation.name }),
);
goto("/automations");
}
};
const loadRuns = async (loadMore = false) => {
if (runsLoading || (!hasMoreRuns && loadMore)) return;
runsLoading = true;
if (!loadMore) {
runsPage = 0;
hasMoreRuns = true;
}
try {
const fetchedRuns =
(await getAutomationRuns(
localStorage.token,
automation.id,
runsPage * 50,
50,
)) ?? [];
if (loadMore) {
runs = [...runs, ...fetchedRuns];
} else {
runs = fetchedRuns;
}
if (fetchedRuns.length < 50) {
hasMoreRuns = false;
}
runsPage++;
} catch {
if (!loadMore) runs = [];
}
runsLoading = false;
};
const markDirty = () => {
isDirty = true;
};
const onScroll = (e: Event) => {
const target = e.target as HTMLElement;
if (
target.scrollTop + target.clientHeight >=
target.scrollHeight - 50
) {
if (!runsLoading && hasMoreRuns) {
loadRuns(true);
}
}
};
onMount(async () => {
name = automation.name;
prompt = automation.data.prompt;
model_id = automation.data.model_id;
is_active = automation.is_active;
if (scheduleDropdown) {
scheduleDropdown.parseRrule(automation.data.rrule);
}
await loadRuns();
});
</script>
<svelte:head>
<title>{name || $i18n.t("Automation")}{$REXPRO_NAME}</title>
</svelte:head>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t("Delete automation?")}
on:confirm={deleteHandler}
>
<div class="text-sm text-gray-500 truncate">
{$i18n.t("This will delete")} <span class="">{automation.name}</span>.
</div>
</DeleteConfirmDialog>
<div
class="flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} max-w-full"
>
<div class="flex-1 max-h-full flex flex-col pt-3 pb-1 px-3 md:px-[18px]">
<!-- Header Segment (Shrink-0 so it doesn't compress) -->
<div class="flex items-start justify-between gap-4 shrink-0 mb-0.5">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1.5">
<Tooltip content={$i18n.t("Back")}>
<button
class="text-sm p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition"
aria-label={$i18n.t("Back")}
on:click={() => goto("/automations")}
type="button"
>
<ChevronLeft strokeWidth="2.5" />
</button>
</Tooltip>
<input
class="text-2xl w-full bg-transparent outline-hidden"
placeholder={$i18n.t("Automation Name")}
bind:value={name}
on:input={markDirty}
/>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Tooltip content={$i18n.t("Delete")}>
<button
class="p-2 rounded-full bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 text-gray-500 hover:text-black dark:hover:text-white transition"
on:click={() => (showDeleteConfirm = true)}
type="button"
>
<GarbageBin />
</button>
</Tooltip>
{#if isDirty}
<button
class="px-3 py-1 text-sm bg-black text-white dark:bg-white dark:text-black rounded-full hover:opacity-90 transition flex items-center gap-1.5"
on:click={saveHandler}
disabled={saving}
type="button"
>
{$i18n.t("Save")}
{#if saving}
<Spinner className="size-3" />
{/if}
</button>
{/if}
<button
class="px-2.5 py-1 text-sm border border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full flex items-center gap-1.5"
on:click={runNowHandler}
type="button"
disabled={loading}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.841Z"
/>
</svg>
<div class="hidden md:block">{$i18n.t("Run now")}</div>
{#if loading}
<Spinner className="size-3" />
{/if}
</button>
</div>
</div>
<!-- Content Segment: Independent Scrolling Columns based on PromptEditor -->
<div
class="flex flex-col md:flex-row gap-4 flex-1 overflow-hidden pb-2 px-1"
>
<!-- Main Input Column -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<div
class="flex items-center justify-between mb-2 shrink-0 px-1"
>
<div class="text-gray-500 text-xs">
{$i18n.t("Instructions")}
</div>
</div>
<div class="relative flex-1 min-h-0">
<div
class="bg-gray-50 dark:bg-gray-900 rounded-2xl p-4 border border-gray-100/50 dark:border-gray-850/50 h-full"
>
<textarea
class="w-full h-full text-sm bg-transparent outline-hidden resize-none placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={prompt}
on:input={markDirty}
placeholder={$i18n.t(
"Enter the prompt instructions for this automation...",
)}
/>
</div>
</div>
</div>
<!-- Sidebar Configuration Column -->
<div
class="hidden md:flex w-full md:w-66 shrink-0 overflow-y-auto px-1 flex-col gap-5"
>
<div>
<div class="text-gray-500 text-xs mb-3">
{$i18n.t("Configuration")}
</div>
<div class="space-y-1">
<!-- Schedule -->
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400"
>{$i18n.t("Repeats")}</span
>
<ScheduleDropdown
bind:this={scheduleDropdown}
side="bottom"
align="end"
onChange={markDirty}
/>
</div>
<!-- Model -->
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400"
>{$i18n.t("Model")}</span
>
<ModelDropdown
bind:model_id
side="bottom"
align="end"
onChange={markDirty}
/>
</div>
</div>
</div>
<!-- Status section -->
<div>
<div class="text-gray-500 text-xs mb-3">
{$i18n.t("Status")}
</div>
<div class="space-y-2.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400"
>{$i18n.t("State")}</span
>
<span
class="flex items-center gap-1.5 text-xs {is_active
? 'text-emerald-700 dark:text-emerald-400'
: 'text-gray-600 dark:text-gray-400'}"
>
<span
class="inline-block size-1.5 rounded-full {is_active
? 'bg-emerald-500'
: 'bg-gray-400'}"
></span>
{is_active
? $i18n.t("Active")
: $i18n.t("Paused")}
</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400"
>{$i18n.t("Next run")}</span
>
<span class=" text-gray-700 dark:text-gray-300"
>{formatNextRun(
automation.next_runs?.[0] ??
automation.next_run_at,
)}</span
>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400"
>{$i18n.t("Last ran")}</span
>
<span class=" text-gray-700 dark:text-gray-300"
>{automation.last_run_at
? formatNextRun(automation.last_run_at)
: $i18n.t("Never")}</span
>
</div>
</div>
</div>
<div class="flex-1 flex flex-col min-h-0 -mx-1">
<div class="text-gray-500 text-xs mb-2 mx-1 shrink-0">
{$i18n.t("Execution Logs")}
</div>
<div
class="flex-1 overflow-y-auto scrollbar-hidden w-full"
on:scroll={onScroll}
>
{#if runsLoading && runs.length === 0}
<div class="flex justify-center py-4">
<Spinner className="size-4" />
</div>
{:else if runs.length === 0}
<div
class="text-xs text-gray-400 py-4 tabular-nums"
>
{$i18n.t("No execution logs available yet")}
</div>
{:else}
<div class="space-y-0.5 w-full">
{#each runs as run (run.id)}
<button
class="w-full text-left flex items-center gap-2.5 px-2.5 py-1.5 rounded-xl hover:bg-gray-100/80 dark:hover:bg-gray-850/80 transition-colors {run.chat_id
? 'cursor-pointer'
: 'cursor-default'}"
on:click={() => {
if (run.chat_id)
goto(`/c/${run.chat_id}`);
}}
type="button"
>
<div
class="shrink-0 flex items-center justify-center"
>
{#if run.status === "success"}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 text-emerald-500"
><path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/></svg
>
{:else if run.status === "error"}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 text-red-500"
><path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
clip-rule="evenodd"
/></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 text-blue-500"
><path
d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .414.336.75.75.75h4a.75.75 0 000-1.5h-3.25V5z"
/></svg
>
{/if}
</div>
<div class="flex-1 min-w-0">
<div
class="text-xs text-gray-800 dark:text-gray-200 truncate"
>
{automation.name}
</div>
</div>
<span
class="shrink-0 text-[10px] text-gray-500 font-mono"
>{formatRunTime(
run.created_at,
)}</span
>
</button>
{/each}
{#if runsLoading && runs.length > 0}
<div class="flex justify-center py-4">
<Spinner className="size-4" />
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
</div>