refactor(ena-planner): align API settings with story-summary and backend proxy flow

This commit is contained in:
2026-02-25 14:50:01 +08:00
parent 5272799b15
commit 841d5f0e1f
3 changed files with 177 additions and 109 deletions

View File

@@ -40,16 +40,15 @@ export const DEFAULT_PROMPT_BLOCKS = [
3. 推进而非重复:每次规划应让故事向前推进,避免原地踏步 3. 推进而非重复:每次规划应让故事向前推进,避免原地踏步
4. 留有空间:给出方向但不要过度规定细节,让主 AI 有创作余地 4. 留有空间:给出方向但不要过度规定细节,让主 AI 有创作余地
5. 遵守世界观:世界书中的规则和设定是硬约束,不可违反 5. 遵守世界观:世界书中的规则和设定是硬约束,不可违反
`,
如有思考过程,请放在 <thinking> 中(会被自动剔除)。`,
}, },
{ {
id: 'ena-default-assistant-001', id: 'ena-default-assistant-001',
role: 'assistant', role: 'assistant',
name: 'Assistant Seed', name: 'Assistant Seed',
content: `<thinking> content: `<think>
让我分析当前情境,梳理玩家意图、已有伏笔和世界观约束,然后规划下一步走向... 让我分析当前情境,梳理玩家意图、已有伏笔和世界观约束,然后规划下一步走向...
</thinking>`, </think>`,
}, },
]; ];

View File

@@ -99,47 +99,38 @@
<div class="form-group"> <div class="form-group">
<label class="form-label">渠道类型</label> <label class="form-label">渠道类型</label>
<select id="ep_api_channel" class="input"> <select id="ep_api_channel" class="input">
<option value="openai">OpenAI 兼容</option> <option value="st_main">ST Main API (no setup)</option>
<option value="gemini">Gemini 兼容</option> <option value="openai">OpenAI compatible</option>
<option value="claude">Claude 兼容</option> <option value="gemini">Gemini compatible</option>
<option value="claude">Claude compatible</option>
<option value="deepseek">DeepSeek compatible</option>
<option value="cohere">Cohere compatible</option>
<option value="custom">Custom compatible</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group hidden" id="ep_model_row">
<label class="form-label">路径前缀</label>
<select id="ep_prefix_mode" class="input">
<option value="auto">自动 (如 /v1)</option>
<option value="custom">自定义</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">API 地址</label>
<input id="ep_api_base" type="text" class="input" placeholder="https://api.openai.com">
</div>
<div class="form-group hidden" id="ep_custom_prefix_group">
<label class="form-label">自定义前缀</label>
<input id="ep_prefix_custom" type="text" class="input" placeholder="/v1">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">API Key</label>
<div class="input-row">
<input id="ep_api_key" type="password" class="input" placeholder="sk-...">
<button id="ep_toggle_key" class="btn">显示</button>
</div>
</div>
<div class="form-group">
<label class="form-label">模型</label> <label class="form-label">模型</label>
<input id="ep_model" type="text" class="input" placeholder="gpt-4o, claude-3-5-sonnet..."> <input id="ep_model" type="text" class="input" placeholder="gpt-4o, claude-3-5-sonnet...">
</div> </div>
</div> </div>
<div class="form-row hidden" id="ep_api_url_key_row">
<div class="form-group">
<label class="form-label">API URL</label>
<input id="ep_api_base" type="text" class="input" placeholder="https://api.openai.com 或代理地址">
</div>
<div class="form-group">
<label class="form-label">API KEY</label>
<input id="ep_api_key" type="password" class="input" placeholder="仅本地保存,不上传">
</div>
</div>
<div class="form-hint">不需要填写 /v1 前缀,后端会按渠道自动处理。</div>
<div id="ep_model_selector" class="hidden" style="margin-top:12px;"> <div id="ep_model_selector" class="hidden" style="margin-top:12px;">
<label class="form-label">选择模型</label> <label class="form-label">选择模型</label>
<select id="ep_model_select" class="input"> <select id="ep_model_select" class="input">
<option value="">-- 从列表选择 --</option> <option value="">-- 从列表选择 --</option>
</select> </select>
</div> </div>
<div class="btn-group" style="margin-top:16px;"> <div class="btn-group" style="margin-top:16px;" id="ep_model_actions">
<button id="ep_fetch_models" class="btn">拉取模型列表</button> <button id="ep_fetch_models" class="btn">拉取模型列表</button>
<button id="ep_test_conn" class="btn">测试连接</button> <button id="ep_test_conn" class="btn">测试连接</button>
</div> </div>
@@ -438,8 +429,18 @@
if (viewId === 'debug') post('xb-ena:logs-request'); if (viewId === 'debug') post('xb-ena:logs-request');
} }
function updatePrefixModeUI() { function updateApiChannelUI() {
$('ep_custom_prefix_group').classList.toggle('hidden', $('ep_prefix_mode').value !== 'custom'); const isMain = $('ep_api_channel').value === 'st_main';
$('ep_api_url_key_row').classList.toggle('hidden', isMain);
$('ep_model_row').classList.toggle('hidden', isMain);
$('ep_model_actions').classList.toggle('hidden', isMain);
if (isMain) $('ep_model_selector').classList.add('hidden');
const model = $('ep_model');
if (model && isMain) {
model.placeholder = 'Empty = follow ST main model';
} else if (model) {
model.placeholder = 'gpt-4o, claude-3-5-sonnet...';
}
} }
/* ── Type conversion ── */ /* ── Type conversion ── */
@@ -650,11 +651,9 @@
$('ep_skip_plot').value = String(toBool(cfg.skipIfPlotPresent, true)); $('ep_skip_plot').value = String(toBool(cfg.skipIfPlotPresent, true));
const api = cfg.api || {}; const api = cfg.api || {};
$('ep_api_channel').value = api.channel || 'openai'; $('ep_api_channel').value = api.channel || 'st_main';
$('ep_prefix_mode').value = api.prefixMode || 'auto'; $('ep_api_base').value = api.url || '';
$('ep_api_base').value = api.baseUrl || ''; $('ep_api_key').value = api.key || '';
$('ep_prefix_custom').value = api.customPrefix || '';
$('ep_api_key').value = api.apiKey || '';
$('ep_model').value = api.model || ''; $('ep_model').value = api.model || '';
$('ep_stream').value = String(toBool(api.stream, false)); $('ep_stream').value = String(toBool(api.stream, false));
$('ep_temp').value = String(toNum(api.temperature, 1)); $('ep_temp').value = String(toNum(api.temperature, 1));
@@ -674,7 +673,7 @@
$('ep_logs_max').value = String(toNum(cfg.logsMax, 20)); $('ep_logs_max').value = String(toNum(cfg.logsMax, 20));
setBadge(toBool(cfg.enabled, true)); setBadge(toBool(cfg.enabled, true));
updatePrefixModeUI(); updateApiChannelUI();
renderTemplateSelect(); renderTemplateSelect();
renderPromptList(); renderPromptList();
renderLogs(); renderLogs();
@@ -688,10 +687,8 @@
p.api = { p.api = {
channel: $('ep_api_channel').value, channel: $('ep_api_channel').value,
prefixMode: $('ep_prefix_mode').value, url: $('ep_api_base').value.trim(),
baseUrl: $('ep_api_base').value.trim(), key: $('ep_api_key').value.trim(),
customPrefix: $('ep_prefix_custom').value.trim(),
apiKey: $('ep_api_key').value,
model: $('ep_model').value.trim(), model: $('ep_model').value.trim(),
stream: toBool($('ep_stream').value, false), stream: toBool($('ep_stream').value, false),
temperature: toNum($('ep_temp').value, 1), temperature: toNum($('ep_temp').value, 1),
@@ -734,17 +731,7 @@
setLocalStatus('ep_test_status', '测试中…', 'loading'); setLocalStatus('ep_test_status', '测试中…', 'loading');
}); });
$('ep_toggle_key').addEventListener('click', () => { $('ep_api_channel').addEventListener('change', () => { updateApiChannelUI(); scheduleSave(); });
const input = $('ep_api_key');
const btn = $('ep_toggle_key');
if (input.type === 'password') {
input.type = 'text'; btn.textContent = '隐藏';
} else {
input.type = 'password'; btn.textContent = '显示';
}
});
$('ep_prefix_mode').addEventListener('change', updatePrefixModeUI);
$('ep_fetch_models').addEventListener('click', () => { $('ep_fetch_models').addEventListener('click', () => {
post('xb-ena:fetch-models'); post('xb-ena:fetch-models');

View File

@@ -1,5 +1,6 @@
import { extension_settings } from '../../../../../extensions.js'; import { extension_settings } from '../../../../../extensions.js';
import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../../script.js'; import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../../script.js';
import { chat_completion_sources, getChatCompletionModel, oai_settings } from '../../../../../openai.js';
import { getStorySummaryForEna } from '../story-summary/story-summary.js'; import { getStorySummaryForEna } from '../story-summary/story-summary.js';
import { extensionFolderPath } from '../../core/constants.js'; import { extensionFolderPath } from '../../core/constants.js';
import { EnaPlannerStorage } from '../../core/server-storage.js'; import { EnaPlannerStorage } from '../../core/server-storage.js';
@@ -40,11 +41,9 @@ function getDefaultSettings() {
// Planner API // Planner API
api: { api: {
channel: 'openai', channel: 'st_main',
baseUrl: '', url: '',
prefixMode: 'auto', key: '',
customPrefix: '',
apiKey: '',
model: '', model: '',
stream: false, stream: false,
temperature: 1, temperature: 1,
@@ -168,30 +167,11 @@ function nowISO() {
return new Date().toISOString(); return new Date().toISOString();
} }
function normalizeUrlBase(u) { function normalizeProxyBaseUrl(url) {
if (!u) return ''; let base = String(url || '').trim().replace(/\/+$/, '');
return u.replace(/\/+$/g, ''); if (/\/v1$/i.test(base)) base = base.replace(/\/v1$/i, '');
} if (/\/v1beta$/i.test(base)) base = base.replace(/\/v1beta$/i, '');
return base;
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 buildUrl(path) {
const s = ensureSettings();
const base = normalizeUrlBase(s.api.baseUrl);
const prefix = buildApiPrefix();
const p = prefix.startsWith('/') ? prefix : `/${prefix}`;
const finalPrefix = p.replace(/\/+$/g, '');
const finalPath = path.startsWith('/') ? path : `/${path}`;
return `${base}${finalPrefix}${finalPath}`;
} }
function setSendUIBusy(busy) { function setSendUIBusy(busy) {
@@ -901,18 +881,39 @@ function filterPlannerForInput(rawFull) {
* Planner API calls * Planner API calls
* -------------------------- * --------------------------
*/ */
async function callPlanner(messages) { async function callPlanner(messages, options = {}) {
const s = ensureSettings(); const s = ensureSettings();
if (!s.api.baseUrl) throw new Error('未配置 API URL'); const channel = String(s.api?.channel || 'st_main').toLowerCase();
if (!s.api.apiKey) throw new Error('未配置 API KEY'); const source = {
if (!s.api.model) throw new Error('未选择模型'); st_main: String(oai_settings?.chat_completion_source || chat_completion_sources.OPENAI),
openai: chat_completion_sources.OPENAI,
claude: chat_completion_sources.CLAUDE,
gemini: chat_completion_sources.MAKERSUITE,
google: chat_completion_sources.MAKERSUITE,
cohere: chat_completion_sources.COHERE,
deepseek: chat_completion_sources.DEEPSEEK,
custom: chat_completion_sources.CUSTOM,
}[channel];
if (!source) throw new Error(`Unsupported channel: ${channel}`);
const url = buildUrl('/chat/completions'); const model = channel === 'st_main'
? String(getChatCompletionModel?.() || '').trim()
: String(s.api?.model || '').trim();
if (!model) throw new Error('No model selected in ST main panel or Ena settings');
const providerUrl = normalizeProxyBaseUrl(s.api?.url);
const providerKey = String(s.api?.key || '').trim();
if (channel !== 'st_main') {
if (!providerUrl) throw new Error('Please provide API URL');
if (!providerKey) throw new Error('Please provide API KEY');
}
const body = { const body = {
model: s.api.model, type: 'quiet',
model,
messages, messages,
stream: !!s.api.stream stream: !!s.api.stream,
chat_completion_source: source,
custom_prompt_post_processing: oai_settings?.custom_prompt_post_processing,
}; };
const t = Number(s.api.temperature); const t = Number(s.api.temperature);
@@ -928,11 +929,48 @@ async function callPlanner(messages) {
const mt = s.api.max_tokens === '' ? null : Number(s.api.max_tokens); const mt = s.api.max_tokens === '' ? null : Number(s.api.max_tokens);
if (mt != null && !Number.isNaN(mt) && mt > 0) body.max_tokens = mt; if (mt != null && !Number.isNaN(mt) && mt > 0) body.max_tokens = mt;
const res = await fetch(url, { if (source === chat_completion_sources.MAKERSUITE && body.max_tokens != null) {
body.max_output_tokens = body.max_tokens;
body.use_makersuite_sysprompt = false;
}
const reverseProxy = channel === 'st_main'
? String(oai_settings?.reverse_proxy || '').trim()
: providerUrl;
const proxyPassword = channel === 'st_main'
? String(oai_settings?.proxy_password || '').trim()
: providerKey;
if (reverseProxy && [
chat_completion_sources.CLAUDE,
chat_completion_sources.OPENAI,
chat_completion_sources.MISTRALAI,
chat_completion_sources.MAKERSUITE,
chat_completion_sources.VERTEXAI,
chat_completion_sources.DEEPSEEK,
chat_completion_sources.XAI,
chat_completion_sources.COHERE,
].includes(source)) {
body.reverse_proxy = reverseProxy;
if (proxyPassword) body.proxy_password = proxyPassword;
}
if (source === chat_completion_sources.CUSTOM) {
body.custom_url = channel === 'st_main' ? oai_settings?.custom_url : providerUrl;
body.custom_include_headers = oai_settings?.custom_include_headers;
if (proxyPassword) body.proxy_password = proxyPassword;
}
if (source === chat_completion_sources.AZURE_OPENAI) {
body.azure_base_url = oai_settings?.azure_base_url;
body.azure_deployment_name = oai_settings?.azure_deployment_name;
body.azure_api_version = oai_settings?.azure_api_version;
}
const res = await fetch('/api/backends/chat-completions/generate', {
method: 'POST', method: 'POST',
headers: { headers: {
...getRequestHeaders(), ...getRequestHeaders(),
Authorization: `Bearer ${s.api.apiKey}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify(body) body: JSON.stringify(body)
@@ -940,15 +978,17 @@ async function callPlanner(messages) {
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ''); const text = await res.text().catch(() => '');
throw new Error(`规划请求失败: ${res.status} ${text}`.slice(0, 500)); throw new Error(`Planner request failed: ${res.status} ${text}`.slice(0, 500));
} }
if (!s.api.stream) { if (!s.api.stream) {
const data = await res.json(); const data = await res.json();
return String(data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? ''); if (data?.error) throw new Error(data.error?.message || 'Planner request failed');
const text = String(data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? '');
if (text) options?.onDelta?.(text, text);
return text;
} }
// SSE stream
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8'); const decoder = new TextDecoder('utf-8');
let buf = ''; let buf = '';
@@ -969,31 +1009,74 @@ async function callPlanner(messages) {
if (payload === '[DONE]') continue; if (payload === '[DONE]') continue;
try { try {
const j = JSON.parse(payload); const j = JSON.parse(payload);
if (j?.error) throw new Error(j.error?.message || 'Planner request failed');
const delta = j?.choices?.[0]?.delta; const delta = j?.choices?.[0]?.delta;
const piece = delta?.content ?? delta?.text ?? ''; const piece = delta?.content ?? delta?.text ?? '';
if (piece) full += piece; if (piece) {
} catch { } full += piece;
options?.onDelta?.(piece, full);
}
} catch {
// ignore non-json chunks
}
} }
} }
} }
return full; return full;
} }
async function fetchModelsForUi() { async function fetchModelsForUi() {
const s = ensureSettings(); const s = ensureSettings();
if (!s.api.baseUrl) throw new Error('请先填写 API URL'); const channel = String(s.api?.channel || 'st_main').toLowerCase();
if (!s.api.apiKey) throw new Error('请先填写 API KEY'); const source = channel === 'st_main'
const url = buildUrl('/models'); ? String(oai_settings?.chat_completion_source || chat_completion_sources.OPENAI)
const res = await fetch(url, { : ({
method: 'GET', openai: chat_completion_sources.OPENAI,
claude: chat_completion_sources.CLAUDE,
gemini: chat_completion_sources.MAKERSUITE,
google: chat_completion_sources.MAKERSUITE,
cohere: chat_completion_sources.COHERE,
deepseek: chat_completion_sources.DEEPSEEK,
custom: chat_completion_sources.CUSTOM,
}[channel]);
if (!source) throw new Error(`Unsupported channel: ${channel}`);
const providerUrl = normalizeProxyBaseUrl(s.api?.url);
const providerKey = String(s.api?.key || '').trim();
if (channel !== 'st_main') {
if (!providerUrl) throw new Error('Please provide API URL');
if (!providerKey) throw new Error('Please provide API KEY');
}
const payload = {
chat_completion_source: source,
reverse_proxy: channel === 'st_main' ? oai_settings?.reverse_proxy : providerUrl,
proxy_password: channel === 'st_main' ? oai_settings?.proxy_password : providerKey,
};
if (source === chat_completion_sources.CUSTOM) {
payload.custom_url = oai_settings?.custom_url;
payload.custom_include_headers = oai_settings?.custom_include_headers;
}
if (source === chat_completion_sources.AZURE_OPENAI) {
payload.azure_base_url = oai_settings?.azure_base_url;
payload.azure_deployment_name = oai_settings?.azure_deployment_name;
payload.azure_api_version = oai_settings?.azure_api_version;
}
const res = await fetch('/api/backends/chat-completions/status', {
method: 'POST',
headers: { headers: {
...getRequestHeaders(), ...getRequestHeaders(),
Authorization: `Bearer ${s.api.apiKey}` 'Content-Type': 'application/json'
} },
body: JSON.stringify(payload),
cache: 'no-cache',
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ''); const text = await res.text().catch(() => '');
throw new Error(`拉取模型失败: ${res.status} ${text}`.slice(0, 300)); throw new Error(`Model list request failed: ${res.status} ${text}`.slice(0, 300));
} }
const data = await res.json(); const data = await res.json();
const list = Array.isArray(data?.data) ? data.data : []; const list = Array.isArray(data?.data) ? data.data : [];
@@ -1446,4 +1529,3 @@ export function cleanupEnaPlanner() {
delete window.xiaobaixEnaPlanner; delete window.xiaobaixEnaPlanner;
} }