mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
Stage 326: PR #1956 — feat: persistent composer draft — server-side, cross-client, survives refresh by @JKJameson
This commit is contained in:
+4
-1
@@ -335,6 +335,7 @@ class Session:
|
||||
llm_title_generated: bool=False,
|
||||
parent_session_id: str=None,
|
||||
enabled_toolsets=None,
|
||||
composer_draft=None,
|
||||
**kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||
self.title = title
|
||||
@@ -373,6 +374,7 @@ class Session:
|
||||
self.session_source = kwargs.get('session_source')
|
||||
self.source_label = kwargs.get('source_label')
|
||||
self.enabled_toolsets = enabled_toolsets # List[str] or None — per-session toolset override
|
||||
self.composer_draft = composer_draft if isinstance(composer_draft, dict) else {}
|
||||
self._metadata_message_count = None
|
||||
|
||||
@property
|
||||
@@ -413,7 +415,7 @@ class Session:
|
||||
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
|
||||
'parent_session_id',
|
||||
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label',
|
||||
'enabled_toolsets',
|
||||
'enabled_toolsets', 'composer_draft',
|
||||
]
|
||||
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
|
||||
meta['messages'] = self.messages
|
||||
@@ -590,6 +592,7 @@ class Session:
|
||||
'session_source': self.session_source,
|
||||
'source_label': self.source_label,
|
||||
'enabled_toolsets': self.enabled_toolsets,
|
||||
'composer_draft': self.composer_draft if isinstance(self.composer_draft, dict) else {},
|
||||
'is_streaming': _is_streaming_session(
|
||||
self.active_stream_id, active_stream_ids
|
||||
) if include_runtime else False,
|
||||
|
||||
@@ -4001,6 +4001,43 @@ def handle_post(handler, parsed) -> bool:
|
||||
s.save()
|
||||
return j(handler, {"ok": True, "enabled_toolsets": s.enabled_toolsets})
|
||||
|
||||
if parsed.path == "/api/session/draft":
|
||||
# GET ?session_id=X → return current draft
|
||||
# POST body → save draft { session_id, text?, files? }
|
||||
# HTTP method is in handler.command (e.g. "POST", "GET"), parsed has no .method
|
||||
if handler.command == "GET":
|
||||
query = parse_qs(parsed.query)
|
||||
sid = query.get("session_id", [""])[0] if parsed.query else ""
|
||||
if not sid:
|
||||
return bad(handler, "session_id is required", 400)
|
||||
try:
|
||||
s = get_session(sid)
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
draft = getattr(s, "composer_draft", {}) or {}
|
||||
return j(handler, {"draft": draft})
|
||||
# POST
|
||||
try:
|
||||
require(body, "session_id")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
sid = body["session_id"]
|
||||
text = body.get("text")
|
||||
files = body.get("files")
|
||||
try:
|
||||
s = get_session(sid)
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
with _get_session_agent_lock(sid):
|
||||
draft = getattr(s, "composer_draft", {}) or {}
|
||||
if text is not None:
|
||||
draft["text"] = text
|
||||
if files is not None:
|
||||
draft["files"] = files
|
||||
s.composer_draft = draft
|
||||
s.save()
|
||||
return j(handler, {"ok": True, "draft": s.composer_draft})
|
||||
|
||||
if parsed.path == "/api/session/update":
|
||||
try:
|
||||
require(body, "session_id")
|
||||
|
||||
@@ -872,6 +872,11 @@ $('modelSelect').onchange=async()=>{
|
||||
$('msg').addEventListener('input',()=>{
|
||||
autoResize();
|
||||
updateSendBtn();
|
||||
// Persist composer draft to server (debounced in _saveComposerDraft).
|
||||
const sid = S && S.session && S.session.session_id;
|
||||
if (sid && typeof _saveComposerDraft === 'function') {
|
||||
_saveComposerDraft(sid, $('msg').value, S.pendingFiles ? [...S.pendingFiles] : []);
|
||||
}
|
||||
const text=$('msg').value;
|
||||
if(text.startsWith('/')&&text.indexOf('\n')===-1){
|
||||
if(typeof getSlashAutocompleteMatches==='function'){
|
||||
|
||||
@@ -189,6 +189,8 @@ async function send(){
|
||||
if(!msgText){setComposerStatus('Nothing to send');return;}
|
||||
|
||||
$('msg').value='';autoResize();
|
||||
// Clear persisted composer draft since message was sent.
|
||||
if (activeSid && typeof _clearComposerDraft === 'function') _clearComposerDraft(activeSid);
|
||||
const displayText=text||(uploaded.length?`Uploaded: ${uploadedNames.join(', ')}`:'(file upload)');
|
||||
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000};
|
||||
S.toolCalls=[]; // clear tool calls from previous turn
|
||||
|
||||
+80
-4
@@ -17,6 +17,74 @@ const ICONS={
|
||||
// before the first request completes (#1060).
|
||||
let _loadingSessionId = null;
|
||||
|
||||
// ── Composer draft persistence ────────────────────────────────────────────────
|
||||
|
||||
// Debounced save — prevents hammering the server on every keystroke.
|
||||
let _draftSaveTimer = null;
|
||||
const _DRAFT_SAVE_DELAY_MS = 400;
|
||||
|
||||
function _saveComposerDraft(sid, text, files) {
|
||||
if (!sid) return;
|
||||
clearTimeout(_draftSaveTimer);
|
||||
_draftSaveTimer = setTimeout(() => {
|
||||
api('/api/session/draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sid, text: text || '', files: files || [] }),
|
||||
}).catch(() => {});
|
||||
}, _DRAFT_SAVE_DELAY_MS);
|
||||
}
|
||||
|
||||
// Fire-and-forget immediate save (used before session switches).
|
||||
function _saveComposerDraftNow(sid, text, files) {
|
||||
if (!sid) return;
|
||||
clearTimeout(_draftSaveTimer);
|
||||
api('/api/session/draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sid, text: text || '', files: files || [] }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Restore composer draft from server onto #msg textarea.
|
||||
// Only restores if there's actual text (skip empty/None drafts).
|
||||
// Guards against double-restore when rapidly switching sessions.
|
||||
function _restoreComposerDraft(draft, targetSid) {
|
||||
const ta = $('msg');
|
||||
if (!ta) return;
|
||||
// targetSid is the session that was requested — if it no longer matches
|
||||
// _loadingSessionId, a newer session switch has already begun, so skip.
|
||||
if (targetSid && _loadingSessionId !== null && _loadingSessionId !== targetSid) return;
|
||||
const text = (draft && typeof draft.text === 'string') ? draft.text : '';
|
||||
const files = (draft && Array.isArray(draft.files)) ? draft.files : [];
|
||||
// If there's no text and no files, clear the textarea (a previous session's
|
||||
// draft may still be sitting there from a cross-session switch).
|
||||
if (!text && !files.length) {
|
||||
if (ta.value) {
|
||||
ta.value = '';
|
||||
if (typeof autoResize === 'function') autoResize();
|
||||
if (typeof updateSendBtn === 'function') updateSendBtn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Only update if different to avoid cursor jumps on unrelated session switches.
|
||||
const current = ta.value || '';
|
||||
if (current !== text) {
|
||||
ta.value = text;
|
||||
if (typeof autoResize === 'function') autoResize();
|
||||
if (typeof updateSendBtn === 'function') updateSendBtn();
|
||||
}
|
||||
// Files restoration is skipped for now (requires S.pendingFiles plumbing).
|
||||
}
|
||||
|
||||
// Clear the saved draft for a session (called when message is sent).
|
||||
function _clearComposerDraft(sid) {
|
||||
if (!sid) return;
|
||||
clearTimeout(_draftSaveTimer);
|
||||
api('/api/session/draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sid, text: '' }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
|
||||
const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';
|
||||
const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming';
|
||||
@@ -345,11 +413,10 @@ async function loadSession(sid){
|
||||
// Show loading indicator immediately for responsiveness.
|
||||
// Cleared by renderMessages() once full session data arrives.
|
||||
// Persist the current composer draft before switching away so it can be
|
||||
// restored when the user switches back (#1060).
|
||||
// restored when the user switches back (#1060). Save to server now so the
|
||||
// draft survives page refresh and syncs across clients.
|
||||
if (currentSid && currentSid !== sid) {
|
||||
if (!S.composerDrafts) S.composerDrafts = {};
|
||||
const draft = { text: ($('msg') || {}).value || '', files: S.pendingFiles ? [...S.pendingFiles] : [] };
|
||||
if (draft.text || draft.files.length) S.composerDrafts[currentSid] = draft;
|
||||
_saveComposerDraftNow(currentSid, ($('msg') || {}).value || '', S.pendingFiles ? [...S.pendingFiles] : []);
|
||||
}
|
||||
if (currentSid !== sid) {
|
||||
S.messages = [];
|
||||
@@ -563,6 +630,15 @@ async function loadSession(sid){
|
||||
});
|
||||
}
|
||||
if(typeof _renderPendingPromptsForActiveSession==='function') _renderPendingPromptsForActiveSession();
|
||||
|
||||
// Restore server-persisted composer draft (synced across clients + survives refresh).
|
||||
// Pass sid so _restoreComposerDraft can skip if this session is mid-load (guards
|
||||
// against stale writes from slow responses racing to restore the previous draft).
|
||||
const _draft = S.session && S.session.composer_draft;
|
||||
if (_draft && (typeof _restoreComposerDraft === 'function')) {
|
||||
_restoreComposerDraft(_draft, sid);
|
||||
}
|
||||
|
||||
_resolveSessionModelForDisplaySoon(sid);
|
||||
// Clear the in-flight session marker now that this load has completed (#1060).
|
||||
if (_loadingSessionId === sid) _loadingSessionId = null;
|
||||
|
||||
@@ -2727,6 +2727,13 @@ let _composerLockState=null;
|
||||
function lockComposerForClarify(placeholderText){
|
||||
const input=$('msg');
|
||||
if(!input) return;
|
||||
// Save the current composer text as a server-side draft before locking,
|
||||
// so the user's draft is preserved if they switch sessions while a clarify
|
||||
// card is active (and survives page refresh / syncs across clients).
|
||||
const sid = S && S.session && S.session.session_id;
|
||||
if (sid && typeof _saveComposerDraftNow === 'function') {
|
||||
_saveComposerDraftNow(sid, input.value || '', S.pendingFiles ? [...S.pendingFiles] : []);
|
||||
}
|
||||
if(!_composerLockState){
|
||||
_composerLockState={
|
||||
disabled: input.disabled,
|
||||
|
||||
Reference in New Issue
Block a user