Inherit agent presets and auto-bind IM delivery targets (0.1.7).

Cron jobs created from WhatsApp can omit bot/target ids and reuse chat overrides; sidebar/agent preset pins persist on fire.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-06 02:31:43 +08:00
parent 2f08136adf
commit 2e690c30c8
15 changed files with 830 additions and 82 deletions

View file

@ -161,8 +161,13 @@ window.__ModuleLoader__.load({
const inject = ['slots', 'locale', 'settingsScope']
const API = '/dsh-ops-cron'
const LOCALE_NS = 'settings.dshCronTasks'
const TITLE_PREFIX = '运维定时 · '
const listSnapshot = { jobs: [], runs: [], workspaces: [], catalog: { groups: [], current: null } }
const TITLE_PREFIX = '定时任务 · '
const listSnapshot = {
jobs: [], runs: [], workspaces: [],
catalog: { groups: [], current: null },
presets: { items: [], current: null },
imCatalog: { available: true, options: [], loading: false },
}
function jobStamp(rows) {
return (rows || []).map((row) => `${row.id}:${row.updatedAt}:${row.nextRunAt}:${row.enabled}:${row.lastStatus}:${row.model}:${row.provider}:${row.delivery?.kind}:${row.delivery?.targetId}`).join('|')
@ -291,19 +296,19 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
`
const zh = {
title: '运维定时',
title: '定时任务',
description: '调度策略:启用、默认时区、历史保留、重叠与漏跑。',
expand: '展开设置', collapse: '收起设置', save: '保存', saving: '保存中…', discard: '放弃修改',
saveFailed: '本部署没有接受这些值,已保留供你修改。',
enabled: '启用调度器', timezone: '默认时区', historyLimit: '历史保留条数',
overlapPolicy: '重叠策略', misfirePolicy: '漏跑策略', policySkip: '跳过',
hint: '任务在左侧「新会话」下方进入。运行结果会出现在历史行里,点击记录会打开和普通会话一样的对话页,可以继续聊。',
entry: '运维定时', entryLabel: '打开运维定时',
entry: '定时任务', entryLabel: '打开定时任务',
backToWorkspace: '返回工作区', backLabel: '返回工作区',
newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程',
cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间',
create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除',
emptyJobs: '还没有运维定时。点右上角 + 新建。', next: '下次', last: '上次',
emptyJobs: '还没有定时任务。点右上角 + 新建。', next: '下次', last: '上次',
expandRuns: '展开记录', collapseRuns: '收起记录',
search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。',
paused: '已暂停',
@ -313,7 +318,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。',
model: '模型',
modelDefault: '每次运行用当时的新会话默认',
modelHint: '运维定时会消耗这个模型的额度。指定后不会跟着聊天模型变。',
modelHint: '定时任务会消耗这个模型的额度。指定后不会跟着聊天模型变。',
agentPreset: 'Agent Preset',
agentPresetDefault: '每次运行用当时的 Host 默认',
agentPresetHint: '指定后每次触发都挂载该 Preset;留空则跟随 Host 默认(创建时若从 WhatsApp 继承会自动写入)。',
editorLead: '保存后按日程在新会话里执行;若投递为 IM,结束后还会发摘要到 WhatsApp/IM。点击下方某次运行,右侧会打开那次对话,可以继续。',
lastOutput: '上次输出', noOutput: '还没有输出。先立即运行一次。',
delivery: '投递',
@ -321,17 +329,22 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
deliveryIm: 'WhatsApp / IM',
imBotId: 'IM Bot ID',
imTargetId: 'IM Target ID',
deliveryHint: 'Web 默认 DSH;WhatsApp 会话里由 Agent 创建时可自动匹配投递目标。也可粘贴 IM「投递设置」里的 botId/targetId。',
imTarget: '投递目标',
imTargetNone: '(请先在 IM「投递设置」新建目标)',
imTargetManual: '手动填写…',
imCatalogLoading: '正在加载投递目标…',
imCatalogUnavailable: '无法加载投递目标(需 dsh-im-ops ≥ops.24,且已配置投递目标)',
deliveryHint: '选 WhatsApp/IM 后从下拉选择已保存的投递目标;也可手动填写。目标在 IM 机器人 → 投递设置里创建。',
}
const en = {
title: 'Ops schedule',
title: 'Scheduled tasks',
description: 'Scheduler policy: enable, default time zone, history retention, overlap and misfire.',
expand: 'Show settings', collapse: 'Hide settings', save: 'Save', saving: 'Saving…', discard: 'Discard',
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
enabled: 'Enable scheduler', timezone: 'Default time zone', historyLimit: 'History retention',
overlapPolicy: 'Overlap policy', misfirePolicy: 'Misfire policy', policySkip: 'Skip',
hint: 'Open from under New Session. Run output appears in history; click a run to continue in the normal chat view.',
entry: 'Ops schedule', entryLabel: 'Open ops schedule',
entry: 'Scheduled tasks', entryLabel: 'Open scheduled tasks',
backToWorkspace: 'Back to workspace', backLabel: 'Back to workspace',
newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule',
cron: 'Cron (recurring)', at: 'One-shot time', expr: 'Cron expression', atTime: 'Time',
@ -347,6 +360,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
model: 'Model',
modelDefault: 'Use the New Session default at fire time',
modelHint: 'Scheduled runs consume this model\'s quota. A pinned model will not follow the chat selector.',
agentPreset: 'Agent preset',
agentPresetDefault: 'Use the Host default at fire time',
agentPresetHint: 'Pin a preset for every fire. Leave empty to follow the Host default (WhatsApp-created jobs inherit the chat preset automatically).',
editorLead: 'Runs start a new session. IM delivery also sends a summary via dshIm after the turn. Click a run below to open that conversation.',
lastOutput: 'Last output', noOutput: 'No output yet. Run it once.',
delivery: 'Delivery',
@ -354,7 +370,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
deliveryIm: 'WhatsApp / IM',
imBotId: 'IM Bot ID',
imTargetId: 'IM Target ID',
deliveryHint: 'Web defaults to DSH. WhatsApp sessions can auto-match a delivery target. Or paste botId/targetId from IM delivery settings.',
imTarget: 'Delivery target',
imTargetNone: '(Create a target in IM delivery settings first)',
imTargetManual: 'Enter manually…',
imCatalogLoading: 'Loading delivery targets…',
imCatalogUnavailable: 'Cannot load targets (need dsh-im-ops ≥ops.24 with saved targets)',
deliveryHint: 'Pick a saved IM delivery target from the list, or enter botId/targetId manually. Create targets under IM bot → Delivery settings.',
}
function normalizePrefs(value) {
@ -597,6 +618,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
timezone: timezone || DEFAULTS.timezone, cwd: '', timeoutMinutes: 10, enabled: true,
provider: current?.provider || '',
model: current?.model || '',
agentPreset: '',
deliveryKind: 'dsh',
imBotId: '',
imTargetId: '',
@ -616,6 +638,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
enabled: job.enabled !== false,
provider: job.provider || '',
model: job.model || '',
agentPreset: job.agentPreset || '',
deliveryKind: job.delivery?.kind === 'im' ? 'im' : 'dsh',
imBotId: job.delivery?.botId || '',
imTargetId: job.delivery?.targetId || '',
@ -858,7 +881,110 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
)
}
function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, catalog, onSave, onRun, onPause, onRemove }) {
function PresetField({ t, form, setForm, presets }) {
const items = Array.isArray(presets?.items) ? presets.items : []
const current = presets?.current
const selected = form.agentPreset || ''
const known = items.some((row) => row.id === selected)
const defaultLabel = current?.id
? `${t('agentPresetDefault')}(${current.name || current.id})`
: t('agentPresetDefault')
return h('label', null, t('agentPreset'),
h('select', {
value: selected,
onChange: (e) => setForm({ ...form, agentPreset: e.target.value }),
},
h('option', { value: '' }, defaultLabel),
!known && selected
? h('option', { value: selected }, selected)
: null,
...items.map((row) => h('option', {
value: row.id,
key: row.id,
}, row.name || row.id)),
),
h('span', { className: 'dsh-ct-cwdHint' }, t('agentPresetHint')),
)
}
function imOptionKey(botId, targetId) {
return `${botId || ''}::${targetId || ''}`
}
function parseImOptionKey(value) {
const raw = String(value || '')
const at = raw.indexOf('::')
if (at <= 0) return { botId: '', targetId: '' }
return { botId: raw.slice(0, at), targetId: raw.slice(at + 2) }
}
function ImDeliveryFields({ t, form, setForm, imCatalog }) {
const options = Array.isArray(imCatalog?.options) ? imCatalog.options : []
const loading = !!imCatalog?.loading
const available = imCatalog?.available !== false
const selectedKey = form.imBotId && form.imTargetId
? imOptionKey(form.imBotId, form.imTargetId)
: ''
const matched = options.some((row) => imOptionKey(row.botId, row.targetId) === selectedKey)
const [manual, setManual] = useState(() => !!(selectedKey && !matched))
useEffect(() => {
if (selectedKey && !matched && !loading) setManual(true)
if (selectedKey && matched) setManual(false)
}, [selectedKey, matched, loading])
const selectValue = manual
? '__manual__'
: (matched ? selectedKey : '')
const showManual = manual || (selectedKey && !matched && !loading)
return h(React.Fragment, null,
h('label', null, t('imTarget'),
h('select', {
value: selectValue,
disabled: loading,
onChange: (e) => {
const value = e.target.value
if (value === '__manual__') {
setManual(true)
return
}
setManual(false)
if (!value) {
setForm({ ...form, imBotId: '', imTargetId: '' })
return
}
const next = parseImOptionKey(value)
setForm({ ...form, imBotId: next.botId, imTargetId: next.targetId })
},
},
h('option', { value: '' }, loading ? t('imCatalogLoading') : (
options.length === 0 ? t('imTargetNone') : `— ${t('imTarget')} —`
)),
...options.map((row) => h('option', {
key: imOptionKey(row.botId, row.targetId),
value: imOptionKey(row.botId, row.targetId),
}, `${row.name || row.targetId} · ${row.channel || 'im'} · ${row.targetId}`)),
h('option', { value: '__manual__' }, t('imTargetManual')),
),
),
!available || (options.length === 0 && !loading)
? h('span', { className: 'dsh-ct-cwdHint' }, imCatalog?.hint || t('imCatalogUnavailable'))
: null,
showManual
? h('div', { className: 'dsh-ct-editorRow' },
h('label', null, t('imBotId'), h('input', {
value: form.imBotId || '',
onChange: (e) => setForm({ ...form, imBotId: e.target.value }),
})),
h('label', null, t('imTargetId'), h('input', {
value: form.imTargetId || '',
onChange: (e) => setForm({ ...form, imTargetId: e.target.value }),
})),
)
: null,
)
}
function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, catalog, presets, imCatalog, onSave, onRun, onPause, onRemove }) {
return h('div', { className: 'dsh-ct-editor' },
h('h1', null, isNew ? t('newJob') : (form.name || t('title'))),
h('p', { className: 'dsh-ct-editorLead' }, t('editorLead')),
@ -890,6 +1016,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
),
h(CwdField, { t, form, setForm, workspaces }),
h(ModelField, { t, form, setForm, catalog }),
h(PresetField, { t, form, setForm, presets }),
h('label', null, t('delivery'),
h('select', {
value: form.deliveryKind || 'dsh',
@ -900,16 +1027,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
),
),
form.deliveryKind === 'im'
? h('div', { className: 'dsh-ct-editorRow' },
h('label', null, t('imBotId'), h('input', {
value: form.imBotId || '',
onChange: (e) => setForm({ ...form, imBotId: e.target.value }),
})),
h('label', null, t('imTargetId'), h('input', {
value: form.imTargetId || '',
onChange: (e) => setForm({ ...form, imTargetId: e.target.value }),
})),
)
? h(ImDeliveryFields, { t, form, setForm, imCatalog })
: null,
h('span', { className: 'dsh-ct-cwdHint' }, t('deliveryHint')),
h('div', { className: 'dsh-ct-editorActs' },
@ -935,6 +1053,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const [cronMode, setCronMode] = useState(false)
const [workspaces, setWorkspaces] = useState(() => listSnapshot.workspaces)
const [catalog, setCatalog] = useState(() => listSnapshot.catalog)
const [presets, setPresets] = useState(() => listSnapshot.presets || { items: [], current: null })
const [imCatalog, setImCatalog] = useState(() => listSnapshot.imCatalog || { available: true, options: [], loading: false })
const [paneHost, setPaneHost] = useState(() => (typeof document === 'undefined' ? null : document.body))
const skipAutoSelect = useRef(false)
@ -957,18 +1077,44 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
}
}, [])
const loadImCatalog = useCallback(async () => {
setImCatalog((prev) => ({ ...prev, loading: true }))
try {
const body = await api('/im-catalog')
const next = {
available: body.available !== false,
options: Array.isArray(body.options) ? body.options : [],
hint: body.hint || '',
loading: false,
}
listSnapshot.imCatalog = next
setImCatalog(next)
} catch (err) {
const next = {
available: false,
options: [],
hint: err instanceof Error ? err.message : String(err),
loading: false,
}
listSnapshot.imCatalog = next
setImCatalog(next)
}
}, [])
const load = useCallback(async () => {
try {
const [jobBody, histBody, wsBody, modelBody] = await Promise.all([
const [jobBody, histBody, wsBody, modelBody, presetBody] = await Promise.all([
api('/jobs'),
api('/history'),
api('/workspaces').catch(() => ({ workspaces: [] })),
api('/models').catch(() => ({ groups: [], current: null })),
api('/presets').catch(() => ({ items: [], current: null })),
])
const nextJobs = jobBody.jobs || []
const nextRuns = histBody.runs || []
const nextWorkspaces = wsBody.workspaces || []
const nextCatalog = { groups: modelBody.groups || [], current: modelBody.current || null }
const nextPresets = { items: presetBody.items || [], current: presetBody.current || null }
if (jobStamp(listSnapshot.jobs) !== jobStamp(nextJobs)) {
listSnapshot.jobs = nextJobs
setJobs(nextJobs)
@ -979,8 +1125,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
}
listSnapshot.workspaces = nextWorkspaces
listSnapshot.catalog = nextCatalog
listSnapshot.presets = nextPresets
setWorkspaces(nextWorkspaces)
setCatalog(nextCatalog)
setPresets(nextPresets)
faces.cronSessionIds = new Set(nextRuns.map((run) => run.sessionId).filter(Boolean))
setError('')
} catch (err) {
@ -1002,6 +1150,11 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
}
}, [load, cronMode])
useEffect(() => {
if (!cronMode || form.deliveryKind !== 'im') return
loadImCatalog()
}, [cronMode, form.deliveryKind, loadImCatalog])
useEffect(() => {
if (!cronMode) {
skipAutoSelect.current = false
@ -1038,9 +1191,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
if ((!next.provider || !next.model) && catalog.current?.provider && catalog.current?.model) {
next = { ...next, provider: catalog.current.provider, model: catalog.current.model }
}
if (!next.agentPreset && presets.current?.id) {
next = { ...next, agentPreset: presets.current.id }
}
return next
})
}, [cronMode, selection.type, catalog.current])
}, [cronMode, selection.type, catalog.current, presets.current])
function selectJob(jobId) {
const job = jobs.find((row) => row.id === jobId)
@ -1084,6 +1240,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
name: form.name, prompt: form.prompt, schedule,
cwd: form.cwd, timeoutMinutes: form.timeoutMinutes, enabled: form.enabled,
provider: form.provider, model: form.model,
agentPreset: form.agentPreset || '',
delivery: form.deliveryKind === 'im'
? { kind: 'im', botId: form.imBotId, targetId: form.imTargetId }
: { kind: 'dsh' },
@ -1132,6 +1289,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
lastRunAt: selectedJob?.lastRunAt,
workspaces,
catalog,
presets,
imCatalog,
onSave: save,
onRun: () => selection.jobId && run(selection.jobId),
onPause: () => {
@ -1197,7 +1356,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
function isCronTitle(text) {
const value = String(text || '').trim()
return value.startsWith('运维定时') || value.startsWith(TITLE_PREFIX.trim()) || value.includes(TITLE_PREFIX)
return value.startsWith('定时任务')
|| value.startsWith('运维定时')
|| value.startsWith(TITLE_PREFIX.trim())
|| value.includes(TITLE_PREFIX)
}
function workspaceTitleFromCronFork(title) {
@ -1207,6 +1369,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
}
if (value.startsWith('定时任务')) {
const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim()
return stripped || value
}
return value
}
@ -1219,7 +1385,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const parent = String(row.parentId || row.parentSessionId || '').trim()
if (parent && ids.has(parent)) return true
const title = String(row.title || row.displayTitle || '')
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('运维定时')
const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('定时任务') || title.startsWith('运维定时')
if (previousId && ids.has(String(previousId)) && looksCron) return true
return false
}