Compare commits

...

14 Commits

27 changed files with 6550 additions and 1592 deletions

144
README.md
View File

@@ -1,143 +1,7 @@
# LittleWhiteBox
# LittleWhiteBox
## 📁 目录结构
一个面向 SillyTavern 的多功能扩展,包含剧情总结/记忆系统、变量系统、任务与多种面板能力。集成了画图、流式生成、模板编辑、调试面板等组件,适合用于复杂玩法与长期剧情记录。
```
LittleWhiteBox/
├── .editorconfig # 编辑器格式规范
├── .eslintignore # ESLint 忽略规则
├── .eslintrc.cjs # ESLint 配置
├── .gitignore # Git 忽略规则
├── index.js # 插件入口:初始化/注册所有模块
├── jsconfig.json # JS/TS 编辑器提示
├── manifest.json # 插件清单:版本/依赖/入口
├── package-lock.json # 依赖锁定
├── package.json # 开发依赖/脚本
├── README.md # 说明文档
├── settings.html # 主设置页:模块开关/UI
├── style.css # 全局样式
├── bridges/ # 外部桥接
│ ├── call-generate-service.js # 调用生成服务桥接
│ ├── worldbook-bridge.js # 世界书桥接
│ └── wrapper-iframe.js # iframe 包装桥接
├── core/ # 核心基础设施
│ ├── constants.js # 常量/路径定义
│ ├── debug-core.js # 日志/缓存注册
│ ├── event-manager.js # 统一事件管理
│ ├── iframe-messaging.js # postMessage 封装
│ ├── server-storage.js # 服务器存储封装
│ ├── slash-command.js # 斜杠命令封装
│ ├── variable-path.js # 变量路径解析
│ └── wrapper-inline.js # iframe 内联脚本
├── docs/ # 文档与许可
│ ├── COPYRIGHT # 版权声明
│ ├── LICENSE.md # 许可协议
│ └── NOTICE # 通知/第三方声明
├── libs/ # 第三方库
│ ├── dexie.mjs # IndexedDB 封装库
│ ├── js-yaml.mjs # YAML 解析/序列化ESM
│ ├── minisearch.mjs # 轻量搜索库
│ ├── pixi.min.js # PixiJS 渲染库
│ └── jieba-wasm/
│ ├── jieba_rs_wasm.js # 结巴分词 WASM JS 包装
│ ├── jieba_rs_wasm_bg.wasm # 结巴分词 WASM 二进制
│ └── jieba_rs_wasm_bg.wasm.d.ts # WASM 类型声明
├── modules/ # 功能模块
│ ├── control-audio.js # 音频权限控制
│ ├── iframe-renderer.js # iframe 渲染
│ ├── immersive-mode.js # 沉浸模式
│ ├── message-preview.js # 消息预览/拦截
│ ├── streaming-generation.js # 生成相关功能
│ │
│ ├── debug-panel/ # 调试面板
│ │ ├── debug-panel.html # 调试面板 UI
│ │ └── debug-panel.js # 调试面板逻辑
│ │
│ ├── fourth-wall/ # 四次元壁
│ │ ├── fourth-wall.html # UI
│ │ ├── fourth-wall.js # 主逻辑
│ │ ├── fw-image.js # 图像相关增强
│ │ ├── fw-message-enhancer.js # 消息增强
│ │ ├── fw-prompt.js # 提示词/注入
│ │ └── fw-voice.js # 语音相关
│ │
│ ├── novel-draw/ # 画图模块
│ │ ├── cloud-presets.js # 云端预设
│ │ ├── floating-panel.js # 浮动面板
│ │ ├── gallery-cache.js # 图库缓存
│ │ ├── image-live-effect.js # 图像动态效果
│ │ ├── llm-service.js # LLM 服务调用
│ │ ├── novel-draw.html # UI
│ │ ├── novel-draw.js # 主逻辑
│ │ └── TAG编写指南.md # TAG 编写指南
│ │
│ ├── scheduled-tasks/ # 定时任务
│ │ ├── embedded-tasks.html # 内嵌任务 UI
│ │ ├── scheduled-tasks.html # 主 UI
│ │ └── scheduled-tasks.js # 逻辑
│ │
│ ├── story-outline/ # 故事大纲
│ │ ├── story-outline-prompt.js # Prompt 模板
│ │ ├── story-outline.html # UI
│ │ └── story-outline.js # 逻辑
│ │
│ ├── story-summary/ # 剧情总结 + 记忆系统
│ │ ├── story-summary-ui.js # UI 逻辑
│ │ ├── story-summary.css # 样式
│ │ ├── story-summary.html # UI含向量设置
│ │ ├── story-summary.js # 主入口:事件/UI/iframe 通讯
│ │ ├── data/
│ │ │ ├── config.js # 配置管理
│ │ │ ├── db.js # 向量存储L1/L2 Vectors (Dexie/IndexedDB)
│ │ │ └── store.js # 核心存储L2事件 + L3世界状态
│ │ ├── generate/
│ │ │ ├── generator.js # 调度器:调用 LLM -> 解析 -> 清洗 -> 合并
│ │ │ ├── llm.js # LLM API 与 Prompt 定义
│ │ │ └── prompt.js # 注入层:格式化 + 预算装配
│ │ └── vector/
│ │ ├── chunk-builder.js # L1 切分与构建
│ │ ├── chunk-store.js # 向量 CRUD 操作
│ │ ├── embedder.js # 向量化服务 (Local/Online)
│ │ ├── embedder.worker.js # 本地模型 Worker
│ │ ├── entity.js # 召回实体/辅助结构
│ │ └── recall.js # 召回引擎加权Query + 实体加分 + MMR去重
│ │
│ ├── template-editor/ # 模板编辑器
│ │ ├── template-editor.html # UI
│ │ └── template-editor.js # 逻辑
│ │
│ ├── tts/ # TTS
│ │ ├── tts-api.js # API 适配
│ │ ├── tts-auth-provider.js # 鉴权提供者
│ │ ├── tts-cache.js # 缓存
│ │ ├── tts-free-provider.js # 免费提供者
│ │ ├── tts-overlay.html # Overlay UI
│ │ ├── tts-panel.js # 面板逻辑
│ │ ├── tts-player.js # 播放器
│ │ ├── tts-text.js # 文本处理
│ │ ├── tts-voices.js # 语音配置
│ │ ├── tts.js # 主入口
│ │ ├── 声音复刻.png # 说明图
│ │ ├── 开通管理.png # 说明图
│ │ └── 获取ID和KEY.png # 说明图
│ │
│ └── variables/ # 变量系统
│ ├── var-commands.js # 变量命令/宏/路径解析
│ ├── varevent-editor.js # 变量编辑器/注入处理
│ ├── variables-core.js # 变量系统核心
│ └── variables-panel.js # 变量面板 UI
└── widgets/ # 通用 UI 组件
├── button-collapse.js # 按钮收纳
└── message-toolbar.js # 消息工具条
## 许可证
```
## 📄 许可证
详见 `docs/LICENSE.md`
详见 `docs/LICENSE.md`

View File

