Sync local version
This commit is contained in:
@@ -21,6 +21,7 @@ module.exports = {
|
|||||||
SillyTavern: 'readonly',
|
SillyTavern: 'readonly',
|
||||||
ePub: 'readonly',
|
ePub: 'readonly',
|
||||||
pdfjsLib: 'readonly',
|
pdfjsLib: 'readonly',
|
||||||
|
echarts: 'readonly',
|
||||||
},
|
},
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
|
|||||||
@@ -183,3 +183,4 @@ export const StoryOutlineStorage = new StorageFile('LittleWhiteBox_StoryOutline.
|
|||||||
export const NovelDrawStorage = new StorageFile('LittleWhiteBox_NovelDraw.json', { debounceMs: 800 });
|
export const NovelDrawStorage = new StorageFile('LittleWhiteBox_NovelDraw.json', { debounceMs: 800 });
|
||||||
export const TtsStorage = new StorageFile('LittleWhiteBox_TTS.json', { debounceMs: 800 });
|
export const TtsStorage = new StorageFile('LittleWhiteBox_TTS.json', { debounceMs: 800 });
|
||||||
export const CommonSettingStorage = new StorageFile('LittleWhiteBox_CommonSettings.json', { debounceMs: 1000 });
|
export const CommonSettingStorage = new StorageFile('LittleWhiteBox_CommonSettings.json', { debounceMs: 1000 });
|
||||||
|
export const VectorStorage = new StorageFile('LittleWhiteBox_Vectors.json', { debounceMs: 3000 });
|
||||||
|
|||||||
5912
libs/dexie.mjs
Normal file
5912
libs/dexie.mjs
Normal file
File diff suppressed because it is too large
Load Diff
390
libs/jieba-wasm/jieba_rs_wasm.js
Normal file
390
libs/jieba-wasm/jieba_rs_wasm.js
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
|
||||||
|
let wasm;
|
||||||
|
|
||||||
|
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||||
|
|
||||||
|
cachedTextDecoder.decode();
|
||||||
|
|
||||||
|
let cachegetUint8Memory0 = null;
|
||||||
|
function getUint8Memory0() {
|
||||||
|
if (cachegetUint8Memory0 === null || cachegetUint8Memory0.buffer !== wasm.memory.buffer) {
|
||||||
|
cachegetUint8Memory0 = new Uint8Array(wasm.memory.buffer);
|
||||||
|
}
|
||||||
|
return cachegetUint8Memory0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
let cachedTextEncoder = new TextEncoder('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);
|
||||||
|
getUint8Memory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||||
|
WASM_VECTOR_LEN = buf.length;
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = arg.length;
|
||||||
|
let ptr = malloc(len);
|
||||||
|
|
||||||
|
const mem = getUint8Memory0();
|
||||||
|
|
||||||
|
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);
|
||||||
|
const view = getUint8Memory0().subarray(ptr + offset, ptr + len);
|
||||||
|
const ret = encodeString(arg, view);
|
||||||
|
|
||||||
|
offset += ret.written;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 isLikeNone(x) {
|
||||||
|
return x === undefined || x === null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param {string} word
|
||||||
|
* @param {number | undefined} freq
|
||||||
|
* @param {string | undefined} 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;
|
||||||
|
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);
|
||||||
|
return ret >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function 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);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await module.arrayBuffer();
|
||||||
|
return await WebAssembly.instantiate(bytes, imports);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const instance = await WebAssembly.instantiate(module, imports);
|
||||||
|
|
||||||
|
if (instance instanceof WebAssembly.Instance) {
|
||||||
|
return { instance, module };
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init(input) {
|
||||||
|
if (typeof input === 'undefined') {
|
||||||
|
input = new URL('jieba_rs_wasm_bg.wasm', import.meta.url);
|
||||||
|
}
|
||||||
|
const imports = {};
|
||||||
|
imports.wbg = {};
|
||||||
|
imports.wbg.__wbindgen_string_new = function(arg0, arg1) {
|
||||||
|
var ret = getStringFromWasm0(arg0, arg1);
|
||||||
|
return addHeapObject(ret);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_object_drop_ref = function(arg0) {
|
||||||
|
takeObject(arg0);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_new_68adb0d58759a4ed = function() {
|
||||||
|
var ret = new Object();
|
||||||
|
return addHeapObject(ret);
|
||||||
|
};
|
||||||
|
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_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;
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const { instance, module } = await load(await input, imports);
|
||||||
|
|
||||||
|
wasm = instance.exports;
|
||||||
|
init.__wbindgen_wasm_module = module;
|
||||||
|
|
||||||
|
return wasm;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default init;
|
||||||
|
|
||||||
BIN
libs/jieba-wasm/jieba_rs_wasm_bg.wasm
Normal file
BIN
libs/jieba-wasm/jieba_rs_wasm_bg.wasm
Normal file
Binary file not shown.
12
libs/jieba-wasm/jieba_rs_wasm_bg.wasm.d.ts
vendored
Normal file
12
libs/jieba-wasm/jieba_rs_wasm_bg.wasm.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/* 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;
|
||||||
2036
libs/minisearch.mjs
Normal file
2036
libs/minisearch.mjs
Normal file
File diff suppressed because it is too large
Load Diff
97
modules/story-summary/data/config.js
Normal file
97
modules/story-summary/data/config.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Story Summary - Config
|
||||||
|
// Plugin settings, panel config, and vector config.
|
||||||
|
|
||||||
|
import { extension_settings } from "../../../../../../extensions.js";
|
||||||
|
import { EXT_ID } from "../../../core/constants.js";
|
||||||
|
import { xbLog } from "../../../core/debug-core.js";
|
||||||
|
import { CommonSettingStorage } from "../../../core/server-storage.js";
|
||||||
|
|
||||||
|
const MODULE_ID = 'summaryConfig';
|
||||||
|
const SUMMARY_CONFIG_KEY = 'storySummaryPanelConfig';
|
||||||
|
|
||||||
|
export function getSettings() {
|
||||||
|
const ext = extension_settings[EXT_ID] ||= {};
|
||||||
|
ext.storySummary ||= { enabled: true };
|
||||||
|
return ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSummaryPanelConfig() {
|
||||||
|
const defaults = {
|
||||||
|
api: { provider: 'st', url: '', key: '', model: '', modelCache: [] },
|
||||||
|
gen: { temperature: null, top_p: null, top_k: null, presence_penalty: null, frequency_penalty: null },
|
||||||
|
trigger: {
|
||||||
|
enabled: false,
|
||||||
|
interval: 20,
|
||||||
|
timing: 'after_ai',
|
||||||
|
useStream: true,
|
||||||
|
maxPerRun: 100,
|
||||||
|
wrapperHead: '',
|
||||||
|
wrapperTail: '',
|
||||||
|
forceInsertAtEnd: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('summary_panel_config');
|
||||||
|
if (!raw) return defaults;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
api: { ...defaults.api, ...(parsed.api || {}) },
|
||||||
|
gen: { ...defaults.gen, ...(parsed.gen || {}) },
|
||||||
|
trigger: { ...defaults.trigger, ...(parsed.trigger || {}) },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.trigger.timing === 'manual') result.trigger.enabled = false;
|
||||||
|
if (result.trigger.useStream === undefined) result.trigger.useStream = true;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSummaryPanelConfig(config) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('summary_panel_config', JSON.stringify(config));
|
||||||
|
CommonSettingStorage.set(SUMMARY_CONFIG_KEY, config);
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.error(MODULE_ID, '保存面板配置失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVectorConfig() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('summary_panel_config');
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed.vector || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveVectorConfig(vectorCfg) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('summary_panel_config') || '{}';
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
parsed.vector = vectorCfg;
|
||||||
|
localStorage.setItem('summary_panel_config', JSON.stringify(parsed));
|
||||||
|
CommonSettingStorage.set(SUMMARY_CONFIG_KEY, parsed);
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.error(MODULE_ID, '保存向量配置失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadConfigFromServer() {
|
||||||
|
try {
|
||||||
|
const savedConfig = await CommonSettingStorage.get(SUMMARY_CONFIG_KEY, null);
|
||||||
|
if (savedConfig) {
|
||||||
|
localStorage.setItem('summary_panel_config', JSON.stringify(savedConfig));
|
||||||
|
xbLog.info(MODULE_ID, '已从服务器加载面板配置');
|
||||||
|
return savedConfig;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.warn(MODULE_ID, '加载面板配置失败', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
24
modules/story-summary/data/db.js
Normal file
24
modules/story-summary/data/db.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Memory Database (Dexie schema)
|
||||||
|
|
||||||
|
import Dexie from '../../../libs/dexie.mjs';
|
||||||
|
|
||||||
|
const DB_NAME = 'LittleWhiteBox_Memory';
|
||||||
|
const DB_VERSION = 2;
|
||||||
|
|
||||||
|
// Chunk parameters
|
||||||
|
export const CHUNK_MAX_TOKENS = 200;
|
||||||
|
|
||||||
|
const db = new Dexie(DB_NAME);
|
||||||
|
|
||||||
|
db.version(DB_VERSION).stores({
|
||||||
|
meta: 'chatId',
|
||||||
|
chunks: '[chatId+chunkId], chatId, [chatId+floor]',
|
||||||
|
chunkVectors: '[chatId+chunkId], chatId',
|
||||||
|
eventVectors: '[chatId+eventId], chatId',
|
||||||
|
});
|
||||||
|
|
||||||
|
export { db };
|
||||||
|
export const metaTable = db.meta;
|
||||||
|
export const chunksTable = db.chunks;
|
||||||
|
export const chunkVectorsTable = db.chunkVectors;
|
||||||
|
export const eventVectorsTable = db.eventVectors;
|
||||||
288
modules/story-summary/data/store.js
Normal file
288
modules/story-summary/data/store.js
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
// Story Summary - Store
|
||||||
|
// L2 (events/characters/arcs) + L3 (world) 统一存储
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
const MODULE_ID = 'summaryStore';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 基础存取
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function getSummaryStore() {
|
||||||
|
const { chatId } = getContext();
|
||||||
|
if (!chatId) return null;
|
||||||
|
chat_metadata.extensions ||= {};
|
||||||
|
chat_metadata.extensions[EXT_ID] ||= {};
|
||||||
|
chat_metadata.extensions[EXT_ID].storySummary ||= {};
|
||||||
|
return chat_metadata.extensions[EXT_ID].storySummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSummaryStore() {
|
||||||
|
saveMetadataDebounced?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeepVisibleCount() {
|
||||||
|
const store = getSummaryStore();
|
||||||
|
return store?.keepVisibleCount ?? 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcHideRange(lastSummarized) {
|
||||||
|
const keepCount = getKeepVisibleCount();
|
||||||
|
const hideEnd = lastSummarized - keepCount;
|
||||||
|
if (hideEnd < 0) return null;
|
||||||
|
return { start: 0, end: hideEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSummarySnapshot(store, endMesId) {
|
||||||
|
store.summaryHistory ||= [];
|
||||||
|
store.summaryHistory.push({ endMesId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// L3 世界状态合并
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function mergeWorldState(existingList, updates, floor) {
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
|
(existingList || []).forEach(item => {
|
||||||
|
const key = `${item.category}:${item.topic}`;
|
||||||
|
map.set(key, item);
|
||||||
|
});
|
||||||
|
|
||||||
|
(updates || []).forEach(up => {
|
||||||
|
if (!up.category || !up.topic) return;
|
||||||
|
|
||||||
|
const key = `${up.category}:${up.topic}`;
|
||||||
|
|
||||||
|
if (up.cleared === true) {
|
||||||
|
map.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = up.content?.trim();
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
map.set(key, {
|
||||||
|
category: up.category,
|
||||||
|
topic: up.topic,
|
||||||
|
content: content,
|
||||||
|
floor: floor,
|
||||||
|
_addedAt: floor,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 数据合并(L2 + L3)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function mergeNewData(oldJson, parsed, endMesId) {
|
||||||
|
const merged = structuredClone(oldJson || {});
|
||||||
|
|
||||||
|
// L2 初始化
|
||||||
|
merged.keywords ||= [];
|
||||||
|
merged.events ||= [];
|
||||||
|
merged.characters ||= {};
|
||||||
|
merged.characters.main ||= [];
|
||||||
|
merged.characters.relationships ||= [];
|
||||||
|
merged.arcs ||= [];
|
||||||
|
|
||||||
|
// L3 初始化
|
||||||
|
merged.world ||= [];
|
||||||
|
|
||||||
|
// L2 数据合并
|
||||||
|
if (parsed.keywords?.length) {
|
||||||
|
merged.keywords = parsed.keywords.map(k => ({ ...k, _addedAt: endMesId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
(parsed.events || []).forEach(e => {
|
||||||
|
e._addedAt = endMesId;
|
||||||
|
merged.events.push(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingMain = new Set(
|
||||||
|
(merged.characters.main || []).map(m => typeof m === 'string' ? m : m.name)
|
||||||
|
);
|
||||||
|
(parsed.newCharacters || []).forEach(name => {
|
||||||
|
if (!existingMain.has(name)) {
|
||||||
|
merged.characters.main.push({ name, _addedAt: 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());
|
||||||
|
|
||||||
|
const arcMap = new Map((merged.arcs || []).map(a => [a.name, a]));
|
||||||
|
(parsed.arcUpdates || []).forEach(update => {
|
||||||
|
const existing = arcMap.get(update.name);
|
||||||
|
if (existing) {
|
||||||
|
existing.trajectory = update.trajectory;
|
||||||
|
existing.progress = update.progress;
|
||||||
|
if (update.newMoment) {
|
||||||
|
existing.moments = existing.moments || [];
|
||||||
|
existing.moments.push({ text: update.newMoment, _addedAt: endMesId });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
arcMap.set(update.name, {
|
||||||
|
name: update.name,
|
||||||
|
trajectory: update.trajectory,
|
||||||
|
progress: update.progress,
|
||||||
|
moments: update.newMoment ? [{ text: update.newMoment, _addedAt: endMesId }] : [],
|
||||||
|
_addedAt: endMesId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
merged.arcs = Array.from(arcMap.values());
|
||||||
|
|
||||||
|
// L3 世界状态合并
|
||||||
|
merged.world = mergeWorldState(
|
||||||
|
merged.world || [],
|
||||||
|
parsed.worldUpdate || [],
|
||||||
|
endMesId
|
||||||
|
);
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 回滚
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function rollbackSummaryIfNeeded() {
|
||||||
|
const { chat, chatId } = getContext();
|
||||||
|
const currentLength = Array.isArray(chat) ? chat.length : 0;
|
||||||
|
const store = getSummaryStore();
|
||||||
|
|
||||||
|
if (!store || store.lastSummarizedMesId == null || store.lastSummarizedMesId < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSummarized = store.lastSummarizedMesId;
|
||||||
|
|
||||||
|
if (currentLength <= lastSummarized) {
|
||||||
|
const deletedCount = lastSummarized + 1 - currentLength;
|
||||||
|
|
||||||
|
if (deletedCount < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
xbLog.warn(MODULE_ID, `删除已总结楼层 ${deletedCount} 条,触发回滚`);
|
||||||
|
|
||||||
|
const history = store.summaryHistory || [];
|
||||||
|
let targetEndMesId = -1;
|
||||||
|
|
||||||
|
for (let i = history.length - 1; i >= 0; i--) {
|
||||||
|
if (history[i].endMesId < currentLength) {
|
||||||
|
targetEndMesId = history[i].endMesId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeRollback(chatId, store, targetEndMesId, currentLength);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeRollback(chatId, store, targetEndMesId, currentLength) {
|
||||||
|
const oldEvents = store.json?.events || [];
|
||||||
|
|
||||||
|
if (targetEndMesId < 0) {
|
||||||
|
store.lastSummarizedMesId = -1;
|
||||||
|
store.json = null;
|
||||||
|
store.summaryHistory = [];
|
||||||
|
store.hideSummarizedHistory = false;
|
||||||
|
|
||||||
|
await clearEventVectors(chatId);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const deletedEventIds = oldEvents
|
||||||
|
.filter(e => (e._addedAt ?? 0) > targetEndMesId)
|
||||||
|
.map(e => e.id);
|
||||||
|
|
||||||
|
const json = store.json || {};
|
||||||
|
|
||||||
|
// L2 回滚
|
||||||
|
json.events = (json.events || []).filter(e => (e._addedAt ?? 0) <= targetEndMesId);
|
||||||
|
json.keywords = (json.keywords || []).filter(k => (k._addedAt ?? 0) <= targetEndMesId);
|
||||||
|
json.arcs = (json.arcs || []).filter(a => (a._addedAt ?? 0) <= targetEndMesId);
|
||||||
|
json.arcs.forEach(a => {
|
||||||
|
a.moments = (a.moments || []).filter(m =>
|
||||||
|
typeof m === 'string' || (m._addedAt ?? 0) <= targetEndMesId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (json.characters) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
store.json = json;
|
||||||
|
store.lastSummarizedMesId = targetEndMesId;
|
||||||
|
store.summaryHistory = (store.summaryHistory || []).filter(h => h.endMesId <= targetEndMesId);
|
||||||
|
|
||||||
|
if (deletedEventIds.length > 0) {
|
||||||
|
await deleteEventVectorsByIds(chatId, deletedEventIds);
|
||||||
|
xbLog.info(MODULE_ID, `回滚删除 ${deletedEventIds.length} 个事件向量`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.updatedAt = Date.now();
|
||||||
|
saveSummaryStore();
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, `回滚完成,目标楼层: ${targetEndMesId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSummaryData(chatId) {
|
||||||
|
const store = getSummaryStore();
|
||||||
|
if (store) {
|
||||||
|
delete store.json;
|
||||||
|
store.lastSummarizedMesId = -1;
|
||||||
|
store.updatedAt = Date.now();
|
||||||
|
saveSummaryStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chatId) {
|
||||||
|
await clearEventVectors(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, '总结数据已清空');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// L3 数据读取(供 prompt.js 使用)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function getWorldSnapshot() {
|
||||||
|
const store = getSummaryStore();
|
||||||
|
return store?.json?.world || [];
|
||||||
|
}
|
||||||
208
modules/story-summary/generate/generator.js
Normal file
208
modules/story-summary/generate/generator.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// Story Summary - Generator
|
||||||
|
// 调用 LLM 生成总结
|
||||||
|
|
||||||
|
import { getContext } from "../../../../../../extensions.js";
|
||||||
|
import { xbLog } from "../../../core/debug-core.js";
|
||||||
|
import { getSummaryStore, saveSummaryStore, addSummarySnapshot, mergeNewData } from "../data/store.js";
|
||||||
|
import { generateSummary, parseSummaryJson } from "./llm.js";
|
||||||
|
|
||||||
|
const MODULE_ID = 'summaryGenerator';
|
||||||
|
const SUMMARY_SESSION_ID = 'xb9';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// worldUpdate 清洗
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function sanitizeWorldUpdate(parsed) {
|
||||||
|
if (!parsed) return;
|
||||||
|
|
||||||
|
const wu = Array.isArray(parsed.worldUpdate) ? parsed.worldUpdate : [];
|
||||||
|
const ok = [];
|
||||||
|
|
||||||
|
for (const item of wu) {
|
||||||
|
const category = String(item?.category || '').trim().toLowerCase();
|
||||||
|
const topic = String(item?.topic || '').trim();
|
||||||
|
|
||||||
|
if (!category || !topic) continue;
|
||||||
|
|
||||||
|
// status/knowledge/relation 必须包含 "::"
|
||||||
|
if (['status', 'knowledge', 'relation'].includes(category) && !topic.includes('::')) {
|
||||||
|
xbLog.warn(MODULE_ID, `丢弃不合格 worldUpdate: ${category}/${topic}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.cleared === true) {
|
||||||
|
ok.push({ category, topic, cleared: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = String(item?.content || '').trim();
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
ok.push({ category, topic, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.worldUpdate = ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 辅助函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function formatExistingSummaryForAI(store) {
|
||||||
|
if (!store?.json) return "(空白,这是首次总结)";
|
||||||
|
|
||||||
|
const data = store.json;
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (data.events?.length) {
|
||||||
|
parts.push("【已记录事件】");
|
||||||
|
data.events.forEach((ev, i) => parts.push(`${i + 1}. [${ev.timeLabel}] ${ev.title}:${ev.summary}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.characters?.main?.length) {
|
||||||
|
const names = data.characters.main.map(m => typeof m === 'string' ? m : m.name);
|
||||||
|
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)}%)`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.keywords?.length) {
|
||||||
|
parts.push(`\n【关键词】${data.keywords.map(k => k.text).join("、")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n") || "(空白,这是首次总结)";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextEventId(store) {
|
||||||
|
const events = store?.json?.events || [];
|
||||||
|
if (!events.length) return 1;
|
||||||
|
|
||||||
|
const maxId = Math.max(...events.map(e => {
|
||||||
|
const match = e.id?.match(/evt-(\d+)/);
|
||||||
|
return match ? parseInt(match[1]) : 0;
|
||||||
|
}));
|
||||||
|
|
||||||
|
return maxId + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIncrementalSlice(targetMesId, lastSummarizedMesId, maxPerRun = 100) {
|
||||||
|
const { chat, name1, name2 } = getContext();
|
||||||
|
|
||||||
|
const start = Math.max(0, (lastSummarizedMesId ?? -1) + 1);
|
||||||
|
const rawEnd = Math.min(targetMesId, chat.length - 1);
|
||||||
|
const end = Math.min(rawEnd, start + maxPerRun - 1);
|
||||||
|
|
||||||
|
if (start > end) return { text: "", count: 0, range: "", endMesId: -1 };
|
||||||
|
|
||||||
|
const userLabel = name1 || '用户';
|
||||||
|
const charLabel = name2 || '角色';
|
||||||
|
const slice = chat.slice(start, end + 1);
|
||||||
|
|
||||||
|
const text = slice.map((m, i) => {
|
||||||
|
const speaker = m.name || (m.is_user ? userLabel : charLabel);
|
||||||
|
return `#${start + i + 1} 【${speaker}】\n${m.mes}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
return { text, count: slice.length, range: `${start + 1}-${end + 1}楼`, endMesId: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 主生成函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function runSummaryGeneration(mesId, config, callbacks = {}) {
|
||||||
|
const { onStatus, onError, onComplete } = callbacks;
|
||||||
|
|
||||||
|
const store = getSummaryStore();
|
||||||
|
const lastSummarized = store?.lastSummarizedMesId ?? -1;
|
||||||
|
const maxPerRun = config.trigger?.maxPerRun || 100;
|
||||||
|
const slice = buildIncrementalSlice(mesId, lastSummarized, maxPerRun);
|
||||||
|
|
||||||
|
if (slice.count === 0) {
|
||||||
|
onStatus?.("没有新的对话需要总结");
|
||||||
|
return { success: true, noContent: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus?.(`正在总结 ${slice.range}(${slice.count}楼新内容)...`);
|
||||||
|
|
||||||
|
const existingSummary = formatExistingSummaryForAI(store);
|
||||||
|
const existingWorld = store?.json?.world || [];
|
||||||
|
const nextEventId = getNextEventId(store);
|
||||||
|
const existingEventCount = store?.json?.events?.length || 0;
|
||||||
|
const useStream = config.trigger?.useStream !== false;
|
||||||
|
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = await generateSummary({
|
||||||
|
existingSummary,
|
||||||
|
existingWorld,
|
||||||
|
newHistoryText: slice.text,
|
||||||
|
historyRange: slice.range,
|
||||||
|
nextEventId,
|
||||||
|
existingEventCount,
|
||||||
|
llmApi: {
|
||||||
|
provider: config.api?.provider,
|
||||||
|
url: config.api?.url,
|
||||||
|
key: config.api?.key,
|
||||||
|
model: config.api?.model,
|
||||||
|
},
|
||||||
|
genParams: config.gen || {},
|
||||||
|
useStream,
|
||||||
|
timeout: 120000,
|
||||||
|
sessionId: SUMMARY_SESSION_ID,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
xbLog.error(MODULE_ID, '生成失败', err);
|
||||||
|
onError?.(err?.message || "生成失败");
|
||||||
|
return { success: false, error: err };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raw?.trim()) {
|
||||||
|
xbLog.error(MODULE_ID, 'AI返回为空');
|
||||||
|
onError?.("AI返回为空");
|
||||||
|
return { success: false, error: "empty" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseSummaryJson(raw);
|
||||||
|
if (!parsed) {
|
||||||
|
xbLog.error(MODULE_ID, 'JSON解析失败');
|
||||||
|
onError?.("AI未返回有效JSON");
|
||||||
|
return { success: false, error: "parse" };
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeWorldUpdate(parsed);
|
||||||
|
|
||||||
|
const merged = mergeNewData(store?.json || {}, parsed, slice.endMesId);
|
||||||
|
|
||||||
|
store.lastSummarizedMesId = slice.endMesId;
|
||||||
|
store.json = merged;
|
||||||
|
store.updatedAt = Date.now();
|
||||||
|
addSummarySnapshot(store, slice.endMesId);
|
||||||
|
saveSummaryStore();
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, `总结完成,已更新至 ${slice.endMesId + 1} 楼`);
|
||||||
|
|
||||||
|
if (parsed.worldUpdate?.length) {
|
||||||
|
xbLog.info(MODULE_ID, `世界状态更新: ${parsed.worldUpdate.length} 条`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEventIds = (parsed.events || []).map(e => e.id);
|
||||||
|
|
||||||
|
onComplete?.({
|
||||||
|
merged,
|
||||||
|
endMesId: slice.endMesId,
|
||||||
|
newEventIds,
|
||||||
|
l3Stats: { worldUpdate: parsed.worldUpdate?.length || 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, merged, endMesId: slice.endMesId, newEventIds };
|
||||||
|
}
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// LLM Service
|
||||||
// Story Summary - LLM Service
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
|
||||||
// 常量
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const PROVIDER_MAP = {
|
const PROVIDER_MAP = {
|
||||||
openai: "openai",
|
openai: "openai",
|
||||||
@@ -43,27 +37,35 @@ Incremental_Summary_Requirements:
|
|||||||
- 氛围: 纯粹氛围片段
|
- 氛围: 纯粹氛围片段
|
||||||
- Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融)
|
- Character_Dynamics: 识别新角色,追踪关系趋势(破裂/厌恶/反感/陌生/投缘/亲密/交融)
|
||||||
- Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0)
|
- Arc_Tracking: 更新角色弧光轨迹与成长进度(0.0-1.0)
|
||||||
|
- World_State_Tracking: 维护当前世界的硬性约束。解决"什么不能违反"。采用 KV 覆盖模型,追踪生死、物品归属、秘密知情、关系状态、环境规则等不可违背的事实。(覆盖式更新)
|
||||||
|
categories:
|
||||||
|
- status: 角色生死、位置锁定、重大状态
|
||||||
|
- inventory: 重要物品归属
|
||||||
|
- knowledge: 秘密的知情状态
|
||||||
|
- relation: 硬性关系(在一起/决裂)
|
||||||
|
- rule: 环境规则/契约限制
|
||||||
</task_settings>
|
</task_settings>
|
||||||
---
|
---
|
||||||
Story Analyst:
|
Story Analyst:
|
||||||
[Responsibility Definition]
|
[Responsibility Definition]
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
analysis_task:
|
analysis_task:
|
||||||
title: Incremental Story Summarization
|
title: Incremental Story Summarization with World State
|
||||||
Story Analyst:
|
Story Analyst:
|
||||||
role: Antigravity
|
role: Antigravity
|
||||||
task: >-
|
task: >-
|
||||||
To analyze provided dialogue content against existing summary state,
|
To analyze provided dialogue content against existing summary state,
|
||||||
extract only NEW plot elements, character developments, relationship
|
extract only NEW plot elements, character developments, relationship
|
||||||
changes, and arc progressions, outputting structured JSON for
|
changes, arc progressions, AND world state changes, outputting
|
||||||
incremental summary database updates.
|
structured JSON for incremental summary database updates.
|
||||||
assistant:
|
assistant:
|
||||||
role: Summary Specialist
|
role: Summary Specialist
|
||||||
description: Incremental Story Summary Analyst
|
description: Incremental Story Summary & World State Analyst
|
||||||
behavior: >-
|
behavior: >-
|
||||||
To compare new dialogue against existing summary, identify genuinely
|
To compare new dialogue against existing summary, identify genuinely
|
||||||
new events and character interactions, classify events by narrative
|
new events and character interactions, classify events by narrative
|
||||||
type and weight, track character arc progression with percentage,
|
type and weight, track character arc progression with percentage,
|
||||||
|
maintain world state as key-value updates with clear flags,
|
||||||
and output structured JSON containing only incremental updates.
|
and output structured JSON containing only incremental updates.
|
||||||
Must strictly avoid repeating any existing summary content.
|
Must strictly avoid repeating any existing summary content.
|
||||||
user:
|
user:
|
||||||
@@ -71,7 +73,7 @@ analysis_task:
|
|||||||
description: Supplies existing summary state and new dialogue
|
description: Supplies existing summary state and new dialogue
|
||||||
behavior: >-
|
behavior: >-
|
||||||
To provide existing summary state (events, characters, relationships,
|
To provide existing summary state (events, characters, relationships,
|
||||||
arcs) and new dialogue content for incremental analysis.
|
arcs, world state) and new dialogue content for incremental analysis.
|
||||||
interaction_mode:
|
interaction_mode:
|
||||||
type: incremental_analysis
|
type: incremental_analysis
|
||||||
output_format: structured_json
|
output_format: structured_json
|
||||||
@@ -80,6 +82,7 @@ execution_context:
|
|||||||
summary_active: true
|
summary_active: true
|
||||||
incremental_only: true
|
incremental_only: true
|
||||||
memory_album_style: true
|
memory_album_style: true
|
||||||
|
world_state_tracking: true
|
||||||
\`\`\`
|
\`\`\`
|
||||||
---
|
---
|
||||||
Summary Specialist:
|
Summary Specialist:
|
||||||
@@ -102,6 +105,12 @@ Acknowledged. Now reviewing the incremental summarization specifications:
|
|||||||
├─ progress: 0.0 to 1.0
|
├─ progress: 0.0 to 1.0
|
||||||
└─ newMoment: 仅记录本次新增的关键时刻
|
└─ newMoment: 仅记录本次新增的关键时刻
|
||||||
|
|
||||||
|
[World State Maintenance]
|
||||||
|
├─ 维护方式: Key-Value 覆盖(category + topic 为键)
|
||||||
|
├─ 只输出有变化的条目
|
||||||
|
├─ 清除时使用 cleared: true,不要填 content
|
||||||
|
└─ 不记录情绪、衣着、临时动作
|
||||||
|
|
||||||
Ready to process incremental summary requests with strict deduplication.`,
|
Ready to process incremental summary requests with strict deduplication.`,
|
||||||
|
|
||||||
assistantAskSummary: `
|
assistantAskSummary: `
|
||||||
@@ -110,7 +119,8 @@ Specifications internalized. Please provide the existing summary state so I can:
|
|||||||
1. Index all recorded events to avoid duplication
|
1. Index all recorded events to avoid duplication
|
||||||
2. Map current character relationships as baseline
|
2. Map current character relationships as baseline
|
||||||
3. Note existing arc progress levels
|
3. Note existing arc progress levels
|
||||||
4. Identify established keywords`,
|
4. Identify established keywords
|
||||||
|
5. Review current world state (category + topic baseline)`,
|
||||||
|
|
||||||
assistantAskContent: `
|
assistantAskContent: `
|
||||||
Summary Specialist:
|
Summary Specialist:
|
||||||
@@ -118,7 +128,8 @@ Existing summary fully analyzed and indexed. I understand:
|
|||||||
├─ Recorded events: Indexed for deduplication
|
├─ Recorded events: Indexed for deduplication
|
||||||
├─ Character relationships: Baseline mapped
|
├─ Character relationships: Baseline mapped
|
||||||
├─ Arc progress: Levels noted
|
├─ Arc progress: Levels noted
|
||||||
└─ Keywords: Current state acknowledged
|
├─ Keywords: Current state acknowledged
|
||||||
|
└─ World state: Baseline loaded
|
||||||
|
|
||||||
I will extract only genuinely NEW elements from the upcoming dialogue.
|
I will extract only genuinely NEW elements from the upcoming dialogue.
|
||||||
Please provide the new dialogue content requiring incremental analysis.`,
|
Please provide the new dialogue content requiring incremental analysis.`,
|
||||||
@@ -139,13 +150,15 @@ Before generating, observe the USER and analyze carefully:
|
|||||||
- What NEW characters appeared for the first time?
|
- What NEW characters appeared for the first time?
|
||||||
- What relationship CHANGES happened?
|
- What relationship CHANGES happened?
|
||||||
- What arc PROGRESS was made?
|
- What arc PROGRESS was made?
|
||||||
|
- What world state changes occurred? (status/inventory/knowledge/relation/rule)
|
||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{
|
{
|
||||||
"mindful_prelude": {
|
"mindful_prelude": {
|
||||||
"user_insight": 用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
|
"user_insight": "用户的幻想是什么时空、场景,是否反应出存在严重心理问题需要建议?",
|
||||||
"dedup_analysis": "已有X个事件,本次识别Y个新事件",
|
"dedup_analysis": "已有X个事件,本次识别Y个新事件",
|
||||||
|
"world_changes": "识别到的世界状态变化概述,仅精选不记录则可能导致吃书的硬状态变化"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
{"text": "综合已有+新内容的全局关键词(5-10个)", "weight": "核心|重要|一般"}
|
{"text": "综合已有+新内容的全局关键词(5-10个)", "weight": "核心|重要|一般"}
|
||||||
@@ -167,16 +180,40 @@ Before generating, observe the USER and analyze carefully:
|
|||||||
],
|
],
|
||||||
"arcUpdates": [
|
"arcUpdates": [
|
||||||
{"name": "角色名", "trajectory": "完整弧光链(30字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
|
{"name": "角色名", "trajectory": "完整弧光链(30字内)", "progress": 0.0-1.0, "newMoment": "本次新增的关键时刻"}
|
||||||
|
],
|
||||||
|
"worldUpdate": [
|
||||||
|
{
|
||||||
|
"category": "status|inventory|knowledge|relation|rule",
|
||||||
|
"topic": "主体名称(人/物/关系/规则)",
|
||||||
|
"content": "当前状态描述",
|
||||||
|
"cleared": true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
## Field Guidelines
|
||||||
|
|
||||||
|
### worldUpdate(世界状态·硬约束KV表)
|
||||||
|
- category 固定 5 选 1:status / inventory / knowledge / relation / rule
|
||||||
|
- topic 命名规范:
|
||||||
|
- status:「角色名::状态类型」如 张三::生死、李四::位置、王五::伤势
|
||||||
|
- knowledge:「角色名::知情事项」如 张三::知道某秘密、李四::知道真相
|
||||||
|
- relation:「角色A::与角色B关系」如 张三::与李四关系
|
||||||
|
- inventory:物品名称,如 钥匙、信物、武器
|
||||||
|
- rule:规则/契约名称,如 门禁时间、魔法契约、禁令
|
||||||
|
- content:当前状态的简短描述
|
||||||
|
- cleared: true 表示该条目已失效需删除(不填 content)
|
||||||
|
- status/knowledge/relation 的 topic 必须包含「::」分隔符
|
||||||
|
- 硬约束才记录,避免叙事化,确保少、硬、稳定、可覆盖
|
||||||
|
|
||||||
## CRITICAL NOTES
|
## CRITICAL NOTES
|
||||||
- events.id 从 evt-{nextEventId} 开始编号
|
- events.id 从 evt-{nextEventId} 开始编号
|
||||||
- 仅输出【增量】内容,已有事件绝不重复
|
- 仅输出【增量】内容,已有事件绝不重复
|
||||||
- keywords 是全局关键词,综合已有+新增
|
- keywords 是全局关键词,综合已有+新增
|
||||||
|
- worldUpdate 可为空数组
|
||||||
- 合法JSON,字符串值内部避免英文双引号
|
- 合法JSON,字符串值内部避免英文双引号
|
||||||
- Output single valid JSON only
|
- 用小说家的细腻笔触记录,带烟火气
|
||||||
</meta_protocol>`,
|
</meta_protocol>`,
|
||||||
|
|
||||||
assistantCheck: `Content review initiated...
|
assistantCheck: `Content review initiated...
|
||||||
@@ -185,6 +222,7 @@ Before generating, observe the USER and analyze carefully:
|
|||||||
├─ New dialogue received: ✓ Content parsed
|
├─ New dialogue received: ✓ Content parsed
|
||||||
├─ Deduplication engine: ✓ Active
|
├─ Deduplication engine: ✓ Active
|
||||||
├─ Event classification: ✓ Ready
|
├─ Event classification: ✓ Ready
|
||||||
|
├─ World state tracking: ✓ Enabled
|
||||||
└─ Output format: ✓ JSON specification loaded
|
└─ Output format: ✓ JSON specification loaded
|
||||||
|
|
||||||
[Material Verification]
|
[Material Verification]
|
||||||
@@ -192,6 +230,7 @@ Before generating, observe the USER and analyze carefully:
|
|||||||
├─ Character baseline: Mapped
|
├─ Character baseline: Mapped
|
||||||
├─ Relationship baseline: Mapped
|
├─ Relationship baseline: Mapped
|
||||||
├─ Arc progress baseline: Noted
|
├─ Arc progress baseline: Noted
|
||||||
|
├─ World state: Baseline loaded
|
||||||
└─ Output specification: ✓ Defined in <meta_protocol>
|
└─ Output specification: ✓ Defined in <meta_protocol>
|
||||||
All checks passed. Beginning incremental extraction...
|
All checks passed. Beginning incremental extraction...
|
||||||
{
|
{
|
||||||
@@ -236,25 +275,55 @@ function waitForStreamingComplete(sessionId, streamingMod, timeout = 120000) {
|
|||||||
// 提示词构建
|
// 提示词构建
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function buildSummaryMessages(existingSummary, newHistoryText, historyRange, nextEventId, existingEventCount) {
|
function formatWorldForLLM(worldList) {
|
||||||
// 替换动态内容
|
if (!worldList?.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 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') || '(空白,尚无世界状态记录)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummaryMessages(existingSummary, existingWorld, newHistoryText, historyRange, nextEventId, existingEventCount) {
|
||||||
|
const worldStateText = formatWorldForLLM(existingWorld);
|
||||||
|
|
||||||
const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat
|
const jsonFormat = LLM_PROMPT_CONFIG.userJsonFormat
|
||||||
.replace(/\{nextEventId\}/g, String(nextEventId));
|
.replace(/\{nextEventId\}/g, String(nextEventId));
|
||||||
|
|
||||||
const checkContent = LLM_PROMPT_CONFIG.assistantCheck
|
const checkContent = LLM_PROMPT_CONFIG.assistantCheck
|
||||||
.replace(/\{existingEventCount\}/g, String(existingEventCount));
|
.replace(/\{existingEventCount\}/g, String(existingEventCount));
|
||||||
|
|
||||||
// 顶部消息:系统设定 + 多轮对话引导
|
|
||||||
const topMessages = [
|
const topMessages = [
|
||||||
{ role: 'system', content: LLM_PROMPT_CONFIG.topSystem },
|
{ role: 'system', content: LLM_PROMPT_CONFIG.topSystem },
|
||||||
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantDoc },
|
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantDoc },
|
||||||
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskSummary },
|
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskSummary },
|
||||||
{ role: 'user', content: `<已有总结状态>\n${existingSummary}\n</已有总结状态>` },
|
{ role: 'user', content: `<已有总结状态>\n${existingSummary}\n</已有总结状态>\n\n<当前世界状态>\n${worldStateText}\n</当前世界状态>` },
|
||||||
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskContent },
|
{ role: 'assistant', content: LLM_PROMPT_CONFIG.assistantAskContent },
|
||||||
{ role: 'user', content: `<新对话内容>(${historyRange})\n${newHistoryText}\n</新对话内容>` }
|
{ role: 'user', content: `<新对话内容>(${historyRange})\n${newHistoryText}\n</新对话内容>` }
|
||||||
];
|
];
|
||||||
|
|
||||||
// 底部消息:元协议 + 格式要求 + 合规检查 + 催促
|
|
||||||
const bottomMessages = [
|
const bottomMessages = [
|
||||||
{ role: 'user', content: LLM_PROMPT_CONFIG.metaProtocolStart + '\n' + jsonFormat },
|
{ role: 'user', content: LLM_PROMPT_CONFIG.metaProtocolStart + '\n' + jsonFormat },
|
||||||
{ role: 'assistant', content: checkContent },
|
{ role: 'assistant', content: checkContent },
|
||||||
@@ -274,26 +343,24 @@ function buildSummaryMessages(existingSummary, newHistoryText, historyRange, nex
|
|||||||
|
|
||||||
export function parseSummaryJson(raw) {
|
export function parseSummaryJson(raw) {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
let cleaned = String(raw).trim()
|
let cleaned = String(raw).trim()
|
||||||
.replace(/^```(?:json)?\s*/i, "")
|
.replace(/^```(?:json)?\s*/i, "")
|
||||||
.replace(/\s*```$/i, "")
|
.replace(/\s*```$/i, "")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// 直接解析
|
try {
|
||||||
try {
|
return JSON.parse(cleaned);
|
||||||
return JSON.parse(cleaned);
|
} catch { }
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// 提取 JSON 对象
|
|
||||||
const start = cleaned.indexOf('{');
|
const start = cleaned.indexOf('{');
|
||||||
const end = cleaned.lastIndexOf('}');
|
const end = cleaned.lastIndexOf('}');
|
||||||
if (start !== -1 && end > start) {
|
if (start !== -1 && end > start) {
|
||||||
let jsonStr = cleaned.slice(start, end + 1)
|
let jsonStr = cleaned.slice(start, end + 1)
|
||||||
.replace(/,(\s*[}\]])/g, '$1'); // 移除尾部逗号
|
.replace(/,(\s*[}\]])/g, '$1');
|
||||||
try {
|
try {
|
||||||
return JSON.parse(jsonStr);
|
return JSON.parse(jsonStr);
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -306,6 +373,7 @@ export function parseSummaryJson(raw) {
|
|||||||
export async function generateSummary(options) {
|
export async function generateSummary(options) {
|
||||||
const {
|
const {
|
||||||
existingSummary,
|
existingSummary,
|
||||||
|
existingWorld,
|
||||||
newHistoryText,
|
newHistoryText,
|
||||||
historyRange,
|
historyRange,
|
||||||
nextEventId,
|
nextEventId,
|
||||||
@@ -327,9 +395,10 @@ export async function generateSummary(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const promptData = buildSummaryMessages(
|
const promptData = buildSummaryMessages(
|
||||||
existingSummary,
|
existingSummary,
|
||||||
newHistoryText,
|
existingWorld,
|
||||||
historyRange,
|
newHistoryText,
|
||||||
|
historyRange,
|
||||||
nextEventId,
|
nextEventId,
|
||||||
existingEventCount
|
existingEventCount
|
||||||
);
|
);
|
||||||
@@ -343,7 +412,6 @@ export async function generateSummary(options) {
|
|||||||
id: sessionId,
|
id: sessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// API 配置(非酒馆主 API)
|
|
||||||
if (llmApi.provider && llmApi.provider !== 'st') {
|
if (llmApi.provider && llmApi.provider !== 'st') {
|
||||||
const mappedApi = PROVIDER_MAP[String(llmApi.provider).toLowerCase()];
|
const mappedApi = PROVIDER_MAP[String(llmApi.provider).toLowerCase()];
|
||||||
if (mappedApi) {
|
if (mappedApi) {
|
||||||
@@ -354,14 +422,12 @@ export async function generateSummary(options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成参数
|
|
||||||
if (genParams.temperature != null) args.temperature = genParams.temperature;
|
if (genParams.temperature != null) args.temperature = genParams.temperature;
|
||||||
if (genParams.top_p != null) args.top_p = genParams.top_p;
|
if (genParams.top_p != null) args.top_p = genParams.top_p;
|
||||||
if (genParams.top_k != null) args.top_k = genParams.top_k;
|
if (genParams.top_k != null) args.top_k = genParams.top_k;
|
||||||
if (genParams.presence_penalty != null) args.presence_penalty = genParams.presence_penalty;
|
if (genParams.presence_penalty != null) args.presence_penalty = genParams.presence_penalty;
|
||||||
if (genParams.frequency_penalty != null) args.frequency_penalty = genParams.frequency_penalty;
|
if (genParams.frequency_penalty != null) args.frequency_penalty = genParams.frequency_penalty;
|
||||||
|
|
||||||
// 调用生成
|
|
||||||
let rawOutput;
|
let rawOutput;
|
||||||
if (useStream) {
|
if (useStream) {
|
||||||
const sid = await streamingMod.xbgenrawCommand(args, '');
|
const sid = await streamingMod.xbgenrawCommand(args, '');
|
||||||
@@ -375,4 +441,4 @@ export async function generateSummary(options) {
|
|||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
|
|
||||||
return rawOutput;
|
return rawOutput;
|
||||||
}
|
}
|
||||||
394
modules/story-summary/generate/prompt.js
Normal file
394
modules/story-summary/generate/prompt.js
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
// Story Summary - Prompt Injection
|
||||||
|
// 注入格式:世界状态 → 亲身经历(含闪回) → 相关背景(含闪回) → 记忆碎片 → 人物弧光
|
||||||
|
|
||||||
|
import { getContext } from "../../../../../../extensions.js";
|
||||||
|
import { extension_prompts, extension_prompt_types, extension_prompt_roles } from "../../../../../../../script.js";
|
||||||
|
import { xbLog } from "../../../core/debug-core.js";
|
||||||
|
import { getSummaryStore } from "../data/store.js";
|
||||||
|
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
|
||||||
|
import { recallMemory, buildQueryText } from "../vector/recall.js";
|
||||||
|
|
||||||
|
const MODULE_ID = "summaryPrompt";
|
||||||
|
const SUMMARY_PROMPT_KEY = "LittleWhiteBox_StorySummary";
|
||||||
|
|
||||||
|
const BUDGET = { total: 10000, l3Max: 2000, l2Max: 5000 };
|
||||||
|
const MAX_CHUNKS_PER_EVENT = 2;
|
||||||
|
const MAX_ORPHAN_CHUNKS = 6;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 工具函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function estimateTokens(text) {
|
||||||
|
if (!text) return 0;
|
||||||
|
const s = String(text);
|
||||||
|
const zh = (s.match(/[\u4e00-\u9fff]/g) || []).length;
|
||||||
|
return Math.ceil(zh + (s.length - zh) / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushWithBudget(lines, text, state) {
|
||||||
|
const t = estimateTokens(text);
|
||||||
|
if (state.used + t > state.max) return false;
|
||||||
|
lines.push(text);
|
||||||
|
state.used += t;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 summary 解析楼层范围:(#321-322) 或 (#321)
|
||||||
|
function parseFloorRange(summary) {
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
// 匹配 (#123-456) 或 (#123)
|
||||||
|
const match = String(summary).match(/\(#(\d+)(?:-(\d+))?\)/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const start = parseInt(match[1], 10);
|
||||||
|
const end = match[2] ? parseInt(match[2], 10) : start;
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去掉 summary 末尾的楼层标记
|
||||||
|
function cleanSummary(summary) {
|
||||||
|
return String(summary || '').replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// L1 → L2 归属
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function attachChunksToEvents(events, chunks) {
|
||||||
|
const usedChunkIds = new Set();
|
||||||
|
|
||||||
|
// 给每个 event 挂载 chunks
|
||||||
|
for (const e of events) {
|
||||||
|
e._chunks = [];
|
||||||
|
const range = parseFloorRange(e.event?.summary);
|
||||||
|
if (!range) continue;
|
||||||
|
|
||||||
|
for (const c of chunks) {
|
||||||
|
if (c.floor >= range.start && c.floor <= range.end) {
|
||||||
|
if (!usedChunkIds.has(c.chunkId)) {
|
||||||
|
e._chunks.push(c);
|
||||||
|
usedChunkIds.add(c.chunkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每个事件最多保留 N 条,按相似度排序
|
||||||
|
e._chunks.sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
|
||||||
|
e._chunks = e._chunks.slice(0, MAX_CHUNKS_PER_EVENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找出无归属的 chunks(记忆碎片)
|
||||||
|
const orphans = chunks
|
||||||
|
.filter(c => !usedChunkIds.has(c.chunkId))
|
||||||
|
.sort((a, b) => (b.similarity || 0) - (a.similarity || 0))
|
||||||
|
.slice(0, MAX_ORPHAN_CHUNKS);
|
||||||
|
|
||||||
|
return { events, orphans };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 格式化函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function formatWorldLines(world) {
|
||||||
|
return [...(world || [])]
|
||||||
|
.sort((a, b) => (b.floor || 0) - (a.floor || 0))
|
||||||
|
.map(w => `- ${w.topic}:${w.content}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChunkLine(c) {
|
||||||
|
const text = String(c.text || '');
|
||||||
|
const preview = text.length > 80 ? text.slice(0, 80) + '...' : text;
|
||||||
|
return `› #${c.floor} ${preview}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventBlock(e, idx) {
|
||||||
|
const ev = e.event || {};
|
||||||
|
const time = ev.timeLabel || '';
|
||||||
|
const people = (ev.participants || []).join(' / ');
|
||||||
|
const summary = cleanSummary(ev.summary);
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
// 标题行
|
||||||
|
const header = time ? `${idx}.【${time}】${people}` : `${idx}. ${people}`;
|
||||||
|
lines.push(header);
|
||||||
|
|
||||||
|
// 摘要
|
||||||
|
lines.push(` ${summary}`);
|
||||||
|
|
||||||
|
// 挂载的闪回
|
||||||
|
for (const c of (e._chunks || [])) {
|
||||||
|
lines.push(` ${formatChunkLine(c)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArcLine(a) {
|
||||||
|
const moments = (a.moments || [])
|
||||||
|
.map(m => typeof m === 'string' ? m : m.text)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (moments.length) {
|
||||||
|
return `- ${a.name}:${moments.join(' → ')}(当前:${a.trajectory})`;
|
||||||
|
}
|
||||||
|
return `- ${a.name}:${a.trajectory}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 主构建函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function buildMemoryPromptVectorEnabled(store, recallResult) {
|
||||||
|
const data = store.json || {};
|
||||||
|
const total = { used: 0, max: BUDGET.total };
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// [世界状态]
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
const worldLines = formatWorldLines(data.world);
|
||||||
|
if (worldLines.length) {
|
||||||
|
const l3 = { used: 0, max: Math.min(BUDGET.l3Max, total.max) };
|
||||||
|
const l3Lines = [];
|
||||||
|
|
||||||
|
for (const line of worldLines) {
|
||||||
|
if (!pushWithBudget(l3Lines, line, l3)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (l3Lines.length) {
|
||||||
|
sections.push(`[世界状态] 请严格遵守\n${l3Lines.join('\n')}`);
|
||||||
|
total.used += l3.used;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// L1 → L2 归属处理
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
const events = recallResult?.events || [];
|
||||||
|
const chunks = recallResult?.chunks || [];
|
||||||
|
const { events: eventsWithChunks, orphans } = attachChunksToEvents(events, chunks);
|
||||||
|
|
||||||
|
// 分离 DIRECT 和 SIMILAR
|
||||||
|
const directEvents = eventsWithChunks.filter(e => e._recallType === 'DIRECT');
|
||||||
|
const similarEvents = eventsWithChunks.filter(e => e._recallType !== 'DIRECT');
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// [亲身经历] - DIRECT
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
if (directEvents.length) {
|
||||||
|
const l2 = { used: 0, max: Math.min(BUDGET.l2Max * 0.7, total.max - total.used) };
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
let idx = 1;
|
||||||
|
for (const e of directEvents) {
|
||||||
|
const block = formatEventBlock(e, idx);
|
||||||
|
if (!pushWithBudget(lines, block, l2)) break;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length) {
|
||||||
|
sections.push(`[亲身经历]\n\n${lines.join('\n\n')}`);
|
||||||
|
total.used += l2.used;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// [相关背景] - SIMILAR
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
if (similarEvents.length) {
|
||||||
|
const l2s = { used: 0, max: Math.min(BUDGET.l2Max * 0.3, total.max - total.used) };
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
let idx = directEvents.length + 1;
|
||||||
|
for (const e of similarEvents) {
|
||||||
|
const block = formatEventBlock(e, idx);
|
||||||
|
if (!pushWithBudget(lines, block, l2s)) break;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length) {
|
||||||
|
sections.push(`[相关背景]\n\n${lines.join('\n\n')}`);
|
||||||
|
total.used += l2s.used;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// [记忆碎片] - 无归属的 chunks
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
if (orphans.length && total.used < total.max) {
|
||||||
|
const l1 = { used: 0, max: total.max - total.used };
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
for (const c of orphans) {
|
||||||
|
const line = formatChunkLine(c);
|
||||||
|
if (!pushWithBudget(lines, line, l1)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length) {
|
||||||
|
sections.push(`[记忆碎片]\n${lines.join('\n')}`);
|
||||||
|
total.used += l1.used;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// [人物弧光]
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
if (data.arcs?.length && total.used < total.max) {
|
||||||
|
const arcLines = data.arcs.map(formatArcLine);
|
||||||
|
const arcText = `[人物弧光]\n${arcLines.join('\n')}`;
|
||||||
|
|
||||||
|
if (total.used + estimateTokens(arcText) <= total.max) {
|
||||||
|
sections.push(arcText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// 组装
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
if (!sections.length) return '';
|
||||||
|
|
||||||
|
return `<剧情记忆>\n\n${sections.join('\n\n')}\n\n</剧情记忆>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMemoryPromptVectorDisabled(store) {
|
||||||
|
const data = store.json || {};
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
// 世界状态
|
||||||
|
if (data.world?.length) {
|
||||||
|
const lines = formatWorldLines(data.world);
|
||||||
|
sections.push(`[世界状态] 请严格遵守\n${lines.join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部事件(无召回,按时间)
|
||||||
|
if (data.events?.length) {
|
||||||
|
const lines = data.events.map((ev, i) => {
|
||||||
|
const time = ev.timeLabel || '';
|
||||||
|
const people = (ev.participants || []).join(' / ');
|
||||||
|
const summary = cleanSummary(ev.summary);
|
||||||
|
const header = time ? `${i + 1}.【${time}】${people}` : `${i + 1}. ${people}`;
|
||||||
|
return `${header}\n ${summary}`;
|
||||||
|
});
|
||||||
|
sections.push(`[剧情记忆]\n\n${lines.join('\n\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弧光
|
||||||
|
if (data.arcs?.length) {
|
||||||
|
const lines = data.arcs.map(formatArcLine);
|
||||||
|
sections.push(`[人物弧光]\n${lines.join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sections.length) return '';
|
||||||
|
|
||||||
|
return `<剧情记忆>\n\n${sections.join('\n\n')}\n\n</剧情记忆>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 导出
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function formatPromptWithMemory(store, recallResult) {
|
||||||
|
const vectorCfg = getVectorConfig();
|
||||||
|
return vectorCfg?.enabled
|
||||||
|
? buildMemoryPromptVectorEnabled(store, recallResult)
|
||||||
|
: buildMemoryPromptVectorDisabled(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recallAndInjectPrompt(excludeLastAi = false, postToFrame = null) {
|
||||||
|
if (!getSettings().storySummary?.enabled) {
|
||||||
|
delete extension_prompts[SUMMARY_PROMPT_KEY];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { chat } = getContext();
|
||||||
|
const store = getSummaryStore();
|
||||||
|
|
||||||
|
if (!store?.json) {
|
||||||
|
delete extension_prompts[SUMMARY_PROMPT_KEY];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allEvents = store.json.events || [];
|
||||||
|
const lastIdx = store.lastSummarizedMesId ?? 0;
|
||||||
|
const length = chat?.length || 0;
|
||||||
|
|
||||||
|
if (lastIdx >= length) {
|
||||||
|
delete extension_prompts[SUMMARY_PROMPT_KEY];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vectorCfg = getVectorConfig();
|
||||||
|
let recallResult = { events: [], chunks: [] };
|
||||||
|
|
||||||
|
if (vectorCfg?.enabled) {
|
||||||
|
try {
|
||||||
|
const queryText = buildQueryText(chat, 2, excludeLastAi);
|
||||||
|
recallResult = await recallMemory(queryText, allEvents, vectorCfg, { excludeLastAi });
|
||||||
|
postToFrame?.({ type: "RECALL_LOG", text: recallResult.logText || "" });
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.error(MODULE_ID, "召回失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
injectPrompt(store, recallResult, chat);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSummaryExtensionPrompt() {
|
||||||
|
if (!getSettings().storySummary?.enabled) {
|
||||||
|
delete extension_prompts[SUMMARY_PROMPT_KEY];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { chat } = getContext();
|
||||||
|
const store = getSummaryStore();
|
||||||
|
|
||||||
|
if (!store?.json || (store.lastSummarizedMesId ?? 0) >= (chat?.length || 0)) {
|
||||||
|
delete extension_prompts[SUMMARY_PROMPT_KEY];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
injectPrompt(store, { events: [], chunks: [] }, chat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectPrompt(store, recallResult, chat) {
|
||||||
|
const length = chat?.length || 0;
|
||||||
|
|
||||||
|
let text = formatPromptWithMemory(store, recallResult);
|
||||||
|
|
||||||
|
const cfg = getSummaryPanelConfig();
|
||||||
|
if (cfg.trigger?.wrapperHead) {
|
||||||
|
text = cfg.trigger.wrapperHead + "\n" + text;
|
||||||
|
}
|
||||||
|
if (cfg.trigger?.wrapperTail) {
|
||||||
|
text = text + "\n" + cfg.trigger.wrapperTail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.trim()) {
|
||||||
|
delete extension_prompts[SUMMARY_PROMPT_KEY];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastIdx = store.lastSummarizedMesId ?? 0;
|
||||||
|
let depth = length - lastIdx - 1;
|
||||||
|
if (depth < 0) depth = 0;
|
||||||
|
|
||||||
|
if (cfg.trigger?.forceInsertAtEnd) {
|
||||||
|
depth = 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension_prompts[SUMMARY_PROMPT_KEY] = {
|
||||||
|
value: text,
|
||||||
|
position: extension_prompt_types.IN_CHAT,
|
||||||
|
depth,
|
||||||
|
role: extension_prompt_roles.ASSISTANT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSummaryExtensionPrompt() {
|
||||||
|
delete extension_prompts[SUMMARY_PROMPT_KEY];
|
||||||
|
}
|
||||||
1718
modules/story-summary/story-summary-ui.js
Normal file
1718
modules/story-summary/story-summary-ui.js
Normal file
File diff suppressed because it is too large
Load Diff
2141
modules/story-summary/story-summary.css
Normal file
2141
modules/story-summary/story-summary.css
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
360
modules/story-summary/vector/chunk-builder.js
Normal file
360
modules/story-summary/vector/chunk-builder.js
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Story Summary - Chunk Builder
|
||||||
|
// 标准 RAG chunking: ~200 tokens per chunk
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { getContext } from '../../../../../../extensions.js';
|
||||||
|
import {
|
||||||
|
getMeta,
|
||||||
|
updateMeta,
|
||||||
|
saveChunks,
|
||||||
|
saveChunkVectors,
|
||||||
|
clearAllChunks,
|
||||||
|
deleteChunksFromFloor,
|
||||||
|
deleteChunksAtFloor,
|
||||||
|
makeChunkId,
|
||||||
|
hashText,
|
||||||
|
CHUNK_MAX_TOKENS,
|
||||||
|
} from './chunk-store.js';
|
||||||
|
import { embed, getEngineFingerprint } from './embedder.js';
|
||||||
|
import { xbLog } from '../../../core/debug-core.js';
|
||||||
|
|
||||||
|
const MODULE_ID = 'chunk-builder';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Token 估算
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function estimateTokens(text) {
|
||||||
|
if (!text) return 0;
|
||||||
|
const chinese = (text.match(/[\u4e00-\u9fff]/g) || []).length;
|
||||||
|
const other = text.length - chinese;
|
||||||
|
return Math.ceil(chinese + other / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSentences(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
const parts = text.split(/(?<=[。!?\n])|(?<=[.!?]\s)/);
|
||||||
|
return parts.map(s => s.trim()).filter(s => s.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Chunk 切分
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function chunkMessage(floor, message, maxTokens = CHUNK_MAX_TOKENS) {
|
||||||
|
const text = message.mes || '';
|
||||||
|
const speaker = message.name || (message.is_user ? '用户' : '角色');
|
||||||
|
const isUser = !!message.is_user;
|
||||||
|
|
||||||
|
const cleanText = text
|
||||||
|
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
||||||
|
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
|
||||||
|
.replace(/\[tts:[^\]]*\]/gi, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!cleanText) return [];
|
||||||
|
|
||||||
|
const totalTokens = estimateTokens(cleanText);
|
||||||
|
|
||||||
|
if (totalTokens <= maxTokens) {
|
||||||
|
return [{
|
||||||
|
chunkId: makeChunkId(floor, 0),
|
||||||
|
floor,
|
||||||
|
chunkIdx: 0,
|
||||||
|
speaker,
|
||||||
|
isUser,
|
||||||
|
text: cleanText,
|
||||||
|
textHash: hashText(cleanText),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentences = splitSentences(cleanText);
|
||||||
|
const chunks = [];
|
||||||
|
let currentSentences = [];
|
||||||
|
let currentTokens = 0;
|
||||||
|
|
||||||
|
for (const sent of sentences) {
|
||||||
|
const sentTokens = estimateTokens(sent);
|
||||||
|
|
||||||
|
if (sentTokens > maxTokens) {
|
||||||
|
if (currentSentences.length > 0) {
|
||||||
|
const chunkText = currentSentences.join('');
|
||||||
|
chunks.push({
|
||||||
|
chunkId: makeChunkId(floor, chunks.length),
|
||||||
|
floor,
|
||||||
|
chunkIdx: chunks.length,
|
||||||
|
speaker,
|
||||||
|
isUser,
|
||||||
|
text: chunkText,
|
||||||
|
textHash: hashText(chunkText),
|
||||||
|
});
|
||||||
|
currentSentences = [];
|
||||||
|
currentTokens = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliceSize = maxTokens * 2;
|
||||||
|
for (let i = 0; i < sent.length; i += sliceSize) {
|
||||||
|
const slice = sent.slice(i, i + sliceSize);
|
||||||
|
chunks.push({
|
||||||
|
chunkId: makeChunkId(floor, chunks.length),
|
||||||
|
floor,
|
||||||
|
chunkIdx: chunks.length,
|
||||||
|
speaker,
|
||||||
|
isUser,
|
||||||
|
text: slice,
|
||||||
|
textHash: hashText(slice),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTokens + sentTokens > maxTokens && currentSentences.length > 0) {
|
||||||
|
const chunkText = currentSentences.join('');
|
||||||
|
chunks.push({
|
||||||
|
chunkId: makeChunkId(floor, chunks.length),
|
||||||
|
floor,
|
||||||
|
chunkIdx: chunks.length,
|
||||||
|
speaker,
|
||||||
|
isUser,
|
||||||
|
text: chunkText,
|
||||||
|
textHash: hashText(chunkText),
|
||||||
|
});
|
||||||
|
currentSentences = [];
|
||||||
|
currentTokens = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSentences.push(sent);
|
||||||
|
currentTokens += sentTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSentences.length > 0) {
|
||||||
|
const chunkText = currentSentences.join('');
|
||||||
|
chunks.push({
|
||||||
|
chunkId: makeChunkId(floor, chunks.length),
|
||||||
|
floor,
|
||||||
|
chunkIdx: chunks.length,
|
||||||
|
speaker,
|
||||||
|
isUser,
|
||||||
|
text: chunkText,
|
||||||
|
textHash: hashText(chunkText),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 构建状态
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function getChunkBuildStatus() {
|
||||||
|
const { chat, chatId } = getContext();
|
||||||
|
if (!chatId) {
|
||||||
|
return { totalFloors: 0, builtFloors: 0, pending: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await getMeta(chatId);
|
||||||
|
const totalFloors = chat?.length || 0;
|
||||||
|
const builtFloors = meta.lastChunkFloor + 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalFloors,
|
||||||
|
builtFloors,
|
||||||
|
lastChunkFloor: meta.lastChunkFloor,
|
||||||
|
pending: Math.max(0, totalFloors - builtFloors),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 全量构建
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function buildAllChunks(options = {}) {
|
||||||
|
const { onProgress, shouldCancel, vectorConfig } = options;
|
||||||
|
|
||||||
|
const { chat, chatId } = getContext();
|
||||||
|
if (!chatId || !chat?.length) {
|
||||||
|
return { built: 0, errors: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = getEngineFingerprint(vectorConfig);
|
||||||
|
|
||||||
|
await clearAllChunks(chatId);
|
||||||
|
await updateMeta(chatId, { lastChunkFloor: -1, fingerprint });
|
||||||
|
|
||||||
|
const allChunks = [];
|
||||||
|
for (let floor = 0; floor < chat.length; floor++) {
|
||||||
|
const chunks = chunkMessage(floor, chat[floor]);
|
||||||
|
allChunks.push(...chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allChunks.length === 0) {
|
||||||
|
return { built: 0, errors: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, `开始构建 ${allChunks.length} 个 chunks(${chat.length} 层楼)`);
|
||||||
|
|
||||||
|
await saveChunks(chatId, allChunks);
|
||||||
|
|
||||||
|
const texts = allChunks.map(c => c.text);
|
||||||
|
const isLocal = vectorConfig.engine === 'local';
|
||||||
|
const batchSize = isLocal ? 5 : 20;
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
let errors = 0;
|
||||||
|
const allVectors = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < texts.length; i += batchSize) {
|
||||||
|
if (shouldCancel?.()) break;
|
||||||
|
|
||||||
|
const batch = texts.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vectors = await embed(batch, vectorConfig);
|
||||||
|
allVectors.push(...vectors);
|
||||||
|
completed += batch.length;
|
||||||
|
onProgress?.(completed, texts.length);
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.error(MODULE_ID, `批次 ${i}/${texts.length} 向量化失败`, e);
|
||||||
|
allVectors.push(...batch.map(() => null));
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldCancel?.()) {
|
||||||
|
return { built: completed, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const vectorItems = allChunks
|
||||||
|
.map((chunk, idx) => allVectors[idx] ? { chunkId: chunk.chunkId, vector: allVectors[idx] } : null)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (vectorItems.length > 0) {
|
||||||
|
await saveChunkVectors(chatId, vectorItems, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateMeta(chatId, { lastChunkFloor: chat.length - 1 });
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, `构建完成:${vectorItems.length} 个向量,${errors} 个错误`);
|
||||||
|
|
||||||
|
return { built: vectorItems.length, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 增量构建
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function buildIncrementalChunks(options = {}) {
|
||||||
|
const { vectorConfig } = options;
|
||||||
|
|
||||||
|
const { chat, chatId } = getContext();
|
||||||
|
if (!chatId || !chat?.length) {
|
||||||
|
return { built: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await getMeta(chatId);
|
||||||
|
const fingerprint = getEngineFingerprint(vectorConfig);
|
||||||
|
|
||||||
|
if (meta.fingerprint && meta.fingerprint !== fingerprint) {
|
||||||
|
xbLog.warn(MODULE_ID, '引擎指纹不匹配,跳过增量构建');
|
||||||
|
return { built: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startFloor = meta.lastChunkFloor + 1;
|
||||||
|
if (startFloor >= chat.length) {
|
||||||
|
return { built: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, `增量构建 ${startFloor} - ${chat.length - 1} 层`);
|
||||||
|
|
||||||
|
const newChunks = [];
|
||||||
|
for (let floor = startFloor; floor < chat.length; floor++) {
|
||||||
|
const chunks = chunkMessage(floor, chat[floor]);
|
||||||
|
newChunks.push(...chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newChunks.length === 0) {
|
||||||
|
await updateMeta(chatId, { lastChunkFloor: chat.length - 1 });
|
||||||
|
return { built: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveChunks(chatId, newChunks);
|
||||||
|
|
||||||
|
const texts = newChunks.map(c => c.text);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vectors = await embed(texts, vectorConfig);
|
||||||
|
const vectorItems = newChunks.map((chunk, idx) => ({
|
||||||
|
chunkId: chunk.chunkId,
|
||||||
|
vector: vectors[idx],
|
||||||
|
}));
|
||||||
|
await saveChunkVectors(chatId, vectorItems, fingerprint);
|
||||||
|
await updateMeta(chatId, { lastChunkFloor: chat.length - 1 });
|
||||||
|
|
||||||
|
return { built: vectorItems.length };
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.error(MODULE_ID, '增量向量化失败', e);
|
||||||
|
return { built: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// L1 同步(消息变化时调用)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息删除后同步:删除 floor >= newLength 的 chunk
|
||||||
|
*/
|
||||||
|
export async function syncOnMessageDeleted(chatId, newLength) {
|
||||||
|
if (!chatId || newLength < 0) return;
|
||||||
|
|
||||||
|
await deleteChunksFromFloor(chatId, newLength);
|
||||||
|
await updateMeta(chatId, { lastChunkFloor: newLength - 1 });
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, `消息删除同步:删除 floor >= ${newLength}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* swipe 后同步:删除最后楼层的 chunk(等待后续重建)
|
||||||
|
*/
|
||||||
|
export async function syncOnMessageSwiped(chatId, lastFloor) {
|
||||||
|
if (!chatId || lastFloor < 0) return;
|
||||||
|
|
||||||
|
await deleteChunksAtFloor(chatId, lastFloor);
|
||||||
|
await updateMeta(chatId, { lastChunkFloor: lastFloor - 1 });
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, `swipe 同步:删除 floor ${lastFloor}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新消息后同步:删除 + 重建最后楼层
|
||||||
|
*/
|
||||||
|
export async function syncOnMessageReceived(chatId, lastFloor, message, vectorConfig) {
|
||||||
|
if (!chatId || lastFloor < 0 || !message) return;
|
||||||
|
if (!vectorConfig?.enabled) return;
|
||||||
|
|
||||||
|
// 删除该楼层旧的
|
||||||
|
await deleteChunksAtFloor(chatId, lastFloor);
|
||||||
|
|
||||||
|
// 重建
|
||||||
|
const chunks = chunkMessage(lastFloor, message);
|
||||||
|
if (chunks.length === 0) return;
|
||||||
|
|
||||||
|
await saveChunks(chatId, chunks);
|
||||||
|
|
||||||
|
// 向量化
|
||||||
|
const fingerprint = getEngineFingerprint(vectorConfig);
|
||||||
|
const texts = chunks.map(c => c.text);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vectors = await embed(texts, vectorConfig);
|
||||||
|
const items = chunks.map((c, i) => ({ chunkId: c.chunkId, vector: vectors[i] }));
|
||||||
|
await saveChunkVectors(chatId, items, fingerprint);
|
||||||
|
await updateMeta(chatId, { lastChunkFloor: lastFloor });
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, `消息同步:重建 floor ${lastFloor},${chunks.length} 个 chunk`);
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.error(MODULE_ID, `消息同步失败:floor ${lastFloor}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
247
modules/story-summary/vector/chunk-store.js
Normal file
247
modules/story-summary/vector/chunk-store.js
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Story Summary - Chunk Store (L1/L2 storage)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import {
|
||||||
|
metaTable,
|
||||||
|
chunksTable,
|
||||||
|
chunkVectorsTable,
|
||||||
|
eventVectorsTable,
|
||||||
|
CHUNK_MAX_TOKENS,
|
||||||
|
} from '../data/db.js';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 工具函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function float32ToBuffer(arr) {
|
||||||
|
return arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bufferToFloat32(buffer) {
|
||||||
|
return new Float32Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeChunkId(floor, chunkIdx) {
|
||||||
|
return `c-${floor}-${chunkIdx}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashText(text) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return hash.toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Meta 表操作
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function getMeta(chatId) {
|
||||||
|
let meta = await metaTable.get(chatId);
|
||||||
|
if (!meta) {
|
||||||
|
meta = {
|
||||||
|
chatId,
|
||||||
|
fingerprint: null,
|
||||||
|
lastChunkFloor: -1,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await metaTable.put(meta);
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMeta(chatId, updates) {
|
||||||
|
await metaTable.update(chatId, {
|
||||||
|
...updates,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Chunks 表操作
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function saveChunks(chatId, chunks) {
|
||||||
|
const records = chunks.map(chunk => ({
|
||||||
|
chatId,
|
||||||
|
chunkId: chunk.chunkId,
|
||||||
|
floor: chunk.floor,
|
||||||
|
chunkIdx: chunk.chunkIdx,
|
||||||
|
speaker: chunk.speaker,
|
||||||
|
isUser: chunk.isUser,
|
||||||
|
text: chunk.text,
|
||||||
|
textHash: chunk.textHash,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}));
|
||||||
|
await chunksTable.bulkPut(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllChunks(chatId) {
|
||||||
|
return await chunksTable.where('chatId').equals(chatId).toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChunksByFloors(chatId, floors) {
|
||||||
|
const chunks = await chunksTable
|
||||||
|
.where('[chatId+floor]')
|
||||||
|
.anyOf(floors.map(f => [chatId, f]))
|
||||||
|
.toArray();
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定楼层及之后的所有 chunk 和向量
|
||||||
|
*/
|
||||||
|
export async function deleteChunksFromFloor(chatId, fromFloor) {
|
||||||
|
const chunks = await chunksTable
|
||||||
|
.where('chatId')
|
||||||
|
.equals(chatId)
|
||||||
|
.filter(c => c.floor >= fromFloor)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const chunkIds = chunks.map(c => c.chunkId);
|
||||||
|
|
||||||
|
await chunksTable
|
||||||
|
.where('chatId')
|
||||||
|
.equals(chatId)
|
||||||
|
.filter(c => c.floor >= fromFloor)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
for (const chunkId of chunkIds) {
|
||||||
|
await chunkVectorsTable.delete([chatId, chunkId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定楼层的 chunk 和向量
|
||||||
|
*/
|
||||||
|
export async function deleteChunksAtFloor(chatId, floor) {
|
||||||
|
const chunks = await chunksTable
|
||||||
|
.where('[chatId+floor]')
|
||||||
|
.equals([chatId, floor])
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const chunkIds = chunks.map(c => c.chunkId);
|
||||||
|
|
||||||
|
await chunksTable.where('[chatId+floor]').equals([chatId, floor]).delete();
|
||||||
|
|
||||||
|
for (const chunkId of chunkIds) {
|
||||||
|
await chunkVectorsTable.delete([chatId, chunkId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAllChunks(chatId) {
|
||||||
|
await chunksTable.where('chatId').equals(chatId).delete();
|
||||||
|
await chunkVectorsTable.where('chatId').equals(chatId).delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// ChunkVectors 表操作
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function saveChunkVectors(chatId, items, fingerprint) {
|
||||||
|
const records = items.map(item => ({
|
||||||
|
chatId,
|
||||||
|
chunkId: item.chunkId,
|
||||||
|
vector: float32ToBuffer(new Float32Array(item.vector)),
|
||||||
|
dims: item.vector.length,
|
||||||
|
fingerprint,
|
||||||
|
}));
|
||||||
|
await chunkVectorsTable.bulkPut(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllChunkVectors(chatId) {
|
||||||
|
const records = await chunkVectorsTable.where('chatId').equals(chatId).toArray();
|
||||||
|
return records.map(r => ({
|
||||||
|
...r,
|
||||||
|
vector: bufferToFloat32(r.vector),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// EventVectors 表操作
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function saveEventVectors(chatId, items, fingerprint) {
|
||||||
|
const records = items.map(item => ({
|
||||||
|
chatId,
|
||||||
|
eventId: item.eventId,
|
||||||
|
vector: float32ToBuffer(new Float32Array(item.vector)),
|
||||||
|
dims: item.vector.length,
|
||||||
|
fingerprint,
|
||||||
|
}));
|
||||||
|
await eventVectorsTable.bulkPut(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllEventVectors(chatId) {
|
||||||
|
const records = await eventVectorsTable.where('chatId').equals(chatId).toArray();
|
||||||
|
return records.map(r => ({
|
||||||
|
...r,
|
||||||
|
vector: bufferToFloat32(r.vector),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearEventVectors(chatId) {
|
||||||
|
await eventVectorsTable.where('chatId').equals(chatId).delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 ID 列表删除 event 向量
|
||||||
|
*/
|
||||||
|
export async function deleteEventVectorsByIds(chatId, eventIds) {
|
||||||
|
for (const eventId of eventIds) {
|
||||||
|
await eventVectorsTable.delete([chatId, eventId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 统计与工具
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function getStorageStats(chatId) {
|
||||||
|
const [meta, chunkCount, chunkVectorCount, eventCount] = await Promise.all([
|
||||||
|
getMeta(chatId),
|
||||||
|
chunksTable.where('chatId').equals(chatId).count(),
|
||||||
|
chunkVectorsTable.where('chatId').equals(chatId).count(),
|
||||||
|
eventVectorsTable.where('chatId').equals(chatId).count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fingerprint: meta.fingerprint,
|
||||||
|
lastChunkFloor: meta.lastChunkFloor,
|
||||||
|
chunks: chunkCount,
|
||||||
|
chunkVectors: chunkVectorCount,
|
||||||
|
eventVectors: eventCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearChatData(chatId) {
|
||||||
|
await Promise.all([
|
||||||
|
metaTable.delete(chatId),
|
||||||
|
chunksTable.where('chatId').equals(chatId).delete(),
|
||||||
|
chunkVectorsTable.where('chatId').equals(chatId).delete(),
|
||||||
|
eventVectorsTable.where('chatId').equals(chatId).delete(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureFingerprintMatch(chatId, newFingerprint) {
|
||||||
|
const meta = await getMeta(chatId);
|
||||||
|
if (meta.fingerprint && meta.fingerprint !== newFingerprint) {
|
||||||
|
await Promise.all([
|
||||||
|
chunkVectorsTable.where('chatId').equals(chatId).delete(),
|
||||||
|
eventVectorsTable.where('chatId').equals(chatId).delete(),
|
||||||
|
]);
|
||||||
|
await updateMeta(chatId, {
|
||||||
|
fingerprint: newFingerprint,
|
||||||
|
lastChunkFloor: -1,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!meta.fingerprint) {
|
||||||
|
await updateMeta(chatId, { fingerprint: newFingerprint });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CHUNK_MAX_TOKENS };
|
||||||
624
modules/story-summary/vector/embedder.js
Normal file
624
modules/story-summary/vector/embedder.js
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Story Summary - Embedding Service
|
||||||
|
// 统一的向量生成接口(本地模型 / 在线服务)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { xbLog } from '../../../core/debug-core.js';
|
||||||
|
|
||||||
|
const MODULE_ID = 'embedding';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 本地模型配置
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export const LOCAL_MODELS = {
|
||||||
|
'bge-small-zh': {
|
||||||
|
id: 'bge-small-zh',
|
||||||
|
name: '中文轻量 (51MB)',
|
||||||
|
hfId: 'Xenova/bge-small-zh-v1.5',
|
||||||
|
dims: 512,
|
||||||
|
desc: '手机/低配适用',
|
||||||
|
},
|
||||||
|
'bge-base-zh': {
|
||||||
|
id: 'bge-base-zh',
|
||||||
|
name: '中文标准 (102MB)',
|
||||||
|
hfId: 'Xenova/bge-base-zh-v1.5',
|
||||||
|
dims: 768,
|
||||||
|
desc: 'PC 推荐,效果更好',
|
||||||
|
},
|
||||||
|
'e5-small': {
|
||||||
|
id: 'e5-small',
|
||||||
|
name: '多语言 (118MB)',
|
||||||
|
hfId: 'Xenova/multilingual-e5-small',
|
||||||
|
dims: 384,
|
||||||
|
desc: '非中文用户',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_LOCAL_MODEL = 'bge-small-zh';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 在线服务配置
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export const ONLINE_PROVIDERS = {
|
||||||
|
siliconflow: {
|
||||||
|
id: 'siliconflow',
|
||||||
|
name: '硅基流动',
|
||||||
|
baseUrl: 'https://api.siliconflow.cn',
|
||||||
|
canFetchModels: false,
|
||||||
|
defaultModels: [
|
||||||
|
'BAAI/bge-m3',
|
||||||
|
'BAAI/bge-large-zh-v1.5',
|
||||||
|
'BAAI/bge-small-zh-v1.5',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cohere: {
|
||||||
|
id: 'cohere',
|
||||||
|
name: 'Cohere',
|
||||||
|
baseUrl: 'https://api.cohere.ai',
|
||||||
|
canFetchModels: false,
|
||||||
|
defaultModels: [
|
||||||
|
'embed-multilingual-v3.0',
|
||||||
|
'embed-english-v3.0',
|
||||||
|
],
|
||||||
|
// Cohere 使用不同的 API 格式
|
||||||
|
customEmbed: true,
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
id: 'openai',
|
||||||
|
name: 'OpenAI 兼容',
|
||||||
|
baseUrl: '',
|
||||||
|
canFetchModels: true,
|
||||||
|
defaultModels: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 本地模型状态管理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// 已加载的模型实例:{ modelId: pipeline }
|
||||||
|
const loadedPipelines = {};
|
||||||
|
|
||||||
|
// 当前正在下载的模型
|
||||||
|
let downloadingModelId = null;
|
||||||
|
let downloadAbortController = null;
|
||||||
|
|
||||||
|
// Worker for local embedding
|
||||||
|
let embeddingWorker = null;
|
||||||
|
let workerRequestId = 0;
|
||||||
|
const workerCallbacks = new Map();
|
||||||
|
|
||||||
|
function getWorker() {
|
||||||
|
if (!embeddingWorker) {
|
||||||
|
const workerPath = new URL('./embedder.worker.js', import.meta.url).href;
|
||||||
|
embeddingWorker = new Worker(workerPath, { type: 'module' });
|
||||||
|
|
||||||
|
embeddingWorker.onmessage = (e) => {
|
||||||
|
const { requestId, ...data } = e.data || {};
|
||||||
|
const callback = workerCallbacks.get(requestId);
|
||||||
|
if (callback) {
|
||||||
|
callback(data);
|
||||||
|
if (data.type === 'result' || data.type === 'error' || data.type === 'loaded') {
|
||||||
|
workerCallbacks.delete(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return embeddingWorker;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workerRequest(message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const requestId = ++workerRequestId;
|
||||||
|
const worker = getWorker();
|
||||||
|
|
||||||
|
workerCallbacks.set(requestId, (data) => {
|
||||||
|
if (data.type === 'error') {
|
||||||
|
reject(new Error(data.error));
|
||||||
|
} else if (data.type === 'result') {
|
||||||
|
resolve(data.vectors);
|
||||||
|
} else if (data.type === 'loaded') {
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.postMessage({ ...message, requestId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 本地模型操作
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查指定本地模型的状态
|
||||||
|
* 只读取缓存,绝不触发下载
|
||||||
|
*/
|
||||||
|
export async function checkLocalModelStatus(modelId = DEFAULT_LOCAL_MODEL) {
|
||||||
|
const modelConfig = LOCAL_MODELS[modelId];
|
||||||
|
if (!modelConfig) {
|
||||||
|
return { status: 'error', message: '未知模型' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已加载到内存
|
||||||
|
if (loadedPipelines[modelId]) {
|
||||||
|
return { status: 'ready', message: '已就绪' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在下载
|
||||||
|
if (downloadingModelId === modelId) {
|
||||||
|
return { status: 'downloading', message: '下载中' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 IndexedDB 缓存
|
||||||
|
const hasCache = await checkModelCache(modelConfig.hfId);
|
||||||
|
if (hasCache) {
|
||||||
|
return { status: 'cached', message: '已缓存,可加载' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'not_downloaded', message: '未下载' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 IndexedDB 中是否有模型缓存
|
||||||
|
*/
|
||||||
|
async function checkModelCache(hfId) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const request = indexedDB.open('transformers-cache', 1);
|
||||||
|
request.onerror = () => resolve(false);
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
const storeNames = Array.from(db.objectStoreNames);
|
||||||
|
db.close();
|
||||||
|
// 检查是否有该模型的缓存
|
||||||
|
const modelKey = hfId.replace('/', '_');
|
||||||
|
const hasModel = storeNames.some(name =>
|
||||||
|
name.includes(modelKey) || name.includes('onnx')
|
||||||
|
);
|
||||||
|
resolve(hasModel);
|
||||||
|
};
|
||||||
|
request.onupgradeneeded = () => resolve(false);
|
||||||
|
} catch {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载/加载本地模型
|
||||||
|
* @param {string} modelId - 模型ID
|
||||||
|
* @param {Function} onProgress - 进度回调 (0-100)
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function downloadLocalModel(modelId = DEFAULT_LOCAL_MODEL, onProgress) {
|
||||||
|
const modelConfig = LOCAL_MODELS[modelId];
|
||||||
|
if (!modelConfig) {
|
||||||
|
throw new Error(`未知模型: ${modelId}`);
|
||||||
|
}
|
||||||
|
// 已加载
|
||||||
|
if (loadedPipelines[modelId]) {
|
||||||
|
onProgress?.(100);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 正在下载其他模型
|
||||||
|
if (downloadingModelId && downloadingModelId !== modelId) {
|
||||||
|
throw new Error(`正在下载其他模型: ${downloadingModelId}`);
|
||||||
|
}
|
||||||
|
// 正在下载同一模型,等待完成
|
||||||
|
if (downloadingModelId === modelId) {
|
||||||
|
xbLog.info(MODULE_ID, `模型 ${modelId} 正在加载中...`);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const check = () => {
|
||||||
|
if (loadedPipelines[modelId]) {
|
||||||
|
resolve(true);
|
||||||
|
} else if (downloadingModelId !== modelId) {
|
||||||
|
reject(new Error('下载已取消'));
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
downloadingModelId = modelId;
|
||||||
|
downloadAbortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
xbLog.info(MODULE_ID, `开始下载模型: ${modelId}`);
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const requestId = ++workerRequestId;
|
||||||
|
const worker = getWorker();
|
||||||
|
|
||||||
|
workerCallbacks.set(requestId, (data) => {
|
||||||
|
if (data.type === 'progress') {
|
||||||
|
onProgress?.(data.percent);
|
||||||
|
} else if (data.type === 'loaded') {
|
||||||
|
loadedPipelines[modelId] = true;
|
||||||
|
workerCallbacks.delete(requestId);
|
||||||
|
resolve(true);
|
||||||
|
} else if (data.type === 'error') {
|
||||||
|
workerCallbacks.delete(requestId);
|
||||||
|
reject(new Error(data.error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.postMessage({
|
||||||
|
type: 'load',
|
||||||
|
modelId,
|
||||||
|
hfId: modelConfig.hfId,
|
||||||
|
requestId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
downloadingModelId = null;
|
||||||
|
downloadAbortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelDownload() {
|
||||||
|
if (downloadAbortController) {
|
||||||
|
downloadAbortController.abort();
|
||||||
|
xbLog.info(MODULE_ID, '下载已取消');
|
||||||
|
}
|
||||||
|
downloadingModelId = null;
|
||||||
|
downloadAbortController = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定模型的缓存
|
||||||
|
*/
|
||||||
|
export async function deleteLocalModelCache(modelId = null) {
|
||||||
|
try {
|
||||||
|
// 删除 IndexedDB
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.deleteDatabase('transformers-cache');
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onblocked = () => {
|
||||||
|
xbLog.warn(MODULE_ID, 'IndexedDB 删除被阻塞');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除 CacheStorage
|
||||||
|
if (window.caches) {
|
||||||
|
const cacheNames = await window.caches.keys();
|
||||||
|
for (const name of cacheNames) {
|
||||||
|
if (name.includes('transformers') || name.includes('huggingface') || name.includes('xenova')) {
|
||||||
|
await window.caches.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除内存中的 pipeline
|
||||||
|
if (modelId && loadedPipelines[modelId]) {
|
||||||
|
delete loadedPipelines[modelId];
|
||||||
|
} else {
|
||||||
|
Object.keys(loadedPipelines).forEach(key => delete loadedPipelines[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID, '模型缓存已清除');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.error(MODULE_ID, '清除缓存失败', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用本地模型生成向量
|
||||||
|
*/
|
||||||
|
async function embedLocal(texts, modelId = DEFAULT_LOCAL_MODEL) {
|
||||||
|
if (!loadedPipelines[modelId]) {
|
||||||
|
await downloadLocalModel(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await workerRequest({ type: 'embed', texts });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalModelLoaded(modelId = DEFAULT_LOCAL_MODEL) {
|
||||||
|
return !!loadedPipelines[modelId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本地模型信息
|
||||||
|
*/
|
||||||
|
export function getLocalModelInfo(modelId = DEFAULT_LOCAL_MODEL) {
|
||||||
|
return LOCAL_MODELS[modelId] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 在线服务操作
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试在线服务连接
|
||||||
|
*/
|
||||||
|
export async function testOnlineService(provider, config) {
|
||||||
|
const { url, key, model } = config;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('请填写 API Key');
|
||||||
|
}
|
||||||
|
if (!model) {
|
||||||
|
throw new Error('请选择模型');
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerConfig = ONLINE_PROVIDERS[provider];
|
||||||
|
const baseUrl = (providerConfig?.baseUrl || url || '').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new Error('请填写 API URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (provider === 'cohere') {
|
||||||
|
// Cohere 使用不同的 API 格式
|
||||||
|
const response = await fetch(`${baseUrl}/v1/embed`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: model,
|
||||||
|
texts: ['测试连接'],
|
||||||
|
input_type: 'search_document',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`API 返回 ${response.status}: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const dims = data.embeddings?.[0]?.length || 0;
|
||||||
|
|
||||||
|
if (dims === 0) {
|
||||||
|
throw new Error('API 返回的向量维度为 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, dims };
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// OpenAI 兼容格式
|
||||||
|
const response = await fetch(`${baseUrl}/v1/embeddings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: model,
|
||||||
|
input: ['测试连接'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`API 返回 ${response.status}: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const dims = data.data?.[0]?.embedding?.length || 0;
|
||||||
|
|
||||||
|
if (dims === 0) {
|
||||||
|
throw new Error('API 返回的向量维度为 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, dims };
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'TypeError' && e.message.includes('fetch')) {
|
||||||
|
throw new Error('网络错误,请检查 URL 是否正确');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取在线模型列表(仅 OpenAI 兼容)
|
||||||
|
*/
|
||||||
|
export async function fetchOnlineModels(config) {
|
||||||
|
const { url, key } = config;
|
||||||
|
|
||||||
|
if (!url || !key) {
|
||||||
|
throw new Error('请填写 URL 和 Key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = url.replace(/\/+$/, '').replace(/\/v1$/, '');
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/v1/models`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${key}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取模型列表失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const models = data.data?.map(m => m.id).filter(Boolean) || [];
|
||||||
|
|
||||||
|
// 过滤出 embedding 相关的模型
|
||||||
|
const embeddingModels = models.filter(m => {
|
||||||
|
const lower = m.toLowerCase();
|
||||||
|
return lower.includes('embed') ||
|
||||||
|
lower.includes('bge') ||
|
||||||
|
lower.includes('e5') ||
|
||||||
|
lower.includes('gte');
|
||||||
|
});
|
||||||
|
|
||||||
|
return embeddingModels.length > 0 ? embeddingModels : models.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用在线服务生成向量
|
||||||
|
*/
|
||||||
|
async function embedOnline(texts, provider, config) {
|
||||||
|
const { url, key, model } = config;
|
||||||
|
|
||||||
|
const providerConfig = ONLINE_PROVIDERS[provider];
|
||||||
|
const baseUrl = (providerConfig?.baseUrl || url || '').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
const reqId = Math.random().toString(36).slice(2, 6);
|
||||||
|
const maxRetries = 3;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
console.log(`[embed ${reqId}] send ${texts.length} items${attempt > 1 ? ` (retry ${attempt}/${maxRetries})` : ''}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (provider === 'cohere') {
|
||||||
|
response = await fetch(`${baseUrl}/v1/embed`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: model,
|
||||||
|
texts: texts,
|
||||||
|
input_type: 'search_document',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch(`${baseUrl}/v1/embeddings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: model,
|
||||||
|
input: texts,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[embed ${reqId}] status=${response.status} time=${Date.now() - startTime}ms`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`API 返回 ${response.status}: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (provider === 'cohere') {
|
||||||
|
console.log(`[embed ${reqId}] done items=${data.embeddings?.length || 0} total=${Date.now() - startTime}ms`);
|
||||||
|
return data.embeddings.map(e => Array.isArray(e) ? e : Array.from(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[embed ${reqId}] done items=${data.data?.length || 0} total=${Date.now() - startTime}ms`);
|
||||||
|
return data.data.map(item => {
|
||||||
|
const embedding = item.embedding;
|
||||||
|
return Array.isArray(embedding) ? embedding : Array.from(embedding);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[embed ${reqId}] failed attempt=${attempt} time=${Date.now() - startTime}ms`, e.message);
|
||||||
|
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const waitTime = Math.pow(2, attempt - 1) * 1000;
|
||||||
|
console.log(`[embed ${reqId}] wait ${waitTime}ms then retry`);
|
||||||
|
await new Promise(r => setTimeout(r, waitTime));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[embed ${reqId}] final failure`, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 统一接口
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成向量(统一接口)
|
||||||
|
* @param {string[]} texts - 要向量化的文本数组
|
||||||
|
* @param {Object} config - 配置
|
||||||
|
* @returns {Promise<number[][]>}
|
||||||
|
*/
|
||||||
|
export async function embed(texts, config) {
|
||||||
|
if (!texts?.length) return [];
|
||||||
|
|
||||||
|
const { engine, local, online } = config;
|
||||||
|
|
||||||
|
if (engine === 'local') {
|
||||||
|
const modelId = local?.modelId || DEFAULT_LOCAL_MODEL;
|
||||||
|
return await embedLocal(texts, modelId);
|
||||||
|
|
||||||
|
} else if (engine === 'online') {
|
||||||
|
const provider = online?.provider || 'siliconflow';
|
||||||
|
if (!online?.key || !online?.model) {
|
||||||
|
throw new Error('在线服务配置不完整');
|
||||||
|
}
|
||||||
|
return await embedOnline(texts, provider, online);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw new Error(`未知的引擎类型: ${engine}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前引擎的唯一标识(用于检查向量是否匹配)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Concurrent embed for online services (local falls back to sequential)
|
||||||
|
export async function embedBatchesConcurrent(textBatches, config, concurrency = 3) {
|
||||||
|
if (config.engine === 'local' || textBatches.length <= 1) {
|
||||||
|
const results = [];
|
||||||
|
for (const batch of textBatches) {
|
||||||
|
results.push(await embed(batch, config));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = new Array(textBatches.length);
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (index < textBatches.length) {
|
||||||
|
const i = index++;
|
||||||
|
results[i] = await embed(textBatches[i], config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array(Math.min(concurrency, textBatches.length))
|
||||||
|
.fill(null)
|
||||||
|
.map(() => worker())
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEngineFingerprint(config) {
|
||||||
|
if (config.engine === 'local') {
|
||||||
|
const modelId = config.local?.modelId || DEFAULT_LOCAL_MODEL;
|
||||||
|
const modelConfig = LOCAL_MODELS[modelId];
|
||||||
|
return `local:${modelId}:${modelConfig?.dims || 512}`;
|
||||||
|
|
||||||
|
} else if (config.engine === 'online') {
|
||||||
|
const provider = config.online?.provider || 'unknown';
|
||||||
|
const model = config.online?.model || 'unknown';
|
||||||
|
return `online:${provider}:${model}`;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
64
modules/story-summary/vector/embedder.worker.js
Normal file
64
modules/story-summary/vector/embedder.worker.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// run local embedding in background
|
||||||
|
|
||||||
|
let pipe = null;
|
||||||
|
let currentModelId = null;
|
||||||
|
|
||||||
|
self.onmessage = async (e) => {
|
||||||
|
const { type, modelId, hfId, texts, requestId } = e.data || {};
|
||||||
|
|
||||||
|
if (type === 'load') {
|
||||||
|
try {
|
||||||
|
self.postMessage({ type: 'status', status: 'loading', requestId });
|
||||||
|
|
||||||
|
const { pipeline, env } = await import(
|
||||||
|
'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2'
|
||||||
|
);
|
||||||
|
|
||||||
|
env.allowLocalModels = false;
|
||||||
|
env.useBrowserCache = false;
|
||||||
|
|
||||||
|
pipe = await pipeline('feature-extraction', hfId, {
|
||||||
|
progress_callback: (progress) => {
|
||||||
|
if (progress.status === 'progress' && typeof progress.progress === 'number') {
|
||||||
|
self.postMessage({ type: 'progress', percent: Math.round(progress.progress), requestId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
currentModelId = modelId;
|
||||||
|
self.postMessage({ type: 'loaded', requestId });
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({ type: 'error', error: err?.message || String(err), requestId });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'embed') {
|
||||||
|
if (!pipe) {
|
||||||
|
self.postMessage({ type: 'error', error: '模型未加载', requestId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < texts.length; i++) {
|
||||||
|
const output = await pipe(texts[i], { pooling: 'mean', normalize: true });
|
||||||
|
results.push(Array.from(output.data));
|
||||||
|
self.postMessage({ type: 'embed_progress', current: i + 1, total: texts.length, requestId });
|
||||||
|
}
|
||||||
|
self.postMessage({ type: 'result', vectors: results, requestId });
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({ type: 'error', error: err?.message || String(err), requestId });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'check') {
|
||||||
|
self.postMessage({
|
||||||
|
type: 'status',
|
||||||
|
loaded: !!pipe,
|
||||||
|
modelId: currentModelId,
|
||||||
|
requestId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
129
modules/story-summary/vector/entity.js
Normal file
129
modules/story-summary/vector/entity.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Entity Recognition & Relation Graph
|
||||||
|
// 实体识别与关系扩散
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文本中匹配已知实体
|
||||||
|
* @param {string} text - 待匹配文本
|
||||||
|
* @param {Set<string>} knownEntities - 已知实体集合
|
||||||
|
* @returns {string[]} - 匹配到的实体
|
||||||
|
*/
|
||||||
|
export function matchEntities(text, knownEntities) {
|
||||||
|
if (!text || !knownEntities?.size) return [];
|
||||||
|
|
||||||
|
const matched = new Set();
|
||||||
|
|
||||||
|
for (const entity of knownEntities) {
|
||||||
|
// 精确包含
|
||||||
|
if (text.includes(entity)) {
|
||||||
|
matched.add(entity);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理简称:如果实体是"林黛玉",文本包含"黛玉"
|
||||||
|
if (entity.length >= 3) {
|
||||||
|
const shortName = entity.slice(-2); // 取后两字
|
||||||
|
if (text.includes(shortName)) {
|
||||||
|
matched.add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从角色数据和事件中收集所有已知实体
|
||||||
|
*/
|
||||||
|
export function collectKnownEntities(characters, events) {
|
||||||
|
const entities = new Set();
|
||||||
|
|
||||||
|
// 从主要角色
|
||||||
|
(characters?.main || []).forEach(m => {
|
||||||
|
const name = typeof m === 'string' ? m : m.name;
|
||||||
|
if (name) entities.add(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 从关系
|
||||||
|
(characters?.relationships || []).forEach(r => {
|
||||||
|
if (r.from) entities.add(r.from);
|
||||||
|
if (r.to) entities.add(r.to);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 从事件参与者
|
||||||
|
(events || []).forEach(e => {
|
||||||
|
(e.participants || []).forEach(p => {
|
||||||
|
if (p) entities.add(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建关系邻接表
|
||||||
|
* @param {Array} relationships - 关系数组
|
||||||
|
* @returns {Map<string, Array<{target: string, weight: number}>>}
|
||||||
|
*/
|
||||||
|
export function buildRelationGraph(relationships) {
|
||||||
|
const graph = new Map();
|
||||||
|
|
||||||
|
const trendWeight = {
|
||||||
|
'交融': 1.0,
|
||||||
|
'亲密': 0.9,
|
||||||
|
'投缘': 0.7,
|
||||||
|
'陌生': 0.3,
|
||||||
|
'反感': 0.5,
|
||||||
|
'厌恶': 0.6,
|
||||||
|
'破裂': 0.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rel of relationships || []) {
|
||||||
|
if (!rel.from || !rel.to) continue;
|
||||||
|
|
||||||
|
const weight = trendWeight[rel.trend] || 0.5;
|
||||||
|
|
||||||
|
// 双向
|
||||||
|
if (!graph.has(rel.from)) graph.set(rel.from, []);
|
||||||
|
if (!graph.has(rel.to)) graph.set(rel.to, []);
|
||||||
|
|
||||||
|
graph.get(rel.from).push({ target: rel.to, weight });
|
||||||
|
graph.get(rel.to).push({ target: rel.from, weight });
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关系扩散(1跳)
|
||||||
|
* @param {string[]} focusEntities - 焦点实体
|
||||||
|
* @param {Map} graph - 关系图
|
||||||
|
* @param {number} decayFactor - 衰减因子
|
||||||
|
* @returns {Map<string, number>} - 实体 -> 激活分数
|
||||||
|
*/
|
||||||
|
export function spreadActivation(focusEntities, graph, decayFactor = 0.5) {
|
||||||
|
const activation = new Map();
|
||||||
|
|
||||||
|
// 焦点实体初始分数 1.0
|
||||||
|
for (const entity of focusEntities) {
|
||||||
|
activation.set(entity, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1跳扩散
|
||||||
|
for (const entity of focusEntities) {
|
||||||
|
const neighbors = graph.get(entity) || [];
|
||||||
|
|
||||||
|
for (const { target, weight } of neighbors) {
|
||||||
|
const spreadScore = weight * decayFactor;
|
||||||
|
const existing = activation.get(target) || 0;
|
||||||
|
|
||||||
|
// 取最大值,不累加
|
||||||
|
if (spreadScore > existing) {
|
||||||
|
activation.set(target, spreadScore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return activation;
|
||||||
|
}
|
||||||
519
modules/story-summary/vector/recall.js
Normal file
519
modules/story-summary/vector/recall.js
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
// Story Summary - Recall Engine
|
||||||
|
// L1 chunk + L2 event 召回
|
||||||
|
// - 全量向量打分
|
||||||
|
// - 指数衰减加权 Query Embedding
|
||||||
|
// - 实体/参与者加分
|
||||||
|
// - MMR 去重
|
||||||
|
// - floor 稀疏去重
|
||||||
|
|
||||||
|
import { getAllEventVectors, getAllChunkVectors, getChunksByFloors, getMeta } from './chunk-store.js';
|
||||||
|
import { embed, getEngineFingerprint } from './embedder.js';
|
||||||
|
import { xbLog } from '../../../core/debug-core.js';
|
||||||
|
import { getContext } from '../../../../../../extensions.js';
|
||||||
|
import { getSummaryStore } from '../data/store.js';
|
||||||
|
|
||||||
|
const MODULE_ID = 'recall';
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
QUERY_MSG_COUNT: 5,
|
||||||
|
QUERY_DECAY_BETA: 0.7,
|
||||||
|
QUERY_MAX_CHARS: 600,
|
||||||
|
QUERY_CONTEXT_CHARS: 240,
|
||||||
|
|
||||||
|
CANDIDATE_CHUNKS: 120,
|
||||||
|
CANDIDATE_EVENTS: 100,
|
||||||
|
|
||||||
|
TOP_K_CHUNKS: 40,
|
||||||
|
TOP_K_EVENTS: 35,
|
||||||
|
|
||||||
|
MIN_SIMILARITY: 0.35,
|
||||||
|
MMR_LAMBDA: 0.72,
|
||||||
|
|
||||||
|
BONUS_PARTICIPANT_HIT: 0.08,
|
||||||
|
BONUS_TEXT_HIT: 0.05,
|
||||||
|
BONUS_WORLD_TOPIC_HIT: 0.06,
|
||||||
|
|
||||||
|
FLOOR_LIMIT: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 工具函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function cosineSimilarity(a, b) {
|
||||||
|
if (!a?.length || !b?.length || a.length !== b.length) return 0;
|
||||||
|
let dot = 0, nA = 0, nB = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dot += a[i] * b[i];
|
||||||
|
nA += a[i] * a[i];
|
||||||
|
nB += b[i] * b[i];
|
||||||
|
}
|
||||||
|
return nA && nB ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVec(v) {
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < v.length; i++) s += v[i] * v[i];
|
||||||
|
s = Math.sqrt(s) || 1;
|
||||||
|
return v.map(x => x / s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(s) {
|
||||||
|
return String(s || '').normalize('NFKC').replace(/[\u200B-\u200D\uFEFF]/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripNoise(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
||||||
|
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
|
||||||
|
.replace(/\[tts:[^\]]*\]/gi, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExpDecayWeights(n, beta) {
|
||||||
|
const last = n - 1;
|
||||||
|
const w = Array.from({ length: n }, (_, i) => Math.exp(beta * (i - last)));
|
||||||
|
const sum = w.reduce((a, b) => a + b, 0) || 1;
|
||||||
|
return w.map(x => x / sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Query 构建
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function buildQuerySegments(chat, count, excludeLastAi) {
|
||||||
|
if (!chat?.length) return [];
|
||||||
|
|
||||||
|
let messages = chat;
|
||||||
|
if (excludeLastAi && messages.length > 0 && !messages[messages.length - 1]?.is_user) {
|
||||||
|
messages = messages.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.slice(-count).map((m, idx, arr) => {
|
||||||
|
const speaker = m.name || (m.is_user ? '用户' : '角色');
|
||||||
|
const clean = stripNoise(m.mes);
|
||||||
|
if (!clean) return '';
|
||||||
|
const limit = idx === arr.length - 1 ? CONFIG.QUERY_MAX_CHARS : CONFIG.QUERY_CONTEXT_CHARS;
|
||||||
|
return `${speaker}: ${clean.slice(0, limit)}`;
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function embedWeightedQuery(segments, vectorConfig) {
|
||||||
|
if (!segments?.length) return null;
|
||||||
|
|
||||||
|
const weights = buildExpDecayWeights(segments.length, CONFIG.QUERY_DECAY_BETA);
|
||||||
|
const vecs = await embed(segments, vectorConfig);
|
||||||
|
const dims = vecs?.[0]?.length || 0;
|
||||||
|
if (!dims) return null;
|
||||||
|
|
||||||
|
const out = new Array(dims).fill(0);
|
||||||
|
for (let i = 0; i < vecs.length; i++) {
|
||||||
|
for (let j = 0; j < dims; j++) out[j] += (vecs[i][j] || 0) * weights[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vector: normalizeVec(out), weights };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 实体抽取
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function buildEntityLexicon(store, allEvents) {
|
||||||
|
const { name1 } = getContext();
|
||||||
|
const userName = normalize(name1);
|
||||||
|
const set = new Set();
|
||||||
|
|
||||||
|
for (const e of allEvents || []) {
|
||||||
|
for (const p of e.participants || []) {
|
||||||
|
const s = normalize(p);
|
||||||
|
if (s) set.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = store?.json || {};
|
||||||
|
|
||||||
|
for (const m of json.characters?.main || []) {
|
||||||
|
const s = normalize(typeof m === 'string' ? m : m?.name);
|
||||||
|
if (s) set.add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of json.arcs || []) {
|
||||||
|
const s = normalize(a?.name);
|
||||||
|
if (s) set.add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const w of json.world || []) {
|
||||||
|
const t = normalize(w?.topic);
|
||||||
|
if (t && !t.includes('::')) set.add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of json.characters?.relationships || []) {
|
||||||
|
const from = normalize(r?.from);
|
||||||
|
const to = normalize(r?.to);
|
||||||
|
if (from) set.add(from);
|
||||||
|
if (to) set.add(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = new Set([userName, '我', '你', '他', '她', '它', '用户', '角色', 'assistant'].map(normalize).filter(Boolean));
|
||||||
|
|
||||||
|
return Array.from(set)
|
||||||
|
.filter(s => s.length >= 2 && !stop.has(s) && !/^[\s\p{P}\p{S}]+$/u.test(s) && !/<[^>]+>/.test(s))
|
||||||
|
.slice(0, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEntities(text, lexicon) {
|
||||||
|
const t = normalize(text);
|
||||||
|
if (!t || !lexicon?.length) return [];
|
||||||
|
|
||||||
|
const sorted = [...lexicon].sort((a, b) => b.length - a.length);
|
||||||
|
const hits = [];
|
||||||
|
for (const e of sorted) {
|
||||||
|
if (t.includes(e)) hits.push(e);
|
||||||
|
if (hits.length >= 20) break;
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// MMR
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function mmrSelect(candidates, k, lambda, getVector, getScore) {
|
||||||
|
const selected = [];
|
||||||
|
const ids = new Set();
|
||||||
|
|
||||||
|
while (selected.length < k && candidates.length) {
|
||||||
|
let best = null, bestScore = -Infinity;
|
||||||
|
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (ids.has(c._id)) continue;
|
||||||
|
|
||||||
|
const rel = getScore(c);
|
||||||
|
let div = 0;
|
||||||
|
|
||||||
|
if (selected.length) {
|
||||||
|
const vC = getVector(c);
|
||||||
|
if (vC?.length) {
|
||||||
|
for (const s of selected) {
|
||||||
|
const sim = cosineSimilarity(vC, getVector(s));
|
||||||
|
if (sim > div) div = sim;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = lambda * rel - (1 - lambda) * div;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!best) break;
|
||||||
|
selected.push(best);
|
||||||
|
ids.add(best._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// L1 Chunks 检索
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function searchChunks(queryVector, vectorConfig) {
|
||||||
|
const { chatId } = getContext();
|
||||||
|
if (!chatId || !queryVector?.length) return [];
|
||||||
|
|
||||||
|
const meta = await getMeta(chatId);
|
||||||
|
const fp = getEngineFingerprint(vectorConfig);
|
||||||
|
if (meta.fingerprint && meta.fingerprint !== fp) return [];
|
||||||
|
|
||||||
|
const chunkVectors = await getAllChunkVectors(chatId);
|
||||||
|
if (!chunkVectors.length) return [];
|
||||||
|
|
||||||
|
const scored = chunkVectors.map(cv => {
|
||||||
|
const match = String(cv.chunkId).match(/c-(\d+)-(\d+)/);
|
||||||
|
return {
|
||||||
|
_id: cv.chunkId,
|
||||||
|
chunkId: cv.chunkId,
|
||||||
|
floor: match ? parseInt(match[1], 10) : 0,
|
||||||
|
chunkIdx: match ? parseInt(match[2], 10) : 0,
|
||||||
|
similarity: cosineSimilarity(queryVector, cv.vector),
|
||||||
|
vector: cv.vector,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates = scored
|
||||||
|
.filter(s => s.similarity >= CONFIG.MIN_SIMILARITY)
|
||||||
|
.sort((a, b) => b.similarity - a.similarity)
|
||||||
|
.slice(0, CONFIG.CANDIDATE_CHUNKS);
|
||||||
|
|
||||||
|
const selected = mmrSelect(
|
||||||
|
candidates,
|
||||||
|
CONFIG.TOP_K_CHUNKS,
|
||||||
|
CONFIG.MMR_LAMBDA,
|
||||||
|
c => c.vector,
|
||||||
|
c => c.similarity
|
||||||
|
);
|
||||||
|
|
||||||
|
// floor 稀疏去重
|
||||||
|
const floorCount = new Map();
|
||||||
|
const sparse = [];
|
||||||
|
for (const s of selected.sort((a, b) => b.similarity - a.similarity)) {
|
||||||
|
const cnt = floorCount.get(s.floor) || 0;
|
||||||
|
if (cnt >= CONFIG.FLOOR_LIMIT) continue;
|
||||||
|
floorCount.set(s.floor, cnt + 1);
|
||||||
|
sparse.push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const floors = [...new Set(sparse.map(c => c.floor))];
|
||||||
|
const chunks = await getChunksByFloors(chatId, floors);
|
||||||
|
const chunkMap = new Map(chunks.map(c => [c.chunkId, c]));
|
||||||
|
|
||||||
|
return sparse.map(item => {
|
||||||
|
const chunk = chunkMap.get(item.chunkId);
|
||||||
|
if (!chunk) return null;
|
||||||
|
return {
|
||||||
|
chunkId: item.chunkId,
|
||||||
|
floor: item.floor,
|
||||||
|
chunkIdx: item.chunkIdx,
|
||||||
|
speaker: chunk.speaker,
|
||||||
|
isUser: chunk.isUser,
|
||||||
|
text: chunk.text,
|
||||||
|
similarity: item.similarity,
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// L2 Events 检索
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function searchEvents(queryVector, allEvents, vectorConfig, store, queryEntities) {
|
||||||
|
const { chatId, name1 } = getContext();
|
||||||
|
if (!chatId || !queryVector?.length) return [];
|
||||||
|
|
||||||
|
const meta = await getMeta(chatId);
|
||||||
|
const fp = getEngineFingerprint(vectorConfig);
|
||||||
|
if (meta.fingerprint && meta.fingerprint !== fp) return [];
|
||||||
|
|
||||||
|
const eventVectors = await getAllEventVectors(chatId);
|
||||||
|
const vectorMap = new Map(eventVectors.map(v => [v.eventId, v.vector]));
|
||||||
|
if (!vectorMap.size) return [];
|
||||||
|
|
||||||
|
const userName = normalize(name1);
|
||||||
|
const querySet = new Set((queryEntities || []).map(normalize));
|
||||||
|
|
||||||
|
// 只取硬约束类的 world topic
|
||||||
|
const worldTopics = (store?.json?.world || [])
|
||||||
|
.filter(w => ['inventory', 'rule', 'knowledge'].includes(String(w.category).toLowerCase()))
|
||||||
|
.map(w => normalize(w.topic))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const scored = (allEvents || []).map((event, idx) => {
|
||||||
|
const v = vectorMap.get(event.id);
|
||||||
|
const sim = v ? cosineSimilarity(queryVector, v) : 0;
|
||||||
|
|
||||||
|
let bonus = 0;
|
||||||
|
const reasons = [];
|
||||||
|
|
||||||
|
// participants 命中
|
||||||
|
const participants = (event.participants || []).map(normalize).filter(Boolean);
|
||||||
|
if (participants.some(p => p !== userName && querySet.has(p))) {
|
||||||
|
bonus += CONFIG.BONUS_PARTICIPANT_HIT;
|
||||||
|
reasons.push('participant');
|
||||||
|
}
|
||||||
|
|
||||||
|
// text 命中
|
||||||
|
const text = normalize(`${event.title || ''} ${event.summary || ''}`);
|
||||||
|
if ((queryEntities || []).some(e => text.includes(normalize(e)))) {
|
||||||
|
bonus += CONFIG.BONUS_TEXT_HIT;
|
||||||
|
reasons.push('text');
|
||||||
|
}
|
||||||
|
|
||||||
|
// world topic 命中
|
||||||
|
if (worldTopics.some(topic => querySet.has(topic) && text.includes(topic))) {
|
||||||
|
bonus += CONFIG.BONUS_WORLD_TOPIC_HIT;
|
||||||
|
reasons.push('world');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: event.id,
|
||||||
|
_idx: idx,
|
||||||
|
event,
|
||||||
|
similarity: sim,
|
||||||
|
bonus,
|
||||||
|
finalScore: sim + bonus,
|
||||||
|
reasons,
|
||||||
|
isDirect: reasons.includes('participant'),
|
||||||
|
vector: v,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates = scored
|
||||||
|
.filter(s => s.similarity >= CONFIG.MIN_SIMILARITY)
|
||||||
|
.sort((a, b) => b.finalScore - a.finalScore)
|
||||||
|
.slice(0, CONFIG.CANDIDATE_EVENTS);
|
||||||
|
|
||||||
|
const selected = mmrSelect(
|
||||||
|
candidates,
|
||||||
|
CONFIG.TOP_K_EVENTS,
|
||||||
|
CONFIG.MMR_LAMBDA,
|
||||||
|
c => c.vector,
|
||||||
|
c => c.finalScore
|
||||||
|
);
|
||||||
|
|
||||||
|
return selected
|
||||||
|
.sort((a, b) => b.finalScore - a.finalScore)
|
||||||
|
.map(s => ({
|
||||||
|
event: s.event,
|
||||||
|
similarity: s.finalScore,
|
||||||
|
_recallType: s.isDirect ? 'DIRECT' : 'SIMILAR',
|
||||||
|
_recallReason: s.reasons.length ? s.reasons.join('+') : '相似',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 日志
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function formatRecallLog({ elapsed, segments, weights, chunkResults, eventResults, allEvents, queryEntities }) {
|
||||||
|
const lines = [
|
||||||
|
'╔══════════════════════════════════════════════════════════════╗',
|
||||||
|
'║ 记忆召回报告 ║',
|
||||||
|
'╠══════════════════════════════════════════════════════════════╣',
|
||||||
|
`║ 耗时: ${elapsed}ms`,
|
||||||
|
'╚══════════════════════════════════════════════════════════════╝',
|
||||||
|
'',
|
||||||
|
'┌─────────────────────────────────────────────────────────────┐',
|
||||||
|
'│ 【查询构建】最近 5 条消息,指数衰减加权 (β=0.7) │',
|
||||||
|
'│ 权重越高 = 对召回方向影响越大 │',
|
||||||
|
'└─────────────────────────────────────────────────────────────┘',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 按权重从高到低排序显示
|
||||||
|
const segmentsSorted = segments.map((s, i) => ({
|
||||||
|
idx: i + 1,
|
||||||
|
weight: weights?.[i] ?? 0,
|
||||||
|
text: s,
|
||||||
|
})).sort((a, b) => b.weight - a.weight);
|
||||||
|
|
||||||
|
segmentsSorted.forEach((s, rank) => {
|
||||||
|
const bar = '█'.repeat(Math.round(s.weight * 20));
|
||||||
|
const preview = s.text.length > 60 ? s.text.slice(0, 60) + '...' : s.text;
|
||||||
|
const marker = rank === 0 ? ' ◀ 主导' : '';
|
||||||
|
lines.push(` ${(s.weight * 100).toFixed(1).padStart(5)}% ${bar.padEnd(12)} ${preview}${marker}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('┌─────────────────────────────────────────────────────────────┐');
|
||||||
|
lines.push('│ 【提取实体】用于判断"亲身经历"(DIRECT) │');
|
||||||
|
lines.push('└─────────────────────────────────────────────────────────────┘');
|
||||||
|
lines.push(` ${queryEntities?.length ? queryEntities.join('、') : '(无)'}`);
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('┌─────────────────────────────────────────────────────────────┐');
|
||||||
|
lines.push(`│ 【L1 原文片段】召回 ${chunkResults.length} 条`);
|
||||||
|
lines.push('└─────────────────────────────────────────────────────────────┘');
|
||||||
|
|
||||||
|
chunkResults.slice(0, 15).forEach((c, i) => {
|
||||||
|
const preview = c.text.length > 50 ? c.text.slice(0, 50) + '...' : c.text;
|
||||||
|
lines.push(` ${String(i + 1).padStart(2)}. #${String(c.floor).padStart(3)} [${c.speaker}] ${preview}`);
|
||||||
|
lines.push(` 相似度: ${c.similarity.toFixed(3)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chunkResults.length > 15) {
|
||||||
|
lines.push(` ... 还有 ${chunkResults.length - 15} 条`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('┌─────────────────────────────────────────────────────────────┐');
|
||||||
|
lines.push(`│ 【L2 事件记忆】召回 ${eventResults.length} / ${allEvents.length} 条`);
|
||||||
|
lines.push('│ DIRECT=亲身经历 SIMILAR=相关背景 │');
|
||||||
|
lines.push('└─────────────────────────────────────────────────────────────┘');
|
||||||
|
|
||||||
|
eventResults.forEach((e, i) => {
|
||||||
|
const type = e._recallType === 'DIRECT' ? '★ DIRECT ' : ' SIMILAR';
|
||||||
|
const title = e.event.title || '(无标题)';
|
||||||
|
lines.push(` ${String(i + 1).padStart(2)}. ${type} ${title}`);
|
||||||
|
lines.push(` 相似度: ${e.similarity.toFixed(3)} | 原因: ${e._recallReason}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
const directCount = eventResults.filter(e => e._recallType === 'DIRECT').length;
|
||||||
|
const similarCount = eventResults.filter(e => e._recallType === 'SIMILAR').length;
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('┌─────────────────────────────────────────────────────────────┐');
|
||||||
|
lines.push('│ 【统计】 │');
|
||||||
|
lines.push('└─────────────────────────────────────────────────────────────┘');
|
||||||
|
lines.push(` L1 片段: ${chunkResults.length} 条`);
|
||||||
|
lines.push(` L2 事件: ${eventResults.length} 条 (DIRECT: ${directCount}, SIMILAR: ${similarCount})`);
|
||||||
|
lines.push(` 实体命中: ${queryEntities?.length || 0} 个`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 主入口
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function recallMemory(queryText, allEvents, vectorConfig, options = {}) {
|
||||||
|
const T0 = performance.now();
|
||||||
|
const { chat } = getContext();
|
||||||
|
const store = getSummaryStore();
|
||||||
|
|
||||||
|
if (!allEvents?.length) {
|
||||||
|
return { events: [], chunks: [], elapsed: 0, logText: 'No events.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = buildQuerySegments(chat, CONFIG.QUERY_MSG_COUNT, !!options.excludeLastAi);
|
||||||
|
|
||||||
|
let queryVector, weights;
|
||||||
|
try {
|
||||||
|
const result = await embedWeightedQuery(segments, vectorConfig);
|
||||||
|
queryVector = result?.vector;
|
||||||
|
weights = result?.weights;
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.error(MODULE_ID, '查询向量生成失败', e);
|
||||||
|
return { events: [], chunks: [], elapsed: Math.round(performance.now() - T0), logText: 'Query embedding failed.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!queryVector?.length) {
|
||||||
|
return { events: [], chunks: [], elapsed: Math.round(performance.now() - T0), logText: 'Empty query vector.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lexicon = buildEntityLexicon(store, allEvents);
|
||||||
|
const queryEntities = extractEntities([queryText, ...segments].join('\n'), lexicon);
|
||||||
|
|
||||||
|
const [chunkResults, eventResults] = await Promise.all([
|
||||||
|
searchChunks(queryVector, vectorConfig),
|
||||||
|
searchEvents(queryVector, allEvents, vectorConfig, store, queryEntities),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const elapsed = Math.round(performance.now() - T0);
|
||||||
|
const logText = formatRecallLog({ elapsed, queryText, segments, weights, chunkResults, eventResults, allEvents, queryEntities });
|
||||||
|
|
||||||
|
console.group('%c[Recall]', 'color: #7c3aed; font-weight: bold');
|
||||||
|
console.log(`Elapsed: ${elapsed}ms | Entities: ${queryEntities.join(', ') || '(none)'}`);
|
||||||
|
console.log(`L1: ${chunkResults.length} | L2: ${eventResults.length}/${allEvents.length}`);
|
||||||
|
console.groupEnd();
|
||||||
|
|
||||||
|
return { events: eventResults, chunks: chunkResults, elapsed, logText };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildQueryText(chat, count = 2, excludeLastAi = false) {
|
||||||
|
if (!chat?.length) return '';
|
||||||
|
|
||||||
|
let messages = chat;
|
||||||
|
if (excludeLastAi && messages.length > 0 && !messages[messages.length - 1]?.is_user) {
|
||||||
|
messages = messages.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.slice(-count).map(m => {
|
||||||
|
const text = stripNoise(m.mes);
|
||||||
|
const speaker = m.name || (m.is_user ? '用户' : '角色');
|
||||||
|
return `${speaker}: ${text.slice(0, 500)}`;
|
||||||
|
}).filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
93
modules/story-summary/vector/search-index.js
Normal file
93
modules/story-summary/vector/search-index.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// MiniSearch Index Manager
|
||||||
|
// 全文搜索索引管理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import MiniSearch from '../../../libs/minisearch.mjs';
|
||||||
|
|
||||||
|
// 索引缓存:chatId -> { index, updatedAt }
|
||||||
|
const indexCache = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建搜索索引
|
||||||
|
* @param {string} chatId
|
||||||
|
* @param {Array} events - L2 事件
|
||||||
|
* @param {number} storeUpdatedAt - store.updatedAt 时间戳
|
||||||
|
* @returns {MiniSearch}
|
||||||
|
*/
|
||||||
|
export function getSearchIndex(chatId, events, storeUpdatedAt) {
|
||||||
|
const cached = indexCache.get(chatId);
|
||||||
|
|
||||||
|
// 缓存有效
|
||||||
|
if (cached && cached.updatedAt >= storeUpdatedAt) {
|
||||||
|
return cached.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重建索引
|
||||||
|
const index = new MiniSearch({
|
||||||
|
fields: ['title', 'summary', 'participants'],
|
||||||
|
storeFields: ['id'],
|
||||||
|
searchOptions: {
|
||||||
|
boost: { title: 2, participants: 1.5, summary: 1 },
|
||||||
|
fuzzy: 0.2,
|
||||||
|
prefix: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 索引事件
|
||||||
|
const docs = events.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
title: e.title || '',
|
||||||
|
summary: e.summary || '',
|
||||||
|
participants: (e.participants || []).join(' '),
|
||||||
|
}));
|
||||||
|
|
||||||
|
index.addAll(docs);
|
||||||
|
|
||||||
|
// 缓存
|
||||||
|
indexCache.set(chatId, { index, updatedAt: storeUpdatedAt });
|
||||||
|
|
||||||
|
// 限制缓存数量
|
||||||
|
if (indexCache.size > 5) {
|
||||||
|
const firstKey = indexCache.keys().next().value;
|
||||||
|
indexCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关键词搜索
|
||||||
|
* @returns {Map<string, number>} - eventId -> 归一化分数
|
||||||
|
*/
|
||||||
|
export function searchByKeywords(index, queryText, limit = 20) {
|
||||||
|
if (!queryText?.trim()) return new Map();
|
||||||
|
|
||||||
|
const results = index.search(queryText, { limit });
|
||||||
|
|
||||||
|
if (results.length === 0) return new Map();
|
||||||
|
|
||||||
|
// 归一化分数到 0-1
|
||||||
|
const maxScore = results[0].score;
|
||||||
|
const scores = new Map();
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
scores.set(r.id, r.score / maxScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指定聊天的索引缓存
|
||||||
|
*/
|
||||||
|
export function invalidateIndex(chatId) {
|
||||||
|
indexCache.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有索引缓存
|
||||||
|
*/
|
||||||
|
export function clearAllIndexes() {
|
||||||
|
indexCache.clear();
|
||||||
|
}
|
||||||
@@ -40,7 +40,10 @@ export function speedToV3SpeechRate(speed) {
|
|||||||
return Math.round((normalizeSpeed(speed) - 1) * 100);
|
return Math.round((normalizeSpeed(speed) - 1) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inferResourceIdBySpeaker(value) {
|
export function inferResourceIdBySpeaker(value, explicitResourceId = null) {
|
||||||
|
if (explicitResourceId) {
|
||||||
|
return explicitResourceId;
|
||||||
|
}
|
||||||
const v = (value || '').trim();
|
const v = (value || '').trim();
|
||||||
const lower = v.toLowerCase();
|
const lower = v.toLowerCase();
|
||||||
if (lower.startsWith('icl_') || lower.startsWith('s_')) {
|
if (lower.startsWith('icl_') || lower.startsWith('s_')) {
|
||||||
@@ -110,7 +113,7 @@ export async function speakSegmentAuth(messageId, segment, segmentIndex, batchId
|
|||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
const speaker = segment.resolvedSpeaker;
|
const speaker = segment.resolvedSpeaker;
|
||||||
const resourceId = inferResourceIdBySpeaker(speaker);
|
const resourceId = segment.resolvedResourceId || inferResourceIdBySpeaker(speaker);
|
||||||
const params = buildSynthesizeParams({ text: segment.text, speaker, resourceId }, config);
|
const params = buildSynthesizeParams({ text: segment.text, speaker, resourceId }, config);
|
||||||
const emotion = normalizeEmotion(segment.emotion);
|
const emotion = normalizeEmotion(segment.emotion);
|
||||||
const contextTexts = resolveContextTexts(segment.context, resourceId);
|
const contextTexts = resolveContextTexts(segment.context, resourceId);
|
||||||
@@ -171,7 +174,7 @@ export async function speakSegmentAuth(messageId, segment, segmentIndex, batchId
|
|||||||
async function playWithStreaming(messageId, segment, segmentIndex, batchId, params, headers, ctx) {
|
async function playWithStreaming(messageId, segment, segmentIndex, batchId, params, headers, ctx) {
|
||||||
const { player, storeLocalCache, buildCacheKey, updateState } = ctx;
|
const { player, storeLocalCache, buildCacheKey, updateState } = ctx;
|
||||||
const speaker = segment.resolvedSpeaker;
|
const speaker = segment.resolvedSpeaker;
|
||||||
const resourceId = inferResourceIdBySpeaker(speaker);
|
const resourceId = params.resourceId;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
@@ -250,7 +253,7 @@ async function playWithStreaming(messageId, segment, segmentIndex, batchId, para
|
|||||||
async function playWithoutStreaming(messageId, segment, segmentIndex, batchId, params, headers, ctx) {
|
async function playWithoutStreaming(messageId, segment, segmentIndex, batchId, params, headers, ctx) {
|
||||||
const { player, storeLocalCache, buildCacheKey, updateState } = ctx;
|
const { player, storeLocalCache, buildCacheKey, updateState } = ctx;
|
||||||
const speaker = segment.resolvedSpeaker;
|
const speaker = segment.resolvedSpeaker;
|
||||||
const resourceId = inferResourceIdBySpeaker(speaker);
|
const resourceId = params.resourceId;
|
||||||
|
|
||||||
const result = await synthesizeV3(params, headers);
|
const result = await synthesizeV3(params, headers);
|
||||||
updateState({ audioBlob: result.audioBlob, usage: result.usage, status: 'queued' });
|
updateState({ audioBlob: result.audioBlob, usage: result.usage, status: 'queued' });
|
||||||
|
|||||||
@@ -1412,6 +1412,13 @@ select.input { cursor: pointer; }
|
|||||||
<label class="form-label">名称</label>
|
<label class="form-label">名称</label>
|
||||||
<input type="text" id="newVoiceName" class="input" placeholder="显示名称">
|
<input type="text" id="newVoiceName" class="input" placeholder="显示名称">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">复刻版本</label>
|
||||||
|
<select id="newVoiceResourceId" class="input">
|
||||||
|
<option value="seed-icl-2.0">复刻 2.0</option>
|
||||||
|
<option value="seed-icl-1.0">复刻 1.0</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button class="btn btn-primary" id="addMySpeakerBtn" style="margin-top: 18px;"><i class="fa-solid fa-plus"></i></button>
|
<button class="btn btn-primary" id="addMySpeakerBtn" style="margin-top: 18px;"><i class="fa-solid fa-plus"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2155,6 +2162,7 @@ function normalizeMySpeakers(list) {
|
|||||||
name: String(item?.name || '').trim(),
|
name: String(item?.name || '').trim(),
|
||||||
value: String(item?.value || '').trim(),
|
value: String(item?.value || '').trim(),
|
||||||
source: item?.source || getVoiceSource(item?.value || ''),
|
source: item?.source || getVoiceSource(item?.value || ''),
|
||||||
|
resourceId: item?.resourceId || null,
|
||||||
})).filter(item => item.value);
|
})).filter(item => item.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2265,11 +2273,14 @@ function doTestVoice(speaker, source, textElId, statusElId) {
|
|||||||
|
|
||||||
setTestStatus(statusElId, 'playing', '正在合成...');
|
setTestStatus(statusElId, 'playing', '正在合成...');
|
||||||
|
|
||||||
|
const speakerItem = mySpeakers.find(s => s.value === speaker);
|
||||||
|
const resolvedResourceId = speakerItem?.resourceId;
|
||||||
|
|
||||||
post('xb-tts:test-speak', {
|
post('xb-tts:test-speak', {
|
||||||
text,
|
text,
|
||||||
speaker,
|
speaker,
|
||||||
source,
|
source,
|
||||||
resourceId: source === 'auth' ? inferResourceIdBySpeaker(speaker) : '',
|
resourceId: source === 'auth' ? (resolvedResourceId || inferResourceIdBySpeaker(speaker)) : '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2437,10 +2448,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
$('addMySpeakerBtn').addEventListener('click', () => {
|
$('addMySpeakerBtn').addEventListener('click', () => {
|
||||||
const id = $('newVoiceId').value.trim();
|
const id = $('newVoiceId').value.trim();
|
||||||
const name = $('newVoiceName').value.trim();
|
const name = $('newVoiceName').value.trim();
|
||||||
|
const resourceId = $('newVoiceResourceId').value;
|
||||||
if (!id) { post('xb-tts:toast', { type: 'error', message: '请输入音色ID' }); return; }
|
if (!id) { post('xb-tts:toast', { type: 'error', message: '请输入音色ID' }); return; }
|
||||||
|
|
||||||
if (!isInMyList(id)) {
|
if (!isInMyList(id)) {
|
||||||
mySpeakers.push({ name: name || id, value: id, source: 'auth' });
|
mySpeakers.push({ name: name || id, value: id, source: 'auth', resourceId });
|
||||||
}
|
}
|
||||||
selectedVoiceValue = id;
|
selectedVoiceValue = id;
|
||||||
$('newVoiceId').value = '';
|
$('newVoiceId').value = '';
|
||||||
|
|||||||
@@ -254,7 +254,8 @@ function resolveSpeakerWithSource(speakerName, mySpeakers, defaultSpeaker) {
|
|||||||
const defaultItem = list.find(s => s.value === defaultSpeaker);
|
const defaultItem = list.find(s => s.value === defaultSpeaker);
|
||||||
return {
|
return {
|
||||||
value: defaultSpeaker,
|
value: defaultSpeaker,
|
||||||
source: defaultItem?.source || getVoiceSource(defaultSpeaker)
|
source: defaultItem?.source || getVoiceSource(defaultSpeaker),
|
||||||
|
resourceId: defaultItem?.resourceId || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +265,8 @@ function resolveSpeakerWithSource(speakerName, mySpeakers, defaultSpeaker) {
|
|||||||
if (byName?.value) {
|
if (byName?.value) {
|
||||||
return {
|
return {
|
||||||
value: byName.value,
|
value: byName.value,
|
||||||
source: byName.source || getVoiceSource(byName.value)
|
source: byName.source || getVoiceSource(byName.value),
|
||||||
|
resourceId: byName.resourceId || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,12 +276,13 @@ function resolveSpeakerWithSource(speakerName, mySpeakers, defaultSpeaker) {
|
|||||||
if (byValue?.value) {
|
if (byValue?.value) {
|
||||||
return {
|
return {
|
||||||
value: byValue.value,
|
value: byValue.value,
|
||||||
source: byValue.source || getVoiceSource(byValue.value)
|
source: byValue.source || getVoiceSource(byValue.value),
|
||||||
|
resourceId: byValue.resourceId || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (FREE_VOICE_KEYS.has(speakerName)) {
|
if (FREE_VOICE_KEYS.has(speakerName)) {
|
||||||
return { value: speakerName, source: 'free' };
|
return { value: speakerName, source: 'free', resourceId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ★ 回退到默认,这是问题发生的地方
|
// ★ 回退到默认,这是问题发生的地方
|
||||||
@@ -288,7 +291,8 @@ function resolveSpeakerWithSource(speakerName, mySpeakers, defaultSpeaker) {
|
|||||||
const defaultItem = list.find(s => s.value === defaultSpeaker);
|
const defaultItem = list.find(s => s.value === defaultSpeaker);
|
||||||
return {
|
return {
|
||||||
value: defaultSpeaker,
|
value: defaultSpeaker,
|
||||||
source: defaultItem?.source || getVoiceSource(defaultSpeaker)
|
source: defaultItem?.source || getVoiceSource(defaultSpeaker),
|
||||||
|
resourceId: defaultItem?.resourceId || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +627,8 @@ async function speakMessage(messageId, { mode = 'manual' } = {}) {
|
|||||||
return {
|
return {
|
||||||
...seg,
|
...seg,
|
||||||
resolvedSpeaker: resolved.value,
|
resolvedSpeaker: resolved.value,
|
||||||
resolvedSource: resolved.source
|
resolvedSource: resolved.source,
|
||||||
|
resolvedResourceId: resolved.resourceId
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1325,7 +1330,7 @@ export async function initTts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceId = options.resourceId || inferResourceIdBySpeaker(resolved.value);
|
const resourceId = options.resourceId || resolved.resourceId || inferResourceIdBySpeaker(resolved.value);
|
||||||
const result = await synthesizeV3({
|
const result = await synthesizeV3({
|
||||||
appId: config.volc.appId,
|
appId: config.volc.appId,
|
||||||
accessKey: config.volc.accessKey,
|
accessKey: config.volc.accessKey,
|
||||||
|
|||||||
Reference in New Issue
Block a user