feat(cron): allow users to associate cron jobs with specific agents (#835)

This commit is contained in:
Tao Yiping
2026-04-12 11:51:29 +08:00
committed by GitHub
Unverified
parent 49518300dc
commit 87ab12849c
9 changed files with 429 additions and 10 deletions

View File

@@ -29,6 +29,7 @@
"taskNamePlaceholder": "e.g., Morning briefing",
"message": "Message / Prompt",
"messagePlaceholder": "What should the AI do? e.g., Give me a summary of today's news and weather",
"agent": "Agent",
"schedule": "Schedule",
"cronPlaceholder": "Cron expression (e.g., 0 9 * * *)",
"usePresets": "Use presets",

View File

@@ -29,6 +29,7 @@
"taskNamePlaceholder": "例:朝のブリーフィング",
"message": "メッセージ / プロンプト",
"messagePlaceholder": "AIに何をさせますか 例:今日のニュースと天気のまとめを作成",
"agent": "エージェント",
"schedule": "スケジュール",
"cronPlaceholder": "Cron式0 9 * * *",
"usePresets": "プリセットを使用",

View File

@@ -29,6 +29,7 @@
"taskNamePlaceholder": "напр., Утренняя сводка",
"message": "Сообщение / Промпт",
"messagePlaceholder": "Что должен сделать AI? напр., Дай мне сводку новостей и погоды на сегодня",
"agent": "Агент",
"schedule": "Расписание",
"cronPlaceholder": "Cron-выражение (напр., 0 9 * * *)",
"usePresets": "Использовать пресеты",

View File

@@ -29,6 +29,7 @@
"taskNamePlaceholder": "例如:早间简报",
"message": "消息/提示词",
"messagePlaceholder": "AI 应该做什么?例如:给我一份今天的新闻和天气摘要",
"agent": "智能体",
"schedule": "调度计划",
"cronPlaceholder": "Cron 表达式 (例如0 9 * * *)",
"usePresets": "使用预设",

View File

@@ -20,6 +20,7 @@ import {
History,
Pause,
ChevronDown,
Bot,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -32,6 +33,8 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { hostApiFetch } from '@/lib/host-api';
import { useCronStore } from '@/stores/cron';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { useChatStore } from '@/stores/chat';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { formatRelativeTime, cn } from '@/lib/utils';
import { toast } from 'sonner';
@@ -243,9 +246,12 @@ interface TaskDialogProps {
function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProps) {
const { t } = useTranslation('cron');
const [saving, setSaving] = useState(false);
const agents = useAgentsStore((s) => s.agents);
const [name, setName] = useState(job?.name || '');
const [message, setMessage] = useState(job?.message || '');
const [selectedAgentId, setSelectedAgentId] = useState(job?.agentId || useChatStore.getState().currentAgentId);
const [agentIdChanged, setAgentIdChanged] = useState(false);
// Extract cron expression string from CronSchedule object or use as-is if string
const initialSchedule = (() => {
const s = job?.schedule;
@@ -405,6 +411,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
schedule: finalSchedule,
delivery: finalDelivery,
enabled,
...(agentIdChanged ? { agentId: selectedAgentId } : {}),
});
onClose();
toast.success(job ? t('toast.updated') : t('toast.created'));
@@ -453,6 +460,26 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
/>
</div>
{/* Agent */}
<div className="space-y-2.5">
<Label htmlFor="agent" className="text-[14px] text-foreground/80 font-bold">{t('dialog.agent')}</Label>
<SelectField
id="agent"
value={selectedAgentId}
onChange={(e) => {
setSelectedAgentId(e.target.value);
setAgentIdChanged(true);
}}
className="h-[44px] rounded-xl border-black/10 dark:border-white/10 bg-[#eeece3] dark:bg-muted text-[13px]"
>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</SelectField>
</div>
{/* Schedule */}
<div className="space-y-2.5">
<Label className="text-[14px] text-foreground/80 font-bold">{t('dialog.schedule')}</Label>
@@ -685,6 +712,8 @@ interface CronJobCardProps {
function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
const { t } = useTranslation('cron');
const [triggering, setTriggering] = useState(false);
const agents = useAgentsStore((s) => s.agents);
const agentName = agents.find((a) => a.id === job.agentId)?.name ?? job.agentId;
const handleTrigger = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -787,6 +816,11 @@ function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onT
{t('card.next')}: {new Date(job.nextRun).toLocaleString()}
</span>
)}
<span className="flex items-center gap-1.5">
<Bot className="h-3.5 w-3.5" />
{agentName}
</span>
</div>
{/* Last Run Error */}

View File

@@ -4,6 +4,7 @@
*/
import { create } from 'zustand';
import { hostApiFetch } from '@/lib/host-api';
import { useChatStore } from './chat';
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
interface CronState {
@@ -34,10 +35,47 @@ export const useCronStore = create<CronState>((set) => ({
} else {
set({ error: null });
}
try {
const result = await hostApiFetch<CronJob[]>('/api/cron/jobs');
set({ jobs: result, loading: false });
// If Gateway returned fewer jobs than we have, something might be wrong - preserve all known jobs
// and just update agentIds from localStorage for the ones Gateway returned.
// Priority: API agentId (if non-'main') > currentJobs > localStorage > 'main'
const resultIds = new Set(result.map(j => j.id));
const savedAgentIdMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record<string, string>;
// Update localStorage agentId map with current data
const newAgentIdMap: Record<string, string> = {};
// For jobs returned by Gateway, restore agentId
const jobsWithAgentId = result.map((job) => {
// Priority: API response (if non-'main') > currentJobs > localStorage > default 'main'
const existingJob = currentJobs.find((j) => j.id === job.id);
const savedAgentId = savedAgentIdMap[job.id];
let agentId = job.agentId;
if (!agentId || agentId === 'main') {
// API returned 'main' or nothing — use cached value
if (existingJob && existingJob.agentId !== 'main') {
agentId = existingJob.agentId;
} else if (savedAgentId && savedAgentId !== 'main') {
agentId = savedAgentId;
} else {
agentId = 'main';
}
}
if (agentId !== 'main') {
newAgentIdMap[job.id] = agentId;
}
return { ...job, agentId };
});
// If Gateway returned fewer jobs, preserve extra jobs from current state
const extraJobs = currentJobs.filter(j => !resultIds.has(j.id));
const allJobs = [...jobsWithAgentId, ...extraJobs];
localStorage.setItem('cronAgentIdMap', JSON.stringify(newAgentIdMap));
set({ jobs: allJobs, loading: false });
} catch (error) {
// Preserve previous jobs on error so the user sees stale data instead of nothing.
set({ error: String(error), loading: false });
@@ -46,12 +84,19 @@ export const useCronStore = create<CronState>((set) => ({
createJob: async (input) => {
try {
// Auto-capture currentAgentId if not provided
const agentId = input.agentId ?? useChatStore.getState().currentAgentId;
const job = await hostApiFetch<CronJob>('/api/cron/jobs', {
method: 'POST',
body: JSON.stringify(input),
body: JSON.stringify({ ...input, agentId }),
});
set((state) => ({ jobs: [...state.jobs, job] }));
return job;
const jobWithAgentId = { ...job, agentId };
// Persist agentId to localStorage (since Gateway doesn't return it)
const savedMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record<string, string>;
savedMap[jobWithAgentId.id] = agentId;
localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap));
set((state) => ({ jobs: [...state.jobs, jobWithAgentId] }));
return jobWithAgentId;
} catch (error) {
console.error('Failed to create cron job:', error);
throw error;
@@ -60,13 +105,44 @@ export const useCronStore = create<CronState>((set) => ({
updateJob: async (id, input) => {
try {
const currentJob = useCronStore.getState().jobs.find((j) => j.id === id);
const newAgentId = input.agentId;
// If agentId changed, recreate with new agentId first then delete old one (Gateway doesn't support updating sessionTarget)
if (newAgentId && currentJob && newAgentId !== currentJob.agentId) {
// Create new job with new agentId first (preserves schedule on failure)
const { agentId: _agentId, ...restInput } = input;
const newJob = await hostApiFetch<CronJob>('/api/cron/jobs', {
method: 'POST',
body: JSON.stringify({ ...restInput, agentId: newAgentId }),
});
const jobWithAgentId = { ...currentJob, ...newJob, agentId: newAgentId };
// Update localStorage: add new id first, then remove old id
const savedMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record<string, string>;
savedMap[jobWithAgentId.id] = newAgentId;
localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap));
// Delete old job after new one is created successfully
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
delete savedMap[id];
localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap));
set((state) => ({
jobs: state.jobs.map((j) => (j.id === id ? jobWithAgentId : j)),
}));
return;
}
// Normal update for other fields - use currentJob as base, overlay updatedJob to preserve fields
const updatedJob = await hostApiFetch<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(input),
});
// Merge: updatedJob fields override currentJob, but preserve currentJob fields not in updatedJob
const jobWithAgentId = { ...currentJob, ...updatedJob, agentId: currentJob?.agentId ?? updatedJob.agentId };
set((state) => ({
jobs: state.jobs.map((job) =>
job.id === id ? updatedJob : job
job.id === id ? jobWithAgentId : job
),
}));
} catch (error) {
@@ -80,6 +156,10 @@ export const useCronStore = create<CronState>((set) => ({
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
// Remove from localStorage
const savedMap = JSON.parse(localStorage.getItem('cronAgentIdMap') || '{}') as Record<string, string>;
delete savedMap[id];
localStorage.setItem('cronAgentIdMap', JSON.stringify(savedMap));
set((state) => ({
jobs: state.jobs.filter((job) => job.id !== id),
}));
@@ -108,15 +188,20 @@ export const useCronStore = create<CronState>((set) => ({
triggerJob: async (id) => {
try {
const result = await hostApiFetch('/api/cron/trigger', {
await hostApiFetch('/api/cron/trigger', {
method: 'POST',
body: JSON.stringify({ id }),
});
console.log('Cron trigger result:', result);
// Refresh jobs after trigger to update lastRun/nextRun state
try {
const jobs = await hostApiFetch<CronJob[]>('/api/cron/jobs');
set({ jobs });
const currentJobs = useCronStore.getState().jobs;
const resultJobs = await hostApiFetch<CronJob[]>('/api/cron/jobs');
// Preserve agentId from existing jobs
const jobsWithAgentId = resultJobs.map((job) => {
const existing = currentJobs.find((j) => j.id === job.id);
return existing ? { ...job, agentId: existing.agentId } : job;
});
set({ jobs: jobsWithAgentId });
} catch {
// Ignore refresh error
}

View File

@@ -58,6 +58,7 @@ export interface CronJob {
updatedAt: string;
lastRun?: CronJobLastRun;
nextRun?: string;
agentId: string;
}
/**
@@ -69,6 +70,7 @@ export interface CronJobCreateInput {
schedule: string;
delivery?: CronJobDelivery;
enabled?: boolean;
agentId?: string;
}
/**
@@ -80,6 +82,7 @@ export interface CronJobUpdateInput {
schedule?: string;
delivery?: CronJobDelivery;
enabled?: boolean;
agentId?: string;
}
/**