14 Commits

Author SHA1 Message Date
Jenkins CI
2c3072a3d8 release: v2.2.2 [2026-05-27 11:10:55]
### 新功能
- **Function Call 填表模式**:在填表设置中新增独立开关,启用后支持通过 OpenAI 兼容接口(DeepSeek / OpenRouter / 各类中转等)直接返回结构化操作列表,绕过 `<Amily2Edit>` 文本解析路径,填表更稳定
  - 遇到不支持 `tool_choice` 的接口时自动降级重试
  - 对思考模型注入强制调用指令,防止绕过工具直接输出文本
  - 全部走 ST 后端代理,修复 CSP 拦截直连外部 URL 的问题
- **主界面新增提示词链编辑器入口**,同时调换了记忆管理与角色世界书的按钮位置
- **规则中心**新增"自动排除用户楼层"选项
### 修复
- 提示词链按钮点击无响应(改为事件委托方式绑定)
- 拖拽组件微抖误触发(加 5px 移动阈值过滤)
- 填表检查窗若干问题修复;翰林院(批量回填)修复;防抖逻辑落地
- 角色世界书入口添加使用警告弹窗(强制 10 秒倒计时),提示该功能长期未维护
- ApiProfile `fakeStream` 字段保存丢失问题
- 正文优化默认改为关闭状态
- NGMS / NCCS API 配置槽位标签修正(NGMS→总结,NCCS→填表)
- API Profile 面板选择逻辑统一重构,修复多处旧字段覆盖新配置的问题
- 世界书控制参数兼容性修复(排除递归、插入位置、扫描深度等,适配 ST 1.17.0+)
2026-05-27 11:10:55 +08:00
Jenkins CI
e00302d04b ci: auto build & obfuscate [2026-05-27 09:15:49] (Jenkins #23) 2026-05-27 09:15:49 +08:00
Jenkins CI
dabc8992f1 ci: auto build & obfuscate [2026-05-17 17:36:51] (Jenkins #22) 2026-05-17 17:36:51 +08:00
Jenkins CI
d9fa3072a2 ci: auto build & obfuscate [2026-05-16 19:16:28] (Jenkins #21) 2026-05-16 19:16:28 +08:00
Jenkins CI
4bc6e0a047 ci: auto build & obfuscate [2026-04-28 22:47:21] (Jenkins #20) 2026-04-28 22:47:21 +08:00
Jenkins CI
31d00f4330 ci: auto build & obfuscate [2026-04-28 00:51:59] (Jenkins #19) 2026-04-28 00:51:59 +08:00
Jenkins CI
13d05651f3 ci: auto build & obfuscate [2026-04-26 23:26:32] (Jenkins #18) 2026-04-26 23:26:32 +08:00
Jenkins CI
544937bb91 ci: auto build & obfuscate [2026-04-23 00:35:57] (Jenkins #17) 2026-04-23 00:35:57 +08:00
Jenkins CI
8d590073f4 ci: auto build & obfuscate [2026-04-19 03:01:12] (Jenkins #16) 2026-04-19 03:01:12 +08:00
Jenkins CI
58ff3c3faf ci: auto build & obfuscate [2026-04-11 19:39:01] (Jenkins #15) 2026-04-11 19:39:01 +08:00
Jenkins CI
c50e1a9425 ci: auto build & obfuscate [2026-04-11 18:45:22] (Jenkins #14) 2026-04-11 18:45:22 +08:00
Jenkins CI
2291a871eb ci: auto build & obfuscate [2026-04-09 14:41:31] (Jenkins #13) 2026-04-09 14:41:31 +08:00
Jenkins CI
bddda1802f ci: auto build & obfuscate [2026-04-08 21:17:22] (Jenkins #12) 2026-04-08 21:17:22 +08:00
Jenkins CI
1fdbe62142 ci: auto build & obfuscate [2026-04-08 17:38:16] (Jenkins #11) 2026-04-08 17:38:16 +08:00
92 changed files with 6983 additions and 2674 deletions

96
51TODO.md Normal file
View File

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

View File

@@ -679,11 +679,6 @@ export async function callCustomOpenAI(messages) {
const headers = { ...getRequestHeaders(), 'Content-Type': 'application/json' };
const body = JSON.stringify(requestBody);
console.groupCollapsed(`[CWB] API Call @ ${new Date().toLocaleTimeString()}`);
console.log('Request URL:', fullApiUrl);
console.log('Request Headers:', headers);
console.log('Request Body:', requestBody);
try {
const response = await fetch(fullApiUrl, {
method: 'POST',
@@ -693,27 +688,19 @@ export async function callCustomOpenAI(messages) {
if (!response.ok) {
const errTxt = await response.text();
console.error('API Error Response:', errTxt);
throw new Error(`API请求失败: ${response.status} ${errTxt}`);
}
const data = await response.json();
console.log('API Full Response:', data);
if (data.choices && data.choices[0]?.message?.content) {
console.log('Extracted Content:', data.choices[0].message.content.trim());
console.groupEnd();
return data.choices[0].message.content.trim();
}
throw new Error('API响应格式不正确。');
} catch (error) {
console.error('API Call Failed:', error);
console.error('[CWB] API Call Failed:', error);
throw error;
} finally {
if (console.groupEnd) {
console.groupEnd();
}
}
}
}

View File

@@ -10,6 +10,7 @@ import { generateRandomSeed } from '../../core/api.js';
import { getChatIdentifier } from '../../core/lore.js';
import { safeLorebookEntries } from '../../core/tavernhelper-compatibility.js';
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { resolveHistoriographyRuleConfig } from '../../utils/config/RuleProfileManager.js';
const { SillyTavern, jQuery, characters } = window;
@@ -127,9 +128,10 @@ function processChatMessages(messages) {
return messages.map(msg => `${msg.is_user ? SillyTavern?.name1 || '用户' : msg.name || '角色'}: ${msg.message}`).join('\n\n');
}
const useTagExtraction = mainSettings.historiographyTagExtractionEnabled ?? false;
const tagsToExtract = useTagExtraction ? (mainSettings.historiographyTags || '').split(',').map(t => t.trim()).filter(Boolean) : [];
const exclusionRules = mainSettings.historiographyExclusionRules || [];
const historiographyRuleConfig = resolveHistoriographyRuleConfig(mainSettings);
const useTagExtraction = historiographyRuleConfig.tagExtractionEnabled ?? false;
const tagsToExtract = useTagExtraction ? (historiographyRuleConfig.tags || '').split(',').map(t => t.trim()).filter(Boolean) : [];
const exclusionRules = historiographyRuleConfig.exclusionRules || [];
logDebug(`[CWB] 标签提取: ${useTagExtraction}, 标签: ${tagsToExtract.join(', ')}, 排除规则: ${exclusionRules.length}`);

View File

@@ -1,6 +1,7 @@
import { extension_settings } from '/scripts/extensions.js';
import { extensionName } from '../../utils/settings.js';
import { saveSettingsDebounced } from '/script.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { world_names } from '/scripts/world-info.js';
import { state } from './cwb_state.js';
import { cwbCompleteDefaultSettings } from './cwb_config.js';
@@ -38,7 +39,7 @@ function saveApiConfig() {
const settings = getSettings();
settings.cwb_api_mode = $panel.find('#cwb-api-mode').val();
settings.cwb_api_url = $panel.find('#cwb-api-url').val().trim();
settings.cwb_api_key = $panel.find('#cwb-api-key').val();
configManager.set('cwb_api_key', $panel.find('#cwb-api-key').val());
settings.cwb_api_model = $panel.find('#cwb-api-model').val();
settings.cwb_tavern_profile = $panel.find('#cwb-tavern-profile').val();
@@ -63,7 +64,7 @@ function saveApiConfig() {
function clearApiConfig() {
const settings = getSettings();
settings.cwb_api_url = '';
settings.cwb_api_key = '';
configManager.set('cwb_api_key', '');
settings.cwb_api_model = '';
saveSettingsDebounced();
state.customApiConfig.url = '';
@@ -86,6 +87,13 @@ function saveBreakArmorPrompt() {
showToastr('success', '破甲预设已保存!');
}
function autosaveBreakArmorPrompt() {
const newPrompt = $panel.find('#cwb-break-armor-prompt-textarea').val();
getSettings().cwb_break_armor_prompt = newPrompt;
state.currentBreakArmorPrompt = newPrompt;
saveSettingsDebounced();
}
function resetBreakArmorPrompt() {
getSettings().cwb_break_armor_prompt = cwbCompleteDefaultSettings.cwb_break_armor_prompt;
state.currentBreakArmorPrompt = cwbCompleteDefaultSettings.cwb_break_armor_prompt;
@@ -106,6 +114,13 @@ function saveCharCardPrompt() {
showToastr('success', '角色卡预设已保存!');
}
function autosaveCharCardPrompt() {
const newPrompt = $panel.find('#cwb-char-card-prompt-textarea').val();
getSettings().cwb_char_card_prompt = newPrompt;
state.currentCharCardPrompt = newPrompt;
saveSettingsDebounced();
}
function resetCharCardPrompt() {
getSettings().cwb_char_card_prompt = cwbCompleteDefaultSettings.cwb_char_card_prompt;
state.currentCharCardPrompt = cwbCompleteDefaultSettings.cwb_char_card_prompt;
@@ -128,6 +143,16 @@ function saveAutoUpdateThreshold() {
}
}
function autosaveAutoUpdateThreshold() {
const valStr = $panel.find('#cwb-auto-update-threshold').val();
const newT = parseInt(valStr, 10);
if (!isNaN(newT) && newT >= 1) {
getSettings().cwb_auto_update_threshold = newT;
state.autoUpdateThreshold = newT;
saveSettingsDebounced();
}
}
function saveScanDepth() {
const valStr = $panel.find('#cwb-scan-depth').val();
const newT = parseInt(valStr, 10);
@@ -142,6 +167,16 @@ function saveScanDepth() {
}
}
function autosaveScanDepth() {
const valStr = $panel.find('#cwb-scan-depth').val();
const newT = parseInt(valStr, 10);
if (!isNaN(newT) && newT >= 1) {
getSettings().cwb_scan_depth = newT;
state.scanDepth = newT;
saveSettingsDebounced();
}
}
function bindWorldBookSettings() {
const MAX_RETRIES = 10;
const RETRY_DELAY = 200;
@@ -283,16 +318,13 @@ export function bindSettingsEvents($settingsPanel) {
$panel.on('input', '#cwb-api-key', function() {
const apiKey = $(this).val();
// 同时更新设置和状态
getSettings().cwb_api_key = apiKey;
// 同时更新设置和状态API Key 经 configManager 写入 localStorage
configManager.set('cwb_api_key', apiKey);
state.customApiConfig.apiKey = apiKey;
saveSettingsDebounced();
console.log('[CWB] API Key已更新 - 设置长度:', getSettings().cwb_api_key?.length || 0, ', 状态长度:', state.customApiConfig.apiKey?.length || 0);
updateApiStatusDisplay($panel);
});
$panel.on('change', '#cwb-api-model', function() {
$panel.on('input change', '#cwb-api-model', function(event) {
const model = $(this).val();
// 同时更新设置和状态
@@ -304,11 +336,16 @@ export function bindSettingsEvents($settingsPanel) {
console.log('[CWB] 模型已更新 - 设置:', getSettings().cwb_api_model, ', 状态:', state.customApiConfig.model);
if (model) {
if (model && event.type === 'change') {
showToastr('success', `模型已选择: ${model}`);
}
});
$panel.on('input change', '#cwb-break-armor-prompt-textarea', autosaveBreakArmorPrompt);
$panel.on('input change', '#cwb-char-card-prompt-textarea', autosaveCharCardPrompt);
$panel.on('input change', '#cwb-auto-update-threshold', autosaveAutoUpdateThreshold);
$panel.on('input change', '#cwb-scan-depth', autosaveScanDepth);
$panel.on('click', '#cwb-load-models', () => fetchModelsAndConnect($panel));
$panel.on('click', '#cwb-save-break-armor-prompt', saveBreakArmorPrompt);
@@ -412,6 +449,30 @@ export function bindSettingsEvents($settingsPanel) {
$(document).trigger('cwb:master-switch-changed', { isEnabled: isChecked });
});
// 处理来自 API 配置面板总开关同步的 change 事件(该面板通过 dispatchEvent 设置 checkbox 状态)
// jQuery 的 .prop('checked') 不触发 change故与上方 click 处理器不会双重触发
$panel.on('change', '#cwb_master_enabled-checkbox', function () {
const isChecked = $(this).prop('checked');
getSettings().cwb_master_enabled = isChecked;
const overrides = JSON.parse(localStorage.getItem(CWB_BOOLEAN_SETTINGS_OVERRIDE_KEY) || '{}');
overrides.cwb_master_enabled = isChecked;
localStorage.setItem(CWB_BOOLEAN_SETTINGS_OVERRIDE_KEY, JSON.stringify(overrides));
state.masterEnabled = isChecked;
saveSettingsDebounced();
updateControlsLockState();
const $viewerButton = $(`#${CHAR_CARD_VIEWER_BUTTON_ID}`);
if ($viewerButton.length > 0) {
$viewerButton.toggle(isChecked && state.viewerEnabled);
}
showToastr('info', `CharacterWorldBook 已 ${isChecked ? '启用' : '禁用'}`);
$(document).trigger('cwb:master-switch-changed', { isEnabled: isChecked });
});
}
function updateApiModeUI(mode) {
@@ -489,7 +550,7 @@ function updateUiWithSettings() {
}
$panel.find('#cwb-api-url').val(settings.cwb_api_url);
$panel.find('#cwb-api-key').val(settings.cwb_api_key);
$panel.find('#cwb-api-key').val(configManager.get('cwb_api_key') || '');
$panel.find('#cwb-tavern-profile').val(settings.cwb_tavern_profile);
const $modelSelect = $panel.find('#cwb-api-model');
@@ -574,7 +635,7 @@ export function loadSettings() {
state.isIncrementalUpdateEnabled = finalSettings.cwb_incremental_update_enabled;
state.customApiConfig.url = finalSettings.cwb_api_url || '';
state.customApiConfig.apiKey = finalSettings.cwb_api_key || '';
state.customApiConfig.apiKey = configManager.get('cwb_api_key') || '';
state.customApiConfig.model = finalSettings.cwb_api_model || '';
state.currentBreakArmorPrompt = finalSettings.cwb_break_armor_prompt;

View File

@@ -7,6 +7,7 @@
import { extension_settings } from '/scripts/extensions.js';
import { saveSettingsDebounced } from '/script.js';
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { configManager } from '../../utils/config/ConfigManager.js';
const { jQuery: $, SillyTavern } = window;
@@ -675,8 +676,7 @@
$('#cwb-api-key').off('input').on('input', function() {
const value = $(this).val();
extension_settings[extensionName].cwb_api_key = value;
saveSettingsDebounced();
configManager.set('cwb_api_key', value);
});
$('#cwb-model').off('input').on('input', function() {

28
DEPLOY_NOTE.md Normal file
View File

@@ -0,0 +1,28 @@
# 部署更新日志
每个版本块格式:`## v{version}`Jenkins 构建时自动提取对应块作为 GitHub 提交说明。
---
## v2.2.2
### 新功能
- **Function Call 填表模式**:在填表设置中新增独立开关,启用后支持通过 OpenAI 兼容接口DeepSeek / OpenRouter / 各类中转等)直接返回结构化操作列表,绕过 `<Amily2Edit>` 文本解析路径,填表更稳定
- 遇到不支持 `tool_choice` 的接口时自动降级重试
- 对思考模型注入强制调用指令,防止绕过工具直接输出文本
- 全部走 ST 后端代理,修复 CSP 拦截直连外部 URL 的问题
- **主界面新增提示词链编辑器入口**,同时调换了记忆管理与角色世界书的按钮位置
- **规则中心**新增"自动排除用户楼层"选项
### 修复
- 提示词链按钮点击无响应(改为事件委托方式绑定)
- 拖拽组件微抖误触发(加 5px 移动阈值过滤)
- 填表检查窗若干问题修复;翰林院(批量回填)修复;防抖逻辑落地
- 角色世界书入口添加使用警告弹窗(强制 10 秒倒计时),提示该功能长期未维护
- ApiProfile `fakeStream` 字段保存丢失问题
- 正文优化默认改为关闭状态
- NGMS / NCCS API 配置槽位标签修正NGMS→总结NCCS→填表
- API Profile 面板选择逻辑统一重构,修复多处旧字段覆盖新配置的问题
- 世界书控制参数兼容性修复(排除递归、插入位置、扫描深度等,适配 ST 1.17.0+

154
DPS.drawio Normal file
View File

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

178
IAD.drawio Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
import { Module, ModuleBuilder } from './Module.js';
import { bindRuleConfigPanel } from '../../ui/rule-config-bindings.js';
const builder = new ModuleBuilder()
.name('RuleConfig')
.view('assets/rule-config-panel.html')
.strict(true)
.required(['mount']);
export default class RuleConfigModule extends Module {
constructor() {
super(builder);
}
async mount() {
if (this.el) {
this.el.id = 'amily2_rule_config_panel';
this.el.style.display = 'none';
}
bindRuleConfigPanel($(this.el));
}
}

View File

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

View File

@@ -16,7 +16,8 @@ export default class TableModule extends Module {
if (this.el) {
this.el.id = 'amily2_memorisation_forms_panel';
this.el.style.display = 'none';
this.el.dataset.module = 'TableModule';
}
bindTableEvents();
bindTableEvents(this.el);
}
}

View File

@@ -20,6 +20,7 @@ import GlossaryModule from './GlossaryModule.js';
import RendererModule from './RendererModule.js';
import SuperMemoryModule from './SuperMemoryModule.js';
import ApiConfigModule from './ApiConfigModule.js';
import RuleConfigModule from './RuleConfigModule.js';
import SfiGenModule from './SfiGenModule.js';
export function registerAllModules() {
@@ -34,5 +35,6 @@ export function registerAllModules() {
registry.register('Renderer', () => new RendererModule());
registry.register('SuperMemory', () => new SuperMemoryModule());
registry.register('ApiConfig', () => new ApiConfigModule());
registry.register('RuleConfig', () => new RuleConfigModule());
registry.register('SfiGen', () => new SfiGenModule());
}

35
TODO.md
View File

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

356
TODOList.md Normal file
View File

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

309
TableTODO.md Normal file
View File

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

View File

@@ -241,20 +241,12 @@
</button>
</div>
<div class="hly-control-block" style="flex-direction: row; justify-content: space-between; align-items: center; margin-top: 10px;">
<label for="historiography-tag-extraction-toggle">标签提取</label>
<label class="hly-toggle-switch">
<input type="checkbox" id="historiography-tag-extraction-toggle" data-setting-key="condensation.tagExtractionEnabled" data-type="boolean">
<span class="slider"></span>
</label>
</div>
<div id="historiography-tag-input-container" class="hly-control-block" style="display: none;">
<label for="historiography-tag-input">输入标签 (以逗号分隔):</label>
<textarea id="historiography-tag-input" class="hly-imperial-brush" rows="2" placeholder="例如: content,details" data-setting-key="condensation.tags" data-type="string"></textarea>
<div class="hly-control-block" style="margin-top: 10px;">
<label style="font-weight: bold;">提取规则配置</label>
<select id="historiography-rule-profile-select" class="hly-imperial-brush" style="width: 100%;"></select>
<small class="hly-notes">选择在「规则配置中心」里创建的提取规则,应用于微言录和宏史卷总结。</small>
</div>
<div class="hly-button-group" style="justify-content: flex-start; margin-top: 10px; display: flex; align-items: center; gap: 20px;">
<button id="historiography-exclusion-rules-btn" class="hly-action-button">内容排除</button>
<div class="auto-control-pair" style="margin-bottom: 0;">
<label for="historiography_auto_summary_interactive" title="开启后,“自动巡录”将弹出交互窗口确认,而不是在后台静默运行。">交互式巡录:</label>
<label class="toggle-switch">

View File

@@ -250,18 +250,19 @@
<input type="number" id="secondary-filler-max-retries" min="0" max="10" step="1" value="2" class="text_pole" style="width: 80px; margin-top: 5px;">
<small class="notes" style="margin-top: 5px; display: block;">分步填表失败时的自动重试次数 (0 = 不重试)。</small>
</div>
<!-- 触发延迟(防抖) -->
<div class="control-block-with-switch" style="margin-bottom: 10px; flex-direction: column; align-items: flex-start;">
<label for="secondary-filler-delay">触发延迟 (毫秒)</label>
<input type="number" id="secondary-filler-delay" min="0" max="60000" step="100" value="0" class="text_pole" style="width: 80px; margin-top: 5px;">
<small class="notes" style="margin-top: 5px; display: block;">收到新消息后延迟多少毫秒再触发分步填表 (0 = 立即触发);延迟期内若再次收到消息会重置计时,起到防抖作用。</small>
</div>
</div>
<div id="table-independent-rules-container" class="control-block-with-switch" style="margin-bottom: 10px; display: none; flex-direction: column; align-items: flex-start; gap: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<label for="table-independent-rules-enabled">启用独立提取规则</label>
<label class="toggle-switch">
<input type="checkbox" id="table-independent-rules-enabled">
<span class="slider"></span>
</label>
</div>
<button id="table-configure-rules-btn" class="menu_button small_button" style="display: none;"><i class="fas fa-cog"></i> 配置规则</button>
<small class="notes">启用后,分步填表和批量填表将使用下方配置的专属规则,而非微言录的规则。</small>
<div class="control-block-with-switch" style="margin-bottom: 10px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px;">
<label style="font-weight: bold;">提取规则配置</label>
<select id="table-rule-profile-select" class="text_pole" style="width: 100%;"></select>
<small class="notes">选择在「规则配置中心」里创建的提取规则,应用于分步填表和批量填表。未选择时使用默认行为。</small>
</div>
<div class="action-center-buttons" style="gap: 8px;">
<button id="amily2-open-relationship-graph-btn" class="menu_button accent small_button interactable"><i class="fas fa-project-diagram"></i> 关系图谱</button>
@@ -294,7 +295,19 @@
</fieldset>
<hr class="section-divider" style="margin: 10px 0;">
<!-- Function Call 填表 -->
<div class="control-block-with-switch" style="margin-bottom: 6px;">
<label for="table-fill-function-call-enabled" title="使用 OpenAI Function Call工具调用进行填表模型直接返回结构化操作列表无需解析 &lt;Amily2Edit&gt; 指令块。仅支持 openai 直连模式。">使用 Function Call 填表</label>
<label class="toggle-switch">
<input type="checkbox" id="table-fill-function-call-enabled">
<span class="slider"></span>
</label>
</div>
<p class="notes" style="margin-bottom: 10px;">仅支持 openai 直连接口tableFilling 槽位)。启用后跳过 &lt;Amily2Edit&gt; 文本解析,由模型直接返回操作列表。</p>
<hr class="section-divider" style="margin: 10px 0;">
<!-- Nccs API 控制区域 -->
<fieldset class="settings-group" style="border-style: dashed; padding: 8px; margin-bottom: 10px;">
<legend><i class="fas fa-brain"></i> Nccs API 系统</legend>

View File

@@ -251,19 +251,10 @@
</div>
</div>
<div class="hly-control-block" style="flex-direction: row; justify-content: space-between; align-items: center;">
<label for="hly-tag-extraction-toggle">标签提取</label>
<label class="hly-toggle-switch">
<input type="checkbox" id="hly-tag-extraction-toggle" data-setting-key="condensation.tagExtractionEnabled" data-type="boolean">
<span class="slider"></span>
</label>
</div>
<div id="hly-tag-input-container" class="hly-control-block" style="display: none;">
<label for="hly-tag-input">输入标签 (以逗号分隔):</label>
<textarea id="hly-tag-input" class="hly-imperial-brush" rows="2" placeholder="例如: content,details,摘要" data-setting-key="condensation.tags" data-type="string"></textarea>
</div>
<div class="hly-button-group" style="justify-content: flex-start;">
<button id="hly-exclusion-rules-btn" class="hly-action-button">内容排除</button>
<div class="hly-control-block" style="margin-top: 8px;">
<label style="font-weight: bold;">提取规则配置</label>
<select id="hly-condensation-rule-profile-select" class="hly-imperial-brush" style="width: 100%;"></select>
<small class="hly-notes">选择在「规则配置中心」里创建的提取规则,应用于浓缩处理。</small>
</div>
<div class="hly-button-group">
<button class="hly-action-button success" onclick="startHLYCondensation()"> 开始凝识</button>
@@ -405,7 +396,7 @@
</div>
</div>
<div id="hly-kb-list-local" class="hly-kb-list">
<p id="hly-kb-list-local-placeholder" style="color: #888;">当前角色没有知识库。通过“书库编纂中的功能可自动创建。</p>
<p id="hly-kb-list-local-placeholder" style="color: #888;">当前角色没有知识库。通过“书库编纂"中的功能可自动创建。</p>
<!-- Local KBs will be populated here -->
</div>
<div class="hly-button-group" style="margin-top: 15px;">
@@ -456,10 +447,11 @@
<span class="slider"></span>
</label>
</div>
<div class="hly-button-group" style="justify-content: flex-start;">
<button id="hly-query-preprocessing-rules-btn" class="hly-action-button">配置处理规则</button>
<div class="hly-control-block" style="margin-top: 8px;">
<label style="font-weight: bold;">处理规则配置</label>
<select id="hly-query-preprocessing-rule-profile-select" class="hly-imperial-brush" style="width: 100%;"></select>
<small class="hly-notes">选择在「规则配置中心」里创建的提取规则,应用于查询预处理(标签提取 + 内容排除)。</small>
</div>
<small class="hly-notes">此功能类似于“凝识法则”,可对您最近的几条聊天记录(即用于检索的文本)进行标签提取和内容排除,以生成更纯净、更高效的检索查询。</small>
</fieldset>
<fieldset class="hly-settings-group">

View File

@@ -212,14 +212,14 @@
<button id="amily2_open_additional_features" class="menu_button wide_button"><i class="fas fa-landmark-dome"></i> 总结模块</button>
<button id="amily2_open_rag_palace" class="menu_button wide_button"><i class="fas fa-brain"></i> 向量模块</button>
<button id="amily2_open_memorisation_forms" class="menu_button wide_button"><i class="fas fa-table"></i> 表格模块</button>
<button id="amily2_open_character_world_book" class="menu_button wide_button"><i class="fa-solid fa-book-atlas"></i> 角色世界</button>
<button id="amily2_open_plot_optimization" class="menu_button wide_button"><i class="fas fa-feather-alt"></i> 记忆管理</button>
</div>
</fieldset>
<fieldset class="settings-group">
<legend><i class="fas fa-puzzle-piece"></i> 附加功能</legend>
<div class="button-group" style="display: flex; justify-content: space-between; gap: 8px;">
<button id="amily2_open_plot_optimization" class="menu_button wide_button"><i class="fas fa-feather-alt"></i> 记忆管理</button>
<button id="amily2_open_character_world_book" class="menu_button wide_button"><i class="fa-solid fa-book-atlas"></i> 角色世界</button>
<button id="amily2_open_text_optimization" class="menu_button wide_button"><i class="fas fa-cogs"></i> 正文优化</button>
<button id="amily2_open_world_editor" class="menu_button wide_button"><i class="fas fa-globe"></i> 世界编辑</button>
<button id="amily2_open_glossary" class="menu_button wide_button"><i class="fas fa-book"></i> 术语表单</button>
@@ -227,6 +227,7 @@
<div class="button-group" style="display: flex; justify-content: space-between; gap: 8px; margin-top: 8px;">
<button id="amily2_open_renderer" class="menu_button wide_button"><i class="fas fa-paint-brush"></i> 前端渲染</button>
<button id="amily2_open_sfigen" class="menu_button wide_button"><i class="fas fa-image"></i> 硅基生图</button>
<button id="amily2_open_preset_editor" class="menu_button wide_button"><i class="fa-solid fa-scroll"></i> 提示词链</button>
</div>
</fieldset>

View File

@@ -2,8 +2,8 @@
<div class="additional-features-title">
<i class="fas fa-key"></i> API 连接配置
</div>
<button id="amily2_back_to_main_from_api_config" class="menu_button secondary small_button interactable">
返回主殿 <i class="fas fa-arrow-right"></i>
<button id="amily2_back_to_main_from_api_config" class="menu_button secondary small_button interactable amily2-vbtn">
<span class="vbtn-icon"><i class="fas fa-arrow-right"></i></span><span class="vbtn-label">返回主殿</span>
</button>
</div>
<hr class="header-divider" style="margin-top: 5px; margin-bottom: 10px;">
@@ -26,10 +26,19 @@
<label>当前密钥对指纹</label>
<div style="display:flex; gap:6px; align-items:center;">
<code id="amily2_keypair_fingerprint" style="flex:1; padding:4px 8px; background:var(--black30a); border-radius:4px; font-size:0.85em;">(未生成)</code>
<button id="amily2_generate_keypair" class="menu_button interactable small_button" title="生成新密钥对(会清除所有已加密的 Key">
<i class="fas fa-sync-alt"></i> 重新生成
<button id="amily2_generate_keypair" class="menu_button interactable small_button amily2-vbtn" title="生成新密钥对(会清除所有已加密的 Key">
<span class="vbtn-icon"><i class="fas fa-sync-alt"></i></span><span class="vbtn-label">重新生成</span>
</button>
</div>
<div style="display:flex; gap:6px; margin-top:6px; flex-wrap:wrap;">
<button id="amily2_export_key_bundle" class="menu_button interactable small_button amily2-vbtn" title="导出当前设备的私钥包,用于新设备恢复解密权限">
<span class="vbtn-icon"><i class="fas fa-download"></i></span><span class="vbtn-label">导出私钥</span>
</button>
<button id="amily2_import_key_bundle" class="menu_button interactable small_button amily2-vbtn" title="导入先前导出的私钥包,恢复云同步密钥的解密能力">
<span class="vbtn-icon"><i class="fas fa-upload"></i></span><span class="vbtn-label">导入私钥</span>
</button>
<input id="amily2_import_key_bundle_input" type="file" accept=".json,application/json" style="display:none;" />
</div>
<small class="notes" style="color: var(--warning-color);">
⚠️ 重新生成密钥对后,所有已加密存储的 API Key 将失效,需重新输入。
</small>
@@ -43,17 +52,17 @@
<div style="display:flex; gap:6px; margin-bottom:10px; flex-wrap:wrap;">
<button class="menu_button small_button amily2_profile_type_filter active" data-type="all">全部</button>
<button class="menu_button small_button amily2_profile_type_filter" data-type="chat">
<i class="fas fa-comments"></i> 对话模型
<button class="menu_button small_button amily2_profile_type_filter amily2-vbtn" data-type="chat">
<span class="vbtn-icon"><i class="fas fa-comments"></i></span><span class="vbtn-label">对话模型</span>
</button>
<button class="menu_button small_button amily2_profile_type_filter" data-type="embedding">
<i class="fas fa-project-diagram"></i> 向量嵌入
<button class="menu_button small_button amily2_profile_type_filter amily2-vbtn" data-type="embedding">
<span class="vbtn-icon"><i class="fas fa-project-diagram"></i></span><span class="vbtn-label">向量嵌入</span>
</button>
<button class="menu_button small_button amily2_profile_type_filter" data-type="rerank">
<i class="fas fa-sort-amount-down"></i> 重排序
<button class="menu_button small_button amily2_profile_type_filter amily2-vbtn" data-type="rerank">
<span class="vbtn-icon"><i class="fas fa-sort-amount-down"></i></span><span class="vbtn-label">重排序</span>
</button>
<button id="amily2_add_profile" class="menu_button small_button interactable" style="margin-left:auto;">
<i class="fas fa-plus"></i> 新建配置
<button id="amily2_add_profile" class="menu_button small_button interactable amily2-vbtn" style="margin-left:auto;">
<span class="vbtn-icon"><i class="fas fa-plus"></i></span><span class="vbtn-label">新建配置</span>
</button>
</div>
@@ -74,6 +83,20 @@
</div>
</fieldset>
<!-- 旧配置清理 -->
<fieldset class="settings-group">
<legend><i class="fas fa-broom"></i> 旧配置清理</legend>
<small class="notes" style="display:block; margin-bottom:10px;">
旧版各模块独立的 API 配置URL / Key / Model / 温度等)已自动迁移到上方"连接配置"。
若上方分配无误且使用一切正常,可点击下方按钮清除 <code>extension_settings</code> 中的旧字段
<code>localStorage</code> 中的旧 API Key避免残留卡 bug。<br/>
<strong>清理前会校验所有旧字段所属槽位都已分配 profile</strong>,未分配的槽位会阻止清理并提示。
</small>
<button id="amily2_clear_legacy_config" class="menu_button caution interactable small_button amily2-vbtn">
<span class="vbtn-icon"><i class="fas fa-trash-alt"></i></span><span class="vbtn-label">清除旧配置残留</span>
</button>
</fieldset>
<!-- 新建/编辑 Profile 表单details 折叠) -->
<details id="amily2_profile_form_details" class="settings-group amily2-profile-form">
<summary>
@@ -100,22 +123,33 @@
<div class="amily2_settings_block">
<label for="amily2_pf_provider">接口类型</label>
<select id="amily2_pf_provider" class="text_pole">
<option value="openai">OpenAI / 兼容接口(推荐)</option>
<option value="google">Google Gemini 直连</option>
<option value="sillytavern_backend">SillyTavern 后端代理</option>
<option value="sillytavern_preset">SillyTavern 预设转发</option>
<optgroup label="官方预设(自动填默认 URL">
<option value="openai">OpenAI (GPT)</option>
<option value="anthropic">Anthropic Claude</option>
<option value="google">Google Gemini</option>
<option value="openrouter">OpenRouter (聚合)</option>
<option value="deepseek">DeepSeek</option>
<option value="xai">xAI Grok</option>
</optgroup>
<optgroup label="自定义 / 高级">
<option value="custom_oai">Custom OpenAI 兼容(自填 URL</option>
<option value="sillytavern_backend">SillyTavern 后端代理</option>
<option value="sillytavern_preset">SillyTavern 预设转发</option>
</optgroup>
</select>
</div>
<div class="amily2_settings_block" id="amily2_pf_url_row">
<label for="amily2_pf_url">API 地址</label>
<input id="amily2_pf_url" type="text" class="text_pole" placeholder="https://api.example.com/v1" />
</div>
<!-- Google 专属提示 -->
<div id="amily2_pf_google_note" style="display:none; margin-bottom:8px;">
<small class="notes" style="display:block; padding:6px 10px; background:var(--black10a); border-radius:4px; border-left:3px solid #4285f4;">
<i class="fas fa-info-circle" style="color:#4285f4;"></i>
Google AI Studio — 接口地址已自动配置,只需填写 API Key 即可。
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener" style="color:#4285f4;">aistudio.google.com</a> 生成密钥。
<!-- Vendor 提示(动态内容由 JS 填充) -->
<div id="amily2_pf_vendor_note" style="display:none; margin-bottom:8px;">
<small class="notes" style="display:block; padding:6px 10px; background:var(--black10a); border-radius:4px; border-left:3px solid var(--SmartThemeQuoteColor);">
<i class="fas fa-info-circle"></i>
<span id="amily2_pf_vendor_note_text"></span>
<span id="amily2_pf_vendor_note_link_wrap" style="display:none;">
<a id="amily2_pf_vendor_note_link" href="#" target="_blank" rel="noopener" style="color:var(--SmartThemeQuoteColor);"></a>
</span>
</small>
</div>
<div class="amily2_settings_block">
@@ -130,16 +164,16 @@
<div style="display:flex; gap:6px; align-items:stretch;">
<input id="amily2_pf_model" type="text" class="text_pole" placeholder="手动填写或点击「获取」" style="flex:1;" />
<select id="amily2_pf_model_select" class="text_pole" style="flex:1; display:none;"></select>
<button id="amily2_pf_fetch_models" class="menu_button small_button interactable" type="button" title="从 API 获取可用模型列表(需先填写地址和 Key">
<i class="fas fa-list"></i> 获取
<button id="amily2_pf_fetch_models" class="menu_button small_button interactable amily2-vbtn" type="button" title="从 API 获取可用模型列表(需先填写地址和 Key">
<span class="vbtn-icon"><i class="fas fa-list"></i></span><span class="vbtn-label">获取</span>
</button>
</div>
</div>
<!-- 测试连接 -->
<div style="display:flex; align-items:center; gap:10px; margin-bottom:10px;">
<button id="amily2_pf_test_conn" class="menu_button small_button interactable" type="button">
<i class="fas fa-plug"></i> 测试连接
<button id="amily2_pf_test_conn" class="menu_button small_button interactable amily2-vbtn" type="button">
<span class="vbtn-icon"><i class="fas fa-plug"></i></span><span class="vbtn-label">测试连接</span>
</button>
<span id="amily2_pf_test_result" style="font-size:0.85em;"></span>
</div>
@@ -159,6 +193,37 @@
<label for="amily2_pf_temperature">温度Temperature</label>
<input id="amily2_pf_temperature" type="number" class="text_pole" min="0" max="2" step="0.1" value="1.0" />
</div>
<div class="amily2_settings_block" style="flex-direction:row; align-items:center; gap:8px;">
<input id="amily2_pf_fake_stream" type="checkbox" />
<label for="amily2_pf_fake_stream">
启用假流式(防 CF 超时)
<small class="notes" style="display:block; font-weight:normal;">以 stream:true 接收 SSE 后拼接,适用于经 CloudFlare 免费代理的接口</small>
</label>
</div>
<div class="amily2_settings_block">
<label for="amily2_pf_custom_params">
自定义参数 (JSON)
<small class="notes" style="display:block; font-weight:normal;">
透传到 LLM 请求 body 的额外参数。留空或 <code>{}</code> 表示不附加。
</small>
</label>
<textarea id="amily2_pf_custom_params"
class="text_pole"
rows="6"
spellcheck="false"
style="font-family:monospace; font-size:0.85em;"
placeholder='{
"top_p": 0.9,
"frequency_penalty": 0.3,
"stop": ["\n###"]
}'></textarea>
<small id="amily2_pf_custom_params_hint"
class="notes"
style="display:block; margin-top:4px; color:var(--SmartThemeQuoteColor); font-size:0.82em;"></small>
<small id="amily2_pf_custom_params_error"
class="notes"
style="display:none; margin-top:4px; color:var(--warning, #d9534f); font-size:0.82em;"></small>
</div>
</div>
</details>
</div>
@@ -206,11 +271,11 @@
<!-- 操作按钮 -->
<div style="display:flex; gap:8px; margin-top:16px;">
<button id="amily2_profile_modal_cancel" class="menu_button secondary interactable">
<i class="fas fa-times"></i> 取消
<button id="amily2_profile_modal_cancel" class="menu_button secondary interactable amily2-vbtn">
<span class="vbtn-icon"><i class="fas fa-times"></i></span><span class="vbtn-label">取消</span>
</button>
<button id="amily2_profile_modal_save" class="menu_button interactable">
<i class="fas fa-save"></i> 保存
<button id="amily2_profile_modal_save" class="menu_button interactable amily2-vbtn">
<span class="vbtn-icon"><i class="fas fa-save"></i></span><span class="vbtn-label">保存</span>
</button>
</div>
</div>

View File

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

View File

@@ -0,0 +1,84 @@
<div class="settings-group" id="amily2_rule_config_panel_root">
<fieldset class="settings-group">
<legend><i class="fas fa-list-check"></i> 规则配置中心</legend>
<div class="amily2-rule-layout">
<div class="amily2-rule-sidebar">
<div style="display:flex; gap:8px; margin-bottom:10px;">
<button id="amily2_rule_profile_new" class="menu_button small_button amily2-vbtn"><span class="vbtn-icon"><i class="fas fa-plus"></i></span><span class="vbtn-label">新建</span></button>
</div>
<div id="amily2_rule_profile_list" style="display:flex; flex-direction:column; gap:8px;"></div>
</div>
<div class="amily2-rule-main">
<div class="amily2_settings_block">
<label for="amily2_rule_profile_name">配置名称</label>
<input id="amily2_rule_profile_name" class="text_pole" type="text" placeholder="例如:通用提取规则">
</div>
<div class="amily2_settings_block" style="margin-top:10px;">
<label><input id="amily2_rule_profile_tag_toggle" type="checkbox"> 启用标签提取</label>
</div>
<div class="amily2_settings_block" style="margin-top:10px;">
<label><input id="amily2_rule_profile_exclude_user" type="checkbox"> 自动排除用户楼层</label>
<small class="notes" style="display:block; margin-top:4px;">勾选后,使用此规则时将自动跳过用户发送的消息楼层,不纳入总结/提取内容。</small>
</div>
<div id="amily2_rule_profile_tags_wrap" class="amily2_settings_block" style="display:none; margin-top:10px;">
<label for="amily2_rule_profile_tags">标签列表</label>
<textarea id="amily2_rule_profile_tags" class="text_pole" rows="3" placeholder="例如content,details,summary"></textarea>
</div>
<div class="amily2_settings_block" style="margin-top:10px;">
<label>排除规则</label>
<div id="amily2_rule_profile_rules" style="display:flex; flex-direction:column; gap:8px; margin:8px 0;"></div>
<button id="amily2_rule_profile_add_rule" class="menu_button small_button amily2-vbtn"><span class="vbtn-icon"><i class="fas fa-plus"></i></span><span class="vbtn-label">添加规则</span></button>
</div>
<div class="amily2-rule-actions">
<button id="amily2_rule_profile_save" class="menu_button menu_button_primary amily2-vbtn"><span class="vbtn-icon"><i class="fas fa-save"></i></span><span class="vbtn-label">保存</span></button>
<button id="amily2_rule_profile_delete" class="menu_button danger amily2-vbtn"><span class="vbtn-icon"><i class="fas fa-trash-alt"></i></span><span class="vbtn-label">删除</span></button>
<button id="amily2_back_to_main_from_rule_config" class="menu_button amily2-vbtn"><span class="vbtn-icon"><i class="fas fa-arrow-left"></i></span><span class="vbtn-label">返回</span></button>
</div>
</div>
</div>
</fieldset>
</div>
<style>
#amily2_rule_config_panel .amily2-rule-row,
#amily2_rule_config_panel_root .amily2-rule-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 8px;
align-items: center;
}
#amily2_rule_config_panel_root .amily2-rule-layout {
display: flex;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
}
#amily2_rule_config_panel_root .amily2-rule-sidebar {
width: 260px;
flex-shrink: 0;
}
#amily2_rule_config_panel_root .amily2-rule-main {
flex: 1;
min-width: 0;
}
#amily2_rule_config_panel_root .amily2-rule-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
@media (max-width: 768px) {
#amily2_rule_config_panel_root .amily2-rule-sidebar {
width: 100%;
}
#amily2_rule_config_panel_root .amily2-rule-actions > .amily2-vbtn {
flex: 1 1 calc(33.333% - 8px);
min-width: 72px;
}
#amily2_rule_config_panel_root .amily2-rule-row {
grid-template-columns: 1fr 1fr !important;
}
#amily2_rule_config_panel_root .amily2-rule-row > :last-child {
grid-column: 1 / -1;
}
}
</style>

View File

@@ -772,3 +772,18 @@ hr.header-divider {
padding-bottom: 8px;
border-bottom: 1px solid var(--SmartThemeBorderColor);
}
/* 图标在上、文字在下的垂直按钮 */
.amily2-vbtn {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
padding: 8px 12px;
min-width: 56px;
text-align: center;
line-height: 1;
}
.amily2-vbtn .vbtn-icon { font-size: 1.15em; }
.amily2-vbtn .vbtn-label { font-size: 0.82em; white-space: nowrap; }

View File

@@ -179,9 +179,12 @@ class Amily2Updater {
if (this.compareVersions(this.latestVersion, this.currentVersion) > 0) {
$updateIndicator.show();
$updateButton.attr('title', `发现新版本 ${this.latestVersion}!点击查看详情`);
const safeVersion = /^[\w.+\-]{1,40}$/.test(String(this.latestVersion ?? '')) ? this.latestVersion : '未知';
$updateButtonNew
.show()
.html(`<i class="fas fa-gift"></i> 新版 ${this.latestVersion}`)
.empty()
.append($('<i>').addClass('fas fa-gift'))
.append(document.createTextNode(` 新版 ${safeVersion}`))
.off('click')
.on('click', () => this.showUpdateConfirmDialog());
} else {

View File

@@ -1,6 +1,7 @@
import { extension_settings, getContext } from "/scripts/extensions.js";
import { characters } from "/script.js";
import { getSlotProfile } from './api/api-resolver.js';
import { getSlotProfile, providerToApiMode } from './api/api-resolver.js';
import { configManager } from '../utils/config/ConfigManager.js';
import { world_names } from "/scripts/world-info.js";
import { extensionName } from "../utils/settings.js";
import { extractContentByTag, replaceContentByTag, extractFullTagBlock } from '../utils/tagProcessor.js';
@@ -329,10 +330,12 @@ async function fetchGoogleDirectModels(apiUrl, apiKey) {
const GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com';
const fetchGoogleModels = async (version) => {
const url = `${GOOGLE_API_BASE_URL}/${version}/models?key=${apiKey}`;
const url = `${GOOGLE_API_BASE_URL}/${version}/models`;
console.log(`[Amily2号-使节团] 正在从 Google API (${version}) 获取模型列表: ${url}`);
const response = await fetch(url);
const response = await fetch(url, {
headers: { 'x-goog-api-key': apiKey },
});
if (!response.ok) {
console.warn(`获取 Google API (${version}) 模型列表失败: ${response.status}`);
return [];
@@ -438,23 +441,59 @@ async function fetchSillyTavernPresetModels() {
export async function getApiSettings(slot = 'main') {
const s = extension_settings[extensionName] || {};
// 优先读取槽位分配的 Profile仅接管连接参数
// 优先读取槽位分配的 Profileprofile 一旦分配即为权威,不再被主面板/模块独立设置压制
const profile = await getSlotProfile(slot);
if (profile) {
const resolvedProvider = profile.provider === 'sillytavern_backend'
? 'sillytavern_backend'
: providerToApiMode(profile.provider);
return {
apiProvider: profile.provider,
apiProvider: resolvedProvider,
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: '',
};
}
// 降级:读旧 DOM 面板配置
// 降级:按槽位读取各自的独立配置
const settings = extension_settings[extensionName] || {};
// plotOpt 槽有独立 API 面板(剧情优化),优先读其专属设置
if (slot === 'plotOpt') {
const apiMode = settings.plotOpt_apiMode || 'openai_test';
if (apiMode === 'sillytavern_preset') {
const context = getContext();
const profileId = settings.plotOpt_tavernProfile || '';
const stProfile = context.extensionSettings?.connectionManager?.profiles?.find(p => p.id === profileId);
return {
apiProvider: 'sillytavern_preset',
apiUrl: '',
apiKey: '',
model: stProfile?.openai_model || 'Preset Model',
maxTokens: settings.plotOpt_max_tokens ?? 65500,
temperature: settings.plotOpt_temperature ?? 1.0,
tavernProfile: profileId,
};
}
return {
apiProvider: apiMode,
apiUrl: settings.plotOpt_apiUrl?.trim() || '',
apiKey: configManager.get('plotOpt_apiKey') || '',
model: document.getElementById('amily2_opt_model')?.value?.trim()
|| settings.plotOpt_model || '',
maxTokens: settings.plotOpt_max_tokens ?? 65500,
temperature: settings.plotOpt_temperature ?? 1.0,
tavernProfile: '',
};
}
// main 槽(及其余未明确处理的槽):读主面板 DOM 配置
const apiProvider = document.getElementById('amily2_api_provider')?.value || 'openai';
let model;
@@ -489,13 +528,15 @@ export async function testApiConnection() {
try {
const apiSettings = await getApiSettings();
const apiProvider = apiSettings.apiProvider || 'openai';
const requiresApiKey = !['sillytavern_backend', 'sillytavern_preset'].includes(apiProvider);
if (apiSettings.apiProvider === 'sillytavern_preset') {
if (apiProvider === 'sillytavern_preset') {
if (!apiSettings.tavernProfile) {
throw new Error("请先在下方选择一个SillyTavern预设");
}
} else {
if (!apiSettings.apiUrl || !apiSettings.apiKey || !apiSettings.model) {
if (!apiSettings.apiUrl || !apiSettings.model) {
throw new Error("API配置不完整请检查URL、Key和模型选择");
}
}
@@ -547,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') {
@@ -639,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,
})
});
@@ -658,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,
@@ -666,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', {
@@ -706,12 +759,13 @@ async function callGoogleDirect(messages, options) {
const GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com';
const apiVersion = options.model.includes('gemini-1.5') ? 'v1beta' : 'v1';
const finalApiUrl = `${GOOGLE_API_BASE_URL}/${apiVersion}/models/${options.model}:generateContent?key=${options.apiKey}`;
const finalApiUrl = `${GOOGLE_API_BASE_URL}/${apiVersion}/models/${options.model}:generateContent`;
console.log(`[Amily2号-Google直连] API地址: ${finalApiUrl}`);
const headers = {
"Content-Type": "application/json"
const headers = {
"Content-Type": "application/json",
"x-goog-api-key": options.apiKey,
};
const requestBody = JSON.stringify(convertToGoogleRequest({
@@ -774,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,
@@ -781,7 +838,7 @@ async function callSillyTavernBackend(messages, options) {
messages: messages,
max_tokens: options.maxTokens,
temperature: options.temperature,
stream: false
stream: false,
})
});
@@ -892,3 +949,111 @@ export async function checkAndFixWithAPI(latestMessage, previousMessages) {
const { processOptimization } = await import('./summarizer.js');
return await processOptimization(latestMessage, previousMessages);
}
/**
* 使用 OpenAI Function Call 调用 AI返回 tool_calls[0].function.arguments 字符串。
* 仅支持 openai / openai_test 接口Google / ST preset / backend 不在标准 tool_calls 格式下工作)。
*
* @param {Array} messages
* @param {Object} tool - OpenAI tools 定义对象(单个,含 type/function 字段)
* @param {Object} options - 同 callAI 的 options支持 slot / customParams 等
* @returns {Promise<string|null>} arguments JSON 字符串,失败返回 null
*/
export async function callAIForTools(messages, tool, options = {}) {
const apiSettings = await getApiSettings(options.slot || 'main');
const finalOptions = {
maxTokens: apiSettings.maxTokens,
temperature: apiSettings.temperature,
model: apiSettings.model,
apiUrl: apiSettings.apiUrl,
apiKey: apiSettings.apiKey,
apiProvider: apiSettings.apiProvider,
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
...options,
};
const FC_SUPPORTED_PROVIDERS = new Set(['openai', 'openai_test', 'custom_oai', 'openrouter', 'deepseek', 'xai']);
if (!FC_SUPPORTED_PROVIDERS.has(finalOptions.apiProvider)) {
console.warn(`[Amily2-外交部] Function Call 不支持当前接口类型: ${finalOptions.apiProvider}`);
toastr.warning(`当前 API 接口类型(${finalOptions.apiProvider})不支持 Function Call。`, 'Function Call');
return null;
}
if (!finalOptions.apiUrl || !finalOptions.model) {
console.warn('[Amily2-外交部] API URL 或模型未配置,无法调用 Function Call AI');
toastr.error('API URL 或模型未配置。', 'Amily2-外交部');
return null;
}
const buildFCBody = (withToolChoice, overrideMessages) => ({
chat_completion_source: 'openai',
reverse_proxy: finalOptions.apiUrl,
proxy_password: finalOptions.apiKey,
model: finalOptions.model,
messages: overrideMessages ?? messages,
max_tokens: finalOptions.maxTokens || 30000,
temperature: finalOptions.temperature ?? 1,
stream: false,
...(finalOptions.customParams || {}),
tools: [tool],
...(withToolChoice ? { tool_choice: { type: 'function', function: { name: tool.function.name } } } : {}),
});
const doFCRequest = async (withToolChoice, overrideMessages) => {
const response = await fetch('/api/backends/chat-completions/generate', {
method: 'POST',
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(buildFCBody(withToolChoice, overrideMessages)),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Function Call 请求失败: ${response.status} - ${errorText}`);
}
const data = await response.json();
// ST 代理在上游报错时仍返回 HTTP 200错误信息在 body 里
if (data?.error) {
throw new Error(`Function Call 请求失败: ${JSON.stringify(data.error)}`);
}
return data;
};
try {
console.groupCollapsed(`[Amily2号-Function Call] ${new Date().toLocaleTimeString()}`);
console.log('【工具】:', tool.function?.name, '【模型】:', finalOptions.model);
console.log('【消息】:', messages);
console.groupEnd();
let data;
try {
// 走 ST 后端代理,避免浏览器 CSP 拦截直连外部 URL
data = await doFCRequest(true);
} catch (firstError) {
// 首次失败(含 ST 代理吞掉错误码场景)无条件去掉 tool_choice 重试一次
// 思考模式模型支持 tools 但不支持强制 tool_choice追加强制指令防止模型直接输出文本
console.warn('[Amily2-外交部] 首次 FC 请求失败,去掉 tool_choice 重试…', firstError.message);
const retryMessages = [
...messages,
{ role: 'user', content: `你必须通过调用 \`${tool.function.name}\` 函数来返回结果,禁止直接输出文本内容。` },
];
data = await doFCRequest(false, retryMessages);
}
const toolCalls = data?.choices?.[0]?.message?.tool_calls;
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
console.warn('[Amily2-外交部] Function Call 响应中无 tool_callsfinish_reason:', data?.choices?.[0]?.finish_reason);
return null;
}
const argsString = toolCalls[0]?.function?.arguments;
console.groupCollapsed('[Amily2号-Function Call 响应]');
console.log(argsString);
console.groupEnd();
return argsString ?? null;
} catch (error) {
console.error('[Amily2-外交部] Function Call 调用失败:', error);
toastr.error(`Function Call 调用失败: ${error.message}`, 'Amily2-外交部');
return null;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -485,7 +485,8 @@ Example:
.replace(/<\/thinking>/gi, '');
const toolNames = Object.keys(tools);
const toolRegex = new RegExp(`<(${toolNames.join('|')})(?:\\s+[^>]*)?>[\\s\\S]*?<\\/\\1>`, 'gi');
const escapedToolNames = toolNames.map(n => String(n).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const toolRegex = new RegExp(`<(${escapedToolNames.join('|')})(?:\\s+[^>]*)?>[\\s\\S]*?<\\/\\1>`, 'gi');
cleanContent = cleanContent.replace(toolRegex, '').trim();
if (cleanContent) {

View File

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

View File

@@ -15,9 +15,10 @@ 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";
let reloadEditor = () => {
console.warn("[大史官] reloadEditor 函数不可用,可能是旧版本。已使用空函数代替。");
@@ -302,11 +303,15 @@ function getRawMessagesForSummary(startFloor, endFloor) {
const userName = context.name1 || '用户';
const characterName = context.name2 || '角色';
const useTagExtraction = settings.historiographyTagExtractionEnabled ?? false;
const tagsToExtract = useTagExtraction ? (settings.historiographyTags || '').split(',').map(t => t.trim()).filter(Boolean) : [];
const exclusionRules = settings.historiographyExclusionRules || [];
const historiographyRuleConfig = resolveHistoriographyRuleConfig(settings);
const useTagExtraction = historiographyRuleConfig.tagExtractionEnabled ?? false;
const tagsToExtract = useTagExtraction ? (historiographyRuleConfig.tags || '').split(',').map(t => t.trim()).filter(Boolean) : [];
const exclusionRules = historiographyRuleConfig.exclusionRules || [];
const excludeUserMessages = historiographyRuleConfig.excludeUserMessages ?? false;
const messages = historySlice.map((msg, index) => {
if (excludeUserMessages && msg.is_user) return null;
let content = msg.mes;
if (useTagExtraction && tagsToExtract.length > 0) {
@@ -317,7 +322,7 @@ function getRawMessagesForSummary(startFloor, endFloor) {
}
content = applyExclusionRules(content, exclusionRules);
if (!content.trim()) return null;
return {
@@ -382,7 +387,9 @@ async function getSummary(formattedHistory, toastTitle, retryCount = 0) {
}
}
const summary = settings.ngmsEnabled ? await callNgmsAI(messages) : await callAI(messages);
// 历史总结统一走 NGMS slotngms 未配置时 callNgmsAI 自带模块名错误提示。
// 旧 ngmsEnabled 三元式 fallback 到 main 的设计已在主 API 移除后失效。
const summary = await callNgmsAI(messages);
console.log('[大史官-微言录] AI回复的全部内容:', summary);
if (!summary || !summary.trim()) {
@@ -601,7 +608,8 @@ export async function executeRefinement(worldbook, loreKey) {
const getRefinedContent = async (retryCount = 0) => {
toastr.info("正在召唤模型进行内容精炼...", "宏史卷重铸");
const content = settings.ngmsEnabled ? await callNgmsAI(messages) : await callAI(messages);
// 历史总结统一走 NGMS slotngms 未配置时 callNgmsAI 自带错误提示。
const content = await callNgmsAI(messages);
if (!content || !content.trim()) {
const maxRetries = settings.historiographyMaxRetries ?? 2;

View File

@@ -329,7 +329,7 @@ export async function getPlotOptimizedWorldbookContent(context, apiSettings, isC
selectedWorldbooks: apiSettings.plotOpt_selectedWorldbooks,
autoSelectWorldbooks: apiSettings.plotOpt_autoSelectWorldbooks || [],
worldbookCharLimit: apiSettings.plotOpt_worldbookCharLimit,
contextLimit: apiSettings.plotOpt_contextLimit || 5,
contextLimit: apiSettings.plotOpt_contextLimit ?? apiSettings.plotOpt_contextTurnCount ?? 5,
enabledWorldbookEntries: apiSettings.plotOpt_enabledWorldbookEntries,
};
}

View File

@@ -9,20 +9,20 @@ import {
buildGoogleEmbeddingApiUrl
} from './utils/googleAdapter.js';
import { getSlotProfile } from './api/api-resolver.js';
import { extensionName } from '../utils/settings.js';
const MODULE_NAME = 'hanlinyuan-rag-core';
const GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com';
function getSettings() {
const context = SillyTavern.getContext();
if (!context || !context.extensionSettings || !context.extensionSettings[MODULE_NAME]) {
console.error('[翰林院-API] 无法获取设置API调用可能失败。');
return {
retrieval: {},
rerank: {}
};
}
return context.extensionSettings[MODULE_NAME];
const root = extension_settings[extensionName];
const nested = root && root[MODULE_NAME];
if (nested) return nested;
// 读侧兼容:若迁移尚未触发(极早期调用),回退至旧顶层位置,避免空配置。
const legacy = extension_settings[MODULE_NAME];
if (legacy) return legacy;
console.error('[翰林院-API] 无法获取设置API调用可能失败。');
return { retrieval: {}, rerank: {} };
}
/**
@@ -33,7 +33,7 @@ export async function getEmbedRetrievalSettings() {
const profile = await getSlotProfile('ragEmbed');
if (profile) {
return {
apiEndpoint: 'custom',
apiEndpoint: profile.provider === 'google' ? 'google_direct' : 'custom',
customApiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
embeddingModel: profile.model,
@@ -112,9 +112,11 @@ export async function fetchEmbeddingModels(overrideSettings = null) {
if (!apiKey) throw new Error("Google直连模式需要API Key。");
const fetchGoogleModels = async (version) => {
const url = `${GOOGLE_API_BASE_URL}/${version}/models?key=${apiKey}`;
const url = `${GOOGLE_API_BASE_URL}/${version}/models`;
console.log(`[翰林院] 正在从 Google API (${version}) 获取模型列表: ${url}`);
const response = await fetch(url);
const response = await fetch(url, {
headers: { 'x-goog-api-key': apiKey },
});
if (!response.ok) {
console.warn(`获取 Google API (${version}) 模型列表失败: ${response.status}`);
return [];
@@ -345,8 +347,8 @@ export async function getEmbeddings(texts, signal = null) {
console.log('[翰林院-API] 使用Google直连模式获取向量。');
if (!apiKey) throw new Error('Google直连模式需要API Key。');
// 使用适配器构建URL和请求体
const googleUrl = `${buildGoogleEmbeddingApiUrl(GOOGLE_API_BASE_URL, embeddingModel)}?key=${apiKey}`;
// 使用适配器构建URL和请求体Key 通过 x-goog-api-key 头传递避免 URL 泄露
const googleUrl = buildGoogleEmbeddingApiUrl(GOOGLE_API_BASE_URL, embeddingModel);
const googleBody = buildGoogleEmbeddingRequest(batch, embeddingModel);
console.log(`[翰林院-API] 发送到 Google API 的请求 URL: ${googleUrl}`);
@@ -356,6 +358,7 @@ export async function getEmbeddings(texts, signal = null) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify(googleBody),
signal: signal,

View File

@@ -4,13 +4,17 @@ import {
extension_prompt_roles,
setExtensionPrompt,
eventSource,
event_types
event_types,
saveSettingsDebounced
} from '/script.js';
import { extension_settings } from '/scripts/extensions.js';
import * as ContextUtils from './utils/context-utils.js';
import { getCollectionIdInfo, getCharacterId, getCharacterStableId } from './utils/context-utils.js';
import { defaultSettings as ragDefaultSettings } from './rag-settings.js';
import { extractBlocksByTags, applyExclusionRules } from './utils/rag-tag-extractor.js';
import { resolveQueryPreprocessingRuleConfig } from '../utils/config/RuleProfileManager.js';
import { extensionName } from '../utils/settings.js';
import * as IngestionManager from './ingestion-manager.js';
import {
getEmbeddings,
@@ -79,12 +83,23 @@ function containsPinyinMatch(text, query) {
function highlightSearchMatch(text, query) {
const safeText = String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
if (!query || !query.trim()) {
return text;
return safeText;
}
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark class="search-highlight">$1</mark>');
const safeQuery = String(query)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const regex = new RegExp(`(${safeQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return safeText.replace(regex, '<mark class="search-highlight">$1</mark>');
}
function debounce(func, wait) {
@@ -136,6 +151,7 @@ function initialize() {
console.error('[翰林院] 未能获取SillyTavern上下文初始化失败。');
return;
}
migrateLegacyRagSettings();
settings = getSettings();
if (!window.hanlinyuanRagProcessor) {
window.hanlinyuanRagProcessor = {};
@@ -284,17 +300,16 @@ async function ingestTextToHanlinyuan(text, source = 'manual', metadata = {}, pr
}
function getSettings() {
if (!context || !context.extensionSettings) {
return structuredClone(ragDefaultSettings);
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
const root = extension_settings[extensionName];
let s = context.extensionSettings[MODULE_NAME];
let s = root[MODULE_NAME];
if (!s) {
s = {};
context.extensionSettings[MODULE_NAME] = s;
root[MODULE_NAME] = s;
}
if (s.condensationHistory === undefined) {
@@ -331,16 +346,49 @@ function getSettings() {
}
function saveSettings() {
if (context) context.saveSettingsDebounced();
saveSettingsDebounced();
}
function resetSettings() {
if (context) {
context.extensionSettings[MODULE_NAME] = structuredClone(ragDefaultSettings);
saveSettings();
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
extension_settings[extensionName][MODULE_NAME] = structuredClone(ragDefaultSettings);
saveSettings();
}
function migrateLegacyRagSettings() {
const legacy = extension_settings[MODULE_NAME];
if (!legacy || typeof legacy !== 'object') return;
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
const root = extension_settings[extensionName];
// legacy 是用户此前实际交互过的真数据来源nested 可能已被 super-memory 等模块用默认值填过,
// 因此采用 legacy-优先的深合并legacy 中的叶子值覆盖 nestednested 中 legacy 没有的键保留。
if (!root[MODULE_NAME] || typeof root[MODULE_NAME] !== 'object') {
root[MODULE_NAME] = legacy;
console.log(`[翰林院] 已迁移旧版 '${MODULE_NAME}' 设置到 extension_settings['${extensionName}']。`);
} else {
const merged = root[MODULE_NAME];
const overlayLegacy = (src, dst) => {
for (const key of Object.keys(src)) {
const sv = src[key];
if (sv && typeof sv === 'object' && !Array.isArray(sv) && dst[key] && typeof dst[key] === 'object' && !Array.isArray(dst[key])) {
overlayLegacy(sv, dst[key]);
} else {
dst[key] = sv;
}
}
};
overlayLegacy(legacy, merged);
console.log(`[翰林院] 发现新旧两处配置;已将顶层 '${MODULE_NAME}' 深合并覆盖到 extension_settings['${extensionName}']。`);
}
delete extension_settings[MODULE_NAME];
saveSettingsDebounced();
}
function showNotification(message, type = 'info') {
@@ -1324,7 +1372,7 @@ function preprocessQueryText(queryText) {
}
let processedText = queryText;
const { tagExtractionEnabled, tags, exclusionRules } = settings.queryPreprocessing;
const { tagExtractionEnabled, tags, exclusionRules } = resolveQueryPreprocessingRuleConfig(settings);
if (tagExtractionEnabled && tags) {
const tagsToExtract = tags.split(',').map(t => t.trim()).filter(Boolean);
@@ -1438,7 +1486,7 @@ async function rearrangeChat(chat, contextSize, abort, type) {
const queryMessages = chat.slice(-settings.advanced.queryMessageCount);
if (queryMessages.length === 0) return;
const queryPreprocessingSettings = settings.queryPreprocessing;
const queryPreprocessingSettings = resolveQueryPreprocessingRuleConfig(settings);
let queryText = '';
const relevantTexts = [];

View File

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

View File

@@ -11,6 +11,7 @@ import {
import { getBatchFillerFlowTemplate, convertTablesToCsvString, updateTableFromText, saveStateToMessage, getMemoryState } from './table-system/manager.js';
import { saveChat } from "/script.js";
import { renderTables } from '../ui/table-bindings.js';
import { resolveHistoriographyRuleConfig } from "../utils/config/RuleProfileManager.js";
import { getPresetPrompts, getMixedOrder } from '../PresetSettings/index.js';
import { callAI, generateRandomSeed } from './api.js';
@@ -423,17 +424,20 @@ export async function processPlotOptimization(currentUserMessage, contextMessage
}
let history = '';
const contextLimit = settings.plotOpt_contextLimit || 0;
const contextLimit = settings.plotOpt_contextLimit ?? settings.plotOpt_contextTurnCount ?? 0;
if (contextLimit > 0 && contextMessages.length > 0) {
const historyMessages = contextMessages.slice(-contextLimit);
// 复刻 Historiographer 的标签提取与内容排除逻辑
const useTagExtraction = settings.historiographyTagExtractionEnabled ?? false;
const tagsToExtract = useTagExtraction ? (settings.historiographyTags || '').split(',').map(t => t.trim()).filter(Boolean) : [];
const exclusionRules = settings.historiographyExclusionRules || [];
const historiographyRuleConfig = resolveHistoriographyRuleConfig(settings);
const useTagExtraction = historiographyRuleConfig.tagExtractionEnabled ?? false;
const tagsToExtract = useTagExtraction ? (historiographyRuleConfig.tags || '').split(',').map(t => t.trim()).filter(Boolean) : [];
const exclusionRules = historiographyRuleConfig.exclusionRules || [];
const excludeUserMessages = historiographyRuleConfig.excludeUserMessages ?? false;
history = historyMessages
.map(msg => {
if (excludeUserMessages && msg.is_user) return null;
if (msg.mes && msg.mes.trim()) {
let content = msg.mes.trim();

View File

@@ -9,10 +9,10 @@ const RAG_MODULE_NAME = 'hanlinyuan-rag-core';
function getRagSettings() {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
if (!extension_settings[extensionName][RAG_MODULE_NAME]) {
extension_settings[extensionName][RAG_MODULE_NAME] = structuredClone(ragDefaultSettings);
if (!extension_settings[RAG_MODULE_NAME]) {
extension_settings[RAG_MODULE_NAME] = structuredClone(ragDefaultSettings);
}
return extension_settings[extensionName][RAG_MODULE_NAME];
return extension_settings[RAG_MODULE_NAME];
}
export function bindSuperMemoryEvents() {

View File

@@ -46,7 +46,7 @@ import { renderTables } from '../../ui/table-bindings.js';
async function processMessageUpdate(messageId) {
TableManager.clearHighlights();
const settings = extension_settings[extensionName];
const settings = extension_settings[extensionName] || {};
const tableSystemEnabled = settings.table_system_enabled !== false;
if (!tableSystemEnabled) {
log('【表格服务】表格系统总开关已关闭,跳过所有表格处理。', 'info');

View File

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

View File

@@ -6,12 +6,29 @@ import { updateTableFromText } from './manager.js';
import { extensionName } from '../../utils/settings.js';
import { renderTables } from '../../ui/table-bindings.js';
import { getPresetPrompts, getMixedOrder } from '../../PresetSettings/index.js';
import { callAI, generateRandomSeed } from '../api.js';
import { callAI, callAIForTools, generateRandomSeed } from '../api.js';
import { callNccsAI } from '../api/NccsApi.js';
import { TABLE_FILL_TOOL, parseToolCallArgs } from './formatters/tool-call.js';
import { updateTableFromOps } from './manager.js';
import { extractBlocksByTags, applyExclusionRules } from '../utils/rag-tag-extractor.js';
import { resolveTableRuleConfig } from '../../utils/config/RuleProfileManager.js';
import { showTableFillReviewModal } from '../../ui/page-window.js';
import { getBatchFillerRuleTemplate, getBatchFillerFlowTemplate, convertTablesToCsvString } from './manager.js';
const CONTINUE_PROMPT = '上一条回复不完整或缺少 <Amily2Edit> 指令块。请直接从中断处继续生成剩余内容,不要重复已输出的文本,也不要添加任何解释或寒暄,确保最终输出中包含完整的 <Amily2Edit>...</Amily2Edit> 指令块。';
async function requestContinuation(baseMessages, partialResponse) {
const continueMessages = [
...baseMessages,
{ role: 'assistant', content: partialResponse || '' },
{ role: 'user', content: CONTINUE_PROMPT },
];
const continued = await callTableModel(continueMessages);
if (!continued) return null;
return `${partialResponse || ''}${continued}`;
}
let isFilling = false;
let manualStopRequested = false;
let currentBatch = 0;
@@ -22,7 +39,7 @@ const MAX_RETRIES = 2;
async function getWorldBookContext() {
const settings = extension_settings[extensionName];
const settings = extension_settings[extensionName] || {};
if (!settings.table_worldbook_enabled) {
return '';
}
@@ -114,7 +131,7 @@ function updateButtonState(state, batchNum = 0, attemptNum = 0) {
async function callTableModel(messages) {
try {
const settings = extension_settings[extensionName];
const settings = extension_settings[extensionName] || {};
if (settings.nccsEnabled) {
log('使用 Nccs API 进行表格填充...', 'info');
@@ -124,8 +141,8 @@ async function callTableModel(messages) {
}
return result;
} else {
log('使用默认 API 进行表格填充...', 'info');
const result = await callAI(messages);
log('使用 tableFilling slot 进行表格填充...', 'info');
const result = await callAI(messages, { slot: 'tableFilling' });
if (!result) {
throw new Error('API返回内容为空。');
}
@@ -141,7 +158,7 @@ async function callTableModel(messages) {
function getRawMessagesForSummary(startFloor, endFloor) {
const context = getContext();
const chat = context.chat;
const settings = extension_settings[extensionName];
const settings = extension_settings[extensionName] || {};
const historySlice = chat.slice(startFloor - 1, endFloor);
if (historySlice.length === 0) return null;
@@ -152,10 +169,11 @@ function getRawMessagesForSummary(startFloor, endFloor) {
let tagsToExtract = [];
let exclusionRules = [];
if (settings.table_independent_rules_enabled) {
log('批量填表:使用独立提取规则。', 'info');
tagsToExtract = (settings.table_tags_to_extract || '').split(',').map(t => t.trim()).filter(Boolean);
exclusionRules = settings.table_exclusion_rules || [];
const tableRuleConfig = resolveTableRuleConfig(settings);
if (tableRuleConfig.tags || (tableRuleConfig.exclusionRules && tableRuleConfig.exclusionRules.length)) {
log('批量填表:使用提取规则配置。', 'info');
tagsToExtract = (tableRuleConfig.tags || '').split(',').map(t => t.trim()).filter(Boolean);
exclusionRules = tableRuleConfig.exclusionRules || [];
}
const messages = historySlice.map((msg, index) => {
@@ -266,24 +284,80 @@ async function runBatchAttempt(batchNum, attemptNum) {
console.dir(messages);
console.groupEnd();
const resultText = await callTableModel(messages);
console.log(`[Amily2 立即远征] 批次 ${batchNum}/${totalBatches} - 收到 API 原始回复:`, resultText);
if (!resultText) {
throw new Error('API返回内容为空。');
const batchSettings = extension_settings[extensionName] || {};
if (batchSettings.tableFillFunctionCall) {
// Function Call 路径:结构化输出,无需检查 <Amily2Edit>
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling' });
if (!argsString) throw new Error('Function Call 返回为空。');
const ops = parseToolCallArgs(argsString);
if (ops.length === 0) {
log(`批次 ${batchNum} 的 Function Call 返回操作列表为空AI 判断此批次无需变更。`, 'warn');
toastr.info('AI 判断此批次无需修改。', `批次 ${batchNum}`);
} else {
await updateTableFromOps(ops, { immediateDelete: true });
renderTables();
log(`批次 ${batchNum} Function Call 处理成功(${ops.length} 条操作)。`, 'success');
}
} else {
// Legacy 文本路径
const resultText = await callTableModel(messages);
console.log(`[Amily2 立即远征] 批次 ${batchNum}/${totalBatches} - 收到 API 原始回复:`, resultText);
if (!resultText) throw new Error('API返回内容为空。');
if (!resultText.includes('<Amily2Edit>')) {
log(`批次 ${batchNum} 的响应未包含 <Amily2Edit> 指令块,弹出检查窗口等待用户处理。`, 'warn');
updateButtonState('paused');
showTableFillReviewModal(resultText, {
title: `填表响应检查 - 批次 ${batchNum}/${totalBatches}`,
subtitle: `批次 ${batchNum}/${totalBatches}(楼层 ${startFloor}-${endFloor})的 AI 响应未包含有效的 <Amily2Edit> 指令块。请检查原始响应并选择处理方式。`,
onContinue: async (currentText) => {
const merged = await requestContinuation(messages, currentText);
if (!merged) { toastr.error('补全请求失败或返回为空。', '继续补全'); return null; }
if (!merged.includes('<Amily2Edit>')) {
toastr.warning('补全后仍未包含 <Amily2Edit> 指令块,可继续补全、手动应用或重新填表。', '继续补全');
} else {
toastr.success('已获得包含指令块的补全内容,可点击”手动应用”写入。', '继续补全');
}
return merged;
},
onApply: (editedText) => {
if (!editedText || !editedText.includes('<Amily2Edit>')) {
toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用');
}
try {
updateTableFromText(editedText, { immediateDelete: true });
renderTables();
log(`批次 ${batchNum} 已由用户手动处理完成。`, 'success');
} catch (err) {
log(`批次 ${batchNum} 手动应用失败: ${err.message}`, 'error');
toastr.error(`手动应用失败: ${err.message}`, '写入异常');
currentBatch = batchNum - 1;
updateButtonState('error');
return;
}
currentBatch = batchNum;
setTimeout(processNextBatch, 500);
},
onRetry: () => {
log(`用户选择重新填表,批次 ${batchNum} 将重新执行。`, 'warn');
setTimeout(() => runBatchAttempt(batchNum, 0), 300);
},
onCancel: () => {
log(`用户取消了批次 ${batchNum} 的处理,任务已暂停。`, 'warn');
currentBatch = batchNum - 1;
updateButtonState('error');
},
});
return;
}
updateTableFromText(resultText, { immediateDelete: true });
renderTables();
log(`批次 ${batchNum} 处理成功。`, 'success');
}
// 【修复】检查 AI 是否返回了有效的指令块,防止 AI 偷懒或格式错误被误判为成功
if (!resultText.includes('<Amily2Edit>')) {
throw new Error('AI未返回有效的 <Amily2Edit> 指令块,可能格式错误或未产生实质性变更。');
}
// 【V155.0】批量填表时,启用立即删除模式,避免红色待删除行残留
updateTableFromText(resultText, { immediateDelete: true });
renderTables();
log(`批次 ${batchNum} 处理成功。`, 'success');
currentBatch = batchNum;
setTimeout(processNextBatch, 1000);
currentBatch = batchNum;
setTimeout(processNextBatch, 1000);
} catch (error) {
log(`批次 ${batchNum} 尝试 ${attemptNum + 1} 失败: ${error.message}`, 'error');
@@ -319,7 +393,7 @@ export function startBatchFilling() {
const button = fillButton();
if (!button) return;
const settings = extension_settings[extensionName];
const settings = extension_settings[extensionName] || {};
const tableSystemEnabled = settings.table_system_enabled !== false;
if (!tableSystemEnabled) {
log('表格系统总开关已关闭,跳过批量填表。', 'info');
@@ -387,7 +461,7 @@ export function startBatchFilling() {
export async function startFloorRangeFilling(startFloor, endFloor) {
const settings = extension_settings[extensionName];
const settings = extension_settings[extensionName] || {};
const tableSystemEnabled = settings.table_system_enabled !== false;
if (!tableSystemEnabled) {
log('表格系统总开关已关闭,跳过楼层填表。', 'info');
@@ -482,24 +556,72 @@ export async function startFloorRangeFilling(startFloor, endFloor) {
console.dir(messages);
console.groupEnd();
const resultText = await callTableModel(messages);
console.log(`[Amily2 楼层填表] 楼层 ${startFloor}-${endFloor} - 收到 API 原始回复:`, resultText);
if (!resultText) {
throw new Error('API返回内容为空。');
const floorSettings = extension_settings[extensionName] || {};
if (floorSettings.tableFillFunctionCall) {
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling' });
if (!argsString) throw new Error('Function Call 返回为空。');
const ops = parseToolCallArgs(argsString);
if (ops.length === 0) {
log(`楼层 ${startFloor}-${endFloor} Function Call 返回操作列表为空,无需变更。`, 'warn');
toastr.info('AI 判断此楼层范围无需修改。', `楼层 ${startFloor}-${endFloor}`);
} else {
await updateTableFromOps(ops, { immediateDelete: true });
renderTables();
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
log(`楼层 ${startFloor}-${endFloor} Function Call 处理成功(${ops.length} 条操作)。`, 'success');
}
} else {
const resultText = await callTableModel(messages);
console.log(`[Amily2 楼层填表] 楼层 ${startFloor}-${endFloor} - 收到 API 原始回复:`, resultText);
if (!resultText) throw new Error('API返回内容为空。');
if (!resultText.includes('<Amily2Edit>')) {
log(`楼层 ${startFloor}-${endFloor} 的响应未包含 <Amily2Edit> 指令块,弹出检查窗口等待用户处理。`, 'warn');
showTableFillReviewModal(resultText, {
title: `填表响应检查 - 楼层 ${startFloor}-${endFloor}`,
subtitle: `楼层 ${startFloor}-${endFloor} 的 AI 响应未包含有效的 <Amily2Edit> 指令块。请检查原始响应并选择处理方式。`,
onContinue: async (currentText) => {
const merged = await requestContinuation(messages, currentText);
if (!merged) { toastr.error('补全请求失败或返回为空。', '继续补全'); return null; }
if (!merged.includes('<Amily2Edit>')) {
toastr.warning('补全后仍未包含 <Amily2Edit> 指令块,可继续补全、手动应用或重新填表。', '继续补全');
} else {
toastr.success('已获得包含指令块的补全内容,可点击”手动应用”写入。', '继续补全');
}
return merged;
},
onApply: (editedText) => {
if (!editedText || !editedText.includes('<Amily2Edit>')) {
toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用');
}
try {
updateTableFromText(editedText, { immediateDelete: true });
renderTables();
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
log(`楼层 ${startFloor}-${endFloor} 填表由用户手动处理完成。`, 'success');
} catch (err) {
log(`楼层 ${startFloor}-${endFloor} 手动应用失败: ${err.message}`, 'error');
toastr.error(`手动应用失败: ${err.message}`, '写入异常');
}
},
onRetry: () => {
log(`用户请求重新填写楼层 ${startFloor}-${endFloor}`, 'warn');
setTimeout(() => startFloorRangeFilling(startFloor, endFloor), 300);
},
onCancel: () => {
log(`用户取消了楼层 ${startFloor}-${endFloor} 的填表。`, 'warn');
toastr.info(`已取消楼层 ${startFloor}-${endFloor} 的填表。`);
},
});
return;
}
updateTableFromText(resultText, { immediateDelete: true });
renderTables();
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
log(`楼层 ${startFloor}-${endFloor} 填表处理完成。`, 'success');
}
// 【修复】检查 AI 是否返回了有效的指令块
if (!resultText.includes('<Amily2Edit>')) {
throw new Error('AI未返回有效的 <Amily2Edit> 指令块,可能格式错误或未产生实质性变更。');
}
updateTableFromText(resultText, { immediateDelete: true });
renderTables();
toastr.success(`楼层 ${startFloor}-${endFloor} 填表完成!`);
log(`楼层 ${startFloor}-${endFloor} 填表处理完成。`, 'success');
} catch (error) {
log(`楼层 ${startFloor}-${endFloor} 填表失败: ${error.message}`, 'error');
toastr.error(`楼层填表失败: ${error.message}`, '处理失败');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

@@ -4,11 +4,11 @@ import { renderTables } from '../../ui/table-bindings.js';
import { extensionName } from "../../utils/settings.js";
import { convertTablesToCsvString, convertSelectedTablesToCsvString, saveStateToMessage, getMemoryState, updateTableFromText, getBatchFillerRuleTemplate, getBatchFillerFlowTemplate } from './manager.js';
import { getPresetPrompts, getMixedOrder } from '../../PresetSettings/index.js';
import { callAI, generateRandomSeed, getApiSettings } from '../api.js';
import { callAI, generateRandomSeed } from '../api.js';
import { callNccsAI } from '../api/NccsApi.js';
export async function reorganizeTableContent(selectedTableIndices) {
const settings = extension_settings[extensionName];
const settings = extension_settings[extensionName] || {};
if (settings.table_system_enabled === false) {
toastr.warning('表格系统总开关已关闭。');
@@ -20,13 +20,6 @@ export async function reorganizeTableContent(selectedTableIndices) {
return;
}
const resolvedApi = await getApiSettings('main');
const { apiUrl, apiKey, model, temperature, maxTokens, forceProxyForCustomApi } = resolvedApi ?? settings;
if (!apiUrl || !model) {
toastr.error("主API的URL或模型未配置重新整理功能无法启动。", "Amily2-重新整理");
return;
}
try {
toastr.info('正在重新整理表格内容...', 'Amily2-重新整理');
@@ -78,8 +71,8 @@ export async function reorganizeTableContent(selectedTableIndices) {
console.log('[Amily2-重新整理] 使用 Nccs API 进行表格重整...');
rawContent = await callNccsAI(messages);
} else {
console.log('[Amily2-重新整理] 使用默认 API 进行表格重整...');
rawContent = await callAI(messages);
console.log('[Amily2-重新整理] 使用 tableFilling slot 进行表格重整...');
rawContent = await callAI(messages, { slot: 'tableFilling' });
}
if (!rawContent) {

View File

@@ -4,16 +4,62 @@ import { saveChat } from "/script.js";
import { renderTables } from '../../ui/table-bindings.js';
import { updateOrInsertTableInChat } from '../../ui/message-table-renderer.js';
import { extensionName } from "../../utils/settings.js";
import { updateTableFromText, getBatchFillerRuleTemplate, getBatchFillerFlowTemplate, convertTablesToCsvString, saveStateToMessage, getMemoryState, clearHighlights } from './manager.js';
import { updateTableFromText, updateTableFromOps, getBatchFillerRuleTemplate, getBatchFillerFlowTemplate, convertTablesToCsvString, saveStateToMessage, getMemoryState, clearHighlights } from './manager.js';
import { getPresetPrompts, getMixedOrder } from '../../PresetSettings/index.js';
import { callAI, generateRandomSeed, getApiSettings } from '../api.js';
import { callAI, callAIForTools, generateRandomSeed } from '../api.js';
import { TABLE_FILL_TOOL, parseToolCallArgs } from './formatters/tool-call.js';
import { callNccsAI } from '../api/NccsApi.js';
import { extractBlocksByTags, applyExclusionRules } from '../utils/rag-tag-extractor.js';
import { resolveTableRuleConfig } from '../../utils/config/RuleProfileManager.js';
import { safeLorebookEntries } from '../tavernhelper-compatibility.js';
import { log } from './logger.js';
import { showTableFillReviewModal } from '../../ui/page-window.js';
const CONTINUE_PROMPT_SECONDARY = '上一条回复不完整或缺少 <Amily2Edit> 指令块。请直接从中断处继续生成剩余内容,不要重复已输出的文本,也不要添加任何解释或寒暄,确保最终输出中包含完整的 <Amily2Edit>...</Amily2Edit> 指令块。';
let secondaryFillerDebounceTimer = null;
async function callSecondaryModel(messages) {
const settings = extension_settings[extensionName] || {};
if (settings.nccsEnabled) {
return await callNccsAI(messages);
}
return await callAI(messages);
}
async function requestSecondaryContinuation(baseMessages, partialResponse) {
const continueMessages = [
...baseMessages,
{ role: 'assistant', content: partialResponse || '' },
{ role: 'user', content: CONTINUE_PROMPT_SECONDARY },
];
const continued = await callSecondaryModel(continueMessages);
if (!continued) return null;
return `${partialResponse || ''}${continued}`;
}
function commitSecondaryFillResult(rawContent, targetMessages) {
updateTableFromText(rawContent);
const memoryState = getMemoryState();
const lastProcessedMsg = targetMessages[targetMessages.length - 1].msg;
for (const target of targetMessages) {
if (!target.msg.metadata) target.msg.metadata = {};
target.msg.metadata.Amily2_Process_Hash = target.hash;
}
if (saveStateToMessage(memoryState, lastProcessedMsg)) {
renderTables();
updateOrInsertTableInChat();
}
saveChat();
}
async function getWorldBookContext() {
const settings = extension_settings[extensionName];
const settings = extension_settings[extensionName] || {};
if (!settings.table_worldbook_enabled) {
return '';
@@ -65,9 +111,28 @@ async function getWorldBookContext() {
}
export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
clearHighlights();
const settings = extension_settings[extensionName] || {};
const settings = extension_settings[extensionName];
// 【V2.1.1】分步填表触发延迟 / 防抖:自动触发时若配置了延迟,则延后执行,
// 延迟期内再次到来的事件会重置计时器,避免消息连续到达时重复拉起填表。
const delay = Math.max(0, parseInt(settings.secondary_filler_delay || 0, 10));
if (!forceRun && delay > 0) {
if (secondaryFillerDebounceTimer) {
clearTimeout(secondaryFillerDebounceTimer);
}
secondaryFillerDebounceTimer = setTimeout(() => {
secondaryFillerDebounceTimer = null;
fillWithSecondaryApi(latestMessage, forceRun);
}, delay);
console.log(`[Amily2-副API] 分步填表已按防抖延迟 ${delay}ms 调度。`);
return;
}
if (secondaryFillerDebounceTimer) {
clearTimeout(secondaryFillerDebounceTimer);
secondaryFillerDebounceTimer = null;
}
clearHighlights();
// 总开关关闭时,分步填表同样禁用
if (settings.table_system_enabled === false) {
@@ -92,15 +157,6 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
return;
}
const resolvedApi = await getApiSettings('main');
const { apiUrl, apiKey, model, temperature, maxTokens, forceProxyForCustomApi } = resolvedApi ?? settings;
if (!apiUrl || !model) {
if (!window.secondaryApiUrlWarned) {
toastr.error("主API的URL或模型未配置分步填表功能无法启动。", "Amily2-分步填表");
window.secondaryApiUrlWarned = true;
}
return;
}
try {
const bufferSize = parseInt(settings.secondary_filler_buffer || 0, 10);
@@ -180,9 +236,10 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
let tagsToExtract = [];
let exclusionRules = [];
if (settings.table_independent_rules_enabled) {
tagsToExtract = (settings.table_tags_to_extract || '').split(',').map(t => t.trim()).filter(Boolean);
exclusionRules = settings.table_exclusion_rules || [];
const tableRuleConfig = resolveTableRuleConfig(settings);
if (tableRuleConfig.tags || (tableRuleConfig.exclusionRules && tableRuleConfig.exclusionRules.length)) {
tagsToExtract = (tableRuleConfig.tags || '').split(',').map(t => t.trim()).filter(Boolean);
exclusionRules = tableRuleConfig.exclusionRules || [];
}
let coreContentText = "";
@@ -279,44 +336,87 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
console.dir(messages);
console.groupEnd();
let rawContent;
if (settings.nccsEnabled) {
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
rawContent = await callNccsAI(messages);
if (settings.tableFillFunctionCall) {
// Function Call 路径
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling' });
if (!argsString) {
console.error('[Amily2-副API] Function Call 返回为空。');
return;
}
const ops = parseToolCallArgs(argsString);
if (ops.length === 0) {
console.warn('[Amily2-副API] Function Call 返回操作列表为空,无需变更。');
toastr.info('AI 判断此范围无需修改。', 'Amily2-分步填表');
} else {
await updateTableFromOps(ops);
toastr.success('分步填表Function Call执行完毕。', 'Amily2-分步填表');
}
} else {
console.log('[Amily2-副API] 使用默认 API 进行分步填表...');
rawContent = await callAI(messages);
// Legacy 文本路径
let rawContent;
if (settings.nccsEnabled) {
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
rawContent = await callNccsAI(messages);
} else {
console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...');
rawContent = await callAI(messages, { slot: 'tableFilling' });
}
if (!rawContent) {
console.error('[Amily2-副API] 未能获取AI响应内容。');
return;
}
console.log('[Amily2号-副API-原始回复]:', rawContent);
if (!rawContent.includes('<Amily2Edit>')) {
const rangeLabel = `${targetMessages[0].index + 1} - ${targetMessages[targetMessages.length - 1].index + 1}`;
console.warn(`[Amily2-副API] 响应未包含 <Amily2Edit> 指令块(楼层 ${rangeLabel}),弹出检查窗口等待用户处理。`);
toastr.warning(`分步填表(楼层 ${rangeLabel})的响应缺少 <Amily2Edit> 指令块,请在弹窗中处理。`, 'Amily2-分步填表');
if (latestMessage && latestMessage.metadata) {
delete latestMessage.metadata.Amily2_Retry_Count;
}
showTableFillReviewModal(rawContent, {
title: `分步填表响应检查 - 楼层 ${rangeLabel}`,
subtitle: `分步填表(楼层 ${rangeLabel})的 AI 响应未包含有效的 <Amily2Edit> 指令块。请检查原始响应并选择处理方式。`,
onContinue: async (currentText) => {
const merged = await requestSecondaryContinuation(messages, currentText);
if (!merged) { toastr.error('补全请求失败或返回为空。', '继续补全'); return null; }
if (!merged.includes('<Amily2Edit>')) {
toastr.warning('补全后仍未包含 <Amily2Edit> 指令块,可继续补全、手动应用或重新填表。', '继续补全');
} else {
toastr.success('已获得包含指令块的补全内容,可点击”手动应用”写入。', '继续补全');
}
return merged;
},
onApply: (editedText) => {
if (!editedText || !editedText.includes('<Amily2Edit>')) {
toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用');
}
try {
commitSecondaryFillResult(editedText, targetMessages);
toastr.success('分步填表已由用户手动处理完成。', 'Amily2-分步填表');
} catch (err) {
console.error('[Amily2-副API] 手动应用失败:', err);
toastr.error(`手动应用失败: ${err.message}`, '写入异常');
}
},
onRetry: () => {
if (latestMessage && latestMessage.metadata) {
delete latestMessage.metadata.Amily2_Retry_Count;
}
toastr.info('将重新执行分步填表...', 'Amily2-分步填表');
setTimeout(() => fillWithSecondaryApi(latestMessage, forceRun), 300);
},
onCancel: () => {
toastr.info('已取消本次分步填表。', 'Amily2-分步填表');
},
});
return;
}
commitSecondaryFillResult(rawContent, targetMessages);
}
if (!rawContent) {
console.error('[Amily2-副API] 未能获取AI响应内容。');
return;
}
console.log("[Amily2号-副API-原始回复]:", rawContent);
// 【修复】检查 AI 是否返回了有效的指令块,防止 AI 偷懒或格式错误被误判为成功
if (!rawContent.includes('<Amily2Edit>')) {
throw new Error('AI未返回有效的 <Amily2Edit> 指令块,可能格式错误或未产生实质性变更。');
}
updateTableFromText(rawContent);
const memoryState = getMemoryState();
const lastProcessedMsg = targetMessages[targetMessages.length - 1].msg;
for (const target of targetMessages) {
if (!target.msg.metadata) target.msg.metadata = {};
target.msg.metadata.Amily2_Process_Hash = target.hash;
}
if (saveStateToMessage(memoryState, lastProcessedMsg)) {
renderTables();
updateOrInsertTableInChat();
}
saveChat();
toastr.success("分步填表执行完毕。", "Amily2-分步填表");
} catch (error) {

View File

@@ -131,6 +131,7 @@ export {
};
export const tableSystemDefaultSettings = {
table_system_enabled: true,
table_injection_enabled: false,
injection: {
@@ -157,4 +158,7 @@ export const tableSystemDefaultSettings = {
// Nccs API 设置
nccsEnabled: false,
nccsFakeStreamEnabled: false,
// Function Call 填表
tableFillFunctionCall: false,
};

View File

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

View File

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

View File

@@ -137,7 +137,12 @@ export function progressTracker(operationId, maxAttempts) {
container.style.backgroundColor = 'rgba(80,0,0,0.9)';
progress.style.display = 'none';
info.style.whiteSpace = 'pre-wrap';
info.innerHTML = `<span style="color:#ff9494">错误详情:</span>\n${errorMsg}`;
info.innerHTML = '';
const label = document.createElement('span');
label.style.color = '#ff9494';
label.textContent = '错误详情:';
info.appendChild(label);
info.appendChild(document.createTextNode('\n' + String(errorMsg ?? '')));
}
};
}

View File

@@ -1,6 +1,8 @@
import { extension_settings, getContext } from "/scripts/extensions.js";
import { saveSettingsDebounced, eventSource, event_types } from "/script.js";
import { extensionName } from "../utils/settings.js";
import { configManager } from '../utils/config/ConfigManager.js';
import { SENSITIVE_KEYS } from '../utils/config/sensitive-keys.js';
import { safeLorebooks, safeLorebookEntries, safeUpdateLorebookEntries } from '../core/tavernhelper-compatibility.js';
import { testSybdApiConnection, fetchSybdModels } from '../core/api/SybdApi.js';
import { handleFileUpload, processNovel } from './index.js';
@@ -29,18 +31,21 @@ function loadSettingsToUI() {
const inputs = container.querySelectorAll('[data-setting-key]');
inputs.forEach(target => {
const key = target.dataset.settingKey;
const value = settings[key];
// 敏感字段从 configManagerlocalStorage读取其余从 extension_settings 读取
const value = SENSITIVE_KEYS.has(key) ? configManager.get(key) : settings[key];
if (value === undefined) {
let defaultValue;
if (target.type === 'checkbox') {
defaultValue = target.checked;
} else if (target.type === 'range') {
defaultValue = target.dataset.type === 'float' ? parseFloat(target.value) : parseInt(target.value, 10);
} else {
defaultValue = target.value;
if (value === undefined || value === null || value === '') {
if (!SENSITIVE_KEYS.has(key)) {
let defaultValue;
if (target.type === 'checkbox') {
defaultValue = target.checked;
} else if (target.type === 'range') {
defaultValue = target.dataset.type === 'float' ? parseFloat(target.value) : parseInt(target.value, 10);
} else {
defaultValue = target.value;
}
updateAndSaveSetting(key, defaultValue);
}
updateAndSaveSetting(key, defaultValue);
return;
};
@@ -90,8 +95,13 @@ function bindAutoSaveEvents() {
case 'float': value = parseFloat(value); break;
case 'boolean': value = (typeof value === 'boolean') ? value : (value === 'true'); break;
}
updateAndSaveSetting(key, value);
// 敏感字段API Key经 configManager 写入 localStorage
if (SENSITIVE_KEYS.has(key)) {
configManager.set(key, value);
} else {
updateAndSaveSetting(key, value);
}
if (key === 'sybdApiMode') {
updateConfigVisibility(value);
@@ -311,7 +321,7 @@ async function renderWorldBookEntries() {
</div>
<div class="entry-content-display">${renderContent(entry.content)}</div>
<div class="entry-content-editor" style="display: none;">
<textarea class="text_pole" style="width: 98%; min-height: 150px;">${entry.content}</textarea>
<textarea class="text_pole" style="width: 98%; min-height: 150px;">${escapeHTML(entry.content || '')}</textarea>
</div>
`;
@@ -367,7 +377,12 @@ async function renderWorldBookEntries() {
} catch (error) {
console.error('加载世界书条目失败:', error);
container.innerHTML = `<p style="text-align:center; color: #ff8a8a;">加载失败: ${error.message}</p>`;
const p = document.createElement('p');
p.style.textAlign = 'center';
p.style.color = '#ff8a8a';
p.textContent = `加载失败: ${error?.message ?? '未知错误'}`;
container.innerHTML = '';
container.appendChild(p);
}
}

View File

@@ -7,6 +7,7 @@ import './SL/bus/Amily2Bus.js'
import './utils/config/ConfigManager.js'
import './utils/config/api-key-store/ApiKeyStore.js'
import './utils/config/ApiProfileManager.js'
import './utils/config/RuleProfileManager.js'
import './core/table-system/TableSystemService.js'
// Re-exports (重新导出供 index.js 使用)
@@ -23,6 +24,7 @@ export { characters, this_chid, eventSource, event_types, saveSettingsDebounced
// Core Systems
export { injectTableData, generateTableContent } from "./core/table-system/injector.js";
export { initialize as initializeRagProcessor } from "./core/rag-processor.js";
export { loadSettingsToUI as loadHanlinyuanSettingsToUI } from "./ui/hanlinyuan-bindings.js";
export { loadTables, clearHighlights, rollbackAndRefill, rollbackState, commitPendingDeletions, saveStateToMessage, getMemoryState, clearUpdatedTables } from './core/table-system/manager.js';
export { fillWithSecondaryApi } from './core/table-system/secondary-filler.js';
export { renderTables } from './ui/table-bindings.js';
@@ -33,7 +35,9 @@ export { pluginVersion, extensionName, defaultSettings } from './utils/settings.
export { configManager } from './utils/config/ConfigManager.js';
export { apiKeyStore } from './utils/config/api-key-store/ApiKeyStore.js';
export { apiProfileManager, PROFILE_TYPES, SLOTS } from './utils/config/ApiProfileManager.js';
export { ruleProfileManager, RULE_SLOTS, resolveSlotRuleConfig, resolveCondensationRuleConfig, resolveQueryPreprocessingRuleConfig, resolveTableRuleConfig, resolveHistoriographyRuleConfig, resolveRuleConfig } from './utils/config/RuleProfileManager.js';
export { bindApiConfigPanel } from './ui/api-config-bindings.js';
export { bindRuleConfigPanel } from './ui/rule-config-bindings.js';
export { checkAuthorization, refreshUserInfo } from './utils/auth.js';
export { tableSystemDefaultSettings } from './core/table-system/settings.js';
export { manageLorebookEntriesForChat } from './core/lore.js';

View File

@@ -8,6 +8,7 @@ import {
characters, this_chid, eventSource, event_types, saveSettingsDebounced,
injectTableData, generateTableContent,
initializeRagProcessor,
loadHanlinyuanSettingsToUI,
loadTables, clearHighlights, rollbackAndRefill, rollbackState, commitPendingDeletions, saveStateToMessage, getMemoryState, clearUpdatedTables,
fillWithSecondaryApi,
renderTables,
@@ -568,11 +569,12 @@ async function onPlotGenerationAfterCommands(type, params, dryRun) {
if (globalSettings?.plotOpt_enabled === false) return false;
const isJqyhEnabled = globalSettings?.jqyhEnabled === true;
const hasMainProfile = !!apiProfileManager.getAssignment('main') || !!apiProfileManager.getAssignment('plotOpt');
const isMainApiConfigured = hasMainProfile || !!globalSettings?.apiUrl || !!globalSettings?.tavernProfile;
const hasProfile = !!apiProfileManager.getAssignment('main') || !!apiProfileManager.getAssignment('plotOpt');
const hasLegacyConfig = !!globalSettings?.apiUrl || !!globalSettings?.tavernProfile
|| !!globalSettings?.plotOpt_apiUrl || !!globalSettings?.plotOpt_tavernProfile;
if (!isJqyhEnabled && !isMainApiConfigured) {
console.log("[Amily2-剧情优化] 优化已启用,但Jqyh API已禁用且主API未配置(无 Profile 分配亦无旧设置)。");
if (!isJqyhEnabled && !hasProfile && !hasLegacyConfig) {
console.log("[Amily2-剧情优化] 优化已启用,但未配置任何可用的 API(无 Profile 分配亦无独立配置)。");
return false;
}
@@ -609,7 +611,7 @@ async function onPlotGenerationAfterCommands(type, params, dryRun) {
}, 100);
});
const contextTurnCount = globalSettings.plotOpt_contextLimit || 10;
const contextTurnCount = globalSettings.plotOpt_contextLimit ?? globalSettings.plotOpt_contextTurnCount ?? 10;
const contextSource = isFromTextarea ? context.chat : context.chat.slice(0, -1);
const slicedContext = contextTurnCount > 0 ? contextSource.slice(-contextTurnCount) : contextSource;
@@ -772,6 +774,15 @@ function initializeRagAndInjection() {
console.error('[Amily2-翰林院] RAG处理器初始化失败:', error);
}
// 此时 ST settings hydration 已完成,且 RAG 第二次 init 拿到的是真实 saved settings 引用。
// mount 阶段那次 loadSettingsToUI 跑得过早hydration 之前UI 拿到的是默认值;
// 在此重跑一次以让翰林院面板显示真实持久化值。
try {
loadHanlinyuanSettingsToUI();
} catch (error) {
console.error('[Amily2-翰林院] 步骤五重载面板设置失败:', error);
}
console.log("[Amily2号-开国大典] 步骤六:智能冲突检测与注入策略...");
console.log('[Amily2-策略] 采用“完全主导”策略,覆盖 `vectors_rearrangeChat`。');
window['vectors_rearrangeChat'] = executeAmily2Injection;
@@ -879,6 +890,7 @@ jQuery(async () => {
initializeAmilyHelper();
mergePluginSettings();
configManager.migrate(); // 将 extension_settings 中残留的敏感字段迁移到 localStorage
await configManager.init();
let attempts = 0;
const maxAttempts = 100;

27
jsconfig.json Normal file
View File

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

View File

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

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

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

View File

@@ -6,8 +6,9 @@
* 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';
import { extension_settings } from '/scripts/extensions.js';
import { extensionName } from '../utils/settings.js';
@@ -16,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互斥根据启用状态选择测试函数
@@ -44,11 +51,21 @@ const SLOT_TOGGLES = {
let _editingId = null; // 当前编辑的 Profile IDnull = 新建)
let _currentFilter = 'all'; // 当前类型筛选
let _slotAssignmentPanel = null;
let _slotAssignmentRefreshBound = false;
// ── 入口:绑定整个面板 ────────────────────────────────────────────────────────
export function bindApiConfigPanel(container) {
const $c = $(container);
_slotAssignmentPanel = $c;
if (!_slotAssignmentRefreshBound) {
_slotAssignmentRefreshBound = true;
document.addEventListener('amily2:slotAssigned', () => {
if (_slotAssignmentPanel) renderSlotAssignments(_slotAssignmentPanel);
});
}
// 存储模式
_bindStorageMode($c);
@@ -69,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);
});
// 弹窗:获取模型列表
@@ -80,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));
@@ -97,6 +140,7 @@ function _bindStorageMode($c) {
const $select = $c.find('#amily2_keystore_mode');
const $cloud = $c.find('#amily2_cloud_key_section');
const $note = $c.find('#amily2_keystore_mode_note');
const $importInput = $c.find('#amily2_import_key_bundle_input');
const MODE_NOTES = {
local: '本地存储API Key 仅存于本设备浏览器,绝不上传服务端。换设备需重新填写。',
@@ -124,6 +168,9 @@ function _bindStorageMode($c) {
try {
await apiKeyStore.setMode(newMode);
if (newMode === 'cloud') {
await configManager.syncSensitiveCache({ force: true });
}
$cloud.toggle(newMode === 'cloud');
$note.text(MODE_NOTES[newMode]);
if (newMode === 'cloud') _refreshFingerprint($c);
@@ -142,6 +189,43 @@ function _bindStorageMode($c) {
_refreshFingerprint($c);
toastr.warning('新密钥对已生成,请重新输入各 Profile 的 API Key。');
});
$c.find('#amily2_export_key_bundle').on('click', async () => {
try {
const bundle = await apiKeyStore.exportPrivateKeyBundle();
_downloadJson(
`amily2-keystore-${_timestampForFilename()}.json`,
bundle
);
toastr.success('私钥包已导出,请妥善保管。');
} catch (e) {
console.error('[ApiConfig] 导出私钥包失败:', e);
toastr.error(e.message || '导出私钥包失败。');
}
});
$c.find('#amily2_import_key_bundle').on('click', () => {
$importInput.val('');
$importInput.trigger('click');
});
$importInput.on('change', async function () {
const file = this.files?.[0];
if (!file) return;
try {
const text = await file.text();
await apiKeyStore.importPrivateKeyBundle(text);
await configManager.syncSensitiveCache({ force: true });
await _refreshFingerprint($c);
toastr.success('私钥包导入成功,已尝试恢复云同步的 API Key 缓存。');
} catch (e) {
console.error('[ApiConfig] 导入私钥包失败:', e);
toastr.error(e.message || '导入私钥包失败。');
} finally {
$importInput.val('');
}
});
}
async function _refreshFingerprint($c) {
@@ -149,6 +233,24 @@ async function _refreshFingerprint($c) {
$c.find('#amily2_keypair_fingerprint').text(fp);
}
function _downloadJson(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function _timestampForFilename() {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
}
// ── Profile 列表渲染 ──────────────────────────────────────────────────────────
export function renderProfileList($c) {
@@ -343,6 +445,12 @@ async function openModal($c, id) {
if (p.type === 'chat') {
$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);
@@ -360,8 +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);
@@ -374,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' });
@@ -401,6 +517,15 @@ async function saveProfile($c) {
if (type === 'chat') {
data.maxTokens = parseInt($c.find('#amily2_pf_max_tokens').val(), 10) || 65500;
data.temperature = parseFloat($c.find('#amily2_pf_temperature').val()) || 1.0;
data.fakeStream = $c.find('#amily2_pf_fake_stream').prop('checked');
// customParamsJSON 校验失败则中止保存
const cp = _parseCustomParamsOrFail($c);
if (cp === null) {
toastr.error('自定义参数 JSON 解析失败,请修正后再保存。', '保存中止');
return;
}
data.customParams = cp;
} else if (type === 'embedding') {
const dim = $c.find('#amily2_pf_dimensions').val();
data.dimensions = dim ? parseInt(dim, 10) : null;
@@ -459,10 +584,11 @@ async function _fetchModels($c) {
let models;
if (provider === 'google') {
// Google 用原生 API以 ?key= 传参,返回 models[] 而非 data[]
// Google 用原生 APIKey 通过 x-goog-api-key 头传递避免 URL 泄露
if (!apiKey) { toastr.warning('请先填写 Google API Key。'); return; }
const resp = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`
'https://generativelanguage.googleapis.com/v1beta/models',
{ headers: { 'x-goog-api-key': apiKey } }
);
if (!resp.ok) {
const status = resp.status;
@@ -512,6 +638,8 @@ async function _fetchModels($c) {
return;
}
models.sort((a, b) => a.localeCompare(b));
const currentVal = $c.find('#amily2_pf_model').val().trim();
const $sel = $c.find('#amily2_pf_model_select');
$sel.html(models.map(m => `<option value="${_escapeHtml(m)}">${_escapeHtml(m)}</option>`).join(''));
@@ -551,7 +679,8 @@ async function _testConnection($c) {
return;
}
const resp = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`
'https://generativelanguage.googleapis.com/v1beta/models',
{ headers: { 'x-goog-api-key': apiKey } }
);
if (resp.ok) {
const data = await resp.json();
@@ -582,8 +711,39 @@ async function _testConnection($c) {
if (modelsResp.ok) {
const rawData = await modelsResp.json();
const list = Array.isArray(rawData) ? rawData : (rawData.data ?? rawData.models ?? []);
const rawList = Array.isArray(rawData) ? rawData : (rawData.data ?? rawData.models ?? []);
const list = Array.isArray(rawList) ? rawList : [];
const count = list.length;
// chat 类型额外发一次假补全,验证 completion 端点也能正常鉴权
const type = $c.find('#amily2_pf_type').val();
const $sel = $c.find('#amily2_pf_model_select');
const model = ($sel.is(':visible') ? $sel.val() : $c.find('#amily2_pf_model').val()).trim();
if (type === 'chat' && model) {
$result.text('模型列表 ✓,正在验证补全端点…').css('color', 'var(--SmartThemeQuoteColor)');
const genResp = await fetch('/api/backends/chat-completions/generate', {
method: 'POST',
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
reverse_proxy: apiUrl,
proxy_password: apiKey,
chat_completion_source: 'openai',
model,
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 1,
stream: false,
}),
});
if (!genResp.ok) {
const genErr = await genResp.json().catch(() => ({}));
const genMsg = genErr?.error?.message || `补全端点返回 HTTP ${genResp.status}`;
$result.text(`模型列表 ✓,补全失败:${genMsg}`).css('color', 'var(--warning-color)');
toastr.warning(`补全端点测试失败:${genMsg}`);
return;
}
}
$result.text(`连接成功${count ? `${count} 个可用模型` : ''}`).css('color', 'var(--green)');
toastr.success('连接测试通过!');
return;
@@ -607,15 +767,71 @@ async function _testConnection($c) {
// ── Provider 切换 ─────────────────────────────────────────────────────────────
const GOOGLE_API_BASE = 'https://generativelanguage.googleapis.com/v1beta/openai';
/**
* 6 个享受 defaultUrl 自动填充的 vendor preset id。registry 之外的 provider
* sillytavern_backend / sillytavern_preset / custom_oai走各自的特殊逻辑。
*/
const VENDOR_PRESETS = new Set(['anthropic', 'openai', 'google', 'openrouter', 'deepseek', 'xai']);
function _handleProviderChange($c, provider) {
const isGoogle = provider === 'google';
$c.find('#amily2_pf_url_row').toggle(!isGoogle);
$c.find('#amily2_pf_google_note').toggle(isGoogle);
/**
* 处理 provider 变化的"展示侧"逻辑URL row 可见性 + vendor 提示框。
* 不修改 URL 输入值(避免编辑现有 profile 时被覆盖)。
* URL 自动填充由 _autofillVendorUrl 单独负责,仅在用户主动 change 时触发。
*/
async function _handleProviderChange($c, provider) {
const $urlRow = $c.find('#amily2_pf_url_row');
const $note = $c.find('#amily2_pf_vendor_note');
const $noteText = $c.find('#amily2_pf_vendor_note_text');
const $linkWrap = $c.find('#amily2_pf_vendor_note_link_wrap');
const $link = $c.find('#amily2_pf_vendor_note_link');
if (isGoogle) {
$c.find('#amily2_pf_url').val(GOOGLE_API_BASE);
// URL row 一律可见(包括 preset vendor —— 用户可能要切到代理/镜像)
$urlRow.show();
if (VENDOR_PRESETS.has(provider)) {
try {
const entry = await getVendorEntry(provider);
if (entry) {
$noteText.text(`${entry.displayName} — 默认接口地址已自动填写,如需走代理/镜像可在下方修改。`);
if (entry.doc) {
$link.attr('href', entry.doc).text('查看官方文档');
$linkWrap.show();
} else {
$linkWrap.hide();
}
$note.show();
return;
}
} catch (e) {
console.warn('[ApiConfig] vendor entry 加载失败:', e);
}
}
$note.hide();
}
/**
* 用户主动切换 provider 时,把 URL 字段写为该 vendor 的 defaultUrl。
* Custom 模式清空 URLST backend/preset 不动 URL。
* 同时刷新 customParams hint 与校验状态。
*/
async function _autofillVendorUrl($c, provider) {
if (provider === 'custom_oai') {
$c.find('#amily2_pf_url').val('');
_updateCustomParamsHint($c);
return;
}
if (!VENDOR_PRESETS.has(provider)) {
// sillytavern_backend / sillytavern_preset 等不修改 URL
return;
}
try {
const entry = await getVendorEntry(provider);
if (entry?.defaultUrl) {
$c.find('#amily2_pf_url').val(entry.defaultUrl);
_updateCustomParamsHint($c);
}
} catch (e) {
console.warn('[ApiConfig] autofill defaultUrl 失败:', e);
}
}
@@ -643,3 +859,153 @@ function _escapeHtml(str) {
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _getCustomParamsEditorState($c) {
const raw = ($c.find('#amily2_pf_custom_params').val() || '').trim();
if (!raw) {
return { valid: true, parsed: {}, empty: true };
}
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
return { valid: false, parsed: null, empty: false };
}
return { valid: true, parsed, empty: false };
} catch {
return { valid: false, parsed: null, empty: false };
}
}
function _getDefaultValueForParamType(type) {
const normalized = String(type || '').toLowerCase();
if (normalized.includes('array')) return [];
if (normalized.includes('object')) return {};
if (normalized.includes('integer') || normalized.includes('number')) return 0;
if (normalized.includes('boolean')) return false;
return '';
}
// ── customParams 辅助 ────────────────────────────────────────────────────────
/**
* 根据当前 URL 输入识别 vendor并把已知参数列表渲染到 hint 行。
* registry 还没异步加载完时detectVendorSync 返回 null静默跳过。
*/
function _updateCustomParamsHint($c) {
const $hint = $c.find('#amily2_pf_custom_params_hint');
if (!$hint.length) return;
const apiUrl = $c.find('#amily2_pf_url').val()?.trim() || '';
const vendorId = detectVendorSync(apiUrl);
if (!vendorId) {
$hint.empty();
return;
}
const params = listVendorParamsSync(vendorId);
if (!params.length) {
$hint.empty();
return;
}
const editorState = _getCustomParamsEditorState($c);
getVendorEntry(vendorId).then(entry => {
const label = entry?.displayName || vendorId;
const disabledAttr = editorState.valid ? '' : ' disabled';
const buttons = params.map(param => `
<button type="button"
class="menu_button small_button amily2_param_hint_btn"
data-param-name="${_escapeHtml(param.name)}"
data-param-type="${_escapeHtml(param.type || '')}"
style="margin:2px 6px 2px 0;"
${disabledAttr}>${_escapeHtml(param.name)}</button>
`).join('');
const invalidNote = editorState.valid
? ''
: '<span style="margin-left:6px; color:var(--warning, #d9534f);">请先修复 JSON再插入参数。</span>';
$hint.html(`${_escapeHtml(label)} 已知参数:${buttons}${invalidNote}`);
});
}
/**
* 实时校验 customParams 文本框内容。空 / 合法 JSON object → 清空错误。
* 非 JSON 或非 object → 在 #_error 行显示。仅做提示,不阻断输入。
*/
function _validateCustomParamsLive($c) {
const $err = $c.find('#amily2_pf_custom_params_error');
if (!$err.length) return;
const state = _getCustomParamsEditorState($c);
if (state.empty) {
$err.hide().text('');
return;
}
if (state.valid) {
$err.hide().text('');
return;
}
try {
JSON.parse(($c.find('#amily2_pf_custom_params').val() || '').trim());
$err.show().text('需要是 JSON 对象({} 形式),不能是数组或基本类型。');
} catch (e) {
$err.show().text(`JSON 解析失败:${e.message}`);
}
}
function _insertParamToCustomParams($c, paramName, paramType) {
const state = _getCustomParamsEditorState($c);
if (!state.valid) return;
const next = { ...(state.parsed || {}) };
if (Object.prototype.hasOwnProperty.call(next, paramName)) {
return;
}
next[paramName] = _getDefaultValueForParamType(paramType);
$c.find('#amily2_pf_custom_params').val(JSON.stringify(next, null, 2));
_validateCustomParamsLive($c);
_updateCustomParamsHint($c);
}
/**
* 清除旧配置残留 —— 二次确认 → 调 clearLegacyConfig → 反馈结果。
*/
function _handleClearLegacyConfig($c) {
const confirmed = window.confirm(
'【清除旧配置残留】\n\n' +
'即将删除以下数据:\n' +
'• extension_settings 中各模块的旧 URL / Model / 温度 / maxTokens / 模式等字段\n' +
'• localStorage 中各模块的旧 API Key\n\n' +
'⚠️ 操作不可恢复。如果某个槽位还没分配 profile操作会被阻止。\n\n' +
'确定继续吗?'
);
if (!confirmed) return;
try {
const result = clearLegacyConfig();
if (!result.ok) {
toastr.error(result.error || '清除失败,未知错误。', '清除被阻止');
return;
}
toastr.success(
`已清除 ${result.clearedFields} 个旧字段、${result.clearedKeys} 个旧 API Key。建议刷新页面验证。`,
'清除完成',
{ timeOut: 6000 }
);
} catch (e) {
console.error('[ApiConfig] 清除旧配置失败:', e);
toastr.error(`清除失败: ${e.message}`, '错误');
}
}
/**
* saveProfile 调用:解析 customParams 文本,失败返回 null调用方中止保存
* 空文本视为空对象 {}。
*
* @returns {Object | null}
*/
function _parseCustomParamsOrFail($c) {
const state = _getCustomParamsEditorState($c);
return state.valid ? (state.parsed || {}) : null;
}

View File

@@ -4,13 +4,15 @@ import { defaultSettings, extensionName, saveSettings, extensionBasePath } from
import { pluginAuthStatus, activatePluginAuthorization, getPasswordForDate } from "../utils/auth.js";
import { fetchModels, testApiConnection } from "../core/api.js";
import { safeLorebooks, safeCharLorebooks, safeLorebookEntries } from "../core/tavernhelper-compatibility.js";
import { configManager } from '../utils/config/ConfigManager.js';
import { setAvailableModels, populateModelDropdown, getLatestUpdateInfo } from "./state.js";
import { fixCommand, testReplyChecker } from "../core/commands.js";
import { messageFormatting } from '/script.js';
import { executeManualCommand } from '../core/autoHideManager.js';
import { showContentModal, showHtmlModal } from './page-window.js';
import { showContentModal, showHtmlModal, showCwbWarningModal } from './page-window.js';
import { openAutoCharCardWindow } from '../core/auto-char-card/ui-bindings.js';
import { showPresetSettings } from '../PresetSettings/prese_ui.js';
function displayDailyAuthCode() {
const displayEl = document.getElementById('amily2_daily_code_display');
@@ -434,6 +436,10 @@ export function bindModalEvents() {
bindAmily2ModalWorldBookSettings();
const container = $("#amily2_drawer_content").length ? $("#amily2_drawer_content") : $("#amily2_chat_optimiser");
const apiConfigButton = container.find('#amily2_open_api_config');
if (apiConfigButton.length && !container.find('#amily2_open_rule_config').length) {
apiConfigButton.after(' <button id="amily2_open_rule_config" class="menu_button wide_button"><i class="fas fa-list-check"></i> 规则配置</button>');
}
// Collapsible sections logic
container.find('.collapsible-legend').each(function() {
@@ -801,7 +807,7 @@ export function bindModalEvents() {
container
.off("click.amily2.chamber_nav")
.on("click.amily2.chamber_nav",
"#amily2_open_text_optimization, #amily2_open_plot_optimization, #amily2_open_additional_features, #amily2_open_rag_palace, #amily2_open_memorisation_forms, #amily2_open_character_world_book, #amily2_open_world_editor, #amily2_open_glossary, #amily2_open_renderer, #amily2_open_super_memory, #amily2_open_auto_char_card, #amily2_open_api_config, #amily2_open_sfigen, #amily2_back_to_main_settings, #amily2_back_to_main_from_hanlinyuan, #amily2_back_to_main_from_forms, #amily2_back_to_main_from_optimization, #amily2_back_to_main_from_text_optimization, #amily2_back_to_main_from_cwb, #amily2_back_to_main_from_world_editor, #amily2_back_to_main_from_glossary, #amily2_renderer_back_button, #amily2_back_to_main_from_super_memory, #amily2_back_to_main_from_api_config, #amily2_sfigen_back_to_main", function () {
"#amily2_open_text_optimization, #amily2_open_plot_optimization, #amily2_open_additional_features, #amily2_open_rag_palace, #amily2_open_memorisation_forms, #amily2_open_character_world_book, #amily2_open_world_editor, #amily2_open_glossary, #amily2_open_renderer, #amily2_open_super_memory, #amily2_open_auto_char_card, #amily2_open_api_config, #amily2_open_rule_config, #amily2_open_sfigen, #amily2_open_preset_editor, #amily2_back_to_main_settings, #amily2_back_to_main_from_hanlinyuan, #amily2_back_to_main_from_forms, #amily2_back_to_main_from_optimization, #amily2_back_to_main_from_text_optimization, #amily2_back_to_main_from_cwb, #amily2_back_to_main_from_world_editor, #amily2_back_to_main_from_glossary, #amily2_renderer_back_button, #amily2_back_to_main_from_super_memory, #amily2_back_to_main_from_api_config, #amily2_back_to_main_from_rule_config, #amily2_sfigen_back_to_main", function () {
if (!pluginAuthStatus.authorized) return;
const mainPanel = container.find('.plugin-features');
@@ -816,6 +822,7 @@ export function bindModalEvents() {
const rendererPanel = container.find('#amily2_renderer_panel');
const superMemoryPanel = container.find('#amily2_super_memory_panel');
const apiConfigPanel = container.find('#amily2_api_config_panel');
const ruleConfigPanel = container.find('#amily2_rule_config_panel');
const sfigenPanel = container.find('#amily2_sfigen_panel');
mainPanel.hide();
@@ -830,6 +837,7 @@ export function bindModalEvents() {
rendererPanel.hide();
superMemoryPanel.hide();
apiConfigPanel.hide();
ruleConfigPanel.hide();
sfigenPanel.hide();
switch (this.id) {
@@ -867,7 +875,10 @@ export function bindModalEvents() {
memorisationFormsPanel.show();
break;
case 'amily2_open_character_world_book':
characterWorldBookPanel.show();
showCwbWarningModal(
() => characterWorldBookPanel.show(),
() => mainPanel.show()
);
break;
case 'amily2_open_world_editor':
worldEditorPanel.show();
@@ -878,9 +889,16 @@ export function bindModalEvents() {
case 'amily2_open_api_config':
apiConfigPanel.show();
break;
case 'amily2_open_rule_config':
ruleConfigPanel.show();
break;
case 'amily2_open_sfigen':
sfigenPanel.show();
break;
case 'amily2_open_preset_editor':
showPresetSettings();
mainPanel.show();
return;
case 'amily2_back_to_main_settings':
case 'amily2_back_to_main_from_hanlinyuan':
case 'amily2_back_to_main_from_forms':
@@ -892,6 +910,7 @@ export function bindModalEvents() {
case 'amily2_renderer_back_button':
case 'amily2_back_to_main_from_super_memory':
case 'amily2_back_to_main_from_api_config':
case 'amily2_back_to_main_from_rule_config':
case 'amily2_sfigen_back_to_main':
mainPanel.show();
break;
@@ -1034,11 +1053,16 @@ export function bindModalEvents() {
});
container
.off("change.amily2.text")
.on("change.amily2.text", "#amily2_api_url, #amily2_api_key, #amily2_optimization_target_tag", function () {
.off("input.amily2.text change.amily2.text")
.on("input.amily2.text change.amily2.text", "#amily2_api_url, #amily2_api_key, #amily2_optimization_target_tag", function () {
if (!pluginAuthStatus.authorized) return;
const key = snakeToCamel(this.id.replace("amily2_", ""));
updateAndSaveSetting(key, this.value);
// apiKey 是敏感字段,必须经 configManager 写入 localStorage
if (key === 'apiKey') {
configManager.set(key, this.value);
} else {
updateAndSaveSetting(key, this.value);
}
toastr.success(`配置 [${key}] 已自动保存!`, "Amily2号");
});
@@ -1076,6 +1100,25 @@ export function bindModalEvents() {
},
);
container
.off("input.amily2.number change.amily2.number")
.on(
"input.amily2.number change.amily2.number",
"#amily2_max_tokens, #amily2_temperature, #amily2_context_messages",
function () {
if (!pluginAuthStatus.authorized) return;
const key = snakeToCamel(this.id.replace("amily2_", ""));
const value = this.id.includes("temperature")
? parseFloat(this.value)
: parseInt(this.value, 10);
if (Number.isNaN(value)) return;
$(`#${this.id}_value`).text(value);
updateAndSaveSetting(key, value);
},
);
const promptMap = {
mainPrompt: "#amily2_main_prompt",
systemPrompt: "#amily2_system_prompt",
@@ -1097,6 +1140,14 @@ export function bindModalEvents() {
.off("change.amily2.prompt_selector")
.on("change.amily2.prompt_selector", selector, updateEditorView);
container
.off("input.amily2.unified_editor change.amily2.unified_editor")
.on("input.amily2.unified_editor change.amily2.unified_editor", editor, function () {
const selectedKey = $(selector).val();
if (!selectedKey) return;
updateAndSaveSetting(selectedKey, $(this).val());
});
container
.off("click.amily2.unified_save")
.on("click.amily2.unified_save", unifiedSaveButton, function () {
@@ -1119,8 +1170,8 @@ export function bindModalEvents() {
});
container
.off("change.amily2.lore_settings")
.on("change.amily2.lore_settings",
.off("input.amily2.lore_settings change.amily2.lore_settings")
.on("input.amily2.lore_settings change.amily2.lore_settings",
'select[id^="amily2_lore_"], input#amily2_lore_depth_input',
function () {
if (!pluginAuthStatus.authorized) return;

View File

@@ -7,6 +7,8 @@ import * as ContextUtils from '../core/utils/context-utils.js';
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,
@@ -16,6 +18,34 @@ import {
'use strict';
function escapeTextareaContent(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function escapeAttribute(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function _populateHlyRuleProfileSelect(select, slot, detail) {
const profiles = detail?.profiles ?? ruleProfileManager.listProfiles();
const assigned = detail?.assignments?.[slot] ?? ruleProfileManager.getAssignment(slot) ?? '';
select.innerHTML = [
'<option value="">— 未分配 —</option>',
...profiles.map(p =>
`<option value="${p.id}" ${p.id === assigned ? 'selected' : ''}>${escapeTextareaContent(p.name || p.id)}</option>`
),
].join('');
}
function setupGlobalEventHandlers() {
window.saveHLYSettings = () => saveSettingsFromUI(false); // false表示非自动保存
@@ -46,6 +76,7 @@ function updateAndSaveSetting(key, value) {
current[keys[keys.length - 1]] = value;
HanlinyuanCore.saveSettings();
log(`[自动保存] 设置项 '${key}' 已更新为: ${JSON.stringify(value)}`, 'success');
}
@@ -112,6 +143,8 @@ export function bindHanlinyuanEvents() {
}
setupGlobalEventHandlers();
syncSlot('ragEmbed');
syncSlot('ragRerank');
bindPanelToggleEvents();
bindInternalUIEvents();
bindTutorialEvents(); // 【新增】绑定教程按钮事件
@@ -121,14 +154,26 @@ export function bindHanlinyuanEvents() {
// 确保核心已经初始化
if (HanlinyuanCore.initialize) {
HanlinyuanCore.initialize();
try {
HanlinyuanCore.initialize();
} catch (e) {
console.error('[翰林院-枢纽] 核心初始化抛出异常:', e);
}
} else {
console.error('[翰林院-枢纽] 核心法典未能提供初始化圣旨!');
return;
}
loadSettingsToUI();
loadWorldbookList(); // 【新增】加载书库列表
try {
loadSettingsToUI();
} catch (e) {
console.error('[翰林院-枢纽] loadSettingsToUI 抛出异常:', e);
}
try {
loadWorldbookList();
} catch (e) {
console.error('[翰林院-枢纽] loadWorldbookList 抛出异常:', e);
}
log('[翰林院-枢纽] 已成功连接各部,政令畅通。', 'info');
const fileInput = document.getElementById('hanlinyuan-ingest-novel-file-input');
const fileNameSpan = document.getElementById('hanlinyuan-ingest-novel-file-name');
@@ -335,15 +380,7 @@ function bindInternalUIEvents() {
}
// 注入设置的UI逻辑已由 initializeUnifiedInjectionEditor 函数统一处理。
// 【新增】为“标签提取”复选框绑定事件
const tagExtractionToggle = document.getElementById('hly-tag-extraction-toggle');
const tagInputContainer = document.getElementById('hly-tag-input-container');
if (tagExtractionToggle && tagInputContainer) {
tagExtractionToggle.addEventListener('change', () => {
tagInputContainer.style.display = tagExtractionToggle.checked ? 'block' : 'none';
});
}
// 标签提取开关/输入框已在 2.1.0 重构中移除,改为规则配置下拉选单管理。
// 为“书库选择”下拉框绑定联动事件
const librarySelect = document.getElementById('hly-hist-select-library');
@@ -351,18 +388,34 @@ function bindInternalUIEvents() {
librarySelect.addEventListener('change', handleWorldbookSelectionChange);
}
// 为规则配置按钮绑定通用弹窗事件
const condensationRulesBtn = document.getElementById('hly-exclusion-rules-btn');
if (condensationRulesBtn) {
condensationRulesBtn.addEventListener('click', () => showRulesModal('condensation'));
// 浓缩 — 提取规则下拉选单
const condensationRuleSelect = document.getElementById('hly-condensation-rule-profile-select');
if (condensationRuleSelect) {
_populateHlyRuleProfileSelect(condensationRuleSelect, 'condensation');
condensationRuleSelect.addEventListener('change', () => {
ruleProfileManager.setAssignment('condensation', condensationRuleSelect.value || null);
const name = condensationRuleSelect.selectedOptions[0]?.textContent || '';
toastr.info(condensationRuleSelect.value ? `浓缩提取规则已切换为「${name}` : '浓缩提取规则已取消分配');
});
}
// 为“检索预处理”的配置按钮绑定事件
const queryPreprocessingBtn = document.getElementById('hly-query-preprocessing-rules-btn');
if (queryPreprocessingBtn) {
queryPreprocessingBtn.addEventListener('click', () => showRulesModal('queryPreprocessing'));
// 查询预处理 — 提取规则下拉选单
const queryPrepRuleSelect = document.getElementById('hly-query-preprocessing-rule-profile-select');
if (queryPrepRuleSelect) {
_populateHlyRuleProfileSelect(queryPrepRuleSelect, 'queryPreprocessing');
queryPrepRuleSelect.addEventListener('change', () => {
ruleProfileManager.setAssignment('queryPreprocessing', queryPrepRuleSelect.value || null);
const name = queryPrepRuleSelect.selectedOptions[0]?.textContent || '';
toastr.info(queryPrepRuleSelect.value ? `查询预处理规则已切换为「${name}` : '查询预处理规则已取消分配');
});
}
// 规则配置中心保存/删除后自动刷新翰林院下拉选单
document.addEventListener('amily2:ruleProfilesChanged', (e) => {
if (condensationRuleSelect) _populateHlyRuleProfileSelect(condensationRuleSelect, 'condensation', e.detail);
if (queryPrepRuleSelect) _populateHlyRuleProfileSelect(queryPrepRuleSelect, 'queryPreprocessing', e.detail);
});
// 为自定义多选下拉框绑定事件
const multiSelectBtn = document.getElementById('hly-hist-entry-multiselect-btn');
const optionsContainer = document.getElementById('hly-hist-entry-multiselect-options');
@@ -547,7 +600,7 @@ function handleApiModeChange() {
}
}
function loadSettingsToUI() {
export function loadSettingsToUI() {
const settings = HanlinyuanCore.getSettings();
if (!settings) return;
@@ -593,14 +646,8 @@ function loadSettingsToUI() {
histMaxRetriesEl.value = settings.historiographyMaxRetries ?? 2;
}
// 新增:加载标签提取设置
const tagExtractionToggle = document.getElementById('hly-tag-extraction-toggle');
const tagInput = document.getElementById('hly-tag-input');
const tagInputContainer = document.getElementById('hly-tag-input-container');
tagExtractionToggle.checked = settings.condensation.tagExtractionEnabled;
tagInput.value = settings.condensation.tags; // 直接使用从核心获取的值
tagInputContainer.style.display = tagExtractionToggle.checked ? 'block' : 'none';
// 标签提取开关/输入框已在 2.1.0 重构中移除(改为规则配置下拉选单),
// 这里不再回填对应 DOM避免因元素已不存在导致 loadSettingsToUI 中断。
// Rerank 设置
document.getElementById('hly-rerank-enabled').checked = settings.rerank.enabled;
@@ -864,8 +911,8 @@ async function renderKnowledgeBases() {
} catch (error) {
console.error('[翰林院-枢纽] 渲染知识库列表失败:', error);
localContainer.innerHTML = `<p class="hly-notes log-error"><i>加载失败: ${error.message}</i></p>`;
globalContainer.innerHTML = `<p class="hly-notes log-error"><i>加载失败: ${error.message}</i></p>`;
localContainer.innerHTML = `<p class="hly-notes log-error"><i>加载失败: ${escapeTextareaContent(error.message)}</i></p>`;
globalContainer.innerHTML = `<p class="hly-notes log-error"><i>加载失败: ${escapeTextareaContent(error.message)}</i></p>`;
}
}
@@ -964,8 +1011,8 @@ function _createKbItemElement(id, kb, scope, vectorCount) {
item.innerHTML = `
<div class="hly-kb-name-container">
<input type="checkbox" class="hly-kb-item-checkbox" data-kb-id="${id}">
<span class="hly-kb-name" title="ID: ${id}">${kb.name} (${vectorCount}条)</span>
<input type="checkbox" class="hly-kb-item-checkbox" data-kb-id="${escapeAttribute(id)}">
<span class="hly-kb-name" title="ID: ${escapeAttribute(id)}">${escapeTextareaContent(kb.name || '')} (${Number(vectorCount) || 0}条)</span>
</div>
<div class="hly-kb-actions">
${moveButtonHtml}
@@ -1473,11 +1520,11 @@ function updateEntryOptions(query, allEntries) {
filteredEntries.forEach(entry => {
const displayText = query ?
highlightSearchMatch(entry.comment, query) :
entry.comment;
escapeTextareaContent(entry.comment);
const optionHtml = `
<label class="hly-multiselect-option" title="${entry.comment} (Key: ${entry.key})">
<input type="checkbox" class="hly-hist-entry-checkbox" value="${entry.key}">
<label class="hly-multiselect-option" title="${escapeAttribute(entry.comment)} (Key: ${escapeAttribute(entry.key)})">
<input type="checkbox" class="hly-hist-entry-checkbox" value="${escapeAttribute(entry.key)}">
<span>${displayText}</span>
</label>`;
optionsContainer.insertAdjacentHTML('beforeend', optionHtml);
@@ -1563,134 +1610,20 @@ API端点: ${settings.retrieval.apiEndpoint}
log(`查询宝库状态失败: ${error.message}`, 'error');
}
}
function showRulesModal(type) {
const settings = HanlinyuanCore.getSettings();
const config = settings[type];
if (!config) {
console.error(`[翰林院-枢纽] 未找到类型为 "${type}" 的配置项。`);
return;
}
const title = type === 'condensation' ? '编辑凝识内容排除规则' : '编辑检索内容排除规则';
const rules = config.exclusionRules || [];
const createRuleRowHtml = (rule = { start: '', end: '' }, index) => `
<div class="hly-exclusion-rule-row" data-index="${index}">
<input type="text" class="hly-imperial-brush" value="${(rule.start || '').replace(/"/g, '"')}" placeholder="开始字符串, 如 <!--">
<span style="margin: 0 5px;">到</span>
<input type="text" class="hly-imperial-brush" value="${(rule.end || '').replace(/"/g, '"')}" placeholder="结束字符串, 如 -->">
<button class="hly-delete-rule-btn" title="删除此规则">&times;</button>
</div>
`;
const rulesHtml = rules.map(createRuleRowHtml).join('');
// 标签提取部分只在“检索预处理”设置中显示
const tagExtractionFieldset = type === 'queryPreprocessing' ? `
<fieldset class="hly-settings-group">
<legend><i class="fas fa-tags"></i> 标签提取</legend>
<div class="hly-control-block" style="flex-direction: row; justify-content: space-between; align-items: center;">
<label for="hly-modal-tag-extraction-enabled">启用标签提取</label>
<label class="hly-toggle-switch">
<input type="checkbox" id="hly-modal-tag-extraction-enabled" ${config.tagExtractionEnabled ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
<div id="hly-modal-tag-input-container" class="hly-control-block" style="display: ${config.tagExtractionEnabled ? 'block' : 'none'};">
<label for="hly-modal-tag-input">输入标签 (以逗号分隔):</label>
<textarea id="hly-modal-tag-input" class="hly-imperial-brush" rows="2" placeholder="例如: content,details,摘要">${config.tags || ''}</textarea>
</div>
</fieldset>
` : '';
const modalHtml = `
<div id="hly-rules-modal-container">
${tagExtractionFieldset}
<fieldset class="hly-settings-group">
<legend><i class="fas fa-ban"></i> 内容排除规则</legend>
<p class="hly-notes">在这里定义需要从提取内容中排除的文本片段。例如排除HTML注释可以设置开始字符串为 \`<!--\`,结束字符串为 \`-->\`。</p>
<div id="hly-rules-list">${rulesHtml.length > 0 ? rulesHtml : '<p class="hly-notes" style="text-align:center;">暂无规则</p>'}</div>
<button id="hly-add-rule-btn" class="hly-action-button" style="margin-top: 10px;">
<i class="fas fa-plus"></i> 添加新规则
</button>
</fieldset>
</div>
<style>
.hly-exclusion-rule-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.hly-exclusion-rule-row input { flex-grow: 1; }
.hly-delete-rule-btn { background: #c0392b; color: white; border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer; font-size: 16px; line-height: 24px; text-align: center; padding: 0; flex-shrink: 0; }
</style>
`;
showHtmlModal(title, modalHtml, {
okText: '保存规则',
onOk: (dialogElement) => {
const newRules = [];
dialogElement.find('.hly-exclusion-rule-row').each(function () {
const start = $(this).find('input').eq(0).val().trim();
const end = $(this).find('input').eq(1).val().trim();
// 规则必须至少有一个开始字符串
if (start) {
newRules.push({ start, end });
}
});
const newConfig = { ...config, exclusionRules: newRules };
// 如果是检索预处理设置,则同时保存标签提取的设置
if (type === 'queryPreprocessing') {
newConfig.tagExtractionEnabled = dialogElement.find('#hly-modal-tag-extraction-enabled').is(':checked');
newConfig.tags = dialogElement.find('#hly-modal-tag-input').val();
}
updateAndSaveSetting(type, newConfig);
toastr.success('规则已保存。', '圣旨已达');
},
onShow: (dialogElement) => {
const rulesList = dialogElement.find('#hly-rules-list');
dialogElement.find('#hly-add-rule-btn').on('click', () => {
const newIndex = rulesList.children('.hly-exclusion-rule-row').length;
const newRowHtml = createRuleRowHtml(undefined, newIndex);
if (rulesList.find('p').length > 0) {
rulesList.html(newRowHtml);
} else {
rulesList.append(newRowHtml);
}
});
rulesList.on('click', '.hly-delete-rule-btn', function () {
$(this).closest('.hly-exclusion-rule-row').remove();
if (rulesList.children().length === 0) {
rulesList.html('<p class="hly-notes" style="text-align:center;">暂无规则</p>');
}
});
// 如果是检索预处理设置则绑定标签提取的UI事件
if (type === 'queryPreprocessing') {
const tagToggle = dialogElement.find('#hly-modal-tag-extraction-enabled');
const tagInputContainer = dialogElement.find('#hly-modal-tag-input-container');
tagToggle.on('change', () => {
tagInputContainer.css('display', tagToggle.is(':checked') ? 'block' : 'none');
});
}
}
});
}
function previewCondensation() {
const resultsEl = document.getElementById('hly-condensation-results');
try {
// 1. 获取UI设置和新规则
const settings = HanlinyuanCore.getSettings();
const exclusionRules = settings.condensation.exclusionRules || [];
const condensationRuleConfig = resolveCondensationRuleConfig(settings);
const exclusionRules = condensationRuleConfig.exclusionRules || [];
const overrideMessageTypes = {
user: document.getElementById('hly-include-user').checked,
ai: document.getElementById('hly-include-ai').checked,
};
const useTagExtraction = document.getElementById('hly-tag-extraction-toggle').checked;
const useTagExtraction = condensationRuleConfig.tagExtractionEnabled;
const tagsToExtract = useTagExtraction
? document.getElementById('hly-tag-input').value.split(',').map(t => t.trim()).filter(Boolean)
? (condensationRuleConfig.tags || '').split(',').map(t => t.trim()).filter(Boolean)
: [];
// 2. 获取原始消息
@@ -1758,7 +1691,7 @@ function previewCondensation() {
<textarea class="hly-preview-textarea"
data-floor="${item.floor}"
data-is-user="${item.is_user}"
data-send-date="${item.send_date}">${item.content}</textarea>
data-send-date="${item.send_date}">${escapeTextareaContent(item.content)}</textarea>
</div>
</details>
<button class="hly-preview-delete-btn-v2" data-target="${item.id}" title="删除此条">&times;</button>
@@ -1836,7 +1769,7 @@ function log(message, type = 'info') {
}
p.className = `hly-log-entry ${colorClass}`;
p.innerHTML = `<i class="fa-solid ${icon}"></i> [${timestamp}] ${message}`;
p.innerHTML = `<i class="fa-solid ${escapeAttribute(icon)}"></i> [${escapeTextareaContent(timestamp)}] ${escapeTextareaContent(message)}`;
// 移除初始的占位符
const placeholder = logOutput.querySelector('.hly-log-placeholder');

View File

@@ -5,7 +5,8 @@ import {
saveSettings,
} from "../utils/settings.js";
import { showHtmlModal } from './page-window.js';
import { applyExclusionRules, extractBlocksByTags } from '../core/utils/rag-tag-extractor.js';
import { configManager } from '../utils/config/ConfigManager.js';
import { ruleProfileManager, resolveHistoriographyRuleConfig } from '../utils/config/RuleProfileManager.js';
import {
getAvailableWorldbooks, getLoresForWorldbook,
@@ -16,6 +17,25 @@ import {
import { getNgmsApiSettings, testNgmsApiConnection, fetchNgmsModels } from "../core/api/Ngms_api.js";
function getHistoriographyRuleConfig() {
return resolveHistoriographyRuleConfig(extension_settings[extensionName] || {});
}
function _escapeHtml(text) {
return String(text ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _populateHistRuleProfileSelect(select, detail) {
const profiles = detail?.profiles ?? ruleProfileManager.listProfiles();
const assigned = detail?.assignments?.historiography ?? ruleProfileManager.getAssignment('historiography') ?? '';
select.innerHTML = [
'<option value="">— 未分配 —</option>',
...profiles.map(p =>
`<option value="${p.id}" ${p.id === assigned ? 'selected' : ''}>${_escapeHtml(p.name || p.id)}</option>`
),
].join('');
}
function setupPromptEditor(type) {
const selector = document.getElementById(
@@ -198,29 +218,19 @@ export function bindHistoriographyEvents() {
saveSettings();
});
// ========== 🏷️ 标签与排除规则绑定 (新增) ==========
const tagExtractionToggle = document.getElementById("historiography-tag-extraction-toggle");
const tagInputContainer = document.getElementById("historiography-tag-input-container");
const tagInput = document.getElementById("historiography-tag-input");
const exclusionRulesBtn = document.getElementById("historiography-exclusion-rules-btn");
tagExtractionToggle.checked = extension_settings[extensionName].historiographyTagExtractionEnabled ?? false;
tagInput.value = extension_settings[extensionName].historiographyTags ?? '';
tagInputContainer.style.display = tagExtractionToggle.checked ? 'block' : 'none';
tagExtractionToggle.addEventListener("change", (event) => {
const isEnabled = event.target.checked;
extension_settings[extensionName].historiographyTagExtractionEnabled = isEnabled;
tagInputContainer.style.display = isEnabled ? 'block' : 'none';
saveSettings();
});
tagInput.addEventListener("change", (event) => {
extension_settings[extensionName].historiographyTags = event.target.value;
saveSettings();
});
exclusionRulesBtn.addEventListener("click", showHistoriographyExclusionRulesModal);
// ========== 提取规则下拉选单 ==========
const histRuleSelect = document.getElementById("historiography-rule-profile-select");
if (histRuleSelect) {
_populateHistRuleProfileSelect(histRuleSelect);
histRuleSelect.addEventListener("change", () => {
ruleProfileManager.setAssignment('historiography', histRuleSelect.value || null);
const name = histRuleSelect.selectedOptions[0]?.textContent || '';
toastr.info(histRuleSelect.value ? `史官提取规则已切换为「${name}` : '史官提取规则已取消分配');
});
document.addEventListener('amily2:ruleProfilesChanged', (e) => {
_populateHistRuleProfileSelect(histRuleSelect, e.detail);
});
}
const expeditionExecuteBtn = document.getElementById("amily2_mhb_small_expedition_execute");
@@ -459,38 +469,23 @@ function bindNgmsApiEvents() {
// API配置字段绑定
const apiFields = [
{ id: 'amily2_ngms_api_url', key: 'ngmsApiUrl' },
{ id: 'amily2_ngms_api_key', key: 'ngmsApiKey' },
{ id: 'amily2_ngms_api_key', key: 'ngmsApiKey', sensitive: true },
{ id: 'amily2_ngms_model', key: 'ngmsModel' }
];
apiFields.forEach(field => {
const element = document.getElementById(field.id);
if (element) {
element.value = extension_settings[extensionName][field.key] || '';
// 敏感字段API Key从 configManagerlocalStorage读取
element.value = field.sensitive
? (configManager.get(field.key) || '')
: (extension_settings[extensionName][field.key] || '');
element.addEventListener('change', function() {
updateAndSaveSetting(field.key, this.value);
});
}
});
// 滑块控件绑定
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);
if (field.sensitive) {
configManager.set(field.key, this.value);
} else {
updateAndSaveSetting(field.key, this.value);
}
});
}
});
@@ -608,62 +603,3 @@ async function loadNgmsTavernPresets() {
}
}
function showHistoriographyExclusionRulesModal() {
const rules = extension_settings[extensionName].historiographyExclusionRules || [];
const createRuleRowHtml = (rule = { start: '', end: '' }, index) => `
<div class="hly-exclusion-rule-row" data-index="${index}">
<input type="text" class="hly-imperial-brush" value="${rule.start}" placeholder="开始字符, 如 <!--">
<span>到</span>
<input type="text" class="hly-imperial-brush" value="${rule.end}" placeholder="结束字符, 如 -->">
<button class="hly-delete-rule-btn" title="删除此规则">&times;</button>
</div>
`;
const rulesHtml = rules.map(createRuleRowHtml).join('');
const modalHtml = `
<div id="historiography-exclusion-rules-container">
<p class="hly-notes">在这里定义需要从提取内容中排除的文本片段。例如排除HTML注释可以设置开始字符为 \`<!--\`,结束字符为 \`-->\`。</p>
<div id="historiography-rules-list">${rulesHtml}</div>
<button id="historiography-add-rule-btn" class="hly-action-button" style="margin-top: 10px;">
<i class="fas fa-plus"></i> 添加新规则
</button>
</div>
<style>
.hly-exclusion-rule-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.hly-exclusion-rule-row input { flex-grow: 1; }
.hly-delete-rule-btn { background: #c0392b; color: white; border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer; font-size: 16px; line-height: 24px; text-align: center; padding: 0; }
</style>
`;
showHtmlModal('编辑内容排除规则', modalHtml, {
okText: '保存规则',
onOk: (dialogElement) => {
const newRules = [];
dialogElement.find('.hly-exclusion-rule-row').each(function() {
const start = $(this).find('input').eq(0).val().trim();
const end = $(this).find('input').eq(1).val().trim();
if (start && end) {
newRules.push({ start, end });
}
});
extension_settings[extensionName].historiographyExclusionRules = newRules;
saveSettings();
toastr.success('内容排除规则已保存。', '圣旨已达');
},
onShow: (dialogElement) => {
const rulesList = dialogElement.find('#historiography-rules-list');
dialogElement.find('#historiography-add-rule-btn').on('click', () => {
const newIndex = rulesList.children().length;
const newRowHtml = createRuleRowHtml({ start: '', end: '' }, newIndex);
rulesList.append(newRowHtml);
});
rulesList.on('click', '.hly-delete-rule-btn', function() {
$(this).closest('.hly-exclusion-rule-row').remove();
});
}
});
}

View File

@@ -2,6 +2,7 @@ import { getMemoryState, getHighlights } from '../core/table-system/manager.js';
import { extension_settings } from '/scripts/extensions.js';
import { extensionName } from '../utils/settings.js';
import { getContext } from '/scripts/extensions.js';
import { escapeHTML } from '../utils/utils.js';
const TABLE_CONTAINER_ID = 'amily2-chat-table-container';
const isTouchDevice = () => window.matchMedia('(pointer: coarse)').matches;
@@ -366,10 +367,11 @@ function renderTablesToHtml(tables, highlights) {
const icon = getTableIcon(table.name);
// 侧边栏按钮 (现在包含文字)
const safeTableName = escapeHTML(table.name || '');
sidebarHtml += `
<div class="amily2-game-tab ${isActive}" data-target="game-panel-${index}" title="${table.name}">
<div class="amily2-game-tab ${isActive}" data-target="game-panel-${index}" title="${safeTableName}">
<i class="fas ${icon}"></i>
<span class="tab-text">${table.name}</span>
<span class="tab-text">${safeTableName}</span>
</div>
`;
@@ -380,7 +382,7 @@ function renderTablesToHtml(tables, highlights) {
const theadHtml = `
<thead>
<tr>
${table.headers.map(header => `<th>${header}</th>`).join('')}
${table.headers.map(header => `<th>${escapeHTML(String(header ?? ''))}</th>`).join('')}
</tr>
</thead>
`;
@@ -396,7 +398,7 @@ function renderTablesToHtml(tables, highlights) {
const highlightKey = `${table.originalIndex}-${rowIndex}-${colIndex}`;
const isHighlighted = highlights.has(highlightKey);
const style = isHighlighted ? 'style="color: #00ff7f; font-weight: bold;"' : '';
tbodyHtml += `<td ${style}>${cell}</td>`;
tbodyHtml += `<td ${style}>${escapeHTML(String(cell ?? ''))}</td>`;
});
tbodyHtml += '</tr>';
});
@@ -413,7 +415,7 @@ function renderTablesToHtml(tables, highlights) {
contentHtml += `
<div id="game-panel-${index}" class="amily2-game-panel ${isActive}">
<div class="amily2-panel-title"><i class="fas ${icon}"></i> ${table.name}</div>
<div class="amily2-panel-title"><i class="fas ${icon}"></i> ${safeTableName}</div>
${tableHtml}
</div>
`;

View File

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

View File

@@ -13,6 +13,8 @@ import { testConcurrentApiConnection, fetchConcurrentModels } from '../core/api/
import { safeLorebooks, safeCharLorebooks, safeLorebookEntries } from "../core/tavernhelper-compatibility.js";
import { createDrawer } from '../ui/drawer.js';
import { pluginAuthStatus } from "../utils/auth.js";
import { configManager } from '../utils/config/ConfigManager.js';
import { SENSITIVE_KEYS } from '../utils/config/sensitive-keys.js';
// ========== Prompt Cache (module-level state) ==========
@@ -161,6 +163,9 @@ async function opt_saveSetting(key, value) {
console.error(`[${extensionName}] 保存角色数据失败:`, error);
toastr.error('无法保存角色卡设置,请检查控制台。');
}
} else if (SENSITIVE_KEYS.has(key)) {
// 敏感字段API Key经 configManager 写入 localStorage
configManager.set(key, value);
} else {
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
@@ -179,6 +184,25 @@ function opt_getMergedSettings() {
return { ...globalSettings, ...characterSettings };
}
function bindInputLikeSave(element, handler) {
if (!element) return;
element.oninput = handler;
element.onchange = handler;
}
function syncModelMirror(inputElement, selectElement) {
if (!inputElement || !selectElement) return;
const value = inputElement.value || '';
if (!value) return;
let option = Array.from(selectElement.options || []).find(item => item.value === value);
if (!option) {
option = new Option(value, value, true, true);
selectElement.add(option);
}
selectElement.value = value;
}
function opt_bindSlider(panel, sliderId, displayId) {
@@ -622,7 +646,8 @@ function opt_loadSettings(panel) {
panel.find('#amily2_opt_worldbook_enabled').prop('checked', settings.plotOpt_worldbookEnabled);
panel.find('#amily2_opt_new_memory_logic_enabled').prop('checked', settings.plotOpt_newMemoryLogicEnabled);
panel.find('#amily2_opt_api_url').val(settings.plotOpt_apiUrl);
panel.find('#amily2_opt_api_key').val(settings.plotOpt_apiKey);
// plotOpt_apiKey 是敏感字段,从 configManagerlocalStorage读取
panel.find('#amily2_opt_api_key').val(configManager.get('plotOpt_apiKey') || '');
const modelInput = panel.find('#amily2_opt_model');
const modelSelect = panel.find('#amily2_opt_model_select');
@@ -635,14 +660,13 @@ function opt_loadSettings(panel) {
modelSelect.append(new Option('<-请先获取模型', '', true, true));
}
panel.find('#amily2_opt_max_tokens').val(settings.plotOpt_max_tokens);
panel.find('#amily2_opt_temperature').val(settings.plotOpt_temperature);
syncModelMirror(modelInput.get(0), modelSelect.get(0));
panel.find('#amily2_opt_top_p').val(settings.plotOpt_top_p);
panel.find('#amily2_opt_presence_penalty').val(settings.plotOpt_presence_penalty);
panel.find('#amily2_opt_frequency_penalty').val(settings.plotOpt_frequency_penalty);
panel.find('#amily2_opt_context_turn_count').val(settings.plotOpt_contextTurnCount);
const contextLimit = settings.plotOpt_contextLimit ?? settings.plotOpt_contextTurnCount ?? defaultSettings.plotOpt_contextLimit;
panel.find('#amily2_opt_worldbook_char_limit').val(settings.plotOpt_worldbookCharLimit);
panel.find('#amily2_opt_context_limit').val(settings.plotOpt_contextLimit);
panel.find('#amily2_opt_context_limit').val(contextLimit);
panel.find('#amily2_opt_rate_main').val(settings.plotOpt_rateMain);
panel.find('#amily2_opt_rate_personal').val(settings.plotOpt_ratePersonal);
@@ -669,12 +693,9 @@ 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');
opt_bindSlider(panel, '#amily2_opt_context_turn_count', '#amily2_opt_context_turn_count_value');
opt_bindSlider(panel, '#amily2_opt_worldbook_char_limit', '#amily2_opt_worldbook_char_limit_value');
opt_bindSlider(panel, '#amily2_opt_context_limit', '#amily2_opt_context_limit_value');
@@ -701,14 +722,17 @@ function bindConcurrentApiEvents() {
const fields = [
{ id: 'amily2_plotOpt_concurrentApiProvider', key: 'plotOpt_concurrentApiProvider' },
{ id: 'amily2_plotOpt_concurrentApiUrl', key: 'plotOpt_concurrentApiUrl' },
{ id: 'amily2_plotOpt_concurrentApiKey', key: 'plotOpt_concurrentApiKey' },
{ id: 'amily2_plotOpt_concurrentApiKey', key: 'plotOpt_concurrentApiKey', sensitive: true },
{ id: 'amily2_plotOpt_concurrentModel', key: 'plotOpt_concurrentModel' }
];
fields.forEach(field => {
const element = document.getElementById(field.id);
if (element) {
element.value = settings[field.key] || '';
// 敏感字段API Key从 configManagerlocalStorage读取
element.value = field.sensitive
? (configManager.get(field.key) || '')
: (settings[field.key] || '');
}
});
@@ -786,11 +810,22 @@ function bindConcurrentApiEvents() {
fields.forEach(field => {
const element = document.getElementById(field.id);
if (element) {
element.addEventListener('change', function() {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName][field.key] = this.value;
saveSettingsDebounced();
});
const saveField = function() {
if (field.sensitive) {
configManager.set(field.key, this.value);
} else {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName][field.key] = this.value;
saveSettingsDebounced();
if (field.key === 'plotOpt_concurrentModel') {
syncModelMirror(
document.getElementById('amily2_plotOpt_concurrentModel'),
document.getElementById('amily2_plotOpt_concurrentModel_select')
);
}
}
};
bindInputLikeSave(element, saveField);
}
});
@@ -1004,12 +1039,32 @@ function bindConcurrentWorldbookEvents() {
});
}
function opt_purgeGarbageKeys() {
const store = extension_settings[extensionName];
if (!store) return;
let removed = 0;
for (const key of Object.keys(store)) {
// 历史 bug 造成的污染 keyhandleSettingChange 误把世界书/条目复选框当作设置项,
// 生成形如 plotOpt_amily2-opt-wb-*、plotOpt_amily2-opt-entry-*、plotOpt_amily2-opt-concurrent-wb-* 的键
if (/^plotOpt_amily2-opt-/.test(key)) {
delete store[key];
removed++;
}
}
if (removed > 0) {
console.log(`[${extensionName}] 清理残留的 ${removed} 条无效 plotOpt_* 设置键。`);
saveSettingsDebounced();
}
}
export function initializePlotOptimizationBindings() {
const panel = $('#amily2_plot_optimization_panel');
if (panel.length === 0 || panel.data('events-bound')) {
return;
}
opt_purgeGarbageKeys();
// Tab switching logic
panel.find('.sinan-navigation-deck').on('click', '.sinan-nav-item', function() {
const tabButton = $(this);
@@ -1140,7 +1195,11 @@ export function initializePlotOptimizationBindings() {
const handleSettingChange = function(element) {
const el = $(element);
const key_part = (element.name || element.id).replace('amily2_opt_', '');
const rawName = element.name || element.id || '';
// 仅处理下划线前缀的真实设置项;动态生成的世界书/条目复选框用连字符命名amily2-opt-wb-*、amily2-opt-entry-*
// 它们有自己的专属 handler若被此处捕获会生成 plotOpt_amily2-opt-... 的垃圾 key 污染 settings
if (!rawName.startsWith('amily2_opt_')) return;
const key_part = rawName.replace('amily2_opt_', '');
const key = 'plotOpt_' + key_part.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
let value = element.type === 'checkbox' ? element.checked : el.val();
@@ -1179,6 +1238,13 @@ export function initializePlotOptimizationBindings() {
handleSettingChange(this);
});
panel.on('input.amily2_opt change.amily2_opt', '#amily2_opt_model', function() {
syncModelMirror(
panel.find('#amily2_opt_model').get(0),
panel.find('#amily2_opt_model_select').get(0)
);
});
panel.on('change.amily2_opt', '#amily2_opt_model_select', function() {
const selectedModel = $(this).val();
if (selectedModel) {
@@ -1384,17 +1450,31 @@ function bindJqyhApiEvents() {
// API配置字段绑定
const apiFields = [
{ id: 'amily2_jqyh_api_url', key: 'jqyhApiUrl' },
{ id: 'amily2_jqyh_api_key', key: 'jqyhApiKey' },
{ id: 'amily2_jqyh_api_key', key: 'jqyhApiKey', sensitive: true },
{ id: 'amily2_jqyh_model', key: 'jqyhModel' }
];
apiFields.forEach(field => {
const element = document.getElementById(field.id);
if (element) {
element.value = extension_settings[extensionName][field.key] || '';
element.addEventListener('change', function() {
updateAndSaveSetting(field.key, this.value);
});
// 敏感字段API Key从 configManagerlocalStorage读取
element.value = field.sensitive
? (configManager.get(field.key) || '')
: (extension_settings[extensionName][field.key] || '');
const saveField = function() {
if (field.sensitive) {
configManager.set(field.key, this.value);
} else {
updateAndSaveSetting(field.key, this.value);
if (field.key === 'jqyhModel') {
syncModelMirror(
document.getElementById('amily2_jqyh_model'),
document.getElementById('amily2_jqyh_model_select')
);
}
}
};
bindInputLikeSave(element, saveField);
}
});

View File

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

154
ui/rule-config-bindings.js Normal file
View File

@@ -0,0 +1,154 @@
import { ruleProfileManager } from '../utils/config/RuleProfileManager.js';
let currentEditingId = null;
function createEmptyProfile() {
return {
id: '',
name: '',
tagExtractionEnabled: false,
tags: '',
exclusionRules: [],
excludeUserMessages: false,
};
}
function createRuleRow(rule = { start: '', end: '' }, index = 0) {
return `
<div class="amily2-rule-row" data-index="${index}">
<input type="text" class="text_pole amily2-rule-start" value="${escapeHtml(rule.start || '')}" placeholder="起始标记">
<input type="text" class="text_pole amily2-rule-end" value="${escapeHtml(rule.end || '')}" placeholder="结束标记">
<button type="button" class="menu_button danger small_button amily2-rule-remove">
<i class="fas fa-trash-alt"></i>
</button>
</div>
`;
}
function escapeHtml(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderRules(container, exclusionRules = []) {
const list = container.find('#amily2_rule_profile_rules');
if (!exclusionRules.length) {
list.html('<p class="notes">当前没有排除规则。</p>');
return;
}
list.html(exclusionRules.map((rule, index) => createRuleRow(rule, index)).join(''));
}
function collectProfile(container) {
const exclusionRules = [];
container.find('.amily2-rule-row').each(function () {
const start = $(this).find('.amily2-rule-start').val().trim();
const end = $(this).find('.amily2-rule-end').val().trim();
if (start) {
exclusionRules.push({ start, end });
}
});
return {
id: currentEditingId || '',
name: container.find('#amily2_rule_profile_name').val().trim(),
tagExtractionEnabled: container.find('#amily2_rule_profile_tag_toggle').is(':checked'),
tags: container.find('#amily2_rule_profile_tags').val(),
exclusionRules,
excludeUserMessages: container.find('#amily2_rule_profile_exclude_user').is(':checked'),
};
}
function renderProfileList(container) {
const list = container.find('#amily2_rule_profile_list');
const profiles = ruleProfileManager.listProfiles();
if (!profiles.length) {
list.html('<p class="notes">还没有规则配置。</p>');
return;
}
list.html(profiles.map(profile => `
<button type="button" class="menu_button wide_button amily2-rule-profile-item" data-id="${profile.id}">
<span>${escapeHtml(profile.name || profile.id)}</span>
</button>
`).join(''));
}
function fillEditor(container, profile) {
const current = profile || createEmptyProfile();
currentEditingId = current.id || null;
container.find('#amily2_rule_profile_name').val(current.name || '');
container.find('#amily2_rule_profile_tag_toggle').prop('checked', !!current.tagExtractionEnabled);
container.find('#amily2_rule_profile_tags').val(current.tags || '');
container.find('#amily2_rule_profile_tags_wrap').toggle(!!current.tagExtractionEnabled);
container.find('#amily2_rule_profile_exclude_user').prop('checked', !!current.excludeUserMessages);
renderRules(container, current.exclusionRules || []);
}
export function bindRuleConfigPanel(container) {
const $c = $(container);
renderProfileList($c);
fillEditor($c, createEmptyProfile());
$c.off('.ruleConfig');
$c.on('click.ruleConfig', '#amily2_rule_profile_new', () => {
fillEditor($c, createEmptyProfile());
});
$c.on('click.ruleConfig', '.amily2-rule-profile-item', function () {
const profile = ruleProfileManager.getProfile($(this).data('id'));
if (profile) {
fillEditor($c, profile);
}
});
$c.on('change.ruleConfig', '#amily2_rule_profile_tag_toggle', function () {
$c.find('#amily2_rule_profile_tags_wrap').toggle(this.checked);
});
$c.on('click.ruleConfig', '#amily2_rule_profile_add_rule', () => {
const rules = collectProfile($c).exclusionRules;
rules.push({ start: '', end: '' });
renderRules($c, rules);
});
$c.on('click.ruleConfig', '.amily2-rule-remove', function () {
$(this).closest('.amily2-rule-row').remove();
if ($c.find('.amily2-rule-row').length === 0) {
renderRules($c, []);
}
});
$c.on('click.ruleConfig', '#amily2_rule_profile_save', () => {
const profile = collectProfile($c);
if (!profile.name) {
toastr.warning('请先填写规则配置名称。');
return;
}
const saved = ruleProfileManager.saveProfile(profile);
fillEditor($c, saved);
renderProfileList($c);
toastr.success('规则配置已保存。');
});
$c.on('click.ruleConfig', '#amily2_rule_profile_delete', () => {
if (!currentEditingId) {
return;
}
if (!confirm('删除当前规则配置?引用它的位置会回退到旧配置。')) {
return;
}
ruleProfileManager.deleteProfile(currentEditingId);
fillEditor($c, createEmptyProfile());
renderProfileList($c);
toastr.success('规则配置已删除。');
});
}

View File

@@ -2,6 +2,7 @@ import { extension_settings } from "/scripts/extensions.js";
import { characters, this_chid } from '/script.js';
import { extensionName, defaultSettings } from "../utils/settings.js";
import { pluginAuthStatus } from "../utils/auth.js";
import { configManager } from '../utils/config/ConfigManager.js';
@@ -82,7 +83,7 @@ export function updateUI() {
$("#amily2_api_provider").val(settings.apiProvider || 'openai');
$("#amily2_api_url").val(settings.apiUrl);
$("#amily2_api_url").attr('type', 'text');
$("#amily2_api_key").val(settings.apiKey);
$("#amily2_api_key").val(configManager.get('apiKey') || '');
$("#amily2_model").val(settings.model);
$("#amily2_preset_selector").val(settings.tavernProfile);
@@ -197,10 +198,20 @@ export function updatePlotOptimizationUI() {
const settings = getMergedPlotOptSettings();
if (!settings) return;
const contextLimit = settings.plotOpt_contextLimit ?? settings.plotOpt_contextTurnCount ?? defaultSettings.plotOpt_contextLimit;
const worldbookCharLimit = settings.plotOpt_worldbookCharLimit ?? defaultSettings.plotOpt_worldbookCharLimit;
const worldbookEnabled = settings.plotOpt_worldbookEnabled ?? settings.plotOpt_worldbook_enabled ?? defaultSettings.plotOpt_worldbookEnabled;
let tableEnabledValue = settings.plotOpt_tableEnabled;
if (tableEnabledValue === true) {
tableEnabledValue = 'main';
} else if (tableEnabledValue === false || tableEnabledValue === undefined) {
tableEnabledValue = 'disabled';
}
$('#amily2_opt_enabled').prop('checked', settings.plotOpt_enabled);
$('#amily2_opt_ejs_enabled').prop('checked', settings.plotOpt_ejsEnabled);
$('#amily2_opt_worldbook_enabled').prop('checked', settings.plotOpt_worldbook_enabled);
$('#amily2_opt_table_enabled').prop('checked', settings.plotOpt_tableEnabled);
$('#amily2_opt_worldbook_enabled').prop('checked', worldbookEnabled);
$('#amily2_opt_table_enabled').val(tableEnabledValue);
$('#amily2_opt_main_prompt').val(settings.plotOpt_mainPrompt);
$('#amily2_opt_system_prompt').val(settings.plotOpt_systemPrompt);
@@ -212,13 +223,12 @@ export function updatePlotOptimizationUI() {
$('#amily2_opt_rate_cuckold').val(settings.plotOpt_rateCuckold);
const sliders = {
'#amily2_opt_context_limit': 'plotOpt_contextLimit',
'#amily2_opt_worldbook_char_limit': 'plotOpt_worldbookCharLimit',
'#amily2_opt_context_limit': contextLimit,
'#amily2_opt_worldbook_char_limit': worldbookCharLimit,
};
for (const sliderId in sliders) {
const key = sliders[sliderId];
const value = settings[key];
const value = sliders[sliderId];
const valueDisplayId = `${sliderId}_value`;
if (value !== undefined) {

View File

@@ -13,10 +13,44 @@ import { characters, this_chid, eventSource, event_types } from "/script.js";
import { fetchNccsModels, testNccsApiConnection } from '../core/api/NccsApi.js';
import { showGraphVisualization } from '../core/relationship-graph/visualizer.js';
import { escapeHTML } from '../utils/utils.js';
import { configManager } from '../utils/config/ConfigManager.js';
import { ruleProfileManager } from '../utils/config/RuleProfileManager.js';
import { bindTableTemplateEditors } from './table/template-bindings.js';
import { bindNccsApiEvents as bindNccsApiSettingsEvents } from './table/nccs-bindings.js';
import { bindChatTableDisplaySetting as bindChatTableDisplaySettings } from './table/chat-display-bindings.js';
const isTouchDevice = () => window.matchMedia('(pointer: coarse)').matches;
const getAllTablesContainer = () => document.getElementById('all-tables-container');
/**
* 通用:填充规则配置下拉选单
* @param {HTMLSelectElement} select
* @param {string} slot — RULE_SLOTS 中的功能槽名
*/
function _populateRuleProfileSelect(select, slot, detail) {
const profiles = detail?.profiles ?? ruleProfileManager.listProfiles();
const assigned = detail?.assignments?.[slot] ?? ruleProfileManager.getAssignment(slot) ?? '';
const options = [
'<option value="">— 未分配 —</option>',
...profiles.map(p =>
`<option value="${p.id}" ${p.id === assigned ? 'selected' : ''}>${escapeHTML(p.name || p.id)}</option>`
),
];
select.innerHTML = options.join('');
}
function getLiveExtensionSettings() {
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
return extension_settings[extensionName];
}
function isTableSystemEnabled() {
return getLiveExtensionSettings().table_system_enabled !== false;
}
let isResizing = false;
let activeTableIndex = 0; // 【V155.0】当前激活的表格索引
@@ -766,73 +800,6 @@ export function renderTables() {
}
function openTableRuleEditor() {
const settings = extension_settings[extensionName];
const tags = settings.table_tags_to_extract || '';
const exclusionRules = settings.table_exclusion_rules || [];
const rulesHtml = exclusionRules.map((rule, index) => `
<div class="exclusion-rule-item" data-index="${index}">
<input type="text" class="text_pole rule-start" value="${rule.start}" placeholder="起始标记">
<span>-</span>
<input type="text" class="text_pole rule-end" value="${rule.end}" placeholder="结束标记">
<button class="menu_button danger small_button remove-rule-btn"><i class="fas fa-trash-alt"></i></button>
</div>
`).join('');
const modalHtml = `
<div id="table-rules-editor" style="display: flex; flex-direction: column; gap: 20px;">
<div>
<label for="table-tags-input"><b>标签提取 (半角逗号分隔)</b></label>
<input type="text" id="table-tags-input" class="text_pole" value="${tags}" placeholder="例如: content,game,time">
<small class="notes">仅提取指定XML标签的内容例如填“content”即提取<content>...</content>中的内容。</small>
</div>
<div>
<label><b>内容排除规则</b></label>
<div id="exclusion-rules-list" style="display: flex; flex-direction: column; gap: 8px; margin-top: 8px;">${rulesHtml}</div>
<button id="add-exclusion-rule-btn" class="menu_button small_button" style="margin-top: 10px;"><i class="fas fa-plus"></i> 添加规则</button>
<small class="notes">移除所有被起始和结束标记包裹的内容(例如 OOC 部分)。</small>
</div>
</div>
`;
const dialog = showHtmlModal('配置独立提取规则', modalHtml, {
onOk: () => {
const newTags = document.getElementById('table-tags-input').value;
updateAndSaveTableSetting('table_tags_to_extract', newTags);
const newExclusionRules = [];
document.querySelectorAll('#exclusion-rules-list .exclusion-rule-item').forEach(item => {
const start = item.querySelector('.rule-start').value.trim();
const end = item.querySelector('.rule-end').value.trim();
if (start && end) {
newExclusionRules.push({ start, end });
}
});
updateAndSaveTableSetting('table_exclusion_rules', newExclusionRules);
toastr.success('独立提取规则已保存。');
},
onShow: (dialogElement) => {
const rulesList = dialogElement.find('#exclusion-rules-list');
dialogElement.find('#add-exclusion-rule-btn').on('click', () => {
const newIndex = rulesList.children().length;
const newItemHtml = `
<div class="exclusion-rule-item" data-index="${newIndex}">
<input type="text" class="text_pole rule-start" value="" placeholder="起始标记">
<span>-</span>
<input type="text" class="text_pole rule-end" value="" placeholder="结束标记">
<button class="menu_button danger small_button remove-rule-btn"><i class="fas fa-trash-alt"></i></button>
</div>`;
rulesList.append(newItemHtml);
});
rulesList.on('click', '.remove-rule-btn', function() {
$(this).closest('.exclusion-rule-item').remove();
});
}
});
}
function openRuleEditor(tableIndex) {
const tables = TableManager.getMemoryState();
@@ -1010,8 +977,6 @@ function openRuleEditor(tableIndex) {
function bindInjectionSettings() {
const settings = extension_settings[extensionName];
const masterSwitchCheckbox = document.getElementById('table-system-master-switch');
const enabledCheckbox = document.getElementById('table-injection-enabled');
const optimizationCheckbox = document.getElementById('context-optimization-enabled'); // 【V144.0】
@@ -1023,6 +988,15 @@ function bindInjectionSettings() {
return;
}
const getLiveSettings = () => {
const liveSettings = getLiveExtensionSettings();
if (!liveSettings.injection) {
liveSettings.injection = { position: 1, depth: 0, role: 0 };
}
return liveSettings;
};
const updateInjectionUI = () => {
const position = positionSelect.value;
const masterEnabled = masterSwitchCheckbox.checked;
@@ -1076,6 +1050,7 @@ function bindInjectionSettings() {
}
};
const settings = getLiveSettings();
masterSwitchCheckbox.checked = settings.table_system_enabled !== false;
enabledCheckbox.checked = settings.table_injection_enabled;
if (optimizationCheckbox) { // 【V144.0】
@@ -1094,7 +1069,8 @@ function bindInjectionSettings() {
if (masterSwitchCheckbox.dataset.eventsBound) return;
masterSwitchCheckbox.addEventListener('change', () => {
settings.table_system_enabled = masterSwitchCheckbox.checked;
const currentSettings = getLiveSettings();
currentSettings.table_system_enabled = masterSwitchCheckbox.checked;
saveSettingsDebounced();
updateInjectionUI();
@@ -1104,35 +1080,40 @@ function bindInjectionSettings() {
});
enabledCheckbox.addEventListener('change', () => {
settings.table_injection_enabled = enabledCheckbox.checked;
const currentSettings = getLiveSettings();
currentSettings.table_injection_enabled = enabledCheckbox.checked;
saveSettingsDebounced();
});
// 【V144.0】
if (optimizationCheckbox) {
optimizationCheckbox.addEventListener('change', () => {
settings.context_optimization_enabled = optimizationCheckbox.checked;
const currentSettings = getLiveSettings();
currentSettings.context_optimization_enabled = optimizationCheckbox.checked;
saveSettingsDebounced();
toastr.info(`上下文优化(世界书合并)已${optimizationCheckbox.checked ? '启用' : '禁用'}`);
});
}
positionSelect.addEventListener('change', () => {
settings.injection.position = parseInt(positionSelect.value, 10);
const currentSettings = getLiveSettings();
currentSettings.injection.position = parseInt(positionSelect.value, 10);
saveSettingsDebounced();
updateInjectionUI();
});
depthInput.addEventListener('input', () => {
settings.injection.depth = parseInt(depthInput.value, 10);
const currentSettings = getLiveSettings();
currentSettings.injection.depth = parseInt(depthInput.value, 10);
saveSettingsDebounced();
});
roleRadioGroup.forEach(radio => {
radio.addEventListener('change', () => {
if (radio.checked) {
settings.injection.role = parseInt(radio.value, 10);
const currentSettings = getLiveSettings();
currentSettings.injection.role = parseInt(radio.value, 10);
saveSettingsDebounced();
}
});
@@ -1144,15 +1125,12 @@ function bindInjectionSettings() {
function updateAndSaveTableSetting(key, value) {
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
extension_settings[extensionName][key] = value;
getLiveExtensionSettings()[key] = value;
saveSettingsDebounced();
}
function bindWorldBookSettings() {
const settings = extension_settings[extensionName];
const settings = getLiveExtensionSettings();
if (settings.table_worldbook_enabled === undefined) settings.table_worldbook_enabled = false;
if (settings.table_worldbook_char_limit === undefined) settings.table_worldbook_char_limit = 30000;
@@ -1168,6 +1146,8 @@ function bindWorldBookSettings() {
const refreshButton = document.getElementById('table_refresh_worldbooks');
const bookListContainer = document.getElementById('table_worldbook_checkbox_list');
const entryListContainer = document.getElementById('table_worldbook_entry_list');
const bookSearchInput = document.getElementById('table_worldbook_search');
const entrySearchInput = document.getElementById('table_entry_search');
if (!enabledCheckbox || !limitSlider || !limitValueSpan || !sourceRadios.length || !manualSelectWrapper || !refreshButton || !bookListContainer || !entryListContainer) {
log('无法找到世界书设置的相关UI元素绑定失败。', 'warn');
@@ -1175,6 +1155,7 @@ function bindWorldBookSettings() {
}
const saveSelectedEntries = () => {
const currentSettings = getLiveExtensionSettings();
const selected = {};
entryListContainer.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
const book = cb.dataset.book;
@@ -1184,17 +1165,18 @@ function bindWorldBookSettings() {
}
selected[book].push(uid);
});
settings.table_selected_entries = selected;
currentSettings.table_selected_entries = selected;
saveSettingsDebounced();
};
const renderWorldBookEntries = async () => {
entryListContainer.innerHTML = '<p>加载条目中...</p>';
const source = settings.table_worldbook_source || 'character';
const currentSettings = getLiveExtensionSettings();
const source = currentSettings.table_worldbook_source || 'character';
let bookNames = [];
if (source === 'manual') {
bookNames = settings.table_selected_worldbooks || [];
bookNames = currentSettings.table_selected_worldbooks || [];
} else {
if (this_chid !== undefined && this_chid >= 0 && characters[this_chid]) {
try {
@@ -1241,7 +1223,7 @@ function bindWorldBookSettings() {
checkbox.dataset.book = entry.bookName;
checkbox.dataset.uid = entry.uid;
const isChecked = settings.table_selected_entries[entry.bookName]?.includes(String(entry.uid));
const isChecked = currentSettings.table_selected_entries[entry.bookName]?.includes(String(entry.uid));
checkbox.checked = !!isChecked;
const label = document.createElement('label');
@@ -1271,15 +1253,16 @@ function bindWorldBookSettings() {
checkbox.type = 'checkbox';
checkbox.id = `wb-check-${book.file_name}`;
checkbox.value = book.file_name;
checkbox.checked = settings.table_selected_worldbooks.includes(book.file_name);
checkbox.checked = getLiveExtensionSettings().table_selected_worldbooks.includes(book.file_name);
checkbox.addEventListener('change', () => {
const currentSettings = getLiveExtensionSettings();
if (checkbox.checked) {
if (!settings.table_selected_worldbooks.includes(book.file_name)) {
settings.table_selected_worldbooks.push(book.file_name);
if (!currentSettings.table_selected_worldbooks.includes(book.file_name)) {
currentSettings.table_selected_worldbooks.push(book.file_name);
}
} else {
settings.table_selected_worldbooks = settings.table_selected_worldbooks.filter(name => name !== book.file_name);
currentSettings.table_selected_worldbooks = currentSettings.table_selected_worldbooks.filter(name => name !== book.file_name);
}
saveSettingsDebounced();
renderWorldBookEntries();
@@ -1300,7 +1283,7 @@ function bindWorldBookSettings() {
};
const updateManualSelectVisibility = () => {
const isManual = settings.table_worldbook_source === 'manual';
const isManual = getLiveExtensionSettings().table_worldbook_source === 'manual';
manualSelectWrapper.style.display = isManual ? 'block' : 'none';
renderWorldBookEntries();
if (isManual) {
@@ -1320,20 +1303,23 @@ function bindWorldBookSettings() {
if (enabledCheckbox.dataset.eventsBound) return;
enabledCheckbox.addEventListener('change', () => {
settings.table_worldbook_enabled = enabledCheckbox.checked;
const currentSettings = getLiveExtensionSettings();
currentSettings.table_worldbook_enabled = enabledCheckbox.checked;
saveSettingsDebounced();
});
limitSlider.addEventListener('input', () => { limitValueSpan.textContent = limitSlider.value; });
limitSlider.addEventListener('change', () => {
settings.table_worldbook_char_limit = parseInt(limitSlider.value, 10);
const currentSettings = getLiveExtensionSettings();
currentSettings.table_worldbook_char_limit = parseInt(limitSlider.value, 10);
saveSettingsDebounced();
});
sourceRadios.forEach(radio => {
radio.addEventListener('change', () => {
if (radio.checked) {
settings.table_worldbook_source = radio.value;
const currentSettings = getLiveExtensionSettings();
currentSettings.table_worldbook_source = radio.value;
updateManualSelectVisibility();
saveSettingsDebounced();
}
@@ -1347,12 +1333,32 @@ function bindWorldBookSettings() {
}
});
if (bookSearchInput) {
bookSearchInput.addEventListener('input', () => {
const keyword = bookSearchInput.value.trim().toLowerCase();
bookListContainer.querySelectorAll('.checkbox-item').forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(keyword) ? '' : 'none';
});
});
}
if (entrySearchInput) {
entrySearchInput.addEventListener('input', () => {
const keyword = entrySearchInput.value.trim().toLowerCase();
entryListContainer.querySelectorAll('.checkbox-item').forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(keyword) ? '' : 'none';
});
});
}
enabledCheckbox.dataset.eventsBound = 'true';
log('世界书设置已成功绑定。', 'success');
}
export function bindTableEvents() {
const panel = document.getElementById('amily2_memorisation_forms_panel');
export function bindTableEvents(panelElement = null) {
const panel = panelElement || document.getElementById('amily2_memorisation_forms_panel');
if (!panel || panel.dataset.eventsBound) {
return;
}
@@ -1365,10 +1371,9 @@ export function bindTableEvents() {
const batchSlider = document.getElementById('secondary-filler-batch');
const bufferSlider = document.getElementById('secondary-filler-buffer');
const maxRetriesSlider = document.getElementById('secondary-filler-max-retries'); // 【新增】
const delaySlider = document.getElementById('secondary-filler-delay');
const independentRulesContainer = document.getElementById('table-independent-rules-container');
const independentRulesToggle = document.getElementById('table-independent-rules-enabled');
const configureRulesBtn = document.getElementById('table-configure-rules-btn');
const tableRuleProfileSelect = document.getElementById('table-rule-profile-select');
const updateFillingModeUI = () => {
const currentMode = extension_settings[extensionName]?.filling_mode || 'main-api';
@@ -1382,12 +1387,8 @@ export function bindTableEvents() {
secondaryFillerControls.style.display = isSecondaryMode ? 'block' : 'none';
}
if (independentRulesContainer) {
independentRulesContainer.style.display = 'flex';
}
if (independentRulesToggle && configureRulesBtn) {
configureRulesBtn.style.display = independentRulesToggle.checked ? 'block' : 'none';
if (tableRuleProfileSelect) {
_populateRuleProfileSelect(tableRuleProfileSelect, 'table');
}
};
@@ -1438,31 +1439,57 @@ export function bindTableEvents() {
if (maxRetriesSlider) {
const value = extension_settings[extensionName]?.secondary_filler_max_retries ?? 2;
maxRetriesSlider.value = value;
maxRetriesSlider.addEventListener('change', function() {
updateAndSaveTableSetting('secondary_filler_max_retries', parseInt(this.value, 10));
toastr.info(`最大重试次数已设置为 ${this.value}`);
});
}
if (independentRulesToggle) {
independentRulesToggle.checked = extension_settings[extensionName]?.table_independent_rules_enabled ?? false;
independentRulesToggle.addEventListener('change', () => {
updateAndSaveTableSetting('table_independent_rules_enabled', independentRulesToggle.checked);
updateFillingModeUI();
if (delaySlider) {
const value = extension_settings[extensionName]?.secondary_filler_delay ?? 0;
delaySlider.value = value;
delaySlider.addEventListener('change', function() {
const parsed = Math.max(0, parseInt(this.value, 10) || 0);
this.value = parsed;
updateAndSaveTableSetting('secondary_filler_delay', parsed);
toastr.info(`触发延迟已设置为 ${parsed} 毫秒。`);
});
}
const fcToggle = document.getElementById('table-fill-function-call-enabled');
if (fcToggle) {
fcToggle.checked = extension_settings[extensionName]?.tableFillFunctionCall ?? false;
fcToggle.addEventListener('change', function() {
updateAndSaveTableSetting('tableFillFunctionCall', this.checked);
toastr.info(`Function Call 填表已${this.checked ? '启用' : '禁用'}`);
});
}
updateFillingModeUI();
if (configureRulesBtn) {
configureRulesBtn.addEventListener('click', openTableRuleEditor);
if (tableRuleProfileSelect) {
_populateRuleProfileSelect(tableRuleProfileSelect, 'table');
tableRuleProfileSelect.addEventListener('change', () => {
ruleProfileManager.setAssignment('table', tableRuleProfileSelect.value || null);
const name = tableRuleProfileSelect.selectedOptions[0]?.textContent || '';
toastr.info(tableRuleProfileSelect.value ? `表格提取规则已切换为「${name}` : '表格提取规则已取消分配');
});
document.addEventListener('amily2:ruleProfilesChanged', (e) => {
_populateRuleProfileSelect(tableRuleProfileSelect, 'table', e.detail);
});
}
const renderAll = () => {
renderTables();
bindInjectionSettings();
bindTemplateEditors();
bindTableTemplateEditors({
TableManager,
log,
defaultRuleTemplate: DEFAULT_AI_RULE_TEMPLATE,
defaultFlowTemplate: DEFAULT_AI_FLOW_TEMPLATE,
});
};
renderAll();
@@ -1471,8 +1498,20 @@ export function bindTableEvents() {
bindFloorFillButtons(); // 【新增】绑定楼层填表按钮
bindReorganizeButton(); // 【新增】绑定重新整理按钮
bindClearRecordsButton(); // 【新增】绑定清除记录按钮
bindNccsApiEvents(); // 【新增】绑定Nccs API系统事件
bindChatTableDisplaySetting(); // 【新增】绑定聊天内表格显示开关
bindNccsApiSettingsEvents({
getLiveExtensionSettings,
saveSettingsDebounced,
getContext,
fetchNccsModels,
testNccsApiConnection,
configManager,
log,
}); // 【新增】绑定Nccs API系统事件
bindChatTableDisplaySettings({
getLiveExtensionSettings,
saveSettingsDebounced,
log,
}); // 【新增】绑定聊天内表格显示开关
const navDeck = document.querySelector('#amily2_memorisation_forms_panel .sinan-navigation-deck');
if (navDeck) {
@@ -1684,7 +1723,7 @@ export function bindTableEvents() {
renderAll();
setTimeout(() => {
const settings = extension_settings[extensionName];
const settings = getLiveExtensionSettings();
if (settings && settings.table_worldbook_enabled) {
try {
bindWorldBookSettings();
@@ -1703,8 +1742,7 @@ function bindBatchFillButton() {
if (fillButton.dataset.batchEventBound) return;
fillButton.addEventListener('click', (event) => {
const settings = extension_settings[extensionName];
const tableSystemEnabled = settings.table_system_enabled !== false;
const tableSystemEnabled = isTableSystemEnabled();
if (!tableSystemEnabled) {
event.preventDefault();
@@ -1727,8 +1765,7 @@ function bindReorganizeButton() {
if (reorganizeBtn.dataset.reorganizeEventBound) return;
reorganizeBtn.addEventListener('click', async (event) => {
const settings = extension_settings[extensionName];
const tableSystemEnabled = settings.table_system_enabled !== false;
const tableSystemEnabled = isTableSystemEnabled();
if (!tableSystemEnabled) {
event.preventDefault();
@@ -1842,8 +1879,7 @@ function bindFloorFillButtons() {
if (selectedFloorsBtn.dataset.floorEventBound) return;
selectedFloorsBtn.addEventListener('click', (event) => {
const settings = extension_settings[extensionName];
const tableSystemEnabled = settings.table_system_enabled !== false;
const tableSystemEnabled = isTableSystemEnabled();
if (!tableSystemEnabled) {
event.preventDefault();
@@ -1885,8 +1921,7 @@ function bindFloorFillButtons() {
if (currentFloorBtn.dataset.currentEventBound) return;
currentFloorBtn.addEventListener('click', (event) => {
const settings = extension_settings[extensionName];
const tableSystemEnabled = settings.table_system_enabled !== false;
const tableSystemEnabled = isTableSystemEnabled();
if (!tableSystemEnabled) {
event.preventDefault();
@@ -1907,8 +1942,7 @@ function bindFloorFillButtons() {
if (rollbackBtn.dataset.rollbackEventBound) return;
rollbackBtn.addEventListener('click', async (event) => {
const settings = extension_settings[extensionName];
const tableSystemEnabled = settings.table_system_enabled !== false;
const tableSystemEnabled = isTableSystemEnabled();
if (!tableSystemEnabled) {
event.preventDefault();
@@ -1931,355 +1965,3 @@ function bindFloorFillButtons() {
}
}
function bindTemplateEditors() {
const ruleEditor = document.getElementById('ai-rule-template-editor');
const ruleSaveBtn = document.getElementById('ai-rule-template-save-btn');
const ruleRestoreBtn = document.getElementById('ai-rule-template-restore-btn');
const flowEditor = document.getElementById('ai-flow-template-editor');
const flowSaveBtn = document.getElementById('ai-flow-template-save-btn');
const flowRestoreBtn = document.getElementById('ai-flow-template-restore-btn');
if (!ruleEditor || !flowEditor || !ruleSaveBtn || !flowSaveBtn) {
log('无法找到指令模板编辑器或其按钮,绑定失败。', 'warn');
return;
}
if (ruleSaveBtn.dataset.templateEventsBound) {
return;
}
ruleEditor.value = TableManager.getBatchFillerRuleTemplate();
flowEditor.value = TableManager.getBatchFillerFlowTemplate();
ruleSaveBtn.addEventListener('click', () => {
TableManager.saveBatchFillerRuleTemplate(ruleEditor.value);
toastr.success('规则提示词已保存。');
log('批量填表-规则提示词已保存。', 'success');
});
flowSaveBtn.addEventListener('click', () => {
TableManager.saveBatchFillerFlowTemplate(flowEditor.value);
toastr.success('流程提示词已保存。');
log('批量填表-流程提示词已保存。', 'success');
});
ruleRestoreBtn.addEventListener('click', () => {
if (confirm('您确定要将规则提示词恢复为默认设置吗?')) {
ruleEditor.value = DEFAULT_AI_RULE_TEMPLATE;
TableManager.saveBatchFillerRuleTemplate(ruleEditor.value);
toastr.info('规则提示词已恢复为默认。');
log('批量填表-规则提示词已恢复默认。', 'info');
}
});
flowRestoreBtn.addEventListener('click', () => {
if (confirm('您确定要将流程提示词恢复为默认设置吗?')) {
flowEditor.value = DEFAULT_AI_FLOW_TEMPLATE;
TableManager.saveBatchFillerFlowTemplate(flowEditor.value);
toastr.info('流程提示词已恢复为默认。');
log('批量填表-流程提示词已恢复默认。', 'info');
}
});
ruleSaveBtn.dataset.templateEventsBound = 'true';
flowSaveBtn.dataset.templateEventsBound = 'true';
log('指令模板编辑器已成功绑定。', 'success');
}
function bindNccsApiEvents() {
const settings = extension_settings[extensionName];
if (settings.nccsEnabled === undefined) settings.nccsEnabled = false;
if (settings.nccsFakeStreamEnabled === undefined) settings.nccsFakeStreamEnabled = false;
if (settings.nccsApiMode === undefined) settings.nccsApiMode = 'openai_test';
if (settings.nccsApiUrl === undefined) settings.nccsApiUrl = 'https://api.openai.com/v1';
if (settings.nccsApiKey === undefined) settings.nccsApiKey = '';
if (settings.nccsModel === undefined) settings.nccsModel = '';
if (settings.nccsTavernProfile === undefined) settings.nccsTavernProfile = '';
const enabledToggle = document.getElementById('nccs-api-enabled');
const enabledFakeStreamToggle = document.getElementById('nccs-api-fakestream-enabled');
const configDiv = document.getElementById('nccs-api-config');
const modeSelect = document.getElementById('nccs-api-mode');
const urlInput = document.getElementById('nccs-api-url');
const keyInput = document.getElementById('nccs-api-key');
const modelInput = document.getElementById('nccs-api-model');
const presetSelect = document.getElementById('nccs-sillytavern-preset');
const testButton = document.getElementById('nccs-test-connection');
const fetchModelsButton = document.getElementById('nccs-fetch-models');
if (!enabledToggle || !configDiv) return;
enabledToggle.checked = settings.nccsEnabled;
enabledFakeStreamToggle.checked = settings.nccsFakeStreamEnabled;
if (modeSelect) modeSelect.value = settings.nccsApiMode;
if (urlInput) urlInput.value = settings.nccsApiUrl;
if (keyInput) keyInput.value = settings.nccsApiKey;
if (modelInput) modelInput.value = settings.nccsModel;
if (presetSelect) presetSelect.value = settings.nccsTavernProfile || '';
const updateConfigVisibility = () => {
configDiv.style.display = enabledToggle.checked ? 'block' : 'none';
};
updateConfigVisibility();
const updateModeBasedVisibility = () => {
if (!modeSelect) return;
const isSillyTavernMode = modeSelect.value === 'sillytavern_preset';
const isOpenAIMode = modeSelect.value === 'openai_test';
const presetContainer = presetSelect?.closest('.amily2_opt_settings_block');
if (presetContainer) {
presetContainer.style.display = isSillyTavernMode ? 'block' : 'none';
}
const fieldsToHideInPresetMode = [
{ element: urlInput, containerId: null },
{ element: keyInput, containerId: null },
{ element: modelInput, containerId: null }
];
fieldsToHideInPresetMode.forEach(({ element }) => {
if (element) {
const container = element.closest('.amily2_opt_settings_block');
if (container) {
container.style.display = isSillyTavernMode ? 'none' : 'block';
}
}
});
const buttonsContainer = testButton?.closest('.nccs-button-row');
if (buttonsContainer) {
buttonsContainer.style.display = 'flex';
}
};
updateModeBasedVisibility();
enabledToggle.addEventListener('change', () => {
settings.nccsEnabled = enabledToggle.checked;
saveSettingsDebounced();
updateConfigVisibility();
log(`Nccs API ${enabledToggle.checked ? '已启用' : '已禁用'}`, 'info');
});
enabledFakeStreamToggle.addEventListener('change', () => {
settings.nccsFakeStreamEnabled = enabledFakeStreamToggle.checked;
saveSettingsDebounced();
log(`Nccs API FakeStream ${enabledFakeStreamToggle.checked ? 'Enabled' : 'Disabled'}`, 'info');
});
if (modeSelect) {
modeSelect.addEventListener('change', () => {
settings.nccsApiMode = modeSelect.value;
saveSettingsDebounced();
updateModeBasedVisibility();
log(`Nccs API模式已切换为: ${modeSelect.value}`, 'info');
});
}
if (urlInput) {
const saveUrl = () => {
settings.nccsApiUrl = urlInput.value;
saveSettingsDebounced();
};
urlInput.addEventListener('blur', saveUrl);
}
if (keyInput) {
const saveKey = () => {
settings.nccsApiKey = keyInput.value;
saveSettingsDebounced();
};
keyInput.addEventListener('blur', saveKey);
}
if (modelInput) {
const saveModel = () => {
settings.nccsModel = modelInput.value;
saveSettingsDebounced();
};
modelInput.addEventListener('blur', saveModel);
modelInput.addEventListener('input', saveModel);
}
if (presetSelect) {
presetSelect.addEventListener('change', () => {
settings.nccsTavernProfile = presetSelect.value;
saveSettingsDebounced();
});
}
if (testButton) {
testButton.addEventListener('click', async () => {
testButton.disabled = true;
testButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 测试中...';
try {
const success = await testNccsApiConnection();
if (success) {
toastr.success('Nccs API连接测试成功');
log('Nccs API连接测试成功', 'success');
} else {
toastr.error('Nccs API连接测试失败请检查配置');
log('Nccs API连接测试失败', 'error');
}
} catch (error) {
toastr.error('Nccs API连接测试出错' + error.message);
log('Nccs API连接测试出错' + error.message, 'error');
} finally {
testButton.disabled = false;
testButton.innerHTML = '<i class="fas fa-plug"></i> 测试连接';
}
});
}
if (fetchModelsButton) {
fetchModelsButton.addEventListener('click', async () => {
fetchModelsButton.disabled = true;
fetchModelsButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 获取中...';
if (urlInput) {
settings.nccsApiUrl = urlInput.value;
}
if (keyInput) {
settings.nccsApiKey = keyInput.value;
}
saveSettingsDebounced();
try {
const models = await fetchNccsModels();
if (models && models.length > 0) {
let modelSelect = document.getElementById('nccs-api-model-select');
if (!modelSelect) {
modelSelect = document.createElement('select');
modelSelect.id = 'nccs-api-model-select';
modelSelect.className = 'text_pole';
modelInput.parentNode.insertBefore(modelSelect, modelInput.nextSibling);
}
modelSelect.innerHTML = '<option value="">-- 请选择模型 --</option>';
models.forEach(model => {
const option = document.createElement('option');
option.value = model.id || model.name;
option.textContent = model.name || model.id;
if ((model.id || model.name) === settings.nccsModel) {
option.selected = true;
}
modelSelect.appendChild(option);
});
modelInput.style.display = 'none';
modelSelect.style.display = 'block';
modelSelect.addEventListener('change', () => {
const selectedModel = modelSelect.value;
settings.nccsModel = selectedModel;
modelInput.value = selectedModel;
saveSettingsDebounced();
});
toastr.success(`成功获取 ${models.length} 个模型`);
log(`Nccs API获取到 ${models.length} 个模型`, 'success');
} else {
toastr.warning('未获取到可用模型');
log('Nccs API未获取到可用模型', 'warn');
}
} catch (error) {
toastr.error('获取模型失败:' + error.message);
log('Nccs API获取模型失败' + error.message, 'error');
} finally {
fetchModelsButton.disabled = false;
fetchModelsButton.innerHTML = '<i class="fas fa-download"></i> 获取模型';
}
});
}
const loadSillyTavernPresets = async () => {
if (!presetSelect) return;
try {
const context = getContext();
if (!context?.extensionSettings?.connectionManager?.profiles) {
throw new Error('无法获取SillyTavern配置文件列表');
}
const profiles = context.extensionSettings.connectionManager.profiles;
const currentProfileId = settings.nccsTavernProfile;
presetSelect.innerHTML = '';
presetSelect.appendChild(new Option('选择预设', '', false, false));
if (profiles && profiles.length > 0) {
profiles.forEach(profile => {
const isSelected = profile.id === currentProfileId;
const option = new Option(profile.name, profile.id, isSelected, isSelected);
presetSelect.appendChild(option);
});
log(`成功加载 ${profiles.length} 个SillyTavern配置文件`, 'success');
} else {
log('未找到可用的SillyTavern配置文件', 'warn');
}
} catch (error) {
log('加载SillyTavern预设失败' + error.message, 'error');
}
};
if (modeSelect && presetSelect) {
modeSelect.addEventListener('change', () => {
if (modeSelect.value === 'sillytavern_preset') {
loadSillyTavernPresets();
}
});
if (settings.nccsApiMode === 'sillytavern_preset') {
loadSillyTavernPresets();
}
}
log('Nccs API事件绑定完成', 'success');
}
function bindChatTableDisplaySetting() {
const settings = extension_settings[extensionName];
const showInChatToggle = document.getElementById('show-table-in-chat-toggle');
const continuousRenderToggle = document.getElementById('render-on-every-message-toggle');
if (!showInChatToggle || !continuousRenderToggle) {
log('找不到聊天内表格相关的开关,绑定失败。', 'warn');
return;
}
showInChatToggle.checked = settings.show_table_in_chat === true;
continuousRenderToggle.checked = settings.render_on_every_message === true;
const updateContinuousRenderState = () => {
if (showInChatToggle.checked) {
continuousRenderToggle.disabled = false;
continuousRenderToggle.closest('.control-block-with-switch').style.opacity = '1';
} else {
continuousRenderToggle.disabled = true;
continuousRenderToggle.closest('.control-block-with-switch').style.opacity = '0.5';
}
};
updateContinuousRenderState();
showInChatToggle.addEventListener('change', () => {
settings.show_table_in_chat = showInChatToggle.checked;
saveSettingsDebounced();
toastr.info(`聊天内表格显示已${showInChatToggle.checked ? '开启' : '关闭'}`);
updateContinuousRenderState();
});
continuousRenderToggle.addEventListener('change', () => {
settings.render_on_every_message = continuousRenderToggle.checked;
saveSettingsDebounced();
toastr.info(`持续渲染最新消息功能已${continuousRenderToggle.checked ? '开启' : '关闭'}。请切换聊天以应用更改。`);
});
log('聊天内表格显示设置及其依赖关系已成功绑定。', 'success');
}

View File

@@ -0,0 +1,48 @@
export function bindChatTableDisplaySetting({
getLiveExtensionSettings,
saveSettingsDebounced,
log,
}) {
const settings = getLiveExtensionSettings();
const showInChatToggle = document.getElementById('show-table-in-chat-toggle');
const continuousRenderToggle = document.getElementById('render-on-every-message-toggle');
if (!showInChatToggle || !continuousRenderToggle) {
log('Chat table display toggles not found, skip binding.', 'warn');
return;
}
showInChatToggle.checked = settings.show_table_in_chat === true;
continuousRenderToggle.checked = settings.render_on_every_message === true;
const updateContinuousRenderState = () => {
const controlBlock = continuousRenderToggle.closest('.control-block-with-switch');
if (showInChatToggle.checked) {
continuousRenderToggle.disabled = false;
if (controlBlock) controlBlock.style.opacity = '1';
return;
}
continuousRenderToggle.disabled = true;
if (controlBlock) controlBlock.style.opacity = '0.5';
};
updateContinuousRenderState();
showInChatToggle.addEventListener('change', () => {
const currentSettings = getLiveExtensionSettings();
currentSettings.show_table_in_chat = showInChatToggle.checked;
saveSettingsDebounced();
toastr.info(`Chat table display ${showInChatToggle.checked ? 'enabled' : 'disabled'}.`);
updateContinuousRenderState();
});
continuousRenderToggle.addEventListener('change', () => {
const currentSettings = getLiveExtensionSettings();
currentSettings.render_on_every_message = continuousRenderToggle.checked;
saveSettingsDebounced();
toastr.info(`Continuous chat render ${continuousRenderToggle.checked ? 'enabled' : 'disabled'}.`);
});
log('Chat table display settings bound.', 'success');
}

242
ui/table/nccs-bindings.js Normal file
View File

@@ -0,0 +1,242 @@
export function bindNccsApiEvents({
getLiveExtensionSettings,
saveSettingsDebounced,
getContext,
fetchNccsModels,
testNccsApiConnection,
configManager,
log,
}) {
const settings = getLiveExtensionSettings();
if (settings.nccsEnabled === undefined) settings.nccsEnabled = false;
if (settings.nccsFakeStreamEnabled === undefined) settings.nccsFakeStreamEnabled = false;
if (settings.nccsApiMode === undefined) settings.nccsApiMode = 'openai_test';
if (settings.nccsApiUrl === undefined) settings.nccsApiUrl = 'https://api.openai.com/v1';
if (settings.nccsModel === undefined) settings.nccsModel = '';
if (settings.nccsTavernProfile === undefined) settings.nccsTavernProfile = '';
const enabledToggle = document.getElementById('nccs-api-enabled');
const enabledFakeStreamToggle = document.getElementById('nccs-api-fakestream-enabled');
const configDiv = document.getElementById('nccs-api-config');
const modeSelect = document.getElementById('nccs-api-mode');
const urlInput = document.getElementById('nccs-api-url');
const keyInput = document.getElementById('nccs-api-key');
const modelInput = document.getElementById('nccs-api-model');
const presetSelect = document.getElementById('nccs-sillytavern-preset');
const testButton = document.getElementById('nccs-test-connection');
const fetchModelsButton = document.getElementById('nccs-fetch-models');
if (!enabledToggle || !enabledFakeStreamToggle || !configDiv) {
return;
}
enabledToggle.checked = settings.nccsEnabled;
enabledFakeStreamToggle.checked = settings.nccsFakeStreamEnabled;
if (modeSelect) modeSelect.value = settings.nccsApiMode;
if (urlInput) urlInput.value = settings.nccsApiUrl;
if (keyInput) keyInput.value = configManager.get('nccsApiKey') || '';
if (modelInput) modelInput.value = settings.nccsModel;
if (presetSelect) presetSelect.value = settings.nccsTavernProfile || '';
const updateConfigVisibility = () => {
configDiv.style.display = enabledToggle.checked ? 'block' : 'none';
};
const updateModeBasedVisibility = () => {
if (!modeSelect) return;
const isPresetMode = modeSelect.value === 'sillytavern_preset';
const presetContainer = presetSelect?.closest('.amily2_opt_settings_block');
if (presetContainer) {
presetContainer.style.display = isPresetMode ? 'block' : 'none';
}
[urlInput, keyInput, modelInput].forEach((element) => {
const container = element?.closest('.amily2_opt_settings_block');
if (container) {
container.style.display = isPresetMode ? 'none' : 'block';
}
});
const buttonsContainer = testButton?.closest('.nccs-button-row');
if (buttonsContainer) {
buttonsContainer.style.display = 'flex';
}
};
const saveSetting = (key, value) => {
const currentSettings = getLiveExtensionSettings();
currentSettings[key] = value;
saveSettingsDebounced();
};
const loadSillyTavernPresets = async () => {
if (!presetSelect) return;
try {
const context = getContext();
const profiles = context?.extensionSettings?.connectionManager?.profiles;
if (!profiles) {
throw new Error('Unable to load SillyTavern presets.');
}
const currentProfileId = getLiveExtensionSettings().nccsTavernProfile;
presetSelect.innerHTML = '';
presetSelect.appendChild(new Option('Select preset', '', false, false));
if (profiles.length === 0) {
log('No SillyTavern presets found.', 'warn');
return;
}
profiles.forEach((profile) => {
const isSelected = profile.id === currentProfileId;
presetSelect.appendChild(new Option(profile.name, profile.id, isSelected, isSelected));
});
log(`Loaded ${profiles.length} SillyTavern presets.`, 'success');
} catch (error) {
log(`Failed to load SillyTavern presets: ${error.message}`, 'error');
}
};
updateConfigVisibility();
updateModeBasedVisibility();
enabledToggle.addEventListener('change', () => {
saveSetting('nccsEnabled', enabledToggle.checked);
updateConfigVisibility();
log(`NCCS API ${enabledToggle.checked ? 'enabled' : 'disabled'}.`, 'info');
});
enabledFakeStreamToggle.addEventListener('change', () => {
saveSetting('nccsFakeStreamEnabled', enabledFakeStreamToggle.checked);
log(`NCCS fake stream ${enabledFakeStreamToggle.checked ? 'enabled' : 'disabled'}.`, 'info');
});
if (modeSelect) {
modeSelect.addEventListener('change', () => {
saveSetting('nccsApiMode', modeSelect.value);
updateModeBasedVisibility();
if (modeSelect.value === 'sillytavern_preset') {
loadSillyTavernPresets();
}
log(`NCCS API mode changed to ${modeSelect.value}.`, 'info');
});
}
if (urlInput) {
urlInput.addEventListener('blur', () => {
saveSetting('nccsApiUrl', urlInput.value);
});
}
if (keyInput) {
keyInput.addEventListener('blur', () => {
configManager.set('nccsApiKey', keyInput.value);
});
}
if (modelInput) {
const saveModel = () => saveSetting('nccsModel', modelInput.value);
modelInput.addEventListener('blur', saveModel);
modelInput.addEventListener('input', saveModel);
}
if (presetSelect) {
presetSelect.addEventListener('change', () => {
saveSetting('nccsTavernProfile', presetSelect.value);
});
}
if (testButton) {
testButton.addEventListener('click', async () => {
testButton.disabled = true;
testButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing...';
try {
const success = await testNccsApiConnection();
if (success) {
toastr.success('NCCS API connection succeeded.');
log('NCCS API connection succeeded.', 'success');
} else {
toastr.error('NCCS API connection failed.');
log('NCCS API connection failed.', 'error');
}
} catch (error) {
toastr.error(`NCCS API test failed: ${error.message}`);
log(`NCCS API test failed: ${error.message}`, 'error');
} finally {
testButton.disabled = false;
testButton.innerHTML = '<i class="fas fa-plug"></i> Test Connection';
}
});
}
if (fetchModelsButton && modelInput) {
fetchModelsButton.addEventListener('click', async () => {
fetchModelsButton.disabled = true;
fetchModelsButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
if (urlInput) {
saveSetting('nccsApiUrl', urlInput.value);
}
if (keyInput) {
configManager.set('nccsApiKey', keyInput.value);
}
try {
const models = await fetchNccsModels();
if (!models?.length) {
toastr.warning('No models returned.');
log('No NCCS models returned.', 'warn');
return;
}
let modelSelect = document.getElementById('nccs-api-model-select');
if (!modelSelect) {
modelSelect = document.createElement('select');
modelSelect.id = 'nccs-api-model-select';
modelSelect.className = 'text_pole';
modelInput.parentNode.insertBefore(modelSelect, modelInput.nextSibling);
}
const currentModel = getLiveExtensionSettings().nccsModel;
modelSelect.innerHTML = '<option value="">-- Select model --</option>';
models.forEach((model) => {
const value = model.id || model.name;
const option = document.createElement('option');
option.value = value;
option.textContent = model.name || model.id;
option.selected = value === currentModel;
modelSelect.appendChild(option);
});
modelInput.style.display = 'none';
modelSelect.style.display = 'block';
modelSelect.onchange = () => {
const selectedModel = modelSelect.value;
modelInput.value = selectedModel;
saveSetting('nccsModel', selectedModel);
};
toastr.success(`Loaded ${models.length} models.`);
log(`Loaded ${models.length} NCCS models.`, 'success');
} catch (error) {
toastr.error(`Failed to load models: ${error.message}`);
log(`Failed to load NCCS models: ${error.message}`, 'error');
} finally {
fetchModelsButton.disabled = false;
fetchModelsButton.innerHTML = '<i class="fas fa-download"></i> Fetch Models';
}
});
}
if (modeSelect?.value === 'sillytavern_preset' && presetSelect) {
loadSillyTavernPresets();
}
log('NCCS API settings bound.', 'success');
}

View File

@@ -0,0 +1,64 @@
export function bindTableTemplateEditors({
TableManager,
log,
defaultRuleTemplate,
defaultFlowTemplate,
}) {
const ruleEditor = document.getElementById('ai-rule-template-editor');
const ruleSaveBtn = document.getElementById('ai-rule-template-save-btn');
const ruleRestoreBtn = document.getElementById('ai-rule-template-restore-btn');
const flowEditor = document.getElementById('ai-flow-template-editor');
const flowSaveBtn = document.getElementById('ai-flow-template-save-btn');
const flowRestoreBtn = document.getElementById('ai-flow-template-restore-btn');
if (!ruleEditor || !flowEditor || !ruleSaveBtn || !flowSaveBtn) {
log('Template editors not found, skip binding.', 'warn');
return;
}
if (ruleSaveBtn.dataset.templateEventsBound) {
return;
}
ruleEditor.value = TableManager.getBatchFillerRuleTemplate();
flowEditor.value = TableManager.getBatchFillerFlowTemplate();
ruleSaveBtn.addEventListener('click', () => {
TableManager.saveBatchFillerRuleTemplate(ruleEditor.value);
toastr.success('Rule template saved.');
log('Batch filler rule template saved.', 'success');
});
flowSaveBtn.addEventListener('click', () => {
TableManager.saveBatchFillerFlowTemplate(flowEditor.value);
toastr.success('Flow template saved.');
log('Batch filler flow template saved.', 'success');
});
ruleRestoreBtn.addEventListener('click', () => {
if (!confirm('Restore the default rule template?')) {
return;
}
ruleEditor.value = defaultRuleTemplate;
TableManager.saveBatchFillerRuleTemplate(ruleEditor.value);
toastr.info('Rule template restored.');
log('Batch filler rule template restored.', 'info');
});
flowRestoreBtn.addEventListener('click', () => {
if (!confirm('Restore the default flow template?')) {
return;
}
flowEditor.value = defaultFlowTemplate;
TableManager.saveBatchFillerFlowTemplate(flowEditor.value);
toastr.info('Flow template restored.');
log('Batch filler flow template restored.', 'info');
});
ruleSaveBtn.dataset.templateEventsBound = 'true';
flowSaveBtn.dataset.templateEventsBound = 'true';
log('Template editors bound.', 'success');
}

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

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -23,6 +23,7 @@ import { extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.js";
import { extensionName } from "../settings.js";
import { SENSITIVE_KEYS } from "./sensitive-keys.js";
import { apiKeyStore } from "./api-key-store/ApiKeyStore.js";
// localStorage key 前缀,避免与其他插件冲突
const LS_PREFIX = 'amily2_secure_';
@@ -30,6 +31,10 @@ const LS_PREFIX = 'amily2_secure_';
// ── ConfigManager ────────────────────────────────────────────────────────────
class ConfigManager {
async init() {
await apiKeyStore.init();
await this.syncSensitiveCache({ force: true });
}
/**
* 读取配置项。
@@ -53,17 +58,18 @@ class ConfigManager {
*/
set(key, value) {
if (SENSITIVE_KEYS.has(key)) {
if (value !== null && value !== undefined && value !== '') {
localStorage.setItem(LS_PREFIX + key, value);
} else {
localStorage.removeItem(LS_PREFIX + key);
}
this._setSensitiveCacheValue(key, value);
// 确保 extension_settings 中不保留该敏感字段
const settings = extension_settings[extensionName];
if (settings && Object.prototype.hasOwnProperty.call(settings, key)) {
delete settings[key];
saveSettingsDebounced();
}
if (apiKeyStore.getMode() === 'cloud') {
apiKeyStore.setKey(key, value).catch(e => {
console.error(`[ConfigManager] 云同步敏感字段 "${key}" 失败:`, e);
});
}
} else {
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
@@ -128,6 +134,28 @@ class ConfigManager {
console.info('[Amily2-Config] 敏感配置迁移完成,已从云同步配置中清除密钥。');
}
}
async syncSensitiveCache({ force = false } = {}) {
if (apiKeyStore.getMode() !== 'cloud') return;
await apiKeyStore.init();
if (!apiKeyStore.isCloudReady()) return;
for (const key of SENSITIVE_KEYS) {
const cached = localStorage.getItem(LS_PREFIX + key);
if (!force && cached !== null && cached !== '') continue;
const value = await apiKeyStore.getKey(key);
this._setSensitiveCacheValue(key, value);
}
}
_setSensitiveCacheValue(key, value) {
if (value !== null && value !== undefined && value !== '') {
localStorage.setItem(LS_PREFIX + key, value);
} else {
localStorage.removeItem(LS_PREFIX + key);
}
}
}
// ── 单例导出 ─────────────────────────────────────────────────────────────────
@@ -147,6 +175,8 @@ setTimeout(() => {
set: (key, value) => configManager.set(key, value),
getSettings: () => configManager.getSettings(),
migrate: () => configManager.migrate(),
init: () => configManager.init(),
syncSensitiveCache: (options) => configManager.syncSensitiveCache(options),
});
_ctx.log('ConfigManager', 'info', 'Config 服务已注册到 Bus。');
} catch (e) {

View File

@@ -0,0 +1,306 @@
import { extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.js";
import { extensionName } from "../settings.js";
const RULE_PROFILE_KEY = 'ruleProfiles';
const RULE_ASSIGNMENTS_KEY = 'ruleProfileAssignments';
// ── 功能槽定义 ──────────────────────────────────────────────────────────────────
export const RULE_SLOTS = {
table: { label: '表格提取规则' },
historiography: { label: '史官/总结提取规则' },
condensation: { label: '翰林院·浓缩规则' },
queryPreprocessing:{ label: '翰林院·查询预处理规则' },
};
function sanitizeRuleProfile(profile = {}) {
const exclusionRules = Array.isArray(profile.exclusionRules)
? profile.exclusionRules
.map(rule => ({
start: String(rule?.start ?? '').trim(),
end: String(rule?.end ?? '').trim(),
}))
.filter(rule => rule.start)
: [];
return {
id: String(profile.id ?? '').trim(),
name: String(profile.name ?? '').trim(),
tagExtractionEnabled: Boolean(profile.tagExtractionEnabled),
tags: String(profile.tags ?? ''),
exclusionRules,
excludeUserMessages: Boolean(profile.excludeUserMessages),
};
}
function cloneRuleProfile(profile = {}) {
return {
id: profile.id || '',
name: profile.name || '',
tagExtractionEnabled: Boolean(profile.tagExtractionEnabled),
tags: profile.tags || '',
exclusionRules: Array.isArray(profile.exclusionRules)
? profile.exclusionRules.map(rule => ({
start: rule.start || '',
end: rule.end || '',
}))
: [],
excludeUserMessages: Boolean(profile.excludeUserMessages),
};
}
function createRuleProfileId(name = 'rule-profile') {
const base = String(name || 'rule-profile')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'rule-profile';
return `${base}-${Date.now().toString(36)}`;
}
function ensureSettingsRoot() {
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
return extension_settings[extensionName];
}
function ensureProfileMap() {
const settings = ensureSettingsRoot();
if (!settings[RULE_PROFILE_KEY] || typeof settings[RULE_PROFILE_KEY] !== 'object' || Array.isArray(settings[RULE_PROFILE_KEY])) {
settings[RULE_PROFILE_KEY] = {};
}
return settings[RULE_PROFILE_KEY];
}
function ensureAssignments() {
const settings = ensureSettingsRoot();
if (!settings[RULE_ASSIGNMENTS_KEY] || typeof settings[RULE_ASSIGNMENTS_KEY] !== 'object' || Array.isArray(settings[RULE_ASSIGNMENTS_KEY])) {
settings[RULE_ASSIGNMENTS_KEY] = {};
}
return settings[RULE_ASSIGNMENTS_KEY];
}
function mergeRuleConfig(profile, fallback = {}) {
const safeFallback = sanitizeRuleProfile({
id: fallback.id,
name: fallback.name,
tagExtractionEnabled: fallback.tagExtractionEnabled,
tags: fallback.tags,
exclusionRules: fallback.exclusionRules,
});
if (!profile) {
return safeFallback;
}
return {
id: profile.id,
name: profile.name,
tagExtractionEnabled: profile.tagExtractionEnabled,
tags: profile.tags,
exclusionRules: cloneRuleProfile(profile).exclusionRules.length > 0
? cloneRuleProfile(profile).exclusionRules
: safeFallback.exclusionRules,
};
}
function _dispatchChange() {
const profiles = Object.values(ensureProfileMap())
.map(p => cloneRuleProfile(p))
.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id, 'zh-Hans-CN'));
const assignments = { ...ensureAssignments() };
document.dispatchEvent(new CustomEvent('amily2:ruleProfilesChanged', {
detail: { profiles, assignments },
}));
}
export class RuleProfileManager {
listProfiles() {
const profiles = Object.values(ensureProfileMap())
.map(profile => cloneRuleProfile(profile))
.sort((left, right) => {
const leftName = left.name || left.id;
const rightName = right.name || right.id;
return leftName.localeCompare(rightName, 'zh-Hans-CN');
});
return profiles;
}
getProfile(id) {
if (!id) return null;
const profile = ensureProfileMap()[id];
return profile ? cloneRuleProfile(profile) : null;
}
saveProfile(profile) {
const normalized = sanitizeRuleProfile(profile);
const profileId = normalized.id || createRuleProfileId(normalized.name);
const nextProfile = {
...normalized,
id: profileId,
name: normalized.name || profileId,
};
ensureProfileMap()[profileId] = nextProfile;
saveSettingsDebounced();
_dispatchChange();
return cloneRuleProfile(nextProfile);
}
deleteProfile(id) {
if (!id) return false;
const profiles = ensureProfileMap();
if (!profiles[id]) return false;
delete profiles[id];
saveSettingsDebounced();
_dispatchChange();
return true;
}
resolveProfile(id, fallback = {}) {
return mergeRuleConfig(this.getProfile(id), fallback);
}
// ── 功能槽分配 ──────────────────────────────────────────────────────────────
getAssignment(slot) {
if (!RULE_SLOTS[slot]) return null;
return ensureAssignments()[slot] || null;
}
setAssignment(slot, profileId) {
if (!RULE_SLOTS[slot]) return false;
const assignments = ensureAssignments();
if (profileId) {
assignments[slot] = profileId;
} else {
delete assignments[slot];
}
saveSettingsDebounced();
_dispatchChange();
return true;
}
getAssignedProfile(slot) {
const id = this.getAssignment(slot);
if (!id) return null;
const profile = ensureProfileMap()[id];
return profile ? cloneRuleProfile(profile) : null;
}
}
export const ruleProfileManager = new RuleProfileManager();
export function resolveRuleConfig(ruleProfileId, fallback = {}) {
return ruleProfileManager.resolveProfile(ruleProfileId, fallback);
}
/**
* 通过功能槽名解析规则配置(推荐方式)
* 先查 assignments再回退到旧字段
*/
export function resolveSlotRuleConfig(slot, legacyFallback = {}) {
const assignedId = ruleProfileManager.getAssignment(slot);
if (assignedId) {
const profile = ruleProfileManager.getProfile(assignedId);
if (profile) return profile;
}
// 回退到旧的 resolve 路径
return sanitizeRuleProfile(legacyFallback);
}
export function resolveCondensationRuleConfig(settings = {}) {
const condensation = settings.condensation || {};
return resolveSlotRuleConfig('condensation', {
...condensation,
ruleProfileId: condensation.ruleProfileId,
});
}
export function resolveQueryPreprocessingRuleConfig(settings = {}) {
const queryPreprocessing = settings.queryPreprocessing || {};
return resolveSlotRuleConfig('queryPreprocessing', {
...queryPreprocessing,
ruleProfileId: queryPreprocessing.ruleProfileId,
});
}
export function resolveTableRuleConfig(settings = {}) {
return resolveSlotRuleConfig('table', {
id: settings.table_rule_profile_id,
tagExtractionEnabled: Boolean(settings.table_tags_to_extract),
tags: settings.table_tags_to_extract || '',
exclusionRules: settings.table_exclusion_rules || [],
});
}
export function resolveHistoriographyRuleConfig(settings = {}) {
return resolveSlotRuleConfig('historiography', {
id: settings.historiographyRuleProfileId,
tagExtractionEnabled: settings.historiographyTagExtractionEnabled ?? false,
tags: settings.historiographyTags || '',
exclusionRules: settings.historiographyExclusionRules || [],
});
}
// ── 一次性迁移:旧分散 profileId 字段 → 统一 assignments ─────────────────────
;(() => {
const settings = ensureSettingsRoot();
const assignments = ensureAssignments();
let changed = false;
// table: table_rule_profile_id → assignments.table
if (settings.table_rule_profile_id && !assignments.table) {
assignments.table = settings.table_rule_profile_id;
changed = true;
}
// historiography: historiographyRuleProfileId → assignments.historiography
if (settings.historiographyRuleProfileId && !assignments.historiography) {
assignments.historiography = settings.historiographyRuleProfileId;
changed = true;
}
// condensation: condensation.ruleProfileId → assignments.condensation
const condensation = settings.condensation || {};
if (condensation.ruleProfileId && !assignments.condensation) {
assignments.condensation = condensation.ruleProfileId;
changed = true;
}
// queryPreprocessing: queryPreprocessing.ruleProfileId → assignments.queryPreprocessing
const queryPreprocessing = settings.queryPreprocessing || {};
if (queryPreprocessing.ruleProfileId && !assignments.queryPreprocessing) {
assignments.queryPreprocessing = queryPreprocessing.ruleProfileId;
changed = true;
}
if (changed) {
saveSettingsDebounced();
console.log('[RuleProfiles] 已迁移旧规则配置分配到统一 assignments。', assignments);
}
})();
setTimeout(() => {
try {
const ctx = window.Amily2Bus?.register('RuleProfiles');
if (!ctx) {
console.warn('[RuleProfiles] Amily2Bus 尚未就绪,注册跳过。');
return;
}
ctx.expose({
listProfiles: () => ruleProfileManager.listProfiles(),
getProfile: (id) => ruleProfileManager.getProfile(id),
saveProfile: (profile) => ruleProfileManager.saveProfile(profile),
deleteProfile: (id) => ruleProfileManager.deleteProfile(id),
resolveProfile: (id, fallback) => ruleProfileManager.resolveProfile(id, fallback),
getAssignment: (slot) => ruleProfileManager.getAssignment(slot),
setAssignment: (slot, id) => ruleProfileManager.setAssignment(slot, id),
getAssignedProfile: (slot) => ruleProfileManager.getAssignedProfile(slot),
RULE_SLOTS,
});
ctx.log('RuleProfiles', 'info', 'RuleProfiles 服务已注册到 Bus。');
} catch (error) {
console.error('[RuleProfiles] Bus 注册失败:', error);
}
}, 0);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
function a0_0x4566(_0x3c4bcd,_0x462ea4){_0x3c4bcd=_0x3c4bcd-0x8d;const _0x1fe670=a0_0x1fe6();let _0x45667d=_0x1fe670[_0x3c4bcd];if(a0_0x4566['TiiNjg']===undefined){var _0x619cb9=function(_0x96a409){const _0x2aec75='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x2fbd34='',_0x13f537='';for(let _0x127b81=0x0,_0x12d2a6,_0x2aa0a2,_0x386214=0x0;_0x2aa0a2=_0x96a409['charAt'](_0x386214++);~_0x2aa0a2&&(_0x12d2a6=_0x127b81%0x4?_0x12d2a6*0x40+_0x2aa0a2:_0x2aa0a2,_0x127b81++%0x4)?_0x2fbd34+=String['fromCharCode'](0xff&_0x12d2a6>>(-0x2*_0x127b81&0x6)):0x0){_0x2aa0a2=_0x2aec75['indexOf'](_0x2aa0a2);}for(let _0x28f7ec=0x0,_0x4831aa=_0x2fbd34['length'];_0x28f7ec<_0x4831aa;_0x28f7ec++){_0x13f537+='%'+('00'+_0x2fbd34['charCodeAt'](_0x28f7ec)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x13f537);};const _0x207ab0=function(_0x721ccd,_0x300873){let _0x5e8a5c=[],_0x1405a7=0x0,_0x3be183,_0x18a968='';_0x721ccd=_0x619cb9(_0x721ccd);let _0x58e1ab;for(_0x58e1ab=0x0;_0x58e1ab<0x100;_0x58e1ab++){_0x5e8a5c[_0x58e1ab]=_0x58e1ab;}for(_0x58e1ab=0x0;_0x58e1ab<0x100;_0x58e1ab++){_0x1405a7=(_0x1405a7+_0x5e8a5c[_0x58e1ab]+_0x300873['charCodeAt'](_0x58e1ab%_0x300873['length']))%0x100,_0x3be183=_0x5e8a5c[_0x58e1ab],_0x5e8a5c[_0x58e1ab]=_0x5e8a5c[_0x1405a7],_0x5e8a5c[_0x1405a7]=_0x3be183;}_0x58e1ab=0x0,_0x1405a7=0x0;for(let _0x460aff=0x0;_0x460aff<_0x721ccd['length'];_0x460aff++){_0x58e1ab=(_0x58e1ab+0x1)%0x100,_0x1405a7=(_0x1405a7+_0x5e8a5c[_0x58e1ab])%0x100,_0x3be183=_0x5e8a5c[_0x58e1ab],_0x5e8a5c[_0x58e1ab]=_0x5e8a5c[_0x1405a7],_0x5e8a5c[_0x1405a7]=_0x3be183,_0x18a968+=String['fromCharCode'](_0x721ccd['charCodeAt'](_0x460aff)^_0x5e8a5c[(_0x5e8a5c[_0x58e1ab]+_0x5e8a5c[_0x1405a7])%0x100]);}return _0x18a968;};a0_0x4566['VlxCUG']=_0x207ab0,a0_0x4566['lQXssB']={},a0_0x4566['TiiNjg']=!![];}const _0x4aafb8=_0x1fe670[0x0],_0x24d3b0=_0x3c4bcd+_0x4aafb8,_0x46e2c4=a0_0x4566['lQXssB'][_0x24d3b0];return!_0x46e2c4?(a0_0x4566['WogIJe']===undefined&&(a0_0x4566['WogIJe']=!![]),_0x45667d=a0_0x4566['VlxCUG'](_0x45667d,_0x462ea4),a0_0x4566['lQXssB'][_0x24d3b0]=_0x45667d):_0x45667d=_0x46e2c4,_0x45667d;}const a0_0x1b4874=a0_0x4566;(function(_0x50cc10,_0x361498){const _0x5ea55d=a0_0x4566,_0x1552f3=_0x50cc10();while(!![]){try{const _0x546ae9=parseInt(_0x5ea55d(0x99,'^H5X'))/0x1+-parseInt(_0x5ea55d(0xa7,'5Q$k'))/0x2*(parseInt(_0x5ea55d(0xa5,'5Q$k'))/0x3)+-parseInt(_0x5ea55d(0x91,'H1ui'))/0x4*(parseInt(_0x5ea55d(0x9d,'7$CC'))/0x5)+-parseInt(_0x5ea55d(0x9f,'$3XG'))/0x6+-parseInt(_0x5ea55d(0xa4,'5^jQ'))/0x7*(parseInt(_0x5ea55d(0x94,'9b%M'))/0x8)+parseInt(_0x5ea55d(0x8d,'5^jQ'))/0x9*(parseInt(_0x5ea55d(0x92,'(T!*'))/0xa)+parseInt(_0x5ea55d(0xa2,'@nIj'))/0xb*(parseInt(_0x5ea55d(0xa1,'gmKu'))/0xc);if(_0x546ae9===_0x361498)break;else _0x1552f3['push'](_0x1552f3['shift']());}catch(_0x5c3020){_0x1552f3['push'](_0x1552f3['shift']());}}}(a0_0x1fe6,0x7b4c5));export const SENSITIVE_KEYS=new Set([a0_0x1b4874(0x9b,'lD*1'),a0_0x1b4874(0xaa,'igl1'),a0_0x1b4874(0x93,'0$p4'),a0_0x1b4874(0xa3,'zygW'),a0_0x1b4874(0x95,'Z0Y4'),a0_0x1b4874(0x98,'XIj)'),a0_0x1b4874(0x97,'z5yh')]);function a0_0x1fe6(){const _0x1e1e6e=['WQpcUwuwuSkeWQxdTW','WPi6FvldO3VdNCoCjZRcOG','WQdcVHvVpSolWQNdRCkwtSotgq','W7VdUCkayaablCkE','WPe6Ev/dQWBcJSopeZBcP07cSG','kCkwW73cImkBq8kEW5FcMhfkWQTJWP0zxSkxxsXlW7lcP8oQxa','W5vXWR3dUCksWPyLpuBcRSkiW7FcHW','EgddH8kLgx0JWODFkW','omo1zWJdGSkmpmkVde4','amkMWQFcIIH3W7SRWRlcRG','W48+k8kMW40pF2e/WRpdOCkz','WRTHnXBcPdO4D2vNWQCuW4e','eZJcOe7dJ8oBF2vSWPNdKa','o8oAgMHcWOK3WOVcUrm','sv0PsmoztmoYgYxcOG','CbpdVSoiW5upWOqkW5ZdMq','et3cQGdcVCoquv5a','d8kenCkFW48oWQPNrY4','WPn0WOZcMrxcLCkCWOSilK4','WPPGtdhcKCk8x8khWQJcLG','WQtcSrnLoSopW7ZdHCkUsSoQoSkL','gbxdJmoPW6P3','tfGIsCowBCoZebZcSq','WQjrdMyaW4Hm','gbGptCk6W7TZ','B17dKCkWW6z2WOFdKdersmoHlq','yCkmtY9vWO4pWQRcKc4','d2JcMaFdHWHOW7JdP8kHW4PQya','d8keW7KiW7tdSuTDeq','a8oRW6tcLWXRk8keB8oV','EgxdGCkImuWJWQDJeG'];a0_0x1fe6=function(){return _0x1e1e6e;};return a0_0x1fe6();}
const a0_0x268319=a0_0x5693;function a0_0x8a12(){const _0x4b3d77=['W6FcN8odWQLZD1NcL8oGEq','W7CpW6umW5/cN8odi8kwW7xdN2a','W4JdOgiKW6XDgSo8W5tcHfy','nuDxWQ4snN3cMW','gXvivsiVkmozkCoE','gr1mw35Emmo/c8omiGe','jCoJWRnOsSoyW7pcH1aCl8kA','W441kxumW5xdIKFdSxddRv3dJCoV','W7ldPmkKj8k/W58Hwmonua','rYbBWRHRW7BcU8oAWRtdUGOL','b1dcSSkbFr5dW6JdRCkE','WOCxFSkkWQDwimo1eKKpbW','W7ZcTCojCGv/','sIzsWRrSW7tcNmo8WRddRWCf','WOtdNqpcGuumWRHdrb3dVSoF','WRf6WRvhW6uiWOyouM5VWO7dPaBcVmklWP5CtJ9FeSk8WQG','WQ/dSmkzcfm3BbBcK8kVfr4','WRCWESoIvxldPdpcTCkvWR4','WOfHWPdcSt7dLSo4uvBdSSoLW5KVW4K','aCoeWPFdQrLdmddcSJv2CmoMCq','qr3dO8oqdbrdW4ddSSkXtq','W4KCuSoUqqxcQHJcNra','WPJcPJjjW4r1hSo0W5BcQa','W4OucCkrurJcLZW','fCkBWPhdTCkqsCoCW5lcRSoJ','aZ0egqFdJmkHWRX+WOW'];a0_0x8a12=function(){return _0x4b3d77;};return a0_0x8a12();}function a0_0x5693(_0x3933a1,_0xea6e26){_0x3933a1=_0x3933a1-0x147;const _0x8a1284=a0_0x8a12();let _0x569399=_0x8a1284[_0x3933a1];if(a0_0x5693['dlauWq']===undefined){var _0x324cea=function(_0x42976b){const _0x3f6c7e='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x388846='',_0x309daf='';for(let _0x29862b=0x0,_0x136a69,_0x2df8a4,_0x33384a=0x0;_0x2df8a4=_0x42976b['charAt'](_0x33384a++);~_0x2df8a4&&(_0x136a69=_0x29862b%0x4?_0x136a69*0x40+_0x2df8a4:_0x2df8a4,_0x29862b++%0x4)?_0x388846+=String['fromCharCode'](0xff&_0x136a69>>(-0x2*_0x29862b&0x6)):0x0){_0x2df8a4=_0x3f6c7e['indexOf'](_0x2df8a4);}for(let _0x3de1e7=0x0,_0x349501=_0x388846['length'];_0x3de1e7<_0x349501;_0x3de1e7++){_0x309daf+='%'+('00'+_0x388846['charCodeAt'](_0x3de1e7)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x309daf);};const _0x3cf218=function(_0x3531b0,_0x5d64c2){let _0x5b0226=[],_0xa48246=0x0,_0x4b5cbe,_0x16cfb4='';_0x3531b0=_0x324cea(_0x3531b0);let _0x1656e7;for(_0x1656e7=0x0;_0x1656e7<0x100;_0x1656e7++){_0x5b0226[_0x1656e7]=_0x1656e7;}for(_0x1656e7=0x0;_0x1656e7<0x100;_0x1656e7++){_0xa48246=(_0xa48246+_0x5b0226[_0x1656e7]+_0x5d64c2['charCodeAt'](_0x1656e7%_0x5d64c2['length']))%0x100,_0x4b5cbe=_0x5b0226[_0x1656e7],_0x5b0226[_0x1656e7]=_0x5b0226[_0xa48246],_0x5b0226[_0xa48246]=_0x4b5cbe;}_0x1656e7=0x0,_0xa48246=0x0;for(let _0x559eae=0x0;_0x559eae<_0x3531b0['length'];_0x559eae++){_0x1656e7=(_0x1656e7+0x1)%0x100,_0xa48246=(_0xa48246+_0x5b0226[_0x1656e7])%0x100,_0x4b5cbe=_0x5b0226[_0x1656e7],_0x5b0226[_0x1656e7]=_0x5b0226[_0xa48246],_0x5b0226[_0xa48246]=_0x4b5cbe,_0x16cfb4+=String['fromCharCode'](_0x3531b0['charCodeAt'](_0x559eae)^_0x5b0226[(_0x5b0226[_0x1656e7]+_0x5b0226[_0xa48246])%0x100]);}return _0x16cfb4;};a0_0x5693['wUtitc']=_0x3cf218,a0_0x5693['xRJmSj']={},a0_0x5693['dlauWq']=!![];}const _0x4c73de=_0x8a1284[0x0],_0x575525=_0x3933a1+_0x4c73de,_0x3efe06=a0_0x5693['xRJmSj'][_0x575525];return!_0x3efe06?(a0_0x5693['vVRsaE']===undefined&&(a0_0x5693['vVRsaE']=!![]),_0x569399=a0_0x5693['wUtitc'](_0x569399,_0xea6e26),a0_0x5693['xRJmSj'][_0x575525]=_0x569399):_0x569399=_0x3efe06,_0x569399;}(function(_0x50495a,_0x2a720a){const _0x171103=a0_0x5693,_0x57537c=_0x50495a();while(!![]){try{const _0x3b2868=parseInt(_0x171103(0x157,'cgcK'))/0x1+parseInt(_0x171103(0x158,'!5Ct'))/0x2+-parseInt(_0x171103(0x148,'(Y7&'))/0x3+parseInt(_0x171103(0x150,'UhGz'))/0x4*(-parseInt(_0x171103(0x15e,'mrSR'))/0x5)+-parseInt(_0x171103(0x152,'y7Py'))/0x6+-parseInt(_0x171103(0x160,'qd^W'))/0x7*(-parseInt(_0x171103(0x14b,'vmM3'))/0x8)+parseInt(_0x171103(0x14e,'hha5'))/0x9;if(_0x3b2868===_0x2a720a)break;else _0x57537c['push'](_0x57537c['shift']());}catch(_0x3dac2a){_0x57537c['push'](_0x57537c['shift']());}}}(a0_0x8a12,0xaa890));export const SENSITIVE_KEYS=new Set([a0_0x268319(0x153,'cgcK'),a0_0x268319(0x15a,'gTlo'),a0_0x268319(0x156,'AWXX'),a0_0x268319(0x147,'YRLz'),a0_0x268319(0x15f,'AeKu'),a0_0x268319(0x14f,'[eDQ'),a0_0x268319(0x149,'ocy2'),a0_0x268319(0x151,')Ma)')]);

View File

@@ -2,7 +2,7 @@ import { extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.js";
import { pluginAuthStatus } from "./auth.js";
export const pluginVersion = "1.4.5";
export const pluginVersion = "2.1.0";
// 从当前文件 URL 动态推导插件文件夹名和根路径兼容任意文件夹名Dev / 正式版均适用)
// URL 结构:.../scripts/extensions/third-party/<folderName>/utils/settings.js
@@ -938,7 +938,7 @@ export const mainOptDefaults = {
suppressToast: false,
optimizationMode: "intercept",
optimizationTargetTag: 'content',
optimizationEnabled: true,
optimizationEnabled: false,
optimizationExclusionEnabled: false,
optimizationExclusionRules: [],
greetingOptimizationEnabled: false,
@@ -997,65 +997,8 @@ export const defaultSettings = {
export function validateSettings() {
const settings = extension_settings[extensionName] || {};
// 新版 Profile 系统管理 API 配置时,跳过旧版字段验证
const assignments = settings.amily2_profile_assignments || {};
if (assignments.main) {
return null;
}
// 如果启用了Ngms或Nccs则跳过主API验证
if (settings.ngmsEnabled || settings.nccsEnabled) {
return null;
}
const apiProvider = settings.apiProvider || 'openai';
const errors = [];
switch (apiProvider) {
case 'openai':
case 'openai_test':
if (!settings.apiUrl) {
errors.push("当前模式需要配置API URL");
} else if (!/^https?:\/\//.test(settings.apiUrl)) {
errors.push("API URL必须以http://或https://开头");
}
if (apiProvider === 'openai' && !settings.apiKey) {
errors.push("当前模式需要配置API Key");
}
break;
case 'sillytavern_backend':
if (!settings.apiUrl) {
errors.push("SillyTavern后端模式需要配置API URL");
}
break;
case 'google':
if (!settings.apiKey) {
errors.push("Google直连模式需要配置API Key");
}
break;
case 'sillytavern_preset':
break;
default:
if (!settings.apiUrl) {
errors.push("API URL未配置");
}
if (!settings.apiKey) {
errors.push("API Key未配置");
}
break;
}
if (!settings.model && apiProvider !== 'sillytavern_preset') {
errors.push("未选择模型");
}
if (settings.maxTokens < 100 || settings.maxTokens > 100000) {
errors.push(`Token数超限 (${settings.maxTokens}) - 必须在100-100000之间`);
}
return errors.length ? errors : null;
// 主 API 概念已移除,各功能模块通过 Profile 槽位或独立配置管理 API。
return null;
}
export function saveSettings() {

View File

@@ -58,21 +58,28 @@ function replaceContentByTag(xmlString, tagName, newContent) {
export { extractContentByTag, replaceContentByTag, extractFullTagBlock, opt_extractContentByTag, opt_replaceContentByTag, opt_extractFullTagBlock };
function escapeRegex(s) {
return String(s ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function opt_extractContentByTag(text, tagName) {
const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`);
const safe = escapeRegex(tagName);
const regex = new RegExp(`<${safe}[^>]*>([\\s\\S]*?)<\\/${safe}>`);
const match = text.match(regex);
return match ? match[1] : null;
}
function opt_extractFullTagBlock(text, tagName) {
const regex = new RegExp(`(<${tagName}[^>]*>[\\s\\S]*?<\\/${tagName}>)`);
const safe = escapeRegex(tagName);
const regex = new RegExp(`(<${safe}[^>]*>[\\s\\S]*?<\\/${safe}>)`);
const match = text.match(regex);
return match ? match[0] : null;
}
function opt_replaceContentByTag(originalText, tagName, newContent) {
const regex = new RegExp(`(<${tagName}[^>]*>)([\\s\\S]*?)(<\\/${tagName}>)`);
const safe = escapeRegex(tagName);
const regex = new RegExp(`(<${safe}[^>]*>)([\\s\\S]*?)(<\\/${safe}>)`);
const match = originalText.match(regex);
if (match) {