From bcf664e9a0cc9040e8c668fe40d4855ff4aba0cb Mon Sep 17 00:00:00 2001 From: bielie Date: Sun, 1 Feb 2026 02:49:35 +0800 Subject: [PATCH] Update variables v2 state handling --- modules/variables/state2/executor.js | 652 ++++++++++++++++++++++++--- modules/variables/state2/guard.js | 123 +++++ modules/variables/state2/index.js | 20 +- modules/variables/var-commands.js | 128 +++++- modules/variables/varevent-editor.js | 14 +- modules/variables/variables-core.js | 199 +++++--- 6 files changed, 1008 insertions(+), 128 deletions(-) create mode 100644 modules/variables/state2/guard.js diff --git a/modules/variables/state2/executor.js b/modules/variables/state2/executor.js index b33c009..01278dd 100644 --- a/modules/variables/state2/executor.js +++ b/modules/variables/state2/executor.js @@ -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:执行单条消息里的 ,返回 atoms + * ========================= + * Core: apply one message text (...) => 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 + * - 不依赖消息文本 (避免被正则清掉) + */ +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 signature:floor 之后都要重新计算 + 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 signatures(floor>=start 都要重新算) + try { + clearStateAppliedFrom(start); + } catch {} + + ctx?.saveMetadataDebounced?.(); + return { ok: true }; +} diff --git a/modules/variables/state2/guard.js b/modules/variables/state2/guard.js new file mode 100644 index 0000000..0b874c6 --- /dev/null +++ b/modules/variables/state2/guard.js @@ -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 }; +} diff --git a/modules/variables/state2/index.js b/modules/variables/state2/index.js index 685e7d0..f1ac846 100644 --- a/modules/variables/state2/index.js +++ b/modules/variables/state2/index.js @@ -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'; diff --git a/modules/variables/var-commands.js b/modules/variables/var-commands.js index aa91268..6307674 100644 --- a/modules/variables/var-commands.js +++ b/modules/variables/var-commands.js @@ -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, }; diff --git a/modules/variables/varevent-editor.js b/modules/variables/varevent-editor.js index 3e03c13..d063b77 100644 --- a/modules/variables/varevent-editor.js +++ b/modules/variables/varevent-editor.js @@ -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 {} }); diff --git a/modules/variables/variables-core.js b/modules/variables/variables-core.js index 66a6d9a..36bc71f 100644 --- a/modules/variables/variables-core.js +++ b/modules/variables/variables-core.js @@ -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 解析失败:楼?${messageId} ?${idx + 1} 预览=${preview(b)}`, e); } catch {} + try { xbLog.error(MODULE_ID, `plot-log 解析失败:楼�?${messageId} �?${idx + 1} 预览=${preview(b)}`, e); } catch {} } return; } @@ -1873,7 +1907,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 {} } @@ -2149,7 +2183,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 {} } @@ -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 - 包含 ... 的文本 + * @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, -}; \ No newline at end of file +};