diff --git a/api/routes.py b/api/routes.py index a8d32521..c80d74aa 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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") diff --git a/static/i18n.js b/static/i18n.js index 4913139d..b746cee5 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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.', diff --git a/static/panels.js b/static/panels.js index a04f45b1..ec6409a9 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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) => ``; body.innerHTML = `
@@ -915,11 +915,8 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
- +
@@ -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 = ''; + } + 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; diff --git a/tests/conftest.py b/tests/conftest.py index 39ab0c1d..5982dd12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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', diff --git a/tests/test_cron_delivery_options.py b/tests/test_cron_delivery_options.py new file mode 100644 index 00000000..9e140ab8 --- /dev/null +++ b/tests/test_cron_delivery_options.py @@ -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"