From d9fa3072a2f6d68607350f1534635da32f032ad1 Mon Sep 17 00:00:00 2001 From: Jenkins CI Date: Sat, 16 May 2026 19:16:28 +0800 Subject: [PATCH] ci: auto build & obfuscate [2026-05-16 19:16:28] (Jenkins #21) --- 51TODO.md | 96 ++ DPS.drawio | 154 ++ IAD.drawio | 178 +++ SL/bus/api/RequestBody.js | 10 +- TODOList.md | 356 +++++ TableTODO.md | 309 ++++ assets/api-config-panel.html | 69 +- assets/api-vendor-params.json | 208 +++ core/api.js | 47 +- core/api/ConcurrentApi.js | 10 +- core/api/JqyhApi.js | 20 +- core/api/NccsApi.js | 20 +- core/api/Ngms_api.js | 20 +- core/api/SybdApi.js | 19 +- core/auto-char-card/ui-bindings.js | 2 + core/historiographer.js | 9 +- core/rag-api.js | 2 +- core/table-system/actions/applyOperations.js | 190 +++ core/table-system/batch-filler.js | 4 +- core/table-system/dto/Change.js | 21 + core/table-system/dto/Operation.js | 26 + core/table-system/dto/Table.js | 38 + core/table-system/dto/TableState.js | 9 + core/table-system/executor.js | 251 ++- core/table-system/infra/persistence.js | 98 ++ core/table-system/infra/store.js | 117 ++ core/table-system/manager.js | 1449 +++++------------- core/table-system/preset.js | 329 ++++ core/table-system/rendering.js | 239 +++ core/table-system/reorganizer.js | 4 +- core/table-system/secondary-filler.js | 4 +- core/table-system/templates.js | 78 + jsconfig.json | 27 + manifest.json | 2 +- types/sillytavern.d.ts | 68 + ui/api-config-bindings.js | 290 +++- ui/hanlinyuan-bindings.js | 3 + ui/historiography-bindings.js | 22 - ui/plot-opt-bindings.js | 4 - ui/profile-sync.js | 492 +++--- utils/api-vendor.js | 157 ++ utils/auth.js | 2 +- utils/config/ApiProfileManager.js | 279 ++++ utils/config/api-key-store/ApiKeyStore.js | 2 +- utils/config/api-key-store/crypto-utils.js | 2 +- utils/config/sensitive-keys.js | 2 +- 46 files changed, 4154 insertions(+), 1584 deletions(-) create mode 100644 51TODO.md create mode 100644 DPS.drawio create mode 100644 IAD.drawio create mode 100644 TODOList.md create mode 100644 TableTODO.md create mode 100644 assets/api-vendor-params.json create mode 100644 core/table-system/actions/applyOperations.js create mode 100644 core/table-system/dto/Change.js create mode 100644 core/table-system/dto/Operation.js create mode 100644 core/table-system/dto/Table.js create mode 100644 core/table-system/dto/TableState.js create mode 100644 core/table-system/infra/persistence.js create mode 100644 core/table-system/infra/store.js create mode 100644 core/table-system/preset.js create mode 100644 core/table-system/rendering.js create mode 100644 core/table-system/templates.js create mode 100644 jsconfig.json create mode 100644 types/sillytavern.d.ts create mode 100644 utils/api-vendor.js diff --git a/51TODO.md b/51TODO.md new file mode 100644 index 0000000..4e11c48 --- /dev/null +++ b/51TODO.md @@ -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 A:Bus tool-call 升级 + +### A.1 ToolRegistry + +- 新文件 `SL/bus/tool/ToolRegistry.js` +- 内部 `Map>` +- 完全私有,不跨插件查询(每个模块自己用自己的工具集,不共享) + +### 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 抛错回喂 LLM,LLM 能自纠 +- [ ] maxSteps 截断行为正确 + +**预估**:1.5 天人时,风险中(agent loop 边界条件多)。 + +--- + +## 三、跨方向决策点 + +> 假后开工前先拍: + +1. **Phase A 与 TableTODO Phase 0 谁先**: + - 选项 A:先 Phase A(Bus 升级),再 Table Phase 0 + - 选项 B:先 Table Phase 0(解耦),再 Phase A + - 选项 C:并行两条分支 + - 倾向:B(Table Phase 0 不依赖 Bus,先把表格上帝模块拆了,后续 Phase A 也好用 ToolRegistry) + +2. **Phase A 是否必须 ship 才能开 Table Phase B**: + - 不必须。Phase B(JSON formatter)独立。Phase C(toolcall)才依赖 Phase A。 + +3. **是否合并发版**: + - 选项 A:Phase 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 月底前可全量上线。 diff --git a/DPS.drawio b/DPS.drawio new file mode 100644 index 0000000..dad0dff --- /dev/null +++ b/DPS.drawio @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/IAD.drawio b/IAD.drawio new file mode 100644 index 0000000..e0d8b87 --- /dev/null +++ b/IAD.drawio @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SL/bus/api/RequestBody.js b/SL/bus/api/RequestBody.js index 30ab167..eb18750 100644 --- a/SL/bus/api/RequestBody.js +++ b/SL/bus/api/RequestBody.js @@ -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; \ No newline at end of file +export default RequestBody; diff --git a/TODOList.md b/TODOList.md new file mode 100644 index 0000000..29b097f --- /dev/null +++ b/TODOList.md @@ -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.js),manager.js 退化为兼容层(仍保留 16 个 UI mutation + loadTables + updateTableFromText) +- **API 厂商识别**:[utils/api-vendor.js](utils/api-vendor.js) 提供 detectVendor / listVendorParams;registry 在 [assets/api-vendor-params.json](assets/api-vendor-params.json) +- **VS Code 类型校验**:[jsconfig.json](jsconfig.json) 已开启 checkJs,[types/sillytavern.d.ts](types/sillytavern.d.ts) 提供 SillyTavern 全局模块声明 + +--- + +## 二、待办任务 + +### 任务卡格式说明 + +每个任务包含: +- **类型**: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-007(Phase 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 版本条目补全 diff --git a/TableTODO.md b/TableTODO.md new file mode 100644 index 0000000..2d04a4b --- /dev/null +++ b/TableTODO.md @@ -0,0 +1,309 @@ +# TableTODO — 表格模块重构清单 + +> 创建于 2026-04-28。劳动节假后启动。 +> 主线:解耦 → 三模式填表(legacy / json / toolcall)。 +> 跨方向依赖(Bus tool-call 升级)见 [51TODO.md](51TODO.md) Phase A。 + +--- + +## 一、动机 + +现行表格填表让 LLM 输出 `insertRow(0, {0:"x",1:"y"})` 这种"四不像"自定义文本格式,由 [executor.js#parseFunctionCall](core/table-system/executor.js#L98) 自实现的 brace-depth + quote-state 状态机解析。高温下: +- 引号转义错乱、嵌套对象内逗号未转义 → 参数切错位 +- `data` 对象键写成无引号字段名 → 多层 JSON.parse fallback 仍可能失败 +- 一处 LLM 偷懒不输出 `` → 整批回滚重试 + +**目标**:把"格式契约"从 prompt 字符串约定改成 schema 约定,让 LLM 直接吐结构化数据,砍掉自实现解析器。同时保留 legacy 文本模式确保老用户行为不变。 + +| 模式 | 输出形态 | 解析复杂度 | 兼容性 | +|------|---------|-----------|--------| +| `legacy`(默认) | `insertRow(...)` 文本块 | 高(现行解析器) | 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 格式锁死(重构核心痛点) +`` 文本格式硬编码在 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(''))` + +### 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 : 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 个突变 export(addRow / 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 B:JSON 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 C:ToolCall formatter + +> 依赖 Phase 0 + 51TODO Phase A(Bus tool-call 升级)+ Phase B(B 已经把 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 模式下不需要 `` 教学,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.2,2.0.3 删除 + +--- + +## 八、不在范围内(明确不做) + +- 不重写 ui/table-bindings.js(UI 层独立演进) +- 不改持久化 schema(`message.extra.amily2_tables_data` 保持) +- 不改 SuperMemory 集成(继续走 Bus query + CustomEvent fallback) +- 不引入 TypeScript(DTS 注释为主) +- Phase 0 阶段不动 prompt 模板内容(只挪文件位置) + +--- + +## 九、入手顺序 + +1. Phase 0.3(operations.js)—— 影响面小,立刻能验证 executor 抽离不破坏 legacy +2. Phase 0.1 + 0.2(store + 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)。 diff --git a/assets/api-config-panel.html b/assets/api-config-panel.html index f13438d..a409540 100644 --- a/assets/api-config-panel.html +++ b/assets/api-config-panel.html @@ -83,6 +83,20 @@ + +
+ 旧配置清理 + + 旧版各模块独立的 API 配置(URL / Key / Model / 温度等)已自动迁移到上方"连接配置"。 + 若上方分配无误且使用一切正常,可点击下方按钮清除 extension_settings 中的旧字段 + 与 localStorage 中的旧 API Key,避免残留卡 bug。
+ 清理前会校验所有旧字段所属槽位都已分配 profile,未分配的槽位会阻止清理并提示。 +
+ +
+
@@ -109,22 +123,33 @@
- -
diff --git a/assets/api-vendor-params.json b/assets/api-vendor-params.json new file mode 100644 index 0000000..8dc5381 --- /dev/null +++ b/assets/api-vendor-params.json @@ -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", + "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", + "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", "desc": "停止序列。" }, + "provider": { + "type": "object", + "desc": "OR 路由配置:{ \"order\": [\"Anthropic\"], \"allow_fallbacks\": true, \"require_parameters\": false, \"data_collection\": \"deny\" }。" + }, + "transforms": { + "type": "array", + "desc": "[\"middle-out\"] 启用中间挤压防 context 超限。" + }, + "models": { + "type": "array", + "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", + "desc": "停止序列(数组形式)。" + }, + "safety_settings": { + "type": "array", + "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", "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", "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", "desc": "停止序列。" }, + "response_format": { "type": "object", "desc": "{ \"type\": \"json_object\" }。" }, + "repetition_penalty": { "type": "number", "desc": "本地模型常用,OpenAI 没有。" } + } + } +} diff --git a/core/api.js b/core/api.js index 9f5d5ef..db457fe 100644 --- a/core/api.js +++ b/core/api.js @@ -441,7 +441,7 @@ async function fetchSillyTavernPresetModels() { export async function getApiSettings(slot = 'main') { const s = extension_settings[extensionName] || {}; - // 优先读取槽位分配的 Profile(仅接管连接参数) + // 优先读取槽位分配的 Profile(profile 一旦分配即为权威,不再被主面板/模块独立设置压制) 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: '', }; } @@ -588,7 +588,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 +683,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 +705,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 +728,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 +828,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 +838,7 @@ async function callSillyTavernBackend(messages, options) { messages: messages, max_tokens: options.maxTokens, temperature: options.temperature, - stream: false + stream: false, }) }); diff --git a/core/api/ConcurrentApi.js b/core/api/ConcurrentApi.js index 8ea6c00..5c1b842 100644 --- a/core/api/ConcurrentApi.js +++ b/core/api/ConcurrentApi.js @@ -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(仅接管连接参数) + // 优先读取槽位分配的 Profile(profile 一旦分配即权威,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', diff --git a/core/api/JqyhApi.js b/core/api/JqyhApi.js index 5591bb7..21da5fb 100644 --- a/core/api/JqyhApi.js +++ b/core/api/JqyhApi.js @@ -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 { diff --git a/core/api/NccsApi.js b/core/api/NccsApi.js index 4230da6..7cc4b7f 100644 --- a/core/api/NccsApi.js +++ b/core/api/NccsApi.js @@ -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' 槽位分配的 Profile(profile 一旦分配即权威,旧 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); diff --git a/core/api/Ngms_api.js b/core/api/Ngms_api.js index 54ad624..b7ac242 100644 --- a/core/api/Ngms_api.js +++ b/core/api/Ngms_api.js @@ -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' 槽位分配的 Profile(profile 一旦分配即权威,旧 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 { diff --git a/core/api/SybdApi.js b/core/api/SybdApi.js index 377b0b3..d9dfd09 100644 --- a/core/api/SybdApi.js +++ b/core/api/SybdApi.js @@ -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' 槽位分配的 Profile(profile 一旦分配即权威,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 { diff --git a/core/auto-char-card/ui-bindings.js b/core/auto-char-card/ui-bindings.js index 078c55b..edf719d 100644 --- a/core/auto-char-card/ui-bindings.js +++ b/core/auto-char-card/ui-bindings.js @@ -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(); diff --git a/core/historiographer.js b/core/historiographer.js index 26b9372..d5934d7 100644 --- a/core/historiographer.js +++ b/core/historiographer.js @@ -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"; @@ -384,7 +384,9 @@ async function getSummary(formattedHistory, toastTitle, retryCount = 0) { } } - const summary = settings.ngmsEnabled ? await callNgmsAI(messages) : await callAI(messages); + // 历史总结统一走 NGMS slot;ngms 未配置时 callNgmsAI 自带模块名错误提示。 + // 旧 ngmsEnabled 三元式 fallback 到 main 的设计已在主 API 移除后失效。 + const summary = await callNgmsAI(messages); console.log('[大史官-微言录] AI回复的全部内容:', summary); if (!summary || !summary.trim()) { @@ -603,7 +605,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 slot;ngms 未配置时 callNgmsAI 自带错误提示。 + const content = await callNgmsAI(messages); if (!content || !content.trim()) { const maxRetries = settings.historiographyMaxRetries ?? 2; diff --git a/core/rag-api.js b/core/rag-api.js index dcdef9e..dcb1165 100644 --- a/core/rag-api.js +++ b/core/rag-api.js @@ -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, diff --git a/core/table-system/actions/applyOperations.js b/core/table-system/actions/applyOperations.js new file mode 100644 index 0000000..5914abc --- /dev/null +++ b/core/table-system/actions/applyOperations.js @@ -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} 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} 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 { 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 }; +} diff --git a/core/table-system/batch-filler.js b/core/table-system/batch-filler.js index 63c73a5..fd21827 100644 --- a/core/table-system/batch-filler.js +++ b/core/table-system/batch-filler.js @@ -125,8 +125,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返回内容为空。'); } diff --git a/core/table-system/dto/Change.js b/core/table-system/dto/Change.js new file mode 100644 index 0000000..6cbee0f --- /dev/null +++ b/core/table-system/dto/Change.js @@ -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 {}; diff --git a/core/table-system/dto/Operation.js b/core/table-system/dto/Operation.js new file mode 100644 index 0000000..7e211e0 --- /dev/null +++ b/core/table-system/dto/Operation.js @@ -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} data { [colIndex]: cellValue } + * + * @typedef {Object} UpdateRowOperation + * @property {'updateRow'} op + * @property {number} tableIndex + * @property {number} rowIndex + * @property {Object} data + * + * @typedef {Object} DeleteRowOperation + * @property {'deleteRow'} op + * @property {number} tableIndex + * @property {number} rowIndex + * + * @typedef {InsertRowOperation | UpdateRowOperation | DeleteRowOperation} Operation + */ + +export {}; diff --git a/core/table-system/dto/Table.js b/core/table-system/dto/Table.js new file mode 100644 index 0000000..f237589 --- /dev/null +++ b/core/table-system/dto/Table.js @@ -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} [charLimitRules] 多列字符限制:{ "colIndexStr": maxChars } + * @property {number} [rowLimitRule] 行数上限,0 表示不限 + * @property {number} [simplifyRowThreshold] 历史行简化阈值,0 表示不简化 + */ + +/** + * 表格集合 = 全局状态。 + * @typedef {Table[]} TableState + */ + +export {}; diff --git a/core/table-system/dto/TableState.js b/core/table-system/dto/TableState.js new file mode 100644 index 0000000..86b92b7 --- /dev/null +++ b/core/table-system/dto/TableState.js @@ -0,0 +1,9 @@ +/** + * @file TableState 的实际定义已合并至 ./Table.js(与 Table 共处一处便于阅读)。 + * 本文件保留为转发别名,供需要按 dto 名称单独导入的消费方使用: + * /** @typedef {import('./TableState.js').TableState} TableState *\/ + * + * @typedef {import('./Table.js').TableState} TableState + */ + +export {}; diff --git a/core/table-system/executor.js b/core/table-system/executor.js index 5103f57..184af9c 100644 --- a/core/table-system/executor.js +++ b/core/table-system/executor.js @@ -1,100 +1,31 @@ +/** + * @file 旧版 文本格式的解析器 + 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[]。 + * 不在文本中找到 块时返回空数组(不视为错误)。 + * + * @param {string} aiResponseText + * @returns {Operation[]} + */ +export function parseToOperations(aiResponseText) { const commandBlockRegex = /([\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 }; } diff --git a/core/table-system/infra/persistence.js b/core/table-system/infra/persistence.js new file mode 100644 index 0000000..b9fdf33 --- /dev/null +++ b/core/table-system/infra/persistence.js @@ -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} + */ +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; +} diff --git a/core/table-system/infra/store.js b/core/table-system/infra/store.js new file mode 100644 index 0000000..2f37d88 --- /dev/null +++ b/core/table-system/infra/store.js @@ -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} 形如 "tableIndex-rowIndex-colIndex" */ +const _highlights = new Set(); + +/** @type {Set} 标记本周期内被改过的表格索引 */ +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} + */ +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} + */ +export function getUpdatedTables() { + return _updatedTables; +} + +export function clearUpdatedTables() { + if (_updatedTables.size > 0) { + _updatedTables.clear(); + log('已清除所有表格的更新标记。', 'info'); + } +} diff --git a/core/table-system/manager.js b/core/table-system/manager.js index 58bf957..8ac3263 100644 --- a/core/table-system/manager.js +++ b/core/table-system/manager.js @@ -1,95 +1,161 @@ +/** + * @file manager.js —— Phase 0 重构后承担"剩余的编排层"。 + * + * 大部分功能已迁出: + * - 状态:infra/store.js (currentTablesState / highlights / updatedTables) + * - 持久化:infra/persistence.js (saveStateToMessage / commitToLastMessage) + * - 推演:actions/applyOperations.js (executor.js 改造为 legacy formatter) + * - 渲染:rendering.js (3 个 toCsv) + * - 模板:templates.js + * - 预设:preset.js + * + * 本文件保留: + * - 默认表格模板 + getDefaultTables + * - SuperMemory 事件分发(dispatchTableUpdate / dispatchAllTablesUpdate / triggerSync) + * - loadTables 的多档回退逻辑 + * - 16 个 UI 突变(addRow / addColumn / ... / clearAllTables) + * - updateTableFromText 编排 + * - rollbackState / rollbackAndRefill + * + * 所有原先 export 的接口一律保留兼容(移走的统一 re-export),调用方零改动。 + * + * @typedef {import('./dto/Table.js').TableState} TableState + */ + import { getContext, extension_settings } from '/scripts/extensions.js'; -import { saveChat, saveSettingsDebounced } from '/script.js'; +import { saveChat } from '/script.js'; +import { saveChatDebounced } from '../../utils/utils.js'; +import { extensionName } from '../../utils/settings.js'; + import { log } from './logger.js'; import { executeCommands } from './executor.js'; import { fillWithSecondaryApi } from './secondary-filler.js'; -import { getChatPiece, saveChatDebounced } from '../../utils/utils.js'; -import { extensionName } from '../../utils/settings.js'; -import { DEFAULT_AI_RULE_TEMPLATE, DEFAULT_AI_FLOW_TEMPLATE } from './settings.js'; import { renderTables } from '../../ui/table-bindings.js'; import { updateOrInsertTableInChat } from '../../ui/message-table-renderer.js'; -import { TABLE_UPDATED_EVENT, createTableUpdateEvent, inferTableRole } from './events-schema.js'; +import { createTableUpdateEvent, inferTableRole } from './events-schema.js'; -const TABLE_DATA_KEY = 'amily2_tables_data'; // Key for multiple tables +// ── 新模块(IAD 拆分后的依赖) ──────────────────────────────────────────── +import { + getState, + setState, + addHighlight as _storeAddHighlight, + getHighlights as _storeGetHighlights, + clearHighlights as _storeClearHighlights, + markTableUpdated, + getUpdatedTables as _storeGetUpdatedTables, + clearUpdatedTables as _storeClearUpdatedTables, +} from './infra/store.js'; -// 用于在内存中缓存当前表格状态数组 -let currentTablesState = null; -// 用于记录需要高亮的单元格 -let highlightedCells = new Set(); -// 用于记录被更新过的表格 -let updatedTables = new Set(); +import { + saveStateToMessage as _persistSaveStateToMessage, + commitToLastMessage, + TABLE_DATA_KEY, +} from './infra/persistence.js'; +import { + tablesToCsv, + tablesToCsvWithSelection, + tablesToCsvContentOnly, +} from './rendering.js'; + +import { + getBatchFillerRuleTemplate as _tplGetBatchFillerRuleTemplate, + saveBatchFillerRuleTemplate as _tplSaveBatchFillerRuleTemplate, + getBatchFillerFlowTemplate as _tplGetBatchFillerFlowTemplate, + saveBatchFillerFlowTemplate as _tplSaveBatchFillerFlowTemplate, + getAiFlowTemplateForInjection as _tplGetAiFlowTemplateForInjection, + saveAiTemplate as _tplSaveAiTemplate, + getAiTemplate as _tplGetAiTemplate, +} from './templates.js'; + +import { + exportPreset as _presetExportPreset, + exportPresetFull as _presetExportPresetFull, + importPreset as _presetImportPreset, + clearGlobalPreset as _presetClearGlobalPreset, + importGlobalPreset as _presetImportGlobalPreset, +} from './preset.js'; + +// ── 私有:SuperMemory 事件分发 ──────────────────────────────────────────── + +/** + * 把单个表格的最新状态推送给 SuperMemory(优先 Bus 直调,降级 CustomEvent)。 + * @param {number} tableIndex + */ function dispatchTableUpdate(tableIndex) { - // 检查 Super Memory 功能开关 const settings = extension_settings[extensionName] || {}; if (settings.super_memory_enabled === false) return; - if (!currentTablesState || !currentTablesState[tableIndex]) return; - const table = currentTablesState[tableIndex]; + const state = getState(); + if (!state || !state[tableIndex]) return; + const table = state[tableIndex]; const role = inferTableRole(table.name); - // 优先走 Bus 直调(避免 DOM 事件广播) const smBus = window.Amily2Bus?.query('SuperMemory'); if (smBus?.pushUpdate) { - smBus.pushUpdate({ tableName: table.name, data: table.rows, headers: table.headers, rowStatuses: table.rowStatuses ?? [], role }); + smBus.pushUpdate({ + tableName: table.name, + data: table.rows, + headers: table.headers, + rowStatuses: table.rowStatuses ?? [], + role, + }); } else { - // 降级:CustomEvent(Bus 未就绪时的初始化阶段) document.dispatchEvent(createTableUpdateEvent(table)); } log(`[SuperMemory] Dispatched update for ${table.name} (role: ${role})`, 'info'); } +/** + * 触发所有表格的全量同步(Pipeline 变更后调用)。 + */ function dispatchAllTablesUpdate() { - if (!currentTablesState) return; + const state = getState(); + if (!state) return; log('[SuperMemory] Dispatching update events for ALL tables...', 'info'); - currentTablesState.forEach((_, index) => { - dispatchTableUpdate(index); - }); + state.forEach((_, index) => dispatchTableUpdate(index)); } /** - * 主动触发所有表格同步到 SuperMemory(Pipeline 变更后调用)。 - * 确保同步的是当前最新的 currentTablesState,而非 loadTables() 时的旧状态。 + * 主动触发所有表格同步到 SuperMemory(外部调用入口)。 */ export function triggerSync() { dispatchAllTablesUpdate(); } +// ── 状态访问(store 包装层) ────────────────────────────────────────────── + export function addHighlight(tableIndex, rowIndex, colIndex) { - const key = `${tableIndex}-${rowIndex}-${colIndex}`; - highlightedCells.add(key); + _storeAddHighlight(tableIndex, rowIndex, colIndex); } export function getHighlights() { - return highlightedCells; + return _storeGetHighlights(); } export function clearHighlights() { - if (highlightedCells.size > 0) { - highlightedCells.clear(); - log('已清除所有单元格高亮标记。', 'info'); - } + _storeClearHighlights(); } export function getUpdatedTables() { - return updatedTables; + return _storeGetUpdatedTables(); } export function clearUpdatedTables() { - if (updatedTables.size > 0) { - updatedTables.clear(); - log('已清除所有表格的更新标记。', 'info'); - } + _storeClearUpdatedTables(); } export function setMemoryState(newState) { - currentTablesState = newState; + setState(newState); +} + +export function getMemoryState() { + return getState(); } export function loadMemoryState(state) { if (!state) return; - setMemoryState(state); - // 触发 UI 刷新 + setState(state); renderTables(); updateOrInsertTableInChat(); log('[SuperMemory] 已从元数据恢复内存状态并刷新 UI。', 'info'); @@ -99,32 +165,25 @@ export function saveMemoryState() { const context = getContext(); if (context.chat && context.chat.length > 0) { const lastMessage = context.chat[context.chat.length - 1]; - if (saveStateToMessage(currentTablesState, lastMessage)) { - // 注意:这里不强制 saveChat(),以免过于频繁 - // 但如果是在生成结束后调用,应该 saveChat - // 目前主要依靠 SuperMemory 的 saveStateToMetadata 调用 saveChat + if (_persistSaveStateToMessage(getState(), lastMessage)) { + // 不在此处强制 saveChat,避免高频;调用方决定时机 return true; } } return false; } -export function getMemoryState() { - return currentTablesState; +export function saveStateToMessage(stateToSave, targetMessage) { + return _persistSaveStateToMessage(stateToSave, targetMessage); } -// 预设模板内容 (直接嵌入,避免异步文件读取的复杂性) +// ── 默认模板 ────────────────────────────────────────────────────────────── + const defaultTemplate = { "tables": [ { "name": "时空栏", - "headers": [ - "日期", - "时段", - "时间", - "地点", - "此地角色" - ], + "headers": ["日期", "时段", "时间", "地点", "此地角色"], "note": "【核心作用】此表格用于精确追踪故事发生的即时时空背景,确保时间与空间的连续性。它应该始终只包含一行,代表当前的“镜头”位置。\n【字段详解】\n- 日期: 格式为'YYYY-MM-DD'。若日期未知,请根据上下文合理推断或设定一个初始日期,如'大夏3年-9月-10日'。\n- 时段: 严格遵循规定(凌晨:0-5时;早晨:5-8时;上午:8-11时;中午:11-13时;下午:13-16时;傍晚:16-19时;晚上:19-24时)。\n- 时间: 格式为'HH:MM'。若时间未知,可根据时段估算,如'08:30'。\n- 地点: 描述当前场景发生的具体位置,应尽可能精确,例如'XX街的咖啡馆'而非'城里'。\n- 此地角色: 列出当前场景中所有在场且参与互动的主要角色,用'/'分隔。", "rule_add": "【触发条件】当故事开始,且此表格为空时,必须立即根据初始场景创建第一行。", "rule_delete": "【触发条件】任何时候,如果此表格的行数超过一行,必须删除旧的行,只保留最新、最准确的一行。", @@ -135,37 +194,18 @@ const defaultTemplate = { }, { "name": "角色栏", - "headers": [ - "角色名", - "外貌", - "身形", - "衣着", - "性格", - "身份", - "职业", - "与关系", - "爱好", - "住所", - "其他重要信息" - ], + "headers": ["角色名", "外貌", "身形", "衣着", "性格", "身份", "职业", "与关系", "爱好", "住所", "其他重要信息"], "note": "【核心作用】此表格是角色关系和状态的核心数据库,用于记录所有在故事中出现的重要角色的详细信息。\n【字段详解】\n- 角色名: 角色的唯一标识。\n- 外貌: 描述五官、发型、发色、肤色等面部特征。\n- 身形: 描述身高、体型、肌肉状况、特殊身体标记(如伤疤)等。\n- 衣着: 描述角色当前或标志性的穿着,包括服装、配饰等。\n- 性格: 概括角色的核心性格特质,使用1-3个关键词,如'勇敢/鲁莽/忠诚'。\n- 身份: 角色的社会背景或出身,如'贵族后裔'、'流浪者'。\n- 职业: 角色赖以谋生的工作或职责,如'佣兵'、'学者'。\n- 与关系: 描述该角色与主角之间的社会或情感关系,如'盟友'、'导师'、'敌人'。\n- 爱好: 角色的兴趣和消遣活动。\n- 住所: 角色的常住地。\n- 其他重要信息: 记录任何不属于以上类别但对角色至关重要的信息,如特殊能力、过去的经历等。", "rule_add": "【触发条件】当一个有名有姓的角色首次出现,并与或当前剧情发生有意义的互动时,必须为其创建新的一行。", "rule_delete": "【触发条件】当一个角色被确认永久性死亡(非假死或失踪),且其存在不再对后续剧情有直接影响时,可以删除该行。", "rule_update": "【触发条件】当角色的任何信息发生持久性或关键性变化时,必须更新对应单元格。例如:\n1. 外貌/身形/衣着发生永久性改变(如断肢、换上新装备)。\n2. 性格因重大事件而扭转。\n3. 身份或职业发生变更(如继承王位、被解雇)。\n4. 与的关系发生根本性转变(如从敌人变为盟友)。", - "charLimitRules": { - "10": 30 - }, + "charLimitRules": { "10": 30 }, "rowLimitRule": 0, "rows": [] }, { "name": "关系栏", - "headers": [ - "主动方", - "被动方", - "关系", - "详情" - ], + "headers": ["主动方", "被动方", "关系", "详情"], "columnWidths": [], "note": "【核心作用】专门用于记录除主角以外的角色之间的复杂人际关系网(NPC to NPC)。\n【字段详解】\n- 主动方: 关系的发起者或主体(例如'艾克')。\n- 被动方: 关系的接收者或对象(例如'莉娜')。\n- 关系: 用简短的词汇描述两者之间的关系本质,如'暗恋'、'世仇'、'师徒'。\n- 详情: 对这段关系的具体描述或背景补充。", "rule_add": "【触发条件】当两个NPC之间展现出明确的、非临时性的人际关系时,应添加新行。", @@ -175,19 +215,10 @@ const defaultTemplate = { "rowLimitRule": 0, "rows": [], "rowStatuses": [] - }, + }, { "name": "任务栏", - "headers": [ - "任务名", - "类型", - "详情", - "状态", - "执行者", - "地点", - "开始时间/结束时间", - "结果" - ], + "headers": ["任务名", "类型", "详情", "状态", "执行者", "地点", "开始时间/结束时间", "结果"], "note": "【核心作用】追踪故事中的主要情节线、目标和挑战。只记录对剧情发展有重大影响的“任务”,忽略日常琐事。\n【字段详解】\n- 任务名: 任务的简洁概括,如'寻找失落的神器'。\n- 类型: 任务的分类,如'主线'、'支线'、'个人'、'约定'。\n- 详情: 对任务目标和背景的简要描述。\n- 状态: 任务的当前进展,如'未开始'、'进行中'、'已完成'、'已失败'、'已取消'。\n- 执行者: 负责完成此任务的角色名。\n- 地点: 任务关键环节发生的地点。\n- 开始时间/结束时间: 记录任务的起止时间,格式'YYYY-MM-DD',若未结束则结束时间留空。\n- 结果: 任务完成或失败后的最终结果。", "rule_add": "【触发条件】当以下情况发生时,应添加新行:\n1. 角色接下一个明确的、有目标的委托或命令。\n2. 角色们达成一个具体的、需要在未来执行的约定。\n3. 角色为自己设定一个长期的、关键性的目标。", "rule_delete": "【触发条件】当任务列表超过10行时,优先删除最早的、已经“已完成”且与当前剧情关联度最低的任务。如果存在内容完全重复的任务,应删除。", @@ -198,14 +229,7 @@ const defaultTemplate = { }, { "name": "物品栏", - "headers": [ - "物品名", - "类型", - "详情", - "状态", - "拥有者", - "重要原因" - ], + "headers": ["物品名", "类型", "详情", "状态", "拥有者", "重要原因"], "note": "【核心作用】记录那些在故事中具有特殊功能、背景或情感价值的关键物品。普通物品不应记录。\n【字段详解】\n- 物品名: 物品的名称。\n- 类型: 物品的分类,如'武器'、'道具'、'信物'、'关键物品'。\n- 详情: 描述物品的外观、材质和已知功能。\n- 状态: 物品的当前状况,如'完好'、'破损'、'能量耗尽'。\n- 拥有者: 当前持有该物品的角色名。\n- 重要原因: 解释该物品为何重要,例如'是解开谜题的钥匙'或'是母亲的遗物'。", "rule_add": "【触发条件】当一个物品被明确赋予了特殊意义(如被赠予、在关键事件中扮演重要角色)或展示出独特功能时,应为其创建条目。", "rule_delete": "【触发条件】当一个物品被彻底摧毁、消耗完毕或永久失去其特殊意义时,可以删除。", @@ -216,10 +240,7 @@ const defaultTemplate = { }, { "name": "技能栏", - "headers": [ - "技能名", - "技能效果" - ], + "headers": ["技能名", "技能效果"], "note": "【核心作用】专门用于记录主角掌握的各种技能、魔法、被动能力或特殊专长。\n【字段详解】\n- 技能名: 技能的正式名称。\n- 技能效果: 清晰、简洁地描述该技能使用时产生的具体效果、消耗和限制条件。", "rule_add": "【触发条件】当在故事中首次成功施展或习得一个全新的、表格中未记录的技能时,必须添加。", "rule_delete": "【触发条件】如果发现表格中存在两个描述完全相同的重复技能,应删除其中一个。如果记录了非的技能,应立即删除。", @@ -230,10 +251,7 @@ const defaultTemplate = { }, { "name": "设定栏", - "headers": [ - "类型", - "具体描述" - ], + "headers": ["类型", "具体描述"], "note": "【核心作用】此表格记录了来自的、超越故事本身的“元指令”或世界观设定,拥有最高解释权。内容应被严格遵守,禁止AI自行修改。\n【字段详解】\n- 类型: 指令的分类,如'世界观设定'、'剧情走向要求'、'角色行为禁令'。\n- 具体描述: 完整、准确地记录提出的具体要求。", "rule_add": "【触发条件】当通过括号、旁白或其他明确的“第四面墙”方式,提出关于故事背景、规则或未来走向的指令时,必须记录于此。", "rule_delete": "【触发条件】只能在明确表示要移除或废弃某条设定时,才能删除对应行。", @@ -241,140 +259,111 @@ const defaultTemplate = { "charLimitRules": {}, "rowLimitRule": 0, "rows": [] - } - ] + } + ] }; - function getDefaultTables() { log('从预设模板生成默认表格...', 'info'); - // 直接深拷贝预设中的表格数组 const tables = JSON.parse(JSON.stringify(defaultTemplate.tables)); tables.forEach(table => { table.charLimitRule = { columnIndex: -1, limit: 0 }; table.rowLimitRule = 0; table.columnWidths = []; - - }); return tables; } +// ── 加载 ────────────────────────────────────────────────────────────────── + export function loadTables(stopIndex = -1) { const context = getContext(); - // 1. 检查聊天记录中是否已有表格数据 + + // 1. 优先从聊天记录中找已存的状态 if (context && context.chat && context.chat.length > 0) { const startIndex = (stopIndex === -1 ? context.chat.length - 1 : stopIndex - 1); for (let i = startIndex; i >= 0; i--) { const message = context.chat[i]; if (message.extra && message.extra[TABLE_DATA_KEY]) { log(`在第 ${i} 条消息中找到基准表格数据。`, 'info'); - // 加载状态时,必须完全信任并还原消息中存储的状态, - // 不应与当前内存状态进行任何合并,否则会导致回退时状态不一致。 let loadedState = JSON.parse(JSON.stringify(message.extra[TABLE_DATA_KEY])); - + loadedState.forEach(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 = '允许'; - - // **【多列规则兼容性改造】** + + // 多列规则兼容 if (table.charLimitRule && !table.charLimitRules) { table.charLimitRules = {}; if (table.charLimitRule.columnIndex !== -1 && table.charLimitRule.limit > 0) { table.charLimitRules[table.charLimitRule.columnIndex] = table.charLimitRule.limit; } } - // 删除旧字段,确保数据结构统一 delete table.charLimitRule; if (table.rowLimitRule === undefined) table.rowLimitRule = 0; if (table.columnWidths === undefined) table.columnWidths = []; - - // 【延迟删除】如果旧数据没有状态数组,则初始化一个 + if (!table.rowStatuses) { table.rowStatuses = Array(table.rows.length).fill('normal'); } }); - currentTablesState = loadedState; - // 【Amily2-SuperMemory】状态加载后,触发全量同步 + setState(loadedState); dispatchAllTablesUpdate(); - return currentTablesState; + return getState(); } } } - // 2. 如果聊天记录中没有数据(新聊天),则尝试加载全局预设 + // 2. 全局预设 if (extension_settings[extensionName]?.global_table_preset) { log('未在聊天记录中找到表格,正在加载全局预设...', 'info'); try { const globalPreset = extension_settings[extensionName].global_table_preset; - currentTablesState = JSON.parse(JSON.stringify(globalPreset.tables)); - - // 【V140.9 Bug修复】当加载全局预设时,也一并加载其中包含的指令模板 + setState(JSON.parse(JSON.stringify(globalPreset.tables))); + if (globalPreset.batchFillerRuleTemplate !== undefined) { - saveBatchFillerRuleTemplate(globalPreset.batchFillerRuleTemplate); + _tplSaveBatchFillerRuleTemplate(globalPreset.batchFillerRuleTemplate); } if (globalPreset.batchFillerFlowTemplate !== undefined) { - saveBatchFillerFlowTemplate(globalPreset.batchFillerFlowTemplate); + _tplSaveBatchFillerFlowTemplate(globalPreset.batchFillerFlowTemplate); } - // 【Amily2-SuperMemory】加载全局预设后,触发全量同步 dispatchAllTablesUpdate(); - return currentTablesState; + return getState(); } catch (error) { log(`加载全局预设失败: ${error.message}`, 'error'); } } - // 3. 如果全局预设也不存在或加载失败,则使用默认模板 + // 3. 默认模板 log('未找到任何表格数据或全局预设,使用默认模板。', 'info'); - currentTablesState = getDefaultTables(); - // 【Amily2-SuperMemory】加载默认模板后,触发全量同步 + setState(getDefaultTables()); dispatchAllTablesUpdate(); - return currentTablesState; -} - -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; + return getState(); } export function saveTables(sourceAction = '未知操作') { log(`UI操作 "${sourceAction}" 已更新内存状态。`, 'info'); - // 不再直接调用 saveChatDebounced(),交由上层处理 return true; } +// ── 16 个 UI 突变 ───────────────────────────────────────────────────────── + export function deleteColumn(tableIndex, colIndex) { - const tables = getMemoryState(); - if (!tables[tableIndex] || colIndex < 0 || colIndex >= tables[tableIndex].headers.length) { + const tables = getState(); + if (!tables || !tables[tableIndex] || colIndex < 0 || colIndex >= tables[tableIndex].headers.length) { log(`删除列失败:在表格 ${tableIndex} 中找不到索引为 ${colIndex} 的列。`, 'error'); return; } - // 删除表头 tables[tableIndex].headers.splice(colIndex, 1); - - // 删除每一行中对应的单元格 tables[tableIndex].rows.forEach(row => { - if (row.length > colIndex) { - row.splice(colIndex, 1); - } + if (row.length > colIndex) row.splice(colIndex, 1); }); - if (tables[tableIndex].columnWidths && tables[tableIndex].columnWidths.length > colIndex) { tables[tableIndex].columnWidths.splice(colIndex, 1); } @@ -385,8 +374,8 @@ export function deleteColumn(tableIndex, colIndex) { } export function moveRow(tableIndex, rowIndex, direction) { - const tables = getMemoryState(); - const table = tables[tableIndex]; + const tables = getState(); + const table = tables?.[tableIndex]; if (!table || rowIndex < 0 || rowIndex >= table.rows.length) return; const newIndex = direction === 'up' ? rowIndex - 1 : rowIndex + 1; @@ -395,7 +384,6 @@ export function moveRow(tableIndex, rowIndex, direction) { const [movedRow] = table.rows.splice(rowIndex, 1); table.rows.splice(newIndex, 0, movedRow); - // 【延迟删除】同步移动状态 if (table.rowStatuses && table.rowStatuses.length === table.rows.length + 1) { const [movedStatus] = table.rowStatuses.splice(rowIndex, 1); table.rowStatuses.splice(newIndex, 0, movedStatus); @@ -407,199 +395,146 @@ export function moveRow(tableIndex, rowIndex, direction) { } export function insertRow(tableIndex, data, position = 'below') { - const tables = getMemoryState(); - const table = tables[tableIndex]; + const tables = getState(); + const table = tables?.[tableIndex]; if (!table) { log(`插入行失败:找不到索引为 ${tableIndex} 的表格。`, 'error'); return; } - // 将 insertIndex 的计算提前,解决“暂时性死区”问题 let insertIndex; if (typeof data === 'number') { insertIndex = position === 'above' ? data : data + 1; } else { - insertIndex = table.rows.length; // 默认在末尾插入 + insertIndex = table.rows.length; } - // 确保索引在有效范围内 if (insertIndex < 0) insertIndex = 0; if (insertIndex > table.rows.length) insertIndex = table.rows.length; const newRow = new Array(table.headers.length).fill(''); - - // 如果 data 是一个对象,则用它来填充新行 + if (typeof data === 'object' && data !== null) { for (const colIndex in data) { const cIndex = parseInt(colIndex, 10); if (!isNaN(cIndex) && cIndex < newRow.length) { newRow[cIndex] = data[colIndex]; - // 现在 insertIndex 已经有值了 addHighlight(tableIndex, insertIndex, cIndex); } } } table.rows.splice(insertIndex, 0, newRow); - // 【延迟删除】同步更新状态数组 if (!table.rowStatuses) table.rowStatuses = Array(table.rows.length).fill('normal'); table.rowStatuses.splice(insertIndex, 0, 'normal'); - updatedTables.add(tableIndex); // 【V15.2 新增】标记表格为已更新 + markTableUpdated(tableIndex); dispatchTableUpdate(tableIndex); log(`成功在表格 ${table.name} (索引 ${tableIndex}) 的第 ${insertIndex + 1} 行位置插入了新行。`, 'success'); - - // 强制立即保存 - const context = getContext(); - if (context.chat && context.chat.length > 0) { - const lastMessage = context.chat[context.chat.length - 1]; - if (saveStateToMessage(tables, lastMessage)) { - saveChat(); - return; - } - } - saveChatDebounced(); // Fallback + + commitToLastMessage(tables); } - export function addRow(tableIndex) { - if (!currentTablesState || !currentTablesState[tableIndex]) return; - const table = currentTablesState[tableIndex]; + const tables = getState(); + if (!tables || !tables[tableIndex]) return; + const table = tables[tableIndex]; const colCount = table.headers.length; const newRow = Array(colCount).fill(''); table.rows.push(newRow); - // 【延迟删除】同步更新状态数组 if (!table.rowStatuses) table.rowStatuses = Array(table.rows.length).fill('normal'); table.rowStatuses.push('normal'); - updatedTables.add(tableIndex); // 【V15.2 新增】标记表格为已更新 + markTableUpdated(tableIndex); dispatchTableUpdate(tableIndex); - const logMessage = `表格 [${table.name}] 新增了一行。`; - log(logMessage, 'info'); + log(`表格 [${table.name}] 新增了一行。`, 'info'); - // 强制立即保存 - 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(); // Fallback + commitToLastMessage(tables); } export function addColumn(tableIndex) { - if (!currentTablesState || !currentTablesState[tableIndex]) return; - const table = currentTablesState[tableIndex]; + const tables = getState(); + if (!tables || !tables[tableIndex]) return; + const table = tables[tableIndex]; const newHeader = `新列 ${table.headers.length + 1}`; table.headers.push(newHeader); table.rows.forEach(row => row.push('')); if (!table.columnWidths) table.columnWidths = []; table.columnWidths.push(null); - const logMessage = `表格 [${table.name}] 新增了一列。`; - log(logMessage, 'info'); + log(`表格 [${table.name}] 新增了一列。`, 'info'); - // 强制立即保存 - 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(); // Fallback + commitToLastMessage(tables); } export function updateHeader(tableIndex, colIndex, value) { - if (!currentTablesState || !currentTablesState[tableIndex] || currentTablesState[tableIndex].headers[colIndex] === undefined) { - return; - } - const tableName = currentTablesState[tableIndex].name; - const originalHeader = currentTablesState[tableIndex].headers[colIndex]; - currentTablesState[tableIndex].headers[colIndex] = value; - const logMessage = `表格 [${tableName}] 的表头“${originalHeader}”已更新为“${value}”。`; - log(logMessage, 'info'); + const tables = getState(); + if (!tables || !tables[tableIndex] || tables[tableIndex].headers[colIndex] === undefined) return; + const tableName = tables[tableIndex].name; + const originalHeader = tables[tableIndex].headers[colIndex]; + tables[tableIndex].headers[colIndex] = value; + log(`表格 [${tableName}] 的表头“${originalHeader}”已更新为“${value}”。`, 'info'); - // 强制立即保存 - 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(); // Fallback + commitToLastMessage(tables); } export async function deleteRow(tableIndex, rowIndex) { - const table = currentTablesState?.[tableIndex]; + const tables = getState(); + const table = tables?.[tableIndex]; if (!table || !table.rows[rowIndex]) return; - // 兼容性检查 if (!table.rowStatuses) { table.rowStatuses = Array(table.rows.length).fill('normal'); } table.rowStatuses[rowIndex] = 'pending-deletion'; - updatedTables.add(tableIndex); // 【V15.2 新增】标记表格为已更新 - const logMessage = `表格 [${table.name}] 的第 ${rowIndex + 1} 行已标记为待删除。`; - log(logMessage, 'info'); + markTableUpdated(tableIndex); + log(`表格 [${table.name}] 的第 ${rowIndex + 1} 行已标记为待删除。`, 'info'); - // 立即保存状态并触发UI重绘 const context = getContext(); if (context.chat?.length > 0) { const lastMessage = context.chat[context.chat.length - 1]; - if (saveStateToMessage(currentTablesState, lastMessage)) { + if (_persistSaveStateToMessage(tables, lastMessage)) { await saveChat(); - renderTables(); // 重新渲染以显示更改 - - // 【SuperMemory 联动】触发更新事件 + renderTables(); dispatchTableUpdate(tableIndex); - return; } } await saveChatDebounced(); renderTables(); - // 【SuperMemory 联动】触发更新事件 dispatchTableUpdate(tableIndex); } export async function restoreRow(tableIndex, rowIndex) { - const table = currentTablesState?.[tableIndex]; + const tables = getState(); + const table = tables?.[tableIndex]; if (!table || !table.rows[rowIndex] || !table.rowStatuses) return; table.rowStatuses[rowIndex] = 'normal'; - updatedTables.add(tableIndex); // 【V15.2 新增】标记表格为已更新 - const logMessage = `表格 [${table.name}] 的第 ${rowIndex + 1} 行已恢复。`; - log(logMessage, 'info'); + markTableUpdated(tableIndex); + log(`表格 [${table.name}] 的第 ${rowIndex + 1} 行已恢复。`, 'info'); const context = getContext(); if (context.chat?.length > 0) { const lastMessage = context.chat[context.chat.length - 1]; - if (saveStateToMessage(currentTablesState, lastMessage)) { + if (_persistSaveStateToMessage(tables, lastMessage)) { await saveChat(); - renderTables(); // 重新渲染以显示更改 - // 【SuperMemory 联动】触发更新事件 (修复恢复行后世界书不更新的 Bug) + renderTables(); dispatchTableUpdate(tableIndex); return; } } await saveChatDebounced(); renderTables(); - // 【SuperMemory 联动】触发更新事件 dispatchTableUpdate(tableIndex); } export function commitPendingDeletions() { - if (!currentTablesState) return false; + const tables = getState(); + if (!tables) return false; let deletionCount = 0; - currentTablesState.forEach((table, tableIndex) => { + tables.forEach((table, tableIndex) => { if (!table.rowStatuses || table.rowStatuses.length === 0) return; let tableHadDeletions = false; - // 必须从后向前遍历,以避免在 splice 期间破坏索引 for (let i = table.rows.length - 1; i >= 0; i--) { if (table.rowStatuses[i] === 'pending-deletion') { table.rows.splice(i, 1); @@ -608,75 +543,51 @@ export function commitPendingDeletions() { tableHadDeletions = true; } } - if (tableHadDeletions) { - updatedTables.add(tableIndex); // 【V15.2 新增】标记表格为已更新 - } + if (tableHadDeletions) markTableUpdated(tableIndex); }); if (deletionCount > 0) { log(`已提交并永久删除了 ${deletionCount} 行。`, 'info'); - - // 【SuperMemory 联动】为所有受影响的表格触发更新事件 - if (updatedTables.size > 0) { - updatedTables.forEach(tableIndex => { - dispatchTableUpdate(tableIndex); - }); + const updated = _storeGetUpdatedTables(); + if (updated.size > 0) { + updated.forEach(tableIndex => dispatchTableUpdate(tableIndex)); } - - return true; // 表示状态已更改,需要保存 + return true; } - return false; // 表示没有更改 + return false; } - export function insertColumn(tableIndex, colIndex, position) { - if (!currentTablesState || !currentTablesState[tableIndex]) return; - const table = currentTablesState[tableIndex]; - - const insertAt = position === 'left' ? colIndex : colIndex + 1; - const newHeader = `新列`; + const tables = getState(); + if (!tables || !tables[tableIndex]) return; + const table = tables[tableIndex]; - // 插入表头 - table.headers.splice(insertAt, 0, newHeader); - // 在每一行中插入空单元格 + const insertAt = position === 'left' ? colIndex : colIndex + 1; + table.headers.splice(insertAt, 0, '新列'); table.rows.forEach(row => row.splice(insertAt, 0, '')); if (!table.columnWidths) table.columnWidths = []; table.columnWidths.splice(insertAt, 0, null); - const logMessage = `表格 [${table.name}] 在第 ${colIndex + 1} 列的${position === 'left' ? '左侧' : '右侧'}插入了新列。`; - log(logMessage, 'info'); - - // 强制立即保存 - 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(); // Fallback + log(`表格 [${table.name}] 在第 ${colIndex + 1} 列的${position === 'left' ? '左侧' : '右侧'}插入了新列。`, 'info'); + commitToLastMessage(tables); } export function moveColumn(tableIndex, colIndex, direction) { - if (!currentTablesState || !currentTablesState[tableIndex]) return; - const table = currentTablesState[tableIndex]; + const tables = getState(); + if (!tables || !tables[tableIndex]) return; + const table = tables[tableIndex]; const headers = table.headers; const rows = table.rows; const targetIndex = direction === 'left' ? colIndex - 1 : colIndex + 1; - - // 边界检查 if (targetIndex < 0 || targetIndex >= headers.length) { log(`无法移动列:索引 ${colIndex} 已在边界。`, 'warn'); return; } - // 移动表头 const [headerToMove] = headers.splice(colIndex, 1); headers.splice(targetIndex, 0, headerToMove); - // 移动每一行中的单元格 rows.forEach(row => { const [cellToMove] = row.splice(colIndex, 1); row.splice(targetIndex, 0, cellToMove); @@ -687,44 +598,23 @@ export function moveColumn(tableIndex, colIndex, direction) { table.columnWidths.splice(targetIndex, 0, widthToMove); } - const logMessage = `表格 [${table.name}] 的列“${headerToMove}”已向${direction === 'left' ? '左' : '右'}移动。`; - log(logMessage, 'info'); - - // 强制立即保存 - 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(); // Fallback + log(`表格 [${table.name}] 的列“${headerToMove}”已向${direction === 'left' ? '左' : '右'}移动。`, 'info'); + commitToLastMessage(tables); } export function deleteTable(tableIndex) { - if (!currentTablesState || !currentTablesState[tableIndex]) { - return; - } - const tableName = currentTablesState[tableIndex].name; - currentTablesState.splice(tableIndex, 1); - const logMessage = `表格 [${tableName}] 已被成功废黜。`; - log(logMessage, 'success'); - // toastr.success(logMessage, '敕令已达'); // 【V28.0】根据指示,移除弹窗 + const tables = getState(); + if (!tables || !tables[tableIndex]) return; + const tableName = tables[tableIndex].name; + tables.splice(tableIndex, 1); + log(`表格 [${tableName}] 已被成功废黜。`, 'success'); - // 与 addTable 保持一致,强制立即保存 - const context = getContext(); - if (context.chat && context.chat.length > 0) { - const lastMessage = context.chat[context.chat.length - 1]; - if (saveStateToMessage(currentTablesState, lastMessage)) { - saveChat(); // 直接调用,不防抖 - log('废黜表格后的状态已强制写入最新消息并立即保存。', 'success'); - return; - } + const success = commitToLastMessage(tables); + if (success) { + log('废黜表格后的状态已强制写入最新消息并立即保存。', 'success'); + } else { + log('无法找到可锚定的消息或保存失败,删除操作可能不会被持久化!', 'error'); } - - log('无法找到可锚定的消息或保存失败,删除操作可能不会被持久化!', 'error'); - saveChatDebounced(); // Fallback } export function addTable(tableName) { @@ -733,12 +623,13 @@ export function addTable(tableName) { toastr.error('表格名称不能为空。', '创建失败'); return; } - if (!currentTablesState) { - loadTables(); // 确保状态已加载 + let tables = getState(); + if (!tables) { + loadTables(); + tables = getState(); } - // 检查重名 - if (currentTablesState.some(table => table.name === tableName.trim())) { + if (tables.some(table => table.name === tableName.trim())) { log(`无法创建表格:名为 "${tableName}" 的表格已存在。`, 'error'); toastr.error(`名为 "${tableName}" 的表格已存在。`, '创建失败'); return; @@ -746,40 +637,32 @@ export function addTable(tableName) { const newTable = { name: tableName.trim(), - headers: ['新列 1'], // 默认带有一列 + headers: ['新列 1'], rows: [], - rowStatuses: [], // 【延迟删除】 + rowStatuses: [], columnWidths: [], note: '这是一个新创建的表格。', rule_add: '允许', rule_delete: '允许', rule_update: '允许', - charLimitRules: {}, // **【核心修正】** 初始化为新的多列规则空对象 - rowLimitRule: 0 + charLimitRules: {}, + rowLimitRule: 0, }; - currentTablesState.push(newTable); - const logMessage = `已成功创建新表格:[${tableName.trim()}]。`; - log(logMessage, 'success'); - // toastr.success(logMessage, '敕令已达'); // 【V28.0】根据指示,移除弹窗 + tables.push(newTable); + log(`已成功创建新表格:[${tableName.trim()}]。`, 'success'); - // 【V19.0 最终统一修正】回归有效的即时保存逻辑 - const context = getContext(); - if (context.chat && context.chat.length > 0) { - const lastMessage = context.chat[context.chat.length - 1]; - if (saveStateToMessage(currentTablesState, lastMessage)) { - saveChat(); // 直接调用,不防抖 - log('新表格状态已强制写入最新消息并立即保存。', 'success'); - return; - } + const success = commitToLastMessage(tables); + if (success) { + log('新表格状态已强制写入最新消息并立即保存。', 'success'); + } else { + log('无法找到可锚定的消息或保存失败,新表格可能不会被持久化!', 'error'); } - - log('无法找到可锚定的消息或保存失败,新表格可能不会被持久化!', 'error'); - saveChatDebounced(); // Fallback } export function renameTable(tableIndex, newName) { - if (!currentTablesState || !currentTablesState[tableIndex]) { + const tables = getState(); + if (!tables || !tables[tableIndex]) { log('重命名失败:表格不存在。', 'error'); toastr.error('表格不存在。', '重命名失败'); return; @@ -790,478 +673,151 @@ export function renameTable(tableIndex, newName) { toastr.error('表格名称不能为空。', '重命名失败'); return; } - // 检查重名(排除自身) - if (currentTablesState.some((table, index) => index !== tableIndex && table.name === trimmedName)) { + if (tables.some((table, index) => index !== tableIndex && table.name === trimmedName)) { log(`重命名失败:名为 "${trimmedName}" 的表格已存在。`, 'error'); toastr.error(`名为 "${trimmedName}" 的表格已存在。`, '重命名失败'); return; } - const oldName = currentTablesState[tableIndex].name; - currentTablesState[tableIndex].name = trimmedName; + const oldName = tables[tableIndex].name; + tables[tableIndex].name = trimmedName; log(`表格 "${oldName}" 已重命名为 "${trimmedName}"。`, 'success'); - // 强制立即保存 - 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(); // Fallback + commitToLastMessage(tables); } export function moveTable(tableIndex, direction) { - if (!currentTablesState || !currentTablesState[tableIndex]) { - return; - } + const tables = getState(); + if (!tables || !tables[tableIndex]) return; const newIndex = direction === 'up' ? tableIndex - 1 : tableIndex + 1; - - // 边界检查 - if (newIndex < 0 || newIndex >= currentTablesState.length) { + if (newIndex < 0 || newIndex >= tables.length) { log(`无法移动表格:索引 ${tableIndex} 已在边界。`, 'warn'); return; } - // 交换元素 - const temp = currentTablesState[tableIndex]; - currentTablesState[tableIndex] = currentTablesState[newIndex]; - currentTablesState[newIndex] = temp; + const temp = tables[tableIndex]; + tables[tableIndex] = tables[newIndex]; + tables[newIndex] = temp; - const logMessage = `表格 [${temp.name}] 的顺序已调整。`; - log(logMessage, 'success'); + log(`表格 [${temp.name}] 的顺序已调整。`, 'success'); - // 强制立即保存 - const context = getContext(); - if (context.chat && context.chat.length > 0) { - const lastMessage = context.chat[context.chat.length - 1]; - if (saveStateToMessage(currentTablesState, lastMessage)) { - saveChat(); - log('表格顺序调整后的状态已强制写入最新消息并立即保存。', 'success'); - return; - } + const success = commitToLastMessage(tables); + if (success) { + log('表格顺序调整后的状态已强制写入最新消息并立即保存。', 'success'); + } else { + log('无法找到可锚定的消息或保存失败,顺序调整可能不会被持久化!', 'error'); } - - log('无法找到可锚定的消息或保存失败,顺序调整可能不会被持久化!', 'error'); - saveChatDebounced(); // Fallback } - export function updateTableRules(tableIndex, newRules) { - if (!currentTablesState || !currentTablesState[tableIndex]) { - return; - } - const table = currentTablesState[tableIndex]; + const tables = getState(); + if (!tables || !tables[tableIndex]) return; + const table = tables[tableIndex]; table.note = newRules.note; table.rule_add = newRules.rule_add; table.rule_delete = newRules.rule_delete; table.rule_update = newRules.rule_update; - table.charLimitRules = newRules.charLimitRules; // 使用新的多列规则对象 + table.charLimitRules = newRules.charLimitRules; table.rowLimitRule = newRules.rowLimitRule; - table.simplifyRowThreshold = newRules.simplifyRowThreshold; // 【V146.0】保存历史内容简化阈值 - - // 删除旧的单列规则字段,保持数据清洁 + table.simplifyRowThreshold = newRules.simplifyRowThreshold; + delete table.charLimitRule; - const logMessage = `表格 [${table.name}] 的规则已更新。`; - log(logMessage, 'info'); - - // 强制立即保存 - 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(); // Fallback + log(`表格 [${table.name}] 的规则已更新。`, 'info'); + commitToLastMessage(tables); } export function updateRow(tableIndex, rowIndex, data) { - if (!currentTablesState || !currentTablesState[tableIndex]) { + const tables = getState(); + if (!tables || !tables[tableIndex]) { log(`AI指令错误:尝试在不存在的表格索引 ${tableIndex} 中操作。`, 'error'); return; } - const table = currentTablesState[tableIndex]; + const table = tables[tableIndex]; - // 如果行不存在,则视为在末尾新增一行 if (rowIndex >= table.rows.length) { log(`AI指令意图更新不存在的行 (rowIndex: ${rowIndex}),已智能转换为在表格 [${table.name}] 末尾新增一行。`, 'warn'); - insertRow(tableIndex, data); // 直接调用 insertRow,它现在有正确的保存逻辑 + insertRow(tableIndex, data); return; } - // 如果行存在,则正常更新 const row = table.rows[rowIndex]; for (const colIndex in data) { const cIndex = parseInt(colIndex, 10); if (cIndex < row.length) { row[cIndex] = data[cIndex]; - // 【V134.0 新增】为被更新的单元格添加高亮 addHighlight(tableIndex, rowIndex, cIndex); } } - - updatedTables.add(tableIndex); // 【V15.2 新增】标记表格为已更新 - dispatchTableUpdate(tableIndex); - const logMessage = `AI 指令更新了表格 [${table.name}] 的第 ${rowIndex + 1} 行。`; - log(logMessage, 'info'); - // 统一为强制立即保存 - 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(); // Fallback + markTableUpdated(tableIndex); + dispatchTableUpdate(tableIndex); + log(`AI 指令更新了表格 [${table.name}] 的第 ${rowIndex + 1} 行。`, 'info'); + + commitToLastMessage(tables); } export function clearAllTables() { - if (!currentTablesState) { + const tables = getState(); + if (!tables) { log('无法清空:当前表格状态为空。', 'error'); return; } - // 1. 只清空每个表格的 rows 和 rowStatuses 数组 - currentTablesState.forEach((table, tableIndex) => { - if (table.rows.length > 0) { - updatedTables.add(tableIndex); // 【V15.2 新增】如果表格原本有数据,则标记为已更新 - } + tables.forEach((table, tableIndex) => { + if (table.rows.length > 0) markTableUpdated(tableIndex); table.rows = []; table.rowStatuses = []; }); log('所有表格的行数据已在内存中清空。', 'warn'); - // 【Amily2-SuperMemory】清空后触发全量同步 dispatchAllTablesUpdate(); - // 2. 强制保存更新后的状态 - const context = getContext(); - if (context.chat && context.chat.length > 0) { - const lastMessage = context.chat[context.chat.length - 1]; - if (saveStateToMessage(currentTablesState, lastMessage)) { - saveChat(); - log('清空行数据后的状态已强制写入最新消息并立即保存。', 'success'); - toastr.success('所有表格的剧情内容已清空。', '操作完成'); - return; - } + const success = commitToLastMessage(tables); + if (success) { + log('清空行数据后的状态已强制写入最新消息并立即保存。', 'success'); + toastr.success('所有表格的剧情内容已清空。', '操作完成'); + } else { + log('无法找到可锚定的消息或保存失败,清空操作可能不会被持久化!', 'error'); } - - // Fallback - log('无法找到可锚定的消息或保存失败,清空操作可能不会被持久化!', 'error'); - saveChatDebounced(); } - -function checkTableRules(table) { - let warnings = []; - - // 1. 检查行数限制 (逻辑不变) - if (table.rowLimitRule && table.rowLimitRule > 0 && table.rows.length > table.rowLimitRule) { - warnings.push(`【当前(${table.name})超出规定(${table.rowLimitRule})行,请结合剧情缩减至(${table.rowLimitRule})行以下,但切莫完全删除。】`); - } - - // 2. **【核心改造】** 检查多列字符限制 - 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) { - // 【V140.5 细节修正】根据用户反馈,行号应从0开始,与AI视角下的rowIndex保持一致 - offendingRows.push(rowIndex); - } - }); - - // 如果有违规的行,则生成聚合后的警告信息 - if (offendingRows.length > 0) { - const rowNumbers = offendingRows.join('、'); - warnings.push(`【当前(${table.name})第(${rowNumbers})行(${colName})列,字符超出规定(${limit})字限制,请进行缩减。】`); - } - } - } - - return warnings.join('\n'); -} +// ── 渲染 wrapper(注入当前 state) ──────────────────────────────────────── export function convertTablesToCsvString() { - if (!currentTablesState) { + let state = getState(); + if (!state) { loadTables(); + state = getState(); } - if (!currentTablesState) { - return ''; - } - - let fullString = ''; - currentTablesState.forEach((table, tableIndex) => { - // 1. 表格标题 - fullString += `\n* ${tableIndex}:${table.name}\n`; - - // 2. 说明 - fullString += `【说明】:\n${table.note || '无'}\n`; - - // 3. 表格内容 (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`; - - const activeRows = table.rows.filter((row, i) => !table.rowStatuses || table.rowStatuses[i] !== 'pending-deletion'); - - if (activeRows.length === 0) { - fullString += '(该表当前内容为空)\n'; - } else { - const simplifyThreshold = table.simplifyRowThreshold || 0; - let simplifiedCount = 0; - - table.rows.forEach((row, rowIndex) => { - // 【延迟删除】注入时,跳过待删除的行 - if (table.rowStatuses && table.rowStatuses[rowIndex] === 'pending-deletion') { - return; - } - - // 【V146.0】历史内容简化逻辑 - if (simplifyThreshold > 0 && rowIndex < simplifyThreshold) { - // 仅在第一行简化时输出一次提示行,避免刷屏 - if (simplifiedCount === 0) { - // 计算被简化的列数,生成占位符 - const placeholderCells = row.map(() => '---已锁定---'); - fullString += `| ${rowIndex} | ${placeholderCells.join(' | ')} |\n`; - fullString += `| ... | ${row.map(() => '...').join(' | ')} |\n`; - } - // 如果是最后一行被简化的行 - if (rowIndex === simplifyThreshold - 1) { - const placeholderCells = row.map(() => '---已锁定---'); - fullString += `| ${rowIndex} | ${placeholderCells.join(' | ')} |\n`; - } - simplifiedCount++; - return; // 跳过具体内容的输出 - } - - if (Array.isArray(row)) { - const rowCells = row.map(cell => { - const cellContent = (cell === null || cell === undefined || cell === '') ? '未知' : String(cell); - // 替换管道符以避免破坏Markdown表格结构 - return cellContent.replace(/\|/g, '|'); - }); - fullString += `| ${rowIndex} | ${rowCells.join(' | ')} |\n`; - } - }); - - if (simplifiedCount > 0) { - fullString += `\n【系统提示】:表格前 ${simplifiedCount} 行(索引 0 到 ${simplifiedCount - 1})的历史内容已简化并锁定,无需读取或修改。请专注于后续行的内容。\n`; - } - } - - // 【V140.4 最终逻辑修正】根据用户最终指示,警告信息始终在主流程提示词({{{Amily2TableData}}})中注入 - const warnings = checkTableRules(table); - if (warnings) { - fullString += `${warnings}\n`; - } - fullString += `\n`; - - // 4. 规则 - fullString += `【增加】: ${table.rule_add || '允许'}\n`; - fullString += `【删除】: ${table.rule_delete || '允许'}\n`; - fullString += `【修改】: ${table.rule_update || '允许'}\n`; - - // 5. 分隔符 - if (tableIndex < currentTablesState.length - 1) { - fullString += '\n---\n'; - } - }); - - return fullString; + return tablesToCsv(state); } export function convertSelectedTablesToCsvString(selectedIndices) { - if (!currentTablesState) { + let state = getState(); + if (!state) { loadTables(); + state = getState(); } - if (!currentTablesState) { - return ''; - } - - let fullString = ''; - - currentTablesState.forEach((table, tableIndex) => { - const isSelected = selectedIndices.includes(tableIndex); - - // 1. 表格标题 - fullString += `\n* ${tableIndex}:${table.name}`; - if (!isSelected) { - fullString += ' (本表格无需重新整理,仅供参考)'; - } - fullString += '\n'; - - // 2. 说明 - fullString += `【说明】:\n${table.note || '无'}\n`; - - // 3. 表格内容 (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`; - - if (isSelected) { - // 如果是选中的表格,包含完整内容 - const activeRows = table.rows.filter((row, i) => !table.rowStatuses || table.rowStatuses[i] !== 'pending-deletion'); - - if (activeRows.length === 0) { - fullString += '(该表当前内容为空)\n'; - } else { - const simplifyThreshold = table.simplifyRowThreshold || 0; - let simplifiedCount = 0; - - table.rows.forEach((row, rowIndex) => { - // 【延迟删除】注入时,跳过待删除的行 - if (table.rowStatuses && table.rowStatuses[rowIndex] === 'pending-deletion') { - return; - } - - // 【V146.0】历史内容简化逻辑 - if (simplifyThreshold > 0 && rowIndex < simplifyThreshold) { - if (simplifiedCount === 0) { - const placeholderCells = row.map(() => '---已锁定---'); - fullString += `| ${rowIndex} | ${placeholderCells.join(' | ')} |\n`; - fullString += `| ... | ${row.map(() => '...').join(' | ')} |\n`; - } - if (rowIndex === simplifyThreshold - 1) { - const placeholderCells = row.map(() => '---已锁定---'); - fullString += `| ${rowIndex} | ${placeholderCells.join(' | ')} |\n`; - } - simplifiedCount++; - return; - } - - if (Array.isArray(row)) { - const rowCells = row.map(cell => { - const cellContent = (cell === null || cell === undefined || cell === '') ? '未知' : String(cell); - // 替换管道符以避免破坏Markdown表格结构 - return cellContent.replace(/\|/g, '|'); - }); - fullString += `| ${rowIndex} | ${rowCells.join(' | ')} |\n`; - } - }); - - if (simplifiedCount > 0) { - fullString += `\n【系统提示】:表格前 ${simplifiedCount} 行(索引 0 到 ${simplifiedCount - 1})的历史内容已简化并锁定,无需读取或修改。请专注于后续行的内容。\n`; - } - } - - // 警告信息 - const warnings = checkTableRules(table); - if (warnings) { - fullString += `${warnings}\n`; - } - } else { - // 如果未选中,仅展示表头作为结构参考,不包含行数据 - fullString += '(此处省略未选中的表格内容,仅提供表头供索引参考)\n'; - } - fullString += `\n`; - - // 4. 规则 - if (isSelected) { - fullString += `【增加】: ${table.rule_add || '允许'}\n`; - fullString += `【删除】: ${table.rule_delete || '允许'}\n`; - fullString += `【修改】: ${table.rule_update || '允许'}\n`; - } else { - fullString += `【操作权限】: 禁止修改此表格\n`; - } - - // 5. 分隔符 - if (tableIndex < currentTablesState.length - 1) { - fullString += '\n---\n'; - } - }); - - return fullString; + return tablesToCsvWithSelection(state, selectedIndices); } export function convertTablesToCsvStringForContentOnly() { - const tables = getMemoryState(); - if (!tables || tables.length === 0) { - return ''; - } - - let outputString = ''; - tables.forEach(table => { - outputString += `\n<${table.name}>\n`; - - // 1. 生成Markdown表头 - const headerLine = `| ${table.headers.join(' | ')} |`; - outputString += headerLine + '\n'; - - // 2. 生成Markdown分隔符 - const separatorLine = `|${table.headers.map(() => '---').join('|')}|`; - outputString += separatorLine + '\n'; - - // 3. 生成数据行 - 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()); - const rowLine = `| ${rowContent.join(' | ')} |`; - outputString += rowLine + '\n'; - } - }); - } else { - outputString += '(该表当前内容为空)\n'; - } - - outputString += `\n`; - }); - - return outputString.trim(); + return tablesToCsvContentOnly(getState()); } -// 初始化时加载一次数据 -loadTables(); +// ── 模板(re-export) ───────────────────────────────────────────────────── -export function getBatchFillerRuleTemplate() { - return extension_settings[extensionName]?.batch_filler_rule_template ?? DEFAULT_AI_RULE_TEMPLATE; -} +export const getBatchFillerRuleTemplate = _tplGetBatchFillerRuleTemplate; +export const saveBatchFillerRuleTemplate = _tplSaveBatchFillerRuleTemplate; +export const getBatchFillerFlowTemplate = _tplGetBatchFillerFlowTemplate; +export const saveBatchFillerFlowTemplate = _tplSaveBatchFillerFlowTemplate; +export const getAiFlowTemplateForInjection = _tplGetAiFlowTemplateForInjection; +export const saveAiTemplate = _tplSaveAiTemplate; +export const getAiTemplate = _tplGetAiTemplate; -export function saveBatchFillerRuleTemplate(template) { - extension_settings[extensionName].batch_filler_rule_template = template; - saveSettingsDebounced(); -} - -export function getBatchFillerFlowTemplate() { - return extension_settings[extensionName]?.batch_filler_flow_template ?? DEFAULT_AI_FLOW_TEMPLATE; -} - -export function saveBatchFillerFlowTemplate(template) { - extension_settings[extensionName].batch_filler_flow_template = template; - saveSettingsDebounced(); -} - -export function getAiFlowTemplateForInjection() { - return extension_settings[extensionName]?.amily2_ai_template ?? DEFAULT_AI_FLOW_TEMPLATE; -} +// ── 文本指令应用(updateTableFromText) ─────────────────────────────────── export async function updateTableFromText(textContent, options = {}) { const settings = extension_settings[extensionName] || {}; @@ -1275,262 +831,76 @@ export async function updateTableFromText(textContent, options = {}) { return; } - // 使用 executor.js 进行推演 - const { finalState, hasChanges, changes } = executeCommands(textContent, currentTablesState); + const { finalState, hasChanges, changes } = executeCommands(textContent, getState()); if (!hasChanges) { log('AI指令未产生任何实质性变更。', 'info'); return; } - // 更新内存状态 - setMemoryState(finalState); + setState(finalState); - // 如果指定了立即删除(如批量填表时),则立即提交待删除行 if (options.immediateDelete) { commitPendingDeletions(); } - - // 标记已更新的表格并处理高亮 + changes.forEach(change => { - updatedTables.add(change.tableIndex); + markTableUpdated(change.tableIndex); if (change.type === 'update' || change.type === 'insert') { - if (change.rowIndex !== undefined && change.colIndex !== undefined) { - addHighlight(change.tableIndex, change.rowIndex, change.colIndex); - } + if (change.rowIndex !== undefined && change.colIndex !== undefined) { + addHighlight(change.tableIndex, change.rowIndex, change.colIndex); + } } }); log(`成功执行了 ${changes.length} 处变更。`, 'success'); - // 触发更新事件 (去重) const affectedTables = [...new Set(changes.map(c => c.tableIndex))]; - affectedTables.forEach(tableIndex => { - dispatchTableUpdate(tableIndex); - }); + affectedTables.forEach(tableIndex => dispatchTableUpdate(tableIndex)); - // 一次性保存 const context = getContext(); if (context.chat && context.chat.length > 0) { const lastMessage = context.chat[context.chat.length - 1]; - if (saveStateToMessage(currentTablesState, lastMessage)) { - await saveChat(); // 使用 await 确保保存完成 + if (_persistSaveStateToMessage(getState(), lastMessage)) { + await saveChat(); toastr.success('已根据AI的指示成功更新表格!', '填表完成'); - // 触发 UI 刷新 document.dispatchEvent(new CustomEvent('amily2-force-ui-reload')); return; } } - + saveChatDebounced(); toastr.success('已根据AI的指示成功更新表格!', '填表完成'); document.dispatchEvent(new CustomEvent('amily2-force-ui-reload')); } -export function saveAiTemplate(template) { - extension_settings[extensionName].amily2_ai_template = template; - saveSettingsDebounced(); +// ── 预设(re-export 或 wrapper) ───────────────────────────────────────── + +export const exportPreset = _presetExportPreset; +export const exportPresetFull = _presetExportPresetFull; +export const clearGlobalPreset = _presetClearGlobalPreset; +export const importGlobalPreset = _presetImportGlobalPreset; + +/** + * importPreset wrapper:在 setState 之后注入 SuperMemory 全量同步。 + * 兼容旧签名 importPreset(callback) 和新 importPreset({ onAfterApply, onImported })。 + */ +export function importPreset(onImportedOrHooks) { + /** @type {{ onAfterApply?: () => void, onImported?: () => void }} */ + const hooks = typeof onImportedOrHooks === 'function' + ? { onImported: onImportedOrHooks } + : (onImportedOrHooks || {}); + + return _presetImportPreset({ + onAfterApply: () => { + dispatchAllTablesUpdate(); + if (hooks.onAfterApply) hooks.onAfterApply(); + }, + onImported: hooks.onImported, + }); } -export function getAiTemplate() { - return getAiFlowTemplateForInjection(); -} - -function exportPresetBase(includeData = false) { - if (!currentTablesState) { - log('无法导出:当前表格状态为空。', 'error'); - toastr.error('没有可导出的表格数据。'); - return; - } - - let tablesToExport; - let version; - let fileNameSuffix; - - if (includeData) { - // 完整备份 - tablesToExport = JSON.parse(JSON.stringify(currentTablesState)); - version = "Amily2-Table-Preset-v2.0-full"; - fileNameSuffix = "完整备份"; - } else { - // 纯净预设 - tablesToExport = currentTablesState.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 || {}, // 【V140.6 修正】导出新的多列规则 - rowLimitRule: table.rowLimitRule || 0, // 【V140.6 修正】导出新行数规则 - // simplifyRowThreshold: 不导出此字段,因为它是针对当前聊天进度的临时设置 - rows: [], // 确保纯净版有空的rows数组 - rowStatuses: [], // 【延迟删除】纯净预设也应为空 - })); - version = "Amily2-Table-Preset-v2.0-clean"; - 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); -} - -export function importPreset(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('文件格式无效或缺少版本号/表格数据。'); - } - - // 【V27.0 新增】导入前增加二次确认 - const confirmation = window.confirm( - "【警告】\n\n导入操作将完全覆盖您当前的AI指令模板和所有表格(包括结构和内容)。\n\n此操作不可逆,是否确定要继续?" - ); - - if (!confirmation) { - log('用户取消了导入操作。', 'info'); - toastr.info('导入操作已取消。'); - return; - } - - - // 1. 更新 AI 模板 - 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) { // 兼容 v2.1 - saveBatchFillerRuleTemplate(preset.aiRuleTemplate || ''); - saveBatchFillerFlowTemplate(preset.aiFlowTemplate || ''); - saveAiTemplate(preset.aiFlowTemplate || ''); // 旧版中,注入和流程模板是同一个 - } else if (preset.aiTemplate) { // 兼容 v2.0 - saveBatchFillerRuleTemplate(''); // 旧版没有规则模板,置空 - saveBatchFillerFlowTemplate(preset.aiTemplate || ''); - saveAiTemplate(preset.aiTemplate || ''); - } else { - // 如果所有模板字段都不存在,为了向前兼容,不做强制要求,只记录一个警告 - log('导入的预设中缺少指令模板字段,模板将不会被更新。', 'warn'); - } - - // 2. 直接使用导入的数据完全覆盖当前状态 - const importedTables = preset.tables; - - // 【V140.6 修正】数据结构校验和兼容性处理 - 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 = '允许'; - - // **【多列规则兼容性改造】** - // 检查是否存在旧的单列规则,并且不存在新的多列规则 - 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; - - // 【延迟删除】兼容性处理 - if (!table.rowStatuses) { - table.rowStatuses = Array(table.rows.length).fill('normal'); - } - - // 确保行数限制规则存在,否则设为0 - if (table.rowLimitRule === undefined) { - table.rowLimitRule = 0; - } - if (table.columnWidths === undefined) { - table.columnWidths = []; - } - }); - - // 更新内存状态 - setMemoryState(importedTables); - - // 【Amily2-SuperMemory】导入后触发全量同步 - dispatchAllTablesUpdate(); - - // 3. 强制保存 - const context = getContext(); - if (context.chat && context.chat.length > 0) { - const lastMessage = context.chat[context.chat.length - 1]; - if (saveStateToMessage(getMemoryState(), lastMessage)) { - saveChat(); - log('导入的预设已强制写入最新消息并立即保存。', 'success'); - } - } else { - saveChatDebounced(); // Fallback - } - - log('预设已成功导入并应用。', 'success'); - toastr.success('预设已成功导入!', '导入成功'); - - // 4. 执行回调,刷新UI - if (typeof onImported === 'function') { - onImported(); - } - - } catch (error) { - log(`导入预设失败: ${error.message}`, 'error'); - toastr.error(`导入失败:${error.message}`, '错误'); - } - }; - reader.readAsText(file); - }; - - input.click(); -} +// ── 回滚 ────────────────────────────────────────────────────────────────── export async function rollbackState() { const context = getContext(); @@ -1544,7 +914,6 @@ export async function rollbackState() { const lastMessageIndex = chat.length - 1; const lastMessage = chat[lastMessageIndex]; - // 1. 加载倒数第二条消息的状态 log(`正在尝试从第 ${lastMessageIndex - 1} 条消息加载表格状态...`, 'info'); const previousState = loadTables(lastMessageIndex); @@ -1554,9 +923,8 @@ export async function rollbackState() { return false; } - // 2. 将加载的状态设置为当前内存状态并立即持久化 - setMemoryState(previousState); - if (saveStateToMessage(previousState, lastMessage)) { + setState(previousState); + if (_persistSaveStateToMessage(previousState, lastMessage)) { await saveChat(); log('已成功将回退后的状态保存至最新消息。', 'success'); } else { @@ -1565,181 +933,64 @@ export async function rollbackState() { return false; } - // 3. 重新渲染UI以显示回退后的状态 renderTables(); updateOrInsertTableInChat(); log('UI已更新以显示回退后的状态。', 'info'); return true; } - export async function rollbackAndRefill() { - // 检查表格系统总开关 const settings = extension_settings[extensionName] || {}; if (settings.table_system_enabled === false) { log('表格系统总开关已关闭,跳过回退填表。', 'info'); toastr.info('表格系统总开关已关闭,无法执行回退填表。'); return; } - + toastr.info('正在执行回退并重新填表...'); const rollbackSuccess = await rollbackState(); - + if (!rollbackSuccess) { toastr.error('状态回退失败,已中止操作。'); return; } - + toastr.success('状态回退成功,准备重新填表...'); - + const context = getContext(); const lastMessage = context.chat[context.chat.length - 1]; try { await fillWithSecondaryApi(lastMessage, true); log('回退并重新填表操作完成。', 'success'); - // 填表函数自己有成功提示,这里不再重复 } catch (error) { log(`回退重填过程中发生错误: ${error.message}`, 'error'); toastr.error(`重新填表失败: ${error.message}`); } } +// ── 杂项 ────────────────────────────────────────────────────────────────── + export function updateColumnWidth(tableIndex, colIndex, width) { - if (!currentTablesState || !currentTablesState[tableIndex]) return; - const table = currentTablesState[tableIndex]; - if (!table.columnWidths) { - table.columnWidths = []; - } - // Ensure array is long enough + const tables = getState(); + if (!tables || !tables[tableIndex]) return; + const table = tables[tableIndex]; + if (!table.columnWidths) table.columnWidths = []; while (table.columnWidths.length < table.headers.length) { table.columnWidths.push(null); } table.columnWidths[colIndex] = width; - // Persist the change - 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(); + commitToLastMessage(tables); } - export function isCurrentTablesEmpty() { - const tables = getMemoryState(); - if (!tables || tables.length === 0) { - return true; // 没有表格当然是空的 - } - // 检查是否所有表格都没有行 + const tables = getState(); + if (!tables || tables.length === 0) return true; return tables.every(table => !table.rows || table.rows.length === 0); } -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('当前没有设置全局预设。', '提示'); - } -} - -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; - } - - // 创建一个纯净的预设副本,不包含任何行数据 - 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: [], // 关键:确保 rows 为空数组 - })); - - // 将纯净预设保存到 extension_settings - 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(); - 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 || ''); - } - - log('全局预设已成功导入并保存到扩展设置中。', 'success'); - toastr.success('全局预设已设置!新聊天将默认使用此预设。', '设置成功'); - - // 导入成功后,执行回调 - if (typeof onImported === 'function') { - onImported(); - } - - } catch (error) { - log(`导入全局预设失败: ${error.message}`, 'error'); - toastr.error(`导入失败:${error.message}`, '错误'); - } - }; - reader.readAsText(file); - }; - - input.click(); -} +// 模块加载时执行一次初始 loadTables +loadTables(); diff --git a/core/table-system/preset.js b/core/table-system/preset.js new file mode 100644 index 0000000..8d40268 --- /dev/null +++ b/core/table-system/preset.js @@ -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(); +} diff --git a/core/table-system/rendering.js b/core/table-system/rendering.js new file mode 100644 index 0000000..765c9ed --- /dev/null +++ b/core/table-system/rendering.js @@ -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 += `\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 += `\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 += `\n`; + }); + + return outputString.trim(); +} diff --git a/core/table-system/reorganizer.js b/core/table-system/reorganizer.js index c9f6561..72b224a 100644 --- a/core/table-system/reorganizer.js +++ b/core/table-system/reorganizer.js @@ -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) { diff --git a/core/table-system/secondary-filler.js b/core/table-system/secondary-filler.js index b4d1eb3..9648a03 100644 --- a/core/table-system/secondary-filler.js +++ b/core/table-system/secondary-filler.js @@ -277,8 +277,8 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) { console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...'); rawContent = await callNccsAI(messages); } else { - console.log('[Amily2-副API] 使用默认 API 进行分步填表...'); - rawContent = await callAI(messages); + console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...'); + rawContent = await callAI(messages, { slot: 'tableFilling' }); } if (!rawContent) { diff --git a/core/table-system/templates.js b/core/table-system/templates.js new file mode 100644 index 0000000..57f2e55 --- /dev/null +++ b/core/table-system/templates.js @@ -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(); +} diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..7d4840b --- /dev/null +++ b/jsconfig.json @@ -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" + ] +} diff --git a/manifest.json b/manifest.json index 640d62c..4af4693 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Amily2号聊天优化助手", "display_name": "Amily2号助手", - "version": "2.1.1", + "version": "2.2.0", "author": "Wx-2025", "description": "一个拥有独立UI的智能引擎,正文优化、自动总结、记忆表格、rag向量、隐藏楼层、剧情推进等多功能整合。", "minSillyTavernVersion": "1.10.0", diff --git a/types/sillytavern.d.ts b/types/sillytavern.d.ts new file mode 100644 index 0000000..39405cb --- /dev/null +++ b/types/sillytavern.d.ts @@ -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 {}; diff --git a/ui/api-config-bindings.js b/ui/api-config-bindings.js index 3d03dc5..bd8c6b6 100644 --- a/ui/api-config-bindings.js +++ b/ui/api-config-bindings.js @@ -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 ID(null = 新建) 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'); + + // customParams:JSON 校验失败则中止保存 + 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; @@ -705,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 模式清空 URL;ST 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); } } @@ -741,3 +859,153 @@ function _escapeHtml(str) { .replace(/>/g, '>') .replace(/"/g, '"'); } + +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 => ` + + `).join(''); + const invalidNote = editorState.valid + ? '' + : '请先修复 JSON,再插入参数。'; + $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; +} diff --git a/ui/hanlinyuan-bindings.js b/ui/hanlinyuan-bindings.js index 31e5a60..468b720 100644 --- a/ui/hanlinyuan-bindings.js +++ b/ui/hanlinyuan-bindings.js @@ -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, @@ -152,6 +153,8 @@ export function bindHanlinyuanEvents() { } setupGlobalEventHandlers(); + syncSlot('ragEmbed'); + syncSlot('ragRerank'); bindPanelToggleEvents(); bindInternalUIEvents(); bindTutorialEvents(); // 【新增】绑定教程按钮事件 diff --git a/ui/historiography-bindings.js b/ui/historiography-bindings.js index ebd9951..b06a274 100644 --- a/ui/historiography-bindings.js +++ b/ui/historiography-bindings.js @@ -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) { diff --git a/ui/plot-opt-bindings.js b/ui/plot-opt-bindings.js index 6b699e5..dd48363 100644 --- a/ui/plot-opt-bindings.js +++ b/ui/plot-opt-bindings.js @@ -661,8 +661,6 @@ function opt_loadSettings(panel) { } 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); @@ -695,8 +693,6 @@ function opt_loadSettings(panel) { 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'); diff --git a/ui/profile-sync.js b/ui/profile-sync.js index 392698f..4febbad 100644 --- a/ui/profile-sync.js +++ b/ui/profile-sync.js @@ -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 : 隐藏元素(上溯到容器直接子元素)+ 前一个兄弟