Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 74 additions & 4 deletions astrbot/core/agent/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,71 @@ 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:
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
"""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)]
Expand All @@ -327,7 +392,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:
Expand All @@ -348,10 +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):
# 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()
for part in message.content
if not getattr(part, "_no_save", False)
part.model_dump() if isinstance(part, ContentPart) else part
for part in stripped_parts
]
dumped.append(message_data)
if message._checkpoint_after is not None:
Expand Down
55 changes: 51 additions & 4 deletions astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1351,6 +1351,44 @@ def _select_image_chat_provider(
return provider


def _is_remote_image_ref(image_ref: str) -> bool:
lowered = image_ref.lower()
return lowered.startswith("http://") or lowered.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:
lowered = ref.lower()
if _is_remote_image_ref(ref):
remote_refs.append(ref)
elif lowered.startswith("data:") or lowered.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,
Expand Down Expand Up @@ -1381,7 +1419,9 @@ 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 = ""
Expand Down Expand Up @@ -1514,7 +1554,9 @@ 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):
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Expand All @@ -1530,7 +1572,12 @@ async def build_main_agent(
)
)
)
req.image_urls = normalize_and_dedupe_strings(req.image_urls)
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(
req.image_urls,
max_total=max_quoted_fallback_images,
)
req.audio_urls = normalize_and_dedupe_strings(req.audio_urls)

if config.file_extract_enabled:
Expand Down
26 changes: 22 additions & 4 deletions astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -273,21 +275,37 @@ 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_PLACEHOLDER,
}

try:
resolved_part = await self._resolve_image_part(
url, image_detail=image_detail
)
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_PLACEHOLDER,
}

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_PLACEHOLDER,
}

if part.get("type") == "audio_url":
audio_ref = self._extract_audio_part_info(part)
Expand Down
67 changes: 67 additions & 0 deletions tests/test_image_history_strip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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",
"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 out == ["C:/tmp/a.jpg", "C:/tmp/b.jpg"]
8 changes: 3 additions & 5 deletions tests/test_openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
Loading