Simplify ENA planner API preset selection

This commit is contained in:
Youzini-afk
2026-04-23 16:43:31 +08:00
parent c0f089b82a
commit 28ccaab0ac
3 changed files with 257 additions and 186 deletions

View File

@@ -11,6 +11,10 @@ import {
setActiveTaskProfileId,
upsertTaskProfile,
} from '../prompting/prompt-profiles.js';
import {
resolveDedicatedLlmProviderConfig,
resolveLlmConfigSelection,
} from '../llm/llm-preset-utils.js';
import { debugLog } from '../runtime/debug-logging.js';
import jsyaml from '../vendor/js-yaml.mjs';
@@ -74,6 +78,7 @@ function getDefaultSettings(options = {}) {
// Planner API
api: {
llmPreset: '',
channel: 'openai',
baseUrl: '',
prefixMode: 'auto',
@@ -563,21 +568,87 @@ function normalizeUrlBase(u) {
return u.replace(/\/+$/g, '');
}
function hasPlannerLegacyDedicatedApiConfig(api = {}) {
return Boolean(
String(api?.baseUrl || '').trim() &&
String(api?.model || '').trim(),
);
}
function inferPlannerChannelFromUrl(url) {
const resolved = resolveDedicatedLlmProviderConfig(String(url || '').trim());
if (resolved.providerId === 'google-ai-studio') return 'gemini';
if (resolved.providerId === 'anthropic-claude') return 'claude';
return 'openai';
}
function buildResolvedPlannerApiConfigFromLlmSelection(selection = {}) {
const snapshot = selection?.config && typeof selection.config === 'object'
? selection.config
: {};
const inputUrl = String(snapshot?.llmApiUrl || '').trim();
const resolved = resolveDedicatedLlmProviderConfig(inputUrl);
const baseUrl = String(resolved.apiUrl || inputUrl).trim();
return {
mode: selection?.requestedPresetName ? 'preset' : 'global',
source: String(selection?.source || ''),
requestedPresetName: String(selection?.requestedPresetName || ''),
presetName: String(selection?.presetName || ''),
fallbackReason: String(selection?.fallbackReason || ''),
channel: inferPlannerChannelFromUrl(baseUrl),
prefixMode: 'auto',
customPrefix: '',
baseUrl,
apiKey: String(snapshot?.llmApiKey || '').trim(),
model: String(snapshot?.llmModel || '').trim(),
};
}
function buildLegacyPlannerApiConfig(api = {}) {
return {
mode: 'legacy',
source: 'legacy-ena-config',
requestedPresetName: '',
presetName: '',
fallbackReason: '',
channel: String(api?.channel || 'openai').trim() || 'openai',
prefixMode: String(api?.prefixMode || 'auto').trim() || 'auto',
customPrefix: String(api?.customPrefix || '').trim(),
baseUrl: String(api?.baseUrl || '').trim(),
apiKey: String(api?.apiKey || '').trim(),
model: String(api?.model || '').trim(),
};
}
function resolvePlannerApiConfig() {
const s = ensureSettings();
const selectedPresetName = String(s?.api?.llmPreset || '').trim();
if (selectedPresetName) {
return buildResolvedPlannerApiConfigFromLlmSelection(
resolveLlmConfigSelection(getBmeSettings(), selectedPresetName),
);
}
if (hasPlannerLegacyDedicatedApiConfig(s?.api)) {
return buildLegacyPlannerApiConfig(s.api);
}
return buildResolvedPlannerApiConfigFromLlmSelection(
resolveLlmConfigSelection(getBmeSettings(), ''),
);
}
function getDefaultPrefixByChannel(channel) {
if (channel === 'gemini') return '/v1beta';
return '/v1';
}
function buildApiPrefix() {
const s = ensureSettings();
if (s.api.prefixMode === 'custom' && s.api.customPrefix?.trim()) return s.api.customPrefix.trim();
return getDefaultPrefixByChannel(s.api.channel);
function buildApiPrefix(apiConfig = resolvePlannerApiConfig()) {
if (apiConfig?.prefixMode === 'custom' && apiConfig?.customPrefix?.trim()) return apiConfig.customPrefix.trim();
return getDefaultPrefixByChannel(apiConfig?.channel);
}
function buildUrl(path) {
const s = ensureSettings();
const base = normalizeUrlBase(s.api.baseUrl);
const prefix = buildApiPrefix();
function buildUrl(path, apiConfig = resolvePlannerApiConfig()) {
const base = normalizeUrlBase(apiConfig?.baseUrl);
const prefix = buildApiPrefix(apiConfig);
const p = prefix.startsWith('/') ? prefix : `/${prefix}`;
const finalPrefix = p.replace(/\/+$/g, '');
const finalPath = path.startsWith('/') ? path : `/${path}`;
@@ -1273,16 +1344,15 @@ function filterPlannerPreview(rawPartial) {
* --------------------------
*/
async function callPlanner(messages, options = {}) {
const s = ensureSettings();
if (!s.api.baseUrl) throw new Error('未配置 API URL');
if (!s.api.apiKey) throw new Error('未配置 API KEY');
if (!s.api.model) throw new Error('未选择模型');
const apiConfig = resolvePlannerApiConfig();
if (!apiConfig.baseUrl) throw new Error('未配置可用的 API URL');
if (!apiConfig.model) throw new Error('未配置可用的模型');
const generation = resolvePlannerGenerationSettings();
const url = buildUrl('/chat/completions');
const url = buildUrl('/chat/completions', apiConfig);
const body = {
model: s.api.model,
model: apiConfig.model,
messages,
stream: generation.stream === true
};
@@ -1298,13 +1368,16 @@ async function callPlanner(messages, options = {}) {
const plannerRequestTimeoutMs = getPlannerRequestTimeoutMs();
const timeoutId = setTimeout(() => controller.abort(), plannerRequestTimeoutMs);
try {
const headers = {
...getRequestHeaders(),
'Content-Type': 'application/json',
};
if (apiConfig.apiKey) {
headers.Authorization = `Bearer ${apiConfig.apiKey}`;
}
const res = await fetch(url, {
method: 'POST',
headers: {
...getRequestHeaders(),
Authorization: `Bearer ${s.api.apiKey}`,
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify(body),
signal: controller.signal
});
@@ -1364,16 +1437,18 @@ async function callPlanner(messages, options = {}) {
}
async function fetchModelsForUi() {
const s = ensureSettings();
if (!s.api.baseUrl) throw new Error('请先填写 API URL');
if (!s.api.apiKey) throw new Error('请先填写 API KEY');
const url = buildUrl('/models');
const apiConfig = resolvePlannerApiConfig();
if (!apiConfig.baseUrl) throw new Error('当前没有可用的 API URL');
const url = buildUrl('/models', apiConfig);
const headers = {
...getRequestHeaders(),
};
if (apiConfig.apiKey) {
headers.Authorization = `Bearer ${apiConfig.apiKey}`;
}
const res = await fetch(url, {
method: 'GET',
headers: {
...getRequestHeaders(),
Authorization: `Bearer ${s.api.apiKey}`
}
headers
});
if (!res.ok) {
const text = await res.text().catch(() => '');
@@ -1744,10 +1819,10 @@ async function buildPlannerMessages(rawUserInput) {
* --------------------------
*/
async function runPlanningOnce(rawUserInput, silent = false, options = {}) {
const s = ensureSettings();
const apiConfig = resolvePlannerApiConfig();
const log = {
time: nowISO(), ok: false, model: s.api.model,
time: nowISO(), ok: false, model: apiConfig.model,
requestMessages: [], rawReply: '', filteredReply: '', error: ''
};

View File

@@ -17,6 +17,7 @@ import {
const SECTION_SELECTOR = '[data-config-section="planner"]';
const AUTOSAVE_DELAY_MS = 600;
const LEGACY_PLANNER_LLM_OPTION = '__planner_legacy_dedicated__';
let bound = false;
let unsubscribePlanner = null;
@@ -149,15 +150,13 @@ function buildPlannerLlmSnapshot(source = {}) {
};
}
function getCurrentPlannerLlmSnapshot() {
const rawUrl = String(
$('bme-planner-api-base')?.value ?? cfgCache?.api?.baseUrl ?? '',
).trim();
function buildPlannerSnapshotFromConfigApi(api = {}) {
const rawUrl = String(api?.baseUrl || '').trim();
const resolved = resolveDedicatedLlmProviderConfig(rawUrl);
return buildPlannerLlmSnapshot({
llmApiUrl: resolved.apiUrl || rawUrl,
llmApiKey: $('bme-planner-api-key')?.value ?? cfgCache?.api?.apiKey ?? '',
llmModel: $('bme-planner-model')?.value ?? cfgCache?.api?.model ?? '',
llmApiKey: api?.apiKey || '',
llmModel: api?.model || '',
});
}
@@ -171,22 +170,80 @@ function normalizePlannerPresetSnapshot(preset = {}) {
});
}
function resolveMatchingPlannerLlmPresetName(snapshot = getCurrentPlannerLlmSnapshot()) {
const { presets, activePreset } = getSharedLlmPresetState();
const exactMatches = Object.keys(presets || {}).filter((name) =>
isSameLlmConfigSnapshot(snapshot, normalizePlannerPresetSnapshot(presets[name])),
function hasPlannerLegacyDedicatedApiConfig(api = {}) {
return Boolean(
String(api?.baseUrl || '').trim() &&
String(api?.model || '').trim(),
);
if (exactMatches.length === 1) return exactMatches[0];
if (exactMatches.length > 1 && activePreset && exactMatches.includes(activePreset)) {
return activePreset;
}
return '';
}
function populatePlannerLlmPresetSelect(selectedPreset = resolveMatchingPlannerLlmPresetName()) {
function resolvePlannerLlmSelectState(config = cfgCache || {}) {
const api = config?.api && typeof config.api === 'object' ? config.api : {};
const selectedPresetName = String(api?.llmPreset || '').trim();
const { presets, activePreset } = getSharedLlmPresetState();
if (selectedPresetName) {
if (Object.prototype.hasOwnProperty.call(presets || {}, selectedPresetName)) {
return {
value: selectedPresetName,
mode: 'preset',
};
}
return {
value: '',
mode: 'global',
missingPresetName: selectedPresetName,
};
}
if (!hasPlannerLegacyDedicatedApiConfig(api)) {
return {
value: '',
mode: 'global',
};
}
const legacySnapshot = buildPlannerSnapshotFromConfigApi(api);
const globalSnapshot = buildPlannerLlmSnapshot(getSharedSettingsSnapshot());
if (isSameLlmConfigSnapshot(legacySnapshot, globalSnapshot)) {
return {
value: '',
mode: 'global',
matchedLegacySource: 'global',
};
}
const exactMatches = Object.keys(presets || {}).filter((name) =>
isSameLlmConfigSnapshot(legacySnapshot, normalizePlannerPresetSnapshot(presets[name])),
);
if (exactMatches.length === 1) {
return {
value: exactMatches[0],
mode: 'preset',
matchedLegacySource: 'preset',
};
}
if (exactMatches.length > 1 && activePreset && exactMatches.includes(activePreset)) {
return {
value: activePreset,
mode: 'preset',
matchedLegacySource: 'preset',
};
}
return {
value: LEGACY_PLANNER_LLM_OPTION,
mode: 'legacy',
};
}
function populatePlannerLlmPresetSelect(selectedPreset = resolvePlannerLlmSelectState().value) {
const select = $('bme-planner-llm-preset-select');
if (!select) return;
if (select.options.length > 0) {
select.options[0].textContent = '-- 跟随全局(当前 BME API --';
}
while (select.options.length > 1) {
select.remove(1);
}
@@ -201,45 +258,18 @@ function populatePlannerLlmPresetSelect(selectedPreset = resolveMatchingPlannerL
select.appendChild(option);
});
if (selectedPreset === LEGACY_PLANNER_LLM_OPTION) {
const legacyOption = document.createElement('option');
legacyOption.value = LEGACY_PLANNER_LLM_OPTION;
legacyOption.textContent = '旧 ENA 独立连接(兼容)';
select.appendChild(legacyOption);
}
select.value = selectedPreset || '';
}
function syncPlannerLlmPresetSelect() {
populatePlannerLlmPresetSelect(resolveMatchingPlannerLlmPresetName());
}
function inferPlannerApiConfigFromPreset(preset = {}) {
const rawUrl = String(preset?.llmApiUrl || '').trim();
const resolved = resolveDedicatedLlmProviderConfig(rawUrl);
let channel = 'openai';
if (resolved.providerId === 'google-ai-studio') channel = 'gemini';
else if (resolved.providerId === 'anthropic-claude') channel = 'claude';
return {
channel,
prefixMode: 'auto',
customPrefix: '',
baseUrl: resolved.apiUrl || rawUrl,
apiKey: String(preset?.llmApiKey || '').trim(),
model: String(preset?.llmModel || '').trim(),
};
}
function applyPlannerLlmPresetToFields(name, preset = {}) {
const inferred = inferPlannerApiConfigFromPreset(preset);
const setVal = (id, value) => {
const el = $(id);
if (el) el.value = value;
};
setVal('bme-planner-api-channel', inferred.channel || 'openai');
setVal('bme-planner-prefix-mode', inferred.prefixMode || 'auto');
setVal('bme-planner-prefix-custom', inferred.customPrefix || '');
setVal('bme-planner-api-base', inferred.baseUrl || '');
setVal('bme-planner-api-key', inferred.apiKey || '');
setVal('bme-planner-model', inferred.model || '');
updatePrefixModeUI();
populatePlannerLlmPresetSelect(name);
populatePlannerLlmPresetSelect(resolvePlannerLlmSelectState().value);
}
/* ── Prompt block editor ────────────────────────────────────────────────── */
@@ -508,6 +538,14 @@ function applyConfigToFields(cfg) {
);
updatePrefixModeUI();
syncPlannerLlmPresetSelect();
const llmSelectState = resolvePlannerLlmSelectState(cfgCache);
if (llmSelectState.mode === 'legacy') {
setLocalStatus('bme-planner-api-status', '当前仍在使用旧版 ENA 独立连接;切换为全局或预设后将不再保留这套隐藏配置。', '');
} else if (llmSelectState.missingPresetName) {
setLocalStatus('bme-planner-api-status', `已回退为跟随全局:缺少预设 ${llmSelectState.missingPresetName}`, 'error');
} else {
setLocalStatus('bme-planner-api-status', '', '');
}
const keepSelected = cfgCache.activePromptTemplate || $('bme-planner-tpl-select')?.value || '';
renderTemplateSelect(keepSelected);
@@ -516,18 +554,32 @@ function applyConfigToFields(cfg) {
function collectPatch() {
const getVal = (id) => $(id)?.value ?? '';
const selectedPlannerPreset = String(getVal('bme-planner-llm-preset-select') || '').trim();
const existingApi = cfgCache?.api && typeof cfgCache.api === 'object' ? cfgCache.api : {};
const preserveLegacyApi = selectedPlannerPreset === LEGACY_PLANNER_LLM_OPTION;
return {
enabled: toBool(getVal('bme-planner-enabled'), false),
skipIfPlotPresent: toBool(getVal('bme-planner-skip-plot'), true),
api: {
channel: getVal('bme-planner-api-channel'),
prefixMode: getVal('bme-planner-prefix-mode'),
baseUrl: getVal('bme-planner-api-base').trim(),
customPrefix: getVal('bme-planner-prefix-custom').trim(),
apiKey: getVal('bme-planner-api-key'),
model: getVal('bme-planner-model').trim(),
},
api: preserveLegacyApi
? {
llmPreset: '',
channel: String(existingApi.channel || 'openai'),
prefixMode: String(existingApi.prefixMode || 'auto'),
customPrefix: String(existingApi.customPrefix || ''),
baseUrl: String(existingApi.baseUrl || '').trim(),
apiKey: String(existingApi.apiKey || ''),
model: String(existingApi.model || '').trim(),
}
: {
llmPreset: selectedPlannerPreset,
channel: 'openai',
prefixMode: 'auto',
customPrefix: '',
baseUrl: '',
apiKey: '',
model: '',
},
includeGlobalWorldbooks: toBool(getVal('bme-planner-include-global-wb'), false),
excludeWorldbookPosition4: toBool(getVal('bme-planner-wb-pos4'), true),
worldbookExcludeNames: csvToArr(getVal('bme-planner-wb-exclude-names')),
@@ -680,19 +732,50 @@ function bindOnce(section) {
$('bme-planner-llm-preset-select')?.addEventListener('change', () => {
const select = $('bme-planner-llm-preset-select');
const selectedName = String(select?.value || '');
cfgCache = cfgCache || {};
cfgCache.api = cfgCache.api && typeof cfgCache.api === 'object' ? cfgCache.api : {};
if (!selectedName) {
setLocalStatus('bme-planner-api-status', '', '');
cfgCache.api.llmPreset = '';
cfgCache.api.channel = 'openai';
cfgCache.api.prefixMode = 'auto';
cfgCache.api.customPrefix = '';
cfgCache.api.baseUrl = '';
cfgCache.api.apiKey = '';
cfgCache.api.model = '';
syncPlannerLlmPresetSelect();
setLocalStatus('bme-planner-api-status', '已改为跟随全局 BME API', 'success');
scheduleSave();
return;
}
if (selectedName === LEGACY_PLANNER_LLM_OPTION) {
syncPlannerLlmPresetSelect();
setLocalStatus('bme-planner-api-status', '继续保留旧版 ENA 独立连接', '');
scheduleSave();
return;
}
const { presets } = getSharedLlmPresetState();
const preset = presets?.[selectedName];
if (!preset) {
populatePlannerLlmPresetSelect('');
setLocalStatus('bme-planner-api-status', '选中的 BME 模板不存在,已切回手动模式', 'error');
if (!presets?.[selectedName]) {
cfgCache.api.llmPreset = '';
cfgCache.api.channel = 'openai';
cfgCache.api.prefixMode = 'auto';
cfgCache.api.customPrefix = '';
cfgCache.api.baseUrl = '';
cfgCache.api.apiKey = '';
cfgCache.api.model = '';
syncPlannerLlmPresetSelect();
setLocalStatus('bme-planner-api-status', '选中的 API 预设不存在,已回退为跟随全局', 'error');
scheduleSave();
return;
}
applyPlannerLlmPresetToFields(selectedName, preset);
setLocalStatus('bme-planner-api-status', `已套用 BME 模板:${selectedName}`, 'success');
cfgCache.api.llmPreset = selectedName;
cfgCache.api.channel = 'openai';
cfgCache.api.prefixMode = 'auto';
cfgCache.api.customPrefix = '';
cfgCache.api.baseUrl = '';
cfgCache.api.apiKey = '';
cfgCache.api.model = '';
syncPlannerLlmPresetSelect();
setLocalStatus('bme-planner-api-status', `已切换为 API 预设:${selectedName}`, 'success');
scheduleSave();
});

View File

@@ -2920,110 +2920,23 @@
<div>
<div class="bme-config-card-title">规划 LLM · 连接</div>
<div class="bme-config-card-subtitle">
独立的规划 LLM 通道,与 BME 记忆 LLM 相互隔离。支持 OpenAI / Gemini / Claude 兼容协议
默认跟随当前全局 BME API也可以切换到任意已保存的 API 预设
</div>
</div>
</div>
<div class="bme-config-row bme-llm-preset-row">
<label for="bme-planner-llm-preset-select">复用 BME LLM 配置模板</label>
<label for="bme-planner-llm-preset-select">API 预设</label>
<div class="bme-llm-preset-controls">
<select
id="bme-planner-llm-preset-select"
class="bme-config-input"
>
<option value="">-- 手动模式 / 当前 ENA 配置 --</option>
<option value="">-- 跟随全局(当前 BME API --</option>
</select>
</div>
</div>
<div class="bme-config-help">
直接复用主面板的 LLM 预设,将 URL、Key、Model 拷贝到 ENA 规划器,并自动推断渠道与默认前缀;套用后仍可单独微调
</div>
<div class="bme-config-row">
<label for="bme-planner-api-channel">渠道类型</label>
<select id="bme-planner-api-channel" class="bme-config-input">
<option value="openai">OpenAI 兼容</option>
<option value="gemini">Gemini 兼容</option>
<option value="claude">Claude 兼容</option>
</select>
</div>
<div class="bme-config-row">
<label for="bme-planner-prefix-mode">路径前缀</label>
<select id="bme-planner-prefix-mode" class="bme-config-input">
<option value="auto">自动(如 /v1</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="bme-config-row" id="bme-planner-prefix-custom-row" hidden>
<label for="bme-planner-prefix-custom">自定义前缀</label>
<input
id="bme-planner-prefix-custom"
class="bme-config-input"
type="text"
placeholder="/v1"
/>
</div>
<div class="bme-config-row">
<label for="bme-planner-api-base">API 地址</label>
<input
id="bme-planner-api-base"
class="bme-config-input"
type="text"
placeholder="https://api.openai.com"
/>
</div>
<div class="bme-config-row">
<label for="bme-planner-api-key">API Key</label>
<div class="bme-planner-inline-row">
<input
id="bme-planner-api-key"
class="bme-config-input"
type="password"
placeholder="sk-..."
/>
<button
class="bme-config-secondary-btn"
id="bme-planner-toggle-key"
type="button"
>
<span>显示</span>
</button>
</div>
</div>
<div class="bme-config-row">
<label for="bme-planner-model">模型</label>
<input
id="bme-planner-model"
class="bme-config-input"
type="text"
placeholder="gpt-4o / claude-3-5-sonnet / gemini-2.0-flash"
/>
</div>
<div class="bme-model-fetch-block">
<button
class="bme-config-secondary-btn"
id="bme-planner-fetch-models"
type="button"
>
<i class="fa-solid fa-rotate"></i>
<span>拉取模型</span>
</button>
<select
id="bme-planner-model-select"
class="bme-config-input bme-model-select"
style="display: none"
>
<option value="">-- 从列表选择 --</option>
</select>
</div>
<div class="bme-config-actions">
<button
class="bme-config-test-btn"
id="bme-planner-test-conn"
type="button"
>
<i class="fa-solid fa-plug"></i>
<span>测试连接</span>
</button>
留空表示直接跟随当前全局 API选择某个预设后规划器会固定使用那套 URL / Key / Model
</div>
<div class="bme-planner-status-text" id="bme-planner-api-status"></div>
</div>