feat: update the cron job visiual way (#11466)

* feat: change the cron settings page the config form to new

* feat: change the create new Cron to a single cron/new routes to create

* fix: slove the first into cron the content lost error

* feat: add cron dropdown actions delete topic & remove cronjob

* feat: change the delete button way to header bottom

* fix: slove the cronjob the editor will show old values

* feat: change the enableBusinessFeatures into server config

* fix: overrides @lobehub/ui

* feat: update the cronpage ui

* feat: ui fixed

* feat: add the minstep into 30mins
This commit is contained in:
Shinji-Li
2026-01-13 21:12:26 +08:00
committed by GitHub
parent 2dd6d423b9
commit 63d81de3be
24 changed files with 1066 additions and 568 deletions

View File

@@ -3,10 +3,16 @@
"about.title": "About",
"advancedSettings": "Advanced Settings",
"agentCronJobs.addJob": "Add Scheduled Task",
"agentCronJobs.clearTopics": "Clear Execution Results",
"agentCronJobs.clearTopicsFailed": "Failed to clear execution results",
"agentCronJobs.confirmClearTopics": "Are you sure you want to clear {{count}} execution result(s)? This action cannot be undone.",
"agentCronJobs.confirmDelete": "Are you sure you want to delete this scheduled task?",
"agentCronJobs.confirmDeleteCronJob": "Are you sure you want to delete this scheduled task and all its execution results? This action cannot be undone.",
"agentCronJobs.content": "Task Content",
"agentCronJobs.create": "Create",
"agentCronJobs.createSuccess": "Scheduled task created successfully",
"agentCronJobs.deleteCronJob": "Delete Scheduled Task",
"agentCronJobs.deleteFailed": "Failed to delete scheduled task",
"agentCronJobs.deleteJob": "Delete Task",
"agentCronJobs.deleteSuccess": "Scheduled task deleted successfully",
"agentCronJobs.description": "Automate your agent with scheduled executions",
@@ -25,6 +31,7 @@
"agentCronJobs.form.validation.nameRequired": "Task name is required",
"agentCronJobs.interval.12hours": "Every 12 hours",
"agentCronJobs.interval.1hour": "Every hour",
"agentCronJobs.interval.2hours": "Every 2 hours",
"agentCronJobs.interval.30min": "Every 30 minutes",
"agentCronJobs.interval.6hours": "Every 6 hours",
"agentCronJobs.interval.daily": "Daily",
@@ -36,7 +43,11 @@
"agentCronJobs.noExecutionResults": "No execution results",
"agentCronJobs.remainingExecutions": "Remaining: {{count}}",
"agentCronJobs.save": "Save",
"agentCronJobs.saveAsNew": "Save as New Scheduled Task",
"agentCronJobs.schedule": "Schedule",
"agentCronJobs.scheduleType.daily": "Daily",
"agentCronJobs.scheduleType.hourly": "Hourly",
"agentCronJobs.scheduleType.weekly": "Weekly",
"agentCronJobs.status.depleted": "Depleted",
"agentCronJobs.status.disabled": "Disabled",
"agentCronJobs.status.enabled": "Enabled",

View File

@@ -3,10 +3,16 @@
"about.title": "关于",
"advancedSettings": "进阶配置",
"agentCronJobs.addJob": "添加定时任务",
"agentCronJobs.clearTopics": "清空执行结果",
"agentCronJobs.clearTopicsFailed": "清空执行结果失败",
"agentCronJobs.confirmClearTopics": "确定要清空 {{count}} 条执行结果吗?此操作不可撤销。",
"agentCronJobs.confirmDelete": "确定要删除此定时任务吗?",
"agentCronJobs.confirmDeleteCronJob": "确定要删除此定时任务及其所有执行结果吗?此操作不可撤销。",
"agentCronJobs.content": "任务内容",
"agentCronJobs.create": "创建",
"agentCronJobs.createSuccess": "定时任务创建成功",
"agentCronJobs.deleteCronJob": "删除定时任务",
"agentCronJobs.deleteFailed": "删除定时任务失败",
"agentCronJobs.deleteJob": "删除任务",
"agentCronJobs.deleteSuccess": "定时任务删除成功",
"agentCronJobs.description": "通过定时执行自动化您的智能体",
@@ -25,6 +31,7 @@
"agentCronJobs.form.validation.nameRequired": "任务名称不能为空",
"agentCronJobs.interval.12hours": "每12小时",
"agentCronJobs.interval.1hour": "每小时",
"agentCronJobs.interval.2hours": "每2小时",
"agentCronJobs.interval.30min": "每30分钟",
"agentCronJobs.interval.6hours": "每6小时",
"agentCronJobs.interval.daily": "每日",
@@ -36,7 +43,11 @@
"agentCronJobs.noExecutionResults": "无执行结果",
"agentCronJobs.remainingExecutions": "剩余:{{count}}",
"agentCronJobs.save": "保存",
"agentCronJobs.saveAsNew": "保存为新定时任务",
"agentCronJobs.schedule": "计划",
"agentCronJobs.scheduleType.daily": "每日",
"agentCronJobs.scheduleType.hourly": "每小时",
"agentCronJobs.scheduleType.weekly": "每周",
"agentCronJobs.status.depleted": "已耗尽",
"agentCronJobs.status.disabled": "已禁用",
"agentCronJobs.status.enabled": "已启用",

View File

@@ -18,8 +18,8 @@ const styles = createStaticStyles(({ css, cssVar: cv }) => ({
align-items: center;
`,
count: css`
color: ${cv.colorTextSecondary};
font-size: 12px;
color: ${cv.colorTextSecondary};
`,
root: css`
overflow: hidden;
@@ -83,8 +83,8 @@ export const BatchCreateAgentsInspector = memo<
<div className={styles.avatarGroup}>
{displayInfo.displayAgents.map((agent, index) => (
<Avatar
key={index}
avatar={agent.avatar}
key={index}
shape={'square'}
size={20}
title={agent.title}

View File

@@ -17,70 +17,70 @@ const styles = createStaticStyles(({ css }) => ({
`,
}));
export const UpdateGroupInspector = memo<BuiltinInspectorProps<UpdateGroupParams, UpdateGroupState>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
export const UpdateGroupInspector = memo<
BuiltinInspectorProps<UpdateGroupParams, UpdateGroupState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const config = args?.config || partialArgs?.config;
const meta = args?.meta || partialArgs?.meta;
const config = args?.config || partialArgs?.config;
const meta = args?.meta || partialArgs?.meta;
// Build display text from updated fields
const displayText = useMemo(() => {
const fields: string[] = [];
// Config fields
if (config?.openingMessage !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.openingMessage'));
}
if (config?.openingQuestions !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.openingQuestions'));
}
// Meta fields
if (meta?.title !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.title'));
}
if (meta?.description !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.description'));
}
if (meta?.avatar !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.avatar'));
}
if (meta?.backgroundColor !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.backgroundColor'));
}
return fields.length > 0 ? fields.join(', ') : '';
}, [config, meta, t]);
// Initial streaming state
if (isArgumentsStreaming && !displayText) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-group-agent-builder.apiName.updateGroup')}</span>
</div>
);
// Build display text from updated fields
const displayText = useMemo(() => {
const fields: string[] = [];
// Config fields
if (config?.openingMessage !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.openingMessage'));
}
if (config?.openingQuestions !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.openingQuestions'));
}
// Meta fields
if (meta?.title !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.title'));
}
if (meta?.description !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.description'));
}
if (meta?.avatar !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.avatar'));
}
if (meta?.backgroundColor !== undefined) {
fields.push(t('builtins.lobe-group-agent-builder.inspector.backgroundColor'));
}
return fields.length > 0 ? fields.join(', ') : '';
}, [config, meta, t]);
const isSuccess = pluginState?.success;
// Initial streaming state
if (isArgumentsStreaming && !displayText) {
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-group-agent-builder.apiName.updateGroup')}</span>
{displayText && (
<>
: <span className={highlightTextStyles.primary}>{displayText}</span>
</>
)}
{!isLoading && isSuccess && (
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
)}
</div>
);
},
);
}
const isSuccess = pluginState?.success;
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-group-agent-builder.apiName.updateGroup')}</span>
{displayText && (
<>
: <span className={highlightTextStyles.primary}>{displayText}</span>
</>
)}
{!isLoading && isSuccess && (
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
)}
</div>
);
});
UpdateGroupInspector.displayName = 'UpdateGroupInspector';

View File

@@ -11,9 +11,10 @@ import type { BatchCreateAgentsParams, BatchCreateAgentsState } from '../../type
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 4px 16px;
background: ${cssVar.colorFillQuaternary};
padding-block: 4px;
padding-inline: 16px;
border-radius: 8px;
background: ${cssVar.colorFillQuaternary};
`,
description: css`
overflow: hidden;

View File

@@ -238,7 +238,8 @@ class GroupAgentBuilderExecutor extends BaseExecutor<typeof GroupAgentBuilderApi
if (!agentId) {
return {
content: 'No agent found. Please provide an agentId or ensure supervisor context is available.',
content:
'No agent found. Please provide an agentId or ensure supervisor context is available.',
error: { message: 'No agent found', type: 'NoAgentContext' },
success: false,
};

View File

@@ -21,25 +21,6 @@ export const cronPatternSchema = z
// Minimum 30 minutes validation (using standard cron format)
export const minimumIntervalSchema = z.string().refine((pattern) => {
// Standard cron format: minute hour day month weekday
const allowedPatterns = [
'*/30 * * * *', // Every 30 minutes
'0 * * * *', // Every hour
'0 */2 * * *', // Every 2 hours
'0 */3 * * *', // Every 3 hours
'0 */4 * * *', // Every 4 hours
'0 */6 * * *', // Every 6 hours
'0 */8 * * *', // Every 8 hours
'0 */12 * * *', // Every 12 hours
'0 0 * * *', // Daily at midnight
'0 0 * * 0', // Weekly on Sunday
'0 0 1 * *', // Monthly on 1st
];
// Check if it matches allowed patterns
if (allowedPatterns.includes(pattern)) {
return true;
}
// Parse pattern to validate minimum 30-minute interval
const parts = pattern.split(' ');
if (parts.length !== 5) {
@@ -48,24 +29,39 @@ export const minimumIntervalSchema = z.string().refine((pattern) => {
const [minute, hour] = parts;
// Validate minute is 0 or 30 (we only allow 30-minute intervals)
const isValidMinute = minute === '0' || minute === '30' || minute === '*/30';
if (!isValidMinute) {
return false;
}
// Allow minute intervals >= 30 (e.g., */30, */45, */60)
if (minute.startsWith('*/')) {
const interval = parseInt(minute.slice(2));
if (!isNaN(interval) && interval >= 30) {
return true;
}
return false;
}
// Allow hourly patterns: 0 */N * * * where N >= 1
if (minute === '0' && hour.startsWith('*/')) {
// Allow hourly patterns: {0|30} */N * * * where N >= 1
if ((minute === '0' || minute === '30') && hour.startsWith('*/')) {
const interval = parseInt(hour.slice(2));
if (!isNaN(interval) && interval >= 1) {
return true;
}
return false;
}
// Allow specific hour patterns: 0 N * * * (runs once per day)
if (minute === '0' && /^\d+$/.test(hour)) {
// Allow hourly patterns: {0|30} * * * * (every hour at :00 or :30)
if ((minute === '0' || minute === '30') && hour === '*') {
return true;
}
// Allow specific hour patterns: {0|30} N * * * (runs once per day)
// or {0|30} N * * {weekdays} (runs on specific weekdays)
if ((minute === '0' || minute === '30') && /^\d+$/.test(hour)) {
const h = parseInt(hour);
if (!isNaN(h) && h >= 0 && h <= 23) {
return true;

View File

@@ -49,6 +49,7 @@ export type ServerLanguageModel = Partial<Record<GlobalLLMProviderKey, ServerMod
export interface GlobalServerConfig {
aiProvider: ServerLanguageModel;
defaultAgent?: PartialDeep<UserDefaultAgent>;
enableBusinessFeatures?: boolean;
enableEmailVerification?: boolean;
enableKlavis?: boolean;
enableLobehubSkill?: boolean;

View File

@@ -0,0 +1,31 @@
import { ActionIcon, Dropdown } from '@lobehub/ui';
import { MoreHorizontal } from 'lucide-react';
import { memo } from 'react';
import { useCronJobDropdownMenu } from './useDropdownMenu';
interface ActionsProps {
cronJobId: string;
topics: Array<{ id: string }>;
}
const Actions = memo<ActionsProps>(({ cronJobId, topics }) => {
const dropdownMenu = useCronJobDropdownMenu(cronJobId, topics);
return (
<Dropdown
arrow={false}
menu={{
items: dropdownMenu,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
},
}}
trigger={['click']}
>
<ActionIcon icon={MoreHorizontal} size={'small'} />
</Dropdown>
);
});
export default Actions;

View File

@@ -9,6 +9,7 @@ import { useParams } from 'react-router-dom';
import { useRouter } from '@/app/[variants]/(main)/hooks/useRouter';
import type { AgentCronJob } from '@/database/schemas/agentCronJob';
import Actions from './Actions';
import CronTopicItem from './CronTopicItem';
interface CronTopicGroupProps {
@@ -43,12 +44,15 @@ const CronTopicGroup = memo<CronTopicGroupProps>(({ cronJob, cronJobId, topics }
return (
<AccordionItem
action={
<ActionIcon
icon={Settings2Icon}
onClick={handleOpenCronJob}
size="small"
title={t('agentCronJobs.editJob')}
/>
<Flexbox align="center" gap={4} horizontal>
<ActionIcon
icon={Settings2Icon}
onClick={handleOpenCronJob}
size="small"
title={t('agentCronJobs.editJob')}
/>
<Actions cronJobId={cronJobId} topics={topics} />
</Flexbox>
}
itemKey={cronJobId}
paddingBlock={4}

View File

@@ -1,6 +1,5 @@
'use client';
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { Accordion, AccordionItem, ActionIcon, Flexbox, Text } from '@lobehub/ui';
import { Plus } from 'lucide-react';
import { memo, useCallback } from 'react';
@@ -12,6 +11,7 @@ import EmptyNavItem from '@/features/NavPanel/components/EmptyNavItem';
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { useAgentStore } from '@/store/agent';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import CronTopicGroup from './CronTopicGroup';
@@ -22,24 +22,20 @@ interface CronTopicListProps {
const CronTopicList = memo<CronTopicListProps>(({ itemKey }) => {
const { t } = useTranslation('setting');
const router = useQueryRoute();
const [agentId, createAgentCronJob, useFetchCronTopicsWithJobInfo] = useAgentStore((s) => [
const [agentId, useFetchCronTopicsWithJobInfo] = useAgentStore((s) => [
s.activeAgentId,
s.createAgentCronJob,
s.useFetchCronTopicsWithJobInfo,
]);
const { data: cronTopicsGroupsWithJobInfo = [], isLoading } =
useFetchCronTopicsWithJobInfo(agentId);
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
const handleCreateCronJob = useCallback(async () => {
const handleCreateCronJob = useCallback(() => {
if (!agentId) return;
router.push(urlJoin('/agent', agentId, 'cron', 'new'));
}, [agentId, router]);
const cronJobId = await createAgentCronJob();
if (cronJobId) {
router.push(urlJoin('/agent', agentId, 'cron', cronJobId));
}
}, [agentId, createAgentCronJob, router]);
if (!ENABLE_BUSINESS_FEATURES) return null;
if (!enableBusinessFeatures) return null;
const addAction = (
<ActionIcon

View File

@@ -0,0 +1,102 @@
import { Icon, type MenuProps } from '@lobehub/ui';
import { App } from 'antd';
import { Trash } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { agentCronJobService } from '@/services/agentCronJob';
import { topicService } from '@/services/topic';
import { useAgentStore } from '@/store/agent';
export const useCronJobDropdownMenu = (
cronJobId: string,
topics: Array<{ id: string }>,
): MenuProps['items'] => {
const { t } = useTranslation(['setting', 'common']);
const { modal } = App.useApp();
const refreshCronTopics = useAgentStore((s) => s.internal_refreshCronTopics);
const handleDeleteCronJob = useCallback(async () => {
try {
// Delete all topics associated with this cron job
if (topics.length > 0) {
const topicIds = topics.map((t) => t.id);
await topicService.batchRemoveTopics(topicIds);
}
// Delete the cron job
await agentCronJobService.delete(cronJobId);
// Refresh the cron topics list
await refreshCronTopics();
} catch (error) {
console.error('Failed to delete cron job:', error);
modal.error({
content: t('agentCronJobs.deleteFailed' as any),
title: t('error' as any, { ns: 'common' }),
});
}
}, [cronJobId, topics, refreshCronTopics, modal, t]);
const handleClearTopics = useCallback(async () => {
if (topics.length === 0) return;
try {
const topicIds = topics.map((t) => t.id);
await topicService.batchRemoveTopics(topicIds);
// Refresh the cron topics list
await refreshCronTopics();
} catch (error) {
console.error('Failed to clear topics:', error);
modal.error({
content: t('agentCronJobs.clearTopicsFailed' as any),
title: t('error' as any, { ns: 'common' }),
});
}
}, [topics, refreshCronTopics, modal, t]);
return useMemo(
() =>
[
{
icon: <Icon icon={Trash} />,
key: 'clearTopics',
label: t('agentCronJobs.clearTopics' as any),
onClick: () => {
modal.confirm({
cancelText: t('cancel', { ns: 'common' }),
centered: true,
content: t('agentCronJobs.confirmClearTopics' as any, { count: topics.length }),
okButtonProps: { danger: true },
okText: t('ok', { ns: 'common' }),
onOk: handleClearTopics,
title: t('agentCronJobs.clearTopics' as any),
});
},
},
{
type: 'divider' as const,
},
{
danger: true,
icon: <Icon icon={Trash} />,
key: 'deleteCronJob',
label: t('agentCronJobs.deleteCronJob' as any),
onClick: () => {
modal.confirm({
cancelText: t('cancel', { ns: 'common' }),
centered: true,
content: t('agentCronJobs.confirmDeleteCronJob' as any),
okButtonProps: { danger: true },
okText: t('ok', { ns: 'common' }),
onOk: handleDeleteCronJob,
title: t('agentCronJobs.deleteCronJob' as any),
});
},
},
].filter(Boolean) as MenuProps['items'],
[topics.length, handleClearTopics, handleDeleteCronJob, t, modal],
);
};

View File

@@ -0,0 +1,179 @@
import type { Dayjs } from 'dayjs';
export type ScheduleType = 'daily' | 'hourly' | 'weekly';
// Schedule type options
export const SCHEDULE_TYPE_OPTIONS = [
{ label: 'agentCronJobs.scheduleType.daily', value: 'daily' },
{ label: 'agentCronJobs.scheduleType.hourly', value: 'hourly' },
{ label: 'agentCronJobs.scheduleType.weekly', value: 'weekly' },
] as const;
// Timezone options - covering major cities worldwide
export const TIMEZONE_OPTIONS = [
{ label: 'UTC', value: 'UTC' },
// Americas
{ label: 'America/New_York (EST/EDT, UTC-5/-4)', value: 'America/New_York' },
{ label: 'America/Chicago (CST/CDT, UTC-6/-5)', value: 'America/Chicago' },
{ label: 'America/Denver (MST/MDT, UTC-7/-6)', value: 'America/Denver' },
{ label: 'America/Los_Angeles (PST/PDT, UTC-8/-7)', value: 'America/Los_Angeles' },
{ label: 'America/Toronto (EST/EDT, UTC-5/-4)', value: 'America/Toronto' },
{ label: 'America/Vancouver (PST/PDT, UTC-8/-7)', value: 'America/Vancouver' },
{ label: 'America/Mexico_City (CST, UTC-6)', value: 'America/Mexico_City' },
{ label: 'America/Sao_Paulo (BRT, UTC-3)', value: 'America/Sao_Paulo' },
{ label: 'America/Buenos_Aires (ART, UTC-3)', value: 'America/Buenos_Aires' },
// Europe
{ label: 'Europe/London (GMT/BST, UTC+0/+1)', value: 'Europe/London' },
{ label: 'Europe/Paris (CET/CEST, UTC+1/+2)', value: 'Europe/Paris' },
{ label: 'Europe/Berlin (CET/CEST, UTC+1/+2)', value: 'Europe/Berlin' },
{ label: 'Europe/Madrid (CET/CEST, UTC+1/+2)', value: 'Europe/Madrid' },
{ label: 'Europe/Rome (CET/CEST, UTC+1/+2)', value: 'Europe/Rome' },
{ label: 'Europe/Amsterdam (CET/CEST, UTC+1/+2)', value: 'Europe/Amsterdam' },
{ label: 'Europe/Brussels (CET/CEST, UTC+1/+2)', value: 'Europe/Brussels' },
{ label: 'Europe/Moscow (MSK, UTC+3)', value: 'Europe/Moscow' },
{ label: 'Europe/Istanbul (TRT, UTC+3)', value: 'Europe/Istanbul' },
// Asia
{ label: 'Asia/Dubai (GST, UTC+4)', value: 'Asia/Dubai' },
{ label: 'Asia/Kolkata (IST, UTC+5:30)', value: 'Asia/Kolkata' },
{ label: 'Asia/Shanghai (CST, UTC+8)', value: 'Asia/Shanghai' },
{ label: 'Asia/Hong_Kong (HKT, UTC+8)', value: 'Asia/Hong_Kong' },
{ label: 'Asia/Taipei (CST, UTC+8)', value: 'Asia/Taipei' },
{ label: 'Asia/Singapore (SGT, UTC+8)', value: 'Asia/Singapore' },
{ label: 'Asia/Tokyo (JST, UTC+9)', value: 'Asia/Tokyo' },
{ label: 'Asia/Seoul (KST, UTC+9)', value: 'Asia/Seoul' },
{ label: 'Asia/Bangkok (ICT, UTC+7)', value: 'Asia/Bangkok' },
{ label: 'Asia/Jakarta (WIB, UTC+7)', value: 'Asia/Jakarta' },
// Oceania
{ label: 'Australia/Sydney (AEDT/AEST, UTC+11/+10)', value: 'Australia/Sydney' },
{ label: 'Australia/Melbourne (AEDT/AEST, UTC+11/+10)', value: 'Australia/Melbourne' },
{ label: 'Australia/Brisbane (AEST, UTC+10)', value: 'Australia/Brisbane' },
{ label: 'Australia/Perth (AWST, UTC+8)', value: 'Australia/Perth' },
{ label: 'Pacific/Auckland (NZDT/NZST, UTC+13/+12)', value: 'Pacific/Auckland' },
// Africa & Middle East
{ label: 'Africa/Cairo (EET, UTC+2)', value: 'Africa/Cairo' },
{ label: 'Africa/Johannesburg (SAST, UTC+2)', value: 'Africa/Johannesburg' },
];
// Weekday options for checkbox group
export const WEEKDAY_OPTIONS = [
{ label: 'Mon', value: 1 },
{ label: 'Tue', value: 2 },
{ label: 'Wed', value: 3 },
{ label: 'Thu', value: 4 },
{ label: 'Fri', value: 5 },
{ label: 'Sat', value: 6 },
{ label: 'Sun', value: 0 },
];
// Weekday labels for display
export const WEEKDAY_LABELS: Record<number, string> = {
0: 'Sunday',
1: 'Monday',
2: 'Tuesday',
3: 'Wednesday',
4: 'Thursday',
5: 'Friday',
6: 'Saturday',
};
/**
* Parse cron pattern to extract schedule info
* Format: minute hour day month weekday
*/
export const parseCronPattern = (
cronPattern: string,
): {
hourlyInterval?: number;
scheduleType: ScheduleType;
triggerHour: number;
triggerMinute: number;
weekdays?: number[];
} => {
const parts = cronPattern.split(' ');
if (parts.length !== 5) {
return { scheduleType: 'daily', triggerHour: 0, triggerMinute: 0 };
}
// eslint-disable-next-line unicorn/no-unreadable-array-destructuring
const [minute, hour, , , weekday] = parts;
const rawMinute = minute === '*' ? 0 : Number.parseInt(minute);
// Normalize to nearest 30-minute interval (0 or 30)
const triggerMinute = rawMinute >= 15 && rawMinute < 45 ? 30 : 0;
// Hourly: 0 * * * * or 0 */N * * *
if (hour.startsWith('*/')) {
const interval = Number.parseInt(hour.slice(2));
return {
hourlyInterval: interval,
scheduleType: 'hourly',
triggerHour: 0,
triggerMinute,
};
}
if (hour === '*') {
return {
hourlyInterval: 1,
scheduleType: 'hourly',
triggerHour: 0,
triggerMinute,
};
}
const triggerHour = Number.parseInt(hour);
// Weekly: has specific weekday(s)
if (weekday !== '*') {
const weekdays = weekday.split(',').map((d) => Number.parseInt(d));
return {
scheduleType: 'weekly',
triggerHour,
triggerMinute,
weekdays,
};
}
// Daily: specific hour, any weekday
return {
scheduleType: 'daily',
triggerHour,
triggerMinute,
};
};
/**
* Build cron pattern from schedule info
* Format: minute hour day month weekday
*/
export const buildCronPattern = (
scheduleType: ScheduleType,
triggerTime: Dayjs,
hourlyInterval?: number,
weekdays?: number[],
): string => {
const rawMinute = triggerTime.minute();
// Normalize to 0 or 30
const minute = rawMinute >= 15 && rawMinute < 45 ? 30 : 0;
const hour = triggerTime.hour();
switch (scheduleType) {
case 'hourly': {
const interval = hourlyInterval || 1;
if (interval === 1) {
return `${minute} * * * *`;
}
return `${minute} */${interval} * * *`;
}
case 'daily': {
return `${minute} ${hour} * * *`;
}
case 'weekly': {
const days = weekdays && weekdays.length > 0 ? weekdays.sort().join(',') : '0,1,2,3,4,5,6';
return `${minute} ${hour} * * ${days}`;
}
}
};

View File

@@ -0,0 +1,111 @@
import {
ReactCodePlugin,
ReactCodemirrorPlugin,
ReactHRPlugin,
ReactLinkPlugin,
ReactListPlugin,
ReactMathPlugin,
ReactTablePlugin,
} from '@lobehub/editor';
import { Editor, useEditor } from '@lobehub/editor/react';
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { Card } from 'antd';
import { Clock } from 'lucide-react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
interface CronJobContentEditorProps {
enableRichRender: boolean;
initialValue: string;
onChange: (value: string) => void;
}
const CronJobContentEditor = memo<CronJobContentEditorProps>(
({ enableRichRender, initialValue, onChange }) => {
const { t } = useTranslation('setting');
const editor = useEditor();
const currentValueRef = useRef(initialValue);
// Update currentValueRef when initialValue changes
useEffect(() => {
currentValueRef.current = initialValue;
}, [initialValue]);
// Initialize editor content when editor is ready
useEffect(() => {
if (!editor) return;
try {
setTimeout(() => {
if (initialValue) {
editor.setDocument(enableRichRender ? 'markdown' : 'text', initialValue);
}
}, 100);
} catch (error) {
console.error('[CronJobContentEditor] Failed to initialize editor content:', error);
setTimeout(() => {
editor.setDocument(enableRichRender ? 'markdown' : 'text', initialValue);
}, 100);
}
}, [editor, enableRichRender, initialValue]);
// Handle content changes
const handleContentChange = useCallback(
(e: any) => {
const nextContent = enableRichRender
? (e.getDocument('markdown') as unknown as string)
: (e.getDocument('text') as unknown as string);
const finalContent = nextContent || '';
// Only call onChange if content actually changed
if (finalContent !== currentValueRef.current) {
currentValueRef.current = finalContent;
onChange(finalContent);
}
},
[enableRichRender, onChange],
);
return (
<Flexbox gap={12}>
<Flexbox align="center" gap={6} horizontal>
<Icon icon={Clock} size={16} />
<Text style={{ fontWeight: 600 }}>{t('agentCronJobs.content')}</Text>
</Flexbox>
<Card
size="small"
style={{ borderRadius: 12, overflow: 'hidden' }}
styles={{ body: { padding: 0 } }}
>
<Flexbox padding={16} style={{ minHeight: 220 }}>
<Editor
content={''}
editor={editor}
lineEmptyPlaceholder={t('agentCronJobs.form.content.placeholder')}
onTextChange={handleContentChange}
placeholder={t('agentCronJobs.form.content.placeholder')}
plugins={
enableRichRender
? [
ReactListPlugin,
ReactCodePlugin,
ReactCodemirrorPlugin,
ReactHRPlugin,
ReactLinkPlugin,
ReactTablePlugin,
ReactMathPlugin,
]
: undefined
}
style={{ paddingBottom: 48 }}
type={'text'}
variant={'chat'}
/>
</Flexbox>
</Card>
</Flexbox>
);
},
);
export default CronJobContentEditor;

View File

@@ -0,0 +1,45 @@
import { Flexbox, Input } from '@lobehub/ui';
import { Switch } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
interface CronJobHeaderProps {
enabled?: boolean;
isNewJob?: boolean;
name: string;
onNameChange: (name: string) => void;
onToggleEnabled?: (enabled: boolean) => void;
}
const CronJobHeader = memo<CronJobHeaderProps>(
({ enabled, isNewJob, name, onNameChange, onToggleEnabled }) => {
const { t } = useTranslation(['setting', 'common']);
return (
<Flexbox gap={16}>
{/* Title Input */}
<Input
onChange={(e) => onNameChange(e.target.value)}
placeholder={t('agentCronJobs.form.name.placeholder')}
style={{
fontSize: 28,
fontWeight: 600,
padding: 0,
}}
value={name}
variant={'borderless'}
/>
{/* Controls Row */}
{!isNewJob && (
<Flexbox align="center" gap={12} horizontal>
{/* Enable/Disable Switch */}
<Switch checked={enabled ?? false} onChange={onToggleEnabled} />
</Flexbox>
)}
</Flexbox>
);
},
);
export default CronJobHeader;

View File

@@ -0,0 +1,31 @@
import { Button, Flexbox } from '@lobehub/ui';
import { Save } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
interface CronJobSaveButtonProps {
disabled?: boolean;
loading?: boolean;
onSave: () => void;
}
const CronJobSaveButton = memo<CronJobSaveButtonProps>(({ disabled, loading, onSave }) => {
const { t } = useTranslation('setting');
return (
<Flexbox paddingBlock={16}>
<Button
disabled={disabled}
icon={Save}
loading={loading}
onClick={onSave}
style={{ width: 200 }}
type="primary"
>
{t('agentCronJobs.saveAsNew', { defaultValue: 'Save as New Scheduled Task' })}
</Button>
</Flexbox>
);
});
export default CronJobSaveButton;

View File

@@ -0,0 +1,213 @@
import { Flexbox, Tag, Text } from '@lobehub/ui';
import { Card, InputNumber, Select, TimePicker } from 'antd';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
SCHEDULE_TYPE_OPTIONS,
type ScheduleType,
TIMEZONE_OPTIONS,
WEEKDAY_LABELS,
WEEKDAY_OPTIONS,
} from '../CronConfig';
interface CronJobScheduleConfigProps {
hourlyInterval?: number;
maxExecutions?: number | null;
onScheduleChange: (updates: {
hourlyInterval?: number;
maxExecutions?: number | null;
scheduleType?: ScheduleType;
timezone?: string;
triggerTime?: Dayjs;
weekdays?: number[];
}) => void;
scheduleType: ScheduleType;
timezone: string;
triggerTime: Dayjs;
weekdays: number[];
}
const CronJobScheduleConfig = memo<CronJobScheduleConfigProps>(
({
hourlyInterval,
maxExecutions,
onScheduleChange,
scheduleType,
timezone,
triggerTime,
weekdays,
}) => {
const { t } = useTranslation('setting');
// Compute summary tags
const summaryTags = useMemo(() => {
const result: Array<{ key: string; label: string }> = [];
// Schedule type
const scheduleTypeLabel = SCHEDULE_TYPE_OPTIONS.find(
(opt) => opt.value === scheduleType,
)?.label;
if (scheduleTypeLabel) {
result.push({
key: 'scheduleType',
label: t(scheduleTypeLabel as any),
});
}
// Trigger time
if (scheduleType === 'hourly') {
const minute = triggerTime.minute();
result.push({
key: 'interval',
label: `Every ${hourlyInterval || 1} hour(s) at :${minute.toString().padStart(2, '0')}`,
});
} else {
result.push({
key: 'triggerTime',
label: triggerTime.format('HH:mm'),
});
}
// Timezone
result.push({
key: 'timezone',
label: timezone,
});
// Weekdays for weekly schedule
if (scheduleType === 'weekly' && weekdays.length > 0) {
result.push({
key: 'weekdays',
label: weekdays.map((day) => WEEKDAY_LABELS[day]).join(', '),
});
}
return result;
}, [scheduleType, triggerTime, timezone, weekdays, hourlyInterval, t]);
return (
<Card size="small" style={{ borderRadius: 12 }} styles={{ body: { padding: 12 } }}>
<Flexbox gap={12}>
{/* Summary Tags */}
{summaryTags.length > 0 && (
<Flexbox align="center" gap={8} horizontal style={{ flexWrap: 'wrap' }}>
{summaryTags.map((tag) => (
<Tag key={tag.key} variant={'filled'}>
{tag.label}
</Tag>
))}
</Flexbox>
)}
{/* Schedule Configuration - All in one row */}
<Flexbox align="center" gap={8} horizontal style={{ flexWrap: 'wrap' }}>
<Tag variant={'borderless'}>{t('agentCronJobs.schedule')}</Tag>
<Select
onChange={(value: ScheduleType) =>
onScheduleChange({
scheduleType: value,
weekdays: value === 'weekly' ? [0, 1, 2, 3, 4, 5, 6] : [],
})
}
options={SCHEDULE_TYPE_OPTIONS.map((opt) => ({
label: t(opt.label as any),
value: opt.value,
}))}
size="small"
style={{ minWidth: 120 }}
value={scheduleType}
/>
{/* Weekdays - show only for weekly */}
{scheduleType === 'weekly' && (
<Select
maxTagCount="responsive"
mode="multiple"
onChange={(values: number[]) => onScheduleChange({ weekdays: values })}
options={WEEKDAY_OPTIONS}
placeholder="Select days"
size="small"
style={{ minWidth: 150 }}
value={weekdays}
/>
)}
{/* Trigger Time - show for daily and weekly */}
{scheduleType !== 'hourly' && (
<TimePicker
format="HH:mm"
minuteStep={30}
onChange={(value) => {
if (value) onScheduleChange({ triggerTime: value });
}}
size="small"
style={{ minWidth: 120 }}
value={triggerTime ?? dayjs().hour(0).minute(0)}
/>
)}
{/* Hourly Interval - show only for hourly */}
{scheduleType === 'hourly' && (
<>
<Tag variant={'borderless'}>Every</Tag>
<InputNumber
max={24}
min={1}
onChange={(value: number | null) =>
onScheduleChange({ hourlyInterval: value ?? 1 })
}
size="small"
style={{ width: 80 }}
value={hourlyInterval ?? 1}
/>
<Text type="secondary">hour(s) at</Text>
<Select
onChange={(value: number) =>
onScheduleChange({ triggerTime: dayjs().hour(0).minute(value) })
}
options={[
{ label: ':00', value: 0 },
{ label: ':30', value: 30 },
]}
size="small"
style={{ width: 80 }}
value={triggerTime?.minute() ?? 0}
/>
</>
)}
{/* Timezone */}
<Select
onChange={(value: string) => onScheduleChange({ timezone: value })}
options={TIMEZONE_OPTIONS}
showSearch
size="small"
style={{ maxWidth: 300, minWidth: 200 }}
value={timezone}
/>
</Flexbox>
{/* Max Executions */}
<Flexbox align="center" gap={8} horizontal style={{ flexWrap: 'wrap' }}>
<Tag variant={'borderless'}>{t('agentCronJobs.maxExecutions')}</Tag>
<InputNumber
min={1}
onChange={(value: number | null) =>
onScheduleChange({ maxExecutions: value ?? null })
}
placeholder={t('agentCronJobs.form.maxExecutions.placeholder')}
size="small"
style={{ width: 160 }}
value={maxExecutions ?? null}
/>
</Flexbox>
</Flexbox>
</Card>
);
},
);
export default CronJobScheduleConfig;

View File

@@ -1,39 +1,19 @@
'use client';
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { EDITOR_DEBOUNCE_TIME } from '@lobechat/const';
import {
ReactCodePlugin,
ReactCodemirrorPlugin,
ReactHRPlugin,
ReactLinkPlugin,
ReactListPlugin,
ReactMathPlugin,
ReactTablePlugin,
} from '@lobehub/editor';
import { Editor, useEditor, useEditorState } from '@lobehub/editor/react';
import { ActionIcon, Flexbox, Icon, Input, Tag, Text } from '@lobehub/ui';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { useDebounceFn } from 'ahooks';
import { App, Card, Checkbox, Empty, InputNumber, Select, Switch, TimePicker, message } from 'antd';
import { App, Empty, message } from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { Clock, Trash2 } from 'lucide-react';
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { Trash2 } from 'lucide-react';
import { memo, useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import useSWR from 'swr';
import AutoSaveHint from '@/components/Editor/AutoSaveHint';
import Loading from '@/components/Loading/BrandTextLoading';
import type { ExecutionConditions, UpdateAgentCronJobData } from '@/database/schemas/agentCronJob';
import { InlineToolbar } from '@/features/EditorCanvas';
import type { UpdateAgentCronJobData } from '@/database/schemas/agentCronJob';
import NavHeader from '@/features/NavHeader';
import WideScreenContainer from '@/features/WideScreenContainer';
import { useQueryRoute } from '@/hooks/useQueryRoute';
@@ -43,18 +23,27 @@ import { agentCronJobService } from '@/services/agentCronJob';
import { topicService } from '@/services/topic';
import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
import { labPreferSelectors } from '@/store/user/selectors';
import { type ScheduleType, buildCronPattern, parseCronPattern } from './CronConfig';
import CronJobContentEditor from './features/CronJobContentEditor';
import CronJobHeader from './features/CronJobHeader';
import CronJobSaveButton from './features/CronJobSaveButton';
import CronJobScheduleConfig from './features/CronJobScheduleConfig';
interface CronJobDraft {
content: string;
cronPattern: string;
description: string;
hourlyInterval?: number; // For hourly: interval in hours (1, 2, 6, 12)
maxExecutions?: number | null;
maxExecutionsPerDay?: number | null;
name: string;
timeRange?: [Dayjs, Dayjs];
weekdays: number[];
scheduleType: ScheduleType;
timezone: string;
triggerTime: Dayjs; // Trigger time (HH:mm)
weekdays: number[]; // For weekly: selected days
}
type AutoSaveStatus = 'idle' | 'saving' | 'saved';
@@ -85,52 +74,6 @@ const AutoSaveHintSlot = memo(() => {
return <AutoSaveHint lastUpdatedTime={lastUpdatedTime} saveStatus={status} />;
});
// Standard cron format: minute hour day month weekday
const CRON_PATTERNS = [
{ label: 'agentCronJobs.interval.30min', value: '*/30 * * * *' },
{ label: 'agentCronJobs.interval.1hour', value: '0 * * * *' },
{ label: 'agentCronJobs.interval.2hours', value: '0 */2 * * *' },
{ label: 'agentCronJobs.interval.6hours', value: '0 */6 * * *' },
{ label: 'agentCronJobs.interval.12hours', value: '0 */12 * * *' },
{ label: 'agentCronJobs.interval.daily', value: '0 0 * * *' },
{ label: 'agentCronJobs.interval.weekly', value: '0 0 * * 0' },
];
const WEEKDAY_OPTIONS = [
{ label: 'Mon', value: 1 },
{ label: 'Tue', value: 2 },
{ label: 'Wed', value: 3 },
{ label: 'Thu', value: 4 },
{ label: 'Fri', value: 5 },
{ label: 'Sat', value: 6 },
{ label: 'Sun', value: 0 },
];
const WEEKDAY_LABELS: Record<number, string> = {
0: 'Sunday',
1: 'Monday',
2: 'Tuesday',
3: 'Wednesday',
4: 'Thursday',
5: 'Friday',
6: 'Saturday',
};
const getIntervalText = (cronPattern: string) => {
// Standard cron format mapping
const intervalMap: Record<string, string> = {
'*/30 * * * *': 'agentCronJobs.interval.30min',
'0 * * * *': 'agentCronJobs.interval.1hour',
'0 */12 * * *': 'agentCronJobs.interval.12hours',
'0 */2 * * *': 'agentCronJobs.interval.2hours',
'0 */6 * * *': 'agentCronJobs.interval.6hours',
'0 0 * * *': 'agentCronJobs.interval.daily',
'0 0 * * 0': 'agentCronJobs.interval.weekly',
};
return intervalMap[cronPattern] || cronPattern;
};
const resolveDate = (value?: Date | string | null) => {
if (!value) return null;
return typeof value === 'string' ? new Date(value) : value;
@@ -141,15 +84,14 @@ const CronJobDetailPage = memo(() => {
const { aid, cronId } = useParams<{ aid?: string; cronId?: string }>();
const router = useQueryRoute();
const { modal } = App.useApp();
const editor = useEditor();
const editorState = useEditorState(editor);
const enableRichRender = useUserStore(labPreferSelectors.enableInputMarkdown);
const [editorReady, setEditorReady] = useState(false);
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
const isNewJob = cronId === 'new';
const [draft, setDraft] = useState<CronJobDraft | null>(null);
const draftRef = useRef<CronJobDraft | null>(null);
const contentRef = useRef('');
const pendingContentRef = useRef<string | null>(null);
const pendingSaveRef = useRef(false);
const initializedIdRef = useRef<string | null>(null);
const readyRef = useRef(false);
@@ -170,9 +112,9 @@ const CronJobDetailPage = memo(() => {
const cronListAgentId = activeAgentId || aid;
const { data: cronJob, isLoading } = useSWR(
ENABLE_BUSINESS_FEATURES && cronId ? ['cronJob', cronId] : null,
enableBusinessFeatures && cronId && !isNewJob ? ['cronJob', cronId] : null,
async () => {
if (!cronId) return null;
if (!cronId || isNewJob) return null;
const result = await agentCronJobService.getById(cronId);
return result.success ? result.data : null;
},
@@ -184,74 +126,28 @@ const CronJobDetailPage = memo(() => {
},
);
const resolvedCronPattern = draft ? draft.cronPattern : cronJob?.cronPattern;
const resolvedWeekdays = draft ? draft.weekdays : cronJob?.executionConditions?.weekdays || [];
const resolvedTimeRange = draft
? draft.timeRange
: cronJob?.executionConditions?.timeRange
? [
dayjs(cronJob.executionConditions.timeRange.start, 'HH:mm'),
dayjs(cronJob.executionConditions.timeRange.end, 'HH:mm'),
]
: undefined;
const summaryTags = useMemo(() => {
const tags: Array<{ key: string; label: string }> = [];
if (resolvedCronPattern) {
tags.push({
key: 'interval',
label: t(getIntervalText(resolvedCronPattern) as any),
});
}
if (resolvedWeekdays.length > 0) {
tags.push({
key: 'weekdays',
label: resolvedWeekdays.map((day) => WEEKDAY_LABELS[day]).join(', '),
});
}
if (resolvedTimeRange && resolvedTimeRange.length === 2) {
tags.push({
key: 'timeRange',
label: `${resolvedTimeRange[0].format('HH:mm')} - ${resolvedTimeRange[1].format('HH:mm')}`,
});
}
return tags;
}, [resolvedCronPattern, resolvedTimeRange, resolvedWeekdays, t]);
const buildUpdateData = useCallback(
(snapshot: CronJobDraft | null, content: string): UpdateAgentCronJobData | null => {
if (!snapshot) return null;
if (!snapshot.content) return null;
if (!snapshot.name) return null;
const executionConditions: ExecutionConditions = {};
if (snapshot.timeRange && snapshot.timeRange.length === 2) {
executionConditions.timeRange = {
end: snapshot.timeRange[1].format('HH:mm'),
start: snapshot.timeRange[0].format('HH:mm'),
};
}
if (snapshot.weekdays && snapshot.weekdays.length > 0) {
executionConditions.weekdays = snapshot.weekdays;
}
if (snapshot.maxExecutionsPerDay) {
executionConditions.maxExecutionsPerDay = snapshot.maxExecutionsPerDay;
}
// Build cron pattern from schedule configuration
const cronPattern = buildCronPattern(
snapshot.scheduleType,
snapshot.triggerTime,
snapshot.hourlyInterval,
snapshot.weekdays,
);
return {
content,
cronPattern: snapshot.cronPattern,
cronPattern,
description: snapshot.description?.trim() || null,
executionConditions:
Object.keys(executionConditions).length > 0 ? executionConditions : null,
executionConditions: null, // No longer using executionConditions for time/weekdays
maxExecutions: snapshot.maxExecutions ?? null,
name: snapshot.name?.trim() || null,
timezone: snapshot.timezone,
};
},
[],
@@ -272,11 +168,11 @@ const CronJobDetailPage = memo(() => {
(current) =>
current
? {
...current,
...payload,
executionConditions: payload.executionConditions ?? null,
...(updatedAt ? { updatedAt } : null),
}
...current,
...payload,
executionConditions: payload.executionConditions ?? null,
...(updatedAt ? { updatedAt } : null),
}
: current,
false,
);
@@ -291,6 +187,7 @@ const CronJobDetailPage = memo(() => {
const { run: debouncedSave, cancel: cancelDebouncedSave } = useDebounceFn(
async () => {
if (isNewJob) return; // Don't auto-save new jobs
if (!cronId || initializedIdRef.current !== cronId) return;
const payload = buildUpdateData(draftRef.current, contentRef.current);
if (!payload) return;
@@ -323,6 +220,7 @@ const CronJobDetailPage = memo(() => {
}, [cancelDebouncedSave, cronId]);
const scheduleSave = useCallback(() => {
if (isNewJob) return; // Don't auto-save new jobs
if (!readyRef.current || !draftRef.current) {
pendingSaveRef.current = true;
return;
@@ -330,15 +228,16 @@ const CronJobDetailPage = memo(() => {
isDirtyRef.current = true;
setAutoSaveState({ status: 'saving' });
debouncedSave();
}, [debouncedSave]);
}, [debouncedSave, isNewJob]);
const flushPendingSave = useCallback(() => {
if (isNewJob) return; // Don't auto-save new jobs
if (!pendingSaveRef.current || !draftRef.current) return;
pendingSaveRef.current = false;
isDirtyRef.current = true;
setAutoSaveState({ status: 'saving' });
debouncedSave();
}, [debouncedSave]);
}, [debouncedSave, isNewJob]);
const updateDraft = useCallback(
(patch: Partial<CronJobDraft>) => {
@@ -353,14 +252,13 @@ const CronJobDetailPage = memo(() => {
[scheduleSave],
);
const handleContentChange = useCallback(() => {
if (!readyRef.current || !editor || !editorReady) return;
const nextContent = enableRichRender
? (editor.getDocument('markdown') as unknown as string)
: (editor.getDocument('text') as unknown as string);
contentRef.current = nextContent || '';
scheduleSave();
}, [editor, editorReady, enableRichRender, scheduleSave]);
const handleContentChange = useCallback(
(content: string) => {
contentRef.current = content;
updateDraft({ content });
},
[updateDraft],
);
const handleToggleEnabled = useCallback(
async (enabled: boolean) => {
@@ -378,12 +276,15 @@ const CronJobDetailPage = memo(() => {
[cronId, mutate, refreshCronList],
);
const handleDeleteCronJob = useCallback(() => {
const handleDeleteCronJob = useCallback(async () => {
if (!cronId) return;
modal.confirm({
cancelText: t('cancel', { ns: 'common' }),
centered: true,
content: t('agentCronJobs.confirmDeleteCronJob' as any),
okButtonProps: { danger: true },
okText: t('ok', { ns: 'common' }),
onOk: async () => {
try {
let topicIds: string[] = [];
@@ -416,10 +317,82 @@ const CronJobDetailPage = memo(() => {
message.error('Failed to delete scheduled task');
}
},
title: t('agentCronJobs.confirmDelete'),
title: t('agentCronJobs.deleteCronJob' as any),
});
}, [activeTopicId, cronId, cronListAgentId, modal, refreshTopic, router, switchTopic, t]);
const handleSaveNewJob = useCallback(async () => {
if (!aid) {
message.error('Agent ID is required');
return;
}
const payload = buildUpdateData(draftRef.current, contentRef.current);
if (!payload) {
message.error('Please fill in all required fields');
return;
}
if (!payload.content || !payload.name || !payload.cronPattern) {
message.error('Name and content are required');
return;
}
setAutoSaveState({ status: 'saving' });
try {
const result = await agentCronJobService.create({
agentId: aid,
content: payload.content,
cronPattern: payload.cronPattern,
description: payload.description,
enabled: true,
executionConditions: payload.executionConditions,
maxExecutions: payload.maxExecutions,
name: payload.name,
timezone: payload.timezone,
});
if (result.success && result.data) {
setAutoSaveState({ lastUpdatedTime: new Date(), status: 'saved' });
message.success('Scheduled task created successfully');
refreshCronList();
// Navigate to the newly created job
router.push(`/agent/${aid}/cron/${result.data.id}`);
} else {
throw new Error('Failed to create job');
}
} catch (error) {
console.error('Failed to create cron job:', error);
setAutoSaveState({ status: 'idle' });
message.error('Failed to create scheduled task');
}
}, [aid, buildUpdateData, refreshCronList, router]);
// Initialize draft for new jobs
useEffect(() => {
if (!isNewJob || draft) return;
// Get browser timezone
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const defaultDraft: CronJobDraft = {
content: '',
cronPattern: '0 0 * * *', // Default: daily at midnight
description: '',
maxExecutions: null,
name: '',
scheduleType: 'daily',
timezone: browserTimezone,
triggerTime: dayjs().hour(0).minute(0),
weekdays: [0, 1, 2, 3, 4, 5, 6],
};
setDraft(defaultDraft);
draftRef.current = defaultDraft;
contentRef.current = '';
readyRef.current = true;
}, [isNewJob, draft]);
useEffect(() => {
if (!cronJob) return;
const cronUpdatedAt = cronJob.updatedAt ? new Date(cronJob.updatedAt).toISOString() : null;
@@ -434,230 +407,99 @@ const CronJobDetailPage = memo(() => {
readyRef.current = false;
lastSavedNameRef.current = cronJob.name ?? null;
// Parse cron pattern to extract schedule configuration
const parsed = parseCronPattern(cronJob.cronPattern);
const nextDraft: CronJobDraft = {
content: cronJob.content || '',
cronPattern: cronJob.cronPattern,
description: cronJob.description || '',
hourlyInterval: parsed.hourlyInterval,
maxExecutions: cronJob.maxExecutions ?? null,
maxExecutionsPerDay: cronJob.executionConditions?.maxExecutionsPerDay ?? null,
name: cronJob.name || '',
timeRange: cronJob.executionConditions?.timeRange
? [
dayjs(cronJob.executionConditions.timeRange.start, 'HH:mm'),
dayjs(cronJob.executionConditions.timeRange.end, 'HH:mm'),
]
: undefined,
weekdays: cronJob.executionConditions?.weekdays || [],
scheduleType: parsed.scheduleType,
timezone: cronJob.timezone || 'UTC',
triggerTime: dayjs().hour(parsed.triggerHour).minute(parsed.triggerMinute),
weekdays:
parsed.scheduleType === 'weekly' && parsed.weekdays
? parsed.weekdays
: [0, 1, 2, 3, 4, 5, 6], // Default: all days for weekly
};
setDraft(nextDraft);
draftRef.current = nextDraft;
contentRef.current = nextDraft.content;
pendingContentRef.current = nextDraft.content;
setAutoSaveState({
lastUpdatedTime: resolveDate(cronJob.updatedAt),
status: 'saved',
});
if (editorReady && editor) {
try {
setTimeout(() => {
editor.setDocument(enableRichRender ? 'markdown' : 'text', nextDraft.content);
}, 100);
pendingContentRef.current = null;
readyRef.current = true;
flushPendingSave();
} catch (error) {
console.error('[CronJobDetailPage] Failed to init editor content:', error);
setTimeout(() => {
editor.setDocument(enableRichRender ? 'markdown' : 'text', nextDraft.content);
}, 100);
}
}
}, [cronJob, editor, editorReady, enableRichRender]);
readyRef.current = true;
flushPendingSave();
}, [cronJob, flushPendingSave]);
useEffect(() => {
if (!editorReady || !editor || pendingContentRef.current === null) return;
try {
setTimeout(() => {
editor.setDocument(enableRichRender ? 'markdown' : 'text', pendingContentRef.current);
}, 100);
pendingContentRef.current = null;
readyRef.current = true;
flushPendingSave();
} catch (error) {
console.error('[CronJobDetailPage] Failed to init editor content:', error);
setTimeout(() => {
console.log('setDocument timeout', pendingContentRef.current);
editor.setDocument(enableRichRender ? 'markdown' : 'text', pendingContentRef.current);
}, 100);
}
}, [editor, editorReady, enableRichRender]);
if (!ENABLE_BUSINESS_FEATURES) {
if (!enableBusinessFeatures) {
return null;
}
return (
<Flexbox flex={1} height={'100%'}>
<NavHeader left={<AutoSaveHintSlot />} />
<NavHeader
left={!isNewJob ? <AutoSaveHintSlot /> : undefined}
right={
!isNewJob ? (
<ActionIcon
icon={Trash2}
onClick={handleDeleteCronJob}
title={t('delete', { ns: 'common' })}
/>
) : undefined
}
/>
<Flexbox flex={1} style={{ overflowY: 'auto' }}>
<WideScreenContainer paddingBlock={16}>
{isLoading && <Loading debugId="CronJobDetailPage" />}
{!isLoading && !cronJob && (
{!isLoading && !cronJob && !isNewJob && (
<Empty
description={t('agentCronJobs.empty.description')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
{!isLoading && cronJob && (
{!isLoading && (cronJob || isNewJob) && draft && (
<Flexbox gap={24}>
<Flexbox align="center" gap={16} horizontal justify="space-between">
<Flexbox gap={6} style={{ flex: 1, minWidth: 0 }}>
<Input
onChange={(e) => updateDraft({ name: e.target.value })}
placeholder={t('agentCronJobs.form.name.placeholder')}
style={{
fontSize: 28,
fontWeight: 600,
padding: 0,
}}
value={draft?.name ?? cronJob.name ?? ''}
variant={'borderless'}
/>
</Flexbox>
<Flexbox align="center" gap={8} horizontal>
<ActionIcon
icon={Trash2}
onClick={handleDeleteCronJob}
size={'small'}
title={t('delete', { ns: 'common' })}
/>
<Text type="secondary">
{t(
cronJob?.enabled
? 'agentCronJobs.status.enabled'
: 'agentCronJobs.status.disabled',
)}
</Text>
<Switch
defaultChecked={cronJob?.enabled ?? false}
disabled={!cronJob}
key={cronJob?.id ?? 'cron-switch'}
onChange={handleToggleEnabled}
size="small"
/>
</Flexbox>
</Flexbox>
<CronJobHeader
enabled={cronJob?.enabled ?? false}
isNewJob={isNewJob}
name={draft.name}
onNameChange={(name) => updateDraft({ name })}
onToggleEnabled={handleToggleEnabled}
/>
<Card size="small" style={{ borderRadius: 12 }} styles={{ body: { padding: 12 } }}>
<Flexbox gap={12}>
{summaryTags.length > 0 && (
<Flexbox align="center" gap={8} horizontal style={{ flexWrap: 'wrap' }}>
{summaryTags.map((tag) => (
<Tag key={tag.key} variant={'filled'}>
{tag.label}
</Tag>
))}
</Flexbox>
)}
<CronJobScheduleConfig
hourlyInterval={draft.hourlyInterval}
maxExecutions={draft.maxExecutions}
onScheduleChange={(updates) => updateDraft(updates)}
scheduleType={draft.scheduleType}
timezone={draft.timezone}
triggerTime={draft.triggerTime}
weekdays={draft.weekdays}
/>
<Flexbox align="center" gap={8} horizontal style={{ flexWrap: 'wrap' }}>
<Tag variant={'borderless'}>{t('agentCronJobs.schedule')}</Tag>
<Select
onChange={(value) => updateDraft({ cronPattern: value })}
options={CRON_PATTERNS.map((pattern) => ({
label: t(pattern.label as any),
value: pattern.value,
}))}
size="small"
style={{ minWidth: 160 }}
value={draft?.cronPattern ?? cronJob.cronPattern}
/>
<TimePicker.RangePicker
format="HH:mm"
onChange={(value) =>
updateDraft({
timeRange:
value && value.length === 2
? [value[0] as Dayjs, value[1] as Dayjs]
: undefined,
})
}
placeholder={[
t('agentCronJobs.form.timeRange.start'),
t('agentCronJobs.form.timeRange.end'),
]}
size="small"
value={
draft?.timeRange ??
(resolvedTimeRange as [Dayjs, Dayjs] | undefined) ??
null
}
/>
<Checkbox.Group
onChange={(values) => updateDraft({ weekdays: values as number[] })}
options={WEEKDAY_OPTIONS}
style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}
value={draft?.weekdays ?? resolvedWeekdays}
/>
</Flexbox>
<CronJobContentEditor
enableRichRender={enableRichRender}
initialValue={cronJob?.content || ''}
onChange={handleContentChange}
/>
<Flexbox align="center" gap={8} horizontal style={{ flexWrap: 'wrap' }}>
<Tag variant={'borderless'}>{t('agentCronJobs.maxExecutions')}</Tag>
<InputNumber
min={1}
onChange={(value) => updateDraft({ maxExecutions: value ?? null })}
placeholder={t('agentCronJobs.form.maxExecutions.placeholder')}
size="small"
style={{ width: 160 }}
value={draft?.maxExecutions ?? cronJob.maxExecutions ?? null}
/>
</Flexbox>
</Flexbox>
</Card>
<Flexbox gap={12}>
<Flexbox align="center" gap={6} horizontal>
<Icon icon={Clock} size={16} />
<Text style={{ fontWeight: 600 }}>{t('agentCronJobs.content')}</Text>
</Flexbox>
<Card
size="small"
style={{ borderRadius: 12, overflow: 'hidden' }}
styles={{ body: { padding: 0 } }}
>
{enableRichRender && <InlineToolbar editor={editor} editorState={editorState} />}
<Flexbox padding={16} style={{ minHeight: 220 }}>
<Editor
content={''}
editor={editor}
lineEmptyPlaceholder={t('agentCronJobs.form.content.placeholder')}
onInit={() => setEditorReady(true)}
onTextChange={handleContentChange}
placeholder={t('agentCronJobs.form.content.placeholder')}
plugins={
enableRichRender
? [
ReactListPlugin,
ReactCodePlugin,
ReactCodemirrorPlugin,
ReactHRPlugin,
ReactLinkPlugin,
ReactTablePlugin,
ReactMathPlugin,
]
: undefined
}
style={{ paddingBottom: 48 }}
type={'text'}
variant={'chat'}
/>
</Flexbox>
</Card>
</Flexbox>
{isNewJob && (
<CronJobSaveButton
disabled={!draft.name || !draft.content}
loading={false}
onSave={handleSaveNewJob}
/>
)}
</Flexbox>
)}
</WideScreenContainer>

View File

@@ -2,38 +2,43 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { Flexbox } from '@lobehub/ui';
import { Modal, Typography } from 'antd';
import { Typography } from 'antd';
import { Clock } from 'lucide-react';
import { memo, useRef, useState } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import urlJoin from 'url-join';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { useAgentStore } from '@/store/agent';
import CronJobCards from './CronJobCards';
import CronJobForm from './CronJobForm';
import { useAgentCronJobs } from './hooks/useAgentCronJobs';
const { Title } = Typography;
interface AgentCronJobsProps {
onFormModalChange?: (show: boolean) => void;
showFormModal?: boolean;
}
const AgentCronJobs = memo<AgentCronJobsProps>(({ showFormModal, onFormModalChange }) => {
const AgentCronJobs = memo(() => {
const { t } = useTranslation('setting');
const agentId = useAgentStore((s) => s.activeAgentId);
const [internalShowForm, setInternalShowForm] = useState(false);
const [editingJob, setEditingJob] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const formRef = useRef<any>(null);
const router = useQueryRoute();
// Use external control if provided, otherwise use internal state
const showForm = showFormModal ?? internalShowForm;
const setShowForm = onFormModalChange ?? setInternalShowForm;
const { cronJobs, loading, deleteCronJob } = useAgentCronJobs(agentId);
const { cronJobs, loading, createCronJob, updateCronJob, deleteCronJob } =
useAgentCronJobs(agentId);
// Edit: Navigate to cron job detail page
const handleEdit = useCallback(
(jobId: string) => {
if (!agentId) return;
router.push(urlJoin('/agent', agentId, 'cron', jobId));
},
[agentId, router],
);
// Delete: Keep the existing delete logic
const handleDelete = useCallback(
async (jobId: string) => {
await deleteCronJob(jobId);
},
[deleteCronJob],
);
if (!ENABLE_BUSINESS_FEATURES) return null;
@@ -41,89 +46,29 @@ const AgentCronJobs = memo<AgentCronJobsProps>(({ showFormModal, onFormModalChan
return null;
}
const handleCreate = async (data: any) => {
setSubmitting(true);
try {
await createCronJob(data);
setShowForm(false);
} finally {
setSubmitting(false);
}
};
const handleEdit = (jobId: string) => {
setEditingJob(jobId);
setShowForm(true);
};
const handleUpdate = async (data: any) => {
if (editingJob) {
setSubmitting(true);
try {
await updateCronJob(editingJob, data);
setShowForm(false);
setEditingJob(null);
} finally {
setSubmitting(false);
}
}
};
const handleCancel = () => {
setShowForm(false);
setEditingJob(null);
formRef.current?.resetFields();
};
const handleModalOk = () => {
formRef.current?.submit();
};
const handleDelete = async (jobId: string) => {
await deleteCronJob(jobId);
};
const hasCronJobs = cronJobs && cronJobs.length > 0;
// Only show if there are jobs
if (!hasCronJobs) {
return null;
}
return (
<>
{/* Show cards section only if there are jobs */}
{hasCronJobs && (
<Flexbox gap={12} style={{ marginBottom: 16, marginTop: 16 }}>
<Title level={5} style={{ margin: 0 }}>
<Flexbox align="center" gap={8} horizontal>
<Clock size={16} />
{t('agentCronJobs.title')}
</Flexbox>
</Title>
<CronJobCards
cronJobs={cronJobs}
loading={loading}
onDelete={handleDelete}
onEdit={handleEdit}
/>
<Flexbox gap={12} style={{ marginBottom: 16, marginTop: 16 }}>
<Title level={5} style={{ margin: 0 }}>
<Flexbox align="center" gap={8} horizontal>
<Clock size={16} />
{t('agentCronJobs.title')}
</Flexbox>
)}
</Title>
{/* Form Modal */}
<Modal
confirmLoading={submitting}
okText={editingJob ? t('agentCronJobs.save' as any) : t('agentCronJobs.create' as any)}
onCancel={handleCancel}
onOk={handleModalOk}
open={showForm}
title={editingJob ? t('agentCronJobs.editJob') : t('agentCronJobs.addJob')}
width={640}
>
<CronJobForm
editingJob={editingJob ? cronJobs?.find((job) => job.id === editingJob) : undefined}
formRef={formRef}
onCancel={handleCancel}
onSubmit={editingJob ? handleUpdate : handleCreate}
/>
</Modal>
</>
<CronJobCards
cronJobs={cronJobs}
loading={loading}
onDelete={handleDelete}
onEdit={handleEdit}
/>
</Flexbox>
);
});

View File

@@ -2,7 +2,7 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { Button, Flexbox } from '@lobehub/ui';
import { Divider, message } from 'antd';
import { Divider } from 'antd';
import isEqual from 'fast-deep-equal';
import { Clock, PlayIcon } from 'lucide-react';
import React, { memo, useCallback } from 'react';
@@ -11,7 +11,6 @@ import urlJoin from 'url-join';
import ModelSelect from '@/features/ModelSelect';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { agentCronJobService } from '@/services/agentCronJob';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
@@ -29,25 +28,10 @@ const ProfileEditor = memo(() => {
const switchTopic = useChatStore((s) => s.switchTopic);
const router = useQueryRoute();
const handleCreateCronJob = useCallback(async () => {
const handleCreateCronJob = useCallback(() => {
if (!agentId) return;
try {
const result = await agentCronJobService.create({
agentId,
content: t('agentCronJobs.form.content.placeholder') || 'This is a cron job',
cronPattern: '*/30 * * * *',
enabled: true,
name: t('agentCronJobs.addJob') || 'Cron Job Task',
});
if (result.success) {
router.push(urlJoin('/agent', agentId, 'cron', result.data.id));
}
} catch (error) {
console.error('Failed to create cron job:', error);
message.error('Failed to create scheduled task');
}
}, [agentId, router, t]);
router.push(urlJoin('/agent', agentId, 'cron', 'new'));
}, [agentId, router]);
return (
<>

View File

@@ -136,12 +136,7 @@ const AgentProfilePopup = memo<AgentProfilePopupProps>(({ agent, groupId, childr
</Text>
{/* Settings Button */}
<Flexbox
align="center"
horizontal
justify="flex-end"
style={{ paddingBlockStart: 0 }}
>
<Flexbox align="center" horizontal justify="flex-end" style={{ paddingBlockStart: 0 }}>
<ActionIcon
icon={Settings}
onClick={handleSettings}

View File

@@ -74,6 +74,7 @@ export const getServerGlobalConfig = async () => {
defaultAgent: {
config: parseAgentConfig(DEFAULT_AGENT_CONFIG),
},
enableBusinessFeatures: ENABLE_BUSINESS_FEATURES,
enableEmailVerification: authEnv.AUTH_EMAIL_VERIFICATION,
enableKlavis: !!klavisEnv.KLAVIS_API_KEY,
enableLobehubSkill: !!(appEnv.MARKET_TRUSTED_CLIENT_SECRET && appEnv.MARKET_TRUSTED_CLIENT_ID),

View File

@@ -89,10 +89,7 @@ class ChatGroupService {
* Batch create virtual agents and add them to an existing group.
* This is more efficient than calling createAgentOnly multiple times.
*/
batchCreateAgentsInGroup = (
groupId: string,
agents: GroupMemberConfig[],
) => {
batchCreateAgentsInGroup = (groupId: string, agents: GroupMemberConfig[]) => {
return lambdaClient.group.batchCreateAgentsInGroup.mutate({
agents: agents as Partial<AgentItem>[],
groupId,

View File

@@ -3,6 +3,7 @@ import { type ServerConfigStore } from './store';
export const featureFlagsSelectors = (s: ServerConfigStore) => s.featureFlags;
export const serverConfigSelectors = {
enableBusinessFeatures: (s: ServerConfigStore) => s.serverConfig.enableBusinessFeatures || false,
enableEmailVerification: (s: ServerConfigStore) =>
s.serverConfig.enableEmailVerification || false,
enableKlavis: (s: ServerConfigStore) => s.serverConfig.enableKlavis || false,