fix: show Laya model download progress and surface errors in Settings (#12)
Track download progress in layaService (bytes/total, phase, error state) and expose it through the laya status route. Render a progress bar during model download in Settings via a layaStatusPresentation service, and display download/status errors inline instead of failing silently. Cover progress tracking and status presentation with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -7,6 +7,7 @@ const MAX_BATCH_ITEMS = 64;
|
||||
const MAX_BATCH_BYTES = 64 * 1024;
|
||||
const MAX_TEXT_LENGTH = 4096;
|
||||
const ALLOWED_MODELS = new Set(['english', 'multilingual', 'typed-decisions']);
|
||||
const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
class LayaRequestError extends Error {
|
||||
constructor(message, statusCode = 503) {
|
||||
@ -62,6 +63,46 @@ async function clearDirectoryContents(directory) {
|
||||
}
|
||||
}
|
||||
|
||||
async function directorySize(directory) {
|
||||
let total = 0;
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return 0;
|
||||
throw error;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) total += await directorySize(target);
|
||||
else if (entry.isFile()) total += (await fs.stat(target)).size;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function safeError(error) {
|
||||
const statusCode = error instanceof LayaRequestError ? error.statusCode : 500;
|
||||
const message = statusCode === 500 ? 'Model download failed' : error.message;
|
||||
return {
|
||||
message,
|
||||
detail: statusCode === 503
|
||||
? 'The optional Laya runtime is unavailable.'
|
||||
: `Laya model operation failed (HTTP ${statusCode}).`
|
||||
};
|
||||
}
|
||||
|
||||
function initialDownloadState() {
|
||||
return {
|
||||
phase: 'idle',
|
||||
downloading: false,
|
||||
progressPercent: null,
|
||||
bytesDownloaded: null,
|
||||
totalBytes: null,
|
||||
error: null,
|
||||
lastError: null
|
||||
};
|
||||
}
|
||||
|
||||
class LayaService {
|
||||
constructor(options = {}) {
|
||||
this.baseUrl = options.baseUrl || process.env.LAYA_BASE_URL || 'http://laya:8000';
|
||||
@ -71,18 +112,23 @@ class LayaService {
|
||||
? require('../db/sqlite').layaLanguageCache
|
||||
: options.cache;
|
||||
this.timeoutMs = options.timeoutMs || 3000;
|
||||
this.downloadTimeoutMs = options.downloadTimeoutMs || DOWNLOAD_TIMEOUT_MS;
|
||||
this.progressIntervalMs = options.progressIntervalMs || 500;
|
||||
this.downloadState = initialDownloadState();
|
||||
this.downloadPromise = null;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
if (typeof this.fetch !== 'function') throw new LayaRequestError('Laya runtime client is unavailable');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
const { timeoutMs = this.timeoutMs, ...fetchOptions } = options;
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
if (process.env.LAYA_API_KEY) headers.authorization = `Bearer ${process.env.LAYA_API_KEY}`;
|
||||
try {
|
||||
const response = await this.fetch(`${this.baseUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: { ...headers, ...options.headers },
|
||||
...fetchOptions,
|
||||
headers: { ...headers, ...fetchOptions.headers },
|
||||
signal: controller.signal
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
@ -98,39 +144,110 @@ class LayaService {
|
||||
|
||||
async status() {
|
||||
await fs.mkdir(this.modelDir, { recursive: true });
|
||||
const modelPresent = (await fs.readdir(this.modelDir)).length > 0;
|
||||
const sizeBytes = await directorySize(this.modelDir);
|
||||
const modelPresent = sizeBytes > 0;
|
||||
if (this.downloadState.downloading) this.downloadState.bytesDownloaded = sizeBytes;
|
||||
try {
|
||||
const runtime = await this.request('/health', { method: 'GET' });
|
||||
return { runtimeAvailable: true, modelPresent, runtime };
|
||||
if (!this.downloadState.downloading && this.downloadState.phase !== 'failed' && modelPresent) {
|
||||
this.downloadState.phase = 'ready';
|
||||
}
|
||||
return { ...this.downloadState, runtimeAvailable: true, modelPresent, sizeBytes, runtime };
|
||||
} catch (error) {
|
||||
return { runtimeAvailable: false, modelPresent, error: error.message };
|
||||
return { ...this.downloadState, runtimeAvailable: false, modelPresent, sizeBytes };
|
||||
}
|
||||
}
|
||||
|
||||
startModelDownload(model = 'multilingual') {
|
||||
if (!ALLOWED_MODELS.has(model)) throw new LayaRequestError('Unsupported Laya model', 400);
|
||||
if (this.downloadState.downloading) throw new LayaRequestError('A Laya model download is already running', 409);
|
||||
this.downloadState = {
|
||||
...initialDownloadState(),
|
||||
phase: 'preparing',
|
||||
downloading: true,
|
||||
bytesDownloaded: 0
|
||||
};
|
||||
this.downloadPromise = this.downloadModel(model).catch(() => undefined);
|
||||
return { ...this.downloadState };
|
||||
}
|
||||
|
||||
async downloadModel(model = 'multilingual') {
|
||||
if (!ALLOWED_MODELS.has(model)) throw new LayaRequestError('Unsupported Laya model', 400);
|
||||
await fs.mkdir(this.modelDir, { recursive: true });
|
||||
await this.request('/v1/systemone', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
state: 'Bonjour',
|
||||
model,
|
||||
questions: {
|
||||
language: {
|
||||
type: 'choice',
|
||||
instructions: 'Identify the language of this text.',
|
||||
criteria: { fr: 'French', en: 'English', es: 'Spanish', other: 'Another language' }
|
||||
this.downloadState.phase = 'downloading';
|
||||
const progressTimer = setInterval(() => {
|
||||
directorySize(this.modelDir)
|
||||
.then(size => { this.downloadState.bytesDownloaded = size; })
|
||||
.catch(() => {});
|
||||
}, this.progressIntervalMs);
|
||||
progressTimer.unref?.();
|
||||
try {
|
||||
await this.request('/v1/systemone', {
|
||||
method: 'POST',
|
||||
timeoutMs: this.downloadTimeoutMs,
|
||||
body: JSON.stringify({
|
||||
state: 'Bonjour',
|
||||
model,
|
||||
questions: {
|
||||
language: {
|
||||
type: 'choice',
|
||||
instructions: 'Identify the language of this text.',
|
||||
criteria: { fr: 'French', en: 'English', es: 'Spanish', other: 'Another language' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
return this.status();
|
||||
})
|
||||
});
|
||||
this.downloadState.phase = 'verifying';
|
||||
this.downloadState.bytesDownloaded = await directorySize(this.modelDir);
|
||||
const status = await this.status();
|
||||
this.downloadState = {
|
||||
...this.downloadState,
|
||||
phase: 'ready',
|
||||
downloading: false,
|
||||
error: null,
|
||||
lastError: null
|
||||
};
|
||||
return { ...status, ...this.downloadState };
|
||||
} catch (error) {
|
||||
const visibleError = safeError(error);
|
||||
this.downloadState = {
|
||||
...this.downloadState,
|
||||
phase: 'failed',
|
||||
downloading: false,
|
||||
error: visibleError,
|
||||
lastError: visibleError,
|
||||
bytesDownloaded: await directorySize(this.modelDir)
|
||||
};
|
||||
throw error;
|
||||
} finally {
|
||||
clearInterval(progressTimer);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteModel() {
|
||||
await clearDirectoryContents(this.modelDir);
|
||||
if (this.cache) this.cache.clear();
|
||||
return { deleted: true, modelPresent: false };
|
||||
if (this.downloadState.downloading) throw new LayaRequestError('Cannot delete a model while downloading', 409);
|
||||
this.downloadState = { ...initialDownloadState(), phase: 'deleting' };
|
||||
try {
|
||||
await clearDirectoryContents(this.modelDir);
|
||||
if (this.cache) this.cache.clear();
|
||||
this.downloadState = initialDownloadState();
|
||||
return {
|
||||
...this.downloadState,
|
||||
deleted: true,
|
||||
modelPresent: false,
|
||||
runtimeAvailable: false,
|
||||
sizeBytes: 0
|
||||
};
|
||||
} catch (error) {
|
||||
const visibleError = safeError(error);
|
||||
this.downloadState = {
|
||||
...this.downloadState,
|
||||
phase: 'failed',
|
||||
error: visibleError,
|
||||
lastError: visibleError
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey(text, mode, model) {
|
||||
@ -191,5 +308,7 @@ module.exports = {
|
||||
LayaService,
|
||||
MODEL_DIR,
|
||||
clearDirectoryContents,
|
||||
directorySize,
|
||||
initialDownloadState,
|
||||
validateBatchPayload
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user