mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-14 19:50:17 +00:00
#3330 Fix pinned chat scroll after message rebuild Co-authored-by: jianongHe <jianongHe@users.noreply.github.com> #3311 fix: reject inline math when $ is followed by a digit (currency) Co-authored-by: toanalien <toanalien@users.noreply.github.com>
This commit is contained in:
+17
-4
@@ -2758,13 +2758,24 @@ function _setMessageScrollToBottom(){
|
||||
_lastScrollTop=el.scrollTop;
|
||||
_nearBottomCount=2;
|
||||
_scrollPinned=true;
|
||||
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
||||
requestAnimationFrame(()=>{
|
||||
el.scrollTop=el.scrollHeight;
|
||||
_lastScrollTop=el.scrollTop;
|
||||
_nearBottomCount=2;
|
||||
_scrollPinned=true;
|
||||
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
||||
});
|
||||
}
|
||||
function _isMessagePaneNearBottom(threshold=250){
|
||||
const el=$('messages');
|
||||
if(!el) return false;
|
||||
return el.scrollHeight-el.scrollTop-el.clientHeight<=threshold;
|
||||
}
|
||||
function _messageBottomDistance(){
|
||||
const el=$('messages');
|
||||
if(!el) return 0;
|
||||
return el.scrollHeight-el.scrollTop-el.clientHeight;
|
||||
}
|
||||
function _shouldFollowMessagesOnDomReplace(){
|
||||
return !_messageUserUnpinned && (_scrollPinned || _isMessagePaneNearBottom(1200));
|
||||
}
|
||||
@@ -2793,6 +2804,7 @@ function _settleMessageScrollToBottom(force){
|
||||
function scrollIfPinned(){
|
||||
if(!_scrollPinned) return;
|
||||
if(_recentNonMessageScrollIntent()) return;
|
||||
if(_messageBottomDistance()>500) _setMessageScrollToBottom();
|
||||
_settleMessageScrollToBottom(false);
|
||||
}
|
||||
function scrollToBottom(){
|
||||
@@ -3098,9 +3110,10 @@ function renderMd(raw){
|
||||
s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
// Match a single literal backslash before the display delimiter (the common LLM form).
|
||||
s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
// Inline math: $...$ — require non-space at boundaries to avoid false positives
|
||||
// e.g. "costs $5 and $10" should not trigger (space after opening $)
|
||||
s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>{if(m.includes(' | '))return '\$'+m+'\$';math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
// Inline math: $...$ — require non-space/non-digit at opening boundary to avoid
|
||||
// false positives on currency like "$1,000 xuống ~$95" or "costs $5 and $10".
|
||||
// Aligns with smd's se() guard which also rejects $ followed by digits.
|
||||
s=s.replace(/\$([^\s$\d\n][^$\n]*?[^\s$\n]|[^\s\d])\$/g,(_,m)=>{if(m.includes(' | '))return '\$'+m+'\$';math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
// Also stash \(...\) LaTeX delimiters.
|
||||
// Match a single literal backslash before the delimiter (the common LLM form).
|
||||
s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Regression coverage for #3319: pinned chat should recover after DOM rebuilds."""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_fn(src: str, name: str) -> str:
|
||||
marker = f"function {name}"
|
||||
start = src.find(marker)
|
||||
assert start >= 0, f"{name} not found"
|
||||
brace = src.find("{", start)
|
||||
assert brace >= 0, f"{name} body not found"
|
||||
depth = 0
|
||||
for i in range(brace, len(src)):
|
||||
ch = src[i]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start : i + 1]
|
||||
raise AssertionError(f"{name} body did not close")
|
||||
|
||||
|
||||
def test_scroll_to_bottom_retries_after_next_layout_frame():
|
||||
fn = _extract_fn(UI_JS, "_setMessageScrollToBottom")
|
||||
assert "el.scrollTop=el.scrollHeight;" in fn
|
||||
assert "requestAnimationFrame(()=>{" in fn
|
||||
assert fn.count("el.scrollTop=el.scrollHeight;") >= 2
|
||||
assert fn.count("_lastScrollTop=el.scrollTop;") >= 2
|
||||
|
||||
|
||||
def test_scroll_if_pinned_recovers_when_far_from_bottom():
|
||||
fn = _extract_fn(UI_JS, "scrollIfPinned")
|
||||
assert "_messageBottomDistance()>500" in fn
|
||||
assert "_setMessageScrollToBottom();" in fn
|
||||
assert fn.index("_messageBottomDistance()>500") < fn.index("_settleMessageScrollToBottom(false)")
|
||||
|
||||
@@ -429,6 +429,24 @@ def test_inline_math_regex_requires_non_space_boundaries():
|
||||
f"Inline math regex must exclude spaces at boundaries to prevent false "
|
||||
f"positives on currency like $5. Found: {inline_line[:120]}"
|
||||
)
|
||||
def test_inline_math_regex_rejects_digit_after_opening_dollar():
|
||||
"""The $...$ inline regex must reject $ followed by a digit.
|
||||
|
||||
Currency like '$1,000 xuống ~$95' must NOT be parsed as math.
|
||||
The opening $ followed by a digit (0-9) is a strong currency signal.
|
||||
Aligns with smd's se() guard which also rejects $ + digit.
|
||||
"""
|
||||
inline_push_idx = UI_JS.find("type:'inline',src:m")
|
||||
assert inline_push_idx != -1
|
||||
line_start = UI_JS.rfind('\n', 0, inline_push_idx) + 1
|
||||
inline_line = UI_JS[line_start:inline_push_idx + 50]
|
||||
# The regex character class for the first char must exclude \d
|
||||
assert '\\d' in inline_line, (
|
||||
f"Inline math regex must exclude digits at opening boundary to prevent "
|
||||
f"false positives on currency like $1,000. Found: {inline_line[:120]}"
|
||||
)
|
||||
|
||||
|
||||
def test_display_math_stashed_before_inline():
|
||||
"""$$...$$ display math must be stashed before $...$ inline math.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user