直聊的种子历史改成逐条真多轮,不再靠块头文本猜轮次。

一条一个角色下拉加一整句正文,换行原样保留;旧盘里的纯文本在读盘时迁移,切得出轮次的转成条目,切不出的挪进史前设定免得悄悄丢掉。历史编辑器做成共用控件,预设工作台的用例与假历史也用同一套。
This commit is contained in:
2026-10-26 01:09:34 +08:00
parent 860eff59b2
commit 98e51f9c6c
11 changed files with 686 additions and 17 deletions
+11
View File
@@ -341,6 +341,17 @@
color: var(--st-muted);
}
/* 种子历史是条目编辑器(不是 textarea),包一层跟其它字段同列的标题 + 控件 */
.chat-shell-editor .chat-shell-field {
display: grid;
gap: 4px;
font-size: 12px;
color: var(--st-muted);
}
.chat-shell-history {
min-width: 0;
}
.chat-shell-editor input,
.chat-shell-editor textarea {
border: 1px solid var(--st-border-strong);
+6 -9
View File
@@ -12,6 +12,7 @@
/>
<script src="/theme-init.js"></script>
<link rel="stylesheet" href="/settings.css" />
<link rel="stylesheet" href="/history-editor.css" />
<link rel="stylesheet" href="/chat.css" />
</head>
<body class="settings-page chat-page">
@@ -23,6 +24,7 @@
<a href="/modules.html">技能</a>
<a href="/libraries.html">库</a>
<a href="/stats.html">Token</a>
<a href="/presets.html">预设</a>
<a href="/settings.html">设置</a>
</nav>
</header>
@@ -162,15 +164,10 @@
史前设定
<textarea id="shell-lore-before" rows="3" disabled></textarea>
</label>
<label>
种子历史
<textarea
id="shell-seed-history"
rows="4"
placeholder="手填历史,会拼在多轮之前"
disabled
></textarea>
</label>
<div class="chat-shell-field">
<span>种子历史(真多轮 user / assistant,拼在多轮之前)</span>
<div id="shell-seed-history" class="chat-shell-history"></div>
</div>
<label>
史后状态
<textarea id="shell-lore-after" rows="2" disabled></textarea>
+33 -4
View File
@@ -1,4 +1,5 @@
import { renderMarkdownToHtml } from "./markdown.js";
import { createHistoryEditor, normalizeHistoryTurns } from "./history-editor.js";
import { planWheelScroll } from "./chat-scroll.js";
import {
bumpKeepStats,
@@ -26,6 +27,7 @@ const EMPTY_SLOTS = {
systemPrompt: "",
loreBefore: "",
seedHistory: "",
seedMessages: [],
loreAfter: "",
postTurn: "",
charName: "",
@@ -118,6 +120,12 @@ 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;
@@ -389,7 +397,8 @@ function slotsFromEditor() {
return {
systemPrompt: els.shellSystem.value,
loreBefore: els.shellLoreBefore.value,
seedHistory: els.shellSeedHistory.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,
@@ -400,12 +409,30 @@ function slotsFromEditor() {
};
}
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 || "";
els.shellSeedHistory.value = s.seedHistory || "";
fillSeedTurns(s.seedMessages, s.seedHistory);
els.shellLoreAfter.value = s.loreAfter || "";
els.shellPostTurn.value = s.postTurn || "";
els.shellCharName.value = s.charName || "";
@@ -420,7 +447,6 @@ function setContextEditorEnabled(enabled, subtitle) {
els.shellName,
els.shellSystem,
els.shellLoreBefore,
els.shellSeedHistory,
els.shellLoreAfter,
els.shellPostTurn,
els.shellCharName,
@@ -432,6 +458,7 @@ function setContextEditorEnabled(enabled, subtitle) {
els.btnClearShell,
];
for (const el of fields) el.disabled = !enabled;
if (seedEditor) seedEditor.setDisabled(!enabled);
if (els.contextSub) {
els.contextSub.textContent =
subtitle || (enabled ? "改完点应用" : "选择或新建后编辑");
@@ -1866,7 +1893,7 @@ function contextFieldsEmpty() {
return (
!els.shellSystem.value.trim() &&
!els.shellLoreBefore.value.trim() &&
!els.shellSeedHistory.value.trim() &&
seedTurns.length === 0 &&
!els.shellLoreAfter.value.trim() &&
!els.shellPostTurn.value.trim()
);
@@ -3223,6 +3250,8 @@ let relayoutTimer = 0;window.addEventListener(
{ passive: true },
);
mountSeedEditor();
loadProfiles()
.then(() => loadChatPrefs())
.then(() => {
+128
View File
@@ -0,0 +1,128 @@
/* 历史条目编辑器(web/history-editor.js):预设假历史 / 用例历史 / 直聊种子历史共用。
只依赖 settings.css 里的 --st-* 变量,所以预设置页与直聊页都能直接引。 */
.he {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.he[data-disabled="1"] {
opacity: 0.55;
}
.he-row {
display: grid;
grid-template-columns: 18px 84px minmax(0, 1fr) auto;
align-items: start;
gap: 6px;
padding: 6px;
border: 1px solid var(--st-border);
border-radius: 8px;
background: var(--st-surface);
}
.he-row[data-drop="before"] {
box-shadow: inset 0 2px 0 var(--st-accent);
}
.he-row[data-drop="after"] {
box-shadow: inset 0 -2px 0 var(--st-accent);
}
.he-row-dragging {
opacity: 0.5;
}
.he-grip {
align-self: stretch;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--st-faint);
font-size: 13px;
line-height: 1;
cursor: grab;
}
.he-grip:hover:not(:disabled) {
border-color: var(--st-border-strong);
color: var(--st-text);
}
.he-grip:active:not(:disabled) {
cursor: grabbing;
}
.he-grip:disabled {
opacity: 0.4;
cursor: default;
}
.he-row[data-role="user"] {
border-left: 2px solid var(--st-border-strong);
}
.he-row[data-role="assistant"] {
border-left: 2px solid var(--st-accent);
}
.he-role {
width: 100%;
padding: 4px 6px;
border: 1px solid var(--st-border-strong);
border-radius: 6px;
background: var(--st-surface);
color: var(--st-text);
font-family: var(--st-font-ui);
font-size: 12px;
}
.he-role:focus-visible,
.he-text:focus-visible,
.he-btn:focus-visible {
outline: 2px solid color-mix(in srgb, var(--st-accent) 45%, transparent);
outline-offset: 1px;
}
.he-text {
width: 100%;
min-height: 38px;
padding: 5px 8px;
border: 1px solid var(--st-border-strong);
border-radius: 6px;
background: var(--st-surface);
color: var(--st-text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.6;
resize: vertical;
}
.he-tools {
display: flex;
gap: 2px;
}
.he-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.he-hint {
font-size: 11.5px;
color: var(--st-faint);
}
.he-btn {
padding: 2px 6px;
border: 1px solid var(--st-border-strong);
border-radius: 6px;
background: var(--st-surface);
color: var(--st-text);
font-family: var(--st-font-ui);
font-size: 11.5px;
line-height: 1.5;
cursor: pointer;
}
.he-btn:hover:not(:disabled) {
border-color: var(--st-accent);
color: var(--st-accent);
}
.he-btn:disabled {
opacity: 0.45;
cursor: default;
}
.he-btn-danger:hover:not(:disabled) {
border-color: var(--st-danger);
color: var(--st-danger);
}
.he-btn-add {
padding: 3px 10px;
}
+287
View File
@@ -0,0 +1,287 @@
/**
* 历史条目编辑器(假历史 / 用例历史 / 直聊种子历史共用)。
*
* 每条 = 角色下拉 + 一整句正文。正文里的换行就是真换行,
* 不靠 `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;
},
};
}