mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(desktop): per-job model picker in the cron create/edit dialog (#67472)
The cron backend has always supported per-job model/provider pins (the dashboard web UI and the cronjob tool expose them), but the desktop app's cron editor had no way to set one — every job silently ran on the global default model. - Cron editor gains an optional Model select, grouped by provider, fed by the same model.options catalog as the chat model picker (configured providers with available models only, curated order preserved). - Resetting to 'Default (global model)' clears a previous pin (model and provider written as null); script-only (no_agent) jobs never touch the model fields since the scheduler ignores overrides for them. - A pinned model that has since left the catalog stays visible and re-selectable instead of rendering Radix's blank trigger. - Job detail pane shows the pinned model when one is set. - ui/select grows SelectGroup + SelectLabel primitives for the grouped list. - CronJob/CronJobCreatePayload/CronJobUpdates types carry model/provider; en/ja/zh/zh-hant locales add the two new labels. The cronjob model tool schema is intentionally unchanged — model selection stays a user-facing UX affordance, not an agent-facing tool parameter.
This commit is contained in:
parent
aa1ad32191
commit
1bf441cd19
10 changed files with 181 additions and 6 deletions
|
|
@ -35,7 +35,7 @@ describe('cronEditorUpdates', () => {
|
|||
it('omits prompt when saving a script-only job with an empty prompt', () => {
|
||||
expect(
|
||||
cronEditorUpdates(
|
||||
{ deliver: 'local', name: 'Weekly', prompt: '', schedule: '0 9 * * 1' },
|
||||
{ deliver: 'local', model: '', name: 'Weekly', prompt: '', provider: '', schedule: '0 9 * * 1' },
|
||||
{ scriptOnlyJob: true }
|
||||
)
|
||||
).toEqual({
|
||||
|
|
@ -48,9 +48,46 @@ describe('cronEditorUpdates', () => {
|
|||
it('includes prompt when the user typed one on a script-only job', () => {
|
||||
expect(
|
||||
cronEditorUpdates(
|
||||
{ deliver: 'email', name: 'Weekly', prompt: 'note', schedule: '0 9 * * 1' },
|
||||
{ deliver: 'email', model: '', name: 'Weekly', prompt: 'note', provider: '', schedule: '0 9 * * 1' },
|
||||
{ scriptOnlyJob: true }
|
||||
).prompt
|
||||
).toBe('note')
|
||||
})
|
||||
|
||||
it('writes the model override for agent jobs', () => {
|
||||
const updates = cronEditorUpdates(
|
||||
{
|
||||
deliver: 'local',
|
||||
model: 'claude-sonnet-4',
|
||||
name: 'Daily',
|
||||
prompt: 'go',
|
||||
provider: 'anthropic',
|
||||
schedule: '0 9 * * *'
|
||||
},
|
||||
{ scriptOnlyJob: false }
|
||||
)
|
||||
|
||||
expect(updates.model).toBe('claude-sonnet-4')
|
||||
expect(updates.provider).toBe('anthropic')
|
||||
})
|
||||
|
||||
it('clears a previous pin when the override is reset to default', () => {
|
||||
const updates = cronEditorUpdates(
|
||||
{ deliver: 'local', model: '', name: 'Daily', prompt: 'go', provider: '', schedule: '0 9 * * *' },
|
||||
{ scriptOnlyJob: false }
|
||||
)
|
||||
|
||||
expect(updates.model).toBe(null)
|
||||
expect(updates.provider).toBe(null)
|
||||
})
|
||||
|
||||
it('never touches model fields on script-only jobs', () => {
|
||||
const updates = cronEditorUpdates(
|
||||
{ deliver: 'local', model: 'x', name: 'Weekly', prompt: '', provider: 'y', schedule: '0 9 * * 1' },
|
||||
{ scriptOnlyJob: true }
|
||||
)
|
||||
|
||||
expect('model' in updates).toBe(false)
|
||||
expect('provider' in updates).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -36,8 +36,12 @@ export function validateCronEditor(input: CronEditorValidationInput): CronEditor
|
|||
|
||||
export interface CronEditorSaveValues {
|
||||
deliver: string
|
||||
/** Per-job model override ('' = follow the global default at fire time). */
|
||||
model: string
|
||||
name: string
|
||||
prompt: string
|
||||
/** Provider for the model override ('' = none). Always paired with model. */
|
||||
provider: string
|
||||
schedule: string
|
||||
}
|
||||
|
||||
|
|
@ -55,5 +59,14 @@ export function cronEditorUpdates(values: CronEditorSaveValues, options: { scrip
|
|||
updates.prompt = trimmedPrompt
|
||||
}
|
||||
|
||||
// Script-only jobs never run an agent, so the scheduler ignores model
|
||||
// overrides — leave whatever is stored untouched. For agent jobs, always
|
||||
// write both axes so resetting to "default" clears a previous pin (the
|
||||
// backend normalizes null/'' to "no override").
|
||||
if (!options.scriptOnlyJob) {
|
||||
updates.model = values.model.trim() || null
|
||||
updates.provider = values.provider.trim() || null
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
|
|
@ -14,7 +15,15 @@ import {
|
|||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
createCronJob,
|
||||
|
|
@ -30,6 +39,7 @@ import {
|
|||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { requestModelOptions } from '@/lib/model-options'
|
||||
import { asText } from '@/lib/text'
|
||||
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -59,6 +69,10 @@ import { jobState, jobTitle, STATE_DOT } from './job-state'
|
|||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
// Radix <SelectItem> rejects empty-string values, so the "no override" row in
|
||||
// the model picker carries this sentinel and is mapped back to '' on save.
|
||||
const MODEL_DEFAULT_VALUE = '__default__'
|
||||
|
||||
const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email']
|
||||
|
||||
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
||||
|
|
@ -103,6 +117,14 @@ function jobDeliver(job: CronJob): string {
|
|||
return asText(job.deliver) || DEFAULT_DELIVER
|
||||
}
|
||||
|
||||
function jobModel(job: CronJob): string {
|
||||
return asText(job.model).trim()
|
||||
}
|
||||
|
||||
function jobProvider(job: CronJob): string {
|
||||
return asText(job.provider).trim()
|
||||
}
|
||||
|
||||
function cronParts(expr: string): null | string[] {
|
||||
const parts = expr.trim().replace(/\s+/g, ' ').split(' ')
|
||||
|
||||
|
|
@ -391,7 +413,8 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
prompt: values.prompt,
|
||||
schedule: values.schedule,
|
||||
name: values.name || undefined,
|
||||
deliver: values.deliver || DEFAULT_DELIVER
|
||||
deliver: values.deliver || DEFAULT_DELIVER,
|
||||
...(values.model.trim() ? { model: values.model.trim(), provider: values.provider.trim() || undefined } : {})
|
||||
})
|
||||
|
||||
updateCronJobs(rows => [...rows, created])
|
||||
|
|
@ -550,6 +573,7 @@ function CronJobDetail({
|
|||
const isPaused = state === 'paused'
|
||||
const deliver = jobDeliver(job)
|
||||
const prompt = jobPrompt(job)
|
||||
const modelOverride = jobModel(job)
|
||||
|
||||
return (
|
||||
<PanelDetail>
|
||||
|
|
@ -574,7 +598,8 @@ function CronJobDetail({
|
|||
{ label: c.frequencyLabel, value: jobScheduleDisplay(job) },
|
||||
{ label: c.last.replace(/:$/, ''), value: formatTime(job.last_run_at) },
|
||||
{ label: c.next.replace(/:$/, ''), value: formatTime(job.next_run_at) },
|
||||
{ label: c.deliverLabel, value: c.deliveryLabels[deliver] ?? deliver }
|
||||
{ label: c.deliverLabel, value: c.deliveryLabels[deliver] ?? deliver },
|
||||
...(modelOverride ? [{ label: c.modelLabel, value: modelOverride }] : [])
|
||||
]}
|
||||
/>
|
||||
|
||||
|
|
@ -717,9 +742,21 @@ function CronEditorDialog({
|
|||
const [schedule, setSchedule] = useState('')
|
||||
const [schedulePreset, setSchedulePreset] = useState('daily')
|
||||
const [deliver, setDeliver] = useState(DEFAULT_DELIVER)
|
||||
// Per-job model override, encoded as `${providerSlug}:${model}` (split on the
|
||||
// first ':' when saving). MODEL_DEFAULT_VALUE = follow the global default.
|
||||
const [modelChoice, setModelChoice] = useState(MODEL_DEFAULT_VALUE)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
// Same catalog the chat model picker uses: configured providers and their
|
||||
// actually-available models only. Script-only jobs never run an agent, so
|
||||
// skip the fetch entirely for them.
|
||||
const modelOptions = useQuery({
|
||||
queryKey: ['model-options', 'global'],
|
||||
queryFn: () => requestModelOptions({}),
|
||||
enabled: open && !scriptOnlyJob
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
|
|
@ -730,6 +767,7 @@ function CronEditorDialog({
|
|||
setSchedule(initial ? jobScheduleExpr(initial) : (SCHEDULE_OPTIONS[0].expr ?? ''))
|
||||
setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily')
|
||||
setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER)
|
||||
setModelChoice(initial && jobModel(initial) ? `${jobProvider(initial)}:${jobModel(initial)}` : MODEL_DEFAULT_VALUE)
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
}, [initial, open])
|
||||
|
|
@ -752,6 +790,19 @@ function CronEditorDialog({
|
|||
|
||||
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule, c)
|
||||
|
||||
// Configured providers with at least one available model — mirrors the chat
|
||||
// model picker's gate so only actually-selectable models are offered.
|
||||
const modelProviders = (modelOptions.data?.providers ?? []).filter(
|
||||
provider => provider.authenticated !== false && (provider.models ?? []).length > 0
|
||||
)
|
||||
|
||||
// A previously pinned model that has since left the catalog (provider
|
||||
// removed / model retired) would render Radix's blank trigger. Keep the
|
||||
// stored pin visible and re-selectable rather than silently dropping it.
|
||||
const modelChoiceKnown =
|
||||
modelChoice === MODEL_DEFAULT_VALUE ||
|
||||
modelProviders.some(provider => (provider.models ?? []).some(model => `${provider.slug}:${model}` === modelChoice))
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
|
|
@ -773,14 +824,22 @@ function CronEditorDialog({
|
|||
return
|
||||
}
|
||||
|
||||
// Decode `${providerSlug}:${model}` — the model half may itself contain
|
||||
// ':' (e.g. openrouter 'anthropic/claude-sonnet-4:beta'), so split once.
|
||||
const overrideIndex = modelChoice === MODEL_DEFAULT_VALUE ? -1 : modelChoice.indexOf(':')
|
||||
const overrideProvider = overrideIndex >= 0 ? modelChoice.slice(0, overrideIndex) : ''
|
||||
const overrideModel = overrideIndex >= 0 ? modelChoice.slice(overrideIndex + 1) : ''
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onSave({
|
||||
deliver,
|
||||
model: overrideModel,
|
||||
name: name.trim(),
|
||||
prompt: prompt.trim(),
|
||||
provider: overrideProvider,
|
||||
schedule: schedule.trim()
|
||||
})
|
||||
} catch (err) {
|
||||
|
|
@ -857,6 +916,38 @@ function CronEditorDialog({
|
|||
</Field>
|
||||
</div>
|
||||
|
||||
{!scriptOnlyJob && (
|
||||
<Field htmlFor="cron-model" label={c.modelLabel} optional optionalLabel={c.optional}>
|
||||
<Select onValueChange={setModelChoice} value={modelChoice}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-model">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={MODEL_DEFAULT_VALUE}>{c.modelDefault}</SelectItem>
|
||||
{!modelChoiceKnown && (
|
||||
<SelectItem className="font-mono" value={modelChoice}>
|
||||
{modelChoice.slice(modelChoice.indexOf(':') + 1)}
|
||||
</SelectItem>
|
||||
)}
|
||||
{modelProviders.map(provider => (
|
||||
<SelectGroup key={provider.slug}>
|
||||
<SelectLabel>{provider.name}</SelectLabel>
|
||||
{(provider.models ?? []).map(model => (
|
||||
<SelectItem
|
||||
className="font-mono"
|
||||
key={`${provider.slug}:${model}`}
|
||||
value={`${provider.slug}:${model}`}
|
||||
>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{schedulePreset === 'custom' ? (
|
||||
<Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
|
||||
<Input
|
||||
|
|
@ -930,8 +1021,12 @@ type EditorState = { mode: 'closed' } | { mode: 'create' } | { job: CronJob; mod
|
|||
|
||||
interface EditorValues {
|
||||
deliver: string
|
||||
/** Per-job model override ('' = follow the global default). */
|
||||
model: string
|
||||
name: string
|
||||
prompt: string
|
||||
/** Provider slug for the model override ('' = none). */
|
||||
provider: string
|
||||
schedule: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,20 @@ function SelectContent({
|
|||
)
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
className={cn('px-2 py-1.5 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground', className)}
|
||||
data-slot="select-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
|
|
@ -89,4 +103,4 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
|
|||
)
|
||||
}
|
||||
|
||||
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }
|
||||
export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue }
|
||||
|
|
|
|||
|
|
@ -1527,6 +1527,8 @@ export const en: Translations = {
|
|||
promptPlaceholder: 'Summarize my unread Slack threads and email me the top 5...',
|
||||
frequencyLabel: 'Frequency',
|
||||
deliverLabel: 'Deliver to',
|
||||
modelLabel: 'Model',
|
||||
modelDefault: 'Default (global model)',
|
||||
customScheduleLabel: 'Custom schedule',
|
||||
customPlaceholder: '0 9 * * * or weekdays at 9am',
|
||||
customHint: 'Cron expression, or phrases like "every hour" or "weekdays at 9am".',
|
||||
|
|
|
|||
|
|
@ -1454,6 +1454,8 @@ export const ja = defineLocale({
|
|||
promptPlaceholder: '実行ごとにエージェントが行う内容は?',
|
||||
frequencyLabel: '頻度',
|
||||
deliverLabel: '配信先',
|
||||
modelLabel: 'モデル',
|
||||
modelDefault: 'デフォルト(グローバルモデル)',
|
||||
customScheduleLabel: 'カスタムスケジュール',
|
||||
customPlaceholder: '0 9 * * * または weekdays at 9am',
|
||||
customHint: 'Cron 式、または「every hour」「weekdays at 9am」のようなフレーズ。',
|
||||
|
|
|
|||
|
|
@ -1258,6 +1258,8 @@ export interface Translations {
|
|||
promptPlaceholder: string
|
||||
frequencyLabel: string
|
||||
deliverLabel: string
|
||||
modelLabel: string
|
||||
modelDefault: string
|
||||
customScheduleLabel: string
|
||||
customPlaceholder: string
|
||||
customHint: string
|
||||
|
|
|
|||
|
|
@ -1407,6 +1407,8 @@ export const zhHant = defineLocale({
|
|||
promptPlaceholder: '代理每次執行時應做什麼?',
|
||||
frequencyLabel: '頻率',
|
||||
deliverLabel: '傳遞至',
|
||||
modelLabel: '模型',
|
||||
modelDefault: '預設(全域模型)',
|
||||
customScheduleLabel: '自訂排程',
|
||||
customPlaceholder: '0 9 * * * 或 weekdays at 9am',
|
||||
customHint: 'Cron 表達式,或類似「每小時」「工作日上午 9 點」的短語。',
|
||||
|
|
|
|||
|
|
@ -1710,6 +1710,8 @@ export const zh: Translations = {
|
|||
promptPlaceholder: '总结我未读的 Slack 话题,并把前 5 条邮件发给我…',
|
||||
frequencyLabel: '频率',
|
||||
deliverLabel: '投递至',
|
||||
modelLabel: '模型',
|
||||
modelDefault: '默认(全局模型)',
|
||||
customScheduleLabel: '自定义排程',
|
||||
customPlaceholder: '0 9 * * * 或 weekdays at 9am',
|
||||
customHint: 'Cron 表达式,或类似"每小时""工作日上午 9 点"的短语。',
|
||||
|
|
|
|||
|
|
@ -572,10 +572,12 @@ export interface CronJob {
|
|||
id: string
|
||||
last_error?: null | string
|
||||
last_run_at?: null | string
|
||||
model?: null | string
|
||||
name?: null | string
|
||||
next_run_at?: null | string
|
||||
no_agent?: boolean
|
||||
prompt?: null | string
|
||||
provider?: null | string
|
||||
schedule?: CronJobSchedule
|
||||
schedule_display?: null | string
|
||||
script?: null | string
|
||||
|
|
@ -584,8 +586,10 @@ export interface CronJob {
|
|||
|
||||
export interface CronJobCreatePayload {
|
||||
deliver?: string
|
||||
model?: string
|
||||
name?: string
|
||||
prompt: string
|
||||
provider?: string
|
||||
schedule: string
|
||||
}
|
||||
|
||||
|
|
@ -598,8 +602,10 @@ export interface CronJobSchedule {
|
|||
export interface CronJobUpdates {
|
||||
deliver?: string
|
||||
enabled?: boolean
|
||||
model?: null | string
|
||||
name?: string
|
||||
prompt?: string
|
||||
provider?: null | string
|
||||
schedule?: string
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue