Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
149 changes: 122 additions & 27 deletions astrbot/builtin_stars/astrbot/group_chat_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def __init__(self, acm: AstrBotConfigManager, context: star.Context) -> None:
self._locks: dict[str, asyncio.Lock] = {}
self.raw_records: dict[str, deque[str]] = defaultdict(deque)
self._record_ids: dict[str, deque[str]] = defaultdict(deque)
self._caption_tasks: set[asyncio.Task[None]] = set()

def _get_lock(self, umo: str) -> asyncio.Lock:
lock = self._locks.get(umo)
Expand Down Expand Up @@ -139,26 +140,66 @@ async def remove_session(self, event: AstrMessageEvent) -> int:
self._locks.pop(umo, None)
return cnt

async def handle_message(self, event: AstrMessageEvent) -> None:
async def handle_message(
self,
event: AstrMessageEvent,
*,
caption_images: bool = True,
) -> None:
"""Record a group message without blocking on image caption requests.

Args:
event: Incoming group message event.
caption_images: Whether passive group images should be captioned for
later context injection. Messages that already trigger an LLM
reply should set this to ``False`` because the main request
pipeline handles their images separately.
"""
if event.get_message_type() != MessageType.GROUP_MESSAGE:
return

umo = event.unified_msg_origin
cfg = self.cfg(event)
final_message = await self._format_message(event, cfg)
final_message, caption_template, pending_images = self._format_message(
event,
cfg,
caption_images=caption_images,
)
record_id = uuid.uuid4().hex

async with self._get_lock(umo):
records = self.raw_records[umo]
record_ids = self._record_ids[umo]
record_id = uuid.uuid4().hex
records.append(final_message)
record_ids.append(record_id)
_trim_left(records, cfg["group_message_max_cnt"], record_ids)
event.set_extra("_group_context_record_id", record_id)
event.set_extra("_group_context_raw_idx", len(records) - 1)

if pending_images:
task = asyncio.create_task(
self._fill_image_captions(
umo=umo,
record_id=record_id,
caption_template=caption_template,
pending_images=pending_images,
provider_id=cfg["image_caption_provider_id"],
prompt=cfg["image_caption_prompt"],
)
)
self._caption_tasks.add(task)
task.add_done_callback(self._on_caption_task_done)

logger.debug(f"group_chat_context | {umo} | {final_message}")

def _on_caption_task_done(self, task: asyncio.Task[None]) -> None:
"""Release a completed caption task and expose unexpected failures."""
self._caption_tasks.discard(task)
if task.cancelled():
return
if exc := task.exception():
logger.error("Group image caption task failed.", exc_info=exc)

async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest) -> None:
umo = event.unified_msg_origin
record_id = event.get_extra("_group_context_record_id", None)
Expand Down Expand Up @@ -196,29 +237,75 @@ async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest) -> Non
TextPart(text=_format_group_history_block(records_to_inject))
)

async def _format_message(self, event: AstrMessageEvent, cfg: dict) -> str:
async def _fill_image_captions(
self,
*,
umo: str,
record_id: str,
caption_template: str,
pending_images: list[tuple[str, str]],
provider_id: str,
prompt: str,
) -> None:
"""Resolve image captions in the background and update one record."""
results = await asyncio.gather(
*(
self.get_image_caption(image_url, provider_id, prompt)
for _, image_url in pending_images
),
return_exceptions=True,
)

resolved_message = caption_template
for (marker, _), result in zip(pending_images, results, strict=True):
if isinstance(result, BaseException):
logger.error("Failed to get image caption: %s", result)
replacement = " [Image]"
else:
replacement = f" [Image: {result}]"
resolved_message = resolved_message.replace(marker, replacement, 1)
Comment on lines +259 to +266

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Improve logging for per-image caption failures to include traceback and image context.

Currently only the exception message is logged, without a traceback or identifying which image failed, which limits diagnosability.

Consider:

  • Using logger.exception("Failed to get image caption for %s", url) (or exc_info=True) so the traceback is captured.
  • Including an image identifier (e.g., URL or hash) in the log message so specific failures can be correlated.

This preserves the non-blocking behavior while making failures easier to debug.

Suggested change
resolved_message = caption_template
for (marker, _), result in zip(pending_images, results, strict=True):
if isinstance(result, BaseException):
logger.error("Failed to get image caption: %s", result)
replacement = " [Image]"
else:
replacement = f" [Image: {result}]"
resolved_message = resolved_message.replace(marker, replacement, 1)
resolved_message = caption_template
for (marker, image_url), result in zip(pending_images, results, strict=True):
if isinstance(result, BaseException):
logger.error(
"Failed to get image caption for %s (marker %s)",
image_url,
marker,
exc_info=result,
)
replacement = " [Image]"
else:
replacement = f" [Image: {result}]"
resolved_message = resolved_message.replace(marker, replacement, 1)


