diff --git a/docs/cli.md b/docs/cli.md index 13ab6d08..265275df 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,6 +47,16 @@ Press `Ctrl-X` to toggle between `agent` and `shell` mode. Use shell mode when you want to run multiple shell commands quickly. +## Image Input + +CLI supports image reading in agent mode: + +- Paste from clipboard: `Ctrl-V` +- Attach local file by path token: `@/absolute/path/to/image.png` +- Paths with spaces: `@"./images/my chart.png"` + +Attached images are sent as multimodal input together with your text. + ## Typical Workflow 1. Check repo status: `,git status` diff --git a/pyproject.toml b/pyproject.toml index 529c2104..2563e37f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "republic>=0.5.2", "rich>=13.0.0", "prompt-toolkit>=3.0.0", + "pillow>=10.0.0", "python-telegram-bot>=21.0", "loguru>=0.7.2", "telegramify-markdown>=0.5.4", diff --git a/src/bub/channels/cli.py b/src/bub/channels/cli.py index 962f4be1..4dc6c0dd 100644 --- a/src/bub/channels/cli.py +++ b/src/bub/channels/cli.py @@ -2,30 +2,72 @@ from __future__ import annotations +import contextlib +import json +import re from collections.abc import Awaitable, Callable +from dataclasses import dataclass from datetime import datetime from hashlib import md5 +from io import BytesIO from pathlib import Path +from typing import Any +from uuid import uuid4 from loguru import logger from prompt_toolkit import PromptSession from prompt_toolkit.completion import WordCompleter from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.history import FileHistory -from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent from prompt_toolkit.patch_stdout import patch_stdout from rich import get_console from bub.app.runtime import AppRuntime -from bub.channels.base import BaseChannel +from bub.channels.base import BaseChannel, exclude_none +from bub.channels.media import MAX_INLINE_IMAGE_BYTES, guess_image_mime, to_data_url from bub.cli.render import CliRenderer from bub.core.agent_loop import LoopResult +CLIPBOARD_PLACEHOLDER_RE = re.compile( + r"\[(?Pimage):(?P[a-zA-Z0-9_\-\.]+)(?:,(?P\d+)x(?P\d+))?\]" +) +LOCAL_IMAGE_TOKEN_RE = re.compile(r"(?\"[^\"]+\"|'[^']+'|[^\s]+)") -class CliChannel(BaseChannel[str]): + +@dataclass(frozen=True) +class InlineImage: + id: str + data_url: str + mime_type: str + file_size: int + width: int | None = None + height: int | None = None + path: str | None = None + + def to_metadata(self) -> dict[str, Any]: + return exclude_none({ + "id": self.id, + "mime_type": self.mime_type, + "file_size": self.file_size, + "width": self.width, + "height": self.height, + "path": self.path, + "data_url": self.data_url, + }) + + +@dataclass(frozen=True) +class CliInput: + text: str + images: tuple[InlineImage, ...] = () + + +class CliChannel(BaseChannel[CliInput]): """Interactive terminal channel.""" name = "cli" + INLINE_IMAGE_LIMIT_BYTES = MAX_INLINE_IMAGE_BYTES def __init__(self, runtime: AppRuntime, *, session_id: str = "cli") -> None: super().__init__(runtime) @@ -36,12 +78,14 @@ def __init__(self, runtime: AppRuntime, *, session_id: str = "cli") -> None: self._last_tape_info: object | None = None self._prompt = self._build_prompt() self._stop_requested = False + self._clipboard_images: dict[str, InlineImage] = {} + self._missing_pillow_reported = False @property def debounce_enabled(self) -> bool: return False - async def start(self, on_receive: Callable[[str], Awaitable[None]]) -> None: + async def start(self, on_receive: Callable[[CliInput], Awaitable[None]]) -> None: self._renderer.welcome(model=self.runtime.settings.model, workspace=str(self.runtime.workspace)) await self._refresh_tape_info() @@ -59,17 +103,32 @@ async def start(self, on_receive: Callable[[str], Awaitable[None]]) -> None: continue request = self._normalize_input(raw) + payload, notices = self._build_cli_input(request) + for notice in notices: + self._renderer.info(notice) with self._renderer.console.status("[cyan]Processing...[/cyan]", spinner="dots"): - await on_receive(request) + await on_receive(payload) self._renderer.info("Bye.") - def is_mentioned(self, message: str) -> bool: + def is_mentioned(self, message: CliInput) -> bool: _ = message return True - async def get_session_prompt(self, message: str) -> tuple[str, str]: - return self._session_id, message + async def get_session_prompt(self, message: CliInput) -> tuple[str, str]: + if not message.images: + return self._session_id, message.text + + content = message.text.strip() or "[Image input]" + prompt = json.dumps( + { + "message": content, + "source": "cli", + "media": {"images": [image.to_metadata() for image in message.images]}, + }, + ensure_ascii=False, + ) + return self._session_id, prompt def format_prompt(self, prompt: str) -> str: return prompt @@ -101,6 +160,13 @@ def _toggle_mode(event) -> None: self._mode = "shell" if self._mode == "agent" else "agent" event.app.invalidate() + @kb.add("c-v", eager=True) + def _paste(event: KeyPressEvent) -> None: + if self._try_paste_image(event): + return + clipboard_data = event.app.clipboard.get_data() + event.current_buffer.paste_clipboard_data(clipboard_data) + def _tool_sort_key(tool_name: str) -> tuple[str, str]: section, _, name = tool_name.rpartition(".") return (section, name) @@ -142,6 +208,168 @@ def _normalize_input(self, raw: str) -> str: return raw return f", {raw}" + def _build_cli_input(self, request: str) -> tuple[CliInput, list[str]]: + if request.lstrip().startswith(","): + self._clipboard_images.clear() + return CliInput(text=request), [] + + extracted_images: list[InlineImage] = [] + notices: list[str] = [] + text = request + text, clipboard_images = self._extract_clipboard_images(text) + extracted_images.extend(clipboard_images) + text, path_images, path_notices = self._extract_local_path_images(text) + extracted_images.extend(path_images) + notices.extend(path_notices) + + self._clipboard_images.clear() + normalized_text = text.strip() + return CliInput(text=normalized_text, images=tuple(extracted_images)), notices + + def _extract_clipboard_images(self, text: str) -> tuple[str, list[InlineImage]]: + images: list[InlineImage] = [] + chunks: list[str] = [] + last = 0 + for match in CLIPBOARD_PLACEHOLDER_RE.finditer(text): + attachment_id = match.group("id") + image = self._clipboard_images.pop(attachment_id, None) + if image is None: + continue + chunks.append(text[last : match.start()]) + last = match.end() + images.append(image) + chunks.append(text[last:]) + return "".join(chunks), images + + def _extract_local_path_images(self, text: str) -> tuple[str, list[InlineImage], list[str]]: + images: list[InlineImage] = [] + notices: list[str] = [] + chunks: list[str] = [] + last = 0 + + for match in LOCAL_IMAGE_TOKEN_RE.finditer(text): + raw_path = self._normalize_path_token(match.group("path")) + loaded, notice = self._load_local_image(raw_path) + if loaded is None: + continue + chunks.append(text[last : match.start()]) + last = match.end() + images.append(loaded) + if notice: + notices.append(notice) + + chunks.append(text[last:]) + return "".join(chunks), images, notices + + def _load_local_image(self, raw_path: str) -> tuple[InlineImage | None, str | None]: + candidate = Path(raw_path).expanduser() + resolved = candidate.resolve() if candidate.is_absolute() else (self.runtime.workspace / candidate).resolve() + + try: + if not resolved.is_file(): + return None, None + except OSError: + logger.exception("cli.local_image.invalid_path path={}", raw_path) + return None, f"Failed to read image path: {raw_path}" + + mime_type = guess_image_mime(None, resolved.name) + if mime_type is None: + return None, None + + try: + payload = resolved.read_bytes() + except OSError: + logger.exception("cli.local_image.read_error path={}", str(resolved)) + return None, f"Failed to read image: {resolved}" + + if not payload: + return None, f"Skipped empty image file: {resolved}" + if len(payload) > self.INLINE_IMAGE_LIMIT_BYTES: + return None, (f"Skipped image larger than {self.INLINE_IMAGE_LIMIT_BYTES // (1024 * 1024)}MB: {resolved}") + + width, height = self._try_detect_dimensions(payload) + image = InlineImage( + id=f"{uuid4().hex[:8]}{resolved.suffix}", + data_url=to_data_url(payload, mime_type), + mime_type=mime_type, + file_size=len(payload), + width=width, + height=height, + path=str(resolved), + ) + return image, f"Attached image: {resolved}" + + @staticmethod + def _normalize_path_token(raw: str) -> str: + if len(raw) >= 2 and ((raw[0] == '"' and raw[-1] == '"') or (raw[0] == "'" and raw[-1] == "'")): + return raw[1:-1] + return raw + + @staticmethod + def _try_detect_dimensions(payload: bytes) -> tuple[int | None, int | None]: + try: + from PIL import Image + except Exception: + return None, None + + with contextlib.suppress(Exception), Image.open(BytesIO(payload)) as image: + return image.width, image.height + return None, None + + def _try_paste_image(self, event: KeyPressEvent) -> bool: + try: + from PIL import Image, ImageGrab + except ModuleNotFoundError as exc: + if exc.name == "PIL" and not self._missing_pillow_reported: + self._renderer.info("Install `pillow` to enable clipboard image paste.") + self._missing_pillow_reported = True + return False + except Exception as exc: + logger.debug("cli.clipboard_image.import_failed error={}", exc) + return False + + with contextlib.suppress(Exception): + image = ImageGrab.grabclipboard() + if image is None: + return False + + if not isinstance(image, Image.Image): + for item in image: + try: + with Image.open(item) as loaded: + image = loaded.copy() + break + except Exception as exc: + logger.debug("cli.clipboard_image.open_candidate_failed candidate={} error={}", item, exc) + else: + return False + + output = BytesIO() + image.save(output, format="PNG") + payload = output.getvalue() + if len(payload) > self.INLINE_IMAGE_LIMIT_BYTES: + self._renderer.info( + f"Skipped pasted image larger than {self.INLINE_IMAGE_LIMIT_BYTES // (1024 * 1024)}MB." + ) + return False + + image_id = f"{uuid4().hex[:8]}.png" + inline_image = InlineImage( + id=image_id, + data_url=to_data_url(payload, "image/png"), + mime_type="image/png", + file_size=len(payload), + width=image.width, + height=image.height, + ) + self._clipboard_images[image_id] = inline_image + placeholder = f"[image:{image_id},{image.width}x{image.height}]" + event.current_buffer.insert_text(placeholder) + event.app.invalidate() + logger.info("cli.clipboard_image.attached id={} size={}x{}", image_id, image.width, image.height) + return True + return False + @staticmethod def _history_file(home: Path, workspace: Path) -> Path: workspace_hash = md5(str(workspace).encode("utf-8")).hexdigest() # noqa: S324 diff --git a/src/bub/channels/discord.py b/src/bub/channels/discord.py index 0ac233d7..059b01df 100644 --- a/src/bub/channels/discord.py +++ b/src/bub/channels/discord.py @@ -6,7 +6,7 @@ import json from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, cast +from typing import Any, ClassVar, cast import discord from discord.ext import commands @@ -14,6 +14,7 @@ from bub.app.runtime import AppRuntime from bub.channels.base import BaseChannel, exclude_none +from bub.channels.media import DEFAULT_IMAGE_MIME, MAX_INLINE_IMAGE_BYTES, guess_image_mime, to_data_url from bub.channels.utils import resolve_proxy from bub.core.agent_loop import LoopResult @@ -43,6 +44,7 @@ class DiscordChannel(BaseChannel[discord.Message]): """Discord adapter based on discord.py.""" name = "discord" + INLINE_IMAGE_LIMIT_BYTES: ClassVar[int] = MAX_INLINE_IMAGE_BYTES def __init__(self, runtime: AppRuntime) -> None: super().__init__(runtime) @@ -96,7 +98,7 @@ async def on_message(message: discord.Message) -> None: async def get_session_prompt(self, message: discord.Message) -> tuple[str, str]: channel_id = str(message.channel.id) session_id = f"{self.name}:{channel_id}" - content, media = self._parse_message(message) + content, media = await self._parse_message_for_prompt(message) prefix = f"{self._config.command_prefix}bub " if content.startswith(prefix): @@ -193,7 +195,9 @@ def is_mentioned(self, message: discord.Message) -> bool: if self._config.allow_channels and channel_id not in self._config.allow_channels: return False - if not message.content.strip(): + has_text = bool(message.content.strip()) + has_media = bool(message.attachments or message.stickers) + if not has_text and not has_media: return False sender_tokens = {str(message.author.id), message.author.name} @@ -209,9 +213,9 @@ def is_mentioned(self, message: discord.Message) -> bool: if ( isinstance(message.channel, discord.DMChannel) - or "bub" in message.content.lower() + or (has_text and "bub" in message.content.lower()) or self._is_bub_scoped_thread(message) - or message.content.startswith(f"{self._config.command_prefix}bub") + or (has_text and message.content.startswith(f"{self._config.command_prefix}bub")) ): return True @@ -264,6 +268,99 @@ def _parse_message(message: discord.Message) -> tuple[str, dict[str, Any] | None return "[Unknown message type]", None + async def _parse_message_for_prompt(self, message: discord.Message) -> tuple[str, dict[str, Any] | None]: + content = message.content + media: dict[str, Any] = {} + + attachment_text, attachment_media = await self._collect_attachment_media(message) + media.update(attachment_media) + if not content and attachment_text: + content = attachment_text + + sticker_text, sticker_media = self._collect_sticker_media(message) + media.update(sticker_media) + if not content and sticker_text: + content = sticker_text + + if not content: + return "[Unknown message type]", media or None + return content, media or None + + async def _collect_attachment_media(self, message: discord.Message) -> tuple[str | None, dict[str, Any]]: + if not message.attachments: + return None, {} + + attachment_meta: list[dict[str, Any]] = [] + image_meta: list[dict[str, Any]] = [] + for att in message.attachments: + meta = exclude_none({ + "id": str(att.id), + "filename": att.filename, + "content_type": att.content_type, + "size": att.size, + "url": att.url, + "width": getattr(att, "width", None), + "height": getattr(att, "height", None), + }) + attachment_meta.append(meta) + + if not self._is_image_attachment(att): + continue + inline_image = await self._read_inline_image(att) + if inline_image is not None: + image_meta.append({**meta, **inline_image}) + + media = {"attachments": attachment_meta} + if image_meta: + media["images"] = image_meta + text = "\n".join(f"[Attachment: {meta['filename']}]" for meta in attachment_meta) + return text, media + + @staticmethod + def _collect_sticker_media(message: discord.Message) -> tuple[str | None, dict[str, Any]]: + if not message.stickers: + return None, {} + + sticker_lines = [f"[Sticker: {sticker.name}]" for sticker in message.stickers] + stickers = [{"id": str(sticker.id), "name": sticker.name} for sticker in message.stickers] + return "\n".join(sticker_lines), {"stickers": stickers} + + async def _read_inline_image(self, attachment: discord.Attachment) -> dict[str, Any] | None: + mime_type = guess_image_mime(attachment.content_type, attachment.filename) or DEFAULT_IMAGE_MIME + if attachment.size > self.INLINE_IMAGE_LIMIT_BYTES: + logger.info( + "discord.inline_image.skip_precheck id={} declared_size={} limit={}", + attachment.id, + attachment.size, + self.INLINE_IMAGE_LIMIT_BYTES, + ) + return None + + try: + payload = await attachment.read(use_cached=True) + except Exception: + logger.exception("discord.inline_image.read_error id={}", attachment.id) + return None + + if len(payload) > self.INLINE_IMAGE_LIMIT_BYTES: + logger.info( + "discord.inline_image.skip_after_download id={} actual_size={} limit={}", + attachment.id, + len(payload), + self.INLINE_IMAGE_LIMIT_BYTES, + ) + return None + + return { + "mime_type": mime_type, + "data_url": to_data_url(payload, mime_type), + "file_size": len(payload), + } + + @staticmethod + def _is_image_attachment(attachment: discord.Attachment) -> bool: + return guess_image_mime(attachment.content_type, attachment.filename) is not None + @staticmethod def _extract_reply_metadata(message: discord.Message) -> dict[str, Any] | None: ref = message.reference diff --git a/src/bub/channels/media.py b/src/bub/channels/media.py new file mode 100644 index 00000000..3e8d33cb --- /dev/null +++ b/src/bub/channels/media.py @@ -0,0 +1,27 @@ +"""Shared media helpers for channel adapters.""" + +from __future__ import annotations + +import base64 +import mimetypes + +MAX_INLINE_IMAGE_BYTES = 4 * 1024 * 1024 +DEFAULT_IMAGE_MIME = "image/png" + + +def to_data_url(data: bytes, mime_type: str) -> str: + encoded = base64.b64encode(data).decode("ascii") + return f"data:{mime_type};base64,{encoded}" + + +def guess_image_mime(content_type: str | None, filename: str | None) -> str | None: + normalized = (content_type or "").split(";", 1)[0].strip().casefold() + if normalized.startswith("image/"): + return normalized + + if filename: + guessed, _ = mimetypes.guess_type(filename) + if guessed and guessed.casefold().startswith("image/"): + return guessed.casefold() + + return None diff --git a/src/bub/channels/runner.py b/src/bub/channels/runner.py index b307672a..caa2cbc2 100644 --- a/src/bub/channels/runner.py +++ b/src/bub/channels/runner.py @@ -1,10 +1,14 @@ import asyncio +import re from typing import Any from loguru import logger from bub.channels.base import BaseChannel +DATA_URL_RE = re.compile(r"data:image/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+") +PROMPT_PREVIEW_LIMIT = 200 + class SessionRunner: def __init__( @@ -40,18 +44,26 @@ def reset_timer(self, timeout: int) -> None: self._timer.cancel() self._timer = self._loop.call_later(timeout, self._event.set) + @staticmethod + def _preview_prompt(prompt: str) -> str: + compact = DATA_URL_RE.sub("data:image/...;base64,", prompt.replace("\n", "\\n")) + if len(compact) <= PROMPT_PREVIEW_LIMIT: + return compact + return f"{compact[:PROMPT_PREVIEW_LIMIT]}..." + async def process_message(self, channel: BaseChannel, message: Any) -> None: is_mentioned = channel.is_mentioned(message) _, prompt = await channel.get_session_prompt(message) + prompt_preview = self._preview_prompt(prompt) now = self._loop.time() if not is_mentioned and ( self._last_mentioned_at is None or now - self._last_mentioned_at > self.active_time_window_seconds ): self._last_mentioned_at = None - logger.info("session.receive ignored session_id={} message={}", self.session_id, prompt) + logger.info("session.receive ignored session_id={} message={}", self.session_id, prompt_preview) return if prompt.startswith(","): - logger.info("session.receive.command session_id={} message={}", self.session_id, prompt) + logger.info("session.receive.command session_id={} message={}", self.session_id, prompt_preview) try: result = await channel.run_prompt(self.session_id, prompt) await channel.process_output(self.session_id, result) @@ -61,7 +73,7 @@ async def process_message(self, channel: BaseChannel, message: Any) -> None: logger.exception("session.run.error session_id={}", self.session_id) return elif not channel.debounce_enabled: - logger.info("session.receive.immediate session_id={} message={}", self.session_id, prompt) + logger.info("session.receive.immediate session_id={} message={}", self.session_id, prompt_preview) result = await channel.run_prompt(self.session_id, prompt) await channel.process_output(self.session_id, result) return @@ -70,14 +82,14 @@ async def process_message(self, channel: BaseChannel, message: Any) -> None: if is_mentioned: # Debounce mentioned messages before responding. self._last_mentioned_at = now - logger.info("session.receive.mentioned session_id={} message={}", self.session_id, prompt) + logger.info("session.receive.mentioned session_id={} message={}", self.session_id, prompt_preview) self.reset_timer(self.debounce_seconds) if self._running_task is None: self._running_task = asyncio.create_task(self._run(channel)) return await self._running_task elif self._last_mentioned_at is not None and self._running_task is None: # Otherwise if bot is mentioned before, we will keep reading messages for at most 60s. - logger.info("session.receive followup session_id={} message={}", self.session_id, prompt) + logger.info("session.receive followup session_id={} message={}", self.session_id, prompt_preview) self.reset_timer(self.message_delay_seconds) self._running_task = asyncio.create_task(self._run(channel)) return await self._running_task diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index 757e6eef..d229970a 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -15,6 +15,7 @@ from bub.app.runtime import AppRuntime from bub.channels.base import BaseChannel, exclude_none +from bub.channels.media import DEFAULT_IMAGE_MIME, MAX_INLINE_IMAGE_BYTES, guess_image_mime, to_data_url from bub.channels.utils import resolve_proxy from bub.core.agent_loop import LoopResult @@ -112,6 +113,7 @@ class TelegramChannel(BaseChannel[Message]): """Telegram adapter using long polling mode.""" name = "telegram" + INLINE_IMAGE_LIMIT_BYTES: ClassVar[int] = MAX_INLINE_IMAGE_BYTES def __init__(self, runtime: AppRuntime) -> None: super().__init__(runtime) @@ -167,7 +169,9 @@ async def start(self, on_receive: Callable[[Message], Awaitable[None]]) -> None: async def get_session_prompt(self, message: Message) -> tuple[str, str]: chat_id = str(message.chat_id) session_id = f"{self.name}:{chat_id}" + msg_type = _message_type(message) content, media = self._parse_message(message) + media = await self._augment_media_with_inline_images(message, msg_type=msg_type, media=media) if content.startswith("/bub "): content = content[5:] @@ -177,7 +181,7 @@ async def get_session_prompt(self, message: Message) -> tuple[str, str]: metadata: dict[str, Any] = { "message_id": message.message_id, - "type": _message_type(message), + "type": msg_type, "username": message.from_user.username if message.from_user else "", "full_name": message.from_user.full_name if message.from_user else "", "sender_id": str(message.from_user.id) if message.from_user else "", @@ -198,6 +202,97 @@ async def get_session_prompt(self, message: Message) -> tuple[str, str]: metadata_json = json.dumps({"message": content, "chat_id": chat_id, **metadata}, ensure_ascii=False) return session_id, metadata_json + async def _augment_media_with_inline_images( + self, + message: Message, + *, + msg_type: str, + media: dict[str, Any] | None, + ) -> dict[str, Any] | None: + if media is None: + return None + + inline_images: list[dict[str, Any]] = [] + if msg_type == "photo": + photos = getattr(message, "photo", None) or [] + if photos: + largest = photos[-1] + image = await self._download_inline_image( + message=message, + file_id=largest.file_id, + mime_type="image/jpeg", + image_id=largest.file_id, + file_size=largest.file_size, + width=largest.width, + height=largest.height, + ) + if image is not None: + inline_images.append(image) + elif msg_type == "document": + document = getattr(message, "document", None) + if document is not None: + mime_type = guess_image_mime(document.mime_type, document.file_name) + if mime_type is not None: + image = await self._download_inline_image( + message=message, + file_id=document.file_id, + mime_type=mime_type, + image_id=document.file_id, + file_size=document.file_size, + ) + if image is not None: + inline_images.append(image) + + if inline_images: + media["images"] = inline_images + return media + + async def _download_inline_image( + self, + *, + message: Message, + file_id: str, + mime_type: str, + image_id: str, + file_size: int | None = None, + width: int | None = None, + height: int | None = None, + ) -> dict[str, Any] | None: + if file_size is not None and file_size > self.INLINE_IMAGE_LIMIT_BYTES: + logger.info( + "telegram.inline_image.skip_precheck file_id={} declared_size={} limit={}", + file_id, + file_size, + self.INLINE_IMAGE_LIMIT_BYTES, + ) + return None + + try: + telegram_file = await message.get_bot().get_file(file_id) + payload = bytes(await telegram_file.download_as_bytearray()) + except Exception: + logger.exception("telegram.inline_image.download_error file_id={}", file_id) + return None + + if len(payload) > self.INLINE_IMAGE_LIMIT_BYTES: + logger.info( + "telegram.inline_image.skip_after_download file_id={} actual_size={} limit={}", + file_id, + len(payload), + self.INLINE_IMAGE_LIMIT_BYTES, + ) + return None + + resolved_mime = mime_type or DEFAULT_IMAGE_MIME + return exclude_none({ + "id": image_id, + "mime_type": resolved_mime, + "file_size": len(payload), + "width": width, + "height": height, + "data_url": to_data_url(payload, resolved_mime), + }) + async def process_output(self, session_id: str, output: LoopResult) -> None: parts = [part for part in (output.immediate_output, output.assistant_output) if part] if output.error: diff --git a/src/bub/cli/render.py b/src/bub/cli/render.py index c89981bc..cc6adab3 100644 --- a/src/bub/cli/render.py +++ b/src/bub/cli/render.py @@ -21,6 +21,7 @@ def welcome(self, *, model: str, workspace: str) -> None: f"model: {model}\n" "internal command prefix: ','\n" "shell command prefix: ',' at line start (Ctrl-X for shell mode)\n" + "image input: Ctrl-V paste or @/path/to/image.png\n" "type ',help' for command list" ) self.console.print(Panel(body, title="Bub", border_style="cyan")) diff --git a/src/bub/core/model_runner.py b/src/bub/core/model_runner.py index cb2dc832..abfcffdf 100644 --- a/src/bub/core/model_runner.py +++ b/src/bub/core/model_runner.py @@ -3,11 +3,12 @@ from __future__ import annotations import asyncio +import json import re import textwrap from collections.abc import Callable from dataclasses import dataclass, field -from typing import ClassVar +from typing import Any, ClassVar from loguru import logger from republic import Tool, ToolAutoResult @@ -21,6 +22,8 @@ HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)") TOOL_CONTINUE_PROMPT = "Continue the task." +DATA_URL_PLACEHOLDER = "[inline image omitted]" +CHANNEL_PREFIX = "channel: $" @dataclass(frozen=True) @@ -163,12 +166,13 @@ async def _consume_route(self, state: _PromptState, route: AssistantRouteResult) async def _chat(self, prompt: str) -> _ChatResult: system_prompt = self._render_system_prompt() + chat_prompt: Any = self._build_multimodal_prompt(prompt) try: async with asyncio.timeout(self._model_timeout_seconds): provider, _, _ = self._model.partition(":") if provider.casefold() == "vertexai": output = await self._tape.tape.run_tools_async( - prompt=prompt, + prompt=chat_prompt, system_prompt=system_prompt, max_tokens=self._max_tokens, tools=self._tools, @@ -176,7 +180,7 @@ async def _chat(self, prompt: str) -> _ChatResult: ) else: output = await self._tape.tape.run_tools_async( - prompt=prompt, + prompt=chat_prompt, system_prompt=system_prompt, max_tokens=self._max_tokens, tools=self._tools, @@ -192,6 +196,45 @@ async def _chat(self, prompt: str) -> _ChatResult: logger.exception("model.call.error") return _ChatResult(text="", error=f"model_call_error: {exc!s}") + def _build_multimodal_prompt(self, prompt: str) -> str | list[dict[str, Any]]: + if '"data_url"' not in prompt: + return prompt + + lines = [line for line in prompt.splitlines() if line.strip()] + if not lines: + return prompt + + prefix = "" + payload_lines = lines + if lines[0].startswith(CHANNEL_PREFIX): + prefix = lines[0] + payload_lines = lines[1:] + + sanitized_lines: list[str] = [] + image_parts: list[dict[str, Any]] = [] + for line in payload_lines: + stripped = line.strip() + if not stripped.startswith("{"): + sanitized_lines.append(line) + continue + + try: + payload = json.loads(stripped) + except json.JSONDecodeError: + sanitized_lines.append(line) + continue + + extracted = _extract_image_parts(payload) + image_parts.extend(extracted) + sanitized_lines.append(json.dumps(payload, ensure_ascii=False)) + + if not image_parts: + return prompt + + text_lines = [prefix, *sanitized_lines] if prefix else sanitized_lines + text_payload = "\n".join(line for line in text_lines if line.strip()) + return [{"type": "text", "text": text_payload}, *image_parts] + def _render_system_prompt(self) -> str: blocks: list[str] = [] if self._base_system_prompt: @@ -264,3 +307,36 @@ def _runtime_contract() -> str: 3. Call the corresponding channel skill to deliver the message 4. ONLY THEN end your turn """) + + +def _extract_image_parts(payload: dict[str, Any]) -> list[dict[str, Any]]: + """Extract inline images from *payload* and sanitize in place. + + The function mutates *payload* by replacing ``data_url`` values with + a short placeholder so that the full base64 blob is not sent as text + to the model. Callers should treat *payload* as consumed after this + call. + """ + media = payload.get("media") + if not isinstance(media, dict): + return [] + images = media.get("images") + if not isinstance(images, list): + return [] + + parts: list[dict[str, Any]] = [] + sanitized_images: list[Any] = [] + for image in images: + if not isinstance(image, dict): + sanitized_images.append(image) + continue + + copied = dict(image) + data_url = copied.pop("data_url", None) + if isinstance(data_url, str) and data_url.startswith("data:image/"): + parts.append({"type": "image_url", "image_url": {"url": data_url}}) + copied["data_url"] = DATA_URL_PLACEHOLDER + sanitized_images.append(copied) + + media["images"] = sanitized_images + return parts diff --git a/tests/test_cli_channel.py b/tests/test_cli_channel.py index b8b32017..f4b85a03 100644 --- a/tests/test_cli_channel.py +++ b/tests/test_cli_channel.py @@ -1,11 +1,15 @@ +import json +from base64 import b64decode from pathlib import Path -from bub.channels.cli import CliChannel +import pytest + +from bub.channels.cli import CliChannel, CliInput, InlineImage class _DummyRuntime: - def __init__(self) -> None: - self.workspace = Path.cwd() + def __init__(self, workspace: Path | None = None) -> None: + self.workspace = workspace or Path.cwd() class _Settings: model = "openrouter:test" @@ -62,3 +66,66 @@ def test_cli_channel_disables_debounce() -> None: def test_cli_channel_does_not_wrap_prompt() -> None: cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] assert cli.format_prompt("plain prompt") == "plain prompt" + + +@pytest.mark.asyncio +async def test_get_session_prompt_without_images_returns_plain_text() -> None: + cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] + session_id, prompt = await cli.get_session_prompt(CliInput(text="hello")) + assert session_id == "cli" + assert prompt == "hello" + + +def test_build_cli_input_extracts_local_image(tmp_path: Path) -> None: + image_path = tmp_path / "sample.png" + image_path.write_bytes( + b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z9xQAAAAASUVORK5CYII=") + ) + cli = CliChannel(_DummyRuntime(tmp_path)) # type: ignore[arg-type] + + payload, notices = cli._build_cli_input(f"describe this @{image_path}") + + assert payload.text == "describe this" + assert len(payload.images) == 1 + assert payload.images[0].path == str(image_path.resolve()) + assert payload.images[0].data_url.startswith("data:image/png;base64,") + assert notices + + +def test_build_cli_input_extracts_clipboard_placeholder() -> None: + cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] + cli._clipboard_images = { + "abc.png": InlineImage( + id="abc.png", + data_url="data:image/png;base64,AAAA", + mime_type="image/png", + file_size=4, + width=1, + height=1, + ) + } + + payload, _notices = cli._build_cli_input("look [image:abc.png,1x1] now") + + assert payload.text == "look now" + assert len(payload.images) == 1 + assert payload.images[0].id == "abc.png" + + +@pytest.mark.asyncio +async def test_get_session_prompt_with_images_uses_json_payload() -> None: + cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] + image = InlineImage( + id="img.png", + data_url="data:image/png;base64,AAAA", + mime_type="image/png", + file_size=4, + ) + + session_id, prompt = await cli.get_session_prompt(CliInput(text="look", images=(image,))) + data = json.loads(prompt) + + assert session_id == "cli" + assert data["message"] == "look" + assert data["source"] == "cli" + assert data["media"]["images"][0]["data_url"] == "data:image/png;base64,AAAA" diff --git a/tests/test_discord_filter.py b/tests/test_discord_filter.py index 7262dbd4..9c5c75f0 100644 --- a/tests/test_discord_filter.py +++ b/tests/test_discord_filter.py @@ -20,10 +20,14 @@ def __init__( content: str, channel: object, author: DummyAuthor | None = None, + attachments: list[object] | None = None, + stickers: list[object] | None = None, ) -> None: self.content = content self.channel = channel self.author = author or DummyAuthor() + self.attachments = attachments or [] + self.stickers = stickers or [] self.mentions: list[object] = [] self.reference = None @@ -71,3 +75,10 @@ def test_reject_empty_content_even_in_bub_thread() -> None: thread = SimpleNamespace(id=104, name="bub-help", parent=SimpleNamespace(name="forum")) message = DummyMessage(content=" ", channel=thread) assert channel.is_mentioned(message) is False # type: ignore[arg-type] + + +def test_allow_attachment_only_message_in_bub_thread() -> None: + channel = _build_channel() + thread = SimpleNamespace(id=105, name="bub-help", parent=SimpleNamespace(name="forum")) + message = DummyMessage(content="", channel=thread, attachments=[SimpleNamespace(filename="a.png")]) + assert channel.is_mentioned(message) is True # type: ignore[arg-type] diff --git a/tests/test_discord_session_prompt.py b/tests/test_discord_session_prompt.py new file mode 100644 index 00000000..efbf2c2e --- /dev/null +++ b/tests/test_discord_session_prompt.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest + +from bub.channels.discord import DiscordChannel + + +def _build_channel() -> DiscordChannel: + settings = SimpleNamespace( + discord_token="token", # noqa: S106 + discord_allow_from=[], + discord_allow_channels=[], + discord_command_prefix="!", + discord_proxy=None, + ) + runtime = SimpleNamespace(settings=settings) + return DiscordChannel(runtime) # type: ignore[arg-type] + + +class _DummyAttachment: + def __init__( + self, + *, + attachment_id: int, + filename: str, + content_type: str, + payload: bytes, + ) -> None: + self.id = attachment_id + self.filename = filename + self.content_type = content_type + self.size = len(payload) + self.url = f"https://example.com/{filename}" + self.width = 20 + self.height = 10 + self._payload = payload + + async def read(self, *, use_cached: bool = False) -> bytes: + _ = use_cached + return self._payload + + +def _build_message(*, content: str, attachments: list[_DummyAttachment]) -> SimpleNamespace: + author = SimpleNamespace(id=42, name="tester", display_name="Tester", global_name=None) + return SimpleNamespace( + id=100, + content=content, + attachments=attachments, + stickers=[], + author=author, + created_at=datetime(2026, 1, 1, tzinfo=UTC), + channel=SimpleNamespace(id=777), + guild=SimpleNamespace(id=99), + reference=None, + ) + + +@pytest.mark.asyncio +async def test_get_session_prompt_includes_inline_discord_image() -> None: + channel = _build_channel() + attachment = _DummyAttachment( + attachment_id=1, + filename="img.png", + content_type="image/png", + payload=b"abc123", + ) + message = _build_message(content="please analyze", attachments=[attachment]) + + session_id, prompt = await channel.get_session_prompt(message) # type: ignore[arg-type] + data = json.loads(prompt) + + assert session_id == "discord:777" + assert data["message"] == "please analyze" + assert data["media"]["attachments"][0]["filename"] == "img.png" + assert data["media"]["images"][0]["mime_type"] == "image/png" + assert data["media"]["images"][0]["data_url"].startswith("data:image/png;base64,") + + +@pytest.mark.asyncio +async def test_get_session_prompt_formats_attachment_only_message() -> None: + channel = _build_channel() + attachment = _DummyAttachment( + attachment_id=1, + filename="img.png", + content_type="image/png", + payload=b"abc123", + ) + message = _build_message(content="", attachments=[attachment]) + + _session_id, prompt = await channel.get_session_prompt(message) # type: ignore[arg-type] + data = json.loads(prompt) + + assert data["message"] == "[Attachment: img.png]" + assert data["media"]["images"][0]["data_url"].startswith("data:image/png;base64,") diff --git a/tests/test_model_runner.py b/tests/test_model_runner.py index 4a9ed0a4..31d40177 100644 --- a/tests/test_model_runner.py +++ b/tests/test_model_runner.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from typing import Any import pytest from republic import ToolAutoResult @@ -93,14 +94,14 @@ def all(self) -> list[object]: return [] outputs: list[ToolAutoResult] - calls: list[tuple[str, str, int]] = field(default_factory=list) + calls: list[tuple[Any, str, int]] = field(default_factory=list) call_kwargs: list[dict[str, object]] = field(default_factory=list) query: _Query = field(default_factory=_Query) async def run_tools_async( self, *, - prompt: str, + prompt: Any, system_prompt: str, max_tokens: int, tools: list[object], @@ -452,3 +453,33 @@ async def test_model_runner_uses_extra_headers_for_unknown_provider() -> None: kwargs = tape.tape.call_kwargs[0] assert kwargs.get("extra_headers") == ModelRunner.DEFAULT_HEADERS assert "http_options" not in kwargs + + +@pytest.mark.asyncio +async def test_model_runner_builds_multimodal_prompt_from_inline_images() -> None: + tape = FakeTapeService(FakeTapeImpl(outputs=[ToolAutoResult.text_result("assistant-only")])) + runner = ModelRunner( + tape=tape, # type: ignore[arg-type] + router=SingleStepRouter(), # type: ignore[arg-type] + tool_view=FakeToolView(), # type: ignore[arg-type] + tools=[], + list_skills=lambda: [], + model="openrouter:test", + max_steps=1, + max_tokens=512, + model_timeout_seconds=90, + base_system_prompt="base", + get_workspace_system_prompt=lambda: "", + ) + + prompt = ( + 'channel: $telegram\n{"message":"[Photo]","chat_id":"123","media":{"images":[{"id":"img-1",' + '"mime_type":"image/png","data_url":"data:image/png;base64,AAAA"}]}}' + ) + await runner.run(prompt) + + first_prompt = tape.tape.calls[0][0] + assert isinstance(first_prompt, list) + assert first_prompt[0]["type"] == "text" + assert "[inline image omitted]" in first_prompt[0]["text"] + assert first_prompt[1] == {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}} diff --git a/tests/test_telegram_session_prompt.py b/tests/test_telegram_session_prompt.py index d7803e90..3c28c416 100644 --- a/tests/test_telegram_session_prompt.py +++ b/tests/test_telegram_session_prompt.py @@ -42,6 +42,22 @@ def _build_message(*, text: str = "hello", chat_id: int = 123, message_id: int = ) +class _DummyTelegramFile: + def __init__(self, data: bytes) -> None: + self._data = data + + async def download_as_bytearray(self) -> bytearray: + return bytearray(self._data) + + +class _DummyBot: + def __init__(self, data: bytes) -> None: + self._data = data + + async def get_file(self, _file_id: str) -> _DummyTelegramFile: + return _DummyTelegramFile(self._data) + + @pytest.mark.asyncio async def test_get_session_prompt_wraps_text_with_notice_and_metadata() -> None: channel = _build_channel() @@ -87,3 +103,25 @@ async def test_get_session_prompt_includes_reply_metadata() -> None: assert reply["from_user_id"] == 1000 assert reply["from_username"] == "bot" assert reply["from_is_bot"] is True + + +@pytest.mark.asyncio +async def test_get_session_prompt_includes_inline_photo_image() -> None: + channel = _build_channel() + message = _build_message(text="") + message.text = None + message.caption = "look" + message.photo = [ + SimpleNamespace(file_id="small", file_size=5, width=10, height=10), + SimpleNamespace(file_id="large", file_size=6, width=20, height=20), + ] + message.get_bot = lambda: _DummyBot(b"abcdef") # type: ignore[assignment] + + _session_id, prompt = await channel.get_session_prompt(message) # type: ignore[arg-type] + data = json.loads(prompt) + + assert data["type"] == "photo" + assert data["media"]["file_id"] == "large" + assert data["media"]["images"][0]["id"] == "large" + assert data["media"]["images"][0]["mime_type"] == "image/jpeg" + assert data["media"]["images"][0]["data_url"].startswith("data:image/jpeg;base64,") diff --git a/uv.lock b/uv.lock index 211f9f5e..239a5d37 100644 --- a/uv.lock +++ b/uv.lock @@ -268,6 +268,7 @@ dependencies = [ { name = "discord-py" }, { name = "httpx", extra = ["socks"] }, { name = "loguru" }, + { name = "pillow" }, { name = "prompt-toolkit" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -305,6 +306,7 @@ requires-dist = [ { name = "discord-py", specifier = ">=2.6.4" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "loguru", specifier = ">=0.7.2" }, + { name = "pillow", specifier = ">=10.0.0" }, { name = "prompt-toolkit", specifier = ">=3.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, @@ -1361,6 +1363,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1"