${replyBodyHtml(body, cardExpandKey(turnId, w.id))}
${regen}
${keep}
${ conversationId ? "这个会话还没保留过回复。卡片上点「保留此回复」就会开始计数。" : "打开一个会话后,这里按次数显示各模型被保留了多少次。" }
`; if (els.keepHint) { els.keepHint.textContent = entries.length ? `共保留 ${total} 次 · 按次数从多到少排` : "只统计当前会话,记在浏览器缓存里。"; } if (els.btnClearKeep) els.btnClearKeep.hidden = !entries.length; } function syncContextTabs() { const shellTab = contextTab !== "keep"; if (els.tabShell) { els.tabShell.classList.toggle("is-active", shellTab); els.tabShell.setAttribute("aria-selected", shellTab ? "true" : "false"); } if (els.tabKeep) { els.tabKeep.classList.toggle("is-active", !shellTab); els.tabKeep.setAttribute("aria-selected", shellTab ? "false" : "true"); } if (els.shellEditor) els.shellEditor.hidden = !shellTab; if (els.keepPanel) els.keepPanel.hidden = shellTab; if (!shellTab) renderKeepStats(); } function setContextTab(next) { contextTab = next === "keep" ? "keep" : "shell"; saveContextTab(contextTab); syncContextTabs(); } function windowLabel(w) { return w.profileName || w.model || w.profileId || "窗口"; } function shuffleIds(ids) { const next = ids.slice(); for (let i = next.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); const swap = next[i]; next[i] = next[j]; next[j] = swap; } return next; } function syncBlindOrder() { const ids = new Set(activeWindows.map((w) => w.id)); blindOrder = blindOrder.filter((id) => ids.has(id)); for (const w of activeWindows) { if (blindOrder.includes(w.id)) continue; const at = Math.floor(Math.random() * (blindOrder.length + 1)); blindOrder.splice(at, 0, w.id); } } function displayWindows() { if (!blindModels || activeWindows.length < 2) return activeWindows; syncBlindOrder(); const byId = new Map(activeWindows.map((w) => [w.id, w])); return blindOrder.map((id) => byId.get(id)).filter(Boolean); } /** * 盲测时的名字:按本轮实际显示顺序编号(模型 1、模型 2……)。 * 编号只看当前这一行,保留后只剩一张卡时就是「模型 1」, * 不会把这张卡在打乱顺序里占第几位漏出去。 */ function blindLabel(index) { return `模型 ${index + 1}`; } /** 本轮实际显示的窗口(保留后只剩被保留的那一个)。 */ function shownWindowEntries(turn) { const entries = displayWindows(); const keptId = keptWindowId(turn); return keptId ? entries.filter((w) => w.id === keptId) : entries; } /** 揭示后显示的真实名字:配置名 · 型号,重复时只留一个。 */ function revealedNameText(w) { const name = windowLabel(w); const model = String(w.model || ""); return model && model !== name ? `${name} · ${model}` : name; } function publicWindowLabel(w, turn) { if (!blindModels || blindNamesShown || activeWindows.length < 2) { return windowLabel(w); } const index = shownWindowEntries(turn).findIndex((item) => item.id === w.id); return index >= 0 ? blindLabel(index) : "该回复"; } function toggleCopyMode() { copyMode = !copyMode; saveCopyMode(copyMode); setStatus( copyMode ? "已开启复制:回复可选中复制,拖动只在卡片空白处生效" : "已关闭复制:可按住卡片任意位置拖动", ); renderArena(); } /** * 现在看得到真实模型名:从没盲测过、已全部显示、或已经揭示了其中几张。 * 只要露出一个名字,菜单就该回到「隐藏并打乱」。 */ function modelNamesShown() { return !blindModels || blindNamesShown || revealedCards.size > 0; } function toggleBlindModels() { if (activeWindows.length < 2) { setStatus("至少两个模型才能打乱位置", true); return; } if (!modelNamesShown()) { // 只把名字露出来,位置保持这一轮打乱后的顺序 blindNamesShown = true; revealedCards.clear(); setStatus("已显示模型名 · 位置仍是打乱后的顺序"); } else { // 藏起名字并重新打乱一次 blindModels = true; blindNamesShown = false; revealedCards.clear(); blindOrder = shuffleIds(activeWindows.map((w) => w.id)); setStatus("已隐藏模型名并打乱位置 · 点卡片上的编号可揭示这一个"); } renderArena(); } function formatBubbleTime(iso) { if (!iso) return ""; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false, }); } function cardExpandKey(turnId, windowId) { return `${turnId}:${windowId}`; } /** * 思维链默认折叠;只有思考、还没正文时(流式头一段)自动展开。 * 用户点过就听用户的,不被后续流式刷新冲掉。 */ function thinkingOpenFor(key, body) { const manual = thinkingToggles.get(key); if (manual !== undefined) return manual; const hasThinking = Boolean(String(body?.thinking ?? "").trim()); return hasThinking && !String(body?.text ?? "").trim(); } function thinkingSummaryText(thinking) { return `思考过程 · ${String(thinking ?? "").trim().length} 字`; } /** 只重写这一张卡的标题区,不整页重绘(保留滚动位置)。 */ function applyCardReveal(turnId, windowId, revealed) { const card = [...els.arena.querySelectorAll(".chat-reply-card")].find( (el) => el.getAttribute("data-turn-id") === turnId && el.getAttribute("data-window-id") === windowId, ); const titles = card?.querySelector(".chat-reply-card-titles"); if (!titles) return; const btn = titles.querySelector(".chat-reply-card-name.is-blind"); const w = activeWindows.find((item) => item.id === windowId); let model = titles.querySelector(".chat-reply-card-model"); if (revealed && w) { if (!model) { model = document.createElement("span"); model.className = "chat-reply-card-model is-revealed"; titles.appendChild(model); } model.textContent = revealedNameText(w); } else { model?.remove(); } if (btn) { btn.classList.toggle("is-revealed", revealed); btn.setAttribute("aria-pressed", revealed ? "true" : "false"); btn.setAttribute( "title", revealed ? "点击重新隐藏这一个模型名" : "点击揭示这一个回答的模型名", ); } // 露出一张卡的名字后,菜单里的按钮也要跟着换成「隐藏并打乱」 syncComposerMenu(); } function replyView(reply) { if (!reply) { return { cls: "is-pending", empty: true, thinking: "", text: "本轮无回复" }; } if (reply.pending) { return { cls: "is-pending", empty: true, thinking: "", text: "回复中…" }; } if (reply.error) { return { cls: "is-error", empty: true, thinking: "", text: String(reply.error), }; } const text = String(reply.content || ""); const thinking = String(reply.thinking || ""); if (!text.trim() && !thinking.trim()) { return { cls: "is-pending", empty: true, thinking: "", text: "本轮无回复" }; } return { cls: "", empty: false, thinking, text }; } function persistActiveSessionMeta() { if (activeIsTemp && activeConversationId) { const temp = loadTemps().find((t) => t.id === activeConversationId); if (!temp) return; temp.windows = activeWindows; temp.turns = activeTurns; temp.slots = { ...EMPTY_SLOTS, ...(temp.slots || {}), ...slotsFromEditor() }; temp.updatedAt = nowIso(); upsertTemp(temp); return; } if (!activeIsTemp && activeConversationId) { api( `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}`, { method: "PATCH", body: JSON.stringify({ windows: activeWindows, turns: activeTurns, }), }, ).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); } } function renderPath() { const items = []; items.push( navLevel === "root" && !activeIsTemp ? `Ctrl/⌘ + Enter 保存 · Esc 取消
` : `在下方输入,各模型会同时回复同一条消息。
${renderTurnStrip(null)}`; els.arena.innerHTML = `