-
操作结果
-
-
+
+
+
+
+
+
世界推演
+
+
+
+
+
+
+
+
+
+
+
+
添加联络人
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
操作结果
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/story-outline/story-outline.js b/modules/story-outline/story-outline.js
index 31cfda0..aa01a92 100644
--- a/modules/story-outline/story-outline.js
+++ b/modules/story-outline/story-outline.js
@@ -1,3 +1,4 @@
+/* eslint-disable no-restricted-syntax */
/**
* ============================================================================
* Story Outline 模块 - 小白板
diff --git a/modules/story-summary/data/db.js b/modules/story-summary/data/db.js
index bf10720..540f110 100644
--- a/modules/story-summary/data/db.js
+++ b/modules/story-summary/data/db.js
@@ -3,7 +3,7 @@
import Dexie from '../../../libs/dexie.mjs';
const DB_NAME = 'LittleWhiteBox_Memory';
-const DB_VERSION = 2;
+const DB_VERSION = 3; // 升级版本
// Chunk parameters
export const CHUNK_MAX_TOKENS = 200;
@@ -15,6 +15,7 @@ db.version(DB_VERSION).stores({
chunks: '[chatId+chunkId], chatId, [chatId+floor]',
chunkVectors: '[chatId+chunkId], chatId',
eventVectors: '[chatId+eventId], chatId',
+ stateVectors: '[chatId+atomId], chatId, [chatId+floor]', // L0 向量表
});
export { db };
@@ -22,3 +23,4 @@ export const metaTable = db.meta;
export const chunksTable = db.chunks;
export const chunkVectorsTable = db.chunkVectors;
export const eventVectorsTable = db.eventVectors;
+export const stateVectorsTable = db.stateVectors;
diff --git a/modules/story-summary/generate/prompt.js b/modules/story-summary/generate/prompt.js
index 88ee49b..2c86e6d 100644
--- a/modules/story-summary/generate/prompt.js
+++ b/modules/story-summary/generate/prompt.js
@@ -128,9 +128,16 @@ function formatArcLine(a) {
return `- ${a.name}:${a.trajectory}`;
}
-// 完整 chunk 输出(不截断)
+// 完整 chunk 输出(支持 L0 虚拟 chunk)
function formatChunkFullLine(c) {
const { name1, name2 } = getContext();
+
+ // L0 虚拟 chunk
+ if (c.isL0) {
+ return `› #${c.floor + 1} [📌] ${String(c.text || "").trim()}`;
+ }
+
+ // L1 真实 chunk
const speaker = c.isUser ? (name1 || "用户") : (name2 || "角色");
return `› #${c.floor + 1} [${speaker}] ${String(c.text || "").trim()}`;
}
diff --git a/modules/story-summary/story-summary.js b/modules/story-summary/story-summary.js
index 9b01842..c9a343a 100644
--- a/modules/story-summary/story-summary.js
+++ b/modules/story-summary/story-summary.js
@@ -75,6 +75,8 @@ import {
syncOnMessageSwiped,
syncOnMessageReceived,
} from "./vector/chunk-builder.js";
+import { initStateIntegration, rebuildStateVectors } from "./vector/state-integration.js";
+import { clearStateVectors, getStateAtomsCount, getStateVectorsCount } from "./vector/state-store.js";
// vector io
import { exportVectors, importVectors } from "./vector/vector-io.js";
@@ -210,6 +212,8 @@ async function sendVectorStatsToFrame() {
const stats = await getStorageStats(chatId);
const chunkStatus = await getChunkBuildStatus();
const totalMessages = chat?.length || 0;
+ const stateAtomsCount = getStateAtomsCount();
+ const stateVectorsCount = await getStateVectorsCount(chatId);
const cfg = getVectorConfig();
let mismatch = false;
@@ -228,6 +232,8 @@ async function sendVectorStatsToFrame() {
builtFloors: chunkStatus.builtFloors,
totalFloors: chunkStatus.totalFloors,
totalMessages,
+ stateAtoms: stateAtomsCount,
+ stateVectors: stateVectorsCount,
},
mismatch,
});
@@ -350,6 +356,14 @@ async function handleGenerateVectors(vectorCfg) {
const batchSize = isLocal ? 5 : 25;
const concurrency = isLocal ? 1 : 2;
+ // L0 向量重建
+ try {
+ await rebuildStateVectors(chatId, vectorCfg);
+ } catch (e) {
+ xbLog.error(MODULE_ID, "L0 向量重建失败", e);
+ // 不阻塞,继续 L1/L2
+ }
+
await clearAllChunks(chatId);
await updateMeta(chatId, { lastChunkFloor: -1, fingerprint });
@@ -649,6 +663,7 @@ async function handleClearVectors() {
await clearEventVectors(chatId);
await clearAllChunks(chatId);
+ await clearStateVectors(chatId);
await updateMeta(chatId, { lastChunkFloor: -1 });
await sendVectorStatsToFrame();
await executeSlashCommand('/echo severity=info 向量数据已清除。如需恢复召回功能,请重新点击"生成向量"。');
@@ -1400,7 +1415,7 @@ async function handleGenerationStarted(type, _params, isDryRun) {
// 2) depth:倒序插入,从末尾往前数
// 最小为 1,避免插入到最底部导致 AI 看到的最后是总结
- const depth = Math.max(1, chatLen - boundary - 1);
+ const depth = Math.max(2, chatLen - boundary - 1);
if (depth < 0) return;
// 3) 构建注入文本(保持原逻辑)
@@ -1504,4 +1519,5 @@ $(document).on("xiaobaix:storySummary:toggle", (_e, enabled) => {
jQuery(() => {
if (!getSettings().storySummary?.enabled) return;
registerEvents();
+ initStateIntegration();
});
diff --git a/modules/story-summary/vector/chunk-builder.js b/modules/story-summary/vector/chunk-builder.js
index f8bd988..f2b47fd 100644
--- a/modules/story-summary/vector/chunk-builder.js
+++ b/modules/story-summary/vector/chunk-builder.js
@@ -50,8 +50,10 @@ export function chunkMessage(floor, message, maxTokens = CHUNK_MAX_TOKENS) {
// 1. 应用用户自定义过滤规则
// 2. 移除 TTS 标记(硬编码)
+ // 3. 移除
标签(硬编码,L0 已单独存储)
const cleanText = filterText(text)
.replace(/\[tts:[^\]]*\]/gi, '')
+ .replace(/[\s\S]*?<\/state>/gi, '')
.trim();
if (!cleanText) return [];
diff --git a/modules/story-summary/vector/embedder.js b/modules/story-summary/vector/embedder.js
index 5f7b40f..d1e8ae8 100644
--- a/modules/story-summary/vector/embedder.js
+++ b/modules/story-summary/vector/embedder.js
@@ -471,8 +471,6 @@ async function embedOnline(texts, provider, config, options = {}) {
const providerConfig = ONLINE_PROVIDERS[provider];
const baseUrl = (providerConfig?.baseUrl || url || '').replace(/\/+$/, '');
- const reqId = Math.random().toString(36).slice(2, 6);
-
// 永远重试:指数退避 + 上限 + 抖动
const BASE_WAIT_MS = 1200;
const MAX_WAIT_MS = 15000;
@@ -491,9 +489,6 @@ async function embedOnline(texts, provider, config, options = {}) {
let attempt = 0;
while (true) {
attempt++;
- const startTime = Date.now();
- console.log(`[embed ${reqId}] send ${texts.length} items (attempt ${attempt})`);
-
try {
let response;
@@ -526,8 +521,6 @@ async function embedOnline(texts, provider, config, options = {}) {
});
}
- console.log(`[embed ${reqId}] status=${response.status} time=${Date.now() - startTime}ms`);
-
// 需要“永远重试”的典型状态:
// - 429:限流
// - 403:配额/风控/未实名等(你提到的硅基未认证)
@@ -541,7 +534,6 @@ async function embedOnline(texts, provider, config, options = {}) {
const exp = Math.min(MAX_WAIT_MS, BASE_WAIT_MS * Math.pow(2, Math.min(attempt, 6) - 1));
const jitter = Math.floor(Math.random() * 350);
const waitMs = exp + jitter;
- console.warn(`[embed ${reqId}] retryable error ${response.status}, wait ${waitMs}ms`);
await sleepAbortable(waitMs);
continue;
}
@@ -569,7 +561,6 @@ async function embedOnline(texts, provider, config, options = {}) {
const exp = Math.min(MAX_WAIT_MS, BASE_WAIT_MS * Math.pow(2, Math.min(attempt, 6) - 1));
const jitter = Math.floor(Math.random() * 350);
const waitMs = exp + jitter;
- console.warn(`[embed ${reqId}] network/error, wait ${waitMs}ms then retry: ${e?.message || e}`);
await sleepAbortable(waitMs);
}
}
diff --git a/modules/story-summary/vector/recall.js b/modules/story-summary/vector/recall.js
index 1d71ed4..b1a2aab 100644
--- a/modules/story-summary/vector/recall.js
+++ b/modules/story-summary/vector/recall.js
@@ -12,6 +12,12 @@ import { xbLog } from '../../../core/debug-core.js';
import { getContext } from '../../../../../../extensions.js';
import { getSummaryStore } from '../data/store.js';
import { filterText } from './text-filter.js';
+import {
+ searchStateAtoms,
+ buildL0FloorBonus,
+ stateToVirtualChunks,
+ mergeAndSparsify,
+} from './state-recall.js';
const MODULE_ID = 'recall';
@@ -35,12 +41,16 @@ const CONFIG = {
MIN_SIMILARITY_EVENT: 0.65,
MMR_LAMBDA: 0.72,
- BONUS_PARTICIPANT_HIT: 0.08,
- BONUS_TEXT_HIT: 0.05,
- BONUS_WORLD_TOPIC_HIT: 0.06,
-
- FLOOR_LIMIT: 1,
-};
+ BONUS_PARTICIPANT_HIT: 0.08,
+ BONUS_TEXT_HIT: 0.05,
+ BONUS_WORLD_TOPIC_HIT: 0.06,
+
+ // L0 配置
+ L0_FLOOR_BONUS_FACTOR: 0.10,
+ FLOOR_MAX_CHUNKS: 2,
+
+ FLOOR_LIMIT: 1,
+};
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
@@ -136,10 +146,20 @@ function sortCausalEvents(causalArray) {
});
}
-function normalize(s) {
- return String(s || '').normalize('NFKC').replace(/[\u200B-\u200D\uFEFF]/g, '').trim();
-}
-
+function normalize(s) {
+ return String(s || '').normalize('NFKC').replace(/[\u200B-\u200D\uFEFF]/g, '').trim();
+}
+
+// 从 summary 解析楼层范围:(#321-322) 或 (#321)
+function parseFloorRange(summary) {
+ if (!summary) return null;
+ const match = String(summary).match(/\(#(\d+)(?:-(\d+))?\)/);
+ if (!match) return null;
+ const start = Math.max(0, parseInt(match[1], 10) - 1);
+ const end = Math.max(0, (match[2] ? parseInt(match[2], 10) : parseInt(match[1], 10)) - 1);
+ return { start, end };
+}
+
function cleanForRecall(text) {
// 1. 应用用户自定义过滤规则
// 2. 移除 TTS 标记(硬编码)
@@ -308,7 +328,7 @@ function mmrSelect(candidates, k, lambda, getVector, getScore) {
// L1 Chunks 检索
// ═══════════════════════════════════════════════════════════════════════════
-async function searchChunks(queryVector, vectorConfig) {
+async function searchChunks(queryVector, vectorConfig, l0FloorBonus = new Map()) {
const { chatId } = getContext();
if (!chatId || !queryVector?.length) return [];
@@ -321,12 +341,18 @@ async function searchChunks(queryVector, vectorConfig) {
const scored = chunkVectors.map(cv => {
const match = String(cv.chunkId).match(/c-(\d+)-(\d+)/);
+ const floor = match ? parseInt(match[1], 10) : 0;
+ const baseSim = cosineSimilarity(queryVector, cv.vector);
+ const l0Bonus = l0FloorBonus.get(floor) || 0;
+
return {
_id: cv.chunkId,
chunkId: cv.chunkId,
- floor: match ? parseInt(match[1], 10) : 0,
- chunkIdx: match ? parseInt(match[2], 10) : 0,
- similarity: cosineSimilarity(queryVector, cv.vector),
+ floor,
+ chunkIdx: match ? parseInt(match[2], 10) : 0,
+ similarity: baseSim + l0Bonus,
+ _baseSimilarity: baseSim,
+ _l0Bonus: l0Bonus,
vector: cv.vector,
};
});
@@ -403,30 +429,19 @@ async function searchChunks(queryVector, vectorConfig) {
// L2 Events 检索
// ═══════════════════════════════════════════════════════════════════════════
-async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEntities) {
- const { chatId, name1 } = getContext();
- if (!chatId || !queryVector?.length) {
- console.warn('[searchEvents] 早期返回: chatId或queryVector为空');
- return [];
- }
-
- const meta = await getMeta(chatId);
- const fp = getEngineFingerprint(vectorConfig);
- console.log('[searchEvents] fingerprint检查:', {
- metaFp: meta.fingerprint,
- currentFp: fp,
- match: meta.fingerprint === fp || !meta.fingerprint,
- });
- if (meta.fingerprint && meta.fingerprint !== fp) return [];
-
- const eventVectors = await getAllEventVectors(chatId);
- const vectorMap = new Map(eventVectors.map(v => [v.eventId, v.vector]));
- console.log('[searchEvents] 向量数据:', {
- eventVectorsCount: eventVectors.length,
- vectorMapSize: vectorMap.size,
- allEventsCount: allEvents?.length,
- });
- if (!vectorMap.size) return [];
+async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEntities, l0FloorBonus = new Map()) {
+ const { chatId, name1 } = getContext();
+ if (!chatId || !queryVector?.length) {
+ return [];
+ }
+
+ const meta = await getMeta(chatId);
+ const fp = getEngineFingerprint(vectorConfig);
+ if (meta.fingerprint && meta.fingerprint !== fp) return [];
+
+ const eventVectors = await getAllEventVectors(chatId);
+ const vectorMap = new Map(eventVectors.map(v => [v.eventId, v.vector]));
+ if (!vectorMap.size) return [];
const userName = normalize(name1);
const querySet = new Set((queryEntities || []).map(normalize));
@@ -458,11 +473,23 @@ async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEn
reasons.push('text');
}
- // world topic 命中
- if (worldTopics.some(topic => querySet.has(topic) && text.includes(topic))) {
- bonus += CONFIG.BONUS_WORLD_TOPIC_HIT;
- reasons.push('world');
- }
+ // world topic 命中
+ if (worldTopics.some(topic => querySet.has(topic) && text.includes(topic))) {
+ bonus += CONFIG.BONUS_WORLD_TOPIC_HIT;
+ reasons.push('world');
+ }
+
+ // L0 加权:事件覆盖楼层范围命中
+ const range = parseFloorRange(event.summary);
+ if (range) {
+ for (let f = range.start; f <= range.end; f++) {
+ if (l0FloorBonus.has(f)) {
+ bonus += l0FloorBonus.get(f);
+ reasons.push('L0');
+ break;
+ }
+ }
+ }
return {
_id: event.id,
@@ -477,15 +504,6 @@ async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEn
};
});
- // 相似度分布日志
- const simValues = scored.map(s => s.similarity).sort((a, b) => b - a);
- console.log('[searchEvents] 相似度分布(前20):', simValues.slice(0, 20));
- console.log('[searchEvents] 相似度分布(后20):', simValues.slice(-20));
- console.log('[searchEvents] 有向量的事件数:', scored.filter(s => s.similarity > 0).length);
- console.log('[searchEvents] sim >= 0.6:', scored.filter(s => s.similarity >= 0.6).length);
- console.log('[searchEvents] sim >= 0.5:', scored.filter(s => s.similarity >= 0.5).length);
- console.log('[searchEvents] sim >= 0.3:', scored.filter(s => s.similarity >= 0.3).length);
-
// ★ 记录过滤前的分布(用 finalScore,与显示一致)
const preFilterDistribution = {
total: scored.length,
@@ -503,7 +521,6 @@ async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEn
.filter(s => s.finalScore >= CONFIG.MIN_SIMILARITY_EVENT)
.sort((a, b) => b.finalScore - a.finalScore)
.slice(0, CONFIG.CANDIDATE_EVENTS);
- console.log('[searchEvents] 过滤后candidates:', candidates.length);
// 动态 K:质量不够就少拿
const dynamicK = Math.min(CONFIG.MAX_EVENTS, candidates.length);
@@ -575,7 +592,7 @@ function formatCausalTree(causalEvents, recalledEvents) {
// 日志:主报告
// ═══════════════════════════════════════════════════════════════════════════
-function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResults, allEvents, queryEntities, causalEvents = [], chunkPreFilterStats = null }) {
+function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResults, allEvents, queryEntities, causalEvents = [], chunkPreFilterStats = null, l0Results = [] }) {
const lines = [
'╔══════════════════════════════════════════════════════════════╗',
'║ 记忆召回报告 ║',
@@ -604,13 +621,36 @@ function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResult
});
lines.push('');
- lines.push('┌─────────────────────────────────────────────────────────────┐');
- lines.push('│ 【提取实体】用于判断"亲身经历"(DIRECT) │');
- lines.push('└─────────────────────────────────────────────────────────────┘');
- lines.push(` ${queryEntities?.length ? queryEntities.join('、') : '(无)'}`);
-
- lines.push('');
- lines.push('┌─────────────────────────────────────────────────────────────┐');
+ lines.push('┌─────────────────────────────────────────────────────────────┐');
+ lines.push('│ 【提取实体】用于判断"亲身经历"(DIRECT) │');
+ lines.push('└─────────────────────────────────────────────────────────────┘');
+ lines.push(` ${queryEntities?.length ? queryEntities.join('、') : '(无)'}`);
+
+ lines.push('');
+ lines.push('┌─────────────────────────────────────────────────────────────┐');
+ lines.push('│ 【L0 语义锚点】状态变更加权信号 │');
+ lines.push('└─────────────────────────────────────────────────────────────┘');
+
+ if (l0Results.length) {
+ const l0Floors = [...new Set(l0Results.map(r => r.floor))].sort((a, b) => a - b);
+ lines.push(` 召回: ${l0Results.length} 条`);
+ lines.push(` 影响楼层: ${l0Floors.join(', ')}(L1/L2 候选在这些楼层获得 +${CONFIG.L0_FLOOR_BONUS_FACTOR} 加分)`);
+ lines.push('');
+
+ l0Results.slice(0, 10).forEach((r, i) => {
+ lines.push(` ${String(i + 1).padStart(2)}. #${r.floor} ${r.atom.semantic.slice(0, 50)}${r.atom.semantic.length > 50 ? '...' : ''}`);
+ lines.push(` 相似度: ${r.similarity.toFixed(3)}`);
+ });
+
+ if (l0Results.length > 10) {
+ lines.push(` ... 还有 ${l0Results.length - 10} 条`);
+ }
+ } else {
+ lines.push(' 召回: 0 条(无 L0 数据或未启用)');
+ }
+
+ lines.push('');
+ lines.push('┌─────────────────────────────────────────────────────────────┐');
lines.push('│ 【L1 原文片段】 │');
lines.push('└─────────────────────────────────────────────────────────────┘');
@@ -706,16 +746,36 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options =
return { events: [], chunks: [], elapsed: Math.round(performance.now() - T0), logText: 'Empty query vector.' };
}
- const lexicon = buildEntityLexicon(store, allEvents);
- const queryEntities = extractEntities([queryText, ...segments].join('\n'), lexicon);
-
+ const lexicon = buildEntityLexicon(store, allEvents);
+ const queryEntities = extractEntities([queryText, ...segments].join('\n'), lexicon);
+
+ // ════════════════════════════════════════════════════════════════════════
+ // L0 召回
+ // ════════════════════════════════════════════════════════════════════════
+ let l0Results = [];
+ let l0FloorBonus = new Map();
+ let l0VirtualChunks = [];
+
+ try {
+ l0Results = await searchStateAtoms(queryVector, vectorConfig);
+ l0FloorBonus = buildL0FloorBonus(l0Results, CONFIG.L0_FLOOR_BONUS_FACTOR);
+ l0VirtualChunks = stateToVirtualChunks(l0Results);
+ } catch (e) {
+ xbLog.warn(MODULE_ID, 'L0 召回失败,降级处理', e);
+ }
+
const [chunkResults, eventResults] = await Promise.all([
- searchChunks(queryVector, vectorConfig),
- searchEvents(queryVector, allEvents, vectorConfig, store, queryEntities),
+ searchChunks(queryVector, vectorConfig, l0FloorBonus),
+ searchEvents(queryVector, allEvents, vectorConfig, store, queryEntities, l0FloorBonus),
]);
const chunkPreFilterStats = chunkResults._preFilterStats || null;
+ // ════════════════════════════════════════════════════════════════════════
+ // 合并 L0 虚拟 chunks 到 L1
+ // ════════════════════════════════════════════════════════════════════════
+ const mergedChunks = mergeAndSparsify(l0VirtualChunks, chunkResults, CONFIG.FLOOR_MAX_CHUNKS);
+
// ─────────────────────────────────────────────────────────────────────
// 因果链追溯:从 eventResults 出发找祖先事件
// 注意:是否“额外注入”要去重(如果祖先事件本来已召回,就不额外注入)
@@ -742,25 +802,26 @@ export async function recallMemory(queryText, allEvents, vectorConfig, options =
const causalEventsTruncated = causalEvents.slice(0, CONFIG.CAUSAL_INJECT_MAX);
const elapsed = Math.round(performance.now() - T0);
- const logText = formatRecallLog({
- elapsed,
- queryText,
- segments,
- weights,
- chunkResults,
+ const logText = formatRecallLog({
+ elapsed,
+ queryText,
+ segments,
+ weights,
+ chunkResults: mergedChunks,
eventResults,
allEvents,
queryEntities,
causalEvents: causalEventsTruncated,
chunkPreFilterStats,
+ l0Results,
});
-
- console.group('%c[Recall]', 'color: #7c3aed; font-weight: bold');
- console.log(`Elapsed: ${elapsed}ms | Entities: ${queryEntities.join(', ') || '(none)'}`);
- console.log(`L1: ${chunkResults.length} | L2: ${eventResults.length}/${allEvents.length} | Causal: ${causalEventsTruncated.length}`);
- console.groupEnd();
-
- return { events: eventResults, causalEvents: causalEventsTruncated, chunks: chunkResults, elapsed, logText, queryEntities };
+
+ console.group('%c[Recall]', 'color: #7c3aed; font-weight: bold');
+ console.log(`Elapsed: ${elapsed}ms | L0: ${l0Results.length} | Entities: ${queryEntities.join(', ') || '(none)'}`);
+ console.log(`L1: ${mergedChunks.length} | L2: ${eventResults.length}/${allEvents.length} | Causal: ${causalEventsTruncated.length}`);
+ console.groupEnd();
+
+ return { events: eventResults, causalEvents: causalEventsTruncated, chunks: mergedChunks, elapsed, logText, queryEntities, l0Results };
}
export function buildQueryText(chat, count = 2, excludeLastAi = false) {
diff --git a/modules/story-summary/vector/state-integration.js b/modules/story-summary/vector/state-integration.js
new file mode 100644
index 0000000..2024126
--- /dev/null
+++ b/modules/story-summary/vector/state-integration.js
@@ -0,0 +1,153 @@
+// ═══════════════════════════════════════════════════════════════════════════
+// Story Summary - State Integration (L0)
+// 事件监听 + 回滚钩子注册
+// ═══════════════════════════════════════════════════════════════════════════
+
+import { getContext } from '../../../../../../extensions.js';
+import { xbLog } from '../../../core/debug-core.js';
+import {
+ saveStateAtoms,
+ saveStateVectors,
+ deleteStateAtomsFromFloor,
+ deleteStateVectorsFromFloor,
+ getStateAtoms,
+ clearStateVectors,
+} from './state-store.js';
+import { embed, getEngineFingerprint } from './embedder.js';
+import { getVectorConfig } from '../data/config.js';
+
+const MODULE_ID = 'state-integration';
+
+let initialized = false;
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 初始化
+// ═══════════════════════════════════════════════════════════════════════════
+
+export function initStateIntegration() {
+ if (initialized) return;
+ initialized = true;
+
+ // 监听变量团队的事件
+ $(document).on('xiaobaix:variables:stateAtomsGenerated', handleStateAtomsGenerated);
+
+ // 注册回滚钩子
+ globalThis.LWB_StateRollbackHook = handleStateRollback;
+
+ xbLog.info(MODULE_ID, 'L0 状态层集成已初始化');
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 事件处理
+// ═══════════════════════════════════════════════════════════════════════════
+
+async function handleStateAtomsGenerated(e, data) {
+ const { atoms } = data || {};
+ if (!atoms?.length) return;
+
+ const { chatId } = getContext();
+ if (!chatId) return;
+
+ const validAtoms = atoms.filter(a => a?.chatId === chatId);
+ if (!validAtoms.length) {
+ xbLog.warn(MODULE_ID, `atoms.chatId 不匹配,期望 ${chatId},跳过`);
+ return;
+ }
+
+ xbLog.info(MODULE_ID, `收到 ${validAtoms.length} 个 StateAtom`);
+
+ // 1. 存入 chat_metadata(持久化)
+ saveStateAtoms(validAtoms);
+
+ // 2. 向量化并存入 IndexedDB
+ const vectorCfg = getVectorConfig();
+ if (!vectorCfg?.enabled) {
+ xbLog.info(MODULE_ID, '向量未启用,跳过 L0 向量化');
+ return;
+ }
+
+ await vectorizeAtoms(chatId, validAtoms, vectorCfg);
+}
+
+async function vectorizeAtoms(chatId, atoms, vectorCfg) {
+ const texts = atoms.map(a => a.semantic);
+ const fingerprint = getEngineFingerprint(vectorCfg);
+
+ try {
+ const vectors = await embed(texts, vectorCfg);
+
+ const items = atoms.map((a, i) => ({
+ atomId: a.atomId,
+ floor: a.floor,
+ vector: vectors[i],
+ }));
+
+ await saveStateVectors(chatId, items, fingerprint);
+ xbLog.info(MODULE_ID, `L0 向量化完成: ${items.length} 个`);
+ } catch (e) {
+ xbLog.error(MODULE_ID, 'L0 向量化失败', e);
+ // 不阻塞,向量可后续通过"生成向量"重建
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 回滚钩子
+// ═══════════════════════════════════════════════════════════════════════════
+
+async function handleStateRollback(floor) {
+ xbLog.info(MODULE_ID, `收到回滚请求: floor >= ${floor}`);
+
+ const { chatId } = getContext();
+
+ // 1. 删除 chat_metadata 中的 atoms
+ deleteStateAtomsFromFloor(floor);
+
+ // 2. 删除 IndexedDB 中的 vectors
+ if (chatId) {
+ await deleteStateVectorsFromFloor(chatId, floor);
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 重建向量(供"生成向量"按钮调用)
+// ═══════════════════════════════════════════════════════════════════════════
+
+export async function rebuildStateVectors(chatId, vectorCfg) {
+ if (!chatId || !vectorCfg?.enabled) return { built: 0 };
+
+ const atoms = getStateAtoms();
+ if (!atoms.length) return { built: 0 };
+
+ xbLog.info(MODULE_ID, `开始重建 L0 向量: ${atoms.length} 个 atom`);
+
+ // 清空旧向量
+ await clearStateVectors(chatId);
+
+ // 重新向量化
+ const fingerprint = getEngineFingerprint(vectorCfg);
+ const batchSize = vectorCfg.engine === 'local' ? 5 : 25;
+ let built = 0;
+
+ for (let i = 0; i < atoms.length; i += batchSize) {
+ const batch = atoms.slice(i, i + batchSize);
+ const texts = batch.map(a => a.semantic);
+
+ try {
+ const vectors = await embed(texts, vectorCfg);
+
+ const items = batch.map((a, j) => ({
+ atomId: a.atomId,
+ floor: a.floor,
+ vector: vectors[j],
+ }));
+
+ await saveStateVectors(chatId, items, fingerprint);
+ built += items.length;
+ } catch (e) {
+ xbLog.error(MODULE_ID, `L0 向量化批次失败: ${i}-${i + batchSize}`, e);
+ }
+ }
+
+ xbLog.info(MODULE_ID, `L0 向量重建完成: ${built}/${atoms.length}`);
+ return { built };
+}
diff --git a/modules/story-summary/vector/state-recall.js b/modules/story-summary/vector/state-recall.js
new file mode 100644
index 0000000..28caa95
--- /dev/null
+++ b/modules/story-summary/vector/state-recall.js
@@ -0,0 +1,160 @@
+// ═══════════════════════════════════════════════════════════════════════════
+// Story Summary - State Recall (L0)
+// L0 语义锚点召回 + floor bonus + 虚拟 chunk 转换
+// ═══════════════════════════════════════════════════════════════════════════
+
+import { getContext } from '../../../../../../extensions.js';
+import { getAllStateVectors, getStateAtoms } from './state-store.js';
+import { getMeta } from './chunk-store.js';
+import { getEngineFingerprint } from './embedder.js';
+import { xbLog } from '../../../core/debug-core.js';
+
+const MODULE_ID = 'state-recall';
+
+const CONFIG = {
+ MAX_RESULTS: 20,
+ MIN_SIMILARITY: 0.55,
+};
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 工具函数
+// ═══════════════════════════════════════════════════════════════════════════
+
+function cosineSimilarity(a, b) {
+ if (!a?.length || !b?.length || a.length !== b.length) return 0;
+ let dot = 0, nA = 0, nB = 0;
+ for (let i = 0; i < a.length; i++) {
+ dot += a[i] * b[i];
+ nA += a[i] * a[i];
+ nB += b[i] * b[i];
+ }
+ return nA && nB ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// L0 向量检索
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * 检索与 query 相似的 StateAtoms
+ * @returns {Array<{atom, similarity}>}
+ */
+export async function searchStateAtoms(queryVector, vectorConfig) {
+ const { chatId } = getContext();
+ if (!chatId || !queryVector?.length) return [];
+
+ // 检查 fingerprint
+ const meta = await getMeta(chatId);
+ const fp = getEngineFingerprint(vectorConfig);
+ if (meta.fingerprint && meta.fingerprint !== fp) {
+ xbLog.warn(MODULE_ID, 'fingerprint 不匹配,跳过 L0 召回');
+ return [];
+ }
+
+ // 获取向量
+ const stateVectors = await getAllStateVectors(chatId);
+ if (!stateVectors.length) return [];
+
+ // 获取 atoms(用于关联 semantic 等字段)
+ const atoms = getStateAtoms();
+ const atomMap = new Map(atoms.map(a => [a.atomId, a]));
+
+ // 计算相似度
+ const scored = stateVectors
+ .map(sv => {
+ const atom = atomMap.get(sv.atomId);
+ if (!atom) return null;
+
+ return {
+ atomId: sv.atomId,
+ floor: sv.floor,
+ similarity: cosineSimilarity(queryVector, sv.vector),
+ atom,
+ };
+ })
+ .filter(Boolean)
+ .filter(s => s.similarity >= CONFIG.MIN_SIMILARITY)
+ .sort((a, b) => b.similarity - a.similarity)
+ .slice(0, CONFIG.MAX_RESULTS);
+
+ return scored;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Floor Bonus 构建
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * 构建 L0 相关楼层的加权映射
+ * @returns {Map}
+ */
+export function buildL0FloorBonus(l0Results, bonusFactor = 0.10) {
+ const floorBonus = new Map();
+
+ for (const r of l0Results || []) {
+ // 每个楼层只加一次,取最高相似度对应的 bonus
+ // 简化处理:统一加 bonusFactor,不区分相似度高低
+ if (!floorBonus.has(r.floor)) {
+ floorBonus.set(r.floor, bonusFactor);
+ }
+ }
+
+ return floorBonus;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 虚拟 Chunk 转换
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * 将 L0 结果转换为虚拟 chunk 格式
+ * 用于和 L1 chunks 统一处理
+ */
+export function stateToVirtualChunks(l0Results) {
+ return (l0Results || []).map(r => ({
+ chunkId: `state-${r.atomId}`,
+ floor: r.floor,
+ chunkIdx: -1, // 负值,排序时排在 L1 前面
+ speaker: '📌', // 固定标记
+ isUser: false,
+ text: r.atom.semantic,
+ textHash: null,
+ similarity: r.similarity,
+ isL0: true, // 标记字段
+ // 保留原始 atom 信息
+ _atom: r.atom,
+ }));
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 每楼层稀疏去重
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * 合并 L0 和 L1 chunks,每楼层最多保留 limit 条
+ * @param {Array} l0Chunks - 虚拟 chunks(已按相似度排序)
+ * @param {Array} l1Chunks - 真实 chunks(已按相似度排序)
+ * @param {number} limit - 每楼层上限
+ * @returns {Array} 合并后的 chunks
+ */
+export function mergeAndSparsify(l0Chunks, l1Chunks, limit = 2) {
+ // 合并并按相似度排序
+ const all = [...(l0Chunks || []), ...(l1Chunks || [])]
+ .sort((a, b) => b.similarity - a.similarity);
+
+ // 每楼层稀疏去重
+ const byFloor = new Map();
+
+ for (const c of all) {
+ const arr = byFloor.get(c.floor) || [];
+ if (arr.length < limit) {
+ arr.push(c);
+ byFloor.set(c.floor, arr);
+ }
+ }
+
+ // 扁平化并保持相似度排序
+ return Array.from(byFloor.values())
+ .flat()
+ .sort((a, b) => b.similarity - a.similarity);
+}
diff --git a/modules/story-summary/vector/state-store.js b/modules/story-summary/vector/state-store.js
new file mode 100644
index 0000000..5a2651b
--- /dev/null
+++ b/modules/story-summary/vector/state-store.js
@@ -0,0 +1,187 @@
+// ═══════════════════════════════════════════════════════════════════════════
+// Story Summary - State Store (L0)
+// StateAtom 存 chat_metadata(持久化)
+// StateVector 存 IndexedDB(可重建)
+// ═══════════════════════════════════════════════════════════════════════════
+
+import { saveMetadataDebounced } from '../../../../../../extensions.js';
+import { chat_metadata } from '../../../../../../../script.js';
+import { stateVectorsTable } from '../data/db.js';
+import { EXT_ID } from '../../../core/constants.js';
+import { xbLog } from '../../../core/debug-core.js';
+
+const MODULE_ID = 'state-store';
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 工具函数
+// ═══════════════════════════════════════════════════════════════════════════
+
+export function float32ToBuffer(arr) {
+ return arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength);
+}
+
+export function bufferToFloat32(buffer) {
+ return new Float32Array(buffer);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// StateAtom 操作(chat_metadata)
+// ═══════════════════════════════════════════════════════════════════════════
+
+function ensureStateAtomsArray() {
+ chat_metadata.extensions ||= {};
+ chat_metadata.extensions[EXT_ID] ||= {};
+ chat_metadata.extensions[EXT_ID].stateAtoms ||= [];
+ return chat_metadata.extensions[EXT_ID].stateAtoms;
+}
+
+/**
+ * 获取当前聊天的所有 StateAtoms
+ */
+export function getStateAtoms() {
+ return ensureStateAtomsArray();
+}
+
+/**
+ * 保存新的 StateAtoms(追加,去重)
+ */
+export function saveStateAtoms(atoms) {
+ if (!atoms?.length) return;
+
+ const arr = ensureStateAtomsArray();
+ const existing = new Set(arr.map(a => a.atomId));
+
+ let added = 0;
+ for (const atom of atoms) {
+ // 有效性检查
+ if (!atom?.atomId || typeof atom.floor !== 'number' || atom.floor < 0 || !atom.semantic) {
+ xbLog.warn(MODULE_ID, `跳过无效 atom: ${atom?.atomId}`);
+ continue;
+ }
+
+ if (!existing.has(atom.atomId)) {
+ arr.push(atom);
+ existing.add(atom.atomId);
+ added++;
+ }
+ }
+
+ if (added > 0) {
+ saveMetadataDebounced();
+ xbLog.info(MODULE_ID, `存储 ${added} 个 StateAtom`);
+ }
+}
+
+/**
+ * 删除指定楼层及之后的 StateAtoms
+ */
+export function deleteStateAtomsFromFloor(floor) {
+ const arr = ensureStateAtomsArray();
+ const before = arr.length;
+
+ const filtered = arr.filter(a => a.floor < floor);
+ chat_metadata.extensions[EXT_ID].stateAtoms = filtered;
+
+ const deleted = before - filtered.length;
+ if (deleted > 0) {
+ saveMetadataDebounced();
+ xbLog.info(MODULE_ID, `删除 ${deleted} 个 StateAtom (floor >= ${floor})`);
+ }
+
+ return deleted;
+}
+
+/**
+ * 清空所有 StateAtoms
+ */
+export function clearStateAtoms() {
+ const arr = ensureStateAtomsArray();
+ const count = arr.length;
+
+ chat_metadata.extensions[EXT_ID].stateAtoms = [];
+
+ if (count > 0) {
+ saveMetadataDebounced();
+ xbLog.info(MODULE_ID, `清空 ${count} 个 StateAtom`);
+ }
+}
+
+/**
+ * 获取 StateAtoms 数量
+ */
+export function getStateAtomsCount() {
+ return ensureStateAtomsArray().length;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// StateVector 操作(IndexedDB)
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * 保存 StateVectors
+ */
+export async function saveStateVectors(chatId, items, fingerprint) {
+ if (!chatId || !items?.length) return;
+
+ const records = items.map(item => ({
+ chatId,
+ atomId: item.atomId,
+ floor: item.floor,
+ vector: float32ToBuffer(new Float32Array(item.vector)),
+ dims: item.vector.length,
+ fingerprint,
+ }));
+
+ await stateVectorsTable.bulkPut(records);
+ xbLog.info(MODULE_ID, `存储 ${records.length} 个 StateVector`);
+}
+
+/**
+ * 获取所有 StateVectors
+ */
+export async function getAllStateVectors(chatId) {
+ if (!chatId) return [];
+
+ const records = await stateVectorsTable.where('chatId').equals(chatId).toArray();
+ return records.map(r => ({
+ ...r,
+ vector: bufferToFloat32(r.vector),
+ }));
+}
+
+/**
+ * 删除指定楼层及之后的 StateVectors
+ */
+export async function deleteStateVectorsFromFloor(chatId, floor) {
+ if (!chatId) return;
+
+ const deleted = await stateVectorsTable
+ .where('chatId')
+ .equals(chatId)
+ .filter(v => v.floor >= floor)
+ .delete();
+
+ if (deleted > 0) {
+ xbLog.info(MODULE_ID, `删除 ${deleted} 个 StateVector (floor >= ${floor})`);
+ }
+}
+
+/**
+ * 清空所有 StateVectors
+ */
+export async function clearStateVectors(chatId) {
+ if (!chatId) return;
+
+ const deleted = await stateVectorsTable.where('chatId').equals(chatId).delete();
+ if (deleted > 0) {
+ xbLog.info(MODULE_ID, `清空 ${deleted} 个 StateVector`);
+ }
+}
+
+/**
+ * 获取 StateVectors 数量
+ */
+export async function getStateVectorsCount(chatId) {
+ if (!chatId) return 0;
+ return await stateVectorsTable.where('chatId').equals(chatId).count();
+}
diff --git a/modules/story-summary/vector/vector-io.js b/modules/story-summary/vector/vector-io.js
index 657ba62..ef2a1d5 100644
--- a/modules/story-summary/vector/vector-io.js
+++ b/modules/story-summary/vector/vector-io.js
@@ -18,6 +18,14 @@ import {
clearEventVectors,
saveEventVectors,
} from './chunk-store.js';
+import {
+ getStateAtoms,
+ saveStateAtoms,
+ clearStateAtoms,
+ getAllStateVectors,
+ saveStateVectors,
+ clearStateVectors,
+} from './state-store.js';
import { getEngineFingerprint } from './embedder.js';
import { getVectorConfig } from '../data/config.js';
@@ -81,13 +89,18 @@ export async function exportVectors(onProgress) {
const chunks = await getAllChunks(chatId);
const chunkVectors = await getAllChunkVectors(chatId);
const eventVectors = await getAllEventVectors(chatId);
+ const stateAtoms = getStateAtoms();
+ const stateVectors = await getAllStateVectors(chatId);
- if (chunks.length === 0 && eventVectors.length === 0) {
+ if (chunkVectors.length === 0 && eventVectors.length === 0 && stateVectors.length === 0) {
throw new Error('没有可导出的向量数据');
}
// 确定维度
- const dims = chunkVectors[0]?.vector?.length || eventVectors[0]?.vector?.length || 0;
+ const dims = chunkVectors[0]?.vector?.length
+ || eventVectors[0]?.vector?.length
+ || stateVectors[0]?.vector?.length
+ || 0;
if (dims === 0) {
throw new Error('无法确定向量维度');
}
@@ -123,6 +136,14 @@ export async function exportVectors(onProgress) {
// event_vectors.bin
const eventVectorsOrdered = sortedEventVectors.map(ev => ev.vector);
+ // state vectors
+ const sortedStateVectors = [...stateVectors].sort((a, b) => String(a.atomId).localeCompare(String(b.atomId)));
+ const stateVectorsOrdered = sortedStateVectors.map(v => v.vector);
+ const stateVectorsJsonl = sortedStateVectors.map(v => JSON.stringify({
+ atomId: v.atomId,
+ floor: v.floor,
+ })).join('\n');
+
// manifest
const manifest = {
version: EXPORT_VERSION,
@@ -133,6 +154,8 @@ export async function exportVectors(onProgress) {
chunkCount: sortedChunks.length,
chunkVectorCount: chunkVectors.length,
eventCount: sortedEventVectors.length,
+ stateAtomCount: stateAtoms.length,
+ stateVectorCount: stateVectors.length,
lastChunkFloor: meta.lastChunkFloor ?? -1,
};
@@ -145,6 +168,11 @@ export async function exportVectors(onProgress) {
'chunk_vectors.bin': float32ToBytes(chunkVectorsOrdered, dims),
'events.jsonl': strToU8(eventsJsonl),
'event_vectors.bin': float32ToBytes(eventVectorsOrdered, dims),
+ 'state_atoms.json': strToU8(JSON.stringify(stateAtoms)),
+ 'state_vectors.jsonl': strToU8(stateVectorsJsonl),
+ 'state_vectors.bin': stateVectorsOrdered.length
+ ? float32ToBytes(stateVectorsOrdered, dims)
+ : new Uint8Array(0),
}, { level: 1 }); // 降低压缩级别,速度优先
onProgress?.('下载文件...');
@@ -238,6 +266,21 @@ export async function importVectors(file, onProgress) {
const eventVectorsBytes = unzipped['event_vectors.bin'];
const eventVectors = eventVectorsBytes ? bytesToFloat32(eventVectorsBytes, manifest.dims) : [];
+ // 解析 L0 state atoms
+ const stateAtoms = unzipped['state_atoms.json']
+ ? JSON.parse(strFromU8(unzipped['state_atoms.json']))
+ : [];
+
+ // 解析 L0 state vectors metas
+ const stateVectorsJsonl = unzipped['state_vectors.jsonl'] ? strFromU8(unzipped['state_vectors.jsonl']) : '';
+ const stateVectorMetas = stateVectorsJsonl.split('\n').filter(Boolean).map(line => JSON.parse(line));
+
+ // 解析 L0 state vectors
+ const stateVectorsBytes = unzipped['state_vectors.bin'];
+ const stateVectors = (stateVectorsBytes && stateVectorMetas.length)
+ ? bytesToFloat32(stateVectorsBytes, manifest.dims)
+ : [];
+
// 校验数量
if (chunkMetas.length !== chunkVectors.length) {
throw new Error(`chunk 数量不匹配: 元数据 ${chunkMetas.length}, 向量 ${chunkVectors.length}`);
@@ -245,12 +288,17 @@ export async function importVectors(file, onProgress) {
if (eventMetas.length !== eventVectors.length) {
throw new Error(`event 数量不匹配: 元数据 ${eventMetas.length}, 向量 ${eventVectors.length}`);
}
+ if (stateVectorMetas.length !== stateVectors.length) {
+ throw new Error(`state 向量数量不匹配: 元数据 ${stateVectorMetas.length}, 向量 ${stateVectors.length}`);
+ }
onProgress?.('清空旧数据...');
// 清空当前数据
await clearAllChunks(chatId);
await clearEventVectors(chatId);
+ await clearStateVectors(chatId);
+ clearStateAtoms();
onProgress?.('写入数据...');
@@ -284,13 +332,28 @@ export async function importVectors(file, onProgress) {
await saveEventVectors(chatId, eventVectorItems, manifest.fingerprint);
}
+ // 写入 state atoms
+ if (stateAtoms.length > 0) {
+ saveStateAtoms(stateAtoms);
+ }
+
+ // 写入 state vectors
+ if (stateVectorMetas.length > 0) {
+ const stateVectorItems = stateVectorMetas.map((meta, idx) => ({
+ atomId: meta.atomId,
+ floor: meta.floor,
+ vector: stateVectors[idx],
+ }));
+ await saveStateVectors(chatId, stateVectorItems, manifest.fingerprint);
+ }
+
// 更新 meta
await updateMeta(chatId, {
fingerprint: manifest.fingerprint,
lastChunkFloor: manifest.lastChunkFloor,
});
- xbLog.info(MODULE_ID, `导入完成: ${chunkMetas.length} chunks, ${eventMetas.length} events`);
+ xbLog.info(MODULE_ID, `导入完成: ${chunkMetas.length} chunks, ${eventMetas.length} events, ${stateAtoms.length} state atoms`);
return {
chunkCount: chunkMetas.length,
diff --git a/modules/streaming-generation.js b/modules/streaming-generation.js
index 6ec3258..16c625d 100644
--- a/modules/streaming-generation.js
+++ b/modules/streaming-generation.js
@@ -1,6 +1,6 @@
// 删掉:getRequestHeaders, extractMessageFromData, getStreamingReply, tryParseStreamingError, getEventSourceStream
-import { eventSource, event_types, chat, name1, activateSendButtons, deactivateSendButtons } from "../../../../../script.js";
+import { eventSource, event_types, chat, name1, activateSendButtons, deactivateSendButtons, substituteParams } from "../../../../../script.js";
import { chat_completion_sources, oai_settings, promptManager, getChatCompletionModel } from "../../../../openai.js";
import { ChatCompletionService } from "../../../../custom-request.js";
import { getContext } from "../../../../st-context.js";
@@ -12,6 +12,7 @@ import { power_user } from "../../../../power-user.js";
import { world_info } from "../../../../world-info.js";
import { xbLog, CacheRegistry } from "../core/debug-core.js";
import { getTrustedOrigin } from "../core/iframe-messaging.js";
+import { replaceXbGetVarInString, replaceXbGetVarYamlInString } from "./variables/var-commands.js";
const EVT_DONE = 'xiaobaix_streaming_completed';
@@ -366,11 +367,28 @@ class StreamingGeneration {
async _emitPromptReady(chatArray) {
try {
if (Array.isArray(chatArray)) {
- await eventSource?.emit?.(event_types.CHAT_COMPLETION_PROMPT_READY, { chat: chatArray, dryRun: false });
+ const snapshot = this._cloneChat(chatArray);
+ await eventSource?.emit?.(event_types.CHAT_COMPLETION_PROMPT_READY, { chat: snapshot, dryRun: false });
}
} catch {}
}
+ _cloneChat(chatArray) {
+ try {
+ if (typeof structuredClone === 'function') return structuredClone(chatArray);
+ } catch {}
+ try {
+ return JSON.parse(JSON.stringify(chatArray));
+ } catch {}
+ try {
+ return Array.isArray(chatArray)
+ ? chatArray.map(m => (m && typeof m === 'object' ? { ...m } : m))
+ : chatArray;
+ } catch {
+ return chatArray;
+ }
+ }
+
async processGeneration(generateData, prompt, sessionId, stream = true) {
const session = this._ensureSession(sessionId, prompt);
const abortController = new AbortController();
@@ -788,6 +806,10 @@ class StreamingGeneration {
.replace(/<\s*user\s*>/gi, String(ctx?.name1 || 'User'))
.replace(/<\s*(char|character)\s*>/gi, String(ctx?.name2 || 'Assistant'))
.replace(/<\s*persona\s*>/gi, String(f.persona || ''));
+ try {
+ out = replaceXbGetVarInString(out);
+ out = replaceXbGetVarYamlInString(out);
+ } catch {}
const snap = this._getLastMessagesSnapshot();
const lastDict = {
'{{lastmessage}}': snap.lastMessage,
@@ -855,13 +877,14 @@ class StreamingGeneration {
/\{\{getvar::([\s\S]*?)\}\}/gi,
(root) => `/getvar key=${escapeForCmd(root)}`
);
- await apply(
- /\{\{getglobalvar::([\s\S]*?)\}\}/gi,
- (root) => `/getglobalvar ${escapeForCmd(root)}`
- );
- return txt;
- };
- out = await expandVarMacros(out);
+ await apply(
+ /\{\{getglobalvar::([\s\S]*?)\}\}/gi,
+ (root) => `/getglobalvar ${escapeForCmd(root)}`
+ );
+ return txt;
+ };
+ out = await expandVarMacros(out);
+ try { out = substituteParams(out); } catch {}
return out;
}
@@ -964,16 +987,12 @@ class StreamingGeneration {
}
return out;
};
- let topMsgs = await mapHistoryPlaceholders(
- []
- .concat(topComposite ? this._parseCompositeParam(topComposite) : [])
- .concat(createMsgs('top'))
- );
- let bottomMsgs = await mapHistoryPlaceholders(
- []
- .concat(bottomComposite ? this._parseCompositeParam(bottomComposite) : [])
- .concat(createMsgs('bottom'))
- );
+ let topMsgs = []
+ .concat(topComposite ? this._parseCompositeParam(topComposite) : [])
+ .concat(createMsgs('top'));
+ let bottomMsgs = []
+ .concat(bottomComposite ? this._parseCompositeParam(bottomComposite) : [])
+ .concat(createMsgs('bottom'));
const expandSegmentInline = async (arr) => {
for (const m of arr) {
if (m && typeof m.content === 'string') {
@@ -988,10 +1007,13 @@ class StreamingGeneration {
await expandSegmentInline(bottomMsgs);
+ topMsgs = await mapHistoryPlaceholders(topMsgs);
+ bottomMsgs = await mapHistoryPlaceholders(bottomMsgs);
+
if (typeof prompt === 'string' && prompt.trim()) {
- const beforeP = await resolveHistoryPlaceholder(prompt);
- const afterP = await this.expandInline(beforeP);
- prompt = afterP && afterP.length ? afterP : beforeP;
+ const afterP = await this.expandInline(prompt);
+ const beforeP = await resolveHistoryPlaceholder(afterP);
+ prompt = beforeP && beforeP.length ? beforeP : afterP;
}
try {
const needsWI = [...topMsgs, ...bottomMsgs].some(m => m && typeof m.content === 'string' && m.content.includes('{$worldInfo}')) || (typeof prompt === 'string' && prompt.includes('{$worldInfo}'));
diff --git a/modules/variables/state2/executor.js b/modules/variables/state2/executor.js
new file mode 100644
index 0000000..b33c009
--- /dev/null
+++ b/modules/variables/state2/executor.js
@@ -0,0 +1,199 @@
+import { getContext } from '../../../../../../extensions.js';
+import {
+ lwbResolveVarPath,
+ lwbAssignVarPath,
+ lwbAddVarPath,
+ lwbPushVarPath,
+ lwbDeleteVarPath,
+ lwbRemoveArrayItemByValue,
+} from '../var-commands.js';
+import { lwbSplitPathWithBrackets } from '../../../core/variable-path.js';
+
+import { extractStateBlocks, computeStateSignature, parseStateBlock } from './parser.js';
+import { generateSemantic } from './semantic.js';
+
+const LWB_STATE_APPLIED_KEY = 'LWB_STATE_APPLIED_KEY';
+
+/**
+ * chatMetadata 内记录每楼层 signature,防止重复执行
+ */
+function getAppliedMap() {
+ const meta = getContext()?.chatMetadata || {};
+ meta[LWB_STATE_APPLIED_KEY] ||= {};
+ return meta[LWB_STATE_APPLIED_KEY];
+}
+
+export function clearStateAppliedFor(floor) {
+ try {
+ const map = getAppliedMap();
+ delete map[floor];
+ getContext()?.saveMetadataDebounced?.();
+ } catch {}
+}
+
+export function clearStateAppliedFrom(floorInclusive) {
+ try {
+ const map = getAppliedMap();
+ for (const k of Object.keys(map)) {
+ const id = Number(k);
+ if (!Number.isNaN(id) && id >= floorInclusive) delete map[k];
+ }
+ getContext()?.saveMetadataDebounced?.();
+ } catch {}
+}
+
+function safeParseAny(str) {
+ if (str == null || str === '') return undefined;
+ if (typeof str !== 'string') return str;
+ const t = str.trim();
+ if (!t) return undefined;
+ if (t[0] === '{' || t[0] === '[') {
+ try { return JSON.parse(t); } catch { return str; }
+ }
+ if (/^-?\d+(?:\.\d+)?$/.test(t)) return Number(t);
+ if (t === 'true') return true;
+ if (t === 'false') return false;
+ return str;
+}
+
+function isIndexDeleteOp(opItem) {
+ if (!opItem || opItem.op !== 'del') return false;
+ const segs = lwbSplitPathWithBrackets(opItem.path);
+ if (!segs.length) return false;
+ const last = segs[segs.length - 1];
+ return typeof last === 'number' && Number.isFinite(last);
+}
+
+function buildParentPathFromSegs(segs) {
+ return segs.reduce((acc, s) => {
+ if (typeof s === 'number') return `${acc}[${s}]`;
+ return acc ? `${acc}.${s}` : String(s);
+ }, '');
+}
+
+function buildExecOpsWithIndexDeleteReorder(ops) {
+ const groups = new Map(); // parentPath -> [{ op, idx }]
+ const groupOrder = new Map();
+ let orderCounter = 0;
+
+ const normalOps = [];
+
+ for (const op of ops) {
+ if (isIndexDeleteOp(op)) {
+ const segs = lwbSplitPathWithBrackets(op.path);
+ const idx = segs[segs.length - 1];
+ const parentPath = buildParentPathFromSegs(segs.slice(0, -1));
+
+ if (!groups.has(parentPath)) {
+ groups.set(parentPath, []);
+ groupOrder.set(parentPath, orderCounter++);
+ }
+ groups.get(parentPath).push({ op, idx });
+ } else {
+ normalOps.push(op);
+ }
+ }
+
+ const orderedParents = Array.from(groups.keys()).sort(
+ (a, b) => (groupOrder.get(a) ?? 0) - (groupOrder.get(b) ?? 0)
+ );
+
+ const reorderedIndexDeletes = [];
+ for (const parent of orderedParents) {
+ const items = groups.get(parent) || [];
+ items.sort((a, b) => b.idx - a.idx);
+ for (const it of items) reorderedIndexDeletes.push(it.op);
+ }
+
+ return [...reorderedIndexDeletes, ...normalOps];
+}
+
+/**
+ * 变量 2.0:执行单条消息里的 ,返回 atoms
+ */
+export function applyStateForMessage(messageId, messageContent) {
+ const ctx = getContext();
+ const chatId = ctx?.chatId || '';
+
+ const text = String(messageContent ?? '');
+ const signature = computeStateSignature(text);
+
+ // 没有 state:清理旧 signature(避免“删掉 state 后仍然认为执行过”)
+ if (!signature) {
+ clearStateAppliedFor(messageId);
+ return { atoms: [], errors: [], skipped: false };
+ }
+
+ // 幂等:signature 没变就跳过
+ const appliedMap = getAppliedMap();
+ if (appliedMap[messageId] === signature) {
+ return { atoms: [], errors: [], skipped: true };
+ }
+
+ const blocks = extractStateBlocks(text);
+ const atoms = [];
+ const errors = [];
+
+ let idx = 0;
+
+ for (const block of blocks) {
+ const ops = parseStateBlock(block);
+ const execOps = buildExecOpsWithIndexDeleteReorder(ops);
+ for (const opItem of execOps) {
+ const { path, op, value, delta, warning } = opItem;
+ if (!path) continue;
+ if (warning) errors.push(`[${path}] ${warning}`);
+
+ const oldValue = safeParseAny(lwbResolveVarPath(path));
+
+ try {
+ switch (op) {
+ case 'set':
+ lwbAssignVarPath(path, value);
+ break;
+ case 'inc':
+ lwbAddVarPath(path, delta);
+ break;
+ case 'push':
+ lwbPushVarPath(path, value);
+ break;
+ case 'pop':
+ lwbRemoveArrayItemByValue(path, value);
+ break;
+ case 'del':
+ lwbDeleteVarPath(path);
+ break;
+ default:
+ errors.push(`[${path}] 未知 op=${op}`);
+ continue;
+ }
+ } catch (e) {
+ errors.push(`[${path}] 执行失败: ${e?.message || e}`);
+ continue;
+ }
+
+ const newValue = safeParseAny(lwbResolveVarPath(path));
+
+ atoms.push({
+ atomId: `sa-${messageId}-${idx}`,
+ chatId,
+ floor: messageId,
+ idx,
+ path,
+ op,
+ oldValue,
+ newValue,
+ delta: op === 'inc' ? delta : undefined,
+ semantic: generateSemantic(path, op, oldValue, newValue, delta, value),
+ timestamp: Date.now(),
+ });
+
+ idx++;
+ }
+ }
+
+ appliedMap[messageId] = signature;
+ getContext()?.saveMetadataDebounced?.();
+
+ return { atoms, errors, skipped: false };
+}
diff --git a/modules/variables/state2/index.js b/modules/variables/state2/index.js
new file mode 100644
index 0000000..685e7d0
--- /dev/null
+++ b/modules/variables/state2/index.js
@@ -0,0 +1,3 @@
+export { applyStateForMessage, clearStateAppliedFor, clearStateAppliedFrom } from './executor.js';
+export { parseStateBlock, extractStateBlocks, computeStateSignature, parseInlineValue } from './parser.js';
+export { generateSemantic } from './semantic.js';
diff --git a/modules/variables/state2/parser.js b/modules/variables/state2/parser.js
new file mode 100644
index 0000000..f42995d
--- /dev/null
+++ b/modules/variables/state2/parser.js
@@ -0,0 +1,196 @@
+import jsyaml from '../../../libs/js-yaml.mjs';
+
+const STATE_TAG_RE = /<\s*state\b[^>]*>([\s\S]*?)<\s*\/\s*state\s*>/gi;
+
+export function extractStateBlocks(text) {
+ const s = String(text ?? '');
+ if (!s || s.toLowerCase().indexOf(' 块内容 -> ops[]
+ * 单行支持运算符,多行只支持覆盖 set(YAML)
+ */
+export function parseStateBlock(content) {
+ const results = [];
+ const lines = String(content ?? '').split(/\r?\n/);
+
+ let pendingPath = null;
+ let pendingLines = [];
+
+ const flushPending = () => {
+ if (!pendingPath) return;
+ // 没有任何缩进行:视为 set 空字符串
+ if (!pendingLines.length) {
+ results.push({ path: pendingPath, op: 'set', value: '' });
+ pendingPath = null;
+ pendingLines = [];
+ return;
+ }
+ try {
+ // 去除公共缩进
+ const nonEmpty = pendingLines.filter(l => l.trim());
+ const minIndent = nonEmpty.length
+ ? Math.min(...nonEmpty.map(l => l.search(/\S/)))
+ : 0;
+
+ const yamlText = pendingLines
+ .map(l => (l.trim() ? l.slice(minIndent) : ''))
+ .join('\n');
+
+ const obj = jsyaml.load(yamlText);
+ results.push({ path: pendingPath, op: 'set', value: obj });
+ } catch (e) {
+ results.push({ path: pendingPath, op: 'set', value: null, warning: `YAML 解析失败: ${e.message}` });
+ } finally {
+ pendingPath = null;
+ pendingLines = [];
+ }
+ };
+
+ for (const raw of lines) {
+ const trimmed = raw.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+
+ const indent = raw.search(/\S/);
+
+ if (indent === 0) {
+ flushPending();
+ const colonIdx = findTopLevelColon(trimmed);
+ if (colonIdx === -1) continue;
+
+ const path = trimmed.slice(0, colonIdx).trim();
+ const rhs = trimmed.slice(colonIdx + 1).trim();
+ if (!path) continue;
+
+ if (!rhs) {
+ pendingPath = path;
+ pendingLines = [];
+ } else {
+ results.push({ path, ...parseInlineValue(rhs) });
+ }
+ } else if (pendingPath) {
+ pendingLines.push(raw);
+ }
+ }
+
+ flushPending();
+ return results;
+}
+
+function findTopLevelColon(line) {
+ let inQuote = false;
+ let q = '';
+ let esc = false;
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i];
+ if (esc) { esc = false; continue; }
+ if (ch === '\\') { esc = true; continue; }
+ if (!inQuote && (ch === '"' || ch === "'")) { inQuote = true; q = ch; continue; }
+ if (inQuote && ch === q) { inQuote = false; q = ''; continue; }
+ if (!inQuote && ch === ':') return i;
+ }
+ return -1;
+}
+
+function unescapeString(s) {
+ return String(s ?? '')
+ .replace(/\\n/g, '\n')
+ .replace(/\\t/g, '\t')
+ .replace(/\\r/g, '\r')
+ .replace(/\\"/g, '"')
+ .replace(/\\'/g, "'")
+ .replace(/\\\\/g, '\\');
+}
+
+/**
+ * 单行内联值解析
+ */
+export function parseInlineValue(raw) {
+ const t = String(raw ?? '').trim();
+
+ if (t === 'null') return { op: 'del' };
+
+ // (负数) 用于强制 set -5,而不是 inc -5
+ const parenNum = t.match(/^\((-?\d+(?:\.\d+)?)\)$/);
+ if (parenNum) return { op: 'set', value: Number(parenNum[1]) };
+
+ // +10 / -20
+ if (/^\+\d/.test(t) || /^-\d/.test(t)) {
+ const n = Number(t);
+ if (Number.isFinite(n)) return { op: 'inc', delta: n };
+ }
+
+ // +"str" / +'str'
+ const pushD = t.match(/^\+"((?:[^"\\]|\\.)*)"\s*$/);
+ if (pushD) return { op: 'push', value: unescapeString(pushD[1]) };
+ const pushS = t.match(/^\+'((?:[^'\\]|\\.)*)'\s*$/);
+ if (pushS) return { op: 'push', value: unescapeString(pushS[1]) };
+
+ // +[...]
+ if (t.startsWith('+[')) {
+ try {
+ const arr = JSON.parse(t.slice(1));
+ if (Array.isArray(arr)) return { op: 'push', value: arr };
+ return { op: 'set', value: t, warning: '+[] 不是数组,作为字符串' };
+ } catch {
+ return { op: 'set', value: t, warning: '+[] JSON 解析失败,作为字符串' };
+ }
+ }
+
+ // -"str" / -'str'
+ const popD = t.match(/^-"((?:[^"\\]|\\.)*)"\s*$/);
+ if (popD) return { op: 'pop', value: unescapeString(popD[1]) };
+ const popS = t.match(/^-'((?:[^'\\]|\\.)*)'\s*$/);
+ if (popS) return { op: 'pop', value: unescapeString(popS[1]) };
+
+ // -[...]
+ if (t.startsWith('-[')) {
+ try {
+ const arr = JSON.parse(t.slice(1));
+ if (Array.isArray(arr)) return { op: 'pop', value: arr };
+ return { op: 'set', value: t, warning: '-[] 不是数组,作为字符串' };
+ } catch {
+ return { op: 'set', value: t, warning: '-[] JSON 解析失败,作为字符串' };
+ }
+ }
+
+ // 裸数字 set
+ if (/^-?\d+(?:\.\d+)?$/.test(t)) return { op: 'set', value: Number(t) };
+
+ // "str" / 'str'
+ const strD = t.match(/^"((?:[^"\\]|\\.)*)"\s*$/);
+ if (strD) return { op: 'set', value: unescapeString(strD[1]) };
+ const strS = t.match(/^'((?:[^'\\]|\\.)*)'\s*$/);
+ if (strS) return { op: 'set', value: unescapeString(strS[1]) };
+
+ if (t === 'true') return { op: 'set', value: true };
+ if (t === 'false') return { op: 'set', value: false };
+
+ // JSON set
+ if (t.startsWith('{') || t.startsWith('[')) {
+ try { return { op: 'set', value: JSON.parse(t) }; }
+ catch { return { op: 'set', value: t, warning: 'JSON 解析失败,作为字符串' }; }
+ }
+
+ // 兜底 set 原文本
+ return { op: 'set', value: t };
+}
diff --git a/modules/variables/state2/semantic.js b/modules/variables/state2/semantic.js
new file mode 100644
index 0000000..a5549e3
--- /dev/null
+++ b/modules/variables/state2/semantic.js
@@ -0,0 +1,42 @@
+export function generateSemantic(path, op, oldValue, newValue, delta, operandValue) {
+ const p = String(path ?? '').replace(/\./g, ' > ');
+
+ const fmt = (v) => {
+ if (v === undefined) return '空';
+ if (v === null) return 'null';
+ try {
+ if (typeof v === 'string') return JSON.stringify(v);
+ return JSON.stringify(v);
+ } catch {
+ return String(v);
+ }
+ };
+
+ switch (op) {
+ case 'set':
+ return oldValue === undefined
+ ? `${p} 设为 ${fmt(newValue)}`
+ : `${p} 从 ${fmt(oldValue)} 变为 ${fmt(newValue)}`;
+
+ case 'inc': {
+ const sign = (delta ?? 0) >= 0 ? '+' : '';
+ return `${p} ${sign}${delta}(${fmt(oldValue)} → ${fmt(newValue)})`;
+ }
+
+ case 'push': {
+ const items = Array.isArray(operandValue) ? operandValue : [operandValue];
+ return `${p} 加入 ${items.map(fmt).join('、')}`;
+ }
+
+ case 'pop': {
+ const items = Array.isArray(operandValue) ? operandValue : [operandValue];
+ return `${p} 移除 ${items.map(fmt).join('、')}`;
+ }
+
+ case 'del':
+ return `${p} 被删除(原值 ${fmt(oldValue)})`;
+
+ default:
+ return `${p} 操作 ${op}`;
+ }
+}
diff --git a/modules/variables/var-commands.js b/modules/variables/var-commands.js
index 61febf7..aa91268 100644
--- a/modules/variables/var-commands.js
+++ b/modules/variables/var-commands.js
@@ -667,6 +667,62 @@ export function lwbPushVarPath(path, value) {
}
}
+export function lwbRemoveArrayItemByValue(path, valuesToRemove) {
+ try {
+ const segs = lwbSplitPathWithBrackets(path);
+ if (!segs.length) return '';
+
+ const rootName = String(segs[0]);
+ const rootRaw = getLocalVariable(rootName);
+ const rootObj = maybeParseObject(rootRaw);
+ if (!rootObj) return '';
+
+ // 定位到目标数组
+ let cur = rootObj;
+ for (let i = 1; i < segs.length; i++) {
+ cur = cur?.[segs[i]];
+ if (cur == null) return '';
+ }
+ if (!Array.isArray(cur)) return '';
+
+ const toRemove = Array.isArray(valuesToRemove) ? valuesToRemove : [valuesToRemove];
+ if (!toRemove.length) return '';
+
+ // 找到索引(每个值只删除一个匹配项)
+ const indices = [];
+ for (const v of toRemove) {
+ const vStr = safeJSONStringify(v);
+ if (!vStr) continue;
+ const idx = cur.findIndex(x => safeJSONStringify(x) === vStr);
+ if (idx !== -1) indices.push(idx);
+ }
+ if (!indices.length) return '';
+
+ // 倒序删除,且逐个走 guardian 的 delNode 校验(用 index path)
+ indices.sort((a, b) => b - a);
+
+ for (const idx of indices) {
+ const absIndexPath = normalizePath(`${path}[${idx}]`);
+
+ try {
+ if (globalThis.LWB_Guard?.validate) {
+ const g = globalThis.LWB_Guard.validate('delNode', absIndexPath);
+ if (!g?.allow) continue;
+ }
+ } catch {}
+
+ if (idx >= 0 && idx < cur.length) {
+ cur.splice(idx, 1);
+ }
+ }
+
+ setLocalVariable(rootName, safeJSONStringify(rootObj));
+ return '';
+ } catch {
+ return '';
+ }
+}
+
function registerXbGetVarSlashCommand() {
try {
const ctx = getContext();
diff --git a/modules/variables/variables-core.js b/modules/variables/variables-core.js
index a8eb972..66a6d9a 100644
--- a/modules/variables/variables-core.js
+++ b/modules/variables/variables-core.js
@@ -1,10 +1,10 @@
/**
* @file modules/variables/variables-core.js
- * @description 变量管理核心(受开关控制)
- * @description 包含 plot-log 解析、快照回滚、变量守护
+ * @description Variables core (feature-flag controlled)
+ * @description Includes plot-log parsing, snapshot rollback, and variable guard
*/
-import { getContext } from "../../../../../extensions.js";
+import { extension_settings, getContext } from "../../../../../extensions.js";
import { updateMessageBlock } from "../../../../../../script.js";
import { getLocalVariable, setLocalVariable } from "../../../../../variables.js";
import { createModuleEvents, event_types } from "../../core/event-manager.js";
@@ -28,6 +28,7 @@ import {
applyXbGetVarForMessage,
parseValueForSet,
} from "./var-commands.js";
+import { applyStateForMessage, clearStateAppliedFrom } from "./state2/index.js";
import {
preprocessBumpAliases,
executeQueuedVareventJsAfterTurn,
@@ -36,17 +37,18 @@ import {
TOP_OP_RE,
} from "./varevent-editor.js";
-/* ============= 模块常量 ============= */
+/* ============ Module Constants ============= */
const MODULE_ID = 'variablesCore';
+const EXT_ID = 'LittleWhiteBox';
const LWB_RULES_KEY = 'LWB_RULES';
const LWB_SNAP_KEY = 'LWB_SNAP';
const LWB_PLOT_APPLIED_KEY = 'LWB_PLOT_APPLIED_KEY';
-// plot-log 标签正则
+// plot-log tag regex
const TAG_RE_PLOTLOG = /<\s*plot-log[^>]*>([\s\S]*?)<\s*\/\s*plot-log\s*>/gi;
-// 守护状态
+// guardian state
const guardianState = {
table: {},
regexCache: {},
@@ -55,7 +57,8 @@ const guardianState = {
lastMetaSyncAt: 0
};
-// 事件管理器
+// note
+
let events = null;
let initialized = false;
let pendingSwipeApply = new Map();
@@ -76,7 +79,7 @@ CacheRegistry.register(MODULE_ID, {
return 0;
}
},
- // 新增:估算字节大小(用于 debug-panel 缓存统计)
+ // estimate bytes for debug panel
getBytes: () => {
try {
let total = 0;
@@ -137,7 +140,7 @@ CacheRegistry.register(MODULE_ID, {
},
});
-/* ============= 内部辅助函数 ============= */
+/* ============ Internal Helpers ============= */
function getMsgKey(msg) {
return (typeof msg?.mes === 'string') ? 'mes'
@@ -160,7 +163,7 @@ function normalizeOpName(k) {
return OP_MAP[String(k).toLowerCase().trim()] || null;
}
-/* ============= 应用签名追踪 ============= */
+/* ============ Applied Signature Tracking ============= */
function getAppliedMap() {
const meta = getContext()?.chatMetadata || {};
@@ -206,10 +209,10 @@ function computePlotSignatureFromText(text) {
return chunks.join('\n---\n');
}
-/* ============= Plot-Log 解析 ============= */
+/* ============ Plot-Log Parsing ============= */
/**
- * 提取 plot-log 块
+ * Extract plot-log blocks
*/
function extractPlotLogBlocks(text) {
if (!text || typeof text !== 'string') return [];
@@ -224,10 +227,10 @@ function extractPlotLogBlocks(text) {
}
/**
- * 解析 plot-log 块内容
+ * Parse plot-log block content
*/
function parseBlock(innerText) {
- // 预处理 bump 别名
+ // preprocess bump aliases
innerText = preprocessBumpAliases(innerText);
const textForJsonToml = stripLeadingHtmlComments(innerText);
@@ -243,7 +246,7 @@ function parseBlock(innerText) {
};
const norm = (p) => String(p || '').replace(/\[(\d+)\]/g, '.$1');
- // 守护指令记录
+ // guard directive tracking
const guardMap = new Map();
const recordGuardDirective = (path, directives) => {
@@ -292,7 +295,7 @@ function parseBlock(innerText) {
return { directives, curPathRaw, guardTargetRaw, segment: segTrim };
};
- // 操作记录函数
+ // operation record helpers
const putSet = (top, path, value) => {
ops.set[top] ||= {};
ops.set[top][path] = value;
@@ -348,7 +351,7 @@ function parseBlock(innerText) {
return results;
};
- // 解码键
+ // decode key
const decodeKey = (rawKey) => {
const { directives, remainder, original } = extractDirectiveInfo(rawKey);
const path = (remainder || original || String(rawKey)).trim();
@@ -356,7 +359,7 @@ function parseBlock(innerText) {
return path;
};
- // 遍历节点
+ // walk nodes
const walkNode = (op, top, node, basePath = '') => {
if (op === 'set') {
if (node === null || node === undefined) return;
@@ -441,7 +444,7 @@ function parseBlock(innerText) {
}
};
- // 处理结构化数据(JSON/TOML)
+ // process structured data (json/toml)
const processStructuredData = (data) => {
const process = (d) => {
if (!d || typeof d !== 'object') return;
@@ -507,7 +510,7 @@ function parseBlock(innerText) {
return true;
};
- // 尝试 JSON 解析
+ // try JSON parsing
const tryParseJson = (text) => {
const s = String(text || '').trim();
if (!s || (s[0] !== '{' && s[0] !== '[')) return false;
@@ -563,7 +566,7 @@ function parseBlock(innerText) {
return relaxed !== s && attempt(relaxed);
};
- // 尝试 TOML 解析
+ // try TOML parsing
const tryParseToml = (text) => {
const src = String(text || '').trim();
if (!src || !src.includes('[') || !src.includes('=')) return false;
@@ -638,11 +641,11 @@ function parseBlock(innerText) {
}
};
- // 尝试 JSON/TOML
+ // try JSON/TOML
if (tryParseJson(textForJsonToml)) return finalizeResults();
if (tryParseToml(textForJsonToml)) return finalizeResults();
- // YAML 解析
+ // YAML parsing
let curOp = '';
const stack = [];
@@ -729,7 +732,8 @@ function parseBlock(innerText) {
const curPath = norm(curPathRaw);
if (!curPath) continue;
- // 块标量
+ // note
+
if (rhs && (rhs[0] === '|' || rhs[0] === '>')) {
const { text, next } = readBlockScalar(i + 1, ind, rhs[0]);
i = next;
@@ -741,7 +745,7 @@ function parseBlock(innerText) {
continue;
}
- // 空值(嵌套对象或列表)
+ // empty value (nested object or list)
if (rhs === '') {
stack.push({
indent: ind,
@@ -791,7 +795,8 @@ function parseBlock(innerText) {
continue;
}
- // 普通值
+ // note
+
const [top, ...rest] = curPath.split('.');
const rel = rest.join('.');
if (curOp === 'set') {
@@ -817,7 +822,8 @@ function parseBlock(innerText) {
continue;
}
- // 顶层列表项(del 操作)
+ // note
+
const mArr = t.match(/^-+\s*(.+)$/);
if (mArr && stack.length === 0 && curOp === 'del') {
const rawItem = stripQ(stripYamlInlineComment(mArr[1]));
@@ -830,7 +836,8 @@ function parseBlock(innerText) {
continue;
}
- // 嵌套列表项
+ // note
+
if (mArr && stack.length) {
const curPath = stack[stack.length - 1].path;
const [top, ...rest] = curPath.split('.');
@@ -856,7 +863,7 @@ function parseBlock(innerText) {
return finalizeResults();
}
-/* ============= 变量守护与规则集 ============= */
+/* ============ Variable Guard & Rules ============= */
function rulesGetTable() {
return guardianState.table || {};
@@ -877,7 +884,7 @@ function rulesLoadFromMeta() {
const raw = meta[LWB_RULES_KEY];
if (raw && typeof raw === 'object') {
rulesSetTable(deepClone(raw));
- // 重建正则缓存
+ // rebuild regex cache
for (const [p, node] of Object.entries(guardianState.table)) {
if (node?.constraints?.regex?.source) {
const src = node.constraints.regex.source;
@@ -1043,7 +1050,7 @@ function getEffectiveParentNode(p) {
}
/**
- * 守护验证
+ * guard validation
*/
export function guardValidate(op, absPath, payload) {
if (guardianState.bypass) return { allow: true, value: payload };
@@ -1057,14 +1064,15 @@ export function guardValidate(op, absPath, payload) {
constraints: {}
};
- // 只读检查
+ // note
+
if (node.ro) return { allow: false, reason: 'ro' };
const parentPath = getParentPath(p);
const parentNode = parentPath ? (getEffectiveParentNode(p) || { objectPolicy: 'none', arrayPolicy: 'lock' }) : null;
const currentValue = getValueAtPath(p);
- // 删除操作
+ // delete op
if (op === 'delNode') {
if (!parentPath) return { allow: false, reason: 'no-parent' };
@@ -1087,7 +1095,7 @@ export function guardValidate(op, absPath, payload) {
}
}
- // 推入操作
+ // push op
if (op === 'push') {
const arr = getValueAtPath(p);
if (arr === undefined) {
@@ -1124,7 +1132,7 @@ export function guardValidate(op, absPath, payload) {
return { allow: true, value: payload };
}
- // 增量操作
+ // bump op
if (op === 'bump') {
let d = Number(payload);
if (!Number.isFinite(d)) return { allow: false, reason: 'delta-nan' };
@@ -1167,7 +1175,7 @@ export function guardValidate(op, absPath, payload) {
return { allow: true, value: clamped.value };
}
- // 设置操作
+ // set op
if (op === 'set') {
const exists = currentValue !== undefined;
if (!exists) {
@@ -1229,7 +1237,7 @@ export function guardValidate(op, absPath, payload) {
}
/**
- * 应用规则增量
+ * apply rules delta
*/
export function applyRuleDelta(path, delta) {
const p = normalizePath(path);
@@ -1284,7 +1292,7 @@ export function applyRuleDelta(path, delta) {
}
/**
- * 从树加载规则
+ * load rules from tree
*/
export function rulesLoadFromTree(valueTree, basePath) {
const isObj = v => v && typeof v === 'object' && !Array.isArray(v);
@@ -1351,7 +1359,7 @@ export function rulesLoadFromTree(valueTree, basePath) {
}
/**
- * 应用规则增量表
+ * apply rules delta table
*/
export function applyRulesDeltaToTable(delta) {
if (!delta || typeof delta !== 'object') return;
@@ -1362,7 +1370,7 @@ export function applyRulesDeltaToTable(delta) {
}
/**
- * 安装变量 API 补丁
+ * install variable API patch
*/
function installVariableApiPatch() {
try {
@@ -1449,7 +1457,7 @@ function installVariableApiPatch() {
}
/**
- * 卸载变量 API 补丁
+ * uninstall variable API patch
*/
function uninstallVariableApiPatch() {
try {
@@ -1467,7 +1475,7 @@ function uninstallVariableApiPatch() {
} catch {}
}
-/* ============= 快照/回滚 ============= */
+/* ============ Snapshots / Rollback ============= */
function getSnapMap() {
const meta = getContext()?.chatMetadata || {};
@@ -1488,7 +1496,7 @@ function setVarDict(dict) {
const current = meta.variables || {};
const next = dict || {};
- // 清除不存在的变量
+ // remove missing variables
for (const k of Object.keys(current)) {
if (!(k in next)) {
try { delete current[k]; } catch {}
@@ -1496,7 +1504,8 @@ function setVarDict(dict) {
}
}
- // 设置新值
+ // note
+
for (const [k, v] of Object.entries(next)) {
let toStore = v;
if (v && typeof v === 'object') {
@@ -1615,6 +1624,13 @@ function rollbackToPreviousOf(messageId) {
const id = Number(messageId);
if (Number.isNaN(id)) return;
+ clearStateAppliedFrom(id);
+
+ if (typeof globalThis.LWB_StateRollbackHook === 'function') {
+ Promise.resolve(globalThis.LWB_StateRollbackHook(id)).catch((e) => {
+ console.error('[variablesCore] LWB_StateRollbackHook failed:', e);
+ });
+ }
const prevId = id - 1;
if (prevId < 0) return;
@@ -1641,10 +1657,10 @@ function rebuildVariablesFromScratch() {
} catch {}
}
-/* ============= 应用变量到消息 ============= */
+/* ============ Apply Variables To Message ============= */
/**
- * 将对象模式转换
+ * switch to object mode
*/
function asObject(rec) {
if (rec.mode !== 'object') {
@@ -1658,7 +1674,7 @@ function asObject(rec) {
}
/**
- * 增量操作辅助
+ * bump helper
*/
function bumpAtPath(rec, path, delta) {
const numDelta = Number(delta);
@@ -1715,7 +1731,7 @@ function bumpAtPath(rec, path, delta) {
}
/**
- * 解析标量数组
+ * parse scalar array
*/
function parseScalarArrayMaybe(str) {
try {
@@ -1727,8 +1743,55 @@ function parseScalarArrayMaybe(str) {
}
/**
- * 应用变量到消息
+ * apply variables for message
*/
+function readMessageText(msg) {
+ if (!msg) return '';
+ if (typeof msg.mes === 'string') return msg.mes;
+ if (typeof msg.content === 'string') return msg.content;
+ if (Array.isArray(msg.content)) {
+ return msg.content
+ .filter(p => p?.type === 'text' && typeof p.text === 'string')
+ .map(p => p.text)
+ .join('\n');
+ }
+ return '';
+}
+
+function getVariablesMode() {
+ try {
+ return extension_settings?.[EXT_ID]?.variablesMode || '1.0';
+ } catch {
+ return '1.0';
+ }
+}
+
+async function applyVarsForMessage(messageId) {
+ const ctx = getContext();
+ const msg = ctx?.chat?.[messageId];
+ if (!msg) return;
+
+ const text = readMessageText(msg);
+ const mode = getVariablesMode();
+
+ if (mode === '2.0') {
+ const result = applyStateForMessage(messageId, text);
+
+ if (result.errors?.length) {
+ console.warn('[variablesCore][2.0] warnings:', result.errors);
+ }
+
+ if (result.atoms?.length) {
+ $(document).trigger('xiaobaix:variables:stateAtomsGenerated', {
+ messageId,
+ atoms: result.atoms
+ });
+ }
+ return;
+ }
+
+ await applyVariablesForMessage(messageId);
+}
async function applyVariablesForMessage(messageId) {
try {
const ctx = getContext();
@@ -1739,7 +1802,7 @@ async function applyVariablesForMessage(messageId) {
const preview = (text, max = 220) => {
try {
const s = String(text ?? '').replace(/\s+/g, ' ').trim();
- return s.length > max ? s.slice(0, max) + '…' : s;
+ return s.length > max ? s.slice(0, max) + '...' : s;
} catch {
return '';
}
@@ -1779,7 +1842,7 @@ async function applyVariablesForMessage(messageId) {
} catch (e) {
parseErrors++;
if (debugOn) {
- try { xbLog.error(MODULE_ID, `plot-log 解析失败:楼层=${messageId} 块#${idx + 1} 预览=${preview(b)}`, e); } catch {}
+ try { xbLog.error(MODULE_ID, `plot-log 解析失败:楼?${messageId} ?${idx + 1} 预览=${preview(b)}`, e); } catch {}
}
return;
}
@@ -1810,7 +1873,7 @@ async function applyVariablesForMessage(messageId) {
try {
xbLog.warn(
MODULE_ID,
- `plot-log 未产生可执行指令:楼层=${messageId} 块数=${blocks.length} 解析条目=${parsedPartsTotal} 解析失败=${parseErrors} 预览=${preview(blocks[0])}`
+ `plot-log 未产生可执行指令:楼?${messageId} 块数=${blocks.length} 解析条目=${parsedPartsTotal} 解析失败=${parseErrors} 预览=${preview(blocks[0])}`
);
} catch {}
}
@@ -1818,7 +1881,7 @@ async function applyVariablesForMessage(messageId) {
return;
}
- // 构建变量记录
+ // build variable records
const byName = new Map();
for (const { name } of ops) {
@@ -1838,9 +1901,9 @@ async function applyVariablesForMessage(messageId) {
const norm = (p) => String(p || '').replace(/\[(\d+)\]/g, '.$1');
- // 执行操作
+ // execute operations
for (const op of ops) {
- // 守护指令
+ // guard directives
if (op.operation === 'guard') {
for (const entry of op.data) {
const path = typeof entry?.path === 'string' ? entry.path.trim() : '';
@@ -1865,7 +1928,7 @@ async function applyVariablesForMessage(messageId) {
const rec = byName.get(root);
if (!rec) continue;
- // SET 操作
+ // set op
if (op.operation === 'setObject') {
for (const [k, v] of Object.entries(op.data)) {
const localPath = joinPath(subPath, k);
@@ -1903,7 +1966,7 @@ async function applyVariablesForMessage(messageId) {
}
}
- // DEL 操作
+ // delete op
else if (op.operation === 'del') {
const obj = asObject(rec);
const pending = [];
@@ -1951,7 +2014,8 @@ async function applyVariablesForMessage(messageId) {
});
}
- // 按索引分组(倒序删除)
+ // note
+
const arrGroups = new Map();
const objDeletes = [];
@@ -1977,7 +2041,7 @@ async function applyVariablesForMessage(messageId) {
}
}
- // PUSH 操作
+ // push op
else if (op.operation === 'push') {
for (const [k, vals] of Object.entries(op.data)) {
const localPath = joinPath(subPath, k);
@@ -2033,7 +2097,7 @@ async function applyVariablesForMessage(messageId) {
}
}
- // BUMP 操作
+ // bump op
else if (op.operation === 'bump') {
for (const [k, delta] of Object.entries(op.data)) {
const num = Number(delta);
@@ -2077,7 +2141,7 @@ async function applyVariablesForMessage(messageId) {
}
}
- // 检查是否有变化
+ // check for changes
const hasChanges = Array.from(byName.values()).some(rec => rec?.changed === true);
if (!hasChanges && delVarNames.size === 0) {
if (debugOn) {
@@ -2085,7 +2149,7 @@ async function applyVariablesForMessage(messageId) {
const denied = guardDenied ? `,被规则拦截=${guardDenied}` : '';
xbLog.warn(
MODULE_ID,
- `plot-log 指令执行后无变化:楼层=${messageId} 指令数=${ops.length}${denied} 示例=${preview(JSON.stringify(guardDeniedSamples))}`
+ `plot-log 指令执行后无变化:楼?${messageId} 指令?${ops.length}${denied} 示例=${preview(JSON.stringify(guardDeniedSamples))}`
);
} catch {}
}
@@ -2093,7 +2157,7 @@ async function applyVariablesForMessage(messageId) {
return;
}
- // 保存变量
+ // save variables
for (const [name, rec] of byName.entries()) {
if (!rec.changed) continue;
try {
@@ -2105,7 +2169,7 @@ async function applyVariablesForMessage(messageId) {
} catch {}
}
- // 删除变量
+ // delete variables
if (delVarNames.size > 0) {
try {
for (const v of delVarNames) {
@@ -2124,7 +2188,7 @@ async function applyVariablesForMessage(messageId) {
} catch {}
}
-/* ============= 事件处理 ============= */
+/* ============ Event Handling ============= */
function getMsgIdLoose(payload) {
if (payload && typeof payload === 'object') {
@@ -2150,56 +2214,57 @@ function bindEvents() {
let lastSwipedId;
suppressUpdatedOnce = new Set();
- // 消息发送
+ // note
+
events?.on(event_types.MESSAGE_SENT, async () => {
try {
snapshotCurrentLastFloor();
const chat = getContext()?.chat || [];
const id = chat.length ? chat.length - 1 : undefined;
if (typeof id === 'number') {
- await applyVariablesForMessage(id);
+ await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
}
} catch {}
});
- // 消息接收
+ // message received
events?.on(event_types.MESSAGE_RECEIVED, async (data) => {
try {
const id = getMsgIdLoose(data);
if (typeof id === 'number') {
- await applyVariablesForMessage(id);
+ await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
await executeQueuedVareventJsAfterTurn();
}
} catch {}
});
- // 用户消息渲染
+ // user message rendered
events?.on(event_types.USER_MESSAGE_RENDERED, async (data) => {
try {
const id = getMsgIdLoose(data);
if (typeof id === 'number') {
- await applyVariablesForMessage(id);
+ await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
snapshotForMessageId(id);
}
} catch {}
});
- // 角色消息渲染
+ // character message rendered
events?.on(event_types.CHARACTER_MESSAGE_RENDERED, async (data) => {
try {
const id = getMsgIdLoose(data);
if (typeof id === 'number') {
- await applyVariablesForMessage(id);
+ await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
snapshotForMessageId(id);
}
} catch {}
});
- // 消息更新
+ // message updated
events?.on(event_types.MESSAGE_UPDATED, async (data) => {
try {
const id = getMsgIdLoose(data);
@@ -2208,13 +2273,13 @@ function bindEvents() {
suppressUpdatedOnce.delete(id);
return;
}
- await applyVariablesForMessage(id);
+ await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
}
} catch {}
});
- // 消息编辑
+ // message edited
events?.on(event_types.MESSAGE_EDITED, async (data) => {
try {
const id = getMsgIdLoose(data);
@@ -2223,7 +2288,7 @@ function bindEvents() {
rollbackToPreviousOf(id);
setTimeout(async () => {
- await applyVariablesForMessage(id);
+ await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
try {
@@ -2248,7 +2313,7 @@ function bindEvents() {
} catch {}
});
- // 消息滑动
+ // message swiped
events?.on(event_types.MESSAGE_SWIPED, async (data) => {
try {
const id = getMsgIdLoose(data);
@@ -2259,7 +2324,7 @@ function bindEvents() {
const tId = setTimeout(async () => {
pendingSwipeApply.delete(id);
- await applyVariablesForMessage(id);
+ await applyVarsForMessage(id);
await executeQueuedVareventJsAfterTurn();
}, 10);
@@ -2268,7 +2333,7 @@ function bindEvents() {
} catch {}
});
- // 消息删除
+ // message deleted
events?.on(event_types.MESSAGE_DELETED, (data) => {
try {
const id = getMsgIdStrict(data);
@@ -2280,12 +2345,13 @@ function bindEvents() {
} catch {}
});
- // 生成开始
+ // note
+
events?.on(event_types.GENERATION_STARTED, (data) => {
try {
snapshotPreviousFloor();
- // 取消滑动延迟
+ // cancel swipe delay
const t = (typeof data === 'string' ? data : (data?.type || '')).toLowerCase();
if (t === 'swipe' && lastSwipedId != null) {
const tId = pendingSwipeApply.get(lastSwipedId);
@@ -2297,7 +2363,7 @@ function bindEvents() {
} catch {}
});
- // 聊天切换
+ // chat changed
events?.on(event_types.CHAT_CHANGED, () => {
try {
rulesClearCache();
@@ -2310,29 +2376,31 @@ function bindEvents() {
});
}
-/* ============= 初始化与清理 ============= */
+/* ============ Init & Cleanup ============= */
/**
- * 初始化模块
+ * init module
*/
export function initVariablesCore() {
try { xbLog.info('variablesCore', '变量系统启动'); } catch {}
if (initialized) return;
initialized = true;
- // 创建事件管理器
+ // init events
+
events = createModuleEvents(MODULE_ID);
- // 加载规则
+ // load rules
rulesLoadFromMeta();
- // 安装 API 补丁
+ // install API patch
installVariableApiPatch();
- // 绑定事件
+ // bind events
bindEvents();
- // 挂载全局函数(供 var-commands.js 使用)
+ // note
+
globalThis.LWB_Guard = {
validate: guardValidate,
loadRules: rulesLoadFromTree,
@@ -2343,47 +2411,47 @@ export function initVariablesCore() {
}
/**
- * 清理模块
+ * cleanup module
*/
export function cleanupVariablesCore() {
try { xbLog.info('variablesCore', '变量系统清理'); } catch {}
if (!initialized) return;
- // 清理事件
+ // cleanup events
events?.cleanup();
events = null;
- // 卸载 API 补丁
+ // uninstall API patch
uninstallVariableApiPatch();
- // 清理规则
+ // clear rules
rulesClearCache();
- // 清理全局函数
+ // clear global hooks
delete globalThis.LWB_Guard;
- // 清理守护状态
+ // clear guard state
guardBypass(false);
initialized = false;
}
-/* ============= 导出 ============= */
+/* ============ Exports ============= */
export {
MODULE_ID,
- // 解析
+ // parsing
parseBlock,
applyVariablesForMessage,
extractPlotLogBlocks,
- // 快照
+ // snapshots
snapshotCurrentLastFloor,
snapshotForMessageId,
rollbackToPreviousOf,
rebuildVariablesFromScratch,
- // 规则
+ // rules
rulesGetTable,
rulesSetTable,
rulesLoadFromMeta,
rulesSaveToMeta,
-};
+};
\ No newline at end of file
diff --git a/settings.html b/settings.html
index f4dfa9a..2e471de 100644
--- a/settings.html
+++ b/settings.html
@@ -213,10 +213,16 @@
变量控制
-