890 lines
43 KiB
TypeScript
890 lines
43 KiB
TypeScript
// @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, '"').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|