import { renderMarkdownToHtml } from "./markdown.js"; import { createHistoryEditor, normalizeHistoryTurns } from "./history-editor.js"; import { planWheelScroll } from "./chat-scroll.js"; import { bumpKeepStats, clearKeepStats, copyKeepStats, keepStatPercent, keepStatTotal, readKeepStats, } from "./chat-keep-stats.js"; const TEMP_STORAGE_KEY = "wa-direct-chat-temps-v2"; const COPY_MODE_STORAGE_KEY = "wa-direct-chat-copy-mode-v1"; const RAILS_STORAGE_KEY = "wa-direct-chat-hide-rails-v1"; /** * 上次打开的对话:只放 sessionStorage。刷新页面回来还在原对话, * 重启应用(新的 run)就从头开始,不把旧位置带过去。 */ const OPEN_CONVERSATION_KEY = "wa-direct-chat-open-conversation-v1"; /** 右侧面板上次看的页签:shell = 上下文,keep = 保留统计 */ const CONTEXT_TAB_STORAGE_KEY = "wa-direct-chat-context-tab-v1"; /** 拖动增益:鼠标移 100px 实际滚动多少 px;可在「设置 · 直聊」里改 */ let dragGain = 1.5; const MAX_TEMPS = 2; const EMPTY_SLOTS = { systemPrompt: "", loreBefore: "", seedHistory: "", seedMessages: [], loreAfter: "", postTurn: "", charName: "", charDescription: "", charPersonality: "", scenario: "", exampleDialogue: "", }; const $ = (id) => document.getElementById(id); const els = { pathList: $("chat-path-list"), railList: $("chat-rail-list"), room: $("chat-room"), tempMark: $("chat-temp-mark"), arena: $("chat-arena"), shell: document.querySelector(".chat-shell"), btnHideLeft: $("btn-hide-left"), btnHideRight: $("btn-hide-right"), btnShowLeft: $("btn-show-left"), btnShowRight: $("btn-show-right"), more: $("chat-more"), morePanel: $("chat-more-panel"), input: $("chat-input"), status: $("chat-status"), shellEditor: $("shell-editor"), contextSub: $("context-sub"), tabShell: $("tab-shell"), tabKeep: $("tab-keep"), keepPanel: $("keep-stats"), keepHint: $("keep-stats-hint"), keepList: $("keep-stats-list"), btnClearKeep: $("btn-clear-keep"), btnNewShell: $("btn-new-shell"), btnSaveTemp: $("btn-save-temp"), btnNewConv: $("btn-new-conv"), btnSend: $("btn-send"), btnSaveShell: $("btn-save-shell"), btnClearShell: $("btn-clear-shell"), shellName: $("shell-name"), shellSystem: $("shell-system"), shellLoreBefore: $("shell-lore-before"), shellSeedHistory: $("shell-seed-history"), shellLoreAfter: $("shell-lore-after"), shellPostTurn: $("shell-post-turn"), shellCharName: $("shell-char-name"), shellCharDesc: $("shell-char-desc"), shellCharPers: $("shell-char-pers"), shellScenario: $("shell-scenario"), shellExamples: $("shell-examples"), dialogPromote: $("dialog-promote"), formPromote: $("form-promote"), promoteShellName: $("promote-shell-name"), promoteConvName: $("promote-conv-name"), dialogAddWindow: $("dialog-add-window"), formAddWindow: $("form-add-window"), addWindowModels: $("add-window-models"), addWindowModelsEmpty: $("add-window-models-empty"), dialogNewConv: $("dialog-new-conv"), formNewConv: $("form-new-conv"), newConvName: $("new-conv-name"), newConvModels: $("new-conv-models"), newConvModelsEmpty: $("new-conv-models-empty"), }; /** @type {"root"|"shell"|"conversation"} */ let navLevel = "root"; /** @type {string|null} */ let activeShellId = null; /** @type {string|null} */ let activeConversationId = null; /** @type {boolean} */ let activeIsTemp = false; /** @type {any} */ let activeShell = null; /** @type {any[]} */ let activeWindows = []; /** @type {any[]} */ let activeTurns = []; /** @type {any[]} */ let shells = []; /** @type {any[]} */ let conversations = []; /** @type {any[]} */ let profileList = []; let busy = false; /** 正在编辑的用户原话;重 roll 前可改 */ let editingTurnId = null; let editingDraft = ""; /** 复制模式:开=回复可选中、显示复制按钮;关=整张卡片都能拖动(禁用复制) */ let copyMode = loadCopyMode(); /** 种子历史(假历史):编辑器和 slots 共用这一份数组,点「应用」才写盘 */ const seedTurns = []; /** 旧版种子历史文本:只为了不把老数据的纯文本丢掉(新数据为空) */ let seedLegacyText = ""; /** @type {ReturnType | null} */ let seedEditor = null; /** 侧栏收起状态:left = 对话列表,right = 上下文面板 */ let railLeftHidden = false; let railRightHidden = false; /** 右侧面板当前页签:shell = 上下文,keep = 保留统计 */ let contextTab = loadContextTab(); /** 盲测:藏起模型名,并用另一套左右顺序展示 */ let blindModels = false; /** @type {string[]} */ let blindOrder = []; /** 名字已全部显示,但位置仍是打乱后的顺序(复原只露名字,不还原位置) */ let blindNamesShown = false; /** 盲测下点了「揭示」的卡片:`turnId:windowId`,只作用于这一张 */ const revealedCards = new Set(); /** @type {Set} */ const expandedCards = new Set(); /** 思维链折叠条:`turnId:windowId` → 用户手动开合(true 展开 / false 收起) */ const thinkingToggles = new Map(); const MIN_CARD_H = 120; /** * 一行的折叠高度:至少两张都能撑到的最大高度。 * 从小到大排好后取第 min(2, n−1) 短:两张即最短,更多即次短。 * 单独更矮的一张拉不低整行。 */ function sharedReachHeight(heights) { if (!heights.length) return MIN_CARD_H; const sorted = [...heights].sort((a, b) => a - b); const index = Math.min(1, Math.max(0, sorted.length - 2)); return Math.max(MIN_CARD_H, sorted[index]); } async function api(path, options = {}) { const res = await fetch(path, { headers: { "Content-Type": "application/json", ...(options.headers || {}) }, ...options, }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = { error: text || res.statusText }; } if (!res.ok) { throw new Error(data?.error || res.statusText || `HTTP ${res.status}`); } return data; } function loadCopyMode() { try { return localStorage.getItem(COPY_MODE_STORAGE_KEY) !== "0"; } catch { return true; } } function saveCopyMode(enabled) { try { localStorage.setItem(COPY_MODE_STORAGE_KEY, enabled ? "1" : "0"); } catch { /* 存不进 localStorage 也不影响当前会话 */ } } function loadContextTab() { try { return localStorage.getItem(CONTEXT_TAB_STORAGE_KEY) === "keep" ? "keep" : "shell"; } catch { return "shell"; } } function saveContextTab(tab) { try { localStorage.setItem(CONTEXT_TAB_STORAGE_KEY, tab); } catch { /* 存不进 localStorage 也不影响当前会话 */ } } function loadRailsState() { try { const raw = JSON.parse(localStorage.getItem(RAILS_STORAGE_KEY) || "{}"); return { left: raw?.left === true, right: raw?.right === true }; } catch { return { left: false, right: false }; } } const railsState = loadRailsState(); railLeftHidden = railsState.left; railRightHidden = railsState.right; function saveRailsState() { try { localStorage.setItem( RAILS_STORAGE_KEY, JSON.stringify({ left: railLeftHidden, right: railRightHidden }), ); } catch { /* 忽略持久化失败 */ } } /** 把收起状态同步到网格与把手按钮上 */ function syncRails() { if (els.shell) { els.shell.classList.toggle("is-left-hidden", railLeftHidden); els.shell.classList.toggle("is-right-hidden", railRightHidden); } if (els.btnShowLeft) els.btnShowLeft.hidden = !railLeftHidden; if (els.btnShowRight) els.btnShowRight.hidden = !railRightHidden; } function toggleRail(side) { const left = side === "left"; if (left) railLeftHidden = !railLeftHidden; else railRightHidden = !railRightHidden; const hidden = left ? railLeftHidden : railRightHidden; const name = left ? "对话列表" : "上下文"; saveRailsState(); syncRails(); setStatus(`${hidden ? "已收起" : "已展开"}${name}`); if (navLevel === "conversation") renderArena(); else syncRoomChrome(); } /** 记下当前打开的对话(不跨 run:只写 sessionStorage) */ function rememberOpenConversation(conversationId) { try { if (conversationId) { sessionStorage.setItem(OPEN_CONVERSATION_KEY, conversationId); } else { sessionStorage.removeItem(OPEN_CONVERSATION_KEY); } } catch { /* 存不进也不影响当前会话 */ } } function loadOpenConversation() { try { return sessionStorage.getItem(OPEN_CONVERSATION_KEY) || ""; } catch { return ""; } } /** 刷新页后回到上次那个对话;没记录或打不开就直接回首屏 */ async function restoreOpenTarget() { const conversationId = loadOpenConversation(); if (conversationId) { try { await openConversation(conversationId); return; } catch { rememberOpenConversation(null); } } await goRoot(); } function loadTemps() { try { const raw = localStorage.getItem(TEMP_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map(normalizeTemp).filter(Boolean) : []; } catch { return []; } } function normalizeTemp(t) { if (!t || typeof t !== "object") return null; const windows = Array.isArray(t.windows) ? t.windows : []; let turns = Array.isArray(t.turns) ? t.turns : []; if (!turns.length && Array.isArray(t.messages) && t.messages.length) { turns = migrateFlatMessages(t.messages, windows[0]?.id || "default"); } return { id: t.id, name: t.name || "临时会话", slots: { ...EMPTY_SLOTS, ...(t.slots || {}) }, windows, turns, createdAt: t.createdAt || nowIso(), updatedAt: t.updatedAt || nowIso(), }; } function migrateFlatMessages(messages, windowId) { const turns = []; let pending = null; for (const m of messages) { if (m.role === "user") { pending = { content: m.content, createdAt: m.createdAt }; } else if (m.role === "assistant" && pending) { turns.push({ id: m.id || newId(), user: pending, replies: [ { windowId, content: m.content, thinking: m.thinking, model: m.model, contextTrace: m.contextTrace, }, ], createdAt: m.createdAt, }); pending = null; } } return turns; } function saveTemps(list) { const trimmed = list .slice() .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))) .slice(0, MAX_TEMPS); localStorage.setItem(TEMP_STORAGE_KEY, JSON.stringify(trimmed)); return trimmed; } function upsertTemp(temp) { const list = loadTemps().filter((t) => t.id !== temp.id); list.unshift(temp); return saveTemps(list); } function deleteTemp(id) { return saveTemps(loadTemps().filter((t) => t.id !== id)); } function newId() { return crypto.randomUUID(); } function nowIso() { return new Date().toISOString(); } function setStatus(text, isError = false) { if (!text) { els.status.hidden = true; els.status.textContent = ""; return; } els.status.hidden = false; els.status.textContent = text; els.status.classList.toggle("error", isError); } function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function slotsFromEditor() { return { systemPrompt: els.shellSystem.value, loreBefore: els.shellLoreBefore.value, seedHistory: seedLegacyText, seedMessages: seedTurns.map((turn) => ({ role: turn.role, content: turn.content })), loreAfter: els.shellLoreAfter.value, postTurn: els.shellPostTurn.value, charName: els.shellCharName.value, charDescription: els.shellCharDesc.value, charPersonality: els.shellCharPers.value, scenario: els.shellScenario.value, exampleDialogue: els.shellExamples.value, }; } function mountSeedEditor() { if (!els.shellSeedHistory || seedEditor) return; seedEditor = createHistoryEditor({ items: seedTurns, disabled: true, addLabel: "+ 加一句", emptyHint: "空则不占历史;点「+ 加一句」开始写。", }); els.shellSeedHistory.replaceChildren(seedEditor.el); } function fillSeedTurns(value, legacyText) { seedLegacyText = typeof legacyText === "string" ? legacyText : ""; const turns = normalizeHistoryTurns(value); seedTurns.splice(0, seedTurns.length, ...turns); if (seedEditor) seedEditor.redraw(); } function fillEditor(shellLike) { const s = shellLike || { name: "", ...EMPTY_SLOTS }; els.shellName.value = s.name || ""; els.shellSystem.value = s.systemPrompt || ""; els.shellLoreBefore.value = s.loreBefore || ""; fillSeedTurns(s.seedMessages, s.seedHistory); els.shellLoreAfter.value = s.loreAfter || ""; els.shellPostTurn.value = s.postTurn || ""; els.shellCharName.value = s.charName || ""; els.shellCharDesc.value = s.charDescription || ""; els.shellCharPers.value = s.charPersonality || ""; els.shellScenario.value = s.scenario || ""; els.shellExamples.value = s.exampleDialogue || ""; } function setContextEditorEnabled(enabled, subtitle) { const fields = [ els.shellName, els.shellSystem, els.shellLoreBefore, els.shellLoreAfter, els.shellPostTurn, els.shellCharName, els.shellCharDesc, els.shellCharPers, els.shellScenario, els.shellExamples, els.btnSaveShell, els.btnClearShell, ]; for (const el of fields) el.disabled = !enabled; if (seedEditor) seedEditor.setDisabled(!enabled); if (els.contextSub) { els.contextSub.textContent = subtitle || (enabled ? "改完点应用" : "选择或新建后编辑"); } } function syncContextPanel() { if (activeIsTemp) { fillEditor(activeShell || { name: "临时会话", ...EMPTY_SLOTS }); setContextEditorEnabled(true, "点应用后写入本机"); renderKeepStats(); return; } if (activeShell) { fillEditor(activeShell); setContextEditorEnabled(true, "持久上下文 · 点应用写入本机"); renderKeepStats(); return; } fillEditor({ name: "", ...EMPTY_SLOTS }); setContextEditorEnabled(false, "选择或新建后编辑"); renderKeepStats(); } /** 保留计数:按次数降序的条状图,只算当前会话 */ function renderKeepStats() { if (!els.keepList) return; const conversationId = activeConversationId || ""; const entries = readKeepStats(conversationId); const total = keepStatTotal(entries); const max = entries.length ? entries[0].count : 0; els.keepList.innerHTML = entries.length ? entries .map( (entry) => `
${escapeHtml(entry.label)} ${entry.count} 次
${entry.model ? `

${escapeHtml(entry.model)}

` : ""}
`, ) .join("") : `

${ 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 ? `
  • 直聊
  • ` : `
  • `, ); if (navLevel === "shell" || (navLevel === "conversation" && !activeIsTemp)) { const shellLabel = activeShell?.name || "上下文"; if (navLevel === "shell") { items.push( `
  • ${escapeHtml(shellLabel)}
  • `, ); } else { items.push( `
  • `, ); const convLabel = conversations.find((c) => c.id === activeConversationId)?.name || "会话"; items.push( `
  • ${escapeHtml(convLabel)}
  • `, ); } } else if (activeIsTemp) { items.push( `
  • 临时
  • `, ); } els.pathList.innerHTML = items.join(""); } function renderRootRail() { const temps = loadTemps(); const parts = []; if (temps.length) { parts.push(`

    快速对话(浏览器)

    `); for (const t of temps) { parts.push( railItemHtml({ id: t.id, title: t.name || "临时会话", meta: `${t.turns?.length || 0} 轮 · 临时`, temp: true, active: activeIsTemp && activeConversationId === t.id, copyable: true, }), ); } } parts.push(`

    已保存上下文

    `); if (!shells.length) { parts.push(`

    还没有上下文壳

    `); } for (const s of shells) { parts.push( railItemHtml({ id: s.id, title: s.name, meta: `${s.conversationCount} 个会话`, temp: false, active: !activeIsTemp && activeShellId === s.id, }), ); } els.railList.innerHTML = parts.join(""); } function renderRail() { if (navLevel === "root" || activeIsTemp) { renderRootRail(); return; } if (navLevel === "shell") { const parts = []; if (!conversations.length) { parts.push(`

    还没有会话

    `); } for (const c of conversations) { parts.push( railItemHtml({ id: c.id, title: c.name, meta: `${c.messageCount} 条消息`, temp: false, active: activeConversationId === c.id, copyable: true, }), ); } els.railList.innerHTML = parts.join(""); return; } els.railList.innerHTML = conversations .map((c) => railItemHtml({ id: c.id, title: c.name, meta: `${c.messageCount} 条消息`, temp: false, active: activeConversationId === c.id, copyable: true, }), ) .join(""); } function railItemHtml({ id, title, meta, temp, active, copyable }) { const copy = copyable ? `` : ""; return `
    ${copy}
    `; } function syncTempMark() { if (!els.tempMark) return; const show = activeIsTemp && navLevel === "conversation" && activeTurns.length === 0; els.tempMark.hidden = !show; } function syncComposerMenu() { if (els.btnSaveTemp) els.btnSaveTemp.hidden = true; if (!els.more || !els.morePanel) return; const inConv = navLevel === "conversation" && Boolean(activeConversationId); els.more.hidden = !inConv; if (!inConv) { els.more.removeAttribute("open"); els.morePanel.innerHTML = ""; return; } const only = activeWindows.length === 1 ? activeWindows[0] : null; const items = [ ``, ]; if (only) { items.push( ``, ); } if (activeWindows.length > 1) { items.push( ``, ); } if (activeIsTemp) { items.push( ``, ); } items.push( ``, ``, ); els.morePanel.innerHTML = items.join(""); } function cloneData(value) { return structuredClone(value); } function selectedFields(id) { if (id === undefined) return {}; return { selectedWindowId: id }; } function snapshotVariant(turn, after) { return { user: { content: turn.user.content, createdAt: turn.user.createdAt }, replies: cloneData(turn.replies || []), ...selectedFields(turn.selectedWindowId), after: cloneData(after), }; } function applyVariant(turn, variant, index, variants) { return { id: turn.id, user: { content: variant.user.content, createdAt: variant.user.createdAt }, replies: cloneData(variant.replies || []), createdAt: turn.createdAt, variants, variantIndex: index, ...selectedFields(variant.selectedWindowId), }; } function commitActiveVariant(turns, index) { const turn = turns[index]; if (!turn) return turns; const after = turns.slice(index + 1); const existing = turn.variants?.length ? turn.variants.map((v) => ({ ...v, after: v.after || [] })) : [snapshotVariant(turn, after)]; const idx = Math.min(existing.length - 1, Math.max(0, turn.variantIndex ?? 0)); existing[idx] = snapshotVariant( { ...turn, variants: existing, variantIndex: idx }, after, ); return [ ...turns.slice(0, index), { ...turn, variants: existing, variantIndex: idx }, ...after, ]; } function beginReroll(turns, turnId, text) { const trimmed = String(text || "").trim(); if (!trimmed) return null; const index = turns.findIndex((t) => t.id === turnId); if (index < 0) return null; const committed = commitActiveVariant(turns, index); const turn = committed[index]; if (!turn) return null; const variants = [ ...(turn.variants || []), { user: { content: trimmed, createdAt: turn.user.createdAt }, replies: [], after: [], }, ]; const nextTurn = { ...turn, user: { content: trimmed, createdAt: turn.user.createdAt }, replies: [], variants, variantIndex: variants.length - 1, }; delete nextTurn.selectedWindowId; return { turns: [...committed.slice(0, index), nextTurn], prior: committed.slice(0, index), index, }; } function writeRerollReplies(turns, turnId, replies) { const index = turns.findIndex((t) => t.id === turnId); if (index < 0) return turns; const turn = turns[index]; const variants = (turn.variants || []).slice(); const idx = Math.min( Math.max(0, turn.variantIndex ?? Math.max(0, variants.length - 1)), Math.max(0, variants.length - 1), ); const cloned = cloneData(replies || []); if (variants[idx]) { variants[idx] = { user: { ...turn.user }, replies: cloned, after: [], }; } const next = { ...turn, replies: cloneData(cloned), variants, variantIndex: idx, }; delete next.selectedWindowId; return [...turns.slice(0, index), next, ...turns.slice(index + 1)]; } function switchTurnVariant(turns, turnId, direction) { const index = turns.findIndex((t) => t.id === turnId); if (index < 0) return null; const committed = commitActiveVariant(turns, index); const turn = committed[index]; if (!turn) return null; const variants = turn.variants || []; const cur = turn.variantIndex ?? 0; const nextIndex = direction === "next" ? cur + 1 : cur - 1; if (nextIndex < 0 || nextIndex >= variants.length) return null; const variant = variants[nextIndex]; const restored = applyVariant(turn, variant, nextIndex, variants); return [ ...committed.slice(0, index), restored, ...cloneData(variant.after || []), ]; } function renderUserActions(turn) { const total = (turn.variants || []).length; const index = Math.min(total, Math.max(1, (turn.variantIndex ?? 0) + 1)); const pager = total > 1 ? ` ${index}/${total} ` : ""; return `
    ${pager}
    `; } function renderReplyCard(turn, w, multi, displayIndex) { const reply = turn ? (turn.replies || []).find((r) => r.windowId === w.id) : null; const body = turn ? replyView(reply) : { cls: "is-pending", empty: true, thinking: "", text: "在下方输入后同时回复" }; const selected = Boolean(turn && turn.selectedWindowId === w.id && !body.empty); const turnId = turn?.id || "idle"; const keep = turn && multi && !body.empty ? `` : turn && selected ? `已保留` : ""; const remove = activeWindows.length > 1 ? `` : ""; const copy = copyMode && turn && !body.empty ? `` : ""; const retrying = Boolean(turn) && retryingWindows.has(retryKey(turn.id, w.id)); const actions = copy || remove ? `
    ${copy}${remove}
    ` : ""; // 重新生成:成功也能再跑一次,就放在「保留」旁边 const regen = turn ? retrying ? `` : body.empty ? "" : `` : ""; const blind = blindModels && !blindNamesShown && multi; const revealed = blind && revealedCards.has(cardExpandKey(turnId, w.id)); const titles = blind ? `
    ${revealed ? `${escapeHtml(revealedNameText(w))}` : ""}
    ` : `
    ${escapeHtml(windowLabel(w))} ${escapeHtml(w.model || w.profileId || "")}
    `; return `
    ${titles} ${actions}
    ${replyBodyHtml(body, cardExpandKey(turnId, w.id))}
    ${regen} ${keep}
    `; } function replyBodyHtml(body, key = "") { if (body.empty) return escapeHtml(body.text); const thinking = String(body.thinking ?? "").trim(); const thinkingHtml = thinking ? `
    ${escapeHtml(thinkingSummaryText(thinking))}
    ${renderMarkdownToHtml(thinking)}
    ` : ""; return `${thinkingHtml}${renderMarkdownToHtml(body.text)}`; } function replyPlainText(reply) { const body = replyView(reply); if (body.empty) return ""; return [body.thinking, body.text] .map((part) => part.trim()) .filter(Boolean) .join("\n\n"); } async function writeClipboard(text) { if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return; } catch { /* fall through to the textarea path */ } } const ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand("copy"); ta.remove(); if (!ok) throw new Error("复制失败"); } async function copyReplyText(turnId, windowId, button) { const turn = activeTurns.find((t) => t.id === turnId); const reply = (turn?.replies || []).find((r) => r.windowId === windowId); const text = replyPlainText(reply); if (!text) { setStatus("没有可复制的回复", true); return; } await writeClipboard(text); if (button) { const prev = button.textContent; button.textContent = "已复制"; window.setTimeout(() => { if (button.isConnected) button.textContent = prev || "复制"; }, 1200); } setStatus("已复制这条回复"); } /** 该轮已保留且回复可用的窗口;没有则返回 null(全部并排显示)。 */ function keptWindowId(turn) { const id = turn?.selectedWindowId; if (!id) return null; const reply = (turn.replies || []).find((r) => r.windowId === id); if (!reply || replyView(reply).empty) return null; return id; } /** 本轮里失败的窗口 id(按当前展示顺序)。 */ function failedWindowIds(turn) { if (!turn) return []; const shown = new Set(displayWindows().map((w) => w.id)); return (turn.replies || []) .filter((r) => !r.pending && r.error && shown.has(r.windowId)) .map((r) => r.windowId); } /** 把重试拿到的回复按 windowId 合并回本轮,不动其它窗口。 */ function mergeRepliesIntoTurn(turns, turnId, replies) { return turns.map((t) => { if (t.id !== turnId) return t; const byWindow = new Map((t.replies || []).map((r) => [r.windowId, r])); for (const reply of replies) byWindow.set(reply.windowId, reply); const nextReplies = cloneData([...byWindow.values()]); const variants = (t.variants || []).slice(); const idx = Math.min( Math.max(0, t.variantIndex ?? Math.max(0, variants.length - 1)), Math.max(0, variants.length - 1), ); if (variants[idx]) { variants[idx] = { ...variants[idx], replies: cloneData(nextReplies) }; } return { ...t, replies: nextReplies, variants, variantIndex: idx }; }); } function renderTurnStrip(turn) { const multi = activeWindows.length > 1; const entries = displayWindows().map((w) => w); const keptId = keptWindowId(turn); // 保留后只留被保留的那一张卡;其余窗口仍会在下一轮分别请求。 const shown = keptId ? entries.filter((w) => w.id === keptId) : entries; const count = shown.length; // 编号按本行显示顺序重排,不跟着打乱后的全局位次走 const cards = shown .map((w, index) => renderReplyCard(turn, w, multi, index)) .join(""); const turnId = turn?.id || "idle"; const userBlock = turn ? (() => { const time = formatBubbleTime(turn.user?.createdAt || turn.createdAt); const editing = editingTurnId === turn.id; const body = editing ? `

    Ctrl/⌘ + Enter 保存 · Esc 取消

    ` : `
    ${escapeHtml(turn.user?.content || "")}
    ${renderUserActions(turn)} ${time ? `` : ""}`; return `
    ${body}
    `; })() : ""; const stripBar = multi && !keptId ? `
    左右切换,上下阅读 ${failedWindowIds(turn).length > 1 ? `` : ""}
    1 / ${count}
    ` : ""; return `
    ${userBlock} ${stripBar}
    ${cards}
    `; } function measureTrack(track) { const card = track.querySelector(".chat-reply-card"); const styles = getComputedStyle(track); const gap = Number.parseFloat(styles.columnGap || styles.gap || "10") || 10; const cardW = card?.offsetWidth || track.clientWidth || 1; const step = cardW + (card ? gap : 0); const trackW = track.clientWidth || 1; const count = track.querySelectorAll(".chat-reply-card").length; const visible = Math.max(1, Math.floor((trackW + 1) / Math.max(step, 1))); track._geo = { step: Math.max(step, 1), visible, count }; return track._geo; } function cardStep(track) { return (track._geo || measureTrack(track)).step; } function visibleCardCount(track) { return (track._geo || measureTrack(track)).visible; } function updateStripIdx(track) { const geo = track._geo || measureTrack(track); if (!geo.count) return; const turnId = track.getAttribute("data-turn-id") || ""; const idx = els.arena?.querySelector(`[data-strip-idx="${CSS.escape(turnId)}"]`); if (!idx) return; const start = Math.min( Math.max(0, geo.count - geo.visible), Math.max(0, Math.round(track.scrollLeft / geo.step)), ); const end = Math.min(geo.count, start + geo.visible); const label = end > start + 1 ? `${start + 1}–${end} / ${geo.count}` : `${start + 1} / ${geo.count}`; if (idx.textContent !== label) idx.textContent = label; const prev = els.arena?.querySelector( `[data-action="strip-prev"][data-turn-id="${CSS.escape(turnId)}"]`, ); const next = els.arena?.querySelector( `[data-action="strip-next"][data-turn-id="${CSS.escape(turnId)}"]`, ); const lastPage = Math.max(0, geo.count - geo.visible); if (prev) prev.disabled = start <= 0; if (next) next.disabled = start >= lastPage; } function scrollTrackToIndex(track, index) { const cards = [...track.querySelectorAll(".chat-reply-card")]; const card = cards[index]; if (!card) return; track.scrollTo({ left: card.offsetLeft - track.offsetLeft, behavior: "smooth", }); } function verticalScrollTarget(from) { const card = from?.closest?.(".chat-reply-card"); const text = card?.querySelector(".chat-reply-card-text"); if (text && text.scrollHeight > text.clientHeight + 1) return text; return els.arena?.querySelector(".chat-arena-stream") || null; } const DRAG_SLOP = 8; function clearTextSelection() { const sel = window.getSelection(); if (sel && sel.rangeCount && !sel.isCollapsed) sel.removeAllRanges(); } /** 滚动时把滚动条露一下,停下来 700ms 再藏回去 */ function flashScrollBar(el) { el.classList.add("is-vscroll"); window.clearTimeout(el._wheelHide); el._wheelHide = window.setTimeout(() => { el.classList.remove("is-vscroll"); }, 700); } /** 卡片正文没在顶端就一直露着滚动条,滚回顶端才收起来 */ function syncCardScrollMark(text) { text.classList.toggle("is-scrolled", text.scrollTop > 1); } /** 给每张分卡片的正文挂上滚动条标记(同一元素只挂一次) */ function bindCardScrollMarks() { if (!els.arena) return; for (const text of els.arena.querySelectorAll(".chat-reply-card-text")) { if (!text._scrollMarkBound) { text._scrollMarkBound = true; text.addEventListener("scroll", () => syncCardScrollMark(text), { passive: true, }); } syncCardScrollMark(text); } } function bindReplyTrack(track) { let dragging = false; let axis = null; let startX = 0; let startY = 0; let startScroll = 0; let vTarget = null; let startVScroll = 0; track.addEventListener("pointerdown", (e) => { if ( e.button !== 0 || e.target.closest("button, a, textarea, input, summary") ) return; // 复制模式下正文留给选中复制;关闭复制后整张卡片都能拖 if (copyMode && e.target.closest(".chat-reply-card-text")) return; dragging = true; axis = null; vTarget = null; startX = e.clientX; startY = e.clientY; startScroll = track.scrollLeft; }); track.addEventListener("selectstart", (e) => { if (axis || !copyMode) e.preventDefault(); }); track.addEventListener("pointermove", (e) => { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (!axis) { if (Math.abs(dx) < DRAG_SLOP && Math.abs(dy) < DRAG_SLOP) return; axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; track.classList.add("is-dragging"); try { track.setPointerCapture(e.pointerId); } catch { /* 指针已经不在或不可捕获时忽略,拖动靠 pointermove 也能继续 */ } if (e.cancelable) e.preventDefault(); clearTextSelection(); if (axis === "y") { vTarget = verticalScrollTarget(e.target); if (vTarget) { startVScroll = vTarget.scrollTop; vTarget.classList.add("is-vscroll"); } } } if (e.cancelable) e.preventDefault(); if (axis === "x") { track.scrollLeft = startScroll - dx * dragGain; return; } if (vTarget) vTarget.scrollTop = startVScroll - dy * dragGain; }); const end = () => { dragging = false; axis = null; track.classList.remove("is-dragging"); vTarget?.classList.remove("is-vscroll"); vTarget = null; }; track.addEventListener("pointerup", end); track.addEventListener("pointercancel", end); track.addEventListener( "wheel", (e) => { if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; const stream = els.arena?.querySelector(".chat-arena-stream"); if (!stream) return; // 指针下的那张卡片优先;卡片到顶 / 到底后,剩下的距离交给大滚动条 const card = e.target.closest?.(".chat-reply-card") || null; const text = card?.querySelector(".chat-reply-card-text") || null; const plan = planWheelScroll({ cardScrollTop: text?.scrollTop ?? 0, cardScrollMax: text ? Math.max(0, text.scrollHeight - text.clientHeight) : 0, deltaY: e.deltaY, streamScrollTop: stream.scrollTop, streamScrollMax: Math.max(0, stream.scrollHeight - stream.clientHeight), }); if (!plan.cardDelta && !plan.streamDelta) return; e.preventDefault(); if (plan.cardDelta && text) { text.scrollTop += plan.cardDelta; flashScrollBar(text); } if (plan.streamDelta) { stream.scrollTop += plan.streamDelta; flashScrollBar(stream); } }, { passive: false }, ); let scrollFrame = 0; track.addEventListener( "scroll", () => { if (scrollFrame) return; scrollFrame = requestAnimationFrame(() => { scrollFrame = 0; updateStripIdx(track); }); }, { passive: true }, ); if (typeof ResizeObserver !== "undefined") { const observer = new ResizeObserver(() => { measureTrack(track); updateStripIdx(track); }); observer.observe(track); } measureTrack(track); updateStripIdx(track); } /** * 卡片最高只到容器可见高度:叠在折叠高度上面, * 再长的回复也一屏装得下,剩下的靠卡片正文自己滚。 */ function cardHeightCap() { const stream = els.arena?.querySelector(".chat-arena-stream"); const visible = stream?.clientHeight || 0; if (visible <= 0) return Number.POSITIVE_INFINITY; // 减去容器内边距和一点余量,行与行之间还能看出边界 return Math.max(MIN_CARD_H, visible - 40); } function collapseReplyTracks() { if (!els.arena) return; const cap = cardHeightCap(); for (const track of els.arena.querySelectorAll(".chat-reply-track")) { const cards = [...track.querySelectorAll(".chat-reply-card")]; if (cards.length < 2) { for (const card of cards) { card.style.height = "auto"; card.classList.remove("is-collapsed"); const btn = card.querySelector("[data-action='expand-card']"); if (btn) btn.hidden = true; } continue; } const measured = cards.map((card) => { card.style.height = "auto"; const empty = card.querySelector(".chat-reply-card-body")?.dataset.empty === "1"; return { card, h: card.offsetHeight, empty }; }); const substantive = measured.filter((item) => !item.empty).map((item) => item.h); const target = Math.min(sharedReachHeight(substantive), cap); for (const { card, h } of measured) { const turnId = card.getAttribute("data-turn-id") || ""; const windowId = card.getAttribute("data-window-id") || ""; const key = cardExpandKey(turnId, windowId); const btn = card.querySelector("[data-action='expand-card']"); if (expandedCards.has(key)) { // 展开也只是比折叠高一点,超过一屏就封顶,剩下的内部滚 const capped = h > cap; card.style.height = capped ? `${cap}px` : "auto"; card.classList.toggle("is-capped", capped); card.classList.remove("is-collapsed"); if (btn) { btn.hidden = false; btn.textContent = "收起"; } continue; } card.style.height = `${target}px`; card.classList.remove("is-capped"); card.classList.add("is-collapsed"); if (btn) { btn.hidden = !(h > target + 1); btn.textContent = "展开"; } } } bindCardScrollMarks(); } function renderArena() { if (!els.arena) return; syncRails(); const scrollLefts = new Map(); for (const track of els.arena.querySelectorAll(".chat-reply-track")) { scrollLefts.set(track.getAttribute("data-turn-id") || "", track.scrollLeft); } syncTempMark(); syncComposerMenu(); if (!activeWindows.length) { els.arena.classList.add("is-empty"); els.arena.innerHTML = ``; return; } els.arena.classList.remove("is-empty"); els.arena.classList.toggle("is-copy-off", !copyMode); const body = activeTurns.length ? activeTurns.map((turn) => renderTurnStrip(turn)).join("") : `

    在下方输入,各模型会同时回复同一条消息。

    ${renderTurnStrip(null)}`; els.arena.innerHTML = `
    ${body}
    `; const stream = els.arena.querySelector(".chat-arena-stream"); if (stream) { // 指针不在卡片上时(行间空隙、提示文字)浏览器自己滚,这里只把滚动条露一下 stream.addEventListener( "wheel", (e) => { if (e.defaultPrevented || !Math.abs(e.deltaY)) return; flashScrollBar(stream); }, { passive: true }, ); } for (const track of els.arena.querySelectorAll(".chat-reply-track")) { bindReplyTrack(track); } const finalize = () => { for (const track of els.arena.querySelectorAll(".chat-reply-track")) { const left = scrollLefts.get(track.getAttribute("data-turn-id") || ""); if (left) track.scrollLeft = left; } collapseReplyTracks(); for (const track of els.arena.querySelectorAll(".chat-reply-track")) { measureTrack(track); updateStripIdx(track); } const stream = els.arena.querySelector(".chat-arena-stream"); const editing = els.arena.querySelector(".chat-user-edit"); if (editing instanceof HTMLTextAreaElement) { sizeUserEdit(editing); if (document.activeElement !== editing) { editing.focus(); const len = editing.value.length; editing.setSelectionRange(len, len); } editing.closest(".chat-user-bubble")?.scrollIntoView({ block: "nearest" }); } else if (stream) { stream.scrollTop = stream.scrollHeight; } }; // 等 flex 宽度落地后再量卡片与折叠高度 requestAnimationFrame(() => requestAnimationFrame(finalize)); } function setComposerEnabled(enabled) { els.input.disabled = !enabled; els.btnSend.disabled = !enabled; } function ensureDraftTemp() { const temps = loadTemps(); if (temps.length) return temps[0]; const temp = { id: newId(), name: "临时会话", slots: { ...EMPTY_SLOTS }, windows: [], turns: [], createdAt: nowIso(), updatedAt: nowIso(), }; upsertTemp(temp); return temp; } function openDraftTemp() { const temp = ensureDraftTemp(); activeIsTemp = true; activeConversationId = temp.id; activeShellId = null; activeShell = { name: temp.name || "临时会话", ...EMPTY_SLOTS, ...temp.slots }; activeWindows = Array.isArray(temp.windows) ? temp.windows : []; activeTurns = temp.turns || []; navLevel = "conversation"; els.btnSaveTemp.hidden = false; els.btnNewConv.hidden = true; setComposerEnabled(true); syncContextPanel(); renderArena(); } function syncRoomChrome() { syncRails(); els.btnNewConv.hidden = !( navLevel === "shell" || (navLevel === "conversation" && !activeIsTemp) ); syncContextPanel(); els.room.hidden = false; if (navLevel === "shell" && !activeIsTemp) { els.btnSaveTemp.hidden = true; setComposerEnabled(false); activeWindows = []; activeTurns = []; syncTempMark(); syncComposerMenu(); if (els.arena) { els.arena.classList.remove("is-empty"); els.arena.innerHTML = `
    尚未打开会话。从左侧选会话,或点「新建会话」。
    `; } return; } if (navLevel !== "conversation") { openDraftTemp(); return; } els.btnSaveTemp.hidden = !activeIsTemp; setComposerEnabled(true); renderArena(); } async function refreshShells() { const data = await api("/api/direct-chat/shells"); shells = data.shells || []; } async function refreshConversations(shellId) { const data = await api( `/api/direct-chat/shells/${encodeURIComponent(shellId)}`, ); activeShell = data.shell; conversations = data.conversations || []; fillEditor(activeShell); } async function loadProfiles() { try { const data = await api("/api/profiles"); profileList = (data.profiles || []).map((p) => ({ id: p.id, name: p.name, model: p.model, hasKey: Boolean(String(p.apiKey ?? "").trim()), })); } catch { profileList = []; } } /** 设置页里的直聊偏好(目前只有拖动倍率);取不到就用默认值 */ async function loadChatPrefs() { try { const data = await api("/api/settings"); const gain = Number(data?.settings?.chatDragGain); if (Number.isFinite(gain) && gain > 0) { dragGain = Math.min(3, Math.max(0.5, gain)); } } catch { /* 用默认倍率 */ } } async function goRoot() { activeShellId = null; conversations = []; rememberOpenConversation(null); await refreshShells(); openDraftTemp(); renderPath(); renderRail(); } async function openShell(shellId) { activeIsTemp = false; activeShellId = shellId; activeConversationId = null; activeTurns = []; activeWindows = []; navLevel = "shell"; rememberOpenConversation(null); await refreshConversations(shellId); renderPath(); renderRail(); syncRoomChrome(); } async function openConversation(conversationId) { const data = await api( `/api/direct-chat/conversations/${encodeURIComponent(conversationId)}`, ); activeIsTemp = false; activeConversationId = conversationId; activeShellId = data.conversation.shellId; activeShell = data.shell; activeWindows = data.conversation.windows || []; activeTurns = data.conversation.turns || []; navLevel = "conversation"; rememberOpenConversation(conversationId); if (activeShellId) { const shellData = await api( `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}`, ); conversations = shellData.conversations || []; activeShell = shellData.shell || activeShell; } renderPath(); renderRail(); syncRoomChrome(); } function openTemp(tempId) { const temp = loadTemps().find((t) => t.id === tempId); if (!temp) return; activeIsTemp = true; activeConversationId = temp.id; activeShellId = null; activeShell = { name: temp.name || "临时会话", ...EMPTY_SLOTS, ...temp.slots }; activeWindows = temp.windows || []; activeTurns = temp.turns || []; navLevel = "conversation"; rememberOpenConversation(null); renderPath(); renderRail(); syncRoomChrome(); } async function createShellAndOpen() { const name = window.prompt("上下文名称", "新上下文"); if (name == null) return; const data = await api("/api/direct-chat/shells", { method: "POST", body: JSON.stringify({ name: name.trim() || "新上下文", ...EMPTY_SLOTS }), }); await refreshShells(); await openShell(data.shell.id); } function copySessionName(name) { const base = String(name || "会话").replace(/(?: 副本)+$/u, "").trim() || "会话"; return `${base} 副本`; } function remapCopiedTurn(turn, idMap) { const remapReply = (r) => ({ ...r, windowId: idMap.get(r.windowId) || r.windowId, }); const remapSelected = (id) => id ? idMap.get(id) || null : (id ?? null); const next = { ...turn, id: newId(), user: turn.user ? { ...turn.user } : turn.user, replies: (turn.replies || []).map(remapReply), selectedWindowId: remapSelected(turn.selectedWindowId), }; if (turn.variants?.length) { next.variants = turn.variants.map((v) => ({ ...v, replies: (v.replies || []).map(remapReply), selectedWindowId: remapSelected(v.selectedWindowId), after: (v.after || []).map((child) => remapCopiedTurn(child, idMap)), })); } return next; } function cloneWindowsAndTurns(windows, turns) { const idMap = new Map(); const nextWindows = (windows || []).map((w) => { const id = newId(); idMap.set(w.id, id); return { ...w, id }; }); const nextTurns = (turns || []).map((t) => remapCopiedTurn(t, idMap)); return { windows: nextWindows, turns: nextTurns }; } async function copyConversation(id, isTemp) { if (busy) { setStatus("回复进行中,请稍后再复制", true); return; } if (isTemp) { const source = loadTemps().find((t) => t.id === id); if (!source) return; if (loadTemps().length >= MAX_TEMPS) { setStatus("快速对话最多保留 2 条,请先保存或删除一条再复制", true); return; } const cloned = cloneWindowsAndTurns(source.windows, source.turns); const temp = { id: newId(), name: copySessionName(source.name), slots: { ...EMPTY_SLOTS, ...(source.slots || {}) }, windows: cloned.windows, turns: cloned.turns, createdAt: nowIso(), updatedAt: nowIso(), }; upsertTemp(temp); copyKeepStats(id, temp.id); openTemp(temp.id); setStatus("已复制会话"); return; } const data = await api( `/api/direct-chat/conversations/${encodeURIComponent(id)}/duplicate`, { method: "POST", body: "{}" }, ); copyKeepStats(id, data.conversation.id); await openConversation(data.conversation.id); setStatus("已复制会话"); } function contextFieldsEmpty() { return ( !els.shellSystem.value.trim() && !els.shellLoreBefore.value.trim() && seedTurns.length === 0 && !els.shellLoreAfter.value.trim() && !els.shellPostTurn.value.trim() ); } async function clearContextSlots() { if (!els.btnClearShell || els.btnClearShell.disabled) return; if (busy) { setStatus("回复进行中,请稍后再清空", true); return; } if (contextFieldsEmpty()) { setStatus("上下文已经是空的,已选模型仍保留"); return; } const shared = !activeIsTemp && conversations.length > 1 ? `此上下文下的 ${conversations.length} 个会话都会改用清空后的设定。` : ""; if ( !window.confirm( `清空系统提示和设定?名称会保留,已选模型也不会动。${shared}`, ) ) { return; } if (activeIsTemp && activeConversationId) { const temp = loadTemps().find((t) => t.id === activeConversationId); if (!temp) return; temp.slots = { ...EMPTY_SLOTS }; temp.windows = activeWindows; temp.turns = activeTurns; temp.updatedAt = nowIso(); upsertTemp(temp); activeShell = { name: els.shellName.value || temp.name || "临时会话", ...EMPTY_SLOTS, }; fillEditor(activeShell); if (els.contextSub) els.contextSub.textContent = "已清空,模型仍保留"; setStatus("已清空上下文,模型仍保留"); return; } if (!activeShellId) return; const name = activeShell?.name || els.shellName.value.trim() || "未命名上下文"; const data = await api( `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}`, { method: "PATCH", body: JSON.stringify({ name, ...EMPTY_SLOTS }), }, ); activeShell = data.shell; fillEditor(activeShell); await refreshShells(); renderPath(); renderRail(); if (els.contextSub) els.contextSub.textContent = "已清空,模型仍保留"; setStatus("已清空上下文,模型仍保留"); } function modelPickHtml(profiles, { checkedIds, lockedIds } = {}) { const checked = checkedIds || new Set(); const locked = lockedIds || new Set(); return profiles .map((p) => { const isLocked = locked.has(p.id); const isChecked = isLocked || checked.has(p.id); const model = p.model ? ` · ${escapeHtml(p.model)}` : ""; return ``; }) .join(""); } function openNewConvDialog() { if (!activeShellId || !els.dialogNewConv) return; els.newConvName.value = "新会话"; const selected = new Set(activeWindows.map((w) => w.profileId)); const available = profileList.filter((p) => p.hasKey); if (!available.length) { els.newConvModels.innerHTML = ""; els.newConvModelsEmpty.hidden = false; } else { els.newConvModelsEmpty.hidden = true; els.newConvModels.innerHTML = modelPickHtml(available, { checkedIds: selected }); } els.dialogNewConv.showModal(); } async function createConversationUnderShell(name, windows) { if (!activeShellId) return; const data = await api( `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}/conversations`, { method: "POST", body: JSON.stringify({ name: name.trim() || "新会话", windows, turns: [], }), }, ); await openConversation(data.conversation.id); setStatus( windows.length ? `已新建会话,${windows.length} 个模型` : "已新建会话", ); } async function saveShellEditor() { const slots = slotsFromEditor(); const name = els.shellName.value.trim() || "未命名上下文"; if (activeIsTemp && activeConversationId) { if (busy) return; const tempId = activeConversationId; const temp = loadTemps().find((t) => t.id === tempId); if (!temp) return; busy = true; els.btnSaveShell.disabled = true; try { const data = await api("/api/direct-chat/promote", { method: "POST", body: JSON.stringify({ shell: { name, ...slots }, conversationName: "会话", windows: activeWindows, turns: activeTurns, }), }); deleteTemp(tempId); await openConversation(data.conversation.id); setStatus("已保存为持久会话"); } finally { busy = false; if (activeIsTemp) syncContextPanel(); } return; } if (!activeShellId) return; const data = await api( `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}`, { method: "PATCH", body: JSON.stringify({ name, ...slots }), }, ); activeShell = data.shell; fillEditor(activeShell); await refreshShells(); renderPath(); renderRail(); if (els.contextSub) els.contextSub.textContent = "已保存"; setStatus("上下文已保存"); } function replyFromWindowResult(result) { return { windowId: result.windowId, content: result.reply || "", ...(result.reasoning ? { thinking: result.reasoning } : {}), ...(result.model ? { model: result.model } : {}), ...(result.error ? { error: result.error } : {}), ...(result.contextTrace ? { contextTrace: result.contextTrace } : {}), }; } function makePendingTurn(text) { const ts = nowIso(); return { id: newId(), pending: true, user: { content: text, createdAt: ts }, replies: activeWindows.map((w) => ({ windowId: w.id, content: "", pending: true, })), createdAt: ts, }; } async function readNdjson(res, onEvent) { const reader = res.body?.getReader(); if (!reader) throw new Error("响应无法读取"); const decoder = new TextDecoder(); let buf = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let nl = buf.indexOf("\n"); while (nl >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (line) onEvent(JSON.parse(line)); nl = buf.indexOf("\n"); } } const tail = buf.trim(); if (tail) onEvent(JSON.parse(tail)); } async function postMulti(path, body, hooks) { const onWindow = hooks?.onWindow || (() => {}); const onDelta = hooks?.onDelta || (() => {}); const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/x-ndjson, application/json", }, body: JSON.stringify(body), }); const ctype = res.headers.get("content-type") || ""; if (ctype.includes("application/json")) { const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = { error: text || res.statusText }; } if (!res.ok) throw new Error(data?.error || res.statusText || `HTTP ${res.status}`); for (const result of data?.multi?.results || []) onWindow(result); return data; } if (!res.ok) { const text = await res.text(); throw new Error(text || res.statusText || `HTTP ${res.status}`); } let done = null; await readNdjson(res, (event) => { if (event.type === "delta" && event.delta) onDelta(event); else if (event.type === "window" && event.result) onWindow(event.result); else if (event.type === "done") done = event; else if (event.type === "error") throw new Error(event.error || "发送失败"); }); if (!done?.multi?.turn) throw new Error("未返回回合结果"); return done; } const streamingPaints = new Set(); function paintStreamingReply(turn, windowId) { const card = els.arena?.querySelector( `.chat-reply-card[data-turn-id="${CSS.escape(turn.id)}"][data-window-id="${CSS.escape(windowId)}"]`, ); const reply = (turn.replies || []).find((r) => r.windowId === windowId); if (!card || !reply) { renderArena(); return; } const body = replyView(reply); const bodyEl = card.querySelector(".chat-reply-card-body"); const textEl = card.querySelector(".chat-reply-card-text"); if (!bodyEl || !textEl) return; bodyEl.classList.toggle("is-pending", body.cls === "is-pending"); bodyEl.classList.toggle("is-error", body.cls === "is-error"); bodyEl.dataset.empty = body.empty ? "1" : "0"; textEl.innerHTML = replyBodyHtml(body, cardExpandKey(turn.id, windowId)); card.style.height = "auto"; card.classList.remove("is-collapsed", "is-capped"); const stream = els.arena?.querySelector(".chat-arena-stream"); if (stream) { const gap = stream.scrollHeight - stream.scrollTop - stream.clientHeight; if (gap < 80) stream.scrollTop = stream.scrollHeight; } } function scheduleStreamingPaint(turn, windowId) { if (streamingPaints.has(windowId)) return; streamingPaints.add(windowId); requestAnimationFrame(() => { streamingPaints.delete(windowId); paintStreamingReply(turn, windowId); }); } let rememberTimer = 0; function stripPendingTurn(turn) { const stripReply = (r) => { const { pending: _pending, ...rest } = r; return rest; }; const next = { ...turn, replies: (turn.replies || []).map(stripReply), }; if (turn.variants?.length) { next.variants = turn.variants.map((v) => ({ ...v, replies: (v.replies || []).map(stripReply), after: (v.after || []).map(stripPendingTurn), })); } return next; } function writeStreamingTurn() { if (!activeIsTemp || !activeConversationId) return; const temp = loadTemps().find((t) => t.id === activeConversationId); if (!temp) return; temp.turns = activeTurns.map(stripPendingTurn); temp.windows = activeWindows; temp.updatedAt = nowIso(); upsertTemp(temp); } function rememberStreamingTurn() { if (!activeIsTemp || !activeConversationId) return; if (rememberTimer) return; rememberTimer = window.setTimeout(() => { rememberTimer = 0; writeStreamingTurn(); }, 400); } function finishSendStatus(results) { const failed = (results || []).filter((r) => r.error); setStatus( failed.length ? `${failed.length} 个窗口失败,其余已写入` : "", failed.length > 0, ); } async function sendMessage() { if (busy) return; const text = els.input.value.trim(); if (!text) return; if (navLevel !== "conversation" || !activeConversationId) { setStatus("请先打开一个会话", true); return; } if (!activeWindows.length) { setStatus("请先新增至少一个模型窗口", true); return; } const total = activeWindows.length; const priorTurns = activeTurns.slice(); const pending = makePendingTurn(text); let arrived = 0; let sawText = false; busy = true; els.btnSend.disabled = true; els.input.value = ""; activeTurns = [...priorTurns, pending]; setStatus(total > 1 ? `回复中 0/${total}` : "回复中…"); renderArena(); const onDelta = (event) => { let reply = pending.replies.find((r) => r.windowId === event.windowId); if (!reply) { reply = { windowId: event.windowId, content: "", thinking: "" }; pending.replies.push(reply); } reply.pending = false; if (event.field === "thinking") { reply.thinking = `${reply.thinking || ""}${event.delta}`; } else { reply.content = `${reply.content || ""}${event.delta}`; } sawText = true; scheduleStreamingPaint(pending, event.windowId); rememberStreamingTurn(); }; const onWindow = (result) => { const idx = pending.replies.findIndex((r) => r.windowId === result.windowId); const reply = replyFromWindowResult(result); if (idx >= 0) pending.replies[idx] = reply; else pending.replies.push(reply); if (reply.content || reply.thinking) sawText = true; arrived += 1; setStatus(total > 1 ? `回复中 ${arrived}/${total}` : "回复中…"); rememberStreamingTurn(); renderArena(); }; const hooks = { onDelta, onWindow }; try { if (activeIsTemp) { const temp = loadTemps().find((t) => t.id === activeConversationId); if (!temp) throw new Error("临时会话不存在"); const slots = { ...EMPTY_SLOTS, ...temp.slots }; const data = await postMulti( "/api/direct-chat/ephemeral/complete", { text, ...slots, windows: activeWindows, turns: priorTurns, }, hooks, ); const multi = data.multi; activeTurns = [...priorTurns, multi.turn]; temp.turns = activeTurns; temp.windows = activeWindows; temp.slots = slots; temp.updatedAt = nowIso(); upsertTemp(temp); finishSendStatus(multi.results); renderArena(); renderRail(); return; } const data = await postMulti( `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}/messages`, { text, windows: activeWindows }, hooks, ); activeTurns = data.conversation?.turns || [...priorTurns, data.multi.turn]; activeWindows = data.conversation?.windows || activeWindows; if (data.shell) activeShell = data.shell; finishSendStatus(data.multi?.results); renderArena(); if (activeShellId) { const shellData = await api( `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}`, ); conversations = shellData.conversations || []; renderRail(); } } catch (err) { if (!sawText && arrived === 0) { activeTurns = priorTurns; els.input.value = text; renderArena(); } else { for (const reply of pending.replies) { if (reply.pending) { reply.pending = false; reply.error = "未返回"; } } if (rememberTimer) { clearTimeout(rememberTimer); rememberTimer = 0; } writeStreamingTurn(); renderArena(); } setStatus(err instanceof Error ? err.message : String(err), true); } finally { busy = false; els.btnSend.disabled = false; renderArena(); } } function sizeUserEdit(ta) { ta.style.height = "auto"; ta.style.height = `${Math.min(ta.scrollHeight, 320)}px`; } function startEditTurn(turnId) { if (busy) return; const turn = activeTurns.find((t) => t.id === turnId); if (!turn) return; editingTurnId = turnId; editingDraft = turn.user?.content || ""; renderArena(); } function cancelEditTurn() { editingTurnId = null; editingDraft = ""; renderArena(); } async function rerollTurn(turnId, text) { if (busy) return; const trimmed = String(text || "").trim(); if (!trimmed) { setStatus("内容不能为空", true); return; } if (navLevel !== "conversation" || !activeConversationId) { setStatus("请先打开一个会话", true); return; } if (!activeWindows.length) { setStatus("请先新增至少一个模型窗口", true); return; } const index = activeTurns.findIndex((t) => t.id === turnId); if (index < 0) return; const later = activeTurns.length - index - 1; const snapshot = cloneData(activeTurns); const begun = beginReroll(snapshot, turnId, trimmed); if (!begun) return; const pending = begun.turns[begun.index]; pending.replies = activeWindows.map((w) => ({ windowId: w.id, content: "", pending: true, })); const variant = pending.variants?.[pending.variantIndex]; if (variant) variant.replies = pending.replies; const priorTurns = begun.prior; // 重roll 的思维链是全新的,之前手动开合的记录作废 for (const key of [...thinkingToggles.keys()]) { if (key.startsWith(`${turnId}:`)) thinkingToggles.delete(key); } editingTurnId = null; editingDraft = ""; activeTurns = begun.turns; const total = activeWindows.length; let arrived = 0; let sawText = false; busy = true; els.btnSend.disabled = true; setStatus(total > 1 ? `重roll 0/${total}` : "重roll…"); renderArena(); const onDelta = (event) => { let reply = pending.replies.find((r) => r.windowId === event.windowId); if (!reply) { reply = { windowId: event.windowId, content: "", thinking: "" }; pending.replies.push(reply); } reply.pending = false; if (event.field === "thinking") { reply.thinking = `${reply.thinking || ""}${event.delta}`; } else { reply.content = `${reply.content || ""}${event.delta}`; } sawText = true; scheduleStreamingPaint(pending, event.windowId); rememberStreamingTurn(); }; const onWindow = (result) => { const idx = pending.replies.findIndex((r) => r.windowId === result.windowId); const reply = replyFromWindowResult(result); if (idx >= 0) pending.replies[idx] = reply; else pending.replies.push(reply); if (variant) variant.replies = pending.replies; if (reply.content || reply.thinking) sawText = true; arrived += 1; setStatus(total > 1 ? `重roll ${arrived}/${total}` : "重roll…"); rememberStreamingTurn(); renderArena(); }; const hooks = { onDelta, onWindow }; const note = later > 0 ? "之后的对话收在上一版,‹ › 可切回" : ""; try { if (activeIsTemp) { const temp = loadTemps().find((t) => t.id === activeConversationId); if (!temp) throw new Error("临时会话不存在"); const slots = { ...EMPTY_SLOTS, ...temp.slots }; const data = await postMulti( "/api/direct-chat/ephemeral/complete", { text: trimmed, ...slots, windows: activeWindows, turns: priorTurns, }, hooks, ); activeTurns = writeRerollReplies( activeTurns, turnId, data.multi?.turn?.replies || pending.replies, ); temp.turns = activeTurns.map(stripPendingTurn); temp.windows = activeWindows; temp.slots = slots; temp.updatedAt = nowIso(); upsertTemp(temp); const failed = (data.multi?.results || []).filter((r) => r.error); setStatus( failed.length ? `${failed.length} 个窗口失败,其余已写入` : note, failed.length > 0, ); renderArena(); renderRail(); return; } const data = await postMulti( `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}/turns/${encodeURIComponent(turnId)}/reroll`, { text: trimmed, windows: activeWindows }, hooks, ); activeTurns = data.conversation?.turns || activeTurns; activeWindows = data.conversation?.windows || activeWindows; if (data.shell) activeShell = data.shell; const failed = (data.multi?.results || []).filter((r) => r.error); setStatus( failed.length ? `${failed.length} 个窗口失败,其余已写入` : note, failed.length > 0, ); renderArena(); } catch (err) { if (!sawText && arrived === 0) { activeTurns = snapshot; if (activeIsTemp) { const temp = loadTemps().find((t) => t.id === activeConversationId); if (temp) { temp.turns = snapshot; temp.updatedAt = nowIso(); upsertTemp(temp); } } renderArena(); } else { for (const reply of pending.replies) { if (reply.pending) { reply.pending = false; reply.error = reply.error || "未返回"; } } if (variant) variant.replies = pending.replies; if (rememberTimer) { clearTimeout(rememberTimer); rememberTimer = 0; } writeStreamingTurn(); renderArena(); } setStatus(err instanceof Error ? err.message : String(err), true); } finally { busy = false; els.btnSend.disabled = false; renderArena(); } } /** 正在重试的窗口键 `turnId:windowId`;允许同一轮多张卡同时重试 */ const retryingWindows = new Set(); function retryKey(turnId, windowId) { return `${turnId}:${windowId}`; } /** 重试跟发送互斥,但重试之间不互斥 */ function syncComposerState() { els.btnSend.disabled = busy || retryingWindows.size > 0; } function saveActiveTemp(slots) { const temp = loadTemps().find((t) => t.id === activeConversationId); if (!temp) return; if (slots) temp.slots = slots; temp.turns = activeTurns.map(stripPendingTurn); temp.windows = activeWindows; temp.updatedAt = nowIso(); upsertTemp(temp); renderRail(); } async function retryReplies(turnId, windowIds) { if (busy || editingTurnId) return; const requested = (windowIds || []).filter(Boolean); if (!requested.length) return; if (navLevel !== "conversation" || !activeConversationId) { setStatus("请先打开一个会话", true); return; } const index = activeTurns.findIndex((t) => t.id === turnId); if (index < 0) return; const turn = activeTurns[index]; const text = String(turn.user?.content || "").trim(); if (!text) { setStatus("本轮缺少用户输入", true); return; } // 已经在跑的那几张跳过,其余照跑 —— 连续点多张卡就是并行 const ids = requested.filter( (id) => !retryingWindows.has(retryKey(turnId, id)), ); const targets = activeWindows.filter((w) => ids.includes(w.id)); if (!targets.length) return; const snapshot = cloneData(activeTurns); for (const reply of turn.replies || []) { if (!ids.includes(reply.windowId)) continue; delete reply.error; delete reply.content; delete reply.thinking; reply.pending = true; // 重试用的是全新回复,之前手动展开的记录作废 thinkingToggles.delete(cardExpandKey(turnId, reply.windowId)); } const priorTurns = activeTurns.slice(0, index); for (const id of ids) retryingWindows.add(retryKey(turnId, id)); syncComposerState(); const total = ids.length; let arrived = 0; const progress = () => total > 1 ? `重试中… ${arrived}/${total}` : "重试中…"; setStatus(progress()); renderArena(); // 服务端是并列跑的,好的先回来先刷这张卡 const onWindow = (result) => { const reply = replyFromWindowResult(result); activeTurns = mergeRepliesIntoTurn(activeTurns, turnId, [reply]); retryingWindows.delete(retryKey(turnId, reply.windowId)); arrived += 1; setStatus(progress()); if (activeIsTemp) saveActiveTemp(); renderArena(); }; try { if (activeIsTemp) { const temp = loadTemps().find((t) => t.id === activeConversationId); if (!temp) throw new Error("临时会话不存在"); const slots = { ...EMPTY_SLOTS, ...temp.slots }; const data = await postMulti( "/api/direct-chat/ephemeral/complete", { text, ...slots, windows: targets, turns: priorTurns, }, { onWindow }, ); const replies = (data.multi?.results || []).map(replyFromWindowResult); activeTurns = mergeRepliesIntoTurn(activeTurns, turnId, replies); saveActiveTemp(slots); } else { const data = await postMulti( `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}/turns/${encodeURIComponent(turnId)}/retry`, { windowIds: targets.map((w) => w.id) }, { onWindow }, ); activeTurns = data.conversation?.turns || activeTurns; } const stillFailed = ( activeTurns.find((t) => t.id === turnId)?.replies || [] ).filter((r) => ids.includes(r.windowId) && r.error); setStatus( stillFailed.length ? `${stillFailed.length} 个仍然失败` : "重试完成", stillFailed.length > 0, ); renderArena(); } catch (err) { // 只回滚本次涉及的窗口,别人正在跑的不管 const before = snapshot.find((t) => t.id === turnId)?.replies || []; for (const id of ids) { const old = before.find((r) => r.windowId === id); if (old) activeTurns = mergeRepliesIntoTurn(activeTurns, turnId, [old]); } renderArena(); setStatus(err instanceof Error ? err.message : String(err), true); } finally { for (const id of ids) retryingWindows.delete(retryKey(turnId, id)); syncComposerState(); renderArena(); } } async function switchVariant(turnId, direction) { if (busy || editingTurnId) return; const snapshot = cloneData(activeTurns); const next = switchTurnVariant(activeTurns, turnId, direction); if (!next) return; activeTurns = next; setStatus(""); renderArena(); if (activeIsTemp) { persistActiveSessionMeta(); renderRail(); return; } try { const data = await api( `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}/turns/${encodeURIComponent(turnId)}/variant`, { method: "POST", body: JSON.stringify({ direction }), }, ); activeTurns = data.conversation?.turns || activeTurns; renderArena(); } catch (err) { activeTurns = snapshot; renderArena(); setStatus(err instanceof Error ? err.message : String(err), true); } } async function selectReply(turnId, windowId) { const turn = activeTurns.find((t) => t.id === turnId); if (!turn) return; const previous = turn.selectedWindowId; const next = turn.selectedWindowId === windowId ? null : windowId; turn.selectedWindowId = next; activeTurns = activeTurns.map((t) => t.id === turnId ? { ...t, selectedWindowId: next } : t, ); // 计数器:保留 +1,取消保留 −1(同一张卡切换就是旧模型 −1、新模型 +1) const counted = activeWindows.find((w) => w.id === (next || previous)); if (counted && activeConversationId) { bumpKeepStats(activeConversationId, counted, next ? 1 : -1); } renderArena(); renderKeepStats(); if (activeIsTemp) { persistActiveSessionMeta(); setStatus( next ? `已保留「${publicWindowLabel(activeWindows.find((w) => w.id === next) || {}, turn)}」为后续共用基准` : "已取消保留,各窗口继续用各自回复", ); return; } try { const data = await api( `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}/turns/${encodeURIComponent(turnId)}/select`, { method: "POST", body: JSON.stringify({ windowId: next }), }, ); activeTurns = data.conversation.turns || activeTurns; renderArena(); setStatus( next ? `已保留「${publicWindowLabel(activeWindows.find((w) => w.id === next) || {}, turn)}」为后续共用基准` : "已取消保留,各窗口继续用各自回复", ); } catch (err) { setStatus(err instanceof Error ? err.message : String(err), true); } } function openAddWindowDialog() { const used = new Set(activeWindows.map((w) => w.profileId)); const available = profileList.filter((p) => p.hasKey); const fresh = available.filter((p) => !used.has(p.id)); if (!fresh.length) { setStatus( used.size ? "没有更多可用的 API 配置" : "请先在设置里配置带 Key 的 API", true, ); return; } els.addWindowModelsEmpty.hidden = true; els.addWindowModels.innerHTML = modelPickHtml(available, { lockedIds: used }); els.dialogAddWindow.showModal(); } els.formAddWindow.addEventListener("submit", (e) => { const submitter = e.submitter; if (submitter?.value === "cancel") return; e.preventDefault(); const ids = [ ...els.addWindowModels.querySelectorAll("input[name='model']:checked:not(:disabled)"), ].map((el) => el.value); if (!ids.length) { els.addWindowModelsEmpty.hidden = false; return; } const added = []; for (const id of ids) { const p = profileList.find((x) => x.id === id); if (!p || activeWindows.some((w) => w.profileId === p.id)) continue; added.push({ id: newId(), profileId: p.id, profileName: p.name, model: p.model, }); } if (!added.length) { els.addWindowModelsEmpty.hidden = false; return; } activeWindows = [...activeWindows, ...added]; persistActiveSessionMeta(); els.dialogAddWindow.close(); renderArena(); setStatus(added.length === 1 ? `已新增窗口:${added[0].profileName}` : `已新增 ${added.length} 个模型`); }); function removeWindow(windowId) { activeWindows = activeWindows.filter((w) => w.id !== windowId); activeTurns = activeTurns.map((t) => ({ ...t, replies: t.replies.filter((r) => r.windowId !== windowId), selectedWindowId: t.selectedWindowId === windowId ? null : t.selectedWindowId, })); for (const key of [...expandedCards]) { if (key.endsWith(`:${windowId}`)) expandedCards.delete(key); } for (const key of [...revealedCards]) { if (key.endsWith(`:${windowId}`)) revealedCards.delete(key); } for (const key of [...thinkingToggles.keys()]) { if (key.endsWith(`:${windowId}`)) thinkingToggles.delete(key); } persistActiveSessionMeta(); renderArena(); } els.formPromote.addEventListener("submit", async (e) => { const submitter = e.submitter; if (submitter?.value === "cancel") return; e.preventDefault(); if (!activeIsTemp || !activeConversationId) return; const temp = loadTemps().find((t) => t.id === activeConversationId); if (!temp) return; try { const data = await api("/api/direct-chat/promote", { method: "POST", body: JSON.stringify({ shell: { name: els.promoteShellName.value.trim() || "临时上下文", ...(temp.slots || EMPTY_SLOTS), }, conversationName: els.promoteConvName.value.trim() || "会话", windows: temp.windows || activeWindows, turns: temp.turns || activeTurns, }), }); deleteTemp(temp.id); els.dialogPromote.close(); await openConversation(data.conversation.id); // 计数跟着搬过去,别因为存成持久会话就丢了 copyKeepStats(temp.id, data.conversation.id); renderKeepStats(); setStatus("已保存为持久会话"); } catch (err) { setStatus(err instanceof Error ? err.message : String(err), true); } }); async function deleteRailItem(id, isTemp) { if (!window.confirm("确定删除?")) return; try { if (isTemp) { deleteTemp(id); clearKeepStats(id); if (activeConversationId === id) await goRoot(); else { renderRail(); renderPath(); } return; } if (navLevel === "root" || activeIsTemp) { await api(`/api/direct-chat/shells/${encodeURIComponent(id)}`, { method: "DELETE", }); if (activeShellId === id) await goRoot(); else await refreshShells().then(() => { renderRail(); }); return; } await api(`/api/direct-chat/conversations/${encodeURIComponent(id)}`, { method: "DELETE", }); clearKeepStats(id); if (activeConversationId === id) { if (activeShellId) await openShell(activeShellId); else await goRoot(); } else if (activeShellId) { await refreshConversations(activeShellId); renderRail(); } } catch (err) { setStatus(err instanceof Error ? err.message : String(err), true); } } // events els.btnNewShell.addEventListener("click", () => { createShellAndOpen().catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); }); els.btnNewConv.addEventListener("click", () => { if (busy) { setStatus("回复进行中,请稍后再新建", true); return; } openNewConvDialog(); }); els.btnClearShell?.addEventListener("click", () => { clearContextSlots().catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); }); els.formNewConv?.addEventListener("submit", (e) => { const submitter = e.submitter; if (submitter?.value === "cancel") return; e.preventDefault(); if (!activeShellId) return; const name = els.newConvName.value.trim() || "新会话"; const ids = [...els.newConvModels.querySelectorAll("input[name='model']:checked")].map( (el) => el.value, ); const windows = []; for (const id of ids) { const p = profileList.find((x) => x.id === id); if (!p) continue; windows.push({ id: newId(), profileId: p.id, profileName: p.name, model: p.model, }); } els.dialogNewConv.close(); createConversationUnderShell(name, windows).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); }); els.btnSaveShell.addEventListener("click", () => { saveShellEditor().catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); }); els.tabShell?.addEventListener("click", () => setContextTab("shell")); els.tabKeep?.addEventListener("click", () => setContextTab("keep")); els.btnClearKeep?.addEventListener("click", () => { if (!activeConversationId) return; if (!window.confirm("清空这个会话的保留统计?")) return; clearKeepStats(activeConversationId); renderKeepStats(); setStatus("已清空这个会话的保留统计"); }); function openPromoteDialog() { if (!activeIsTemp || !activeConversationId) return; const temp = loadTemps().find((t) => t.id === activeConversationId); if (!temp) return; els.promoteShellName.value = "临时上下文"; els.promoteConvName.value = temp.name || "会话"; els.dialogPromote.showModal(); } els.btnSaveTemp.addEventListener("click", () => openPromoteDialog()); els.btnSend.addEventListener("click", () => { sendMessage().catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); }); els.input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage().catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); } }); els.pathList.addEventListener("click", (e) => { const btn = e.target.closest("button[data-nav]"); if (!btn) return; const nav = btn.getAttribute("data-nav"); if (nav === "root") { goRoot().catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); } else if (nav === "shell" && activeShellId) { openShell(activeShellId).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); } }); els.railList.addEventListener("click", (e) => { const del = e.target.closest("[data-action='delete']"); const copy = e.target.closest("[data-action='copy']"); const open = e.target.closest("[data-action='open']"); const row = e.target.closest(".chat-rail-item"); if (!row) return; const id = row.getAttribute("data-id"); const isTemp = row.getAttribute("data-temp") === "1"; if (copy) { copyConversation(id, isTemp).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); return; } if (del) { deleteRailItem(id, isTemp).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); return; } if (!open) return; if (isTemp) { openTemp(id); return; } if (activeIsTemp || navLevel === "root") { openShell(id).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); return; } openConversation(id).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); }); els.btnHideLeft?.addEventListener("click", () => toggleRail("left")); els.btnShowLeft?.addEventListener("click", () => toggleRail("left")); els.btnHideRight?.addEventListener("click", () => toggleRail("right")); els.btnShowRight?.addEventListener("click", () => toggleRail("right")); els.morePanel?.addEventListener("click", (e) => { const btn = e.target.closest("[data-action]"); if (!btn) return; const action = btn.getAttribute("data-action"); els.more?.removeAttribute("open"); if (action === "add-window") { openAddWindowDialog(); return; } if (action === "save-temp") { openPromoteDialog(); return; } if (action === "copy-session" && activeConversationId) { copyConversation(activeConversationId, activeIsTemp).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); return; } if (action === "blind-models") { toggleBlindModels(); return; } if (action === "toggle-copy") { toggleCopyMode(); return; } if (action === "remove-window") { const id = btn.getAttribute("data-window-id"); if (id) removeWindow(id); } }); els.arena?.addEventListener("input", (e) => { const ta = e.target.closest?.(".chat-user-edit"); if (!(ta instanceof HTMLTextAreaElement)) return; editingDraft = ta.value; sizeUserEdit(ta); }); els.arena?.addEventListener("keydown", (e) => { const ta = e.target.closest?.(".chat-user-edit"); if (!(ta instanceof HTMLTextAreaElement)) return; if (e.key === "Escape") { e.preventDefault(); cancelEditTurn(); return; } if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); const turnId = ta.getAttribute("data-turn-id"); if (!turnId) return; const turn = activeTurns.find((t) => t.id === turnId); const next = ta.value.trim(); editingTurnId = null; editingDraft = ""; if (!next || next === (turn?.user?.content || "").trim()) { renderArena(); return; } rerollTurn(turnId, next).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); } }); els.arena?.addEventListener("click", (e) => { const editBtn = e.target.closest( "[data-action='edit-turn'], [data-action='edit-cancel'], [data-action='edit-save'], [data-action='reroll-turn'], [data-action='variant-prev'], [data-action='variant-next']", ); if (editBtn) { if (editBtn.disabled) return; const action = editBtn.getAttribute("data-action"); const turnId = editBtn.getAttribute("data-turn-id") || ""; if (action === "edit-turn") { startEditTurn(turnId); return; } if (action === "edit-cancel") { cancelEditTurn(); return; } if (action === "edit-save") { const ta = editBtn.closest(".chat-user-bubble")?.querySelector(".chat-user-edit"); const turn = activeTurns.find((t) => t.id === turnId); const next = (ta?.value ?? editingDraft).trim(); editingTurnId = null; editingDraft = ""; if (!next || next === (turn?.user?.content || "").trim()) { renderArena(); if (!next) setStatus("内容不能为空", true); return; } rerollTurn(turnId, next).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); return; } if (action === "reroll-turn") { const turn = activeTurns.find((t) => t.id === turnId); if (!turn) return; rerollTurn(turnId, turn.user?.content || "").catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); return; } if (action === "variant-prev" || action === "variant-next") { switchVariant( turnId, action === "variant-next" ? "next" : "prev", ).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); } return; } const retryBtn = e.target.closest( "[data-action='retry-reply'], [data-action='retry-failed']", ); if (retryBtn) { if (retryBtn.disabled) return; const turnId = retryBtn.getAttribute("data-turn-id") || ""; const windowIds = retryBtn.getAttribute("data-action") === "retry-failed" ? failedWindowIds(activeTurns.find((t) => t.id === turnId)) : [retryBtn.getAttribute("data-window-id") || ""]; retryReplies(turnId, windowIds).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); return; } if (e.target.closest("[data-action='add-window']")) { openAddWindowDialog(); return; } const thinkSummary = e.target.closest?.("summary.chat-think-summary"); if (thinkSummary) { const details = thinkSummary.closest("details.chat-think"); const card = thinkSummary.closest(".chat-reply-card"); if (!details || !card) return; e.preventDefault(); const open = !details.open; details.open = open; thinkingToggles.set( cardExpandKey( card.getAttribute("data-turn-id") || "", card.getAttribute("data-window-id") || "", ), open, ); return; } const expandBtn = e.target.closest("[data-action='expand-card']"); if (expandBtn) { const turnId = expandBtn.getAttribute("data-turn-id") || ""; const windowId = expandBtn.getAttribute("data-window-id") || ""; const key = cardExpandKey(turnId, windowId); if (expandedCards.has(key)) expandedCards.delete(key); else expandedCards.add(key); collapseReplyTracks(); return; } const stripBtn = e.target.closest( "[data-action='strip-prev'], [data-action='strip-next']", ); if (stripBtn) { const turnId = stripBtn.getAttribute("data-turn-id") || ""; const track = els.arena.querySelector( `.chat-reply-track[data-turn-id="${CSS.escape(turnId)}"]`, ); if (!track) return; const visible = visibleCardCount(track); const step = cardStep(track); const i = Math.round(track.scrollLeft / step); const lastPage = Math.max( 0, track.querySelectorAll(".chat-reply-card").length - visible, ); const nextIndex = stripBtn.getAttribute("data-action") === "strip-next" ? Math.min(lastPage, i + 1) : Math.max(0, i - 1); scrollTrackToIndex(track, nextIndex); return; } const copyBtn = copyMode ? e.target.closest("[data-action='copy-reply']") : null; if (copyBtn) { const turnId = copyBtn.getAttribute("data-turn-id") || ""; const windowId = copyBtn.getAttribute("data-window-id") || ""; copyReplyText(turnId, windowId, copyBtn).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); return; } const removeBtn = e.target.closest("[data-action='remove-window']"); if (removeBtn) { const id = removeBtn.getAttribute("data-window-id") || removeBtn.closest("[data-window-id]")?.getAttribute("data-window-id"); if (id) removeWindow(id); return; } const revealBtn = e.target.closest("[data-action='reveal-card']"); if (revealBtn) { const turnId = revealBtn.getAttribute("data-turn-id") || ""; const windowId = revealBtn.getAttribute("data-window-id") || ""; const key = cardExpandKey(turnId, windowId); const reveal = !revealedCards.has(key); if (reveal) revealedCards.add(key); else revealedCards.delete(key); applyCardReveal(turnId, windowId, reveal); return; } const selectBtn = e.target.closest("[data-action='select-reply']"); if (!selectBtn) return; const turnId = selectBtn.getAttribute("data-turn-id"); const windowId = selectBtn.getAttribute("data-window-id"); if (turnId && windowId) { selectReply(turnId, windowId).catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), ); } }); // 容器变高变矮后重新量一次折叠高度(上限跟着可见高度走) let relayoutTimer = 0;window.addEventListener( "resize", () => { window.clearTimeout(relayoutTimer); relayoutTimer = window.setTimeout(() => { if (navLevel === "conversation") collapseReplyTracks(); }, 120); }, { passive: true }, ); mountSeedEditor(); loadProfiles() .then(() => loadChatPrefs()) .then(() => { syncContextTabs(); }) .then(() => restoreOpenTarget()) .catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), );