Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
commit
1333b0c36b
202 files changed
+63054
No files matched your search
@@ -0,0 +1,340 @@
|
||||
import { AlertCircle, Check, Download, Folder, HardDrive, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAppSettings } from '../../contexts/AppSettingsContext'
|
||||
import { useHfAuth } from '../../hooks/use-hf-auth'
|
||||
import { useHfModelAccess } from '../../hooks/use-hf-model-access'
|
||||
import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../../lib/api-client'
|
||||
import { formatBytes } from '../../lib/format'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { HfModelAccessGate } from '../HfModelAccessGate'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
type LtxModelVersionItem = ApiSuccessOf<'getLtxVersions'>['versions'][number]
|
||||
type ModelCheckpointID = NonNullable<
|
||||
NonNullable<ApiRequestBodyOf<'checkModelAccess'>>['cp_ids']
|
||||
>[number]
|
||||
type HfAuthStatus = ApiSuccessOf<'getHuggingFaceAuthStatus'>['status']
|
||||
|
||||
const DOWNLOAD_POLL_INTERVAL_MS = 1000
|
||||
|
||||
function VersionRow({
|
||||
version,
|
||||
onChanged,
|
||||
resumeSessionId,
|
||||
hfAuthStatus,
|
||||
hfAuthPolling,
|
||||
startHuggingFaceLogin,
|
||||
}: {
|
||||
version: LtxModelVersionItem
|
||||
onChanged: () => Promise<void>
|
||||
resumeSessionId: string | null
|
||||
hfAuthStatus: HfAuthStatus
|
||||
hfAuthPolling: boolean
|
||||
startHuggingFaceLogin: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [downloadSessionId, setDownloadSessionId] = useState<string | null>(null)
|
||||
const [downloadPercent, setDownloadPercent] = useState(0)
|
||||
const mountedRef = useRef(true)
|
||||
useEffect(() => () => { mountedRef.current = false }, [])
|
||||
|
||||
const cpsToDownload = useMemo(
|
||||
() => (version.installed ? [] : (version.cps_to_download as ModelCheckpointID[])),
|
||||
[version.installed, version.cps_to_download],
|
||||
)
|
||||
const { accessMap, allAuthorized, checking: checkingAccess, checkError, recheckAccess } = useHfModelAccess(
|
||||
cpsToDownload,
|
||||
hfAuthStatus,
|
||||
)
|
||||
const canDownload = version.installed || (allAuthorized && !checkingAccess)
|
||||
|
||||
const adoptedRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (resumeSessionId && resumeSessionId !== adoptedRef.current && !downloadSessionId) {
|
||||
adoptedRef.current = resumeSessionId
|
||||
setDownloadSessionId(resumeSessionId)
|
||||
setBusy(true)
|
||||
}
|
||||
}, [resumeSessionId, downloadSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadSessionId) return
|
||||
let cancelled = false
|
||||
|
||||
const poll = async () => {
|
||||
const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId })
|
||||
if (cancelled) return
|
||||
if (!result.ok) {
|
||||
logger.error(`Progress poll error: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
const progress = result.data
|
||||
if (progress.status === 'downloading') {
|
||||
setDownloadPercent(Math.round(progress.total_progress))
|
||||
return
|
||||
}
|
||||
if (progress.status === 'error') {
|
||||
setDownloadSessionId(null)
|
||||
setBusy(false)
|
||||
setError(progress.error || 'Download failed.')
|
||||
return
|
||||
}
|
||||
if (progress.status === 'complete') {
|
||||
setDownloadSessionId(null)
|
||||
setDownloadPercent(100)
|
||||
await onChanged()
|
||||
if (!cancelled) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
const interval = setInterval(() => void poll(), DOWNLOAD_POLL_INTERVAL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [downloadSessionId, onChanged])
|
||||
|
||||
const handleSetActive = useCallback(async () => {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
const result = await ApiClient.setActiveLtxModel({ model_id: version.model_id })
|
||||
if (!result.ok) {
|
||||
setBusy(false)
|
||||
setError(result.error.message || 'Failed to set active model.')
|
||||
return
|
||||
}
|
||||
await onChanged()
|
||||
if (mountedRef.current) setBusy(false)
|
||||
}, [version.model_id, onChanged])
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
setDownloadPercent(0)
|
||||
const result = await ApiClient.startModelDownload({ type: 'download', cp_ids: version.cps_to_download })
|
||||
if (!result.ok) {
|
||||
setBusy(false)
|
||||
setError(result.error.message || 'Failed to start download.')
|
||||
return
|
||||
}
|
||||
if (result.data.status !== 'started') {
|
||||
setBusy(false)
|
||||
setError('Unexpected response while starting download.')
|
||||
return
|
||||
}
|
||||
setDownloadSessionId(result.data.sessionId)
|
||||
}, [version.cps_to_download])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
const result = await ApiClient.deleteModels({ cp_ids: [version.model_cp] })
|
||||
if (!result.ok) {
|
||||
setBusy(false)
|
||||
setError(result.error.message || 'Failed to delete model.')
|
||||
return
|
||||
}
|
||||
await onChanged()
|
||||
if (mountedRef.current) setBusy(false)
|
||||
}, [version.model_cp, onChanged])
|
||||
|
||||
const isDownloading = downloadSessionId !== null
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-800/50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<input
|
||||
type="radio"
|
||||
name="active-ltx-model"
|
||||
checked={version.active}
|
||||
disabled={!version.installed || version.active || busy}
|
||||
onChange={() => void handleSetActive()}
|
||||
className="h-4 w-4 accent-blue-500 flex-shrink-0 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-white truncate">{version.label}</span>
|
||||
{version.active && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 inline-flex items-center gap-1 flex-shrink-0">
|
||||
<Check className="h-3 w-3" />
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{version.is_newest && !version.installed && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 flex-shrink-0">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!version.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={busy || !canDownload}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white text-xs"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{isDownloading ? `Downloading… ${downloadPercent}%` : `Download ${formatBytes(version.size_bytes)}`}
|
||||
</Button>
|
||||
)}
|
||||
{version.installed && !version.active && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={busy}
|
||||
className="border-zinc-700 text-zinc-300 hover:text-red-400 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!version.installed && (
|
||||
<HfModelAccessGate
|
||||
accessMap={accessMap}
|
||||
allAuthorized={allAuthorized}
|
||||
hfAuthStatus={hfAuthStatus}
|
||||
hfAuthPolling={hfAuthPolling}
|
||||
startHuggingFaceLogin={startHuggingFaceLogin}
|
||||
checkError={checkError}
|
||||
onRetryCheck={recheckAccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 inline-flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3 w-3 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BaseModelSection() {
|
||||
const [versions, setVersions] = useState<LtxModelVersionItem[]>([])
|
||||
const [modelsDir, setModelsDir] = useState('')
|
||||
const [activeDownload, setActiveDownload] = useState<{ sessionId: string; cpIds: string[] } | null>(null)
|
||||
const { hfAuthStatus, hfAuthPolling, startHuggingFaceLogin } = useHfAuth(true)
|
||||
const { notifyModelsChanged } = useAppSettings()
|
||||
const knownActiveRef = useRef<string | null>(null)
|
||||
|
||||
const refreshVersions = useCallback(async () => {
|
||||
const [versionsResult, activeResult] = await Promise.all([
|
||||
ApiClient.getLtxVersions(),
|
||||
ApiClient.getActiveDownload(),
|
||||
])
|
||||
if (!versionsResult.ok) {
|
||||
logger.error(`Failed to fetch LTX versions: ${versionsResult.error.message}`)
|
||||
return
|
||||
}
|
||||
setVersions(versionsResult.data.versions)
|
||||
// Signal only on a real change so mounting the panel doesn't refetch generation specs.
|
||||
const nextActive = versionsResult.data.versions.find((item) => item.active)?.model_id ?? null
|
||||
const nextKey = `${nextActive}|${versionsResult.data.versions.filter((item) => item.installed).map((item) => item.model_id).join(',')}`
|
||||
if (knownActiveRef.current !== null && knownActiveRef.current !== nextKey) {
|
||||
notifyModelsChanged()
|
||||
}
|
||||
knownActiveRef.current = nextKey
|
||||
if (activeResult.ok) {
|
||||
setActiveDownload(
|
||||
activeResult.data.session_id
|
||||
? { sessionId: activeResult.data.session_id, cpIds: activeResult.data.cp_ids ?? [] }
|
||||
: null,
|
||||
)
|
||||
}
|
||||
}, [notifyModelsChanged])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshVersions()
|
||||
void (async () => {
|
||||
const result = await ApiClient.getSettings()
|
||||
if (!result.ok) {
|
||||
logger.error(`Failed to fetch settings: ${result.error.message}`)
|
||||
return
|
||||
}
|
||||
setModelsDir(result.data.modelsDir ?? '')
|
||||
})()
|
||||
}, [refreshVersions])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="h-4 w-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-white">Models Folder</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 leading-relaxed">
|
||||
Where model checkpoints are stored. Changing the location requires restarting the app.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-300 text-sm truncate select-text">
|
||||
{modelsDir || <span className="text-zinc-600">Not set</span>}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-zinc-700 flex-shrink-0"
|
||||
onClick={async () => {
|
||||
const result = await window.electronAPI.openModelsDirChangeDialog()
|
||||
if (result.success) {
|
||||
setModelsDir(result.path)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Change…
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-zinc-700 flex-shrink-0"
|
||||
disabled={!modelsDir}
|
||||
onClick={() => {
|
||||
void window.electronAPI.openModelsFolder()
|
||||
}}
|
||||
>
|
||||
Open folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-4 border-t border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-white">Base Model</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 leading-relaxed">
|
||||
The active version is used for new generations. Download a version to make it available,
|
||||
then set it active. Newer versions may require a Hugging Face sign-in.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{versions.length === 0 ? (
|
||||
<div className="text-xs text-zinc-600">No versions available.</div>
|
||||
) : (
|
||||
versions.map((version) => (
|
||||
<VersionRow
|
||||
key={version.model_id}
|
||||
version={version}
|
||||
onChanged={refreshVersions}
|
||||
resumeSessionId={
|
||||
activeDownload && activeDownload.cpIds.includes(version.model_cp)
|
||||
? activeDownload.sessionId
|
||||
: null
|
||||
}
|
||||
hfAuthStatus={hfAuthStatus}
|
||||
hfAuthPolling={hfAuthPolling}
|
||||
startHuggingFaceLogin={() => {
|
||||
void startHuggingFaceLogin()
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user