Merge branch 'fix/t12-laya-download-progress' into develop (#12)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -776,6 +776,60 @@ h4 {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.laya-progress {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.laya-progress-track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.laya-progress-bar {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--color-accent);
|
||||
transition: width var(--transition-fast);
|
||||
}
|
||||
|
||||
.laya-progress-track.indeterminate .laya-progress-bar {
|
||||
width: 35%;
|
||||
animation: laya-progress-indeterminate 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes laya-progress-indeterminate {
|
||||
from { transform: translateX(-120%); }
|
||||
to { transform: translateX(360%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.laya-progress-track.indeterminate .laya-progress-bar {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
opacity: .45;
|
||||
}
|
||||
}
|
||||
|
||||
.laya-details-toggle {
|
||||
margin-top: var(--space-sm);
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.laya-error-details {
|
||||
margin-top: var(--space-xs);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-muted);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.laya-model-actions {
|
||||
justify-content: flex-start;
|
||||
margin: var(--space-md) 0;
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<title>nodecast-tv | IPTV Player</title>
|
||||
<meta name="description" content="Stream live TV, movies, and series with EPG support">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="css/main.css?v=3">
|
||||
<link rel="stylesheet" href="css/main.css?v=4">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@800&display=swap"
|
||||
@ -489,9 +489,18 @@
|
||||
<span id="laya-model-size" class="setting-hint"></span>
|
||||
</div>
|
||||
<div id="laya-model-message" class="setting-hint"></div>
|
||||
<div id="laya-download-progress" class="laya-progress" hidden>
|
||||
<div id="laya-progress-track" class="laya-progress-track" role="progressbar" aria-label="Local AI model download progress">
|
||||
<span id="laya-progress-bar" class="laya-progress-bar"></span>
|
||||
</div>
|
||||
<div id="laya-progress-text" class="setting-hint"></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost laya-details-toggle" id="laya-show-details" aria-expanded="false" hidden>Show details</button>
|
||||
<pre id="laya-error-details" class="laya-error-details" hidden></pre>
|
||||
<div class="laya-model-actions">
|
||||
<button type="button" class="btn btn-primary" id="laya-download-model">Download model</button>
|
||||
<button type="button" class="btn btn-ghost" id="laya-remove-model">Remove downloaded model</button>
|
||||
<button type="button" class="btn btn-ghost" id="laya-use-standard" hidden>Use Standard</button>
|
||||
</div>
|
||||
<p class="setting-hint">The model runs on this server. Channel names and metadata are not sent to an AI service.</p>
|
||||
</div>
|
||||
@ -1171,6 +1180,7 @@
|
||||
<script src="/js/components/VideoPlayer.js?v=5"></script>
|
||||
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
||||
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
|
||||
<script src="/js/services/layaStatusPresentation.js?v=1"></script>
|
||||
<script src="/js/services/channelVariantGrouping.js?v=4"></script>
|
||||
<script src="/js/components/ChannelList.js?v=7"></script>
|
||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
||||
@ -1180,7 +1190,7 @@
|
||||
<script src="/js/pages/Guide.js?v=2"></script>
|
||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||
<script src="/js/pages/Settings.js?v=4"></script>
|
||||
<script src="/js/pages/Settings.js?v=5"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=1"></script>
|
||||
<script src="/js/app.js?v=4"></script>
|
||||
<script>
|
||||
|
||||
@ -125,6 +125,14 @@ class SettingsPage {
|
||||
this.runLayaModelAction('remove');
|
||||
}
|
||||
});
|
||||
document.getElementById('laya-use-standard')?.addEventListener('click', () => this.useStandardLanguageDetection());
|
||||
document.getElementById('laya-show-details')?.addEventListener('click', () => {
|
||||
const details = document.getElementById('laya-error-details');
|
||||
const button = document.getElementById('laya-show-details');
|
||||
const expanded = button.getAttribute('aria-expanded') === 'true';
|
||||
button.setAttribute('aria-expanded', String(!expanded));
|
||||
details.hidden = expanded;
|
||||
});
|
||||
void this.loadLayaStatus();
|
||||
}
|
||||
|
||||
@ -135,6 +143,7 @@ class SettingsPage {
|
||||
this.layaStatus = { runtimeAvailable: false, modelPresent: false, error: err.message };
|
||||
}
|
||||
this.renderLayaStatus(this.layaStatus);
|
||||
if (this.layaStatus?.downloading) this.startLayaStatusPolling();
|
||||
return this.layaStatus;
|
||||
}
|
||||
|
||||
@ -144,25 +153,56 @@ class SettingsPage {
|
||||
const messageElement = document.getElementById('laya-model-message');
|
||||
const downloadButton = document.getElementById('laya-download-model');
|
||||
const removeButton = document.getElementById('laya-remove-model');
|
||||
const useStandardButton = document.getElementById('laya-use-standard');
|
||||
const progress = document.getElementById('laya-download-progress');
|
||||
const progressTrack = document.getElementById('laya-progress-track');
|
||||
const progressBar = document.getElementById('laya-progress-bar');
|
||||
const progressText = document.getElementById('laya-progress-text');
|
||||
const detailsButton = document.getElementById('laya-show-details');
|
||||
const details = document.getElementById('laya-error-details');
|
||||
if (!statusElement) return;
|
||||
|
||||
const modelPresent = status.modelPresent === true;
|
||||
const runtimeAvailable = status.runtimeAvailable === true;
|
||||
statusElement.textContent = modelPresent
|
||||
? (runtimeAvailable ? 'Model ready' : 'Model downloaded · runtime unavailable')
|
||||
: (runtimeAvailable ? 'Model not downloaded' : 'Local AI runtime unavailable');
|
||||
const size = status.modelSizeBytes || status.sizeBytes || status.runtime?.modelSizeBytes;
|
||||
sizeElement.textContent = size ? this.formatBytes(size) : '';
|
||||
downloadButton.hidden = modelPresent;
|
||||
removeButton.hidden = !modelPresent;
|
||||
downloadButton.disabled = Boolean(this.layaOperationPending);
|
||||
removeButton.disabled = Boolean(this.layaOperationPending);
|
||||
|
||||
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||
if (this.layaOperationPending) messageElement.textContent = this.layaOperationPending;
|
||||
else if (mode !== 'algo' && (!modelPresent || !runtimeAvailable)) {
|
||||
const presentation = LayaStatusPresentation.presentLayaStatus(status, mode);
|
||||
statusElement.textContent = presentation.statusLabel;
|
||||
const size = status.sizeBytes || status.runtime?.modelSizeBytes;
|
||||
sizeElement.textContent = size ? this.formatBytes(size) : '';
|
||||
downloadButton.hidden = !presentation.showDownload;
|
||||
downloadButton.textContent = presentation.downloadLabel;
|
||||
removeButton.hidden = !presentation.showRemove;
|
||||
useStandardButton.hidden = !presentation.showUseStandard;
|
||||
downloadButton.disabled = presentation.busy || Boolean(this.layaOperationPending);
|
||||
removeButton.disabled = presentation.busy || Boolean(this.layaOperationPending);
|
||||
|
||||
progress.hidden = !presentation.showProgress;
|
||||
progressTrack.classList.toggle('indeterminate', presentation.progressIndeterminate);
|
||||
if (presentation.progressPercent !== null) {
|
||||
progressTrack.setAttribute('aria-valuemin', '0');
|
||||
progressTrack.setAttribute('aria-valuemax', '100');
|
||||
progressTrack.setAttribute('aria-valuenow', String(presentation.progressPercent));
|
||||
progressBar.style.width = `${presentation.progressPercent}%`;
|
||||
progressText.textContent = `${presentation.progressPercent}%${presentation.bytesDownloaded !== null ? ` · ${this.formatBytes(presentation.bytesDownloaded)} downloaded` : ''}`;
|
||||
progressTrack.setAttribute('aria-valuetext', progressText.textContent);
|
||||
} else {
|
||||
progressTrack.removeAttribute('aria-valuemin');
|
||||
progressTrack.removeAttribute('aria-valuemax');
|
||||
progressTrack.removeAttribute('aria-valuenow');
|
||||
progressBar.style.width = '';
|
||||
progressText.textContent = presentation.bytesDownloaded !== null
|
||||
? `${this.formatBytes(presentation.bytesDownloaded)} downloaded`
|
||||
: 'Downloading…';
|
||||
progressTrack.setAttribute('aria-valuetext', progressText.textContent);
|
||||
}
|
||||
|
||||
detailsButton.hidden = !presentation.errorDetail;
|
||||
detailsButton.setAttribute('aria-expanded', 'false');
|
||||
details.hidden = true;
|
||||
details.textContent = presentation.errorDetail;
|
||||
if (presentation.errorMessage) messageElement.textContent = presentation.errorMessage;
|
||||
else if (this.layaOperationPending) messageElement.textContent = this.layaOperationPending;
|
||||
else if (mode !== 'algo' && (!status.modelPresent || !status.runtimeAvailable)) {
|
||||
messageElement.textContent = 'Local AI is not ready. Download the model and ensure the runtime is running. Live TV will remain available.';
|
||||
} else messageElement.textContent = status.error || '';
|
||||
} else messageElement.textContent = '';
|
||||
}
|
||||
|
||||
formatBytes(bytes) {
|
||||
@ -176,24 +216,56 @@ class SettingsPage {
|
||||
async runLayaModelAction(action) {
|
||||
if (this.layaOperationPending) return;
|
||||
this.layaOperationPending = action === 'download' ? 'Downloading model…' : 'Removing model…';
|
||||
this.layaStatus = {
|
||||
...(this.layaStatus || {}),
|
||||
phase: action === 'download' ? 'preparing' : 'deleting',
|
||||
downloading: action === 'download'
|
||||
};
|
||||
this.renderLayaStatus(this.layaStatus);
|
||||
const poll = setInterval(() => void this.loadLayaStatus(), 2000);
|
||||
try {
|
||||
this.layaStatus = action === 'download'
|
||||
? await API.laya.downloadModel('multilingual')
|
||||
: await API.laya.removeModel();
|
||||
this.layaOperationPending = '';
|
||||
this.renderLayaStatus(this.layaStatus);
|
||||
if (this.layaStatus?.downloading) this.startLayaStatusPolling();
|
||||
else await this.loadLayaStatus();
|
||||
} catch (err) {
|
||||
this.layaStatus = { ...(this.layaStatus || {}), error: err.message };
|
||||
} finally {
|
||||
clearInterval(poll);
|
||||
this.layaOperationPending = '';
|
||||
await this.loadLayaStatus();
|
||||
if (this.app.player.settings.languageDetectionMode !== 'algo') {
|
||||
this.app.channelList.setLanguageDetectionMode(this.app.player.settings.languageDetectionMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startLayaStatusPolling() {
|
||||
if (this.layaPollTimer) return;
|
||||
this.layaPollTimer = setInterval(async () => {
|
||||
if (this.layaPollInFlight) return;
|
||||
this.layaPollInFlight = true;
|
||||
try {
|
||||
const status = await this.loadLayaStatus();
|
||||
if (!status?.downloading && ['ready', 'failed', 'idle'].includes(status?.phase)) {
|
||||
clearInterval(this.layaPollTimer);
|
||||
this.layaPollTimer = null;
|
||||
if (status.phase === 'ready' && this.app.player.settings.languageDetectionMode !== 'algo') {
|
||||
this.app.channelList.setLanguageDetectionMode(this.app.player.settings.languageDetectionMode);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.layaPollInFlight = false;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
useStandardLanguageDetection() {
|
||||
const radio = document.querySelector('input[name="language-detection-mode"][value="algo"]');
|
||||
if (radio) radio.checked = true;
|
||||
this.app.player.settings.languageDetectionMode = 'algo';
|
||||
this.app.player.saveSettings();
|
||||
this.app.channelList.setLanguageDetectionMode('algo');
|
||||
this.renderLayaStatus(this.layaStatus);
|
||||
}
|
||||
|
||||
async initTranscodingSettings() {
|
||||
// Encoder settings
|
||||
const hwEncoderSelect = document.getElementById('setting-hw-encoder');
|
||||
|
||||
63
public/js/services/layaStatusPresentation.js
Normal file
63
public/js/services/layaStatusPresentation.js
Normal file
@ -0,0 +1,63 @@
|
||||
(function (root, factory) {
|
||||
const service = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = service;
|
||||
if (root) root.LayaStatusPresentation = service;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const PHASE_LABELS = {
|
||||
idle: 'Model not downloaded',
|
||||
checking: 'Checking local AI model…',
|
||||
downloading: 'Downloading model…',
|
||||
verifying: 'Verifying model…',
|
||||
preparing: 'Preparing download…',
|
||||
ready: 'Model ready',
|
||||
failed: 'Model download failed',
|
||||
deleting: 'Removing model…'
|
||||
};
|
||||
|
||||
function errorParts(status) {
|
||||
const value = status.error || status.lastError;
|
||||
const fallback = status.lastError;
|
||||
if (!value) return { message: '', detail: '' };
|
||||
if (typeof value === 'string') return {
|
||||
message: value,
|
||||
detail: typeof fallback === 'object' ? String(fallback.detail || '') : ''
|
||||
};
|
||||
return {
|
||||
message: String(value.message || 'The local AI model operation failed.'),
|
||||
detail: String(value.detail || '')
|
||||
};
|
||||
}
|
||||
|
||||
function presentLayaStatus(status = {}, mode = 'algo') {
|
||||
const phase = PHASE_LABELS[status.phase] ? status.phase : 'idle';
|
||||
const busy = Boolean(status.downloading) || ['checking', 'downloading', 'verifying', 'preparing', 'deleting'].includes(phase);
|
||||
const percentValue = Number(status.progressPercent);
|
||||
const determined = phase === 'downloading' && status.progressPercent !== null &&
|
||||
status.progressPercent !== undefined && Number.isFinite(percentValue);
|
||||
const progressPercent = determined ? Math.max(0, Math.min(100, Math.round(percentValue))) : null;
|
||||
const error = errorParts(status);
|
||||
const failed = phase === 'failed';
|
||||
return {
|
||||
phase,
|
||||
statusLabel: status.modelPresent && phase === 'idle'
|
||||
? (status.runtimeAvailable ? 'Model ready' : 'Model downloaded · runtime unavailable')
|
||||
: PHASE_LABELS[phase],
|
||||
busy,
|
||||
showProgress: phase === 'downloading',
|
||||
progressIndeterminate: phase === 'downloading' && progressPercent === null,
|
||||
progressPercent,
|
||||
bytesDownloaded: status.bytesDownloaded !== null && status.bytesDownloaded !== undefined && Number.isFinite(Number(status.bytesDownloaded))
|
||||
? Number(status.bytesDownloaded) : null,
|
||||
totalBytes: status.totalBytes !== null && status.totalBytes !== undefined && Number.isFinite(Number(status.totalBytes))
|
||||
? Number(status.totalBytes) : null,
|
||||
errorMessage: failed ? (error.message || 'The local AI model could not be downloaded.') : '',
|
||||
errorDetail: failed ? error.detail : '',
|
||||
downloadLabel: failed ? 'Retry download' : 'Download model',
|
||||
showDownload: !busy && !status.modelPresent && (phase === 'idle' || failed),
|
||||
showRemove: !busy && Boolean(status.modelPresent) && !failed,
|
||||
showUseStandard: failed && mode !== 'algo'
|
||||
};
|
||||
}
|
||||
|
||||
return { presentLayaStatus };
|
||||
});
|
||||
@ -17,7 +17,7 @@ router.get('/status', requireAuth, async (req, res) => {
|
||||
|
||||
router.post('/model/download', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
res.json(await laya.downloadModel(req.body?.model));
|
||||
res.status(202).json(laya.startModelDownload(req.body?.model));
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -20,6 +20,77 @@ test('status reports an unavailable runtime without throwing', async () => {
|
||||
const status = await service.status();
|
||||
assert.equal(status.runtimeAvailable, false);
|
||||
assert.equal(status.modelPresent, false);
|
||||
assert.equal(status.phase, 'idle');
|
||||
assert.equal(status.downloading, false);
|
||||
assert.equal(status.progressPercent, null);
|
||||
assert.equal(status.bytesDownloaded, null);
|
||||
assert.equal(status.totalBytes, null);
|
||||
assert.equal(status.sizeBytes, 0);
|
||||
assert.equal(status.error, null);
|
||||
});
|
||||
|
||||
test('model download exposes an indeterminate transition and finishes ready', async () => {
|
||||
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-download-'));
|
||||
let finishDownload;
|
||||
const downloadResponse = new Promise(resolve => { finishDownload = resolve; });
|
||||
const service = new LayaService({
|
||||
modelDir,
|
||||
cache: null,
|
||||
progressIntervalMs: 5,
|
||||
fetch: async url => {
|
||||
if (url.endsWith('/health')) return { ok: true, json: async () => ({ status: 'ok' }) };
|
||||
await downloadResponse;
|
||||
return { ok: true, json: async () => ({ answers: {} }) };
|
||||
}
|
||||
});
|
||||
|
||||
const started = service.startModelDownload();
|
||||
assert.equal(started.phase, 'preparing');
|
||||
assert.equal(started.downloading, true);
|
||||
assert.equal(started.progressPercent, null);
|
||||
|
||||
await fs.writeFile(path.join(modelDir, 'partial.bin'), 'partial model');
|
||||
await new Promise(resolve => setTimeout(resolve, 15));
|
||||
const downloading = await service.status();
|
||||
assert.equal(downloading.phase, 'downloading');
|
||||
assert.equal(downloading.downloading, true);
|
||||
assert.ok(downloading.bytesDownloaded > 0);
|
||||
|
||||
finishDownload();
|
||||
await service.downloadPromise;
|
||||
const ready = await service.status();
|
||||
assert.equal(ready.phase, 'ready');
|
||||
assert.equal(ready.downloading, false);
|
||||
assert.equal(ready.modelPresent, true);
|
||||
assert.equal(ready.error, null);
|
||||
});
|
||||
|
||||
test('download failures remain visible in status and retry resets the error', async () => {
|
||||
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-failure-'));
|
||||
let shouldFail = true;
|
||||
const service = new LayaService({
|
||||
modelDir,
|
||||
cache: null,
|
||||
fetch: async url => {
|
||||
if (url.endsWith('/health')) throw new Error('connection refused at https://secret.invalid/token');
|
||||
if (shouldFail) throw new Error('download failed at https://secret.invalid/token');
|
||||
return { ok: true, json: async () => ({ answers: {} }) };
|
||||
}
|
||||
});
|
||||
|
||||
service.startModelDownload();
|
||||
await service.downloadPromise;
|
||||
const failed = await service.status();
|
||||
assert.equal(failed.phase, 'failed');
|
||||
assert.equal(failed.downloading, false);
|
||||
assert.equal(failed.error.message, 'Laya runtime is unavailable');
|
||||
assert.doesNotMatch(JSON.stringify(failed), /secret\.invalid|token/);
|
||||
|
||||
shouldFail = false;
|
||||
const retry = service.startModelDownload();
|
||||
assert.equal(retry.error, null);
|
||||
assert.equal(retry.lastError, null);
|
||||
await service.downloadPromise;
|
||||
});
|
||||
|
||||
test('model deletion remains limited to the configured model directory', async () => {
|
||||
@ -30,10 +101,16 @@ test('model deletion remains limited to the configured model directory', async (
|
||||
await fs.writeFile(path.join(modelDir, 'nested', 'model.bin'), 'model');
|
||||
await fs.writeFile(sentinel, 'keep');
|
||||
|
||||
await clearDirectoryContents(modelDir);
|
||||
const service = new LayaService({ modelDir, cache: null, fetch: async () => { throw new Error('offline'); } });
|
||||
service.downloadState.phase = 'failed';
|
||||
service.downloadState.error = { message: 'previous error', detail: 'safe' };
|
||||
const deleted = await service.deleteModel();
|
||||
|
||||
assert.deepEqual(await fs.readdir(modelDir), []);
|
||||
assert.equal(await fs.readFile(sentinel, 'utf8'), 'keep');
|
||||
assert.equal(deleted.phase, 'idle');
|
||||
assert.equal(deleted.error, null);
|
||||
assert.equal(deleted.sizeBytes, 0);
|
||||
});
|
||||
|
||||
test('batch validation rejects oversized item counts', () => {
|
||||
|
||||
43
test/layaStatusPresentation.test.js
Normal file
43
test/layaStatusPresentation.test.js
Normal file
@ -0,0 +1,43 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { presentLayaStatus } = require('../public/js/services/layaStatusPresentation.js');
|
||||
|
||||
test('presents unknown-size downloads as indeterminate with downloaded bytes', () => {
|
||||
const view = presentLayaStatus({
|
||||
phase: 'downloading',
|
||||
downloading: true,
|
||||
progressPercent: null,
|
||||
bytesDownloaded: 12 * 1024 * 1024,
|
||||
totalBytes: null
|
||||
});
|
||||
assert.equal(view.progressIndeterminate, true);
|
||||
assert.equal(view.progressPercent, null);
|
||||
assert.equal(view.bytesDownloaded, 12 * 1024 * 1024);
|
||||
assert.equal(view.showDownload, false);
|
||||
assert.equal(view.showRemove, false);
|
||||
});
|
||||
|
||||
test('presents known progress as a bounded percentage', () => {
|
||||
const view = presentLayaStatus({ phase: 'downloading', downloading: true, progressPercent: 47.6 });
|
||||
assert.equal(view.progressIndeterminate, false);
|
||||
assert.equal(view.progressPercent, 48);
|
||||
});
|
||||
|
||||
test('failed downloads expose retry, safe details and Standard fallback', () => {
|
||||
const view = presentLayaStatus({
|
||||
phase: 'failed',
|
||||
modelPresent: false,
|
||||
error: { message: 'Model download failed', detail: 'The optional Laya runtime is unavailable.' }
|
||||
}, 'hybrid');
|
||||
assert.equal(view.downloadLabel, 'Retry download');
|
||||
assert.equal(view.showDownload, true);
|
||||
assert.equal(view.showUseStandard, true);
|
||||
assert.equal(view.errorMessage, 'Model download failed');
|
||||
assert.equal(view.errorDetail, 'The optional Laya runtime is unavailable.');
|
||||
});
|
||||
|
||||
test('ready models expose removal without download actions', () => {
|
||||
const view = presentLayaStatus({ phase: 'ready', modelPresent: true, runtimeAvailable: true });
|
||||
assert.equal(view.showRemove, true);
|
||||
assert.equal(view.showDownload, false);
|
||||
});
|
||||
Reference in New Issue
Block a user