mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
refactor(desktop): fold cron Blueprints into the New Job dialog
Blueprints lived behind a separate Jobs/Blueprints tab with its own card gallery — a bespoke surface no other overlay uses. Remove the tab and make blueprints a "Start from" dropdown at the top of the New Job dialog (default "Custom" = the manual editor); picking one swaps the form for that blueprint's typed slots. Also promote the detail-view "Trigger now" button to a primary action and adopt the shared Field primitive.
This commit is contained in:
parent
a3421aadda
commit
ef6049c215
7 changed files with 83 additions and 175 deletions
|
|
@ -1,16 +1,6 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { getAutomationBlueprints } from '@/hermes'
|
||||
import type { AutomationBlueprint, AutomationBlueprintField } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PanelDetail, PanelEmpty, PanelPill } from '../overlays/panel'
|
||||
|
||||
// The blueprint catalog is shared with the dashboard, so its deliver slot
|
||||
// defaults to "origin" (the chat/home-channel a dashboard or gateway job was
|
||||
|
|
@ -99,66 +89,3 @@ export function BlueprintSlotControl({
|
|||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// A clickable blueprint card — mirrors the app's other selectable cards
|
||||
// (theme/pet/gateway/profile pickers) via selectableCardClass. Clicking opens
|
||||
// the shared cron editor dialog pre-filled with this blueprint's slots; there's
|
||||
// no inline expand form or divider.
|
||||
function BlueprintCard({ blueprint, onSetUp }: { blueprint: AutomationBlueprint; onSetUp: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(selectableCardClass({ prominent: true }), 'w-full p-2 text-left')}
|
||||
onClick={onSetUp}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{blueprint.title}</p>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{blueprint.description}</p>
|
||||
{blueprint.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{blueprint.tags.map(tag => (
|
||||
<PanelPill key={tag}>{tag}</PanelPill>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Automation Blueprints gallery — the desktop counterpart to the dashboard's
|
||||
// blueprint tab. Each card opens the shared cron editor dialog pre-filled with
|
||||
// the blueprint's typed slots; submitting POSTs to
|
||||
// /api/cron/blueprints/instantiate, which fills the blueprint and creates the
|
||||
// job via the same create_job path as a hand-written cron.
|
||||
export function BlueprintsPanel({ onSetUp }: { onSetUp: (blueprint: AutomationBlueprint) => void }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
|
||||
const blueprints = useQuery({
|
||||
queryKey: ['cron-blueprints'],
|
||||
queryFn: async () => (await getAutomationBlueprints()).blueprints
|
||||
})
|
||||
|
||||
const cards = useMemo(() => blueprints.data ?? [], [blueprints.data])
|
||||
|
||||
if (blueprints.isLoading) {
|
||||
return <PageLoader label={c.blueprints.loading} />
|
||||
}
|
||||
|
||||
if (blueprints.isError) {
|
||||
return <PanelEmpty description={c.blueprints.failedLoad} icon="warning" title={c.blueprints.failedLoad} />
|
||||
}
|
||||
|
||||
if (cards.length === 0) {
|
||||
return <PanelEmpty description={c.blueprints.emptyDesc} icon="lightbulb" title={c.blueprints.emptyTitle} />
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelDetail>
|
||||
{cards.map(blueprint => (
|
||||
<BlueprintCard blueprint={blueprint} key={blueprint.key} onSetUp={() => onSetUp(blueprint)} />
|
||||
))}
|
||||
</PanelDetail>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon, codiconIcon } from '@/components/ui/codicon'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -14,8 +14,8 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field, FieldHint } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
type CronDeliveryTarget,
|
||||
type CronJob,
|
||||
deleteCronJob,
|
||||
getAutomationBlueprints,
|
||||
getCronDeliveryTargets,
|
||||
getCronJobRuns,
|
||||
getCronJobs,
|
||||
|
|
@ -70,27 +71,20 @@ import {
|
|||
} from '../overlays/panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import {
|
||||
BlueprintSlotControl,
|
||||
blueprintSlotHelp,
|
||||
BlueprintsPanel,
|
||||
cleanBlueprintFieldError,
|
||||
initialBlueprintValues
|
||||
} from './blueprints'
|
||||
import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, initialBlueprintValues } from './blueprints'
|
||||
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
// Two surfaces share the cron panel: the live Jobs list and the Blueprints
|
||||
// gallery (parameterized templates that instantiate a real job). The active tab
|
||||
// is pure view state — it lives here, not in a store.
|
||||
type CronTab = 'blueprints' | 'jobs'
|
||||
|
||||
// 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__'
|
||||
|
||||
// "Start from" default: the manual editor (blank cron). Any other value is a
|
||||
// blueprint key. Blueprint keys never collide with this sentinel.
|
||||
const CUSTOM_TEMPLATE = 'custom'
|
||||
|
||||
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
||||
{ expr: '0 9 * * *', value: 'daily' },
|
||||
{ expr: '0 9 * * 1-5', value: 'weekdays' },
|
||||
|
|
@ -305,7 +299,6 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
const pendingScrollRef = useRef<null | string>(null)
|
||||
const focusJobId = useStore($cronFocusJobId)
|
||||
|
||||
const [tab, setTab] = useState<CronTab>('jobs')
|
||||
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
|
||||
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
|
@ -455,12 +448,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
|
||||
// Blueprint instantiation is a distinct backend path (fills typed slots, then
|
||||
// creates the job) so it can't share the raw-cron onSave contract. Merge the
|
||||
// created job into $cronJobs like every other create path.
|
||||
async function handleBlueprintCreate(
|
||||
blueprint: AutomationBlueprint,
|
||||
values: Record<string, string>,
|
||||
profile: string
|
||||
) {
|
||||
// created job into $cronJobs like every other create path. A blueprint writes a
|
||||
// real per-profile job, and "all" is not a writable target — collapse it to
|
||||
// 'default', matching the manual create path in handleEditorSave.
|
||||
async function handleBlueprintCreate(blueprint: AutomationBlueprint, values: Record<string, string>) {
|
||||
const profile = profileScope === ALL_PROFILES ? 'default' : profileScope
|
||||
const job = await instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile)
|
||||
|
||||
updateCronJobs(rows => {
|
||||
|
|
@ -472,42 +464,11 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
setEditor({ mode: 'closed' })
|
||||
}
|
||||
|
||||
const tabToggle = (
|
||||
<SegmentedControl
|
||||
onChange={setTab}
|
||||
options={[
|
||||
{ icon: codiconIcon('watch'), id: 'jobs', label: c.tabs.jobs },
|
||||
{ icon: codiconIcon('lightbulb'), id: 'blueprints', label: c.blueprints.tab }
|
||||
]}
|
||||
value={tab}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Panel closeLabel={c.close} onClose={onClose}>
|
||||
<PanelHeader
|
||||
actions={tabToggle}
|
||||
subtitle={tab === 'jobs' ? c.count(totalCount) : c.blueprints.subtitle}
|
||||
title={c.title}
|
||||
/>
|
||||
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
|
||||
|
||||
{tab === 'blueprints' ? (
|
||||
// A blueprint instantiates a real per-profile job, and "all" is not a
|
||||
// writable target — collapse it to 'default', matching the create path
|
||||
// in handleEditorSave. A user scoped to all profiles gets the job in
|
||||
// 'default'. The gallery is a single scroll column, so it renders
|
||||
// directly (BlueprintsPanel uses PanelDetail) rather than in PanelBody's
|
||||
// master/detail row.
|
||||
<BlueprintsPanel
|
||||
onSetUp={blueprint =>
|
||||
setEditor({
|
||||
blueprint,
|
||||
mode: 'blueprint',
|
||||
profile: profileScope === ALL_PROFILES ? 'default' : profileScope
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : loading && jobs.length === 0 ? (
|
||||
{loading && jobs.length === 0 ? (
|
||||
<PageLoader label={c.loading} />
|
||||
) : totalCount === 0 ? (
|
||||
<PanelEmpty
|
||||
|
|
@ -663,7 +624,7 @@ function CronJobDetail({
|
|||
<PanelAction disabled={busy} icon={isPaused ? 'play' : 'debug-pause'} onClick={onPauseResume}>
|
||||
{isPaused ? c.resumeTitle : c.pauseTitle}
|
||||
</PanelAction>
|
||||
<PanelAction disabled={busy} icon="zap" onClick={onTrigger}>
|
||||
<PanelAction disabled={busy} icon="zap" onClick={onTrigger} primary>
|
||||
{c.triggerNow}
|
||||
</PanelAction>
|
||||
</div>
|
||||
|
|
@ -847,7 +808,7 @@ function CronEditorDialog({
|
|||
onSave
|
||||
}: {
|
||||
editor: EditorState
|
||||
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>, profile: string) => Promise<void>
|
||||
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>) => Promise<void>
|
||||
onClose: () => void
|
||||
onSave: (values: EditorValues) => Promise<void>
|
||||
}) {
|
||||
|
|
@ -855,8 +816,6 @@ function CronEditorDialog({
|
|||
const c = t.cron
|
||||
const open = editor.mode !== 'closed'
|
||||
const isEdit = editor.mode === 'edit'
|
||||
const isBlueprint = editor.mode === 'blueprint'
|
||||
const blueprint = isBlueprint ? editor.blueprint : null
|
||||
const initial = isEdit ? editor.job : null
|
||||
const scriptOnlyJob = initial ? jobIsScriptOnly(initial) : false
|
||||
|
||||
|
|
@ -868,14 +827,33 @@ function CronEditorDialog({
|
|||
// 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)
|
||||
// Blueprint mode fills typed slots (time/enum/weekdays/text) instead of the
|
||||
// raw cron fields; the backend renders the prompt + schedule from them.
|
||||
// Blueprint fills typed slots (time/enum/weekdays/text) instead of the raw
|
||||
// cron fields; the backend renders the prompt + schedule from them.
|
||||
const [slotValues, setSlotValues] = useState<Record<string, string>>({})
|
||||
// Create mode can start from a ready-made blueprint instead of a blank cron.
|
||||
// CUSTOM_TEMPLATE (default) = the manual editor; any other value is a
|
||||
// blueprint key that swaps the form for that blueprint's typed slots.
|
||||
const [templateChoice, setTemplateChoice] = useState(CUSTOM_TEMPLATE)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
// The blueprint catalog powers the create dialog's "Start from" dropdown; it's
|
||||
// meaningless when editing an existing job, so skip the fetch there.
|
||||
const blueprintsQuery = useQuery({
|
||||
queryKey: ['cron-blueprints'],
|
||||
queryFn: async () => (await getAutomationBlueprints()).blueprints,
|
||||
enabled: open && !isEdit
|
||||
})
|
||||
|
||||
const blueprintList = blueprintsQuery.data ?? []
|
||||
|
||||
const blueprint =
|
||||
templateChoice === CUSTOM_TEMPLATE ? null : (blueprintList.find(item => item.key === templateChoice) ?? null)
|
||||
|
||||
const isBlueprint = blueprint !== null
|
||||
|
||||
// Same catalog the chat model picker uses: configured providers and their
|
||||
// actually-available models only. Script-only + blueprint dialogs never pick a
|
||||
// actually-available models only. Script-only + blueprint forms never pick a
|
||||
// model here, so skip the fetch entirely for them.
|
||||
const modelOptions = useQuery({
|
||||
queryKey: ['model-options', 'global'],
|
||||
|
|
@ -903,10 +881,18 @@ function CronEditorDialog({
|
|||
setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily')
|
||||
setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER)
|
||||
setModelChoice(initial && jobModel(initial) ? `${jobProvider(initial)}:${jobModel(initial)}` : MODEL_DEFAULT_VALUE)
|
||||
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
|
||||
setSlotValues({})
|
||||
setTemplateChoice(CUSTOM_TEMPLATE)
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
}, [blueprint, initial, open])
|
||||
}, [initial, open])
|
||||
|
||||
// Seed the typed slots with the blueprint's defaults whenever a blueprint is
|
||||
// picked from "Start from" (and reset them when switching back to Custom).
|
||||
useEffect(() => {
|
||||
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
|
||||
setError(null)
|
||||
}, [blueprint])
|
||||
|
||||
const selectedScheduleOption =
|
||||
SCHEDULE_OPTIONS.find(candidate => candidate.value === schedulePreset) ?? SCHEDULE_OPTIONS[0]
|
||||
|
|
@ -988,7 +974,7 @@ function CronEditorDialog({
|
|||
async function handleBlueprintSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!isBlueprint) {
|
||||
if (!blueprint) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -996,7 +982,7 @@ function CronEditorDialog({
|
|||
setError(null)
|
||||
|
||||
try {
|
||||
await onBlueprintCreate(editor.blueprint, slotValues, editor.profile)
|
||||
await onBlueprintCreate(blueprint, slotValues)
|
||||
} catch (err) {
|
||||
// 422 carries the slot-level validation message; surface it inline.
|
||||
setError(cleanBlueprintFieldError(err instanceof Error ? err.message : String(err)))
|
||||
|
|
@ -1009,12 +995,29 @@ function CronEditorDialog({
|
|||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isBlueprint ? blueprint?.title : isEdit ? c.editTitle : c.createTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isBlueprint ? blueprint?.description || c.blueprints.dialogDesc : isEdit ? c.editDesc : c.createDesc}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
|
||||
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!isEdit && blueprintList.length > 0 && (
|
||||
<Field htmlFor="cron-template" label={c.blueprints.startFrom}>
|
||||
<Select onValueChange={setTemplateChoice} value={templateChoice}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-template">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CUSTOM_TEMPLATE}>{c.blueprints.custom}</SelectItem>
|
||||
{blueprintList.map(item => (
|
||||
<SelectItem key={item.key} value={item.key}>
|
||||
{item.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{blueprint?.description && <FieldHint>{blueprint.description}</FieldHint>}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isBlueprint && blueprint ? (
|
||||
<form className="grid gap-4" onSubmit={handleBlueprintSubmit}>
|
||||
{blueprint.fields.map(field => {
|
||||
|
|
@ -1192,39 +1195,7 @@ function CronEditorDialog({
|
|||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
children,
|
||||
htmlFor,
|
||||
label,
|
||||
optional,
|
||||
optionalLabel
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
htmlFor: string
|
||||
label: string
|
||||
optional?: boolean
|
||||
optionalLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
|
||||
{label}
|
||||
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldHint({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-[0.66rem] leading-4 text-muted-foreground">{children}</p>
|
||||
}
|
||||
|
||||
type EditorState =
|
||||
| { blueprint: AutomationBlueprint; mode: 'blueprint'; profile: string }
|
||||
| { job: CronJob; mode: 'edit' }
|
||||
| { mode: 'closed' }
|
||||
| { mode: 'create' }
|
||||
type EditorState = { job: CronJob; mode: 'edit' } | { mode: 'closed' } | { mode: 'create' }
|
||||
|
||||
interface EditorValues {
|
||||
deliver: string
|
||||
|
|
|
|||
|
|
@ -1688,6 +1688,8 @@ export const en: Translations = {
|
|||
},
|
||||
blueprints: {
|
||||
tab: 'Blueprints',
|
||||
startFrom: 'Start from',
|
||||
custom: 'Custom',
|
||||
subtitle: 'Ready-made automations',
|
||||
dialogDesc: 'Fill in the details and schedule it.',
|
||||
scheduleIt: 'Schedule it',
|
||||
|
|
|
|||
|
|
@ -1560,6 +1560,8 @@ export const ja = defineLocale({
|
|||
},
|
||||
blueprints: {
|
||||
tab: 'ブレーンプリント',
|
||||
startFrom: '開始点',
|
||||
custom: 'カスタム',
|
||||
subtitle: 'すぐに使える自動化',
|
||||
dialogDesc: '詳細を入力してスケジュールします。',
|
||||
scheduleIt: 'スケジュールする',
|
||||
|
|
|
|||
|
|
@ -1399,6 +1399,8 @@ export interface Translations {
|
|||
}
|
||||
blueprints: {
|
||||
tab: string
|
||||
startFrom: string
|
||||
custom: string
|
||||
subtitle: string
|
||||
dialogDesc: string
|
||||
scheduleIt: string
|
||||
|
|
|
|||
|
|
@ -1509,6 +1509,8 @@ export const zhHant = defineLocale({
|
|||
},
|
||||
blueprints: {
|
||||
tab: '藍圖',
|
||||
startFrom: '從此開始',
|
||||
custom: '自訂',
|
||||
subtitle: '現成的自動化',
|
||||
dialogDesc: '填寫詳細資訊並進行排程。',
|
||||
scheduleIt: '安排工作',
|
||||
|
|
|
|||
|
|
@ -1879,6 +1879,8 @@ export const zh: Translations = {
|
|||
},
|
||||
blueprints: {
|
||||
tab: '蓝图',
|
||||
startFrom: '从此开始',
|
||||
custom: '自定义',
|
||||
subtitle: '现成的自动化',
|
||||
dialogDesc: '填写详细信息并进行排程。',
|
||||
scheduleIt: '安排任务',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue