Skip to content

Commit 3ab3ff3

Browse files
committed
fix: strip base64 media blocks from messages during compression
Large base64-encoded images/videos in message history can cause API to return empty choices. Filter out image_url/video_url content blocks before sending to summarization model. Add test to verify filtering and that original messages are not mutated.
1 parent 190edc4 commit 3ab3ff3

2 files changed

Lines changed: 63 additions & 0 deletions

File tree

chcode/chat.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,15 @@ async def _cmd_compress(self, _arg: str) -> None:
743743
for msg in messages:
744744
if msg.id not in recent_message_ids:
745745
msg.additional_kwargs["composed"] = True
746+
# 压缩时去掉 base64 图片/视频,避免 payload 过大导致 API 返回空 choices
747+
if isinstance(msg.content, list):
748+
clean_blocks = [
749+
b for b in msg.content
750+
if not isinstance(b, dict)
751+
or b.get("type") not in ("image_url", "video_url")
752+
]
753+
if clean_blocks != msg.content:
754+
msg = msg.model_copy(update={"content": clean_blocks})
746755
pre_messages.append(msg)
747756

748757
from chcode.utils.enhanced_chat_openai import EnhancedChatOpenAI
@@ -771,6 +780,7 @@ async def _cmd_compress(self, _arg: str) -> None:
771780
content = json_match.group()
772781
else:
773782
# 可能 summary 值中包含嵌套对象,用逐字符括号匹配兜底
783+
# NOTE: 不处理字符串内的 `}`,但模型 summary 含 `}` 的概率极低,暂不改
774784
depth = 0
775785
start = -1
776786
for i, ch in enumerate(content):

tests/test_chat_repl.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,59 @@ async def test_cmd_compress_error(self):
962962

963963
mock_err.assert_called_once()
964964

965+
@pytest.mark.asyncio
966+
async def test_compress_strips_multimodal_content(self):
967+
"""多模态消息中的 base64 图片/视频块应在压缩时被过滤,只保留文本块。"""
968+
repl = ChatREPL()
969+
repl.model_config = {"model": "gpt-4"}
970+
repl.agent = Mock()
971+
repl.agent.aget_state = AsyncMock()
972+
# 构造包含多模态 content 的消息
973+
multimodal_msg = HumanMessage(
974+
content=[
975+
{"type": "text", "text": "[image: test.png] 描述这张图"},
976+
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
977+
],
978+
id="1",
979+
)
980+
text_msg = HumanMessage("普通文本消息", id="2")
981+
state = Mock()
982+
state.values = {"messages": [multimodal_msg, text_msg]}
983+
repl.agent.aget_state.return_value = state
984+
repl.agent.aupdate_state = AsyncMock()
985+
repl.session_mgr = Mock()
986+
repl.session_mgr.config = {}
987+
988+
captured_pre_messages = None
989+
990+
def capture_invoke(func, messages, *args):
991+
nonlocal captured_pre_messages
992+
captured_pre_messages = messages
993+
mock_resp = Mock()
994+
mock_resp.content = '{"summary": "ok"}'
995+
return mock_resp
996+
997+
with patch("chcode.chat.confirm", new_callable=AsyncMock, return_value=True):
998+
with patch("chcode.chat.render_info"):
999+
with patch("chcode.utils.enhanced_chat_openai.EnhancedChatOpenAI") as mock_llm:
1000+
mock_inst = Mock()
1001+
mock_inst.invoke = Mock(side_effect=capture_invoke)
1002+
mock_llm.return_value = mock_inst
1003+
with patch("chcode.chat.asyncio.to_thread", new_callable=AsyncMock, side_effect=capture_invoke):
1004+
with patch.object(repl, "_load_conversation", new_callable=AsyncMock):
1005+
with patch("chcode.chat.render_success"):
1006+
await repl._cmd_compress("")
1007+
1008+
# 验证发给模型的消息中,多模态块已被过滤
1009+
for msg in captured_pre_messages:
1010+
if isinstance(msg.content, list):
1011+
for block in msg.content:
1012+
if isinstance(block, dict):
1013+
assert block["type"] not in ("image_url", "video_url"), \
1014+
f"多模态块未被过滤: {block['type']}"
1015+
# 验证原始消息未被修改(content 仍包含 image_url)
1016+
assert multimodal_msg.content[1]["type"] == "image_url"
1017+
9651018

9661019
# ============================================================================
9671020
# Test ChatREPL._cmd_messages

0 commit comments

Comments
 (0)