3479 lines
121 KiB
JavaScript
3479 lines
121 KiB
JavaScript
(() => {
|
||
const modelSelect = document.getElementById('modelSelect');
|
||
const modelPicker = document.getElementById('modelPicker');
|
||
const modelPickerBtn = document.getElementById('modelPickerBtn');
|
||
const modelPickerLabel = document.getElementById('modelPickerLabel');
|
||
const modelPickerMenu = document.getElementById('modelPickerMenu');
|
||
const reasoningSelect = document.getElementById('reasoningSelect');
|
||
const tempRange = document.getElementById('tempRange');
|
||
const tempValue = document.getElementById('tempValue');
|
||
const topPRange = document.getElementById('topPRange');
|
||
const topPValue = document.getElementById('topPValue');
|
||
const systemInput = document.getElementById('systemInput');
|
||
const promptInput = document.getElementById('promptInput');
|
||
const sendBtn = document.getElementById('sendBtn');
|
||
const settingsToggle = document.getElementById('settingsToggle');
|
||
const settingsPanel = document.getElementById('settingsPanel');
|
||
const chatLog = document.getElementById('chatLog');
|
||
const emptyState = document.getElementById('emptyState');
|
||
const statusText = document.getElementById('statusText');
|
||
const attachBtn = document.getElementById('attachBtn');
|
||
const fileInput = document.getElementById('fileInput');
|
||
const fileBadge = document.getElementById('fileBadge');
|
||
const chatSidebar = document.getElementById('chatSidebar');
|
||
const sidebarOverlay = document.getElementById('sidebarOverlay');
|
||
const sidebarToggle = document.getElementById('sidebarToggle');
|
||
const newChatBtn = document.getElementById('newChatBtn');
|
||
const collapseSidebarBtn = document.getElementById('collapseSidebarBtn');
|
||
const sidebarExpandBtn = document.getElementById('sidebarExpandBtn');
|
||
const sessionListEl = document.getElementById('sessionList');
|
||
|
||
let messageHistory = [];
|
||
let isSending = false;
|
||
let abortController = null;
|
||
let attachments = [];
|
||
let availableModels = [];
|
||
let activeStreamInfo = null;
|
||
let sessionsData = null;
|
||
let followStreamScroll = true;
|
||
let suppressScrollTracking = false;
|
||
let userLockedStreamScroll = false;
|
||
const activeThinkSpinEntries = new Set();
|
||
let thinkSpinRafId = 0;
|
||
const feedbackUrl = 'https://github.com/chenyme/grok2api/issues/new';
|
||
const STORAGE_KEY = 'grok2api_chat_sessions';
|
||
const SIDEBAR_STATE_KEY = 'grok2api_chat_sidebar_collapsed';
|
||
const MAX_CONTEXT_MESSAGES = 30;
|
||
const AUTO_SCROLL_THRESHOLD = 48;
|
||
const DEFAULT_SESSION_TITLES = ['新会话', 'New Session'];
|
||
const SEND_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"></path><path d="M22 2L15 22L11 13L2 9L22 2Z"></path></svg>';
|
||
const STOP_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"></rect></svg>';
|
||
|
||
function generateId() {
|
||
return crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||
}
|
||
|
||
function isDefaultTitleValue(title) {
|
||
return DEFAULT_SESSION_TITLES.includes(title);
|
||
}
|
||
|
||
function getMessageDisplay(msg) {
|
||
if (!msg) return '';
|
||
if (typeof msg.content === 'string') return msg.content;
|
||
if (typeof msg.display === 'string' && msg.display.trim()) return msg.display;
|
||
if (Array.isArray(msg.content)) {
|
||
const textParts = [];
|
||
let fileCount = 0;
|
||
for (const block of msg.content) {
|
||
if (!block) continue;
|
||
if (block.type === 'text' && block.text) {
|
||
textParts.push(block.text);
|
||
}
|
||
if (block.type === 'file') {
|
||
fileCount += 1;
|
||
}
|
||
}
|
||
const suffix = fileCount > 0 ? `\n[文件] ${fileCount} 个` : '';
|
||
return `${textParts.join('\n')}${suffix}`.trim() || '(复合内容)';
|
||
}
|
||
return '(复合内容)';
|
||
}
|
||
|
||
function serializeMessage(msg) {
|
||
if (!msg || typeof msg !== 'object') return msg;
|
||
return msg;
|
||
}
|
||
|
||
function saveSessions() {
|
||
if (!sessionsData) return;
|
||
const snapshot = {
|
||
activeId: sessionsData.activeId,
|
||
sessions: sessionsData.sessions.map((session) => ({
|
||
...session,
|
||
messages: Array.isArray(session.messages) ? session.messages.map(serializeMessage) : []
|
||
}))
|
||
};
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||
} catch (e) {
|
||
toast('会话保存失败,可能是浏览器存储空间不足', 'error');
|
||
}
|
||
}
|
||
|
||
function getActiveSession() {
|
||
if (!sessionsData) return null;
|
||
return sessionsData.sessions.find((s) => s.id === sessionsData.activeId) || null;
|
||
}
|
||
|
||
function trimMessageHistory(maxCount = MAX_CONTEXT_MESSAGES) {
|
||
if (!maxCount || maxCount <= 0) return;
|
||
if (messageHistory.length <= maxCount) return;
|
||
messageHistory = messageHistory.slice(-maxCount);
|
||
const session = getActiveSession();
|
||
if (session) {
|
||
session.messages = messageHistory.slice();
|
||
session.updatedAt = Date.now();
|
||
saveSessions();
|
||
renderSessionList();
|
||
}
|
||
}
|
||
|
||
function restoreActiveSession() {
|
||
const session = getActiveSession();
|
||
if (!session) return;
|
||
messageHistory = Array.isArray(session.messages) ? session.messages.slice() : [];
|
||
trimMessageHistory();
|
||
if (chatLog) chatLog.innerHTML = '';
|
||
if (!messageHistory.length) {
|
||
showEmptyState();
|
||
return;
|
||
}
|
||
hideEmptyState();
|
||
for (const msg of messageHistory) {
|
||
const text = getMessageDisplay(msg);
|
||
const entry = createMessage(msg.role, text);
|
||
if (entry && msg.role === 'assistant') {
|
||
entry.sources = msg.sources || null;
|
||
entry.rendering = msg.rendering || null;
|
||
updateMessage(entry, text, true);
|
||
} else if (entry && msg.role === 'user') {
|
||
let msgAttachments = [];
|
||
if (Array.isArray(msg.content)) {
|
||
msgAttachments = msg.content
|
||
.filter(b => b && (b.type === 'image_url' || b.type === 'file'))
|
||
.map(b => {
|
||
if (b.type === 'image_url' && b.image_url) return { mime: 'image/jpeg', name: 'image', data: b.image_url.url };
|
||
if (b.type === 'file') return { mime: b.mime || '', name: b.name || 'file', data: b.data || b.url };
|
||
return null;
|
||
}).filter(Boolean);
|
||
}
|
||
renderUserMessage(entry, text, msgAttachments);
|
||
}
|
||
}
|
||
if (activeStreamInfo && activeStreamInfo.sessionId === session.id && activeStreamInfo.entry.row) {
|
||
chatLog.appendChild(activeStreamInfo.entry.row);
|
||
}
|
||
scrollToBottom();
|
||
}
|
||
|
||
function syncCurrentSession() {
|
||
const session = getActiveSession();
|
||
if (!session) return;
|
||
session.messages = messageHistory.slice();
|
||
session.updatedAt = Date.now();
|
||
}
|
||
|
||
function updateSessionTitle(session) {
|
||
if (!session || session.isDefaultTitle === false) return;
|
||
const firstUser = session.messages.find((m) => m.role === 'user');
|
||
if (!firstUser) return;
|
||
const text = getMessageDisplay(firstUser);
|
||
if (!text) return;
|
||
const title = text.replace(/\n/g, ' ').trim().slice(0, 20);
|
||
if (title) {
|
||
session.title = title;
|
||
session.isDefaultTitle = false;
|
||
}
|
||
}
|
||
|
||
function renameSession(id, newTitle) {
|
||
if (!sessionsData) return;
|
||
const session = sessionsData.sessions.find((s) => s.id === id);
|
||
if (!session) return;
|
||
const trimmed = (newTitle || '').trim();
|
||
session.title = trimmed || '新会话';
|
||
session.isDefaultTitle = !trimmed && isDefaultTitleValue(session.title);
|
||
session.updatedAt = Date.now();
|
||
saveSessions();
|
||
renderSessionList();
|
||
}
|
||
|
||
function startRenameSession(sessionId, titleSpan) {
|
||
if (!sessionsData) return;
|
||
const session = sessionsData.sessions.find((s) => s.id === sessionId);
|
||
if (!session) return;
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'session-rename-input';
|
||
input.value = session.title || '';
|
||
input.maxLength = 40;
|
||
titleSpan.replaceWith(input);
|
||
input.focus();
|
||
input.select();
|
||
const commit = () => renameSession(sessionId, input.value);
|
||
input.addEventListener('blur', commit);
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
input.blur();
|
||
}
|
||
if (e.key === 'Escape') {
|
||
input.value = session.title || '新会话';
|
||
input.blur();
|
||
}
|
||
});
|
||
}
|
||
|
||
function syncSessionModel() {
|
||
const session = getActiveSession();
|
||
if (!session) return;
|
||
session.model = (modelSelect && modelSelect.value) || '';
|
||
}
|
||
|
||
function restoreSessionModel() {
|
||
const session = getActiveSession();
|
||
if (!session || !session.model || !Array.isArray(availableModels)) return;
|
||
if (availableModels.includes(session.model)) {
|
||
setModelValue(session.model);
|
||
}
|
||
}
|
||
|
||
function renderSessionList() {
|
||
if (!sessionListEl || !sessionsData) return;
|
||
sessionListEl.innerHTML = '';
|
||
for (const session of sessionsData.sessions) {
|
||
const item = document.createElement('div');
|
||
item.className = `session-item${session.id === sessionsData.activeId ? ' active' : ''}`;
|
||
item.dataset.id = session.id;
|
||
|
||
const titleSpan = document.createElement('span');
|
||
titleSpan.className = 'session-title';
|
||
titleSpan.textContent = session.title || '新会话';
|
||
titleSpan.title = titleSpan.textContent;
|
||
titleSpan.addEventListener('dblclick', (e) => {
|
||
e.stopPropagation();
|
||
startRenameSession(session.id, titleSpan);
|
||
});
|
||
item.appendChild(titleSpan);
|
||
|
||
if (session.unread && session.id !== sessionsData.activeId) {
|
||
const dot = document.createElement('span');
|
||
dot.className = 'session-unread';
|
||
item.appendChild(dot);
|
||
}
|
||
|
||
const delBtn = document.createElement('button');
|
||
delBtn.type = 'button';
|
||
delBtn.className = 'session-delete';
|
||
delBtn.title = '删除';
|
||
delBtn.textContent = '×';
|
||
delBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
deleteSession(session.id);
|
||
});
|
||
item.appendChild(delBtn);
|
||
|
||
item.addEventListener('click', () => switchSession(session.id));
|
||
sessionListEl.appendChild(item);
|
||
}
|
||
}
|
||
|
||
function createSession() {
|
||
if (!sessionsData) return;
|
||
const id = generateId();
|
||
const session = {
|
||
id,
|
||
title: '新会话',
|
||
isDefaultTitle: true,
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now(),
|
||
messages: []
|
||
};
|
||
sessionsData.sessions.unshift(session);
|
||
sessionsData.activeId = id;
|
||
messageHistory = [];
|
||
if (chatLog) chatLog.innerHTML = '';
|
||
showEmptyState();
|
||
saveSessions();
|
||
renderSessionList();
|
||
if (isMobileSidebar()) closeSidebar();
|
||
}
|
||
|
||
function deleteSession(id) {
|
||
if (!sessionsData) return;
|
||
const idx = sessionsData.sessions.findIndex((s) => s.id === id);
|
||
if (idx === -1) return;
|
||
sessionsData.sessions.splice(idx, 1);
|
||
if (!sessionsData.sessions.length) {
|
||
createSession();
|
||
return;
|
||
}
|
||
if (sessionsData.activeId === id) {
|
||
const newIdx = Math.min(idx, sessionsData.sessions.length - 1);
|
||
sessionsData.activeId = sessionsData.sessions[newIdx].id;
|
||
restoreActiveSession();
|
||
restoreSessionModel();
|
||
}
|
||
saveSessions();
|
||
renderSessionList();
|
||
}
|
||
|
||
function switchSession(id) {
|
||
if (!sessionsData || sessionsData.activeId === id) return;
|
||
syncCurrentSession();
|
||
syncSessionModel();
|
||
sessionsData.activeId = id;
|
||
const target = getActiveSession();
|
||
if (target) target.unread = false;
|
||
restoreActiveSession();
|
||
restoreSessionModel();
|
||
saveSessions();
|
||
renderSessionList();
|
||
if (isMobileSidebar()) closeSidebar();
|
||
}
|
||
|
||
function isMobileSidebar() {
|
||
return window.matchMedia('(max-width: 1024px)').matches;
|
||
}
|
||
|
||
function setSidebarCollapsed(collapsed) {
|
||
const layout = chatSidebar ? chatSidebar.closest('.chat-layout') : null;
|
||
if (!layout) return;
|
||
layout.classList.toggle('collapsed', collapsed);
|
||
try {
|
||
localStorage.setItem(SIDEBAR_STATE_KEY, collapsed ? '1' : '0');
|
||
} catch (e) {
|
||
// ignore storage errors
|
||
}
|
||
}
|
||
|
||
function openSidebar() {
|
||
if (isMobileSidebar()) {
|
||
if (chatSidebar) chatSidebar.classList.add('open');
|
||
if (sidebarOverlay) sidebarOverlay.classList.add('open');
|
||
return;
|
||
}
|
||
setSidebarCollapsed(false);
|
||
}
|
||
|
||
function closeSidebar() {
|
||
if (isMobileSidebar()) {
|
||
if (chatSidebar) chatSidebar.classList.remove('open');
|
||
if (sidebarOverlay) sidebarOverlay.classList.remove('open');
|
||
return;
|
||
}
|
||
setSidebarCollapsed(true);
|
||
}
|
||
|
||
function toggleSidebar() {
|
||
if (isMobileSidebar()) {
|
||
if (chatSidebar && chatSidebar.classList.contains('open')) {
|
||
closeSidebar();
|
||
} else {
|
||
openSidebar();
|
||
}
|
||
return;
|
||
}
|
||
const layout = chatSidebar ? chatSidebar.closest('.chat-layout') : null;
|
||
if (!layout) return;
|
||
setSidebarCollapsed(!layout.classList.contains('collapsed'));
|
||
}
|
||
|
||
function restoreSidebarState() {
|
||
try {
|
||
const raw = localStorage.getItem(SIDEBAR_STATE_KEY);
|
||
setSidebarCollapsed(raw === '1');
|
||
} catch (e) {
|
||
// ignore storage errors
|
||
}
|
||
}
|
||
|
||
function loadSessions() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (raw) {
|
||
sessionsData = JSON.parse(raw);
|
||
if (!sessionsData || !Array.isArray(sessionsData.sessions)) {
|
||
sessionsData = null;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
sessionsData = null;
|
||
}
|
||
if (!sessionsData || !sessionsData.sessions.length) {
|
||
const id = generateId();
|
||
sessionsData = {
|
||
activeId: id,
|
||
sessions: [{
|
||
id,
|
||
title: '新会话',
|
||
isDefaultTitle: true,
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now(),
|
||
messages: []
|
||
}]
|
||
};
|
||
saveSessions();
|
||
}
|
||
sessionsData.sessions.forEach((session) => {
|
||
if (session && typeof session.isDefaultTitle === 'undefined') {
|
||
session.isDefaultTitle = isDefaultTitleValue(session.title);
|
||
}
|
||
if (!Array.isArray(session.messages)) {
|
||
session.messages = [];
|
||
}
|
||
});
|
||
if (!sessionsData.activeId || !sessionsData.sessions.find((s) => s.id === sessionsData.activeId)) {
|
||
sessionsData.activeId = sessionsData.sessions[0].id;
|
||
}
|
||
restoreActiveSession();
|
||
restoreSessionModel();
|
||
renderSessionList();
|
||
}
|
||
|
||
function toast(message, type) {
|
||
if (typeof showToast === 'function') {
|
||
showToast(message, type);
|
||
}
|
||
}
|
||
|
||
async function parseApiError(res) {
|
||
let text = '';
|
||
try {
|
||
text = await res.text();
|
||
} catch (e) {
|
||
text = '';
|
||
}
|
||
|
||
let message = `请求失败: ${res.status}`;
|
||
let code = '';
|
||
let param = '';
|
||
if (text) {
|
||
try {
|
||
const data = JSON.parse(text);
|
||
const err = data && typeof data === 'object' && data.error ? data.error : data;
|
||
if (err && typeof err === 'object') {
|
||
message = String(err.message || message);
|
||
code = String(err.code || '');
|
||
param = String(err.param || '');
|
||
} else if (typeof data === 'string' && data.trim()) {
|
||
message = data.trim();
|
||
}
|
||
} catch (e) {
|
||
const plain = text.trim();
|
||
if (plain) message = plain;
|
||
}
|
||
}
|
||
|
||
if (code === 'content_moderated' || /content[- ]moderated/i.test(message)) {
|
||
message = '图片内容触发审核限制,无法上传。请更换图片后重试。';
|
||
}
|
||
|
||
const err = new Error(message);
|
||
err.status = res.status;
|
||
err.code = code;
|
||
err.param = param;
|
||
err.raw = text;
|
||
return err;
|
||
}
|
||
|
||
function setStatus(state, text) {
|
||
if (!statusText) return;
|
||
statusText.textContent = text || '就绪';
|
||
statusText.classList.remove('connected', 'connecting', 'error');
|
||
if (state) statusText.classList.add(state);
|
||
}
|
||
|
||
function setSendingState(sending) {
|
||
isSending = sending;
|
||
if (!sendBtn) return;
|
||
sendBtn.disabled = false;
|
||
sendBtn.classList.toggle('is-abort', sending);
|
||
sendBtn.setAttribute('aria-label', sending ? 'Abort' : 'Send');
|
||
sendBtn.innerHTML = sending ? STOP_ICON : SEND_ICON;
|
||
}
|
||
|
||
function abortCurrentRequest() {
|
||
if (!isSending || !abortController) return false;
|
||
try {
|
||
abortController.abort();
|
||
} catch (e) {
|
||
// ignore abort races
|
||
}
|
||
setStatus('error', '已中止');
|
||
return true;
|
||
}
|
||
|
||
function updateRangeValues() {
|
||
if (tempValue && tempRange) {
|
||
tempValue.textContent = Number(tempRange.value).toFixed(2);
|
||
}
|
||
if (topPValue && topPRange) {
|
||
topPValue.textContent = Number(topPRange.value).toFixed(2);
|
||
}
|
||
}
|
||
|
||
function getScrollContainer() {
|
||
const body = document.scrollingElement || document.documentElement;
|
||
if (!body) return null;
|
||
const hasOwnScroll = chatLog && chatLog.scrollHeight > chatLog.clientHeight + 1;
|
||
return hasOwnScroll ? chatLog : body;
|
||
}
|
||
|
||
function isNearScrollBottom() {
|
||
const container = getScrollContainer();
|
||
if (!container) return true;
|
||
const remaining = container.scrollHeight - (container.scrollTop + container.clientHeight);
|
||
return remaining <= AUTO_SCROLL_THRESHOLD;
|
||
}
|
||
|
||
function updateFollowStreamScroll() {
|
||
if (userLockedStreamScroll) {
|
||
followStreamScroll = false;
|
||
return;
|
||
}
|
||
followStreamScroll = isNearScrollBottom();
|
||
}
|
||
|
||
function lockStreamScrollFollow() {
|
||
if (!isSending) return;
|
||
userLockedStreamScroll = true;
|
||
followStreamScroll = false;
|
||
}
|
||
|
||
function scrollToBottom(force = false) {
|
||
const container = getScrollContainer();
|
||
if (!container) return;
|
||
if (!force && !followStreamScroll) return;
|
||
suppressScrollTracking = true;
|
||
container.scrollTop = container.scrollHeight;
|
||
requestAnimationFrame(() => {
|
||
suppressScrollTracking = false;
|
||
updateFollowStreamScroll();
|
||
});
|
||
}
|
||
|
||
function hideEmptyState() {
|
||
if (emptyState) emptyState.classList.add('hidden');
|
||
}
|
||
|
||
function showEmptyState() {
|
||
if (emptyState) emptyState.classList.remove('hidden');
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return value
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function closeChatImagePreview() {
|
||
const overlay = document.getElementById('chatImagePreviewOverlay');
|
||
if (!overlay) return;
|
||
overlay.remove();
|
||
}
|
||
|
||
function openChatImagePreview(src, name) {
|
||
if (!src) return;
|
||
const opened = document.getElementById('chatImagePreviewOverlay');
|
||
if (opened && opened.dataset.src === src) {
|
||
closeChatImagePreview();
|
||
return;
|
||
}
|
||
closeChatImagePreview();
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'chatImagePreviewOverlay';
|
||
overlay.className = 'chat-image-preview-overlay';
|
||
overlay.dataset.src = src;
|
||
|
||
const img = document.createElement('img');
|
||
img.className = 'chat-image-preview-image';
|
||
img.src = src;
|
||
img.alt = name || 'image';
|
||
img.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
});
|
||
|
||
overlay.appendChild(img);
|
||
overlay.addEventListener('click', () => closeChatImagePreview());
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
function bindMessageImagePreview(root) {
|
||
if (!root || !root.querySelectorAll) return;
|
||
const userImageButtons = root.querySelectorAll('.user-image-btn');
|
||
userImageButtons.forEach((btn) => {
|
||
if (btn.dataset.previewBound === '1') return;
|
||
btn.dataset.previewBound = '1';
|
||
btn.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const src = btn.dataset.previewSrc || '';
|
||
const name = btn.dataset.previewName || 'image';
|
||
openChatImagePreview(src, name);
|
||
});
|
||
});
|
||
|
||
const images = root.querySelectorAll('img');
|
||
images.forEach((img) => {
|
||
if (img.dataset.previewBound === '1') return;
|
||
img.dataset.previewBound = '1';
|
||
img.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const src = img.getAttribute('src') || '';
|
||
const name = img.getAttribute('alt') || 'image';
|
||
if (!src) return;
|
||
openChatImagePreview(src, name);
|
||
});
|
||
});
|
||
}
|
||
|
||
function getInlineCitationLabel(link) {
|
||
if (!link || typeof link !== 'object') return '';
|
||
const hostname = normalizeSourceText(link.hostname || getSourceHostname(link.href || ''));
|
||
if (hostname) return hostname;
|
||
const label = normalizeSourceText(link.label || '');
|
||
if (label) return label;
|
||
return normalizeSourceText(link.href || '');
|
||
}
|
||
|
||
function buildInlineCitationChip(links) {
|
||
const items = Array.isArray(links) ? links.filter(Boolean) : [];
|
||
if (!items.length) return '';
|
||
const first = items[0];
|
||
const href = escapeHtml(String(first.href || '').trim());
|
||
const label = escapeHtml(getInlineCitationLabel(first));
|
||
if (!href || !label) return '';
|
||
const extraCount = items.length - 1;
|
||
const titles = items
|
||
.map((item) => normalizeSourceText(item.label || item.hostname || item.href || ''))
|
||
.filter(Boolean);
|
||
const titleAttr = titles.length ? ` title="${escapeHtml(titles.join('\n'))}"` : '';
|
||
if (extraCount <= 0) {
|
||
return `<span class="inline print-hidden"><span class="inline"><a href="${href}" target="_blank" rel="noopener noreferrer nofollow" class="citation inline-citation-chip no-copy inline text-nowrap print-hidden"${titleAttr} data-state="closed"><span class="inline-citation-chip__label">${label}</span></a></span></span>`;
|
||
}
|
||
const payload = escapeHtml(encodeURIComponent(JSON.stringify(items.map((item) => ({
|
||
href: String(item && item.href || '').trim(),
|
||
hostname: normalizeSourceText(item && item.hostname || ''),
|
||
label: normalizeSourceText(item && item.label || '')
|
||
})))));
|
||
return `<span class="inline print-hidden"><span class="inline"><a href="${href}" target="_blank" rel="noopener noreferrer nofollow" class="citation inline-citation-chip inline-citation-cluster no-copy inline text-nowrap print-hidden" data-state="closed" data-citation-links="${payload}"${titleAttr}><span class="inline-citation-chip__label">${label}</span><span class="inline-citation-chip__count">+${extraCount}</span></a></span></span>`;
|
||
}
|
||
|
||
function expandInlineCitationCluster(cluster) {
|
||
if (!(cluster instanceof HTMLElement)) return;
|
||
if (cluster.dataset.expanded === '1') return;
|
||
const raw = cluster.dataset.citationLinks || '';
|
||
if (!raw) return;
|
||
try {
|
||
const links = JSON.parse(decodeURIComponent(raw));
|
||
if (!Array.isArray(links) || !links.length) return;
|
||
const expanded = links
|
||
.map((item) => buildInlineCitationChip([item]))
|
||
.filter(Boolean)
|
||
.join('');
|
||
if (!expanded) return;
|
||
const wrapper = document.createElement('span');
|
||
wrapper.className = 'inline print-hidden inline-citation-cluster-expanded';
|
||
wrapper.innerHTML = expanded;
|
||
cluster.replaceWith(wrapper);
|
||
} catch (e) {
|
||
// ignore malformed payload
|
||
}
|
||
}
|
||
|
||
function parseRenderingCards(rendering) {
|
||
const rawModelResponse = rendering && rendering.rawModelResponse && typeof rendering.rawModelResponse === 'object'
|
||
? rendering.rawModelResponse
|
||
: null;
|
||
const rawCards = Array.isArray(rawModelResponse && rawModelResponse.cardAttachmentsJson)
|
||
? rawModelResponse.cardAttachmentsJson
|
||
: [];
|
||
const cardMap = new Map();
|
||
rawCards.forEach((raw) => {
|
||
if (typeof raw !== 'string' || !raw.trim()) return;
|
||
try {
|
||
const card = JSON.parse(raw);
|
||
if (!card || typeof card !== 'object' || !card.id) return;
|
||
cardMap.set(String(card.id), card);
|
||
} catch (e) {
|
||
// ignore malformed cards
|
||
}
|
||
});
|
||
return cardMap;
|
||
}
|
||
|
||
function buildRenderedImageMarkdown(card) {
|
||
let image = card && card.image && typeof card.image === 'object' ? card.image : null;
|
||
let original = image ? String(image.original || image.link || '').trim() : '';
|
||
let title = image ? normalizeSourceText(image.title || '') : '';
|
||
|
||
if (!original && card && card.image_chunk) {
|
||
original = String(card.image_chunk.imageUrl || '').trim();
|
||
if (!title && card.image_chunk.imageTitle) {
|
||
title = normalizeSourceText(card.image_chunk.imageTitle || '');
|
||
}
|
||
}
|
||
if (original && !original.startsWith('http')) {
|
||
let basePath = original.startsWith('/') ? original : '/' + original;
|
||
original = '/v1/files/image' + basePath;
|
||
}
|
||
|
||
if (!original) return '';
|
||
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 '';
|
||
if (raw.startsWith('data:')) {
|
||
return raw;
|
||
}
|
||
if (/^(?:https?:)?\/\//i.test(raw)) {
|
||
try {
|
||
const parsed = new URL(raw, window.location.origin);
|
||
const host = String(parsed.hostname || '').toLowerCase();
|
||
const path = String(parsed.pathname || '').trim();
|
||
const marker = '/v1/files/image/';
|
||
|
||
if (path.includes(marker)) {
|
||
return path.slice(path.indexOf(marker));
|
||
}
|
||
if (host === 'localhost' || host === '127.0.0.1') {
|
||
return path || '';
|
||
}
|
||
if (host === 'assets.grok.com' && path) {
|
||
return `/v1/files/image${path.startsWith('/') ? path : `/${path}`}`;
|
||
}
|
||
return '';
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
}
|
||
const basePath = raw.startsWith('/') ? raw : `/${raw}`;
|
||
return basePath.startsWith('/v1/files/image/')
|
||
? basePath
|
||
: `/v1/files/image${basePath}`;
|
||
}
|
||
|
||
function collectRenderedImageUrlsFromCard(card) {
|
||
const urls = [];
|
||
if (!card || typeof card !== 'object') return urls;
|
||
if (card.image && typeof card.image === 'object') {
|
||
urls.push(card.image.original, card.image.link);
|
||
}
|
||
if (card.image_chunk && typeof card.image_chunk === 'object') {
|
||
urls.push(card.image_chunk.imageUrl);
|
||
}
|
||
return urls
|
||
.map((item) => normalizeRenderedImageUrl(item))
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function blobToDataUrl(blob) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result || ''));
|
||
reader.onerror = () => reject(reader.error || new Error('读取图片失败'));
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
function inferImageExtension(mime, fallbackUrl) {
|
||
const normalizedMime = String(mime || '').trim().toLowerCase();
|
||
if (normalizedMime === 'image/jpeg') return 'jpg';
|
||
if (normalizedMime === 'image/png') return 'png';
|
||
if (normalizedMime === 'image/webp') return 'webp';
|
||
if (normalizedMime === 'image/gif') return 'gif';
|
||
if (normalizedMime === 'image/svg+xml') return 'svg';
|
||
const match = String(fallbackUrl || '').match(/\.([a-z0-9]+)(?:[\?#]|$)/i);
|
||
return match ? String(match[1]).toLowerCase() : 'png';
|
||
}
|
||
|
||
async function buildAssistantImageAttachment(url, index) {
|
||
const normalizedUrl = normalizeRenderedImageUrl(url);
|
||
if (!normalizedUrl) return null;
|
||
try {
|
||
const res = await fetch(normalizedUrl);
|
||
if (!res.ok) {
|
||
throw new Error(`下载图片失败: ${res.status}`);
|
||
}
|
||
const blob = await res.blob();
|
||
const data = await blobToDataUrl(blob);
|
||
const mime = String(blob.type || '').trim() || 'image/png';
|
||
const ext = inferImageExtension(mime, normalizedUrl);
|
||
return {
|
||
mime,
|
||
name: `grok-image-${index + 1}.${ext}`,
|
||
data,
|
||
source: 'assistant'
|
||
};
|
||
} catch (e) {
|
||
console.warn('自动附加 assistant 图片失败', normalizedUrl, e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function collectAssistantImageUrls(entry) {
|
||
const urls = [];
|
||
const seen = new Set();
|
||
const pushUrl = (value) => {
|
||
const normalized = normalizeRenderedImageUrl(value);
|
||
if (!normalized || seen.has(normalized)) return;
|
||
seen.add(normalized);
|
||
urls.push(normalized);
|
||
};
|
||
|
||
if (entry && entry.contentNode && entry.contentNode.querySelectorAll) {
|
||
const images = entry.contentNode.querySelectorAll('.message-image-card img, .img-grid img');
|
||
images.forEach((img) => {
|
||
pushUrl(img.currentSrc || img.getAttribute('src') || '');
|
||
});
|
||
}
|
||
|
||
if (!urls.length && entry && entry.rendering) {
|
||
const cardMap = parseRenderingCards(entry.rendering);
|
||
cardMap.forEach((card) => {
|
||
const cType = String(card && card.type || '');
|
||
const cardType = String(card && card.cardType || '');
|
||
if (
|
||
cType === 'render_searched_image' ||
|
||
cType === 'render_edited_image' ||
|
||
cType === 'render_generated_image' ||
|
||
cardType === 'generated_image_card'
|
||
) {
|
||
collectRenderedImageUrlsFromCard(card).forEach(pushUrl);
|
||
}
|
||
});
|
||
const extraImages = Array.isArray(entry.rendering.extraImages) ? entry.rendering.extraImages : [];
|
||
extraImages.forEach(pushUrl);
|
||
}
|
||
|
||
return urls;
|
||
}
|
||
|
||
async function quoteAssistantImages(entry) {
|
||
if (!entry || entry.role !== 'assistant') {
|
||
toast('当前消息不可引用', 'error');
|
||
return;
|
||
}
|
||
const urls = collectAssistantImageUrls(entry);
|
||
if (!urls.length) {
|
||
toast('当前回答没有可引用的图片', 'error');
|
||
return;
|
||
}
|
||
const results = await Promise.all(urls.map((url, index) => buildAssistantImageAttachment(url, index)));
|
||
const imageAttachments = results.filter(Boolean);
|
||
if (!imageAttachments.length) {
|
||
toast('引用图片失败', 'error');
|
||
return;
|
||
}
|
||
imageAttachments.forEach((item) => {
|
||
attachments.push({
|
||
...item,
|
||
name: buildUniqueFileName(item.name || 'image')
|
||
});
|
||
});
|
||
showAttachmentBadge();
|
||
if (promptInput) {
|
||
promptInput.focus();
|
||
}
|
||
toast(`已引用 ${imageAttachments.length} 张图片`, 'success');
|
||
}
|
||
|
||
function normalizeRenderedMarkdownLayout(text) {
|
||
let output = String(text || '');
|
||
output = output.replace(/([^\n])\s*(#{2,6}\s+)/g, '$1\n\n$2');
|
||
output = output.replace(/(<\/span><\/span>)\s*(#{2,6}\s+)/g, '$1\n\n$2');
|
||
output = output.replace(/(<\/span><\/span>)\s*(\d+\.\s+)/g, '$1\n\n$2');
|
||
output = output.replace(/(<\/span><\/span>)\s*([*-]\s+)/g, '$1\n\n$2');
|
||
output = output.replace(/(<\/span><\/span>)\s*(\*\*[^*]+\*\*:)/g, '$1\n\n$2');
|
||
output = output.replace(/([。!?;])\s*(\d+\.\s+)/g, '$1\n\n$2');
|
||
output = output.replace(/([。!?;])\s*([*-]\s+)/g, '$1\n\n$2');
|
||
output = output.replace(/([^\n])\s*(?:[-*•]\s+\*\*[^*]+\*\*:)/g, (match, prefix) => `${prefix}\n\n${match.slice(prefix.length).trimStart()}`);
|
||
output = output.replace(/\n{3,}/g, '\n\n');
|
||
return output;
|
||
}
|
||
|
||
function preserveRenderBoundary(match, replacement) {
|
||
const trailingWhitespaceMatch = String(match || '').match(/((?:\s| |\u00a0|\u2060)*)$/);
|
||
const trailingWhitespace = trailingWhitespaceMatch ? trailingWhitespaceMatch[1] : '';
|
||
const normalizedTrailing = trailingWhitespace
|
||
.replace(/ |\u00a0|\u2060/g, ' ')
|
||
.replace(/[ \t]+\n/g, '\n');
|
||
|
||
if (!normalizedTrailing) return replacement;
|
||
if (normalizedTrailing.includes('\n\n')) return `${replacement}\n\n`;
|
||
if (normalizedTrailing.includes('\n')) return `${replacement}\n`;
|
||
return `${replacement} `;
|
||
}
|
||
|
||
function renderExactGrokCards(rawMessage, rendering) {
|
||
const message = String(rawMessage || '');
|
||
if (!rendering || typeof rendering !== 'object') return message;
|
||
const cardMap = parseRenderingCards(rendering);
|
||
const extraImages = Array.isArray(rendering.extraImages) ? rendering.extraImages : [];
|
||
|
||
if (!cardMap.size && !extraImages.length) return message;
|
||
|
||
let rendered = message.replace(
|
||
/(?:<grok:render\b[^>]*card_id="[^"]+"[^>]*>[\s\S]*?<\/grok:render>(?:\s| |\u00a0|\u2060)*)+/g,
|
||
(match) => {
|
||
const ids = Array.from(match.matchAll(/card_id="([^"]+)"/g))
|
||
.map((part) => String(part[1] || '').trim())
|
||
.filter(Boolean);
|
||
if (!ids.length) return '';
|
||
|
||
const output = [];
|
||
let pendingCitations = [];
|
||
const flushCitations = () => {
|
||
if (!pendingCitations.length) return;
|
||
output.push(buildInlineCitationChip(pendingCitations));
|
||
pendingCitations = [];
|
||
};
|
||
|
||
ids.forEach((id) => {
|
||
const card = cardMap.get(id);
|
||
if (!card) return;
|
||
if (String(card.type || '') === 'render_inline_citation' && card.url) {
|
||
pendingCitations.push({
|
||
href: String(card.url).trim(),
|
||
hostname: getSourceHostname(card.url),
|
||
label: normalizeSourceText(card.title || '') || getSourceHostname(card.url) || String(card.url).trim()
|
||
});
|
||
return;
|
||
}
|
||
flushCitations();
|
||
const cType = String(card.type || '');
|
||
const cardType = String(card.cardType || '');
|
||
if (cType === 'render_searched_image' || cType === 'render_edited_image' || cType === 'render_generated_image' || cardType === 'generated_image_card') {
|
||
output.push(buildRenderedImageMarkdown(card));
|
||
}
|
||
});
|
||
|
||
flushCitations();
|
||
return preserveRenderBoundary(match, output.join(''));
|
||
}
|
||
);
|
||
|
||
if (extraImages.length) {
|
||
const appended = extraImages
|
||
.map((url) => String(url || '').trim())
|
||
.filter(Boolean)
|
||
.map((url) => `\n\n`)
|
||
.join('');
|
||
if (appended) rendered += appended;
|
||
}
|
||
|
||
return normalizeRenderedMarkdownLayout(rendered);
|
||
}
|
||
|
||
function extractThinkMarkup(raw) {
|
||
const source = String(raw || '');
|
||
if (!source.includes('<think>')) return '';
|
||
const matches = source.match(/<think>[\s\S]*?<\/think>|<think>[\s\S]*$/g) || [];
|
||
return matches.join('\n');
|
||
}
|
||
|
||
function getRenderableAssistantText(entry) {
|
||
if (!entry || entry.role !== 'assistant') {
|
||
return entry && entry.raw ? entry.raw : '';
|
||
}
|
||
const rendering = entry.rendering && typeof entry.rendering === 'object' ? entry.rendering : null;
|
||
const rawModelResponse = rendering && rendering.rawModelResponse && typeof rendering.rawModelResponse === 'object'
|
||
? rendering.rawModelResponse
|
||
: null;
|
||
const rawMessage = rawModelResponse && typeof rawModelResponse.message === 'string'
|
||
? rawModelResponse.message
|
||
: (entry.raw || '');
|
||
const renderedAnswer = renderExactGrokCards(rawMessage, rendering);
|
||
const thinkMarkup = extractThinkMarkup(entry.raw || '');
|
||
if (!thinkMarkup) return renderedAnswer;
|
||
return `${thinkMarkup}\n\n${renderedAnswer}`.trim();
|
||
}
|
||
|
||
function renderInlineCitationTokens(value, htmlLinks) {
|
||
return value.replace(/(?:@@HTMLLINK_\d+@@(?:\s| |\u00a0|\u2060)*)+/g, (match) => {
|
||
const indices = Array.from(match.matchAll(/@@HTMLLINK_(\d+)@@/g))
|
||
.map((part) => Number(part[1]))
|
||
.filter((index) => Number.isFinite(index));
|
||
const links = [];
|
||
const seen = new Set();
|
||
indices.forEach((index) => {
|
||
const item = htmlLinks[index];
|
||
const href = String(item && item.href || '').trim();
|
||
if (!href || seen.has(href)) return;
|
||
seen.add(href);
|
||
links.push(item);
|
||
});
|
||
if (!links.length) return '';
|
||
if (links.every((item) => typeof item.html === 'string' && item.html.trim())) {
|
||
return links.map((item) => item.html).join('');
|
||
}
|
||
return buildInlineCitationChip(links);
|
||
});
|
||
}
|
||
|
||
function renderBasicMarkdown(rawText, imageSourceMap = null) {
|
||
const text = (rawText || '').replace(/\\n/g, '\n');
|
||
const htmlLinks = [];
|
||
const linkExtractedText = text
|
||
.replace(/<a\b[^>]*href=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, (match, quote, href, inner) => {
|
||
const label = String(inner || '')
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replace(/ /gi, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.replace(/\u2060/g, '')
|
||
.trim();
|
||
const token = `@@HTMLLINK_${htmlLinks.length}@@`;
|
||
htmlLinks.push({
|
||
href: String(href || '').trim(),
|
||
label,
|
||
hostname: getSourceHostname(href),
|
||
html: match
|
||
});
|
||
return token;
|
||
});
|
||
const normalizedText = linkExtractedText.replace(/<\/?span\b[^>]*>/gi, '');
|
||
const escaped = escapeHtml(normalizedText);
|
||
const codeBlocks = [];
|
||
const fenced = escaped.replace(/```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g, (match, lang, code) => {
|
||
const safeLang = lang ? escapeHtml(lang) : '';
|
||
const encoded = encodeURIComponent(code);
|
||
const html = `<div class="code-block-wrap"><button type="button" class="code-copy-btn" data-copy-code="${encoded}">复制</button><pre class="code-block"><code${safeLang ? ` class="language-${safeLang}"` : ''}>${code}</code></pre></div>`;
|
||
const token = `@@CODEBLOCK_${codeBlocks.length}@@`;
|
||
codeBlocks.push(html);
|
||
return token;
|
||
});
|
||
|
||
const renderInline = (value) => {
|
||
let output = value
|
||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||
|
||
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>`
|
||
: '';
|
||
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(decodeHtmlEntities(label || ''));
|
||
const safeUrl = escapeHtml(decodeHtmlEntities(url || ''));
|
||
return `<a href="${safeUrl}" target="_blank" rel="noopener">${safeLabel}</a>`;
|
||
});
|
||
|
||
output = renderInlineCitationTokens(output, htmlLinks);
|
||
output = linkifyPlainTextSegments(output);
|
||
|
||
return output;
|
||
};
|
||
|
||
const linkifyPlainTextSegments = (html) => {
|
||
const segments = String(html || '').split(/(<[^>]+>)/g);
|
||
return segments.map((segment) => {
|
||
if (!segment || segment.startsWith('<')) return segment;
|
||
return segment.replace(/https?:\/\/[A-Za-z0-9\-._~:/?#\[\]@!$&'*+,;=%]+/gi, (rawUrl) => {
|
||
let url = rawUrl;
|
||
let trailing = '';
|
||
while (/[),.;!?,。;:!?)\]]$/.test(url)) {
|
||
trailing = url.slice(-1) + trailing;
|
||
url = url.slice(0, -1);
|
||
}
|
||
if (!url) return rawUrl;
|
||
const safeUrl = escapeHtml(url);
|
||
return `<a href="${safeUrl}" target="_blank" rel="noopener">${safeUrl}</a>${escapeHtml(trailing)}`;
|
||
});
|
||
}).join('');
|
||
};
|
||
|
||
const lines = fenced.split(/\r?\n/);
|
||
const htmlParts = [];
|
||
let inUl = false;
|
||
let inOl = false;
|
||
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>');
|
||
inUl = false;
|
||
}
|
||
if (inOl) {
|
||
htmlParts.push('</ol>');
|
||
inOl = false;
|
||
}
|
||
};
|
||
|
||
const closeTable = () => {
|
||
if (inTable) {
|
||
htmlParts.push('</tbody></table>');
|
||
inTable = false;
|
||
}
|
||
};
|
||
|
||
const flushParagraph = () => {
|
||
if (!paragraphLines.length) return;
|
||
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;
|
||
if (isStandaloneMediaLine(trimmed)) {
|
||
flushTextChunk();
|
||
mediaChunk.push(trimmed);
|
||
return;
|
||
}
|
||
flushMediaChunk();
|
||
textChunk.push(trimmed);
|
||
});
|
||
|
||
flushTextChunk();
|
||
flushMediaChunk();
|
||
paragraphLines = [];
|
||
};
|
||
|
||
const isTableSeparator = (line) => /^\s*\|?(?:\s*:?-+:?\s*\|)+\s*$/.test(line);
|
||
const splitTableRow = (line) => {
|
||
const trimmed = line.trim();
|
||
const row = trimmed.replace(/^\|/, '').replace(/\|$/, '');
|
||
return row.split('|').map(cell => cell.trim());
|
||
};
|
||
|
||
for (let i = 0; i < lines.length; i += 1) {
|
||
const line = lines[i];
|
||
const trimmed = line.trim();
|
||
if (!trimmed) {
|
||
flushParagraph();
|
||
closeLists();
|
||
closeTable();
|
||
continue;
|
||
}
|
||
|
||
const codeTokenMatch = trimmed.match(/^@@CODEBLOCK_(\d+)@@$/);
|
||
if (codeTokenMatch) {
|
||
flushParagraph();
|
||
closeLists();
|
||
closeTable();
|
||
htmlParts.push(trimmed);
|
||
continue;
|
||
}
|
||
|
||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||
if (headingMatch) {
|
||
flushParagraph();
|
||
closeLists();
|
||
closeTable();
|
||
const level = headingMatch[1].length;
|
||
htmlParts.push(`<h${level}>${renderInline(headingMatch[2])}</h${level}>`);
|
||
continue;
|
||
}
|
||
|
||
if (trimmed.includes('|')) {
|
||
const nextLine = lines[i + 1] || '';
|
||
if (!inTable && isTableSeparator(nextLine.trim())) {
|
||
flushParagraph();
|
||
closeLists();
|
||
const headers = splitTableRow(trimmed);
|
||
htmlParts.push('<div class="table-wrap"><table><thead><tr>');
|
||
headers.forEach(cell => htmlParts.push(`<th>${renderInline(cell)}</th>`));
|
||
htmlParts.push('</tr></thead><tbody>');
|
||
inTable = true;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (inTable && !isTableSeparator(trimmed)) {
|
||
const cells = splitTableRow(trimmed);
|
||
htmlParts.push('<tr>');
|
||
cells.forEach(cell => htmlParts.push(`<td>${renderInline(cell)}</td>`));
|
||
htmlParts.push('</tr>');
|
||
continue;
|
||
}
|
||
}
|
||
|
||
const ulMatch = trimmed.match(/^[-*+•]\s+(.*)$/);
|
||
if (ulMatch) {
|
||
flushParagraph();
|
||
if (!inUl) {
|
||
closeLists();
|
||
closeTable();
|
||
htmlParts.push('<ul>');
|
||
inUl = true;
|
||
}
|
||
htmlParts.push(`<li>${renderInline(ulMatch[1])}</li>`);
|
||
continue;
|
||
}
|
||
|
||
const olMatch = trimmed.match(/^\d+[.)、]\s+(.*)$/);
|
||
if (olMatch) {
|
||
flushParagraph();
|
||
if (!inOl) {
|
||
closeLists();
|
||
closeTable();
|
||
htmlParts.push('<ol>');
|
||
inOl = true;
|
||
}
|
||
htmlParts.push(`<li>${renderInline(olMatch[1])}</li>`);
|
||
continue;
|
||
}
|
||
|
||
paragraphLines.push(trimmed);
|
||
}
|
||
|
||
flushParagraph();
|
||
closeLists();
|
||
closeTable();
|
||
|
||
let output = htmlParts.join('');
|
||
codeBlocks.forEach((html, index) => {
|
||
output = output.replace(`@@CODEBLOCK_${index}@@`, html);
|
||
});
|
||
return output;
|
||
}
|
||
|
||
function parseThinkSections(raw) {
|
||
const parts = [];
|
||
let cursor = 0;
|
||
while (cursor < raw.length) {
|
||
const start = raw.indexOf('<think>', cursor);
|
||
if (start === -1) {
|
||
parts.push({ type: 'text', value: raw.slice(cursor) });
|
||
break;
|
||
}
|
||
if (start > cursor) {
|
||
parts.push({ type: 'text', value: raw.slice(cursor, start) });
|
||
}
|
||
const thinkStart = start + 7;
|
||
const end = raw.indexOf('</think>', thinkStart);
|
||
if (end === -1) {
|
||
parts.push({ type: 'think', value: raw.slice(thinkStart), open: true });
|
||
cursor = raw.length;
|
||
} else {
|
||
parts.push({ type: 'think', value: raw.slice(thinkStart, end), open: false });
|
||
cursor = end + 8;
|
||
}
|
||
}
|
||
return parts;
|
||
}
|
||
|
||
function parseRolloutBlocks(text, defaultId = 'General') {
|
||
const lines = (text || '').split(/\r?\n/);
|
||
const blocks = [];
|
||
let current = null;
|
||
for (const line of lines) {
|
||
const matchDouble = line.match(/^\s*\[([^\]]+)\]\[([^\]]+)\]\s*(.*)$/);
|
||
if (matchDouble) {
|
||
if (current) blocks.push(current);
|
||
current = { id: matchDouble[1], type: matchDouble[2], lines: [] };
|
||
if (matchDouble[3]) current.lines.push(matchDouble[3]);
|
||
continue;
|
||
}
|
||
const matchSingle = line.match(/^\s*\[([^\]]+)\]\s*(.*)$/);
|
||
if (matchSingle) {
|
||
const maybeType = String(matchSingle[1] || '').trim();
|
||
if (/^(WebSearch|SearchImage|AgentThink)$/i.test(maybeType)) {
|
||
if (current) blocks.push(current);
|
||
current = { id: defaultId || 'General', type: maybeType, lines: [] };
|
||
if (matchSingle[2]) current.lines.push(matchSingle[2]);
|
||
continue;
|
||
}
|
||
}
|
||
if (current && /^\s*\[[^\]]+\]\s*$/.test(line)) {
|
||
continue;
|
||
}
|
||
if (current) {
|
||
current.lines.push(line);
|
||
}
|
||
}
|
||
if (current) blocks.push(current);
|
||
return blocks;
|
||
}
|
||
|
||
function parseAgentSections(text) {
|
||
const lines = (text || '').split(/\r?\n/);
|
||
const sections = [];
|
||
let current = { title: null, lines: [] };
|
||
let hasAgentHeading = false;
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) {
|
||
current.lines.push(line);
|
||
continue;
|
||
}
|
||
const agentMatch = trimmed.match(/^(Grok\s+Leader|(?:Grok\s+)?Agent\s*\d+)$/i);
|
||
if (agentMatch) {
|
||
hasAgentHeading = true;
|
||
if (current.lines.length) {
|
||
sections.push(current);
|
||
}
|
||
current = { title: agentMatch[1], lines: [] };
|
||
continue;
|
||
}
|
||
current.lines.push(line);
|
||
}
|
||
if (current.lines.length) {
|
||
sections.push(current);
|
||
}
|
||
if (!hasAgentHeading) {
|
||
return [{ title: null, lines }];
|
||
}
|
||
return sections;
|
||
}
|
||
|
||
function splitBlocksIntoSyntheticAgents(blocks) {
|
||
const list = Array.isArray(blocks) ? blocks : [];
|
||
if (!list.length) return [];
|
||
|
||
const ids = Array.from(new Set(list.map((b) => String(b.id || '').trim()).filter(Boolean)));
|
||
const nonGeneralIds = ids.filter((id) => !/^general$/i.test(id));
|
||
|
||
if (nonGeneralIds.length <= 1) {
|
||
return [];
|
||
}
|
||
const groups = [];
|
||
const map = new Map();
|
||
for (const block of list) {
|
||
const key = String(block.id || 'General');
|
||
let group = map.get(key);
|
||
if (!group) {
|
||
group = { key, blocks: [] };
|
||
map.set(key, group);
|
||
groups.push(group);
|
||
}
|
||
group.blocks.push(block);
|
||
}
|
||
return groups.map((group, idx) => ({
|
||
title: idx === 0 ? 'Grok Leader' : `Agent ${idx}`,
|
||
blocks: group.blocks
|
||
}));
|
||
}
|
||
|
||
function renderFlatBlocks(blocks, imageSourceMap = null) {
|
||
return (Array.isArray(blocks) ? blocks : []).map((item) => {
|
||
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);
|
||
return `<div class="think-item-row"><div class="think-item-type" data-type="${typeAttr}">${typeText}</div><div class="think-item-body">${body || '<em>(空)</em>'}</div></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderThinkContent(text, openAll, imageSourceMap = null) {
|
||
const sections = parseAgentSections(text);
|
||
if (!sections.length) {
|
||
return renderBasicMarkdown(text, imageSourceMap);
|
||
}
|
||
const renderThinkAgentSummary = (title) => {
|
||
const safeTitle = escapeHtml(title);
|
||
return `<summary><span class="think-agent-avatar" aria-hidden="true"></span><span class="think-agent-label">${safeTitle}</span></summary>`;
|
||
};
|
||
const renderGroups = (blocks, openAllGroups) => {
|
||
const groups = [];
|
||
const map = new Map();
|
||
for (const block of blocks) {
|
||
const key = block.id;
|
||
let group = map.get(key);
|
||
if (!group) {
|
||
group = { id: key, items: [] };
|
||
map.set(key, group);
|
||
groups.push(group);
|
||
}
|
||
group.items.push(block);
|
||
}
|
||
return groups.map((group) => {
|
||
const items = group.items.map((item) => {
|
||
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);
|
||
return `<div class="think-item-row"><div class="think-item-type" data-type="${typeAttr}">${typeText}</div><div class="think-item-body">${body || '<em>(空)</em>'}</div></div>`;
|
||
}).join('');
|
||
const title = escapeHtml(group.id);
|
||
const openAttr = openAllGroups ? ' open' : '';
|
||
return `<details class="think-rollout-group"${openAttr}><summary><span class="think-rollout-title"><span class="think-rollout-avatar" aria-hidden="true"></span><span class="think-rollout-label">${title}</span></span></summary><div class="think-rollout-body">${items}</div></details>`;
|
||
}).join('');
|
||
};
|
||
|
||
const agentBlocks = sections.map((section, idx) => {
|
||
const blocks = parseRolloutBlocks(section.lines.join('\n'), section.title || 'General');
|
||
if (!section.title && blocks.length) {
|
||
const synthetic = splitBlocksIntoSyntheticAgents(blocks);
|
||
if (synthetic.length) {
|
||
return synthetic.map((agent, agentIdx) => {
|
||
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('');
|
||
}
|
||
}
|
||
const inner = blocks.length
|
||
? renderGroups(blocks, openAll)
|
||
: `<div class="think-rollout-body">${renderBasicMarkdown(section.lines.join('\n').trim(), imageSourceMap)}</div>`;
|
||
if (!section.title) {
|
||
return `<div class="think-agent-items">${inner}</div>`;
|
||
}
|
||
const openAttr = openAll ? ' open' : (idx === 0 ? ' open' : '');
|
||
return `<details class="think-agent"${openAttr}>${renderThinkAgentSummary(section.title)}<div class="think-agent-items">${inner}</div></details>`;
|
||
});
|
||
return `<div class="think-agents">${agentBlocks.join('')}</div>`;
|
||
}
|
||
|
||
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, 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, imageSourceMap);
|
||
}).join('');
|
||
}
|
||
|
||
function createMessage(role, content) {
|
||
if (!chatLog) return null;
|
||
hideEmptyState();
|
||
const row = document.createElement('div');
|
||
row.className = `message-row ${role === 'user' ? 'user' : 'assistant'}`;
|
||
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'message-bubble';
|
||
const contentNode = document.createElement('div');
|
||
contentNode.className = 'message-content';
|
||
contentNode.textContent = content || '';
|
||
bubble.appendChild(contentNode);
|
||
row.appendChild(bubble);
|
||
|
||
chatLog.appendChild(row);
|
||
scrollToBottom(true);
|
||
const entry = {
|
||
row,
|
||
contentNode,
|
||
role,
|
||
raw: content || '',
|
||
sources: null,
|
||
rendering: null,
|
||
committed: false,
|
||
startedAt: Date.now(),
|
||
firstTokenAt: null,
|
||
hasThink: false,
|
||
thinkElapsed: null,
|
||
thinkingActive: false
|
||
};
|
||
return entry;
|
||
}
|
||
|
||
function renderUserMessage(entry, text, files) {
|
||
if (!entry || !entry.contentNode) return;
|
||
const prompt = String(text || '').trim();
|
||
const attachmentsList = Array.isArray(files) ? files : [];
|
||
const imageFiles = attachmentsList.filter((item) => String(item.mime || '').startsWith('image/') && item.data);
|
||
const otherFiles = attachmentsList.filter((item) => !(String(item.mime || '').startsWith('image/')));
|
||
|
||
const parts = [];
|
||
if (prompt) {
|
||
parts.push(`<div class="user-text-bubble">${renderBasicMarkdown(prompt)}</div>`);
|
||
}
|
||
if (imageFiles.length) {
|
||
const thumbs = imageFiles.map((item) => {
|
||
const src = escapeHtml(item.data || '');
|
||
const name = escapeHtml(item.name || 'image');
|
||
return `<button type="button" class="user-image-btn" data-preview-src="${src}" data-preview-name="${name}" aria-label="预览图片 ${name}"><img src="${src}" alt="${name}" loading="lazy"></button>`;
|
||
}).join('');
|
||
parts.push(`<div class="user-media-row">${thumbs}</div>`);
|
||
}
|
||
if (otherFiles.length) {
|
||
const tags = otherFiles.map((item) => `<span class="user-file-chip">[文件] ${escapeHtml(item.name || 'file')}</span>`).join('');
|
||
parts.push(`<div class="user-file-row">${tags}</div>`);
|
||
}
|
||
if (!parts.length) {
|
||
parts.push('<div class="user-text-bubble">(空)</div>');
|
||
}
|
||
|
||
entry.raw = prompt;
|
||
entry.contentNode.classList.add('rendered', 'user-rendered');
|
||
entry.contentNode.innerHTML = parts.join('');
|
||
bindMessageImagePreview(entry.contentNode);
|
||
scrollToBottom(true);
|
||
}
|
||
|
||
function applyImageGrid(root) {
|
||
if (!root) return;
|
||
const isIgnorable = (node) => {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
return !node.textContent.trim();
|
||
}
|
||
return node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR';
|
||
};
|
||
|
||
const isImageLink = (node) => {
|
||
if (!node || node.nodeType !== Node.ELEMENT_NODE || node.tagName !== 'A') return false;
|
||
const children = Array.from(node.childNodes);
|
||
if (!children.length) return false;
|
||
return children.every((child) => {
|
||
if (child.nodeType === Node.TEXT_NODE) {
|
||
return !child.textContent.trim();
|
||
}
|
||
return child.nodeType === Node.ELEMENT_NODE && child.tagName === 'IMG';
|
||
});
|
||
};
|
||
|
||
const extractImageItems = (node) => {
|
||
if (!node || node.nodeType !== Node.ELEMENT_NODE) return null;
|
||
if (node.classList && node.classList.contains('img-grid')) return null;
|
||
if (node.tagName === 'IMG') {
|
||
return { items: [node], removeNode: null };
|
||
}
|
||
if (node.tagName === 'FIGURE' && node.classList.contains('message-image-card')) {
|
||
return { items: [node], removeNode: null };
|
||
}
|
||
if (isImageLink(node)) {
|
||
return { items: [node], removeNode: null };
|
||
}
|
||
if (node.tagName === 'P') {
|
||
const items = [];
|
||
const children = Array.from(node.childNodes);
|
||
if (!children.length) return null;
|
||
for (const child of children) {
|
||
if (child.nodeType === Node.TEXT_NODE) {
|
||
if (!child.textContent.trim()) continue;
|
||
return null;
|
||
}
|
||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||
if (
|
||
child.tagName === 'IMG' ||
|
||
isImageLink(child) ||
|
||
(child.tagName === 'FIGURE' && child.classList.contains('message-image-card'))
|
||
) {
|
||
items.push(child);
|
||
continue;
|
||
}
|
||
if (child.tagName === 'BR') continue;
|
||
return null;
|
||
}
|
||
return null;
|
||
}
|
||
if (!items.length) return null;
|
||
return { items, removeNode: node };
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const wrapImagesInContainer = (container) => {
|
||
const children = Array.from(container.childNodes);
|
||
let group = [];
|
||
let groupStart = null;
|
||
let removeNodes = [];
|
||
|
||
const flush = () => {
|
||
if (group.length < 2) {
|
||
group = [];
|
||
groupStart = null;
|
||
removeNodes = [];
|
||
return;
|
||
}
|
||
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) {
|
||
container.insertBefore(wrapper, groupStart);
|
||
} else {
|
||
container.appendChild(wrapper);
|
||
}
|
||
group.forEach((img) => wrapper.appendChild(img));
|
||
removeNodes.forEach((n) => n.parentNode && n.parentNode.removeChild(n));
|
||
group = [];
|
||
groupStart = null;
|
||
removeNodes = [];
|
||
};
|
||
|
||
children.forEach((node) => {
|
||
if (group.length && isIgnorable(node)) {
|
||
removeNodes.push(node);
|
||
return;
|
||
}
|
||
const extracted = extractImageItems(node);
|
||
if (extracted && extracted.items.length) {
|
||
if (!groupStart) groupStart = node;
|
||
group.push(...extracted.items);
|
||
if (extracted.removeNode) {
|
||
removeNodes.push(extracted.removeNode);
|
||
}
|
||
return;
|
||
}
|
||
flush();
|
||
});
|
||
flush();
|
||
};
|
||
|
||
const containers = [root, ...root.querySelectorAll('.think-content, .think-item-body, .think-rollout-body, .think-agent-items')];
|
||
containers.forEach((container) => {
|
||
if (!container || container.closest('.img-grid')) return;
|
||
if (!container.querySelector || !container.querySelector('img')) return;
|
||
wrapImagesInContainer(container);
|
||
});
|
||
}
|
||
|
||
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'));
|
||
thinkBlocks.forEach((block, blockIndex) => {
|
||
const images = Array.from(block.querySelectorAll('.think-content img'));
|
||
if (!images.length) return;
|
||
|
||
let gallery = block.nextElementSibling;
|
||
if (!(gallery instanceof HTMLElement) || !gallery.classList.contains('think-image-extract')) {
|
||
gallery = document.createElement('div');
|
||
gallery.className = 'think-image-extract';
|
||
gallery.dataset.thinkBlockIndex = String(blockIndex);
|
||
block.insertAdjacentElement('afterend', gallery);
|
||
}
|
||
|
||
images.forEach((img) => {
|
||
const paragraph = img.closest('p');
|
||
gallery.appendChild(img);
|
||
|
||
if (paragraph) {
|
||
const residue = (paragraph.textContent || '').replace(/\s+/g, '');
|
||
if (!residue || /^\.(?:png|jpe?g|webp|gif)\)?$/i.test(residue)) {
|
||
paragraph.remove();
|
||
return;
|
||
}
|
||
}
|
||
|
||
const nextText = img.nextSibling;
|
||
if (nextText && nextText.nodeType === Node.TEXT_NODE) {
|
||
nextText.textContent = String(nextText.textContent || '').replace(/^\s*\.(?:png|jpe?g|webp|gif)\)?/i, '');
|
||
if (!nextText.textContent.trim()) {
|
||
nextText.parentNode && nextText.parentNode.removeChild(nextText);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function bindCodeCopyButtons(root) {
|
||
if (!root || !root.querySelectorAll) return;
|
||
const buttons = root.querySelectorAll('.code-copy-btn');
|
||
buttons.forEach((btn) => {
|
||
if (btn.dataset.bound === '1') return;
|
||
btn.dataset.bound = '1';
|
||
btn.addEventListener('click', async () => {
|
||
const encoded = btn.getAttribute('data-copy-code') || '';
|
||
const code = decodeURIComponent(encoded);
|
||
try {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
await navigator.clipboard.writeText(code);
|
||
} else {
|
||
const temp = document.createElement('textarea');
|
||
temp.value = code;
|
||
temp.style.position = 'fixed';
|
||
temp.style.opacity = '0';
|
||
document.body.appendChild(temp);
|
||
temp.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(temp);
|
||
}
|
||
const original = btn.textContent || '复制';
|
||
btn.textContent = '已复制';
|
||
setTimeout(() => {
|
||
btn.textContent = original;
|
||
}, 1200);
|
||
} catch (e) {
|
||
toast('复制失败', 'error');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateMessage(entry, content, finalize = false) {
|
||
if (!entry) return;
|
||
entry.raw = content || '';
|
||
if (!entry.contentNode) return;
|
||
if (entry.role === 'user') {
|
||
renderUserMessage(entry, entry.raw, []);
|
||
return;
|
||
}
|
||
const shouldPreserveScroll = isSending && !followStreamScroll;
|
||
const scrollContainer = shouldPreserveScroll ? getScrollContainer() : null;
|
||
const preservedScrollTop = scrollContainer ? scrollContainer.scrollTop : 0;
|
||
const captureOpenState = (root, selector) => {
|
||
if (!root || !root.querySelectorAll) return null;
|
||
const nodes = Array.from(root.querySelectorAll(selector));
|
||
if (!nodes.length) return null;
|
||
return nodes.map((node) => node.hasAttribute('open'));
|
||
};
|
||
const captureScrollState = (root, selector) => {
|
||
if (!root || !root.querySelectorAll) return null;
|
||
const nodes = Array.from(root.querySelectorAll(selector));
|
||
if (!nodes.length) return null;
|
||
return nodes.map((node) => node.scrollTop || 0);
|
||
};
|
||
const restoreOpenState = (root, selector, states) => {
|
||
if (!root || !root.querySelectorAll || !Array.isArray(states) || !states.length) return;
|
||
const nodes = Array.from(root.querySelectorAll(selector));
|
||
const max = Math.min(nodes.length, states.length);
|
||
for (let i = 0; i < max; i += 1) {
|
||
if (states[i]) {
|
||
nodes[i].setAttribute('open', '');
|
||
} else {
|
||
nodes[i].removeAttribute('open');
|
||
}
|
||
}
|
||
};
|
||
const restoreScrollState = (root, selector, states) => {
|
||
if (!root || !root.querySelectorAll || !Array.isArray(states) || !states.length) return;
|
||
const nodes = Array.from(root.querySelectorAll(selector));
|
||
const max = Math.min(nodes.length, states.length);
|
||
for (let i = 0; i < max; i += 1) {
|
||
nodes[i].scrollTop = states[i] || 0;
|
||
}
|
||
};
|
||
const savedThinkBlockState = captureOpenState(entry.contentNode, '.think-block');
|
||
const savedThinkAgentState = captureOpenState(entry.contentNode, '.think-agent');
|
||
const savedRolloutState = captureOpenState(entry.contentNode, '.think-rollout-group');
|
||
const savedThinkContentScroll = captureScrollState(entry.contentNode, '.think-content');
|
||
const savedThinkAgentItemsScroll = captureScrollState(entry.contentNode, '.think-agent-items');
|
||
const savedThinkRolloutBodyScroll = captureScrollState(entry.contentNode, '.think-rollout-body');
|
||
if (!entry.hasThink && entry.raw.includes('<think>')) {
|
||
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, imageSourceMap);
|
||
} else {
|
||
if (entry.role === 'assistant') {
|
||
entry.contentNode.innerHTML = renderMarkdown(renderText, imageSourceMap);
|
||
} else {
|
||
entry.contentNode.textContent = entry.raw;
|
||
}
|
||
}
|
||
restoreOpenState(entry.contentNode, '.think-block', savedThinkBlockState);
|
||
restoreOpenState(entry.contentNode, '.think-agent', savedThinkAgentState);
|
||
restoreOpenState(entry.contentNode, '.think-rollout-group', savedRolloutState);
|
||
if (shouldPreserveScroll) {
|
||
restoreScrollState(entry.contentNode, '.think-content', savedThinkContentScroll);
|
||
restoreScrollState(entry.contentNode, '.think-agent-items', savedThinkAgentItemsScroll);
|
||
restoreScrollState(entry.contentNode, '.think-rollout-body', savedThinkRolloutBodyScroll);
|
||
}
|
||
if (entry.hasThink) {
|
||
entry.thinkingActive = !finalize;
|
||
if (finalize && (entry.thinkElapsed === null || typeof entry.thinkElapsed === 'undefined')) {
|
||
entry.thinkElapsed = Math.max(1, Math.round((Date.now() - (entry.startedAt || Date.now())) / 1000));
|
||
}
|
||
updateThinkSummary(entry, entry.thinkElapsed);
|
||
}
|
||
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);
|
||
}
|
||
if (entry.role === 'assistant') {
|
||
bindCodeCopyButtons(entry.contentNode);
|
||
const thinkNodes = entry.contentNode.querySelectorAll('.think-content');
|
||
if (!shouldPreserveScroll) {
|
||
thinkNodes.forEach((node) => {
|
||
node.scrollTop = node.scrollHeight;
|
||
});
|
||
}
|
||
if (finalize && entry.row && !entry.row.querySelector('.message-actions')) {
|
||
attachAssistantActions(entry);
|
||
}
|
||
}
|
||
if (scrollContainer) {
|
||
suppressScrollTracking = true;
|
||
scrollContainer.scrollTop = preservedScrollTop;
|
||
requestAnimationFrame(() => {
|
||
suppressScrollTracking = false;
|
||
});
|
||
return;
|
||
}
|
||
scrollToBottom();
|
||
}
|
||
|
||
function enhanceBrokenImages(root) {
|
||
if (!root) return;
|
||
const images = root.querySelectorAll('img');
|
||
images.forEach((img) => {
|
||
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';
|
||
wrapper.textContent = '图片加载失败,点击重试';
|
||
wrapper.addEventListener('click', () => {
|
||
wrapper.classList.add('loading');
|
||
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);
|
||
});
|
||
img.addEventListener('load', () => {
|
||
if (img.dataset.failed) {
|
||
img.dataset.failed = '';
|
||
}
|
||
const figure = img.closest('.message-image-card');
|
||
if (figure) {
|
||
figure.classList.remove('is-broken');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateThinkSummary(entry, elapsedSec) {
|
||
if (!entry || !entry.contentNode) return;
|
||
const summaries = entry.contentNode.querySelectorAll('.think-summary');
|
||
if (!summaries.length) return;
|
||
const text = typeof elapsedSec === 'number' ? `思考 ${elapsedSec} 秒` : '思考中';
|
||
const spinDurationMs = 5500;
|
||
const elapsedMs = Math.max(0, Date.now() - (entry.startedAt || Date.now()));
|
||
const spinOffset = `-${(elapsedMs % spinDurationMs)}ms`;
|
||
summaries.forEach((node) => {
|
||
node.textContent = text;
|
||
const block = node.closest('.think-block');
|
||
if (!block) return;
|
||
if (!entry.thinkingActive) {
|
||
block.removeAttribute('data-thinking');
|
||
node.style.removeProperty('--think-spin-delay');
|
||
activeThinkSpinEntries.delete(entry);
|
||
block.querySelectorAll('.think-agent-avatar, .think-rollout-avatar').forEach((avatar) => {
|
||
avatar.style.removeProperty('transform');
|
||
});
|
||
} else {
|
||
block.setAttribute('data-thinking', 'true');
|
||
node.style.setProperty('--think-spin-delay', spinOffset);
|
||
activeThinkSpinEntries.add(entry);
|
||
ensureThinkSpinLoop();
|
||
}
|
||
});
|
||
}
|
||
|
||
function ensureThinkSpinLoop() {
|
||
if (thinkSpinRafId) return;
|
||
const tick = () => {
|
||
thinkSpinRafId = 0;
|
||
if (!activeThinkSpinEntries.size) return;
|
||
const now = Date.now();
|
||
activeThinkSpinEntries.forEach((entry) => {
|
||
if (!entry || !entry.contentNode || !entry.thinkingActive || !entry.contentNode.isConnected) {
|
||
activeThinkSpinEntries.delete(entry);
|
||
return;
|
||
}
|
||
const elapsedMs = Math.max(0, now - (entry.startedAt || now));
|
||
const angle = ((elapsedMs % 2200) / 2200) * 360;
|
||
entry.contentNode.querySelectorAll('.think-block[data-thinking="true"] .think-agent-avatar, .think-block[data-thinking="true"] .think-rollout-avatar').forEach((avatar) => {
|
||
avatar.style.transform = `rotate(${angle}deg)`;
|
||
});
|
||
});
|
||
if (activeThinkSpinEntries.size) {
|
||
thinkSpinRafId = requestAnimationFrame(tick);
|
||
}
|
||
};
|
||
thinkSpinRafId = requestAnimationFrame(tick);
|
||
}
|
||
|
||
function normalizeSourceText(value) {
|
||
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, '');
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function getSourceFavicon(hostname) {
|
||
if (!hostname) return '';
|
||
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';
|
||
root.addEventListener('click', (event) => {
|
||
const target = event.target instanceof Element ? event.target.closest('.inline-citation-cluster.inline-citation-chip') : null;
|
||
if (!(target instanceof HTMLElement)) return;
|
||
if (target.dataset.expanded === '1') return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
expandInlineCitationCluster(target);
|
||
});
|
||
}
|
||
|
||
function cleanExtractedUrl(url) {
|
||
return String(url || '').trim().replace(/[),.;]+$/g, '');
|
||
}
|
||
|
||
function extractUrlsFromText(text) {
|
||
const raw = String(text || '');
|
||
const matches = raw.match(/https?:\/\/[^\s"'<>]+/g) || [];
|
||
return matches.map(cleanExtractedUrl).filter((url) => /^https?:\/\//i.test(url));
|
||
}
|
||
|
||
function extractAssistantSources(root) {
|
||
if (!root || !root.querySelectorAll) {
|
||
return { links: [], searches: [] };
|
||
}
|
||
const rows = Array.from(root.querySelectorAll('.think-item-row'));
|
||
const links = [];
|
||
const searches = [];
|
||
const seenLinks = new Set();
|
||
const seenSearches = new Set();
|
||
|
||
const pushLink = (item) => {
|
||
const url = String(item && item.url || '').trim();
|
||
if (!url || seenLinks.has(url)) return;
|
||
seenLinks.add(url);
|
||
links.push(item);
|
||
};
|
||
|
||
const pushSearch = (item) => {
|
||
const label = String(item && item.label || '').trim();
|
||
if (!label || seenSearches.has(label)) return;
|
||
seenSearches.add(label);
|
||
searches.push(item);
|
||
};
|
||
|
||
rows.forEach((row) => {
|
||
const typeNode = row.querySelector('.think-item-type');
|
||
const bodyNode = row.querySelector('.think-item-body');
|
||
const type = String(typeNode && typeNode.dataset && typeNode.dataset.type || '').trim().toLowerCase();
|
||
if (!bodyNode || !type) return;
|
||
|
||
if (type === 'websearch' || type === 'searchimage') {
|
||
const firstParagraph = bodyNode.querySelector('p');
|
||
const queryText = normalizeSourceText(firstParagraph ? firstParagraph.textContent : bodyNode.textContent || '');
|
||
const compactQuery = queryText.split(/\s{2,}|\n/)[0].slice(0, 140).trim();
|
||
if (compactQuery) {
|
||
pushSearch({
|
||
type: 'search',
|
||
label: compactQuery,
|
||
meta: type === 'searchimage' ? '已搜索图片' : '已搜索的网络'
|
||
});
|
||
}
|
||
}
|
||
|
||
const links = Array.from(bodyNode.querySelectorAll('a[href]'));
|
||
links.forEach((link) => {
|
||
const url = String(link.getAttribute('href') || '').trim();
|
||
if (!/^https?:\/\//i.test(url)) return;
|
||
const hostname = getSourceHostname(url);
|
||
pushLink({
|
||
type: 'visit',
|
||
label: hostname || url,
|
||
meta: '已浏览',
|
||
url,
|
||
hostname
|
||
});
|
||
});
|
||
|
||
extractUrlsFromText(bodyNode.textContent || '').forEach((url) => {
|
||
const hostname = getSourceHostname(url);
|
||
pushLink({
|
||
type: 'visit',
|
||
label: hostname || url,
|
||
meta: '已浏览',
|
||
url,
|
||
hostname
|
||
});
|
||
});
|
||
});
|
||
|
||
return { links, searches };
|
||
}
|
||
|
||
function createSourcesWidget(entry) {
|
||
const structured = entry && entry.sources && typeof entry.sources === 'object' ? entry.sources : null;
|
||
const groups = Array.isArray(structured && structured.groups) ? structured.groups : [];
|
||
const citations = Array.isArray(structured && structured.citations) ? structured.citations : [];
|
||
const rawSourceCount = citations.length + groups.reduce((sum, group) => {
|
||
const results = Array.isArray(group && group.results) ? group.results : [];
|
||
return sum + results.length;
|
||
}, 0);
|
||
const data = (!groups.length && !citations.length)
|
||
? extractAssistantSources(entry && entry.contentNode)
|
||
: { links: [], searches: [] };
|
||
const sources = Array.isArray(data && data.links) ? data.links.slice() : [];
|
||
const searches = Array.isArray(data && data.searches) ? data.searches.slice() : [];
|
||
const seenLinks = new Set(sources.map((item) => String(item && item.url || '').trim()).filter(Boolean));
|
||
|
||
citations.forEach((item) => {
|
||
const url = String(item && item.url || '').trim();
|
||
if (!url || seenLinks.has(url)) return;
|
||
const hostname = getSourceHostname(url);
|
||
seenLinks.add(url);
|
||
sources.unshift({
|
||
type: 'citation',
|
||
label: hostname || url,
|
||
meta: '引用来源',
|
||
preview: normalizeSourceText(item && item.preview || ''),
|
||
url,
|
||
hostname
|
||
});
|
||
});
|
||
|
||
groups.forEach((group) => {
|
||
const query = normalizeSourceText(group && group.query || '');
|
||
if (query) {
|
||
searches.push({
|
||
type: 'search',
|
||
label: query,
|
||
meta: group.kind === 'search_images' ? '图片搜索' : '网络搜索'
|
||
});
|
||
}
|
||
const results = Array.isArray(group && group.results) ? group.results : [];
|
||
results.forEach((item) => {
|
||
const url = String(item && item.url || '').trim();
|
||
if (!url || seenLinks.has(url)) return;
|
||
const hostname = getSourceHostname(url);
|
||
seenLinks.add(url);
|
||
sources.push({
|
||
type: 'visit',
|
||
label: normalizeSourceText(item && item.title || '') || hostname || url,
|
||
meta: hostname || '搜索结果',
|
||
preview: normalizeSourceText(item && item.preview || ''),
|
||
url,
|
||
hostname
|
||
});
|
||
});
|
||
});
|
||
|
||
if (!sources.length && !searches.length) return null;
|
||
|
||
const wrapper = document.createElement('details');
|
||
wrapper.className = 'sources-widget';
|
||
|
||
const summary = document.createElement('summary');
|
||
summary.className = 'sources-chip';
|
||
const summaryCount = rawSourceCount || sources.length || searches.length;
|
||
const summaryLabel = sources.length ? `${summaryCount} sources` : `${summaryCount} searches`;
|
||
summary.setAttribute('aria-label', summaryLabel);
|
||
|
||
const iconStack = document.createElement('div');
|
||
iconStack.className = 'sources-icons';
|
||
const faviconHosts = [];
|
||
sources.forEach((item) => {
|
||
if (item.hostname && !faviconHosts.includes(item.hostname)) {
|
||
faviconHosts.push(item.hostname);
|
||
}
|
||
});
|
||
faviconHosts.slice(0, 3).forEach((hostname, index) => {
|
||
const badge = document.createElement('div');
|
||
badge.className = 'sources-icon-badge';
|
||
badge.style.zIndex = String(3 - index);
|
||
const img = document.createElement('img');
|
||
img.src = getSourceFavicon(hostname);
|
||
img.alt = '';
|
||
img.setAttribute('role', 'presentation');
|
||
badge.appendChild(img);
|
||
iconStack.appendChild(badge);
|
||
});
|
||
if (!iconStack.childNodes.length) {
|
||
const fallback = document.createElement('div');
|
||
fallback.className = 'sources-icon-fallback';
|
||
fallback.textContent = 'S';
|
||
iconStack.appendChild(fallback);
|
||
}
|
||
summary.appendChild(iconStack);
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'sources-chip-label';
|
||
label.textContent = summaryLabel;
|
||
summary.appendChild(label);
|
||
|
||
const panel = document.createElement('div');
|
||
panel.className = 'sources-panel';
|
||
|
||
const panelHeader = document.createElement('div');
|
||
panelHeader.className = 'sources-panel-header';
|
||
|
||
const panelHeading = document.createElement('div');
|
||
panelHeading.className = 'sources-section-title sources-panel-heading';
|
||
panelHeading.textContent = '可验证来源';
|
||
|
||
const closeButton = document.createElement('button');
|
||
closeButton.type = 'button';
|
||
closeButton.className = 'sources-panel-close';
|
||
closeButton.setAttribute('aria-label', '关闭来源面板');
|
||
closeButton.textContent = '×';
|
||
closeButton.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
wrapper.open = false;
|
||
});
|
||
|
||
panelHeader.appendChild(panelHeading);
|
||
panelHeader.appendChild(closeButton);
|
||
panel.appendChild(panelHeader);
|
||
|
||
const sectionAnchors = [];
|
||
const registerSectionAnchor = (labelText) => {
|
||
const anchor = document.createElement('div');
|
||
anchor.className = 'sources-section-anchor';
|
||
anchor.dataset.sectionLabel = labelText;
|
||
panel.appendChild(anchor);
|
||
sectionAnchors.push(anchor);
|
||
return anchor;
|
||
};
|
||
|
||
const updatePanelHeading = () => {
|
||
if (!sectionAnchors.length) return;
|
||
const headerHeight = panelHeader.offsetHeight || 0;
|
||
const threshold = panel.scrollTop + headerHeight + 8;
|
||
let activeLabel = sectionAnchors[0].dataset.sectionLabel || '';
|
||
sectionAnchors.forEach((anchor) => {
|
||
if (anchor.offsetTop <= threshold) {
|
||
activeLabel = anchor.dataset.sectionLabel || activeLabel;
|
||
}
|
||
});
|
||
if (activeLabel) {
|
||
panelHeading.textContent = activeLabel;
|
||
}
|
||
};
|
||
|
||
registerSectionAnchor('可验证来源');
|
||
|
||
sources.forEach((item) => {
|
||
const row = document.createElement(item.url ? 'a' : 'div');
|
||
row.className = 'source-row';
|
||
if (item.url) {
|
||
row.href = item.url;
|
||
row.target = '_blank';
|
||
row.rel = 'noopener noreferrer nofollow';
|
||
}
|
||
|
||
const icon = document.createElement('div');
|
||
icon.className = 'source-row-icon';
|
||
if (item.hostname) {
|
||
const img = document.createElement('img');
|
||
img.src = getSourceFavicon(item.hostname);
|
||
img.alt = '';
|
||
img.setAttribute('role', 'presentation');
|
||
icon.appendChild(img);
|
||
} else {
|
||
icon.textContent = item.type === 'search' ? 'Q' : 'L';
|
||
}
|
||
|
||
const textWrap = document.createElement('div');
|
||
textWrap.className = 'source-row-text';
|
||
|
||
const meta = document.createElement('span');
|
||
meta.className = 'source-row-meta';
|
||
meta.textContent = item.meta || '来源';
|
||
|
||
const title = document.createElement('span');
|
||
title.className = 'source-row-title';
|
||
title.textContent = item.label || item.url || '';
|
||
|
||
textWrap.appendChild(title);
|
||
if (item.preview) {
|
||
const preview = document.createElement('span');
|
||
preview.className = 'source-row-preview';
|
||
preview.textContent = item.preview;
|
||
textWrap.appendChild(preview);
|
||
}
|
||
textWrap.appendChild(meta);
|
||
row.appendChild(icon);
|
||
row.appendChild(textWrap);
|
||
panel.appendChild(row);
|
||
});
|
||
|
||
if (searches.length) {
|
||
registerSectionAnchor('搜索轨迹');
|
||
const title = document.createElement('div');
|
||
title.className = 'sources-section-title';
|
||
title.textContent = '搜索轨迹';
|
||
panel.appendChild(title);
|
||
}
|
||
|
||
searches.forEach((item) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'source-row is-query';
|
||
|
||
const icon = document.createElement('div');
|
||
icon.className = 'source-row-icon';
|
||
icon.textContent = 'Q';
|
||
|
||
const textWrap = document.createElement('div');
|
||
textWrap.className = 'source-row-text';
|
||
|
||
const meta = document.createElement('span');
|
||
meta.className = 'source-row-meta';
|
||
meta.textContent = item.meta || '搜索轨迹';
|
||
|
||
const title = document.createElement('span');
|
||
title.className = 'source-row-title';
|
||
title.textContent = item.label || '';
|
||
|
||
textWrap.appendChild(meta);
|
||
textWrap.appendChild(title);
|
||
row.appendChild(icon);
|
||
row.appendChild(textWrap);
|
||
panel.appendChild(row);
|
||
});
|
||
|
||
wrapper.appendChild(summary);
|
||
wrapper.appendChild(panel);
|
||
panel.addEventListener('scroll', updatePanelHeading, { passive: true });
|
||
wrapper.addEventListener('toggle', () => {
|
||
if (wrapper.open) {
|
||
updatePanelHeading();
|
||
}
|
||
});
|
||
return wrapper;
|
||
}
|
||
|
||
function clearChat() {
|
||
messageHistory = [];
|
||
if (chatLog) {
|
||
chatLog.innerHTML = '';
|
||
}
|
||
showEmptyState();
|
||
}
|
||
|
||
function buildMessages() {
|
||
return buildMessagesFrom(messageHistory);
|
||
}
|
||
|
||
function buildMessagesFrom(history) {
|
||
const payload = [];
|
||
const systemPrompt = systemInput ? systemInput.value.trim() : '';
|
||
if (systemPrompt) {
|
||
payload.push({ role: 'system', content: systemPrompt });
|
||
}
|
||
for (const msg of history) {
|
||
payload.push({ role: msg.role, content: msg.content });
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function buildPayload() {
|
||
const payload = {
|
||
model: (modelSelect && modelSelect.value) || 'grok-3',
|
||
messages: buildMessages(),
|
||
stream: true,
|
||
temperature: Number(tempRange ? tempRange.value : 0.8),
|
||
top_p: Number(topPRange ? topPRange.value : 0.95)
|
||
};
|
||
const reasoning = reasoningSelect ? reasoningSelect.value : '';
|
||
if (reasoning) {
|
||
payload.reasoning_effort = reasoning;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function buildPayloadFrom(history) {
|
||
const payload = {
|
||
model: (modelSelect && modelSelect.value) || 'grok-3',
|
||
messages: buildMessagesFrom(history),
|
||
stream: true,
|
||
temperature: Number(tempRange ? tempRange.value : 0.8),
|
||
top_p: Number(topPRange ? topPRange.value : 0.95)
|
||
};
|
||
const reasoning = reasoningSelect ? reasoningSelect.value : '';
|
||
if (reasoning) {
|
||
payload.reasoning_effort = reasoning;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function closeModelPicker() {
|
||
if (!modelPicker || !modelPickerMenu || !modelPickerBtn) return;
|
||
modelPicker.classList.remove('open');
|
||
modelPickerMenu.classList.add('hidden');
|
||
modelPickerBtn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
|
||
function openModelPicker() {
|
||
if (!modelPicker || !modelPickerMenu || !modelPickerBtn) return;
|
||
modelPicker.classList.add('open');
|
||
modelPickerMenu.classList.remove('hidden');
|
||
modelPickerBtn.setAttribute('aria-expanded', 'true');
|
||
}
|
||
|
||
function setModelValue(modelId) {
|
||
if (!modelSelect || !modelId) return;
|
||
modelSelect.value = modelId;
|
||
if (modelPickerLabel) {
|
||
modelPickerLabel.textContent = modelId;
|
||
}
|
||
if (modelPickerMenu) {
|
||
const options = modelPickerMenu.querySelectorAll('.model-option');
|
||
options.forEach((node) => {
|
||
node.classList.toggle('active', node.dataset.value === modelId);
|
||
});
|
||
}
|
||
if (sessionsData) {
|
||
syncSessionModel();
|
||
saveSessions();
|
||
renderSessionList();
|
||
}
|
||
}
|
||
|
||
function renderModelOptions(models) {
|
||
if (!modelSelect || !modelPickerMenu) return;
|
||
modelSelect.innerHTML = '';
|
||
modelPickerMenu.innerHTML = '';
|
||
availableModels = Array.isArray(models) ? models.slice() : [];
|
||
|
||
availableModels.forEach((id) => {
|
||
const option = document.createElement('option');
|
||
option.value = id;
|
||
option.textContent = id;
|
||
modelSelect.appendChild(option);
|
||
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'model-option';
|
||
btn.textContent = id;
|
||
btn.dataset.value = id;
|
||
btn.setAttribute('role', 'option');
|
||
btn.addEventListener('click', () => {
|
||
setModelValue(id);
|
||
closeModelPicker();
|
||
});
|
||
modelPickerMenu.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
async function loadModels() {
|
||
if (!modelSelect) return;
|
||
const fallback = ['grok-4.1-fast', 'grok-4', 'grok-3', 'grok-3-mini', 'grok-3-thinking', 'grok-4.20-fast', 'grok-4.20-expert', 'grok-4.20-auto'];
|
||
const preferred = 'grok-4.20-auto';
|
||
let list = fallback;
|
||
|
||
try {
|
||
const authHeader = await ensurePublicKey();
|
||
if (authHeader === null) {
|
||
renderModelOptions(list);
|
||
if (list.includes(preferred)) {
|
||
setModelValue(preferred);
|
||
} else {
|
||
setModelValue(list[list.length - 1] || preferred);
|
||
}
|
||
return;
|
||
}
|
||
const res = await fetch('/v1/models', {
|
||
cache: 'no-store',
|
||
headers: buildAuthHeaders(authHeader)
|
||
});
|
||
if (!res.ok) throw new Error('models fetch failed');
|
||
const data = await res.json();
|
||
const items = Array.isArray(data && data.data) ? data.data : [];
|
||
const ids = items
|
||
.map(item => item && item.id)
|
||
.filter(Boolean)
|
||
.filter(id => !String(id).startsWith('grok-imagine'))
|
||
.filter(id => !String(id).includes('video'));
|
||
if (ids.length) list = ids;
|
||
} catch (e) {
|
||
list = fallback;
|
||
}
|
||
|
||
renderModelOptions(list);
|
||
if (list.includes(preferred)) {
|
||
setModelValue(preferred);
|
||
} else {
|
||
setModelValue(list[list.length - 1] || preferred);
|
||
}
|
||
restoreSessionModel();
|
||
}
|
||
|
||
function showAttachmentBadge() {
|
||
if (!fileBadge) return;
|
||
fileBadge.innerHTML = '';
|
||
if (!attachments.length) {
|
||
fileBadge.classList.add('hidden');
|
||
return;
|
||
}
|
||
fileBadge.classList.remove('hidden');
|
||
attachments.forEach((item, index) => {
|
||
const tag = document.createElement('div');
|
||
tag.className = 'file-badge-item';
|
||
tag.dataset.index = String(index);
|
||
|
||
const isImage = String(item.mime || '').startsWith('image/');
|
||
if (isImage && item.data) {
|
||
const preview = document.createElement('img');
|
||
preview.className = 'file-preview';
|
||
preview.src = item.data;
|
||
preview.alt = item.name || 'preview';
|
||
tag.classList.add('is-image');
|
||
tag.appendChild(preview);
|
||
}
|
||
|
||
const name = document.createElement('span');
|
||
name.className = 'file-name';
|
||
name.textContent = item.name || 'file';
|
||
tag.appendChild(name);
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button';
|
||
removeBtn.className = 'file-remove';
|
||
removeBtn.dataset.action = 'remove';
|
||
removeBtn.dataset.index = String(index);
|
||
removeBtn.textContent = '×';
|
||
tag.appendChild(removeBtn);
|
||
|
||
fileBadge.appendChild(tag);
|
||
});
|
||
}
|
||
|
||
function removeAttachmentAt(index) {
|
||
if (!Number.isInteger(index) || index < 0 || index >= attachments.length) return;
|
||
attachments.splice(index, 1);
|
||
if (!attachments.length && fileInput) {
|
||
fileInput.value = '';
|
||
}
|
||
showAttachmentBadge();
|
||
closeAttachmentPreview();
|
||
}
|
||
|
||
function clearAttachment() {
|
||
attachments = [];
|
||
if (fileInput) fileInput.value = '';
|
||
showAttachmentBadge();
|
||
closeAttachmentPreview();
|
||
}
|
||
|
||
function closeAttachmentPreview() {
|
||
const overlay = document.getElementById('attachmentPreviewOverlay');
|
||
if (!overlay) return;
|
||
overlay.remove();
|
||
}
|
||
|
||
function openAttachmentPreview(src, name) {
|
||
if (!src) return;
|
||
closeAttachmentPreview();
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'attachmentPreviewOverlay';
|
||
overlay.className = 'attachment-preview-overlay';
|
||
|
||
const img = document.createElement('img');
|
||
img.className = 'attachment-preview-image';
|
||
img.src = src;
|
||
img.alt = name || 'preview';
|
||
img.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
});
|
||
|
||
overlay.appendChild(img);
|
||
overlay.addEventListener('click', () => closeAttachmentPreview());
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
function readFileAsDataUrl(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = () => reject(new Error('文件读取失败'));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function readFileAsDataUrlFallback(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
const buffer = reader.result;
|
||
const bytes = new Uint8Array(buffer);
|
||
let binary = '';
|
||
const chunkSize = 0x8000;
|
||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||
const chunk = bytes.subarray(i, i + chunkSize);
|
||
binary += String.fromCharCode.apply(null, chunk);
|
||
}
|
||
const b64 = btoa(binary);
|
||
const mime = file && file.type ? file.type : 'application/octet-stream';
|
||
resolve(`data:${mime};base64,${b64}`);
|
||
} catch (e) {
|
||
reject(new Error('文件读取失败'));
|
||
}
|
||
};
|
||
reader.onerror = () => reject(new Error('文件读取失败'));
|
||
reader.readAsArrayBuffer(file);
|
||
});
|
||
}
|
||
|
||
function buildUniqueFileName(name) {
|
||
const baseName = name || 'file';
|
||
const exists = new Set(attachments.map(item => item.name));
|
||
if (!exists.has(baseName)) return baseName;
|
||
|
||
const dot = baseName.lastIndexOf('.');
|
||
const hasExt = dot > 0;
|
||
const prefix = hasExt ? baseName.slice(0, dot) : baseName;
|
||
const ext = hasExt ? baseName.slice(dot) : '';
|
||
let index = 2;
|
||
while (true) {
|
||
const candidate = `${prefix} (${index})${ext}`;
|
||
if (!exists.has(candidate)) return candidate;
|
||
index += 1;
|
||
}
|
||
}
|
||
|
||
async function handleFileSelect(file) {
|
||
if (!file) return false;
|
||
try {
|
||
let dataUrl = '';
|
||
try {
|
||
dataUrl = await readFileAsDataUrl(file);
|
||
} catch (e) {
|
||
dataUrl = await readFileAsDataUrlFallback(file);
|
||
}
|
||
attachments.push({
|
||
name: buildUniqueFileName(file.name || 'file'),
|
||
data: dataUrl,
|
||
mime: file.type || ''
|
||
});
|
||
try {
|
||
showAttachmentBadge();
|
||
} catch (e) {
|
||
console.error('showAttachmentBadge failed', e);
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
console.error('handleFileSelect failed', e, file);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function dataTransferHasFiles(dataTransfer) {
|
||
if (!dataTransfer) return false;
|
||
const types = Array.from(dataTransfer.types || []);
|
||
return types.includes('Files');
|
||
}
|
||
|
||
function extractFiles(dataTransfer) {
|
||
if (!dataTransfer) return [];
|
||
const items = Array.from(dataTransfer.items || []);
|
||
const filesFromItems = [];
|
||
const seen = new Set();
|
||
const pushUnique = (file) => {
|
||
if (!file) return;
|
||
const size = Number(file.size || 0);
|
||
if (size <= 0) return;
|
||
const key = `${file.name || ''}|${file.type || ''}|${size}|${file.lastModified || 0}`;
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
filesFromItems.push(file);
|
||
};
|
||
for (const item of items) {
|
||
if (item && item.kind === 'file') {
|
||
const file = item.getAsFile();
|
||
if (!file) continue;
|
||
const type = String(file.type || '').toLowerCase();
|
||
const hasName = Boolean(file.name);
|
||
const isUseful = type.startsWith('image/') || type.startsWith('audio/') || type.startsWith('video/') || type.startsWith('application/') || hasName;
|
||
if (isUseful) pushUnique(file);
|
||
}
|
||
}
|
||
if (filesFromItems.length) return filesFromItems;
|
||
const fallbackFiles = Array.from(dataTransfer.files || []).filter(Boolean).filter((file) => {
|
||
const type = String(file.type || '').toLowerCase();
|
||
return type.startsWith('image/') || type.startsWith('audio/') || type.startsWith('video/') || type.startsWith('application/') || Boolean(file.name);
|
||
});
|
||
fallbackFiles.forEach(pushUnique);
|
||
return filesFromItems;
|
||
}
|
||
|
||
function createActionButton(label, title, onClick) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'action-btn';
|
||
btn.type = 'button';
|
||
btn.textContent = label;
|
||
if (title) btn.title = title;
|
||
if (onClick) btn.addEventListener('click', onClick);
|
||
return btn;
|
||
}
|
||
|
||
function attachAssistantActions(entry) {
|
||
if (!entry || !entry.row) return;
|
||
const existing = entry.row.querySelector('.message-actions');
|
||
if (existing) existing.remove();
|
||
const actions = document.createElement('div');
|
||
actions.className = 'message-actions';
|
||
const sourcesWidget = createSourcesWidget(entry);
|
||
|
||
const retryBtn = createActionButton('重试', '重试上一条回答', () => retryLast());
|
||
const copyBtn = createActionButton('复制', '复制回答内容', () => copyToClipboard(entry.raw || ''));
|
||
const quoteBtn = createActionButton('引用', '把这条回答里的图片加入附件', () => quoteAssistantImages(entry));
|
||
const feedbackBtn = createActionButton('反馈', '反馈到 Grok2API', () => {
|
||
window.open(feedbackUrl, '_blank', 'noopener');
|
||
});
|
||
|
||
if (sourcesWidget) actions.appendChild(sourcesWidget);
|
||
actions.appendChild(retryBtn);
|
||
actions.appendChild(copyBtn);
|
||
actions.appendChild(quoteBtn);
|
||
actions.appendChild(feedbackBtn);
|
||
entry.row.appendChild(actions);
|
||
}
|
||
|
||
async function copyToClipboard(text) {
|
||
if (!text) {
|
||
toast('暂无内容可复制', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
await navigator.clipboard.writeText(text);
|
||
} else {
|
||
const temp = document.createElement('textarea');
|
||
temp.value = text;
|
||
temp.style.position = 'fixed';
|
||
temp.style.opacity = '0';
|
||
document.body.appendChild(temp);
|
||
temp.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(temp);
|
||
}
|
||
toast('已复制', 'success');
|
||
} catch (e) {
|
||
toast('复制失败', 'error');
|
||
}
|
||
}
|
||
|
||
async function retryLast() {
|
||
if (isSending) return;
|
||
if (!messageHistory.length) return;
|
||
let lastUserIndex = -1;
|
||
for (let i = messageHistory.length - 1; i >= 0; i -= 1) {
|
||
if (messageHistory[i].role === 'user') {
|
||
lastUserIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
if (lastUserIndex === -1) {
|
||
toast('没有可重试的对话', 'error');
|
||
return;
|
||
}
|
||
const historySlice = messageHistory.slice(0, lastUserIndex + 1);
|
||
const retrySessionId = sessionsData ? sessionsData.activeId : null;
|
||
const assistantEntry = createMessage('assistant', '');
|
||
setSendingState(true);
|
||
setStatus('connecting', '发送中');
|
||
followStreamScroll = true;
|
||
userLockedStreamScroll = false;
|
||
|
||
abortController = new AbortController();
|
||
const payload = buildPayloadFrom(historySlice);
|
||
|
||
let headers = { 'Content-Type': 'application/json' };
|
||
try {
|
||
const authHeader = await ensurePublicKey();
|
||
headers = { ...headers, ...buildAuthHeaders(authHeader) };
|
||
} catch (e) {
|
||
// ignore auth helper failures
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/v1/public/chat/completions', {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(payload),
|
||
signal: abortController.signal
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw await parseApiError(res);
|
||
}
|
||
|
||
await handleStream(res, assistantEntry, retrySessionId);
|
||
setStatus('connected', '完成');
|
||
} catch (e) {
|
||
if (e && e.name === 'AbortError') {
|
||
updateMessage(assistantEntry, assistantEntry.raw || '已中止', true);
|
||
if (!assistantEntry.committed) {
|
||
assistantEntry.committed = true;
|
||
if (retrySessionId) {
|
||
commitToSession(retrySessionId, assistantEntry.raw || '', assistantEntry.sources, assistantEntry.rendering);
|
||
} else {
|
||
messageHistory.push({ role: 'assistant', content: assistantEntry.raw || '', sources: assistantEntry.sources || null, rendering: assistantEntry.rendering || null });
|
||
}
|
||
}
|
||
setStatus('error', '已中止');
|
||
} else {
|
||
updateMessage(assistantEntry, `请求失败: ${e.message || e}`, true);
|
||
setStatus('error', '失败');
|
||
toast(e.message || '请求失败,请检查服务状态', 'error');
|
||
}
|
||
} finally {
|
||
setSendingState(false);
|
||
abortController = null;
|
||
scrollToBottom();
|
||
}
|
||
}
|
||
|
||
async function sendMessage() {
|
||
if (isSending) return;
|
||
const prompt = promptInput ? promptInput.value.trim() : '';
|
||
if (!prompt && attachments.length === 0) {
|
||
toast('请输入内容', 'error');
|
||
return;
|
||
}
|
||
|
||
const attachmentsSnapshot = attachments.map((item) => ({ ...item }));
|
||
const userEntry = createMessage('user', '');
|
||
renderUserMessage(userEntry, prompt, attachmentsSnapshot);
|
||
|
||
let content = prompt;
|
||
if (attachments.length) {
|
||
const blocks = [];
|
||
if (prompt) {
|
||
blocks.push({ type: 'text', text: prompt });
|
||
}
|
||
attachments.forEach((item) => {
|
||
const isImage = String(item.mime || '').startsWith('image/');
|
||
if (isImage) {
|
||
blocks.push({ type: 'image_url', image_url: { url: item.data } });
|
||
} else {
|
||
blocks.push({ type: 'file', file: { file_data: item.data } });
|
||
}
|
||
});
|
||
content = blocks;
|
||
}
|
||
|
||
messageHistory.push({ role: 'user', content });
|
||
trimMessageHistory();
|
||
if (promptInput) promptInput.value = '';
|
||
clearAttachment();
|
||
syncCurrentSession();
|
||
syncSessionModel();
|
||
updateSessionTitle(getActiveSession());
|
||
saveSessions();
|
||
renderSessionList();
|
||
|
||
const sendSessionId = sessionsData ? sessionsData.activeId : null;
|
||
const assistantEntry = createMessage('assistant', '');
|
||
setSendingState(true);
|
||
setStatus('connecting', '发送中');
|
||
followStreamScroll = true;
|
||
userLockedStreamScroll = false;
|
||
|
||
abortController = new AbortController();
|
||
const payload = buildPayload();
|
||
|
||
let headers = { 'Content-Type': 'application/json' };
|
||
try {
|
||
const authHeader = await ensurePublicKey();
|
||
headers = { ...headers, ...buildAuthHeaders(authHeader) };
|
||
} catch (e) {
|
||
// ignore auth helper failures
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/v1/public/chat/completions', {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(payload),
|
||
signal: abortController.signal
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw await parseApiError(res);
|
||
}
|
||
|
||
await handleStream(res, assistantEntry, sendSessionId);
|
||
setStatus('connected', '完成');
|
||
} catch (e) {
|
||
if (e && e.name === 'AbortError') {
|
||
updateMessage(assistantEntry, assistantEntry.raw || '已中止', true);
|
||
if (assistantEntry.hasThink) {
|
||
const elapsed = assistantEntry.thinkElapsed || Math.max(1, Math.round((Date.now() - assistantEntry.startedAt) / 1000));
|
||
updateThinkSummary(assistantEntry, elapsed);
|
||
}
|
||
setStatus('error', '已中止');
|
||
if (!assistantEntry.committed) {
|
||
assistantEntry.committed = true;
|
||
if (sendSessionId) {
|
||
commitToSession(sendSessionId, assistantEntry.raw || '', assistantEntry.sources, assistantEntry.rendering);
|
||
} else {
|
||
messageHistory.push({ role: 'assistant', content: assistantEntry.raw || '', sources: assistantEntry.sources || null, rendering: assistantEntry.rendering || null });
|
||
}
|
||
}
|
||
} else {
|
||
updateMessage(assistantEntry, `请求失败: ${e.message || e}`, true);
|
||
setStatus('error', '失败');
|
||
toast(e.message || '请求失败,请检查服务状态', 'error');
|
||
}
|
||
} finally {
|
||
setSendingState(false);
|
||
abortController = null;
|
||
scrollToBottom();
|
||
}
|
||
}
|
||
|
||
function commitToSession(sessionId, assistantText, assistantSources = null, assistantRendering = null) {
|
||
if (!sessionId || !sessionsData) return;
|
||
const session = sessionsData.sessions.find((s) => s.id === sessionId);
|
||
if (!session) return;
|
||
session.messages.push({
|
||
role: 'assistant',
|
||
content: assistantText,
|
||
sources: assistantSources || null,
|
||
rendering: assistantRendering || null
|
||
});
|
||
if (session.messages.length > MAX_CONTEXT_MESSAGES) {
|
||
session.messages = session.messages.slice(-MAX_CONTEXT_MESSAGES);
|
||
}
|
||
session.updatedAt = Date.now();
|
||
updateSessionTitle(session);
|
||
if (sessionsData.activeId === sessionId) {
|
||
messageHistory = session.messages.slice();
|
||
trimMessageHistory();
|
||
} else {
|
||
session.unread = true;
|
||
}
|
||
saveSessions();
|
||
renderSessionList();
|
||
}
|
||
|
||
async function handleStream(res, assistantEntry, targetSessionId = null) {
|
||
activeStreamInfo = { sessionId: targetSessionId, entry: assistantEntry };
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder('utf-8');
|
||
let buffer = '';
|
||
let assistantText = '';
|
||
try {
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const parts = buffer.split('\n\n');
|
||
buffer = parts.pop() || '';
|
||
for (const part of parts) {
|
||
const lines = part.split('\n');
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed.startsWith('data:')) continue;
|
||
const payload = trimmed.slice(5).trim();
|
||
if (!payload) continue;
|
||
if (payload === '[DONE]') {
|
||
updateMessage(assistantEntry, assistantText, true);
|
||
if (assistantEntry.hasThink) {
|
||
const elapsed = assistantEntry.thinkElapsed || Math.max(1, Math.round((Date.now() - assistantEntry.startedAt) / 1000));
|
||
updateThinkSummary(assistantEntry, elapsed);
|
||
}
|
||
assistantEntry.committed = true;
|
||
if (targetSessionId) {
|
||
commitToSession(targetSessionId, assistantText, assistantEntry.sources, assistantEntry.rendering);
|
||
} else {
|
||
messageHistory.push({ role: 'assistant', content: assistantText, sources: assistantEntry.sources || null, rendering: assistantEntry.rendering || null });
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
const json = JSON.parse(payload);
|
||
if (json && json.sources && typeof json.sources === 'object') {
|
||
assistantEntry.sources = json.sources;
|
||
if (assistantEntry.row && assistantEntry.row.querySelector('.message-actions')) {
|
||
attachAssistantActions(assistantEntry);
|
||
}
|
||
}
|
||
if (json && json.rendering && typeof json.rendering === 'object') {
|
||
assistantEntry.rendering = json.rendering;
|
||
if (!targetSessionId || (sessionsData && sessionsData.activeId === targetSessionId)) {
|
||
updateMessage(assistantEntry, assistantText, false);
|
||
}
|
||
}
|
||
const delta = json && json.choices && json.choices[0] && json.choices[0].delta
|
||
? json.choices[0].delta.content
|
||
: '';
|
||
if (delta) {
|
||
assistantText += delta;
|
||
if (!assistantEntry.firstTokenAt) {
|
||
assistantEntry.firstTokenAt = Date.now();
|
||
}
|
||
if (!assistantEntry.hasThink && assistantText.includes('<think>')) {
|
||
assistantEntry.hasThink = true;
|
||
assistantEntry.thinkingActive = true;
|
||
assistantEntry.thinkElapsed = null;
|
||
updateThinkSummary(assistantEntry, null);
|
||
}
|
||
if (!targetSessionId || (sessionsData && sessionsData.activeId === targetSessionId)) {
|
||
updateMessage(assistantEntry, assistantText, false);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// ignore parse errors
|
||
}
|
||
}
|
||
}
|
||
}
|
||
updateMessage(assistantEntry, assistantText, true);
|
||
if (assistantEntry.hasThink) {
|
||
const elapsed = assistantEntry.thinkElapsed || Math.max(1, Math.round((Date.now() - assistantEntry.startedAt) / 1000));
|
||
updateThinkSummary(assistantEntry, elapsed);
|
||
}
|
||
assistantEntry.committed = true;
|
||
if (targetSessionId) {
|
||
commitToSession(targetSessionId, assistantText, assistantEntry.sources, assistantEntry.rendering);
|
||
} else {
|
||
messageHistory.push({ role: 'assistant', content: assistantText, sources: assistantEntry.sources || null, rendering: assistantEntry.rendering || null });
|
||
}
|
||
} finally {
|
||
activeStreamInfo = null;
|
||
}
|
||
}
|
||
|
||
function toggleSettings(show) {
|
||
if (!settingsPanel) return;
|
||
if (typeof show === 'boolean') {
|
||
settingsPanel.classList.toggle('hidden', !show);
|
||
return;
|
||
}
|
||
settingsPanel.classList.toggle('hidden');
|
||
}
|
||
|
||
function bindEvents() {
|
||
if (tempRange) tempRange.addEventListener('input', updateRangeValues);
|
||
if (topPRange) topPRange.addEventListener('input', updateRangeValues);
|
||
if (sendBtn) {
|
||
sendBtn.addEventListener('click', () => {
|
||
if (isSending) {
|
||
abortCurrentRequest();
|
||
return;
|
||
}
|
||
sendMessage();
|
||
});
|
||
}
|
||
if (modelPickerBtn) {
|
||
modelPickerBtn.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
if (modelPicker && modelPicker.classList.contains('open')) {
|
||
closeModelPicker();
|
||
} else {
|
||
openModelPicker();
|
||
}
|
||
});
|
||
}
|
||
if (settingsToggle) {
|
||
settingsToggle.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
toggleSettings();
|
||
});
|
||
}
|
||
document.addEventListener('click', (event) => {
|
||
if (modelPicker && !modelPicker.contains(event.target)) {
|
||
closeModelPicker();
|
||
}
|
||
if (!settingsPanel || settingsPanel.classList.contains('hidden')) return;
|
||
if (settingsPanel.contains(event.target) || (settingsToggle && settingsToggle.contains(event.target))) {
|
||
return;
|
||
}
|
||
toggleSettings(false);
|
||
});
|
||
if (promptInput) {
|
||
let composing = false;
|
||
promptInput.addEventListener('compositionstart', () => {
|
||
composing = true;
|
||
});
|
||
promptInput.addEventListener('compositionend', () => {
|
||
composing = false;
|
||
});
|
||
promptInput.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Enter' && !event.shiftKey) {
|
||
if (composing || event.isComposing) return;
|
||
event.preventDefault();
|
||
sendMessage();
|
||
}
|
||
});
|
||
promptInput.addEventListener('paste', async (event) => {
|
||
const files = extractFiles(event.clipboardData);
|
||
if (!files.length) return;
|
||
event.preventDefault();
|
||
let okCount = 0;
|
||
for (const file of files) {
|
||
if (await handleFileSelect(file)) okCount += 1;
|
||
}
|
||
if (okCount > 0) {
|
||
toast(`已粘贴 ${okCount} 个文件`, 'success');
|
||
}
|
||
if (okCount < files.length) {
|
||
toast('部分文件读取失败', 'error');
|
||
}
|
||
});
|
||
}
|
||
if (attachBtn && fileInput) {
|
||
attachBtn.addEventListener('click', () => fileInput.click());
|
||
fileInput.addEventListener('change', async () => {
|
||
const files = Array.from(fileInput.files || []);
|
||
if (!files.length) return;
|
||
let okCount = 0;
|
||
for (const file of files) {
|
||
if (await handleFileSelect(file)) okCount += 1;
|
||
}
|
||
if (okCount > 0) {
|
||
toast(`已选择 ${okCount} 个文件`, 'success');
|
||
}
|
||
if (okCount < files.length) {
|
||
toast('部分文件读取失败', 'error');
|
||
}
|
||
fileInput.value = '';
|
||
});
|
||
}
|
||
if (fileBadge) {
|
||
fileBadge.addEventListener('click', (event) => {
|
||
const removeBtn = event.target.closest('.file-remove');
|
||
if (removeBtn) {
|
||
event.stopPropagation();
|
||
const index = Number(removeBtn.dataset.index);
|
||
if (Number.isInteger(index)) {
|
||
removeAttachmentAt(index);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const tag = event.target.closest('.file-badge-item');
|
||
if (!tag) return;
|
||
const index = Number(tag.dataset.index);
|
||
if (!Number.isInteger(index) || index < 0 || index >= attachments.length) return;
|
||
const item = attachments[index];
|
||
const isImage = String(item.mime || '').startsWith('image/');
|
||
if (!isImage || !item.data) return;
|
||
|
||
const opened = document.getElementById('attachmentPreviewOverlay');
|
||
if (opened) {
|
||
closeAttachmentPreview();
|
||
return;
|
||
}
|
||
openAttachmentPreview(item.data, item.name);
|
||
});
|
||
}
|
||
if (newChatBtn) {
|
||
newChatBtn.addEventListener('click', createSession);
|
||
}
|
||
if (collapseSidebarBtn) {
|
||
collapseSidebarBtn.addEventListener('click', toggleSidebar);
|
||
}
|
||
if (sidebarExpandBtn) {
|
||
sidebarExpandBtn.addEventListener('click', openSidebar);
|
||
}
|
||
if (sidebarToggle) {
|
||
sidebarToggle.addEventListener('click', toggleSidebar);
|
||
}
|
||
if (sidebarOverlay) {
|
||
sidebarOverlay.addEventListener('click', closeSidebar);
|
||
}
|
||
|
||
document.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Escape') {
|
||
closeModelPicker();
|
||
closeAttachmentPreview();
|
||
closeChatImagePreview();
|
||
}
|
||
});
|
||
window.addEventListener('resize', () => {
|
||
document.querySelectorAll('.message-content .img-grid').forEach((grid) => {
|
||
updateImageGridLayout(grid);
|
||
});
|
||
syncImageGridControls(document);
|
||
});
|
||
|
||
const composerInput = document.querySelector('.composer-input');
|
||
if (composerInput) {
|
||
let dragDepth = 0;
|
||
const setDragState = (active) => {
|
||
composerInput.classList.toggle('drag-over', Boolean(active));
|
||
};
|
||
|
||
composerInput.addEventListener('dragenter', (event) => {
|
||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||
event.preventDefault();
|
||
dragDepth += 1;
|
||
setDragState(true);
|
||
});
|
||
|
||
composerInput.addEventListener('dragover', (event) => {
|
||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = 'copy';
|
||
});
|
||
|
||
composerInput.addEventListener('dragleave', (event) => {
|
||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||
event.preventDefault();
|
||
dragDepth = Math.max(0, dragDepth - 1);
|
||
if (dragDepth === 0) {
|
||
setDragState(false);
|
||
}
|
||
});
|
||
|
||
composerInput.addEventListener('drop', async (event) => {
|
||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||
event.preventDefault();
|
||
dragDepth = 0;
|
||
setDragState(false);
|
||
const files = extractFiles(event.dataTransfer);
|
||
if (!files.length) return;
|
||
let okCount = 0;
|
||
for (const file of files) {
|
||
if (await handleFileSelect(file)) okCount += 1;
|
||
}
|
||
if (okCount > 0) {
|
||
toast(`已添加 ${okCount} 个文件`, 'success');
|
||
}
|
||
if (okCount < files.length) {
|
||
toast('部分文件读取失败', 'error');
|
||
}
|
||
});
|
||
|
||
document.addEventListener('dragover', (event) => {
|
||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||
event.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('drop', (event) => {
|
||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||
if (composerInput.contains(event.target)) return;
|
||
event.preventDefault();
|
||
});
|
||
}
|
||
|
||
const handleScrollTracking = () => {
|
||
if (suppressScrollTracking) return;
|
||
if (isSending) {
|
||
lockStreamScrollFollow();
|
||
return;
|
||
}
|
||
updateFollowStreamScroll();
|
||
};
|
||
const handleUserScrollIntent = () => {
|
||
lockStreamScrollFollow();
|
||
};
|
||
if (chatLog) {
|
||
chatLog.addEventListener('scroll', handleScrollTracking, { passive: true });
|
||
chatLog.addEventListener('wheel', handleUserScrollIntent, { passive: true });
|
||
chatLog.addEventListener('touchmove', handleUserScrollIntent, { passive: true });
|
||
chatLog.addEventListener('pointerdown', handleUserScrollIntent, { passive: true });
|
||
}
|
||
window.addEventListener('scroll', handleScrollTracking, { passive: true });
|
||
window.addEventListener('wheel', handleUserScrollIntent, { passive: true });
|
||
window.addEventListener('touchmove', handleUserScrollIntent, { passive: true });
|
||
}
|
||
|
||
updateRangeValues();
|
||
setSendingState(false);
|
||
bindEvents();
|
||
restoreSidebarState();
|
||
loadSessions();
|
||
loadModels();
|
||
})();
|
||
|
||
|
||
|