async with self._get_lock(umo):
record_ids = self._record_ids.get(umo)
records = self.raw_records.get(umo)
if not record_ids or not records or record_id not in record_ids:
return
record_index = record_ids.index(record_id)
records[record_index] = resolved_message

logger.debug(f"group_chat_context captioned | {umo} | {resolved_message}")

def _format_message(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring image caption handling to use structured image slot metadata and a helper for task orchestration instead of template markers and parallel lists.

You can keep the new feature but reduce complexity by separating concerns and avoiding the parallel template_parts + marker juggling.

1. Narrow _format_message and avoid template markers

Instead of building both parts and template_parts and synthetic marker strings, return a single string plus structured image slot metadata that can be used by the background task.

For example:

@dataclass
class ImageSlot:
    index: int  # position in parts list
    url: str

def _format_message(
    self,
    event: AstrMessageEvent,
    cfg: dict,
    *,
    caption_images: bool,
) -> tuple[str, list[ImageSlot]]:
    datetime_str = datetime.datetime.now().strftime("%H:%M:%S")
    prefix = f"[{event.message_obj.sender.nickname}/{datetime_str}]: "
    parts: list[str] = [prefix]
    image_slots: list[ImageSlot] = []

    for comp in event.get_messages():
        if isinstance(comp, Plain):
            parts.append(f" {comp.text}")
        elif isinstance(comp, Image):
            url = comp.url if comp.url else comp.file
            parts.append(" [Image]")
            if caption_images and cfg["image_caption"]:
                if url:
                    image_slots.append(ImageSlot(index=len(parts) - 1, url=url))
                else:
                    logger.error("Failed to get image caption: image URL is empty.")
        elif isinstance(comp, Json):
            # unchanged JSON handling...
            ...
        elif isinstance(comp, At):
            is_at_self = str(comp.qq) in (event.get_self_id(), "all")
            if is_at_self:
                parts.insert(1, "⚠️[DIRECTED AT YOU] ")
            parts.append(f" [At: {comp.name}]")
        elif isinstance(comp, Reply):
            # unchanged reply handling...
            ...

    return "".join(parts), image_slots

Then _fill_image_captions can work directly on the stored record using indices instead of markers:

async def _fill_image_captions(
    self,
    *,
    umo: str,
    record_id: str,
    image_slots: list[ImageSlot],
    provider_id: str,
    prompt: str,
) -> None:
    results = await asyncio.gather(
        *(self.get_image_caption(slot.url, provider_id, prompt) for slot in image_slots),
        return_exceptions=True,
    )

    async with self._get_lock(umo):
        record_ids = self._record_ids.get(umo)
        records = self.raw_records.get(umo)
        if not record_ids or not records or record_id not in record_ids:
            return

        record_index = record_ids.index(record_id)
        original = records[record_index]
        parts = list(original)  # or re-split if needed; e.g. store `parts` instead of joined string

        for slot, result in zip(image_slots, results, strict=True):
            if isinstance(result, BaseException):
                logger.error("Failed to get image caption: %s", result)
                caption_text = " [Image]"
            else:
                caption_text = f" [Image: {result}]"
            parts[slot.index] = caption_text

        resolved_message = "".join(parts)
        records[record_index] = resolved_message

    logger.debug(f"group_chat_context captioned | {umo} | {resolved_message}")

This removes:

  • Synthetic marker strings and .replace(..., 1) calls.
  • The duplicated parts/template_parts maintenance.
  • The need to encode captioning state inside a secondary template representation.

2. Extract caption task orchestration out of handle_message

You can keep handle_message closer to its original “store record” responsibility by moving the task scheduling into a helper:

async def handle_message(
    self,
    event: AstrMessageEvent,
    *,
    caption_images: bool = True,
) -> None:
    if event.get_message_type() != MessageType.GROUP_MESSAGE:
        return

    umo = event.unified_msg_origin
    cfg = self.cfg(event)

    final_message, image_slots = self._format_message(
        event,
        cfg,
        caption_images=caption_images,
    )
    record_id = uuid.uuid4().hex

    async with self._get_lock(umo):
        records = self.raw_records[umo]
        record_ids = self._record_ids[umo]
        records.append(final_message)
        record_ids.append(record_id)
        _trim_left(records, cfg["group_message_max_cnt"], record_ids)
        event.set_extra("_group_context_record_id", record_id)
        event.set_extra("_group_context_raw_idx", len(records) - 1)

    self._maybe_schedule_caption_task(
        umo=umo,
        record_id=record_id,
        image_slots=image_slots,
        cfg=cfg,
    )

    logger.debug(f"group_chat_context | {umo} | {final_message}")

def _maybe_schedule_caption_task(
    self,
    *,
    umo: str,
    record_id: str,
    image_slots: list[ImageSlot],
    cfg: dict,
) -> None:
    if not image_slots:
        return

    task = asyncio.create_task(
        self._fill_image_captions(
            umo=umo,
            record_id=record_id,
            image_slots=image_slots,
            provider_id=cfg["image_caption_provider_id"],
            prompt=cfg["image_caption_prompt"],
        )
    )
    self._caption_tasks.add(task)
    task.add_done_callback(self._on_caption_task_done)

This keeps the new functionality but:

  • Restores _format_message to a single primary representation (string + structured slots).
  • Moves caption task setup into a focused helper, making handle_message easier to read.
  • Eliminates parallel template_parts and brittle marker replacement.

self,
event: AstrMessageEvent,
cfg: dict,
*,
caption_images: bool,
) -> tuple[str, str, list[tuple[str, str]]]:
"""Format one record and prepare optional background image captions."""
datetime_str = datetime.datetime.now().strftime("%H:%M:%S")
parts = [f"[{event.message_obj.sender.nickname}/{datetime_str}]: "]
prefix = f"[{event.message_obj.sender.nickname}/{datetime_str}]: "
parts = [prefix]
template_parts = [prefix]
pending_images: list[tuple[str, str]] = []

