Clarify model scope in composer and settings

This commit is contained in:
Frank Song authored and Hermes Agent committed 2026-04-29 04:33:29 +00:00
1 parent 2487de2cc0
commit 6f37da38a6
7 files changed
+83 -16

No files matched your search

+3 -3
View File
@@ -455,9 +455,9 @@ $('modelSelect').onchange=async()=>{
const warn=_checkProviderMismatch(selectedModel);
if(warn&&typeof showToast==='function') showToast(warn,4000);
}
// Notify user that model changes only take effect in the next conversation (#419)
if(S.messages && S.messages.length > 0 && typeof showToast==='function'){
showToast('Model change takes effect in your next conversation', 3000);
// Clarify scope: composer model changes are session-local, not the global default.
if(typeof showToast==='function'){
showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
}
};
$('msg').addEventListener('input',()=>{
+21
View File
@@ -70,6 +70,8 @@ const LOCALES = {
model_custom_placeholder: 'e.g. openai/gpt-5.4',
model_search_placeholder: 'Search models…',
model_search_no_results: 'No models found',
model_scope_advisory: 'Applies to this conversation from your next message.',
model_scope_toast: 'Applies to this conversation from your next message.',
// commands.js
cmd_clear: 'Clear conversation messages',
cmd_compress: 'Manually compress conversation context (usage: /compress [focus topic])',
@@ -298,6 +300,7 @@ const LOCALES = {
settings_title: 'Settings',
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_send_key: 'Send Key',
settings_label_theme: 'Theme',
settings_label_skin: 'Skin',
@@ -824,6 +827,8 @@ const LOCALES = {
focus_label: 'Фокус',
model_search_no_results: 'Модели не найдены',
model_search_placeholder: 'Поиск моделей…',
model_scope_advisory: 'Применяется к этой беседе со следующего сообщения.',
model_scope_toast: 'Применяется к этой беседе со следующего сообщения.',
reference_only_label: 'Только справка',
settings_label_skin: 'Скин',
settings_label_font_size: 'Размер шрифта',
@@ -885,6 +890,7 @@ const LOCALES = {
settings_title: 'Настройки',
settings_save_btn: 'Сохранить настройки',
settings_label_model: 'Модель по умолчанию',
settings_desc_model: 'Используется для новых бесед. Существующие беседы сохраняют выбранную модель.',
settings_label_send_key: 'Клавиша отправки',
settings_label_theme: 'Тема',
settings_label_language: 'Язык',
@@ -1407,6 +1413,8 @@ const LOCALES = {
model_custom_placeholder: 'p. ej. openai/gpt-5.4',
model_search_placeholder: 'Buscar modelos…',
model_search_no_results: 'No se encontraron modelos',
model_scope_advisory: 'Se aplica a esta conversación desde tu próximo mensaje.',
model_scope_toast: 'Se aplica a esta conversación desde tu próximo mensaje.',
// commands.js
cmd_help: 'Listar los comandos disponibles',
cmd_clear: 'Borrar los mensajes de la conversación',
@@ -1518,6 +1526,7 @@ const LOCALES = {
settings_title: 'Configuración',
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_send_key: 'Tecla de envío',
settings_label_theme: 'Tema',
settings_label_skin: 'Piel',
@@ -2079,6 +2088,8 @@ const LOCALES = {
theme_usage: 'Nutzung: /theme ',
theme_set: 'Theme: ',
no_active_session: 'Keine aktive Sitzung',
model_scope_advisory: 'Gilt für diesen Chat ab Ihrer nächsten Nachricht.',
model_scope_toast: 'Gilt für diesen Chat ab Ihrer nächsten Nachricht.',
cmd_queue: 'Nachricht f\u00fcr den n\u00e4chsten Durchgang einreihen',
cmd_interrupt: 'Aktuellen Durchgang abbrechen und neue Nachricht senden',
cmd_steer: 'Korrektursignal einf\u00fcgen ohne Unterbrechung',
@@ -2156,6 +2167,7 @@ const LOCALES = {
settings_title: 'Einstellungen',
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_send_key: 'Sende-Taste',
settings_label_theme: 'Theme',
settings_label_skin: 'Skin',
@@ -2452,6 +2464,8 @@ const LOCALES = {
model_custom_placeholder: '\u4f8b\u5982 openai/gpt-5.4',
model_search_placeholder: '\u641c\u7d22\u6a21\u578b\u2026',
model_search_no_results: '\u672a\u627e\u5230\u6a21\u578b',
model_scope_advisory: '\u4ece\u4e0b\u4e00\u6761\u6d88\u606f\u8d77\u5e94\u7528\u4e8e\u5f53\u524d\u5bf9\u8bdd\u3002',
model_scope_toast: '\u4ece\u4e0b\u4e00\u6761\u6d88\u606f\u8d77\u5e94\u7528\u4e8e\u5f53\u524d\u5bf9\u8bdd\u3002',
// commands.js
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
cmd_clear: '\u6e05\u7a7a\u5f53\u524d\u5bf9\u8bdd\u6d88\u606f',
@@ -2570,6 +2584,7 @@ const LOCALES = {
settings_title: '\u8bbe\u7f6e',
settings_save_btn: '\u4fdd\u5b58\u8bbe\u7f6e',
settings_label_model: '\u9ed8\u8ba4\u6a21\u578b',
settings_desc_model: '\u7528\u4e8e\u65b0\u5bf9\u8bdd\u3002\u73b0\u6709\u5bf9\u8bdd\u4fdd\u6301\u5404\u81ea\u9009\u5b9a\u7684\u6a21\u578b\u3002',
settings_label_send_key: '\u53d1\u9001\u5feb\u6377\u952e',
settings_label_theme: '\u4e3b\u9898',
settings_label_skin: '\u76ae\u80a4',
@@ -3217,6 +3232,7 @@ const LOCALES = {
settings_title: '\u8a2d\u5b9a',
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_send_key: '\u767c\u9001\u5feb\u6377\u9375',
settings_label_theme: '\u4e3b\u984c',
settings_label_skin: '佈景',
@@ -3468,6 +3484,8 @@ const LOCALES = {
model_custom_placeholder: '\u4f8b\u5982 openai/gpt-5.4',
model_search_no_results: '\u627e\u4e0d\u5230\u6a21\u578b',
model_search_placeholder: '\u641c\u5c0b\u6a21\u578b\u2026',
model_scope_advisory: '\u5f9e\u4e0b\u4e00\u5247\u8a0a\u606f\u8d77\u9069\u7528\u65bc\u6b64\u6703\u8a71\u3002',
model_scope_toast: '\u5f9e\u4e0b\u4e00\u5247\u8a0a\u606f\u8d77\u9069\u7528\u65bc\u6b64\u6703\u8a71\u3002',
my_notes: '\u6211\u7684\u5099\u8a3b',
name_required: '\u9700\u8981\u540d\u7a31',
never: '\u5f9e\u4e0d',
@@ -3767,6 +3785,8 @@ const LOCALES = {
model_custom_placeholder: 'e.g. openai/gpt-5.4',
model_search_placeholder: 'Search models…',
model_search_no_results: 'No models found',
model_scope_advisory: '다음 메시지부터 이 대화에 적용됩니다.',
model_scope_toast: '다음 메시지부터 이 대화에 적용됩니다.',
// commands.js
cmd_clear: '대화 메시지 지우기',
cmd_compress: 'Manually compress conversation context (usage: /compress [focus topic])',
@@ -3994,6 +4014,7 @@ const LOCALES = {
settings_title: '설정',
settings_save_btn: '설정 저장',
settings_label_model: '기본 모델',
settings_desc_model: '새 대화에 사용됩니다. 기존 대화는 선택된 모델을 유지합니다.',
settings_label_send_key: '전송 키',
settings_label_theme: '테마',
settings_label_skin: '스킨',
+1
View File
@@ -625,6 +625,7 @@
<div class="settings-field">
<label for="settingsModel" data-i18n="settings_label_model">Default Model</label>
<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 for="settingsSendKey" data-i18n="settings_label_send_key">Send Key</label>
+1
View File
@@ -976,6 +976,7 @@
.ws-dropdown-footer{left:0;right:auto;bottom:calc(100% + 4px);min-width:280px;max-width:min(420px,calc(100vw - 32px));}
.model-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:280px;max-width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
.model-dropdown.open{display:block;}
.model-scope-note{position:sticky;top:0;z-index:2;padding:9px 14px;border-bottom:1px solid var(--border);color:var(--text);font-size:11px;font-weight:650;line-height:1.4;background:color-mix(in srgb,var(--surface) 82%,var(--accent-bg));box-shadow:0 1px 0 rgba(0,0,0,.12);}
.model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;}
.model-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:3px;align-items:flex-start;}
.model-opt:hover{background:rgba(255,255,255,.07);}
+5
View File
@@ -350,6 +350,9 @@ function renderModelDropdown(){
}
}
// Create search input FIRST before filterModels definition
const _scopeNote=document.createElement('div');
_scopeNote.className='model-scope-note';
_scopeNote.textContent=t('model_scope_advisory')||'Applies to this conversation from your next message.';
const _searchRow=document.createElement('div');
_searchRow.className='model-search-row';
_searchRow.innerHTML=`<input class="model-search-input" type="text" placeholder="${esc(t('model_search_placeholder')||'Search models…')}" spellcheck="false" autocomplete="off"><button class="model-search-clear" title="Clear search">${li('x',10)}</button>`;
@@ -378,6 +381,7 @@ function renderModelDropdown(){
// Clear and rebuild
dd.innerHTML='';
// Add search and custom elements first (CRITICAL: must be before models)
dd.appendChild(_scopeNote);
dd.appendChild(_searchRow);
dd.appendChild(_custSep);
dd.appendChild(_custRow);
@@ -425,6 +429,7 @@ function renderModelDropdown(){
_ci.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();_applyCustom();}if(e.key==='Escape'){closeModelDropdown();}});
_ci.addEventListener('click',e=>e.stopPropagation());
// Add search and custom elements to dropdown (initial render)
dd.appendChild(_scopeNote);
dd.appendChild(_searchRow);
dd.appendChild(_custSep);
dd.appendChild(_custRow);
+40
View File
@@ -0,0 +1,40 @@
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
def read(rel: str) -> str:
return (REPO / rel).read_text(encoding="utf-8")
def test_composer_model_dropdown_has_scope_advisory():
ui = read("static/ui.js")
style = read("static/style.css")
assert "model-scope-note" in ui
assert "model_scope_advisory" in ui
assert "Applies to this conversation from your next message." in ui
assert ui.index("dd.appendChild(_scopeNote);") < ui.index("dd.appendChild(_searchRow);")
assert ".model-scope-note" in style
assert "position:sticky" in style
def test_model_selection_toast_describes_conversation_scope():
boot = read("static/boot.js")
i18n = read("static/i18n.js")
assert "model_scope_toast" in boot
assert "Applies to this conversation from your next message." in i18n
assert "model_scope_advisory: 'Applies to this conversation from your next message.'" in i18n
assert "model_scope_toast: 'Applies to this conversation from your next message.'" in i18n
assert "Model change takes effect in your next conversation" not in boot
def test_settings_default_model_copy_describes_new_conversations():
html = read("static/index.html")
i18n = read("static/i18n.js")
assert 'data-i18n="settings_desc_model"' in html
assert "Used for new conversations. Existing conversations keep their selected model." in html
assert "settings_desc_model" in i18n
+12 -13
View File
@@ -576,34 +576,33 @@ def test_api_session_is_side_effect_free_for_stale_models():
# ── Model switch toast (#419) ─────────────────────────────────────────────────
class TestModelSwitchToast:
"""Toast appears when user switches model during an active session."""
"""Toast appears when user switches the current conversation model."""
def test_toast_in_model_select_onchange(self):
"""modelSelect.onchange must show a toast when S.messages is non-empty."""
"""modelSelect.onchange must show a scope toast after selecting a model."""
src = _read("static/boot.js")
# Find the onchange block
idx = src.find("modelSelect').onchange")
assert idx != -1, "modelSelect.onchange not found in boot.js"
block = src[idx:idx + 1100]
assert "Model change takes effect in your next conversation" in block, (
"modelSelect.onchange must show a toast when switching model mid-session"
assert "model_scope_toast" in block, (
"modelSelect.onchange must show that the selected model applies to this conversation"
)
def test_toast_guards_on_messages_length(self):
"""Toast must only fire when there are existing messages (active session)."""
def test_toast_is_not_gated_on_messages_length(self):
"""Toast must fire for every model selection, not only sessions with messages."""
src = _read("static/boot.js")
idx = src.find("Model change takes effect in your next conversation")
idx = src.find("model_scope_toast")
assert idx != -1
# Look back 200 chars for the S.messages guard
surrounding = src[max(0, idx - 200):idx + 50]
assert "S.messages" in surrounding and ".length" in surrounding, (
"Model switch toast must be gated on S.messages.length > 0"
surrounding = src[max(0, idx - 220):idx + 80]
assert not ("S.messages" in surrounding and ".length" in surrounding), (
"Model scope toast should not be gated on S.messages.length"
)
def test_toast_uses_show_toast_not_alert(self):
"""Toast must use showToast(), not alert()."""
src = _read("static/boot.js")
idx = src.find("Model change takes effect in your next conversation")
idx = src.find("model_scope_toast")
assert idx != -1
surrounding = src[max(0, idx - 50):idx + 100]
assert "showToast" in surrounding, "Must use showToast() not alert()"
@@ -612,7 +611,7 @@ class TestModelSwitchToast:
def test_toast_has_typeof_showtoast_guard(self):
"""Toast call must guard typeof showToast to be safe during boot."""
src = _read("static/boot.js")
idx = src.find("Model change takes effect in your next conversation")
idx = src.find("model_scope_toast")
assert idx != -1
surrounding = src[max(0, idx - 100):idx + 50]
assert "typeof showToast" in surrounding, (