Update variables v2 state handling

This commit is contained in:
2026-02-01 02:49:35 +08:00
parent 4b0541610b
commit bcf664e9a0
6 changed files with 1008 additions and 128 deletions

View File

@@ -1,22 +1,329 @@
import { getContext } from '../../../../../../extensions.js';
import {
lwbResolveVarPath,
lwbAssignVarPath,
lwbAddVarPath,
lwbPushVarPath,
lwbDeleteVarPath,
lwbRemoveArrayItemByValue,
} from '../var-commands.js';
import { lwbSplitPathWithBrackets } from '../../../core/variable-path.js';
import { getLocalVariable, setLocalVariable } from '../../../../../../variables.js';
import { extractStateBlocks, computeStateSignature, parseStateBlock } from './parser.js';
import { generateSemantic } from './semantic.js';
const LWB_STATE_APPLIED_KEY = 'LWB_STATE_APPLIED_KEY';
import { validate, setRule, loadRulesFromMeta, saveRulesToMeta } from './guard.js';
/**
* chatMetadata 内记录每楼层 signature防止重复执行
* =========================
* Path / JSON helpers
* =========================
*/
function splitPath(path) {
const s = String(path || '');
const segs = [];
let buf = '';
let i = 0;
while (i < s.length) {
const ch = s[i];
if (ch === '.') {
if (buf) { segs.push(/^\d+$/.test(buf) ? Number(buf) : buf); buf = ''; }
i++;
} else if (ch === '[') {
if (buf) { segs.push(/^\d+$/.test(buf) ? Number(buf) : buf); buf = ''; }
i++;
let val = '';
if (s[i] === '"' || s[i] === "'") {
const q = s[i++];
while (i < s.length && s[i] !== q) val += s[i++];
i++;
} else {
while (i < s.length && s[i] !== ']') val += s[i++];
}
if (s[i] === ']') i++;
segs.push(/^\d+$/.test(val.trim()) ? Number(val.trim()) : val.trim());
} else {
buf += ch;
i++;
}
}
if (buf) segs.push(/^\d+$/.test(buf) ? Number(buf) : buf);
return segs;
}
function normalizePath(path) {
return splitPath(path).map(String).join('.');
}
function safeJSON(v) {
try { return JSON.stringify(v); } catch { return ''; }
}
function safeParse(s) {
if (s == null || s === '') return undefined;
if (typeof s !== 'string') return s;
const t = s.trim();
if (!t) return undefined;
if (t[0] === '{' || t[0] === '[') {
try { return JSON.parse(t); } catch { return s; }
}
if (/^-?\d+(?:\.\d+)?$/.test(t)) return Number(t);
if (t === 'true') return true;
if (t === 'false') return false;
return s;
}
function deepClone(obj) {
try { return structuredClone(obj); } catch {
try { return JSON.parse(JSON.stringify(obj)); } catch { return obj; }
}
}
/**
* =========================
* Variable getters/setters (local vars)
* =========================
*/
function getVar(path) {
const segs = splitPath(path);
if (!segs.length) return undefined;
const rootRaw = getLocalVariable(String(segs[0]));
if (segs.length === 1) return safeParse(rootRaw);
let obj = safeParse(rootRaw);
if (!obj || typeof obj !== 'object') return undefined;
for (let i = 1; i < segs.length; i++) {
obj = obj?.[segs[i]];
if (obj === undefined) return undefined;
}
return obj;
}
function setVar(path, value) {
const segs = splitPath(path);
if (!segs.length) return;
const rootName = String(segs[0]);
if (segs.length === 1) {
const toStore = (value && typeof value === 'object') ? safeJSON(value) : String(value ?? '');
setLocalVariable(rootName, toStore);
return;
}
let root = safeParse(getLocalVariable(rootName));
if (!root || typeof root !== 'object') {
root = typeof segs[1] === 'number' ? [] : {};
}
let cur = root;
for (let i = 1; i < segs.length - 1; i++) {
const key = segs[i];
const nextKey = segs[i + 1];
if (cur[key] == null || typeof cur[key] !== 'object') {
cur[key] = typeof nextKey === 'number' ? [] : {};
}
cur = cur[key];
}
cur[segs[segs.length - 1]] = value;
setLocalVariable(rootName, safeJSON(root));
}
function delVar(path) {
const segs = splitPath(path);
if (!segs.length) return;
const rootName = String(segs[0]);
if (segs.length === 1) {
setLocalVariable(rootName, '');
return;
}
let root = safeParse(getLocalVariable(rootName));
if (!root || typeof root !== 'object') return;
let cur = root;
for (let i = 1; i < segs.length - 1; i++) {
cur = cur?.[segs[i]];
if (!cur || typeof cur !== 'object') return;
}
const lastKey = segs[segs.length - 1];
if (Array.isArray(cur) && typeof lastKey === 'number') {
cur.splice(lastKey, 1);
} else {
delete cur[lastKey];
}
setLocalVariable(rootName, safeJSON(root));
}
function pushVar(path, value) {
const segs = splitPath(path);
if (!segs.length) return { ok: false, reason: 'invalid-path' };
const rootName = String(segs[0]);
if (segs.length === 1) {
let arr = safeParse(getLocalVariable(rootName));
// ✅ 类型检查:必须是数组或不存在
if (arr !== undefined && !Array.isArray(arr)) {
return { ok: false, reason: 'not-array' };
}
if (!Array.isArray(arr)) arr = [];
const items = Array.isArray(value) ? value : [value];
arr.push(...items);
setLocalVariable(rootName, safeJSON(arr));
return { ok: true };
}
let root = safeParse(getLocalVariable(rootName));
if (!root || typeof root !== 'object') {
root = typeof segs[1] === 'number' ? [] : {};
}
let cur = root;
for (let i = 1; i < segs.length - 1; i++) {
const key = segs[i];
const nextKey = segs[i + 1];
if (cur[key] == null || typeof cur[key] !== 'object') {
cur[key] = typeof nextKey === 'number' ? [] : {};
}
cur = cur[key];
}
const lastKey = segs[segs.length - 1];
let arr = cur[lastKey];
// ✅ 类型检查:必须是数组或不存在
if (arr !== undefined && !Array.isArray(arr)) {
return { ok: false, reason: 'not-array' };
}
if (!Array.isArray(arr)) arr = [];
const items = Array.isArray(value) ? value : [value];
arr.push(...items);
cur[lastKey] = arr;
setLocalVariable(rootName, safeJSON(root));
return { ok: true };
}
function popVar(path, value) {
const segs = splitPath(path);
if (!segs.length) return { ok: false, reason: 'invalid-path' };
const rootName = String(segs[0]);
let root = safeParse(getLocalVariable(rootName));
if (segs.length === 1) {
if (!Array.isArray(root)) {
return { ok: false, reason: 'not-array' };
}
const toRemove = Array.isArray(value) ? value : [value];
for (const v of toRemove) {
const vStr = safeJSON(v);
const idx = root.findIndex(x => safeJSON(x) === vStr);
if (idx !== -1) root.splice(idx, 1);
}
setLocalVariable(rootName, safeJSON(root));
return { ok: true };
}
if (!root || typeof root !== 'object') {
return { ok: false, reason: 'not-array' };
}
let cur = root;
for (let i = 1; i < segs.length - 1; i++) {
cur = cur?.[segs[i]];
if (!cur || typeof cur !== 'object') {
return { ok: false, reason: 'path-not-found' };
}
}
const lastKey = segs[segs.length - 1];
let arr = cur[lastKey];
if (!Array.isArray(arr)) {
return { ok: false, reason: 'not-array' };
}
const toRemove = Array.isArray(value) ? value : [value];
for (const v of toRemove) {
const vStr = safeJSON(v);
const idx = arr.findIndex(x => safeJSON(x) === vStr);
if (idx !== -1) arr.splice(idx, 1);
}
setLocalVariable(rootName, safeJSON(root));
return { ok: true };
}
/**
* =========================
* Storage (chat_metadata.extensions.LittleWhiteBox)
* =========================
*/
const EXT_ID = 'LittleWhiteBox';
const LOG_KEY = 'stateLogV2';
const CKPT_KEY = 'stateCkptV2';
function getLwbExtMeta() {
const ctx = getContext();
const meta = ctx?.chatMetadata || (ctx.chatMetadata = {});
meta.extensions ||= {};
meta.extensions[EXT_ID] ||= {};
return meta.extensions[EXT_ID];
}
function getStateLog() {
const ext = getLwbExtMeta();
ext[LOG_KEY] ||= { version: 1, floors: {} };
return ext[LOG_KEY];
}
function getCheckpointStore() {
const ext = getLwbExtMeta();
ext[CKPT_KEY] ||= { version: 1, every: 50, points: {} };
return ext[CKPT_KEY];
}
function saveWalRecord(floor, signature, rules, ops) {
const log = getStateLog();
log.floors[String(floor)] = {
signature: String(signature || ''),
rules: Array.isArray(rules) ? deepClone(rules) : [],
ops: Array.isArray(ops) ? deepClone(ops) : [],
ts: Date.now(),
};
getContext()?.saveMetadataDebounced?.();
}
/**
* checkpoint = 执行完 floor 后的全量变量+规则
*/
function saveCheckpointIfNeeded(floor) {
const ckpt = getCheckpointStore();
const every = Number(ckpt.every) || 50;
// floor=0 也可以存,但一般没意义;你可按需调整
if (floor < 0) return;
if (every <= 0) return;
if (floor % every !== 0) return;
const ctx = getContext();
const meta = ctx?.chatMetadata || {};
const vars = deepClone(meta.variables || {});
// 2.0 rules 存在 chatMetadata 里guard.js 写入的位置)
const rules = deepClone(meta.LWB_RULES_V2 || {});
ckpt.points[String(floor)] = { vars, rules, ts: Date.now() };
ctx?.saveMetadataDebounced?.();
}
/**
* =========================
* Applied signature map (idempotent)
* =========================
*/
const LWB_STATE_APPLIED_KEY = 'LWB_STATE_APPLIED_KEY';
function getAppliedMap() {
const meta = getContext()?.chatMetadata || {};
meta[LWB_STATE_APPLIED_KEY] ||= {};
@@ -25,8 +332,7 @@ function getAppliedMap() {
export function clearStateAppliedFor(floor) {
try {
const map = getAppliedMap();
delete map[floor];
delete getAppliedMap()[floor];
getContext()?.saveMetadataDebounced?.();
} catch {}
}
@@ -35,44 +341,24 @@ 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];
if (Number(k) >= 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);
const segs = splitPath(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 }]
// 同一个数组的 index-del按 parentPath 分组,组内 index 倒序
// 其它操作:保持原顺序
const groups = new Map(); // parentPath -> { order, items: [{...opItem, index}] }
const groupOrder = new Map();
let orderCounter = 0;
@@ -80,9 +366,12 @@ function buildExecOpsWithIndexDeleteReorder(ops) {
for (const op of ops) {
if (isIndexDeleteOp(op)) {
const segs = lwbSplitPathWithBrackets(op.path);
const segs = splitPath(op.path);
const idx = segs[segs.length - 1];
const parentPath = buildParentPathFromSegs(segs.slice(0, -1));
const parentPath = segs.slice(0, -1).reduce((acc, s) => {
if (typeof s === 'number') return acc + `[${s}]`;
return acc ? `${acc}.${s}` : String(s);
}, '');
if (!groups.has(parentPath)) {
groups.set(parentPath, []);
@@ -94,85 +383,137 @@ function buildExecOpsWithIndexDeleteReorder(ops) {
}
}
const orderedParents = Array.from(groups.keys()).sort(
(a, b) => (groupOrder.get(a) ?? 0) - (groupOrder.get(b) ?? 0)
);
// 按“该数组第一次出现的顺序”输出各组(可预测)
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);
}
// ✅ 我们把“索引删除”放在最前面执行:这样它们永远按“原索引”删
// (避免在同一轮里先删后 push 导致索引变化)
return [...reorderedIndexDeletes, ...normalOps];
}
/**
* 变量 2.0:执行单条消息里的 <state>,返回 atoms
* =========================
* Core: apply one message text (<state>...) => update vars + rules + wal + checkpoint
* =========================
*/
export function applyStateForMessage(messageId, messageContent) {
const ctx = getContext();
const chatId = ctx?.chatId || '';
loadRulesFromMeta();
const text = String(messageContent ?? '');
const signature = computeStateSignature(text);
// 没有 state清理旧 signature避免“删掉 state 后仍然认为执行过”)
if (!signature) {
const blocks = extractStateBlocks(text);
// ✅ 统一:只要没有可执行 blocks就视为本层 state 被移除
if (!signature || blocks.length === 0) {
clearStateAppliedFor(messageId);
// delete WAL record
try {
const ext = getLwbExtMeta();
const log = ext[LOG_KEY];
if (log?.floors) delete log.floors[String(messageId)];
getContext()?.saveMetadataDebounced?.();
} catch {}
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;
const mergedRules = [];
const mergedOps = [];
for (const block of blocks) {
const ops = parseStateBlock(block);
const execOps = buildExecOpsWithIndexDeleteReorder(ops);
const parsed = parseStateBlock(block);
mergedRules.push(...(parsed?.rules || []));
mergedOps.push(...(parsed?.ops || []));
}
if (blocks.length) {
// ✅ WAL一次写入完整的 rules/ops
saveWalRecord(messageId, signature, mergedRules, mergedOps);
// ✅ rules 一次性注册
let rulesTouched = false;
for (const { path, rule } of mergedRules) {
if (path && rule && Object.keys(rule).length) {
setRule(normalizePath(path), rule);
rulesTouched = true;
}
}
if (rulesTouched) saveRulesToMeta();
const execOps = buildExecOpsWithIndexDeleteReorder(mergedOps);
// 执行操作(用 execOps
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));
const absPath = normalizePath(path);
const oldValue = getVar(path);
const guard = validate(op, absPath, op === 'inc' ? delta : value, oldValue);
if (!guard.allow) {
errors.push(`[${path}] 拒绝: ${guard.reason}`);
continue;
}
let execOk = true;
let execReason = '';
try {
switch (op) {
case 'set':
lwbAssignVarPath(path, value);
setVar(path, guard.value);
break;
case 'inc':
lwbAddVarPath(path, delta);
// guard.value 对 inc 是最终 nextValue
setVar(path, guard.value);
break;
case 'push':
lwbPushVarPath(path, value);
case 'push': {
const result = pushVar(path, guard.value);
if (!result.ok) { execOk = false; execReason = result.reason; }
break;
case 'pop':
lwbRemoveArrayItemByValue(path, value);
}
case 'pop': {
const result = popVar(path, guard.value);
if (!result.ok) { execOk = false; execReason = result.reason; }
break;
}
case 'del':
lwbDeleteVarPath(path);
delVar(path);
break;
default:
errors.push(`[${path}] 未知 op=${op}`);
continue;
execOk = false;
execReason = `未知 op=${op}`;
}
} catch (e) {
errors.push(`[${path}] 执行失败: ${e?.message || e}`);
execOk = false;
execReason = e?.message || String(e);
}
if (!execOk) {
errors.push(`[${path}] 失败: ${execReason}`);
continue;
}
const newValue = safeParseAny(lwbResolveVarPath(path));
const newValue = getVar(path);
atoms.push({
atomId: `sa-${messageId}-${idx}`,
@@ -195,5 +536,182 @@ export function applyStateForMessage(messageId, messageContent) {
appliedMap[messageId] = signature;
getContext()?.saveMetadataDebounced?.();
// ✅ checkpoint执行完该楼后可选存一次全量
saveCheckpointIfNeeded(messageId);
return { atoms, errors, skipped: false };
}
/**
* =========================
* Restore / Replay (for rollback & rebuild)
* =========================
*/
/**
* 恢复到 targetFloor 执行完成后的变量状态(含规则)
* - 使用最近 checkpoint然后 replay WAL
* - 不依赖消息文本 <state>(避免被正则清掉)
*/
export async function restoreStateV2ToFloor(targetFloor) {
const ctx = getContext();
const meta = ctx?.chatMetadata || {};
const floor = Number(targetFloor);
if (!Number.isFinite(floor) || floor < 0) {
// floor < 0 => 清空
meta.variables = {};
meta.LWB_RULES_V2 = {};
ctx?.saveMetadataDebounced?.();
return { ok: true, usedCheckpoint: null };
}
const log = getStateLog();
const ckpt = getCheckpointStore();
const points = ckpt.points || {};
const available = Object.keys(points)
.map(Number)
.filter(n => Number.isFinite(n) && n <= floor)
.sort((a, b) => b - a);
const ck = available.length ? available[0] : null;
// 1) 恢复 checkpoint 或清空基线
if (ck != null) {
const snap = points[String(ck)];
meta.variables = deepClone(snap?.vars || {});
meta.LWB_RULES_V2 = deepClone(snap?.rules || {});
} else {
meta.variables = {};
meta.LWB_RULES_V2 = {};
}
ctx?.saveMetadataDebounced?.();
// 2) 从 meta 载入规则到内存guard.js 的内存表)
loadRulesFromMeta();
let rulesTouchedAny = false;
// 3) replay WAL: (ck+1 .. floor)
const start = ck == null ? 0 : (ck + 1);
for (let f = start; f <= floor; f++) {
const rec = log.floors?.[String(f)];
if (!rec) continue;
// 先应用 rules
const rules = Array.isArray(rec.rules) ? rec.rules : [];
let touched = false;
for (const r of rules) {
const p = r?.path;
const rule = r?.rule;
if (p && rule && typeof rule === 'object') {
setRule(normalizePath(p), rule);
touched = true;
}
}
if (touched) rulesTouchedAny = true;
// 再应用 ops不产出 atoms、不写 wal
const ops = Array.isArray(rec.ops) ? rec.ops : [];
const execOps = buildExecOpsWithIndexDeleteReorder(ops);
for (const opItem of execOps) {
const path = opItem?.path;
const op = opItem?.op;
if (!path || !op) continue;
const absPath = normalizePath(path);
const oldValue = getVar(path);
const payload = (op === 'inc') ? opItem.delta : opItem.value;
const guard = validate(op, absPath, payload, oldValue);
if (!guard.allow) continue;
try {
switch (op) {
case 'set':
setVar(path, guard.value);
break;
case 'inc':
setVar(path, guard.value);
break;
case 'push': {
const result = pushVar(path, guard.value);
if (!result.ok) {/* ignore */}
break;
}
case 'pop': {
const result = popVar(path, guard.value);
if (!result.ok) {/* ignore */}
break;
}
case 'del':
delVar(path);
break;
}
} catch {
// ignore replay errors
}
}
}
if (rulesTouchedAny) {
saveRulesToMeta();
}
// 4) 清理 applied signaturefloor 之后都要重新计算
clearStateAppliedFrom(floor + 1);
ctx?.saveMetadataDebounced?.();
return { ok: true, usedCheckpoint: ck };
}
/**
* 删除 floor >= fromFloor 的 2.0 持久化数据:
* - WAL: stateLogV2.floors
* - checkpoint: stateCkptV2.points
* - applied signature: LWB_STATE_APPLIED_KEY
*
* 用于 MESSAGE_DELETED 等“物理删除消息”场景,避免 WAL/ckpt 无限膨胀。
*/
export async function trimStateV2FromFloor(fromFloor) {
const start = Number(fromFloor);
if (!Number.isFinite(start)) return { ok: false };
const ctx = getContext();
const meta = ctx?.chatMetadata || {};
meta.extensions ||= {};
meta.extensions[EXT_ID] ||= {};
const ext = meta.extensions[EXT_ID];
// 1) WAL
const log = ext[LOG_KEY];
if (log?.floors && typeof log.floors === 'object') {
for (const k of Object.keys(log.floors)) {
const f = Number(k);
if (Number.isFinite(f) && f >= start) {
delete log.floors[k];
}
}
}
// 2) Checkpoints
const ckpt = ext[CKPT_KEY];
if (ckpt?.points && typeof ckpt.points === 'object') {
for (const k of Object.keys(ckpt.points)) {
const f = Number(k);
if (Number.isFinite(f) && f >= start) {
delete ckpt.points[k];
}
}
}
// 3) Applied signaturesfloor>=start 都要重新算)
try {
clearStateAppliedFrom(start);
} catch {}
ctx?.saveMetadataDebounced?.();
return { ok: true };
}

View File

@@ -0,0 +1,123 @@
import { getContext } from '../../../../../../extensions.js';
const LWB_RULES_V2_KEY = 'LWB_RULES_V2';
let rulesTable = {};
export function loadRulesFromMeta() {
try {
const meta = getContext()?.chatMetadata || {};
rulesTable = meta[LWB_RULES_V2_KEY] || {};
} catch {
rulesTable = {};
}
}
export function saveRulesToMeta() {
try {
const meta = getContext()?.chatMetadata || {};
meta[LWB_RULES_V2_KEY] = { ...rulesTable };
getContext()?.saveMetadataDebounced?.();
} catch {}
}
export function getRuleNode(path) {
return rulesTable[path] || null;
}
export function setRule(path, rule) {
rulesTable[path] = { ...(rulesTable[path] || {}), ...rule };
}
export function clearRule(path) {
delete rulesTable[path];
saveRulesToMeta();
}
export function clearAllRules() {
rulesTable = {};
saveRulesToMeta();
}
export function getParentPath(absPath) {
const parts = String(absPath).split('.').filter(Boolean);
if (parts.length <= 1) return '';
return parts.slice(0, -1).join('.');
}
/**
* 验证操作
* @param {string} op - set/inc/push/pop/del
* @param {string} absPath - 规范化路径
* @param {*} payload - set的值 / inc的delta / push的值 / pop的值
* @param {*} currentValue - 当前值
*/
export function validate(op, absPath, payload, currentValue) {
const node = getRuleNode(absPath);
const parentPath = getParentPath(absPath);
const parentNode = parentPath ? getRuleNode(parentPath) : null;
const isNewKey = currentValue === undefined;
// 父层 $lock不允许新增/删除 key
if (parentNode?.lock) {
if (isNewKey && (op === 'set' || op === 'push')) {
return { allow: false, reason: 'parent-locked' };
}
if (op === 'del') {
return { allow: false, reason: 'parent-locked' };
}
}
// 当前 $ro不允许改值
if (node?.ro && (op === 'set' || op === 'inc')) {
return { allow: false, reason: 'ro' };
}
// 当前 $lock不允许改结构
if (node?.lock && (op === 'push' || op === 'pop' || op === 'del')) {
return { allow: false, reason: 'lock' };
}
// set 数值约束
if (op === 'set') {
const num = Number(payload);
if (Number.isFinite(num) && (node?.min !== undefined || node?.max !== undefined)) {
let v = num;
if (node.min !== undefined) v = Math.max(v, node.min);
if (node.max !== undefined) v = Math.min(v, node.max);
return { allow: true, value: v };
}
// enum
if (node?.enum?.length) {
if (!node.enum.includes(String(payload ?? ''))) {
return { allow: false, reason: 'enum' };
}
}
return { allow: true, value: payload };
}
// inc返回最终值
if (op === 'inc') {
const delta = Number(payload);
if (!Number.isFinite(delta)) return { allow: false, reason: 'delta-nan' };
const cur = Number(currentValue) || 0;
let d = delta;
// step 限制
if (node?.step !== undefined && node.step >= 0) {
if (d > node.step) d = node.step;
if (d < -node.step) d = -node.step;
}
let next = cur + d;
// range 限制
if (node?.min !== undefined) next = Math.max(next, node.min);
if (node?.max !== undefined) next = Math.min(next, node.max);
return { allow: true, value: next };
}
return { allow: true, value: payload };
}

View File

@@ -1,3 +1,21 @@
export { applyStateForMessage, clearStateAppliedFor, clearStateAppliedFrom } from './executor.js';
export {
applyStateForMessage,
clearStateAppliedFor,
clearStateAppliedFrom,
restoreStateV2ToFloor,
trimStateV2FromFloor,
} from './executor.js';
export { parseStateBlock, extractStateBlocks, computeStateSignature, parseInlineValue } from './parser.js';
export { generateSemantic } from './semantic.js';
export {
validate,
setRule,
clearRule,
clearAllRules,
loadRulesFromMeta,
saveRulesToMeta,
getRuleNode,
getParentPath,
} from './guard.js';

View File

@@ -21,6 +21,7 @@ import {
const MODULE_ID = 'varCommands';
const TAG_RE_XBGETVAR = /\{\{xbgetvar::([^}]+)\}\}/gi;
const TAG_RE_XBGETVAR_YAML = /\{\{xbgetvar_yaml::([^}]+)\}\}/gi;
const TAG_RE_XBGETVAR_YAML_IDX = /\{\{xbgetvar_yaml_idx::([^}]+)\}\}/gi;
let events = null;
let initialized = false;
@@ -183,6 +184,121 @@ export function replaceXbGetVarYamlInString(s) {
});
}
/**
* 将 {{xbgetvar_yaml_idx::路径}} 替换为带索引注释的 YAML
*/
export function replaceXbGetVarYamlIdxInString(s) {
s = String(s ?? '');
if (!s || s.indexOf('{{xbgetvar_yaml_idx::') === -1) return s;
TAG_RE_XBGETVAR_YAML_IDX.lastIndex = 0;
return s.replace(TAG_RE_XBGETVAR_YAML_IDX, (_, p) => {
const value = lwbResolveVarPath(p);
if (!value) return '';
try {
const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null) {
return formatYamlWithIndex(parsed, 0).trim();
}
return value;
} catch {
return value;
}
});
}
function formatYamlWithIndex(obj, indent) {
const pad = ' '.repeat(indent);
if (Array.isArray(obj)) {
if (obj.length === 0) return `${pad}[]`;
const lines = [];
obj.forEach((item, idx) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const keys = Object.keys(item);
if (keys.length === 0) {
lines.push(`${pad}- {} # [${idx}]`);
} else {
const firstKey = keys[0];
const firstVal = item[firstKey];
const firstFormatted = formatValue(firstVal, indent + 2);
if (typeof firstVal === 'object' && firstVal !== null) {
lines.push(`${pad}- ${firstKey}: # [${idx}]`);
lines.push(firstFormatted);
} else {
lines.push(`${pad}- ${firstKey}: ${firstFormatted} # [${idx}]`);
}
for (let i = 1; i < keys.length; i++) {
const k = keys[i];
const v = item[k];
const vFormatted = formatValue(v, indent + 2);
if (typeof v === 'object' && v !== null) {
lines.push(`${pad} ${k}:`);
lines.push(vFormatted);
} else {
lines.push(`${pad} ${k}: ${vFormatted}`);
}
}
}
} else if (Array.isArray(item)) {
lines.push(`${pad}- # [${idx}]`);
lines.push(formatYamlWithIndex(item, indent + 1));
} else {
lines.push(`${pad}- ${formatScalar(item)} # [${idx}]`);
}
});
return lines.join('\n');
}
if (obj && typeof obj === 'object') {
if (Object.keys(obj).length === 0) return `${pad}{}`;
const lines = [];
for (const [key, val] of Object.entries(obj)) {
const vFormatted = formatValue(val, indent + 1);
if (typeof val === 'object' && val !== null) {
lines.push(`${pad}${key}:`);
lines.push(vFormatted);
} else {
lines.push(`${pad}${key}: ${vFormatted}`);
}
}
return lines.join('\n');
}
return `${pad}${formatScalar(obj)}`;
}
function formatValue(val, indent) {
if (Array.isArray(val)) return formatYamlWithIndex(val, indent);
if (val && typeof val === 'object') return formatYamlWithIndex(val, indent);
return formatScalar(val);
}
function formatScalar(v) {
if (v === null) return 'null';
if (v === undefined) return '';
if (typeof v === 'boolean') return String(v);
if (typeof v === 'number') return String(v);
if (typeof v === 'string') {
const needsQuote =
v === '' ||
/^\s|\s$/.test(v) || // 首尾空格
/[:[]\]{}&*!|>'"%@`#,]/.test(v) || // YAML 易歧义字符
/^(?:true|false|null)$/i.test(v) || // YAML 关键字
/^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(v); // 纯数字字符串
if (needsQuote) {
return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
return v;
}
return String(v);
}
export function replaceXbGetVarInChat(chat) {
if (!Array.isArray(chat)) return;
@@ -194,10 +310,12 @@ export function replaceXbGetVarInChat(chat) {
const old = String(msg[key] ?? '');
const hasJson = old.indexOf('{{xbgetvar::') !== -1;
const hasYaml = old.indexOf('{{xbgetvar_yaml::') !== -1;
if (!hasJson && !hasYaml) continue;
const hasYamlIdx = old.indexOf('{{xbgetvar_yaml_idx::') !== -1;
if (!hasJson && !hasYaml && !hasYamlIdx) continue;
let result = hasJson ? replaceXbGetVarInString(old) : old;
result = hasYaml ? replaceXbGetVarYamlInString(result) : result;
result = hasYamlIdx ? replaceXbGetVarYamlIdxInString(result) : result;
msg[key] = result;
} catch {}
}
@@ -215,10 +333,12 @@ export function applyXbGetVarForMessage(messageId, writeback = true) {
const old = String(msg[key] ?? '');
const hasJson = old.indexOf('{{xbgetvar::') !== -1;
const hasYaml = old.indexOf('{{xbgetvar_yaml::') !== -1;
if (!hasJson && !hasYaml) return;
const hasYamlIdx = old.indexOf('{{xbgetvar_yaml_idx::') !== -1;
if (!hasJson && !hasYaml && !hasYamlIdx) return;
let out = hasJson ? replaceXbGetVarInString(old) : old;
out = hasYaml ? replaceXbGetVarYamlInString(out) : out;
out = hasYamlIdx ? replaceXbGetVarYamlIdxInString(out) : out;
if (writeback && out !== old) {
msg[key] = out;
}
@@ -1111,7 +1231,9 @@ export function cleanupVarCommands() {
initialized = false;
}
/**
* 按值从数组中删除元素2.0 pop 操作)
*/
export {
MODULE_ID,
};

View File

@@ -6,7 +6,7 @@
import { getContext } from "../../../../../extensions.js";
import { getLocalVariable } from "../../../../../variables.js";
import { createModuleEvents } from "../../core/event-manager.js";
import { replaceXbGetVarInString, replaceXbGetVarYamlInString } from "./var-commands.js";
import { replaceXbGetVarInString, replaceXbGetVarYamlInString, replaceXbGetVarYamlIdxInString } from "./var-commands.js";
const MODULE_ID = 'vareventEditor';
const LWB_EXT_ID = 'LittleWhiteBox';
@@ -303,6 +303,9 @@ function installWIHiddenTagStripper() {
if (msg.content.indexOf('{{xbgetvar_yaml::') !== -1) {
msg.content = replaceXbGetVarYamlInString(msg.content);
}
if (msg.content.indexOf('{{xbgetvar_yaml_idx::') !== -1) {
msg.content = replaceXbGetVarYamlIdxInString(msg.content);
}
}
if (Array.isArray(msg?.content)) {
for (const part of msg.content) {
@@ -321,6 +324,9 @@ function installWIHiddenTagStripper() {
if (part.text.indexOf('{{xbgetvar_yaml::') !== -1) {
part.text = replaceXbGetVarYamlInString(part.text);
}
if (part.text.indexOf('{{xbgetvar_yaml_idx::') !== -1) {
part.text = replaceXbGetVarYamlIdxInString(part.text);
}
}
}
}
@@ -339,6 +345,9 @@ function installWIHiddenTagStripper() {
if (msg.mes.indexOf('{{xbgetvar_yaml::') !== -1) {
msg.mes = replaceXbGetVarYamlInString(msg.mes);
}
if (msg.mes.indexOf('{{xbgetvar_yaml_idx::') !== -1) {
msg.mes = replaceXbGetVarYamlIdxInString(msg.mes);
}
}
}
} catch {}
@@ -373,6 +382,9 @@ function installWIHiddenTagStripper() {
if (data.prompt.indexOf('{{xbgetvar_yaml::') !== -1) {
data.prompt = replaceXbGetVarYamlInString(data.prompt);
}
if (data.prompt.indexOf('{{xbgetvar_yaml_idx::') !== -1) {
data.prompt = replaceXbGetVarYamlIdxInString(data.prompt);
}
}
} catch {}
});

