diff --git a/app/services/grok/services/chat.py b/app/services/grok/services/chat.py index 4238ba3..f37ef5b 100644 --- a/app/services/grok/services/chat.py +++ b/app/services/grok/services/chat.py @@ -11,7 +11,7 @@ import orjson from curl_cffi.requests import AsyncSession from curl_cffi.requests.errors import RequestsError -from app.core.logger import logger +from app.core.logger import logger, LOG_DIR from app.core.config import get_config from app.core.exceptions import ( AppException, @@ -268,6 +268,97 @@ def extract_render_payload(model_response: Dict[str, Any]) -> Dict[str, Any] | N } +def _parse_card_attachments_json(card_attachments: Any) -> List[Dict[str, Any]]: + parsed: List[Dict[str, Any]] = [] + for raw in card_attachments or []: + if not isinstance(raw, str) or not raw.strip(): + continue + try: + item = orjson.loads(raw) + except orjson.JSONDecodeError: + parsed.append({"_raw": raw}) + continue + if isinstance(item, dict): + parsed.append(item) + return parsed + + +def _build_capture_event(resp: Dict[str, Any], model: str, phase: str, seq: int) -> Dict[str, Any]: + event: Dict[str, Any] = { + "type": "app_chat_capture", + "phase": phase, + "seq": seq, + "model": model, + "responseId": resp.get("responseId"), + "rolloutId": resp.get("rolloutId"), + "messageTag": resp.get("messageTag"), + "isThinking": bool(resp.get("isThinking")), + } + + token = resp.get("token") + if token is not None: + event["tokenPreview"] = str(token)[:300] + + if llm := resp.get("llmInfo"): + if isinstance(llm, dict): + event["llmInfo"] = llm + + if tool_card := resp.get("toolUsageCard"): + event["toolUsageCardId"] = ( + str(resp.get("toolUsageCardId") or tool_card.get("toolUsageCardId") or "").strip() + ) + event["toolUsageCard"] = tool_card + + if card := resp.get("cardAttachment"): + if isinstance(card, dict): + event["cardAttachment"] = card + json_data = card.get("jsonData") + if isinstance(json_data, str) and json_data.strip(): + try: + event["cardAttachmentJsonParsed"] = orjson.loads(json_data) + except orjson.JSONDecodeError: + event["cardAttachmentJsonParsed"] = {"_raw": json_data} + + if web_search_results := resp.get("webSearchResults"): + event["webSearchResults"] = web_search_results + + if mr := resp.get("modelResponse"): + if isinstance(mr, dict): + event["modelResponse"] = { + "responseId": mr.get("responseId"), + "message": mr.get("message"), + "cardAttachmentsJsonParsed": _parse_card_attachments_json( + mr.get("cardAttachmentsJson") + ), + "steps": mr.get("steps"), + "webSearchResults": mr.get("webSearchResults"), + "metadata": mr.get("metadata"), + } + if sources_payload := extract_sources_payload(mr): + event["sourcesPayload"] = sources_payload + if render_payload := extract_render_payload(mr): + event["renderingPayload"] = render_payload + + return event + + +def _capture_app_chat_event(resp: Dict[str, Any], model: str, phase: str, seq: int) -> None: + if not get_config("chat.capture_enabled"): + return + + capture_file = get_config("chat.capture_file") or str(LOG_DIR / "app_chat_capture.jsonl") + try: + event = _build_capture_event(resp, model, phase, seq) + with open(capture_file, "ab") as f: + f.write(orjson.dumps(event)) + f.write(b"\n") + except Exception as e: + logger.warning( + f"App chat capture write failed: {e}", + extra={"model": model, "error_type": type(e).__name__}, + ) + + def _get_chat_semaphore() -> asyncio.Semaphore: global _CHAT_SEMAPHORE, _CHAT_SEM_VALUE value = max(1, int(get_config("chat.concurrent"))) @@ -747,6 +838,7 @@ class StreamProcessor(proc_base.BaseProcessor): self._source_order: List[str] = [] self._citation_sources: List[Dict[str, str]] = [] self._image_sources: List[Dict[str, str]] = [] + self._capture_seq: int = 0 def _filter_tool_card(self, token: str) -> str: if not token or not self.tool_usage_enabled: @@ -1000,6 +1092,8 @@ class StreamProcessor(proc_base.BaseProcessor): continue resp = data.get("result", {}).get("response", {}) + self._capture_seq += 1 + _capture_app_chat_event(resp, self.model, "stream", self._capture_seq) is_thinking = bool(resp.get("isThinking")) # isThinking controls tagging # when absent, treat as False @@ -1273,6 +1367,7 @@ class CollectProcessor(proc_base.BaseProcessor): final_model_response: Dict[str, Any] = {} idle_timeout = get_config("chat.stream_timeout") first_item_timeout = get_config("chat.first_token_timeout") + capture_seq = 0 try: async for line in proc_base._with_idle_timeout( @@ -1287,6 +1382,8 @@ class CollectProcessor(proc_base.BaseProcessor): continue resp = data.get("result", {}).get("response", {}) + capture_seq += 1 + _capture_app_chat_event(resp, self.model, "collect", capture_seq) if (llm := resp.get("llmInfo")) and not fingerprint: fingerprint = llm.get("modelHash", "") diff --git a/app/services/grok/utils/download.py b/app/services/grok/utils/download.py index d934b46..38fefc9 100644 --- a/app/services/grok/utils/download.py +++ b/app/services/grok/utils/download.py @@ -25,6 +25,16 @@ from app.services.reverse.assets_download import AssetsDownloadReverse from app.services.reverse.utils.headers import build_headers from app.services.grok.utils.locks import _get_download_semaphore, _file_lock +_CONTENT_TYPES = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".gif": "image/gif", + ".mp4": "video/mp4", + ".webm": "video/webm", +} + class DownloadService: """Assets download service.""" @@ -53,12 +63,7 @@ class DownloadService: async def resolve_url( self, path_or_url: str, token: str, media_type: str = "image" ) -> str: - if self._is_public_share_url(path_or_url): - app_url = get_config("app.app_url") - filename = self._public_cache_filename(path_or_url, media_type) - if app_url: - await self.download_file(path_or_url, token, media_type) - return f"{app_url.rstrip('/')}/v1/files/{media_type}/{filename}" + if self._is_public_direct_url(path_or_url): return path_or_url asset_url = path_or_url @@ -87,12 +92,29 @@ class DownloadService: or text.startswith("http://imagine-public.x.ai/") ) + @staticmethod + def _is_grok_asset_url(url: str) -> bool: + parsed = urlparse(str(url or "").strip()) + host = (parsed.hostname or "").lower() + return host == "assets.grok.com" + @staticmethod def _is_localhost_url(url: str) -> bool: parsed = urlparse(str(url or "").strip()) host = (parsed.hostname or "").lower() return host in {"localhost", "127.0.0.1", "::1"} + @classmethod + def _is_public_direct_url(cls, url: str) -> bool: + text = str(url or "").strip() + if not text.startswith(("http://", "https://")): + return False + if cls._is_localhost_url(text): + return False + if cls._is_public_share_url(text): + return True + return not cls._is_grok_asset_url(text) + @staticmethod def _public_cache_filename(file_url: str, media_type: str = "image") -> str: parsed = urlparse(str(file_url or "").strip()) @@ -131,9 +153,13 @@ class DownloadService: base_proxy = (get_config("proxy.base_proxy_url") or "").strip() asset_proxy = (get_config("proxy.asset_proxy_url") or "").strip() proxy_url = asset_proxy or base_proxy + guessed_content_type = _CONTENT_TYPES.get( + Path(urlparse(file_url).path).suffix.lower() + ) headers = build_headers( cookie_token="", - content_type="video/mp4" if media_type == "video" else "image/jpeg", + content_type=guessed_content_type + or ("video/mp4" if media_type == "video" else "image/jpeg"), origin="https://grok.com", referer="https://grok.com/", ) @@ -274,28 +300,34 @@ class DownloadService: if not self._is_url(file_path): raise AppException("Invalid file path", code="invalid_file_path") - file_path = self._normalize_path(file_path) - lock_name = f"dl_b64_{hashlib.sha1(file_path.encode()).hexdigest()[:16]}" - lock_timeout = max(1, int(get_config("asset.download_timeout"))) - async with _get_download_semaphore(): - async with _file_lock(lock_name, timeout=lock_timeout): - session = await self.create() - response = await AssetsDownloadReverse.request( - session, token, file_path - ) - - if hasattr(response, "aiter_content"): - data = bytearray() - async for chunk in response.aiter_content(): - if chunk: - data.extend(chunk) - raw = bytes(data) + if self._is_public_direct_url(file_path): + raise AppException( + "Third-party image does not support server-side base64 render", + code="third_party_direct_url", + ) else: - raw = response.content + file_path = self._normalize_path(file_path) + lock_name = f"dl_b64_{hashlib.sha1(file_path.encode()).hexdigest()[:16]}" + lock_timeout = max(1, int(get_config("asset.download_timeout"))) + async with _get_download_semaphore(): + async with _file_lock(lock_name, timeout=lock_timeout): + session = await self.create() + response = await AssetsDownloadReverse.request( + session, token, file_path + ) - content_type = response.headers.get( - "content-type", "application/octet-stream" - ).split(";")[0] + if hasattr(response, "aiter_content"): + data = bytearray() + async for chunk in response.aiter_content(): + if chunk: + data.extend(chunk) + raw = bytes(data) + else: + raw = response.content + + content_type = response.headers.get( + "content-type", "application/octet-stream" + ).split(";")[0] data_uri = f"data:{content_type};base64,{base64.b64encode(raw).decode()}" return data_uri @@ -329,8 +361,11 @@ class DownloadService: Returns: Tuple[Optional[Path], str]: The path of the downloaded file and the MIME type. """ - if self._is_public_share_url(file_path): - return await self._download_public_url(file_path, media_type) + if self._is_public_direct_url(file_path): + raise AppException( + "Third-party direct URL should be served by browser directly", + code="third_party_direct_url", + ) started_at = time.perf_counter() async with _get_download_semaphore(): diff --git a/app/static/public/css/chat.css b/app/static/public/css/chat.css index bcbccc6..7c656f3 100644 --- a/app/static/public/css/chat.css +++ b/app/static/public/css/chat.css @@ -699,31 +699,117 @@ body { } .img-grid { - display: flex; + display: grid; + grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); gap: 12px; margin: 18px 0 20px; width: 100%; max-width: 100%; + align-items: start; + overflow: visible; + padding-bottom: 0; +} + +.img-grid-shell { + position: relative; + width: 100%; +} + +.img-grid-shell.is-scrollable { + position: relative; +} + +.img-grid.img-grid--desktop-scroll { + grid-template-columns: none !important; + grid-auto-flow: column; + grid-auto-columns: minmax(280px, 32vw); overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; - padding-bottom: 6px; + padding-bottom: 4px; +} + +.img-grid.img-grid--desktop-scroll > * { + scroll-snap-align: start; +} + +.img-grid.img-grid--desktop-scroll::-webkit-scrollbar { + display: none; } .img-grid > * { display: block; - width: auto; - flex: 0 0 clamp(160px, 22vw, 260px); + width: 100%; margin: 0; - scroll-snap-align: start; + min-width: 0; +} + +.img-grid-controls { + position: absolute; + inset-inline: 0; + top: 50%; + z-index: 3; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + transform: translateY(-50%); + padding: 0 10px; + pointer-events: none; +} + +.img-grid-nav { + width: 42px; + height: 42px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 999px; + background: rgba(15, 23, 42, 0.46); + color: rgba(255, 255, 255, 0.94); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 28px; + line-height: 1; + cursor: pointer; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.22); + pointer-events: auto; + transition: background 0.18s ease, opacity 0.18s ease, transform 0.18s ease; +} + +.img-grid-nav__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex: 0 0 18px; +} + +.img-grid-nav__icon svg { + display: block; + width: 100%; + height: 100%; +} + +.img-grid-nav:hover:not(:disabled) { + background: rgba(15, 23, 42, 0.64); + transform: scale(1.02); +} + +.img-grid-nav:disabled { + opacity: 0.28; + cursor: default; } .message-content.rendered .img-grid .message-image-card { max-width: none; width: 100%; margin: 0; + height: auto; } .message-content.rendered .img-grid img { @@ -736,8 +822,8 @@ body { border-radius: 12px; border: 1px solid var(--chat-border); background: transparent; - aspect-ratio: 1 / 1; - object-fit: cover; + aspect-ratio: auto; + object-fit: contain; } .message-content.rendered .img-grid .message-image-caption { @@ -749,15 +835,6 @@ body { border-radius: 12px; } -.img-grid::-webkit-scrollbar { - height: 8px; -} - -.img-grid::-webkit-scrollbar-thumb { - background: var(--chat-border); - border-radius: 999px; -} - .img-retry { width: 100%; min-height: 120px; @@ -1861,10 +1938,14 @@ html[data-theme='dark'] .chat-sidebar { } .message-content.rendered .message-image-card { + position: relative; display: block; width: 100%; max-width: min(680px, 100%); margin: 18px 0 20px; + background: transparent; + border: none; + box-shadow: none; } .message-content.rendered .message-image-card img { @@ -1874,8 +1955,8 @@ html[data-theme='dark'] .chat-sidebar { max-height: 480px; margin: 0; border-radius: 16px; - border: 1px solid var(--chat-border); - background: var(--chat-surface); + border: none; + background: transparent; cursor: zoom-in; object-fit: contain; } @@ -1887,3 +1968,94 @@ html[data-theme='dark'] .chat-sidebar { line-height: 1.45; color: var(--accents-4); } + +.message-content.rendered .message-image-source { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 2; + max-width: calc(100% - 24px); + padding: 4px 10px; + border-radius: 999px; + background: rgba(17, 24, 39, 0.78); + color: #fff; + font-size: 12px; + line-height: 1.2; + font-weight: 600; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; + pointer-events: auto; + cursor: pointer; +} + +.message-content.rendered .message-image-source:hover { + background: rgba(17, 24, 39, 0.92); + color: #fff; +} + +.message-content.rendered .message-image-card.is-broken .message-image-source { + bottom: auto; + top: 12px; +} + +.message-content.rendered .img-grid .message-image-card { + background: transparent; + border: none; + box-shadow: none; +} + +.message-content.rendered .img-grid .message-image-card img { + width: 100%; + height: auto; + max-width: 100%; + max-height: none; + border: none; + background: transparent; + object-fit: contain; +} + +@media (max-width: 720px) { + .img-grid { + grid-template-columns: 1fr; + } + + .img-grid.img-grid--mobile-scroll { + grid-template-columns: none !important; + grid-auto-flow: column; + grid-auto-columns: minmax(78vw, 78vw); + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scroll-snap-type: x proximity; + -webkit-overflow-scrolling: touch; + padding-bottom: 4px; + } + + .img-grid.img-grid--mobile-scroll > * { + scroll-snap-align: start; + } + + .img-grid.img-grid--mobile-scroll::-webkit-scrollbar { + display: none; + } + + .img-grid-controls { + padding: 0 6px; + } + + .img-grid-nav { + width: 38px; + height: 38px; + } + + .img-grid-nav__icon { + width: 16px; + height: 16px; + flex-basis: 16px; + } +} diff --git a/app/static/public/js/chat.js b/app/static/public/js/chat.js index 621d034..f11fda1 100644 --- a/app/static/public/js/chat.js +++ b/app/static/public/js/chat.js @@ -717,6 +717,35 @@ return `\n![${title || 'image'}](${original})\n`; } + function buildRenderedImageSourceMap(rendering) { + const cardMap = parseRenderingCards(rendering); + const sourceMap = new Map(); + cardMap.forEach((card) => { + if (!card || typeof card !== 'object') return; + const image = card.image && typeof card.image === 'object' ? card.image : null; + const articleUrl = image ? String(image.link || '').trim() : ''; + const originalUrl = image ? String(image.original || '').trim() : ''; + const thumbnailUrl = image ? String(image.thumbnail || '').trim() : ''; + const resolvedUrl = articleUrl || originalUrl; + if (!resolvedUrl) return; + const sourceInfo = { + href: resolvedUrl, + label: getSourceHostname(resolvedUrl), + fallbackImage: thumbnailUrl + }; + [ + originalUrl, + normalizeRenderedImageUrl(originalUrl), + articleUrl + ] + .filter(Boolean) + .forEach((candidate) => { + sourceMap.set(String(candidate), sourceInfo); + }); + }); + return sourceMap; + } + function normalizeRenderedImageUrl(url) { const raw = String(url || '').trim(); if (!raw) return ''; @@ -1008,7 +1037,7 @@ }); } - function renderBasicMarkdown(rawText) { + function renderBasicMarkdown(rawText, imageSourceMap = null) { const text = (rawText || '').replace(/\\n/g, '\n'); const htmlLinks = []; const linkExtractedText = text @@ -1046,18 +1075,46 @@ .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1'); - output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { - const safeAlt = escapeHtml(alt || 'image'); - const safeUrl = escapeHtml(url || ''); - const caption = safeAlt && safeAlt !== 'image' - ? `
${safeAlt}
` + output = replaceMarkdownImages(output, ({ alt, middle, url, raw }) => { + const normalizedAlt = decodeHtmlEntities(alt || ''); + const normalizedMiddle = decodeHtmlEntities(middle || ''); + const normalizedUrl = decodeHtmlEntities(url || '').trim(); + if (!/^https?:\/\//i.test(normalizedUrl) && !normalizedUrl.startsWith('data:')) { + return raw; + } + const mergedAlt = [String(normalizedAlt || '').trim(), String(normalizedMiddle || '').trim()] + .filter(Boolean) + .join(' ') + .trim(); + const safeAlt = escapeHtml(mergedAlt || normalizedAlt || 'image'); + const safeUrl = escapeHtml(normalizedUrl); + const sourceInfo = imageSourceMap instanceof Map + ? ( + imageSourceMap.get(normalizedUrl) + || imageSourceMap.get(normalizeRenderedImageUrl(normalizedUrl)) + || null + ) + : null; + const sourceHref = escapeHtml(sourceInfo && sourceInfo.href ? sourceInfo.href : normalizedUrl); + const fallbackSrc = escapeHtml(sourceInfo && sourceInfo.fallbackImage ? sourceInfo.fallbackImage : ''); + const sourceLabel = escapeHtml( + sourceInfo && sourceInfo.label + ? sourceInfo.label + : getImageSourceLabel(normalizedUrl) + ); + const caption = mergedAlt && mergedAlt !== 'image' + ? `
${escapeHtml(mergedAlt)}
` : ''; - return `
${safeAlt}${caption}
`; + const sourceBadge = sourceLabel + ? `${sourceLabel}` + : ''; + const fallbackAttr = fallbackSrc ? ` data-fallback-src="${fallbackSrc}"` : ''; + return `
${safeAlt}${sourceBadge}${caption}
`; }); output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => { - const safeLabel = escapeHtml(label || ''); - const safeUrl = escapeHtml(url || ''); + const safeLabel = escapeHtml(decodeHtmlEntities(label || '')); + const safeUrl = escapeHtml(decodeHtmlEntities(url || '')); return `${safeLabel}`; }); @@ -1092,6 +1149,19 @@ let inTable = false; let paragraphLines = []; + const isStandaloneMediaLine = (line) => { + const trimmed = String(line || '').trim(); + if (!trimmed) return false; + if (/^\[[^\]]+\]\((https?:\/\/.+)\)$/.test(trimmed)) { + return true; + } + if (!trimmed.startsWith('![')) { + return false; + } + const replaced = replaceMarkdownImages(trimmed, () => '@@IMG@@'); + return String(replaced || '').trim() === '@@IMG@@'; + }; + const closeLists = () => { if (inUl) { htmlParts.push(''); @@ -1112,20 +1182,35 @@ const flushParagraph = () => { if (!paragraphLines.length) return; - const joined = paragraphLines.join('
'); - const standaloneMediaLines = paragraphLines.every((line) => { + let textChunk = []; + let mediaChunk = []; + + const flushTextChunk = () => { + if (!textChunk.length) return; + htmlParts.push(`

