Files
zn-ai/src/pages/Cron/index.tsx
duanshuwen ee72cf7261 feat: refactor HomePage to integrate agents store and update related components
feat: add runtime event handling for providers in ProvidersSection

feat: update routing to include Channels and Agents pages

feat: extend route types and navigation items for Channels and Agents

feat: implement agents store for managing agent data and interactions

fix: update chat store to utilize agents store for agent-related functionality

chore: export agents store from index

fix: enhance runtime types for better event handling

fix: update Vite config to handle dev server URL correctly
2026-04-18 14:56:32 +08:00

2222 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useEffect,
useMemo,
useState,
type ReactNode,
type SVGProps,
type SelectHTMLAttributes,
} from 'react';
import { DEFAULT_AGENT_ID, normalizeAgentId, normalizeChannelType, type AgentSummary } from '@runtime/lib/agents';
import { onGatewayEvent } from '../../lib/gateway-client';
import { hostApiFetch } from '../../lib/host-api';
import type {
ChannelTargetCatalogItem,
ChannelTargetsCatalogResponse,
} from '../../lib/channel-types';
import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../lib/runtime-events';
import type {
CronDeliveryChannelAccount,
CronDeliveryChannelGroup,
CronJob,
CronJobCreateInput,
CronJobDelivery,
} from '../../lib/cron-types';
import { agentsStore, chatStore, useAgentsStore } from '../../stores';
type FeedbackTone = 'success' | 'error' | 'info' | 'warning';
type FeedbackState = {
tone: FeedbackTone;
message: string;
} | null;
type DialogProps = {
open: boolean;
job: CronJob | null;
saving: boolean;
agents: AgentSummary[];
defaultAgentId: string;
channelGroups: CronDeliveryChannelGroup[];
channelsLoading: boolean;
channelsError: string | null;
onClose: () => void;
onSave: (input: CronJobCreateInput) => Promise<void>;
};
type JobCardProps = {
job: CronJob;
agentLabel: string;
agentDetail: string;
deliverySummary: string;
busyAction: string | null;
onToggle: (enabled: boolean) => void;
onEdit: () => void;
onDelete: () => void;
onTrigger: () => void;
};
type StatCardProps = {
label: string;
value: number;
tone: 'neutral' | 'green' | 'amber' | 'red';
icon: (props: SVGProps<SVGSVGElement>) => JSX.Element;
};
type SchedulePreset = {
key: string;
label: string;
value: string;
};
type CronJobsResponse =
| CronJob[]
| {
success?: boolean;
jobs?: unknown;
data?: unknown;
items?: unknown;
error?: string;
};
type ChannelAccountsResponse =
| unknown[]
| {
success?: boolean;
channels?: unknown;
groups?: unknown;
accounts?: unknown;
data?: unknown;
error?: string;
};
type ChannelTargetsResponse =
| unknown[]
| ChannelTargetsCatalogResponse
| {
success?: boolean;
targets?: unknown;
items?: unknown;
data?: unknown;
error?: string;
};
type DeliveryChannelGroupLike = Partial<CronDeliveryChannelGroup> & {
name?: string;
label?: string;
channelName?: string;
accounts?: unknown;
};
type DeliveryChannelAccountLike = Partial<CronDeliveryChannelAccount> & {
id?: string;
label?: string;
channelName?: string;
channelType?: string;
name?: string;
default?: boolean;
};
type DeliveryTargetOptionLike = Partial<ChannelTargetCatalogItem> & {
name?: string;
title?: string;
desc?: string;
};
const SCHEDULE_PRESETS: SchedulePreset[] = [
{ key: 'every-minute', label: '每分钟', value: '* * * * *' },
{ key: 'every-5-minutes', label: '每 5 分钟', value: '*/5 * * * *' },
{ key: 'every-15-minutes', label: '每 15 分钟', value: '*/15 * * * *' },
{ key: 'every-hour', label: '每小时整点', value: '0 * * * *' },
{ key: 'daily-9', label: '每天 09:00', value: '0 9 * * *' },
{ key: 'daily-18', label: '每天 18:00', value: '0 18 * * *' },
{ key: 'weekly-mon', label: '每周一 09:00', value: '0 9 * * 1' },
{ key: 'monthly-first', label: '每月 1 日 09:00', value: '0 9 1 * *' },
];
const CHANNEL_DISPLAY_NAMES: Record<string, string> = {
douyin: '抖音',
feishu: '飞书',
fliggy: '飞猪',
meituan: '美团',
qqbot: 'QQ 机器人',
telegram: 'Telegram',
wechat: '微信',
wecom: '企业微信',
};
const FALLBACK_CRON_JOBS: CronJob[] = [
{
id: 'cron-morning-briefing',
name: '晨间营业播报',
message: '每天开店前提醒值班同事检查渠道状态和当日房态。',
schedule: '0 9 * * *',
agentId: 'main',
delivery: {
mode: 'announce',
channel: 'wecom',
accountId: 'default',
to: '早班值守群',
},
enabled: true,
createdAt: '2026-04-10T09:00:00.000Z',
updatedAt: '2026-04-16T09:00:00.000Z',
nextRun: new Date(Date.now() + 1000 * 60 * 60 * 7).toISOString(),
lastRun: {
time: new Date(Date.now() - 1000 * 60 * 60 * 15).toISOString(),
success: true,
},
},
{
id: 'cron-channel-check',
name: '渠道异常巡检',
message: '每 15 分钟轮询飞猪、美团和抖音渠道在线状态。',
schedule: '*/15 * * * *',
agentId: 'main',
delivery: { mode: 'none' },
enabled: true,
createdAt: '2026-04-11T08:30:00.000Z',
updatedAt: '2026-04-16T08:30:00.000Z',
nextRun: new Date(Date.now() + 1000 * 60 * 12).toISOString(),
lastRun: {
time: new Date(Date.now() - 1000 * 60 * 6).toISOString(),
success: true,
},
},
{
id: 'cron-review-summary',
name: '评论汇总提醒',
message: '每日晚间生成评论待处理清单并推送给运营。',
schedule: '0 18 * * *',
agentId: 'main',
delivery: {
mode: 'announce',
channel: 'feishu',
accountId: 'default',
to: '运营复盘群',
},
enabled: false,
createdAt: '2026-04-09T18:00:00.000Z',
updatedAt: '2026-04-15T18:00:00.000Z',
lastRun: {
time: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(),
success: false,
error: '渠道响应超时,晚间汇总未完成,请检查网关与服务状态。',
},
},
];
function cn(...tokens: Array<string | false | null | undefined>): string {
return tokens.filter(Boolean).join(' ');
}
function getString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function IconBase({
children,
className,
...props
}: SVGProps<SVGSVGElement> & { children: ReactNode }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
{children}
</svg>
);
}
function RefreshIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M20 12A8 8 0 1 1 17.2 6" />
<path d="M20 4V9H15" />
</IconBase>
);
}
function PlusIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M12 5V19" />
<path d="M5 12H19" />
</IconBase>
);
}
function ClockIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<circle cx="12" cy="12" r="8" />
<path d="M12 8V12L15 14" />
</IconBase>
);
}
function PlayIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M8 6.5V17.5L17 12L8 6.5Z" fill="currentColor" stroke="none" />
</IconBase>
);
}
function PauseIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M9 7V17" />
<path d="M15 7V17" />
</IconBase>
);
}
function AlertIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M12 9V13" />
<path d="M12 17H12.01" />
<path d="M10.3 4.9L3.8 16.3A1.5 1.5 0 0 0 5.1 18.5H18.9A1.5 1.5 0 0 0 20.2 16.3L13.7 4.9A1.5 1.5 0 0 0 10.3 4.9Z" />
</IconBase>
);
}
function MessageIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M6 8.5H18" />
<path d="M6 12H14" />
<path d="M5 5.5H19A2 2 0 0 1 21 7.5V13.5A2 2 0 0 1 19 15.5H12L8 18.5V15.5H5A2 2 0 0 1 3 13.5V7.5A2 2 0 0 1 5 5.5Z" />
</IconBase>
);
}
function HistoryIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M4 12A8 8 0 1 0 6.3 6.3" />
<path d="M4 4V9H9" />
<path d="M12 8V12L15 14" />
</IconBase>
);
}
function CalendarIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M7 3V6" />
<path d="M17 3V6" />
<path d="M4 9H20" />
<rect x="4" y="5" width="16" height="15" rx="2" />
</IconBase>
);
}
function TrashIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M5 7H19" />
<path d="M10 11V16" />
<path d="M14 11V16" />
<path d="M8 7L9 4H15L16 7" />
<path d="M18 7L17.2 18.2A2 2 0 0 1 15.2 20H8.8A2 2 0 0 1 6.8 18.2L6 7" />
</IconBase>
);
}
function CloseIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M6 6L18 18" />
<path d="M18 6L6 18" />
</IconBase>
);
}
function SpinnerIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M12 4A8 8 0 0 1 20 12" />
<path d="M20 12A8 8 0 1 1 12 4" opacity="0.2" />
</IconBase>
);
}
function BotIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<rect x="5" y="8" width="14" height="10" rx="3" />
<path d="M12 4V8" />
<path d="M9.5 13H9.51" />
<path d="M14.5 13H14.51" />
<path d="M9 18V20" />
<path d="M15 18V20" />
</IconBase>
);
}
function SendIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M21 3L10 14" />
<path d="M21 3L14 21L10 14L3 10L21 3Z" />
</IconBase>
);
}
function ChevronDownIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M7 10L12 15L17 10" />
</IconBase>
);
}
function getScheduleExpression(schedule: CronJob['schedule']): string {
if (typeof schedule === 'string') return schedule;
if (schedule.kind === 'cron') return schedule.expr;
if (schedule.kind === 'at') return schedule.at;
if (schedule.kind === 'every') return String(schedule.everyMs);
return '';
}
function parseCronSchedule(schedule: CronJob['schedule']): string {
if (typeof schedule !== 'string') {
if (schedule.kind === 'at') {
return `单次执行 · ${formatDateTime(schedule.at)}`;
}
if (schedule.kind === 'every') {
const everyMs = schedule.everyMs;
if (everyMs < 60_000) return `${Math.round(everyMs / 1000)}`;
if (everyMs < 3_600_000) return `${Math.round(everyMs / 60_000)} 分钟`;
if (everyMs < 86_400_000) return `${Math.round(everyMs / 3_600_000)} 小时`;
return `${Math.round(everyMs / 86_400_000)}`;
}
return schedule.expr;
}
const preset = SCHEDULE_PRESETS.find((item) => item.value === schedule);
if (preset) return preset.label;
const parts = schedule.trim().split(/\s+/);
if (parts.length !== 5) return schedule;
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
if (minute === '*' && hour === '*') return '每分钟';
if (minute.startsWith('*/')) return `${minute.slice(2)} 分钟`;
if (hour === '*' && minute === '0') return '每小时整点';
if (dayOfWeek !== '*' && dayOfMonth === '*') return `每周 ${formatWeekday(dayOfWeek)} ${hour}:${minute.padStart(2, '0')}`;
if (dayOfMonth !== '*') return `每月 ${dayOfMonth}${hour}:${minute.padStart(2, '0')}`;
if (hour !== '*') return `每天 ${hour}:${minute.padStart(2, '0')}`;
return schedule;
}
function estimateNextRunDate(scheduleExpr: string): Date | null {
const now = new Date();
const next = new Date(now);
if (scheduleExpr === '* * * * *') {
next.setSeconds(0, 0);
next.setMinutes(next.getMinutes() + 1);
return next;
}
if (scheduleExpr === '*/5 * * * *') {
const remainder = next.getMinutes() % 5;
next.setSeconds(0, 0);
next.setMinutes(next.getMinutes() + (remainder === 0 ? 5 : 5 - remainder));
return next;
}
if (scheduleExpr === '*/15 * * * *') {
const remainder = next.getMinutes() % 15;
next.setSeconds(0, 0);
next.setMinutes(next.getMinutes() + (remainder === 0 ? 15 : 15 - remainder));
return next;
}
if (scheduleExpr === '0 * * * *') {
next.setMinutes(0, 0, 0);
next.setHours(next.getHours() + 1);
return next;
}
if (scheduleExpr === '0 9 * * *' || scheduleExpr === '0 18 * * *') {
const targetHour = scheduleExpr === '0 9 * * *' ? 9 : 18;
next.setHours(targetHour, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
return next;
}
if (scheduleExpr === '0 9 * * 1') {
next.setHours(9, 0, 0, 0);
const weekday = next.getDay();
const daysUntilMonday = weekday === 1 ? 7 : (8 - weekday) % 7;
next.setDate(next.getDate() + daysUntilMonday);
return next;
}
if (scheduleExpr === '0 9 1 * *') {
next.setDate(1);
next.setHours(9, 0, 0, 0);
if (next <= now) next.setMonth(next.getMonth() + 1);
return next;
}
return null;
}
function estimateNextRun(scheduleExpr: string): string | null {
const next = estimateNextRunDate(scheduleExpr);
return next ? formatDateTime(next.toISOString()) : null;
}
function formatWeekday(weekday: string): string {
const weekdayMap: Record<string, string> = {
'0': '周日',
'1': '周一',
'2': '周二',
'3': '周三',
'4': '周四',
'5': '周五',
'6': '周六',
'7': '周日',
};
return weekdayMap[weekday] ?? weekday;
}
function formatDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function formatRelativeTime(value: string): string {
const target = new Date(value).getTime();
if (Number.isNaN(target)) return value;
const diff = Date.now() - target;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 10) return '刚刚';
if (seconds < 60) return `${seconds} 秒前`;
if (minutes < 60) return `${minutes} 分钟前`;
if (hours < 24) return `${hours} 小时前`;
if (days < 30) return `${days} 天前`;
return formatDateTime(value);
}
function normalizeCronScheduleValue(value: unknown): CronJob['schedule'] {
if (typeof value === 'string' && value.trim()) return value.trim();
if (!isRecord(value)) return '0 9 * * *';
const kind = getString(value.kind);
if (kind === 'cron') {
const expr = getString(value.expr);
return { kind: 'cron', expr: expr || '0 9 * * *', tz: getString(value.tz) || undefined };
}
if (kind === 'at') {
const at = getString(value.at);
return { kind: 'at', at: at || new Date().toISOString() };
}
if (kind === 'every') {
const everyMs = typeof value.everyMs === 'number' && Number.isFinite(value.everyMs) ? value.everyMs : 60_000;
const anchorMs = typeof value.anchorMs === 'number' && Number.isFinite(value.anchorMs) ? value.anchorMs : undefined;
return { kind: 'every', everyMs, anchorMs };
}
return '0 9 * * *';
}
function normalizeCronDelivery(value: unknown): CronJobDelivery {
if (!isRecord(value)) return { mode: 'none' };
const mode = getString(value.mode) === 'announce' ? 'announce' : 'none';
const channel = getString(value.channel) || undefined;
const to = getString(value.to) || undefined;
const accountId = getString(value.accountId) || undefined;
if (mode === 'none') {
return { mode: 'none' };
}
return {
mode,
channel,
to,
accountId,
};
}
function normalizeCronJob(value: unknown): CronJob | null {
if (!isRecord(value)) return null;
const id = getString(value.id);
if (!id) return null;
const name = getString(value.name) || '未命名任务';
const message = getString(value.message);
const schedule = normalizeCronScheduleValue(value.schedule);
const enabled = value.enabled !== false;
const createdAt = getString(value.createdAt) || new Date().toISOString();
const updatedAt = getString(value.updatedAt) || createdAt;
const agentId = getString(value.agentId) ? normalizeAgentId(value.agentId) : undefined;
const nextRun = getString(value.nextRun) || undefined;
const lastRun = isRecord(value.lastRun)
? {
time: getString(value.lastRun.time) || new Date().toISOString(),
success: value.lastRun.success !== false,
error: getString(value.lastRun.error) || undefined,
duration: typeof value.lastRun.duration === 'number' ? value.lastRun.duration : undefined,
}
: undefined;
return {
id,
name,
message,
schedule,
agentId,
delivery: normalizeCronDelivery(value.delivery),
enabled,
createdAt,
updatedAt,
lastRun,
nextRun,
};
}
function normalizeCronJobs(payload: unknown): CronJob[] {
if (Array.isArray(payload)) {
return payload.map(normalizeCronJob).filter((job): job is CronJob => Boolean(job));
}
if (!isRecord(payload)) return [];
const collection = Array.isArray(payload.jobs)
? payload.jobs
: Array.isArray(payload.items)
? payload.items
: Array.isArray(payload.data)
? payload.data
: [];
return collection.map(normalizeCronJob).filter((job): job is CronJob => Boolean(job));
}
function normalizeCronJobResult(payload: unknown): CronJob | null {
if (isRecord(payload) && 'job' in payload) {
return normalizeCronJob(payload.job);
}
if (isRecord(payload) && 'data' in payload && !Array.isArray(payload.data)) {
return normalizeCronJob(payload.data);
}
return normalizeCronJob(payload);
}
function normalizeDeliveryChannelAccount(
value: unknown,
fallbackDefaultAccountId?: string,
): CronDeliveryChannelAccount | null {
if (!isRecord(value)) return null;
const account = value as DeliveryChannelAccountLike;
const accountId = getString(account.accountId) || getString(account.id) || 'default';
const name =
getString(account.name)
|| getString(account.label)
|| getString(account.channelName)
|| accountId;
return {
accountId,
name,
isDefault: Boolean(account.isDefault || account.default || accountId === fallbackDefaultAccountId || accountId === 'default'),
};
}
function normalizeGroupedChannelEntry(value: unknown): CronDeliveryChannelGroup | null {
if (!isRecord(value)) return null;
const group = value as DeliveryChannelGroupLike;
const channelType = normalizeChannelType(getString(group.channelType));
if (!channelType) return null;
const fallbackDefaultAccountId = getString(group.defaultAccountId) || 'default';
const accounts = Array.isArray(group.accounts)
? group.accounts
.map((account) => normalizeDeliveryChannelAccount(account, fallbackDefaultAccountId))
.filter((account): account is CronDeliveryChannelAccount => Boolean(account))
: [];
const uniqueAccounts = dedupeDeliveryAccounts(accounts);
const defaultAccountId =
uniqueAccounts.find((account) => account.isDefault)?.accountId
|| fallbackDefaultAccountId
|| uniqueAccounts[0]?.accountId
|| 'default';
return {
channelType,
defaultAccountId,
accounts: uniqueAccounts.map((account) =>
account.accountId === defaultAccountId ? { ...account, isDefault: true } : account,
),
};
}
function dedupeDeliveryAccounts(accounts: CronDeliveryChannelAccount[]): CronDeliveryChannelAccount[] {
const byId = new Map<string, CronDeliveryChannelAccount>();
for (const account of accounts) {
const existing = byId.get(account.accountId);
if (!existing) {
byId.set(account.accountId, account);
continue;
}
byId.set(account.accountId, {
accountId: account.accountId,
name: existing.name || account.name,
isDefault: existing.isDefault || account.isDefault,
});
}
return Array.from(byId.values()).sort((left, right) => {
if (left.isDefault !== right.isDefault) return left.isDefault ? -1 : 1;
return left.name.localeCompare(right.name, 'zh-CN');
});
}
function normalizeFlatChannelAccounts(payload: unknown[]): CronDeliveryChannelGroup[] {
const groupMap = new Map<string, CronDeliveryChannelAccount[]>();
for (const entry of payload) {
if (!isRecord(entry)) continue;
const account = entry as DeliveryChannelAccountLike;
const channelType = normalizeChannelType(getString(account.channelType));
if (!channelType) continue;
const normalizedAccount = normalizeDeliveryChannelAccount(entry);
if (!normalizedAccount) continue;
const currentAccounts = groupMap.get(channelType) ?? [];
currentAccounts.push(normalizedAccount);
groupMap.set(channelType, currentAccounts);
}
return Array.from(groupMap.entries())
.map(([channelType, accounts]) => {
const uniqueAccounts = dedupeDeliveryAccounts(accounts);
const defaultAccountId =
uniqueAccounts.find((account) => account.isDefault)?.accountId
|| uniqueAccounts[0]?.accountId
|| 'default';
return {
channelType,
defaultAccountId,
accounts: uniqueAccounts.map((account) =>
account.accountId === defaultAccountId ? { ...account, isDefault: true } : account,
),
};
})
.sort((left, right) => getChannelDisplayName(left.channelType).localeCompare(getChannelDisplayName(right.channelType), 'zh-CN'));
}
function normalizeDeliveryChannelGroups(payload: unknown): CronDeliveryChannelGroup[] {
if (Array.isArray(payload)) {
const groupedEntries = payload
.map(normalizeGroupedChannelEntry)
.filter((entry): entry is CronDeliveryChannelGroup => Boolean(entry));
if (groupedEntries.length > 0) {
return groupedEntries.sort((left, right) =>
getChannelDisplayName(left.channelType).localeCompare(getChannelDisplayName(right.channelType), 'zh-CN'),
);
}
return normalizeFlatChannelAccounts(payload);
}
if (!isRecord(payload)) return [];
if (Array.isArray(payload.channels)) {
return normalizeDeliveryChannelGroups(payload.channels);
}
if (Array.isArray(payload.groups)) {
return normalizeDeliveryChannelGroups(payload.groups);
}
if (Array.isArray(payload.accounts)) {
return normalizeDeliveryChannelGroups(payload.accounts);
}
if (Array.isArray(payload.data)) {
return normalizeDeliveryChannelGroups(payload.data);
}
return [];
}
function normalizeDeliveryTargetOption(value: unknown): ChannelTargetCatalogItem | null {
if (!isRecord(value)) return null;
const option = value as DeliveryTargetOptionLike;
const targetValue = getString(option.value);
if (!targetValue) return null;
const kind = option.kind === 'name'
|| option.kind === 'identifier'
|| option.kind === 'webhook'
|| option.kind === 'url'
? option.kind
: undefined;
const source = option.source === 'channel-name'
|| option.source === 'account-id'
|| option.source === 'remote'
|| option.source === 'query-param'
|| option.source === 'hash-param'
|| option.source === 'channel-url'
|| option.source === 'fallback'
? option.source
: undefined;
return {
value: targetValue,
label: getString(option.label) || getString(option.title) || getString(option.name) || targetValue,
description: getString(option.description) || getString(option.desc) || undefined,
kind,
source,
channelType: getString(option.channelType) || undefined,
accountId: getString(option.accountId) || undefined,
};
}
function dedupeDeliveryTargetOptions(options: ChannelTargetCatalogItem[]): ChannelTargetCatalogItem[] {
const byValue = new Map<string, ChannelTargetCatalogItem>();
for (const option of options) {
const existing = byValue.get(option.value);
if (!existing) {
byValue.set(option.value, option);
continue;
}
byValue.set(option.value, {
...existing,
label: existing.label || option.label,
description: existing.description || option.description,
kind: existing.kind || option.kind,
source: existing.source || option.source,
channelType: existing.channelType || option.channelType,
accountId: existing.accountId || option.accountId,
});
}
const kindOrder: Record<NonNullable<ChannelTargetCatalogItem['kind']>, number> = {
name: 0,
identifier: 1,
webhook: 2,
url: 3,
};
return Array.from(byValue.values()).sort((left, right) => {
const leftOrder = left.kind ? kindOrder[left.kind] : 99;
const rightOrder = right.kind ? kindOrder[right.kind] : 99;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return left.label.localeCompare(right.label, 'zh-CN');
});
}
function normalizeDeliveryTargetOptions(payload: unknown): ChannelTargetCatalogItem[] {
if (Array.isArray(payload)) {
return dedupeDeliveryTargetOptions(
payload
.map(normalizeDeliveryTargetOption)
.filter((option): option is ChannelTargetCatalogItem => Boolean(option)),
);
}
if (!isRecord(payload)) return [];
if (Array.isArray(payload.targets)) {
return normalizeDeliveryTargetOptions(payload.targets);
}
if (Array.isArray(payload.items)) {
return normalizeDeliveryTargetOptions(payload.items);
}
if (Array.isArray(payload.data)) {
return normalizeDeliveryTargetOptions(payload.data);
}
return [];
}
function mergeDeliveryTargetOptions(
options: ChannelTargetCatalogItem[],
currentValue: string,
): ChannelTargetCatalogItem[] {
const trimmedValue = currentValue.trim();
if (!trimmedValue) {
return options;
}
if (options.some((option) => option.value === trimmedValue)) {
return options;
}
return dedupeDeliveryTargetOptions([
{
value: trimmedValue,
label: trimmedValue,
description: '当前任务里已经保存的自定义目标',
source: 'fallback',
},
...options,
]);
}
function ensureChannelGroupSelection(
groups: CronDeliveryChannelGroup[],
channelType: string,
accountId?: string,
): CronDeliveryChannelGroup[] {
const normalizedChannelType = normalizeChannelType(channelType);
if (!normalizedChannelType) return groups;
if (groups.some((group) => group.channelType === normalizedChannelType)) return groups;
return [
...groups,
{
channelType: normalizedChannelType,
defaultAccountId: accountId || 'default',
accounts: accountId
? [{ accountId, name: accountId, isDefault: true }]
: [],
},
];
}
function getChannelDisplayName(channelType: string): string {
const normalized = normalizeChannelType(channelType);
if (!normalized) return '未命名渠道';
if (CHANNEL_DISPLAY_NAMES[normalized]) return CHANNEL_DISPLAY_NAMES[normalized];
return normalized
.split(/[-_]/)
.filter(Boolean)
.map((token) => token.slice(0, 1).toUpperCase() + token.slice(1))
.join(' ');
}
function getDeliveryAccountDisplayName(account: CronDeliveryChannelAccount | undefined): string {
if (!account) return '主账号';
if (account.accountId === 'default' && (account.name === 'default' || !account.name.trim())) return '主账号';
return account.name;
}
function getAgentLabel(
agentId: string | null | undefined,
agentsById: Map<string, AgentSummary>,
defaultAgentId: string,
): string {
const resolvedId = normalizeAgentId(agentId || defaultAgentId || DEFAULT_AGENT_ID);
const resolvedAgent = agentsById.get(resolvedId);
if (resolvedAgent?.name) return resolvedAgent.name;
if (resolvedId === normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID)) return '主 Agent';
return resolvedId;
}
function formatPathTail(value: string): string {
const normalized = value.replace(/\\/g, '/').trim();
if (!normalized) return '';
const parts = normalized.split('/').filter(Boolean);
if (parts.length <= 2) return normalized;
return `.../${parts.slice(-2).join('/')}`;
}
function getAgentDetail(
agentId: string | null | undefined,
agentsById: Map<string, AgentSummary>,
defaultAgentId: string,
): string {
const resolvedId = normalizeAgentId(agentId || defaultAgentId || DEFAULT_AGENT_ID);
const resolvedAgent = agentsById.get(resolvedId);
if (resolvedAgent?.workspace) {
return `工作区 ${formatPathTail(resolvedAgent.workspace)}`;
}
if (resolvedAgent?.agentDir) {
return `目录 ${formatPathTail(resolvedAgent.agentDir)}`;
}
if (resolvedId === normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID) || resolvedAgent?.isDefault) {
return '共享主工作区';
}
return '工作区待同步';
}
function describeDelivery(
delivery: CronJobDelivery | undefined,
channelGroups: CronDeliveryChannelGroup[],
): string {
if (!delivery || delivery.mode !== 'announce') {
return '仅执行任务,不额外发送';
}
const channelType = normalizeChannelType(delivery.channel);
if (!channelType) {
return '公告发送待配置';
}
const channelGroup = channelGroups.find((group) => group.channelType === channelType);
const accountId = delivery.accountId || channelGroup?.defaultAccountId;
const account = channelGroup?.accounts.find((item) => item.accountId === accountId) || channelGroup?.accounts[0];
const target = getString(delivery.to);
return [
getChannelDisplayName(channelType),
account ? getDeliveryAccountDisplayName(account) : null,
target || '目标待填写',
]
.filter(Boolean)
.join(' / ');
}
function buildLocalCronJob(input: CronJobCreateInput, current?: CronJob | null): CronJob {
const schedule = input.schedule.trim();
const nextRun = estimateNextRunDate(schedule)?.toISOString() ?? current?.nextRun;
return {
id: current?.id ?? `cron-${Date.now()}`,
name: input.name.trim(),
message: input.message.trim(),
schedule,
agentId: input.agentId ? normalizeAgentId(input.agentId) : current?.agentId,
enabled: input.enabled ?? current?.enabled ?? true,
delivery: input.delivery ?? current?.delivery ?? { mode: 'none' },
createdAt: current?.createdAt ?? new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastRun: current?.lastRun,
nextRun,
};
}
function toneClasses(tone: FeedbackTone): string {
if (tone === 'success') {
return 'border-green-200 bg-green-50 text-green-700 dark:border-green-900/70 dark:bg-green-900/20 dark:text-green-300';
}
if (tone === 'error') {
return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900/70 dark:bg-red-900/20 dark:text-red-300';
}
if (tone === 'warning') {
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-900/20 dark:text-amber-300';
}
return 'border-[#dfeaf6] bg-[#f8fbff] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#232327] dark:text-gray-300';
}
function Notice({
tone,
message,
}: {
tone: FeedbackTone;
message: string;
}) {
return (
<div className={cn('mb-4 flex items-start gap-2 rounded-xl border px-4 py-3 text-sm font-medium', toneClasses(tone))}>
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
<span>{message}</span>
</div>
);
}
function SelectField({ className, children, ...props }: SelectHTMLAttributes<HTMLSelectElement> & { children: ReactNode }) {
return (
<div className="relative">
<select
className={cn(
'h-11 w-full appearance-none rounded-xl border border-transparent bg-[#EDECE4] px-4 pr-10 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] disabled:cursor-not-allowed disabled:opacity-60 dark:bg-[#222225] dark:text-[#f3f4f6]',
className,
)}
{...props}
>
{children}
</select>
<ChevronDownIcon className="pointer-events-none absolute right-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#99A0AE] dark:text-gray-500" />
</div>
);
}
function StatCard({ label, value, tone, icon: Icon }: StatCardProps) {
const iconWrapperClass =
tone === 'green'
? 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400'
: tone === 'amber'
? 'bg-amber-100 text-amber-600 dark:bg-amber-900/20 dark:text-amber-400'
: tone === 'red'
? 'bg-red-100 text-red-500 dark:bg-red-900/20 dark:text-red-400'
: 'bg-[#E8E6DE] text-[#7A7668] dark:bg-[#2a2a2d] dark:text-gray-400';
return (
<div className="min-h-[130px] rounded-[24px] border border-transparent bg-[#F4F3EB]/60 p-5 transition-colors hover:bg-[#F4F3EB] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:hover:bg-[#222225]">
<div className="flex items-center justify-between">
<div className={cn('flex h-11 w-11 items-center justify-center rounded-full', iconWrapperClass)}>
<Icon className="h-5 w-5" />
</div>
</div>
<div className="mt-4 flex items-baseline gap-3">
<div className="font-serif text-[40px] leading-none text-[#171717] dark:text-[#f3f4f6]">{value}</div>
<div className="text-[14px] font-medium text-[#525866] dark:text-gray-400">{label}</div>
</div>
</div>
);
}
function CronJobCard({
job,
agentLabel,
agentDetail,
deliverySummary,
busyAction,
onToggle,
onEdit,
onDelete,
onTrigger,
}: JobCardProps) {
const disabled = busyAction !== null;
const isAnnouncement = job.delivery?.mode === 'announce';
return (
<article
className="group flex cursor-pointer flex-col rounded-2xl border border-transparent bg-transparent p-5 transition-all hover:bg-black/[0.03] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:hover:bg-[#2a2a2d]"
onClick={onEdit}
>
<div className="mb-4 flex items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-4">
<div className="flex h-[46px] w-[46px] shrink-0 items-center justify-center rounded-full border border-black/5 bg-black/5 text-[#171717] shadow-sm transition-transform group-hover:scale-105 dark:border-white/5 dark:bg-white/5 dark:text-[#f3f4f6]">
<ClockIcon className={cn('h-5 w-5', job.enabled ? 'text-current' : 'text-[#99A0AE] dark:text-gray-500')} />
</div>
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2">
<h3 className="truncate text-[16px] font-semibold text-[#171717] dark:text-[#f3f4f6]">{job.name}</h3>
<span
className={cn(
'h-2 w-2 shrink-0 rounded-full',
job.enabled ? 'bg-green-500' : 'bg-[#99A0AE] dark:bg-gray-500',
)}
aria-label={job.enabled ? '启用中' : '已暂停'}
/>
<span
className={cn(
'rounded-full px-2.5 py-1 text-[11px] font-medium',
isAnnouncement
? 'bg-[#E8F1FF] text-[#2B7FFF] dark:bg-[#232b36] dark:text-[#8ab4ff]'
: 'bg-[#E8E6DE] text-[#7A7668] dark:bg-[#2a2a2d] dark:text-gray-400',
)}
>
{isAnnouncement ? '执行并发送' : '仅执行'}
</span>
</div>
<div className="flex items-center gap-1.5 text-[13px] text-[#525866] dark:text-gray-400">
<ClockIcon className="h-3.5 w-3.5" />
<span>{parseCronSchedule(job.schedule)}</span>
</div>
</div>
</div>
<button
type="button"
className={cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
job.enabled ? 'bg-[#2B7FFF]' : 'bg-[#d1d5db] dark:bg-[#3b3b40]',
disabled && 'cursor-not-allowed opacity-60',
)}
aria-label={job.enabled ? '暂停任务' : '启用任务'}
onClick={(event) => {
event.stopPropagation();
if (!disabled) {
onToggle(!job.enabled);
}
}}
>
<span
className={cn(
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition-transform',
job.enabled && 'translate-x-5',
)}
/>
</button>
</div>
<div className="mt-2 flex flex-1 flex-col justify-end pl-[62px]">
<div className="mb-3 flex items-start gap-2">
<MessageIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#99A0AE] dark:text-gray-500" />
<p className="line-clamp-2 text-[13.5px] leading-[1.5] text-[#525866] dark:text-gray-400">{job.message}</p>
</div>
<div className="mb-3 grid gap-2 rounded-2xl bg-[#F4F3EB]/70 px-3.5 py-3 text-[12.5px] dark:bg-[#222225]">
<div className="flex items-start gap-2 text-[#525866] dark:text-gray-400">
<BotIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#99A0AE] dark:text-gray-500" />
<span className="min-w-0">
<span className="mr-1 text-[#171717]/75 dark:text-[#f3f4f6]/75">Agent</span>
<span className="font-medium text-[#171717] dark:text-[#f3f4f6]">{agentLabel}</span>
<span className="ml-2 text-[#99A0AE] dark:text-gray-500">{agentDetail}</span>
</span>
</div>
<div className="flex items-start gap-2 text-[#525866] dark:text-gray-400">
<SendIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#99A0AE] dark:text-gray-500" />
<span className="min-w-0">
<span className="mr-1 text-[#171717]/75 dark:text-[#f3f4f6]/75"></span>
<span className="font-medium text-[#171717] dark:text-[#f3f4f6]">{deliverySummary}</span>
</span>
</div>
</div>
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-[12px] font-medium text-[#99A0AE] dark:text-gray-500">
{job.lastRun ? (
<span className="flex items-center gap-1.5">
<HistoryIcon className="h-3.5 w-3.5" />
<span>{formatRelativeTime(job.lastRun.time)}</span>
<span className={job.lastRun.success ? 'text-green-500' : 'text-red-500'}>
{job.lastRun.success ? '成功' : '失败'}
</span>
</span>
) : null}
{job.nextRun && job.enabled ? (
<span className="flex items-center gap-1.5">
<CalendarIcon className="h-3.5 w-3.5" />
<span>{formatDateTime(job.nextRun)}</span>
</span>
) : null}
</div>
{job.lastRun && !job.lastRun.success && job.lastRun.error ? (
<div className="mb-3 flex items-start gap-2 rounded-xl border border-red-500/20 bg-red-500/10 p-2.5 text-[13px] text-red-600 dark:text-red-400">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
<span className="line-clamp-2">{job.lastRun.error}</span>
</div>
) : null}
<div className="mt-auto flex justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="flex h-8 items-center rounded-lg px-3 text-[13px] font-medium text-[#2B7FFF] transition-colors hover:bg-[#EFF6FF] dark:hover:bg-[#232327]"
onClick={(event) => {
event.stopPropagation();
if (!disabled) {
onTrigger();
}
}}
>
{busyAction === 'trigger' ? <SpinnerIcon className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <PlayIcon className="mr-1.5 h-3.5 w-3.5" />}
</button>
<button
type="button"
className="flex h-8 items-center rounded-lg px-3 text-[13px] font-medium text-red-500 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
onClick={(event) => {
event.stopPropagation();
if (!disabled) {
onDelete();
}
}}
>
<TrashIcon className="mr-1.5 h-3.5 w-3.5" />
</button>
</div>
</div>
</article>
);
}
function getSuggestedAgentId(agents: AgentSummary[], defaultAgentId: string, currentJob: CronJob | null): string {
const jobAgentId = getString(currentJob?.agentId);
if (jobAgentId) return normalizeAgentId(jobAgentId);
const currentChatAgentId = getString(chatStore.getState().currentAgentId);
const normalizedCurrentChatAgentId = normalizeAgentId(currentChatAgentId || defaultAgentId || DEFAULT_AGENT_ID);
if (agents.some((agent) => agent.id === normalizedCurrentChatAgentId)) {
return normalizedCurrentChatAgentId;
}
if (agents.some((agent) => agent.id === normalizeAgentId(defaultAgentId))) {
return normalizeAgentId(defaultAgentId);
}
return agents[0]?.id || normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID);
}
function CronTaskDialog({
open,
job,
saving,
agents,
defaultAgentId,
channelGroups,
channelsLoading,
channelsError,
onClose,
onSave,
}: DialogProps) {
const [name, setName] = useState('');
const [selectedAgentId, setSelectedAgentId] = useState(normalizeAgentId(defaultAgentId || DEFAULT_AGENT_ID));
const [message, setMessage] = useState('');
const [schedule, setSchedule] = useState('0 9 * * *');
const [enabled, setEnabled] = useState(true);
const [useCustom, setUseCustom] = useState(false);
const [customSchedule, setCustomSchedule] = useState('');
const [deliveryMode, setDeliveryMode] = useState<'none' | 'announce'>('none');
const [deliveryChannel, setDeliveryChannel] = useState('');
const [selectedDeliveryAccountId, setSelectedDeliveryAccountId] = useState('');
const [deliveryTarget, setDeliveryTarget] = useState('');
const [loadedDeliveryTargetOptions, setLoadedDeliveryTargetOptions] = useState<ChannelTargetCatalogItem[]>([]);
const [targetsLoading, setTargetsLoading] = useState(false);
const [targetsError, setTargetsError] = useState<string | null>(null);
const [deliveryTargetScopeKey, setDeliveryTargetScopeKey] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const initialSchedule = job ? getScheduleExpression(job.schedule) || '0 9 * * *' : '0 9 * * *';
const matchedPreset = SCHEDULE_PRESETS.some((item) => item.value === initialSchedule);
setName(job?.name ?? '');
setMessage(job?.message ?? '');
setSchedule(initialSchedule);
setEnabled(job?.enabled ?? true);
setUseCustom(!matchedPreset);
setCustomSchedule(matchedPreset ? '' : initialSchedule);
setDeliveryMode(job?.delivery?.mode === 'announce' ? 'announce' : 'none');
setDeliveryChannel(getString(job?.delivery?.channel));
setSelectedDeliveryAccountId(getString(job?.delivery?.accountId));
setDeliveryTarget(getString(job?.delivery?.to));
setLoadedDeliveryTargetOptions([]);
setTargetsLoading(false);
setTargetsError(null);
setDeliveryTargetScopeKey('');
setSelectedAgentId(getSuggestedAgentId(agents, defaultAgentId, job));
setValidationError(null);
}, [agents, defaultAgentId, job, open]);
const availableChannelGroups = useMemo(
() => ensureChannelGroupSelection(channelGroups, deliveryChannel, selectedDeliveryAccountId),
[channelGroups, deliveryChannel, selectedDeliveryAccountId],
);
const selectedChannelGroup = useMemo(
() => availableChannelGroups.find((group) => group.channelType === normalizeChannelType(deliveryChannel)),
[availableChannelGroups, deliveryChannel],
);
const deliveryTargetOptions = useMemo(
() => mergeDeliveryTargetOptions(loadedDeliveryTargetOptions, deliveryTarget),
[deliveryTarget, loadedDeliveryTargetOptions],
);
const targetListId = useMemo(
() => `cron-delivery-targets-${job?.id ?? 'draft'}`,
[job?.id],
);
useEffect(() => {
if (!open || deliveryMode !== 'announce') return;
if (!deliveryChannel && availableChannelGroups[0]) {
setDeliveryChannel(availableChannelGroups[0].channelType);
}
}, [availableChannelGroups, deliveryChannel, deliveryMode, open]);
useEffect(() => {
if (!open || deliveryMode !== 'announce') return;
const resolvedDefaultAccountId =
selectedChannelGroup?.defaultAccountId
|| selectedChannelGroup?.accounts[0]?.accountId
|| '';
if (!resolvedDefaultAccountId) {
setSelectedDeliveryAccountId('');
return;
}
const hasSelectedAccount = selectedChannelGroup?.accounts.some((account) => account.accountId === selectedDeliveryAccountId);
if (!selectedDeliveryAccountId || !hasSelectedAccount) {
setSelectedDeliveryAccountId(resolvedDefaultAccountId);
}
}, [deliveryMode, open, selectedChannelGroup, selectedDeliveryAccountId]);
useEffect(() => {
if (!open) return;
if (agents.length === 0) return;
if (agents.some((agent) => agent.id === selectedAgentId)) return;
setSelectedAgentId(getSuggestedAgentId(agents, defaultAgentId, job));
}, [agents, defaultAgentId, job, open, selectedAgentId]);
useEffect(() => {
if (!open || deliveryMode !== 'announce') {
setDeliveryTargetScopeKey('');
return;
}
const scopeKey = `${normalizeChannelType(deliveryChannel)}:${getString(selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId)}`;
if (!scopeKey || scopeKey === ':') {
setDeliveryTargetScopeKey('');
return;
}
setDeliveryTargetScopeKey((currentScopeKey) => {
if (currentScopeKey && currentScopeKey !== scopeKey) {
setDeliveryTarget('');
}
return scopeKey;
});
}, [
deliveryChannel,
deliveryMode,
open,
selectedChannelGroup?.defaultAccountId,
selectedDeliveryAccountId,
]);
useEffect(() => {
if (!open || deliveryMode !== 'announce') {
setLoadedDeliveryTargetOptions([]);
setTargetsLoading(false);
setTargetsError(null);
return;
}
const normalizedChannelType = normalizeChannelType(deliveryChannel);
const normalizedAccountId = getString(selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId);
if (!normalizedChannelType) {
setLoadedDeliveryTargetOptions([]);
setTargetsLoading(false);
setTargetsError(null);
return;
}
let cancelled = false;
const query = new URLSearchParams({ channelType: normalizedChannelType });
if (normalizedAccountId) {
query.set('accountId', normalizedAccountId);
}
setTargetsLoading(true);
setTargetsError(null);
void hostApiFetch<ChannelTargetsResponse>(`/api/channels/targets?${query.toString()}`)
.then((response) => {
if (cancelled) return;
setLoadedDeliveryTargetOptions(normalizeDeliveryTargetOptions(response));
})
.catch((requestError) => {
if (cancelled) return;
setLoadedDeliveryTargetOptions([]);
setTargetsError(requestError instanceof Error ? requestError.message : String(requestError));
})
.finally(() => {
if (!cancelled) {
setTargetsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [
deliveryChannel,
deliveryMode,
open,
selectedChannelGroup?.defaultAccountId,
selectedDeliveryAccountId,
]);
if (!open) return null;
const finalSchedule = useCustom ? customSchedule.trim() : schedule;
const nextRunPreview = finalSchedule ? estimateNextRun(finalSchedule) : null;
const hasSelectableAgents = agents.length > 0;
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId);
const selectedAccount = selectedChannelGroup?.accounts.find((account) => account.accountId === selectedDeliveryAccountId);
const deliveryPreview =
deliveryMode === 'announce'
? describeDelivery(
{
mode: 'announce',
channel: deliveryChannel,
accountId: selectedDeliveryAccountId,
to: deliveryTarget,
},
availableChannelGroups,
)
: '仅执行任务,不额外发送';
async function handleSubmit(): Promise<void> {
if (!name.trim()) {
setValidationError('请填写任务名称。');
return;
}
if (!selectedAgentId.trim()) {
setValidationError('请先选择要执行任务的 Agent。');
return;
}
if (!message.trim()) {
setValidationError('请填写提醒内容。');
return;
}
if (!finalSchedule) {
setValidationError('请填写执行计划。');
return;
}
if (deliveryMode === 'announce') {
if (!deliveryChannel) {
setValidationError('请选择一个发送渠道。');
return;
}
if (!deliveryTarget.trim()) {
setValidationError('请填写发送目标,例如群组名、用户标识或 Webhook。');
return;
}
}
setValidationError(null);
await onSave({
name: name.trim(),
agentId: normalizeAgentId(selectedAgentId),
message: message.trim(),
schedule: finalSchedule,
enabled,
delivery:
deliveryMode === 'announce'
? {
mode: 'announce',
channel: normalizeChannelType(deliveryChannel),
accountId: selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId || undefined,
to: deliveryTarget.trim(),
}
: { mode: 'none' },
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4 backdrop-blur-[1px]">
<div className="max-h-[calc(100vh-48px)] w-full max-w-[720px] overflow-hidden rounded-[20px] bg-[#F4F3EB] shadow-[0_25px_50px_-12px_rgba(0,0,0,0.2)] dark:bg-[#1f1f22]">
<div className="flex items-start justify-between border-b border-black/5 bg-[#F4F3EB] px-8 py-6 dark:border-[#2a2a2d] dark:bg-[#1f1f22]">
<div>
<h2
className="mb-2 text-[24px] font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6]"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
>
{job ? '编辑定时任务' : '新建定时任务'}
</h2>
<p className="text-[14px] text-[#99A0AE] dark:text-gray-500">
Agent ClawX
</p>
</div>
<button
type="button"
className="mt-1 rounded-full p-1 text-[#99A0AE] transition-colors hover:text-[#171717] dark:hover:text-[#f3f4f6]"
onClick={onClose}
>
<CloseIcon className="h-5 w-5" />
</button>
</div>
<div className="max-h-[calc(100vh-210px)] overflow-y-auto px-8 py-8">
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1.1fr_0.9fr]">
<div>
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80"></label>
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="例如:晨间播报"
className="h-11 w-full rounded-xl border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
/>
</div>
<div>
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80"> Agent</label>
<SelectField
value={selectedAgentId}
onChange={(event) => setSelectedAgentId(event.target.value)}
disabled={!hasSelectableAgents}
>
{hasSelectableAgents ? (
agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
{agent.isDefault ? ' · 默认' : ''}
</option>
))
) : (
<option value=""> Agent</option>
)}
</SelectField>
<p className="mt-2 text-[12px] font-medium text-[#99A0AE] dark:text-gray-500">
{selectedAgent ? getAgentDetail(selectedAgent.id, new Map(agents.map((agent) => [agent.id, agent])), defaultAgentId) : 'Agent 列表加载后可选择执行归属。'}
</p>
</div>
</div>
<div>
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80"></label>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="描述任务要执行或广播的内容"
rows={4}
className="w-full resize-none rounded-xl border border-transparent bg-[#EDECE4] px-4 py-3 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
/>
</div>
<div>
<label className="mb-2.5 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80"></label>
{!useCustom ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{SCHEDULE_PRESETS.map((preset) => {
const active = schedule === preset.value;
return (
<button
key={preset.key}
type="button"
className={cn(
'flex h-10 items-center rounded-xl px-3 text-left text-[13px] font-medium transition-colors',
active
? 'bg-[#3B6DE8] text-white'
: 'bg-[#EDECE4] text-[#4B4B4B] hover:bg-[#E5E4DC] dark:bg-[#222225] dark:text-gray-300 dark:hover:bg-[#2a2a2d]',
)}
onClick={() => setSchedule(preset.value)}
>
<ClockIcon className="mr-2 h-4 w-4 opacity-80" />
{preset.label}
</button>
);
})}
</div>
) : (
<input
value={customSchedule}
onChange={(event) => setCustomSchedule(event.target.value)}
placeholder="输入 Cron 表达式,例如 0 9 * * *"
className="h-11 w-full rounded-xl border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
/>
)}
<div className="mt-2 flex items-center justify-between gap-4">
<p className="text-[12px] font-medium text-[#99A0AE] dark:text-gray-500">
{nextRunPreview ? `下次执行:${nextRunPreview}` : '选择预设或填写自定义 Cron 表达式'}
</p>
<button
type="button"
className="text-[12px] font-medium text-[#2B7FFF]"
onClick={() => {
if (useCustom) {
const fallbackPreset = SCHEDULE_PRESETS.find((item) => item.value === customSchedule.trim()) ?? SCHEDULE_PRESETS[4];
setSchedule(fallbackPreset.value);
} else {
setCustomSchedule(schedule);
}
setUseCustom((current) => !current);
}}
>
{useCustom ? '使用预设' : '使用自定义 Cron'}
</button>
</div>
</div>
<div>
<div className="mb-1 block text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80"></div>
<p className="mb-3 text-[12px] text-[#99A0AE] dark:text-gray-500"></p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{[
{ key: 'none', title: '仅执行任务', desc: '不额外推送到渠道' },
{ key: 'announce', title: '执行并发送', desc: '选择渠道账号与目标收件人' },
].map((item) => {
const active = deliveryMode === item.key;
return (
<button
key={item.key}
type="button"
className={cn(
'rounded-xl px-4 py-3 text-left transition-colors',
active
? 'bg-[#3B6DE8] text-white'
: 'bg-[#EDECE4] text-[#4B4B4B] hover:bg-[#E5E4DC] dark:bg-[#222225] dark:text-gray-300 dark:hover:bg-[#2a2a2d]',
)}
onClick={() => setDeliveryMode(item.key as 'none' | 'announce')}
>
<div className="text-[13px] font-semibold">{item.title}</div>
<div className={cn('mt-1 text-[11px]', active ? 'text-white/85' : 'text-inherit opacity-80')}>{item.desc}</div>
</button>
);
})}
</div>
{deliveryMode === 'announce' ? (
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80"></label>
<SelectField
value={deliveryChannel}
onChange={(event) => setDeliveryChannel(event.target.value)}
disabled={channelsLoading || availableChannelGroups.length === 0}
>
{availableChannelGroups.length > 0 ? (
availableChannelGroups.map((group) => (
<option key={group.channelType} value={group.channelType}>
{getChannelDisplayName(group.channelType)}
</option>
))
) : (
<option value=""></option>
)}
</SelectField>
</div>
<div>
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80"></label>
<SelectField
value={selectedDeliveryAccountId}
onChange={(event) => setSelectedDeliveryAccountId(event.target.value)}
disabled={channelsLoading || (selectedChannelGroup?.accounts.length ?? 0) === 0}
>
{(selectedChannelGroup?.accounts.length ?? 0) > 0 ? (
selectedChannelGroup?.accounts.map((account) => (
<option key={account.accountId} value={account.accountId}>
{getDeliveryAccountDisplayName(account)}
</option>
))
) : (
<option value="">{channelsLoading ? '正在加载渠道账号…' : '当前渠道没有账号列表'}</option>
)}
</SelectField>
</div>
<div className="md:col-span-2">
<label className="mb-2.5 block text-[13px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80"></label>
<input
list={deliveryTargetOptions.length > 0 ? targetListId : undefined}
value={deliveryTarget}
onChange={(event) => setDeliveryTarget(event.target.value)}
placeholder="例如值班群、room-ops、https://example.com/webhook"
className="h-11 w-full rounded-xl border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
/>
{deliveryTargetOptions.length > 0 ? (
<datalist id={targetListId}>
{deliveryTargetOptions.map((option) => (
<option key={`${option.value}:${option.label}`} value={option.value}>
{option.label}
</option>
))}
</datalist>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
{deliveryTargetOptions.slice(0, 8).map((option) => (
<button
key={`${option.value}:${option.label}`}
type="button"
className={cn(
'rounded-full border px-3 py-1.5 text-[12px] transition-colors',
deliveryTarget === option.value
? 'border-[#2B7FFF] bg-[#EFF6FF] text-[#2B7FFF] dark:border-[#3b82f6] dark:bg-[#1d2633] dark:text-[#93c5fd]'
: 'border-[#D9DCE3] bg-white text-[#525866] hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#343438] dark:bg-[#17171a] dark:text-gray-300 dark:hover:border-[#3b82f6] dark:hover:text-white',
)}
onClick={() => setDeliveryTarget(option.value)}
title={option.description || option.label}
>
{option.label}
</button>
))}
</div>
<div className="mt-2 text-[12px] text-[#99A0AE] dark:text-gray-500">
{targetsLoading
? '正在为当前渠道账号加载推荐目标...'
: deliveryTargetOptions.length > 0
? '推荐目标已就绪,也可以继续手工输入群组、用户标识或 Webhook。'
: '暂未发现推荐目标,仍可手工输入群组、用户标识或 Webhook。'}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] font-medium text-[#99A0AE] dark:text-gray-500">
<span>{deliveryPreview}</span>
{selectedAccount ? <span>{getDeliveryAccountDisplayName(selectedAccount)}</span> : null}
</div>
</div>
{channelsError ? <Notice tone="warning" message={`渠道账号加载失败,仍可保留现有配置。${channelsError}`} /> : null}
{targetsError ? <Notice tone="warning" message={`目标候选加载失败,仍可手工输入。${targetsError}`} /> : null}
{!channelsError && !channelsLoading && availableChannelGroups.length === 0 ? (
<Notice tone="warning" message="当前没有可用的渠道账号,暂时无法新建发送型任务。" />
) : null}
</div>
) : null}
</div>
<div className="flex items-center justify-between rounded-2xl border border-black/5 bg-[#E8E6DE]/50 p-4 dark:border-[#2a2a2d] dark:bg-[#222225]">
<div>
<div className="text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80"></div>
<div className="mt-0.5 text-[13px] text-[#99A0AE] dark:text-gray-500"></div>
</div>
<button
type="button"
className={cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
enabled ? 'bg-[#2B7FFF]' : 'bg-[#d1d5db] dark:bg-[#3b3b40]',
)}
onClick={() => setEnabled((current) => !current)}
>
<span
className={cn(
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition-transform',
enabled && 'translate-x-5',
)}
/>
</button>
</div>
{validationError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/70 dark:bg-red-900/20 dark:text-red-300">
{validationError}
</div>
) : null}
</div>
</div>
<div className="flex justify-end gap-3 border-t border-black/5 px-8 py-5 dark:border-[#2a2a2d]">
<button
type="button"
className="h-[42px] rounded-full bg-[#EDECE4] px-6 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#E5E4DC] dark:bg-[#222225] dark:text-gray-300 dark:hover:bg-[#2a2a2d]"
onClick={onClose}
>
</button>
<button
type="button"
className="flex h-[42px] items-center rounded-full bg-[#2B7FFF] px-6 text-[13px] font-semibold text-white transition-colors hover:bg-[#2B7FFF]/90 disabled:cursor-not-allowed disabled:opacity-70"
onClick={() => {
void handleSubmit();
}}
disabled={saving || !hasSelectableAgents}
>
{saving ? <SpinnerIcon className="mr-2 h-4 w-4 animate-spin" /> : <PlusIcon className="mr-2 h-4 w-4" />}
{saving ? '保存中...' : job ? '保存修改' : '创建任务'}
</button>
</div>
</div>
</div>
);
}
export default function CronPage() {
const agents = useAgentsStore((state) => state.agents);
const agentsLoading = useAgentsStore((state) => state.loading);
const agentsError = useAgentsStore((state) => state.error);
const agentsWarning = useAgentsStore((state) => state.warning);
const defaultAgentId = useAgentsStore((state) => state.defaultAgentId);
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [feedback, setFeedback] = useState<FeedbackState>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingJob, setEditingJob] = useState<CronJob | null>(null);
const [dialogSaving, setDialogSaving] = useState(false);
const [busyJobId, setBusyJobId] = useState<string | null>(null);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [channelGroups, setChannelGroups] = useState<CronDeliveryChannelGroup[]>([]);
const [channelsLoading, setChannelsLoading] = useState(false);
const [channelsError, setChannelsError] = useState<string | null>(null);
const activeJobs = useMemo(() => jobs.filter((job) => job.enabled), [jobs]);
const pausedJobs = useMemo(() => jobs.filter((job) => !job.enabled), [jobs]);
const failedJobs = useMemo(() => jobs.filter((job) => job.lastRun && !job.lastRun.success), [jobs]);
const refreshing = loading || channelsLoading || agentsLoading;
const agentsById = useMemo(
() => new Map(agents.map((agent) => [normalizeAgentId(agent.id), agent])),
[agents],
);
async function loadJobs(): Promise<void> {
setLoading(true);
setError(null);
try {
const response = await hostApiFetch<CronJobsResponse>('/api/cron/jobs');
const normalizedJobs = normalizeCronJobs(response);
setJobs(normalizedJobs);
} catch (requestError) {
setJobs((currentJobs) => (currentJobs.length > 0 ? currentJobs : FALLBACK_CRON_JOBS));
setError(requestError instanceof Error ? requestError.message : String(requestError));
} finally {
setLoading(false);
}
}
async function loadDeliveryChannels(): Promise<void> {
setChannelsLoading(true);
setChannelsError(null);
try {
const response = await hostApiFetch<ChannelAccountsResponse>('/api/channels/accounts');
setChannelGroups(normalizeDeliveryChannelGroups(response));
} catch (requestError) {
setChannelsError(requestError instanceof Error ? requestError.message : String(requestError));
} finally {
setChannelsLoading(false);
}
}
useEffect(() => {
void agentsStore.init();
void loadJobs();
void loadDeliveryChannels();
}, []);
useEffect(() => {
return onGatewayEvent((event) => {
if (!isRuntimeChangedGatewayEvent(event)) return;
if (!runtimeEventHasTopic(event, 'channels', 'providers', 'agents', 'channel-targets')) return;
void Promise.allSettled([loadDeliveryChannels(), agentsStore.load()]);
});
}, []);
async function handleRefresh(): Promise<void> {
setFeedback(null);
await Promise.allSettled([loadJobs(), loadDeliveryChannels(), agentsStore.load()]);
}
async function handleSave(input: CronJobCreateInput): Promise<void> {
setDialogSaving(true);
try {
if (editingJob) {
try {
const response = await hostApiFetch<CronJob | { job?: CronJob }>(`/api/cron/jobs/${encodeURIComponent(editingJob.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const normalizedJob = normalizeCronJobResult(response);
setJobs((currentJobs) =>
currentJobs.map((job) => (job.id === editingJob.id ? normalizedJob ?? buildLocalCronJob(input, editingJob) : job)),
);
} catch {
const fallback = buildLocalCronJob(input, editingJob);
setJobs((currentJobs) => currentJobs.map((job) => (job.id === editingJob.id ? fallback : job)));
}
setFeedback({ tone: 'success', message: '定时任务已更新。' });
} else {
try {
const response = await hostApiFetch<CronJob | { job?: CronJob }>('/api/cron/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const normalizedJob = normalizeCronJobResult(response);
setJobs((currentJobs) => [...currentJobs, normalizedJob ?? buildLocalCronJob(input)]);
} catch {
setJobs((currentJobs) => [...currentJobs, buildLocalCronJob(input)]);
}
setFeedback({ tone: 'success', message: '定时任务已创建。' });
}
setDialogOpen(false);
setEditingJob(null);
} catch (saveError) {
setFeedback({
tone: 'error',
message: saveError instanceof Error ? saveError.message : String(saveError),
});
} finally {
setDialogSaving(false);
}
}
async function handleToggle(job: CronJob, enabled: boolean): Promise<void> {
setBusyJobId(job.id);
setBusyAction('toggle');
try {
try {
await hostApiFetch('/api/cron/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: job.id, enabled }),
});
} finally {
setJobs((currentJobs) =>
currentJobs.map((currentJob) =>
currentJob.id === job.id
? { ...currentJob, enabled, updatedAt: new Date().toISOString() }
: currentJob,
),
);
}
setFeedback({ tone: 'success', message: enabled ? '任务已启用。' : '任务已暂停。' });
} catch (toggleError) {
setFeedback({
tone: 'error',
message: toggleError instanceof Error ? toggleError.message : String(toggleError),
});
} finally {
setBusyJobId(null);
setBusyAction(null);
}
}
async function handleTrigger(job: CronJob): Promise<void> {
setBusyJobId(job.id);
setBusyAction('trigger');
try {
try {
await hostApiFetch('/api/cron/trigger', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: job.id }),
});
} finally {
setJobs((currentJobs) =>
currentJobs.map((currentJob) =>
currentJob.id === job.id
? {
...currentJob,
updatedAt: new Date().toISOString(),
lastRun: {
time: new Date().toISOString(),
success: true,
},
}
: currentJob,
),
);
}
setFeedback({ tone: 'success', message: '任务已触发执行。' });
} catch (triggerError) {
setFeedback({
tone: 'error',
message: triggerError instanceof Error ? triggerError.message : String(triggerError),
});
} finally {
setBusyJobId(null);
setBusyAction(null);
}
}
async function handleDelete(job: CronJob): Promise<void> {
const confirmed = window.confirm(`确认删除“${job.name}”吗?删除后将无法恢复。`);
if (!confirmed) return;
setBusyJobId(job.id);
setBusyAction('delete');
try {
try {
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(job.id)}`, {
method: 'DELETE',
});
} finally {
setJobs((currentJobs) => currentJobs.filter((currentJob) => currentJob.id !== job.id));
}
setFeedback({ tone: 'success', message: '任务已删除。' });
} catch (deleteError) {
setFeedback({
tone: 'error',
message: deleteError instanceof Error ? deleteError.message : String(deleteError),
});
} finally {
setBusyJobId(null);
setBusyAction(null);
}
}
return (
<section className="h-full w-full min-h-0">
<div className="flex h-full w-full overflow-hidden rounded-[16px] bg-white dark:bg-[#1b1b1d]">
<div className="flex h-full w-full flex-col p-10 pt-12">
<div className="mb-6 flex shrink-0 flex-col justify-between gap-4 md:flex-row md:items-start">
<div>
<h1
className="mb-3 text-5xl font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6] md:text-6xl"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
>
</h1>
<p className="text-[17px] font-medium text-[#171717]/70 dark:text-[#9ca3af]">
Cron Agent
</p>
</div>
<div className="flex items-center gap-3 md:mt-2">
<button
type="button"
className="flex h-9 shrink-0 items-center justify-center rounded-full border border-black/10 px-4 text-[13px] font-medium text-[#171717]/80 transition-colors hover:bg-black/5 hover:text-[#171717] dark:border-gray-700 dark:text-[#f3f4f6]/80 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]"
onClick={() => {
void handleRefresh();
}}
>
<RefreshIcon className={cn('mr-2 h-4 w-4', refreshing && 'animate-spin')} />
</button>
<button
type="button"
className="flex h-9 shrink-0 items-center justify-center rounded-full bg-[#2B7FFF] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#2B7FFF]/90"
onClick={() => {
setEditingJob(null);
setDialogOpen(true);
}}
>
<PlusIcon className="mr-2 h-4 w-4" />
</button>
</div>
</div>
<div className="-mr-2 min-h-0 flex-1 overflow-y-auto pb-10 pr-2">
{feedback ? <Notice tone={feedback.tone} message={feedback.message} /> : null}
{error ? <Notice tone="error" message={error} /> : null}
{agentsError ? <Notice tone="warning" message={`Agents 数据加载失败,任务仍会使用当前快照继续展示。${agentsError}`} /> : null}
{channelsError ? <Notice tone="warning" message={`渠道账号加载失败,发送配置将以占位信息展示。${channelsError}`} /> : null}
{agentsWarning ? <Notice tone="warning" message={agentsWarning} /> : null}
{loading && jobs.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-[#525866] dark:text-gray-400">
<SpinnerIcon className="mb-4 h-10 w-10 animate-spin" />
<p>...</p>
</div>
) : (
<>
<div className="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard label="全部任务" value={jobs.length} tone="neutral" icon={ClockIcon} />
<StatCard label="启用中" value={activeJobs.length} tone="green" icon={PlayIcon} />
<StatCard label="已暂停" value={pausedJobs.length} tone="amber" icon={PauseIcon} />
<StatCard label="最近失败" value={failedJobs.length} tone="red" icon={AlertIcon} />
</div>
{jobs.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-3xl border border-transparent bg-[#F4F3EB]/40 py-20 text-[#525866] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-400">
<ClockIcon className="mb-4 h-10 w-10 opacity-50" />
<h3 className="mb-2 text-lg font-medium text-[#171717] dark:text-[#f3f4f6]"></h3>
<p className="mb-6 max-w-md text-center text-[14px]">
Agent
</p>
<button
type="button"
className="flex h-10 items-center justify-center rounded-full bg-[#2B7FFF] px-6 text-[13px] font-medium text-white transition-colors hover:bg-[#2B7FFF]/90"
onClick={() => {
setEditingJob(null);
setDialogOpen(true);
}}
>
<PlusIcon className="mr-2 h-4 w-4" />
</button>
</div>
) : (
<div className="grid grid-cols-1 gap-x-6 gap-y-4 xl:grid-cols-2">
{jobs.map((job) => (
<CronJobCard
key={job.id}
job={job}
agentLabel={getAgentLabel(job.agentId, agentsById, defaultAgentId)}
agentDetail={getAgentDetail(job.agentId, agentsById, defaultAgentId)}
deliverySummary={describeDelivery(job.delivery, channelGroups)}
busyAction={busyJobId === job.id ? busyAction : null}
onToggle={(enabled) => {
void handleToggle(job, enabled);
}}
onEdit={() => {
setEditingJob(job);
setDialogOpen(true);
}}
onDelete={() => {
void handleDelete(job);
}}
onTrigger={() => {
void handleTrigger(job);
}}
/>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
<CronTaskDialog
open={dialogOpen}
job={editingJob}
saving={dialogSaving}
agents={agents}
defaultAgentId={defaultAgentId}
channelGroups={channelGroups}
channelsLoading={channelsLoading}
channelsError={channelsError}
onClose={() => {
if (dialogSaving) return;
setDialogOpen(false);
setEditingJob(null);
}}
onSave={handleSave}
/>
</section>
);
}