diff --git a/modules/variables/state2/executor.js b/modules/variables/state2/executor.js
index 01278dd..c9387f8 100644
--- a/modules/variables/state2/executor.js
+++ b/modules/variables/state2/executor.js
@@ -261,9 +261,23 @@ function popVar(path, value) {
* =========================
*/
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 = {});
@@ -416,6 +430,7 @@ export function applyStateForMessage(messageId, messageContent) {
// ✅ 统一:只要没有可执行 blocks,就视为本层 state 被移除
if (!signature || blocks.length === 0) {
clearStateAppliedFor(messageId);
+ writeStateErrorsToLocalVar([]);
// delete WAL record
try {
const ext = getLwbExtMeta();
@@ -470,10 +485,21 @@ export function applyStateForMessage(messageId, messageContent) {
const guard = validate(op, absPath, op === 'inc' ? delta : value, oldValue);
if (!guard.allow) {
- errors.push(`[${path}] 拒绝: ${guard.reason}`);
+ 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 = '';
@@ -539,6 +565,9 @@ export function applyStateForMessage(messageId, messageContent) {
// ✅ checkpoint:执行完该楼后,可选存一次全量
saveCheckpointIfNeeded(messageId);
+ // Write error list to local variable
+ writeStateErrorsToLocalVar(errors);
+
return { atoms, errors, skipped: false };
}
diff --git a/modules/variables/state2/guard.js b/modules/variables/state2/guard.js
index 0b874c6..66200c3 100644
--- a/modules/variables/state2/guard.js
+++ b/modules/variables/state2/guard.js
@@ -21,8 +21,8 @@ export function saveRulesToMeta() {
} catch {}
}
-export function getRuleNode(path) {
- return rulesTable[path] || null;
+export function getRuleNode(absPath) {
+ return matchRuleWithWildcard(absPath);
}
export function setRule(path, rule) {
@@ -45,12 +45,73 @@ export function getParentPath(absPath) {
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;
+}
+
/**
* 验证操作
- * @param {string} op - set/inc/push/pop/del
- * @param {string} absPath - 规范化路径
- * @param {*} payload - set的值 / inc的delta / push的值 / pop的值
- * @param {*} currentValue - 当前值
+ * @returns {{ allow: boolean, value?: any, reason?: string, note?: string }}
*/
export function validate(op, absPath, payload, currentValue) {
const node = getRuleNode(absPath);
@@ -58,66 +119,131 @@ export function validate(op, absPath, payload, currentValue) {
const parentNode = parentPath ? getRuleNode(parentPath) : null;
const isNewKey = currentValue === undefined;
- // 父层 $lock:不允许新增/删除 key
- if (parentNode?.lock) {
+ const lastSeg = String(absPath).split('.').pop() || '';
+
+ // ===== 1. $schema 白名单检查 =====
+ if (parentNode?.allowedKeys && Array.isArray(parentNode.allowedKeys)) {
if (isNewKey && (op === 'set' || op === 'push')) {
- return { allow: false, reason: 'parent-locked' };
- }
- if (op === 'del') {
- return { allow: false, reason: 'parent-locked' };
- }
- }
-
- // 当前 $ro:不允许改值
- if (node?.ro && (op === 'set' || op === 'inc')) {
- return { allow: false, reason: 'ro' };
- }
-
- // 当前 $lock:不允许改结构
- if (node?.lock && (op === 'push' || op === 'pop' || op === 'del')) {
- return { allow: false, reason: 'lock' };
- }
-
- // set 数值约束
- if (op === 'set') {
- const num = Number(payload);
- if (Number.isFinite(num) && (node?.min !== undefined || node?.max !== undefined)) {
- let v = num;
- if (node.min !== undefined) v = Math.max(v, node.min);
- if (node.max !== undefined) v = Math.min(v, node.max);
- return { allow: true, value: v };
- }
- // enum
- if (node?.enum?.length) {
- if (!node.enum.includes(String(payload ?? ''))) {
- return { allow: false, reason: 'enum' };
+ 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 };
}
- // inc:返回最终值
+ // ===== 7. inc 操作:step / range 限制 =====
if (op === 'inc') {
const delta = Number(payload);
- if (!Number.isFinite(delta)) return { allow: false, reason: 'delta-nan' };
+ 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 };
+ return {
+ allow: true,
+ value: next,
+ note: noteParts.length ? noteParts.join(',') : undefined,
+ };
}
return { allow: true, value: payload };
}
+
+
diff --git a/modules/variables/state2/parser.js b/modules/variables/state2/parser.js
index 6ddad38..e943ae9 100644
--- a/modules/variables/state2/parser.js
+++ b/modules/variables/state2/parser.js
@@ -1,15 +1,100 @@
import jsyaml from '../../../libs/js-yaml.mjs';
-const STATE_TAG_RE = /<\s*state\b[^>]*>([\s\S]*?)<\s*\/\s*state\s*>/gi;
+/**
+ * Robust block matcher (no regex)
+ * - Pairs each with the nearest preceding
+ * - Ignores unclosed
+ */
+
+function isValidOpenTagAt(s, i) {
+ if (s[i] !== '<') return false;
+
+ const head = s.slice(i, i + 6).toLowerCase();
+ if (head !== '' || 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 !== '';
+}
+
+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 ?? '');
- if (!s || s.toLowerCase().indexOf(' s.slice(sp.openStart, sp.closeEnd).trim());
+ return chunks.join('\n---\n');
}
-/**
- * 解析 块
- * 返回: { rules: [{path, rule}], ops: [{path, op, value, ...}] }
- */
export function parseStateBlock(content) {
const lines = String(content ?? '').split(/\r?\n/);
const rules = [];
const dataLines = [];
- // 第一遍:分离规则行和数据行
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith('#')) continue;
+ let inSchema = false;
+ let schemaPath = '';
+ let schemaLines = [];
+ let schemaBaseIndent = -1;
- // 规则行:以 $ 开头
- if (trimmed.startsWith('$')) {
- const parsed = parseRuleLineInternal(trimmed);
- if (parsed) rules.push(parsed);
- } else {
- dataLines.push(line);
+ 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);
}
- // 第二遍:解析数据
- const ops = parseDataLines(dataLines);
+ flushSchema();
+ const ops = parseDataLines(dataLines);
return { rules, ops };
}
-function parseRuleLineInternal(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; }
- if (tok === '$lock') { rule.lock = 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]));
+/**
+ * 解析数据行
+ */
+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;
}
-
- 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);
+ 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 { path, rule };
+ return text.trimEnd();
}
function parseDataLines(lines) {
@@ -146,7 +266,8 @@ function parseDataLines(lines) {
if (colonIdx === -1) continue;
const path = trimmed.slice(0, colonIdx).trim();
- const rhs = trimmed.slice(colonIdx + 1).trim();
+ let rhs = trimmed.slice(colonIdx + 1).trim();
+ rhs = stripYamlInlineComment(rhs);
if (!path) continue;
if (!rhs) {
@@ -194,23 +315,19 @@ export function parseInlineValue(raw) {
if (t === 'null') return { op: 'del' };
- // (负数) 强制 set
const parenNum = t.match(/^\((-?\d+(?:\.\d+)?)\)$/);
if (parenNum) return { op: 'set', value: Number(parenNum[1]) };
- // +N / -N
if (/^\+\d/.test(t) || /^-\d/.test(t)) {
const n = Number(t);
if (Number.isFinite(n)) return { op: 'inc', delta: n };
}
- // +"str"
const pushD = t.match(/^\+"((?:[^"\\]|\\.)*)"\s*$/);
if (pushD) return { op: 'push', value: unescapeString(pushD[1]) };
const pushS = t.match(/^\+'((?:[^'\\]|\\.)*)'\s*$/);
if (pushS) return { op: 'push', value: unescapeString(pushS[1]) };
- // +[...]
if (t.startsWith('+[')) {
try {
const arr = JSON.parse(t.slice(1));
@@ -219,13 +336,11 @@ export function parseInlineValue(raw) {
return { op: 'set', value: t, warning: '+[] 解析失败' };
}
- // -"str"
const popD = t.match(/^-"((?:[^"\\]|\\.)*)"\s*$/);
if (popD) return { op: 'pop', value: unescapeString(popD[1]) };
const popS = t.match(/^-'((?:[^'\\]|\\.)*)'\s*$/);
if (popS) return { op: 'pop', value: unescapeString(popS[1]) };
- // -[...]
if (t.startsWith('-[')) {
try {
const arr = JSON.parse(t.slice(1));
@@ -234,10 +349,8 @@ export function parseInlineValue(raw) {
return { op: 'set', value: t, warning: '-[] 解析失败' };
}
- // 裸数字
if (/^-?\d+(?:\.\d+)?$/.test(t)) return { op: 'set', value: Number(t) };
- // "str" / 'str'
const strD = t.match(/^"((?:[^"\\]|\\.)*)"\s*$/);
if (strD) return { op: 'set', value: unescapeString(strD[1]) };
const strS = t.match(/^'((?:[^'\\]|\\.)*)'\s*$/);
@@ -246,12 +359,10 @@ export function parseInlineValue(raw) {
if (t === 'true') return { op: 'set', value: true };
if (t === 'false') return { op: 'set', value: false };
- // JSON array/object
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 };
}