From 256b3fbbdf74550d1afe43402afe7d867184ffcc Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 22 Apr 2026 13:20:01 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20image=5Fgenerate=20renders=20inline=20+?= =?UTF-8?q?=20auto-title=20strips=20thinking=20preamble=20=E2=80=94=20v0.5?= =?UTF-8?q?0.152=20(closes=20#853,=20#857)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEDIA: restore renders all https:// URLs as img (closes #853). _strip_thinking_markup strips Qwen3 plain-text reasoning preambles (closes #857). --- CHANGELOG.md | 6 +++ api/streaming.py | 10 ++++ static/ui.js | 5 +- tests/test_issues_853_857.py | 99 ++++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tests/test_issues_853_857.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f0c4d0d..aacc120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Hermes Web UI -- Changelog +## [v0.50.152] — 2026-04-22 + +### Fixed +- **Image generation renders inline** — `MEDIA:` token restore renders all `https://` URLs as ``. (closes #853) +- **Auto-title strips thinking preambles** — `_strip_thinking_markup()` strips Qwen3-style plain-text reasoning preambles. (closes #857) + ## [v0.50.151] — 2026-04-22 ### Added diff --git a/api/streaming.py b/api/streaming.py index 59622e5..13dc5e2 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -70,6 +70,15 @@ def _strip_thinking_markup(text: str) -> str: s = re.sub(r'<\|channel\|>thought.*?', ' ', s, flags=re.IGNORECASE | re.DOTALL) s = re.sub(r'<\|turn\|>thinking\n.*?', ' ', s, flags=re.IGNORECASE | re.DOTALL) # Gemma 4 s = re.sub(r'^\s*(the|ther)\s+user\s+is\s+asking.*$', ' ', s, flags=re.IGNORECASE | re.MULTILINE) + # Strip plain-text thinking preambles from models that don't use tags (e.g. Qwen3). + # These appear as the very first sentence of the assistant response and are not useful as titles. + s = re.sub( + r"^\s*(?:here(?:'s| is) (?:a |my )?(?:thinking|thought) (?:process|trace|through)\b[^\n]*\n?" + r"|let me (?:think|work|reason|analyze|walk) (?:through|about|this|step)\b[^\n]*\n?" + r"|i(?:'ll| will) (?:think|work|reason|analyze|break this down)\b[^\n]*\n?" + r"|(?:okay|alright|sure|of course),?\s+let me\b[^\n]*\n?)", + ' ', s, flags=re.IGNORECASE + ) s = re.sub(r'\s+', ' ', s).strip() return s @@ -122,6 +131,7 @@ def _looks_invalid_generated_title(text: str) -> bool: or re.search(r'\b(they|user)\s+want(s)?\s+me\s+to\b', s, flags=re.IGNORECASE) or re.search(r'^\s*(i|we)\s+(should|need to|will|can)\b', s, flags=re.IGNORECASE) or re.search(r'^\s*let me\b', s, flags=re.IGNORECASE) + or re.search(r"^\s*here(?:'s| is) (?:a |my )?(?:thinking|thought)", s, flags=re.IGNORECASE) or re.search(r'用户(要求|希望|想让|让我)', s) or re.search(r'请只?回复', s) or re.search(r'^\s*(ok|okay|done|all set|complete|completed|finished)\b[\s.!?]*$', s, flags=re.IGNORECASE) diff --git a/static/ui.js b/static/ui.js index 1e0ec51..4e8a412 100644 --- a/static/ui.js +++ b/static/ui.js @@ -717,7 +717,10 @@ function renderMd(raw){ const base=document.baseURI.replace(/\/$/,''); src=src.replace(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i,base); } - if(_IMAGE_EXTS.test(src.split('?')[0])){ + // MEDIA: tokens are only emitted for tool-generated images (image_generate etc.). + // Render all https:// URLs as — extension check would miss extensionless + // CDN paths like fal.media content-addressed URLs (closes #853). + if(_IMAGE_EXTS.test(src.split('?')[0]) || /^https?:\/\//i.test(src)){ return `image`; } return `${esc(src)}`; diff --git a/tests/test_issues_853_857.py b/tests/test_issues_853_857.py new file mode 100644 index 0000000..e0c326c --- /dev/null +++ b/tests/test_issues_853_857.py @@ -0,0 +1,99 @@ +"""Regression tests for #853 (image_generate inline rendering) and +#857 (auto-title strips thinking preambles).""" +import os +import re + + +_SRC = os.path.join(os.path.dirname(__file__), "..") + + +def _read(name): + return open(os.path.join(_SRC, name), encoding="utf-8").read() + + +# ── #853: MEDIA: URL restore renders any https:// as ──────────────────── + +class TestMediaUrlRendersInline: + """The `MEDIA:` stash restore in renderMd() must render https:// URLs + as even when they lack a recognized file extension (FAL.ai and + similar CDNs serve images via content-addressed paths).""" + + def test_render_md_checks_https_scheme_for_img_tag(self): + js = _read("static/ui.js") + # The fix OR-chains the extension check with a scheme check so any + # https:// URL in a MEDIA: token renders as + assert re.search( + r"_IMAGE_EXTS\.test\(.*?\)\s*\|\|\s*/\^https\?:\\/\\/", + js, + ), ( + "renderMd MEDIA: restore must accept any https:// URL as , " + "not only those with image file extensions (#853)" + ) + + def test_img_class_applied_to_media_image(self): + """The resulting uses the existing msg-media-img class so the + styling/fullscreen-click interaction is preserved.""" + js = _read("static/ui.js") + # The img tag is constructed with the existing class + onclick toggle + assert "msg-media-img" in js + assert "msg-media-img--full" in js + + +# ── #857: thinking-preamble stripping in auto-title ────────────────────────── + +class TestThinkingPreambleStripping: + """Qwen3 and similar models emit plain-text thinking preambles + ("Here's a thinking process:", "Let me think through this…") without + tags. These must be stripped before the text is used for + session auto-titling.""" + + def test_strip_thinking_markup_drops_heres_thinking_process(self): + from api.streaming import _strip_thinking_markup + raw = "Here's a thinking process: 1. Analyze the request.\nThe answer is 42." + out = _strip_thinking_markup(raw) + assert "thinking process" not in out.lower() + assert "42" in out, "Non-preamble content must be preserved" + + def test_strip_thinking_markup_drops_let_me_think(self): + from api.streaming import _strip_thinking_markup + raw = "Let me think through this carefully.\nHere's the answer." + out = _strip_thinking_markup(raw) + assert "let me think" not in out.lower() + + def test_strip_thinking_markup_drops_ill_think_about(self): + from api.streaming import _strip_thinking_markup + raw = "I'll think about this step by step.\nFinal result: 7." + out = _strip_thinking_markup(raw) + assert "i'll think about" not in out.lower() + assert "result" in out.lower() + + def test_strip_thinking_markup_drops_okay_let_me(self): + from api.streaming import _strip_thinking_markup + raw = "Okay, let me break this down.\nThe answer is yes." + out = _strip_thinking_markup(raw) + assert "okay, let me break" not in out.lower() + + def test_strip_thinking_markup_preserves_non_preamble_content(self): + """When the text doesn't start with a thinking preamble, leave it alone.""" + from api.streaming import _strip_thinking_markup + raw = "The user's question is about Python imports." + out = _strip_thinking_markup(raw) + assert "python imports" in out.lower() + + def test_strip_thinking_markup_case_insensitive(self): + from api.streaming import _strip_thinking_markup + assert "Here's" not in _strip_thinking_markup("HERE'S A THINKING PROCESS:\nThe answer.") + + def test_looks_invalid_generated_title_catches_heres_thinking(self): + """The belt-and-suspenders guard on titles that slip past the strip.""" + from api.streaming import _looks_invalid_generated_title + assert _looks_invalid_generated_title("Here's a thinking process about Python"), ( + "_looks_invalid_generated_title must reject 'Here's a thinking ...' titles" + ) + + def test_looks_invalid_generated_title_accepts_real_titles(self): + """Normal, non-preamble titles must not be rejected.""" + from api.streaming import _looks_invalid_generated_title + assert not _looks_invalid_generated_title("Python import debugging"), ( + "Real titles must still pass the invalid-title guard" + )