feat(webui): support image uploads in composer and message bubbles

This commit is contained in:
Xubin Ren
2026-04-23 00:07:27 +08:00
committed by Xubin Ren
parent c1e7aa5504
commit 61a28c2c0a
39 changed files with 3670 additions and 124 deletions
@@ -217,3 +217,55 @@ def test_window_cuts_mid_tool_group():
# leaving orphan tool results for split_a at the front.
history = session.get_history(max_messages=6)
_assert_no_orphans(history)
# --- Image breadcrumbs: media kwarg is synthesized into content for replay ---
def test_get_history_synthesizes_image_breadcrumb_from_media_kwarg():
"""Persisted user turns carry image paths as a ``media`` kwarg; LLM
replay must still see an ``[image: path]`` breadcrumb so the assistant's
follow-up reply has a referent instead of trailing an empty user row."""
session = Session(key="test:media")
session.messages.append(
{"role": "user", "content": "look", "media": ["/m/a.png", "/m/b.png"]}
)
session.messages.append({"role": "assistant", "content": "nice"})
history = session.get_history(max_messages=500)
assert history == [
{"role": "user", "content": "look\n[image: /m/a.png]\n[image: /m/b.png]"},
{"role": "assistant", "content": "nice"},
]
def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
"""Turns with no text but attached images must not replay as empty
strings — the LLM would otherwise see a bare user turn followed by an
unexplained assistant answer."""
session = Session(key="test:image-only")
session.messages.append({"role": "user", "content": "", "media": ["/m/pic.png"]})
session.messages.append({"role": "assistant", "content": "I see a cat"})
history = session.get_history(max_messages=500)
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
def test_get_history_ignores_media_kwarg_on_non_user_rows():
"""``media`` only ever appears on user entries in practice, but the
synthesizer must be defensive: assistants / tools with list content
don't get the breadcrumb pasted on top."""
session = Session(key="test:defensive")
session.messages.append(
{
"role": "assistant",
"content": [{"type": "text", "text": "structured"}],
"media": ["/m/x.png"], # nonsense but shouldn't crash
}
)
history = session.get_history(max_messages=500)
# List content is passed through verbatim — the synthesizer only
# rewrites plain-string content.
assert history[0]["content"] == [{"type": "text", "text": "structured"}]