Stage 405: PR #2680 — feat: add Auxiliary Models settings card by @mccxj

Cherry-picked via 3-way apply (rebase had failed on static/index.html
conflict when applied via rebase commit chain; 3-way of the net delta
against stage HEAD applied cleanly).

Co-authored-by: mccxj <mccxj@github.users.noreply.github.com>
This commit is contained in:
hermes-agentandmccxj committed 2026-05-24 18:28:26 +00:00
1 parent e1a407f0ec
commit a86b378036
6 files changed
+751

No files matched your search

+106
View File
@@ -2185,6 +2185,112 @@ def set_hermes_default_model(model_id: str) -> dict:
return {"ok": True, "model": persisted_model}
# ── Auxiliary model configuration ──────────────────────────────────────────
# Canonical auxiliary task slots. Keep in sync with hermes_cli/config.py
# DEFAULT_CONFIG["auxiliary"] and hermes_cli/web_server.py _AUX_TASK_SLOTS.
AUX_TASK_SLOTS: tuple[str, ...] = (
"vision",
"web_extract",
"compression",
"session_search",
"skills_hub",
"approval",
"mcp",
"title_generation",
"curator",
)
def get_auxiliary_models() -> dict:
"""Return current auxiliary task assignments from config.yaml.
Shape:
{
"tasks": [
{"task": "vision", "provider": "auto", "model": "", "base_url": ""},
...
],
"main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"},
}
"""
reload_config()
model_cfg = cfg.get("model", {})
if not isinstance(model_cfg, dict):
model_cfg = {}
main_provider = str(model_cfg.get("provider") or "").strip()
main_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip()
aux_cfg = cfg.get("auxiliary", {})
if not isinstance(aux_cfg, dict):
aux_cfg = {}
tasks = []
for slot in AUX_TASK_SLOTS:
entry = aux_cfg.get(slot, {})
if not isinstance(entry, dict):
entry = {}
tasks.append({
"task": slot,
"provider": str(entry.get("provider") or "auto").strip(),
"model": str(entry.get("model") or "").strip(),
"base_url": str(entry.get("base_url") or "").strip(),
})
return {
"tasks": tasks,
"main": {"provider": main_provider, "model": main_model},
}
def set_auxiliary_model(task: str, provider: str, model: str) -> dict:
"""Persist an auxiliary model assignment in config.yaml.
Special case: task='__reset__' clears all auxiliary slots.
"""
config_path = _get_config_path()
with _cfg_lock:
config_data = _load_yaml_config_file(config_path)
if task == "__reset__":
# Per-slot reset: set each slot to auto, preserving extra fields
# (timeout, extra_body, api_key, base_url, download_timeout, etc.)
aux_cfg = config_data.get("auxiliary", {})
if not isinstance(aux_cfg, dict):
aux_cfg = {}
for slot in AUX_TASK_SLOTS:
slot_cfg = aux_cfg.get(slot, {})
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = "auto"
slot_cfg["model"] = ""
aux_cfg[slot] = slot_cfg
config_data["auxiliary"] = aux_cfg
else:
aux_cfg = config_data.get("auxiliary", {})
if not isinstance(aux_cfg, dict):
aux_cfg = {}
slot_cfg = aux_cfg.get(task, {})
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = provider or "auto"
slot_cfg["model"] = model or ""
if provider and (provider.startswith("custom:") or provider == "custom"):
try:
_, _, resolved_base_url = resolve_model_provider(model)
if resolved_base_url:
slot_cfg["base_url"] = str(resolved_base_url).strip().rstrip("/")
except Exception:
pass
aux_cfg[task] = slot_cfg
config_data["auxiliary"] = aux_cfg
_save_yaml_config_file(config_path, config_data)
reload_config()
return {"ok": True, "task": task, "provider": provider, "model": model}
# ── TTL cache for get_available_models() ─────────────────────────────────────
_available_models_cache: dict | None = None
_available_models_cache_ts: float = 0.0
+24
View File
@@ -3687,6 +3687,11 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/models/live":
return _handle_live_models(handler, parsed)
# ── Auxiliary models (GET/POST) ──
if parsed.path == "/api/model/auxiliary":
from api.config import get_auxiliary_models
return j(handler, get_auxiliary_models())
if parsed.path == "/api/dashboard/status":
from api import dashboard_probe
@@ -4857,6 +4862,25 @@ def handle_post(handler, parsed) -> bool:
except RuntimeError as e:
return bad(handler, str(e), 500)
# ── Auxiliary model set (POST) ──
if parsed.path == "/api/model/set":
scope = str(body.get("scope") or "").strip()
task = str(body.get("task") or "").strip()
provider = str(body.get("provider") or "auto").strip()
model = str(body.get("model") or "").strip()
if scope == "auxiliary":
from api.config import set_auxiliary_model
try:
return j(handler, set_auxiliary_model(task, provider, model))
except Exception as exc:
return bad(handler, str(exc), status=400)
if scope == "main":
try:
return j(handler, set_hermes_default_model(model))
except ValueError as exc:
return bad(handler, str(exc), status=400)
return bad(handler, f"unknown scope: {scope}", status=400)
# ── Providers (POST) ──
if parsed.path == "/api/providers":
provider_id = (body.get("provider") or "").strip().lower()
+176
View File
@@ -548,6 +548,22 @@ const LOCALES = {
settings_save_btn: 'Save Settings',
settings_label_model: 'Default Model',
settings_desc_model: 'Used for new conversations. Existing conversations keep their selected model.',
settings_label_auxiliary_models: 'Auxiliary Models',
settings_desc_auxiliary_models: 'Side-task routing for vision, compression, title generation, etc. "Auto" uses your main chat model.',
settings_btn_reset_aux_models: 'Reset all to auto',
settings_btn_apply_aux_models: 'Apply changes',
settings_aux_provider_auto: 'use main model',
settings_aux_model_auto: 'auto (use provider default)',
settings_aux_model_custom: 'Custom model…',
settings_aux_model_custom_prompt: 'Enter model ID:',
settings_aux_loading: 'Loading auxiliary models…',
settings_aux_load_failed: 'Could not load auxiliary model settings. Make sure the agent API is available.',
settings_aux_reset_confirm_title: 'Reset auxiliary models?',
settings_aux_reset_confirm_msg: 'This will set all auxiliary tasks to auto (use main model).',
settings_aux_reset_done: 'Auxiliary models reset to auto',
settings_aux_save_failed: 'Failed to save auxiliary model',
settings_aux_saved: 'Auxiliary models updated',
settings_aux_no_changes: 'No changes to apply',
settings_label_send_key: 'Send Key',
settings_label_theme: 'Theme',
settings_label_skin: 'Skin',
@@ -1791,6 +1807,22 @@ const LOCALES = {
settings_save_btn: 'Salva Impostazioni',
settings_label_model: 'Modello Predefinito',
settings_desc_model: 'Usato per le nuove conversazioni. Le conversazioni esistenti mantengono il modello selezionato.',
settings_label_auxiliary_models: 'Modelli Ausiliari',
settings_desc_auxiliary_models: 'Routing per attività secondarie come visione, compressione, generazione titoli, ecc. "Auto" utilizza il modello chat principale.',
settings_btn_reset_aux_models: 'Ripristina tutto ad auto',
settings_btn_apply_aux_models: 'Applica modifiche',
settings_aux_provider_auto: 'usa modello principale',
settings_aux_model_auto: 'auto (usa predefinito del provider)',
settings_aux_model_custom: 'Modello personalizzato…',
settings_aux_model_custom_prompt: 'Inserisci ID modello:',
settings_aux_loading: 'Caricamento modelli ausiliari…',
settings_aux_load_failed: 'Impossibile caricare le impostazioni dei modelli ausiliari. Assicurati che l’API dell’agent sia disponibile.',
settings_aux_reset_confirm_title: 'Ripristinare modelli ausiliari?',
settings_aux_reset_confirm_msg: 'Questo imposterà tutte le attività ausiliarie ad auto (usa modello principale).',
settings_aux_reset_done: 'Modelli ausiliari ripristinati ad auto',
settings_aux_save_failed: 'Salvataggio del modello ausiliario non riuscito',
settings_aux_saved: 'Modelli ausiliari aggiornati',
settings_aux_no_changes: 'Nessuna modifica da applicare',
settings_label_send_key: 'Tasto Invio',
settings_label_theme: 'Tema',
settings_label_skin: 'Skin',
@@ -3026,6 +3058,22 @@ const LOCALES = {
settings_save_btn: '設定を保存',
settings_label_model: 'デフォルトモデル',
settings_desc_model: '新しい会話で使用されます。既存の会話は選択済みモデルを保持します。',
settings_label_auxiliary_models: '補助モデル',
settings_desc_auxiliary_models: 'ビジョン、圧縮、タイトル生成などの補助タスクのルーティング。「自動」はメインチャットモデルを使用します。',
settings_btn_reset_aux_models: 'すべて自動にリセット',
settings_btn_apply_aux_models: '変更を適用',
settings_aux_provider_auto: 'メインモデルを使用',
settings_aux_model_auto: '自動(プロバイダーのデフォルトを使用)',
settings_aux_model_custom: 'カスタムモデル…',
settings_aux_model_custom_prompt: 'モデルIDを入力:',
settings_aux_loading: '補助モデルを読み込み中…',
settings_aux_load_failed: '補助モデル設定を読み込めませんでした。エージェントAPIが利用可能であることを確認してください。',
settings_aux_reset_confirm_title: '補助モデルをリセットしますか?',
settings_aux_reset_confirm_msg: 'すべての補助タスクを自動(メインモデルを使用)に設定します。',
settings_aux_reset_done: '補助モデルを自動にリセットしました',
settings_aux_save_failed: '補助モデルの保存に失敗しました',
settings_aux_saved: '補助モデルを更新しました',
settings_aux_no_changes: '適用する変更はありません',
settings_label_send_key: '送信キー',
settings_label_theme: 'テーマ',
settings_label_skin: 'スキン',
@@ -4077,6 +4125,22 @@ const LOCALES = {
settings_save_btn: 'Сохранить настройки',
settings_label_model: 'Модель по умолчанию',
settings_desc_model: 'Используется для новых бесед. Существующие беседы сохраняют выбранную модель.',
settings_label_auxiliary_models: 'Вспомогательные модели',
settings_desc_auxiliary_models: 'Маршрутизация побочных задач: зрение, сжатие, генерация заголовков и т.д. «Авто» использует основную модель чата.',
settings_btn_reset_aux_models: 'Сбросить всё на авто',
settings_btn_apply_aux_models: 'Применить изменения',
settings_aux_provider_auto: 'использовать основную модель',
settings_aux_model_auto: 'авто (по умолчанию провайдера)',
settings_aux_model_custom: 'Пользовательская модель…',
settings_aux_model_custom_prompt: 'Введите ID модели:',
settings_aux_loading: 'Загрузка вспомогательных моделей…',
settings_aux_load_failed: 'Не удалось загрузить настройки вспомогательных моделей. Убедитесь, что API агента доступен.',
settings_aux_reset_confirm_title: 'Сбросить вспомогательные модели?',
settings_aux_reset_confirm_msg: 'Все вспомогательные задачи будут установлены на «авто» (использовать основную модель).',
settings_aux_reset_done: 'Вспомогательные модели сброшены на авто',
settings_aux_save_failed: 'Не удалось сохранить вспомогательную модель',
settings_aux_saved: 'Вспомогательные модели обновлены',
settings_aux_no_changes: 'Нет изменений для применения',
settings_label_send_key: 'Клавиша отправки',
settings_label_theme: 'Тема',
settings_label_language: 'Язык',
@@ -5231,6 +5295,22 @@ const LOCALES = {
settings_save_btn: 'Guardar configuración',
settings_label_model: 'Modelo predeterminado',
settings_desc_model: 'Se usa para conversaciones nuevas. Las conversaciones existentes conservan su modelo seleccionado.',
settings_label_auxiliary_models: 'Modelos auxiliares',
settings_desc_auxiliary_models: 'Enrutamiento para tareas secundarias como visión, compresión, generación de títulos, etc. «Auto» usa el modelo de chat principal.',
settings_btn_reset_aux_models: 'Restablecer todo a auto',
settings_btn_apply_aux_models: 'Aplicar cambios',
settings_aux_provider_auto: 'usar modelo principal',
settings_aux_model_auto: 'auto (usar predeterminado del proveedor)',
settings_aux_model_custom: 'Modelo personalizado…',
settings_aux_model_custom_prompt: 'Ingrese ID del modelo:',
settings_aux_loading: 'Cargando modelos auxiliares…',
settings_aux_load_failed: 'No se pudieron cargar las configuraciones de modelos auxiliares. Asegúrese de que la API del agente esté disponible.',
settings_aux_reset_confirm_title: '¿Restablecer modelos auxiliares?',
settings_aux_reset_confirm_msg: 'Esto establecerá todas las tareas auxiliares en auto (usar modelo principal).',
settings_aux_reset_done: 'Modelos auxiliares restablecidos a auto',
settings_aux_save_failed: 'Error al guardar el modelo auxiliar',
settings_aux_saved: 'Modelos auxiliares actualizados',
settings_aux_no_changes: 'Sin cambios para aplicar',
settings_label_send_key: 'Tecla de envío',
settings_label_theme: 'Tema',
settings_label_skin: 'Piel',
@@ -6388,6 +6468,22 @@ const LOCALES = {
settings_save_btn: 'Einstellungen speichern',
settings_label_model: 'Standard-Modell',
settings_desc_model: 'Wird für neue Chats verwendet. Bestehende Chats behalten ihr ausgewähltes Modell.',
settings_label_auxiliary_models: 'Hilfsmodelle',
settings_desc_auxiliary_models: 'Routing für Nebenaufgaben wie Vision, Komprimierung, Titelgenerierung usw. „Auto" verwendet das Haupt-Chat-Modell.',
settings_btn_reset_aux_models: 'Alle auf auto zurücksetzen',
settings_btn_apply_aux_models: 'Änderungen anwenden',
settings_aux_provider_auto: 'Hauptmodell verwenden',
settings_aux_model_auto: 'auto (Provider-Standard verwenden)',
settings_aux_model_custom: 'Benutzerdefiniertes Modell…',
settings_aux_model_custom_prompt: 'Modell-ID eingeben:',
settings_aux_loading: 'Hilfsmodelle werden geladen…',
settings_aux_load_failed: 'Hilfsmodelle-Einstellungen konnten nicht geladen werden. Stellen Sie sicher, dass die Agent-API verfügbar ist.',
settings_aux_reset_confirm_title: 'Hilfsmodelle zurücksetzen?',
settings_aux_reset_confirm_msg: 'Dies setzt alle Hilfsaufgaben auf auto (Hauptmodell verwenden).',
settings_aux_reset_done: 'Hilfsmodelle auf auto zurückgesetzt',
settings_aux_save_failed: 'Hilfsmodell konnte nicht gespeichert werden',
settings_aux_saved: 'Hilfsmodelle aktualisiert',
settings_aux_no_changes: 'Keine Änderungen anzuwenden',
settings_label_send_key: 'Sende-Taste',
settings_label_theme: 'Theme',
settings_label_skin: 'Skin',
@@ -7597,6 +7693,22 @@ const LOCALES = {
settings_save_btn: '保存设置',
settings_label_model: '默认模型',
settings_desc_model: '用于新对话。现有对话保持各自选定的模型。',
settings_label_auxiliary_models: '辅助模型',
settings_desc_auxiliary_models: '视觉分析、上下文压缩、标题生成等辅助任务的路由。"自动"表示使用主聊天模型。',
settings_btn_reset_aux_models: '全部重置为自动',
settings_btn_apply_aux_models: '应用更改',
settings_aux_provider_auto: '使用主模型',
settings_aux_model_auto: '自动(使用提供商默认)',
settings_aux_model_custom: '自定义模型…',
settings_aux_model_custom_prompt: '输入模型 ID:',
settings_aux_loading: '正在加载辅助模型…',
settings_aux_load_failed: '无法加载辅助模型设置,请确保 Agent API 可用。',
settings_aux_reset_confirm_title: '重置辅助模型?',
settings_aux_reset_confirm_msg: '这将把所有辅助任务设置为自动(使用主模型)。',
settings_aux_reset_done: '辅助模型已重置为自动',
settings_aux_save_failed: '辅助模型保存失败',
settings_aux_saved: '辅助模型已更新',
settings_aux_no_changes: '没有需要应用的更改',
settings_label_send_key: '发送快捷键',
settings_label_theme: '主题',
settings_label_skin: '皮肤',
@@ -8783,6 +8895,22 @@ const LOCALES = {
settings_save_btn: '\u5132\u5b58\u8a2d\u5b9a',
settings_label_model: '\u9ed8\u8a8d\u6a21\u578b',
settings_desc_model: '\u7528\u65bc\u65b0\u6703\u8a71\u3002\u73fe\u6709\u6703\u8a71\u6703\u4fdd\u7559\u5404\u81ea\u9078\u5b9a\u7684\u6a21\u578b\u3002',
settings_label_auxiliary_models: '\u8f14\u52a9\u6a21\u578b',
settings_desc_auxiliary_models: '\u8996\u89ba\u5206\u6790\u3001\u4e0a\u4e0b\u6587\u58d3\u7e2e\u3001\u6a19\u984c\u7522\u751f\u7b49\u8f14\u52a9\u4efb\u52d9\u7684\u8def\u7531\u3002\u300c\u81ea\u52d5\u300d\u8868\u793a\u4f7f\u7528\u4e3b\u804a\u5929\u6a21\u578b\u3002',
settings_btn_reset_aux_models: '\u5168\u90e8\u91cd\u7f6e\u70ba\u81ea\u52d5',
settings_btn_apply_aux_models: '\u61c9\u7528\u8b8a\u66f4',
settings_aux_provider_auto: '\u4f7f\u7528\u4e3b\u6a21\u578b',
settings_aux_model_auto: '\u81ea\u52d5\uff08\u4f7f\u7528\u63d0\u4f9b\u5546\u9810\u8a2d\uff09',
settings_aux_model_custom: '\u81ea\u8a02\u6a21\u578b\u2026',
settings_aux_model_custom_prompt: '\u8f38\u5165\u6a21\u578b ID\uff1a',
settings_aux_loading: '\u6b63\u5728\u8f09\u5165\u8f14\u52a9\u6a21\u578b\u2026',
settings_aux_load_failed: '\u7121\u6cd5\u8f09\u5165\u8f14\u52a9\u6a21\u578b\u8a2d\u5b9a\uff0c\u8acb\u78ba\u4fdd Agent API \u53ef\u7528\u3002',
settings_aux_reset_confirm_title: '\u91cd\u7f6e\u8f14\u52a9\u6a21\u578b\uff1f',
settings_aux_reset_confirm_msg: '\u9019\u5c07\u628a\u6240\u6709\u8f14\u52a9\u4efb\u52d9\u8a2d\u5b9a\u70ba\u81ea\u52d5\uff08\u4f7f\u7528\u4e3b\u6a21\u578b\uff09\u3002',
settings_aux_reset_done: '\u8f14\u52a9\u6a21\u578b\u5df2\u91cd\u7f6e\u70ba\u81ea\u52d5',
settings_aux_save_failed: '\u8f14\u52a9\u6a21\u578b\u5132\u5b58\u5931\u6557',
settings_aux_saved: '\u8f14\u52a9\u6a21\u578b\u5df2\u66f4\u65b0',
settings_aux_no_changes: '\u6c92\u6709\u9700\u8981\u61c9\u7528\u7684\u8b8a\u66f4',
settings_label_send_key: '\u767c\u9001\u5feb\u6377\u9375',
settings_label_theme: '\u4e3b\u984c',
settings_label_skin: '佈景',
@@ -10096,6 +10224,22 @@ const LOCALES = {
settings_save_btn: 'Salvar Configurações',
settings_label_model: 'Modelo Padrão',
settings_desc_model: 'Usado para novas conversas. Conversas existentes mantêm o modelo selecionado.',
settings_label_auxiliary_models: 'Modelos auxiliares',
settings_desc_auxiliary_models: 'Roteamento para tarefas secundárias como visão, compressão, geração de títulos, etc. "Auto" usa o modelo de chat principal.',
settings_btn_reset_aux_models: 'Restaurar tudo para auto',
settings_btn_apply_aux_models: 'Aplicar alterações',
settings_aux_provider_auto: 'usar modelo principal',
settings_aux_model_auto: 'auto (usar padrão do provedor)',
settings_aux_model_custom: 'Modelo personalizado…',
settings_aux_model_custom_prompt: 'Insira o ID do modelo:',
settings_aux_loading: 'Carregando modelos auxiliares…',
settings_aux_load_failed: 'Não foi possível carregar as configurações de modelos auxiliares. Certifique-se de que a API do agente esteja disponível.',
settings_aux_reset_confirm_title: 'Restaurar modelos auxiliares?',
settings_aux_reset_confirm_msg: 'Isso definirá todas as tarefas auxiliares para auto (usar modelo principal).',
settings_aux_reset_done: 'Modelos auxiliares restaurados para auto',
settings_aux_save_failed: 'Falha ao salvar o modelo auxiliar',
settings_aux_saved: 'Modelos auxiliares atualizados',
settings_aux_no_changes: 'Nenhuma alteração para aplicar',
settings_label_send_key: 'Tecla de Envio',
settings_label_theme: 'Tema',
settings_label_skin: 'Skin',
@@ -11234,6 +11378,22 @@ const LOCALES = {
settings_save_btn: '설정 저장',
settings_label_model: '기본 모델',
settings_desc_model: '새 대화에 사용됩니다. 기존 대화는 선택된 모델을 유지합니다.',
settings_label_auxiliary_models: '보조 모델',
settings_desc_auxiliary_models: '비전, 압축, 제목 생성 등 보조 작업 라우팅. "자동"은 기본 채팅 모델을 사용합니다.',
settings_btn_reset_aux_models: '모두 자동으로 재설정',
settings_btn_apply_aux_models: '변경 사항 적용',
settings_aux_provider_auto: '기본 모델 사용',
settings_aux_model_auto: '자동 (제공자 기본값 사용)',
settings_aux_model_custom: '사용자 정의 모델…',
settings_aux_model_custom_prompt: '모델 ID 입력:',
settings_aux_loading: '보조 모델 로딩 중…',
settings_aux_load_failed: '보조 모델 설정을 로드할 수 없습니다. 에이전트 API가 사용 가능한지 확인하세요.',
settings_aux_reset_confirm_title: '보조 모델을 재설정하시겠습니까?',
settings_aux_reset_confirm_msg: '모든 보조 작업이 자동(기본 모델 사용)으로 설정됩니다.',
settings_aux_reset_done: '보조 모델이 자동으로 재설정됨',
settings_aux_save_failed: '보조 모델 저장 실패',
settings_aux_saved: '보조 모델 업데이트됨',
settings_aux_no_changes: '적용할 변경 사항 없음',
settings_label_send_key: '전송 키',
settings_label_theme: '테마',
settings_label_skin: '스킨',
@@ -12385,6 +12545,22 @@ const LOCALES = {
settings_save_btn: 'Enregistrer les paramètres',
settings_label_model: 'Modèle par défaut',
settings_desc_model: 'Utilisé pour les nouvelles conversations. Les conversations existantes conservent leur modèle sélectionné.',
settings_label_auxiliary_models: 'Modèles auxiliaires',
settings_desc_auxiliary_models: 'Routage des tâches secondaires : vision, compression, génération de titres, etc. « Auto » utilise le modèle de chat principal.',
settings_btn_reset_aux_models: 'Tout réinitialiser à auto',
settings_btn_apply_aux_models: 'Appliquer les modifications',
settings_aux_provider_auto: 'utiliser le modèle principal',
settings_aux_model_auto: 'auto (utiliser la valeur par défaut du fournisseur)',
settings_aux_model_custom: 'Modèle personnalisé…',
settings_aux_model_custom_prompt: 'Entrez l\u2019ID du modèle :',
settings_aux_loading: 'Chargement des modèles auxiliaires…',
settings_aux_load_failed: 'Impossible de charger les paramètres des modèles auxiliaires. Vérifiez que l\u2019API de l\u2019agent est disponible.',
settings_aux_reset_confirm_title: 'Réinitialiser les modèles auxiliaires ?',
settings_aux_reset_confirm_msg: 'Cela définira toutes les tâches auxiliaires sur auto (utiliser le modèle principal).',
settings_aux_reset_done: 'Modèles auxiliaires réinitialisés à auto',
settings_aux_save_failed: 'Échec de la sauvegarde du modèle auxiliaire',
settings_aux_saved: 'Modèles auxiliaires mis à jour',
settings_aux_no_changes: 'Aucune modification à appliquer',
settings_label_send_key: 'Envoyer la clé',
settings_label_theme: 'Thème',
settings_label_skin: 'Peau',
+9
View File
@@ -997,6 +997,15 @@
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_model">Used for new conversations. Existing conversations keep their selected model.</div>
</div>
<div class="settings-field">
<label data-i18n="settings_label_auxiliary_models">Auxiliary Models</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:10px" data-i18n="settings_desc_auxiliary_models">Side-task routing for vision, compression, title generation, etc. "Auto" uses your main chat model.</div>
<div id="auxModelsContainer"></div>
<div style="margin-top:8px;display:flex;gap:8px">
<button type="button" id="btnResetAuxModels" class="settings-btn" style="font-size:12px;padding:4px 12px;border-radius:6px" data-i18n="settings_btn_reset_aux_models">Reset all to auto</button>
<button type="button" id="btnApplyAuxModels" class="settings-btn" style="font-size:12px;padding:4px 12px;border-radius:6px;display:none" data-i18n="settings_btn_apply_aux_models">Apply changes</button>
</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsHideSuggestions" style="width:15px;height:15px;accent-color:var(--accent)">
+219
View File
@@ -5920,6 +5920,8 @@ async function loadSettingsPanel(){
}
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
}
// Auxiliary models — load task assignments and provider/model options
_loadAuxiliaryModels();
// Send key preference
const sendKeySel=$('settingsSendKey');
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
@@ -6881,6 +6883,223 @@ async function checkUpdatesNow(){
}
}
// ── Auxiliary Models ──────────────────────────────────────────────────────────
// Canonical auxiliary task slots with display names.
// Keep in sync with hermes_cli/main.py _AUX_TASKS and hermes_cli/web_server.py _AUX_TASK_SLOTS.
const _AUX_TASK_SLOTS=[
{key:'vision',name:'Vision',desc:'image/screenshot analysis'},
{key:'compression',name:'Compression',desc:'context summarization'},
{key:'web_extract',name:'Web extract',desc:'web page summarization'},
{key:'session_search',name:'Session search',desc:'past-conversation recall'},
{key:'approval',name:'Approval',desc:'smart command approval'},
{key:'mcp',name:'MCP',desc:'MCP tool reasoning'},
{key:'title_generation',name:'Title generation',desc:'session titles'},
{key:'skills_hub',name:'Skills hub',desc:'skills search/install'},
{key:'curator',name:'Curator',desc:'skill-usage review pass'},
];
let _auxProviders=[]; // cached provider list from /api/model/options
let _auxOriginalConfig=null; // snapshot of initial config for dirty detection
function _auxSelectStyle(){
return 'width:100%;padding:6px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:12px;box-sizing:border-box';
}
function _buildAuxProviderOptions(sel,providers,currentProvider){
sel.innerHTML='';
// "auto" = use main model
const autoOpt=document.createElement('option');
autoOpt.value='auto';autoOpt.textContent='auto ('+t('settings_aux_provider_auto')+')';
if(currentProvider==='auto'||!currentProvider) autoOpt.selected=true;
sel.appendChild(autoOpt);
for(const p of providers){
const opt=document.createElement('option');
opt.value=p.slug;opt.textContent=p.name;
if(p.slug===currentProvider) opt.selected=true;
sel.appendChild(opt);
}
}
function _buildAuxModelOptions(sel,provider,providers,currentModel){
sel.innerHTML='';
const emptyOpt=document.createElement('option');
emptyOpt.value='';emptyOpt.textContent=t('settings_aux_model_auto')||'auto (use provider default)';
sel.appendChild(emptyOpt);
if(!provider||provider==='auto'){
sel.value=currentModel||'';
return;
}
// Find matching provider in cached list
const pData=providers.find(p=>p.slug===provider);
if(pData&&pData.models){
for(const mId of pData.models){
const opt=document.createElement('option');
opt.value=mId;opt.textContent=mId;
if(mId===currentModel) opt.selected=true;
sel.appendChild(opt);
}
}
// Always allow custom model — add a text input option hint
const customOpt=document.createElement('option');
customOpt.value='__custom__';customOpt.textContent=t('settings_aux_model_custom')||'Custom model…';
sel.appendChild(customOpt);
// If currentModel not in list and not empty, add it as a custom option
if(currentModel&&!pData?.models?.includes(currentModel)){
const existingOpt=document.createElement('option');
existingOpt.value=currentModel;existingOpt.textContent=currentModel+' (configured)';
existingOpt.selected=true;
sel.insertBefore(existingOpt,customOpt);
}
}
function _onAuxProviderChange(taskKey,providers){
const provSel=$('aux-prov-'+taskKey);
const modelSel=$('aux-model-'+taskKey);
if(!provSel||!modelSel) return;
const provider=provSel.value;
_buildAuxModelOptions(modelSel,provider,providers,'');
_markAuxDirty();
}
async function _onAuxModelChange(taskKey){
const modelSel=$('aux-model-'+taskKey);
if(!modelSel) return;
if(modelSel.value==='__custom__'){
const customModel=await showPromptDialog({title:t('settings_aux_model_custom')||'Custom model',message:t('settings_aux_model_custom_prompt')||'Enter model ID:',placeholder:'model/provider:model-id',confirmLabel:t('settings_btn_apply_aux_models')||'Apply'});
if(customModel&&customModel.trim()){
// Insert custom model option before the __custom__ option
const opt=document.createElement('option');
opt.value=customModel.trim();opt.textContent=customModel.trim();
// Remove __custom__ selection
const customIdx=[...modelSel.options].findIndex(o=>o.value==='__custom__');
if(customIdx>=0) modelSel.insertBefore(opt,modelSel.options[customIdx]);
modelSel.value=customModel.trim();
}else{
modelSel.value='';
}
}
_markAuxDirty();
}
function _markAuxDirty(){
const applyBtn=$('btnApplyAuxModels');
if(applyBtn) applyBtn.style.display='';
_markSettingsDirty();
}
async function _loadAuxiliaryModels(){
const container=$('auxModelsContainer');
if(!container) return;
container.innerHTML='<div style="color:var(--muted);font-size:12px">'+(t('settings_aux_loading')||'Loading…')+'</div>';
try{
// Fetch auxiliary config AND the WebUI's own /api/models for provider/model lists
const [auxData,modelsData]=await Promise.all([
api('/api/model/auxiliary').catch(()=>null),
api('/api/models').catch(()=>null),
]);
// Build provider list from /api/models groups
// /api/models returns: { groups: [{ provider: str, provider_id: str, models: [{id,label}] }] }
const groups=(modelsData&&modelsData.groups)||[];
_auxProviders=groups.filter(g=>g.provider&&g.models&&g.models.length>0).map(g=>({
slug:g.provider_id||g.provider,
name:g.provider,
models:g.models.map(m=>m.id),
}));
const tasks=(auxData&&auxData.tasks)||[];
// Build a quick lookup: taskKey → {provider, model}
const taskMap={};
for(const t of tasks) taskMap[t.task]=t;
_auxOriginalConfig=JSON.parse(JSON.stringify(taskMap));
container.innerHTML='';
for(const slot of _AUX_TASK_SLOTS){
const cfg=taskMap[slot.key]||{provider:'auto',model:''};
const row=document.createElement('div');
row.style.cssText='display:grid;grid-template-columns:120px 1fr 1fr;gap:8px;align-items:center;margin-bottom:8px';
// Task name + description
const label=document.createElement('div');
label.style.cssText='font-size:12px;font-weight:500;color:var(--text);line-height:1.3';
label.innerHTML=esc(slot.name)+'<div style="font-size:10px;color:var(--muted);font-weight:400">'+esc(slot.desc)+'</div>';
row.appendChild(label);
// Provider select
const provSel=document.createElement('select');
provSel.id='aux-prov-'+slot.key;
provSel.style.cssText=_auxSelectStyle();
_buildAuxProviderOptions(provSel,_auxProviders,cfg.provider);
provSel.addEventListener('change',()=>_onAuxProviderChange(slot.key,_auxProviders));
row.appendChild(provSel);
// Model select
const modelSel=document.createElement('select');
modelSel.id='aux-model-'+slot.key;
modelSel.style.cssText=_auxSelectStyle();
_buildAuxModelOptions(modelSel,cfg.provider,_auxProviders,cfg.model);
modelSel.addEventListener('change',()=>_onAuxModelChange(slot.key));
row.appendChild(modelSel);
container.appendChild(row);
}
// Hide apply button (no changes yet)
const applyBtn=$('btnApplyAuxModels');
if(applyBtn) applyBtn.style.display='none';
// Reset button
const resetBtn=$('btnResetAuxModels');
if(resetBtn&&!resetBtn._bound){
resetBtn._bound=true;
resetBtn.addEventListener('click',async()=>{
if(!(await showConfirmDialog({title:t('settings_aux_reset_confirm_title')||'Reset auxiliary models?',message:t('settings_aux_reset_confirm_msg')||'This will set all auxiliary tasks to auto (use main model).',confirmLabel:t('settings_btn_reset_aux_models')||'Reset',danger:true}))) return;
try{
await api('/api/model/set',{method:'POST',body:JSON.stringify({scope:'auxiliary',task:'__reset__',provider:'auto',model:''})});
if(typeof showToast==='function') showToast(t('settings_aux_reset_done')||'Auxiliary models reset to auto');
_loadAuxiliaryModels();
}catch(e){
if(typeof showToast==='function') showToast(t('settings_aux_save_failed')||'Failed to reset auxiliary models');
}
});
}
// Apply button
if(applyBtn&&!applyBtn._bound){
applyBtn._bound=true;
applyBtn.addEventListener('click',_applyAuxModels);
}
}catch(e){
console.warn('[settings] auxiliary models load failed',e);
container.innerHTML='<div style="color:var(--muted);font-size:12px">'+(t('settings_aux_load_failed')||'Could not load auxiliary model settings. Make sure the agent API is available.')+'</div>';
}
}
async function _applyAuxModels(){
let saved=0;
for(const slot of _AUX_TASK_SLOTS){
const provSel=$('aux-prov-'+slot.key);
const modelSel=$('aux-model-'+slot.key);
if(!provSel) continue;
const provider=provSel.value;
const model=(modelSel&&modelSel.value!=='__custom__')?(modelSel.value||''):'';
const orig=_auxOriginalConfig?.[slot.key]||{provider:'auto',model:''};
// Only save if changed
if(provider!==orig.provider||model!==orig.model){
try{
await api('/api/model/set',{method:'POST',body:JSON.stringify({scope:'auxiliary',task:slot.key,provider,model})});
saved++;
}catch(e){
console.warn('[settings] failed to save aux task',slot.key,e);
if(typeof showToast==='function') showToast(t('settings_aux_save_failed')||'Failed to save auxiliary model for '+slot.name);
return;
}
}
}
if(typeof showToast==='function') showToast(saved?(t('settings_aux_saved')||'Auxiliary models updated'):(t('settings_aux_no_changes')||'No changes to apply'));
// Reload to refresh state
_loadAuxiliaryModels();
}
async function saveSettings(andClose){
const model=($('settingsModel')||{}).value;
const modelChanged=(model||'')!==(_settingsHermesDefaultModelOnOpen||'');
+217
View File
@@ -0,0 +1,217 @@
"""Tests for auxiliary models settings UI — panels.js + index.html + i18n.js.
Verifies that the auxiliary models card is present in the settings HTML,
that the JS loading/saving logic is wired up, and that all locales have the
required i18n keys.
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
class TestAuxiliaryModelsHTML:
"""The auxiliary models card must be present in the settings preferences pane."""
def test_aux_models_container_exists(self):
"""The #auxModelsContainer div must exist in the preferences pane."""
assert 'id="auxModelsContainer"' in INDEX_HTML, (
"Missing #auxModelsContainer in index.html — auxiliary models card not rendered"
)
def test_reset_button_exists(self):
assert 'id="btnResetAuxModels"' in INDEX_HTML, (
"Missing #btnResetAuxModels button in index.html"
)
def test_apply_button_exists(self):
assert 'id="btnApplyAuxModels"' in INDEX_HTML, (
"Missing #btnApplyAuxModels button in index.html"
)
def test_aux_card_after_default_model(self):
"""Auxiliary Models card should come after the Default Model card in the DOM."""
model_idx = INDEX_HTML.find('id="settingsModel"')
aux_idx = INDEX_HTML.find('id="auxModelsContainer"')
assert model_idx >= 0, "Default Model select not found in index.html"
assert aux_idx >= 0, "Auxiliary Models container not found in index.html"
assert aux_idx > model_idx, (
"Auxiliary Models container must appear after Default Model in the DOM"
)
def test_i18n_label_on_aux_card(self):
"""The auxiliary models card label must use data-i18n attribute."""
assert 'data-i18n="settings_label_auxiliary_models"' in INDEX_HTML, (
"Missing data-i18n='settings_label_auxiliary_models' on auxiliary card label"
)
class TestAuxiliaryModelsJS:
"""The JS logic for loading and saving auxiliary models must be in panels.js."""
def test_load_function_exists(self):
assert "async function _loadAuxiliaryModels" in PANELS_JS, (
"Missing _loadAuxiliaryModels() in panels.js"
)
def test_apply_function_exists(self):
assert "async function _applyAuxModels" in PANELS_JS, (
"Missing _applyAuxModels() in panels.js"
)
def test_aux_task_slots_defined(self):
"""_AUX_TASK_SLOTS must list the 9 canonical task slots."""
assert "_AUX_TASK_SLOTS" in PANELS_JS, (
"Missing _AUX_TASK_SLOTS constant in panels.js"
)
# Verify all 9 tasks are present
for key in ("vision", "compression", "web_extract", "session_search",
"approval", "mcp", "title_generation", "skills_hub", "curator"):
assert f"key:'{key}'" in PANELS_JS, (
f"Missing auxiliary task slot '{key}' in _AUX_TASK_SLOTS"
)
def test_calls_model_auxiliary_api(self):
"""_loadAuxiliaryModels must call /api/model/auxiliary."""
assert "/api/model/auxiliary" in PANELS_JS, (
"panels.js must call /api/model/auxiliary to fetch current config"
)
def test_calls_model_set_api(self):
"""_applyAuxModels must call /api/model/set to save changes."""
assert "/api/model/set" in PANELS_JS, (
"panels.js must call /api/model/set to save auxiliary model changes"
)
def test_provider_cascade(self):
"""Changing provider must rebuild model dropdown."""
assert "_onAuxProviderChange" in PANELS_JS, (
"Missing _onAuxProviderChange() for provider→model cascade"
)
assert "_buildAuxModelOptions" in PANELS_JS, (
"Missing _buildAuxModelOptions() for model dropdown rebuild"
)
def test_custom_model_prompt(self):
"""Selecting 'Custom model…' must prompt for model ID."""
assert "__custom__" in PANELS_JS, (
"Missing __custom__ sentinel option for custom model input"
)
def test_reset_calls_api_with_reset_task(self):
"""Reset button must call /api/model/set with task='__reset__'."""
idx = PANELS_JS.find("btnResetAuxModels")
assert idx >= 0, "btnResetAuxModels not found in panels.js"
# Check that __reset__ is sent in the reset handler
body_after = PANELS_JS[idx:idx + 2000]
assert "__reset__" in body_after, (
"Reset handler must send task='__reset__' to /api/model/set"
)
def test_load_called_from_loadSettingsPanel(self):
"""_loadAuxiliaryModels must be called from loadSettingsPanel."""
assert "_loadAuxiliaryModels()" in PANELS_JS, (
"_loadAuxiliaryModels() is not called from loadSettingsPanel"
)
def test_dirty_flag_marking(self):
"""Changing an auxiliary dropdown must mark settings dirty."""
assert "_markAuxDirty" in PANELS_JS, (
"Missing _markAuxDirty() for dirty detection"
)
# _markAuxDirty should call _markSettingsDirty
idx = PANELS_JS.find("function _markAuxDirty")
body = PANELS_JS[idx:idx + 200]
assert "_markSettingsDirty" in body, (
"_markAuxDirty must call _markSettingsDirty"
)
class TestAuxiliaryModelsI18n:
"""All locales must have the auxiliary model i18n keys."""
REQUIRED_KEYS = [
"settings_label_auxiliary_models",
"settings_desc_auxiliary_models",
"settings_btn_reset_aux_models",
"settings_btn_apply_aux_models",
"settings_aux_provider_auto",
"settings_aux_model_auto",
"settings_aux_model_custom",
"settings_aux_model_custom_prompt",
"settings_aux_loading",
"settings_aux_load_failed",
"settings_aux_reset_confirm_title",
"settings_aux_reset_confirm_msg",
"settings_aux_reset_done",
"settings_aux_save_failed",
"settings_aux_saved",
"settings_aux_no_changes",
]
def test_all_i18n_keys_present(self):
"""Every required key must exist in i18n.js at least once."""
for key in self.REQUIRED_KEYS:
assert key in I18N_JS, (
f"Missing i18n key '{key}' in i18n.js"
)
def test_all_locales_have_auxiliary_keys(self):
"""Count of each key should equal the number of locales (11)."""
for key in self.REQUIRED_KEYS:
count = I18N_JS.count(f"{key}:")
assert count == 11, (
f"i18n key '{key}' found {count} times — expected 11 (one per locale)"
)
class TestAuxiliaryModelsBackend:
"""WebUI backend must expose /api/model/auxiliary and /api/model/set."""
ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
def test_model_auxiliary_route_exists(self):
"""/api/model/auxiliary route must be registered in routes.py."""
assert '"/api/model/auxiliary"' in self.ROUTES_PY, (
"Missing /api/model/auxiliary route in routes.py"
)
def test_model_set_route_exists(self):
"""/api/model/set route must be registered in routes.py."""
assert '"/api/model/set"' in self.ROUTES_PY, (
"Missing /api/model/set route in routes.py"
)
def test_get_auxiliary_models_function_exists(self):
"""get_auxiliary_models() must exist in api/config.py."""
assert "def get_auxiliary_models" in self.CONFIG_PY, (
"Missing get_auxiliary_models() in api/config.py"
)
def test_set_auxiliary_model_function_exists(self):
"""set_auxiliary_model() must exist in api/config.py."""
assert "def set_auxiliary_model" in self.CONFIG_PY, (
"Missing set_auxiliary_model() in api/config.py"
)
def test_aux_task_slots_constant_exists(self):
"""AUX_TASK_SLOTS must be defined in api/config.py."""
assert "AUX_TASK_SLOTS" in self.CONFIG_PY, (
"Missing AUX_TASK_SLOTS constant in api/config.py"
)
def test_js_uses_models_endpoint_not_options(self):
"""Frontend must use /api/models (WebUI's own API) not /api/model/options (agent API)."""
# _loadAuxiliaryModels should call /api/models, not /api/model/options
idx = PANELS_JS.find("async function _loadAuxiliaryModels")
assert idx >= 0, "_loadAuxiliaryModels not found"
body = PANELS_JS[idx:idx + 800]
assert "/api/models" in body, (
"_loadAuxiliaryModels must call /api/models for provider/model lists"
)
assert "/api/model/options" not in body, (
"_loadAuxiliaryModels must NOT call /api/model/options (agent-only endpoint)"
)