Files
auto_agent/src/web/js/chat-render.js
T

1740 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = `<img src="/image/文件夹.svg" alt="" class="chip-icon" /> ${escapeHtml(file.name)} <button type="button" aria-label="移除">\u00D7</button>`;
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 = `<img src="/image/文件夹.svg" alt="" class="ref-chip-icon" /> <span class="ref-chip-label">${escapeHtml(file.refName || file.name)}</span> <button type="button" class="ref-chip-close" aria-label="移除">\u00D7</button>`;
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 = `<img src="${getFileIcon(file.name)}" alt="" class="chip-icon" /> ${escapeHtml(file.name)} <button type="button" aria-label="移除">\u00D7</button>`;
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 = `<img src="${getFileIcon(file.refName || file.name)}" alt="" class="ref-chip-icon" /> <span class="ref-chip-label">${escapeHtml(file.refName || file.name)}</span> <button type="button" class="ref-chip-close" aria-label="移除">\u00D7</button>`;
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 = `<img src="/image/回复.svg" alt="" class="ref-chip-icon" /> <span class="ref-chip-label">${escapeHtml(file.refName || file.name)}</span> <button type="button" class="ref-chip-close" aria-label="移除">\u00D7</button>`;
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, "&nbsp;").replace(/\t/g, "&nbsp;&nbsp;&nbsp;&nbsp;").replace(/\n/g, "<br>"); })();
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 = `
<span class="audio-pill-progress" aria-hidden="true"></span>
<span class="audio-pill-tail" aria-hidden="true"></span>
<span class="audio-pill-main">
<span class="audio-pill-icon">\u25B6</span>
<span class="audio-pill-wave" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span>
</span>
<span class="audio-pill-text${role === "user" ? " audio-pill-text--muted" : ""}">${escapeHtml(displayName)}</span>
</span>
<span class="audio-pill-side">
<span class="audio-pill-duration">0.0\u2033 / ${escapeHtml(durationLabel || "语音")}</span>
</span>
`;
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,
};