Replace the local preview stub with a real Ollama-backed test message flow using the configured local model. Show the exact final prompt live on create and edit task pages, render generated output below it, and cover the integration with frontend tests.
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
|
import { getEntity } from '../api/entitiesApi'
|
|
import {
|
|
buildTaskPreviewPrompt,
|
|
createTask,
|
|
generateTaskPreview,
|
|
type EmailLookback,
|
|
} from '../api/tasksApi'
|
|
|
|
interface TaskFormState {
|
|
name: string
|
|
prompt: string
|
|
scheduleCron: string
|
|
emailLookback: EmailLookback
|
|
}
|
|
|
|
interface CronParts {
|
|
minute: string
|
|
hour: string
|
|
dayOfMonth: string
|
|
month: string
|
|
dayOfWeek: string
|
|
}
|
|
|
|
interface RegularitySuggestion {
|
|
id: string
|
|
label: string
|
|
description: string
|
|
cronParts: CronParts
|
|
}
|
|
|
|
const DEFAULT_CRON_PARTS: CronParts = {
|
|
minute: '0',
|
|
hour: '9',
|
|
dayOfMonth: '*',
|
|
month: '*',
|
|
dayOfWeek: '1-5',
|
|
}
|
|
|
|
const REGULARITY_SUGGESTIONS: RegularitySuggestion[] = [
|
|
{
|
|
id: 'daily',
|
|
label: 'Daily',
|
|
description: 'Every day at 09:00',
|
|
cronParts: { minute: '0', hour: '9', dayOfMonth: '*', month: '*', dayOfWeek: '*' },
|
|
},
|
|
{
|
|
id: 'weekdays',
|
|
label: 'Weekdays',
|
|
description: 'Monday to Friday at 09:00',
|
|
cronParts: { minute: '0', hour: '9', dayOfMonth: '*', month: '*', dayOfWeek: '1-5' },
|
|
},
|
|
{
|
|
id: 'weekly',
|
|
label: 'Weekly',
|
|
description: 'Every Monday at 09:00',
|
|
cronParts: { minute: '0', hour: '9', dayOfMonth: '*', month: '*', dayOfWeek: '1' },
|
|
},
|
|
{
|
|
id: 'monthly',
|
|
label: 'Monthly',
|
|
description: 'Day 1 of each month at 09:00',
|
|
cronParts: { minute: '0', hour: '9', dayOfMonth: '1', month: '*', dayOfWeek: '*' },
|
|
},
|
|
]
|
|
|
|
function buildCron(parts: CronParts): string {
|
|
return [parts.minute, parts.hour, parts.dayOfMonth, parts.month, parts.dayOfWeek].join(' ')
|
|
}
|
|
|
|
const DEFAULT_TASK_FORM: TaskFormState = {
|
|
name: '',
|
|
prompt: '',
|
|
scheduleCron: buildCron(DEFAULT_CRON_PARTS),
|
|
emailLookback: 'last_week',
|
|
}
|
|
|
|
export default function CreateTaskPage() {
|
|
const { entityId = '' } = useParams()
|
|
const navigate = useNavigate()
|
|
const queryClient = useQueryClient()
|
|
const [cronParts, setCronParts] = useState<CronParts>(DEFAULT_CRON_PARTS)
|
|
const [taskForm, setTaskForm] = useState<TaskFormState>(DEFAULT_TASK_FORM)
|
|
const [preview, setPreview] = useState('')
|
|
const [previewError, setPreviewError] = useState('')
|
|
|
|
const { data: entity, isLoading } = useQuery({
|
|
queryKey: ['entity', entityId],
|
|
queryFn: () => getEntity(entityId),
|
|
enabled: Boolean(entityId),
|
|
})
|
|
|
|
const createTaskMutation = useMutation({
|
|
mutationFn: createTask,
|
|
onSuccess: async () => {
|
|
await queryClient.invalidateQueries({ queryKey: ['entity-tasks', entityId] })
|
|
navigate(`/entities/${entityId}`)
|
|
},
|
|
})
|
|
|
|
const previewMutation = useMutation({
|
|
mutationFn: generateTaskPreview,
|
|
onMutate: () => {
|
|
setPreview('')
|
|
setPreviewError('')
|
|
},
|
|
onSuccess: (value) => setPreview(value),
|
|
onError: (error) => {
|
|
setPreviewError(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Unable to generate a test message from the local model.'
|
|
)
|
|
},
|
|
})
|
|
|
|
const canSubmit = useMemo(() => {
|
|
const hasFilledCronParts = Object.values(cronParts).every((value) => value.trim().length > 0)
|
|
return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts)
|
|
}, [cronParts, taskForm.name, taskForm.prompt])
|
|
|
|
const currentTask = useMemo(
|
|
() => ({
|
|
entityId,
|
|
name: taskForm.name,
|
|
prompt: taskForm.prompt,
|
|
scheduleCron: taskForm.scheduleCron,
|
|
emailLookback: taskForm.emailLookback,
|
|
}),
|
|
[entityId, taskForm.emailLookback, taskForm.name, taskForm.prompt, taskForm.scheduleCron]
|
|
)
|
|
|
|
const finalPrompt = useMemo(() => {
|
|
if (!entity) {
|
|
return 'Entity details unavailable.'
|
|
}
|
|
|
|
return buildTaskPreviewPrompt(entity, currentTask)
|
|
}, [currentTask, entity])
|
|
|
|
const applyCronParts = (nextCronParts: CronParts) => {
|
|
setCronParts(nextCronParts)
|
|
setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) }))
|
|
}
|
|
|
|
const regularitySummary = useMemo(
|
|
() =>
|
|
`At ${cronParts.hour.padStart(2, '0')}:${cronParts.minute.padStart(2, '0')} - day ${cronParts.dayOfMonth}, month ${cronParts.month}, week day ${cronParts.dayOfWeek}`,
|
|
[cronParts.dayOfMonth, cronParts.dayOfWeek, cronParts.hour, cronParts.minute, cronParts.month]
|
|
)
|
|
|
|
if (!entityId) {
|
|
return <div className="p-8 text-sm text-slate-300">Entity identifier is missing.</div>
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <div className="p-8 text-sm text-slate-300">Loading...</div>
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen overflow-y-auto bg-slate-950 py-8">
|
|
<div className="mx-auto max-w-2xl px-4 sm:px-6">
|
|
<nav className="mb-6">
|
|
<Link
|
|
to={`/entities/${entityId}`}
|
|
className="text-sm text-cyan-400 hover:text-cyan-300"
|
|
>
|
|
← Back to {entity?.name ?? 'Entity'}
|
|
</Link>
|
|
</nav>
|
|
|
|
<h1 className="text-2xl font-bold text-slate-100">
|
|
New Task
|
|
{entity && (
|
|
<span className="ml-2 text-base font-normal text-slate-400">— {entity.name}</span>
|
|
)}
|
|
</h1>
|
|
|
|
<form
|
|
className="mt-6 space-y-6"
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
if (!canSubmit) return
|
|
createTaskMutation.mutate({
|
|
entityId,
|
|
name: taskForm.name,
|
|
prompt: taskForm.prompt,
|
|
scheduleCron: taskForm.scheduleCron,
|
|
emailLookback: taskForm.emailLookback,
|
|
})
|
|
}}
|
|
>
|
|
<div>
|
|
<label htmlFor="task-name" className="text-sm font-medium text-slate-200">
|
|
Task Name
|
|
</label>
|
|
<input
|
|
id="task-name"
|
|
value={taskForm.name}
|
|
onChange={(event) => setTaskForm((prev) => ({ ...prev, name: event.target.value }))}
|
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="task-prompt" className="text-sm font-medium text-slate-200">
|
|
Task Prompt
|
|
</label>
|
|
<textarea
|
|
id="task-prompt"
|
|
value={taskForm.prompt}
|
|
onChange={(event) =>
|
|
setTaskForm((prev) => ({ ...prev, prompt: event.target.value }))
|
|
}
|
|
className="mt-1 min-h-36 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="task-lookback" className="text-sm font-medium text-slate-200">
|
|
Email Period
|
|
</label>
|
|
<select
|
|
id="task-lookback"
|
|
value={taskForm.emailLookback}
|
|
onChange={(event) =>
|
|
setTaskForm((prev) => ({
|
|
...prev,
|
|
emailLookback: event.target.value as EmailLookback,
|
|
}))
|
|
}
|
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
|
>
|
|
<option value="last_day">Last 24 hours</option>
|
|
<option value="last_week">Last week</option>
|
|
<option value="last_month">Last month</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-slate-800 bg-slate-900/60 p-4">
|
|
<p className="text-sm font-medium text-slate-200">Task Schedule</p>
|
|
<p className="mt-1 text-xs text-slate-400">
|
|
Pick a regularity then fine-tune each cron field. Use <code className="text-slate-300">*</code> for "every".
|
|
</p>
|
|
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
|
{REGULARITY_SUGGESTIONS.map((suggestion) => (
|
|
<button
|
|
key={suggestion.id}
|
|
type="button"
|
|
className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-left text-slate-200 hover:border-cyan-500/70"
|
|
onClick={() => applyCronParts(suggestion.cronParts)}
|
|
>
|
|
<span className="block text-sm font-semibold">{suggestion.label}</span>
|
|
<span className="block text-xs text-slate-400">{suggestion.description}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-5">
|
|
<div>
|
|
<label htmlFor="cron-minute" className="text-xs font-medium text-slate-300">
|
|
Minute
|
|
</label>
|
|
<input
|
|
id="cron-minute"
|
|
type="text"
|
|
value={cronParts.minute}
|
|
onChange={(event) =>
|
|
applyCronParts({ ...cronParts, minute: event.target.value })
|
|
}
|
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
|
|
placeholder="0-59 or *"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="cron-hour" className="text-xs font-medium text-slate-300">
|
|
Hour
|
|
</label>
|
|
<input
|
|
id="cron-hour"
|
|
type="text"
|
|
value={cronParts.hour}
|
|
onChange={(event) =>
|
|
applyCronParts({ ...cronParts, hour: event.target.value })
|
|
}
|
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
|
|
placeholder="0-23 or *"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="cron-day-of-month" className="text-xs font-medium text-slate-300">
|
|
Day of Month
|
|
</label>
|
|
<input
|
|
id="cron-day-of-month"
|
|
type="text"
|
|
value={cronParts.dayOfMonth}
|
|
onChange={(event) =>
|
|
applyCronParts({ ...cronParts, dayOfMonth: event.target.value })
|
|
}
|
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
|
|
placeholder="1-31 or *"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="cron-month" className="text-xs font-medium text-slate-300">
|
|
Month
|
|
</label>
|
|
<input
|
|
id="cron-month"
|
|
type="text"
|
|
value={cronParts.month}
|
|
onChange={(event) =>
|
|
applyCronParts({ ...cronParts, month: event.target.value })
|
|
}
|
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
|
|
placeholder="1-12 or *"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="cron-day-of-week" className="text-xs font-medium text-slate-300">
|
|
Day of Week
|
|
</label>
|
|
<input
|
|
id="cron-day-of-week"
|
|
type="text"
|
|
value={cronParts.dayOfWeek}
|
|
onChange={(event) =>
|
|
applyCronParts({ ...cronParts, dayOfWeek: event.target.value })
|
|
}
|
|
className="mt-1 w-full rounded-md border border-slate-700 bg-slate-900 px-2 py-2 text-sm text-slate-100"
|
|
placeholder="0-7 or *"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="mt-3 text-xs text-slate-400">{regularitySummary}</p>
|
|
<p className="mt-1 text-xs text-slate-500">Cron: {taskForm.scheduleCron}</p>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-slate-800 bg-slate-900 p-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!canSubmit) return
|
|
if (!entity) return
|
|
previewMutation.mutate({ entity, task: currentTask })
|
|
}}
|
|
disabled={!canSubmit || previewMutation.isPending}
|
|
className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50"
|
|
>
|
|
{previewMutation.isPending ? 'Generating…' : 'Generate Test Message'}
|
|
</button>
|
|
|
|
<div className="mt-4">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
|
Final Prompt
|
|
</p>
|
|
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
|
{finalPrompt}
|
|
</pre>
|
|
</div>
|
|
|
|
{previewError && (
|
|
<p role="alert" className="mt-4 text-sm text-red-300">
|
|
{previewError}
|
|
</p>
|
|
)}
|
|
|
|
{preview && (
|
|
<div className="mt-4">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
|
Generated Message
|
|
</p>
|
|
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
|
|
{preview}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pb-8">
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(`/entities/${entityId}`)}
|
|
className="rounded-md border border-slate-700 px-4 py-2 text-sm text-slate-200 hover:border-slate-500"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="rounded-md bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400 disabled:opacity-50"
|
|
disabled={!canSubmit || createTaskMutation.isPending}
|
|
>
|
|
{createTaskMutation.isPending ? 'Creating…' : 'Create Task'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|