mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✨ 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:
@@ -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",
|
||||
|
||||
@@ -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": "已启用",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
179
src/app/[variants]/(main)/agent/cron/[cronId]/CronConfig.ts
Normal file
179
src/app/[variants]/(main)/agent/cron/[cronId]/CronConfig.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user