feat: add early session provisional titles

This commit is contained in:
Jordan SkyLF
2026-05-13 08:38:07 -07:00
parent f5be6e3a69
commit 0381294f1c
5 changed files with 235 additions and 24 deletions

View File

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

View File

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

View File

@@ -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=>{

View File

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

View File

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