aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--llamachat/backend.py7
-rw-r--r--llamachat/ui.py29
-rwxr-xr-xtest_llamachat.py51
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()