Add profile creation model picker

This commit is contained in:
Frank Song 2026-05-14 12:06:43 +08:00
parent a17d75431c
commit 8b30ade923
8 changed files with 224 additions and 1 deletions

View file

@ -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 `<label>` AND moves it into its own `settings-field` div instead of leaving it orphaned after the session-jump description with a stray closing `</label>`. 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.

View file

@ -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).

View file

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View file

@ -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',

View file

@ -4720,6 +4720,11 @@ function _renderProfileForm(){
<input type="checkbox" id="profileFormClone"> <span>${esc(t('profile_clone_label') || 'Clone config from active profile')}</span>
</label>
</div>
<div class="detail-form-row">
<label for="profileFormModel">${esc(t('profile_model_label') || 'Model / provider')}</label>
<select id="profileFormModel"></select>
<div class="detail-form-hint">${esc(t('profile_model_hint') || 'Choose from configured providers and models for this new profile.')}</div>
</div>
<div class="detail-form-row">
<label for="profileFormBaseUrl">${esc(t('profile_base_url_label') || 'Base URL')}</label>
<input type="text" id="profileFormBaseUrl" placeholder="${esc(t('profile_base_url_placeholder') || 'Optional, e.g. http://localhost:11434')}" autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false">
@ -4736,6 +4741,35 @@ function _renderProfileForm(){
_setProfileHeaderButtons('create');
const n = $('profileFormName');
if (n) n.focus();
_populateProfileFormModelSelect();
}
async function _populateProfileFormModelSelect(){
const sel = $('profileFormModel');
if (!sel) return;
sel.innerHTML = `<option value="">${esc(t('profile_model_use_default') || 'Use active profile default')}</option>`;
try {
const data = await api('/api/models');
const groups = (Array.isArray(data && data.groups) && data.groups.length) ? data.groups : [];
for (const g of groups) {
const og = document.createElement('optgroup');
og.label = g.provider || g.provider_id || 'Configured';
if (g.provider_id) og.dataset.provider = g.provider_id;
for (const m of (Array.isArray(g.models) ? g.models : [])) {
if (!m || !m.id) continue;
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.label || m.id;
og.appendChild(opt);
}
if (og.children.length) sel.appendChild(og);
}
if (data && data.default_model && typeof _applyModelToDropdown === 'function') {
_applyModelToDropdown(data.default_model, sel, data.active_provider || window._activeProvider || null);
}
} catch (e) {
console.warn('Failed to load profile model picker:', e.message);
}
}
function cancelProfileForm(){
@ -4752,6 +4786,7 @@ function cancelProfileForm(){
async function saveProfileForm(){
const nameEl = $('profileFormName');
const cloneEl = $('profileFormClone');
const modelEl = $('profileFormModel');
const baseEl = $('profileFormBaseUrl');
const apiKeyEl = $('profileFormApiKey');
const errEl = $('profileFormError');
@ -4766,6 +4801,14 @@ async function saveProfileForm(){
if (baseUrl && !/^https?:\/\//.test(baseUrl)) { errEl.textContent = t('profile_base_url_rule'); errEl.style.display = ''; return; }
try {
const payload = { name, clone_config: cloneConfig };
const selectedModel = modelEl ? (modelEl.value || '').trim() : '';
if (selectedModel) {
const modelState = (typeof _modelStateForSelect === 'function')
? _modelStateForSelect(modelEl, selectedModel)
: { model: selectedModel, model_provider: null };
if (modelState.model) payload.default_model = modelState.model;
if (modelState.model_provider) payload.model_provider = modelState.model_provider;
}
if (baseUrl) payload.base_url = baseUrl;
if (apiKey) payload.api_key = apiKey;
await api('/api/profile/create', { method: 'POST', body: JSON.stringify(payload) });

View file

@ -0,0 +1,74 @@
"""Regression coverage for #749 profile creation model/provider selection."""
from pathlib import Path
import yaml
import api.profiles as profiles
REPO = Path(__file__).resolve().parent.parent
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
def test_profile_create_form_exposes_model_picker():
assert 'id="profileFormModel"' in PANELS_JS
assert "_populateProfileFormModelSelect" in PANELS_JS
assert "profile_model_label" in PANELS_JS
assert "profile_model_hint" in PANELS_JS
def test_profile_create_payload_preserves_provider_context():
fn_start = PANELS_JS.find("async function saveProfileForm()")
assert fn_start != -1
fn_body = PANELS_JS[fn_start : PANELS_JS.find("\n}", fn_start) + 2]
assert "profileFormModel" in fn_body
assert "_modelStateForSelect(modelEl, selectedModel)" in fn_body
assert "payload.default_model" in fn_body
assert "payload.model_provider" in fn_body
def test_profile_create_route_passes_model_fields_to_profile_api():
route_start = ROUTES_PY.find('if parsed.path == "/api/profile/create":')
assert route_start != -1
route_body = ROUTES_PY[route_start : ROUTES_PY.find('if parsed.path == "/api/profile/delete":', route_start)]
assert 'default_model = body.get("default_model"' in route_body
assert 'model_provider = body.get("model_provider"' in route_body
assert "default_model=default_model" in route_body
assert "model_provider=model_provider" in route_body
def test_profile_model_config_writer_persists_default_and_provider(tmp_path):
profile_dir = tmp_path / "profiles" / "research"
profile_dir.mkdir(parents=True)
profiles._write_model_defaults_to_config(
profile_dir,
default_model="anthropic/claude-opus-4.6",
model_provider="nous",
)
saved = yaml.safe_load((profile_dir / "config.yaml").read_text(encoding="utf-8"))
assert saved["model"]["default"] == "anthropic/claude-opus-4.6"
assert saved["model"]["provider"] == "nous"
def test_profile_model_config_writer_preserves_existing_model_settings(tmp_path):
profile_dir = tmp_path / "profiles" / "research"
profile_dir.mkdir(parents=True)
(profile_dir / "config.yaml").write_text(
"model:\n base_url: https://gateway.example/v1\n",
encoding="utf-8",
)
profiles._write_model_defaults_to_config(
profile_dir,
default_model="gpt-5.5",
model_provider="openai-codex",
)
saved = yaml.safe_load((profile_dir / "config.yaml").read_text(encoding="utf-8"))
assert saved["model"]["base_url"] == "https://gateway.example/v1"
assert saved["model"]["default"] == "gpt-5.5"
assert saved["model"]["provider"] == "openai-codex"