/** * 历史条目编辑器(假历史 / 用例历史 / 直聊种子历史共用)。 * * 每条 = 角色下拉 + 一整句正文。正文里的换行就是真换行, * 不靠 `user: / assistant:` 行首和 `\n` 转义来猜边界,一句永远是一条消息。 * * items 是 { role, content }[]:组件直接改数组元素, * 结构变化(加 / 删 / 移 / 换角色)后自己重画,正文输入不重画(不打断光标)。 */ export const HISTORY_ROLES = ["user", "assistant"]; /** 只留 user / assistant 且有正文的条目;脏数据丢掉,不抛。 */ export function normalizeHistoryTurns(value) { if (!Array.isArray(value)) return []; const out = []; for (const item of value) { if (!item || typeof item !== "object") continue; const role = item.role === "assistant" ? "assistant" : item.role === "user" ? "user" : ""; if (!role) continue; const content = typeof item.content === "string" ? item.content : ""; if (!content.trim()) continue; out.push({ role, content }); } return out; } function roleLabel(role) { return role === "assistant" ? "assistant" : "user"; } export function createHistoryEditor({ items, onChange, disabled = false, addLabel = "+ 加一句", emptyHint = "空则不占历史;点「+ 加一句」开始写。", countHint = (count) => `共 ${count} 句 · 一条一句,正文换行原样保留`, }) { const host = document.createElement("div"); host.className = "he"; host.dataset.disabled = disabled ? "1" : "0"; let focusIndex = -1; let isDisabled = disabled; /** 拖动排序:从哪一行起拖,以及当前插入点(0..items.length) */ let dragIndex = -1; let dropAt = -1; let dragRow = null; const commit = () => { if (onChange) onChange(); }; const rowsFor = (text) => { const lines = String(text || "").split("\n").length; return Math.min(12, Math.max(2, lines)); }; const redraw = () => { host.replaceChildren(); items.forEach((item, index) => host.append(row(item, index))); host.append(actions()); if (focusIndex >= 0) { const area = host.querySelector(`[data-history-index="${focusIndex}"]`); if (area) { area.focus(); area.setSelectionRange(area.value.length, area.value.length); } focusIndex = -1; } host.dataset.disabled = isDisabled ? "1" : "0"; }; const move = (index, delta) => { const target = index + delta; if (target < 0 || target >= items.length) return; const tmp = items[index]; items[index] = items[target]; items[target] = tmp; focusIndex = target; commit(); redraw(); }; const remove = (index) => { items.splice(index, 1); commit(); redraw(); }; const addTurn = () => { const last = items[items.length - 1]; items.push({ role: last && roleLabel(last.role) === "user" ? "assistant" : "user", content: "", }); focusIndex = items.length - 1; commit(); redraw(); }; const row = (item, index) => { const grip = document.createElement("button"); grip.type = "button"; grip.className = "he-grip"; grip.textContent = "⠿"; grip.title = "拖动调节上下"; grip.setAttribute("aria-label", `拖动第 ${index + 1} 句调整顺序`); grip.disabled = isDisabled; const role = document.createElement("select"); role.className = "he-role"; role.setAttribute("aria-label", `第 ${index + 1} 句的角色`); role.disabled = isDisabled; for (const value of HISTORY_ROLES) { const option = document.createElement("option"); option.value = value; option.textContent = value; role.append(option); } role.value = roleLabel(item.role); const area = document.createElement("textarea"); area.className = "he-text"; area.rows = rowsFor(item.content); area.spellcheck = false; area.placeholder = role.value === "user" ? "用户这一句…" : "角色这一句…"; area.disabled = isDisabled; area.dataset.historyIndex = String(index); area.setAttribute("aria-label", `第 ${index + 1} 句正文`); area.value = item.content || ""; role.addEventListener("change", () => { item.role = role.value === "assistant" ? "assistant" : "user"; area.placeholder = item.role === "user" ? "用户这一句…" : "角色这一句…"; host.querySelectorAll(".he-row")[index]?.setAttribute("data-role", item.role); commit(); }); area.addEventListener("input", () => { item.content = area.value; area.rows = rowsFor(item.content); commit(); }); const tools = document.createElement("div"); tools.className = "he-tools"; tools.append( toolButton("↑", "上移", index === 0, () => move(index, -1)), toolButton("↓", "下移", index === items.length - 1, () => move(index, 1)), toolButton("✕", "删除这一句", false, () => remove(index), "he-btn-danger"), ); const node = document.createElement("div"); node.className = "he-row"; node.dataset.role = roleLabel(item.role); node.dataset.index = String(index); // 拖动手柄按下时才允许拖整行:否则在 textarea 里选字会被当成拖动。 grip.addEventListener("pointerdown", () => { if (!isDisabled) node.draggable = true; }); grip.addEventListener("pointerup", () => { window.setTimeout(() => { if (dragIndex < 0) node.draggable = false; }, 0); }); grip.addEventListener("pointercancel", () => { node.draggable = false; }); node.addEventListener("dragstart", (event) => { dragIndex = index; dragRow = node; node.classList.add("he-row-dragging"); host.dataset.dragging = "1"; if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; try { event.dataTransfer.setData("text/plain", String(index)); } catch { /* 某些环境不让写 data */ } } }); node.addEventListener("dragend", () => { endDrag(); redraw(); }); node.append(grip, role, area, tools); return node; }; const toolButton = (text, title, isOff, onclick, extraClass) => { const button = document.createElement("button"); button.type = "button"; button.className = `he-btn${extraClass ? ` ${extraClass}` : ""}`; button.textContent = text; button.title = title; button.disabled = isOff || isDisabled; button.addEventListener("click", onclick); return button; }; const actions = () => { const wrap = document.createElement("div"); wrap.className = "he-actions"; const add = document.createElement("button"); add.type = "button"; add.className = "he-btn he-btn-add"; add.textContent = addLabel; add.disabled = isDisabled; add.addEventListener("click", addTurn); const hint = document.createElement("span"); hint.className = "he-hint"; hint.textContent = items.length ? countHint(items.length) : emptyHint; wrap.append(add, hint); return wrap; }; /* 拖动排序:拖到某一行的一半高度以上就插到它前面,否则插到最后。 */ const clearDropMarks = () => { for (const node of host.querySelectorAll(".he-row")) delete node.dataset.drop; }; const endDrag = () => { dragIndex = -1; dropAt = -1; if (dragRow) { dragRow.draggable = false; dragRow.classList.remove("he-row-dragging"); } dragRow = null; delete host.dataset.dragging; clearDropMarks(); }; host.addEventListener("dragover", (event) => { if (dragIndex < 0 || isDisabled) return; event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; const rows = [...host.querySelectorAll(".he-row")]; if (rows.length === 0) return; let at = rows.length; for (let i = 0; i < rows.length; i += 1) { const rect = rows[i].getBoundingClientRect(); if (event.clientY < rect.top + rect.height / 2) { at = i; break; } } dropAt = at; clearDropMarks(); if (at >= rows.length) rows[rows.length - 1].dataset.drop = "after"; else rows[at].dataset.drop = "before"; }); host.addEventListener("drop", (event) => { if (dragIndex < 0 || dropAt < 0) return; event.preventDefault(); const from = dragIndex; const to = dropAt > from ? dropAt - 1 : dropAt; if (to !== from) { const [moved] = items.splice(from, 1); items.splice(to, 0, moved); focusIndex = -1; commit(); } endDrag(); redraw(); }); redraw(); return { el: host, redraw, setDisabled(flag) { isDisabled = Boolean(flag); host.dataset.disabled = isDisabled ? "1" : "0"; redraw(); }, count() { return items.length; }, }; }