feat: add scheduled tasks functionality and UI integration
- Introduced a new ScheduledTasksPanel component to manage and display scheduled tasks. - Implemented useScheduledTasks hook for fetching, creating, and deleting scheduled tasks. - Added cronUtils for handling cron expressions and scheduling logic. - Updated ThreadList component to include a button for accessing the scheduled tasks view. - Enhanced the useAvailableModels hook for better code readability.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
||||
import {
|
||||
BrainCircuit,
|
||||
Clock,
|
||||
Download,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
@@ -656,6 +657,28 @@ export function ThreadList({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (view === "schedule") {
|
||||
setView(null);
|
||||
onClose?.();
|
||||
return;
|
||||
}
|
||||
setView("schedule");
|
||||
onClose?.();
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-shrink-0 items-center gap-2.5 border-b border-border px-3 py-3 text-left text-sm font-medium transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
view === "schedule" && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<Clock
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Scheduled
|
||||
</button>
|
||||
<div className="flex-shrink-0 border-b border-border p-2.5">
|
||||
<div className="relative">
|
||||
<Search
|
||||
|
||||
@@ -34,7 +34,10 @@ const EMPTY: ModelRegistry = { entries: [], defaultEntry: null };
|
||||
// Failed fetches are evicted so the next mount retries.
|
||||
const cache = new Map<string, Promise<ModelRegistry>>();
|
||||
|
||||
function fetchRegistry(deploymentUrl: string, apiKey: string): Promise<ModelRegistry> {
|
||||
function fetchRegistry(
|
||||
deploymentUrl: string,
|
||||
apiKey: string
|
||||
): Promise<ModelRegistry> {
|
||||
const key = deploymentUrl.replace(/\/$/, "");
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit;
|
||||
@@ -76,9 +79,7 @@ function fetchRegistry(deploymentUrl: string, apiKey: string): Promise<ModelRegi
|
||||
defaultEntry = {
|
||||
name: d.name,
|
||||
provider:
|
||||
typeof d.provider === "string" && d.provider
|
||||
? d.provider
|
||||
: null,
|
||||
typeof d.provider === "string" && d.provider ? d.provider : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Cron } from "@langchain/langgraph-sdk";
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
import { getConfig } from "@/lib/config";
|
||||
|
||||
const SCHEDULED_RUN_KIND = "scheduled_task";
|
||||
const SCHEDULER_GRAPH_ID = "scheduler";
|
||||
|
||||
function makeClient(): Client | null {
|
||||
const config = getConfig();
|
||||
if (!config) return null;
|
||||
const apiKey =
|
||||
config.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
|
||||
return new Client({
|
||||
apiUrl: config.deploymentUrl,
|
||||
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
|
||||
});
|
||||
}
|
||||
|
||||
export interface ScheduledTask {
|
||||
cron_id: string;
|
||||
name: string;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
next_run_date: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function parseCron(cron: Cron): ScheduledTask {
|
||||
const meta = (cron.metadata ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
cron_id: cron.cron_id,
|
||||
name:
|
||||
typeof meta.name === "string" && meta.name ? meta.name : "Unnamed Task",
|
||||
prompt: typeof meta.prompt === "string" ? meta.prompt : "",
|
||||
schedule: cron.schedule,
|
||||
next_run_date: cron.next_run_date ?? null,
|
||||
created_at: cron.created_at,
|
||||
updated_at: cron.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listScheduledTasks(): Promise<ScheduledTask[]> {
|
||||
const client = makeClient();
|
||||
if (!client) return [];
|
||||
// The TS SDK's crons.search doesn't support metadata filtering — fetch all
|
||||
// and filter client-side. Only EvoScientist crons carry run_kind in metadata.
|
||||
const crons = await client.crons.search({ limit: 200 });
|
||||
return crons
|
||||
.filter(
|
||||
(c) =>
|
||||
(c.metadata as Record<string, unknown>)?.run_kind === SCHEDULED_RUN_KIND
|
||||
)
|
||||
.map(parseCron);
|
||||
}
|
||||
|
||||
export async function createScheduledTask(params: {
|
||||
name: string;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
}): Promise<ScheduledTask> {
|
||||
const client = makeClient();
|
||||
if (!client) throw new Error("No EvoScientist deployment configured.");
|
||||
const cron = await client.crons.create(SCHEDULER_GRAPH_ID, {
|
||||
input: { messages: [{ role: "user", content: params.prompt }] },
|
||||
schedule: params.schedule,
|
||||
metadata: {
|
||||
run_kind: SCHEDULED_RUN_KIND,
|
||||
name: params.name,
|
||||
prompt: params.prompt,
|
||||
},
|
||||
});
|
||||
return parseCron(cron as unknown as Cron);
|
||||
}
|
||||
|
||||
export async function deleteScheduledTask(cronId: string): Promise<void> {
|
||||
const client = makeClient();
|
||||
if (!client) throw new Error("No EvoScientist deployment configured.");
|
||||
await client.crons.delete(cronId);
|
||||
}
|
||||
|
||||
export async function runScheduledTaskNow(prompt: string): Promise<void> {
|
||||
const client = makeClient();
|
||||
if (!client) throw new Error("No EvoScientist deployment configured.");
|
||||
const thread = await client.threads.create({ graphId: SCHEDULER_GRAPH_ID });
|
||||
await client.runs.create(thread.thread_id, SCHEDULER_GRAPH_ID, {
|
||||
input: { messages: [{ role: "user", content: prompt }] },
|
||||
metadata: {
|
||||
run_kind: SCHEDULED_RUN_KIND,
|
||||
name: "manual-run",
|
||||
prompt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTasks(): {
|
||||
tasks: ScheduledTask[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
} {
|
||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rev, setRev] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => setRev((r) => r + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
listScheduledTasks()
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setTasks(result);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to load scheduled tasks."
|
||||
);
|
||||
setTasks([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [rev]);
|
||||
|
||||
return { tasks, loading, error, refresh };
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { ChatProvider } from "@/providers/ChatProvider";
|
||||
import { ChatInterface } from "@/app/components/ChatInterface";
|
||||
import { SkillsMarketplace } from "@/app/components/SkillsMarketplace";
|
||||
import { MemoryPanel } from "@/app/components/MemoryPanel";
|
||||
import { ScheduledTasksPanel } from "@/app/components/ScheduledTasksPanel";
|
||||
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
||||
import { HealthIndicator } from "@/app/components/HealthIndicator";
|
||||
import { InspectorPanel } from "@/app/components/InspectorPanel";
|
||||
@@ -420,6 +421,7 @@ function HomePageInner({
|
||||
</div>
|
||||
{view === "skills" && <SkillsMarketplace />}
|
||||
{view === "memory" && <MemoryPanel />}
|
||||
{view === "schedule" && <ScheduledTasksPanel />}
|
||||
</ResizablePanel>
|
||||
|
||||
{inspector && isDesktopLayout && (
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/** Cron expression helpers for the Scheduled Tasks panel. */
|
||||
|
||||
export type Frequency = "daily" | "weekly" | "monthly" | "custom";
|
||||
|
||||
export interface ScheduleSpec {
|
||||
frequency: Frequency;
|
||||
hour: number;
|
||||
minute: number;
|
||||
/** 0 = Sunday … 6 = Saturday (used when frequency === "weekly") */
|
||||
dayOfWeek: number;
|
||||
/** 1–28 (used when frequency === "monthly") */
|
||||
dayOfMonth: number;
|
||||
/** Raw cron string (used when frequency === "custom") */
|
||||
custom: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCHEDULE_SPEC: ScheduleSpec = {
|
||||
frequency: "daily",
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
dayOfWeek: 1,
|
||||
dayOfMonth: 1,
|
||||
custom: "0 9 * * *",
|
||||
};
|
||||
|
||||
export const DAY_NAMES = [
|
||||
"Sun",
|
||||
"Mon",
|
||||
"Tue",
|
||||
"Wed",
|
||||
"Thu",
|
||||
"Fri",
|
||||
"Sat",
|
||||
] as const;
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function validateCronExpression(cron: string): string | null {
|
||||
const trimmed = cron.trim();
|
||||
if (!trimmed) return "Enter a cron expression.";
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return "Use five cron fields: minute hour day month weekday.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function specToCron(spec: ScheduleSpec): string {
|
||||
const minute = clampNumber(spec.minute, 0, 59);
|
||||
const hour = clampNumber(spec.hour, 0, 23);
|
||||
const dayOfWeek = clampNumber(spec.dayOfWeek, 0, 6);
|
||||
const dayOfMonth = clampNumber(spec.dayOfMonth, 1, 28);
|
||||
|
||||
switch (spec.frequency) {
|
||||
case "daily":
|
||||
return `${minute} ${hour} * * *`;
|
||||
case "weekly":
|
||||
return `${minute} ${hour} * * ${dayOfWeek}`;
|
||||
case "monthly":
|
||||
return `${minute} ${hour} ${dayOfMonth} * *`;
|
||||
case "custom":
|
||||
return spec.custom.trim();
|
||||
}
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
|
||||
/** Parse a cron string back into a ScheduleSpec (best-effort). */
|
||||
export function cronToSpec(cron: string): ScheduleSpec {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return { ...DEFAULT_SCHEDULE_SPEC, frequency: "custom", custom: cron };
|
||||
}
|
||||
const [min, hr, dom, month, dow] = parts;
|
||||
const minute = parseInt(min, 10);
|
||||
const hour = parseInt(hr, 10);
|
||||
|
||||
if (
|
||||
month === "*" &&
|
||||
dom === "*" &&
|
||||
dow === "*" &&
|
||||
!isNaN(minute) &&
|
||||
!isNaN(hour)
|
||||
) {
|
||||
return { ...DEFAULT_SCHEDULE_SPEC, frequency: "daily", hour, minute };
|
||||
}
|
||||
if (
|
||||
month === "*" &&
|
||||
dom === "*" &&
|
||||
!isNaN(parseInt(dow, 10)) &&
|
||||
!isNaN(minute) &&
|
||||
!isNaN(hour)
|
||||
) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_SPEC,
|
||||
frequency: "weekly",
|
||||
hour: clampNumber(hour, 0, 23),
|
||||
minute: clampNumber(minute, 0, 59),
|
||||
dayOfWeek: clampNumber(parseInt(dow, 10), 0, 6),
|
||||
};
|
||||
}
|
||||
if (
|
||||
month === "*" &&
|
||||
dow === "*" &&
|
||||
!isNaN(parseInt(dom, 10)) &&
|
||||
!isNaN(minute) &&
|
||||
!isNaN(hour)
|
||||
) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_SPEC,
|
||||
frequency: "monthly",
|
||||
hour: clampNumber(hour, 0, 23),
|
||||
minute: clampNumber(minute, 0, 59),
|
||||
dayOfMonth: clampNumber(parseInt(dom, 10), 1, 28),
|
||||
};
|
||||
}
|
||||
return { ...DEFAULT_SCHEDULE_SPEC, frequency: "custom", custom: cron };
|
||||
}
|
||||
|
||||
/** Human-readable summary of a cron expression. */
|
||||
export function cronLabel(cron: string): string {
|
||||
const spec = cronToSpec(cron);
|
||||
const time = `${String(spec.hour).padStart(2, "0")}:${String(
|
||||
spec.minute
|
||||
).padStart(2, "0")}`;
|
||||
switch (spec.frequency) {
|
||||
case "daily":
|
||||
return `Every day at ${time}`;
|
||||
case "weekly":
|
||||
return `Every ${DAY_NAMES[spec.dayOfWeek]} at ${time}`;
|
||||
case "monthly":
|
||||
return `Monthly on day ${spec.dayOfMonth} at ${time}`;
|
||||
default:
|
||||
return cron;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a next_run_date ISO string into a short relative label. */
|
||||
export function nextRunLabel(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
const now = Date.now();
|
||||
const diff = d.getTime() - now;
|
||||
if (diff < 0) return "Overdue";
|
||||
const mins = Math.round(diff / 60_000);
|
||||
if (mins < 1) return "in <1m";
|
||||
if (mins < 60) return `in ${mins}m`;
|
||||
const hrs = Math.round(diff / 3_600_000);
|
||||
if (hrs < 24) return `in ${hrs}h`;
|
||||
const days = Math.round(diff / 86_400_000);
|
||||
return `in ${days}d`;
|
||||
}
|
||||
Reference in New Issue
Block a user