@@ -1,4 +1,4 @@
import { EventCenter } from "./event-manager.js";
import { EventCenter } from "./event-manager.js";
const DEFAULT_MAX_LOGS = 200;
@@ -110,14 +110,14 @@ class LoggerCore {
});
}
info(moduleId, message) {
this._log("info", moduleId, message, null);
info(moduleId, ...args) {
const msg = args.map(a => (typeof a === 'string' ? a : safeStringify(a))).join(' ');
this._log('info', moduleId, msg, null);
}
warn(moduleId, message) {
this._log("warn", moduleId, message, null);
warn(moduleId, ...args) {
const msg = args.map(a => (typeof a === 'string' ? a : safeStringify(a))).join(' ');
this._log('warn', moduleId, msg, null);
}
error(moduleId, message, err) {
this._log("error", moduleId, message, err || null);
}

View File

@@ -1,49 +1,33 @@
let wasm;
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
let cachedUint8ArrayMemory0 = null;
cachedTextDecoder.decode();
let cachegetUint8Memory0 = null;
function getUint8Memory0() {
if (cachegetUint8Memory0 === null || cachegetUint8Memory0.buffer !== wasm.memory.buffer) {
cachegetUint8Memory0 = new Uint8Array(wasm.memory.buffer);
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachegetUint8Memory0;
return cachedUint8ArrayMemory0;
}
let cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );
if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); };
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
function getStringFromWasm0(ptr, len) {
return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len));
}
const heap = new Array(32).fill(undefined);
heap.push(undefined, null, true, false);
let heap_next = heap.length;
function addHeapObject(obj) {
if (heap_next === heap.length) heap.push(heap.length + 1);
const idx = heap_next;
heap_next = heap[idx];
heap[idx] = obj;
return idx;
}
function getObject(idx) { return heap[idx]; }
function dropObject(idx) {
if (idx < 36) return;
heap[idx] = heap_next;
heap_next = idx;
}
function takeObject(idx) {
const ret = getObject(idx);
dropObject(idx);
return ret;
ptr = ptr >>> 0;
return decodeText(ptr, len);
}
function debugString(val) {
@@ -87,7 +71,7 @@ function debugString(val) {
// Test for built-in
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
let className;
if (builtInMatches.length > 1) {
if (builtInMatches && builtInMatches.length > 1) {
className = builtInMatches[1];
} else {
// Failed to match the standard '[object ClassName]'
@@ -113,7 +97,7 @@ function debugString(val) {
let WASM_VECTOR_LEN = 0;
let cachedTextEncoder = new TextEncoder('utf-8');
const cachedTextEncoder = (typeof TextEncoder !== 'undefined' ? new TextEncoder('utf-8') : { encode: () => { throw Error('TextEncoder not available') } } );
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
? function (arg, view) {
@@ -132,16 +116,16 @@ function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length);
getUint8Memory0().subarray(ptr, ptr + buf.length).set(buf);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len);
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8Memory0();
const mem = getUint8ArrayMemory0();
let offset = 0;
@@ -155,155 +139,161 @@ function passStringToWasm0(arg, malloc, realloc) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3);
const view = getUint8Memory0().subarray(ptr + offset, ptr + len);
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = encodeString(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
let cachegetInt32Memory0 = null;
function getInt32Memory0() {
if (cachegetInt32Memory0 === null || cachegetInt32Memory0.buffer !== wasm.memory.buffer) {
cachegetInt32Memory0 = new Int32Array(wasm.memory.buffer);
}
return cachegetInt32Memory0;
}
let cachedDataViewMemory0 = null;
let cachegetUint32Memory0 = null;
function getUint32Memory0() {
if (cachegetUint32Memory0 === null || cachegetUint32Memory0.buffer !== wasm.memory.buffer) {
cachegetUint32Memory0 = new Uint32Array(wasm.memory.buffer);
}
return cachegetUint32Memory0;
}
function getArrayJsValueFromWasm0(ptr, len) {
const mem = getUint32Memory0();
const slice = mem.subarray(ptr / 4, ptr / 4 + len);
const result = [];
for (let i = 0; i < slice.length; i++) {
result.push(takeObject(slice[i]));
}
return result;
}
/**
* @param {string} text
* @param {boolean} hmm
* @returns {any[]}
*/
export function cut(text, hmm) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
var ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len0 = WASM_VECTOR_LEN;
wasm.cut(retptr, ptr0, len0, hmm);
var r0 = getInt32Memory0()[retptr / 4 + 0];
var r1 = getInt32Memory0()[retptr / 4 + 1];
var v1 = getArrayJsValueFromWasm0(r0, r1).slice();
wasm.__wbindgen_free(r0, r1 * 4);
return v1;
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* @param {string} text
* @returns {any[]}
*/
export function cut_all(text) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
var ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len0 = WASM_VECTOR_LEN;
wasm.cut_all(retptr, ptr0, len0);
var r0 = getInt32Memory0()[retptr / 4 + 0];
var r1 = getInt32Memory0()[retptr / 4 + 1];
var v1 = getArrayJsValueFromWasm0(r0, r1).slice();
wasm.__wbindgen_free(r0, r1 * 4);
return v1;
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* @param {string} text
* @param {boolean} hmm
* @returns {any[]}
*/
export function cut_for_search(text, hmm) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
var ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len0 = WASM_VECTOR_LEN;
wasm.cut_for_search(retptr, ptr0, len0, hmm);
var r0 = getInt32Memory0()[retptr / 4 + 0];
var r1 = getInt32Memory0()[retptr / 4 + 1];
var v1 = getArrayJsValueFromWasm0(r0, r1).slice();
wasm.__wbindgen_free(r0, r1 * 4);
return v1;
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* @param {string} text
* @param {string} mode
* @param {boolean} hmm
* @returns {any[]}
*/
export function tokenize(text, mode, hmm) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
var ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len0 = WASM_VECTOR_LEN;
var ptr1 = passStringToWasm0(mode, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
wasm.tokenize(retptr, ptr0, len0, ptr1, len1, hmm);
var r0 = getInt32Memory0()[retptr / 4 + 0];
var r1 = getInt32Memory0()[retptr / 4 + 1];
var v2 = getArrayJsValueFromWasm0(r0, r1).slice();
wasm.__wbindgen_free(r0, r1 * 4);
return v2;
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function getArrayJsValueFromWasm0(ptr, len) {
ptr = ptr >>> 0;
const mem = getDataViewMemory0();
const result = [];
for (let i = ptr; i < ptr + 4 * len; i += 4) {
result.push(wasm.__wbindgen_export_2.get(mem.getUint32(i, true)));
}
wasm.__externref_drop_slice(ptr, len);
return result;
}
/**
* @param {string} word
* @param {number | undefined} freq
* @param {string | undefined} tag
* @returns {number}
*/
* @param {string} text
* @param {boolean | null} [hmm]
* @returns {string[]}
*/
export function cut(text, hmm) {
const ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.cut(ptr0, len0, isLikeNone(hmm) ? 0xFFFFFF : hmm ? 1 : 0);
var v2 = getArrayJsValueFromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 4, 4);
return v2;
}
/**
* @param {string} text
* @returns {string[]}
*/
export function cut_all(text) {
const ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.cut_all(ptr0, len0);
var v2 = getArrayJsValueFromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 4, 4);
return v2;
}
/**
* @param {string} text
* @param {boolean | null} [hmm]
* @returns {string[]}
*/
export function cut_for_search(text, hmm) {
const ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.cut_for_search(ptr0, len0, isLikeNone(hmm) ? 0xFFFFFF : hmm ? 1 : 0);
var v2 = getArrayJsValueFromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 4, 4);
return v2;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_export_2.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
/**
* @param {string} text
* @param {string} mode
* @param {boolean | null} [hmm]
* @returns {Token[]}
*/
export function tokenize(text, mode, hmm) {
const ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(mode, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.tokenize(ptr0, len0, ptr1, len1, isLikeNone(hmm) ? 0xFFFFFF : hmm ? 1 : 0);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v3 = getArrayJsValueFromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 4, 4);
return v3;
}
/**
* @param {string} word
* @param {number | null} [freq]
* @param {string | null} [tag]
* @returns {number}
*/
export function add_word(word, freq, tag) {
var ptr0 = passStringToWasm0(word, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len0 = WASM_VECTOR_LEN;
const ptr0 = passStringToWasm0(word, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
var ptr1 = isLikeNone(tag) ? 0 : passStringToWasm0(tag, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
var ret = wasm.add_word(ptr0, len0, !isLikeNone(freq), isLikeNone(freq) ? 0 : freq, ptr1, len1);
const ret = wasm.add_word(ptr0, len0, isLikeNone(freq) ? 0x100000001 : (freq) >>> 0, ptr1, len1);
return ret >>> 0;
}
async function load(module, imports) {
/**
* @param {string} sentence
* @param {boolean | null} [hmm]
* @returns {Tag[]}
*/
export function tag(sentence, hmm) {
const ptr0 = passStringToWasm0(sentence, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.tag(ptr0, len0, isLikeNone(hmm) ? 0xFFFFFF : hmm ? 1 : 0);
var v2 = getArrayJsValueFromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 4, 4);
return v2;
}
/**
* @param {string} dict
*/
export function with_dict(dict) {
const ptr0 = passStringToWasm0(dict, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.with_dict(ptr0, len0);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
const EXPECTED_RESPONSE_TYPES = new Set(['basic', 'cors', 'default']);
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
if (module.headers.get('Content-Type') != 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
const validResponse = module.ok && EXPECTED_RESPONSE_TYPES.has(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else {
throw e;
@@ -326,65 +316,123 @@ async function load(module, imports) {
}
}
async function init(input) {
if (typeof input === 'undefined') {
input = new URL('jieba_rs_wasm_bg.wasm', import.meta.url);
}
function __wbg_get_imports() {
const imports = {};
imports.wbg = {};
imports.wbg.__wbindgen_string_new = function(arg0, arg1) {
var ret = getStringFromWasm0(arg0, arg1);
return addHeapObject(ret);
imports.wbg.__wbg_Error_0497d5bdba9362e5 = function(arg0, arg1) {
const ret = Error(getStringFromWasm0(arg0, arg1));
return ret;
};
imports.wbg.__wbindgen_object_drop_ref = function(arg0) {
takeObject(arg0);
imports.wbg.__wbg_new_07b483f72211fd66 = function() {
const ret = new Object();
return ret;
};
imports.wbg.__wbg_new_68adb0d58759a4ed = function() {
var ret = new Object();
return addHeapObject(ret);
imports.wbg.__wbg_set_3f1d0b984ed272ed = function(arg0, arg1, arg2) {
arg0[arg1] = arg2;
};
imports.wbg.__wbindgen_number_new = function(arg0) {
var ret = arg0;
return addHeapObject(ret);
};
imports.wbg.__wbg_set_2e79e744454afade = function(arg0, arg1, arg2) {
getObject(arg0)[takeObject(arg1)] = takeObject(arg2);
};
imports.wbg.__wbindgen_object_clone_ref = function(arg0) {
var ret = getObject(arg0);
return addHeapObject(ret);
};
imports.wbg.__wbg_new_7031805939a80203 = function(arg0, arg1) {
var ret = new Error(getStringFromWasm0(arg0, arg1));
return addHeapObject(ret);
imports.wbg.__wbindgen_bigint_from_u64 = function(arg0) {
const ret = BigInt.asUintN(64, arg0);
return ret;
};
imports.wbg.__wbindgen_debug_string = function(arg0, arg1) {
var ret = debugString(getObject(arg1));
var ptr0 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len0 = WASM_VECTOR_LEN;
getInt32Memory0()[arg0 / 4 + 1] = len0;
getInt32Memory0()[arg0 / 4 + 0] = ptr0;
const ret = debugString(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
};
imports.wbg.__wbindgen_init_externref_table = function() {
const table = wasm.__wbindgen_export_2;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
;
};
imports.wbg.__wbindgen_number_new = function(arg0) {
const ret = arg0;
return ret;
};
imports.wbg.__wbindgen_string_new = function(arg0, arg1) {
const ret = getStringFromWasm0(arg0, arg1);
return ret;
};
imports.wbg.__wbindgen_throw = function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
};
imports.wbg.__wbindgen_rethrow = function(arg0) {
throw takeObject(arg0);
};
if (typeof input === 'string' || (typeof Request === 'function' && input instanceof Request) || (typeof URL === 'function' && input instanceof URL)) {
input = fetch(input);
}
return imports;
}
function __wbg_init_memory(imports, memory) {
}
const { instance, module } = await load(await input, imports);
function __wbg_finalize_init(instance, module) {
wasm = instance.exports;
init.__wbindgen_wasm_module = module;
__wbg_init.__wbindgen_wasm_module = module;
cachedDataViewMemory0 = null;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
export default init;
function initSync(module) {
if (wasm !== undefined) return wasm;
if (typeof module !== 'undefined') {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
__wbg_init_memory(imports);
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (typeof module_or_path !== 'undefined') {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (typeof module_or_path === 'undefined') {
module_or_path = new URL('jieba_rs_wasm_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
__wbg_init_memory(imports);
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync };
export default __wbg_init;

View File

@@ -0,0 +1,372 @@
let wasm;
export function __wbg_set_wasm(val) {
wasm = val;
}
const heap = new Array(128).fill(undefined);
heap.push(undefined, null, true, false);
function getObject(idx) { return heap[idx]; }
let heap_next = heap.length;
function dropObject(idx) {
if (idx < 132) return;
heap[idx] = heap_next;
heap_next = idx;
}
function takeObject(idx) {
const ret = getObject(idx);
dropObject(idx);
return ret;
}
const lTextDecoder = typeof TextDecoder === 'undefined' ? (0, module.require)('util').TextDecoder : TextDecoder;
let cachedTextDecoder = new lTextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
function addHeapObject(obj) {
if (heap_next === heap.length) heap.push(heap.length + 1);
const idx = heap_next;
heap_next = heap[idx];
heap[idx] = obj;
return idx;
}
function debugString(val) {
// primitive types
const type = typeof val;
if (type == 'number' || type == 'boolean' || val == null) {
return `${val}`;
}
if (type == 'string') {
return `"${val}"`;
}
if (type == 'symbol') {
const description = val.description;
if (description == null) {
return 'Symbol';
} else {
return `Symbol(${description})`;
}
}
if (type == 'function') {
const name = val.name;
if (typeof name == 'string' && name.length > 0) {
return `Function(${name})`;
} else {
return 'Function';
}
}
// objects
if (Array.isArray(val)) {
const length = val.length;
let debug = '[';
if (length > 0) {
debug += debugString(val[0]);
}
for(let i = 1; i < length; i++) {
debug += ', ' + debugString(val[i]);
}
debug += ']';
return debug;
}
// Test for built-in
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
let className;
if (builtInMatches.length > 1) {
className = builtInMatches[1];
} else {
// Failed to match the standard '[object ClassName]'
return toString.call(val);
}
if (className == 'Object') {
// we're a user defined class or Object
// JSON.stringify avoids problems with cycles, and is generally much
// easier than looping through ownProperties of `val`.
try {
return 'Object(' + JSON.stringify(val) + ')';
} catch (_) {
return 'Object';
}
}
// errors
if (val instanceof Error) {
return `${val.name}: ${val.message}\n${val.stack}`;
}
// TODO we could test for more things here, like `Set`s and `Map`s.
return className;
}
let WASM_VECTOR_LEN = 0;
const lTextEncoder = typeof TextEncoder === 'undefined' ? (0, module.require)('util').TextEncoder : TextEncoder;
let cachedTextEncoder = new lTextEncoder('utf-8');
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
? function (arg, view) {
return cachedTextEncoder.encodeInto(arg, view);
}
: function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
});
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = encodeString(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function getArrayJsValueFromWasm0(ptr, len) {
ptr = ptr >>> 0;
const mem = getDataViewMemory0();
const result = [];
for (let i = ptr; i < ptr + 4 * len; i += 4) {
result.push(takeObject(mem.getUint32(i, true)));
}
return result;
}
/**
* @param {string} text
* @param {boolean | undefined} [hmm]
* @returns {any[]}
*/
export function cut(text, hmm) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.cut(retptr, ptr0, len0, isLikeNone(hmm) ? 0xFFFFFF : hmm ? 1 : 0);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var v2 = getArrayJsValueFromWasm0(r0, r1).slice();
wasm.__wbindgen_free(r0, r1 * 4, 4);
return v2;
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* @param {string} text
* @returns {any[]}
*/
export function cut_all(text) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.cut_all(retptr, ptr0, len0);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var v2 = getArrayJsValueFromWasm0(r0, r1).slice();
wasm.__wbindgen_free(r0, r1 * 4, 4);
return v2;
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* @param {string} text
* @param {boolean | undefined} [hmm]
* @returns {any[]}
*/
export function cut_for_search(text, hmm) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.cut_for_search(retptr, ptr0, len0, isLikeNone(hmm) ? 0xFFFFFF : hmm ? 1 : 0);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var v2 = getArrayJsValueFromWasm0(r0, r1).slice();
wasm.__wbindgen_free(r0, r1 * 4, 4);
return v2;
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* @param {string} text
* @param {string} mode
* @param {boolean | undefined} [hmm]
* @returns {any[]}
*/
export function tokenize(text, mode, hmm) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(mode, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
wasm.tokenize(retptr, ptr0, len0, ptr1, len1, isLikeNone(hmm) ? 0xFFFFFF : hmm ? 1 : 0);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
if (r3) {
throw takeObject(r2);
}
var v3 = getArrayJsValueFromWasm0(r0, r1).slice();
wasm.__wbindgen_free(r0, r1 * 4, 4);
return v3;
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
/**
* @param {string} word
* @param {number | undefined} [freq]
* @param {string | undefined} [tag]
* @returns {number}
*/
export function add_word(word, freq, tag) {
const ptr0 = passStringToWasm0(word, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
var ptr1 = isLikeNone(tag) ? 0 : passStringToWasm0(tag, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
const ret = wasm.add_word(ptr0, len0, !isLikeNone(freq), isLikeNone(freq) ? 0 : freq, ptr1, len1);
return ret >>> 0;
}
/**
* @param {string} sentence
* @param {boolean | undefined} [hmm]
* @returns {any[]}
*/
export function tag(sentence, hmm) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(sentence, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.tag(retptr, ptr0, len0, isLikeNone(hmm) ? 0xFFFFFF : hmm ? 1 : 0);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
var v2 = getArrayJsValueFromWasm0(r0, r1).slice();
wasm.__wbindgen_free(r0, r1 * 4, 4);
return v2;
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
}
}
export function __wbindgen_object_drop_ref(arg0) {
takeObject(arg0);
};
export function __wbindgen_string_new(arg0, arg1) {
const ret = getStringFromWasm0(arg0, arg1);
return addHeapObject(ret);
};
export function __wbindgen_object_clone_ref(arg0) {
const ret = getObject(arg0);
return addHeapObject(ret);
};
export function __wbg_new_1e7c00339420672b() {
const ret = new Object();
return addHeapObject(ret);
};
export function __wbindgen_number_new(arg0) {
const ret = arg0;
return addHeapObject(ret);
};
export function __wbg_set_1754fb90457a8cce(arg0, arg1, arg2) {
getObject(arg0)[takeObject(arg1)] = takeObject(arg2);
};
export function __wbg_new_b44ab9ef6060dd36(arg0, arg1) {
const ret = new Error(getStringFromWasm0(arg0, arg1));
return addHeapObject(ret);
};
export function __wbindgen_debug_string(arg0, arg1) {
const ret = debugString(getObject(arg1));
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
};
export function __wbindgen_throw(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
};

View File

@@ -1,12 +1,25 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export function cut(a: number, b: number, c: number, d: number): void;
export function cut_all(a: number, b: number, c: number): void;
export function cut_for_search(a: number, b: number, c: number, d: number): void;
export function tokenize(a: number, b: number, c: number, d: number, e: number, f: number): void;
export function add_word(a: number, b: number, c: number, d: number, e: number, f: number): number;
export function __wbindgen_malloc(a: number): number;
export function __wbindgen_realloc(a: number, b: number, c: number): number;
export function __wbindgen_add_to_stack_pointer(a: number): number;
export function __wbindgen_free(a: number, b: number): void;
export const cut: (a: number, b: number, c: number) => [number, number];
export const cut_all: (a: number, b: number) => [number, number];
export const cut_for_search: (a: number, b: number, c: number) => [number, number];
export const tokenize: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
export const add_word: (a: number, b: number, c: number, d: number, e: number) => number;
export const tag: (a: number, b: number, c: number) => [number, number];
export const with_dict: (a: number, b: number) => [number, number];
export const rust_zstd_wasm_shim_qsort: (a: number, b: number, c: number, d: number) => void;
export const rust_zstd_wasm_shim_malloc: (a: number) => number;
export const rust_zstd_wasm_shim_memcmp: (a: number, b: number, c: number) => number;
export const rust_zstd_wasm_shim_calloc: (a: number, b: number) => number;
export const rust_zstd_wasm_shim_free: (a: number) => void;
export const rust_zstd_wasm_shim_memcpy: (a: number, b: number, c: number) => number;
export const rust_zstd_wasm_shim_memmove: (a: number, b: number, c: number) => number;
export const rust_zstd_wasm_shim_memset: (a: number, b: number, c: number) => number;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_export_2: WebAssembly.Table;
export const __externref_drop_slice: (a: number, b: number) => void;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_start: () => void;

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,12 @@
// Story Summary - Store
// L2 (events/characters/arcs) + L3 (world) 统一存储
// L2 (events/characters/arcs) + L3 (facts) 统一存储
import { getContext, saveMetadataDebounced } from "../../../../../../extensions.js";
import { chat_metadata } from "../../../../../../../script.js";
import { EXT_ID } from "../../../core/constants.js";
import { xbLog } from "../../../core/debug-core.js";
import { clearEventVectors, deleteEventVectorsByIds } from "../vector/chunk-store.js";
import { clearEventTextIndex } from '../vector/text-search.js';
const MODULE_ID = 'summaryStore';
@@ -19,7 +20,26 @@ export function getSummaryStore() {
chat_metadata.extensions ||= {};
chat_metadata.extensions[EXT_ID] ||= {};
chat_metadata.extensions[EXT_ID].storySummary ||= {};
return chat_metadata.extensions[EXT_ID].storySummary;
const store = chat_metadata.extensions[EXT_ID].storySummary;
// ★ 自动迁移旧数据
if (store.json && !store.json.facts) {
const hasOldData = store.json.world?.length || store.json.characters?.relationships?.length;
if (hasOldData) {
store.json.facts = migrateToFacts(store.json);
// 删除旧字段
delete store.json.world;
if (store.json.characters) {
delete store.json.characters.relationships;
}
store.updatedAt = Date.now();
saveSummaryStore();
xbLog.info(MODULE_ID, `自动迁移完成: ${store.json.facts.length} 条 facts`);
}
}
return store;
}
export function saveSummaryStore() {
@@ -31,7 +51,6 @@ export function getKeepVisibleCount() {
return store?.keepVisibleCount ?? 3;
}
// boundary隐藏边界由调用方决定语义LLM总结边界 or 向量边界)
export function calcHideRange(boundary) {
if (boundary == null || boundary < 0) return null;
@@ -47,42 +66,176 @@ export function addSummarySnapshot(store, endMesId) {
}
// ═══════════════════════════════════════════════════════════════════════════
// L3 世界状态合并
// Fact 工具函数
// ═══════════════════════════════════════════════════════════════════════════
export function mergeWorldState(existingList, updates, floor) {
/**
* 判断是否为关系类 fact
*/
export function isRelationFact(f) {
return /^对.+的/.test(f.p);
}
// ═══════════════════════════════════════════════════════════════════════════
// 从 facts 提取关系(供关系图 UI 使用)
// ═══════════════════════════════════════════════════════════════════════════
export function extractRelationshipsFromFacts(facts) {
return (facts || [])
.filter(f => !f.retracted && isRelationFact(f))
.map(f => {
const match = f.p.match(/^对(.+)的/);
const to = match ? match[1] : '';
if (!to) return null;
return {
from: f.s,
to,
label: f.o,
trend: f.trend || '陌生',
};
})
.filter(Boolean);
}
/**
* 生成 fact 的唯一键s + p
*/
function factKey(f) {
return `${f.s}::${f.p}`;
}
/**
* 生成下一个 fact ID
*/
function getNextFactId(existingFacts) {
let maxId = 0;
for (const f of existingFacts || []) {
const match = f.id?.match(/^f-(\d+)$/);
if (match) {
maxId = Math.max(maxId, parseInt(match[1], 10));
}
}
return maxId + 1;
}
// ═══════════════════════════════════════════════════════════════════════════
// Facts 合并KV 覆盖模型)
// ═══════════════════════════════════════════════════════════════════════════
export function mergeFacts(existingFacts, updates, floor) {
const map = new Map();
(existingList || []).forEach(item => {
const key = `${item.category}:${item.topic}`;
map.set(key, item);
});
// 加载现有 facts
for (const f of existingFacts || []) {
if (!f.retracted) {
map.set(factKey(f), f);
}
}
(updates || []).forEach(up => {
if (!up.category || !up.topic) return;
// 获取下一个 ID
let nextId = getNextFactId(existingFacts);
const key = `${up.category}:${up.topic}`;
// 应用更新
for (const u of updates || []) {
if (!u.s || !u.p) continue;
if (up.cleared === true) {
const key = factKey(u);
// 删除操作
if (u.retracted === true) {
map.delete(key);
return;
continue;
}
const content = up.content?.trim();
if (!content) return;
// 无 o 则跳过
if (!u.o || !String(u.o).trim()) continue;
map.set(key, {
category: up.category,
topic: up.topic,
content: content,
floor: floor,
_addedAt: floor,
});
});
// 覆盖或新增
const existing = map.get(key);
const newFact = {
id: existing?.id || `f-${nextId++}`,
s: u.s.trim(),
p: u.p.trim(),
o: String(u.o).trim(),
since: floor,
};
// 关系类保留 trend
if (isRelationFact(newFact) && u.trend) {
newFact.trend = u.trend;
}
// 保留原始 _addedAt如果是更新
if (existing?._addedAt != null) {
newFact._addedAt = existing._addedAt;
} else {
newFact._addedAt = floor;
}
map.set(key, newFact);
}
return Array.from(map.values());
}
// ═══════════════════════════════════════════════════════════════════════════
// 旧数据迁移
// ═══════════════════════════════════════════════════════════════════════════
export function migrateToFacts(json) {
if (!json) return [];
// 已有 facts 则跳过迁移
if (json.facts?.length) return json.facts;
const facts = [];
let nextId = 1;
// 迁移 worldworldUpdate 的持久化结果)
for (const w of json.world || []) {
if (!w.category || !w.topic || !w.content) continue;
let s, p;
// 解析 topic 格式status/knowledge/relation 用 "::" 分隔
if (w.topic.includes('::')) {
[s, p] = w.topic.split('::').map(x => x.trim());
} else {
// inventory/rule 类
s = w.topic.trim();
p = w.category;
}
if (!s || !p) continue;
facts.push({
id: `f-${nextId++}`,
s,
p,
o: w.content.trim(),
since: w.floor ?? w._addedAt ?? 0,
_addedAt: w._addedAt ?? w.floor ?? 0,
});
}
// 迁移 relationships
for (const r of json.characters?.relationships || []) {
if (!r.from || !r.to) continue;
facts.push({
id: `f-${nextId++}`,
s: r.from,
p: `${r.to}的看法`,
o: r.label || '未知',
trend: r.trend,
since: r._addedAt ?? 0,
_addedAt: r._addedAt ?? 0,
});
}
return facts;
}
// ═══════════════════════════════════════════════════════════════════════════
// 数据合并L2 + L3
// ═══════════════════════════════════════════════════════════════════════════
@@ -95,11 +248,10 @@ export function mergeNewData(oldJson, parsed, endMesId) {
merged.events ||= [];
merged.characters ||= {};
merged.characters.main ||= [];
merged.characters.relationships ||= [];
merged.arcs ||= [];
// L3 初始化
merged.world ||= [];
// L3 初始化不再迁移getSummaryStore 已处理)
merged.facts ||= [];
// L2 数据合并
if (parsed.keywords?.length) {
@@ -111,6 +263,7 @@ export function mergeNewData(oldJson, parsed, endMesId) {
merged.events.push(e);
});
// newCharacters
const existingMain = new Set(
(merged.characters.main || []).map(m => typeof m === 'string' ? m : m.name)
);
@@ -120,22 +273,7 @@ export function mergeNewData(oldJson, parsed, endMesId) {
}
});
const relMap = new Map(
(merged.characters.relationships || []).map(r => [`${r.from}->${r.to}`, r])
);
(parsed.newRelationships || []).forEach(r => {
const key = `${r.from}->${r.to}`;
const existing = relMap.get(key);
if (existing) {
existing.label = r.label;
existing.trend = r.trend;
} else {
r._addedAt = endMesId;
relMap.set(key, r);
}
});
merged.characters.relationships = Array.from(relMap.values());
// arcUpdates
const arcMap = new Map((merged.arcs || []).map(a => [a.name, a]));
(parsed.arcUpdates || []).forEach(update => {
const existing = arcMap.get(update.name);
@@ -158,12 +296,8 @@ export function mergeNewData(oldJson, parsed, endMesId) {
});
merged.arcs = Array.from(arcMap.values());
// L3 世界状态合并
merged.world = mergeWorldState(
merged.world || [],
parsed.worldUpdate || [],
endMesId
);
// L3 factUpdates 合并
merged.facts = mergeFacts(merged.facts, parsed.factUpdates || [], endMesId);
return merged;
}
@@ -241,13 +375,10 @@ export async function executeRollback(chatId, store, targetEndMesId, currentLeng
json.characters.main = (json.characters.main || []).filter(m =>
typeof m === 'string' || (m._addedAt ?? 0) <= targetEndMesId
);
json.characters.relationships = (json.characters.relationships || []).filter(r =>
(r._addedAt ?? 0) <= targetEndMesId
);
}
// L3 回滚
json.world = (json.world || []).filter(w => (w._addedAt ?? 0) <= targetEndMesId);
// L3 facts 回滚
json.facts = (json.facts || []).filter(f => (f._addedAt ?? 0) <= targetEndMesId);
store.json = json;
store.lastSummarizedMesId = targetEndMesId;
@@ -278,14 +409,23 @@ export async function clearSummaryData(chatId) {
await clearEventVectors(chatId);
}
clearEventTextIndex();
xbLog.info(MODULE_ID, '总结数据已清空');
}
// ═══════════════════════════════════════════════════════════════════════════
// L3 数据读取(供 prompt.js 使用)
// L3 数据读取(供 prompt.js / recall.js 使用)
// ═══════════════════════════════════════════════════════════════════════════
export function getWorldSnapshot() {
export function getFacts() {
const store = getSummaryStore();
return store?.json?.world || [];
return (store?.json?.facts || []).filter(f => !f.retracted);
}
export function getNewCharacters() {
const store = getSummaryStore();
return (store?.json?.characters?.main || []).map(m =>
typeof m === 'string' ? m : m.name
);
}

View File

@@ -3,7 +3,7 @@
import { getContext } from "../../../../../../extensions.js";
import { xbLog } from "../../../core/debug-core.js";
import { getSummaryStore, saveSummaryStore, addSummarySnapshot, mergeNewData } from "../data/store.js";
import { getSummaryStore, saveSummaryStore, addSummarySnapshot, mergeNewData, getFacts } from "../data/store.js";
import { generateSummary, parseSummaryJson } from "./llm.js";
const MODULE_ID = 'summaryGenerator';
@@ -11,46 +11,56 @@ const SUMMARY_SESSION_ID = 'xb9';
const MAX_CAUSED_BY = 2;
// ═══════════════════════════════════════════════════════════════════════════
// worldUpdate 清洗
// factUpdates 清洗
// ═══════════════════════════════════════════════════════════════════════════
function sanitizeWorldUpdate(parsed) {
function normalizeRelationPredicate(p) {
if (/^对.+的看法$/.test(p)) return p;
if (/^与.+的关系$/.test(p)) return p;
return null;
}
function sanitizeFacts(parsed) {
if (!parsed) return;
const wu = Array.isArray(parsed.worldUpdate) ? parsed.worldUpdate : [];
const updates = Array.isArray(parsed.factUpdates) ? parsed.factUpdates : [];
const ok = [];
for (const item of wu) {
const category = String(item?.category || '').trim().toLowerCase();
const topic = String(item?.topic || '').trim();
for (const item of updates) {
const s = String(item?.s || '').trim();
const pRaw = String(item?.p || '').trim();
if (!category || !topic) continue;
if (!s || !pRaw) continue;
// status/knowledge/relation 必须包含 "::"
if (['status', 'knowledge', 'relation'].includes(category) && !topic.includes('::')) {
xbLog.warn(MODULE_ID, `丢弃不合格 worldUpdate: ${category}/${topic}`);
// 删除操作
if (item.retracted === true) {
ok.push({ s, p: pRaw, retracted: true });
continue;
}
if (item.cleared === true) {
ok.push({ category, topic, cleared: true });
continue;
const o = String(item?.o || '').trim();
if (!o) continue;
const relP = normalizeRelationPredicate(pRaw);
const isRel = !!relP;
const fact = { s, p: isRel ? relP : pRaw, o };
// 关系类保留 trend
if (isRel && item.trend) {
const validTrends = ['破裂', '厌恶', '反感', '陌生', '投缘', '亲密', '交融'];
if (validTrends.includes(item.trend)) {
fact.trend = item.trend;
}
}
const content = String(item?.content || '').trim();
if (!content) continue;
ok.push({ category, topic, content });
ok.push(fact);
}
parsed.worldUpdate = ok;
parsed.factUpdates = ok;
}
// ═══════════════════════════════════════════════════════════════════════════
// causedBy 清洗(事件因果边)
// - 允许引用:已存在事件 + 本次新输出事件
// - 限制长度0-2
// - 去重、剔除非法ID、剔除自引用
// ═══════════════════════════════════════════════════════════════════════════
function sanitizeEventsCausality(parsed, existingEventIds) {
@@ -61,7 +71,6 @@ function sanitizeEventsCausality(parsed, existingEventIds) {
const idRe = /^evt-\d+$/;
// 本次新输出事件ID集合允许引用
const newIds = new Set(
events
.map(e => String(e?.id || '').trim())
@@ -73,7 +82,6 @@ function sanitizeEventsCausality(parsed, existingEventIds) {
for (const e of events) {
const selfId = String(e?.id || '').trim();
if (!idRe.test(selfId)) {
// id 不合格的话causedBy 直接清空,避免污染
e.causedBy = [];
continue;
}
@@ -117,11 +125,6 @@ export function formatExistingSummaryForAI(store) {
parts.push(`\n【主要角色】${names.join("、")}`);
}
if (data.characters?.relationships?.length) {
parts.push("【人物关系】");
data.characters.relationships.forEach(r => parts.push(`- ${r.from}${r.to}${r.label}${r.trend}`));
}
if (data.arcs?.length) {
parts.push("【角色弧光】");
data.arcs.forEach(a => parts.push(`- ${a.name}${a.trajectory}(进度${Math.round(a.progress * 100)}%`));
@@ -187,7 +190,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
onStatus?.(`正在总结 ${slice.range}${slice.count}楼新内容)...`);
const existingSummary = formatExistingSummaryForAI(store);
const existingWorld = store?.json?.world || [];
const existingFacts = getFacts();
const nextEventId = getNextEventId(store);
const existingEventCount = store?.json?.events?.length || 0;
const useStream = config.trigger?.useStream !== false;
@@ -196,7 +199,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
try {
raw = await generateSummary({
existingSummary,
existingWorld,
existingFacts,
newHistoryText: slice.text,
historyRange: slice.range,
nextEventId,
@@ -231,7 +234,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
return { success: false, error: "parse" };
}
sanitizeWorldUpdate(parsed);
sanitizeFacts(parsed);
const existingEventIds = new Set((store?.json?.events || []).map(e => e?.id).filter(Boolean));
sanitizeEventsCausality(parsed, existingEventIds);
@@ -245,8 +248,8 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
xbLog.info(MODULE_ID, `总结完成,已更新至 ${slice.endMesId + 1}`);
if (parsed.worldUpdate?.length) {
xbLog.info(MODULE_ID, `世界状态更新: ${parsed.worldUpdate.length}`);
if (parsed.factUpdates?.length) {
xbLog.info(MODULE_ID, `Facts 更新: ${parsed.factUpdates.length}`);
}
const newEventIds = (parsed.events || []).map(e => e.id);
@@ -255,7 +258,7 @@ export async function runSummaryGeneration(mesId, config, callbacks = {}) {
merged,
endMesId: slice.endMesId,
newEventIds,
l3Stats: { worldUpdate: parsed.worldUpdate?.length || 0 },
factStats: { updated: parsed.factUpdates?.length || 0 },
});
return { success: true, merged, endMesId: slice.endMesId, newEventIds };

View File

@@ -1,7 +1,6 @@
// LLM Service
const PROVIDER_MAP = {
// ...
openai: "openai",
google: "gemini",
gemini: "gemini",
@@ -39,43 +38,37 @@ Incremental_Summary_Requirements:
- Causal_Chain: 为每个新事件标注直接前因事件IDcausedBy。仅在因果关系明确直接导致/明确动机/承接后果)时填写;不明确时填[]完全正常。0-2个只填 evt-数字,指向已存在或本次新输出事件。
- Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融)
- Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0)
- World_State_Tracking: 维护当前世界的硬性约束。解决"什么不能违反"。采用 KV 覆盖模型,追踪生死、物品归属、秘密知情、关系状态、环境规则等不可违背的事实。(覆盖式更新)
categories:
- status: 角色生死、位置锁定、重大状态
- inventory: 重要物品归属
- knowledge: 秘密的知情状态
- relation: 硬性关系(在一起/决裂)
- rule: 环境规则/契约限制
- Fact_Tracking: 维护 SPO 三元组知识图谱。追踪生死、物品归属、位置、关系等硬性事实。采用 KV 覆盖模型s+p 为键)。
</task_settings>
---
Story Analyst:
[Responsibility Definition]
\`\`\`yaml
analysis_task:
title: Incremental Story Summarization with World State
title: Incremental Story Summarization with Knowledge Graph
Story Analyst:
role: Antigravity
task: >-
To analyze provided dialogue content against existing summary state,
extract only NEW plot elements, character developments, relationship
changes, arc progressions, AND world state changes, outputting
changes, arc progressions, AND fact updates, outputting
structured JSON for incremental summary database updates.
assistant:
role: Summary Specialist
description: Incremental Story Summary & World State Analyst
description: Incremental Story Summary & Knowledge Graph Analyst
behavior: >-
To compare new dialogue against existing summary, identify genuinely
new events and character interactions, classify events by narrative
type and weight, track character arc progression with percentage,
maintain world state as key-value updates with clear flags,
maintain facts as SPO triples with clear semantics,
and output structured JSON containing only incremental updates.
Must strictly avoid repeating any existing summary content.
user:
role: Content Provider
description: Supplies existing summary state and new dialogue
behavior: >-
To provide existing summary state (events, characters, relationships,
arcs, world state) and new dialogue content for incremental analysis.
To provide existing summary state (events, characters, arcs, facts)
and new dialogue content for incremental analysis.
interaction_mode:
type: incremental_analysis
output_format: structured_json
@@ -84,7 +77,7 @@ execution_context:
summary_active: true
incremental_only: true
memory_album_style: true
world_state_tracking: true
fact_tracking: true
\`\`\`
---
Summary Specialist:
@@ -103,15 +96,18 @@ Acknowledged. Now reviewing the incremental summarization specifications:
破裂 ← 厌恶 ← 反感 ← 陌生 → 投缘 → 亲密 → 交融
[Arc Progress Tracking]
├─ trajectory: 完整弧光链描述(30字内)
├─ trajectory: 当前阶段描述(15字内)
├─ progress: 0.0 to 1.0
└─ newMoment: 仅记录本次新增的关键时刻
[World State Maintenance]
├─ 维护方式: Key-Value 覆盖category + topic 为键
├─ 只输出有变化的条目
├─ 清除时使用 cleared: true不要填 content
不记录情绪、衣着、临时动作
[Fact Tracking - SPO Triples]
├─ s: 主体(角色名/物品名
├─ p: 谓词(属性名)
│ - 关系类只允许对X的看法 / 与X的关系
o: 值(当前状态)
├─ trend: 仅关系类填写
├─ retracted: 删除标记
└─ s+p 为键,相同键会覆盖旧值
Ready to process incremental summary requests with strict deduplication.`,
@@ -119,19 +115,19 @@ Ready to process incremental summary requests with strict deduplication.`,
Summary Specialist:
Specifications internalized. Please provide the existing summary state so I can:
1. Index all recorded events to avoid duplication
2. Map current character relationships as baseline
2. Map current character list as baseline
3. Note existing arc progress levels
4. Identify established keywords
5. Review current world state (category + topic baseline)`,
5. Review current facts (SPO triples baseline)`,
assistantAskContent: `
Summary Specialist:
Existing summary fully analyzed and indexed. I understand:
├─ Recorded events: Indexed for deduplication
├─ Character relationships: Baseline mapped
├─ Character list: Baseline mapped
├─ Arc progress: Levels noted
├─ Keywords: Current state acknowledged
└─ World state: Baseline loaded
└─ Facts: SPO baseline loaded
I will extract only genuinely NEW elements from the upcoming dialogue.
Please provide the new dialogue content requiring incremental analysis.`,
@@ -152,7 +148,7 @@ Before generating, observe the USER and analyze carefully:
- What NEW characters appeared for the first time?
- What relationship CHANGES happened?
- What arc PROGRESS was made?
- What world state changes occurred? (status/inventory/knowledge/relation/rule)
- What facts changed? (status/position/ownership/relationships)
## Output Format
\`\`\`json
@@ -160,7 +156,7 @@ Before generating, observe the USER and analyze carefully:
"mindful_prelude": {
"user_insight": "用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
"dedup_analysis": "已有X个事件本次识别Y个新事件",
"world_changes": "识别到的世界状态变化概述,仅精选不记录则可能导致吃书的硬状态变化"
"fact_changes": "识别到的事实变化概述"
},
"keywords": [
{"text": "综合已有+新内容的全局关键词(5-10个)", "weight": "核心|重要|一般"}
@@ -178,45 +174,35 @@ Before generating, observe the USER and analyze carefully:
}
],
"newCharacters": ["仅本次首次出现的角色名"],
"newRelationships": [
{"from": "A", "to": "B", "label": "基于全局的关系描述", "trend": "破裂|厌恶|反感|陌生|投缘|亲密|交融"}
],
"arcUpdates": [
{"name": "角色名", "trajectory": "完整弧光链(30字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
{"name": "角色名", "trajectory": "当前阶段描述(15字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
],
"worldUpdate": [
"factUpdates": [
{
"category": "status|inventory|knowledge|relation|rule",
"topic": "主体名称(人/物/关系/规则",
"content": "当前状态描述",
"cleared": true
"s": "主体(角色名/物品名)",
"p": "谓词(属性名/对X的看法",
"o": "当前",
"trend": "破裂|厌恶|反感|陌生|投缘|亲密|交融",
"retracted": false
}
]
}
\`\`\`
## Field Guidelines
### worldUpdate世界状态·硬约束KV表
- category 固定 5 选 1status / inventory / knowledge / relation / rule
- topic 命名规范:
- status「角色名::状态类型」如 张三::生死、李四::位置、王五::伤势
- knowledge「角色名::知情事项」如 张三::知道某秘密、李四::知道真相
- relation「角色A::与角色B关系」如 张三::与李四关系
- inventory物品名称如 钥匙、信物、武器
- rule规则/契约名称,如 门禁时间、魔法契约、禁令
- content当前状态的简短描述
- cleared: true 表示该条目已失效需删除(不填 content
- status/knowledge/relation 的 topic 必须包含「::」分隔符
- 硬约束才记录,避免叙事化,确保少、硬、稳定、可覆盖
- 动态清理:若发现已有条目中存在不适合作为硬约束的内容(如衣着打扮、临时情绪、琐碎动作),本次输出中用 cleared: true 删除
## factUpdates 规则
- s+p 为键,相同键会覆盖旧值
- 状态类s=角色名, p=属性(生死/位置/状态等), o=值
- 关系类s=角色A, p="对B的看法" 或 p="与B的关系"trend 仅限关系类
- 删除:设置 retracted: true不需要填 o
- 只输出有变化的条目
- 硬约束才记录,避免叙事化,确保少、硬、稳定
## CRITICAL NOTES
- events.id 从 evt-{nextEventId} 开始编号
- 仅输出【增量】内容,已有事件绝不重复
- keywords 是全局关键词,综合已有+新增
- causedBy 仅在因果明确时填写,允许为[]0-2个,详见上方 Causal_Chain 规则
- worldUpdate 可为空数组
- causedBy 仅在因果明确时填写,允许为[]0-2个
- factUpdates 可为空数组
- 合法JSON字符串值内部避免英文双引号
- 用朴实、白描、有烟火气的笔触记录,避免比喻和意象
</meta_protocol>`,
@@ -227,15 +213,14 @@ Before generating, observe the USER and analyze carefully:
├─ New dialogue received: ✓ Content parsed
├─ Deduplication engine: ✓ Active
├─ Event classification: ✓ Ready
├─ World state tracking: ✓ Enabled
├─ Fact tracking: ✓ Enabled
└─ Output format: ✓ JSON specification loaded
[Material Verification]
├─ Existing events: Indexed ({existingEventCount} recorded)
├─ Character baseline: Mapped
├─ Relationship baseline: Mapped
├─ Arc progress baseline: Noted
├─ World state: Baseline loaded
├─ Facts baseline: Loaded
└─ Output specification: ✓ Defined in <meta_protocol>
All checks passed. Beginning incremental extraction...
{
@@ -280,39 +265,23 @@ function waitForStreamingComplete(sessionId, streamingMod, timeout = 120000) {
// 提示词构建
// ═══════════════════════════════════════════════════════════════════════════
function formatWorldForLLM(worldList) {
if (!worldList?.length) {
return '(空白,尚无世界状态记录)';
function formatFactsForLLM(facts) {
if (!facts?.length) {
return '(空白,尚无事实记录)';
}
const grouped = { status: [], inventory: [], knowledge: [], relation: [], rule: [] };
const labels = {
status: '状态(生死/位置锁定)',
inventory: '物品归属',
knowledge: '秘密/认知',
relation: '关系状态',
rule: '规则/约束'
};
worldList.forEach(w => {
if (grouped[w.category]) {
grouped[w.category].push(w);
const lines = facts.map(f => {
if (f.trend) {
return `- ${f.s} | ${f.p} | ${f.o} [${f.trend}]`;
}
return `- ${f.s} | ${f.p} | ${f.o}`;
});
const parts = [];
for (const [cat, items] of Object.entries(grouped)) {
if (items.length > 0) {
const lines = items.map(w => ` - ${w.topic}: ${w.content}`).join('\n');
parts.push(`${labels[cat]}\n${lines}`);
}
}
return parts.join('\n\n') || '(空白,尚无世界状态记录)';
return lines.join('\n') || '(空白,尚无事实记录)';
}
function buildSummaryMessages(existingSummary, existingWorld, newHistoryText, historyRange, nextEventId, existingEventCount) {
const worldStateText = formatWorldForLLM(existingWorld);
function buildSummaryMessages(existingSummary, existingFacts, newHistoryText, historyRange, nextEventId, existingEventCount) {
const factsText = formatFactsForLLM(existingFacts);
const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat
.replace(/\{nextEventId\}/g, String(nextEventId));
@@ -324,7 +293,7 @@ function buildSummaryMessages(existingSummary, existingWorld, newHistoryText, hi
{ role: 'system', content: LLM_PROMPT_CONFIG.topSystem },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantDoc },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskSummary },
{ role: 'user', content: `<已有总结状态>\n${existingSummary}\n</已有总结状态>\n\n<当前世界状态>\n${worldStateText}\n</当前世界状态>` },
{ role: 'user', content: `<已有总结状态>\n${existingSummary}\n</已有总结状态>\n\n<当前事实图谱>\n${factsText}\n</当前事实图谱>` },
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskContent },
{ role: 'user', content: `<新对话内容>${historyRange}\n${newHistoryText}\n</新对话内容>` }
];
@@ -378,7 +347,7 @@ export function parseSummaryJson(raw) {
export async function generateSummary(options) {
const {
existingSummary,
existingWorld,
existingFacts,
newHistoryText,
historyRange,
nextEventId,
@@ -401,7 +370,7 @@ export async function generateSummary(options) {
const promptData = buildSummaryMessages(
existingSummary,
existingWorld,
existingFacts,
newHistoryText,
historyRange,
nextEventId,

View File

@@ -6,7 +6,7 @@
import { getContext } from "../../../../../../extensions.js";
import { xbLog } from "../../../core/debug-core.js";
import { getSummaryStore } from "../data/store.js";
import { getSummaryStore, getFacts, isRelationFact } from "../data/store.js";
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
import { recallMemory, buildQueryText } from "../vector/recall.js";
import { getChunksByFloors, getAllChunkVectors, getAllEventVectors, getMeta } from "../vector/chunk-store.js";
@@ -91,9 +91,9 @@ function cleanSummary(summary) {
function buildSystemPreamble() {
return [
"以上内容为因上下文窗口限制保留的可见历史",
"以下【剧情记忆】是对可见与不可见历史的总结",
"• 【世界约束】记录着已确立的事实",
"以上是还留在眼前的对话",
"以下是脑海里的记忆",
"• [定了的事] 这些是不会变的",
"• 其余部分是过往经历的回忆碎片",
"",
"请内化这些记忆:",
@@ -103,7 +103,7 @@ function buildSystemPreamble() {
function buildPostscript() {
return [
"",
"——",
"这些记忆是真实的,请自然地记住它们。",
].join("\n");
}
@@ -111,10 +111,18 @@ function buildPostscript() {
// 格式化函数
// ─────────────────────────────────────────────────────────────────────────────
function formatWorldLines(world) {
return [...(world || [])]
.sort((a, b) => (b.floor || 0) - (a.floor || 0))
.map(w => `- ${w.topic}${w.content}`);
function formatFactsForInjection(facts) {
const activeFacts = (facts || []).filter(f => !f.retracted);
if (!activeFacts.length) return [];
return activeFacts
.sort((a, b) => (b.since || 0) - (a.since || 0))
.map(f => {
const since = f.since ? ` (#${f.since + 1})` : '';
if (isRelationFact(f) && f.trend) {
return `- ${f.s} ${f.p}: ${f.o} [${f.trend}]${since}`;
}
return `- ${f.s}${f.p}: ${f.o}${since}`;
});
}
function formatArcLine(a) {
@@ -179,85 +187,67 @@ function formatInjectionLog(stats, details, recentOrphanStats = null) {
const pct = (n, d) => (d > 0 ? Math.round((n / d) * 100) : 0);
const lines = [
"",
"╔══════════════════════════════════════════════════════════════╗",
"║ Prompt 装配报告 ║",
"╠══════════════════════════════════════════════════════════════╣",
` 总预算: ${stats.budget.max} tokens`,
`║ 已使用: ${stats.budget.used} tokens (${pct(stats.budget.used, stats.budget.max)}%)`,
`║ 剩余: ${stats.budget.max - stats.budget.used} tokens`,
"╚══════════════════════════════════════════════════════════════╝",
"",
'',
'┌─────────────────────────────────────────────────────────────┐',
'│ 【装配统计】 │',
'└─────────────────────────────────────────────────────────────┘',
` 总预算: ${stats.budget.max} tokens | 已使用: ${stats.budget.used} tokens (${pct(stats.budget.used, stats.budget.max)}%)`,
'',
];
// 世界状态
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [1] 世界约束 (上限 2000) │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` 注入: ${stats.world.count} 条 | ${stats.world.tokens} tokens`);
lines.push("");
// [1] 世界约束
lines.push(` [1] 世界约束 (上限 2000)`);
lines.push(` 选入: ${stats.facts.count} 条 | 消耗: ${stats.facts.tokens} tokens`);
lines.push('');
// 核心经历 + 过往背景
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [2] 核心经历 + 过往背景(含证据) │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` 选入: ${stats.events.selected} 条 | 事件本体: ${stats.events.tokens} tokens`);
lines.push(` 挂载证据: ${stats.evidence.attached} 条 | 证据: ${stats.evidence.tokens} tokens`);
lines.push(` 核心: ${details.directCount || 0} | 过往: ${details.similarCount || 0}`);
if (details.eventList?.length) {
lines.push(" ────────────────────────────────────────");
details.eventList.slice(0, 20).forEach((ev, i) => {
const type = ev.isDirect ? "核心" : "过往";
const hasE = ev.hasEvidence ? " +E" : "";
const title = (ev.title || "(无标题)").slice(0, 32);
lines.push(` ${String(i + 1).padStart(2)}. [${type}${hasE}] ${title} (${ev.tokens}tok)`);
});
if (details.eventList.length > 20) lines.push(` ... 还有 ${details.eventList.length - 20}`);
}
lines.push("");
// [2] 核心经历 + 过往背景
lines.push(` [2] 核心经历 + 过往背景`);
lines.push(` 事件: ${stats.events.selected} 条 | 消耗: ${stats.events.tokens} tokens`);
// 远期片段
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [3] 远期片段(已总结范围) │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` 注入: ${stats.orphans.injected} 条 | ${stats.orphans.tokens} tokens`);
lines.push("");
// 证据统计(区分 L0 和 L1
const l0EvidenceCount = details.eventList?.filter(e => e.hasL0Evidence)?.length || 0;
const l1EvidenceCount = (stats.evidence.attached || 0) - l0EvidenceCount;
lines.push(` 证据: ${stats.evidence.attached} 条 (L0: ${l0EvidenceCount}, L1: ${l1EvidenceCount}) | 消耗: ${stats.evidence.tokens} tokens`);
lines.push(` 核心: ${details.directCount || 0} 条 | 过往: ${details.similarCount || 0}`);
lines.push('');
// 待整理
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [4] 待整理(未总结范围,独立预算 5000 │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` 入: ${recentOrphanStats?.injected || 0}| ${recentOrphanStats?.tokens || 0} tokens`);
lines.push(` 楼层范围: ${recentOrphanStats?.floorRange || "N/A"}`);
lines.push("");
// [3] 远期片段
const l0OrphanCount = stats.orphans.l0Count || 0;
const l1OrphanCount = (stats.orphans.injected || 0) - l0OrphanCount;
lines.push(` [3] 远期片段 (已总结范围)`);
lines.push(` 入: ${stats.orphans.injected}(L0: ${l0OrphanCount}, L1: ${l1OrphanCount}) | 消耗: ${stats.orphans.tokens} tokens`);
lines.push('');
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ [5] 人物弧光(上限 1500 │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(` 注入: ${stats.arcs.count} 条 | ${stats.arcs.tokens} tokens`);
lines.push("");
// [4] 待整理
lines.push(` [4] 待整理 (独立预算 5000)`);
lines.push(` 选入: ${recentOrphanStats?.injected || 0} 条 | 消耗: ${recentOrphanStats?.tokens || 0} tokens`);
lines.push(` 楼层: ${recentOrphanStats?.floorRange || 'N/A'}`);
lines.push('');
// [5] 人物弧光
lines.push(` [5] 人物弧光 (上限 1500)`);
lines.push(` 选入: ${stats.arcs.count} 条 | 消耗: ${stats.arcs.tokens} tokens`);
lines.push('');
// 预算条形图
lines.push("┌─────────────────────────────────────────────────────────────┐");
lines.push("│ 【预算分布】 │");
lines.push("└─────────────────────────────────────────────────────────────┘");
lines.push(' 【预算分布】');
const total = stats.budget.max;
const bar = (tokens, label) => {
const width = Math.round((tokens / total) * 40);
const pctStr = pct(tokens, total) + "%";
return ` ${label.padEnd(6)} ${"█".repeat(width).padEnd(40)} ${String(tokens).padStart(5)} (${pctStr})`;
const width = Math.round((tokens / total) * 30);
const pctStr = pct(tokens, total) + '%';
return ` ${label.padEnd(6)} ${'█'.repeat(width).padEnd(30)} ${String(tokens).padStart(5)} (${pctStr})`;
};
lines.push(bar(stats.world.tokens, "约束"));
lines.push(bar(stats.events.tokens, "经历"));
lines.push(bar(stats.evidence.tokens, "证据"));
lines.push(bar(stats.orphans.tokens, "远期"));
lines.push(bar(recentOrphanStats?.tokens || 0, "待整理"));
lines.push(bar(stats.arcs.tokens, "弧光"));
lines.push(bar(stats.budget.max - stats.budget.used, "剩余"));
lines.push("");
lines.push(bar(stats.facts.tokens, '约束'));
lines.push(bar(stats.events.tokens + stats.evidence.tokens, '经历'));
lines.push(bar(stats.orphans.tokens, '远期'));
lines.push(bar(recentOrphanStats?.tokens || 0, '待整理'));
lines.push(bar(stats.arcs.tokens, '弧光'));
lines.push(bar(stats.budget.max - stats.budget.used, '剩余'));
lines.push('');
return lines.join("\n");
return lines.join('\n');
}
// 重写事件文本里的序号前缀:把 “{idx}. ” 或 “{idx}.【...】” 的 idx 替换
function renumberEventText(text, newIndex) {
const s = String(text || "");
@@ -281,9 +271,9 @@ function buildNonVectorPrompt(store) {
const data = store.json || {};
const sections = [];
if (data.world?.length) {
const lines = formatWorldLines(data.world);
sections.push(`[世界约束] 已确立的事实\n${lines.join("\n")}`);
const factLines = formatFactsForInjection(getFacts(store));
if (factLines.length) {
sections.push(`[定了的事] 已确立的事实\n${factLines.join("\n")}`);
}
if (data.events?.length) {
@@ -348,7 +338,7 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
// ═══════════════════════════════════════════════════════════════════
const assembled = {
world: { lines: [], tokens: 0 },
facts: { lines: [], tokens: 0 },
arcs: { lines: [], tokens: 0 },
events: { direct: [], similar: [] },
orphans: { lines: [], tokens: 0 },
@@ -357,7 +347,7 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
const injectionStats = {
budget: { max: TOTAL_BUDGET_MAX, used: 0 },
world: { count: 0, tokens: 0 },
facts: { count: 0, tokens: 0 },
arcs: { count: 0, tokens: 0 },
events: { selected: 0, tokens: 0 },
evidence: { attached: 0, tokens: 0 },
@@ -378,16 +368,16 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
// ═══════════════════════════════════════════════════════════════════
// [优先级 1] 世界约束 - 最高优先级
// ═══════════════════════════════════════════════════════════════════
const worldLines = formatWorldLines(data.world);
if (worldLines.length) {
const factLines = formatFactsForInjection(getFacts(store));
if (factLines.length) {
const l3Budget = { used: 0, max: Math.min(L3_MAX, total.max - total.used) };
for (const line of worldLines) {
if (!pushWithBudget(assembled.world.lines, line, l3Budget)) break;
for (const line of factLines) {
if (!pushWithBudget(assembled.facts.lines, line, l3Budget)) break;
}
assembled.world.tokens = l3Budget.used;
assembled.facts.tokens = l3Budget.used;
total.used += l3Budget.used;
injectionStats.world.count = assembled.world.lines.length;
injectionStats.world.tokens = l3Budget.used;
injectionStats.facts.count = assembled.facts.lines.length;
injectionStats.facts.tokens = l3Budget.used;
}
// ═══════════════════════════════════════════════════════════════════
@@ -612,49 +602,36 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
}
// ═══════════════════════════════════════════════════════════════════
// 按注入顺序拼接 sections
// ═══════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════
// 按注入顺序拼接 sections
// ═══════════════════════════════════════════════════════════════════════
const sections = [];
// 1. 世界约束 → 定了的事
if (assembled.facts.lines.length) {
sections.push(`[定了的事] 已确立的事实\n${assembled.facts.lines.join("\n")}`);
}
// 2. 核心经历 → 印象深的事
if (assembled.events.direct.length) {
sections.push(`[印象深的事] 记得很清楚\n\n${assembled.events.direct.join("\n\n")}`);
}
// 3. 过往背景 → 好像有关的事
if (assembled.events.similar.length) {
sections.push(`[好像有关的事] 听说过或有点模糊\n\n${assembled.events.similar.join("\n\n")}`);
}
// 4. 远期片段 → 更早以前
if (assembled.orphans.lines.length) {
sections.push(`[更早以前] 记忆里残留的老画面\n${assembled.orphans.lines.join("\n")}`);
}
// 5. 待整理 → 刚发生的
if (assembled.recentOrphans.lines.length) {
sections.push(`[刚发生的] 还没来得及想明白\n${assembled.recentOrphans.lines.join("\n")}`);
}
// 6. 人物弧光 → 这些人
if (assembled.arcs.lines.length) {
sections.push(`[这些人] 他们现在怎样了\n${assembled.arcs.lines.join("\n")}`);
}
const sections = [];
// 1. 世界约束
if (assembled.world.lines.length) {
sections.push(`[世界约束] 已确立的事实\n${assembled.world.lines.join("\n")}`);
}
// 2. 核心经历
if (assembled.events.direct.length) {
sections.push(`[核心经历] 深刻的记忆\n\n${assembled.events.direct.join("\n\n")}`);
}
// 3. 过往背景
if (assembled.events.similar.length) {
sections.push(`[过往背景] 听别人说起或比较模糊的往事\n\n${assembled.events.similar.join("\n\n")}`);
}
// 4. 远期片段
if (assembled.orphans.lines.length) {
sections.push(`[远期片段] 记忆里残留的一些老画面\n${assembled.orphans.lines.join("\n")}`);
}
// 5. 待整理
if (assembled.recentOrphans.lines.length) {
sections.push(`[待整理] 最近发生但尚未梳理的原始记忆\n${assembled.recentOrphans.lines.join("\n")}`);
}
// 6. 人物弧光(最后注入,但预算已在优先级 2 预留)
if (assembled.arcs.lines.length) {
sections.push(`[人物弧光]\n${assembled.arcs.lines.join("\n")}`);
}
// ═══════════════════════════════════════════════════════════════════
// 统计 & 返回
// ═══════════════════════════════════════════════════════════════════
// 总预算 = 主装配 + 待整理
injectionStats.budget.used = total.used + (recentOrphanStats.tokens || 0);
if (!sections.length) {
if (!sections.length) {
return { promptText: "", injectionLogText: "", injectionStats };
}
@@ -663,6 +640,8 @@ async function buildVectorPrompt(store, recallResult, causalById, queryEntities
`<剧情记忆>\n\n${sections.join("\n\n")}\n\n</剧情记忆>\n` +
`${buildPostscript()}`;
// ★ 修复:先写回预算统计,再生成日志
injectionStats.budget.used = total.used + (assembled.recentOrphans.tokens || 0);
const injectionLogText = formatInjectionLog(injectionStats, details, recentOrphanStats);
return { promptText, injectionLogText, injectionStats };

View File

@@ -60,7 +60,7 @@
events: { title: '编辑事件时间线', hint: '编辑时,每个事件要素都应完整' },
characters: { title: '编辑人物关系', hint: '编辑时,每个要素都应完整' },
arcs: { title: '编辑角色弧光', hint: '编辑时,每个要素都应完整' },
world: { title: '编辑世界状态', hint: '每行一条:category|topic|content。清除用category|topic|(留空)或 category|topic|cleared' }
facts: { title: '编辑事实图谱', hint: '每行一条:主体|谓词|值|趋势(可选)。删除用:主体|谓词|(留空值)' }
};
const TREND_COLORS = {
@@ -116,7 +116,7 @@
vector: { enabled: false, engine: 'online', local: { modelId: 'bge-small-zh' }, online: { provider: 'siliconflow', url: '', key: '', model: '' } }
};
let summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], world: [] };
let summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], facts: [] };
let localGenerating = false;
let vectorGenerating = false;
let relationChart = null;
@@ -1415,9 +1415,14 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
if (section === 'keywords') {
ta.value = summaryData.keywords.map(k => `${k.text}|${k.weight || '一般'}`).join('\n');
} else if (section === 'world') {
ta.value = (summaryData.world || [])
.map(w => `${w.category || ''}|${w.topic || ''}|${w.content || ''}`)
} else if (section === 'facts') {
ta.value = (summaryData.facts || [])
.filter(f => !f.retracted)
.map(f => {
const parts = [f.s, f.p, f.o];
if (f.trend) parts.push(f.trend);
return parts.join('|');
})
.join('\n');
} else {
ta.classList.add('hidden');
@@ -1496,21 +1501,32 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
moments
}, oldArc);
}).filter(a => a.name || a.trajectory || a.moments?.length);
} else if (section === 'world') {
const oldWorldMap = new Map((summaryData.world || []).map(w => [`${w.category}|${w.topic}`, w]));
} else if (section === 'facts') {
const oldMap = new Map((summaryData.facts || []).map(f => [`${f.s}::${f.p}`, f]));
parsed = ta.value
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.map(line => {
const parts = line.split('|').map(s => s.trim());
const category = parts[0];
const topic = parts[1];
const content = parts.slice(2).join('|').trim();
if (!category || !topic) return null;
if (!content || content.toLowerCase() === 'cleared') return null;
const key = `${category}|${topic}`;
return preserveAddedAt({ category, topic, content }, oldWorldMap.get(key));
const s = parts[0];
const p = parts[1];
const o = parts[2];
const trend = parts[3];
if (!s || !p) return null;
if (!o) return null;
const key = `${s}::${p}`;
const old = oldMap.get(key);
const fact = {
id: old?.id || `f-${Date.now()}`,
s, p, o,
since: old?.since ?? 0,
_addedAt: old?._addedAt ?? 0,
};
if (/^对.+的/.test(p) && trend) {
fact.trend = trend;
}
return fact;
})
.filter(Boolean);
}
@@ -1526,7 +1542,7 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
else if (section === 'events') { renderTimeline(parsed); $('stat-events').textContent = parsed.length; }
else if (section === 'characters') renderRelations(parsed);
else if (section === 'arcs') renderArcs(parsed);
else if (section === 'world') renderWorldState(parsed);
else if (section === 'facts') renderFacts(parsed);
closeEditor();
}
@@ -1565,7 +1581,7 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
if (p.events) renderTimeline(p.events);
if (p.characters) renderRelations(p.characters);
if (p.arcs) renderArcs(p.arcs);
if (p.world) renderWorldState(p.world);
if (p.facts) renderFacts(p.facts);
$('stat-events').textContent = p.events?.length || 0;
if (p.lastSummarizedMesId != null) $('stat-summarized').textContent = p.lastSummarizedMesId + 1;
if (p.stats) updateStats(p.stats);
@@ -1582,12 +1598,12 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
$('stat-summarized').textContent = 0;
$('stat-pending').textContent = t;
$('summarized-count').textContent = 0;
summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], world: [] };
summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [], facts: [] };
renderKeywords([]);
renderTimeline([]);
renderRelations(null);
renderArcs([]);
renderWorldState([]);
renderFacts([]);
break;
}
@@ -1829,7 +1845,7 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
renderKeywords([]);
renderTimeline([]);
renderArcs([]);
renderWorldState([]);
renderFacts([]);
bindEvents();
@@ -1845,51 +1861,40 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "
}
function renderWorldState(world) {
summaryData.world = world || [];
function renderFacts(facts) {
summaryData.facts = facts || [];
const container = $('world-state-list');
const container = $('facts-list');
if (!container) return;
if (!world?.length) {
setHtml(container, '<div class="empty">暂无世界状态</div>');
const isRelation = f => /^.+/.test(f.p);
const stateFacts = (facts || []).filter(f => !f.retracted && !isRelation(f));
if (!stateFacts.length) {
setHtml(container, '<div class="empty">暂无状态记录</div>');
return;
}
const labels = {
status: '状态',
inventory: '物品',
knowledge: '认知',
relation: '关系',
rule: '规则'
};
const grouped = new Map();
for (const f of stateFacts) {
if (!grouped.has(f.s)) grouped.set(f.s, []);
grouped.get(f.s).push(f);
}
const categoryOrder = ['status', 'inventory', 'relation', 'knowledge', 'rule'];
let html = '';
for (const [subject, items] of grouped) {
html += `<div class="fact-group">
<div class="fact-group-title">${h(subject)}</div>
${items.map(f => `
<div class="fact-item">
<span class="fact-predicate">${h(f.p)}</span>
<span class="fact-object">${h(f.o)}</span>
<span class="fact-since">#${(f.since || 0) + 1}</span>
</div>
`).join('')}
</div>`;
}
const grouped = {};
world.forEach(w => {
const cat = w.category || 'other';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(w);
});
const html = categoryOrder
.filter(cat => grouped[cat]?.length)
.map(cat => {
const items = grouped[cat].sort((a, b) => (b.floor || 0) - (a.floor || 0));
return `
<div class="world-group">
<div class="world-group-title">${labels[cat] || cat}</div>
${items.map(w => `
<div class="world-item">
<span class="world-topic">${h(w.topic)}</span>
<span class="world-content">${h(w.content)}</span>
</div>
`).join('')}
</div>
`;
}).join('');
setHtml(container, html || '<div class="empty">暂无世界状态</div>');
setHtml(container, html);
}
})();

View File

@@ -6,6 +6,79 @@
box-sizing: border-box;
}
/* ═══════════════════════════════════════════════════════════════════════════
Facts (替换 World State)
═══════════════════════════════════════════════════════════════════════════ */
.facts {
flex: 0 0 auto;
}
.facts-list {
max-height: 200px;
overflow-y: auto;
padding-right: 4px;
}
.fact-group {
margin-bottom: 12px;
}
.fact-group:last-child {
margin-bottom: 0;
}
.fact-group-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--hl);
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px dashed var(--bdr2);
}
.fact-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
margin-bottom: 4px;
background: var(--bg3);
border: 1px solid var(--bdr2);
border-radius: 4px;
font-size: 0.8125rem;
}
.fact-predicate {
color: var(--txt2);
min-width: 60px;
}
.fact-predicate::after {
content: '';
}
.fact-object {
color: var(--txt);
flex: 1;
}
.fact-since {
font-size: 0.625rem;
color: var(--txt3);
}
@media (max-width: 768px) {
.facts-list {
max-height: 180px;
}
.fact-item {
padding: 6px 8px;
font-size: 0.75rem;
}
}
:root {
--bg: #fafafa;
--bg2: #fff;

View File

@@ -80,13 +80,13 @@
</div>
<div class="right">
<!-- World State -->
<section class="card world-state">
<!-- Facts -->
<section class="card facts">
<div class="sec-head">
<div class="sec-title">世界状态</div>
<button class="sec-btn" data-section="world">编辑</button>
<div class="sec-title">世界状态</div>
<button class="sec-btn" data-section="facts">编辑</button>
</div>
<div class="world-state-list scroll" id="world-state-list"></div>
<div class="facts-list scroll" id="facts-list"></div>
</section>
<!-- Relations -->

View File

@@ -30,6 +30,7 @@ import {
calcHideRange,
rollbackSummaryIfNeeded,
clearSummaryData,
extractRelationshipsFromFacts,
} from "./data/store.js";
// prompt text builder
@@ -109,6 +110,26 @@ let vectorAbortController = null;
let lastSentUserMessage = null;
let lastSentTimestamp = 0;
function captureUserInput() {
const text = $("#send_textarea").val();
if (text?.trim()) {
lastSentUserMessage = text.trim();
lastSentTimestamp = Date.now();
}
}
function onSendPointerdown(e) {
if (e.target?.closest?.("#send_but")) {
captureUserInput();
}
}
function onSendKeydown(e) {
if (e.key === "Enter" && !e.shiftKey && e.target?.closest?.("#send_textarea")) {
captureUserInput();
}
}
let hideApplyTimer = null;
const HIDE_APPLY_DEBOUNCE_MS = 250;
@@ -826,24 +847,32 @@ async function sendFrameBaseData(store, totalFloors) {
}
function sendFrameFullData(store, totalFloors) {
const lastSummarized = store?.lastSummarizedMesId ?? -1;
if (store?.json) {
postToFrame({
type: "SUMMARY_FULL_DATA",
payload: {
keywords: store.json.keywords || [],
events: store.json.events || [],
characters: store.json.characters || { main: [], relationships: [] },
arcs: store.json.arcs || [],
world: store.json.world || [],
lastSummarizedMesId: lastSummarized,
},
payload: buildFramePayload(store),
});
} else {
postToFrame({ type: "SUMMARY_CLEARED", payload: { totalFloors } });
}
}
function buildFramePayload(store) {
const json = store?.json || {};
const facts = json.facts || [];
return {
keywords: json.keywords || [],
events: json.events || [],
characters: {
main: json.characters?.main || [],
relationships: extractRelationshipsFromFacts(facts),
},
arcs: json.arcs || [],
facts,
lastSummarizedMesId: store?.lastSummarizedMesId ?? -1,
};
}
function openPanelForMessage(mesId) {
createOverlay();
showOverlay();
@@ -953,20 +982,11 @@ async function autoRunSummaryWithRetry(targetMesId, configForRun) {
onStatus: (text) => postToFrame({ type: "SUMMARY_STATUS", statusText: text }),
onError: (msg) => postToFrame({ type: "SUMMARY_ERROR", message: msg }),
onComplete: async ({ merged, endMesId, newEventIds }) => {
postToFrame({
type: "SUMMARY_FULL_DATA",
payload: {
keywords: merged.keywords || [],
events: merged.events || [],
characters: merged.characters || { main: [], relationships: [] },
arcs: merged.arcs || [],
world: merged.world || [],
lastSummarizedMesId: endMesId,
},
});
const store = getSummaryStore();
postToFrame({ type: "SUMMARY_FULL_DATA", payload: buildFramePayload(store) });
applyHideStateDebounced();
updateFrameStatsAfterSummary(endMesId, merged);
updateFrameStatsAfterSummary(endMesId, store.json || {});
// L2 自动增量向量化
await autoVectorizeNewEvents(newEventIds);
@@ -1253,20 +1273,11 @@ async function handleManualGenerate(mesId, config) {
onStatus: (text) => postToFrame({ type: "SUMMARY_STATUS", statusText: text }),
onError: (msg) => postToFrame({ type: "SUMMARY_ERROR", message: msg }),
onComplete: async ({ merged, endMesId, newEventIds }) => {
postToFrame({
type: "SUMMARY_FULL_DATA",
payload: {
keywords: merged.keywords || [],
events: merged.events || [],
characters: merged.characters || { main: [], relationships: [] },
arcs: merged.arcs || [],
world: merged.world || [],
lastSummarizedMesId: endMesId,
},
});
const store = getSummaryStore();
postToFrame({ type: "SUMMARY_FULL_DATA", payload: buildFramePayload(store) });
applyHideStateDebounced();
updateFrameStatsAfterSummary(endMesId, merged);
updateFrameStatsAfterSummary(endMesId, store.json || {});
// L2 自动增量向量化
await autoVectorizeNewEvents(newEventIds);
@@ -1483,6 +1494,10 @@ function registerEvents() {
eventSource.on(event_types.USER_MESSAGE_RENDERED, (data) => setTimeout(() => handleMessageRendered(data), 50));
eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, (data) => setTimeout(() => handleMessageRendered(data), 50));
// 用户输入捕获(原生捕获阶段)
document.addEventListener("pointerdown", onSendPointerdown, true);
document.addEventListener("keydown", onSendKeydown, true);
// 注入链路
eventSource.on(event_types.GENERATION_STARTED, handleGenerationStarted);
eventSource.on(event_types.GENERATION_STOPPED, clearExtensionPrompt);
@@ -1497,6 +1512,9 @@ function unregisterEvents() {
hideOverlay();
clearExtensionPrompt();
document.removeEventListener("pointerdown", onSendPointerdown, true);
document.removeEventListener("keydown", onSendKeydown, true);
}
// ═══════════════════════════════════════════════════════════════════════════

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
// text-search.js - 最终版
import MiniSearch from '../../../libs/minisearch.mjs';
const STOP_WORDS = new Set([
'的', '了', '是', '在', '和', '与', '或', '但', '而', '却',
'这', '那', '他', '她', '它', '我', '你', '们', '着', '过',
'把', '被', '给', '让', '向', '就', '都', '也', '还', '又',
'很', '太', '更', '最', '只', '才', '已', '正', '会', '能',
'要', '可', '得', '地', '之', '所', '以', '为', '于', '有',
'不', '去', '来', '上', '下', '里', '说', '看', '吧', '呢',
'啊', '吗', '呀', '哦', '嗯', '么',
'の', 'に', 'は', 'を', 'が', 'と', 'で', 'へ', 'や', 'か',
'も', 'な', 'よ', 'ね', 'わ', 'です', 'ます', 'した', 'ない',
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
'to', 'of', 'in', 'on', 'at', 'for', 'with', 'by', 'from',
'and', 'or', 'but', 'if', 'that', 'this', 'it', 'its',
'i', 'you', 'he', 'she', 'we', 'they', 'my', 'your', 'his',
]);
function tokenize(text) {
const s = String(text || '').toLowerCase().trim();
if (!s) return [];
const tokens = new Set();
// CJK Bigram + Trigram
const cjk = s.match(/[\u4e00-\u9fff\u3400-\u4dbf]+/g) || [];
for (const seg of cjk) {
const chars = [...seg].filter(c => !STOP_WORDS.has(c));
for (let i = 0; i < chars.length - 1; i++) {
tokens.add(chars[i] + chars[i + 1]);
}
for (let i = 0; i < chars.length - 2; i++) {
tokens.add(chars[i] + chars[i + 1] + chars[i + 2]);
}
}
// 日语假名
const kana = s.match(/[\u3040-\u309f\u30a0-\u30ff]{2,}/g) || [];
for (const k of kana) {
if (!STOP_WORDS.has(k)) tokens.add(k);
}
// 英文
const en = s.match(/[a-z]{2,}/g) || [];
for (const w of en) {
if (!STOP_WORDS.has(w)) tokens.add(w);
}
return [...tokens];
}
let idx = null;
let lastRevision = null;
function stripFloorTag(s) {
return String(s || '').replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, '').trim();
}
export function ensureEventTextIndex(events, revision) {
if (!events?.length) {
idx = null;
lastRevision = null;
return;
}
if (idx && revision === lastRevision) return;
try {
idx = new MiniSearch({
fields: ['title', 'summary', 'participants'],
storeFields: ['id'],
tokenize,
searchOptions: { tokenize },
});
idx.addAll(events.map(e => ({
id: e.id,
title: e.title || '',
summary: stripFloorTag(e.summary),
participants: (e.participants || []).join(' '),
})));
lastRevision = revision;
} catch (e) {
console.error('[text-search] Index build failed:', e);
idx = null;
}
}
/**
* BM25 检索,返回 top-K 候选给 RRF
*
* 设计原则:
* - 不做分数过滤BM25 分数跨查询不可比)
* - 不做匹配数过滤bigram 让一个词产生多个 token
* - 只做 top-KBM25 排序本身有区分度)
* - 质量过滤交给 RRF 后的 hasVector 过滤
*/
/**
* 动态 top-K累积分数占比法
*
* 原理BM25 分数服从幂律分布,少数高分条目贡献大部分总分
* 取累积分数达到阈值的最小 K
*
* 参考帕累托法则80/20 法则)在信息检索中的应用
*/
function dynamicTopK(scores, coverage = 0.90, minK = 15, maxK = 80) {
if (!scores.length) return 0;
const total = scores.reduce((a, b) => a + b, 0);
if (total <= 0) return Math.min(minK, scores.length);
let cumulative = 0;
for (let i = 0; i < scores.length; i++) {
cumulative += scores[i];
if (cumulative / total >= coverage) {
return Math.max(minK, Math.min(maxK, i + 1));
}
}
return Math.min(maxK, scores.length);
}
export function searchEventsByText(queryText, limit = 80) {
if (!idx || !queryText?.trim()) return [];
try {
const results = idx.search(queryText, {
boost: { title: 4, participants: 2, summary: 1 },
fuzzy: false,
prefix: false,
});
if (!results.length) return [];
const scores = results.map(r => r.score);
const k = dynamicTopK(scores, 0.90, 15, limit);
const output = results.slice(0, k).map((r, i) => ({
id: r.id,
textRank: i + 1,
score: r.score,
}));
const total = scores.reduce((a, b) => a + b, 0);
const kCumulative = scores.slice(0, k).reduce((a, b) => a + b, 0);
output._gapInfo = {
total: results.length,
returned: k,
coverage: ((kCumulative / total) * 100).toFixed(1) + '%',
scoreRange: {
top: scores[0]?.toFixed(1),
cutoff: scores[k - 1]?.toFixed(1),
p50: scores[Math.floor(scores.length / 2)]?.toFixed(1),
last: scores[scores.length - 1]?.toFixed(1),
},
};
return output;
} catch (e) {
console.error('[text-search] Search failed:', e);
return [];
}
}
export function clearEventTextIndex() {
idx = null;
lastRevision = null;
}

View File

@@ -0,0 +1,287 @@
import { xbLog } from '../../../core/debug-core.js';
import { extensionFolderPath } from '../../../core/constants.js';
const MODULE_ID = 'tokenizer';
// ═══════════════════════════════════════════════════════════════════════════
// 词性过滤
// ═══════════════════════════════════════════════════════════════════════════
// 保留的词性(名词类 + 英文)
const KEEP_POS_PREFIXES = ['n', 'eng'];
function shouldKeepByPos(pos) {
return KEEP_POS_PREFIXES.some(prefix => pos.startsWith(prefix));
}
// ═══════════════════════════════════════════════════════════════════════════
// 语言检测
// ═══════════════════════════════════════════════════════════════════════════
function shouldUseJieba(text) {
const zh = (text.match(/[\u4e00-\u9fff]/g) || []).length;
return zh >= 5;
}
function detectMainLanguage(text) {
const zh = (text.match(/[\u4e00-\u9fff]/g) || []).length;
const jp = (text.match(/[\u3040-\u309f\u30a0-\u30ff]/g) || []).length;
const en = (text.match(/[a-zA-Z]/g) || []).length;
const total = zh + jp + en || 1;
if (jp / total > 0.2) return 'jp';
if (en / total > 0.5) return 'en';
return 'zh';
}
// 替换原有的大停用词表
const STOP_WORDS = new Set([
// 系统词
'用户', '角色', '玩家', '旁白', 'user', 'assistant', 'system',
// 时间泛词
'时候', '现在', '今天', '明天', '昨天', '早上', '晚上',
// 方位泛词
'这里', '那里', '上面', '下面', '里面', '外面',
// 泛化名词
'东西', '事情', '事儿', '地方', '样子', '意思', '感觉',
'一下', '一些', '一点', '一会', '一次',
]);
// 英文停用词fallback 用)
const EN_STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
'could', 'should', 'may', 'might', 'must', 'can',
'to', 'of', 'in', 'on', 'at', 'for', 'with', 'by', 'from',
'and', 'or', 'but', 'if', 'that', 'this', 'it', 'its',
'i', 'you', 'he', 'she', 'we', 'they',
'my', 'your', 'his', 'her', 'our', 'their',
'what', 'which', 'who', 'whom', 'where', 'when', 'why', 'how',
]);
let jiebaModule = null;
let jiebaReady = false;
let jiebaLoading = false;
async function ensureJieba() {
if (jiebaReady) return true;
if (jiebaLoading) {
for (let i = 0; i < 50; i++) {
await new Promise(r => setTimeout(r, 100));
if (jiebaReady) return true;
}
return false;
}
jiebaLoading = true;
try {
const jiebaPath = `/${extensionFolderPath}/libs/jieba-wasm/jieba_rs_wasm.js`;
// eslint-disable-next-line no-unsanitized/method
jiebaModule = await import(jiebaPath);
if (jiebaModule.default) {
await jiebaModule.default();
}
jiebaReady = true;
xbLog.info(MODULE_ID, 'jieba-wasm 加载成功');
const keys = Object.getOwnPropertyNames(jiebaModule || {});
const dkeys = Object.getOwnPropertyNames(jiebaModule?.default || {});
xbLog.info(MODULE_ID, `jieba keys: ${keys.join(',')}`);
xbLog.info(MODULE_ID, `jieba default keys: ${dkeys.join(',')}`);
xbLog.info(MODULE_ID, `jieba.tag: ${typeof jiebaModule?.tag}`);
return true;
} catch (e) {
xbLog.error(MODULE_ID, 'jieba-wasm 加载失败', e);
jiebaLoading = false;
return false;
}
}
function fallbackTokenize(text) {
const tokens = [];
const lang = detectMainLanguage(text);
// 英文
const enMatches = text.match(/[a-zA-Z]{2,20}/gi) || [];
tokens.push(...enMatches.filter(w => !EN_STOP_WORDS.has(w.toLowerCase())));
// 日语假名
if (lang === 'jp') {
const kanaMatches = text.match(/[\u3040-\u309f\u30a0-\u30ff]{2,10}/g) || [];
tokens.push(...kanaMatches);
}
// 中文/日语汉字
const zhMatches = text.match(/[\u4e00-\u9fff]{2,6}/g) || [];
tokens.push(...zhMatches);
// 数字+汉字组合
const numZhMatches = text.match(/\d+[\u4e00-\u9fff]{1,4}/g) || [];
tokens.push(...numZhMatches);
return tokens;
}
export async function extractNouns(text, options = {}) {
const { minLen = 2, maxCount = 0 } = options;
if (!text?.trim()) return [];
// 中文为主 → 用 jieba
if (shouldUseJieba(text)) {
const hasJieba = await ensureJieba();
if (hasJieba && jiebaModule?.tag) {
try {
const tagged = jiebaModule.tag(text, true);
const result = [];
const seen = new Set();
const list = Array.isArray(tagged) ? tagged : [];
for (const item of list) {
let word = '';
let pos = '';
if (Array.isArray(item)) {
[word, pos] = item;
} else if (item && typeof item === 'object') {
word = item.word || item.w || item.text || item.term || '';
pos = item.tag || item.pos || item.p || '';
}
if (!word || !pos) continue;
if (word.length < minLen) continue;
if (!shouldKeepByPos(pos)) continue;
if (STOP_WORDS.has(word)) continue;
if (seen.has(word)) continue;
seen.add(word);
result.push(word);
if (maxCount > 0 && result.length >= maxCount) break;
}
return result;
} catch (e) {
xbLog.warn(MODULE_ID, 'jieba tag 失败:' + (e && e.message ? e.message : String(e)));
}
}
}
// 非中文 / jieba 失败 → fallback
const tokens = fallbackTokenize(text);
const result = [];
const seen = new Set();
for (const t of tokens) {
if (t.length < minLen) continue;
if (STOP_WORDS.has(t)) continue;
if (seen.has(t)) continue;
seen.add(t);
result.push(t);
if (maxCount > 0 && result.length >= maxCount) break;
}
return result;
}
export async function extractRareTerms(text, maxCount = 15) {
if (!text?.trim()) return [];
// 中文为主 → 用 jieba
if (shouldUseJieba(text)) {
const hasJieba = await ensureJieba();
if (hasJieba && jiebaModule?.tag) {
try {
const tagged = jiebaModule.tag(text, true);
const candidates = [];
const seen = new Set();
const list = Array.isArray(tagged) ? tagged : [];
for (const item of list) {
let word = '';
let pos = '';
if (Array.isArray(item)) {
[word, pos] = item;
} else if (item && typeof item === 'object') {
word = item.word || item.w || item.text || item.term || '';
pos = item.tag || item.pos || item.p || '';
}
if (!word || !pos) continue;
if (word.length < 2) continue;
if (!shouldKeepByPos(pos)) continue;
if (STOP_WORDS.has(word)) continue;
if (seen.has(word)) continue;
seen.add(word);
// 稀有度评分
let score = 0;
if (word.length >= 4) score += 3;
else if (word.length >= 3) score += 1;
if (/[a-zA-Z]/.test(word)) score += 2;
if (/\d/.test(word)) score += 1;
// 专名词性加分
if (['nr', 'ns', 'nt', 'nz'].some(p => pos.startsWith(p))) score += 2;
candidates.push({ term: word, score });
}
candidates.sort((a, b) => b.score - a.score);
return candidates.slice(0, maxCount).map(x => x.term);
} catch (e) {
xbLog.warn(MODULE_ID, 'jieba tag 失败:' + (e && e.message ? e.message : String(e)));
}
}
}
// 非中文 / jieba 失败 → fallback
const allNouns = await extractNouns(text, { minLen: 2, maxCount: 0 });
const scored = allNouns.map(t => {
let score = 0;
if (t.length >= 4) score += 3;
else if (t.length >= 3) score += 1;
if (/[a-zA-Z]/.test(t)) score += 2;
if (/\d/.test(t)) score += 1;
return { term: t, score };
});
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, maxCount).map(x => x.term);
}
export async function extractNounsFromFactsO(facts, relevantSubjects, maxCount = 5) {
if (!facts?.length || !relevantSubjects?.size) return [];
const oTexts = [];
for (const f of facts) {
if (f.retracted) continue;
// 只取相关主体的 facts
const s = String(f.s || '').trim();
if (!relevantSubjects.has(s)) continue;
const o = String(f.o || '').trim();
if (!o) continue;
// 跳过太长的 O可能是完整句子
if (o.length > 30) continue;
oTexts.push(o);
}
if (!oTexts.length) return [];
const combined = oTexts.join(' ');
return await extractNouns(combined, { minLen: 2, maxCount });
}
export { ensureJieba };

View File

@@ -1,22 +1,343 @@
import { getContext } from '../../../../../../extensions.js';
import {
lwbResolveVarPath,
lwbAssignVarPath,
lwbAddVarPath,
lwbPushVarPath,
lwbDeleteVarPath,
lwbRemoveArrayItemByValue,
} from '../var-commands.js';
import { lwbSplitPathWithBrackets } from '../../../core/variable-path.js';
import { getLocalVariable, setLocalVariable } from '../../../../../../variables.js';
import { extractStateBlocks, computeStateSignature, parseStateBlock } from './parser.js';
import { generateSemantic } from './semantic.js';
const LWB_STATE_APPLIED_KEY = 'LWB_STATE_APPLIED_KEY';
import { validate, setRule, loadRulesFromMeta, saveRulesToMeta } from './guard.js';
/**
* chatMetadata 内记录每楼层 signature防止重复执行
* =========================
* Path / JSON helpers
* =========================
*/
function splitPath(path) {
const s = String(path || '');
const segs = [];
let buf = '';
let i = 0;
while (i < s.length) {
const ch = s[i];
if (ch === '.') {
if (buf) { segs.push(/^\d+$/.test(buf) ? Number(buf) : buf); buf = ''; }
i++;
} else if (ch === '[') {
if (buf) { segs.push(/^\d+$/.test(buf) ? Number(buf) : buf); buf = ''; }
i++;
let val = '';
if (s[i] === '"' || s[i] === "'") {
const q = s[i++];
while (i < s.length && s[i] !== q) val += s[i++];
i++;
} else {
while (i < s.length && s[i] !== ']') val += s[i++];
}
if (s[i] === ']') i++;
segs.push(/^\d+$/.test(val.trim()) ? Number(val.trim()) : val.trim());
} else {
buf += ch;
i++;
}
}
if (buf) segs.push(/^\d+$/.test(buf) ? Number(buf) : buf);
return segs;
}
function normalizePath(path) {
return splitPath(path).map(String).join('.');
}
function safeJSON(v) {
try { return JSON.stringify(v); } catch { return ''; }
}
function safeParse(s) {
if (s == null || s === '') return undefined;
if (typeof s !== 'string') return s;
const t = s.trim();
if (!t) return undefined;
if (t[0] === '{' || t[0] === '[') {
try { return JSON.parse(t); } catch { return s; }
}
if (/^-?\d+(?:\.\d+)?$/.test(t)) return Number(t);
if (t === 'true') return true;
if (t === 'false') return false;
return s;
}
function deepClone(obj) {
try { return structuredClone(obj); } catch {
try { return JSON.parse(JSON.stringify(obj)); } catch { return obj; }
}
}
/**
* =========================
* Variable getters/setters (local vars)
* =========================
*/
function getVar(path) {
const segs = splitPath(path);
if (!segs.length) return undefined;
const rootRaw = getLocalVariable(String(segs[0]));
if (segs.length === 1) return safeParse(rootRaw);
let obj = safeParse(rootRaw);
if (!obj || typeof obj !== 'object') return undefined;
for (let i = 1; i < segs.length; i++) {
obj = obj?.[segs[i]];
if (obj === undefined) return undefined;
}
return obj;
}
function setVar(path, value) {
const segs = splitPath(path);
if (!segs.length) return;
const rootName = String(segs[0]);
if (segs.length === 1) {
const toStore = (value && typeof value === 'object') ? safeJSON(value) : String(value ?? '');
setLocalVariable(rootName, toStore);
return;
}
let root = safeParse(getLocalVariable(rootName));
if (!root || typeof root !== 'object') {
root = typeof segs[1] === 'number' ? [] : {};
}
let cur = root;
for (let i = 1; i < segs.length - 1; i++) {
const key = segs[i];
const nextKey = segs[i + 1];
if (cur[key] == null || typeof cur[key] !== 'object') {
cur[key] = typeof nextKey === 'number' ? [] : {};
}
cur = cur[key];
}
cur[segs[segs.length - 1]] = value;
setLocalVariable(rootName, safeJSON(root));
}
function delVar(path) {
const segs = splitPath(path);
if (!segs.length) return;
const rootName = String(segs[0]);
if (segs.length === 1) {
setLocalVariable(rootName, '');
return;
}
let root = safeParse(getLocalVariable(rootName));
if (!root || typeof root !== 'object') return;
let cur = root;
for (let i = 1; i < segs.length - 1; i++) {
cur = cur?.[segs[i]];
if (!cur || typeof cur !== 'object') return;
}
const lastKey = segs[segs.length - 1];
if (Array.isArray(cur) && typeof lastKey === 'number') {
cur.splice(lastKey, 1);
} else {
delete cur[lastKey];
}
setLocalVariable(rootName, safeJSON(root));
}
function pushVar(path, value) {
const segs = splitPath(path);
if (!segs.length) return { ok: false, reason: 'invalid-path' };
const rootName = String(segs[0]);
if (segs.length === 1) {
let arr = safeParse(getLocalVariable(rootName));
// ✅ 类型检查:必须是数组或不存在
if (arr !== undefined && !Array.isArray(arr)) {
return { ok: false, reason: 'not-array' };
}
if (!Array.isArray(arr)) arr = [];
const items = Array.isArray(value) ? value : [value];
arr.push(...items);
setLocalVariable(rootName, safeJSON(arr));
return { ok: true };
}
let root = safeParse(getLocalVariable(rootName));
if (!root || typeof root !== 'object') {
root = typeof segs[1] === 'number' ? [] : {};
}
let cur = root;
for (let i = 1; i < segs.length - 1; i++) {
const key = segs[i];
const nextKey = segs[i + 1];
if (cur[key] == null || typeof cur[key] !== 'object') {
cur[key] = typeof nextKey === 'number' ? [] : {};
}
cur = cur[key];
}
const lastKey = segs[segs.length - 1];
let arr = cur[lastKey];
// ✅ 类型检查:必须是数组或不存在
if (arr !== undefined && !Array.isArray(arr)) {
return { ok: false, reason: 'not-array' };
}
if (!Array.isArray(arr)) arr = [];
const items = Array.isArray(value) ? value : [value];
arr.push(...items);
cur[lastKey] = arr;
setLocalVariable(rootName, safeJSON(root));
return { ok: true };
}
function popVar(path, value) {
const segs = splitPath(path);
if (!segs.length) return { ok: false, reason: 'invalid-path' };
const rootName = String(segs[0]);
let root = safeParse(getLocalVariable(rootName));
if (segs.length === 1) {
if (!Array.isArray(root)) {
return { ok: false, reason: 'not-array' };
}
const toRemove = Array.isArray(value) ? value : [value];
for (const v of toRemove) {
const vStr = safeJSON(v);
const idx = root.findIndex(x => safeJSON(x) === vStr);
if (idx !== -1) root.splice(idx, 1);
}
setLocalVariable(rootName, safeJSON(root));
return { ok: true };
}
if (!root || typeof root !== 'object') {
return { ok: false, reason: 'not-array' };
}
let cur = root;
for (let i = 1; i < segs.length - 1; i++) {
cur = cur?.[segs[i]];
if (!cur || typeof cur !== 'object') {
return { ok: false, reason: 'path-not-found' };
}
}
const lastKey = segs[segs.length - 1];
let arr = cur[lastKey];
if (!Array.isArray(arr)) {
return { ok: false, reason: 'not-array' };
}
const toRemove = Array.isArray(value) ? value : [value];
for (const v of toRemove) {
const vStr = safeJSON(v);
const idx = arr.findIndex(x => safeJSON(x) === vStr);
if (idx !== -1) arr.splice(idx, 1);
}
setLocalVariable(rootName, safeJSON(root));
return { ok: true };
}
/**
* =========================
* Storage (chat_metadata.extensions.LittleWhiteBox)
* =========================
*/
const EXT_ID = 'LittleWhiteBox';
const 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] ||= {};
@@ -25,8 +346,7 @@ function getAppliedMap() {
export function clearStateAppliedFor(floor) {
try {
const map = getAppliedMap();
delete map[floor];
delete getAppliedMap()[floor];
getContext()?.saveMetadataDebounced?.();
} catch {}
}
@@ -35,44 +355,24 @@ export function clearStateAppliedFrom(floorInclusive) {
try {
const map = getAppliedMap();
for (const k of Object.keys(map)) {
const id = Number(k);
if (!Number.isNaN(id) && id >= floorInclusive) delete map[k];
if (Number(k) >= floorInclusive) delete map[k];
}
getContext()?.saveMetadataDebounced?.();
} catch {}
}
function safeParseAny(str) {
if (str == null || str === '') return undefined;
if (typeof str !== 'string') return str;
const t = str.trim();
if (!t) return undefined;
if (t[0] === '{' || t[0] === '[') {
try { return JSON.parse(t); } catch { return str; }
}
if (/^-?\d+(?:\.\d+)?$/.test(t)) return Number(t);
if (t === 'true') return true;
if (t === 'false') return false;
return str;
}
function isIndexDeleteOp(opItem) {
if (!opItem || opItem.op !== 'del') return false;
const segs = lwbSplitPathWithBrackets(opItem.path);
const segs = splitPath(opItem.path);
if (!segs.length) return false;
const last = segs[segs.length - 1];
return typeof last === 'number' && Number.isFinite(last);
}
function buildParentPathFromSegs(segs) {
return segs.reduce((acc, s) => {
if (typeof s === 'number') return `${acc}[${s}]`;
return acc ? `${acc}.${s}` : String(s);
}, '');
}
function buildExecOpsWithIndexDeleteReorder(ops) {
const groups = new Map(); // parentPath -> [{ op, idx }]
// 同一个数组的 index-del按 parentPath 分组,组内 index 倒序
// 其它操作:保持原顺序
const groups = new Map(); // parentPath -> { order, items: [{...opItem, index}] }
const groupOrder = new Map();
let orderCounter = 0;
@@ -80,9 +380,12 @@ function buildExecOpsWithIndexDeleteReorder(ops) {
for (const op of ops) {
if (isIndexDeleteOp(op)) {
const segs = lwbSplitPathWithBrackets(op.path);
const segs = splitPath(op.path);
const idx = segs[segs.length - 1];
const parentPath = buildParentPathFromSegs(segs.slice(0, -1));
const parentPath = segs.slice(0, -1).reduce((acc, s) => {
if (typeof s === 'number') return acc + `[${s}]`;
return acc ? `${acc}.${s}` : String(s);
}, '');
if (!groups.has(parentPath)) {
groups.set(parentPath, []);
@@ -94,85 +397,149 @@ function buildExecOpsWithIndexDeleteReorder(ops) {
}
}
const orderedParents = Array.from(groups.keys()).sort(
(a, b) => (groupOrder.get(a) ?? 0) - (groupOrder.get(b) ?? 0)
);
// 按“该数组第一次出现的顺序”输出各组(可预测)
const orderedParents = Array.from(groups.keys()).sort((a, b) => (groupOrder.get(a) ?? 0) - (groupOrder.get(b) ?? 0));
const reorderedIndexDeletes = [];
for (const parent of orderedParents) {
const items = groups.get(parent) || [];
// 关键:倒序
items.sort((a, b) => b.idx - a.idx);
for (const it of items) reorderedIndexDeletes.push(it.op);
}
// ✅ 我们把“索引删除”放在最前面执行:这样它们永远按“原索引”删
// (避免在同一轮里先删后 push 导致索引变化)
return [...reorderedIndexDeletes, ...normalOps];
}
/**
* 变量 2.0:执行单条消息里的 <state>,返回 atoms
* =========================
* 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);
// 没有 state清理旧 signature避免“删掉 state 后仍然认为执行过”)
if (!signature) {
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 };
}
// 幂等signature 没变就跳过
const appliedMap = getAppliedMap();
if (appliedMap[messageId] === signature) {
return { atoms: [], errors: [], skipped: true };
}
const blocks = extractStateBlocks(text);
const atoms = [];
const errors = [];
let idx = 0;
const mergedRules = [];
const mergedOps = [];
for (const block of blocks) {
const ops = parseStateBlock(block);
const execOps = buildExecOpsWithIndexDeleteReorder(ops);
const parsed = parseStateBlock(block);
mergedRules.push(...(parsed?.rules || []));
mergedOps.push(...(parsed?.ops || []));
}
if (blocks.length) {
// ✅ WAL一次写入完整的 rules/ops
saveWalRecord(messageId, signature, mergedRules, mergedOps);
// ✅ rules 一次性注册
let rulesTouched = false;
for (const { path, rule } of mergedRules) {
if (path && rule && Object.keys(rule).length) {
setRule(normalizePath(path), rule);
rulesTouched = true;
}
}
if (rulesTouched) saveRulesToMeta();
const execOps = buildExecOpsWithIndexDeleteReorder(mergedOps);
// 执行操作(用 execOps
for (const opItem of execOps) {
const { path, op, value, delta, warning } = opItem;
if (!path) continue;
if (warning) errors.push(`[${path}] ${warning}`);
const oldValue = safeParseAny(lwbResolveVarPath(path));
const absPath = normalizePath(path);
const oldValue = getVar(path);
const guard = validate(op, absPath, op === 'inc' ? delta : value, oldValue);
if (!guard.allow) {
errors.push(`${path}: ${guard.reason || '\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':
lwbAssignVarPath(path, value);
setVar(path, guard.value);
break;
case 'inc':
lwbAddVarPath(path, delta);
// guard.value 对 inc 是最终 nextValue
setVar(path, guard.value);
break;
case 'push':
lwbPushVarPath(path, value);
case 'push': {
const result = pushVar(path, guard.value);
if (!result.ok) { execOk = false; execReason = result.reason; }
break;
case 'pop':
lwbRemoveArrayItemByValue(path, value);
}
case 'pop': {
const result = popVar(path, guard.value);
if (!result.ok) { execOk = false; execReason = result.reason; }
break;
}
case 'del':
lwbDeleteVarPath(path);
delVar(path);
break;
default:
errors.push(`[${path}] 未知 op=${op}`);
continue;
execOk = false;
execReason = `未知 op=${op}`;
}
} catch (e) {
errors.push(`[${path}] 执行失败: ${e?.message || e}`);
execOk = false;
execReason = e?.message || String(e);
}
if (!execOk) {
errors.push(`[${path}] 失败: ${execReason}`);
continue;
}
const newValue = safeParseAny(lwbResolveVarPath(path));
const newValue = getVar(path);
atoms.push({
atomId: `sa-${messageId}-${idx}`,
@@ -195,5 +562,185 @@ export function applyStateForMessage(messageId, messageContent) {
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 signaturefloor 之后都要重新计算
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 signaturesfloor>=start 都要重新算)
try {
clearStateAppliedFrom(start);
} catch {}
ctx?.saveMetadataDebounced?.();
return { ok: true };
}

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

View File

@@ -1,3 +1,21 @@
export { applyStateForMessage, clearStateAppliedFor, clearStateAppliedFrom } from './executor.js';
export {
applyStateForMessage,
clearStateAppliedFor,
clearStateAppliedFrom,
restoreStateV2ToFloor,
trimStateV2FromFloor,
} from './executor.js';
export { parseStateBlock, extractStateBlocks, computeStateSignature, parseInlineValue } from './parser.js';
export { generateSemantic } from './semantic.js';
export {
validate,
setRule,
clearRule,
clearAllRules,
loadRulesFromMeta,
saveRulesToMeta,
getRuleNode,
getParentPath,
} from './guard.js';

View File

@@ -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 <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 ?? '');
if (!s || s.toLowerCase().indexOf('<state') === -1) return [];
const spans = findStateBlockSpans(s);
const out = [];
STATE_TAG_RE.lastIndex = 0;
let m;
while ((m = STATE_TAG_RE.exec(s)) !== null) {
const inner = String(m[1] ?? '');
for (const sp of spans) {
const inner = s.slice(sp.openTagEnd, sp.closeStart);
if (inner.trim()) out.push(inner);
}
return out;
@@ -17,36 +102,285 @@ export function extractStateBlocks(text) {
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') : '';
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;
}
/**
* 解析 <state> 块内容 -> ops[]
* 单行支持运算符,多行只支持覆盖 setYAML
* 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 results = [];
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;
// 没有任何缩进行:视为 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/)))
@@ -78,7 +412,8 @@ export function parseStateBlock(content) {
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) {
@@ -121,62 +456,47 @@ function unescapeString(s) {
.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 解析失败,作为字符串' };
}
} catch {}
return { op: 'set', value: t, warning: '+[] 解析失败' };
}
// -"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 解析失败,作为字符串' };
}
} catch {}
return { op: 'set', value: t, warning: '-[] 解析失败' };
}
// 裸数字 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*$/);
@@ -185,12 +505,10 @@ export function parseInlineValue(raw) {
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 解析失败,作为字符串' }; }
catch { return { op: 'set', value: t, warning: 'JSON 解析失败' }; }
}
// 兜底 set 原文本
return { op: 'set', value: t };
}

View File

@@ -5,7 +5,6 @@ export function generateSemantic(path, op, oldValue, newValue, delta, operandVal
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);

View File

@@ -21,6 +21,7 @@ import {
const MODULE_ID = 'varCommands';
const TAG_RE_XBGETVAR = /\{\{xbgetvar::([^}]+)\}\}/gi;
const TAG_RE_XBGETVAR_YAML = /\{\{xbgetvar_yaml::([^}]+)\}\}/gi;
const TAG_RE_XBGETVAR_YAML_IDX = /\{\{xbgetvar_yaml_idx::([^}]+)\}\}/gi;
let events = null;
let initialized = false;
@@ -183,6 +184,121 @@ export function replaceXbGetVarYamlInString(s) {
});
}
/**
* 将 {{xbgetvar_yaml_idx::路径}} 替换为带索引注释的 YAML
*/
export function replaceXbGetVarYamlIdxInString(s) {
s = String(s ?? '');
if (!s || s.indexOf('{{xbgetvar_yaml_idx::') === -1) return s;
TAG_RE_XBGETVAR_YAML_IDX.lastIndex = 0;
return s.replace(TAG_RE_XBGETVAR_YAML_IDX, (_, p) => {
const value = lwbResolveVarPath(p);
if (!value) return '';
try {
const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null) {
return formatYamlWithIndex(parsed, 0).trim();
}
return value;
} catch {
return value;
}
});
}
function formatYamlWithIndex(obj, indent) {
const pad = ' '.repeat(indent);
if (Array.isArray(obj)) {
if (obj.length === 0) return `${pad}[]`;
const lines = [];
obj.forEach((item, idx) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const keys = Object.keys(item);
if (keys.length === 0) {
lines.push(`${pad}- {} # [${idx}]`);
} else {
const firstKey = keys[0];
const firstVal = item[firstKey];
const firstFormatted = formatValue(firstVal, indent + 2);
if (typeof firstVal === 'object' && firstVal !== null) {
lines.push(`${pad}- ${firstKey}: # [${idx}]`);
lines.push(firstFormatted);
} else {
lines.push(`${pad}- ${firstKey}: ${firstFormatted} # [${idx}]`);
}
for (let i = 1; i < keys.length; i++) {
const k = keys[i];
const v = item[k];
const vFormatted = formatValue(v, indent + 2);
if (typeof v === 'object' && v !== null) {
lines.push(`${pad} ${k}:`);
lines.push(vFormatted);
} else {
lines.push(`${pad} ${k}: ${vFormatted}`);
}
}
}
} else if (Array.isArray(item)) {
lines.push(`${pad}- # [${idx}]`);
lines.push(formatYamlWithIndex(item, indent + 1));
} else {
lines.push(`${pad}- ${formatScalar(item)} # [${idx}]`);
}
});
return lines.join('\n');
}
if (obj && typeof obj === 'object') {
if (Object.keys(obj).length === 0) return `${pad}{}`;
const lines = [];
for (const [key, val] of Object.entries(obj)) {
const vFormatted = formatValue(val, indent + 1);
if (typeof val === 'object' && val !== null) {
lines.push(`${pad}${key}:`);
lines.push(vFormatted);
} else {
lines.push(`${pad}${key}: ${vFormatted}`);
}
}
return lines.join('\n');
}
return `${pad}${formatScalar(obj)}`;
}
function formatValue(val, indent) {
if (Array.isArray(val)) return formatYamlWithIndex(val, indent);
if (val && typeof val === 'object') return formatYamlWithIndex(val, indent);
return formatScalar(val);
}
function formatScalar(v) {
if (v === null) return 'null';
if (v === undefined) return '';
if (typeof v === 'boolean') return String(v);
if (typeof v === 'number') return String(v);
if (typeof v === 'string') {
const needsQuote =
v === '' ||
/^\s|\s$/.test(v) || // 首尾空格
/[:[]\]{}&*!|>'"%@`#,]/.test(v) || // YAML 易歧义字符
/^(?:true|false|null)$/i.test(v) || // YAML 关键字
/^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(v); // 纯数字字符串
if (needsQuote) {
return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
return v;
}
return String(v);
}
export function replaceXbGetVarInChat(chat) {
if (!Array.isArray(chat)) return;
@@ -194,10 +310,12 @@ export function replaceXbGetVarInChat(chat) {
const old = String(msg[key] ?? '');
const hasJson = old.indexOf('{{xbgetvar::') !== -1;
const hasYaml = old.indexOf('{{xbgetvar_yaml::') !== -1;
if (!hasJson && !hasYaml) continue;
const hasYamlIdx = old.indexOf('{{xbgetvar_yaml_idx::') !== -1;
if (!hasJson && !hasYaml && !hasYamlIdx) continue;
let result = hasJson ? replaceXbGetVarInString(old) : old;
result = hasYaml ? replaceXbGetVarYamlInString(result) : result;
result = hasYamlIdx ? replaceXbGetVarYamlIdxInString(result) : result;
msg[key] = result;
} catch {}
}
@@ -215,10 +333,12 @@ export function applyXbGetVarForMessage(messageId, writeback = true) {
const old = String(msg[key] ?? '');
const hasJson = old.indexOf('{{xbgetvar::') !== -1;
const hasYaml = old.indexOf('{{xbgetvar_yaml::') !== -1;
if (!hasJson && !hasYaml) return;
const hasYamlIdx = old.indexOf('{{xbgetvar_yaml_idx::') !== -1;
if (!hasJson && !hasYaml && !hasYamlIdx) return;
let out = hasJson ? replaceXbGetVarInString(old) : old;
out = hasYaml ? replaceXbGetVarYamlInString(out) : out;
out = hasYamlIdx ? replaceXbGetVarYamlIdxInString(out) : out;
if (writeback && out !== old) {
msg[key] = out;
}
@@ -1111,7 +1231,9 @@ export function cleanupVarCommands() {
initialized = false;
}
/**
* 按值从数组中删除元素2.0 pop 操作)
*/
export {
MODULE_ID,
};

View File

@@ -6,7 +6,7 @@
import { getContext } from "../../../../../extensions.js";
import { getLocalVariable } from "../../../../../variables.js";
import { createModuleEvents } from "../../core/event-manager.js";
import { replaceXbGetVarInString, replaceXbGetVarYamlInString } from "./var-commands.js";
import { replaceXbGetVarInString, replaceXbGetVarYamlInString, replaceXbGetVarYamlIdxInString } from "./var-commands.js";
const MODULE_ID = 'vareventEditor';
const LWB_EXT_ID = 'LittleWhiteBox';
@@ -303,6 +303,9 @@ function installWIHiddenTagStripper() {
if (msg.content.indexOf('{{xbgetvar_yaml::') !== -1) {
msg.content = replaceXbGetVarYamlInString(msg.content);
}
if (msg.content.indexOf('{{xbgetvar_yaml_idx::') !== -1) {
msg.content = replaceXbGetVarYamlIdxInString(msg.content);
}
}
if (Array.isArray(msg?.content)) {
for (const part of msg.content) {
@@ -321,6 +324,9 @@ function installWIHiddenTagStripper() {
if (part.text.indexOf('{{xbgetvar_yaml::') !== -1) {
part.text = replaceXbGetVarYamlInString(part.text);
}
if (part.text.indexOf('{{xbgetvar_yaml_idx::') !== -1) {
part.text = replaceXbGetVarYamlIdxInString(part.text);
}
}
}
}
@@ -339,6 +345,9 @@ function installWIHiddenTagStripper() {
if (msg.mes.indexOf('{{xbgetvar_yaml::') !== -1) {
msg.mes = replaceXbGetVarYamlInString(msg.mes);
}
if (msg.mes.indexOf('{{xbgetvar_yaml_idx::') !== -1) {
msg.mes = replaceXbGetVarYamlIdxInString(msg.mes);
}
}
}
} catch {}
@@ -373,6 +382,9 @@ function installWIHiddenTagStripper() {
if (data.prompt.indexOf('{{xbgetvar_yaml::') !== -1) {
data.prompt = replaceXbGetVarYamlInString(data.prompt);
}
if (data.prompt.indexOf('{{xbgetvar_yaml_idx::') !== -1) {
data.prompt = replaceXbGetVarYamlIdxInString(data.prompt);
}
}
} catch {}
});

View File

@@ -28,7 +28,7 @@ import {
applyXbGetVarForMessage,
parseValueForSet,
} from "./var-commands.js";
import { applyStateForMessage, clearStateAppliedFrom } from "./state2/index.js";
import { applyStateForMessage } from "./state2/index.js";
import {
preprocessBumpAliases,
executeQueuedVareventJsAfterTurn,
@@ -1624,16 +1624,10 @@ function rollbackToPreviousOf(messageId) {
const id = Number(messageId);
if (Number.isNaN(id)) return;
clearStateAppliedFrom(id);
if (typeof globalThis.LWB_StateRollbackHook === 'function') {
Promise.resolve(globalThis.LWB_StateRollbackHook(id)).catch((e) => {
console.error('[variablesCore] LWB_StateRollbackHook failed:', e);
});
}
const prevId = id - 1;
if (prevId < 0) return;
// ???? 1.0 ???????
const snap = getSnapshot(prevId);
if (snap) {
const normalized = normalizeSnapshotRecord(snap);
@@ -1647,12 +1641,52 @@ function rollbackToPreviousOf(messageId) {
}
}
function rebuildVariablesFromScratch() {
async function rollbackToPreviousOfAsync(messageId) {
const id = Number(messageId);
if (Number.isNaN(id)) return;
// ???????? floor>=id ? L0
if (typeof globalThis.LWB_StateRollbackHook === 'function') {
try {
await globalThis.LWB_StateRollbackHook(id);
} catch (e) {
console.error('[variablesCore] LWB_StateRollbackHook failed:', e);
}
}
const prevId = id - 1;
const mode = getVariablesMode();
if (mode === '2.0') {
try {
const mod = await import('./state2/index.js');
await mod.restoreStateV2ToFloor(prevId); // prevId<0 ???
} catch (e) {
console.error('[variablesCore][2.0] restoreStateV2ToFloor failed:', e);
}
return;
}
// mode === '1.0'
rollbackToPreviousOf(id);
}
async function rebuildVariablesFromScratch() {
try {
const mode = getVariablesMode();
if (mode === '2.0') {
const mod = await import('./state2/index.js');
const chat = getContext()?.chat || [];
const lastId = chat.length ? chat.length - 1 : -1;
await mod.restoreStateV2ToFloor(lastId);
return;
}
// 1.0 旧逻辑
setVarDict({});
const chat = getContext()?.chat || [];
for (let i = 0; i < chat.length; i++) {
applyVariablesForMessage(i);
await applyVariablesForMessage(i);
}
} catch {}
}
@@ -1842,7 +1876,7 @@ async function applyVariablesForMessage(messageId) {
} catch (e) {
parseErrors++;
if (debugOn) {
try { xbLog.error(MODULE_ID, `plot-log è§£æž<EFBFBD>失败:楼å±?${messageId} å<EFBFBD>?${idx + 1} 预览=${preview(b)}`, e); } catch {}
try { xbLog.error(MODULE_ID, `plot-log 解析失败<EFBFBD>?${messageId} <20>?${idx + 1} 预览=${preview(b)}`, e); } catch {}
}
return;
}
@@ -1873,7 +1907,7 @@ async function applyVariablesForMessage(messageId) {
try {
xbLog.warn(
MODULE_ID,
`plot-log 未产生å<EFBFBD>¯æ‰§è¡ŒæŒ‡ä»¤ï¼šæ¥¼å±?${messageId} å<EFBFBD>—æ•°=${blocks.length} è§£æž<EFBFBD>æ<EFBFBD>¡ç®=${parsedPartsTotal} è§£æž<EFBFBD>失败=${parseErrors} 预览=${preview(blocks[0])}`
`plot-log 未产生可执行指令<EFBFBD>?${messageId} 块数=${blocks.length} 解析条目=${parsedPartsTotal} 解析失败=${parseErrors} 预览=${preview(blocks[0])}`
);
} catch {}
}
@@ -2149,7 +2183,7 @@ async function applyVariablesForMessage(messageId) {
const denied = guardDenied ? `,被规则拦截=${guardDenied}` : '';
xbLog.warn(
MODULE_ID,
`plot-log 指令执行å<EFBFBD>Žæ— å<EFBFBD>˜åŒï¼šæ¥¼å±?${messageId} 指令æ•?${ops.length}${denied} 示例=${preview(JSON.stringify(guardDeniedSamples))}`
`plot-log 指令执行后无变化<EFBFBD>?${messageId} 指令<EFBFBD>?${ops.length}${denied} 示例=${preview(JSON.stringify(guardDeniedSamples))}`
);
} catch {}
}
@@ -2218,7 +2252,7 @@ function bindEvents() {
events?.on(event_types.MESSAGE_SENT, async () => {
try {
snapshotCurrentLastFloor();
if (getVariablesMode() !== '2.0') snapshotCurrentLastFloor();
const chat = getContext()?.chat || [];
const id = chat.length ? chat.length - 1 : undefined;
if (typeof id === 'number') {
@@ -2247,7 +2281,7 @@ function bindEvents() {
if (typeof id === 'number') {
await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
snapshotForMessageId(id);
if (getVariablesMode() !== '2.0') snapshotForMessageId(id);
}
} catch {}
});
@@ -2259,7 +2293,7 @@ function bindEvents() {
if (typeof id === 'number') {
await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
snapshotForMessageId(id);
if (getVariablesMode() !== '2.0') snapshotForMessageId(id);
}
} catch {}
});
@@ -2283,33 +2317,35 @@ function bindEvents() {
events?.on(event_types.MESSAGE_EDITED, async (data) => {
try {
const id = getMsgIdLoose(data);
if (typeof id === 'number') {
clearAppliedFor(id);
rollbackToPreviousOf(id);
if (typeof id !== 'number') return;
setTimeout(async () => {
await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
if (getVariablesMode() !== '2.0') clearAppliedFor(id);
try {
const ctx = getContext();
const msg = ctx?.chat?.[id];
if (msg) updateMessageBlock(id, msg, { rerenderMessage: true });
} catch {}
// ? ?? await????? apply ????????????
await rollbackToPreviousOfAsync(id);
try {
const ctx = getContext();
const es = ctx?.eventSource;
const et = ctx?.event_types;
if (es?.emit && et?.MESSAGE_UPDATED) {
suppressUpdatedOnce.add(id);
await es.emit(et.MESSAGE_UPDATED, id);
}
} catch {}
setTimeout(async () => {
await applyVarsForMessage(id);
applyXbGetVarForMessage(id, true);
await executeQueuedVareventJsAfterTurn();
}, 10);
}
try {
const ctx = getContext();
const msg = ctx?.chat?.[id];
if (msg) updateMessageBlock(id, msg, { rerenderMessage: true });
} catch {}
try {
const ctx = getContext();
const es = ctx?.eventSource;
const et = ctx?.event_types;
if (es?.emit && et?.MESSAGE_UPDATED) {
suppressUpdatedOnce.add(id);
await es.emit(et.MESSAGE_UPDATED, id);
}
} catch {}
await executeQueuedVareventJsAfterTurn();
}, 10);
} catch {}
});
@@ -2317,28 +2353,44 @@ function bindEvents() {
events?.on(event_types.MESSAGE_SWIPED, async (data) => {
try {
const id = getMsgIdLoose(data);
if (typeof id === 'number') {
lastSwipedId = id;
clearAppliedFor(id);
rollbackToPreviousOf(id);
if (typeof id !== 'number') return;
const tId = setTimeout(async () => {
pendingSwipeApply.delete(id);
await applyVarsForMessage(id);
await executeQueuedVareventJsAfterTurn();
}, 10);
lastSwipedId = id;
if (getVariablesMode() !== '2.0') clearAppliedFor(id);
pendingSwipeApply.set(id, tId);
}
// ? ?? await???????????????
await rollbackToPreviousOfAsync(id);
const tId = setTimeout(async () => {
pendingSwipeApply.delete(id);
await applyVarsForMessage(id);
await executeQueuedVareventJsAfterTurn();
}, 10);
pendingSwipeApply.set(id, tId);
} catch {}
});
// message deleted
events?.on(event_types.MESSAGE_DELETED, (data) => {
events?.on(event_types.MESSAGE_DELETED, async (data) => {
try {
const id = getMsgIdStrict(data);
if (typeof id === 'number') {
rollbackToPreviousOf(id);
if (typeof id !== 'number') return;
// ? ????????await ???????
await rollbackToPreviousOfAsync(id);
// ✅ 2.0:物理删除消息 => 同步清理 WAL/ckpt避免膨胀
if (getVariablesMode() === '2.0') {
try {
const mod = await import('./state2/index.js');
await mod.trimStateV2FromFloor(id);
} catch (e) {
console.error('[variablesCore][2.0] trimStateV2FromFloor failed:', e);
}
}
if (getVariablesMode() !== '2.0') {
clearSnapshotsFrom(id);
clearAppliedFrom(id);
}
@@ -2349,7 +2401,7 @@ function bindEvents() {
events?.on(event_types.GENERATION_STARTED, (data) => {
try {
snapshotPreviousFloor();
if (getVariablesMode() !== '2.0') snapshotPreviousFloor();
// cancel swipe delay
const t = (typeof data === 'string' ? data : (data?.type || '')).toLowerCase();
@@ -2364,7 +2416,7 @@ function bindEvents() {
});
// chat changed
events?.on(event_types.CHAT_CHANGED, () => {
events?.on(event_types.CHAT_CHANGED, async () => {
try {
rulesClearCache();
rulesLoadFromMeta();
@@ -2372,6 +2424,13 @@ function bindEvents() {
const meta = getContext()?.chatMetadata || {};
meta[LWB_PLOT_APPLIED_KEY] = {};
getContext()?.saveMetadataDebounced?.();
if (getVariablesMode() === '2.0') {
try {
const mod = await import('./state2/index.js');
mod.clearStateAppliedFrom(0);
} catch {}
}
} catch {}
});
}
@@ -2408,6 +2467,33 @@ export function initVariablesCore() {
applyDeltaTable: applyRulesDeltaToTable,
save: rulesSaveToMeta,
};
globalThis.LWB_StateV2 = {
/**
* @param {string} text - 包含 <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;
},
};
}
/**
@@ -2429,6 +2515,7 @@ export function cleanupVariablesCore() {
// clear global hooks
delete globalThis.LWB_Guard;
delete globalThis.LWB_StateV2;
// clear guard state
guardBypass(false);
@@ -2454,4 +2541,4 @@ export {
rulesSetTable,
rulesLoadFromMeta,
rulesSaveToMeta,
};
};

View File

@@ -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,'&quot;'), 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,'&quot;');
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 {