feat(cron): dynamic delivery options from API instead of hardcoded select

Replace the hardcoded 4-option deliver dropdown (local/discord/telegram/slack)
with a dynamic select populated from a new GET /api/crons/delivery-options
endpoint that reads _KNOWN_DELIVERY_PLATFORMS from hermes-agent.

Key changes:
- Add GET /api/crons/delivery-options endpoint returning all known platforms
- Frontend loads options asynchronously on first cron form open, with caching
- Enable deliver editing for existing jobs (was previously disabled)
- Include deliver in update payload when editing cron jobs
- Fallback to local-only if API unavailable
- Custom deliver values (e.g. feishu:oc_xxx) shown with * suffix
- Add cron_deliver_custom i18n key to all 12 locales
- Add 5 integration tests for the new endpoint
This commit is contained in:
BonyFish
2026-05-27 10:58:49 +08:00
parent 08e9ce3d8a
commit ea3d4ec0b3
5 changed files with 141 additions and 6 deletions
+21
View File
@@ -4781,6 +4781,12 @@ def handle_get(handler, parsed) -> bool:
with cron_profile_context():
return _handle_cron_status(handler, parsed)
if parsed.path == "/api/crons/delivery-options":
from api.profiles import cron_profile_context
with cron_profile_context():
return _handle_cron_delivery_options(handler)
# ── Skills API (GET) ──
if parsed.path == "/api/skills":
qs = parse_qs(parsed.query)
@@ -9515,6 +9521,21 @@ def _handle_cron_create(handler, body):
return j(handler, {"error": str(e)}, status=400)
def _handle_cron_delivery_options(handler):
"""Return available delivery platforms for cron jobs."""
try:
from cron.scheduler import _KNOWN_DELIVERY_PLATFORMS
except Exception:
_KNOWN_DELIVERY_PLATFORMS = frozenset()
platforms = [
{"value": "local", "label": "Local (save output only)"},
{"value": "origin", "label": "Origin (reply to creator)"}
]
for name in sorted(_KNOWN_DELIVERY_PLATFORMS):
platforms.append({"value": name, "label": name.capitalize()})
return j(handler, {"platforms": platforms})
def _handle_cron_update(handler, body):
try:
require(body, "job_id")
+12
View File
@@ -1206,6 +1206,7 @@ const LOCALES = {
cron_prompt_label: 'Prompt',
cron_deliver_label: 'Deliver output to',
cron_deliver_local: 'Local (save output only)',
cron_deliver_custom: 'Custom delivery target',
cron_profile_label: 'Profile',
cron_profile_server_default: 'server default',
cron_profile_server_default_hint: 'Uses the WebUI server default profile at run time. Existing jobs without a profile keep this legacy behavior.',
@@ -2477,6 +2478,7 @@ const LOCALES = {
cron_prompt_label: 'Prompt',
cron_deliver_label: 'Recapita output a',
cron_deliver_local: 'Locale (solo salva output)',
cron_deliver_custom: 'Destinazione personalizzata',
cron_profile_label: 'Profilo',
cron_profile_server_default: 'predefinito server',
cron_profile_server_default_hint: 'Usa il profilo predefinito del server WebUI a runtime. I job esistenti senza profilo mantengono questo comportamento legacy.',
@@ -3753,6 +3755,7 @@ const LOCALES = {
cron_prompt_label: 'プロンプト',
cron_deliver_label: '出力先',
cron_deliver_local: 'ローカル (出力を保存のみ)',
cron_deliver_custom: 'カスタム配信先',
cron_profile_label: 'プロフィール',
cron_profile_server_default: 'サーバーデフォルト',
cron_profile_server_default_hint: '実行時に WebUI サーバーのデフォルトプロフィールを使用します。プロフィールのない既存ジョブはこの従来の動作を維持します。',
@@ -4793,6 +4796,7 @@ const LOCALES = {
cron_prompt_label: 'Запрос',
cron_deliver_label: 'Доставлять вывод',
cron_deliver_local: 'Локально (только сохранение)',
cron_deliver_custom: 'Пользовательская доставка',
cron_profile_label: 'Профиль',
cron_profile_server_default: 'по умолчанию сервера',
cron_profile_server_default_hint: 'Использует профиль WebUI-сервера по умолчанию во время запуска. Существующие задания без профиля сохраняют это поведение.',
@@ -5990,6 +5994,7 @@ const LOCALES = {
cron_prompt_label: 'Prompt',
cron_deliver_label: 'Entregar salida a',
cron_deliver_local: 'Local (solo guardar salida)',
cron_deliver_custom: 'Destino personalizado',
cron_profile_label: 'Perfil',
cron_profile_server_default: 'predeterminado del servidor',
cron_profile_server_default_hint: 'Usa el perfil predeterminado del servidor WebUI durante la ejecución. Los trabajos existentes sin perfil conservan este comportamiento heredado.',
@@ -6903,6 +6908,7 @@ const LOCALES = {
cron_prompt_label: 'Prompt',
cron_deliver_label: 'Ausgabe senden an',
cron_deliver_local: 'Lokal (nur speichern)',
cron_deliver_custom: 'Benutzerdefiniertes Ziel',
cron_profile_label: 'Profil',
cron_profile_server_default: 'Serverstandard',
cron_profile_server_default_hint: 'Verwendet zur Laufzeit das Standardprofil des WebUI-Servers. Bestehende Jobs ohne Profil behalten dieses Legacy-Verhalten.',
@@ -8417,6 +8423,7 @@ const LOCALES = {
cron_prompt_label: '提示词',
cron_deliver_label: '输出位置',
cron_deliver_local: '本地(仅保存输出)',
cron_deliver_custom: '自定义推送目标',
cron_profile_label: '配置档',
cron_profile_server_default: '服务器默认',
cron_profile_server_default_hint: '运行时使用 WebUI 服务器默认配置档。没有配置档的现有作业会保留此旧行为。',
@@ -9841,6 +9848,7 @@ const LOCALES = {
cron_prompt_label: '提示',
cron_deliver_label: '發送至',
cron_deliver_local: '僅本地儲存',
cron_deliver_custom: '自訂推送目標',
cron_profile_label: '設定檔',
cron_profile_server_default: '伺服器預設',
cron_profile_server_default_hint: '執行時使用 WebUI 伺服器預設設定檔。沒有設定檔的既有工作會保留此舊行為。',
@@ -10919,6 +10927,7 @@ const LOCALES = {
cron_prompt_label: 'Prompt',
cron_deliver_label: 'Entregar output para',
cron_deliver_local: 'Local (salvar output apenas)',
cron_deliver_custom: 'Destino personalizado',
cron_profile_label: 'Perfil',
cron_profile_server_default: 'padrão do servidor',
cron_profile_server_default_hint: 'Usa o perfil padrão do servidor WebUI no momento da execução. Tarefas existentes sem perfil mantêm esse comportamento legado.',
@@ -12163,6 +12172,7 @@ const LOCALES = {
cron_prompt_label: 'Prompt',
cron_deliver_label: 'Deliver output to',
cron_deliver_local: 'Local (save output only)',
cron_deliver_custom: '사용자 지정 전달 대상',
cron_profile_label: 'Profile',
cron_profile_server_default: 'server default',
cron_profile_server_default_hint: 'Uses the WebUI server default profile at run time. Existing jobs without a profile keep this legacy behavior.',
@@ -13340,6 +13350,7 @@ const LOCALES = {
cron_prompt_label: 'Rapide',
cron_deliver_label: 'Livrer la sortie à',
cron_deliver_local: 'Local (enregistrer la sortie uniquement)',
cron_deliver_custom: 'Destination personnalisee',
cron_profile_label: 'Profil',
cron_profile_server_default: 'serveur par défaut',
cron_profile_server_default_hint: 'Utilise le profil par défaut du serveur WebUI au moment de l\'exécution. Les tâches existantes sans profil conservent ce comportement hérité.',
@@ -14638,6 +14649,7 @@ const LOCALES = {
cron_prompt_label: 'Çabuk',
cron_deliver_label: 'Çıktıyı şuraya ilet:',
cron_deliver_local: 'Yerel (yalnızca çıktıyı kaydet)',
cron_deliver_custom: 'Ozel teslimat hedefi',
cron_profile_label: 'Profil',
cron_profile_server_default: 'sunucu varsayılanı',
cron_profile_server_default_hint: 'Çalışma zamanında WebUI sunucusunun varsayılan profilini kullanır. Profili olmayan mevcut işler bu eski davranışı sürdürüyor.',
+36 -6
View File
@@ -847,6 +847,7 @@ let _cronSelectedSkills=[];
let _cronIsDuplicate = false;
let _cronSkillsCache=null;
let _cronProfilesCache=null;
let _cronDeliveryOptionsCache=null;
function openCronCreate(){
if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
@@ -894,7 +895,6 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
const isNoAgent = !!no_agent;
const toastNotifications = toast_notifications !== false;
title.textContent = isEdit ? (t('edit') + ' · ' + (name || schedule || t('scheduled_jobs'))) : t('new_job');
const deliverOpt = (v,l) => `<option value="${v}"${deliver===v?' selected':''}>${esc(l)}</option>`;
body.innerHTML = `
<div class="main-view-content">
<form class="detail-form" onsubmit="event.preventDefault(); saveCronForm();">
@@ -915,11 +915,8 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
</div>
<div class="detail-form-row">
<label for="cronFormDeliver">${esc(t('cron_deliver_label') || 'Deliver output to')}</label>
<select id="cronFormDeliver" ${isEdit ? 'disabled' : ''}>
${deliverOpt('local', t('cron_deliver_local') || 'Local (save output only)')}
${deliverOpt('discord','Discord')}
${deliverOpt('telegram','Telegram')}
${deliverOpt('slack','Slack')}
<select id="cronFormDeliver">
<option value="" disabled>loading...</option>
</select>
</div>
<div class="detail-form-row">
@@ -951,6 +948,7 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
body.style.display = '';
if (empty) empty.style.display = 'none';
_setCronHeaderButtons(isEdit ? 'edit' : 'create');
_populateCronDeliverOptions(deliver, isEdit);
_renderCronSkillTags();
const scheduleEl = $('cronFormSchedule');
if (scheduleEl) {
@@ -962,6 +960,37 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
if (focusEl) focusEl.focus();
}
async function _populateCronDeliverOptions(selectedValue, isEdit) {
var sel = $('cronFormDeliver');
if (!sel) return;
sel.disabled = true;
try {
if (!_cronDeliveryOptionsCache) {
var res = await api('/api/crons/delivery-options');
_cronDeliveryOptionsCache = res && res.platforms ? res.platforms : [];
}
sel.innerHTML = '';
for (var i = 0; i < _cronDeliveryOptionsCache.length; i++) {
var p = _cronDeliveryOptionsCache[i];
var opt = document.createElement('option');
opt.value = p.value;
opt.textContent = p.label;
if (p.value === selectedValue) opt.selected = true;
sel.appendChild(opt);
}
if (selectedValue && !sel.querySelector('option[value="' + CSS.escape(selectedValue) + '"]')) {
var opt = document.createElement('option');
opt.value = selectedValue;
opt.textContent = selectedValue + ' *';
opt.selected = true;
sel.prepend(opt);
}
} catch (e) {
sel.innerHTML = '<option value="local">Local (save output only)</option>';
}
sel.disabled = false;
}
function _renderCronSkillTags(){
const wrap=$('cronFormSkillTags');
if(!wrap)return;
@@ -1045,6 +1074,7 @@ async function saveCronForm(){
const updates = {job_id: _editingCronId, schedule, profile: profile, toast_notifications: toastNotifications};
if (!isNoAgent) updates.prompt = prompt;
if (name) updates.name = name;
if (deliver) updates.deliver = deliver;
await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)});
const editedId = _editingCronId;
_editingCronId = null;
+5
View File
@@ -378,6 +378,11 @@ def pytest_collection_modifyitems(config, items):
'test_cron_update_unknown_job_404',
'test_cron_delete_unknown_404',
'test_crons_output_limit_param',
'test_delivery_options_returns_200',
'test_delivery_options_has_platforms',
'test_delivery_options_structure',
'test_delivery_options_includes_common_platforms',
'test_delivery_options_local_label',
# Skills endpoints (need tools.skills_tool module)
'test_skills_list',
'test_skills_list_has_required_fields',
+67
View File
@@ -0,0 +1,67 @@
"""Tests for GET /api/crons/delivery-options endpoint.
Verifies the dynamic delivery options API returns a structured list
of known platforms the user can choose as cron job delivery targets.
"""
import json
import urllib.request
import urllib.error
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def test_delivery_options_returns_200():
"""Endpoint exists and returns 200."""
result, status = get("/api/crons/delivery-options")
assert status == 200
def test_delivery_options_has_platforms():
"""Response contains a 'platforms' list with at least 'local'."""
result, status = get("/api/crons/delivery-options")
assert status == 200
assert "platforms" in result
platforms = result["platforms"]
assert isinstance(platforms, list)
assert len(platforms) > 0
# 'local' must always be present (it's the built-in default)
values = [p["value"] for p in platforms]
assert "local" in values, f"'local' missing from delivery options: {values}"
def test_delivery_options_structure():
"""Each platform entry has value and label."""
result, status = get("/api/crons/delivery-options")
assert status == 200
for p in result["platforms"]:
assert "value" in p, f"Platform entry missing 'value': {p}"
assert "label" in p, f"Platform entry missing 'label': {p}"
assert isinstance(p["value"], str)
assert isinstance(p["label"], str)
assert p["value"], "Platform value must not be empty"
assert p["label"], "Platform label must not be empty"
def test_delivery_options_includes_common_platforms():
"""Well-known platforms from _KNOWN_DELIVERY_PLATFORMS appear."""
result, status = get("/api/crons/delivery-options")
assert status == 200
values = [p["value"] for p in result["platforms"]]
# These are from the hardcoded _KNOWN_DELIVERY_PLATFORMS in hermes-agent
for expected in ("local", "telegram", "discord", "slack", "feishu"):
assert expected in values, f"Expected platform '{expected}' not found in: {values}"
def test_delivery_options_local_label():
"""'local' entry has a user-friendly label (not just 'Local')."""
result, status = get("/api/crons/delivery-options")
assert status == 200
local_entry = next(p for p in result["platforms"] if p["value"] == "local")
# Label should contain "Local" or be an i18n key — just verify it's non-empty
assert local_entry["label"], "Local platform label is empty"