Lumi/plugins/lumi_transcription/views/settings.ejs
2026-07-23 09:12:28 +02:00

147 lines
14 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<% const displayCompanionVersion = (value) => { const match = /^(\d+\.\d+\.\d+)-experimental\.(\d+)$/i.exec(String(value || '')); return match ? `${match[1]} · Experimental release ${match[2]}` : String(value || 'Unknown version'); }; %>
<section class="card transcription-overview" data-transcription-admin>
<div class="section-header">
<%- include("../../../src/web/views/partials/page-header", {
eyebrow: "Experimental companion",
pageTitle: "Lumi Transcription",
description: "Server-hosted speech recognition for selected OBS sources, managed through one Lumi Companion application."
}) %>
<span class="status-indicator <%= providerHealth.healthy ? 'status-success' : 'status-warning' %>">
<%= providerHealth.healthy ? 'Inference ready' : 'Setup required' %>
</span>
</div>
<div class="callout warning">
<strong>Live Twitch delivery is not accepted yet</strong>
<p>The native OBS 31+ caption path still needs target-machine validation. Lumi will not present a local simulated caption as live Twitch success.</p>
</div>
</section>
<section class="card setup-path" aria-labelledby="setup-title" data-transcription-admin>
<div class="section-header">
<div><span class="eyebrow">First usable path</span><h2 id="setup-title">Setup progress</h2><p class="hint">Complete one clear step at a time. Existing server administration remains in Lumi; streaming-computer controls remain in Companion.</p></div>
</div>
<ol>
<li class="<%= devices.length ? 'is-complete' : 'is-current' %>"><strong>Download and pair Companion</strong><span>Create a short-lived, single-use package for the streaming computer.</span></li>
<li class="<%= devices.some((device) => device.metadata?.bridge_installed) ? 'is-complete' : devices.length ? 'is-current' : '' %>"><strong>Install the OBS integration</strong><span><%= devices.some((device) => device.metadata?.bridge_installed) ? 'A paired Companion has verified its managed OBS bridge installation.' : 'Companion owns installation and repair; the bridge has no separate settings.' %></span></li>
<li class="<%= providerHealth.model_ready ? 'is-complete' : '' %>"><strong>Load and benchmark a model</strong><span>Small English is the recommended starting point.</span></li>
<li class="<%= devices.some((device) => device.metadata?.path_test_valid) ? 'is-complete' : '' %>"><strong>Select and test a microphone</strong><span><%= devices.some((device) => device.metadata?.path_test_valid) ? 'The saved voice-free path check is still valid for the current configuration.' : 'Run the short voice-free path check once after setup or a relevant configuration change.' %></span></li>
</ol>
<div class="inline-actions">
<button class="button" type="button" data-create-pairing>Download Companion</button>
<a class="button subtle" href="#speech-models">Review speech models</a>
</div>
<p class="hint" role="status" aria-live="polite" data-status></p>
</section>
<div class="transcription-grid" data-transcription-admin>
<section class="card" aria-labelledby="runtime-title">
<div class="section-header"><div><span class="eyebrow">Lumi host</span><h2 id="runtime-title">Inference health</h2><p class="hint">Speech recognition runs on the Lumi server, never on the streaming computer.</p></div></div>
<div class="dashboard-metric-grid transcription-metrics">
<div><span>Provider</span><strong>whisper.cpp <%= runtimeManifest.tested_version %></strong></div>
<div><span>Worker</span><strong><%= providerHealth.state %></strong></div>
<div><span>Selected model</span><strong><%= providerHealth.model ? providerHealth.model + (providerHealth.model_ready ? '' : ' · not ready') : 'Not loaded' %></strong></div>
<div><span>Active sessions</span><strong><%= providerHealth.sessions || 0 %></strong></div>
</div>
<% if (!providerHealth.healthy) { const workerReason = providerHealth.last_error?.message || (providerHealth.last_exit ? `Last exit code: ${providerHealth.last_exit.code ?? 'unknown'}` : null) || providerHealth.last_diagnostic?.message || 'No worker failure detail has been reported yet.'; %>
<div class="callout warning"><strong>Speech recognition needs attention</strong><p><%= workerReason %> Reload the verified model below, then rerun the Companion test.</p></div>
<% } %>
<div class="button-row runtime-actions">
<% runtimeManifest.artifacts.forEach((artifact) => { %>
<button class="button <%= artifact.backend === 'cpu' ? 'subtle' : '' %>" type="button" data-install-runtime="<%= artifact.id %>" data-runtime-label="<%= artifact.label %>" data-runtime-size="<%= Math.ceil((artifact.bytes || 0) / 1048576) %>">
<%= artifact.backend === 'cuda' ? 'Install / upgrade GPU runtime' : 'Install CPU fallback' %>
</button>
<% }) %>
</div>
<p class="hint">The low-latency package targets <%= runtimeManifest.artifacts.find((artifact) => artifact.backend === 'cuda')?.minimum_gpu || 'a supported NVIDIA GPU' %> and newer. Runtime changes activate immediately when no transcription session is active.</p>
<div class="callout info"><strong>Shared GPU awareness</strong><p>Lumi warns and benchmarks when Lumi AI already occupies GPU memory. It never unloads AI models automatically.</p></div>
</section>
<section class="card" aria-labelledby="devices-title">
<div class="section-header"><div><span class="eyebrow">Access</span><h2 id="devices-title">Paired devices</h2><p class="hint">Active credentials are shown first. Switch to revoked history when needed; revoked records are permanently removed after 30 days.</p></div><span class="badge"><%= devices.length %> active</span></div>
<div class="analysis-mode device-view-toggle" role="group" aria-label="Paired device status">
<button class="button" type="button" data-device-view="active" aria-pressed="true">Active (<%= devices.length %>)</button>
<button class="button subtle" type="button" data-device-view="revoked" aria-pressed="false">Revoked (<%= revokedDevices.length %>)</button>
</div>
<div data-active-devices>
<% if (!devices.length) { %><div class="empty-state"><strong>No active Companion is paired</strong><p>Use Download Companion to create a package that expires after 15 minutes and works once.</p></div><% } %>
<% if (devices.length) { %>
<ul class="transcription-device-list">
<% devices.forEach((device) => { %>
<li data-device-row="<%= device.id %>"><div><strong><%= device.name %></strong><span class="hint">Last connected <%= new Date(device.last_connected_at).toLocaleString() %><% if (device.metadata?.companion_version) { %> · Companion <%= displayCompanionVersion(device.metadata.companion_version) %><% } %></span></div><div class="device-actions"><span class="badge success">Allowed</span><button class="button danger" type="button" data-revoke-device="<%= device.id %>" data-device-name="<%= device.name %>">Revoke access</button></div></li>
<% }) %>
</ul>
<% } %>
</div>
<div data-revoked-devices hidden>
<% if (!revokedDevices.length) { %><div class="empty-state"><strong>No revoked device history</strong><p>Revoked credentials will appear here for up to 30 days.</p></div><% } %>
<% if (revokedDevices.length) { %>
<ul class="transcription-device-list">
<% revokedDevices.forEach((device) => { %><li><div><strong><%= device.name %></strong><span class="hint">Revoked <%= new Date(device.revoked_at).toLocaleString() %> · permanent removal by <%= new Date(device.revoked_at + 30 * 86400000).toLocaleDateString() %></span></div><span class="badge danger">Revoked</span></li><% }) %>
</ul>
<% } %>
</div>
</section>
</div>
<section class="card benchmark-history" aria-labelledby="benchmark-history-title" data-transcription-admin>
<div class="section-header">
<div><span class="eyebrow">Last hour</span><h2 id="benchmark-history-title">Transcription test analysis</h2><p class="hint">Inspect each dedicated Companion test by latency, finalized confidence, or both. Test transcripts and measurements are permanently deleted after one hour.</p></div>
<span class="badge"><%= benchmarks.length %> retained</span>
</div>
<% if (!benchmarks.length) { %>
<div class="empty-state"><strong>No dedicated tests in the last hour</strong><p>Start Accuracy &amp; latency test from the Companion Test page. Full-path checks are intentionally not retained here.</p></div>
<% } %>
<% benchmarks.forEach((test) => { const metricRows = [
['Minimum', 'min'], ['Low 1% avg', 'low_1_average'], ['Median', 'median'], ['Average', 'average'],
['P99', 'p99'], ['High 1% avg', 'high_1_average'], ['Maximum', 'max']
]; %>
<details class="benchmark-test" data-benchmark-test data-analysis-mode="combined">
<summary>
<span><strong><%= new Date(test.started_at).toLocaleString() %></strong><small><%= test.source_name %> · <%= test.model_id || 'model pending' %> / <%= test.backend || 'backend pending' %> · <%= Math.round(test.duration_ms / 1000) %>s</small></span>
<span class="badge <%= test.status === 'completed' || test.status === 'silence_timeout' ? 'success' : test.status === 'running' ? 'info' : 'warning' %>"><%= test.status.replaceAll('_', ' ') %></span>
</summary>
<div class="benchmark-body">
<div class="analysis-mode" role="group" aria-label="Transcript analysis coloring">
<button class="button subtle" type="button" data-analysis-select="latency" aria-pressed="false">Latency</button>
<button class="button subtle" type="button" data-analysis-select="confidence" aria-pressed="false">Confidence</button>
<button class="button" type="button" data-analysis-select="combined" aria-pressed="true">Combined</button>
</div>
<div class="benchmark-legends">
<div class="benchmark-legend" aria-label="Latency color meaning"><strong>Latency</strong><span class="legend-fast">Green &lt;750 ms</span><span class="legend-good">Blue &lt;1,250 ms</span><span class="legend-trouble">Yellow &lt;2,000 ms</span><span class="legend-borderline">Orange &lt;3,000 ms</span><span class="legend-critical">Red ≥3,000 ms</span></div>
<div class="benchmark-legend" aria-label="Confidence color meaning"><strong>Confidence</strong><span class="legend-fast">Green ≥92%</span><span class="legend-good">Blue ≥80%</span><span class="legend-trouble">Yellow ≥65%</span><span class="legend-borderline">Orange ≥45%</span><span class="legend-critical">Red &lt;45%</span></div>
</div>
<div class="benchmark-transcript" aria-label="Measured transcript">
<% if (!test.words.length) { %><p class="hint"><%= test.transcript || 'No finalized words were measured.' %></p><% } %>
<% test.words.forEach((word) => { const confidenceLabel = Number.isFinite(word.confidence) ? `${(word.confidence * 100).toFixed(1)}%` : 'not finalized'; %>
<span class="benchmark-word" tabindex="0" data-latency="<%= word.latency_ms %>" data-confidence="<%= Number.isFinite(word.confidence) ? word.confidence : '' %>" data-tooltip="Latency <%= Math.round(word.latency_ms) %> ms · Confidence <%= confidenceLabel %> · Audio <%= Math.round(word.audio_start_ms) %><%= Math.round(word.audio_end_ms) %> ms"><%= word.text %></span>
<% }) %>
</div>
<div class="benchmark-stat-columns">
<% [['Latency', test.stats.latency, (value) => `${Math.round(value)} ms`], ['Final confidence', test.stats.confidence, (value) => `${(value * 100).toFixed(1)}%`]].forEach(([label, stats, format]) => { %>
<section><h3><%= label %></h3><div class="benchmark-stat-grid">
<% metricRows.forEach(([metricLabel, key]) => { %><div><span><%= metricLabel %></span><strong><%= Number.isFinite(stats[key]) ? format(stats[key]) : '—' %></strong></div><% }) %>
</div><p class="hint"><%= stats.count %> measured values</p></section>
<% }) %>
</div>
</div>
</details>
<% }) %>
</section>
<section class="card" id="speech-models" aria-labelledby="models-title" data-transcription-admin>
<div class="section-header"><div><span class="eyebrow">Curated choices</span><h2 id="models-title">Speech models</h2><p class="hint">Downloads require confirmation and checksum verification before a model can load.</p></div></div>
<div class="model-row">
<% models.forEach((model) => { %>
<article>
<div><h3><%= model.label %></h3><p class="hint"><%= model.recommended ? 'Recommended starting point' : 'Fallback option' %> · <%= Math.round(model.bytes / 1048576) %> MiB</p></div>
<div class="model-actions">
<span class="badge <%= providerHealth.model === model.id && providerHealth.model_ready ? 'success' : model.status.valid ? 'info' : model.status.installed ? 'danger' : 'muted' %>"><%= providerHealth.model === model.id && providerHealth.model_ready ? 'Loaded' : providerHealth.model === model.id && model.status.valid ? 'Reload required' : model.status.valid ? 'Verified' : model.status.installed ? 'Checksum failed' : 'Not installed' %></span>
<% if (!model.status.valid) { %><button class="button subtle" type="button" data-download-model="<%= model.id %>" data-model-label="<%= model.label %>" data-model-size="<%= Math.round(model.bytes / 1048576) %>">Download</button><% } %>
<% if (model.status.valid && (providerHealth.model !== model.id || !providerHealth.model_ready)) { %><button class="button subtle" type="button" data-load-model="<%= model.id %>"><%= providerHealth.model === model.id ? 'Reload model' : 'Load model' %></button><% } %>
</div>
</article>
<% }) %>
</div>
<div class="callout info"><strong>Audio privacy</strong><p>Raw audio is held only in bounded memory and is never written to disk by default. Diagnostic caption text is retained for seven days unless disabled.</p></div>
</section>