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:
Jenkins CI
2026-06-13 01:02:05 +08:00
parent 1a4a10d42d
commit 0d7e3b799e
35 changed files with 1981 additions and 1034 deletions

View File

@@ -0,0 +1,54 @@
/**
* core/memory-blocks/ai-call-handler.js — 'ai_call' generator handlerPhase 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 {};

View File

@@ -0,0 +1,97 @@
/**
* core/memory-blocks/chain.js
*
* 顺序拼接式工作流:把 context 下所有启用块的结果,按 block.order 排序后用 separator
* 拼接,并可选 header/footer 包裹,最终输出一个"完整的注入块"字符串。
*
* 与 executor.js模板替换式并列两种组合范式
* - executor: 模板里挖空 placeholder块负责填料 → 替换式
* - chain: 无模板,块各自产出一段文本 → 顺序拼成一整段
*
* 战斗系统设计稿§3.2)里的"战报作底部独立注入块"、未来的"记忆注入合成块"
* 都是 chain 模式的天然用例:战斗模块只需声明一个 BlockDefinitionorder 取大
* 就自动落在拼接末尾。
*
* ── 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));
}

View File

@@ -0,0 +1,102 @@
/**
* core/memory-blocks/custom-blocks.js — 用户自定义块的持久化Phase 2
*
* 自定义块以纯 JSONBlockDefinition 数组)存于
* 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;
}
/** 修改自定义块(浅合并 patchid/非 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;
}

View File

@@ -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);

View File

@@ -3,19 +3,39 @@
*
* 记忆块工作流系统对外入口。导入此模块即触发:
* 1. generator-handlers 加载 → 注册内置 'static' handler
* 2. registerBuiltinBlocks() → 注册首批内置块sulv1-4
* 2. ai-call-handler 加载 → 注册 'ai_call' handlerPhase 2
* 3. registerBuiltinBlocks() → 注册首批内置块sulv1-4
* 4. syncCustomBlocksFromSettings() → 重放用户自定义块Phase 2
*
* 两种组合范式:
* - 模板替换式executor.jsprompt 里挖空 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}>(调试/自定义后处理)
* 自定义块 CRUDPhase 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 };

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

View File

@@ -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

View File

@@ -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);

View File

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

View File

@@ -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',

View File

@@ -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,

View File

@@ -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') {

View File

@@ -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_datainfra/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] 全量同步完成。');
}

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

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

View File

@@ -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 / ... / clearAllTablesPhase 0.4)
* - SuperMemory 分发events-dispatch.js (dispatchTableUpdate / dispatchAllTablesUpdate)
* - 渲染rendering.js (3 个 toCsv)
* - 模板templates.js
* - 预设preset.js
*
* 本文件保留:
* - 默认表格模板 + getDefaultTables
* - SuperMemory 事件分发dispatchTableUpdate / dispatchAllTablesUpdate / triggerSync
* - triggerSyncSuperMemory 全量同步入口
* - 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;