1 / ${count}
@@ -669,6 +1319,34 @@ function clearTextSelection() {
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;
@@ -679,7 +1357,13 @@ function bindReplyTrack(track) {
let startVScroll = 0;
track.addEventListener("pointerdown", (e) => {
- if (e.button !== 0 || e.target.closest("button")) return;
+ 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;
@@ -689,7 +1373,7 @@ function bindReplyTrack(track) {
});
track.addEventListener("selectstart", (e) => {
- if (axis) e.preventDefault();
+ if (axis || !copyMode) e.preventDefault();
});
track.addEventListener("pointermove", (e) => {
@@ -700,7 +1384,11 @@ function bindReplyTrack(track) {
if (Math.abs(dx) < DRAG_SLOP && Math.abs(dy) < DRAG_SLOP) return;
axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
track.classList.add("is-dragging");
- track.setPointerCapture(e.pointerId);
+ try {
+ track.setPointerCapture(e.pointerId);
+ } catch {
+ /* 指针已经不在或不可捕获时忽略,拖动靠 pointermove 也能继续 */
+ }
if (e.cancelable) e.preventDefault();
clearTextSelection();
if (axis === "y") {
@@ -713,10 +1401,10 @@ function bindReplyTrack(track) {
}
if (e.cancelable) e.preventDefault();
if (axis === "x") {
- track.scrollLeft = startScroll - dx;
+ track.scrollLeft = startScroll - dx * dragGain;
return;
}
- if (vTarget) vTarget.scrollTop = startVScroll - dy;
+ if (vTarget) vTarget.scrollTop = startVScroll - dy * dragGain;
});
const end = () => {
@@ -734,8 +1422,28 @@ function bindReplyTrack(track) {
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();
- stream.scrollTop += e.deltaY;
+ if (plan.cardDelta && text) {
+ text.scrollTop += plan.cardDelta;
+ flashScrollBar(text);
+ }
+ if (plan.streamDelta) {
+ stream.scrollTop += plan.streamDelta;
+ flashScrollBar(stream);
+ }
},
{ passive: false },
);
@@ -762,8 +1470,21 @@ function bindReplyTrack(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) {
@@ -781,15 +1502,17 @@ function collapseReplyTracks() {
return { card, h: card.offsetHeight, empty };
});
const substantive = measured.filter((item) => !item.empty).map((item) => item.h);
- const shortest = substantive.length ? Math.min(...substantive) : MIN_CARD_H;
- const target = Math.max(MIN_CARD_H, shortest);
+ 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)) {
- card.style.height = "auto";
+ // 展开也只是比折叠高一点,超过一屏就封顶,剩下的内部滚
+ 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;
@@ -798,6 +1521,7 @@ function collapseReplyTracks() {
continue;
}
card.style.height = `${target}px`;
+ card.classList.remove("is-capped");
card.classList.add("is-collapsed");
if (btn) {
btn.hidden = !(h > target + 1);
@@ -805,10 +1529,12 @@ function collapseReplyTracks() {
}
}
}
+ 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);
@@ -821,12 +1547,25 @@ function renderArena() {
return;
}
els.arena.classList.remove("is-empty");
+ els.arena.classList.toggle("is-copy-off", !copyMode);
const body = activeTurns.length
? activeTurns.map((turn) => renderTurnStrip(turn)).join("")
: `
在下方输入,各模型会同时回复同一条消息。
${renderTurnStrip(null)}`;
els.arena.innerHTML = `
${body}
`;
+ const stream = els.arena.querySelector(".chat-arena-stream");
+ if (stream) {
+ // 指针不在卡片上时(行间空隙、提示文字)浏览器自己滚,这里只把滚动条露一下
+ stream.addEventListener(
+ "wheel",
+ (e) => {
+ if (e.defaultPrevented || !Math.abs(e.deltaY)) return;
+ flashScrollBar(stream);
+ },
+ { passive: true },
+ );
+ }
for (const track of els.arena.querySelectorAll(".chat-reply-track")) {
bindReplyTrack(track);
}
@@ -841,7 +1580,18 @@ function renderArena() {
updateStripIdx(track);
}
const stream = els.arena.querySelector(".chat-arena-stream");
- if (stream) stream.scrollTop = stream.scrollHeight;
+ 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));
@@ -885,6 +1635,7 @@ function openDraftTemp() {
}
function syncRoomChrome() {
+ syncRails();
els.btnNewConv.hidden = !(
navLevel === "shell" ||
(navLevel === "conversation" && !activeIsTemp)
@@ -945,9 +1696,23 @@ async function loadProfiles() {
}
}
+/** 设置页里的直聊偏好(目前只有拖动倍率);取不到就用默认值 */
+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();
@@ -961,6 +1726,7 @@ async function openShell(shellId) {
activeTurns = [];
activeWindows = [];
navLevel = "shell";
+ rememberOpenConversation(null);
await refreshConversations(shellId);
renderPath();
renderRail();
@@ -978,6 +1744,7 @@ async function openConversation(conversationId) {
activeWindows = data.conversation.windows || [];
activeTurns = data.conversation.turns || [];
navLevel = "conversation";
+ rememberOpenConversation(conversationId);
if (activeShellId) {
const shellData = await api(
`/api/direct-chat/shells/${encodeURIComponent(activeShellId)}`,
@@ -1000,6 +1767,7 @@ function openTemp(tempId) {
activeWindows = temp.windows || [];
activeTurns = temp.turns || [];
navLevel = "conversation";
+ rememberOpenConversation(null);
renderPath();
renderRail();
syncRoomChrome();
@@ -1016,22 +1784,198 @@ async function createShellAndOpen() {
await openShell(data.shell.id);
}
-async function createConversationUnderShell() {
+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() &&
+ !els.shellSeedHistory.value.trim() &&
+ !els.shellLoreAfter.value.trim() &&
+ !els.shellPostTurn.value.trim()
+ );
+}
+
+async function clearContextSlots() {
+ if (!els.btnClearShell || els.btnClearShell.disabled) return;
+ if (busy) {
+ setStatus("回复进行中,请稍后再清空", true);
+ return;
+ }
+ if (contextFieldsEmpty()) {
+ setStatus("上下文已经是空的,已选模型仍保留");
+ return;
+ }
+ const shared =
+ !activeIsTemp && conversations.length > 1
+ ? `此上下文下的 ${conversations.length} 个会话都会改用清空后的设定。`
+ : "";
+ if (
+ !window.confirm(
+ `清空系统提示和设定?名称会保留,已选模型也不会动。${shared}`,
+ )
+ ) {
+ return;
+ }
+ if (activeIsTemp && activeConversationId) {
+ const temp = loadTemps().find((t) => t.id === activeConversationId);
+ if (!temp) return;
+ temp.slots = { ...EMPTY_SLOTS };
+ temp.windows = activeWindows;
+ temp.turns = activeTurns;
+ temp.updatedAt = nowIso();
+ upsertTemp(temp);
+ activeShell = {
+ name: els.shellName.value || temp.name || "临时会话",
+ ...EMPTY_SLOTS,
+ };
+ fillEditor(activeShell);
+ if (els.contextSub) els.contextSub.textContent = "已清空,模型仍保留";
+ setStatus("已清空上下文,模型仍保留");
+ return;
+ }
+ if (!activeShellId) return;
+ const name = activeShell?.name || els.shellName.value.trim() || "未命名上下文";
+ const data = await api(
+ `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({ name, ...EMPTY_SLOTS }),
+ },
+ );
+ activeShell = data.shell;
+ fillEditor(activeShell);
+ await refreshShells();
+ renderPath();
+ renderRail();
+ if (els.contextSub) els.contextSub.textContent = "已清空,模型仍保留";
+ setStatus("已清空上下文,模型仍保留");
+}
+
+function modelPickHtml(profiles, { checkedIds, lockedIds } = {}) {
+ const checked = checkedIds || new Set();
+ const locked = lockedIds || new Set();
+ return profiles
+ .map((p) => {
+ const isLocked = locked.has(p.id);
+ const isChecked = isLocked || checked.has(p.id);
+ const model = p.model ? ` · ${escapeHtml(p.model)}` : "";
+ return `
`;
+ })
+ .join("");
+}
+
+function openNewConvDialog() {
+ if (!activeShellId || !els.dialogNewConv) return;
+ els.newConvName.value = "新会话";
+ const selected = new Set(activeWindows.map((w) => w.profileId));
+ const available = profileList.filter((p) => p.hasKey);
+ if (!available.length) {
+ els.newConvModels.innerHTML = "";
+ els.newConvModelsEmpty.hidden = false;
+ } else {
+ els.newConvModelsEmpty.hidden = true;
+ els.newConvModels.innerHTML = modelPickHtml(available, { checkedIds: selected });
+ }
+ els.dialogNewConv.showModal();
+}
+
+async function createConversationUnderShell(name, windows) {
if (!activeShellId) return;
- const name = window.prompt("会话名称", "新会话");
- if (name == null) return;
const data = await api(
`/api/direct-chat/shells/${encodeURIComponent(activeShellId)}/conversations`,
{
method: "POST",
body: JSON.stringify({
name: name.trim() || "新会话",
- windows: activeWindows,
+ windows,
turns: [],
}),
},
);
await openConversation(data.conversation.id);
+ setStatus(
+ windows.length ? `已新建会话,${windows.length} 个模型` : "已新建会话",
+ );
}
async function saveShellEditor() {
@@ -1128,8 +2072,8 @@ async function readNdjson(res, onEvent) {
}
async function postMulti(path, body, hooks) {
- const onWindow = hooks.onWindow || (() => {});
- const onDelta = hooks.onDelta || (() => {});
+ const onWindow = hooks?.onWindow || (() => {});
+ const onDelta = hooks?.onDelta || (() => {});
const res = await fetch(path, {
method: "POST",
headers: {
@@ -1184,9 +2128,9 @@ function paintStreamingReply(turn, windowId) {
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);
+ textEl.innerHTML = replyBodyHtml(body, cardExpandKey(turn.id, windowId));
card.style.height = "auto";
- card.classList.remove("is-collapsed");
+ 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;
@@ -1205,17 +2149,30 @@ function scheduleStreamingPaint(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((t) => ({
- ...t,
- replies: (t.replies || []).map((r) => {
- const { pending: _pending, ...rest } = r;
- return rest;
- }),
- }));
+ temp.turns = activeTurns.map(stripPendingTurn);
temp.windows = activeWindows;
temp.updatedAt = nowIso();
upsertTemp(temp);
@@ -1357,25 +2314,374 @@ async function sendMessage() {
} 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) || {})}」为后续共用基准`
+ ? `已保留「${publicWindowLabel(activeWindows.find((w) => w.id === next) || {}, turn)}」为后续共用基准`
: "已取消保留,各窗口继续用各自回复",
);
return;
@@ -1392,7 +2698,7 @@ async function selectReply(turnId, windowId) {
renderArena();
setStatus(
next
- ? `已保留「${publicWindowLabel(activeWindows.find((w) => w.id === next) || {})}」为后续共用基准`
+ ? `已保留「${publicWindowLabel(activeWindows.find((w) => w.id === next) || {}, turn)}」为后续共用基准`
: "已取消保留,各窗口继续用各自回复",
);
} catch (err) {
@@ -1402,8 +2708,9 @@ async function selectReply(turnId, windowId) {
function openAddWindowDialog() {
const used = new Set(activeWindows.map((w) => w.profileId));
- const available = profileList.filter((p) => p.hasKey && !used.has(p.id));
- if (!available.length) {
+ const available = profileList.filter((p) => p.hasKey);
+ const fresh = available.filter((p) => !used.has(p.id));
+ if (!fresh.length) {
setStatus(
used.size
? "没有更多可用的 API 配置"
@@ -1412,12 +2719,8 @@ function openAddWindowDialog() {
);
return;
}
- els.addWindowProfile.innerHTML = available
- .map(
- (p) =>
- `
`,
- )
- .join("");
+ els.addWindowModelsEmpty.hidden = true;
+ els.addWindowModels.innerHTML = modelPickHtml(available, { lockedIds: used });
els.dialogAddWindow.showModal();
}
@@ -1425,26 +2728,33 @@ els.formAddWindow.addEventListener("submit", (e) => {
const submitter = e.submitter;
if (submitter?.value === "cancel") return;
e.preventDefault();
- const profileId = els.addWindowProfile.value;
- const p = profileList.find((x) => x.id === profileId);
- if (!p) return;
- if (activeWindows.some((w) => w.profileId === profileId)) {
- setStatus("该配置已在窗口中", true);
+ const ids = [
+ ...els.addWindowModels.querySelectorAll("input[name='model']:checked:not(:disabled)"),
+ ].map((el) => el.value);
+ if (!ids.length) {
+ els.addWindowModelsEmpty.hidden = false;
return;
}
- activeWindows = [
- ...activeWindows,
- {
+ 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(`已新增窗口:${p.name}`);
+ setStatus(added.length === 1 ? `已新增窗口:${added[0].profileName}` : `已新增 ${added.length} 个模型`);
});
function removeWindow(windowId) {
@@ -1458,6 +2768,12 @@ function removeWindow(windowId) {
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();
}
@@ -1485,6 +2801,9 @@ els.formPromote.addEventListener("submit", async (e) => {
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);
@@ -1496,6 +2815,7 @@ async function deleteRailItem(id, isTemp) {
try {
if (isTemp) {
deleteTemp(id);
+ clearKeepStats(id);
if (activeConversationId === id) await goRoot();
else {
renderRail();
@@ -1517,6 +2837,7 @@ async function deleteRailItem(id, isTemp) {
await api(`/api/direct-chat/conversations/${encodeURIComponent(id)}`, {
method: "DELETE",
});
+ clearKeepStats(id);
if (activeConversationId === id) {
if (activeShellId) await openShell(activeShellId);
else await goRoot();
@@ -1536,7 +2857,39 @@ els.btnNewShell.addEventListener("click", () => {
);
});
els.btnNewConv.addEventListener("click", () => {
- createConversationUnderShell().catch((err) =>
+ 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),
);
});
@@ -1545,6 +2898,15 @@ els.btnSaveShell.addEventListener("click", () => {
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);
@@ -1586,11 +2948,18 @@ els.pathList.addEventListener("click", (e) => {
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),
@@ -1613,6 +2982,11 @@ els.railList.addEventListener("click", (e) => {
);
});
+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;
@@ -1626,21 +3000,145 @@ els.morePanel?.addEventListener("click", (e) => {
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") || "";
@@ -1674,6 +3172,15 @@ els.arena?.addEventListener("click", (e) => {
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 =
@@ -1682,6 +3189,17 @@ els.arena?.addEventListener("click", (e) => {
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");
@@ -1693,8 +3211,24 @@ els.arena?.addEventListener("click", (e) => {
}
});
+// 容器变高变矮后重新量一次折叠高度(上限跟着可见高度走)
+let relayoutTimer = 0;window.addEventListener(
+ "resize",
+ () => {
+ window.clearTimeout(relayoutTimer);
+ relayoutTimer = window.setTimeout(() => {
+ if (navLevel === "conversation") collapseReplyTracks();
+ }, 120);
+ },
+ { passive: true },
+);
+
loadProfiles()
- .then(() => goRoot())
+ .then(() => loadChatPrefs())
+ .then(() => {
+ syncContextTabs();
+ })
+ .then(() => restoreOpenTarget())
.catch((err) =>
setStatus(err instanceof Error ? err.message : String(err), true),
);
diff --git a/web/preview-arena-cards.html b/web/preview-arena-cards.html
new file mode 100644
index 0000000..6c9f814
--- /dev/null
+++ b/web/preview-arena-cards.html
@@ -0,0 +1,649 @@
+
+
+
+
+
+
预览 · 气泡下的模型卡片
+
+
+
+
+
+ 临时会话 · 预览,回复都是字符串
+ 同一条消息只出现一次,卡片可左右拖
+
+
+
+
+
+
+
diff --git a/web/settings.css b/web/settings.css
index aad2e98..3e4cf7b 100644
--- a/web/settings.css
+++ b/web/settings.css
@@ -503,6 +503,20 @@ code {
display: none;
}
+/* 宽屏下不要把表单和卡片拉到整屏宽 */
+.st-main-head {
+ max-width: calc(1080px + 28px);
+}
+
+.st-body > .settings-section {
+ max-width: 1080px;
+}
+
+.active-settings-bar,
+.st-status {
+ max-width: 1080px;
+}
+
/* —— Buttons —— */
.btn-primary,
.btn-secondary,
@@ -1073,6 +1087,22 @@ code {
gap: 8px;
}
+.import-report-warnings {
+ margin: 8px 0 0;
+ padding-left: 18px;
+ list-style: disc;
+}
+
+.import-report-warnings li {
+ margin: 2px 0;
+}
+
+.import-report-warnings li + li {
+ border-top: 1px solid var(--st-border);
+ padding-top: 4px;
+ margin-top: 4px;
+}
+
.preset-entries-panel {
margin-top: 4px;
padding-left: 48px;
@@ -1427,6 +1457,101 @@ code {
color: var(--st-faint);
}
+/* 左标签、右控件的表单行:比竖排 label 更短、也不拉满整卡 */
+.settings-form .form-row {
+ display: grid;
+ grid-template-columns: 104px minmax(0, 1fr);
+ align-items: start;
+ gap: 4px 16px;
+ padding: 14px 0;
+ border-top: 1px solid var(--st-border);
+}
+
+.settings-form .form-row:first-child {
+ border-top: none;
+ padding-top: 2px;
+}
+
+.settings-form .form-label {
+ padding-top: 7px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--st-text);
+}
+
+.settings-form .form-label code {
+ padding: 1px 4px;
+ border-radius: 4px;
+ background: #f4f4f5;
+ font-size: 12px;
+}
+
+.settings-form .form-control {
+ min-width: 0;
+}
+
+.settings-form select {
+ width: 100%;
+ max-width: 420px;
+ padding: 7px 10px;
+ border: 1px solid var(--st-border-strong);
+ border-radius: 6px;
+ background: var(--st-surface);
+ color: inherit;
+ font: inherit;
+ font-size: 13px;
+ cursor: pointer;
+}
+
+.settings-form select:focus {
+ outline: none;
+ border-color: #93c5fd;
+ box-shadow: 0 0 0 3px var(--st-accent-soft);
+}
+
+.settings-form .form-hint {
+ margin: 6px 0 0;
+ max-width: 560px;
+ font-size: 12px;
+ line-height: 1.55;
+ color: var(--st-faint);
+}
+
+.settings-form .form-static {
+ margin: 0;
+ max-width: 560px;
+ padding-top: 7px;
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--st-muted);
+}
+
+.settings-form .form-static strong {
+ font-weight: 600;
+ color: var(--st-text);
+}
+
+.settings-form .no-break {
+ white-space: nowrap;
+}
+
+@media (max-width: 720px) {
+ .settings-form .form-row {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 6px;
+ padding: 12px 0;
+ }
+
+ .settings-form .form-label,
+ .settings-form .form-static {
+ padding-top: 0;
+ }
+
+ .settings-form select {
+ max-width: none;
+ }
+}
+
.storage-card .field {
display: flex;
flex-direction: column;
diff --git a/web/settings.html b/web/settings.html
index 0ffc0db..e6d361c 100644
--- a/web/settings.html
+++ b/web/settings.html
@@ -34,6 +34,7 @@
+
保存在本机 ~/.writing-agent/,明文、不同步。
@@ -106,6 +107,53 @@
+
+
发出前的消息整形
+
+ 预设(如酒馆的 squash_system_messages)会要求把相邻同角色的消息合并。
+ 这里默认「跟随预设」,也可以强制覆盖。
+
+
+
+
+
+
@@ -156,6 +204,32 @@