一条一个角色下拉加一整句正文,换行原样保留;旧盘里的纯文本在读盘时迁移,切得出轮次的转成条目,切不出的挪进史前设定免得悄悄丢掉。历史编辑器做成共用控件,预设工作台的用例与假历史也用同一套。
288 lines
8.7 KiB
JavaScript
288 lines
8.7 KiB
JavaScript
/**
|
||
* 历史条目编辑器(假历史 / 用例历史 / 直聊种子历史共用)。
|
||
*
|
||
* 每条 = 角色下拉 + 一整句正文。正文里的换行就是真换行,
|
||
* 不靠 `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;
|
||
},
|
||
};
|
||
}
|