196 lines
6.1 KiB
TypeScript
196 lines
6.1 KiB
TypeScript
// @ts-nocheck
|
|
|
|
|
|
import { unloadAllLocalModels, probeLocalModelsLoaded } from '../utils/model-manager.js';
|
|
import { log as auditLog } from '../utils/audit-log.js';
|
|
|
|
let _dialogOpen = false;
|
|
let _allowLeave = false;
|
|
|
|
export function showLeaveUnloadDialog({ ollama = 0, omlx = 0, sparkle = 0 } = {}) {
|
|
if (_dialogOpen) return Promise.resolve(null);
|
|
_dialogOpen = true;
|
|
|
|
return new Promise((resolve) => {
|
|
const parts = [];
|
|
if (ollama > 0) parts.push(`Ollama ${ollama} 个`);
|
|
if (omlx > 0) parts.push(`oMLX ${omlx} 个`);
|
|
if (sparkle > 0) parts.push(`Sparkle ${sparkle} 个`);
|
|
const loadedHint = parts.length
|
|
? `当前检测到已加载: ${parts.join(' + ')}。`
|
|
: '关闭后本地模型可能仍占用统一内存。';
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'dialog-overlay';
|
|
overlay.innerHTML = `
|
|
<div class="dialog leave-dialog" role="dialog" aria-modal="true" aria-labelledby="leave-dialog-title">
|
|
<div class="dialog-header">
|
|
<h2 id="leave-dialog-title">离开前确认</h2>
|
|
</div>
|
|
<div class="dialog-body">
|
|
<p class="leave-dialog-lead">是否卸载本地模型以释放内存?</p>
|
|
<p class="leave-dialog-hint">${escapeHtml(loadedHint)}</p>
|
|
<p class="leave-dialog-hint">卸载后下次对话需重新加载(首次响应会慢);保留则可立即继续对话。</p>
|
|
</div>
|
|
<div class="dialog-footer leave-dialog-footer">
|
|
<button type="button" class="btn-cancel" data-choice="cancel">取消</button>
|
|
<button type="button" class="btn-keep" data-choice="keep">保留并离开</button>
|
|
<button type="button" class="btn-save" data-choice="unload">卸载并离开</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(overlay);
|
|
document.body.classList.add('modal-open');
|
|
|
|
const finish = (choice) => {
|
|
overlay.remove();
|
|
document.body.classList.remove('modal-open');
|
|
_dialogOpen = false;
|
|
resolve(choice);
|
|
};
|
|
|
|
overlay.querySelectorAll('[data-choice]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const c = btn.dataset.choice;
|
|
if (c === 'unload') finish('unload');
|
|
else if (c === 'keep') finish('keep');
|
|
else finish(null);
|
|
});
|
|
});
|
|
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) finish(null);
|
|
});
|
|
|
|
const onKey = (e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
window.removeEventListener('keydown', onKey, true);
|
|
finish(null);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey, true);
|
|
});
|
|
}
|
|
|
|
export async function requestLeave(action = 'reload') {
|
|
if (_allowLeave) return true;
|
|
|
|
let probe = { loaded: false, ollama: 0, omlx: 0, sparkle: 0 };
|
|
try {
|
|
probe = await probeLocalModelsLoaded();
|
|
} catch (e) {}
|
|
|
|
|
|
if (!probe.loaded) {
|
|
_allowLeave = true;
|
|
performLeave(action);
|
|
return true;
|
|
}
|
|
|
|
const choice = await showLeaveUnloadDialog(probe);
|
|
if (choice == null) return false;
|
|
|
|
if (choice === 'unload') {
|
|
try {
|
|
const r = await unloadAllLocalModels();
|
|
auditLog('system', `离开前卸载本地模型: Ollama ${r.ollama} / oMLX ${r.omlx} / Sparkle ${r.sparkle}`);
|
|
} catch (e) {
|
|
auditLog('error', `离开前卸载失败: ${e.message}`);
|
|
}
|
|
} else {
|
|
auditLog('system', '离开时保留本地模型');
|
|
}
|
|
|
|
_allowLeave = true;
|
|
performLeave(action);
|
|
return true;
|
|
}
|
|
|
|
function performLeave(action) {
|
|
if (action === 'close') {
|
|
|
|
window.close();
|
|
|
|
return;
|
|
}
|
|
location.reload();
|
|
}
|
|
|
|
export async function confirmClose() {
|
|
if (_allowLeave) return { allow: true, unloaded: false };
|
|
|
|
let probe = { loaded: false, ollama: 0, omlx: 0, sparkle: 0 };
|
|
try {
|
|
probe = await probeLocalModelsLoaded();
|
|
} catch (e) {}
|
|
|
|
if (!probe.loaded) {
|
|
_allowLeave = true;
|
|
return { allow: true, unloaded: false };
|
|
}
|
|
|
|
const choice = await showLeaveUnloadDialog(probe);
|
|
if (choice == null) return { allow: false, unloaded: false };
|
|
|
|
let unloaded = false;
|
|
if (choice === 'unload') {
|
|
try {
|
|
await unloadAllLocalModels();
|
|
unloaded = true;
|
|
auditLog('system', '窗口关闭前已卸载本地模型');
|
|
} catch (e) {
|
|
auditLog('error', `窗口关闭前卸载失败: ${e.message}`);
|
|
}
|
|
} else {
|
|
auditLog('system', '窗口关闭时保留本地模型');
|
|
}
|
|
|
|
_allowLeave = true;
|
|
return { allow: true, unloaded };
|
|
}
|
|
|
|
export function installLeaveGuards() {
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
const isRefresh = e.key === 'F5' || ((e.metaKey || e.ctrlKey) && (e.key === 'r' || e.key === 'R'));
|
|
if (!isRefresh) return;
|
|
if (_dialogOpen || _allowLeave) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
requestLeave('reload');
|
|
}, true);
|
|
|
|
|
|
|
|
window.addEventListener('beforeunload', (e) => {
|
|
if (_allowLeave || _dialogOpen) return;
|
|
|
|
|
|
try {
|
|
const src = localStorage.getItem('ccsparkle_model_source') || '';
|
|
if (src === 'ollama' || src === 'omlx' || src === 'sparkle') {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
|
|
|
|
window.ccSparkleApp = Object.assign(window.ccSparkleApp || {}, {
|
|
confirmClose,
|
|
requestLeave,
|
|
showLeaveUnloadDialog,
|
|
});
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
if (s == null) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|