fix(local-ai): show startup progress heartbeat

This commit is contained in:
NgoQuocViet2001
2026-05-23 18:14:48 +07:00
parent 0bc974433d
commit cf86f9f029
5 changed files with 54 additions and 6 deletions
+29 -1
View File
@@ -9,6 +9,7 @@ const {
pickBinaryAssetForPlatform,
} = require('./localInferenceAssets');
const {
formatStartupProgressMessage,
parseGenerationProgressChunk,
resolveGenerationSteps,
resolveGuidanceScale,
@@ -481,7 +482,30 @@ async function generate(params, mainWindow) {
}
return new Promise((resolve, reject) => {
send({ step: 0, totalSteps: steps, status: 'starting', progress: 0 });
const startupStartedAt = Date.now();
let startupHeartbeat = null;
let samplingStarted = false;
const sendStartupProgress = () => {
send({
step: 0,
totalSteps: steps,
status: 'starting',
progress: 0,
message: formatStartupProgressMessage(Date.now() - startupStartedAt),
});
};
const stopStartupHeartbeat = () => {
if (startupHeartbeat) {
clearInterval(startupHeartbeat);
startupHeartbeat = null;
}
};
sendStartupProgress();
startupHeartbeat = setInterval(() => {
if (!samplingStarted) sendStartupProgress();
}, 5000);
console.log('[sd-cli] command:', BINARY_PATH, args.join(' '));
// DYLD_LIBRARY_PATH lets macOS find libstable-diffusion.dylib next to sd-cli
@@ -495,6 +519,8 @@ async function generate(params, mainWindow) {
outputLines.push(line.trimEnd());
const progressEvents = parseGenerationProgressChunk(line, progressState);
for (const event of progressEvents) {
samplingStarted = true;
stopStartupHeartbeat();
send({ ...event, status: 'generating' });
}
};
@@ -503,6 +529,7 @@ async function generate(params, mainWindow) {
activeProcess.stderr.on('data', handleOutput);
activeProcess.on('close', (code) => {
stopStartupHeartbeat();
activeProcess = null;
const allOutput = outputLines.filter(l => l.trim()).join('\n');
console.error('[sd-cli] full output:\n' + allOutput);
@@ -527,6 +554,7 @@ async function generate(params, mainWindow) {
});
activeProcess.on('error', (err) => {
stopStartupHeartbeat();
activeProcess = null;
reject(err);
});
+11
View File
@@ -28,6 +28,16 @@ function resolveGuidanceScale(params, model) {
return 7.5;
}
function formatStartupProgressMessage(elapsedMs) {
const seconds = Math.max(0, Math.floor((Number(elapsedMs) || 0) / 1000));
if (seconds < 10) return 'Starting local model...';
if (seconds < 60) return `Loading local model (${seconds}s)...`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `Loading local model (${minutes}m ${remainingSeconds}s)...`;
}
function stripAnsiSequences(text) {
return text.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '');
}
@@ -86,6 +96,7 @@ function parseGenerationProgressChunk(chunk, state = { tail: '', lastStep: 0, la
module.exports = {
coerceFiniteNumber,
extractProgressEvents,
formatStartupProgressMessage,
parseGenerationProgressChunk,
resolveGenerationSteps,
resolveGuidanceScale,
+4 -3
View File
@@ -1183,11 +1183,12 @@ export function ImageStudio() {
progressWrap.classList.remove('hidden');
progressWrap.classList.add('flex');
const unsub = localAI.onProgress(({ progress, status }) => {
const unsub = localAI.onProgress(({ progress, status, message }) => {
const pct = Math.round((progress ?? 0) * 100);
const label = message || (status === 'starting' ? 'Starting...' : `${pct}%`);
if (progressFill) progressFill.style.width = `${pct}%`;
if (progressPct) progressPct.textContent = status === 'starting' ? 'Starting...' : `${pct}%`;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> ${status === 'starting' ? '...' : pct + '%'}`;
if (progressPct) progressPct.textContent = label;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> ${label}`;
});
let hadError = false;
+3 -2
View File
@@ -1133,9 +1133,10 @@ export function VideoStudio() {
// For local generations, surface step progress in the button label.
let unsubscribeProgress = null;
if (isLocal) {
unsubscribeProgress = localAI.onProgress(({ status, progress }) => {
unsubscribeProgress = localAI.onProgress(({ status, progress, message }) => {
const pct = typeof progress === 'number' ? Math.round(progress * 100) : null;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> ${status || t('common.generating')}${pct != null ? ` ${pct}%` : ''}`;
const label = message || `${status || t('common.generating')}${pct != null ? ` ${pct}%` : '...'}`;
generateBtn.innerHTML = `<span class="animate-spin inline-block mr-2 text-black">◌</span> ${label}`;
});
}
+7
View File
@@ -2,6 +2,7 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const {
formatStartupProgressMessage,
parseGenerationProgressChunk,
resolveGenerationSteps,
resolveGuidanceScale,
@@ -23,6 +24,12 @@ test('stripAnsiSequences removes terminal control codes', () => {
assert.equal(cleaned, 'hello');
});
test('formatStartupProgressMessage surfaces elapsed startup time', () => {
assert.equal(formatStartupProgressMessage(0), 'Starting local model...');
assert.equal(formatStartupProgressMessage(12_400), 'Loading local model (12s)...');
assert.equal(formatStartupProgressMessage(65_000), 'Loading local model (1m 5s)...');
});
test('parseGenerationProgressChunk extracts sd-cli sampling progress from carriage-return output', () => {
const state = { tail: '', lastStep: 0, lastTotalSteps: 0 };
const chunk = '\r |==> | 1/20 - 7.16s/it\u001b[K\r |=====> | 2/20 - 6.98s/it\u001b[K';