16 Commits

Author SHA1 Message Date
Jenkins CI
59c4adc1c0 release: v2.2.4 [2026-05-30 13:03:07]
### 新功能
- **Function Call 填表**:
  - FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
  - 操作列表为空时在日志面板输出原始响应 JSON,便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
### 修复
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段(URL / Key / Model),统一走 API 连接配置功能分配槽位
- **表格**:
  - 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30)
  - 修复分步填表并发锁与 async/await 时序问题
  - 修复外层多余 `try...finally` 导致的插件加载报错
- **Rerank**:
  - 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`)
  - 补全 `hly-rerank-api-mode` 加载绑定及默认值
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
- **二次填表**:
  - 修复 `secondary-filler.js` 把哈希/重试次数写入非持久化的 `msg.metadata` 字段(ST 标准位是 `msg.extra`),导致刷新后去重与重试计数失效
  - 修复扫描深度重复计入 `bufferSize`(`contextLimit + buffer + batch + redundancy` → `contextLimit + batch + redundancy`),避免越过预期窗口
  - SWIPED 事件改走扫描路径,不再用 `targetMessage` bypass 强填最末条,`保留缓冲区(bufferSize)` 设置在滑动场景下正确生效(手动"回退重填"按钮仍保留 bypass,意图明确)
  - 修复 FC(Function Call)路径下成功填表与"AI 判断无需修改"两种结果均未写回 `amily2_process_hash` 与 `saveChat()` 的问题——之前导致 FC 模式去重完全失效,最旧的未处理楼层会被每次扫描重复发给 AI;现统一回写路径为 `markTargetsProcessed`
  - FC 空操作时同步输出原始响应 JSON 到控制台(与批量回填日志面板保持一致),便于区分"无需变更"/"格式校验失败"/"JSON 解析失败"
2026-05-30 13:03:07 +08:00
Jenkins CI
e66544f774 release: v2.2.4 [2026-05-30 12:44:56]
### 新功能
- **Function Call 填表**:
  - FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
  - 操作列表为空时在日志面板输出原始响应 JSON,便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
### 修复
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段(URL / Key / Model),统一走 API 连接配置功能分配槽位
- **表格**:
  - 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30)
  - 修复分步填表并发锁与 async/await 时序问题
  - 修复外层多余 `try...finally` 导致的插件加载报错
- **Rerank**:
  - 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`)
  - 补全 `hly-rerank-api-mode` 加载绑定及默认值
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
- **二次填表**:
  - 修复 `secondary-filler.js` 把哈希/重试次数写入非持久化的 `msg.metadata` 字段(ST 标准位是 `msg.extra`),导致刷新后去重与重试计数失效
  - 修复扫描深度重复计入 `bufferSize`(`contextLimit + buffer + batch + redundancy` → `contextLimit + batch + redundancy`),避免越过预期窗口
  - SWIPED 事件改走扫描路径,不再用 `targetMessage` bypass 强填最末条,`保留缓冲区(bufferSize)` 设置在滑动场景下正确生效(手动"回退重填"按钮仍保留 bypass,意图明确)
2026-05-30 12:44:56 +08:00
Jenkins CI
d6b3b00c86 release: v2.2.4 [2026-05-30 12:16:52]
### 新功能
- **Function Call 填表**:
  - FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
  - 操作列表为空时在日志面板输出原始响应 JSON,便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
### 修复
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段(URL / Key / Model),统一走 API 连接配置功能分配槽位
- **表格**:
  - 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30)
  - 修复分步填表并发锁与 async/await 时序问题
  - 修复外层多余 `try...finally` 导致的插件加载报错
- **Rerank**:
  - 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`)
  - 补全 `hly-rerank-api-mode` 加载绑定及默认值
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
2026-05-30 12:16:52 +08:00
Jenkins CI
a8c3ad9027 release: v2.2.4 [2026-05-30 11:32:49]
### 新功能
- **Function Call 填表**:
  - FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
  - 操作列表为空时在日志面板输出原始响应 JSON,便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
### 修复
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段(URL / Key / Model),统一走 API 连接配置功能分配槽位
- **表格**:
  - 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30)
  - 修复分步填表并发锁与 async/await 时序问题
  - 修复外层多余 `try...finally` 导致的插件加载报错
- **Rerank**:
  - 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`)
  - 补全 `hly-rerank-api-mode` 加载绑定及默认值
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
2026-05-30 11:32:49 +08:00
Jenkins CI
0e11f85031 release: v2.2.3 [2026-05-29 21:31:05]
### 新功能
- Function Call 填表开关下方新增公益站风险提示横幅:部分公益站会屏蔽 tools 参数,请确认支持情况避免被意外封禁
### 修复
- **Function Call 填表**:
  - 修复 ST 代理以 HTTP 200 + error body 形式返回错误、导致降级重试机制从未触发的问题
  - 修复思考模式模型(如 DeepSeek v4-flash)因 tool_choice 不兼容返回 Bad Request 后正确降级并重试
  - 重试时自动追加强制调用指令,防止思考模型绕过工具直接输出文本造成无效二次开销
- **超级记忆 / 翰林院**:
  - 修复 `getRagSettings()` 读写顶层路径而非嵌套路径,导致打开超级记忆面板后向量化、归档等开关在重载时被全默认值覆盖的问题
  - 修复自动归档失效问题
  - 修复归档管理器在同一事件中被三次触发的回归问题
  - 修复翰林院设置旧版迁移逻辑异常
2026-05-29 21:31:05 +08:00
Jenkins CI
9bc2f694b0 release: v2.2.3 [2026-05-29 13:07:39]
### 新功能
- Function Call 填表开关下方新增公益站风险提示横幅:部分公益站会屏蔽 tools 参数,请确认支持情况避免被意外封禁
### 修复
- **Function Call 填表**:
  - 修复 ST 代理以 HTTP 200 + error body 形式返回错误、导致降级重试机制从未触发的问题
  - 修复思考模式模型(如 DeepSeek v4-flash)因 tool_choice 不兼容返回 Bad Request 后正确降级并重试
  - 重试时自动追加强制调用指令,防止思考模型绕过工具直接输出文本造成无效二次开销
- **超级记忆 / 翰林院**:
  - 修复 `getRagSettings()` 读写顶层路径而非嵌套路径,导致打开超级记忆面板后向量化、归档等开关在重载时被全默认值覆盖的问题
  - 修复自动归档失效问题
  - 修复归档管理器在同一事件中被三次触发的回归问题
  - 修复翰林院设置旧版迁移逻辑异常
2026-05-29 13:07:39 +08:00
Jenkins CI
08e1dbde85 release: v2.2.3 [2026-05-27 23:06:48]
### 新功能
- Function Call 填表开关下方新增公益站风险提示横幅:部分公益站会屏蔽 tools 参数,请确认支持情况避免被意外封禁
### 修复
- **Function Call 填表**:
  - 修复 ST 代理以 HTTP 200 + error body 形式返回错误、导致降级重试机制从未触发的问题
  - 修复思考模式模型(如 DeepSeek v4-flash)因 tool_choice 不兼容返回 Bad Request 后正确降级并重试
  - 重试时自动追加强制调用指令,防止思考模型绕过工具直接输出文本造成无效二次开销
- **超级记忆 / 翰林院**:
  - 修复 `getRagSettings()` 读写顶层路径而非嵌套路径,导致打开超级记忆面板后向量化、归档等开关在重载时被全默认值覆盖的问题
  - 修复自动归档失效问题
  - 修复归档管理器在同一事件中被三次触发的回归问题
  - 修复翰林院设置旧版迁移逻辑异常
2026-05-27 23:06:48 +08:00
Jenkins CI
42e0bdec19 release: v2.2.3 [2026-05-27 21:24:56]
### 新功能
- Function Call 填表开关下方新增公益站风险提示横幅:部分公益站会屏蔽 tools 参数,请确认支持情况避免被意外封禁
### 修复
- **Function Call 填表**:
  - 修复 ST 代理以 HTTP 200 + error body 形式返回错误、导致降级重试机制从未触发的问题
  - 修复思考模式模型(如 DeepSeek v4-flash)因 tool_choice 不兼容返回 Bad Request 后正确降级并重试
  - 重试时自动追加强制调用指令,防止思考模型绕过工具直接输出文本造成无效二次开销
- **超级记忆 / 翰林院**:
  - 修复 `getRagSettings()` 读写顶层路径而非嵌套路径,导致打开超级记忆面板后向量化、归档等开关在重载时被全默认值覆盖的问题
  - 修复自动归档失效问题
  - 修复归档管理器在同一事件中被三次触发的回归问题
  - 修复翰林院设置旧版迁移逻辑异常
2026-05-27 21:24:56 +08:00
Jenkins CI
3e217e8ed8 release: v2.2.2 [2026-05-27 19:39:34]
### 新功能
- **Function Call 填表模式**:在填表设置中新增独立开关,启用后支持通过 OpenAI 兼容接口(DeepSeek / OpenRouter / 各类中转等)直接返回结构化操作列表,绕过 `<Amily2Edit>` 文本解析路径,填表更稳定
  - 遇到不支持 `tool_choice` 的接口时自动降级重试
  - 对思考模型注入强制调用指令,防止绕过工具直接输出文本
  - 全部走 ST 后端代理,修复 CSP 拦截直连外部 URL 的问题
- **主界面新增提示词链编辑器入口**,同时调换了记忆管理与角色世界书的按钮位置
- **规则中心**新增"自动排除用户楼层"选项
### 修复
- 提示词链按钮点击无响应(改为事件委托方式绑定)
- 拖拽组件微抖误触发(加 5px 移动阈值过滤)
- 填表检查窗若干问题修复;翰林院(批量回填)修复;防抖逻辑落地
- 角色世界书入口添加使用警告弹窗(强制 10 秒倒计时),提示该功能长期未维护
- ApiProfile `fakeStream` 字段保存丢失问题
- 正文优化默认改为关闭状态
- NGMS / NCCS API 配置槽位标签修正(NGMS→总结,NCCS→填表)
- API Profile 面板选择逻辑统一重构,修复多处旧字段覆盖新配置的问题
- 世界书控制参数兼容性修复(排除递归、插入位置、扫描深度等,适配 ST 1.17.0+)
2026-05-27 19:39:34 +08:00
Jenkins CI
2c3072a3d8 release: v2.2.2 [2026-05-27 11:10:55]
### 新功能
- **Function Call 填表模式**:在填表设置中新增独立开关,启用后支持通过 OpenAI 兼容接口(DeepSeek / OpenRouter / 各类中转等)直接返回结构化操作列表,绕过 `<Amily2Edit>` 文本解析路径,填表更稳定
  - 遇到不支持 `tool_choice` 的接口时自动降级重试
  - 对思考模型注入强制调用指令,防止绕过工具直接输出文本
  - 全部走 ST 后端代理,修复 CSP 拦截直连外部 URL 的问题
