Merge branch 'main' into fix/model-size-selection-ignored

This commit is contained in:
Jamie Pine
2026-02-23 11:12:05 -08:00
committed by GitHub
16 changed files with 401 additions and 128 deletions
+63
View File
@@ -0,0 +1,63 @@
name: Build Windows
on:
workflow_dispatch:
jobs:
build-windows:
permissions:
contents: write
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
- name: Build Python server
shell: bash
run: |
cd backend
python build_binary.py
PLATFORM=$(rustc --print host-tuple)
mkdir -p ../tauri/src-tauri/binaries
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
echo "Built voicebox-server-${PLATFORM}.exe"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./tauri/src-tauri -> target"
- name: Install dependencies
run: bun install
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: tauri
tagName: v__VERSION__
releaseName: "voicebox v__VERSION__ (test build)"
releaseBody: "Test build for audio export fix"
releaseDraft: true
prerelease: true
args: ""
includeUpdaterJson: false
@@ -58,6 +58,7 @@ export function AudioSampleRecording({
// Request microphone access when component mounts
useEffect(() => {
if (!showWaveform) return;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
let stream: MediaStream | null = null;
@@ -43,7 +43,7 @@ import {
} from '@/lib/hooks/useProfiles';
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { convertToWav, formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
@@ -505,10 +505,23 @@ export function ProfileForm() {
language: data.language,
});
// Convert non-WAV uploads to WAV so the backend can always use soundfile.
// Recorded audio is already WAV (from useAudioRecording's convertToWav call).
let fileToUpload: File = sampleFile;
if (!sampleFile.type.includes('wav') && !sampleFile.name.toLowerCase().endsWith('.wav')) {
try {
const wavBlob = await convertToWav(sampleFile);
const wavName = sampleFile.name.replace(/\.[^.]+$/, '.wav');
fileToUpload = new File([wavBlob], wavName, { type: 'audio/wav' });
} catch {
// If browser can't decode the format, send the original and let the backend try.
}
}
try {
await addSample.mutateAsync({
profileId: profile.id,
file: sampleFile,
file: fileToUpload,
referenceText: referenceText,
});
+26 -20
View File
@@ -20,11 +20,13 @@ export function useAudioRecording({
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const startTimeRef = useRef<number | null>(null);
const cancelledRef = useRef<boolean>(false);
const startRecording = useCallback(async () => {
try {
setError(null);
chunksRef.current = [];
cancelledRef.current = false;
setDuration(0);
// Check if getUserMedia is available
@@ -87,31 +89,34 @@ export function useAudioRecording({
};
mediaRecorder.onstop = async () => {
// Snapshot the cancellation flag and recorded duration immediately —
// cancelRecording() clears chunks and sets cancelledRef synchronously
// before this async handler runs, so we must check it first.
const wasCancelled = cancelledRef.current;
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' });
// Convert to WAV format to avoid needing ffmpeg on backend
try {
const wavBlob = await convertToWav(webmBlob);
// Pass the actual recorded duration
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
onRecordingComplete?.(wavBlob, recordedDuration);
} catch (err) {
console.error('Error converting audio to WAV:', err);
// Fallback to original blob if conversion fails
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
: undefined;
onRecordingComplete?.(webmBlob, recordedDuration);
}
// Stop all tracks
// Stop all tracks now that we have the data
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
streamRef.current = null;
// Don't fire completion callback if the recording was cancelled
if (wasCancelled) return;
// Convert to WAV format to avoid needing ffmpeg on backend
try {
const wavBlob = await convertToWav(webmBlob);
onRecordingComplete?.(wavBlob, recordedDuration);
} catch (err) {
console.error('Error converting audio to WAV:', err);
// Fallback to original blob if conversion fails
onRecordingComplete?.(webmBlob, recordedDuration);
}
};
mediaRecorder.onerror = (event) => {
@@ -167,9 +172,10 @@ export function useAudioRecording({
const cancelRecording = useCallback(() => {
if (mediaRecorderRef.current) {
cancelledRef.current = true; // Must be set before stop() triggers onstop
chunksRef.current = [];
mediaRecorderRef.current.stop();
setIsRecording(false);
chunksRef.current = [];
setDuration(0);
}
+36 -18
View File
@@ -22,6 +22,11 @@ export function formatAudioDuration(seconds: number): string {
* If the file has a recordedDuration property (from recording hooks),
* use that instead of trying to read metadata. This fixes issues on Windows
* where WebM files from MediaRecorder don't have proper duration metadata.
*
* For uploaded files we use AudioContext.decodeAudioData which fully decodes
* the audio and returns the exact duration. This is more reliable than
* HTMLMediaElement.duration which can return incorrect large values for VBR
* MP3 files that lack a proper XING/VBRI header.
*/
export async function getAudioDuration(
file: File & { recordedDuration?: number },
@@ -30,26 +35,39 @@ export async function getAudioDuration(
return file.recordedDuration;
}
return new Promise((resolve, reject) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
// Use Web Audio API for accurate duration — avoids VBR MP3 metadata issues.
try {
const audioContext = new AudioContext();
try {
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return audioBuffer.duration;
} finally {
await audioContext.close();
}
} catch {
// Fallback: read duration from the media element (less accurate but works for WAV).
return new Promise((resolve, reject) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener('loadedmetadata', () => {
URL.revokeObjectURL(url);
if (Number.isFinite(audio.duration) && audio.duration > 0) {
resolve(audio.duration);
} else {
reject(new Error('Audio file has invalid duration metadata'));
}
audio.addEventListener('loadedmetadata', () => {
URL.revokeObjectURL(url);
if (Number.isFinite(audio.duration) && audio.duration > 0) {
resolve(audio.duration);
} else {
reject(new Error('Audio file has invalid duration metadata'));
}
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load audio file'));
});
audio.src = url;
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load audio file'));
});
audio.src = url;
});
}
}
/**
+48 -11
View File
@@ -29,9 +29,23 @@ class PyTorchTTSBackend:
"""Get the best available device."""
if torch.cuda.is_available():
return "cuda"
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
# MPS can have issues, use CPU for stability
return "cpu"
# Intel Arc / Intel Xe GPU via intel-extension-for-pytorch (IPEX)
try:
import intel_extension_for_pytorch # noqa: F401
if hasattr(torch, 'xpu') and torch.xpu.is_available():
return "xpu"
except ImportError:
pass
# Any GPU on Windows via DirectML (torch-directml)
try:
import torch_directml
if torch_directml.device_count() > 0:
return torch_directml.device(0)
except ImportError:
pass
# MPS (Apple Silicon) — kept for completeness but MLX backend is preferred
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
return "cpu" # MPS disabled for stability; MLX backend handles Apple Silicon
return "cpu"
def is_loaded(self) -> bool:
@@ -166,11 +180,21 @@ class PyTorchTTSBackend:
# Load the model (tqdm is patched, but filters out non-download progress)
try:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
device_map=self.device,
torch_dtype=torch.float32 if self.device == "cpu" else torch.bfloat16,
)
# Don't pass device_map on CPU: accelerate's meta-tensor mechanism
# causes "Cannot copy out of meta tensor" when moving to CPU.
# Instead load directly then call .to(device) if needed.
if self.device == "cpu":
self.model = Qwen3TTSModel.from_pretrained(
model_path,
torch_dtype=torch.float32,
low_cpu_mem_usage=False,
)
else:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
device_map=self.device,
torch_dtype=torch.bfloat16,
)
finally:
# Exit the patch context
tracker_context.__exit__(None, None, None)
@@ -358,9 +382,22 @@ class PyTorchSTTBackend:
"""Get the best available device."""
if torch.cuda.is_available():
return "cuda"
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
# MPS support for Whisper
return "cpu" # Use CPU for stability
# Intel Arc / Intel Xe GPU via intel-extension-for-pytorch (IPEX)
try:
import intel_extension_for_pytorch # noqa: F401
if hasattr(torch, 'xpu') and torch.xpu.is_available():
return "xpu"
except ImportError:
pass
# Any GPU on Windows via DirectML (torch-directml)
try:
import torch_directml
if torch_directml.device_count() > 0:
return torch_directml.device(0)
except ImportError:
pass
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
return "cpu" # MPS disabled for stability
return "cpu"
def is_loaded(self) -> bool:
+9
View File
@@ -4,8 +4,17 @@ Configuration module for voicebox backend.
Handles data directory configuration for production bundling.
"""
import os
from pathlib import Path
# Allow users to override the HuggingFace model download directory.
# Set VOICEBOX_MODELS_DIR to an absolute path before starting the server.
# This sets HF_HUB_CACHE so all huggingface_hub downloads go to that path.
_custom_models_dir = os.environ.get("VOICEBOX_MODELS_DIR")
if _custom_models_dir:
os.environ["HF_HUB_CACHE"] = _custom_models_dir
print(f"[config] Model download path set to: {_custom_models_dir}")
# Default data directory (used in development)
_data_dir = Path("data")
+147 -36
View File
@@ -22,6 +22,24 @@ import uuid
import asyncio
import signal
import os
from urllib.parse import quote
def _safe_content_disposition(disposition_type: str, filename: str) -> str:
"""Build a Content-Disposition header that is safe for non-ASCII filenames.
Uses RFC 5987 ``filename*`` parameter so that browsers can decode
UTF-8 filenames while the ``filename`` fallback stays ASCII-only.
"""
ascii_name = "".join(
c for c in filename if c.isascii() and (c.isalnum() or c in " -_.")
).strip() or "download"
utf8_name = quote(filename, safe="")
return (
f'{disposition_type}; filename="{ascii_name}"; '
f"filename*=UTF-8''{utf8_name}"
)
from . import database, models, profiles, history, tts, transcribe, config, export_import, channels, stories, __version__
from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
@@ -77,10 +95,39 @@ async def health():
tts_model = tts.get_tts_model()
backend_type = get_backend_type()
# Check for GPU availability (CUDA or MPS)
# Check for GPU availability (CUDA, MPS, Intel Arc XPU, or DirectML)
has_cuda = torch.cuda.is_available()
has_mps = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
gpu_available = has_cuda or has_mps
# Intel Arc / Intel Xe via intel-extension-for-pytorch (IPEX)
has_xpu = False
xpu_name = None
try:
import intel_extension_for_pytorch as ipex # noqa: F401
if hasattr(torch, 'xpu') and torch.xpu.is_available():
has_xpu = True
try:
xpu_name = torch.xpu.get_device_name(0)
except Exception:
xpu_name = "Intel GPU"
except ImportError:
pass
# DirectML backend (torch-directml) for any Windows GPU
has_directml = False
directml_name = None
try:
import torch_directml
if torch_directml.device_count() > 0:
has_directml = True
try:
directml_name = torch_directml.device_name(0)
except Exception:
directml_name = "DirectML GPU"
except ImportError:
pass
gpu_available = has_cuda or has_mps or has_xpu or has_directml or backend_type == "mlx"
gpu_type = None
if has_cuda:
@@ -89,6 +136,10 @@ async def health():
gpu_type = "MPS (Apple Silicon)"
elif backend_type == "mlx":
gpu_type = "Metal (Apple Silicon via MLX)"
elif has_xpu:
gpu_type = f"XPU ({xpu_name})"
elif has_directml:
gpu_type = f"DirectML ({directml_name})"
vram_used = None
if has_cuda:
@@ -252,12 +303,17 @@ async def add_profile_sample(
db: Session = Depends(get_db),
):
"""Add a sample to a voice profile."""
# Save uploaded file to temporary location
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
# Preserve the uploaded file's extension so librosa can detect format correctly.
# Defaulting to .wav was causing soundfile to reject MP3/WebM content as invalid WAV.
_allowed_audio_exts = {'.wav', '.mp3', '.m4a', '.ogg', '.flac', '.aac', '.webm', '.opus'}
_uploaded_ext = Path(file.filename or '').suffix.lower()
file_suffix = _uploaded_ext if _uploaded_ext in _allowed_audio_exts else '.wav'
with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
try:
sample = await profiles.add_profile_sample(
profile_id,
@@ -268,6 +324,8 @@ async def add_profile_sample(
return sample
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to process audio file: {str(e)}")
finally:
# Clean up temp file
Path(tmp_path).unlink(missing_ok=True)
@@ -388,7 +446,7 @@ async def export_profile(
io.BytesIO(zip_bytes),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
"Content-Disposition": _safe_content_disposition("attachment", filename)
}
)
except ValueError as e:
@@ -543,44 +601,44 @@ async def generate_speech(
raise HTTPException(status_code=404, detail="Profile not found")
# Generate audio
# Resolve model size and load the correct model FIRST.
# This must happen before create_voice_prompt_for_profile because that
# function calls load_model_async(None), which falls back to self.model_size.
# If the model is already loaded with the right size at that point, it
# returns immediately and the voice prompt is created by the correct model.
tts_model = tts.get_tts_model()
# Load the requested model size if different from current (async to not block)
model_size = data.model_size or "1.7B"
# Check if model needs to be downloaded first
model_path = tts_model._get_model_path(model_size)
if model_path.startswith("Qwen/"):
# Model not cached - check if it exists remotely or needs download
from huggingface_hub import constants as hf_constants
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
if not repo_cache.exists():
# Start download in background
model_name = f"qwen-tts-{model_size}"
if not tts_model._is_model_cached(model_size):
# Model is not fully cached — kick off a background download and tell
# the client to retry once it's ready.
model_name = f"qwen-tts-{model_size}"
async def download_model_background():
try:
await tts_model.load_model_async(model_size)
except Exception as e:
task_manager.error_download(model_name, str(e))
async def download_model_background():
try:
await tts_model.load_model_async(model_size)
except Exception as e:
task_manager.error_download(model_name, str(e))
task_manager.start_download(model_name)
asyncio.create_task(download_model_background())
task_manager.start_download(model_name)
asyncio.create_task(download_model_background())
# Return 202 Accepted with download info
raise HTTPException(
status_code=202,
detail={
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True
}
)
raise HTTPException(
status_code=202,
detail={
"message": f"Model {model_size} is being downloaded. Please wait and try again.",
"model_name": model_name,
"downloading": True,
},
)
# Load the requested model BEFORE creating voice prompt,
# so create_voice_prompt uses the correct model size
# Load (or switch to) the requested model before building the voice prompt
await tts_model.load_model_async(model_size)
# Create voice prompt from profile
# Create voice prompt from profile (model is already loaded with correct size)
voice_prompt = await profiles.create_voice_prompt_for_profile(
data.profile_id,
db,
@@ -628,6 +686,59 @@ async def generate_speech(
raise HTTPException(status_code=500, detail=str(e))
@app.post("/generate/stream")
async def stream_speech(
data: models.GenerationRequest,
db: Session = Depends(get_db),
):
"""
Generate speech and stream the WAV audio directly without saving to disk.
Returns raw WAV bytes via a StreamingResponse so the client can start
playing audio before the entire file has been received. This endpoint
does NOT create a history entry — use /generate for that.
"""
profile = await profiles.get_profile(data.profile_id, db)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
tts_model = tts.get_tts_model()
model_size = data.model_size or "1.7B"
if not tts_model._is_model_cached(model_size):
raise HTTPException(
status_code=400,
detail=f"Model {model_size} is not downloaded yet. Use /generate to trigger a download.",
)
# Load the correct model before building the voice prompt (fixes issue #96)
await tts_model.load_model_async(model_size)
voice_prompt = await profiles.create_voice_prompt_for_profile(data.profile_id, db)
audio, sample_rate = await tts_model.generate(
data.text,
voice_prompt,
data.language,
data.seed,
data.instruct,
)
wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate)
async def _wav_stream():
# Yield in chunks so large responses don't block the event loop
chunk_size = 64 * 1024 # 64 KB
for i in range(0, len(wav_bytes), chunk_size):
yield wav_bytes[i : i + chunk_size]
return StreamingResponse(
_wav_stream(),
media_type="audio/wav",
headers={"Content-Disposition": 'attachment; filename="speech.wav"'},
)
# ============================================
# HISTORY ENDPOINTS
# ============================================
@@ -756,7 +867,7 @@ async def export_generation(
io.BytesIO(zip_bytes),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
"Content-Disposition": _safe_content_disposition("attachment", filename)
}
)
except ValueError as e:
@@ -789,7 +900,7 @@ async def export_generation_audio(
audio_path,
media_type="audio/wav",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
"Content-Disposition": _safe_content_disposition("attachment", filename)
}
)
@@ -1057,7 +1168,7 @@ async def export_story_audio(
io.BytesIO(audio_bytes),
media_type="audio/wav",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
"Content-Disposition": _safe_content_disposition("attachment", filename)
}
)
except HTTPException:
-8
View File
@@ -32,11 +32,3 @@ def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
sf.write(buffer, audio, sample_rate, format="WAV")
buffer.seek(0)
return buffer.read()
def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
"""Convert audio array to WAV bytes."""
buffer = io.BytesIO()
sf.write(buffer, audio, sample_rate, format="WAV")
buffer.seek(0)
return buffer.read()
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Banner } from '@/components/Banner';
import { Footer } from '@/components/Footer';
import { Header } from '@/components/Header';
@@ -31,6 +32,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en" suppressHydrationWarning className="dark">
<body className={inter.variable}>
<div className="relative min-h-screen bg-background font-sans flex flex-col">
<Banner />
<Header />
<main className="container mx-auto px-4 sm:px-6 md:px-4 flex-1 py-4 sm:py-6 md:py-0">
{children}
+25
View File
@@ -0,0 +1,25 @@
import { ArrowRight } from 'lucide-react';
export function Banner() {
return (
<div className="bg-primary/[0.06] border-b border-border backdrop-blur-sm">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center h-10 text-sm">
<a
href="https://spacebot.sh"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors group"
>
<span>
Also by the creator of Voicebox:{' '}
<strong className="text-foreground/90">Spacebot</strong>, an AI agent OS for teams.
Connect Discord, Slack, or Telegram in one click.
</span>
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
</a>
</div>
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
uvicorn
fastapi
sqlalchemy
torch
torchvision
soundfile
librosa
python-multipart
huggingface_hub
+1 -1
View File
@@ -12,7 +12,7 @@
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"createUpdaterArtifacts": false,
"externalBin": ["binaries/voicebox-server"],
"icon": [
"icons/32x32.png",
+16 -31
View File
@@ -2,40 +2,25 @@ import type { PlatformFilesystem, FileFilter } from '@/platform/types';
export const tauriFilesystem: PlatformFilesystem = {
async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) {
try {
const { save } = await import('@tauri-apps/plugin-dialog');
const filePath = await save({
defaultPath: filename,
filters: filters || [],
});
const { save } = await import('@tauri-apps/plugin-dialog');
const { writeFile } = await import('@tauri-apps/plugin-fs');
if (filePath) {
let resolvedPath = '';
if (typeof filePath === 'string') {
resolvedPath = filePath;
} else if (filePath && typeof filePath === 'object' && 'path' in filePath) {
resolvedPath = (filePath as { path: string }).path;
}
const filePath = await save({
defaultPath: filename,
filters: filters || [],
});
if (!resolvedPath) {
throw new Error('Failed to resolve save path');
}
if (!filePath) return; // User cancelled the dialog
const { writeFile } = await import('@tauri-apps/plugin-fs');
const arrayBuffer = await blob.arrayBuffer();
await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
}
} catch (error) {
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
// Fall back to browser download if Tauri dialog fails
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
const resolvedPath = typeof filePath === 'string'
? filePath
: (filePath as { path: string }).path;
if (!resolvedPath) {
throw new Error('Failed to resolve save path from dialog');
}
const arrayBuffer = await blob.arrayBuffer();
await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
},
};
+1
View File
@@ -21,6 +21,7 @@
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
+2 -1
View File
@@ -1,9 +1,10 @@
import path from 'node:path';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '../app/src'),