import { renderMarkdownToHtml } from "./markdown.js"; const TEMP_STORAGE_KEY = "wa-direct-chat-temps-v2"; const MAX_TEMPS = 2; const EMPTY_SLOTS = { systemPrompt: "", loreBefore: "", seedHistory: "", loreAfter: "", postTurn: "", }; 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"), more: $("chat-more"), morePanel: $("chat-more-panel"), input: $("chat-input"), status: $("chat-status"), shellEditor: $("shell-editor"), contextSub: $("context-sub"), btnNewShell: $("btn-new-shell"), btnSaveTemp: $("btn-save-temp"), btnNewConv: $("btn-new-conv"), btnSend: $("btn-send"), btnSaveShell: $("btn-save-shell"), shellName: $("shell-name"), shellSystem: $("shell-system"), shellLoreBefore: $("shell-lore-before"), shellSeedHistory: $("shell-seed-history"), shellLoreAfter: $("shell-lore-after"), shellPostTurn: $("shell-post-turn"), dialogPromote: $("dialog-promote"), formPromote: $("form-promote"), promoteShellName: $("promote-shell-name"), promoteConvName: $("promote-conv-name"), dialogAddWindow: $("dialog-add-window"), formAddWindow: $("form-add-window"), addWindowProfile: $("add-window-profile"), }; /** @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; /** 盲测:藏起模型名,并用另一套左右顺序展示 */ let blindModels = false; /** @type {string[]} */ let blindOrder = []; /** @type {Set} */ const expandedCards = new Set(); const MIN_CARD_H = 120; 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 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: els.shellSeedHistory.value, loreAfter: els.shellLoreAfter.value, postTurn: els.shellPostTurn.value, }; } function fillEditor(shellLike) { const s = shellLike || { name: "", ...EMPTY_SLOTS }; els.shellName.value = s.name || ""; els.shellSystem.value = s.systemPrompt || ""; els.shellLoreBefore.value = s.loreBefore || ""; els.shellSeedHistory.value = s.seedHistory || ""; els.shellLoreAfter.value = s.loreAfter || ""; els.shellPostTurn.value = s.postTurn || ""; } function setContextEditorEnabled(enabled, subtitle) { const fields = [ els.shellName, els.shellSystem, els.shellLoreBefore, els.shellSeedHistory, els.shellLoreAfter, els.shellPostTurn, els.btnSaveShell, ]; for (const el of fields) el.disabled = !enabled; if (els.contextSub) { els.contextSub.textContent = subtitle || (enabled ? "改完点应用" : "选择或新建后编辑"); } } function syncContextPanel() { if (activeIsTemp) { fillEditor(activeShell || { name: "临时会话", ...EMPTY_SLOTS }); setContextEditorEnabled(true, "点应用后写入本机"); return; } if (activeShell) { fillEditor(activeShell); setContextEditorEnabled(true, "持久上下文 · 点应用写入本机"); return; } fillEditor({ name: "", ...EMPTY_SLOTS }); setContextEditorEnabled(false, "选择或新建后编辑"); } function windowLabel(w) { return w.profileName || w.model || w.profileId || "窗口"; } const BLIND_MARKS = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"]; 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); } function blindLabel(index) { const mark = BLIND_MARKS[index] || String(index + 1); return `模型${mark}`; } function publicWindowLabel(w) { if (!blindModels || activeWindows.length < 2) return windowLabel(w); const index = displayWindows().findIndex((item) => item.id === w.id); return index >= 0 ? blindLabel(index) : "该回复"; } function toggleBlindModels() { if (activeWindows.length < 2) { setStatus("至少两个模型才能打乱位置", true); return; } blindModels = !blindModels; blindOrder = blindModels ? shuffleIds(activeWindows.map((w) => w.id)) : []; setStatus(blindModels ? "已隐藏模型名并打乱位置" : "已恢复模型名和原位置"); 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 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, }), ); } } 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, }), ); } 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, }), ) .join(""); } function railItemHtml({ id, title, meta, temp, active }) { return `
    `; } 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( ``, ); } els.morePanel.innerHTML = items.join(""); } 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 ? `` : ""; return `
    ${escapeHtml(blindModels && multi ? blindLabel(displayIndex) : windowLabel(w))} ${blindModels && multi ? "" : `${escapeHtml(w.model || w.profileId || "")}`}
    ${remove}
    ${replyBodyHtml(body)}
    ${keep}
    `; } function replyBodyHtml(body) { if (body.empty) return escapeHtml(body.text); const thinkingHtml = body.thinking ? `
    ${renderMarkdownToHtml(body.thinking)}
    ` : ""; return `${thinkingHtml}${renderMarkdownToHtml(body.text)}`; } function renderTurnStrip(turn) { const multi = activeWindows.length > 1; const count = activeWindows.length; const cards = displayWindows() .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); return `
    ${escapeHtml(turn.user?.content || "")}
    ${time ? `` : ""}
    `; })() : ""; const stripBar = multi ? `
    左右切换,上下阅读
    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(); } 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")) return; dragging = true; axis = null; vTarget = null; startX = e.clientX; startY = e.clientY; startScroll = track.scrollLeft; }); track.addEventListener("selectstart", (e) => { if (axis) 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"); track.setPointerCapture(e.pointerId); 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; return; } if (vTarget) vTarget.scrollTop = startVScroll - dy; }); 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; e.preventDefault(); stream.scrollTop += e.deltaY; }, { 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 collapseReplyTracks() { if (!els.arena) return; 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 shortest = substantive.length ? Math.min(...substantive) : MIN_CARD_H; const target = Math.max(MIN_CARD_H, shortest); 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)) { card.style.height = "auto"; card.classList.remove("is-collapsed"); if (btn) { btn.hidden = false; btn.textContent = "收起"; } continue; } card.style.height = `${target}px`; card.classList.add("is-collapsed"); if (btn) { btn.hidden = !(h > target + 1); btn.textContent = "展开"; } } } } function renderArena() { if (!els.arena) return; 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"); const body = activeTurns.length ? activeTurns.map((turn) => renderTurnStrip(turn)).join("") : `

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

    ${renderTurnStrip(null)}`; els.arena.innerHTML = `
    ${body}
    `; 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"); 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() { 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 goRoot() { activeShellId = null; conversations = []; await refreshShells(); openDraftTemp(); renderPath(); renderRail(); } async function openShell(shellId) { activeIsTemp = false; activeShellId = shellId; activeConversationId = null; activeTurns = []; activeWindows = []; navLevel = "shell"; 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"; 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"; 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); } async function createConversationUnderShell() { if (!activeShellId) return; const name = window.prompt("会话名称", "新会话"); if (name == null) return; const data = await api( `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}/conversations`, { method: "POST", body: JSON.stringify({ name: name.trim() || "新会话", windows: activeWindows, turns: [], }), }, ); await openConversation(data.conversation.id); } 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); card.style.height = "auto"; card.classList.remove("is-collapsed"); 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 writeStreamingTurn() { if (!activeIsTemp || !activeConversationId) return; const temp = loadTemps().find((t) => t.id === activeConversationId); if (!temp) return; temp.turns = activeTurns.map((t) => ({ ...t, replies: (t.replies || []).map((r) => { const { pending: _pending, ...rest } = r; return rest; }), })); 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; } } async function selectReply(turnId, windowId) { const turn = activeTurns.find((t) => t.id === turnId); if (!turn) return; const next = turn.selectedWindowId === windowId ? null : windowId; turn.selectedWindowId = next; activeTurns = activeTurns.map((t) => t.id === turnId ? { ...t, selectedWindowId: next } : t, ); renderArena(); if (activeIsTemp) { persistActiveSessionMeta(); setStatus( next ? `已保留「${publicWindowLabel(activeWindows.find((w) => w.id === next) || {})}」为后续共用基准` : "已取消保留,各窗口继续用各自回复", ); 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) || {})}」为后续共用基准` : "已取消保留,各窗口继续用各自回复", ); } 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 && !used.has(p.id)); if (!available.length) { setStatus( used.size ? "没有更多可用的 API 配置" : "请先在设置里配置带 Key 的 API", true, ); return; } els.addWindowProfile.innerHTML = available .map( (p) => ``, ) .join(""); els.dialogAddWindow.showModal(); } els.formAddWindow.addEventListener("submit", (e) => { const submitter = e.submitter; if (submitter?.value === "cancel") return; e.preventDefault(); const profileId = els.addWindowProfile.value; const p = profileList.find((x) => x.id === profileId); if (!p) return; if (activeWindows.some((w) => w.profileId === profileId)) { setStatus("该配置已在窗口中", true); return; } activeWindows = [ ...activeWindows, { id: newId(), profileId: p.id, profileName: p.name, model: p.model, }, ]; persistActiveSessionMeta(); els.dialogAddWindow.close(); renderArena(); setStatus(`已新增窗口:${p.name}`); }); 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); } 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); 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); 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", }); 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", () => { createConversationUnderShell().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), ); }); 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 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 (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.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 === "blind-models") { toggleBlindModels(); return; } if (action === "remove-window") { const id = btn.getAttribute("data-window-id"); if (id) removeWindow(id); } }); els.arena?.addEventListener("click", (e) => { if (e.target.closest("[data-action='add-window']")) { openAddWindowDialog(); 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 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 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), ); } }); loadProfiles() .then(() => goRoot()) .catch((err) => setStatus(err instanceof Error ? err.message : String(err), true), );