diff --git a/ena-planner/ena-planner.js b/ena-planner/ena-planner.js index 016841c..9fd1ce6 100644 --- a/ena-planner/ena-planner.js +++ b/ena-planner/ena-planner.js @@ -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() + ? `\n${String(context.bmeMemory || '').trim()}\n` + : ''; + 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: `\n${bmeMemory}\n` }); - } - - // 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(); } diff --git a/prompting/prompt-profiles.js b/prompting/prompt-profiles.js index 18a7ae2..638bc35 100644 --- a/prompting/prompt-profiles.js +++ b/prompting/prompt-profiles.js @@ -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: "注入最近的 历史规划块,帮助保持剧情推进连续性。", + 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 = {}) { diff --git a/tests/task-profile-migration.mjs b/tests/task-profile-migration.mjs index 785c594..419c11b 100644 --- a/tests/task-profile-migration.mjs +++ b/tests/task-profile-migration.mjs @@ -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( { diff --git a/ui/panel-ena-sections.js b/ui/panel-ena-sections.js index d6b33f7..d440e21 100644 --- a/ui/panel-ena-sections.js +++ b/ui/panel-ena-sections.js @@ -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); diff --git a/ui/panel.html b/ui/panel.html index b83898a..4bb6424 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -3031,170 +3031,23 @@
-
规划 LLM · 生成参数
+
生成参数 · Prompt 编排
- 流式输出用于实时预览,数值留空表示不覆盖渠道默认。 + 这两部分现在统一收敛到任务预设里的“规划”任务,避免 ENA 面板和任务面板各维护一套配置。
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
-
提示词 · 模板
-
- 模板保存的是当前提示词块列表;切换模板会覆盖当前编辑中的块。 -
-
-
-
- - +
+ 现在每个规划预设都可以同时携带自己的生成参数和 Prompt block。ENA 这里仍然只负责连接配置、上下文来源、输出过滤和调试日志。
- - -
- -
- -
-
-
-
提示词 · 块编排
-
- 每个块会作为一条独立消息发送给规划 LLM。系统会在块之后自动追加:角色卡、世界书、BME 结构化记忆、聊天历史和历史 plot。 -
-
-
-
- -
- -
diff --git a/ui/panel.js b/ui/panel.js index 4f0fff8..229a154 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -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 `
@@ -9757,10 +9761,10 @@ function _renderTaskProfileWorkspace(state) {
- -