View File

@@ -28,7 +28,7 @@ import {
applyXbGetVarForMessage,
parseValueForSet,
} from "./var-commands.js";
import { applyStateForMessage, clearStateAppliedFrom } from "./state2/index.js";
import { applyStateForMessage } from "./state2/index.js";
import {
preprocessBumpAliases,
executeQueuedVareventJsAfterTurn,
@@ -1624,16 +1624,10 @@ 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;
// ???? 1.0 ???????
const snap = getSnapshot(prevId);
if (snap) {
const normalized = normalizeSnapshotRecord(snap);
@@ -1647,12 +1641,52 @@ function rollbackToPreviousOf(messageId) {
}
}
function rebuildVariablesFromScratch() {
async function rollbackToPreviousOfAsync(messageId) {
const id = Number(messageId);
if (Number.isNaN(id)) return;
// ???????? floor>=id ? L0
if (typeof globalThis.LWB_StateRollbackHook === 'function') {
try {
await globalThis.LWB_StateRollbackHook(id);
} catch (e) {
console.error('[variablesCore] LWB_StateRollbackHook failed:', e);
}
}
const prevId = id - 1;
const mode = getVariablesMode();
if (mode === '2.0') {
try {
const mod = await import('./state2/index.js');
await mod.restoreStateV2ToFloor(prevId); // prevId<0 ???
} catch (e) {
console.error('[variablesCore][2.0] restoreStateV2ToFloor failed:', e);
}
return;
}
// mode === '1.0'
rollbackToPreviousOf(id);
}
async function rebuildVariablesFromScratch() {
try {
const mode = getVariablesMode();
if (mode === '2.0') {
const mod = await import('./state2/index.js');
const chat = getContext()?.chat || [];
const lastId = chat.length ? chat.length - 1 : -1;
await mod.restoreStateV2ToFloor(lastId);
return;
}
// 1.0 旧逻辑
setVarDict({});
const chat = getContext()?.chat || [];
for (let i = 0; i < chat.length; i++) {
applyVariablesForMessage(i);
await applyVariablesForMessage(i);
}
} catch {}
}
@@ -1842,7 +1876,7 @@ async function applyVariablesForMessage(messageId) {
} catch (e) {
parseErrors++;
if (debugOn) {
try { xbLog.error(MODULE_ID, `plot-log è§£æž<EFBFBD>失败:楼å±?${messageId} å<EFBFBD>?${idx + 1} 预览=${preview(b)}`, e); } catch {}
try { xbLog.error(MODULE_ID, `plot-log 解析失败<EFBFBD>?${messageId} <20>?${idx + 1} 预览=${preview(b)}`, e); } catch {}
}
return;
}
@@ -1873,7 +1907,7 @@ async function applyVariablesForMessage(messageId) {
try {
xbLog.warn(
MODULE_ID,
`plot-log 未产生å<EFBFBD>¯æ‰§è¡ŒæŒ‡ä»¤ï¼šæ¥¼å±?${messageId} å<EFBFBD>—æ•°=${blocks.length} è§£æž<EFBFBD>æ<EFBFBD>¡ç®=${parsedPartsTotal} è§£æž<EFBFBD>失败=${parseErrors} 预览=${preview(blocks[0])}`
`plot-log 未产生可执行指令<EFBFBD>?${messageId} 块数=${blocks.length} 解析条目=${parsedPartsTotal} 解析失败=${parseErrors} 预览=${preview(blocks[0])}`
);
} catch {}
}
@@ -2149,7 +2183,7 @@ async function applyVariablesForMessage(messageId) {
const denied = guardDenied ? `,被规则拦截=${guardDenied}` : '';
xbLog.warn(
MODULE_ID,
`plot-log 指令执行å<EFBFBD>Žæ— å<EFBFBD>˜åŒï¼šæ¥¼å±?${messageId} 指令æ•?${ops.length}${denied} 示例=${preview(JSON.stringify(guardDeniedSamples))}`
`plot-log 指令执行后无变化<EFBFBD>?${messageId} 指令<EFBFBD>?${ops.length}${denied} 示例=${preview(JSON.stringify(guardDeniedSamples))}`
);
} catch {}
}
@@ -2218,7 +2252,7 @@ function bindEvents() {
events?.on(event_types.MESSAGE_SENT, async () => {
try {
snapshotCurrentLastFloor();
if (getVariablesMode() !== '2.0') snapshotCurrentLastFloor();
const chat = getContext()?.chat || [];
const id = chat.length ? chat.length - 1 : undefined;
if (typeof id === 'number') {
@@ -2247,7 +2281,7 @@ function bindEvents() {
if (typeof id === 'number') {
await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
snapshotForMessageId(id);
if (getVariablesMode() !== '2.0') snapshotForMessageId(id);
}
} catch {}
});
@@ -2259,7 +2293,7 @@ function bindEvents() {
if (typeof id === 'number') {
await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
snapshotForMessageId(id);
if (getVariablesMode() !== '2.0') snapshotForMessageId(id);
}
} catch {}
});
@@ -2283,33 +2317,35 @@ function bindEvents() {
events?.on(event_types.MESSAGE_EDITED, async (data) => {
try {
const id = getMsgIdLoose(data);
if (typeof id === 'number') {
clearAppliedFor(id);
rollbackToPreviousOf(id);
if (typeof id !== 'number') return;
setTimeout(async () => {
await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
if (getVariablesMode() !== '2.0') clearAppliedFor(id);
try {
const ctx = getContext();
const msg = ctx?.chat?.[id];
if (msg) updateMessageBlock(id, msg, { rerenderMessage: true });
} catch {}
// ? ?? await????? apply ????????????
await rollbackToPreviousOfAsync(id);
try {
const ctx = getContext();
const es = ctx?.eventSource;
const et = ctx?.event_types;
if (es?.emit && et?.MESSAGE_UPDATED) {
suppressUpdatedOnce.add(id);
await es.emit(et.MESSAGE_UPDATED, id);
}
} catch {}
setTimeout(async () => {
await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
await executeQueuedVareventJsAfterTurn();
}, 10);
}
try {
const ctx = getContext();
const msg = ctx?.chat?.[id];
if (msg) updateMessageBlock(id, msg, { rerenderMessage: true });
} catch {}
try {
const ctx = getContext();
const es = ctx?.eventSource;
const et = ctx?.event_types;
if (es?.emit && et?.MESSAGE_UPDATED) {
suppressUpdatedOnce.add(id);
await es.emit(et.MESSAGE_UPDATED, id);
}
} catch {}
await executeQueuedVareventJsAfterTurn();
}, 10);
} catch {}
});
@@ -2317,28 +2353,44 @@ function bindEvents() {
events?.on(event_types.MESSAGE_SWIPED, async (data) => {
try {
const id = getMsgIdLoose(data);
if (typeof id === 'number') {
lastSwipedId = id;
clearAppliedFor(id);
rollbackToPreviousOf(id);
if (typeof id !== 'number') return;
const tId = setTimeout(async () => {
pendingSwipeApply.delete(id);
await applyVarsForMessage(id);
await executeQueuedVareventJsAfterTurn();
}, 10);
lastSwipedId = id;
if (getVariablesMode() !== '2.0') clearAppliedFor(id);
pendingSwipeApply.set(id, tId);
}
// ? ?? await???????????????
await rollbackToPreviousOfAsync(id);
const tId = setTimeout(async () => {
pendingSwipeApply.delete(id);
await applyVarsForMessage(id);
await executeQueuedVareventJsAfterTurn();
}, 10);
pendingSwipeApply.set(id, tId);
} catch {}
});
// message deleted
events?.on(event_types.MESSAGE_DELETED, (data) => {
events?.on(event_types.MESSAGE_DELETED, async (data) => {
try {
const id = getMsgIdStrict(data);
if (typeof id === 'number') {
rollbackToPreviousOf(id);
if (typeof id !== 'number') return;
// ? ????????await ???????
await rollbackToPreviousOfAsync(id);
// ✅ 2.0:物理删除消息 => 同步清理 WAL/ckpt避免膨胀
if (getVariablesMode() === '2.0') {
try {
const mod = await import('./state2/index.js');
await mod.trimStateV2FromFloor(id);
} catch (e) {
console.error('[variablesCore][2.0] trimStateV2FromFloor failed:', e);
}
}
if (getVariablesMode() !== '2.0') {
clearSnapshotsFrom(id);
clearAppliedFrom(id);
}
@@ -2349,7 +2401,7 @@ function bindEvents() {
events?.on(event_types.GENERATION_STARTED, (data) => {
try {
snapshotPreviousFloor();
if (getVariablesMode() !== '2.0') snapshotPreviousFloor();
// cancel swipe delay
const t = (typeof data === 'string' ? data : (data?.type || '')).toLowerCase();
@@ -2364,7 +2416,7 @@ function bindEvents() {
});
// chat changed
events?.on(event_types.CHAT_CHANGED, () => {
events?.on(event_types.CHAT_CHANGED, async () => {
try {
rulesClearCache();
rulesLoadFromMeta();
@@ -2372,6 +2424,13 @@ function bindEvents() {
const meta = getContext()?.chatMetadata || {};
meta[LWB_PLOT_APPLIED_KEY] = {};
getContext()?.saveMetadataDebounced?.();
if (getVariablesMode() === '2.0') {
try {
const mod = await import('./state2/index.js');
mod.clearStateAppliedFrom(0);
} catch {}
}
} catch {}
});
}
@@ -2408,6 +2467,33 @@ export function initVariablesCore() {
applyDeltaTable: applyRulesDeltaToTable,
save: rulesSaveToMeta,
};
globalThis.LWB_StateV2 = {
/**
* @param {string} text - 包含 <state>...</state> 的文本
* @param {{ floor?: number, silent?: boolean }} [options]
* - floor: 指定写入/记录用楼层(默认:最后一楼)
* - silent: true 时不触发 stateAtomsGenerated初始化用
*/
applyText: async (text, options = {}) => {
const { applyStateForMessage } = await import('./state2/index.js');
const ctx = getContext();
const floor =
Number.isFinite(options.floor)
? Number(options.floor)
: Math.max(0, (ctx?.chat?.length || 1) - 1);
const result = applyStateForMessage(floor, String(text || ''));
// ✅ 默认会触发(当作事件)
// ✅ 初始化时 silent=true不触发当作基线写入
if (!options.silent && result?.atoms?.length) {
$(document).trigger('xiaobaix:variables:stateAtomsGenerated', {
messageId: floor,
atoms: result.atoms,
});
}
return result;
},
};
}
/**
@@ -2429,6 +2515,7 @@ export function cleanupVariablesCore() {
// clear global hooks
delete globalThis.LWB_Guard;
delete globalThis.LWB_StateV2;
// clear guard state
guardBypass(false);
@@ -2454,4 +2541,4 @@ export {
rulesSetTable,
rulesLoadFromMeta,
rulesSaveToMeta,
};
};