diff --git a/ena-planner/ena-planner.js b/ena-planner/ena-planner.js index 9fd1ce6..e2f538a 100644 --- a/ena-planner/ena-planner.js +++ b/ena-planner/ena-planner.js @@ -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: '' }; diff --git a/ui/panel-ena-sections.js b/ui/panel-ena-sections.js index d440e21..be7bf65 100644 --- a/ui/panel-ena-sections.js +++ b/ui/panel-ena-sections.js @@ -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(); }); diff --git a/ui/panel.html b/ui/panel.html index 4bb6424..1633e7f 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -2920,110 +2920,23 @@