From 5d571db06ee62e8cfca705683805c98582456e93 Mon Sep 17 00:00:00 2001 From: x1051445024 <你的GitHub注册邮箱> Date: Sat, 18 Jul 2026 15:31:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(agent):=20=E9=98=B2=E6=AD=A2=E5=A4=9A?= =?UTF-8?q?=E8=BD=AE/=E5=BC=95=E7=94=A8=E8=AF=86=E5=9B=BE=E4=B8=B2?= =?UTF-8?q?=E5=9B=BE=E4=B8=8E=E5=A4=B1=E6=95=88=E5=9B=BE=E7=89=87URL?= =?UTF-8?q?=E5=9B=9E=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 历史落库与加载时剥离 image_url,替换为 [Image] 占位 - OpenAI source 图片解析失败时丢弃原 URL,改为 [Image unavailable] - 请求图片本地优先、去重并限制数量,降低远程死链干扰 - 增加 tests/test_image_history_strip.py --- astrbot/core/agent/message.py | 87 ++++++++++++++++++- astrbot/core/astr_main_agent.py | 47 +++++++++- .../core/provider/sources/openai_source.py | 24 ++++- tests/test_image_history_strip.py | 57 ++++++++++++ 4 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 tests/test_image_history_strip.py diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py index 4292f4c04e..3b8cd0997c 100644 --- a/astrbot/core/agent/message.py +++ b/astrbot/core/agent/message.py @@ -302,6 +302,72 @@ def get_checkpoint_id(message: Message | dict) -> str | None: return None + +_IMAGE_HISTORY_PLACEHOLDER = "[Image]" + + +def _is_image_content_part(part: Any) -> bool: + if isinstance(part, ImageURLPart): + return True + if isinstance(part, dict) and part.get("type") == "image_url": + return True + return getattr(part, "type", None) == "image_url" + + +def _image_placeholder_part_dict() -> dict[str, Any]: + return {"type": "text", "text": _IMAGE_HISTORY_PLACEHOLDER} + + +def strip_images_from_content_parts(content: Any) -> Any: + """Replace image parts with a short text placeholder. + + Used when persisting or reloading conversation history so previous-turn + image pixels / remote URLs do not keep leaking into later requests. + """ + if not isinstance(content, list): + return content + + stripped: list[Any] = [] + previous_was_image_placeholder = False + for part in content: + if _is_image_content_part(part): + if previous_was_image_placeholder: + continue + if isinstance(part, ContentPart): + stripped.append(TextPart(text=_IMAGE_HISTORY_PLACEHOLDER)) + else: + stripped.append(_image_placeholder_part_dict()) + previous_was_image_placeholder = True + continue + + previous_was_image_placeholder = False + stripped.append(part) + return stripped + + +def strip_images_from_history_message_dict(message: dict[str, Any]) -> dict[str, Any]: + """Return a shallow-copied history message dict without image payloads.""" + if not isinstance(message, dict): + return message + if is_checkpoint_message(message): + return message + + content = message.get("content") + if not isinstance(content, list): + return message + + new_message = dict(message) + new_message["content"] = strip_images_from_content_parts(content) + return new_message + + +def strip_images_from_history_messages(history: list[dict] | None) -> list[dict]: + """Strip image payloads from a full persisted history list.""" + if not history: + return [] + return [strip_images_from_history_message_dict(item) for item in history] + + def strip_checkpoint_messages(history: list[dict]) -> list[dict]: """Remove internal checkpoint messages from provider-facing history.""" return [message for message in history if not is_checkpoint_message(message)] @@ -327,7 +393,8 @@ def _get_checkpoint_data(message: Message | dict) -> CheckpointData | None: def bind_checkpoint_messages(history: list[dict]) -> list[Message]: """Load persisted history and bind checkpoint segments to prior messages.""" messages: list[Message] = [] - for item in history: + for raw_item in history: + item = strip_images_from_history_message_dict(raw_item) if is_checkpoint_message(item): checkpoint = _get_checkpoint_data(item) if checkpoint is not None and messages: @@ -348,10 +415,22 @@ def dump_messages_with_checkpoints(messages: list[Message]) -> list[dict]: for message in messages: message_data = message.model_dump() if isinstance(message.content, list): + persisted_parts: list[Any] = [] + previous_was_image_placeholder = False + for part in message.content: + if getattr(part, "_no_save", False): + continue + if _is_image_content_part(part): + if previous_was_image_placeholder: + continue + persisted_parts.append(TextPart(text=_IMAGE_HISTORY_PLACEHOLDER)) + previous_was_image_placeholder = True + continue + previous_was_image_placeholder = False + persisted_parts.append(part) message_data["content"] = [ - part.model_dump() - for part in message.content - if not getattr(part, "_no_save", False) + part.model_dump() if isinstance(part, ContentPart) else part + for part in persisted_parts ] dumped.append(message_data) if message._checkpoint_after is not None: diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 1eae0f17e3..580605a3df 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -115,6 +115,7 @@ extract_quoted_message_text, ) from astrbot.core.utils.string_utils import normalize_and_dedupe_strings +from astrbot.core.agent.message import strip_images_from_history_messages from astrbot.core.workspace import ( normalize_umo_for_workspace, resolve_workspace_root_for_umo, @@ -1351,6 +1352,43 @@ def _select_image_chat_provider( return provider + +def _is_remote_image_ref(image_ref: str) -> bool: + return image_ref.startswith("http://") or image_ref.startswith("https://") + + +def _finalize_request_image_urls( + image_urls: list[str] | None, + *, + max_total: int = 4, +) -> list[str]: + """Dedupe and prioritize local image refs over remote URLs. + + Remote CDN links (especially QQ multimedia URLs) expire easily. Keeping them + beside already-downloaded local paths causes models to "see" missing/wrong + images in later turns. + """ + urls = normalize_and_dedupe_strings(image_urls or []) + if not urls: + return [] + + local_refs: list[str] = [] + embedded_refs: list[str] = [] + remote_refs: list[str] = [] + for ref in urls: + if _is_remote_image_ref(ref): + remote_refs.append(ref) + elif ref.startswith("data:") or ref.startswith("base64://"): + embedded_refs.append(ref) + else: + local_refs.append(ref) + + ordered = local_refs + embedded_refs + remote_refs + if max_total > 0: + ordered = ordered[:max_total] + return ordered + + async def build_main_agent( *, event: AstrMessageEvent, @@ -1381,7 +1419,7 @@ async def build_main_agent( "provider_request 必须是 ProviderRequest 类型。" ) if req.conversation: - req.contexts = json.loads(req.conversation.history) + req.contexts = strip_images_from_history_messages(json.loads(req.conversation.history)) else: req = ProviderRequest() req.prompt = "" @@ -1514,7 +1552,7 @@ async def build_main_agent( conversation = await _get_session_conv(event, plugin_context) req.conversation = conversation - req.contexts = json.loads(conversation.history) + req.contexts = strip_images_from_history_messages(json.loads(conversation.history)) event.set_extra("provider_request", req) if isinstance(req.contexts, str): @@ -1530,7 +1568,10 @@ async def build_main_agent( ) ) ) - req.image_urls = normalize_and_dedupe_strings(req.image_urls) + req.image_urls = _finalize_request_image_urls( + normalize_and_dedupe_strings(req.image_urls), + max_total=max(int(getattr(config, "max_quoted_fallback_images", 4) or 4), 4), + ) req.audio_urls = normalize_and_dedupe_strings(req.audio_urls) if config.file_extract_enabled: diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index b1594d608a..445f0efa06 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -273,7 +273,10 @@ async def _transform_content_part(self, part: dict) -> dict: if part.get("type") == "image_url": url, image_detail = self._extract_image_part_info(part) if not url: - return part + return { + "type": "text", + "text": "[Image unavailable]", + } try: resolved_part = await self._resolve_image_part( @@ -281,13 +284,26 @@ async def _transform_content_part(self, part: dict) -> dict: ) except Exception as exc: logger.warning( - "图片 %s 预处理失败,将保留原始内容。错误: %s", + "Image preprocess failed; dropping image content. url=%s err=%s", url, exc, ) - return part + return { + "type": "text", + "text": "[Image unavailable]", + } - return resolved_part or part + if resolved_part: + return resolved_part + + logger.warning( + "Image preprocess returned empty; dropping image content. url=%s", + url, + ) + return { + "type": "text", + "text": "[Image unavailable]", + } if part.get("type") == "audio_url": audio_ref = self._extract_audio_part_info(part) diff --git a/tests/test_image_history_strip.py b/tests/test_image_history_strip.py new file mode 100644 index 0000000000..6901ac2871 --- /dev/null +++ b/tests/test_image_history_strip.py @@ -0,0 +1,57 @@ +from astrbot.core.agent.message import ( + ImageURLPart, + Message, + TextPart, + bind_checkpoint_messages, + dump_messages_with_checkpoints, + strip_images_from_history_messages, +) +from astrbot.core.astr_main_agent import _finalize_request_image_urls + + +def test_dump_messages_strips_image_parts(): + msg = Message( + role="user", + content=[ + TextPart(text="see this"), + ImageURLPart(image_url=ImageURLPart.ImageURL(url="data:image/png;base64,AAAA")), + ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/x.jpg")), + ], + ) + dumped = dump_messages_with_checkpoints([msg]) + content = dumped[0]["content"] + assert all(part.get("type") != "image_url" for part in content) + assert sum(1 for part in content if part.get("text") == "[Image]") == 1 + + +def test_bind_checkpoint_messages_strips_polluted_history(): + hist = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "old"}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,BBB"}}, + {"type": "image_url", "image_url": {"url": "https://example.com/a.jpg"}}, + ], + } + ] + bound = bind_checkpoint_messages(hist) + types = [getattr(part, "type", None) for part in bound[0].content] + assert "image_url" not in types + stripped = strip_images_from_history_messages(hist) + assert all(part.get("type") != "image_url" for part in stripped[0]["content"]) + + +def test_finalize_request_image_urls_prefers_local_and_limits(): + out = _finalize_request_image_urls( + [ + "https://example.com/a", + r"C:\\tmp\\a.jpg", + "https://example.com/a", + r"C:\\tmp\\b.jpg", + "https://example.com/c.jpg", + ], + max_total=2, + ) + assert len(out) == 2 + assert all(not u.startswith("http") for u in out) From b2407e0db88d1d34a178def751b54faab7540192 Mon Sep 17 00:00:00 2001 From: x1051445024 <你的GitHub注册邮箱> Date: Sat, 18 Jul 2026 15:53:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(agent):=20=E5=93=8D=E5=BA=94=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E6=84=8F=E8=A7=81=E5=B9=B6=E4=BF=AE=E5=A4=8D=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dump_messages 复用 strip_images_from_content_parts,去掉重复逻辑 - finalize 不再双重 normalize,remote/data scheme 大小写不敏感 - Image unavailable 抽成常量;同步更新 openai_source 既有测试预期 - ruff format / import 整理 --- astrbot/core/agent/message.py | 21 ++++---------- astrbot/core/astr_main_agent.py | 24 ++++++++++------ .../core/provider/sources/openai_source.py | 8 ++++-- tests/test_image_history_strip.py | 28 +++++++++++++------ tests/test_openai_source.py | 8 ++---- 5 files changed, 48 insertions(+), 41 deletions(-) diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py index 3b8cd0997c..6a7e523681 100644 --- a/astrbot/core/agent/message.py +++ b/astrbot/core/agent/message.py @@ -302,7 +302,6 @@ def get_checkpoint_id(message: Message | dict) -> str | None: return None - _IMAGE_HISTORY_PLACEHOLDER = "[Image]" @@ -415,22 +414,14 @@ def dump_messages_with_checkpoints(messages: list[Message]) -> list[dict]: for message in messages: message_data = message.model_dump() if isinstance(message.content, list): - persisted_parts: list[Any] = [] - previous_was_image_placeholder = False - for part in message.content: - if getattr(part, "_no_save", False): - continue - if _is_image_content_part(part): - if previous_was_image_placeholder: - continue - persisted_parts.append(TextPart(text=_IMAGE_HISTORY_PLACEHOLDER)) - previous_was_image_placeholder = True - continue - previous_was_image_placeholder = False - persisted_parts.append(part) + # Drop ephemeral parts first, then reuse shared image-stripping rules. + filtered_parts = [ + part for part in message.content if not getattr(part, "_no_save", False) + ] + stripped_parts = strip_images_from_content_parts(filtered_parts) message_data["content"] = [ part.model_dump() if isinstance(part, ContentPart) else part - for part in persisted_parts + for part in stripped_parts ] dumped.append(message_data) if message._checkpoint_after is not None: diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 580605a3df..826a35c682 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -14,7 +14,7 @@ from astrbot.core import logger from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool -from astrbot.core.agent.message import TextPart +from astrbot.core.agent.message import TextPart, strip_images_from_history_messages from astrbot.core.agent.tool import ToolSet from astrbot.core.astr_agent_context import AgentContextWrapper, AstrAgentContext from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS @@ -115,7 +115,6 @@ extract_quoted_message_text, ) from astrbot.core.utils.string_utils import normalize_and_dedupe_strings -from astrbot.core.agent.message import strip_images_from_history_messages from astrbot.core.workspace import ( normalize_umo_for_workspace, resolve_workspace_root_for_umo, @@ -1352,9 +1351,9 @@ def _select_image_chat_provider( return provider - def _is_remote_image_ref(image_ref: str) -> bool: - return image_ref.startswith("http://") or image_ref.startswith("https://") + lowered = image_ref.lower() + return lowered.startswith("http://") or lowered.startswith("https://") def _finalize_request_image_urls( @@ -1376,9 +1375,10 @@ def _finalize_request_image_urls( embedded_refs: list[str] = [] remote_refs: list[str] = [] for ref in urls: + lowered = ref.lower() if _is_remote_image_ref(ref): remote_refs.append(ref) - elif ref.startswith("data:") or ref.startswith("base64://"): + elif lowered.startswith("data:") or lowered.startswith("base64://"): embedded_refs.append(ref) else: local_refs.append(ref) @@ -1419,7 +1419,9 @@ async def build_main_agent( "provider_request 必须是 ProviderRequest 类型。" ) if req.conversation: - req.contexts = strip_images_from_history_messages(json.loads(req.conversation.history)) + req.contexts = strip_images_from_history_messages( + json.loads(req.conversation.history) + ) else: req = ProviderRequest() req.prompt = "" @@ -1552,7 +1554,9 @@ async def build_main_agent( conversation = await _get_session_conv(event, plugin_context) req.conversation = conversation - req.contexts = strip_images_from_history_messages(json.loads(conversation.history)) + req.contexts = strip_images_from_history_messages( + json.loads(conversation.history) + ) event.set_extra("provider_request", req) if isinstance(req.contexts, str): @@ -1568,9 +1572,11 @@ async def build_main_agent( ) ) ) + raw_max_quoted_fallback_images = getattr(config, "max_quoted_fallback_images", 4) + max_quoted_fallback_images = max(int(raw_max_quoted_fallback_images or 4), 4) req.image_urls = _finalize_request_image_urls( - normalize_and_dedupe_strings(req.image_urls), - max_total=max(int(getattr(config, "max_quoted_fallback_images", 4) or 4), 4), + req.image_urls, + max_total=max_quoted_fallback_images, ) req.audio_urls = normalize_and_dedupe_strings(req.audio_urls) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 445f0efa06..88456bc39d 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -43,6 +43,8 @@ from ..register import register_provider_adapter from .request_retry import retry_provider_request +IMAGE_UNAVAILABLE_PLACEHOLDER = "[Image unavailable]" + @register_provider_adapter( "openai_chat_completion", @@ -275,7 +277,7 @@ async def _transform_content_part(self, part: dict) -> dict: if not url: return { "type": "text", - "text": "[Image unavailable]", + "text": IMAGE_UNAVAILABLE_PLACEHOLDER, } try: @@ -290,7 +292,7 @@ async def _transform_content_part(self, part: dict) -> dict: ) return { "type": "text", - "text": "[Image unavailable]", + "text": IMAGE_UNAVAILABLE_PLACEHOLDER, } if resolved_part: @@ -302,7 +304,7 @@ async def _transform_content_part(self, part: dict) -> dict: ) return { "type": "text", - "text": "[Image unavailable]", + "text": IMAGE_UNAVAILABLE_PLACEHOLDER, } if part.get("type") == "audio_url": diff --git a/tests/test_image_history_strip.py b/tests/test_image_history_strip.py index 6901ac2871..3b2f63d5e6 100644 --- a/tests/test_image_history_strip.py +++ b/tests/test_image_history_strip.py @@ -14,8 +14,12 @@ def test_dump_messages_strips_image_parts(): role="user", content=[ TextPart(text="see this"), - ImageURLPart(image_url=ImageURLPart.ImageURL(url="data:image/png;base64,AAAA")), - ImageURLPart(image_url=ImageURLPart.ImageURL(url="https://example.com/x.jpg")), + ImageURLPart( + image_url=ImageURLPart.ImageURL(url="data:image/png;base64,AAAA") + ), + ImageURLPart( + image_url=ImageURLPart.ImageURL(url="https://example.com/x.jpg") + ), ], ) dumped = dump_messages_with_checkpoints([msg]) @@ -30,8 +34,14 @@ def test_bind_checkpoint_messages_strips_polluted_history(): "role": "user", "content": [ {"type": "text", "text": "old"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,BBB"}}, - {"type": "image_url", "image_url": {"url": "https://example.com/a.jpg"}}, + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,BBB"}, + }, + { + "type": "image_url", + "image_url": {"url": "https://example.com/a.jpg"}, + }, ], } ] @@ -46,12 +56,12 @@ def test_finalize_request_image_urls_prefers_local_and_limits(): out = _finalize_request_image_urls( [ "https://example.com/a", - r"C:\\tmp\\a.jpg", - "https://example.com/a", - r"C:\\tmp\\b.jpg", + "C:/tmp/a.jpg", + "HTTPS://example.com/a", + "C:/tmp/b.jpg", "https://example.com/c.jpg", + "data:image/png;base64,AAAA", ], max_total=2, ) - assert len(out) == 2 - assert all(not u.startswith("http") for u in out) + assert out == ["C:/tmp/a.jpg", "C:/tmp/b.jpg"] diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index a45a232938..8cfbcff1fa 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1217,7 +1217,7 @@ def fake_warning(message, *args, **kwargs): @pytest.mark.asyncio -async def test_prepare_chat_payload_keeps_original_context_image_when_materialization_fails( +async def test_prepare_chat_payload_drops_context_image_when_materialization_fails( monkeypatch, ): provider = _make_provider() @@ -1261,10 +1261,8 @@ async def fake_resolve_media_ref_to_base64_data( assert payloads["messages"][0]["content"] == [ {"type": "text", "text": "look"}, { - "type": "image_url", - "image_url": { - "url": "https://example.com/expired.png", - }, + "type": "text", + "text": "[Image unavailable]", }, ] finally: