diff --git a/llm-preset-utils.js b/llm-preset-utils.js index 1e02859..22ab264 100644 --- a/llm-preset-utils.js +++ b/llm-preset-utils.js @@ -2,14 +2,26 @@ function normalizeLlmConfigValue(value) { return String(value || "").trim(); } +export function createLlmConfigSnapshot(source = {}) { + return { + llmApiUrl: normalizeLlmConfigValue(source?.llmApiUrl), + llmApiKey: normalizeLlmConfigValue(source?.llmApiKey), + llmModel: normalizeLlmConfigValue(source?.llmModel), + }; +} + +export function isUsableLlmConfigSnapshot(snapshot = {}) { + const normalized = createLlmConfigSnapshot(snapshot); + return Boolean(normalized.llmApiUrl && normalized.llmModel); +} + export function isSameLlmConfigSnapshot(left = {}, right = {}) { + const normalizedLeft = createLlmConfigSnapshot(left); + const normalizedRight = createLlmConfigSnapshot(right); return ( - normalizeLlmConfigValue(left?.llmApiUrl) === - normalizeLlmConfigValue(right?.llmApiUrl) && - normalizeLlmConfigValue(left?.llmApiKey) === - normalizeLlmConfigValue(right?.llmApiKey) && - normalizeLlmConfigValue(left?.llmModel) === - normalizeLlmConfigValue(right?.llmModel) + normalizedLeft.llmApiUrl === normalizedRight.llmApiUrl && + normalizedLeft.llmApiKey === normalizedRight.llmApiKey && + normalizedLeft.llmModel === normalizedRight.llmModel ); } @@ -84,11 +96,7 @@ export function sanitizeLlmPresetSettings(settings = {}) { export function resolveActiveLlmPresetName(settings = {}) { const normalized = settings && typeof settings === "object" ? settings : {}; const { presets, activePreset } = sanitizeLlmPresetSettings(normalized); - const snapshot = { - llmApiUrl: normalizeLlmConfigValue(normalized.llmApiUrl), - llmApiKey: normalizeLlmConfigValue(normalized.llmApiKey), - llmModel: normalizeLlmConfigValue(normalized.llmModel), - }; + const snapshot = createLlmConfigSnapshot(normalized); if ( activePreset && @@ -108,3 +116,50 @@ export function resolveActiveLlmPresetName(settings = {}) { return ""; } + +export function resolveLlmConfigSelection(settings = {}, selectedPresetName = "") { + const normalized = settings && typeof settings === "object" ? settings : {}; + const { presets } = sanitizeLlmPresetSettings(normalized); + const globalConfig = createLlmConfigSnapshot(normalized); + const requestedPresetName = normalizeLlmConfigValue(selectedPresetName); + + if (!requestedPresetName) { + return { + source: "global", + config: globalConfig, + requestedPresetName: "", + presetName: "", + fallbackReason: "", + }; + } + + const presetConfig = presets[requestedPresetName]; + if (!presetConfig) { + return { + source: "global-fallback-missing-task-preset", + config: globalConfig, + requestedPresetName, + presetName: "", + fallbackReason: "selected_task_preset_missing", + }; + } + + const normalizedPresetConfig = createLlmConfigSnapshot(presetConfig); + if (!isUsableLlmConfigSnapshot(normalizedPresetConfig)) { + return { + source: "global-fallback-invalid-task-preset", + config: globalConfig, + requestedPresetName, + presetName: "", + fallbackReason: "selected_task_preset_incomplete", + }; + } + + return { + source: "task-preset", + config: normalizedPresetConfig, + requestedPresetName, + presetName: requestedPresetName, + fallbackReason: "", + }; +} diff --git a/llm.js b/llm.js index c07dae8..d0b7b9e 100644 --- a/llm.js +++ b/llm.js @@ -6,6 +6,8 @@ import { extension_settings } from "../../../extensions.js"; import { chat_completion_sources, sendOpenAIRequest } from "../../../openai.js"; import { debugLog, debugWarn } from "./debug-logging.js"; import { resolveTaskGenerationOptions } from "./generation-options.js"; +import { resolveLlmConfigSelection } from "./llm-preset-utils.js"; +import { getActiveTaskProfile } from "./prompt-profiles.js"; import { resolveConfiguredTimeoutMs } from "./request-timeout.js"; import { applyTaskRegex } from "./task-regex.js"; @@ -122,13 +124,41 @@ function getLlmTestOverride(name) { return typeof override === "function" ? override : null; } -function getMemoryLLMConfig() { +function formatLlmConfigSourceLabel(source = "") { + switch (String(source || "").trim()) { + case "task-preset": + return "任务专用模板"; + case "global-fallback-missing-task-preset": + return "任务模板缺失,已回退当前 API"; + case "global-fallback-invalid-task-preset": + return "任务模板不完整,已回退当前 API"; + case "global": + default: + return "跟随当前 API"; + } +} + +function getMemoryLLMConfig(taskType = "") { const settings = extension_settings[MODULE_NAME] || {}; + const normalizedTaskType = String(taskType || "").trim(); + const activeProfile = normalizedTaskType + ? getActiveTaskProfile(settings, normalizedTaskType) + : null; + const selectedPresetName = + typeof activeProfile?.generation?.llm_preset === "string" + ? activeProfile.generation.llm_preset + : ""; + const selection = resolveLlmConfigSelection(settings, selectedPresetName); return { - apiUrl: normalizeOpenAICompatibleBaseUrl(settings.llmApiUrl), - apiKey: String(settings.llmApiKey || "").trim(), - model: String(settings.llmModel || "").trim(), + apiUrl: normalizeOpenAICompatibleBaseUrl(selection.config?.llmApiUrl), + apiKey: String(selection.config?.llmApiKey || "").trim(), + model: String(selection.config?.llmModel || "").trim(), timeoutMs: getConfiguredTimeoutMs(settings), + llmConfigSource: selection.source || "global", + llmConfigSourceLabel: formatLlmConfigSourceLabel(selection.source), + llmPresetName: selection.presetName || "", + requestedLlmPresetName: selection.requestedPresetName || "", + llmPresetFallbackReason: selection.fallbackReason || "", }; } @@ -1296,9 +1326,15 @@ async function callDedicatedOpenAICompatible( taskType, requestSource, ); - const config = getMemoryLLMConfig(); + const config = getMemoryLLMConfig(taskType); const settings = extension_settings[MODULE_NAME] || {}; const hasDedicatedConfig = hasDedicatedLLMConfig(config); + if (taskType && config.llmPresetFallbackReason) { + debugWarn( + `[ST-BME] 任务 ${taskType} 指定的 API 模板不可用,已回退当前 API: ` + + `${config.requestedLlmPresetName || "(empty)"} / ${config.llmPresetFallbackReason}`, + ); + } const generationResolved = taskType ? resolveTaskGenerationOptions(settings, taskType, { max_completion_tokens: Number.isFinite(maxCompletionTokens) @@ -1332,6 +1368,11 @@ async function callDedicatedOpenAICompatible( : "sillytavern-current-model", model: hasDedicatedConfig ? config.model : "sillytavern-current-model", apiUrl: hasDedicatedConfig ? config.apiUrl : "", + llmConfigSource: config.llmConfigSource || "global", + llmConfigSourceLabel: config.llmConfigSourceLabel || "", + llmPresetName: config.llmPresetName || "", + requestedLlmPresetName: config.requestedLlmPresetName || "", + llmPresetFallbackReason: config.llmPresetFallbackReason || "", messages, generation: generationResolved.generation || {}, filteredGeneration: generationResolved.filtered || {}, @@ -1435,6 +1476,11 @@ async function callDedicatedOpenAICompatible( route: "dedicated-openai-compatible", model: config.model, apiUrl: config.apiUrl, + llmConfigSource: config.llmConfigSource || "global", + llmConfigSourceLabel: config.llmConfigSourceLabel || "", + llmPresetName: config.llmPresetName || "", + requestedLlmPresetName: config.requestedLlmPresetName || "", + llmPresetFallbackReason: config.llmPresetFallbackReason || "", messages, generation: generationResolved.generation || {}, filteredGeneration, diff --git a/panel.js b/panel.js index 5a8739f..902ed45 100644 --- a/panel.js +++ b/panel.js @@ -103,6 +103,18 @@ const GRAPH_WRITE_ACTION_IDS = [ ]; const TASK_PROFILE_GENERATION_GROUPS = [ + { + title: "API 配置", + fields: [ + { + key: "llm_preset", + label: "API 配置模板", + type: "llm_preset", + defaultValue: "", + help: "留空表示跟随当前 API;选中已保存模板后,这个任务会独立使用那套 URL / Key / Model。", + }, + ], + }, { title: "基础生成参数", fields: [ @@ -2587,7 +2599,7 @@ function _bindConfigControls() { const settings = _normalizeLlmPresetSettings(_getSettings?.() || {}); const preset = settings.llmPresets?.[selectedName]; if (!preset) { - _patchSettings({ llmActivePreset: "" }); + _patchSettings({ llmActivePreset: "" }, { refreshTaskWorkspace: true }); _populateLlmPresetSelect(settings.llmPresets || {}, ""); _syncLlmPresetControls(""); toastr.warning("选中的模板不存在,已切回手动模式", "ST-BME"); @@ -2623,7 +2635,7 @@ function _bindConfigControls() { ...(settings.llmPresets || {}), [activePreset]: _getLlmConfigInputSnapshot(), }; - _patchSettings({ llmPresets: nextPresets }); + _patchSettings({ llmPresets: nextPresets }, { refreshTaskWorkspace: true }); _populateLlmPresetSelect(nextPresets, activePreset); _syncLlmPresetControls(activePreset); toastr.success("当前模板已保存", "ST-BME"); @@ -2659,7 +2671,7 @@ function _bindConfigControls() { _patchSettings({ llmPresets: nextPresets, llmActivePreset: trimmedName, - }); + }, { refreshTaskWorkspace: true }); _populateLlmPresetSelect(nextPresets, trimmedName); _syncLlmPresetControls(trimmedName); toastr.success("已另存为新模板", "ST-BME"); @@ -2687,7 +2699,7 @@ function _bindConfigControls() { _patchSettings({ llmPresets: nextPresets, llmActivePreset: "", - }); + }, { refreshTaskWorkspace: true }); _populateLlmPresetSelect(nextPresets, ""); _syncLlmPresetControls(""); toastr.success("模板已删除", "ST-BME"); @@ -3861,7 +3873,11 @@ function _renderTaskGenerationTab(state) {
${group.fields .map((field) => - _renderGenerationField(field, state.profile.generation?.[field.key]), + _renderGenerationField( + field, + state.profile.generation?.[field.key], + state, + ), ) .join("")}
@@ -4552,6 +4568,14 @@ function _renderTaskDebugLlmCard(taskType, llmRequest) { 模型 ${_escHtml(llmRequest.model || "—")} +
+ API 配置来源 + ${_escHtml(llmRequest.llmConfigSourceLabel || llmRequest.llmConfigSource || "—")} +
+
+ 任务 API 模板 + ${_escHtml(llmRequest.llmPresetName || (llmRequest.requestedLlmPresetName ? `缺失: ${llmRequest.requestedLlmPresetName}` : "跟随当前 API"))} +
能力过滤模式 ${_escHtml(llmRequest.capabilityMode || "—")} @@ -4577,6 +4601,13 @@ function _renderTaskDebugLlmCard(taskType, llmRequest) { ${_renderDebugDetails("发送前输入清洗", llmRequest.requestCleaning || null)} ${_renderDebugDetails("实际请求路径", llmRequest.effectiveRoute || null)} ${_renderDebugDetails("输出清洗", llmRequest.responseCleaning || null)} + ${_renderDebugDetails("API 配置解析", { + llmConfigSource: llmRequest.llmConfigSource || "", + llmConfigSourceLabel: llmRequest.llmConfigSourceLabel || "", + requestedLlmPresetName: llmRequest.requestedLlmPresetName || "", + llmPresetName: llmRequest.llmPresetName || "", + llmPresetFallbackReason: llmRequest.llmPresetFallbackReason || "", + })} ${_renderDebugDetails("实际保留参数", llmRequest.filteredGeneration || {})} ${_renderDebugDetails("被过滤掉的参数", llmRequest.removedGeneration || [])} ${_renderDebugDetails("最终消息列表", llmRequest.messages || [])} @@ -4884,9 +4915,62 @@ function _renderTaskBlockEditor(state) { `; } -function _renderGenerationField(field, value) { +function _renderGenerationField(field, value, state = {}) { const effectiveValue = (value != null && value !== "") ? value : field.defaultValue; + if (field.type === "llm_preset") { + const presetMap = + state?.settings && typeof state.settings === "object" + ? state.settings.llmPresets || {} + : {}; + const presetNames = Object.keys(presetMap).sort((left, right) => + left.localeCompare(right, "zh-Hans-CN"), + ); + const currentValue = String(effectiveValue || ""); + const hasCurrentPreset = + !currentValue || presetNames.includes(currentValue); + const currentLabel = !currentValue + ? "跟随当前 API" + : hasCurrentPreset + ? currentValue + : `${currentValue}(已丢失,将回退当前 API)`; + const options = [ + { + value: "", + label: "跟随当前 API", + }, + ...(!currentValue || hasCurrentPreset + ? [] + : [{ value: currentValue, label: currentLabel }]), + ...presetNames.map((name) => ({ + value: name, + label: name, + })), + ]; + + return ` +
+ + + ${field.help ? `
${_escHtml(field.help)}
` : ""} +
+ `; + } + if (field.type === "tri_bool") { const currentValue = effectiveValue === true ? "true" : effectiveValue === false ? "false" : ""; @@ -5746,6 +5830,8 @@ function _normalizeLlmPresetSettings(settings = _getSettings?.() || {}) { return _patchSettings({ llmPresets: normalized.presets, llmActivePreset: normalized.activePreset, + }, { + refreshTaskWorkspace: true, }); } diff --git a/prompt-profiles.js b/prompt-profiles.js index 8cedb9e..9cad9e9 100644 --- a/prompt-profiles.js +++ b/prompt-profiles.js @@ -871,6 +871,7 @@ function createFallbackDefaultTaskProfile(taskType) { updatedAt: nowIso(), blocks: buildDefaultTaskProfileBlocks(taskType), generation: { + llm_preset: "", max_context_tokens: null, max_completion_tokens: null, reply_count: null, diff --git a/tests/llm-preset-utils.mjs b/tests/llm-preset-utils.mjs index 0577c74..7d5f3c1 100644 --- a/tests/llm-preset-utils.mjs +++ b/tests/llm-preset-utils.mjs @@ -1,12 +1,40 @@ import assert from "node:assert/strict"; import { + createLlmConfigSnapshot, isSameLlmConfigSnapshot, + isUsableLlmConfigSnapshot, normalizeLlmPresetMap, + resolveLlmConfigSelection, resolveActiveLlmPresetName, sanitizeLlmPresetSettings, } from "../llm-preset-utils.js"; +assert.deepEqual(createLlmConfigSnapshot({ + llmApiUrl: " https://example.com/v1 ", + llmApiKey: " sk-test ", + llmModel: " model-a ", +}), { + llmApiUrl: "https://example.com/v1", + llmApiKey: "sk-test", + llmModel: "model-a", +}); + +assert.equal( + isUsableLlmConfigSnapshot({ + llmApiUrl: "https://example.com/v1", + llmModel: "model-a", + }), + true, +); +assert.equal( + isUsableLlmConfigSnapshot({ + llmApiUrl: "", + llmModel: "model-a", + }), + false, +); + assert.equal( isSameLlmConfigSnapshot( { @@ -131,4 +159,71 @@ const noMatchSettings = { }; assert.equal(resolveActiveLlmPresetName(noMatchSettings), ""); +const taskPresetSelection = resolveLlmConfigSelection( + uniqueMatchSettings, + "Alpha", +); +assert.equal(taskPresetSelection.source, "task-preset"); +assert.equal(taskPresetSelection.presetName, "Alpha"); +assert.deepEqual(taskPresetSelection.config, { + llmApiUrl: "https://example.com/v1", + llmApiKey: "sk-alpha", + llmModel: "model-a", +}); + +const globalSelection = resolveLlmConfigSelection( + uniqueMatchSettings, + "", +); +assert.equal(globalSelection.source, "global"); +assert.equal(globalSelection.presetName, ""); + +const missingTaskPresetSelection = resolveLlmConfigSelection( + uniqueMatchSettings, + "Missing", +); +assert.equal( + missingTaskPresetSelection.source, + "global-fallback-missing-task-preset", +); +assert.equal(missingTaskPresetSelection.requestedPresetName, "Missing"); +assert.equal( + missingTaskPresetSelection.fallbackReason, + "selected_task_preset_missing", +); +assert.deepEqual(missingTaskPresetSelection.config, { + llmApiUrl: "https://example.com/v1", + llmApiKey: "sk-alpha", + llmModel: "model-a", +}); + +const invalidTaskPresetSelection = resolveLlmConfigSelection( + { + llmApiUrl: "https://global.example/v1", + llmApiKey: "sk-global", + llmModel: "model-global", + llmPresets: { + Broken: { + llmApiUrl: "", + llmApiKey: "sk-broken", + llmModel: "", + }, + }, + }, + "Broken", +); +assert.equal( + invalidTaskPresetSelection.source, + "global-fallback-invalid-task-preset", +); +assert.equal( + invalidTaskPresetSelection.fallbackReason, + "selected_task_preset_incomplete", +); +assert.deepEqual(invalidTaskPresetSelection.config, { + llmApiUrl: "https://global.example/v1", + llmApiKey: "sk-global", + llmModel: "model-global", +}); + console.log("llm-preset-utils tests passed"); diff --git a/tests/task-profile-storage.mjs b/tests/task-profile-storage.mjs index 15ce663..58bad98 100644 --- a/tests/task-profile-storage.mjs +++ b/tests/task-profile-storage.mjs @@ -15,11 +15,13 @@ import { const taskProfiles = createDefaultTaskProfiles(); const baseProfile = taskProfiles.extract.profiles[0]; +assert.equal(baseProfile.generation.llm_preset, ""); const clonedProfile = cloneTaskProfile(baseProfile, { taskType: "extract", name: "激进提取", }); +clonedProfile.generation.llm_preset = "Recall-API"; clonedProfile.blocks = [ ...clonedProfile.blocks, createBuiltinPromptBlock("extract", "userMessage", { @@ -65,6 +67,7 @@ assert.ok(customBlock); assert.equal(customBlock.role, "user"); assert.equal(activeProfile.regex.localRules.length, 1); assert.equal(activeProfile.regex.localRules[0].script_name, "裁边"); +assert.equal(activeProfile.generation.llm_preset, "Recall-API"); const exported = exportTaskProfile( updatedProfiles, @@ -74,10 +77,12 @@ const exported = exportTaskProfile( assert.equal(exported.format, "st-bme-task-profile"); assert.equal(exported.taskType, "extract"); assert.equal(exported.profile.name, "激进提取"); +assert.equal(exported.profile.generation.llm_preset, "Recall-API"); const imported = importTaskProfile(updatedProfiles, JSON.stringify(exported)); assert.equal(imported.taskType, "extract"); assert.notEqual(imported.profile.id, clonedProfile.id); +assert.equal(imported.profile.generation.llm_preset, "Recall-API"); assert.ok( imported.profile.blocks.some( (block) => block.type === "builtin" && block.sourceKey === "userMessage",