diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index 0a5e4bcd..a6159dd7 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -194,6 +194,34 @@ const translations: Record> = { tasks_active: '运行中', tasks_paused: '已暂停', tasks_empty: '暂无定时任务', + tasks_empty_guide: '在对话中告诉 Agent「每天 9 点提醒我…」即可创建定时任务', + tasks_go_chat: '去对话创建', + tasks_next_run: '下次执行', + tasks_loading: '加载定时任务中...', + task_edit_title: '编辑任务', + task_name: '名称', + task_enabled: '启用', + task_schedule_type: '调度类型', + task_type_cron: 'Cron 表达式', + task_type_interval: '固定间隔', + task_type_once: '单次执行', + task_cron_expr: 'Cron 表达式', + task_cron_hint: '如 0 9 * * * 表示每天 9 点', + task_interval_seconds: '间隔(秒)', + task_once_time: '执行时间', + task_action_type: '动作类型', + task_action_send: '发送消息', + task_action_agent: 'Agent 任务', + task_message_content: '消息内容', + task_task_description: '任务描述', + task_channel: '通道', + task_receiver: '接收者', + task_channel_locked: '通道与接收者创建后不可修改', + task_save: '保存', + task_cancel: '取消', + task_delete: '删除', + task_delete_confirm: '确定删除该任务吗?此操作不可撤销。', + task_save_error: '保存失败', logs_title: '日志', logs_desc: '实时日志输出 (run.log)', logs_live: '实时', @@ -399,6 +427,34 @@ const translations: Record> = { tasks_active: 'Active', tasks_paused: 'Paused', tasks_empty: 'No scheduled tasks', + tasks_empty_guide: 'Tell the agent "remind me every day at 9am…" in chat to create a scheduled task', + tasks_go_chat: 'Create in chat', + tasks_next_run: 'Next run', + tasks_loading: 'Loading scheduled tasks...', + task_edit_title: 'Edit Task', + task_name: 'Name', + task_enabled: 'Enabled', + task_schedule_type: 'Schedule type', + task_type_cron: 'Cron', + task_type_interval: 'Interval', + task_type_once: 'Once', + task_cron_expr: 'Cron expression', + task_cron_hint: 'e.g. 0 9 * * * runs daily at 9am', + task_interval_seconds: 'Interval (seconds)', + task_once_time: 'Run at', + task_action_type: 'Action type', + task_action_send: 'Send message', + task_action_agent: 'Agent task', + task_message_content: 'Message content', + task_task_description: 'Task description', + task_channel: 'Channel', + task_receiver: 'Receiver', + task_channel_locked: 'Channel and receiver cannot be changed after creation', + task_save: 'Save', + task_cancel: 'Cancel', + task_delete: 'Delete', + task_delete_confirm: 'Delete this task? This cannot be undone.', + task_save_error: 'Failed to save', logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)', logs_live: 'Live', diff --git a/desktop/src/renderer/src/pages/TasksPage.tsx b/desktop/src/renderer/src/pages/TasksPage.tsx index 722d4d3b..aff099e9 100644 --- a/desktop/src/renderer/src/pages/TasksPage.tsx +++ b/desktop/src/renderer/src/pages/TasksPage.tsx @@ -1,20 +1,43 @@ -import React, { useState, useEffect } from 'react' +import React, { useEffect, useState } from 'react' +import { Loader2, Clock, CalendarClock } from 'lucide-react' +import { useNavigate } from 'react-router-dom' import { t } from '../i18n' import apiClient from '../api/client' -import type { SchedulerTask } from '../types' +import type { SchedulerTask, TaskSchedule, TaskAction } from '../types' +import { Modal, Btn, Toggle, TextInput, Dropdown } from './settings/primitives' interface TasksPageProps { baseUrl: string } +// Human-readable schedule summary, mirroring the web console. +const scheduleSummary = (s: TaskSchedule): string => { + if (s.type === 'cron') return s.expression || 'cron' + if (s.type === 'interval') { + const sec = s.seconds || 0 + const h = Math.floor(sec / 3600) + const m = Math.floor((sec % 3600) / 60) + const r = sec % 60 + const parts: string[] = [] + if (h) parts.push(`${h}h`) + if (m) parts.push(`${m}m`) + if (r || parts.length === 0) parts.push(`${r}s`) + return parts.join(' ') + } + return s.type || 'once' +} + +const formatNextRun = (iso?: string): string => { + if (!iso) return '--' + const d = new Date(iso) + return isNaN(d.getTime()) ? '--' : d.toLocaleString() +} + const TasksPage: React.FC = ({ baseUrl }) => { + const navigate = useNavigate() const [tasks, setTasks] = useState([]) const [loading, setLoading] = useState(true) - - useEffect(() => { - apiClient.setBaseUrl(baseUrl) - loadTasks() - }, [baseUrl]) + const [editing, setEditing] = useState(null) const loadTasks = async () => { try { @@ -23,68 +46,269 @@ const TasksPage: React.FC = ({ baseUrl }) => { setTasks(data || []) } catch (err) { console.error('Failed to load tasks:', err) + setTasks([]) } finally { setLoading(false) } } - return ( -
-
-
-
-

{t('tasks_title')}

-

{t('tasks_desc')}

-
-
+ useEffect(() => { + apiClient.setBaseUrl(baseUrl) + void loadTasks() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [baseUrl]) - {loading ? ( -
-
- -
-

Loading...

-
- ) : tasks.length > 0 ? ( -
- {tasks.map((task) => ( -
-
-
- - {task.name} -
- - {task.enabled ? t('tasks_active') : t('tasks_paused')} - -
-
- - {task.schedule?.type}:{' '} - - {task.schedule?.expression || task.schedule?.run_at || `${task.schedule?.seconds ?? ''}s`} - - - {task.next_run_at && Next: {task.next_run_at}} -
-
- ))} -
- ) : ( -
-
- -
-

{t('tasks_empty')}

-
- )} + const toggle = async (task: SchedulerTask, enabled: boolean) => { + // Optimistic flip; revert on failure. + setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, enabled } : x))) + try { + await apiClient.toggleTask(task.id, enabled) + } catch { + setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, enabled: !enabled } : x))) + } + } + + return ( +
+
+

{t('tasks_title')}

+

{t('tasks_desc')}

+ +
+
+ {loading ? ( +
+ + {t('tasks_loading')} +
+ ) : tasks.length === 0 ? ( +
+ +

{t('tasks_empty')}

+

{t('tasks_empty_guide')}

+ +
+ ) : ( +
+ {tasks.map((task) => { + const content = task.action?.content || task.action?.task_description || '' + return ( +
setEditing(task)} + className={`rounded-card border border-default bg-surface p-4 cursor-pointer hover:border-strong transition-colors ${ + task.enabled ? '' : 'opacity-60' + }`} + > +
+ + {task.name || task.id} +
+ {scheduleSummary(task.schedule)} +
+ {content &&

{content}

} +
e.stopPropagation()} + > + + + {t('tasks_next_run')}: {formatNextRun(task.next_run_at)} + +
+ toggle(task, v)} /> +
+
+ ) + })} +
+ )} +
+
+ + {editing && ( + setEditing(null)} + onSaved={() => { + setEditing(null) + void loadTasks() + }} + onDeleted={() => { + setEditing(null) + void loadTasks() + }} + /> + )}
) } +const TaskEditModal: React.FC<{ + task: SchedulerTask + onClose: () => void + onSaved: () => void + onDeleted: () => void +}> = ({ task, onClose, onSaved, onDeleted }) => { + const [name, setName] = useState(task.name || '') + const [enabled, setEnabled] = useState(task.enabled) + const [schedType, setSchedType] = useState(task.schedule.type || 'cron') + const [cron, setCron] = useState(task.schedule.expression || '') + const [interval, setIntervalVal] = useState(task.schedule.seconds ? String(task.schedule.seconds) : '') + const [runAt, setRunAt] = useState(task.schedule.run_at ? task.schedule.run_at.slice(0, 16) : '') + const [actionType, setActionType] = useState(task.action.type || 'send_message') + const [content, setContent] = useState(task.action.content || task.action.task_description || '') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const buildSchedule = (): TaskSchedule => { + if (schedType === 'cron') return { type: 'cron', expression: cron.trim() } + if (schedType === 'interval') return { type: 'interval', seconds: Number(interval) || 0 } + return { type: 'once', run_at: runAt } + } + + const buildAction = (): TaskAction => { + const a: TaskAction = { ...task.action, type: actionType } + if (actionType === 'send_message') a.content = content + else a.task_description = content + return a + } + + const save = async () => { + setSaving(true) + setError('') + try { + await apiClient.updateTask(task.id, { + name: name.trim(), + enabled, + schedule: buildSchedule(), + action: buildAction(), + }) + onSaved() + } catch (e) { + setError(e instanceof Error ? e.message : t('task_save_error')) + } finally { + setSaving(false) + } + } + + const del = async () => { + if (!window.confirm(t('task_delete_confirm'))) return + setSaving(true) + try { + await apiClient.deleteTask(task.id) + onDeleted() + } catch { + setSaving(false) + } + } + + return ( + + + {t('task_delete')} + + + {t('task_cancel')} + + + {t('task_save')} + + + } + > + + setName(e.target.value)} /> + + +
+ {t('task_enabled')} + +
+ + + setSchedType(v as TaskSchedule['type'])} + options={[ + { value: 'cron', label: t('task_type_cron') }, + { value: 'interval', label: t('task_type_interval') }, + { value: 'once', label: t('task_type_once') }, + ]} + /> + + + {schedType === 'cron' && ( + + setCron(e.target.value)} placeholder="0 9 * * *" className="font-mono" /> + + )} + {schedType === 'interval' && ( + + setIntervalVal(e.target.value)} /> + + )} + {schedType === 'once' && ( + + setRunAt(e.target.value)} /> + + )} + + + setActionType(v as TaskAction['type'])} + options={[ + { value: 'send_message', label: t('task_action_send') }, + { value: 'agent_task', label: t('task_action_agent') }, + ]} + /> + + + +