diff --git a/app/services/grok/services/chat.py b/app/services/grok/services/chat.py index 9c94573..bae64e5 100644 --- a/app/services/grok/services/chat.py +++ b/app/services/grok/services/chat.py @@ -551,9 +551,8 @@ class StreamProcessor(proc_base.BaseProcessor): self.rollout_id: str = "" self.fingerprint: str = "" self.think_opened: bool = False - self.seen_think_once: bool = False + self.think_closed_once: bool = False self.image_think_active: bool = False - self.answer_buffer: list[str] = [] self.role_sent: bool = False self.filter_tags = get_config("app.filter_tags") self.tool_usage_enabled = ( @@ -758,6 +757,13 @@ class StreamProcessor(proc_base.BaseProcessor): } return f"data: {orjson.dumps(chunk).decode()}\n\n" + async def _close_think_block(self) -> AsyncGenerator[str, None]: + """Close the visible think block before emitting normal answer content.""" + if self.think_opened: + yield self._sse("\n\n") + self.think_opened = False + self.think_closed_once = True + async def process(self, response: AsyncIterable[bytes]) -> AsyncGenerator[str, None]: """Process stream response. @@ -805,7 +811,6 @@ class StreamProcessor(proc_base.BaseProcessor): if not self.think_opened: yield self._sse("\n") self.think_opened = True - self.seen_think_once = True idx = img.get("imageIndex", 0) + 1 progress = img.get("progress", 0) yield self._sse( @@ -814,6 +819,9 @@ class StreamProcessor(proc_base.BaseProcessor): continue if mr := resp.get("modelResponse"): + if self.image_think_active and self.think_opened: + async for chunk in self._close_think_block(): + yield chunk self.image_think_active = False for url in proc_base._collect_images(mr): parts = url.split("/") @@ -844,6 +852,8 @@ class StreamProcessor(proc_base.BaseProcessor): original = image.get("original") title = image.get("title") or "" if original: + async for chunk in self._close_think_block(): + yield chunk title_safe = title.replace("\n", " ").strip() if title_safe: yield self._sse(f"![{title_safe}]({original})\n") @@ -854,6 +864,8 @@ class StreamProcessor(proc_base.BaseProcessor): if (token := resp.get("token")) is not None: if not token: continue + if is_thinking and self.think_closed_once and not self.image_think_active: + continue filtered = self._filter_token(token) if not filtered: continue @@ -862,7 +874,7 @@ class StreamProcessor(proc_base.BaseProcessor): if not filtered: continue in_think = ( - is_thinking + (is_thinking and not self.think_closed_once) or self.image_think_active ) if in_think: @@ -871,14 +883,11 @@ class StreamProcessor(proc_base.BaseProcessor): if not self.think_opened: yield self._sse("\n") self.think_opened = True - self.seen_think_once = True yield self._sse(filtered) continue - - # 一旦进入思考模式,先缓存正文,等思考结束后再统一输出 - if self.show_think and self.seen_think_once: - self.answer_buffer.append(filtered) - continue + if self.think_opened: + async for chunk in self._close_think_block(): + yield chunk if self._tool_stream_enabled: for kind, payload in self._handle_tool_stream(filtered): @@ -891,25 +900,9 @@ class StreamProcessor(proc_base.BaseProcessor): yield self._sse(filtered) if self.think_opened: - yield self._sse("\n") - self.think_opened = False - if self.answer_buffer: - buffered = "".join(self.answer_buffer) - if self._tool_stream_enabled: - for kind, payload in self._handle_tool_stream(buffered): - if kind == "text": - yield self._sse(payload) - elif kind == "tool": - yield self._sse(tool_calls=[payload]) - for kind, payload in self._flush_tool_stream(): - if kind == "text": - yield self._sse(payload) - elif kind == "tool": - yield self._sse(tool_calls=[payload]) - else: - yield self._sse(buffered) - self.answer_buffer = [] - if self._tool_stream_enabled and not self.answer_buffer: + async for chunk in self._close_think_block(): + yield chunk + if self._tool_stream_enabled: for kind, payload in self._flush_tool_stream(): if kind == "text": yield self._sse(payload) diff --git a/app/static/public/css/chat.css b/app/static/public/css/chat.css index 6c8acd0..467605e 100644 --- a/app/static/public/css/chat.css +++ b/app/static/public/css/chat.css @@ -429,6 +429,10 @@ body { padding-right: 0; } +.think-image-extract { + margin: 12px 0 16px; +} + .img-grid { display: grid; gap: 2px; diff --git a/app/static/public/js/chat.js b/app/static/public/js/chat.js index c3fdd22..64c9f58 100644 --- a/app/static/public/js/chat.js +++ b/app/static/public/js/chat.js @@ -1109,6 +1109,44 @@ }); } + function liftThinkImages(root) { + if (!root || !root.querySelectorAll) return; + const thinkBlocks = Array.from(root.querySelectorAll('.think-block')); + thinkBlocks.forEach((block, blockIndex) => { + const images = Array.from(block.querySelectorAll('.think-content img')); + if (!images.length) return; + + let gallery = block.nextElementSibling; + if (!(gallery instanceof HTMLElement) || !gallery.classList.contains('think-image-extract')) { + gallery = document.createElement('div'); + gallery.className = 'think-image-extract'; + gallery.dataset.thinkBlockIndex = String(blockIndex); + block.insertAdjacentElement('afterend', gallery); + } + + images.forEach((img) => { + const paragraph = img.closest('p'); + gallery.appendChild(img); + + if (paragraph) { + const residue = (paragraph.textContent || '').replace(/\s+/g, ''); + if (!residue || /^\.(?:png|jpe?g|webp|gif)\)?$/i.test(residue)) { + paragraph.remove(); + return; + } + } + + const nextText = img.nextSibling; + if (nextText && nextText.nodeType === Node.TEXT_NODE) { + nextText.textContent = String(nextText.textContent || '').replace(/^\s*\.(?:png|jpe?g|webp|gif)\)?/i, ''); + if (!nextText.textContent.trim()) { + nextText.parentNode && nextText.parentNode.removeChild(nextText); + } + } + }); + }); + } + function bindCodeCopyButtons(root) { if (!root || !root.querySelectorAll) return; const buttons = root.querySelectorAll('.code-copy-btn'); @@ -1216,6 +1254,7 @@ updateThinkSummary(entry, entry.thinkElapsed); } if (entry.role === 'assistant' || entry.role === 'user') { + liftThinkImages(entry.contentNode); applyImageGrid(entry.contentNode); enhanceBrokenImages(entry.contentNode); bindMessageImagePreview(entry.contentNode);