Files
writing-agent/web/chat.js
chenmoranzhi 98e51f9c6c 直聊的种子历史改成逐条真多轮,不再靠块头文本猜轮次。
一条一个角色下拉加一整句正文,换行原样保留;旧盘里的纯文本在读盘时迁移,切得出轮次的转成条目,切不出的挪进史前设定免得悄悄丢掉。历史编辑器做成共用控件,预设工作台的用例与假历史也用同一套。
2026-10-26 01:09:34 +08:00

3264 lines
104 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof createHistoryEditor> | 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<string>} */
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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) => `<div class="chat-keep-bar">
<div class="chat-keep-bar-head">
<span class="chat-keep-bar-name" title="${escapeHtml(entry.label)}">${escapeHtml(entry.label)}</span>
<span class="chat-keep-bar-count">${entry.count} 次</span>
</div>
<div class="chat-keep-bar-track"><div class="chat-keep-bar-fill" style="width:${keepStatPercent(entry.count, max)}%"></div></div>
${entry.model ? `<p class="chat-keep-bar-model">${escapeHtml(entry.model)}</p>` : ""}
</div>`,
)
.join("")
: `<p class="chat-arena-idle">${
conversationId
? "这个会话还没保留过回复。卡片上点「保留此回复」就会开始计数。"
: "打开一个会话后,这里按次数显示各模型被保留了多少次。"
}</p>`;
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
? `<li class="path-bar-item"><span class="path-bar-current">直聊</span></li>`
: `<li class="path-bar-item"><button type="button" data-nav="root">直聊</button></li>`,
);
if (navLevel === "shell" || (navLevel === "conversation" && !activeIsTemp)) {
const shellLabel = activeShell?.name || "上下文";
if (navLevel === "shell") {
items.push(
`<li class="path-bar-item"><span class="path-bar-current">${escapeHtml(shellLabel)}</span></li>`,
);
} else {
items.push(
`<li class="path-bar-item"><button type="button" data-nav="shell">${escapeHtml(shellLabel)}</button></li>`,
);
const convLabel =
conversations.find((c) => c.id === activeConversationId)?.name || "会话";
items.push(
`<li class="path-bar-item"><span class="path-bar-current">${escapeHtml(convLabel)}</span></li>`,
);
}
} else if (activeIsTemp) {
items.push(
`<li class="path-bar-item"><span class="path-bar-current">临时</span></li>`,
);
}
els.pathList.innerHTML = items.join("");
}
function renderRootRail() {
const temps = loadTemps();
const parts = [];
if (temps.length) {
parts.push(`<p class="chat-rail-item-meta">快速对话(浏览器)</p>`);
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(`<p class="chat-rail-item-meta">已保存上下文</p>`);
if (!shells.length) {
parts.push(`<p class="chat-rail-item-meta">还没有上下文壳</p>`);
}
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(`<p class="chat-rail-item-meta">还没有会话</p>`);
}
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
? `<button type="button" class="chat-rail-item-copy" data-action="copy" title="复制会话" aria-label="复制会话">复制</button>`
: "";
return `<div class="chat-rail-item ${active ? "active" : ""}" data-id="${escapeHtml(id)}" data-temp="${temp ? "1" : "0"}">
<button type="button" class="chat-rail-item-main" data-action="open">
<span class="chat-rail-item-title">${escapeHtml(title)}</span>
<span class="chat-rail-item-meta">${escapeHtml(meta)}</span>
</button>
<div class="chat-rail-item-actions">
${copy}
<button type="button" class="chat-rail-item-del" data-action="delete" title="删除" aria-label="删除">×</button>
</div>
</div>`;
}
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 = [
`<button type="button" class="chat-more-item" data-action="add-window" role="menuitem">增加模型</button>`,
];
if (only) {
items.push(
`<button type="button" class="chat-more-item" data-action="remove-window" data-window-id="${escapeHtml(only.id)}" role="menuitem">移除此模型</button>`,
);
}
if (activeWindows.length > 1) {
items.push(
`<button type="button" class="chat-more-item" data-action="blind-models" role="menuitem">${modelNamesShown() ? "隐藏模型名并打乱位置" : "显示模型名"}</button>`,
);
}
if (activeIsTemp) {
items.push(
`<button type="button" class="chat-more-item" data-action="save-temp" role="menuitem">保存为持久会话</button>`,
);
}
items.push(
`<button type="button" class="chat-more-item" data-action="toggle-copy" role="menuitem" aria-pressed="${copyMode}">${copyMode ? "关闭复制(卡片任意处可拖动)" : "开启复制"}</button>`,
`<button type="button" class="chat-more-item" data-action="copy-session" role="menuitem">复制会话</button>`,
);
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
? `<span class="chat-turn-variants" title="‹ › 切换版本">
<button type="button" class="chat-nav-btn" data-action="variant-prev" data-turn-id="${escapeHtml(turn.id)}"${index <= 1 || busy ? " disabled" : ""} aria-label="上一版">‹</button>
<span class="chat-turn-variant-count">${index}/${total}</span>
<button type="button" class="chat-nav-btn" data-action="variant-next" data-turn-id="${escapeHtml(turn.id)}"${index >= total || busy ? " disabled" : ""} aria-label="下一版">›</button>
</span>`
: "";
return `<div class="chat-user-foot">
${pager}
<button type="button" class="chat-user-act" data-action="edit-turn" data-turn-id="${escapeHtml(turn.id)}"${busy ? " disabled" : ""} title="编辑原对话并重roll">编辑</button>
<button type="button" class="chat-user-act" data-action="reroll-turn" data-turn-id="${escapeHtml(turn.id)}"${busy ? " disabled" : ""} title="直接重roll" aria-label="重roll">↻</button>
</div>`;
}
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
? `<button type="button" class="chat-select-btn ${selected ? "is-on" : ""}" data-action="select-reply" data-turn-id="${escapeHtml(turn.id)}" data-window-id="${escapeHtml(w.id)}">${selected ? "已保留 · 取消" : "保留此回复"}</button>`
: turn && selected
? `<span class="chat-select-btn is-on">已保留</span>`
: "";
const remove =
activeWindows.length > 1
? `<button type="button" class="chat-remove-btn" data-action="remove-window" data-window-id="${escapeHtml(w.id)}" title="移除此模型">移除</button>`
: "";
const copy =
copyMode && turn && !body.empty
? `<button type="button" class="chat-copy-btn" data-action="copy-reply" data-turn-id="${escapeHtml(turn.id)}" data-window-id="${escapeHtml(w.id)}" title="复制这条回复的原文">复制</button>`
: "";
const retrying = Boolean(turn) && retryingWindows.has(retryKey(turn.id, w.id));
const actions =
copy || remove
? `<div class="chat-reply-card-actions">${copy}${remove}</div>`
: "";
// 重新生成:成功也能再跑一次,就放在「保留」旁边
const regen = turn
? retrying
? `<button type="button" class="chat-regen-btn" data-action="retry-reply" data-turn-id="${escapeHtml(turn.id)}" data-window-id="${escapeHtml(w.id)}" disabled title="正在重新生成">生成中…</button>`
: body.empty
? ""
: `<button type="button" class="chat-regen-btn${body.cls === "is-error" ? " chat-retry-btn" : ""}" data-action="retry-reply" data-turn-id="${escapeHtml(turn.id)}" data-window-id="${escapeHtml(w.id)}"${busy ? " disabled" : ""} title="只重跑这一个模型,其它回复不动;可以多张卡同时重试">重新生成</button>`
: "";
const blind = blindModels && !blindNamesShown && multi;
const revealed = blind && revealedCards.has(cardExpandKey(turnId, w.id));
const titles = blind
? `<div class="chat-reply-card-titles">
<button type="button" class="chat-reply-card-name is-blind${revealed ? " is-revealed" : ""}" data-action="reveal-card" data-turn-id="${escapeHtml(turnId)}" data-window-id="${escapeHtml(w.id)}" aria-pressed="${revealed ? "true" : "false"}" title="${revealed ? "点击重新隐藏这一个模型名" : "点击揭示这一个回答的模型名"}">${escapeHtml(blindLabel(displayIndex))}</button>
${revealed ? `<span class="chat-reply-card-model is-revealed">${escapeHtml(revealedNameText(w))}</span>` : ""}
</div>`
: `<div class="chat-reply-card-titles">
<span class="chat-reply-card-name">${escapeHtml(windowLabel(w))}</span>
<span class="chat-reply-card-model">${escapeHtml(w.model || w.profileId || "")}</span>
</div>`;
return `<article class="chat-reply-card ${selected ? "is-selected" : ""} ${body.cls === "is-error" ? "is-error" : ""}" data-window-id="${escapeHtml(w.id)}" data-turn-id="${escapeHtml(turnId)}">
<header class="chat-reply-card-head">
${titles}
${actions}
</header>
<div class="chat-reply-card-body ${body.cls}" data-empty="${body.empty ? "1" : "0"}">
<div class="chat-reply-card-text">${replyBodyHtml(body, cardExpandKey(turnId, w.id))}</div>
</div>
<div class="chat-reply-card-foot">
<button type="button" class="chat-expand-btn" data-action="expand-card" data-turn-id="${escapeHtml(turnId)}" data-window-id="${escapeHtml(w.id)}" hidden>展开</button>
${regen}
${keep}
</div>
</article>`;
}
function replyBodyHtml(body, key = "") {
if (body.empty) return escapeHtml(body.text);
const thinking = String(body.thinking ?? "").trim();
const thinkingHtml = thinking
? `<details class="chat-think"${thinkingOpenFor(key, body) ? " open" : ""}>
<summary class="chat-think-summary">${escapeHtml(thinkingSummaryText(thinking))}</summary>
<div class="chat-think-body">${renderMarkdownToHtml(thinking)}</div>
</details>`
: "";
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
? `<textarea class="chat-user-edit" data-turn-id="${escapeHtml(turn.id)}" rows="3" aria-label="编辑原对话">${escapeHtml(editingDraft)}</textarea>
<div class="chat-user-edit-bar">
<button type="button" class="chat-user-act" data-action="edit-cancel" data-turn-id="${escapeHtml(turn.id)}">取消</button>
<button type="button" class="chat-user-act is-primary" data-action="edit-save" data-turn-id="${escapeHtml(turn.id)}">保存并重roll</button>
</div>
<p class="chat-user-edit-hint">Ctrl/⌘ + Enter 保存 · Esc 取消</p>`
: `<div class="chat-user-body">${escapeHtml(turn.user?.content || "")}</div>
${renderUserActions(turn)}
${time ? `<time class="chat-bubble-time">${escapeHtml(time)}</time>` : ""}`;
return `<div class="chat-user-row">
<article class="chat-user-bubble${editing ? " is-editing" : ""}" data-turn-id="${escapeHtml(turn.id)}">
${body}
</article>
</div>`;
})()
: "";
const stripBar = multi && !keptId
? `<div class="chat-strip-bar">
<span class="chat-strip-hint">左右切换,上下阅读</span>
${failedWindowIds(turn).length > 1 ? `<button type="button" class="chat-retry-all" data-action="retry-failed" data-turn-id="${escapeHtml(turnId)}"${busy ? " disabled" : ""} title="把所有失败的模型重跑一遍,已成功的保持不动">全部重试</button>` : ""}
<div class="chat-strip-nav">
<button type="button" class="chat-nav-btn" data-action="strip-prev" data-turn-id="${escapeHtml(turnId)}" aria-label="上一组">‹</button>
<span class="chat-strip-idx" data-strip-idx="${escapeHtml(turnId)}">1 / ${count}</span>
<button type="button" class="chat-nav-btn" data-action="strip-next" data-turn-id="${escapeHtml(turnId)}" aria-label="下一组">›</button>
</div>
</div>`
: "";
return `<section class="chat-turn" data-turn-id="${escapeHtml(turnId)}">
${userBlock}
${stripBar}
<div class="chat-reply-track" data-turn-id="${escapeHtml(turnId)}" data-count="${count}" tabindex="0">${cards}</div>
</section>`;
}
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 = `<button type="button" class="chat-add-card" data-action="add-window" aria-label="增加模型">+</button>`;
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("")
: `<p class="chat-arena-idle">在下方输入,各模型会同时回复同一条消息。</p>${renderTurnStrip(null)}`;
els.arena.innerHTML = `<div class="chat-arena-stream">${body}</div>`;
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 =
`<div class="chat-arena-empty">尚未打开会话。从左侧选会话,或点「新建会话」。</div>`;
}
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 `<label>
<input type="checkbox" name="model" value="${escapeHtml(p.id)}" ${isChecked ? "checked" : ""} ${isLocked ? "disabled" : ""} />
<span>${escapeHtml(p.name)}${model}</span>
</label>`;
})
.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),
);