diff --git a/api/routes.py b/api/routes.py index 76359aa1..765857ca 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6912,6 +6912,17 @@ def _checkpoint_user_message_for_eager_session_save(s, msg: str, attachments, st s.messages.append(user_msg) +def _is_default_or_empty_session_title(title) -> bool: + return str(title or "").strip() in ("", "Untitled", "New Chat") + + +def _provisional_title_from_prompt(prompt: str, fallback: str = "Untitled") -> str: + text = str(prompt or "").strip() + if not text: + return fallback + return title_from([{"role": "user", "content": text}], fallback) or fallback + + def _prepare_chat_start_session_for_stream( s, *, @@ -6939,6 +6950,11 @@ def _prepare_chat_start_session_for_stream( s.pending_user_message = msg s.pending_attachments = attachments s.pending_started_at = started_at if started_at is not None else time.time() + current_title = getattr(s, "title", None) + if _is_default_or_empty_session_title(current_title): + provisional_title = _provisional_title_from_prompt(msg, current_title or "Untitled") + if provisional_title and not _is_default_or_empty_session_title(provisional_title): + s.title = provisional_title if get_webui_session_save_mode() == "eager": _checkpoint_user_message_for_eager_session_save( s, @@ -7046,6 +7062,7 @@ def _start_chat_stream_for_session( "session_id": s.session_id, "pending_started_at": s.pending_started_at, "turn_id": journal_event.get("turn_id"), + "title": s.title, } if normalized_model: response["effective_model"] = model diff --git a/api/streaming.py b/api/streaming.py index c596f2bf..b1d182ae 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -789,7 +789,7 @@ def _is_provisional_title(current_title: str, messages) -> bool: candidate = re.sub(r'\s+', ' ', str(derived[:64] or '')).strip() if not current or not candidate: return False - return current == candidate or candidate.startswith(current) + return current == candidate def _title_prompts(user_text: str, assistant_text: str) -> tuple[str, list[str]]: diff --git a/static/messages.js b/static/messages.js index ddf13d5a..4ee2e1eb 100644 --- a/static/messages.js +++ b/static/messages.js @@ -56,6 +56,48 @@ if(_msgEl) _msgEl.addEventListener('blur', ()=>{ if('speechSynthesis' in window // (e.g. queue drain + user click) can both pass the S.busy check because // setBusy(true) is only called after the first await inside send(). let _sendInProgress = false; +const _sessionTitleProvisionalBySid = new Map(); + +function _sessionTitleLooksDefaultOrProvisional(titleText, provisionalText){ + const title=String(titleText||'').replace(/\s+/g,' ').trim(); + if(!title||title==='Untitled'||title==='New Chat')return true; + const provisional=String(provisionalText||'').replace(/\s+/g,' ').trim().slice(0,64); + return !!provisional&&title===provisional; +} + +function _firstUserMessageTitleCandidate(){ + const first=(S.messages||[]).find(m=>m&&m.role==='user'&&m.content); + return first?String(first.content||'').trim().slice(0,64):''; +} + +function applySessionTitleUpdate(sid, titleText, options={}){ + const newTitle=String(titleText||'').trim(); + if(!sid||!newTitle)return false; + const row=(typeof _allSessions!=='undefined'&&Array.isArray(_allSessions)) + ? _allSessions.find(s=>s&&s.session_id===sid) + : null; + const currentTitle=S.session&&S.session.session_id===sid + ? S.session.title + : row&&row.title; + if(!options.force){ + const expected=String(options.expectedCurrent||'').trim(); + const remembered=_sessionTitleProvisionalBySid.get(sid)||''; + const provisionalCandidates=[options.provisionalText,remembered,_firstUserMessageTitleCandidate()]; + const allowed=(expected&&String(currentTitle||'').trim()===expected) + || String(currentTitle||'').trim()===newTitle + || provisionalCandidates.some(p=>_sessionTitleLooksDefaultOrProvisional(currentTitle, p)); + if(!allowed)return false; + } + if(S.session&&S.session.session_id===sid){ + S.session.title=newTitle; + if(typeof syncTopbar==='function') syncTopbar(); + } + if(row) row.title=newTitle; + if(options.rememberProvisional) _sessionTitleProvisionalBySid.set(sid,newTitle); + if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); + else if(typeof renderSessionList==='function') renderSessionList(); + return true; +} async function send(){ // Reject concurrent invocations early — before any await yields control. @@ -249,21 +291,16 @@ async function send(){ if(typeof updateSendBtn==='function') updateSendBtn(); // Set provisional title from user message immediately so session appears - // in the sidebar right away with a meaningful name (server may refine later) + // in the sidebar right away with a meaningful name. /api/chat/start persists + // the server-side provisional title and may refine this optimistic text. if(S.session&&(S.session.title==='Untitled'||!S.session.title)){ const provisionalTitle=displayText.slice(0,64); - S.session.title=provisionalTitle; - syncTopbar(); - // Persist it in the background; keep the optimistic sidebar cache as the - // immediate source of truth until /api/chat/start saves pending state. - api('/api/session/rename',{method:'POST',body:JSON.stringify({ - session_id:activeSid, title:provisionalTitle - })}).catch(()=>{}); // fire-and-forget, server refines on done + applySessionTitleUpdate(activeSid, provisionalTitle, {force:true, rememberProvisional:true}); if(typeof upsertActiveSessionForLocalTurn==='function'){ // Second optimistic pass: carry the provisional title into the cached row // without re-fetching /api/sessions before pending state exists server-side. upsertActiveSessionForLocalTurn({title:provisionalTitle,messageCount:S.messages.length,timestampMs:Date.now()}); - }else if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); + } } else if(typeof upsertActiveSessionForLocalTurn==='function'){ upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()}); } else { @@ -281,6 +318,8 @@ async function send(){ attachments:uploaded.length?uploaded:undefined })}); + if(startData.title) applySessionTitleUpdate(activeSid, startData.title, {provisionalText:displayText.slice(0,64), rememberProvisional:true}); + if(startData.effective_model && S.session){ S.session.model=startData.effective_model; S.session.model_provider=startData.effective_model_provider||S.session.model_provider||null; @@ -909,18 +948,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let d={}; try{ d=JSON.parse(e.data||'{}'); }catch(_){} if((d.session_id||activeSid)!==activeSid) return; - const newTitle=String(d.title||'').trim(); - if(!newTitle) return; - if(S.session&&S.session.session_id===activeSid){ - S.session.title=newTitle; - syncTopbar(); - } - if(typeof _allSessions!=='undefined'&&Array.isArray(_allSessions)){ - const row=_allSessions.find(s=>s&&s.session_id===activeSid); - if(row) row.title=newTitle; - } - if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); - else if(typeof renderSessionList==='function') renderSessionList(); + applySessionTitleUpdate(activeSid, d.title); }); source.addEventListener('title_status',e=>{ diff --git a/tests/test_early_session_title.py b/tests/test_early_session_title.py new file mode 100644 index 00000000..864e0e73 --- /dev/null +++ b/tests/test_early_session_title.py @@ -0,0 +1,167 @@ +from pathlib import Path + + +def test_prepare_chat_start_sets_provisional_title_for_default_session(tmp_path, monkeypatch): + from api.models import Session + from api.routes import _prepare_chat_start_session_for_stream + + saved = [] + + def fake_save(self, *args, **kwargs): + saved.append( + { + "title": self.title, + "pending_user_message": self.pending_user_message, + "active_stream_id": self.active_stream_id, + } + ) + + monkeypatch.setattr(Session, "save", fake_save) + + s = Session(session_id="test-early-title", title="Untitled") + _prepare_chat_start_session_for_stream( + s, + msg="Can you conclude whether early WebUI session titles are possible?", + attachments=[], + workspace=str(tmp_path), + model="test-model", + model_provider=None, + stream_id="stream-1", + started_at=123.0, + ) + + assert s.title != "Untitled" + assert "early WebUI session titles" in s.title or s.title.startswith("Can you conclude") + assert saved[-1]["title"] == s.title + assert saved[-1]["pending_user_message"] == "Can you conclude whether early WebUI session titles are possible?" + assert saved[-1]["active_stream_id"] == "stream-1" + + +def test_prepare_chat_start_sets_provisional_title_in_eager_save_mode(tmp_path, monkeypatch): + from api.models import Session + import api.routes as routes + + saved = [] + + def fake_save(self, *args, **kwargs): + saved.append({"title": self.title, "messages": list(self.messages)}) + + monkeypatch.setattr(Session, "save", fake_save) + monkeypatch.setattr(routes, "get_webui_session_save_mode", lambda: "eager") + + s = Session(session_id="test-eager-early-title", title="Untitled") + routes._prepare_chat_start_session_for_stream( + s, + msg="Can eager session save also get early titles?", + attachments=[], + workspace=str(tmp_path), + model="test-model", + model_provider=None, + stream_id="stream-1", + started_at=123.0, + ) + + assert s.title != "Untitled" + assert s.messages[-1]["role"] == "user" + assert s.messages[-1]["content"] == "Can eager session save also get early titles?" + assert saved[-1]["title"] == s.title + + +def test_prepare_chat_start_does_not_overwrite_manual_title(tmp_path, monkeypatch): + from api.models import Session + from api.routes import _prepare_chat_start_session_for_stream + + monkeypatch.setattr(Session, "save", lambda self, *a, **k: None) + + s = Session(session_id="test-manual-title", title="My Manual Title") + _prepare_chat_start_session_for_stream( + s, + msg="This prompt should not replace the title", + attachments=[], + workspace=str(tmp_path), + model="test-model", + model_provider=None, + stream_id="stream-1", + started_at=123.0, + ) + + assert s.title == "My Manual Title" + + +def test_start_chat_stream_response_includes_provisional_title(tmp_path, monkeypatch): + from api.models import Session + import api.routes as routes + + monkeypatch.setattr(Session, "save", lambda self, *a, **k: None) + monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) + monkeypatch.setattr(routes, "create_stream_channel", lambda: object()) + monkeypatch.setattr(routes, "_run_agent_streaming", lambda *a, **k: None) + + class ImmediateThread: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + def start(self): + return None + + monkeypatch.setattr(routes.threading, "Thread", ImmediateThread) + + s = Session(session_id="test-start-response-title", title="Untitled") + response = routes._start_chat_stream_for_session( + s, + msg="Please design early session titles for Hermes WebUI", + attachments=[], + workspace=str(tmp_path), + model="test-model", + model_provider=None, + ) + + try: + routes.STREAMS.pop(response["stream_id"], None) + except Exception: + pass + + assert response["title"] == s.title + assert response["title"] != "Untitled" + + +def test_prompt_provisional_title_still_counts_as_provisional_after_response(): + from api.models import title_from + from api.streaming import _is_provisional_title + + messages = [ + {"role": "user", "content": "Can you implement early session titles in Hermes WebUI?"}, + {"role": "assistant", "content": "Yes, here is the plan..."}, + ] + provisional = title_from(messages, "Untitled") + assert _is_provisional_title(provisional, messages) + + +def test_prompt_prefix_manual_title_is_not_treated_as_provisional(): + from api.models import title_from + from api.streaming import _is_provisional_title + + messages = [ + {"role": "user", "content": "Can you implement early session titles in Hermes WebUI?"}, + {"role": "assistant", "content": "Yes, here is the plan..."}, + ] + provisional = title_from(messages, "Untitled") + manual_prefix = provisional[: max(3, min(12, len(provisional) - 1))] + + assert manual_prefix + assert provisional.startswith(manual_prefix) + assert manual_prefix != provisional + assert not _is_provisional_title(manual_prefix, messages) + + +def test_messages_js_applies_chat_start_title(): + src = Path("static/messages.js").read_text(encoding="utf-8") + assert "applySessionTitleUpdate" in src + assert "startData.title" in src or "provisional_title" in src + assert "addEventListener('title'" in src + assert "_sessionTitleLooksDefaultOrProvisional" in src + assert "options.force" in src + assert "_sessionTitleProvisionalBySid" in src + assert "rememberProvisional:true" in src + assert "provisionalText:displayText.slice(0,64)" in src diff --git a/tests/test_sprint41.py b/tests/test_sprint41.py index 1e5b3983..f2d72ec1 100644 --- a/tests/test_sprint41.py +++ b/tests/test_sprint41.py @@ -285,9 +285,8 @@ class TestIssue495TitleStreaming(unittest.TestCase): ] derived = title_from(messages, "") - current = derived[:63] # Simulate the provisional title the UI writes immediately. + current = derived[:64] # Simulate the provisional title the UI writes immediately. - self.assertNotEqual(current, derived[:64]) self.assertTrue( _is_provisional_title(current, messages), "Whitespace-normalized provisional titles should still be recognized",