mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-13 16:15:50 +00:00
release: v2.2.6 [2026-06-13 01:02:05]
### 新功能 - **翰林院向量化质量升级**: - **边界感知切块**:替换四个来源(聊天记录/小说/世界书/手动)的纯字符硬切——优先在段落边界断开,其次句末标点(含中文引号闭合),极端长串才硬切;句子/对话不再被拦腰截断,embedding 质量同步受益。仅影响新录入,已有向量无需重建 - **注入时序重排**:检索结果注入提示词前按时序重排(聊天记录按楼层、小说按卷/章/节——中文数字章节号可解析),rerank 只决定"选哪些块",不再决定呈现顺序;修复"不打不相识的剧情之后紧跟关系亲密"这类因按相关度排序导致的认知时间错乱 - **断层提示**:聊天记录相邻块楼层跳跃时自动插入"与上文相隔约 N 楼,并非连续发生"提示行,消除中间剧情缺失造成的割裂感 - **时间标识**:新录入的聊天记录块在来源标识中带上消息发送时间(ST 向量存储不持久化元数据,时间必须写入块文本才能在检索后取回;旧格式块兼容解析) - **记忆块工作流(memory-blocks)**:剧情优化新增"自定义记忆块"体系——占位符驱动的并发工作流框架 - 在剧情优化面板「匹配替换 (sulv)」下方可增删自定义块:每个块定义一个占位符,执行剧情优化时主/拦截提示词中的占位符会被块的产出替换 - **静态块**:直接输出固定内容;**AI 调用块**:用所选 API 功能槽独立请求一次,把回复(或其中指定 `<标签>` 的内容)作为替换值 - 原有 sulv1-4 速率占位符迁入同一框架,行为与旧版逐字节一致 - 块定义为纯 JSON、随设置持久化,为后续导入导出与战斗系统接入预留扩展点 - 框架层新增**顺序拼接式 Chain**(`composeChain`):与占位符替换并列的第二种组合范式——同链的块并发执行后按 `order` 排序、以 `separator` 拼接并可选 `header/footer` 包裹,产出一个完整注入块;为记忆注入合成块与战斗系统"底部战报块"预留的承载结构,本版本暂无 UI 入口 - **API 连接配置**: - 角色世界书(cwb)与一键生卡(autoCharCard)纳入旧配置自动迁移:老用户首次加载会把旧 URL / Key / 模型自动迁移为连接配置并分配槽位(一键生卡仅在规划者与执行者配置一致或规划者为空时迁移,避免悄悄改变行为) - **profile 已分配时参数控件 informational 化**:主面板 / 并发剧情优化 / 角色世界书 / 术语表的温度、maxTokens 控件在槽位分配 profile 后自动禁用并显示"由连接配置控制"提示,消除"改了没效果"的用户陷阱 - **profile 状态卡新增"本设备无 Key"警示**:API Key 仅保存在最初填写它的设备/浏览器上(安全设计,不随云端设置同步),换设备后状态卡会直接亮出警示徽标,不必等到调用报错才发现 ### 修复 - **独立聊天记忆从摆设变真功能(原作遗留坑)**:此前向量数据"随卡不随聊天"——开启"独立聊天记忆"后录入仍存进角色库、查询却去查一个从未被写入过的聊天集合、计数恒为 0,整体静默失效。现已重构为聊天级分桶: - 独立模式下,聊天记录类向量按当前聊天隔离存储与检索,同一张卡开多个聊天(不同剧情线)的记忆互不污染 - 小说 / 世界书 / 手动录入属于"知识",仍随角色卡跨聊天共享;全局库不受影响 - 知识管理列表为聊天专属库显示"聊天级"徽标;聊天级库禁止移动到全局 - 统一模式(默认关闭独立记忆)的存量数据与行为完全不变 - 已知限制:聊天专属记忆跟随聊天文件,重命名聊天文件会使其失联(与 ST 官方向量扩展同等限制) - **超级排序截断顺序修正**:开启"超级排序"时,时序重排发生在 top_n 截断之前,导致保留的是"时序最早"而非"最相关"的块,检索结果长期偏向最旧的聊天记录。现改为先按相关度截取 top_n、再做时序排序 - **翰林院向量化失败("向量化块数量不识别"反馈)**: - 一次性清洗 profile-sync 历史污染:`retrieval/rerank.apiKey` 中的掩码占位符在持久层根治(此前仅读取侧防御);`apiEndpoint` / `rerank.apiMode` 的非法值(如被旧版写入的空字符串)归一化为 `custom` - 修复 `apiEndpoint` 为空/非法时请求被硬定向到 `api.openai.com`、无视用户自定义 URL 的问题(CSP 拦截 / 401 的元凶) - 修复**本地代理(LM Studio/Ollama)模式**自始就缺少 URL 分支、同样被错误定向到 openai.com 的问题 - API 模式下拉补全 `OpenAI 官方` / `Azure` 选项;默认 API 模式改为 `custom`(与默认 URL 配套),新用户不再因选项缺失导致首次保存写入空值 - profile-sync 给下拉框赋不存在选项值的污染源头修复(影响所有模块面板,不止翰林院) - **Rerank "API Key 未提供"报错升级**:当原因是"连接配置在本设备没有可用 Key"时,报错会直接说明 Key 的设备本地性并指引到 API 连接配置重新填写(向量化 Google 直连、获取模型列表同步处理) - **旧配置迁移**:一键生卡迁移时排除掩码占位符,避免把历史污染的假 Key 迁入新连接配置 - **超级记忆稳定性专项**(针对"工作不大稳定"反馈,4 处根因一次修复): - **切聊天竞态污染**:CHAT_CHANGED 时超级记忆立即全量同步,而表格系统延迟 100ms 才加载新聊天的表格,导致【旧聊天】的表格内容被写进【新角色】的记忆世界书;两边表名不同时旧表条目无 GC 兜底会**永久残留**("记忆串台"元凶)。现 CHAT_CHANGED 只确保世界书存在,新状态同步交由 `loadTables()` 完成后的自动推送,单次且时序正确 - **死代码双轨存储拆除**:`saveStateToMetadata` / `tryRestoreStateFromMetadata` 把表格状态写到 `msg.metadata`——该字段非 ST 持久化位(同 v2.2.5 二次填表修过的坑),写入即蒸发、恢复永远为空,且每次同步还白调一次 `saveChat()`。整条链路删除,表格状态唯一信源为表格系统的 `msg.extra.amily2_tables_data` - **`awaitSync()` 穿透**:同步队列正忙时 `pushUpdate` 会用一个立即 resolve 的空 Promise 覆盖 `_syncPromise`,Pipeline Stage 4 等待形同虚设、后续阶段在同步未完成时被放行。现忙时不覆盖,正在运行的 drain 循环自然吃掉新入队项 - **开关打开不生效**:启动时若总开关为关,初始化早退且不注册监听器;此后在 UI 勾选开关只写设置,超级记忆直到刷新页面前都是死的。现勾选即触发初始化(幂等) - 附带:`forceSyncAll` 的表格角色推断改为复用 `events-schema.inferTableRole`,消除两处重复逻辑漂移风险;每次切聊天的双倍全量同步(restore 路径一次 + 显式一次)随死代码移除归一 ### 重构 - 表格核心 `manager.js` 瘦身(约 1050 → 600 行):19 个 UI 突变操作拆分至 `actions/ui-mutations.js`,SuperMemory 事件分发拆分至 `events-dispatch.js`;全部经 re-export 保持兼容,外部调用路径零改动 - 角色世界书最后 2 处散乱的厂商 URL 判断迁移至 `detectVendor` 统一入口,业务路径上不再有硬编码的 URL substring 判断
This commit is contained in:
54
core/memory-blocks/ai-call-handler.js
Normal file
54
core/memory-blocks/ai-call-handler.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* core/memory-blocks/ai-call-handler.js — 'ai_call' generator handler(Phase 2)
|
||||
*
|
||||
* 执行一次独立 AI 调用,把回复(或其中指定标签的内容)作为块的替换值。
|
||||
*
|
||||
* 与 generator-handlers.js 分离的原因:本 handler 依赖 core/api.js(牵涉
|
||||
* DOM / ST 运行时),注册表本身保持零依赖,便于单测与 JSON 工具复用。
|
||||
*
|
||||
* generator 字段(AiCallGenerator,契约见 types.js):
|
||||
* apiSlot - callAI 的功能槽('main' / 'plotOpt' / 'nccs' ...),缺省 'main'
|
||||
* promptTemplate - 作为 user 消息发送的提示词(必填,空则块跳过)
|
||||
* systemPrompt - 可选,附加在前面的 system 消息
|
||||
* extractTag - 可选,只取回复中最后一个 <tag>...</tag> 的内容;
|
||||
* 标签缺失时回退为完整回复(宽容处理,模型偶发不包
|
||||
* 标签时块仍有产出,而不是静默保留占位符)
|
||||
*
|
||||
* 失败语义(与 executor 约定一致):
|
||||
* - callAI 内部捕获的 API 错误返回 null → 块产出 null → 占位符保留
|
||||
* - AbortError 由 callAI 原样上抛 → executor 整体中断(signal 贯穿 fetch)
|
||||
*/
|
||||
|
||||
import { callAI } from '../api.js';
|
||||
import { extractContentByTag } from '../../utils/tagProcessor.js';
|
||||
import { registerHandler } from './generator-handlers.js';
|
||||
|
||||
registerHandler('ai_call', async (block, ctx) => {
|
||||
const gen = block.generator || {};
|
||||
const prompt = typeof gen.promptTemplate === 'string' ? gen.promptTemplate.trim() : '';
|
||||
if (!prompt) {
|
||||
console.warn(`[MemoryBlocks] ai_call 块 ${block.id} 缺少 promptTemplate,已跳过。`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
if (typeof gen.systemPrompt === 'string' && gen.systemPrompt.trim()) {
|
||||
messages.push({ role: 'system', content: gen.systemPrompt });
|
||||
}
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const response = await callAI(messages, {
|
||||
slot: gen.apiSlot || 'main',
|
||||
signal: ctx?.signal,
|
||||
});
|
||||
if (!response || !response.trim()) return null;
|
||||
|
||||
if (gen.extractTag) {
|
||||
const extracted = extractContentByTag(response, gen.extractTag);
|
||||
if (extracted !== null && extracted.trim()) return extracted.trim();
|
||||
console.warn(`[MemoryBlocks] ai_call 块 ${block.id} 回复中未找到 <${gen.extractTag}> 标签,回退为完整回复。`);
|
||||
}
|
||||
return response.trim();
|
||||
});
|
||||
|
||||
export {};
|
||||
97
core/memory-blocks/chain.js
Normal file
97
core/memory-blocks/chain.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* core/memory-blocks/chain.js
|
||||
*
|
||||
* 顺序拼接式工作流:把 context 下所有启用块的结果,按 block.order 排序后用 separator
|
||||
* 拼接,并可选 header/footer 包裹,最终输出一个"完整的注入块"字符串。
|
||||
*
|
||||
* 与 executor.js(模板替换式)并列两种组合范式:
|
||||
* - executor: 模板里挖空 placeholder,块负责填料 → 替换式
|
||||
* - chain: 无模板,块各自产出一段文本 → 顺序拼成一整段
|
||||
*
|
||||
* 战斗系统设计稿(§3.2)里的"战报作底部独立注入块"、未来的"记忆注入合成块"
|
||||
* 都是 chain 模式的天然用例:战斗模块只需声明一个 BlockDefinition,order 取大
|
||||
* 就自动落在拼接末尾。
|
||||
*
|
||||
* ── Chain 定义(纯数据、JSON 可序列化)─────────────────────────────────────
|
||||
* {
|
||||
* id: string // 与 BlockDefinition.context 对齐,块通过 context 隐式归属
|
||||
* name?: string // UI 显示名
|
||||
* separator?: string // 块间分隔符,默认 '\n\n'
|
||||
* header?: string // 整段前缀,可选
|
||||
* footer?: string // 整段后缀,可选
|
||||
* }
|
||||
*
|
||||
* Chain 无须显式注册也能 compose——未注册时使用默认值,方便临时拼接。
|
||||
*/
|
||||
|
||||
import { executeContext } from './runner.js';
|
||||
|
||||
const chains = new Map();
|
||||
|
||||
const DEFAULT_SEPARATOR = '\n\n';
|
||||
|
||||
function validateChain(def) {
|
||||
if (!def || typeof def !== 'object') throw new Error('[MemoryBlocks/Chain] 定义必须是对象。');
|
||||
if (!def.id) throw new Error('[MemoryBlocks/Chain] Chain.id 必填。');
|
||||
}
|
||||
|
||||
export function registerChain(def) {
|
||||
validateChain(def);
|
||||
chains.set(def.id, {
|
||||
separator: DEFAULT_SEPARATOR,
|
||||
header: '',
|
||||
footer: '',
|
||||
...def,
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterChain(id) {
|
||||
return chains.delete(id);
|
||||
}
|
||||
|
||||
export function getChain(id) {
|
||||
return chains.get(id) ?? null;
|
||||
}
|
||||
|
||||
export function listChains() {
|
||||
return [...chains.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Chain,按 order 排序后拼接成最终字符串。
|
||||
*
|
||||
* @param {string} chainId
|
||||
* @param {{ settings?, signal?, extras? }} [opts]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function composeChain(chainId, opts = {}) {
|
||||
if (!chainId) return '';
|
||||
const chain = getChain(chainId);
|
||||
const results = await executeContext({ context: chainId, ...opts });
|
||||
|
||||
const sorted = results
|
||||
.filter(r => r !== null)
|
||||
.sort((a, b) => (a.block.order ?? 0) - (b.block.order ?? 0));
|
||||
|
||||
const separator = chain?.separator ?? DEFAULT_SEPARATOR;
|
||||
const body = sorted.map(r => r.value).join(separator);
|
||||
|
||||
const parts = [chain?.header, body, chain?.footer]
|
||||
.map(p => (typeof p === 'string' ? p : ''))
|
||||
.filter(p => p.length > 0);
|
||||
|
||||
return parts.join(separator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 Chain 的执行结果明细(含每块原值),用于调试或调用方自定义后处理。
|
||||
*
|
||||
* @returns {Promise<Array<{ block, value }>>}
|
||||
*/
|
||||
export async function inspectChain(chainId, opts = {}) {
|
||||
if (!chainId) return [];
|
||||
const results = await executeContext({ context: chainId, ...opts });
|
||||
return results
|
||||
.filter(r => r !== null)
|
||||
.sort((a, b) => (a.block.order ?? 0) - (b.block.order ?? 0));
|
||||
}
|
||||
102
core/memory-blocks/custom-blocks.js
Normal file
102
core/memory-blocks/custom-blocks.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* core/memory-blocks/custom-blocks.js — 用户自定义块的持久化(Phase 2)
|
||||
*
|
||||
* 自定义块以纯 JSON(BlockDefinition 数组)存于
|
||||
* extension_settings[extensionName].memoryBlocks_customBlocks,
|
||||
* 与运行时注册中心(registry.js)双向同步:
|
||||
* - bootstrap / UI 初始化时 syncCustomBlocksFromSettings() 全量重放
|
||||
* - 增删改 CRUD 同时更新 settings 与 registry,并 saveSettingsDebounced
|
||||
*
|
||||
* 自定义块 id 一律以 'custom.' 为前缀,与内置块('plotOpt.sulv1' 等)天然
|
||||
* 隔离;CRUD 仅对该前缀生效,内置块不可经此修改或删除。
|
||||
*/
|
||||
|
||||
import { extension_settings } from '/scripts/extensions.js';
|
||||
import { saveSettingsDebounced } from '/script.js';
|
||||
import { extensionName } from '../../utils/settings.js';
|
||||
import { register, unregister, listAll } from './registry.js';
|
||||
|
||||
const STORAGE_KEY = 'memoryBlocks_customBlocks';
|
||||
export const CUSTOM_ID_PREFIX = 'custom.';
|
||||
|
||||
export function isCustomBlockId(id) {
|
||||
return typeof id === 'string' && id.startsWith(CUSTOM_ID_PREFIX);
|
||||
}
|
||||
|
||||
function getStore() {
|
||||
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
|
||||
const s = extension_settings[extensionName];
|
||||
if (!Array.isArray(s[STORAGE_KEY])) s[STORAGE_KEY] = [];
|
||||
return s[STORAGE_KEY];
|
||||
}
|
||||
|
||||
function persist() {
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
function newCustomId() {
|
||||
return `${CUSTOM_ID_PREFIX}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 settings 中的自定义块全量重放进 registry(幂等,可重复调用)。
|
||||
* 单个块定义损坏时跳过并告警,不影响其余块。
|
||||
*/
|
||||
export function syncCustomBlocksFromSettings() {
|
||||
for (const b of listAll()) {
|
||||
if (isCustomBlockId(b.id)) unregister(b.id);
|
||||
}
|
||||
for (const def of getStore()) {
|
||||
try {
|
||||
register(def);
|
||||
} catch (error) {
|
||||
console.warn(`[MemoryBlocks] 自定义块定义损坏,已跳过:`, def, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 列出某 context 下的自定义块(settings 为权威源;不过滤 enabled)。 */
|
||||
export function listCustomBlocks(context) {
|
||||
const store = getStore();
|
||||
return context ? store.filter(b => b.context === context) : [...store];
|
||||
}
|
||||
|
||||
export function getCustomBlock(id) {
|
||||
return getStore().find(b => b.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增自定义块。def 不含 id(自动生成);校验失败时抛错、不落库。
|
||||
* @returns {Object} 落库后的完整定义
|
||||
*/
|
||||
export function addCustomBlock(def) {
|
||||
const full = { enabled: true, ...def, id: newCustomId() };
|
||||
register(full); // 先过 registry 校验,抛错则不落库
|
||||
getStore().push(full);
|
||||
persist();
|
||||
return full;
|
||||
}
|
||||
|
||||
/** 修改自定义块(浅合并 patch;id/非 custom 块不可改)。 */
|
||||
export function updateCustomBlock(id, patch) {
|
||||
if (!isCustomBlockId(id)) throw new Error(`[MemoryBlocks] 仅自定义块可修改: ${id}`);
|
||||
const store = getStore();
|
||||
const idx = store.findIndex(b => b.id === id);
|
||||
if (idx === -1) throw new Error(`[MemoryBlocks] 自定义块不存在: ${id}`);
|
||||
const merged = { ...store[idx], ...patch, id };
|
||||
register(merged); // 校验 + 覆盖注册
|
||||
store[idx] = merged;
|
||||
persist();
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function deleteCustomBlock(id) {
|
||||
if (!isCustomBlockId(id)) return false;
|
||||
const store = getStore();
|
||||
const idx = store.findIndex(b => b.id === id);
|
||||
if (idx === -1) return false;
|
||||
store.splice(idx, 1);
|
||||
unregister(id);
|
||||
persist();
|
||||
return true;
|
||||
}
|
||||
@@ -1,43 +1,27 @@
|
||||
/**
|
||||
* core/memory-blocks/executor.js
|
||||
*
|
||||
* 工作流执行器:拉 context 下的全部块 → Promise.all 并发执行 generator
|
||||
* → 把每个块的结果按 placeholder 替换回模板。
|
||||
* 模板替换式工作流:用块结果 substitute 到模板的 placeholder 处。
|
||||
* 与 chain.js(顺序拼接式)并列两种组合方式,共用 runner.js 的底层执行原语。
|
||||
*
|
||||
* 适用场景:sulv1-4 这种"prompt 里已挖好占位符,块负责填料"。
|
||||
*
|
||||
* 核心 API:
|
||||
* applyToTemplate(template, opts) 单模板进,字符串出
|
||||
* applyToTemplates(templates, opts) 多模板进(数组或对象),结构同形出;
|
||||
* 块只执行一次,对每个模板复用结果
|
||||
* generateBlockMap(opts) 不替换,返回 { id → value } 给调用方自己玩
|
||||
* generateBlockMap(opts) 不替换,返回 { id → value } 给调用方自由组合
|
||||
*
|
||||
* 中断行为:opts.signal 由调用方控制,传给每个 handler;任一 handler 抛
|
||||
* AbortError 时,executor 也抛 AbortError 向上传递(与现有 callAI 体系一致)。
|
||||
* AbortError 时整体抛出向上传递(与现有 callAI 体系一致)。
|
||||
*/
|
||||
|
||||
import { getHandler } from './generator-handlers.js';
|
||||
import { listByContext } from './registry.js';
|
||||
import { executeContext } from './runner.js';
|
||||
|
||||
function escapeForRegex(s) {
|
||||
return String(s).replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function runBlock(block, ctx) {
|
||||
const handler = getHandler(block.generator?.type);
|
||||
if (!handler) {
|
||||
console.warn(`[MemoryBlocks] 未注册的 generator 类型 "${block.generator?.type}",块 ${block.id} 已跳过。`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const value = await handler(block, ctx);
|
||||
if (value === null || value === undefined) return null;
|
||||
return { block, value: String(value) };
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') throw error;
|
||||
console.error(`[MemoryBlocks] 块 ${block.id} 生成失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function substituteOne(template, results) {
|
||||
if (typeof template !== 'string' || !template) return template ?? '';
|
||||
let out = template;
|
||||
@@ -49,20 +33,9 @@ function substituteOne(template, results) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 context 下的所有块,返回 [ {block, value} | null, ... ]。
|
||||
* 内部使用,applyToTemplate(s) 复用。
|
||||
*/
|
||||
async function executeBlocks({ context, settings, signal, extras } = {}) {
|
||||
const blocks = listByContext(context);
|
||||
if (blocks.length === 0) return [];
|
||||
const ctx = { settings: settings ?? {}, signal, context, extras };
|
||||
return await Promise.all(blocks.map(b => runBlock(b, ctx)));
|
||||
}
|
||||
|
||||
export async function applyToTemplate(template, opts = {}) {
|
||||
if (typeof template !== 'string' || !template) return template ?? '';
|
||||
const results = await executeBlocks(opts);
|
||||
const results = await executeContext(opts);
|
||||
return substituteOne(template, results);
|
||||
}
|
||||
|
||||
@@ -73,7 +46,7 @@ export async function applyToTemplate(template, opts = {}) {
|
||||
* - 字符串 → 退化为 applyToTemplate
|
||||
*/
|
||||
export async function applyToTemplates(templates, opts = {}) {
|
||||
const results = await executeBlocks(opts);
|
||||
const results = await executeContext(opts);
|
||||
|
||||
if (typeof templates === 'string') return substituteOne(templates, results);
|
||||
if (Array.isArray(templates)) return templates.map(t => substituteOne(t, results));
|
||||
@@ -89,7 +62,7 @@ export async function applyToTemplates(templates, opts = {}) {
|
||||
* 不替换,只把块结果汇成 Map<id, value>,调用方拿去自由组合。
|
||||
*/
|
||||
export async function generateBlockMap(opts = {}) {
|
||||
const results = await executeBlocks(opts);
|
||||
const results = await executeContext(opts);
|
||||
const map = new Map();
|
||||
for (const r of results) {
|
||||
if (r) map.set(r.block.id, r.value);
|
||||
|
||||
@@ -3,19 +3,39 @@
|
||||
*
|
||||
* 记忆块工作流系统对外入口。导入此模块即触发:
|
||||
* 1. generator-handlers 加载 → 注册内置 'static' handler
|
||||
* 2. registerBuiltinBlocks() → 注册首批内置块(sulv1-4)
|
||||
* 2. ai-call-handler 加载 → 注册 'ai_call' handler(Phase 2)
|
||||
* 3. registerBuiltinBlocks() → 注册首批内置块(sulv1-4)
|
||||
* 4. syncCustomBlocksFromSettings() → 重放用户自定义块(Phase 2)
|
||||
*
|
||||
* 两种组合范式:
|
||||
* - 模板替换式(executor.js):prompt 里挖空 placeholder,块填料 → 适合 sulv1-4
|
||||
* - 顺序拼接式(chain.js) :块各自产出一段,按 order 拼接成完整注入块 →
|
||||
* 适合记忆注入、战报底部块
|
||||
*
|
||||
* 公开 API:
|
||||
* - register / unregister / getById / listByContext / listAll
|
||||
* - registerHandler / getHandler / listHandlerTypes
|
||||
* - applyToTemplate(template, opts)
|
||||
* - applyToTemplates(templates, opts) ← 多模板批处理首选
|
||||
* - generateBlockMap(opts)
|
||||
* Block:
|
||||
* register / unregister / getById / listByContext / listAll
|
||||
* replaceContextBlocks (批量替换某 context 下全部块,JSON 导入用)
|
||||
* Handler:
|
||||
* registerHandler / unregisterHandler / getHandler / listHandlerTypes
|
||||
* 模板替换式:
|
||||
* applyToTemplate(template, opts)
|
||||
* applyToTemplates(templates, opts) ← 多模板批处理首选
|
||||
* generateBlockMap(opts)
|
||||
* 顺序拼接式:
|
||||
* registerChain(def) / unregisterChain / getChain / listChains
|
||||
* composeChain(chainId, opts) → string
|
||||
* inspectChain(chainId, opts) → Array<{block, value}>(调试/自定义后处理)
|
||||
* 自定义块 CRUD(Phase 2,用户在 UI 增删改):
|
||||
* listCustomBlocks / getCustomBlock / addCustomBlock /
|
||||
* updateCustomBlock / deleteCustomBlock / syncCustomBlocksFromSettings
|
||||
* CUSTOM_ID_PREFIX / isCustomBlockId
|
||||
*
|
||||
* opts 字段:{ context, settings, signal?, extras? }
|
||||
* opts 字段:{ settings, signal?, extras? }
|
||||
* (context 对应 chainId / Block.context,由各 API 自行传或从 chainId 推导)
|
||||
*
|
||||
* 设计目标:
|
||||
* - BlockDefinition 纯数据,可 JSON 序列化(Phase 3 用户自定义导入导出)
|
||||
* - BlockDefinition / ChainDefinition 都是纯数据,JSON 可序列化(Phase 3 用户自定义导入导出)
|
||||
* - generator 通过 type 查表,handler 集中注册,便于扩展 ai_call / plugin
|
||||
* - 同一 context 下的块 Promise.all 并发;任一块抛 AbortError 整体中断
|
||||
*/
|
||||
@@ -43,9 +63,33 @@ export {
|
||||
generateBlockMap,
|
||||
} from './executor.js';
|
||||
|
||||
import { registerBuiltinBlocks } from './builtin-blocks.js';
|
||||
export {
|
||||
registerChain,
|
||||
unregisterChain,
|
||||
getChain,
|
||||
listChains,
|
||||
composeChain,
|
||||
inspectChain,
|
||||
} from './chain.js';
|
||||
|
||||
// 导入此模块即完成内置块注册(幂等)
|
||||
export {
|
||||
CUSTOM_ID_PREFIX,
|
||||
isCustomBlockId,
|
||||
listCustomBlocks,
|
||||
getCustomBlock,
|
||||
addCustomBlock,
|
||||
updateCustomBlock,
|
||||
deleteCustomBlock,
|
||||
syncCustomBlocksFromSettings,
|
||||
} from './custom-blocks.js';
|
||||
|
||||
import './ai-call-handler.js'; // 副作用:注册 'ai_call' handler
|
||||
import { registerBuiltinBlocks } from './builtin-blocks.js';
|
||||
import { syncCustomBlocksFromSettings } from './custom-blocks.js';
|
||||
|
||||
// 导入此模块即完成内置块注册与自定义块重放(均幂等)。
|
||||
// ST 在 import 扩展脚本前已加载完 extension_settings,此时读取是安全的。
|
||||
registerBuiltinBlocks();
|
||||
syncCustomBlocksFromSettings();
|
||||
|
||||
export { registerBuiltinBlocks };
|
||||
|
||||
40
core/memory-blocks/runner.js
Normal file
40
core/memory-blocks/runner.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* core/memory-blocks/runner.js
|
||||
*
|
||||
* 块执行的底层原语,被 executor.js(模板替换)和 chain.js(顺序拼接)共用。
|
||||
*
|
||||
* runBlock(block, ctx) → { block, value } | null
|
||||
* 单块执行;handler 抛 AbortError 时向上传递,其余异常吞掉并返回 null
|
||||
* handler 返回 null/undefined 时同样返回 null(视为"无内容")
|
||||
*
|
||||
* executeContext({ context, settings, signal, extras }) → Array<{block,value}|null>
|
||||
* 按 context 拉块 → Promise.all 并发执行 → 返回结果数组(保留 null 占位以便上层
|
||||
* 按 order 排序时不丢失映射关系,调用方过滤 null 即可)
|
||||
*/
|
||||
|
||||
import { getHandler } from './generator-handlers.js';
|
||||
import { listByContext } from './registry.js';
|
||||
|
||||
export async function runBlock(block, ctx) {
|
||||
const handler = getHandler(block.generator?.type);
|
||||
if (!handler) {
|
||||
console.warn(`[MemoryBlocks] 未注册的 generator 类型 "${block.generator?.type}",块 ${block.id} 已跳过。`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const value = await handler(block, ctx);
|
||||
if (value === null || value === undefined) return null;
|
||||
return { block, value: String(value) };
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') throw error;
|
||||
console.error(`[MemoryBlocks] 块 ${block.id} 生成失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeContext({ context, settings, signal, extras } = {}) {
|
||||
const blocks = listByContext(context);
|
||||
if (blocks.length === 0) return [];
|
||||
const ctx = { settings: settings ?? {}, signal, context, extras };
|
||||
return await Promise.all(blocks.map(b => runBlock(b, ctx)));
|
||||
}
|
||||
@@ -18,11 +18,12 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AiCallGenerator (Phase 2 预留)
|
||||
* @typedef {Object} AiCallGenerator 独立 AI 调用(handler 见 ai-call-handler.js)
|
||||
* @property {'ai_call'} type
|
||||
* @property {string} apiSlot
|
||||
* @property {string} promptTemplate
|
||||
* @property {string} [extractTag]
|
||||
* @property {string} [apiSlot='main'] - callAI 功能槽(ApiProfileManager.SLOTS 中的 chat 槽)
|
||||
* @property {string} promptTemplate - 作为 user 消息发送;空则块跳过
|
||||
* @property {string} [systemPrompt] - 可选的 system 消息
|
||||
* @property {string} [extractTag] - 只取回复中 <tag>...</tag> 的内容;缺失时回退完整回复
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -36,7 +37,7 @@
|
||||
|
||||
/**
|
||||
* @typedef {Object} BlockDefinition
|
||||
* @property {string} id - 全局唯一
|
||||
* @property {string} id - 全局唯一;用户自定义块以 'custom.' 为前缀(见 custom-blocks.js)
|
||||
* @property {string} placeholder - 在模板中要被替换的占位符(按字面量匹配,正则元字符自动转义)
|
||||
* @property {string} context - 所属流水线,如 'plotOptimization'
|
||||
* @property {GeneratorSpec} generator
|
||||
|
||||
@@ -40,18 +40,33 @@ function getSettings() {
|
||||
export async function getEmbedRetrievalSettings() {
|
||||
const profile = await getSlotProfile('ragEmbed');
|
||||
if (profile) {
|
||||
const apiKey = sanitizeMaskedKey(profile.apiKey ?? '');
|
||||
return {
|
||||
apiEndpoint: profile.provider === 'google' ? 'google_direct' : 'custom',
|
||||
customApiUrl: profile.apiUrl,
|
||||
apiKey: sanitizeMaskedKey(profile.apiKey ?? ''),
|
||||
apiKey,
|
||||
embeddingModel: profile.model,
|
||||
batchSize: getSettings().retrieval?.batchSize ?? 5,
|
||||
// Key 存储是设备本地的(ApiKeyStore local/cloud 模式均不跨设备),
|
||||
// 换设备/浏览器后 profile 同步而 Key 缺失——标记出来供报错说明
|
||||
_keyMissingFromProfile: !apiKey,
|
||||
_profileName: profile.name || profile.id,
|
||||
};
|
||||
}
|
||||
const fallback = getSettings().retrieval || {};
|
||||
return { ...fallback, apiKey: sanitizeMaskedKey(fallback.apiKey ?? '') };
|
||||
}
|
||||
|
||||
/** Key 缺失时的统一文案:区分"profile 在本设备无 Key"与"未配置" */
|
||||
export function describeMissingKey(resolved, plainMessage) {
|
||||
if (resolved?._keyMissingFromProfile) {
|
||||
return `连接配置「${resolved._profileName}」在本设备上没有可用的 API Key。` +
|
||||
`Key 仅保存在最初填写它的设备/浏览器上,不随云端设置同步。` +
|
||||
`请在「API 连接配置」面板编辑该配置并重新填写 Key。`;
|
||||
}
|
||||
return plainMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Rerank 配置,优先从 ragRerank 槽位 Profile 读取。
|
||||
*/
|
||||
@@ -59,12 +74,15 @@ export async function getRerankSettings() {
|
||||
const profile = await getSlotProfile('ragRerank');
|
||||
if (profile) {
|
||||
const manualSettings = getSettings().rerank || {};
|
||||
const apiKey = sanitizeMaskedKey(profile.apiKey ?? '');
|
||||
return {
|
||||
url: profile.apiUrl,
|
||||
apiKey: sanitizeMaskedKey(profile.apiKey ?? ''),
|
||||
apiKey,
|
||||
model: profile.model,
|
||||
top_n: manualSettings.top_n ?? 10,
|
||||
apiMode: manualSettings.apiMode ?? 'custom',
|
||||
_keyMissingFromProfile: !apiKey,
|
||||
_profileName: profile.name || profile.id,
|
||||
};
|
||||
}
|
||||
const fallback = getSettings().rerank || {};
|
||||
@@ -120,8 +138,8 @@ export async function fetchEmbeddingModels(overrideSettings = null) {
|
||||
|
||||
switch (apiEndpoint) {
|
||||
case 'google_direct':
|
||||
if (!apiKey) throw new Error("Google直连模式需要API Key。");
|
||||
|
||||
if (!apiKey) throw new Error(describeMissingKey(settings, "Google直连模式需要API Key。"));
|
||||
|
||||
const fetchGoogleModels = async (version) => {
|
||||
const url = `${GOOGLE_API_BASE_URL}/${version}/models`;
|
||||
console.log(`[翰林院] 正在从 Google API (${version}) 获取模型列表: ${url}`);
|
||||
@@ -225,7 +243,7 @@ export async function fetchRerankModels() {
|
||||
throw new Error("Rerank API URL 未提供。");
|
||||
}
|
||||
if (apiMode === 'custom' && !apiKey) {
|
||||
throw new Error("自定义模式下,Rerank API Key 未提供。");
|
||||
throw new Error(describeMissingKey(settings, "自定义模式下,Rerank API Key 未提供。"));
|
||||
}
|
||||
|
||||
const baseUrl = getRerankBaseUrl(url);
|
||||
@@ -264,7 +282,7 @@ export async function executeRerank(query, documents, rerankSettings = null) {
|
||||
const { url, apiKey, model, top_n, apiMode = 'custom' } = resolved;
|
||||
|
||||
if (!url) throw new Error("Rerank API URL 未提供。");
|
||||
if (apiMode === 'custom' && !apiKey) throw new Error("自定义模式下,Rerank API Key 未提供。");
|
||||
if (apiMode === 'custom' && !apiKey) throw new Error(describeMissingKey(resolved, "自定义模式下,Rerank API Key 未提供。"));
|
||||
|
||||
const baseUrl = getRerankBaseUrl(url);
|
||||
const rerankUrl = `${baseUrl}/v1/rerank`;
|
||||
@@ -309,10 +327,12 @@ export function getApiEndpointUrl(raw = false, overrideRetrieval = null) {
|
||||
break;
|
||||
case 'azure':
|
||||
case 'custom':
|
||||
case 'local_proxy': // 本地代理(LM Studio/Ollama)同样使用用户填写的地址,此前漏掉落入 default 被错误定向到 openai.com
|
||||
url = customApiUrl;
|
||||
break;
|
||||
default:
|
||||
url = 'https://api.openai.com';
|
||||
// apiEndpoint 为空/非法(历史 profile-sync 污染)时,customApiUrl 比硬编码 openai.com 更可能是用户真实意图
|
||||
url = customApiUrl || 'https://api.openai.com';
|
||||
break;
|
||||
}
|
||||
if (raw) {
|
||||
@@ -356,7 +376,7 @@ export async function getEmbeddings(texts, signal = null) {
|
||||
switch (apiEndpoint) {
|
||||
case 'google_direct':
|
||||
console.log('[翰林院-API] 使用Google直连模式获取向量。');
|
||||
if (!apiKey) throw new Error('Google直连模式需要API Key。');
|
||||
if (!apiKey) throw new Error(describeMissingKey(settings, 'Google直连模式需要API Key。'));
|
||||
|
||||
// 使用适配器构建URL和请求体;Key 通过 x-goog-api-key 头传递避免 URL 泄露
|
||||
const googleUrl = buildGoogleEmbeddingApiUrl(GOOGLE_API_BASE_URL, embeddingModel);
|
||||
|
||||
@@ -152,6 +152,7 @@ function initialize() {
|
||||
return;
|
||||
}
|
||||
migrateLegacyRagSettings();
|
||||
sanitizeProfilePollution();
|
||||
settings = getSettings();
|
||||
if (!window.hanlinyuanRagProcessor) {
|
||||
window.hanlinyuanRagProcessor = {};
|
||||
@@ -219,20 +220,27 @@ async function ingestTextToHanlinyuan(text, source = 'manual', metadata = {}, pr
|
||||
break;
|
||||
}
|
||||
|
||||
// 独立聊天记忆模式:聊天记录类向量按聊天分桶(剧情线隔离),
|
||||
// 其余来源(小说/世界书/手动)属于"知识",仍随角色卡共享
|
||||
const independentChatId = (source === 'chat_history' && settings.retrieval.independentChatMemoryEnabled)
|
||||
? getChatId()
|
||||
: null;
|
||||
|
||||
const existingKbs = Object.values(getKnowledgeBases());
|
||||
const foundKb = existingKbs.find(kb => kb.name === kbName);
|
||||
// 同名合并需限定在同一聊天命名空间内,避免独立模式下不同聊天的同名楼层段互相串库
|
||||
const foundKb = existingKbs.find(kb => kb.name === kbName && (kb.chatId ?? null) === independentChatId);
|
||||
|
||||
if (foundKb) {
|
||||
taskId = foundKb.id;
|
||||
logCallback(`[翰林院-核心] 检测到同名知识库 "${kbName}",将数据合并入库。`, 'info');
|
||||
} else {
|
||||
logCallback(`[翰林院-核心] 准备为任务 "${kbName}" 创建专属知识库...`, 'info');
|
||||
const newKb = addKnowledgeBase(kbName, source);
|
||||
const newKb = addKnowledgeBase(kbName, source, independentChatId);
|
||||
taskId = newKb.id;
|
||||
}
|
||||
|
||||
|
||||
const charId = getCharacterStableId();
|
||||
const collectionId = `${charId}_${taskId}`;
|
||||
const collectionId = independentChatId ? `${independentChatId}_${taskId}` : `${charId}_${taskId}`;
|
||||
logCallback(`[翰林院-核心] 已创建并锁定知识库: ${kbName} (集合ID: ${collectionId})`, 'success');
|
||||
logCallback(`[翰林院-核心] 已锁定忆识宝库ID: ${collectionId}`, 'info');
|
||||
|
||||
@@ -410,6 +418,49 @@ function migrateLegacyRagSettings() {
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性清洗 profile-sync 历史污染(2.2.5 之前的版本遗留)。
|
||||
*
|
||||
* 旧版 saveSettingsFromUI 会把被 Profile 接管的隐藏字段值写回 settings:
|
||||
* - apiKey 被写成掩码 '••••••••'(rag-api 已有读侧防御,这里根治持久层)
|
||||
* - apiEndpoint 的 select 被 _fillLegacyFields 赋了不存在的 option 值
|
||||
* (profile.provider 如 'custom_oai')后 value 变 '','' 被写回 settings;
|
||||
* '' 在 getApiEndpointUrl 落 default 分支,请求被错误定向 → 向量化全失败
|
||||
*
|
||||
* 2.2.5 修复了"继续污染",本函数清理已污染的存量数据。
|
||||
*/
|
||||
function sanitizeProfilePollution() {
|
||||
const s = getSettings();
|
||||
const MASKED = '••••••••';
|
||||
let cleaned = [];
|
||||
|
||||
if (s.retrieval?.apiKey === MASKED) {
|
||||
s.retrieval.apiKey = '';
|
||||
cleaned.push('retrieval.apiKey 掩码');
|
||||
}
|
||||
if (s.rerank?.apiKey === MASKED) {
|
||||
s.rerank.apiKey = '';
|
||||
cleaned.push('rerank.apiKey 掩码');
|
||||
}
|
||||
|
||||
// 合法值与 UI select 选项及 rag-api 的 switch 分支保持一致
|
||||
const validEndpoints = ['custom', 'google_direct', 'local_proxy', 'openai', 'azure'];
|
||||
if (s.retrieval && !validEndpoints.includes(s.retrieval.apiEndpoint)) {
|
||||
cleaned.push(`retrieval.apiEndpoint 非法值 "${s.retrieval.apiEndpoint}"`);
|
||||
s.retrieval.apiEndpoint = 'custom';
|
||||
}
|
||||
const validRerankModes = ['custom', 'local_proxy'];
|
||||
if (s.rerank && !validRerankModes.includes(s.rerank.apiMode)) {
|
||||
cleaned.push(`rerank.apiMode 非法值 "${s.rerank.apiMode}"`);
|
||||
s.rerank.apiMode = 'custom';
|
||||
}
|
||||
|
||||
if (cleaned.length > 0) {
|
||||
console.warn(`[翰林院] 已清洗 profile-sync 历史污染字段: ${cleaned.join('、')}`);
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
function showNotification(message, type = 'info') {
|
||||
toastr[type](message);
|
||||
}
|
||||
@@ -431,6 +482,71 @@ function getTagForSource(source) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 边界感知切分:把 content 切成不超过 chunkSize 的片段,尽量在自然边界断开。
|
||||
*
|
||||
* 三级回退策略(替代旧的纯字符硬切,避免句子/对话被拦腰截断):
|
||||
* 1. 段落边界(最后一个换行符)
|
||||
* 2. 句末边界(。!?!?… 及其后跟随的闭合引号/括号)
|
||||
* 3. 都找不到(极端长串)才硬切
|
||||
* 边界切点过于靠前(< 40% 块长)时视为无效,降级到下一策略——防止
|
||||
* 一个超长段落开头的短句导致块碎片化。
|
||||
*
|
||||
* @param {string} content
|
||||
* @param {number} chunkSize - 单块最大字符数
|
||||
* @param {number} overlap - 相邻块重叠字符数(语义衔接),从上一块尾部回看
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function splitBySemanticBoundary(content, chunkSize, overlap) {
|
||||
const pieces = [];
|
||||
if (!content || chunkSize <= 0) return pieces;
|
||||
|
||||
const minCut = Math.floor(chunkSize * 0.4);
|
||||
const sentenceEndRegex = /[。!?!?…][”"』」))】]?/g;
|
||||
|
||||
let pos = 0;
|
||||
while (pos < content.length) {
|
||||
let end = Math.min(pos + chunkSize, content.length);
|
||||
|
||||
if (end < content.length) {
|
||||
const slice = content.substring(pos, end);
|
||||
|
||||
// 1. 段落边界:最后一个换行(切点含换行符本身)
|
||||
let cut = slice.lastIndexOf('\n') + 1;
|
||||
|
||||
// 2. 段落边界无效时找最后一个句末边界
|
||||
if (cut <= minCut) {
|
||||
let lastSentenceEnd = -1;
|
||||
sentenceEndRegex.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = sentenceEndRegex.exec(slice)) !== null) {
|
||||
lastSentenceEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastSentenceEnd > minCut) cut = lastSentenceEnd;
|
||||
}
|
||||
|
||||
// 3. 有效边界则收缩切点,否则保持硬切
|
||||
if (cut > minCut) end = pos + cut;
|
||||
}
|
||||
|
||||
const piece = content.substring(pos, end);
|
||||
if (piece.trim().length > 0) pieces.push(piece);
|
||||
|
||||
if (end >= content.length) break;
|
||||
// overlap 回看;Math.max 防止 overlap >= 块长时死循环
|
||||
pos = Math.max(end - overlap, pos + 1);
|
||||
}
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/** 把 ISO/任意时间值格式化为写入块 prefix 的紧凑标识(不含逗号,便于正则反解) */
|
||||
function formatChunkTimeLabel(timestamp) {
|
||||
const d = new Date(timestamp);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function splitIntoChunks(text, source, metadata = {}) {
|
||||
switch (source) {
|
||||
case 'novel':
|
||||
@@ -465,30 +581,22 @@ function _chunkForNovel(text, metadata) {
|
||||
function processBuffer() {
|
||||
if (contentBuffer.length === 0) return;
|
||||
const content = contentBuffer.join('\n');
|
||||
let start = 0;
|
||||
let section = 1;
|
||||
while (start < content.length) {
|
||||
const end = Math.min(start + chunkSize, content.length);
|
||||
const chunkText = content.substring(start, end);
|
||||
if (chunkText.trim().length > 0) {
|
||||
const chunkMetadata = {
|
||||
source: 'novel',
|
||||
sourceName: sourceName,
|
||||
timestamp: new Date().toISOString(),
|
||||
globalIndex: globalChunkIndex++,
|
||||
volume: currentVolumeTitle,
|
||||
chapter: currentChapterTitle,
|
||||
section: section,
|
||||
};
|
||||
const tagName = getTagForSource('novel');
|
||||
const prefix = `[来源: ${sourceName}, ${currentVolumeTitle}, ${currentChapterTitle}, 第${section}节]`;
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
allChunks.push({ text: wrappedText, metadata: chunkMetadata });
|
||||
section++;
|
||||
}
|
||||
start += (chunkSize - overlap);
|
||||
if (start >= content.length) break;
|
||||
}
|
||||
const tagName = getTagForSource('novel');
|
||||
splitBySemanticBoundary(content, chunkSize, overlap).forEach((chunkText, idx) => {
|
||||
const section = idx + 1;
|
||||
const chunkMetadata = {
|
||||
source: 'novel',
|
||||
sourceName: sourceName,
|
||||
timestamp: new Date().toISOString(),
|
||||
globalIndex: globalChunkIndex++,
|
||||
volume: currentVolumeTitle,
|
||||
chapter: currentChapterTitle,
|
||||
section: section,
|
||||
};
|
||||
const prefix = `[来源: ${sourceName}, ${currentVolumeTitle}, ${currentChapterTitle}, 第${section}节]`;
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
allChunks.push({ text: wrappedText, metadata: chunkMetadata });
|
||||
});
|
||||
contentBuffer = [];
|
||||
}
|
||||
|
||||
@@ -508,11 +616,9 @@ function _chunkForNovel(text, metadata) {
|
||||
processBuffer();
|
||||
|
||||
if (allChunks.length === 0 && text.length > 0) {
|
||||
let start = 0;
|
||||
let section = 1;
|
||||
while (start < text.length) {
|
||||
const end = Math.min(start + chunkSize, text.length);
|
||||
const chunkText = text.substring(start, end);
|
||||
const tagName = getTagForSource('novel');
|
||||
splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
|
||||
const section = idx + 1;
|
||||
const chunkMetadata = {
|
||||
source: 'novel',
|
||||
sourceName: sourceName,
|
||||
@@ -522,13 +628,10 @@ function _chunkForNovel(text, metadata) {
|
||||
chapter: "第1章",
|
||||
section: section,
|
||||
};
|
||||
const tagName = getTagForSource('novel');
|
||||
const prefix = `[来源: ${sourceName}, 第1卷, 第1章, 第${section}节]`;
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
allChunks.push({ text: wrappedText, metadata: chunkMetadata });
|
||||
section++;
|
||||
start += (chunkSize - overlap);
|
||||
}
|
||||
});
|
||||
}
|
||||
return allChunks;
|
||||
}
|
||||
@@ -540,15 +643,15 @@ function _chunkForChatHistory(text, metadata) {
|
||||
const allChunks = [];
|
||||
if (!text || chunkSize <= 0) return allChunks;
|
||||
|
||||
let part = 1;
|
||||
let start = 0;
|
||||
// 时间写进 prefix 才能在检索后被反解回来(ST 向量存储不持久化 metadata)
|
||||
const timeLabel = formatChunkTimeLabel(timestamp);
|
||||
const tagName = getTagForSource('chat_history');
|
||||
|
||||
while (start < text.length) {
|
||||
const end = Math.min(start + chunkSize, text.length);
|
||||
const chunkText = text.substring(start, end);
|
||||
|
||||
const prefix = `[来源: 聊天记录, 楼层: #${floor}, 第${part}部分]`;
|
||||
const tagName = getTagForSource('chat_history');
|
||||
splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
|
||||
const part = idx + 1;
|
||||
const prefix = timeLabel
|
||||
? `[来源: 聊天记录, 楼层: #${floor}, 时间: ${timeLabel}, 第${part}部分]`
|
||||
: `[来源: 聊天记录, 楼层: #${floor}, 第${part}部分]`;
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
|
||||
allChunks.push({
|
||||
@@ -562,11 +665,7 @@ function _chunkForChatHistory(text, metadata) {
|
||||
timestamp: timestamp,
|
||||
}
|
||||
});
|
||||
|
||||
part++;
|
||||
start += (chunkSize - overlap);
|
||||
if (start >= text.length) break;
|
||||
}
|
||||
});
|
||||
return allChunks;
|
||||
}
|
||||
|
||||
@@ -577,15 +676,11 @@ function _chunkForLorebook(text, metadata) {
|
||||
const allChunks = [];
|
||||
if (!text || chunkSize <= 0) return allChunks;
|
||||
|
||||
let part = 1;
|
||||
let start = 0;
|
||||
const tagName = getTagForSource('lorebook');
|
||||
|
||||
while (start < text.length) {
|
||||
const end = Math.min(start + chunkSize, text.length);
|
||||
const chunkText = text.substring(start, end);
|
||||
|
||||
splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
|
||||
const part = idx + 1;
|
||||
const prefix = `[来源: ${bookName}, 条目: ${entryName}, 第${part}部分]`;
|
||||
const tagName = getTagForSource('lorebook');
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
|
||||
allChunks.push({
|
||||
@@ -599,11 +694,7 @@ function _chunkForLorebook(text, metadata) {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
part++;
|
||||
start += (chunkSize - overlap);
|
||||
if (start >= text.length) break;
|
||||
}
|
||||
});
|
||||
return allChunks;
|
||||
}
|
||||
|
||||
@@ -615,16 +706,12 @@ function _chunkForManual(text, metadata) {
|
||||
if (!text || chunkSize <= 0) return allChunks;
|
||||
|
||||
const timestamp = new Date();
|
||||
const readableTime = timestamp.toLocaleString('zh-CN');
|
||||
let part = 1;
|
||||
let start = 0;
|
||||
const readableTime = formatChunkTimeLabel(timestamp);
|
||||
const tagName = getTagForSource('manual');
|
||||
|
||||
while (start < text.length) {
|
||||
const end = Math.min(start + chunkSize, text.length);
|
||||
const chunkText = text.substring(start, end);
|
||||
|
||||
splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
|
||||
const part = idx + 1;
|
||||
const prefix = `[来源: ${sourceName}, 向量化录入时间: ${readableTime}, 第${part}部分]`;
|
||||
const tagName = getTagForSource('manual');
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
|
||||
allChunks.push({
|
||||
@@ -636,11 +723,7 @@ function _chunkForManual(text, metadata) {
|
||||
timestamp: timestamp.toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
part++;
|
||||
start += (chunkSize - overlap);
|
||||
if (start >= text.length) break;
|
||||
}
|
||||
});
|
||||
return allChunks;
|
||||
}
|
||||
|
||||
@@ -708,7 +791,13 @@ function getKnowledgeBases() {
|
||||
return { ...globalBases, ...localBases };
|
||||
}
|
||||
|
||||
function addKnowledgeBase(name, source = 'manual') {
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} source
|
||||
* @param {string|null} chatId - 非空时该库为"聊天级":向量集合按 `${chatId}_${taskId}`
|
||||
* 命名空间隔离(独立聊天记忆模式下的聊天记录库),查询时只对该聊天可见
|
||||
*/
|
||||
function addKnowledgeBase(name, source = 'manual', chatId = null) {
|
||||
if (!name || !name.trim()) {
|
||||
throw new Error('知识库名称不能为空');
|
||||
}
|
||||
@@ -721,17 +810,28 @@ function addKnowledgeBase(name, source = 'manual') {
|
||||
name: name.trim(),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
owner: charId,
|
||||
source: source,
|
||||
owner: charId,
|
||||
source: source,
|
||||
...(chatId ? { chatId } : {}),
|
||||
};
|
||||
|
||||
bases[taskId] = newBase;
|
||||
saveSettings();
|
||||
|
||||
console.log(`[翰林院-核心] 已为角色 ${charId} 添加新知识库: ${name} (ID: ${taskId})`);
|
||||
|
||||
console.log(`[翰林院-核心] 已为角色 ${charId} 添加新知识库: ${name} (ID: ${taskId}${chatId ? `, 聊天级: ${chatId}` : ''})`);
|
||||
return newBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算知识库的向量集合 ID(单一事实来源)。
|
||||
* 聊天级库(kb.chatId)按聊天命名空间,其余按 owner/角色命名空间。
|
||||
*/
|
||||
function getKbCollectionId(kb, scope = 'local') {
|
||||
if (kb.chatId) return `${kb.chatId}_${kb.id}`;
|
||||
if (scope === 'global') return `${kb.owner || GLOBAL_SCOPE_ID}_${kb.id}`;
|
||||
return `${getCharacterStableId()}_${kb.id}`;
|
||||
}
|
||||
|
||||
async function removeKnowledgeBase(taskId, scope) {
|
||||
const charId = getCharacterStableId();
|
||||
const bases = scope === 'global' ? getGlobalKnowledgeBases() : getLocalKnowledgeBases();
|
||||
@@ -743,9 +843,8 @@ async function removeKnowledgeBase(taskId, scope) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId = scope === 'global' ? (base.owner || GLOBAL_SCOPE_ID) : charId;
|
||||
const collectionIdToPurge = `${ownerId}_${taskId}`;
|
||||
|
||||
const collectionIdToPurge = getKbCollectionId(base, scope);
|
||||
|
||||
console.log(`[翰林院-核心] 准备删除知识库 ${taskId},将清空集合: ${collectionIdToPurge}`);
|
||||
|
||||
const purged = await purgeStorage(collectionIdToPurge);
|
||||
@@ -792,30 +891,38 @@ async function queryVectors(queryText, options = {}) {
|
||||
}
|
||||
else if (settings.retrieval.independentChatMemoryEnabled) {
|
||||
console.log('[翰林院-日志] 独立聊天记忆模式开启...');
|
||||
|
||||
|
||||
const chatId = getChatId();
|
||||
if (chatId) {
|
||||
console.log(`[翰林院-日志] 添加当前聊天宝库: ${chatId}`);
|
||||
basesToQuery.push({ id: chatId, name: `当前聊天 (${chatId})`, scope: 'chat' });
|
||||
} else {
|
||||
console.warn('[翰林院-日志] 无法获取当前聊天ID,跳过聊天宝库。');
|
||||
if (!chatId) {
|
||||
console.warn('[翰林院-日志] 无法获取当前聊天ID,聊天级知识库将被跳过。');
|
||||
}
|
||||
|
||||
const globalBases = getGlobalKnowledgeBases();
|
||||
const enabledGlobalBases = Object.values(globalBases).filter(b => b.enabled);
|
||||
// 本地库过滤规则:知识类库(无 chatId)照常可查;
|
||||
// 聊天级库(有 chatId)只对所属聊天可见——这就是"独立"的含义
|
||||
const localBases = Object.values(getLocalKnowledgeBases())
|
||||
.filter(b => b.enabled && (!b.chatId || b.chatId === chatId));
|
||||
if (localBases.length > 0) {
|
||||
const chatScoped = localBases.filter(b => b.chatId).length;
|
||||
console.log(`[翰林院-日志] 添加 ${localBases.length} 个本地知识库(其中 ${chatScoped} 个为当前聊天专属)。`);
|
||||
basesToQuery.push(...localBases.map(b => ({ ...b, scope: b.chatId ? 'chat' : 'local' })));
|
||||
}
|
||||
|
||||
const enabledGlobalBases = Object.values(getGlobalKnowledgeBases()).filter(b => b.enabled);
|
||||
if (enabledGlobalBases.length > 0) {
|
||||
console.log(`[翰林院-日志] 添加 ${enabledGlobalBases.length} 个已启用的全局知识库。`);
|
||||
basesToQuery.push(...enabledGlobalBases.map(b => ({ ...b, scope: 'global' })));
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log('[翰林院-日志] 统一角色卡模式开启...');
|
||||
const localBases = getLocalKnowledgeBases();
|
||||
const globalBases = getGlobalKnowledgeBases();
|
||||
const enabledLocalBases = Object.values(localBases).filter(b => b.enabled);
|
||||
const enabledGlobalBases = Object.values(globalBases).filter(b => b.enabled);
|
||||
|
||||
basesToQuery.push(...enabledLocalBases.map(b => ({ ...b, scope: 'local' })));
|
||||
|
||||
// 聊天级库(独立模式期间产生)在统一模式下也可见,但需用 'chat' scope
|
||||
// 才能拼出正确的集合 ID(${chatId}_${taskId})
|
||||
basesToQuery.push(...enabledLocalBases.map(b => ({ ...b, scope: b.chatId ? 'chat' : 'local' })));
|
||||
basesToQuery.push(...enabledGlobalBases.map(b => ({ ...b, scope: 'global' })));
|
||||
|
||||
if (basesToQuery.length === 0) {
|
||||
@@ -879,7 +986,9 @@ async function _executeQueryForBase(base, queryText, queryEmbedding = null) {
|
||||
collectionId = await getDynamicCollectionId();
|
||||
break;
|
||||
case 'chat':
|
||||
collectionId = base.id;
|
||||
// 聊天级库:${chatId}_${taskId} 命名空间(独立聊天记忆)。
|
||||
// 旧语义的裸 chatId 集合从未被任何录入路径写入过,无存量兼容负担
|
||||
collectionId = base.chatId ? `${base.chatId}_${base.id}` : base.id;
|
||||
break;
|
||||
case 'global':
|
||||
const ownerId = base.owner || GLOBAL_SCOPE_ID;
|
||||
@@ -945,10 +1054,12 @@ async function _executeQueryForBase(base, queryText, queryEmbedding = null) {
|
||||
switch (sourceTag) {
|
||||
case '聊天记录':
|
||||
newMetadata.source = 'chat_history';
|
||||
const chatMatch = item.text.match(/楼层:\s*#(\d+),\s*第(\d+)部分/);
|
||||
if (chatMatch && chatMatch[1] && chatMatch[2]) {
|
||||
// 时间段为可选:兼容旧格式 [楼层: #X, 第Y部分] 与新格式 [楼层: #X, 时间: ..., 第Y部分]
|
||||
const chatMatch = item.text.match(/楼层:\s*#(\d+)(?:,\s*时间:\s*([^,\]]+))?,\s*第(\d+)部分/);
|
||||
if (chatMatch && chatMatch[1] && chatMatch[3]) {
|
||||
newMetadata.floor = parseInt(chatMatch[1], 10);
|
||||
newMetadata.part = parseInt(chatMatch[2], 10);
|
||||
if (chatMatch[2]) newMetadata.timeLabel = chatMatch[2].trim();
|
||||
newMetadata.part = parseInt(chatMatch[3], 10);
|
||||
newMetadata.sourceName = `聊天记录 #${newMetadata.floor}`;
|
||||
}
|
||||
break;
|
||||
@@ -1051,43 +1162,40 @@ async function getVectorCount(taskId = null, scope = 'local') {
|
||||
console.warn(`[翰林院-计数] 在作用域 '${scope}' 中未找到ID为 ${taskId} 的知识库。`);
|
||||
return 0;
|
||||
}
|
||||
const ownerId = scope === 'global' ? (base.owner || GLOBAL_SCOPE_ID) : charId;
|
||||
const collectionId = `${ownerId}_${taskId}`;
|
||||
return await countVectorsInCollection(collectionId);
|
||||
// 聊天级库按 ${chatId}_${taskId} 命名空间计数(getKbCollectionId 统一处理)
|
||||
return await countVectorsInCollection(getKbCollectionId(base, scope));
|
||||
|
||||
} else {
|
||||
if (settings.retrieval.independentChatMemoryEnabled) {
|
||||
const chatId = getChatId();
|
||||
if (!chatId) return 0;
|
||||
const totalCount = await countVectorsInCollection(chatId);
|
||||
console.log(`[翰林院-日志] 独立聊天记忆模式开启,聊天 ${chatId} 的向量总数: ${totalCount}`);
|
||||
return totalCount;
|
||||
}
|
||||
// 总数统计与查询侧保持同一可见性规则:
|
||||
// 独立模式 → 本地知识库 + 当前聊天的聊天级库 + 全局库
|
||||
// 统一模式 → 全部本地库(含聊天级)+ 全局库 + legacy 宝库
|
||||
const independent = settings.retrieval.independentChatMemoryEnabled;
|
||||
const chatId = independent ? getChatId() : null;
|
||||
console.log(`[翰林院-日志] 开始获取${independent ? '当前聊天可见的' : '所有'}知识库向量总数...`);
|
||||
|
||||
console.log('[翰林院-日志] 开始获取所有知识库的向量总数...');
|
||||
const localBases = Object.values(getLocalKnowledgeBases());
|
||||
const localBases = Object.values(getLocalKnowledgeBases())
|
||||
.filter(base => !independent || !base.chatId || base.chatId === chatId);
|
||||
const globalBases = Object.values(getGlobalKnowledgeBases());
|
||||
|
||||
const countPromises = [];
|
||||
|
||||
localBases.forEach(base => {
|
||||
const collectionId = `${charId}_${base.id}`;
|
||||
countPromises.push(countVectorsInCollection(collectionId));
|
||||
countPromises.push(countVectorsInCollection(getKbCollectionId(base, 'local')));
|
||||
});
|
||||
|
||||
globalBases.forEach(base => {
|
||||
const ownerId = base.owner || GLOBAL_SCOPE_ID;
|
||||
const collectionId = `${ownerId}_${base.id}`;
|
||||
countPromises.push(countVectorsInCollection(collectionId));
|
||||
countPromises.push(countVectorsInCollection(getKbCollectionId(base, 'global')));
|
||||
});
|
||||
|
||||
const legacyCollectionId = await getDynamicCollectionId();
|
||||
countPromises.push(countVectorsInCollection(legacyCollectionId));
|
||||
if (!independent) {
|
||||
const legacyCollectionId = await getDynamicCollectionId();
|
||||
countPromises.push(countVectorsInCollection(legacyCollectionId));
|
||||
}
|
||||
|
||||
const counts = await Promise.all(countPromises);
|
||||
const totalCount = counts.reduce((total, count) => total + count, 0);
|
||||
|
||||
console.log(`[翰林院-日志] 所有知识库统计完成,总向量数: ${totalCount}`);
|
||||
|
||||
console.log(`[翰林院-日志] 知识库统计完成,总向量数: ${totalCount}`);
|
||||
return totalCount;
|
||||
}
|
||||
}
|
||||
@@ -1202,20 +1310,23 @@ async function processCondensation(messages, logCallback = () => {}, range = nul
|
||||
kbName = `聊天记录: ${timestamp}`;
|
||||
}
|
||||
|
||||
const existingKbs = Object.values(getLocalKnowledgeBases());
|
||||
const foundKb = existingKbs.find(kb => kb.name === kbName);
|
||||
// 独立聊天记忆模式下凝识结果按聊天分桶,与 ingestTextToHanlinyuan 的语义一致
|
||||
const independentChatId = settings.retrieval.independentChatMemoryEnabled ? getChatId() : null;
|
||||
|
||||
const existingKbs = Object.values(getLocalKnowledgeBases());
|
||||
const foundKb = existingKbs.find(kb => kb.name === kbName && (kb.chatId ?? null) === independentChatId);
|
||||
|
||||
if (foundKb) {
|
||||
taskId = foundKb.id;
|
||||
logCallback(`[翰林院-核心] 检测到同名知识库 "${kbName}",将数据合并入库。`, 'info');
|
||||
} else {
|
||||
logCallback(`[翰林院-核心] 准备为任务 "${kbName}" 创建专属知识库...`, 'info');
|
||||
const newKb = addKnowledgeBase(kbName, 'chat_history');
|
||||
const newKb = addKnowledgeBase(kbName, 'chat_history', independentChatId);
|
||||
taskId = newKb.id;
|
||||
}
|
||||
|
||||
|
||||
const charId = getCharacterStableId();
|
||||
const collectionId = `${charId}_${taskId}`;
|
||||
const collectionId = independentChatId ? `${independentChatId}_${taskId}` : `${charId}_${taskId}`;
|
||||
logCallback(`[翰林院-核心] 凝识任务已锁定知识库: ${kbName} (集合ID: ${collectionId})`, 'success');
|
||||
|
||||
const allChunks = [];
|
||||
@@ -1478,18 +1589,102 @@ async function rerankResults(allResults, queryText, settings) {
|
||||
finalScoredResults.sort((a, b) => (b.final_score || 0) - (a.final_score || 0));
|
||||
console.log('[翰林院-Rerank] 元数据加权排序完成。');
|
||||
|
||||
let finalResults = finalScoredResults;
|
||||
// 先按相关度截断 top_n,再做时序排序——顺序反了会让"时序最早"而非"最相关"
|
||||
// 的块占据名额(超级排序把最旧楼层排最前,slice 会扔掉高相关的靠后结果)
|
||||
let finalResults = finalScoredResults.slice(0, settings.rerank.top_n);
|
||||
if (settings.rerank.superSortEnabled) {
|
||||
finalResults = superSort(finalScoredResults);
|
||||
finalResults = superSort(finalResults);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
results: finalResults.slice(0, settings.rerank.top_n),
|
||||
results: finalResults,
|
||||
reranked: rerankedSuccessfully
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从"第十二章"/"第3卷"/"4"等字符串中解析序数,用于注入前的时序排序。
|
||||
* 支持阿拉伯数字与常见中文数字(至万级);解析失败返回 MAX_SAFE_INTEGER(排最后)。
|
||||
*/
|
||||
function _parseOrdinal(value) {
|
||||
if (typeof value === 'number') return value;
|
||||
if (!value) return Number.MAX_SAFE_INTEGER;
|
||||
const str = String(value);
|
||||
const arabic = str.match(/\d+/);
|
||||
if (arabic) return parseInt(arabic[0], 10);
|
||||
|
||||
const cnDigit = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
|
||||
const m = str.match(/[零一二两三四五六七八九十百千万]+/);
|
||||
if (!m) return Number.MAX_SAFE_INTEGER;
|
||||
let total = 0, current = 0;
|
||||
for (const ch of m[0]) {
|
||||
if (cnDigit[ch] !== undefined) {
|
||||
current = cnDigit[ch];
|
||||
} else if (ch === '十') {
|
||||
total += (current || 1) * 10;
|
||||
current = 0;
|
||||
} else if (ch === '百') {
|
||||
total += (current || 1) * 100;
|
||||
current = 0;
|
||||
} else if (ch === '千') {
|
||||
total += (current || 1) * 1000;
|
||||
current = 0;
|
||||
} else if (ch === '万') {
|
||||
total = (total + current) * 10000;
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
return total + current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入前的组内时序重排 + 断层提示。
|
||||
*
|
||||
* rerank/相似度负责"选哪些块",本函数负责"按什么顺序呈现":
|
||||
* - chat_history 按楼层+部分升序;相邻块楼层跳跃时插入断层提示行,
|
||||
* 避免 LLM 把"不打不相识"和"关系亲密"两个远隔的片段读成连续剧情
|
||||
* - novel 按卷/章/节序数升序(中文数字章节号可解析)
|
||||
* - lorebook / manual 按来源聚合 + part 升序,碎块归位
|
||||
* 元数据缺失的块排在末尾、保持彼此原有顺序(sort 稳定性)。
|
||||
*/
|
||||
function _composeInjectionText(source, results) {
|
||||
const sorted = [...results];
|
||||
const ord = (v) => (Number.isFinite(v) ? v : Number.MAX_SAFE_INTEGER);
|
||||
|
||||
if (source === 'chat_history') {
|
||||
sorted.sort((a, b) =>
|
||||
ord(a.metadata?.floor) - ord(b.metadata?.floor)
|
||||
|| (a.metadata?.part ?? 0) - (b.metadata?.part ?? 0));
|
||||
|
||||
const parts = [];
|
||||
let prevFloor = null;
|
||||
for (const r of sorted) {
|
||||
const floor = r.metadata?.floor;
|
||||
if (prevFloor !== null && Number.isFinite(floor) && floor - prevFloor > 1) {
|
||||
parts.push(`〔提示:以下内容与上文相隔约 ${floor - prevFloor} 楼,期间的剧情未被检索到,两段内容并非连续发生〕`);
|
||||
}
|
||||
parts.push(r.text);
|
||||
if (Number.isFinite(floor)) prevFloor = floor;
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
if (source === 'novel') {
|
||||
sorted.sort((a, b) =>
|
||||
_parseOrdinal(a.metadata?.volume) - _parseOrdinal(b.metadata?.volume)
|
||||
|| _parseOrdinal(a.metadata?.chapter) - _parseOrdinal(b.metadata?.chapter)
|
||||
|| _parseOrdinal(a.metadata?.section) - _parseOrdinal(b.metadata?.section));
|
||||
return sorted.map(r => r.text).join('\n\n');
|
||||
}
|
||||
|
||||
// lorebook / manual:同源聚合 + part 升序
|
||||
sorted.sort((a, b) =>
|
||||
String(a.metadata?.sourceName ?? '').localeCompare(String(b.metadata?.sourceName ?? ''), 'zh')
|
||||
|| (a.metadata?.part ?? 0) - (b.metadata?.part ?? 0));
|
||||
return sorted.map(r => r.text).join('\n\n');
|
||||
}
|
||||
|
||||
async function rearrangeChat(chat, contextSize, abort, type) {
|
||||
const injectionKeys = {
|
||||
novel: 'HANLINYUAN_RAG_NOVEL',
|
||||
@@ -1704,7 +1899,8 @@ async function rearrangeChat(chat, contextSize, abort, type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const formattedText = results.map(r => r.text).join('\n\n');
|
||||
// 组内按时序重排 + 断层提示(rerank 决定选哪些块,时序决定呈现顺序)
|
||||
const formattedText = _composeInjectionText(source, results);
|
||||
const placeholder = `{{${source.replace('_history', '')}_text}}`;
|
||||
let injectionContent = injectionSettings.template.replace(placeholder, formattedText);
|
||||
|
||||
@@ -1751,6 +1947,13 @@ async function moveKnowledgeBase(taskId, fromScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 聊天级库(独立聊天记忆产物)专属于单个聊天,移到全局会让所有角色
|
||||
// 检索到某个特定聊天的记忆,语义矛盾,禁止
|
||||
if (kbData.chatId && toScope === 'global') {
|
||||
toastr.warning(`知识库【${kbData.name}】是聊天专属记忆,不能移动到全局。`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromScope === 'local' && toScope === 'global' && !kbData.owner) {
|
||||
console.log(`[翰林院-配置] 为旧版知识库 ${taskId} 补充所有者ID: ${charId}`);
|
||||
kbData.owner = charId;
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
export const defaultSettings = {
|
||||
retrieval: {
|
||||
enabled: false,
|
||||
apiEndpoint: 'openai',
|
||||
enabled: false,
|
||||
// 默认走 custom 与下面的 customApiUrl 配套;旧默认 'openai' 不在 UI select
|
||||
// 选项里,会在首次保存时被写成 ''(已有用户的 'openai' 值仍合法、不迁移)
|
||||
apiEndpoint: 'custom',
|
||||
customApiUrl: 'https://api.siliconflow.cn/v1',
|
||||
apiKey: '',
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
* 公开接口(query('SuperMemory')):
|
||||
* initialize() — 初始化超级记忆系统
|
||||
* forceSyncAll() — 全量同步到世界书
|
||||
* tryRestoreStateFromMetadata() — 从聊天元数据恢复状态
|
||||
* awaitSync() — 等待当前同步队列完成(Pipeline Stage 4 使用)
|
||||
* purge() — 清空记忆世界书
|
||||
*
|
||||
* 注:tryRestoreStateFromMetadata 已删除——msg.metadata 非 ST 持久化字段,
|
||||
* 该恢复路径从未真正工作过;表格状态的持久化与恢复由表格系统
|
||||
* (loadTables / msg.extra.amily2_tables_data)唯一负责。
|
||||
*/
|
||||
|
||||
import {
|
||||
initializeSuperMemory,
|
||||
tryRestoreStateFromMetadata,
|
||||
forceSyncAll,
|
||||
awaitSync,
|
||||
purgeSuperMemory,
|
||||
@@ -34,12 +36,11 @@ setTimeout(() => {
|
||||
return;
|
||||
}
|
||||
_ctx.expose({
|
||||
initialize: () => initializeSuperMemory(),
|
||||
forceSyncAll: () => forceSyncAll(),
|
||||
tryRestoreStateFromMetadata: () => tryRestoreStateFromMetadata(),
|
||||
awaitSync: () => awaitSync(),
|
||||
purge: () => purgeSuperMemory(),
|
||||
pushUpdate: (payload) => pushUpdate(payload),
|
||||
initialize: () => initializeSuperMemory(),
|
||||
forceSyncAll: () => forceSyncAll(),
|
||||
awaitSync: () => awaitSync(),
|
||||
purge: () => purgeSuperMemory(),
|
||||
pushUpdate: (payload) => pushUpdate(payload),
|
||||
});
|
||||
_ctx.log('SuperMemoryService', 'info', 'SuperMemory 服务已注册到 Bus。');
|
||||
} catch (e) {
|
||||
@@ -50,7 +51,6 @@ setTimeout(() => {
|
||||
// ── 向后兼容具名导出 ──────────────────────────────────────────────────────
|
||||
export {
|
||||
initializeSuperMemory,
|
||||
tryRestoreStateFromMetadata,
|
||||
forceSyncAll,
|
||||
awaitSync,
|
||||
purgeSuperMemory,
|
||||
|
||||
@@ -42,6 +42,12 @@ export function bindSuperMemoryEvents() {
|
||||
if (id === 'sm-system-enabled') {
|
||||
extension_settings[extensionName]['super_memory_enabled'] = this.checked;
|
||||
saveSettingsDebounced();
|
||||
// 【修复】启动时若开关为关,initializeSuperMemory 会早退且不注册监听器;
|
||||
// 旧实现勾选后只写设置不初始化,导致开关"打开了但没反应"直到刷新页面。
|
||||
// initializeSuperMemory 幂等(isInitialized 防重入),此处直接补初始化。
|
||||
if (this.checked) {
|
||||
initializeSuperMemory();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id === 'sm-bridge-enabled') {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { extensionName } from "../../utils/settings.js";
|
||||
import { amilyHelper } from "../tavern-helper/main.js";
|
||||
import { generateIndex } from "./smart-indexer.js";
|
||||
import { syncToLorebook, ensureMemoryBook, updateTransientHint, getMemoryBookName } from "./lorebook-bridge.js";
|
||||
import { getMemoryState, loadMemoryState, saveMemoryState } from "../table-system/manager.js";
|
||||
import { TABLE_UPDATED_EVENT } from "../table-system/events-schema.js";
|
||||
import { getMemoryState } from "../table-system/manager.js";
|
||||
import { TABLE_UPDATED_EVENT, inferTableRole } from "../table-system/events-schema.js";
|
||||
import { eventSource, event_types } from "/script.js";
|
||||
import { handleArchiveUpdate } from "../archive-manager.js";
|
||||
|
||||
@@ -12,11 +12,8 @@ import { handleArchiveUpdate } from "../archive-manager.js";
|
||||
let isInitialized = false;
|
||||
let updateQueue = [];
|
||||
let isProcessing = false;
|
||||
let lastChatId = null;
|
||||
let _syncPromise = null; // tracks the running processQueue() promise for pipeline awaiting
|
||||
|
||||
const METADATA_KEY = 'Amily2_Memory_Data';
|
||||
|
||||
/**
|
||||
* [AMILY2-MODIFIED] Pipeline integration:
|
||||
* Allows MessagePipeline Stage 4 to await the super-memory sync triggered
|
||||
@@ -53,24 +50,22 @@ export async function initializeSuperMemory() {
|
||||
}
|
||||
|
||||
document.addEventListener(TABLE_UPDATED_EVENT, handleTableUpdate);
|
||||
|
||||
|
||||
// 【修复】CHAT_CHANGED 时不再主动 forceSyncAll:
|
||||
// 表格系统在 index.js 的 CHAT_CHANGED 里延迟 100ms 才 loadTables(),
|
||||
// 此处立即同步会把【旧聊天】的表格内容写进【新角色】的记忆世界书(竞态污染;
|
||||
// 两边表名不同时旧表条目无 GC 兜底,会永久残留)。
|
||||
// 无需自行补同步:loadTables() 三个分支结尾都会 dispatchAllTablesUpdate(),
|
||||
// 新状态会经 pushUpdate 自动入队。这里只负责确保新角色的记忆世界书存在。
|
||||
eventSource.on(event_types.CHAT_CHANGED, async () => {
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
if (settings.super_memory_enabled === false) return;
|
||||
|
||||
console.log('[Amily2-SuperMemory] 检测到聊天切换,正在刷新记忆状态...');
|
||||
await checkWorldBookStatus();
|
||||
|
||||
await tryRestoreStateFromMetadata();
|
||||
|
||||
await forceSyncAll();
|
||||
});
|
||||
|
||||
|
||||
await checkWorldBookStatus();
|
||||
|
||||
await tryRestoreStateFromMetadata();
|
||||
|
||||
await forceSyncAll();
|
||||
|
||||
await forceSyncAll();
|
||||
|
||||
isInitialized = true;
|
||||
console.log('[Amily2-SuperMemory] 核心管理器初始化完成。');
|
||||
@@ -110,7 +105,13 @@ export function pushUpdate(payload) {
|
||||
console.log(`[Amily2-SuperMemory] 收到表格更新 (Bus): ${tableName} (Role: ${role})`);
|
||||
|
||||
updateQueue.push({ tableName, data, role, headers, rowStatuses });
|
||||
_syncPromise = processQueue();
|
||||
// 【修复】队列正忙时不可覆盖 _syncPromise:旧实现每次都赋值 processQueue(),
|
||||
// 而 processQueue 在 isProcessing 时立即返回(已 resolve 的空 Promise),
|
||||
// 导致 Pipeline Stage 4 的 awaitSync() 穿透、在同步未完成时放行后续阶段。
|
||||
// 正在跑的 drain 循环会自然吃掉刚入队的项,无需新起 Promise。
|
||||
if (!isProcessing) {
|
||||
_syncPromise = processQueue();
|
||||
}
|
||||
|
||||
// Bus 路径下 document event 不再分发,需直接通知归档管理器
|
||||
handleArchiveUpdate(payload);
|
||||
@@ -146,15 +147,18 @@ async function processQueue() {
|
||||
await processUpdateTask(task);
|
||||
}
|
||||
}
|
||||
|
||||
await saveStateToMetadata();
|
||||
|
||||
|
||||
// 【修复】移除 saveStateToMetadata():msg.metadata 不是 ST 的持久化字段
|
||||
// (消息体标准位是 msg.extra),写入后会蒸发,恢复路径永远找不到东西——
|
||||
// 整条"元数据状态保存/恢复"链路是死代码。表格状态的唯一持久化信源是
|
||||
// 表格系统自己的 msg.extra.amily2_tables_data(infra/persistence.js)。
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Amily2-SuperMemory] 处理更新队列失败:', error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
if (updateQueue.length > 0) {
|
||||
processQueue();
|
||||
_syncPromise = processQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,54 +195,67 @@ async function processUpdateTask(task) {
|
||||
updateDashboardCounters();
|
||||
}
|
||||
|
||||
async function saveStateToMetadata() {
|
||||
const context = getContext();
|
||||
if (!context.chat || context.chat.length === 0) return;
|
||||
|
||||
const lastMsgIndex = context.chat.length - 1;
|
||||
const lastMsg = context.chat[lastMsgIndex];
|
||||
|
||||
const currentState = getMemoryState();
|
||||
|
||||
if (!lastMsg.metadata) lastMsg.metadata = {};
|
||||
|
||||
lastMsg.metadata[METADATA_KEY] = JSON.parse(JSON.stringify(currentState));
|
||||
|
||||
if (context.saveChat) {
|
||||
await context.saveChat();
|
||||
}
|
||||
|
||||
console.log(`[Amily2-SuperMemory] 状态已保存至消息 #${lastMsgIndex}`);
|
||||
}
|
||||
|
||||
export async function tryRestoreStateFromMetadata() {
|
||||
const context = getContext();
|
||||
if (!context.chat || context.chat.length === 0) return;
|
||||
|
||||
let foundState = null;
|
||||
let foundIndex = -1;
|
||||
|
||||
for (let i = context.chat.length - 1; i >= 0; i--) {
|
||||
const msg = context.chat[i];
|
||||
if (msg.metadata && msg.metadata[METADATA_KEY]) {
|
||||
foundState = msg.metadata[METADATA_KEY];
|
||||
foundIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundState) {
|
||||
console.log(`[Amily2-SuperMemory] 发现历史状态 (Msg #${foundIndex}),正在恢复...`);
|
||||
if (typeof loadMemoryState === 'function') {
|
||||
loadMemoryState(foundState);
|
||||
await forceSyncAll();
|
||||
} else {
|
||||
console.warn('[Amily2-SuperMemory] table-system 缺少 loadMemoryState 方法,无法恢复状态。');
|
||||
}
|
||||
} else {
|
||||
console.log('[Amily2-SuperMemory] 未在聊天记录中发现历史状态,使用默认/当前状态。');
|
||||
}
|
||||
}
|
||||
// 【已停用 2026-06-12】saveStateToMetadata / tryRestoreStateFromMetadata:
|
||||
// msg.metadata 不是 ST 持久化字段(同 secondary-filler 修过的坑),写了会蒸发、
|
||||
// 读永远为空——整条链路判定为从未真正工作过。若它"工作"了反而更糟:恢复出的
|
||||
// 过期副本会覆盖表格系统从 msg.extra.amily2_tables_data 恢复的正确状态(双信源打架)。
|
||||
// 表格状态的持久化与恢复完全交由表格系统(loadTables / saveStateToMessage)。
|
||||
//
|
||||
// 原实现注释保留(原作者代码,不排除存在未知副作用依赖;确认稳定几个版本后再清):
|
||||
//
|
||||
// const METADATA_KEY = 'Amily2_Memory_Data';
|
||||
//
|
||||
// async function saveStateToMetadata() {
|
||||
// const context = getContext();
|
||||
// if (!context.chat || context.chat.length === 0) return;
|
||||
//
|
||||
// const lastMsgIndex = context.chat.length - 1;
|
||||
// const lastMsg = context.chat[lastMsgIndex];
|
||||
//
|
||||
// const currentState = getMemoryState();
|
||||
//
|
||||
// if (!lastMsg.metadata) lastMsg.metadata = {};
|
||||
//
|
||||
// lastMsg.metadata[METADATA_KEY] = JSON.parse(JSON.stringify(currentState));
|
||||
//
|
||||
// if (context.saveChat) {
|
||||
// await context.saveChat();
|
||||
// }
|
||||
//
|
||||
// console.log(`[Amily2-SuperMemory] 状态已保存至消息 #${lastMsgIndex}`);
|
||||
// }
|
||||
// (原调用点:processQueue 的 while 循环结束后 `await saveStateToMetadata();`)
|
||||
//
|
||||
// export async function tryRestoreStateFromMetadata() {
|
||||
// const context = getContext();
|
||||
// if (!context.chat || context.chat.length === 0) return;
|
||||
//
|
||||
// let foundState = null;
|
||||
// let foundIndex = -1;
|
||||
//
|
||||
// for (let i = context.chat.length - 1; i >= 0; i--) {
|
||||
// const msg = context.chat[i];
|
||||
// if (msg.metadata && msg.metadata[METADATA_KEY]) {
|
||||
// foundState = msg.metadata[METADATA_KEY];
|
||||
// foundIndex = i;
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (foundState) {
|
||||
// console.log(`[Amily2-SuperMemory] 发现历史状态 (Msg #${foundIndex}),正在恢复...`);
|
||||
// if (typeof loadMemoryState === 'function') { // 需从 table-system/manager.js 导入 loadMemoryState
|
||||
// loadMemoryState(foundState);
|
||||
// await forceSyncAll();
|
||||
// } else {
|
||||
// console.warn('[Amily2-SuperMemory] table-system 缺少 loadMemoryState 方法,无法恢复状态。');
|
||||
// }
|
||||
// } else {
|
||||
// console.log('[Amily2-SuperMemory] 未在聊天记录中发现历史状态,使用默认/当前状态。');
|
||||
// }
|
||||
// }
|
||||
// (原调用点:initializeSuperMemory 与 CHAT_CHANGED 监听器内,各一次,后接 forceSyncAll;
|
||||
// Bus 暴露:SuperMemoryService 的 tryRestoreStateFromMetadata,已一并停用)
|
||||
|
||||
function updateDashboardCounters() {
|
||||
const tables = getMemoryState();
|
||||
@@ -271,20 +288,19 @@ export async function forceSyncAll() {
|
||||
}
|
||||
|
||||
for (const table of tables) {
|
||||
let role = 'database';
|
||||
if (table.name.includes('时空') || table.name.includes('世界钟')) role = 'anchor';
|
||||
if (table.name.includes('日志') || table.name.includes('Log')) role = 'log';
|
||||
|
||||
updateQueue.push({
|
||||
tableName: table.name,
|
||||
data: table.rows,
|
||||
headers: table.headers,
|
||||
rowStatuses: table.rowStatuses || [],
|
||||
role: role
|
||||
headers: table.headers,
|
||||
rowStatuses: table.rowStatuses || [],
|
||||
role: inferTableRole(table.name), // 复用 events-schema 的统一推断,避免两处逻辑漂移
|
||||
});
|
||||
}
|
||||
|
||||
await processQueue();
|
||||
|
||||
if (!isProcessing) {
|
||||
_syncPromise = processQueue();
|
||||
}
|
||||
await _syncPromise;
|
||||
console.log('[Amily2-SuperMemory] 全量同步完成。');
|
||||
}
|
||||
|
||||
|
||||
477
core/table-system/actions/ui-mutations.js
Normal file
477
core/table-system/actions/ui-mutations.js
Normal file
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* @file actions/ui-mutations.js —— 19 个 UI 突变(Phase 0.4 自 manager.js 搬出)
|
||||
*
|
||||
* 表格面板上的所有用户操作入口:增删行列 / 移动 / 重命名 / 规则更新 / 清空等。
|
||||
* 函数签名与行为与搬出前完全一致;manager.js re-export 这些函数,外部调用路径零改动。
|
||||
*
|
||||
* 依赖说明:
|
||||
* - 状态读写走 infra/store.js,持久化走 infra/persistence.js
|
||||
* - SuperMemory 分发走 events-dispatch.js(与 manager 共用,无环)
|
||||
* - loadTables / saveTables 仍从 manager 引入(addTable 空状态兜底 / 日志桩),
|
||||
* manager ↔ ui-mutations 构成 ESM 循环,但二者均为 hoisted 函数声明、
|
||||
* 仅在运行时调用,与既有 manager ↔ ui/table-bindings 环同模式,安全
|
||||
*/
|
||||
|
||||
import { getContext } from '/scripts/extensions.js';
|
||||
import { saveChat } from '/script.js';
|
||||
import { saveChatDebounced } from '../../../utils/utils.js';
|
||||
|
||||
import { log } from '../logger.js';
|
||||
import { renderTables } from '../../../ui/table-bindings.js';
|
||||
import { dispatchTableUpdate, dispatchAllTablesUpdate } from '../events-dispatch.js';
|
||||
import { loadTables, saveTables } from '../manager.js';
|
||||
|
||||
import {
|
||||
getState,
|
||||
addHighlight,
|
||||
markTableUpdated,
|
||||
getUpdatedTables,
|
||||
} from '../infra/store.js';
|
||||
|
||||
import {
|
||||
saveStateToMessage,
|
||||
commitToLastMessage,
|
||||
} from '../infra/persistence.js';
|
||||
|
||||
export function deleteColumn(tableIndex, colIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex] || colIndex < 0 || colIndex >= tables[tableIndex].headers.length) {
|
||||
log(`删除列失败:在表格 ${tableIndex} 中找不到索引为 ${colIndex} 的列。`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
tables[tableIndex].headers.splice(colIndex, 1);
|
||||
tables[tableIndex].rows.forEach(row => {
|
||||
if (row.length > colIndex) row.splice(colIndex, 1);
|
||||
});
|
||||
if (tables[tableIndex].columnWidths && tables[tableIndex].columnWidths.length > colIndex) {
|
||||
tables[tableIndex].columnWidths.splice(colIndex, 1);
|
||||
}
|
||||
|
||||
log(`成功删除了表格 ${tableIndex} 的第 ${colIndex + 1} 列。`, 'success');
|
||||
saveTables(tables);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export function moveRow(tableIndex, rowIndex, direction) {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table || rowIndex < 0 || rowIndex >= table.rows.length) return;
|
||||
|
||||
const newIndex = direction === 'up' ? rowIndex - 1 : rowIndex + 1;
|
||||
if (newIndex < 0 || newIndex >= table.rows.length) return;
|
||||
|
||||
const [movedRow] = table.rows.splice(rowIndex, 1);
|
||||
table.rows.splice(newIndex, 0, movedRow);
|
||||
|
||||
if (table.rowStatuses && table.rowStatuses.length === table.rows.length + 1) {
|
||||
const [movedStatus] = table.rowStatuses.splice(rowIndex, 1);
|
||||
table.rowStatuses.splice(newIndex, 0, movedStatus);
|
||||
}
|
||||
|
||||
log(`成功将表格 ${tableIndex} 的第 ${rowIndex + 1} 行移动到第 ${newIndex + 1} 行。`, 'success');
|
||||
saveTables(tables);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export function insertRow(tableIndex, data, position = 'below') {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table) {
|
||||
log(`插入行失败:找不到索引为 ${tableIndex} 的表格。`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let insertIndex;
|
||||
if (typeof data === 'number') {
|
||||
insertIndex = position === 'above' ? data : data + 1;
|
||||
} else {
|
||||
insertIndex = table.rows.length;
|
||||
}
|
||||
if (insertIndex < 0) insertIndex = 0;
|
||||
if (insertIndex > table.rows.length) insertIndex = table.rows.length;
|
||||
|
||||
const newRow = new Array(table.headers.length).fill('');
|
||||
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
for (const colIndex in data) {
|
||||
const cIndex = parseInt(colIndex, 10);
|
||||
if (!isNaN(cIndex) && cIndex < newRow.length) {
|
||||
newRow[cIndex] = data[colIndex];
|
||||
addHighlight(tableIndex, insertIndex, cIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.rows.splice(insertIndex, 0, newRow);
|
||||
if (!table.rowStatuses) table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
table.rowStatuses.splice(insertIndex, 0, 'normal');
|
||||
|
||||
markTableUpdated(tableIndex);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
log(`成功在表格 ${table.name} (索引 ${tableIndex}) 的第 ${insertIndex + 1} 行位置插入了新行。`, 'success');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function addRow(tableIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
const colCount = table.headers.length;
|
||||
const newRow = Array(colCount).fill('');
|
||||
table.rows.push(newRow);
|
||||
if (!table.rowStatuses) table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
table.rowStatuses.push('normal');
|
||||
markTableUpdated(tableIndex);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
log(`表格 [${table.name}] 新增了一行。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function addColumn(tableIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
const newHeader = `新列 ${table.headers.length + 1}`;
|
||||
table.headers.push(newHeader);
|
||||
table.rows.forEach(row => row.push(''));
|
||||
if (!table.columnWidths) table.columnWidths = [];
|
||||
table.columnWidths.push(null);
|
||||
log(`表格 [${table.name}] 新增了一列。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function updateHeader(tableIndex, colIndex, value) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex] || tables[tableIndex].headers[colIndex] === undefined) return;
|
||||
const tableName = tables[tableIndex].name;
|
||||
const originalHeader = tables[tableIndex].headers[colIndex];
|
||||
tables[tableIndex].headers[colIndex] = value;
|
||||
log(`表格 [${tableName}] 的表头“${originalHeader}”已更新为“${value}”。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export async function deleteRow(tableIndex, rowIndex) {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table || !table.rows[rowIndex]) return;
|
||||
|
||||
if (!table.rowStatuses) {
|
||||
table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
}
|
||||
|
||||
table.rowStatuses[rowIndex] = 'pending-deletion';
|
||||
markTableUpdated(tableIndex);
|
||||
log(`表格 [${table.name}] 的第 ${rowIndex + 1} 行已标记为待删除。`, 'info');
|
||||
|
||||
const context = getContext();
|
||||
if (context.chat?.length > 0) {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
if (saveStateToMessage(tables, lastMessage)) {
|
||||
await saveChat();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveChatDebounced();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export async function restoreRow(tableIndex, rowIndex) {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table || !table.rows[rowIndex] || !table.rowStatuses) return;
|
||||
|
||||
table.rowStatuses[rowIndex] = 'normal';
|
||||
markTableUpdated(tableIndex);
|
||||
log(`表格 [${table.name}] 的第 ${rowIndex + 1} 行已恢复。`, 'info');
|
||||
|
||||
const context = getContext();
|
||||
if (context.chat?.length > 0) {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
if (saveStateToMessage(tables, lastMessage)) {
|
||||
await saveChat();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveChatDebounced();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export function commitPendingDeletions() {
|
||||
const tables = getState();
|
||||
if (!tables) return false;
|
||||
let deletionCount = 0;
|
||||
|
||||
tables.forEach((table, tableIndex) => {
|
||||
if (!table.rowStatuses || table.rowStatuses.length === 0) return;
|
||||
let tableHadDeletions = false;
|
||||
for (let i = table.rows.length - 1; i >= 0; i--) {
|
||||
if (table.rowStatuses[i] === 'pending-deletion') {
|
||||
table.rows.splice(i, 1);
|
||||
table.rowStatuses.splice(i, 1);
|
||||
deletionCount++;
|
||||
tableHadDeletions = true;
|
||||
}
|
||||
}
|
||||
if (tableHadDeletions) markTableUpdated(tableIndex);
|
||||
});
|
||||
|
||||
if (deletionCount > 0) {
|
||||
log(`已提交并永久删除了 ${deletionCount} 行。`, 'info');
|
||||
const updated = getUpdatedTables();
|
||||
if (updated.size > 0) {
|
||||
updated.forEach(tableIndex => dispatchTableUpdate(tableIndex));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function insertColumn(tableIndex, colIndex, position) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
|
||||
const insertAt = position === 'left' ? colIndex : colIndex + 1;
|
||||
table.headers.splice(insertAt, 0, '新列');
|
||||
table.rows.forEach(row => row.splice(insertAt, 0, ''));
|
||||
if (!table.columnWidths) table.columnWidths = [];
|
||||
table.columnWidths.splice(insertAt, 0, null);
|
||||
|
||||
log(`表格 [${table.name}] 在第 ${colIndex + 1} 列的${position === 'left' ? '左侧' : '右侧'}插入了新列。`, 'info');
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function moveColumn(tableIndex, colIndex, direction) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
const headers = table.headers;
|
||||
const rows = table.rows;
|
||||
|
||||
const targetIndex = direction === 'left' ? colIndex - 1 : colIndex + 1;
|
||||
if (targetIndex < 0 || targetIndex >= headers.length) {
|
||||
log(`无法移动列:索引 ${colIndex} 已在边界。`, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const [headerToMove] = headers.splice(colIndex, 1);
|
||||
headers.splice(targetIndex, 0, headerToMove);
|
||||
|
||||
rows.forEach(row => {
|
||||
const [cellToMove] = row.splice(colIndex, 1);
|
||||
row.splice(targetIndex, 0, cellToMove);
|
||||
});
|
||||
|
||||
if (table.columnWidths && table.columnWidths.length > colIndex) {
|
||||
const [widthToMove] = table.columnWidths.splice(colIndex, 1);
|
||||
table.columnWidths.splice(targetIndex, 0, widthToMove);
|
||||
}
|
||||
|
||||
log(`表格 [${table.name}] 的列“${headerToMove}”已向${direction === 'left' ? '左' : '右'}移动。`, 'info');
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function deleteTable(tableIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const tableName = tables[tableIndex].name;
|
||||
tables.splice(tableIndex, 1);
|
||||
log(`表格 [${tableName}] 已被成功废黜。`, 'success');
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('废黜表格后的状态已强制写入最新消息并立即保存。', 'success');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,删除操作可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function addTable(tableName) {
|
||||
if (!tableName || !tableName.trim()) {
|
||||
log('无法创建表格:名称不能为空。', 'error');
|
||||
toastr.error('表格名称不能为空。', '创建失败');
|
||||
return;
|
||||
}
|
||||
let tables = getState();
|
||||
if (!tables) {
|
||||
loadTables();
|
||||
tables = getState();
|
||||
}
|
||||
|
||||
if (tables.some(table => table.name === tableName.trim())) {
|
||||
log(`无法创建表格:名为 "${tableName}" 的表格已存在。`, 'error');
|
||||
toastr.error(`名为 "${tableName}" 的表格已存在。`, '创建失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const newTable = {
|
||||
name: tableName.trim(),
|
||||
headers: ['新列 1'],
|
||||
rows: [],
|
||||
rowStatuses: [],
|
||||
columnWidths: [],
|
||||
note: '这是一个新创建的表格。',
|
||||
rule_add: '允许',
|
||||
rule_delete: '允许',
|
||||
rule_update: '允许',
|
||||
charLimitRules: {},
|
||||
rowLimitRule: 0,
|
||||
};
|
||||
|
||||
tables.push(newTable);
|
||||
log(`已成功创建新表格:[${tableName.trim()}]。`, 'success');
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('新表格状态已强制写入最新消息并立即保存。', 'success');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,新表格可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function renameTable(tableIndex, newName) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) {
|
||||
log('重命名失败:表格不存在。', 'error');
|
||||
toastr.error('表格不存在。', '重命名失败');
|
||||
return;
|
||||
}
|
||||
const trimmedName = newName.trim();
|
||||
if (!trimmedName) {
|
||||
log('重命名失败:名称不能为空。', 'error');
|
||||
toastr.error('表格名称不能为空。', '重命名失败');
|
||||
return;
|
||||
}
|
||||
if (tables.some((table, index) => index !== tableIndex && table.name === trimmedName)) {
|
||||
log(`重命名失败:名为 "${trimmedName}" 的表格已存在。`, 'error');
|
||||
toastr.error(`名为 "${trimmedName}" 的表格已存在。`, '重命名失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const oldName = tables[tableIndex].name;
|
||||
tables[tableIndex].name = trimmedName;
|
||||
log(`表格 "${oldName}" 已重命名为 "${trimmedName}"。`, 'success');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function moveTable(tableIndex, direction) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
|
||||
const newIndex = direction === 'up' ? tableIndex - 1 : tableIndex + 1;
|
||||
if (newIndex < 0 || newIndex >= tables.length) {
|
||||
log(`无法移动表格:索引 ${tableIndex} 已在边界。`, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const temp = tables[tableIndex];
|
||||
tables[tableIndex] = tables[newIndex];
|
||||
tables[newIndex] = temp;
|
||||
|
||||
log(`表格 [${temp.name}] 的顺序已调整。`, 'success');
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('表格顺序调整后的状态已强制写入最新消息并立即保存。', 'success');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,顺序调整可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTableRules(tableIndex, newRules) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
table.note = newRules.note;
|
||||
table.rule_add = newRules.rule_add;
|
||||
table.rule_delete = newRules.rule_delete;
|
||||
table.rule_update = newRules.rule_update;
|
||||
table.charLimitRules = newRules.charLimitRules;
|
||||
table.rowLimitRule = newRules.rowLimitRule;
|
||||
table.simplifyRowThreshold = newRules.simplifyRowThreshold;
|
||||
|
||||
delete table.charLimitRule;
|
||||
|
||||
log(`表格 [${table.name}] 的规则已更新。`, 'info');
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function updateRow(tableIndex, rowIndex, data) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) {
|
||||
log(`AI指令错误:尝试在不存在的表格索引 ${tableIndex} 中操作。`, 'error');
|
||||
return;
|
||||
}
|
||||
const table = tables[tableIndex];
|
||||
|
||||
if (rowIndex >= table.rows.length) {
|
||||
log(`AI指令意图更新不存在的行 (rowIndex: ${rowIndex}),已智能转换为在表格 [${table.name}] 末尾新增一行。`, 'warn');
|
||||
insertRow(tableIndex, data);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = table.rows[rowIndex];
|
||||
for (const colIndex in data) {
|
||||
const cIndex = parseInt(colIndex, 10);
|
||||
if (cIndex < row.length) {
|
||||
row[cIndex] = data[cIndex];
|
||||
addHighlight(tableIndex, rowIndex, cIndex);
|
||||
}
|
||||
}
|
||||
|
||||
markTableUpdated(tableIndex);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
log(`AI 指令更新了表格 [${table.name}] 的第 ${rowIndex + 1} 行。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function clearAllTables() {
|
||||
const tables = getState();
|
||||
if (!tables) {
|
||||
log('无法清空:当前表格状态为空。', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
tables.forEach((table, tableIndex) => {
|
||||
if (table.rows.length > 0) markTableUpdated(tableIndex);
|
||||
table.rows = [];
|
||||
table.rowStatuses = [];
|
||||
});
|
||||
log('所有表格的行数据已在内存中清空。', 'warn');
|
||||
|
||||
dispatchAllTablesUpdate();
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('清空行数据后的状态已强制写入最新消息并立即保存。', 'success');
|
||||
toastr.success('所有表格的剧情内容已清空。', '操作完成');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,清空操作可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function updateColumnWidth(tableIndex, colIndex, width) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
if (!table.columnWidths) table.columnWidths = [];
|
||||
while (table.columnWidths.length < table.headers.length) {
|
||||
table.columnWidths.push(null);
|
||||
}
|
||||
table.columnWidths[colIndex] = width;
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
51
core/table-system/events-dispatch.js
Normal file
51
core/table-system/events-dispatch.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file events-dispatch.js —— SuperMemory 事件分发(Phase 0.4 自 manager.js 抽出)
|
||||
*
|
||||
* 把单表 / 全表的最新状态推送给 SuperMemory(优先 Bus 直调,降级 CustomEvent)。
|
||||
* 独立成模块的原因:manager.js 与 actions/ui-mutations.js 都需要调用,
|
||||
* 放在任何一方都会制造新的循环依赖;本模块只依赖 store / events-schema / logger,零环。
|
||||
*/
|
||||
|
||||
import { extension_settings } from '/scripts/extensions.js';
|
||||
import { extensionName } from '../../utils/settings.js';
|
||||
import { log } from './logger.js';
|
||||
import { getState } from './infra/store.js';
|
||||
import { createTableUpdateEvent, inferTableRole } from './events-schema.js';
|
||||
|
||||
/**
|
||||
* 把单个表格的最新状态推送给 SuperMemory(优先 Bus 直调,降级 CustomEvent)。
|
||||
* @param {number} tableIndex
|
||||
*/
|
||||
export function dispatchTableUpdate(tableIndex) {
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
if (settings.super_memory_enabled === false) return;
|
||||
|
||||
const state = getState();
|
||||
if (!state || !state[tableIndex]) return;
|
||||
const table = state[tableIndex];
|
||||
const role = inferTableRole(table.name);
|
||||
|
||||
const smBus = window.Amily2Bus?.query('SuperMemory');
|
||||
if (smBus?.pushUpdate) {
|
||||
smBus.pushUpdate({
|
||||
tableName: table.name,
|
||||
data: table.rows,
|
||||
headers: table.headers,
|
||||
rowStatuses: table.rowStatuses ?? [],
|
||||
role,
|
||||
});
|
||||
} else {
|
||||
document.dispatchEvent(createTableUpdateEvent(table));
|
||||
}
|
||||
log(`[SuperMemory] Dispatched update for ${table.name} (role: ${role})`, 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发所有表格的全量同步(Pipeline 变更后调用)。
|
||||
*/
|
||||
export function dispatchAllTablesUpdate() {
|
||||
const state = getState();
|
||||
if (!state) return;
|
||||
log('[SuperMemory] Dispatching update events for ALL tables...', 'info');
|
||||
state.forEach((_, index) => dispatchTableUpdate(index));
|
||||
}
|
||||
@@ -5,16 +5,17 @@
|
||||
* - 状态:infra/store.js (currentTablesState / highlights / updatedTables)
|
||||
* - 持久化:infra/persistence.js (saveStateToMessage / commitToLastMessage)
|
||||
* - 推演:actions/applyOperations.js (executor.js 改造为 legacy formatter)
|
||||
* - UI 突变:actions/ui-mutations.js (addRow / addColumn / ... / clearAllTables,Phase 0.4)
|
||||
* - SuperMemory 分发:events-dispatch.js (dispatchTableUpdate / dispatchAllTablesUpdate)
|
||||
* - 渲染:rendering.js (3 个 toCsv)
|
||||
* - 模板:templates.js
|
||||
* - 预设:preset.js
|
||||
*
|
||||
* 本文件保留:
|
||||
* - 默认表格模板 + getDefaultTables
|
||||
* - SuperMemory 事件分发(dispatchTableUpdate / dispatchAllTablesUpdate / triggerSync)
|
||||
* - triggerSync(SuperMemory 全量同步入口)
|
||||
* - loadTables 的多档回退逻辑
|
||||
* - 16 个 UI 突变(addRow / addColumn / ... / clearAllTables)
|
||||
* - updateTableFromText 编排
|
||||
* - updateTableFromText / updateTableFromOps 编排
|
||||
* - rollbackState / rollbackAndRefill
|
||||
*
|
||||
* 所有原先 export 的接口一律保留兼容(移走的统一 re-export),调用方零改动。
|
||||
@@ -33,7 +34,31 @@ import { applyOperations } from './actions/applyOperations.js';
|
||||
import { fillWithSecondaryApi } from './secondary-filler.js';
|
||||
import { renderTables } from '../../ui/table-bindings.js';
|
||||
import { updateOrInsertTableInChat } from '../../ui/message-table-renderer.js';
|
||||
import { createTableUpdateEvent, inferTableRole } from './events-schema.js';
|
||||
import { dispatchTableUpdate, dispatchAllTablesUpdate } from './events-dispatch.js';
|
||||
|
||||
// ── UI 突变(Phase 0.4 迁至 actions/ui-mutations.js,此处 re-export 保持兼容) ──
|
||||
import { commitPendingDeletions } from './actions/ui-mutations.js';
|
||||
export {
|
||||
deleteColumn,
|
||||
moveRow,
|
||||
insertRow,
|
||||
addRow,
|
||||
addColumn,
|
||||
updateHeader,
|
||||
deleteRow,
|
||||
restoreRow,
|
||||
commitPendingDeletions,
|
||||
insertColumn,
|
||||
moveColumn,
|
||||
deleteTable,
|
||||
addTable,
|
||||
renameTable,
|
||||
moveTable,
|
||||
updateTableRules,
|
||||
updateRow,
|
||||
clearAllTables,
|
||||
updateColumnWidth,
|
||||
} from './actions/ui-mutations.js';
|
||||
|
||||
// ── 新模块(IAD 拆分后的依赖) ────────────────────────────────────────────
|
||||
import {
|
||||
@@ -77,45 +102,7 @@ import {
|
||||
importGlobalPreset as _presetImportGlobalPreset,
|
||||
} from './preset.js';
|
||||
|
||||
// ── 私有:SuperMemory 事件分发 ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 把单个表格的最新状态推送给 SuperMemory(优先 Bus 直调,降级 CustomEvent)。
|
||||
* @param {number} tableIndex
|
||||
*/
|
||||
function dispatchTableUpdate(tableIndex) {
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
if (settings.super_memory_enabled === false) return;
|
||||
|
||||
const state = getState();
|
||||
if (!state || !state[tableIndex]) return;
|
||||
const table = state[tableIndex];
|
||||
const role = inferTableRole(table.name);
|
||||
|
||||
const smBus = window.Amily2Bus?.query('SuperMemory');
|
||||
if (smBus?.pushUpdate) {
|
||||
smBus.pushUpdate({
|
||||
tableName: table.name,
|
||||
data: table.rows,
|
||||
headers: table.headers,
|
||||
rowStatuses: table.rowStatuses ?? [],
|
||||
role,
|
||||
});
|
||||
} else {
|
||||
document.dispatchEvent(createTableUpdateEvent(table));
|
||||
}
|
||||
log(`[SuperMemory] Dispatched update for ${table.name} (role: ${role})`, 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发所有表格的全量同步(Pipeline 变更后调用)。
|
||||
*/
|
||||
function dispatchAllTablesUpdate() {
|
||||
const state = getState();
|
||||
if (!state) return;
|
||||
log('[SuperMemory] Dispatching update events for ALL tables...', 'info');
|
||||
state.forEach((_, index) => dispatchTableUpdate(index));
|
||||
}
|
||||
// ── SuperMemory 同步入口 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 主动触发所有表格同步到 SuperMemory(外部调用入口)。
|
||||
@@ -352,438 +339,6 @@ export function saveTables(sourceAction = '未知操作') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 16 个 UI 突变 ─────────────────────────────────────────────────────────
|
||||
|
||||
export function deleteColumn(tableIndex, colIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex] || colIndex < 0 || colIndex >= tables[tableIndex].headers.length) {
|
||||
log(`删除列失败:在表格 ${tableIndex} 中找不到索引为 ${colIndex} 的列。`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
tables[tableIndex].headers.splice(colIndex, 1);
|
||||
tables[tableIndex].rows.forEach(row => {
|
||||
if (row.length > colIndex) row.splice(colIndex, 1);
|
||||
});
|
||||
if (tables[tableIndex].columnWidths && tables[tableIndex].columnWidths.length > colIndex) {
|
||||
tables[tableIndex].columnWidths.splice(colIndex, 1);
|
||||
}
|
||||
|
||||
log(`成功删除了表格 ${tableIndex} 的第 ${colIndex + 1} 列。`, 'success');
|
||||
saveTables(tables);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export function moveRow(tableIndex, rowIndex, direction) {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table || rowIndex < 0 || rowIndex >= table.rows.length) return;
|
||||
|
||||
const newIndex = direction === 'up' ? rowIndex - 1 : rowIndex + 1;
|
||||
if (newIndex < 0 || newIndex >= table.rows.length) return;
|
||||
|
||||
const [movedRow] = table.rows.splice(rowIndex, 1);
|
||||
table.rows.splice(newIndex, 0, movedRow);
|
||||
|
||||
if (table.rowStatuses && table.rowStatuses.length === table.rows.length + 1) {
|
||||
const [movedStatus] = table.rowStatuses.splice(rowIndex, 1);
|
||||
table.rowStatuses.splice(newIndex, 0, movedStatus);
|
||||
}
|
||||
|
||||
log(`成功将表格 ${tableIndex} 的第 ${rowIndex + 1} 行移动到第 ${newIndex + 1} 行。`, 'success');
|
||||
saveTables(tables);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export function insertRow(tableIndex, data, position = 'below') {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table) {
|
||||
log(`插入行失败:找不到索引为 ${tableIndex} 的表格。`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let insertIndex;
|
||||
if (typeof data === 'number') {
|
||||
insertIndex = position === 'above' ? data : data + 1;
|
||||
} else {
|
||||
insertIndex = table.rows.length;
|
||||
}
|
||||
if (insertIndex < 0) insertIndex = 0;
|
||||
if (insertIndex > table.rows.length) insertIndex = table.rows.length;
|
||||
|
||||
const newRow = new Array(table.headers.length).fill('');
|
||||
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
for (const colIndex in data) {
|
||||
const cIndex = parseInt(colIndex, 10);
|
||||
if (!isNaN(cIndex) && cIndex < newRow.length) {
|
||||
newRow[cIndex] = data[colIndex];
|
||||
addHighlight(tableIndex, insertIndex, cIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.rows.splice(insertIndex, 0, newRow);
|
||||
if (!table.rowStatuses) table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
table.rowStatuses.splice(insertIndex, 0, 'normal');
|
||||
|
||||
markTableUpdated(tableIndex);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
log(`成功在表格 ${table.name} (索引 ${tableIndex}) 的第 ${insertIndex + 1} 行位置插入了新行。`, 'success');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function addRow(tableIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
const colCount = table.headers.length;
|
||||
const newRow = Array(colCount).fill('');
|
||||
table.rows.push(newRow);
|
||||
if (!table.rowStatuses) table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
table.rowStatuses.push('normal');
|
||||
markTableUpdated(tableIndex);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
log(`表格 [${table.name}] 新增了一行。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function addColumn(tableIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
const newHeader = `新列 ${table.headers.length + 1}`;
|
||||
table.headers.push(newHeader);
|
||||
table.rows.forEach(row => row.push(''));
|
||||
if (!table.columnWidths) table.columnWidths = [];
|
||||
table.columnWidths.push(null);
|
||||
log(`表格 [${table.name}] 新增了一列。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function updateHeader(tableIndex, colIndex, value) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex] || tables[tableIndex].headers[colIndex] === undefined) return;
|
||||
const tableName = tables[tableIndex].name;
|
||||
const originalHeader = tables[tableIndex].headers[colIndex];
|
||||
tables[tableIndex].headers[colIndex] = value;
|
||||
log(`表格 [${tableName}] 的表头“${originalHeader}”已更新为“${value}”。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export async function deleteRow(tableIndex, rowIndex) {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table || !table.rows[rowIndex]) return;
|
||||
|
||||
if (!table.rowStatuses) {
|
||||
table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
}
|
||||
|
||||
table.rowStatuses[rowIndex] = 'pending-deletion';
|
||||
markTableUpdated(tableIndex);
|
||||
log(`表格 [${table.name}] 的第 ${rowIndex + 1} 行已标记为待删除。`, 'info');
|
||||
|
||||
const context = getContext();
|
||||
if (context.chat?.length > 0) {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
if (_persistSaveStateToMessage(tables, lastMessage)) {
|
||||
await saveChat();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveChatDebounced();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export async function restoreRow(tableIndex, rowIndex) {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table || !table.rows[rowIndex] || !table.rowStatuses) return;
|
||||
|
||||
table.rowStatuses[rowIndex] = 'normal';
|
||||
markTableUpdated(tableIndex);
|
||||
log(`表格 [${table.name}] 的第 ${rowIndex + 1} 行已恢复。`, 'info');
|
||||
|
||||
const context = getContext();
|
||||
if (context.chat?.length > 0) {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
if (_persistSaveStateToMessage(tables, lastMessage)) {
|
||||
await saveChat();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveChatDebounced();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export function commitPendingDeletions() {
|
||||
const tables = getState();
|
||||
if (!tables) return false;
|
||||
let deletionCount = 0;
|
||||
|
||||
tables.forEach((table, tableIndex) => {
|
||||
if (!table.rowStatuses || table.rowStatuses.length === 0) return;
|
||||
let tableHadDeletions = false;
|
||||
for (let i = table.rows.length - 1; i >= 0; i--) {
|
||||
if (table.rowStatuses[i] === 'pending-deletion') {
|
||||
table.rows.splice(i, 1);
|
||||
table.rowStatuses.splice(i, 1);
|
||||
deletionCount++;
|
||||
tableHadDeletions = true;
|
||||
}
|
||||
}
|
||||
if (tableHadDeletions) markTableUpdated(tableIndex);
|
||||
});
|
||||
|
||||
if (deletionCount > 0) {
|
||||
log(`已提交并永久删除了 ${deletionCount} 行。`, 'info');
|
||||
const updated = _storeGetUpdatedTables();
|
||||
if (updated.size > 0) {
|
||||
updated.forEach(tableIndex => dispatchTableUpdate(tableIndex));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function insertColumn(tableIndex, colIndex, position) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
|
||||
const insertAt = position === 'left' ? colIndex : colIndex + 1;
|
||||
table.headers.splice(insertAt, 0, '新列');
|
||||
table.rows.forEach(row => row.splice(insertAt, 0, ''));
|
||||
if (!table.columnWidths) table.columnWidths = [];
|
||||
table.columnWidths.splice(insertAt, 0, null);
|
||||
|
||||
log(`表格 [${table.name}] 在第 ${colIndex + 1} 列的${position === 'left' ? '左侧' : '右侧'}插入了新列。`, 'info');
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function moveColumn(tableIndex, colIndex, direction) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
const headers = table.headers;
|
||||
const rows = table.rows;
|
||||
|
||||
const targetIndex = direction === 'left' ? colIndex - 1 : colIndex + 1;
|
||||
if (targetIndex < 0 || targetIndex >= headers.length) {
|
||||
log(`无法移动列:索引 ${colIndex} 已在边界。`, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const [headerToMove] = headers.splice(colIndex, 1);
|
||||
headers.splice(targetIndex, 0, headerToMove);
|
||||
|
||||
rows.forEach(row => {
|
||||
const [cellToMove] = row.splice(colIndex, 1);
|
||||
row.splice(targetIndex, 0, cellToMove);
|
||||
});
|
||||
|
||||
if (table.columnWidths && table.columnWidths.length > colIndex) {
|
||||
const [widthToMove] = table.columnWidths.splice(colIndex, 1);
|
||||
table.columnWidths.splice(targetIndex, 0, widthToMove);
|
||||
}
|
||||
|
||||
log(`表格 [${table.name}] 的列“${headerToMove}”已向${direction === 'left' ? '左' : '右'}移动。`, 'info');
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function deleteTable(tableIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const tableName = tables[tableIndex].name;
|
||||
tables.splice(tableIndex, 1);
|
||||
log(`表格 [${tableName}] 已被成功废黜。`, 'success');
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('废黜表格后的状态已强制写入最新消息并立即保存。', 'success');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,删除操作可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function addTable(tableName) {
|
||||
if (!tableName || !tableName.trim()) {
|
||||
log('无法创建表格:名称不能为空。', 'error');
|
||||
toastr.error('表格名称不能为空。', '创建失败');
|
||||
return;
|
||||
}
|
||||
let tables = getState();
|
||||
if (!tables) {
|
||||
loadTables();
|
||||
tables = getState();
|
||||
}
|
||||
|
||||
if (tables.some(table => table.name === tableName.trim())) {
|
||||
log(`无法创建表格:名为 "${tableName}" 的表格已存在。`, 'error');
|
||||
toastr.error(`名为 "${tableName}" 的表格已存在。`, '创建失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const newTable = {
|
||||
name: tableName.trim(),
|
||||
headers: ['新列 1'],
|
||||
rows: [],
|
||||
rowStatuses: [],
|
||||
columnWidths: [],
|
||||
note: '这是一个新创建的表格。',
|
||||
rule_add: '允许',
|
||||
rule_delete: '允许',
|
||||
rule_update: '允许',
|
||||
charLimitRules: {},
|
||||
rowLimitRule: 0,
|
||||
};
|
||||
|
||||
tables.push(newTable);
|
||||
log(`已成功创建新表格:[${tableName.trim()}]。`, 'success');
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('新表格状态已强制写入最新消息并立即保存。', 'success');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,新表格可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function renameTable(tableIndex, newName) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) {
|
||||
log('重命名失败:表格不存在。', 'error');
|
||||
toastr.error('表格不存在。', '重命名失败');
|
||||
return;
|
||||
}
|
||||
const trimmedName = newName.trim();
|
||||
if (!trimmedName) {
|
||||
log('重命名失败:名称不能为空。', 'error');
|
||||
toastr.error('表格名称不能为空。', '重命名失败');
|
||||
return;
|
||||
}
|
||||
if (tables.some((table, index) => index !== tableIndex && table.name === trimmedName)) {
|
||||
log(`重命名失败:名为 "${trimmedName}" 的表格已存在。`, 'error');
|
||||
toastr.error(`名为 "${trimmedName}" 的表格已存在。`, '重命名失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const oldName = tables[tableIndex].name;
|
||||
tables[tableIndex].name = trimmedName;
|
||||
log(`表格 "${oldName}" 已重命名为 "${trimmedName}"。`, 'success');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function moveTable(tableIndex, direction) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
|
||||
const newIndex = direction === 'up' ? tableIndex - 1 : tableIndex + 1;
|
||||
if (newIndex < 0 || newIndex >= tables.length) {
|
||||
log(`无法移动表格:索引 ${tableIndex} 已在边界。`, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const temp = tables[tableIndex];
|
||||
tables[tableIndex] = tables[newIndex];
|
||||
tables[newIndex] = temp;
|
||||
|
||||
log(`表格 [${temp.name}] 的顺序已调整。`, 'success');
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('表格顺序调整后的状态已强制写入最新消息并立即保存。', 'success');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,顺序调整可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTableRules(tableIndex, newRules) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
table.note = newRules.note;
|
||||
table.rule_add = newRules.rule_add;
|
||||
table.rule_delete = newRules.rule_delete;
|
||||
table.rule_update = newRules.rule_update;
|
||||
table.charLimitRules = newRules.charLimitRules;
|
||||
table.rowLimitRule = newRules.rowLimitRule;
|
||||
table.simplifyRowThreshold = newRules.simplifyRowThreshold;
|
||||
|
||||
delete table.charLimitRule;
|
||||
|
||||
log(`表格 [${table.name}] 的规则已更新。`, 'info');
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function updateRow(tableIndex, rowIndex, data) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) {
|
||||
log(`AI指令错误:尝试在不存在的表格索引 ${tableIndex} 中操作。`, 'error');
|
||||
return;
|
||||
}
|
||||
const table = tables[tableIndex];
|
||||
|
||||
if (rowIndex >= table.rows.length) {
|
||||
log(`AI指令意图更新不存在的行 (rowIndex: ${rowIndex}),已智能转换为在表格 [${table.name}] 末尾新增一行。`, 'warn');
|
||||
insertRow(tableIndex, data);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = table.rows[rowIndex];
|
||||
for (const colIndex in data) {
|
||||
const cIndex = parseInt(colIndex, 10);
|
||||
if (cIndex < row.length) {
|
||||
row[cIndex] = data[cIndex];
|
||||
addHighlight(tableIndex, rowIndex, cIndex);
|
||||
}
|
||||
}
|
||||
|
||||
markTableUpdated(tableIndex);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
log(`AI 指令更新了表格 [${table.name}] 的第 ${rowIndex + 1} 行。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function clearAllTables() {
|
||||
const tables = getState();
|
||||
if (!tables) {
|
||||
log('无法清空:当前表格状态为空。', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
tables.forEach((table, tableIndex) => {
|
||||
if (table.rows.length > 0) markTableUpdated(tableIndex);
|
||||
table.rows = [];
|
||||
table.rowStatuses = [];
|
||||
});
|
||||
log('所有表格的行数据已在内存中清空。', 'warn');
|
||||
|
||||
dispatchAllTablesUpdate();
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('清空行数据后的状态已强制写入最新消息并立即保存。', 'success');
|
||||
toastr.success('所有表格的剧情内容已清空。', '操作完成');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,清空操作可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染 wrapper(注入当前 state) ────────────────────────────────────────
|
||||
|
||||
export function convertTablesToCsvString() {
|
||||
@@ -1031,19 +586,6 @@ export async function rollbackAndRefill() {
|
||||
|
||||
// ── 杂项 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function updateColumnWidth(tableIndex, colIndex, width) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
if (!table.columnWidths) table.columnWidths = [];
|
||||
while (table.columnWidths.length < table.headers.length) {
|
||||
table.columnWidths.push(null);
|
||||
}
|
||||
table.columnWidths[colIndex] = width;
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function isCurrentTablesEmpty() {
|
||||
const tables = getState();
|
||||
if (!tables || tables.length === 0) return true;
|
||||
|
||||
Reference in New Issue
Block a user