直聊的种子历史改成逐条真多轮,不再靠块头文本猜轮次。
一条一个角色下拉加一整句正文,换行原样保留;旧盘里的纯文本在读盘时迁移,切得出轮次的转成条目,切不出的挪进史前设定免得悄悄丢掉。历史编辑器做成共用控件,预设工作台的用例与假历史也用同一套。
This commit is contained in:
@@ -22,6 +22,9 @@
|
||||
## 请求
|
||||
|
||||
- 槽位 → WorldInfoPack;`chatHistory` = 种子历史 + 已落定多轮 transcript;本轮用户输入进 turn。
|
||||
- 种子历史是**逐条编辑的真多轮**(`seedMessages: {role, content}[]`):界面上一条一个角色下拉 + 一整句正文 + 「+ 加一句」,
|
||||
正文换行原样保留,不再靠 `## 用户` 块头猜边界。旧盘里的 `seedHistory` 文本在 `getShell()` 读盘时自动迁移:
|
||||
能切出轮次的迁进 `seedMessages`(历史里的「系统」降为 assistant),切不出的挪进「史前设定」。
|
||||
- 经 `PresetLlmProvider` 按选用/壳绑定预设夹心后发模型。
|
||||
- 无选用预设:拒绝发送。
|
||||
- 无 API Key:可只返回拼装结果。
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getDirectChatsDir } from "../config/user-data-dir.js";
|
||||
import { parseHistoryMessages } from "../skills/dialogue-history.js";
|
||||
import {
|
||||
EMPTY_SHELL_SLOTS,
|
||||
countTurnMessages,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
type DirectChatConversation,
|
||||
type DirectChatConversationSummary,
|
||||
type DirectChatMessage,
|
||||
type DirectChatSeedTurn,
|
||||
type DirectChatShell,
|
||||
type DirectChatShellSlots,
|
||||
type DirectChatShellSummary,
|
||||
@@ -77,6 +79,48 @@ function conversationPath(id: string): string {
|
||||
return path.join(conversationsDir(), `${id}.json`);
|
||||
}
|
||||
|
||||
function parseSeedMessages(raw: unknown): DirectChatSeedTurn[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: DirectChatSeedTurn[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const role = asString(row.role);
|
||||
const content = asString(row.content);
|
||||
if ((role !== "user" && role !== "assistant") || content == null) continue;
|
||||
if (!content.trim()) continue;
|
||||
out.push({ role, content });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧盘迁移:种子历史原本是一段文本,现在由结构化条目编辑。
|
||||
* - 能切出真轮次的 → seedMessages(历史里的「系统」降为 assistant,历史只认两个角色);
|
||||
* - 切不出的(没有块头的普通文本)→ 挪进 loreBefore 的「前情」
|
||||
* (否则它还会被发送,但界面上已经看不到)。
|
||||
*/
|
||||
function migrateSeedHistory(shell: DirectChatShell): DirectChatShell {
|
||||
if (shell.seedMessages && shell.seedMessages.length > 0) return shell;
|
||||
const raw = (shell.seedHistory || "").trim();
|
||||
if (!raw) return shell;
|
||||
const turns = parseHistoryMessages(raw);
|
||||
if (turns.length === 0) {
|
||||
return {
|
||||
...shell,
|
||||
loreBefore: [shell.loreBefore.trim(), raw].filter(Boolean).join("\n\n"),
|
||||
seedHistory: "",
|
||||
};
|
||||
}
|
||||
const seedMessages: DirectChatSeedTurn[] = turns
|
||||
.map((message) => ({
|
||||
role: message.role === "user" ? ("user" as const) : ("assistant" as const),
|
||||
content: typeof message.content === "string" ? message.content : "",
|
||||
}))
|
||||
.filter((message) => message.content.trim().length > 0);
|
||||
return { ...shell, seedHistory: "", seedMessages };
|
||||
}
|
||||
|
||||
function parseShell(raw: unknown): DirectChatShell | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
@@ -96,6 +140,7 @@ function parseShell(raw: unknown): DirectChatShell | null {
|
||||
systemPrompt: asString(row.systemPrompt) ?? "",
|
||||
loreBefore: asString(row.loreBefore) ?? "",
|
||||
seedHistory: asString(row.seedHistory) ?? "",
|
||||
seedMessages: parseSeedMessages(row.seedMessages),
|
||||
loreAfter: asString(row.loreAfter) ?? "",
|
||||
postTurn: asString(row.postTurn) ?? "",
|
||||
presetId,
|
||||
@@ -320,7 +365,17 @@ export function getShell(id: string): DirectChatShell | null {
|
||||
const key = id.trim();
|
||||
if (!key) return null;
|
||||
ensureDirectChatDirs();
|
||||
return parseShell(readJson(shellPath(key)));
|
||||
const parsed = parseShell(readJson(shellPath(key)));
|
||||
if (!parsed) return null;
|
||||
const shell = migrateSeedHistory(parsed);
|
||||
if (shell !== parsed) {
|
||||
try {
|
||||
writeJsonAtomic(shellPath(shell.id), shell);
|
||||
} catch {
|
||||
// 写不进去也不影响本次使用
|
||||
}
|
||||
}
|
||||
return shell;
|
||||
}
|
||||
|
||||
export function createShell(input: {
|
||||
@@ -357,6 +412,7 @@ export function updateShell(
|
||||
| "systemPrompt"
|
||||
| "loreBefore"
|
||||
| "seedHistory"
|
||||
| "seedMessages"
|
||||
| "loreAfter"
|
||||
| "postTurn"
|
||||
| "presetId"
|
||||
@@ -377,6 +433,8 @@ export function updateShell(
|
||||
patch.loreBefore !== undefined ? patch.loreBefore : existing.loreBefore,
|
||||
seedHistory:
|
||||
patch.seedHistory !== undefined ? patch.seedHistory : existing.seedHistory,
|
||||
seedMessages:
|
||||
patch.seedMessages !== undefined ? patch.seedMessages : existing.seedMessages,
|
||||
loreAfter:
|
||||
patch.loreAfter !== undefined ? patch.loreAfter : existing.loreAfter,
|
||||
postTurn: patch.postTurn !== undefined ? patch.postTurn : existing.postTurn,
|
||||
|
||||
@@ -94,8 +94,14 @@ export function worldInfoPackFromDirectChat(input: {
|
||||
slots: DirectChatShellSlots;
|
||||
priorMessages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
}): WorldInfoPack {
|
||||
// 种子前情:带对话块头的当真的历史轮,不带的当史前读料(不污染历史区)。
|
||||
const seedTurns = parseHistoryMessages(input.slots.seedHistory);
|
||||
// 种子前情:结构化条目直接用(真多轮);旧盘文本靠块头切,切不出的当史前读料。
|
||||
const seedFromSlots = (input.slots.seedMessages ?? [])
|
||||
.filter((turn) => (turn.role === "user" || turn.role === "assistant") && turn.content.trim())
|
||||
.map((turn) => ({ role: turn.role, content: turn.content }));
|
||||
const seedTurns =
|
||||
seedFromSlots.length > 0
|
||||
? seedFromSlots
|
||||
: parseHistoryMessages(input.slots.seedHistory);
|
||||
const seedIsStructured = seedTurns.length > 0;
|
||||
const history: ChatMessage[] = [
|
||||
...seedTurns,
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import type { LlmContextTrace } from "../types/context-trace.js";
|
||||
|
||||
/** 种子历史里的真多轮条目(user / assistant 各占一条消息) */
|
||||
export type DirectChatSeedTurn = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
/** L1:上下文壳(系统提示 / 设定 / 种子历史等) */
|
||||
export type DirectChatShellSlots = {
|
||||
/** 任务契约 / 系统提示(进 worldBookBefore 任务袋) */
|
||||
systemPrompt: string;
|
||||
/** 史前设定 */
|
||||
loreBefore: string;
|
||||
/** 种子历史(拼在多轮之前) */
|
||||
/**
|
||||
* 旧版种子历史:一段文本,靠 `## 用户` / `【用户】` / `用户:` 块头切真轮次。
|
||||
* 新数据改用 `seedMessages`;这里只用于读旧盘 / 迁移。
|
||||
*/
|
||||
seedHistory: string;
|
||||
/** 种子历史(真多轮):逐条 user / assistant,拼在多轮之前 */
|
||||
seedMessages?: DirectChatSeedTurn[];
|
||||
/** 史后状态 */
|
||||
loreAfter: string;
|
||||
/** 尾注 */
|
||||
@@ -134,6 +145,7 @@ export const EMPTY_SHELL_SLOTS: DirectChatShellSlots = {
|
||||
systemPrompt: "",
|
||||
loreBefore: "",
|
||||
seedHistory: "",
|
||||
seedMessages: [],
|
||||
loreAfter: "",
|
||||
postTurn: "",
|
||||
};
|
||||
|
||||
@@ -233,6 +233,9 @@ function slotsFromBody(body: Record<string, unknown>): DirectChatShellSlots {
|
||||
typeof body.seedHistory === "string"
|
||||
? body.seedHistory
|
||||
: EMPTY_SHELL_SLOTS.seedHistory,
|
||||
seedMessages: Array.isArray(body.seedMessages)
|
||||
? (body.seedMessages as DirectChatShellSlots["seedMessages"])
|
||||
: EMPTY_SHELL_SLOTS.seedMessages,
|
||||
loreAfter:
|
||||
typeof body.loreAfter === "string"
|
||||
? body.loreAfter
|
||||
@@ -319,6 +322,9 @@ export async function handleDirectChatApi(
|
||||
typeof body.seedHistory === "string"
|
||||
? body.seedHistory
|
||||
: undefined,
|
||||
seedMessages: Array.isArray(body.seedMessages)
|
||||
? (body.seedMessages as DirectChatShellSlots["seedMessages"])
|
||||
: undefined,
|
||||
loreAfter:
|
||||
typeof body.loreAfter === "string" ? body.loreAfter : undefined,
|
||||
postTurn:
|
||||
@@ -489,6 +495,7 @@ export async function handleDirectChatApi(
|
||||
systemPrompt: shell.systemPrompt,
|
||||
loreBefore: shell.loreBefore,
|
||||
seedHistory: shell.seedHistory,
|
||||
seedMessages: shell.seedMessages,
|
||||
loreAfter: shell.loreAfter,
|
||||
postTurn: shell.postTurn,
|
||||
},
|
||||
@@ -600,6 +607,7 @@ export async function handleDirectChatApi(
|
||||
systemPrompt: shell.systemPrompt,
|
||||
loreBefore: shell.loreBefore,
|
||||
seedHistory: shell.seedHistory,
|
||||
seedMessages: shell.seedMessages,
|
||||
loreAfter: shell.loreAfter,
|
||||
postTurn: shell.postTurn,
|
||||
},
|
||||
@@ -721,6 +729,7 @@ export async function handleDirectChatApi(
|
||||
systemPrompt: shell.systemPrompt,
|
||||
loreBefore: shell.loreBefore,
|
||||
seedHistory: shell.seedHistory,
|
||||
seedMessages: shell.seedMessages,
|
||||
loreAfter: shell.loreAfter,
|
||||
postTurn: shell.postTurn,
|
||||
},
|
||||
@@ -909,6 +918,7 @@ export async function handleDirectChatApi(
|
||||
systemPrompt?: string;
|
||||
loreBefore?: string;
|
||||
seedHistory?: string;
|
||||
seedMessages?: DirectChatShellSlots["seedMessages"];
|
||||
loreAfter?: string;
|
||||
postTurn?: string;
|
||||
presetId?: string | null;
|
||||
@@ -928,6 +938,7 @@ export async function handleDirectChatApi(
|
||||
systemPrompt: body.shell.systemPrompt ?? "",
|
||||
loreBefore: body.shell.loreBefore ?? "",
|
||||
seedHistory: body.shell.seedHistory ?? "",
|
||||
seedMessages: body.shell.seedMessages ?? [],
|
||||
loreAfter: body.shell.loreAfter ?? "",
|
||||
postTurn: body.shell.postTurn ?? "",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
||||
import {
|
||||
EMPTY_SHELL_SLOTS,
|
||||
assembleDirectChatMessages,
|
||||
createShell,
|
||||
getDirectChatsDir,
|
||||
getShell,
|
||||
worldInfoPackFromDirectChat,
|
||||
} from "../src/direct-chat/index.js";
|
||||
|
||||
function samplePreset() {
|
||||
return importSillyTavernPreset({
|
||||
prompts: [
|
||||
{ identifier: "main", name: "Main", role: "system", content: "MAIN" },
|
||||
{ identifier: "chatHistory", name: "History", role: "system", marker: true },
|
||||
],
|
||||
prompt_order: [
|
||||
{
|
||||
character_id: 1,
|
||||
order: [
|
||||
{ identifier: "main", enabled: true },
|
||||
{ identifier: "chatHistory", enabled: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
}).preset;
|
||||
}
|
||||
|
||||
const slots = (extra: Record<string, unknown> = {}) => ({
|
||||
...EMPTY_SHELL_SLOTS,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("直聊种子历史:结构化真多轮", () => {
|
||||
beforeEach(() => {
|
||||
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
||||
path.join(tmpdir(), "wa-seed-history-"),
|
||||
);
|
||||
});
|
||||
|
||||
it("seedMessages 每条一个 message,排在已有轮次之前", () => {
|
||||
const messages = assembleDirectChatMessages(samplePreset(), {
|
||||
message: "继续",
|
||||
slots: slots({
|
||||
seedMessages: [
|
||||
{ role: "user", content: "前情-问" },
|
||||
{ role: "assistant", content: "前情-答" },
|
||||
],
|
||||
}),
|
||||
priorMessages: [{ role: "user", content: "真-问" }],
|
||||
});
|
||||
const contents = messages.map((m) => String(m.content));
|
||||
const at = (text: string) => contents.findIndex((c) => c.includes(text));
|
||||
expect(at("前情-问")).toBeGreaterThanOrEqual(0);
|
||||
expect(at("前情-问")).toBeLessThan(at("前情-答"));
|
||||
expect(at("前情-答")).toBeLessThan(at("真-问"));
|
||||
expect(at("真-问")).toBeLessThan(at("继续"));
|
||||
// 真轮次,不是塞进 system 的 few-shot
|
||||
expect(messages.some((m) => m.name)).toBe(false);
|
||||
});
|
||||
|
||||
it("seedMessages 优先于旧版 seedHistory 文本", () => {
|
||||
const pack = worldInfoPackFromDirectChat({
|
||||
message: "继续",
|
||||
slots: slots({
|
||||
seedHistory: "## 用户\n\n旧文本",
|
||||
seedMessages: [{ role: "assistant", content: "新结构" }],
|
||||
}),
|
||||
priorMessages: [],
|
||||
});
|
||||
expect(pack.history).toEqual([{ role: "assistant", content: "新结构" }]);
|
||||
});
|
||||
|
||||
it("脏条目丢掉,全空时回落旧文本", () => {
|
||||
const pack = worldInfoPackFromDirectChat({
|
||||
message: "继续",
|
||||
slots: slots({
|
||||
seedHistory: "## 用户\n\n旧文本",
|
||||
seedMessages: [
|
||||
{ role: "system", content: "不是历史角色" },
|
||||
{ role: "user", content: " " },
|
||||
],
|
||||
}),
|
||||
priorMessages: [],
|
||||
});
|
||||
expect(pack.history).toEqual([{ role: "user", content: "旧文本" }]);
|
||||
});
|
||||
|
||||
it("读旧盘时把种子历史文本迁成结构化条目并落盘", () => {
|
||||
const shell = createShell({ name: "旧壳", slots: slots() });
|
||||
const file = path.join(getDirectChatsDir(), "shells", `${shell.id}.json`);
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
...shell,
|
||||
seedHistory: "【用户】\n我推开门\n\n【助手】\n屋里没有人。\n\n【系统】\n旁白一句",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const loaded = getShell(shell.id);
|
||||
expect(loaded?.seedHistory).toBe("");
|
||||
expect(loaded?.seedMessages).toEqual([
|
||||
{ role: "user", content: "我推开门" },
|
||||
{ role: "assistant", content: "屋里没有人。" },
|
||||
{ role: "assistant", content: "旁白一句" },
|
||||
]);
|
||||
// 迁移要写回盘,下次读到的就是新形状
|
||||
const onDisk = JSON.parse(readFileSync(file, "utf8")) as { seedMessages?: unknown };
|
||||
expect(Array.isArray(onDisk.seedMessages)).toBe(true);
|
||||
});
|
||||
|
||||
it("切不出轮次的旧文本挪进前情,避免变成看不见的发送内容", () => {
|
||||
const shell = createShell({ name: "读料壳", slots: slots({ loreBefore: "设定" }) });
|
||||
const file = path.join(getDirectChatsDir(), "shells", `${shell.id}.json`);
|
||||
writeFileSync(file, JSON.stringify({ ...shell, seedHistory: "一段没有块头的读料" }), "utf8");
|
||||
|
||||
const loaded = getShell(shell.id);
|
||||
expect(loaded?.seedHistory).toBe("");
|
||||
expect(loaded?.seedMessages).toEqual([]);
|
||||
expect(loaded?.loreBefore).toBe("设定\n\n一段没有块头的读料");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user