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 type { HistoryResponse } from '@/lib/api/types';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -33,18 +34,21 @@ import { usePlayerStore } from '@/stores/playerStore';
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
// 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() {
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 scrollRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const limit = 20;
const { toast } = useToast();
const { data: historyData, isLoading } = useHistory({
const { data: historyData, isLoading, isFetching } = useHistory({
limit,
offset: page * limit,
});
@@ -60,6 +64,56 @@ export function HistoryTable() {
const audioUrl = usePlayerStore((state) => state.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(() => {
const scrollEl = scrollRef.current;
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 = () => {
if (selectedFile) {
importGeneration.mutate(selectedFile, {
@@ -159,13 +192,16 @@ export function HistoryTable() {
}
};
if (isLoading) {
return null;
if (isLoading && page === 0) {
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 total = historyData?.total || 0;
const _hasMore = history.length === limit && (page + 1) * limit < total;
const history = allHistory;
const hasMore = allHistory.length < total;
return (
<div className="flex flex-col h-full min-h-0 relative">
@@ -284,6 +320,20 @@ export function HistoryTable() {
</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>
</>
)}
@@ -2,8 +2,8 @@ import { Plus, Trash2, Play, Edit, Check, X, Volume2, Pause } from 'lucide-react
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { CircleButton } from '@/components/ui/circle-button';
import { Textarea } from '@/components/ui/textarea';
import { Slider } from '@/components/ui/slider';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
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">
<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-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 className="space-y-2">
@@ -206,7 +208,7 @@ export function SampleList({ profileId }: SampleListProps) {
key={sample.id}
className={cn(
'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 ? (
@@ -287,11 +289,22 @@ export function SampleList({ profileId }: SampleListProps) {
</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" />
Add Sample
</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} />
</div>
);
+14 -1
View File
@@ -175,7 +175,13 @@ class MLXTTSBackend:
if cached_prompt is not None:
# Return cached prompt (should be dict format)
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
# 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_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
# MLX API may support ref_audio parameter directly
try:
+2
View File
@@ -196,6 +196,8 @@ class PyTorchTTSBackend:
# Cache stores as torch.Tensor but actual prompt is dict
# Convert if needed
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
elif isinstance(cached_prompt, torch.Tensor):
# 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 .utils.progress import get_progress_manager
from .utils.tasks import get_task_manager
from .utils.cache import clear_voice_prompt_cache
from .platform_detect import get_backend_type
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)}")
@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
# ============================================
+21 -16
View File
@@ -22,6 +22,7 @@ from .database import (
)
from .utils.audio import validate_reference_audio, load_audio, save_audio
from .utils.images import validate_image, process_avatar
from .utils.cache import _get_cache_dir
from .tts import get_tts_model
from . import config
@@ -345,23 +346,27 @@ async def create_voice_prompt_for_profile(
reference_texts,
)
# Save combined audio temporarily
import tempfile
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
save_audio(combined_audio, tmp.name, 24000)
tmp_path = tmp.name
# Save combined audio to cache directory (persistent)
# Create a hash of sample IDs to identify this specific combination
import hashlib
sample_ids_str = "-".join(sorted([s.id for s in samples]))
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
voice_prompt, _ = await tts_model.create_voice_prompt(
tmp_path,
combined_text,
use_cache=use_cache,
)
return voice_prompt
finally:
# Clean up temp file
Path(tmp_path).unlink(missing_ok=True)
# Create prompt from combined audio
voice_prompt, _ = await tts_model.create_voice_prompt(
str(combined_path),
combined_text,
use_cache=use_cache,
)
return voice_prompt
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)
cache_file = _get_cache_dir() / f"{cache_key}.prompt"
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.