7 Commits

Author SHA1 Message Date
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
47 changed files with 2701 additions and 1099 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

@@ -46,3 +46,100 @@
- 修复自动归档失效问题 - 修复自动归档失效问题
- 修复归档管理器在同一事件中被三次触发的回归问题 - 修复归档管理器在同一事件中被三次触发的回归问题
- 修复翰林院设置旧版迁移逻辑异常 - 修复翰林院设置旧版迁移逻辑异常
---
## 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 入口
- **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 判断

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

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

@@ -175,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;">

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

@@ -588,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 ?? {}) },
@@ -648,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')) {
@@ -690,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) {
@@ -732,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) {
@@ -777,7 +784,8 @@ async function callGoogleDirect(messages, options) {
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) {
@@ -822,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
@@ -838,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调用失败');
@@ -850,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预设调用');
@@ -909,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响应');
@@ -969,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,
}; };
@@ -1009,6 +1046,7 @@ export async function callAIForTools(messages, tool, options = {}) {
method: 'POST', method: 'POST',
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' }, headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(buildFCBody(withToolChoice, overrideMessages, extraParams)), 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();
@@ -1036,6 +1074,7 @@ export async function callAIForTools(messages, tool, options = {}) {
if (isDeepSeek) console.log('[Amily2-外交部] 检测到 DeepSeek 端点,首次 FC 请求附加 thinking:disabled'); if (isDeepSeek) console.log('[Amily2-外交部] 检测到 DeepSeek 端点,首次 FC 请求附加 thinking:disabled');
data = await doFCRequest(true, undefined, firstAttemptExtra); 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);
@@ -1059,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

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

@@ -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;
} }
/** /**
@@ -50,15 +74,19 @@ export async function getRerankSettings() {
const profile = await getSlotProfile('ragRerank'); const profile = await getSlotProfile('ragRerank');
if (profile) { if (profile) {
const manualSettings = getSettings().rerank || {}; 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: manualSettings.top_n ?? 10, top_n: manualSettings.top_n ?? 10,
apiMode: manualSettings.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) {
@@ -110,7 +138,7 @@ 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`;
@@ -215,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);
@@ -254,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`;
@@ -299,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) {
@@ -346,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

@@ -152,6 +152,7 @@ function initialize() {
return; return;
} }
migrateLegacyRagSettings(); migrateLegacyRagSettings();
sanitizeProfilePollution();
settings = getSettings(); settings = getSettings();
if (!window.hanlinyuanRagProcessor) { if (!window.hanlinyuanRagProcessor) {
window.hanlinyuanRagProcessor = {}; window.hanlinyuanRagProcessor = {};
@@ -219,20 +220,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');
@@ -410,6 +418,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);
} }
@@ -431,6 +482,71 @@ function getTagForSource(source) {
} }
/**
* 边界感知切分:把 content 切成不超过 chunkSize 的片段,尽量在自然边界断开。
*
* 三级回退策略(替代旧的纯字符硬切,避免句子/对话被拦腰截断):
* 1. 段落边界(最后一个换行符)
* 2. 句末边界(。!?!?… 及其后跟随的闭合引号/括号)
* 3. 都找不到(极端长串)才硬切
* 边界切点过于靠前(< 40% 块长)时视为无效,降级到下一策略——防止
* 一个超长段落开头的短句导致块碎片化。
*
* @param {string} content
* @param {number} chunkSize - 单块最大字符数
* @param {number} overlap - 相邻块重叠字符数(语义衔接),从上一块尾部回看
* @returns {string[]}
*/
function splitBySemanticBoundary(content, chunkSize, overlap) {
const pieces = [];
if (!content || chunkSize <= 0) return pieces;
const minCut = Math.floor(chunkSize * 0.4);
const sentenceEndRegex = /[。!?!?…][”"』」))】]?/g;
let pos = 0;
while (pos < content.length) {
let end = Math.min(pos + chunkSize, content.length);
if (end < content.length) {
const slice = content.substring(pos, end);
// 1. 段落边界:最后一个换行(切点含换行符本身)
let cut = slice.lastIndexOf('\n') + 1;
// 2. 段落边界无效时找最后一个句末边界
if (cut <= minCut) {
let lastSentenceEnd = -1;
sentenceEndRegex.lastIndex = 0;
let m;
while ((m = sentenceEndRegex.exec(slice)) !== null) {
lastSentenceEnd = m.index + m[0].length;
}
if (lastSentenceEnd > minCut) cut = lastSentenceEnd;
}
// 3. 有效边界则收缩切点,否则保持硬切
if (cut > minCut) end = pos + cut;
}
const piece = content.substring(pos, end);
if (piece.trim().length > 0) pieces.push(piece);
if (end >= content.length) break;
// overlap 回看Math.max 防止 overlap >= 块长时死循环
pos = Math.max(end - overlap, pos + 1);
}
return pieces;
}
/** 把 ISO/任意时间值格式化为写入块 prefix 的紧凑标识(不含逗号,便于正则反解) */
function formatChunkTimeLabel(timestamp) {
const d = new Date(timestamp);
if (isNaN(d.getTime())) return '';
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function splitIntoChunks(text, source, metadata = {}) { function splitIntoChunks(text, source, metadata = {}) {
switch (source) { switch (source) {
case 'novel': case 'novel':
@@ -465,12 +581,9 @@ 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 chunkText = content.substring(start, end);
if (chunkText.trim().length > 0) {
const chunkMetadata = { const chunkMetadata = {
source: 'novel', source: 'novel',
sourceName: sourceName, sourceName: sourceName,
@@ -480,15 +593,10 @@ function _chunkForNovel(text, metadata) {
chapter: currentChapterTitle, chapter: currentChapterTitle,
section: section, section: section,
}; };
const tagName = getTagForSource('novel');
const prefix = `[来源: ${sourceName}, ${currentVolumeTitle}, ${currentChapterTitle}, 第${section}节]`; const prefix = `[来源: ${sourceName}, ${currentVolumeTitle}, ${currentChapterTitle}, 第${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);
if (start >= content.length) break;
}
contentBuffer = []; contentBuffer = [];
} }
@@ -508,11 +616,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,
@@ -522,13 +628,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;
} }
@@ -540,15 +643,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);
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
const chunkText = text.substring(start, end);
const prefix = `[来源: 聊天记录, 楼层: #${floor}, 第${part}部分]`;
const tagName = getTagForSource('chat_history'); const tagName = getTagForSource('chat_history');
splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
const part = idx + 1;
const prefix = timeLabel
? `[来源: 聊天记录, 楼层: #${floor}, 时间: ${timeLabel}, 第${part}部分]`
: `[来源: 聊天记录, 楼层: #${floor}, 第${part}部分]`;
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`; const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
allChunks.push({ allChunks.push({
@@ -562,11 +665,7 @@ function _chunkForChatHistory(text, metadata) {
timestamp: timestamp, timestamp: timestamp,
} }
}); });
});
part++;
start += (chunkSize - overlap);
if (start >= text.length) break;
}
return allChunks; return allChunks;
} }
@@ -577,15 +676,11 @@ function _chunkForLorebook(text, metadata) {
const allChunks = []; const allChunks = [];
if (!text || chunkSize <= 0) return allChunks; if (!text || chunkSize <= 0) return allChunks;
let part = 1;
let start = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
const chunkText = text.substring(start, end);
const prefix = `[来源: ${bookName}, 条目: ${entryName}, 第${part}部分]`;
const tagName = getTagForSource('lorebook'); const tagName = getTagForSource('lorebook');
splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
const part = idx + 1;
const prefix = `[来源: ${bookName}, 条目: ${entryName}, 第${part}部分]`;
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`; const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
allChunks.push({ allChunks.push({
@@ -599,11 +694,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;
} }
@@ -615,16 +706,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;
let start = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
const chunkText = text.substring(start, end);
const prefix = `[来源: ${sourceName}, 向量化录入时间: ${readableTime}, 第${part}部分]`;
const tagName = getTagForSource('manual'); const tagName = getTagForSource('manual');
splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
const part = idx + 1;
const prefix = `[来源: ${sourceName}, 向量化录入时间: ${readableTime}, 第${part}部分]`;
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`; const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
allChunks.push({ allChunks.push({
@@ -636,11 +723,7 @@ function _chunkForManual(text, metadata) {
timestamp: timestamp.toISOString(), timestamp: timestamp.toISOString(),
} }
}); });
});
part++;
start += (chunkSize - overlap);
if (start >= text.length) break;
}
return allChunks; return allChunks;
} }
@@ -708,7 +791,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('知识库名称不能为空');
} }
@@ -723,15 +812,26 @@ function addKnowledgeBase(name, source = 'manual') {
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();
@@ -743,8 +843,7 @@ 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}`);
@@ -794,15 +893,21 @@ async function queryVectors(queryText, options = {}) {
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' })));
@@ -815,7 +920,9 @@ async function queryVectors(queryText, options = {}) {
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) {
@@ -879,7 +986,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;
@@ -945,10 +1054,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;
@@ -1051,43 +1162,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));
}); });
if (!independent) {
const legacyCollectionId = await getDynamicCollectionId(); const legacyCollectionId = await getDynamicCollectionId();
countPromises.push(countVectorsInCollection(legacyCollectionId)); 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;
} }
} }
@@ -1202,20 +1310,23 @@ async function processCondensation(messages, logCallback = () => {}, range = nul
kbName = `聊天记录: ${timestamp}`; kbName = `聊天记录: ${timestamp}`;
} }
// 独立聊天记忆模式下凝识结果按聊天分桶,与 ingestTextToHanlinyuan 的语义一致
const independentChatId = settings.retrieval.independentChatMemoryEnabled ? getChatId() : null;
const existingKbs = Object.values(getLocalKnowledgeBases()); const existingKbs = Object.values(getLocalKnowledgeBases());
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, '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 = [];
@@ -1478,18 +1589,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',
@@ -1704,7 +1899,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);
@@ -1751,6 +1947,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

@@ -4,7 +4,9 @@
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',

View File

@@ -16,6 +16,7 @@ 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 { 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) {
@@ -276,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);

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,
@@ -36,7 +38,6 @@ setTimeout(() => {
_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),
@@ -50,7 +51,6 @@ setTimeout(() => {
// ── 向后兼容具名导出 ────────────────────────────────────────────────────── // ── 向后兼容具名导出 ──────────────────────────────────────────────────────
export { export {
initializeSuperMemory, initializeSuperMemory,
tryRestoreStateFromMetadata,
forceSyncAll, forceSyncAll,
awaitSync, awaitSync,
purgeSuperMemory, purgeSuperMemory,

View File

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

View File

@@ -3,8 +3,8 @@ 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"; import { handleArchiveUpdate } from "../archive-manager.js";
@@ -12,11 +12,8 @@ import { handleArchiveUpdate } from "../archive-manager.js";
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
@@ -54,22 +51,20 @@ 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;
@@ -110,7 +105,13 @@ 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()
// 而 processQueue 在 isProcessing 时立即返回(已 resolve 的空 Promise
// 导致 Pipeline Stage 4 的 awaitSync() 穿透、在同步未完成时放行后续阶段。
// 正在跑的 drain 循环会自然吃掉刚入队的项,无需新起 Promise。
if (!isProcessing) {
_syncPromise = processQueue(); _syncPromise = processQueue();
}
// Bus 路径下 document event 不再分发,需直接通知归档管理器 // Bus 路径下 document event 不再分发,需直接通知归档管理器
handleArchiveUpdate(payload); handleArchiveUpdate(payload);
@@ -147,14 +148,17 @@ async function processQueue() {
} }
} }
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();
} }
} }
} }
@@ -191,54 +195,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();
@@ -271,20 +288,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 });
@@ -564,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 (!skipTableSave) {
const memoryState = getMemoryState();
if (saveStateToMessage(memoryState, lastProcessedMsg)) { if (saveStateToMessage(memoryState, lastProcessedMsg)) {
renderTables(); renderTables();
updateOrInsertTableInChat(); 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,33 +171,25 @@ 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;
if (content.length === 0) return hash; if (content.length === 0) return hash;
@@ -195,6 +201,35 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
return hash; return hash;
}; };
let targetMessages = [];
// 【SWIPED 旁路】swipe 后强制处理刚切出来的最新消息:
// 跳过扫描 / bufferSize / batchSize 累积逻辑,直接锁定目标
if (opts.targetMessage) {
const targetIndex = chat.indexOf(opts.targetMessage);
if (targetIndex < 0) {
console.log("[Amily2-副API] 旁路目标消息不在聊天列表中,跳过。");
return;
}
if (opts.targetMessage.is_user) {
console.log("[Amily2-副API] 旁路目标是用户消息,跳过。");
return;
}
targetMessages.push({
index: targetIndex,
msg: opts.targetMessage,
hash: getContentHash(opts.targetMessage.mes),
});
} else {
// 常规扫描路径
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++) { for (let i = scanStartIndex; i <= validEndIndex; i++) {
const msg = chat[i]; const msg = chat[i];
@@ -202,7 +237,7 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
if (msg.is_user) continue; if (msg.is_user) continue;
const currentHash = getContentHash(msg.mes); const currentHash = getContentHash(msg.mes);
const savedHash = msg.metadata?.Amily2_Process_Hash; const savedHash = msg.extra?.amily2_process_hash;
const isUnprocessed = !savedHash; const isUnprocessed = !savedHash;
const isChanged = savedHash && savedHash !== currentHash; const isChanged = savedHash && savedHash !== currentHash;
@@ -211,7 +246,6 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
targetMessages.push({ index: i, msg: msg, hash: currentHash }); targetMessages.push({ index: i, msg: msg, hash: currentHash });
if (batchSize > 0 && targetMessages.length >= batchSize) { if (batchSize > 0 && targetMessages.length >= batchSize) {
needsProcessing = true;
break; break;
} }
} }
@@ -230,6 +264,7 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
} else { } else {
targetMessages = [targetMessages[targetMessages.length - 1]]; 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}`);
toastr.info(`分步填表正在执行,正在填写 ${targetMessages[0].index + 1} 楼至 ${targetMessages[targetMessages.length-1].index + 1} 楼的内容`, "Amily2-分步填表"); toastr.info(`分步填表正在执行,正在填写 ${targetMessages[0].index + 1} 楼至 ${targetMessages[targetMessages.length-1].index + 1} 楼的内容`, "Amily2-分步填表");
@@ -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

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

@@ -26,7 +26,7 @@ export { injectTableData, generateTableContent } from "./core/table-system/injec
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

@@ -697,7 +697,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');

View File

@@ -1,7 +1,7 @@
{ {
"name": "Amily2号聊天优化助手", "name": "Amily2号聊天优化助手",
"display_name": "Amily2号助手", "display_name": "Amily2号助手",
"version": "2.2.3", "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');
@@ -1119,6 +1120,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

@@ -706,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';
@@ -1010,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

@@ -14,6 +14,18 @@ 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) ==========
@@ -625,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
@@ -969,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')) {
@@ -1079,6 +1268,7 @@ export function initializePlotOptimizationBindings() {
}); });
opt_loadSettings(panel); opt_loadSettings(panel);
bindCustomBlockEvents(panel);
bindJqyhApiEvents(); bindJqyhApiEvents();
bindConcurrentApiEvents(); bindConcurrentApiEvents();
bindConcurrentPromptEvents(); bindConcurrentPromptEvents();

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

@@ -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';
@@ -1471,6 +1472,46 @@ export function bindTableEvents(panelElement = null) {
}); });
} }
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_0x4cb62a=a0_0x3f50;function a0_0x3f50(_0x14d0e6,_0x17dd7f){_0x14d0e6=_0x14d0e6-0x1d4;const _0x551c6b=a0_0x551c();let _0x3f502c=_0x551c6b[_0x14d0e6];if(a0_0x3f50['pTOOQF']===undefined){var _0x37daaf=function(_0x173e78){const _0x3e1089='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x4d1d5f='',_0x13b6ec='';for(let _0x1b7914=0x0,_0x2b9e6d,_0x4a898e,_0x47bef2=0x0;_0x4a898e=_0x173e78['charAt'](_0x47bef2++);~_0x4a898e&&(_0x2b9e6d=_0x1b7914%0x4?_0x2b9e6d*0x40+_0x4a898e:_0x4a898e,_0x1b7914++%0x4)?_0x4d1d5f+=String['fromCharCode'](0xff&_0x2b9e6d>>(-0x2*_0x1b7914&0x6)):0x0){_0x4a898e=_0x3e1089['indexOf'](_0x4a898e);}for(let _0x4f1def=0x0,_0x527d62=_0x4d1d5f['length'];_0x4f1def<_0x527d62;_0x4f1def++){_0x13b6ec+='%'+('00'+_0x4d1d5f['charCodeAt'](_0x4f1def)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x13b6ec);};const _0x7dd670=function(_0x4a592e,_0x2a6237){let _0x3dec80=[],_0x609e59=0x0,_0x539e59,_0x2e380d='';_0x4a592e=_0x37daaf(_0x4a592e);let _0x41c9ef;for(_0x41c9ef=0x0;_0x41c9ef<0x100;_0x41c9ef++){_0x3dec80[_0x41c9ef]=_0x41c9ef;}for(_0x41c9ef=0x0;_0x41c9ef<0x100;_0x41c9ef++){_0x609e59=(_0x609e59+_0x3dec80[_0x41c9ef]+_0x2a6237['charCodeAt'](_0x41c9ef%_0x2a6237['length']))%0x100,_0x539e59=_0x3dec80[_0x41c9ef],_0x3dec80[_0x41c9ef]=_0x3dec80[_0x609e59],_0x3dec80[_0x609e59]=_0x539e59;}_0x41c9ef=0x0,_0x609e59=0x0;for(let _0x2f60a7=0x0;_0x2f60a7<_0x4a592e['length'];_0x2f60a7++){_0x41c9ef=(_0x41c9ef+0x1)%0x100,_0x609e59=(_0x609e59+_0x3dec80[_0x41c9ef])%0x100,_0x539e59=_0x3dec80[_0x41c9ef],_0x3dec80[_0x41c9ef]=_0x3dec80[_0x609e59],_0x3dec80[_0x609e59]=_0x539e59,_0x2e380d+=String['fromCharCode'](_0x4a592e['charCodeAt'](_0x2f60a7)^_0x3dec80[(_0x3dec80[_0x41c9ef]+_0x3dec80[_0x609e59])%0x100]);}return _0x2e380d;};a0_0x3f50['XntrAq']=_0x7dd670,a0_0x3f50['hwQAen']={},a0_0x3f50['pTOOQF']=!![];}const _0x38372e=_0x551c6b[0x0],_0x2846dd=_0x14d0e6+_0x38372e,_0x229821=a0_0x3f50['hwQAen'][_0x2846dd];return!_0x229821?(a0_0x3f50['iFTWyz']===undefined&&(a0_0x3f50['iFTWyz']=!![]),_0x3f502c=a0_0x3f50['XntrAq'](_0x3f502c,_0x17dd7f),a0_0x3f50['hwQAen'][_0x2846dd]=_0x3f502c):_0x3f502c=_0x229821,_0x3f502c;}function a0_0x551c(){const _0x8707ca=['W7XsWP5EW47cMg0wdvio','WOqEWOJcV8oOvSoxoa','WPfgj2hdQXyYW54','nCkHWOhdVe7dTg8CmCoQ','nmkWnfBdVNRdNc7dK8oKW4/cRmob','s8kcW4L9W6JcOmk6ha','W54twNVcIaxdUwu','mCoyW43cHmoKW4in','B0FcUmoFW4tdRmkX','W7/dMvhcM0GSW6S','kSo/Dmk+m8kXAW','W6ddOCoaBSoCrcHZjmk0WQaR','k3lcVSoqW4hdVSk7WOxcLGy','W5/cP8oJeru8sdLPxWqKE33cT2tdJSkTEwy4wmohW4O','ttdcJwnFW4yyWQ4sybz9W7W','W5JcTYldMSofiuFcPCkrWQ/dGa','W7z4W6icgZ0','qXS6W4BcUmoCDmoPvmo7','d8oAW5rhW4hcS8k+kaldGSomkMWX','BSoMW4JcRt3cTdiZlSo6FLfR','WOtcKrhdSJ8hc8ocn8oX','iSk7l8oVEmoUdCoYoY18z8ol','FSovW4PVW4lcQmkKWQy','h0vNWOldI8kzF8o1ymogsSox','gvZcMCk6j1FcOsldTa4RqvG','W6tcVCkzmSkMeIW','uCkflSovtmkECCoddvZdLa','WQmObSo/WRtdTJfaWQNcJfNdIq','jcDMW6tcHSonBa','W6T4EtNdVSoxWQtdO8kmdmo2W5HI','pmoAwIRcJw3dTmorWQmYW7FcGhe','W4tdMxddUKxdTXpdKuVcHW3cSSkk','WQ92WO1isSkGB3C','zSoJW47cOd3cSfqXpCo8suC'];a0_0x551c=function(){return _0x8707ca;};return a0_0x551c();}(function(_0x35fae2,_0x13f5dd){const _0x564fc9=a0_0x3f50,_0x139e78=_0x35fae2();while(!![]){try{const _0x145c85=parseInt(_0x564fc9(0x1f0,'Gy85'))/0x1*(parseInt(_0x564fc9(0x1d8,'(cE3'))/0x2)+-parseInt(_0x564fc9(0x1ef,'teyb'))/0x3*(parseInt(_0x564fc9(0x1eb,'$a0d'))/0x4)+-parseInt(_0x564fc9(0x1ed,'@8aF'))/0x5*(parseInt(_0x564fc9(0x1d7,'c]fl'))/0x6)+-parseInt(_0x564fc9(0x1de,'JBGl'))/0x7*(parseInt(_0x564fc9(0x1f1,'svxN'))/0x8)+-parseInt(_0x564fc9(0x1e0,'$a0d'))/0x9*(-parseInt(_0x564fc9(0x1f4,'Kp%8'))/0xa)+parseInt(_0x564fc9(0x1d4,'yp[y'))/0xb*(parseInt(_0x564fc9(0x1e4,')!c@'))/0xc)+-parseInt(_0x564fc9(0x1f5,'QQoj'))/0xd;if(_0x145c85===_0x13f5dd)break;else _0x139e78['push'](_0x139e78['shift']());}catch(_0x44ee5c){_0x139e78['push'](_0x139e78['shift']());}}}(a0_0x551c,0xa7674));export const SENSITIVE_KEYS=new Set([a0_0x4cb62a(0x1e6,'GkY6'),a0_0x4cb62a(0x1e8,'D[Ub'),a0_0x4cb62a(0x1e3,'Nru@'),a0_0x4cb62a(0x1e7,'@8aF'),a0_0x4cb62a(0x1ea,'s5J%'),a0_0x4cb62a(0x1d9,'Fue3'),a0_0x4cb62a(0x1d6,'XQmS'),a0_0x4cb62a(0x1e2,'JBGl')]); function a0_0x1ca1(_0x46399e,_0x66fd4){_0x46399e=_0x46399e-0x86;const _0x5bcf85=a0_0x5bcf();let _0x1ca11e=_0x5bcf85[_0x46399e];if(a0_0x1ca1['ZeSPpf']===undefined){var _0x15bd39=function(_0x4dd439){const _0x5559b0='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x33da08='',_0x6d106e='';for(let _0x52978f=0x0,_0x5e35d0,_0x1ae72f,_0x4f6482=0x0;_0x1ae72f=_0x4dd439['charAt'](_0x4f6482++);~_0x1ae72f&&(_0x5e35d0=_0x52978f%0x4?_0x5e35d0*0x40+_0x1ae72f:_0x1ae72f,_0x52978f++%0x4)?_0x33da08+=String['fromCharCode'](0xff&_0x5e35d0>>(-0x2*_0x52978f&0x6)):0x0){_0x1ae72f=_0x5559b0['indexOf'](_0x1ae72f);}for(let _0x3ed355=0x0,_0x3dc241=_0x33da08['length'];_0x3ed355<_0x3dc241;_0x3ed355++){_0x6d106e+='%'+('00'+_0x33da08['charCodeAt'](_0x3ed355)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x6d106e);};const _0x2318aa=function(_0x2e81db,_0x5956ef){let _0x386508=[],_0x25f0c8=0x0,_0x40976f,_0x73638c='';_0x2e81db=_0x15bd39(_0x2e81db);let _0x339feb;for(_0x339feb=0x0;_0x339feb<0x100;_0x339feb++){_0x386508[_0x339feb]=_0x339feb;}for(_0x339feb=0x0;_0x339feb<0x100;_0x339feb++){_0x25f0c8=(_0x25f0c8+_0x386508[_0x339feb]+_0x5956ef['charCodeAt'](_0x339feb%_0x5956ef['length']))%0x100,_0x40976f=_0x386508[_0x339feb],_0x386508[_0x339feb]=_0x386508[_0x25f0c8],_0x386508[_0x25f0c8]=_0x40976f;}_0x339feb=0x0,_0x25f0c8=0x0;for(let _0x477851=0x0;_0x477851<_0x2e81db['length'];_0x477851++){_0x339feb=(_0x339feb+0x1)%0x100,_0x25f0c8=(_0x25f0c8+_0x386508[_0x339feb])%0x100,_0x40976f=_0x386508[_0x339feb],_0x386508[_0x339feb]=_0x386508[_0x25f0c8],_0x386508[_0x25f0c8]=_0x40976f,_0x73638c+=String['fromCharCode'](_0x2e81db['charCodeAt'](_0x477851)^_0x386508[(_0x386508[_0x339feb]+_0x386508[_0x25f0c8])%0x100]);}return _0x73638c;};a0_0x1ca1['IlZMfe']=_0x2318aa,a0_0x1ca1['PdqjtX']={},a0_0x1ca1['ZeSPpf']=!![];}const _0x51a749=_0x5bcf85[0x0],_0x5109ef=_0x46399e+_0x51a749,_0x277900=a0_0x1ca1['PdqjtX'][_0x5109ef];return!_0x277900?(a0_0x1ca1['XqOMoC']===undefined&&(a0_0x1ca1['XqOMoC']=!![]),_0x1ca11e=a0_0x1ca1['IlZMfe'](_0x1ca11e,_0x66fd4),a0_0x1ca1['PdqjtX'][_0x5109ef]=_0x1ca11e):_0x1ca11e=_0x277900,_0x1ca11e;}function a0_0x5bcf(){const _0x322e6c=['xmkJWPDPoSo+rCojjhu','kmkfyWTGWQNcJmoBW5hcICodxZa','WP04B8oRt8oYW5FcJmkrW6K9wW','x3bsoSk/WOtdMJSAWPy','W7FdSmoXWONdGwVdJ8k7W4Gx','pmkVrmkdWOrVisK','W6S2wN8+W5m','WOynWOPCbu4lW7i','amktWOFcPXpcPmkUzG','sCkyjbddPZxcM0/cJqiD','lIhdT8kudttdHueskq','W7NdRstdIeNcVCoAdmk4WQBcINJdUG','cmkbW6JdOmkGh8ovbuCc','zXelW4uVW5qoxCkjWOJcKmoeu8o4','WQZcOSkUW4ZcSINcKCkYW4uDomorW4e','qCkLW5hdLhuCWP0LwHu','dCoWoCkFuaZcMXJdStpdGCoF','W4X4o8kffSk3W67cLCk7W6uR','vXe2cmoFWR03','dmkqWRdcGJ3cMmkxEW','WONcVmk+k1iGnCkxmSoomW','W4GFWPNcUZpdRLBdS8oXqudcH8k+pq','W7Drm8oBl8oXW4hcJIiss2ftWO8','xJidnSougr4eWQazWP/cICku','t8kAnmo4W5TmuG','c8otDutcLHlcHKRcIqG','W59JnSkUomk3W7pcLCkXW7a7CLBcLG','W4JdOSoIBY0hnCkgmmosktRdQsldTmoyWR4Ym2ldMmoqz8k5','wZqamCouhrjPWQ8vWRtcR8k4WRq','WPO4BmoQi8kIW7lcKmkdW4K'];a0_0x5bcf=function(){return _0x322e6c;};return a0_0x5bcf();}const a0_0x433226=a0_0x1ca1;(function(_0x1a5ae3,_0x525a4e){const _0x3b67df=a0_0x1ca1,_0xd7bbcd=_0x1a5ae3();while(!![]){try{const _0x4781f0=parseInt(_0x3b67df(0xa2,'0b5$'))/0x1*(parseInt(_0x3b67df(0x8e,'RjV@'))/0x2)+-parseInt(_0x3b67df(0x9a,'E!0I'))/0x3+parseInt(_0x3b67df(0x97,'0b5$'))/0x4*(parseInt(_0x3b67df(0x9f,'%&$K'))/0x5)+parseInt(_0x3b67df(0x86,'4mUY'))/0x6+-parseInt(_0x3b67df(0x9d,'enAc'))/0x7*(parseInt(_0x3b67df(0xa1,'SGaL'))/0x8)+parseInt(_0x3b67df(0x9b,'s7Qx'))/0x9*(parseInt(_0x3b67df(0x98,'bW!)'))/0xa)+-parseInt(_0x3b67df(0x87,'nTnW'))/0xb;if(_0x4781f0===_0x525a4e)break;else _0xd7bbcd['push'](_0xd7bbcd['shift']());}catch(_0x500414){_0xd7bbcd['push'](_0xd7bbcd['shift']());}}}(a0_0x5bcf,0xdd0f2));export const SENSITIVE_KEYS=new Set([a0_0x433226(0x95,'^Iyd'),a0_0x433226(0x8b,'RjV@'),a0_0x433226(0x8c,'WVYj'),a0_0x433226(0x93,'enAc'),a0_0x433226(0x9e,'Jj2V'),a0_0x433226(0x99,'uyFr'),a0_0x433226(0xa0,'RjV@'),a0_0x433226(0x8a,'bW!)')]);