mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-13 13:55:50 +00:00
### 新功能 - **翰林院向量化质量升级**: - **边界感知切块**:替换四个来源(聊天记录/小说/世界书/手动)的纯字符硬切——优先在段落边界断开,其次句末标点(含中文引号闭合),极端长串才硬切;句子/对话不再被拦腰截断,embedding 质量同步受益。仅影响新录入,已有向量无需重建 - **注入时序重排**:检索结果注入提示词前按时序重排(聊天记录按楼层、小说按卷/章/节——中文数字章节号可解析),rerank 只决定"选哪些块",不再决定呈现顺序;修复"不打不相识的剧情之后紧跟关系亲密"这类因按相关度排序导致的认知时间错乱 - **断层提示**:聊天记录相邻块楼层跳跃时自动插入"与上文相隔约 N 楼,并非连续发生"提示行,消除中间剧情缺失造成的割裂感 - **时间标识**:新录入的聊天记录块在来源标识中带上消息发送时间(ST 向量存储不持久化元数据,时间必须写入块文本才能在检索后取回;旧格式块兼容解析) - **记忆块工作流(memory-blocks)**:剧情优化新增"自定义记忆块"体系——占位符驱动的并发工作流框架 - 在剧情优化面板「匹配替换 (sulv)」下方可增删自定义块:每个块定义一个占位符,执行剧情优化时主/拦截提示词中的占位符会被块的产出替换 - **静态块**:直接输出固定内容;**AI 调用块**:用所选 API 功能槽独立请求一次,把回复(或其中指定 `<标签>` 的内容)作为替换值 - 原有 sulv1-4 速率占位符迁入同一框架,行为与旧版逐字节一致 - 块定义为纯 JSON、随设置持久化,为后续导入导出与战斗系统接入预留扩展点 - 框架层新增**顺序拼接式 Chain**(`composeChain`):与占位符替换并列的第二种组合范式——同链的块并发执行后按 `order` 排序、以 `separator` 拼接并可选 `header/footer` 包裹,产出一个完整注入块;为记忆注入合成块与战斗系统"底部战报块"预留的承载结构,本版本暂无 UI 入口 - **API 连接配置**: - 角色世界书(cwb)与一键生卡(autoCharCard)纳入旧配置自动迁移:老用户首次加载会把旧 URL / Key / 模型自动迁移为连接配置并分配槽位(一键生卡仅在规划者与执行者配置一致或规划者为空时迁移,避免悄悄改变行为) - **profile 已分配时参数控件 informational 化**:主面板 / 并发剧情优化 / 角色世界书 / 术语表的温度、maxTokens 控件在槽位分配 profile 后自动禁用并显示"由连接配置控制"提示,消除"改了没效果"的用户陷阱 - **profile 状态卡新增"本设备无 Key"警示**:API Key 仅保存在最初填写它的设备/浏览器上(安全设计,不随云端设置同步),换设备后状态卡会直接亮出警示徽标,不必等到调用报错才发现 ### 修复 - **独立聊天记忆从摆设变真功能(原作遗留坑)**:此前向量数据"随卡不随聊天"——开启"独立聊天记忆"后录入仍存进角色库、查询却去查一个从未被写入过的聊天集合、计数恒为 0,整体静默失效。现已重构为聊天级分桶: - 独立模式下,聊天记录类向量按当前聊天隔离存储与检索,同一张卡开多个聊天(不同剧情线)的记忆互不污染 - 小说 / 世界书 / 手动录入属于"知识",仍随角色卡跨聊天共享;全局库不受影响 - 知识管理列表为聊天专属库显示"聊天级"徽标;聊天级库禁止移动到全局 - 统一模式(默认关闭独立记忆)的存量数据与行为完全不变 - 已知限制:聊天专属记忆跟随聊天文件,重命名聊天文件会使其失联(与 ST 官方向量扩展同等限制) - **超级排序截断顺序修正**:开启"超级排序"时,时序重排发生在 top_n 截断之前,导致保留的是"时序最早"而非"最相关"的块,检索结果长期偏向最旧的聊天记录。现改为先按相关度截取 top_n、再做时序排序 - **翰林院向量化失败("向量化块数量不识别"反馈)**: - 一次性清洗 profile-sync 历史污染:`retrieval/rerank.apiKey` 中的掩码占位符在持久层根治(此前仅读取侧防御);`apiEndpoint` / `rerank.apiMode` 的非法值(如被旧版写入的空字符串)归一化为 `custom` - 修复 `apiEndpoint` 为空/非法时请求被硬定向到 `api.openai.com`、无视用户自定义 URL 的问题(CSP 拦截 / 401 的元凶) - 修复**本地代理(LM Studio/Ollama)模式**自始就缺少 URL 分支、同样被错误定向到 openai.com 的问题 - API 模式下拉补全 `OpenAI 官方` / `Azure` 选项;默认 API 模式改为 `custom`(与默认 URL 配套),新用户不再因选项缺失导致首次保存写入空值 - profile-sync 给下拉框赋不存在选项值的污染源头修复(影响所有模块面板,不止翰林院) - **Rerank "API Key 未提供"报错升级**:当原因是"连接配置在本设备没有可用 Key"时,报错会直接说明 Key 的设备本地性并指引到 API 连接配置重新填写(向量化 Google 直连、获取模型列表同步处理) - **旧配置迁移**:一键生卡迁移时排除掩码占位符,避免把历史污染的假 Key 迁入新连接配置 - **超级记忆稳定性专项**(针对"工作不大稳定"反馈,4 处根因一次修复): - **切聊天竞态污染**:CHAT_CHANGED 时超级记忆立即全量同步,而表格系统延迟 100ms 才加载新聊天的表格,导致【旧聊天】的表格内容被写进【新角色】的记忆世界书;两边表名不同时旧表条目无 GC 兜底会**永久残留**("记忆串台"元凶)。现 CHAT_CHANGED 只确保世界书存在,新状态同步交由 `loadTables()` 完成后的自动推送,单次且时序正确 - **死代码双轨存储拆除**:`saveStateToMetadata` / `tryRestoreStateFromMetadata` 把表格状态写到 `msg.metadata`——该字段非 ST 持久化位(同 v2.2.5 二次填表修过的坑),写入即蒸发、恢复永远为空,且每次同步还白调一次 `saveChat()`。整条链路删除,表格状态唯一信源为表格系统的 `msg.extra.amily2_tables_data` - **`awaitSync()` 穿透**:同步队列正忙时 `pushUpdate` 会用一个立即 resolve 的空 Promise 覆盖 `_syncPromise`,Pipeline Stage 4 等待形同虚设、后续阶段在同步未完成时被放行。现忙时不覆盖,正在运行的 drain 循环自然吃掉新入队项 - **开关打开不生效**:启动时若总开关为关,初始化早退且不注册监听器;此后在 UI 勾选开关只写设置,超级记忆直到刷新页面前都是死的。现勾选即触发初始化(幂等) - 附带:`forceSyncAll` 的表格角色推断改为复用 `events-schema.inferTableRole`,消除两处重复逻辑漂移风险;每次切聊天的双倍全量同步(restore 路径一次 + 显式一次)随死代码移除归一 ### 重构 - 表格核心 `manager.js` 瘦身(约 1050 → 600 行):19 个 UI 突变操作拆分至 `actions/ui-mutations.js`,SuperMemory 事件分发拆分至 `events-dispatch.js`;全部经 re-export 保持兼容,外部调用路径零改动 - 角色世界书最后 2 处散乱的厂商 URL 判断迁移至 `detectVendor` 统一入口,业务路径上不再有硬编码的 URL substring 判断
501 lines
20 KiB
JavaScript
501 lines
20 KiB
JavaScript
/**
|
||
* ui/profile-sync.js - Synchronize central API profiles into legacy sub-panels.
|
||
*
|
||
* 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, 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';
|
||
|
||
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 MASKED_KEY = '••••••••';
|
||
|
||
const _fieldSnapshots = {};
|
||
|
||
const SLOT_CONFIGS = {
|
||
main: {
|
||
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'],
|
||
fields: { provider: '#amily2_api_provider', apiUrl: '#amily2_api_url', model: '#amily2_manual_model_input' },
|
||
keyField: '#amily2_api_key',
|
||
testFn: testApiConnection,
|
||
},
|
||
plotOpt: {
|
||
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',
|
||
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',
|
||
},
|
||
keyField: '#amily2_plotOpt_concurrentApiKey',
|
||
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',
|
||
],
|
||
hideInContainer: ['.nccs-button-row'],
|
||
fields: { provider: '#nccs-api-mode', apiUrl: '#nccs-api-url', model: '#nccs-api-model' },
|
||
keyField: '#nccs-api-key',
|
||
testFn: testNccsApiConnection,
|
||
},
|
||
ngms: {
|
||
container: '#amily2_ngms_content',
|
||
hideParentBlock: ['#amily2_ngms_api_mode', '#amily2_ngms_fakestream_enabled'],
|
||
hideDirectly: ['#amily2_ngms_compatible_config', '#amily2_ngms_preset_config'],
|
||
hideInContainer: ['.ngms-button-row'],
|
||
fields: { provider: '#amily2_ngms_api_mode', apiUrl: '#amily2_ngms_api_url', model: '#amily2_ngms_model' },
|
||
keyField: '#amily2_ngms_api_key',
|
||
testFn: testNgmsApiConnection,
|
||
},
|
||
sybd: {
|
||
container: '#amily2_sybd_content',
|
||
hideParentBlock: ['#amily2_sybd_api_mode'],
|
||
hideDirectly: ['#amily2_sybd_compatible_config', '#amily2_sybd_preset_config'],
|
||
hideInContainer: ['.sybd-button-row'],
|
||
fields: { provider: '#amily2_sybd_api_mode', apiUrl: '#amily2_sybd_api_url', model: '#amily2_sybd_model' },
|
||
keyField: '#amily2_sybd_api_key',
|
||
testFn: testSybdApiConnection,
|
||
},
|
||
cwb: {
|
||
container: '#cwb-api-settings-tab',
|
||
hideDirectly: [
|
||
'label[for="cwb-api-mode"]',
|
||
'#cwb-api-mode',
|
||
'label[for="cwb-api-url"]',
|
||
'#cwb-api-url',
|
||
'label[for="cwb-api-key"]',
|
||
'#cwb-api-key',
|
||
'label[for="cwb-api-model"]',
|
||
'#cwb-api-model',
|
||
'label[for="cwb-tavern-profile"]',
|
||
'#cwb-tavern-profile',
|
||
],
|
||
hideInContainer: ['.jqyh-button-row'],
|
||
fields: { provider: '#cwb-api-mode', apiUrl: '#cwb-api-url', model: '#cwb-api-model' },
|
||
keyField: '#cwb-api-key',
|
||
testFn: testCwbConnection,
|
||
},
|
||
autoCharCard: {
|
||
container: '#acc-api-settings-content',
|
||
hideParentBlock: ['#acc-executor-url', '#acc-executor-key', '#acc-executor-model'],
|
||
hideDirectly: ['#acc-executor-refresh-models', '#acc-executor-test', '#acc-save-api'],
|
||
fields: { apiUrl: '#acc-executor-url', model: '#acc-executor-model' },
|
||
keyField: '#acc-executor-key',
|
||
testFn: async () => testAutoCharCardConnection('executor'),
|
||
},
|
||
ragEmbed: {
|
||
container: '#hly-retrieval-tab .hly-settings-group',
|
||
hideParentBlock: ['#hly-api-endpoint', '#hly-custom-api-url', '#hly-api-key', '#hly-embedding-model'],
|
||
hideDirectly: [
|
||
'button[onclick="testHLYApi()"]',
|
||
'button[onclick="fetchHLYEmbeddingModels()"]',
|
||
],
|
||
fields: { provider: '#hly-api-endpoint', apiUrl: '#hly-custom-api-url', model: '#hly-embedding-model' },
|
||
keyField: '#hly-api-key',
|
||
testFn: async () => {
|
||
await testRagEmbeddingConnection();
|
||
return true;
|
||
},
|
||
fetchModelsFn: fetchRagEmbeddingModels,
|
||
},
|
||
ragRerank: {
|
||
container: '#hly-rerank-tab .hly-settings-group',
|
||
hideParentBlock: ['#hly-rerank-api-mode', '#hly-rerank-url', '#hly-rerank-api-key', '#hly-rerank-model'],
|
||
fields: { apiUrl: '#hly-rerank-url', model: '#hly-rerank-model' },
|
||
keyField: '#hly-rerank-api-key',
|
||
testFn: async () => {
|
||
await executeRagRerank('test', ['test'], null);
|
||
return true;
|
||
},
|
||
fetchModelsFn: fetchRagRerankModels,
|
||
},
|
||
};
|
||
|
||
export async function syncSlot(slot) {
|
||
const config = SLOT_CONFIGS[slot];
|
||
if (!config) return;
|
||
|
||
const container = _resolveContainer(config.container);
|
||
if (!container) return;
|
||
|
||
_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);
|
||
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) continue;
|
||
const value = profile[key] ?? '';
|
||
// select 赋不存在的 option 值(如 provider 'custom_oai' 写进只有
|
||
// custom/google_direct 的 select)会让 value 静默变 '',后续任何
|
||
// 全量保存会把 '' 污染进 settings——跳过这类赋值,保留原选项
|
||
if (el.tagName === 'SELECT' && ![...el.options].some(o => o.value === value)) {
|
||
continue;
|
||
}
|
||
el.value = value;
|
||
}
|
||
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;
|
||
el.setAttribute(HIDDEN_ATTR, slot);
|
||
el.setAttribute('data-prev-display', el.style.display || '');
|
||
el.style.display = 'none';
|
||
}
|
||
|
||
function _restoreHidden(slot) {
|
||
document.querySelectorAll(`[${HIDDEN_ATTR}="${slot}"]`).forEach(el => {
|
||
el.style.display = el.getAttribute('data-prev-display') || '';
|
||
el.removeAttribute(HIDDEN_ATTR);
|
||
el.removeAttribute('data-prev-display');
|
||
});
|
||
}
|
||
|
||
function _hideApiFields(config, container, slot) {
|
||
(config.hideParentBlock || []).forEach(sel => {
|
||
const el = document.querySelector(sel);
|
||
if (!el) return;
|
||
const block = el.closest(BLOCK_SEL);
|
||
if (block && block !== container) _hideEl(block, slot);
|
||
});
|
||
|
||
(config.hideDirectly || []).forEach(sel => {
|
||
const el = document.querySelector(sel);
|
||
if (el) _hideEl(el, slot);
|
||
});
|
||
|
||
(config.hideWithLabel || []).forEach(sel => {
|
||
const el = document.querySelector(sel);
|
||
if (!el) return;
|
||
|
||
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);
|
||
});
|
||
|
||
(config.hideInContainer || []).forEach(sel => {
|
||
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('');
|
||
|
||
// Key 是设备本地存储(ApiKeyStore 不跨设备同步),profile 随云端设置同步而来
|
||
// 时本设备可能没有 Key——明确提示,否则用户只会在调用时收到"Key 未提供"报错
|
||
const needsKey = profile && !['sillytavern_preset', 'sillytavern_backend'].includes(profile.provider);
|
||
const keyWarnHtml = (needsKey && !profile.apiKey) ? `
|
||
<span style="color:var(--warning-color,#e6a23c); font-size:0.85em;"
|
||
title="API Key 仅保存在最初填写它的设备/浏览器上,不随云端设置同步。请点击「管理」编辑该配置并重新填写 Key。">
|
||
<i class="fas fa-key"></i> 本设备无 Key
|
||
</span>
|
||
` : '';
|
||
|
||
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>
|
||
${keyWarnHtml}
|
||
` : `
|
||
<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',
|
||
'background:var(--black10a)',
|
||
'border:1px solid var(--SmartThemeBorderColor)',
|
||
'border-radius:6px',
|
||
'font-size:0.88em',
|
||
].join(';');
|
||
|
||
card.innerHTML = `
|
||
<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" ${profile ? '' : 'disabled'}>
|
||
<i class="fas fa-plug"></i> 测试连接
|
||
</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));
|
||
|
||
const legend = container.querySelector(':scope > legend');
|
||
if (legend) {
|
||
legend.insertAdjacentElement('afterend', card);
|
||
} else {
|
||
container.prepend(card);
|
||
}
|
||
}
|
||
|
||
async function _testSlot(slot, card) {
|
||
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 profile = await apiProfileManager.getAssignedProfile(slot);
|
||
if (!profile) {
|
||
$result.text('槽位未分配').css('color', 'var(--warning-color)');
|
||
return;
|
||
}
|
||
|
||
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(result?.error || '测试失败,请查看弹窗/控制台').css('color', 'var(--warning-color)');
|
||
}
|
||
} catch (e) {
|
||
$result.text(`错误:${e.message}`).css('color', 'var(--warning-color)');
|
||
} finally {
|
||
$btn.prop('disabled', false).html('<i class="fas fa-plug"></i> 测试连接');
|
||
}
|
||
}
|
||
|
||
async function _fetchSlotModels(slot, card) {
|
||
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', '');
|
||
|
||
try {
|
||
const profile = await apiProfileManager.getAssignedProfile(slot);
|
||
if (!profile) {
|
||
$result.text('槽位未分配').css('color', 'var(--warning-color)');
|
||
return;
|
||
}
|
||
|
||
if (profile.provider === 'sillytavern_preset' || profile.provider === 'sillytavern_backend') {
|
||
$result.text('ST 预设/后端管理,无需获取').css('color', 'var(--SmartThemeQuoteColor)');
|
||
return;
|
||
}
|
||
|
||
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);
|
||
$result.html(
|
||
`<span style="color:var(--green);">${models.length} 个模型</span>` +
|
||
(current ? ` · 当前: <b>${_esc(current)}</b> ${inList ? '✓' : '<span style="color:var(--warning-color);">(不在列表中)</span>'}` : '')
|
||
);
|
||
toastr.success(`已获取 ${models.length} 个模型。`, `槽位:${slot}`);
|
||
} catch (e) {
|
||
$result.text(`错误:${e.message}`).css('color', 'var(--warning-color)');
|
||
} finally {
|
||
$btn.prop('disabled', false).html('<i class="fas fa-list"></i> 获取模型');
|
||
}
|
||
}
|
||
|
||
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, '"');
|
||
}
|