|
|
<script> |
|
|
import { |
|
|
Play, |
|
|
Download, |
|
|
Loader2, |
|
|
AlertCircle, |
|
|
ChevronDown, |
|
|
Copy, |
|
|
Share, |
|
|
MoreHorizontal, |
|
|
Shuffle, |
|
|
Pause, |
|
|
X, |
|
|
Code, |
|
|
Layout, |
|
|
} from 'lucide-svelte'; |
|
|
import { onMount } from 'svelte'; |
|
|
|
|
|
let text = `In a hole in the ground, there lived a hobbit. Not a nasty, dirty, wet hole, filled with the ends of worms and an oozy smell, nor yet a dry, bare, sandy hole with nothing in it to sit down on or to eat: it was a hobbit-hole, and that means comfort.`; |
|
|
let selectedVoice = 'Andrew'; |
|
|
let selectedModel = 'Chatterbox'; |
|
|
let modelDropdownOpen = false; |
|
|
let isGenerating = false; |
|
|
let audioUrl = null; |
|
|
let exaggeration = 0.25; |
|
|
let temperature = 0.7; |
|
|
let isPlaying = false; |
|
|
let currentTime = 0; |
|
|
let duration = 0; |
|
|
let audioTitle = ''; |
|
|
let audioElement = null; |
|
|
let sampleAudioElement = null; |
|
|
let playingSampleVoice = null; |
|
|
let showErrorModal = false; |
|
|
let errorMessage = ''; |
|
|
let errorDetails = ''; |
|
|
let historyCount = 0; |
|
|
|
|
|
const famousBookOpeners = [ |
|
|
'It was the best of times, it was the worst of times. It was the age of wisdom, it was the age of foolishness.', |
|
|
'It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.', |
|
|
'All happy families are alike; each unhappy family is unhappy in its own way.', |
|
|
]; |
|
|
let currentBookIndex = 0; |
|
|
|
|
|
const models = [ |
|
|
{ id: 'chatterbox', name: 'Chatterbox', badge: 'recommended' }, |
|
|
{ id: 'kokoro', name: 'Kokoro', badge: 'coming soon', disabled: true }, |
|
|
]; |
|
|
|
|
|
const voices = [ |
|
|
{ |
|
|
id: 'andrew', |
|
|
name: 'Andrew', |
|
|
description: 'Older British man who speaks clearly and kindly', |
|
|
sample: '/voices/andrew.mp3', |
|
|
preview_url: |
|
|
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/andrew.mp3', |
|
|
}, |
|
|
{ |
|
|
id: 'lily', |
|
|
name: 'Jasmine', |
|
|
description: 'Warm, conversational tone of a woman in her 30s', |
|
|
sample: '/voices/lily.mp3', |
|
|
preview_url: |
|
|
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/lily.mp3', |
|
|
}, |
|
|
{ |
|
|
id: 'pirate', |
|
|
name: 'Pirate', |
|
|
description: 'Young male pirate-y voice that speaks gruffly and with excitement', |
|
|
sample: '/voices/pirate.mp3', |
|
|
preview_url: |
|
|
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/pirate.mp3', |
|
|
}, |
|
|
{ |
|
|
id: 'fairy', |
|
|
name: 'Fairy', |
|
|
description: 'High and airy female voice that bursts with excitement', |
|
|
sample: '/voices/fairy.mp3', |
|
|
preview_url: |
|
|
'https://huggingface.co/spaces/abidlabs/hfstudio/resolve/main/frontend/static/voices/fairy.mp3', |
|
|
}, |
|
|
]; |
|
|
|
|
|
async function generateSpeech() { |
|
|
if (!text.trim()) return; |
|
|
|
|
|
const response = await fetch('/api/auth/user', { credentials: 'include' }); |
|
|
if (!response.ok) { |
|
|
window.dispatchEvent(new CustomEvent('show-login-prompt')); |
|
|
return; |
|
|
} |
|
|
|
|
|
isGenerating = true; |
|
|
audioUrl = null; |
|
|
currentTime = 0; |
|
|
isPlaying = false; |
|
|
audioTitle = text.length > 30 ? text.substring(0, 30) + '...' : text; |
|
|
|
|
|
try { |
|
|
const requestBody = { |
|
|
text: text, |
|
|
voice_id: selectedVoice.toLowerCase(), |
|
|
model_id: selectedModel.toLowerCase(), |
|
|
mode: 'api', |
|
|
parameters: { |
|
|
exaggeration: exaggeration, |
|
|
temperature: temperature, |
|
|
}, |
|
|
}; |
|
|
|
|
|
const response = await fetch('/api/tts/generate', { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
}, |
|
|
credentials: 'include', |
|
|
body: JSON.stringify(requestBody), |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response.text(); |
|
|
throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`); |
|
|
} |
|
|
|
|
|
const result = await response.json(); |
|
|
|
|
|
if (result.success && result.audio_url) { |
|
|
audioUrl = result.audio_url; |
|
|
|
|
|
|
|
|
await saveToHistory(requestBody, result); |
|
|
|
|
|
setTimeout(() => { |
|
|
if (audioElement) { |
|
|
audioElement.play().catch(() => { |
|
|
|
|
|
}); |
|
|
} |
|
|
}, 100); |
|
|
} else { |
|
|
const errorMessage = result.error || 'Unknown error occurred'; |
|
|
showError('Generation Failed', errorMessage); |
|
|
audioUrl = null; |
|
|
} |
|
|
} catch (error) { |
|
|
showError( |
|
|
'Network Error', |
|
|
'Failed to connect to the server. Please check your connection and try again.' |
|
|
); |
|
|
audioUrl = null; |
|
|
} finally { |
|
|
isGenerating = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function togglePlayPause() { |
|
|
if (audioElement) { |
|
|
if (isPlaying) { |
|
|
audioElement.pause(); |
|
|
} else { |
|
|
audioElement.play(); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function handleAudioLoad() { |
|
|
if (audioElement) { |
|
|
duration = audioElement.duration; |
|
|
} |
|
|
} |
|
|
|
|
|
function handleTimeUpdate() { |
|
|
if (audioElement) { |
|
|
currentTime = audioElement.currentTime; |
|
|
} |
|
|
} |
|
|
|
|
|
function handlePlay() { |
|
|
isPlaying = true; |
|
|
} |
|
|
|
|
|
function handlePause() { |
|
|
isPlaying = false; |
|
|
} |
|
|
|
|
|
function formatTime(seconds) { |
|
|
const mins = Math.floor(seconds / 60); |
|
|
const secs = Math.floor(seconds % 60); |
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`; |
|
|
} |
|
|
|
|
|
function downloadAudio() { |
|
|
if (audioUrl) { |
|
|
const a = document.createElement('a'); |
|
|
a.href = audioUrl; |
|
|
a.download = 'speech.wav'; |
|
|
a.click(); |
|
|
} |
|
|
} |
|
|
|
|
|
function shareAudio() {} |
|
|
|
|
|
function playSampleVoice(voice, event) { |
|
|
event.stopPropagation(); |
|
|
|
|
|
if (playingSampleVoice === voice.name) { |
|
|
if (sampleAudioElement) { |
|
|
sampleAudioElement.pause(); |
|
|
sampleAudioElement.currentTime = 0; |
|
|
} |
|
|
playingSampleVoice = null; |
|
|
} else { |
|
|
if (sampleAudioElement) { |
|
|
sampleAudioElement.pause(); |
|
|
} |
|
|
playingSampleVoice = voice.name; |
|
|
|
|
|
const sampleUrl = voice.sample || '/samples/harvard.wav'; |
|
|
|
|
|
if (!sampleAudioElement) { |
|
|
sampleAudioElement = new Audio(sampleUrl); |
|
|
sampleAudioElement.addEventListener('ended', () => { |
|
|
playingSampleVoice = null; |
|
|
}); |
|
|
} else { |
|
|
sampleAudioElement.src = sampleUrl; |
|
|
} |
|
|
|
|
|
sampleAudioElement.play().catch((err) => { |
|
|
playingSampleVoice = null; |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
function handleKeyDown(event) { |
|
|
if (event.key === 'Enter' && !event.shiftKey) { |
|
|
event.preventDefault(); |
|
|
generateSpeech(); |
|
|
} |
|
|
} |
|
|
|
|
|
function handleClickOutside(event) { |
|
|
if (!event.target.closest('.model-dropdown')) { |
|
|
modelDropdownOpen = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function showError(message, details = '') { |
|
|
errorMessage = message; |
|
|
errorDetails = details; |
|
|
showErrorModal = true; |
|
|
} |
|
|
|
|
|
function closeErrorModal() { |
|
|
showErrorModal = false; |
|
|
errorMessage = ''; |
|
|
errorDetails = ''; |
|
|
} |
|
|
|
|
|
function refreshText() { |
|
|
currentBookIndex = (currentBookIndex + 1) % famousBookOpeners.length; |
|
|
text = famousBookOpeners[currentBookIndex]; |
|
|
} |
|
|
|
|
|
async function saveToHistory(requestBody, result) { |
|
|
try { |
|
|
const pythonCode = `# Generate speech |
|
|
client.text_to_speech( |
|
|
text="${requestBody.text.replace(/"/g, '\\"')}", |
|
|
voice_id="${requestBody.voice_id}", |
|
|
model_id="${requestBody.model_id}", |
|
|
exaggeration=${requestBody.parameters.exaggeration}, |
|
|
temperature=${requestBody.parameters.temperature} |
|
|
)`; |
|
|
|
|
|
await fetch('/api/history/save', { |
|
|
method: 'POST', |
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
credentials: 'include', |
|
|
body: JSON.stringify({ |
|
|
code: pythonCode, |
|
|
result_type: 'audio', |
|
|
result_data: { |
|
|
url: result.audio_url, |
|
|
title: audioTitle, |
|
|
type: 'audio' |
|
|
}, |
|
|
entry_type: 'generation' |
|
|
}) |
|
|
}); |
|
|
|
|
|
// Update history count |
|
|
await loadHistoryCount(); |
|
|
} catch (error) { |
|
|
console.error('Error saving to history:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function loadHistoryCount() { |
|
|
try { |
|
|
const response = await fetch('/api/history/load', { |
|
|
method: 'GET', |
|
|
credentials: 'include', |
|
|
}); |
|
|
|
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
const generationEntries = data.entries.filter((e) => e.entry_type === 'generation'); |
|
|
historyCount = generationEntries.length; |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error loading history count:', error); |
|
|
historyCount = 0; |
|
|
} |
|
|
} |
|
|
|
|
|
onMount(async () => { |
|
|
await loadHistoryCount(); |
|
|
}); |
|
|
</script> |
|
|
|
|
|
<svelte:head> |
|
|
<title>Text to Speech - HFStudio</title> |
|
|
</svelte:head> |
|
|
|
|
|
<div class="flex flex-col h-full" on:click={handleClickOutside}> |
|
|
<!-- Top navbar --> |
|
|
<div class="flex items-center justify-end px-4 py-4 border-b border-gray-200 min-h-[73px]"> |
|
|
<a |
|
|
href="/code-recorder" |
|
|
class="px-3 py-1.5 text-sm font-medium rounded transition-colors text-gray-600 hover:bg-gray-50 flex items-center gap-1 bg-gray-100" |
|
|
> |
|
|
<Code size={14} /> |
|
|
Code Recorder |
|
|
{#if historyCount > 0} |
|
|
<span class="ml-1 px-1.5 py-0.5 bg-gray-500 text-white text-xs rounded-full min-w-[18px] text-center"> |
|
|
{historyCount} |
|
|
</span> |
|
|
{/if} |
|
|
</a> |
|
|
</div> |
|
|
|
|
|
<!-- Main content area --> |
|
|
<div class="flex-1 flex"> |
|
|
<!-- Main content area --> |
|
|
<div class="flex-1 flex flex-col p-6 relative"> |
|
|
<!-- Text input area --> |
|
|
<div class="flex-1 relative"> |
|
|
<div class="absolute -top-2 right-0 flex items-center gap-2 z-10"> |
|
|
<span class="text-sm text-gray-400"> |
|
|
{text.length.toLocaleString()} / 1,000 characters |
|
|
</span> |
|
|
<button |
|
|
on:click={refreshText} |
|
|
class="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors" |
|
|
title="Refresh with famous book opening" |
|
|
> |
|
|
<Shuffle size={16} /> |
|
|
</button> |
|
|
</div> |
|
|
<textarea |
|
|
bind:value={text} |
|
|
maxlength="1000" |
|
|
class="w-full h-full p-6 bg-white resize-none border-0 focus:outline-none text-gray-900 text-base leading-relaxed" |
|
|
placeholder="Type the text you'd like to convert to spoken audio here..." |
|
|
autofocus |
|
|
on:keydown={handleKeyDown} |
|
|
/> |
|
|
</div> |
|
|
|
|
|
<!-- Generate button at bottom --> |
|
|
<div class="absolute bottom-4 left-0 right-0 px-2"> |
|
|
<button |
|
|
on:click={generateSpeech} |
|
|
disabled={isGenerating || !text.trim()} |
|
|
class="w-full px-6 py-3 bg-gradient-to-r from-amber-400 to-orange-500 text-white rounded-lg font-medium hover:from-amber-500 hover:to-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shadow-sm" |
|
|
> |
|
|
{#if isGenerating} |
|
|
<Loader2 size={20} class="animate-spin" /> |
|
|
Generating... |
|
|
{:else} |
|
|
<Play size={20} /> |
|
|
Generate speech |
|
|
{/if} |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<!-- Generated audio section --> |
|
|
{#if audioUrl} |
|
|
<div class="p-4 border border-gray-200 rounded-lg bg-white mb-6"> |
|
|
<!-- Audio title and voice info --> |
|
|
<div class="flex items-center gap-3 mb-4"> |
|
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div> |
|
|
<div class="flex-1"> |
|
|
<h3 class="font-medium text-gray-900 text-sm">{audioTitle}</h3> |
|
|
<p class="text-xs text-gray-500">{selectedVoice} • Created 1 second ago</p> |
|
|
</div> |
|
|
<!-- Mini action buttons --> |
|
|
<div class="flex items-center gap-2"> |
|
|
<button |
|
|
on:click={shareAudio} |
|
|
class="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-50 transition-colors" |
|
|
> |
|
|
<Share size={14} class="text-gray-600" /> |
|
|
<span class="text-gray-700">Share</span> |
|
|
</button> |
|
|
<button |
|
|
on:click={downloadAudio} |
|
|
class="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 rounded-md hover:bg-gray-50 transition-colors" |
|
|
> |
|
|
<span class="text-gray-700">Download</span> |
|
|
<Download size={14} class="text-gray-600" /> |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Mini audio controls --> |
|
|
<div class="flex items-center gap-3 mb-4"> |
|
|
<!-- Play/Pause button --> |
|
|
<button |
|
|
on:click={togglePlayPause} |
|
|
class="w-8 h-8 bg-black rounded-full flex items-center justify-center hover:bg-gray-800 transition-colors" |
|
|
> |
|
|
{#if isPlaying} |
|
|
<div class="pause-filled text-white"></div> |
|
|
{:else} |
|
|
<Play size={14} class="text-white ml-0.5" /> |
|
|
{/if} |
|
|
</button> |
|
|
|
|
|
<!-- Progress bar --> |
|
|
<div class="flex-1 flex items-center gap-2"> |
|
|
<span class="text-xs text-gray-500 font-mono">{formatTime(currentTime)}</span> |
|
|
<div class="flex-1 h-1 bg-gray-200 rounded-full cursor-pointer"> |
|
|
<div |
|
|
class="h-full bg-gradient-to-r from-amber-400 to-orange-500 rounded-full transition-all" |
|
|
style="width: {(currentTime / duration) * 100}%" |
|
|
></div> |
|
|
</div> |
|
|
<span class="text-xs text-gray-500 font-mono">{formatTime(duration)}</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Hidden audio element --> |
|
|
{#if audioUrl} |
|
|
<audio |
|
|
bind:this={audioElement} |
|
|
src={audioUrl} |
|
|
on:loadedmetadata={handleAudioLoad} |
|
|
on:timeupdate={handleTimeUpdate} |
|
|
on:play={handlePlay} |
|
|
on:pause={handlePause} |
|
|
style="display: none;" |
|
|
/> |
|
|
{/if} |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<!-- Right panel --> |
|
|
<div class="w-80 border-l border-gray-200 bg-white p-3 overflow-y-auto"> |
|
|
<!-- Model selector --> |
|
|
<div class="mb-4 relative model-dropdown"> |
|
|
<h3 class="text-sm font-medium text-gray-900 mb-2">Model</h3> |
|
|
<button |
|
|
on:click={() => (modelDropdownOpen = !modelDropdownOpen)} |
|
|
class="w-full p-2.5 border border-gray-200 rounded-lg bg-white text-xs focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent appearance-none bg-no-repeat bg-right pr-10 shadow-sm text-left flex items-center justify-between" |
|
|
> |
|
|
<span> |
|
|
{#each models as model} |
|
|
{#if model.name === selectedModel} |
|
|
{model.name}{#if model.badge} <span class="text-xs text-gray-500" |
|
|
>({model.badge})</span |
|
|
>{/if} |
|
|
{/if} |
|
|
{/each} |
|
|
</span> |
|
|
<ChevronDown size={14} class="text-gray-500" /> |
|
|
</button> |
|
|
|
|
|
{#if modelDropdownOpen} |
|
|
<div |
|
|
class="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-10" |
|
|
> |
|
|
{#each models as model} |
|
|
<button |
|
|
class="w-full px-3 py-1.5 text-left transition-colors text-xs {model.disabled |
|
|
? 'opacity-50 cursor-not-allowed' |
|
|
: 'hover:bg-gray-50'} {model.name === selectedModel ? 'bg-gray-100' : ''}" |
|
|
disabled={model.disabled} |
|
|
on:click={() => { |
|
|
if (!model.disabled) { |
|
|
selectedModel = model.name; |
|
|
modelDropdownOpen = false; |
|
|
} |
|
|
}} |
|
|
> |
|
|
{model.name}{#if model.badge} <span class="text-xs text-gray-500" |
|
|
>({model.badge})</span |
|
|
>{/if} |
|
|
</button> |
|
|
{/each} |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
<!-- Pricing info --> |
|
|
<div class="mt-1.5 text-xs text-gray-500"> |
|
|
Estimated $0.025 per 1000 characters • <a |
|
|
href="https://huggingface.co/settings/billing" |
|
|
target="_blank" |
|
|
class="text-amber-600 hover:text-amber-700 underline">Billing ⤴</a |
|
|
> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="mb-4"> |
|
|
<div class="mb-2"> |
|
|
<h3 class="text-sm font-medium text-gray-900">Voice</h3> |
|
|
</div> |
|
|
|
|
|
<div class="space-y-1.5"> |
|
|
{#each voices as voice} |
|
|
<button |
|
|
class="w-full flex items-center justify-between p-1.5 rounded-lg hover:bg-gray-50 transition-colors text-left group border border-transparent |
|
|
{voice.name === selectedVoice ? 'bg-gray-100 border-gray-200' : ''}" |
|
|
on:click={() => (selectedVoice = voice.name)} |
|
|
> |
|
|
<div class="flex items-center gap-2.5 flex-1 min-w-0"> |
|
|
<div |
|
|
class="w-8 h-8 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center text-white text-xs font-semibold flex-shrink-0" |
|
|
> |
|
|
{voice.name[0]} |
|
|
</div> |
|
|
<div class="flex-1 min-w-0"> |
|
|
<div class="text-xs font-medium text-gray-900">{voice.name}</div> |
|
|
<div class="text-xs text-gray-500 leading-relaxed"> |
|
|
{voice.description} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<button |
|
|
on:click={(e) => playSampleVoice(voice, e)} |
|
|
class="p-1.5 rounded-full hover:bg-gray-200 transition-colors flex-shrink-0 ml-2 w-7 h-7 flex items-center justify-center" |
|
|
title="Play sample" |
|
|
> |
|
|
{#if playingSampleVoice === voice.name} |
|
|
<Pause size={14} class="text-gray-600" /> |
|
|
{:else} |
|
|
<Play size={14} class="text-gray-600" /> |
|
|
{/if} |
|
|
</button> |
|
|
</button> |
|
|
{/each} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="space-y-3 pt-3 border-t border-gray-200"> |
|
|
<!-- Exaggeration control --> |
|
|
<div> |
|
|
<div class="flex justify-between mb-0.5"> |
|
|
<label for="exaggeration-slider" class="text-xs font-medium text-gray-700" |
|
|
>Exaggeration</label |
|
|
> |
|
|
<span class="text-xs text-gray-500">{exaggeration.toFixed(2)}</span> |
|
|
</div> |
|
|
<input |
|
|
id="exaggeration-slider" |
|
|
type="range" |
|
|
bind:value={exaggeration} |
|
|
min="0" |
|
|
max="1" |
|
|
step="0.01" |
|
|
class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-hf" |
|
|
/> |
|
|
<div class="flex justify-between text-xs text-gray-400 mt-0.5"> |
|
|
<span>None</span> |
|
|
<span>Exaggerated</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Stability control --> |
|
|
<div> |
|
|
<div class="flex justify-between mb-0.5"> |
|
|
<label for="temperature-slider" class="text-xs font-medium text-gray-700" |
|
|
>Stability</label |
|
|
> |
|
|
<span class="text-xs text-gray-500">{temperature.toFixed(2)}</span> |
|
|
</div> |
|
|
<input |
|
|
id="temperature-slider" |
|
|
type="range" |
|
|
bind:value={temperature} |
|
|
min="0" |
|
|
max="1" |
|
|
step="0.01" |
|
|
class="w-full h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-hf" |
|
|
/> |
|
|
<div class="flex justify-between text-xs text-gray-400 mt-0.5"> |
|
|
<span>More stable</span> |
|
|
<span>More variable</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- GitHub link at bottom --> |
|
|
<div class="mt-auto pt-3 flex justify-end"> |
|
|
<a |
|
|
href="https://github.com/gradio-app/hfstudio" |
|
|
target="_blank" |
|
|
class="p-2 text-gray-400 hover:text-gray-600 transition-colors" |
|
|
title="View on GitHub" |
|
|
> |
|
|
<svg |
|
|
xmlns="http://www.w3.org/2000/svg" |
|
|
width="20" |
|
|
height="20" |
|
|
viewBox="0 0 24 24" |
|
|
fill="currentColor" |
|
|
> |
|
|
<path |
|
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" |
|
|
/> |
|
|
</svg> |
|
|
</a> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Error Modal --> |
|
|
{#if showErrorModal} |
|
|
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> |
|
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[80vh] flex flex-col"> |
|
|
<!-- Header --> |
|
|
<div |
|
|
class="flex items-center justify-between p-6 border-b border-gray-200 bg-red-50 flex-shrink-0" |
|
|
> |
|
|
<div class="flex items-center gap-3 min-w-0"> |
|
|
<div |
|
|
class="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0" |
|
|
> |
|
|
<AlertCircle size={20} class="text-red-600" /> |
|
|
</div> |
|
|
<div class="min-w-0"> |
|
|
<h3 class="text-lg font-semibold text-gray-900 truncate">{errorMessage}</h3> |
|
|
<p class="text-sm text-gray-600">An error occurred while processing your request</p> |
|
|
</div> |
|
|
</div> |
|
|
<button |
|
|
on:click={closeErrorModal} |
|
|
class="p-2 hover:bg-red-100 rounded-full transition-colors flex-shrink-0" |
|
|
title="Close" |
|
|
> |
|
|
<X size={20} class="text-gray-500" /> |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<!-- Content --> |
|
|
<div class="p-6 overflow-y-auto flex-1 min-h-0"> |
|
|
{#if errorDetails} |
|
|
<div class="bg-gray-50 rounded-lg p-4 border"> |
|
|
<h4 class="text-sm font-medium text-gray-900 mb-2">Error Details:</h4> |
|
|
<pre |
|
|
class="text-xs text-gray-700 whitespace-pre-wrap font-mono leading-relaxed break-words">{#if errorDetails.includes('exceeded your monthly included credits')}{@html errorDetails.replace( |
|
|
'Subscribe to PRO', |
|
|
'<a href="https://huggingface.co/settings/billing" target="_blank" class="text-amber-600 hover:text-amber-700 underline font-medium">Subscribe to PRO</a>' |
|
|
)}{:else}{errorDetails}{/if}</pre> |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<!-- Footer --> |
|
|
<div |
|
|
class="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50 flex-shrink-0" |
|
|
> |
|
|
<button |
|
|
on:click={closeErrorModal} |
|
|
class="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" |
|
|
> |
|
|
Close |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
<style> |
|
|
.pause-filled::after { |
|
|
content: ''; |
|
|
width: 3px; |
|
|
height: 12px; |
|
|
background: currentColor; |
|
|
display: inline-block; |
|
|
margin-right: 2px; |
|
|
} |
|
|
.pause-filled::before { |
|
|
content: ''; |
|
|
width: 3px; |
|
|
height: 12px; |
|
|
background: currentColor; |
|
|
display: inline-block; |
|
|
margin-right: 2px; |
|
|
} |
|
|
</style> |