- **主界面新增提示词链编辑器入口**,同时调换了记忆管理与角色世界书的按钮位置
- **规则中心**新增"自动排除用户楼层"选项
### 修复
- 提示词链按钮点击无响应(改为事件委托方式绑定)
- 拖拽组件微抖误触发(加 5px 移动阈值过滤)
- 填表检查窗若干问题修复;翰林院(批量回填)修复;防抖逻辑落地
- 角色世界书入口添加使用警告弹窗(强制 10 秒倒计时),提示该功能长期未维护
- ApiProfile `fakeStream` 字段保存丢失问题
- 正文优化默认改为关闭状态
- NGMS / NCCS API 配置槽位标签修正(NGMS→总结,NCCS→填表)
- API Profile 面板选择逻辑统一重构,修复多处旧字段覆盖新配置的问题
- 世界书控制参数兼容性修复(排除递归、插入位置、扫描深度等,适配 ST 1.17.0+)
2026-05-27 11:10:55 +08:00
Jenkins CI
e00302d04b ci: auto build & obfuscate [2026-05-27 09:15:49] (Jenkins #23) 2026-05-27 09:15:49 +08:00
Jenkins CI
dabc8992f1 ci: auto build & obfuscate [2026-05-17 17:36:51] (Jenkins #22) 2026-05-17 17:36:51 +08:00
Jenkins CI
d9fa3072a2 ci: auto build & obfuscate [2026-05-16 19:16:28] (Jenkins #21) 2026-05-16 19:16:28 +08:00
Jenkins CI
4bc6e0a047 ci: auto build & obfuscate [2026-04-28 22:47:21] (Jenkins #20) 2026-04-28 22:47:21 +08:00
Jenkins CI
31d00f4330 ci: auto build & obfuscate [2026-04-28 00:51:59] (Jenkins #19) 2026-04-28 00:51:59 +08:00
Jenkins CI
13d05651f3 ci: auto build & obfuscate [2026-04-26 23:26:32] (Jenkins #18) 2026-04-26 23:26:32 +08:00
74 changed files with 5478 additions and 2183 deletions

96
51TODO.md Normal file
View File

@@ -0,0 +1,96 @@
# 51TODO — 劳动节后开工清单
> 创建于 2026-04-28。计划在 5月1日劳动节假后启动。
> 本文件聚焦跨方向工作Bus 升级 + 整体节奏)。
> 表格模块的解耦与三模式落地详见 [TableTODO.md](TableTODO.md)。
---
## 一、全景
两条并行主线:
1. **Bus tool-call 能力升级**(本文 Phase A —— 让任何 Amily2Bus 注册的插件都能定义自己的 tool_calls 工具集LLM 调用时自动 dispatch 回 handler跑 agent loop。
2. **表格模块重构 + 三模式填表** —— 解耦 manager.js 上帝模块;新增 JSON / toolcall 填表模式;保留 legacy 默认,老用户零感知。详见 [TableTODO.md](TableTODO.md)。
两条线**可并行**,仅在表格的 toolcall 模式TableTODO Phase C落地时需要 Bus Phase A 完成。
---
## 二、Phase ABus tool-call 升级
### A.1 ToolRegistry
- 新文件 `SL/bus/tool/ToolRegistry.js`
- 内部 `Map<pluginName, Map<toolName, { def, handler }>>`
- 完全私有,不跨插件查询(每个模块自己用自己的工具集,不共享)
### A.2 plugin context 加 tool 能力
- `register(pluginName)` 返回的 context 上挂 `tool`
- `define(name, { description, parameters }, handler)`
- `undefine(name)`
- `list()`
### A.3 Options + RequestBody 透传 tools
- [Options.js](SL/bus/api/Options.js) 加 `tools` / `toolChoice` 字段
- [RequestBody.toPayload](SL/bus/api/RequestBody.js) 在有 tools 时包进 payload
- `ModelCaller._normalize` 在响应含 `tool_calls` 时返回完整 message 对象(而非只返字符串)—— 注意做后向兼容标记
### A.4 callWithTools agent loop
- `context.model.callWithTools(messages, options, { maxSteps = 8, onToolError = 'feedback' })`
- 自动拼本插件 define 的工具进 request
- 收 tool_calls → 串行 dispatch 到对应 handler → tool result 回喂 messages
- handler 抛错时 catch把 error string 作为 tool_result 喂回 LLM 让其自纠
- maxSteps 兜底,防死循环
**Phase A 验收**
- [ ] 写一个最简 ping tool 跑通 round-trip
- [ ] handler 抛错回喂 LLMLLM 能自纠
- [ ] maxSteps 截断行为正确
**预估**1.5 天人时风险中agent loop 边界条件多)。
---
## 三、跨方向决策点
> 假后开工前先拍:
1. **Phase A 与 TableTODO Phase 0 谁先**
- 选项 A先 Phase ABus 升级),再 Table Phase 0
- 选项 B先 Table Phase 0解耦再 Phase A
- 选项 C并行两条分支
- 倾向BTable Phase 0 不依赖 Bus先把表格上帝模块拆了后续 Phase A 也好用 ToolRegistry
2. **Phase A 是否必须 ship 才能开 Table Phase B**
- 不必须。Phase BJSON formatter独立。Phase Ctoolcall才依赖 Phase A。
3. **是否合并发版**
- 选项 APhase 0 → 单独 ship → Phase A → ship → Phase B/C → ship增量发布回归风险低
- 选项 B全部攒一起一次性发节奏简单但风险高
- 倾向A每完成一段先发老用户始终能用 legacy。
---
## 四、不在范围内
- 不重写 ui/table-bindings.js
- 不改持久化 schema
- 不改 SuperMemory 集成
- 不引入 TypeScript
---
## 五、工时汇总
| 主线 | 子项 | 估时 |
| ---- | ---- | ---- |
| Bus | Phase A (tool-call 升级) | 1.5 天 |
| 表格 | TableTODO Phase 0-C | ~5 天(详见 TableTODO §十) |
| 验收 | 整体回归 + UI 验证 | 1 天 |
**合计 ~7.5 天人时。** 假期 5 天 + 假后两周缓冲5 月底前可全量上线。

View File

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

76
DEPLOY_NOTE.md Normal file
View File

@@ -0,0 +1,76 @@
# 部署更新日志
每个版本块格式:`## 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
- **二次填表**
- 修复 `secondary-filler.js` 把哈希/重试次数写入非持久化的 `msg.metadata` 字段ST 标准位是 `msg.extra`),导致刷新后去重与重试计数失效
- 修复扫描深度重复计入 `bufferSize``contextLimit + buffer + batch + redundancy``contextLimit + batch + redundancy`),避免越过预期窗口
- SWIPED 事件改走扫描路径,不再用 `targetMessage` bypass 强填最末条,`保留缓冲区(bufferSize)` 设置在滑动场景下正确生效(手动"回退重填"按钮仍保留 bypass意图明确
- 修复 FCFunction Call路径下成功填表与"AI 判断无需修改"两种结果均未写回 `amily2_process_hash``saveChat()` 的问题——之前导致 FC 模式去重完全失效,最旧的未处理楼层会被每次扫描重复发给 AI现统一回写路径为 `markTargetsProcessed`
- FC 空操作时同步输出原始响应 JSON 到控制台(与批量回填日志面板保持一致),便于区分"无需变更"/"格式校验失败"/"JSON 解析失败"

154
DPS.drawio Normal file
View File

@@ -0,0 +1,154 @@
<mxfile host="65bd71144e" modified="2026-04-29T00:00:00.000Z" agent="Claude" version="22.0.0" type="device">
<diagram id="dps" name="Domain-Pipeline-Service">
<mxGraphModel dx="1422" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1200" pageHeight="900" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="title" value="表格模块 — Domain → Operation → Pipeline → Service 五层架构" style="text;html=1;align=center;fontSize=20;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="180" y="20" width="840" height="30" as="geometry" />
</mxCell>
<mxCell id="subtitle" value="自下而上Domain(纯逻辑) → Operation(动作) → Pipeline(三模式) → Service(门面) → UI(订阅)" style="text;html=1;align=center;fontSize=12;fontStyle=2;fontColor=#666666" vertex="1" parent="1">
<mxGeometry x="160" y="50" width="880" height="20" as="geometry" />
</mxCell>
<mxCell id="uiLayer" value="UI Layer — 现有,不动;通过订阅事件刷新" style="swimlane;fontStyle=1;fillColor=#e1d5e7;strokeColor=#9673a6;startSize=30" vertex="1" parent="1">
<mxGeometry x="40" y="80" width="1120" height="80" as="geometry" />
</mxCell>
<mxCell id="ui1" value="ui/table-bindings.js" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6" vertex="1" parent="uiLayer">
<mxGeometry x="320" y="32" width="220" height="36" as="geometry" />
</mxCell>
<mxCell id="ui2" value="ui/message-table-renderer.js" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6" vertex="1" parent="uiLayer">
<mxGeometry x="580" y="32" width="220" height="36" as="geometry" />
</mxCell>
<mxCell id="serviceLayer" value="Service Layer — 顶层门面,组合下层" style="swimlane;fontStyle=1;fillColor=#dae8fc;strokeColor=#6c8ebf;startSize=30" vertex="1" parent="1">
<mxGeometry x="40" y="190" width="1120" height="80" as="geometry" />
</mxCell>
<mxCell id="svc" value="TableSystemService&#xa;processMessageUpdate / fillSecondary / fillBatch / reorganize / rollback" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11" vertex="1" parent="serviceLayer">
<mxGeometry x="280" y="20" width="560" height="50" as="geometry" />
</mxCell>
<mxCell id="pipelineLayer" value="Pipeline Layer — 三模式落地formatter 可插拔" style="swimlane;fontStyle=1;fillColor=#d5e8d4;strokeColor=#82b366;startSize=30" vertex="1" parent="1">
<mxGeometry x="40" y="300" width="1120" height="200" as="geometry" />
</mxCell>
<mxCell id="formattersGroup" value="formatters/" style="rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#82b366;dashed=1;verticalAlign=top;fontStyle=1" vertex="1" parent="pipelineLayer">
<mxGeometry x="20" y="40" width="500" height="140" as="geometry" />
</mxCell>
<mxCell id="fmtIdx" value="index.js&#xa;按 settings 分发" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="formattersGroup">
<mxGeometry x="160" y="30" width="180" height="40" as="geometry" />
</mxCell>
<mxCell id="fmtLeg" value="legacy.js&#xa;&lt;Amily2Edit&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="formattersGroup">
<mxGeometry x="20" y="85" width="140" height="44" as="geometry" />
</mxCell>
<mxCell id="fmtJson" value="json.js&#xa;{operations}" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="formattersGroup">
<mxGeometry x="180" y="85" width="140" height="44" as="geometry" />
</mxCell>
<mxCell id="fmtTC" value="toolcall.js&#xa;Bus tools" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="formattersGroup">
<mxGeometry x="340" y="85" width="140" height="44" as="geometry" />
</mxCell>
<mxCell id="fillerGroup" value="filler/" style="rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#82b366;dashed=1;verticalAlign=top;fontStyle=1" vertex="1" parent="pipelineLayer">
<mxGeometry x="560" y="40" width="540" height="140" as="geometry" />
</mxCell>
<mxCell id="fillShared" value="shared.js&#xa;worldbook + history + buildMessages + callModel" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="fillerGroup">
<mxGeometry x="60" y="30" width="420" height="40" as="geometry" />
</mxCell>
<mxCell id="fillSec" value="secondary.js&#xa;触发条件 + 楼层扫描" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="fillerGroup">
<mxGeometry x="20" y="85" width="160" height="44" as="geometry" />
</mxCell>
<mxCell id="fillBatch" value="batch.js&#xa;批次循环" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="fillerGroup">
<mxGeometry x="200" y="85" width="160" height="44" as="geometry" />
</mxCell>
<mxCell id="fillReorg" value="reorganize.js" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="fillerGroup">
<mxGeometry x="380" y="85" width="140" height="44" as="geometry" />
</mxCell>
<mxCell id="opLayer" value="Operation Layer — 统一动作;从 executor.js 抽出" style="swimlane;fontStyle=1;fillColor=#fff2cc;strokeColor=#d6b656;startSize=30" vertex="1" parent="1">
<mxGeometry x="40" y="530" width="1120" height="80" as="geometry" />
</mxCell>
<mxCell id="op1" value="operations.js&#xa;applyOperations(state, ops) → { state, changes }&#xa;含 insertRow / updateRow / deleteRow 三内部函数" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=11" vertex="1" parent="opLayer">
<mxGeometry x="320" y="10" width="480" height="60" as="geometry" />
</mxCell>
<mxCell id="domainLayer" value="Domain Layer — 拆自 manager.js禁止 import UI" style="swimlane;fontStyle=1;fillColor=#f8cecc;strokeColor=#b85450;startSize=30" vertex="1" parent="1">
<mxGeometry x="40" y="640" width="1120" height="120" as="geometry" />
</mxCell>
<mxCell id="dm1" value="store.js&#xa;currentTablesState&#xa;独占所有权" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
<mxGeometry x="20" y="40" width="160" height="60" as="geometry" />
</mxCell>
<mxCell id="dm2" value="persist.js&#xa;commitToLastMessage&#xa;封装 16 处样板" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
<mxGeometry x="200" y="40" width="160" height="60" as="geometry" />
</mxCell>
<mxCell id="dm3" value="mutations.js&#xa;addRow / addCol / ...&#xa;(16 个 UI 突变)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
<mxGeometry x="380" y="40" width="180" height="60" as="geometry" />
</mxCell>
<mxCell id="dm4" value="rendering.js&#xa;toCsv * 3&#xa;(纯函数)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
<mxGeometry x="580" y="40" width="160" height="60" as="geometry" />
</mxCell>
<mxCell id="dm5" value="templates.js&#xa;getter/setter" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
<mxGeometry x="760" y="40" width="160" height="60" as="geometry" />
</mxCell>
<mxCell id="dm6" value="preset.js&#xa;import/export" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
<mxGeometry x="940" y="40" width="160" height="60" as="geometry" />
</mxCell>
<mxCell id="e_svc_fillSec" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="fillSec">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_svc_fillBatch" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="fillBatch">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_svc_fillReorg" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="fillReorg">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_svc_op" value="apply Op[]" style="endArrow=classic;html=1;fontSize=9" edge="1" parent="1" source="svc" target="op1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_fillSec_shared" style="endArrow=classic;html=1" edge="1" parent="1" source="fillSec" target="fillShared">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_fillBatch_shared" style="endArrow=classic;html=1" edge="1" parent="1" source="fillBatch" target="fillShared">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_fillReorg_shared" style="endArrow=classic;html=1" edge="1" parent="1" source="fillReorg" target="fillShared">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_shared_fmtIdx" value="dispatch" style="endArrow=classic;html=1;fontSize=9" edge="1" parent="1" source="fillShared" target="fmtIdx">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_fmtIdx_leg" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="fmtIdx" target="fmtLeg">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_fmtIdx_json" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="fmtIdx" target="fmtJson">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_fmtIdx_tc" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="fmtIdx" target="fmtTC">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_leg_op" value="Op[]" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="fmtLeg" target="op1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_json_op" value="Op[]" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="fmtJson" target="op1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_tc_op" value="Op[]" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="fmtTC" target="op1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_op_dm1" value="state" style="endArrow=classic;html=1;fontSize=9" edge="1" parent="1" source="op1" target="dm1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_svc_dm2" value="commit" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="svc" target="dm2">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_dm3_dm1" value="mutates" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="dm3" target="dm1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_dm3_dm2" value="commit" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="dm3" target="dm2">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_dm1_ui" value="subscribe / events" style="endArrow=classic;html=1;dashed=1;strokeColor=#9673a6;fontSize=9" edge="1" parent="1" source="dm1" target="ui1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="legend" value="实线箭头 → 直接调用(自顶而下)&#10;虚线箭头 → 数据流向 / 跨层订阅&#10;红色 Domain 层最严格:禁止 import UI" style="text;html=1;align=left;fontSize=11;fillColor=#ffffff;strokeColor=#cccccc;rounded=0" vertex="1" parent="1">
<mxGeometry x="40" y="780" width="380" height="60" as="geometry" />
</mxCell>
<mxCell id="note1" value="特点:&#10;• 五层洋葱模型,依赖单向自顶而下&#10;• 文件少(~14manager.js 拆出后立即清晰&#10;• Domain 是纯逻辑岛,可独立测试&#10;• 无显式 DTO 层shape 散在 JSDoc 注释里" style="text;html=1;align=left;fontSize=11;fillColor=#fff8e1;strokeColor=#ffb300;rounded=0" vertex="1" parent="1">
<mxGeometry x="450" y="770" width="380" height="80" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

178
IAD.drawio Normal file
View File

@@ -0,0 +1,178 @@
<mxfile host="65bd71144e" modified="2026-04-29T00:00:00.000Z" agent="Claude" version="22.0.0" type="device">
<diagram id="iad" name="Interface-Action-DTO">
<mxGraphModel dx="1422" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1200" pageHeight="900" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="title" value="表格模块 — Interface → Action → DTO 架构" style="text;html=1;align=center;fontSize=20;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="280" y="20" width="640" height="30" as="geometry" />
</mxCell>
<mxCell id="subtitle" value="数据形状(DTO) ← 契约+实现(Interface) ← 业务动词(Action) ← 门面(Service)" style="text;html=1;align=center;fontSize=12;fontStyle=2;fontColor=#666666" vertex="1" parent="1">
<mxGeometry x="200" y="50" width="800" height="20" as="geometry" />
</mxCell>
<mxCell id="serviceLayer" value="Service Layer — 顶层门面" style="swimlane;fontStyle=1;fillColor=#dae8fc;strokeColor=#6c8ebf;startSize=30;horizontal=1" vertex="1" parent="1">
<mxGeometry x="40" y="80" width="1120" height="100" as="geometry" />
</mxCell>
<mxCell id="svc" value="TableSystemService&#xa;(Bus 注册 + 事件分发 + Action 编排)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12" vertex="1" parent="serviceLayer">
<mxGeometry x="380" y="35" width="360" height="50" as="geometry" />
</mxCell>
<mxCell id="actionLayer" value="Action Layer — 业务动词,纯函数,注入 Interface" style="swimlane;fontStyle=1;fillColor=#d5e8d4;strokeColor=#82b366;startSize=30" vertex="1" parent="1">
<mxGeometry x="40" y="200" width="1120" height="120" as="geometry" />
</mxCell>
<mxCell id="a1" value="applyOperations" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
<mxGeometry x="20" y="50" width="130" height="50" as="geometry" />
</mxCell>
<mxCell id="a2" value="fillSecondary" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
<mxGeometry x="170" y="50" width="130" height="50" as="geometry" />
</mxCell>
<mxCell id="a3" value="fillBatch" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
<mxGeometry x="320" y="50" width="130" height="50" as="geometry" />
</mxCell>
<mxCell id="a4" value="reorganize" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
<mxGeometry x="470" y="50" width="130" height="50" as="geometry" />
</mxCell>
<mxCell id="a5" value="loadTables" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
<mxGeometry x="620" y="50" width="130" height="50" as="geometry" />
</mxCell>
<mxCell id="a6" value="rollback" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
<mxGeometry x="770" y="50" width="130" height="50" as="geometry" />
</mxCell>
<mxCell id="a7" value="ui-mutations&#xa;(addRow / addCol / ...)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="actionLayer">
<mxGeometry x="920" y="50" width="160" height="50" as="geometry" />
</mxCell>
<mxCell id="interfaceLayer" value="Interface Layer — 契约(斜体) + 实现(橙色)" style="swimlane;fontStyle=1;fillColor=#fff2cc;strokeColor=#d6b656;startSize=30" vertex="1" parent="1">
<mxGeometry x="40" y="340" width="1120" height="220" as="geometry" />
</mxCell>
<mxCell id="i1" value="ITableStore" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=2" vertex="1" parent="interfaceLayer">
<mxGeometry x="20" y="50" width="180" height="40" as="geometry" />
</mxCell>
<mxCell id="i1impl" value="infra/store.js&#xa;getState/setState/subscribe" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
<mxGeometry x="20" y="130" width="180" height="50" as="geometry" />
</mxCell>
<mxCell id="i2" value="ITablePersistence" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=2" vertex="1" parent="interfaceLayer">
<mxGeometry x="220" y="50" width="180" height="40" as="geometry" />
</mxCell>
<mxCell id="i2impl" value="infra/persistence.js&#xa;saveStateToMessage&#xa;loadFromMessage" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
<mxGeometry x="220" y="130" width="180" height="50" as="geometry" />
</mxCell>
<mxCell id="i3" value="IModelCaller" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=2" vertex="1" parent="interfaceLayer">
<mxGeometry x="420" y="50" width="180" height="40" as="geometry" />
</mxCell>
<mxCell id="i3impl" value="infra/modelCaller.js&#xa;封装 callAI / callNccsAI" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
<mxGeometry x="420" y="130" width="180" height="50" as="geometry" />
</mxCell>
<mxCell id="i4" value="IFormatter&#xa;buildPrompt(state) / parseResponse(raw) → Op[]" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=2;fontSize=10" vertex="1" parent="interfaceLayer">
<mxGeometry x="620" y="50" width="280" height="60" as="geometry" />
</mxCell>
<mxCell id="i4a" value="legacy.js&#xa;&lt;Amily2Edit&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
<mxGeometry x="620" y="130" width="85" height="50" as="geometry" />
</mxCell>
<mxCell id="i4b" value="json.js&#xa;{operations}" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
<mxGeometry x="715" y="130" width="85" height="50" as="geometry" />
</mxCell>
<mxCell id="i4c" value="toolcall.js&#xa;Bus tools" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
<mxGeometry x="810" y="130" width="90" height="50" as="geometry" />
</mxCell>
<mxCell id="i5" value="IEventBus" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=2" vertex="1" parent="interfaceLayer">
<mxGeometry x="920" y="50" width="180" height="40" as="geometry" />
</mxCell>
<mxCell id="i5impl" value="infra/eventBus.js&#xa;UI 通过订阅刷新" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
<mxGeometry x="920" y="130" width="180" height="50" as="geometry" />
</mxCell>
<mxCell id="dtoLayer" value="DTO Layer — 纯数据形状(@typedef + 工厂函数)" style="swimlane;fontStyle=1;fillColor=#f5f5f5;strokeColor=#666666;startSize=30" vertex="1" parent="1">
<mxGeometry x="40" y="580" width="1120" height="100" as="geometry" />
</mxCell>
<mxCell id="d1" value="TableState" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
<mxGeometry x="20" y="40" width="130" height="40" as="geometry" />
</mxCell>
<mxCell id="d2" value="Table" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
<mxGeometry x="170" y="40" width="130" height="40" as="geometry" />
</mxCell>
<mxCell id="d3" value="Operation" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontStyle=1" vertex="1" parent="dtoLayer">
<mxGeometry x="320" y="40" width="130" height="40" as="geometry" />
</mxCell>
<mxCell id="d4" value="Change" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
<mxGeometry x="470" y="40" width="130" height="40" as="geometry" />
</mxCell>
<mxCell id="d5" value="FillRequest" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
<mxGeometry x="620" y="40" width="130" height="40" as="geometry" />
</mxCell>
<mxCell id="d6" value="FillResult" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
<mxGeometry x="770" y="40" width="130" height="40" as="geometry" />
</mxCell>
<mxCell id="d7" value="PromptContext" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
<mxGeometry x="920" y="40" width="130" height="40" as="geometry" />
</mxCell>
<mxCell id="e_svc_a1" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="a1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_svc_a2" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="a2">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_svc_a4" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="a4">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_svc_a6" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="a6">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_a2_i2" value="uses" style="endArrow=classic;html=1;dashed=1;strokeColor=#82b366;fontSize=9" edge="1" parent="1" source="a2" target="i2">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_a2_i3" value="uses" style="endArrow=classic;html=1;dashed=1;strokeColor=#82b366;fontSize=9" edge="1" parent="1" source="a2" target="i3">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_a2_i4" value="uses" style="endArrow=classic;html=1;dashed=1;strokeColor=#82b366;fontSize=9" edge="1" parent="1" source="a2" target="i4">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_a1_i1" value="uses" style="endArrow=classic;html=1;dashed=1;strokeColor=#82b366;fontSize=9" edge="1" parent="1" source="a1" target="i1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_a7_i5" value="emits" style="endArrow=classic;html=1;dashed=1;strokeColor=#82b366;fontSize=9" edge="1" parent="1" source="a7" target="i5">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_i1_impl" value="impl" style="endArrow=open;html=1;dashed=1;endFill=0;fontSize=9" edge="1" parent="1" source="i1" target="i1impl">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_i2_impl" value="impl" style="endArrow=open;html=1;dashed=1;endFill=0;fontSize=9" edge="1" parent="1" source="i2" target="i2impl">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_i3_impl" value="impl" style="endArrow=open;html=1;dashed=1;endFill=0;fontSize=9" edge="1" parent="1" source="i3" target="i3impl">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_i4_a" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="i4" target="i4a">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_i4_b" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="i4" target="i4b">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_i4_c" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="i4" target="i4c">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_i5_impl" value="impl" style="endArrow=open;html=1;dashed=1;endFill=0;fontSize=9" edge="1" parent="1" source="i5" target="i5impl">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_a1_d3" value="ops" style="endArrow=classic;html=1;dashed=1;strokeColor=#666666;fontSize=9" edge="1" parent="1" source="a1" target="d3">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_a1_d4" value="changes" style="endArrow=classic;html=1;dashed=1;strokeColor=#666666;fontSize=9" edge="1" parent="1" source="a1" target="d4">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_a2_d5" value="req" style="endArrow=classic;html=1;dashed=1;strokeColor=#666666;fontSize=9" edge="1" parent="1" source="a2" target="d5">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_a2_d6" value="result" style="endArrow=classic;html=1;dashed=1;strokeColor=#666666;fontSize=9" edge="1" parent="1" source="a2" target="d6">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_i4_d3" value="produces" style="endArrow=classic;html=1;dashed=1;strokeColor=#666666;fontSize=9" edge="1" parent="1" source="i4" target="d3">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="legend" value="实线箭头 → 直接调用&#10;虚线箭头 → 依赖 / 数据流向 / 接口实现关系&#10;斜体 = 抽象契约(@typedef橙色 = 具体实现" style="text;html=1;align=left;fontSize=11;fillColor=#ffffff;strokeColor=#cccccc;rounded=0" vertex="1" parent="1">
<mxGeometry x="40" y="710" width="380" height="60" as="geometry" />
</mxCell>
<mxCell id="note1" value="特点:&#10;• DTO 层独立,三模式 formatter 输出统一吐 Op[]&#10;• Action 是纯函数,注入 Interface 后可单元测试&#10;• 文件多(~25目录树是主导航&#10;• 适合未来 TS 化" style="text;html=1;align=left;fontSize=11;fillColor=#fff8e1;strokeColor=#ffb300;rounded=0" vertex="1" parent="1">
<mxGeometry x="450" y="700" width="350" height="80" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

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

View File

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

View File

@@ -1,4 +1,7 @@
import Options from './Options.js';
import { detectVendorSync, getRegistry } from '../../../utils/api-vendor.js';
getRegistry().catch(() => {});
/**
* RequestBody (DTO)
@@ -24,7 +27,10 @@ export class RequestBody {
*/
toPayload() {
const { apiUrl, apiKey, model, maxTokens, temperature, params, fakeStream } = this.options;
const isGoogle = apiUrl && apiUrl.includes('googleapis.com');
const detectedVendor = detectVendorSync(apiUrl);
const isGoogle = detectedVendor
? detectedVendor === 'google'
: Boolean(apiUrl && apiUrl.includes('googleapis.com'));
// 基础字段 (Base Fields)
const payload = {
@@ -71,4 +77,4 @@ export class RequestBody {
}
}
export default RequestBody;
export default RequestBody;

View File

@@ -2,8 +2,7 @@ import { Module, ModuleBuilder } from './Module.js';
import { extension_settings, getContext } from '../../../../../extensions.js';
import { saveSettingsDebounced, saveChat, reloadCurrentChat, eventSource, event_types } from '../../../../../../script.js';
import { registerSlashCommand } from '../../../../../slash-commands.js';
const extensionName = 'ST-Amily2-Chat-Optimisation-Dev'; // Use main extension name for settings
import { extensionName } from '../../utils/settings.js';
const sfigenSettingsKey = 'sfigen_settings';
const defaultSettings = {

14
TODO.md
View File

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

356
TODOList.md Normal file
View File

@@ -0,0 +1,356 @@
# TODOList — 待办任务总览
> 用于派工与进度跟踪。任务卡格式统一,可拆分给不同执行者(人 / Claude / GPT / 其他模型)。
>
> 关联文档:
> - [51TODO.md](51TODO.md) — 跨方向重构计划Bus tool-call 升级 / 跨议题决策点)
> - [TableTODO.md](TableTODO.md) — 表格模块 IAD 深度重构计划Phase 0/B/C
> - [TODO.md](TODO.md) — 旧版本变更日志(保留作为发布记录)
>
> 最后更新2026-05-08对应 v2.2.0 已发布。
---
## 一、最近落地v2.1.1 → 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` |
**核心架构现状**(接手必读):
- **状态权威**`utils/config/ApiProfileManager.js` 是 API 配置单一指挥所profile 分配后即权威,旧字段(`s.ngmsTemperature` 等)不再覆盖 profile
- **表格模块**:核心在 [core/table-system/](core/table-system/) ,已按 IAD 拆分dto/infra/actions/rendering.js/templates.js/preset.jsmanager.js 退化为兼容层(仍保留 16 个 UI mutation + loadTables + updateTableFromText
- **API 厂商识别**[utils/api-vendor.js](utils/api-vendor.js) 提供 detectVendor / listVendorParamsregistry 在 [assets/api-vendor-params.json](assets/api-vendor-params.json)
- **VS Code 类型校验**[jsconfig.json](jsconfig.json) 已开启 checkJs[types/sillytavern.d.ts](types/sillytavern.d.ts) 提供 SillyTavern 全局模块声明
---
## 二、待办任务
### 任务卡格式说明
每个任务包含:
- **类型**bug / feature / refactor / cleanup / docs
- **难度**:🟢 简单(< 1h 🟡 中等1-3h 🔴 高耦合(> 3h 或需架构判断)
- **建议执行者**`GPT` / `Claude` / `Human` / `任意`
- **文件**:明确路径 + 行号锚点(若适用)
- **修改要点**bullet 列表
- **验收**:可验证的预期行为
- **依赖**:前置任务的 ID若有
---
### 🟢 GPT-friendly 简单任务
#### T-001: 清理已确认的死代码
- **类型**cleanup
- **难度**:🟢 简单
- **建议执行者**GPT
- **依赖**:无
**待清理项**
1. **[core/fractal-memory.js](core/fractal-memory.js)** —— 整个文件死代码,`initializeFractalMemory` 在文件外完全没人调用。建议:直接删除整个文件。
2. **[ui/historiography-bindings.js:494-513](ui/historiography-bindings.js#L494)** —— 绑定 `#amily2_ngms_temperature``#amily2_ngms_max_tokens` 这两个 HTML 中已不存在的元素。`getElementById` 永远返回 null整段代码空跑。建议直接删掉这段。
3. **[ui/plot-opt-bindings.js:664-665](ui/plot-opt-bindings.js#L664)** —— 同样引用不存在的 `#amily2_opt_max_tokens` / `#amily2_opt_temperature`。建议:删掉。
4. **[ui/plot-opt-bindings.js:698-699](ui/plot-opt-bindings.js#L698)** —— `opt_bindSlider` 调用同样的不存在 ID删除。
**修改要点**
- 删除前用 grep 确认每个 ID 在所有 .html 文件里都不存在
- 删完后用 grep 检查没有其他文件 import 被删的函数
- 提交前肉眼跑一次表格填表 / 剧情优化 / NGMS 总结,确认 UI 无回归
**验收**
- [ ] 4 处死代码块全部删除
- [ ] 启动控制台无 JS 错误
- [ ] 表格 / 剧情优化 / 总结功能无回归
---
#### T-002: cwb / autoCharCard 加入 legacy 自动迁移
- **类型**feature
- **难度**:🟢 简单
- **建议执行者**GPT
- **依赖**:无
**背景**[utils/config/ApiProfileManager.js](utils/config/ApiProfileManager.js) 的 `LEGACY_PROFILE_MIGRATION_MAP` 目前覆盖 main / plotOpt / plotOptConc / ngms / nccs / sybd 6 个 slot。cwb 和 autoCharCard 的 legacy 字段结构略不同cwb 用 `cwb_apiUrl` / `cwb_apiKey` / `cwb_model` autoCharCard 用 `acc_*` 前缀),所以暂时没纳入。
**修改要点**
1. 找出 cwb / autoCharCard 的 legacy 字段名grep `cwb_apiUrl` / `acc_apiUrl` 之类)
2.`LEGACY_PROFILE_MIGRATION_MAP` 加两条:
```js
{
slot: 'cwb',
urlKey: 'cwb_apiUrl',
modelKey: 'cwb_model',
keyName: 'cwb_apiKey',
maxTokensKey: 'cwb_max_tokens',
temperatureKey: 'cwb_temperature',
name: 'CWB 旧配置',
},
{
slot: 'autoCharCard',
urlKey: '???', // 需 grep 确认实际 key
...
}
```
3. 同时在 `clearLegacyConfig` 的 `ALL_LEGACY_FIELDS` 和 `LEGACY_KEY_NAMES` 加对应条目
**验收**
- [ ] 两个 slot 在迁移自调用 IIFE 跑过后能正确创建 profile + setKey + setAssignment
- [ ] 清理按钮能识别并清除这俩模块的旧字段
---
#### T-003: 表格 NCCS 支路透传 customParams
- **类型**feature
- **难度**:🟢 简单
- **建议执行者**GPT
- **依赖**:无
**背景**v2.2.0 给 `core/api.js` 的 callOpenAITest / callOpenAICompatible / callSillyTavernBackend 都接入了 `options.customParams` spread。但 [core/api/NccsApi.js](core/api/NccsApi.js) 的 `callNccsOpenAITest` 等独立路径**没有**接入,导致用户在 NCCS profile 配置的 customParams 不生效。
**修改要点**
1. 找 [NccsApi.js](core/api/NccsApi.js) 里发请求的函数(`callNccsOpenAITest` / `callNccsSillyTavernPreset`),定位到 `JSON.stringify({ ... })` 处
2. 在 body 构建时按"customParams 在前,核心字段在后覆盖"的顺序 spread
```js
body: JSON.stringify({
...(options.customParams || {}),
// 核心字段
chat_completion_source: 'openai',
model: options.model,
messages,
// ...
})
```
3. 同时确保 `getNccsApiSettings` 把 `profile.customParams` 透出(参考 [core/api.js:447-462](core/api.js#L447) 模式)
4. 同步给 NgmsApi / JqyhApi / SybdApi 做相同处理
**验收**
- [ ] 在 NCCS profile 加 `{"top_p": 0.5}` 后DevTools Network 看请求 body 包含 top_p:0.5
- [ ] NGMS / JQYH / SYBD 同样验证
---
#### T-004: hint panel 点击参数名插入到 textarea
- **类型**feature
- **难度**:🟢 简单
- **建议执行者**GPT
- **依赖**:无
**背景**[ui/api-config-bindings.js](ui/api-config-bindings.js) 的 `_updateCustomParamsHint` 现在只显示纯文本"已知参数top_p、frequency_penalty、..."。没有交互。
**修改要点**
1. 把 hint 区改成参数名按钮列表,每个按钮 click 触发"如果当前 textarea JSON 已有这个 key 则不动,没有就 append 进去"
2. 实现 `_insertParamToCustomParams(paramName, defaultValue)`:解析 textarea JSON → 添加 key用合理的占位值例如 number 类型用 0、string 类型用 ""、object 类型用 {})→ JSON.stringify 回写
3. 处理 textarea 当前为空 / 当前是非法 JSON 的情况(非法 JSON 时按钮 disabled + 提示用户先修复)
**验收**
- [ ] 切换 vendor 后参数名按钮列表更新
- [ ] 点击按钮把对应 key 添加到 textarea
- [ ] 已存在的 key 不重复添加
---
### 🟡 中等任务
#### T-005: 15 处散乱 vendor URL 检查迁到 detectVendor
- **类型**refactor
- **难度**:🟡 中等
- **建议执行者**GPT 或 Claude
- **依赖**:无
**背景**:之前的 51TODO Phase B 收尾任务。代码里 15+ 处 `apiUrl.includes('googleapis.com')` 散乱判断厂商,应该统一调 [utils/api-vendor.js#detectVendor](utils/api-vendor.js)。
**待迁移文件**grep `googleapis.com|anthropic.com|openai.com` 找):
- `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 总结 / 表格填表)确认无回归
---
#### T-006: jqyh/sybd/cwb 在 profile 已分配时把 slider 改成 informational
- **类型**feature / UX
- **难度**:🟡 中等
- **建议执行者**GPT 或 Claude
- **依赖**:无
**背景**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或者轮询
**验收**
- [ ] 给 plotOpt 分配 profile 后,剧情优化面板的温度/maxTokens slider 变灰 + 提示
- [ ] 取消分配后 slider 重新可用
- [ ] 其他模块同样行为
---
#### T-007: 表格 Phase 0.4 — 抽出 mutations.js
- **类型**refactor
- **难度**:🟡 中等
- **建议执行者**Claude涉及 IAD 一致性判断)
- **依赖**:无
**背景**[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 失败
---
### 🔴 高耦合 / 架构任务
#### T-008: Bus tool-call 能力升级
- **类型**feature / 架构
- **难度**:🔴 高
- **建议执行者**Claude涉及 Bus 架构判断)
- **依赖**:无(独立于表格重构)
**详见**[51TODO.md#二-phase-a-bus-tool-call-升级](51TODO.md)
**核心交付**
- `SL/bus/tool/ToolRegistry.js` 私有工具注册表
- `register(pluginName)` 返回的 context 加 `tool` 能力
- `Options.js` / `RequestBody.js` 支持 `tools` / `toolChoice` 字段
- `context.model.callWithTools(messages, options, { maxSteps, onToolError })` agent loop
**预估**1.5 天
---
#### 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
- **难度**:🟡 中等
- **建议执行者**Claude
- **依赖**T-008 完成 + T-009 完成
**详见**[TableTODO.md#六-phase-c-toolcall-formatter](TableTODO.md)
---
#### T-011: 表格 Phase 0.7-0.9 收尾
- **类型**refactor
- **难度**:🔴 高filler 三方差异需小心对齐 / 解循环依赖 / Service 重写)
- **建议执行者**Claude
- **依赖**T-007Phase 0.4 mutations 完成后做)
**详见**[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.9: TableSystemService 真正变成门面
**预估**1 天
---
## 三、派工建议
### 适合现在直接派给 GPT独立、无架构判断
- ✅ 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 风险高)
---
## 四、未列入但可能的小项
- 自动迁移完成后给所有 chat 类型 slot 加默认 link 选项(不只 tableFilling
- profile 分配 UI 加"复用现有 profile"快捷按钮(避免用户为每个 slot 重复创建相同配置)
- 51TODO.md 第三节决策点中"是否合并发版"等问题做最终决定记录
- TODO.md旧版本变更日志的 v2.2.0 版本条目补全

309
TableTODO.md Normal file
View File

@@ -0,0 +1,309 @@
# TableTODO — 表格模块重构清单
> 创建于 2026-04-28。劳动节假后启动。
> 主线:解耦 → 三模式填表legacy / json / toolcall
> 跨方向依赖Bus tool-call 升级)见 [51TODO.md](51TODO.md) Phase A。
---
## 一、动机
现行表格填表让 LLM 输出 `<Amily2Edit>insertRow(0, {0:"x",1:"y"})</Amily2Edit>` 这种"四不像"自定义文本格式,由 [executor.js#parseFunctionCall](core/table-system/executor.js#L98) 自实现的 brace-depth + quote-state 状态机解析。高温下:
- 引号转义错乱、嵌套对象内逗号未转义 → 参数切错位
- `data` 对象键写成无引号字段名 → 多层 JSON.parse fallback 仍可能失败
- 一处 LLM 偷懒不输出 `<Amily2Edit>` → 整批回滚重试
**目标**:把"格式契约"从 prompt 字符串约定改成 schema 约定,让 LLM 直接吐结构化数据,砍掉自实现解析器。同时保留 legacy 文本模式确保老用户行为不变。
| 模式 | 输出形态 | 解析复杂度 | 兼容性 |
|------|---------|-----------|--------|
| `legacy`(默认) | `<Amily2Edit>insertRow(...)</Amily2Edit>` 文本块 | 高(现行解析器) | 100% 老行为 |
| `json` | `{ "operations": [{op, tableIndex, ...}] }` 单 JSON 块 | 中JSON.parse + schema 校验) | 新模式 |
| `toolcall` | OpenAI tool_calls 多步迭代 | 低(结构化原生) | 依赖 Bus 升级51TODO Phase A |
---
## 二、当前耦合分析2026-04-28 摸底)
### 2.1 manager.js 是上帝模块
- 1745 行51 个 export
- 七层职责混杂:状态容器 / 持久化 / UI 突变操作 / LLM 指令执行 / Markdown 提示词渲染 / 模板 getter setter / 预设导入导出 / 回滚 / 跨模块事件分发
### 2.2 状态所有权
- module-level mutable`currentTablesState``highlightedCells``updatedTables`[manager.js:16-20](core/table-system/manager.js#L16-L20)
- 20+ export 函数直接 mutate没有封装边界
### 2.3 持久化模式被复制 16 次
每个 UI 突变 export 末尾都有同款样板:
```js
const context = getContext();
if (context.chat && context.chat.length > 0) {
const lastMessage = context.chat[context.chat.length - 1];
if (saveStateToMessage(currentTablesState, lastMessage)) {
saveChat();
return;
}
}
saveChatDebounced();
```
受影响addRow / addColumn / updateHeader / deleteRow / restoreRow / insertColumn / moveColumn / deleteTable / addTable / renameTable / moveTable / updateTableRules / updateRow / clearAllTables / updateColumnWidth / insertRow
### 2.4 三个 filler 大量重复
- [secondary-filler.js#getWorldBookContext](core/table-system/secondary-filler.js#L16) ≈ [batch-filler.js#getWorldBookContext](core/table-system/batch-filler.js#L25)含微妙差异character book 来源处理不同)
- mixed-order 拼装循环 + `callNccsAI vs callAI` 分支三处 copy
- 三者都调 `updateTableFromText(rawContent)` 收尾
### 2.5 业务层硬依赖 UI 层
[manager.js:9-10](core/table-system/manager.js#L9-L10)
```js
import { renderTables } from '../../ui/table-bindings.js';
import { updateOrInsertTableInChat } from '../../ui/message-table-renderer.js';
```
在 loadMemoryState / deleteRow / restoreRow / rollbackState / updateTableFromText 里直调。逻辑和渲染焊死。
### 2.6 提示词构建散在 4 个文件
- 模板常量:[settings.js](core/table-system/settings.js)
- getter[manager.js:1244-1259](core/table-system/manager.js#L1244)
- 占位符替换 `flowTemplate.replace('{{{Amily2TableData}}}', ...)`secondary-filler / batch-filler / reorganizer / injector 各自一份
### 2.7 格式锁死(重构核心痛点)
`<Amily2Edit>` 文本格式硬编码在 4 处:
- [executor.js#L98-202](core/table-system/executor.js#L98) 解析器
- [settings.js#L11-16](core/table-system/settings.js#L11) 模板示例
- [manager.js#updateTableFromText](core/table-system/manager.js#L1266) 入口
- [secondary-filler.js#L292](core/table-system/secondary-filler.js#L292) 失败检测 `if (!rawContent.includes('<Amily2Edit>'))`
### 2.8 循环依赖
- [manager.js:5](core/table-system/manager.js#L5) → `secondary-filler.js`
- [secondary-filler.js:7](core/table-system/secondary-filler.js#L7) → `manager.js`
- 引发点:`manager.rollbackAndRefill` 需要调 `fillWithSecondaryApi`
### 2.9 TableSystemService 是半成品门面
[TableSystemService.js](core/table-system/TableSystemService.js) 把 manager / executor / secondary-filler / ui 全 import 后再 expose没解耦任何东西只是 Bus 注册帖。
---
## 三、目标分层
```
┌────────────────────────────────────────────────────────────┐
│ UI Layer (existing, untouched) │
│ ui/table-bindings.js · ui/message-table-renderer.js │
└────────────────────▲────────────────────────────────────────┘
│ 仅订阅事件,不被业务层 import
┌────────────────────┴────────────────────────────────────────┐
│ Service Layer (TableSystemService 真正承担门面) │
│ ├─ 编排fill/reorganize/rollback │
│ ├─ Bus 注册 │
│ └─ 通过事件通知 UI而非 import
└────────────────────▲────────────────────────────────────────┘
┌────────────────────┴────────────────────────────────────────┐
│ Pipeline Layer (新增,三模式落地点) │
│ ├─ formatters/legacy.js : <Amily2Edit> prompt + parse │
│ ├─ formatters/json.js : JSON prompt + parse │
│ ├─ formatters/toolcall.js : Bus tool_calls (依赖 Bus 升级) │
│ ├─ formatters/index.js : 按 settings 分发 │
│ └─ filler/ │
│ ├─ shared.js : worldbook + history + 拼装 │
│ ├─ secondary.js : 触发条件 + 用 shared │
│ └─ batch.js : 批次循环 + 用 shared │
└────────────────────▲────────────────────────────────────────┘
│ 输出统一 Operation[]
┌────────────────────┴────────────────────────────────────────┐
│ Operation Layer (从 executor.js 抽出) │
│ operations.js │
│ ├─ applyOperations(state, ops) → { state, changes } │
│ └─ schema: Op = { op, tableIndex, ...args } │
└────────────────────▲────────────────────────────────────────┘
┌────────────────────┴────────────────────────────────────────┐
│ Domain Layer (从 manager.js 拆出) │
│ ├─ store.js : currentTablesState 单一所有权 + 订阅 │
│ ├─ persist.js : saveStateToMessage / load / 持久化封装 │
│ ├─ mutations.js : addRow/addColumn/.../updateRow 突变 API │
│ ├─ rendering.js : convertTablesToCsvString * 3 (纯函数) │
│ ├─ templates.js : prompt 模板 getter setter │
│ └─ preset.js : 导入导出 / 全局预设 │
└──────────────────────────────────────────────────────────────┘
```
**关键原则**
- Domain Layer 是纯逻辑,**禁止 import UI**
- Service Layer 与 UI 通过事件解耦(已有 events-schema.js 基础设施)
- Pipeline Layer 的 formatter 是可插拔的,新增格式 = 加文件,不动旧文件
- `currentTablesState` 由 store.js 独占,对外只有 `getState() / setState() / subscribe()`
---
## 四、Phase 0解耦准备必须先做
下列任务**不引入新功能**,只重排现有代码。每条独立可 ship。
### 0.1 抽出 store.js单一所有权
- 文件:`core/table-system/domain/store.js`
-`currentTablesState` / `highlightedCells` / `updatedTables` 搬过来
- 提供:`getState() / setState() / addHighlight / clearHighlights / getUpdatedTables / subscribe(listener)`
- manager.js 改为代理调用
### 0.2 抽出 persist.js消除 16 处持久化样板)
- 文件:`core/table-system/domain/persist.js`
- 提供 `commitToLastMessage(state)`:封装 `getContext + saveStateToMessage + saveChat + fallback`
- 替换 manager.js 16 处样板
### 0.3 抽出 operations.js解锁三模式的关键
- 文件:`core/table-system/operations.js`
- 把 [executor.js insertRow/updateRow/deleteRow](core/table-system/executor.js#L3-L89) 抽成纯函数
- schema`Op = { op: 'insertRow'|'updateRow'|'deleteRow', tableIndex, rowIndex?, data? }`
- API`applyOperations(state, ops): { state, changes }`
- executor.js 改名 → `formatters/legacy.js`,只保留文本解析 → 输出 Op[] → 调 applyOperations
### 0.4 拆 mutations.js
- 文件:`core/table-system/domain/mutations.js`
- 把 manager.js 里 16 个突变 exportaddRow / addColumn 等)搬过来
- 全部改为:调 store.setState + persist.commitToLastMessage + 发事件
- **删除**对 ui/* 的所有 import改为 `store.subscribe` 让 UI 自己订阅刷新
### 0.5 拆 rendering.js
- 文件:`core/table-system/domain/rendering.js`
- 把 [convertTablesToCsvString](core/table-system/manager.js#L1005) / [convertSelectedTablesToCsvString](core/table-system/manager.js#L1096) / [convertTablesToCsvStringForContentOnly](core/table-system/manager.js#L1201) 搬过来
- 都做成纯函数 `(state, options?) => string`,不依赖 store
### 0.6 拆 templates.js + preset.js
- `domain/templates.js`getBatchFillerRuleTemplate / saveBatchFillerRuleTemplate / Flow 同款
- `domain/preset.js`exportPreset / importPreset / clearGlobalPreset / importGlobalPreset
### 0.7 抽出 fillerShared.js消除三 filler 重复)
- 文件:`core/table-system/filler/shared.js`
- 提供:
- `getWorldBookContext(settings)` — 合并 secondary 和 batch 两份的差异,参数化处理
- `buildHistoryContext(opts)` — 统一对话历史拼装
- `buildMessages(scope, { worldbook, history, coreContent, flowPrompt, ruleTemplate })` — mixed-order 循环 + presetPrompts 拼装
- `callModel(messages, settings)` — 统一 nccsEnabled 分支
- secondary-filler.js / batch-filler.js / reorganizer.js 改用 shared
### 0.8 解循环依赖
- manager.js 的 `rollbackAndRefill` 不直接 import `fillWithSecondaryApi`
- 改为:在 service 层 (TableSystemService) 编排"先 rollback 再 fill"
- manager或新的 mutations.js只暴露 rollbackState
### 0.9 TableSystemService 真正变成门面
- 不再 `import * as TableManager` + 一一 expose
- 改为:内部组合 store / persist / mutations / formatters / filler对外只暴露稳定接口
- 现有 `processMessageUpdate` 保留
**Phase 0 完成验收**
- [ ] manager.js 缩到 < 200 行(仅作为 deprecation 兼容层重导出 + 标 @deprecated
- [ ] 任何 domain/* 文件都不 import ui/*
- [ ] 三个 filler 共用 fillerShared.js各自只有 ~100 行
- [ ] 现行 legacy 模式行为完全不变(手动验证)
---
## 五、Phase BJSON formatter
> 依赖 Phase 0。不依赖 Bus 升级Phase A
### B.1 formatters/json.js
- prompt 模板:教 LLM 输出 `{ "operations": [{ "op": "insertRow", "tableIndex": 0, "data": { "0": "...", "1": "..." } }] }`
- 解析:`JSON.parse` + schema 校验 → Op[]
- 输出 Op[] 给 applyOperations
### B.2 设置项与 UI
- 新设置:`settings.table_filling_format: 'legacy' | 'json' | 'toolcall'`,默认 `legacy`
- 表格设置面板加 dropdown
- 默认值保证老用户零感知
### B.3 集成到 fillerShared
- shared.callModel 调完后传 raw response 给当前 formatter
- formatter 返回 Op[]
- shared 负责 applyOperations + persist + 发事件
**Phase B 验收**
- [ ] 切换到 json 模式后,手动跑分步填表 + 批量填表 + 重新整理 三种场景都能成功
- [ ] 回切 legacy 行为不变
---
## 六、Phase CToolCall formatter
> 依赖 Phase 0 + 51TODO Phase ABus tool-call 升级)+ Phase BB 已经把 formatter 切换走通了)。
### C.1 formatters/toolcall.js
- 注册 Bus 工具:`table.insertRow / table.updateRow / table.deleteRow`
- 工具 parameters 用标准 JSONSchema 描述
- handler 内部调 `applyOperations`(其实是收集 Op[] 累加)
- 让 fillerShared 在该模式下走 `model.callWithTools`loop 跑完后取累计的 Op[]
### C.2 终止条件
- LLM 在某轮没有吐 tool_calls 即停(对应"我已填完"的语义信号)
- maxSteps 兜底
### C.3 Prompt 调整
- toolcall 模式下不需要 `<Amily2Edit>` 教学prompt 简化
- 但要保留 `{{{Amily2TableData}}}` 注入当前状态作为参考
**Phase C 验收**
- [ ] toolcall 模式跑通分步填表
- [ ] 串表问题肉眼对比 legacy 显著减少
- [ ] handler 内 tableIndex 不存在时回喂 LLM 能自纠
---
## 七、表格部分决策点
> 重构前需要确认:
1. **填表格式开关粒度**:全局一个?还是分步 / 批量 / 重整 三个独立?
- 倾向:全局一个 `table_filling_format`,简化 UI
2. **JSON 模式形态**
- A单 JSON 块 `{"operations":[...]}` 直球到底
- B允许 LLM 在 ops 前后写自由文本(像 toolcall 那样夹带推理)
- 倾向A简单可靠
3. **toolcall 终止条件**
- A模型某轮无 tool_calls 即停 + maxSteps 兜底
- B必须显式调 `commit_table_changes` 工具才算完
- 倾向A
4. **manager.js 兜底兼容期**
- 拆解后保留 manager.js 作 re-export 兼容层多久?
- 倾向:保留至 2.0.22.0.3 删除
---
## 八、不在范围内(明确不做)
- 不重写 ui/table-bindings.jsUI 层独立演进)
- 不改持久化 schema`message.extra.amily2_tables_data` 保持)
- 不改 SuperMemory 集成(继续走 Bus query + CustomEvent fallback
- 不引入 TypeScriptDTS 注释为主)
- Phase 0 阶段不动 prompt 模板内容(只挪文件位置)
---
## 九、入手顺序
1. Phase 0.3operations.js—— 影响面小,立刻能验证 executor 抽离不破坏 legacy
2. Phase 0.1 + 0.2store + persist—— 给后续 mutations 拆解铺路
3. Phase 0.4-0.6 —— manager.js 收缩主战
4. Phase 0.7-0.9 —— filler 重复消除 + 循环依赖
5. Phase 0 整体回归
6. Phase B独立可走不等 Bus 升级)
7. Phase C等 51TODO Phase A 完成后再做)
---
## 十、工时(粗)
| Phase | 预估 | 风险 |
|-------|------|------|
| 0.1-0.3 (store/persist/operations) | 1 天 | 低 |
| 0.4-0.6 (mutations/rendering/templates) | 1 天 | 中manager.js 删减易漏) |
| 0.7-0.9 (filler / 循环依赖 / Service) | 1 天 | 中filler 三方差异需仔细对齐) |
| Phase B | 0.5 天 | 低 |
| Phase C | 0.5 天 | 低(前置都搞完了,纯组装) |
| 回归测试 | 1 天 | — |
合计 ~5 天人时(不含 Bus 升级,那部分见 51TODO

View File

@@ -36,47 +36,12 @@
<!-- API Settings Tab -->
<div id="sinan-api-settings-tab" class="sinan-tab-pane active">
<fieldset class="settings-group">
<legend>Jqyh API</legend>
<div class="control-block-with-switch">
<label for="amily2_jqyh_enabled"><strong>启用 Jqyh API</strong></label>
<label class="toggle-switch">
<input id="amily2_jqyh_enabled" type="checkbox" />
<span class="slider"></span>
</label>
</div>
<div id="amily2_jqyh_content" style="display: none;" class="inline-settings-grid">
<label for="amily2_jqyh_api_mode">API 模式</label>
<select id="amily2_jqyh_api_mode" class="text_pole">
<option value="openai_test">全兼容模式</option>
<option value="sillytavern_preset">SillyTavern 预设</option>
</select>
<div id="amily2_jqyh_compatible_config" class="inline-settings-grid" style="grid-column: 1 / -1;">
<label for="amily2_jqyh_api_url">API URL</label>
<input type="text" id="amily2_jqyh_api_url" class="text_pole" placeholder="例如: https://api.openai.com/v1">
<label for="amily2_jqyh_api_key">API Key</label>
<input type="password" id="amily2_jqyh_api_key" class="text_pole" placeholder="请输入您的 API Key">
<label for="amily2_jqyh_model">模型</label>
<div class="amily2_opt_preset_selector_wrapper">
<input type="text" id="amily2_jqyh_model" class="text_pole" placeholder="请先获取模型列表或手动输入">
<select id="amily2_jqyh_model_select" class="text_pole" style="display: none;"></select>
</div>
<div class="jqyh-button-row" style="grid-column: 1 / -1;">
<button id="amily2_jqyh_fetch_models" class="menu_button secondary" title="获取模型列表"><i class="fas fa-sync-alt"></i> 获取模型</button>
<button id="amily2_jqyh_test_connection" class="menu_button primary"><i class="fas fa-plug"></i> 测试连接</button>
</div>
</div>
<div id="amily2_jqyh_preset_config" class="inline-settings-grid" style="display: none; grid-column: 1 / -1;">
<label for="amily2_jqyh_tavern_profile">选择酒馆预设</label>
<select id="amily2_jqyh_tavern_profile" class="text_pole"></select>
</div>
<label for="amily2_jqyh_max_tokens">最大 Tokens: <span id="amily2_jqyh_max_tokens_value">4000</span></label>
<input type="number" class="text_pole" id="amily2_jqyh_max_tokens" min="100" max="100000" value="4000">
<label for="amily2_jqyh_temperature">温度: <span id="amily2_jqyh_temperature_value">0.7</span></label>
<input type="number" class="text_pole" id="amily2_jqyh_temperature" min="0" max="2" value="0.7">
</div>
<legend>剧情优化 API</legend>
<p class="notes" style="margin: 0;">
剧情优化所用的连接配置统一在
<strong>API 连接配置 → 功能分配 → 剧情优化 / JQYH</strong>
中指定,无需在此单独填写。
</p>
</fieldset>
<fieldset class="settings-group">

View File

@@ -250,6 +250,13 @@
<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>
<div class="control-block-with-switch" style="margin-bottom: 10px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px;">
@@ -288,7 +295,22 @@
</fieldset>
<hr class="section-divider" style="margin: 10px 0;">
<!-- Function Call 填表 -->
<div class="control-block-with-switch" style="margin-bottom: 6px;">
<label for="table-fill-function-call-enabled" title="使用 OpenAI Function Call工具调用进行填表模型直接返回结构化操作列表无需解析 &lt;Amily2Edit&gt; 指令块。仅支持 openai 直连模式。">使用 Function Call 填表</label>
<label class="toggle-switch">
<input type="checkbox" id="table-fill-function-call-enabled">
<span class="slider"></span>
</label>
</div>
<p class="notes" style="margin-bottom: 6px;">仅支持 openai 直连接口tableFilling 槽位)。启用后跳过 &lt;Amily2Edit&gt; 文本解析,由模型直接返回操作列表。</p>
<div style="background: rgba(255, 160, 0, 0.12); border-left: 3px solid #ffa000; border-radius: 3px; padding: 6px 10px; margin-bottom: 10px; font-size: 0.85em; color: #ffcc80;">
⚠️ 部分公益站因禁止用于跑代码会屏蔽 tools 参数,请确认公益站是否支持 tools 调用,避免被意外封禁。
</div>
<hr class="section-divider" style="margin: 10px 0;">
<!-- Nccs API 控制区域 -->
<fieldset class="settings-group" style="border-style: dashed; padding: 8px; margin-bottom: 10px;">
<legend><i class="fas fa-brain"></i> Nccs API 系统</legend>

View File

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

View File

@@ -83,6 +83,20 @@
</div>
</fieldset>
<!-- 旧配置清理 -->
<fieldset class="settings-group">
<legend><i class="fas fa-broom"></i> 旧配置清理</legend>
<small class="notes" style="display:block; margin-bottom:10px;">
旧版各模块独立的 API 配置URL / Key / Model / 温度等)已自动迁移到上方"连接配置"。
若上方分配无误且使用一切正常,可点击下方按钮清除 <code>extension_settings</code> 中的旧字段
<code>localStorage</code> 中的旧 API Key避免残留卡 bug。<br/>
<strong>清理前会校验所有旧字段所属槽位都已分配 profile</strong>,未分配的槽位会阻止清理并提示。
</small>
<button id="amily2_clear_legacy_config" class="menu_button caution interactable small_button amily2-vbtn">
<span class="vbtn-icon"><i class="fas fa-trash-alt"></i></span><span class="vbtn-label">清除旧配置残留</span>
</button>
</fieldset>
<!-- 新建/编辑 Profile 表单details 折叠) -->
<details id="amily2_profile_form_details" class="settings-group amily2-profile-form">
<summary>
@@ -109,22 +123,33 @@
<div class="amily2_settings_block">
<label for="amily2_pf_provider">接口类型</label>
<select id="amily2_pf_provider" class="text_pole">
<option value="openai">OpenAI / 兼容接口(推荐)</option>
<option value="google">Google Gemini 直连</option>
<option value="sillytavern_backend">SillyTavern 后端代理</option>
<option value="sillytavern_preset">SillyTavern 预设转发</option>
<optgroup label="官方预设(自动填默认 URL">
<option value="openai">OpenAI (GPT)</option>
<option value="anthropic">Anthropic Claude</option>
<option value="google">Google Gemini</option>
<option value="openrouter">OpenRouter (聚合)</option>
<option value="deepseek">DeepSeek</option>
<option value="xai">xAI Grok</option>
</optgroup>
<optgroup label="自定义 / 高级">
<option value="custom_oai">Custom OpenAI 兼容(自填 URL</option>
<option value="sillytavern_backend">SillyTavern 后端代理</option>
<option value="sillytavern_preset">SillyTavern 预设转发</option>
</optgroup>
</select>
</div>
<div class="amily2_settings_block" id="amily2_pf_url_row">
<label for="amily2_pf_url">API 地址</label>
<input id="amily2_pf_url" type="text" class="text_pole" placeholder="https://api.example.com/v1" />
</div>
<!-- Google 专属提示 -->
<div id="amily2_pf_google_note" style="display:none; margin-bottom:8px;">
<small class="notes" style="display:block; padding:6px 10px; background:var(--black10a); border-radius:4px; border-left:3px solid #4285f4;">
<i class="fas fa-info-circle" style="color:#4285f4;"></i>
Google AI Studio — 接口地址已自动配置,只需填写 API Key 即可。
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener" style="color:#4285f4;">aistudio.google.com</a> 生成密钥。
<!-- Vendor 提示(动态内容由 JS 填充) -->
<div id="amily2_pf_vendor_note" style="display:none; margin-bottom:8px;">
<small class="notes" style="display:block; padding:6px 10px; background:var(--black10a); border-radius:4px; border-left:3px solid var(--SmartThemeQuoteColor);">
<i class="fas fa-info-circle"></i>
<span id="amily2_pf_vendor_note_text"></span>
<span id="amily2_pf_vendor_note_link_wrap" style="display:none;">
<a id="amily2_pf_vendor_note_link" href="#" target="_blank" rel="noopener" style="color:var(--SmartThemeQuoteColor);"></a>
</span>
</small>
</div>
<div class="amily2_settings_block">
@@ -175,6 +200,30 @@
<small class="notes" style="display:block; font-weight:normal;">以 stream:true 接收 SSE 后拼接,适用于经 CloudFlare 免费代理的接口</small>
</label>
</div>
<div class="amily2_settings_block">
<label for="amily2_pf_custom_params">
自定义参数 (JSON)
<small class="notes" style="display:block; font-weight:normal;">
透传到 LLM 请求 body 的额外参数。留空或 <code>{}</code> 表示不附加。
</small>
</label>
<textarea id="amily2_pf_custom_params"
class="text_pole"
rows="6"
spellcheck="false"
style="font-family:monospace; font-size:0.85em;"
placeholder='{
"top_p": 0.9,
"frequency_penalty": 0.3,
"stop": ["\n###"]
}'></textarea>
<small id="amily2_pf_custom_params_hint"
class="notes"
style="display:block; margin-top:4px; color:var(--SmartThemeQuoteColor); font-size:0.82em;"></small>
<small id="amily2_pf_custom_params_error"
class="notes"
style="display:none; margin-top:4px; color:var(--warning, #d9534f); font-size:0.82em;"></small>
</div>
</div>
</details>
</div>

View File

@@ -0,0 +1,208 @@
{
"version": 1,
"_doc": "API 厂商参数 registry。用作自定义参数编辑器的提示导航不做强制约束 —— 用户写厂商不认识的参数会被原样发送,至多被服务端忽略。新增厂商:在 vendors 数组追加一项;新增参数:在对应 vendor.params 加一条。",
"vendors": [
{
"id": "anthropic",
"displayName": "Anthropic Claude",
"match": ["api.anthropic.com", "anthropic.com"],
"defaultUrl": "https://api.anthropic.com/v1",
"doc": "https://docs.anthropic.com/en/api/openai-sdk",
"_note": "通过 Anthropic 官方的 OpenAI 兼容层接入。需要 anthropic-version header 走 ST backend 自动加。",
"params": {
"top_p": {
"type": "number",
"range": [0, 1],
"desc": "核采样阈值。与 temperature 二选一,不要同时调。"
},
"top_k": {
"type": "integer",
"desc": "采样候选词数量上限。"
},
"stop_sequences": {
"type": "array<string>",
"desc": "停止序列(注意 Anthropic 用复数形式)。"
},
"thinking": {
"type": "object",
"desc": "Claude 3.7+ 思考模式:{ \"type\": \"enabled\", \"budget_tokens\": 1024 }。"
},
"metadata": {
"type": "object",
"desc": "{ \"user_id\": \"...\" } 用于厂商侧滥用追踪。"
}
}
},
{
"id": "openai",
"displayName": "OpenAI (GPT)",
"match": ["api.openai.com", "openai.com"],
"defaultUrl": "https://api.openai.com/v1",
"doc": "https://platform.openai.com/docs/api-reference/chat/create",
"params": {
"top_p": {
"type": "number",
"range": [0, 1],
"desc": "核采样阈值。与 temperature 二选一。"
},
"frequency_penalty": {
"type": "number",
"range": [-2, 2],
"desc": "已出现 token 的惩罚(频次基础)。"
},
"presence_penalty": {
"type": "number",
"range": [-2, 2],
"desc": "已出现 token 的惩罚(存在与否)。"
},
"seed": {
"type": "integer",
"desc": "随机数种子,相同 seed + 相同输入 ≈ 相同输出(不保证)。"
},
"stop": {
"type": "string | array<string>",
"desc": "停止序列,最多 4 个。"
},
"response_format": {
"type": "object",
"desc": "{ \"type\": \"json_object\" } 或 { \"type\": \"json_schema\", \"json_schema\": {...} }。"
},
"reasoning_effort": {
"type": "string",
"values": ["low", "medium", "high"],
"desc": "o 系列推理强度。"
},
"logit_bias": {
"type": "object",
"desc": "{ tokenId: bias } 调整特定 token 概率。"
}
}
},
{
"id": "openrouter",
"displayName": "OpenRouter (聚合)",
"match": ["openrouter.ai"],
"defaultUrl": "https://openrouter.ai/api/v1",
"doc": "https://openrouter.ai/docs",
"params": {
"top_p": { "type": "number", "range": [0, 1], "desc": "核采样阈值。" },
"top_k": { "type": "integer", "desc": "部分模型支持。" },
"frequency_penalty": { "type": "number", "range": [-2, 2], "desc": "频次惩罚。" },
"presence_penalty": { "type": "number", "range": [-2, 2], "desc": "存在惩罚。" },
"seed": { "type": "integer", "desc": "随机数种子。" },
"stop": { "type": "string | array<string>", "desc": "停止序列。" },
"provider": {
"type": "object",
"desc": "OR 路由配置:{ \"order\": [\"Anthropic\"], \"allow_fallbacks\": true, \"require_parameters\": false, \"data_collection\": \"deny\" }。"
},
"transforms": {
"type": "array<string>",
"desc": "[\"middle-out\"] 启用中间挤压防 context 超限。"
},
"models": {
"type": "array<string>",
"desc": "fallback 模型列表,主模型失败时按顺序尝试。"
},
"route": {
"type": "string",
"values": ["fallback"],
"desc": "\"fallback\" 启用 models 列表。"
}
}
},
{
"id": "google",
"displayName": "Google Gemini",
"match": ["googleapis.com", "generativelanguage.googleapis.com"],
"defaultUrl": "https://generativelanguage.googleapis.com/v1beta/openai",
"doc": "https://ai.google.dev/gemini-api/docs/openai",
"_note": "走 Gemini 的 OpenAI 兼容端点 /v1beta/openai。原生 generate-content 端点不在此模式覆盖范围,需用 Custom 模式手填。",
"params": {
"top_p": { "type": "number", "range": [0, 1], "desc": "核采样阈值。" },
"top_k": { "type": "integer", "desc": "Gemini 支持 top_k 采样。" },
"stop_sequences": {
"type": "array<string>",
"desc": "停止序列(数组形式)。"
},
"safety_settings": {
"type": "array<object>",
"desc": "[{\"category\": \"HARM_CATEGORY_HARASSMENT\", \"threshold\": \"BLOCK_NONE\"}, ...] 安全过滤。"
},
"response_mime_type": {
"type": "string",
"values": ["text/plain", "application/json"],
"desc": "强制响应格式。"
},
"thinking_config": {
"type": "object",
"desc": "Gemini 2.5 思考配置:{ \"thinking_budget\": 1024 }。"
}
}
},
{
"id": "deepseek",
"displayName": "DeepSeek",
"match": ["api.deepseek.com", "deepseek.com"],
"defaultUrl": "https://api.deepseek.com/v1",
"doc": "https://api-docs.deepseek.com",
"params": {
"top_p": { "type": "number", "range": [0, 1], "desc": "核采样阈值。" },
"frequency_penalty": { "type": "number", "range": [-2, 2], "desc": "频次惩罚。" },
"presence_penalty": { "type": "number", "range": [-2, 2], "desc": "存在惩罚。" },
"stop": { "type": "string | array<string>", "desc": "停止序列。" },
"response_format": {
"type": "object",
"desc": "{ \"type\": \"json_object\" } 强制 JSON 输出。"
},
"thinking": {
"type": "object",
"desc": "V3.2+ 思考模式开关:{ \"type\": \"enabled\" } 或 { \"type\": \"disabled\" },默认 enabled。"
},
"reasoning_effort": {
"type": "string",
"values": ["high", "max"],
"desc": "思考强度,默认 high复杂 Agent 请求会自动升至 max。"
},
"_warning_reasoner": "deepseek-reasoner 模型会忽略 temperature/top_p/frequency_penalty/presence_penalty。"
}
},
{
"id": "xai",
"displayName": "xAI Grok",
"match": ["api.x.ai", "x.ai", "xai.com"],
"defaultUrl": "https://api.x.ai/v1",
"doc": "https://docs.x.ai/api",
"params": {
"top_p": { "type": "number", "range": [0, 1], "desc": "核采样阈值。" },
"frequency_penalty": { "type": "number", "range": [-2, 2], "desc": "频次惩罚。" },
"presence_penalty": { "type": "number", "range": [-2, 2], "desc": "存在惩罚。" },
"seed": { "type": "integer", "desc": "随机数种子。" },
"stop": { "type": "string | array<string>", "desc": "停止序列。" },
"response_format": {
"type": "object",
"desc": "{ \"type\": \"json_object\" }。"
},
"search_parameters": {
"type": "object",
"desc": "Live Search 配置:{ \"mode\": \"auto\" | \"on\" | \"off\", \"sources\": [...] }。"
}
}
}
],
"fallback": {
"id": "openai-compat",
"displayName": "OpenAI-compatible (通用)",
"doc": "Mistral / Together / Fireworks / 本地 KoboldCpp / Ollama 等。匹配不到具体 vendor 时归到此条,提示 OpenAI 标准参数。",
"params": {
"top_p": { "type": "number", "range": [0, 1], "desc": "核采样阈值。" },
"top_k": { "type": "integer", "desc": "部分实现支持。" },
"frequency_penalty": { "type": "number", "range": [-2, 2], "desc": "频次惩罚。" },
"presence_penalty": { "type": "number", "range": [-2, 2], "desc": "存在惩罚。" },
"min_p": { "type": "number", "range": [0, 1], "desc": "本地模型常用OpenAI 没有。" },
"seed": { "type": "integer", "desc": "随机数种子。" },
"stop": { "type": "string | array<string>", "desc": "停止序列。" },
"response_format": { "type": "object", "desc": "{ \"type\": \"json_object\" }。" },
"repetition_penalty": { "type": "number", "desc": "本地模型常用OpenAI 没有。" }
}
}
}

View File

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

View File

@@ -441,7 +441,7 @@ async function fetchSillyTavernPresetModels() {
export async function getApiSettings(slot = 'main') {
const s = extension_settings[extensionName] || {};
// 优先读取槽位分配的 Profile仅接管连接参数
// 优先读取槽位分配的 Profileprofile 一旦分配即为权威,不再被主面板/模块独立设置压制
const profile = await getSlotProfile(slot);
if (profile) {
const resolvedProvider = profile.provider === 'sillytavern_backend'
@@ -453,10 +453,10 @@ export async function getApiSettings(slot = 'main') {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
// 温度 / MaxTokens 读面板值profile-sync 保留了这些输入框)
maxTokens: s.maxTokens ?? profile.maxTokens ?? 65500,
temperature: s.temperature ?? profile.temperature ?? 1.0,
maxTokens: profile.maxTokens ?? 65500,
temperature: profile.temperature ?? 1.0,
fakeStream: profile.fakeStream ?? false,
customParams: profile.customParams ?? {},
tavernProfile: '',
};
}
@@ -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: '',
@@ -588,7 +587,10 @@ export async function callAI(messages, options = {}) {
apiUrl: apiSettings.apiUrl,
apiKey: apiSettings.apiKey,
apiProvider: apiSettings.apiProvider,
...options
customParams: apiSettings.customParams ?? {},
...options,
// options 可显式覆盖 customParams体现"代码内显式 > profile 配置"
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
};
if (finalOptions.apiProvider !== 'sillytavern_preset') {
@@ -680,11 +682,14 @@ async function callOpenAICompatible(messages, options) {
'Content-Type': 'application/json'
},
body: JSON.stringify({
// 用户自定义参数profile.customParams + 显式 options.customParams 已在 callAI 合并)
...(options.customParams || {}),
// 表单托管的核心字段总是覆盖 customParams
model: options.model,
messages: messages,
max_tokens: options.maxTokens,
temperature: options.temperature,
stream: false
stream: false,
})
});
@@ -699,6 +704,21 @@ async function callOpenAICompatible(messages, options) {
async function callOpenAITest(messages, options) {
const body = {
// 1. 可调默认值(用户 customParams 可覆盖)
top_p: options.top_p || 1,
frequency_penalty: 0,
presence_penalty: 0.12,
include_reasoning: false,
reasoning_effort: 'medium',
enable_web_search: false,
request_images: false,
custom_prompt_post_processing: 'strict',
group_names: [],
// 2. 用户 customParams 覆盖上层默认值
...(options.customParams || {}),
// 3. 表单托管的核心字段总是 win
chat_completion_source: 'openai',
messages: messages,
model: options.model,
@@ -707,15 +727,6 @@ async function callOpenAITest(messages, options) {
stream: false,
max_tokens: options.maxTokens || 30000,
temperature: options.temperature || 1,
top_p: options.top_p || 1,
custom_prompt_post_processing: 'strict',
enable_web_search: false,
frequency_penalty: 0,
group_names: [],
include_reasoning: false,
presence_penalty: 0.12,
reasoning_effort: 'medium',
request_images: false,
};
const response = await fetch('/api/backends/chat-completions/generate', {
@@ -816,6 +827,9 @@ async function callSillyTavernBackend(messages, options) {
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
// 用户 customParams可被核心字段覆盖
...(options.customParams || {}),
// 表单托管字段总是 win
chat_completion_source: 'custom',
custom_url: options.apiUrl,
api_key: options.apiKey,
@@ -823,7 +837,7 @@ async function callSillyTavernBackend(messages, options) {
messages: messages,
max_tokens: options.maxTokens,
temperature: options.temperature,
stream: false
stream: false,
})
});
@@ -934,3 +948,119 @@ 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 ?? {}) },
...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)),
});
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) {
// 首次失败(含 ST 代理吞掉错误码场景)无条件去掉 tool_choice 重试一次
// 思考模式模型支持 tools 但不支持强制 tool_choice追加强制指令防止模型直接输出文本
console.warn('[Amily2-外交部] 首次 FC 请求失败,去掉 tool_choice 重试…', firstError.message);
const retryMessages = [
...messages,
{ role: 'user', content: `你必须通过调用 \`${tool.function.name}\` 函数来返回结果,禁止直接输出文本内容。` },
];
data = await doFCRequest(false, retryMessages);
}
const toolCalls = data?.choices?.[0]?.message?.tool_calls;
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
console.warn('[Amily2-外交部] Function Call 响应中无 tool_callsfinish_reason:', data?.choices?.[0]?.finish_reason);
return null;
}
const argsString = toolCalls[0]?.function?.arguments;
console.groupCollapsed('[Amily2号-Function Call 响应]');
console.log(argsString);
console.groupEnd();
return argsString ?? null;
} catch (error) {
console.error('[Amily2-外交部] Function Call 调用失败:', error);
toastr.error(`Function Call 调用失败: ${error.message}`, 'Amily2-外交部');
return null;
}
}

View File

@@ -3,11 +3,12 @@ import { getRequestHeaders } from "/script.js";
import { extensionName } from "../../utils/settings.js";
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { detectVendor } from '../../utils/api-vendor.js';
async function getConcurrentApiSettings() {
const s = extension_settings[extensionName] || {};
// 优先读取槽位分配的 Profile仅接管连接参数
// 优先读取槽位分配的 Profileprofile 一旦分配即权威slider 残值不再覆盖
const profile = await getSlotProfile('plotOptConc');
if (profile) {
return {
@@ -15,8 +16,7 @@ async function getConcurrentApiSettings() {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
// MaxTokens 读面板值
maxTokens: s.plotOpt_concurrentMaxTokens ?? profile.maxTokens ?? 8100,
maxTokens: profile.maxTokens ?? 8100,
temperature: profile.temperature ?? 1,
};
}
@@ -47,7 +47,7 @@ export async function callConcurrentAI(messages, options = {}) {
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
console.warn("[Amily2-Concurrent外交部] API配置不完整无法调用AI");
toastr.error("并发API配置不完整请检查URL、Key和模型配置。", "Concurrent-外交部");
toastr.error("并发剧情优化plotOptConc未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写并发优化独立设置。", "Amily2-并发优化未配置");
return null;
}
@@ -97,7 +97,7 @@ export async function callConcurrentAI(messages, options = {}) {
}
async function callConcurrentOpenAITest(messages, options) {
const isGoogleApi = options.apiUrl.includes('googleapis.com');
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
const body = {
chat_completion_source: 'openai',

View File

@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { detectVendor } from '../../utils/api-vendor.js';
let ChatCompletionService = undefined;
try {
@@ -47,7 +48,7 @@ function normalizeApiResponse(responseData) {
export async function getJqyhApiSettings() {
const s = extension_settings[extensionName] || {};
// JQYH 与剧情优化互斥,共用 'plotOpt' 槽位
// JQYH 与剧情优化互斥,共用 'plotOpt' 槽位profile 一旦分配即权威slider 残值不再覆盖)
const profile = await getSlotProfile('plotOpt');
if (profile) {
return {
@@ -55,9 +56,9 @@ export async function getJqyhApiSettings() {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
// 温度 / MaxTokens 读面板值
maxTokens: s.jqyhMaxTokens ?? profile.maxTokens ?? 65500,
temperature: s.jqyhTemperature ?? profile.temperature ?? 1.0,
maxTokens: profile.maxTokens ?? 65500,
temperature: profile.temperature ?? 1.0,
customParams: profile.customParams ?? {},
tavernProfile: '',
};
}
@@ -70,6 +71,7 @@ export async function getJqyhApiSettings() {
model: s.jqyhModel || '',
maxTokens: s.jqyhMaxTokens || 4000,
temperature: s.jqyhTemperature || 0.7,
customParams: {},
tavernProfile: s.jqyhTavernProfile || '',
};
}
@@ -96,7 +98,7 @@ export async function callJqyhAI(messages, options = {}) {
if (finalOptions.apiMode !== 'sillytavern_preset') {
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
console.warn("[Amily2-Jqyh外交部] API配置不完整无法调用AI");
toastr.error("API配置不完整请检查URL、Key和模型配置。", "Jqyh-外交部");
toastr.error("剧情优化前置JQYH未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 JQYH 独立设置。", "Amily2-JQYH 未配置");
return null;
}
}
@@ -160,9 +162,11 @@ export async function callJqyhAI(messages, options = {}) {
}
async function callJqyhOpenAITest(messages, options) {
const isGoogleApi = options.apiUrl.includes('googleapis.com');
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
const body = {
top_p: options.top_p || 1,
...(options.customParams || {}),
chat_completion_source: 'openai',
messages: messages,
model: options.model,
@@ -171,7 +175,6 @@ async function callJqyhOpenAITest(messages, options) {
stream: false,
max_tokens: options.maxTokens || 30000,
temperature: options.temperature || 1,
top_p: options.top_p || 1,
};
if (!isGoogleApi) {
@@ -245,7 +248,8 @@ async function callJqyhSillyTavernPreset(messages, options) {
responsePromise = context.ConnectionManagerRequestService.sendRequest(
targetProfile.id,
messages,
options.maxTokens || 4000
options.maxTokens || 4000,
options.customParams || {}
);
} finally {

View File

@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { detectVendor } from '../../utils/api-vendor.js';
let ChatCompletionService = undefined;
try {
@@ -41,7 +42,7 @@ if (window.Amily2Bus) {
export async function getNccsApiSettings() {
const s = extension_settings[extensionName] || {};
// 优先读取 'nccs' 槽位分配的 Profile仅接管连接参数
// 优先读取 'nccs' 槽位分配的 Profileprofile 一旦分配即权威,旧 slider 残值不再覆盖
const profile = await getSlotProfile('nccs');
if (profile) {
return {
@@ -50,9 +51,9 @@ export async function getNccsApiSettings() {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
// 温度 / MaxTokens / FakeStream 读面板值profile-sync 保留了这些输入框)
maxTokens: s.nccsMaxTokens ?? profile.maxTokens ?? 65500,
temperature: s.nccsTemperature ?? profile.temperature ?? 1.0,
maxTokens: profile.maxTokens ?? 65500,
temperature: profile.temperature ?? 1.0,
customParams: profile.customParams ?? {},
tavernProfile: '',
useFakeStream: s.nccsFakeStreamEnabled ?? false,
};
@@ -67,6 +68,7 @@ export async function getNccsApiSettings() {
model: s.nccsModel || '',
maxTokens: s.nccsMaxTokens ?? 8192,
temperature: s.nccsTemperature ?? 1,
customParams: {},
tavernProfile: s.nccsTavernProfile || '',
useFakeStream: s.nccsFakeStreamEnabled || false,
};
@@ -94,7 +96,7 @@ export async function callNccsAI(messages, options = {}) {
if (finalOptions.apiMode !== 'sillytavern_preset') {
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
console.warn("[Amily2-Nccs外交部] API配置不完整无法调用AI");
toastr.error("API配置不完整请检查URL、Key和模型配置。", "Nccs-外交部");
toastr.error("并发模块NCCS未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 NCCS 独立设置。", "Amily2-NCCS 未配置");
return null;
}
} else {
@@ -187,8 +189,10 @@ function normalizeApiResponse(responseData) {
}
async function callNccsOpenAITest(messages, options) {
const isGoogleApi = options.apiUrl.includes('googleapis.com');
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
const body = {
top_p: options.top_p || 1,
...(options.customParams || {}),
chat_completion_source: 'openai',
messages: messages,
model: options.model,
@@ -197,7 +201,6 @@ async function callNccsOpenAITest(messages, options) {
stream: !!options.stream,
max_tokens: 8192,
temperature: 1,
top_p: options.top_p || 1,
};
if (!isGoogleApi) {
@@ -244,7 +247,8 @@ async function callNccsSillyTavernPreset(messages, options) {
const result = await context.ConnectionManagerRequestService.sendRequest(
targetProfile.id,
messages,
8192
8192,
options.customParams || {}
);
return normalizeApiResponse(result);

View File

@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { detectVendor } from '../../utils/api-vendor.js';
let ChatCompletionService = undefined;
try {
@@ -47,7 +48,7 @@ function normalizeApiResponse(responseData) {
export async function getNgmsApiSettings() {
const s = extension_settings[extensionName] || {};
// 优先读取 'ngms' 槽位分配的 Profile仅接管连接参数
// 优先读取 'ngms' 槽位分配的 Profileprofile 一旦分配即权威,旧 slider 残值不再覆盖
const profile = await getSlotProfile('ngms');
if (profile) {
return {
@@ -55,9 +56,9 @@ export async function getNgmsApiSettings() {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
// 温度 / MaxTokens / FakeStream 读面板值
maxTokens: s.ngmsMaxTokens ?? profile.maxTokens ?? 65500,
temperature: s.ngmsTemperature ?? profile.temperature ?? 1.0,
maxTokens: profile.maxTokens ?? 65500,
temperature: profile.temperature ?? 1.0,
customParams: profile.customParams ?? {},
tavernProfile: '',
useFakeStream: s.ngmsFakeStreamEnabled ?? false,
};
@@ -71,6 +72,7 @@ export async function getNgmsApiSettings() {
model: s.ngmsModel || '',
maxTokens: s.ngmsMaxTokens ?? 30000,
temperature: s.ngmsTemperature ?? 1.0,
customParams: {},
tavernProfile: s.ngmsTavernProfile || '',
useFakeStream: s.ngmsFakeStreamEnabled || false,
};
@@ -101,7 +103,7 @@ export async function callNgmsAI(messages, options = {}) {
if (finalOptions.apiMode !== 'sillytavern_preset') {
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
console.warn("[Amily2-Ngms外交部] API配置不完整无法调用AI");
toastr.error("API配置不完整请检查URL、Key和模型配置。", "Ngms-外交部");
toastr.error("总结模块NGMS未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 NGMS 独立设置。", "Amily2-NGMS 未配置");
return null;
}
} else {
@@ -221,9 +223,11 @@ async function fetchFakeStream(url, opts) {
}
async function callNgmsOpenAITest(messages, options) {
const isGoogleApi = options.apiUrl.includes('googleapis.com');
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
const body = {
top_p: options.top_p || 1,
...(options.customParams || {}),
chat_completion_source: 'openai',
messages: messages,
model: options.model,
@@ -232,7 +236,6 @@ async function callNgmsOpenAITest(messages, options) {
stream: !!options.stream,
max_tokens: options.maxTokens || 30000,
temperature: options.temperature || 1,
top_p: options.top_p || 1,
};
if (!isGoogleApi) {
@@ -312,7 +315,8 @@ async function callNgmsSillyTavernPreset(messages, options) {
responsePromise = context.ConnectionManagerRequestService.sendRequest(
targetProfile.id,
messages,
options.maxTokens || 4000
options.maxTokens || 4000,
options.customParams || {}
);
} finally {

View File

@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { detectVendor } from '../../utils/api-vendor.js';
let ChatCompletionService = undefined;
try {
@@ -47,7 +48,7 @@ function normalizeApiResponse(responseData) {
export async function getSybdApiSettings() {
const s = extension_settings[extensionName] || {};
// 优先读取 'sybd' 槽位分配的 Profile
// 优先读取 'sybd' 槽位分配的 Profileprofile 一旦分配即权威slider 残值不再覆盖)
const profile = await getSlotProfile('sybd');
if (profile) {
return {
@@ -55,8 +56,9 @@ export async function getSybdApiSettings() {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
maxTokens: s.sybdMaxTokens ?? profile.maxTokens ?? 4000,
temperature: s.sybdTemperature ?? profile.temperature ?? 0.7,
maxTokens: profile.maxTokens ?? 4000,
temperature: profile.temperature ?? 0.7,
customParams: profile.customParams ?? {},
tavernProfile: '',
};
}
@@ -69,6 +71,7 @@ export async function getSybdApiSettings() {
model: s.sybdModel || '',
maxTokens: s.sybdMaxTokens || 4000,
temperature: s.sybdTemperature || 0.7,
customParams: {},
tavernProfile: s.sybdTavernProfile || '',
};
}
@@ -95,7 +98,7 @@ export async function callSybdAI(messages, options = {}) {
if (finalOptions.apiMode !== 'sillytavern_preset') {
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
console.warn("[Amily2-Sybd外交部] API配置不完整无法调用AI");
toastr.error("API配置不完整请检查URL、Key和模型配置。", "Sybd-外交部");
toastr.error("术语表填写SYBD未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 SYBD 独立设置。", "Amily2-SYBD 未配置");
return null;
}
}
@@ -159,9 +162,11 @@ export async function callSybdAI(messages, options = {}) {
}
async function callSybdOpenAITest(messages, options) {
const isGoogleApi = options.apiUrl.includes('googleapis.com');
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
const body = {
top_p: options.top_p || 1,
...(options.customParams || {}),
chat_completion_source: 'openai',
messages: messages,
model: options.model,
@@ -170,7 +175,6 @@ async function callSybdOpenAITest(messages, options) {
stream: false,
max_tokens: options.maxTokens || 30000,
temperature: options.temperature || 1,
top_p: options.top_p || 1,
};
if (!isGoogleApi) {
@@ -244,7 +248,8 @@ async function callSybdSillyTavernPreset(messages, options) {
responsePromise = context.ConnectionManagerRequestService.sendRequest(
targetProfile.id,
messages,
options.maxTokens || 4000
options.maxTokens || 4000,
options.customParams || {}
);
} finally {

View File

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

View File

@@ -4,6 +4,7 @@ import { characters, this_chid, saveSettingsDebounced, getCharacters } from "/sc
import { world_names } from "/scripts/world-info.js";
import { getApiConfig, setApiConfig, testConnection, fetchModels } from "./api.js";
import { tools } from "./tools.js";
import { syncSlot } from "../../ui/profile-sync.js";
const extensionFolderPath = `scripts/extensions/third-party/${extensionName}`;
@@ -42,6 +43,7 @@ export async function openAutoCharCardWindow() {
try {
populateDropdowns();
loadApiSettings();
await syncSlot('autoCharCard');
renderRulesList();
renderSessionsList();
restoreChatHistory();

View File

@@ -15,7 +15,7 @@ import { compatibleWriteToLorebook } from "./tavernhelper-compatibility.js";
import { ingestTextToHanlinyuan } from "./rag-processor.js";
import { showSummaryModal, showHtmlModal } from "../ui/page-window.js";
import { getPresetPrompts, getMixedOrder } from '../PresetSettings/index.js';
import { callAI, generateRandomSeed } from "./api.js";
import { generateRandomSeed } from "./api.js";
import { callNgmsAI } from "./api/Ngms_api.js";
import { executeAutoHide } from "./autoHideManager.js";
import { resolveHistoriographyRuleConfig } from "../utils/config/RuleProfileManager.js";
@@ -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 {
@@ -384,7 +387,9 @@ async function getSummary(formattedHistory, toastTitle, retryCount = 0) {
}
}
const summary = settings.ngmsEnabled ? await callNgmsAI(messages) : await callAI(messages);
// 历史总结统一走 NGMS slotngms 未配置时 callNgmsAI 自带模块名错误提示。
// 旧 ngmsEnabled 三元式 fallback 到 main 的设计已在主 API 移除后失效。
const summary = await callNgmsAI(messages);
console.log('[大史官-微言录] AI回复的全部内容:', summary);
if (!summary || !summary.trim()) {
@@ -603,7 +608,8 @@ export async function executeRefinement(worldbook, loreKey) {
const getRefinedContent = async (retryCount = 0) => {
toastr.info("正在召唤模型进行内容精炼...", "宏史卷重铸");
const content = settings.ngmsEnabled ? await callNgmsAI(messages) : await callAI(messages);
// 历史总结统一走 NGMS slotngms 未配置时 callNgmsAI 自带错误提示。
const content = await callNgmsAI(messages);
if (!content || !content.trim()) {
const maxRetries = settings.historiographyMaxRetries ?? 2;

View File

@@ -9,20 +9,20 @@ import {
buildGoogleEmbeddingApiUrl
} from './utils/googleAdapter.js';
import { getSlotProfile } from './api/api-resolver.js';
import { extensionName } from '../utils/settings.js';
const MODULE_NAME = 'hanlinyuan-rag-core';
const GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com';
function getSettings() {
const context = SillyTavern.getContext();
if (!context || !context.extensionSettings || !context.extensionSettings[MODULE_NAME]) {
console.error('[翰林院-API] 无法获取设置API调用可能失败。');
return {
retrieval: {},
rerank: {}
};
}
return context.extensionSettings[MODULE_NAME];
const root = extension_settings[extensionName];
const nested = root && root[MODULE_NAME];
if (nested) return nested;
// 读侧兼容:若迁移尚未触发(极早期调用),回退至旧顶层位置,避免空配置。
const legacy = extension_settings[MODULE_NAME];
if (legacy) return legacy;
console.error('[翰林院-API] 无法获取设置API调用可能失败。');
return { retrieval: {}, rerank: {} };
}
/**
@@ -33,7 +33,7 @@ export async function getEmbedRetrievalSettings() {
const profile = await getSlotProfile('ragEmbed');
if (profile) {
return {
apiEndpoint: 'custom',
apiEndpoint: profile.provider === 'google' ? 'google_direct' : 'custom',
customApiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
embeddingModel: profile.model,
@@ -49,12 +49,13 @@ export async function getEmbedRetrievalSettings() {
export async function getRerankSettings() {
const profile = await getSlotProfile('ragRerank');
if (profile) {
const manualSettings = getSettings().rerank || {};
return {
url: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
top_n: getSettings().rerank?.top_n ?? 10,
apiMode: 'custom',
top_n: manualSettings.top_n ?? 10,
apiMode: manualSettings.apiMode ?? 'custom',
};
}
return getSettings().rerank || {};

View File

@@ -4,14 +4,17 @@ import {
extension_prompt_roles,
setExtensionPrompt,
eventSource,
event_types
event_types,
saveSettingsDebounced
} from '/script.js';
import { extension_settings } from '/scripts/extensions.js';
import * as ContextUtils from './utils/context-utils.js';
import { getCollectionIdInfo, getCharacterId, getCharacterStableId } from './utils/context-utils.js';
import { defaultSettings as ragDefaultSettings } from './rag-settings.js';
import { extractBlocksByTags, applyExclusionRules } from './utils/rag-tag-extractor.js';
import { resolveQueryPreprocessingRuleConfig } from '../utils/config/RuleProfileManager.js';
import { extensionName } from '../utils/settings.js';
import * as IngestionManager from './ingestion-manager.js';
import {
getEmbeddings,
@@ -148,6 +151,7 @@ function initialize() {
console.error('[翰林院] 未能获取SillyTavern上下文初始化失败。');
return;
}
migrateLegacyRagSettings();
settings = getSettings();
if (!window.hanlinyuanRagProcessor) {
window.hanlinyuanRagProcessor = {};
@@ -296,17 +300,16 @@ async function ingestTextToHanlinyuan(text, source = 'manual', metadata = {}, pr
}
function getSettings() {
if (!context || !context.extensionSettings) {
return structuredClone(ragDefaultSettings);
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
const root = extension_settings[extensionName];
let s = context.extensionSettings[MODULE_NAME];
let s = root[MODULE_NAME];
if (!s) {
s = {};
context.extensionSettings[MODULE_NAME] = s;
root[MODULE_NAME] = s;
}
if (s.condensationHistory === undefined) {
@@ -338,21 +341,73 @@ 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;
}
function saveSettings() {
if (context) context.saveSettingsDebounced();
saveSettingsDebounced();
}
function resetSettings() {
if (context) {
context.extensionSettings[MODULE_NAME] = structuredClone(ragDefaultSettings);
saveSettings();
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
extension_settings[extensionName][MODULE_NAME] = structuredClone(ragDefaultSettings);
saveSettings();
}
function migrateLegacyRagSettings() {
const legacy = extension_settings[MODULE_NAME];
if (!legacy || typeof legacy !== 'object') return;
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
const root = extension_settings[extensionName];
// legacy 是用户此前实际交互过的真数据来源nested 可能已被 super-memory 等模块用默认值填过,
// 因此采用 legacy-优先的深合并legacy 中的叶子值覆盖 nestednested 中 legacy 没有的键保留。
if (!root[MODULE_NAME] || typeof root[MODULE_NAME] !== 'object') {
root[MODULE_NAME] = legacy;
console.log(`[翰林院] 已迁移旧版 '${MODULE_NAME}' 设置到 extension_settings['${extensionName}']。`);
} else {
const merged = root[MODULE_NAME];
const overlayLegacy = (src, dst) => {
for (const key of Object.keys(src)) {
const sv = src[key];
if (sv && typeof sv === 'object' && !Array.isArray(sv) && dst[key] && typeof dst[key] === 'object' && !Array.isArray(dst[key])) {
overlayLegacy(sv, dst[key]);
} else {
dst[key] = sv;
}
}
};
overlayLegacy(legacy, merged);
console.log(`[翰林院] 发现新旧两处配置;已将顶层 '${MODULE_NAME}' 深合并覆盖到 extension_settings['${extensionName}']。`);
}
delete extension_settings[MODULE_NAME];
saveSettingsDebounced();
}
function showNotification(message, type = 'info') {

View File

@@ -65,8 +65,9 @@ export const defaultSettings = {
},
rerank: {
enabled: false,
apiMode: 'custom',
url: 'https://api.siliconflow.cn/v1',
apiKey: '',
apiKey: '',
model: 'Pro/BAAI/bge-reranker-v2-m3',
top_n: 5,
hybrid_alpha: 0.7,

View File

@@ -1,4 +1,5 @@
import { getContext, extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.js";
import { getCharacterStableId } from "../utils/context-utils.js";
import { getMemoryState } from "../table-system/manager.js";
import { extensionName } from "../../utils/settings.js";
@@ -160,26 +161,62 @@ export function getRelatedNodes(nodeId, maxDepth = 1) {
return related;
}
function getGraphStore(create = false) {
if (!extension_settings[extensionName]) {
if (!create) return null;
extension_settings[extensionName] = {};
}
const root = extension_settings[extensionName];
if (!root.relationship_graphs) {
if (!create) return null;
root.relationship_graphs = {};
}
return root.relationship_graphs;
}
function migrateLegacyRelationshipGraphs() {
const legacy = extension_settings.relationship_graphs;
if (!legacy || typeof legacy !== 'object') return;
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
const root = extension_settings[extensionName];
if (!root.relationship_graphs) {
root.relationship_graphs = legacy;
console.log(`[关系图谱] 已迁移旧版 'relationship_graphs' 到 extension_settings['${extensionName}']。`);
} else {
console.log(`[关系图谱] 发现遗留顶层 'relationship_graphs',但新位置已存在;合并遗留数据并清理顶层。`);
for (const [cid, data] of Object.entries(legacy)) {
if (!root.relationship_graphs[cid]) {
root.relationship_graphs[cid] = data;
}
}
}
delete extension_settings.relationship_graphs;
saveSettingsDebounced();
}
export async function saveGraph() {
const context = getContext();
const charId = getCharacterStableId();
if (!charId) return;
if (!context.extensionSettings.relationship_graphs) {
context.extensionSettings.relationship_graphs = {};
}
context.extensionSettings.relationship_graphs[charId] = graphData;
context.saveSettingsDebounced();
const store = getGraphStore(true);
if (!store) return;
store[charId] = graphData;
saveSettingsDebounced();
}
export async function loadGraph() {
const context = getContext();
const charId = getCharacterStableId();
if (!charId) return;
if (context.extensionSettings.relationship_graphs && context.extensionSettings.relationship_graphs[charId]) {
graphData = context.extensionSettings.relationship_graphs[charId];
const store = getGraphStore(false);
if (store && store[charId]) {
graphData = store[charId];
console.log(`[关系图谱] 已加载角色 ${charId} 的图谱: ${graphData.nodes.length} 个节点, ${graphData.edges.length} 条边。`);
} else {
graphData = { nodes: [], edges: [] };
@@ -188,6 +225,7 @@ export async function loadGraph() {
const context = getContext();
if (context) {
migrateLegacyRelationshipGraphs();
loadGraph();
document.addEventListener('AMILY2_TABLE_UPDATED', (e) => {
const { tableName } = e.detail;

View File

@@ -15,7 +15,6 @@ 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';
export async function processOptimization(latestMessage, previousMessages) {
@@ -433,9 +432,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 +479,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 +553,7 @@ export async function processPlotOptimization(currentUserMessage, contextMessage
attempt++;
console.log(`[${extensionName}] 剧情优化第 ${attempt} 次尝试...`);
const rawResponse = settings.jqyhEnabled ? await callJqyhAI(mainMessages) : await callAI(mainMessages, { slot: 'plotOpt' });
const rawResponse = await callAI(mainMessages, { slot: 'plotOpt' });
if (cancellationState.isCancelled) {
console.log(`[${extensionName}] 优化任务在API调用后被中止。`);

View File

@@ -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() {

View File

@@ -6,6 +6,7 @@ import { syncToLorebook, ensureMemoryBook, updateTransientHint, getMemoryBookNam
import { getMemoryState, loadMemoryState, saveMemoryState } from "../table-system/manager.js";
import { TABLE_UPDATED_EVENT } 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;
@@ -110,10 +111,15 @@ export function pushUpdate(payload) {
updateQueue.push({ tableName, data, role, headers, rowStatuses });
_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);
}

View File

@@ -0,0 +1,190 @@
/**
* @file Action: applyOperations —— 表格操作推演核心。
*
* 输入:基准 state + Operation[]
* 输出:新 state深拷贝+ Change[] 变更记录
*
* 不依赖任何 formatter / store / persistence —— 纯函数。
* 所有 formatter (legacy / json / toolcall) 解析完都吐 Operation[] 给本函数。
*
* 历史来源:从 executor.js 中 insertRow / updateRow / deleteRow 三个内部函数
* 抽出行为完全等价。executeCommands 改造为parse 文本 → ops → 调本函数。
*
* 关键行为约定(不要随便改,否则破坏老存档):
* - 入参 state 不被修改;返回的 state 是 JSON 深拷贝
* - updateRow 的 rowIndex 越界 → 自动转换为 insertRow历史智能修正
* - deleteRow 是延迟删除rowStatuses[rowIndex] = 'pending-deletion',行不实际从 rows 中移除
* - insertRow 的 changes 用 type='update'(每个被填的单元格一条),不要发明 'insert'
*
* @typedef {import('../dto/Table.js').TableState} TableState
* @typedef {import('../dto/Operation.js').Operation} Operation
* @typedef {import('../dto/Operation.js').InsertRowOperation} InsertRowOperation
* @typedef {import('../dto/Operation.js').UpdateRowOperation} UpdateRowOperation
* @typedef {import('../dto/Operation.js').DeleteRowOperation} DeleteRowOperation
* @typedef {import('../dto/Change.js').Change} Change
*/
import { log } from '../logger.js';
/**
* 在表格末尾插入一行。in-place mutation调用方已 clone
* @param {TableState} state
* @param {number} tableIndex
* @param {Object<string, string>} data
* @returns {{ state: TableState, changes: Change[] }}
*/
function _insertRow(state, tableIndex, data) {
if (!state[tableIndex]) {
log(`AI指令错误尝试在不存在的表格索引 ${tableIndex} 中插入行。`, 'error');
return { state, changes: [] };
}
if (typeof data !== 'object' || data === null) {
log(`AI指令错误insertRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
return { state, changes: [] };
}
const table = state[tableIndex];
const colCount = table.headers.length;
const newRow = Array(colCount).fill('');
/** @type {Change[]} */
const changes = [];
const newRowIndex = table.rows.length;
for (const colIndex in data) {
const cIndex = parseInt(colIndex, 10);
if (cIndex < colCount) {
newRow[cIndex] = data[colIndex];
changes.push({ type: 'update', tableIndex, rowIndex: newRowIndex, colIndex: cIndex });
}
}
table.rows.push(newRow);
// 同步更新 rowStatuses
if (!table.rowStatuses) {
table.rowStatuses = Array(table.rows.length - 1).fill('normal');
}
table.rowStatuses.push('normal');
return { state, changes };
}
/**
* 更新指定行。in-place mutation。
* 历史智能修正rowIndex 越界自动降级为 insertRow。
* @param {TableState} state
* @param {number} tableIndex
* @param {number} rowIndex
* @param {Object<string, string>} data
* @returns {{ state: TableState, changes: Change[] }}
*/
function _updateRow(state, tableIndex, rowIndex, data) {
if (!state[tableIndex]) {
log(`AI指令错误尝试更新不存在的表格 ${tableIndex}`, 'error');
return { state, changes: [] };
}
if (typeof data !== 'object' || data === null) {
log(`AI指令错误updateRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
return { state, changes: [] };
}
const table = state[tableIndex];
if (rowIndex >= table.rows.length) {
log(`AI指令修正updateRow 的行索引 ${rowIndex} 超出范围,自动转换为 insertRow。`, 'warn');
return _insertRow(state, tableIndex, data);
}
const row = table.rows[rowIndex];
/** @type {Change[]} */
const changes = [];
for (const colIndex in data) {
const cIndex = parseInt(colIndex, 10);
if (cIndex < row.length) {
row[cIndex] = data[colIndex];
changes.push({ type: 'update', tableIndex, rowIndex, colIndex: cIndex });
}
}
return { state, changes };
}
/**
* 标记指定行为待删除延迟删除。in-place mutation。
* 不从 rows 实际移除commitPendingDeletions 才会真正 splice。
* @param {TableState} state
* @param {number} tableIndex
* @param {number} rowIndex
* @returns {{ state: TableState, changes: Change[] }}
*/
function _deleteRow(state, tableIndex, rowIndex) {
const table = state[tableIndex];
if (!table || !table.rows[rowIndex]) {
log(`AI指令错误尝试删除不存在的表格 ${tableIndex} 或行 ${rowIndex}`, 'error');
return { state, changes: [] };
}
if (!table.rowStatuses) {
table.rowStatuses = Array(table.rows.length).fill('normal');
}
if (table.rowStatuses[rowIndex] !== 'pending-deletion') {
table.rowStatuses[rowIndex] = 'pending-deletion';
/** @type {Change[]} */
const changes = [{ type: 'delete', tableIndex, rowIndex }];
return { state, changes };
}
return { state, changes: [] };
}
/** @type {Object<string, (state: TableState, op: Operation) => { state: TableState, changes: Change[] }>} */
const HANDLERS = {
insertRow: (state, op) => _insertRow(state, op.tableIndex, /** @type {InsertRowOperation} */(op).data),
updateRow: (state, op) => _updateRow(state, op.tableIndex, /** @type {UpdateRowOperation} */(op).rowIndex, /** @type {UpdateRowOperation} */(op).data),
deleteRow: (state, op) => _deleteRow(state, op.tableIndex, /** @type {DeleteRowOperation} */(op).rowIndex),
};
/**
* 把一组操作推演到 state 上。
*
* @param {TableState} initialState
* @param {Operation[]} operations
* @returns {{ state: TableState, changes: Change[] }}
*/
export function applyOperations(initialState, operations) {
if (!Array.isArray(operations) || operations.length === 0) {
return { state: initialState, changes: [] };
}
let state = JSON.parse(JSON.stringify(initialState));
/** @type {Change[]} */
let allChanges = [];
for (const op of operations) {
if (!op || typeof op !== 'object' || typeof op.op !== 'string') {
log(`跳过非法操作: ${JSON.stringify(op)}`, 'warn');
continue;
}
const handler = HANDLERS[op.op];
if (!handler) {
log(`未知操作类型: ${op.op}`, 'error');
continue;
}
try {
const result = handler(state, op);
state = result.state;
if (result.changes && result.changes.length > 0) {
allChanges = allChanges.concat(result.changes);
}
const opLabel = op.op + '(' + op.tableIndex
+ (typeof (/** @type {any} */(op)).rowIndex === 'number' ? `, ${(/** @type {any} */(op)).rowIndex}` : '')
+ ')';
log(`成功推演操作: ${opLabel}`, 'success');
} catch (e) {
log(`推演操作 ${op.op} 时发生运行时错误: ${e.message}`, 'error');
}
}
return { state, changes: allChanges };
}

View File

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

View File

@@ -0,0 +1,21 @@
/**
* applyOperations 推演完成后吐出的变更记录。供高亮、SuperMemory 同步、UI 刷新使用。
*
* 注意 type 只有 'update' 和 'delete' 两种 —— insertRow 在 executor.js 历史实现里
* 也吐 type='update'(每个被填的单元格一条),不要发明 'insert' type。
*
* @typedef {Object} UpdateChange
* @property {'update'} type
* @property {number} tableIndex
* @property {number} rowIndex
* @property {number} colIndex
*
* @typedef {Object} DeleteChange
* @property {'delete'} type
* @property {number} tableIndex
* @property {number} rowIndex
*
* @typedef {UpdateChange | DeleteChange} Change
*/
export {};

View File

@@ -0,0 +1,26 @@
/**
* LLM 输出的统一动作格式。无论 formatter 是 legacy / json / toolcall
* 解析完都吐 Operation[],下游 applyOperations 不关心来源。
*
* data 字段的 key 是列索引的字符串形式('0', '1', ...),与 executor.js 历史行为对齐。
*
* @typedef {Object} InsertRowOperation
* @property {'insertRow'} op
* @property {number} tableIndex
* @property {Object<string, string>} data { [colIndex]: cellValue }
*
* @typedef {Object} UpdateRowOperation
* @property {'updateRow'} op
* @property {number} tableIndex
* @property {number} rowIndex
* @property {Object<string, string>} data
*
* @typedef {Object} DeleteRowOperation
* @property {'deleteRow'} op
* @property {number} tableIndex
* @property {number} rowIndex
*
* @typedef {InsertRowOperation | UpdateRowOperation | DeleteRowOperation} Operation
*/
export {};

View File

@@ -0,0 +1,38 @@
/**
* @file 表格相关数据形状DTO
* 对应运行时存于 message.extra.amily2_tables_data 的结构。
*/
/**
* 单元格内容;空值约定为空串而非 null/undefined。
* @typedef {string} Cell
*/
/**
* 行状态。'pending-deletion' 表示已标记待删除(延迟删除机制)。
* @typedef {'normal' | 'pending-deletion'} RowStatus
*/
/**
* 单张表格。
* @typedef {Object} Table
* @property {string} name 表格名(唯一标识 + UI 显示名)
* @property {string[]} headers 列头数组,长度 = 列数
* @property {Cell[][]} rows 行数据二维数组rows[i].length = headers.length
* @property {RowStatus[]} [rowStatuses] 行状态数组,与 rows 等长
* @property {(number|null)[]} [columnWidths] 列宽数组UI 用),与 headers 等长null 表示自适应
* @property {string} [note] 表格说明
* @property {string} [rule_add] 添加行规则(自然语言)
* @property {string} [rule_delete] 删除行规则
* @property {string} [rule_update] 更新行规则
* @property {Object<string, number>} [charLimitRules] 多列字符限制:{ "colIndexStr": maxChars }
* @property {number} [rowLimitRule] 行数上限0 表示不限
* @property {number} [simplifyRowThreshold] 历史行简化阈值0 表示不简化
*/
/**
* 表格集合 = 全局状态。
* @typedef {Table[]} TableState
*/
export {};

View File

@@ -0,0 +1,9 @@
/**
* @file TableState 的实际定义已合并至 ./Table.js与 Table 共处一处便于阅读)。
* 本文件保留为转发别名,供需要按 dto 名称单独导入的消费方使用:
* /** @typedef {import('./TableState.js').TableState} TableState *\/
*
* @typedef {import('./Table.js').TableState} TableState
*/
export {};

View File

@@ -1,100 +1,31 @@
/**
* @file 旧版 <Amily2Edit> 文本格式的解析器 + executeCommands 入口。
*
* Phase 0 重构后职责收窄:
* - 仅负责把 LLM 返回的文本块解析成 Operation[]legacy formatter 角色)
* - 推演下推到 actions/applyOperations.js本文件不再持有 insertRow/updateRow/deleteRow 实现
*
* 对外 API
* - parseToOperations(text) : 纯解析,文本 → Op[]Phase B legacy formatter 直接复用)
* - executeCommands(text, state) : 解析 + 推演,返回历史 shape { finalState, hasChanges, changes }
*
* 等 Phase B 引入 formatters/ 目录后,本文件改名为 formatters/legacy.js。
*
* @typedef {import('./dto/Operation.js').Operation} Operation
* @typedef {import('./dto/Table.js').TableState} TableState
*/
import { log } from './logger.js';
import { applyOperations } from './actions/applyOperations.js';
function insertRow(state, tableIndex, data) {
if (!state[tableIndex]) {
log(`AI指令错误尝试在不存在的表格索引 ${tableIndex} 中插入行。`, 'error');
return { state, changes: [] };
}
// 【安全检查】确保 data 是对象
if (typeof data !== 'object' || data === null) {
log(`AI指令错误insertRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
return { state, changes: [] };
}
const table = state[tableIndex];
const colCount = table.headers.length;
const newRow = Array(colCount).fill('');
const changes = [];
const newRowIndex = table.rows.length;
for (const colIndex in data) {
const cIndex = parseInt(colIndex, 10);
if (cIndex < colCount) {
newRow[cIndex] = data[colIndex];
changes.push({ type: 'update', tableIndex, rowIndex: newRowIndex, colIndex: cIndex });
}
}
table.rows.push(newRow);
// 同步更新 rowStatuses
if (!table.rowStatuses) {
table.rowStatuses = Array(table.rows.length - 1).fill('normal');
}
table.rowStatuses.push('normal');
return { state, changes };
}
function updateRow(state, tableIndex, rowIndex, data) {
if (!state[tableIndex]) {
log(`AI指令错误尝试更新不存在的表格 ${tableIndex}`, 'error');
return { state, changes: [] };
}
// 【安全检查】确保 data 是对象
if (typeof data !== 'object' || data === null) {
log(`AI指令错误updateRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
return { state, changes: [] };
}
const table = state[tableIndex];
if (rowIndex >= table.rows.length) {
log(`AI指令修正updateRow 的行索引 ${rowIndex} 超出范围,自动转换为 insertRow。`, 'warn');
return insertRow(state, tableIndex, data);
}
const row = table.rows[rowIndex];
const changes = [];
for (const colIndex in data) {
const cIndex = parseInt(colIndex, 10);
if (cIndex < row.length) {
row[cIndex] = data[colIndex];
changes.push({ type: 'update', tableIndex, rowIndex, colIndex: cIndex });
}
}
return { state, changes };
}
function deleteRow(state, tableIndex, rowIndex) {
const table = state[tableIndex];
if (!table || !table.rows[rowIndex]) {
log(`AI指令错误尝试删除不存在的表格 ${tableIndex} 或行 ${rowIndex}`, 'error');
return { state, changes: [] };
}
if (!table.rowStatuses) {
table.rowStatuses = Array(table.rows.length).fill('normal');
}
if (table.rowStatuses[rowIndex] !== 'pending-deletion') {
table.rowStatuses[rowIndex] = 'pending-deletion';
const changes = [{ type: 'delete', tableIndex, rowIndex }];
return { state, changes };
}
return { state, changes: [] };
}
const allowedFunctions = {
insertRow,
updateRow,
deleteRow,
};
const ALLOWED_FN_NAMES = new Set(['insertRow', 'updateRow', 'deleteRow']);
/**
* 把单行函数调用文本解析为 { name, args } 中间表示。
* 内部用不导出。args 是位置参数数组,待 _argsToOperation 转成 Operation 对象。
* @param {string} callString
* @returns {{ name: string, args: any[] } | null}
*/
function parseFunctionCall(callString) {
const match = callString.trim().match(/(\w+)\((.*)\)/);
if (!match) {
@@ -105,7 +36,7 @@ function parseFunctionCall(callString) {
const functionName = match[1];
const argsString = match[2];
if (!allowedFunctions[functionName]) {
if (!ALLOWED_FN_NAMES.has(functionName)) {
log(`检测到非法函数调用: "${functionName}"。已阻止执行。`, 'error');
return null;
}
@@ -116,11 +47,11 @@ function parseFunctionCall(callString) {
let currentArg = '';
let inQuote = false;
let quoteChar = '';
let braceDepth = 0;
let braceDepth = 0;
for (let i = 0; i < argsString.length; i++) {
const char = argsString[i];
if ((char === '"' || char === "'") && (i === 0 || argsString[i-1] !== '\\')) {
if (!inQuote) {
inQuote = true;
@@ -164,7 +95,7 @@ function parseValue(val) {
if (val === 'null') return null;
if (val === 'undefined') return undefined;
if (!isNaN(Number(val)) && val !== '') return Number(val);
if (val.startsWith('"') && val.endsWith('"')) {
try { return JSON.parse(val); } catch (e) { return val.slice(1, -1); }
}
@@ -203,14 +134,14 @@ function parseValue(val) {
function tryParseObject(str) {
if (!str.startsWith('{') || !str.endsWith('}')) return null;
let content = str.slice(1, -1);
const result = {};
let hasMatch = false;
const strings = [];
let placeholderIndex = 0;
// 提取字符串并替换为占位符,避免正则在字符串内部匹配
const stringRegex = /"([^"\\]|\\.)*"|'([^'\\]|\\.)*'/g;
content = content.replace(stringRegex, (match) => {
@@ -219,36 +150,36 @@ function tryParseObject(str) {
placeholderIndex++;
return placeholder;
});
// 匹配键:(开头或逗号/分号/冒号) + (数字 或 字母数字下划线 或 占位符) + 冒号
const keyRegex = /(?:^|[,;:]+\s*)(?:(\d+)|([a-zA-Z0-9_]+)|(__STR_\d+__))\s*:/g;
let match;
let lastIndex = 0;
let lastKey = null;
while ((match = keyRegex.exec(content)) !== null) {
hasMatch = true;
if (lastKey !== null) {
let valStr = content.slice(lastIndex, match.index).trim();
valStr = valStr.replace(/[,;:]+$/, '').trim();
let actualKey = restoreStrings(lastKey, strings);
result[actualKey] = restoreStrings(valStr, strings);
}
lastKey = match[1] || match[2] || match[3];
lastIndex = match.index + match[0].length;
}
if (lastKey !== null) {
let valStr = content.slice(lastIndex).trim();
valStr = valStr.replace(/[,;:]+$/, '').trim();
let actualKey = restoreStrings(lastKey, strings);
result[actualKey] = restoreStrings(valStr, strings);
}
return hasMatch ? result : null;
}
@@ -269,51 +200,77 @@ function cleanValueStr(str) {
return str;
}
/**
* 把 parseFunctionCall 返回的位置参数数组转成 Operation 对象。
* @param {string} name
* @param {any[]} args
* @returns {Operation | null}
*/
function _argsToOperation(name, args) {
if (name === 'insertRow') {
return /** @type {Operation} */ ({ op: 'insertRow', tableIndex: args[0], data: args[1] });
}
if (name === 'updateRow') {
return /** @type {Operation} */ ({ op: 'updateRow', tableIndex: args[0], rowIndex: args[1], data: args[2] });
}
if (name === 'deleteRow') {
return /** @type {Operation} */ ({ op: 'deleteRow', tableIndex: args[0], rowIndex: args[1] });
}
return null;
}
export function executeCommands(aiResponseText, initialState) {
/**
* 把 LLM 返回的文本块解析为 Operation[]。
* 不在文本中找到 <Amily2Edit> 块时返回空数组(不视为错误)。
*
* @param {string} aiResponseText
* @returns {Operation[]}
*/
export function parseToOperations(aiResponseText) {
const commandBlockRegex = /<Amily2Edit>([\s\S]*?)<\/Amily2Edit>/;
const match = aiResponseText.match(commandBlockRegex);
const match = (aiResponseText || '').match(commandBlockRegex);
if (!match) return [];
if (!match) {
return { finalState: initialState, hasChanges: false, changes: [] };
}
log('检测到AI指令块开始推演...', 'info');
const commandBlock = match[1].replace(/<!--|-->/g, '').trim();
if (!commandBlock) {
return { finalState: initialState, hasChanges: false, changes: [] };
}
if (!commandBlock) return [];
const commands = commandBlock.split('\n').filter(line => line.trim() !== '');
if (commands.length === 0) {
if (commands.length === 0) return [];
/** @type {Operation[]} */
const ops = [];
for (const commandString of commands) {
const trimmed = commandString.trim();
if (!trimmed.startsWith('insertRow(') &&
!trimmed.startsWith('updateRow(') &&
!trimmed.startsWith('deleteRow(')) {
continue;
}
const parsed = parseFunctionCall(trimmed);
if (!parsed) continue;
const op = _argsToOperation(parsed.name, parsed.args);
if (op) ops.push(op);
}
return ops;
}
/**
* 解析 LLM 文本指令并推演到 state 上。
* 历史 API调用方期望返回 { finalState, hasChanges, changes }。
*
* @param {string} aiResponseText
* @param {TableState} initialState
* @returns {{ finalState: TableState, hasChanges: boolean, changes: import('./dto/Change.js').Change[] }}
*/
export function executeCommands(aiResponseText, initialState) {
const ops = parseToOperations(aiResponseText);
if (ops.length === 0) {
return { finalState: initialState, hasChanges: false, changes: [] };
}
let currentState = JSON.parse(JSON.stringify(initialState));
let allChanges = [];
log(`检测到 ${ops.length} 条 AI 指令,开始推演...`, 'info');
commands.forEach(commandString => {
const trimmedCommand = commandString.trim();
if (trimmedCommand.startsWith('insertRow(') ||
trimmedCommand.startsWith('deleteRow(') ||
trimmedCommand.startsWith('updateRow('))
{
const parsed = parseFunctionCall(trimmedCommand);
if (parsed) {
try {
const result = allowedFunctions[parsed.name](currentState, ...parsed.args);
currentState = result.state;
if (result.changes && result.changes.length > 0) {
allChanges = allChanges.concat(result.changes);
}
log(`成功推演指令: ${commandString}`, 'success');
} catch (e) {
log(`推演指令 "${commandString}" 时发生运行时错误: ${e.message}`, 'error');
}
}
}
});
const hasChanges = allChanges.length > 0;
return { finalState: currentState, hasChanges, changes: allChanges };
const { state, changes } = applyOperations(initialState, ops);
return { finalState: state, hasChanges: changes.length > 0, changes };
}

View File

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

View File

@@ -0,0 +1,98 @@
/**
* @file ITablePersistence 实现 —— 表格状态的持久化层。
*
* 替代 manager.js 中:
* - saveStateToMessage(state, targetMessage) → 写入指定消息的 extra
* - 16 处复制样板getContext + saveStateToMessage + saveChat / saveChatDebounced
* 被合并为 commitToLastMessage / commitToLastMessageAsync 两个函数
*
* 不读取 store调用方显式传入要持久化的 state。这样
* - 测试容易(不依赖全局单例)
* - 万一未来需要在事务边界提交"快照"而非当前 state接口已就位
*
* @typedef {import('../dto/Table.js').TableState} TableState
*/
import { saveChat } from '/script.js';
import { getContext } from '/scripts/extensions.js';
import { saveChatDebounced } from '../../../utils/utils.js';
import { log } from '../logger.js';
/**
* message.extra 中存储表格状态的 key。
* 此值不能轻易改 —— 所有历史聊天的存档都用这个 key。
*/
export const TABLE_DATA_KEY = 'amily2_tables_data';
/**
* 把状态深拷贝写入指定消息的 metadata。
* 不主动调用 saveChat —— 写盘时机由调用方决定。
*
* @param {TableState | null} stateToSave
* @param {Object} targetMessage
* @returns {boolean} 是否写入成功
*/
export function saveStateToMessage(stateToSave, targetMessage) {
if (!stateToSave || !targetMessage) {
log('缺少状态或目标消息,无法保存。', 'error');
return false;
}
if (!targetMessage.extra) {
targetMessage.extra = {};
}
targetMessage.extra[TABLE_DATA_KEY] = JSON.parse(JSON.stringify(stateToSave));
log(`表格状态已准备写入消息 [${targetMessage.mes.substring(0, 20)}...]`, 'info');
return true;
}
/**
* 把 state 提交到 chat 最新一条消息并立即 saveChat。
*
* 该函数封装了 manager.js 中复制了 16 次的样板:
* const context = getContext();
* if (context.chat && context.chat.length > 0) {
* const lastMessage = context.chat[context.chat.length - 1];
* if (saveStateToMessage(state, lastMessage)) {
* saveChat();
* return;
* }
* }
* saveChatDebounced();
*
* @param {TableState | null} state
* @returns {boolean} true = 走 last-message commit 路径false = 降级到 debounced
*/
export function commitToLastMessage(state) {
const context = getContext();
if (context.chat && context.chat.length > 0) {
const lastMessage = context.chat[context.chat.length - 1];
if (saveStateToMessage(state, lastMessage)) {
saveChat();
return true;
}
}
saveChatDebounced();
return false;
}
/**
* commitToLastMessage 的 async 变体。
* deleteRow / restoreRow / rollbackState 等需要等 saveChat 完成后才做后续渲染的场景使用。
*
* @param {TableState | null} state
* @returns {Promise<boolean>}
*/
export async function commitToLastMessageAsync(state) {
const context = getContext();
if (context.chat && context.chat.length > 0) {
const lastMessage = context.chat[context.chat.length - 1];
if (saveStateToMessage(state, lastMessage)) {
await saveChat();
return true;
}
}
await saveChatDebounced();
return false;
}

View File

@@ -0,0 +1,117 @@
/**
* @file ITableStore 实现 —— 表格运行时状态的唯一所有者。
*
* 替代 manager.js 中三个 module-level 可变量:
* currentTablesState → 通过 getState/setState 访问
* highlightedCells → addHighlight/getHighlights/clearHighlights
* updatedTables → markTableUpdated/getUpdatedTables/clearUpdatedTables
*
* 本模块只承担"存",不触发任何副作用(不保存、不渲染、不发事件总线消息)。
* 副作用编排留给 Service 层 / Action 层。
*
* setState 会触发 subscribe 注册的回调,给 UI / SuperMemory 一个钩子,
* 但不直接 import UI保持 domain 纯度)。
*
* @typedef {import('../dto/Table.js').TableState} TableState
*/
import { log } from '../logger.js';
/** @type {TableState | null} */
let _state = null;
/** @type {Set<string>} 形如 "tableIndex-rowIndex-colIndex" */
const _highlights = new Set();
/** @type {Set<number>} 标记本周期内被改过的表格索引 */
const _updatedTables = new Set();
/** @type {Set<(state: TableState | null) => void>} */
const _listeners = new Set();
// ── 主状态 ────────────────────────────────────────────────────────────────
/**
* @returns {TableState | null}
*/
export function getState() {
return _state;
}
/**
* 直接替换全局状态。注意:不做深拷贝,调用方需自己负责传入的 state 不被外部 mutate。
* @param {TableState | null} newState
*/
export function setState(newState) {
_state = newState;
_notify();
}
/**
* 订阅 setState 触发的变更通知。返回取消订阅函数。
* 仅在 setState 被调用时触发mutate 同一引用不会触发。
* @param {(state: TableState | null) => void} listener
* @returns {() => void}
*/
export function subscribe(listener) {
_listeners.add(listener);
return () => _listeners.delete(listener);
}
function _notify() {
for (const l of _listeners) {
try {
l(_state);
} catch (e) {
console.error('[TableStore] listener error:', e);
}
}
}
// ── 单元格高亮 ─────────────────────────────────────────────────────────────
/**
* @param {number} tableIndex
* @param {number} rowIndex
* @param {number} colIndex
*/
export function addHighlight(tableIndex, rowIndex, colIndex) {
_highlights.add(`${tableIndex}-${rowIndex}-${colIndex}`);
}
/**
* @returns {Set<string>}
*/
export function getHighlights() {
return _highlights;
}
export function clearHighlights() {
if (_highlights.size > 0) {
_highlights.clear();
log('已清除所有单元格高亮标记。', 'info');
}
}
// ── 更新过的表格标记 ───────────────────────────────────────────────────────
/**
* @param {number} tableIndex
*/
export function markTableUpdated(tableIndex) {
_updatedTables.add(tableIndex);
}
/**
* @returns {Set<number>}
*/
export function getUpdatedTables() {
return _updatedTables;
}
export function clearUpdatedTables() {
if (_updatedTables.size > 0) {
_updatedTables.clear();
log('已清除所有表格的更新标记。', 'info');
}
}

File diff suppressed because it is too large Load Diff

329
core/table-system/preset.js Normal file
View File

@@ -0,0 +1,329 @@
/**
* @file 表格预设的导入 / 导出 / 全局预设管理。
*
* 历史来源:从 manager.js 抽出
* - exportPreset / exportPresetFull → 调内部 exportPresetBase
* - importPreset → 接受 hooks 注入 SuperMemory 同步等副作用
* - clearGlobalPreset → 清除 extension_settings 中的全局预设
* - importGlobalPreset → 写入全局预设
*
* 设计要点:
* - 不内含 SuperMemory dispatch 逻辑(避免与 manager.js 循环依赖)
* - importPreset 接受 hooks: { onAfterApply, onImported },调用方注入需要的副作用
* - 所有持久化走 infra/persistence.js不再复制 saveStateToMessage 样板
*/
import { extension_settings, getContext } from '/scripts/extensions.js';
import { saveSettingsDebounced } from '/script.js';
import { extensionName } from '../../utils/settings.js';
import { log } from './logger.js';
import { getState, setState } from './infra/store.js';
import { saveStateToMessage, commitToLastMessage } from './infra/persistence.js';
import {
getBatchFillerRuleTemplate,
getBatchFillerFlowTemplate,
saveBatchFillerRuleTemplate,
saveBatchFillerFlowTemplate,
saveAiTemplate,
} from './templates.js';
/**
* @typedef {{
* onAfterApply?: () => void,
* onImported?: () => void
* }} ImportPresetHooks
*/
// ── 导出 ──────────────────────────────────────────────────────────────────
/**
* @param {boolean} includeData 是否包含 rows 实际数据
*/
function exportPresetBase(includeData = false) {
const state = getState();
if (!state) {
log('无法导出:当前表格状态为空。', 'error');
toastr.error('没有可导出的表格数据。');
return;
}
let tablesToExport;
let fileNameSuffix;
if (includeData) {
// 完整备份
tablesToExport = JSON.parse(JSON.stringify(state));
fileNameSuffix = '完整备份';
} else {
// 纯净预设:仅结构 + 规则,不带数据
tablesToExport = state.map(table => ({
name: table.name,
headers: table.headers,
columnWidths: table.columnWidths || [],
note: table.note,
rule_add: table.rule_add,
rule_delete: table.rule_delete,
rule_update: table.rule_update,
charLimitRules: table.charLimitRules || {},
rowLimitRule: table.rowLimitRule || 0,
// simplifyRowThreshold 不导出:与当前聊天进度强绑定的临时设置
rows: [],
rowStatuses: [],
}));
fileNameSuffix = '纯净预设';
}
const preset = {
version: 'Amily2-Table-Preset-v3.0-separated_templates',
batchFillerRuleTemplate: getBatchFillerRuleTemplate(),
batchFillerFlowTemplate: getBatchFillerFlowTemplate(),
tables: tablesToExport,
};
const blob = new Blob([JSON.stringify(preset, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Amily2-${fileNameSuffix}-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
log(`${fileNameSuffix}】已成功导出。`, 'success');
toastr.success(`${fileNameSuffix}】已开始下载。`, '导出成功');
}
export function exportPreset() {
exportPresetBase(false);
}
export function exportPresetFull() {
exportPresetBase(true);
}
// ── 导入 ──────────────────────────────────────────────────────────────────
/**
* 把导入的 tables 数组归一化(补字段 + 兼容旧版结构。in-place mutation。
*/
function _normalizeImportedTables(importedTables) {
importedTables.forEach(table => {
if (table.name === undefined || table.headers === undefined || table.rows === undefined) {
throw new Error(`导入的表格数据格式不正确: ${JSON.stringify(table)}`);
}
if (table.note === undefined) table.note = '无';
if (table.rule_add === undefined) table.rule_add = '允许';
if (table.rule_delete === undefined) table.rule_delete = '允许';
if (table.rule_update === undefined) table.rule_update = '允许';
// 多列规则兼容:旧 charLimitRule 单列对象 → 新 charLimitRules 对象映射
if (table.charLimitRule && !table.charLimitRules) {
table.charLimitRules = {};
if (table.charLimitRule.columnIndex !== -1 && table.charLimitRule.limit > 0) {
table.charLimitRules[table.charLimitRule.columnIndex] = table.charLimitRule.limit;
}
} else if (table.charLimitRules === undefined) {
table.charLimitRules = {};
}
delete table.charLimitRule;
// 延迟删除rowStatuses 必须存在
if (!table.rowStatuses) {
table.rowStatuses = Array(table.rows.length).fill('normal');
}
if (table.rowLimitRule === undefined) table.rowLimitRule = 0;
if (table.columnWidths === undefined) table.columnWidths = [];
});
}
/**
* 把导入的预设里的模板字段写回 extension_settings。版本兼容三档
* v3.0(separated) / v2.1(aiRule+aiFlow) / v2.0(aiTemplate)
*/
function _applyImportedTemplates(preset) {
if (preset.version === 'Amily2-Table-Preset-v3.0-separated_templates') {
saveBatchFillerRuleTemplate(preset.batchFillerRuleTemplate || '');
saveBatchFillerFlowTemplate(preset.batchFillerFlowTemplate || '');
saveAiTemplate(preset.injectionFlowTemplate || '');
} else if (preset.aiRuleTemplate !== undefined && preset.aiFlowTemplate !== undefined) {
saveBatchFillerRuleTemplate(preset.aiRuleTemplate || '');
saveBatchFillerFlowTemplate(preset.aiFlowTemplate || '');
saveAiTemplate(preset.aiFlowTemplate || '');
} else if (preset.aiTemplate) {
saveBatchFillerRuleTemplate('');
saveBatchFillerFlowTemplate(preset.aiTemplate || '');
saveAiTemplate(preset.aiTemplate || '');
} else {
log('导入的预设中缺少指令模板字段,模板将不会被更新。', 'warn');
}
}
/**
* 弹出文件选择 → 解析 JSON → 归一化 → 写入 store + 持久化。
*
* hooks.onAfterApply 在 setState 之后、saveChat 之前触发(用于注入 SuperMemory 同步等副作用)。
* hooks.onImported 在全部完成后触发UI 刷新)。
*
* @param {ImportPresetHooks | (() => void)} [hooksOrCallback] 兼容旧签名 importPreset(callback)
*/
export function importPreset(hooksOrCallback) {
/** @type {ImportPresetHooks} */
const hooks = typeof hooksOrCallback === 'function'
? { onImported: hooksOrCallback }
: (hooksOrCallback || {});
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = event => {
try {
const preset = JSON.parse(event.target.result);
if (!preset.version || !Array.isArray(preset.tables)) {
throw new Error('文件格式无效或缺少版本号/表格数据。');
}
const confirmation = window.confirm(
'【警告】\n\n导入操作将完全覆盖您当前的AI指令模板和所有表格包括结构和内容。\n\n此操作不可逆是否确定要继续'
);
if (!confirmation) {
log('用户取消了导入操作。', 'info');
toastr.info('导入操作已取消。');
return;
}
_applyImportedTemplates(preset);
const importedTables = preset.tables;
_normalizeImportedTables(importedTables);
setState(importedTables);
// 钩子:让调用方注入 SuperMemory 全量同步等副作用
if (typeof hooks.onAfterApply === 'function') {
try { hooks.onAfterApply(); } catch (e) {
log(`importPreset onAfterApply 抛错: ${e.message}`, 'error');
}
}
commitToLastMessage(getState());
log('导入的预设已强制写入最新消息并立即保存。', 'success');
log('预设已成功导入并应用。', 'success');
toastr.success('预设已成功导入!', '导入成功');
if (typeof hooks.onImported === 'function') {
try { hooks.onImported(); } catch (e) {
log(`importPreset onImported 抛错: ${e.message}`, 'error');
}
}
} catch (error) {
log(`导入预设失败: ${error.message}`, 'error');
toastr.error(`导入失败:${error.message}`, '错误');
}
};
reader.readAsText(file);
};
input.click();
}
// ── 全局预设 ──────────────────────────────────────────────────────────────
export function clearGlobalPreset() {
if (extension_settings[extensionName] && extension_settings[extensionName].global_table_preset) {
const confirmation = window.confirm(
'【清除全局预设】\n\n您确定要清除已设置的全局预设吗\n\n清除后新聊天将恢复使用扩展内置的默认表格模板。'
);
if (confirmation) {
delete extension_settings[extensionName].global_table_preset;
saveSettingsDebounced();
log('全局预设已被清除。', 'success');
toastr.success('全局预设已清除,新聊天将使用默认模板。', '操作成功');
} else {
log('用户取消了清除全局预设的操作。', 'info');
toastr.info('操作已取消。');
}
} else {
log('无需清除,当前未设置任何全局预设。', 'info');
toastr.info('当前没有设置全局预设。', '提示');
}
}
/**
* @param {(() => void) | undefined} onImported
*/
export function importGlobalPreset(onImported) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = event => {
try {
const preset = JSON.parse(event.target.result);
if (!preset.version || !Array.isArray(preset.tables)) {
throw new Error('文件格式无效或缺少版本号/表格数据。');
}
const confirmation = window.confirm(
'【全局预设导入】\n\n这将把选定的预设设置为所有新聊天的默认表格。\n\n此操作将覆盖任何已存在的全局预设是否确定'
);
if (!confirmation) {
log('用户取消了全局预设导入操作。', 'info');
toastr.info('操作已取消。');
return;
}
// 纯净副本:仅结构,不含 rows
const cleanTables = preset.tables.map(table => ({
name: table.name,
headers: table.headers,
note: table.note,
rule_add: table.rule_add,
rule_delete: table.rule_delete,
rule_update: table.rule_update,
rows: [],
}));
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].global_table_preset = {
version: preset.version,
tables: cleanTables,
batchFillerRuleTemplate: preset.batchFillerRuleTemplate,
batchFillerFlowTemplate: preset.batchFillerFlowTemplate,
};
saveSettingsDebounced();
_applyImportedTemplates(preset);
log('全局预设已成功导入并保存到扩展设置中。', 'success');
toastr.success('全局预设已设置!新聊天将默认使用此预设。', '设置成功');
if (typeof onImported === 'function') {
try { onImported(); } catch (e) {
log(`importGlobalPreset onImported 抛错: ${e.message}`, 'error');
}
}
} catch (error) {
log(`导入全局预设失败: ${error.message}`, 'error');
toastr.error(`导入失败:${error.message}`, '错误');
}
};
reader.readAsText(file);
};
input.click();
}

View File

@@ -0,0 +1,239 @@
/**
* @file Markdown/CSV 渲染 —— 把 TableState 渲染为 prompt 可用的字符串。
*
* 纯函数:吃 state、吐字符串。不读 store、不写盘、不发事件。
*
* 历史来源:从 manager.js 抽出
* - convertTablesToCsvString → tablesToCsv
* - convertSelectedTablesToCsvString → tablesToCsvWithSelection
* - convertTablesToCsvStringForContentOnly → tablesToCsvContentOnly
* - checkTableRules (内部) → _checkTableRules (内部)
*
* manager.js 保留同名 export 作 wrapper自动注入 getState()),所有外部调用点零改动。
*
* @typedef {import('./dto/Table.js').Table} Table
* @typedef {import('./dto/Table.js').TableState} TableState
*/
/**
* 检查表格规则违规,返回聚合警告字符串(多行)。
* 行数超限 + 多列字符限制超限。
* @param {Table} table
* @returns {string}
*/
function _checkTableRules(table) {
const warnings = [];
// 行数限制
if (table.rowLimitRule && table.rowLimitRule > 0 && table.rows.length > table.rowLimitRule) {
warnings.push(`【当前(${table.name})超出规定(${table.rowLimitRule})行,请结合剧情缩减至(${table.rowLimitRule})行以下,但切莫完全删除。】`);
}
// 多列字符限制
const charLimitRules = table.charLimitRules || {};
for (const colIndexStr in charLimitRules) {
const colIndex = parseInt(colIndexStr, 10);
const limit = charLimitRules[colIndex];
if (limit > 0 && colIndex >= 0 && colIndex < table.headers.length) {
const colName = table.headers[colIndex];
const offendingRows = [];
table.rows.forEach((row, rowIndex) => {
if (table.rowStatuses && table.rowStatuses[rowIndex] === 'pending-deletion') return;
const cellContent = row[colIndex] || '';
if (cellContent.length > limit) offendingRows.push(rowIndex);
});
if (offendingRows.length > 0) {
warnings.push(`【当前(${table.name})第(${offendingRows.join('、')})行(${colName})列,字符超出规定(${limit})字限制,请进行缩减。】`);
}
}
}
return warnings.join('\n');
}
/**
* 把单个 table 的"内容主体"(含 simplify 处理 + warnings写入到 fullString 末尾。
* 提取自三个渲染函数中重复的内层逻辑。
*
* @param {Table} table
* @param {string} tagName
* @returns {string}
*/
function _renderTableBody(table, tagName) {
let out = '';
const activeRows = table.rows.filter((row, i) => !table.rowStatuses || table.rowStatuses[i] !== 'pending-deletion');
if (activeRows.length === 0) {
out += '(该表当前内容为空)\n';
} else {
const simplifyThreshold = table.simplifyRowThreshold || 0;
let simplifiedCount = 0;
table.rows.forEach((row, rowIndex) => {
if (table.rowStatuses && table.rowStatuses[rowIndex] === 'pending-deletion') return;
// 历史内容简化:前 N 行用 ---已锁定--- 占位
if (simplifyThreshold > 0 && rowIndex < simplifyThreshold) {
if (simplifiedCount === 0) {
const placeholderCells = row.map(() => '---已锁定---');
out += `| ${rowIndex} | ${placeholderCells.join(' | ')} |\n`;
out += `| ... | ${row.map(() => '...').join(' | ')} |\n`;
}
if (rowIndex === simplifyThreshold - 1) {
const placeholderCells = row.map(() => '---已锁定---');
out += `| ${rowIndex} | ${placeholderCells.join(' | ')} |\n`;
}
simplifiedCount++;
return;
}
if (Array.isArray(row)) {
const rowCells = row.map(cell => {
const cellContent = (cell === null || cell === undefined || cell === '') ? '未知' : String(cell);
return cellContent.replace(/\|/g, '');
});
out += `| ${rowIndex} | ${rowCells.join(' | ')} |\n`;
}
});
if (simplifiedCount > 0) {
out += `\n【系统提示】:表格前 ${simplifiedCount} 行(索引 0 到 ${simplifiedCount - 1})的历史内容已简化并锁定,无需读取或修改。请专注于后续行的内容。\n`;
}
}
return out;
}
/**
* 完整渲染:所有表格内容 + 规则 + 警告,注入到主流程 prompt。
* 对应 manager.js#convertTablesToCsvString。
*
* @param {TableState | null} state
* @returns {string}
*/
export function tablesToCsv(state) {
if (!state || state.length === 0) return '';
let fullString = '';
state.forEach((table, tableIndex) => {
// 标题
fullString += `\n* ${tableIndex}:${table.name}\n`;
// 说明
fullString += `【说明】:\n${table.note || '无'}\n`;
// 内容Markdown 表)
const tagName = table.name.replace(/\s/g, '') + '内容';
fullString += `<${tagName}>\n`;
const headerWithIndex = ['rowIndex', ...table.headers.map((h, i) => `${i}:${h}`)];
fullString += `| ${headerWithIndex.join(' | ')} |\n`;
fullString += `|${headerWithIndex.map(() => '---').join('|')}|\n`;
fullString += _renderTableBody(table, tagName);
// 警告
const warnings = _checkTableRules(table);
if (warnings) fullString += `${warnings}\n`;
fullString += `</${tagName}>\n`;
// 规则
fullString += `【增加】: ${table.rule_add || '允许'}\n`;
fullString += `【删除】: ${table.rule_delete || '允许'}\n`;
fullString += `【修改】: ${table.rule_update || '允许'}\n`;
if (tableIndex < state.length - 1) fullString += '\n---\n';
});
return fullString;
}
/**
* 选中态渲染:未选中的表格只展示表头作为索引参考;选中的展示完整内容。
* 对应 manager.js#convertSelectedTablesToCsvString。
*
* @param {TableState | null} state
* @param {number[]} selectedIndices
* @returns {string}
*/
export function tablesToCsvWithSelection(state, selectedIndices) {
if (!state || state.length === 0) return '';
const selected = Array.isArray(selectedIndices) ? selectedIndices : [];
let fullString = '';
state.forEach((table, tableIndex) => {
const isSelected = selected.includes(tableIndex);
// 标题
fullString += `\n* ${tableIndex}:${table.name}`;
if (!isSelected) fullString += ' (本表格无需重新整理,仅供参考)';
fullString += '\n';
// 说明
fullString += `【说明】:\n${table.note || '无'}\n`;
const tagName = table.name.replace(/\s/g, '') + '内容';
fullString += `<${tagName}>\n`;
const headerWithIndex = ['rowIndex', ...table.headers.map((h, i) => `${i}:${h}`)];
fullString += `| ${headerWithIndex.join(' | ')} |\n`;
fullString += `|${headerWithIndex.map(() => '---').join('|')}|\n`;
if (isSelected) {
fullString += _renderTableBody(table, tagName);
const warnings = _checkTableRules(table);
if (warnings) fullString += `${warnings}\n`;
} else {
fullString += '(此处省略未选中的表格内容,仅提供表头供索引参考)\n';
}
fullString += `</${tagName}>\n`;
// 规则
if (isSelected) {
fullString += `【增加】: ${table.rule_add || '允许'}\n`;
fullString += `【删除】: ${table.rule_delete || '允许'}\n`;
fullString += `【修改】: ${table.rule_update || '允许'}\n`;
} else {
fullString += `【操作权限】: 禁止修改此表格\n`;
}
if (tableIndex < state.length - 1) fullString += '\n---\n';
});
return fullString;
}
/**
* 仅内容渲染:不带规则、不带 rowIndex 列、不带说明。
* 用于"分步填表"和"优化中填表"模式下的 prompt 注入(只展示数据本身)。
* 对应 manager.js#convertTablesToCsvStringForContentOnly。
*
* @param {TableState | null} state
* @returns {string}
*/
export function tablesToCsvContentOnly(state) {
if (!state || state.length === 0) return '';
let outputString = '';
state.forEach(table => {
outputString += `\n<${table.name}>\n`;
// Markdown 表头
outputString += `| ${table.headers.join(' | ')} |\n`;
outputString += `|${table.headers.map(() => '---').join('|')}|\n`;
// 数据
const activeRows = table.rows.filter((row, i) => !table.rowStatuses || table.rowStatuses[i] !== 'pending-deletion');
if (activeRows.length > 0) {
activeRows.forEach(row => {
if (Array.isArray(row)) {
const rowContent = row.map(cell => (cell === null || cell === undefined || cell === '') ? ' ' : cell.toString());
outputString += `| ${rowContent.join(' | ')} |\n`;
}
});
} else {
outputString += '(该表当前内容为空)\n';
}
outputString += `</${table.name}>\n`;
});
return outputString.trim();
}

View File

@@ -71,8 +71,8 @@ export async function reorganizeTableContent(selectedTableIndices) {
console.log('[Amily2-重新整理] 使用 Nccs API 进行表格重整...');
rawContent = await callNccsAI(messages);
} else {
console.log('[Amily2-重新整理] 使用默认 API 进行表格重整...');
rawContent = await callAI(messages);
console.log('[Amily2-重新整理] 使用 tableFilling slot 进行表格重整...');
rawContent = await callAI(messages, { slot: 'tableFilling' });
}
if (!rawContent) {

View File

@@ -4,13 +4,66 @@ 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;
async function callSecondaryModel(messages) {
const settings = extension_settings[extensionName] || {};
if (settings.nccsEnabled) {
return await callNccsAI(messages);
}
return await callAI(messages);
}
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) {
await updateTableFromText(rawContent);
await markTargetsProcessed(targetMessages);
}
async function getWorldBookContext() {
@@ -65,11 +118,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;
}
secondaryFillerRunning = true;
const settings = extension_settings[extensionName] || {};
// 【V2.1.1】分步填表触发延迟 / 防抖:自动触发时若配置了延迟,则延后执行,
// 延迟期内再次到来的事件会重置计时器,避免消息连续到达时重复拉起填表。
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');
@@ -96,29 +173,18 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
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 +192,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,44 +367,97 @@ 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' });
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 {
await updateTableFromOps(ops);
await markTargetsProcessed(targetMessages);
toastr.success('分步填表Function Call执行完毕。', 'Amily2-分步填表');
}
} else {
console.log('[Amily2-副API] 使用默认 API 进行分步填表...');
rawContent = await callAI(messages);
// Legacy 文本路径
let rawContent;
if (settings.nccsEnabled) {
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
rawContent = await callNccsAI(messages);
} else {
console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...');
rawContent = await callAI(messages, { slot: 'tableFilling' });
}
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) {
@@ -317,32 +465,35 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
// 【新增】自定义重试逻辑
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;
}
secondaryFillerRunning = false;
}
async function getHistoryContext(messagesToFetch, historyEndIndex, tagsToExtract, exclusionRules) {

View File

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

View File

@@ -0,0 +1,78 @@
/**
* @file 表格 prompt 模板的 getter/setter 集中点。
*
* 三套模板:
* - batch_filler_rule_template 规则模板(系统提示词部分)
* - batch_filler_flow_template 流程模板(含 {{{Amily2TableData}}} 占位符)
* - amily2_ai_template 注入模板(主 API 模式下走的注入)
*
* 所有读写都落到 extension_settings[extensionName]saveSettingsDebounced 触发持久化。
*
* 历史来源:从 manager.js 抽出
* - getBatchFillerRuleTemplate / saveBatchFillerRuleTemplate
* - getBatchFillerFlowTemplate / saveBatchFillerFlowTemplate
* - getAiFlowTemplateForInjection
* - saveAiTemplate / getAiTemplate
*/
import { extension_settings } from '/scripts/extensions.js';
import { saveSettingsDebounced } from '/script.js';
import { extensionName } from '../../utils/settings.js';
import { DEFAULT_AI_RULE_TEMPLATE, DEFAULT_AI_FLOW_TEMPLATE } from './settings.js';
/**
* @returns {string}
*/
export function getBatchFillerRuleTemplate() {
return extension_settings[extensionName]?.batch_filler_rule_template ?? DEFAULT_AI_RULE_TEMPLATE;
}
/**
* @param {string} template
*/
export function saveBatchFillerRuleTemplate(template) {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].batch_filler_rule_template = template;
saveSettingsDebounced();
}
/**
* @returns {string}
*/
export function getBatchFillerFlowTemplate() {
return extension_settings[extensionName]?.batch_filler_flow_template ?? DEFAULT_AI_FLOW_TEMPLATE;
}
/**
* @param {string} template
*/
export function saveBatchFillerFlowTemplate(template) {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].batch_filler_flow_template = template;
saveSettingsDebounced();
}
/**
* 主 API 模式下注入用的流程模板。与 batch_filler_flow_template 是两套独立配置。
* @returns {string}
*/
export function getAiFlowTemplateForInjection() {
return extension_settings[extensionName]?.amily2_ai_template ?? DEFAULT_AI_FLOW_TEMPLATE;
}
/**
* @param {string} template
*/
export function saveAiTemplate(template) {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].amily2_ai_template = template;
saveSettingsDebounced();
}
/**
* 别名 —— 历史 manager.js 同名函数,等价于 getAiFlowTemplateForInjection。
* @returns {string}
*/
export function getAiTemplate() {
return getAiFlowTemplateForInjection();
}

View File

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

View File

@@ -24,6 +24,7 @@ export { characters, this_chid, eventSource, event_types, saveSettingsDebounced
// Core Systems
export { injectTableData, generateTableContent } from "./core/table-system/injector.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 { renderTables } from './ui/table-bindings.js';

View File

@@ -8,6 +8,7 @@ import {
characters, this_chid, eventSource, event_types, saveSettingsDebounced,
injectTableData, generateTableContent,
initializeRagProcessor,
loadHanlinyuanSettingsToUI,
loadTables, clearHighlights, rollbackAndRefill, rollbackState, commitPendingDeletions, saveStateToMessage, getMemoryState, clearUpdatedTables,
fillWithSecondaryApi,
renderTables,
@@ -696,7 +697,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');
@@ -773,6 +774,15 @@ function initializeRagAndInjection() {
console.error('[Amily2-翰林院] RAG处理器初始化失败:', error);
}
// 此时 ST settings hydration 已完成,且 RAG 第二次 init 拿到的是真实 saved settings 引用。
// mount 阶段那次 loadSettingsToUI 跑得过早hydration 之前UI 拿到的是默认值;
// 在此重跑一次以让翰林院面板显示真实持久化值。
try {
loadHanlinyuanSettingsToUI();
} catch (error) {
console.error('[Amily2-翰林院] 步骤五重载面板设置失败:', error);
}
console.log("[Amily2号-开国大典] 步骤六:智能冲突检测与注入策略...");
console.log('[Amily2-策略] 采用“完全主导”策略,覆盖 `vectors_rearrangeChat`。');
window['vectors_rearrangeChat'] = executeAmily2Injection;

27
jsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"allowJs": true,
"checkJs": true,
"noEmit": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"strict": false,
"noImplicitAny": false,
"strictNullChecks": false,
"lib": ["esnext", "dom"]
},
"include": [
"**/*.js",
"types/**/*.d.ts"
],
"exclude": [
"node_modules",
"dist",
"build"
]
}

View File

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

68
types/sillytavern.d.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
// SillyTavern 全局模块的环境声明。
// 让 import { ... } from '/script.js' 这类绝对路径在 TS 引擎眼里能解析。
// 此文件不被运行时加载,仅供 jsconfig.json 的 checkJs 使用。
//
// 字段类型一律 any —— 不做强约束,只为消除 "Cannot find module" 红线。
// 想要真类型,把对应字段改成具体签名即可。
declare module '/script.js' {
export const saveChat: any;
export const saveChatDebounced: any;
export const saveSettingsDebounced: any;
export const saveSettings: any;
export const characters: any;
export const this_chid: any;
export const eventSource: any;
export const event_types: any;
export const getRequestHeaders: any;
export const name1: any;
export const name2: any;
export const chat: any;
export const reloadCurrentChat: any;
export const saveChatConditional: any;
const _default: any;
export default _default;
}
declare module '/scripts/extensions.js' {
export const extension_settings: any;
export const getContext: any;
export const renderExtensionTemplate: any;
export const renderExtensionTemplateAsync: any;
export const writeExtensionField: any;
const _default: any;
export default _default;
}
declare module '/scripts/world-info.js' {
export const loadWorldInfo: any;
export const saveWorldInfo: any;
export const world_names: any;
export const getWorldInfoPrompt: any;
const _default: any;
export default _default;
}
declare module '/scripts/slash-commands.js' {
const anything: any;
export = anything;
}
declare module '/scripts/extensions/*' {
const anything: any;
export = anything;
}
// 全局对象 —— 在 .js 文件里直接用 toastr / window.Amily2Bus 不会被标红。
declare global {
const toastr: any;
interface Window {
Amily2Bus: any;
AMILY2_SYSTEM_PARALYZED: boolean;
AMILY2_MACRO_REPLACED: boolean;
MiZheSi_Global: any;
_amilySafeConsole: any;
}
}
export {};

View File

@@ -6,7 +6,7 @@
* ApiKeyStore密钥存储
*/
import { apiProfileManager, PROFILE_TYPES, SLOTS } from '../utils/config/ApiProfileManager.js';
import { apiProfileManager, PROFILE_TYPES, SLOTS, clearLegacyConfig } from '../utils/config/ApiProfileManager.js';
import { apiKeyStore } from '../utils/config/api-key-store/ApiKeyStore.js';
import { configManager } from '../utils/config/ConfigManager.js';
import { getRequestHeaders, saveSettingsDebounced } from '/script.js';
@@ -17,6 +17,12 @@ import { testJqyhApiConnection } from '../core/api/JqyhApi.js';
import { testConcurrentApiConnection } from '../core/api/ConcurrentApi.js';
import { testNgmsApiConnection } from '../core/api/Ngms_api.js';
import { testNccsApiConnection } from '../core/api/NccsApi.js';
import {
getRegistry,
detectVendorSync,
listVendorParamsSync,
getVendorEntry,
} from '../utils/api-vendor.js';
// 槽位 → 真实测试函数映射(发送聊天请求验证连接)
// plotOpt 槽位同时服务剧情优化和 JQYH互斥根据启用状态选择测试函数
@@ -45,11 +51,21 @@ const SLOT_TOGGLES = {
let _editingId = null; // 当前编辑的 Profile IDnull = 新建)
let _currentFilter = 'all'; // 当前类型筛选
let _slotAssignmentPanel = null;
let _slotAssignmentRefreshBound = false;
// ── 入口:绑定整个面板 ────────────────────────────────────────────────────────
export function bindApiConfigPanel(container) {
const $c = $(container);
_slotAssignmentPanel = $c;
if (!_slotAssignmentRefreshBound) {
_slotAssignmentRefreshBound = true;
document.addEventListener('amily2:slotAssigned', () => {
if (_slotAssignmentPanel) renderSlotAssignments(_slotAssignmentPanel);
});
}
// 存储模式
_bindStorageMode($c);
@@ -70,9 +86,11 @@ export function bindApiConfigPanel(container) {
_switchParamSections($c, $(this).val());
});
// 弹窗:接口类型切换Google 自动填 URL
$c.find('#amily2_pf_provider').on('change', function () {
_handleProviderChange($c, $(this).val());
// 弹窗:接口类型切换 —— vendor preset 自动填 defaultUrl + 切换提示框
$c.find('#amily2_pf_provider').on('change', async function () {
const provider = $(this).val();
_handleProviderChange($c, provider);
await _autofillVendorUrl($c, provider);
});
// 弹窗:获取模型列表
@@ -81,6 +99,30 @@ export function bindApiConfigPanel(container) {
// 弹窗:测试连接
$c.find('#amily2_pf_test_conn').on('click', () => _testConnection($c));
// 弹窗URL 变更 → 更新 customParams hint
$c.find('#amily2_pf_url').on('input change blur', () => _updateCustomParamsHint($c));
// 弹窗customParams 文本框实时校验 JSON
$c.find('#amily2_pf_custom_params').on('blur input', () => {
_validateCustomParamsLive($c);
_updateCustomParamsHint($c);
});
$c.on('click', '.amily2_param_hint_btn', function () {
if (this.disabled) return;
_insertParamToCustomParams(
$c,
$(this).data('paramName'),
$(this).data('paramType')
);
});
// 预加载 vendor registry异步UI 不阻塞)
getRegistry().catch(() => { /* 失败已在 api-vendor 内部 fallback无需再处理 */ });
// 旧配置清理按钮
$c.find('#amily2_clear_legacy_config').on('click', () => _handleClearLegacyConfig($c));
// 表单:取消
$c.find('#amily2_profile_modal_cancel').on('click', () => closeModal($c));
@@ -404,6 +446,11 @@ async function openModal($c, id) {
$c.find('#amily2_pf_max_tokens').val(p.maxTokens);
$c.find('#amily2_pf_temperature').val(p.temperature);
$c.find('#amily2_pf_fake_stream').prop('checked', p.fakeStream ?? false);
// customParams 写回成格式化 JSON 字符串
const cp = p.customParams ?? {};
$c.find('#amily2_pf_custom_params').val(
Object.keys(cp).length ? JSON.stringify(cp, null, 2) : ''
);
} else if (p.type === 'embedding') {
$c.find('#amily2_pf_dimensions').val(p.dimensions ?? '');
$c.find('#amily2_pf_encoding_format').val(p.encodingFormat);
@@ -421,9 +468,12 @@ async function openModal($c, id) {
$c.find('#amily2_pf_name, #amily2_pf_url, #amily2_pf_key, #amily2_pf_model').val('');
$c.find('#amily2_pf_provider').val('openai');
_handleProviderChange($c, 'openai');
// 新建模式下自动填充默认 URL编辑模式不调避免覆盖用户已配置的代理 URL
_autofillVendorUrl($c, 'openai');
$c.find('#amily2_pf_max_tokens').val(65500);
$c.find('#amily2_pf_temperature').val(1.0);
$c.find('#amily2_pf_fake_stream').prop('checked', false);
$c.find('#amily2_pf_custom_params').val('');
$c.find('#amily2_pf_dimensions').val('');
$c.find('#amily2_pf_encoding_format').val('float');
$c.find('#amily2_pf_top_n').val(5);
@@ -436,6 +486,10 @@ async function openModal($c, id) {
$c.find('#amily2_pf_model_select').hide().empty();
$c.find('#amily2_pf_model').show();
// 刷新 customParams 旁的 vendor 提示 + 清空错误
_updateCustomParamsHint($c);
_validateCustomParamsLive($c);
const $details = $c.find('#amily2_profile_form_details');
$details.prop('open', true);
$details[0].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@@ -464,6 +518,14 @@ async function saveProfile($c) {
data.maxTokens = parseInt($c.find('#amily2_pf_max_tokens').val(), 10) || 65500;
data.temperature = parseFloat($c.find('#amily2_pf_temperature').val()) || 1.0;
data.fakeStream = $c.find('#amily2_pf_fake_stream').prop('checked');
// customParamsJSON 校验失败则中止保存
const cp = _parseCustomParamsOrFail($c);
if (cp === null) {
toastr.error('自定义参数 JSON 解析失败,请修正后再保存。', '保存中止');
return;
}
data.customParams = cp;
} else if (type === 'embedding') {
const dim = $c.find('#amily2_pf_dimensions').val();
data.dimensions = dim ? parseInt(dim, 10) : null;
@@ -576,6 +638,8 @@ async function _fetchModels($c) {
return;
}
models.sort((a, b) => a.localeCompare(b));
const currentVal = $c.find('#amily2_pf_model').val().trim();
const $sel = $c.find('#amily2_pf_model_select');
$sel.html(models.map(m => `<option value="${_escapeHtml(m)}">${_escapeHtml(m)}</option>`).join(''));
@@ -703,15 +767,71 @@ async function _testConnection($c) {
// ── Provider 切换 ─────────────────────────────────────────────────────────────
const GOOGLE_API_BASE = 'https://generativelanguage.googleapis.com/v1beta/openai';
/**
* 6 个享受 defaultUrl 自动填充的 vendor preset id。registry 之外的 provider
* sillytavern_backend / sillytavern_preset / custom_oai走各自的特殊逻辑。
*/
const VENDOR_PRESETS = new Set(['anthropic', 'openai', 'google', 'openrouter', 'deepseek', 'xai']);
function _handleProviderChange($c, provider) {
const isGoogle = provider === 'google';
$c.find('#amily2_pf_url_row').toggle(!isGoogle);
$c.find('#amily2_pf_google_note').toggle(isGoogle);
/**
* 处理 provider 变化的"展示侧"逻辑URL row 可见性 + vendor 提示框。
* 不修改 URL 输入值(避免编辑现有 profile 时被覆盖)。
* URL 自动填充由 _autofillVendorUrl 单独负责,仅在用户主动 change 时触发。
*/
async function _handleProviderChange($c, provider) {
const $urlRow = $c.find('#amily2_pf_url_row');
const $note = $c.find('#amily2_pf_vendor_note');
const $noteText = $c.find('#amily2_pf_vendor_note_text');
const $linkWrap = $c.find('#amily2_pf_vendor_note_link_wrap');
const $link = $c.find('#amily2_pf_vendor_note_link');
if (isGoogle) {
$c.find('#amily2_pf_url').val(GOOGLE_API_BASE);
// URL row 一律可见(包括 preset vendor —— 用户可能要切到代理/镜像)
$urlRow.show();
if (VENDOR_PRESETS.has(provider)) {
try {
const entry = await getVendorEntry(provider);
if (entry) {
$noteText.text(`${entry.displayName} — 默认接口地址已自动填写,如需走代理/镜像可在下方修改。`);
if (entry.doc) {
$link.attr('href', entry.doc).text('查看官方文档');
$linkWrap.show();
} else {
$linkWrap.hide();
}
$note.show();
return;
}
} catch (e) {
console.warn('[ApiConfig] vendor entry 加载失败:', e);
}
}
$note.hide();
}
/**
* 用户主动切换 provider 时,把 URL 字段写为该 vendor 的 defaultUrl。
* Custom 模式清空 URLST backend/preset 不动 URL。
* 同时刷新 customParams hint 与校验状态。
*/
async function _autofillVendorUrl($c, provider) {
if (provider === 'custom_oai') {
$c.find('#amily2_pf_url').val('');
_updateCustomParamsHint($c);
return;
}
if (!VENDOR_PRESETS.has(provider)) {
// sillytavern_backend / sillytavern_preset 等不修改 URL
return;
}
try {
const entry = await getVendorEntry(provider);
if (entry?.defaultUrl) {
$c.find('#amily2_pf_url').val(entry.defaultUrl);
_updateCustomParamsHint($c);
}
} catch (e) {
console.warn('[ApiConfig] autofill defaultUrl 失败:', e);
}
}
@@ -739,3 +859,153 @@ function _escapeHtml(str) {
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _getCustomParamsEditorState($c) {
const raw = ($c.find('#amily2_pf_custom_params').val() || '').trim();
if (!raw) {
return { valid: true, parsed: {}, empty: true };
}
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
return { valid: false, parsed: null, empty: false };
}
return { valid: true, parsed, empty: false };
} catch {
return { valid: false, parsed: null, empty: false };
}
}
function _getDefaultValueForParamType(type) {
const normalized = String(type || '').toLowerCase();
if (normalized.includes('array')) return [];
if (normalized.includes('object')) return {};
if (normalized.includes('integer') || normalized.includes('number')) return 0;
if (normalized.includes('boolean')) return false;
return '';
}
// ── customParams 辅助 ────────────────────────────────────────────────────────
/**
* 根据当前 URL 输入识别 vendor并把已知参数列表渲染到 hint 行。
* registry 还没异步加载完时detectVendorSync 返回 null静默跳过。
*/
function _updateCustomParamsHint($c) {
const $hint = $c.find('#amily2_pf_custom_params_hint');
if (!$hint.length) return;
const apiUrl = $c.find('#amily2_pf_url').val()?.trim() || '';
const vendorId = detectVendorSync(apiUrl);
if (!vendorId) {
$hint.empty();
return;
}
const params = listVendorParamsSync(vendorId);
if (!params.length) {
$hint.empty();
return;
}
const editorState = _getCustomParamsEditorState($c);
getVendorEntry(vendorId).then(entry => {
const label = entry?.displayName || vendorId;
const disabledAttr = editorState.valid ? '' : ' disabled';
const buttons = params.map(param => `
<button type="button"
class="menu_button small_button amily2_param_hint_btn"
data-param-name="${_escapeHtml(param.name)}"
data-param-type="${_escapeHtml(param.type || '')}"
style="margin:2px 6px 2px 0;"
${disabledAttr}>${_escapeHtml(param.name)}</button>
`).join('');
const invalidNote = editorState.valid
? ''
: '<span style="margin-left:6px; color:var(--warning, #d9534f);">请先修复 JSON再插入参数。</span>';
$hint.html(`${_escapeHtml(label)} 已知参数:${buttons}${invalidNote}`);
});
}
/**
* 实时校验 customParams 文本框内容。空 / 合法 JSON object → 清空错误。
* 非 JSON 或非 object → 在 #_error 行显示。仅做提示,不阻断输入。
*/
function _validateCustomParamsLive($c) {
const $err = $c.find('#amily2_pf_custom_params_error');
if (!$err.length) return;
const state = _getCustomParamsEditorState($c);
if (state.empty) {
$err.hide().text('');
return;
}
if (state.valid) {
$err.hide().text('');
return;
}
try {
JSON.parse(($c.find('#amily2_pf_custom_params').val() || '').trim());
$err.show().text('需要是 JSON 对象({} 形式),不能是数组或基本类型。');
} catch (e) {
$err.show().text(`JSON 解析失败:${e.message}`);
}
}
function _insertParamToCustomParams($c, paramName, paramType) {
const state = _getCustomParamsEditorState($c);
if (!state.valid) return;
const next = { ...(state.parsed || {}) };
if (Object.prototype.hasOwnProperty.call(next, paramName)) {
return;
}
next[paramName] = _getDefaultValueForParamType(paramType);
$c.find('#amily2_pf_custom_params').val(JSON.stringify(next, null, 2));
_validateCustomParamsLive($c);
_updateCustomParamsHint($c);
}
/**
* 清除旧配置残留 —— 二次确认 → 调 clearLegacyConfig → 反馈结果。
*/
function _handleClearLegacyConfig($c) {
const confirmed = window.confirm(
'【清除旧配置残留】\n\n' +
'即将删除以下数据:\n' +
'• extension_settings 中各模块的旧 URL / Model / 温度 / maxTokens / 模式等字段\n' +
'• localStorage 中各模块的旧 API Key\n\n' +
'⚠️ 操作不可恢复。如果某个槽位还没分配 profile操作会被阻止。\n\n' +
'确定继续吗?'
);
if (!confirmed) return;
try {
const result = clearLegacyConfig();
if (!result.ok) {
toastr.error(result.error || '清除失败,未知错误。', '清除被阻止');
return;
}
toastr.success(
`已清除 ${result.clearedFields} 个旧字段、${result.clearedKeys} 个旧 API Key。建议刷新页面验证。`,
'清除完成',
{ timeOut: 6000 }
);
} catch (e) {
console.error('[ApiConfig] 清除旧配置失败:', e);
toastr.error(`清除失败: ${e.message}`, '错误');
}
}
/**
* saveProfile 调用:解析 customParams 文本,失败返回 null调用方中止保存
* 空文本视为空对象 {}。
*
* @returns {Object | null}
*/
function _parseCustomParamsOrFail($c) {
const state = _getCustomParamsEditorState($c);
return state.valid ? (state.parsed || {}) : null;
}

View File

@@ -10,8 +10,9 @@ 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';
function displayDailyAuthCode() {
const displayEl = document.getElementById('amily2_daily_code_display');
@@ -806,7 +807,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_auto_char_card, #amily2_open_api_config, #amily2_open_rule_config, #amily2_open_sfigen, #amily2_open_preset_editor, #amily2_back_to_main_settings, #amily2_back_to_main_from_hanlinyuan, #amily2_back_to_main_from_forms, #amily2_back_to_main_from_optimization, #amily2_back_to_main_from_text_optimization, #amily2_back_to_main_from_cwb, #amily2_back_to_main_from_world_editor, #amily2_back_to_main_from_glossary, #amily2_renderer_back_button, #amily2_back_to_main_from_super_memory, #amily2_back_to_main_from_api_config, #amily2_back_to_main_from_rule_config, #amily2_sfigen_back_to_main", function () {
if (!pluginAuthStatus.authorized) return;
const mainPanel = container.find('.plugin-features');
@@ -874,7 +875,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 +895,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':

View File

@@ -8,6 +8,7 @@ import * as IngestionManager from '../core/ingestion-manager.js';
import { showContentModal, showHtmlModal } from './page-window.js';
import { extractBlocksByTags, applyExclusionRules } from '../core/utils/rag-tag-extractor.js';
import { ruleProfileManager, resolveCondensationRuleConfig } from '../utils/config/RuleProfileManager.js';
import { syncSlot } from './profile-sync.js';
import {
filterWorldbooks,
filterWorldbookEntries,
@@ -76,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');
}
@@ -152,6 +143,8 @@ export function bindHanlinyuanEvents() {
}
setupGlobalEventHandlers();
syncSlot('ragEmbed');
syncSlot('ragRerank');
bindPanelToggleEvents();
bindInternalUIEvents();
bindTutorialEvents(); // 【新增】绑定教程按钮事件
@@ -161,14 +154,26 @@ export function bindHanlinyuanEvents() {
// 确保核心已经初始化
if (HanlinyuanCore.initialize) {
HanlinyuanCore.initialize();
try {
HanlinyuanCore.initialize();
} catch (e) {
console.error('[翰林院-枢纽] 核心初始化抛出异常:', e);
}
} else {
console.error('[翰林院-枢纽] 核心法典未能提供初始化圣旨!');
return;
}
loadSettingsToUI();
loadWorldbookList(); // 【新增】加载书库列表
try {
loadSettingsToUI();
} catch (e) {
console.error('[翰林院-枢纽] loadSettingsToUI 抛出异常:', e);
}
try {
loadWorldbookList();
} catch (e) {
console.error('[翰林院-枢纽] loadWorldbookList 抛出异常:', e);
}
log('[翰林院-枢纽] 已成功连接各部,政令畅通。', 'info');
const fileInput = document.getElementById('hanlinyuan-ingest-novel-file-input');
const fileNameSpan = document.getElementById('hanlinyuan-ingest-novel-file-name');
@@ -375,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');
@@ -603,7 +600,7 @@ function handleApiModeChange() {
}
}
function loadSettingsToUI() {
export function loadSettingsToUI() {
const settings = HanlinyuanCore.getSettings();
if (!settings) return;
@@ -649,17 +646,12 @@ function loadSettingsToUI() {
histMaxRetriesEl.value = settings.historiographyMaxRetries ?? 2;
}
// 新增:加载标签提取设置
const tagExtractionToggle = document.getElementById('hly-tag-extraction-toggle');
const tagInput = document.getElementById('hly-tag-input');
const tagInputContainer = document.getElementById('hly-tag-input-container');
tagExtractionToggle.checked = settings.condensation.tagExtractionEnabled;
tagInput.value = settings.condensation.tags; // 直接使用从核心获取的值
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');
@@ -683,7 +675,7 @@ 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"]`);

View File

@@ -490,28 +490,6 @@ function bindNgmsApiEvents() {
}
});
// 滑块控件绑定
const sliderFields = [
{ id: 'amily2_ngms_max_tokens', key: 'ngmsMaxTokens', defaultValue: 4000 },
{ id: 'amily2_ngms_temperature', key: 'ngmsTemperature', 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_ngms_tavern_profile');
if (tavernProfileSelect) {

View File

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

View File

@@ -8,7 +8,6 @@
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';
@@ -45,30 +44,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 +60,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,29 +572,9 @@ function opt_loadSettings(panel) {
panel.find('#amily2_opt_table_enabled').val(tableEnabledValue);
panel.find('#amily2_opt_ejs_enabled').prop('checked', settings.plotOpt_ejsEnabled);
panel.find(`input[name="amily2_opt_api_mode"][value="${settings.plotOpt_apiMode}"]`).prop('checked', true);
panel.find('#amily2_opt_tavern_api_profile_select').val(settings.plotOpt_tavernProfile);
panel.find(`input[name="amily2_opt_worldbook_source"][value="${settings.plotOpt_worldbookSource || 'character'}"]`).prop('checked', true);
panel.find('#amily2_opt_worldbook_enabled').prop('checked', settings.plotOpt_worldbookEnabled);
panel.find('#amily2_opt_new_memory_logic_enabled').prop('checked', settings.plotOpt_newMemoryLogicEnabled);
panel.find('#amily2_opt_api_url').val(settings.plotOpt_apiUrl);
// plotOpt_apiKey 是敏感字段,从 configManagerlocalStorage读取
panel.find('#amily2_opt_api_key').val(configManager.get('plotOpt_apiKey') || '');
const modelInput = panel.find('#amily2_opt_model');
const modelSelect = panel.find('#amily2_opt_model_select');
modelInput.val(settings.plotOpt_model);
modelSelect.empty();
if (settings.plotOpt_model) {
modelSelect.append(new Option(settings.plotOpt_model, settings.plotOpt_model, true, true));
} else {
modelSelect.append(new Option('<-请先获取模型', '', true, true));
}
syncModelMirror(modelInput.get(0), modelSelect.get(0));
panel.find('#amily2_opt_max_tokens').val(settings.plotOpt_max_tokens);
panel.find('#amily2_opt_temperature').val(settings.plotOpt_temperature);
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);
@@ -692,11 +604,8 @@ function opt_loadSettings(panel) {
}, 0);
}
opt_updateApiUrlVisibility(panel, settings.plotOpt_apiMode);
opt_updateWorldbookSourceVisibility(panel, settings.plotOpt_worldbookSource || 'character');
opt_bindSlider(panel, '#amily2_opt_max_tokens', '#amily2_opt_max_tokens_value');
opt_bindSlider(panel, '#amily2_opt_temperature', '#amily2_opt_temperature_value');
opt_bindSlider(panel, '#amily2_opt_top_p', '#amily2_opt_top_p_value');
opt_bindSlider(panel, '#amily2_opt_presence_penalty', '#amily2_opt_presence_penalty_value');
opt_bindSlider(panel, '#amily2_opt_frequency_penalty', '#amily2_opt_frequency_penalty_value');
@@ -707,7 +616,6 @@ function opt_loadSettings(panel) {
opt_loadWorldbookEntries(panel);
});
opt_loadTavernApiProfiles(panel);
}
@@ -1043,12 +951,32 @@ function bindConcurrentWorldbookEvents() {
});
}
function opt_purgeGarbageKeys() {
const store = extension_settings[extensionName];
if (!store) return;
let removed = 0;
for (const key of Object.keys(store)) {
// 历史 bug 造成的污染 keyhandleSettingChange 误把世界书/条目复选框当作设置项,
// 生成形如 plotOpt_amily2-opt-wb-*、plotOpt_amily2-opt-entry-*、plotOpt_amily2-opt-concurrent-wb-* 的键
if (/^plotOpt_amily2-opt-/.test(key)) {
delete store[key];
removed++;
}
}
if (removed > 0) {
console.log(`[${extensionName}] 清理残留的 ${removed} 条无效 plotOpt_* 设置键。`);
saveSettingsDebounced();
}
}
export function initializePlotOptimizationBindings() {
const panel = $('#amily2_plot_optimization_panel');
if (panel.length === 0 || panel.data('events-bound')) {
return;
}
opt_purgeGarbageKeys();
// Tab switching logic
panel.find('.sinan-navigation-deck').on('click', '.sinan-nav-item', function() {
const tabButton = $(this);
@@ -1179,7 +1107,11 @@ export function initializePlotOptimizationBindings() {
const handleSettingChange = function(element) {
const el = $(element);
const key_part = (element.name || element.id).replace('amily2_opt_', '');
const rawName = element.name || element.id || '';
// 仅处理下划线前缀的真实设置项;动态生成的世界书/条目复选框用连字符命名amily2-opt-wb-*、amily2-opt-entry-*
// 它们有自己的专属 handler若被此处捕获会生成 plotOpt_amily2-opt-... 的垃圾 key 污染 settings
if (!rawName.startsWith('amily2_opt_')) return;
const key_part = rawName.replace('amily2_opt_', '');
const key = 'plotOpt_' + key_part.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
let value = element.type === 'checkbox' ? element.checked : el.val();
@@ -1199,17 +1131,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(', ');
@@ -1218,30 +1146,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());
@@ -1371,220 +1275,9 @@ export function initializePlotOptimizationBindings() {
});
}
// ========== Jqyh API 事件绑定函数 ==========
// ========== Jqyh API 事件绑定函数(已迁移至 plotOpt 槽位,此处仅保留空壳) ==========
function bindJqyhApiEvents() {
console.log("[Amily2号-Jqyh工部] 正在绑定Jqyh API事件...");
const updateAndSaveSetting = (key, value) => {
console.log(`[Amily2-Jqyh令] 收到指令: 将 [${key}] 设置为 ->`, value);
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
extension_settings[extensionName][key] = value;
saveSettingsDebounced();
console.log(`[Amily2-Jqyh录] [${key}] 的新状态已保存。`);
};
// Jqyh API 开关控制
const jqyhToggle = document.getElementById('amily2_jqyh_enabled');
const jqyhContent = document.getElementById('amily2_jqyh_content');
if (jqyhToggle && jqyhContent) {
jqyhToggle.checked = extension_settings[extensionName].jqyhEnabled ?? false;
jqyhContent.style.display = jqyhToggle.checked ? 'block' : 'none';
jqyhToggle.addEventListener('change', function() {
const isEnabled = this.checked;
updateAndSaveSetting('jqyhEnabled', isEnabled);
jqyhContent.style.display = isEnabled ? 'block' : 'none';
});
}
// API模式切换
const apiModeSelect = document.getElementById('amily2_jqyh_api_mode');
const compatibleConfig = document.getElementById('amily2_jqyh_compatible_config');
const presetConfig = document.getElementById('amily2_jqyh_preset_config');
if (apiModeSelect && compatibleConfig && presetConfig) {
apiModeSelect.value = extension_settings[extensionName].jqyhApiMode || 'openai_test';
const updateConfigVisibility = (mode) => {
if (mode === 'sillytavern_preset') {
compatibleConfig.style.display = 'none';
presetConfig.style.display = 'block';
loadJqyhTavernPresets();
} else {
compatibleConfig.style.display = 'block';
presetConfig.style.display = 'none';
}
};
updateConfigVisibility(apiModeSelect.value);
apiModeSelect.addEventListener('change', function() {
updateAndSaveSetting('jqyhApiMode', this.value);
updateConfigVisibility(this.value);
});
}
// API配置字段绑定
const apiFields = [
{ id: 'amily2_jqyh_api_url', key: 'jqyhApiUrl' },
{ id: 'amily2_jqyh_api_key', key: 'jqyhApiKey', sensitive: true },
{ id: 'amily2_jqyh_model', key: 'jqyhModel' }
];
apiFields.forEach(field => {
const element = document.getElementById(field.id);
if (element) {
// 敏感字段API Key从 configManagerlocalStorage读取
element.value = field.sensitive
? (configManager.get(field.key) || '')
: (extension_settings[extensionName][field.key] || '');
const saveField = function() {
if (field.sensitive) {
configManager.set(field.key, this.value);
} else {
updateAndSaveSetting(field.key, this.value);
if (field.key === 'jqyhModel') {
syncModelMirror(
document.getElementById('amily2_jqyh_model'),
document.getElementById('amily2_jqyh_model_select')
);
}
}
};
bindInputLikeSave(element, saveField);
}
});
// 滑块控件绑定
const sliderFields = [
{ id: 'amily2_jqyh_max_tokens', key: 'jqyhMaxTokens', defaultValue: 4000 },
{ id: 'amily2_jqyh_temperature', key: 'jqyhTemperature', defaultValue: 0.7 }
];
sliderFields.forEach(field => {
const slider = document.getElementById(field.id);
const display = document.getElementById(field.id + '_value');
if (slider && display) {
const value = extension_settings[extensionName][field.key] || field.defaultValue;
slider.value = value;
display.textContent = value;
slider.addEventListener('input', function() {
const newValue = parseFloat(this.value);
display.textContent = newValue;
updateAndSaveSetting(field.key, newValue);
});
}
});
// SillyTavern预设选择器
const tavernProfileSelect = document.getElementById('amily2_jqyh_tavern_profile');
if (tavernProfileSelect) {
tavernProfileSelect.value = extension_settings[extensionName].jqyhTavernProfile || '';
tavernProfileSelect.addEventListener('change', function() {
updateAndSaveSetting('jqyhTavernProfile', this.value);
});
}
// 测试连接按钮
const testButton = document.getElementById('amily2_jqyh_test_connection');
if (testButton) {
testButton.addEventListener('click', async function() {
const button = $(this);
const originalHtml = button.html();
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 测试中');
try {
await testJqyhApiConnection();
} catch (error) {
console.error('[Amily2号-Jqyh] 测试连接失败:', error);
} finally {
button.prop('disabled', false).html(originalHtml);
}
});
}
const fetchModelsButton = document.getElementById('amily2_jqyh_fetch_models');
const modelSelect = document.getElementById('amily2_jqyh_model_select');
const modelInput = document.getElementById('amily2_jqyh_model');
if (fetchModelsButton && modelSelect && modelInput) {
fetchModelsButton.addEventListener('click', async function() {
const button = $(this);
const originalHtml = button.html();
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 获取中');
try {
const models = await fetchJqyhModels();
if (models && models.length > 0) {
modelSelect.innerHTML = '<option value="">-- 请选择模型 --</option>';
models.forEach(model => {
const option = document.createElement('option');
option.value = model.id || model.name || model;
option.textContent = model.name || model.id || model;
modelSelect.appendChild(option);
});
modelSelect.style.display = 'block';
modelInput.style.display = 'none';
modelSelect.addEventListener('change', function() {
const selectedModel = this.value;
modelInput.value = selectedModel;
updateAndSaveSetting('jqyhModel', selectedModel);
console.log(`[Amily2-Jqyh] 已选择模型: ${selectedModel}`);
});
toastr.success(`成功获取 ${models.length} 个模型`, 'Jqyh 模型获取');
} else {
toastr.warning('未获取到任何模型', 'Jqyh 模型获取');
}
} catch (error) {
console.error('[Amily2号-Jqyh] 获取模型列表失败:', error);
toastr.error(`获取模型失败: ${error.message}`, 'Jqyh 模型获取');
} finally {
button.prop('disabled', false).html(originalHtml);
}
});
}
}
async function loadJqyhTavernPresets() {
const select = document.getElementById('amily2_jqyh_tavern_profile');
if (!select) return;
const currentValue = select.value;
select.innerHTML = '<option value="">-- 加载中 --</option>';
try {
const context = getContext();
const tavernProfiles = context.extensionSettings?.connectionManager?.profiles || [];
select.innerHTML = '<option value="">-- 请选择预设 --</option>';
if (tavernProfiles.length > 0) {
tavernProfiles.forEach(profile => {
if (profile.api && profile.preset) {
const option = document.createElement('option');
option.value = profile.id;
option.textContent = profile.name || profile.id;
if (profile.id === currentValue) {
option.selected = true;
}
select.appendChild(option);
}
});
} else {
select.innerHTML = '<option value="">未找到可用预设</option>';
}
} catch (error) {
console.error('[Amily2号-Jqyh] 加载SillyTavern预设失败:', error);
select.innerHTML = '<option value="">加载失败</option>';
}
// Jqyh 直连配置已移除,剧情优化统一走 ApiProfile plotOpt 槽位
}
// ========== 图标位置切换(跨模块通用事件) ==========

View File

@@ -1,144 +1,200 @@
/**
* ui/profile-sync.js API Profile → 子面板 UI 同步
* ui/profile-sync.js - Synchronize central API profiles into legacy sub-panels.
*
* 当某功能槽分配了 Profile 时:
* 1. 隐藏对应功能区的 API 连接配置字段(保留温度/Token 等生成参数)
* 2. 注入一张状态卡,显示 Profile 信息 + 测试连接 / 获取模型按钮
*
* 当槽位未分配时:恢复旧字段显示,移除状态卡。
*
* 用法:
* import { syncAllSlots, syncSlot } from './profile-sync.js';
* await syncAllSlots(); // 面板初始化时全量同步
* await syncSlot('main'); // 单个槽位分配变更时调用
*
* 外部事件:
* document 上监听 'amily2:slotAssigned'detail = { slot }
* 由 api-config-bindings.js 在分配变更后 dispatch。
* The central API profile assignment is authoritative. Sub-panels only show a
* profile selector card and keep legacy URL/key/model fields hidden. When a
* profile is assigned we still backfill those hidden fields so older fallback
* code that reads from DOM continues to work during the migration.
*/
import { apiProfileManager } from '../utils/config/ApiProfileManager.js';
import { apiProfileManager, PROFILE_TYPES, SLOTS } from '../utils/config/ApiProfileManager.js';
import { getRequestHeaders } from '/script.js';
import { testApiConnection } from '../core/api.js';
import { testJqyhApiConnection } from '../core/api/JqyhApi.js';
import { testConcurrentApiConnection } from '../core/api/ConcurrentApi.js';
import { testNgmsApiConnection } from '../core/api/Ngms_api.js';
import { testNccsApiConnection } from '../core/api/NccsApi.js';
import { testSybdApiConnection } from '../core/api/SybdApi.js';
import { testCwbConnection } from '../CharacterWorldBook/src/cwb_apiService.js';
import { testConnection as testAutoCharCardConnection } from '../core/auto-char-card/api.js';
import {
executeRerank as executeRagRerank,
fetchEmbeddingModels as fetchRagEmbeddingModels,
fetchRerankModels as fetchRagRerankModels,
testApiConnection as testRagEmbeddingConnection,
} from '../core/rag-api.js';
// ── 常量 ──────────────────────────────────────────────────────────────────────
// 用于通过子元素定位父 block 的选择器
const BLOCK_SEL = '.amily2_settings_block, .control-group, .amily2_opt_settings_block';
// 每个槽位在回填 Profile 值前的 DOM 字段快照(用于取消分配时还原)
// 结构:{ [slot]: { [selector]: value } }
const _fieldSnapshots = {};
const CARD_CLASS = 'amily2_profile_status_card';
const BLOCK_SEL = '.amily2_settings_block, .control-group, .amily2_opt_settings_block, .acc-form-group, .hly-control-block';
const CARD_CLASS = 'amily2_profile_status_card';
const CARD_SLOT_ATTR = 'data-card-slot';
const HIDDEN_ATTR = 'data-profile-hidden';
const HIDDEN_ATTR = 'data-profile-hidden';
const MASKED_KEY = '••••••••';
// ── 槽位 → DOM 映射 ───────────────────────────────────────────────────────────
//
// container : 状态卡注入的父容器CSS 选择器或 'closest-fieldset:xxx'
// hideParentBlock: 通过子元素选择器找到其最近的 BLOCK_SEL 父元素并隐藏
// hideDirectly : 直接隐藏的元素选择器
// hideWithLabel : 隐藏元素(上溯到容器直接子元素)+ 前一个兄弟 <label>inline-grid 布局用)
// hideInContainer: 在容器内 querySelector 查找并隐藏
// fields : { profileKey: domSelector } — 用于回填值(向下兼容 fallback 读取)
// keyField : API Key 输入框(回填遮蔽值)
// testFn : 测试连接函数(发送真实聊天请求)
const _fieldSnapshots = {};
const SLOT_CONFIGS = {
main: {
container: 'closest-fieldset:#amily2_api_provider',
container: 'closest-fieldset:#amily2_api_provider',
hideParentBlock: ['#amily2_api_provider', '#amily2_model_selector'],
hideDirectly: ['#amily2_api_url_wrapper', '#amily2_api_key_wrapper', '#amily2_preset_wrapper'],
hideWithLabel: [],
hideInContainer: [],
fields: { provider: '#amily2_api_provider', apiUrl: '#amily2_api_url', model: '#amily2_manual_model_input' },
hideDirectly: ['#amily2_api_url_wrapper', '#amily2_api_key_wrapper', '#amily2_preset_wrapper'],
fields: { provider: '#amily2_api_provider', apiUrl: '#amily2_api_url', model: '#amily2_manual_model_input' },
keyField: '#amily2_api_key',
testFn: testApiConnection,
testFn: testApiConnection,
},
plotOpt: {
container: '#amily2_opt_custom_api_settings_block',
hideParentBlock: [],
hideDirectly: [],
hideWithLabel: [],
hideInContainer: [],
fields: { apiUrl: '#amily2_opt_api_url', model: '#amily2_opt_model' },
keyField: '#amily2_opt_api_key',
testFn: null,
container: '#amily2_jqyh_content',
hideParentBlock: ['#amily2_jqyh_api_mode'],
hideDirectly: ['#amily2_jqyh_compatible_config', '#amily2_jqyh_preset_config'],
hideInContainer: ['.jqyh-button-row'],
fields: { provider: '#amily2_jqyh_api_mode', apiUrl: '#amily2_jqyh_api_url', model: '#amily2_jqyh_model' },
keyField: '#amily2_jqyh_api_key',
testFn: testJqyhApiConnection,
},
plotOptConc: {
container: '#amily2_concurrent_content',
hideParentBlock: [],
hideDirectly: [],
hideWithLabel: [
container: '#amily2_concurrent_content',
hideWithLabel: [
'#amily2_plotOpt_concurrentApiProvider',
'#amily2_plotOpt_concurrentApiUrl',
'#amily2_plotOpt_concurrentApiKey',
'#amily2_plotOpt_concurrentModel',
],
hideInContainer: ['.jqyh-button-row'],
fields: { provider: '#amily2_plotOpt_concurrentApiProvider', apiUrl: '#amily2_plotOpt_concurrentApiUrl', model: '#amily2_plotOpt_concurrentModel' },
fields: {
provider: '#amily2_plotOpt_concurrentApiProvider',
apiUrl: '#amily2_plotOpt_concurrentApiUrl',
model: '#amily2_plotOpt_concurrentModel',
},
keyField: '#amily2_plotOpt_concurrentApiKey',
testFn: testConcurrentApiConnection,
testFn: testConcurrentApiConnection,
},
nccs: {
container: '#nccs-api-config',
hideParentBlock: ['#nccs-api-mode', '#nccs-api-url', '#nccs-api-key', '#nccs-api-model', '#nccs-api-fakestream-enabled', '#nccs-sillytavern-preset'],
hideDirectly: [],
hideWithLabel: [],
container: '#nccs-api-config',
hideParentBlock: [
'#nccs-api-mode',
'#nccs-api-url',
'#nccs-api-key',
'#nccs-api-model',
'#nccs-api-fakestream-enabled',
'#nccs-sillytavern-preset',
],
hideInContainer: ['.nccs-button-row'],
fields: { apiUrl: '#nccs-api-url', model: '#nccs-api-model' },
fields: { provider: '#nccs-api-mode', apiUrl: '#nccs-api-url', model: '#nccs-api-model' },
keyField: '#nccs-api-key',
testFn: testNccsApiConnection,
testFn: testNccsApiConnection,
},
ngms: {
container: '#amily2_ngms_content',
container: '#amily2_ngms_content',
hideParentBlock: ['#amily2_ngms_api_mode', '#amily2_ngms_fakestream_enabled'],
hideDirectly: ['#amily2_ngms_compatible_config', '#amily2_ngms_preset_config'],
hideWithLabel: [],
hideDirectly: ['#amily2_ngms_compatible_config', '#amily2_ngms_preset_config'],
hideInContainer: ['.ngms-button-row'],
fields: { apiUrl: '#amily2_ngms_api_url', model: '#amily2_ngms_model' },
fields: { provider: '#amily2_ngms_api_mode', apiUrl: '#amily2_ngms_api_url', model: '#amily2_ngms_model' },
keyField: '#amily2_ngms_api_key',
testFn: testNgmsApiConnection,
testFn: testNgmsApiConnection,
},
sybd: {
container: '#amily2_sybd_content',
hideParentBlock: ['#amily2_sybd_api_mode'],
hideDirectly: ['#amily2_sybd_compatible_config', '#amily2_sybd_preset_config'],
hideInContainer: ['.sybd-button-row'],
fields: { provider: '#amily2_sybd_api_mode', apiUrl: '#amily2_sybd_api_url', model: '#amily2_sybd_model' },
keyField: '#amily2_sybd_api_key',
testFn: testSybdApiConnection,
},
cwb: {
container: '#cwb-api-settings-tab',
hideDirectly: [
'label[for="cwb-api-mode"]',
'#cwb-api-mode',
'label[for="cwb-api-url"]',
'#cwb-api-url',
'label[for="cwb-api-key"]',
'#cwb-api-key',
'label[for="cwb-api-model"]',
'#cwb-api-model',
'label[for="cwb-tavern-profile"]',
'#cwb-tavern-profile',
],
hideInContainer: ['.jqyh-button-row'],
fields: { provider: '#cwb-api-mode', apiUrl: '#cwb-api-url', model: '#cwb-api-model' },
keyField: '#cwb-api-key',
testFn: testCwbConnection,
},
autoCharCard: {
container: '#acc-api-settings-content',
hideParentBlock: ['#acc-executor-url', '#acc-executor-key', '#acc-executor-model'],
hideDirectly: ['#acc-executor-refresh-models', '#acc-executor-test', '#acc-save-api'],
fields: { apiUrl: '#acc-executor-url', model: '#acc-executor-model' },
keyField: '#acc-executor-key',
testFn: async () => testAutoCharCardConnection('executor'),
},
ragEmbed: {
container: '#hly-retrieval-tab .hly-settings-group',
hideParentBlock: ['#hly-api-endpoint', '#hly-custom-api-url', '#hly-api-key', '#hly-embedding-model'],
hideDirectly: [
'button[onclick="testHLYApi()"]',
'button[onclick="fetchHLYEmbeddingModels()"]',
],
fields: { provider: '#hly-api-endpoint', apiUrl: '#hly-custom-api-url', model: '#hly-embedding-model' },
keyField: '#hly-api-key',
testFn: async () => {
await testRagEmbeddingConnection();
return true;
},
fetchModelsFn: fetchRagEmbeddingModels,
},
ragRerank: {
container: '#hly-rerank-tab .hly-settings-group',
hideParentBlock: ['#hly-rerank-api-mode', '#hly-rerank-url', '#hly-rerank-api-key', '#hly-rerank-model'],
fields: { apiUrl: '#hly-rerank-url', model: '#hly-rerank-model' },
keyField: '#hly-rerank-api-key',
testFn: async () => {
await executeRagRerank('test', ['test'], null);
return true;
},
fetchModelsFn: fetchRagRerankModels,
},
};
// ── 公开 API ──────────────────────────────────────────────────────────────────
/** 同步单个槽位到对应 DOM 区域。 */
export async function syncSlot(slot) {
const config = SLOT_CONFIGS[slot];
if (!config) return;
const profile = await apiProfileManager.getAssignedProfile(slot);
// 先清理:移除旧卡片、恢复被隐藏的元素
_removeCard(slot);
_restoreHidden(slot);
if (!profile) {
// 取消分配:将 DOM 字段值还原为分配 Profile 前的快照,
// 防止残留的 Profile 回填值(尤其是 '••••••••' 的 Key 占位符)
// 因 blur 事件被误存入 extension_settings / localStorage。
const snap = _fieldSnapshots[slot];
if (snap) {
for (const [sel, val] of Object.entries(snap)) {
const el = document.querySelector(sel);
if (el) el.value = val;
}
delete _fieldSnapshots[slot];
}
return;
}
const container = _resolveContainer(config.container);
if (!container) return;
// 回填前先快照各字段当前值(即 extension_settings / configManager 中的真实值),
// 以便取消分配时能还原,避免 Profile 值污染旧配置。
_removeCard(slot);
_restoreHidden(slot);
_snapshotLegacyFields(slot, config);
const profile = await apiProfileManager.getAssignedProfile(slot);
if (profile) _fillLegacyFields(config, profile);
_hideApiFields(config, container, slot);
_injectCard(slot, profile, config, container);
}
export async function syncAllSlots() {
await Promise.all(Object.keys(SLOT_CONFIGS).map(syncSlot));
}
document.addEventListener('amily2:slotAssigned', (e) => {
const slot = e.detail?.slot;
if (slot) syncSlot(slot);
});
function _resolveContainer(spec) {
if (!spec) return null;
if (spec.startsWith('closest-fieldset:')) {
const anchorSel = spec.slice('closest-fieldset:'.length);
const anchor = document.querySelector(anchorSel);
return anchor?.closest('fieldset') ?? null;
}
return document.querySelector(spec);
}
function _snapshotLegacyFields(slot, config) {
if (_fieldSnapshots[slot]) return;
const snap = {};
for (const sel of Object.values(config.fields || {})) {
const el = document.querySelector(sel);
@@ -149,53 +205,19 @@ export async function syncSlot(slot) {
if (keyEl) snap[config.keyField] = keyEl.value;
}
_fieldSnapshots[slot] = snap;
}
// 回填值(向下兼容:部分代码仍从 DOM 读取 fallback
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 (config.keyField) {
const keyEl = document.querySelector(config.keyField);
if (keyEl) keyEl.value = profile.apiKey ? '••••••••' : '';
if (keyEl) keyEl.value = profile.apiKey ? MASKED_KEY : '';
}
// 隐藏 API 连接字段(保留温度 / 最大 Token 等生成参数)
_hideApiFields(config, container, slot);
// 注入状态卡
_injectCard(slot, profile, config, container);
}
/** 同步所有槽位(面板初始化时调用)。 */
export async function syncAllSlots() {
await Promise.all(Object.keys(SLOT_CONFIGS).map(syncSlot));
}
// ── 事件监听:响应 api-config-bindings 的 slotAssigned 事件 ──────────────────
document.addEventListener('amily2:slotAssigned', (e) => {
const slot = e.detail?.slot;
if (slot) syncSlot(slot);
});
// ── 内部:容器定位 ──────────────────────────────────────────────────────────────
function _resolveContainer(spec) {
if (!spec) return null;
// 'closest-fieldset:#amily2_api_provider' → 从该元素向上找 fieldset
if (spec.startsWith('closest-fieldset:')) {
const anchorSel = spec.slice('closest-fieldset:'.length);
const anchor = document.querySelector(anchorSel);
return anchor?.closest('fieldset') ?? null;
}
return document.querySelector(spec);
}
// ── 内部:隐藏 / 恢复 API 字段 ──────────────────────────────────────────────────
function _hideEl(el, slot) {
if (!el || el.hasAttribute(HIDDEN_ATTR)) return;
el.setAttribute(HIDDEN_ATTR, slot);
@@ -212,7 +234,6 @@ function _restoreHidden(slot) {
}
function _hideApiFields(config, container, slot) {
// 1. 通过子元素找到其父 block 并隐藏
(config.hideParentBlock || []).forEach(sel => {
const el = document.querySelector(sel);
if (!el) return;
@@ -220,90 +241,112 @@ function _hideApiFields(config, container, slot) {
if (block && block !== container) _hideEl(block, slot);
});
// 2. 直接隐藏指定元素
(config.hideDirectly || []).forEach(sel => {
const el = document.querySelector(sel);
if (el) _hideEl(el, slot);
});
// 3. 隐藏元素(上溯到容器直接子元素)+ 前一个兄弟 labelinline-grid 布局)
(config.hideWithLabel || []).forEach(sel => {
const el = document.querySelector(sel);
if (!el) return;
// 沿 DOM 树上溯到容器的直接子元素
let target = el;
while (target.parentElement && target.parentElement !== container) {
target = target.parentElement;
}
_hideEl(target, slot);
const prev = target.previousElementSibling;
if (prev && prev.tagName === 'LABEL') _hideEl(prev, slot);
});
// 4. 在容器内查找并隐藏
(config.hideInContainer || []).forEach(sel => {
const el = container.querySelector(sel);
if (el) _hideEl(el, slot);
container.querySelectorAll(sel).forEach(el => _hideEl(el, slot));
});
}
// ── 内部:状态卡 ──────────────────────────────────────────────────────────────
function _removeCard(slot) {
document.querySelectorAll(`.${CARD_CLASS}[${CARD_SLOT_ATTR}="${slot}"]`)
.forEach(el => el.remove());
}
function _injectCard(slot, profile, _config, container) {
const slotInfo = SLOTS[slot] || { label: slot, type: 'chat' };
const typeInfo = PROFILE_TYPES[slotInfo.type] || {};
const assigned = apiProfileManager.getAssignment(slot) || '';
const profiles = apiProfileManager.getProfiles(slotInfo.type);
const providerLabel = _providerLabel(profile?.provider);
const options = [
`<option value="">-- 未分配,请选择 API 连接 --</option>`,
...profiles.map(p =>
`<option value="${_esc(p.id)}" ${p.id === assigned ? 'selected' : ''}>${_esc(p.name)}</option>`
),
].join('');
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>
` : `
<span style="color:var(--warning-color); font-size:0.85em;">
未分配时该模块不会继续展示/保存独立 API 输入项。
</span>
`;
const card = document.createElement('div');
card.className = CARD_CLASS;
card.setAttribute(CARD_SLOT_ATTR, slot);
card.style.cssText = [
'padding:10px 14px', 'margin:6px 0 10px',
'padding:10px 14px',
'margin:6px 0 10px',
'background:var(--black10a)',
'border:1px solid var(--SmartThemeBorderColor)',
'border-radius:6px', 'font-size:0.88em',
'border-radius:6px',
'font-size:0.88em',
].join(';');
const providerLabel = {
openai: 'OpenAI 兼容',
openai_test: '全兼容',
google: 'Google Gemini',
sillytavern_backend: 'ST 后端',
sillytavern_preset: 'ST 预设',
}[profile.provider] || profile.provider || '';
card.innerHTML = `
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
<i class="fas fa-link" style="color:var(--green,#4caf50);"></i>
<span style="font-weight:600;">${_esc(profile.name)}</span>
<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>
<span class="amily2_psc_goto" style="margin-left:auto; opacity:0.6; font-size:0.85em; cursor:pointer;"
title="前往 API 配置页面">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px; flex-wrap:wrap;">
<i class="fas ${_esc(typeInfo.icon || 'fa-link')}" style="color:var(--green,#4caf50);"></i>
<span style="font-weight:600;">${_esc(slotInfo.label)}</span>
${detailHtml}
<span class="amily2_psc_goto" style="margin-left:auto; opacity:0.7; font-size:0.85em; cursor:pointer;"
title="前往统一 API 配置页">
<i class="fas fa-cog"></i> 管理
</span>
</div>
<select class="text_pole amily2_psc_select" data-slot="${_esc(slot)}" style="width:100%; margin-bottom:8px;">
${options}
</select>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button class="menu_button small_button interactable amily2_psc_test" type="button">
<button class="menu_button small_button interactable amily2_psc_test" type="button" ${profile ? '' : 'disabled'}>
<i class="fas fa-plug"></i> 测试连接
</button>
<button class="menu_button small_button interactable amily2_psc_fetch" type="button">
<button class="menu_button small_button interactable amily2_psc_fetch" type="button" ${profile ? '' : 'disabled'}>
<i class="fas fa-list"></i> 获取模型
</button>
<span class="amily2_psc_result" style="font-size:0.85em; display:flex; align-items:center; margin-left:4px;"></span>
</div>`;
// 绑定按钮事件
card.querySelector('.amily2_psc_goto').addEventListener('click', () => {
document.getElementById('amily2_open_api_config')?.click();
});
card.querySelector('.amily2_psc_select').addEventListener('change', function () {
const id = this.value || null;
if (!apiProfileManager.setAssignment(slot, id)) {
toastr.error('配置类型不匹配,分配失败。');
syncSlot(slot);
return;
}
document.dispatchEvent(new CustomEvent('amily2:slotAssigned', { detail: { slot } }));
});
card.querySelector('.amily2_psc_test').addEventListener('click', () => _testSlot(slot, card));
card.querySelector('.amily2_psc_fetch').addEventListener('click', () => _fetchSlotModels(slot, card));
// 插入到 legend 之后fieldset或容器开头
const legend = container.querySelector(':scope > legend');
if (legend) {
legend.insertAdjacentElement('afterend', card);
@@ -312,30 +355,33 @@ function _injectCard(slot, profile, _config, container) {
}
}
// ── 内部:测试连接(调用各模块的真实测试函数,发送聊天请求)──────────────────────
async function _testSlot(slot, card) {
const $btn = $(card.querySelector('.amily2_psc_test')).prop('disabled', true);
const $btn = $(card.querySelector('.amily2_psc_test')).prop('disabled', true);
const $result = $(card.querySelector('.amily2_psc_result'));
$btn.html('<i class="fas fa-spinner fa-spin"></i> 测试中...');
$result.text('').css('color', '');
try {
const testFn = SLOT_CONFIGS[slot]?.testFn;
if (!testFn) {
$result.text('槽位不支持测试').css('color', 'var(--warning-color)');
const profile = await apiProfileManager.getAssignedProfile(slot);
if (!profile) {
$result.text('槽位未分配').css('color', 'var(--warning-color)');
return;
}
// 调用模块原生测试函数(发送 "你好!" 聊天请求验证连接)
const success = await testFn();
const testFn = SLOT_CONFIGS[slot]?.testFn;
if (!testFn) {
$result.text('该槽位暂不支持快捷测试').css('color', 'var(--warning-color)');
return;
}
const result = await testFn();
const success = typeof result === 'object' ? result?.success : result;
if (success === true) {
$result.text('测试通过').css('color', 'var(--green)');
} else if (success === false) {
$result.text('测试失败(详见弹窗)').css('color', 'var(--warning-color)');
$result.text(result?.error || '测试失败,请查看弹窗/控制台').css('color', 'var(--warning-color)');
}
// undefined = 函数未执行(如 DOM 依赖缺失),不更新卡片
} catch (e) {
$result.text(`错误:${e.message}`).css('color', 'var(--warning-color)');
} finally {
@@ -343,10 +389,8 @@ async function _testSlot(slot, card) {
}
}
// ── 内部:获取模型列表 ──────────────────────────────────────────────────────────
async function _fetchSlotModels(slot, card) {
const $btn = $(card.querySelector('.amily2_psc_fetch')).prop('disabled', true);
const $btn = $(card.querySelector('.amily2_psc_fetch')).prop('disabled', true);
const $result = $(card.querySelector('.amily2_psc_result'));
$btn.html('<i class="fas fa-spinner fa-spin"></i> 获取中...');
$result.text('').css('color', '');
@@ -358,60 +402,20 @@ async function _fetchSlotModels(slot, card) {
return;
}
// ST 预设由酒馆管理,无法获取模型列表
if (profile.provider === 'sillytavern_preset' || profile.provider === 'sillytavern_backend') {
$result.text('ST 预设/后端管理,无需获取').css('color', 'var(--SmartThemeQuoteColor)');
return;
}
let models = [];
if (profile.provider === 'google') {
if (!profile.apiKey) {
$result.text('API Key 为空').css('color', 'var(--warning-color)');
return;
}
const resp = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models',
{ headers: { 'x-goog-api-key': profile.apiKey } }
);
if (!resp.ok) {
$result.text(`失败HTTP ${resp.status}`).css('color', 'var(--warning-color)');
return;
}
const data = await resp.json();
models = (data.models ?? [])
.filter(m => m.supportedGenerationMethods?.some(
method => ['generateContent', 'embedContent'].includes(method)
))
.map(m => m.name.replace(/^models\//, ''));
} else {
// OpenAI 兼容 — 通过 ST 后端代理获取模型列表
const resp = await fetch('/api/backends/chat-completions/status', {
method: 'POST',
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
reverse_proxy: profile.apiUrl,
proxy_password: profile.apiKey,
chat_completion_source: 'openai',
}),
});
if (!resp.ok) {
$result.text(`失败HTTP ${resp.status}`).css('color', 'var(--warning-color)');
return;
}
const rawData = await resp.json();
const list = Array.isArray(rawData) ? rawData : (rawData.data ?? rawData.models ?? []);
models = list.map(m => m.id ?? m.name ?? m).filter(m => typeof m === 'string' && m);
}
const customFetch = SLOT_CONFIGS[slot]?.fetchModelsFn;
const models = customFetch ? await customFetch() : await _loadModels(profile);
if (models.length === 0) {
$result.text('未获取到模型').css('color', 'var(--warning-color)');
return;
}
const current = profile.model;
const inList = current && models.includes(current);
const inList = current && models.includes(current);
$result.html(
`<span style="color:var(--green);">${models.length} 个模型</span>` +
(current ? ` · 当前: <b>${_esc(current)}</b> ${inList ? '✓' : '<span style="color:var(--warning-color);">(不在列表中)</span>'}` : '')
@@ -424,10 +428,54 @@ async function _fetchSlotModels(slot, card) {
}
}
// ── 工具 ──────────────────────────────────────────────────────────────────────
async function _loadModels(profile) {
if (profile.provider === 'google') {
if (!profile.apiKey) throw new Error('API Key 为空');
const resp = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models',
{ headers: { 'x-goog-api-key': profile.apiKey } }
);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
return (data.models ?? [])
.filter(m => m.supportedGenerationMethods?.some(method => ['generateContent', 'embedContent'].includes(method)))
.map(m => m.name.replace(/^models\//, ''))
.sort((a, b) => a.localeCompare(b));
}
const resp = await fetch('/api/backends/chat-completions/status', {
method: 'POST',
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
reverse_proxy: profile.apiUrl,
proxy_password: profile.apiKey,
chat_completion_source: 'openai',
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const rawData = await resp.json();
const list = Array.isArray(rawData) ? rawData : (rawData.data ?? rawData.models ?? []);
return list
.map(m => m.id ?? m.name ?? m)
.filter(m => typeof m === 'string' && m)
.sort((a, b) => a.localeCompare(b));
}
function _providerLabel(provider) {
return {
openai: 'OpenAI 兼容',
openai_test: '全兼容',
google: 'Google Gemini',
sillytavern_backend: 'ST 后端',
sillytavern_preset: 'ST 预设',
}[provider] || provider || '';
}
function _esc(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

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

View File

@@ -1370,7 +1370,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 +1440,46 @@ 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 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) {

157
utils/api-vendor.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* @file API 厂商识别 + 参数 registry 查询。
*
* Registry 文件assets/api-vendor-params.json
* 加载策略:模块首次调用时 fetch 一次,缓存到 _registry。
*
* 提供的能力:
* - detectVendor(apiUrl) → vendorId | null
* - getVendorEntry(vendorId) → 完整 vendor 对象(含 params 元信息)
* - listVendorParams(vendorId) → [{ name, type, desc, ... }]
* - getRegistry() → 整个 registrydebug 用)
*
* Phase A 仅用于 customParams 编辑器的提示展示,不做强制校验。
* Phase B 计划:迁移现有 15 处散乱的 `apiUrl.includes('googleapis.com')` 等检查到 detectVendor 单一入口。
*/
import { extensionName } from './settings.js';
const REGISTRY_PATH = `scripts/extensions/third-party/${extensionName}/assets/api-vendor-params.json`;
/** @type {Promise<any> | null} */
let _registryPromise = null;
/** @type {any | null} */
let _registry = null;
/**
* 懒加载 registry缓存到模块作用域。多次调用只 fetch 一次。
* @returns {Promise<any>}
*/
async function _loadRegistry() {
if (_registry) return _registry;
if (!_registryPromise) {
_registryPromise = fetch(REGISTRY_PATH)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status} 加载 ${REGISTRY_PATH} 失败`);
return res.json();
})
.then(data => {
_registry = data;
return data;
})
.catch(err => {
console.error('[api-vendor] registry 加载失败:', err);
// 降级到内置最小 fallback保证业务不中断
_registry = {
version: 0,
vendors: [],
fallback: { id: 'openai-compat', displayName: 'OpenAI-compatible', params: {} },
};
return _registry;
});
}
return _registryPromise;
}
/**
* 强制刷新 registry开发期热更新或测试用
*/
export async function reloadRegistry() {
_registry = null;
_registryPromise = null;
return _loadRegistry();
}
/**
* 返回完整 registry。供 UI 列举所有 vendor、debug 用。
* @returns {Promise<any>}
*/
export async function getRegistry() {
return _loadRegistry();
}
/**
* 根据 apiUrl 识别 vendor。匹配不上时返回 fallback.id默认 'openai-compat')。
* 大小写不敏感的 substring 匹配。
*
* @param {string} apiUrl
* @returns {Promise<string | null>}
*/
export async function detectVendor(apiUrl) {
const reg = await _loadRegistry();
const url = (apiUrl || '').toLowerCase();
if (!url) return reg.fallback?.id || null;
for (const vendor of reg.vendors || []) {
const matches = vendor.match || [];
if (matches.some(m => url.includes(String(m).toLowerCase()))) {
return vendor.id;
}
}
return reg.fallback?.id || null;
}
/**
* 根据 vendorId 取完整 vendor 对象(含 params 元信息)。
* fallback id 也能查到。
*
* @param {string | null | undefined} vendorId
* @returns {Promise<any | null>}
*/
export async function getVendorEntry(vendorId) {
if (!vendorId) return null;
const reg = await _loadRegistry();
if (reg.fallback && vendorId === reg.fallback.id) return reg.fallback;
return (reg.vendors || []).find(v => v.id === vendorId) || null;
}
/**
* 列出指定 vendor 的所有标准参数(已过滤掉 _doc / _warning_* 这类 meta 字段)。
*
* @param {string | null | undefined} vendorId
* @returns {Promise<Array<{ name: string, type?: string, range?: number[], values?: string[], desc?: string }>>}
*/
export async function listVendorParams(vendorId) {
const entry = await getVendorEntry(vendorId);
if (!entry || !entry.params) return [];
return Object.entries(entry.params)
.filter(([k]) => !k.startsWith('_'))
.map(([name, meta]) => ({ name, ...(meta || {}) }));
}
/**
* 同步版:从已加载的 registry 直接查询。仅在确知已 await 过 _loadRegistry 后使用,
* 主要给 UI render 循环用(避免 React-style 异步重渲染)。
* registry 未加载时返回空。
*
* @param {string} apiUrl
* @returns {string | null}
*/
export function detectVendorSync(apiUrl) {
if (!_registry) return null;
const url = (apiUrl || '').toLowerCase();
if (!url) return _registry.fallback?.id || null;
for (const vendor of _registry.vendors || []) {
const matches = vendor.match || [];
if (matches.some(m => url.includes(String(m).toLowerCase()))) {
return vendor.id;
}
}
return _registry.fallback?.id || null;
}
/**
* 同步版 listVendorParams。同样要求 registry 已 preload。
* @param {string | null | undefined} vendorId
* @returns {Array<{ name: string, type?: string, range?: number[], values?: string[], desc?: string }>}
*/
export function listVendorParamsSync(vendorId) {
if (!_registry || !vendorId) return [];
let entry = null;
if (_registry.fallback && vendorId === _registry.fallback.id) entry = _registry.fallback;
else entry = (_registry.vendors || []).find(v => v.id === vendorId) || null;
if (!entry || !entry.params) return [];
return Object.entries(entry.params)
.filter(([k]) => !k.startsWith('_'))
.map(([name, meta]) => ({ name, ...(meta || {}) }));
}

File diff suppressed because one or more lines are too long

View File

@@ -35,6 +35,7 @@ import { extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.js";
import { extensionName } from "../settings.js";
import { apiKeyStore } from "./api-key-store/ApiKeyStore.js";
import { configManager } from "./ConfigManager.js";
// ── 类型与功能槽定义 ──────────────────────────────────────────────────────────
@@ -66,11 +67,12 @@ 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' },
tableFilling: { label: '表格填表 / 重整', type: 'chat' },
// Embedding 槽
ragEmbed: { label: 'RAG 向量化', type: 'embedding' },
// Rerank 槽
@@ -252,6 +254,12 @@ class ApiProfileManager {
...base,
maxTokens: data.maxTokens ?? 65500,
temperature: data.temperature ?? 1.0,
fakeStream: data.fakeStream ?? false,
// 自定义参数:透传到 LLM 请求 body 的额外 key/valuetop_p、frequency_penalty 等)
// 由 utils/api-vendor.js 提供 vendor 标准参数提示,但不强校验。
customParams: (typeof data.customParams === 'object' && data.customParams !== null)
? data.customParams
: {},
};
}
if (type === 'embedding') {
@@ -295,6 +303,278 @@ export const apiProfileManager = new ApiProfileManager();
}
})();
// ── Profile.provider 迁移 ────────────────────────────────────────────────────
// Phase B 改造:旧 'openai' 是"OpenAI 兼容总称",现在拆为 6 个具体 vendor + 'custom_oai'。
// 按 URL substring 推断真实 vendor推断不出来 → 改成 'custom_oai'URL 为空 → 保持 'openai'。
// 仅迁移 provider==='openai' 的旧 profile新值anthropic/openrouter/deepseek/xai/custom_oai/google/...)一概不动。
function _detectVendorFromUrlSync(url) {
if (!url) return null;
const lower = String(url).toLowerCase();
if (lower.includes('anthropic.com')) return 'anthropic';
if (lower.includes('openrouter.ai')) return 'openrouter';
if (lower.includes('googleapis.com') || lower.includes('aistudio.google.com')) return 'google';
if (lower.includes('deepseek.com')) return 'deepseek';
if (lower.includes('x.ai') || lower.includes('xai.com')) return 'xai';
if (lower.includes('openai.com')) return 'openai';
return null;
}
;(() => {
try {
const s = extension_settings[extensionName];
if (!s || !Array.isArray(s[EXT_PROFILES])) return;
let migratedCount = 0;
for (const profile of s[EXT_PROFILES]) {
if (profile?.provider !== 'openai') continue; // 已是新值或非 chat profile
const detected = _detectVendorFromUrlSync(profile.apiUrl);
if (detected && detected !== 'openai') {
profile.provider = detected;
migratedCount++;
} else if (profile.apiUrl && !detected) {
// URL 填了但不匹配任何已知厂商 → 标记为 custom_oai
profile.provider = 'custom_oai';
migratedCount++;
}
// URL 为空(新建中)或确实是 openai.com → 保持 'openai'
}
if (migratedCount > 0) {
console.info(`[ApiProfiles] 迁移: ${migratedCount} 个 profile 的 provider 字段已按 URL 重分类。`);
saveSettingsDebounced();
}
} catch (e) {
console.warn('[ApiProfiles] provider 迁移失败:', e);
}
})();
// ── Legacy → Profile 自动迁移v2.1.x─────────────────────────────────────
// 对每个 chat slot若没分配 profile 且旧字段apiUrl + model 都填了)存在,
// 自动建一个 profile + 迁移 API Key + 分配给该 slot。
// 幂等:通过 _legacyProfileMigrationDone 标记,只在首次 ship 后跑一次。
// 旧字段保留不动,由"清除旧配置残留"按钮显式清理。
/**
* 每个 slot 的 legacy 字段映射。jqyh 已合并到 plotOpt 不单独迁移。
* cwb / autoCharCard / ragEmbed / ragRerank 字段结构差异较大,留作后续。
*/
const LEGACY_PROFILE_MIGRATION_MAP = [
{
slot: 'main',
urlKey: 'apiUrl',
modelKey: 'model',
keyName: 'apiKey',
maxTokensKey: 'maxTokens',
temperatureKey: 'temperature',
name: '主面板 旧配置',
},
{
slot: 'plotOpt',
urlKey: 'plotOpt_apiUrl',
modelKey: 'plotOpt_model',
keyName: 'plotOpt_apiKey',
maxTokensKey: 'plotOpt_max_tokens',
temperatureKey: 'plotOpt_temperature',
name: '剧情优化 旧配置',
},
{
slot: 'plotOptConc',
urlKey: 'plotOpt_concurrentApiUrl',
modelKey: 'plotOpt_concurrentModel',
keyName: 'plotOpt_concurrentApiKey',
maxTokensKey: 'plotOpt_concurrentMaxTokens',
temperatureKey: null, // 并发优化无独立 temperature 旧字段
name: '并发剧情优化 旧配置',
},
{
slot: 'ngms',
urlKey: 'ngmsApiUrl',
modelKey: 'ngmsModel',
keyName: 'ngmsApiKey',
maxTokensKey: 'ngmsMaxTokens',
temperatureKey: 'ngmsTemperature',
name: 'NGMS 旧配置',
},
{
slot: 'nccs',
urlKey: 'nccsApiUrl',
modelKey: 'nccsModel',
keyName: 'nccsApiKey',
maxTokensKey: 'nccsMaxTokens',
temperatureKey: 'nccsTemperature',
name: 'NCCS 旧配置',
},
{
slot: 'sybd',
urlKey: 'sybdApiUrl',
modelKey: 'sybdModel',
keyName: 'sybdApiKey',
maxTokensKey: 'sybdMaxTokens',
temperatureKey: 'sybdTemperature',
name: 'SYBD 旧配置',
},
];
;(async () => {
try {
const s = extension_settings[extensionName];
if (!s) return;
if (s._legacyProfileMigrationDone) return; // 幂等
const migrated = [];
for (const m of LEGACY_PROFILE_MIGRATION_MAP) {
// 已分配 profile 的 slot 跳过
if (apiProfileManager.getAssignment(m.slot)) continue;
const url = String(s[m.urlKey] ?? '').trim();
const model = String(s[m.modelKey] ?? '').trim();
if (!url || !model) continue; // 旧配置不完整,跳过
const provider = _detectVendorFromUrlSync(url) || 'custom_oai';
const profileId = apiProfileManager.createProfile({
type: 'chat',
name: m.name,
provider,
apiUrl: url,
model,
maxTokens: s[m.maxTokensKey] ?? undefined,
temperature: m.temperatureKey ? s[m.temperatureKey] : undefined,
});
// 旧 API Key 从 configManagerlocalStorage读出写入 ApiKeyStore
try {
const legacyKey = configManager.get(m.keyName);
if (legacyKey) await apiProfileManager.setKey(profileId, legacyKey);
} catch (keyErr) {
console.warn(`[ApiProfiles] ${m.slot} Key 迁移失败:`, keyErr);
}
apiProfileManager.setAssignment(m.slot, profileId);
migrated.push(`${m.slot}${profileId}`);
}
// 新引入的 slot无 legacy 字段可迁移)默认借用其他 slot 的 profile
// 让升级用户的功能不至于因为没主动分配而中断。用户可以随后改成专属 profile。
const SLOT_INHERITANCE = {
tableFilling: 'main', // 表格填表历史上默认走主 API升级后默认沿用 main 的 profile
};
const linked = [];
for (const [newSlot, sourceSlot] of Object.entries(SLOT_INHERITANCE)) {
if (apiProfileManager.getAssignment(newSlot)) continue;
const sourceId = apiProfileManager.getAssignment(sourceSlot);
if (sourceId) {
apiProfileManager.setAssignment(newSlot, sourceId);
linked.push(`${newSlot}${sourceSlot} (${sourceId})`);
}
}
s._legacyProfileMigrationDone = true;
saveSettingsDebounced();
if (migrated.length > 0 || linked.length > 0) {
if (migrated.length > 0) {
console.info(`[ApiProfiles] 自动迁移 ${migrated.length} 个旧配置 → profile:`, migrated);
}
if (linked.length > 0) {
console.info(`[ApiProfiles] 自动 link ${linked.length} 个新 slot 借用现有 profile:`, linked);
}
// 延迟提示,等 toastr 就绪
setTimeout(() => {
if (typeof toastr !== 'undefined' && migrated.length > 0) {
toastr.success(
`已自动迁移 ${migrated.length} 个旧 API 配置到新连接配置${linked.length > 0 ? `(含 ${linked.length} 个新槽位借用)` : ''}。请检查"API 连接配置"面板,确认无误后可点"清除旧配置残留"。`,
'Amily2 配置迁移',
{ timeOut: 8000 }
);
}
}, 2000);
}
} catch (e) {
console.warn('[ApiProfiles] Legacy → profile 自动迁移失败:', e);
}
})();
/**
* 清除旧配置残留 —— 用户在 UI 点击按钮时调用。
*
* 行为:
* 1. 校验所有有 legacy 字段的 slot 都已分配 profile防止误删导致功能没配置
* 2. 删除 extension_settings 里的 legacy URL / model / maxTokens / temperature / apiMode / tavernProfile / fakeStream 字段
* 3. 删除 configManagerlocalStorage里的 legacy API Key
* 4. 不删 _legacyProfileMigrationDone 标记(避免再次运行迁移)
*
* @returns {{ ok: boolean, error?: string, clearedFields: number, clearedKeys: number }}
*/
export function clearLegacyConfig() {
const s = extension_settings[extensionName];
if (!s) return { ok: false, error: 'extension_settings 不存在', clearedFields: 0, clearedKeys: 0 };
// 前置校验:每个有 legacy 数据的 slot 必须已分配 profile
for (const m of LEGACY_PROFILE_MIGRATION_MAP) {
const url = String(s[m.urlKey] ?? '').trim();
const model = String(s[m.modelKey] ?? '').trim();
const hasLegacy = url || model;
if (!hasLegacy) continue;
if (!apiProfileManager.getAssignment(m.slot)) {
return {
ok: false,
error: `槽位 "${m.slot}" 仍有旧配置但未分配 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'],
plotOpt: ['plotOpt_apiUrl', 'plotOpt_model', 'plotOpt_apiMode', 'plotOpt_tavernProfile', 'plotOpt_max_tokens', 'plotOpt_temperature', 'plotOpt_top_p', 'plotOpt_presence_penalty', 'plotOpt_frequency_penalty'],
plotOptConc: ['plotOpt_concurrentApiUrl', 'plotOpt_concurrentModel', 'plotOpt_concurrentApiProvider', 'plotOpt_concurrentMaxTokens'],
ngms: ['ngmsApiUrl', 'ngmsModel', 'ngmsApiMode', 'ngmsTavernProfile', 'ngmsMaxTokens', 'ngmsTemperature', 'ngmsFakeStreamEnabled'],
nccs: ['nccsApiUrl', 'nccsModel', 'nccsApiMode', 'nccsTavernProfile', 'nccsMaxTokens', 'nccsTemperature', 'nccsFakeStreamEnabled'],
sybd: ['sybdApiUrl', 'sybdModel', 'sybdApiMode', 'sybdTavernProfile', 'sybdMaxTokens', 'sybdTemperature'],
// jqyh 字段也清掉(已合并到 plotOpt 但残留可能还在)
jqyh: ['jqyhApiUrl', 'jqyhModel', 'jqyhApiMode', 'jqyhTavernProfile', 'jqyhMaxTokens', 'jqyhTemperature', 'jqyhEnabled'],
};
const LEGACY_KEY_NAMES = {
main: 'apiKey',
plotOpt: 'plotOpt_apiKey',
plotOptConc: 'plotOpt_concurrentApiKey',
ngms: 'ngmsApiKey',
nccs: 'nccsApiKey',
sybd: 'sybdApiKey',
jqyh: 'jqyhApiKey',
};
let clearedFields = 0;
let clearedKeys = 0;
for (const slot of Object.keys(ALL_LEGACY_FIELDS)) {
for (const field of ALL_LEGACY_FIELDS[slot]) {
if (field in s) {
delete s[field];
clearedFields++;
}
}
const keyName = LEGACY_KEY_NAMES[slot];
if (keyName) {
try {
if (configManager.get(keyName)) {
// configManager.set(key, '') 对敏感字段会同时清除 localStorage + extension_settings
configManager.set(keyName, '');
clearedKeys++;
}
} catch (e) {
console.warn(`[ApiProfiles] 清除旧 Key ${keyName} 失败:`, e);
}
}
}
saveSettingsDebounced();
console.info(`[ApiProfiles] 清除旧配置残留:${clearedFields} 个字段 + ${clearedKeys} 个 Key。`);
return { ok: true, clearedFields, clearedKeys };
}
// ── Bus 注册 ──────────────────────────────────────────────────────────────────
setTimeout(() => {
try {

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
const a0_0x483ad6=a0_0xa4a6;(function(_0x347c41,_0x3681fa){const _0x439ab9=a0_0xa4a6,_0x19ae58=_0x347c41();while(!![]){try{const _0x5b741e=-parseInt(_0x439ab9(0x8b,'ZFoI'))/0x1*(-parseInt(_0x439ab9(0x9c,'FP7g'))/0x2)+-parseInt(_0x439ab9(0x8c,'*G1t'))/0x3*(-parseInt(_0x439ab9(0x9e,'DPPl'))/0x4)+parseInt(_0x439ab9(0x93,'*G1t'))/0x5*(-parseInt(_0x439ab9(0x96,'butc'))/0x6)+-parseInt(_0x439ab9(0x81,'0sy3'))/0x7*(parseInt(_0x439ab9(0x9b,'QR)F'))/0x8)+parseInt(_0x439ab9(0x86,'DPPl'))/0x9+parseInt(_0x439ab9(0x91,'&LNc'))/0xa*(parseInt(_0x439ab9(0x99,'I7rj'))/0xb)+-parseInt(_0x439ab9(0x90,'Ue90'))/0xc;if(_0x5b741e===_0x3681fa)break;else _0x19ae58['push'](_0x19ae58['shift']());}catch(_0x33f9a1){_0x19ae58['push'](_0x19ae58['shift']());}}}(a0_0x1527,0x8ab75));function a0_0xa4a6(_0xa2dfc1,_0x138a0c){_0xa2dfc1=_0xa2dfc1-0x7f;const _0x152796=a0_0x1527();let _0xa4a638=_0x152796[_0xa2dfc1];if(a0_0xa4a6['JmeJrD']===undefined){var _0x517737=function(_0x5a8e29){const _0x319d63='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x4e0805='',_0x57921f='';for(let _0x997e7d=0x0,_0x2df684,_0x5ad720,_0x433629=0x0;_0x5ad720=_0x5a8e29['charAt'](_0x433629++);~_0x5ad720&&(_0x2df684=_0x997e7d%0x4?_0x2df684*0x40+_0x5ad720:_0x5ad720,_0x997e7d++%0x4)?_0x4e0805+=String['fromCharCode'](0xff&_0x2df684>>(-0x2*_0x997e7d&0x6)):0x0){_0x5ad720=_0x319d63['indexOf'](_0x5ad720);}for(let _0x33166c=0x0,_0x28e4f2=_0x4e0805['length'];_0x33166c<_0x28e4f2;_0x33166c++){_0x57921f+='%'+('00'+_0x4e0805['charCodeAt'](_0x33166c)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x57921f);};const _0x6d6410=function(_0x4f3ad6,_0x28cd52){let _0x3a4fa6=[],_0x494034=0x0,_0x4f28b1,_0x1ec03f='';_0x4f3ad6=_0x517737(_0x4f3ad6);let _0x1acd8b;for(_0x1acd8b=0x0;_0x1acd8b<0x100;_0x1acd8b++){_0x3a4fa6[_0x1acd8b]=_0x1acd8b;}for(_0x1acd8b=0x0;_0x1acd8b<0x100;_0x1acd8b++){_0x494034=(_0x494034+_0x3a4fa6[_0x1acd8b]+_0x28cd52['charCodeAt'](_0x1acd8b%_0x28cd52['length']))%0x100,_0x4f28b1=_0x3a4fa6[_0x1acd8b],_0x3a4fa6[_0x1acd8b]=_0x3a4fa6[_0x494034],_0x3a4fa6[_0x494034]=_0x4f28b1;}_0x1acd8b=0x0,_0x494034=0x0;for(let _0x400606=0x0;_0x400606<_0x4f3ad6['length'];_0x400606++){_0x1acd8b=(_0x1acd8b+0x1)%0x100,_0x494034=(_0x494034+_0x3a4fa6[_0x1acd8b])%0x100,_0x4f28b1=_0x3a4fa6[_0x1acd8b],_0x3a4fa6[_0x1acd8b]=_0x3a4fa6[_0x494034],_0x3a4fa6[_0x494034]=_0x4f28b1,_0x1ec03f+=String['fromCharCode'](_0x4f3ad6['charCodeAt'](_0x400606)^_0x3a4fa6[(_0x3a4fa6[_0x1acd8b]+_0x3a4fa6[_0x494034])%0x100]);}return _0x1ec03f;};a0_0xa4a6['YEbknG']=_0x6d6410,a0_0xa4a6['QNQDqw']={},a0_0xa4a6['JmeJrD']=!![];}const _0x10211e=_0x152796[0x0],_0x2a793d=_0xa2dfc1+_0x10211e,_0x2bc5e3=a0_0xa4a6['QNQDqw'][_0x2a793d];return!_0x2bc5e3?(a0_0xa4a6['lYBLiN']===undefined&&(a0_0xa4a6['lYBLiN']=!![]),_0xa4a638=a0_0xa4a6['YEbknG'](_0xa4a638,_0x138a0c),a0_0xa4a6['QNQDqw'][_0x2a793d]=_0xa4a638):_0xa4a638=_0x2bc5e3,_0xa4a638;}function a0_0x1527(){const _0x21a110=['FSootSo+W67cRspcJZldV0FdTq','WOv7W59+W6BdKSodwIBcIa','uSo6W6FdHmkTCmovAa','WRxdUN/dOSkFbGe','fmkBfSoIWQCAcCoOWR9nW5hdVgO','WObJymkihSoVBWv+DW','ggWVW4rMuCkpW4dcGbyhW47dLCkvDGaXW43cMX7cKSoqy1q','lCoSWQzFumkPWRtcJSk6WOiJgCkHW5W','WQBcUmkeW7ZcSSocg8oL','ofBcQ8oWkSkehmkFWRD4WQnj','WRVcHWNcKmkdmcJdMmkUWQK','C8kBk1FdP2bcyNmYE8oaWQeQ','WOJcPgqbWPPsWPW','WPpdM0XKzenpgCkkmq','r8oOef3cP8oFqSk1WOdcOa','ESojs8oZW63cR3BcIYZdVNtdKSoo','W54TWOm1WPtcLmkCzYJcTSoJW7yv','BtjZW73cJCkxWRddTfK9WOj+W6Kk','hGddJqnBmvXdxMZdLgS','DqxdNb08cuhcHdeMzGW','WQ4zFqxdNLxcOJ8','WQieW5OeuXmWDColxCorf8oh','tSoGDaZcQCobDCkU','W4BdKSk1bmofWOtdPCoOWQJcMG','zCkPWOWIW5JdUSonWOC','nK4fldX7','W4tdTc7dKCoMrCknWOdcQCkyW7hdJ8ol','Ac3dH8okWOldPgqdWPCg','nNOYWQxdVSoFW6/cH0Op','WQOpW58duHm1wSoDq8oyl8o9','WPhdNWCmDubfiG','p3WPWPldNSoFW6/cK0qtWPC'];a0_0x1527=function(){return _0x21a110;};return a0_0x1527();}export const SENSITIVE_KEYS=new Set([a0_0x483ad6(0x82,'zzAg'),a0_0x483ad6(0x94,'Lp4^'),a0_0x483ad6(0x8f,']o&H'),a0_0x483ad6(0x8a,'I7rj'),a0_0x483ad6(0x80,'MLS]'),a0_0x483ad6(0x85,'x5GK'),a0_0x483ad6(0x88,'x5GK'),a0_0x483ad6(0x84,'Vzpf')]);
function a0_0x271c(){const _0x4b7b23=['lhvoW4jRgSoQlXxcIW','EmojW7BcUdLKW4qDhIFcM8oH','WP7dM1RcQ8kAW5HNWOy','WQxcI0VdUCo9Ca','W7ldJHVcGSkSmg7cRexcNmojW7O','sZnjE8kcW7JdJCkTWQyOchm','kmkMWOZdTY5AWQ/cUdWN','W5lcSHLBF8odWQ3dRmk6l8kv','W7jspw7dQI8FpSkjha','WQKlf8kpWQGbW790','WOhcMmoZnx/dG8kJgcaODCk6DeKShmo1vmkzW5bZW6FdGWu','FSoRg2XIW6BcPs45WOj/W77dMq','WQ0gx8othedcGsVdRSkfWQPQWPjW','W5BcGNDMWOxdIGaBFsK','CmktCsNcH8kitH11rYq','WQj4sHTirmoMW7CbWOvopSkP','g3ddGCkLBmouA1BcJCkZW7a','n3dcOtqPWQusaxWo','tdjdECkgW7/cG8kLWPCJb3FcSq','WPFdUmo4W5/dT3XJd8oBoW','WPVcGeVcKCkhW5T/','WQOaw8ossGpdLxldQmkx','E8onW7ZcUtTPW70weZVcQCo3','W4FdQ1a6W5GHE8oebbG','W5urpfBdGCk1kvzIuSkOyCkouG','F8oiW7ZcUJ1JWOOvlYZcLmoLxa','E8k8tH8IWPZdJW','W6HJftmQW6SlWR1rgG','WO7dSmoJW5pdT3XJd8oBoW','stBcKSopomkxntZcO8kfW4XmFSke'];a0_0x271c=function(){return _0x4b7b23;};return a0_0x271c();}function a0_0x29d9(_0x5910a2,_0x31a139){_0x5910a2=_0x5910a2-0x10e;const _0x271c55=a0_0x271c();let _0x29d9db=_0x271c55[_0x5910a2];if(a0_0x29d9['vXrLbK']===undefined){var _0x1f4aa0=function(_0x419a5a){const _0x54ddf3='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x1487a3='',_0x358070='';for(let _0x556c37=0x0,_0x38eb9b,_0x5849e9,_0x382ad5=0x0;_0x5849e9=_0x419a5a['charAt'](_0x382ad5++);~_0x5849e9&&(_0x38eb9b=_0x556c37%0x4?_0x38eb9b*0x40+_0x5849e9:_0x5849e9,_0x556c37++%0x4)?_0x1487a3+=String['fromCharCode'](0xff&_0x38eb9b>>(-0x2*_0x556c37&0x6)):0x0){_0x5849e9=_0x54ddf3['indexOf'](_0x5849e9);}for(let _0x3f032e=0x0,_0xb58a17=_0x1487a3['length'];_0x3f032e<_0xb58a17;_0x3f032e++){_0x358070+='%'+('00'+_0x1487a3['charCodeAt'](_0x3f032e)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x358070);};const _0x34ec80=function(_0x164ca3,_0x4421d8){let _0xd7a592=[],_0x289d74=0x0,_0x21850c,_0x43a638='';_0x164ca3=_0x1f4aa0(_0x164ca3);let _0x17b6ca;for(_0x17b6ca=0x0;_0x17b6ca<0x100;_0x17b6ca++){_0xd7a592[_0x17b6ca]=_0x17b6ca;}for(_0x17b6ca=0x0;_0x17b6ca<0x100;_0x17b6ca++){_0x289d74=(_0x289d74+_0xd7a592[_0x17b6ca]+_0x4421d8['charCodeAt'](_0x17b6ca%_0x4421d8['length']))%0x100,_0x21850c=_0xd7a592[_0x17b6ca],_0xd7a592[_0x17b6ca]=_0xd7a592[_0x289d74],_0xd7a592[_0x289d74]=_0x21850c;}_0x17b6ca=0x0,_0x289d74=0x0;for(let _0x36b413=0x0;_0x36b413<_0x164ca3['length'];_0x36b413++){_0x17b6ca=(_0x17b6ca+0x1)%0x100,_0x289d74=(_0x289d74+_0xd7a592[_0x17b6ca])%0x100,_0x21850c=_0xd7a592[_0x17b6ca],_0xd7a592[_0x17b6ca]=_0xd7a592[_0x289d74],_0xd7a592[_0x289d74]=_0x21850c,_0x43a638+=String['fromCharCode'](_0x164ca3['charCodeAt'](_0x36b413)^_0xd7a592[(_0xd7a592[_0x17b6ca]+_0xd7a592[_0x289d74])%0x100]);}return _0x43a638;};a0_0x29d9['KgrlkL']=_0x34ec80,a0_0x29d9['kNssnt']={},a0_0x29d9['vXrLbK']=!![];}const _0x1b2a57=_0x271c55[0x0],_0x49c2f7=_0x5910a2+_0x1b2a57,_0x1a2216=a0_0x29d9['kNssnt'][_0x49c2f7];return!_0x1a2216?(a0_0x29d9['XDIAAg']===undefined&&(a0_0x29d9['XDIAAg']=!![]),_0x29d9db=a0_0x29d9['KgrlkL'](_0x29d9db,_0x31a139),a0_0x29d9['kNssnt'][_0x49c2f7]=_0x29d9db):_0x29d9db=_0x1a2216,_0x29d9db;}const a0_0x46b8d0=a0_0x29d9;(function(_0x37f9e6,_0xba7d17){const _0xcc4674=a0_0x29d9,_0x15f749=_0x37f9e6();while(!![]){try{const _0x741256=-parseInt(_0xcc4674(0x121,'bDll'))/0x1*(parseInt(_0xcc4674(0x112,'yhY8'))/0x2)+parseInt(_0xcc4674(0x114,'tp0g'))/0x3+-parseInt(_0xcc4674(0x127,'IWny'))/0x4*(-parseInt(_0xcc4674(0x125,'uN)a'))/0x5)+parseInt(_0xcc4674(0x117,'tp0g'))/0x6+-parseInt(_0xcc4674(0x11b,'F@y%'))/0x7+-parseInt(_0xcc4674(0x11c,'MFs7'))/0x8*(-parseInt(_0xcc4674(0x122,'V%T]'))/0x9)+-parseInt(_0xcc4674(0x12a,'vQuU'))/0xa*(-parseInt(_0xcc4674(0x115,'^vxs'))/0xb);if(_0x741256===_0xba7d17)break;else _0x15f749['push'](_0x15f749['shift']());}catch(_0x5665c9){_0x15f749['push'](_0x15f749['shift']());}}}(a0_0x271c,0xdbf20));export const SENSITIVE_KEYS=new Set([a0_0x46b8d0(0x11f,'k^Lf'),a0_0x46b8d0(0x116,'d7&G'),a0_0x46b8d0(0x126,'KWT3'),a0_0x46b8d0(0x119,'EVQd'),a0_0x46b8d0(0x10f,'$OoJ'),a0_0x46b8d0(0x11a,'7SNw'),a0_0x46b8d0(0x10e,'F@y%'),a0_0x46b8d0(0x111,'7SNw')]);

View File

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