// chat-render.js -- 消息渲染 / 流式输出 / 音频气泡(新版:无气泡 + 媒体行 + agent-run-block) import { $, escapeHtml, getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage, clamp, formatAudioDuration, getAudioBubbleWidth, codeBlockNode, inferUploadKind, dedupePendingFiles, showToast, getFileIcon } from './utils.js'; import { state } from './state.js'; import { showImagePreview, renderUserFileCard, attachHorizontalScroll, showFilePreviewDialog, } from './toolcalls.js'; import { renderTurnNav } from './turn-nav.js'; let rollbackChatFn = null; export function setRollbackChatFn(fn) { rollbackChatFn = fn; } let renderPersistentToolCallsFn = null; export function setRenderPersistentToolCalls(fn) { renderPersistentToolCallsFn = fn; } const chatMessages = $("chatMessages"); const chatPlaceholder = $("chatPlaceholder"); /* ===== 双容器渲染支持(chat / cron 各自独立消息容器) ===== * 所有渲染函数默认作用于 chat 容器(#chatMessages), * 通过 opts.container 可指定目标容器(如 cron 模式的 #cronMessages)。 * 占位符与容器配对:container._placeholderEl 或 dataset.placeholderId。 */ function _defaultContainer() { return chatMessages; } /** 获取容器配对的占位符元素(无则返回 null)。 */ function _placeholderFor(container) { if (!container) return null; if (container._placeholderEl) return container._placeholderEl; const pid = container.dataset ? container.dataset.placeholderId : ""; if (pid) { const el = $(pid); if (el) { container._placeholderEl = el; return el; } } // chat 容器默认配对 chatPlaceholder if (container === chatMessages) return chatPlaceholder; return null; } /** 注册容器 ↔ 占位符配对(初始化时调用)。 */ export function registerMessageContainer(container, placeholderEl) { if (container && placeholderEl) container._placeholderEl = placeholderEl; } function scrollChatToBottom(container) { const el = container || chatMessages; if (!el) return; el.scrollTop = el.scrollHeight; } function renderAttachmentChips() { const imagesArea = $("inputInlineImages"); const chipsArea = $("inputInlineChips"); const wrap = $("inputInlineArea"); if (!imagesArea || !chipsArea || !wrap) return; // 去重(防御性:确保无重复条目) state.pendingFiles = dedupePendingFiles(state.pendingFiles); // 清除自身渲染的元素(非 ref 类型) imagesArea.querySelectorAll(".input-inline-img-thumb-wrap:not(.ref-img-thumb-wrap)").forEach((wrapEl) => { const imgEl = wrapEl.querySelector("img"); if (imgEl && imgEl._blobUrl) URL.revokeObjectURL(imgEl._blobUrl); wrapEl.remove(); }); chipsArea.querySelectorAll(".chip, .chip--folder").forEach((el) => el.remove()); chipsArea.querySelectorAll(".ref-chip").forEach((el) => el.remove()); imagesArea.querySelectorAll(".ref-img-thumb-wrap").forEach((el) => el.remove()); let hasContent = false; const imageFiles = []; const folderItems = []; const otherFiles = []; const refImages = []; const refFiles = []; const refFolderItems = []; const refQuotes = []; state.pendingFiles.forEach((f, i) => { if (f.__isRef) { if (f.type === 'ref/image') refImages.push({ file: f, idx: i }); else if (f.type === 'ref/folder') refFolderItems.push({ file: f, idx: i }); else if (f.type === 'ref/quote') refQuotes.push({ file: f, idx: i }); else refFiles.push({ file: f, idx: i }); } else { const kind = inferUploadKind(f); if (kind === "folder") folderItems.push({ file: f, idx: i }); else if (kind === "image") imageFiles.push({ file: f, idx: i }); else otherFiles.push({ file: f, idx: i }); } }); // 上传/拖放图片缩略图 imageFiles.forEach(({ file, idx }) => { const url = URL.createObjectURL(file); const wrapEl = document.createElement("div"); wrapEl.className = "input-inline-img-thumb-wrap"; const img = document.createElement("img"); img.className = "input-inline-img-thumb"; img.src = url; img._blobUrl = url; img._pendingIdx = idx; img.title = file.name; img.addEventListener("click", () => { // 点击图片本身 → 打开预览浮窗 showImagePreview(url); }); const delBtn = document.createElement("button"); delBtn.type = "button"; delBtn.className = "input-inline-img-del"; delBtn.setAttribute("aria-label", "移除"); delBtn.title = "移除"; delBtn.textContent = "\u00D7"; delBtn.addEventListener("click", (e) => { e.stopPropagation(); state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx); renderAttachmentChips(); }); wrapEl.appendChild(img); wrapEl.appendChild(delBtn); imagesArea.appendChild(wrapEl); hasContent = true; }); // edit 模式拖拽的图片引用缩略图 refImages.forEach(({ file, idx }) => { const wrapEl = document.createElement("div"); wrapEl.className = "input-inline-img-thumb-wrap ref-img-thumb-wrap"; const img = document.createElement("img"); img.className = "input-inline-img-thumb ref-img-thumb"; img.src = ""; img.title = file.refName || file.name; img.addEventListener("click", () => { // 点击图片本身 → 打开预览浮窗(src 由 _loadRefImageSrc 异步加载) if (img.src && img.src !== location.href && !img.src.endsWith("/image/文档按钮.svg")) { showImagePreview(img.src); } }); const delBtn = document.createElement("button"); delBtn.type = "button"; delBtn.className = "input-inline-img-del"; delBtn.setAttribute("aria-label", "移除"); delBtn.title = "移除"; delBtn.textContent = "\u00D7"; delBtn.addEventListener("click", (e) => { e.stopPropagation(); state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx); if (file.refPath) { state.pendingRefs = state.pendingRefs.filter((r) => r.path !== file.refPath); } renderAttachmentChips(); }); wrapEl.appendChild(img); wrapEl.appendChild(delBtn); imagesArea.appendChild(wrapEl); hasContent = true; _loadRefImageSrc(img, file); }); // 文件夹气泡 folderItems.forEach(({ file, idx }) => { const chip = document.createElement("span"); chip.className = "chip chip--folder"; chip.innerHTML = ` ${escapeHtml(file.name)} `; chip.querySelector("button").addEventListener("click", () => { state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx); renderAttachmentChips(); }); chipsArea.appendChild(chip); hasContent = true; }); // edit 模式拖拽的文件夹引用 chips(显示单个文件夹 UI,不展开内容) refFolderItems.forEach(({ file, idx }) => { const chip = document.createElement("span"); chip.className = "ref-chip ref-chip--folder"; chip.innerHTML = ` ${escapeHtml(file.refName || file.name)} `; chip.querySelector("button").addEventListener("click", () => { state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx); if (file.refPath !== undefined) { state.pendingRefs = state.pendingRefs.filter((r) => !(r.isFolder && r.path === file.refPath)); } renderAttachmentChips(); }); chipsArea.appendChild(chip); hasContent = true; }); // 上传/拖放文件 chips otherFiles.forEach(({ file, idx }) => { const chip = document.createElement("span"); chip.className = "chip"; chip.innerHTML = ` ${escapeHtml(file.name)} `; chip.querySelector("button").addEventListener("click", () => { state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx); renderAttachmentChips(); }); chipsArea.appendChild(chip); hasContent = true; }); // edit 模式拖拽的文件引用 chips refFiles.forEach(({ file, idx }) => { const chip = document.createElement("span"); chip.className = "ref-chip"; chip.innerHTML = ` ${escapeHtml(file.refName || file.name)} `; chip.querySelector("button").addEventListener("click", () => { state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx); if (file.refPath) { state.pendingRefs = state.pendingRefs.filter((r) => r.path !== file.refPath); } renderAttachmentChips(); }); chipsArea.appendChild(chip); hasContent = true; }); // 聊天区文字引用 chips(复用 ref-chip UI,注明为用户引用) refQuotes.forEach(({ file, idx }) => { const chip = document.createElement("span"); chip.className = "ref-chip ref-chip--quote"; chip.title = "用户引用:" + (file.refText || file.refName || ""); chip.innerHTML = ` ${escapeHtml(file.refName || file.name)} `; chip.querySelector("button").addEventListener("click", () => { state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx); if (file.refPath) { state.pendingRefs = state.pendingRefs.filter((r) => r.path !== file.refPath); } renderAttachmentChips(); }); chipsArea.appendChild(chip); hasContent = true; }); if (hasContent || imagesArea.children.length > 0 || chipsArea.children.length > 0) { wrap.classList.add("has-content"); } else { wrap.classList.remove("has-content"); } } async function _loadRefImageSrc(img, file) { // 非 Electron 环境下无法读取文件,尝试直接用路径 if (file.refPath && file.refPath.startsWith("data:")) { img.src = file.refPath; return; } try { const { readBinaryFile } = await import('./electron-api.js'); if (file.refPath) { const res = await readBinaryFile(file.refPath, state._docRootPath); if (res && res.status === 'ok' && res.data) { img.src = `data:${res.mime || 'image/png'};base64,${res.data}`; return; } } } catch {} // 回退 img.src = '/image/文档按钮.svg'; img.style.width = '24px'; img.style.height = '24px'; img.style.objectFit = 'contain'; } function renderMarkdownToFragment(text) { const raw = String(text ?? ""); const html = window.marked ? window.marked.parse(raw, { breaks: false, gfm: true }) : (() => { const s = String(raw); return escapeHtml(s).replace(/ /g, " ").replace(/\t/g, "    ").replace(/\n/g, "
"); })(); const safeHtml = window.DOMPurify ? window.DOMPurify.sanitize(html) : html; const tpl = document.createElement("template"); tpl.innerHTML = safeHtml; tpl.content.querySelectorAll("pre code").forEach((codeEl) => { const pre = codeEl.parentElement; if (!pre) return; const rawCode = codeEl.textContent || ""; const cls = codeEl.className || ""; const m = cls.match(/language-([\w-]+)/); const wrap = codeBlockNode(m ? m[1] : "text", rawCode); pre.replaceWith(wrap); }); return tpl.content.cloneNode(true); } function splitMarkdownStreamBlocks(text) { const raw = String(text ?? ""); const lines = raw.split("\n"); const blocks = []; let current = []; let inFence = false; function flushCurrent() { if (current.length === 0) return; blocks.push(current.join("\n")); current = []; } for (const line of lines) { const trimmed = line.trimStart(); const isFence = trimmed.startsWith("```"); if (isFence) { current.push(line); if (inFence) { flushCurrent(); inFence = false; } else { flushCurrent(); inFence = true; current = [line]; } continue; } if (inFence) { current.push(line); continue; } if (line.trim() === "") { current.push(line); flushCurrent(); continue; } const looksLikeList = /^\s*([-*+]\s|\d+\.\s|>\s)/.test(line); if (looksLikeList) { current.push(line); continue; } const prev = current[current.length - 1] || ""; const prevLooksLikeList = /^\s*([-*+]\s|\d+\.\s|>\s)/.test(prev); if (prevLooksLikeList && line.trim() !== "") { current.push(line); continue; } current.push(line); } flushCurrent(); return blocks; } function renderMarkdownBlock(text, isStreaming = false) { const raw = String(text ?? ""); const d = document.createElement("div"); d.className = "msg-prose markdown-body" + (isStreaming ? " markdown-body--streaming" : ""); d.appendChild(renderMarkdownToFragment(raw)); return d; } function fillAssistantBubbleImmediate(bubble, text) { const t = text == null ? "" : String(text); bubble.classList.add("msg-bubble--rich", "markdown-body"); bubble.textContent = ""; bubble.appendChild(renderMarkdownToFragment(t)); } async function pseudoStreamAssistant(bubble, full) { const t = full == null ? "" : String(full); bubble.classList.add("msg-bubble--rich", "markdown-body"); bubble.textContent = ""; const n = t.length; const charsPerSec = 350; const totalMs = Math.min(4000, Math.max(300, (n / charsPerSec) * 1000)); const t0 = performance.now(); let lastSnapshot = ""; // 流式期间固定滚动目标为 bubble 所属的消息容器(双容器安全) const scrollTarget = bubble.closest(".chat-messages") || chatMessages; return new Promise((resolve) => { function frame(now) { const elapsed = now - t0; const p = Math.min(1, elapsed / totalMs); const k = Math.min(n, Math.floor(p * n)); const snapshot = t.slice(0, k); if (snapshot !== lastSnapshot) { lastSnapshot = snapshot; const blocks = splitMarkdownStreamBlocks(snapshot); bubble.textContent = ""; blocks.forEach((block, idx) => { const isLast = idx === blocks.length - 1; const stillOpenFence = (block.match(/```/g) || []).length % 2 === 1; bubble.appendChild(renderMarkdownBlock(block, isLast || stillOpenFence)); }); scrollChatToBottom(scrollTarget); } if (p < 1) { requestAnimationFrame(frame); } else { bubble.textContent = ""; bubble.appendChild(renderMarkdownToFragment(t)); scrollChatToBottom(scrollTarget); resolve(); } } requestAnimationFrame(frame); }); } function renderAudioBubble(part, options = {}) { const { autoplay = false, compact = false, messageMeta = null, role = "assistant", allowAutoplay = false, } = options; const wrap = document.createElement("div"); wrap.className = "audio-pill-wrap" + (compact ? " audio-pill-wrap--compact" : "") + (role === "user" ? " audio-pill-wrap--user" : " audio-pill-wrap--assistant"); const durationSeconds = part && part.duration_seconds != null ? part.duration_seconds : (messageMeta && messageMeta.audio_duration_seconds != null ? messageMeta.audio_duration_seconds : null); const durationLabel = formatAudioDuration(durationSeconds); const bubbleWidth = getAudioBubbleWidth(durationSeconds); const displayName = role === "user" ? "语音" : (part.name || "语音消息"); const button = document.createElement("button"); button.type = "button"; button.className = `audio-pill audio-pill--${role}`; if (bubbleWidth) button.style.width = bubbleWidth; button.innerHTML = ` \u25B6 ${escapeHtml(displayName)} 0.0\u2033 / ${escapeHtml(durationLabel || "语音")} `; const audio = new Audio(part.url); audio.preload = "metadata"; const iconEl = button.querySelector(".audio-pill-icon"); const durationEl = button.querySelector(".audio-pill-duration"); const progressEl = button.querySelector(".audio-pill-progress"); let resolvedDuration = Number(durationSeconds); function updateProgressUi() { const total = Number.isFinite(resolvedDuration) && resolvedDuration > 0 ? resolvedDuration : audio.duration; const current = audio.currentTime || 0; const currentLabel = formatAudioDuration(current) || "0.0\u2033"; const totalLabel = formatAudioDuration(total) || durationLabel || "语音"; if (durationEl) durationEl.textContent = role === "user" ? totalLabel : `${currentLabel} / ${totalLabel}`; if (progressEl && Number.isFinite(total) && total > 0) { const percent = clamp((current / total) * 100, 0, 100); progressEl.style.setProperty("--audio-progress", `${percent}%`); } } function syncDurationFromAudio() { const runtimeDuration = audio.duration; if (Number.isFinite(runtimeDuration) && runtimeDuration > 0) { resolvedDuration = runtimeDuration; const runtimeWidth = getAudioBubbleWidth(runtimeDuration); if (runtimeWidth) button.style.width = runtimeWidth; } updateProgressUi(); } function setPlayingUi(playing) { button.classList.toggle("is-playing", playing); if (iconEl) iconEl.textContent = playing ? "\u275A\u275A" : "\u25B6"; button.setAttribute("aria-pressed", playing ? "true" : "false"); button.title = playing ? "暂停播放" : "播放语音"; if (!playing && state.activeAudioController && state.activeAudioController.audio === audio) { state.activeAudioController = null; } } audio.addEventListener("loadedmetadata", syncDurationFromAudio); audio.addEventListener("durationchange", syncDurationFromAudio); audio.addEventListener("timeupdate", updateProgressUi); audio.addEventListener("play", () => { if (state.activeAudioController && state.activeAudioController.audio !== audio) { try { state.activeAudioController.audio.pause(); } catch { /* ignore */ } } state.activeAudioController = { audio, button }; setPlayingUi(true); updateProgressUi(); }); audio.addEventListener("pause", () => { setPlayingUi(false); updateProgressUi(); }); audio.addEventListener("ended", () => { audio.currentTime = 0; setPlayingUi(false); updateProgressUi(); }); button.addEventListener("click", async () => { try { if (!audio.paused) { audio.pause(); return; } if (state.activeAudioController && state.activeAudioController.audio !== audio) { try { state.activeAudioController.audio.pause(); } catch { /* ignore */ } } await audio.play(); } catch { /* ignore */ } }); wrap.appendChild(button); updateProgressUi(); const shouldAutoplay = allowAutoplay !== false && Boolean( autoplay || part.autoplay || (messageMeta && messageMeta.autoplay) ); if (shouldAutoplay) { queueMicrotask(async () => { try { if (state.activeAudioController && state.activeAudioController.audio !== audio) { try { state.activeAudioController.audio.pause(); } catch { /* ignore */ } } await audio.play(); } catch { setPlayingUi(false); updateProgressUi(); } }); } return wrap; } /* ===== 用户图片缩略图(用于 user-media-row) ===== */ function renderUserImageThumb(url, name) { const img = document.createElement("img"); img.className = "user-media-thumb"; img.src = url; img.alt = name || "图片"; img.title = name || "点击查看大图"; img.loading = "lazy"; img.addEventListener("click", (e) => { e.stopPropagation(); showImagePreview(url); }); // 自定义拖拽:阻止浏览器创建通用名称的 File,传递原始信息 img.addEventListener("dragstart", (e) => { e.dataTransfer.clearData(); // 清除浏览器默认数据(会生成名为 "image.png" 的 File) e.dataTransfer.setData("application/x-chat-image", url); e.dataTransfer.setData("text/plain", name || url.split("/").pop() || "图片"); e.dataTransfer.effectAllowed = "copy"; }); return img; } /* ===== 用户媒体行:图片或文件水平排列 + 渐变隐藏 + 滚轮滑动 ===== */ function renderUserMediaRow(items, options = {}) { if (!items || items.length === 0) return null; const row = document.createElement("div"); row.className = "user-media-row"; items.forEach((item) => row.appendChild(item)); attachHorizontalScroll(row); return row; } /* ===== 从消息 content 中提取图片/文件/文本分组 ===== */ function groupUserContentParts(content) { const images = []; const files = []; const texts = []; if (Array.isArray(content)) { content.forEach((part) => { if (!part) return; if (part.type === "text" && typeof part.text === "string") { if (part.text.trim()) texts.push(part.text); } else if (part.type === "image" && part.url) { images.push(part); } else if (part.type === "file") { files.push(part); } else if (part.type === "audio") { // 用户上传的音频文件按普通文件展示 files.push(part); } }); } else if (typeof content === "string") { if (content.trim()) texts.push(content); } return { images, files, texts }; } /* ===== 渲染用户消息内容(无气泡,文本 + 图片行 + 文件行 + 文件夹行) ===== */ function renderUserContent(content, baseOptions = {}) { const wrap = document.createElement("div"); wrap.className = "msg-bubble msg-bubble--user"; const { images, files, texts } = groupUserContentParts(content); // 文本:提取 @folder: 和 @file: 引用,渲染为独立气泡 const folderRefs = []; const fileRefs = []; const cleanTexts = []; if (texts.length > 0) { const fullText = texts.join("\n"); const refRegex = /@(folder|file):([^\n]+)/g; let lastIndex = 0; let match; while ((match = refRegex.exec(fullText)) !== null) { // 收集引用前的文本 const before = fullText.substring(lastIndex, match.index).trim(); if (before) cleanTexts.push(before); lastIndex = refRegex.lastIndex; const refType = match[1]; // "folder" or "file" const refPath = match[2].trim(); if (refType === "folder") { folderRefs.push(refPath); } else { fileRefs.push(refPath); } } // 收集剩余文本 const after = fullText.substring(lastIndex).trim(); if (after) cleanTexts.push(after); } // 渲染剩余文本(去除 @folder:/@file: 引用后) if (cleanTexts.length > 0) { const textEl = document.createElement("div"); textEl.className = "msg-text"; textEl.textContent = cleanTexts.join("\n"); wrap.appendChild(textEl); } // 去重:避免同一文件既作为附件 part 又作为 @file:/@folder: 文本引用重复渲染。 // 同名(按 basename 大小写无关)只保留首个:图片 > 文件 > @file: 引用卡片。 // 这样「上传/拖拽的文件 + 同名 @file: 引用」不会出现两个一模一样的文件 UI。 const _basename = (p) => (p || '').replace(/[\\/]/g, '/').split('/').pop() || ''; const _renderedNames = new Set(); const dedupedImages = images.filter((p) => { const key = (p.name || _basename(p.url) || '').toLowerCase(); if (!key || _renderedNames.has(key)) return false; _renderedNames.add(key); return true; }); const dedupedFiles = files.filter((p) => { const key = (p.name || _basename(p.url) || '').toLowerCase(); if (!key || _renderedNames.has(key)) return false; _renderedNames.add(key); return true; }); const dedupedFileRefs = fileRefs.filter((fp) => { const key = _basename(fp).toLowerCase(); if (!key || _renderedNames.has(key)) return false; _renderedNames.add(key); return true; }); // 图片行 if (dedupedImages.length > 0) { const imgItems = dedupedImages.map((p) => renderUserImageThumb(p.url, p.name)); const row = renderUserMediaRow(imgItems); if (row) wrap.appendChild(row); } // 文件行 if (dedupedFiles.length > 0) { const fileItems = dedupedFiles.map((p) => renderUserFileCard(p.url, p.name, { preview: true })); const row = renderUserMediaRow(fileItems); if (row) wrap.appendChild(row); } // 文件夹引用气泡(@folder:path) if (folderRefs.length > 0) { const folderItems = folderRefs.map((fp) => renderUserFolderRefCard(fp)); const row = renderUserMediaRow(folderItems); if (row) wrap.appendChild(row); } // 文件引用气泡(@file:path) if (dedupedFileRefs.length > 0) { const fileRefItems = dedupedFileRefs.map((fp) => renderUserFileRefCard(fp)); const row = renderUserMediaRow(fileRefItems); if (row) wrap.appendChild(row); } return wrap; } /** 渲染用户消息中的文件夹引用卡片 */ function renderUserFolderRefCard(folderPath) { const card = document.createElement("div"); card.className = "user-file-card user-folder-card"; card.draggable = true; card.title = folderPath; // 拖拽:支持从聊天区拖拽文件夹引用到输入区 card.addEventListener("dragstart", (e) => { e.dataTransfer.setData("application/doc-path", folderPath); e.dataTransfer.setData("application/doc-folder", "1"); e.dataTransfer.setData("text/plain", folderPath.replace(/\\/g, "/").split("/").pop() || folderPath); e.dataTransfer.effectAllowed = "copy"; }); const icon = document.createElement("img"); icon.className = "user-file-card-icon"; icon.src = "/image/\u6587\u4EF6\u5939.svg"; // 文件夹.svg icon.alt = "\u6587\u4EF6\u5939"; const info = document.createElement("div"); info.className = "user-file-card-info"; const nameEl = document.createElement("span"); nameEl.className = "user-file-name"; const safeName = folderPath.replace(/\\/g, "/").split("/").pop() || folderPath; nameEl.textContent = safeName; nameEl.title = folderPath; const extEl = document.createElement("span"); extEl.className = "user-file-ext"; extEl.textContent = "folder"; info.appendChild(nameEl); info.appendChild(extEl); card.appendChild(icon); card.appendChild(info); return card; } /** 渲染用户消息中的文件引用卡片 */ function renderUserFileRefCard(filePath) { const card = document.createElement("div"); card.className = "user-file-card"; card.draggable = true; card.title = filePath; const icon = document.createElement("img"); icon.className = "user-file-card-icon"; icon.src = getFileIcon(safeName); // 按扩展名分类图标(文本/Excel/Word/PDF/压缩/音频) icon.alt = "\u6587\u4EF6"; const info = document.createElement("div"); info.className = "user-file-card-info"; const nameEl = document.createElement("span"); nameEl.className = "user-file-name"; const safeName = filePath.replace(/\\/g, "/").split("/").pop() || filePath; nameEl.textContent = safeName; nameEl.title = filePath; const extEl = document.createElement("span"); extEl.className = "user-file-ext"; const dotIdx = safeName.lastIndexOf("."); extEl.textContent = dotIdx >= 0 ? safeName.slice(dotIdx + 1).toLowerCase() : "file"; info.appendChild(nameEl); info.appendChild(extEl); card.appendChild(icon); card.appendChild(info); // 点击卡片 → 浮窗预览内容 card.addEventListener("click", (e) => { e.stopPropagation(); previewRefFile(filePath); }); // 拖拽:支持从聊天区拖拽整张文件引用卡片到输入区(与文件夹引用一致) card.addEventListener("dragstart", (e) => { e.dataTransfer.setData("application/doc-path", filePath); e.dataTransfer.setData("text/plain", safeName); e.dataTransfer.effectAllowed = "copy"; }); return card; } /* ===== 浮窗预览本地路径文件(@file: 引用卡片点击) ===== */ async function previewRefFile(filePath) { const fileName = filePath.split(/[/\\]/).pop() || filePath; // 有工作区时复用 edit 模式的文档预览(图片/HTML/表格/二进制/文本统一处理) if (state._docRootPath) { try { const { loadDocContent } = await import('./edit-mode.js'); await loadDocContent(filePath); return; } catch { /* 回退到直接读取 */ } } const { readTextFile, readBinaryFile } = await import('./electron-api.js'); const { openFilePreview } = await import('./file-preview.js'); // 图片 if (/\.(png|jpe?g|gif|webp|bmp|ico)$/i.test(fileName)) { try { const res = await readBinaryFile(filePath, state._docRootPath); if (res && res.status === 'ok' && res.data) { showImagePreview(`data:${res.mime || 'image/png'};base64,${res.data}`); return; } } catch { /* 继续 */ } openFilePreview(filePath, fileName, '(无法读取文件)'); return; } // 表格(xlsx/xls):后端提取为 TSV 后按列着色预览 if (/\.(xlsx|xls)$/i.test(fileName)) { try { const { api } = await import('./api.js'); const tsv = await api('/api/table/text?path=' + encodeURIComponent(filePath)); openFilePreview(filePath, fileName, (tsv && tsv.content) || ''); } catch (err) { openFilePreview(filePath, fileName, '(加载失败: ' + (err.message || err) + ')'); } return; } // 文本/代码 try { const res = await readTextFile(filePath, state._docRootPath); if (res && res.status === 'ok') { openFilePreview(filePath, fileName, res.content || ''); } else { openFilePreview(filePath, fileName, '(无法读取文件)'); } } catch (err) { openFilePreview(filePath, fileName, '(加载失败: ' + (err.message || err) + ')'); } } /* ===== 从工具文件 URL 中提取文件名 ===== */ function extractToolFileName(url) { if (!url) return "文件"; // 处理 /api/tool-file?path=/foo/bar.txt&session=xxx 格式 try { const u = new URL(url, window.location.origin); const pathParam = u.searchParams.get("path"); if (pathParam) { const name = pathParam.replace(/\\/g, "/").split("/").pop(); if (name) return decodeURIComponent(name); } } catch { // URL 解析失败,回退到字符串处理 } // 回退:取最后一个 / 之后、? 之前的部分 const clean = url.split("?")[0]; const name = clean.replace(/\\/g, "/").split("/").pop(); return name ? decodeURIComponent(name) : "文件"; } /* ===== 从工具调用列表中提取最终产出物(sendfile 返回文件 + image_gen 返回图片) ===== */ function extractFinalAssets(toolCalls) { if (!Array.isArray(toolCalls) || toolCalls.length === 0) { return { images: [], files: [], folders: [] }; } const images = []; const files = []; const folders = []; const collectFromCall = (call) => { if (!call) return; const name = String(call.name || "").trim(); // image_gen:收集 result_images if (name === "image_gen" && Array.isArray(call.result_images)) { call.result_images.forEach((url) => { if (typeof url === "string" && url) images.push({ url, name: "image_gen.png" }); }); } // sendfile:收集 result_download_files / result_file_info / result_images if (name === "sendfile" || name === "send_file") { // result_file_info 优先(有正确文件名),仅在无 result_file_info 时用 result_download_files const fileInfos = Array.isArray(call.result_file_info) ? call.result_file_info : []; if (fileInfos.length > 0) { fileInfos.forEach((fi) => { if (fi && fi.file_path && fi.file_name) { if (fi.file_type === "folder") { // 文件夹:展示为文件夹卡片 folders.push({ path: fi.file_path, name: fi.file_name }); } else if (fi.file_type === "image") { // 图片:展示为缩略图预览 const imgUrl = `/api/tool-file?path=${encodeURIComponent(fi.file_path)}&session=${encodeURIComponent(call.turn_id || '')}`; images.push({ url: imgUrl, name: fi.file_name }); } else { const apiUrl = `/api/tool-file?path=${encodeURIComponent(fi.file_path)}&session=${encodeURIComponent(call.turn_id || '')}`; files.push({ url: apiUrl, name: fi.file_name }); } } }); } else { // 无 result_file_info 时:先尝试 result_images(send_file 发送的图片可能走这条路径) if (Array.isArray(call.result_images) && call.result_images.length > 0) { call.result_images.forEach((url) => { if (typeof url === "string" && url) images.push({ url, name: "send_file_image.png" }); }); } const dlFiles = Array.isArray(call.result_download_files) ? call.result_download_files : []; dlFiles.forEach((url) => { if (typeof url === "string" && url) { files.push({ url, name: extractToolFileName(url) }); } }); } } // 嵌套子 agent 调用:递归 if ((name === "call_subagent" || name === "agent_call") && Array.isArray(call.sub_tool_calls)) { call.sub_tool_calls.forEach(collectFromCall); } }; toolCalls.forEach(collectFromCall); return { images, files, folders }; } /* ===== 渲染最终回复下方的产出物(图片 + 文件卡片) ===== */ function renderFinalAssets(toolCalls) { const { images, files, folders } = extractFinalAssets(toolCalls); if (images.length === 0 && files.length === 0 && folders.length === 0) return null; const wrap = document.createElement("div"); wrap.className = "final-assets"; const label = document.createElement("div"); label.className = "final-assets-label"; label.textContent = "生成文件"; wrap.appendChild(label); if (images.length > 0) { const imgItems = images.map((p) => renderUserImageThumb(p.url, p.name)); const row = renderUserMediaRow(imgItems); if (row) wrap.appendChild(row); } if (folders.length > 0) { const folderItems = folders.map((p) => renderUserFolderRefCard(p.path || p.name)); const row = renderUserMediaRow(folderItems); if (row) wrap.appendChild(row); } if (files.length > 0) { const fileItems = files.map((p) => renderUserFileCard(p.url, p.name, { preview: true })); const row = renderUserMediaRow(fileItems); if (row) wrap.appendChild(row); } return wrap; } /* ===== 渲染 assistant 消息内容(agent-run-block + 文本 + 产出物 + 媒体) ===== */ function renderAssistantContent(msg, allowAutoplay = false) { const wrap = document.createElement("div"); wrap.className = "msg-bubble"; const meta = msg.meta || {}; const toolCalls = Array.isArray(meta.tool_calls) ? meta.tool_calls : []; // 1. Agent 运行过程(默认折叠,运行结束后展示) if (toolCalls.length > 0 && renderPersistentToolCallsFn) { // 传入轮次级计时元数据(duration/started_at) const turnMeta = meta.turn_meta || {}; const tcList = renderPersistentToolCallsFn(toolCalls, { duration: turnMeta.duration != null ? Number(turnMeta.duration) : undefined, }); if (tcList) { // 执行结束后默认折叠,用户可点击折叠条展开 wrap.appendChild(tcList); } } // 2. 文本回复(追加到子容器中,不能用 fillAssistantBubbleImmediate 因为它会 textContent="" 清除工具调用列表) const c = msg.content; const isAudioOnly = isAudioOnlyAssistantMessage(msg); if (!isAudioOnly) { if (typeof c === "string" && c.trim()) { const textDiv = document.createElement("div"); textDiv.className = "msg-prose markdown-body"; textDiv.appendChild(renderMarkdownToFragment(c)); wrap.appendChild(textDiv); } else if (Array.isArray(c)) { const textParts = c.filter((part) => part && part.type === "text" && typeof part.text === "string"); if (textParts.length > 0) { const text = textParts.map((part) => part.text || "").join("\n"); const textDiv = document.createElement("div"); textDiv.className = "msg-prose markdown-body"; textDiv.appendChild(renderMarkdownToFragment(text)); wrap.appendChild(textDiv); } } } // 3. 最终产出物(sendfile / image_gen 返回的文件/图片) if (toolCalls.length > 0) { const assets = renderFinalAssets(toolCalls); if (assets) wrap.appendChild(assets); } // 4. 媒体 part(TTS 音频等) if (Array.isArray(c)) { const mediaParts = c.filter((part) => part && part.type !== "text"); const baseOptions = { messageMeta: meta, role: "assistant", allowAutoplay: allowAutoplay, compact: true, }; mediaParts.forEach((p) => { wrap.appendChild(renderContentPart(p, baseOptions)); }); } return wrap; } function renderContentPart(part, options = {}) { if (typeof part === "string") { const div = document.createElement("div"); div.className = "msg-text"; div.textContent = part; return div; } if (!part || typeof part !== "object") { const div = document.createElement("div"); div.className = "msg-text"; div.textContent = String(part); return div; } if (part.type === "text") { const div = document.createElement("div"); div.className = "msg-text"; div.textContent = part.text || ""; return div; } if (part.type === "image" && part.url) { const img = document.createElement("img"); img.src = part.url; img.alt = "\u56FE\u7247"; img.style.cursor = "pointer"; img.title = "\u70B9\u51FB\u67E5\u770B\u5927\u56FE"; img.addEventListener("click", (e) => { e.stopPropagation(); showImagePreview(part.url); }); return img; } if (part.type === "audio" && part.url) { return renderAudioBubble(part, options); } if (part.type === "file") { // 助手消息中的文件 part(理论上不会出现,但兜底) return renderUserFileCard(part.url, part.name, { preview: true }); } const div = document.createElement("div"); div.className = "msg-text"; div.textContent = JSON.stringify(part); return div; } function renderMessage(msg, allowAutoplay = false, opts = {}) { const wrap = document.createElement("div"); wrap.className = "msg " + (msg.role === "user" ? "user" : "assistant") + (isAudioOnlyAssistantMessage(msg) ? " msg--audio-only" : ""); // 标记消息所属回合(回合导航条按此分组 / 跳转) wrap.dataset.turnId = (msg.meta && msg.meta.turn_id) || ""; const baseOptions = { messageMeta: msg.meta || null, role: msg.role === "user" ? "user" : "assistant", allowAutoplay: allowAutoplay, }; // minimalActions:cron 执行记录等只读场景,仅保留复制按钮 const actionOpts = { minimal: !!opts.minimalActions }; if (msg.role === "user") { // 用户消息:内容 + 下方操作按钮(复制 / 撤回) const contentEl = renderUserContent(msg.content, baseOptions); wrap.appendChild(contentEl); wrap.appendChild(renderMessageActions("user", msg, wrap, actionOpts)); } else { // 助手消息:内容 + 下方操作按钮(复制 / 重试) const contentEl = renderAssistantContent(msg, allowAutoplay); wrap.appendChild(contentEl); wrap.appendChild(renderMessageActions("assistant", msg, wrap, actionOpts)); if (msg.meta && msg.meta.pending_action) { // pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示 } } return wrap; } /* ===== 消息下方操作按钮行(无边框图标按钮) ===== */ function renderMessageActions(role, msg, wrap, opts = {}) { const actions = document.createElement("div"); actions.className = "msg-actions msg-actions--" + role; // 复制到剪贴板 const copyBtn = document.createElement("button"); copyBtn.type = "button"; copyBtn.className = "chat-action-btn"; copyBtn.title = "复制"; copyBtn.setAttribute("aria-label", "复制消息内容"); const copyImg = document.createElement("img"); copyImg.src = "/image/复制.svg"; copyImg.alt = ""; copyImg.draggable = false; copyBtn.appendChild(copyImg); copyBtn.addEventListener("click", (e) => { e.stopPropagation(); const text = getTextFromContent(msg.content); if (!text) { showToast("没有可复制的内容"); return; } navigator.clipboard.writeText(text).then(() => { showToast("已复制到剪贴板"); }).catch(() => { showToast("复制失败"); }); }); actions.appendChild(copyBtn); // 只读场景(如 cron 执行记录):仅保留复制按钮 if (opts.minimal) return actions; if (role === "user") { // 撤回:回退到此消息 const rollbackBtn = document.createElement("button"); rollbackBtn.type = "button"; rollbackBtn.className = "chat-action-btn"; rollbackBtn.title = "撤回"; rollbackBtn.setAttribute("aria-label", "撤回此消息"); const rbImg = document.createElement("img"); rbImg.src = "/image/撤回.svg"; rbImg.alt = ""; rbImg.draggable = false; rollbackBtn.appendChild(rbImg); rollbackBtn.addEventListener("click", (e) => { e.stopPropagation(); const parent = wrap.parentElement; if (!parent) return; const children = Array.from(parent.children); const idx = children.indexOf(wrap); if (idx < 0) return; if (rollbackChatFn) rollbackChatFn(idx); }); actions.appendChild(rollbackBtn); } else { // 点赞 / 点踩(本地持久化,刷新/切换会话后保留) const msgIdx = (() => { const parent = wrap.parentElement; if (!parent) return -1; return Array.from(parent.children).indexOf(wrap); })(); const fbKey = _feedbackKey(msg, msgIdx); const fbMap0 = _loadFeedbackMap(); const curVal = fbMap0[fbKey] || ""; const likeBtn = document.createElement("button"); likeBtn.type = "button"; likeBtn.className = "chat-action-btn chat-feedback-btn"; likeBtn.title = "点赞"; likeBtn.setAttribute("aria-label", "点赞"); const likeImg = document.createElement("img"); likeImg.src = "/image/点赞.svg"; likeImg.alt = ""; likeImg.draggable = false; likeBtn.appendChild(likeImg); if (curVal === "like") likeBtn.classList.add("is-active"); const dislikeBtn = document.createElement("button"); dislikeBtn.type = "button"; dislikeBtn.className = "chat-action-btn chat-feedback-btn"; dislikeBtn.title = "点踩"; dislikeBtn.setAttribute("aria-label", "点踩"); const dislikeImg = document.createElement("img"); dislikeImg.src = "/image/点踩.svg"; dislikeImg.alt = ""; dislikeImg.draggable = false; dislikeBtn.appendChild(dislikeImg); if (curVal === "dislike") dislikeBtn.classList.add("is-active"); const applyFeedback = (val) => { const map = _loadFeedbackMap(); const next = map[fbKey] === val ? "" : val; map[fbKey] = next; _saveFeedbackMap(map); likeBtn.classList.toggle("is-active", next === "like"); dislikeBtn.classList.toggle("is-active", next === "dislike"); showToast(next === "like" ? "已点赞" : next === "dislike" ? "已点踩" : "已取消评价"); }; likeBtn.addEventListener("click", (e) => { e.stopPropagation(); applyFeedback("like"); }); dislikeBtn.addEventListener("click", (e) => { e.stopPropagation(); applyFeedback("dislike"); }); actions.appendChild(likeBtn); actions.appendChild(dislikeBtn); // 重试:删除本轮记录(撤回)并重新执行 agent(撤回 + 发送) const retryBtn = document.createElement("button"); retryBtn.type = "button"; retryBtn.className = "chat-action-btn"; retryBtn.title = "重试"; retryBtn.setAttribute("aria-label", "重试本轮回复"); const rtImg = document.createElement("img"); rtImg.src = "/image/重试.svg"; rtImg.alt = ""; rtImg.draggable = false; retryBtn.appendChild(rtImg); retryBtn.addEventListener("click", (e) => { e.stopPropagation(); handleRetry(wrap); }); actions.appendChild(retryBtn); // 分支:将当前回复及之前的所有历史复制到新会话 const branchBtn = document.createElement("button"); branchBtn.type = "button"; branchBtn.className = "chat-action-btn"; branchBtn.title = "分支"; branchBtn.setAttribute("aria-label", "复制历史到新会话"); const brImg = document.createElement("img"); brImg.src = "/image/分支.svg"; brImg.alt = ""; brImg.draggable = false; branchBtn.appendChild(brImg); branchBtn.addEventListener("click", (e) => { e.stopPropagation(); handleBranch(wrap, msg); }); actions.appendChild(branchBtn); // 分支右侧:SSE 开始时间(即该轮 AI 回复开始的时间,时:分) const turnMeta = (msg && msg.meta && msg.meta.turn_meta) || {}; if (turnMeta.started_at) { const timeEl = document.createElement("span"); timeEl.className = "msg-start-time"; timeEl.textContent = _formatStartTime(turnMeta.started_at); timeEl.title = "本轮回复开始时间"; actions.appendChild(timeEl); } } return actions; } /** 将 Unix 秒时间戳格式化为 时:分(24 小时制,补零) */ function _formatStartTime(startedAt) { const ts = Number(startedAt); if (!Number.isFinite(ts) || ts <= 0) return ""; const d = new Date(ts * 1000); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); return `${hh}:${mm}`; } /** 分支:将当前回复及之前的所有历史复制到新会话并跳转 */ async function handleBranch(wrap, msg) { if (state.sending) { showToast("Agent 运行中,无法分支。请先暂停任务。"); return; } if (!state.sessionId) { showToast("无当前会话"); return; } const turnId = msg && msg.meta && msg.meta.turn_id; if (!turnId) { showToast("无法获取该回复的回合信息"); return; } try { const { api } = await import("./api.js"); const fd = new FormData(); fd.append("session_id", state.sessionId); fd.append("branch_turn_id", turnId); const data = await api("/api/sessions/branch", { method: "POST", body: fd }); const { applySessionData } = await import("./sessions.js"); await applySessionData(data); showToast("已创建分支会话,历史已复制到新会话"); } catch (err) { showToast("分支失败: " + (err.message || "未知错误")); } } /* ===== 点赞 / 点踩 本地持久化 ===== */ const FEEDBACK_STORAGE_KEY = "minor_agent_msg_feedback"; function _loadFeedbackMap() { try { return JSON.parse(localStorage.getItem(FEEDBACK_STORAGE_KEY) || "{}"); } catch { return {}; } } function _saveFeedbackMap(map) { try { localStorage.setItem(FEEDBACK_STORAGE_KEY, JSON.stringify(map)); } catch { /* 忽略写入失败 */ } } /** 生成消息级反馈的持久化键:优先 turn_id,缺失时退回消息索引 */ function _feedbackKey(msg, msgIndex) { const tid = msg && msg.meta && msg.meta.turn_id; if (tid) return `${state.sessionId}:turn:${tid}:assistant`; return `${state.sessionId}:idx:${msgIndex}:assistant`; } /** 重试:回退到本轮的用户消息(含该消息),恢复输入后重新发送 */ async function handleRetry(wrap) { if (state.sending) { showToast("Agent 运行中,无法重试。请先暂停任务。"); return; } if (!state.sessionId) { showToast("无当前会话"); return; } // 找到本轮对应的用户消息(当前回复之前最近的一条 user 消息) let prev = wrap.previousElementSibling; while (prev && !prev.classList.contains("user")) { prev = prev.previousElementSibling; } if (!prev) { showToast("未找到对应的用户消息"); return; } const parent = wrap.parentElement; if (!parent) return; const children = Array.from(parent.children); const userIdx = children.indexOf(prev); if (userIdx < 0) return; // 撤回该轮(删除本轮用户消息及其后的记录,内容恢复到输入区) let rolledBack = false; if (rollbackChatFn) { rolledBack = await rollbackChatFn(userIdx, { skipConfirm: true }); } if (!rolledBack) return; // 撤回成功后重新执行 agent(相当于撤回 + 发送) const textInput = $("textInput"); const { sendChat } = await import("./send.js"); if (textInput && textInput.value.trim()) { sendChat(); } else { showToast("已撤回本轮消息,请在输入框确认内容后重新发送"); } } /* ===== 聊天区文字引用(引用为"用户引用",复用 ref-chip UI) ===== */ let _quoteSeq = 0; /** 将选中的聊天文字添加为引用 chip(发送时以「用户引用」形式注入文本) */ function addTextQuote(text) { const clean = String(text || "").trim(); if (!clean) { showToast("请先选中要引用的文字"); return; } const id = "quote:" + Date.now().toString(36) + ":" + (_quoteSeq++).toString(36); state.pendingRefs.push({ type: "quote", path: id, text: clean }); state.pendingFiles.push({ __isRef: true, refPath: id, refName: clean.length > 28 ? clean.slice(0, 28) + "…" : clean, type: "ref/quote", name: "引用", refText: clean, }); renderAttachmentChips(); const textInput = $("textInput"); if (textInput) textInput.focus(); showToast("已添加引用"); } let _quoteMenuCloseBound = null; function removeQuoteMenu() { const m = document.querySelector(".chat-quote-menu"); if (m) m.remove(); // 清理文档级关闭监听,避免旧菜单的监听误删新菜单 if (_quoteMenuCloseBound) { document.removeEventListener("mousedown", _quoteMenuCloseBound); _quoteMenuCloseBound = null; } } /** 初始化聊天区引用:右键选中文字显示「引用」菜单 + 选中文字拖拽到输入区 */ function initChatQuoteMenu() { // 双容器支持:chat 与 cron 消息容器均可引用文字 const containers = Array.from(document.querySelectorAll(".chat-messages")); if (!containers.length) return; // 左键选中文字后右键 → 显示"引用"菜单 const onContextMenu = (hostEl) => (e) => { const sel = window.getSelection(); const text = sel ? sel.toString().trim() : ""; if (!text) return; // 无选中内容时保留默认右键菜单 if (!hostEl.contains(sel.anchorNode)) return; e.preventDefault(); removeQuoteMenu(); const menu = document.createElement("div"); menu.className = "doc-context-menu chat-quote-menu"; // 限制菜单在视口内,避免靠右/靠底时溢出不可点击 const menuWidth = 168; // 约等于 .doc-context-menu min-width(160) + 边距 const menuHeight = 48; // 单个菜单项的高度余量 const posX = Math.max(4, Math.min(e.clientX, window.innerWidth - menuWidth)); const posY = Math.max(4, Math.min(e.clientY, window.innerHeight - menuHeight)); menu.style.left = posX + "px"; menu.style.top = posY + "px"; const item = document.createElement("div"); item.className = "doc-context-menu-item"; item.textContent = "引用所选文字"; item.addEventListener("click", () => { addTextQuote(text); removeQuoteMenu(); }); menu.appendChild(item); document.body.appendChild(menu); const close = (ev) => { // 点击菜单项时 item 的 click 处理器先执行(移除菜单),此处检测到已移除则跳过 if (menu.contains(ev.target)) return; removeQuoteMenu(); }; _quoteMenuCloseBound = close; setTimeout(() => document.addEventListener("click", close), 0); }; // 左键选中文字后拖拽到输入区 const onDragStart = (hostEl) => (e) => { // 图片 / 文件 / 文档引用拖拽走各自的自定义数据,不干扰 if ( e.dataTransfer.types.includes("application/x-chat-image") || e.dataTransfer.types.includes("application/x-chat-file") || e.dataTransfer.types.includes("application/doc-path") ) return; const sel = window.getSelection(); const text = sel ? sel.toString().trim() : ""; if (!text) return; if (!hostEl.contains(sel.anchorNode)) return; e.dataTransfer.setData("application/x-chat-quote", text); e.dataTransfer.setData("text/plain", text); e.dataTransfer.effectAllowed = "copy"; }; containers.forEach((el) => { el.addEventListener("contextmenu", onContextMenu(el)); el.addEventListener("dragstart", onDragStart(el)); }); } /** * 全量渲染消息列表(清空目标容器后重建)。 * * @param {Array} messages - 消息数组 * @param {boolean|object} opts - 兼容旧签名:true/false 表示 allowAutoplay; * 对象形式 {allowAutoplay, container, minimalActions, keepComposer} * - container: 目标消息容器(默认 chat 容器) * - minimalActions: 只读场景仅保留复制按钮(cron 执行记录用) * - keepComposer: 为 true 时不触发 composer 居中切换(非 chat 视图用) */ function renderMessages(messages, opts = {}) { // 向后兼容:第二参数为布尔时视为 allowAutoplay if (typeof opts === "boolean") opts = { allowAutoplay: opts }; const { allowAutoplay = false, container = null, minimalActions = false, keepComposer = false, } = opts; const target = container || chatMessages; const placeholder = _placeholderFor(target); if (!target) return; const list = messages || []; // 清理所有 typingEl 的计时器,防止内存泄漏 target.querySelectorAll(".tool-call-list-wrap").forEach((w) => { if (typeof w._stopTimer === "function") w._stopTimer(); }); target.innerHTML = ""; // 容器级渲染快照(增量渲染与恢复用)+ 全局快照(回合导航 / 撤回用,跟随最近渲染视图) const snapshot = list.length ? JSON.parse(JSON.stringify(list)) : []; target._renderedMessages = snapshot; target._renderedCount = list.length; state.lastRenderedMessages.length = 0; state.lastRenderedMessages.push(...snapshot); const has = list.length > 0; if (placeholder) placeholder.hidden = has; target.hidden = !has; if (!has) { // 确保无消息时隐藏:内联 display:none 优先级高于 CSS 类设定,不依赖 [hidden] 规则 target.style.display = "none"; // 设置为居中状态(仅 chat 视图影响 composer 布局) if (!keepComposer) import('../app.js').then((m) => m.setComposerCentered(true)); renderTurnNav(); return; } // 有消息时清除内联 display,让 CSS flex 布局生效 target.style.display = ""; list.forEach((m) => target.appendChild(renderMessage(m, allowAutoplay, { minimalActions }))); scrollChatToBottom(target); // 取消居中状态 if (!keepComposer) import('../app.js').then((m) => m.setComposerCentered(false)); renderTurnNav(); } /** * 增量渲染:仅追加新增消息(运行期使用,保证渲染速度与连续性)。 * 通过 container._renderedCount 判断新增数量;若数据与已渲染不一致则回退全量渲染。 * * @param {Array} messages - 完整消息数组(含已渲染部分) * @param {object} opts - {container, minimalActions, allowAutoplay} */ function renderMessagesIncremental(messages, opts = {}) { const { container = null, minimalActions = false, allowAutoplay = false } = opts; const target = container || chatMessages; if (!target) return; const list = messages || []; const prevCount = target._renderedCount || 0; // 容器为空或数据缩短(撤回/清空场景)→ 全量渲染兜底 if (!prevCount || list.length < prevCount) { renderMessages(list, opts); return; } // 已渲染区间的一致性校验:比较末条已渲染消息的 turn_id/role/content 摘要 const prevSnapshot = target._renderedMessages || []; if (prevCount > 0) { const a = prevSnapshot[prevCount - 1]; const b = list[prevCount - 1]; const key = (m) => (m ? `${m.role}|${(m.meta && m.meta.turn_id) || ""}|${typeof m.content === "string" ? m.content.slice(0, 64) : JSON.stringify(m.content || "").slice(0, 64)}` : ""); if (key(a) !== key(b)) { renderMessages(list, opts); return; } } if (list.length === prevCount) return; // 无新增 const placeholder = _placeholderFor(target); if (placeholder) placeholder.hidden = true; target.hidden = false; target.style.display = ""; const fresh = list.slice(prevCount); fresh.forEach((m) => target.appendChild(renderMessage(m, allowAutoplay, { minimalActions }))); target._renderedMessages = JSON.parse(JSON.stringify(list)); target._renderedCount = list.length; state.lastRenderedMessages.length = 0; state.lastRenderedMessages.push(...target._renderedMessages); scrollChatToBottom(target); renderTurnNav(); } /** 释放非活动容器的 DOM(非运行态切出时调用,降低内存占用)。 */ function releaseMessageContainer(container) { if (!container) return; container.querySelectorAll(".tool-call-list-wrap").forEach((w) => { if (typeof w._stopTimer === "function") w._stopTimer(); }); container.innerHTML = ""; container._renderedMessages = null; container._renderedCount = 0; } function appendThinkingIndicator(container) { const target = container || chatMessages; if (!target) return null; const wrap = document.createElement("div"); wrap.className = "msg assistant msg-typing"; wrap.setAttribute("aria-live", "polite"); const bubble = document.createElement("div"); bubble.className = "msg-bubble"; // 三点加载动画,首次工具调用或 AI 回复后消失 const dots = document.createElement("div"); dots.className = "typing-dots"; for (let i = 0; i < 3; i++) { const dot = document.createElement("span"); dot.textContent = "."; dots.appendChild(dot); } bubble.appendChild(dots); wrap.appendChild(bubble); const placeholder = _placeholderFor(target); if (placeholder) placeholder.hidden = true; target.hidden = false; target.style.display = ""; target.appendChild(wrap); scrollChatToBottom(target); return wrap; } function showPendingActionInThinking(typingEl, msg) { if (!typingEl || !msg || !msg.meta || !msg.meta.pending_action) return false; // pending_action 由 pending-overlay 浮窗展示 const snapshot = JSON.parse(JSON.stringify(msg)); state.lastRenderedMessages.push(snapshot); scrollChatToBottom(typingEl.closest(".chat-messages") || chatMessages); return true; } async function streamAssistantMessage(msg, container) { const target = container || chatMessages; if (!target) return; const wrap = document.createElement("div"); wrap.className = "msg assistant" + (isAudioOnlyAssistantMessage(msg) ? " msg--audio-only" : ""); // 标记消息所属回合(回合导航条按此分组 / 跳转) wrap.dataset.turnId = (msg.meta && msg.meta.turn_id) || ""; target.appendChild(wrap); const placeholder = _placeholderFor(target); if (placeholder) placeholder.hidden = true; target.hidden = false; target.style.display = ""; // 流式开始时即刷新回合导航条,让新回合横条尽早出现 renderTurnNav(); const meta = msg.meta || {}; const toolCalls = Array.isArray(meta.tool_calls) ? meta.tool_calls : []; // 1. Agent 运行过程(执行结束,默认折叠,可展开) if (toolCalls.length > 0 && renderPersistentToolCallsFn) { const turnMeta = meta.turn_meta || {}; const tcList = renderPersistentToolCallsFn(toolCalls, { duration: turnMeta.duration != null ? Number(turnMeta.duration) : undefined, }); if (tcList) { wrap.appendChild(tcList); } } // 2. 文本回复(伪流式) const bubble = document.createElement("div"); bubble.className = "msg-bubble"; wrap.appendChild(bubble); const parts = Array.isArray(msg.content) ? msg.content : [{ type: "text", text: String(msg.content || "") }]; const textParts = parts.filter((part) => part && part.type === "text" && typeof part.text === "string"); const mediaParts = parts.filter((part) => !part || part.type !== "text"); if (textParts.length > 0) { await pseudoStreamAssistant(bubble, textParts.map((part) => part.text || "").join("\n")); } // 3. 最终产出物(sendfile / image_gen 返回的文件/图片) if (toolCalls.length > 0) { const assets = renderFinalAssets(toolCalls); if (assets) wrap.appendChild(assets); } // 4. 媒体 part(TTS 音频等) const mediaOptions = { autoplay: true, compact: textParts.length === 0, messageMeta: meta, role: "assistant", allowAutoplay: true, }; mediaParts.forEach((part) => { bubble.appendChild(renderContentPart(part, mediaOptions)); }); if (textParts.length === 0 && mediaParts.length > 0) { bubble.textContent = ""; mediaParts.forEach((part) => { bubble.appendChild(renderContentPart(part, mediaOptions)); }); } if (msg.meta && msg.meta.pending_action) { // pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示 } // 操作按钮行(复制 / 重试) wrap.appendChild(renderMessageActions("assistant", msg, wrap)); scrollChatToBottom(target); // 流式回复完成后重建回合导航条(该回合横条此时才出现) renderTurnNav(); } /* ===== 语音转文字中占位(用户语音输入时) ===== */ function renderVoiceTranscribingPlaceholder(container) { const target = container || chatMessages; if (!target) return null; // 确保聊天区域可见(即使之前没有消息) const placeholder = _placeholderFor(target); if (placeholder) placeholder.hidden = true; target.hidden = false; target.style.display = ""; const wrap = document.createElement("div"); wrap.className = "msg user"; const bubble = document.createElement("div"); bubble.className = "msg-bubble msg-bubble--user"; const indicator = document.createElement("span"); indicator.className = "voice-transcribing"; indicator.textContent = "语音转文字中"; bubble.appendChild(indicator); wrap.appendChild(bubble); target.appendChild(wrap); scrollChatToBottom(target); // 取消居中状态 import('../app.js').then((m) => m.setComposerCentered(false)).catch(() => {}); return wrap; } export { scrollChatToBottom, renderAttachmentChips, renderMarkdownToFragment, splitMarkdownStreamBlocks, renderMarkdownBlock, fillAssistantBubbleImmediate, pseudoStreamAssistant, renderAudioBubble, renderContentPart, renderUserMediaRow, renderUserImageThumb, renderUserContent, renderAssistantContent, renderFinalAssets, extractFinalAssets, renderMessage, renderMessages, renderMessagesIncremental, releaseMessageContainer, appendThinkingIndicator, showPendingActionInThinking, streamAssistantMessage, renderVoiceTranscribingPlaceholder, addTextQuote, initChatQuoteMenu, };