ci: auto build & obfuscate [2026-05-16 19:16:28] (Jenkins #21)

This commit is contained in:
Jenkins CI
2026-05-16 19:16:28 +08:00
parent 4bc6e0a047
commit d9fa3072a2
46 changed files with 4154 additions and 1584 deletions

View File

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

View File

@@ -8,6 +8,7 @@ import * as IngestionManager from '../core/ingestion-manager.js';
import { showContentModal, showHtmlModal } from './page-window.js';
import { extractBlocksByTags, applyExclusionRules } from '../core/utils/rag-tag-extractor.js';
import { ruleProfileManager, resolveCondensationRuleConfig } from '../utils/config/RuleProfileManager.js';
import { syncSlot } from './profile-sync.js';
import {
filterWorldbooks,
filterWorldbookEntries,
@@ -152,6 +153,8 @@ export function bindHanlinyuanEvents() {
}
setupGlobalEventHandlers();
syncSlot('ragEmbed');
syncSlot('ragRerank');
bindPanelToggleEvents();
bindInternalUIEvents();
bindTutorialEvents(); // 【新增】绑定教程按钮事件

View File

@@ -490,28 +490,6 @@ function bindNgmsApiEvents() {
}
});
// 滑块控件绑定
const sliderFields = [
{ id: 'amily2_ngms_max_tokens', key: 'ngmsMaxTokens', defaultValue: 4000 },
{ id: 'amily2_ngms_temperature', key: 'ngmsTemperature', defaultValue: 0.7 }
];
sliderFields.forEach(field => {
const slider = document.getElementById(field.id);
const display = document.getElementById(field.id + '_value');
if (slider && display) {
const value = extension_settings[extensionName][field.key] || field.defaultValue;
slider.value = value;
display.textContent = value;
slider.addEventListener('input', function() {
const newValue = parseFloat(this.value);
display.textContent = newValue;
updateAndSaveSetting(field.key, newValue);
});
}
});
// SillyTavern预设选择器
const tavernProfileSelect = document.getElementById('amily2_ngms_tavern_profile');
if (tavernProfileSelect) {

View File

@@ -661,8 +661,6 @@ function opt_loadSettings(panel) {
}
syncModelMirror(modelInput.get(0), modelSelect.get(0));
panel.find('#amily2_opt_max_tokens').val(settings.plotOpt_max_tokens);
panel.find('#amily2_opt_temperature').val(settings.plotOpt_temperature);
panel.find('#amily2_opt_top_p').val(settings.plotOpt_top_p);
panel.find('#amily2_opt_presence_penalty').val(settings.plotOpt_presence_penalty);
panel.find('#amily2_opt_frequency_penalty').val(settings.plotOpt_frequency_penalty);
@@ -695,8 +693,6 @@ function opt_loadSettings(panel) {
opt_updateApiUrlVisibility(panel, settings.plotOpt_apiMode);
opt_updateWorldbookSourceVisibility(panel, settings.plotOpt_worldbookSource || 'character');
opt_bindSlider(panel, '#amily2_opt_max_tokens', '#amily2_opt_max_tokens_value');
opt_bindSlider(panel, '#amily2_opt_temperature', '#amily2_opt_temperature_value');
opt_bindSlider(panel, '#amily2_opt_top_p', '#amily2_opt_top_p_value');
opt_bindSlider(panel, '#amily2_opt_presence_penalty', '#amily2_opt_presence_penalty_value');
opt_bindSlider(panel, '#amily2_opt_frequency_penalty', '#amily2_opt_frequency_penalty_value');

View File

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