diff --git a/compressor.js b/compressor.js index 351ae14..7be9800 100644 --- a/compressor.js +++ b/compressor.js @@ -4,6 +4,8 @@ import { createNode, addNode, createEdge, addEdge, getActiveNodes, getNode } from './graph.js'; import { callLLMForJSON } from './llm.js'; import { embedText } from './embedding.js'; +import { buildTaskPrompt } from './prompt-builder.js'; +import { applyTaskRegex } from './task-regex.js'; import { isDirectVectorConfig } from './vector-index.js'; function createAbortError(message = '操作已终止') { @@ -28,7 +30,7 @@ function throwIfAborted(signal) { * @param {boolean} [params.force=false] - 忽略阈值强制压缩 * @returns {Promise<{created: number, archived: number}>} */ -export async function compressType({ graph, typeDef, embeddingConfig, force = false, customPrompt, signal }) { +export async function compressType({ graph, typeDef, embeddingConfig, force = false, customPrompt, signal, settings = {} }) { const compression = typeDef.compression; if (!compression || compression.mode !== 'hierarchical') { return { created: 0, archived: 0 }; @@ -48,6 +50,7 @@ export async function compressType({ graph, typeDef, embeddingConfig, force = fa force, customPrompt, signal, + settings, }); totalCreated += result.created; @@ -63,7 +66,7 @@ export async function compressType({ graph, typeDef, embeddingConfig, force = fa /** * 压缩特定层级的节点 */ -async function compressLevel({ graph, typeDef, level, embeddingConfig, force, customPrompt, signal }) { +async function compressLevel({ graph, typeDef, level, embeddingConfig, force, customPrompt, signal, settings = {} }) { const compression = typeDef.compression; throwIfAborted(signal); @@ -95,7 +98,7 @@ async function compressLevel({ graph, typeDef, level, embeddingConfig, force, cu if (batch.length < 2) break; // 至少 2 个才压缩 // 调用 LLM 总结 - const summaryResult = await summarizeBatch(batch, typeDef, customPrompt, signal); + const summaryResult = await summarizeBatch(batch, typeDef, customPrompt, signal, settings); if (!summaryResult) continue; // 创建压缩节点 @@ -168,7 +171,7 @@ function migrateBatchEdges(graph, batch, compressedNode) { /** * 调用 LLM 总结一批节点 */ -async function summarizeBatch(nodes, typeDef, customPrompt, signal) { +async function summarizeBatch(nodes, typeDef, customPrompt, signal, settings = {}) { const nodeDescriptions = nodes.map((n, i) => { const fieldsStr = Object.entries(n.fields) .filter(([_, v]) => v) @@ -179,7 +182,18 @@ async function summarizeBatch(nodes, typeDef, customPrompt, signal) { const instruction = typeDef.compression.instruction || '将以下节点压缩总结为一条精炼记录。'; - const systemPrompt = customPrompt || [ + const compressPromptBuild = buildTaskPrompt(settings, 'compress', { + taskName: 'compress', + nodeContent: nodeDescriptions, + candidateNodes: nodeDescriptions, + currentRange: `${nodes[0]?.seq ?? '?'} ~ ${nodes[nodes.length - 1]?.seq ?? '?'}`, + graphStats: `node_count=${nodes.length}, node_type=${typeDef.id}`, + }); + const systemPrompt = applyTaskRegex( + settings, + 'compress', + 'finalPrompt', + compressPromptBuild.systemPrompt || customPrompt || [ '你是一个记忆压缩器。将多个同类型节点总结为一条更高层级的压缩节点。', instruction, '', @@ -190,11 +204,19 @@ async function summarizeBatch(nodes, typeDef, customPrompt, signal) { '- 保留关键信息:因果关系、不可逆结果、未解决伏笔', '- 去除重复和低信息密度内容', '- 压缩后文本应精炼,目标 150 字左右', - ].join('\n'); + ].join('\n'), + ); const userPrompt = `请压缩以下 ${nodes.length} 个 "${typeDef.label}" 节点:\n\n${nodeDescriptions}`; - return await callLLMForJSON({ systemPrompt, userPrompt, maxRetries: 1, signal }); + return await callLLMForJSON({ + systemPrompt, + userPrompt, + maxRetries: 1, + signal, + taskType: 'compress', + additionalMessages: compressPromptBuild.customMessages || [], + }); } /** @@ -206,14 +228,14 @@ async function summarizeBatch(nodes, typeDef, customPrompt, signal) { * @param {boolean} [force=false] * @returns {Promise<{created: number, archived: number}>} */ -export async function compressAll(graph, schema, embeddingConfig, force = false, customPrompt, signal) { +export async function compressAll(graph, schema, embeddingConfig, force = false, customPrompt, signal, settings = {}) { let totalCreated = 0; let totalArchived = 0; for (const typeDef of schema) { throwIfAborted(signal); if (typeDef.compression?.mode === 'hierarchical') { - const result = await compressType({ graph, typeDef, embeddingConfig, force, customPrompt, signal }); + const result = await compressType({ graph, typeDef, embeddingConfig, force, customPrompt, signal, settings }); totalCreated += result.created; totalArchived += result.archived; } diff --git a/consolidator.js b/consolidator.js index 925c9a1..0b48dab 100644 --- a/consolidator.js +++ b/consolidator.js @@ -5,6 +5,8 @@ import { embedBatch, searchSimilar } from './embedding.js'; import { addEdge, createEdge, getActiveNodes, getNode } from './graph.js'; import { callLLMForJSON } from './llm.js'; +import { buildTaskPrompt } from './prompt-builder.js'; +import { applyTaskRegex } from './task-regex.js'; import { buildNodeVectorText, findSimilarNodesByText, @@ -106,6 +108,7 @@ export async function consolidateMemories({ options = {}, customPrompt, signal, + settings = {}, }) { const neighborCount = options.neighborCount ?? 5; const conflictThreshold = options.conflictThreshold ?? 0.85; @@ -268,12 +271,25 @@ export async function consolidateMemories({ const userPrompt = userPromptSections.join('\n\n'); let decision; + const consolidationPromptBuild = buildTaskPrompt(settings, 'consolidation', { + taskName: 'consolidation', + candidateNodes: userPrompt, + candidateText: userPrompt, + graphStats: `new_entries=${newEntries.length}, threshold=${conflictThreshold}`, + }); try { decision = await callLLMForJSON({ - systemPrompt: customPrompt || CONSOLIDATION_SYSTEM_PROMPT, + systemPrompt: applyTaskRegex( + settings, + 'consolidation', + 'finalPrompt', + consolidationPromptBuild.systemPrompt || customPrompt || CONSOLIDATION_SYSTEM_PROMPT, + ), userPrompt, maxRetries: 1, signal, + taskType: 'consolidation', + additionalMessages: consolidationPromptBuild.customMessages || [], }); } catch (e) { if (isAbortError(e)) throw e; diff --git a/extractor.js b/extractor.js index c8b5547..f4f9d9c 100644 --- a/extractor.js +++ b/extractor.js @@ -19,6 +19,8 @@ import { ensureEventTitle, getNodeDisplayName, } from "./node-labels.js"; +import { buildTaskPrompt } from "./prompt-builder.js"; +import { applyTaskRegex } from "./task-regex.js"; import { RELATION_TYPES } from "./schema.js"; import { buildNodeVectorText, @@ -68,6 +70,7 @@ export async function extractMemories({ embeddingConfig, extractPrompt, signal = undefined, + settings = {}, }) { throwIfAborted(signal); if (!messages || messages.length === 0) { @@ -110,9 +113,29 @@ export async function extractMemories({ // 构建 Schema 描述 const schemaDescription = buildSchemaDescription(schema); + const currentRange = + messages.length > 0 + ? `${messages[0]?.seq ?? "?"} ~ ${messages[messages.length - 1]?.seq ?? "?"}` + : ""; + + const promptBuild = buildTaskPrompt(settings, "extract", { + taskName: "extract", + schema: schemaDescription, + schemaDescription, + recentMessages: dialogueText, + dialogueText, + graphStats: graphOverview, + graphOverview, + currentRange, + }); // 系统提示词 - const systemPrompt = extractPrompt || buildDefaultExtractPrompt(schema); + const systemPrompt = applyTaskRegex( + settings, + "extract", + "finalPrompt", + promptBuild.systemPrompt || extractPrompt || buildDefaultExtractPrompt(schema), + ); // 用户提示词 const userPrompt = [ @@ -134,6 +157,8 @@ export async function extractMemories({ userPrompt, maxRetries: 2, signal, + taskType: "extract", + additionalMessages: promptBuild.customMessages || [], }); throwIfAborted(signal); @@ -566,7 +591,7 @@ function buildDefaultExtractPrompt(schema) { * @param {number} params.currentSeq * @returns {Promise} */ -export async function generateSynopsis({ graph, schema, currentSeq, customPrompt, signal }) { +export async function generateSynopsis({ graph, schema, currentSeq, customPrompt, signal, settings = {} }) { const eventNodes = getActiveNodes(graph, "event").sort( (a, b) => a.seq - b.seq, ); @@ -587,12 +612,26 @@ export async function generateSynopsis({ graph, schema, currentSeq, customPrompt .map((n) => `${n.fields.title}: ${n.fields.status || "active"}`) .join("; "); - const result = await callLLMForJSON({ - systemPrompt: customPrompt || [ + const synopsisPromptBuild = buildTaskPrompt(settings, "synopsis", { + taskName: "synopsis", + eventSummary: eventSummaries, + characterSummary: charSummary || "(无)", + threadSummary: threadSummary || "(无)", + graphStats: `event=${eventNodes.length}, character=${characterNodes.length}, thread=${threadNodes.length}`, + }); + const synopsisSystemPrompt = applyTaskRegex( + settings, + "synopsis", + "finalPrompt", + synopsisPromptBuild.systemPrompt || customPrompt || [ "你是故事概要生成器。根据事件线、角色和主线生成简洁的前情提要。", '输出 JSON:{"summary": "前情提要文本(200字以内)"}', "要求:涵盖核心冲突、关键转折、主要角色当前状态。", ].join("\n"), + ); + + const result = await callLLMForJSON({ + systemPrompt: synopsisSystemPrompt, userPrompt: [ "## 事件时间线", eventSummaries, @@ -605,6 +644,8 @@ export async function generateSynopsis({ graph, schema, currentSeq, customPrompt ].join("\n"), maxRetries: 1, signal, + taskType: "synopsis", + additionalMessages: synopsisPromptBuild.customMessages || [], }); if (!result?.summary) return; @@ -636,7 +677,7 @@ export async function generateSynopsis({ graph, schema, currentSeq, customPrompt } } -export async function generateReflection({ graph, currentSeq, customPrompt, signal }) { +export async function generateReflection({ graph, currentSeq, customPrompt, signal, settings = {} }) { const recentEvents = getActiveNodes(graph, "event") .sort((a, b) => b.seq - a.seq) .slice(0, 6) @@ -675,8 +716,19 @@ export async function generateReflection({ graph, currentSeq, customPrompt, sign .map((e) => `${e.fromId} -> ${e.toId} (${e.relation})`) .join("\n"); - const result = await callLLMForJSON({ - systemPrompt: customPrompt || [ + const reflectionPromptBuild = buildTaskPrompt(settings, "reflection", { + taskName: "reflection", + eventSummary, + characterSummary: characterSummary || "(无)", + threadSummary: threadSummary || "(无)", + contradictionSummary: contradictionSummary || "(无)", + graphStats: `event=${recentEvents.length}, character=${recentCharacters.length}, thread=${recentThreads.length}`, + }); + const reflectionSystemPrompt = applyTaskRegex( + settings, + "reflection", + "finalPrompt", + reflectionPromptBuild.systemPrompt || customPrompt || [ "你是 RP 长期记忆系统的反思生成器。", '输出严格 JSON:{"insight":"...","trigger":"...","suggestion":"...","importance":1-10}', "insight 应总结最近情节中最值得长期保留的变化、关系趋势或潜在线索。", @@ -684,6 +736,10 @@ export async function generateReflection({ graph, currentSeq, customPrompt, sign "suggestion 给出后续检索或叙事上值得关注的提示。", "不要复述全部事件,要提炼高层结论。", ].join("\n"), + ); + + const result = await callLLMForJSON({ + systemPrompt: reflectionSystemPrompt, userPrompt: [ "## 最近事件", eventSummary, @@ -699,6 +755,8 @@ export async function generateReflection({ graph, currentSeq, customPrompt, sign ].join("\n"), maxRetries: 1, signal, + taskType: "reflection", + additionalMessages: reflectionPromptBuild.customMessages || [], }); if (!result?.insight) return null; diff --git a/generation-options.js b/generation-options.js new file mode 100644 index 0000000..835e40a --- /dev/null +++ b/generation-options.js @@ -0,0 +1,224 @@ +// ST-BME: 任务级生成参数过滤层(Phase 1) + +import { getActiveTaskProfile } from "./prompt-profiles.js"; + +const SUPPORTED_FIELDS = [ + "max_context_tokens", + "max_completion_tokens", + "reply_count", + "stream", + "temperature", + "top_p", + "top_k", + "top_a", + "min_p", + "seed", + "frequency_penalty", + "presence_penalty", + "repetition_penalty", + "squash_system_messages", + "reasoning_effort", + "request_thoughts", + "enable_function_calling", + "enable_web_search", + "character_name_prefix", + "wrap_user_messages_in_quotes", +]; + +const CONSERVATIVE_ALLOWLIST = new Set([ + "temperature", + "top_p", + "seed", + "max_completion_tokens", + "stream", + "frequency_penalty", + "presence_penalty", +]); + +const OPENAI_COMPAT_ALLOWLIST = new Set([ + "max_completion_tokens", + "stream", + "temperature", + "top_p", + "seed", + "frequency_penalty", + "presence_penalty", + "reasoning_effort", + "request_thoughts", + "enable_function_calling", + "enable_web_search", + "wrap_user_messages_in_quotes", +]); + +const BOOLEAN_FIELDS = new Set([ + "stream", + "squash_system_messages", + "request_thoughts", + "enable_function_calling", + "enable_web_search", + "wrap_user_messages_in_quotes", +]); + +const INTEGER_FIELDS = new Set([ + "max_context_tokens", + "max_completion_tokens", + "reply_count", + "top_k", + "seed", +]); + +const FLOAT_FIELDS = new Set([ + "temperature", + "top_p", + "top_a", + "min_p", + "frequency_penalty", + "presence_penalty", + "repetition_penalty", +]); + +const REASONING_EFFORT_VALUES = new Set(["low", "medium", "high", "minimal"]); + +function resolveCapabilityMode(context = {}) { + const normalizedMode = String(context.mode || "").trim().toLowerCase(); + if (normalizedMode === "dedicated-openai-compatible") { + return "openai-compatible"; + } + + const normalizedSource = String(context.source || "").trim().toLowerCase(); + if ( + normalizedSource && + ["openai", "openrouter", "mistral", "cohere", "custom", "vllm"].includes( + normalizedSource, + ) + ) { + return "openai-compatible"; + } + + return "conservative"; +} + +function getAllowlistForCapability(capabilityMode) { + if (capabilityMode === "openai-compatible") { + return OPENAI_COMPAT_ALLOWLIST; + } + return CONSERVATIVE_ALLOWLIST; +} + +function normalizeByField(field, rawValue) { + if (rawValue == null || rawValue === "") { + return { ok: false, reason: "empty_value" }; + } + + if (BOOLEAN_FIELDS.has(field)) { + return { ok: true, value: Boolean(rawValue) }; + } + + if (INTEGER_FIELDS.has(field)) { + const parsed = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsed)) { + return { ok: false, reason: "invalid_number" }; + } + if (parsed < 0) { + return { ok: false, reason: "invalid_range" }; + } + if (field === "reply_count" && parsed < 1) { + return { ok: false, reason: "invalid_range" }; + } + return { ok: true, value: parsed }; + } + + if (FLOAT_FIELDS.has(field)) { + const parsed = Number.parseFloat(rawValue); + if (!Number.isFinite(parsed)) { + return { ok: false, reason: "invalid_number" }; + } + + if (field === "temperature" && (parsed < 0 || parsed > 2)) { + return { ok: false, reason: "invalid_range" }; + } + if ( + ["top_p", "top_a", "min_p"].includes(field) && + (parsed < 0 || parsed > 1) + ) { + return { ok: false, reason: "invalid_range" }; + } + if ( + ["frequency_penalty", "presence_penalty", "repetition_penalty"].includes( + field, + ) && + (parsed < -2 || parsed > 2) + ) { + return { ok: false, reason: "invalid_range" }; + } + + return { ok: true, value: parsed }; + } + + if (field === "reasoning_effort") { + const normalized = String(rawValue || "") + .trim() + .toLowerCase(); + if (!normalized) { + return { ok: false, reason: "empty_value" }; + } + if (!REASONING_EFFORT_VALUES.has(normalized)) { + return { ok: false, reason: "invalid_value" }; + } + return { ok: true, value: normalized }; + } + + if (field === "character_name_prefix") { + return { ok: true, value: String(rawValue || "").trim() }; + } + + return { ok: true, value: rawValue }; +} + +export function resolveTaskGenerationOptions( + settings = {}, + taskType, + fallback = {}, + capabilityContext = {}, +) { + const profile = getActiveTaskProfile(settings, taskType); + const generation = { ...(profile?.generation || {}) }; + const filtered = {}; + const removed = []; + const capabilityMode = resolveCapabilityMode(capabilityContext); + const allowlist = getAllowlistForCapability(capabilityMode); + + for (const field of SUPPORTED_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(generation, field)) continue; + const rawValue = generation[field]; + if (rawValue == null || rawValue === "") continue; + + if (!allowlist.has(field)) { + removed.push({ field, reason: "capability_filtered", capabilityMode }); + continue; + } + + const normalized = normalizeByField(field, rawValue); + if (!normalized.ok) { + removed.push({ field, reason: normalized.reason, capabilityMode }); + continue; + } + + filtered[field] = normalized.value; + } + + if (!Number.isFinite(filtered.max_completion_tokens)) { + const fallbackTokens = Number.parseInt(fallback.max_completion_tokens, 10); + if (Number.isFinite(fallbackTokens) && fallbackTokens > 0) { + filtered.max_completion_tokens = fallbackTokens; + } + } + + return { + profile, + generation, + filtered, + removed, + capabilityMode, + }; +} diff --git a/index.js b/index.js index 867a8c1..c193e66 100644 --- a/index.js +++ b/index.js @@ -59,6 +59,7 @@ import { testVectorConnection, validateVectorConfig, } from "./vector-index.js"; +import { createDefaultTaskProfiles, migrateLegacyTaskProfiles } from "./prompt-profiles.js"; // 操控面板模块(动态加载,防止加载失败崩溃整个扩展) let _panelModule = null; @@ -120,6 +121,13 @@ const defaultSettings = { // 自定义提示词 extractPrompt: "", + recallPrompt: "", + consolidationPrompt: "", + compressPrompt: "", + synopsisPrompt: "", + reflectionPrompt: "", + taskProfilesVersion: 1, + taskProfiles: createDefaultTaskProfiles(), // ====== v2 增强设置 ====== @@ -631,6 +639,9 @@ function getSettings() { ...defaultSettings, ...(extension_settings[MODULE_NAME] || {}), }; + const migrated = migrateLegacyTaskProfiles(mergedSettings); + mergedSettings.taskProfilesVersion = migrated.taskProfilesVersion; + mergedSettings.taskProfiles = migrated.taskProfiles; extension_settings[MODULE_NAME] = mergedSettings; return mergedSettings; } @@ -1669,7 +1680,7 @@ async function handleExtractionSuccess( neighborCount: settings.consolidationNeighborCount, conflictThreshold: settings.consolidationThreshold, }, - customPrompt: settings.consolidationPrompt || undefined, + settings, signal, }); postProcessArtifacts.push("consolidation"); @@ -1688,7 +1699,7 @@ async function handleExtractionSuccess( graph: currentGraph, schema: getSchema(), currentSeq: endIdx, - customPrompt: settings.synopsisPrompt || undefined, + settings, signal, }); postProcessArtifacts.push("synopsis"); @@ -1706,7 +1717,7 @@ async function handleExtractionSuccess( await generateReflection({ graph: currentGraph, currentSeq: endIdx, - customPrompt: settings.reflectionPrompt || undefined, + settings, signal, }); postProcessArtifacts.push("reflection"); @@ -1735,8 +1746,9 @@ async function handleExtractionSuccess( getSchema(), getEmbeddingConfig(), false, - settings.compressPrompt || undefined, + undefined, signal, + settings, ); if (compressionResult.created > 0 || compressionResult.archived > 0) { postProcessArtifacts.push("compression"); @@ -2193,7 +2205,8 @@ async function executeExtractionBatch({ lastProcessedSeq: lastProcessed, schema: getSchema(), embeddingConfig: getEmbeddingConfig(), - extractPrompt: settings.extractPrompt || undefined, + extractPrompt: undefined, + settings, signal, }); @@ -2795,6 +2808,7 @@ async function runRecall(options = {}) { embeddingConfig: getEmbeddingConfig(), schema: getSchema(), signal: recallSignal, + settings, options: { topK: settings.recallTopK, maxRecallNodes: settings.recallMaxNodes, @@ -2803,7 +2817,7 @@ async function runRecall(options = {}) { enableGraphDiffusion: settings.recallEnableGraphDiffusion, diffusionTopK: settings.recallDiffusionTopK, llmCandidatePool: settings.recallLlmCandidatePool, - recallPrompt: settings.recallPrompt || undefined, + recallPrompt: undefined, weights: { graphWeight: settings.graphWeight, vectorWeight: settings.vectorWeight, @@ -3065,6 +3079,9 @@ async function onManualCompress() { getSchema(), getEmbeddingConfig(), false, + undefined, + undefined, + getSettings(), ); await recordGraphMutation({ beforeSnapshot, @@ -3353,6 +3370,8 @@ async function onManualSynopsis() { graph: currentGraph, schema: getSchema(), currentSeq: getCurrentChatSeq(), + customPrompt: undefined, + settings: getSettings(), }); await recordGraphMutation({ beforeSnapshot, @@ -3377,6 +3396,8 @@ async function onManualEvolve() { graph: currentGraph, newNodeIds: candidateIds, embeddingConfig: getEmbeddingConfig(), + customPrompt: undefined, + settings: getSettings(), options: { neighborCount: getSettings().consolidationNeighborCount, conflictThreshold: getSettings().consolidationThreshold, diff --git a/llm.js b/llm.js index f1ddd87..8eb87a3 100644 --- a/llm.js +++ b/llm.js @@ -4,6 +4,7 @@ import { getRequestHeaders } from "../../../../script.js"; import { extension_settings } from "../../../extensions.js"; import { chat_completion_sources, sendOpenAIRequest } from "../../../openai.js"; +import { resolveTaskGenerationOptions } from "./generation-options.js"; const MODULE_NAME = "st_bme"; const LLM_REQUEST_TIMEOUT_MS = 300000; @@ -203,6 +204,7 @@ function buildJsonAttemptMessages( userPrompt, attempt, reason = "", + additionalMessages = [], ) { const systemParts = [ systemPrompt, @@ -223,10 +225,23 @@ function buildJsonAttemptMessages( userParts.push("请直接输出紧凑 JSON 对象,不要包含任何额外文本。"); } - return [ - { role: "system", content: systemParts.join("\n\n") }, - { role: "user", content: userParts.join("\n\n") }, - ]; + const messages = []; + const normalizedSystemPrompt = systemParts.join("\n\n").trim(); + if (normalizedSystemPrompt) { + messages.push({ role: "system", content: normalizedSystemPrompt }); + } + + for (const message of additionalMessages || []) { + if (!message || typeof message !== "object") continue; + const role = String(message.role || "").trim().toLowerCase(); + const content = String(message.content || "").trim(); + if (!content) continue; + if (!["system", "user", "assistant"].includes(role)) continue; + messages.push({ role, content }); + } + + messages.push({ role: "user", content: userParts.join("\n\n") }); + return messages; } async function fetchWithTimeout( @@ -294,10 +309,33 @@ function isAbortError(error) { async function callDedicatedOpenAICompatible( messages, - { signal, jsonMode = false, maxCompletionTokens = null } = {}, + { + signal, + jsonMode = false, + maxCompletionTokens = null, + taskType = "", + } = {}, ) { const config = getMemoryLLMConfig(); - if (!hasDedicatedLLMConfig(config)) { + const settings = extension_settings[MODULE_NAME] || {}; + const hasDedicatedConfig = hasDedicatedLLMConfig(config); + const generationResolved = taskType + ? resolveTaskGenerationOptions(settings, taskType, { + max_completion_tokens: Number.isFinite(maxCompletionTokens) + ? maxCompletionTokens + : jsonMode + ? DEFAULT_JSON_COMPLETION_TOKENS + : DEFAULT_TEXT_COMPLETION_TOKENS, + }, { + mode: hasDedicatedConfig + ? "dedicated-openai-compatible" + : "sillytavern-current-model", + }) + : { + filtered: {}, + removed: [], + }; + if (!hasDedicatedConfig) { const payload = await sendOpenAIRequest( "quiet", messages, @@ -321,6 +359,12 @@ async function callDedicatedOpenAICompatible( : jsonMode ? DEFAULT_JSON_COMPLETION_TOKENS : DEFAULT_TEXT_COMPLETION_TOKENS; + const filteredGeneration = generationResolved.filtered || {}; + const resolvedCompletionTokens = Number.isFinite( + filteredGeneration.max_completion_tokens, + ) + ? filteredGeneration.max_completion_tokens + : completionTokens; const body = { chat_completion_source: chat_completion_sources.CUSTOM, @@ -332,12 +376,37 @@ async function callDedicatedOpenAICompatible( : "", model: config.model, messages, - temperature: jsonMode ? 0 : 0.2, - max_tokens: completionTokens, - max_completion_tokens: completionTokens, - stream: false, + temperature: filteredGeneration.temperature ?? (jsonMode ? 0 : 0.2), + max_tokens: resolvedCompletionTokens, + max_completion_tokens: resolvedCompletionTokens, + stream: filteredGeneration.stream ?? false, }; + const optionalGenerationFields = [ + "top_p", + "top_k", + "top_a", + "min_p", + "seed", + "frequency_penalty", + "presence_penalty", + "repetition_penalty", + "squash_system_messages", + "reasoning_effort", + "request_thoughts", + "enable_function_calling", + "enable_web_search", + "wrap_user_messages_in_quotes", + "reply_count", + "max_context_tokens", + "character_name_prefix", + ]; + + for (const field of optionalGenerationFields) { + if (!Object.prototype.hasOwnProperty.call(filteredGeneration, field)) continue; + body[field] = filteredGeneration[field]; + } + if (jsonMode) { body.custom_include_body = buildYamlObject({ response_format: { @@ -424,6 +493,8 @@ export async function callLLMForJSON({ userPrompt, maxRetries = 2, signal, + taskType = "", + additionalMessages = [], } = {}) { let lastFailureReason = ""; @@ -434,10 +505,12 @@ export async function callLLMForJSON({ userPrompt, attempt, lastFailureReason, + additionalMessages, ); const response = await callDedicatedOpenAICompatible(messages, { signal, jsonMode: true, + taskType, maxCompletionTokens: attempt === 0 ? DEFAULT_JSON_COMPLETION_TOKENS diff --git a/panel.html b/panel.html index 4ba547c..e024fe0 100644 --- a/panel.html +++ b/panel.html @@ -110,7 +110,7 @@ type="button" > - 系统提示词 + 任务预设 - - - - - -
-
-
-
智能召回
-
- 用于对候选记忆进行语义判断和精排解释。 -
-
-
- - -
-
- -
- -
-
-
-
记忆整合
-
- 用于分析新记忆与旧记忆的冲突、去重和进化关系。 -
-
-
- - -
-
- -
- -
-
-
-
记忆压缩
-
- 用于合并高层摘要节点并保留关键信息。 -
-
-
- - -
-
- -
- -
-
-
-
全局概要
-
- 用于生成故事主线的阶段性前情提要。 -
-
-
- - -
-
- -
- -
-
-
-
反思生成
-
- 用于抽取长期叙事中的趋势、触发点和建议。 -
-
-
- - -
-
- -
- +
+
{ if (button.dataset.bmeBound === "true") return; @@ -1319,6 +1497,1556 @@ function bindSelectModel(selectId, inputId, settingKey) { element.dataset.bmeBound = "true"; } +function _bindTaskProfileWorkspace() { + const workspace = document.getElementById("bme-task-profile-workspace"); + const importInput = document.getElementById("bme-task-profile-import"); + if (!workspace) return; + + if (workspace.dataset.bmeBound !== "true") { + workspace.addEventListener("click", (event) => { + void _handleTaskProfileWorkspaceClick(event); + }); + workspace.addEventListener("input", (event) => { + _handleTaskProfileWorkspaceInput(event); + }); + workspace.addEventListener("change", (event) => { + _handleTaskProfileWorkspaceChange(event); + }); + workspace.dataset.bmeBound = "true"; + } + + if (importInput && importInput.dataset.bmeBound !== "true") { + importInput.addEventListener("change", async () => { + const file = importInput.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const settings = _getSettings?.() || {}; + const imported = parseImportedTaskProfile( + settings.taskProfiles || {}, + text, + ); + currentTaskProfileTaskType = imported.taskType || currentTaskProfileTaskType; + currentTaskProfileBlockId = imported.profile?.blocks?.[0]?.id || ""; + currentTaskProfileRuleId = + imported.profile?.regex?.localRules?.[0]?.id || ""; + _patchTaskProfiles(imported.taskProfiles); + toastr.success("预设导入成功", "ST-BME"); + } catch (error) { + console.error("[ST-BME] 导入任务预设失败:", error); + toastr.error(`预设导入失败: ${error?.message || error}`, "ST-BME"); + } finally { + importInput.value = ""; + } + }); + importInput.dataset.bmeBound = "true"; + } +} + +function _handleTaskProfileWorkspaceInput(event) { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + if (target.id === "bme-task-profile-name") { + _updateCurrentTaskProfile( + (draft) => { + draft.name = String(target.value || "").trim() || draft.name; + }, + { refresh: false }, + ); + return; + } + + if (target.matches("[data-block-field]")) { + _persistSelectedBlockField(target, false); + return; + } + + if (target.matches("[data-generation-key]")) { + _persistGenerationField(target, false); + return; + } + + if ( + target.matches("[data-regex-rule-field]") || + target.matches("[data-regex-rule-source]") || + target.matches("[data-regex-rule-destination]") + ) { + _persistSelectedRegexRuleField(target, false); + } +} + +function _handleTaskProfileWorkspaceChange(event) { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + + if (target.id === "bme-task-profile-select") { + const settings = _getSettings?.() || {}; + const nextTaskProfiles = setActiveTaskProfileId( + settings.taskProfiles || {}, + currentTaskProfileTaskType, + target.value, + ); + currentTaskProfileBlockId = ""; + currentTaskProfileRuleId = ""; + _patchTaskProfiles(nextTaskProfiles); + return; + } + + if (target.matches("[data-block-field]")) { + _persistSelectedBlockField(target, true); + return; + } + + if (target.matches("[data-generation-key]")) { + _persistGenerationField(target, true); + return; + } + + if (target.matches("[data-regex-field]")) { + _persistRegexConfigField(target, false); + return; + } + + if (target.matches("[data-regex-source]")) { + _persistRegexSourceField(target, false); + return; + } + + if (target.matches("[data-regex-stage]")) { + _persistRegexStageField(target, false); + return; + } + + if ( + target.matches("[data-regex-rule-field]") || + target.matches("[data-regex-rule-source]") || + target.matches("[data-regex-rule-destination]") + ) { + _persistSelectedRegexRuleField(target, true); + } +} + +function _getTaskProfileWorkspaceState(settings = _getSettings?.() || {}) { + const taskProfiles = ensureTaskProfiles(settings); + const taskTypeOptions = getTaskTypeOptions(); + + if (!taskTypeOptions.some((item) => item.id === currentTaskProfileTaskType)) { + currentTaskProfileTaskType = taskTypeOptions[0]?.id || "extract"; + } + + if (!TASK_PROFILE_TABS.some((item) => item.id === currentTaskProfileTabId)) { + currentTaskProfileTabId = TASK_PROFILE_TABS[0]?.id || "prompt"; + } + + const bucket = taskProfiles[currentTaskProfileTaskType] || { + activeProfileId: "default", + profiles: [], + }; + const profile = + bucket.profiles.find((item) => item.id === bucket.activeProfileId) || + bucket.profiles[0] || + null; + const blocks = _sortTaskBlocks(profile?.blocks || []); + const regexRules = Array.isArray(profile?.regex?.localRules) + ? profile.regex.localRules + : []; + + if (!blocks.some((block) => block.id === currentTaskProfileBlockId)) { + currentTaskProfileBlockId = blocks[0]?.id || ""; + } + if (!regexRules.some((rule) => rule.id === currentTaskProfileRuleId)) { + currentTaskProfileRuleId = regexRules[0]?.id || ""; + } + + return { + settings, + taskProfiles, + taskTypeOptions, + taskType: currentTaskProfileTaskType, + taskTabId: currentTaskProfileTabId, + bucket, + profile, + blocks, + selectedBlock: + blocks.find((block) => block.id === currentTaskProfileBlockId) || null, + regexRules, + selectedRule: + regexRules.find((rule) => rule.id === currentTaskProfileRuleId) || null, + builtinBlockDefinitions: getBuiltinBlockDefinitions(), + }; +} + +function _refreshTaskProfileWorkspace(settings = _getSettings?.() || {}) { + const workspace = document.getElementById("bme-task-profile-workspace"); + if (!workspace) return; + + const state = _getTaskProfileWorkspaceState(settings); + workspace.innerHTML = _renderTaskProfileWorkspace(state); +} + +function _patchTaskProfiles(taskProfiles, extraPatch = {}, options = {}) { + return _patchSettings( + { + taskProfilesVersion: 1, + taskProfiles, + ...extraPatch, + }, + { + refreshTaskWorkspace: options.refresh !== false, + }, + ); +} + +async function _handleTaskProfileWorkspaceClick(event) { + const actionEl = event.target.closest("[data-task-action]"); + if (!actionEl) return; + + const action = actionEl.dataset.taskAction || ""; + const state = _getTaskProfileWorkspaceState(); + const selectedProfile = state.profile; + if (!selectedProfile && action !== "switch-task-type") return; + + switch (action) { + case "switch-task-type": + currentTaskProfileTaskType = + actionEl.dataset.taskType || currentTaskProfileTaskType; + currentTaskProfileBlockId = ""; + currentTaskProfileRuleId = ""; + _refreshTaskProfileWorkspace(); + return; + case "switch-task-tab": + currentTaskProfileTabId = + actionEl.dataset.taskTab || currentTaskProfileTabId; + _refreshTaskProfileWorkspace(); + return; + case "select-block": + currentTaskProfileBlockId = actionEl.dataset.blockId || ""; + _refreshTaskProfileWorkspace(); + return; + case "select-regex-rule": + currentTaskProfileRuleId = actionEl.dataset.ruleId || ""; + _refreshTaskProfileWorkspace(); + return; + case "add-custom-block": + _updateCurrentTaskProfile((draft, context) => { + const nextBlock = createCustomPromptBlock(context.taskType, { + name: `自定义块 ${draft.blocks.length + 1}`, + order: draft.blocks.length, + }); + draft.blocks.push(nextBlock); + return { selectBlockId: nextBlock.id }; + }); + return; + case "add-builtin-block": { + const select = document.getElementById("bme-task-builtin-select"); + const sourceKey = String(select?.value || "").trim(); + if (!sourceKey) { + toastr.info("先选择一个内置块来源", "ST-BME"); + return; + } + _updateCurrentTaskProfile((draft, context) => { + const nextBlock = createBuiltinPromptBlock(context.taskType, sourceKey, { + order: draft.blocks.length, + }); + draft.blocks.push(nextBlock); + return { selectBlockId: nextBlock.id }; + }); + return; + } + case "move-block-up": + _moveTaskBlock(actionEl.dataset.blockId, -1); + return; + case "move-block-down": + _moveTaskBlock(actionEl.dataset.blockId, 1); + return; + case "toggle-block-enabled": + _updateCurrentTaskProfile((draft) => { + const blocks = _sortTaskBlocks(draft.blocks); + const block = blocks.find((item) => item.id === actionEl.dataset.blockId); + if (!block) return null; + block.enabled = block.enabled === false; + draft.blocks = _normalizeTaskBlocks(blocks); + return { selectBlockId: block.id }; + }); + return; + case "delete-block": + _deleteTaskBlock(actionEl.dataset.blockId); + return; + case "save-profile": + _patchTaskProfiles(state.taskProfiles, {}, { refresh: true }); + toastr.success("当前预设已保存", "ST-BME"); + return; + case "rename-profile": { + const nameInput = document.getElementById("bme-task-profile-name"); + const nextName = String(nameInput?.value || "").trim(); + if (!nextName) { + toastr.info("预设名称不能为空", "ST-BME"); + return; + } + _updateCurrentTaskProfile((draft) => { + draft.name = nextName; + }); + toastr.success("预设名称已更新", "ST-BME"); + return; + } + case "save-as-profile": { + const suggestedName = `${selectedProfile.name || "预设"} 副本`; + const nextName = window.prompt("请输入新预设名称", suggestedName); + if (nextName == null) return; + const trimmedName = String(nextName).trim(); + if (!trimmedName) { + toastr.info("预设名称不能为空", "ST-BME"); + return; + } + const nextProfile = cloneTaskProfile(selectedProfile, { + taskType: currentTaskProfileTaskType, + name: trimmedName, + }); + currentTaskProfileBlockId = nextProfile.blocks?.[0]?.id || ""; + currentTaskProfileRuleId = nextProfile.regex?.localRules?.[0]?.id || ""; + const nextTaskProfiles = upsertTaskProfile( + state.taskProfiles, + currentTaskProfileTaskType, + nextProfile, + { setActive: true }, + ); + _patchTaskProfiles(nextTaskProfiles); + toastr.success("已另存为新预设", "ST-BME"); + return; + } + case "export-profile": + _downloadTaskProfile(state.taskProfiles, currentTaskProfileTaskType, selectedProfile); + return; + case "import-profile": + document.getElementById("bme-task-profile-import")?.click(); + return; + case "restore-default-profile": { + const confirmed = window.confirm( + "这会重建当前任务的默认预设,并切换到默认预设。是否继续?", + ); + if (!confirmed) return; + const nextTaskProfiles = restoreDefaultTaskProfile( + state.taskProfiles, + currentTaskProfileTaskType, + ); + const legacyField = getLegacyPromptFieldForTask(currentTaskProfileTaskType); + currentTaskProfileBlockId = ""; + currentTaskProfileRuleId = ""; + _patchTaskProfiles( + nextTaskProfiles, + legacyField ? { [legacyField]: "" } : {}, + ); + toastr.success("默认预设已恢复", "ST-BME"); + return; + } + case "add-regex-rule": + _updateCurrentTaskProfile((draft, context) => { + const localRules = Array.isArray(draft.regex?.localRules) + ? draft.regex.localRules + : []; + const nextRule = createLocalRegexRule(context.taskType, { + script_name: `本地规则 ${localRules.length + 1}`, + }); + draft.regex = { + ...(draft.regex || {}), + localRules: [...localRules, nextRule], + }; + return { selectRuleId: nextRule.id }; + }); + return; + case "delete-regex-rule": + _deleteRegexRule(actionEl.dataset.ruleId); + return; + default: + return; + } +} + +function _renderTaskProfileWorkspace(state) { + if (!state.profile) { + return ` +
+
任务预设不可用
+
当前没有可编辑的任务预设数据。
+
+ `; + } + + const taskMeta = + state.taskTypeOptions.find((item) => item.id === state.taskType) || + state.taskTypeOptions[0]; + const profileUpdatedAt = _formatTaskProfileTime(state.profile.updatedAt); + + return ` +
+
+
+ ${state.taskTypeOptions + .map( + (item) => ` + + `, + ) + .join("")} +
+ +
+
+
+
+ ${_escHtml(taskMeta?.label || state.taskType)} 任务预设 +
+
+ ${_escHtml(taskMeta?.description || "")} +
+
+
+ + ${state.profile.builtin ? "内置" : "自定义"} + + 更新于 ${_escHtml(profileUpdatedAt)} +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + + + + + +
+
+
+ +
+ ${TASK_PROFILE_TABS.map( + (tab) => ` + + `, + ).join("")} +
+ +
+ ${ + state.taskTabId === "generation" + ? _renderTaskGenerationTab(state) + : state.taskTabId === "regex" + ? _renderTaskRegexTab(state) + : _renderTaskPromptTab(state) + } +
+
+ `; +} + +function _renderTaskPromptTab(state) { + return ` +
+
+
+
+
Prompt 块列表
+
+ 通过顺序、启停与角色控制最终请求的编排方式。 +
+
+
+ +
+ +
+ + +
+
+ +
+ ${state.blocks.length + ? state.blocks + .map((block, index) => _renderTaskBlockListItem(block, index, state)) + .join("") + : ` +
+ 当前预设还没有块。可以先新增一个自定义块或内置块。 +
+ `} +
+
+ +
+ ${_renderTaskBlockEditor(state)} +
+
+ `; +} + +function _renderTaskGenerationTab(state) { + return ` +
+ ${TASK_PROFILE_GENERATION_GROUPS.map( + (group) => ` +
+
+
+
${_escHtml(group.title)}
+
+ 留空表示不强制下发,由模型或 provider 默认值决定。 +
+
+
+
+ ${group.fields + .map((field) => + _renderGenerationField(field, state.profile.generation?.[field.key]), + ) + .join("")} +
+
+ `, + ).join("")} +
+
运行时说明
+
+ 这里配置的是完整版 generation options。实际请求发送前,仍会根据模型能力做过滤,避免把不支持的字段直接下发给 provider。 +
+
+
+ `; +} + +function _renderTaskRegexTab(state) { + const regex = state.profile.regex || {}; + return ` +
+
+
+
+
复用与阶段
+
+ 任务预设可复用酒馆正则,并叠加当前任务自己的附加规则。 +
+
+
+ +
+ + + +
+ + +
+ ${[ + ["global", "全局"], + ["preset", "当前预设"], + ["character", "角色卡"], + ] + .map( + ([key, label]) => ` + + `, + ) + .join("")} +
+ + +
+ ${TASK_PROFILE_REGEX_STAGES.map( + (stage) => ` + + `, + ).join("")} +
+
+ +
+
+
+
本地附加规则
+
+ 本地规则只作用于当前任务预设,不会污染宿主酒馆配置。 +
+
+ +
+ +
+ ${state.regexRules.length + ? state.regexRules + .map((rule, index) => _renderRegexRuleListItem(rule, index, state)) + .join("") + : ` +
+ 当前预设还没有本地正则规则。 +
+ `} +
+
+ +
+ ${_renderRegexRuleEditor(state)} +
+
+ `; +} + +function _renderTaskBlockListItem(block, index, state) { + const isSelected = block.id === state.selectedBlock?.id; + return ` +
+ +
+ + + + +
+
+ `; +} + +function _renderTaskBlockEditor(state) { + const block = state.selectedBlock; + if (!block) { + return ` +
块详情
+
从左侧列表选择一个块进行编辑。
+ `; + } + + const builtinOptions = state.builtinBlockDefinitions + .map( + (item) => ` + + `, + ) + .join(""); + const legacyField = getLegacyPromptFieldForTask(state.taskType); + const legacyValue = + legacyField && block.type === "legacyPrompt" + ? state.settings?.[legacyField] || block.content || "" + : block.content || ""; + + return ` +
+
+
块详情
+
+ 当前块会直接写回到任务预设中。 +
+
+ ${_escHtml(_getTaskBlockTypeLabel(block.type))} +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + + + ${ + block.type === "builtin" + ? ` +
+ + +
+
+ + +
+ ` + : block.type === "legacyPrompt" + ? ` +
+ 当前块与旧版 prompt 字段保持兼容。留空时运行时会回退到内置默认 prompt。 +
+
+ + +
+
+ + +
+ ` + : ` +
+ + +
+ ` + } + `; +} + +function _renderGenerationField(field, value) { + if (field.type === "tri_bool") { + const currentValue = + value === true ? "true" : value === false ? "false" : ""; + return ` +
+ + +
+ `; + } + + if (field.type === "enum") { + return ` +
+ + +
+ `; + } + + return ` +
+ + +
+ `; +} + +function _renderRegexRuleListItem(rule, index, state) { + const isSelected = rule.id === state.selectedRule?.id; + return ` +
+ +
+ +
+
+ `; +} + +function _renderRegexRuleEditor(state) { + const rule = state.selectedRule; + if (!rule) { + return ` +
规则详情
+
从左侧规则列表选择一条规则进行编辑。
+ `; + } + + const trimStrings = Array.isArray(rule.trim_strings) + ? rule.trim_strings.join("\n") + : String(rule.trim_strings || ""); + + return ` +
+
+
规则详情
+
+ 字段尽量与 Tavern 正则结构保持对齐,方便后续导入导出与对照。 +
+
+ ${rule.enabled ? "启用中" : "已停用"} +
+ +
+ + +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ `; +} + +function _moveTaskBlock(blockId, direction) { + if (!blockId || !Number.isFinite(direction) || direction === 0) return; + _updateCurrentTaskProfile((draft) => { + const blocks = _sortTaskBlocks(draft.blocks); + const index = blocks.findIndex((item) => item.id === blockId); + const targetIndex = index + direction; + if (index < 0 || targetIndex < 0 || targetIndex >= blocks.length) { + return null; + } + [blocks[index], blocks[targetIndex]] = [blocks[targetIndex], blocks[index]]; + draft.blocks = _normalizeTaskBlocks(blocks); + return { selectBlockId: blockId }; + }); +} + +function _deleteTaskBlock(blockId) { + if (!blockId) return; + _updateCurrentTaskProfile((draft) => { + const blocks = _sortTaskBlocks(draft.blocks); + const index = blocks.findIndex((item) => item.id === blockId); + if (index < 0) return null; + const block = blocks[index]; + if (block.type !== "custom") { + toastr.info("只有自定义块可以删除", "ST-BME"); + return null; + } + blocks.splice(index, 1); + draft.blocks = _normalizeTaskBlocks(blocks); + return { + selectBlockId: blocks[Math.max(0, index - 1)]?.id || blocks[0]?.id || "", + }; + }); +} + +function _deleteRegexRule(ruleId) { + if (!ruleId) return; + _updateCurrentTaskProfile((draft) => { + const localRules = Array.isArray(draft.regex?.localRules) + ? [...draft.regex.localRules] + : []; + const index = localRules.findIndex((item) => item.id === ruleId); + if (index < 0) return null; + localRules.splice(index, 1); + draft.regex = { + ...(draft.regex || {}), + localRules, + }; + return { + selectRuleId: + localRules[Math.max(0, index - 1)]?.id || localRules[0]?.id || "", + }; + }); +} + +function _persistSelectedBlockField(target, refresh) { + const field = target.dataset.blockField; + if (!field) return; + + _updateCurrentTaskProfile( + (draft, context) => { + const blocks = _sortTaskBlocks(draft.blocks); + const block = blocks.find((item) => item.id === currentTaskProfileBlockId); + if (!block) return null; + + const rawValue = + target instanceof HTMLInputElement && target.type === "checkbox" + ? Boolean(target.checked) + : target.value; + + let extraSettingsPatch = {}; + if (field === "enabled") { + block.enabled = Boolean(rawValue); + } else if (field === "content" && block.type === "legacyPrompt") { + block.content = String(rawValue || ""); + const legacyField = getLegacyPromptFieldForTask(context.taskType); + if (legacyField) { + extraSettingsPatch[legacyField] = block.content; + } + } else { + block[field] = String(rawValue || ""); + } + + draft.blocks = _normalizeTaskBlocks(blocks); + return { + extraSettingsPatch, + selectBlockId: block.id, + }; + }, + { refresh }, + ); +} + +function _persistGenerationField(target, refresh) { + const key = target.dataset.generationKey; + const valueType = target.dataset.valueType || "text"; + if (!key) return; + + _updateCurrentTaskProfile( + (draft) => { + draft.generation = { + ...(draft.generation || {}), + [key]: _parseTaskWorkspaceValue(target, valueType), + }; + }, + { refresh }, + ); +} + +function _persistRegexConfigField(target, refresh) { + const key = target.dataset.regexField; + if (!key) return; + + _updateCurrentTaskProfile( + (draft) => { + draft.regex = { + ...(draft.regex || {}), + [key]: + target instanceof HTMLInputElement && target.type === "checkbox" + ? Boolean(target.checked) + : target.value, + }; + }, + { refresh }, + ); +} + +function _persistRegexSourceField(target, refresh) { + const sourceKey = target.dataset.regexSource; + if (!sourceKey) return; + + _updateCurrentTaskProfile( + (draft) => { + draft.regex = { + ...(draft.regex || {}), + sources: { + ...(draft.regex?.sources || {}), + [sourceKey]: Boolean(target.checked), + }, + }; + }, + { refresh }, + ); +} + +function _persistRegexStageField(target, refresh) { + const stageKey = target.dataset.regexStage; + if (!stageKey) return; + + _updateCurrentTaskProfile( + (draft) => { + draft.regex = { + ...(draft.regex || {}), + stages: { + ...(draft.regex?.stages || {}), + [stageKey]: Boolean(target.checked), + }, + }; + }, + { refresh }, + ); +} + +function _persistSelectedRegexRuleField(target, refresh) { + _updateCurrentTaskProfile( + (draft) => { + const localRules = Array.isArray(draft.regex?.localRules) + ? [...draft.regex.localRules] + : []; + const rule = localRules.find((item) => item.id === currentTaskProfileRuleId); + if (!rule) return null; + + if (target.dataset.regexRuleField) { + const field = target.dataset.regexRuleField; + if (target instanceof HTMLInputElement && target.type === "checkbox") { + rule[field] = Boolean(target.checked); + } else if (["min_depth", "max_depth"].includes(field)) { + const parsed = Number.parseInt(String(target.value || "").trim(), 10); + rule[field] = Number.isFinite(parsed) ? parsed : 0; + } else if (field === "trim_strings") { + rule[field] = String(target.value || ""); + } else { + rule[field] = String(target.value || ""); + } + } + + if (target.dataset.regexRuleSource) { + const sourceKey = target.dataset.regexRuleSource; + rule.source = { + ...(rule.source || {}), + [sourceKey]: Boolean(target.checked), + }; + } + + if (target.dataset.regexRuleDestination) { + const destinationKey = target.dataset.regexRuleDestination; + rule.destination = { + ...(rule.destination || {}), + [destinationKey]: Boolean(target.checked), + }; + } + + draft.regex = { + ...(draft.regex || {}), + localRules, + }; + return { selectRuleId: rule.id }; + }, + { refresh }, + ); +} + +function _updateCurrentTaskProfile(mutator, options = {}) { + const settings = _getSettings?.() || {}; + const taskProfiles = ensureTaskProfiles(settings); + const taskType = currentTaskProfileTaskType; + const bucket = taskProfiles[taskType]; + const activeProfile = + bucket?.profiles?.find((item) => item.id === bucket.activeProfileId) || + bucket?.profiles?.[0]; + + if (!activeProfile) return null; + + const draft = _normalizeTaskProfileDraft(_cloneJson(activeProfile)); + const mutationResult = mutator?.(draft, { + settings, + taskProfiles, + taskType, + bucket, + activeProfile, + }); + + if (mutationResult === null) return null; + + const result = mutationResult || {}; + + const nextProfile = _normalizeTaskProfileDraft(result.profile || draft); + const nextTaskProfiles = upsertTaskProfile(taskProfiles, taskType, nextProfile, { + setActive: true, + }); + + if (Object.prototype.hasOwnProperty.call(result, "selectBlockId")) { + currentTaskProfileBlockId = result.selectBlockId || ""; + } + if (Object.prototype.hasOwnProperty.call(result, "selectRuleId")) { + currentTaskProfileRuleId = result.selectRuleId || ""; + } + + return _patchTaskProfiles( + nextTaskProfiles, + result.extraSettingsPatch || {}, + { + refresh: result.refresh === undefined ? options.refresh !== false : result.refresh, + }, + ); +} + +function _normalizeTaskProfileDraft(profile = {}) { + const draft = profile || {}; + draft.blocks = _normalizeTaskBlocks(draft.blocks); + draft.regex = { + enabled: false, + inheritStRegex: true, + sources: { + global: true, + preset: true, + character: true, + }, + stages: { + finalPrompt: true, + "input.userMessage": false, + "input.recentMessages": false, + "input.candidateText": false, + "input.finalPrompt": false, + rawResponse: false, + beforeParse: false, + "output.rawResponse": false, + "output.beforeParse": false, + }, + localRules: [], + ...(draft.regex || {}), + sources: { + global: true, + preset: true, + character: true, + ...(draft.regex?.sources || {}), + }, + stages: { + finalPrompt: true, + "input.userMessage": false, + "input.recentMessages": false, + "input.candidateText": false, + "input.finalPrompt": false, + rawResponse: false, + beforeParse: false, + "output.rawResponse": false, + "output.beforeParse": false, + ...(draft.regex?.stages || {}), + }, + localRules: Array.isArray(draft.regex?.localRules) + ? draft.regex.localRules.map((rule) => ({ + ...rule, + source: { + user_input: true, + ai_output: true, + ...(rule?.source || {}), + }, + destination: { + prompt: true, + display: false, + ...(rule?.destination || {}), + }, + })) + : [], + }; + return draft; +} + +function _normalizeTaskBlocks(blocks = []) { + return _sortTaskBlocks(blocks).map((block, index) => ({ + ...block, + order: index, + })); +} + +function _sortTaskBlocks(blocks = []) { + return [...(Array.isArray(blocks) ? blocks : [])].sort((a, b) => { + const orderA = Number.isFinite(Number(a?.order)) ? Number(a.order) : 0; + const orderB = Number.isFinite(Number(b?.order)) ? Number(b.order) : 0; + return orderA - orderB; + }); +} + +function _parseTaskWorkspaceValue(target, valueType = "text") { + if (valueType === "tri_bool") { + if (target.value === "true") return true; + if (target.value === "false") return false; + return null; + } + + if (valueType === "number") { + const raw = String(target.value || "").trim(); + if (!raw) return null; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; + } + + return String(target.value || "").trim(); +} + +function _downloadTaskProfile(taskProfiles, taskType, profile) { + try { + const payload = serializeTaskProfile(taskProfiles, taskType, profile?.id || ""); + const fileName = _sanitizeFileName( + `st-bme-${taskType}-${profile?.name || "profile"}.json`, + ); + const blob = new Blob([JSON.stringify(payload, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + toastr.success("预设导出成功", "ST-BME"); + } catch (error) { + console.error("[ST-BME] 导出任务预设失败:", error); + toastr.error(`预设导出失败: ${error?.message || error}`, "ST-BME"); + } +} + +function _sanitizeFileName(fileName = "profile.json") { + return String(fileName || "profile.json").replace(/[<>:"/\\|?*\x00-\x1f]/g, "-"); +} + +function _cloneJson(value) { + return JSON.parse(JSON.stringify(value ?? null)); +} + +function _getTaskBlockTypeLabel(type) { + const typeMap = { + custom: "自定义块", + builtin: "内置块", + legacyPrompt: "兼容块", + }; + return typeMap[type] || type || "块"; +} + +function _formatTaskProfileTime(raw) { + if (!raw) return "刚刚"; + try { + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return "刚刚"; + return date.toLocaleString("zh-CN", { + hour12: false, + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return "刚刚"; + } +} + // ==================== 工具函数 ==================== function _setText(id, text) { @@ -1339,6 +3067,7 @@ function _patchSettings(patch = {}, options = {}) { const settings = _updateSettings?.(patch) || _getSettings?.() || {}; if (options.refreshGuards) _refreshGuardedConfigStates(settings); if (options.refreshPrompts) _refreshPromptCardStates(settings); + if (options.refreshTaskWorkspace) _refreshTaskProfileWorkspace(settings); if (options.refreshTheme) _highlightThemeChoice(settings.panelTheme || "crimson"); return settings; diff --git a/prompt-builder.js b/prompt-builder.js new file mode 100644 index 0000000..b8c4650 --- /dev/null +++ b/prompt-builder.js @@ -0,0 +1,107 @@ +// ST-BME: Prompt Builder(Phase 1 兼容骨架) + +import { getActiveTaskProfile, getLegacyPromptForTask } from "./prompt-profiles.js"; + +export function buildTaskPrompt(settings = {}, taskType, context = {}) { + const profile = getActiveTaskProfile(settings, taskType); + const legacyPrompt = getLegacyPromptForTask(settings, taskType); + const rawBlocks = Array.isArray(profile?.blocks) ? profile.blocks : []; + const blocks = rawBlocks + .map((block, index) => ({ ...block, _orderIndex: index })) + .sort((a, b) => { + const orderA = Number.isFinite(Number(a.order)) + ? Number(a.order) + : a._orderIndex; + const orderB = Number.isFinite(Number(b.order)) + ? Number(b.order) + : b._orderIndex; + return orderA - orderB; + }); + + let systemPrompt = ""; + const customMessages = []; + + for (const block of blocks) { + if (!block || block.enabled === false) continue; + const role = normalizeRole(block.role); + let content = ""; + + if (block.type === "legacyPrompt") { + content = legacyPrompt || block.content || ""; + } else if (block.type === "builtin") { + if (block.content) { + content = interpolateVariables(block.content, context); + } else if (block.sourceKey) { + const value = getByPath(context, block.sourceKey); + if (value != null) { + content = + typeof value === "string" ? value : JSON.stringify(value, null, 2); + } + } + } else if (block.type === "custom") { + content = interpolateVariables(block.content || "", context); + } + + if (!content) continue; + const mode = normalizeInjectionMode(block.injectionMode); + + if (role === "system") { + if (!systemPrompt) { + systemPrompt = content; + } else if (mode === "prepend") { + systemPrompt = `${content}\n\n${systemPrompt}`; + } else { + systemPrompt = `${systemPrompt}\n\n${content}`; + } + } else { + if (mode === "prepend") { + customMessages.unshift({ role, content }); + } else { + customMessages.push({ role, content }); + } + } + } + + return { + profile, + systemPrompt, + customMessages, + debug: { + taskType, + profileId: profile?.id || "", + profileName: profile?.name || "", + usedLegacyPrompt: Boolean(legacyPrompt), + blockCount: blocks.length, + }, + }; +} + +export function interpolateVariables(template, context = {}) { + return String(template || "").replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => { + const value = getByPath(context, key); + return value == null ? "" : String(value); + }); +} + +function getByPath(target, path) { + return String(path || "") + .split(".") + .filter(Boolean) + .reduce((acc, key) => (acc == null ? undefined : acc[key]), target); +} + +function normalizeRole(role) { + const value = String(role || "system").toLowerCase(); + if (["system", "user", "assistant"].includes(value)) { + return value; + } + return "system"; +} + +function normalizeInjectionMode(mode) { + const value = String(mode || "append").toLowerCase(); + if (["prepend", "append", "relative"].includes(value)) { + return value; + } + return "append"; +} diff --git a/prompt-profiles.js b/prompt-profiles.js new file mode 100644 index 0000000..9513620 --- /dev/null +++ b/prompt-profiles.js @@ -0,0 +1,824 @@ +// ST-BME: 任务预设与兼容迁移层 + +const TASK_TYPES = [ + "extract", + "recall", + "compress", + "synopsis", + "reflection", + "consolidation", +]; + +const TASK_TYPE_META = { + extract: { + label: "提取", + description: "从当前对话批次中抽取结构化记忆。", + }, + recall: { + label: "召回", + description: "根据上下文筛选最相关的记忆节点。", + }, + compress: { + label: "压缩", + description: "合并并压缩高层节点内容。", + }, + synopsis: { + label: "概要", + description: "生成阶段性的全局剧情提要。", + }, + reflection: { + label: "反思", + description: "沉淀长期趋势、触发点与建议。", + }, + consolidation: { + label: "整合", + description: "分析新旧记忆的冲突、去重与进化。", + }, +}; + +const BUILTIN_BLOCK_DEFINITIONS = [ + { + sourceKey: "taskName", + name: "任务名", + role: "system", + description: "当前任务类型标识。", + }, + { + sourceKey: "systemInstruction", + name: "系统说明", + role: "system", + description: "任务系统级说明或通用约束。", + }, + { + sourceKey: "outputRules", + name: "输出规则", + role: "system", + description: "用于声明 JSON 或结构化输出要求。", + }, + { + sourceKey: "schema", + name: "Schema", + role: "system", + description: "节点类型或字段定义。", + }, + { + sourceKey: "recentMessages", + name: "最近消息", + role: "user", + description: "最近对话上下文或历史片段。", + }, + { + sourceKey: "userMessage", + name: "用户消息", + role: "user", + description: "当前用户输入内容。", + }, + { + sourceKey: "candidateNodes", + name: "候选节点", + role: "user", + description: "召回或整合阶段的候选节点列表。", + }, + { + sourceKey: "graphStats", + name: "图统计", + role: "user", + description: "图谱状态或当前图概览。", + }, + { + sourceKey: "currentRange", + name: "当前范围", + role: "user", + description: "当前处理的消息或楼层范围。", + }, + { + sourceKey: "nodeContent", + name: "节点内容", + role: "user", + description: "待压缩或待处理的节点正文。", + }, + { + sourceKey: "eventSummary", + name: "事件摘要", + role: "user", + description: "近期事件线摘要。", + }, + { + sourceKey: "characterSummary", + name: "角色摘要", + role: "user", + description: "近期角色状态摘要。", + }, + { + sourceKey: "threadSummary", + name: "主线摘要", + role: "user", + description: "活跃主线或当前线程摘要。", + }, + { + sourceKey: "contradictionSummary", + name: "矛盾摘要", + role: "user", + description: "近期冲突或矛盾信息。", + }, +]; + +const DEFAULT_TASK_PROFILE_VERSION = 1; +const DEFAULT_PROFILE_ID = "default"; + +const LEGACY_PROMPT_FIELD_MAP = { + extract: "extractPrompt", + recall: "recallPrompt", + compress: "compressPrompt", + synopsis: "synopsisPrompt", + reflection: "reflectionPrompt", + consolidation: "consolidationPrompt", +}; + +function nowIso() { + return new Date().toISOString(); +} + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value ?? null)); +} + +function createUniqueId(prefix = "profile") { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; +} + +function normalizeRole(role) { + const value = String(role || "system").trim().toLowerCase(); + if (["system", "user", "assistant"].includes(value)) { + return value; + } + return "system"; +} + +function normalizeInjectionMode(mode) { + const value = String(mode || "append").trim().toLowerCase(); + if (["append", "prepend", "relative"].includes(value)) { + return value; + } + return "append"; +} + +function normalizePromptBlock(taskType, block = {}, index = 0) { + const fallbackType = String(block?.type || "custom"); + return { + id: String(block?.id || createPromptBlockId(taskType)), + name: typeof block?.name === "string" ? block.name : "", + type: fallbackType, + enabled: block?.enabled !== false, + role: normalizeRole(block?.role), + sourceKey: typeof block?.sourceKey === "string" ? block.sourceKey : "", + sourceField: typeof block?.sourceField === "string" ? block.sourceField : "", + content: typeof block?.content === "string" ? block.content : "", + injectionMode: normalizeInjectionMode(block?.injectionMode), + order: Number.isFinite(Number(block?.order)) ? Number(block.order) : index, + }; +} + +function normalizeRegexLocalRule(rule = {}, taskType = "task", index = 0) { + return { + id: String(rule?.id || createRegexRuleId(taskType)), + script_name: String( + rule?.script_name || rule?.scriptName || `本地规则 ${index + 1}`, + ), + enabled: rule?.enabled !== false, + find_regex: String(rule?.find_regex || rule?.findRegex || ""), + replace_string: String( + rule?.replace_string ?? rule?.replaceString ?? "", + ), + trim_strings: Array.isArray(rule?.trim_strings) + ? rule.trim_strings.map((item) => String(item || "")) + : typeof rule?.trim_strings === "string" + ? rule.trim_strings + : "", + source: { + user_input: + rule?.source?.user_input === undefined + ? true + : Boolean(rule.source.user_input), + ai_output: + rule?.source?.ai_output === undefined + ? true + : Boolean(rule.source.ai_output), + }, + destination: { + prompt: + rule?.destination?.prompt === undefined + ? true + : Boolean(rule.destination.prompt), + display: Boolean(rule?.destination?.display), + }, + min_depth: Number.isFinite(Number(rule?.min_depth)) + ? Number(rule.min_depth) + : 0, + max_depth: Number.isFinite(Number(rule?.max_depth)) + ? Number(rule.max_depth) + : 9999, + }; +} + +function normalizeTaskProfilesState(taskProfiles = {}) { + return ensureTaskProfiles({ taskProfiles }); +} + +function getDefaultProfileDescription(taskType) { + return TASK_TYPE_META[taskType]?.description || ""; +} + +export function createPromptBlockId(taskType = "task") { + return createUniqueId(`${taskType}-block`); +} + +export function createRegexRuleId(taskType = "task") { + return createUniqueId(`${taskType}-rule`); +} + +export function createProfileId(taskType = "task") { + return createUniqueId(`${taskType}-profile`); +} + +export function createDefaultTaskProfiles() { + const profiles = {}; + for (const taskType of TASK_TYPES) { + profiles[taskType] = { + activeProfileId: DEFAULT_PROFILE_ID, + profiles: [createDefaultTaskProfile(taskType)], + }; + } + return profiles; +} + +export function createDefaultTaskProfile(taskType) { + const legacyPromptField = LEGACY_PROMPT_FIELD_MAP[taskType]; + return { + id: DEFAULT_PROFILE_ID, + name: "默认预设", + taskType, + version: DEFAULT_TASK_PROFILE_VERSION, + builtin: true, + enabled: true, + description: getDefaultProfileDescription(taskType), + promptMode: "legacy-compatible", + updatedAt: nowIso(), + blocks: [ + { + id: "legacy-system", + name: "兼容主提示词", + type: "legacyPrompt", + enabled: true, + role: "system", + sourceField: legacyPromptField, + sourceKey: "", + content: "", + injectionMode: "append", + order: 0, + }, + ], + generation: { + max_context_tokens: null, + max_completion_tokens: null, + reply_count: null, + stream: false, + temperature: null, + top_p: null, + top_k: null, + 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: false, + inheritStRegex: true, + sources: { + global: true, + preset: true, + character: true, + }, + stages: { + finalPrompt: true, + "input.userMessage": false, + "input.recentMessages": false, + "input.candidateText": false, + "input.finalPrompt": false, + rawResponse: false, + beforeParse: false, + "output.rawResponse": false, + "output.beforeParse": false, + }, + localRules: [], + }, + metadata: { + migratedFromLegacy: false, + legacyPromptField, + }, + }; +} + +export function createCustomPromptBlock(taskType, overrides = {}) { + return normalizePromptBlock(taskType, { + id: createPromptBlockId(taskType), + name: "自定义块", + type: "custom", + enabled: true, + role: "system", + sourceKey: "", + sourceField: "", + content: "", + injectionMode: "append", + order: 0, + ...overrides, + }); +} + +export function createBuiltinPromptBlock(taskType, sourceKey = "", overrides = {}) { + const definition = + BUILTIN_BLOCK_DEFINITIONS.find((item) => item.sourceKey === sourceKey) || + BUILTIN_BLOCK_DEFINITIONS[0]; + return normalizePromptBlock(taskType, { + id: createPromptBlockId(taskType), + name: definition?.name || "内置块", + type: "builtin", + enabled: true, + role: definition?.role || "system", + sourceKey: definition?.sourceKey || sourceKey, + sourceField: "", + content: "", + injectionMode: "append", + order: 0, + ...overrides, + }); +} + +export function createLocalRegexRule(taskType, overrides = {}) { + return normalizeRegexLocalRule( + { + id: createRegexRuleId(taskType), + script_name: "本地规则", + enabled: true, + find_regex: "", + replace_string: "", + trim_strings: "", + source: { + user_input: true, + ai_output: true, + }, + destination: { + prompt: true, + display: false, + }, + min_depth: 0, + max_depth: 9999, + ...overrides, + }, + taskType, + 0, + ); +} + +export function ensureTaskProfiles(settings = {}) { + const existing = settings.taskProfiles; + const defaults = createDefaultTaskProfiles(); + + if (!existing || typeof existing !== "object") { + return defaults; + } + + const normalized = {}; + for (const taskType of TASK_TYPES) { + const current = existing[taskType] || {}; + const defaultBucket = defaults[taskType]; + const profiles = + Array.isArray(current.profiles) && current.profiles.length > 0 + ? current.profiles.map((profile) => + normalizeTaskProfile(taskType, profile, settings), + ) + : defaultBucket.profiles; + + const activeProfileId = + typeof current.activeProfileId === "string" && + profiles.some((profile) => profile.id === current.activeProfileId) + ? current.activeProfileId + : profiles[0]?.id || DEFAULT_PROFILE_ID; + + normalized[taskType] = { + activeProfileId, + profiles, + }; + } + + return normalized; +} + +export function normalizeTaskProfile(taskType, profile = {}, settings = {}) { + const base = createDefaultTaskProfile(taskType); + const legacyPromptField = LEGACY_PROMPT_FIELD_MAP[taskType]; + const blocks = + Array.isArray(profile.blocks) && profile.blocks.length > 0 + ? profile.blocks.map((block, index) => + normalizePromptBlock(taskType, block, index), + ) + : base.blocks.map((block, index) => + normalizePromptBlock(taskType, block, index), + ); + + return { + ...base, + ...profile, + id: String(profile?.id || base.id), + name: String(profile?.name || base.name), + taskType, + builtin: + profile?.builtin === undefined + ? profile?.id === DEFAULT_PROFILE_ID + : Boolean(profile?.builtin), + enabled: profile?.enabled !== false, + description: + typeof profile?.description === "string" + ? profile.description + : base.description, + promptMode: String(profile?.promptMode || base.promptMode), + updatedAt: + typeof profile?.updatedAt === "string" && profile.updatedAt + ? profile.updatedAt + : nowIso(), + blocks, + generation: { + ...base.generation, + ...(profile?.generation || {}), + }, + regex: { + ...base.regex, + ...(profile?.regex || {}), + sources: { + ...base.regex.sources, + ...(profile?.regex?.sources || {}), + }, + stages: { + ...base.regex.stages, + ...(profile?.regex?.stages || {}), + }, + localRules: Array.isArray(profile?.regex?.localRules) + ? profile.regex.localRules.map((rule, index) => + normalizeRegexLocalRule(rule, taskType, index), + ) + : [], + }, + metadata: { + ...base.metadata, + ...(profile?.metadata || {}), + legacyPromptField, + legacyPromptSnapshot: + typeof settings?.[legacyPromptField] === "string" + ? settings[legacyPromptField] + : "", + }, + }; +} + +export function migrateLegacyTaskProfiles(settings = {}) { + const alreadyMigrated = + Number(settings.taskProfilesVersion) >= DEFAULT_TASK_PROFILE_VERSION; + const nextTaskProfiles = ensureTaskProfiles(settings); + let changed = !alreadyMigrated; + + for (const taskType of TASK_TYPES) { + const legacyField = LEGACY_PROMPT_FIELD_MAP[taskType]; + const legacyPrompt = + typeof settings?.[legacyField] === "string" ? settings[legacyField] : ""; + const bucket = nextTaskProfiles[taskType]; + if (!bucket || !Array.isArray(bucket.profiles) || bucket.profiles.length === 0) { + nextTaskProfiles[taskType] = { + activeProfileId: DEFAULT_PROFILE_ID, + profiles: [createDefaultTaskProfile(taskType)], + }; + changed = true; + continue; + } + + const firstProfile = bucket.profiles[0]; + if ( + firstProfile?.id === DEFAULT_PROFILE_ID && + firstProfile?.metadata?.migratedFromLegacy !== true && + legacyPrompt + ) { + firstProfile.metadata = { + ...(firstProfile.metadata || {}), + migratedFromLegacy: true, + legacyPromptField: legacyField, + legacyPromptSnapshot: legacyPrompt, + }; + changed = true; + } + } + + return { + changed, + taskProfilesVersion: DEFAULT_TASK_PROFILE_VERSION, + taskProfiles: nextTaskProfiles, + }; +} + +export function getActiveTaskProfile(settings = {}, taskType) { + const taskProfiles = ensureTaskProfiles(settings); + const bucket = taskProfiles?.[taskType]; + if (!bucket?.profiles?.length) { + return createDefaultTaskProfile(taskType); + } + return ( + bucket.profiles.find((profile) => profile.id === bucket.activeProfileId) || + bucket.profiles[0] + ); +} + +export function getLegacyPromptForTask(settings = {}, taskType) { + const field = LEGACY_PROMPT_FIELD_MAP[taskType]; + return typeof settings?.[field] === "string" ? settings[field] : ""; +} + +export function getLegacyPromptFieldForTask(taskType) { + return LEGACY_PROMPT_FIELD_MAP[taskType] || ""; +} + +export function getTaskTypeMeta(taskType) { + return { + id: taskType, + label: TASK_TYPE_META[taskType]?.label || taskType, + description: TASK_TYPE_META[taskType]?.description || "", + }; +} + +export function getTaskTypeOptions() { + return TASK_TYPES.map((taskType) => getTaskTypeMeta(taskType)); +} + +export function getTaskTypes() { + return [...TASK_TYPES]; +} + +export function getBuiltinBlockDefinitions() { + return BUILTIN_BLOCK_DEFINITIONS.map((definition) => ({ ...definition })); +} + +export function cloneTaskProfile(profile = {}, options = {}) { + const taskType = String(options.taskType || profile.taskType || "extract"); + const cloned = normalizeTaskProfile(taskType, cloneJson(profile)); + const nextName = String(options.name || "").trim() || `${cloned.name} 副本`; + const nextProfile = { + ...cloned, + id: createProfileId(taskType), + taskType, + name: nextName, + builtin: false, + updatedAt: nowIso(), + blocks: (Array.isArray(cloned.blocks) ? cloned.blocks : []).map( + (block, index) => + normalizePromptBlock( + taskType, + { + ...block, + id: createPromptBlockId(taskType), + order: index, + }, + index, + ), + ), + regex: { + ...(cloned.regex || {}), + localRules: Array.isArray(cloned?.regex?.localRules) + ? cloned.regex.localRules.map((rule, index) => + normalizeRegexLocalRule( + { + ...rule, + id: createRegexRuleId(taskType), + }, + taskType, + index, + ), + ) + : [], + }, + metadata: { + ...(cloned.metadata || {}), + clonedFromId: cloned.id || "", + clonedAt: nowIso(), + }, + }; + + return nextProfile; +} + +export function upsertTaskProfile( + taskProfiles = {}, + taskType, + profile, + options = {}, +) { + const normalizedState = normalizeTaskProfilesState(taskProfiles); + const bucket = normalizedState[taskType] || { + activeProfileId: DEFAULT_PROFILE_ID, + profiles: [], + }; + const normalizedProfile = normalizeTaskProfile(taskType, { + ...(profile || {}), + updatedAt: nowIso(), + }); + const nextProfiles = [...bucket.profiles]; + const existingIndex = nextProfiles.findIndex( + (item) => item.id === normalizedProfile.id, + ); + + if (existingIndex >= 0) { + nextProfiles.splice(existingIndex, 1, normalizedProfile); + } else if (normalizedProfile.id === DEFAULT_PROFILE_ID) { + nextProfiles.unshift(normalizedProfile); + } else { + nextProfiles.push(normalizedProfile); + } + + normalizedState[taskType] = { + activeProfileId: + options.setActive === false + ? bucket.activeProfileId + : normalizedProfile.id, + profiles: nextProfiles.map((item, index) => + normalizeTaskProfile(taskType, { + ...item, + blocks: Array.isArray(item.blocks) + ? item.blocks.map((block, blockIndex) => ({ + ...block, + order: Number.isFinite(Number(block?.order)) + ? Number(block.order) + : blockIndex, + })) + : [], + builtin: item.id === DEFAULT_PROFILE_ID ? true : item.builtin, + updatedAt: + item.id === normalizedProfile.id ? normalizedProfile.updatedAt : item.updatedAt, + }), + ), + }; + + return normalizedState; +} + +export function setActiveTaskProfileId(taskProfiles = {}, taskType, profileId) { + const normalizedState = normalizeTaskProfilesState(taskProfiles); + const bucket = normalizedState[taskType]; + if (!bucket?.profiles?.some((profile) => profile.id === profileId)) { + return normalizedState; + } + normalizedState[taskType] = { + ...bucket, + activeProfileId: profileId, + }; + return normalizedState; +} + +export function deleteTaskProfile(taskProfiles = {}, taskType, profileId) { + if (!profileId) return normalizeTaskProfilesState(taskProfiles); + + const normalizedState = normalizeTaskProfilesState(taskProfiles); + const bucket = normalizedState[taskType]; + if (!bucket?.profiles?.length) { + return normalizedState; + } + + const remaining = bucket.profiles.filter((profile) => profile.id !== profileId); + if (remaining.length === 0) { + normalizedState[taskType] = { + activeProfileId: DEFAULT_PROFILE_ID, + profiles: [createDefaultTaskProfile(taskType)], + }; + return normalizedState; + } + + normalizedState[taskType] = { + activeProfileId: remaining.some( + (profile) => profile.id === bucket.activeProfileId, + ) + ? bucket.activeProfileId + : remaining[0].id, + profiles: remaining, + }; + return normalizedState; +} + +export function restoreDefaultTaskProfile(taskProfiles = {}, taskType) { + const normalizedState = normalizeTaskProfilesState(taskProfiles); + const bucket = normalizedState[taskType] || { + activeProfileId: DEFAULT_PROFILE_ID, + profiles: [], + }; + const defaultProfile = createDefaultTaskProfile(taskType); + const remaining = (bucket.profiles || []).filter( + (profile) => profile.id !== DEFAULT_PROFILE_ID, + ); + + normalizedState[taskType] = { + activeProfileId: DEFAULT_PROFILE_ID, + profiles: [defaultProfile, ...remaining], + }; + + return normalizedState; +} + +export function exportTaskProfile(taskProfiles = {}, taskType, profileId = "") { + const normalizedState = normalizeTaskProfilesState(taskProfiles); + const bucket = normalizedState[taskType]; + const profile = + bucket?.profiles?.find((item) => item.id === profileId) || + bucket?.profiles?.[0]; + + if (!profile) { + throw new Error(`Task profile not found: ${taskType}/${profileId}`); + } + + return { + format: "st-bme-task-profile", + version: DEFAULT_TASK_PROFILE_VERSION, + taskType, + exportedAt: nowIso(), + profile: cloneJson(profile), + }; +} + +export function importTaskProfile( + taskProfiles = {}, + rawInput, + preferredTaskType = "", +) { + const parsed = + typeof rawInput === "string" ? JSON.parse(rawInput) : cloneJson(rawInput); + const candidate = + parsed?.profile && typeof parsed.profile === "object" + ? parsed.profile + : parsed; + const importedTaskType = String( + preferredTaskType || parsed?.taskType || candidate?.taskType || "", + ).trim(); + + if (!TASK_TYPES.includes(importedTaskType)) { + throw new Error(`Unsupported task type: ${importedTaskType || "(empty)"}`); + } + + const bucket = normalizeTaskProfilesState(taskProfiles)[importedTaskType]; + const baseName = String(candidate?.name || "").trim() || "导入预设"; + const importedProfile = normalizeTaskProfile(importedTaskType, { + ...candidate, + id: createProfileId(importedTaskType), + taskType: importedTaskType, + name: baseName, + builtin: false, + updatedAt: nowIso(), + metadata: { + ...(candidate?.metadata || {}), + importedAt: nowIso(), + }, + blocks: Array.isArray(candidate?.blocks) && candidate.blocks.length > 0 + ? candidate.blocks.map((block, index) => ({ + ...block, + id: createPromptBlockId(importedTaskType), + order: index, + })) + : createDefaultTaskProfile(importedTaskType).blocks, + regex: { + ...(candidate?.regex || {}), + localRules: Array.isArray(candidate?.regex?.localRules) + ? candidate.regex.localRules.map((rule) => ({ + ...rule, + id: createRegexRuleId(importedTaskType), + })) + : [], + }, + }); + + const nextTaskProfiles = upsertTaskProfile( + { + ...normalizeTaskProfilesState(taskProfiles), + [importedTaskType]: bucket, + }, + importedTaskType, + importedProfile, + { setActive: true }, + ); + + return { + taskProfiles: nextTaskProfiles, + taskType: importedTaskType, + profile: importedProfile, + }; +} diff --git a/retriever.js b/retriever.js index aab48e3..0ce8997 100644 --- a/retriever.js +++ b/retriever.js @@ -11,6 +11,8 @@ import { getNodeEdges, } from "./graph.js"; import { callLLMForJSON } from "./llm.js"; +import { buildTaskPrompt } from "./prompt-builder.js"; +import { applyTaskRegex } from "./task-regex.js"; import { findSimilarNodesByText, validateVectorConfig } from "./vector-index.js"; function createAbortError(message = "操作已终止") { @@ -51,6 +53,7 @@ export async function retrieve({ schema, signal = undefined, options = {}, + settings = {}, }) { throwIfAborted(signal); const topK = options.topK ?? 20; @@ -255,6 +258,7 @@ export async function retrieve({ schema, normalizedMaxRecallNodes, options.recallPrompt, + settings, signal, ); selectedNodeIds = llmResult.selectedNodeIds; @@ -397,6 +401,7 @@ async function llmRecall( schema, maxNodes, customPrompt, + settings = {}, signal, ) { throwIfAborted(signal); @@ -413,14 +418,27 @@ async function llmRecall( }) .join("\n"); - const systemPrompt = customPrompt || [ - "你是一个记忆召回分析器。", - "根据用户最新输入和对话上下文,从候选记忆节点中选择最相关的节点。", - "优先选择:(1) 直接相关的当前场景节点, (2) 因果关系连续性节点, (3) 有潜在影响的背景节点。", - `最多选择 ${maxNodes} 个节点。`, - "输出严格的 JSON 格式:", - '{"selected_ids": ["id1", "id2", ...], "reason": "简要说明选择理由"}', - ].join("\n"); + const recallPromptBuild = buildTaskPrompt(settings, "recall", { + taskName: "recall", + recentMessages: contextStr || "(无)", + userMessage, + candidateNodes: candidateDescriptions, + candidateText: candidateDescriptions, + graphStats: `candidate_count=${candidates.length}`, + }); + const systemPrompt = applyTaskRegex( + settings, + "recall", + "finalPrompt", + recallPromptBuild.systemPrompt || customPrompt || [ + "你是一个记忆召回分析器。", + "根据用户最新输入和对话上下文,从候选记忆节点中选择最相关的节点。", + "优先选择:(1) 直接相关的当前场景节点, (2) 因果关系连续性节点, (3) 有潜在影响的背景节点。", + `最多选择 ${maxNodes} 个节点。`, + "输出严格的 JSON 格式:", + '{"selected_ids": ["id1", "id2", ...], "reason": "简要说明选择理由"}', + ].join("\n"), + ); const userPrompt = [ "## 最近对话上下文", @@ -440,6 +458,8 @@ async function llmRecall( userPrompt, maxRetries: 1, signal, + taskType: "recall", + additionalMessages: recallPromptBuild.customMessages || [], }); if (result?.selected_ids && Array.isArray(result.selected_ids)) { diff --git a/style.css b/style.css index 270e188..68a1bbd 100644 --- a/style.css +++ b/style.css @@ -1250,6 +1250,246 @@ cursor: not-allowed; } +.bme-task-profile-workspace { + display: flex; + flex-direction: column; + gap: 16px; +} + +.bme-task-shell { + display: flex; + flex-direction: column; + gap: 16px; +} + +.bme-task-header { + display: flex; + flex-direction: column; + gap: 14px; +} + +.bme-task-type-tabs, +.bme-task-subtabs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.bme-task-type-btn, +.bme-task-subtab-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + color: var(--bme-on-surface-dim); + cursor: pointer; + transition: + border-color 0.15s, + background 0.15s, + color 0.15s; +} + +.bme-task-type-btn:hover, +.bme-task-subtab-btn:hover { + border-color: rgba(255, 255, 255, 0.16); + color: var(--bme-on-surface); +} + +.bme-task-type-btn.active, +.bme-task-subtab-btn.active { + border-color: var(--bme-primary); + background: var(--bme-primary-dim); + color: var(--bme-primary); +} + +.bme-task-header-card { + gap: 16px; +} + +.bme-task-profile-badges { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.bme-task-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + color: var(--bme-on-surface-dim); + font-size: 11px; + font-weight: 600; +} + +.bme-task-pill.is-builtin { + background: var(--bme-primary-dim); + color: var(--bme-primary); +} + +.bme-task-header-grid, +.bme-task-field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.bme-task-header-actions, +.bme-task-inline-actions, +.bme-task-toolbar-inline { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.bme-task-tab-body { + display: flex; + flex-direction: column; + gap: 14px; +} + +.bme-task-editor-grid, +.bme-task-generation-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.bme-task-regex-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.bme-task-toolbar-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 14px; + flex-wrap: wrap; +} + +.bme-task-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.bme-task-list-entry { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bme-task-list-item { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + padding: 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.025); + color: var(--bme-on-surface); + text-align: left; + cursor: pointer; + transition: + border-color 0.15s, + background 0.15s, + transform 0.15s; +} + +.bme-task-list-item:hover { + border-color: rgba(255, 255, 255, 0.12); + transform: translateY(-1px); +} + +.bme-task-list-item.active { + border-color: var(--bme-primary); + background: rgba(255, 255, 255, 0.055); +} + +.bme-task-list-index { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + height: 28px; + padding: 0 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + color: var(--bme-on-surface-dim); + font-size: 11px; + font-weight: 700; +} + +.bme-task-list-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; +} + +.bme-task-list-title { + font-size: 13px; + font-weight: 700; + color: var(--bme-on-surface); +} + +.bme-task-list-meta { + font-size: 11px; + line-height: 1.45; + color: var(--bme-on-surface-dim); +} + +.bme-task-mini-btn { + min-width: 0; +} + +.bme-task-empty, +.bme-task-note { + padding: 12px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); + border: 1px dashed rgba(255, 255, 255, 0.08); + color: var(--bme-on-surface-dim); + font-size: 12px; + line-height: 1.55; +} + +.bme-task-editor-toggle { + margin-bottom: 12px; +} + +.bme-task-section-label { + margin: 16px 0 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--bme-on-surface-dim); +} + +.bme-task-toggle-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.bme-task-profile-workspace .bme-config-textarea { + min-height: 160px; +} + .bme-theme-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1510,7 +1750,12 @@ } .bme-config-grid-2, - .bme-theme-card-grid { + .bme-theme-card-grid, + .bme-task-header-grid, + .bme-task-field-grid, + .bme-task-editor-grid, + .bme-task-generation-grid, + .bme-task-regex-grid { grid-template-columns: 1fr; } @@ -1525,6 +1770,31 @@ justify-content: space-between; } + .bme-task-toolbar-row, + .bme-task-header-actions, + .bme-task-inline-actions, + .bme-task-toolbar-inline { + width: 100%; + } + + .bme-task-type-tabs, + .bme-task-subtabs { + overflow-x: auto; + flex-wrap: nowrap; + padding-bottom: 4px; + } + + .bme-task-type-tabs::-webkit-scrollbar, + .bme-task-subtabs::-webkit-scrollbar { + display: none; + } + + .bme-task-type-btn, + .bme-task-subtab-btn { + flex: 0 0 auto; + white-space: nowrap; + } + .bme-config-actions { width: 100%; } diff --git a/task-regex.js b/task-regex.js new file mode 100644 index 0000000..ae600c8 --- /dev/null +++ b/task-regex.js @@ -0,0 +1,336 @@ +// ST-BME: 任务正则兼容层(Phase 1) +// 目标:在任务预设中复用 Tavern 正则来源(global/preset/character), +// 同时叠加任务本地规则,并按任务阶段执行。 + +import { extension_settings, getContext } from "../../../extensions.js"; +import { getActiveTaskProfile } from "./prompt-profiles.js"; + +const HTML_TAG_PATTERN = + /<\/?(?:div|span|p|br|hr|img|details|summary|section|article|aside|header|footer|nav|ul|ol|li|table|tr|td|th|h[1-6]|a|em|strong|blockquote|pre|code|svg|path)\b/i; +const HTML_ATTR_PATTERN = /\b(?:style|class|id|href|src|data-)\s*=/i; + +const PROMPT_STAGES = new Set([ + "finalPrompt", + "input.userMessage", + "input.recentMessages", + "input.candidateText", + "input.finalPrompt", +]); + +const OUTPUT_STAGES = new Set([ + "rawResponse", + "beforeParse", + "output.rawResponse", + "output.beforeParse", +]); + +function isBeautificationReplace(text = "") { + const normalized = String(text || ""); + return HTML_TAG_PATTERN.test(normalized) || HTML_ATTR_PATTERN.test(normalized); +} + +function parseRegexFromString(regexStr = "") { + const input = String(regexStr || "").trim(); + if (!input) return null; + + const slashFormat = input.match(/^\/([\s\S]+)\/([gimsuy]*)$/); + if (slashFormat) { + try { + return new RegExp(slashFormat[1], slashFormat[2]); + } catch { + return null; + } + } + + try { + return new RegExp(input, "g"); + } catch { + return null; + } +} + +function normalizeTrimStrings(rawTrim) { + if (Array.isArray(rawTrim)) { + return rawTrim.map((item) => String(item || "")).filter(Boolean); + } + if (typeof rawTrim === "string") { + return rawTrim + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); + } + return []; +} + +function normalizeRule(raw = {}, fallbackSource = "local", index = 0) { + const destination = + raw?.destination && typeof raw.destination === "object" + ? raw.destination + : null; + const source = + raw?.source && typeof raw.source === "object" ? raw.source : null; + + return { + id: String(raw.id || `${fallbackSource}-${index + 1}`), + scriptName: String(raw.script_name || raw.scriptName || ""), + enabled: raw.enabled !== false && raw.disabled !== true, + findRegex: String(raw.find_regex || raw.findRegex || raw.find || "").trim(), + replaceString: String( + raw.replace_string ?? raw.replaceString ?? raw.replace ?? "", + ), + trimStrings: normalizeTrimStrings(raw.trim_strings ?? raw.trimStrings), + sourceFlags: { + user: source ? Boolean(source.user_input) : true, + assistant: source ? Boolean(source.ai_output) : true, + system: source ? Boolean(source.ai_output) : true, + }, + destinationFlags: { + prompt: destination + ? Boolean(destination.prompt) + : raw.promptOnly !== true, + display: destination ? Boolean(destination.display) : Boolean(raw.markdownOnly), + }, + sourceType: fallbackSource, + raw, + }; +} + +function readArrayPath(root, paths = []) { + for (const path of paths) { + let current = root; + let valid = true; + for (const segment of path) { + if (!current || typeof current !== "object") { + valid = false; + break; + } + current = current[segment]; + } + if (valid && Array.isArray(current)) { + return current; + } + } + return []; +} + +function collectViaApi(sourceType) { + const getter = globalThis?.getTavernRegexes; + if (typeof getter !== "function") return []; + try { + if (sourceType === "global") return getter({ type: "global" }) || []; + if (sourceType === "preset") return getter({ type: "preset", name: "in_use" }) || []; + if (sourceType === "character") { + const checkEnabled = globalThis?.isCharacterTavernRegexesEnabled; + if (typeof checkEnabled === "function" && !checkEnabled()) return []; + return getter({ type: "character", name: "current" }) || []; + } + } catch { + return []; + } + return []; +} + +function collectTavernRules(regexConfig = {}) { + const shouldReuse = regexConfig.inheritStRegex !== false; + if (!shouldReuse) return []; + + const sourceConfig = regexConfig.sources || {}; + const enabledSources = { + global: sourceConfig.global !== false, + preset: sourceConfig.preset !== false, + character: sourceConfig.character !== false, + }; + + const context = getContext?.() || {}; + const extSettings = context?.extensionSettings || extension_settings || {}; + const oaiSettings = + context?.chatCompletionSettings || globalThis?.oai_settings || {}; + const collected = []; + const seen = new Set(); + + const pushRules = (items, sourceType) => { + for (let index = 0; index < items.length; index++) { + const normalized = normalizeRule(items[index], sourceType, index); + if (!normalized.enabled || !normalized.findRegex) continue; + const key = `${sourceType}:${normalized.id}:${normalized.findRegex}`; + if (seen.has(key)) continue; + seen.add(key); + collected.push(normalized); + } + }; + + if (enabledSources.global) { + const viaApi = collectViaApi("global"); + if (viaApi.length > 0) { + pushRules(viaApi, "global"); + } else { + pushRules( + readArrayPath(extSettings, [["regex"], ["regex", "regex_scripts"]]), + "global", + ); + } + } + + if (enabledSources.preset) { + const viaApi = collectViaApi("preset"); + if (viaApi.length > 0) { + pushRules(viaApi, "preset"); + } else { + pushRules( + readArrayPath(oaiSettings, [["regex_scripts"], ["extensions", "regex_scripts"]]), + "preset", + ); + } + } + + if (enabledSources.character) { + const viaApi = collectViaApi("character"); + if (viaApi.length > 0) { + pushRules(viaApi, "character"); + } else { + const charId = context?.characterId; + const characters = context?.characters; + if (charId !== undefined && characters) { + const character = characters[Number(charId)]; + pushRules( + readArrayPath(character, [ + ["extensions", "regex_scripts"], + ["data", "extensions", "regex_scripts"], + ]), + "character", + ); + } + } + } + + return collected; +} + +function collectLocalRules(regexConfig = {}) { + const localRules = Array.isArray(regexConfig.localRules) + ? regexConfig.localRules + : []; + return localRules + .map((rule, index) => normalizeRule(rule, "local", index)) + .filter((rule) => rule.enabled && rule.findRegex); +} + +function shouldApplyRuleForStage(rule, stage = "") { + if (PROMPT_STAGES.has(stage)) { + return rule.destinationFlags.prompt !== false; + } + if (OUTPUT_STAGES.has(stage)) { + return true; + } + return rule.destinationFlags.prompt !== false; +} + +function shouldApplyRuleForRole(rule, role = "system") { + if (role === "user") return rule.sourceFlags.user !== false; + if (role === "assistant") return rule.sourceFlags.assistant !== false; + return rule.sourceFlags.system !== false; +} + +function applyOneRule(input, rule, stage = "") { + const regex = parseRegexFromString(rule.findRegex); + if (!regex) return { output: input, changed: false, error: "invalid_regex" }; + + let replacement = rule.replaceString || ""; + if (PROMPT_STAGES.has(stage) && isBeautificationReplace(replacement)) { + replacement = ""; + } + + let output = input.replace(regex, replacement); + if (rule.trimStrings.length > 0) { + for (const trimText of rule.trimStrings) { + if (!trimText) continue; + output = output.split(trimText).join(""); + } + } + + return { output, changed: output !== input, error: "" }; +} + +function pushDebug(collector, entry) { + if (collector && Array.isArray(collector.entries)) { + collector.entries.push(entry); + } +} + +export function applyTaskRegex( + settings = {}, + taskType, + stage, + text, + debugCollector = null, + role = "system", +) { + const profile = getActiveTaskProfile(settings, taskType); + const regexConfig = profile?.regex || {}; + const input = typeof text === "string" ? text : ""; + + if (!regexConfig.enabled) { + pushDebug(debugCollector, { + taskType, + stage, + enabled: false, + appliedRules: [], + sourceCount: { tavern: 0, local: 0 }, + }); + return input; + } + + const stageEnabled = regexConfig?.stages?.[stage] === true; + if (!stageEnabled) { + pushDebug(debugCollector, { + taskType, + stage, + enabled: true, + appliedRules: [], + sourceCount: { tavern: 0, local: 0 }, + skipped: "stage_disabled", + }); + return input; + } + + const tavernRules = collectTavernRules(regexConfig); + const localRules = collectLocalRules(regexConfig); + const orderedRules = [...tavernRules, ...localRules]; + const appliedRules = []; + let output = input; + + for (const rule of orderedRules) { + if (!shouldApplyRuleForStage(rule, stage)) continue; + if (!shouldApplyRuleForRole(rule, role)) continue; + + const result = applyOneRule(output, rule, stage); + if (result.error) { + appliedRules.push({ + id: rule.id, + source: rule.sourceType, + error: result.error, + }); + continue; + } + if (result.changed) { + appliedRules.push({ + id: rule.id, + source: rule.sourceType, + }); + output = result.output; + } + } + + pushDebug(debugCollector, { + taskType, + stage, + enabled: true, + appliedRules, + sourceCount: { + tavern: tavernRules.length, + local: localRules.length, + }, + }); + + return output; +} diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index afac438..014e85d 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -14,7 +14,17 @@ async function loadDefaultSettings() { throw new Error("无法从 index.js 提取 defaultSettings"); } - const context = vm.createContext({}); + const context = vm.createContext({ + createDefaultTaskProfiles() { + return { + extract: { activeProfileId: "default", profiles: [] }, + recall: { activeProfileId: "default", profiles: [] }, + compress: { activeProfileId: "default", profiles: [] }, + synopsis: { activeProfileId: "default", profiles: [] }, + reflection: { activeProfileId: "default", profiles: [] }, + }; + }, + }); const script = new vm.Script(` ${settingsMatch[0]} this.defaultSettings = defaultSettings; @@ -34,5 +44,9 @@ assert.equal(defaultSettings.recallDiffusionTopK, 100); assert.equal(defaultSettings.recallLlmCandidatePool, 30); assert.equal(defaultSettings.recallLlmContextMessages, 4); assert.equal(defaultSettings.injectDepth, 9999); +assert.equal(defaultSettings.taskProfilesVersion, 1); +assert.ok(defaultSettings.taskProfiles); +assert.ok(defaultSettings.taskProfiles.extract); +assert.ok(defaultSettings.taskProfiles.recall); console.log("default-settings tests passed"); diff --git a/tests/generation-options-filter.mjs b/tests/generation-options-filter.mjs new file mode 100644 index 0000000..a48fa5a --- /dev/null +++ b/tests/generation-options-filter.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { resolveTaskGenerationOptions } from "../generation-options.js"; +import { createDefaultTaskProfiles } from "../prompt-profiles.js"; + +function buildSettingsWithExtractGeneration(generation) { + const taskProfiles = createDefaultTaskProfiles(); + taskProfiles.extract.profiles[0].generation = { + ...taskProfiles.extract.profiles[0].generation, + ...generation, + }; + return { + taskProfilesVersion: 1, + taskProfiles, + }; +} + +const openAiLikeSettings = buildSettingsWithExtractGeneration({ + temperature: 0.6, + top_p: 0.95, + top_k: 30, + max_completion_tokens: 512, + stream: true, + reasoning_effort: "high", + enable_function_calling: true, + wrap_user_messages_in_quotes: true, + character_name_prefix: "Narrator", +}); + +const openAiLike = resolveTaskGenerationOptions( + openAiLikeSettings, + "extract", + { max_completion_tokens: 256 }, + { mode: "dedicated-openai-compatible" }, +); + +assert.equal(openAiLike.capabilityMode, "openai-compatible"); +assert.equal(openAiLike.filtered.temperature, 0.6); +assert.equal(openAiLike.filtered.top_p, 0.95); +assert.equal(openAiLike.filtered.max_completion_tokens, 512); +assert.equal(openAiLike.filtered.stream, true); +assert.equal(openAiLike.filtered.reasoning_effort, "high"); +assert.equal(openAiLike.filtered.enable_function_calling, true); +assert.equal(openAiLike.filtered.wrap_user_messages_in_quotes, true); +assert.ok(!Object.prototype.hasOwnProperty.call(openAiLike.filtered, "top_k")); +assert.ok( + openAiLike.removed.some( + (entry) => entry.field === "top_k" && entry.reason === "capability_filtered", + ), +); + +const conservative = resolveTaskGenerationOptions( + openAiLikeSettings, + "extract", + { max_completion_tokens: 256 }, + { mode: "sillytavern-current-model" }, +); +assert.equal(conservative.capabilityMode, "conservative"); +assert.ok( + !Object.prototype.hasOwnProperty.call( + conservative.filtered, + "reasoning_effort", + ), +); +assert.ok( + conservative.removed.some( + (entry) => + entry.field === "reasoning_effort" && + entry.reason === "capability_filtered", + ), +); + +const fallbackSettings = buildSettingsWithExtractGeneration({ + max_completion_tokens: "", +}); +const fallback = resolveTaskGenerationOptions( + fallbackSettings, + "extract", + { max_completion_tokens: 300 }, + { mode: "conservative" }, +); +assert.equal(fallback.filtered.max_completion_tokens, 300); + +console.log("generation-options-filter tests passed"); diff --git a/tests/retrieval-config.mjs b/tests/retrieval-config.mjs index ea463e2..39e45f2 100644 --- a/tests/retrieval-config.mjs +++ b/tests/retrieval-config.mjs @@ -90,6 +90,12 @@ const graph = createGraph(); const helpers = createGraphHelpers(graph); const retrieve = await loadRetrieve({ ...helpers, + buildTaskPrompt() { + return { systemPrompt: "" }; + }, + applyTaskRegex(_settings, _taskType, _stage, text) { + return text; + }, hybridScore: ({ graphScore = 0, vectorScore = 0, importance = 0 }) => graphScore + vectorScore + importance, reinforceAccessBatch() {}, diff --git a/tests/task-profile-migration.mjs b/tests/task-profile-migration.mjs new file mode 100644 index 0000000..24f0842 --- /dev/null +++ b/tests/task-profile-migration.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { + createDefaultTaskProfiles, + getActiveTaskProfile, + migrateLegacyTaskProfiles, +} from "../prompt-profiles.js"; + +const legacySettings = { + extractPrompt: "旧提取提示", + recallPrompt: "旧召回提示", + compressPrompt: "", + synopsisPrompt: "", + reflectionPrompt: "", + consolidationPrompt: "", +}; + +const migrated = migrateLegacyTaskProfiles(legacySettings); +assert.equal(migrated.taskProfilesVersion, 1); +assert.ok(migrated.taskProfiles); +assert.ok(migrated.taskProfiles.extract); +assert.ok(migrated.taskProfiles.recall); + +const extractProfile = getActiveTaskProfile( + { + ...legacySettings, + taskProfiles: migrated.taskProfiles, + }, + "extract", +); +assert.equal(extractProfile.taskType, "extract"); +assert.equal(extractProfile.id, "default"); +assert.ok(Array.isArray(extractProfile.blocks)); +assert.equal(extractProfile.blocks[0].type, "legacyPrompt"); + +const defaults = createDefaultTaskProfiles(); +assert.ok(defaults.extract.profiles.length > 0); +assert.ok(defaults.recall.profiles.length > 0); +assert.ok(defaults.compress.profiles.length > 0); +assert.ok(defaults.synopsis.profiles.length > 0); +assert.ok(defaults.reflection.profiles.length > 0); + +console.log("task-profile-migration tests passed"); diff --git a/tests/task-profile-storage.mjs b/tests/task-profile-storage.mjs new file mode 100644 index 0000000..4bb2006 --- /dev/null +++ b/tests/task-profile-storage.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { + cloneTaskProfile, + createBuiltinPromptBlock, + createCustomPromptBlock, + createDefaultTaskProfiles, + createLocalRegexRule, + exportTaskProfile, + getActiveTaskProfile, + getLegacyPromptFieldForTask, + importTaskProfile, + restoreDefaultTaskProfile, + upsertTaskProfile, +} from "../prompt-profiles.js"; + +const taskProfiles = createDefaultTaskProfiles(); +const baseProfile = taskProfiles.extract.profiles[0]; + +const clonedProfile = cloneTaskProfile(baseProfile, { + taskType: "extract", + name: "激进提取", +}); +clonedProfile.blocks = [ + ...clonedProfile.blocks, + createBuiltinPromptBlock("extract", "userMessage", { + name: "用户消息块", + injectionMode: "prepend", + order: 1, + }), + createCustomPromptBlock("extract", { + name: "补充说明", + content: "请关注 {{userMessage}}", + role: "user", + order: 2, + }), +]; +clonedProfile.regex.localRules = [ + createLocalRegexRule("extract", { + script_name: "裁边", + find_regex: "/^foo/g", + replace_string: "bar", + }), +]; + +const updatedProfiles = upsertTaskProfile(taskProfiles, "extract", clonedProfile, { + setActive: true, +}); + +const activeProfile = getActiveTaskProfile( + { taskProfiles: updatedProfiles }, + "extract", +); +assert.equal(activeProfile.name, "激进提取"); +assert.equal(activeProfile.blocks.length, 3); +assert.equal(activeProfile.blocks[1].type, "builtin"); +assert.equal(activeProfile.blocks[1].sourceKey, "userMessage"); +assert.equal(activeProfile.blocks[1].injectionMode, "prepend"); +assert.equal(activeProfile.blocks[2].type, "custom"); +assert.equal(activeProfile.blocks[2].role, "user"); +assert.equal(activeProfile.regex.localRules.length, 1); +assert.equal(activeProfile.regex.localRules[0].script_name, "裁边"); + +const exported = exportTaskProfile( + updatedProfiles, + "extract", + clonedProfile.id, +); +assert.equal(exported.format, "st-bme-task-profile"); +assert.equal(exported.taskType, "extract"); +assert.equal(exported.profile.name, "激进提取"); + +const imported = importTaskProfile(updatedProfiles, JSON.stringify(exported)); +assert.equal(imported.taskType, "extract"); +assert.notEqual(imported.profile.id, clonedProfile.id); +assert.equal(imported.profile.blocks[1].sourceKey, "userMessage"); + +const restoredProfiles = restoreDefaultTaskProfile(imported.taskProfiles, "extract"); +const restoredActive = getActiveTaskProfile( + { taskProfiles: restoredProfiles }, + "extract", +); +assert.equal(restoredActive.id, "default"); +assert.equal(getLegacyPromptFieldForTask("extract"), "extractPrompt"); + +console.log("task-profile-storage tests passed"); diff --git a/tests/vector-config.mjs b/tests/vector-config.mjs index b96b93b..18aa1bb 100644 --- a/tests/vector-config.mjs +++ b/tests/vector-config.mjs @@ -13,6 +13,8 @@ async function loadVectorHelpers() { source.match(/export const BACKEND_VECTOR_SOURCES = \[[\s\S]*?\];/m)?.[0], source.match(/export const BACKEND_DEFAULT_MODELS = \{[\s\S]*?\};/m)?.[0], source.match(/const BACKEND_SOURCES_REQUIRING_API_URL = new Set\([\s\S]*?\);/m)?.[0], + source.match(/const VECTOR_REQUEST_TIMEOUT_MS = \d+;/m)?.[0], + source.match(/function getConfiguredTimeoutMs\(config = \{\}\) \{[\s\S]*?^\}/m)?.[0], source.match(/export function normalizeOpenAICompatibleBaseUrl\(value, autoSuffix = true\) \{[\s\S]*?^\}/m)?.[0], source.match(/export function getVectorConfigFromSettings\(settings = \{\}\) \{[\s\S]*?^\}/m)?.[0], source.match(/export function isBackendVectorConfig\(config\) \{[\s\S]*?^\}/m)?.[0], @@ -20,7 +22,7 @@ async function loadVectorHelpers() { source.match(/export function validateVectorConfig\(config\) \{[\s\S]*?^\}/m)?.[0], ].filter(Boolean); - if (pieces.length < 8) { + if (pieces.length < 10) { throw new Error("无法从 vector-index.js 提取向量配置辅助函数"); }