mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-17 22:55:52 +00:00
Compare commits
17 Commits
d9fa3072a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc6618a493 | ||
|
|
2dad292d70 | ||
|
|
0d7e3b799e | ||
|
|
1a4a10d42d | ||
|
|
347016d5ac | ||
|
|
59c4adc1c0 | ||
|
|
e66544f774 | ||
|
|
d6b3b00c86 | ||
|
|
a8c3ad9027 | ||
|
|
0e11f85031 | ||
|
|
9bc2f694b0 | ||
|
|
08e1dbde85 | ||
|
|
42e0bdec19 | ||
|
|
3e217e8ed8 | ||
|
|
2c3072a3d8 | ||
|
|
e00302d04b | ||
|
|
dabc8992f1 |
@@ -6,6 +6,7 @@ import { extension_settings, getContext } from "/scripts/extensions.js";
|
||||
import { compatibleTriggerSlash } from '../../core/tavernhelper-compatibility.js';
|
||||
import { getSlotProfile, providerToApiMode } from '../../core/api/api-resolver.js';
|
||||
import { configManager } from '../../utils/config/ConfigManager.js';
|
||||
import { detectVendor } from '../../utils/api-vendor.js';
|
||||
|
||||
function normalizeApiResponse(responseData) {
|
||||
let data = responseData;
|
||||
@@ -180,7 +181,7 @@ async function callCwbOpenAITest(messages, options) {
|
||||
};
|
||||
});
|
||||
|
||||
const isGoogleApi = validatedOptions.apiUrl.includes('googleapis.com');
|
||||
const isGoogleApi = (await detectVendor(validatedOptions.apiUrl)) === 'google';
|
||||
|
||||
const requestBody = {
|
||||
chat_completion_source: 'openai',
|
||||
@@ -648,7 +649,7 @@ export async function callCustomOpenAI(messages) {
|
||||
throw new Error('API URL/Model未配置。');
|
||||
}
|
||||
|
||||
const isGoogleApi = state.customApiConfig.url.includes('googleapis.com');
|
||||
const isGoogleApi = (await detectVendor(state.customApiConfig.url)) === 'google';
|
||||
|
||||
const requestBody = {
|
||||
messages: messages,
|
||||
|
||||
@@ -449,6 +449,30 @@ export function bindSettingsEvents($settingsPanel) {
|
||||
|
||||
$(document).trigger('cwb:master-switch-changed', { isEnabled: isChecked });
|
||||
});
|
||||
|
||||
// 处理来自 API 配置面板总开关同步的 change 事件(该面板通过 dispatchEvent 设置 checkbox 状态)
|
||||
// jQuery 的 .prop('checked') 不触发 change,故与上方 click 处理器不会双重触发
|
||||
$panel.on('change', '#cwb_master_enabled-checkbox', function () {
|
||||
const isChecked = $(this).prop('checked');
|
||||
|
||||
getSettings().cwb_master_enabled = isChecked;
|
||||
|
||||
const overrides = JSON.parse(localStorage.getItem(CWB_BOOLEAN_SETTINGS_OVERRIDE_KEY) || '{}');
|
||||
overrides.cwb_master_enabled = isChecked;
|
||||
localStorage.setItem(CWB_BOOLEAN_SETTINGS_OVERRIDE_KEY, JSON.stringify(overrides));
|
||||
|
||||
state.masterEnabled = isChecked;
|
||||
saveSettingsDebounced();
|
||||
updateControlsLockState();
|
||||
|
||||
const $viewerButton = $(`#${CHAR_CARD_VIEWER_BUTTON_ID}`);
|
||||
if ($viewerButton.length > 0) {
|
||||
$viewerButton.toggle(isChecked && state.viewerEnabled);
|
||||
}
|
||||
|
||||
showToastr('info', `CharacterWorldBook 已 ${isChecked ? '启用' : '禁用'}`);
|
||||
$(document).trigger('cwb:master-switch-changed', { isEnabled: isChecked });
|
||||
});
|
||||
}
|
||||
|
||||
function updateApiModeUI(mode) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { saveSettingsDebounced } from '/script.js';
|
||||
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
||||
import { configManager } from '../../utils/config/ConfigManager.js';
|
||||
import { watchProfileSliderGuard } from '../../ui/profile-slider-guard.js';
|
||||
|
||||
const { jQuery: $, SillyTavern } = window;
|
||||
|
||||
@@ -699,6 +700,9 @@
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
// cwb 槽分配 profile 后,温度/maxTokens 由 profile 权威控制(T-006 informational 化)
|
||||
watchProfileSliderGuard('cwb', ['#cwb-temperature', '#cwb-max-tokens']);
|
||||
|
||||
$('#cwb-test-connection').off('click').on('click', async function() {
|
||||
const $button = $(this);
|
||||
$button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 测试中...');
|
||||
|
||||
162
DEPLOY_NOTE.md
Normal file
162
DEPLOY_NOTE.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 部署更新日志
|
||||
|
||||
每个版本块格式:`## v{version}`,Jenkins 构建时自动提取对应块作为 GitHub 提交说明。
|
||||
|
||||
---
|
||||
|
||||
## v2.2.2
|
||||
|
||||
### 新功能
|
||||
|
||||
- **Function Call 填表模式**:在填表设置中新增独立开关,启用后支持通过 OpenAI 兼容接口(DeepSeek / OpenRouter / 各类中转等)直接返回结构化操作列表,绕过 `<Amily2Edit>` 文本解析路径,填表更稳定
|
||||
- 遇到不支持 `tool_choice` 的接口时自动降级重试
|
||||
- 对思考模型注入强制调用指令,防止绕过工具直接输出文本
|
||||
- 全部走 ST 后端代理,修复 CSP 拦截直连外部 URL 的问题
|
||||
- **主界面新增提示词链编辑器入口**,同时调换了记忆管理与角色世界书的按钮位置
|
||||
- **规则中心**新增"自动排除用户楼层"选项
|
||||
|
||||
### 修复
|
||||
|
||||
- 提示词链按钮点击无响应(改为事件委托方式绑定)
|
||||
- 拖拽组件微抖误触发(加 5px 移动阈值过滤)
|
||||
- 填表检查窗若干问题修复;翰林院(批量回填)修复;防抖逻辑落地
|
||||
- 角色世界书入口添加使用警告弹窗(强制 10 秒倒计时),提示该功能长期未维护
|
||||
- ApiProfile `fakeStream` 字段保存丢失问题
|
||||
- 正文优化默认改为关闭状态
|
||||
- NGMS / NCCS API 配置槽位标签修正(NGMS→总结,NCCS→填表)
|
||||
- API Profile 面板选择逻辑统一重构,修复多处旧字段覆盖新配置的问题
|
||||
- 世界书控制参数兼容性修复(排除递归、插入位置、扫描深度等,适配 ST 1.17.0+)
|
||||
|
||||
---
|
||||
|
||||
## v2.2.3
|
||||
|
||||
### 新功能
|
||||
|
||||
- Function Call 填表开关下方新增公益站风险提示横幅:部分公益站会屏蔽 tools 参数,请确认支持情况避免被意外封禁
|
||||
|
||||
### 修复
|
||||
|
||||
- **Function Call 填表**:
|
||||
- 修复 ST 代理以 HTTP 200 + error body 形式返回错误、导致降级重试机制从未触发的问题
|
||||
- 修复思考模式模型(如 DeepSeek v4-flash)因 tool_choice 不兼容返回 Bad Request 后正确降级并重试
|
||||
- 重试时自动追加强制调用指令,防止思考模型绕过工具直接输出文本造成无效二次开销
|
||||
- **超级记忆 / 翰林院**:
|
||||
- 修复 `getRagSettings()` 读写顶层路径而非嵌套路径,导致打开超级记忆面板后向量化、归档等开关在重载时被全默认值覆盖的问题
|
||||
- 修复自动归档失效问题
|
||||
- 修复归档管理器在同一事件中被三次触发的回归问题
|
||||
- 修复翰林院设置旧版迁移逻辑异常
|
||||
|
||||
---
|
||||
|
||||
## v2.2.4
|
||||
|
||||
### 新功能
|
||||
|
||||
- **Function Call 填表**:
|
||||
- FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
|
||||
- 操作列表为空时在日志面板输出原始响应 JSON,便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
|
||||
|
||||
### 修复
|
||||
|
||||
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段(URL / Key / Model),统一走 API 连接配置功能分配槽位
|
||||
- **表格**:
|
||||
- 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30)
|
||||
- 修复分步填表并发锁与 async/await 时序问题
|
||||
- 修复外层多余 `try...finally` 导致的插件加载报错
|
||||
- **Rerank**:
|
||||
- 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`)
|
||||
- 补全 `hly-rerank-api-mode` 加载绑定及默认值
|
||||
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
|
||||
|
||||
---
|
||||
|
||||
## v2.2.5
|
||||
|
||||
### 修复
|
||||
|
||||
- **翰林院(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.7
|
||||
|
||||
### 修复
|
||||
|
||||
- **分步填表 · 保留楼层场景下 swipe 最新楼会回退掉已填内容**:开启「保留楼层(bufferSize)」后,分步填表处理的是较早楼层、状态本应绑定到「被填楼层里最后一条」(E)。但 `updateTableFromText` / `updateTableFromOps` 在应用完操作后会**统一把表格状态写到聊天最新楼 L**,覆盖了随后 `markTargetsProcessed` 写到 E 的快照(`loadTables` 从尾部回溯先命中 L)。结果状态实际落在 L 上,**滑动/重新生成最新楼时 `rollbackState` 回退到上一轮快照,把本轮填入的内容一起丢掉、且因较早楼层的 hash 仍在而不再重填**。
|
||||
- 修复:给 `updateTableFromText` / `updateTableFromOps` 增加 `skipPersist` 选项;分步填表(文本 / Function Call / 手动应用三条 commit 路径)统一传入,跳过"写最新楼",改由 `markTargetsProcessed` 把状态保存到 E。
|
||||
- `bufferSize=0`(默认)时 E 即最新楼,行为与旧版一致;仅 `bufferSize>0` 的保留楼层场景受影响并被修复。
|
||||
|
||||
---
|
||||
|
||||
## v2.2.6
|
||||
|
||||
### 新功能
|
||||
|
||||
- **翰林院向量化质量升级**:
|
||||
- **边界感知切块**:替换四个来源(聊天记录/小说/世界书/手动)的纯字符硬切——优先在段落边界断开,其次句末标点(含中文引号闭合),极端长串才硬切;句子/对话不再被拦腰截断,embedding 质量同步受益。仅影响新录入,已有向量无需重建
|
||||
- **注入时序重排**:检索结果注入提示词前按时序重排(聊天记录按楼层、小说按卷/章/节——中文数字章节号可解析),rerank 只决定"选哪些块",不再决定呈现顺序;修复"不打不相识的剧情之后紧跟关系亲密"这类因按相关度排序导致的认知时间错乱
|
||||
- **断层提示**:聊天记录相邻块楼层跳跃时自动插入"与上文相隔约 N 楼,并非连续发生"提示行,消除中间剧情缺失造成的割裂感
|
||||
- **时间标识**:新录入的聊天记录块在来源标识中带上消息发送时间(ST 向量存储不持久化元数据,时间必须写入块文本才能在检索后取回;旧格式块兼容解析)
|
||||
- **记忆块工作流(memory-blocks)**:剧情优化新增"自定义记忆块"体系——占位符驱动的并发工作流框架
|
||||
- 在剧情优化面板「匹配替换 (sulv)」下方可增删自定义块:每个块定义一个占位符,执行剧情优化时主/拦截提示词中的占位符会被块的产出替换
|
||||
- **静态块**:直接输出固定内容;**AI 调用块**:用所选 API 功能槽独立请求一次,把回复(或其中指定 `<标签>` 的内容)作为替换值
|
||||
- 原有 sulv1-4 速率占位符迁入同一框架,行为与旧版逐字节一致
|
||||
- 块定义为纯 JSON、随设置持久化,为后续导入导出与战斗系统接入预留扩展点
|
||||
- 框架层新增**顺序拼接式 Chain**(`composeChain`):与占位符替换并列的第二种组合范式——同链的块并发执行后按 `order` 排序、以 `separator` 拼接并可选 `header/footer` 包裹,产出一个完整注入块;为记忆注入合成块与战斗系统"底部战报块"预留的承载结构,本版本暂无 UI 入口
|
||||
- **渐进记忆(开发中功能,暂未对外开放)**:主菜单新增独立入口(点击提示"开发中,未来版本开放"),后续完善后放出。当前已落地的设计:
|
||||
- 按"近期完整、远期摘要"的时间梯度,从指定表格(默认总结表,行序旧→新)采样历史并注入上下文:最新 X 行全量保留 + 其余历史对半拆分,较近一半等距取 Y 行、较远一半等距取 Z 行(中心对齐等距采样,不随机、不首尾加权,避免内容扎堆或事件结局被规律性忽略)
|
||||
- 经 `setExtensionPrompt` 直接注入当回合上下文——内容独立、不写世界书、不随聊天/角色卡导出,生命周期天然跟随会话(区别于超级记忆的世界书条目路线)
|
||||
- 注入位置 / 深度 / 角色 / 模板(含 `{{progressive_memory}}` 占位符)均可在面板配置;采样参数 X/Y/Z 默认 5/5/3,全部纯 JSON 持久化
|
||||
- 采样器 `sampler.js` 为纯函数,参数结构与 memory-blocks 工作链对齐,后续可平移为 `progressive_sample` 节点
|
||||
- **超级记忆 · 首行常驻**(表格专属配置新增开关,默认关闭):表格第一行通常是总调/全局定义行(基调、主线目标等),原先与普通行一样走绿灯——没人提到主键就永远不注入;开启后该行详情条目升为蓝灯常驻,切换即时生效
|
||||
- **API 连接配置**:
|
||||
- 角色世界书(cwb)与一键生卡(autoCharCard)纳入旧配置自动迁移:老用户首次加载会把旧 URL / Key / 模型自动迁移为连接配置并分配槽位(一键生卡仅在规划者与执行者配置一致或规划者为空时迁移,避免悄悄改变行为)
|
||||
- **profile 已分配时参数控件 informational 化**:主面板 / 并发剧情优化 / 角色世界书 / 术语表的温度、maxTokens 控件在槽位分配 profile 后自动禁用并显示"由连接配置控制"提示,消除"改了没效果"的用户陷阱
|
||||
- **profile 状态卡新增"本设备无 Key"警示**:API Key 仅保存在最初填写它的设备/浏览器上(安全设计,不随云端设置同步),换设备后状态卡会直接亮出警示徽标,不必等到调用报错才发现
|
||||
|
||||
### 修复
|
||||
|
||||
- **独立聊天记忆从摆设变真功能**:此前向量数据"随卡不随聊天"——开启"独立聊天记忆"后录入仍存进角色库、查询却去查一个从未被写入过的聊天集合、计数恒为 0,整体静默失效。现已重构为聊天级分桶:
|
||||
- 独立模式下,聊天记录类向量按当前聊天隔离存储与检索,同一张卡开多个聊天(不同剧情线)的记忆互不污染
|
||||
- 小说 / 世界书 / 手动录入属于"知识",仍随角色卡跨聊天共享;全局库不受影响
|
||||
- 知识管理列表为聊天专属库显示"聊天级"徽标;聊天级库禁止移动到全局
|
||||
- 统一模式(默认关闭独立记忆)的存量数据与行为完全不变
|
||||
- 已知限制:聊天专属记忆跟随聊天文件,重命名聊天文件会使其失联(与 ST 官方向量扩展同等限制)
|
||||
- **超级排序截断顺序修正**:开启"超级排序"时,时序重排发生在 top_n 截断之前,导致保留的是"时序最早"而非"最相关"的块,检索结果长期偏向最旧的聊天记录。现改为先按相关度截取 top_n、再做时序排序
|
||||
- **翰林院向量化失败("向量化块数量不识别"反馈)**:
|
||||
- 一次性清洗 profile-sync 历史污染:`retrieval/rerank.apiKey` 中的掩码占位符在持久层根治(此前仅读取侧防御);`apiEndpoint` / `rerank.apiMode` 的非法值(如被旧版写入的空字符串)归一化为 `custom`
|
||||
- 修复 `apiEndpoint` 为空/非法时请求被硬定向到 `api.openai.com`、无视用户自定义 URL 的问题(CSP 拦截 / 401 的元凶)
|
||||
- 修复**本地代理(LM Studio/Ollama)模式**自始就缺少 URL 分支、同样被错误定向到 openai.com 的问题
|
||||
- API 模式下拉补全 `OpenAI 官方` / `Azure` 选项;默认 API 模式改为 `custom`(与默认 URL 配套),新用户不再因选项缺失导致首次保存写入空值
|
||||
- profile-sync 给下拉框赋不存在选项值的污染源头修复(影响所有模块面板,不止翰林院)
|
||||
- **Rerank "测试成功但实际请求报 API Key 未提供"(路径分叉根因)**:实际重排调用 `executeRerank(query, docs, settings.rerank)` 直接把 legacy 嵌套设置当连接传入,绕过了 `getRerankSettings()` 的 profile 解析;而「测试连接」传 `null` 会正常解析 profile——于是用 API Profile 配 rerank 的用户测试通过、实际生成时却拿到空 apiKey/stale url 报错。现实际调用点统一走 `getRerankSettings()`(profile 优先、legacy 兜底),与测试路径一致;`enabled / notify / hybrid_alpha` 等行为开关仍读 legacy 设置
|
||||
- **Rerank "API Key 未提供"报错升级**:当原因是"连接配置在本设备没有可用 Key"时,报错会直接说明 Key 的设备本地性并指引到 API 连接配置重新填写(向量化 Google 直连、获取模型列表同步处理)
|
||||
- **旧配置迁移**:一键生卡迁移时排除掩码占位符,避免把历史污染的假 Key 迁入新连接配置
|
||||
- **超级记忆稳定性专项**(针对"工作不大稳定"反馈,4 处根因一次修复):
|
||||
- **切聊天竞态污染**:CHAT_CHANGED 时超级记忆立即全量同步,而表格系统延迟 100ms 才加载新聊天的表格,导致【旧聊天】的表格内容被写进【新角色】的记忆世界书;两边表名不同时旧表条目无 GC 兜底会**永久残留**("记忆串台"元凶)。现 CHAT_CHANGED 只确保世界书存在,新状态同步交由 `loadTables()` 完成后的自动推送,单次且时序正确
|
||||
- **死代码双轨存储拆除**:`saveStateToMetadata` / `tryRestoreStateFromMetadata` 把表格状态写到 `msg.metadata`——该字段非 ST 持久化位(同 v2.2.5 二次填表修过的坑),写入即蒸发、恢复永远为空,且每次同步还白调一次 `saveChat()`。整条链路删除,表格状态唯一信源为表格系统的 `msg.extra.amily2_tables_data`
|
||||
- **`awaitSync()` 穿透**:同步队列正忙时 `pushUpdate` 会用一个立即 resolve 的空 Promise 覆盖 `_syncPromise`,Pipeline Stage 4 等待形同虚设、后续阶段在同步未完成时被放行。现忙时不覆盖,正在运行的 drain 循环自然吃掉新入队项
|
||||
- **开关打开不生效**:启动时若总开关为关,初始化早退且不注册监听器;此后在 UI 勾选开关只写设置,超级记忆直到刷新页面前都是死的。现勾选即触发初始化(幂等)
|
||||
- 附带:`forceSyncAll` 的表格角色推断改为复用 `events-schema.inferTableRole`,消除两处重复逻辑漂移风险;每次切聊天的双倍全量同步(restore 路径一次 + 显式一次)随死代码移除归一
|
||||
|
||||
### 重构
|
||||
|
||||
- 表格核心 `manager.js` 瘦身(约 1050 → 600 行):19 个 UI 突变操作拆分至 `actions/ui-mutations.js`,SuperMemory 事件分发拆分至 `events-dispatch.js`;全部经 re-export 保持兼容,外部调用路径零改动
|
||||
- 角色世界书最后 2 处散乱的厂商 URL 判断迁移至 `detectVendor` 统一入口,业务路径上不再有硬编码的 URL substring 判断
|
||||
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. 总览与核心开关
|
||||
|
||||
这里是翰林院的仪表面板,展示了核心状态并提供了最高权限的操作。
|
||||
|
||||
@@ -60,9 +60,16 @@ export function makeDraggable($element, onClick, storageKey) {
|
||||
});
|
||||
};
|
||||
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
const dragMove = (e) => {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
if (!hasDragged) {
|
||||
const coords = getEventCoords(e.originalEvent || e);
|
||||
const dist = Math.abs(coords.x - startPos.x) + Math.abs(coords.y - startPos.y);
|
||||
if (dist < DRAG_THRESHOLD) return;
|
||||
}
|
||||
hasDragged = true;
|
||||
|
||||
const coords = getEventCoords(e.originalEvent || e);
|
||||
|
||||
@@ -194,7 +194,7 @@ export function toggleSettingsOrb() {
|
||||
}
|
||||
}
|
||||
|
||||
async function showPresetSettings() {
|
||||
export async function showPresetSettings() {
|
||||
const template = $(await renderExtensionTemplateAsync(presetSettingsPath, 'prese-settings'));
|
||||
|
||||
renderPresetManager(template);
|
||||
|
||||
22
SL/module/ProgressiveMemoryModule.js
Normal file
22
SL/module/ProgressiveMemoryModule.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module, ModuleBuilder } from './Module.js';
|
||||
import { bindProgressiveMemoryEvents } from '../../core/progressive-memory/bindings.js';
|
||||
|
||||
const builder = new ModuleBuilder()
|
||||
.name('ProgressiveMemory')
|
||||
.view('core/progressive-memory/index.html')
|
||||
.strict(true)
|
||||
.required(['mount']);
|
||||
|
||||
export default class ProgressiveMemoryModule extends Module {
|
||||
constructor() {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
async mount() {
|
||||
if (this.el) {
|
||||
this.el.id = 'amily2_progressive_memory_panel';
|
||||
this.el.style.display = 'none';
|
||||
}
|
||||
bindProgressiveMemoryEvents();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import WorldEditorModule from './WorldEditorModule.js';
|
||||
import GlossaryModule from './GlossaryModule.js';
|
||||
import RendererModule from './RendererModule.js';
|
||||
import SuperMemoryModule from './SuperMemoryModule.js';
|
||||
import ProgressiveMemoryModule from './ProgressiveMemoryModule.js';
|
||||
import ApiConfigModule from './ApiConfigModule.js';
|
||||
import RuleConfigModule from './RuleConfigModule.js';
|
||||
import SfiGenModule from './SfiGenModule.js';
|
||||
@@ -34,6 +35,7 @@ export function registerAllModules() {
|
||||
registry.register('Glossary', () => new GlossaryModule());
|
||||
registry.register('Renderer', () => new RendererModule());
|
||||
registry.register('SuperMemory', () => new SuperMemoryModule());
|
||||
registry.register('ProgressiveMemory', () => new ProgressiveMemoryModule());
|
||||
registry.register('ApiConfig', () => new ApiConfigModule());
|
||||
registry.register('RuleConfig', () => new RuleConfigModule());
|
||||
registry.register('SfiGen', () => new SfiGenModule());
|
||||
|
||||
14
TODO.md
14
TODO.md
@@ -46,7 +46,7 @@
|
||||
|
||||
- 添加记忆管理并发调用
|
||||
|
||||
### 最新更新 (待发布)
|
||||
### 2.1.1 (2026/04/23)
|
||||
|
||||
以下为修复内容:
|
||||
- **自动写卡系统 Diff 视图修复**:
|
||||
@@ -81,6 +81,18 @@
|
||||
- **Ngms API 强制参数**:在 `core/api/Ngms_api.js` 中,移除了旧版 UI 中的温度和最大 Token 设置,强制将默认温度设为 `1.0`,最大 Token 设为 `30000`,以确保总结任务的稳定性和完整性。
|
||||
- **总结失败自动重试**:在 `core/historiographer.js` 中为“微言录”和“宏史卷”的生成过程添加了自定义重试逻辑。用户可在 UI 中设置重试次数,当 AI 返回空内容时,系统会自动等待并重试,降低了因 API 波动导致的总结失败率。
|
||||
- **时间跨度标识优化**:修改了 `utils/settings.js` 中的”微言录”和”宏史卷”提示词,强制要求 AI 在提取时间时加入相对时间跨度标识 `(Xd)`(如 `2023-09-15(2d)-星期五-15:00`),以解决长篇剧情中因缺乏具体日期导致的时间线混乱问题。
|
||||
- **翰林院设置回填中断修复(Rerank 等开关无法回显的根因)**:修复了 `ui/hanlinyuan-bindings.js` 的 `loadSettingsToUI` 在处理“标签提取”相关 DOM(`hly-tag-extraction-toggle` / `hly-tag-input` / `hly-tag-input-container`,已在 2.1.0 重构中删除)时对 `null` 赋值抛出 TypeError 的问题。由于该异常发生在 Rerank 设置回填之前,导致 Rerank 等开关虽已正确保存至 `extension_settings['hanlinyuan-rag-core']`,但刷新后 UI 不再回显,表现为“开关无法持久化”。清理相关 DOM 回填与 `bindInternalUIEvents` 中同名元素的事件绑定后,Rerank 等翰林院面板设置可正常持久化显示。
|
||||
- **翰林院孤儿引用清理**:移除 `ui/hanlinyuan-bindings.js` → `updateAndSaveSetting` 中对已删除函数 `syncHanlinLinkedRuleProfile` 的四处调用,修复了修改浓缩/查询预处理的标签提取或标签字段时抛出 ReferenceError 的问题(2.1.0 重构遗留)。
|
||||
- **超级记忆 RAG 设置路径修复**:修复了 `core/super-memory/bindings.js` 中 `getRagSettings` 使用错误路径 `extension_settings[extensionName]['hanlinyuan-rag-core']` 读写的问题。翰林院核心 (`core/rag-processor.js`) 使用的是顶层 `extension_settings['hanlinyuan-rag-core']`,改为一致路径后,归档开关 / 关联图谱开关 / 归档阈值等设置可正确持久化并与翰林院面板同步。
|
||||
- **分步填表防抖延迟参数落地**:之前 `utils/settings.js` 与 `core/table-system/settings.js` 均声明了 `secondary_filler_delay` 默认值,但既没有 UI 入口也没有在代码中被读取。现已:
|
||||
- 在「分步填表高级控制」面板新增「触发延迟 (毫秒)」数值输入(`assets/amily-data-table/Memorisation-forms.html`);
|
||||
- 在 `ui/table-bindings.js` 中为该输入框补齐值回填与 `updateAndSaveTableSetting('secondary_filler_delay', ...)` 的 change 绑定;
|
||||
- 在 `core/table-system/secondary-filler.js` 的 `fillWithSecondaryApi` 入口处实现真正的防抖:自动触发(`forceRun=false`)且延迟 > 0 时,会用模块级定时器调度本次调用,延迟期内再次到来的触发会重置计时器;`forceRun=true` 的手动触发及重新填表仍会立即执行,并清掉待触发的防抖任务。
|
||||
- **填表响应检查窗(Amily2Edit 指令块缺失处理)**:
|
||||
- 新增 `ui/page-window.js` → `showTableFillReviewModal`,参照总结模块 `showSummaryModal` 的交互模式,提供原始响应查看/编辑、继续补全、重新填表、手动应用、取消五种操作。
|
||||
- **批量填表 / 楼层填表**:修改 `core/table-system/batch-filler.js` 的 `runBatchAttempt` 与 `startFloorRangeFilling`,当 AI 响应缺少 `<Amily2Edit>` 指令块时不再直接抛错进入自动重试,而是弹出检查窗让用户查看原始报文;批次模式下会先将按钮置为“继续填表”暂停状态,操作结束后自动恢复流程;网络/空响应等其它异常仍走原有的 `MAX_RETRIES` 自动重试。
|
||||
- **分步填表**:修改 `core/table-system/secondary-filler.js` 的 `fillWithSecondaryApi`,在缺少指令块时弹出同款检查窗,并将原先分散的“写表 → 存 hash → saveChat”流程抽取为 `commitSecondaryFillResult` 公共函数,供正常路径与手动应用路径复用;顺带补齐该文件缺失的 `log` 导入。
|
||||
- **继续补全实现**:新增 `requestContinuation` / `requestSecondaryContinuation` 工具函数,将用户当前编辑的文本作为 `assistant` 消息追加到原始请求之后,并附加专用的“接续”用户提示词再次调用表格模型,将返回文本拼接到原文末尾回填到检查窗文本框中。
|
||||
|
||||
### 2.1.0 (2026/04/18)
|
||||
|
||||
|
||||
323
TODOList.md
323
TODOList.md
@@ -7,252 +7,97 @@
|
||||
> - [TableTODO.md](TableTODO.md) — 表格模块 IAD 深度重构计划(Phase 0/B/C)
|
||||
> - [TODO.md](TODO.md) — 旧版本变更日志(保留作为发布记录)
|
||||
>
|
||||
> 最后更新:2026-05-08,对应 v2.2.0 已发布。
|
||||
> 最后更新:2026-06-11,对应 v2.2.5+(2603 分支)。
|
||||
|
||||
---
|
||||
|
||||
## 一、最近落地(v2.1.1 → v2.2.0)
|
||||
## 一、最近落地(v2.2.0 → 当前)
|
||||
|
||||
> 上下文摘要,让接手者了解当前状态。代码细节看对应 commit。
|
||||
|
||||
| commit | 内容 | 涉及范围 |
|
||||
|--------|------|--------|
|
||||
| `d283ff4` | 表格模块 IAD 解耦 + API 自定义参数 + 厂商预设连接 | `core/table-system/*` 新增 dto/infra/actions;`assets/api-vendor-params.json`;UI |
|
||||
| `f022002` | DeepSeek registry 补 thinking 模式参数 | `assets/api-vendor-params.json` |
|
||||
| `671c1b2` | profile 优先级修正:profile 分配后即权威,旧字段不再覆盖 | `core/api.js` 6 处 `getApiSettings` |
|
||||
| `68217ff` | legacy 自动迁移 + 清除按钮 + tableFilling slot + silent fallback 移除 | `ApiProfileManager.js` / `historiographer.js` / 表格 3 filler |
|
||||
| `b40f575` | bump 2.2.0 + tableFilling 默认 link main | `manifest.json` / `ApiProfileManager.js` |
|
||||
| `d283ff4` | 表格模块 IAD 解耦 + API 自定义参数 + 厂商预设连接 | `core/table-system/*` dto/infra/actions;`assets/api-vendor-params.json`;UI |
|
||||
| `671c1b2` | profile 优先级修正:profile 分配后即权威 | `core/api.js` 6 处 `getApiSettings` |
|
||||
| `8b4b6b0` | 二级填表死锁修复 + 强制中断按钮(AbortController 贯穿) | `secondary-filler.js` |
|
||||
| `dc57a1d` | memory-blocks Phase 1:占位符工作流抽象层,sulv1-4 迁入 | `core/memory-blocks/*`;`summarizer.js` |
|
||||
| `91ceecc` | memory-blocks Phase 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
|
||||
- **表格模块**:核心在 [core/table-system/](core/table-system/) ,已按 IAD 拆分(dto/infra/actions/rendering.js/templates.js/preset.js),manager.js 退化为兼容层(仍保留 16 个 UI mutation + loadTables + updateTableFromText)
|
||||
- **API 厂商识别**:[utils/api-vendor.js](utils/api-vendor.js) 提供 detectVendor / listVendorParams;registry 在 [assets/api-vendor-params.json](assets/api-vendor-params.json)
|
||||
- **VS Code 类型校验**:[jsconfig.json](jsconfig.json) 已开启 checkJs,[types/sillytavern.d.ts](types/sillytavern.d.ts) 提供 SillyTavern 全局模块声明
|
||||
- **状态权威**:`utils/config/ApiProfileManager.js` 是 API 配置单一指挥所;profile 分配后即权威,旧字段不再覆盖 profile;legacy 迁移已版本化(`_legacyProfileMigrationVersion`,当前 v2 覆盖 8 个 chat slot)
|
||||
- **表格模块**:核心在 [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 兼容)
|
||||
- **memory-blocks**:[core/memory-blocks/](core/memory-blocks/) 占位符驱动工作流,static + ai_call 两种 handler,自定义块 UI 在剧情优化面板;Phase 3(JSON 导入导出 / 战斗系统 plugin handler)未做
|
||||
- **API 厂商识别**:[utils/api-vendor.js](utils/api-vendor.js) detectVendor 为单一入口,业务路径散乱 includes 已清零
|
||||
- **VS Code 类型校验**:[jsconfig.json](jsconfig.json) checkJs 开启,[types/sillytavern.d.ts](types/sillytavern.d.ts) 提供全局声明
|
||||
|
||||
---
|
||||
|
||||
## 二、待办任务
|
||||
## 二、已完成任务(2026-06-11 核对)
|
||||
|
||||
### 任务卡格式说明
|
||||
|
||||
每个任务包含:
|
||||
- **类型**:bug / feature / refactor / cleanup / docs
|
||||
- **难度**:🟢 简单(< 1h)/ 🟡 中等(1-3h)/ 🔴 高耦合(> 3h 或需架构判断)
|
||||
- **建议执行者**:`GPT` / `Claude` / `Human` / `任意`
|
||||
- **文件**:明确路径 + 行号锚点(若适用)
|
||||
- **修改要点**:bullet 列表
|
||||
- **验收**:可验证的预期行为
|
||||
- **依赖**:前置任务的 ID(若有)
|
||||
| ID | 内容 | 状态 |
|
||||
|----|------|------|
|
||||
| T-001 | 死代码清理 | ✅ 3 处死绑定已删。**例外**:`core/fractal-memory.js` 刻意保留——非本人设计,原作者未弃坑,留作坑位,勿删 |
|
||||
| T-002 | cwb / autoCharCard legacy 自动迁移 | ✅ `6ad1354`。cwb 实际字段为 snake_case(`cwb_api_url`);autoCharCard 双角色嵌套对象,仅 planner 空/同 executor 时自动迁 |
|
||||
| T-003 | NCCS 等支路透传 customParams | ✅ Nccs / Ngms / Jqyh / Sybd 四个 API 文件均已接入 |
|
||||
| T-004 | hint panel 点击参数名插入 | ✅ `.amily2_param_hint_btn` + `_insertParamToCustomParams` |
|
||||
| T-005 | 散乱 vendor URL 检查迁 detectVendor | ✅ `f7781c2` 收尾。保留项:`_detectVendorFromUrlSync`(迁移 IIFE 自包含)、RequestBody.js 兜底(即目标模式) |
|
||||
| T-006 | profile 已分配时 slider informational | ✅ `784bd70`。范围:参数滑条(main / plotOptConc / cwb / sybd 四面板);URL/Key/模型输入框见 T-012 |
|
||||
| T-007 | manager.js 抽出 ui-mutations.js | ✅ `ef45e74`。含 events-dispatch.js 抽出;manager↔ui-mutations 运行时环留待 0.8 解 |
|
||||
|
||||
---
|
||||
|
||||
### 🟢 GPT-friendly 简单任务
|
||||
|
||||
#### T-001: 清理已确认的死代码
|
||||
|
||||
- **类型**:cleanup
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**待清理项**:
|
||||
|
||||
1. **[core/fractal-memory.js](core/fractal-memory.js)** —— 整个文件死代码,`initializeFractalMemory` 在文件外完全没人调用。建议:直接删除整个文件。
|
||||
2. **[ui/historiography-bindings.js:494-513](ui/historiography-bindings.js#L494)** —— 绑定 `#amily2_ngms_temperature` 和 `#amily2_ngms_max_tokens` 这两个 HTML 中已不存在的元素。`getElementById` 永远返回 null,整段代码空跑。建议:直接删掉这段。
|
||||
3. **[ui/plot-opt-bindings.js:664-665](ui/plot-opt-bindings.js#L664)** —— 同样引用不存在的 `#amily2_opt_max_tokens` / `#amily2_opt_temperature`。建议:删掉。
|
||||
4. **[ui/plot-opt-bindings.js:698-699](ui/plot-opt-bindings.js#L698)** —— `opt_bindSlider` 调用同样的不存在 ID,删除。
|
||||
|
||||
**修改要点**:
|
||||
- 删除前用 grep 确认每个 ID 在所有 .html 文件里都不存在
|
||||
- 删完后用 grep 检查没有其他文件 import 被删的函数
|
||||
- 提交前肉眼跑一次表格填表 / 剧情优化 / NGMS 总结,确认 UI 无回归
|
||||
|
||||
**验收**:
|
||||
- [ ] 4 处死代码块全部删除
|
||||
- [ ] 启动控制台无 JS 错误
|
||||
- [ ] 表格 / 剧情优化 / 总结功能无回归
|
||||
|
||||
---
|
||||
|
||||
#### T-002: cwb / autoCharCard 加入 legacy 自动迁移
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:[utils/config/ApiProfileManager.js](utils/config/ApiProfileManager.js) 的 `LEGACY_PROFILE_MIGRATION_MAP` 目前覆盖 main / plotOpt / plotOptConc / ngms / nccs / sybd 6 个 slot。cwb 和 autoCharCard 的 legacy 字段结构略不同(cwb 用 `cwb_apiUrl` / `cwb_apiKey` / `cwb_model` ;autoCharCard 用 `acc_*` 前缀),所以暂时没纳入。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 找出 cwb / autoCharCard 的 legacy 字段名(grep `cwb_apiUrl` / `acc_apiUrl` 之类)
|
||||
2. 在 `LEGACY_PROFILE_MIGRATION_MAP` 加两条:
|
||||
```js
|
||||
{
|
||||
slot: 'cwb',
|
||||
urlKey: 'cwb_apiUrl',
|
||||
modelKey: 'cwb_model',
|
||||
keyName: 'cwb_apiKey',
|
||||
maxTokensKey: 'cwb_max_tokens',
|
||||
temperatureKey: 'cwb_temperature',
|
||||
name: 'CWB 旧配置',
|
||||
},
|
||||
{
|
||||
slot: 'autoCharCard',
|
||||
urlKey: '???', // 需 grep 确认实际 key
|
||||
...
|
||||
}
|
||||
```
|
||||
3. 同时在 `clearLegacyConfig` 的 `ALL_LEGACY_FIELDS` 和 `LEGACY_KEY_NAMES` 加对应条目
|
||||
|
||||
**验收**:
|
||||
- [ ] 两个 slot 在迁移自调用 IIFE 跑过后能正确创建 profile + setKey + setAssignment
|
||||
- [ ] 清理按钮能识别并清除这俩模块的旧字段
|
||||
|
||||
---
|
||||
|
||||
#### T-003: 表格 NCCS 支路透传 customParams
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:v2.2.0 给 `core/api.js` 的 callOpenAITest / callOpenAICompatible / callSillyTavernBackend 都接入了 `options.customParams` spread。但 [core/api/NccsApi.js](core/api/NccsApi.js) 的 `callNccsOpenAITest` 等独立路径**没有**接入,导致用户在 NCCS profile 配置的 customParams 不生效。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 找 [NccsApi.js](core/api/NccsApi.js) 里发请求的函数(`callNccsOpenAITest` / `callNccsSillyTavernPreset`),定位到 `JSON.stringify({ ... })` 处
|
||||
2. 在 body 构建时按"customParams 在前,核心字段在后覆盖"的顺序 spread:
|
||||
```js
|
||||
body: JSON.stringify({
|
||||
...(options.customParams || {}),
|
||||
// 核心字段
|
||||
chat_completion_source: 'openai',
|
||||
model: options.model,
|
||||
messages,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
3. 同时确保 `getNccsApiSettings` 把 `profile.customParams` 透出(参考 [core/api.js:447-462](core/api.js#L447) 模式)
|
||||
4. 同步给 NgmsApi / JqyhApi / SybdApi 做相同处理
|
||||
|
||||
**验收**:
|
||||
- [ ] 在 NCCS profile 加 `{"top_p": 0.5}` 后,DevTools Network 看请求 body 包含 top_p:0.5
|
||||
- [ ] NGMS / JQYH / SYBD 同样验证
|
||||
|
||||
---
|
||||
|
||||
#### T-004: hint panel 点击参数名插入到 textarea
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:[ui/api-config-bindings.js](ui/api-config-bindings.js) 的 `_updateCustomParamsHint` 现在只显示纯文本"已知参数:top_p、frequency_penalty、..."。没有交互。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 把 hint 区改成参数名按钮列表,每个按钮 click 触发"如果当前 textarea JSON 已有这个 key 则不动,没有就 append 进去"
|
||||
2. 实现 `_insertParamToCustomParams(paramName, defaultValue)`:解析 textarea JSON → 添加 key(用合理的占位值,例如 number 类型用 0、string 类型用 ""、object 类型用 {})→ JSON.stringify 回写
|
||||
3. 处理 textarea 当前为空 / 当前是非法 JSON 的情况(非法 JSON 时按钮 disabled + 提示用户先修复)
|
||||
|
||||
**验收**:
|
||||
- [ ] 切换 vendor 后参数名按钮列表更新
|
||||
- [ ] 点击按钮把对应 key 添加到 textarea
|
||||
- [ ] 已存在的 key 不重复添加
|
||||
|
||||
---
|
||||
## 三、待办任务
|
||||
|
||||
### 🟡 中等任务
|
||||
|
||||
#### T-005: 15 处散乱 vendor URL 检查迁到 detectVendor
|
||||
#### T-009: 表格 Phase B — JSON formatter
|
||||
|
||||
- **类型**:refactor
|
||||
- **类型**:feature
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:GPT 或 Claude
|
||||
- **依赖**:无
|
||||
- **依赖**:无(不依赖 Bus 升级)
|
||||
|
||||
**背景**:之前的 51TODO Phase B 收尾任务。代码里 15+ 处 `apiUrl.includes('googleapis.com')` 散乱判断厂商,应该统一调 [utils/api-vendor.js#detectVendor](utils/api-vendor.js)。
|
||||
**详见**:[TableTODO.md#五-phase-b-json-formatter](TableTODO.md)
|
||||
|
||||
**待迁移文件**(grep `googleapis.com|anthropic.com|openai.com` 找):
|
||||
**核心交付**:
|
||||
- `core/table-system/formatters/json.js`:教 LLM 输出 `{"operations":[...]}`,解析为 Op[]
|
||||
- 设置项 `table_filling_format: 'legacy'|'json'|'toolcall'`,默认 `legacy`
|
||||
- UI 加 dropdown 切换
|
||||
- fillerShared 调用统一 formatter dispatcher
|
||||
|
||||
- `ui/api-config-bindings.js`
|
||||
- `ui/plot-opt-bindings.js`
|
||||
- `core/rag-api.js`
|
||||
- `ui/profile-sync.js`
|
||||
- `core/api.js`
|
||||
- `CharacterWorldBook/src/cwb_apiService.js`
|
||||
- `ui/bindings.js`
|
||||
- `ui/table/nccs-bindings.js`
|
||||
- `core/api/SybdApi.js`
|
||||
- `core/api/Ngms_api.js`
|
||||
- `core/api/JqyhApi.js`
|
||||
- `core/api/NccsApi.js`
|
||||
- `core/api/ConcurrentApi.js`
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 每处 `if (apiUrl.includes('googleapis.com'))` 改为 `if ((await detectVendor(apiUrl)) === 'google')`
|
||||
2. 注意有的位置在同步上下文(事件回调),用 `detectVendorSync` 但要先 `await getRegistry()` 预加载
|
||||
3. 不要为了重构改变行为:原来只判断 google 就只判断 google,原来判断多个 vendor 就保留多个
|
||||
|
||||
**验收**:
|
||||
- [ ] 所有散乱 URL 检查替换完
|
||||
- [ ] 行为完全等价(用 grep 自检 includes 已全替换)
|
||||
- [ ] 跑一遍主功能(主聊天 / 剧情优化 / NGMS 总结 / 表格填表)确认无回归
|
||||
**预估**:0.5 天
|
||||
|
||||
---
|
||||
|
||||
#### T-006: jqyh/sybd/cwb 在 profile 已分配时把 slider 改成 informational
|
||||
#### T-012: URL / Key / 模型输入框的 profile 压制提示(T-006 续)
|
||||
|
||||
- **类型**:feature / UX
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:GPT 或 Claude
|
||||
- **依赖**:无
|
||||
- **依赖**:无(复用 [ui/profile-slider-guard.js](ui/profile-slider-guard.js))
|
||||
|
||||
**背景**:v2.2.0 之后,profile 一旦分配就权威,jqyh/sybd/cwb 这些有 slider 的模块在 profile 分配后 slider 是无效的(用户改 slider 不影响请求)。这是用户陷阱。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
每个有 slider 的模块面板([plot-opt-bindings.js](ui/plot-opt-bindings.js) / [historiography-bindings.js](ui/historiography-bindings.js) / [glossary 相关 bindings](ui/) / [cwb_settingsManager.js](CharacterWorldBook/src/cwb_settingsManager.js)):
|
||||
|
||||
1. 启动时 / profile 分配变化时检查对应 slot 是否分配了 profile
|
||||
2. 若已分配:
|
||||
- slider disable
|
||||
- slider 旁加小字提示:"当前由 profile 「{profile.name}」 控制,请在 API 连接配置面板修改 profile"
|
||||
3. 若未分配:保持原样(slider 可用,写入 legacy 字段)
|
||||
4. 监听 profile 分配变化事件(可通过 ApiProfileManager 加 subscribe,或者轮询)
|
||||
**背景**:T-006 只覆盖了参数滑条。各模块面板的 API URL / Key / 模型输入框在 profile 分配后同样失效,且涉及「测试连接 / 拉取模型」按钮的联动判断(这些按钮读的是 profile 还是 DOM 因模块而异),需逐面板核对后接入 `watchProfileSliderGuard`。
|
||||
|
||||
**验收**:
|
||||
- [ ] 给 plotOpt 分配 profile 后,剧情优化面板的温度/maxTokens slider 变灰 + 提示
|
||||
- [ ] 取消分配后 slider 重新可用
|
||||
- [ ] 其他模块同样行为
|
||||
- [ ] profile 分配后各面板 URL/Key/模型输入框 disable + 提示
|
||||
- [ ] 测试连接按钮行为与提示一致(测的是 profile 配置就保持可用)
|
||||
|
||||
---
|
||||
|
||||
#### T-007: 表格 Phase 0.4 — 抽出 mutations.js
|
||||
#### T-013: 剧情优化面板 top_p / presence / frequency 输入为死配置
|
||||
|
||||
- **类型**:refactor
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:Claude(涉及 IAD 一致性判断)
|
||||
- **类型**:bug / cleanup
|
||||
- **难度**:🟡 中等(需决策)
|
||||
- **建议执行者**:Human 决策 + 任意执行
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:[TableTODO.md#四-phase-0](TableTODO.md) 计划的 Phase 0.4。manager.js 还有 16 个 UI 突变函数(addRow / deleteColumn / renameTable 等),应抽到 `core/table-system/actions/ui-mutations.js`。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 在 `core/table-system/actions/` 创建 `ui-mutations.js`
|
||||
2. 把 manager.js 里这 16 个函数搬过去:deleteColumn / moveRow / insertRow / addRow / addColumn / updateHeader / deleteRow / restoreRow / commitPendingDeletions / insertColumn / moveColumn / deleteTable / addTable / renameTable / moveTable / updateTableRules / updateRow / clearAllTables / updateColumnWidth
|
||||
3. manager.js 改为 re-export 这些函数(保持外部调用路径不变)
|
||||
4. 各函数签名/行为保持完全一致
|
||||
|
||||
**验收**:
|
||||
- [ ] manager.js 行数显著减少
|
||||
- [ ] 所有 UI 突变操作在表格面板里行为一致(手动测每个操作)
|
||||
- [ ] 没有任何 import 失败
|
||||
**背景**:`plotOpt_top_p` / `plotOpt_presence_penalty` / `plotOpt_frequency_penalty` 只有 UI 在读写([plot-opt-bindings.js](ui/plot-opt-bindings.js)),core 请求路径无人消费——用户改了完全没效果。二选一:
|
||||
1. 接上:在 plotOpt 请求体里带上这三个参数(profile 的 customParams 机制已能覆盖此需求,可能多余)
|
||||
2. 删掉:移除 UI 控件 + 默认值 +(已在 clearLegacyConfig 列表中)
|
||||
|
||||
---
|
||||
|
||||
@@ -277,25 +122,6 @@
|
||||
|
||||
---
|
||||
|
||||
#### T-009: 表格 Phase B — JSON formatter
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:GPT 或 Claude
|
||||
- **依赖**:无(不依赖 Bus 升级)
|
||||
|
||||
**详见**:[TableTODO.md#五-phase-b-json-formatter](TableTODO.md)
|
||||
|
||||
**核心交付**:
|
||||
- `core/table-system/formatters/json.js`:教 LLM 输出 `{"operations":[...]}`,解析为 Op[]
|
||||
- 设置项 `table_filling_format: 'legacy'|'json'|'toolcall'`,默认 `legacy`
|
||||
- UI 加 dropdown 切换
|
||||
- fillerShared 调用统一 formatter dispatcher
|
||||
|
||||
**预估**:0.5 天
|
||||
|
||||
---
|
||||
|
||||
#### T-010: 表格 Phase C — ToolCall formatter
|
||||
|
||||
- **类型**:feature
|
||||
@@ -312,45 +138,52 @@
|
||||
- **类型**:refactor
|
||||
- **难度**:🔴 高(filler 三方差异需小心对齐 / 解循环依赖 / Service 重写)
|
||||
- **建议执行者**:Claude
|
||||
- **依赖**:T-007(Phase 0.4 mutations 完成后做)
|
||||
- **依赖**:T-007 已完成 ✅,可随时开工
|
||||
|
||||
**详见**:[TableTODO.md#四-phase-0](TableTODO.md) 0.7-0.9
|
||||
|
||||
- 0.7: `core/table-system/filler/shared.js` —— 三个 filler 重复代码消除
|
||||
- 0.8: 解 manager.js ↔ secondary-filler.js 循环依赖
|
||||
- 0.8: 解循环依赖(manager ↔ secondary-filler;新增的 manager ↔ ui-mutations 一并处理)
|
||||
- 0.9: TableSystemService 真正变成门面
|
||||
|
||||
**预估**:1 天
|
||||
|
||||
---
|
||||
|
||||
## 三、派工建议
|
||||
#### T-014: memory-blocks Phase 3
|
||||
|
||||
### 适合现在直接派给 GPT(独立、无架构判断)
|
||||
- **类型**:feature
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:Claude
|
||||
- **依赖**:Phase 2 已完成 ✅(`91ceecc`)
|
||||
|
||||
- ✅ T-001 死代码清理
|
||||
- ✅ T-002 cwb/autoCharCard 加入迁移
|
||||
- ✅ T-003 NCCS 透传 customParams
|
||||
- ✅ T-004 hint panel 点击插入
|
||||
|
||||
### GPT 或 Claude 都可以
|
||||
|
||||
- T-005 vendor 检查迁移(量大但机械)
|
||||
- T-006 slider informational 状态
|
||||
- T-009 JSON formatter
|
||||
|
||||
### 建议留给 Claude 或人
|
||||
|
||||
- T-007 mutations.js 抽出(涉及 IAD 一致性)
|
||||
- T-008 Bus tool-call 升级(架构核心)
|
||||
- T-010 ToolCall formatter(依赖前置)
|
||||
- T-011 表格 Phase 0 收尾(filler 重复代码 dedup 风险高)
|
||||
**核心交付**:
|
||||
- 自定义块 JSON 导入导出(`replaceContextBlocks` 已就位)
|
||||
- 战斗系统通过 `plugin` handler 接入(types.js 契约已预留)
|
||||
- summarizer 链路补 AbortController,让 ai_call 块可中断(handler 的 signal 透传已就位)
|
||||
|
||||
---
|
||||
|
||||
## 四、未列入但可能的小项
|
||||
## 四、派工建议
|
||||
|
||||
### GPT 或 Claude 都可以
|
||||
|
||||
- T-009 JSON formatter
|
||||
- T-012 URL/Key informational(机械,照 T-006 模式)
|
||||
|
||||
### 建议留给 Claude 或人
|
||||
|
||||
- T-008 Bus tool-call 升级(架构核心)
|
||||
- T-010 ToolCall formatter(依赖前置)
|
||||
- T-011 表格 Phase 0 收尾(filler dedup 风险高)
|
||||
- T-013 死配置决策(需 Human 拍板接上还是删掉)
|
||||
- T-014 memory-blocks Phase 3
|
||||
|
||||
---
|
||||
|
||||
## 五、未列入但可能的小项
|
||||
|
||||
- 自动迁移完成后给所有 chat 类型 slot 加默认 link 选项(不只 tableFilling)
|
||||
- profile 分配 UI 加"复用现有 profile"快捷按钮(避免用户为每个 slot 重复创建相同配置)
|
||||
- 51TODO.md 第三节决策点中"是否合并发版"等问题做最终决定记录
|
||||
- TODO.md(旧版本变更日志)的 v2.2.0 版本条目补全
|
||||
- TODO.md(旧版本变更日志)的 v2.2.x 版本条目补全
|
||||
|
||||
@@ -36,47 +36,12 @@
|
||||
<!-- API Settings Tab -->
|
||||
<div id="sinan-api-settings-tab" class="sinan-tab-pane active">
|
||||
<fieldset class="settings-group">
|
||||
<legend>Jqyh API</legend>
|
||||
<div class="control-block-with-switch">
|
||||
<label for="amily2_jqyh_enabled"><strong>启用 Jqyh API</strong></label>
|
||||
<label class="toggle-switch">
|
||||
<input id="amily2_jqyh_enabled" type="checkbox" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="amily2_jqyh_content" style="display: none;" class="inline-settings-grid">
|
||||
<label for="amily2_jqyh_api_mode">API 模式</label>
|
||||
<select id="amily2_jqyh_api_mode" class="text_pole">
|
||||
<option value="openai_test">全兼容模式</option>
|
||||
<option value="sillytavern_preset">SillyTavern 预设</option>
|
||||
</select>
|
||||
|
||||
<div id="amily2_jqyh_compatible_config" class="inline-settings-grid" style="grid-column: 1 / -1;">
|
||||
<label for="amily2_jqyh_api_url">API URL</label>
|
||||
<input type="text" id="amily2_jqyh_api_url" class="text_pole" placeholder="例如: https://api.openai.com/v1">
|
||||
<label for="amily2_jqyh_api_key">API Key</label>
|
||||
<input type="password" id="amily2_jqyh_api_key" class="text_pole" placeholder="请输入您的 API Key">
|
||||
<label for="amily2_jqyh_model">模型</label>
|
||||
<div class="amily2_opt_preset_selector_wrapper">
|
||||
<input type="text" id="amily2_jqyh_model" class="text_pole" placeholder="请先获取模型列表或手动输入">
|
||||
<select id="amily2_jqyh_model_select" class="text_pole" style="display: none;"></select>
|
||||
</div>
|
||||
<div class="jqyh-button-row" style="grid-column: 1 / -1;">
|
||||
<button id="amily2_jqyh_fetch_models" class="menu_button secondary" title="获取模型列表"><i class="fas fa-sync-alt"></i> 获取模型</button>
|
||||
<button id="amily2_jqyh_test_connection" class="menu_button primary"><i class="fas fa-plug"></i> 测试连接</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="amily2_jqyh_preset_config" class="inline-settings-grid" style="display: none; grid-column: 1 / -1;">
|
||||
<label for="amily2_jqyh_tavern_profile">选择酒馆预设</label>
|
||||
<select id="amily2_jqyh_tavern_profile" class="text_pole"></select>
|
||||
</div>
|
||||
|
||||
<label for="amily2_jqyh_max_tokens">最大 Tokens: <span id="amily2_jqyh_max_tokens_value">4000</span></label>
|
||||
<input type="number" class="text_pole" id="amily2_jqyh_max_tokens" min="100" max="100000" value="4000">
|
||||
<label for="amily2_jqyh_temperature">温度: <span id="amily2_jqyh_temperature_value">0.7</span></label>
|
||||
<input type="number" class="text_pole" id="amily2_jqyh_temperature" min="0" max="2" value="0.7">
|
||||
</div>
|
||||
<legend>剧情优化 API</legend>
|
||||
<p class="notes" style="margin: 0;">
|
||||
剧情优化所用的连接配置统一在
|
||||
<strong>API 连接配置 → 功能分配 → 剧情优化 / JQYH</strong>
|
||||
中指定,无需在此单独填写。
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
@@ -210,6 +175,17 @@
|
||||
<input id="amily2_opt_rate_cuckold" type="number" class="text_pole" step="0.05" value="1.0">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="settings-group">
|
||||
<legend>自定义记忆块</legend>
|
||||
<div style="font-size: 0.85em; opacity: 0.75; margin-bottom: 8px;">
|
||||
每个块定义一个占位符:执行剧情优化时,主/拦截提示词中出现的占位符会被块的产出替换。
|
||||
静态块直接输出固定内容;AI 调用块会用所选 API 槽独立请求一次,把回复作为替换值。
|
||||
</div>
|
||||
<div id="amily2_opt_custom_blocks_list"></div>
|
||||
<button id="amily2_opt_add_custom_block" class="menu_button" style="margin-top: 8px;">
|
||||
<i class="fa-solid fa-plus"></i> 新增记忆块
|
||||
</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Context Settings Tab -->
|
||||
|
||||
@@ -250,6 +250,28 @@
|
||||
<input type="number" id="secondary-filler-max-retries" min="0" max="10" step="1" value="2" class="text_pole" style="width: 80px; margin-top: 5px;">
|
||||
<small class="notes" style="margin-top: 5px; display: block;">分步填表失败时的自动重试次数 (0 = 不重试)。</small>
|
||||
</div>
|
||||
|
||||
<!-- 触发延迟(防抖) -->
|
||||
<div class="control-block-with-switch" style="margin-bottom: 10px; flex-direction: column; align-items: flex-start;">
|
||||
<label for="secondary-filler-delay">触发延迟 (毫秒)</label>
|
||||
<input type="number" id="secondary-filler-delay" min="0" max="60000" step="100" value="0" class="text_pole" style="width: 80px; margin-top: 5px;">
|
||||
<small class="notes" style="margin-top: 5px; display: block;">收到新消息后延迟多少毫秒再触发分步填表 (0 = 立即触发);延迟期内若再次收到消息会重置计时,起到防抖作用。</small>
|
||||
</div>
|
||||
|
||||
<!-- 中断与手动解锁(兜底) -->
|
||||
<div class="control-block-with-switch" style="margin-bottom: 10px; flex-direction: column; align-items: flex-start;">
|
||||
<label>填表运行控制</label>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 5px; flex-wrap: wrap;">
|
||||
<button id="amily2-abort-secondary-filler" class="menu_button danger small_button interactable" type="button">
|
||||
<i class="fas fa-stop-circle"></i> 强制中断当前填表
|
||||
</button>
|
||||
<button id="amily2-reset-secondary-filler-lock" class="menu_button warning small_button interactable" type="button">
|
||||
<i class="fas fa-unlock"></i> 手动解除填表锁
|
||||
</button>
|
||||
<span id="amily2-secondary-filler-lock-status" class="notes" style="font-size: 12px;">状态:空闲</span>
|
||||
</div>
|
||||
<small class="notes" style="margin-top: 5px; display: block;"><b>强制中断</b>:通过 AbortController 真正掐断进行中的 API 请求并丢弃结果(写表/写 hash/saveChat 都不会执行)。<br><b>手动解除填表锁</b>:仅释放 UI 锁,用于"中断"也救不回来的极端死锁兜底——若遇到"分步填表正在进行中,跳过本次触发"反复出现且新消息无法触发,可手动点击释放。</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-block-with-switch" style="margin-bottom: 10px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px;">
|
||||
@@ -288,7 +310,22 @@
|
||||
</fieldset>
|
||||
|
||||
<hr class="section-divider" style="margin: 10px 0;">
|
||||
|
||||
|
||||
<!-- Function Call 填表 -->
|
||||
<div class="control-block-with-switch" style="margin-bottom: 6px;">
|
||||
<label for="table-fill-function-call-enabled" title="使用 OpenAI Function Call(工具调用)进行填表,模型直接返回结构化操作列表,无需解析 <Amily2Edit> 指令块。仅支持 openai 直连模式。">使用 Function Call 填表</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="table-fill-function-call-enabled">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="notes" style="margin-bottom: 6px;">仅支持 openai 直连接口(tableFilling 槽位)。启用后跳过 <Amily2Edit> 文本解析,由模型直接返回操作列表。</p>
|
||||
<div style="background: rgba(255, 160, 0, 0.12); border-left: 3px solid #ffa000; border-radius: 3px; padding: 6px 10px; margin-bottom: 10px; font-size: 0.85em; color: #ffcc80;">
|
||||
⚠️ 部分公益站因禁止用于跑代码会屏蔽 tools 参数,请确认公益站是否支持 tools 调用,避免被意外封禁。
|
||||
</div>
|
||||
|
||||
<hr class="section-divider" style="margin: 10px 0;">
|
||||
|
||||
<!-- Nccs API 控制区域 -->
|
||||
<fieldset class="settings-group" style="border-style: dashed; padding: 8px; margin-bottom: 10px;">
|
||||
<legend><i class="fas fa-brain"></i> Nccs API 系统</legend>
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
<option value="custom">自定义 (OpenAI/Azure 兼容)</option>
|
||||
<option value="google_direct">Google 直连</option>
|
||||
<option value="local_proxy">本地代理 (LM Studio/Ollama)</option>
|
||||
<option value="openai">OpenAI 官方</option>
|
||||
<option value="azure">Azure (api-key 头)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hly-control-block" id="hly-custom-endpoint-docket">
|
||||
|
||||
@@ -212,14 +212,14 @@
|
||||
<button id="amily2_open_additional_features" class="menu_button wide_button"><i class="fas fa-landmark-dome"></i> 总结模块</button>
|
||||
<button id="amily2_open_rag_palace" class="menu_button wide_button"><i class="fas fa-brain"></i> 向量模块</button>
|
||||
<button id="amily2_open_memorisation_forms" class="menu_button wide_button"><i class="fas fa-table"></i> 表格模块</button>
|
||||
<button id="amily2_open_character_world_book" class="menu_button wide_button"><i class="fa-solid fa-book-atlas"></i> 角色世界</button>
|
||||
<button id="amily2_open_plot_optimization" class="menu_button wide_button"><i class="fas fa-feather-alt"></i> 记忆管理</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend><i class="fas fa-puzzle-piece"></i> 附加功能</legend>
|
||||
<div class="button-group" style="display: flex; justify-content: space-between; gap: 8px;">
|
||||
<button id="amily2_open_plot_optimization" class="menu_button wide_button"><i class="fas fa-feather-alt"></i> 记忆管理</button>
|
||||
<button id="amily2_open_character_world_book" class="menu_button wide_button"><i class="fa-solid fa-book-atlas"></i> 角色世界</button>
|
||||
<button id="amily2_open_text_optimization" class="menu_button wide_button"><i class="fas fa-cogs"></i> 正文优化</button>
|
||||
<button id="amily2_open_world_editor" class="menu_button wide_button"><i class="fas fa-globe"></i> 世界编辑</button>
|
||||
<button id="amily2_open_glossary" class="menu_button wide_button"><i class="fas fa-book"></i> 术语表单</button>
|
||||
@@ -227,6 +227,7 @@
|
||||
<div class="button-group" style="display: flex; justify-content: space-between; gap: 8px; margin-top: 8px;">
|
||||
<button id="amily2_open_renderer" class="menu_button wide_button"><i class="fas fa-paint-brush"></i> 前端渲染</button>
|
||||
<button id="amily2_open_sfigen" class="menu_button wide_button"><i class="fas fa-image"></i> 硅基生图</button>
|
||||
<button id="amily2_open_preset_editor" class="menu_button wide_button"><i class="fa-solid fa-scroll"></i> 提示词链</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -236,6 +237,9 @@
|
||||
<button id="amily2_open_super_memory" class="menu_button wide_button"><i class="fas fa-brain"></i> 超级记忆</button>
|
||||
<button id="amily2_open_auto_char_card" class="menu_button wide_button"><i class="fas fa-robot"></i> 一键生卡</button>
|
||||
</div>
|
||||
<div class="button-group" style="display: flex; justify-content: space-between; gap: 8px; margin-top: 8px;">
|
||||
<button id="amily2_open_progressive_memory" class="menu_button wide_button"><i class="fas fa-layer-group"></i> 渐进记忆</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
<div class="amily2_settings_block" style="margin-top:10px;">
|
||||
<label><input id="amily2_rule_profile_tag_toggle" type="checkbox"> 启用标签提取</label>
|
||||
</div>
|
||||
<div class="amily2_settings_block" style="margin-top:10px;">
|
||||
<label><input id="amily2_rule_profile_exclude_user" type="checkbox"> 自动排除用户楼层</label>
|
||||
<small class="notes" style="display:block; margin-top:4px;">勾选后,使用此规则时将自动跳过用户发送的消息楼层,不纳入总结/提取内容。</small>
|
||||
</div>
|
||||
<div id="amily2_rule_profile_tags_wrap" class="amily2_settings_block" style="display:none; margin-top:10px;">
|
||||
<label for="amily2_rule_profile_tags">标签列表</label>
|
||||
<textarea id="amily2_rule_profile_tags" class="text_pole" rows="3" placeholder="例如:content,details,summary"></textarea>
|
||||
|
||||
190
core/api.js
190
core/api.js
@@ -485,8 +485,7 @@ export async function getApiSettings(slot = 'main') {
|
||||
apiProvider: apiMode,
|
||||
apiUrl: settings.plotOpt_apiUrl?.trim() || '',
|
||||
apiKey: configManager.get('plotOpt_apiKey') || '',
|
||||
model: document.getElementById('amily2_opt_model')?.value?.trim()
|
||||
|| settings.plotOpt_model || '',
|
||||
model: settings.plotOpt_model || '',
|
||||
maxTokens: settings.plotOpt_max_tokens ?? 65500,
|
||||
temperature: settings.plotOpt_temperature ?? 1.0,
|
||||
tavernProfile: '',
|
||||
@@ -589,6 +588,7 @@ export async function callAI(messages, options = {}) {
|
||||
apiKey: apiSettings.apiKey,
|
||||
apiProvider: apiSettings.apiProvider,
|
||||
customParams: apiSettings.customParams ?? {},
|
||||
signal: options.signal,
|
||||
...options,
|
||||
// options 可显式覆盖 customParams,体现"代码内显式 > profile 配置"
|
||||
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
|
||||
@@ -649,6 +649,10 @@ export async function callAI(messages, options = {}) {
|
||||
return responseContent;
|
||||
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
console.warn('[Amily2-外交部] API 调用被用户中断。');
|
||||
throw error; // 让上层(如 secondary-filler)识别并跳过结果处理
|
||||
}
|
||||
console.error(`[Amily2-外交部] API调用发生错误:`, error);
|
||||
|
||||
if (error.message.includes('400')) {
|
||||
@@ -664,7 +668,7 @@ export async function callAI(messages, options = {}) {
|
||||
} else {
|
||||
toastr.error(`API调用失败: ${error.message}`, "API调用失败");
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -691,7 +695,8 @@ async function callOpenAICompatible(messages, options) {
|
||||
max_tokens: options.maxTokens,
|
||||
temperature: options.temperature,
|
||||
stream: false,
|
||||
})
|
||||
}),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -733,7 +738,8 @@ async function callOpenAITest(messages, options) {
|
||||
const response = await fetch('/api/backends/chat-completions/generate', {
|
||||
method: 'POST',
|
||||
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
body: JSON.stringify(body),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -775,10 +781,11 @@ async function callGoogleDirect(messages, options) {
|
||||
temperature: options.temperature
|
||||
}));
|
||||
|
||||
const response = await fetch(finalApiUrl, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: requestBody
|
||||
const response = await fetch(finalApiUrl, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: requestBody,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -823,11 +830,10 @@ async function callGoogleDirect(messages, options) {
|
||||
async function callSillyTavernBackend(messages, options) {
|
||||
console.log('[Amily2号-ST后端] 通过SillyTavern后端调用API');
|
||||
|
||||
const rawResponse = await $.ajax({
|
||||
url: '/api/backends/chat-completions/generate',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
const response = await fetch('/api/backends/chat-completions/generate', {
|
||||
method: 'POST',
|
||||
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
// 用户 customParams(可被核心字段覆盖)
|
||||
...(options.customParams || {}),
|
||||
// 表单托管字段总是 win
|
||||
@@ -839,9 +845,16 @@ async function callSillyTavernBackend(messages, options) {
|
||||
max_tokens: options.maxTokens,
|
||||
temperature: options.temperature,
|
||||
stream: false,
|
||||
})
|
||||
}),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`SillyTavern后端API请求失败: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const rawResponse = await response.json();
|
||||
const result = normalizeApiResponse(rawResponse);
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || 'SillyTavern后端API调用失败');
|
||||
@@ -851,6 +864,28 @@ async function callSillyTavernBackend(messages, options) {
|
||||
}
|
||||
|
||||
|
||||
function raceAgainstSignal(promise, signal) {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) {
|
||||
const err = new Error('Aborted');
|
||||
err.name = 'AbortError';
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
const err = new Error('Aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(
|
||||
(v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
|
||||
(e) => { signal.removeEventListener('abort', onAbort); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function callSillyTavernPreset(messages, options) {
|
||||
console.log('[Amily2号-ST预设] 使用SillyTavern预设调用');
|
||||
|
||||
@@ -910,7 +945,7 @@ async function callSillyTavernPreset(messages, options) {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await responsePromise;
|
||||
const result = await raceAgainstSignal(responsePromise, options.signal);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('未收到API响应');
|
||||
@@ -949,3 +984,126 @@ export async function checkAndFixWithAPI(latestMessage, previousMessages) {
|
||||
const { processOptimization } = await import('./summarizer.js');
|
||||
return await processOptimization(latestMessage, previousMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 OpenAI Function Call 调用 AI,返回 tool_calls[0].function.arguments 字符串。
|
||||
* 仅支持 openai / openai_test 接口(Google / ST preset / backend 不在标准 tool_calls 格式下工作)。
|
||||
*
|
||||
* @param {Array} messages
|
||||
* @param {Object} tool - OpenAI tools 定义对象(单个,含 type/function 字段)
|
||||
* @param {Object} options - 同 callAI 的 options,支持 slot / customParams 等
|
||||
* @returns {Promise<string|null>} arguments JSON 字符串,失败返回 null
|
||||
*/
|
||||
export async function callAIForTools(messages, tool, options = {}) {
|
||||
const apiSettings = await getApiSettings(options.slot || 'main');
|
||||
|
||||
const finalOptions = {
|
||||
maxTokens: apiSettings.maxTokens,
|
||||
temperature: apiSettings.temperature,
|
||||
model: apiSettings.model,
|
||||
apiUrl: apiSettings.apiUrl,
|
||||
apiKey: apiSettings.apiKey,
|
||||
apiProvider: apiSettings.apiProvider,
|
||||
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
|
||||
signal: options.signal,
|
||||
...options,
|
||||
};
|
||||
|
||||
const FC_SUPPORTED_PROVIDERS = new Set(['openai', 'openai_test', 'custom_oai', 'openrouter', 'deepseek', 'xai']);
|
||||
if (!FC_SUPPORTED_PROVIDERS.has(finalOptions.apiProvider)) {
|
||||
console.warn(`[Amily2-外交部] Function Call 不支持当前接口类型: ${finalOptions.apiProvider}`);
|
||||
toastr.warning(`当前 API 接口类型(${finalOptions.apiProvider})不支持 Function Call。`, 'Function Call');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!finalOptions.apiUrl || !finalOptions.model) {
|
||||
console.warn('[Amily2-外交部] API URL 或模型未配置,无法调用 Function Call AI');
|
||||
toastr.error('API URL 或模型未配置。', 'Amily2-外交部');
|
||||
return null;
|
||||
}
|
||||
|
||||
// deepseek.com 域名或模型名含 deepseek 时,第一次调用主动关闭思考模式,
|
||||
// 让 tool_choice 强制走 Function Call(思考模式下 tool_choice 会报错/失败)
|
||||
const isDeepSeek = /deepseek/i.test(finalOptions.apiUrl || '') || /deepseek/i.test(finalOptions.model || '');
|
||||
|
||||
const buildFCBody = (withToolChoice, overrideMessages, extraParams = {}) => ({
|
||||
chat_completion_source: 'openai',
|
||||
reverse_proxy: finalOptions.apiUrl,
|
||||
proxy_password: finalOptions.apiKey,
|
||||
model: finalOptions.model,
|
||||
messages: overrideMessages ?? messages,
|
||||
max_tokens: finalOptions.maxTokens || 30000,
|
||||
temperature: finalOptions.temperature ?? 1,
|
||||
stream: false,
|
||||
...(finalOptions.customParams || {}),
|
||||
...extraParams,
|
||||
tools: [tool],
|
||||
...(withToolChoice ? { tool_choice: { type: 'function', function: { name: tool.function.name } } } : {}),
|
||||
});
|
||||
|
||||
const doFCRequest = async (withToolChoice, overrideMessages, extraParams) => {
|
||||
const response = await fetch('/api/backends/chat-completions/generate', {
|
||||
method: 'POST',
|
||||
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildFCBody(withToolChoice, overrideMessages, extraParams)),
|
||||
signal: finalOptions.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Function Call 请求失败: ${response.status} - ${errorText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// ST 代理在上游报错时仍返回 HTTP 200,错误信息在 body 里
|
||||
if (data?.error) {
|
||||
throw new Error(`Function Call 请求失败: ${JSON.stringify(data.error)}`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
try {
|
||||
console.groupCollapsed(`[Amily2号-Function Call] ${new Date().toLocaleTimeString()}`);
|
||||
console.log('【工具】:', tool.function?.name, '【模型】:', finalOptions.model);
|
||||
console.log('【消息】:', messages);
|
||||
console.groupEnd();
|
||||
|
||||
let data;
|
||||
try {
|
||||
// 走 ST 后端代理,避免浏览器 CSP 拦截直连外部 URL
|
||||
// DeepSeek 思考模式与 tool_choice 不兼容,第一次请求时主动关闭思考模式
|
||||
const firstAttemptExtra = isDeepSeek ? { thinking: { type: 'disabled' } } : {};
|
||||
if (isDeepSeek) console.log('[Amily2-外交部] 检测到 DeepSeek 端点,首次 FC 请求附加 thinking:disabled');
|
||||
data = await doFCRequest(true, undefined, firstAttemptExtra);
|
||||
} catch (firstError) {
|
||||
if (firstError?.name === 'AbortError') throw firstError; // 用户中断,不要重试
|
||||
// 首次失败(含 ST 代理吞掉错误码场景)无条件去掉 tool_choice 重试一次
|
||||
// 思考模式模型支持 tools 但不支持强制 tool_choice,追加强制指令防止模型直接输出文本
|
||||
console.warn('[Amily2-外交部] 首次 FC 请求失败,去掉 tool_choice 重试…', firstError.message);
|
||||
const retryMessages = [
|
||||
...messages,
|
||||
{ role: 'user', content: `你必须通过调用 \`${tool.function.name}\` 函数来返回结果,禁止直接输出文本内容。` },
|
||||
];
|
||||
data = await doFCRequest(false, retryMessages);
|
||||
}
|
||||
|
||||
const toolCalls = data?.choices?.[0]?.message?.tool_calls;
|
||||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
|
||||
console.warn('[Amily2-外交部] Function Call 响应中无 tool_calls,finish_reason:', data?.choices?.[0]?.finish_reason);
|
||||
return null;
|
||||
}
|
||||
|
||||
const argsString = toolCalls[0]?.function?.arguments;
|
||||
console.groupCollapsed('[Amily2号-Function Call 响应]');
|
||||
console.log(argsString);
|
||||
console.groupEnd();
|
||||
return argsString ?? null;
|
||||
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
console.warn('[Amily2-外交部] Function Call 调用被用户中断。');
|
||||
throw error;
|
||||
}
|
||||
console.error('[Amily2-外交部] Function Call 调用失败:', error);
|
||||
toastr.error(`Function Call 调用失败: ${error.message}`, 'Amily2-外交部');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export async function callNccsAI(messages, options = {}) {
|
||||
const settings = await getNccsApiSettings();
|
||||
const finalOptions = {
|
||||
...settings,
|
||||
signal: options.signal,
|
||||
...options
|
||||
};
|
||||
|
||||
@@ -123,14 +124,40 @@ export async function callNccsAI(messages, options = {}) {
|
||||
}
|
||||
return responseContent;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
console.warn('[Amily2-Nccs] API 调用被用户中断。');
|
||||
throw error;
|
||||
}
|
||||
console.error(`[Amily2-Nccs] API 调用失败:`, error);
|
||||
toastr.error(`调用失败: ${error.message}`, "Nccs API Error");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFakeStream(url, opts) {
|
||||
const res = await fetch(url, opts);
|
||||
function raceAgainstSignal(promise, signal) {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) {
|
||||
const err = new Error('Aborted');
|
||||
err.name = 'AbortError';
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
const err = new Error('Aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(
|
||||
(v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
|
||||
(e) => { signal.removeEventListener('abort', onAbort); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchFakeStream(url, opts, signal) {
|
||||
const res = await fetch(url, { ...opts, signal });
|
||||
if (!res.ok) throw new Error(`Stream HTTP ${res.status}: ${await res.text()}`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
@@ -217,10 +244,10 @@ async function callNccsOpenAITest(messages, options) {
|
||||
};
|
||||
|
||||
if (options.stream) {
|
||||
return await fetchFakeStream('/api/backends/chat-completions/generate', fetchOpts);
|
||||
return await fetchFakeStream('/api/backends/chat-completions/generate', fetchOpts, options.signal);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/backends/chat-completions/generate', fetchOpts);
|
||||
const response = await fetch('/api/backends/chat-completions/generate', { ...fetchOpts, signal: options.signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
return normalizeApiResponse(await response.json());
|
||||
}
|
||||
@@ -244,13 +271,14 @@ async function callNccsSillyTavernPreset(messages, options) {
|
||||
|
||||
if (!context.ConnectionManagerRequestService) throw new Error('ConnectionManagerRequestService unavailable');
|
||||
|
||||
const result = await context.ConnectionManagerRequestService.sendRequest(
|
||||
const sendPromise = context.ConnectionManagerRequestService.sendRequest(
|
||||
targetProfile.id,
|
||||
messages,
|
||||
8192,
|
||||
options.customParams || {}
|
||||
);
|
||||
|
||||
const result = await raceAgainstSignal(sendPromise, options.signal);
|
||||
return normalizeApiResponse(result);
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -10,8 +10,16 @@ export function initializeArchiveManager() {
|
||||
console.log('[归档管理器] 已启动,正在监控表格状态...');
|
||||
}
|
||||
|
||||
/** Bus 直调路径:由 super-memory/manager.js 的 pushUpdate 调用,接受纯 payload 对象。 */
|
||||
export function handleArchiveUpdate(payload) {
|
||||
return handleArchivePayload(payload);
|
||||
}
|
||||
|
||||
async function handleTableUpdate(event) {
|
||||
const { tableName, data, role } = event.detail;
|
||||
return handleArchivePayload(event.detail);
|
||||
}
|
||||
|
||||
async function handleArchivePayload({ tableName, data, role }) {
|
||||
const settings = getSettings();
|
||||
|
||||
if (!settings.archive || !settings.archive.enabled) return;
|
||||
@@ -24,7 +32,8 @@ async function handleTableUpdate(event) {
|
||||
if (isArchiving) return;
|
||||
|
||||
let hasNotice = false;
|
||||
|
||||
let realRows = data;
|
||||
|
||||
if (data.length > 0 && data[0][2] && data[0][2].includes('已自动归档')) {
|
||||
hasNotice = true;
|
||||
realRows = data.slice(1);
|
||||
|
||||
@@ -307,8 +307,11 @@ function getRawMessagesForSummary(startFloor, endFloor) {
|
||||
const useTagExtraction = historiographyRuleConfig.tagExtractionEnabled ?? false;
|
||||
const tagsToExtract = useTagExtraction ? (historiographyRuleConfig.tags || '').split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
const exclusionRules = historiographyRuleConfig.exclusionRules || [];
|
||||
const excludeUserMessages = historiographyRuleConfig.excludeUserMessages ?? false;
|
||||
|
||||
const messages = historySlice.map((msg, index) => {
|
||||
if (excludeUserMessages && msg.is_user) return null;
|
||||
|
||||
let content = msg.mes;
|
||||
|
||||
if (useTagExtraction && tagsToExtract.length > 0) {
|
||||
@@ -319,7 +322,7 @@ function getRawMessagesForSummary(startFloor, endFloor) {
|
||||
}
|
||||
|
||||
content = applyExclusionRules(content, exclusionRules);
|
||||
|
||||
|
||||
if (!content.trim()) return null;
|
||||
|
||||
return {
|
||||
|
||||
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 {};
|
||||
88
core/progressive-memory/bindings.js
Normal file
88
core/progressive-memory/bindings.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* core/progressive-memory/bindings.js
|
||||
*
|
||||
* 渐进记忆面板的 UI 事件绑定与设置回填。
|
||||
* 设置统一存 extension_settings[extensionName].progressive_memory(纯 JSON)。
|
||||
*/
|
||||
|
||||
import { extension_settings } from "/scripts/extensions.js";
|
||||
import { saveSettingsDebounced } from "/script.js";
|
||||
import { extensionName } from "../../utils/settings.js";
|
||||
import { progressiveMemoryDefaults } from "./engine.js";
|
||||
|
||||
function getStore() {
|
||||
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
|
||||
const root = extension_settings[extensionName];
|
||||
if (!root.progressive_memory) {
|
||||
root.progressive_memory = structuredClone(progressiveMemoryDefaults.progressive_memory);
|
||||
}
|
||||
// 补齐缺失键(旧存档或部分写入)
|
||||
root.progressive_memory = {
|
||||
...structuredClone(progressiveMemoryDefaults.progressive_memory),
|
||||
...root.progressive_memory,
|
||||
injection: {
|
||||
...progressiveMemoryDefaults.progressive_memory.injection,
|
||||
...(root.progressive_memory.injection || {}),
|
||||
},
|
||||
};
|
||||
return root.progressive_memory;
|
||||
}
|
||||
|
||||
export function bindProgressiveMemoryEvents() {
|
||||
const panel = $('#amily2_progressive_memory_panel');
|
||||
if (panel.length === 0) return;
|
||||
|
||||
panel.on('change', '#pm-enabled', function () {
|
||||
getStore().enabled = this.checked;
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
panel.on('change', '#pm-target-table', function () {
|
||||
getStore().targetTable = this.value.trim() || '总结表';
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
const numMap = {
|
||||
'pm-recent': 'recentCount',
|
||||
'pm-mid': 'midCount',
|
||||
'pm-far': 'farCount',
|
||||
};
|
||||
panel.on('change', '#pm-recent, #pm-mid, #pm-far', function () {
|
||||
getStore()[numMap[this.id]] = Math.max(0, parseInt(this.value, 10) || 0);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
panel.on('change', '#pm-inj-position', function () {
|
||||
getStore().injection.position = Math.max(0, parseInt(this.value, 10) || 0);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
panel.on('change', '#pm-inj-depth', function () {
|
||||
getStore().injection.depth = Math.max(0, parseInt(this.value, 10) || 0);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
panel.on('change', '#pm-inj-role', function () {
|
||||
getStore().injection.role = parseInt(this.value, 10) || 0;
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
panel.on('change', '#pm-template', function () {
|
||||
getStore().template = this.value;
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
loadProgressiveMemorySettings();
|
||||
console.log('[Amily2-渐进记忆] 事件绑定完成。');
|
||||
}
|
||||
|
||||
function loadProgressiveMemorySettings() {
|
||||
const s = getStore();
|
||||
$('#pm-enabled').prop('checked', s.enabled === true);
|
||||
$('#pm-target-table').val(s.targetTable);
|
||||
$('#pm-recent').val(s.recentCount);
|
||||
$('#pm-mid').val(s.midCount);
|
||||
$('#pm-far').val(s.farCount);
|
||||
$('#pm-inj-position').val(s.injection.position);
|
||||
$('#pm-inj-depth').val(s.injection.depth);
|
||||
$('#pm-inj-role').val(String(s.injection.role));
|
||||
$('#pm-template').val(s.template);
|
||||
}
|
||||
141
core/progressive-memory/engine.js
Normal file
141
core/progressive-memory/engine.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* core/progressive-memory/engine.js
|
||||
*
|
||||
* 渐进记忆(内测)注入引擎。
|
||||
*
|
||||
* 与超级记忆(写世界书条目)不同,本模块通过 setExtensionPrompt 直接把采样结果
|
||||
* 注入到当回合上下文——内容独立、不落世界书、不随聊天/角色卡导出,生命周期天然
|
||||
* 跟随会话。数据源为某张追加式表格(默认「总结表」),按时间梯度采样:
|
||||
* 最新 X 行全量 + 历史对半拆,较近一半等距取 Y 行、较远一半等距取 Z 行。
|
||||
*
|
||||
* 权限:开发中功能,plugin_user_type >= 3 方可生效(未来版本对外开放)。
|
||||
*
|
||||
* 设计为纯数据驱动:所有参数存 extension_settings[extensionName].progressive_memory,
|
||||
* 采样逻辑委托 sampler.js(纯函数),后续可平移为 memory-blocks 工作链节点。
|
||||
*/
|
||||
|
||||
import { setExtensionPrompt } from "/script.js";
|
||||
import { extension_settings, getContext } from "/scripts/extensions.js";
|
||||
import { extensionName } from "../../utils/settings.js";
|
||||
import { getMemoryState } from "../table-system/manager.js";
|
||||
import { sampleProgressive } from "./sampler.js";
|
||||
|
||||
const INJECTION_KEY = "AMILY2_PROGRESSIVE_MEMORY";
|
||||
const PLACEHOLDER = "{{progressive_memory}}";
|
||||
|
||||
export const progressiveMemoryDefaults = {
|
||||
progressive_memory: {
|
||||
enabled: false,
|
||||
targetTable: "总结表",
|
||||
recentCount: 5,
|
||||
midCount: 5,
|
||||
farCount: 3,
|
||||
// 注入模板:占位符 {{progressive_memory}} 处填入采样后的行文本
|
||||
template:
|
||||
"##以下是按时间梯度回顾的历史记忆(近期完整、远期摘要,时间从旧到新),作为后续剧情的连续性参考:\n{{progressive_memory}}",
|
||||
injection: { position: 1, depth: 0, role: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
function getSettings() {
|
||||
const root = extension_settings[extensionName] || {};
|
||||
return { ...progressiveMemoryDefaults.progressive_memory, ...(root.progressive_memory || {}) };
|
||||
}
|
||||
|
||||
function isAuthorized() {
|
||||
return parseInt(localStorage.getItem("plugin_user_type") || "0") >= 3;
|
||||
}
|
||||
|
||||
/** 把单行渲染为 `- 列名: 值` 块,与超级记忆详情条目格式一致,便于 AI 解析。 */
|
||||
function renderRow(row, headers, tableName) {
|
||||
let finalHeaders = headers;
|
||||
if (!finalHeaders || finalHeaders.length < row.length) {
|
||||
finalHeaders = [];
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
finalHeaders.push((headers && headers[i]) ? headers[i] : `Col_${i}`);
|
||||
}
|
||||
}
|
||||
let out = `【${tableName} · ${row[0] || "?"}】\n`;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
out += `- ${finalHeaders[i] || `Col_${i}`}: ${row[i] ?? ""}\n`;
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建注入文本。返回 '' 表示无需注入(未启用 / 无权限 / 无数据)。
|
||||
*/
|
||||
export function buildProgressiveInjection() {
|
||||
if (!isAuthorized()) return "";
|
||||
|
||||
const s = getSettings();
|
||||
if (!s.enabled) return "";
|
||||
|
||||
const tables = getMemoryState();
|
||||
if (!Array.isArray(tables) || tables.length === 0) return "";
|
||||
|
||||
const table = tables.find(t => t.name === s.targetTable);
|
||||
if (!table || !Array.isArray(table.rows) || table.rows.length === 0) return "";
|
||||
|
||||
const headers = table.headers || [];
|
||||
const rowStatuses = table.rowStatuses || [];
|
||||
|
||||
// 候选行:有主键、非删除中
|
||||
const candidates = [];
|
||||
table.rows.forEach((row, index) => {
|
||||
if (!row || row.length === 0) return;
|
||||
const primary = row[0];
|
||||
if (primary === undefined || primary === null || String(primary).trim() === "") return;
|
||||
if (rowStatuses[index] === "pending-deletion") return;
|
||||
candidates.push(row);
|
||||
});
|
||||
|
||||
if (candidates.length === 0) return "";
|
||||
|
||||
const picked = sampleProgressive(candidates.length, {
|
||||
recentCount: s.recentCount,
|
||||
midCount: s.midCount,
|
||||
farCount: s.farCount,
|
||||
});
|
||||
if (picked.length === 0) return "";
|
||||
|
||||
const body = picked.map(pos => renderRow(candidates[pos], headers, s.targetTable)).join("\n\n");
|
||||
const template = s.template || PLACEHOLDER;
|
||||
return template.includes(PLACEHOLDER) ? template.replace(PLACEHOLDER, body) : `${template}\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行注入。由统一注入周期(executeAmily2Injection)调用。
|
||||
* @param {string} [type] 'quiet' 时跳过(与表格注入器一致)
|
||||
*/
|
||||
export function injectProgressiveMemory(type) {
|
||||
try {
|
||||
if (type === "quiet") return;
|
||||
|
||||
const content = buildProgressiveInjection();
|
||||
if (!content) {
|
||||
setExtensionPrompt(INJECTION_KEY, "", 0, 0, false, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const s = getSettings();
|
||||
const inj = s.injection || {};
|
||||
setExtensionPrompt(
|
||||
INJECTION_KEY,
|
||||
content,
|
||||
parseInt(inj.position ?? 1, 10),
|
||||
parseInt(inj.depth ?? 0, 10),
|
||||
false,
|
||||
parseInt(inj.role ?? 0, 10),
|
||||
);
|
||||
console.log(`[Amily2-渐进记忆] 已注入 (position:${inj.position}, depth:${inj.depth}, role:${inj.role})。`);
|
||||
} catch (error) {
|
||||
console.error("[Amily2-渐进记忆] 注入失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearProgressiveMemoryInjection() {
|
||||
try {
|
||||
setExtensionPrompt(INJECTION_KEY, "", 0, 0, false, 0);
|
||||
} catch { /* ST 未就绪时静默 */ }
|
||||
}
|
||||
76
core/progressive-memory/index.html
Normal file
76
core/progressive-memory/index.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<div class="amily2-header">
|
||||
<div class="additional-features-title interactable" title="Amily2 渐进记忆(内测)">
|
||||
<i class="fas fa-layer-group"></i> 渐进记忆 <span style="font-size: 0.6em; color: #ffc107; vertical-align: super;">BETA</span>
|
||||
</div>
|
||||
<button id="amily2_back_to_main_from_progressive_memory" class="menu_button secondary small_button interactable">
|
||||
返回主殿 <i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<hr class="header-divider">
|
||||
|
||||
<div id="pm-modal-container">
|
||||
<div class="sm-intro-box">
|
||||
<h3><i class="fas fa-layer-group"></i> 渐进记忆 (Progressive Memory)</h3>
|
||||
<p>按"近期完整、远期摘要"的时间梯度,从指定表格采样历史并注入上下文——模拟人类记忆衰减,在有限 Token 内兼顾最近连贯与长期回顾。</p>
|
||||
<p style="color: #ffc107; font-size: 0.9em;"><i class="fas fa-flask"></i> 内测功能:注入内容直达上下文、不写世界书、不随聊天导出,仅当回合生效。</p>
|
||||
</div>
|
||||
|
||||
<fieldset class="sm-settings-group">
|
||||
<legend><i class="fas fa-cogs"></i> 基础设置</legend>
|
||||
<div class="sm-control-block">
|
||||
<label>启用渐进记忆 (总开关):</label>
|
||||
<label class="sm-toggle-switch">
|
||||
<input type="checkbox" id="pm-enabled">
|
||||
<span class="sm-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sm-control-block">
|
||||
<label title="作为采样数据源的表格名称,需为追加式表格(行序从旧到新),推荐总结表">数据源表格:</label>
|
||||
<input type="text" id="pm-target-table" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px;" value="总结表">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="sm-settings-group">
|
||||
<legend><i class="fas fa-sliders-h"></i> 采样参数</legend>
|
||||
<div class="sm-control-block">
|
||||
<label title="最新 N 行全量保留(最近发生的事,完整保真)">近期全量行数 (X):</label>
|
||||
<input type="number" id="pm-recent" min="0" step="1" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 80px;" value="5">
|
||||
</div>
|
||||
<div class="sm-control-block">
|
||||
<label title="除近期外的历史对半拆分,较近的一半(中区)等距取 N 行">中区采样行数 (Y):</label>
|
||||
<input type="number" id="pm-mid" min="0" step="1" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 80px;" value="5">
|
||||
</div>
|
||||
<div class="sm-control-block">
|
||||
<label title="历史较远的一半(远区)等距取 N 行">远区采样行数 (Z):</label>
|
||||
<input type="number" id="pm-far" min="0" step="1" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 80px;" value="3">
|
||||
</div>
|
||||
<small style="color: #888; font-size: 0.8em; display: block; margin-top: -5px; margin-bottom: 10px; padding-left: 5px;">
|
||||
行序按时间从旧到新:最新 X 行全量;其余历史对半拆,较近一半等距取 Y 行、较远一半等距取 Z 行(中心对齐等距采样,不随机、不首尾加权,避免内容扎堆或事件结局被规律性忽略)。
|
||||
</small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="sm-settings-group">
|
||||
<legend><i class="fas fa-syringe"></i> 注入设置</legend>
|
||||
<div class="sm-control-block">
|
||||
<label title="注入位置:0=系统提示前,1=系统提示后(@D 注入用深度控制)">注入位置 (position):</label>
|
||||
<input type="number" id="pm-inj-position" min="0" max="1" step="1" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 80px;" value="1">
|
||||
</div>
|
||||
<div class="sm-control-block">
|
||||
<label title="注入深度(楼层),0 = 最底部">注入深度 (depth):</label>
|
||||
<input type="number" id="pm-inj-depth" min="0" step="1" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 80px;" value="0">
|
||||
</div>
|
||||
<div class="sm-control-block">
|
||||
<label title="注入角色:0=系统,1=用户,2=AI">注入角色 (role):</label>
|
||||
<select id="pm-inj-role" class="text_pole" style="background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; width: 100px;">
|
||||
<option value="0">系统</option>
|
||||
<option value="1">用户</option>
|
||||
<option value="2">AI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm-control-block" style="display: block;">
|
||||
<label title="占位符 {{progressive_memory}} 会被替换为采样后的行文本">注入模板:</label>
|
||||
<textarea id="pm-template" rows="4" style="width: 100%; margin-top: 5px; background: rgba(0, 0, 0, 0.3); border: 1px solid #444; color: #e0e0e0; padding: 5px; border-radius: 4px; box-sizing: border-box;"></textarea>
|
||||
<small style="color: #888; font-size: 0.8em; display: block; margin-top: 3px;">必须包含占位符 <code>{{progressive_memory}}</code>;缺失时采样内容会追加到模板末尾。</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
69
core/progressive-memory/sampler.js
Normal file
69
core/progressive-memory/sampler.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* core/progressive-memory/sampler.js
|
||||
*
|
||||
* 渐进式记忆采样(梯度记忆):行序假定为 旧 → 新(追加式表格,如总结表)。
|
||||
*
|
||||
* [───── 远区(历史前 50%)─────][───── 中区(历史后 50%)─────][最新 recentCount 行]
|
||||
* 等距取 farCount 行 等距取 midCount 行 全量保留
|
||||
*
|
||||
* 设计约束(与用户确认):
|
||||
* - 不随机、不首尾加权——等距(中心对齐)抽样,时间分布均匀、结果可预期,
|
||||
* 避免内容扎堆在某段时期或事件结局被采样规律性忽略。
|
||||
* - 参数为纯 JSON(recentCount / midCount / farCount),后续可直接作为
|
||||
* memory-blocks 工作链的 progressive_sample 节点参数平移。
|
||||
*
|
||||
* 本模块只做"选哪些行"的纯计算,不涉及渲染与世界书写入。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 中心对齐等距抽样:从长度 length 的区间内取 count 个索引。
|
||||
* 取样点 = floor((i + 0.5) * length / count),使样本落在各等分段的中点,
|
||||
* 避免"恒取区间开头/结尾"造成的边界偏置。
|
||||
*
|
||||
* @param {number} length 区间长度
|
||||
* @param {number} count 期望取样数
|
||||
* @returns {number[]} 升序、去重后的区间内偏移索引(0-based)
|
||||
*/
|
||||
export function evenIndices(length, count) {
|
||||
if (length <= 0 || count <= 0) return [];
|
||||
if (count >= length) return Array.from({ length }, (_, i) => i);
|
||||
const out = new Set();
|
||||
for (let i = 0; i < count; i++) {
|
||||
out.add(Math.floor((i + 0.5) * length / count));
|
||||
}
|
||||
return [...out].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渐进式采样主入口。
|
||||
*
|
||||
* @param {number} totalCount 候选行总数(已过滤掉删除中/无主键的行之后)
|
||||
* @param {{ recentCount?: number, midCount?: number, farCount?: number }} [params]
|
||||
* @returns {number[]} 升序的入选行索引(相对候选序列,0 = 最旧)
|
||||
*/
|
||||
export function sampleProgressive(totalCount, params = {}) {
|
||||
const recentCount = Math.max(0, params.recentCount ?? 5);
|
||||
const midCount = Math.max(0, params.midCount ?? 5);
|
||||
const farCount = Math.max(0, params.farCount ?? 3);
|
||||
|
||||
if (totalCount <= 0) return [];
|
||||
|
||||
const picked = new Set();
|
||||
|
||||
// 最新 recentCount 行全量
|
||||
const recent = Math.min(recentCount, totalCount);
|
||||
const recentStart = totalCount - recent;
|
||||
for (let i = recentStart; i < totalCount; i++) picked.add(i);
|
||||
|
||||
// 历史区 [0, recentStart),对半拆:前半=远区,后半=中区(更靠近现在)
|
||||
const histLen = recentStart;
|
||||
if (histLen > 0) {
|
||||
const farLen = Math.floor(histLen / 2);
|
||||
const midLen = histLen - farLen;
|
||||
|
||||
for (const offset of evenIndices(midLen, midCount)) picked.add(farLen + offset);
|
||||
for (const offset of evenIndices(farLen, farCount)) picked.add(offset);
|
||||
}
|
||||
|
||||
return [...picked].sort((a, b) => a - b);
|
||||
}
|
||||
@@ -14,6 +14,14 @@ import { extensionName } from '../utils/settings.js';
|
||||
const MODULE_NAME = 'hanlinyuan-rag-core';
|
||||
const GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com';
|
||||
|
||||
// profile-sync 在 UI 隐藏字段时填入的掩码占位符(const MASKED_KEY = '••••••••')。
|
||||
// 历史上 saveSettingsFromUI 曾把这个占位符写回 settings.{rerank,retrieval}.apiKey,
|
||||
// 导致取消 Profile 分配后实际请求带占位符 token 被 401。这里做防御性还原。
|
||||
const PROFILE_MASKED_KEY = '••••••••';
|
||||
function sanitizeMaskedKey(key) {
|
||||
return key === PROFILE_MASKED_KEY ? '' : key;
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
const root = extension_settings[extensionName];
|
||||
const nested = root && root[MODULE_NAME];
|
||||
@@ -32,15 +40,31 @@ function getSettings() {
|
||||
export async function getEmbedRetrievalSettings() {
|
||||
const profile = await getSlotProfile('ragEmbed');
|
||||
if (profile) {
|
||||
const apiKey = sanitizeMaskedKey(profile.apiKey ?? '');
|
||||
return {
|
||||
apiEndpoint: profile.provider === 'google' ? 'google_direct' : 'custom',
|
||||
customApiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
apiKey,
|
||||
embeddingModel: profile.model,
|
||||
batchSize: getSettings().retrieval?.batchSize ?? 5,
|
||||
// Key 存储是设备本地的(ApiKeyStore local/cloud 模式均不跨设备),
|
||||
// 换设备/浏览器后 profile 同步而 Key 缺失——标记出来供报错说明
|
||||
_keyMissingFromProfile: !apiKey,
|
||||
_profileName: profile.name || profile.id,
|
||||
};
|
||||
}
|
||||
return getSettings().retrieval || {};
|
||||
const fallback = getSettings().retrieval || {};
|
||||
return { ...fallback, apiKey: sanitizeMaskedKey(fallback.apiKey ?? '') };
|
||||
}
|
||||
|
||||
/** Key 缺失时的统一文案:区分"profile 在本设备无 Key"与"未配置" */
|
||||
export function describeMissingKey(resolved, plainMessage) {
|
||||
if (resolved?._keyMissingFromProfile) {
|
||||
return `连接配置「${resolved._profileName}」在本设备上没有可用的 API Key。` +
|
||||
`Key 仅保存在最初填写它的设备/浏览器上,不随云端设置同步。` +
|
||||
`请在「API 连接配置」面板编辑该配置并重新填写 Key。`;
|
||||
}
|
||||
return plainMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,15 +73,20 @@ export async function getEmbedRetrievalSettings() {
|
||||
export async function getRerankSettings() {
|
||||
const profile = await getSlotProfile('ragRerank');
|
||||
if (profile) {
|
||||
const manualSettings = getSettings().rerank || {};
|
||||
const apiKey = sanitizeMaskedKey(profile.apiKey ?? '');
|
||||
return {
|
||||
url: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
apiKey,
|
||||
model: profile.model,
|
||||
top_n: getSettings().rerank?.top_n ?? 10,
|
||||
apiMode: 'custom',
|
||||
top_n: manualSettings.top_n ?? 10,
|
||||
apiMode: manualSettings.apiMode ?? 'custom',
|
||||
_keyMissingFromProfile: !apiKey,
|
||||
_profileName: profile.name || profile.id,
|
||||
};
|
||||
}
|
||||
return getSettings().rerank || {};
|
||||
const fallback = getSettings().rerank || {};
|
||||
return { ...fallback, apiKey: sanitizeMaskedKey(fallback.apiKey ?? '') };
|
||||
}
|
||||
|
||||
function normalizeApiResponse(responseData) {
|
||||
@@ -109,8 +138,8 @@ export async function fetchEmbeddingModels(overrideSettings = null) {
|
||||
|
||||
switch (apiEndpoint) {
|
||||
case 'google_direct':
|
||||
if (!apiKey) throw new Error("Google直连模式需要API Key。");
|
||||
|
||||
if (!apiKey) throw new Error(describeMissingKey(settings, "Google直连模式需要API Key。"));
|
||||
|
||||
const fetchGoogleModels = async (version) => {
|
||||
const url = `${GOOGLE_API_BASE_URL}/${version}/models`;
|
||||
console.log(`[翰林院] 正在从 Google API (${version}) 获取模型列表: ${url}`);
|
||||
@@ -214,7 +243,7 @@ export async function fetchRerankModels() {
|
||||
throw new Error("Rerank API URL 未提供。");
|
||||
}
|
||||
if (apiMode === 'custom' && !apiKey) {
|
||||
throw new Error("自定义模式下,Rerank API Key 未提供。");
|
||||
throw new Error(describeMissingKey(settings, "自定义模式下,Rerank API Key 未提供。"));
|
||||
}
|
||||
|
||||
const baseUrl = getRerankBaseUrl(url);
|
||||
@@ -253,7 +282,7 @@ export async function executeRerank(query, documents, rerankSettings = null) {
|
||||
const { url, apiKey, model, top_n, apiMode = 'custom' } = resolved;
|
||||
|
||||
if (!url) throw new Error("Rerank API URL 未提供。");
|
||||
if (apiMode === 'custom' && !apiKey) throw new Error("自定义模式下,Rerank API Key 未提供。");
|
||||
if (apiMode === 'custom' && !apiKey) throw new Error(describeMissingKey(resolved, "自定义模式下,Rerank API Key 未提供。"));
|
||||
|
||||
const baseUrl = getRerankBaseUrl(url);
|
||||
const rerankUrl = `${baseUrl}/v1/rerank`;
|
||||
@@ -298,10 +327,12 @@ export function getApiEndpointUrl(raw = false, overrideRetrieval = null) {
|
||||
break;
|
||||
case 'azure':
|
||||
case 'custom':
|
||||
case 'local_proxy': // 本地代理(LM Studio/Ollama)同样使用用户填写的地址,此前漏掉落入 default 被错误定向到 openai.com
|
||||
url = customApiUrl;
|
||||
break;
|
||||
default:
|
||||
url = 'https://api.openai.com';
|
||||
// apiEndpoint 为空/非法(历史 profile-sync 污染)时,customApiUrl 比硬编码 openai.com 更可能是用户真实意图
|
||||
url = customApiUrl || 'https://api.openai.com';
|
||||
break;
|
||||
}
|
||||
if (raw) {
|
||||
@@ -345,7 +376,7 @@ export async function getEmbeddings(texts, signal = null) {
|
||||
switch (apiEndpoint) {
|
||||
case 'google_direct':
|
||||
console.log('[翰林院-API] 使用Google直连模式获取向量。');
|
||||
if (!apiKey) throw new Error('Google直连模式需要API Key。');
|
||||
if (!apiKey) throw new Error(describeMissingKey(settings, 'Google直连模式需要API Key。'));
|
||||
|
||||
// 使用适配器构建URL和请求体;Key 通过 x-goog-api-key 头传递避免 URL 泄露
|
||||
const googleUrl = buildGoogleEmbeddingApiUrl(GOOGLE_API_BASE_URL, embeddingModel);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
fetchEmbeddingModels as apiFetchEmbeddingModels,
|
||||
fetchRerankModels as apiFetchRerankModels,
|
||||
executeRerank,
|
||||
getRerankSettings,
|
||||
testApiConnection as apiTestApiConnection
|
||||
} from './rag-api.js';
|
||||
import { superSort } from './super-sorter.js';
|
||||
@@ -152,6 +153,7 @@ function initialize() {
|
||||
return;
|
||||
}
|
||||
migrateLegacyRagSettings();
|
||||
sanitizeProfilePollution();
|
||||
settings = getSettings();
|
||||
if (!window.hanlinyuanRagProcessor) {
|
||||
window.hanlinyuanRagProcessor = {};
|
||||
@@ -219,20 +221,27 @@ async function ingestTextToHanlinyuan(text, source = 'manual', metadata = {}, pr
|
||||
break;
|
||||
}
|
||||
|
||||
// 独立聊天记忆模式:聊天记录类向量按聊天分桶(剧情线隔离),
|
||||
// 其余来源(小说/世界书/手动)属于"知识",仍随角色卡共享
|
||||
const independentChatId = (source === 'chat_history' && settings.retrieval.independentChatMemoryEnabled)
|
||||
? getChatId()
|
||||
: null;
|
||||
|
||||
const existingKbs = Object.values(getKnowledgeBases());
|
||||
const foundKb = existingKbs.find(kb => kb.name === kbName);
|
||||
// 同名合并需限定在同一聊天命名空间内,避免独立模式下不同聊天的同名楼层段互相串库
|
||||
const foundKb = existingKbs.find(kb => kb.name === kbName && (kb.chatId ?? null) === independentChatId);
|
||||
|
||||
if (foundKb) {
|
||||
taskId = foundKb.id;
|
||||
logCallback(`[翰林院-核心] 检测到同名知识库 "${kbName}",将数据合并入库。`, 'info');
|
||||
} else {
|
||||
logCallback(`[翰林院-核心] 准备为任务 "${kbName}" 创建专属知识库...`, 'info');
|
||||
const newKb = addKnowledgeBase(kbName, source);
|
||||
const newKb = addKnowledgeBase(kbName, source, independentChatId);
|
||||
taskId = newKb.id;
|
||||
}
|
||||
|
||||
|
||||
const charId = getCharacterStableId();
|
||||
const collectionId = `${charId}_${taskId}`;
|
||||
const collectionId = independentChatId ? `${independentChatId}_${taskId}` : `${charId}_${taskId}`;
|
||||
logCallback(`[翰林院-核心] 已创建并锁定知识库: ${kbName} (集合ID: ${collectionId})`, 'success');
|
||||
logCallback(`[翰林院-核心] 已锁定忆识宝库ID: ${collectionId}`, 'info');
|
||||
|
||||
@@ -341,7 +350,26 @@ function getSettings() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 旧版设置 rerank.priorityRetrieval 可能只有 enabled 字段而缺少 sources,补全
|
||||
if (s.rerank?.priorityRetrieval && !s.rerank.priorityRetrieval.sources) {
|
||||
s.rerank.priorityRetrieval.sources = structuredClone(ragDefaultSettings.rerank.priorityRetrieval.sources);
|
||||
}
|
||||
// 确保 sources 中每个来源条目完整(新增来源 / 新增字段时旧用户不会缺失)
|
||||
if (s.rerank?.priorityRetrieval?.sources) {
|
||||
const defaultSources = ragDefaultSettings.rerank.priorityRetrieval.sources;
|
||||
for (const sourceName in defaultSources) {
|
||||
if (!s.rerank.priorityRetrieval.sources[sourceName]) {
|
||||
s.rerank.priorityRetrieval.sources[sourceName] = structuredClone(defaultSources[sourceName]);
|
||||
} else {
|
||||
const existing = s.rerank.priorityRetrieval.sources[sourceName];
|
||||
for (const key in defaultSources[sourceName]) {
|
||||
if (existing[key] === undefined) existing[key] = defaultSources[sourceName][key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -391,6 +419,49 @@ function migrateLegacyRagSettings() {
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性清洗 profile-sync 历史污染(2.2.5 之前的版本遗留)。
|
||||
*
|
||||
* 旧版 saveSettingsFromUI 会把被 Profile 接管的隐藏字段值写回 settings:
|
||||
* - apiKey 被写成掩码 '••••••••'(rag-api 已有读侧防御,这里根治持久层)
|
||||
* - apiEndpoint 的 select 被 _fillLegacyFields 赋了不存在的 option 值
|
||||
* (profile.provider 如 'custom_oai')后 value 变 '','' 被写回 settings;
|
||||
* '' 在 getApiEndpointUrl 落 default 分支,请求被错误定向 → 向量化全失败
|
||||
*
|
||||
* 2.2.5 修复了"继续污染",本函数清理已污染的存量数据。
|
||||
*/
|
||||
function sanitizeProfilePollution() {
|
||||
const s = getSettings();
|
||||
const MASKED = '••••••••';
|
||||
let cleaned = [];
|
||||
|
||||
if (s.retrieval?.apiKey === MASKED) {
|
||||
s.retrieval.apiKey = '';
|
||||
cleaned.push('retrieval.apiKey 掩码');
|
||||
}
|
||||
if (s.rerank?.apiKey === MASKED) {
|
||||
s.rerank.apiKey = '';
|
||||
cleaned.push('rerank.apiKey 掩码');
|
||||
}
|
||||
|
||||
// 合法值与 UI select 选项及 rag-api 的 switch 分支保持一致
|
||||
const validEndpoints = ['custom', 'google_direct', 'local_proxy', 'openai', 'azure'];
|
||||
if (s.retrieval && !validEndpoints.includes(s.retrieval.apiEndpoint)) {
|
||||
cleaned.push(`retrieval.apiEndpoint 非法值 "${s.retrieval.apiEndpoint}"`);
|
||||
s.retrieval.apiEndpoint = 'custom';
|
||||
}
|
||||
const validRerankModes = ['custom', 'local_proxy'];
|
||||
if (s.rerank && !validRerankModes.includes(s.rerank.apiMode)) {
|
||||
cleaned.push(`rerank.apiMode 非法值 "${s.rerank.apiMode}"`);
|
||||
s.rerank.apiMode = 'custom';
|
||||
}
|
||||
|
||||
if (cleaned.length > 0) {
|
||||
console.warn(`[翰林院] 已清洗 profile-sync 历史污染字段: ${cleaned.join('、')}`);
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
function showNotification(message, type = 'info') {
|
||||
toastr[type](message);
|
||||
}
|
||||
@@ -412,6 +483,71 @@ function getTagForSource(source) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 边界感知切分:把 content 切成不超过 chunkSize 的片段,尽量在自然边界断开。
|
||||
*
|
||||
* 三级回退策略(替代旧的纯字符硬切,避免句子/对话被拦腰截断):
|
||||
* 1. 段落边界(最后一个换行符)
|
||||
* 2. 句末边界(。!?!?… 及其后跟随的闭合引号/括号)
|
||||
* 3. 都找不到(极端长串)才硬切
|
||||
* 边界切点过于靠前(< 40% 块长)时视为无效,降级到下一策略——防止
|
||||
* 一个超长段落开头的短句导致块碎片化。
|
||||
*
|
||||
* @param {string} content
|
||||
* @param {number} chunkSize - 单块最大字符数
|
||||
* @param {number} overlap - 相邻块重叠字符数(语义衔接),从上一块尾部回看
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function splitBySemanticBoundary(content, chunkSize, overlap) {
|
||||
const pieces = [];
|
||||
if (!content || chunkSize <= 0) return pieces;
|
||||
|
||||
const minCut = Math.floor(chunkSize * 0.4);
|
||||
const sentenceEndRegex = /[。!?!?…][”"』」))】]?/g;
|
||||
|
||||
let pos = 0;
|
||||
while (pos < content.length) {
|
||||
let end = Math.min(pos + chunkSize, content.length);
|
||||
|
||||
if (end < content.length) {
|
||||
const slice = content.substring(pos, end);
|
||||
|
||||
// 1. 段落边界:最后一个换行(切点含换行符本身)
|
||||
let cut = slice.lastIndexOf('\n') + 1;
|
||||
|
||||
// 2. 段落边界无效时找最后一个句末边界
|
||||
if (cut <= minCut) {
|
||||
let lastSentenceEnd = -1;
|
||||
sentenceEndRegex.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = sentenceEndRegex.exec(slice)) !== null) {
|
||||
lastSentenceEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastSentenceEnd > minCut) cut = lastSentenceEnd;
|
||||
}
|
||||
|
||||
// 3. 有效边界则收缩切点,否则保持硬切
|
||||
if (cut > minCut) end = pos + cut;
|
||||
}
|
||||
|
||||
const piece = content.substring(pos, end);
|
||||
if (piece.trim().length > 0) pieces.push(piece);
|
||||
|
||||
if (end >= content.length) break;
|
||||
// overlap 回看;Math.max 防止 overlap >= 块长时死循环
|
||||
pos = Math.max(end - overlap, pos + 1);
|
||||
}
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/** 把 ISO/任意时间值格式化为写入块 prefix 的紧凑标识(不含逗号,便于正则反解) */
|
||||
function formatChunkTimeLabel(timestamp) {
|
||||
const d = new Date(timestamp);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function splitIntoChunks(text, source, metadata = {}) {
|
||||
switch (source) {
|
||||
case 'novel':
|
||||
@@ -446,30 +582,22 @@ function _chunkForNovel(text, metadata) {
|
||||
function processBuffer() {
|
||||
if (contentBuffer.length === 0) return;
|
||||
const content = contentBuffer.join('\n');
|
||||
let start = 0;
|
||||
let section = 1;
|
||||
while (start < content.length) {
|
||||
const end = Math.min(start + chunkSize, content.length);
|
||||
const chunkText = content.substring(start, end);
|
||||
if (chunkText.trim().length > 0) {
|
||||
const chunkMetadata = {
|
||||
source: 'novel',
|
||||
sourceName: sourceName,
|
||||
timestamp: new Date().toISOString(),
|
||||
globalIndex: globalChunkIndex++,
|
||||
volume: currentVolumeTitle,
|
||||
chapter: currentChapterTitle,
|
||||
section: section,
|
||||
};
|
||||
const tagName = getTagForSource('novel');
|
||||
const prefix = `[来源: ${sourceName}, ${currentVolumeTitle}, ${currentChapterTitle}, 第${section}节]`;
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
allChunks.push({ text: wrappedText, metadata: chunkMetadata });
|
||||
section++;
|
||||
}
|
||||
start += (chunkSize - overlap);
|
||||
if (start >= content.length) break;
|
||||
}
|
||||
const tagName = getTagForSource('novel');
|
||||
splitBySemanticBoundary(content, chunkSize, overlap).forEach((chunkText, idx) => {
|
||||
const section = idx + 1;
|
||||
const chunkMetadata = {
|
||||
source: 'novel',
|
||||
sourceName: sourceName,
|
||||
timestamp: new Date().toISOString(),
|
||||
globalIndex: globalChunkIndex++,
|
||||
volume: currentVolumeTitle,
|
||||
chapter: currentChapterTitle,
|
||||
section: section,
|
||||
};
|
||||
const prefix = `[来源: ${sourceName}, ${currentVolumeTitle}, ${currentChapterTitle}, 第${section}节]`;
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
allChunks.push({ text: wrappedText, metadata: chunkMetadata });
|
||||
});
|
||||
contentBuffer = [];
|
||||
}
|
||||
|
||||
@@ -489,11 +617,9 @@ function _chunkForNovel(text, metadata) {
|
||||
processBuffer();
|
||||
|
||||
if (allChunks.length === 0 && text.length > 0) {
|
||||
let start = 0;
|
||||
let section = 1;
|
||||
while (start < text.length) {
|
||||
const end = Math.min(start + chunkSize, text.length);
|
||||
const chunkText = text.substring(start, end);
|
||||
const tagName = getTagForSource('novel');
|
||||
splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
|
||||
const section = idx + 1;
|
||||
const chunkMetadata = {
|
||||
source: 'novel',
|
||||
sourceName: sourceName,
|
||||
@@ -503,13 +629,10 @@ function _chunkForNovel(text, metadata) {
|
||||
chapter: "第1章",
|
||||
section: section,
|
||||
};
|
||||
const tagName = getTagForSource('novel');
|
||||
const prefix = `[来源: ${sourceName}, 第1卷, 第1章, 第${section}节]`;
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
allChunks.push({ text: wrappedText, metadata: chunkMetadata });
|
||||
section++;
|
||||
start += (chunkSize - overlap);
|
||||
}
|
||||
});
|
||||
}
|
||||
return allChunks;
|
||||
}
|
||||
@@ -521,15 +644,15 @@ function _chunkForChatHistory(text, metadata) {
|
||||
const allChunks = [];
|
||||
if (!text || chunkSize <= 0) return allChunks;
|
||||
|
||||
let part = 1;
|
||||
let start = 0;
|
||||
// 时间写进 prefix 才能在检索后被反解回来(ST 向量存储不持久化 metadata)
|
||||
const timeLabel = formatChunkTimeLabel(timestamp);
|
||||
const tagName = getTagForSource('chat_history');
|
||||
|
||||
while (start < text.length) {
|
||||
const end = Math.min(start + chunkSize, text.length);
|
||||
const chunkText = text.substring(start, end);
|
||||
|
||||
const prefix = `[来源: 聊天记录, 楼层: #${floor}, 第${part}部分]`;
|
||||
const tagName = getTagForSource('chat_history');
|
||||
splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
|
||||
const part = idx + 1;
|
||||
const prefix = timeLabel
|
||||
? `[来源: 聊天记录, 楼层: #${floor}, 时间: ${timeLabel}, 第${part}部分]`
|
||||
: `[来源: 聊天记录, 楼层: #${floor}, 第${part}部分]`;
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
|
||||
allChunks.push({
|
||||
@@ -543,11 +666,7 @@ function _chunkForChatHistory(text, metadata) {
|
||||
timestamp: timestamp,
|
||||
}
|
||||
});
|
||||
|
||||
part++;
|
||||
start += (chunkSize - overlap);
|
||||
if (start >= text.length) break;
|
||||
}
|
||||
});
|
||||
return allChunks;
|
||||
}
|
||||
|
||||
@@ -558,15 +677,11 @@ function _chunkForLorebook(text, metadata) {
|
||||
const allChunks = [];
|
||||
if (!text || chunkSize <= 0) return allChunks;
|
||||
|
||||
let part = 1;
|
||||
let start = 0;
|
||||
const tagName = getTagForSource('lorebook');
|
||||
|
||||
while (start < text.length) {
|
||||
const end = Math.min(start + chunkSize, text.length);
|
||||
const chunkText = text.substring(start, end);
|
||||
|
||||
splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
|
||||
const part = idx + 1;
|
||||
const prefix = `[来源: ${bookName}, 条目: ${entryName}, 第${part}部分]`;
|
||||
const tagName = getTagForSource('lorebook');
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
|
||||
allChunks.push({
|
||||
@@ -580,11 +695,7 @@ function _chunkForLorebook(text, metadata) {
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
part++;
|
||||
start += (chunkSize - overlap);
|
||||
if (start >= text.length) break;
|
||||
}
|
||||
});
|
||||
return allChunks;
|
||||
}
|
||||
|
||||
@@ -596,16 +707,12 @@ function _chunkForManual(text, metadata) {
|
||||
if (!text || chunkSize <= 0) return allChunks;
|
||||
|
||||
const timestamp = new Date();
|
||||
const readableTime = timestamp.toLocaleString('zh-CN');
|
||||
let part = 1;
|
||||
let start = 0;
|
||||
const readableTime = formatChunkTimeLabel(timestamp);
|
||||
const tagName = getTagForSource('manual');
|
||||
|
||||
while (start < text.length) {
|
||||
const end = Math.min(start + chunkSize, text.length);
|
||||
const chunkText = text.substring(start, end);
|
||||
|
||||
splitBySemanticBoundary(text, chunkSize, overlap).forEach((chunkText, idx) => {
|
||||
const part = idx + 1;
|
||||
const prefix = `[来源: ${sourceName}, 向量化录入时间: ${readableTime}, 第${part}部分]`;
|
||||
const tagName = getTagForSource('manual');
|
||||
const wrappedText = `<${tagName}>\n${prefix}\n${chunkText}\n</${tagName}>`;
|
||||
|
||||
allChunks.push({
|
||||
@@ -617,11 +724,7 @@ function _chunkForManual(text, metadata) {
|
||||
timestamp: timestamp.toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
part++;
|
||||
start += (chunkSize - overlap);
|
||||
if (start >= text.length) break;
|
||||
}
|
||||
});
|
||||
return allChunks;
|
||||
}
|
||||
|
||||
@@ -689,7 +792,13 @@ function getKnowledgeBases() {
|
||||
return { ...globalBases, ...localBases };
|
||||
}
|
||||
|
||||
function addKnowledgeBase(name, source = 'manual') {
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} source
|
||||
* @param {string|null} chatId - 非空时该库为"聊天级":向量集合按 `${chatId}_${taskId}`
|
||||
* 命名空间隔离(独立聊天记忆模式下的聊天记录库),查询时只对该聊天可见
|
||||
*/
|
||||
function addKnowledgeBase(name, source = 'manual', chatId = null) {
|
||||
if (!name || !name.trim()) {
|
||||
throw new Error('知识库名称不能为空');
|
||||
}
|
||||
@@ -702,17 +811,28 @@ function addKnowledgeBase(name, source = 'manual') {
|
||||
name: name.trim(),
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
owner: charId,
|
||||
source: source,
|
||||
owner: charId,
|
||||
source: source,
|
||||
...(chatId ? { chatId } : {}),
|
||||
};
|
||||
|
||||
bases[taskId] = newBase;
|
||||
saveSettings();
|
||||
|
||||
console.log(`[翰林院-核心] 已为角色 ${charId} 添加新知识库: ${name} (ID: ${taskId})`);
|
||||
|
||||
console.log(`[翰林院-核心] 已为角色 ${charId} 添加新知识库: ${name} (ID: ${taskId}${chatId ? `, 聊天级: ${chatId}` : ''})`);
|
||||
return newBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算知识库的向量集合 ID(单一事实来源)。
|
||||
* 聊天级库(kb.chatId)按聊天命名空间,其余按 owner/角色命名空间。
|
||||
*/
|
||||
function getKbCollectionId(kb, scope = 'local') {
|
||||
if (kb.chatId) return `${kb.chatId}_${kb.id}`;
|
||||
if (scope === 'global') return `${kb.owner || GLOBAL_SCOPE_ID}_${kb.id}`;
|
||||
return `${getCharacterStableId()}_${kb.id}`;
|
||||
}
|
||||
|
||||
async function removeKnowledgeBase(taskId, scope) {
|
||||
const charId = getCharacterStableId();
|
||||
const bases = scope === 'global' ? getGlobalKnowledgeBases() : getLocalKnowledgeBases();
|
||||
@@ -724,9 +844,8 @@ async function removeKnowledgeBase(taskId, scope) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId = scope === 'global' ? (base.owner || GLOBAL_SCOPE_ID) : charId;
|
||||
const collectionIdToPurge = `${ownerId}_${taskId}`;
|
||||
|
||||
const collectionIdToPurge = getKbCollectionId(base, scope);
|
||||
|
||||
console.log(`[翰林院-核心] 准备删除知识库 ${taskId},将清空集合: ${collectionIdToPurge}`);
|
||||
|
||||
const purged = await purgeStorage(collectionIdToPurge);
|
||||
@@ -773,30 +892,38 @@ async function queryVectors(queryText, options = {}) {
|
||||
}
|
||||
else if (settings.retrieval.independentChatMemoryEnabled) {
|
||||
console.log('[翰林院-日志] 独立聊天记忆模式开启...');
|
||||
|
||||
|
||||
const chatId = getChatId();
|
||||
if (chatId) {
|
||||
console.log(`[翰林院-日志] 添加当前聊天宝库: ${chatId}`);
|
||||
basesToQuery.push({ id: chatId, name: `当前聊天 (${chatId})`, scope: 'chat' });
|
||||
} else {
|
||||
console.warn('[翰林院-日志] 无法获取当前聊天ID,跳过聊天宝库。');
|
||||
if (!chatId) {
|
||||
console.warn('[翰林院-日志] 无法获取当前聊天ID,聊天级知识库将被跳过。');
|
||||
}
|
||||
|
||||
const globalBases = getGlobalKnowledgeBases();
|
||||
const enabledGlobalBases = Object.values(globalBases).filter(b => b.enabled);
|
||||
// 本地库过滤规则:知识类库(无 chatId)照常可查;
|
||||
// 聊天级库(有 chatId)只对所属聊天可见——这就是"独立"的含义
|
||||
const localBases = Object.values(getLocalKnowledgeBases())
|
||||
.filter(b => b.enabled && (!b.chatId || b.chatId === chatId));
|
||||
if (localBases.length > 0) {
|
||||
const chatScoped = localBases.filter(b => b.chatId).length;
|
||||
console.log(`[翰林院-日志] 添加 ${localBases.length} 个本地知识库(其中 ${chatScoped} 个为当前聊天专属)。`);
|
||||
basesToQuery.push(...localBases.map(b => ({ ...b, scope: b.chatId ? 'chat' : 'local' })));
|
||||
}
|
||||
|
||||
const enabledGlobalBases = Object.values(getGlobalKnowledgeBases()).filter(b => b.enabled);
|
||||
if (enabledGlobalBases.length > 0) {
|
||||
console.log(`[翰林院-日志] 添加 ${enabledGlobalBases.length} 个已启用的全局知识库。`);
|
||||
basesToQuery.push(...enabledGlobalBases.map(b => ({ ...b, scope: 'global' })));
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log('[翰林院-日志] 统一角色卡模式开启...');
|
||||
const localBases = getLocalKnowledgeBases();
|
||||
const globalBases = getGlobalKnowledgeBases();
|
||||
const enabledLocalBases = Object.values(localBases).filter(b => b.enabled);
|
||||
const enabledGlobalBases = Object.values(globalBases).filter(b => b.enabled);
|
||||
|
||||
basesToQuery.push(...enabledLocalBases.map(b => ({ ...b, scope: 'local' })));
|
||||
|
||||
// 聊天级库(独立模式期间产生)在统一模式下也可见,但需用 'chat' scope
|
||||
// 才能拼出正确的集合 ID(${chatId}_${taskId})
|
||||
basesToQuery.push(...enabledLocalBases.map(b => ({ ...b, scope: b.chatId ? 'chat' : 'local' })));
|
||||
basesToQuery.push(...enabledGlobalBases.map(b => ({ ...b, scope: 'global' })));
|
||||
|
||||
if (basesToQuery.length === 0) {
|
||||
@@ -860,7 +987,9 @@ async function _executeQueryForBase(base, queryText, queryEmbedding = null) {
|
||||
collectionId = await getDynamicCollectionId();
|
||||
break;
|
||||
case 'chat':
|
||||
collectionId = base.id;
|
||||
// 聊天级库:${chatId}_${taskId} 命名空间(独立聊天记忆)。
|
||||
// 旧语义的裸 chatId 集合从未被任何录入路径写入过,无存量兼容负担
|
||||
collectionId = base.chatId ? `${base.chatId}_${base.id}` : base.id;
|
||||
break;
|
||||
case 'global':
|
||||
const ownerId = base.owner || GLOBAL_SCOPE_ID;
|
||||
@@ -926,10 +1055,12 @@ async function _executeQueryForBase(base, queryText, queryEmbedding = null) {
|
||||
switch (sourceTag) {
|
||||
case '聊天记录':
|
||||
newMetadata.source = 'chat_history';
|
||||
const chatMatch = item.text.match(/楼层:\s*#(\d+),\s*第(\d+)部分/);
|
||||
if (chatMatch && chatMatch[1] && chatMatch[2]) {
|
||||
// 时间段为可选:兼容旧格式 [楼层: #X, 第Y部分] 与新格式 [楼层: #X, 时间: ..., 第Y部分]
|
||||
const chatMatch = item.text.match(/楼层:\s*#(\d+)(?:,\s*时间:\s*([^,\]]+))?,\s*第(\d+)部分/);
|
||||
if (chatMatch && chatMatch[1] && chatMatch[3]) {
|
||||
newMetadata.floor = parseInt(chatMatch[1], 10);
|
||||
newMetadata.part = parseInt(chatMatch[2], 10);
|
||||
if (chatMatch[2]) newMetadata.timeLabel = chatMatch[2].trim();
|
||||
newMetadata.part = parseInt(chatMatch[3], 10);
|
||||
newMetadata.sourceName = `聊天记录 #${newMetadata.floor}`;
|
||||
}
|
||||
break;
|
||||
@@ -1032,43 +1163,40 @@ async function getVectorCount(taskId = null, scope = 'local') {
|
||||
console.warn(`[翰林院-计数] 在作用域 '${scope}' 中未找到ID为 ${taskId} 的知识库。`);
|
||||
return 0;
|
||||
}
|
||||
const ownerId = scope === 'global' ? (base.owner || GLOBAL_SCOPE_ID) : charId;
|
||||
const collectionId = `${ownerId}_${taskId}`;
|
||||
return await countVectorsInCollection(collectionId);
|
||||
// 聊天级库按 ${chatId}_${taskId} 命名空间计数(getKbCollectionId 统一处理)
|
||||
return await countVectorsInCollection(getKbCollectionId(base, scope));
|
||||
|
||||
} else {
|
||||
if (settings.retrieval.independentChatMemoryEnabled) {
|
||||
const chatId = getChatId();
|
||||
if (!chatId) return 0;
|
||||
const totalCount = await countVectorsInCollection(chatId);
|
||||
console.log(`[翰林院-日志] 独立聊天记忆模式开启,聊天 ${chatId} 的向量总数: ${totalCount}`);
|
||||
return totalCount;
|
||||
}
|
||||
// 总数统计与查询侧保持同一可见性规则:
|
||||
// 独立模式 → 本地知识库 + 当前聊天的聊天级库 + 全局库
|
||||
// 统一模式 → 全部本地库(含聊天级)+ 全局库 + legacy 宝库
|
||||
const independent = settings.retrieval.independentChatMemoryEnabled;
|
||||
const chatId = independent ? getChatId() : null;
|
||||
console.log(`[翰林院-日志] 开始获取${independent ? '当前聊天可见的' : '所有'}知识库向量总数...`);
|
||||
|
||||
console.log('[翰林院-日志] 开始获取所有知识库的向量总数...');
|
||||
const localBases = Object.values(getLocalKnowledgeBases());
|
||||
const localBases = Object.values(getLocalKnowledgeBases())
|
||||
.filter(base => !independent || !base.chatId || base.chatId === chatId);
|
||||
const globalBases = Object.values(getGlobalKnowledgeBases());
|
||||
|
||||
const countPromises = [];
|
||||
|
||||
localBases.forEach(base => {
|
||||
const collectionId = `${charId}_${base.id}`;
|
||||
countPromises.push(countVectorsInCollection(collectionId));
|
||||
countPromises.push(countVectorsInCollection(getKbCollectionId(base, 'local')));
|
||||
});
|
||||
|
||||
globalBases.forEach(base => {
|
||||
const ownerId = base.owner || GLOBAL_SCOPE_ID;
|
||||
const collectionId = `${ownerId}_${base.id}`;
|
||||
countPromises.push(countVectorsInCollection(collectionId));
|
||||
countPromises.push(countVectorsInCollection(getKbCollectionId(base, 'global')));
|
||||
});
|
||||
|
||||
const legacyCollectionId = await getDynamicCollectionId();
|
||||
countPromises.push(countVectorsInCollection(legacyCollectionId));
|
||||
if (!independent) {
|
||||
const legacyCollectionId = await getDynamicCollectionId();
|
||||
countPromises.push(countVectorsInCollection(legacyCollectionId));
|
||||
}
|
||||
|
||||
const counts = await Promise.all(countPromises);
|
||||
const totalCount = counts.reduce((total, count) => total + count, 0);
|
||||
|
||||
console.log(`[翰林院-日志] 所有知识库统计完成,总向量数: ${totalCount}`);
|
||||
|
||||
console.log(`[翰林院-日志] 知识库统计完成,总向量数: ${totalCount}`);
|
||||
return totalCount;
|
||||
}
|
||||
}
|
||||
@@ -1183,20 +1311,23 @@ async function processCondensation(messages, logCallback = () => {}, range = nul
|
||||
kbName = `聊天记录: ${timestamp}`;
|
||||
}
|
||||
|
||||
const existingKbs = Object.values(getLocalKnowledgeBases());
|
||||
const foundKb = existingKbs.find(kb => kb.name === kbName);
|
||||
// 独立聊天记忆模式下凝识结果按聊天分桶,与 ingestTextToHanlinyuan 的语义一致
|
||||
const independentChatId = settings.retrieval.independentChatMemoryEnabled ? getChatId() : null;
|
||||
|
||||
const existingKbs = Object.values(getLocalKnowledgeBases());
|
||||
const foundKb = existingKbs.find(kb => kb.name === kbName && (kb.chatId ?? null) === independentChatId);
|
||||
|
||||
if (foundKb) {
|
||||
taskId = foundKb.id;
|
||||
logCallback(`[翰林院-核心] 检测到同名知识库 "${kbName}",将数据合并入库。`, 'info');
|
||||
} else {
|
||||
logCallback(`[翰林院-核心] 准备为任务 "${kbName}" 创建专属知识库...`, 'info');
|
||||
const newKb = addKnowledgeBase(kbName, 'chat_history');
|
||||
const newKb = addKnowledgeBase(kbName, 'chat_history', independentChatId);
|
||||
taskId = newKb.id;
|
||||
}
|
||||
|
||||
|
||||
const charId = getCharacterStableId();
|
||||
const collectionId = `${charId}_${taskId}`;
|
||||
const collectionId = independentChatId ? `${independentChatId}_${taskId}` : `${charId}_${taskId}`;
|
||||
logCallback(`[翰林院-核心] 凝识任务已锁定知识库: ${kbName} (集合ID: ${collectionId})`, 'success');
|
||||
|
||||
const allChunks = [];
|
||||
@@ -1405,7 +1536,13 @@ async function rerankResults(allResults, queryText, settings) {
|
||||
console.log('[翰林院-Rerank] 开始外部API重排序...');
|
||||
try {
|
||||
const documentsToRerank = allResults.map(res => res.text);
|
||||
const rerankedData = await executeRerank(queryText, documentsToRerank, settings.rerank);
|
||||
// 【修复】实际重排必须走 getRerankSettings() 解析连接(profile 优先、legacy 兜底),
|
||||
// 与「测试连接」路径一致。旧代码直接传 settings.rerank(legacy 嵌套对象),
|
||||
// 在用户用 API Profile 配 rerank 时其 apiKey/url/model 是空/stale 的——
|
||||
// 导致测试连接成功、实际请求却报「Rerank API Key 未提供」。
|
||||
// enabled / notify / hybrid_alpha 等行为开关仍读 legacy settings.rerank。
|
||||
const rerankConn = await getRerankSettings();
|
||||
const rerankedData = await executeRerank(queryText, documentsToRerank, rerankConn);
|
||||
const indexedResults = allResults.map((res, index) => ({ ...res, original_index: index }));
|
||||
|
||||
processedResults = indexedResults.map(result => {
|
||||
@@ -1459,18 +1596,102 @@ async function rerankResults(allResults, queryText, settings) {
|
||||
finalScoredResults.sort((a, b) => (b.final_score || 0) - (a.final_score || 0));
|
||||
console.log('[翰林院-Rerank] 元数据加权排序完成。');
|
||||
|
||||
let finalResults = finalScoredResults;
|
||||
// 先按相关度截断 top_n,再做时序排序——顺序反了会让"时序最早"而非"最相关"
|
||||
// 的块占据名额(超级排序把最旧楼层排最前,slice 会扔掉高相关的靠后结果)
|
||||
let finalResults = finalScoredResults.slice(0, settings.rerank.top_n);
|
||||
if (settings.rerank.superSortEnabled) {
|
||||
finalResults = superSort(finalScoredResults);
|
||||
finalResults = superSort(finalResults);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
results: finalResults.slice(0, settings.rerank.top_n),
|
||||
results: finalResults,
|
||||
reranked: rerankedSuccessfully
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从"第十二章"/"第3卷"/"4"等字符串中解析序数,用于注入前的时序排序。
|
||||
* 支持阿拉伯数字与常见中文数字(至万级);解析失败返回 MAX_SAFE_INTEGER(排最后)。
|
||||
*/
|
||||
function _parseOrdinal(value) {
|
||||
if (typeof value === 'number') return value;
|
||||
if (!value) return Number.MAX_SAFE_INTEGER;
|
||||
const str = String(value);
|
||||
const arabic = str.match(/\d+/);
|
||||
if (arabic) return parseInt(arabic[0], 10);
|
||||
|
||||
const cnDigit = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
|
||||
const m = str.match(/[零一二两三四五六七八九十百千万]+/);
|
||||
if (!m) return Number.MAX_SAFE_INTEGER;
|
||||
let total = 0, current = 0;
|
||||
for (const ch of m[0]) {
|
||||
if (cnDigit[ch] !== undefined) {
|
||||
current = cnDigit[ch];
|
||||
} else if (ch === '十') {
|
||||
total += (current || 1) * 10;
|
||||
current = 0;
|
||||
} else if (ch === '百') {
|
||||
total += (current || 1) * 100;
|
||||
current = 0;
|
||||
} else if (ch === '千') {
|
||||
total += (current || 1) * 1000;
|
||||
current = 0;
|
||||
} else if (ch === '万') {
|
||||
total = (total + current) * 10000;
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
return total + current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入前的组内时序重排 + 断层提示。
|
||||
*
|
||||
* rerank/相似度负责"选哪些块",本函数负责"按什么顺序呈现":
|
||||
* - chat_history 按楼层+部分升序;相邻块楼层跳跃时插入断层提示行,
|
||||
* 避免 LLM 把"不打不相识"和"关系亲密"两个远隔的片段读成连续剧情
|
||||
* - novel 按卷/章/节序数升序(中文数字章节号可解析)
|
||||
* - lorebook / manual 按来源聚合 + part 升序,碎块归位
|
||||
* 元数据缺失的块排在末尾、保持彼此原有顺序(sort 稳定性)。
|
||||
*/
|
||||
function _composeInjectionText(source, results) {
|
||||
const sorted = [...results];
|
||||
const ord = (v) => (Number.isFinite(v) ? v : Number.MAX_SAFE_INTEGER);
|
||||
|
||||
if (source === 'chat_history') {
|
||||
sorted.sort((a, b) =>
|
||||
ord(a.metadata?.floor) - ord(b.metadata?.floor)
|
||||
|| (a.metadata?.part ?? 0) - (b.metadata?.part ?? 0));
|
||||
|
||||
const parts = [];
|
||||
let prevFloor = null;
|
||||
for (const r of sorted) {
|
||||
const floor = r.metadata?.floor;
|
||||
if (prevFloor !== null && Number.isFinite(floor) && floor - prevFloor > 1) {
|
||||
parts.push(`〔提示:以下内容与上文相隔约 ${floor - prevFloor} 楼,期间的剧情未被检索到,两段内容并非连续发生〕`);
|
||||
}
|
||||
parts.push(r.text);
|
||||
if (Number.isFinite(floor)) prevFloor = floor;
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
if (source === 'novel') {
|
||||
sorted.sort((a, b) =>
|
||||
_parseOrdinal(a.metadata?.volume) - _parseOrdinal(b.metadata?.volume)
|
||||
|| _parseOrdinal(a.metadata?.chapter) - _parseOrdinal(b.metadata?.chapter)
|
||||
|| _parseOrdinal(a.metadata?.section) - _parseOrdinal(b.metadata?.section));
|
||||
return sorted.map(r => r.text).join('\n\n');
|
||||
}
|
||||
|
||||
// lorebook / manual:同源聚合 + part 升序
|
||||
sorted.sort((a, b) =>
|
||||
String(a.metadata?.sourceName ?? '').localeCompare(String(b.metadata?.sourceName ?? ''), 'zh')
|
||||
|| (a.metadata?.part ?? 0) - (b.metadata?.part ?? 0));
|
||||
return sorted.map(r => r.text).join('\n\n');
|
||||
}
|
||||
|
||||
async function rearrangeChat(chat, contextSize, abort, type) {
|
||||
const injectionKeys = {
|
||||
novel: 'HANLINYUAN_RAG_NOVEL',
|
||||
@@ -1685,7 +1906,8 @@ async function rearrangeChat(chat, contextSize, abort, type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const formattedText = results.map(r => r.text).join('\n\n');
|
||||
// 组内按时序重排 + 断层提示(rerank 决定选哪些块,时序决定呈现顺序)
|
||||
const formattedText = _composeInjectionText(source, results);
|
||||
const placeholder = `{{${source.replace('_history', '')}_text}}`;
|
||||
let injectionContent = injectionSettings.template.replace(placeholder, formattedText);
|
||||
|
||||
@@ -1732,6 +1954,13 @@ async function moveKnowledgeBase(taskId, fromScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 聊天级库(独立聊天记忆产物)专属于单个聊天,移到全局会让所有角色
|
||||
// 检索到某个特定聊天的记忆,语义矛盾,禁止
|
||||
if (kbData.chatId && toScope === 'global') {
|
||||
toastr.warning(`知识库【${kbData.name}】是聊天专属记忆,不能移动到全局。`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromScope === 'local' && toScope === 'global' && !kbData.owner) {
|
||||
console.log(`[翰林院-配置] 为旧版知识库 ${taskId} 补充所有者ID: ${charId}`);
|
||||
kbData.owner = charId;
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
export const defaultSettings = {
|
||||
retrieval: {
|
||||
enabled: false,
|
||||
apiEndpoint: 'openai',
|
||||
enabled: false,
|
||||
// 默认走 custom 与下面的 customApiUrl 配套;旧默认 'openai' 不在 UI select
|
||||
// 选项里,会在首次保存时被写成 ''(已有用户的 'openai' 值仍合法、不迁移)
|
||||
apiEndpoint: 'custom',
|
||||
customApiUrl: 'https://api.siliconflow.cn/v1',
|
||||
apiKey: '',
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
@@ -65,8 +67,9 @@ export const defaultSettings = {
|
||||
},
|
||||
rerank: {
|
||||
enabled: false,
|
||||
apiMode: 'custom',
|
||||
url: 'https://api.siliconflow.cn/v1',
|
||||
apiKey: '',
|
||||
apiKey: '',
|
||||
model: 'Pro/BAAI/bge-reranker-v2-m3',
|
||||
top_n: 5,
|
||||
hybrid_alpha: 0.7,
|
||||
|
||||
@@ -15,8 +15,8 @@ import { resolveHistoriographyRuleConfig } from "../utils/config/RuleProfileMana
|
||||
|
||||
import { getPresetPrompts, getMixedOrder } from '../PresetSettings/index.js';
|
||||
import { callAI, generateRandomSeed } from './api.js';
|
||||
import { callJqyhAI } from './api/JqyhApi.js';
|
||||
import { callConcurrentAI } from './api/ConcurrentApi.js';
|
||||
import { applyToTemplates } from './memory-blocks/index.js';
|
||||
|
||||
export async function processOptimization(latestMessage, previousMessages) {
|
||||
if (window.AMILY2_SYSTEM_PARALYZED === true) {
|
||||
@@ -277,22 +277,18 @@ export async function processPlotOptimization(currentUserMessage, contextMessage
|
||||
const userName = context.name1 || '用户';
|
||||
const charName = context.name2 || '角色';
|
||||
|
||||
const replacements = {
|
||||
'sulv1': settings.plotOpt_rateMain ?? 1.0,
|
||||
'sulv2': settings.plotOpt_ratePersonal ?? 1.0,
|
||||
'sulv3': settings.plotOpt_rateErotic ?? 1.0,
|
||||
'sulv4': settings.plotOpt_rateCuckold ?? 1.0,
|
||||
};
|
||||
|
||||
let mainPrompt = settings.plotOpt_mainPrompt || '';
|
||||
let systemPrompt = settings.plotOpt_systemPrompt || '';
|
||||
|
||||
for (const key in replacements) {
|
||||
const value = replacements[key];
|
||||
const regex = new RegExp(key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g');
|
||||
mainPrompt = mainPrompt.replace(regex, value);
|
||||
systemPrompt = systemPrompt.replace(regex, value);
|
||||
}
|
||||
// 【Phase 1 重构】sulv1-4 占位符替换迁入记忆块工作流。
|
||||
// 块定义见 core/memory-blocks/builtin-blocks.js,行为与旧硬编码字节级一致:
|
||||
// - 同一 context 内 Promise.all 并发执行 generator
|
||||
// - 模板批量替换,块只跑一次复用结果
|
||||
// - 后续新增占位符(含战斗系统)走 register({...}),此处零改动
|
||||
const { mainPrompt, systemPrompt } = await applyToTemplates(
|
||||
{
|
||||
mainPrompt: settings.plotOpt_mainPrompt || '',
|
||||
systemPrompt: settings.plotOpt_systemPrompt || '',
|
||||
},
|
||||
{ context: 'plotOptimization', settings },
|
||||
);
|
||||
|
||||
onProgress(getRandomText(['正在进行情感光谱分析...', '正在解析情绪波动频率...', '正在捕捉微表情信号...']), false);
|
||||
onProgress(getRandomText(['正在进行情感光谱分析...', '正在解析情绪波动频率...', '正在捕捉微表情信号...']), true);
|
||||
@@ -433,9 +429,11 @@ export async function processPlotOptimization(currentUserMessage, contextMessage
|
||||
const useTagExtraction = historiographyRuleConfig.tagExtractionEnabled ?? false;
|
||||
const tagsToExtract = useTagExtraction ? (historiographyRuleConfig.tags || '').split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
const exclusionRules = historiographyRuleConfig.exclusionRules || [];
|
||||
const excludeUserMessages = historiographyRuleConfig.excludeUserMessages ?? false;
|
||||
|
||||
history = historyMessages
|
||||
.map(msg => {
|
||||
if (excludeUserMessages && msg.is_user) return null;
|
||||
if (msg.mes && msg.mes.trim()) {
|
||||
let content = msg.mes.trim();
|
||||
|
||||
@@ -478,7 +476,7 @@ export async function processPlotOptimization(currentUserMessage, contextMessage
|
||||
onProgress(getRandomText(['正在检索辅助记忆 (LLM-B)...', '正在扫描平行世界线 (LLM-B)...']), false);
|
||||
|
||||
onProgress(getRandomText(['正在与核心意识同步 (LLM-A)...', '正在等待灵魂共鸣 (LLM-A)...']), false);
|
||||
const promise1 = (settings.jqyhEnabled ? callJqyhAI(mainMessages) : callAI(mainMessages, { slot: 'plotOpt' })).then(res => {
|
||||
const promise1 = callAI(mainMessages, { slot: 'plotOpt' }).then(res => {
|
||||
onProgress(getRandomText(['正在与核心意识同步 (LLM-A)...', '正在等待灵魂共鸣 (LLM-A)...']), true);
|
||||
return res;
|
||||
});
|
||||
@@ -552,7 +550,7 @@ export async function processPlotOptimization(currentUserMessage, contextMessage
|
||||
attempt++;
|
||||
console.log(`[${extensionName}] 剧情优化第 ${attempt} 次尝试...`);
|
||||
|
||||
const rawResponse = settings.jqyhEnabled ? await callJqyhAI(mainMessages) : await callAI(mainMessages, { slot: 'plotOpt' });
|
||||
const rawResponse = await callAI(mainMessages, { slot: 'plotOpt' });
|
||||
|
||||
if (cancellationState.isCancelled) {
|
||||
console.log(`[${extensionName}] 优化任务在API调用后被中止。`);
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
* 公开接口(query('SuperMemory')):
|
||||
* initialize() — 初始化超级记忆系统
|
||||
* forceSyncAll() — 全量同步到世界书
|
||||
* tryRestoreStateFromMetadata() — 从聊天元数据恢复状态
|
||||
* awaitSync() — 等待当前同步队列完成(Pipeline Stage 4 使用)
|
||||
* purge() — 清空记忆世界书
|
||||
*
|
||||
* 注:tryRestoreStateFromMetadata 已删除——msg.metadata 非 ST 持久化字段,
|
||||
* 该恢复路径从未真正工作过;表格状态的持久化与恢复由表格系统
|
||||
* (loadTables / msg.extra.amily2_tables_data)唯一负责。
|
||||
*/
|
||||
|
||||
import {
|
||||
initializeSuperMemory,
|
||||
tryRestoreStateFromMetadata,
|
||||
forceSyncAll,
|
||||
awaitSync,
|
||||
purgeSuperMemory,
|
||||
@@ -34,12 +36,11 @@ setTimeout(() => {
|
||||
return;
|
||||
}
|
||||
_ctx.expose({
|
||||
initialize: () => initializeSuperMemory(),
|
||||
forceSyncAll: () => forceSyncAll(),
|
||||
tryRestoreStateFromMetadata: () => tryRestoreStateFromMetadata(),
|
||||
awaitSync: () => awaitSync(),
|
||||
purge: () => purgeSuperMemory(),
|
||||
pushUpdate: (payload) => pushUpdate(payload),
|
||||
initialize: () => initializeSuperMemory(),
|
||||
forceSyncAll: () => forceSyncAll(),
|
||||
awaitSync: () => awaitSync(),
|
||||
purge: () => purgeSuperMemory(),
|
||||
pushUpdate: (payload) => pushUpdate(payload),
|
||||
});
|
||||
_ctx.log('SuperMemoryService', 'info', 'SuperMemory 服务已注册到 Bus。');
|
||||
} catch (e) {
|
||||
@@ -50,7 +51,6 @@ setTimeout(() => {
|
||||
// ── 向后兼容具名导出 ──────────────────────────────────────────────────────
|
||||
export {
|
||||
initializeSuperMemory,
|
||||
tryRestoreStateFromMetadata,
|
||||
forceSyncAll,
|
||||
awaitSync,
|
||||
purgeSuperMemory,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { extensionName } from "../../utils/settings.js";
|
||||
import { extension_settings } from "/scripts/extensions.js";
|
||||
import { saveSettingsDebounced } from "/script.js";
|
||||
import { initializeSuperMemory, purgeSuperMemory } from "./manager.js";
|
||||
import { initializeSuperMemory, purgeSuperMemory, forceSyncAll } from "./manager.js";
|
||||
import { defaultSettings as ragDefaultSettings } from "../rag-settings.js";
|
||||
import { getMemoryState } from "../table-system/manager.js";
|
||||
|
||||
@@ -9,10 +9,11 @@ const RAG_MODULE_NAME = 'hanlinyuan-rag-core';
|
||||
|
||||
function getRagSettings() {
|
||||
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
|
||||
if (!extension_settings[extensionName][RAG_MODULE_NAME]) {
|
||||
extension_settings[extensionName][RAG_MODULE_NAME] = structuredClone(ragDefaultSettings);
|
||||
const root = extension_settings[extensionName];
|
||||
if (!root[RAG_MODULE_NAME]) {
|
||||
root[RAG_MODULE_NAME] = structuredClone(ragDefaultSettings);
|
||||
}
|
||||
return extension_settings[extensionName][RAG_MODULE_NAME];
|
||||
return root[RAG_MODULE_NAME];
|
||||
}
|
||||
|
||||
export function bindSuperMemoryEvents() {
|
||||
@@ -41,6 +42,12 @@ export function bindSuperMemoryEvents() {
|
||||
if (id === 'sm-system-enabled') {
|
||||
extension_settings[extensionName]['super_memory_enabled'] = this.checked;
|
||||
saveSettingsDebounced();
|
||||
// 【修复】启动时若开关为关,initializeSuperMemory 会早退且不注册监听器;
|
||||
// 旧实现勾选后只写设置不初始化,导致开关"打开了但没反应"直到刷新页面。
|
||||
// initializeSuperMemory 幂等(isInitialized 防重入),此处直接补初始化。
|
||||
if (this.checked) {
|
||||
initializeSuperMemory();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id === 'sm-bridge-enabled') {
|
||||
@@ -109,7 +116,7 @@ export function bindSuperMemoryEvents() {
|
||||
}
|
||||
|
||||
const tableName = $(this).data('table');
|
||||
const type = $(this).data('type'); // 'sync' or 'constant'
|
||||
const type = $(this).data('type'); // 'sync' | 'constant' | 'pinFirstRow'
|
||||
const checked = this.checked;
|
||||
|
||||
if (!extension_settings[extensionName].superMemory_tableSettings[tableName]) {
|
||||
@@ -118,6 +125,8 @@ export function bindSuperMemoryEvents() {
|
||||
|
||||
extension_settings[extensionName].superMemory_tableSettings[tableName][type] = checked;
|
||||
saveSettingsDebounced();
|
||||
// 立即应用:首行常驻切换需要把该行详情条目在 蓝灯/绿灯 之间重写
|
||||
forceSyncAll();
|
||||
console.log(`[Amily2-SuperMemory] Table setting updated: ${tableName}.${type} = ${checked}`);
|
||||
});
|
||||
|
||||
@@ -143,9 +152,10 @@ function renderTableSettingsList() {
|
||||
const tableName = table.name;
|
||||
const tableConfig = settings[tableName] || {};
|
||||
|
||||
// Default values: Sync=True, Constant=True
|
||||
const isSyncEnabled = tableConfig.sync !== false;
|
||||
// Default values: Sync=True, Constant=True; PinFirstRow=False
|
||||
const isSyncEnabled = tableConfig.sync !== false;
|
||||
const isConstant = tableConfig.constant !== false;
|
||||
const isPinFirstRow = tableConfig.pinFirstRow === true;
|
||||
|
||||
html += `
|
||||
<div class="sm-control-block" style="border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 10px; margin-bottom: 10px;">
|
||||
@@ -166,6 +176,15 @@ function renderTableSettingsList() {
|
||||
<span style="font-size: 0.9em; color: #ccc;">索引绿灯(常驻)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-start; margin-top: 5px;">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<label class="sm-toggle-switch" style="transform: scale(0.8); margin-right: 5px;">
|
||||
<input type="checkbox" class="sm-table-setting-check" data-table="${tableName}" data-type="pinFirstRow" ${isPinFirstRow ? 'checked' : ''}>
|
||||
<span class="sm-slider"></span>
|
||||
</label>
|
||||
<span style="font-size: 0.9em; color: #ccc;" title="第一行通常是总调/全局定义行,开启后升为常驻注入,不再依赖关键词触发">首行常驻</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
@@ -35,8 +35,19 @@ async function _doEnsureBook(bookName) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncToLorebook(tableName, data, indexText, role, headers, rowStatuses, depth = 100, isIndexConstant = true) {
|
||||
console.log(`[Amily2-Bridge] 开始同步表格: ${tableName} (Depth: ${depth}, IndexConstant: ${isIndexConstant})`);
|
||||
/**
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.depth=100] 详情条目的注入深度
|
||||
* @param {boolean} [opts.isIndexConstant=true] 索引条目是否常驻(蓝灯)
|
||||
* @param {boolean} [opts.pinFirstRow=false] 首行详情条目升为常驻(总调/全局定义行)
|
||||
*/
|
||||
export async function syncToLorebook(tableName, data, indexText, role, headers, rowStatuses, opts = {}) {
|
||||
const {
|
||||
depth = 100,
|
||||
isIndexConstant = true,
|
||||
pinFirstRow = false,
|
||||
} = opts;
|
||||
console.log(`[Amily2-Bridge] 开始同步表格: ${tableName} (Depth: ${depth}, IndexConstant: ${isIndexConstant}, PinFirstRow: ${pinFirstRow})`);
|
||||
return withLoreLock(`syncToLorebook(${tableName})`, async () => {
|
||||
await _doEnsureBook(getMemoryBookName());
|
||||
|
||||
@@ -133,34 +144,19 @@ export async function syncToLorebook(tableName, data, indexText, role, headers,
|
||||
processEntry(indexComment, indexKey, indexContent, indexType, true, true, 0, 0);
|
||||
}
|
||||
|
||||
data.forEach((row, index) => {
|
||||
if (!row || row.length === 0) return;
|
||||
|
||||
const rawVal = row[0];
|
||||
if (rawVal === undefined || rawVal === null) return;
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
const optimizationEnabled = settings.context_optimization_enabled !== false;
|
||||
|
||||
const primaryVal = String(rawVal).trim();
|
||||
if (primaryVal === '') return;
|
||||
|
||||
const isPendingDeletion = rowStatuses && rowStatuses[index] === 'pending-deletion';
|
||||
const isEnabled = !isPendingDeletion;
|
||||
|
||||
const triggerKeys = [primaryVal];
|
||||
const entryComment = `[Amily2] Detail: ${tableName} - ${primaryVal}`;
|
||||
|
||||
const renderRowContent = (row) => {
|
||||
let finalHeaders = headers;
|
||||
if (!finalHeaders || finalHeaders.length < row.length) {
|
||||
finalHeaders = [];
|
||||
for(let i=0; i<row.length; i++) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
finalHeaders.push((headers && headers[i]) ? headers[i] : `Col_${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
const optimizationEnabled = settings.context_optimization_enabled !== false;
|
||||
|
||||
let entryContent;
|
||||
|
||||
if (optimizationEnabled) {
|
||||
const primaryVal = row[0] || 'Unknown';
|
||||
entryContent = `【${tableName}档案: ${primaryVal}】\n`;
|
||||
@@ -178,13 +174,34 @@ export async function syncToLorebook(tableName, data, indexText, role, headers,
|
||||
}
|
||||
entryContent = textContent.trim();
|
||||
}
|
||||
return entryContent.trim();
|
||||
};
|
||||
|
||||
processEntry(entryComment, triggerKeys, entryContent.trim(), 'selective', isEnabled);
|
||||
data.forEach((row, index) => {
|
||||
if (!row || row.length === 0) return;
|
||||
|
||||
const rawVal = row[0];
|
||||
if (rawVal === undefined || rawVal === null) return;
|
||||
|
||||
const primaryVal = String(rawVal).trim();
|
||||
if (primaryVal === '') return;
|
||||
|
||||
const isPendingDeletion = rowStatuses && rowStatuses[index] === 'pending-deletion';
|
||||
const isEnabled = !isPendingDeletion;
|
||||
|
||||
const triggerKeys = [primaryVal];
|
||||
const entryComment = `[Amily2] Detail: ${tableName} - ${primaryVal}`;
|
||||
|
||||
// 首行常驻:第一行通常是总调/全局定义(基调、主线目标等),
|
||||
// 绿灯模式下没人提到其主键就永远不注入;开启后升为蓝灯常驻。
|
||||
const entryType = (pinFirstRow && index === 0) ? 'constant' : 'selective';
|
||||
|
||||
processEntry(entryComment, triggerKeys, renderRowContent(row), entryType, isEnabled);
|
||||
});
|
||||
|
||||
const entriesToDelete = [];
|
||||
const tablePrefix = `[Amily2] Detail: ${tableName} -`;
|
||||
|
||||
|
||||
const activeKeys = new Set();
|
||||
for(const row of data) {
|
||||
if(row && row.length > 0) {
|
||||
|
||||
@@ -3,19 +3,17 @@ import { extensionName } from "../../utils/settings.js";
|
||||
import { amilyHelper } from "../tavern-helper/main.js";
|
||||
import { generateIndex } from "./smart-indexer.js";
|
||||
import { syncToLorebook, ensureMemoryBook, updateTransientHint, getMemoryBookName } from "./lorebook-bridge.js";
|
||||
import { getMemoryState, loadMemoryState, saveMemoryState } from "../table-system/manager.js";
|
||||
import { TABLE_UPDATED_EVENT } from "../table-system/events-schema.js";
|
||||
import { getMemoryState } from "../table-system/manager.js";
|
||||
import { TABLE_UPDATED_EVENT, inferTableRole } from "../table-system/events-schema.js";
|
||||
import { eventSource, event_types } from "/script.js";
|
||||
import { handleArchiveUpdate } from "../archive-manager.js";
|
||||
|
||||
/* ── [AMILY2-MODIFIED] ── pipeline integration: awaitSync() export ── */
|
||||
let isInitialized = false;
|
||||
let updateQueue = [];
|
||||
let isProcessing = false;
|
||||
let lastChatId = null;
|
||||
let _syncPromise = null; // tracks the running processQueue() promise for pipeline awaiting
|
||||
|
||||
const METADATA_KEY = 'Amily2_Memory_Data';
|
||||
|
||||
/**
|
||||
* [AMILY2-MODIFIED] Pipeline integration:
|
||||
* Allows MessagePipeline Stage 4 to await the super-memory sync triggered
|
||||
@@ -52,24 +50,22 @@ export async function initializeSuperMemory() {
|
||||
}
|
||||
|
||||
document.addEventListener(TABLE_UPDATED_EVENT, handleTableUpdate);
|
||||
|
||||
|
||||
// 【修复】CHAT_CHANGED 时不再主动 forceSyncAll:
|
||||
// 表格系统在 index.js 的 CHAT_CHANGED 里延迟 100ms 才 loadTables(),
|
||||
// 此处立即同步会把【旧聊天】的表格内容写进【新角色】的记忆世界书(竞态污染;
|
||||
// 两边表名不同时旧表条目无 GC 兜底,会永久残留)。
|
||||
// 无需自行补同步:loadTables() 三个分支结尾都会 dispatchAllTablesUpdate(),
|
||||
// 新状态会经 pushUpdate 自动入队。这里只负责确保新角色的记忆世界书存在。
|
||||
eventSource.on(event_types.CHAT_CHANGED, async () => {
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
if (settings.super_memory_enabled === false) return;
|
||||
|
||||
console.log('[Amily2-SuperMemory] 检测到聊天切换,正在刷新记忆状态...');
|
||||
await checkWorldBookStatus();
|
||||
|
||||
await tryRestoreStateFromMetadata();
|
||||
|
||||
await forceSyncAll();
|
||||
});
|
||||
|
||||
|
||||
await checkWorldBookStatus();
|
||||
|
||||
await tryRestoreStateFromMetadata();
|
||||
|
||||
await forceSyncAll();
|
||||
|
||||
await forceSyncAll();
|
||||
|
||||
isInitialized = true;
|
||||
console.log('[Amily2-SuperMemory] 核心管理器初始化完成。');
|
||||
@@ -109,11 +105,22 @@ export function pushUpdate(payload) {
|
||||
console.log(`[Amily2-SuperMemory] 收到表格更新 (Bus): ${tableName} (Role: ${role})`);
|
||||
|
||||
updateQueue.push({ tableName, data, role, headers, rowStatuses });
|
||||
_syncPromise = processQueue();
|
||||
// 【修复】队列正忙时不可覆盖 _syncPromise:旧实现每次都赋值 processQueue(),
|
||||
// 而 processQueue 在 isProcessing 时立即返回(已 resolve 的空 Promise),
|
||||
// 导致 Pipeline Stage 4 的 awaitSync() 穿透、在同步未完成时放行后续阶段。
|
||||
// 正在跑的 drain 循环会自然吃掉刚入队的项,无需新起 Promise。
|
||||
if (!isProcessing) {
|
||||
_syncPromise = processQueue();
|
||||
}
|
||||
|
||||
// Bus 路径下 document event 不再分发,需直接通知归档管理器
|
||||
handleArchiveUpdate(payload);
|
||||
}
|
||||
|
||||
/** CustomEvent 降级路径(Bus 未就绪时的兜底监听器) */
|
||||
function handleTableUpdate(event) {
|
||||
// Bus 已就绪时 pushUpdate 已由 dispatchTableUpdate 直调,跳过避免重复处理
|
||||
if (window.Amily2Bus?.query('SuperMemory')?.pushUpdate) return;
|
||||
pushUpdate(event.detail);
|
||||
}
|
||||
|
||||
@@ -140,15 +147,18 @@ async function processQueue() {
|
||||
await processUpdateTask(task);
|
||||
}
|
||||
}
|
||||
|
||||
await saveStateToMetadata();
|
||||
|
||||
|
||||
// 【修复】移除 saveStateToMetadata():msg.metadata 不是 ST 的持久化字段
|
||||
// (消息体标准位是 msg.extra),写入后会蒸发,恢复路径永远找不到东西——
|
||||
// 整条"元数据状态保存/恢复"链路是死代码。表格状态的唯一持久化信源是
|
||||
// 表格系统自己的 msg.extra.amily2_tables_data(infra/persistence.js)。
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Amily2-SuperMemory] 处理更新队列失败:', error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
if (updateQueue.length > 0) {
|
||||
processQueue();
|
||||
_syncPromise = processQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,12 +178,16 @@ async function processUpdateTask(task) {
|
||||
|
||||
const activeData = data.filter((_, i) => !rowStatuses || rowStatuses[i] !== 'pending-deletion');
|
||||
const indexText = generateIndex(activeData, headers, role, tableName);
|
||||
|
||||
|
||||
const allTables = getMemoryState();
|
||||
const tableIndex = allTables.findIndex(t => t.name === tableName);
|
||||
const depth = 8001 + (tableIndex >= 0 ? tableIndex : 99);
|
||||
|
||||
await syncToLorebook(tableName, data, indexText, role, headers, rowStatuses, depth, isIndexConstant);
|
||||
await syncToLorebook(tableName, data, indexText, role, headers, rowStatuses, {
|
||||
depth,
|
||||
isIndexConstant,
|
||||
pinFirstRow: tableSettings.pinFirstRow === true,
|
||||
});
|
||||
|
||||
if (hint) {
|
||||
console.log(`[Amily2-SuperMemory] 应用主动记忆提示: ${hint}`);
|
||||
@@ -185,54 +199,67 @@ async function processUpdateTask(task) {
|
||||
updateDashboardCounters();
|
||||
}
|
||||
|
||||
async function saveStateToMetadata() {
|
||||
const context = getContext();
|
||||
if (!context.chat || context.chat.length === 0) return;
|
||||
|
||||
const lastMsgIndex = context.chat.length - 1;
|
||||
const lastMsg = context.chat[lastMsgIndex];
|
||||
|
||||
const currentState = getMemoryState();
|
||||
|
||||
if (!lastMsg.metadata) lastMsg.metadata = {};
|
||||
|
||||
lastMsg.metadata[METADATA_KEY] = JSON.parse(JSON.stringify(currentState));
|
||||
|
||||
if (context.saveChat) {
|
||||
await context.saveChat();
|
||||
}
|
||||
|
||||
console.log(`[Amily2-SuperMemory] 状态已保存至消息 #${lastMsgIndex}`);
|
||||
}
|
||||
|
||||
export async function tryRestoreStateFromMetadata() {
|
||||
const context = getContext();
|
||||
if (!context.chat || context.chat.length === 0) return;
|
||||
|
||||
let foundState = null;
|
||||
let foundIndex = -1;
|
||||
|
||||
for (let i = context.chat.length - 1; i >= 0; i--) {
|
||||
const msg = context.chat[i];
|
||||
if (msg.metadata && msg.metadata[METADATA_KEY]) {
|
||||
foundState = msg.metadata[METADATA_KEY];
|
||||
foundIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundState) {
|
||||
console.log(`[Amily2-SuperMemory] 发现历史状态 (Msg #${foundIndex}),正在恢复...`);
|
||||
if (typeof loadMemoryState === 'function') {
|
||||
loadMemoryState(foundState);
|
||||
await forceSyncAll();
|
||||
} else {
|
||||
console.warn('[Amily2-SuperMemory] table-system 缺少 loadMemoryState 方法,无法恢复状态。');
|
||||
}
|
||||
} else {
|
||||
console.log('[Amily2-SuperMemory] 未在聊天记录中发现历史状态,使用默认/当前状态。');
|
||||
}
|
||||
}
|
||||
// 【已停用 2026-06-12】saveStateToMetadata / tryRestoreStateFromMetadata:
|
||||
// msg.metadata 不是 ST 持久化字段(同 secondary-filler 修过的坑),写了会蒸发、
|
||||
// 读永远为空——整条链路判定为从未真正工作过。若它"工作"了反而更糟:恢复出的
|
||||
// 过期副本会覆盖表格系统从 msg.extra.amily2_tables_data 恢复的正确状态(双信源打架)。
|
||||
// 表格状态的持久化与恢复完全交由表格系统(loadTables / saveStateToMessage)。
|
||||
//
|
||||
// 原实现注释保留(原作者代码,不排除存在未知副作用依赖;确认稳定几个版本后再清):
|
||||
//
|
||||
// const METADATA_KEY = 'Amily2_Memory_Data';
|
||||
//
|
||||
// async function saveStateToMetadata() {
|
||||
// const context = getContext();
|
||||
// if (!context.chat || context.chat.length === 0) return;
|
||||
//
|
||||
// const lastMsgIndex = context.chat.length - 1;
|
||||
// const lastMsg = context.chat[lastMsgIndex];
|
||||
//
|
||||
// const currentState = getMemoryState();
|
||||
//
|
||||
// if (!lastMsg.metadata) lastMsg.metadata = {};
|
||||
//
|
||||
// lastMsg.metadata[METADATA_KEY] = JSON.parse(JSON.stringify(currentState));
|
||||
//
|
||||
// if (context.saveChat) {
|
||||
// await context.saveChat();
|
||||
// }
|
||||
//
|
||||
// console.log(`[Amily2-SuperMemory] 状态已保存至消息 #${lastMsgIndex}`);
|
||||
// }
|
||||
// (原调用点:processQueue 的 while 循环结束后 `await saveStateToMetadata();`)
|
||||
//
|
||||
// export async function tryRestoreStateFromMetadata() {
|
||||
// const context = getContext();
|
||||
// if (!context.chat || context.chat.length === 0) return;
|
||||
//
|
||||
// let foundState = null;
|
||||
// let foundIndex = -1;
|
||||
//
|
||||
// for (let i = context.chat.length - 1; i >= 0; i--) {
|
||||
// const msg = context.chat[i];
|
||||
// if (msg.metadata && msg.metadata[METADATA_KEY]) {
|
||||
// foundState = msg.metadata[METADATA_KEY];
|
||||
// foundIndex = i;
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (foundState) {
|
||||
// console.log(`[Amily2-SuperMemory] 发现历史状态 (Msg #${foundIndex}),正在恢复...`);
|
||||
// if (typeof loadMemoryState === 'function') { // 需从 table-system/manager.js 导入 loadMemoryState
|
||||
// loadMemoryState(foundState);
|
||||
// await forceSyncAll();
|
||||
// } else {
|
||||
// console.warn('[Amily2-SuperMemory] table-system 缺少 loadMemoryState 方法,无法恢复状态。');
|
||||
// }
|
||||
// } else {
|
||||
// console.log('[Amily2-SuperMemory] 未在聊天记录中发现历史状态,使用默认/当前状态。');
|
||||
// }
|
||||
// }
|
||||
// (原调用点:initializeSuperMemory 与 CHAT_CHANGED 监听器内,各一次,后接 forceSyncAll;
|
||||
// Bus 暴露:SuperMemoryService 的 tryRestoreStateFromMetadata,已一并停用)
|
||||
|
||||
function updateDashboardCounters() {
|
||||
const tables = getMemoryState();
|
||||
@@ -265,20 +292,19 @@ export async function forceSyncAll() {
|
||||
}
|
||||
|
||||
for (const table of tables) {
|
||||
let role = 'database';
|
||||
if (table.name.includes('时空') || table.name.includes('世界钟')) role = 'anchor';
|
||||
if (table.name.includes('日志') || table.name.includes('Log')) role = 'log';
|
||||
|
||||
updateQueue.push({
|
||||
tableName: table.name,
|
||||
data: table.rows,
|
||||
headers: table.headers,
|
||||
rowStatuses: table.rowStatuses || [],
|
||||
role: role
|
||||
headers: table.headers,
|
||||
rowStatuses: table.rowStatuses || [],
|
||||
role: inferTableRole(table.name), // 复用 events-schema 的统一推断,避免两处逻辑漂移
|
||||
});
|
||||
}
|
||||
|
||||
await processQueue();
|
||||
|
||||
if (!isProcessing) {
|
||||
_syncPromise = processQueue();
|
||||
}
|
||||
await _syncPromise;
|
||||
console.log('[Amily2-SuperMemory] 全量同步完成。');
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -6,13 +6,29 @@ import { updateTableFromText } from './manager.js';
|
||||
import { extensionName } from '../../utils/settings.js';
|
||||
import { renderTables } from '../../ui/table-bindings.js';
|
||||
import { getPresetPrompts, getMixedOrder } from '../../PresetSettings/index.js';
|
||||
import { callAI, generateRandomSeed } from '../api.js';
|
||||
import { callAI, callAIForTools, generateRandomSeed } from '../api.js';
|
||||
import { callNccsAI } from '../api/NccsApi.js';
|
||||
import { TABLE_FILL_TOOL, parseToolCallArgs } from './formatters/tool-call.js';
|
||||
import { updateTableFromOps } from './manager.js';
|
||||
import { extractBlocksByTags, applyExclusionRules } from '../utils/rag-tag-extractor.js';
|
||||
import { resolveTableRuleConfig } from '../../utils/config/RuleProfileManager.js';
|
||||
import { showTableFillReviewModal } from '../../ui/page-window.js';
|
||||
|
||||
import { getBatchFillerRuleTemplate, getBatchFillerFlowTemplate, convertTablesToCsvString } from './manager.js';
|
||||
|
||||
const CONTINUE_PROMPT = '上一条回复不完整或缺少 <Amily2Edit> 指令块。请直接从中断处继续生成剩余内容,不要重复已输出的文本,也不要添加任何解释或寒暄,确保最终输出中包含完整的 <Amily2Edit>...</Amily2Edit> 指令块。';
|
||||
|
||||
async function requestContinuation(baseMessages, partialResponse) {
|
||||
const continueMessages = [
|
||||
...baseMessages,
|
||||
{ role: 'assistant', content: partialResponse || '' },
|
||||
{ role: 'user', content: CONTINUE_PROMPT },
|
||||
];
|
||||
const continued = await callTableModel(continueMessages);
|
||||
if (!continued) return null;
|
||||
return `${partialResponse || ''}${continued}`;
|
||||
}
|
||||
|
||||
let isFilling = false;
|
||||
let manualStopRequested = false;
|
||||
let currentBatch = 0;
|
||||
@@ -268,24 +284,90 @@ async function runBatchAttempt(batchNum, attemptNum) {
|
||||
console.dir(messages);
|
||||
console.groupEnd();
|
||||
|
||||
const resultText = await callTableModel(messages);
|
||||
console.log(`[Amily2 立即远征] 批次 ${batchNum}/${totalBatches} - 收到 API 原始回复:`, resultText);
|
||||
if (!resultText) {
|
||||
throw new Error('API返回内容为空。');
|
||||
const batchSettings = extension_settings[extensionName] || {};
|
||||
if (batchSettings.tableFillFunctionCall) {
|
||||
// Function Call 路径:结构化输出,无需检查 <Amily2Edit>
|
||||
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling' });
|
||||
if (!argsString) throw new Error('Function Call 返回为空。');
|
||||
const ops = parseToolCallArgs(argsString);
|
||||
if (ops.length === 0) {
|
||||
let parseHint = '';
|
||||
try {
|
||||
const rawParsed = JSON.parse(argsString);
|
||||
const rawOpsLen = rawParsed?.operations?.length ?? 0;
|
||||
if (rawOpsLen > 0) {
|
||||
parseHint = `(响应含 ${rawOpsLen} 条操作,但全部未通过格式校验)`;
|
||||
}
|
||||
} catch {
|
||||
parseHint = '(响应 JSON 解析失败)';
|
||||
}
|
||||
log(`批次 ${batchNum} FC 操作列表为空${parseHint},原始响应:\n${argsString}`, 'warn');
|
||||
toastr.info('AI 判断此批次无需修改。', `批次 ${batchNum}`);
|
||||
} else {
|
||||
await updateTableFromOps(ops, { immediateDelete: true });
|
||||
renderTables();
|
||||
log(`批次 ${batchNum} Function Call 处理成功(${ops.length} 条操作)。`, 'success');
|
||||
}
|
||||
} else {
|
||||
// Legacy 文本路径
|
||||
const resultText = await callTableModel(messages);
|
||||
console.log(`[Amily2 立即远征] 批次 ${batchNum}/${totalBatches} - 收到 API 原始回复:`, resultText);
|
||||
if (!resultText) throw new Error('API返回内容为空。');
|
||||
|
||||
if (!resultText.includes('<Amily2Edit>')) {
|
||||
log(`批次 ${batchNum} 的响应未包含 <Amily2Edit> 指令块,弹出检查窗口等待用户处理。`, 'warn');
|
||||
updateButtonState('paused');
|
||||
showTableFillReviewModal(resultText, {
|
||||
title: `填表响应检查 - 批次 ${batchNum}/${totalBatches}`,
|
||||
subtitle: `批次 ${batchNum}/${totalBatches}(楼层 ${startFloor}-${endFloor})的 AI 响应未包含有效的 <Amily2Edit> 指令块。请检查原始响应并选择处理方式。`,
|
||||
onContinue: async (currentText) => {
|
||||
const merged = await requestContinuation(messages, currentText);
|
||||
if (!merged) { toastr.error('补全请求失败或返回为空。', '继续补全'); return null; }
|
||||
if (!merged.includes('<Amily2Edit>')) {
|
||||
toastr.warning('补全后仍未包含 <Amily2Edit> 指令块,可继续补全、手动应用或重新填表。', '继续补全');
|
||||
} else {
|
||||
toastr.success('已获得包含指令块的补全内容,可点击”手动应用”写入。', '继续补全');
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
onApply: (editedText) => {
|
||||
if (!editedText || !editedText.includes('<Amily2Edit>')) {
|
||||
toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用');
|
||||
}
|
||||
try {
|
||||
updateTableFromText(editedText, { immediateDelete: true });
|
||||
renderTables();
|
||||
log(`批次 ${batchNum} 已由用户手动处理完成。`, 'success');
|
||||
} catch (err) {
|
||||
log(`批次 ${batchNum} 手动应用失败: ${err.message}`, 'error');
|
||||
toastr.error(`手动应用失败: ${err.message}`, '写入异常');
|
||||
currentBatch = batchNum - 1;
|
||||
updateButtonState('error');
|
||||
return;
|
||||
}
|
||||
currentBatch = batchNum;
|
||||
setTimeout(processNextBatch, 500);
|
||||
},
|
||||
onRetry: () => {
|
||||
log(`用户选择重新填表,批次 ${batchNum} 将重新执行。`, 'warn');
|
||||
setTimeout(() => runBatchAttempt(batchNum, 0), 300);
|
||||
},
|
||||
onCancel: () => {
|
||||
log(`用户取消了批次 ${batchNum} 的处理,任务已暂停。`, 'warn');
|
||||
currentBatch = batchNum - 1;
|
||||
updateButtonState('error');
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateTableFromText(resultText, { immediateDelete: true });
|
||||
renderTables();
|
||||
log(`批次 ${batchNum} 处理成功。`, 'success');
|
||||
}
|
||||
|
||||
// 【修复】检查 AI 是否返回了有效的指令块,防止 AI 偷懒或格式错误被误判为成功
|
||||
if (!resultText.includes('<Amily2Edit>')) {
|
||||
throw new Error('AI未返回有效的 <Amily2Edit> 指令块,可能格式错误或未产生实质性变更。');
|
||||
}
|
||||
|
||||
// 【V155.0】批量填表时,启用立即删除模式,避免红色待删除行残留
|
||||
updateTableFromText(resultText, { immediateDelete: true });
|
||||
renderTables();
|
||||
log(`批次 ${batchNum} 处理成功。`, 'success');
|
||||
|
||||
currentBatch = batchNum;
|
||||
setTimeout(processNextBatch, 1000);
|
||||
currentBatch = batchNum;
|
||||
setTimeout(processNextBatch, 1000);
|
||||
|
||||
} catch (error) {
|
||||
log(`批次 ${batchNum} 尝试 ${attemptNum + 1} 失败: ${error.message}`, 'error');
|
||||
@@ -345,7 +427,9 @@ export function startBatchFilling() {
|
||||
manualStopRequested = false;
|
||||
const context = getContext();
|
||||
chatHistoryLength = context.chat.length;
|
||||
threshold = parseInt(document.getElementById('batch-filling-threshold')?.value, 10) || 30;
|
||||
threshold = extension_settings[extensionName]?.batch_filling_threshold
|
||||
?? parseInt(/** @type {HTMLInputElement|null} */ (document.getElementById('batch-filling-threshold'))?.value, 10)
|
||||
?? 30;
|
||||
|
||||
const ruleTemplate = getBatchFillerRuleTemplate();
|
||||
const flowTemplate = getBatchFillerFlowTemplate();
|
||||
@@ -484,24 +568,82 @@ export async function startFloorRangeFilling(startFloor, endFloor) {
|
||||
console.dir(messages);
|
||||
console.groupEnd();
|
||||
|
||||
const resultText = await callTableModel(messages);
|
||||
console.log(`[Amily2 楼层填表] 楼层 ${startFloor}-${endFloor} - 收到 API 原始回复:`, resultText);
|
||||
|
||||
if (!resultText) {
|
||||
throw new Error('API返回内容为空。');
|
||||
const floorSettings = extension_settings[extensionName] || {};
|
||||
if (floorSettings.tableFillFunctionCall) {
|
||||
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling' });
|
||||
if (!argsString) throw new Error('Function Call 返回为空。');
|
||||
const ops = parseToolCallArgs(argsString);
|
||||
if (ops.length === 0) {
|
||||
let parseHint = '';
|
||||
try {
|
||||
const rawParsed = JSON.parse(argsString);
|
||||
const rawOpsLen = rawParsed?.operations?.length ?? 0;
|
||||
if (rawOpsLen > 0) {
|
||||
parseHint = `(响应含 ${rawOpsLen} 条操作,但全部未通过格式校验)`;
|
||||
}
|
||||
} catch {
|
||||
parseHint = '(响应 JSON 解析失败)';
|
||||
}
|
||||
log(`楼层 ${startFloor}-${endFloor} FC 操作列表为空${parseHint},原始响应:\n${argsString}`, 'warn');
|
||||
toastr.info('AI 判断此楼层范围无需修改。', `楼层 ${startFloor}-${endFloor}`);
|
||||
} else {
|
||||
await updateTableFromOps(ops, { immediateDelete: true });
|
||||
renderTables();
|
||||
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
|
||||
log(`楼层 ${startFloor}-${endFloor} Function Call 处理成功(${ops.length} 条操作)。`, 'success');
|
||||
}
|
||||
} else {
|
||||
const resultText = await callTableModel(messages);
|
||||
console.log(`[Amily2 楼层填表] 楼层 ${startFloor}-${endFloor} - 收到 API 原始回复:`, resultText);
|
||||
if (!resultText) throw new Error('API返回内容为空。');
|
||||
|
||||
if (!resultText.includes('<Amily2Edit>')) {
|
||||
log(`楼层 ${startFloor}-${endFloor} 的响应未包含 <Amily2Edit> 指令块,弹出检查窗口等待用户处理。`, 'warn');
|
||||
showTableFillReviewModal(resultText, {
|
||||
title: `填表响应检查 - 楼层 ${startFloor}-${endFloor}`,
|
||||
subtitle: `楼层 ${startFloor}-${endFloor} 的 AI 响应未包含有效的 <Amily2Edit> 指令块。请检查原始响应并选择处理方式。`,
|
||||
onContinue: async (currentText) => {
|
||||
const merged = await requestContinuation(messages, currentText);
|
||||
if (!merged) { toastr.error('补全请求失败或返回为空。', '继续补全'); return null; }
|
||||
if (!merged.includes('<Amily2Edit>')) {
|
||||
toastr.warning('补全后仍未包含 <Amily2Edit> 指令块,可继续补全、手动应用或重新填表。', '继续补全');
|
||||
} else {
|
||||
toastr.success('已获得包含指令块的补全内容,可点击”手动应用”写入。', '继续补全');
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
onApply: (editedText) => {
|
||||
if (!editedText || !editedText.includes('<Amily2Edit>')) {
|
||||
toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用');
|
||||
}
|
||||
try {
|
||||
updateTableFromText(editedText, { immediateDelete: true });
|
||||
renderTables();
|
||||
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
|
||||
log(`楼层 ${startFloor}-${endFloor} 填表由用户手动处理完成。`, 'success');
|
||||
} catch (err) {
|
||||
log(`楼层 ${startFloor}-${endFloor} 手动应用失败: ${err.message}`, 'error');
|
||||
toastr.error(`手动应用失败: ${err.message}`, '写入异常');
|
||||
}
|
||||
},
|
||||
onRetry: () => {
|
||||
log(`用户请求重新填写楼层 ${startFloor}-${endFloor}。`, 'warn');
|
||||
setTimeout(() => startFloorRangeFilling(startFloor, endFloor), 300);
|
||||
},
|
||||
onCancel: () => {
|
||||
log(`用户取消了楼层 ${startFloor}-${endFloor} 的填表。`, 'warn');
|
||||
toastr.info(`已取消楼层 ${startFloor}-${endFloor} 的填表。`);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateTableFromText(resultText, { immediateDelete: true });
|
||||
renderTables();
|
||||
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
|
||||
log(`楼层 ${startFloor}-${endFloor} 填表处理完成。`, 'success');
|
||||
}
|
||||
|
||||
// 【修复】检查 AI 是否返回了有效的指令块
|
||||
if (!resultText.includes('<Amily2Edit>')) {
|
||||
throw new Error('AI未返回有效的 <Amily2Edit> 指令块,可能格式错误或未产生实质性变更。');
|
||||
}
|
||||
|
||||
updateTableFromText(resultText, { immediateDelete: true });
|
||||
renderTables();
|
||||
|
||||
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
|
||||
log(`楼层 ${startFloor}-${endFloor} 填表处理完成。`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
log(`楼层 ${startFloor}-${endFloor} 填表失败: ${error.message}`, 'error');
|
||||
toastr.error(`楼层填表失败: ${error.message}`, '处理失败');
|
||||
|
||||
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));
|
||||
}
|
||||
91
core/table-system/formatters/tool-call.js
Normal file
91
core/table-system/formatters/tool-call.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file formatters/tool-call.js — Function Call 填表格式器
|
||||
*
|
||||
* 职责:
|
||||
* - 导出 TABLE_FILL_TOOL:发给模型的 tools 定义(单工具 + operations 数组)
|
||||
* - 导出 parseToolCallArgs:把 tool_calls[0].function.arguments 解析为 Operation[]
|
||||
*
|
||||
* 与 executor.js(legacy formatter)并列;下游 applyOperations 不感知来源。
|
||||
*
|
||||
* @typedef {import('../dto/Operation.js').Operation} Operation
|
||||
*/
|
||||
|
||||
/**
|
||||
* 填表工具 schema。使用 operations 数组而非多工具并发,兼容所有支持 function calling 的提供商。
|
||||
*
|
||||
* data 的 key 为列索引字符串("0"、"1"...),与 executor.js legacy 格式保持一致,
|
||||
* 提示词中会给出列索引与列名的对应关系。
|
||||
*/
|
||||
export const TABLE_FILL_TOOL = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'apply_table_edits',
|
||||
description: '将一批表格编辑操作应用到记忆表格中。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
operations: {
|
||||
type: 'array',
|
||||
description: '按顺序执行的操作列表。',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: ['insertRow', 'updateRow', 'deleteRow'],
|
||||
description: 'insertRow=新增行,updateRow=更新已有行,deleteRow=删除行'
|
||||
},
|
||||
tableIndex: {
|
||||
type: 'integer',
|
||||
description: '目标表格的 0-based 索引'
|
||||
},
|
||||
rowIndex: {
|
||||
type: 'integer',
|
||||
description: 'updateRow / deleteRow 时必填,目标行的 0-based 索引'
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'insertRow / updateRow 时必填,key 为列索引字符串("0"/"1"...),value 为单元格内容',
|
||||
additionalProperties: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['op', 'tableIndex']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['operations']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析 tool_calls[0].function.arguments 字符串为 Operation[]。
|
||||
* 结构校验失败的单条操作会被静默跳过,不中断整体解析。
|
||||
*
|
||||
* @param {string} argsString - JSON 字符串
|
||||
* @returns {Operation[]}
|
||||
*/
|
||||
export function parseToolCallArgs(argsString) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(argsString);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rawOps = parsed?.operations;
|
||||
if (!Array.isArray(rawOps)) return [];
|
||||
|
||||
/** @type {Operation[]} */
|
||||
const ops = [];
|
||||
for (const raw of rawOps) {
|
||||
if (raw.op === 'insertRow' && Number.isInteger(raw.tableIndex) && raw.data && typeof raw.data === 'object') {
|
||||
ops.push({ op: 'insertRow', tableIndex: raw.tableIndex, data: raw.data });
|
||||
} else if (raw.op === 'updateRow' && Number.isInteger(raw.tableIndex) && Number.isInteger(raw.rowIndex) && raw.data && typeof raw.data === 'object') {
|
||||
ops.push({ op: 'updateRow', tableIndex: raw.tableIndex, rowIndex: raw.rowIndex, data: raw.data });
|
||||
} else if (raw.op === 'deleteRow' && Number.isInteger(raw.tableIndex) && Number.isInteger(raw.rowIndex)) {
|
||||
ops.push({ op: 'deleteRow', tableIndex: raw.tableIndex, rowIndex: raw.rowIndex });
|
||||
}
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
@@ -5,16 +5,17 @@
|
||||
* - 状态:infra/store.js (currentTablesState / highlights / updatedTables)
|
||||
* - 持久化:infra/persistence.js (saveStateToMessage / commitToLastMessage)
|
||||
* - 推演:actions/applyOperations.js (executor.js 改造为 legacy formatter)
|
||||
* - UI 突变:actions/ui-mutations.js (addRow / addColumn / ... / clearAllTables,Phase 0.4)
|
||||
* - SuperMemory 分发:events-dispatch.js (dispatchTableUpdate / dispatchAllTablesUpdate)
|
||||
* - 渲染:rendering.js (3 个 toCsv)
|
||||
* - 模板:templates.js
|
||||
* - 预设:preset.js
|
||||
*
|
||||
* 本文件保留:
|
||||
* - 默认表格模板 + getDefaultTables
|
||||
* - SuperMemory 事件分发(dispatchTableUpdate / dispatchAllTablesUpdate / triggerSync)
|
||||
* - triggerSync(SuperMemory 全量同步入口)
|
||||
* - loadTables 的多档回退逻辑
|
||||
* - 16 个 UI 突变(addRow / addColumn / ... / clearAllTables)
|
||||
* - updateTableFromText 编排
|
||||
* - updateTableFromText / updateTableFromOps 编排
|
||||
* - rollbackState / rollbackAndRefill
|
||||
*
|
||||
* 所有原先 export 的接口一律保留兼容(移走的统一 re-export),调用方零改动。
|
||||
@@ -29,10 +30,35 @@ import { extensionName } from '../../utils/settings.js';
|
||||
|
||||
import { log } from './logger.js';
|
||||
import { executeCommands } from './executor.js';
|
||||
import { applyOperations } from './actions/applyOperations.js';
|
||||
import { fillWithSecondaryApi } from './secondary-filler.js';
|
||||
import { renderTables } from '../../ui/table-bindings.js';
|
||||
import { updateOrInsertTableInChat } from '../../ui/message-table-renderer.js';
|
||||
import { createTableUpdateEvent, inferTableRole } from './events-schema.js';
|
||||
import { dispatchTableUpdate, dispatchAllTablesUpdate } from './events-dispatch.js';
|
||||
|
||||
// ── UI 突变(Phase 0.4 迁至 actions/ui-mutations.js,此处 re-export 保持兼容) ──
|
||||
import { commitPendingDeletions } from './actions/ui-mutations.js';
|
||||
export {
|
||||
deleteColumn,
|
||||
moveRow,
|
||||
insertRow,
|
||||
addRow,
|
||||
addColumn,
|
||||
updateHeader,
|
||||
deleteRow,
|
||||
restoreRow,
|
||||
commitPendingDeletions,
|
||||
insertColumn,
|
||||
moveColumn,
|
||||
deleteTable,
|
||||
addTable,
|
||||
renameTable,
|
||||
moveTable,
|
||||
updateTableRules,
|
||||
updateRow,
|
||||
clearAllTables,
|
||||
updateColumnWidth,
|
||||
} from './actions/ui-mutations.js';
|
||||
|
||||
// ── 新模块(IAD 拆分后的依赖) ────────────────────────────────────────────
|
||||
import {
|
||||
@@ -76,45 +102,7 @@ import {
|
||||
importGlobalPreset as _presetImportGlobalPreset,
|
||||
} from './preset.js';
|
||||
|
||||
// ── 私有:SuperMemory 事件分发 ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 把单个表格的最新状态推送给 SuperMemory(优先 Bus 直调,降级 CustomEvent)。
|
||||
* @param {number} tableIndex
|
||||
*/
|
||||
function dispatchTableUpdate(tableIndex) {
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
if (settings.super_memory_enabled === false) return;
|
||||
|
||||
const state = getState();
|
||||
if (!state || !state[tableIndex]) return;
|
||||
const table = state[tableIndex];
|
||||
const role = inferTableRole(table.name);
|
||||
|
||||
const smBus = window.Amily2Bus?.query('SuperMemory');
|
||||
if (smBus?.pushUpdate) {
|
||||
smBus.pushUpdate({
|
||||
tableName: table.name,
|
||||
data: table.rows,
|
||||
headers: table.headers,
|
||||
rowStatuses: table.rowStatuses ?? [],
|
||||
role,
|
||||
});
|
||||
} else {
|
||||
document.dispatchEvent(createTableUpdateEvent(table));
|
||||
}
|
||||
log(`[SuperMemory] Dispatched update for ${table.name} (role: ${role})`, 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发所有表格的全量同步(Pipeline 变更后调用)。
|
||||
*/
|
||||
function dispatchAllTablesUpdate() {
|
||||
const state = getState();
|
||||
if (!state) return;
|
||||
log('[SuperMemory] Dispatching update events for ALL tables...', 'info');
|
||||
state.forEach((_, index) => dispatchTableUpdate(index));
|
||||
}
|
||||
// ── SuperMemory 同步入口 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 主动触发所有表格同步到 SuperMemory(外部调用入口)。
|
||||
@@ -351,438 +339,6 @@ export function saveTables(sourceAction = '未知操作') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 16 个 UI 突变 ─────────────────────────────────────────────────────────
|
||||
|
||||
export function deleteColumn(tableIndex, colIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex] || colIndex < 0 || colIndex >= tables[tableIndex].headers.length) {
|
||||
log(`删除列失败:在表格 ${tableIndex} 中找不到索引为 ${colIndex} 的列。`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
tables[tableIndex].headers.splice(colIndex, 1);
|
||||
tables[tableIndex].rows.forEach(row => {
|
||||
if (row.length > colIndex) row.splice(colIndex, 1);
|
||||
});
|
||||
if (tables[tableIndex].columnWidths && tables[tableIndex].columnWidths.length > colIndex) {
|
||||
tables[tableIndex].columnWidths.splice(colIndex, 1);
|
||||
}
|
||||
|
||||
log(`成功删除了表格 ${tableIndex} 的第 ${colIndex + 1} 列。`, 'success');
|
||||
saveTables(tables);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export function moveRow(tableIndex, rowIndex, direction) {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table || rowIndex < 0 || rowIndex >= table.rows.length) return;
|
||||
|
||||
const newIndex = direction === 'up' ? rowIndex - 1 : rowIndex + 1;
|
||||
if (newIndex < 0 || newIndex >= table.rows.length) return;
|
||||
|
||||
const [movedRow] = table.rows.splice(rowIndex, 1);
|
||||
table.rows.splice(newIndex, 0, movedRow);
|
||||
|
||||
if (table.rowStatuses && table.rowStatuses.length === table.rows.length + 1) {
|
||||
const [movedStatus] = table.rowStatuses.splice(rowIndex, 1);
|
||||
table.rowStatuses.splice(newIndex, 0, movedStatus);
|
||||
}
|
||||
|
||||
log(`成功将表格 ${tableIndex} 的第 ${rowIndex + 1} 行移动到第 ${newIndex + 1} 行。`, 'success');
|
||||
saveTables(tables);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export function insertRow(tableIndex, data, position = 'below') {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table) {
|
||||
log(`插入行失败:找不到索引为 ${tableIndex} 的表格。`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let insertIndex;
|
||||
if (typeof data === 'number') {
|
||||
insertIndex = position === 'above' ? data : data + 1;
|
||||
} else {
|
||||
insertIndex = table.rows.length;
|
||||
}
|
||||
if (insertIndex < 0) insertIndex = 0;
|
||||
if (insertIndex > table.rows.length) insertIndex = table.rows.length;
|
||||
|
||||
const newRow = new Array(table.headers.length).fill('');
|
||||
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
for (const colIndex in data) {
|
||||
const cIndex = parseInt(colIndex, 10);
|
||||
if (!isNaN(cIndex) && cIndex < newRow.length) {
|
||||
newRow[cIndex] = data[colIndex];
|
||||
addHighlight(tableIndex, insertIndex, cIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.rows.splice(insertIndex, 0, newRow);
|
||||
if (!table.rowStatuses) table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
table.rowStatuses.splice(insertIndex, 0, 'normal');
|
||||
|
||||
markTableUpdated(tableIndex);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
log(`成功在表格 ${table.name} (索引 ${tableIndex}) 的第 ${insertIndex + 1} 行位置插入了新行。`, 'success');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function addRow(tableIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
const colCount = table.headers.length;
|
||||
const newRow = Array(colCount).fill('');
|
||||
table.rows.push(newRow);
|
||||
if (!table.rowStatuses) table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
table.rowStatuses.push('normal');
|
||||
markTableUpdated(tableIndex);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
log(`表格 [${table.name}] 新增了一行。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function addColumn(tableIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
const newHeader = `新列 ${table.headers.length + 1}`;
|
||||
table.headers.push(newHeader);
|
||||
table.rows.forEach(row => row.push(''));
|
||||
if (!table.columnWidths) table.columnWidths = [];
|
||||
table.columnWidths.push(null);
|
||||
log(`表格 [${table.name}] 新增了一列。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function updateHeader(tableIndex, colIndex, value) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex] || tables[tableIndex].headers[colIndex] === undefined) return;
|
||||
const tableName = tables[tableIndex].name;
|
||||
const originalHeader = tables[tableIndex].headers[colIndex];
|
||||
tables[tableIndex].headers[colIndex] = value;
|
||||
log(`表格 [${tableName}] 的表头“${originalHeader}”已更新为“${value}”。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export async function deleteRow(tableIndex, rowIndex) {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table || !table.rows[rowIndex]) return;
|
||||
|
||||
if (!table.rowStatuses) {
|
||||
table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
}
|
||||
|
||||
table.rowStatuses[rowIndex] = 'pending-deletion';
|
||||
markTableUpdated(tableIndex);
|
||||
log(`表格 [${table.name}] 的第 ${rowIndex + 1} 行已标记为待删除。`, 'info');
|
||||
|
||||
const context = getContext();
|
||||
if (context.chat?.length > 0) {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
if (_persistSaveStateToMessage(tables, lastMessage)) {
|
||||
await saveChat();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveChatDebounced();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export async function restoreRow(tableIndex, rowIndex) {
|
||||
const tables = getState();
|
||||
const table = tables?.[tableIndex];
|
||||
if (!table || !table.rows[rowIndex] || !table.rowStatuses) return;
|
||||
|
||||
table.rowStatuses[rowIndex] = 'normal';
|
||||
markTableUpdated(tableIndex);
|
||||
log(`表格 [${table.name}] 的第 ${rowIndex + 1} 行已恢复。`, 'info');
|
||||
|
||||
const context = getContext();
|
||||
if (context.chat?.length > 0) {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
if (_persistSaveStateToMessage(tables, lastMessage)) {
|
||||
await saveChat();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveChatDebounced();
|
||||
renderTables();
|
||||
dispatchTableUpdate(tableIndex);
|
||||
}
|
||||
|
||||
export function commitPendingDeletions() {
|
||||
const tables = getState();
|
||||
if (!tables) return false;
|
||||
let deletionCount = 0;
|
||||
|
||||
tables.forEach((table, tableIndex) => {
|
||||
if (!table.rowStatuses || table.rowStatuses.length === 0) return;
|
||||
let tableHadDeletions = false;
|
||||
for (let i = table.rows.length - 1; i >= 0; i--) {
|
||||
if (table.rowStatuses[i] === 'pending-deletion') {
|
||||
table.rows.splice(i, 1);
|
||||
table.rowStatuses.splice(i, 1);
|
||||
deletionCount++;
|
||||
tableHadDeletions = true;
|
||||
}
|
||||
}
|
||||
if (tableHadDeletions) markTableUpdated(tableIndex);
|
||||
});
|
||||
|
||||
if (deletionCount > 0) {
|
||||
log(`已提交并永久删除了 ${deletionCount} 行。`, 'info');
|
||||
const updated = _storeGetUpdatedTables();
|
||||
if (updated.size > 0) {
|
||||
updated.forEach(tableIndex => dispatchTableUpdate(tableIndex));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function insertColumn(tableIndex, colIndex, position) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
|
||||
const insertAt = position === 'left' ? colIndex : colIndex + 1;
|
||||
table.headers.splice(insertAt, 0, '新列');
|
||||
table.rows.forEach(row => row.splice(insertAt, 0, ''));
|
||||
if (!table.columnWidths) table.columnWidths = [];
|
||||
table.columnWidths.splice(insertAt, 0, null);
|
||||
|
||||
log(`表格 [${table.name}] 在第 ${colIndex + 1} 列的${position === 'left' ? '左侧' : '右侧'}插入了新列。`, 'info');
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function moveColumn(tableIndex, colIndex, direction) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
const headers = table.headers;
|
||||
const rows = table.rows;
|
||||
|
||||
const targetIndex = direction === 'left' ? colIndex - 1 : colIndex + 1;
|
||||
if (targetIndex < 0 || targetIndex >= headers.length) {
|
||||
log(`无法移动列:索引 ${colIndex} 已在边界。`, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const [headerToMove] = headers.splice(colIndex, 1);
|
||||
headers.splice(targetIndex, 0, headerToMove);
|
||||
|
||||
rows.forEach(row => {
|
||||
const [cellToMove] = row.splice(colIndex, 1);
|
||||
row.splice(targetIndex, 0, cellToMove);
|
||||
});
|
||||
|
||||
if (table.columnWidths && table.columnWidths.length > colIndex) {
|
||||
const [widthToMove] = table.columnWidths.splice(colIndex, 1);
|
||||
table.columnWidths.splice(targetIndex, 0, widthToMove);
|
||||
}
|
||||
|
||||
log(`表格 [${table.name}] 的列“${headerToMove}”已向${direction === 'left' ? '左' : '右'}移动。`, 'info');
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function deleteTable(tableIndex) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const tableName = tables[tableIndex].name;
|
||||
tables.splice(tableIndex, 1);
|
||||
log(`表格 [${tableName}] 已被成功废黜。`, 'success');
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('废黜表格后的状态已强制写入最新消息并立即保存。', 'success');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,删除操作可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function addTable(tableName) {
|
||||
if (!tableName || !tableName.trim()) {
|
||||
log('无法创建表格:名称不能为空。', 'error');
|
||||
toastr.error('表格名称不能为空。', '创建失败');
|
||||
return;
|
||||
}
|
||||
let tables = getState();
|
||||
if (!tables) {
|
||||
loadTables();
|
||||
tables = getState();
|
||||
}
|
||||
|
||||
if (tables.some(table => table.name === tableName.trim())) {
|
||||
log(`无法创建表格:名为 "${tableName}" 的表格已存在。`, 'error');
|
||||
toastr.error(`名为 "${tableName}" 的表格已存在。`, '创建失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const newTable = {
|
||||
name: tableName.trim(),
|
||||
headers: ['新列 1'],
|
||||
rows: [],
|
||||
rowStatuses: [],
|
||||
columnWidths: [],
|
||||
note: '这是一个新创建的表格。',
|
||||
rule_add: '允许',
|
||||
rule_delete: '允许',
|
||||
rule_update: '允许',
|
||||
charLimitRules: {},
|
||||
rowLimitRule: 0,
|
||||
};
|
||||
|
||||
tables.push(newTable);
|
||||
log(`已成功创建新表格:[${tableName.trim()}]。`, 'success');
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('新表格状态已强制写入最新消息并立即保存。', 'success');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,新表格可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function renameTable(tableIndex, newName) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) {
|
||||
log('重命名失败:表格不存在。', 'error');
|
||||
toastr.error('表格不存在。', '重命名失败');
|
||||
return;
|
||||
}
|
||||
const trimmedName = newName.trim();
|
||||
if (!trimmedName) {
|
||||
log('重命名失败:名称不能为空。', 'error');
|
||||
toastr.error('表格名称不能为空。', '重命名失败');
|
||||
return;
|
||||
}
|
||||
if (tables.some((table, index) => index !== tableIndex && table.name === trimmedName)) {
|
||||
log(`重命名失败:名为 "${trimmedName}" 的表格已存在。`, 'error');
|
||||
toastr.error(`名为 "${trimmedName}" 的表格已存在。`, '重命名失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const oldName = tables[tableIndex].name;
|
||||
tables[tableIndex].name = trimmedName;
|
||||
log(`表格 "${oldName}" 已重命名为 "${trimmedName}"。`, 'success');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function moveTable(tableIndex, direction) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
|
||||
const newIndex = direction === 'up' ? tableIndex - 1 : tableIndex + 1;
|
||||
if (newIndex < 0 || newIndex >= tables.length) {
|
||||
log(`无法移动表格:索引 ${tableIndex} 已在边界。`, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const temp = tables[tableIndex];
|
||||
tables[tableIndex] = tables[newIndex];
|
||||
tables[newIndex] = temp;
|
||||
|
||||
log(`表格 [${temp.name}] 的顺序已调整。`, 'success');
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('表格顺序调整后的状态已强制写入最新消息并立即保存。', 'success');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,顺序调整可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTableRules(tableIndex, newRules) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
table.note = newRules.note;
|
||||
table.rule_add = newRules.rule_add;
|
||||
table.rule_delete = newRules.rule_delete;
|
||||
table.rule_update = newRules.rule_update;
|
||||
table.charLimitRules = newRules.charLimitRules;
|
||||
table.rowLimitRule = newRules.rowLimitRule;
|
||||
table.simplifyRowThreshold = newRules.simplifyRowThreshold;
|
||||
|
||||
delete table.charLimitRule;
|
||||
|
||||
log(`表格 [${table.name}] 的规则已更新。`, 'info');
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function updateRow(tableIndex, rowIndex, data) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) {
|
||||
log(`AI指令错误:尝试在不存在的表格索引 ${tableIndex} 中操作。`, 'error');
|
||||
return;
|
||||
}
|
||||
const table = tables[tableIndex];
|
||||
|
||||
if (rowIndex >= table.rows.length) {
|
||||
log(`AI指令意图更新不存在的行 (rowIndex: ${rowIndex}),已智能转换为在表格 [${table.name}] 末尾新增一行。`, 'warn');
|
||||
insertRow(tableIndex, data);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = table.rows[rowIndex];
|
||||
for (const colIndex in data) {
|
||||
const cIndex = parseInt(colIndex, 10);
|
||||
if (cIndex < row.length) {
|
||||
row[cIndex] = data[cIndex];
|
||||
addHighlight(tableIndex, rowIndex, cIndex);
|
||||
}
|
||||
}
|
||||
|
||||
markTableUpdated(tableIndex);
|
||||
dispatchTableUpdate(tableIndex);
|
||||
log(`AI 指令更新了表格 [${table.name}] 的第 ${rowIndex + 1} 行。`, 'info');
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function clearAllTables() {
|
||||
const tables = getState();
|
||||
if (!tables) {
|
||||
log('无法清空:当前表格状态为空。', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
tables.forEach((table, tableIndex) => {
|
||||
if (table.rows.length > 0) markTableUpdated(tableIndex);
|
||||
table.rows = [];
|
||||
table.rowStatuses = [];
|
||||
});
|
||||
log('所有表格的行数据已在内存中清空。', 'warn');
|
||||
|
||||
dispatchAllTablesUpdate();
|
||||
|
||||
const success = commitToLastMessage(tables);
|
||||
if (success) {
|
||||
log('清空行数据后的状态已强制写入最新消息并立即保存。', 'success');
|
||||
toastr.success('所有表格的剧情内容已清空。', '操作完成');
|
||||
} else {
|
||||
log('无法找到可锚定的消息或保存失败,清空操作可能不会被持久化!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染 wrapper(注入当前 state) ────────────────────────────────────────
|
||||
|
||||
export function convertTablesToCsvString() {
|
||||
@@ -858,6 +414,78 @@ export async function updateTableFromText(textContent, options = {}) {
|
||||
const affectedTables = [...new Set(changes.map(c => c.tableIndex))];
|
||||
affectedTables.forEach(tableIndex => dispatchTableUpdate(tableIndex));
|
||||
|
||||
// 【skipPersist】分步填表(保留楼层场景)下,状态应由调用方保存到“被填楼层的最后一条”(E),
|
||||
// 不能在此处统一写到最新楼 L——否则 L 上的快照会盖住 E 的,swipe 最新楼时回退掉本轮已填内容。
|
||||
if (options.skipPersist) {
|
||||
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
|
||||
return;
|
||||
}
|
||||
|
||||
const context = getContext();
|
||||
if (context.chat && context.chat.length > 0) {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
if (_persistSaveStateToMessage(getState(), lastMessage)) {
|
||||
await saveChat();
|
||||
toastr.success('已根据AI的指示成功更新表格!', '填表完成');
|
||||
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
saveChatDebounced();
|
||||
toastr.success('已根据AI的指示成功更新表格!', '填表完成');
|
||||
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接从 Operation[] 应用变更(Function Call 路径),跳过文本解析。
|
||||
* 后续流程与 updateTableFromText 完全一致。
|
||||
*
|
||||
* @param {import('./dto/Operation.js').Operation[]} ops
|
||||
* @param {Object} options - 同 updateTableFromText 的 options
|
||||
*/
|
||||
export async function updateTableFromOps(ops, options = {}) {
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
if (settings.table_system_enabled === false) return;
|
||||
|
||||
if (!Array.isArray(ops) || ops.length === 0) {
|
||||
log('Function Call 返回操作列表为空,无需更新表格。', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const { state, changes } = applyOperations(getState(), ops);
|
||||
|
||||
if (changes.length === 0) {
|
||||
log('Function Call 操作未产生任何实质性变更。', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(state);
|
||||
|
||||
if (options.immediateDelete) {
|
||||
commitPendingDeletions();
|
||||
}
|
||||
|
||||
changes.forEach(change => {
|
||||
markTableUpdated(change.tableIndex);
|
||||
if (change.type === 'update' || change.type === 'insert') {
|
||||
if (change.rowIndex !== undefined && change.colIndex !== undefined) {
|
||||
addHighlight(change.tableIndex, change.rowIndex, change.colIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
log(`Function Call 成功执行了 ${changes.length} 处变更。`, 'success');
|
||||
|
||||
const affectedTables = [...new Set(changes.map(c => c.tableIndex))];
|
||||
affectedTables.forEach(tableIndex => dispatchTableUpdate(tableIndex));
|
||||
|
||||
// 【skipPersist】见 updateTableFromText 同名说明:分步填表由调用方存到 E,不在此写最新楼。
|
||||
if (options.skipPersist) {
|
||||
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
|
||||
return;
|
||||
}
|
||||
|
||||
const context = getContext();
|
||||
if (context.chat && context.chat.length > 0) {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
@@ -961,7 +589,7 @@ export async function rollbackAndRefill() {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
|
||||
try {
|
||||
await fillWithSecondaryApi(lastMessage, true);
|
||||
await fillWithSecondaryApi(lastMessage, true, { targetMessage: lastMessage });
|
||||
log('回退并重新填表操作完成。', 'success');
|
||||
} catch (error) {
|
||||
log(`回退重填过程中发生错误: ${error.message}`, 'error');
|
||||
@@ -971,19 +599,6 @@ export async function rollbackAndRefill() {
|
||||
|
||||
// ── 杂项 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function updateColumnWidth(tableIndex, colIndex, width) {
|
||||
const tables = getState();
|
||||
if (!tables || !tables[tableIndex]) return;
|
||||
const table = tables[tableIndex];
|
||||
if (!table.columnWidths) table.columnWidths = [];
|
||||
while (table.columnWidths.length < table.headers.length) {
|
||||
table.columnWidths.push(null);
|
||||
}
|
||||
table.columnWidths[colIndex] = width;
|
||||
|
||||
commitToLastMessage(tables);
|
||||
}
|
||||
|
||||
export function isCurrentTablesEmpty() {
|
||||
const tables = getState();
|
||||
if (!tables || tables.length === 0) return true;
|
||||
|
||||
@@ -4,13 +4,69 @@ import { saveChat } from "/script.js";
|
||||
import { renderTables } from '../../ui/table-bindings.js';
|
||||
import { updateOrInsertTableInChat } from '../../ui/message-table-renderer.js';
|
||||
import { extensionName } from "../../utils/settings.js";
|
||||
import { updateTableFromText, getBatchFillerRuleTemplate, getBatchFillerFlowTemplate, convertTablesToCsvString, saveStateToMessage, getMemoryState, clearHighlights } from './manager.js';
|
||||
import { updateTableFromText, updateTableFromOps, getBatchFillerRuleTemplate, getBatchFillerFlowTemplate, convertTablesToCsvString, saveStateToMessage, getMemoryState, clearHighlights } from './manager.js';
|
||||
import { getPresetPrompts, getMixedOrder } from '../../PresetSettings/index.js';
|
||||
import { callAI, generateRandomSeed } from '../api.js';
|
||||
import { callAI, callAIForTools, generateRandomSeed } from '../api.js';
|
||||
import { TABLE_FILL_TOOL, parseToolCallArgs } from './formatters/tool-call.js';
|
||||
import { callNccsAI } from '../api/NccsApi.js';
|
||||
import { extractBlocksByTags, applyExclusionRules } from '../utils/rag-tag-extractor.js';
|
||||
import { resolveTableRuleConfig } from '../../utils/config/RuleProfileManager.js';
|
||||
import { safeLorebookEntries } from '../tavernhelper-compatibility.js';
|
||||
import { log } from './logger.js';
|
||||
import { showTableFillReviewModal } from '../../ui/page-window.js';
|
||||
|
||||
const CONTINUE_PROMPT_SECONDARY = '上一条回复不完整或缺少 <Amily2Edit> 指令块。请直接从中断处继续生成剩余内容,不要重复已输出的文本,也不要添加任何解释或寒暄,确保最终输出中包含完整的 <Amily2Edit>...</Amily2Edit> 指令块。';
|
||||
|
||||
let secondaryFillerDebounceTimer = null;
|
||||
let secondaryFillerRunning = false;
|
||||
let currentAbortController = null;
|
||||
|
||||
async function callSecondaryModel(messages, signal) {
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
if (settings.nccsEnabled) {
|
||||
return await callNccsAI(messages, { signal });
|
||||
}
|
||||
return await callAI(messages, { signal });
|
||||
}
|
||||
|
||||
async function requestSecondaryContinuation(baseMessages, partialResponse) {
|
||||
const continueMessages = [
|
||||
...baseMessages,
|
||||
{ role: 'assistant', content: partialResponse || '' },
|
||||
{ role: 'user', content: CONTINUE_PROMPT_SECONDARY },
|
||||
];
|
||||
const continued = await callSecondaryModel(continueMessages);
|
||||
if (!continued) return null;
|
||||
return `${partialResponse || ''}${continued}`;
|
||||
}
|
||||
|
||||
async function markTargetsProcessed(targetMessages, { skipTableSave = false } = {}) {
|
||||
if (!targetMessages || targetMessages.length === 0) return;
|
||||
|
||||
const lastProcessedMsg = targetMessages[targetMessages.length - 1].msg;
|
||||
|
||||
for (const target of targetMessages) {
|
||||
if (!target.msg.extra) target.msg.extra = {};
|
||||
target.msg.extra.amily2_process_hash = target.hash;
|
||||
}
|
||||
|
||||
if (!skipTableSave) {
|
||||
const memoryState = getMemoryState();
|
||||
if (saveStateToMessage(memoryState, lastProcessedMsg)) {
|
||||
renderTables();
|
||||
updateOrInsertTableInChat();
|
||||
}
|
||||
}
|
||||
|
||||
await saveChat();
|
||||
}
|
||||
|
||||
async function commitSecondaryFillResult(rawContent, targetMessages) {
|
||||
// skipPersist:不让 updateTableFromText 把状态写到最新楼 L,改由 markTargetsProcessed
|
||||
// 存到 lastProcessedMsg(E)——否则保留楼层场景下 swipe 最新楼会回退掉本轮已填内容。
|
||||
await updateTableFromText(rawContent, { skipPersist: true });
|
||||
await markTargetsProcessed(targetMessages);
|
||||
}
|
||||
|
||||
|
||||
async function getWorldBookContext() {
|
||||
@@ -65,11 +121,35 @@ async function getWorldBookContext() {
|
||||
return content.trim() ? `<世界书>\n${content.trim()}\n</世界书>` : '';
|
||||
}
|
||||
|
||||
export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
|
||||
clearHighlights();
|
||||
|
||||
export async function fillWithSecondaryApi(latestMessage, forceRun = false, opts = {}) {
|
||||
if (secondaryFillerRunning) {
|
||||
log('分步填表正在进行中,跳过本次触发。', 'warn');
|
||||
return;
|
||||
}
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
|
||||
// 【V2.1.1】分步填表触发延迟 / 防抖:自动触发时若配置了延迟,则延后执行,
|
||||
// 延迟期内再次到来的事件会重置计时器,避免消息连续到达时重复拉起填表。
|
||||
// 注意:防抖与早返路径都不持锁,避免 setTimeout 回调撞上自己的锁导致死锁。
|
||||
const delay = Math.max(0, parseInt(settings.secondary_filler_delay || 0, 10));
|
||||
if (!forceRun && delay > 0) {
|
||||
if (secondaryFillerDebounceTimer) {
|
||||
clearTimeout(secondaryFillerDebounceTimer);
|
||||
}
|
||||
secondaryFillerDebounceTimer = setTimeout(() => {
|
||||
secondaryFillerDebounceTimer = null;
|
||||
fillWithSecondaryApi(latestMessage, forceRun, opts);
|
||||
}, delay);
|
||||
console.log(`[Amily2-副API] 分步填表已按防抖延迟 ${delay}ms 调度。`);
|
||||
return;
|
||||
}
|
||||
if (secondaryFillerDebounceTimer) {
|
||||
clearTimeout(secondaryFillerDebounceTimer);
|
||||
secondaryFillerDebounceTimer = null;
|
||||
}
|
||||
|
||||
clearHighlights();
|
||||
|
||||
// 总开关关闭时,分步填表同样禁用
|
||||
if (settings.table_system_enabled === false) {
|
||||
log('【分步填表】表格系统总开关已关闭,跳过。', 'info');
|
||||
@@ -93,32 +173,24 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 所有早返检查通过后再获取锁,确保 finally 一定能解锁
|
||||
secondaryFillerRunning = true;
|
||||
currentAbortController = new AbortController();
|
||||
const signal = currentAbortController.signal;
|
||||
try {
|
||||
const bufferSize = parseInt(settings.secondary_filler_buffer || 0, 10);
|
||||
const batchSize = parseInt(settings.secondary_filler_batch || 0, 10);
|
||||
const batchSize = parseInt(settings.secondary_filler_batch || 0, 10);
|
||||
const contextLimit = parseInt(settings.secondary_filler_context || 2, 10);
|
||||
|
||||
|
||||
// 【V1.7.7 修复】限制最大回溯深度,防止更新后无限填补旧历史
|
||||
// 响应用户反馈:扫描深度 = 上下文 + 填表批次 + 保留楼层 + 冗余量(10)
|
||||
// redundancy (冗余量): 额外扫描 10 层作为安全缓冲,防止因消息索引计算偏差导致漏掉边缘消息
|
||||
// 扫描深度 = 上下文 + 填表批次 + 冗余量(10)
|
||||
// bufferSize(保留楼层)仅用于限定尾部边界 validEndIndex,
|
||||
// 不再回流到扫描起点,避免重复影响范围
|
||||
const redundancy = 10;
|
||||
const maxScanDepth = contextLimit + batchSize + bufferSize + redundancy;
|
||||
const maxScanDepth = contextLimit + batchSize + redundancy;
|
||||
|
||||
const chat = context.chat;
|
||||
const totalMessages = chat.length;
|
||||
|
||||
const validEndIndex = totalMessages - 1 - bufferSize;
|
||||
// 计算扫描的起始索引(不小于0)
|
||||
const scanStartIndex = Math.max(0, validEndIndex - maxScanDepth);
|
||||
|
||||
if (validEndIndex < 0) {
|
||||
console.log(`[Amily2-副API] 消息数量不足以超出保留区(${bufferSize}),跳过。`);
|
||||
return;
|
||||
}
|
||||
|
||||
let targetMessages = [];
|
||||
let needsProcessing = false;
|
||||
|
||||
const getContentHash = (content) => {
|
||||
let hash = 0, i, chr;
|
||||
@@ -126,45 +198,74 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
|
||||
for (i = 0; i < content.length; i++) {
|
||||
chr = content.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + chr;
|
||||
hash |= 0;
|
||||
hash |= 0;
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
// 【修复】改为正向扫描,优先处理最老的未处理消息,防止遗留消息被挤出扫描区
|
||||
for (let i = scanStartIndex; i <= validEndIndex; i++) {
|
||||
const msg = chat[i];
|
||||
|
||||
if (msg.is_user) continue;
|
||||
let targetMessages = [];
|
||||
|
||||
const currentHash = getContentHash(msg.mes);
|
||||
const savedHash = msg.metadata?.Amily2_Process_Hash;
|
||||
|
||||
const isUnprocessed = !savedHash;
|
||||
const isChanged = savedHash && savedHash !== currentHash;
|
||||
|
||||
if (isUnprocessed || isChanged) {
|
||||
targetMessages.push({ index: i, msg: msg, hash: currentHash });
|
||||
|
||||
if (batchSize > 0 && targetMessages.length >= batchSize) {
|
||||
needsProcessing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetMessages.length === 0) {
|
||||
console.log("[Amily2-副API] 没有发现需要处理的消息。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchSize > 0) {
|
||||
if (targetMessages.length < batchSize) {
|
||||
console.log(`[Amily2-副API] 批量模式: 当前累积 ${targetMessages.length}/${batchSize} 条未处理消息,暂不触发。`);
|
||||
// 【SWIPED 旁路】swipe 后强制处理刚切出来的最新消息:
|
||||
// 跳过扫描 / bufferSize / batchSize 累积逻辑,直接锁定目标
|
||||
if (opts.targetMessage) {
|
||||
const targetIndex = chat.indexOf(opts.targetMessage);
|
||||
if (targetIndex < 0) {
|
||||
console.log("[Amily2-副API] 旁路目标消息不在聊天列表中,跳过。");
|
||||
return;
|
||||
}
|
||||
if (opts.targetMessage.is_user) {
|
||||
console.log("[Amily2-副API] 旁路目标是用户消息,跳过。");
|
||||
return;
|
||||
}
|
||||
targetMessages.push({
|
||||
index: targetIndex,
|
||||
msg: opts.targetMessage,
|
||||
hash: getContentHash(opts.targetMessage.mes),
|
||||
});
|
||||
} else {
|
||||
targetMessages = [targetMessages[targetMessages.length - 1]];
|
||||
// 常规扫描路径
|
||||
const validEndIndex = totalMessages - 1 - bufferSize;
|
||||
const scanStartIndex = Math.max(0, validEndIndex - maxScanDepth);
|
||||
|
||||
if (validEndIndex < 0) {
|
||||
console.log(`[Amily2-副API] 消息数量不足以超出保留区(${bufferSize}),跳过。`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 【修复】改为正向扫描,优先处理最老的未处理消息,防止遗留消息被挤出扫描区
|
||||
for (let i = scanStartIndex; i <= validEndIndex; i++) {
|
||||
const msg = chat[i];
|
||||
|
||||
if (msg.is_user) continue;
|
||||
|
||||
const currentHash = getContentHash(msg.mes);
|
||||
const savedHash = msg.extra?.amily2_process_hash;
|
||||
|
||||
const isUnprocessed = !savedHash;
|
||||
const isChanged = savedHash && savedHash !== currentHash;
|
||||
|
||||
if (isUnprocessed || isChanged) {
|
||||
targetMessages.push({ index: i, msg: msg, hash: currentHash });
|
||||
|
||||
if (batchSize > 0 && targetMessages.length >= batchSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetMessages.length === 0) {
|
||||
console.log("[Amily2-副API] 没有发现需要处理的消息。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchSize > 0) {
|
||||
if (targetMessages.length < batchSize) {
|
||||
console.log(`[Amily2-副API] 批量模式: 当前累积 ${targetMessages.length}/${batchSize} 条未处理消息,暂不触发。`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
targetMessages = [targetMessages[targetMessages.length - 1]];
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Amily2-副API] 触发填表: 处理 ${targetMessages.length} 条消息。索引范围: ${targetMessages[0].index} - ${targetMessages[targetMessages.length-1].index}`);
|
||||
@@ -272,79 +373,173 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
|
||||
console.dir(messages);
|
||||
console.groupEnd();
|
||||
|
||||
let rawContent;
|
||||
if (settings.nccsEnabled) {
|
||||
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
|
||||
rawContent = await callNccsAI(messages);
|
||||
if (settings.tableFillFunctionCall) {
|
||||
// Function Call 路径
|
||||
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling', signal });
|
||||
if (!argsString) {
|
||||
console.error('[Amily2-副API] Function Call 返回为空。');
|
||||
return;
|
||||
}
|
||||
const ops = parseToolCallArgs(argsString);
|
||||
if (ops.length === 0) {
|
||||
let parseHint = '';
|
||||
try {
|
||||
const rawParsed = JSON.parse(argsString);
|
||||
const rawOpsLen = rawParsed?.operations?.length ?? 0;
|
||||
if (rawOpsLen > 0) parseHint = `(响应含 ${rawOpsLen} 条操作,但全部未通过格式校验)`;
|
||||
} catch {
|
||||
parseHint = '(响应 JSON 解析失败)';
|
||||
}
|
||||
console.warn(`[Amily2-副API] Function Call 返回操作列表为空${parseHint},原始响应:\n${argsString}`);
|
||||
toastr.info('AI 判断此范围无需修改。', 'Amily2-分步填表');
|
||||
await markTargetsProcessed(targetMessages, { skipTableSave: true });
|
||||
} else {
|
||||
// skipPersist:状态由 markTargetsProcessed 存到 E,不写最新楼(同文本路径)
|
||||
await updateTableFromOps(ops, { skipPersist: true });
|
||||
await markTargetsProcessed(targetMessages);
|
||||
toastr.success('分步填表(Function Call)执行完毕。', 'Amily2-分步填表');
|
||||
}
|
||||
} else {
|
||||
console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...');
|
||||
rawContent = await callAI(messages, { slot: 'tableFilling' });
|
||||
// Legacy 文本路径
|
||||
let rawContent;
|
||||
if (settings.nccsEnabled) {
|
||||
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
|
||||
rawContent = await callNccsAI(messages, { signal });
|
||||
} else {
|
||||
console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...');
|
||||
rawContent = await callAI(messages, { slot: 'tableFilling', signal });
|
||||
}
|
||||
|
||||
if (!rawContent) {
|
||||
console.error('[Amily2-副API] 未能获取AI响应内容。');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Amily2号-副API-原始回复]:', rawContent);
|
||||
|
||||
if (!rawContent.includes('<Amily2Edit>')) {
|
||||
const rangeLabel = `${targetMessages[0].index + 1} - ${targetMessages[targetMessages.length - 1].index + 1}`;
|
||||
console.warn(`[Amily2-副API] 响应未包含 <Amily2Edit> 指令块(楼层 ${rangeLabel}),弹出检查窗口等待用户处理。`);
|
||||
toastr.warning(`分步填表(楼层 ${rangeLabel})的响应缺少 <Amily2Edit> 指令块,请在弹窗中处理。`, 'Amily2-分步填表');
|
||||
if (latestMessage && latestMessage.extra) {
|
||||
delete latestMessage.extra.amily2_retry_count;
|
||||
}
|
||||
showTableFillReviewModal(rawContent, {
|
||||
title: `分步填表响应检查 - 楼层 ${rangeLabel}`,
|
||||
subtitle: `分步填表(楼层 ${rangeLabel})的 AI 响应未包含有效的 <Amily2Edit> 指令块。请检查原始响应并选择处理方式。`,
|
||||
onContinue: async (currentText) => {
|
||||
const merged = await requestSecondaryContinuation(messages, currentText);
|
||||
if (!merged) { toastr.error('补全请求失败或返回为空。', '继续补全'); return null; }
|
||||
if (!merged.includes('<Amily2Edit>')) {
|
||||
toastr.warning('补全后仍未包含 <Amily2Edit> 指令块,可继续补全、手动应用或重新填表。', '继续补全');
|
||||
} else {
|
||||
toastr.success('已获得包含指令块的补全内容,可点击”手动应用”写入。', '继续补全');
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
onApply: async (editedText) => {
|
||||
if (!editedText || !editedText.includes('<Amily2Edit>')) {
|
||||
toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用');
|
||||
}
|
||||
try {
|
||||
await commitSecondaryFillResult(editedText, targetMessages);
|
||||
toastr.success('分步填表已由用户手动处理完成。', 'Amily2-分步填表');
|
||||
} catch (err) {
|
||||
console.error('[Amily2-副API] 手动应用失败:', err);
|
||||
toastr.error(`手动应用失败: ${err.message}`, '写入异常');
|
||||
}
|
||||
},
|
||||
onRetry: () => {
|
||||
if (latestMessage && latestMessage.extra) {
|
||||
delete latestMessage.extra.amily2_retry_count;
|
||||
}
|
||||
toastr.info('将重新执行分步填表...', 'Amily2-分步填表');
|
||||
setTimeout(() => fillWithSecondaryApi(latestMessage, forceRun, opts), 300);
|
||||
},
|
||||
onCancel: () => {
|
||||
toastr.info('已取消本次分步填表。', 'Amily2-分步填表');
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await commitSecondaryFillResult(rawContent, targetMessages);
|
||||
}
|
||||
|
||||
if (!rawContent) {
|
||||
console.error('[Amily2-副API] 未能获取AI响应内容。');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Amily2号-副API-原始回复]:", rawContent);
|
||||
|
||||
// 【修复】检查 AI 是否返回了有效的指令块,防止 AI 偷懒或格式错误被误判为成功
|
||||
if (!rawContent.includes('<Amily2Edit>')) {
|
||||
throw new Error('AI未返回有效的 <Amily2Edit> 指令块,可能格式错误或未产生实质性变更。');
|
||||
}
|
||||
|
||||
updateTableFromText(rawContent);
|
||||
|
||||
const memoryState = getMemoryState();
|
||||
|
||||
const lastProcessedMsg = targetMessages[targetMessages.length - 1].msg;
|
||||
|
||||
for (const target of targetMessages) {
|
||||
if (!target.msg.metadata) target.msg.metadata = {};
|
||||
target.msg.metadata.Amily2_Process_Hash = target.hash;
|
||||
}
|
||||
|
||||
if (saveStateToMessage(memoryState, lastProcessedMsg)) {
|
||||
renderTables();
|
||||
updateOrInsertTableInChat();
|
||||
}
|
||||
|
||||
saveChat();
|
||||
toastr.success("分步填表执行完毕。", "Amily2-分步填表");
|
||||
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError' || signal.aborted) {
|
||||
console.warn('[Amily2-副API] 分步填表已被用户中断,跳过结果处理与重试。');
|
||||
toastr.info('分步填表已中断。', 'Amily2-分步填表');
|
||||
if (latestMessage && latestMessage.extra) {
|
||||
delete latestMessage.extra.amily2_retry_count;
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error(`[Amily2-副API] 发生严重错误:`, error);
|
||||
|
||||
|
||||
// 【新增】自定义重试逻辑
|
||||
const maxRetries = parseInt(settings.secondary_filler_max_retries || 0, 10);
|
||||
const currentRetryCount = latestMessage?.metadata?.Amily2_Retry_Count || 0;
|
||||
const currentRetryCount = latestMessage?.extra?.amily2_retry_count || 0;
|
||||
|
||||
if (currentRetryCount < maxRetries) {
|
||||
const nextRetryCount = currentRetryCount + 1;
|
||||
console.log(`[Amily2-副API] 准备进行第 ${nextRetryCount}/${maxRetries} 次重试...`);
|
||||
toastr.warning(`副API填表失败: ${error.message}。将在3秒后进行第 ${nextRetryCount} 次重试...`, "自动重试");
|
||||
|
||||
// 记录重试次数到最新消息的 metadata 中,以便跨调用传递状态
|
||||
|
||||
// 记录重试次数到最新消息的 extra 中,以便跨调用传递状态(跟 amily2_tables_data 一起持久化)
|
||||
if (latestMessage) {
|
||||
if (!latestMessage.metadata) latestMessage.metadata = {};
|
||||
latestMessage.metadata.Amily2_Retry_Count = nextRetryCount;
|
||||
if (!latestMessage.extra) latestMessage.extra = {};
|
||||
latestMessage.extra.amily2_retry_count = nextRetryCount;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
fillWithSecondaryApi(latestMessage, forceRun);
|
||||
fillWithSecondaryApi(latestMessage, forceRun, opts);
|
||||
}, 3000);
|
||||
} else {
|
||||
console.log(`[Amily2-副API] 已达到最大重试次数 (${maxRetries}),放弃本次填表。`);
|
||||
toastr.error(`副API填表失败: ${error.message}。已达到最大重试次数,任务终止。`, "严重错误");
|
||||
|
||||
|
||||
// 清除重试计数器
|
||||
if (latestMessage && latestMessage.metadata) {
|
||||
delete latestMessage.metadata.Amily2_Retry_Count;
|
||||
if (latestMessage && latestMessage.extra) {
|
||||
delete latestMessage.extra.amily2_retry_count;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
secondaryFillerRunning = false;
|
||||
currentAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resetSecondaryFillerLock() {
|
||||
const wasLocked = secondaryFillerRunning;
|
||||
if (secondaryFillerDebounceTimer) {
|
||||
clearTimeout(secondaryFillerDebounceTimer);
|
||||
secondaryFillerDebounceTimer = null;
|
||||
}
|
||||
if (currentAbortController) {
|
||||
try { currentAbortController.abort(); } catch {}
|
||||
currentAbortController = null;
|
||||
}
|
||||
secondaryFillerRunning = false;
|
||||
return wasLocked;
|
||||
}
|
||||
|
||||
export function isSecondaryFillerRunning() {
|
||||
return secondaryFillerRunning;
|
||||
}
|
||||
|
||||
export function abortCurrentSecondaryFiller() {
|
||||
if (!secondaryFillerRunning && !currentAbortController) {
|
||||
return false;
|
||||
}
|
||||
if (currentAbortController) {
|
||||
try { currentAbortController.abort(); } catch {}
|
||||
}
|
||||
// 锁的释放由 finally 完成;这里只发出中断信号
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getHistoryContext(messagesToFetch, historyEndIndex, tagsToExtract, exclusionRules) {
|
||||
const context = getContext();
|
||||
const chat = context.chat;
|
||||
|
||||
@@ -158,4 +158,10 @@ export const tableSystemDefaultSettings = {
|
||||
// Nccs API 设置
|
||||
nccsEnabled: false,
|
||||
nccsFakeStreamEnabled: false,
|
||||
|
||||
// Function Call 填表
|
||||
tableFillFunctionCall: false,
|
||||
|
||||
// 批量填表每批楼层数
|
||||
batch_filling_threshold: 30,
|
||||
};
|
||||
|
||||
@@ -393,8 +393,12 @@ class AmilyHelper {
|
||||
keys: entry.key || [],
|
||||
enabled: !entry.disable,
|
||||
constant: entry.constant || false,
|
||||
position: positionMap[entry.position] || 'at_depth_as_system',
|
||||
depth: entry.depth || 998,
|
||||
position: positionMap[entry.position ?? entry.extensions?.position] || 'at_depth_as_system',
|
||||
depth: entry.depth ?? entry.extensions?.depth ?? 998,
|
||||
scanDepth: entry.scanDepth ?? entry.extensions?.scan_depth,
|
||||
order: entry.order ?? entry.extensions?.display_index,
|
||||
exclude_recursion: entry.excludeRecursion ?? entry.extensions?.exclude_recursion ?? false,
|
||||
prevent_recursion: entry.preventRecursion ?? entry.extensions?.prevent_recursion ?? false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[Amily助手] 获取世界书《${bookName}》条目时出错:`, error);
|
||||
@@ -429,13 +433,36 @@ class AmilyHelper {
|
||||
'at_depth': 4,
|
||||
'at_depth_as_system': 4
|
||||
};
|
||||
existingEntry.position = positionMap[entryUpdate.position] ?? 4;
|
||||
const mappedPos = positionMap[entryUpdate.position] ?? 4;
|
||||
existingEntry.position = mappedPos;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.position = mappedPos;
|
||||
}
|
||||
if (entryUpdate.depth !== undefined) {
|
||||
existingEntry.depth = entryUpdate.depth;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.depth = entryUpdate.depth;
|
||||
}
|
||||
if (entryUpdate.scanDepth !== undefined) {
|
||||
existingEntry.scanDepth = entryUpdate.scanDepth;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.scan_depth = entryUpdate.scanDepth;
|
||||
}
|
||||
if (entryUpdate.order !== undefined) {
|
||||
existingEntry.order = entryUpdate.order;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.display_index = entryUpdate.order;
|
||||
}
|
||||
if (entryUpdate.exclude_recursion !== undefined) {
|
||||
existingEntry.excludeRecursion = entryUpdate.exclude_recursion;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.exclude_recursion = entryUpdate.exclude_recursion;
|
||||
}
|
||||
if (entryUpdate.prevent_recursion !== undefined) {
|
||||
existingEntry.preventRecursion = entryUpdate.prevent_recursion;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.prevent_recursion = entryUpdate.prevent_recursion;
|
||||
}
|
||||
if (entryUpdate.depth !== undefined) existingEntry.depth = entryUpdate.depth;
|
||||
if (entryUpdate.scanDepth !== undefined) existingEntry.scanDepth = entryUpdate.scanDepth;
|
||||
if (entryUpdate.order !== undefined) existingEntry.order = entryUpdate.order;
|
||||
if (entryUpdate.exclude_recursion !== undefined) existingEntry.excludeRecursion = entryUpdate.exclude_recursion;
|
||||
if (entryUpdate.prevent_recursion !== undefined) existingEntry.preventRecursion = entryUpdate.prevent_recursion;
|
||||
}
|
||||
}
|
||||
await saveWorldInfo(bookName, bookData, true);
|
||||
@@ -470,19 +497,37 @@ class AmilyHelper {
|
||||
'at_depth': 4,
|
||||
'at_depth_as_system': 4
|
||||
};
|
||||
const mappedPos = typeof newEntryData.position === 'string' ? (positionMap[newEntryData.position] ?? 4) : (newEntryData.position ?? 4);
|
||||
Object.assign(newEntry, {
|
||||
comment: newEntryData.comment || '新条目',
|
||||
content: newEntryData.content || '',
|
||||
key: newEntryData.keys || newEntryData.key || [],
|
||||
constant: newEntryData.type === 'constant' ? true : (newEntryData.constant || false),
|
||||
position: typeof newEntryData.position === 'string' ? (positionMap[newEntryData.position] ?? 4) : (newEntryData.position ?? 4),
|
||||
position: mappedPos,
|
||||
depth: newEntryData.depth ?? 998,
|
||||
scanDepth: newEntryData.scanDepth ?? null,
|
||||
order: newEntryData.order ?? 100,
|
||||
disable: !(newEntryData.enabled ?? true),
|
||||
excludeRecursion: newEntryData.excludeRecursion ?? newEntryData.exclude_recursion ?? false,
|
||||
preventRecursion: newEntryData.preventRecursion ?? newEntryData.prevent_recursion ?? false,
|
||||
});
|
||||
if (newEntryData.type === 'selective') newEntry.constant = false;
|
||||
|
||||
// 兼容新版酒馆的防递归等扩展逻辑 (v1.17.0+)
|
||||
if (!newEntry.extensions) newEntry.extensions = {};
|
||||
newEntry.extensions.position = mappedPos;
|
||||
newEntry.extensions.depth = newEntry.depth;
|
||||
if (newEntry.scanDepth !== null) newEntry.extensions.scan_depth = newEntry.scanDepth;
|
||||
if (newEntryData.order !== undefined) newEntry.extensions.display_index = newEntryData.order;
|
||||
|
||||
const hasExclude = newEntryData.excludeRecursion !== undefined || newEntryData.exclude_recursion !== undefined;
|
||||
const hasPrevent = newEntryData.preventRecursion !== undefined || newEntryData.prevent_recursion !== undefined;
|
||||
if (hasExclude) {
|
||||
newEntry.extensions.exclude_recursion = newEntryData.excludeRecursion ?? newEntryData.exclude_recursion ?? false;
|
||||
}
|
||||
if (hasPrevent) {
|
||||
newEntry.extensions.prevent_recursion = newEntryData.preventRecursion ?? newEntryData.prevent_recursion ?? false;
|
||||
}
|
||||
}
|
||||
await saveWorldInfo(bookName, bookData, true);
|
||||
reloadEditor(bookName);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { handleFileUpload, processNovel } from './index.js';
|
||||
import { reorganizeEntriesByHeadings, loadDatabaseFiles } from './executor.js';
|
||||
import { SETTINGS_KEY as PRESET_SETTINGS_KEY } from '../PresetSettings/config.js';
|
||||
import { escapeHTML } from '../utils/utils.js';
|
||||
import { watchProfileSliderGuard } from '../ui/profile-slider-guard.js';
|
||||
|
||||
const moduleState = {
|
||||
selectedWorldBook: '',
|
||||
@@ -669,6 +670,8 @@ export function bindGlossaryEvents() {
|
||||
|
||||
loadSettingsToUI();
|
||||
bindAutoSaveEvents();
|
||||
// sybd 槽分配 profile 后,温度/maxTokens 由 profile 权威控制(T-006 informational 化)
|
||||
watchProfileSliderGuard('sybd', ['#amily2_sybd_max_tokens', '#amily2_sybd_temperature']);
|
||||
bindManualActionEvents();
|
||||
bindTabEvents();
|
||||
bindNovelProcessEvents();
|
||||
|
||||
@@ -23,10 +23,11 @@ export { characters, this_chid, eventSource, event_types, saveSettingsDebounced
|
||||
|
||||
// Core Systems
|
||||
export { injectTableData, generateTableContent } from "./core/table-system/injector.js";
|
||||
export { injectProgressiveMemory, clearProgressiveMemoryInjection } from "./core/progressive-memory/engine.js";
|
||||
export { initialize as initializeRagProcessor } from "./core/rag-processor.js";
|
||||
export { loadSettingsToUI as loadHanlinyuanSettingsToUI } from "./ui/hanlinyuan-bindings.js";
|
||||
export { loadTables, clearHighlights, rollbackAndRefill, rollbackState, commitPendingDeletions, saveStateToMessage, getMemoryState, clearUpdatedTables } from './core/table-system/manager.js';
|
||||
export { fillWithSecondaryApi } from './core/table-system/secondary-filler.js';
|
||||
export { fillWithSecondaryApi, resetSecondaryFillerLock, isSecondaryFillerRunning, abortCurrentSecondaryFiller } from './core/table-system/secondary-filler.js';
|
||||
export { renderTables } from './ui/table-bindings.js';
|
||||
export { log } from './core/table-system/logger.js';
|
||||
export { checkForUpdates, fetchMessageBoardContent } from './core/api.js';
|
||||
|
||||
9
index.js
9
index.js
@@ -7,6 +7,7 @@ import {
|
||||
getContext, extension_settings,
|
||||
characters, this_chid, eventSource, event_types, saveSettingsDebounced,
|
||||
injectTableData, generateTableContent,
|
||||
injectProgressiveMemory,
|
||||
initializeRagProcessor,
|
||||
loadHanlinyuanSettingsToUI,
|
||||
loadTables, clearHighlights, rollbackAndRefill, rollbackState, commitPendingDeletions, saveStateToMessage, getMemoryState, clearUpdatedTables,
|
||||
@@ -697,7 +698,7 @@ function registerEventListeners() {
|
||||
log(`【监察系统】主填表模式,回退后强制刷新消息ID: ${chat_id}。`, 'info');
|
||||
await handleTableUpdate(chat_id, true);
|
||||
} else if (fillingMode === 'secondary-api' || fillingMode === 'optimized') {
|
||||
log('【监察系统】分步/优化模式,回退后强制二次填表最新消息。', 'info');
|
||||
log('【监察系统】分步/优化模式,回退后触发二次填表扫描(受保留缓冲区限制)。', 'info');
|
||||
await fillWithSecondaryApi(latestMessage, true);
|
||||
} else {
|
||||
log('【监察系统】未配置填表模式,跳过填表。', 'info');
|
||||
@@ -751,6 +752,12 @@ async function executeAmily2Injection(...args) {
|
||||
} catch (error) {
|
||||
console.error('[Amily2-内存储司] 表格注入失败:', error);
|
||||
}
|
||||
try {
|
||||
// 渐进记忆(内测,type2 门槛由引擎内部判定);args[3] = type('quiet' 时跳过)
|
||||
injectProgressiveMemory(args[3]);
|
||||
} catch (error) {
|
||||
console.error('[Amily2-渐进记忆] 注入失败:', error);
|
||||
}
|
||||
if (window.hanlinyuanRagProcessor && typeof window.hanlinyuanRagProcessor.rearrangeChat === 'function') {
|
||||
try {
|
||||
console.log('[Amily2-核心引擎] 执行内置RAG注入。');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Amily2号聊天优化助手",
|
||||
"display_name": "Amily2号助手",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.7",
|
||||
"author": "Wx-2025",
|
||||
"description": "一个拥有独立UI的智能引擎,正文优化、自动总结、记忆表格、rag向量、隐藏楼层、剧情推进等多功能整合。",
|
||||
"minSillyTavernVersion": "1.10.0",
|
||||
|
||||
@@ -10,8 +10,10 @@ import { setAvailableModels, populateModelDropdown, getLatestUpdateInfo } from "
|
||||
import { fixCommand, testReplyChecker } from "../core/commands.js";
|
||||
import { messageFormatting } from '/script.js';
|
||||
import { executeManualCommand } from '../core/autoHideManager.js';
|
||||
import { showContentModal, showHtmlModal } from './page-window.js';
|
||||
import { showContentModal, showHtmlModal, showCwbWarningModal } from './page-window.js';
|
||||
import { openAutoCharCardWindow } from '../core/auto-char-card/ui-bindings.js';
|
||||
import { showPresetSettings } from '../PresetSettings/prese_ui.js';
|
||||
import { watchProfileSliderGuard } from './profile-slider-guard.js';
|
||||
|
||||
function displayDailyAuthCode() {
|
||||
const displayEl = document.getElementById('amily2_daily_code_display');
|
||||
@@ -806,7 +808,7 @@ export function bindModalEvents() {
|
||||
container
|
||||
.off("click.amily2.chamber_nav")
|
||||
.on("click.amily2.chamber_nav",
|
||||
"#amily2_open_text_optimization, #amily2_open_plot_optimization, #amily2_open_additional_features, #amily2_open_rag_palace, #amily2_open_memorisation_forms, #amily2_open_character_world_book, #amily2_open_world_editor, #amily2_open_glossary, #amily2_open_renderer, #amily2_open_super_memory, #amily2_open_auto_char_card, #amily2_open_api_config, #amily2_open_rule_config, #amily2_open_sfigen, #amily2_back_to_main_settings, #amily2_back_to_main_from_hanlinyuan, #amily2_back_to_main_from_forms, #amily2_back_to_main_from_optimization, #amily2_back_to_main_from_text_optimization, #amily2_back_to_main_from_cwb, #amily2_back_to_main_from_world_editor, #amily2_back_to_main_from_glossary, #amily2_renderer_back_button, #amily2_back_to_main_from_super_memory, #amily2_back_to_main_from_api_config, #amily2_back_to_main_from_rule_config, #amily2_sfigen_back_to_main", function () {
|
||||
"#amily2_open_text_optimization, #amily2_open_plot_optimization, #amily2_open_additional_features, #amily2_open_rag_palace, #amily2_open_memorisation_forms, #amily2_open_character_world_book, #amily2_open_world_editor, #amily2_open_glossary, #amily2_open_renderer, #amily2_open_super_memory, #amily2_open_progressive_memory, #amily2_open_auto_char_card, #amily2_open_api_config, #amily2_open_rule_config, #amily2_open_sfigen, #amily2_open_preset_editor, #amily2_back_to_main_settings, #amily2_back_to_main_from_hanlinyuan, #amily2_back_to_main_from_forms, #amily2_back_to_main_from_optimization, #amily2_back_to_main_from_text_optimization, #amily2_back_to_main_from_cwb, #amily2_back_to_main_from_world_editor, #amily2_back_to_main_from_glossary, #amily2_renderer_back_button, #amily2_back_to_main_from_super_memory, #amily2_back_to_main_from_progressive_memory, #amily2_back_to_main_from_api_config, #amily2_back_to_main_from_rule_config, #amily2_sfigen_back_to_main", function () {
|
||||
if (!pluginAuthStatus.authorized) return;
|
||||
|
||||
const mainPanel = container.find('.plugin-features');
|
||||
@@ -820,6 +822,7 @@ export function bindModalEvents() {
|
||||
const glossaryPanel = container.find('#amily2_glossary_panel');
|
||||
const rendererPanel = container.find('#amily2_renderer_panel');
|
||||
const superMemoryPanel = container.find('#amily2_super_memory_panel');
|
||||
const progressiveMemoryPanel = container.find('#amily2_progressive_memory_panel');
|
||||
const apiConfigPanel = container.find('#amily2_api_config_panel');
|
||||
const ruleConfigPanel = container.find('#amily2_rule_config_panel');
|
||||
const sfigenPanel = container.find('#amily2_sfigen_panel');
|
||||
@@ -835,6 +838,7 @@ export function bindModalEvents() {
|
||||
glossaryPanel.hide();
|
||||
rendererPanel.hide();
|
||||
superMemoryPanel.hide();
|
||||
progressiveMemoryPanel.hide();
|
||||
apiConfigPanel.hide();
|
||||
ruleConfigPanel.hide();
|
||||
sfigenPanel.hide();
|
||||
@@ -852,6 +856,16 @@ export function bindModalEvents() {
|
||||
}
|
||||
superMemoryPanel.show();
|
||||
break;
|
||||
case 'amily2_open_progressive_memory': {
|
||||
const pmUserType = parseInt(localStorage.getItem("plugin_user_type") || "0");
|
||||
if (pmUserType < 3) {
|
||||
toastr.info("该功能正在开发中,将于未来版本开放,敬请期待。", "开发中功能");
|
||||
mainPanel.show();
|
||||
return;
|
||||
}
|
||||
progressiveMemoryPanel.show();
|
||||
break;
|
||||
}
|
||||
case 'amily2_open_auto_char_card':
|
||||
openAutoCharCardWindow();
|
||||
// 自动构建器是独立窗口,不需要隐藏主面板,或者根据需求决定
|
||||
@@ -874,7 +888,10 @@ export function bindModalEvents() {
|
||||
memorisationFormsPanel.show();
|
||||
break;
|
||||
case 'amily2_open_character_world_book':
|
||||
characterWorldBookPanel.show();
|
||||
showCwbWarningModal(
|
||||
() => characterWorldBookPanel.show(),
|
||||
() => mainPanel.show()
|
||||
);
|
||||
break;
|
||||
case 'amily2_open_world_editor':
|
||||
worldEditorPanel.show();
|
||||
@@ -891,6 +908,10 @@ export function bindModalEvents() {
|
||||
case 'amily2_open_sfigen':
|
||||
sfigenPanel.show();
|
||||
break;
|
||||
case 'amily2_open_preset_editor':
|
||||
showPresetSettings();
|
||||
mainPanel.show();
|
||||
return;
|
||||
case 'amily2_back_to_main_settings':
|
||||
case 'amily2_back_to_main_from_hanlinyuan':
|
||||
case 'amily2_back_to_main_from_forms':
|
||||
@@ -901,6 +922,7 @@ export function bindModalEvents() {
|
||||
case 'amily2_back_to_main_from_glossary':
|
||||
case 'amily2_renderer_back_button':
|
||||
case 'amily2_back_to_main_from_super_memory':
|
||||
case 'amily2_back_to_main_from_progressive_memory':
|
||||
case 'amily2_back_to_main_from_api_config':
|
||||
case 'amily2_back_to_main_from_rule_config':
|
||||
case 'amily2_sfigen_back_to_main':
|
||||
@@ -1111,6 +1133,9 @@ export function bindModalEvents() {
|
||||
},
|
||||
);
|
||||
|
||||
// main 槽分配 profile 后,这两个参数由 profile 权威控制(T-006 informational 化)
|
||||
watchProfileSliderGuard('main', ['#amily2_max_tokens', '#amily2_temperature']);
|
||||
|
||||
const promptMap = {
|
||||
mainPrompt: "#amily2_main_prompt",
|
||||
systemPrompt: "#amily2_system_prompt",
|
||||
|
||||
@@ -77,16 +77,6 @@ function updateAndSaveSetting(key, value) {
|
||||
|
||||
HanlinyuanCore.saveSettings();
|
||||
|
||||
if (key === 'condensation.tagExtractionEnabled') {
|
||||
syncHanlinLinkedRuleProfile('condensation', { tagExtractionEnabled: value });
|
||||
} else if (key === 'condensation.tags') {
|
||||
syncHanlinLinkedRuleProfile('condensation', { tags: value });
|
||||
} else if (key === 'queryPreprocessing.tagExtractionEnabled') {
|
||||
syncHanlinLinkedRuleProfile('queryPreprocessing', { tagExtractionEnabled: value });
|
||||
} else if (key === 'queryPreprocessing.tags') {
|
||||
syncHanlinLinkedRuleProfile('queryPreprocessing', { tags: value });
|
||||
}
|
||||
|
||||
log(`[自动保存] 设置项 '${key}' 已更新为: ${JSON.stringify(value)}`, 'success');
|
||||
}
|
||||
|
||||
@@ -390,15 +380,7 @@ function bindInternalUIEvents() {
|
||||
}
|
||||
|
||||
// 注入设置的UI逻辑已由 initializeUnifiedInjectionEditor 函数统一处理。
|
||||
|
||||
// 【新增】为“标签提取”复选框绑定事件
|
||||
const tagExtractionToggle = document.getElementById('hly-tag-extraction-toggle');
|
||||
const tagInputContainer = document.getElementById('hly-tag-input-container');
|
||||
if (tagExtractionToggle && tagInputContainer) {
|
||||
tagExtractionToggle.addEventListener('change', () => {
|
||||
tagInputContainer.style.display = tagExtractionToggle.checked ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
// 标签提取开关/输入框已在 2.1.0 重构中移除,改为规则配置下拉选单管理。
|
||||
|
||||
// 为“书库选择”下拉框绑定联动事件
|
||||
const librarySelect = document.getElementById('hly-hist-select-library');
|
||||
@@ -664,20 +646,12 @@ export function loadSettingsToUI() {
|
||||
histMaxRetriesEl.value = settings.historiographyMaxRetries ?? 2;
|
||||
}
|
||||
|
||||
// 注:hly-tag-extraction-toggle / hly-tag-input / hly-tag-input-container 已从 HTML 移除,
|
||||
// 标签提取规则改由 RuleProfileManager 管理。此处保留兼容性 null 检查,避免抛错吞掉后续段落加载。
|
||||
const tagExtractionToggle = document.getElementById('hly-tag-extraction-toggle');
|
||||
const tagInput = document.getElementById('hly-tag-input');
|
||||
const tagInputContainer = document.getElementById('hly-tag-input-container');
|
||||
|
||||
if (tagExtractionToggle) tagExtractionToggle.checked = settings.condensation.tagExtractionEnabled;
|
||||
if (tagInput) tagInput.value = settings.condensation.tags;
|
||||
if (tagInputContainer && tagExtractionToggle) {
|
||||
tagInputContainer.style.display = tagExtractionToggle.checked ? 'block' : 'none';
|
||||
}
|
||||
// 标签提取开关/输入框已在 2.1.0 重构中移除(改为规则配置下拉选单),
|
||||
// 这里不再回填对应 DOM,避免因元素已不存在导致 loadSettingsToUI 中断。
|
||||
|
||||
// Rerank 设置
|
||||
document.getElementById('hly-rerank-enabled').checked = settings.rerank.enabled;
|
||||
/** @type {HTMLSelectElement} */ (document.getElementById('hly-rerank-api-mode')).value = settings.rerank.apiMode ?? 'custom';
|
||||
document.getElementById('hly-rerank-url').value = settings.rerank.url;
|
||||
document.getElementById('hly-rerank-api-key').value = settings.rerank.apiKey;
|
||||
const rerankModelSelect = document.getElementById('hly-rerank-model');
|
||||
@@ -701,7 +675,7 @@ export function loadSettingsToUI() {
|
||||
|
||||
const sources = ['novel', 'chat_history', 'lorebook', 'manual'];
|
||||
sources.forEach(source => {
|
||||
const sourceSettings = prioritySettings.sources[source];
|
||||
const sourceSettings = prioritySettings.sources?.[source];
|
||||
if (sourceSettings) {
|
||||
const enabledCheckbox = document.querySelector(`[data-setting-key="rerank.priorityRetrieval.sources.${source}.enabled"]`);
|
||||
const countInput = document.querySelector(`[data-setting-key="rerank.priorityRetrieval.sources.${source}.count"]`);
|
||||
@@ -732,6 +706,11 @@ function saveSettingsFromUI(isAutoSave = true) {
|
||||
const key = target.dataset.settingKey;
|
||||
if (!key) return;
|
||||
|
||||
// 被 profile-sync 接管的字段(祖先元素带 data-profile-hidden)会被填充
|
||||
// MASKED_KEY 占位符并隐藏,若一并写回会污染 settings.{rerank,retrieval}.apiKey
|
||||
// 等字段为 '••••••••',导致取消 Profile 分配后实际请求带占位符 token 被 401。
|
||||
if (target.closest('[data-profile-hidden]')) return;
|
||||
|
||||
let value;
|
||||
const type = target.dataset.type || 'string';
|
||||
|
||||
@@ -1036,10 +1015,15 @@ function _createKbItemElement(id, kb, scope, vectorCount) {
|
||||
? `<button class="hly-kb-move-btn" title="上移到全局"><i class="fas fa-arrow-up"></i></button>`
|
||||
: `<button class="hly-kb-move-btn" title="下移到局部"><i class="fas fa-arrow-down"></i></button>`;
|
||||
|
||||
// 聊天级库(独立聊天记忆产物)标记:仅所属聊天可检索
|
||||
const chatBadgeHtml = kb.chatId
|
||||
? `<span title="聊天专属记忆,仅在聊天 ${escapeAttribute(kb.chatId)} 中可被检索" style="font-size: 0.8em; padding: 0 5px; border-radius: 3px; background: rgba(88,166,255,0.25); margin-left: 4px; white-space: nowrap;"><i class="fas fa-comment"></i> 聊天级</span>`
|
||||
: '';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="hly-kb-name-container">
|
||||
<input type="checkbox" class="hly-kb-item-checkbox" data-kb-id="${escapeAttribute(id)}">
|
||||
<span class="hly-kb-name" title="ID: ${escapeAttribute(id)}">${escapeTextareaContent(kb.name || '')} (${Number(vectorCount) || 0}条)</span>
|
||||
<span class="hly-kb-name" title="ID: ${escapeAttribute(id)}">${escapeTextareaContent(kb.name || '')} (${Number(vectorCount) || 0}条)</span>${chatBadgeHtml}
|
||||
</div>
|
||||
<div class="hly-kb-actions">
|
||||
${moveButtonHtml}
|
||||
|
||||
@@ -161,9 +161,151 @@ export function showSummaryModal(summaryText, callbacks) {
|
||||
regenerateButton.on('click', () => {
|
||||
if (onRegenerate) {
|
||||
dialogElement[0].close();
|
||||
onRegenerate(dialogElement);
|
||||
onRegenerate(dialogElement);
|
||||
}
|
||||
});
|
||||
|
||||
dialogElement.find('.popup-controls').prepend(regenerateButton);
|
||||
}
|
||||
|
||||
|
||||
export function showTableFillReviewModal(rawResponse, callbacks = {}) {
|
||||
const {
|
||||
title = '填表响应检查',
|
||||
subtitle = 'AI未返回有效的 <Amily2Edit> 指令块。您可以在下方查看/编辑原始响应,并选择后续处理方式。',
|
||||
onApply,
|
||||
onContinue,
|
||||
onRetry,
|
||||
onCancel,
|
||||
} = callbacks;
|
||||
|
||||
const modalHtml = `
|
||||
<div class="amily2-fill-review-modal">
|
||||
<div class="notes" style="margin-bottom: 10px; color: #ffb74d; line-height: 1.6;">
|
||||
<i class="fas fa-exclamation-triangle"></i> ${escapeHtml(subtitle)}
|
||||
</div>
|
||||
<textarea class="text_pole amily2-fill-review-text"
|
||||
style="width: 100%; height: 45vh; resize: vertical; font-family: var(--monoFontFamily, monospace); font-size: 12px; white-space: pre; overflow-wrap: normal; overflow-x: auto;"
|
||||
>${escapeHtml(rawResponse || '')}</textarea>
|
||||
<div class="notes" style="margin-top: 8px; font-size: 0.85em; opacity: 0.8; line-height: 1.6;">
|
||||
<div><b>继续补全</b>:让 AI 基于当前文本继续生成剩余内容,结果会追加到文本框后。</div>
|
||||
<div><b>重新填表</b>:舍弃当前响应并重新向 AI 请求同一批次的填表。</div>
|
||||
<div><b>手动应用</b>:将文本框中的当前内容直接作为最终结果写入表格(跳过格式校验)。</div>
|
||||
<div><b>取消</b>:放弃本次填表,任务暂停。</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const dialogElement = showHtmlModal(title, modalHtml, {
|
||||
okText: '手动应用',
|
||||
cancelText: '取消',
|
||||
showCancel: true,
|
||||
onOk: (dialog) => {
|
||||
const editedText = dialog.find('.amily2-fill-review-text').val();
|
||||
if (onApply) {
|
||||
onApply(editedText);
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const textarea = dialogElement.find('.amily2-fill-review-text');
|
||||
|
||||
if (typeof onContinue === 'function') {
|
||||
const continueButton = $('<button class="menu_button interactable" style="margin-right: auto;"><i class="fas fa-forward"></i> 继续补全</button>');
|
||||
continueButton.on('click', async () => {
|
||||
const currentText = textarea.val();
|
||||
textarea.prop('disabled', true);
|
||||
continueButton.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 正在请求补全...');
|
||||
try {
|
||||
const continued = await onContinue(currentText);
|
||||
if (typeof continued === 'string' && continued.length > 0) {
|
||||
textarea.val(continued);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Amily2 填表检查] 补全请求失败:', err);
|
||||
if (window.toastr) toastr.error(`补全失败: ${err.message || err}`, '继续补全');
|
||||
} finally {
|
||||
textarea.prop('disabled', false);
|
||||
continueButton.prop('disabled', false).html('<i class="fas fa-forward"></i> 继续补全');
|
||||
}
|
||||
});
|
||||
dialogElement.find('.popup-controls').prepend(continueButton);
|
||||
}
|
||||
|
||||
if (typeof onRetry === 'function') {
|
||||
const retryButton = $('<button class="menu_button secondary interactable"><i class="fas fa-redo"></i> 重新填表</button>');
|
||||
retryButton.on('click', () => {
|
||||
dialogElement[0].close();
|
||||
dialogElement.remove();
|
||||
onRetry();
|
||||
});
|
||||
const okBtn = dialogElement.find('.popup-button-ok');
|
||||
if (okBtn.length) {
|
||||
retryButton.insertBefore(okBtn);
|
||||
} else {
|
||||
dialogElement.find('.popup-controls').append(retryButton);
|
||||
}
|
||||
}
|
||||
|
||||
return dialogElement;
|
||||
}
|
||||
|
||||
const CWB_WARNING_COUNTDOWN = 10;
|
||||
|
||||
/**
|
||||
* 角色世界书入口警告弹窗,强制倒计时后才可继续。
|
||||
* @param {Function} onProceed - 用户点击"继续使用"时的回调
|
||||
* @param {Function} onClose - 用户点击"关闭退出"时的回调(含弹窗关闭前直接离开)
|
||||
*/
|
||||
export function showCwbWarningModal(onProceed, onClose) {
|
||||
const dialogHtml = `
|
||||
<dialog class="popup wide_dialogue_popup">
|
||||
<div class="popup-body">
|
||||
<h3 style="margin-top:0; color:#e8a838; border-bottom:1px solid rgba(255,255,255,0.2); padding-bottom:10px;">
|
||||
<i class="fas fa-exclamation-triangle" style="color:#e8a838;"></i> 注意 — 角色世界书功能维护状态
|
||||
</h3>
|
||||
<div style="line-height:1.8; padding:12px 4px; color:var(--SmartThemeBodyColor);">
|
||||
该功能长期未进行维护且其实现可被表格及其他功能替代,若非必须一般不建议使用,如确认希望使用,请明确该功能无法获得有效技术支持。
|
||||
</div>
|
||||
<div class="popup-controls" style="gap:8px;">
|
||||
<button class="cwb-warning-close menu_button secondary interactable">关闭退出</button>
|
||||
<button class="cwb-warning-proceed menu_button menu_button_primary interactable" disabled>
|
||||
继续使用(<span class="cwb-countdown">${CWB_WARNING_COUNTDOWN}</span>)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>`;
|
||||
|
||||
const $dialog = $(dialogHtml).appendTo('body');
|
||||
|
||||
const close = (cb) => {
|
||||
clearInterval(timer);
|
||||
$dialog[0].close();
|
||||
$dialog.remove();
|
||||
cb?.();
|
||||
};
|
||||
|
||||
$dialog.find('.cwb-warning-close').on('click', () => close(onClose));
|
||||
|
||||
$dialog.find('.cwb-warning-proceed').on('click', function () {
|
||||
if (!this.disabled) close(onProceed);
|
||||
});
|
||||
|
||||
let remaining = CWB_WARNING_COUNTDOWN;
|
||||
const timer = setInterval(() => {
|
||||
remaining -= 1;
|
||||
$dialog.find('.cwb-countdown').text(remaining);
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timer);
|
||||
const $btn = $dialog.find('.cwb-warning-proceed');
|
||||
$btn.prop('disabled', false).html('继续使用');
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
$dialog[0].showModal();
|
||||
}
|
||||
|
||||
@@ -8,13 +8,24 @@
|
||||
import { extension_settings, getContext } from "/scripts/extensions.js";
|
||||
import { characters, this_chid, getRequestHeaders, saveSettingsDebounced, eventSource, event_types } from "/script.js";
|
||||
import { defaultSettings, extensionName } from "../utils/settings.js";
|
||||
import { testJqyhApiConnection, fetchJqyhModels } from '../core/api/JqyhApi.js';
|
||||
import { testConcurrentApiConnection, fetchConcurrentModels } from '../core/api/ConcurrentApi.js';
|
||||
import { safeLorebooks, safeCharLorebooks, safeLorebookEntries } from "../core/tavernhelper-compatibility.js";
|
||||
import { createDrawer } from '../ui/drawer.js';
|
||||
import { pluginAuthStatus } from "../utils/auth.js";
|
||||
import { configManager } from '../utils/config/ConfigManager.js';
|
||||
import { SENSITIVE_KEYS } from '../utils/config/sensitive-keys.js';
|
||||
import { showHtmlModal } from './page-window.js';
|
||||
import { escapeHTML } from '../utils/utils.js';
|
||||
import { SLOTS } from '../utils/config/ApiProfileManager.js';
|
||||
import {
|
||||
listCustomBlocks,
|
||||
getCustomBlock,
|
||||
addCustomBlock,
|
||||
updateCustomBlock,
|
||||
deleteCustomBlock,
|
||||
syncCustomBlocksFromSettings,
|
||||
} from '../core/memory-blocks/index.js';
|
||||
import { watchProfileSliderGuard } from './profile-slider-guard.js';
|
||||
|
||||
// ========== Prompt Cache (module-level state) ==========
|
||||
|
||||
@@ -45,30 +56,6 @@ function opt_toCamelCase(str) {
|
||||
return str.replace(/[-_]([a-z])/g, (g) => g[1].toUpperCase());
|
||||
}
|
||||
|
||||
function opt_updateApiUrlVisibility(panel, apiMode) {
|
||||
const customApiSettings = panel.find('#amily2_opt_custom_api_settings_block');
|
||||
const tavernProfileSettings = panel.find('#amily2_opt_tavern_api_profile_block');
|
||||
const apiUrlInput = panel.find('#amily2_opt_api_url');
|
||||
|
||||
customApiSettings.hide();
|
||||
tavernProfileSettings.hide();
|
||||
|
||||
if (apiMode === 'tavern') {
|
||||
tavernProfileSettings.show();
|
||||
} else {
|
||||
customApiSettings.show();
|
||||
if (apiMode === 'google') {
|
||||
panel.find('#amily2_opt_api_url_block').hide();
|
||||
const googleUrl = 'https://generativelanguage.googleapis.com';
|
||||
if (apiUrlInput.val() !== googleUrl) {
|
||||
apiUrlInput.val(googleUrl).attr('type', 'text').trigger('change');
|
||||
}
|
||||
} else {
|
||||
panel.find('#amily2_opt_api_url_block').show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function opt_updateWorldbookSourceVisibility(panel, source) {
|
||||
const manualSelectionWrapper = panel.find('#amily2_opt_worldbook_select_wrapper');
|
||||
if (source === 'manual') {
|
||||
@@ -85,49 +72,6 @@ function opt_updateWorldbookSourceVisibility(panel, source) {
|
||||
}
|
||||
}
|
||||
|
||||
async function opt_loadTavernApiProfiles(panel) {
|
||||
const select = panel.find('#amily2_opt_tavern_api_profile_select');
|
||||
const apiSettings = opt_getMergedSettings();
|
||||
const currentProfileId = apiSettings.plotOpt_tavernProfile;
|
||||
|
||||
const currentValue = select.val();
|
||||
select.empty().append(new Option('-- 请选择一个酒馆预设 --', ''));
|
||||
|
||||
try {
|
||||
const tavernProfiles = getContext().extensionSettings?.connectionManager?.profiles || [];
|
||||
if (!tavernProfiles || tavernProfiles.length === 0) {
|
||||
select.append($('<option>', { value: '', text: '未找到酒馆预设', disabled: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
let foundCurrentProfile = false;
|
||||
tavernProfiles.forEach(profile => {
|
||||
if (profile.api && profile.preset) {
|
||||
const option = $('<option>', {
|
||||
value: profile.id,
|
||||
text: profile.name || profile.id,
|
||||
selected: profile.id === currentProfileId
|
||||
});
|
||||
select.append(option);
|
||||
if (profile.id === currentProfileId) {
|
||||
foundCurrentProfile = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (currentProfileId && !foundCurrentProfile) {
|
||||
toastr.warning(`之前选择的酒馆预设 "${currentProfileId}" 已不存在,请重新选择。`);
|
||||
opt_saveSetting('tavernProfile', '');
|
||||
} else if (foundCurrentProfile) {
|
||||
select.val(currentProfileId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[${extensionName}] 加载酒馆API预设失败:`, error);
|
||||
toastr.error('无法加载酒馆API预设列表,请查看控制台。');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const opt_characterSpecificSettings = [
|
||||
'plotOpt_worldbookSource',
|
||||
@@ -640,27 +584,9 @@ function opt_loadSettings(panel) {
|
||||
panel.find('#amily2_opt_table_enabled').val(tableEnabledValue);
|
||||
|
||||
panel.find('#amily2_opt_ejs_enabled').prop('checked', settings.plotOpt_ejsEnabled);
|
||||
panel.find(`input[name="amily2_opt_api_mode"][value="${settings.plotOpt_apiMode}"]`).prop('checked', true);
|
||||
panel.find('#amily2_opt_tavern_api_profile_select').val(settings.plotOpt_tavernProfile);
|
||||
panel.find(`input[name="amily2_opt_worldbook_source"][value="${settings.plotOpt_worldbookSource || 'character'}"]`).prop('checked', true);
|
||||
panel.find('#amily2_opt_worldbook_enabled').prop('checked', settings.plotOpt_worldbookEnabled);
|
||||
panel.find('#amily2_opt_new_memory_logic_enabled').prop('checked', settings.plotOpt_newMemoryLogicEnabled);
|
||||
panel.find('#amily2_opt_api_url').val(settings.plotOpt_apiUrl);
|
||||
// plotOpt_apiKey 是敏感字段,从 configManager(localStorage)读取
|
||||
panel.find('#amily2_opt_api_key').val(configManager.get('plotOpt_apiKey') || '');
|
||||
|
||||
const modelInput = panel.find('#amily2_opt_model');
|
||||
const modelSelect = panel.find('#amily2_opt_model_select');
|
||||
|
||||
modelInput.val(settings.plotOpt_model);
|
||||
modelSelect.empty();
|
||||
if (settings.plotOpt_model) {
|
||||
modelSelect.append(new Option(settings.plotOpt_model, settings.plotOpt_model, true, true));
|
||||
} else {
|
||||
modelSelect.append(new Option('<-请先获取模型', '', true, true));
|
||||
}
|
||||
|
||||
syncModelMirror(modelInput.get(0), modelSelect.get(0));
|
||||
panel.find('#amily2_opt_top_p').val(settings.plotOpt_top_p);
|
||||
panel.find('#amily2_opt_presence_penalty').val(settings.plotOpt_presence_penalty);
|
||||
panel.find('#amily2_opt_frequency_penalty').val(settings.plotOpt_frequency_penalty);
|
||||
@@ -690,7 +616,6 @@ function opt_loadSettings(panel) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
opt_updateApiUrlVisibility(panel, settings.plotOpt_apiMode);
|
||||
opt_updateWorldbookSourceVisibility(panel, settings.plotOpt_worldbookSource || 'character');
|
||||
|
||||
opt_bindSlider(panel, '#amily2_opt_top_p', '#amily2_opt_top_p_value');
|
||||
@@ -703,7 +628,6 @@ function opt_loadSettings(panel) {
|
||||
opt_loadWorldbookEntries(panel);
|
||||
});
|
||||
|
||||
opt_loadTavernApiProfiles(panel);
|
||||
}
|
||||
|
||||
|
||||
@@ -713,6 +637,9 @@ function bindConcurrentApiEvents() {
|
||||
|
||||
if (!concurrentToggle || !concurrentContent) return;
|
||||
|
||||
// plotOptConc 槽分配 profile 后,maxTokens 由 profile 权威控制(T-006 informational 化)
|
||||
watchProfileSliderGuard('plotOptConc', ['#amily2_plotOpt_concurrentMaxTokens']);
|
||||
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
|
||||
// Initial Load
|
||||
@@ -1057,6 +984,180 @@ function opt_purgeGarbageKeys() {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 自定义记忆块(memory-blocks Phase 2) ==========
|
||||
|
||||
// 该面板管理的块固定挂在剧情优化流水线;其他 context(如战斗系统)由各自模块注册
|
||||
const MEMORY_BLOCK_CONTEXT = 'plotOptimization';
|
||||
|
||||
function opt_renderCustomBlocks(panel) {
|
||||
const list = panel.find('#amily2_opt_custom_blocks_list');
|
||||
if (list.length === 0) return;
|
||||
|
||||
const blocks = listCustomBlocks(MEMORY_BLOCK_CONTEXT);
|
||||
if (blocks.length === 0) {
|
||||
list.html('<div style="opacity: 0.6; font-style: italic; padding: 4px 0;">尚无自定义块。</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = blocks.map(b => {
|
||||
const typeBadge = b.generator?.type === 'ai_call'
|
||||
? '<span style="font-size: 0.8em; padding: 1px 6px; border-radius: 3px; background: rgba(88,166,255,0.25);">AI 调用</span>'
|
||||
: '<span style="font-size: 0.8em; padding: 1px 6px; border-radius: 3px; background: rgba(120,200,120,0.25);">静态</span>';
|
||||
return `
|
||||
<div class="amily2-custom-block-row" data-id="${escapeHTML(b.id)}" style="display: flex; align-items: center; gap: 8px; padding: 6px 8px; margin-bottom: 4px; background: rgba(0,0,0,0.15); border-radius: 4px;">
|
||||
<input type="checkbox" class="mb-enabled" ${b.enabled !== false ? 'checked' : ''} title="启用/停用此块">
|
||||
<span style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
<b>${escapeHTML(b.name || '(未命名)')}</b>
|
||||
<code style="margin-left: 6px;">${escapeHTML(b.placeholder)}</code>
|
||||
</span>
|
||||
${typeBadge}
|
||||
<button class="menu_button mb-edit" title="编辑" style="padding: 2px 8px;"><i class="fa-solid fa-pen"></i></button>
|
||||
<button class="menu_button mb-delete" title="删除" style="padding: 2px 8px;"><i class="fa-solid fa-trash-alt"></i></button>
|
||||
</div>`;
|
||||
});
|
||||
list.html(rows.join(''));
|
||||
}
|
||||
|
||||
function opt_showCustomBlockModal(panel, blockId) {
|
||||
const existing = blockId ? getCustomBlock(blockId) : null;
|
||||
const gen = existing?.generator || {};
|
||||
const isAiCall = gen.type === 'ai_call';
|
||||
|
||||
// API 槽下拉:仅列出 chat 类型功能槽
|
||||
const slotOptions = Object.entries(SLOTS)
|
||||
.filter(([, def]) => def.type === 'chat')
|
||||
.map(([key, def]) => `<option value="${key}" ${key === (gen.apiSlot || 'main') ? 'selected' : ''}>${escapeHTML(def.label)} (${key})</option>`)
|
||||
.join('');
|
||||
|
||||
const formHtml = `
|
||||
<div class="amily2-mb-form" style="display: flex; flex-direction: column; gap: 12px;">
|
||||
<label>显示名称
|
||||
<input id="mb_name" class="text_pole" type="text" value="${escapeHTML(existing?.name || '')}" placeholder="例如:战况摘要">
|
||||
</label>
|
||||
<label>占位符(在主/拦截提示词中按字面量匹配替换)
|
||||
<input id="mb_placeholder" class="text_pole" type="text" value="${escapeHTML(existing?.placeholder || '')}" placeholder="例如:{{combat_state}} 或 myBlock1">
|
||||
</label>
|
||||
<label>生成方式
|
||||
<select id="mb_type" class="text_pole">
|
||||
<option value="static" ${!isAiCall ? 'selected' : ''}>静态内容</option>
|
||||
<option value="ai_call" ${isAiCall ? 'selected' : ''}>AI 调用</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="mb_static_fields" style="${isAiCall ? 'display: none;' : ''}">
|
||||
<label>静态内容
|
||||
<textarea id="mb_static_value" class="text_pole" rows="4">${escapeHTML(gen.value !== undefined ? String(gen.value) : '')}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div id="mb_ai_fields" style="display: ${isAiCall ? 'flex' : 'none'}; flex-direction: column; gap: 12px;">
|
||||
<label>API 槽(使用该功能槽的连接配置独立请求一次)
|
||||
<select id="mb_api_slot" class="text_pole">${slotOptions}</select>
|
||||
</label>
|
||||
<label>系统提示词(可选)
|
||||
<textarea id="mb_system_prompt" class="text_pole" rows="3">${escapeHTML(gen.systemPrompt || '')}</textarea>
|
||||
</label>
|
||||
<label>用户提示词(必填)
|
||||
<textarea id="mb_prompt_template" class="text_pole" rows="5">${escapeHTML(gen.promptTemplate || '')}</textarea>
|
||||
</label>
|
||||
<label>提取标签(可选,只取回复中 <标签>...</标签> 的内容;标签缺失时回退完整回复)
|
||||
<input id="mb_extract_tag" class="text_pole" type="text" value="${escapeHTML(gen.extractTag || '')}" placeholder="例如:result">
|
||||
</label>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
showHtmlModal(existing ? '编辑记忆块' : '新增记忆块', formHtml, {
|
||||
okText: '保存',
|
||||
onShow: (dialog) => {
|
||||
dialog.find('#mb_type').on('change', function () {
|
||||
const aiMode = $(this).val() === 'ai_call';
|
||||
dialog.find('#mb_static_fields').toggle(!aiMode);
|
||||
dialog.find('#mb_ai_fields').css('display', aiMode ? 'flex' : 'none');
|
||||
});
|
||||
},
|
||||
onOk: (dialog) => {
|
||||
const placeholder = String(dialog.find('#mb_placeholder').val() || '').trim();
|
||||
if (!placeholder) {
|
||||
toastr.warning('占位符不能为空。');
|
||||
return false;
|
||||
}
|
||||
const conflict = listCustomBlocks(MEMORY_BLOCK_CONTEXT)
|
||||
.find(b => b.placeholder === placeholder && b.id !== blockId);
|
||||
if (conflict) {
|
||||
toastr.warning(`占位符 "${placeholder}" 已被块 "${conflict.name || conflict.id}" 占用。`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const type = dialog.find('#mb_type').val();
|
||||
let generator;
|
||||
if (type === 'ai_call') {
|
||||
const promptTemplate = String(dialog.find('#mb_prompt_template').val() || '');
|
||||
if (!promptTemplate.trim()) {
|
||||
toastr.warning('AI 调用块的用户提示词不能为空。');
|
||||
return false;
|
||||
}
|
||||
generator = {
|
||||
type: 'ai_call',
|
||||
apiSlot: dialog.find('#mb_api_slot').val() || 'main',
|
||||
promptTemplate,
|
||||
};
|
||||
const systemPrompt = String(dialog.find('#mb_system_prompt').val() || '');
|
||||
if (systemPrompt.trim()) generator.systemPrompt = systemPrompt;
|
||||
const extractTag = String(dialog.find('#mb_extract_tag').val() || '').trim();
|
||||
if (extractTag) generator.extractTag = extractTag;
|
||||
} else {
|
||||
generator = { type: 'static', value: String(dialog.find('#mb_static_value').val() || '') };
|
||||
}
|
||||
|
||||
const patch = {
|
||||
name: String(dialog.find('#mb_name').val() || '').trim(),
|
||||
placeholder,
|
||||
context: MEMORY_BLOCK_CONTEXT,
|
||||
generator,
|
||||
};
|
||||
|
||||
try {
|
||||
if (existing) {
|
||||
updateCustomBlock(blockId, patch);
|
||||
toastr.success('记忆块已更新。');
|
||||
} else {
|
||||
addCustomBlock(patch);
|
||||
toastr.success('记忆块已创建。');
|
||||
}
|
||||
} catch (error) {
|
||||
toastr.error(`保存失败: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
opt_renderCustomBlocks(panel);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function bindCustomBlockEvents(panel) {
|
||||
// settings → registry 重放一次,确保面板与执行器看到同一份块清单
|
||||
syncCustomBlocksFromSettings();
|
||||
opt_renderCustomBlocks(panel);
|
||||
|
||||
panel.on('click', '#amily2_opt_add_custom_block', () => opt_showCustomBlockModal(panel, null));
|
||||
|
||||
panel.on('click', '.amily2-custom-block-row .mb-edit', function () {
|
||||
opt_showCustomBlockModal(panel, $(this).closest('[data-id]').attr('data-id'));
|
||||
});
|
||||
|
||||
panel.on('click', '.amily2-custom-block-row .mb-delete', function () {
|
||||
const row = $(this).closest('[data-id]');
|
||||
const id = row.attr('data-id');
|
||||
const block = getCustomBlock(id);
|
||||
if (!confirm(`确定删除记忆块 "${block?.name || id}"?`)) return;
|
||||
deleteCustomBlock(id);
|
||||
opt_renderCustomBlocks(panel);
|
||||
toastr.success('记忆块已删除。');
|
||||
});
|
||||
|
||||
panel.on('change', '.amily2-custom-block-row .mb-enabled', function () {
|
||||
const id = $(this).closest('[data-id]').attr('data-id');
|
||||
updateCustomBlock(id, { enabled: this.checked });
|
||||
});
|
||||
}
|
||||
|
||||
export function initializePlotOptimizationBindings() {
|
||||
const panel = $('#amily2_plot_optimization_panel');
|
||||
if (panel.length === 0 || panel.data('events-bound')) {
|
||||
@@ -1167,6 +1268,7 @@ export function initializePlotOptimizationBindings() {
|
||||
});
|
||||
|
||||
opt_loadSettings(panel);
|
||||
bindCustomBlockEvents(panel);
|
||||
bindJqyhApiEvents();
|
||||
bindConcurrentApiEvents();
|
||||
bindConcurrentPromptEvents();
|
||||
@@ -1219,17 +1321,13 @@ export function initializePlotOptimizationBindings() {
|
||||
opt_saveSetting(key, value);
|
||||
}
|
||||
|
||||
if (key === 'plotOpt_api_mode') {
|
||||
opt_updateApiUrlVisibility(panel, value);
|
||||
}
|
||||
|
||||
if (element.name === 'amily2_opt_worldbook_source') {
|
||||
opt_updateWorldbookSourceVisibility(panel, value);
|
||||
opt_loadWorldbookEntries(panel);
|
||||
}
|
||||
};
|
||||
const allInputSelectors = [
|
||||
'input[type="checkbox"]', 'input[type="radio"]', 'select:not(#amily2_opt_model_select)',
|
||||
'input[type="checkbox"]', 'input[type="radio"]', 'select',
|
||||
'input[type="text"]', 'input[type="password"]', 'textarea',
|
||||
'input[type="range"]', 'input[type="number"]'
|
||||
].join(', ');
|
||||
@@ -1238,30 +1336,6 @@ export function initializePlotOptimizationBindings() {
|
||||
handleSettingChange(this);
|
||||
});
|
||||
|
||||
panel.on('input.amily2_opt change.amily2_opt', '#amily2_opt_model', function() {
|
||||
syncModelMirror(
|
||||
panel.find('#amily2_opt_model').get(0),
|
||||
panel.find('#amily2_opt_model_select').get(0)
|
||||
);
|
||||
});
|
||||
|
||||
panel.on('change.amily2_opt', '#amily2_opt_model_select', function() {
|
||||
const selectedModel = $(this).val();
|
||||
if (selectedModel) {
|
||||
panel.find('#amily2_opt_model').val(selectedModel).trigger('change');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
panel.on('click.amily2_opt', '#amily2_opt_refresh_tavern_api_profiles', () => {
|
||||
opt_loadTavernApiProfiles(panel);
|
||||
});
|
||||
|
||||
panel.on('change.amily2_opt', '#amily2_opt_tavern_api_profile_select', function() {
|
||||
const value = $(this).val();
|
||||
opt_saveSetting('tavernProfile', value);
|
||||
});
|
||||
|
||||
|
||||
panel.find('#amily2_opt_import_prompt_presets').on('click', () => panel.find('#amily2_opt_preset_file_input').click());
|
||||
panel.find('#amily2_opt_export_prompt_presets').on('click', () => opt_exportPromptPresets());
|
||||
@@ -1391,220 +1465,9 @@ export function initializePlotOptimizationBindings() {
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Jqyh API 事件绑定函数 ==========
|
||||
// ========== Jqyh API 事件绑定函数(已迁移至 plotOpt 槽位,此处仅保留空壳) ==========
|
||||
function bindJqyhApiEvents() {
|
||||
console.log("[Amily2号-Jqyh工部] 正在绑定Jqyh API事件...");
|
||||
|
||||
const updateAndSaveSetting = (key, value) => {
|
||||
console.log(`[Amily2-Jqyh令] 收到指令: 将 [${key}] 设置为 ->`, value);
|
||||
if (!extension_settings[extensionName]) {
|
||||
extension_settings[extensionName] = {};
|
||||
}
|
||||
extension_settings[extensionName][key] = value;
|
||||
saveSettingsDebounced();
|
||||
console.log(`[Amily2-Jqyh录] [${key}] 的新状态已保存。`);
|
||||
};
|
||||
|
||||
// Jqyh API 开关控制
|
||||
const jqyhToggle = document.getElementById('amily2_jqyh_enabled');
|
||||
const jqyhContent = document.getElementById('amily2_jqyh_content');
|
||||
|
||||
if (jqyhToggle && jqyhContent) {
|
||||
jqyhToggle.checked = extension_settings[extensionName].jqyhEnabled ?? false;
|
||||
jqyhContent.style.display = jqyhToggle.checked ? 'block' : 'none';
|
||||
|
||||
jqyhToggle.addEventListener('change', function() {
|
||||
const isEnabled = this.checked;
|
||||
updateAndSaveSetting('jqyhEnabled', isEnabled);
|
||||
jqyhContent.style.display = isEnabled ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// API模式切换
|
||||
const apiModeSelect = document.getElementById('amily2_jqyh_api_mode');
|
||||
const compatibleConfig = document.getElementById('amily2_jqyh_compatible_config');
|
||||
const presetConfig = document.getElementById('amily2_jqyh_preset_config');
|
||||
|
||||
if (apiModeSelect && compatibleConfig && presetConfig) {
|
||||
apiModeSelect.value = extension_settings[extensionName].jqyhApiMode || 'openai_test';
|
||||
|
||||
const updateConfigVisibility = (mode) => {
|
||||
if (mode === 'sillytavern_preset') {
|
||||
compatibleConfig.style.display = 'none';
|
||||
presetConfig.style.display = 'block';
|
||||
loadJqyhTavernPresets();
|
||||
} else {
|
||||
compatibleConfig.style.display = 'block';
|
||||
presetConfig.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
updateConfigVisibility(apiModeSelect.value);
|
||||
|
||||
apiModeSelect.addEventListener('change', function() {
|
||||
updateAndSaveSetting('jqyhApiMode', this.value);
|
||||
updateConfigVisibility(this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// API配置字段绑定
|
||||
const apiFields = [
|
||||
{ id: 'amily2_jqyh_api_url', key: 'jqyhApiUrl' },
|
||||
{ id: 'amily2_jqyh_api_key', key: 'jqyhApiKey', sensitive: true },
|
||||
{ id: 'amily2_jqyh_model', key: 'jqyhModel' }
|
||||
];
|
||||
|
||||
apiFields.forEach(field => {
|
||||
const element = document.getElementById(field.id);
|
||||
if (element) {
|
||||
// 敏感字段(API Key)从 configManager(localStorage)读取
|
||||
element.value = field.sensitive
|
||||
? (configManager.get(field.key) || '')
|
||||
: (extension_settings[extensionName][field.key] || '');
|
||||
const saveField = function() {
|
||||
if (field.sensitive) {
|
||||
configManager.set(field.key, this.value);
|
||||
} else {
|
||||
updateAndSaveSetting(field.key, this.value);
|
||||
if (field.key === 'jqyhModel') {
|
||||
syncModelMirror(
|
||||
document.getElementById('amily2_jqyh_model'),
|
||||
document.getElementById('amily2_jqyh_model_select')
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
bindInputLikeSave(element, saveField);
|
||||
}
|
||||
});
|
||||
|
||||
// 滑块控件绑定
|
||||
const sliderFields = [
|
||||
{ id: 'amily2_jqyh_max_tokens', key: 'jqyhMaxTokens', defaultValue: 4000 },
|
||||
{ id: 'amily2_jqyh_temperature', key: 'jqyhTemperature', defaultValue: 0.7 }
|
||||
];
|
||||
|
||||
sliderFields.forEach(field => {
|
||||
const slider = document.getElementById(field.id);
|
||||
const display = document.getElementById(field.id + '_value');
|
||||
if (slider && display) {
|
||||
const value = extension_settings[extensionName][field.key] || field.defaultValue;
|
||||
slider.value = value;
|
||||
display.textContent = value;
|
||||
|
||||
slider.addEventListener('input', function() {
|
||||
const newValue = parseFloat(this.value);
|
||||
display.textContent = newValue;
|
||||
updateAndSaveSetting(field.key, newValue);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// SillyTavern预设选择器
|
||||
const tavernProfileSelect = document.getElementById('amily2_jqyh_tavern_profile');
|
||||
if (tavernProfileSelect) {
|
||||
tavernProfileSelect.value = extension_settings[extensionName].jqyhTavernProfile || '';
|
||||
tavernProfileSelect.addEventListener('change', function() {
|
||||
updateAndSaveSetting('jqyhTavernProfile', this.value);
|
||||
});
|
||||
}
|
||||
|
||||
// 测试连接按钮
|
||||
const testButton = document.getElementById('amily2_jqyh_test_connection');
|
||||
if (testButton) {
|
||||
testButton.addEventListener('click', async function() {
|
||||
const button = $(this);
|
||||
const originalHtml = button.html();
|
||||
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 测试中');
|
||||
|
||||
try {
|
||||
await testJqyhApiConnection();
|
||||
} catch (error) {
|
||||
console.error('[Amily2号-Jqyh] 测试连接失败:', error);
|
||||
} finally {
|
||||
button.prop('disabled', false).html(originalHtml);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const fetchModelsButton = document.getElementById('amily2_jqyh_fetch_models');
|
||||
const modelSelect = document.getElementById('amily2_jqyh_model_select');
|
||||
const modelInput = document.getElementById('amily2_jqyh_model');
|
||||
|
||||
if (fetchModelsButton && modelSelect && modelInput) {
|
||||
fetchModelsButton.addEventListener('click', async function() {
|
||||
const button = $(this);
|
||||
const originalHtml = button.html();
|
||||
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 获取中');
|
||||
|
||||
try {
|
||||
const models = await fetchJqyhModels();
|
||||
|
||||
if (models && models.length > 0) {
|
||||
modelSelect.innerHTML = '<option value="">-- 请选择模型 --</option>';
|
||||
models.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.id || model.name || model;
|
||||
option.textContent = model.name || model.id || model;
|
||||
modelSelect.appendChild(option);
|
||||
});
|
||||
modelSelect.style.display = 'block';
|
||||
modelInput.style.display = 'none';
|
||||
|
||||
modelSelect.addEventListener('change', function() {
|
||||
const selectedModel = this.value;
|
||||
modelInput.value = selectedModel;
|
||||
updateAndSaveSetting('jqyhModel', selectedModel);
|
||||
console.log(`[Amily2-Jqyh] 已选择模型: ${selectedModel}`);
|
||||
});
|
||||
|
||||
toastr.success(`成功获取 ${models.length} 个模型`, 'Jqyh 模型获取');
|
||||
} else {
|
||||
toastr.warning('未获取到任何模型', 'Jqyh 模型获取');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Amily2号-Jqyh] 获取模型列表失败:', error);
|
||||
toastr.error(`获取模型失败: ${error.message}`, 'Jqyh 模型获取');
|
||||
} finally {
|
||||
button.prop('disabled', false).html(originalHtml);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJqyhTavernPresets() {
|
||||
const select = document.getElementById('amily2_jqyh_tavern_profile');
|
||||
if (!select) return;
|
||||
|
||||
const currentValue = select.value;
|
||||
select.innerHTML = '<option value="">-- 加载中 --</option>';
|
||||
|
||||
try {
|
||||
const context = getContext();
|
||||
const tavernProfiles = context.extensionSettings?.connectionManager?.profiles || [];
|
||||
|
||||
select.innerHTML = '<option value="">-- 请选择预设 --</option>';
|
||||
|
||||
if (tavernProfiles.length > 0) {
|
||||
tavernProfiles.forEach(profile => {
|
||||
if (profile.api && profile.preset) {
|
||||
const option = document.createElement('option');
|
||||
option.value = profile.id;
|
||||
option.textContent = profile.name || profile.id;
|
||||
if (profile.id === currentValue) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
select.innerHTML = '<option value="">未找到可用预设</option>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Amily2号-Jqyh] 加载SillyTavern预设失败:', error);
|
||||
select.innerHTML = '<option value="">加载失败</option>';
|
||||
}
|
||||
// Jqyh 直连配置已移除,剧情优化统一走 ApiProfile plotOpt 槽位
|
||||
}
|
||||
|
||||
// ========== 图标位置切换(跨模块通用事件) ==========
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -145,7 +145,7 @@ const SLOT_CONFIGS = {
|
||||
ragRerank: {
|
||||
container: '#hly-rerank-tab .hly-settings-group',
|
||||
hideParentBlock: ['#hly-rerank-api-mode', '#hly-rerank-url', '#hly-rerank-api-key', '#hly-rerank-model'],
|
||||
fields: { provider: '#hly-rerank-api-mode', apiUrl: '#hly-rerank-url', model: '#hly-rerank-model' },
|
||||
fields: { apiUrl: '#hly-rerank-url', model: '#hly-rerank-model' },
|
||||
keyField: '#hly-rerank-api-key',
|
||||
testFn: async () => {
|
||||
await executeRagRerank('test', ['test'], null);
|
||||
@@ -210,7 +210,15 @@ function _snapshotLegacyFields(slot, config) {
|
||||
function _fillLegacyFields(config, profile) {
|
||||
for (const [key, sel] of Object.entries(config.fields || {})) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) el.value = profile[key] ?? '';
|
||||
if (!el) continue;
|
||||
const value = profile[key] ?? '';
|
||||
// select 赋不存在的 option 值(如 provider 'custom_oai' 写进只有
|
||||
// custom/google_direct 的 select)会让 value 静默变 '',后续任何
|
||||
// 全量保存会把 '' 污染进 settings——跳过这类赋值,保留原选项
|
||||
if (el.tagName === 'SELECT' && ![...el.options].some(o => o.value === value)) {
|
||||
continue;
|
||||
}
|
||||
el.value = value;
|
||||
}
|
||||
if (config.keyField) {
|
||||
const keyEl = document.querySelector(config.keyField);
|
||||
@@ -284,11 +292,22 @@ function _injectCard(slot, profile, _config, container) {
|
||||
),
|
||||
].join('');
|
||||
|
||||
// Key 是设备本地存储(ApiKeyStore 不跨设备同步),profile 随云端设置同步而来
|
||||
// 时本设备可能没有 Key——明确提示,否则用户只会在调用时收到"Key 未提供"报错
|
||||
const needsKey = profile && !['sillytavern_preset', 'sillytavern_backend'].includes(profile.provider);
|
||||
const keyWarnHtml = (needsKey && !profile.apiKey) ? `
|
||||
<span style="color:var(--warning-color,#e6a23c); font-size:0.85em;"
|
||||
title="API Key 仅保存在最初填写它的设备/浏览器上,不随云端设置同步。请点击「管理」编辑该配置并重新填写 Key。">
|
||||
<i class="fas fa-key"></i> 本设备无 Key
|
||||
</span>
|
||||
` : '';
|
||||
|
||||
const detailHtml = profile ? `
|
||||
<span style="color:var(--SmartThemeQuoteColor); font-size:0.85em;">
|
||||
${providerLabel ? `<i class="fas fa-cloud"></i> ${_esc(providerLabel)}` : ''}
|
||||
${profile.model ? ` · <i class="fas fa-robot"></i> ${_esc(profile.model)}` : ''}
|
||||
</span>
|
||||
${keyWarnHtml}
|
||||
` : `
|
||||
<span style="color:var(--warning-color); font-size:0.85em;">
|
||||
未分配时该模块不会继续展示/保存独立 API 输入项。
|
||||
|
||||
@@ -9,6 +9,7 @@ function createEmptyProfile() {
|
||||
tagExtractionEnabled: false,
|
||||
tags: '',
|
||||
exclusionRules: [],
|
||||
excludeUserMessages: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ function collectProfile(container) {
|
||||
tagExtractionEnabled: container.find('#amily2_rule_profile_tag_toggle').is(':checked'),
|
||||
tags: container.find('#amily2_rule_profile_tags').val(),
|
||||
exclusionRules,
|
||||
excludeUserMessages: container.find('#amily2_rule_profile_exclude_user').is(':checked'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,6 +85,7 @@ function fillEditor(container, profile) {
|
||||
container.find('#amily2_rule_profile_tag_toggle').prop('checked', !!current.tagExtractionEnabled);
|
||||
container.find('#amily2_rule_profile_tags').val(current.tags || '');
|
||||
container.find('#amily2_rule_profile_tags_wrap').toggle(!!current.tagExtractionEnabled);
|
||||
container.find('#amily2_rule_profile_exclude_user').prop('checked', !!current.excludeUserMessages);
|
||||
renderRules(container, current.exclusionRules || []);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { extensionName } from '../utils/settings.js';
|
||||
import { updateOrInsertTableInChat } from './message-table-renderer.js';
|
||||
import { saveSettingsDebounced } from '/script.js';
|
||||
import { startBatchFilling } from '../core/table-system/batch-filler.js';
|
||||
import { resetSecondaryFillerLock, isSecondaryFillerRunning, abortCurrentSecondaryFiller } from '../core/table-system/secondary-filler.js';
|
||||
import { showHtmlModal } from './page-window.js';
|
||||
import { DEFAULT_AI_RULE_TEMPLATE, DEFAULT_AI_FLOW_TEMPLATE } from '../core/table-system/settings.js';
|
||||
import { world_names, loadWorldInfo } from '/scripts/world-info.js';
|
||||
@@ -1370,7 +1371,9 @@ export function bindTableEvents(panelElement = null) {
|
||||
const contextSlider = document.getElementById('secondary-filler-context');
|
||||
const batchSlider = document.getElementById('secondary-filler-batch');
|
||||
const bufferSlider = document.getElementById('secondary-filler-buffer');
|
||||
const maxRetriesSlider = document.getElementById('secondary-filler-max-retries'); // 【新增】
|
||||
const maxRetriesSlider = document.getElementById('secondary-filler-max-retries');
|
||||
const delaySlider = document.getElementById('secondary-filler-delay');
|
||||
const batchFillingThresholdInput = document.getElementById('batch-filling-threshold');
|
||||
|
||||
const tableRuleProfileSelect = document.getElementById('table-rule-profile-select');
|
||||
|
||||
@@ -1438,13 +1441,86 @@ export function bindTableEvents(panelElement = null) {
|
||||
if (maxRetriesSlider) {
|
||||
const value = extension_settings[extensionName]?.secondary_filler_max_retries ?? 2;
|
||||
maxRetriesSlider.value = value;
|
||||
|
||||
|
||||
maxRetriesSlider.addEventListener('change', function() {
|
||||
updateAndSaveTableSetting('secondary_filler_max_retries', parseInt(this.value, 10));
|
||||
toastr.info(`最大重试次数已设置为 ${this.value}。`);
|
||||
});
|
||||
}
|
||||
|
||||
if (delaySlider) {
|
||||
const value = extension_settings[extensionName]?.secondary_filler_delay ?? 0;
|
||||
delaySlider.value = value;
|
||||
|
||||
delaySlider.addEventListener('change', function() {
|
||||
const parsed = Math.max(0, parseInt(this.value, 10) || 0);
|
||||
this.value = parsed;
|
||||
updateAndSaveTableSetting('secondary_filler_delay', parsed);
|
||||
toastr.info(`触发延迟已设置为 ${parsed} 毫秒。`);
|
||||
});
|
||||
}
|
||||
|
||||
if (batchFillingThresholdInput) {
|
||||
const value = extension_settings[extensionName]?.batch_filling_threshold ?? 30;
|
||||
batchFillingThresholdInput.value = value;
|
||||
|
||||
batchFillingThresholdInput.addEventListener('change', function() {
|
||||
const parsed = Math.max(1, parseInt(this.value, 10) || 30);
|
||||
this.value = parsed;
|
||||
updateAndSaveTableSetting('batch_filling_threshold', parsed);
|
||||
toastr.info(`批处理阈值已设置为 ${parsed}。`);
|
||||
});
|
||||
}
|
||||
|
||||
const abortBtn = document.getElementById('amily2-abort-secondary-filler');
|
||||
const resetLockBtn = document.getElementById('amily2-reset-secondary-filler-lock');
|
||||
const lockStatusSpan = document.getElementById('amily2-secondary-filler-lock-status');
|
||||
if ((abortBtn || resetLockBtn) && lockStatusSpan) {
|
||||
const refreshLockStatus = () => {
|
||||
const running = isSecondaryFillerRunning();
|
||||
lockStatusSpan.textContent = running ? '状态:占用中' : '状态:空闲';
|
||||
lockStatusSpan.style.color = running ? 'var(--SmartThemeQuoteColor, #d97706)' : '';
|
||||
};
|
||||
refreshLockStatus();
|
||||
if (abortBtn) {
|
||||
abortBtn.addEventListener('click', () => {
|
||||
const signaled = abortCurrentSecondaryFiller();
|
||||
if (signaled) {
|
||||
toastr.warning('已发出中断信号,进行中的请求将立即终止,结果会被丢弃。', 'Amily2');
|
||||
log('用户手动中断了当前分步填表(AbortController.abort)。', 'warn');
|
||||
} else {
|
||||
toastr.info('当前没有正在进行的分步填表。', 'Amily2');
|
||||
}
|
||||
setTimeout(refreshLockStatus, 300);
|
||||
});
|
||||
abortBtn.addEventListener('mouseenter', refreshLockStatus);
|
||||
abortBtn.addEventListener('focus', refreshLockStatus);
|
||||
}
|
||||
if (resetLockBtn) {
|
||||
resetLockBtn.addEventListener('click', () => {
|
||||
const wasLocked = resetSecondaryFillerLock();
|
||||
refreshLockStatus();
|
||||
if (wasLocked) {
|
||||
toastr.success('分步填表锁已手动释放。', 'Amily2');
|
||||
log('用户手动释放了分步填表锁(之前处于占用状态)。', 'warn');
|
||||
} else {
|
||||
toastr.info('当前并无锁占用,无需释放。', 'Amily2');
|
||||
}
|
||||
});
|
||||
resetLockBtn.addEventListener('mouseenter', refreshLockStatus);
|
||||
resetLockBtn.addEventListener('focus', refreshLockStatus);
|
||||
}
|
||||
}
|
||||
|
||||
const fcToggle = document.getElementById('table-fill-function-call-enabled');
|
||||
if (fcToggle) {
|
||||
fcToggle.checked = extension_settings[extensionName]?.tableFillFunctionCall ?? false;
|
||||
fcToggle.addEventListener('change', function() {
|
||||
updateAndSaveTableSetting('tableFillFunctionCall', this.checked);
|
||||
toastr.info(`Function Call 填表已${this.checked ? '启用' : '禁用'}。`);
|
||||
});
|
||||
}
|
||||
|
||||
updateFillingModeUI();
|
||||
|
||||
if (tableRuleProfileSelect) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -67,8 +67,8 @@ export const SLOTS = {
|
||||
main: { label: '主 API(正文优化)', type: 'chat' },
|
||||
plotOpt: { label: '剧情优化 / JQYH', type: 'chat' },
|
||||
plotOptConc: { label: '剧情优化(并发)', type: 'chat' },
|
||||
ngms: { label: 'NGMS 历史记录', type: 'chat' },
|
||||
nccs: { label: 'NCCS 并发', type: 'chat' },
|
||||
ngms: { label: 'NGMS(总结)', type: 'chat' },
|
||||
nccs: { label: 'NCCS(填表)', type: 'chat' },
|
||||
cwb: { label: '角色世界书', type: 'chat' },
|
||||
autoCharCard: { label: '一键生卡', type: 'chat' },
|
||||
sybd: { label: '术语表填写', type: 'chat' },
|
||||
@@ -220,6 +220,8 @@ class ApiProfileManager {
|
||||
}
|
||||
this._assignments()[slot] = profileId;
|
||||
this._save();
|
||||
// 通知各模块面板刷新 profile 压制状态(见 ui/profile-slider-guard.js)
|
||||
document.dispatchEvent(new CustomEvent('amily2-profile-assignment-changed', { detail: { slot, profileId } }));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -254,6 +256,7 @@ class ApiProfileManager {
|
||||
...base,
|
||||
maxTokens: data.maxTokens ?? 65500,
|
||||
temperature: data.temperature ?? 1.0,
|
||||
fakeStream: data.fakeStream ?? false,
|
||||
// 自定义参数:透传到 LLM 请求 body 的额外 key/value(top_p、frequency_penalty 等)
|
||||
// 由 utils/api-vendor.js 提供 vendor 标准参数提示,但不强校验。
|
||||
customParams: (typeof data.customParams === 'object' && data.customParams !== null)
|
||||
@@ -353,7 +356,8 @@ function _detectVendorFromUrlSync(url) {
|
||||
|
||||
/**
|
||||
* 每个 slot 的 legacy 字段映射。jqyh 已合并到 plotOpt 不单独迁移。
|
||||
* cwb / autoCharCard / ragEmbed / ragRerank 字段结构差异较大,留作后续。
|
||||
* autoCharCard 字段是嵌套对象(acc_executor_config / acc_planner_config),
|
||||
* 不走此平铺映射,在迁移 IIFE 里单独处理;ragEmbed / ragRerank 留作后续。
|
||||
*/
|
||||
const LEGACY_PROFILE_MIGRATION_MAP = [
|
||||
{
|
||||
@@ -410,13 +414,32 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
|
||||
temperatureKey: 'sybdTemperature',
|
||||
name: 'SYBD 旧配置',
|
||||
},
|
||||
{
|
||||
slot: 'cwb',
|
||||
urlKey: 'cwb_api_url',
|
||||
modelKey: 'cwb_api_model',
|
||||
keyName: 'cwb_api_key',
|
||||
maxTokensKey: 'cwb_max_tokens',
|
||||
temperatureKey: 'cwb_temperature',
|
||||
modeKey: 'cwb_api_mode', // 预设模式下残留的 url/model 不可信,跳过迁移
|
||||
name: '角色世界书 旧配置',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 迁移版本号:首次发布的 6 槽迁移为 v1(旧布尔标记 _legacyProfileMigrationDone),
|
||||
* v2 新增 cwb + autoCharCard。版本号小于当前值时重跑迁移循环——循环本身按
|
||||
* "已分配 profile 的 slot 跳过"幂等,老用户只会补迁新增槽位,不会产生重复 profile。
|
||||
*/
|
||||
const LEGACY_MIGRATION_VERSION = 2;
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const s = extension_settings[extensionName];
|
||||
if (!s) return;
|
||||
if (s._legacyProfileMigrationDone) return; // 幂等
|
||||
// 版本化幂等:旧布尔标记视为 v1,小于当前版本则重跑(循环内部按 slot 幂等)
|
||||
const migratedVersion = s._legacyProfileMigrationVersion ?? (s._legacyProfileMigrationDone ? 1 : 0);
|
||||
if (migratedVersion >= LEGACY_MIGRATION_VERSION) return;
|
||||
|
||||
const migrated = [];
|
||||
for (const m of LEGACY_PROFILE_MIGRATION_MAP) {
|
||||
@@ -426,6 +449,8 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
|
||||
const url = String(s[m.urlKey] ?? '').trim();
|
||||
const model = String(s[m.modelKey] ?? '').trim();
|
||||
if (!url || !model) continue; // 旧配置不完整,跳过
|
||||
// 模块运行在 ST 预设模式时,url/model 是切换模式前的残留,迁成权威 profile 会改变行为
|
||||
if (m.modeKey && s[m.modeKey] === 'sillytavern_preset') continue;
|
||||
|
||||
const provider = _detectVendorFromUrlSync(url) || 'custom_oai';
|
||||
|
||||
@@ -451,6 +476,40 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
|
||||
migrated.push(`${m.slot} → ${profileId}`);
|
||||
}
|
||||
|
||||
// autoCharCard 特殊处理:legacy 配置是两份嵌套对象(executor=模型A / planner=模型B),
|
||||
// 而 profile 分配后两个角色共用同一份配置。只有当 planner 未配置或与 executor
|
||||
// 完全一致时才自动迁移,否则迁移会悄悄改变 planner 行为,留给用户手动处理。
|
||||
if (!apiProfileManager.getAssignment('autoCharCard')) {
|
||||
const exec = s.acc_executor_config || {};
|
||||
const plan = s.acc_planner_config || {};
|
||||
const execComplete = String(exec.apiUrl ?? '').trim() && String(exec.model ?? '').trim();
|
||||
const planEmpty = !String(plan.apiUrl ?? '').trim();
|
||||
const planSame = plan.apiUrl === exec.apiUrl && plan.model === exec.model && plan.apiKey === exec.apiKey;
|
||||
if (execComplete && (planEmpty || planSame)) {
|
||||
const provider = _detectVendorFromUrlSync(exec.apiUrl) || 'custom_oai';
|
||||
const profileId = apiProfileManager.createProfile({
|
||||
type: 'chat',
|
||||
name: '一键生卡 旧配置',
|
||||
provider,
|
||||
apiUrl: exec.apiUrl,
|
||||
model: exec.model,
|
||||
maxTokens: exec.maxTokens ?? undefined,
|
||||
temperature: exec.temperature ?? undefined,
|
||||
});
|
||||
// acc 的 Key 明文存在嵌套对象里(不在 configManager),直接写入 ApiKeyStore。
|
||||
// 排除 profile-sync 历史污染写回的掩码占位符,避免把 '••••••••' 当真 Key 迁移
|
||||
try {
|
||||
if (exec.apiKey && exec.apiKey !== '••••••••') await apiProfileManager.setKey(profileId, exec.apiKey);
|
||||
} catch (keyErr) {
|
||||
console.warn('[ApiProfiles] autoCharCard Key 迁移失败:', keyErr);
|
||||
}
|
||||
apiProfileManager.setAssignment('autoCharCard', profileId);
|
||||
migrated.push(`autoCharCard → ${profileId}`);
|
||||
} else if (execComplete) {
|
||||
console.info('[ApiProfiles] autoCharCard 的规划者与执行者配置不同,跳过自动迁移(迁移会让两角色共用一份配置)。请在 API 连接配置面板手动处理。');
|
||||
}
|
||||
}
|
||||
|
||||
// 新引入的 slot(无 legacy 字段可迁移)默认借用其他 slot 的 profile,
|
||||
// 让升级用户的功能不至于因为没主动分配而中断。用户可以随后改成专属 profile。
|
||||
const SLOT_INHERITANCE = {
|
||||
@@ -466,7 +525,8 @@ const LEGACY_PROFILE_MIGRATION_MAP = [
|
||||
}
|
||||
}
|
||||
|
||||
s._legacyProfileMigrationDone = true;
|
||||
s._legacyProfileMigrationDone = true; // 兼容旧版本读取
|
||||
s._legacyProfileMigrationVersion = LEGACY_MIGRATION_VERSION;
|
||||
saveSettingsDebounced();
|
||||
|
||||
if (migrated.length > 0 || linked.length > 0) {
|
||||
@@ -523,6 +583,17 @@ export function clearLegacyConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// autoCharCard 不在平铺映射里,单独校验:嵌套配置仍有内容且未分配 profile 时拒绝清除
|
||||
const accHasLegacy = String(s.acc_executor_config?.apiUrl ?? '').trim() || String(s.acc_planner_config?.apiUrl ?? '').trim();
|
||||
if (accHasLegacy && !apiProfileManager.getAssignment('autoCharCard')) {
|
||||
return {
|
||||
ok: false,
|
||||
error: '槽位 "autoCharCard" 仍有旧配置但未分配 profile,清除会导致一键生卡不可用。请先在 API 连接配置面板为它分配 profile。',
|
||||
clearedFields: 0,
|
||||
clearedKeys: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 全套 legacy 字段(含 maxTokens / temperature / apiMode / tavernProfile / fakeStream / enabled 等)
|
||||
const ALL_LEGACY_FIELDS = {
|
||||
main: ['apiUrl', 'model', 'maxTokens', 'temperature', 'apiProvider', 'tavernProfile'],
|
||||
@@ -531,6 +602,9 @@ export function clearLegacyConfig() {
|
||||
ngms: ['ngmsApiUrl', 'ngmsModel', 'ngmsApiMode', 'ngmsTavernProfile', 'ngmsMaxTokens', 'ngmsTemperature', 'ngmsFakeStreamEnabled'],
|
||||
nccs: ['nccsApiUrl', 'nccsModel', 'nccsApiMode', 'nccsTavernProfile', 'nccsMaxTokens', 'nccsTemperature', 'nccsFakeStreamEnabled'],
|
||||
sybd: ['sybdApiUrl', 'sybdModel', 'sybdApiMode', 'sybdTavernProfile', 'sybdMaxTokens', 'sybdTemperature'],
|
||||
cwb: ['cwb_api_url', 'cwb_api_model', 'cwb_api_mode', 'cwb_tavern_profile', 'cwb_max_tokens', 'cwb_temperature'],
|
||||
// autoCharCard 的旧配置是两份嵌套对象(含明文 Key),整体删除
|
||||
autoCharCard: ['acc_executor_config', 'acc_planner_config'],
|
||||
// jqyh 字段也清掉(已合并到 plotOpt 但残留可能还在)
|
||||
jqyh: ['jqyhApiUrl', 'jqyhModel', 'jqyhApiMode', 'jqyhTavernProfile', 'jqyhMaxTokens', 'jqyhTemperature', 'jqyhEnabled'],
|
||||
};
|
||||
@@ -542,6 +616,7 @@ export function clearLegacyConfig() {
|
||||
ngms: 'ngmsApiKey',
|
||||
nccs: 'nccsApiKey',
|
||||
sybd: 'sybdApiKey',
|
||||
cwb: 'cwb_api_key',
|
||||
jqyh: 'jqyhApiKey',
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ function sanitizeRuleProfile(profile = {}) {
|
||||
tagExtractionEnabled: Boolean(profile.tagExtractionEnabled),
|
||||
tags: String(profile.tags ?? ''),
|
||||
exclusionRules,
|
||||
excludeUserMessages: Boolean(profile.excludeUserMessages),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ function cloneRuleProfile(profile = {}) {
|
||||
end: rule.end || '',
|
||||
}))
|
||||
: [],
|
||||
excludeUserMessages: Boolean(profile.excludeUserMessages),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
const a0_0x12bdd0=a0_0x2c8a;function a0_0xe868(){const _0x2310ad=['jSoxW7VdQX1uySkcWO/cPG','jSkQh8kKW4hcMZxdGmkHjHRdLxvo','CSoCyvpdS8okW5RcNSkmW7u','kLCYxNjdWRRcNfFdKq','AqFdOaBdPH3cL0pcKwq','DeGZWOnGxdNcSSozy8odW4ma','haeZWRRcOCkRW7pcRmktWPtdS300WPi','e8kQeCoVWONdJCkpfSkTWOW','DbNdRXhdPH3cL0pcKwq','W5OUDaWNW5BcPmkxW47dG0q','W5GPFXGJW58','ke/cTLpcKt/cShZcVuNdRa','W7WtW6JcQJrKW4qDW6NdLG','W4PWW7LpWPC7cCkvW4v4W6RdLwpcImkmDgiAW57dS2KrWQOh','eSkUrSkBWQtdTCk+eG','W4f9emk7vSkhW4i1Emkoca7cOue','lmo8WRbjyHTpW5pcLXm0d0G','WOSRWQmkW6b5tmo6W4b5W6BdNgZcVW','W6ldVmkWpSoWtCo9','vg4TW7f1tqO','a8oeWOvRWR/dOgbFsSoo','W70tW6JcQqLJW6a/W5ZdLG','WR1KWQddVdqlbxHhW4Wswq','sCoqmCo3AbG8t8ojcfzgW50','W77cUmoBmqFdJmk6uCkovW9AWPC','WR5QWQZdVZGbEcrLW4yCrtjY','smkfbCoNrbZcUY9CkLtcTG','A2/cRba+WOhcKZXZns0','WO18W5aWWOvaiq/dUCks','ymolW6WGcCkxhmkg'];a0_0xe868=function(){return _0x2310ad;};return a0_0xe868();}function a0_0x2c8a(_0x304b6c,_0x4753ee){_0x304b6c=_0x304b6c-0x14b;const _0xe868c1=a0_0xe868();let _0x2c8a17=_0xe868c1[_0x304b6c];if(a0_0x2c8a['jcUhOn']===undefined){var _0x18b1a8=function(_0x1913f1){const _0x46992d='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x2b0883='',_0x1ba5bf='';for(let _0x326410=0x0,_0x191402,_0xa4aacd,_0x5bc31b=0x0;_0xa4aacd=_0x1913f1['charAt'](_0x5bc31b++);~_0xa4aacd&&(_0x191402=_0x326410%0x4?_0x191402*0x40+_0xa4aacd:_0xa4aacd,_0x326410++%0x4)?_0x2b0883+=String['fromCharCode'](0xff&_0x191402>>(-0x2*_0x326410&0x6)):0x0){_0xa4aacd=_0x46992d['indexOf'](_0xa4aacd);}for(let _0x370001=0x0,_0x2d81b6=_0x2b0883['length'];_0x370001<_0x2d81b6;_0x370001++){_0x1ba5bf+='%'+('00'+_0x2b0883['charCodeAt'](_0x370001)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x1ba5bf);};const _0x36b387=function(_0x308a27,_0x155f05){let _0x23e23a=[],_0xa9db3e=0x0,_0x503250,_0x435a1e='';_0x308a27=_0x18b1a8(_0x308a27);let _0x96001c;for(_0x96001c=0x0;_0x96001c<0x100;_0x96001c++){_0x23e23a[_0x96001c]=_0x96001c;}for(_0x96001c=0x0;_0x96001c<0x100;_0x96001c++){_0xa9db3e=(_0xa9db3e+_0x23e23a[_0x96001c]+_0x155f05['charCodeAt'](_0x96001c%_0x155f05['length']))%0x100,_0x503250=_0x23e23a[_0x96001c],_0x23e23a[_0x96001c]=_0x23e23a[_0xa9db3e],_0x23e23a[_0xa9db3e]=_0x503250;}_0x96001c=0x0,_0xa9db3e=0x0;for(let _0x3d0156=0x0;_0x3d0156<_0x308a27['length'];_0x3d0156++){_0x96001c=(_0x96001c+0x1)%0x100,_0xa9db3e=(_0xa9db3e+_0x23e23a[_0x96001c])%0x100,_0x503250=_0x23e23a[_0x96001c],_0x23e23a[_0x96001c]=_0x23e23a[_0xa9db3e],_0x23e23a[_0xa9db3e]=_0x503250,_0x435a1e+=String['fromCharCode'](_0x308a27['charCodeAt'](_0x3d0156)^_0x23e23a[(_0x23e23a[_0x96001c]+_0x23e23a[_0xa9db3e])%0x100]);}return _0x435a1e;};a0_0x2c8a['NTubId']=_0x36b387,a0_0x2c8a['MoFRcc']={},a0_0x2c8a['jcUhOn']=!![];}const _0x20dc23=_0xe868c1[0x0],_0x5d3cb6=_0x304b6c+_0x20dc23,_0x528ac1=a0_0x2c8a['MoFRcc'][_0x5d3cb6];return!_0x528ac1?(a0_0x2c8a['fNoJzu']===undefined&&(a0_0x2c8a['fNoJzu']=!![]),_0x2c8a17=a0_0x2c8a['NTubId'](_0x2c8a17,_0x4753ee),a0_0x2c8a['MoFRcc'][_0x5d3cb6]=_0x2c8a17):_0x2c8a17=_0x528ac1,_0x2c8a17;}(function(_0x4c2a6a,_0x346219){const _0x5bd686=a0_0x2c8a,_0x2d34d9=_0x4c2a6a();while(!![]){try{const _0x5c1eb4=-parseInt(_0x5bd686(0x152,'OKVo'))/0x1*(parseInt(_0x5bd686(0x159,'$uDP'))/0x2)+-parseInt(_0x5bd686(0x15b,'zjli'))/0x3*(parseInt(_0x5bd686(0x153,'1TWk'))/0x4)+-parseInt(_0x5bd686(0x156,'@WMS'))/0x5+-parseInt(_0x5bd686(0x14f,'#aez'))/0x6+parseInt(_0x5bd686(0x14b,')Qkj'))/0x7*(-parseInt(_0x5bd686(0x15a,'ZUO0'))/0x8)+parseInt(_0x5bd686(0x163,'[5%]'))/0x9+parseInt(_0x5bd686(0x150,'YSYZ'))/0xa*(parseInt(_0x5bd686(0x14d,'^K]y'))/0xb);if(_0x5c1eb4===_0x346219)break;else _0x2d34d9['push'](_0x2d34d9['shift']());}catch(_0x12a77b){_0x2d34d9['push'](_0x2d34d9['shift']());}}}(a0_0xe868,0xbb112));export const SENSITIVE_KEYS=new Set([a0_0x12bdd0(0x167,'Cc2E'),a0_0x12bdd0(0x15e,'2eTc'),a0_0x12bdd0(0x14c,'YSYZ'),a0_0x12bdd0(0x165,'1rYK'),a0_0x12bdd0(0x15d,'*W5g'),a0_0x12bdd0(0x160,'lh[O'),a0_0x12bdd0(0x166,'Cc2E'),a0_0x12bdd0(0x161,'1rYK')]);
|
||||
const a0_0xb649e2=a0_0x375c;(function(_0x64334b,_0x15b67e){const _0x4e79ff=a0_0x375c,_0x2b2857=_0x64334b();while(!![]){try{const _0x4901eb=parseInt(_0x4e79ff(0x123,'2@lL'))/0x1+parseInt(_0x4e79ff(0x122,'0Kh&'))/0x2*(parseInt(_0x4e79ff(0x124,'o(#F'))/0x3)+-parseInt(_0x4e79ff(0x129,'376W'))/0x4+-parseInt(_0x4e79ff(0x11a,'Iv5i'))/0x5+parseInt(_0x4e79ff(0x12f,'W(LY'))/0x6+-parseInt(_0x4e79ff(0x12e,'NgGk'))/0x7+-parseInt(_0x4e79ff(0x121,'*K2g'))/0x8;if(_0x4901eb===_0x15b67e)break;else _0x2b2857['push'](_0x2b2857['shift']());}catch(_0x464eb2){_0x2b2857['push'](_0x2b2857['shift']());}}}(a0_0x1872,0xd8973));function a0_0x375c(_0xfe4ae7,_0x41e131){_0xfe4ae7=_0xfe4ae7-0x11a;const _0x1872bb=a0_0x1872();let _0x375c7d=_0x1872bb[_0xfe4ae7];if(a0_0x375c['bBaMZB']===undefined){var _0x434c9a=function(_0x2af3e1){const _0x53693e='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x568539='',_0x5ae763='';for(let _0xcab226=0x0,_0x16a280,_0x5d9229,_0x12dc01=0x0;_0x5d9229=_0x2af3e1['charAt'](_0x12dc01++);~_0x5d9229&&(_0x16a280=_0xcab226%0x4?_0x16a280*0x40+_0x5d9229:_0x5d9229,_0xcab226++%0x4)?_0x568539+=String['fromCharCode'](0xff&_0x16a280>>(-0x2*_0xcab226&0x6)):0x0){_0x5d9229=_0x53693e['indexOf'](_0x5d9229);}for(let _0x212fc8=0x0,_0x4713a8=_0x568539['length'];_0x212fc8<_0x4713a8;_0x212fc8++){_0x5ae763+='%'+('00'+_0x568539['charCodeAt'](_0x212fc8)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x5ae763);};const _0x57e4d6=function(_0x4726f5,_0x1b6168){let _0x29f827=[],_0x17d8d2=0x0,_0x1b4437,_0x134a1e='';_0x4726f5=_0x434c9a(_0x4726f5);let _0x12be5b;for(_0x12be5b=0x0;_0x12be5b<0x100;_0x12be5b++){_0x29f827[_0x12be5b]=_0x12be5b;}for(_0x12be5b=0x0;_0x12be5b<0x100;_0x12be5b++){_0x17d8d2=(_0x17d8d2+_0x29f827[_0x12be5b]+_0x1b6168['charCodeAt'](_0x12be5b%_0x1b6168['length']))%0x100,_0x1b4437=_0x29f827[_0x12be5b],_0x29f827[_0x12be5b]=_0x29f827[_0x17d8d2],_0x29f827[_0x17d8d2]=_0x1b4437;}_0x12be5b=0x0,_0x17d8d2=0x0;for(let _0x5c5d1a=0x0;_0x5c5d1a<_0x4726f5['length'];_0x5c5d1a++){_0x12be5b=(_0x12be5b+0x1)%0x100,_0x17d8d2=(_0x17d8d2+_0x29f827[_0x12be5b])%0x100,_0x1b4437=_0x29f827[_0x12be5b],_0x29f827[_0x12be5b]=_0x29f827[_0x17d8d2],_0x29f827[_0x17d8d2]=_0x1b4437,_0x134a1e+=String['fromCharCode'](_0x4726f5['charCodeAt'](_0x5c5d1a)^_0x29f827[(_0x29f827[_0x12be5b]+_0x29f827[_0x17d8d2])%0x100]);}return _0x134a1e;};a0_0x375c['tIPMZJ']=_0x57e4d6,a0_0x375c['KrDxKH']={},a0_0x375c['bBaMZB']=!![];}const _0x202817=_0x1872bb[0x0],_0x30662f=_0xfe4ae7+_0x202817,_0x4ee7bd=a0_0x375c['KrDxKH'][_0x30662f];return!_0x4ee7bd?(a0_0x375c['aWuRyq']===undefined&&(a0_0x375c['aWuRyq']=!![]),_0x375c7d=a0_0x375c['tIPMZJ'](_0x375c7d,_0x41e131),a0_0x375c['KrDxKH'][_0x30662f]=_0x375c7d):_0x375c7d=_0x4ee7bd,_0x375c7d;}export const SENSITIVE_KEYS=new Set([a0_0xb649e2(0x11c,'F9V%'),a0_0xb649e2(0x125,'va[)'),a0_0xb649e2(0x120,'Qj(P'),a0_0xb649e2(0x127,'JAUQ'),a0_0xb649e2(0x11f,'A@1$'),a0_0xb649e2(0x11b,'^bc9'),a0_0xb649e2(0x11d,'vtUq'),a0_0xb649e2(0x128,'jLPT')]);function a0_0x1872(){const _0x2fc826=['W4FcGxFcRqfMvLabjCoFt8o3','W6VcVmkcr1nspxJdMeScsmkJ','W7q1W4f+W6ZdMSoZWPnUfa','oYtcSmkHWO7dQX81W7VcRrvGoW','W4auAIqcW653W4v1W7RdRSk7oW','ECoXbSoTBqZcP8orfbJcNSowWOq','W5NdTmoNWOv9CghdSCkFWOiDluxdSa','CmoisdiNW5akz8oud8oUemkDWOG','W5RdTmoNWOKlnqVcRCkyWPu','W61JW6JcKYuQW5iQaSk6W7ddRGm','WOe0W4nmfSkpxmo4yI4','W51eWOxcTSogdG','Fmo+tKpcIWLIdJ8sW5i','WR3dTMhcTGmVB8oFj8kZW4rZW78','WQhcVGKVWPNcK8oTWRTcFW','WOXMyCokuNOtkCkQWRvDW7e/pshdTCkpW5BcPmkziNyajW','a8kXc8oUW6RcH8kFyCk7bSkPWOTR','ASkdW4LLbfxcVCkVD8oE','CLBdJZNdUCoPW4zXWOu5WQmcDq','WPFcKmk0WR7dKJvedCksva','pqOGtCopgNNdJSocvZZdVSkWWOi','CmomtJmJW58joSoKa8otdSkD','v3bmdxL7cI0jW54','WRZdKmkYfmolWOumWRybWRO'];a0_0x1872=function(){return _0x2fc826;};return a0_0x1872();}
|
||||
@@ -938,7 +938,7 @@ export const mainOptDefaults = {
|
||||
suppressToast: false,
|
||||
optimizationMode: "intercept",
|
||||
optimizationTargetTag: 'content',
|
||||
optimizationEnabled: true,
|
||||
optimizationEnabled: false,
|
||||
optimizationExclusionEnabled: false,
|
||||
optimizationExclusionRules: [],
|
||||
greetingOptimizationEnabled: false,
|
||||
|
||||
Reference in New Issue
Block a user