Agent/ui/leave-confirm.ts
2026-08-06 22:17:39 +08:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}