diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b212ac55..2c2c43d2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -99,6 +99,8 @@ jobs:
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: tauri
tagName: v__VERSION__
@@ -111,6 +113,9 @@ jobs:
- **macOS**: Download the `.dmg` file
- **Windows**: Download the `.msi` installer
- **Linux**: Download the `.AppImage` or `.deb` package
+
+ The app includes automatic updates - future updates will be installed automatically.
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
+ includeUpdaterJson: true
diff --git a/.gitignore b/.gitignore
index 2cbd3e5c..05f7ef0d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,7 +9,7 @@ __pycache__/
venv/
env/
ENV/
-
+*.prompt
# Build outputs
dist/
build/
diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md
index 0a885860..975b1447 100644
--- a/CURRENT_STATE.md
+++ b/CURRENT_STATE.md
@@ -437,7 +437,7 @@ projects
## 💡 Development Workflow
-1. **Start backend**: `bun run dev:backend` (or via Tauri)
+1. **Start backend**: `bun run dev:server` (or via Tauri)
2. **Start frontend**: `bun run dev` (Tauri) or `bun run dev:web` (web)
3. **Generate API client**: `bun run generate:api` (after backend changes)
4. **Build server binary**: `bun run build:server` (for Tauri bundling)
diff --git a/app/package.json b/app/package.json
index 5b99c73e..927c591e 100644
--- a/app/package.json
+++ b/app/package.json
@@ -13,34 +13,38 @@
"check": "biome check --write src"
},
"dependencies": {
- "@tauri-apps/api": "^2.0.0",
- "react": "^18.3.0",
- "react-dom": "^18.3.0",
- "@tanstack/react-query": "^5.0.0",
- "@tanstack/react-query-devtools": "^5.0.0",
- "zustand": "^4.5.0",
- "react-hook-form": "^7.53.0",
"@hookform/resolvers": "^3.9.0",
- "zod": "^3.23.8",
- "wavesurfer.js": "^7.0.0",
- "lucide-react": "^0.454.0",
- "date-fns": "^3.6.0",
- "class-variance-authority": "^0.7.0",
- "clsx": "^2.1.1",
- "tailwind-merge": "^2.5.4",
+ "@radix-ui/react-alert-dialog": "^1.1.1",
+ "@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
+ "@radix-ui/react-popover": "^1.1.1",
+ "@radix-ui/react-progress": "^1.1.0",
+ "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
+ "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
- "@radix-ui/react-popover": "^1.1.1",
- "@radix-ui/react-progress": "^1.1.0",
- "@radix-ui/react-scroll-area": "^1.1.0",
- "@radix-ui/react-avatar": "^1.1.0",
- "@radix-ui/react-alert-dialog": "^1.1.1"
+ "@tanstack/react-query": "^5.0.0",
+ "@tanstack/react-query-devtools": "^5.0.0",
+ "@tauri-apps/api": "^2.0.0",
+ "@tauri-apps/plugin-process": "^2.3.1",
+ "@tauri-apps/plugin-updater": "^2.9.0",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
+ "date-fns": "^3.6.0",
+ "framer-motion": "^12.29.0",
+ "lucide-react": "^0.454.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "react-hook-form": "^7.53.0",
+ "tailwind-merge": "^2.5.4",
+ "wavesurfer.js": "^7.0.0",
+ "zod": "^3.23.8",
+ "zustand": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 52c6a18b..7fc4411e 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -3,48 +3,73 @@ import { GenerationForm } from '@/components/Generation/GenerationForm';
import { HistoryTable } from '@/components/History/HistoryTable';
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
+import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
import { Toaster } from '@/components/ui/toaster';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { Sidebar } from '@/components/Sidebar';
-import { isTauri, startServer, stopServer } from '@/lib/tauri';
+import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
+import { UpdateNotification } from '@/components/UpdateNotification';
+import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri';
// Track if server is starting to prevent duplicate starts
let serverStarting = false;
function App() {
- const [activeTab, setActiveTab] = useState('profiles');
+ const [activeTab, setActiveTab] = useState('main');
const [serverReady, setServerReady] = useState(false);
- // Auto-start server when running in Tauri
+ // Setup window close handler and auto-start server when running in Tauri (production only)
useEffect(() => {
- if (!isTauri() || serverStarting) {
+ if (!isTauri()) {
+ return;
+ }
+
+ // Setup window close handler to check setting and stop server if needed
+ // This works in both dev and prod, but will only stop server if it was started by the app
+ setupWindowCloseHandler().catch((error) => {
+ console.error('Failed to setup window close handler:', error);
+ });
+
+ // Only auto-start server in production mode
+ // In dev mode, user runs server separately
+ if (!import.meta.env?.PROD) {
+ console.log('Dev mode: Skipping auto-start of server (run it separately)');
+ setServerReady(true); // Mark as ready so UI doesn't show loading screen
+ // Mark that server was not started by app (so we don't try to stop it on close)
+ // @ts-expect-error - adding property to window
+ window.__voiceboxServerStartedByApp = false;
+ return;
+ }
+
+ // Auto-start server in production
+ if (serverStarting) {
return;
}
serverStarting = true;
- console.log('Running in Tauri, starting bundled server...');
+ console.log('Production mode: Starting bundled server...');
startServer(false)
.then(() => {
console.log('Server is ready');
setServerReady(true);
+ // Mark that we started the server (so we know to stop it on close)
+ // @ts-expect-error - adding property to window
+ window.__voiceboxServerStartedByApp = true;
})
.catch((error) => {
console.error('Failed to auto-start server:', error);
serverStarting = false;
+ // @ts-expect-error - adding property to window
+ window.__voiceboxServerStartedByApp = false;
});
// Cleanup: stop server on actual unmount (not StrictMode remount)
+ // Note: Window close is handled separately in Tauri Rust code
return () => {
- // In production builds, we want to stop the server on unmount
- // In dev mode, React StrictMode causes remounts, so we skip cleanup
- if (import.meta.env?.PROD) {
- stopServer().catch((error) => {
- console.error('Failed to stop server on cleanup:', error);
- });
- serverStarting = false;
- }
+ // Window close event handles server shutdown based on setting
+ serverStarting = false;
};
}, []);
@@ -61,40 +86,51 @@ function App() {
}
return (
-
-
-
-
-
- {activeTab === 'profiles' && (
-
- )}
-
- {activeTab === 'generate' && (
-
-
-
- )}
-
- {activeTab === 'history' && (
-
-
-
- )}
-
- {activeTab === 'settings' && (
-
-
-
-
+
+
+
+
+
+
+
+
+ {activeTab === 'settings' ? (
+
+
+
+
+
+ {isTauri() &&
}
+
-
-
- )}
-
-
+ ) : (
+ // Main view: Profiles top left, Generator bottom left, History right
+
+ {/* Left Column */}
+
+ {/* Profiles - Top Left */}
+
+
+ {/* Generator - Bottom Left */}
+
+
+
+
+
+ {/* Right Column - History */}
+
+
+
+
+ )}
+
+
+
+
+ {/* Audio Player - always visible except on settings */}
+ {activeTab !== 'settings' &&
}
diff --git a/app/src/assets/voicebox-logo.png b/app/src/assets/voicebox-logo.png
new file mode 100644
index 00000000..eebe5438
Binary files /dev/null and b/app/src/assets/voicebox-logo.png differ
diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx
new file mode 100644
index 00000000..d6ffb5bd
--- /dev/null
+++ b/app/src/components/AudioPlayer/AudioPlayer.tsx
@@ -0,0 +1,477 @@
+import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import WaveSurfer from 'wavesurfer.js';
+import { Button } from '@/components/ui/button';
+import { Slider } from '@/components/ui/slider';
+import { formatAudioDuration } from '@/lib/utils/audio';
+import { usePlayerStore } from '@/stores/playerStore';
+
+export function AudioPlayer() {
+ const {
+ audioUrl,
+ title,
+ isPlaying,
+ currentTime,
+ duration,
+ volume,
+ isLooping,
+ setIsPlaying,
+ setCurrentTime,
+ setDuration,
+ setVolume,
+ toggleLoop,
+ } = usePlayerStore();
+
+ const waveformRef = useRef
(null);
+ const wavesurferRef = useRef(null);
+ const loadingRef = useRef(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Initialize WaveSurfer (only when audioUrl exists and container is ready)
+ useEffect(() => {
+ // Don't initialize if no audioUrl or already initialized
+ if (!audioUrl) {
+ return;
+ }
+
+ if (wavesurferRef.current) {
+ console.log('WaveSurfer already initialized, skipping');
+ return;
+ }
+
+ console.log('Creating NEW WaveSurfer instance');
+
+ // Wait for container to be properly rendered
+ const initWaveSurfer = () => {
+ const container = waveformRef.current;
+ if (!container) {
+ // Container not ready yet, retry
+ setTimeout(initWaveSurfer, 50);
+ return;
+ }
+
+ // Check if container has dimensions and is visible
+ const rect = container.getBoundingClientRect();
+ const style = window.getComputedStyle(container);
+ const isVisible =
+ rect.width > 0 &&
+ rect.height > 0 &&
+ style.display !== 'none' &&
+ style.visibility !== 'hidden';
+
+ if (!isVisible) {
+ // Retry after a short delay
+ setTimeout(initWaveSurfer, 50);
+ return;
+ }
+
+ console.log('Initializing WaveSurfer...', {
+ container,
+ width: rect.width,
+ height: rect.height,
+ });
+
+ try {
+ // Get computed CSS variable values
+ const root = document.documentElement;
+ const getCSSVar = (varName: string) => {
+ const value = getComputedStyle(root).getPropertyValue(varName).trim();
+ return value ? `hsl(${value})` : '';
+ };
+
+ const waveColor = getCSSVar('--muted');
+ const progressColor = getCSSVar('--accent');
+ const cursorColor = getCSSVar('--accent');
+
+ const wavesurfer = WaveSurfer.create({
+ container: container,
+ waveColor: waveColor,
+ progressColor: progressColor,
+ cursorColor: cursorColor,
+ barWidth: 2,
+ barRadius: 2,
+ height: 80,
+ normalize: true,
+ backend: 'WebAudio',
+ interact: true, // Enable interaction (click to seek)
+ mediaControls: false, // Don't show native controls
+ });
+
+ wavesurferRef.current = wavesurfer;
+ console.log('WaveSurfer created successfully');
+ } catch (error) {
+ console.error('Failed to create WaveSurfer:', error);
+ setError(
+ `Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ return;
+ }
+
+ const wavesurfer = wavesurferRef.current;
+ if (!wavesurfer) return;
+
+ // Update store when time changes
+ wavesurfer.on('timeupdate', (time) => {
+ setCurrentTime(time);
+ });
+
+ // Update store when duration is loaded
+ wavesurfer.on('ready', () => {
+ const dur = wavesurfer.getDuration();
+ setDuration(dur);
+ loadingRef.current = false;
+ setIsLoading(false);
+ setError(null);
+ console.log('Audio ready, duration:', dur);
+ console.log('Waveform should be visible now');
+
+ // Ensure volume is set
+ const currentVolume = usePlayerStore.getState().volume;
+ wavesurfer.setVolume(currentVolume);
+
+ // Get the underlying audio element and ensure it's not muted
+ const mediaElement = wavesurfer.getMediaElement();
+ if (mediaElement) {
+ mediaElement.volume = currentVolume;
+ mediaElement.muted = false;
+ console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
+ }
+
+ // Auto-play when ready
+ // Use a small delay to ensure audio element is fully ready
+ setTimeout(() => {
+ wavesurfer.play().catch((error) => {
+ console.error('Failed to autoplay:', error);
+ // Don't show error for autoplay failures (browser restrictions)
+ });
+ }, 100);
+ });
+
+ // Handle play/pause
+ wavesurfer.on('play', () => {
+ setIsPlaying(true);
+ // Ensure audio element is not muted when playing
+ const mediaElement = wavesurfer.getMediaElement();
+ if (mediaElement) {
+ mediaElement.muted = false;
+ const currentVolume = usePlayerStore.getState().volume;
+ mediaElement.volume = currentVolume;
+ console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted);
+ }
+ });
+ wavesurfer.on('pause', () => setIsPlaying(false));
+ wavesurfer.on('finish', () => {
+ // Check loop state from store
+ const loop = usePlayerStore.getState().isLooping;
+ if (loop) {
+ wavesurfer.seekTo(0);
+ wavesurfer.play();
+ } else {
+ setIsPlaying(false);
+ }
+ });
+
+ // Handle errors
+ wavesurfer.on('error', (error) => {
+ console.error('WaveSurfer error:', error);
+ setIsLoading(false);
+ setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
+ });
+
+ // Handle loading
+ wavesurfer.on('loading', (percent) => {
+ setIsLoading(true);
+ if (percent === 100) {
+ setIsLoading(false);
+ }
+ });
+
+ // Load audio immediately if audioUrl is already set
+ if (audioUrl) {
+ console.log('WaveSurfer ready, loading audio:', audioUrl);
+ loadingRef.current = true;
+ setIsLoading(true);
+ // Stop any current playback before loading new audio
+ if (wavesurfer.isPlaying()) {
+ wavesurfer.pause();
+ }
+ wavesurfer
+ .load(audioUrl)
+ .then(() => {
+ console.log('Audio loaded into WaveSurfer');
+ loadingRef.current = false;
+ })
+ .catch((error) => {
+ console.error('Failed to load audio into WaveSurfer:', error);
+ loadingRef.current = false;
+ setIsLoading(false);
+ setError(
+ `Failed to load audio: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ });
+ }
+ };
+
+ // Use double requestAnimationFrame to ensure DOM is fully rendered
+ let rafId1: number;
+ let rafId2: number;
+ let timeoutId: number | null = null;
+
+ rafId1 = requestAnimationFrame(() => {
+ rafId2 = requestAnimationFrame(() => {
+ // Add a small delay to ensure container is fully laid out
+ timeoutId = setTimeout(() => {
+ initWaveSurfer();
+ }, 10);
+ });
+ });
+
+ return () => {
+ console.log('Cleaning up WaveSurfer initialization effect');
+ if (rafId1) cancelAnimationFrame(rafId1);
+ if (rafId2) cancelAnimationFrame(rafId2);
+ if (timeoutId) clearTimeout(timeoutId);
+ if (wavesurferRef.current) {
+ console.log('Destroying WaveSurfer instance');
+ try {
+ const mediaElement = wavesurferRef.current.getMediaElement();
+ if (mediaElement) {
+ mediaElement.pause();
+ mediaElement.src = '';
+ }
+ wavesurferRef.current.destroy();
+ } catch (error) {
+ console.error('Error destroying WaveSurfer:', error);
+ }
+ wavesurferRef.current = null;
+ }
+ };
+ }, [audioUrl, setIsPlaying, setCurrentTime, setDuration]);
+
+ // Load audio when URL changes (only if WaveSurfer is already initialized)
+ useEffect(() => {
+ const wavesurfer = wavesurferRef.current;
+
+ if (!audioUrl || !wavesurfer) {
+ // Reset state when no audio or WaveSurfer not ready
+ if (!audioUrl && wavesurfer) {
+ wavesurfer.pause();
+ wavesurfer.seekTo(0);
+ loadingRef.current = false;
+ setIsLoading(false);
+ setDuration(0);
+ setCurrentTime(0);
+ setError(null);
+ }
+ return;
+ }
+
+ // CRITICAL: Force stop any current playback and cancel any pending loads
+ // This must happen BEFORE any early returns
+ console.log('Audio URL changed to:', audioUrl);
+
+ // COMPLETELY stop and destroy the current audio
+ try {
+ // First pause if playing
+ if (wavesurfer.isPlaying()) {
+ console.log('Pausing current playback');
+ wavesurfer.pause();
+ }
+
+ // Stop the media element explicitly
+ const mediaElement = wavesurfer.getMediaElement();
+ if (mediaElement) {
+ console.log('Stopping media element');
+ mediaElement.pause();
+ mediaElement.currentTime = 0;
+ mediaElement.src = '';
+ }
+
+ // Use empty() to completely destroy the waveform and media element
+ console.log('Calling wavesurfer.empty() to destroy audio');
+ wavesurfer.empty();
+ } catch (error) {
+ console.error('Error stopping previous audio:', error);
+ // Continue anyway to load new audio
+ }
+
+ // Reset loading state to allow new load (cancel any pending loads)
+ loadingRef.current = false;
+
+ // Now start the new load
+ loadingRef.current = true;
+ setIsLoading(true);
+ setError(null);
+ setCurrentTime(0);
+ setDuration(0);
+
+ // Load new audio
+ console.log('Starting new audio load for:', audioUrl);
+ wavesurfer
+ .load(audioUrl)
+ .then(() => {
+ console.log('Audio load promise resolved');
+ // Don't set loading to false here - wait for 'ready' event
+ })
+ .catch((error) => {
+ console.error('Failed to load audio:', error);
+ console.error('Audio URL:', audioUrl);
+ loadingRef.current = false;
+ setIsLoading(false);
+ setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
+ });
+ }, [audioUrl, setCurrentTime, setDuration]);
+
+ // Sync play/pause state (only when user clicks play/pause button, not auto-sync)
+ // This effect is kept for external state changes but should be minimal
+ useEffect(() => {
+ if (!wavesurferRef.current || duration === 0) return;
+
+ if (isPlaying && wavesurferRef.current.isPlaying() === false) {
+ // Only auto-play if audio is ready
+ wavesurferRef.current.play().catch((error) => {
+ console.error('Failed to play:', error);
+ setIsPlaying(false);
+ setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
+ });
+ } else if (!isPlaying && wavesurferRef.current.isPlaying()) {
+ wavesurferRef.current.pause();
+ }
+ }, [isPlaying, setIsPlaying, duration]);
+
+ // Sync volume
+ useEffect(() => {
+ if (wavesurferRef.current) {
+ wavesurferRef.current.setVolume(volume);
+ // Also ensure the underlying audio element volume is set
+ const mediaElement = wavesurferRef.current.getMediaElement();
+ if (mediaElement) {
+ mediaElement.volume = volume;
+ mediaElement.muted = volume === 0;
+ console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
+ }
+ }
+ }, [volume]);
+
+ // Handle loop - WaveSurfer handles this via the 'finish' event
+
+ const handlePlayPause = () => {
+ if (!wavesurferRef.current) {
+ console.error('WaveSurfer not initialized');
+ return;
+ }
+
+ // Check if audio is loaded
+ if (duration === 0 && !isLoading) {
+ console.error('Audio not loaded yet');
+ setError('Audio not loaded. Please wait...');
+ return;
+ }
+
+ if (wavesurferRef.current.isPlaying()) {
+ wavesurferRef.current.pause();
+ } else {
+ wavesurferRef.current.play().catch((error) => {
+ console.error('Failed to play:', error);
+ setIsPlaying(false);
+ setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
+ });
+ }
+ };
+
+ const handleSeek = (value: number[]) => {
+ if (!wavesurferRef.current || duration === 0) return;
+ const progress = value[0] / 100;
+ wavesurferRef.current.seekTo(progress);
+ };
+
+ const handleVolumeChange = (value: number[]) => {
+ setVolume(value[0] / 100);
+ };
+
+ // Don't render if no audio
+ if (!audioUrl) {
+ return null;
+ }
+
+ return (
+
+
+
+ {/* Play/Pause Button */}
+
+
+ {/* Waveform */}
+
+
+ {duration > 0 && (
+
0 ? [(currentTime / duration) * 100] : [0]}
+ onValueChange={handleSeek}
+ max={100}
+ step={0.1}
+ className="w-full"
+ />
+ )}
+ {isLoading && (
+ Loading audio...
+ )}
+ {error && {error}
}
+
+
+ {/* Time Display */}
+
+ {formatAudioDuration(currentTime)}
+ /
+ {formatAudioDuration(duration)}
+
+
+ {/* Title */}
+ {title && (
+
{title}
+ )}
+
+ {/* Loop Button */}
+
+
+ {/* Volume Control */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/components/Generation/GenerationForm.tsx b/app/src/components/Generation/GenerationForm.tsx
index e4f6cbd9..ffff041b 100644
--- a/app/src/components/Generation/GenerationForm.tsx
+++ b/app/src/components/Generation/GenerationForm.tsx
@@ -1,7 +1,8 @@
import { zodResolver } from '@hookform/resolvers/zod';
-import { Loader2 } from 'lucide-react';
+import { Loader2, Mic } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
+import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@@ -24,42 +25,54 @@ import {
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { useGeneration } from '@/lib/hooks/useGeneration';
-import { useProfiles } from '@/lib/hooks/useProfiles';
+import { useProfile } from '@/lib/hooks/useProfiles';
+import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
- profileId: z.string().min(1, 'Please select a voice profile'),
text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(['en', 'zh']),
seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(),
+ instruct: z.string().max(500).optional(),
});
type GenerationFormValues = z.infer;
export function GenerationForm() {
- const { data: profiles } = useProfiles();
+ const selectedProfileId = useUIStore((state) => state.selectedProfileId);
+ const { data: selectedProfile } = useProfile(selectedProfileId || '');
const generation = useGeneration();
const { toast } = useToast();
const form = useForm({
resolver: zodResolver(generationSchema),
defaultValues: {
- profileId: '',
text: '',
language: 'en',
seed: undefined,
modelSize: '1.7B',
+ instruct: '',
},
});
async function onSubmit(data: GenerationFormValues) {
+ if (!selectedProfileId) {
+ toast({
+ title: 'No profile selected',
+ description: 'Please select a voice profile from the cards above.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
try {
const result = await generation.mutateAsync({
- profile_id: data.profileId,
+ profile_id: selectedProfileId,
text: data.text,
language: data.language,
seed: data.seed,
model_size: data.modelSize,
+ instruct: data.instruct || undefined,
});
toast({
@@ -85,26 +98,35 @@ export function GenerationForm() {
-