13 Commits

Author SHA1 Message Date
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
57 changed files with 3318 additions and 1524 deletions

View File

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

View File

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

View File

@@ -26,3 +26,127 @@
- NGMS / NCCS API 配置槽位标签修正NGMS→总结NCCS→填表 - NGMS / NCCS API 配置槽位标签修正NGMS→总结NCCS→填表
- API Profile 面板选择逻辑统一重构,修复多处旧字段覆盖新配置的问题 - API Profile 面板选择逻辑统一重构,修复多处旧字段覆盖新配置的问题
- 世界书控制参数兼容性修复(排除递归、插入位置、扫描深度等,适配 ST 1.17.0+ - 世界书控制参数兼容性修复(排除递归、插入位置、扫描深度等,适配 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.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. 总览与核心开关 ### 1. 总览与核心开关
这里是翰林院的仪表面板,展示了核心状态并提供了最高权限的操作。 这里是翰林院的仪表面板,展示了核心状态并提供了最高权限的操作。

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

View File

@@ -7,252 +7,97 @@
> - [TableTODO.md](TableTODO.md) — 表格模块 IAD 深度重构计划Phase 0/B/C > - [TableTODO.md](TableTODO.md) — 表格模块 IAD 深度重构计划Phase 0/B/C
> - [TODO.md](TODO.md) — 旧版本变更日志(保留作为发布记录) > - [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。
| commit | 内容 | 涉及范围 | | commit | 内容 | 涉及范围 |
|--------|------|--------| |--------|------|--------|
| `d283ff4` | 表格模块 IAD 解耦 + API 自定义参数 + 厂商预设连接 | `core/table-system/*` 新增 dto/infra/actions`assets/api-vendor-params.json`UI | | `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` |
| `671c1b2` | profile 优先级修正profile 分配后即权威,旧字段不再覆盖 | `core/api.js` 6 处 `getApiSettings` | | `8b4b6b0` | 二级填表死锁修复 + 强制中断按钮AbortController 贯穿) | `secondary-filler.js` |
| `68217ff` | legacy 自动迁移 + 清除按钮 + tableFilling slot + silent fallback 移除 | `ApiProfileManager.js` / `historiographer.js` / 表格 3 filler | | `dc57a1d` | memory-blocks Phase 1占位符工作流抽象层sulv1-4 迁入 | `core/memory-blocks/*``summarizer.js` |
| `b40f575` | bump 2.2.0 + tableFilling 默认 link main | `manifest.json` / `ApiProfileManager.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 - **状态权威**`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.jsmanager.js 退化为兼容层(仍保留 16 个 UI mutation + loadTables + updateTableFromText - **表格模块**:核心在 [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 兼容
- **API 厂商识别**[utils/api-vendor.js](utils/api-vendor.js) 提供 detectVendor / listVendorParamsregistry 在 [assets/api-vendor-params.json](assets/api-vendor-params.json) - **memory-blocks**[core/memory-blocks/](core/memory-blocks/) 占位符驱动工作流static + ai_call 两种 handler自定义块 UI 在剧情优化面板Phase 3JSON 导入导出 / 战斗系统 plugin handler未做
- **VS Code 类型校验**[jsconfig.json](jsconfig.json) 已开启 checkJs[types/sillytavern.d.ts](types/sillytavern.d.ts) 提供 SillyTavern 全局模块声明 - **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 核对)
### 任务卡格式说明 | ID | 内容 | 状态 |
|----|------|------|
每个任务包含: | T-001 | 死代码清理 | ✅ 3 处死绑定已删。**例外**`core/fractal-memory.js` 刻意保留——非本人设计,原作者未弃坑,留作坑位,勿删 |
- **类型**bug / feature / refactor / cleanup / docs | T-002 | cwb / autoCharCard legacy 自动迁移 | ✅ `6ad1354`。cwb 实际字段为 snake_case`cwb_api_url`autoCharCard 双角色嵌套对象,仅 planner 空/同 executor 时自动迁 |
- **难度**:🟢 简单(< 1h 🟡 中等1-3h 🔴 高耦合(> 3h 或需架构判断) | T-003 | NCCS 等支路透传 customParams | ✅ Nccs / Ngms / Jqyh / Sybd 四个 API 文件均已接入 |
- **建议执行者**`GPT` / `Claude` / `Human` / `任意` | T-004 | hint panel 点击参数名插入 | ✅ `.amily2_param_hint_btn` + `_insertParamToCustomParams` |
- **文件**:明确路径 + 行号锚点(若适用) | T-005 | 散乱 vendor URL 检查迁 detectVendor | ✅ `f7781c2` 收尾。保留项:`_detectVendorFromUrlSync`(迁移 IIFE 自包含、RequestBody.js 兜底(即目标模式) |
- **修改要点**bullet 列表 | 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 解 |
- **依赖**:前置任务的 ID若有
--- ---
### 🟢 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 - **建议执行者**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` **预估**0.5 天
- `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 总结 / 表格填表)确认无回归
--- ---
#### T-006: jqyh/sybd/cwb 在 profile 已分配时把 slider 改成 informational #### T-012: URL / Key / 模型输入框的 profile 压制提示T-006 续)
- **类型**feature / UX - **类型**feature / UX
- **难度**:🟡 中等 - **难度**:🟡 中等
- **建议执行者**GPT 或 Claude - **建议执行者**GPT 或 Claude
- **依赖**:无 - **依赖**:无(复用 [ui/profile-slider-guard.js](ui/profile-slider-guard.js)
**背景**v2.2.0 之后profile 一旦分配就权威jqyh/sybd/cwb 这些有 slider 的模块在 profile 分配后 slider 是无效的(用户改 slider 不影响请求)。这是用户陷阱 **背景**T-006 只覆盖了参数滑条。各模块面板的 API URL / Key / 模型输入框在 profile 分配后同样失效,且涉及「测试连接 / 拉取模型」按钮的联动判断(这些按钮读的是 profile 还是 DOM 因模块而异),需逐面板核对后接入 `watchProfileSliderGuard`
**修改要点**
每个有 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或者轮询
**验收** **验收**
- [ ] 给 plotOpt 分配 profile 后,剧情优化面板的温度/maxTokens slider 变灰 + 提示 - [ ] profile 分配后各面板 URL/Key/模型输入框 disable + 提示
- [ ] 取消分配后 slider 重新可用 - [ ] 测试连接按钮行为与提示一致(测的是 profile 配置就保持可用)
- [ ] 其他模块同样行为
--- ---
#### T-007: 表格 Phase 0.4 — 抽出 mutations.js #### T-013: 剧情优化面板 top_p / presence / frequency 输入为死配置
- **类型**refactor - **类型**bug / cleanup
- **难度**:🟡 中等 - **难度**:🟡 中等(需决策)
- **建议执行者**Claude涉及 IAD 一致性判断) - **建议执行者**Human 决策 + 任意执行
- **依赖**:无 - **依赖**:无
**背景**[TableTODO.md#四-phase-0](TableTODO.md) 计划的 Phase 0.4。manager.js 还有 16 个 UI 突变函数addRow / deleteColumn / renameTable 等),应抽到 `core/table-system/actions/ui-mutations.js`。 **背景**`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 列表中)
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 失败
--- ---
@@ -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 #### T-010: 表格 Phase C — ToolCall formatter
- **类型**feature - **类型**feature
@@ -312,45 +138,52 @@
- **类型**refactor - **类型**refactor
- **难度**:🔴 高filler 三方差异需小心对齐 / 解循环依赖 / Service 重写) - **难度**:🔴 高filler 三方差异需小心对齐 / 解循环依赖 / Service 重写)
- **建议执行者**Claude - **建议执行者**Claude
- **依赖**T-007Phase 0.4 mutations 完成后做) - **依赖**T-007 已完成 ✅,可随时开工
**详见**[TableTODO.md#四-phase-0](TableTODO.md) 0.7-0.9 **详见**[TableTODO.md#四-phase-0](TableTODO.md) 0.7-0.9
- 0.7: `core/table-system/filler/shared.js` —— 三个 filler 重复代码消除 - 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 真正变成门面 - 0.9: TableSystemService 真正变成门面
**预估**1 天 **预估**1 天
--- ---
## 三、派工建议 #### T-014: memory-blocks Phase 3
### 适合现在直接派给 GPT独立、无架构判断 - **类型**feature
- **难度**:🟡 中等
- **建议执行者**Claude
- **依赖**Phase 2 已完成 ✅(`91ceecc`
- ✅ T-001 死代码清理 **核心交付**
- ✅ T-002 cwb/autoCharCard 加入迁移 - 自定义块 JSON 导入导出(`replaceContextBlocks` 已就位)
- ✅ T-003 NCCS 透传 customParams - 战斗系统通过 `plugin` handler 接入types.js 契约已预留)
- ✅ T-004 hint panel 点击插入 - summarizer 链路补 AbortController让 ai_call 块可中断handler 的 signal 透传已就位)
### 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 风险高)
--- ---
## 四、未列入但可能的小项 ## 四、派工建议
### 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 - 自动迁移完成后给所有 chat 类型 slot 加默认 link 选项(不只 tableFilling
- profile 分配 UI 加"复用现有 profile"快捷按钮(避免用户为每个 slot 重复创建相同配置) - profile 分配 UI 加"复用现有 profile"快捷按钮(避免用户为每个 slot 重复创建相同配置)
- 51TODO.md 第三节决策点中"是否合并发版"等问题做最终决定记录 - 51TODO.md 第三节决策点中"是否合并发版"等问题做最终决定记录
- TODO.md旧版本变更日志的 v2.2.0 版本条目补全 - TODO.md旧版本变更日志的 v2.2.x 版本条目补全

View File

@@ -36,47 +36,12 @@
<!-- API Settings Tab --> <!-- API Settings Tab -->
<div id="sinan-api-settings-tab" class="sinan-tab-pane active"> <div id="sinan-api-settings-tab" class="sinan-tab-pane active">
<fieldset class="settings-group"> <fieldset class="settings-group">
<legend>Jqyh API</legend> <legend>剧情优化 API</legend>
<div class="control-block-with-switch"> <p class="notes" style="margin: 0;">
<label for="amily2_jqyh_enabled"><strong>启用 Jqyh API</strong></label> 剧情优化所用的连接配置统一在
<label class="toggle-switch"> <strong>API 连接配置 → 功能分配 → 剧情优化 / JQYH</strong>
<input id="amily2_jqyh_enabled" type="checkbox" /> 中指定,无需在此单独填写。
<span class="slider"></span> </p>
</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>
</fieldset> </fieldset>
<fieldset class="settings-group"> <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"> <input id="amily2_opt_rate_cuckold" type="number" class="text_pole" step="0.05" value="1.0">
</div> </div>
</fieldset> </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> </div>
<!-- Context Settings Tab --> <!-- Context Settings Tab -->

View File

@@ -257,6 +257,21 @@
<input type="number" id="secondary-filler-delay" min="0" max="60000" step="100" value="0" class="text_pole" style="width: 80px; margin-top: 5px;"> <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> <small class="notes" style="margin-top: 5px; display: block;">收到新消息后延迟多少毫秒再触发分步填表 (0 = 立即触发);延迟期内若再次收到消息会重置计时,起到防抖作用。</small>
</div> </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>
<div class="control-block-with-switch" style="margin-bottom: 10px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px;"> <div class="control-block-with-switch" style="margin-bottom: 10px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px;">
@@ -304,7 +319,10 @@
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </div>
<p class="notes" style="margin-bottom: 10px;">仅支持 openai 直连接口tableFilling 槽位)。启用后跳过 &lt;Amily2Edit&gt; 文本解析,由模型直接返回操作列表。</p> <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;"> <hr class="section-divider" style="margin: 10px 0;">

View File

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

View File

@@ -237,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_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> <button id="amily2_open_auto_char_card" class="menu_button wide_button"><i class="fas fa-robot"></i> 一键生卡</button>
</div> </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>
<fieldset class="settings-group"> <fieldset class="settings-group">

View File

