diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js index a30f3b07..26bb3356 100644 --- a/interfaces/admin/static/chat.js +++ b/interfaces/admin/static/chat.js @@ -557,14 +557,70 @@ function _foldProcessTextRedundant(processText, collapsedItems) { return false; } +/** + * Prefer a single render of each attachment_id inside one aggregated bubble. + * When both generate + save_deliverable_attachment persist the same id, keep the + * deliverable row (or the first occurrence if none is marked deliverable). + */ +function _preferredAttachmentOwnerById(items) { + const preferred = new Map(); + (Array.isArray(items) ? items : []).forEach((it, itemIdx) => { + const list = parseAttachments(it && it.attachments); + for (const att of list) { + if (!att || typeof att !== "object") continue; + const aid = String(att.attachment_id || att.attachmentId || "") + .trim() + .toLowerCase(); + if (!aid) continue; + const prev = preferred.get(aid); + if (prev === undefined) { + preferred.set(aid, itemIdx); + continue; + } + if (att.deliverable === true) { + const prevAtts = parseAttachments((items[prev] && items[prev].attachments) || null); + const prevAtt = prevAtts.find((a) => { + const id = String((a && (a.attachment_id || a.attachmentId)) || "") + .trim() + .toLowerCase(); + return id === aid; + }); + if (!(prevAtt && prevAtt.deliverable === true)) preferred.set(aid, itemIdx); + } + } + }); + return preferred; +} + +function _attachmentsForBubbleItem(raw, itemIdx, preferredById) { + const list = parseAttachments(raw); + if (!list.length) return null; + const kept = []; + for (const att of list) { + if (!att || typeof att !== "object") continue; + const aid = String(att.attachment_id || att.attachmentId || "") + .trim() + .toLowerCase(); + if (aid && preferredById instanceof Map) { + const owner = preferredById.get(aid); + if (owner != null && owner !== itemIdx) continue; + } + kept.push(att); + } + return kept.length ? kept : null; +} + async function _buildAggregatedAssistantBubble(tsIso, items) { const inner = el("div", { class: "chat-msg chat-msg--assistant chat-msg--rich" }); const collapsedItems = []; - for (const it of items || []) { + const preferredAttOwner = _preferredAttachmentOwnerById(items); + const itemList = Array.isArray(items) ? items : []; + for (let itemIdx = 0; itemIdx < itemList.length; itemIdx++) { + const it = itemList[itemIdx]; const kind = String((it && it.kind) || ""); const text = String((it && it.text) || ""); - const parsedAtts = parseAttachments(it && it.attachments); - const hasInlineAtt = parsedAtts.length > 0; + const filteredAtts = _attachmentsForBubbleItem(it && it.attachments, itemIdx, preferredAttOwner); + const hasInlineAtt = Array.isArray(filteredAtts) && filteredAtts.length > 0; if ( !text.trim() && !(kind === "tool_result" && hasInlineAtt) && @@ -582,11 +638,11 @@ async function _buildAggregatedAssistantBubble(tsIso, items) { collapsedItems.push({ title: t("reasoning.processNotes"), text: t0 }); } } - const attsAssistant = await renderAttachmentsEl(it && it.attachments); + const attsAssistant = await renderAttachmentsEl(filteredAtts); if (attsAssistant) inner.appendChild(attsAssistant); } else { _appendAssistantTextSegments(inner, text, collapsedItems); - const attsAssistant = await renderAttachmentsEl(it && it.attachments); + const attsAssistant = await renderAttachmentsEl(filteredAtts); if (attsAssistant) inner.appendChild(attsAssistant); } } else if (kind === "reasoning") { @@ -597,7 +653,8 @@ async function _buildAggregatedAssistantBubble(tsIso, items) { if (adminChatShowToolOutput) collapsedItems.push({ title: _toolSummaryTitle("tool"), text }); // 支持两类附件:base64(image/input_image)和引用型(image_ref)。 // image_ref 需要异步拉取 blob,因此改为走 renderAttachmentsEl()。 - const attsEl = await renderAttachmentsEl(it && it.attachments); + // Same attachment_id from generate + save_deliverable is shown once (prefer deliverable). + const attsEl = await renderAttachmentsEl(filteredAtts); if (attsEl) inner.appendChild(attsEl); } else { inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) })); diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index 0a681cc1..dd7d3f8c 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -1454,11 +1454,19 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: ) channel_turn_uuid = str(turn_result.turn_uuid or "").strip() turn_reply = str(turn_result.reply_text or "").strip() + # Prefer attachments on the matching assistant text row. + # Fallback: deliverable tool media from this user turn + # (e.g. save_deliverable_attachment / image generate). turn_atts = _collect_reply_attachments_from_history( store=store, session_id=str(session_id), reply_text=turn_reply, ) + if not turn_atts: + turn_atts = _collect_recent_tool_attachments( + store=store, + session_id=str(session_id), + ) except Exception as e: turn_reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}" turn_atts = [] diff --git a/tests/test_whatsapp_inbound_queue_cancel.py b/tests/test_whatsapp_inbound_queue_cancel.py index ee6abb9e..3cad1fa4 100644 --- a/tests/test_whatsapp_inbound_queue_cancel.py +++ b/tests/test_whatsapp_inbound_queue_cancel.py @@ -121,6 +121,63 @@ class WhatsappInboundSerialQueueTests(unittest.TestCase): self.assertEqual(len(pending), 1) self.assertEqual(pending[0].get("text"), "only once") + def test_whatsapp_queue_includes_deliverable_tool_image(self) -> None: + """Regression (session 8d2b…): image on tool row must still enqueue to WhatsApp. + + Final assistant_text often has no attachments; deliverable lives on + ``save_deliverable_attachment`` tool_result. Queue path used to skip it. + """ + reply_text = "done, here is a cute puppy" + + def _handle_turn(**kwargs): + msg = kwargs.get("msg") + sid = str(getattr(msg, "session_id", "") or "") + self.store.add_message(sid, "user", "draw a puppy", event_type="user_text") + self.store.add_message( + sid, + "tool", + json.dumps({"ok": True, "attachment_id": "img-puppy", "deliverable": True}), + attachments=[ + { + "type": "image_ref", + "attachment_id": "img-puppy", + "name": "cute_puppy.png", + "mime": "image/png", + "deliverable": True, + } + ], + event_type="tool_result", + ) + # Final assistant body intentionally has no attachments (real gateway behavior). + self.store.add_message(sid, "assistant", reply_text, event_type="assistant_text") + return _FakeTurn(reply_text) + + with self._patch_common(), mock.patch("runtime.gateway.OclawGateway") as gw_cls, mock.patch( + "runtime.orchestration.group_ingest.should_process_group_inbound", return_value=True + ), mock.patch( + "runtime.application.gateway.whatsapp_inbound_access.handle_whatsapp_access", + return_value=None, + ), mock.patch.object( + inbound_mod, + "_maybe_expand_reply_attachments_for_channel", + # Keep image_ref as-is so we can assert attachment_id without a real asset blob. + side_effect=lambda reply: None, + ): + gw = gw_cls.return_value + gw.handle_turn.side_effect = lambda **kw: _handle_turn(**kw) + out = inbound_mod.process_inbound_payload(self._payload("draw a puppy", stanza="img1")) + + self.assertEqual(out.get("delivery"), "queued") + pending = self.store.list_pending_channel_outbound_messages( + channel="whatsapp", account_id="wa-default", limit=5 + ) + self.assertEqual(len(pending), 1) + self.assertEqual(pending[0].get("text"), reply_text) + atts = pending[0].get("attachments") or [] + self.assertTrue(atts, "outbound must include deliverable image attachment") + self.assertEqual(atts[0].get("attachment_id"), "img-puppy") + self.assertTrue(atts[0].get("deliverable") is True) + def test_busy_inbound_is_accepted_queued_then_merged(self) -> None: release = threading.Event() seen_texts: list[str] = []