feat: variables 2.0 state + L0 summary integration
This commit is contained in:
199
modules/variables/state2/executor.js
Normal file
199
modules/variables/state2/executor.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import { getContext } from '../../../../../../extensions.js';
|
||||
import {
|
||||
lwbResolveVarPath,
|
||||
lwbAssignVarPath,
|
||||
lwbAddVarPath,
|
||||
lwbPushVarPath,
|
||||
lwbDeleteVarPath,
|
||||
lwbRemoveArrayItemByValue,
|
||||
} from '../var-commands.js';
|
||||
import { lwbSplitPathWithBrackets } from '../../../core/variable-path.js';
|
||||
|
||||
import { extractStateBlocks, computeStateSignature, parseStateBlock } from './parser.js';
|
||||
import { generateSemantic } from './semantic.js';
|
||||
|
||||
const LWB_STATE_APPLIED_KEY = 'LWB_STATE_APPLIED_KEY';
|
||||
|
||||
/**
|
||||
* chatMetadata 内记录每楼层 signature,防止重复执行
|
||||
*/
|
||||
function getAppliedMap() {
|
||||
const meta = getContext()?.chatMetadata || {};
|
||||
meta[LWB_STATE_APPLIED_KEY] ||= {};
|
||||
return meta[LWB_STATE_APPLIED_KEY];
|
||||
}
|
||||
|
||||
export function clearStateAppliedFor(floor) {
|
||||
try {
|
||||
const map = getAppliedMap();
|
||||
delete map[floor];
|
||||
getContext()?.saveMetadataDebounced?.();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function clearStateAppliedFrom(floorInclusive) {
|
||||
try {
|
||||
const map = getAppliedMap();
|
||||
for (const k of Object.keys(map)) {
|
||||
const id = Number(k);
|
||||
if (!Number.isNaN(id) && id >= floorInclusive) delete map[k];
|
||||
}
|
||||
getContext()?.saveMetadataDebounced?.();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function safeParseAny(str) {
|
||||
if (str == null || str === '') return undefined;
|
||||
if (typeof str !== 'string') return str;
|
||||
const t = str.trim();
|
||||
if (!t) return undefined;
|
||||
if (t[0] === '{' || t[0] === '[') {
|
||||
try { return JSON.parse(t); } catch { return str; }
|
||||
}
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(t)) return Number(t);
|
||||
if (t === 'true') return true;
|
||||
if (t === 'false') return false;
|
||||
return str;
|
||||
}
|
||||
|
||||
function isIndexDeleteOp(opItem) {
|
||||
if (!opItem || opItem.op !== 'del') return false;
|
||||
const segs = lwbSplitPathWithBrackets(opItem.path);
|
||||
if (!segs.length) return false;
|
||||
const last = segs[segs.length - 1];
|
||||
return typeof last === 'number' && Number.isFinite(last);
|
||||
}
|
||||
|
||||
function buildParentPathFromSegs(segs) {
|
||||
return segs.reduce((acc, s) => {
|
||||
if (typeof s === 'number') return `${acc}[${s}]`;
|
||||
return acc ? `${acc}.${s}` : String(s);
|
||||
}, '');
|
||||
}
|
||||
|
||||
function buildExecOpsWithIndexDeleteReorder(ops) {
|
||||
const groups = new Map(); // parentPath -> [{ op, idx }]
|
||||
const groupOrder = new Map();
|
||||
let orderCounter = 0;
|
||||
|
||||
const normalOps = [];
|
||||
|
||||
for (const op of ops) {
|
||||
if (isIndexDeleteOp(op)) {
|
||||
const segs = lwbSplitPathWithBrackets(op.path);
|
||||
const idx = segs[segs.length - 1];
|
||||
const parentPath = buildParentPathFromSegs(segs.slice(0, -1));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return [...reorderedIndexDeletes, ...normalOps];
|
||||
}
|
||||
|
||||
/**
|
||||
* 变量 2.0:执行单条消息里的 <state>,返回 atoms
|
||||
*/
|
||||
export function applyStateForMessage(messageId, messageContent) {
|
||||
const ctx = getContext();
|
||||
const chatId = ctx?.chatId || '';
|
||||
|
||||
const text = String(messageContent ?? '');
|
||||
const signature = computeStateSignature(text);
|
||||
|
||||
// 没有 state:清理旧 signature(避免“删掉 state 后仍然认为执行过”)
|
||||
if (!signature) {
|
||||
clearStateAppliedFor(messageId);
|
||||
return { atoms: [], errors: [], skipped: false };
|
||||
}
|
||||
|
||||
// 幂等:signature 没变就跳过
|
||||
const appliedMap = getAppliedMap();
|
||||
if (appliedMap[messageId] === signature) {
|
||||
return { atoms: [], errors: [], skipped: true };
|
||||
}
|
||||
|
||||
const blocks = extractStateBlocks(text);
|
||||
const atoms = [];
|
||||
const errors = [];
|
||||
|
||||
let idx = 0;
|
||||
|
||||
for (const block of blocks) {
|
||||
const ops = parseStateBlock(block);
|
||||
const execOps = buildExecOpsWithIndexDeleteReorder(ops);
|
||||
for (const opItem of execOps) {
|
||||
const { path, op, value, delta, warning } = opItem;
|
||||
if (!path) continue;
|
||||
if (warning) errors.push(`[${path}] ${warning}`);
|
||||
|
||||
const oldValue = safeParseAny(lwbResolveVarPath(path));
|
||||
|
||||
try {
|
||||
switch (op) {
|
||||
case 'set':
|
||||
lwbAssignVarPath(path, value);
|
||||
break;
|
||||
case 'inc':
|
||||
lwbAddVarPath(path, delta);
|
||||
break;
|
||||
case 'push':
|
||||
lwbPushVarPath(path, value);
|
||||
break;
|
||||
case 'pop':
|
||||
lwbRemoveArrayItemByValue(path, value);
|
||||
break;
|
||||
case 'del':
|
||||
lwbDeleteVarPath(path);
|
||||
break;
|
||||
default:
|
||||
errors.push(`[${path}] 未知 op=${op}`);
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`[${path}] 执行失败: ${e?.message || e}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newValue = safeParseAny(lwbResolveVarPath(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?.();
|
||||
|
||||
return { atoms, errors, skipped: false };
|
||||
}
|
||||
3
modules/variables/state2/index.js
Normal file
3
modules/variables/state2/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { applyStateForMessage, clearStateAppliedFor, clearStateAppliedFrom } from './executor.js';
|
||||
export { parseStateBlock, extractStateBlocks, computeStateSignature, parseInlineValue } from './parser.js';
|
||||
export { generateSemantic } from './semantic.js';
|
||||
196
modules/variables/state2/parser.js
Normal file
196
modules/variables/state2/parser.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import jsyaml from '../../../libs/js-yaml.mjs';
|
||||
|
||||
const STATE_TAG_RE = /<\s*state\b[^>]*>([\s\S]*?)<\s*\/\s*state\s*>/gi;
|
||||
|
||||
export function extractStateBlocks(text) {
|
||||
const s = String(text ?? '');
|
||||
if (!s || s.toLowerCase().indexOf('<state') === -1) return [];
|
||||
const out = [];
|
||||
STATE_TAG_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = STATE_TAG_RE.exec(s)) !== null) {
|
||||
const inner = String(m[1] ?? '');
|
||||
if (inner.trim()) out.push(inner);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeStateSignature(text) {
|
||||
const s = String(text ?? '');
|
||||
if (!s || s.toLowerCase().indexOf('<state') === -1) return '';
|
||||
const chunks = [];
|
||||
STATE_TAG_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = STATE_TAG_RE.exec(s)) !== null) chunks.push(String(m[0] ?? '').trim());
|
||||
return chunks.length ? chunks.join('\n---\n') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 <state> 块内容 -> ops[]
|
||||
* 单行支持运算符,多行只支持覆盖 set(YAML)
|
||||
*/
|
||||
export function parseStateBlock(content) {
|
||||
const results = [];
|
||||
const lines = String(content ?? '').split(/\r?\n/);
|
||||
|
||||
let pendingPath = null;
|
||||
let pendingLines = [];
|
||||
|
||||
const flushPending = () => {
|
||||
if (!pendingPath) return;
|
||||
// 没有任何缩进行:视为 set 空字符串
|
||||
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();
|
||||
const rhs = trimmed.slice(colonIdx + 1).trim();
|
||||
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' };
|
||||
|
||||
// (负数) 用于强制 set -5,而不是 inc -5
|
||||
const parenNum = t.match(/^\((-?\d+(?:\.\d+)?)\)$/);
|
||||
if (parenNum) return { op: 'set', value: Number(parenNum[1]) };
|
||||
|
||||
// +10 / -20
|
||||
if (/^\+\d/.test(t) || /^-\d/.test(t)) {
|
||||
const n = Number(t);
|
||||
if (Number.isFinite(n)) return { op: 'inc', delta: n };
|
||||
}
|
||||
|
||||
// +"str" / +'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));
|
||||
if (Array.isArray(arr)) return { op: 'push', value: arr };
|
||||
return { op: 'set', value: t, warning: '+[] 不是数组,作为字符串' };
|
||||
} catch {
|
||||
return { op: 'set', value: t, warning: '+[] JSON 解析失败,作为字符串' };
|
||||
}
|
||||
}
|
||||
|
||||
// -"str" / -'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));
|
||||
if (Array.isArray(arr)) return { op: 'pop', value: arr };
|
||||
return { op: 'set', value: t, warning: '-[] 不是数组,作为字符串' };
|
||||
} catch {
|
||||
return { op: 'set', value: t, warning: '-[] JSON 解析失败,作为字符串' };
|
||||
}
|
||||
}
|
||||
|
||||
// 裸数字 set
|
||||
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*$/);
|
||||
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 };
|
||||
|
||||
// JSON set
|
||||
if (t.startsWith('{') || t.startsWith('[')) {
|
||||
try { return { op: 'set', value: JSON.parse(t) }; }
|
||||
catch { return { op: 'set', value: t, warning: 'JSON 解析失败,作为字符串' }; }
|
||||
}
|
||||
|
||||
// 兜底 set 原文本
|
||||
return { op: 'set', value: t };
|
||||
}
|
||||
42
modules/variables/state2/semantic.js
Normal file
42
modules/variables/state2/semantic.js
Normal file
@@ -0,0 +1,42 @@
|
||||
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 {
|
||||
if (typeof v === 'string') return JSON.stringify(v);
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user