@@ -485,8 +485,7 @@ export async function getApiSettings(slot = 'main') {
apiProvider: apiMode, apiProvider: apiMode,
apiUrl: settings.plotOpt_apiUrl?.trim() || '', apiUrl: settings.plotOpt_apiUrl?.trim() || '',
apiKey: configManager.get('plotOpt_apiKey') || '', apiKey: configManager.get('plotOpt_apiKey') || '',
model: document.getElementById('amily2_opt_model')?.value?.trim() model: settings.plotOpt_model || '',
|| settings.plotOpt_model || '',
maxTokens: settings.plotOpt_max_tokens ?? 65500, maxTokens: settings.plotOpt_max_tokens ?? 65500,
temperature: settings.plotOpt_temperature ?? 1.0, temperature: settings.plotOpt_temperature ?? 1.0,
tavernProfile: '', tavernProfile: '',
@@ -589,6 +588,7 @@ export async function callAI(messages, options = {}) {
apiKey: apiSettings.apiKey, apiKey: apiSettings.apiKey,
apiProvider: apiSettings.apiProvider, apiProvider: apiSettings.apiProvider,
customParams: apiSettings.customParams ?? {}, customParams: apiSettings.customParams ?? {},
signal: options.signal,
...options, ...options,
// options 可显式覆盖 customParams体现"代码内显式 > profile 配置" // options 可显式覆盖 customParams体现"代码内显式 > profile 配置"
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) }, customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
@@ -649,6 +649,10 @@ export async function callAI(messages, options = {}) {
return responseContent; return responseContent;
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') {
console.warn('[Amily2-外交部] API 调用被用户中断。');
throw error; // 让上层(如 secondary-filler识别并跳过结果处理
}
console.error(`[Amily2-外交部] API调用发生错误:`, error); console.error(`[Amily2-外交部] API调用发生错误:`, error);
if (error.message.includes('400')) { if (error.message.includes('400')) {
@@ -664,7 +668,7 @@ export async function callAI(messages, options = {}) {
} else { } else {
toastr.error(`API调用失败: ${error.message}`, "API调用失败"); toastr.error(`API调用失败: ${error.message}`, "API调用失败");
} }
return null; return null;
} }
} }
@@ -691,7 +695,8 @@ async function callOpenAICompatible(messages, options) {
max_tokens: options.maxTokens, max_tokens: options.maxTokens,
temperature: options.temperature, temperature: options.temperature,
stream: false, stream: false,
}) }),
signal: options.signal,
}); });
if (!response.ok) { if (!response.ok) {
@@ -733,7 +738,8 @@ async function callOpenAITest(messages, options) {
const response = await fetch('/api/backends/chat-completions/generate', { const response = await fetch('/api/backends/chat-completions/generate', {
method: 'POST', method: 'POST',
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' }, headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body) body: JSON.stringify(body),
signal: options.signal,
}); });
if (!response.ok) { if (!response.ok) {
@@ -775,10 +781,11 @@ async function callGoogleDirect(messages, options) {
temperature: options.temperature temperature: options.temperature
})); }));
const response = await fetch(finalApiUrl, { const response = await fetch(finalApiUrl, {
method: "POST", method: "POST",
headers: headers, headers: headers,
body: requestBody body: requestBody,
signal: options.signal,
}); });
if (!response.ok) { if (!response.ok) {
@@ -823,11 +830,10 @@ async function callGoogleDirect(messages, options) {
async function callSillyTavernBackend(messages, options) { async function callSillyTavernBackend(messages, options) {
console.log('[Amily2号-ST后端] 通过SillyTavern后端调用API'); console.log('[Amily2号-ST后端] 通过SillyTavern后端调用API');
const rawResponse = await $.ajax({ const response = await fetch('/api/backends/chat-completions/generate', {
url: '/api/backends/chat-completions/generate', method: 'POST',
type: 'POST', headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
contentType: 'application/json', body: JSON.stringify({
data: JSON.stringify({
// 用户 customParams可被核心字段覆盖 // 用户 customParams可被核心字段覆盖
...(options.customParams || {}), ...(options.customParams || {}),
// 表单托管字段总是 win // 表单托管字段总是 win
@@ -839,9 +845,16 @@ async function callSillyTavernBackend(messages, options) {
max_tokens: options.maxTokens, max_tokens: options.maxTokens,
temperature: options.temperature, temperature: options.temperature,
stream: false, 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); const result = normalizeApiResponse(rawResponse);
if (result.error) { if (result.error) {
throw new Error(result.error.message || 'SillyTavern后端API调用失败'); 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) { async function callSillyTavernPreset(messages, options) {
console.log('[Amily2号-ST预设] 使用SillyTavern预设调用'); 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) { if (!result) {
throw new Error('未收到API响应'); throw new Error('未收到API响应');
@@ -970,6 +1005,7 @@ export async function callAIForTools(messages, tool, options = {}) {
apiKey: apiSettings.apiKey, apiKey: apiSettings.apiKey,
apiProvider: apiSettings.apiProvider, apiProvider: apiSettings.apiProvider,
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) }, customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
signal: options.signal,
...options, ...options,
}; };
@@ -986,7 +1022,11 @@ export async function callAIForTools(messages, tool, options = {}) {
return null; return null;
} }
const buildFCBody = (withToolChoice, overrideMessages) => ({ // 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', chat_completion_source: 'openai',
reverse_proxy: finalOptions.apiUrl, reverse_proxy: finalOptions.apiUrl,
proxy_password: finalOptions.apiKey, proxy_password: finalOptions.apiKey,
@@ -996,15 +1036,17 @@ export async function callAIForTools(messages, tool, options = {}) {
temperature: finalOptions.temperature ?? 1, temperature: finalOptions.temperature ?? 1,
stream: false, stream: false,
...(finalOptions.customParams || {}), ...(finalOptions.customParams || {}),
...extraParams,
tools: [tool], tools: [tool],
...(withToolChoice ? { tool_choice: { type: 'function', function: { name: tool.function.name } } } : {}), ...(withToolChoice ? { tool_choice: { type: 'function', function: { name: tool.function.name } } } : {}),
}); });
const doFCRequest = async (withToolChoice, overrideMessages) => { const doFCRequest = async (withToolChoice, overrideMessages, extraParams) => {
const response = await fetch('/api/backends/chat-completions/generate', { const response = await fetch('/api/backends/chat-completions/generate', {
method: 'POST', method: 'POST',
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' }, headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(buildFCBody(withToolChoice, overrideMessages)), body: JSON.stringify(buildFCBody(withToolChoice, overrideMessages, extraParams)),
signal: finalOptions.signal,
}); });
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
@@ -1027,8 +1069,12 @@ export async function callAIForTools(messages, tool, options = {}) {
let data; let data;
try { try {
// 走 ST 后端代理,避免浏览器 CSP 拦截直连外部 URL // 走 ST 后端代理,避免浏览器 CSP 拦截直连外部 URL
data = await doFCRequest(true); // 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) { } catch (firstError) {
if (firstError?.name === 'AbortError') throw firstError; // 用户中断,不要重试
// 首次失败(含 ST 代理吞掉错误码场景)无条件去掉 tool_choice 重试一次 // 首次失败(含 ST 代理吞掉错误码场景)无条件去掉 tool_choice 重试一次
// 思考模式模型支持 tools 但不支持强制 tool_choice追加强制指令防止模型直接输出文本 // 思考模式模型支持 tools 但不支持强制 tool_choice追加强制指令防止模型直接输出文本
console.warn('[Amily2-外交部] 首次 FC 请求失败,去掉 tool_choice 重试…', firstError.message); console.warn('[Amily2-外交部] 首次 FC 请求失败,去掉 tool_choice 重试…', firstError.message);
@@ -1052,6 +1098,10 @@ export async function callAIForTools(messages, tool, options = {}) {
return argsString ?? null; return argsString ?? null;
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') {
console.warn('[Amily2-外交部] Function Call 调用被用户中断。');
throw error;
}
console.error('[Amily2-外交部] Function Call 调用失败:', error); console.error('[Amily2-外交部] Function Call 调用失败:', error);
toastr.error(`Function Call 调用失败: ${error.message}`, 'Amily2-外交部'); toastr.error(`Function Call 调用失败: ${error.message}`, 'Amily2-外交部');
return null; return null;

View File

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

View File

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

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 MODULE_NAME = 'hanlinyuan-rag-core';
const GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com'; 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() { function getSettings() {
const root = extension_settings[extensionName]; const root = extension_settings[extensionName];
const nested = root && root[MODULE_NAME]; const nested = root && root[MODULE_NAME];
@@ -32,15 +40,31 @@ function getSettings() {
export async function getEmbedRetrievalSettings() { export async function getEmbedRetrievalSettings() {
const profile = await getSlotProfile('ragEmbed'); const profile = await getSlotProfile('ragEmbed');
if (profile) { if (profile) {
const apiKey = sanitizeMaskedKey(profile.apiKey ?? '');
return { return {
apiEndpoint: profile.provider === 'google' ? 'google_direct' : 'custom', apiEndpoint: profile.provider === 'google' ? 'google_direct' : 'custom',
customApiUrl: profile.apiUrl, customApiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '', apiKey,
embeddingModel: profile.model, embeddingModel: profile.model,
batchSize: getSettings().retrieval?.batchSize ?? 5, 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() { export async function getRerankSettings() {
const profile = await getSlotProfile('ragRerank'); const profile = await getSlotProfile('ragRerank');
if (profile) { if (profile) {
const manualSettings = getSettings().rerank || {};
const apiKey = sanitizeMaskedKey(profile.apiKey ?? '');
return { return {
url: profile.apiUrl, url: profile.apiUrl,
apiKey: profile.apiKey ?? '', apiKey,
model: profile.model, model: profile.model,
top_n: getSettings().rerank?.top_n ?? 10, top_n: manualSettings.top_n ?? 10,
apiMode: 'custom', 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) { function normalizeApiResponse(responseData) {
@@ -109,8 +138,8 @@ export async function fetchEmbeddingModels(overrideSettings = null) {
switch (apiEndpoint) { switch (apiEndpoint) {
case 'google_direct': 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 fetchGoogleModels = async (version) => {
const url = `${GOOGLE_API_BASE_URL}/${version}/models`; const url = `${GOOGLE_API_BASE_URL}/${version}/models`;
console.log(`[翰林院] 正在从 Google API (${version}) 获取模型列表: ${url}`); console.log(`[翰林院] 正在从 Google API (${version}) 获取模型列表: ${url}`);
@@ -214,7 +243,7 @@ export async function fetchRerankModels() {
throw new Error("Rerank API URL 未提供。"); throw new Error("Rerank API URL 未提供。");
} }
if (apiMode === 'custom' && !apiKey) { if (apiMode === 'custom' && !apiKey) {
throw new Error("自定义模式下Rerank API Key 未提供。"); throw new Error(describeMissingKey(settings, "自定义模式下Rerank API Key 未提供。"));
} }
const baseUrl = getRerankBaseUrl(url); 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; const { url, apiKey, model, top_n, apiMode = 'custom' } = resolved;
if (!url) throw new Error("Rerank API URL 未提供。"); 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 baseUrl = getRerankBaseUrl(url);
const rerankUrl = `${baseUrl}/v1/rerank`; const rerankUrl = `${baseUrl}/v1/rerank`;
@@ -298,10 +327,12 @@ export function getApiEndpointUrl(raw = false, overrideRetrieval = null) {
break; break;
case 'azure': case 'azure':
case 'custom': case 'custom':
case 'local_proxy': // 本地代理LM Studio/Ollama同样使用用户填写的地址此前漏掉落入 default 被错误定向到 openai.com
url = customApiUrl; url = customApiUrl;
break; break;
default: default:
url = 'https://api.openai.com'; // apiEndpoint 为空/非法(历史 profile-sync 污染customApiUrl 比硬编码 openai.com 更可能是用户真实意图
url = customApiUrl || 'https://api.openai.com';
break; break;
} }
if (raw) { if (raw) {
@@ -345,7 +376,7 @@ export async function getEmbeddings(texts, signal = null) {
switch (apiEndpoint) { switch (apiEndpoint) {
case 'google_direct': case 'google_direct':
console.log('[翰林院-API] 使用Google直连模式获取向量。'); 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 泄露 // 使用适配器构建URL和请求体Key 通过 x-goog-api-key 头传递避免 URL 泄露
const googleUrl = buildGoogleEmbeddingApiUrl(GOOGLE_API_BASE_URL, embeddingModel); const googleUrl = buildGoogleEmbeddingApiUrl(GOOGLE_API_BASE_URL, embeddingModel);

View File

@@ -21,6 +21,7 @@ import {
fetchEmbeddingModels as apiFetchEmbeddingModels, fetchEmbeddingModels as apiFetchEmbeddingModels,
fetchRerankModels as apiFetchRerankModels, fetchRerankModels as apiFetchRerankModels,
executeRerank, executeRerank,
getRerankSettings,
testApiConnection as apiTestApiConnection testApiConnection as apiTestApiConnection
} from './rag-api.js'; } from './rag-api.js';
import { superSort } from './super-sorter.js'; import { superSort } from './super-sorter.js';
@@ -152,6 +153,7 @@ function initialize() {
return; return;
} }
migrateLegacyRagSettings(); migrateLegacyRagSettings();
sanitizeProfilePollution();
settings = getSettings(); settings = getSettings();
if (!window.hanlinyuanRagProcessor) { if (!window.hanlinyuanRagProcessor) {
window.hanlinyuanRagProcessor = {}; window.hanlinyuanRagProcessor = {};
@@ -219,20 +221,27 @@ async function ingestTextToHanlinyuan(text, source = 'manual', metadata = {}, pr
break; break;
} }
// 独立聊天记忆模式:聊天记录类向量按聊天分桶(剧情线隔离),
// 其余来源(小说/世界书/手动)属于"知识",仍随角色卡共享
const independentChatId = (source === 'chat_history' && settings.retrieval.independentChatMemoryEnabled)
? getChatId()
: null;
const existingKbs = Object.values(getKnowledgeBases()); 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) { if (foundKb) {
taskId = foundKb.id; taskId = foundKb.id;
logCallback(`[翰林院-核心] 检测到同名知识库 "${kbName}",将数据合并入库。`, 'info'); logCallback(`[翰林院-核心] 检测到同名知识库 "${kbName}",将数据合并入库。`, 'info');
} else { } else {
logCallback(`[翰林院-核心] 准备为任务 "${kbName}" 创建专属知识库...`, 'info'); logCallback(`[翰林院-核心] 准备为任务 "${kbName}" 创建专属知识库...`, 'info');
const newKb = addKnowledgeBase(kbName, source); const newKb = addKnowledgeBase(kbName, source, independentChatId);
taskId = newKb.id; taskId = newKb.id;
} }
const charId = getCharacterStableId(); const charId = getCharacterStableId();
const collectionId = `${charId}_${taskId}`; const collectionId = independentChatId ? `${independentChatId}_${taskId}` : `${charId}_${taskId}`;
logCallback(`[翰林院-核心] 已创建并锁定知识库: ${kbName} (集合ID: ${collectionId})`, 'success'); logCallback(`[翰林院-核心] 已创建并锁定知识库: ${kbName} (集合ID: ${collectionId})`, 'success');
logCallback(`[翰林院-核心] 已锁定忆识宝库ID: ${collectionId}`, 'info'); 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; return s;
} }
@@ -391,6 +419,49 @@ function migrateLegacyRagSettings() {
saveSettingsDebounced(); 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') { function showNotification(message, type = 'info') {
toastr[type](message); 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 = {}) { function splitIntoChunks(text, source, metadata = {}) {
switch (source) { switch (source) {
case 'novel': case 'novel':
@@ -446,30 +582,22 @@ function _chunkForNovel(text, metadata) {
function processBuffer() { function processBuffer() {
if (contentBuffer.length === 0) return; if (contentBuffer.length === 0) return;
const content = contentBuffer.join('\n'); const content = contentBuffer.join('\n');
let start = 0; const tagName = getTagForSource('novel');
let section = 1; splitBySemanticBoundary(content, chunkSize, overlap).forEach((chunkText, idx) => {
while (start < content.length) { const section = idx + 1;
const end = Math.min(start + chunkSize, content.length); const chunkMetadata = {
const chunkText = content.substring(start, end); source: 'novel',
if (chunkText.trim().length > 0) { sourceName: sourceName,
const chunkMetadata = { timestamp: new Date().toISOString(),
source: 'novel', globalIndex: globalChunkIndex++,
sourceName: sourceName, volume: currentVolumeTitle,
timestamp: new Date().toISOString(), chapter: currentChapterTitle,
globalIndex: globalChunkIndex++, section: section,
volume: currentVolumeTitle, };
chapter: currentChapterTitle, const prefix = `[来源: ${sourceName}, ${currentVolumeTitle}, ${currentChapterTitle}, 第${section}节]`;
section: section, const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
}; allChunks.push({ text: wrappedText, metadata: chunkMetadata });
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;
}
contentBuffer = []; contentBuffer = [];
} }
@@ -489,11 +617,9 @@ function _chunkForNovel(text, metadata) {
processBuffer(); processBuffer();
if (allChunks.length === 0 && text.length > 0) { if (allChunks.length === 0 && text.length > 0) {
let start = 0; const tagName = getTagForSource('novel');
let section = 1; splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
while (start < text.length) { const section = idx + 1;
const end = Math.min(start + chunkSize, text.length);
const chunkText = text.substring(start, end);
const chunkMetadata = { const chunkMetadata = {
source: 'novel', source: 'novel',
sourceName: sourceName, sourceName: sourceName,
@@ -503,13 +629,10 @@ function _chunkForNovel(text, metadata) {
chapter: "第1章", chapter: "第1章",
section: section, section: section,
}; };
const tagName = getTagForSource('novel');
const prefix = `[来源: ${sourceName}, 第1卷, 第1章, 第${section}节]`; const prefix = `[来源: ${sourceName}, 第1卷, 第1章, 第${section}节]`;
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`; const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
allChunks.push({ text: wrappedText, metadata: chunkMetadata }); allChunks.push({ text: wrappedText, metadata: chunkMetadata });
section++; });
start += (chunkSize - overlap);
}
} }
return allChunks; return allChunks;
} }
@@ -521,15 +644,15 @@ function _chunkForChatHistory(text, metadata) {
const allChunks = []; const allChunks = [];
if (!text || chunkSize <= 0) return allChunks; if (!text || chunkSize <= 0) return allChunks;
let part = 1; // 时间写进 prefix 才能在检索后被反解回来ST 向量存储不持久化 metadata
let start = 0; const timeLabel = formatChunkTimeLabel(timestamp);
const tagName = getTagForSource('chat_history');
while (start < text.length) { splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
const end = Math.min(start + chunkSize, text.length); const part = idx + 1;
const chunkText = text.substring(start, end); const prefix = timeLabel
? `[来源: 聊天记录, 楼层: #${floor}, 时间: ${timeLabel}, 第${part}部分]`
const prefix = `[来源: 聊天记录, 楼层: #${floor}, 第${part}部分]`; : `[来源: 聊天记录, 楼层: #${floor}, 第${part}部分]`;
const tagName = getTagForSource('chat_history');
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`; const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
allChunks.push({ allChunks.push({
@@ -543,11 +666,7 @@ function _chunkForChatHistory(text, metadata) {
timestamp: timestamp, timestamp: timestamp,
} }
}); });
});
part++;
start += (chunkSize - overlap);
if (start >= text.length) break;
}
return allChunks; return allChunks;
} }
@@ -558,15 +677,11 @@ function _chunkForLorebook(text, metadata) {
const allChunks = []; const allChunks = [];
if (!text || chunkSize <= 0) return allChunks; if (!text || chunkSize <= 0) return allChunks;
let part = 1; const tagName = getTagForSource('lorebook');
let start = 0;
while (start < text.length) { splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
const end = Math.min(start + chunkSize, text.length); const part = idx + 1;
const chunkText = text.substring(start, end);
const prefix = `[来源: ${bookName}, 条目: ${entryName}, 第${part}部分]`; const prefix = `[来源: ${bookName}, 条目: ${entryName}, 第${part}部分]`;
const tagName = getTagForSource('lorebook');
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`; const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
allChunks.push({ allChunks.push({
@@ -580,11 +695,7 @@ function _chunkForLorebook(text, metadata) {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
} }
}); });
});
part++;
start += (chunkSize - overlap);
if (start >= text.length) break;
}
return allChunks; return allChunks;
} }
@@ -596,16 +707,12 @@ function _chunkForManual(text, metadata) {
if (!text || chunkSize <= 0) return allChunks; if (!text || chunkSize <= 0) return allChunks;
const timestamp = new Date(); const timestamp = new Date();
const readableTime = timestamp.toLocaleString('zh-CN'); const readableTime = formatChunkTimeLabel(timestamp);
let part = 1; const tagName = getTagForSource('manual');
let start = 0;
while (start < text.length) { splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
const end = Math.min(start + chunkSize, text.length); const part = idx + 1;
const chunkText = text.substring(start, end);
const prefix = `[来源: ${sourceName}, 向量化录入时间: ${readableTime}, 第${part}部分]`; const prefix = `[来源: ${sourceName}, 向量化录入时间: ${readableTime}, 第${part}部分]`;
const tagName = getTagForSource('manual');
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`; const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
allChunks.push({ allChunks.push({
@@ -617,11 +724,7 @@ function _chunkForManual(text, metadata) {
timestamp: timestamp.toISOString(), timestamp: timestamp.toISOString(),
} }
}); });
});
part++;
start += (chunkSize - overlap);
if (start >= text.length) break;
}
return allChunks; return allChunks;
} }
@@ -689,7 +792,13 @@ function getKnowledgeBases() {
return { ...globalBases, ...localBases }; 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()) { if (!name || !name.trim()) {
throw new Error('知识库名称不能为空'); throw new Error('知识库名称不能为空');
} }
@@ -702,17 +811,28 @@ function addKnowledgeBase(name, source = 'manual') {
name: name.trim(), name: name.trim(),
enabled: true, enabled: true,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
owner: charId, owner: charId,
source: source, source: source,
...(chatId ? { chatId } : {}),
}; };
bases[taskId] = newBase; bases[taskId] = newBase;
saveSettings(); saveSettings();
console.log(`[翰林院-核心] 已为角色 ${charId} 添加新知识库: ${name} (ID: ${taskId})`); console.log(`[翰林院-核心] 已为角色 ${charId} 添加新知识库: ${name} (ID: ${taskId}${chatId ? `, 聊天级: ${chatId}` : ''})`);
return newBase; 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) { async function removeKnowledgeBase(taskId, scope) {
const charId = getCharacterStableId(); const charId = getCharacterStableId();
const bases = scope === 'global' ? getGlobalKnowledgeBases() : getLocalKnowledgeBases(); const bases = scope === 'global' ? getGlobalKnowledgeBases() : getLocalKnowledgeBases();
@@ -724,9 +844,8 @@ async function removeKnowledgeBase(taskId, scope) {
return; return;
} }
const ownerId = scope === 'global' ? (base.owner || GLOBAL_SCOPE_ID) : charId; const collectionIdToPurge = getKbCollectionId(base, scope);
const collectionIdToPurge = `${ownerId}_${taskId}`;
console.log(`[翰林院-核心] 准备删除知识库 ${taskId},将清空集合: ${collectionIdToPurge}`); console.log(`[翰林院-核心] 准备删除知识库 ${taskId},将清空集合: ${collectionIdToPurge}`);
const purged = await purgeStorage(collectionIdToPurge); const purged = await purgeStorage(collectionIdToPurge);
@@ -773,30 +892,38 @@ async function queryVectors(queryText, options = {}) {
} }
else if (settings.retrieval.independentChatMemoryEnabled) { else if (settings.retrieval.independentChatMemoryEnabled) {
console.log('[翰林院-日志] 独立聊天记忆模式开启...'); console.log('[翰林院-日志] 独立聊天记忆模式开启...');
const chatId = getChatId(); const chatId = getChatId();
if (chatId) { if (!chatId) {
console.log(`[翰林院-日志] 添加当前聊天宝库: ${chatId}`); console.warn('[翰林院-日志] 无法获取当前聊天ID聊天级知识库将被跳过。');
basesToQuery.push({ id: chatId, name: `当前聊天 (${chatId})`, scope: 'chat' });
} else {
console.warn('[翰林院-日志] 无法获取当前聊天ID跳过聊天宝库。');
} }
const globalBases = getGlobalKnowledgeBases(); // 本地库过滤规则:知识类库(无 chatId照常可查
const enabledGlobalBases = Object.values(globalBases).filter(b => b.enabled); // 聊天级库(有 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) { if (enabledGlobalBases.length > 0) {
console.log(`[翰林院-日志] 添加 ${enabledGlobalBases.length} 个已启用的全局知识库。`); console.log(`[翰林院-日志] 添加 ${enabledGlobalBases.length} 个已启用的全局知识库。`);
basesToQuery.push(...enabledGlobalBases.map(b => ({ ...b, scope: 'global' }))); basesToQuery.push(...enabledGlobalBases.map(b => ({ ...b, scope: 'global' })));
} }
} }
else { else {
console.log('[翰林院-日志] 统一角色卡模式开启...'); console.log('[翰林院-日志] 统一角色卡模式开启...');
const localBases = getLocalKnowledgeBases(); const localBases = getLocalKnowledgeBases();
const globalBases = getGlobalKnowledgeBases(); const globalBases = getGlobalKnowledgeBases();
const enabledLocalBases = Object.values(localBases).filter(b => b.enabled); const enabledLocalBases = Object.values(localBases).filter(b => b.enabled);
const enabledGlobalBases = Object.values(globalBases).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' }))); basesToQuery.push(...enabledGlobalBases.map(b => ({ ...b, scope: 'global' })));
if (basesToQuery.length === 0) { if (basesToQuery.length === 0) {
@@ -860,7 +987,9 @@ async function _executeQueryForBase(base, queryText, queryEmbedding = null) {
collectionId = await getDynamicCollectionId(); collectionId = await getDynamicCollectionId();
break; break;
case 'chat': case 'chat':
collectionId = base.id; // 聊天级库:${chatId}_${taskId} 命名空间(独立聊天记忆)。
// 旧语义的裸 chatId 集合从未被任何录入路径写入过,无存量兼容负担
collectionId = base.chatId ? `${base.chatId}_${base.id}` : base.id;
break; break;
case 'global': case 'global':
const ownerId = base.owner || GLOBAL_SCOPE_ID; const ownerId = base.owner || GLOBAL_SCOPE_ID;
@@ -926,10 +1055,12 @@ async function _executeQueryForBase(base, queryText, queryEmbedding = null) {
switch (sourceTag) { switch (sourceTag) {
case '聊天记录': case '聊天记录':
newMetadata.source = 'chat_history'; newMetadata.source = 'chat_history';
const chatMatch = item.text.match(/楼层:\s*#(\d+),\s*第(\d+)部分/); // 时间段为可选:兼容旧格式 [楼层: #X, 第Y部分] 与新格式 [楼层: #X, 时间: ..., 第Y部分]
if (chatMatch && chatMatch[1] && chatMatch[2]) { const chatMatch = item.text.match(/楼层:\s*#(\d+)(?:,\s*时间:\s*([^,\]]+))?,\s*第(\d+)部分/);
if (chatMatch && chatMatch[1] && chatMatch[3]) {
newMetadata.floor = parseInt(chatMatch[1], 10); 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}`; newMetadata.sourceName = `聊天记录 #${newMetadata.floor}`;
} }
break; break;
@@ -1032,43 +1163,40 @@ async function getVectorCount(taskId = null, scope = 'local') {
console.warn(`[翰林院-计数] 在作用域 '${scope}' 中未找到ID为 ${taskId} 的知识库。`); console.warn(`[翰林院-计数] 在作用域 '${scope}' 中未找到ID为 ${taskId} 的知识库。`);
return 0; return 0;
} }
const ownerId = scope === 'global' ? (base.owner || GLOBAL_SCOPE_ID) : charId; // 聊天级库按 ${chatId}_${taskId} 命名空间计数getKbCollectionId 统一处理)
const collectionId = `${ownerId}_${taskId}`; return await countVectorsInCollection(getKbCollectionId(base, scope));
return await countVectorsInCollection(collectionId);
} else { } else {
if (settings.retrieval.independentChatMemoryEnabled) { // 总数统计与查询侧保持同一可见性规则:
const chatId = getChatId(); // 独立模式 → 本地知识库 + 当前聊天的聊天级库 + 全局库
if (!chatId) return 0; // 统一模式 → 全部本地库(含聊天级)+ 全局库 + legacy 宝库
const totalCount = await countVectorsInCollection(chatId); const independent = settings.retrieval.independentChatMemoryEnabled;
console.log(`[翰林院-日志] 独立聊天记忆模式开启,聊天 ${chatId} 的向量总数: ${totalCount}`); const chatId = independent ? getChatId() : null;
return totalCount; 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 globalBases = Object.values(getGlobalKnowledgeBases());
const countPromises = []; const countPromises = [];
localBases.forEach(base => { localBases.forEach(base => {
const collectionId = `${charId}_${base.id}`; countPromises.push(countVectorsInCollection(getKbCollectionId(base, 'local')));
countPromises.push(countVectorsInCollection(collectionId));
}); });
globalBases.forEach(base => { globalBases.forEach(base => {
const ownerId = base.owner || GLOBAL_SCOPE_ID; countPromises.push(countVectorsInCollection(getKbCollectionId(base, 'global')));
const collectionId = `${ownerId}_${base.id}`;
countPromises.push(countVectorsInCollection(collectionId));
}); });
const legacyCollectionId = await getDynamicCollectionId(); if (!independent) {
countPromises.push(countVectorsInCollection(legacyCollectionId)); const legacyCollectionId = await getDynamicCollectionId();
countPromises.push(countVectorsInCollection(legacyCollectionId));
}
const counts = await Promise.all(countPromises); const counts = await Promise.all(countPromises);
const totalCount = counts.reduce((total, count) => total + count, 0); const totalCount = counts.reduce((total, count) => total + count, 0);
console.log(`[翰林院-日志] 所有知识库统计完成,总向量数: ${totalCount}`); console.log(`[翰林院-日志] 知识库统计完成,总向量数: ${totalCount}`);
return totalCount; return totalCount;
} }
} }
@@ -1183,20 +1311,23 @@ async function processCondensation(messages, logCallback = () => {}, range = nul
kbName = `聊天记录: ${timestamp}`; kbName = `聊天记录: ${timestamp}`;
} }
const existingKbs = Object.values(getLocalKnowledgeBases()); // 独立聊天记忆模式下凝识结果按聊天分桶,与 ingestTextToHanlinyuan 的语义一致
const foundKb = existingKbs.find(kb => kb.name === kbName); 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) { if (foundKb) {
taskId = foundKb.id; taskId = foundKb.id;
logCallback(`[翰林院-核心] 检测到同名知识库 "${kbName}",将数据合并入库。`, 'info'); logCallback(`[翰林院-核心] 检测到同名知识库 "${kbName}",将数据合并入库。`, 'info');
} else { } else {
logCallback(`[翰林院-核心] 准备为任务 "${kbName}" 创建专属知识库...`, 'info'); logCallback(`[翰林院-核心] 准备为任务 "${kbName}" 创建专属知识库...`, 'info');
const newKb = addKnowledgeBase(kbName, 'chat_history'); const newKb = addKnowledgeBase(kbName, 'chat_history', independentChatId);
taskId = newKb.id; taskId = newKb.id;
} }
const charId = getCharacterStableId(); const charId = getCharacterStableId();
const collectionId = `${charId}_${taskId}`; const collectionId = independentChatId ? `${independentChatId}_${taskId}` : `${charId}_${taskId}`;
logCallback(`[翰林院-核心] 凝识任务已锁定知识库: ${kbName} (集合ID: ${collectionId})`, 'success'); logCallback(`[翰林院-核心] 凝识任务已锁定知识库: ${kbName} (集合ID: ${collectionId})`, 'success');
const allChunks = []; const allChunks = [];
@@ -1405,7 +1536,13 @@ async function rerankResults(allResults, queryText, settings) {
console.log('[翰林院-Rerank] 开始外部API重排序...'); console.log('[翰林院-Rerank] 开始外部API重排序...');
try { try {
const documentsToRerank = allResults.map(res => res.text); 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 })); const indexedResults = allResults.map((res, index) => ({ ...res, original_index: index }));
processedResults = indexedResults.map(result => { 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)); finalScoredResults.sort((a, b) => (b.final_score || 0) - (a.final_score || 0));
console.log('[翰林院-Rerank] 元数据加权排序完成。'); console.log('[翰林院-Rerank] 元数据加权排序完成。');
let finalResults = finalScoredResults; // 先按相关度截断 top_n再做时序排序——顺序反了会让"时序最早"而非"最相关"
// 的块占据名额超级排序把最旧楼层排最前slice 会扔掉高相关的靠后结果)
let finalResults = finalScoredResults.slice(0, settings.rerank.top_n);
if (settings.rerank.superSortEnabled) { if (settings.rerank.superSortEnabled) {
finalResults = superSort(finalScoredResults); finalResults = superSort(finalResults);
} }
return { return {
results: finalResults.slice(0, settings.rerank.top_n), results: finalResults,
reranked: rerankedSuccessfully 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) { async function rearrangeChat(chat, contextSize, abort, type) {
const injectionKeys = { const injectionKeys = {
novel: 'HANLINYUAN_RAG_NOVEL', novel: 'HANLINYUAN_RAG_NOVEL',
@@ -1685,7 +1906,8 @@ async function rearrangeChat(chat, contextSize, abort, type) {
continue; continue;
} }
const formattedText = results.map(r => r.text).join('\n\n'); // 组内按时序重排 + 断层提示rerank 决定选哪些块,时序决定呈现顺序)
const formattedText = _composeInjectionText(source, results);
const placeholder = `{{${source.replace('_history', '')}_text}}`; const placeholder = `{{${source.replace('_history', '')}_text}}`;
let injectionContent = injectionSettings.template.replace(placeholder, formattedText); let injectionContent = injectionSettings.template.replace(placeholder, formattedText);
@@ -1732,6 +1954,13 @@ async function moveKnowledgeBase(taskId, fromScope) {
return; return;
} }
// 聊天级库(独立聊天记忆产物)专属于单个聊天,移到全局会让所有角色
// 检索到某个特定聊天的记忆,语义矛盾,禁止
if (kbData.chatId && toScope === 'global') {
toastr.warning(`知识库【${kbData.name}】是聊天专属记忆,不能移动到全局。`);
return;
}
if (fromScope === 'local' && toScope === 'global' && !kbData.owner) { if (fromScope === 'local' && toScope === 'global' && !kbData.owner) {
console.log(`[翰林院-配置] 为旧版知识库 ${taskId} 补充所有者ID: ${charId}`); console.log(`[翰林院-配置] 为旧版知识库 ${taskId} 补充所有者ID: ${charId}`);
kbData.owner = charId; kbData.owner = charId;

View File

@@ -3,8 +3,10 @@
export const defaultSettings = { export const defaultSettings = {
retrieval: { retrieval: {
enabled: false, enabled: false,
apiEndpoint: 'openai', // 默认走 custom 与下面的 customApiUrl 配套;旧默认 'openai' 不在 UI select
// 选项里,会在首次保存时被写成 ''(已有用户的 'openai' 值仍合法、不迁移)
apiEndpoint: 'custom',
customApiUrl: 'https://api.siliconflow.cn/v1', customApiUrl: 'https://api.siliconflow.cn/v1',
apiKey: '', apiKey: '',
embeddingModel: 'text-embedding-3-small', embeddingModel: 'text-embedding-3-small',
@@ -65,8 +67,9 @@ export const defaultSettings = {
}, },
rerank: { rerank: {
enabled: false, enabled: false,
apiMode: 'custom',
url: 'https://api.siliconflow.cn/v1', url: 'https://api.siliconflow.cn/v1',
apiKey: '', apiKey: '',
model: 'Pro/BAAI/bge-reranker-v2-m3', model: 'Pro/BAAI/bge-reranker-v2-m3',
top_n: 5, top_n: 5,
hybrid_alpha: 0.7, hybrid_alpha: 0.7,

View File

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

View File

@@ -11,14 +11,16 @@
* 公开接口query('SuperMemory') * 公开接口query('SuperMemory')
* initialize() — 初始化超级记忆系统 * initialize() — 初始化超级记忆系统
* forceSyncAll() — 全量同步到世界书 * forceSyncAll() — 全量同步到世界书
* tryRestoreStateFromMetadata() — 从聊天元数据恢复状态
* awaitSync() — 等待当前同步队列完成Pipeline Stage 4 使用) * awaitSync() — 等待当前同步队列完成Pipeline Stage 4 使用)
* purge() — 清空记忆世界书 * purge() — 清空记忆世界书
*
* 注tryRestoreStateFromMetadata 已删除——msg.metadata 非 ST 持久化字段,
* 该恢复路径从未真正工作过;表格状态的持久化与恢复由表格系统
* loadTables / msg.extra.amily2_tables_data唯一负责。
*/ */
import { import {
initializeSuperMemory, initializeSuperMemory,
tryRestoreStateFromMetadata,
forceSyncAll, forceSyncAll,
awaitSync, awaitSync,
purgeSuperMemory, purgeSuperMemory,
@@ -34,12 +36,11 @@ setTimeout(() => {
return; return;
} }
_ctx.expose({ _ctx.expose({
initialize: () => initializeSuperMemory(), initialize: () => initializeSuperMemory(),
forceSyncAll: () => forceSyncAll(), forceSyncAll: () => forceSyncAll(),
tryRestoreStateFromMetadata: () => tryRestoreStateFromMetadata(), awaitSync: () => awaitSync(),
awaitSync: () => awaitSync(), purge: () => purgeSuperMemory(),
purge: () => purgeSuperMemory(), pushUpdate: (payload) => pushUpdate(payload),
pushUpdate: (payload) => pushUpdate(payload),
}); });
_ctx.log('SuperMemoryService', 'info', 'SuperMemory 服务已注册到 Bus。'); _ctx.log('SuperMemoryService', 'info', 'SuperMemory 服务已注册到 Bus。');
} catch (e) { } catch (e) {
@@ -50,7 +51,6 @@ setTimeout(() => {
// ── 向后兼容具名导出 ────────────────────────────────────────────────────── // ── 向后兼容具名导出 ──────────────────────────────────────────────────────
export { export {
initializeSuperMemory, initializeSuperMemory,
tryRestoreStateFromMetadata,
forceSyncAll, forceSyncAll,
awaitSync, awaitSync,
purgeSuperMemory, purgeSuperMemory,

View File

@@ -1,7 +1,7 @@
import { extensionName } from "../../utils/settings.js"; import { extensionName } from "../../utils/settings.js";
import { extension_settings } from "/scripts/extensions.js"; import { extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.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 { defaultSettings as ragDefaultSettings } from "../rag-settings.js";
import { getMemoryState } from "../table-system/manager.js"; import { getMemoryState } from "../table-system/manager.js";
@@ -9,10 +9,11 @@ const RAG_MODULE_NAME = 'hanlinyuan-rag-core';
function getRagSettings() { function getRagSettings() {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {}; if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
if (!extension_settings[RAG_MODULE_NAME]) { const root = extension_settings[extensionName];
extension_settings[RAG_MODULE_NAME] = structuredClone(ragDefaultSettings); if (!root[RAG_MODULE_NAME]) {
root[RAG_MODULE_NAME] = structuredClone(ragDefaultSettings);
} }
return extension_settings[RAG_MODULE_NAME]; return root[RAG_MODULE_NAME];
} }
export function bindSuperMemoryEvents() { export function bindSuperMemoryEvents() {
@@ -41,6 +42,12 @@ export function bindSuperMemoryEvents() {
if (id === 'sm-system-enabled') { if (id === 'sm-system-enabled') {
extension_settings[extensionName]['super_memory_enabled'] = this.checked; extension_settings[extensionName]['super_memory_enabled'] = this.checked;
saveSettingsDebounced(); saveSettingsDebounced();
// 【修复】启动时若开关为关initializeSuperMemory 会早退且不注册监听器;
// 旧实现勾选后只写设置不初始化,导致开关"打开了但没反应"直到刷新页面。
// initializeSuperMemory 幂等isInitialized 防重入),此处直接补初始化。
if (this.checked) {
initializeSuperMemory();
}
return; return;
} }
if (id === 'sm-bridge-enabled') { if (id === 'sm-bridge-enabled') {
@@ -109,7 +116,7 @@ export function bindSuperMemoryEvents() {
} }
const tableName = $(this).data('table'); 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; const checked = this.checked;
if (!extension_settings[extensionName].superMemory_tableSettings[tableName]) { if (!extension_settings[extensionName].superMemory_tableSettings[tableName]) {
@@ -118,6 +125,8 @@ export function bindSuperMemoryEvents() {
extension_settings[extensionName].superMemory_tableSettings[tableName][type] = checked; extension_settings[extensionName].superMemory_tableSettings[tableName][type] = checked;
saveSettingsDebounced(); saveSettingsDebounced();
// 立即应用:首行常驻切换需要把该行详情条目在 蓝灯/绿灯 之间重写
forceSyncAll();
console.log(`[Amily2-SuperMemory] Table setting updated: ${tableName}.${type} = ${checked}`); console.log(`[Amily2-SuperMemory] Table setting updated: ${tableName}.${type} = ${checked}`);
}); });
@@ -143,9 +152,10 @@ function renderTableSettingsList() {
const tableName = table.name; const tableName = table.name;
const tableConfig = settings[tableName] || {}; const tableConfig = settings[tableName] || {};
// Default values: Sync=True, Constant=True // Default values: Sync=True, Constant=True; PinFirstRow=False
const isSyncEnabled = tableConfig.sync !== false; const isSyncEnabled = tableConfig.sync !== false;
const isConstant = tableConfig.constant !== false; const isConstant = tableConfig.constant !== false;
const isPinFirstRow = tableConfig.pinFirstRow === true;
html += ` html += `
<div class="sm-control-block" style="border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 10px; margin-bottom: 10px;"> <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> <span style="font-size: 0.9em; color: #ccc;">索引绿灯(常驻)</span>
</div> </div>
</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> </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 () => { return withLoreLock(`syncToLorebook(${tableName})`, async () => {
await _doEnsureBook(getMemoryBookName()); 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); processEntry(indexComment, indexKey, indexContent, indexType, true, true, 0, 0);
} }
data.forEach((row, index) => { const settings = extension_settings[extensionName] || {};
if (!row || row.length === 0) return; const optimizationEnabled = settings.context_optimization_enabled !== false;
const rawVal = row[0];
if (rawVal === undefined || rawVal === null) return;
const primaryVal = String(rawVal).trim(); const renderRowContent = (row) => {
if (primaryVal === '') return;
const isPendingDeletion = rowStatuses && rowStatuses[index] === 'pending-deletion';
const isEnabled = !isPendingDeletion;
const triggerKeys = [primaryVal];
const entryComment = `[Amily2] Detail: ${tableName} - ${primaryVal}`;
let finalHeaders = headers; let finalHeaders = headers;
if (!finalHeaders || finalHeaders.length < row.length) { if (!finalHeaders || finalHeaders.length < row.length) {
finalHeaders = []; 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}`); finalHeaders.push((headers && headers[i]) ? headers[i] : `Col_${i}`);
} }
} }
const settings = extension_settings[extensionName] || {};
const optimizationEnabled = settings.context_optimization_enabled !== false;
let entryContent; let entryContent;
if (optimizationEnabled) { if (optimizationEnabled) {
const primaryVal = row[0] || 'Unknown'; const primaryVal = row[0] || 'Unknown';
entryContent = `${tableName}档案: ${primaryVal}\n`; entryContent = `${tableName}档案: ${primaryVal}\n`;
@@ -178,13 +174,34 @@ export async function syncToLorebook(tableName, data, indexText, role, headers,
} }
entryContent = textContent.trim(); 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 entriesToDelete = [];
const tablePrefix = `[Amily2] Detail: ${tableName} -`; const tablePrefix = `[Amily2] Detail: ${tableName} -`;
const activeKeys = new Set(); const activeKeys = new Set();
for(const row of data) { for(const row of data) {
if(row && row.length > 0) { 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 { amilyHelper } from "../tavern-helper/main.js";
import { generateIndex } from "./smart-indexer.js"; import { generateIndex } from "./smart-indexer.js";
import { syncToLorebook, ensureMemoryBook, updateTransientHint, getMemoryBookName } from "./lorebook-bridge.js"; import { syncToLorebook, ensureMemoryBook, updateTransientHint, getMemoryBookName } from "./lorebook-bridge.js";
import { getMemoryState, loadMemoryState, saveMemoryState } from "../table-system/manager.js"; import { getMemoryState } from "../table-system/manager.js";
import { TABLE_UPDATED_EVENT } from "../table-system/events-schema.js"; import { TABLE_UPDATED_EVENT, inferTableRole } from "../table-system/events-schema.js";
import { eventSource, event_types } from "/script.js"; import { eventSource, event_types } from "/script.js";
import { handleArchiveUpdate } from "../archive-manager.js";
/* ── [AMILY2-MODIFIED] ── pipeline integration: awaitSync() export ── */ /* ── [AMILY2-MODIFIED] ── pipeline integration: awaitSync() export ── */
let isInitialized = false; let isInitialized = false;
let updateQueue = []; let updateQueue = [];
let isProcessing = false; let isProcessing = false;
let lastChatId = null;
let _syncPromise = null; // tracks the running processQueue() promise for pipeline awaiting let _syncPromise = null; // tracks the running processQueue() promise for pipeline awaiting
const METADATA_KEY = 'Amily2_Memory_Data';
/** /**
* [AMILY2-MODIFIED] Pipeline integration: * [AMILY2-MODIFIED] Pipeline integration:
* Allows MessagePipeline Stage 4 to await the super-memory sync triggered * 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); 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 () => { eventSource.on(event_types.CHAT_CHANGED, async () => {
const settings = extension_settings[extensionName] || {}; const settings = extension_settings[extensionName] || {};
if (settings.super_memory_enabled === false) return; if (settings.super_memory_enabled === false) return;
console.log('[Amily2-SuperMemory] 检测到聊天切换,正在刷新记忆状态...');
await checkWorldBookStatus(); await checkWorldBookStatus();
await tryRestoreStateFromMetadata();
await forceSyncAll();
}); });
await checkWorldBookStatus(); await checkWorldBookStatus();
await tryRestoreStateFromMetadata(); await forceSyncAll();
await forceSyncAll();
isInitialized = true; isInitialized = true;
console.log('[Amily2-SuperMemory] 核心管理器初始化完成。'); console.log('[Amily2-SuperMemory] 核心管理器初始化完成。');
@@ -109,11 +105,22 @@ export function pushUpdate(payload) {
console.log(`[Amily2-SuperMemory] 收到表格更新 (Bus): ${tableName} (Role: ${role})`); console.log(`[Amily2-SuperMemory] 收到表格更新 (Bus): ${tableName} (Role: ${role})`);
updateQueue.push({ tableName, data, role, headers, rowStatuses }); 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 未就绪时的兜底监听器) */ /** CustomEvent 降级路径Bus 未就绪时的兜底监听器) */
function handleTableUpdate(event) { function handleTableUpdate(event) {
// Bus 已就绪时 pushUpdate 已由 dispatchTableUpdate 直调,跳过避免重复处理
if (window.Amily2Bus?.query('SuperMemory')?.pushUpdate) return;
pushUpdate(event.detail); pushUpdate(event.detail);
} }
@@ -140,15 +147,18 @@ async function processQueue() {
await processUpdateTask(task); await processUpdateTask(task);
} }
} }
await saveStateToMetadata(); // 【修复】移除 saveStateToMetadata()msg.metadata 不是 ST 的持久化字段
// (消息体标准位是 msg.extra写入后会蒸发恢复路径永远找不到东西——
// 整条"元数据状态保存/恢复"链路是死代码。表格状态的唯一持久化信源是
// 表格系统自己的 msg.extra.amily2_tables_datainfra/persistence.js
} catch (error) { } catch (error) {
console.error('[Amily2-SuperMemory] 处理更新队列失败:', error); console.error('[Amily2-SuperMemory] 处理更新队列失败:', error);
} finally { } finally {
isProcessing = false; isProcessing = false;
if (updateQueue.length > 0) { 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 activeData = data.filter((_, i) => !rowStatuses || rowStatuses[i] !== 'pending-deletion');
const indexText = generateIndex(activeData, headers, role, tableName); const indexText = generateIndex(activeData, headers, role, tableName);
const allTables = getMemoryState(); const allTables = getMemoryState();
const tableIndex = allTables.findIndex(t => t.name === tableName); const tableIndex = allTables.findIndex(t => t.name === tableName);
const depth = 8001 + (tableIndex >= 0 ? tableIndex : 99); 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) { if (hint) {
console.log(`[Amily2-SuperMemory] 应用主动记忆提示: ${hint}`); console.log(`[Amily2-SuperMemory] 应用主动记忆提示: ${hint}`);
@@ -185,54 +199,67 @@ async function processUpdateTask(task) {
updateDashboardCounters(); updateDashboardCounters();
} }
async function saveStateToMetadata() { // 【已停用 2026-06-12】saveStateToMetadata / tryRestoreStateFromMetadata
const context = getContext(); // msg.metadata 不是 ST 持久化字段(同 secondary-filler 修过的坑),写了会蒸发、
if (!context.chat || context.chat.length === 0) return; // 读永远为空——整条链路判定为从未真正工作过。若它"工作"了反而更糟:恢复出的
// 过期副本会覆盖表格系统从 msg.extra.amily2_tables_data 恢复的正确状态(双信源打架)。
const lastMsgIndex = context.chat.length - 1; // 表格状态的持久化与恢复完全交由表格系统loadTables / saveStateToMessage
const lastMsg = context.chat[lastMsgIndex]; //
// 原实现注释保留(原作者代码,不排除存在未知副作用依赖;确认稳定几个版本后再清):
const currentState = getMemoryState(); //
// const METADATA_KEY = 'Amily2_Memory_Data';
if (!lastMsg.metadata) lastMsg.metadata = {}; //
// async function saveStateToMetadata() {
lastMsg.metadata[METADATA_KEY] = JSON.parse(JSON.stringify(currentState)); // const context = getContext();
// if (!context.chat || context.chat.length === 0) return;
if (context.saveChat) { //
await context.saveChat(); // const lastMsgIndex = context.chat.length - 1;
} // const lastMsg = context.chat[lastMsgIndex];
//
console.log(`[Amily2-SuperMemory] 状态已保存至消息 #${lastMsgIndex}`); // const currentState = getMemoryState();
} //
// if (!lastMsg.metadata) lastMsg.metadata = {};
export async function tryRestoreStateFromMetadata() { //
const context = getContext(); // lastMsg.metadata[METADATA_KEY] = JSON.parse(JSON.stringify(currentState));
if (!context.chat || context.chat.length === 0) return; //
// if (context.saveChat) {
let foundState = null; // await context.saveChat();
let foundIndex = -1; // }
//
for (let i = context.chat.length - 1; i >= 0; i--) { // console.log(`[Amily2-SuperMemory] 状态已保存至消息 #${lastMsgIndex}`);
const msg = context.chat[i]; // }
if (msg.metadata && msg.metadata[METADATA_KEY]) { // 原调用点processQueue 的 while 循环结束后 `await saveStateToMetadata();`
foundState = msg.metadata[METADATA_KEY]; //
foundIndex = i; // export async function tryRestoreStateFromMetadata() {
break; // const context = getContext();
} // if (!context.chat || context.chat.length === 0) return;
} //
// let foundState = null;
if (foundState) { // let foundIndex = -1;
console.log(`[Amily2-SuperMemory] 发现历史状态 (Msg #${foundIndex}),正在恢复...`); //
if (typeof loadMemoryState === 'function') { // for (let i = context.chat.length - 1; i >= 0; i--) {
loadMemoryState(foundState); // const msg = context.chat[i];
await forceSyncAll(); // if (msg.metadata && msg.metadata[METADATA_KEY]) {
} else { // foundState = msg.metadata[METADATA_KEY];
console.warn('[Amily2-SuperMemory] table-system 缺少 loadMemoryState 方法,无法恢复状态。'); // foundIndex = i;
} // break;
} else { // }
console.log('[Amily2-SuperMemory] 未在聊天记录中发现历史状态,使用默认/当前状态。'); // }
} //
} // 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() { function updateDashboardCounters() {
const tables = getMemoryState(); const tables = getMemoryState();
@@ -265,20 +292,19 @@ export async function forceSyncAll() {
} }
for (const table of tables) { 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({ updateQueue.push({
tableName: table.name, tableName: table.name,
data: table.rows, data: table.rows,
headers: table.headers, headers: table.headers,
rowStatuses: table.rowStatuses || [], rowStatuses: table.rowStatuses || [],
role: role role: inferTableRole(table.name), // 复用 events-schema 的统一推断,避免两处逻辑漂移
}); });
} }
await processQueue(); if (!isProcessing) {
_syncPromise = processQueue();
}
await _syncPromise;
console.log('[Amily2-SuperMemory] 全量同步完成。'); 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

@@ -291,7 +291,17 @@ async function runBatchAttempt(batchNum, attemptNum) {
if (!argsString) throw new Error('Function Call 返回为空。'); if (!argsString) throw new Error('Function Call 返回为空。');
const ops = parseToolCallArgs(argsString); const ops = parseToolCallArgs(argsString);
if (ops.length === 0) { if (ops.length === 0) {
log(`批次 ${batchNum} 的 Function Call 返回操作列表为空AI 判断此批次无需变更。`, 'warn'); 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}`); toastr.info('AI 判断此批次无需修改。', `批次 ${batchNum}`);
} else { } else {
await updateTableFromOps(ops, { immediateDelete: true }); await updateTableFromOps(ops, { immediateDelete: true });
@@ -417,7 +427,9 @@ export function startBatchFilling() {
manualStopRequested = false; manualStopRequested = false;
const context = getContext(); const context = getContext();
chatHistoryLength = context.chat.length; 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 ruleTemplate = getBatchFillerRuleTemplate();
const flowTemplate = getBatchFillerFlowTemplate(); const flowTemplate = getBatchFillerFlowTemplate();
@@ -562,7 +574,17 @@ export async function startFloorRangeFilling(startFloor, endFloor) {
if (!argsString) throw new Error('Function Call 返回为空。'); if (!argsString) throw new Error('Function Call 返回为空。');
const ops = parseToolCallArgs(argsString); const ops = parseToolCallArgs(argsString);
if (ops.length === 0) { if (ops.length === 0) {
log(`楼层 ${startFloor}-${endFloor} Function Call 返回操作列表为空,无需变更。`, 'warn'); 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}`); toastr.info('AI 判断此楼层范围无需修改。', `楼层 ${startFloor}-${endFloor}`);
} else { } else {
await updateTableFromOps(ops, { immediateDelete: true }); await updateTableFromOps(ops, { immediateDelete: true });

View File

@@ -0,0 +1,51 @@
/**
* @file events-dispatch.js —— SuperMemory 事件分发Phase 0.4 自 manager.js 抽出)
*
* 把单表 / 全表的最新状态推送给 SuperMemory优先 Bus 直调,降级 CustomEvent
* 独立成模块的原因manager.js 与 actions/ui-mutations.js 都需要调用,
* 放在任何一方都会制造新的循环依赖;本模块只依赖 store / events-schema / logger零环。
*/
import { extension_settings } from '/scripts/extensions.js';
import { extensionName } from '../../utils/settings.js';
import { log } from './logger.js';
import { getState } from './infra/store.js';
import { createTableUpdateEvent, inferTableRole } from './events-schema.js';
/**
* 把单个表格的最新状态推送给 SuperMemory优先 Bus 直调,降级 CustomEvent
* @param {number} tableIndex
*/
export function dispatchTableUpdate(tableIndex) {
const settings = extension_settings[extensionName] || {};
if (settings.super_memory_enabled === false) return;
const state = getState();
if (!state || !state[tableIndex]) return;
const table = state[tableIndex];
const role = inferTableRole(table.name);
const smBus = window.Amily2Bus?.query('SuperMemory');
if (smBus?.pushUpdate) {
smBus.pushUpdate({
tableName: table.name,
data: table.rows,
headers: table.headers,
rowStatuses: table.rowStatuses ?? [],
role,
});
} else {
document.dispatchEvent(createTableUpdateEvent(table));
}
log(`[SuperMemory] Dispatched update for ${table.name} (role: ${role})`, 'info');
}
/**
* 触发所有表格的全量同步Pipeline 变更后调用)。
*/
export function dispatchAllTablesUpdate() {
const state = getState();
if (!state) return;
log('[SuperMemory] Dispatching update events for ALL tables...', 'info');
state.forEach((_, index) => dispatchTableUpdate(index));
}

View File

@@ -5,16 +5,17 @@
* - 状态infra/store.js (currentTablesState / highlights / updatedTables) * - 状态infra/store.js (currentTablesState / highlights / updatedTables)
* - 持久化infra/persistence.js (saveStateToMessage / commitToLastMessage) * - 持久化infra/persistence.js (saveStateToMessage / commitToLastMessage)
* - 推演actions/applyOperations.js (executor.js 改造为 legacy formatter) * - 推演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) * - 渲染rendering.js (3 个 toCsv)
* - 模板templates.js * - 模板templates.js
* - 预设preset.js * - 预设preset.js
* *
* 本文件保留: * 本文件保留:
* - 默认表格模板 + getDefaultTables * - 默认表格模板 + getDefaultTables
* - SuperMemory 事件分发dispatchTableUpdate / dispatchAllTablesUpdate / triggerSync * - triggerSyncSuperMemory 全量同步入口
* - loadTables 的多档回退逻辑 * - loadTables 的多档回退逻辑
* - 16 个 UI 突变addRow / addColumn / ... / clearAllTables * - updateTableFromText / updateTableFromOps 编排
* - updateTableFromText 编排
* - rollbackState / rollbackAndRefill * - rollbackState / rollbackAndRefill
* *
* 所有原先 export 的接口一律保留兼容(移走的统一 re-export调用方零改动。 * 所有原先 export 的接口一律保留兼容(移走的统一 re-export调用方零改动。
@@ -33,7 +34,31 @@ import { applyOperations } from './actions/applyOperations.js';
import { fillWithSecondaryApi } from './secondary-filler.js'; import { fillWithSecondaryApi } from './secondary-filler.js';
import { renderTables } from '../../ui/table-bindings.js'; import { renderTables } from '../../ui/table-bindings.js';
import { updateOrInsertTableInChat } from '../../ui/message-table-renderer.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 拆分后的依赖) ──────────────────────────────────────────── // ── 新模块IAD 拆分后的依赖) ────────────────────────────────────────────
import { import {
@@ -77,45 +102,7 @@ import {
importGlobalPreset as _presetImportGlobalPreset, importGlobalPreset as _presetImportGlobalPreset,
} from './preset.js'; } from './preset.js';
// ── 私有:SuperMemory 事件分发 ──────────────────────────────────────────── // ── SuperMemory 同步入口 ──────────────────────────────────────────────────
/**
* 把单个表格的最新状态推送给 SuperMemory优先 Bus 直调,降级 CustomEvent
* @param {number} tableIndex
*/
function dispatchTableUpdate(tableIndex) {
const settings = extension_settings[extensionName] || {};
if (settings.super_memory_enabled === false) return;
const state = getState();
if (!state || !state[tableIndex]) return;
const table = state[tableIndex];
const role = inferTableRole(table.name);
const smBus = window.Amily2Bus?.query('SuperMemory');
if (smBus?.pushUpdate) {
smBus.pushUpdate({
tableName: table.name,
data: table.rows,
headers: table.headers,
rowStatuses: table.rowStatuses ?? [],
role,
});
} else {
document.dispatchEvent(createTableUpdateEvent(table));
}
log(`[SuperMemory] Dispatched update for ${table.name} (role: ${role})`, 'info');
}
/**
* 触发所有表格的全量同步Pipeline 变更后调用)。
*/
function dispatchAllTablesUpdate() {
const state = getState();
if (!state) return;
log('[SuperMemory] Dispatching update events for ALL tables...', 'info');
state.forEach((_, index) => dispatchTableUpdate(index));
}
/** /**
* 主动触发所有表格同步到 SuperMemory外部调用入口 * 主动触发所有表格同步到 SuperMemory外部调用入口
@@ -352,438 +339,6 @@ export function saveTables(sourceAction = '未知操作') {
return true; 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 ──────────────────────────────────────── // ── 渲染 wrapper注入当前 state ────────────────────────────────────────
export function convertTablesToCsvString() { export function convertTablesToCsvString() {
@@ -1021,7 +576,7 @@ export async function rollbackAndRefill() {
const lastMessage = context.chat[context.chat.length - 1]; const lastMessage = context.chat[context.chat.length - 1];
try { try {
await fillWithSecondaryApi(lastMessage, true); await fillWithSecondaryApi(lastMessage, true, { targetMessage: lastMessage });
log('回退并重新填表操作完成。', 'success'); log('回退并重新填表操作完成。', 'success');
} catch (error) { } catch (error) {
log(`回退重填过程中发生错误: ${error.message}`, 'error'); log(`回退重填过程中发生错误: ${error.message}`, 'error');
@@ -1031,19 +586,6 @@ export async function rollbackAndRefill() {
// ── 杂项 ────────────────────────────────────────────────────────────────── // ── 杂项 ──────────────────────────────────────────────────────────────────
export function updateColumnWidth(tableIndex, colIndex, width) {
const tables = getState();
if (!tables || !tables[tableIndex]) return;
const table = tables[tableIndex];
if (!table.columnWidths) table.columnWidths = [];
while (table.columnWidths.length < table.headers.length) {
table.columnWidths.push(null);
}
table.columnWidths[colIndex] = width;
commitToLastMessage(tables);
}
export function isCurrentTablesEmpty() { export function isCurrentTablesEmpty() {
const tables = getState(); const tables = getState();
if (!tables || tables.length === 0) return true; if (!tables || tables.length === 0) return true;

View File

@@ -18,13 +18,15 @@ import { showTableFillReviewModal } from '../../ui/page-window.js';
const CONTINUE_PROMPT_SECONDARY = '上一条回复不完整或缺少 <Amily2Edit> 指令块。请直接从中断处继续生成剩余内容,不要重复已输出的文本,也不要添加任何解释或寒暄,确保最终输出中包含完整的 <Amily2Edit>...</Amily2Edit> 指令块。'; const CONTINUE_PROMPT_SECONDARY = '上一条回复不完整或缺少 <Amily2Edit> 指令块。请直接从中断处继续生成剩余内容,不要重复已输出的文本,也不要添加任何解释或寒暄,确保最终输出中包含完整的 <Amily2Edit>...</Amily2Edit> 指令块。';
let secondaryFillerDebounceTimer = null; let secondaryFillerDebounceTimer = null;
let secondaryFillerRunning = false;
let currentAbortController = null;
async function callSecondaryModel(messages) { async function callSecondaryModel(messages, signal) {
const settings = extension_settings[extensionName] || {}; const settings = extension_settings[extensionName] || {};
if (settings.nccsEnabled) { if (settings.nccsEnabled) {
return await callNccsAI(messages); return await callNccsAI(messages, { signal });
} }
return await callAI(messages); return await callAI(messages, { signal });
} }
async function requestSecondaryContinuation(baseMessages, partialResponse) { async function requestSecondaryContinuation(baseMessages, partialResponse) {
@@ -38,23 +40,30 @@ async function requestSecondaryContinuation(baseMessages, partialResponse) {
return `${partialResponse || ''}${continued}`; return `${partialResponse || ''}${continued}`;
} }
function commitSecondaryFillResult(rawContent, targetMessages) { async function markTargetsProcessed(targetMessages, { skipTableSave = false } = {}) {
updateTableFromText(rawContent); if (!targetMessages || targetMessages.length === 0) return;
const memoryState = getMemoryState();
const lastProcessedMsg = targetMessages[targetMessages.length - 1].msg; const lastProcessedMsg = targetMessages[targetMessages.length - 1].msg;
for (const target of targetMessages) { for (const target of targetMessages) {
if (!target.msg.metadata) target.msg.metadata = {}; if (!target.msg.extra) target.msg.extra = {};
target.msg.metadata.Amily2_Process_Hash = target.hash; target.msg.extra.amily2_process_hash = target.hash;
} }
if (saveStateToMessage(memoryState, lastProcessedMsg)) { if (!skipTableSave) {
renderTables(); const memoryState = getMemoryState();
updateOrInsertTableInChat(); if (saveStateToMessage(memoryState, lastProcessedMsg)) {
renderTables();
updateOrInsertTableInChat();
}
} }
saveChat(); await saveChat();
}
async function commitSecondaryFillResult(rawContent, targetMessages) {
await updateTableFromText(rawContent);
await markTargetsProcessed(targetMessages);
} }
@@ -110,11 +119,16 @@ async function getWorldBookContext() {
return content.trim() ? `<世界书>\n${content.trim()}\n</世界书>` : ''; return content.trim() ? `<世界书>\n${content.trim()}\n</世界书>` : '';
} }
export async function fillWithSecondaryApi(latestMessage, forceRun = false) { export async function fillWithSecondaryApi(latestMessage, forceRun = false, opts = {}) {
if (secondaryFillerRunning) {
log('分步填表正在进行中,跳过本次触发。', 'warn');
return;
}
const settings = extension_settings[extensionName] || {}; const settings = extension_settings[extensionName] || {};
// 【V2.1.1】分步填表触发延迟 / 防抖:自动触发时若配置了延迟,则延后执行, // 【V2.1.1】分步填表触发延迟 / 防抖:自动触发时若配置了延迟,则延后执行,
// 延迟期内再次到来的事件会重置计时器,避免消息连续到达时重复拉起填表。 // 延迟期内再次到来的事件会重置计时器,避免消息连续到达时重复拉起填表。
// 注意:防抖与早返路径都不持锁,避免 setTimeout 回调撞上自己的锁导致死锁。
const delay = Math.max(0, parseInt(settings.secondary_filler_delay || 0, 10)); const delay = Math.max(0, parseInt(settings.secondary_filler_delay || 0, 10));
if (!forceRun && delay > 0) { if (!forceRun && delay > 0) {
if (secondaryFillerDebounceTimer) { if (secondaryFillerDebounceTimer) {
@@ -122,7 +136,7 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
} }
secondaryFillerDebounceTimer = setTimeout(() => { secondaryFillerDebounceTimer = setTimeout(() => {
secondaryFillerDebounceTimer = null; secondaryFillerDebounceTimer = null;
fillWithSecondaryApi(latestMessage, forceRun); fillWithSecondaryApi(latestMessage, forceRun, opts);
}, delay); }, delay);
console.log(`[Amily2-副API] 分步填表已按防抖延迟 ${delay}ms 调度。`); console.log(`[Amily2-副API] 分步填表已按防抖延迟 ${delay}ms 调度。`);
return; return;
@@ -157,32 +171,24 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
return; return;
} }
// 所有早返检查通过后再获取锁,确保 finally 一定能解锁
secondaryFillerRunning = true;
currentAbortController = new AbortController();
const signal = currentAbortController.signal;
try { try {
const bufferSize = parseInt(settings.secondary_filler_buffer || 0, 10); 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); const contextLimit = parseInt(settings.secondary_filler_context || 2, 10);
// 【V1.7.7 修复】限制最大回溯深度,防止更新后无限填补旧历史 // 【V1.7.7 修复】限制最大回溯深度,防止更新后无限填补旧历史
// 响应用户反馈:扫描深度 = 上下文 + 填表批次 + 保留楼层 + 冗余量(10) // 扫描深度 = 上下文 + 填表批次 + 冗余量(10)
// redundancy (冗余量): 额外扫描 10 层作为安全缓冲,防止因消息索引计算偏差导致漏掉边缘消息 // bufferSize保留楼层仅用于限定尾部边界 validEndIndex
// 不再回流到扫描起点,避免重复影响范围
const redundancy = 10; const redundancy = 10;
const maxScanDepth = contextLimit + batchSize + bufferSize + redundancy; const maxScanDepth = contextLimit + batchSize + redundancy;
const chat = context.chat; const chat = context.chat;
const totalMessages = chat.length; 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) => { const getContentHash = (content) => {
let hash = 0, i, chr; let hash = 0, i, chr;
@@ -190,45 +196,74 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
for (i = 0; i < content.length; i++) { for (i = 0; i < content.length; i++) {
chr = content.charCodeAt(i); chr = content.charCodeAt(i);
hash = ((hash << 5) - hash) + chr; hash = ((hash << 5) - hash) + chr;
hash |= 0; hash |= 0;
} }
return hash; return hash;
}; };
// 【修复】改为正向扫描,优先处理最老的未处理消息,防止遗留消息被挤出扫描区 let targetMessages = [];
for (let i = scanStartIndex; i <= validEndIndex; i++) {
const msg = chat[i];
if (msg.is_user) continue;
const currentHash = getContentHash(msg.mes); // 【SWIPED 旁路】swipe 后强制处理刚切出来的最新消息:
const savedHash = msg.metadata?.Amily2_Process_Hash; // 跳过扫描 / bufferSize / batchSize 累积逻辑,直接锁定目标
if (opts.targetMessage) {
const isUnprocessed = !savedHash; const targetIndex = chat.indexOf(opts.targetMessage);
const isChanged = savedHash && savedHash !== currentHash; if (targetIndex < 0) {
console.log("[Amily2-副API] 旁路目标消息不在聊天列表中,跳过。");
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} 条未处理消息,暂不触发。`);
return; return;
} }
if (opts.targetMessage.is_user) {
console.log("[Amily2-副API] 旁路目标是用户消息,跳过。");
return;
}
targetMessages.push({
index: targetIndex,
msg: opts.targetMessage,
hash: getContentHash(opts.targetMessage.mes),
});
} else { } 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}`); console.log(`[Amily2-副API] 触发填表: 处理 ${targetMessages.length} 条消息。索引范围: ${targetMessages[0].index} - ${targetMessages[targetMessages.length-1].index}`);
@@ -338,17 +373,27 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
if (settings.tableFillFunctionCall) { if (settings.tableFillFunctionCall) {
// Function Call 路径 // Function Call 路径
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling' }); const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling', signal });
if (!argsString) { if (!argsString) {
console.error('[Amily2-副API] Function Call 返回为空。'); console.error('[Amily2-副API] Function Call 返回为空。');
return; return;
} }
const ops = parseToolCallArgs(argsString); const ops = parseToolCallArgs(argsString);
if (ops.length === 0) { if (ops.length === 0) {
console.warn('[Amily2-副API] Function Call 返回操作列表为空,无需变更。'); 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-分步填表'); toastr.info('AI 判断此范围无需修改。', 'Amily2-分步填表');
await markTargetsProcessed(targetMessages, { skipTableSave: true });
} else { } else {
await updateTableFromOps(ops); await updateTableFromOps(ops);
await markTargetsProcessed(targetMessages);
toastr.success('分步填表Function Call执行完毕。', 'Amily2-分步填表'); toastr.success('分步填表Function Call执行完毕。', 'Amily2-分步填表');
} }
} else { } else {
@@ -356,10 +401,10 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
let rawContent; let rawContent;
if (settings.nccsEnabled) { if (settings.nccsEnabled) {
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...'); console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
rawContent = await callNccsAI(messages); rawContent = await callNccsAI(messages, { signal });
} else { } else {
console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...'); console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...');
rawContent = await callAI(messages, { slot: 'tableFilling' }); rawContent = await callAI(messages, { slot: 'tableFilling', signal });
} }
if (!rawContent) { if (!rawContent) {
@@ -373,8 +418,8 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
const rangeLabel = `${targetMessages[0].index + 1} - ${targetMessages[targetMessages.length - 1].index + 1}`; const rangeLabel = `${targetMessages[0].index + 1} - ${targetMessages[targetMessages.length - 1].index + 1}`;
console.warn(`[Amily2-副API] 响应未包含 <Amily2Edit> 指令块(楼层 ${rangeLabel}),弹出检查窗口等待用户处理。`); console.warn(`[Amily2-副API] 响应未包含 <Amily2Edit> 指令块(楼层 ${rangeLabel}),弹出检查窗口等待用户处理。`);
toastr.warning(`分步填表(楼层 ${rangeLabel})的响应缺少 <Amily2Edit> 指令块,请在弹窗中处理。`, 'Amily2-分步填表'); toastr.warning(`分步填表(楼层 ${rangeLabel})的响应缺少 <Amily2Edit> 指令块,请在弹窗中处理。`, 'Amily2-分步填表');
if (latestMessage && latestMessage.metadata) { if (latestMessage && latestMessage.extra) {
delete latestMessage.metadata.Amily2_Retry_Count; delete latestMessage.extra.amily2_retry_count;
} }
showTableFillReviewModal(rawContent, { showTableFillReviewModal(rawContent, {
title: `分步填表响应检查 - 楼层 ${rangeLabel}`, title: `分步填表响应检查 - 楼层 ${rangeLabel}`,
@@ -389,12 +434,12 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
} }
return merged; return merged;
}, },
onApply: (editedText) => { onApply: async (editedText) => {
if (!editedText || !editedText.includes('<Amily2Edit>')) { if (!editedText || !editedText.includes('<Amily2Edit>')) {
toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用'); toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用');
} }
try { try {
commitSecondaryFillResult(editedText, targetMessages); await commitSecondaryFillResult(editedText, targetMessages);
toastr.success('分步填表已由用户手动处理完成。', 'Amily2-分步填表'); toastr.success('分步填表已由用户手动处理完成。', 'Amily2-分步填表');
} catch (err) { } catch (err) {
console.error('[Amily2-副API] 手动应用失败:', err); console.error('[Amily2-副API] 手动应用失败:', err);
@@ -402,11 +447,11 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
} }
}, },
onRetry: () => { onRetry: () => {
if (latestMessage && latestMessage.metadata) { if (latestMessage && latestMessage.extra) {
delete latestMessage.metadata.Amily2_Retry_Count; delete latestMessage.extra.amily2_retry_count;
} }
toastr.info('将重新执行分步填表...', 'Amily2-分步填表'); toastr.info('将重新执行分步填表...', 'Amily2-分步填表');
setTimeout(() => fillWithSecondaryApi(latestMessage, forceRun), 300); setTimeout(() => fillWithSecondaryApi(latestMessage, forceRun, opts), 300);
}, },
onCancel: () => { onCancel: () => {
toastr.info('已取消本次分步填表。', 'Amily2-分步填表'); toastr.info('已取消本次分步填表。', 'Amily2-分步填表');
@@ -415,43 +460,83 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
return; return;
} }
commitSecondaryFillResult(rawContent, targetMessages); await commitSecondaryFillResult(rawContent, targetMessages);
} }
toastr.success("分步填表执行完毕。", "Amily2-分步填表"); toastr.success("分步填表执行完毕。", "Amily2-分步填表");
} catch (error) { } 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); console.error(`[Amily2-副API] 发生严重错误:`, error);
// 【新增】自定义重试逻辑 // 【新增】自定义重试逻辑
const maxRetries = parseInt(settings.secondary_filler_max_retries || 0, 10); 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) { if (currentRetryCount < maxRetries) {
const nextRetryCount = currentRetryCount + 1; const nextRetryCount = currentRetryCount + 1;
console.log(`[Amily2-副API] 准备进行第 ${nextRetryCount}/${maxRetries} 次重试...`); console.log(`[Amily2-副API] 准备进行第 ${nextRetryCount}/${maxRetries} 次重试...`);
toastr.warning(`副API填表失败: ${error.message}。将在3秒后进行第 ${nextRetryCount} 次重试...`, "自动重试"); toastr.warning(`副API填表失败: ${error.message}。将在3秒后进行第 ${nextRetryCount} 次重试...`, "自动重试");
// 记录重试次数到最新消息的 metadata 中,以便跨调用传递状态 // 记录重试次数到最新消息的 extra 中,以便跨调用传递状态(跟 amily2_tables_data 一起持久化)
if (latestMessage) { if (latestMessage) {
if (!latestMessage.metadata) latestMessage.metadata = {}; if (!latestMessage.extra) latestMessage.extra = {};
latestMessage.metadata.Amily2_Retry_Count = nextRetryCount; latestMessage.extra.amily2_retry_count = nextRetryCount;
} }
setTimeout(() => { setTimeout(() => {
fillWithSecondaryApi(latestMessage, forceRun); fillWithSecondaryApi(latestMessage, forceRun, opts);
}, 3000); }, 3000);
} else { } else {
console.log(`[Amily2-副API] 已达到最大重试次数 (${maxRetries}),放弃本次填表。`); console.log(`[Amily2-副API] 已达到最大重试次数 (${maxRetries}),放弃本次填表。`);
toastr.error(`副API填表失败: ${error.message}。已达到最大重试次数,任务终止。`, "严重错误"); toastr.error(`副API填表失败: ${error.message}。已达到最大重试次数,任务终止。`, "严重错误");
// 清除重试计数器 // 清除重试计数器
if (latestMessage && latestMessage.metadata) { if (latestMessage && latestMessage.extra) {
delete latestMessage.metadata.Amily2_Retry_Count; 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) { async function getHistoryContext(messagesToFetch, historyEndIndex, tagsToExtract, exclusionRules) {
const context = getContext(); const context = getContext();
const chat = context.chat; const chat = context.chat;

View File

@@ -161,4 +161,7 @@ export const tableSystemDefaultSettings = {
// Function Call 填表 // Function Call 填表
tableFillFunctionCall: false, tableFillFunctionCall: false,
// 批量填表每批楼层数
batch_filling_threshold: 30,
}; };

View File

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

View File

@@ -23,10 +23,11 @@ export { characters, this_chid, eventSource, event_types, saveSettingsDebounced
// Core Systems // Core Systems
export { injectTableData, generateTableContent } from "./core/table-system/injector.js"; 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 { initialize as initializeRagProcessor } from "./core/rag-processor.js";
export { loadSettingsToUI as loadHanlinyuanSettingsToUI } from "./ui/hanlinyuan-bindings.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 { 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 { renderTables } from './ui/table-bindings.js';
export { log } from './core/table-system/logger.js'; export { log } from './core/table-system/logger.js';
export { checkForUpdates, fetchMessageBoardContent } from './core/api.js'; export { checkForUpdates, fetchMessageBoardContent } from './core/api.js';

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import { executeManualCommand } from '../core/autoHideManager.js';
import { showContentModal, showHtmlModal, showCwbWarningModal } from './page-window.js'; import { showContentModal, showHtmlModal, showCwbWarningModal } from './page-window.js';
import { openAutoCharCardWindow } from '../core/auto-char-card/ui-bindings.js'; import { openAutoCharCardWindow } from '../core/auto-char-card/ui-bindings.js';
import { showPresetSettings } from '../PresetSettings/prese_ui.js'; import { showPresetSettings } from '../PresetSettings/prese_ui.js';
import { watchProfileSliderGuard } from './profile-slider-guard.js';
function displayDailyAuthCode() { function displayDailyAuthCode() {
const displayEl = document.getElementById('amily2_daily_code_display'); const displayEl = document.getElementById('amily2_daily_code_display');
@@ -807,7 +808,7 @@ export function bindModalEvents() {
container container
.off("click.amily2.chamber_nav") .off("click.amily2.chamber_nav")
.on("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_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_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; if (!pluginAuthStatus.authorized) return;
const mainPanel = container.find('.plugin-features'); const mainPanel = container.find('.plugin-features');
@@ -821,6 +822,7 @@ export function bindModalEvents() {
const glossaryPanel = container.find('#amily2_glossary_panel'); const glossaryPanel = container.find('#amily2_glossary_panel');
const rendererPanel = container.find('#amily2_renderer_panel'); const rendererPanel = container.find('#amily2_renderer_panel');
const superMemoryPanel = container.find('#amily2_super_memory_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 apiConfigPanel = container.find('#amily2_api_config_panel');
const ruleConfigPanel = container.find('#amily2_rule_config_panel'); const ruleConfigPanel = container.find('#amily2_rule_config_panel');
const sfigenPanel = container.find('#amily2_sfigen_panel'); const sfigenPanel = container.find('#amily2_sfigen_panel');
@@ -836,6 +838,7 @@ export function bindModalEvents() {
glossaryPanel.hide(); glossaryPanel.hide();
rendererPanel.hide(); rendererPanel.hide();
superMemoryPanel.hide(); superMemoryPanel.hide();
progressiveMemoryPanel.hide();
apiConfigPanel.hide(); apiConfigPanel.hide();
ruleConfigPanel.hide(); ruleConfigPanel.hide();
sfigenPanel.hide(); sfigenPanel.hide();
@@ -853,6 +856,16 @@ export function bindModalEvents() {
} }
superMemoryPanel.show(); superMemoryPanel.show();
break; 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': case 'amily2_open_auto_char_card':
openAutoCharCardWindow(); openAutoCharCardWindow();
// 自动构建器是独立窗口,不需要隐藏主面板,或者根据需求决定 // 自动构建器是独立窗口,不需要隐藏主面板,或者根据需求决定
@@ -909,6 +922,7 @@ export function bindModalEvents() {
case 'amily2_back_to_main_from_glossary': case 'amily2_back_to_main_from_glossary':
case 'amily2_renderer_back_button': case 'amily2_renderer_back_button':
case 'amily2_back_to_main_from_super_memory': 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_api_config':
case 'amily2_back_to_main_from_rule_config': case 'amily2_back_to_main_from_rule_config':
case 'amily2_sfigen_back_to_main': case 'amily2_sfigen_back_to_main':
@@ -1119,6 +1133,9 @@ export function bindModalEvents() {
}, },
); );
// main 槽分配 profile 后,这两个参数由 profile 权威控制T-006 informational 化)
watchProfileSliderGuard('main', ['#amily2_max_tokens', '#amily2_temperature']);
const promptMap = { const promptMap = {
mainPrompt: "#amily2_main_prompt", mainPrompt: "#amily2_main_prompt",
systemPrompt: "#amily2_system_prompt", systemPrompt: "#amily2_system_prompt",

View File

@@ -651,6 +651,7 @@ export function loadSettingsToUI() {
// Rerank 设置 // Rerank 设置
document.getElementById('hly-rerank-enabled').checked = settings.rerank.enabled; 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-url').value = settings.rerank.url;
document.getElementById('hly-rerank-api-key').value = settings.rerank.apiKey; document.getElementById('hly-rerank-api-key').value = settings.rerank.apiKey;
const rerankModelSelect = document.getElementById('hly-rerank-model'); const rerankModelSelect = document.getElementById('hly-rerank-model');
@@ -674,7 +675,7 @@ export function loadSettingsToUI() {
const sources = ['novel', 'chat_history', 'lorebook', 'manual']; const sources = ['novel', 'chat_history', 'lorebook', 'manual'];
sources.forEach(source => { sources.forEach(source => {
const sourceSettings = prioritySettings.sources[source]; const sourceSettings = prioritySettings.sources?.[source];
if (sourceSettings) { if (sourceSettings) {
const enabledCheckbox = document.querySelector(`[data-setting-key="rerank.priorityRetrieval.sources.${source}.enabled"]`); const enabledCheckbox = document.querySelector(`[data-setting-key="rerank.priorityRetrieval.sources.${source}.enabled"]`);
const countInput = document.querySelector(`[data-setting-key="rerank.priorityRetrieval.sources.${source}.count"]`); const countInput = document.querySelector(`[data-setting-key="rerank.priorityRetrieval.sources.${source}.count"]`);
@@ -705,6 +706,11 @@ function saveSettingsFromUI(isAutoSave = true) {
const key = target.dataset.settingKey; const key = target.dataset.settingKey;
if (!key) return; 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; let value;
const type = target.dataset.type || 'string'; const type = target.dataset.type || 'string';
@@ -1009,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-up"></i></button>`
: `<button class="hly-kb-move-btn" title="下移到局部"><i class="fas fa-arrow-down"></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 = ` item.innerHTML = `
<div class="hly-kb-name-container"> <div class="hly-kb-name-container">
<input type="checkbox" class="hly-kb-item-checkbox" data-kb-id="${escapeAttribute(id)}"> <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>
<div class="hly-kb-actions"> <div class="hly-kb-actions">
${moveButtonHtml} ${moveButtonHtml}

View File

@@ -8,13 +8,24 @@
import { extension_settings, getContext } from "/scripts/extensions.js"; import { extension_settings, getContext } from "/scripts/extensions.js";
import { characters, this_chid, getRequestHeaders, saveSettingsDebounced, eventSource, event_types } from "/script.js"; import { characters, this_chid, getRequestHeaders, saveSettingsDebounced, eventSource, event_types } from "/script.js";
import { defaultSettings, extensionName } from "../utils/settings.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 { testConcurrentApiConnection, fetchConcurrentModels } from '../core/api/ConcurrentApi.js';
import { safeLorebooks, safeCharLorebooks, safeLorebookEntries } from "../core/tavernhelper-compatibility.js"; import { safeLorebooks, safeCharLorebooks, safeLorebookEntries } from "../core/tavernhelper-compatibility.js";
import { createDrawer } from '../ui/drawer.js'; import { createDrawer } from '../ui/drawer.js';
import { pluginAuthStatus } from "../utils/auth.js"; import { pluginAuthStatus } from "../utils/auth.js";
import { configManager } from '../utils/config/ConfigManager.js'; import { configManager } from '../utils/config/ConfigManager.js';
import { SENSITIVE_KEYS } from '../utils/config/sensitive-keys.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) ========== // ========== Prompt Cache (module-level state) ==========
@@ -45,30 +56,6 @@ function opt_toCamelCase(str) {
return str.replace(/[-_]([a-z])/g, (g) => g[1].toUpperCase()); 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) { function opt_updateWorldbookSourceVisibility(panel, source) {
const manualSelectionWrapper = panel.find('#amily2_opt_worldbook_select_wrapper'); const manualSelectionWrapper = panel.find('#amily2_opt_worldbook_select_wrapper');
if (source === 'manual') { 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 = [ const opt_characterSpecificSettings = [
'plotOpt_worldbookSource', 'plotOpt_worldbookSource',
@@ -640,27 +584,9 @@ function opt_loadSettings(panel) {
panel.find('#amily2_opt_table_enabled').val(tableEnabledValue); panel.find('#amily2_opt_table_enabled').val(tableEnabledValue);
panel.find('#amily2_opt_ejs_enabled').prop('checked', settings.plotOpt_ejsEnabled); 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(`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_worldbook_enabled').prop('checked', settings.plotOpt_worldbookEnabled);
panel.find('#amily2_opt_new_memory_logic_enabled').prop('checked', settings.plotOpt_newMemoryLogicEnabled); 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_top_p').val(settings.plotOpt_top_p);
panel.find('#amily2_opt_presence_penalty').val(settings.plotOpt_presence_penalty); panel.find('#amily2_opt_presence_penalty').val(settings.plotOpt_presence_penalty);
panel.find('#amily2_opt_frequency_penalty').val(settings.plotOpt_frequency_penalty); panel.find('#amily2_opt_frequency_penalty').val(settings.plotOpt_frequency_penalty);
@@ -690,7 +616,6 @@ function opt_loadSettings(panel) {
}, 0); }, 0);
} }
opt_updateApiUrlVisibility(panel, settings.plotOpt_apiMode);
opt_updateWorldbookSourceVisibility(panel, settings.plotOpt_worldbookSource || 'character'); opt_updateWorldbookSourceVisibility(panel, settings.plotOpt_worldbookSource || 'character');
opt_bindSlider(panel, '#amily2_opt_top_p', '#amily2_opt_top_p_value'); opt_bindSlider(panel, '#amily2_opt_top_p', '#amily2_opt_top_p_value');
@@ -703,7 +628,6 @@ function opt_loadSettings(panel) {
opt_loadWorldbookEntries(panel); opt_loadWorldbookEntries(panel);
}); });
opt_loadTavernApiProfiles(panel);
} }
@@ -713,6 +637,9 @@ function bindConcurrentApiEvents() {
if (!concurrentToggle || !concurrentContent) return; if (!concurrentToggle || !concurrentContent) return;
// plotOptConc 槽分配 profile 后maxTokens 由 profile 权威控制T-006 informational 化)
watchProfileSliderGuard('plotOptConc', ['#amily2_plotOpt_concurrentMaxTokens']);
const settings = extension_settings[extensionName] || {}; const settings = extension_settings[extensionName] || {};
// Initial Load // 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() { export function initializePlotOptimizationBindings() {
const panel = $('#amily2_plot_optimization_panel'); const panel = $('#amily2_plot_optimization_panel');
if (panel.length === 0 || panel.data('events-bound')) { if (panel.length === 0 || panel.data('events-bound')) {
@@ -1167,6 +1268,7 @@ export function initializePlotOptimizationBindings() {
}); });
opt_loadSettings(panel); opt_loadSettings(panel);
bindCustomBlockEvents(panel);
bindJqyhApiEvents(); bindJqyhApiEvents();
bindConcurrentApiEvents(); bindConcurrentApiEvents();
bindConcurrentPromptEvents(); bindConcurrentPromptEvents();
@@ -1219,17 +1321,13 @@ export function initializePlotOptimizationBindings() {
opt_saveSetting(key, value); opt_saveSetting(key, value);
} }
if (key === 'plotOpt_api_mode') {
opt_updateApiUrlVisibility(panel, value);
}
if (element.name === 'amily2_opt_worldbook_source') { if (element.name === 'amily2_opt_worldbook_source') {
opt_updateWorldbookSourceVisibility(panel, value); opt_updateWorldbookSourceVisibility(panel, value);
opt_loadWorldbookEntries(panel); opt_loadWorldbookEntries(panel);
} }
}; };
const allInputSelectors = [ 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="text"]', 'input[type="password"]', 'textarea',
'input[type="range"]', 'input[type="number"]' 'input[type="range"]', 'input[type="number"]'
].join(', '); ].join(', ');
@@ -1238,30 +1336,6 @@ export function initializePlotOptimizationBindings() {
handleSettingChange(this); 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_import_prompt_presets').on('click', () => panel.find('#amily2_opt_preset_file_input').click());
panel.find('#amily2_opt_export_prompt_presets').on('click', () => opt_exportPromptPresets()); 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() { function bindJqyhApiEvents() {
console.log("[Amily2号-Jqyh工部] 正在绑定Jqyh API事件..."); // Jqyh 直连配置已移除,剧情优化统一走 ApiProfile plotOpt 槽位
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>';
}
} }
// ========== 图标位置切换(跨模块通用事件) ========== // ========== 图标位置切换(跨模块通用事件) ==========

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: { ragRerank: {
container: '#hly-rerank-tab .hly-settings-group', container: '#hly-rerank-tab .hly-settings-group',
hideParentBlock: ['#hly-rerank-api-mode', '#hly-rerank-url', '#hly-rerank-api-key', '#hly-rerank-model'], 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', keyField: '#hly-rerank-api-key',
testFn: async () => { testFn: async () => {
await executeRagRerank('test', ['test'], null); await executeRagRerank('test', ['test'], null);
@@ -210,7 +210,15 @@ function _snapshotLegacyFields(slot, config) {
function _fillLegacyFields(config, profile) { function _fillLegacyFields(config, profile) {
for (const [key, sel] of Object.entries(config.fields || {})) { for (const [key, sel] of Object.entries(config.fields || {})) {
const el = document.querySelector(sel); 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) { if (config.keyField) {
const keyEl = document.querySelector(config.keyField); const keyEl = document.querySelector(config.keyField);
@@ -284,11 +292,22 @@ function _injectCard(slot, profile, _config, container) {
), ),
].join(''); ].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 ? ` const detailHtml = profile ? `
<span style="color:var(--SmartThemeQuoteColor); font-size:0.85em;"> <span style="color:var(--SmartThemeQuoteColor); font-size:0.85em;">
${providerLabel ? `<i class="fas fa-cloud"></i> ${_esc(providerLabel)}` : ''} ${providerLabel ? `<i class="fas fa-cloud"></i> ${_esc(providerLabel)}` : ''}
${profile.model ? ` · <i class="fas fa-robot"></i> ${_esc(profile.model)}` : ''} ${profile.model ? ` · <i class="fas fa-robot"></i> ${_esc(profile.model)}` : ''}
</span> </span>
${keyWarnHtml}
` : ` ` : `
<span style="color:var(--warning-color); font-size:0.85em;"> <span style="color:var(--warning-color); font-size:0.85em;">
未分配时该模块不会继续展示/保存独立 API 输入项。 未分配时该模块不会继续展示/保存独立 API 输入项。

View File

@@ -5,6 +5,7 @@ import { extensionName } from '../utils/settings.js';
import { updateOrInsertTableInChat } from './message-table-renderer.js'; import { updateOrInsertTableInChat } from './message-table-renderer.js';
import { saveSettingsDebounced } from '/script.js'; import { saveSettingsDebounced } from '/script.js';
import { startBatchFilling } from '../core/table-system/batch-filler.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 { showHtmlModal } from './page-window.js';
import { DEFAULT_AI_RULE_TEMPLATE, DEFAULT_AI_FLOW_TEMPLATE } from '../core/table-system/settings.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'; import { world_names, loadWorldInfo } from '/scripts/world-info.js';
@@ -1370,8 +1371,9 @@ export function bindTableEvents(panelElement = null) {
const contextSlider = document.getElementById('secondary-filler-context'); const contextSlider = document.getElementById('secondary-filler-context');
const batchSlider = document.getElementById('secondary-filler-batch'); const batchSlider = document.getElementById('secondary-filler-batch');
const bufferSlider = document.getElementById('secondary-filler-buffer'); 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 delaySlider = document.getElementById('secondary-filler-delay');
const batchFillingThresholdInput = document.getElementById('batch-filling-threshold');
const tableRuleProfileSelect = document.getElementById('table-rule-profile-select'); const tableRuleProfileSelect = document.getElementById('table-rule-profile-select');
@@ -1458,6 +1460,58 @@ export function bindTableEvents(panelElement = null) {
}); });
} }
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'); const fcToggle = document.getElementById('table-fill-function-call-enabled');
if (fcToggle) { if (fcToggle) {
fcToggle.checked = extension_settings[extensionName]?.tableFillFunctionCall ?? false; fcToggle.checked = extension_settings[extensionName]?.tableFillFunctionCall ?? false;

File diff suppressed because one or more lines are too long

View File

@@ -220,6 +220,8 @@ class ApiProfileManager {
} }
this._assignments()[slot] = profileId; this._assignments()[slot] = profileId;
this._save(); this._save();
// 通知各模块面板刷新 profile 压制状态(见 ui/profile-slider-guard.js
document.dispatchEvent(new CustomEvent('amily2-profile-assignment-changed', { detail: { slot, profileId } }));
return true; return true;
} }
@@ -354,7 +356,8 @@ function _detectVendorFromUrlSync(url) {
/** /**
* 每个 slot 的 legacy 字段映射。jqyh 已合并到 plotOpt 不单独迁移。 * 每个 slot 的 legacy 字段映射。jqyh 已合并到 plotOpt 不单独迁移。
* cwb / autoCharCard / ragEmbed / ragRerank 字段结构差异较大,留作后续。 * autoCharCard 字段是嵌套对象acc_executor_config / acc_planner_config
* 不走此平铺映射,在迁移 IIFE 里单独处理ragEmbed / ragRerank 留作后续。
*/ */
const LEGACY_PROFILE_MIGRATION_MAP = [ const LEGACY_PROFILE_MIGRATION_MAP = [
{ {
@@ -411,13 +414,32 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
temperatureKey: 'sybdTemperature', temperatureKey: 'sybdTemperature',
name: 'SYBD 旧配置', 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 () => { ;(async () => {
try { try {
const s = extension_settings[extensionName]; const s = extension_settings[extensionName];
if (!s) return; 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 = []; const migrated = [];
for (const m of LEGACY_PROFILE_MIGRATION_MAP) { for (const m of LEGACY_PROFILE_MIGRATION_MAP) {
@@ -427,6 +449,8 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
const url = String(s[m.urlKey] ?? '').trim(); const url = String(s[m.urlKey] ?? '').trim();
const model = String(s[m.modelKey] ?? '').trim(); const model = String(s[m.modelKey] ?? '').trim();
if (!url || !model) continue; // 旧配置不完整,跳过 if (!url || !model) continue; // 旧配置不完整,跳过
// 模块运行在 ST 预设模式时url/model 是切换模式前的残留,迁成权威 profile 会改变行为
if (m.modeKey && s[m.modeKey] === 'sillytavern_preset') continue;
const provider = _detectVendorFromUrlSync(url) || 'custom_oai'; const provider = _detectVendorFromUrlSync(url) || 'custom_oai';
@@ -452,6 +476,40 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
migrated.push(`${m.slot}${profileId}`); 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 // 新引入的 slot无 legacy 字段可迁移)默认借用其他 slot 的 profile
// 让升级用户的功能不至于因为没主动分配而中断。用户可以随后改成专属 profile。 // 让升级用户的功能不至于因为没主动分配而中断。用户可以随后改成专属 profile。
const SLOT_INHERITANCE = { const SLOT_INHERITANCE = {
@@ -467,7 +525,8 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
} }
} }
s._legacyProfileMigrationDone = true; s._legacyProfileMigrationDone = true; // 兼容旧版本读取
s._legacyProfileMigrationVersion = LEGACY_MIGRATION_VERSION;
saveSettingsDebounced(); saveSettingsDebounced();
if (migrated.length > 0 || linked.length > 0) { if (migrated.length > 0 || linked.length > 0) {
@@ -524,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 等) // 全套 legacy 字段(含 maxTokens / temperature / apiMode / tavernProfile / fakeStream / enabled 等)
const ALL_LEGACY_FIELDS = { const ALL_LEGACY_FIELDS = {
main: ['apiUrl', 'model', 'maxTokens', 'temperature', 'apiProvider', 'tavernProfile'], main: ['apiUrl', 'model', 'maxTokens', 'temperature', 'apiProvider', 'tavernProfile'],
@@ -532,6 +602,9 @@ export function clearLegacyConfig() {
ngms: ['ngmsApiUrl', 'ngmsModel', 'ngmsApiMode', 'ngmsTavernProfile', 'ngmsMaxTokens', 'ngmsTemperature', 'ngmsFakeStreamEnabled'], ngms: ['ngmsApiUrl', 'ngmsModel', 'ngmsApiMode', 'ngmsTavernProfile', 'ngmsMaxTokens', 'ngmsTemperature', 'ngmsFakeStreamEnabled'],
nccs: ['nccsApiUrl', 'nccsModel', 'nccsApiMode', 'nccsTavernProfile', 'nccsMaxTokens', 'nccsTemperature', 'nccsFakeStreamEnabled'], nccs: ['nccsApiUrl', 'nccsModel', 'nccsApiMode', 'nccsTavernProfile', 'nccsMaxTokens', 'nccsTemperature', 'nccsFakeStreamEnabled'],
sybd: ['sybdApiUrl', 'sybdModel', 'sybdApiMode', 'sybdTavernProfile', 'sybdMaxTokens', 'sybdTemperature'], 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 字段也清掉(已合并到 plotOpt 但残留可能还在)
jqyh: ['jqyhApiUrl', 'jqyhModel', 'jqyhApiMode', 'jqyhTavernProfile', 'jqyhMaxTokens', 'jqyhTemperature', 'jqyhEnabled'], jqyh: ['jqyhApiUrl', 'jqyhModel', 'jqyhApiMode', 'jqyhTavernProfile', 'jqyhMaxTokens', 'jqyhTemperature', 'jqyhEnabled'],
}; };
@@ -543,6 +616,7 @@ export function clearLegacyConfig() {
ngms: 'ngmsApiKey', ngms: 'ngmsApiKey',
nccs: 'nccsApiKey', nccs: 'nccsApiKey',
sybd: 'sybdApiKey', sybd: 'sybdApiKey',
cwb: 'cwb_api_key',
jqyh: 'jqyhApiKey', jqyh: 'jqyhApiKey',
}; };

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_0x268319=a0_0x5693;function a0_0x8a12(){const _0x4b3d77=['W6FcN8odWQLZD1NcL8oGEq','W7CpW6umW5/cN8odi8kwW7xdN2a','W4JdOgiKW6XDgSo8W5tcHfy','nuDxWQ4snN3cMW','gXvivsiVkmozkCoE','gr1mw35Emmo/c8omiGe','jCoJWRnOsSoyW7pcH1aCl8kA','W441kxumW5xdIKFdSxddRv3dJCoV','W7ldPmkKj8k/W58Hwmonua','rYbBWRHRW7BcU8oAWRtdUGOL','b1dcSSkbFr5dW6JdRCkE','WOCxFSkkWQDwimo1eKKpbW','W7ZcTCojCGv/','sIzsWRrSW7tcNmo8WRddRWCf','WOtdNqpcGuumWRHdrb3dVSoF','WRf6WRvhW6uiWOyouM5VWO7dPaBcVmklWP5CtJ9FeSk8WQG','WQ/dSmkzcfm3BbBcK8kVfr4','WRCWESoIvxldPdpcTCkvWR4','WOfHWPdcSt7dLSo4uvBdSSoLW5KVW4K','aCoeWPFdQrLdmddcSJv2CmoMCq','qr3dO8oqdbrdW4ddSSkXtq','W4KCuSoUqqxcQHJcNra','WPJcPJjjW4r1hSo0W5BcQa','W4OucCkrurJcLZW','fCkBWPhdTCkqsCoCW5lcRSoJ','aZ0egqFdJmkHWRX+WOW'];a0_0x8a12=function(){return _0x4b3d77;};return a0_0x8a12();}function a0_0x5693(_0x3933a1,_0xea6e26){_0x3933a1=_0x3933a1-0x147;const _0x8a1284=a0_0x8a12();let _0x569399=_0x8a1284[_0x3933a1];if(a0_0x5693['dlauWq']===undefined){var _0x324cea=function(_0x42976b){const _0x3f6c7e='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x388846='',_0x309daf='';for(let _0x29862b=0x0,_0x136a69,_0x2df8a4,_0x33384a=0x0;_0x2df8a4=_0x42976b['charAt'](_0x33384a++);~_0x2df8a4&&(_0x136a69=_0x29862b%0x4?_0x136a69*0x40+_0x2df8a4:_0x2df8a4,_0x29862b++%0x4)?_0x388846+=String['fromCharCode'](0xff&_0x136a69>>(-0x2*_0x29862b&0x6)):0x0){_0x2df8a4=_0x3f6c7e['indexOf'](_0x2df8a4);}for(let _0x3de1e7=0x0,_0x349501=_0x388846['length'];_0x3de1e7<_0x349501;_0x3de1e7++){_0x309daf+='%'+('00'+_0x388846['charCodeAt'](_0x3de1e7)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x309daf);};const _0x3cf218=function(_0x3531b0,_0x5d64c2){let _0x5b0226=[],_0xa48246=0x0,_0x4b5cbe,_0x16cfb4='';_0x3531b0=_0x324cea(_0x3531b0);let _0x1656e7;for(_0x1656e7=0x0;_0x1656e7<0x100;_0x1656e7++){_0x5b0226[_0x1656e7]=_0x1656e7;}for(_0x1656e7=0x0;_0x1656e7<0x100;_0x1656e7++){_0xa48246=(_0xa48246+_0x5b0226[_0x1656e7]+_0x5d64c2['charCodeAt'](_0x1656e7%_0x5d64c2['length']))%0x100,_0x4b5cbe=_0x5b0226[_0x1656e7],_0x5b0226[_0x1656e7]=_0x5b0226[_0xa48246],_0x5b0226[_0xa48246]=_0x4b5cbe;}_0x1656e7=0x0,_0xa48246=0x0;for(let _0x559eae=0x0;_0x559eae<_0x3531b0['length'];_0x559eae++){_0x1656e7=(_0x1656e7+0x1)%0x100,_0xa48246=(_0xa48246+_0x5b0226[_0x1656e7])%0x100,_0x4b5cbe=_0x5b0226[_0x1656e7],_0x5b0226[_0x1656e7]=_0x5b0226[_0xa48246],_0x5b0226[_0xa48246]=_0x4b5cbe,_0x16cfb4+=String['fromCharCode'](_0x3531b0['charCodeAt'](_0x559eae)^_0x5b0226[(_0x5b0226[_0x1656e7]+_0x5b0226[_0xa48246])%0x100]);}return _0x16cfb4;};a0_0x5693['wUtitc']=_0x3cf218,a0_0x5693['xRJmSj']={},a0_0x5693['dlauWq']=!![];}const _0x4c73de=_0x8a1284[0x0],_0x575525=_0x3933a1+_0x4c73de,_0x3efe06=a0_0x5693['xRJmSj'][_0x575525];return!_0x3efe06?(a0_0x5693['vVRsaE']===undefined&&(a0_0x5693['vVRsaE']=!![]),_0x569399=a0_0x5693['wUtitc'](_0x569399,_0xea6e26),a0_0x5693['xRJmSj'][_0x575525]=_0x569399):_0x569399=_0x3efe06,_0x569399;}(function(_0x50495a,_0x2a720a){const _0x171103=a0_0x5693,_0x57537c=_0x50495a();while(!![]){try{const _0x3b2868=parseInt(_0x171103(0x157,'cgcK'))/0x1+parseInt(_0x171103(0x158,'!5Ct'))/0x2+-parseInt(_0x171103(0x148,'(Y7&'))/0x3+parseInt(_0x171103(0x150,'UhGz'))/0x4*(-parseInt(_0x171103(0x15e,'mrSR'))/0x5)+-parseInt(_0x171103(0x152,'y7Py'))/0x6+-parseInt(_0x171103(0x160,'qd^W'))/0x7*(-parseInt(_0x171103(0x14b,'vmM3'))/0x8)+parseInt(_0x171103(0x14e,'hha5'))/0x9;if(_0x3b2868===_0x2a720a)break;else _0x57537c['push'](_0x57537c['shift']());}catch(_0x3dac2a){_0x57537c['push'](_0x57537c['shift']());}}}(a0_0x8a12,0xaa890));export const SENSITIVE_KEYS=new Set([a0_0x268319(0x153,'cgcK'),a0_0x268319(0x15a,'gTlo'),a0_0x268319(0x156,'AWXX'),a0_0x268319(0x147,'YRLz'),a0_0x268319(0x15f,'AeKu'),a0_0x268319(0x14f,'[eDQ'),a0_0x268319(0x149,'ocy2'),a0_0x268319(0x151,')Ma)')]); function a0_0x4c31(_0x4bee5f,_0x496717){_0x4bee5f=_0x4bee5f-0x16d;const _0x388542=a0_0x3885();let _0x4c3149=_0x388542[_0x4bee5f];if(a0_0x4c31['ZDMnSF']===undefined){var _0x23d44d=function(_0x1d8643){const _0x127b22='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x2ad9dd='',_0x158ce3='';for(let _0x2912d0=0x0,_0x4789ba,_0x582c8e,_0x1b92c0=0x0;_0x582c8e=_0x1d8643['charAt'](_0x1b92c0++);~_0x582c8e&&(_0x4789ba=_0x2912d0%0x4?_0x4789ba*0x40+_0x582c8e:_0x582c8e,_0x2912d0++%0x4)?_0x2ad9dd+=String['fromCharCode'](0xff&_0x4789ba>>(-0x2*_0x2912d0&0x6)):0x0){_0x582c8e=_0x127b22['indexOf'](_0x582c8e);}for(let _0x402384=0x0,_0x4e4818=_0x2ad9dd['length'];_0x402384<_0x4e4818;_0x402384++){_0x158ce3+='%'+('00'+_0x2ad9dd['charCodeAt'](_0x402384)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x158ce3);};const _0x145b18=function(_0x246448,_0x11912a){let _0x12e36a=[],_0x56c407=0x0,_0x18865e,_0x2fca19='';_0x246448=_0x23d44d(_0x246448);let _0x320ce0;for(_0x320ce0=0x0;_0x320ce0<0x100;_0x320ce0++){_0x12e36a[_0x320ce0]=_0x320ce0;}for(_0x320ce0=0x0;_0x320ce0<0x100;_0x320ce0++){_0x56c407=(_0x56c407+_0x12e36a[_0x320ce0]+_0x11912a['charCodeAt'](_0x320ce0%_0x11912a['length']))%0x100,_0x18865e=_0x12e36a[_0x320ce0],_0x12e36a[_0x320ce0]=_0x12e36a[_0x56c407],_0x12e36a[_0x56c407]=_0x18865e;}_0x320ce0=0x0,_0x56c407=0x0;for(let _0x30f34e=0x0;_0x30f34e<_0x246448['length'];_0x30f34e++){_0x320ce0=(_0x320ce0+0x1)%0x100,_0x56c407=(_0x56c407+_0x12e36a[_0x320ce0])%0x100,_0x18865e=_0x12e36a[_0x320ce0],_0x12e36a[_0x320ce0]=_0x12e36a[_0x56c407],_0x12e36a[_0x56c407]=_0x18865e,_0x2fca19+=String['fromCharCode'](_0x246448['charCodeAt'](_0x30f34e)^_0x12e36a[(_0x12e36a[_0x320ce0]+_0x12e36a[_0x56c407])%0x100]);}return _0x2fca19;};a0_0x4c31['acniYk']=_0x145b18,a0_0x4c31['JEEKRR']={},a0_0x4c31['ZDMnSF']=!![];}const _0x6ec022=_0x388542[0x0],_0x154932=_0x4bee5f+_0x6ec022,_0x1a588f=a0_0x4c31['JEEKRR'][_0x154932];return!_0x1a588f?(a0_0x4c31['YHeDZF']===undefined&&(a0_0x4c31['YHeDZF']=!![]),_0x4c3149=a0_0x4c31['acniYk'](_0x4c3149,_0x496717),a0_0x4c31['JEEKRR'][_0x154932]=_0x4c3149):_0x4c3149=_0x1a588f,_0x4c3149;}const a0_0x16cc87=a0_0x4c31;(function(_0xd51fb6,_0x7b0f19){const _0x2b4e00=a0_0x4c31,_0xa7de71=_0xd51fb6();while(!![]){try{const _0x15978a=parseInt(_0x2b4e00(0x171,'@bOT'))/0x1*(-parseInt(_0x2b4e00(0x174,'xH1['))/0x2)+-parseInt(_0x2b4e00(0x179,'Qn8O'))/0x3+-parseInt(_0x2b4e00(0x176,'ZgZG'))/0x4+-parseInt(_0x2b4e00(0x184,'NNwJ'))/0x5+-parseInt(_0x2b4e00(0x183,'aUBI'))/0x6*(parseInt(_0x2b4e00(0x18a,'5n2T'))/0x7)+parseInt(_0x2b4e00(0x177,'014B'))/0x8*(-parseInt(_0x2b4e00(0x185,'jX1y'))/0x9)+parseInt(_0x2b4e00(0x189,'!OzC'))/0xa*(parseInt(_0x2b4e00(0x17e,'2pJk'))/0xb);if(_0x15978a===_0x7b0f19)break;else _0xa7de71['push'](_0xa7de71['shift']());}catch(_0x322320){_0xa7de71['push'](_0xa7de71['shift']());}}}(a0_0x3885,0x42fa0));export const SENSITIVE_KEYS=new Set([a0_0x16cc87(0x186,'NNwJ'),a0_0x16cc87(0x178,'v(4z'),a0_0x16cc87(0x188,'PZ7o'),a0_0x16cc87(0x17c,'2pJk'),a0_0x16cc87(0x187,'aUBI'),a0_0x16cc87(0x180,'82%$'),a0_0x16cc87(0x17a,'YPyP'),a0_0x16cc87(0x17b,'vE4D')]);function a0_0x3885(){const _0x2c73e8=['W4ypW5XcW7nzW6vlA8o+W5pcPLO','W4ldVuNcO8oXWOrZWQ97W7Whm8k8','WPvlWO0/WQav','W5mLW6zUW7rZmSoxWR/dNW','fCksiCkFWQnfW78qW43dMgVdRHnsBmkmm1lcMHS4W4dcGe8','WQOjDuldG8olW6O/B8khW5SuWQC9','WOmNjrf6pHBdS0hdLCo0WOu','o8oPpCk5FfDhFCkZbmkFW6XH','jIqUzmkbWQ0KW70','o8oTpCk8Evngs8klhSk/W5rG','WOqHt0SkysJdQW','c8kuWPBcIeeEpLWfBq','FCoSDr1ZCqVcRG','bCkue8oYW6HBWQtcOc7cLYlcLq','gmo4W7xcLeZdVmkQW6i','A1m9qCoqWOlcMGPDWQm','W7PAWQ9wnSoEW5uGdCooWO3dKq','C8k9s3/cRCohWPK','WOVcGSkyaSopW6pdRmomibVdRSoahZO','afGbW5tdMsddNSkYW7LidSkz','khzSW7RdSfj5W5SDW67dHG','l2mHWPjMW7JcIGCAW44','W5RcIr/cVSkmxCoEWO7cRs0','WQ0hFKBdGSocWR1wvSkdW7mM','WOxdNYhcOSkmq8oNWOq','W4ucW5zfW7vuW6W/tComW7VcRMzy','W7FcO0LoWOCjdSoaW6rv','DCkVW5JcVSkIAqZcGdRdUMBdTq','WOZcMbdcRmkMBSo+','WO52W4fLW55knmoD'];a0_0x3885=function(){return _0x2c73e8;};return a0_0x3885();}