diff options
| -rw-r--r-- | llamachat/backend.py | 7 | ||||
| -rw-r--r-- | llamachat/ui.py | 29 | ||||
| -rwxr-xr-x | test_llamachat.py | 51 |
3 files changed, 84 insertions, 3 deletions
diff --git a/llamachat/backend.py b/llamachat/backend.py index 9e0556f..ec3b7d6 100644 --- a/llamachat/backend.py +++ b/llamachat/backend.py @@ -267,9 +267,12 @@ def _parse_sse_line(line: str) -> tuple[str, str] | None: return None delta = choices[0].get("delta") or {} + # Both fields can arrive in one delta, and a reasoning delta can be an + # empty string. Test for presence, not truthiness, so a chunk carrying + # reasoning is never mistaken for a content chunk. reasoning = delta.get("reasoning_content") - if reasoning: - return ("reasoning", reasoning) + if reasoning is not None: + return ("reasoning", reasoning) if reasoning else None content = delta.get("content") if content: return ("content", content) diff --git a/llamachat/ui.py b/llamachat/ui.py index fd2380c..f599bdd 100644 --- a/llamachat/ui.py +++ b/llamachat/ui.py @@ -1291,6 +1291,33 @@ _PRE_STYLE = ( ) +_FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})", re.MULTILINE) + + +def _close_fences(text: str) -> str: + """Close an unterminated code fence and neuter a lone inline backtick. + + A reply is streamed, so a fence is unclosed on nearly every frame; a + model can also emit an odd backtick by accident. Either way the rest of + the reply would reflow as code, which is how a whole message turns into + one grey block. Closing the fence here bounds the damage to the text + that was really meant as code. + """ + fences = _FENCE_RE.findall(text) + if len(fences) % 2: + # Match the opener's own length; a longer run is legal and only a + # fence at least as long as the opener actually closes it. + closer = fences[-1][0] * max(3, len(fences[-1])) + nl = "" if text.endswith("\n") else "\n" + return f"{text}{nl}{closer}" + if not fences and text.count("`") % 2: + # No fences at all, one dangling inline tick: drop it rather than + # let it open a run to the end of the message. + head, _, tail = text.rpartition("`") + return head + tail + return text + + def _markdown_to_fragment(text: str) -> str: """Render markdown to an HTML fragment safe to splice into the page. @@ -1300,7 +1327,7 @@ def _markdown_to_fragment(text: str) -> str: if not text: return "" doc = QTextDocument() - doc.setMarkdown(text, MARKDOWN_FLAGS) + doc.setMarkdown(_close_fences(text), MARKDOWN_FLAGS) html_text = doc.toHtml() start = html_text.find("<body") diff --git a/test_llamachat.py b/test_llamachat.py index 0986631..f23a576 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -217,6 +217,21 @@ def test_sse_parsing(): ) assert opening is None assert opening != backend.DONE + + # A delta carrying both fields is thinking, not reply. Classifying it as + # content splices the tail of the reasoning onto the front of the reply. + both = backend._parse_sse_line( + 'data: {"choices":[{"delta":' + '{"reasoning_content":"still thinking","content":""}}]}' + ) + assert both == ("reasoning", "still thinking"), both + + # An empty reasoning delta renders nothing and must not fall through to + # the content branch. + empty = backend._parse_sse_line( + 'data: {"choices":[{"delta":{"reasoning_content":""}}]}' + ) + assert empty is None, empty print("ok sse parsing") @@ -365,6 +380,41 @@ def test_markdown_rendering(): print("ok markdown rendering") +def test_unbalanced_backticks(): + """An odd backtick cannot reflow the rest of a reply as code.""" + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + + from llamachat.ui import _close_fences, _markdown_to_fragment + + app = QApplication.instance() or QApplication([]) + assert app is not None + + # An unclosed fence gets one, so the tail is not swallowed. + assert _close_fences("a\n```py\nx=1").endswith("\n```") + # A closed fence is left exactly as it was. + balanced = "a\n```py\nx=1\n```\ntail" + assert _close_fences(balanced) == balanced + # A longer opener needs a closer at least as long. + assert _close_fences("````\nx").endswith("\n````") + # Tildes are fences too. + assert _close_fences("~~~\nx").endswith("\n~~~") + # A lone inline tick is dropped rather than opening a run. + assert "`" not in _close_fences("use ` here") + # Balanced inline ticks survive untouched. + assert _close_fences("a `b` c") == "a `b` c" + + # The real failure: reasoning text full of ticks must not become one + # block that eats the prose after it. + leaked = "` (wait)\nthen `find / -perm -4000` and more prose here" + rendered = _markdown_to_fragment(leaked) + assert "more prose here" in rendered + assert "<pre" not in rendered, rendered + print("ok unbalanced backticks") + + def test_system_qt_theme_guard(): """The plugin path is only borrowed when the Qt versions agree.""" import os @@ -864,6 +914,7 @@ if __name__ == "__main__": test_session_prompt_storage() test_prompt_column_migration() test_markdown_rendering() + test_unbalanced_backticks() test_sidebar_toggle() test_shortcuts() test_system_qt_theme_guard() |
