Agent/ui/config-dialog.ts
2026-08-06 22:17:39 +08:00

890 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @ts-nocheck
import {
CONFIG,
loadOllamaConfig, saveOllamaConfig,
loadAnthropicConfig, saveAnthropicConfig,
loadOmlxConfig, saveOmlxConfig,
loadSparkleConfig, saveSparkleConfig,
loadDefaultSource, saveDefaultSource,
loadContextThreshold, saveContextThreshold,
THRESHOLD_OPTIONS,
CLOUD_API_PRESETS,
DEFAULT_OLLAMA, DEFAULT_ANTHROPIC, DEFAULT_OMLX,
} from '../utils/config.js';
import { shortName } from '../utils/metrics-client.js';
function thresholdSelect(source) {
const current = loadContextThreshold(source);
return `<select id="cfg-threshold-${source}">
${THRESHOLD_OPTIONS.map(opt =>
`<option value="${opt.value}" ${opt.value === current ? 'selected' : ''}>${opt.label}</option>`
).join('')}
</select>`;
}
export function showConfigDialog() {
return new Promise((resolve) => {
const ollama = loadOllamaConfig();
const anthropic = loadAnthropicConfig();
const omlx = loadOmlxConfig();
const sparkle = loadSparkleConfig();
const defaultSource = loadDefaultSource();
const overlay = document.createElement('div');
overlay.className = 'dialog-overlay';
overlay.innerHTML = `
<div class="dialog" role="dialog" aria-modal="true">
<div class="dialog-header">
<h2>模型源配置</h2>
<button class="dialog-close" type="button" aria-label="关闭">×</button>
</div>
<div class="dialog-tabs">
<button class="dialog-tab active" data-tab="ollama" type="button">本地 Ollama</button>
<button class="dialog-tab" data-tab="omlx" type="button">本地 oMLX</button>
<button class="dialog-tab" data-tab="sparkle" type="button">本地 Sparkle</button>
<button class="dialog-tab" data-tab="anthropic" type="button">云端 API</button>
</div>
<div class="dialog-body">
<div class="dialog-pane active" data-pane="ollama">
<div class="form-row">
<label>Base URL</label>
<input type="text" id="cfg-ollama-url" value="${escape(ollama.baseUrl)}" placeholder="http://127.0.0.1:11434"/>
<small>Ollama 服务地址</small>
</div>
<div class="form-row">
<label>模型</label>
<div class="input-with-action">
<select id="cfg-ollama-model" data-current="${escape(ollama.model)}">
${ollama.model ? `<option value="${escape(ollama.model)}" selected>${escape(shortName(ollama.model))}</option>` : '<option value="">自动拉取中...</option>'}
</select>
</div>
<small>从本地 Ollama 自动拉取</small>
</div>
<div class="form-row">
<label>上下文压缩</label>
${thresholdSelect('ollama')}
<small>超阈值自动截断中间历史(建议:32k 模型选标准 24k)</small>
</div>
<div class="form-hint">
注意:启动 Ollama 时记得带 <code>OLLAMA_ORIGINS=http://localhost:8090</code>
</div>
</div>
<div class="dialog-pane" data-pane="omlx">
<div class="form-row">
<label>Base URL</label>
<input type="text" id="cfg-omlx-url" value="${escape(omlx.baseUrl)}" placeholder="http://127.0.0.1:8000"/>
<small>oMLX 服务地址(Apple Silicon 原生 MLX 推理)</small>
</div>
<div class="form-row">
<label>API Key</label>
<div class="input-with-action">
<input type="password" id="cfg-omlx-key" value="${escape(omlx.apiKey)}" placeholder="omlx-xxxx"/>
<button type="button" class="btn-toggle-pw" data-target="cfg-omlx-key">显</button>
</div>
<small>oMLX admin dashboard 里设置/查看</small>
</div>
<div class="form-row">
<label>模型</label>
<div class="input-with-action">
<select id="cfg-omlx-model" data-current="${escape(omlx.model)}">
${omlx.model ? `<option value="${escape(omlx.model)}" selected>${escape(shortName(omlx.model))}</option>` : '<option value="">填好 URL+Key 自动拉取</option>'}
</select>
</div>
<small>从本地 oMLX 自动拉取</small>
</div>
<div class="form-row">
<label>上下文压缩</label>
${thresholdSelect('omlx')}
<small>超阈值自动截断中间历史</small>
</div>
<div class="form-hint">
模型下载:打开 <code>http://localhost:8000/admin</code> 用 Model Downloader
</div>
</div>
<div class="dialog-pane" data-pane="sparkle">
<div class="engine-section">
<div class="engine-status-row">
<span class="engine-dot" id="sparkle-engine-dot"></span>
<span class="engine-status-text" id="sparkle-engine-status">查询中...</span>
<span class="engine-live-stats" id="sparkle-engine-live"></span>
</div>
<div class="engine-actions">
<button type="button" id="sparkle-engine-load" disabled>加载</button>
<button type="button" id="sparkle-engine-unload" disabled>卸载</button>
</div>
<div class="engine-params">
<div class="engine-param-row">
<label>expert-slots</label>
<input type="range" id="sparkle-cfg-slots" min="32" max="160" step="1" value="64" disabled/>
<span class="engine-param-value" id="sparkle-cfg-slots-mem"></span>
</div>
<div class="engine-param-row">
<label>投机数</label>
<input type="number" id="sparkle-cfg-k" min="1" max="5" step="1" value="2" disabled/>
</div>
<div class="engine-param-row">
<label>max_tokens</label>
<input type="number" id="sparkle-cfg-maxtokens" min="1" step="1" value="4096" disabled/>
<button type="button" class="engine-params-apply" id="sparkle-cfg-apply" disabled>应用</button>
</div>
</div>
<small class="engine-note" id="sparkle-engine-note"></small>
</div>
<div class="form-row">
<label>Base URL</label>
<input type="text" id="cfg-sparkle-url" value="${escape(sparkle.baseUrl)}" placeholder="http://127.0.0.1:8317"/>
<small>Sparkle 服务地址(自建 OpenAI 兼容推理服务)</small>
</div>
<div class="form-row">
<label>API Key</label>
<div class="input-with-action">
<input type="password" id="cfg-sparkle-key" value="${escape(sparkle.apiKey)}" placeholder="任意值即可"/>
<button type="button" class="btn-toggle-pw" data-target="cfg-sparkle-key">显</button>
</div>
</div>
<div class="form-row">
<label>模型</label>
<div class="input-with-action">
<select id="cfg-sparkle-model" data-current="${escape(sparkle.model)}">
${sparkle.model ? `<option value="${escape(sparkle.model)}" selected>${escape(shortName(sparkle.model))}</option>` : '<option value="">点「刷新」拉取模型列表</option>'}
</select>
<button type="button" class="btn-refresh-models" data-target="sparkle">刷新</button>
</div>
<small>从 Sparkle /v1/models 拉取</small>
</div>
<div class="form-row">
<label>上下文压缩</label>
${thresholdSelect('sparkle')}
<small>超阈值自动截断中间历史</small>
</div>
</div>
<div class="dialog-pane" data-pane="anthropic">
<div class="form-row">
<label>Base URL</label>
<input type="text" id="cfg-anthropic-url" value="${escape(anthropic.baseUrl)}" placeholder="云端 API 端点"/>
<small id="cfg-anthropic-url-hint">${(anthropic.protocol || 'anthropic') === 'openai' ? '将请求 {Base}/chat/completions' : '将请求 {Base}/v1/messages'}</small>
</div>
<div class="form-row">
<label>Auth Token</label>
<div class="input-with-action">
<input type="password" id="cfg-anthropic-token" value="${escape(anthropic.authToken)}" placeholder="云端 API Token"/>
<button type="button" class="btn-toggle-pw" data-target="cfg-anthropic-token">显</button>
</div>
<small>用 Authorization: Bearer 头发送</small>
</div>
<div class="form-row form-row-3">
<div>
<label>快速档</label>
<input type="text" id="cfg-anthropic-fast" value="${escape(anthropic.fastModel)}" placeholder="如 glm-4.5 / GLM-5.2"/>
</div>
<div>
<label>平衡档</label>
<input type="text" id="cfg-anthropic-standard" value="${escape(anthropic.standardModel)}" placeholder="用户自填"/>
</div>
<div>
<label>增强档</label>
<input type="text" id="cfg-anthropic-pro" value="${escape(anthropic.proModel)}" placeholder="用户自填"/>
</div>
</div>
<div class="form-row">
<label>当前使用</label>
<div class="radio-row">
${['fast', 'standard', 'pro'].map(tier => `
<label class="radio-label">
<input type="radio" name="tier" value="${tier}" ${anthropic.currentTier === tier ? 'checked' : ''}/>
<span>${tier === 'fast' ? '快速档' : tier === 'standard' ? '平衡档' : '增强档'}</span>
</label>
`).join('')}
</div>
<small>影响下次调用使用的模型</small>
</div>
<div class="form-row">
<label>上下文压缩</label>
${thresholdSelect('anthropic')}
<small>云端模型通常 context 大,建议选超大(96k)或极限(500k)</small>
</div>
<div class="form-row">
<label>调用方式</label>
<select id="cfg-anthropic-preset-select">
${CLOUD_API_PRESETS.map(p => `
<option value="${p.id}" ${(anthropic.preset || 'custom') === p.id ? 'selected' : ''}>${escape(p.label)}</option>
`).join('')}
</select>
<small id="cfg-anthropic-preset-hint">${escape((CLOUD_API_PRESETS.find(p => p.id === (anthropic.preset || 'custom')) || CLOUD_API_PRESETS.find(p => p.id === 'custom') || {}).hint || '')}</small>
</div>
<div class="form-row">
<label>协议</label>
<select id="cfg-anthropic-protocol-select">
<option value="anthropic" ${(anthropic.protocol || 'anthropic') === 'anthropic' ? 'selected' : ''}>Anthropic</option>
<option value="openai" ${anthropic.protocol === 'openai' ? 'selected' : ''}>OpenAI</option>
</select>
<small>账号余额须选 OpenAI;套餐可用 Anthropic</small>
</div>
<div class="form-row form-row-2">
<div id="cfg-anthropic-version-row">
<label>API 版本</label>
<input type="text" id="cfg-anthropic-version" value="${escape(anthropic.apiVersion)}" placeholder="2023-06-01"/>
<small>仅 Anthropic 需要</small>
</div>
<div>
<label>最大 tokens</label>
<input type="number" id="cfg-anthropic-maxtokens" value="${anthropic.maxTokens}" placeholder="4096"/>
</div>
</div>
</div>
</div>
<div class="dialog-footer">
<div class="footer-default-source">
<label>默认启动</label>
<select id="cfg-default-source">
<option value="ollama" ${defaultSource === 'ollama' ? 'selected' : ''}>Ollama</option>
<option value="omlx" ${defaultSource === 'omlx' ? 'selected' : ''}>oMLX</option>
<option value="sparkle" ${defaultSource === 'sparkle' ? 'selected' : ''}>Sparkle</option>
<option value="anthropic" ${defaultSource === 'anthropic' ? 'selected' : ''}>云端 API</option>
</select>
</div>
<button class="btn-cancel" type="button">取消</button>
<button class="btn-save" type="button">保存</button>
</div>
</div>
`;
document.body.appendChild(overlay);
document.body.classList.add('modal-open');
const close = (result) => {
stopEngineTimers();
overlay.remove();
document.body.classList.remove('modal-open');
resolve(result);
};
overlay.querySelectorAll('[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
overlay.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b === btn));
overlay.querySelectorAll('[data-pane]').forEach(p => p.classList.toggle('active', p.dataset.pane === tab));
});
});
overlay.querySelectorAll('.btn-toggle-pw').forEach(btn => {
btn.addEventListener('click', () => {
const input = document.getElementById(btn.dataset.target);
input.type = input.type === 'password' ? 'text' : 'password';
});
});
const urlInput = overlay.querySelector('#cfg-anthropic-url');
const presetHint = overlay.querySelector('#cfg-anthropic-preset-hint');
const urlHint = overlay.querySelector('#cfg-anthropic-url-hint');
const versionRow = overlay.querySelector('#cfg-anthropic-version-row');
function currentProtocol() {
const sel = overlay.querySelector('#cfg-anthropic-protocol-select');
return sel ? sel.value : 'anthropic';
}
function currentPreset() {
const sel = overlay.querySelector('#cfg-anthropic-preset-select');
return sel ? sel.value : 'custom';
}
function setPreset(presetId) {
const sel = overlay.querySelector('#cfg-anthropic-preset-select');
if (sel) sel.value = presetId;
}
function setProtocol(protocol) {
const sel = overlay.querySelector('#cfg-anthropic-protocol-select');
if (!sel) return;
sel.value = protocol;
syncProtocolUi(protocol);
}
function syncProtocolUi(protocol) {
if (urlHint) {
urlHint.textContent = protocol === 'openai'
? '将请求 {Base}/chat/completions'
: '将请求 {Base}/v1/messages';
}
if (versionRow) {
versionRow.style.display = protocol === 'openai' ? 'none' : '';
}
}
function applyPreset(presetId, { fillUrl = true } = {}) {
const preset = CLOUD_API_PRESETS.find(p => p.id === presetId)
|| CLOUD_API_PRESETS.find(p => p.id === 'custom')
|| CLOUD_API_PRESETS[CLOUD_API_PRESETS.length - 1];
const tokenInput = overlay.querySelector('#cfg-anthropic-token');
const fastEl = overlay.querySelector('#cfg-anthropic-fast');
const stdEl = overlay.querySelector('#cfg-anthropic-standard');
const proEl = overlay.querySelector('#cfg-anthropic-pro');
const tierChecked = overlay.querySelector('input[name="tier"]:checked');
const currentPresetId = anthropic.preset || 'custom';
const snapshot = {
authToken: tokenInput?.value || '',
fastModel: fastEl?.value || '',
standardModel: stdEl?.value || '',
proModel: proEl?.value || '',
currentTier: tierChecked?.value || anthropic.currentTier || 'fast',
};
if (currentPresetId !== 'custom' && snapshot.authToken) {
anthropic.presets = anthropic.presets || {};
anthropic.presets[currentPresetId] = snapshot;
}
if (currentPresetId === 'custom' && (snapshot.authToken || snapshot.fastModel)) {
anthropic.presets = anthropic.presets || {};
anthropic.presets['custom'] = snapshot;
}
setPreset(preset.id);
if (presetHint) presetHint.textContent = preset.hint || '';
if (preset.protocol) setProtocol(preset.protocol);
if (fillUrl && preset.baseUrl && urlInput) urlInput.value = preset.baseUrl;
if (preset.models) {
if (fastEl && 'fastModel' in preset.models) fastEl.value = preset.models.fastModel || '';
if (stdEl && 'standardModel' in preset.models) stdEl.value = preset.models.standardModel || '';
if (proEl && 'proModel' in preset.models) proEl.value = preset.models.proModel || '';
if (preset.models.currentTier) {
const tierRadio = overlay.querySelector(`input[name="tier"][value="${preset.models.currentTier}"]`);
if (tierRadio) tierRadio.checked = true;
}
}
anthropic.presets = anthropic.presets || {};
const saved = anthropic.presets[preset.id];
if (saved) {
if (tokenInput) tokenInput.value = saved.authToken || '';
if (saved.fastModel && fastEl) fastEl.value = saved.fastModel;
if (saved.standardModel && stdEl) stdEl.value = saved.standardModel;
if (saved.proModel && proEl) proEl.value = saved.proModel;
if (saved.currentTier) {
const tierRadio = overlay.querySelector(`input[name="tier"][value="${saved.currentTier}"]`);
if (tierRadio) tierRadio.checked = true;
}
} else if (preset.id !== 'custom') {
if (tokenInput) tokenInput.value = '';
}
syncProtocolUi(preset.protocol || currentProtocol());
}
const presetSelect = overlay.querySelector('#cfg-anthropic-preset-select');
if (presetSelect) {
presetSelect.addEventListener('change', () => {
applyPreset(presetSelect.value, { fillUrl: true });
});
}
const protocolSelect = overlay.querySelector('#cfg-anthropic-protocol-select');
if (protocolSelect) {
protocolSelect.addEventListener('change', () => {
const newProtocol = protocolSelect.value;
if (currentPreset() !== 'custom') {
const preset = CLOUD_API_PRESETS.find(p => p.id === currentPreset());
if (preset?.protocol && preset.protocol !== newProtocol) {
setPreset('custom');
if (presetHint) {
presetHint.textContent = (CLOUD_API_PRESETS.find(p => p.id === 'custom') || {}).hint || '';
}
}
}
syncProtocolUi(newProtocol);
});
}
if (urlInput) {
urlInput.addEventListener('input', () => {
if (currentPreset() === 'custom') return;
const preset = CLOUD_API_PRESETS.find(p => p.id === currentPreset());
if (preset?.baseUrl && urlInput.value.trim().replace(/\/+$/, '') !== preset.baseUrl.replace(/\/+$/, '')) {
setPreset('custom');
if (presetHint) {
presetHint.textContent = (CLOUD_API_PRESETS.find(p => p.id === 'custom') || {}).hint || '';
}
}
});
}
syncProtocolUi(currentProtocol());
async function autoFetchModels(target) {
const urlInput = document.getElementById(`cfg-${target}-url`);
const select = document.getElementById(`cfg-${target}-model`);
if (!urlInput || !select) return;
const baseUrl = (urlInput.value || '').trim().replace(/\/+$/, '');
if (!baseUrl) return;
const currentVal = select.dataset.current || select.value || '';
select.innerHTML = '<option value="">拉取中...</option>';
try {
let models = [];
if (target === 'ollama') {
const r = await fetch(`${baseUrl}/api/tags`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
models = (data.models || []).map(m => m.name);
} else if (target === 'omlx') {
const keyInput = document.getElementById('cfg-omlx-key');
const apiKey = (keyInput?.value || '').trim();
if (!apiKey) {
select.innerHTML = '<option value="">填好 API Key 后自动拉取</option>';
return;
}
const r = await fetch(`${baseUrl}/v1/models`, {
headers: { 'Authorization': `Bearer ${apiKey}` },
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
models = (data.data || []).map(m => m.id);
} else if (target === 'sparkle') {
const keyInput = document.getElementById('cfg-sparkle-key');
const apiKey = (keyInput?.value || '').trim() || 'sparkle';
const r = await fetch(`${baseUrl}/v1/models`, {
headers: { 'Authorization': `Bearer ${apiKey}` },
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
models = (data.data || []).map(m => m.id);
}
if (models.length === 0) {
select.innerHTML = '<option value="">未找到模型</option>';
} else {
select.innerHTML = models.map(m =>
`<option value="${escape(m)}" ${m === currentVal ? 'selected' : ''}>${escape(shortName(m))}</option>`
).join('');
}
} catch (e) {
select.innerHTML = `<option value="">拉取失败:${e.message}(检查服务是否启动)</option>`;
}
}
autoFetchModels('ollama');
autoFetchModels('omlx');
autoFetchModels('sparkle');
overlay.querySelectorAll('.btn-refresh-models').forEach(btn => {
btn.addEventListener('click', () => autoFetchModels(btn.dataset.target));
});
const ollamaUrlInput = overlay.querySelector('#cfg-ollama-url');
if (ollamaUrlInput) ollamaUrlInput.addEventListener('change', () => autoFetchModels('ollama'));
const omlxUrlInput = overlay.querySelector('#cfg-omlx-url');
const omlxKeyInput = overlay.querySelector('#cfg-omlx-key');
if (omlxUrlInput) omlxUrlInput.addEventListener('change', () => autoFetchModels('omlx'));
if (omlxKeyInput) omlxKeyInput.addEventListener('change', () => autoFetchModels('omlx'));
const sparkleUrlInput = overlay.querySelector('#cfg-sparkle-url');
const sparkleKeyInput = overlay.querySelector('#cfg-sparkle-key');
if (sparkleUrlInput) sparkleUrlInput.addEventListener('change', () => autoFetchModels('sparkle'));
if (sparkleKeyInput) sparkleKeyInput.addEventListener('change', () => autoFetchModels('sparkle'));
const engineEls = {
dot: overlay.querySelector('#sparkle-engine-dot'),
status: overlay.querySelector('#sparkle-engine-status'),
live: overlay.querySelector('#sparkle-engine-live'),
btnLoad: overlay.querySelector('#sparkle-engine-load'),
btnUnload: overlay.querySelector('#sparkle-engine-unload'),
slots: overlay.querySelector('#sparkle-cfg-slots'),
slotsMem: overlay.querySelector('#sparkle-cfg-slots-mem'),
k: overlay.querySelector('#sparkle-cfg-k'),
maxTokens: overlay.querySelector('#sparkle-cfg-maxtokens'),
apply: overlay.querySelector('#sparkle-cfg-apply'),
note: overlay.querySelector('#sparkle-engine-note'),
};
let enginePollTimer = null;
let engineStatsTimer = null;
let engineConfigLoaded = false;
let engineCurrentSlots = null;
let pendingParamConfig = null;
function stopEngineTimers() {
if (enginePollTimer) { clearInterval(enginePollTimer); enginePollTimer = null; }
if (engineStatsTimer) { clearInterval(engineStatsTimer); engineStatsTimer = null; }
}
function setEngineNote(text) {
if (engineEls.note) engineEls.note.textContent = text || '';
}
function updateSlotsMem() {
if (!engineEls.slots || !engineEls.slotsMem) return;
const slots = parseInt(engineEls.slots.value, 10) || 0;
engineEls.slotsMem.textContent = `${slots}× 160MB ≈ ${(slots * 160 / 1024).toFixed(1)}GB 池内存`;
}
function renderEngineState(state, statusText) {
if (!engineEls.dot) return;
engineEls.dot.className = 'engine-dot'
+ (state === 'loading' ? ' is-loading' : state === 'ready' ? ' is-ready' : '');
engineEls.status.textContent = statusText;
const canLoad = state === 'stopped';
const canUnload = state === 'loading' || state === 'ready';
const canEdit = state === 'ready';
engineEls.btnLoad.disabled = !canLoad;
engineEls.btnUnload.disabled = !canUnload;
engineEls.slots.disabled = !canEdit;
engineEls.k.disabled = !canEdit;
engineEls.maxTokens.disabled = !canEdit;
engineEls.apply.disabled = !canEdit;
if (!canEdit) engineConfigLoaded = false;
}
function updateEngineLive(stats) {
if (!engineEls.live) return;
if (!stats) {
engineEls.live.textContent = '';
return;
}
const parts = [];
if (stats.rss_bytes) parts.push(`RSS ${(stats.rss_bytes / 1073741824).toFixed(1)}GB`);
if (stats.tok_per_s != null) parts.push(`${Number(stats.tok_per_s).toFixed(1)} tok/s`);
engineEls.live.textContent = parts.join(' · ');
}
async function refreshEngineStatus() {
let st = null;
let timedOut = false;
try {
const r = await fetch(`${CONFIG.ZHONGTAI_BASE}/sparkle-engine/status`, {
signal: AbortSignal.timeout(10000),
});
if (r.ok) st = await r.json();
} catch (e) {
timedOut = !!e && (e.name === 'TimeoutError' || e.name === 'AbortError');
}
if (!st) {
if (timedOut) {
if (engineStatsTimer) { clearInterval(engineStatsTimer); engineStatsTimer = null; }
renderEngineState('querying', '引擎繁忙,状态查询超时,重试中...');
if (!enginePollTimer) enginePollTimer = setInterval(refreshEngineStatus, 3000);
return;
}
stopEngineTimers();
renderEngineState('zhongtai_down', '中台未启动,引擎管理不可用');
setEngineNote('');
updateEngineLive(null);
return;
}
if (!st.installed) {
stopEngineTimers();
renderEngineState('not_installed', '未检测到本地引擎安装');
setEngineNote('引擎为可选组件,未安装不影响其他模型源的使用。');
updateEngineLive(null);
return;
}
if (!st.running) {
stopEngineTimers();
renderEngineState('stopped', '未启动');
setEngineNote('点击「加载」启动引擎,首次加载约需 90 秒。');
updateEngineLive(null);
return;
}
if (!st.loaded) {
renderEngineState('loading', '加载中...');
updateEngineLive(null);
if (engineStatsTimer) { clearInterval(engineStatsTimer); engineStatsTimer = null; }
if (!enginePollTimer) enginePollTimer = setInterval(refreshEngineStatus, 3000);
return;
}
if (enginePollTimer) { clearInterval(enginePollTimer); enginePollTimer = null; }
renderEngineState('ready', '已就绪');
setEngineNote('');
updateEngineLive(st.stats);
if (st.expert_slots != null) {
engineCurrentSlots = st.expert_slots;
engineEls.slots.value = st.expert_slots;
updateSlotsMem();
}
if (!engineConfigLoaded) {
engineConfigLoaded = true;
loadEngineConfig();
}
if (pendingParamConfig) {
const p = pendingParamConfig;
pendingParamConfig = null;
postEngineParams(p);
}
if (!engineStatsTimer) engineStatsTimer = setInterval(refreshEngineStats, 5000);
}
async function refreshEngineStats() {
try {
const r = await fetch(`${CONFIG.ZHONGTAI_BASE}/sparkle-engine/api/stats`, {
signal: AbortSignal.timeout(4000),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
updateEngineLive(await r.json());
} catch (e) {
refreshEngineStatus();
}
}
async function loadEngineConfig() {
try {
const r = await fetch(`${CONFIG.ZHONGTAI_BASE}/sparkle-engine/api/config`, {
signal: AbortSignal.timeout(4000),
});
if (!r.ok) return;
const cfg = await r.json();
if (cfg.expert_slots != null && engineCurrentSlots == null) {
engineCurrentSlots = cfg.expert_slots;
engineEls.slots.value = cfg.expert_slots;
}
if (cfg.k != null) engineEls.k.value = cfg.k;
if (cfg.max_tokens != null) engineEls.maxTokens.value = cfg.max_tokens;
updateSlotsMem();
} catch (e) {}
}
async function postEngineParams(params) {
try {
const r = await fetch(`${CONFIG.ZHONGTAI_BASE}/sparkle-engine/api/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
setEngineNote(r.ok ? '参数已应用。' : `应用失败: HTTP ${r.status}`);
} catch (e) {
setEngineNote('应用失败:引擎不可达。');
}
}
engineEls.btnLoad?.addEventListener('click', async () => {
renderEngineState('loading', '加载中...');
setEngineNote('正在启动引擎...');
let data = null;
try {
const r = await fetch(`${CONFIG.ZHONGTAI_BASE}/sparkle-engine/start`, { method: 'POST' });
data = r.ok ? await r.json() : null;
} catch (e) {
renderEngineState('zhongtai_down', '中台未启动,引擎管理不可用');
setEngineNote('');
return;
}
if (data && data.ok) {
setEngineNote('引擎加载中,约需 90 秒。');
} else if (data && data.error === 'not_installed') {
renderEngineState('not_installed', '未检测到本地引擎安装');
setEngineNote('引擎为可选组件,未安装不影响其他模型源的使用。');
return;
} else {
setEngineNote(`启动失败: ${(data && data.error) || '中台无响应'}`);
}
if (!enginePollTimer) enginePollTimer = setInterval(refreshEngineStatus, 3000);
});
engineEls.btnUnload?.addEventListener('click', async () => {
engineEls.btnUnload.disabled = true;
setEngineNote('正在停止引擎...');
try {
const r = await fetch(`${CONFIG.ZHONGTAI_BASE}/sparkle-engine/stop`, { method: 'POST' });
const data = r.ok ? await r.json() : null;
setEngineNote(data && data.ok ? '引擎已停止。' : `停止失败: ${(data && data.error) || '中台无响应'}`);
} catch (e) {
setEngineNote('中台未启动,无法停止引擎。');
}
stopEngineTimers();
refreshEngineStatus();
});
engineEls.slots?.addEventListener('input', updateSlotsMem);
engineEls.apply?.addEventListener('click', async () => {
const newSlots = parseInt(engineEls.slots.value, 10);
const kVal = parseInt(engineEls.k.value, 10);
const mtVal = parseInt(engineEls.maxTokens.value, 10);
const slotsChanged = engineCurrentSlots != null && newSlots !== engineCurrentSlots;
engineEls.apply.disabled = true;
if (slotsChanged) {
try {
const r = await fetch(`${CONFIG.ZHONGTAI_BASE}/sparkle-engine/restart`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expert_slots: newSlots }),
});
const data = r.ok ? await r.json().catch(() => null) : null;
if (data && data.ok) {
pendingParamConfig = { k: kVal, max_tokens: mtVal };
engineCurrentSlots = data.expert_slots != null ? data.expert_slots : newSlots;
renderEngineState('loading', '加载中...');
setEngineNote('引擎重启中,约需 90 秒。');
if (!enginePollTimer) enginePollTimer = setInterval(refreshEngineStatus, 3000);
} else {
setEngineNote(`重启失败: ${(data && data.error) || `HTTP ${r.status}`}`);
}
} catch (e) {
setEngineNote('重启失败:中台不可达。');
}
} else {
await postEngineParams({ k: kVal, max_tokens: mtVal });
}
engineEls.apply.disabled = engineEls.slots.disabled;
});
updateSlotsMem();
refreshEngineStatus();
overlay.querySelector('.dialog-close').addEventListener('click', () => close(false));
overlay.querySelector('.btn-cancel').addEventListener('click', () => close(false));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(false);
});
overlay.querySelector('.btn-save').addEventListener('click', async () => {
const defaultSelect = overlay.querySelector('#cfg-default-source');
if (defaultSelect) {
saveDefaultSource(defaultSelect.value);
}
for (const src of ['ollama', 'omlx', 'sparkle', 'anthropic']) {
const sel = overlay.querySelector(`#cfg-threshold-${src}`);
if (sel) {
saveContextThreshold(src, parseInt(sel.value, 10));
}
}
const newOllama = {
baseUrl: val('cfg-ollama-url'),
model: val('cfg-ollama-model'),
};
const newOmlx = {
baseUrl: val('cfg-omlx-url'),
apiKey: val('cfg-omlx-key'),
model: val('cfg-omlx-model'),
};
const newSparkle = {
baseUrl: val('cfg-sparkle-url'),
apiKey: val('cfg-sparkle-key'),
model: val('cfg-sparkle-model'),
};
const tierChecked = overlay.querySelector('input[name="tier"]:checked');
const protocolSelect = overlay.querySelector('#cfg-anthropic-protocol-select');
const presetSelect = overlay.querySelector('#cfg-anthropic-preset-select');
const protocolChecked = protocolSelect ? { value: protocolSelect.value } : null;
const presetChecked = presetSelect ? { value: presetSelect.value } : null;
const newAnthropic = {
baseUrl: val('cfg-anthropic-url'),
authToken: val('cfg-anthropic-token'),
protocol: protocolChecked ? protocolChecked.value : 'anthropic',
preset: presetChecked ? presetChecked.value : 'custom',
fastModel: val('cfg-anthropic-fast'),
standardModel: val('cfg-anthropic-standard'),
proModel: val('cfg-anthropic-pro'),
currentTier: tierChecked ? tierChecked.value : 'fast',
apiVersion: val('cfg-anthropic-version') || '2023-06-01',
maxTokens: parseInt(val('cfg-anthropic-maxtokens'), 10) || 4096,
presets: (() => {
const saved = { ...(anthropic.presets || {}) };
saved[presetChecked?.value || 'custom'] = {
authToken: val('cfg-anthropic-token'),
fastModel: val('cfg-anthropic-fast'),
standardModel: val('cfg-anthropic-standard'),
proModel: val('cfg-anthropic-pro'),
currentTier: tierChecked ? tierChecked.value : 'fast',
};
return saved;
})(),
};
if (ollama.model && newOllama.model && ollama.model !== newOllama.model) {
const baseUrl = (ollama.baseUrl || '').replace(/\/+$/, '');
if (baseUrl) {
try {
await fetch(`${baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: ollama.model, keep_alive: 0 }),
});
console.log(`[config] 已卸载 Ollama 旧模型: ${ollama.model}`);
} catch (e) {}
}
}
if (omlx.model && newOmlx.model && omlx.model !== newOmlx.model) {
const baseUrl = (newOmlx.baseUrl || omlx.baseUrl || '').replace(/\/+$/, '');
const apiKey = newOmlx.apiKey || omlx.apiKey || '';
if (baseUrl && apiKey) {
try {
const r = await fetch(`${baseUrl}/v1/models/${encodeURIComponent(omlx.model)}/unload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` },
});
if (r.ok) {
console.log(`[config] 已卸载 oMLX 旧模型: ${omlx.model}`);
}
} catch (e) {}
}
}
saveOllamaConfig(newOllama);
saveOmlxConfig(newOmlx);
saveSparkleConfig(newSparkle);
saveAnthropicConfig(newAnthropic);
close(true);
});
function val(id) {
const el = document.getElementById(id);
return el ? el.value.trim() : '';
}
});
}
function escape(s) {
if (s == null) return '';
return String(s).replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}