feat: add early session provisional titles
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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=>{
|
||||
|
||||
167
tests/test_early_session_title.py
Normal file
167
tests/test_early_session_title.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user