<script lang="ts">
|
import { onMount } from 'svelte'
|
import { Events, Call } from '@wailsio/runtime'
|
import { t } from '../../lib/i18n'
|
import { engineStatus } from '../../lib/stores/app'
|
import AppIcon from '../shared/AppIcon.svelte'
|
|
let statusText = $state(t('onboarding.syncModel'))
|
let detailText = $state('')
|
let progress = $state(0)
|
let failed = $state(false)
|
let downloadActive = $state(false)
|
let cancelling = $state(false)
|
let currentModelID = $state('')
|
|
onMount(() => {
|
const offDownloadProgress = Events.On('model:download-progress', (ev: any) => {
|
const data = ev?.data
|
if (data?.percent != null) {
|
if (data?.modelID) {
|
currentModelID = data.modelID
|
}
|
downloadActive = true
|
cancelling = false
|
failed = false
|
statusText = t('onboarding.syncModel')
|
progress = data.percent
|
const downloadedMB = Math.floor((data.downloaded || 0) / 1024 / 1024)
|
const totalMB = Math.floor((data.total || 0) / 1024 / 1024)
|
detailText = `${data.percent.toFixed(1)}% (${downloadedMB}MB / ${totalMB}MB)`
|
}
|
})
|
|
const offEngineStatus = Events.On('engine:status', (ev: any) => {
|
const data = ev?.data
|
if (data?.status === 'ready') {
|
downloadActive = false
|
cancelling = false
|
failed = false
|
statusText = t('onboarding.complete')
|
progress = 100
|
} else if (data?.status === 'need_model') {
|
const error = typeof data?.error === 'string' ? data.error.trim() : ''
|
if (error && !downloadActive) {
|
statusText = t('onboarding.failed')
|
detailText = error
|
failed = true
|
}
|
} else if (data?.status === 'error') {
|
downloadActive = false
|
cancelling = false
|
statusText = t('onboarding.failed')
|
detailText = data?.error || ''
|
failed = true
|
}
|
})
|
|
triggerDownload()
|
|
return () => {
|
offDownloadProgress()
|
offEngineStatus()
|
}
|
})
|
|
async function triggerDownload() {
|
try {
|
const current: any = await Call.ByName('voicesnap/services.EngineService.GetCurrentModelStatus')
|
currentModelID = typeof current?.modelID === 'string' ? current.modelID : ''
|
downloadActive = true
|
cancelling = false
|
failed = false
|
statusText = t('onboarding.syncModel')
|
detailText = ''
|
await Call.ByName('voicesnap/services.EngineService.DownloadCurrentModel')
|
downloadActive = false
|
statusText = t('onboarding.optimizing')
|
detailText = t('onboarding.extracting')
|
} catch (err: any) {
|
downloadActive = false
|
cancelling = false
|
if (isCancelError(err)) {
|
statusText = t('onboarding.cancelled')
|
detailText = t('onboarding.cancelledDetail')
|
} else {
|
statusText = t('onboarding.failed')
|
detailText = err?.message || String(err)
|
}
|
failed = true
|
}
|
}
|
|
function isCancelError(err: any): boolean {
|
const text = String(err?.message || err || '').toLowerCase()
|
return text.includes('context canceled') || text.includes('cancelled') || text.includes('canceled')
|
}
|
|
async function cancelDownload() {
|
if (!downloadActive || cancelling) return
|
cancelling = true
|
statusText = t('onboarding.cancelling')
|
await Call.ByName('voicesnap/services.EngineService.CancelModelDownload', currentModelID)
|
}
|
</script>
|
|
<div class="onboarding">
|
<div class="card">
|
<div class="icon">
|
<AppIcon size={56} elevated />
|
</div>
|
<h1 class="title">{t('onboarding.title')}</h1>
|
|
<div class="status">
|
<p class="status-text" class:failed>{statusText}</p>
|
{#if detailText}
|
<p class="detail-text">{detailText}</p>
|
{/if}
|
</div>
|
|
<!-- Progress bar -->
|
<div class="progress-track">
|
<div class="progress-fill" style="width: {progress}%" class:failed></div>
|
</div>
|
|
<div class="actions">
|
{#if downloadActive}
|
<button class="action cancel" onclick={cancelDownload} disabled={cancelling}>
|
{cancelling ? t('onboarding.cancelling') : t('onboarding.cancel')}
|
</button>
|
{:else if failed}
|
<button class="action primary" onclick={triggerDownload}>
|
{t('onboarding.retry')}
|
</button>
|
{/if}
|
</div>
|
</div>
|
</div>
|
|
<style>
|
.onboarding {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
height: 100vh;
|
background: var(--color-bg-secondary);
|
padding: var(--spacing-xxl);
|
}
|
|
.card {
|
background: var(--color-bg-primary);
|
border-radius: var(--radius-xl);
|
padding: var(--spacing-xxl) 48px;
|
text-align: center;
|
max-width: 400px;
|
width: 100%;
|
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.06);
|
}
|
|
.icon {
|
width: 56px;
|
height: 56px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin: 0 auto var(--spacing-lg);
|
}
|
|
.title {
|
font-size: var(--font-size-xl);
|
font-weight: 700;
|
margin-bottom: var(--spacing-xl);
|
}
|
|
.status {
|
margin-bottom: var(--spacing-lg);
|
}
|
|
.status-text {
|
font-size: var(--font-size-base);
|
font-weight: 500;
|
color: var(--color-label);
|
}
|
|
.status-text.failed {
|
color: var(--color-red);
|
}
|
|
.detail-text {
|
margin-top: var(--spacing-xs);
|
font-size: var(--font-size-sm);
|
color: var(--color-secondary-label);
|
}
|
|
.progress-track {
|
height: 6px;
|
background: var(--color-bg-secondary);
|
border-radius: 3px;
|
overflow: hidden;
|
}
|
|
.progress-fill {
|
height: 100%;
|
background: var(--color-blue);
|
border-radius: 3px;
|
transition: width 200ms ease;
|
}
|
|
.progress-fill.failed {
|
background: var(--color-red);
|
}
|
|
.actions {
|
margin-top: var(--spacing-lg);
|
min-height: 32px;
|
}
|
|
.action {
|
min-width: 96px;
|
height: 32px;
|
padding: 0 14px;
|
border: none;
|
border-radius: var(--radius-sm);
|
color: white;
|
font-size: var(--font-size-sm);
|
font-weight: 500;
|
cursor: pointer;
|
}
|
|
.action.primary {
|
background: var(--color-blue);
|
}
|
|
.action.cancel {
|
background: var(--color-red);
|
}
|
|
.action:disabled {
|
cursor: not-allowed;
|
opacity: 0.72;
|
}
|
</style>
|