fix: XML tool-call leak + workspace empty-state + notification text — v0.50.92 (PR #712)

Strips <function_calls> XML from assistant messages before rendering, adds workspace file panel empty-state messages, and changes notification description from 'tab' to 'app'. 16 new tests. Fixes #702, #703, #704.
This commit is contained in:
nesquena-hermes
2026-04-18 22:40:37 -07:00
committed by GitHub
parent 0386dc261a
commit 877a32f49c
6 changed files with 294 additions and 4 deletions

View File

@@ -147,6 +147,8 @@ const LOCALES = {
failed_colon: 'Failed: ',
// ui.js
no_workspace: 'No workspace',
workspace_empty_no_path: 'No workspace selected. Set a workspace in Settings \u2192 Workspace to browse files.',
workspace_empty_dir: 'This workspace is empty.',
dialog_confirm_title: 'Confirm action',
dialog_prompt_title: 'Enter a value',
dialog_confirm_btn: 'Confirm',
@@ -255,7 +257,7 @@ const LOCALES = {
settings_label_sound: 'Notification sound',
settings_desc_sound: 'Play a sound when the assistant finishes a response.',
settings_label_notifications: 'Browser notifications',
settings_desc_notifications: 'Show a system notification when a response completes while the tab is in the background.',
settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.',
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
settings_desc_bubble_layout: 'Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.',
settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.',
@@ -572,6 +574,8 @@ const LOCALES = {
failed_colon: 'Error: ',
// ui.js
no_workspace: 'Sin espacio de trabajo',
workspace_empty_no_path: 'No hay espacio de trabajo seleccionado. Configure un espacio de trabajo en Ajustes \u2192 Workspace para explorar archivos.',
workspace_empty_dir: 'Este espacio de trabajo está vacío.',
// workspace.js
unsaved_confirm: 'Tienes cambios sin guardar en la vista previa. ¿Descartar y navegar?',
save: 'Guardar',
@@ -985,6 +989,8 @@ const LOCALES = {
failed_colon: 'Fehlgeschlagen: ',
// ui.js
no_workspace: 'Kein Workspace',
workspace_empty_no_path: 'Kein Workspace ausgewählt. Wähle einen Workspace unter Einstellungen \u2192 Workspace, um Dateien zu durchsuchen.',
workspace_empty_dir: 'Dieser Workspace ist leer.',
dialog_confirm_title: 'Aktion bestätigen',
dialog_prompt_title: 'Wert eingeben',
dialog_confirm_btn: 'Bestätigen',
@@ -1199,6 +1205,9 @@ const LOCALES = {
theme_usage: '\u7528\u6cd5\uff1a/theme ',
theme_set: '\u4e3b\u9898\uff1a',
no_active_session: '\u5f53\u524d\u6ca1\u6709\u6d3b\u52a8\u4f1a\u8bdd',
workspace_empty_no_path: '未选择工作区。请在 设置 → 工作区 中设置工作区以浏览文件。',
workspace_empty_dir: '此工作区为空。',
no_personalities: '\u6ca1\u6709\u627e\u5230\u4eba\u8bbe\uff08\u53ef\u6dfb\u52a0\u5230 ~/.hermes/personalities/\uff09',
available_personalities: '\u53ef\u7528\u4eba\u8bbe\uff1a',
personality_switch_hint: '\n\n\u4f7f\u7528 `/personality <name>` \u5207\u6362\uff0c\u6216\u7528 `/personality none` \u6e05\u7a7a\u3002',
@@ -1611,6 +1620,9 @@ const LOCALES = {
theme_usage: '\u7528\u6cd5\uff1a/theme ',
theme_set: '\u4e3b\u984c\uff1a',
no_active_session: '\u7576\u524d\u6c92\u6709\u6d3b\u52d5\u6703\u8a71',
workspace_empty_no_path: '未選擇工作區。請在 設定 → 工作區 中設定工作區以瀏覽檔案。',
workspace_empty_dir: '此工作區為空。',
no_personalities: '\u6c92\u6709\u627e\u5230\u4eba\u8a2d\uff08\u53ef\u6dfb\u52a0\u5230 ~/.hermes/personalities/\uff09',
available_personalities: '\u53ef\u7528\u4eba\u8a2d\uff1a',
personality_switch_hint: '\n\n\u4f7f\u7528 `/personality <name>` \u5207\u63db\uff0c\u6216\u7528 `/personality none` \u6e05\u7a7a\u3002',

View File

@@ -390,6 +390,7 @@
</div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
<div class="file-tree" id="fileTree"></div>
<div id="wsEmptyState" style="display:none;flex:1;align-items:center;justify-content:center;padding:24px 16px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6"></div>
<div class="preview-area" id="previewArea">
<div class="preview-path" id="previewPath">
<span id="previewPathText"></span>

View File

@@ -229,8 +229,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let _renderPending=false;
// Extract display text from assistantText, stripping completed thinking blocks
// and hiding content still inside an open thinking block.
function _stripXmlToolCalls(s){
// Strip <function_calls>...</function_calls> blocks (DeepSeek XML tool syntax).
// These are processed as tool calls server-side; showing them raw in the bubble
// looks broken. Also handles orphaned opening tags mid-stream. (#702)
if(!s||s.toLowerCase().indexOf('<function_calls>')===-1) return s;
s=s.replace(/<function_calls>[\s\S]*?<\/function_calls>/gi,'');
s=s.replace(/<function_calls>[\s\S]*$/i,'');
return s.trim();
}
function _streamDisplay(){
const raw=assistantText;
const raw=_stripXmlToolCalls(assistantText);
if(reasoningText) return raw;
for(const {open,close} of _thinkPairs){
// Trim leading whitespace before checking for the open tag — some models
@@ -252,7 +261,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
return raw;
}
function _parseStreamState(){
const raw=assistantText;
const raw=_stripXmlToolCalls(assistantText);
if(reasoningText){
return {thinkingText:liveReasoningText, displayText:_streamDisplay(), inThinking:false};
}

View File

@@ -463,6 +463,17 @@ function getModelLabel(modelId){
return modelId.split('/').pop()||'Unknown';
}
function _stripXmlToolCallsDisplay(s){
// Strip <function_calls>...</function_calls> blocks emitted by DeepSeek and
// similar models in their raw response text. These are processed separately
// as tool calls; leaving them in the content causes them to render visibly
// in the settled chat bubble. (#702)
if(!s||s.toLowerCase().indexOf('<function_calls>')===-1) return s;
s=s.replace(/<function_calls>[\s\S]*?<\/function_calls>/gi,'');
s=s.replace(/<function_calls>[\s\S]*$/i,'');
return s.trim();
}
function renderMd(raw){
let s=raw||'';
// ── MEDIA: token stash (must run first, before any other processing) ───────
@@ -1459,7 +1470,7 @@ function renderMessages(){
if(m.attachments&&m.attachments.length){
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">${li('paperclip',12)} ${esc(f)}</div>`).join('')}</div>`;
}
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(String(content));
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(_stripXmlToolCallsDisplay(String(content)));
const editBtn = isUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
const copyBtn = `<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>`;
@@ -2135,6 +2146,20 @@ function renderFileTree(){
const box=$('fileTree');box.innerHTML='';
// Cache current dir entries
S._dirCache[S.currentDir||'.']=S.entries;
// Show empty-state when no workspace is set or the directory is empty (#703)
const emptyEl=$('wsEmptyState');
const hasWorkspace=!!(S.session&&S.session.workspace);
if(!hasWorkspace){
if(emptyEl){emptyEl.textContent=t('workspace_empty_no_path');emptyEl.style.display='flex';}
box.style.display='none';
return;
}
if(emptyEl) emptyEl.style.display='none';
box.style.display='';
if(!S.entries||!S.entries.length){
if(emptyEl){emptyEl.textContent=t('workspace_empty_dir');emptyEl.style.display='flex';}
return;
}
_renderTreeItems(box, S.entries, 0);
}