一条一个角色下拉加一整句正文,换行原样保留;旧盘里的纯文本在读盘时迁移,切得出轮次的转成条目,切不出的挪进史前设定免得悄悄丢掉。历史编辑器做成共用控件,预设工作台的用例与假历史也用同一套。
3264 lines
104 KiB
JavaScript
3264 lines
104 KiB
JavaScript
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function slotsFromEditor() {
|
||
return {
|
||
systemPrompt: els.shellSystem.value,
|
||
loreBefore: els.shellLoreBefore.value,
|
||
seedHistory: seedLegacyText,
|
||
seedMessages: seedTurns.map((turn) => ({ role: turn.role, content: turn.content })),
|
||
loreAfter: els.shellLoreAfter.value,
|
||
postTurn: els.shellPostTurn.value,
|
||
charName: els.shellCharName.value,
|
||
charDescription: els.shellCharDesc.value,
|
||
charPersonality: els.shellCharPers.value,
|
||
scenario: els.shellScenario.value,
|
||
exampleDialogue: els.shellExamples.value,
|
||
};
|
||
}
|
||
|
||
function mountSeedEditor() {
|
||
if (!els.shellSeedHistory || seedEditor) return;
|
||
seedEditor = createHistoryEditor({
|
||
items: seedTurns,
|
||
disabled: true,
|
||
addLabel: "+ 加一句",
|
||
emptyHint: "空则不占历史;点「+ 加一句」开始写。",
|
||
});
|
||
els.shellSeedHistory.replaceChildren(seedEditor.el);
|
||
}
|
||
|
||
function fillSeedTurns(value, legacyText) {
|
||
seedLegacyText = typeof legacyText === "string" ? legacyText : "";
|
||
const turns = normalizeHistoryTurns(value);
|
||
seedTurns.splice(0, seedTurns.length, ...turns);
|
||
if (seedEditor) seedEditor.redraw();
|
||
}
|
||
|
||
function fillEditor(shellLike) {
|
||
const s = shellLike || { name: "", ...EMPTY_SLOTS };
|
||
els.shellName.value = s.name || "";
|
||
els.shellSystem.value = s.systemPrompt || "";
|
||
els.shellLoreBefore.value = s.loreBefore || "";
|
||
fillSeedTurns(s.seedMessages, s.seedHistory);
|
||
els.shellLoreAfter.value = s.loreAfter || "";
|
||
els.shellPostTurn.value = s.postTurn || "";
|
||
els.shellCharName.value = s.charName || "";
|
||
els.shellCharDesc.value = s.charDescription || "";
|
||
els.shellCharPers.value = s.charPersonality || "";
|
||
els.shellScenario.value = s.scenario || "";
|
||
els.shellExamples.value = s.exampleDialogue || "";
|
||
}
|
||
|
||
function setContextEditorEnabled(enabled, subtitle) {
|
||
const fields = [
|
||
els.shellName,
|
||
els.shellSystem,
|
||
els.shellLoreBefore,
|
||
els.shellLoreAfter,
|
||
els.shellPostTurn,
|
||
els.shellCharName,
|
||
els.shellCharDesc,
|
||
els.shellCharPers,
|
||
els.shellScenario,
|
||
els.shellExamples,
|
||
els.btnSaveShell,
|
||
els.btnClearShell,
|
||
];
|
||
for (const el of fields) el.disabled = !enabled;
|
||
if (seedEditor) seedEditor.setDisabled(!enabled);
|
||
if (els.contextSub) {
|
||
els.contextSub.textContent =
|
||
subtitle || (enabled ? "改完点应用" : "选择或新建后编辑");
|
||
}
|
||
}
|
||
|
||
function syncContextPanel() {
|
||
if (activeIsTemp) {
|
||
fillEditor(activeShell || { name: "临时会话", ...EMPTY_SLOTS });
|
||
setContextEditorEnabled(true, "点应用后写入本机");
|
||
renderKeepStats();
|
||
return;
|
||
}
|
||
if (activeShell) {
|
||
fillEditor(activeShell);
|
||
setContextEditorEnabled(true, "持久上下文 · 点应用写入本机");
|
||
renderKeepStats();
|
||
return;
|
||
}
|
||
fillEditor({ name: "", ...EMPTY_SLOTS });
|
||
setContextEditorEnabled(false, "选择或新建后编辑");
|
||
renderKeepStats();
|
||
}
|
||
|
||
/** 保留计数:按次数降序的条状图,只算当前会话 */
|
||
function renderKeepStats() {
|
||
if (!els.keepList) return;
|
||
const conversationId = activeConversationId || "";
|
||
const entries = readKeepStats(conversationId);
|
||
const total = keepStatTotal(entries);
|
||
const max = entries.length ? entries[0].count : 0;
|
||
els.keepList.innerHTML = entries.length
|
||
? entries
|
||
.map(
|
||
(entry) => `<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),
|
||
);
|