2.0变量 , 向量总结正式推送
This commit is contained in:
746
modules/variables/state2/executor.js
Normal file
746
modules/variables/state2/executor.js
Normal file
@@ -0,0 +1,746 @@
|
||||
import { getContext } from '../../../../../../extensions.js';
|
||||
import { getLocalVariable, setLocalVariable } from '../../../../../../variables.js';
|
||||
import { extractStateBlocks, computeStateSignature, parseStateBlock } from './parser.js';
|
||||
import { generateSemantic } from './semantic.js';
|
||||
import { validate, setRule, loadRulesFromMeta, saveRulesToMeta } from './guard.js';
|
||||
|
||||
/**
|
||||
* =========================
|
||||
* 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 ERR_VAR_NAME = 'LWB_STATE_ERRORS';
|
||||
const LOG_KEY = 'stateLogV2';
|
||||
const CKPT_KEY = 'stateCkptV2';
|
||||
|
||||
|
||||
/**
|
||||
* 写入状态错误到本地变量(覆盖写入)
|
||||
*/
|
||||
function writeStateErrorsToLocalVar(lines) {
|
||||
try {
|
||||
const text = Array.isArray(lines) && lines.length
|
||||
? lines.map(s => `- ${String(s)}`).join('\n')
|
||||
: '';
|
||||
setLocalVariable(ERR_VAR_NAME, text);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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] ||= {};
|
||||
return meta[LWB_STATE_APPLIED_KEY];
|
||||
}
|
||||
|
||||
export function clearStateAppliedFor(floor) {
|
||||
try {
|
||||
delete getAppliedMap()[floor];
|
||||
getContext()?.saveMetadataDebounced?.();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function clearStateAppliedFrom(floorInclusive) {
|
||||
try {
|
||||
const map = getAppliedMap();
|
||||
for (const k of Object.keys(map)) {
|
||||
if (Number(k) >= floorInclusive) delete map[k];
|
||||
}
|
||||
getContext()?.saveMetadataDebounced?.();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isIndexDeleteOp(opItem) {
|
||||
if (!opItem || opItem.op !== 'del') return false;
|
||||
const segs = splitPath(opItem.path);
|
||||
if (!segs.length) return false;
|
||||
const last = segs[segs.length - 1];
|
||||
return typeof last === 'number' && Number.isFinite(last);
|
||||
}
|
||||
|
||||
function buildExecOpsWithIndexDeleteReorder(ops) {
|
||||
// 同一个数组的 index-del:按 parentPath 分组,组内 index 倒序
|
||||
// 其它操作:保持原顺序
|
||||
const groups = new Map(); // parentPath -> { order, items: [{...opItem, index}] }
|
||||
const groupOrder = new Map();
|
||||
let orderCounter = 0;
|
||||
|
||||
const normalOps = [];
|
||||
|
||||
for (const op of ops) {
|
||||
if (isIndexDeleteOp(op)) {
|
||||
const segs = splitPath(op.path);
|
||||
const idx = segs[segs.length - 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, []);
|
||||
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);
|
||||
}
|
||||
|
||||
// ✅ 我们把“索引删除”放在最前面执行:这样它们永远按“原索引”删
|
||||
// (避免在同一轮里先删后 push 导致索引变化)
|
||||
return [...reorderedIndexDeletes, ...normalOps];
|
||||
}
|
||||
|
||||
/**
|
||||
* =========================
|
||||
* 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);
|
||||
const blocks = extractStateBlocks(text);
|
||||
// ✅ 统一:只要没有可执行 blocks,就视为本层 state 被移除
|
||||
if (!signature || blocks.length === 0) {
|
||||
clearStateAppliedFor(messageId);
|
||||
writeStateErrorsToLocalVar([]);
|
||||
// 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 };
|
||||
}
|
||||
|
||||
const appliedMap = getAppliedMap();
|
||||
if (appliedMap[messageId] === signature) {
|
||||
return { atoms: [], errors: [], skipped: true };
|
||||
}
|
||||
const atoms = [];
|
||||
const errors = [];
|
||||
let idx = 0;
|
||||
|
||||
const mergedRules = [];
|
||||
const mergedOps = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
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 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 || '\u88ab\u89c4\u5219\u62d2\u7edd'}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 记录修正信息
|
||||
if (guard.note) {
|
||||
if (op === 'inc') {
|
||||
const raw = Number(delta);
|
||||
const rawTxt = Number.isFinite(raw) ? `${raw >= 0 ? '+' : ''}${raw}` : String(delta ?? '');
|
||||
errors.push(`${path}: ${rawTxt} ${guard.note}`);
|
||||
} else {
|
||||
errors.push(`${path}: ${guard.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
let execOk = true;
|
||||
let execReason = '';
|
||||
|
||||
try {
|
||||
switch (op) {
|
||||
case 'set':
|
||||
setVar(path, guard.value);
|
||||
break;
|
||||
case 'inc':
|
||||
// guard.value 对 inc 是最终 nextValue
|
||||
setVar(path, guard.value);
|
||||
break;
|
||||
case 'push': {
|
||||
const result = pushVar(path, guard.value);
|
||||
if (!result.ok) { execOk = false; execReason = result.reason; }
|
||||
break;
|
||||
}
|
||||
case 'pop': {
|
||||
const result = popVar(path, guard.value);
|
||||
if (!result.ok) { execOk = false; execReason = result.reason; }
|
||||
break;
|
||||
}
|
||||
case 'del':
|
||||
delVar(path);
|
||||
break;
|
||||
default:
|
||||
execOk = false;
|
||||
execReason = `未知 op=${op}`;
|
||||
}
|
||||
} catch (e) {
|
||||
execOk = false;
|
||||
execReason = e?.message || String(e);
|
||||
}
|
||||
|
||||
if (!execOk) {
|
||||
errors.push(`[${path}] 失败: ${execReason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newValue = getVar(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?.();
|
||||
|
||||
// ✅ checkpoint:执行完该楼后,可选存一次全量
|
||||
saveCheckpointIfNeeded(messageId);
|
||||
|
||||
// Write error list to local variable
|
||||
writeStateErrorsToLocalVar(errors);
|
||||
|
||||
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 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 };
|
||||
}
|
||||
249
modules/variables/state2/guard.js
Normal file
249
modules/variables/state2/guard.js
Normal file
@@ -0,0 +1,249 @@
|
||||
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(absPath) {
|
||||
return matchRuleWithWildcard(absPath);
|
||||
}
|
||||
|
||||
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('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 通配符路径匹配
|
||||
* 例如:data.同行者.张三.HP 可以匹配 data.同行者.*.HP
|
||||
*/
|
||||
function matchRuleWithWildcard(absPath) {
|
||||
// 1. 精确匹配
|
||||
if (rulesTable[absPath]) return rulesTable[absPath];
|
||||
|
||||
const segs = String(absPath).split('.').filter(Boolean);
|
||||
const n = segs.length;
|
||||
|
||||
// 2. 尝试各种 * 替换组合(从少到多)
|
||||
for (let starCount = 1; starCount <= n; starCount++) {
|
||||
const patterns = generateStarPatterns(segs, starCount);
|
||||
for (const pattern of patterns) {
|
||||
if (rulesTable[pattern]) return rulesTable[pattern];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 尝试 [*] 匹配(数组元素模板)
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (/^\d+$/.test(segs[i])) {
|
||||
const trySegs = [...segs];
|
||||
trySegs[i] = '[*]';
|
||||
const tryPath = trySegs.join('.');
|
||||
if (rulesTable[tryPath]) return rulesTable[tryPath];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成恰好有 starCount 个 * 的所有模式
|
||||
*/
|
||||
function generateStarPatterns(segs, starCount) {
|
||||
const n = segs.length;
|
||||
const results = [];
|
||||
|
||||
function backtrack(idx, stars, path) {
|
||||
if (idx === n) {
|
||||
if (stars === starCount) results.push(path.join('.'));
|
||||
return;
|
||||
}
|
||||
// 用原值
|
||||
if (n - idx > starCount - stars) {
|
||||
backtrack(idx + 1, stars, [...path, segs[idx]]);
|
||||
}
|
||||
// 用 *
|
||||
if (stars < starCount) {
|
||||
backtrack(idx + 1, stars + 1, [...path, '*']);
|
||||
}
|
||||
}
|
||||
|
||||
backtrack(0, 0, []);
|
||||
return results;
|
||||
}
|
||||
|
||||
function getValueType(v) {
|
||||
if (Array.isArray(v)) return 'array';
|
||||
if (v === null) return 'null';
|
||||
return typeof v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证操作
|
||||
* @returns {{ allow: boolean, value?: any, reason?: string, note?: string }}
|
||||
*/
|
||||
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;
|
||||
|
||||
const lastSeg = String(absPath).split('.').pop() || '';
|
||||
|
||||
// ===== 1. $schema 白名单检查 =====
|
||||
if (parentNode?.allowedKeys && Array.isArray(parentNode.allowedKeys)) {
|
||||
if (isNewKey && (op === 'set' || op === 'push')) {
|
||||
if (!parentNode.allowedKeys.includes(lastSeg)) {
|
||||
return { allow: false, reason: `字段不在结构模板中` };
|
||||
}
|
||||
}
|
||||
if (op === 'del') {
|
||||
if (parentNode.allowedKeys.includes(lastSeg)) {
|
||||
return { allow: false, reason: `模板定义的字段不能删除` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 2. 父层结构锁定(无 objectExt / 无 allowedKeys / 无 hasWildcard) =====
|
||||
if (parentNode && parentNode.typeLock === 'object') {
|
||||
if (!parentNode.objectExt && !parentNode.allowedKeys && !parentNode.hasWildcard) {
|
||||
if (isNewKey && (op === 'set' || op === 'push')) {
|
||||
return { allow: false, reason: '父层结构已锁定,不允许新增字段' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 3. 类型锁定 =====
|
||||
if (node?.typeLock && op === 'set') {
|
||||
let finalPayload = payload;
|
||||
|
||||
// 宽松:数字字符串 => 数字
|
||||
if (node.typeLock === 'number' && typeof payload === 'string') {
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(payload.trim())) {
|
||||
finalPayload = Number(payload);
|
||||
}
|
||||
}
|
||||
|
||||
const finalType = getValueType(finalPayload);
|
||||
if (node.typeLock !== finalType) {
|
||||
return { allow: false, reason: `类型不匹配,期望 ${node.typeLock},实际 ${finalType}` };
|
||||
}
|
||||
|
||||
payload = finalPayload;
|
||||
}
|
||||
|
||||
// ===== 4. 数组扩展检查 =====
|
||||
if (op === 'push') {
|
||||
if (node && node.typeLock === 'array' && !node.arrayGrow) {
|
||||
return { allow: false, reason: '数组不允许扩展' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 5. $ro 只读 =====
|
||||
if (node?.ro && (op === 'set' || op === 'inc')) {
|
||||
return { allow: false, reason: '只读字段' };
|
||||
}
|
||||
|
||||
// ===== 6. set 操作:数值约束 =====
|
||||
if (op === 'set') {
|
||||
const num = Number(payload);
|
||||
|
||||
// range 限制
|
||||
if (Number.isFinite(num) && (node?.min !== undefined || node?.max !== undefined)) {
|
||||
let v = num;
|
||||
const min = node?.min;
|
||||
const max = node?.max;
|
||||
|
||||
if (min !== undefined) v = Math.max(v, min);
|
||||
if (max !== undefined) v = Math.min(v, max);
|
||||
|
||||
const clamped = v !== num;
|
||||
return {
|
||||
allow: true,
|
||||
value: v,
|
||||
note: clamped ? `超出范围,已限制到 ${v}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// enum 枚举(不自动修正,直接拒绝)
|
||||
if (node?.enum?.length) {
|
||||
const s = String(payload ?? '');
|
||||
if (!node.enum.includes(s)) {
|
||||
return { allow: false, reason: `枚举不匹配,允许:${node.enum.join(' / ')}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { allow: true, value: payload };
|
||||
}
|
||||
|
||||
// ===== 7. inc 操作:step / range 限制 =====
|
||||
if (op === 'inc') {
|
||||
const delta = Number(payload);
|
||||
if (!Number.isFinite(delta)) return { allow: false, reason: 'delta 不是数字' };
|
||||
|
||||
const cur = Number(currentValue) || 0;
|
||||
let d = delta;
|
||||
const noteParts = [];
|
||||
|
||||
// step 限制
|
||||
if (node?.step !== undefined && node.step >= 0) {
|
||||
const before = d;
|
||||
if (d > node.step) d = node.step;
|
||||
if (d < -node.step) d = -node.step;
|
||||
if (d !== before) {
|
||||
noteParts.push(`超出步长限制,已限制到 ${d >= 0 ? '+' : ''}${d}`);
|
||||
}
|
||||
}
|
||||
|
||||
let next = cur + d;
|
||||
|
||||
// range 限制
|
||||
const beforeClamp = next;
|
||||
if (node?.min !== undefined) next = Math.max(next, node.min);
|
||||
if (node?.max !== undefined) next = Math.min(next, node.max);
|
||||
if (next !== beforeClamp) {
|
||||
noteParts.push(`超出范围,已限制到 ${next}`);
|
||||
}
|
||||
|
||||
return {
|
||||
allow: true,
|
||||
value: next,
|
||||
note: noteParts.length ? noteParts.join(',') : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { allow: true, value: payload };
|
||||
}
|
||||
|
||||
|
||||
21
modules/variables/state2/index.js
Normal file
21
modules/variables/state2/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
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';
|
||||
514
modules/variables/state2/parser.js
Normal file
514
modules/variables/state2/parser.js
Normal file
@@ -0,0 +1,514 @@
|
||||
import jsyaml from '../../../libs/js-yaml.mjs';
|
||||
|
||||
/**
|
||||
* Robust <state> block matcher (no regex)
|
||||
* - Pairs each </state> with the nearest preceding <state ...>
|
||||
* - Ignores unclosed <state>
|
||||
*/
|
||||
|
||||
function isValidOpenTagAt(s, i) {
|
||||
if (s[i] !== '<') return false;
|
||||
|
||||
const head = s.slice(i, i + 6).toLowerCase();
|
||||
if (head !== '<state') return false;
|
||||
|
||||
const next = s[i + 6] ?? '';
|
||||
if (next && !(next === '>' || next === '/' || /\s/.test(next))) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidCloseTagAt(s, i) {
|
||||
if (s[i] !== '<') return false;
|
||||
if (s[i + 1] !== '/') return false;
|
||||
|
||||
const head = s.slice(i, i + 7).toLowerCase();
|
||||
if (head !== '</state') return false;
|
||||
|
||||
let j = i + 7;
|
||||
while (j < s.length && /\s/.test(s[j])) j++;
|
||||
return s[j] === '>';
|
||||
}
|
||||
|
||||
function findTagEnd(s, openIndex) {
|
||||
const end = s.indexOf('>', openIndex);
|
||||
return end === -1 ? -1 : end;
|
||||
}
|
||||
|
||||
function findStateBlockSpans(text) {
|
||||
const s = String(text ?? '');
|
||||
const closes = [];
|
||||
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] !== '<') continue;
|
||||
if (isValidCloseTagAt(s, i)) closes.push(i);
|
||||
}
|
||||
if (!closes.length) return [];
|
||||
|
||||
const spans = [];
|
||||
let searchEnd = s.length;
|
||||
|
||||
for (let cIdx = closes.length - 1; cIdx >= 0; cIdx--) {
|
||||
const closeStart = closes[cIdx];
|
||||
if (closeStart >= searchEnd) continue;
|
||||
|
||||
let closeEnd = closeStart + 7;
|
||||
while (closeEnd < s.length && s[closeEnd] !== '>') closeEnd++;
|
||||
if (s[closeEnd] !== '>') continue;
|
||||
closeEnd += 1;
|
||||
|
||||
let openStart = -1;
|
||||
for (let i = closeStart - 1; i >= 0; i--) {
|
||||
if (s[i] !== '<') continue;
|
||||
if (!isValidOpenTagAt(s, i)) continue;
|
||||
|
||||
const tagEnd = findTagEnd(s, i);
|
||||
if (tagEnd === -1) continue;
|
||||
if (tagEnd >= closeStart) continue;
|
||||
|
||||
openStart = i;
|
||||
break;
|
||||
}
|
||||
|
||||
if (openStart === -1) continue;
|
||||
|
||||
const openTagEnd = findTagEnd(s, openStart);
|
||||
if (openTagEnd === -1) continue;
|
||||
|
||||
spans.push({
|
||||
openStart,
|
||||
openTagEnd: openTagEnd + 1,
|
||||
closeStart,
|
||||
closeEnd,
|
||||
});
|
||||
|
||||
searchEnd = openStart;
|
||||
}
|
||||
|
||||
spans.reverse();
|
||||
return spans;
|
||||
}
|
||||
|
||||
export function extractStateBlocks(text) {
|
||||
const s = String(text ?? '');
|
||||
const spans = findStateBlockSpans(s);
|
||||
const out = [];
|
||||
for (const sp of spans) {
|
||||
const inner = s.slice(sp.openTagEnd, sp.closeStart);
|
||||
if (inner.trim()) out.push(inner);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeStateSignature(text) {
|
||||
const s = String(text ?? '');
|
||||
const spans = findStateBlockSpans(s);
|
||||
if (!spans.length) return '';
|
||||
const chunks = spans.map(sp => s.slice(sp.openStart, sp.closeEnd).trim());
|
||||
return chunks.join('\n---\n');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse $schema block
|
||||
*/
|
||||
function parseSchemaBlock(basePath, schemaLines) {
|
||||
const rules = [];
|
||||
|
||||
const nonEmpty = schemaLines.filter(l => l.trim());
|
||||
if (!nonEmpty.length) return rules;
|
||||
|
||||
const minIndent = Math.min(...nonEmpty.map(l => l.search(/\S/)));
|
||||
const yamlText = schemaLines
|
||||
.map(l => (l.trim() ? l.slice(minIndent) : ''))
|
||||
.join('\n');
|
||||
|
||||
let schemaObj;
|
||||
try {
|
||||
schemaObj = jsyaml.load(yamlText);
|
||||
} catch (e) {
|
||||
console.warn('[parser] $schema YAML parse failed:', e.message);
|
||||
return rules;
|
||||
}
|
||||
|
||||
if (!schemaObj || typeof schemaObj !== 'object') return rules;
|
||||
|
||||
function walk(obj, curPath) {
|
||||
if (obj === null || obj === undefined) return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
if (obj.length === 0) {
|
||||
rules.push({
|
||||
path: curPath,
|
||||
rule: { typeLock: 'array', arrayGrow: true },
|
||||
});
|
||||
} else {
|
||||
rules.push({
|
||||
path: curPath,
|
||||
rule: { typeLock: 'array', arrayGrow: true },
|
||||
});
|
||||
walk(obj[0], curPath ? `${curPath}.[*]` : '[*]');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof obj !== 'object') {
|
||||
const t = typeof obj;
|
||||
if (t === 'string' || t === 'number' || t === 'boolean') {
|
||||
rules.push({
|
||||
path: curPath,
|
||||
rule: { typeLock: t },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = Object.keys(obj);
|
||||
|
||||
if (keys.length === 0) {
|
||||
rules.push({
|
||||
path: curPath,
|
||||
rule: { typeLock: 'object', objectExt: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const hasWildcard = keys.includes('*');
|
||||
|
||||
if (hasWildcard) {
|
||||
rules.push({
|
||||
path: curPath,
|
||||
rule: { typeLock: 'object', objectExt: true, hasWildcard: true },
|
||||
});
|
||||
|
||||
const wildcardTemplate = obj['*'];
|
||||
if (wildcardTemplate !== undefined) {
|
||||
walk(wildcardTemplate, curPath ? `${curPath}.*` : '*');
|
||||
}
|
||||
|
||||
for (const k of keys) {
|
||||
if (k === '*') continue;
|
||||
const childPath = curPath ? `${curPath}.${k}` : k;
|
||||
walk(obj[k], childPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
rules.push({
|
||||
path: curPath,
|
||||
rule: { typeLock: 'object', allowedKeys: keys },
|
||||
});
|
||||
|
||||
for (const k of keys) {
|
||||
const childPath = curPath ? `${curPath}.${k}` : k;
|
||||
walk(obj[k], childPath);
|
||||
}
|
||||
}
|
||||
|
||||
walk(schemaObj, basePath);
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse rule line ($ro, $range, $step, $enum)
|
||||
*/
|
||||
function parseRuleLine(line) {
|
||||
const tokens = line.trim().split(/\s+/);
|
||||
const directives = [];
|
||||
let pathStart = 0;
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (tokens[i].startsWith('$')) {
|
||||
directives.push(tokens[i]);
|
||||
pathStart = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const path = tokens.slice(pathStart).join(' ').trim();
|
||||
if (!path || !directives.length) return null;
|
||||
|
||||
const rule = {};
|
||||
|
||||
for (const tok of directives) {
|
||||
if (tok === '$ro') { rule.ro = true; continue; }
|
||||
|
||||
const rangeMatch = tok.match(/^\$range=\[\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\]$/);
|
||||
if (rangeMatch) {
|
||||
rule.min = Math.min(Number(rangeMatch[1]), Number(rangeMatch[2]));
|
||||
rule.max = Math.max(Number(rangeMatch[1]), Number(rangeMatch[2]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const stepMatch = tok.match(/^\$step=(\d+(?:\.\d+)?)$/);
|
||||
if (stepMatch) { rule.step = Math.abs(Number(stepMatch[1])); continue; }
|
||||
|
||||
const enumMatch = tok.match(/^\$enum=\{([^}]+)\}$/);
|
||||
if (enumMatch) {
|
||||
rule.enum = enumMatch[1].split(/[,、;]/).map(s => s.trim()).filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { path, rule };
|
||||
}
|
||||
|
||||
export function parseStateBlock(content) {
|
||||
const lines = String(content ?? '').split(/\r?\n/);
|
||||
|
||||
const rules = [];
|
||||
const dataLines = [];
|
||||
|
||||
let inSchema = false;
|
||||
let schemaPath = '';
|
||||
let schemaLines = [];
|
||||
let schemaBaseIndent = -1;
|
||||
|
||||
const flushSchema = () => {
|
||||
if (schemaLines.length) {
|
||||
const parsed = parseSchemaBlock(schemaPath, schemaLines);
|
||||
rules.push(...parsed);
|
||||
}
|
||||
inSchema = false;
|
||||
schemaPath = '';
|
||||
schemaLines = [];
|
||||
schemaBaseIndent = -1;
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i];
|
||||
const trimmed = raw.trim();
|
||||
const indent = raw.search(/\S/);
|
||||
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
if (inSchema && schemaBaseIndent >= 0) schemaLines.push(raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
// $schema 开始
|
||||
if (trimmed.startsWith('$schema')) {
|
||||
flushSchema();
|
||||
const rest = trimmed.slice(7).trim();
|
||||
schemaPath = rest || '';
|
||||
inSchema = true;
|
||||
schemaBaseIndent = -1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSchema) {
|
||||
if (schemaBaseIndent < 0) {
|
||||
schemaBaseIndent = indent;
|
||||
}
|
||||
|
||||
// 缩进回退 => schema 结束
|
||||
if (indent < schemaBaseIndent && indent >= 0 && trimmed) {
|
||||
flushSchema();
|
||||
i--;
|
||||
continue;
|
||||
}
|
||||
|
||||
schemaLines.push(raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 普通 $rule($ro, $range, $step, $enum)
|
||||
if (trimmed.startsWith('$')) {
|
||||
const parsed = parseRuleLine(trimmed);
|
||||
if (parsed) rules.push(parsed);
|
||||
continue;
|
||||
}
|
||||
|
||||
dataLines.push(raw);
|
||||
}
|
||||
|
||||
flushSchema();
|
||||
|
||||
const ops = parseDataLines(dataLines);
|
||||
return { rules, ops };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析数据行
|
||||
*/
|
||||
function stripYamlInlineComment(s) {
|
||||
const text = String(s ?? '');
|
||||
if (!text) return '';
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let escaped = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inSingle) {
|
||||
if (ch === "'") {
|
||||
if (text[i + 1] === "'") { i++; continue; }
|
||||
inSingle = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inDouble) {
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (ch === '\\') { escaped = true; continue; }
|
||||
if (ch === '"') inDouble = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") { inSingle = true; continue; }
|
||||
if (ch === '"') { inDouble = true; continue; }
|
||||
if (ch === '#') {
|
||||
const prev = i > 0 ? text[i - 1] : '';
|
||||
if (i === 0 || /\s/.test(prev)) {
|
||||
return text.slice(0, i).trimEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
return text.trimEnd();
|
||||
}
|
||||
|
||||
function parseDataLines(lines) {
|
||||
const results = [];
|
||||
|
||||
let pendingPath = null;
|
||||
let pendingLines = [];
|
||||
|
||||
const flushPending = () => {
|
||||
if (!pendingPath) return;
|
||||
|
||||
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();
|
||||
let rhs = trimmed.slice(colonIdx + 1).trim();
|
||||
rhs = stripYamlInlineComment(rhs);
|
||||
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' };
|
||||
|
||||
const parenNum = t.match(/^\((-?\d+(?:\.\d+)?)\)$/);
|
||||
if (parenNum) return { op: 'set', value: Number(parenNum[1]) };
|
||||
|
||||
if (/^\+\d/.test(t) || /^-\d/.test(t)) {
|
||||
const n = Number(t);
|
||||
if (Number.isFinite(n)) return { op: 'inc', delta: n };
|
||||
}
|
||||
|
||||
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 };
|
||||
} catch {}
|
||||
return { op: 'set', value: t, warning: '+[] 解析失败' };
|
||||
}
|
||||
|
||||
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 };
|
||||
} catch {}
|
||||
return { op: 'set', value: t, warning: '-[] 解析失败' };
|
||||
}
|
||||
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(t)) return { op: 'set', value: Number(t) };
|
||||
|
||||
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 };
|
||||
|
||||
if (t.startsWith('{') || t.startsWith('[')) {
|
||||
try { return { op: 'set', value: JSON.parse(t) }; }
|
||||
catch { return { op: 'set', value: t, warning: 'JSON 解析失败' }; }
|
||||
}
|
||||
|
||||
return { op: 'set', value: t };
|
||||
}
|
||||
41
modules/variables/state2/semantic.js
Normal file
41
modules/variables/state2/semantic.js
Normal file
@@ -0,0 +1,41 @@
|
||||
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 {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import { getContext } from "../../../../../extensions.js";
|
||||
import { getLocalVariable, setLocalVariable } from "../../../../../variables.js";
|
||||
import { createModuleEvents, event_types } from "../../core/event-manager.js";
|
||||
import jsYaml from "../../libs/js-yaml.mjs";
|
||||
import {
|
||||
lwbSplitPathWithBrackets,
|
||||
lwbSplitPathAndValue,
|
||||
@@ -19,6 +20,8 @@ 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;
|
||||
@@ -94,12 +97,22 @@ function setDeepBySegments(target, segs, value) {
|
||||
cur[key] = value;
|
||||
} else {
|
||||
const nxt = cur[key];
|
||||
if (typeof nxt === 'object' && nxt && !Array.isArray(nxt)) {
|
||||
const nextSeg = segs[i + 1];
|
||||
const wantArray = (typeof nextSeg === 'number');
|
||||
|
||||
// 已存在且类型正确:继续深入
|
||||
if (wantArray && Array.isArray(nxt)) {
|
||||
cur = nxt;
|
||||
} else {
|
||||
cur[key] = {};
|
||||
cur = cur[key];
|
||||
continue;
|
||||
}
|
||||
if (!wantArray && (nxt && typeof nxt === 'object') && !Array.isArray(nxt)) {
|
||||
cur = nxt;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 不存在或类型不匹配:创建正确的容器
|
||||
cur[key] = wantArray ? [] : {};
|
||||
cur = cur[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,6 +152,153 @@ export function replaceXbGetVarInString(s) {
|
||||
return s.replace(TAG_RE_XBGETVAR, (_, p) => lwbResolveVarPath(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 {{xbgetvar_yaml::路径}} 替换为 YAML 格式的值
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
export function replaceXbGetVarYamlInString(s) {
|
||||
s = String(s ?? '');
|
||||
if (!s || s.indexOf('{{xbgetvar_yaml::') === -1) return s;
|
||||
|
||||
TAG_RE_XBGETVAR_YAML.lastIndex = 0;
|
||||
return s.replace(TAG_RE_XBGETVAR_YAML, (_, p) => {
|
||||
const value = lwbResolveVarPath(p);
|
||||
if (!value) return '';
|
||||
|
||||
// 尝试解析为对象/数组,然后转 YAML
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
return jsYaml.dump(parsed, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
quotingType: '"',
|
||||
}).trim();
|
||||
}
|
||||
return value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 {{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;
|
||||
|
||||
@@ -148,9 +308,15 @@ export function replaceXbGetVarInChat(chat) {
|
||||
if (!key) continue;
|
||||
|
||||
const old = String(msg[key] ?? '');
|
||||
if (old.indexOf('{{xbgetvar::') === -1) continue;
|
||||
const hasJson = old.indexOf('{{xbgetvar::') !== -1;
|
||||
const hasYaml = old.indexOf('{{xbgetvar_yaml::') !== -1;
|
||||
const hasYamlIdx = old.indexOf('{{xbgetvar_yaml_idx::') !== -1;
|
||||
if (!hasJson && !hasYaml && !hasYamlIdx) continue;
|
||||
|
||||
msg[key] = replaceXbGetVarInString(old);
|
||||
let result = hasJson ? replaceXbGetVarInString(old) : old;
|
||||
result = hasYaml ? replaceXbGetVarYamlInString(result) : result;
|
||||
result = hasYamlIdx ? replaceXbGetVarYamlIdxInString(result) : result;
|
||||
msg[key] = result;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -165,9 +331,14 @@ export function applyXbGetVarForMessage(messageId, writeback = true) {
|
||||
if (!key) return;
|
||||
|
||||
const old = String(msg[key] ?? '');
|
||||
if (old.indexOf('{{xbgetvar::') === -1) return;
|
||||
const hasJson = old.indexOf('{{xbgetvar::') !== -1;
|
||||
const hasYaml = old.indexOf('{{xbgetvar_yaml::') !== -1;
|
||||
const hasYamlIdx = old.indexOf('{{xbgetvar_yaml_idx::') !== -1;
|
||||
if (!hasJson && !hasYaml && !hasYamlIdx) return;
|
||||
|
||||
const out = replaceXbGetVarInString(old);
|
||||
let out = hasJson ? replaceXbGetVarInString(old) : old;
|
||||
out = hasYaml ? replaceXbGetVarYamlInString(out) : out;
|
||||
out = hasYamlIdx ? replaceXbGetVarYamlIdxInString(out) : out;
|
||||
if (writeback && out !== old) {
|
||||
msg[key] = out;
|
||||
}
|
||||
@@ -616,6 +787,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();
|
||||
@@ -1004,7 +1231,9 @@ export function cleanupVarCommands() {
|
||||
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按值从数组中删除元素(2.0 pop 操作)
|
||||
*/
|
||||
export {
|
||||
MODULE_ID,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { getContext } from "../../../../../extensions.js";
|
||||
import { getLocalVariable } from "../../../../../variables.js";
|
||||
import { createModuleEvents } from "../../core/event-manager.js";
|
||||
import { replaceXbGetVarInString } from "./var-commands.js";
|
||||
import { replaceXbGetVarInString, replaceXbGetVarYamlInString, replaceXbGetVarYamlIdxInString } from "./var-commands.js";
|
||||
|
||||
const MODULE_ID = 'vareventEditor';
|
||||
const LWB_EXT_ID = 'LittleWhiteBox';
|
||||
@@ -297,12 +297,18 @@ function installWIHiddenTagStripper() {
|
||||
}
|
||||
msg.content = await replaceVareventInString(msg.content, false, false);
|
||||
}
|
||||
if (msg.content.indexOf('{{xbgetvar::') !== -1) {
|
||||
msg.content = replaceXbGetVarInString(msg.content);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(msg?.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (msg.content.indexOf('{{xbgetvar::') !== -1) {
|
||||
msg.content = replaceXbGetVarInString(msg.content);
|
||||
}
|
||||
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) {
|
||||
if (part?.type === 'text' && typeof part.text === 'string') {
|
||||
if (part.text.includes('<varevent')) {
|
||||
TAG_RE_VAREVENT.lastIndex = 0;
|
||||
@@ -312,12 +318,18 @@ function installWIHiddenTagStripper() {
|
||||
}
|
||||
part.text = await replaceVareventInString(part.text, false, false);
|
||||
}
|
||||
if (part.text.indexOf('{{xbgetvar::') !== -1) {
|
||||
part.text = replaceXbGetVarInString(part.text);
|
||||
if (part.text.indexOf('{{xbgetvar::') !== -1) {
|
||||
part.text = replaceXbGetVarInString(part.text);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof msg?.mes === 'string') {
|
||||
if (msg.mes.includes('<varevent')) {
|
||||
TAG_RE_VAREVENT.lastIndex = 0;
|
||||
@@ -327,12 +339,18 @@ function installWIHiddenTagStripper() {
|
||||
}
|
||||
msg.mes = await replaceVareventInString(msg.mes, false, false);
|
||||
}
|
||||
if (msg.mes.indexOf('{{xbgetvar::') !== -1) {
|
||||
msg.mes = replaceXbGetVarInString(msg.mes);
|
||||
if (msg.mes.indexOf('{{xbgetvar::') !== -1) {
|
||||
msg.mes = replaceXbGetVarInString(msg.mes);
|
||||
}
|
||||
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 {}
|
||||
} catch {}
|
||||
};
|
||||
try {
|
||||
if (eventSource && typeof eventSource.makeLast === 'function') {
|
||||
@@ -361,6 +379,12 @@ function installWIHiddenTagStripper() {
|
||||
if (data.prompt.indexOf('{{xbgetvar::') !== -1) {
|
||||
data.prompt = replaceXbGetVarInString(data.prompt);
|
||||
}
|
||||
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 {}
|
||||
});
|
||||
|
||||
@@ -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 } 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') {
|
||||
@@ -1618,6 +1627,7 @@ function rollbackToPreviousOf(messageId) {
|
||||
const prevId = id - 1;
|
||||
if (prevId < 0) return;
|
||||
|
||||
// 1.0: restore from snapshot if available
|
||||
const snap = getSnapshot(prevId);
|
||||
if (snap) {
|
||||
const normalized = normalizeSnapshotRecord(snap);
|
||||
@@ -1631,20 +1641,60 @@ function rollbackToPreviousOf(messageId) {
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildVariablesFromScratch() {
|
||||
async function rollbackToPreviousOfAsync(messageId) {
|
||||
const id = Number(messageId);
|
||||
if (Number.isNaN(id)) return;
|
||||
|
||||
// Notify L0 rollback hook for floor >= id
|
||||
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 handled by implementation
|
||||
} 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 legacy logic
|
||||
setVarDict({});
|
||||
const chat = getContext()?.chat || [];
|
||||
for (let i = 0; i < chat.length; i++) {
|
||||
applyVariablesForMessage(i);
|
||||
await applyVariablesForMessage(i);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/* ============= 应用变量到消息 ============= */
|
||||
/* ============ Apply Variables To Message ============= */
|
||||
|
||||
/**
|
||||
* 将对象模式转换
|
||||
* switch to object mode
|
||||
*/
|
||||
function asObject(rec) {
|
||||
if (rec.mode !== 'object') {
|
||||
@@ -1658,7 +1708,7 @@ function asObject(rec) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量操作辅助
|
||||
* bump helper
|
||||
*/
|
||||
function bumpAtPath(rec, path, delta) {
|
||||
const numDelta = Number(delta);
|
||||
@@ -1715,7 +1765,7 @@ function bumpAtPath(rec, path, delta) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析标量数组
|
||||
* parse scalar array
|
||||
*/
|
||||
function parseScalarArrayMaybe(str) {
|
||||
try {
|
||||
@@ -1727,8 +1777,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 +1836,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 +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;
|
||||
}
|
||||
@@ -1810,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 {}
|
||||
}
|
||||
@@ -1818,7 +1915,7 @@ async function applyVariablesForMessage(messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建变量记录
|
||||
// build variable records
|
||||
const byName = new Map();
|
||||
|
||||
for (const { name } of ops) {
|
||||
@@ -1838,9 +1935,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 +1962,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 +2000,7 @@ async function applyVariablesForMessage(messageId) {
|
||||
}
|
||||
}
|
||||
|
||||
// DEL 操作
|
||||
// delete op
|
||||
else if (op.operation === 'del') {
|
||||
const obj = asObject(rec);
|
||||
const pending = [];
|
||||
@@ -1951,7 +2048,8 @@ async function applyVariablesForMessage(messageId) {
|
||||
});
|
||||
}
|
||||
|
||||
// 按索引分组(倒序删除)
|
||||
// note
|
||||
|
||||
const arrGroups = new Map();
|
||||
const objDeletes = [];
|
||||
|
||||
@@ -1977,7 +2075,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 +2131,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 +2175,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 +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 {}
|
||||
}
|
||||
@@ -2093,7 +2191,7 @@ async function applyVariablesForMessage(messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存变量
|
||||
// save variables
|
||||
for (const [name, rec] of byName.entries()) {
|
||||
if (!rec.changed) continue;
|
||||
try {
|
||||
@@ -2105,7 +2203,7 @@ async function applyVariablesForMessage(messageId) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 删除变量
|
||||
// delete variables
|
||||
if (delVarNames.size > 0) {
|
||||
try {
|
||||
for (const v of delVarNames) {
|
||||
@@ -2124,7 +2222,7 @@ async function applyVariablesForMessage(messageId) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/* ============= 事件处理 ============= */
|
||||
/* ============ Event Handling ============= */
|
||||
|
||||
function getMsgIdLoose(payload) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
@@ -2150,56 +2248,57 @@ function bindEvents() {
|
||||
let lastSwipedId;
|
||||
suppressUpdatedOnce = new Set();
|
||||
|
||||
// 消息发送
|
||||
// note
|
||||
|
||||
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') {
|
||||
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);
|
||||
if (getVariablesMode() !== '2.0') 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);
|
||||
if (getVariablesMode() !== '2.0') snapshotForMessageId(id);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// 消息更新
|
||||
// message updated
|
||||
events?.on(event_types.MESSAGE_UPDATED, async (data) => {
|
||||
try {
|
||||
const id = getMsgIdLoose(data);
|
||||
@@ -2208,84 +2307,103 @@ 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);
|
||||
if (typeof id === 'number') {
|
||||
clearAppliedFor(id);
|
||||
rollbackToPreviousOf(id);
|
||||
if (typeof id !== 'number') return;
|
||||
|
||||
setTimeout(async () => {
|
||||
await applyVariablesForMessage(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 {}
|
||||
// Roll back first so re-apply uses the edited message
|
||||
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 {}
|
||||
});
|
||||
|
||||
// 消息滑动
|
||||
// message swiped
|
||||
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 applyVariablesForMessage(id);
|
||||
await executeQueuedVareventJsAfterTurn();
|
||||
}, 10);
|
||||
lastSwipedId = id;
|
||||
if (getVariablesMode() !== '2.0') clearAppliedFor(id);
|
||||
|
||||
pendingSwipeApply.set(id, tId);
|
||||
}
|
||||
// Roll back first so swipe applies cleanly
|
||||
await rollbackToPreviousOfAsync(id);
|
||||
|
||||
const tId = setTimeout(async () => {
|
||||
pendingSwipeApply.delete(id);
|
||||
await applyVarsForMessage(id);
|
||||
await executeQueuedVareventJsAfterTurn();
|
||||
}, 10);
|
||||
|
||||
pendingSwipeApply.set(id, tId);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// 消息删除
|
||||
events?.on(event_types.MESSAGE_DELETED, (data) => {
|
||||
// message deleted
|
||||
events?.on(event_types.MESSAGE_DELETED, async (data) => {
|
||||
try {
|
||||
const id = getMsgIdStrict(data);
|
||||
if (typeof id === 'number') {
|
||||
rollbackToPreviousOf(id);
|
||||
if (typeof id !== 'number') return;
|
||||
|
||||
// Roll back first before delete handling
|
||||
await rollbackToPreviousOfAsync(id);
|
||||
|
||||
// 2.0: physical delete -> trim WAL/ckpt to avoid bloat
|
||||
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);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// 生成开始
|
||||
// note
|
||||
|
||||
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();
|
||||
if (t === 'swipe' && lastSwipedId != null) {
|
||||
const tId = pendingSwipeApply.get(lastSwipedId);
|
||||
@@ -2297,8 +2415,8 @@ function bindEvents() {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// 聊天切换
|
||||
events?.on(event_types.CHAT_CHANGED, () => {
|
||||
// chat changed
|
||||
events?.on(event_types.CHAT_CHANGED, async () => {
|
||||
try {
|
||||
rulesClearCache();
|
||||
rulesLoadFromMeta();
|
||||
@@ -2306,33 +2424,42 @@ 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 {}
|
||||
});
|
||||
}
|
||||
|
||||
/* ============= 初始化与清理 ============= */
|
||||
/* ============ 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,
|
||||
@@ -2340,48 +2467,76 @@ 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理模块
|
||||
* 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;
|
||||
delete globalThis.LWB_StateV2;
|
||||
|
||||
// 清理守护状态
|
||||
// clear guard state
|
||||
guardBypass(false);
|
||||
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
/* ============= 导出 ============= */
|
||||
/* ============ Exports ============= */
|
||||
|
||||
export {
|
||||
MODULE_ID,
|
||||
// 解析
|
||||
// parsing
|
||||
parseBlock,
|
||||
applyVariablesForMessage,
|
||||
extractPlotLogBlocks,
|
||||
// 快照
|
||||
// snapshots
|
||||
snapshotCurrentLastFloor,
|
||||
snapshotForMessageId,
|
||||
rollbackToPreviousOf,
|
||||
rebuildVariablesFromScratch,
|
||||
// 规则
|
||||
// rules
|
||||
rulesGetTable,
|
||||
rulesSetTable,
|
||||
rulesLoadFromMeta,
|
||||
|
||||
@@ -117,38 +117,64 @@ const VT = {
|
||||
global: { getter: getGlobalVariable, setter: setGlobalVariable, storage: ()=> extension_settings.variables?.global || ((extension_settings.variables = { global: {} }).global), save: saveSettingsDebounced },
|
||||
};
|
||||
|
||||
const LWB_RULES_KEY='LWB_RULES';
|
||||
const getRulesTable = () => { try { return getContext()?.chatMetadata?.[LWB_RULES_KEY] || {}; } catch { return {}; } };
|
||||
const EXT_ID = 'LittleWhiteBox';
|
||||
const LWB_RULES_V1_KEY = 'LWB_RULES';
|
||||
const LWB_RULES_V2_KEY = 'LWB_RULES_V2';
|
||||
|
||||
const getRulesTable = () => {
|
||||
try {
|
||||
const ctx = getContext();
|
||||
const mode = extension_settings?.[EXT_ID]?.variablesMode || '1.0';
|
||||
const meta = ctx?.chatMetadata || {};
|
||||
return mode === '2.0'
|
||||
? (meta[LWB_RULES_V2_KEY] || {})
|
||||
: (meta[LWB_RULES_V1_KEY] || {});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const pathKey = (arr)=>{ try { return (arr||[]).map(String).join('.'); } catch { return ''; } };
|
||||
const getRuleNodeByPath = (arr)=> (pathKey(arr) ? (getRulesTable()||{})[pathKey(arr)] : undefined);
|
||||
const hasAnyRule = (n)=>{
|
||||
if(!n) return false;
|
||||
if(n.ro) return true;
|
||||
if(n.objectPolicy && n.objectPolicy!=='none') return true;
|
||||
if(n.arrayPolicy && n.arrayPolicy!=='lock') return true;
|
||||
const c=n.constraints||{};
|
||||
return ('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source);
|
||||
const hasAnyRule = (n) => {
|
||||
if (!n) return false;
|
||||
if (n.ro) return true;
|
||||
if (n.lock) return true;
|
||||
if (n.min !== undefined || n.max !== undefined) return true;
|
||||
if (n.step !== undefined) return true;
|
||||
if (Array.isArray(n.enum) && n.enum.length) return true;
|
||||
return false;
|
||||
};
|
||||
const ruleTip = (n)=>{
|
||||
if(!n) return '';
|
||||
const lines=[], c=n.constraints||{};
|
||||
if(n.ro) lines.push('只读:$ro');
|
||||
if(n.objectPolicy){ const m={none:'(默认:不可增删键)',ext:'$ext(可增键)',prune:'$prune(可删键)',free:'$free(可增删键)'}; lines.push(`对象策略:${m[n.objectPolicy]||n.objectPolicy}`); }
|
||||
if(n.arrayPolicy){ const m={lock:'(默认:不可增删项)',grow:'$grow(可增项)',shrink:'$shrink(可删项)',list:'$list(可增删项)'}; lines.push(`数组策略:${m[n.arrayPolicy]||n.arrayPolicy}`); }
|
||||
if('min'in c||'max'in c){ if('min'in c&&'max'in c) lines.push(`范围:$range=[${c.min},${c.max}]`); else if('min'in c) lines.push(`下限:$min=${c.min}`); else lines.push(`上限:$max=${c.max}`); }
|
||||
if('step'in c) lines.push(`步长:$step=${c.step}`);
|
||||
if(Array.isArray(c.enum)&&c.enum.length) lines.push(`枚举:$enum={${c.enum.join(';')}}`);
|
||||
if(c.regex&&c.regex.source) lines.push(`正则:$match=/${c.regex.source}/${c.regex.flags||''}`);
|
||||
|
||||
const ruleTip = (n) => {
|
||||
if (!n) return '';
|
||||
const lines = [];
|
||||
if (n.ro) lines.push('只读:$ro');
|
||||
if (n.lock) lines.push('结构锁:$lock(禁止增删该层 key/项)');
|
||||
|
||||
if (n.min !== undefined || n.max !== undefined) {
|
||||
const a = n.min !== undefined ? n.min : '-∞';
|
||||
const b = n.max !== undefined ? n.max : '+∞';
|
||||
lines.push(`范围:$range=[${a},${b}]`);
|
||||
}
|
||||
if (n.step !== undefined) lines.push(`步长:$step=${n.step}`);
|
||||
if (Array.isArray(n.enum) && n.enum.length) lines.push(`枚举:$enum={${n.enum.join(';')}}`);
|
||||
return lines.join('\n');
|
||||
};
|
||||
const badgesHtml = (n)=>{
|
||||
if(!hasAnyRule(n)) return '';
|
||||
const tip=ruleTip(n).replace(/"/g,'"'), out=[];
|
||||
if(n.ro) out.push(`<span class="vm-badge" data-type="ro" title="${tip}"><i class="fa-solid fa-shield-halved"></i></span>`);
|
||||
if((n.objectPolicy&&n.objectPolicy!=='none')||(n.arrayPolicy&&n.arrayPolicy!=='lock')) out.push(`<span class="vm-badge" data-type="struct" title="${tip}"><i class="fa-solid fa-diagram-project"></i></span>`);
|
||||
const c=n.constraints||{}; if(('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source)) out.push(`<span class="vm-badge" data-type="cons" title="${tip}"><i class="fa-solid fa-ruler-vertical"></i></span>`);
|
||||
return out.length?`<span class="vm-badges">${out.join('')}</span>`:'';
|
||||
|
||||
const badgesHtml = (n) => {
|
||||
if (!hasAnyRule(n)) return '';
|
||||
const tip = ruleTip(n).replace(/"/g,'"');
|
||||
|
||||
const out = [];
|
||||
if (n.ro) out.push(`<span class="vm-badge" data-type="ro" title="${tip}"><i class="fa-solid fa-shield-halved"></i></span>`);
|
||||
if (n.lock) out.push(`<span class="vm-badge" data-type="struct" title="${tip}"><i class="fa-solid fa-diagram-project"></i></span>`);
|
||||
if ((n.min !== undefined || n.max !== undefined) || (n.step !== undefined) || (Array.isArray(n.enum) && n.enum.length)) {
|
||||
out.push(`<span class="vm-badge" data-type="cons" title="${tip}"><i class="fa-solid fa-ruler-vertical"></i></span>`);
|
||||
}
|
||||
return out.length ? `<span class="vm-badges">${out.join('')}</span>` : '';
|
||||
};
|
||||
|
||||
const debounce=(fn,ms=200)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms);}};
|
||||
|
||||
class VariablesPanel {
|
||||
|
||||
Reference in New Issue
Block a user