Integrate ENA planner task profiles

This commit is contained in:
Youzini-afk
2026-04-23 16:25:05 +08:00
parent dfe0ba3d6e
commit efff3b15b3
6 changed files with 759 additions and 230 deletions

View File

@@ -2,10 +2,22 @@ import { extension_settings } from '../../../../extensions.js';
import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../script.js';
import { EnaPlannerStorage, migrateFromLWBIfNeeded } from './ena-planner-storage.js';
import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js';
import {
createBuiltinPromptBlock,
createCustomPromptBlock,
createProfileId,
ensureTaskProfiles,
getActiveTaskProfile,
setActiveTaskProfileId,
upsertTaskProfile,
} from '../prompting/prompt-profiles.js';
import { debugLog } from '../runtime/debug-logging.js';
import jsyaml from '../vendor/js-yaml.mjs';
const EXT_NAME = 'ena-planner';
const BME_MODULE_NAME = 'st_bme';
const PLANNER_TASK_TYPE = 'planner';
const LEGACY_PLANNER_TASK_PROFILE_MIGRATION_VERSION = 1;
const VECTOR_RECALL_TIMEOUT_MS = 30000;
const PLANNER_REQUEST_TIMEOUT_MS = 90000;
@@ -114,6 +126,316 @@ function notifyNativeChange(kind, payload) {
}
}
function getBmeSettings() {
const settings = extension_settings?.[BME_MODULE_NAME];
return settings && typeof settings === 'object' ? settings : {};
}
function hasPlannerTaskProfileMigration(settings = getBmeSettings()) {
return Number(settings?.enaPlannerTaskProfileMigrationVersion || 0) >= LEGACY_PLANNER_TASK_PROFILE_MIGRATION_VERSION;
}
function getPlannerTaskProfile() {
return getActiveTaskProfile(getBmeSettings(), PLANNER_TASK_TYPE);
}
function sortPlannerProfileBlocks(blocks = []) {
return [...(Array.isArray(blocks) ? blocks : [])]
.map((block, index) => ({ ...block, _orderIndex: index }))
.sort((left, right) => {
const leftOrder = Number.isFinite(Number(left?.order))
? Number(left.order)
: left._orderIndex;
const rightOrder = Number.isFinite(Number(right?.order))
? Number(right.order)
: right._orderIndex;
return leftOrder - rightOrder;
});
}
function normalizeLegacyPlannerPromptBlocks(blocks = []) {
return (Array.isArray(blocks) ? blocks : [])
.filter((block) => block && typeof block === 'object')
.map((block, index) => ({
id: String(block?.id || `ena-legacy-block-${index + 1}`),
name: String(block?.name || `提示词块 ${index + 1}`),
role: ['system', 'user', 'assistant'].includes(String(block?.role || '').trim())
? String(block.role).trim()
: 'system',
content: String(block?.content || ''),
order: Number.isFinite(Number(block?.order)) ? Number(block.order) : index,
}))
.filter((block) => String(block.content || '').trim());
}
function buildPlannerProfileBlocksFromLegacy(promptBlocks = []) {
const normalizedBlocks = normalizeLegacyPlannerPromptBlocks(promptBlocks);
const systemBlocks = normalizedBlocks.filter((block) => block.role === 'system');
const userBlocks = normalizedBlocks.filter((block) => block.role === 'user');
const assistantBlocks = normalizedBlocks.filter((block) => block.role === 'assistant');
const builtins = [
'plannerCharacterCard',
'plannerWorldbook',
'plannerRecentChat',
'plannerMemory',
'plannerPreviousPlots',
];
const result = [];
let order = 0;
const pushCustom = (block) => {
result.push(createCustomPromptBlock(PLANNER_TASK_TYPE, {
name: block.name,
role: block.role,
content: block.content,
injectionMode: 'relative',
order: order++,
}));
};
systemBlocks.forEach(pushCustom);
builtins.forEach((sourceKey) => {
result.push(createBuiltinPromptBlock(PLANNER_TASK_TYPE, sourceKey, {
injectionMode: 'relative',
order: order++,
}));
});
userBlocks.forEach(pushCustom);
result.push(createBuiltinPromptBlock(PLANNER_TASK_TYPE, 'plannerUserInput', {
injectionMode: 'relative',
order: order++,
}));
assistantBlocks.forEach(pushCustom);
return result;
}
function normalizePlannerGenerationNumber(value) {
if (value == null || value === '') return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function buildPlannerGenerationFromLegacyConfig(plannerConfig = {}) {
const api = plannerConfig?.api && typeof plannerConfig.api === 'object'
? plannerConfig.api
: {};
return {
stream:
typeof api.stream === 'boolean'
? api.stream
: api.stream === 'true'
? true
: api.stream === 'false'
? false
: true,
temperature: normalizePlannerGenerationNumber(api.temperature),
top_p: normalizePlannerGenerationNumber(api.top_p),
top_k: normalizePlannerGenerationNumber(api.top_k),
frequency_penalty: normalizePlannerGenerationNumber(api.frequency_penalty),
presence_penalty: normalizePlannerGenerationNumber(api.presence_penalty),
max_completion_tokens: normalizePlannerGenerationNumber(api.max_tokens),
};
}
function buildComparablePlannerGenerationSnapshot(generation = {}) {
return {
stream:
generation?.stream === true
? true
: generation?.stream === false
? false
: null,
temperature: normalizePlannerGenerationNumber(generation?.temperature),
top_p: normalizePlannerGenerationNumber(generation?.top_p),
top_k: normalizePlannerGenerationNumber(generation?.top_k),
frequency_penalty: normalizePlannerGenerationNumber(generation?.frequency_penalty),
presence_penalty: normalizePlannerGenerationNumber(generation?.presence_penalty),
max_completion_tokens: normalizePlannerGenerationNumber(generation?.max_completion_tokens),
};
}
function arePlannerGenerationSettingsEquivalent(left = {}, right = {}) {
return JSON.stringify(buildComparablePlannerGenerationSnapshot(left)) === JSON.stringify(buildComparablePlannerGenerationSnapshot(right));
}
function normalizePlannerProfileBlockComparisonPayload(blocks = []) {
return sortPlannerProfileBlocks(blocks).map((block) => ({
role: String(block?.role || ''),
type: String(block?.type || 'custom'),
sourceKey: String(block?.sourceKey || ''),
content: String(block?.content || '').trim(),
enabled: block?.enabled !== false,
}));
}
function arePlannerProfileBlocksEquivalent(left = [], right = []) {
return JSON.stringify(normalizePlannerProfileBlockComparisonPayload(left)) === JSON.stringify(normalizePlannerProfileBlockComparisonPayload(right));
}
function buildPlannerMigrationProfileName(baseName = '', fallbackName = 'ENA 当前配置', usedNames = new Set()) {
const base = String(baseName || '').trim() || fallbackName;
let nextName = base;
let suffix = 2;
while (usedNames.has(nextName)) {
nextName = `${base} ${suffix}`;
suffix += 1;
}
usedNames.add(nextName);
return nextName;
}
function createLegacyPlannerTaskProfile(name, promptBlocks, plannerConfig, options = {}) {
return {
id: createProfileId(PLANNER_TASK_TYPE),
name,
taskType: PLANNER_TASK_TYPE,
builtin: false,
enabled: true,
promptMode: 'block-based',
updatedAt: nowISO(),
blocks: buildPlannerProfileBlocksFromLegacy(promptBlocks),
generation: buildPlannerGenerationFromLegacyConfig(plannerConfig),
metadata: {
migratedFromLegacy: true,
enaLegacyTemplateName: String(options.templateName || ''),
enaLegacySource: String(options.source || 'legacy-ena'),
},
};
}
function migrateLegacyPlannerTaskProfilesIfNeeded() {
const settings = getBmeSettings();
if (hasPlannerTaskProfileMigration(settings)) {
return false;
}
const plannerConfig = ensureSettings({ defaultEnabled: false });
let nextTaskProfiles = ensureTaskProfiles(settings);
const plannerBucket = nextTaskProfiles?.[PLANNER_TASK_TYPE] || {
activeProfileId: 'default',
profiles: [],
};
const hasExistingCustomProfiles = Array.isArray(plannerBucket.profiles)
&& plannerBucket.profiles.some((profile) => String(profile?.id || '') !== 'default');
if (hasExistingCustomProfiles) {
extension_settings[BME_MODULE_NAME] = {
...settings,
taskProfiles: nextTaskProfiles,
enaPlannerTaskProfileMigrationVersion: LEGACY_PLANNER_TASK_PROFILE_MIGRATION_VERSION,
};
saveSettingsDebounced?.();
return false;
}
const defaultPlannerProfile = getActiveTaskProfile({}, PLANNER_TASK_TYPE);
const defaultPlannerBlocks = Array.isArray(defaultPlannerProfile?.blocks)
? defaultPlannerProfile.blocks
: [];
const defaultPlannerGeneration = defaultPlannerProfile?.generation || {};
const currentBlocks = Array.isArray(plannerConfig.promptBlocks)
? plannerConfig.promptBlocks
: getDefaultSettings().promptBlocks;
const promptTemplates = plannerConfig?.promptTemplates && typeof plannerConfig.promptTemplates === 'object'
? plannerConfig.promptTemplates
: {};
const activeTemplateName = String(plannerConfig.activePromptTemplate || '').trim();
const usedNames = new Set(
(Array.isArray(plannerBucket.profiles) ? plannerBucket.profiles : [])
.map((profile) => String(profile?.name || '').trim())
.filter(Boolean),
);
const seenSignatures = new Set();
const profileSpecs = [];
let activeProfileName = '';
const appendProfileSpec = (name, promptBlocks, options = {}) => {
const migratedBlocks = buildPlannerProfileBlocksFromLegacy(promptBlocks);
const migratedGeneration = buildPlannerGenerationFromLegacyConfig(plannerConfig);
if (
arePlannerProfileBlocksEquivalent(migratedBlocks, defaultPlannerBlocks)
&& arePlannerGenerationSettingsEquivalent(migratedGeneration, defaultPlannerGeneration)
&& options.allowDefaultDuplicate !== true
) {
return '';
}
const signature = JSON.stringify({
blocks: normalizePlannerProfileBlockComparisonPayload(migratedBlocks),
generation: buildComparablePlannerGenerationSnapshot(migratedGeneration),
});
if (seenSignatures.has(signature)) {
return '';
}
seenSignatures.add(signature);
const uniqueName = buildPlannerMigrationProfileName(name, options.fallbackName, usedNames);
profileSpecs.push({
name: uniqueName,
promptBlocks,
templateName: options.templateName || '',
source: options.source || 'legacy-ena',
active: options.active === true,
});
return uniqueName;
};
for (const [templateName, templateBlocks] of Object.entries(promptTemplates)) {
if (!Array.isArray(templateBlocks)) continue;
const appendedName = appendProfileSpec(templateName, templateBlocks, {
fallbackName: 'ENA 模板',
templateName,
source: 'legacy-template',
});
if (
appendedName
&& activeTemplateName === templateName
&& arePlannerProfileBlocksEquivalent(templateBlocks, currentBlocks)
) {
activeProfileName = appendedName;
}
}
if (!activeProfileName) {
activeProfileName = appendProfileSpec(
activeTemplateName ? `${activeTemplateName}(当前)` : 'ENA 当前配置',
currentBlocks,
{
fallbackName: 'ENA 当前配置',
source: 'legacy-working-copy',
active: true,
},
);
}
let activeProfileId = '';
for (const spec of profileSpecs) {
const profile = createLegacyPlannerTaskProfile(spec.name, spec.promptBlocks, plannerConfig, {
templateName: spec.templateName,
source: spec.source,
});
nextTaskProfiles = upsertTaskProfile(nextTaskProfiles, PLANNER_TASK_TYPE, profile, {
setActive: false,
});
if (spec.name === activeProfileName || (spec.active && !activeProfileId)) {
activeProfileId = profile.id;
}
}
if (activeProfileId) {
nextTaskProfiles = setActiveTaskProfileId(nextTaskProfiles, PLANNER_TASK_TYPE, activeProfileId);
}
extension_settings[BME_MODULE_NAME] = {
...settings,
taskProfiles: nextTaskProfiles,
enaPlannerTaskProfileMigrationVersion: LEGACY_PLANNER_TASK_PROFILE_MIGRATION_VERSION,
};
saveSettingsDebounced?.();
return profileSpecs.length > 0;
}
/**
* -------------------------
* Helpers
@@ -172,6 +494,7 @@ async function loadConfig() {
const hasSavedConfig = !!(loaded && typeof loaded === 'object');
config = hasSavedConfig ? loaded : getDefaultSettings({ enabled: false });
ensureSettings({ defaultEnabled: hasSavedConfig ? true : false });
migrateLegacyPlannerTaskProfilesIfNeeded();
state.logs = Array.isArray(await EnaPlannerStorage.get('logs', [])) ? await EnaPlannerStorage.get('logs', []) : [];
if (extension_settings?.[EXT_NAME]) {
@@ -954,27 +1277,22 @@ async function callPlanner(messages, options = {}) {
if (!s.api.baseUrl) throw new Error('未配置 API URL');
if (!s.api.apiKey) throw new Error('未配置 API KEY');
if (!s.api.model) throw new Error('未选择模型');
const generation = resolvePlannerGenerationSettings();
const url = buildUrl('/chat/completions');
const body = {
model: s.api.model,
messages,
stream: !!s.api.stream
stream: generation.stream === true
};
const t = Number(s.api.temperature);
if (!Number.isNaN(t)) body.temperature = t;
const tp = Number(s.api.top_p);
if (!Number.isNaN(tp)) body.top_p = tp;
const tk = Number(s.api.top_k);
if (!Number.isNaN(tk) && tk > 0) body.top_k = tk;
const pp = s.api.presence_penalty === '' ? null : Number(s.api.presence_penalty);
if (pp != null && !Number.isNaN(pp)) body.presence_penalty = pp;
const fp = s.api.frequency_penalty === '' ? null : Number(s.api.frequency_penalty);
if (fp != null && !Number.isNaN(fp)) body.frequency_penalty = fp;
const mt = s.api.max_tokens === '' ? null : Number(s.api.max_tokens);
if (mt != null && !Number.isNaN(mt) && mt > 0) body.max_tokens = mt;
if (generation.temperature != null) body.temperature = generation.temperature;
if (generation.top_p != null) body.top_p = generation.top_p;
if (generation.top_k != null && generation.top_k > 0) body.top_k = generation.top_k;
if (generation.presence_penalty != null) body.presence_penalty = generation.presence_penalty;
if (generation.frequency_penalty != null) body.frequency_penalty = generation.frequency_penalty;
if (generation.max_tokens != null && generation.max_tokens > 0) body.max_tokens = generation.max_tokens;
const controller = new AbortController();
const plannerRequestTimeoutMs = getPlannerRequestTimeoutMs();
@@ -996,7 +1314,7 @@ async function callPlanner(messages, options = {}) {
throw new Error(`规划请求失败: ${res.status} ${text}`.slice(0, 500));
}
if (!s.api.stream) {
if (!generation.stream) {
const data = await res.json();
const text = String(data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? '');
if (text) options?.onDelta?.(text, text);
@@ -1219,9 +1537,89 @@ async function clearPlannerLogs() {
* Build planner messages
* --------------------------
*/
function getPromptBlocksByRole(role) {
function resolvePlannerGenerationSettings() {
const s = ensureSettings();
return (s.promptBlocks || []).filter(b => b?.role === role && String(b?.content ?? '').trim());
const profile = getPlannerTaskProfile();
const generation = profile?.generation && typeof profile.generation === 'object'
? profile.generation
: {};
const pickNumber = (profileValue, fallbackValue) => {
const normalizedProfileValue = normalizePlannerGenerationNumber(profileValue);
if (normalizedProfileValue != null) return normalizedProfileValue;
return normalizePlannerGenerationNumber(fallbackValue);
};
const stream =
generation?.stream === true
? true
: generation?.stream === false
? false
: Boolean(s.api.stream);
return {
profile,
stream,
temperature: pickNumber(generation?.temperature, s.api.temperature),
top_p: pickNumber(generation?.top_p, s.api.top_p),
top_k: pickNumber(generation?.top_k, s.api.top_k),
presence_penalty: pickNumber(generation?.presence_penalty, s.api.presence_penalty),
frequency_penalty: pickNumber(generation?.frequency_penalty, s.api.frequency_penalty),
max_tokens: pickNumber(generation?.max_completion_tokens, s.api.max_tokens),
};
}
function getPlannerPromptBlocksForRuntime() {
const profile = getPlannerTaskProfile();
const blocks = sortPlannerProfileBlocks(profile?.blocks || []).filter(
(block) => block?.enabled !== false,
);
if (blocks.length > 0) {
return {
source: 'task-profile',
profile,
blocks,
};
}
return {
source: 'legacy-config',
profile: null,
blocks: normalizeLegacyPlannerPromptBlocks(ensureSettings().promptBlocks || []).map(
(block, index) => ({
id: block.id,
name: block.name,
role: block.role,
type: 'custom',
sourceKey: '',
content: block.content,
order: Number.isFinite(Number(block?.order)) ? Number(block.order) : index,
enabled: true,
}),
),
};
}
function resolvePlannerBuiltinBlockContent(block = {}, context = {}) {
const sourceKey = String(block?.sourceKey || '').trim();
switch (sourceKey) {
case 'plannerCharacterCard':
return String(context.charBlock || '');
case 'plannerWorldbook':
return String(context.worldbook || '');
case 'plannerRecentChat':
return String(context.recentChat || '');
case 'plannerMemory':
return String(context.bmeMemory || '').trim()
? `<bme_memory>\n${String(context.bmeMemory || '').trim()}\n</bme_memory>`
: '';
case 'plannerPreviousPlots':
return String(context.plots || '');
case 'plannerUserInput':
return String(context.userMsgContent || '');
default:
return '';
}
}
async function buildPlannerMessages(rawUserInput) {
@@ -1231,10 +1629,7 @@ async function buildPlannerMessages(rawUserInput) {
const charObj = getCurrentCharSafe();
const env = await prepareEjsEnv();
const messageVars = getLatestMessageVarTable();
const enaSystemBlocks = getPromptBlocksByRole('system');
const enaAssistantBlocks = getPromptBlocksByRole('assistant');
const enaUserBlocks = getPromptBlocksByRole('user');
const plannerPromptConfig = getPlannerPromptBlocksForRuntime();
const charBlockRaw = formatCharCardBlock(charObj);
@@ -1292,51 +1687,47 @@ async function buildPlannerMessages(rawUserInput) {
const bmeMemory = memoryBlock || '';
const worldbook = await renderTemplateAll(worldbookRaw, env, messageVars);
const userInput = await renderTemplateAll(rawUserInput, env, messageVars);
const userMsgContent = `以下是玩家的最新指令哦~:\n[${userInput}]`;
const plannerBlockContext = {
charBlock,
worldbook,
recentChat,
bmeMemory,
plots,
userInput,
userMsgContent,
};
const messages = [];
// 1) Ena system prompts
for (const b of enaSystemBlocks) {
const content = await renderTemplateAll(b.content, env, messageVars);
messages.push({ role: 'system', content });
}
// 2) Character card
if (String(charBlock).trim()) messages.push({ role: 'system', content: charBlock });
// 3) Worldbook
if (String(worldbook).trim()) messages.push({ role: 'system', content: worldbook });
// 4) Chat history (last 2 AI responses — floors N-1 & N-3)
if (String(recentChat).trim()) messages.push({ role: 'system', content: recentChat });
// 4.5) BME memory — after chat context, before plots
if (bmeMemory.trim()) {
messages.push({ role: 'system', content: `<bme_memory>\n${bmeMemory}\n</bme_memory>` });
}
// 5) Previous plots
if (String(plots).trim()) messages.push({ role: 'system', content: plots });
// 6) User input (with friendly framing)
const userMsgContent = `以下是玩家的最新指令哦~:\n[${userInput}]`;
messages.push({ role: 'user', content: userMsgContent });
// Extra user blocks before user message
for (const b of enaUserBlocks) {
const content = await renderTemplateAll(b.content, env, messageVars);
messages.splice(Math.max(0, messages.length - 1), 0, { role: 'user', content: `【extra-user-block】\n${content}` });
}
// 7) Assistant blocks
for (const b of enaAssistantBlocks) {
const content = await renderTemplateAll(b.content, env, messageVars);
messages.push({ role: 'assistant', content });
for (const block of plannerPromptConfig.blocks) {
if (!block || block.enabled === false) continue;
let content = '';
if (String(block.type || 'custom') === 'builtin') {
if (String(block.content || '').trim()) {
content = await renderTemplateAll(block.content, env, messageVars);
} else {
content = resolvePlannerBuiltinBlockContent(block, plannerBlockContext);
}
} else {
content = await renderTemplateAll(block.content, env, messageVars);
}
if (!String(content || '').trim()) continue;
messages.push({
role: ['system', 'user', 'assistant'].includes(String(block.role || '').trim())
? String(block.role).trim()
: 'system',
content,
});
}
return {
messages,
meta: {
promptSource: plannerPromptConfig.source,
profileId: plannerPromptConfig.profile?.id || '',
profileName: plannerPromptConfig.profile?.name || '',
charBlockRaw,
worldbookRaw,
recentChatRaw,
@@ -1363,6 +1754,11 @@ async function runPlanningOnce(rawUserInput, silent = false, options = {}) {
try {
const { messages, meta } = await buildPlannerMessages(rawUserInput);
log.requestMessages = messages;
if (meta && typeof meta === 'object') {
log.promptSource = String(meta.promptSource || '');
log.profileId = String(meta.profileId || '');
log.profileName = String(meta.profileName || '');
}
const rawReply = await callPlanner(messages, options);
log.rawReply = rawReply;
@@ -1423,7 +1819,7 @@ async function doInterceptAndPlanThenSend() {
const { filtered, plannerRecall } = await runPlanningOnce(raw, false, {
onDelta(_piece, full) {
if (!state.isPlanning) return;
if (!ensureSettings().api.stream) return;
if (!resolvePlannerGenerationSettings().stream) return;
const preview = filterPlannerPreview(full);
ta.value = `${raw}\n\n${preview}`.trim();
}

View File

@@ -1,5 +1,6 @@
// ST-BME: 任务预设与兼容迁移层
import { DEFAULT_PROMPT_BLOCKS as DEFAULT_PLANNER_PROMPT_BLOCKS } from "../ena-planner/ena-planner-presets.js";
import { DEFAULT_TASK_PROFILE_TEMPLATES } from "./default-task-profile-templates.js";
const TASK_TYPES = [
@@ -10,6 +11,7 @@ const TASK_TYPES = [
"summary_rollup",
"reflection",
"consolidation",
"planner",
];
const TASK_TYPE_META = {
@@ -41,6 +43,10 @@ const TASK_TYPE_META = {
label: "整合",
description: "分析新旧记忆的冲突、去重与进化。",
},
planner: {
label: "规划",
description: "为下一轮回复生成剧情规划与写作提示。",
},
};
const BUILTIN_BLOCK_DEFINITIONS = [
@@ -170,6 +176,48 @@ const BUILTIN_BLOCK_DEFINITIONS = [
role: "system",
description: "注入当前活跃的故事时间线标签与来源。extract 任务使用,帮助 LLM 定位本批对话在剧情时间轴上的位置。",
},
{
sourceKey: "plannerCharacterCard",
name: "规划:角色卡",
role: "system",
description: "注入 ENA Planner 使用的角色卡整合块description / personality / scenario。",
taskTypes: ["planner"],
},
{
sourceKey: "plannerWorldbook",
name: "规划:世界书",
role: "system",
description: "注入 ENA Planner 自己解析出的世界书块,保持当前规划链路的激活与排序语义。",
taskTypes: ["planner"],
},
{
sourceKey: "plannerRecentChat",
name: "规划:最近聊天",
role: "system",
description: "注入最近若干条 AI 回复片段,并沿用 ENA 的清洗规则去掉 think/排除标签。",
taskTypes: ["planner"],
},
{
sourceKey: "plannerMemory",
name: "规划:BME 记忆",
role: "system",
description: "注入供 ENA 规划使用的 BME 召回记忆块。",
taskTypes: ["planner"],
},
{
sourceKey: "plannerPreviousPlots",
name: "规划:历史 plot",
role: "system",
description: "注入最近的 <plot> 历史规划块,帮助保持剧情推进连续性。",
taskTypes: ["planner"],
},
{
sourceKey: "plannerUserInput",
name: "规划:玩家输入",
role: "user",
description: "注入当前玩家输入,并保留 ENA 当前使用的用户消息包裹格式。",
taskTypes: ["planner"],
},
];
const DEFAULT_TASK_PROFILE_VERSION = 3;
@@ -671,6 +719,173 @@ const DEFAULT_TRAILING_BLOCK_BLUEPRINTS = [
},
];
function getPlannerPromptBlockContentByRole(role = "system") {
return String(
(Array.isArray(DEFAULT_PLANNER_PROMPT_BLOCKS) ? DEFAULT_PLANNER_PROMPT_BLOCKS : []).find(
(block) => String(block?.role || "").trim() === String(role || "").trim(),
)?.content || "",
);
}
function buildPlannerDefaultTaskProfileTemplate() {
return {
id: "default",
name: "默认预设",
taskType: "planner",
version: 4,
builtin: true,
enabled: true,
description: TASK_TYPE_META.planner?.description || "",
promptMode: "block-based",
updatedAt: "2026-04-23T16:30:00.000Z",
blocks: [
{
id: "planner-default-system",
name: "Ena Planner System",
type: "custom",
enabled: true,
role: "system",
sourceKey: "",
sourceField: "",
content: getPlannerPromptBlockContentByRole("system"),
injectionMode: "relative",
order: 0,
},
{
id: "planner-default-character-card",
name: "角色卡",
type: "builtin",
enabled: true,
role: "system",
sourceKey: "plannerCharacterCard",
sourceField: "",
content: "",
injectionMode: "relative",
order: 1,
},
{
id: "planner-default-worldbook",
name: "世界书",
type: "builtin",
enabled: true,
role: "system",
sourceKey: "plannerWorldbook",
sourceField: "",
content: "",
injectionMode: "relative",
order: 2,
},
{
id: "planner-default-recent-chat",
name: "最近聊天",
type: "builtin",
enabled: true,
role: "system",
sourceKey: "plannerRecentChat",
sourceField: "",
content: "",
injectionMode: "relative",
order: 3,
},
{
id: "planner-default-memory",
name: "BME 记忆",
type: "builtin",
enabled: true,
role: "system",
sourceKey: "plannerMemory",
sourceField: "",
content: "",
injectionMode: "relative",
order: 4,
},
{
id: "planner-default-previous-plots",
name: "历史 plot",
type: "builtin",
enabled: true,
role: "system",
sourceKey: "plannerPreviousPlots",
sourceField: "",
content: "",
injectionMode: "relative",
order: 5,
},
{
id: "planner-default-user-input",
name: "玩家输入",
type: "builtin",
enabled: true,
role: "user",
sourceKey: "plannerUserInput",
sourceField: "",
content: "",
injectionMode: "relative",
order: 6,
},
{
id: "planner-default-assistant-seed",
name: "Assistant Seed",
type: "custom",
enabled: true,
role: "assistant",
sourceKey: "",
sourceField: "",
content: getPlannerPromptBlockContentByRole("assistant"),
injectionMode: "relative",
order: 7,
},
],
generation: {
llm_preset: "",
max_context_tokens: null,
max_completion_tokens: null,
reply_count: null,
stream: true,
temperature: 1,
top_p: 1,
top_k: 0,
top_a: null,
min_p: null,
seed: null,
frequency_penalty: null,
presence_penalty: null,
repetition_penalty: null,
squash_system_messages: null,
reasoning_effort: null,
request_thoughts: null,
enable_function_calling: null,
enable_web_search: null,
character_name_prefix: null,
wrap_user_messages_in_quotes: null,
},
regex: {
enabled: true,
inheritStRegex: true,
sources: {
global: true,
preset: true,
character: true,
},
stages: {
"input.userMessage": true,
"input.recentMessages": true,
"input.candidateText": true,
"input.finalPrompt": false,
"output.rawResponse": false,
"output.beforeParse": false,
input: true,
output: false,
},
localRules: [],
},
metadata: {
migratedFromLegacy: false,
legacyPromptField: "",
},
};
}
function applyRuntimeDefaultTemplateOverrides(taskType, template = null) {
if (!template || typeof template !== "object") {
return template;
@@ -705,6 +920,9 @@ function applyRuntimeDefaultTemplateOverrides(taskType, template = null) {
}
function getDefaultTaskProfileTemplate(taskType) {
if (String(taskType || "") === "planner") {
return buildPlannerDefaultTaskProfileTemplate();
}
const template = DEFAULT_TASK_PROFILE_TEMPLATES?.[taskType];
if (!template || typeof template !== "object") {
return null;
@@ -1590,6 +1808,11 @@ export function createCustomPromptBlock(taskType, overrides = {}) {
export function createBuiltinPromptBlock(taskType, sourceKey = "", overrides = {}) {
const definition =
BUILTIN_BLOCK_DEFINITIONS.find(
(item) =>
item.sourceKey === sourceKey &&
(!Array.isArray(item.taskTypes) || item.taskTypes.includes(taskType)),
) ||
BUILTIN_BLOCK_DEFINITIONS.find((item) => item.sourceKey === sourceKey) ||
BUILTIN_BLOCK_DEFINITIONS[0];
return normalizePromptBlock(taskType, {
@@ -1975,8 +2198,18 @@ export function getTaskTypes() {
return [...TASK_TYPES];
}
export function getBuiltinBlockDefinitions() {
return BUILTIN_BLOCK_DEFINITIONS.map((definition) => ({ ...definition }));
export function getBuiltinBlockDefinitions(taskType = "") {
const normalizedTaskType = String(taskType || "").trim();
return BUILTIN_BLOCK_DEFINITIONS
.filter(
(definition) =>
normalizedTaskType === "planner"
? Array.isArray(definition.taskTypes) && definition.taskTypes.includes("planner")
: !Array.isArray(definition.taskTypes) ||
!normalizedTaskType ||
definition.taskTypes.includes(normalizedTaskType),
)
.map((definition) => ({ ...definition }));
}
export function cloneTaskProfile(profile = {}, options = {}) {

View File

@@ -23,6 +23,7 @@ assert.equal(migrated.taskProfilesVersion, 3);
assert.ok(migrated.taskProfiles);
assert.ok(migrated.taskProfiles.extract);
assert.ok(migrated.taskProfiles.recall);
assert.ok(migrated.taskProfiles.planner);
const extractProfile = getActiveTaskProfile(
{
@@ -153,6 +154,22 @@ assert.deepEqual(
],
);
assert.ok(defaults.summary_rollup.profiles.length > 0);
assert.ok(defaults.planner.profiles.length > 0);
assert.deepEqual(
defaults.planner.profiles[0].blocks.map((block) => block.sourceKey || block.id),
[
"planner-default-system",
"plannerCharacterCard",
"plannerWorldbook",
"plannerRecentChat",
"plannerMemory",
"plannerPreviousPlots",
"plannerUserInput",
"planner-default-assistant-seed",
],
);
assert.equal(defaults.planner.profiles[0].generation.stream, true);
assert.equal(defaults.planner.profiles[0].generation.temperature, 1);
const upgradedLegacyDefault = getActiveTaskProfile(
{

View File

@@ -117,6 +117,30 @@ function getSharedLlmPresetState() {
return sanitizeLlmPresetSettings(settings || {});
}
function openPlannerTaskPresetWorkspace() {
const taskTabBtn = document.querySelector('.bme-tab-btn[data-tab="task"]');
taskTabBtn?.click();
const activatePlannerTaskType = () => {
const plannerBtn = document.querySelector(
'[data-task-action="switch-task-type"][data-task-type="planner"]',
);
plannerBtn?.click();
return Boolean(plannerBtn);
};
if (activatePlannerTaskType()) {
return true;
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
activatePlannerTaskType();
});
});
return Boolean(taskTabBtn);
}
function buildPlannerLlmSnapshot(source = {}) {
return {
llmApiUrl: String(source?.llmApiUrl || '').trim(),
@@ -503,13 +527,6 @@ function collectPatch() {
customPrefix: getVal('bme-planner-prefix-custom').trim(),
apiKey: getVal('bme-planner-api-key'),
model: getVal('bme-planner-model').trim(),
stream: toBool(getVal('bme-planner-stream'), false),
temperature: toNum(getVal('bme-planner-temp'), 1),
top_p: toNum(getVal('bme-planner-top-p'), 1),
top_k: Math.floor(toNum(getVal('bme-planner-top-k'), 0)),
presence_penalty: getVal('bme-planner-pp').trim(),
frequency_penalty: getVal('bme-planner-fp').trim(),
max_tokens: getVal('bme-planner-mt').trim(),
},
includeGlobalWorldbooks: toBool(getVal('bme-planner-include-global-wb'), false),
excludeWorldbookPosition4: toBool(getVal('bme-planner-wb-pos4'), true),
@@ -519,9 +536,6 @@ function collectPatch() {
chatExcludeTags: csvToArr(getVal('bme-planner-exclude-tags')),
logsPersist: toBool(getVal('bme-planner-logs-persist'), true),
logsMax: Math.max(1, Math.min(200, Math.floor(toNum(getVal('bme-planner-logs-max'), 20)))),
promptBlocks: cfgCache?.promptBlocks || [],
promptTemplates: cfgCache?.promptTemplates || {},
activePromptTemplate: $('bme-planner-tpl-select')?.value || '',
};
}
@@ -682,6 +696,15 @@ function bindOnce(section) {
scheduleSave();
});
$('bme-planner-open-task-presets')?.addEventListener('click', () => {
const opened = openPlannerTaskPresetWorkspace();
if (!opened) {
setLocalStatus('bme-planner-api-status', '未找到任务预设工作区,请手动切到“任务 -> 规划”', 'error');
return;
}
setLocalStatus('bme-planner-api-status', '已切换到“任务 -> 规划”预设编辑器', 'success');
});
/* Prompts + templates */
$('bme-planner-keep-tags')?.addEventListener('change', onKeepTagsBlur);

View File

@@ -3031,170 +3031,23 @@
<div class="bme-config-card">
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">规划 LLM · 生成参数</div>
<div class="bme-config-card-title">生成参数 · Prompt 编排</div>
<div class="bme-config-card-subtitle">
流式输出用于实时预览,数值留空表示不覆盖渠道默认
这两部分现在统一收敛到任务预设里的“规划”任务,避免 ENA 面板和任务面板各维护一套配置
</div>
</div>
</div>
<div class="bme-config-row">
<label for="bme-planner-stream">流式输出</label>
<select id="bme-planner-stream" class="bme-config-input">
<option value="true">开启</option>
<option value="false">关闭</option>
</select>
</div>
<div class="bme-planner-param-grid">
<div class="bme-config-row">
<label for="bme-planner-temp">Temperature</label>
<input
id="bme-planner-temp"
class="bme-config-input"
type="number"
step="0.1"
min="0"
max="2"
/>
</div>
<div class="bme-config-row">
<label for="bme-planner-top-p">Top P</label>
<input
id="bme-planner-top-p"
class="bme-config-input"
type="number"
step="0.05"
min="0"
max="1"
/>
</div>
<div class="bme-config-row">
<label for="bme-planner-top-k">Top K</label>
<input
id="bme-planner-top-k"
class="bme-config-input"
type="number"
step="1"
min="0"
/>
</div>
<div class="bme-config-row">
<label for="bme-planner-pp">Presence penalty</label>
<input
id="bme-planner-pp"
class="bme-config-input"
type="text"
placeholder="-2 ~ 2"
/>
</div>
<div class="bme-config-row">
<label for="bme-planner-fp">Frequency penalty</label>
<input
id="bme-planner-fp"
class="bme-config-input"
type="text"
placeholder="-2 ~ 2"
/>
</div>
<div class="bme-config-row">
<label for="bme-planner-mt">最大 Token 数</label>
<input
id="bme-planner-mt"
class="bme-config-input"
type="text"
placeholder="留空则不限制"
/>
</div>
</div>
</div>
<div class="bme-config-card">
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">提示词 · 模板</div>
<div class="bme-config-card-subtitle">
模板保存的是当前提示词块列表;切换模板会覆盖当前编辑中的块。
</div>
</div>
</div>
<div class="bme-config-row">
<label for="bme-planner-tpl-select">活动模板</label>
<select id="bme-planner-tpl-select" class="bme-config-input">
<option value="">-- 选择模板 --</option>
</select>
<div class="bme-config-help">
现在每个规划预设都可以同时携带自己的生成参数和 Prompt block。ENA 这里仍然只负责连接配置、上下文来源、输出过滤和调试日志。
</div>
<div class="bme-config-actions">
<button
class="bme-config-secondary-btn"
id="bme-planner-tpl-save"
id="bme-planner-open-task-presets"
type="button"
>
<i class="fa-solid fa-floppy-disk"></i>
<span>覆盖保存</span>
</button>
<button
class="bme-config-secondary-btn"
id="bme-planner-tpl-saveas"
type="button"
>
<i class="fa-solid fa-file-circle-plus"></i>
<span>另存为</span>
</button>
<button
class="bme-config-secondary-btn bme-config-danger-btn"
id="bme-planner-tpl-delete"
type="button"
>
<i class="fa-solid fa-trash-can"></i>
<span>删除</span>
</button>
</div>
<div class="bme-planner-undo-bar" id="bme-planner-tpl-undo" hidden>
<span
>模板 <strong id="bme-planner-tpl-undo-name"></strong> 已删除</span
>
<button
class="bme-config-secondary-btn"
id="bme-planner-tpl-undo-btn"
type="button"
>
撤销
</button>
</div>
</div>
<div class="bme-config-card">
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">提示词 · 块编排</div>
<div class="bme-config-card-subtitle">
每个块会作为一条独立消息发送给规划 LLM。系统会在块之后自动追加角色卡、世界书、BME 结构化记忆、聊天历史和历史 plot。
</div>
</div>
</div>
<div id="bme-planner-prompt-list" class="bme-planner-prompt-list"></div>
<div
class="bme-planner-prompt-empty"
id="bme-planner-prompt-empty"
hidden
>
暂无提示词块
</div>
<div class="bme-config-actions">
<button
class="bme-config-secondary-btn"
id="bme-planner-add-prompt"
type="button"
>
<i class="fa-solid fa-plus"></i>
<span>添加块</span>
</button>
<button
class="bme-config-secondary-btn bme-config-danger-btn"
id="bme-planner-reset-prompt"
type="button"
>
<i class="fa-solid fa-rotate-left"></i>
<span>恢复默认</span>
<i class="fa-solid fa-list-check"></i>
<span>打开任务预设 · 规划</span>
</button>
</div>
</div>

View File

@@ -173,6 +173,7 @@ const GRAPH_WRITE_ACTION_IDS = [
const TASK_PROFILE_GENERATION_GROUPS = [
{
title: "API 配置",
excludeTaskTypes: ["planner"],
fields: [
{
key: "llm_preset",
@@ -8412,6 +8413,8 @@ function _getTaskProfileWorkspaceState(settings = _getSettings?.() || {}) {
currentGlobalRegexRuleId = globalRegexRules[0]?.id || "";
}
const builtinBlockDefinitions = getBuiltinBlockDefinitions(currentTaskProfileTaskType);
return {
settings,
taskProfiles,
@@ -8431,7 +8434,7 @@ function _getTaskProfileWorkspaceState(settings = _getSettings?.() || {}) {
regexRules.find((rule) => rule.id === currentTaskProfileRuleId) || null,
selectedGlobalRegexRule:
globalRegexRules.find((rule) => rule.id === currentGlobalRegexRuleId) || null,
builtinBlockDefinitions: getBuiltinBlockDefinitions(),
builtinBlockDefinitions,
runtimeDebug,
};
}
@@ -9626,11 +9629,11 @@ async function _handleTaskProfileWorkspaceClick(event) {
document.getElementById("bme-task-profile-import-all")?.click();
return;
case "restore-all-profiles": {
const taskTypes = getTaskTypeOptions().map((t) => t.id);
const confirmed = window.confirm(
"这会将全部 6 个任务的默认预设恢复为出厂状态。已保存的自定义预设不受影响,通用正则规则也不受影响。是否继续?",
`这会将全部 ${taskTypes.length} 个任务的默认预设恢复为出厂状态。已保存的自定义预设不受影响,通用正则规则也不受影响。是否继续?`,
);
if (!confirmed) return;
const taskTypes = getTaskTypeOptions().map((t) => t.id);
let restored = state.taskProfiles;
const extraPatch = {};
for (const tt of taskTypes) {
@@ -9727,6 +9730,7 @@ function _renderTaskProfileWorkspace(state) {
state.taskTypeOptions.find((item) => item.id === state.taskType) ||
state.taskTypeOptions[0];
const profileUpdatedAt = _formatTaskProfileTime(state.profile.updatedAt);
const totalTaskTypes = Array.isArray(state.taskTypeOptions) ? state.taskTypeOptions.length : 0;
return `
<div class="bme-task-shell">
@@ -9757,10 +9761,10 @@ function _renderTaskProfileWorkspace(state) {
</div>
</div>
<div class="bme-task-action-bar-right">
<button class="bme-config-secondary-btn bme-bulk-profile-btn bme-task-btn-danger" data-task-action="restore-all-profiles" type="button" title="恢复全部 6 个任务的默认预设">
<button class="bme-config-secondary-btn bme-bulk-profile-btn bme-task-btn-danger" data-task-action="restore-all-profiles" type="button" title="恢复全部 ${_escAttr(String(totalTaskTypes || 0))} 个任务的默认预设">
<i class="fa-solid fa-arrows-rotate"></i><span>恢复全部</span>
</button>
<button class="bme-config-secondary-btn bme-bulk-profile-btn" data-task-action="export-all-profiles" type="button" title="导出全部 6 个任务预设">
<button class="bme-config-secondary-btn bme-bulk-profile-btn" data-task-action="export-all-profiles" type="button" title="导出全部 ${_escAttr(String(totalTaskTypes || 0))} 个任务预设">
<i class="fa-solid fa-file-export"></i><span>导出全部</span>
</button>
<button class="bme-config-secondary-btn bme-bulk-profile-btn" data-task-action="import-all-profiles" type="button" title="导入全部预设(覆盖当前)">
@@ -9881,9 +9885,12 @@ function _renderTaskPromptTab(state) {
function _renderTaskGenerationTab(state) {
const inputGroups = TASK_PROFILE_INPUT_GROUPS[state.taskType] || [];
const generationGroups = TASK_PROFILE_GENERATION_GROUPS.filter(
(group) => !Array.isArray(group.excludeTaskTypes) || !group.excludeTaskTypes.includes(state.taskType),
);
return `
<div class="bme-task-tab-body">
${TASK_PROFILE_GENERATION_GROUPS.map(
${generationGroups.map(
(group) => `
<div class="bme-config-card">
<div class="bme-config-card-head">