feat: 完善图片来源与画廊展示

This commit is contained in:
XianYuDaXian committed 2026-03-28 10:26:16 +08:00
1 parent e217f7cc7e
commit e08244ed20
5 files changed
+703 -81

No files matched your search

+98 -1
View File
@@ -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 <think> 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", "")
+64 -29
View File
@@ -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():
+190 -18
View File
@@ -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;
}
}
+347 -33
View File
@@ -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, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
const safeAlt = escapeHtml(alt || 'image');
const safeUrl = escapeHtml(url || '');
const caption = safeAlt && safeAlt !== 'image'
? `<figcaption class="message-image-caption">${safeAlt}</figcaption>`
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'
? `<figcaption class="message-image-caption">${escapeHtml(mergedAlt)}</figcaption>`
: '';
return `<figure class="message-image-card"><img src="${safeUrl}" alt="${safeAlt}" loading="lazy">${caption}</figure>`;
const sourceBadge = sourceLabel
? `<a class="message-image-source" href="${sourceHref}" target="_blank" rel="noopener noreferrer" title="${sourceHref}">${sourceLabel}</a>`
: '';
const fallbackAttr = fallbackSrc ? ` data-fallback-src="${fallbackSrc}"` : '';
return `<figure class="message-image-card"><img src="${safeUrl}" alt="${safeAlt}" loading="lazy" referrerpolicy="no-referrer" crossorigin="anonymous"${fallbackAttr}>${sourceBadge}${caption}</figure>`;
});
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 `<a href="${safeUrl}" target="_blank" rel="noopener">${safeLabel}</a>`;
});
@@ -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('</ul>');
@@ -1112,20 +1182,35 @@
const flushParagraph = () => {
if (!paragraphLines.length) return;
const joined = paragraphLines.join('<br>');
const standaloneMediaLines = paragraphLines.every((line) => {
let textChunk = [];
let mediaChunk = [];
const flushTextChunk = () => {
if (!textChunk.length) return;
htmlParts.push(`<p>${renderInline(textChunk.join('<br>'))}</p>`);
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(`<p>${renderInline(joined)}</p>`);
}
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 `<details class="think-agent"${openAttr}>${renderThinkAgentSummary(agent.title)}<div class="think-agent-items">${inner}</div></details>`;
}).join('');
@@ -1404,7 +1489,7 @@
}
const inner = blocks.length
? renderGroups(blocks, openAll)
: `<div class="think-rollout-body">${renderBasicMarkdown(section.lines.join('\n').trim())}</div>`;
: `<div class="think-rollout-body">${renderBasicMarkdown(section.lines.join('\n').trim(), imageSourceMap)}</div>`;
if (!section.title) {
return `<div class="think-agent-items">${inner}</div>`;
}
@@ -1414,16 +1499,16 @@
return `<div class="think-agents">${agentBlocks.join('')}</div>`;
}
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 `<details class="think-block" data-think="true"${openAttr}><summary class="think-summary">思考</summary><div class="think-content">${body || '<em>(空)</em>'}</div></details>`;
}
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 = [
'<button type="button" class="img-grid-nav img-grid-nav--prev" aria-label="查看上一张"><span class="img-grid-nav__icon" aria-hidden="true"><svg viewBox="0 0 20 20" focusable="false"><path d="M12.5 4.5L7 10l5.5 5.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></button>',
'<button type="button" class="img-grid-nav img-grid-nav--next" aria-label="查看下一张"><span class="img-grid-nav__icon" aria-hidden="true"><svg viewBox="0 0 20 20" focusable="false"><path d="M7.5 4.5L13 10l-5.5 5.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></button>'
].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) {
+4
View File
@@ -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 接口超时时间(秒)