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 }; }