for comp in event.get_messages():
if isinstance(comp, Plain):
parts.append(f" {comp.text}")
text = f" {comp.text}"
parts.append(text)
template_parts.append(text)
elif isinstance(comp, Image):
if cfg["image_caption"]:
try:
url = comp.url if comp.url else comp.file
if not url:
raise Exception("图片 URL 为空")
caption = await self.get_image_caption(
url,
cfg["image_caption_provider_id"],
cfg["image_caption_prompt"],
)
parts.append(f" [Image: {caption}]")
except Exception as e:
logger.error(f"获取图片描述失败: {e}")
url = comp.url if comp.url else comp.file
should_caption = caption_images and cfg["image_caption"] and bool(url)
parts.append(" [Image]")
if should_caption:
marker = f" __ASTRBOT_IMAGE_CAPTION_{uuid.uuid4().hex}__"
template_parts.append(marker)
pending_images.append((marker, url))
else:
parts.append(" [Image]")
template_parts.append(" [Image]")
if caption_images and cfg["image_caption"] and not url:
logger.error("Failed to get image caption: image URL is empty.")
elif isinstance(comp, Json):
card_data = comp.data
if isinstance(card_data, dict) and isinstance(
Expand Down Expand Up @@ -249,27 +336,35 @@ async def _format_message(self, event: AstrMessageEvent, cfg: dict) -> str:
normalized = " ".join(value.split())
fields.append(f"{label}: {_truncate_reply_text(normalized)}")
suffix = f": {'; '.join(fields)}" if fields else ""
parts.append(f" [Shared Card{suffix}]")
text = f" [Shared Card{suffix}]"
parts.append(text)
template_parts.append(text)
elif isinstance(comp, At):
is_at_self = str(comp.qq) in (
event.get_self_id(),
"all",
)
if is_at_self:
parts.insert(1, "⚠️[DIRECTED AT YOU] ")
parts.append(f" [At: {comp.name}]")
template_parts.insert(1, "⚠️[DIRECTED AT YOU] ")
text = f" [At: {comp.name}]"
parts.append(text)
template_parts.append(text)
elif isinstance(comp, Reply):
if comp.message_str:
parts.append(
f" [Quote({comp.sender_nickname}: {_truncate_reply_text(comp.message_str)})]"
text = (
f" [Quote({comp.sender_nickname}: "
f"{_truncate_reply_text(comp.message_str)})]"
)
elif comp.chain:
chain_desc = _describe_chain(comp.chain)
parts.append(f" [Quote({comp.sender_nickname}: {chain_desc})]")
text = f" [Quote({comp.sender_nickname}: {chain_desc})]"
else:
parts.append(" [Quote]")
text = " [Quote]"
parts.append(text)
template_parts.append(text)

return "".join(parts)
return "".join(parts), "".join(template_parts), pending_images


_MAX_REPLY_TEXT_LENGTH = 200
Expand Down
10 changes: 9 additions & 1 deletion astrbot/builtin_stars/astrbot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,15 @@ async def on_message(self, event: AstrMessageEvent):
# chat context that should be injected into future LLM requests.
if not event.get_extra("handlers_parsed_params", {}):
try:
await self.group_chat_context.handle_message(event)
# The main LLM pipeline already handles images from messages
# that trigger a reply. Captioning them here would add a
# duplicate foreground model request before the reply starts.
await self.group_chat_context.handle_message(
event,
caption_images=not (
event.is_at_or_wake_command or need_active
),
)
except BaseException as e:
logger.error(e)

Expand Down
Loading