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

288 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 历史条目编辑器(假历史 / 用例历史 / 直聊种子历史共用)。
*
* 每条 = 角色下拉 + 一整句正文。正文里的换行就是真换行,
* 不靠 `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;
},
};
}