mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-27 20:20:20 +00:00
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:
@@ -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")
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user