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:
Xi Zhang
2026-06-27 01:21:51 +01:00
parent 964430441a
commit afbea9fd77
6 changed files with 1362 additions and 4 deletions
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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
+5 -4
View File
@@ -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,
};
}
}
+141
View File
@@ -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 };
}
+2
View File
@@ -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 && (
+155
View File
@@ -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`;
}