mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
154509b929
Typing `@shot.png` in the line attaches that picture to the turn — no separate command — and `@notes.txt` appends the file as text under a `--- @path ---` header, capped like a tool result with a resumable `truncate_token`. `@"my shot.png"` is the quoted form, and Tab completes the path after `@` (directories end in `/`, a name with a space completes quoted). An image is skipped, with a note naming the key to set, on a model without `image_in`; a missing or denylisted path is a note too, and the turn still goes. The default catalogs mark what can take a picture (`deepseek-flash` yes, `deepseek-v4-pro` no, the OpenAI defaults yes). `Agent.run` takes the parts, so the turn is stored with its images and shows up as `[image]` wherever only prose is wanted.
81 lines
2.6 KiB
Python
81 lines
2.6 KiB
Python
"""Image parts: format sniffing, data URLs, and the inline size policy."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
|
|
from plyngent.agent.images import (
|
|
MAX_INLINE_IMAGE_BYTES,
|
|
image_part_from_bytes,
|
|
image_part_from_url,
|
|
sniff_image_media_type,
|
|
split_data_url,
|
|
to_data_url,
|
|
user_content,
|
|
)
|
|
from plyngent.lmproto.openai_compatible.model import ChatImageUrl, ChatTextPart
|
|
|
|
PNG = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
|
)
|
|
|
|
|
|
def test_the_format_is_sniffed_from_the_bytes() -> None:
|
|
assert sniff_image_media_type(PNG) == "image/png"
|
|
assert sniff_image_media_type(b"\xff\xd8\xff\xe0JFIF") == "image/jpeg"
|
|
assert sniff_image_media_type(b"GIF89a....") == "image/gif"
|
|
assert sniff_image_media_type(b"RIFF\x00\x00\x00\x00WEBPVP8 ") == "image/webp"
|
|
assert sniff_image_media_type(b"not an image") is None
|
|
assert sniff_image_media_type(b"") is None
|
|
|
|
|
|
def test_a_data_url_round_trips() -> None:
|
|
url = to_data_url("image/png", PNG)
|
|
|
|
assert url.startswith("data:image/png;base64,")
|
|
assert split_data_url(url) == ("image/png", base64.b64encode(PNG).decode("ascii"))
|
|
assert split_data_url("https://example.com/a.png") is None
|
|
|
|
|
|
def test_bytes_become_an_image_part_with_a_detail_level() -> None:
|
|
part = image_part_from_bytes(PNG, detail="low")
|
|
|
|
assert not isinstance(part, str)
|
|
assert part.image_url.detail == "low"
|
|
assert part.image_url.url.startswith("data:image/png;base64,")
|
|
|
|
|
|
def test_non_image_bytes_are_an_error_text() -> None:
|
|
result = image_part_from_bytes(b"plain text")
|
|
|
|
assert isinstance(result, str)
|
|
assert "not a JPEG, PNG, GIF, or WebP image" in result
|
|
|
|
|
|
def test_an_oversized_image_is_refused() -> None:
|
|
result = image_part_from_bytes(PNG + b"\x00" * (MAX_INLINE_IMAGE_BYTES + 1))
|
|
|
|
assert isinstance(result, str)
|
|
assert "inline limit" in result
|
|
|
|
|
|
def test_a_url_becomes_an_image_part() -> None:
|
|
part = image_part_from_url("https://example.com/a.jpg", detail="original")
|
|
|
|
assert part.image_url == ChatImageUrl(url="https://example.com/a.jpg", detail="original")
|
|
|
|
|
|
def test_user_content_stays_a_string_without_images() -> None:
|
|
assert user_content("hello") == "hello"
|
|
assert user_content("hello", []) == "hello"
|
|
|
|
|
|
def test_user_content_becomes_a_part_array_with_images() -> None:
|
|
part = image_part_from_url("https://example.com/a.jpg")
|
|
|
|
content = user_content("look", [part])
|
|
|
|
assert content == [ChatTextPart(text="look"), part]
|
|
# An image-only turn sends no empty text part.
|
|
assert user_content("", [part]) == [part]
|