${renderInline(textChunk.join('
'))}

`); + textChunk = []; + }; + + const flushMediaChunk = () => { + if (!mediaChunk.length) return; + htmlParts.push(mediaChunk.map((line) => renderInline(line.trim())).join('')); + mediaChunk = []; + }; + + paragraphLines.forEach((line) => { const trimmed = String(line || '').trim(); - if (!trimmed) return false; - return ( - /^!\[[^\]]*\]\([^)]+\)$/.test(trimmed) || - /^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/.test(trimmed) - ); + if (!trimmed) return; + if (isStandaloneMediaLine(trimmed)) { + flushTextChunk(); + mediaChunk.push(trimmed); + return; + } + flushMediaChunk(); + textChunk.push(trimmed); }); - if (standaloneMediaLines) { - htmlParts.push(paragraphLines.map((line) => renderInline(line.trim())).join('')); - } else { - htmlParts.push(`

${renderInline(joined)}

`); - } + + flushTextChunk(); + flushMediaChunk(); paragraphLines = []; }; @@ -1344,9 +1429,9 @@ })); } - function renderFlatBlocks(blocks) { + function renderFlatBlocks(blocks, imageSourceMap = null) { return (Array.isArray(blocks) ? blocks : []).map((item) => { - const body = renderBasicMarkdown((item.lines || []).join('\n').trim()); + const body = renderBasicMarkdown((item.lines || []).join('\n').trim(), imageSourceMap); const typeText = escapeHtml(item.type); const typeKey = String(item.type || '').trim().toLowerCase().replace(/\s+/g, ''); const typeAttr = escapeHtml(typeKey); @@ -1354,10 +1439,10 @@ }).join(''); } - function renderThinkContent(text, openAll) { + function renderThinkContent(text, openAll, imageSourceMap = null) { const sections = parseAgentSections(text); if (!sections.length) { - return renderBasicMarkdown(text); + return renderBasicMarkdown(text, imageSourceMap); } const renderThinkAgentSummary = (title) => { const safeTitle = escapeHtml(title); @@ -1378,7 +1463,7 @@ } return groups.map((group) => { const items = group.items.map((item) => { - const body = renderBasicMarkdown(item.lines.join('\n').trim()); + const body = renderBasicMarkdown(item.lines.join('\n').trim(), imageSourceMap); const typeText = escapeHtml(item.type); const typeKey = String(item.type || '').trim().toLowerCase().replace(/\s+/g, ''); const typeAttr = escapeHtml(typeKey); @@ -1396,7 +1481,7 @@ const synthetic = splitBlocksIntoSyntheticAgents(blocks); if (synthetic.length) { return synthetic.map((agent, agentIdx) => { - const inner = renderFlatBlocks(agent.blocks); + const inner = renderFlatBlocks(agent.blocks, imageSourceMap); const openAttr = openAll ? ' open' : (idx === 0 && agentIdx === 0 ? ' open' : ''); return `
${renderThinkAgentSummary(agent.title)}
${inner}
`; }).join(''); @@ -1404,7 +1489,7 @@ } const inner = blocks.length ? renderGroups(blocks, openAll) - : `
${renderBasicMarkdown(section.lines.join('\n').trim())}
`; + : `
${renderBasicMarkdown(section.lines.join('\n').trim(), imageSourceMap)}
`; if (!section.title) { return `
${inner}
`; } @@ -1414,16 +1499,16 @@ return `
${agentBlocks.join('')}
`; } - function renderMarkdown(text) { + function renderMarkdown(text, imageSourceMap = null) { const raw = text || ''; const parts = parseThinkSections(raw); return parts.map((part) => { if (part.type === 'think') { - const body = renderThinkContent(part.value.trim(), part.open); + const body = renderThinkContent(part.value.trim(), part.open, imageSourceMap); const openAttr = part.open ? ' open' : ''; return `
思考
${body || '(空)'}
`; } - return renderBasicMarkdown(part.value); + return renderBasicMarkdown(part.value, imageSourceMap); }).join(''); } @@ -1571,6 +1656,12 @@ } const wrapper = document.createElement('div'); wrapper.className = 'img-grid'; + if (group.length >= 4) { + wrapper.classList.add('img-grid--desktop-scroll'); + } + if (group.length >= 3) { + wrapper.classList.add('img-grid--mobile-scroll'); + } const cols = Math.min(4, group.length); wrapper.style.setProperty('--cols', String(cols)); if (groupStart) { @@ -1612,6 +1703,118 @@ }); } + function updateImageGridLayout(grid) { + if (!(grid instanceof HTMLElement)) return; + if (window.innerWidth <= 720) { + grid.style.removeProperty('grid-template-columns'); + return; + } + const figures = Array.from(grid.querySelectorAll(':scope > .message-image-card')); + if (figures.length < 2) { + grid.style.removeProperty('grid-template-columns'); + return; + } + const ratios = figures.map((figure) => { + const img = figure.querySelector('img'); + if (!(img instanceof HTMLImageElement)) return 1; + const naturalWidth = Number(img.naturalWidth || 0); + const naturalHeight = Number(img.naturalHeight || 0); + if (naturalWidth > 0 && naturalHeight > 0) { + return Math.max(0.72, Math.min(2.4, naturalWidth / naturalHeight)); + } + return 1; + }); + if (ratios.every((value) => Math.abs(value - 1) < 0.01)) { + grid.style.removeProperty('grid-template-columns'); + return; + } + const template = ratios.map((value) => `minmax(0, ${value.toFixed(4)}fr)`).join(' '); + grid.style.gridTemplateColumns = template; + } + + function syncImageGridLayouts(root) { + if (!root) return; + const grids = root instanceof Element && root.classList.contains('img-grid') + ? [root] + : Array.from(root.querySelectorAll ? root.querySelectorAll('.img-grid') : []); + grids.forEach((grid) => { + updateImageGridLayout(grid); + const images = Array.from(grid.querySelectorAll(':scope > .message-image-card img')); + images.forEach((img) => { + if (!(img instanceof HTMLImageElement) || img.dataset.gridBound === '1') return; + img.dataset.gridBound = '1'; + const refresh = () => updateImageGridLayout(grid); + img.addEventListener('load', refresh); + img.addEventListener('error', refresh); + }); + }); + } + + function syncImageGridControls(root) { + if (!root) return; + const grids = root instanceof Element && root.classList.contains('img-grid') + ? [root] + : Array.from(root.querySelectorAll ? root.querySelectorAll('.img-grid') : []); + grids.forEach((grid) => { + if (!(grid instanceof HTMLElement)) return; + let shell = grid.parentElement; + if (!(shell instanceof HTMLElement) || !shell.classList.contains('img-grid-shell')) { + shell = document.createElement('div'); + shell.className = 'img-grid-shell'; + grid.parentNode && grid.parentNode.insertBefore(shell, grid); + shell.appendChild(grid); + } + let controls = shell.querySelector(':scope > .img-grid-controls'); + if (!(controls instanceof HTMLElement)) { + controls = document.createElement('div'); + controls.className = 'img-grid-controls'; + controls.innerHTML = [ + '', + '' + ].join(''); + shell.appendChild(controls); + } + const prevBtn = controls.querySelector('.img-grid-nav--prev'); + const nextBtn = controls.querySelector('.img-grid-nav--next'); + const updateState = () => { + const maxScrollLeft = Math.max(0, grid.scrollWidth - grid.clientWidth); + const scrollable = maxScrollLeft > 8; + controls.hidden = !scrollable; + shell.classList.toggle('is-scrollable', scrollable); + if (!(prevBtn instanceof HTMLButtonElement) || !(nextBtn instanceof HTMLButtonElement)) return; + prevBtn.disabled = !scrollable || grid.scrollLeft <= 8; + nextBtn.disabled = !scrollable || grid.scrollLeft >= (maxScrollLeft - 8); + }; + const bindButton = (button, direction) => { + if (!(button instanceof HTMLButtonElement) || button.dataset.bound === '1') return; + button.dataset.bound = '1'; + button.addEventListener('click', () => { + const items = Array.from(grid.querySelectorAll(':scope > .message-image-card')); + if (!items.length) return; + const positions = items.map((item) => item.offsetLeft).sort((a, b) => a - b); + const current = grid.scrollLeft; + const maxScrollLeft = Math.max(0, grid.scrollWidth - grid.clientWidth); + let target = current; + if (direction > 0) { + target = positions.find((value) => value > current + 24) ?? maxScrollLeft; + } else { + const previous = positions.filter((value) => value < current - 24); + target = previous.length ? previous[previous.length - 1] : 0; + } + target = Math.max(0, Math.min(maxScrollLeft, target)); + grid.scrollTo({ left: target, behavior: 'smooth' }); + }); + }; + bindButton(prevBtn, -1); + bindButton(nextBtn, 1); + if (grid.dataset.controlsBound !== '1') { + grid.dataset.controlsBound = '1'; + grid.addEventListener('scroll', updateState, { passive: true }); + } + requestAnimationFrame(updateState); + }); + } + function liftThinkImages(root) { if (!root || !root.querySelectorAll) return; const thinkBlocks = Array.from(root.querySelectorAll('.think-block')); @@ -1737,12 +1940,15 @@ entry.hasThink = true; } const renderText = entry.role === 'assistant' ? getRenderableAssistantText(entry) : entry.raw; + const imageSourceMap = entry.role === 'assistant' + ? buildRenderedImageSourceMap(entry.rendering) + : null; if (finalize) { entry.contentNode.classList.add('rendered'); - entry.contentNode.innerHTML = renderMarkdown(renderText); + entry.contentNode.innerHTML = renderMarkdown(renderText, imageSourceMap); } else { if (entry.role === 'assistant') { - entry.contentNode.innerHTML = renderMarkdown(renderText); + entry.contentNode.innerHTML = renderMarkdown(renderText, imageSourceMap); } else { entry.contentNode.textContent = entry.raw; } @@ -1765,6 +1971,8 @@ if (entry.role === 'assistant' || entry.role === 'user') { liftThinkImages(entry.contentNode); applyImageGrid(entry.contentNode); + syncImageGridLayouts(entry.contentNode); + syncImageGridControls(entry.contentNode); enhanceBrokenImages(entry.contentNode); bindMessageImagePreview(entry.contentNode); bindInlineCitationExpand(entry.contentNode); @@ -1799,8 +2007,18 @@ if (img.dataset.retryBound) return; img.dataset.retryBound = '1'; img.addEventListener('error', () => { + const fallbackSrc = String(img.dataset.fallbackSrc || '').trim(); + if (fallbackSrc && img.dataset.fallbackTried !== '1') { + img.dataset.fallbackTried = '1'; + img.src = fallbackSrc; + return; + } if (img.dataset.failed) return; img.dataset.failed = '1'; + const figure = img.closest('.message-image-card'); + if (figure) { + figure.classList.add('is-broken'); + } const wrapper = document.createElement('button'); wrapper.type = 'button'; wrapper.className = 'img-retry'; @@ -1810,6 +2028,9 @@ const original = img.getAttribute('src') || ''; const cacheBust = original.includes('?') ? '&' : '?'; img.dataset.failed = ''; + if (figure) { + figure.classList.remove('is-broken'); + } img.src = `${original}${cacheBust}t=${Date.now()}`; }); img.replaceWith(wrapper); @@ -1818,6 +2039,10 @@ if (img.dataset.failed) { img.dataset.failed = ''; } + const figure = img.closest('.message-image-card'); + if (figure) { + figure.classList.remove('is-broken'); + } }); }); } @@ -1878,6 +2103,78 @@ return String(value || '').replace(/\s+/g, ' ').trim(); } + function decodeHtmlEntities(value) { + const raw = String(value || ''); + if (!raw || !/[&][a-zA-Z#0-9]+;/.test(raw)) return raw; + const textarea = document.createElement('textarea'); + textarea.innerHTML = raw; + return textarea.value; + } + + function replaceMarkdownImages(value, renderImage) { + const text = String(value || ''); + if (!text.includes('![')) return text; + let result = ''; + let index = 0; + + while (index < text.length) { + const start = text.indexOf('![', index); + if (start === -1) { + result += text.slice(index); + break; + } + + result += text.slice(index, start); + + const altEnd = text.indexOf(']', start + 2); + if (altEnd === -1) { + result += text.slice(start); + break; + } + + let cursor = altEnd + 1; + while (cursor < text.length && text[cursor] !== '\n' && text[cursor] !== '(') { + cursor += 1; + } + const middle = text.slice(altEnd + 1, cursor); + + if (cursor >= text.length || text[cursor] !== '(') { + result += text.slice(start, cursor); + index = cursor; + continue; + } + + let depth = 0; + let end = cursor; + for (; end < text.length; end += 1) { + const ch = text[end]; + if (ch === '(') { + depth += 1; + } else if (ch === ')') { + depth -= 1; + if (depth === 0) break; + } + } + + if (end >= text.length || text[end] !== ')') { + result += text.slice(start); + break; + } + + const alt = text.slice(start + 2, altEnd); + const url = text.slice(cursor + 1, end); + result += renderImage({ + alt, + middle, + url, + raw: text.slice(start, end + 1) + }); + index = end + 1; + } + + return result; + } + function getSourceHostname(url) { try { return new URL(url).hostname.replace(/^www\./i, ''); @@ -1891,6 +2188,17 @@ return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=256`; } + function getImageSourceLabel(url) { + const raw = String(url || '').trim(); + if (!raw || raw.startsWith('data:')) return ''; + try { + const parsed = new URL(raw, window.location.origin); + return String(parsed.hostname || '').replace(/^www\./i, ''); + } catch (e) { + return ''; + } + } + function bindInlineCitationExpand(root) { if (!root || root.dataset.citationExpandBound === '1') return; root.dataset.citationExpandBound = '1'; @@ -3069,6 +3377,12 @@ closeChatImagePreview(); } }); + window.addEventListener('resize', () => { + document.querySelectorAll('.message-content .img-grid').forEach((grid) => { + updateImageGridLayout(grid); + }); + syncImageGridControls(document); + }); const composerInput = document.querySelector('.composer-input'); if (composerInput) { diff --git a/config.defaults.toml b/config.defaults.toml index 06f4207..ec77492 100644 --- a/config.defaults.toml +++ b/config.defaults.toml @@ -99,6 +99,10 @@ limit_mb = 512 [chat] # Reverse 接口并发上限 concurrent = 50 +# 是否将 app-chat 上游关键响应写入抓包文件 +capture_enabled = true +# app-chat 详细抓包文件路径(JSONL) +capture_file = "logs/app_chat_capture.jsonl" # 连接超时时间(秒),用于弱网快速失败 connect_timeout = 12 # Reverse 接口超时时间(秒)