mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-06 17:25:51 +00:00
Compare commits
14 Commits
0c5ac2c70b
...
2.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c3072a3d8 | ||
|
|
e00302d04b | ||
|
|
dabc8992f1 | ||
|
|
d9fa3072a2 | ||
|
|
4bc6e0a047 | ||
|
|
31d00f4330 | ||
|
|
13d05651f3 | ||
|
|
544937bb91 | ||
|
|
8d590073f4 | ||
|
|
58ff3c3faf | ||
|
|
c50e1a9425 | ||
|
|
2291a871eb | ||
|
|
bddda1802f | ||
|
|
1fdbe62142 |
96
51TODO.md
Normal file
96
51TODO.md
Normal 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 A:Bus 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 抛错回喂 LLM,LLM 能自纠
|
||||
- [ ] maxSteps 截断行为正确
|
||||
|
||||
**预估**:1.5 天人时,风险中(agent loop 边界条件多)。
|
||||
|
||||
---
|
||||
|
||||
## 三、跨方向决策点
|
||||
|
||||
> 假后开工前先拍:
|
||||
|
||||
1. **Phase A 与 TableTODO Phase 0 谁先**:
|
||||
- 选项 A:先 Phase A(Bus 升级),再 Table Phase 0
|
||||
- 选项 B:先 Table Phase 0(解耦),再 Phase A
|
||||
- 选项 C:并行两条分支
|
||||
- 倾向:B(Table Phase 0 不依赖 Bus,先把表格上帝模块拆了,后续 Phase A 也好用 ToolRegistry)
|
||||
|
||||
2. **Phase A 是否必须 ship 才能开 Table Phase B**:
|
||||
- 不必须。Phase B(JSON formatter)独立。Phase C(toolcall)才依赖 Phase A。
|
||||
|
||||
3. **是否合并发版**:
|
||||
- 选项 A:Phase 0 → 单独 ship → Phase A → ship → Phase B/C → ship(增量发布,回归风险低)
|
||||
- 选项 B:全部攒一起一次性发(节奏简单但风险高)
|
||||
- 倾向:A,每完成一段先发,老用户始终能用 legacy。
|
||||
|
||||
---
|
||||
|
||||
## 四、不在范围内
|
||||
|
||||
- 不重写 ui/table-bindings.js
|
||||
- 不改持久化 schema
|
||||
- 不改 SuperMemory 集成
|
||||
- 不引入 TypeScript
|
||||
|
||||
---
|
||||
|
||||
## 五、工时汇总
|
||||
|
||||
| 主线 | 子项 | 估时 |
|
||||
| ---- | ---- | ---- |
|
||||
| Bus | Phase A (tool-call 升级) | 1.5 天 |
|
||||
| 表格 | TableTODO Phase 0-C | ~5 天(详见 TableTODO §十) |
|
||||
| 验收 | 整体回归 + UI 验证 | 1 天 |
|
||||
|
||||
**合计 ~7.5 天人时。** 假期 5 天 + 假后两周缓冲,5 月底前可全量上线。
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
28
DEPLOY_NOTE.md
Normal 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
154
DPS.drawio
Normal 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
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
按 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
<Amily2Edit>" 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
{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
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
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
触发条件 + 楼层扫描" 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
批次循环" 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
applyOperations(state, ops) → { state, changes }
含 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
currentTablesState
独占所有权" 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
commitToLastMessage
封装 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
addRow / addCol / ...
(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
toCsv * 3
(纯函数)" 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
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
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="实线箭头 → 直接调用(自顶而下) 虚线箭头 → 数据流向 / 跨层订阅 红色 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="特点: • 五层洋葱模型,依赖单向自顶而下 • 文件少(~14),manager.js 拆出后立即清晰 • Domain 是纯逻辑岛,可独立测试 • 无显式 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
178
IAD.drawio
Normal 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
(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
(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
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
saveStateToMessage
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
封装 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
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
<Amily2Edit>" 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
{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
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
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="实线箭头 → 直接调用 虚线箭头 → 依赖 / 数据流向 / 接口实现关系 斜体 = 抽象契约(@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="特点: • DTO 层独立,三模式 formatter 输出统一吐 Op[] • Action 是纯函数,注入 Interface 后可单元测试 • 文件多(~25),目录树是主导航 • 适合未来 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>
|
||||
@@ -60,9 +60,16 @@ export function makeDraggable($element, onClick, storageKey) {
|
||||
});
|
||||
};
|
||||
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
const dragMove = (e) => {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
if (!hasDragged) {
|
||||
const coords = getEventCoords(e.originalEvent || e);
|
||||
const dist = Math.abs(coords.x - startPos.x) + Math.abs(coords.y - startPos.y);
|
||||
if (dist < DRAG_THRESHOLD) return;
|
||||
}
|
||||
hasDragged = true;
|
||||
|
||||
const coords = getEventCoords(e.originalEvent || e);
|
||||
|
||||
@@ -194,7 +194,7 @@ export function toggleSettingsOrb() {
|
||||
}
|
||||
}
|
||||
|
||||
async function showPresetSettings() {
|
||||
export async function showPresetSettings() {
|
||||
const template = $(await renderExtensionTemplateAsync(presetSettingsPath, 'prese-settings'));
|
||||
|
||||
renderPresetManager(template);
|
||||
|
||||
@@ -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;
|
||||
|
||||
22
SL/module/RuleConfigModule.js
Normal file
22
SL/module/RuleConfigModule.js
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
35
TODO.md
@@ -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
356
TODOList.md
Normal 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.js),manager.js 退化为兼容层(仍保留 16 个 UI mutation + loadTables + updateTableFromText)
|
||||
- **API 厂商识别**:[utils/api-vendor.js](utils/api-vendor.js) 提供 detectVendor / listVendorParams;registry 在 [assets/api-vendor-params.json](assets/api-vendor-params.json)
|
||||
- **VS Code 类型校验**:[jsconfig.json](jsconfig.json) 已开启 checkJs,[types/sillytavern.d.ts](types/sillytavern.d.ts) 提供 SillyTavern 全局模块声明
|
||||
|
||||
---
|
||||
|
||||
## 二、待办任务
|
||||
|
||||
### 任务卡格式说明
|
||||
|
||||
每个任务包含:
|
||||
- **类型**:bug / feature / refactor / cleanup / docs
|
||||
- **难度**:🟢 简单(< 1h)/ 🟡 中等(1-3h)/ 🔴 高耦合(> 3h 或需架构判断)
|
||||
- **建议执行者**:`GPT` / `Claude` / `Human` / `任意`
|
||||
- **文件**:明确路径 + 行号锚点(若适用)
|
||||
- **修改要点**:bullet 列表
|
||||
- **验收**:可验证的预期行为
|
||||
- **依赖**:前置任务的 ID(若有)
|
||||
|
||||
---
|
||||
|
||||
### 🟢 GPT-friendly 简单任务
|
||||
|
||||
#### T-001: 清理已确认的死代码
|
||||
|
||||
- **类型**:cleanup
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**待清理项**:
|
||||
|
||||
1. **[core/fractal-memory.js](core/fractal-memory.js)** —— 整个文件死代码,`initializeFractalMemory` 在文件外完全没人调用。建议:直接删除整个文件。
|
||||
2. **[ui/historiography-bindings.js:494-513](ui/historiography-bindings.js#L494)** —— 绑定 `#amily2_ngms_temperature` 和 `#amily2_ngms_max_tokens` 这两个 HTML 中已不存在的元素。`getElementById` 永远返回 null,整段代码空跑。建议:直接删掉这段。
|
||||
3. **[ui/plot-opt-bindings.js:664-665](ui/plot-opt-bindings.js#L664)** —— 同样引用不存在的 `#amily2_opt_max_tokens` / `#amily2_opt_temperature`。建议:删掉。
|
||||
4. **[ui/plot-opt-bindings.js:698-699](ui/plot-opt-bindings.js#L698)** —— `opt_bindSlider` 调用同样的不存在 ID,删除。
|
||||
|
||||
**修改要点**:
|
||||
- 删除前用 grep 确认每个 ID 在所有 .html 文件里都不存在
|
||||
- 删完后用 grep 检查没有其他文件 import 被删的函数
|
||||
- 提交前肉眼跑一次表格填表 / 剧情优化 / NGMS 总结,确认 UI 无回归
|
||||
|
||||
**验收**:
|
||||
- [ ] 4 处死代码块全部删除
|
||||
- [ ] 启动控制台无 JS 错误
|
||||
- [ ] 表格 / 剧情优化 / 总结功能无回归
|
||||
|
||||
---
|
||||
|
||||
#### T-002: cwb / autoCharCard 加入 legacy 自动迁移
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:[utils/config/ApiProfileManager.js](utils/config/ApiProfileManager.js) 的 `LEGACY_PROFILE_MIGRATION_MAP` 目前覆盖 main / plotOpt / plotOptConc / ngms / nccs / sybd 6 个 slot。cwb 和 autoCharCard 的 legacy 字段结构略不同(cwb 用 `cwb_apiUrl` / `cwb_apiKey` / `cwb_model` ;autoCharCard 用 `acc_*` 前缀),所以暂时没纳入。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 找出 cwb / autoCharCard 的 legacy 字段名(grep `cwb_apiUrl` / `acc_apiUrl` 之类)
|
||||
2. 在 `LEGACY_PROFILE_MIGRATION_MAP` 加两条:
|
||||
```js
|
||||
{
|
||||
slot: 'cwb',
|
||||
urlKey: 'cwb_apiUrl',
|
||||
modelKey: 'cwb_model',
|
||||
keyName: 'cwb_apiKey',
|
||||
maxTokensKey: 'cwb_max_tokens',
|
||||
temperatureKey: 'cwb_temperature',
|
||||
name: 'CWB 旧配置',
|
||||
},
|
||||
{
|
||||
slot: 'autoCharCard',
|
||||
urlKey: '???', // 需 grep 确认实际 key
|
||||
...
|
||||
}
|
||||
```
|
||||
3. 同时在 `clearLegacyConfig` 的 `ALL_LEGACY_FIELDS` 和 `LEGACY_KEY_NAMES` 加对应条目
|
||||
|
||||
**验收**:
|
||||
- [ ] 两个 slot 在迁移自调用 IIFE 跑过后能正确创建 profile + setKey + setAssignment
|
||||
- [ ] 清理按钮能识别并清除这俩模块的旧字段
|
||||
|
||||
---
|
||||
|
||||
#### T-003: 表格 NCCS 支路透传 customParams
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:v2.2.0 给 `core/api.js` 的 callOpenAITest / callOpenAICompatible / callSillyTavernBackend 都接入了 `options.customParams` spread。但 [core/api/NccsApi.js](core/api/NccsApi.js) 的 `callNccsOpenAITest` 等独立路径**没有**接入,导致用户在 NCCS profile 配置的 customParams 不生效。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 找 [NccsApi.js](core/api/NccsApi.js) 里发请求的函数(`callNccsOpenAITest` / `callNccsSillyTavernPreset`),定位到 `JSON.stringify({ ... })` 处
|
||||
2. 在 body 构建时按"customParams 在前,核心字段在后覆盖"的顺序 spread:
|
||||
```js
|
||||
body: JSON.stringify({
|
||||
...(options.customParams || {}),
|
||||
// 核心字段
|
||||
chat_completion_source: 'openai',
|
||||
model: options.model,
|
||||
messages,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
3. 同时确保 `getNccsApiSettings` 把 `profile.customParams` 透出(参考 [core/api.js:447-462](core/api.js#L447) 模式)
|
||||
4. 同步给 NgmsApi / JqyhApi / SybdApi 做相同处理
|
||||
|
||||
**验收**:
|
||||
- [ ] 在 NCCS profile 加 `{"top_p": 0.5}` 后,DevTools Network 看请求 body 包含 top_p:0.5
|
||||
- [ ] NGMS / JQYH / SYBD 同样验证
|
||||
|
||||
---
|
||||
|
||||
#### T-004: hint panel 点击参数名插入到 textarea
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:[ui/api-config-bindings.js](ui/api-config-bindings.js) 的 `_updateCustomParamsHint` 现在只显示纯文本"已知参数:top_p、frequency_penalty、..."。没有交互。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 把 hint 区改成参数名按钮列表,每个按钮 click 触发"如果当前 textarea JSON 已有这个 key 则不动,没有就 append 进去"
|
||||
2. 实现 `_insertParamToCustomParams(paramName, defaultValue)`:解析 textarea JSON → 添加 key(用合理的占位值,例如 number 类型用 0、string 类型用 ""、object 类型用 {})→ JSON.stringify 回写
|
||||
3. 处理 textarea 当前为空 / 当前是非法 JSON 的情况(非法 JSON 时按钮 disabled + 提示用户先修复)
|
||||
|
||||
**验收**:
|
||||
- [ ] 切换 vendor 后参数名按钮列表更新
|
||||
- [ ] 点击按钮把对应 key 添加到 textarea
|
||||
- [ ] 已存在的 key 不重复添加
|
||||
|
||||
---
|
||||
|
||||
### 🟡 中等任务
|
||||
|
||||
#### T-005: 15 处散乱 vendor URL 检查迁到 detectVendor
|
||||
|
||||
- **类型**:refactor
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:GPT 或 Claude
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:之前的 51TODO Phase B 收尾任务。代码里 15+ 处 `apiUrl.includes('googleapis.com')` 散乱判断厂商,应该统一调 [utils/api-vendor.js#detectVendor](utils/api-vendor.js)。
|
||||
|
||||
**待迁移文件**(grep `googleapis.com|anthropic.com|openai.com` 找):
|
||||
|
||||
- `ui/api-config-bindings.js`
|
||||
- `ui/plot-opt-bindings.js`
|
||||
- `core/rag-api.js`
|
||||
- `ui/profile-sync.js`
|
||||
- `core/api.js`
|
||||
- `CharacterWorldBook/src/cwb_apiService.js`
|
||||
- `ui/bindings.js`
|
||||
- `ui/table/nccs-bindings.js`
|
||||
- `core/api/SybdApi.js`
|
||||
- `core/api/Ngms_api.js`
|
||||
- `core/api/JqyhApi.js`
|
||||
- `core/api/NccsApi.js`
|
||||
- `core/api/ConcurrentApi.js`
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 每处 `if (apiUrl.includes('googleapis.com'))` 改为 `if ((await detectVendor(apiUrl)) === 'google')`
|
||||
2. 注意有的位置在同步上下文(事件回调),用 `detectVendorSync` 但要先 `await getRegistry()` 预加载
|
||||
3. 不要为了重构改变行为:原来只判断 google 就只判断 google,原来判断多个 vendor 就保留多个
|
||||
|
||||
**验收**:
|
||||
- [ ] 所有散乱 URL 检查替换完
|
||||
- [ ] 行为完全等价(用 grep 自检 includes 已全替换)
|
||||
- [ ] 跑一遍主功能(主聊天 / 剧情优化 / NGMS 总结 / 表格填表)确认无回归
|
||||
|
||||
---
|
||||
|
||||
#### T-006: jqyh/sybd/cwb 在 profile 已分配时把 slider 改成 informational
|
||||
|
||||
- **类型**:feature / UX
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:GPT 或 Claude
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:v2.2.0 之后,profile 一旦分配就权威,jqyh/sybd/cwb 这些有 slider 的模块在 profile 分配后 slider 是无效的(用户改 slider 不影响请求)。这是用户陷阱。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
每个有 slider 的模块面板([plot-opt-bindings.js](ui/plot-opt-bindings.js) / [historiography-bindings.js](ui/historiography-bindings.js) / [glossary 相关 bindings](ui/) / [cwb_settingsManager.js](CharacterWorldBook/src/cwb_settingsManager.js)):
|
||||
|
||||
1. 启动时 / profile 分配变化时检查对应 slot 是否分配了 profile
|
||||
2. 若已分配:
|
||||
- slider disable
|
||||
- slider 旁加小字提示:"当前由 profile 「{profile.name}」 控制,请在 API 连接配置面板修改 profile"
|
||||
3. 若未分配:保持原样(slider 可用,写入 legacy 字段)
|
||||
4. 监听 profile 分配变化事件(可通过 ApiProfileManager 加 subscribe,或者轮询)
|
||||
|
||||
**验收**:
|
||||
- [ ] 给 plotOpt 分配 profile 后,剧情优化面板的温度/maxTokens slider 变灰 + 提示
|
||||
- [ ] 取消分配后 slider 重新可用
|
||||
- [ ] 其他模块同样行为
|
||||
|
||||
---
|
||||
|
||||
#### T-007: 表格 Phase 0.4 — 抽出 mutations.js
|
||||
|
||||
- **类型**:refactor
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:Claude(涉及 IAD 一致性判断)
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:[TableTODO.md#四-phase-0](TableTODO.md) 计划的 Phase 0.4。manager.js 还有 16 个 UI 突变函数(addRow / deleteColumn / renameTable 等),应抽到 `core/table-system/actions/ui-mutations.js`。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 在 `core/table-system/actions/` 创建 `ui-mutations.js`
|
||||
2. 把 manager.js 里这 16 个函数搬过去:deleteColumn / moveRow / insertRow / addRow / addColumn / updateHeader / deleteRow / restoreRow / commitPendingDeletions / insertColumn / moveColumn / deleteTable / addTable / renameTable / moveTable / updateTableRules / updateRow / clearAllTables / updateColumnWidth
|
||||
3. manager.js 改为 re-export 这些函数(保持外部调用路径不变)
|
||||
4. 各函数签名/行为保持完全一致
|
||||
|
||||
**验收**:
|
||||
- [ ] manager.js 行数显著减少
|
||||
- [ ] 所有 UI 突变操作在表格面板里行为一致(手动测每个操作)
|
||||
- [ ] 没有任何 import 失败
|
||||
|
||||
---
|
||||
|
||||
### 🔴 高耦合 / 架构任务
|
||||
|
||||
#### T-008: Bus tool-call 能力升级
|
||||
|
||||
- **类型**:feature / 架构
|
||||
- **难度**:🔴 高
|
||||
- **建议执行者**:Claude(涉及 Bus 架构判断)
|
||||
- **依赖**:无(独立于表格重构)
|
||||
|
||||
**详见**:[51TODO.md#二-phase-a-bus-tool-call-升级](51TODO.md)
|
||||
|
||||
**核心交付**:
|
||||
- `SL/bus/tool/ToolRegistry.js` 私有工具注册表
|
||||
- `register(pluginName)` 返回的 context 加 `tool` 能力
|
||||
- `Options.js` / `RequestBody.js` 支持 `tools` / `toolChoice` 字段
|
||||
- `context.model.callWithTools(messages, options, { maxSteps, onToolError })` agent loop
|
||||
|
||||
**预估**:1.5 天
|
||||
|
||||
---
|
||||
|
||||
#### T-009: 表格 Phase B — JSON formatter
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:GPT 或 Claude
|
||||
- **依赖**:无(不依赖 Bus 升级)
|
||||
|
||||
**详见**:[TableTODO.md#五-phase-b-json-formatter](TableTODO.md)
|
||||
|
||||
**核心交付**:
|
||||
- `core/table-system/formatters/json.js`:教 LLM 输出 `{"operations":[...]}`,解析为 Op[]
|
||||
- 设置项 `table_filling_format: 'legacy'|'json'|'toolcall'`,默认 `legacy`
|
||||
- UI 加 dropdown 切换
|
||||
- fillerShared 调用统一 formatter dispatcher
|
||||
|
||||
**预估**:0.5 天
|
||||
|
||||
---
|
||||
|
||||
#### T-010: 表格 Phase C — ToolCall formatter
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:Claude
|
||||
- **依赖**:T-008 完成 + T-009 完成
|
||||
|
||||
**详见**:[TableTODO.md#六-phase-c-toolcall-formatter](TableTODO.md)
|
||||
|
||||
---
|
||||
|
||||
#### T-011: 表格 Phase 0.7-0.9 收尾
|
||||
|
||||
- **类型**:refactor
|
||||
- **难度**:🔴 高(filler 三方差异需小心对齐 / 解循环依赖 / Service 重写)
|
||||
- **建议执行者**:Claude
|
||||
- **依赖**:T-007(Phase 0.4 mutations 完成后做)
|
||||
|
||||
**详见**:[TableTODO.md#四-phase-0](TableTODO.md) 0.7-0.9
|
||||
|
||||
- 0.7: `core/table-system/filler/shared.js` —— 三个 filler 重复代码消除
|
||||
- 0.8: 解 manager.js ↔ secondary-filler.js 循环依赖
|
||||
- 0.9: TableSystemService 真正变成门面
|
||||
|
||||
**预估**:1 天
|
||||
|
||||
---
|
||||
|
||||
## 三、派工建议
|
||||
|
||||
### 适合现在直接派给 GPT(独立、无架构判断)
|
||||
|
||||
- ✅ T-001 死代码清理
|
||||
- ✅ T-002 cwb/autoCharCard 加入迁移
|
||||
- ✅ T-003 NCCS 透传 customParams
|
||||
- ✅ T-004 hint panel 点击插入
|
||||
|
||||
### GPT 或 Claude 都可以
|
||||
|
||||
- T-005 vendor 检查迁移(量大但机械)
|
||||
- T-006 slider informational 状态
|
||||
- T-009 JSON formatter
|
||||
|
||||
### 建议留给 Claude 或人
|
||||
|
||||
- T-007 mutations.js 抽出(涉及 IAD 一致性)
|
||||
- T-008 Bus tool-call 升级(架构核心)
|
||||
- T-010 ToolCall formatter(依赖前置)
|
||||
- T-011 表格 Phase 0 收尾(filler 重复代码 dedup 风险高)
|
||||
|
||||
---
|
||||
|
||||
## 四、未列入但可能的小项
|
||||
|
||||
- 自动迁移完成后给所有 chat 类型 slot 加默认 link 选项(不只 tableFilling)
|
||||
- profile 分配 UI 加"复用现有 profile"快捷按钮(避免用户为每个 slot 重复创建相同配置)
|
||||
- 51TODO.md 第三节决策点中"是否合并发版"等问题做最终决定记录
|
||||
- TODO.md(旧版本变更日志)的 v2.2.0 版本条目补全
|
||||
309
TableTODO.md
Normal file
309
TableTODO.md
Normal 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 个突变 export(addRow / addColumn 等)搬过来
|
||||
- 全部改为:调 store.setState + persist.commitToLastMessage + 发事件
|
||||
- **删除**对 ui/* 的所有 import;改为 `store.subscribe` 让 UI 自己订阅刷新
|
||||
|
||||
### 0.5 拆 rendering.js
|
||||
- 文件:`core/table-system/domain/rendering.js`
|
||||
- 把 [convertTablesToCsvString](core/table-system/manager.js#L1005) / [convertSelectedTablesToCsvString](core/table-system/manager.js#L1096) / [convertTablesToCsvStringForContentOnly](core/table-system/manager.js#L1201) 搬过来
|
||||
- 都做成纯函数 `(state, options?) => string`,不依赖 store
|
||||
|
||||
### 0.6 拆 templates.js + preset.js
|
||||
- `domain/templates.js`:getBatchFillerRuleTemplate / saveBatchFillerRuleTemplate / Flow 同款
|
||||
- `domain/preset.js`:exportPreset / importPreset / clearGlobalPreset / importGlobalPreset
|
||||
|
||||
### 0.7 抽出 fillerShared.js(消除三 filler 重复)
|
||||
- 文件:`core/table-system/filler/shared.js`
|
||||
- 提供:
|
||||
- `getWorldBookContext(settings)` — 合并 secondary 和 batch 两份的差异,参数化处理
|
||||
- `buildHistoryContext(opts)` — 统一对话历史拼装
|
||||
- `buildMessages(scope, { worldbook, history, coreContent, flowPrompt, ruleTemplate })` — mixed-order 循环 + presetPrompts 拼装
|
||||
- `callModel(messages, settings)` — 统一 nccsEnabled 分支
|
||||
- secondary-filler.js / batch-filler.js / reorganizer.js 改用 shared
|
||||
|
||||
### 0.8 解循环依赖
|
||||
- manager.js 的 `rollbackAndRefill` 不直接 import `fillWithSecondaryApi`
|
||||
- 改为:在 service 层 (TableSystemService) 编排"先 rollback 再 fill"
|
||||
- manager(或新的 mutations.js)只暴露 rollbackState
|
||||
|
||||
### 0.9 TableSystemService 真正变成门面
|
||||
- 不再 `import * as TableManager` + 一一 expose
|
||||
- 改为:内部组合 store / persist / mutations / formatters / filler,对外只暴露稳定接口
|
||||
- 现有 `processMessageUpdate` 保留
|
||||
|
||||
**Phase 0 完成验收**:
|
||||
- [ ] manager.js 缩到 < 200 行(仅作为 deprecation 兼容层重导出 + 标 @deprecated)
|
||||
- [ ] 任何 domain/* 文件都不 import ui/*
|
||||
- [ ] 三个 filler 共用 fillerShared.js,各自只有 ~100 行
|
||||
- [ ] 现行 legacy 模式行为完全不变(手动验证)
|
||||
|
||||
---
|
||||
|
||||
## 五、Phase B:JSON formatter
|
||||
|
||||
> 依赖 Phase 0。不依赖 Bus 升级(Phase A)。
|
||||
|
||||
### B.1 formatters/json.js
|
||||
- prompt 模板:教 LLM 输出 `{ "operations": [{ "op": "insertRow", "tableIndex": 0, "data": { "0": "...", "1": "..." } }] }`
|
||||
- 解析:`JSON.parse` + schema 校验 → Op[]
|
||||
- 输出 Op[] 给 applyOperations
|
||||
|
||||
### B.2 设置项与 UI
|
||||
- 新设置:`settings.table_filling_format: 'legacy' | 'json' | 'toolcall'`,默认 `legacy`
|
||||
- 表格设置面板加 dropdown
|
||||
- 默认值保证老用户零感知
|
||||
|
||||
### B.3 集成到 fillerShared
|
||||
- shared.callModel 调完后传 raw response 给当前 formatter
|
||||
- formatter 返回 Op[]
|
||||
- shared 负责 applyOperations + persist + 发事件
|
||||
|
||||
**Phase B 验收**:
|
||||
- [ ] 切换到 json 模式后,手动跑分步填表 + 批量填表 + 重新整理 三种场景都能成功
|
||||
- [ ] 回切 legacy 行为不变
|
||||
|
||||
---
|
||||
|
||||
## 六、Phase C:ToolCall formatter
|
||||
|
||||
> 依赖 Phase 0 + 51TODO Phase A(Bus tool-call 升级)+ Phase B(B 已经把 formatter 切换走通了)。
|
||||
|
||||
### C.1 formatters/toolcall.js
|
||||
- 注册 Bus 工具:`table.insertRow / table.updateRow / table.deleteRow`
|
||||
- 工具 parameters 用标准 JSONSchema 描述
|
||||
- handler 内部调 `applyOperations`(其实是收集 Op[] 累加)
|
||||
- 让 fillerShared 在该模式下走 `model.callWithTools`,loop 跑完后取累计的 Op[]
|
||||
|
||||
### C.2 终止条件
|
||||
- LLM 在某轮没有吐 tool_calls 即停(对应"我已填完"的语义信号)
|
||||
- maxSteps 兜底
|
||||
|
||||
### C.3 Prompt 调整
|
||||
- toolcall 模式下不需要 `<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.2,2.0.3 删除
|
||||
|
||||
---
|
||||
|
||||
## 八、不在范围内(明确不做)
|
||||
|
||||
- 不重写 ui/table-bindings.js(UI 层独立演进)
|
||||
- 不改持久化 schema(`message.extra.amily2_tables_data` 保持)
|
||||
- 不改 SuperMemory 集成(继续走 Bus query + CustomEvent fallback)
|
||||
- 不引入 TypeScript(DTS 注释为主)
|
||||
- Phase 0 阶段不动 prompt 模板内容(只挪文件位置)
|
||||
|
||||
---
|
||||
|
||||
## 九、入手顺序
|
||||
|
||||
1. Phase 0.3(operations.js)—— 影响面小,立刻能验证 executor 抽离不破坏 legacy
|
||||
2. Phase 0.1 + 0.2(store + persist)—— 给后续 mutations 拆解铺路
|
||||
3. Phase 0.4-0.6 —— manager.js 收缩主战
|
||||
4. Phase 0.7-0.9 —— filler 重复消除 + 循环依赖
|
||||
5. Phase 0 整体回归
|
||||
6. Phase B(独立可走,不等 Bus 升级)
|
||||
7. Phase C(等 51TODO Phase A 完成后再做)
|
||||
|
||||
---
|
||||
|
||||
## 十、工时(粗)
|
||||
|
||||
| Phase | 预估 | 风险 |
|
||||
|-------|------|------|
|
||||
| 0.1-0.3 (store/persist/operations) | 1 天 | 低 |
|
||||
| 0.4-0.6 (mutations/rendering/templates) | 1 天 | 中(manager.js 删减易漏) |
|
||||
| 0.7-0.9 (filler / 循环依赖 / Service) | 1 天 | 中(filler 三方差异需仔细对齐) |
|
||||
| Phase B | 0.5 天 | 低 |
|
||||
| Phase C | 0.5 天 | 低(前置都搞完了,纯组装) |
|
||||
| 回归测试 | 1 天 | — |
|
||||
|
||||
合计 ~5 天人时(不含 Bus 升级,那部分见 51TODO)。
|
||||
@@ -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">
|
||||
|
||||
@@ -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(工具调用)进行填表,模型直接返回结构化操作列表,无需解析 <Amily2Edit> 指令块。仅支持 openai 直连模式。">使用 Function Call 填表</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="table-fill-function-call-enabled">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="notes" style="margin-bottom: 10px;">仅支持 openai 直连接口(tableFilling 槽位)。启用后跳过 <Amily2Edit> 文本解析,由模型直接返回操作列表。</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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
208
assets/api-vendor-params.json
Normal file
208
assets/api-vendor-params.json
Normal 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 没有。" }
|
||||
}
|
||||
}
|
||||
}
|
||||
84
assets/rule-config-panel.html
Normal file
84
assets/rule-config-panel.html
Normal 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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
221
core/api.js
221
core/api.js
@@ -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(仅接管连接参数)
|
||||
// 优先读取槽位分配的 Profile(profile 一旦分配即为权威,不再被主面板/模块独立设置压制)
|
||||
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_calls,finish_reason:', data?.choices?.[0]?.finish_reason);
|
||||
return null;
|
||||
}
|
||||
|
||||
const argsString = toolCalls[0]?.function?.arguments;
|
||||
console.groupCollapsed('[Amily2号-Function Call 响应]');
|
||||
console.log(argsString);
|
||||
console.groupEnd();
|
||||
return argsString ?? null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Amily2-外交部] Function Call 调用失败:', error);
|
||||
toastr.error(`Function Call 调用失败: ${error.message}`, 'Amily2-外交部');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { getRequestHeaders } from "/script.js";
|
||||
import { extensionName } from "../../utils/settings.js";
|
||||
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||
import { configManager } from '../../utils/config/ConfigManager.js';
|
||||
import { detectVendor } from '../../utils/api-vendor.js';
|
||||
|
||||
async function getConcurrentApiSettings() {
|
||||
const s = extension_settings[extensionName] || {};
|
||||
|
||||
// 优先读取槽位分配的 Profile(仅接管连接参数)
|
||||
// 优先读取槽位分配的 Profile(profile 一旦分配即权威,slider 残值不再覆盖)
|
||||
const profile = await getSlotProfile('plotOptConc');
|
||||
if (profile) {
|
||||
return {
|
||||
@@ -15,8 +16,7 @@ async function getConcurrentApiSettings() {
|
||||
apiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
model: profile.model,
|
||||
// MaxTokens 读面板值
|
||||
maxTokens: s.plotOpt_concurrentMaxTokens ?? profile.maxTokens ?? 8100,
|
||||
maxTokens: profile.maxTokens ?? 8100,
|
||||
temperature: profile.temperature ?? 1,
|
||||
};
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export async function callConcurrentAI(messages, options = {}) {
|
||||
|
||||
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
|
||||
console.warn("[Amily2-Concurrent外交部] API配置不完整,无法调用AI");
|
||||
toastr.error("并发API配置不完整,请检查URL、Key和模型配置。", "Concurrent-外交部");
|
||||
toastr.error("并发剧情优化(plotOptConc)未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写并发优化独立设置。", "Amily2-并发优化未配置");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function callConcurrentAI(messages, options = {}) {
|
||||
}
|
||||
|
||||
async function callConcurrentOpenAITest(messages, options) {
|
||||
const isGoogleApi = options.apiUrl.includes('googleapis.com');
|
||||
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
|
||||
|
||||
const body = {
|
||||
chat_completion_source: 'openai',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
|
||||
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
||||
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||
import { configManager } from '../../utils/config/ConfigManager.js';
|
||||
import { detectVendor } from '../../utils/api-vendor.js';
|
||||
|
||||
let ChatCompletionService = undefined;
|
||||
try {
|
||||
@@ -41,7 +42,7 @@ if (window.Amily2Bus) {
|
||||
export async function getNccsApiSettings() {
|
||||
const s = extension_settings[extensionName] || {};
|
||||
|
||||
// 优先读取 'nccs' 槽位分配的 Profile(仅接管连接参数)
|
||||
// 优先读取 'nccs' 槽位分配的 Profile(profile 一旦分配即权威,旧 slider 残值不再覆盖)
|
||||
const profile = await getSlotProfile('nccs');
|
||||
if (profile) {
|
||||
return {
|
||||
@@ -50,9 +51,9 @@ export async function getNccsApiSettings() {
|
||||
apiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
model: profile.model,
|
||||
// 温度 / MaxTokens / FakeStream 读面板值(profile-sync 保留了这些输入框)
|
||||
maxTokens: s.nccsMaxTokens ?? profile.maxTokens ?? 65500,
|
||||
temperature: s.nccsTemperature ?? profile.temperature ?? 1.0,
|
||||
maxTokens: profile.maxTokens ?? 65500,
|
||||
temperature: profile.temperature ?? 1.0,
|
||||
customParams: profile.customParams ?? {},
|
||||
tavernProfile: '',
|
||||
useFakeStream: s.nccsFakeStreamEnabled ?? false,
|
||||
};
|
||||
@@ -67,6 +68,7 @@ export async function getNccsApiSettings() {
|
||||
model: s.nccsModel || '',
|
||||
maxTokens: s.nccsMaxTokens ?? 8192,
|
||||
temperature: s.nccsTemperature ?? 1,
|
||||
customParams: {},
|
||||
tavernProfile: s.nccsTavernProfile || '',
|
||||
useFakeStream: s.nccsFakeStreamEnabled || false,
|
||||
};
|
||||
@@ -94,7 +96,7 @@ export async function callNccsAI(messages, options = {}) {
|
||||
if (finalOptions.apiMode !== 'sillytavern_preset') {
|
||||
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
|
||||
console.warn("[Amily2-Nccs外交部] API配置不完整,无法调用AI");
|
||||
toastr.error("API配置不完整,请检查URL、Key和模型配置。", "Nccs-外交部");
|
||||
toastr.error("并发模块(NCCS)未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 NCCS 独立设置。", "Amily2-NCCS 未配置");
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
@@ -187,8 +189,10 @@ function normalizeApiResponse(responseData) {
|
||||
}
|
||||
|
||||
async function callNccsOpenAITest(messages, options) {
|
||||
const isGoogleApi = options.apiUrl.includes('googleapis.com');
|
||||
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
|
||||
const body = {
|
||||
top_p: options.top_p || 1,
|
||||
...(options.customParams || {}),
|
||||
chat_completion_source: 'openai',
|
||||
messages: messages,
|
||||
model: options.model,
|
||||
@@ -197,7 +201,6 @@ async function callNccsOpenAITest(messages, options) {
|
||||
stream: !!options.stream,
|
||||
max_tokens: 8192,
|
||||
temperature: 1,
|
||||
top_p: options.top_p || 1,
|
||||
};
|
||||
|
||||
if (!isGoogleApi) {
|
||||
@@ -244,7 +247,8 @@ async function callNccsSillyTavernPreset(messages, options) {
|
||||
const result = await context.ConnectionManagerRequestService.sendRequest(
|
||||
targetProfile.id,
|
||||
messages,
|
||||
8192
|
||||
8192,
|
||||
options.customParams || {}
|
||||
);
|
||||
|
||||
return normalizeApiResponse(result);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
|
||||
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
||||
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||
import { configManager } from '../../utils/config/ConfigManager.js';
|
||||
import { detectVendor } from '../../utils/api-vendor.js';
|
||||
|
||||
let ChatCompletionService = undefined;
|
||||
try {
|
||||
@@ -47,7 +48,7 @@ function normalizeApiResponse(responseData) {
|
||||
export async function getNgmsApiSettings() {
|
||||
const s = extension_settings[extensionName] || {};
|
||||
|
||||
// 优先读取 'ngms' 槽位分配的 Profile(仅接管连接参数)
|
||||
// 优先读取 'ngms' 槽位分配的 Profile(profile 一旦分配即权威,旧 slider 残值不再覆盖)
|
||||
const profile = await getSlotProfile('ngms');
|
||||
if (profile) {
|
||||
return {
|
||||
@@ -55,9 +56,9 @@ export async function getNgmsApiSettings() {
|
||||
apiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
model: profile.model,
|
||||
// 温度 / MaxTokens / FakeStream 读面板值
|
||||
maxTokens: s.ngmsMaxTokens ?? profile.maxTokens ?? 65500,
|
||||
temperature: s.ngmsTemperature ?? profile.temperature ?? 1.0,
|
||||
maxTokens: profile.maxTokens ?? 65500,
|
||||
temperature: profile.temperature ?? 1.0,
|
||||
customParams: profile.customParams ?? {},
|
||||
tavernProfile: '',
|
||||
useFakeStream: s.ngmsFakeStreamEnabled ?? false,
|
||||
};
|
||||
@@ -71,6 +72,7 @@ export async function getNgmsApiSettings() {
|
||||
model: s.ngmsModel || '',
|
||||
maxTokens: s.ngmsMaxTokens ?? 30000,
|
||||
temperature: s.ngmsTemperature ?? 1.0,
|
||||
customParams: {},
|
||||
tavernProfile: s.ngmsTavernProfile || '',
|
||||
useFakeStream: s.ngmsFakeStreamEnabled || false,
|
||||
};
|
||||
@@ -101,7 +103,7 @@ export async function callNgmsAI(messages, options = {}) {
|
||||
if (finalOptions.apiMode !== 'sillytavern_preset') {
|
||||
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
|
||||
console.warn("[Amily2-Ngms外交部] API配置不完整,无法调用AI");
|
||||
toastr.error("API配置不完整,请检查URL、Key和模型配置。", "Ngms-外交部");
|
||||
toastr.error("总结模块(NGMS)未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 NGMS 独立设置。", "Amily2-NGMS 未配置");
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
@@ -221,9 +223,11 @@ async function fetchFakeStream(url, opts) {
|
||||
}
|
||||
|
||||
async function callNgmsOpenAITest(messages, options) {
|
||||
const isGoogleApi = options.apiUrl.includes('googleapis.com');
|
||||
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
|
||||
|
||||
const body = {
|
||||
top_p: options.top_p || 1,
|
||||
...(options.customParams || {}),
|
||||
chat_completion_source: 'openai',
|
||||
messages: messages,
|
||||
model: options.model,
|
||||
@@ -232,7 +236,6 @@ async function callNgmsOpenAITest(messages, options) {
|
||||
stream: !!options.stream,
|
||||
max_tokens: options.maxTokens || 30000,
|
||||
temperature: options.temperature || 1,
|
||||
top_p: options.top_p || 1,
|
||||
};
|
||||
|
||||
if (!isGoogleApi) {
|
||||
@@ -312,7 +315,8 @@ async function callNgmsSillyTavernPreset(messages, options) {
|
||||
responsePromise = context.ConnectionManagerRequestService.sendRequest(
|
||||
targetProfile.id,
|
||||
messages,
|
||||
options.maxTokens || 4000
|
||||
options.maxTokens || 4000,
|
||||
options.customParams || {}
|
||||
);
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
|
||||
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
||||
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||
import { configManager } from '../../utils/config/ConfigManager.js';
|
||||
import { detectVendor } from '../../utils/api-vendor.js';
|
||||
|
||||
let ChatCompletionService = undefined;
|
||||
try {
|
||||
@@ -47,7 +48,7 @@ function normalizeApiResponse(responseData) {
|
||||
export async function getSybdApiSettings() {
|
||||
const s = extension_settings[extensionName] || {};
|
||||
|
||||
// 优先读取 'sybd' 槽位分配的 Profile
|
||||
// 优先读取 'sybd' 槽位分配的 Profile(profile 一旦分配即权威,slider 残值不再覆盖)
|
||||
const profile = await getSlotProfile('sybd');
|
||||
if (profile) {
|
||||
return {
|
||||
@@ -55,8 +56,9 @@ export async function getSybdApiSettings() {
|
||||
apiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
model: profile.model,
|
||||
maxTokens: s.sybdMaxTokens ?? profile.maxTokens ?? 4000,
|
||||
temperature: s.sybdTemperature ?? profile.temperature ?? 0.7,
|
||||
maxTokens: profile.maxTokens ?? 4000,
|
||||
temperature: profile.temperature ?? 0.7,
|
||||
customParams: profile.customParams ?? {},
|
||||
tavernProfile: '',
|
||||
};
|
||||
}
|
||||
@@ -69,6 +71,7 @@ export async function getSybdApiSettings() {
|
||||
model: s.sybdModel || '',
|
||||
maxTokens: s.sybdMaxTokens || 4000,
|
||||
temperature: s.sybdTemperature || 0.7,
|
||||
customParams: {},
|
||||
tavernProfile: s.sybdTavernProfile || '',
|
||||
};
|
||||
}
|
||||
@@ -95,7 +98,7 @@ export async function callSybdAI(messages, options = {}) {
|
||||
if (finalOptions.apiMode !== 'sillytavern_preset') {
|
||||
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
|
||||
console.warn("[Amily2-Sybd外交部] API配置不完整,无法调用AI");
|
||||
toastr.error("API配置不完整,请检查URL、Key和模型配置。", "Sybd-外交部");
|
||||
toastr.error("术语表填写(SYBD)未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 SYBD 独立设置。", "Amily2-SYBD 未配置");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -159,9 +162,11 @@ export async function callSybdAI(messages, options = {}) {
|
||||
}
|
||||
|
||||
async function callSybdOpenAITest(messages, options) {
|
||||
const isGoogleApi = options.apiUrl.includes('googleapis.com');
|
||||
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
|
||||
|
||||
const body = {
|
||||
top_p: options.top_p || 1,
|
||||
...(options.customParams || {}),
|
||||
chat_completion_source: 'openai',
|
||||
messages: messages,
|
||||
model: options.model,
|
||||
@@ -170,7 +175,6 @@ async function callSybdOpenAITest(messages, options) {
|
||||
stream: false,
|
||||
max_tokens: options.maxTokens || 30000,
|
||||
temperature: options.temperature || 1,
|
||||
top_p: options.top_p || 1,
|
||||
};
|
||||
|
||||
if (!isGoogleApi) {
|
||||
@@ -244,7 +248,8 @@ async function callSybdSillyTavernPreset(messages, options) {
|
||||
responsePromise = context.ConnectionManagerRequestService.sendRequest(
|
||||
targetProfile.id,
|
||||
messages,
|
||||
options.maxTokens || 4000
|
||||
options.maxTokens || 4000,
|
||||
options.customParams || {}
|
||||
);
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 slot;ngms 未配置时 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 slot;ngms 未配置时 callNgmsAI 自带错误提示。
|
||||
const content = await callNgmsAI(messages);
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
const maxRetries = settings.historiographyMaxRetries ?? 2;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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 中的叶子值覆盖 nested,nested 中 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 = [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
|
||||
190
core/table-system/actions/applyOperations.js
Normal file
190
core/table-system/actions/applyOperations.js
Normal 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 };
|
||||
}
|
||||
@@ -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}`, '处理失败');
|
||||
|
||||
21
core/table-system/dto/Change.js
Normal file
21
core/table-system/dto/Change.js
Normal 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 {};
|
||||
26
core/table-system/dto/Operation.js
Normal file
26
core/table-system/dto/Operation.js
Normal 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 {};
|
||||
38
core/table-system/dto/Table.js
Normal file
38
core/table-system/dto/Table.js
Normal 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 {};
|
||||
9
core/table-system/dto/TableState.js
Normal file
9
core/table-system/dto/TableState.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @file TableState 的实际定义已合并至 ./Table.js(与 Table 共处一处便于阅读)。
|
||||
* 本文件保留为转发别名,供需要按 dto 名称单独导入的消费方使用:
|
||||
* /** @typedef {import('./TableState.js').TableState} TableState *\/
|
||||
*
|
||||
* @typedef {import('./Table.js').TableState} TableState
|
||||
*/
|
||||
|
||||
export {};
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
91
core/table-system/formatters/tool-call.js
Normal file
91
core/table-system/formatters/tool-call.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file formatters/tool-call.js — Function Call 填表格式器
|
||||
*
|
||||
* 职责:
|
||||
* - 导出 TABLE_FILL_TOOL:发给模型的 tools 定义(单工具 + operations 数组)
|
||||
* - 导出 parseToolCallArgs:把 tool_calls[0].function.arguments 解析为 Operation[]
|
||||
*
|
||||
* 与 executor.js(legacy formatter)并列;下游 applyOperations 不感知来源。
|
||||
*
|
||||
* @typedef {import('../dto/Operation.js').Operation} Operation
|
||||
*/
|
||||
|
||||
/**
|
||||
* 填表工具 schema。使用 operations 数组而非多工具并发,兼容所有支持 function calling 的提供商。
|
||||
*
|
||||
* data 的 key 为列索引字符串("0"、"1"...),与 executor.js legacy 格式保持一致,
|
||||
* 提示词中会给出列索引与列名的对应关系。
|
||||
*/
|
||||
export const TABLE_FILL_TOOL = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'apply_table_edits',
|
||||
description: '将一批表格编辑操作应用到记忆表格中。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
operations: {
|
||||
type: 'array',
|
||||
description: '按顺序执行的操作列表。',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: ['insertRow', 'updateRow', 'deleteRow'],
|
||||
description: 'insertRow=新增行,updateRow=更新已有行,deleteRow=删除行'
|
||||
},
|
||||
tableIndex: {
|
||||
type: 'integer',
|
||||
description: '目标表格的 0-based 索引'
|
||||
},
|
||||
rowIndex: {
|
||||
type: 'integer',
|
||||
description: 'updateRow / deleteRow 时必填,目标行的 0-based 索引'
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'insertRow / updateRow 时必填,key 为列索引字符串("0"/"1"...),value 为单元格内容',
|
||||
additionalProperties: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['op', 'tableIndex']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['operations']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析 tool_calls[0].function.arguments 字符串为 Operation[]。
|
||||
* 结构校验失败的单条操作会被静默跳过,不中断整体解析。
|
||||
*
|
||||
* @param {string} argsString - JSON 字符串
|
||||
* @returns {Operation[]}
|
||||
*/
|
||||
export function parseToolCallArgs(argsString) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(argsString);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rawOps = parsed?.operations;
|
||||
if (!Array.isArray(rawOps)) return [];
|
||||
|
||||
/** @type {Operation[]} */
|
||||
const ops = [];
|
||||
for (const raw of rawOps) {
|
||||
if (raw.op === 'insertRow' && Number.isInteger(raw.tableIndex) && raw.data && typeof raw.data === 'object') {
|
||||
ops.push({ op: 'insertRow', tableIndex: raw.tableIndex, data: raw.data });
|
||||
} else if (raw.op === 'updateRow' && Number.isInteger(raw.tableIndex) && Number.isInteger(raw.rowIndex) && raw.data && typeof raw.data === 'object') {
|
||||
ops.push({ op: 'updateRow', tableIndex: raw.tableIndex, rowIndex: raw.rowIndex, data: raw.data });
|
||||
} else if (raw.op === 'deleteRow' && Number.isInteger(raw.tableIndex) && Number.isInteger(raw.rowIndex)) {
|
||||
ops.push({ op: 'deleteRow', tableIndex: raw.tableIndex, rowIndex: raw.rowIndex });
|
||||
}
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
98
core/table-system/infra/persistence.js
Normal file
98
core/table-system/infra/persistence.js
Normal 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;
|
||||
}
|
||||
117
core/table-system/infra/store.js
Normal file
117
core/table-system/infra/store.js
Normal 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
329
core/table-system/preset.js
Normal 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();
|
||||
}
|
||||
239
core/table-system/rendering.js
Normal file
239
core/table-system/rendering.js
Normal 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();
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
78
core/table-system/templates.js
Normal file
78
core/table-system/templates.js
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? '')));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
// 敏感字段从 configManager(localStorage)读取,其余从 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
22
index.js
22
index.js
@@ -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
27
jsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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
68
types/sillytavern.d.ts
vendored
Normal 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 {};
|
||||
@@ -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 ID(null = 新建)
|
||||
let _currentFilter = 'all'; // 当前类型筛选
|
||||
let _slotAssignmentPanel = null;
|
||||
let _slotAssignmentRefreshBound = false;
|
||||
|
||||
// ── 入口:绑定整个面板 ────────────────────────────────────────────────────────
|
||||
|
||||
export function bindApiConfigPanel(container) {
|
||||
const $c = $(container);
|
||||
_slotAssignmentPanel = $c;
|
||||
|
||||
if (!_slotAssignmentRefreshBound) {
|
||||
_slotAssignmentRefreshBound = true;
|
||||
document.addEventListener('amily2:slotAssigned', () => {
|
||||
if (_slotAssignmentPanel) renderSlotAssignments(_slotAssignmentPanel);
|
||||
});
|
||||
}
|
||||
|
||||
// 存储模式
|
||||
_bindStorageMode($c);
|
||||
@@ -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');
|
||||
|
||||
// customParams:JSON 校验失败则中止保存
|
||||
const cp = _parseCustomParamsOrFail($c);
|
||||
if (cp === null) {
|
||||
toastr.error('自定义参数 JSON 解析失败,请修正后再保存。', '保存中止');
|
||||
return;
|
||||
}
|
||||
data.customParams = cp;
|
||||
} else if (type === 'embedding') {
|
||||
const dim = $c.find('#amily2_pf_dimensions').val();
|
||||
data.dimensions = dim ? parseInt(dim, 10) : null;
|
||||
@@ -459,10 +584,11 @@ async function _fetchModels($c) {
|
||||
let models;
|
||||
|
||||
if (provider === 'google') {
|
||||
// Google 用原生 API,以 ?key= 传参,返回 models[] 而非 data[]
|
||||
// Google 用原生 API,Key 通过 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 模式清空 URL;ST backend/preset 不动 URL。
|
||||
* 同时刷新 customParams hint 与校验状态。
|
||||
*/
|
||||
async function _autofillVendorUrl($c, provider) {
|
||||
if (provider === 'custom_oai') {
|
||||
$c.find('#amily2_pf_url').val('');
|
||||
_updateCustomParamsHint($c);
|
||||
return;
|
||||
}
|
||||
if (!VENDOR_PRESETS.has(provider)) {
|
||||
// sillytavern_backend / sillytavern_preset 等不修改 URL
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const entry = await getVendorEntry(provider);
|
||||
if (entry?.defaultUrl) {
|
||||
$c.find('#amily2_pf_url').val(entry.defaultUrl);
|
||||
_updateCustomParamsHint($c);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ApiConfig] autofill defaultUrl 失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,3 +859,153 @@ function _escapeHtml(str) {
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _getCustomParamsEditorState($c) {
|
||||
const raw = ($c.find('#amily2_pf_custom_params').val() || '').trim();
|
||||
if (!raw) {
|
||||
return { valid: true, parsed: {}, empty: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
|
||||
return { valid: false, parsed: null, empty: false };
|
||||
}
|
||||
return { valid: true, parsed, empty: false };
|
||||
} catch {
|
||||
return { valid: false, parsed: null, empty: false };
|
||||
}
|
||||
}
|
||||
|
||||
function _getDefaultValueForParamType(type) {
|
||||
const normalized = String(type || '').toLowerCase();
|
||||
if (normalized.includes('array')) return [];
|
||||
if (normalized.includes('object')) return {};
|
||||
if (normalized.includes('integer') || normalized.includes('number')) return 0;
|
||||
if (normalized.includes('boolean')) return false;
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── customParams 辅助 ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 根据当前 URL 输入识别 vendor,并把已知参数列表渲染到 hint 行。
|
||||
* registry 还没异步加载完时(detectVendorSync 返回 null)静默跳过。
|
||||
*/
|
||||
function _updateCustomParamsHint($c) {
|
||||
const $hint = $c.find('#amily2_pf_custom_params_hint');
|
||||
if (!$hint.length) return;
|
||||
|
||||
const apiUrl = $c.find('#amily2_pf_url').val()?.trim() || '';
|
||||
const vendorId = detectVendorSync(apiUrl);
|
||||
if (!vendorId) {
|
||||
$hint.empty();
|
||||
return;
|
||||
}
|
||||
|
||||
const params = listVendorParamsSync(vendorId);
|
||||
if (!params.length) {
|
||||
$hint.empty();
|
||||
return;
|
||||
}
|
||||
|
||||
const editorState = _getCustomParamsEditorState($c);
|
||||
getVendorEntry(vendorId).then(entry => {
|
||||
const label = entry?.displayName || vendorId;
|
||||
const disabledAttr = editorState.valid ? '' : ' disabled';
|
||||
const buttons = params.map(param => `
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function escapeAttribute(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
|
||||
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="删除此规则">×</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="删除此条">×</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');
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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)从 configManager(localStorage)读取
|
||||
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="删除此规则">×</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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 是敏感字段,从 configManager(localStorage)读取
|
||||
panel.find('#amily2_opt_api_key').val(configManager.get('plotOpt_apiKey') || '');
|
||||
|
||||
const modelInput = panel.find('#amily2_opt_model');
|
||||
const modelSelect = panel.find('#amily2_opt_model_select');
|
||||
@@ -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)从 configManager(localStorage)读取
|
||||
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 造成的污染 key:handleSettingChange 误把世界书/条目复选框当作设置项,
|
||||
// 生成形如 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)从 configManager(localStorage)读取
|
||||
element.value = field.sensitive
|
||||
? (configManager.get(field.key) || '')
|
||||
: (extension_settings[extensionName][field.key] || '');
|
||||
const saveField = function() {
|
||||
if (field.sensitive) {
|
||||
configManager.set(field.key, this.value);
|
||||
} else {
|
||||
updateAndSaveSetting(field.key, this.value);
|
||||
if (field.key === 'jqyhModel') {
|
||||
syncModelMirror(
|
||||
document.getElementById('amily2_jqyh_model'),
|
||||
document.getElementById('amily2_jqyh_model_select')
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
bindInputLikeSave(element, saveField);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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. 隐藏元素(上溯到容器直接子元素)+ 前一个兄弟 label(inline-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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
154
ui/rule-config-bindings.js
Normal file
154
ui/rule-config-bindings.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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('规则配置已删除。');
|
||||
});
|
||||
}
|
||||
24
ui/state.js
24
ui/state.js
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
48
ui/table/chat-display-bindings.js
Normal file
48
ui/table/chat-display-bindings.js
Normal 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
242
ui/table/nccs-bindings.js
Normal 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');
|
||||
}
|
||||
64
ui/table/template-bindings.js
Normal file
64
ui/table/template-bindings.js
Normal 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
157
utils/api-vendor.js
Normal 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() → 整个 registry(debug 用)
|
||||
*
|
||||
* 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
@@ -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/value(top_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 从 configManager(localStorage)读出,写入 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. 删除 configManager(localStorage)里的 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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
306
utils/config/RuleProfileManager.js
Normal file
306
utils/config/RuleProfileManager.js
Normal 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
@@ -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)')]);
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user