mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
feat: enhance scheduled tasks with flexible scheduling, run now, execution logs, and edit support
- Add 7 frequency modes (every N min/hours, daily, weekdays, specific days, monthly, custom cron) with progressive disclosure UI - Add "Run Now" button with confirmation popover and fire-and-forget API - Add execution logs panel (TaskRunsPanel) with auto-polling and accordion behavior - Add task edit mode with cron reverse-parsing (parseCron) to populate form - Add server-side extractAssistantText to store meaningful AI responses instead of raw NDJSON - Fix session linking: pass --session-id to CLI subprocess so "View conversation" navigates to actual content - Fix MACRO undefined error by adding --preload to Bun.spawn - Add confirmation popovers for all destructive actions (run/disable/delete) - Add DayOfWeekPicker component for specific-days scheduling - Add cronDescribe utility with i18n support and unit tests - Display task creation time and last run time - Add ~50 i18n keys (en/zh) for all new UI elements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
41fb22790a
commit
983d83f86b
@ -22,6 +22,10 @@ export const tasksApi = {
|
||||
return api.delete<{ ok: true }>(`/api/scheduled-tasks/${id}`)
|
||||
},
|
||||
|
||||
runTask(id: string) {
|
||||
return api.post<{ ok: true }>(`/api/scheduled-tasks/${id}/run`, {})
|
||||
},
|
||||
|
||||
getRecentRuns(limit = 50) {
|
||||
return api.get<RunsResponse>(`/api/scheduled-tasks/runs?limit=${limit}`)
|
||||
},
|
||||
|
||||
57
desktop/src/components/tasks/DayOfWeekPicker.tsx
Normal file
57
desktop/src/components/tasks/DayOfWeekPicker.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type Props = {
|
||||
selected: number[]
|
||||
onChange: (days: number[]) => void
|
||||
}
|
||||
|
||||
// Display order: Mon(1) → Sun(0), matching Chinese convention
|
||||
const DAY_ORDER = [1, 2, 3, 4, 5, 6, 0]
|
||||
|
||||
const DAY_KEYS = [
|
||||
'newTask.daySun',
|
||||
'newTask.dayMon',
|
||||
'newTask.dayTue',
|
||||
'newTask.dayWed',
|
||||
'newTask.dayThu',
|
||||
'newTask.dayFri',
|
||||
'newTask.daySat',
|
||||
] as const
|
||||
|
||||
export function DayOfWeekPicker({ selected, onChange }: Props) {
|
||||
const t = useTranslation()
|
||||
|
||||
const toggle = (day: number) => {
|
||||
if (selected.includes(day)) {
|
||||
// Don't allow deselecting the last day
|
||||
if (selected.length <= 1) return
|
||||
onChange(selected.filter((d) => d !== day))
|
||||
} else {
|
||||
onChange([...selected, day])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
{DAY_ORDER.map((day) => {
|
||||
const isActive = selected.includes(day)
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => toggle(day)}
|
||||
className={`
|
||||
w-8 h-8 rounded-full text-xs font-medium transition-colors
|
||||
${isActive
|
||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] border border-[var(--color-border-focus)]'
|
||||
: 'bg-[var(--color-surface)] text-[var(--color-text-tertiary)] border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{t(DAY_KEYS[day] as any)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -5,100 +5,146 @@ import { Modal } from '../shared/Modal'
|
||||
import { Input } from '../shared/Input'
|
||||
import { Button } from '../shared/Button'
|
||||
import { PromptEditor } from './PromptEditor'
|
||||
import { DayOfWeekPicker } from './DayOfWeekPicker'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { describeCron, isValidCron, parseCron, type FrequencyKey } from '../../lib/cronDescribe'
|
||||
import type { PermissionMode } from '../../types/settings'
|
||||
import type { CronTask } from '../../types/task'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
editTask?: CronTask
|
||||
}
|
||||
|
||||
type FrequencyKey = 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'monthly'
|
||||
const MINUTE_INTERVALS = [5, 10, 15, 20, 30]
|
||||
const HOUR_INTERVALS = [1, 2, 3, 4, 6, 8, 12]
|
||||
const MINUTE_OFFSETS = [0, 15, 30, 45]
|
||||
|
||||
function buildCron(freq: FrequencyKey, time: string): string {
|
||||
function buildCron(
|
||||
freq: FrequencyKey,
|
||||
time: string,
|
||||
opts: {
|
||||
minuteInterval: number
|
||||
hourInterval: number
|
||||
minuteOffset: number
|
||||
selectedDays: number[]
|
||||
monthDay: number
|
||||
customCron: string
|
||||
},
|
||||
): string {
|
||||
const [hours, minutes] = time.split(':').map(Number)
|
||||
switch (freq) {
|
||||
case 'hourly': return '0 * * * *'
|
||||
case 'daily': return `${minutes} ${hours} * * *`
|
||||
case 'weekdays': return `${minutes} ${hours} * * 1-5`
|
||||
case 'weekly': return `${minutes} ${hours} * * 1`
|
||||
case 'monthly': return `${minutes} ${hours} 1 * *`
|
||||
case 'everyNMinutes':
|
||||
return `*/${opts.minuteInterval} * * * *`
|
||||
case 'everyNHours':
|
||||
return `${opts.minuteOffset} */${opts.hourInterval} * * *`
|
||||
case 'daily':
|
||||
return `${minutes} ${hours} * * *`
|
||||
case 'weekdays':
|
||||
return `${minutes} ${hours} * * 1-5`
|
||||
case 'specificDays':
|
||||
return `${minutes} ${hours} * * ${[...opts.selectedDays].sort((a, b) => a - b).join(',')}`
|
||||
case 'monthly':
|
||||
return `${minutes} ${hours} ${opts.monthDay} * *`
|
||||
case 'customCron':
|
||||
return opts.customCron.trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function NewTaskModal({ open, onClose }: Props) {
|
||||
export function NewTaskModal({ open, onClose, editTask }: Props) {
|
||||
const t = useTranslation()
|
||||
const { createTask } = useTaskStore()
|
||||
const { createTask, updateTask } = useTaskStore()
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId)
|
||||
const defaultWorkDir = activeSession?.workDir || ''
|
||||
|
||||
const FREQUENCY_OPTIONS: Array<{ value: FrequencyKey; label: string; showTime: boolean }> = [
|
||||
{ value: 'hourly', label: t('newTask.hourly'), showTime: false },
|
||||
{ value: 'daily', label: t('newTask.daily'), showTime: true },
|
||||
{ value: 'weekdays', label: t('newTask.weekdays'), showTime: true },
|
||||
{ value: 'weekly', label: t('newTask.weekly'), showTime: true },
|
||||
{ value: 'monthly', label: t('newTask.monthly'), showTime: true },
|
||||
const isEdit = !!editTask
|
||||
const parsed = editTask ? parseCron(editTask.cron) : null
|
||||
|
||||
const FREQUENCY_OPTIONS: Array<{ value: FrequencyKey; label: string }> = [
|
||||
{ value: 'everyNMinutes', label: t('newTask.everyNMinutes') },
|
||||
{ value: 'everyNHours', label: t('newTask.everyNHours') },
|
||||
{ value: 'daily', label: t('newTask.daily') },
|
||||
{ value: 'weekdays', label: t('newTask.weekdays') },
|
||||
{ value: 'specificDays', label: t('newTask.specificDays') },
|
||||
{ value: 'monthly', label: t('newTask.monthly') },
|
||||
{ value: 'customCron', label: t('newTask.customCron') },
|
||||
]
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [frequency, setFrequency] = useState<FrequencyKey>('daily')
|
||||
const [time, setTime] = useState('09:00')
|
||||
const [model, setModel] = useState('')
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
|
||||
const [folderPath, setFolderPath] = useState(defaultWorkDir)
|
||||
const [useWorktree, setUseWorktree] = useState(false)
|
||||
const [name, setName] = useState(editTask?.name || '')
|
||||
const [description, setDescription] = useState(editTask?.description || '')
|
||||
const [prompt, setPrompt] = useState(editTask?.prompt || '')
|
||||
const [frequency, setFrequency] = useState<FrequencyKey>(parsed?.frequency || 'daily')
|
||||
const [time, setTime] = useState(parsed?.time || '09:00')
|
||||
const [model, setModel] = useState(editTask?.model || '')
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>((editTask?.permissionMode as PermissionMode) || 'default')
|
||||
const [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir)
|
||||
const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const canSubmit = name.trim() && description.trim() && prompt.trim()
|
||||
const showTime = FREQUENCY_OPTIONS.find((o) => o.value === frequency)?.showTime ?? false
|
||||
// Enhanced scheduling state
|
||||
const [minuteInterval, setMinuteInterval] = useState(parsed?.minuteInterval || 15)
|
||||
const [hourInterval, setHourInterval] = useState(parsed?.hourInterval || 1)
|
||||
const [minuteOffset, setMinuteOffset] = useState(parsed?.minuteOffset || 0)
|
||||
const [selectedDays, setSelectedDays] = useState<number[]>(parsed?.selectedDays || [1])
|
||||
const [monthDay, setMonthDay] = useState(parsed?.monthDay || 1)
|
||||
const [customCron, setCustomCron] = useState(parsed?.customCron || '0 9 * * *')
|
||||
|
||||
const showTime = ['daily', 'weekdays', 'specificDays', 'monthly'].includes(frequency)
|
||||
|
||||
const cronValue = buildCron(frequency, time, {
|
||||
minuteInterval, hourInterval, minuteOffset, selectedDays, monthDay, customCron,
|
||||
})
|
||||
|
||||
const canSubmit =
|
||||
name.trim() &&
|
||||
description.trim() &&
|
||||
prompt.trim() &&
|
||||
(frequency !== 'customCron' || isValidCron(customCron)) &&
|
||||
(frequency !== 'specificDays' || selectedDays.length > 0)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createTask({
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
cron: buildCron(frequency, time),
|
||||
cron: cronValue,
|
||||
prompt: prompt.trim(),
|
||||
enabled: true,
|
||||
recurring: true,
|
||||
model: model || undefined,
|
||||
permissionMode: permissionMode !== 'default' ? permissionMode : undefined,
|
||||
folderPath: folderPath.trim() || undefined,
|
||||
useWorktree: useWorktree || undefined,
|
||||
})
|
||||
// Reset form
|
||||
setName('')
|
||||
setDescription('')
|
||||
setPrompt('')
|
||||
setFrequency('daily')
|
||||
setTime('09:00')
|
||||
setModel('')
|
||||
setPermissionMode('default')
|
||||
setFolderPath('')
|
||||
setUseWorktree(false)
|
||||
}
|
||||
if (isEdit) {
|
||||
await updateTask(editTask!.id, payload)
|
||||
} else {
|
||||
await createTask({ ...payload, enabled: true, recurring: true })
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to create task:', err)
|
||||
console.error(`Failed to ${isEdit ? 'update' : 'create'} task:`, err)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectClass = 'w-full h-10 px-3 pr-8 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)] appearance-none cursor-pointer'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('newTask.title')}
|
||||
title={isEdit ? t('tasks.editTitle') : t('newTask.title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>{t('newTask.create')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
|
||||
{isEdit ? t('tasks.saveChanges') : t('newTask.create')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@ -149,7 +195,7 @@ export function NewTaskModal({ open, onClose }: Props) {
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value as FrequencyKey)}
|
||||
className="w-full h-10 px-3 pr-8 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)] appearance-none cursor-pointer"
|
||||
className={selectClass}
|
||||
>
|
||||
{FREQUENCY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
@ -161,7 +207,95 @@ export function NewTaskModal({ open, onClose }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time picker — shown when frequency supports specific time */}
|
||||
{/* Sub-controls based on frequency */}
|
||||
{frequency === 'everyNMinutes' && (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={minuteInterval}
|
||||
onChange={(e) => setMinuteInterval(Number(e.target.value))}
|
||||
className={selectClass}
|
||||
>
|
||||
{MINUTE_INTERVALS.map((n) => (
|
||||
<option key={n} value={n}>{t('newTask.intervalMinutes', { n })}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
expand_more
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frequency === 'everyNHours' && (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<select
|
||||
value={hourInterval}
|
||||
onChange={(e) => setHourInterval(Number(e.target.value))}
|
||||
className={selectClass}
|
||||
>
|
||||
{HOUR_INTERVALS.map((n) => (
|
||||
<option key={n} value={n}>{t('newTask.intervalHours', { n })}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
expand_more
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative flex-1">
|
||||
<select
|
||||
value={minuteOffset}
|
||||
onChange={(e) => setMinuteOffset(Number(e.target.value))}
|
||||
className={selectClass}
|
||||
>
|
||||
{MINUTE_OFFSETS.map((m) => (
|
||||
<option key={m} value={m}>{t('newTask.atMinute', { m: m.toString().padStart(2, '0') })}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
expand_more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frequency === 'specificDays' && (
|
||||
<DayOfWeekPicker selected={selectedDays} onChange={setSelectedDays} />
|
||||
)}
|
||||
|
||||
{frequency === 'monthly' && (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={monthDay}
|
||||
onChange={(e) => setMonthDay(Number(e.target.value))}
|
||||
className={selectClass}
|
||||
>
|
||||
{Array.from({ length: 28 }, (_, i) => i + 1).map((d) => (
|
||||
<option key={d} value={d}>{t('newTask.onMonthDay', { d })}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
expand_more
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frequency === 'customCron' && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={customCron}
|
||||
onChange={(e) => setCustomCron(e.target.value)}
|
||||
placeholder={t('newTask.cronFormatHint')}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] font-[var(--font-mono)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">{t('newTask.cronFormatHint')}</span>
|
||||
{customCron.trim() && !isValidCron(customCron) && (
|
||||
<span className="text-xs text-[var(--color-error)]">{t('newTask.invalidCron')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time picker — shown for daily, weekdays, specificDays, monthly */}
|
||||
{showTime && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
@ -174,6 +308,17 @@ export function NewTaskModal({ open, onClose }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cron preview */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] text-xs text-[var(--color-text-secondary)]">
|
||||
<span className="material-symbols-outlined text-[16px]">schedule</span>
|
||||
<span>
|
||||
{frequency === 'customCron' && customCron.trim() && !isValidCron(customCron)
|
||||
? t('newTask.invalidCron')
|
||||
: describeCron(cronValue, t)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('newTask.delayNote')}
|
||||
</p>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import type { CronTask } from '../../types/task'
|
||||
import { TaskRow } from './TaskRow'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@ -9,6 +10,7 @@ type Props = {
|
||||
export function TaskList({ tasks }: Props) {
|
||||
const t = useTranslation()
|
||||
const enabledCount = tasks.filter((task) => task.enabled).length
|
||||
const [expandedLogsId, setExpandedLogsId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -19,10 +21,15 @@ export function TaskList({ tasks }: Props) {
|
||||
<StatCard label={t('tasks.disabled')} value={String(tasks.length - enabledCount)} />
|
||||
</div>
|
||||
|
||||
{/* Task rows */}
|
||||
{/* Task rows — accordion: only one logs panel open at a time */}
|
||||
<div className="flex flex-col">
|
||||
{tasks.map((task) => (
|
||||
<TaskRow key={task.id} task={task} />
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
showLogs={expandedLogsId === task.id}
|
||||
onToggleLogs={() => setExpandedLogsId(expandedLogsId === task.id ? null : task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,52 +1,250 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import type { CronTask } from '../../types/task'
|
||||
import { useTaskStore } from '../../stores/taskStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { describeCron } from '../../lib/cronDescribe'
|
||||
import { TaskRunsPanel } from './TaskRunsPanel'
|
||||
import { NewTaskModal } from './NewTaskModal'
|
||||
|
||||
type Props = {
|
||||
task: CronTask
|
||||
showLogs: boolean
|
||||
onToggleLogs: () => void
|
||||
}
|
||||
|
||||
export function TaskRow({ task }: Props) {
|
||||
const { deleteTask, updateTask } = useTaskStore()
|
||||
const t = useTranslation()
|
||||
type ConfirmAction = 'run' | 'toggle' | 'delete' | null
|
||||
|
||||
const toggleEnabled = () => {
|
||||
export function TaskRow({ task, showLogs, onToggleLogs }: Props) {
|
||||
const { deleteTask, updateTask, runTask } = useTaskStore()
|
||||
const t = useTranslation()
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null)
|
||||
const [logsRefreshKey, setLogsRefreshKey] = useState(0)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const confirmRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close menu / confirm on outside click
|
||||
useEffect(() => {
|
||||
if (!showMenu && !confirmAction) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (showMenu && menuRef.current && !menuRef.current.contains(target)) {
|
||||
setShowMenu(false)
|
||||
}
|
||||
if (confirmAction && confirmRef.current && !confirmRef.current.contains(target)) {
|
||||
setConfirmAction(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [showMenu, confirmAction])
|
||||
|
||||
const handleRunNow = async () => {
|
||||
setConfirmAction(null)
|
||||
setIsRunning(true)
|
||||
if (!showLogs) onToggleLogs() // open logs panel (accordion will close others)
|
||||
try {
|
||||
await runTask(task.id)
|
||||
setLogsRefreshKey((k) => k + 1)
|
||||
} catch (err) {
|
||||
console.error('Failed to run task:', err)
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
setConfirmAction(null)
|
||||
setShowMenu(false)
|
||||
updateTask(task.id, { enabled: !task.enabled })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-border-separator)] hover:bg-[var(--color-surface-hover)] transition-colors group">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{/* Status indicator */}
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${task.enabled ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
const handleDelete = () => {
|
||||
setConfirmAction(null)
|
||||
setShowMenu(false)
|
||||
deleteTask(task.id)
|
||||
}
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">{task.name}</div>
|
||||
{task.description && (
|
||||
<div className="text-xs text-[var(--color-text-secondary)] truncate">{task.description}</div>
|
||||
)}
|
||||
const iconBtn = 'p-1.5 rounded-[var(--radius-sm)] transition-colors'
|
||||
const menuItem = 'flex items-center gap-2.5 w-full px-3 py-2 text-xs text-left rounded-[var(--radius-sm)] transition-colors'
|
||||
|
||||
return (
|
||||
<div className="border-b border-[var(--color-border-separator)]">
|
||||
<div className="flex items-center justify-between px-4 py-3 hover:bg-[var(--color-surface-hover)] transition-colors group">
|
||||
{/* Left: status + info */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${task.enabled ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">{task.name}</div>
|
||||
{task.description && (
|
||||
<div className="text-xs text-[var(--color-text-secondary)] truncate">{task.description}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-[11px] text-[var(--color-text-tertiary)] mt-0.5">
|
||||
<span>{t('tasks.createdAt')}{new Date(task.createdAt).toLocaleDateString()}</span>
|
||||
{task.lastFiredAt && (
|
||||
<span>{t('tasks.lastRunAt')}{new Date(task.lastFiredAt).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: cron + actions */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]" title={task.cron}>
|
||||
{describeCron(task.cron, t)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Run Now */}
|
||||
<div className="relative" ref={confirmAction === 'run' ? confirmRef : undefined}>
|
||||
<button
|
||||
onClick={() => isRunning ? undefined : setConfirmAction(confirmAction === 'run' ? null : 'run')}
|
||||
disabled={isRunning}
|
||||
className={`${iconBtn} text-[var(--color-brand)] hover:bg-[var(--color-surface-selected)] disabled:opacity-50`}
|
||||
title={t('tasks.runNow')}
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[18px] ${isRunning ? 'animate-spin' : ''}`}>
|
||||
{isRunning ? 'sync' : 'play_arrow'}
|
||||
</span>
|
||||
</button>
|
||||
{confirmAction === 'run' && (
|
||||
<ConfirmPopover
|
||||
message={t('tasks.confirmRun')}
|
||||
confirmLabel={t('tasks.runNow')}
|
||||
onConfirm={handleRunNow}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
cancelLabel={t('common.cancel')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View Logs */}
|
||||
<button
|
||||
onClick={onToggleLogs}
|
||||
className={`${iconBtn} ${showLogs ? 'text-[var(--color-brand)] bg-[var(--color-surface-selected)]' : 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-selected)]'}`}
|
||||
title={t('tasks.viewLogs')}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">receipt_long</span>
|
||||
</button>
|
||||
|
||||
{/* More menu */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => { setShowMenu(!showMenu); setConfirmAction(null) }}
|
||||
className={`${iconBtn} text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-selected)]`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">more_vert</span>
|
||||
</button>
|
||||
|
||||
{showMenu && !confirmAction && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-lg py-1">
|
||||
{/* Edit */}
|
||||
<button
|
||||
onClick={() => { setShowMenu(false); setShowEdit(true) }}
|
||||
className={`${menuItem} text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-secondary)]">edit</span>
|
||||
{t('tasks.edit')}
|
||||
</button>
|
||||
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={() => setConfirmAction('toggle')}
|
||||
className={`${menuItem} text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-secondary)]">
|
||||
{task.enabled ? 'pause_circle' : 'play_circle'}
|
||||
</span>
|
||||
{task.enabled ? t('common.disable') : t('common.enable')}
|
||||
</button>
|
||||
|
||||
<div className="my-1 h-px bg-[var(--color-border-separator)]" />
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={() => setConfirmAction('delete')}
|
||||
className={`${menuItem} text-[var(--color-error)] hover:bg-red-50`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm popovers for menu actions */}
|
||||
{confirmAction === 'toggle' && (
|
||||
<div ref={confirmRef}>
|
||||
<ConfirmPopover
|
||||
message={task.enabled ? t('tasks.confirmDisable') : t('tasks.confirmEnable')}
|
||||
confirmLabel={task.enabled ? t('common.disable') : t('common.enable')}
|
||||
onConfirm={handleToggle}
|
||||
onCancel={() => { setConfirmAction(null); setShowMenu(false) }}
|
||||
cancelLabel={t('common.cancel')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{confirmAction === 'delete' && (
|
||||
<div ref={confirmRef}>
|
||||
<ConfirmPopover
|
||||
message={t('tasks.confirmDelete')}
|
||||
confirmLabel={t('common.delete')}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => { setConfirmAction(null); setShowMenu(false) }}
|
||||
cancelLabel={t('common.cancel')}
|
||||
variant="error"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
{/* Cron expression */}
|
||||
<span className="text-xs text-[var(--color-text-tertiary)] font-[var(--font-mono)]">{task.cron}</span>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={toggleEnabled}
|
||||
className="px-2 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-selected)] transition-colors"
|
||||
>
|
||||
{task.enabled ? t('common.disable') : t('common.enable')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteTask(task.id)}
|
||||
className="px-2 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-error)] hover:bg-red-50 transition-colors"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
{/* Runs panel */}
|
||||
{showLogs && (
|
||||
<div className="px-4 pb-3">
|
||||
<TaskRunsPanel taskId={task.id} onClose={onToggleLogs} refreshKey={logsRefreshKey} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit modal */}
|
||||
{showEdit && (
|
||||
<NewTaskModal open editTask={task} onClose={() => setShowEdit(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Confirm Popover ─────────────────────────────────────────────────────────
|
||||
|
||||
function ConfirmPopover({ message, confirmLabel, onConfirm, onCancel, cancelLabel, variant = 'brand' }: {
|
||||
message: string
|
||||
confirmLabel: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
cancelLabel: string
|
||||
variant?: 'brand' | 'error'
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute right-0 top-full mt-1.5 z-50 w-52 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-lg p-3">
|
||||
<p className="text-xs text-[var(--color-text-secondary)] mb-2.5">{message}</p>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-2.5 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-2.5 py-1 text-xs rounded-[var(--radius-sm)] text-white hover:opacity-90 transition-opacity ${
|
||||
variant === 'error' ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
198
desktop/src/components/tasks/TaskRunsPanel.tsx
Normal file
198
desktop/src/components/tasks/TaskRunsPanel.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTaskStore } from '../../stores/taskStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { parseRunOutput } from '../../lib/parseRunOutput'
|
||||
import type { TaskRun } from '../../types/task'
|
||||
|
||||
function RunOutput({ run }: { run: TaskRun }) {
|
||||
const t = useTranslation()
|
||||
|
||||
// Show error prominently if present
|
||||
if (run.error) {
|
||||
return (
|
||||
<div className="mt-2 p-2.5 rounded-[var(--radius-sm)] bg-red-50 border border-red-200 text-xs text-[var(--color-error)] whitespace-pre-wrap break-words max-h-40 overflow-y-auto">
|
||||
{run.error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const text = parseRunOutput(run.output || '')
|
||||
|
||||
if (!text) {
|
||||
return (
|
||||
<div className="mt-2 p-2.5 rounded-[var(--radius-sm)] bg-[var(--color-surface-container)] text-xs text-[var(--color-text-tertiary)] italic">
|
||||
{run.sessionId ? t('tasks.outputHintSession') : t('tasks.noOutputText')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render AI text response with proper formatting (not monospace <pre>)
|
||||
return (
|
||||
<div className="mt-2 p-2.5 rounded-[var(--radius-sm)] bg-[var(--color-surface-container)] text-xs text-[var(--color-text-secondary)] whitespace-pre-wrap break-words max-h-48 overflow-y-auto leading-relaxed">
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
taskId: string
|
||||
onClose: () => void
|
||||
refreshKey?: number
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { icon: string; color: string }> = {
|
||||
running: { icon: 'sync', color: 'var(--color-warning)' },
|
||||
completed: { icon: 'check_circle', color: 'var(--color-success)' },
|
||||
failed: { icon: 'error', color: 'var(--color-error)' },
|
||||
timeout: { icon: 'timer_off', color: 'var(--color-error)' },
|
||||
}
|
||||
|
||||
export function TaskRunsPanel({ taskId, onClose, refreshKey }: Props) {
|
||||
const t = useTranslation()
|
||||
const { fetchTaskRuns } = useTaskStore()
|
||||
const setActiveSession = useSessionStore((s) => s.setActiveSession)
|
||||
const connectToSession = useChatStore((s) => s.connectToSession)
|
||||
const setActiveView = useUIStore((s) => s.setActiveView)
|
||||
const [runs, setRuns] = useState<TaskRun[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const openSession = (sessionId: string) => {
|
||||
setActiveView('code')
|
||||
setActiveSession(sessionId)
|
||||
connectToSession(sessionId)
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
fetchTaskRuns(taskId).then((r) => {
|
||||
setRuns(r)
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
}
|
||||
|
||||
// Initial fetch + re-fetch when refreshKey changes
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
refresh()
|
||||
}, [taskId, fetchTaskRuns, refreshKey])
|
||||
|
||||
// Auto-poll while any run is "running" or shortly after a manual trigger.
|
||||
// Uses faster 1s polling for the first 10s after refreshKey changes, then 3s.
|
||||
const hasRunning = runs.some((r) => r.status === 'running')
|
||||
useEffect(() => {
|
||||
if (!hasRunning && refreshKey === 0) return // no reason to poll initially
|
||||
// Start with fast polling (1s) to give snappy feedback after "Run Now"
|
||||
let interval = 1000
|
||||
let timer = setInterval(refresh, interval)
|
||||
// After 10s, switch to slower 3s polling if still running
|
||||
const slowDown = setTimeout(() => {
|
||||
clearInterval(timer)
|
||||
if (hasRunning) {
|
||||
timer = setInterval(refresh, 3000)
|
||||
}
|
||||
}, 10000)
|
||||
// If nothing is running and initial window passes, stop entirely
|
||||
const stopTimer = hasRunning ? undefined : setTimeout(() => clearInterval(timer), 12000)
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
clearTimeout(slowDown)
|
||||
if (stopTimer) clearTimeout(stopTimer)
|
||||
}
|
||||
}, [hasRunning, taskId, refreshKey])
|
||||
|
||||
return (
|
||||
<div className="mt-2 mb-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--color-surface-container)]">
|
||||
<span className="text-xs font-medium text-[var(--color-text-primary)]">{t('tasks.logsTitle')}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-0.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('tasks.noLogs')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--color-border-separator)]">
|
||||
{runs.map((run) => {
|
||||
const cfg = STATUS_CONFIG[run.status] || STATUS_CONFIG.failed!
|
||||
const isExpanded = expandedId === run.id
|
||||
return (
|
||||
<div key={run.id} className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status icon */}
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${run.status === 'running' ? 'animate-spin' : ''}`}
|
||||
style={{ color: cfg.color, fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
|
||||
{/* Status text */}
|
||||
<span className="text-xs font-medium" style={{ color: cfg.color }}>
|
||||
{t(`tasks.runStatus.${run.status}` as any)}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{new Date(run.startedAt).toLocaleString()}
|
||||
</span>
|
||||
|
||||
{/* Duration */}
|
||||
{run.durationMs != null && (
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('tasks.duration', { s: Math.round(run.durationMs / 1000) })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Open session — only after run completes (session is empty while running) */}
|
||||
{run.sessionId && run.status !== 'running' && (
|
||||
<button
|
||||
onClick={() => openSession(run.sessionId!)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-[var(--color-brand)] bg-[var(--color-brand)]/8 hover:bg-[var(--color-brand)]/15 rounded-[var(--radius-sm)] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
{t('tasks.openSession')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Summary toggle */}
|
||||
{(run.output || run.error) && (
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : run.id)}
|
||||
className="text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||
>
|
||||
{isExpanded ? t('tasks.hideOutput') : t('tasks.viewOutput')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded output */}
|
||||
{isExpanded && (
|
||||
<RunOutput run={run} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -95,6 +95,35 @@ export const en = {
|
||||
'settings.permissions.bypass': 'Bypass all',
|
||||
'settings.permissions.bypassDesc': 'Skip all permission checks (dangerous)',
|
||||
|
||||
// Settings > Adapters
|
||||
'settings.tab.adapters': 'IM Adapters',
|
||||
'settings.adapters.description': 'Configure IM adapters to chat with Claude Code via Telegram or Feishu.',
|
||||
'settings.adapters.telegram': 'Telegram',
|
||||
'settings.adapters.feishu': 'Feishu',
|
||||
'settings.adapters.botToken': 'Bot Token',
|
||||
'settings.adapters.botTokenPlaceholder': 'Paste token from @BotFather',
|
||||
'settings.adapters.appId': 'App ID',
|
||||
'settings.adapters.appIdPlaceholder': 'e.g. cli_xxx',
|
||||
'settings.adapters.appSecret': 'App Secret',
|
||||
'settings.adapters.appSecretPlaceholder': 'From Feishu Open Platform',
|
||||
'settings.adapters.encryptKey': 'Encrypt Key',
|
||||
'settings.adapters.encryptKeyPlaceholder': 'Optional',
|
||||
'settings.adapters.verificationToken': 'Verification Token',
|
||||
'settings.adapters.verificationTokenPlaceholder': 'Optional',
|
||||
'settings.adapters.allowedUsers': 'Allowed Users',
|
||||
'settings.adapters.allowedUsersHint': 'Comma-separated. Leave empty to allow everyone.',
|
||||
'settings.adapters.tgAllowedUsersPlaceholder': 'e.g. 123456789, 987654321',
|
||||
'settings.adapters.fsAllowedUsersPlaceholder': 'e.g. ou_xxx, ou_yyy',
|
||||
'settings.adapters.workDir': 'Working Directory',
|
||||
'settings.adapters.streamingCard': 'Streaming Card Mode',
|
||||
'settings.adapters.streamingCardDesc': 'Real-time card updates for better experience',
|
||||
'settings.adapters.serverUrl': 'Server URL',
|
||||
'settings.adapters.serverUrlPlaceholder': 'ws://127.0.0.1:3456',
|
||||
'settings.adapters.save': 'Save',
|
||||
'settings.adapters.saved': 'Saved',
|
||||
'settings.adapters.saving': 'Saving...',
|
||||
'settings.adapters.startHint': 'After saving, run adapter: cd adapters && bun run {platform}',
|
||||
|
||||
// Settings > General
|
||||
'settings.general.languageTitle': 'Language',
|
||||
'settings.general.languageDescription': 'Choose the display language for the application.',
|
||||
@ -207,15 +236,76 @@ export const en = {
|
||||
'newTask.description': 'Description',
|
||||
'newTask.descPlaceholder': "Review yesterday's commits and flag anything concerning",
|
||||
'newTask.frequency': 'Frequency',
|
||||
'newTask.hourly': 'Hourly',
|
||||
'newTask.everyNMinutes': 'Every N minutes',
|
||||
'newTask.everyNHours': 'Every N hours',
|
||||
'newTask.daily': 'Daily',
|
||||
'newTask.weekdays': 'Weekdays',
|
||||
'newTask.weekly': 'Weekly',
|
||||
'newTask.weekdays': 'Weekdays (Mon–Fri)',
|
||||
'newTask.specificDays': 'Specific days of week',
|
||||
'newTask.monthly': 'Monthly',
|
||||
'newTask.customCron': 'Custom cron expression',
|
||||
'newTask.intervalMinutes': 'Every {n} minutes',
|
||||
'newTask.intervalHours': 'Every {n} hours',
|
||||
'newTask.atMinute': 'at minute :{m}',
|
||||
'newTask.onMonthDay': 'On day {d} of each month',
|
||||
'newTask.cronFormatHint': 'Format: minute hour day-of-month month day-of-week',
|
||||
'newTask.invalidCron': 'Invalid cron expression',
|
||||
'newTask.daySun': 'Sun',
|
||||
'newTask.dayMon': 'Mon',
|
||||
'newTask.dayTue': 'Tue',
|
||||
'newTask.dayWed': 'Wed',
|
||||
'newTask.dayThu': 'Thu',
|
||||
'newTask.dayFri': 'Fri',
|
||||
'newTask.daySat': 'Sat',
|
||||
'newTask.delayNote': 'Scheduled tasks use a randomized delay of several minutes for server performance.',
|
||||
'newTask.create': 'Create task',
|
||||
'newTask.promptPlaceholder': 'Look at the commits from the last 24 hours. Summarize what changed, call out any risky patterns or missing tests, and note anything worth following up on.',
|
||||
|
||||
// ─── Cron Descriptions ──────────────────────────────────────
|
||||
'cron.everyMinute': 'Runs every minute',
|
||||
'cron.everyNMinutes': 'Runs every {n} minutes',
|
||||
'cron.everyHour': 'Runs every hour',
|
||||
'cron.everyNHours': 'Runs every {n} hours',
|
||||
'cron.everyNHoursAtMinute': 'Runs every {n} hours at :{m}',
|
||||
'cron.dailyAt': 'Runs daily at {time}',
|
||||
'cron.weekdaysAt': 'Runs on weekdays at {time}',
|
||||
'cron.specificDaysAt': 'Runs every {days} at {time}',
|
||||
'cron.monthlyAt': 'Runs on the {day}th of every month at {time}',
|
||||
'cron.customSchedule': 'Custom: {cron}',
|
||||
'cron.dow.0': 'Sun',
|
||||
'cron.dow.1': 'Mon',
|
||||
'cron.dow.2': 'Tue',
|
||||
'cron.dow.3': 'Wed',
|
||||
'cron.dow.4': 'Thu',
|
||||
'cron.dow.5': 'Fri',
|
||||
'cron.dow.6': 'Sat',
|
||||
|
||||
// ─── Task Actions & Logs ──────────────────────────────────────
|
||||
'tasks.runNow': 'Run now',
|
||||
'tasks.confirmRun': 'Execute this task immediately?',
|
||||
'tasks.confirmDisable': 'Disable this scheduled task?',
|
||||
'tasks.confirmEnable': 'Enable this scheduled task?',
|
||||
'tasks.confirmDelete': 'Permanently delete this task and all its logs?',
|
||||
'tasks.running': 'Running...',
|
||||
'tasks.viewLogs': 'Logs',
|
||||
'tasks.logsTitle': 'Execution logs',
|
||||
'tasks.noLogs': 'No execution logs yet',
|
||||
'tasks.runStatus.running': 'Running',
|
||||
'tasks.runStatus.completed': 'Completed',
|
||||
'tasks.runStatus.failed': 'Failed',
|
||||
'tasks.runStatus.timeout': 'Timeout',
|
||||
'tasks.duration': '{s}s',
|
||||
'tasks.viewOutput': 'Summary',
|
||||
'tasks.hideOutput': 'Hide',
|
||||
'tasks.close': 'Close',
|
||||
'tasks.edit': 'Edit',
|
||||
'tasks.editTitle': 'Edit scheduled task',
|
||||
'tasks.saveChanges': 'Save changes',
|
||||
'tasks.openSession': 'View conversation',
|
||||
'tasks.createdAt': 'Created: ',
|
||||
'tasks.lastRunAt': 'Last run: ',
|
||||
'tasks.outputHintSession': 'Click "View conversation" to see the full output in session view.',
|
||||
'tasks.noOutputText': 'No output available.',
|
||||
|
||||
// ─── Prompt Editor ──────────────────────────────────────
|
||||
'promptEditor.worktree': 'worktree',
|
||||
'promptEditor.bypassWarning': 'Bypass mode grants full system access',
|
||||
|
||||
@ -97,6 +97,35 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.permissions.bypass': '跳过全部',
|
||||
'settings.permissions.bypassDesc': '跳过所有权限检查(危险)',
|
||||
|
||||
// Settings > Adapters
|
||||
'settings.tab.adapters': 'IM 接入',
|
||||
'settings.adapters.description': '配置即时通讯适配器,通过 Telegram 或飞书与 Claude Code 对话。',
|
||||
'settings.adapters.telegram': 'Telegram',
|
||||
'settings.adapters.feishu': '飞书 (Feishu)',
|
||||
'settings.adapters.botToken': 'Bot Token',
|
||||
'settings.adapters.botTokenPlaceholder': '粘贴从 @BotFather 获取的 Token',
|
||||
'settings.adapters.appId': 'App ID',
|
||||
'settings.adapters.appIdPlaceholder': '如 cli_xxx',
|
||||
'settings.adapters.appSecret': 'App Secret',
|
||||
'settings.adapters.appSecretPlaceholder': '从飞书开放平台获取',
|
||||
'settings.adapters.encryptKey': 'Encrypt Key',
|
||||
'settings.adapters.encryptKeyPlaceholder': '可选',
|
||||
'settings.adapters.verificationToken': 'Verification Token',
|
||||
'settings.adapters.verificationTokenPlaceholder': '可选',
|
||||
'settings.adapters.allowedUsers': '允许的用户',
|
||||
'settings.adapters.allowedUsersHint': '逗号分隔。留空允许所有人。',
|
||||
'settings.adapters.tgAllowedUsersPlaceholder': '如 123456789, 987654321',
|
||||
'settings.adapters.fsAllowedUsersPlaceholder': '如 ou_xxx, ou_yyy',
|
||||
'settings.adapters.workDir': '工作目录',
|
||||
'settings.adapters.streamingCard': '流式卡片模式',
|
||||
'settings.adapters.streamingCardDesc': '实时更新消息内容,体验更好',
|
||||
'settings.adapters.serverUrl': '服务器地址',
|
||||
'settings.adapters.serverUrlPlaceholder': 'ws://127.0.0.1:3456',
|
||||
'settings.adapters.save': '保存',
|
||||
'settings.adapters.saved': '已保存',
|
||||
'settings.adapters.saving': '保存中...',
|
||||
'settings.adapters.startHint': '保存后运行适配器:cd adapters && bun run {platform}',
|
||||
|
||||
// Settings > General
|
||||
'settings.general.languageTitle': '语言',
|
||||
'settings.general.languageDescription': '选择应用程序的显示语言。',
|
||||
@ -209,15 +238,76 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'newTask.description': '描述',
|
||||
'newTask.descPlaceholder': '审查昨天的提交并标记可疑之处',
|
||||
'newTask.frequency': '频率',
|
||||
'newTask.hourly': '每小时',
|
||||
'newTask.everyNMinutes': '每 N 分钟',
|
||||
'newTask.everyNHours': '每 N 小时',
|
||||
'newTask.daily': '每天',
|
||||
'newTask.weekdays': '工作日',
|
||||
'newTask.weekly': '每周',
|
||||
'newTask.weekdays': '工作日(周一至周五)',
|
||||
'newTask.specificDays': '指定星期几',
|
||||
'newTask.monthly': '每月',
|
||||
'newTask.customCron': '自定义 Cron 表达式',
|
||||
'newTask.intervalMinutes': '每 {n} 分钟',
|
||||
'newTask.intervalHours': '每 {n} 小时',
|
||||
'newTask.atMinute': '在第 :{m} 分钟',
|
||||
'newTask.onMonthDay': '每月第 {d} 天',
|
||||
'newTask.cronFormatHint': '格式:分 时 日 月 星期',
|
||||
'newTask.invalidCron': '无效的 Cron 表达式',
|
||||
'newTask.daySun': '日',
|
||||
'newTask.dayMon': '一',
|
||||
'newTask.dayTue': '二',
|
||||
'newTask.dayWed': '三',
|
||||
'newTask.dayThu': '四',
|
||||
'newTask.dayFri': '五',
|
||||
'newTask.daySat': '六',
|
||||
'newTask.delayNote': '定时任务会使用随机延迟以优化服务器性能。',
|
||||
'newTask.create': '创建任务',
|
||||
'newTask.promptPlaceholder': '查看过去 24 小时的提交。总结变更内容,标记有风险的模式或缺失的测试,并记录值得跟进的事项。',
|
||||
|
||||
// ─── Cron 描述 ──────────────────────────────────────
|
||||
'cron.everyMinute': '每分钟执行',
|
||||
'cron.everyNMinutes': '每 {n} 分钟执行',
|
||||
'cron.everyHour': '每小时执行',
|
||||
'cron.everyNHours': '每 {n} 小时执行',
|
||||
'cron.everyNHoursAtMinute': '每 {n} 小时在第 :{m} 分钟执行',
|
||||
'cron.dailyAt': '每天 {time} 执行',
|
||||
'cron.weekdaysAt': '工作日 {time} 执行',
|
||||
'cron.specificDaysAt': '每{days} {time} 执行',
|
||||
'cron.monthlyAt': '每月 {day} 日 {time} 执行',
|
||||
'cron.customSchedule': '自定义:{cron}',
|
||||
'cron.dow.0': '周日',
|
||||
'cron.dow.1': '周一',
|
||||
'cron.dow.2': '周二',
|
||||
'cron.dow.3': '周三',
|
||||
'cron.dow.4': '周四',
|
||||
'cron.dow.5': '周五',
|
||||
'cron.dow.6': '周六',
|
||||
|
||||
// ─── 任务操作与日志 ──────────────────────────────────────
|
||||
'tasks.runNow': '立即执行',
|
||||
'tasks.confirmRun': '确定要立即执行此任务吗?',
|
||||
'tasks.confirmDisable': '确定要禁用此定时任务吗?',
|
||||
'tasks.confirmEnable': '确定要启用此定时任务吗?',
|
||||
'tasks.confirmDelete': '确定要永久删除此任务及其所有日志吗?',
|
||||
'tasks.running': '执行中...',
|
||||
'tasks.viewLogs': '日志',
|
||||
'tasks.logsTitle': '执行记录',
|
||||
'tasks.noLogs': '暂无执行记录',
|
||||
'tasks.runStatus.running': '运行中',
|
||||
'tasks.runStatus.completed': '已完成',
|
||||
'tasks.runStatus.failed': '失败',
|
||||
'tasks.runStatus.timeout': '超时',
|
||||
'tasks.duration': '{s}秒',
|
||||
'tasks.viewOutput': '摘要',
|
||||
'tasks.hideOutput': '收起',
|
||||
'tasks.close': '关闭',
|
||||
'tasks.edit': '编辑',
|
||||
'tasks.editTitle': '编辑定时任务',
|
||||
'tasks.saveChanges': '保存修改',
|
||||
'tasks.openSession': '查看完整对话',
|
||||
'tasks.createdAt': '创建于 ',
|
||||
'tasks.lastRunAt': '上次执行 ',
|
||||
'tasks.outputHintSession': '点击「查看完整对话」可在会话中查看完整输出。',
|
||||
'tasks.noOutputText': '暂无输出内容。',
|
||||
|
||||
// ─── Prompt Editor ──────────────────────────────────────
|
||||
'promptEditor.worktree': '工作树',
|
||||
'promptEditor.bypassWarning': '跳过模式将授予完整系统访问权限',
|
||||
|
||||
93
desktop/src/lib/__tests__/cronDescribe.test.ts
Normal file
93
desktop/src/lib/__tests__/cronDescribe.test.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describeCron, isValidCron } from '../cronDescribe'
|
||||
|
||||
// Simple mock t() that returns the key with params interpolated
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
let text = key
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
describe('describeCron', () => {
|
||||
it('every minute', () => {
|
||||
expect(describeCron('* * * * *', t)).toBe('cron.everyMinute')
|
||||
})
|
||||
|
||||
it('every N minutes', () => {
|
||||
expect(describeCron('*/15 * * * *', t)).toBe('cron.everyNMinutes')
|
||||
expect(describeCron('*/5 * * * *', t)).toBe('cron.everyNMinutes')
|
||||
})
|
||||
|
||||
it('*/1 is treated as every minute', () => {
|
||||
expect(describeCron('*/1 * * * *', t)).toBe('cron.everyMinute')
|
||||
})
|
||||
|
||||
it('every hour', () => {
|
||||
expect(describeCron('0 */1 * * *', t)).toBe('cron.everyHour')
|
||||
})
|
||||
|
||||
it('every N hours', () => {
|
||||
expect(describeCron('0 */4 * * *', t)).toBe('cron.everyNHours')
|
||||
})
|
||||
|
||||
it('every N hours at minute offset', () => {
|
||||
expect(describeCron('30 */4 * * *', t)).toBe('cron.everyNHoursAtMinute')
|
||||
})
|
||||
|
||||
it('daily at time', () => {
|
||||
expect(describeCron('30 9 * * *', t)).toBe('cron.dailyAt')
|
||||
})
|
||||
|
||||
it('weekdays at time', () => {
|
||||
expect(describeCron('30 9 * * 1-5', t)).toBe('cron.weekdaysAt')
|
||||
})
|
||||
|
||||
it('specific days of week', () => {
|
||||
const result = describeCron('0 9 * * 1,3,5', t)
|
||||
expect(result).toBe('cron.specificDaysAt')
|
||||
})
|
||||
|
||||
it('monthly on specific day', () => {
|
||||
expect(describeCron('0 9 15 * *', t)).toBe('cron.monthlyAt')
|
||||
})
|
||||
|
||||
it('unrecognized pattern falls back to custom', () => {
|
||||
expect(describeCron('0 9 1 6 *', t)).toBe('cron.customSchedule')
|
||||
})
|
||||
|
||||
it('invalid field count falls back to custom', () => {
|
||||
expect(describeCron('0 9 *', t)).toBe('cron.customSchedule')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidCron', () => {
|
||||
it('accepts valid expressions', () => {
|
||||
expect(isValidCron('0 9 * * *')).toBe(true)
|
||||
expect(isValidCron('*/15 * * * *')).toBe(true)
|
||||
expect(isValidCron('30 14 * * 1-5')).toBe(true)
|
||||
expect(isValidCron('0 9 * * 1,3,5')).toBe(true)
|
||||
expect(isValidCron('0 9 15 * *')).toBe(true)
|
||||
expect(isValidCron('0 */2 * * *')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid expressions', () => {
|
||||
expect(isValidCron('')).toBe(false)
|
||||
expect(isValidCron('hello')).toBe(false)
|
||||
expect(isValidCron('0 9 *')).toBe(false) // too few fields
|
||||
expect(isValidCron('0 9 * * * *')).toBe(false) // too many fields
|
||||
expect(isValidCron('60 9 * * *')).toBe(false) // minute > 59
|
||||
expect(isValidCron('0 25 * * *')).toBe(false) // hour > 23
|
||||
expect(isValidCron('0 9 32 * *')).toBe(false) // day > 31
|
||||
expect(isValidCron('0 9 * 13 *')).toBe(false) // month > 12
|
||||
expect(isValidCron('0 9 * * 8')).toBe(false) // dow > 7
|
||||
})
|
||||
|
||||
it('accepts edge case values', () => {
|
||||
expect(isValidCron('0 0 1 1 0')).toBe(true)
|
||||
expect(isValidCron('59 23 31 12 7')).toBe(true)
|
||||
})
|
||||
})
|
||||
187
desktop/src/lib/cronDescribe.ts
Normal file
187
desktop/src/lib/cronDescribe.ts
Normal file
@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Cron expression utilities: human-readable description & validation.
|
||||
* Works with standard 5-field cron: minute hour day-of-month month day-of-week
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type TFunc = (key: any, params?: Record<string, string | number>) => string
|
||||
|
||||
function pad(n: number): string {
|
||||
return n.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
function formatTime(hour: number, minute: number): string {
|
||||
return `${pad(hour)}:${pad(minute)}`
|
||||
}
|
||||
|
||||
function describeDow(field: string, t: TFunc): string {
|
||||
const parts = field.split(',')
|
||||
const days: number[] = []
|
||||
for (const part of parts) {
|
||||
const range = part.match(/^(\d+)-(\d+)$/)
|
||||
if (range) {
|
||||
const start = parseInt(range[1]!)
|
||||
const end = parseInt(range[2]!)
|
||||
for (let i = start; i <= end; i++) days.push(i)
|
||||
} else {
|
||||
days.push(parseInt(part))
|
||||
}
|
||||
}
|
||||
return days.map((d) => t(`cron.dow.${d % 7}`)).join(', ')
|
||||
}
|
||||
|
||||
export function describeCron(cron: string, t: TFunc): string {
|
||||
const fields = cron.trim().split(/\s+/)
|
||||
if (fields.length !== 5) return t('cron.customSchedule', { cron })
|
||||
|
||||
const min = fields[0]!
|
||||
const hour = fields[1]!
|
||||
const dom = fields[2]!
|
||||
const month = fields[3]!
|
||||
const dow = fields[4]!
|
||||
|
||||
// */N * * * * → every N minutes
|
||||
if (hour === '*' && dom === '*' && month === '*' && dow === '*') {
|
||||
const stepMatch = min.match(/^\*\/(\d+)$/)
|
||||
if (stepMatch) {
|
||||
const n = parseInt(stepMatch[1]!)
|
||||
if (n === 1) return t('cron.everyMinute')
|
||||
return t('cron.everyNMinutes', { n })
|
||||
}
|
||||
if (min === '*') return t('cron.everyMinute')
|
||||
}
|
||||
|
||||
// M */N * * * → every N hours (optionally at :M)
|
||||
if (/^\d+$/.test(min) && dom === '*' && month === '*' && dow === '*') {
|
||||
const hourStep = hour.match(/^\*\/(\d+)$/)
|
||||
if (hourStep) {
|
||||
const n = parseInt(hourStep[1]!)
|
||||
const m = parseInt(min)
|
||||
if (m === 0) {
|
||||
if (n === 1) return t('cron.everyHour')
|
||||
return t('cron.everyNHours', { n })
|
||||
}
|
||||
return t('cron.everyNHoursAtMinute', { n, m: pad(m) })
|
||||
}
|
||||
}
|
||||
|
||||
// M H * * <dow> → daily / weekdays / specific days
|
||||
if (/^\d+$/.test(min) && /^\d+$/.test(hour) && dom === '*' && month === '*') {
|
||||
const time = formatTime(parseInt(hour), parseInt(min))
|
||||
|
||||
if (dow === '*') {
|
||||
return t('cron.dailyAt', { time })
|
||||
}
|
||||
if (dow === '1-5') {
|
||||
return t('cron.weekdaysAt', { time })
|
||||
}
|
||||
if (/^[\d,\-]+$/.test(dow)) {
|
||||
const days = describeDow(dow, t)
|
||||
return t('cron.specificDaysAt', { days, time })
|
||||
}
|
||||
}
|
||||
|
||||
// M H D * * → monthly on day D
|
||||
if (/^\d+$/.test(min) && /^\d+$/.test(hour) && /^\d+$/.test(dom) && month === '*' && dow === '*') {
|
||||
const time = formatTime(parseInt(hour), parseInt(min))
|
||||
return t('cron.monthlyAt', { day: parseInt(dom), time })
|
||||
}
|
||||
|
||||
return t('cron.customSchedule', { cron })
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-parse a cron expression back into UI-friendly state
|
||||
* for the task edit modal.
|
||||
*/
|
||||
export type FrequencyKey = 'everyNMinutes' | 'everyNHours' | 'daily' | 'weekdays' | 'specificDays' | 'monthly' | 'customCron'
|
||||
|
||||
export type ParsedCron = {
|
||||
frequency: FrequencyKey
|
||||
time: string
|
||||
minuteInterval: number
|
||||
hourInterval: number
|
||||
minuteOffset: number
|
||||
selectedDays: number[]
|
||||
monthDay: number
|
||||
customCron: string
|
||||
}
|
||||
|
||||
const DEFAULTS: ParsedCron = {
|
||||
frequency: 'customCron',
|
||||
time: '09:00',
|
||||
minuteInterval: 15,
|
||||
hourInterval: 1,
|
||||
minuteOffset: 0,
|
||||
selectedDays: [1],
|
||||
monthDay: 1,
|
||||
customCron: '',
|
||||
}
|
||||
|
||||
export function parseCron(cron: string): ParsedCron {
|
||||
const fields = cron.trim().split(/\s+/)
|
||||
if (fields.length !== 5) return { ...DEFAULTS, customCron: cron }
|
||||
|
||||
const min = fields[0]!
|
||||
const hour = fields[1]!
|
||||
const dom = fields[2]!
|
||||
const month = fields[3]!
|
||||
const dow = fields[4]!
|
||||
|
||||
// */N * * * * → everyNMinutes
|
||||
if (/^\*\/\d+$/.test(min) && hour === '*' && dom === '*' && month === '*' && dow === '*') {
|
||||
return { ...DEFAULTS, frequency: 'everyNMinutes', minuteInterval: parseInt(min.split('/')[1]!) }
|
||||
}
|
||||
|
||||
// M */N * * * → everyNHours
|
||||
if (/^\d+$/.test(min) && /^\*\/\d+$/.test(hour) && dom === '*' && month === '*' && dow === '*') {
|
||||
return { ...DEFAULTS, frequency: 'everyNHours', minuteOffset: parseInt(min), hourInterval: parseInt(hour.split('/')[1]!) }
|
||||
}
|
||||
|
||||
// M H ... patterns need time
|
||||
if (/^\d+$/.test(min) && /^\d+$/.test(hour)) {
|
||||
const time = formatTime(parseInt(hour), parseInt(min))
|
||||
|
||||
// M H * * * → daily
|
||||
if (dom === '*' && month === '*' && dow === '*') {
|
||||
return { ...DEFAULTS, frequency: 'daily', time }
|
||||
}
|
||||
// M H * * 1-5 → weekdays
|
||||
if (dom === '*' && month === '*' && dow === '1-5') {
|
||||
return { ...DEFAULTS, frequency: 'weekdays', time }
|
||||
}
|
||||
// M H * * <list> → specificDays
|
||||
if (dom === '*' && month === '*' && /^[\d,]+$/.test(dow)) {
|
||||
return { ...DEFAULTS, frequency: 'specificDays', time, selectedDays: dow.split(',').map(Number) }
|
||||
}
|
||||
// M H D * * → monthly
|
||||
if (/^\d+$/.test(dom) && month === '*' && dow === '*') {
|
||||
return { ...DEFAULTS, frequency: 'monthly', time, monthDay: parseInt(dom) }
|
||||
}
|
||||
}
|
||||
|
||||
return { ...DEFAULTS, customCron: cron }
|
||||
}
|
||||
|
||||
export function isValidCron(cron: string): boolean {
|
||||
const fields = cron.trim().split(/\s+/)
|
||||
if (fields.length !== 5) return false
|
||||
|
||||
const fieldPattern = /^(\*|(\d+(-\d+)?(\/\d+)?)(,(\d+(-\d+)?(\/\d+)?))*)$/
|
||||
const maxValues = [59, 23, 31, 12, 7]
|
||||
const minValues = [0, 0, 1, 1, 0]
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const field = fields[i]!
|
||||
if (/^\*\/\d+$/.test(field)) continue
|
||||
if (field === '*') continue
|
||||
if (!fieldPattern.test(field)) return false
|
||||
const nums = field.replace(/\/\d+/g, '').split(/[,\-]/).filter((s) => /^\d+$/.test(s))
|
||||
for (const num of nums) {
|
||||
const n = parseInt(num)
|
||||
if (n < minValues[i]! || n > maxValues[i]!) return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
79
desktop/src/lib/parseRunOutput.ts
Normal file
79
desktop/src/lib/parseRunOutput.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Parse task run output into displayable text.
|
||||
*
|
||||
* The output may be in one of two formats:
|
||||
*
|
||||
* 1. **Extracted text** (new runs) — The server's `extractAssistantText` has
|
||||
* already parsed the raw NDJSON and stored only the AI's text response.
|
||||
* This is plain text / markdown that should be returned as-is.
|
||||
*
|
||||
* 2. **Raw NDJSON** (old runs before the server-side extraction was added) —
|
||||
* Each line is a JSON object from the CLI's stream-json output. We parse
|
||||
* these and extract assistant text blocks + result messages.
|
||||
*
|
||||
* Detection: if at least one line parses as JSON with a recognized `type`
|
||||
* field, treat as NDJSON. Otherwise return as-is.
|
||||
*/
|
||||
export function parseRunOutput(raw: string): string {
|
||||
if (!raw || !raw.trim()) return ''
|
||||
|
||||
const lines = raw.trim().split('\n')
|
||||
|
||||
// Quick check: does this look like NDJSON? (first non-empty line starts with '{')
|
||||
const firstLine = lines.find((l) => l.trim())
|
||||
if (!firstLine || !firstLine.trim().startsWith('{')) {
|
||||
// Already extracted plain text — return as-is
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
// Try to parse as NDJSON (legacy format)
|
||||
const textParts: string[] = []
|
||||
let anyRecognized = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const type = parsed?.type
|
||||
|
||||
if (type === 'assistant') {
|
||||
anyRecognized = true
|
||||
const content = parsed?.message?.content
|
||||
if (!Array.isArray(content)) continue
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text?.trim()) {
|
||||
textParts.push(block.text.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'result') {
|
||||
anyRecognized = true
|
||||
const result = parsed?.result
|
||||
if (typeof result === 'string' && result.trim()) {
|
||||
textParts.push(result.trim())
|
||||
} else if (result?.message?.trim()) {
|
||||
textParts.push(result.message.trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'system' || type === 'user') {
|
||||
anyRecognized = true
|
||||
// Skip these — not useful to display
|
||||
}
|
||||
}
|
||||
|
||||
// If we recognized NDJSON structure, return extracted text
|
||||
if (anyRecognized) {
|
||||
return textParts.join('\n\n')
|
||||
}
|
||||
|
||||
// Fallback: the JSON lines didn't have recognized types — return raw
|
||||
return raw.trim()
|
||||
}
|
||||
@ -12,6 +12,7 @@ type TaskStore = {
|
||||
createTask: (input: CreateTaskInput) => Promise<void>
|
||||
updateTask: (id: string, updates: Partial<CronTask>) => Promise<void>
|
||||
deleteTask: (id: string) => Promise<void>
|
||||
runTask: (taskId: string) => Promise<void>
|
||||
fetchRecentRuns: () => Promise<void>
|
||||
fetchTaskRuns: (taskId: string) => Promise<TaskRun[]>
|
||||
}
|
||||
@ -47,6 +48,10 @@ export const useTaskStore = create<TaskStore>((set) => ({
|
||||
set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) }))
|
||||
},
|
||||
|
||||
runTask: async (taskId) => {
|
||||
await tasksApi.runTask(taskId)
|
||||
},
|
||||
|
||||
fetchRecentRuns: async () => {
|
||||
try {
|
||||
const { runs } = await tasksApi.getRecentRuns()
|
||||
|
||||
@ -45,4 +45,5 @@ export type TaskRun = {
|
||||
error?: string
|
||||
exitCode?: number
|
||||
durationMs?: number
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
* POST /api/scheduled-tasks — 创建任务
|
||||
* GET /api/scheduled-tasks/runs — 获取所有任务的最近执行记录
|
||||
* GET /api/scheduled-tasks/:id/runs — 获取指定任务的执行记录
|
||||
* POST /api/scheduled-tasks/:id/run — 立即执行指定任务
|
||||
* PUT /api/scheduled-tasks/:id — 更新任务
|
||||
* DELETE /api/scheduled-tasks/:id — 删除任务
|
||||
*/
|
||||
@ -64,6 +65,21 @@ export async function handleScheduledTasksApi(
|
||||
return Response.json({ task }, { status: 201 })
|
||||
}
|
||||
|
||||
// ── POST /api/scheduled-tasks/:id/run ──────────────────────────────────
|
||||
// Fire-and-forget: start execution in background, return immediately.
|
||||
// The frontend polls GET /:id/runs to track progress.
|
||||
if (method === 'POST' && taskId && subResource === 'run') {
|
||||
const tasks = await cronService.listTasks()
|
||||
const task = tasks.find((t) => t.id === taskId)
|
||||
if (!task) throw ApiError.notFound(`Task ${taskId} not found`)
|
||||
cronScheduler.executeTask(task, { createSession: true }).catch((err) => {
|
||||
console.error(`[ScheduledTasks] Manual run failed for task ${taskId}:`, err)
|
||||
})
|
||||
// Small delay to let appendRun() write the "running" entry to disk
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
// ── PUT /api/scheduled-tasks/:id ──────────────────────────────────────
|
||||
if (method === 'PUT' && taskId && !subResource) {
|
||||
const body = await parseJsonBody(req)
|
||||
|
||||
@ -12,6 +12,7 @@ import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import * as crypto from 'crypto'
|
||||
import { CronService, type CronTask } from './cronService.js'
|
||||
import { SessionService } from './sessionService.js'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -27,6 +28,59 @@ export type TaskRun = {
|
||||
error?: string
|
||||
exitCode?: number
|
||||
durationMs?: number
|
||||
sessionId?: string // links to a session for rich output rendering
|
||||
}
|
||||
|
||||
// ─── Output extraction ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract meaningful assistant text from raw CLI stream-json (NDJSON) output.
|
||||
*
|
||||
* The raw stdout contains system/init messages, tool_use blocks, tool_result
|
||||
* echoes, and thinking blocks — all of which are noise to the end user. The
|
||||
* actual AI answer (assistant text blocks + final result) is what matters.
|
||||
*
|
||||
* By extracting server-side we avoid the 10K naive truncation problem where
|
||||
* the useful content sits well past the first 10K characters.
|
||||
*/
|
||||
function extractAssistantText(raw: string): string {
|
||||
if (!raw) return ''
|
||||
const lines = raw.split('\n')
|
||||
const parts: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
continue // skip non-JSON lines and truncated lines
|
||||
}
|
||||
|
||||
const type = parsed?.type
|
||||
|
||||
if (type === 'assistant') {
|
||||
const content = parsed?.message?.content
|
||||
if (!Array.isArray(content)) continue
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text?.trim()) {
|
||||
parts.push(block.text.trim())
|
||||
}
|
||||
// Skip tool_use, thinking blocks
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'result') {
|
||||
const result = parsed?.result
|
||||
if (typeof result === 'string' && result.trim()) {
|
||||
parts.push(result.trim())
|
||||
} else if (result?.message?.trim()) {
|
||||
parts.push(result.message.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
// ─── Cron expression matching ──────────────────────────────────────────────────
|
||||
@ -193,9 +247,11 @@ export class CronScheduler {
|
||||
{ proc: ReturnType<typeof Bun.spawn>; startedAt: number; runId: string }
|
||||
>()
|
||||
private cronService: CronService
|
||||
private sessionService: SessionService
|
||||
|
||||
constructor(cronService?: CronService) {
|
||||
this.cronService = cronService || new CronService()
|
||||
this.sessionService = new SessionService()
|
||||
}
|
||||
|
||||
/** Start the scheduler (called on server boot). */
|
||||
@ -252,10 +308,30 @@ export class CronScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute a single task by spawning a CLI subprocess. */
|
||||
async executeTask(task: CronTask): Promise<TaskRun> {
|
||||
/**
|
||||
* Execute a single task by spawning a CLI subprocess.
|
||||
* @param task The task to execute
|
||||
* @param options.createSession When true, creates a Session for rich output viewing (used for manual "Run Now")
|
||||
*/
|
||||
async executeTask(task: CronTask, options?: { createSession?: boolean }): Promise<TaskRun> {
|
||||
const runId = crypto.randomBytes(6).toString('hex')
|
||||
const startedAt = new Date().toISOString()
|
||||
const workDir = task.folderPath || os.homedir()
|
||||
|
||||
// Only create a session when explicitly requested (manual "Run Now"),
|
||||
// not for automatic cron runs — avoids flooding the sidebar.
|
||||
let sessionId: string | undefined
|
||||
if (options?.createSession) {
|
||||
try {
|
||||
const result = await this.sessionService.createSession(workDir)
|
||||
sessionId = result.sessionId
|
||||
// Delete the placeholder JSONL file so the CLI can create it fresh
|
||||
// with actual content. Same pattern as conversationService.ts.
|
||||
await this.sessionService.deleteSessionFile(sessionId)
|
||||
} catch {
|
||||
// Fall back to no session if creation fails
|
||||
}
|
||||
}
|
||||
|
||||
const run: TaskRun = {
|
||||
id: runId,
|
||||
@ -264,13 +340,16 @@ export class CronScheduler {
|
||||
startedAt,
|
||||
status: 'running',
|
||||
prompt: task.prompt,
|
||||
sessionId,
|
||||
}
|
||||
|
||||
// Persist the "running" state
|
||||
await appendRun(run)
|
||||
|
||||
// Resolve CLI entry point relative to this file
|
||||
const cliPath = path.resolve(import.meta.dir, '../../entrypoints/cli.tsx')
|
||||
// Resolve paths relative to project root
|
||||
const projectRoot = path.resolve(import.meta.dir, '../../..')
|
||||
const cliPath = path.join(projectRoot, 'src/entrypoints/cli.tsx')
|
||||
const preloadPath = path.join(projectRoot, 'preload.ts')
|
||||
|
||||
const inputPayload = JSON.stringify({
|
||||
type: 'user',
|
||||
@ -279,12 +358,14 @@ export class CronScheduler {
|
||||
content: [{ type: 'text', text: task.prompt }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
session_id: sessionId || '',
|
||||
}) + '\n'
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
'bun',
|
||||
'--preload',
|
||||
preloadPath,
|
||||
cliPath,
|
||||
'--print',
|
||||
'--verbose',
|
||||
@ -292,12 +373,13 @@ export class CronScheduler {
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
...(sessionId ? ['--session-id', sessionId] : []),
|
||||
],
|
||||
{
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
cwd: task.folderPath || os.homedir(),
|
||||
cwd: workDir,
|
||||
},
|
||||
)
|
||||
|
||||
@ -346,18 +428,24 @@ export class CronScheduler {
|
||||
this.runningTasks.delete(task.id)
|
||||
|
||||
const completedAt = new Date().toISOString()
|
||||
const output = stdoutChunks.join('')
|
||||
const rawOutput = stdoutChunks.join('')
|
||||
const durationMs =
|
||||
new Date(completedAt).getTime() - new Date(startedAt).getTime()
|
||||
|
||||
// Determine if this was a timeout
|
||||
const wasTimeout = durationMs >= TASK_TIMEOUT_MS
|
||||
|
||||
// Extract only meaningful AI text responses from raw NDJSON output.
|
||||
// The raw stream contains system/init messages, tool_use blocks, and
|
||||
// tool_result echoes that consume thousands of chars before any actual
|
||||
// AI answer appears. A naive .slice(0, 10_000) would lose the answer.
|
||||
const output = extractAssistantText(rawOutput)
|
||||
|
||||
const completedRun: TaskRun = {
|
||||
...run,
|
||||
completedAt,
|
||||
status: wasTimeout ? 'timeout' : exitCode === 0 ? 'completed' : 'failed',
|
||||
output: output.slice(0, 10_000), // cap stored output
|
||||
output: output.slice(0, 50_000), // cap after extraction
|
||||
exitCode,
|
||||
durationMs,
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user