Enhance HistoryTable Component with Infinite Scroll and Cache Management

- Updated HistoryTable to implement infinite scrolling for loading history items dynamically.
- Introduced state management for accumulated history and total item count.
- Added Intersection Observer for triggering additional data fetches when scrolling.
- Implemented cache clearing functionality in the backend to manage voice prompt caches effectively.
- Improved loading indicators and user feedback for data fetching states.
- Refactored code for better readability and maintainability.
This commit is contained in:
Jamie Pine
2026-01-30 16:16:05 -08:00
parent b6e772c6ac
commit d3c65fc6c2
8 changed files with 173 additions and 51 deletions
+80 -30
View File
@@ -1,5 +1,6 @@
import { AudioWaveform, Download, FileArchive, MoreHorizontal, Play, Trash2 } from 'lucide-react'; import { AudioWaveform, Download, FileArchive, Loader2, MoreHorizontal, Play, Trash2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { HistoryResponse } from '@/lib/api/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -33,18 +34,21 @@ import { usePlayerStore } from '@/stores/playerStore';
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history) // OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
// This is the new alternate history view with fixed height rows // This is the new alternate history view with fixed height rows
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS // NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
export function HistoryTable() { export function HistoryTable() {
const [page, _setPage] = useState(0); const [page, setPage] = useState(0);
const [allHistory, setAllHistory] = useState<HistoryResponse[]>([]);
const [total, setTotal] = useState(0);
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const limit = 20; const limit = 20;
const { toast } = useToast(); const { toast } = useToast();
const { data: historyData, isLoading } = useHistory({ const { data: historyData, isLoading, isFetching } = useHistory({
limit, limit,
offset: page * limit, offset: page * limit,
}); });
@@ -60,6 +64,56 @@ export function HistoryTable() {
const audioUrl = usePlayerStore((state) => state.audioUrl); const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl; const isPlayerVisible = !!audioUrl;
// Update accumulated history when new data arrives
useEffect(() => {
if (historyData?.items) {
setTotal(historyData.total);
if (page === 0) {
// Reset to first page
setAllHistory(historyData.items);
} else {
// Append new items, avoiding duplicates
setAllHistory((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const newItems = historyData.items.filter((item) => !existingIds.has(item.id));
return [...prev, ...newItems];
});
}
}
}, [historyData, page]);
// Reset to page 0 when deletions or imports occur
useEffect(() => {
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
setPage(0);
setAllHistory([]);
}
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
// Intersection Observer for infinite scroll
useEffect(() => {
const loadMoreEl = loadMoreRef.current;
if (!loadMoreEl) return;
const observer = new IntersectionObserver(
(entries) => {
const target = entries[0];
if (target.isIntersecting && !isFetching && allHistory.length < total) {
setPage((prev) => prev + 1);
}
},
{
root: scrollRef.current,
rootMargin: '100px',
threshold: 0.1,
},
);
observer.observe(loadMoreEl);
return () => observer.disconnect();
}, [isFetching, allHistory.length, total]);
// Track scroll position for gradient effect
useEffect(() => { useEffect(() => {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
if (!scrollEl) return; if (!scrollEl) return;
@@ -113,27 +167,6 @@ export function HistoryTable() {
); );
}; };
const _handleImportClick = () => {
file_handleImportClickk.click();
};
const _handleFileChange = (_e: React.ChangeEvent<HTMLInputElement>) => {
cons_handleFileChangeet.files?.[0];
if (file) {
// Validate file extension
if (!file.name.endsWith('.voicebox.zip')) {
toast({
title: 'Invalid file type',
description: 'Please select a valid .voicebox.zip file',
variant: 'destructive',
});
return;
}
setSelectedFile(file);
setImportDialogOpen(true);
}
};
const handleImportConfirm = () => { const handleImportConfirm = () => {
if (selectedFile) { if (selectedFile) {
importGeneration.mutate(selectedFile, { importGeneration.mutate(selectedFile, {
@@ -159,13 +192,16 @@ export function HistoryTable() {
} }
}; };
if (isLoading) { if (isLoading && page === 0) {
return null; return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
} }
const history = historyData?.items || []; const history = allHistory;
const total = historyData?.total || 0; const hasMore = allHistory.length < total;
const _hasMore = history.length === limit && (page + 1) * limit < total;
return ( return (
<div className="flex flex-col h-full min-h-0 relative"> <div className="flex flex-col h-full min-h-0 relative">
@@ -284,6 +320,20 @@ export function HistoryTable() {
</div> </div>
); );
})} })}
{/* Load more trigger element */}
{hasMore && (
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
{isFetching && <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />}
</div>
)}
{/* End of list indicator */}
{!hasMore && history.length > 0 && (
<div className="text-center py-4 text-xs text-muted-foreground">
You've reached the end
</div>
)}
</div> </div>
</> </>
)} )}
@@ -2,8 +2,8 @@ import { Plus, Trash2, Play, Edit, Check, X, Volume2, Pause } from 'lucide-react
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CircleButton } from '@/components/ui/circle-button'; import { CircleButton } from '@/components/ui/circle-button';
import { Textarea } from '@/components/ui/textarea';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles'; import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
@@ -194,7 +194,9 @@ export function SampleList({ profileId }: SampleListProps) {
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg"> <div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" /> <Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No samples yet</p> <p className="text-sm text-muted-foreground">No samples yet</p>
<p className="text-xs text-muted-foreground/70 mt-1">Add your first audio sample to get started</p> <p className="text-xs text-muted-foreground/70 mt-1">
Add your first audio sample to get started
</p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -206,7 +208,7 @@ export function SampleList({ profileId }: SampleListProps) {
key={sample.id} key={sample.id}
className={cn( className={cn(
'group relative rounded-lg border bg-card transition-all duration-200', 'group relative rounded-lg border bg-card transition-all duration-200',
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30' isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30',
)} )}
> >
{isEditing ? ( {isEditing ? (
@@ -287,11 +289,22 @@ export function SampleList({ profileId }: SampleListProps) {
</div> </div>
)} )}
<Button type="button" variant="outline" className="w-full" onClick={() => setUploadOpen(true)}> <Button
type="button"
variant="outline"
className="w-full"
onClick={() => setUploadOpen(true)}
>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Sample Add Sample
</Button> </Button>
<p className="text-xs text-muted-foreground text-center px-2">
Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple
samples. In a future update samples might be interchangable and tagged for varying styles of
the same voice.
</p>
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} /> <SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
</div> </div>
); );
+14 -1
View File
@@ -175,7 +175,13 @@ class MLXTTSBackend:
if cached_prompt is not None: if cached_prompt is not None:
# Return cached prompt (should be dict format) # Return cached prompt (should be dict format)
if isinstance(cached_prompt, dict): if isinstance(cached_prompt, dict):
return cached_prompt, True # Validate that the cached audio file still exists
cached_audio_path = cached_prompt.get("ref_audio") or cached_prompt.get("ref_audio_path")
if cached_audio_path and Path(cached_audio_path).exists():
return cached_prompt, True
else:
# Cached file no longer exists, invalidate cache
print(f"Cached audio file not found: {cached_audio_path}, regenerating prompt")
# MLX voice prompt format - store audio path and text # MLX voice prompt format - store audio path and text
# The model will process this during generation # The model will process this during generation
@@ -263,6 +269,13 @@ class MLXTTSBackend:
ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path") ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path")
ref_text = voice_prompt.get("ref_text", "") ref_text = voice_prompt.get("ref_text", "")
# Validate that the audio file exists
if ref_audio and not Path(ref_audio).exists():
print(f"Warning: Audio file not found: {ref_audio}")
print("This may be due to a cached voice prompt referencing a deleted temp file.")
print("Regenerating without voice prompt.")
ref_audio = None
# Check if model supports voice cloning via generate method # Check if model supports voice cloning via generate method
# MLX API may support ref_audio parameter directly # MLX API may support ref_audio parameter directly
try: try:
+2
View File
@@ -196,6 +196,8 @@ class PyTorchTTSBackend:
# Cache stores as torch.Tensor but actual prompt is dict # Cache stores as torch.Tensor but actual prompt is dict
# Convert if needed # Convert if needed
if isinstance(cached_prompt, dict): if isinstance(cached_prompt, dict):
# For PyTorch backend, the dict should contain tensors, not file paths
# So we can safely return it
return cached_prompt, True return cached_prompt, True
elif isinstance(cached_prompt, torch.Tensor): elif isinstance(cached_prompt, torch.Tensor):
# Legacy cache format - convert to dict # Legacy cache format - convert to dict
+14
View File
@@ -27,6 +27,7 @@ from . import database, models, profiles, history, tts, transcribe, config, expo
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
from .utils.progress import get_progress_manager from .utils.progress import get_progress_manager
from .utils.tasks import get_task_manager from .utils.tasks import get_task_manager
from .utils.cache import clear_voice_prompt_cache
from .platform_detect import get_backend_type from .platform_detect import get_backend_type
app = FastAPI( app = FastAPI(
@@ -1495,6 +1496,19 @@ async def delete_model(model_name: str):
raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}")
@app.post("/cache/clear")
async def clear_cache():
"""Clear all voice prompt caches (memory and disk)."""
try:
deleted_count = clear_voice_prompt_cache()
return {
"message": f"Voice prompt cache cleared successfully",
"files_deleted": deleted_count,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}")
# ============================================ # ============================================
# TASK MANAGEMENT # TASK MANAGEMENT
# ============================================ # ============================================
+21 -16
View File
@@ -22,6 +22,7 @@ from .database import (
) )
from .utils.audio import validate_reference_audio, load_audio, save_audio from .utils.audio import validate_reference_audio, load_audio, save_audio
from .utils.images import validate_image, process_avatar from .utils.images import validate_image, process_avatar
from .utils.cache import _get_cache_dir
from .tts import get_tts_model from .tts import get_tts_model
from . import config from . import config
@@ -345,23 +346,27 @@ async def create_voice_prompt_for_profile(
reference_texts, reference_texts,
) )
# Save combined audio temporarily # Save combined audio to cache directory (persistent)
import tempfile # Create a hash of sample IDs to identify this specific combination
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: import hashlib
save_audio(combined_audio, tmp.name, 24000) sample_ids_str = "-".join(sorted([s.id for s in samples]))
tmp_path = tmp.name combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12]
# Store in cache directory
cache_dir = _get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav"
# Save combined audio
save_audio(combined_audio, str(combined_path), 24000)
try: # Create prompt from combined audio
# Create prompt from combined audio voice_prompt, _ = await tts_model.create_voice_prompt(
voice_prompt, _ = await tts_model.create_voice_prompt( str(combined_path),
tmp_path, combined_text,
combined_text, use_cache=use_cache,
use_cache=use_cache, )
) return voice_prompt
return voice_prompt
finally:
# Clean up temp file
Path(tmp_path).unlink(missing_ok=True)
async def upload_avatar( async def upload_avatar(
+25
View File
@@ -88,3 +88,28 @@ def cache_voice_prompt(
# Store on disk (torch.save can handle both dicts and tensors) # Store on disk (torch.save can handle both dicts and tensors)
cache_file = _get_cache_dir() / f"{cache_key}.prompt" cache_file = _get_cache_dir() / f"{cache_key}.prompt"
torch.save(voice_prompt, cache_file) torch.save(voice_prompt, cache_file)
def clear_voice_prompt_cache() -> int:
"""
Clear all voice prompt caches (memory and disk).
Returns:
Number of cache files deleted
"""
# Clear memory cache
_memory_cache.clear()
# Clear disk cache
cache_dir = _get_cache_dir()
deleted_count = 0
if cache_dir.exists():
for cache_file in cache_dir.glob("*.prompt"):
try:
cache_file.unlink()
deleted_count += 1
except Exception as e:
print(f"Failed to delete cache file {cache_file}: {e}")
return deleted_count
Binary file not shown.