feat: 完善图片来源与画廊展示
This commit is contained in:
1 parent
e217f7cc7e
commit
e08244ed20
5 files changed
+703
-81
No files matched your search
@@ -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", "")
|
||||
|
||||
@@ -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
@@ -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
@@ -717,6 +717,35 @@
|
||||
return `\n\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) {
|
||||
|
||||
@@ -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 接口超时时间(秒)
|
||||
|
||||
Reference in new issue
Block a user