diff --git a/CHANGELOG.md b/CHANGELOG.md index 1137235b..823ade7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **PR #2228** by @franksong2702 (refs #749) — Profile creation now exposes the same configured model/provider choices users already see in the composer/settings model picker. New profiles can be created with an explicit `model.default` and `model.provider` written into that profile's own `config.yaml`, while clone/base-url/API-key creation behavior remains unchanged. + - **PR #2227** by @theh4v0c (closes #2223 — critical) — Context compression no longer destroys session history. The previous implementation renamed `old_sid.json` → `new_sid.json` before the new compressed session had been saved, destroying the only persistent copy of the full conversation. When the summarisation LLM call also failed, the user was left with zero recoverable messages and the bug report `Summary generation was unavailable. N message(s) were removed to free context space but could not be summarized.` text with no way to scroll back. The fix removes the destructive `old_path.rename(new_path)` call: `old_sid.json` is preserved intact as an immutable pre-compression archive, `new_sid.json` is created fresh via `s.save()`, and `parent_session_id` is set on the continuation session so the frontend can traverse the lineage chain back to the original. Even when summarisation or `s.save()` fails, the original conversation file survives on disk. New 106-line regression test file covers the no-rename invariant, parent_session_id stamping, and marker-only-result preservation. Stage-353 Opus SHOULD-FIX applied inline: the preservation block previously cleared `s.parent_session_id` before saving the snapshot (writing `parent=None` to `old_sid.json` on disk) and used a `if not s.parent_session_id` guard when stamping the continuation. Both bugs broke fork-of-fork compression lineage — a `/branch` fork that subsequently compressed would lose its "Forked from X" badge on the snapshot AND the continuation would skip past the snapshot back to the original fork parent. Maintainer fix removes the parent clearing during preservation (preserves the fork lineage on disk) and drops the `if not` guard (always stamps continuation to `old_sid`). Two new regression tests pin both invariants. Traversal now consistently walks new → old → old.parent → ... root. - **PR #2222** by @franksong2702 — Settings → Appearance now wraps the "Load older messages while scrolling up" checkbox in its own ``. Stage-353 maintainer resolution adopted PR #2227's stronger structural variant (each preference in its own `settings-field`) over PR #2222's smaller in-place wrap. Regression test `test_session_endless_scroll.py` pins the new per-label per-settings-field contract. diff --git a/api/profiles.py b/api/profiles.py index bebd8201..35440b47 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -16,6 +16,7 @@ import shutil import sys import threading from pathlib import Path +from typing import Optional logger = logging.getLogger(__name__) @@ -960,16 +961,78 @@ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key: config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8') +def _clean_profile_config_value(value: Optional[str], field: str) -> Optional[str]: + """Return a safe single-line config value or raise ValueError.""" + if value is None: + return None + cleaned = str(value).strip() + if not cleaned: + return None + if any(ch in cleaned for ch in ("\x00", "\r", "\n")): + raise ValueError(f"{field} must be a single-line value") + if len(cleaned) > 512: + raise ValueError(f"{field} is too long") + return cleaned + + +def _split_webui_provider_model_value(default_model: Optional[str], model_provider: Optional[str]) -> tuple[Optional[str], Optional[str]]: + """Normalize WebUI-internal @provider:model picker values for config.yaml.""" + model = _clean_profile_config_value(default_model, "default_model") + provider = _clean_profile_config_value(model_provider, "model_provider") + if model and model.startswith("@") and ":" in model: + provider_part, model_part = model[1:].rsplit(":", 1) + provider = provider or _clean_profile_config_value(provider_part, "model_provider") + model = _clean_profile_config_value(model_part, "default_model") + return model, provider + + +def _write_model_defaults_to_config( + profile_dir: Path, + *, + default_model: Optional[str] = None, + model_provider: Optional[str] = None, +) -> None: + """Write model default/provider fields into config.yaml for a profile.""" + default_model, model_provider = _split_webui_provider_model_value(default_model, model_provider) + if not default_model and not model_provider: + return + config_path = profile_dir / 'config.yaml' + try: + import yaml as _yaml + except ImportError: + return + cfg = {} + if config_path.exists(): + try: + loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + cfg = loaded + except Exception: + logger.debug("Failed to load config from %s", config_path) + model_section = cfg.get('model', {}) + if not isinstance(model_section, dict): + model_section = {} + if default_model: + model_section['default'] = default_model + if model_provider: + model_section['provider'] = model_provider + cfg['model'] = model_section + config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8') + + def create_profile_api(name: str, clone_from: str = None, clone_config: bool = False, base_url: str = None, - api_key: str = None) -> dict: + api_key: str = None, + default_model: str = None, + model_provider: str = None) -> dict: """Create a new profile. Returns the new profile info dict.""" _validate_profile_name(name) # Defense-in-depth: validate clone_from here too, even though routes.py # also validates it. Any caller that bypasses the HTTP layer gets protection. if clone_from is not None and not _is_root_profile(clone_from): _validate_profile_name(clone_from) + default_model, model_provider = _split_webui_provider_model_value(default_model, model_provider) try: from hermes_cli.profiles import create_profile @@ -998,6 +1061,11 @@ def create_profile_api(name: str, clone_from: str = None, profile_path.mkdir(parents=True, exist_ok=True) _write_endpoint_to_config(profile_path, base_url=base_url, api_key=api_key) + _write_model_defaults_to_config( + profile_path, + default_model=default_model, + model_provider=model_provider, + ) # Invalidate cached root-profile-name lookup; create_profile may have added # a new profile that flips is_default semantics on the agent side (#1612). diff --git a/api/routes.py b/api/routes.py index 3d3c5a15..dadc17f4 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4892,6 +4892,8 @@ def handle_post(handler, parsed) -> bool: return bad(handler, "Invalid clone_from name") base_url = body.get("base_url", "").strip() if body.get("base_url") else None api_key = body.get("api_key", "").strip() if body.get("api_key") else None + default_model = body.get("default_model", "").strip() if body.get("default_model") else None + model_provider = body.get("model_provider", "").strip() if body.get("model_provider") else None if base_url and not base_url.startswith(("http://", "https://")): return bad(handler, "base_url must start with http:// or https://") try: @@ -4903,6 +4905,8 @@ def handle_post(handler, parsed) -> bool: clone_config=bool(body.get("clone_config", False)), base_url=base_url, api_key=api_key, + default_model=default_model, + model_provider=model_provider, ) return j(handler, {"ok": True, "profile": result}) except (ValueError, FileExistsError, RuntimeError) as e: diff --git a/docs/pr-media/749/profile-create-after.png b/docs/pr-media/749/profile-create-after.png new file mode 100644 index 00000000..12c1d58d Binary files /dev/null and b/docs/pr-media/749/profile-create-after.png differ diff --git a/docs/pr-media/749/profile-create-before.png b/docs/pr-media/749/profile-create-before.png new file mode 100644 index 00000000..eaffea1a Binary files /dev/null and b/docs/pr-media/749/profile-create-before.png differ diff --git a/static/i18n.js b/static/i18n.js index 1c6321e7..84a13810 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1030,6 +1030,9 @@ const LOCALES = { profile_default_label: '(default)', profile_name_placeholder: 'Profile name (lowercase, a-z 0-9 hyphens)', profile_clone_label: 'Clone config from active profile', + profile_model_label: 'Model / provider', + profile_model_hint: 'Choose from configured providers and models for this new profile.', + profile_model_use_default: 'Use active profile default', profile_base_url_placeholder: 'Base URL (optional, e.g. http://localhost:11434)', profile_api_key_placeholder: 'API key (optional)', manage_profiles: 'Manage profiles', @@ -2164,6 +2167,9 @@ const LOCALES = { profile_default_label: '(predefinito)', profile_name_placeholder: 'Nome profilo (minuscole, a-z 0-9 trattini)', profile_clone_label: 'Clona config dal profilo attivo', + profile_model_label: 'Modello / provider', + profile_model_hint: 'Scegli tra provider e modelli configurati per questo nuovo profilo.', + profile_model_use_default: 'Usa il default del profilo attivo', profile_base_url_placeholder: 'Base URL (opzionale, es. http://localhost:11434)', profile_api_key_placeholder: 'Chiave API (opzionale)', manage_profiles: 'Gestisci profili', @@ -3303,6 +3309,9 @@ const LOCALES = { profile_default_label: '(デフォルト)', profile_name_placeholder: 'プロファイル名 (小文字 a-z 0-9 ハイフン)', profile_clone_label: 'アクティブプロファイルから設定をコピー', + profile_model_label: 'モデル / プロバイダー', + profile_model_hint: 'この新しいプロファイルで使う設定済みプロバイダーとモデルを選択します。', + profile_model_use_default: 'アクティブプロファイルのデフォルトを使用', profile_base_url_placeholder: 'ベース URL (任意、例: http://localhost:11434)', profile_api_key_placeholder: 'APIキー (任意)', manage_profiles: 'プロファイルを管理', @@ -4203,6 +4212,9 @@ const LOCALES = { profile_default_label: '(по умолчанию)', profile_name_placeholder: 'Название профиля (строчные буквы, a-z, 0-9, дефисы)', profile_clone_label: 'Скопировать конфигурацию из активного профиля', + profile_model_label: 'Модель / провайдер', + profile_model_hint: 'Выберите настроенного провайдера и модель для этого нового профиля.', + profile_model_use_default: 'Использовать значение активного профиля', profile_base_url_placeholder: 'Базовый URL (необязательно, например http://localhost:11434)', profile_api_key_placeholder: 'API-ключ (необязательно)', manage_profiles: 'Управление профилями', @@ -5285,6 +5297,9 @@ const LOCALES = { profile_default_label: '(predeterminado)', profile_name_placeholder: 'Nombre del perfil (minúsculas, a-z, 0-9, guiones)', profile_clone_label: 'Clonar configuración del perfil activo', + profile_model_label: 'Modelo / proveedor', + profile_model_hint: 'Elige entre proveedores y modelos configurados para este nuevo perfil.', + profile_model_use_default: 'Usar el valor predeterminado del perfil activo', profile_base_url_placeholder: 'URL base (opcional, p. ej. http://localhost:11434)', profile_api_key_placeholder: 'Clave API (opcional)', manage_profiles: 'Manage profiles', @@ -6526,6 +6541,9 @@ const LOCALES = { profile_default_label: 'Standard', profile_name_placeholder: 'Profilname', profile_clone_label: 'Klonen', + profile_model_label: 'Modell / Anbieter', + profile_model_hint: 'Wähle konfigurierte Anbieter und Modelle für dieses neue Profil.', + profile_model_use_default: 'Standard des aktiven Profils verwenden', profile_base_url_placeholder: 'https://api.openai.com/v1', profile_api_key_placeholder: 'sk-…', manage_profiles: 'Profile verwalten', @@ -7414,6 +7432,9 @@ const LOCALES = { profile_default_label: '(默认)', profile_name_placeholder: '配置档名称(小写字母、a-z、0-9、连字符)', profile_clone_label: '复制当前配置档的配置', + profile_model_label: '模型 / 提供商', + profile_model_hint: '为这个新配置档选择已配置的提供商和模型。', + profile_model_use_default: '使用当前配置档默认值', profile_base_url_placeholder: 'Base URL(可选,例如 http://localhost:11434)', profile_api_key_placeholder: 'API 密钥(可选)', manage_profiles: '管理配置档', @@ -8498,6 +8519,9 @@ const LOCALES = { profile_base_url_placeholder: '\u57fa\u672c URL\uff08\u9078\u586b\uff0c\u4f8b\u5982 http://localhost:11434\uff09', profile_base_url_rule: '\u57fa\u672c URL \u5fc5\u9808\u4ee5 http:// \u6216 https:// \u958b\u982d', profile_clone_label: '\u5f9e\u555f\u7528\u4e2d\u7684\u8a2d\u5b9a\u6a94\u8907\u88fd\u914d\u7f6e', + profile_model_label: '\u6a21\u578b / \u63d0\u4f9b\u5546', + profile_model_hint: '\u70ba\u9019\u500b\u65b0\u8a2d\u5b9a\u6a94\u9078\u64c7\u5df2\u8a2d\u5b9a\u7684\u63d0\u4f9b\u5546\u548c\u6a21\u578b\u3002', + profile_model_use_default: '\u4f7f\u7528\u555f\u7528\u4e2d\u8a2d\u5b9a\u6a94\u7684\u9810\u8a2d\u503c', profile_created: (name) => `設定檔 ${name} 已建立`, profile_default_label: '\uff08\u9810\u8a2d\uff09', profile_delete_confirm_title: (name) => `刪除設定檔「${name}」?`, @@ -9730,6 +9754,8 @@ const LOCALES = { profile_name_placeholder: 'ex: Trabalho, Pessoal', profile_provider_label: 'Provedor', profile_model_label: 'Modelo', + profile_model_hint: 'Escolha provedores e modelos configurados para este novo perfil.', + profile_model_use_default: 'Usar o padrão do perfil ativo', profile_model_required: 'Modelo é necessário', profile_base_url_label: 'Base URL', profile_base_url_placeholder: 'Opcional, ex: http://localhost:11434', @@ -10769,6 +10795,9 @@ const LOCALES = { profile_default_label: '(default)', profile_name_placeholder: 'Profile name (lowercase, a-z 0-9 hyphens)', profile_clone_label: 'Clone config from active profile', + profile_model_label: 'Model / provider', + profile_model_hint: 'Choose from configured providers and models for this new profile.', + profile_model_use_default: 'Use active profile default', profile_base_url_placeholder: 'Base URL (optional, e.g. http://localhost:11434)', profile_api_key_placeholder: 'API key (optional)', manage_profiles: 'Manage profiles', @@ -11823,6 +11852,9 @@ const LOCALES = { profile_default_label: '(défaut)', profile_name_placeholder: 'Nom du profil (minuscules, a-z 0-9 tirets)', profile_clone_label: 'Cloner la configuration du profil actif', + profile_model_label: 'Modèle / fournisseur', + profile_model_hint: 'Choisissez parmi les fournisseurs et modèles configurés pour ce nouveau profil.', + profile_model_use_default: 'Utiliser la valeur par défaut du profil actif', profile_base_url_placeholder: 'URL de base (facultatif, par exemple http://localhost:11434)', profile_api_key_placeholder: 'Clé API (facultatif)', manage_profiles: 'Gérer les profils', diff --git a/static/panels.js b/static/panels.js index a81a597c..8ebf44d6 100644 --- a/static/panels.js +++ b/static/panels.js @@ -4720,6 +4720,11 @@ function _renderProfileForm(){ ${esc(t('profile_clone_label') || 'Clone config from active profile')} +