mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-12 19:15:50 +00:00
Compare commits
7 Commits
0e11f85031
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7e3b799e | ||
|
|
1a4a10d42d | ||
|
|
347016d5ac | ||
|
|
59c4adc1c0 | ||
|
|
e66544f774 | ||
|
|
d6b3b00c86 | ||
|
|
a8c3ad9027 |
@@ -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,
|
||||||
|
|||||||
@@ -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> 测试中...');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **翰林院(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
|
||||||
|
- **二次填表**:
|
||||||
|
- 修复 `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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 判断
|
||||||
|
|||||||
60
HanLin.md
60
HanLin.md
@@ -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. 总览与核心开关
|
||||||
|
|
||||||
这里是翰林院的仪表面板,展示了核心状态并提供了最高权限的操作。
|
这里是翰林院的仪表面板,展示了核心状态并提供了最高权限的操作。
|
||||||
|
|||||||
323
TODOList.md
323
TODOList.md
@@ -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 2:ai_call handler + 自定义块 UI 与持久化 | `core/memory-blocks/*`;剧情优化面板 |
|
||||||
|
| `6ad1354` | T-002:cwb / autoCharCard 纳入 legacy 自动迁移(迁移版本化 v2) | `ApiProfileManager.js` |
|
||||||
|
| `784bd70` | T-006:profile 已分配时参数控件 informational 化 | `ui/profile-slider-guard.js` + 4 面板 |
|
||||||
|
| `ef45e74` | T-007 / Phase 0.4:manager.js 抽出 ui-mutations + events-dispatch | `core/table-system/*` |
|
||||||
|
|
||||||
**核心架构现状**(接手必读):
|
**核心架构现状**(接手必读):
|
||||||
|
|
||||||
- **状态权威**:`utils/config/ApiProfileManager.js` 是 API 配置单一指挥所;profile 分配后即权威,旧字段(`s.ngmsTemperature` 等)不再覆盖 profile
|
- **状态权威**:`utils/config/ApiProfileManager.js` 是 API 配置单一指挥所;profile 分配后即权威,旧字段不再覆盖 profile;legacy 迁移已版本化(`_legacyProfileMigrationVersion`,当前 v2 覆盖 8 个 chat slot)
|
||||||
- **表格模块**:核心在 [core/table-system/](core/table-system/) ,已按 IAD 拆分(dto/infra/actions/rendering.js/templates.js/preset.js),manager.js 退化为兼容层(仍保留 16 个 UI mutation + loadTables + updateTableFromText)
|
- **表格模块**:核心在 [core/table-system/](core/table-system/),IAD 拆分(dto/infra/actions/rendering.js/templates.js/preset.js/events-dispatch.js);manager.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 / listVendorParams;registry 在 [assets/api-vendor-params.json](assets/api-vendor-params.json)
|
- **memory-blocks**:[core/memory-blocks/](core/memory-blocks/) 占位符驱动工作流,static + ai_call 两种 handler,自定义块 UI 在剧情优化面板;Phase 3(JSON 导入导出 / 战斗系统 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-007(Phase 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 版本条目补全
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
63
core/api.js
63
core/api.js
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
54
core/memory-blocks/ai-call-handler.js
Normal file
54
core/memory-blocks/ai-call-handler.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* core/memory-blocks/ai-call-handler.js — 'ai_call' generator handler(Phase 2)
|
||||||
|
*
|
||||||
|
* 执行一次独立 AI 调用,把回复(或其中指定标签的内容)作为块的替换值。
|
||||||
|
*
|
||||||
|
* 与 generator-handlers.js 分离的原因:本 handler 依赖 core/api.js(牵涉
|
||||||
|
* DOM / ST 运行时),注册表本身保持零依赖,便于单测与 JSON 工具复用。
|
||||||
|
*
|
||||||
|
* generator 字段(AiCallGenerator,契约见 types.js):
|
||||||
|
* apiSlot - callAI 的功能槽('main' / 'plotOpt' / 'nccs' ...),缺省 'main'
|
||||||
|
* promptTemplate - 作为 user 消息发送的提示词(必填,空则块跳过)
|
||||||
|
* systemPrompt - 可选,附加在前面的 system 消息
|
||||||
|
* extractTag - 可选,只取回复中最后一个 <tag>...</tag> 的内容;
|
||||||
|
* 标签缺失时回退为完整回复(宽容处理,模型偶发不包
|
||||||
|
* 标签时块仍有产出,而不是静默保留占位符)
|
||||||
|
*
|
||||||
|
* 失败语义(与 executor 约定一致):
|
||||||
|
* - callAI 内部捕获的 API 错误返回 null → 块产出 null → 占位符保留
|
||||||
|
* - AbortError 由 callAI 原样上抛 → executor 整体中断(signal 贯穿 fetch)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { callAI } from '../api.js';
|
||||||
|
import { extractContentByTag } from '../../utils/tagProcessor.js';
|
||||||
|
import { registerHandler } from './generator-handlers.js';
|
||||||
|
|
||||||
|
registerHandler('ai_call', async (block, ctx) => {
|
||||||
|
const gen = block.generator || {};
|
||||||
|
const prompt = typeof gen.promptTemplate === 'string' ? gen.promptTemplate.trim() : '';
|
||||||
|
if (!prompt) {
|
||||||
|
console.warn(`[MemoryBlocks] ai_call 块 ${block.id} 缺少 promptTemplate,已跳过。`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = [];
|
||||||
|
if (typeof gen.systemPrompt === 'string' && gen.systemPrompt.trim()) {
|
||||||
|
messages.push({ role: 'system', content: gen.systemPrompt });
|
||||||
|
}
|
||||||
|
messages.push({ role: 'user', content: prompt });
|
||||||
|
|
||||||
|
const response = await callAI(messages, {
|
||||||
|
slot: gen.apiSlot || 'main',
|
||||||
|
signal: ctx?.signal,
|
||||||
|
});
|
||||||
|
if (!response || !response.trim()) return null;
|
||||||
|
|
||||||
|
if (gen.extractTag) {
|
||||||
|
const extracted = extractContentByTag(response, gen.extractTag);
|
||||||
|
if (extracted !== null && extracted.trim()) return extracted.trim();
|
||||||
|
console.warn(`[MemoryBlocks] ai_call 块 ${block.id} 回复中未找到 <${gen.extractTag}> 标签,回退为完整回复。`);
|
||||||
|
}
|
||||||
|
return response.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
export {};
|
||||||
51
core/memory-blocks/builtin-blocks.js
Normal file
51
core/memory-blocks/builtin-blocks.js
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
97
core/memory-blocks/chain.js
Normal file
97
core/memory-blocks/chain.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* core/memory-blocks/chain.js
|
||||||
|
*
|
||||||
|
* 顺序拼接式工作流:把 context 下所有启用块的结果,按 block.order 排序后用 separator
|
||||||
|
* 拼接,并可选 header/footer 包裹,最终输出一个"完整的注入块"字符串。
|
||||||
|
*
|
||||||
|
* 与 executor.js(模板替换式)并列两种组合范式:
|
||||||
|
* - executor: 模板里挖空 placeholder,块负责填料 → 替换式
|
||||||
|
* - chain: 无模板,块各自产出一段文本 → 顺序拼成一整段
|
||||||
|
*
|
||||||
|
* 战斗系统设计稿(§3.2)里的"战报作底部独立注入块"、未来的"记忆注入合成块"
|
||||||
|
* 都是 chain 模式的天然用例:战斗模块只需声明一个 BlockDefinition,order 取大
|
||||||
|
* 就自动落在拼接末尾。
|
||||||
|
*
|
||||||
|
* ── Chain 定义(纯数据、JSON 可序列化)─────────────────────────────────────
|
||||||
|
* {
|
||||||
|
* id: string // 与 BlockDefinition.context 对齐,块通过 context 隐式归属
|
||||||
|
* name?: string // UI 显示名
|
||||||
|
* separator?: string // 块间分隔符,默认 '\n\n'
|
||||||
|
* header?: string // 整段前缀,可选
|
||||||
|
* footer?: string // 整段后缀,可选
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Chain 无须显式注册也能 compose——未注册时使用默认值,方便临时拼接。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { executeContext } from './runner.js';
|
||||||
|
|
||||||
|
const chains = new Map();
|
||||||
|
|
||||||
|
const DEFAULT_SEPARATOR = '\n\n';
|
||||||
|
|
||||||
|
function validateChain(def) {
|
||||||
|
if (!def || typeof def !== 'object') throw new Error('[MemoryBlocks/Chain] 定义必须是对象。');
|
||||||
|
if (!def.id) throw new Error('[MemoryBlocks/Chain] Chain.id 必填。');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerChain(def) {
|
||||||
|
validateChain(def);
|
||||||
|
chains.set(def.id, {
|
||||||
|
separator: DEFAULT_SEPARATOR,
|
||||||
|
header: '',
|
||||||
|
footer: '',
|
||||||
|
...def,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterChain(id) {
|
||||||
|
return chains.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChain(id) {
|
||||||
|
return chains.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listChains() {
|
||||||
|
return [...chains.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 Chain,按 order 排序后拼接成最终字符串。
|
||||||
|
*
|
||||||
|
* @param {string} chainId
|
||||||
|
* @param {{ settings?, signal?, extras? }} [opts]
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export async function composeChain(chainId, opts = {}) {
|
||||||
|
if (!chainId) return '';
|
||||||
|
const chain = getChain(chainId);
|
||||||
|
const results = await executeContext({ context: chainId, ...opts });
|
||||||
|
|
||||||
|
const sorted = results
|
||||||
|
.filter(r => r !== null)
|
||||||
|
.sort((a, b) => (a.block.order ?? 0) - (b.block.order ?? 0));
|
||||||
|
|
||||||
|
const separator = chain?.separator ?? DEFAULT_SEPARATOR;
|
||||||
|
const body = sorted.map(r => r.value).join(separator);
|
||||||
|
|
||||||
|
const parts = [chain?.header, body, chain?.footer]
|
||||||
|
.map(p => (typeof p === 'string' ? p : ''))
|
||||||
|
.filter(p => p.length > 0);
|
||||||
|
|
||||||
|
return parts.join(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取 Chain 的执行结果明细(含每块原值),用于调试或调用方自定义后处理。
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<{ block, value }>>}
|
||||||
|
*/
|
||||||
|
export async function inspectChain(chainId, opts = {}) {
|
||||||
|
if (!chainId) return [];
|
||||||
|
const results = await executeContext({ context: chainId, ...opts });
|
||||||
|
return results
|
||||||
|
.filter(r => r !== null)
|
||||||
|
.sort((a, b) => (a.block.order ?? 0) - (b.block.order ?? 0));
|
||||||
|
}
|
||||||
102
core/memory-blocks/custom-blocks.js
Normal file
102
core/memory-blocks/custom-blocks.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* core/memory-blocks/custom-blocks.js — 用户自定义块的持久化(Phase 2)
|
||||||
|
*
|
||||||
|
* 自定义块以纯 JSON(BlockDefinition 数组)存于
|
||||||
|
* extension_settings[extensionName].memoryBlocks_customBlocks,
|
||||||
|
* 与运行时注册中心(registry.js)双向同步:
|
||||||
|
* - bootstrap / UI 初始化时 syncCustomBlocksFromSettings() 全量重放
|
||||||
|
* - 增删改 CRUD 同时更新 settings 与 registry,并 saveSettingsDebounced
|
||||||
|
*
|
||||||
|
* 自定义块 id 一律以 'custom.' 为前缀,与内置块('plotOpt.sulv1' 等)天然
|
||||||
|
* 隔离;CRUD 仅对该前缀生效,内置块不可经此修改或删除。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { extension_settings } from '/scripts/extensions.js';
|
||||||
|
import { saveSettingsDebounced } from '/script.js';
|
||||||
|
import { extensionName } from '../../utils/settings.js';
|
||||||
|
import { register, unregister, listAll } from './registry.js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'memoryBlocks_customBlocks';
|
||||||
|
export const CUSTOM_ID_PREFIX = 'custom.';
|
||||||
|
|
||||||
|
export function isCustomBlockId(id) {
|
||||||
|
return typeof id === 'string' && id.startsWith(CUSTOM_ID_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStore() {
|
||||||
|
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
|
||||||
|
const s = extension_settings[extensionName];
|
||||||
|
if (!Array.isArray(s[STORAGE_KEY])) s[STORAGE_KEY] = [];
|
||||||
|
return s[STORAGE_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
saveSettingsDebounced();
|
||||||
|
}
|
||||||
|
|
||||||
|
function newCustomId() {
|
||||||
|
return `${CUSTOM_ID_PREFIX}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 settings 中的自定义块全量重放进 registry(幂等,可重复调用)。
|
||||||
|
* 单个块定义损坏时跳过并告警,不影响其余块。
|
||||||
|
*/
|
||||||
|
export function syncCustomBlocksFromSettings() {
|
||||||
|
for (const b of listAll()) {
|
||||||
|
if (isCustomBlockId(b.id)) unregister(b.id);
|
||||||
|
}
|
||||||
|
for (const def of getStore()) {
|
||||||
|
try {
|
||||||
|
register(def);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[MemoryBlocks] 自定义块定义损坏,已跳过:`, def, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出某 context 下的自定义块(settings 为权威源;不过滤 enabled)。 */
|
||||||
|
export function listCustomBlocks(context) {
|
||||||
|
const store = getStore();
|
||||||
|
return context ? store.filter(b => b.context === context) : [...store];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomBlock(id) {
|
||||||
|
return getStore().find(b => b.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增自定义块。def 不含 id(自动生成);校验失败时抛错、不落库。
|
||||||
|
* @returns {Object} 落库后的完整定义
|
||||||
|
*/
|
||||||
|
export function addCustomBlock(def) {
|
||||||
|
const full = { enabled: true, ...def, id: newCustomId() };
|
||||||
|
register(full); // 先过 registry 校验,抛错则不落库
|
||||||
|
getStore().push(full);
|
||||||
|
persist();
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改自定义块(浅合并 patch;id/非 custom 块不可改)。 */
|
||||||
|
export function updateCustomBlock(id, patch) {
|
||||||
|
if (!isCustomBlockId(id)) throw new Error(`[MemoryBlocks] 仅自定义块可修改: ${id}`);
|
||||||
|
const store = getStore();
|
||||||
|
const idx = store.findIndex(b => b.id === id);
|
||||||
|
if (idx === -1) throw new Error(`[MemoryBlocks] 自定义块不存在: ${id}`);
|
||||||
|
const merged = { ...store[idx], ...patch, id };
|
||||||
|
register(merged); // 校验 + 覆盖注册
|
||||||
|
store[idx] = merged;
|
||||||
|
persist();
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCustomBlock(id) {
|
||||||
|
if (!isCustomBlockId(id)) return false;
|
||||||
|
const store = getStore();
|
||||||
|
const idx = store.findIndex(b => b.id === id);
|
||||||
|
if (idx === -1) return false;
|
||||||
|
store.splice(idx, 1);
|
||||||
|
unregister(id);
|
||||||
|
persist();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
71
core/memory-blocks/executor.js
Normal file
71
core/memory-blocks/executor.js
Normal 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;
|
||||||
|
}
|
||||||
46
core/memory-blocks/generator-handlers.js
Normal file
46
core/memory-blocks/generator-handlers.js
Normal 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()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内置 handler:static ──────────────────────────────────────────────────────
|
||||||
|
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 '';
|
||||||
|
});
|
||||||
95
core/memory-blocks/index.js
Normal file
95
core/memory-blocks/index.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* core/memory-blocks/index.js
|
||||||
|
*
|
||||||
|
* 记忆块工作流系统对外入口。导入此模块即触发:
|
||||||
|
* 1. generator-handlers 加载 → 注册内置 'static' handler
|
||||||
|
* 2. ai-call-handler 加载 → 注册 'ai_call' handler(Phase 2)
|
||||||
|
* 3. registerBuiltinBlocks() → 注册首批内置块(sulv1-4)
|
||||||
|
* 4. syncCustomBlocksFromSettings() → 重放用户自定义块(Phase 2)
|
||||||
|
*
|
||||||
|
* 两种组合范式:
|
||||||
|
* - 模板替换式(executor.js):prompt 里挖空 placeholder,块填料 → 适合 sulv1-4
|
||||||
|
* - 顺序拼接式(chain.js) :块各自产出一段,按 order 拼接成完整注入块 →
|
||||||
|
* 适合记忆注入、战报底部块
|
||||||
|
*
|
||||||
|
* 公开 API:
|
||||||
|
* Block:
|
||||||
|
* register / unregister / getById / listByContext / listAll
|
||||||
|
* replaceContextBlocks (批量替换某 context 下全部块,JSON 导入用)
|
||||||
|
* Handler:
|
||||||
|
* registerHandler / unregisterHandler / getHandler / listHandlerTypes
|
||||||
|
* 模板替换式:
|
||||||
|
* applyToTemplate(template, opts)
|
||||||
|
* applyToTemplates(templates, opts) ← 多模板批处理首选
|
||||||
|
* generateBlockMap(opts)
|
||||||
|
* 顺序拼接式:
|
||||||
|
* registerChain(def) / unregisterChain / getChain / listChains
|
||||||
|
* composeChain(chainId, opts) → string
|
||||||
|
* inspectChain(chainId, opts) → Array<{block, value}>(调试/自定义后处理)
|
||||||
|
* 自定义块 CRUD(Phase 2,用户在 UI 增删改):
|
||||||
|
* listCustomBlocks / getCustomBlock / addCustomBlock /
|
||||||
|
* updateCustomBlock / deleteCustomBlock / syncCustomBlocksFromSettings
|
||||||
|
* CUSTOM_ID_PREFIX / isCustomBlockId
|
||||||
|
*
|
||||||
|
* opts 字段:{ 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 };
|
||||||
63
core/memory-blocks/registry.js
Normal file
63
core/memory-blocks/registry.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
core/memory-blocks/runner.js
Normal file
40
core/memory-blocks/runner.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* core/memory-blocks/runner.js
|
||||||
|
*
|
||||||
|
* 块执行的底层原语,被 executor.js(模板替换)和 chain.js(顺序拼接)共用。
|
||||||
|
*
|
||||||
|
* runBlock(block, ctx) → { block, value } | null
|
||||||
|
* 单块执行;handler 抛 AbortError 时向上传递,其余异常吞掉并返回 null
|
||||||
|
* handler 返回 null/undefined 时同样返回 null(视为"无内容")
|
||||||
|
*
|
||||||
|
* executeContext({ context, settings, signal, extras }) → Array<{block,value}|null>
|
||||||
|
* 按 context 拉块 → Promise.all 并发执行 → 返回结果数组(保留 null 占位以便上层
|
||||||
|
* 按 order 排序时不丢失映射关系,调用方过滤 null 即可)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getHandler } from './generator-handlers.js';
|
||||||
|
import { listByContext } from './registry.js';
|
||||||
|
|
||||||
|
export async function runBlock(block, ctx) {
|
||||||
|
const handler = getHandler(block.generator?.type);
|
||||||
|
if (!handler) {
|
||||||
|
console.warn(`[MemoryBlocks] 未注册的 generator 类型 "${block.generator?.type}",块 ${block.id} 已跳过。`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const value = await handler(block, ctx);
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
return { block, value: String(value) };
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') throw error;
|
||||||
|
console.error(`[MemoryBlocks] 块 ${block.id} 生成失败:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeContext({ context, settings, signal, extras } = {}) {
|
||||||
|
const blocks = listByContext(context);
|
||||||
|
if (blocks.length === 0) return [];
|
||||||
|
const ctx = { settings: settings ?? {}, signal, context, extras };
|
||||||
|
return await Promise.all(blocks.map(b => runBlock(b, ctx)));
|
||||||
|
}
|
||||||
57
core/memory-blocks/types.js
Normal file
57
core/memory-blocks/types.js
Normal 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 {};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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_data(infra/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] 全量同步完成。');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
477
core/table-system/actions/ui-mutations.js
Normal file
477
core/table-system/actions/ui-mutations.js
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
/**
|
||||||
|
* @file actions/ui-mutations.js —— 19 个 UI 突变(Phase 0.4 自 manager.js 搬出)
|
||||||
|
*
|
||||||
|
* 表格面板上的所有用户操作入口:增删行列 / 移动 / 重命名 / 规则更新 / 清空等。
|
||||||
|
* 函数签名与行为与搬出前完全一致;manager.js re-export 这些函数,外部调用路径零改动。
|
||||||
|
*
|
||||||
|
* 依赖说明:
|
||||||
|
* - 状态读写走 infra/store.js,持久化走 infra/persistence.js
|
||||||
|
* - SuperMemory 分发走 events-dispatch.js(与 manager 共用,无环)
|
||||||
|
* - loadTables / saveTables 仍从 manager 引入(addTable 空状态兜底 / 日志桩),
|
||||||
|
* manager ↔ ui-mutations 构成 ESM 循环,但二者均为 hoisted 函数声明、
|
||||||
|
* 仅在运行时调用,与既有 manager ↔ ui/table-bindings 环同模式,安全
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getContext } from '/scripts/extensions.js';
|
||||||
|
import { saveChat } from '/script.js';
|
||||||
|
import { saveChatDebounced } from '../../../utils/utils.js';
|
||||||
|
|
||||||
|
import { log } from '../logger.js';
|
||||||
|
import { renderTables } from '../../../ui/table-bindings.js';
|
||||||
|
import { dispatchTableUpdate, dispatchAllTablesUpdate } from '../events-dispatch.js';
|
||||||
|
import { loadTables, saveTables } from '../manager.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getState,
|
||||||
|
addHighlight,
|
||||||
|
markTableUpdated,
|
||||||
|
getUpdatedTables,
|
||||||
|
} from '../infra/store.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
saveStateToMessage,
|
||||||
|
commitToLastMessage,
|
||||||
|
} from '../infra/persistence.js';
|
||||||
|
|
||||||
|
export function deleteColumn(tableIndex, colIndex) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex] || colIndex < 0 || colIndex >= tables[tableIndex].headers.length) {
|
||||||
|
log(`删除列失败:在表格 ${tableIndex} 中找不到索引为 ${colIndex} 的列。`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tables[tableIndex].headers.splice(colIndex, 1);
|
||||||
|
tables[tableIndex].rows.forEach(row => {
|
||||||
|
if (row.length > colIndex) row.splice(colIndex, 1);
|
||||||
|
});
|
||||||
|
if (tables[tableIndex].columnWidths && tables[tableIndex].columnWidths.length > colIndex) {
|
||||||
|
tables[tableIndex].columnWidths.splice(colIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`成功删除了表格 ${tableIndex} 的第 ${colIndex + 1} 列。`, 'success');
|
||||||
|
saveTables(tables);
|
||||||
|
dispatchTableUpdate(tableIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveRow(tableIndex, rowIndex, direction) {
|
||||||
|
const tables = getState();
|
||||||
|
const table = tables?.[tableIndex];
|
||||||
|
if (!table || rowIndex < 0 || rowIndex >= table.rows.length) return;
|
||||||
|
|
||||||
|
const newIndex = direction === 'up' ? rowIndex - 1 : rowIndex + 1;
|
||||||
|
if (newIndex < 0 || newIndex >= table.rows.length) return;
|
||||||
|
|
||||||
|
const [movedRow] = table.rows.splice(rowIndex, 1);
|
||||||
|
table.rows.splice(newIndex, 0, movedRow);
|
||||||
|
|
||||||
|
if (table.rowStatuses && table.rowStatuses.length === table.rows.length + 1) {
|
||||||
|
const [movedStatus] = table.rowStatuses.splice(rowIndex, 1);
|
||||||
|
table.rowStatuses.splice(newIndex, 0, movedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`成功将表格 ${tableIndex} 的第 ${rowIndex + 1} 行移动到第 ${newIndex + 1} 行。`, 'success');
|
||||||
|
saveTables(tables);
|
||||||
|
dispatchTableUpdate(tableIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertRow(tableIndex, data, position = 'below') {
|
||||||
|
const tables = getState();
|
||||||
|
const table = tables?.[tableIndex];
|
||||||
|
if (!table) {
|
||||||
|
log(`插入行失败:找不到索引为 ${tableIndex} 的表格。`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertIndex;
|
||||||
|
if (typeof data === 'number') {
|
||||||
|
insertIndex = position === 'above' ? data : data + 1;
|
||||||
|
} else {
|
||||||
|
insertIndex = table.rows.length;
|
||||||
|
}
|
||||||
|
if (insertIndex < 0) insertIndex = 0;
|
||||||
|
if (insertIndex > table.rows.length) insertIndex = table.rows.length;
|
||||||
|
|
||||||
|
const newRow = new Array(table.headers.length).fill('');
|
||||||
|
|
||||||
|
if (typeof data === 'object' && data !== null) {
|
||||||
|
for (const colIndex in data) {
|
||||||
|
const cIndex = parseInt(colIndex, 10);
|
||||||
|
if (!isNaN(cIndex) && cIndex < newRow.length) {
|
||||||
|
newRow[cIndex] = data[colIndex];
|
||||||
|
addHighlight(tableIndex, insertIndex, cIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.rows.splice(insertIndex, 0, newRow);
|
||||||
|
if (!table.rowStatuses) table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||||
|
table.rowStatuses.splice(insertIndex, 0, 'normal');
|
||||||
|
|
||||||
|
markTableUpdated(tableIndex);
|
||||||
|
dispatchTableUpdate(tableIndex);
|
||||||
|
log(`成功在表格 ${table.name} (索引 ${tableIndex}) 的第 ${insertIndex + 1} 行位置插入了新行。`, 'success');
|
||||||
|
|
||||||
|
commitToLastMessage(tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addRow(tableIndex) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex]) return;
|
||||||
|
const table = tables[tableIndex];
|
||||||
|
const colCount = table.headers.length;
|
||||||
|
const newRow = Array(colCount).fill('');
|
||||||
|
table.rows.push(newRow);
|
||||||
|
if (!table.rowStatuses) table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||||
|
table.rowStatuses.push('normal');
|
||||||
|
markTableUpdated(tableIndex);
|
||||||
|
dispatchTableUpdate(tableIndex);
|
||||||
|
log(`表格 [${table.name}] 新增了一行。`, 'info');
|
||||||
|
|
||||||
|
commitToLastMessage(tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addColumn(tableIndex) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex]) return;
|
||||||
|
const table = tables[tableIndex];
|
||||||
|
const newHeader = `新列 ${table.headers.length + 1}`;
|
||||||
|
table.headers.push(newHeader);
|
||||||
|
table.rows.forEach(row => row.push(''));
|
||||||
|
if (!table.columnWidths) table.columnWidths = [];
|
||||||
|
table.columnWidths.push(null);
|
||||||
|
log(`表格 [${table.name}] 新增了一列。`, 'info');
|
||||||
|
|
||||||
|
commitToLastMessage(tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateHeader(tableIndex, colIndex, value) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex] || tables[tableIndex].headers[colIndex] === undefined) return;
|
||||||
|
const tableName = tables[tableIndex].name;
|
||||||
|
const originalHeader = tables[tableIndex].headers[colIndex];
|
||||||
|
tables[tableIndex].headers[colIndex] = value;
|
||||||
|
log(`表格 [${tableName}] 的表头“${originalHeader}”已更新为“${value}”。`, 'info');
|
||||||
|
|
||||||
|
commitToLastMessage(tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRow(tableIndex, rowIndex) {
|
||||||
|
const tables = getState();
|
||||||
|
const table = tables?.[tableIndex];
|
||||||
|
if (!table || !table.rows[rowIndex]) return;
|
||||||
|
|
||||||
|
if (!table.rowStatuses) {
|
||||||
|
table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||||
|
}
|
||||||
|
|
||||||
|
table.rowStatuses[rowIndex] = 'pending-deletion';
|
||||||
|
markTableUpdated(tableIndex);
|
||||||
|
log(`表格 [${table.name}] 的第 ${rowIndex + 1} 行已标记为待删除。`, 'info');
|
||||||
|
|
||||||
|
const context = getContext();
|
||||||
|
if (context.chat?.length > 0) {
|
||||||
|
const lastMessage = context.chat[context.chat.length - 1];
|
||||||
|
if (saveStateToMessage(tables, lastMessage)) {
|
||||||
|
await saveChat();
|
||||||
|
renderTables();
|
||||||
|
dispatchTableUpdate(tableIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveChatDebounced();
|
||||||
|
renderTables();
|
||||||
|
dispatchTableUpdate(tableIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreRow(tableIndex, rowIndex) {
|
||||||
|
const tables = getState();
|
||||||
|
const table = tables?.[tableIndex];
|
||||||
|
if (!table || !table.rows[rowIndex] || !table.rowStatuses) return;
|
||||||
|
|
||||||
|
table.rowStatuses[rowIndex] = 'normal';
|
||||||
|
markTableUpdated(tableIndex);
|
||||||
|
log(`表格 [${table.name}] 的第 ${rowIndex + 1} 行已恢复。`, 'info');
|
||||||
|
|
||||||
|
const context = getContext();
|
||||||
|
if (context.chat?.length > 0) {
|
||||||
|
const lastMessage = context.chat[context.chat.length - 1];
|
||||||
|
if (saveStateToMessage(tables, lastMessage)) {
|
||||||
|
await saveChat();
|
||||||
|
renderTables();
|
||||||
|
dispatchTableUpdate(tableIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveChatDebounced();
|
||||||
|
renderTables();
|
||||||
|
dispatchTableUpdate(tableIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commitPendingDeletions() {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables) return false;
|
||||||
|
let deletionCount = 0;
|
||||||
|
|
||||||
|
tables.forEach((table, tableIndex) => {
|
||||||
|
if (!table.rowStatuses || table.rowStatuses.length === 0) return;
|
||||||
|
let tableHadDeletions = false;
|
||||||
|
for (let i = table.rows.length - 1; i >= 0; i--) {
|
||||||
|
if (table.rowStatuses[i] === 'pending-deletion') {
|
||||||
|
table.rows.splice(i, 1);
|
||||||
|
table.rowStatuses.splice(i, 1);
|
||||||
|
deletionCount++;
|
||||||
|
tableHadDeletions = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tableHadDeletions) markTableUpdated(tableIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deletionCount > 0) {
|
||||||
|
log(`已提交并永久删除了 ${deletionCount} 行。`, 'info');
|
||||||
|
const updated = getUpdatedTables();
|
||||||
|
if (updated.size > 0) {
|
||||||
|
updated.forEach(tableIndex => dispatchTableUpdate(tableIndex));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertColumn(tableIndex, colIndex, position) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex]) return;
|
||||||
|
const table = tables[tableIndex];
|
||||||
|
|
||||||
|
const insertAt = position === 'left' ? colIndex : colIndex + 1;
|
||||||
|
table.headers.splice(insertAt, 0, '新列');
|
||||||
|
table.rows.forEach(row => row.splice(insertAt, 0, ''));
|
||||||
|
if (!table.columnWidths) table.columnWidths = [];
|
||||||
|
table.columnWidths.splice(insertAt, 0, null);
|
||||||
|
|
||||||
|
log(`表格 [${table.name}] 在第 ${colIndex + 1} 列的${position === 'left' ? '左侧' : '右侧'}插入了新列。`, 'info');
|
||||||
|
commitToLastMessage(tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveColumn(tableIndex, colIndex, direction) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex]) return;
|
||||||
|
const table = tables[tableIndex];
|
||||||
|
const headers = table.headers;
|
||||||
|
const rows = table.rows;
|
||||||
|
|
||||||
|
const targetIndex = direction === 'left' ? colIndex - 1 : colIndex + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= headers.length) {
|
||||||
|
log(`无法移动列:索引 ${colIndex} 已在边界。`, 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [headerToMove] = headers.splice(colIndex, 1);
|
||||||
|
headers.splice(targetIndex, 0, headerToMove);
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const [cellToMove] = row.splice(colIndex, 1);
|
||||||
|
row.splice(targetIndex, 0, cellToMove);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (table.columnWidths && table.columnWidths.length > colIndex) {
|
||||||
|
const [widthToMove] = table.columnWidths.splice(colIndex, 1);
|
||||||
|
table.columnWidths.splice(targetIndex, 0, widthToMove);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`表格 [${table.name}] 的列“${headerToMove}”已向${direction === 'left' ? '左' : '右'}移动。`, 'info');
|
||||||
|
commitToLastMessage(tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTable(tableIndex) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex]) return;
|
||||||
|
const tableName = tables[tableIndex].name;
|
||||||
|
tables.splice(tableIndex, 1);
|
||||||
|
log(`表格 [${tableName}] 已被成功废黜。`, 'success');
|
||||||
|
|
||||||
|
const success = commitToLastMessage(tables);
|
||||||
|
if (success) {
|
||||||
|
log('废黜表格后的状态已强制写入最新消息并立即保存。', 'success');
|
||||||
|
} else {
|
||||||
|
log('无法找到可锚定的消息或保存失败,删除操作可能不会被持久化!', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTable(tableName) {
|
||||||
|
if (!tableName || !tableName.trim()) {
|
||||||
|
log('无法创建表格:名称不能为空。', 'error');
|
||||||
|
toastr.error('表格名称不能为空。', '创建失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tables = getState();
|
||||||
|
if (!tables) {
|
||||||
|
loadTables();
|
||||||
|
tables = getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tables.some(table => table.name === tableName.trim())) {
|
||||||
|
log(`无法创建表格:名为 "${tableName}" 的表格已存在。`, 'error');
|
||||||
|
toastr.error(`名为 "${tableName}" 的表格已存在。`, '创建失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTable = {
|
||||||
|
name: tableName.trim(),
|
||||||
|
headers: ['新列 1'],
|
||||||
|
rows: [],
|
||||||
|
rowStatuses: [],
|
||||||
|
columnWidths: [],
|
||||||
|
note: '这是一个新创建的表格。',
|
||||||
|
rule_add: '允许',
|
||||||
|
rule_delete: '允许',
|
||||||
|
rule_update: '允许',
|
||||||
|
charLimitRules: {},
|
||||||
|
rowLimitRule: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
tables.push(newTable);
|
||||||
|
log(`已成功创建新表格:[${tableName.trim()}]。`, 'success');
|
||||||
|
|
||||||
|
const success = commitToLastMessage(tables);
|
||||||
|
if (success) {
|
||||||
|
log('新表格状态已强制写入最新消息并立即保存。', 'success');
|
||||||
|
} else {
|
||||||
|
log('无法找到可锚定的消息或保存失败,新表格可能不会被持久化!', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameTable(tableIndex, newName) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex]) {
|
||||||
|
log('重命名失败:表格不存在。', 'error');
|
||||||
|
toastr.error('表格不存在。', '重命名失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedName = newName.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
log('重命名失败:名称不能为空。', 'error');
|
||||||
|
toastr.error('表格名称不能为空。', '重命名失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tables.some((table, index) => index !== tableIndex && table.name === trimmedName)) {
|
||||||
|
log(`重命名失败:名为 "${trimmedName}" 的表格已存在。`, 'error');
|
||||||
|
toastr.error(`名为 "${trimmedName}" 的表格已存在。`, '重命名失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldName = tables[tableIndex].name;
|
||||||
|
tables[tableIndex].name = trimmedName;
|
||||||
|
log(`表格 "${oldName}" 已重命名为 "${trimmedName}"。`, 'success');
|
||||||
|
|
||||||
|
commitToLastMessage(tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveTable(tableIndex, direction) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex]) return;
|
||||||
|
|
||||||
|
const newIndex = direction === 'up' ? tableIndex - 1 : tableIndex + 1;
|
||||||
|
if (newIndex < 0 || newIndex >= tables.length) {
|
||||||
|
log(`无法移动表格:索引 ${tableIndex} 已在边界。`, 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const temp = tables[tableIndex];
|
||||||
|
tables[tableIndex] = tables[newIndex];
|
||||||
|
tables[newIndex] = temp;
|
||||||
|
|
||||||
|
log(`表格 [${temp.name}] 的顺序已调整。`, 'success');
|
||||||
|
|
||||||
|
const success = commitToLastMessage(tables);
|
||||||
|
if (success) {
|
||||||
|
log('表格顺序调整后的状态已强制写入最新消息并立即保存。', 'success');
|
||||||
|
} else {
|
||||||
|
log('无法找到可锚定的消息或保存失败,顺序调整可能不会被持久化!', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTableRules(tableIndex, newRules) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex]) return;
|
||||||
|
const table = tables[tableIndex];
|
||||||
|
table.note = newRules.note;
|
||||||
|
table.rule_add = newRules.rule_add;
|
||||||
|
table.rule_delete = newRules.rule_delete;
|
||||||
|
table.rule_update = newRules.rule_update;
|
||||||
|
table.charLimitRules = newRules.charLimitRules;
|
||||||
|
table.rowLimitRule = newRules.rowLimitRule;
|
||||||
|
table.simplifyRowThreshold = newRules.simplifyRowThreshold;
|
||||||
|
|
||||||
|
delete table.charLimitRule;
|
||||||
|
|
||||||
|
log(`表格 [${table.name}] 的规则已更新。`, 'info');
|
||||||
|
commitToLastMessage(tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRow(tableIndex, rowIndex, data) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex]) {
|
||||||
|
log(`AI指令错误:尝试在不存在的表格索引 ${tableIndex} 中操作。`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const table = tables[tableIndex];
|
||||||
|
|
||||||
|
if (rowIndex >= table.rows.length) {
|
||||||
|
log(`AI指令意图更新不存在的行 (rowIndex: ${rowIndex}),已智能转换为在表格 [${table.name}] 末尾新增一行。`, 'warn');
|
||||||
|
insertRow(tableIndex, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = table.rows[rowIndex];
|
||||||
|
for (const colIndex in data) {
|
||||||
|
const cIndex = parseInt(colIndex, 10);
|
||||||
|
if (cIndex < row.length) {
|
||||||
|
row[cIndex] = data[cIndex];
|
||||||
|
addHighlight(tableIndex, rowIndex, cIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markTableUpdated(tableIndex);
|
||||||
|
dispatchTableUpdate(tableIndex);
|
||||||
|
log(`AI 指令更新了表格 [${table.name}] 的第 ${rowIndex + 1} 行。`, 'info');
|
||||||
|
|
||||||
|
commitToLastMessage(tables);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllTables() {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables) {
|
||||||
|
log('无法清空:当前表格状态为空。', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tables.forEach((table, tableIndex) => {
|
||||||
|
if (table.rows.length > 0) markTableUpdated(tableIndex);
|
||||||
|
table.rows = [];
|
||||||
|
table.rowStatuses = [];
|
||||||
|
});
|
||||||
|
log('所有表格的行数据已在内存中清空。', 'warn');
|
||||||
|
|
||||||
|
dispatchAllTablesUpdate();
|
||||||
|
|
||||||
|
const success = commitToLastMessage(tables);
|
||||||
|
if (success) {
|
||||||
|
log('清空行数据后的状态已强制写入最新消息并立即保存。', 'success');
|
||||||
|
toastr.success('所有表格的剧情内容已清空。', '操作完成');
|
||||||
|
} else {
|
||||||
|
log('无法找到可锚定的消息或保存失败,清空操作可能不会被持久化!', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateColumnWidth(tableIndex, colIndex, width) {
|
||||||
|
const tables = getState();
|
||||||
|
if (!tables || !tables[tableIndex]) return;
|
||||||
|
const table = tables[tableIndex];
|
||||||
|
if (!table.columnWidths) table.columnWidths = [];
|
||||||
|
while (table.columnWidths.length < table.headers.length) {
|
||||||
|
table.columnWidths.push(null);
|
||||||
|
}
|
||||||
|
table.columnWidths[colIndex] = width;
|
||||||
|
|
||||||
|
commitToLastMessage(tables);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
51
core/table-system/events-dispatch.js
Normal file
51
core/table-system/events-dispatch.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* @file events-dispatch.js —— SuperMemory 事件分发(Phase 0.4 自 manager.js 抽出)
|
||||||
|
*
|
||||||
|
* 把单表 / 全表的最新状态推送给 SuperMemory(优先 Bus 直调,降级 CustomEvent)。
|
||||||
|
* 独立成模块的原因:manager.js 与 actions/ui-mutations.js 都需要调用,
|
||||||
|
* 放在任何一方都会制造新的循环依赖;本模块只依赖 store / events-schema / logger,零环。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { extension_settings } from '/scripts/extensions.js';
|
||||||
|
import { extensionName } from '../../utils/settings.js';
|
||||||
|
import { log } from './logger.js';
|
||||||
|
import { getState } from './infra/store.js';
|
||||||
|
import { createTableUpdateEvent, inferTableRole } from './events-schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把单个表格的最新状态推送给 SuperMemory(优先 Bus 直调,降级 CustomEvent)。
|
||||||
|
* @param {number} tableIndex
|
||||||
|
*/
|
||||||
|
export function dispatchTableUpdate(tableIndex) {
|
||||||
|
const settings = extension_settings[extensionName] || {};
|
||||||
|
if (settings.super_memory_enabled === false) return;
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
if (!state || !state[tableIndex]) return;
|
||||||
|
const table = state[tableIndex];
|
||||||
|
const role = inferTableRole(table.name);
|
||||||
|
|
||||||
|
const smBus = window.Amily2Bus?.query('SuperMemory');
|
||||||
|
if (smBus?.pushUpdate) {
|
||||||
|
smBus.pushUpdate({
|
||||||
|
tableName: table.name,
|
||||||
|
data: table.rows,
|
||||||
|
headers: table.headers,
|
||||||
|
rowStatuses: table.rowStatuses ?? [],
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.dispatchEvent(createTableUpdateEvent(table));
|
||||||
|
}
|
||||||
|
log(`[SuperMemory] Dispatched update for ${table.name} (role: ${role})`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发所有表格的全量同步(Pipeline 变更后调用)。
|
||||||
|
*/
|
||||||
|
export function dispatchAllTablesUpdate() {
|
||||||
|
const state = getState();
|
||||||
|
if (!state) return;
|
||||||
|
log('[SuperMemory] Dispatching update events for ALL tables...', 'info');
|
||||||
|
state.forEach((_, index) => dispatchTableUpdate(index));
|
||||||
|
}
|
||||||
@@ -5,16 +5,17 @@
|
|||||||
* - 状态:infra/store.js (currentTablesState / highlights / updatedTables)
|
* - 状态:infra/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 / ... / clearAllTables,Phase 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)
|
* - triggerSync(SuperMemory 全量同步入口)
|
||||||
* - 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
2
index.js
2
index.js
@@ -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');
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>提取标签(可选,只取回复中 <标签>...</标签> 的内容;标签缺失时回退完整回复)
|
||||||
|
<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();
|
||||||
|
|||||||
62
ui/profile-slider-guard.js
Normal file
62
ui/profile-slider-guard.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 输入项。
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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!)')]);
|
||||||
Reference in New Issue
Block a user