stiff
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions

This commit is contained in:
Dmitry 2026-01-24 20:24:51 +03:00
parent 6187db39bb
commit 2b65fb00ee
5 changed files with 182 additions and 26 deletions

View file

@ -12,12 +12,14 @@ export const voiceServiceApi = {
* @param text Text to convert to speech * @param text Text to convert to speech
* @param voice Voice identifier (optional, defaults to 'alloy') * @param voice Voice identifier (optional, defaults to 'alloy')
* @param language Language code (optional) * @param language Language code (optional)
* @param instructions Instructions for speech generation (optional)
* @returns Object ID and presigned URL * @returns Object ID and presigned URL
*/ */
async generateTTS( async generateTTS(
text: string, text: string,
voice?: string, voice?: string,
language?: string language?: string,
instructions?: string
): Promise<GenerateTTSResponse> { ): Promise<GenerateTTSResponse> {
const response = await adminApiClient.post<GenerateTTSResponse>( const response = await adminApiClient.post<GenerateTTSResponse>(
'/api/v2/media/generate-tts', '/api/v2/media/generate-tts',
@ -25,6 +27,7 @@ export const voiceServiceApi = {
text, text,
voice: voice || 'alloy', voice: voice || 'alloy',
...(language && { language }), ...(language && { language }),
...(instructions && { instructions }),
} }
) )

View file

@ -5,7 +5,17 @@ import { voicesApi, type VoiceDto } from '@/api/voices'
import { AudioUpload } from '@/components/ui/audio-upload' import { AudioUpload } from '@/components/ui/audio-upload'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { X, Plus, Music, Play, Pause, Volume2, Loader2 } from 'lucide-react' import { X, Plus, Music, Play, Pause, Volume2, Loader2 } from 'lucide-react'
import type { AxiosError } from 'axios' import type { AxiosError } from 'axios'
import { voiceServiceApi } from '@/api/voiceService' import { voiceServiceApi } from '@/api/voiceService'
@ -25,7 +35,15 @@ export function CardVoicesManager({
const [showAddForm, setShowAddForm] = useState(false) const [showAddForm, setShowAddForm] = useState(false)
const [newAudio, setNewAudio] = useState<string | undefined>(undefined) const [newAudio, setNewAudio] = useState<string | undefined>(undefined)
const [newLanguage, setNewLanguage] = useState('es') const [newLanguage, setNewLanguage] = useState('es')
const [newInstructions, setNewInstructions] = useState('')
const [isGeneratingTTS, setIsGeneratingTTS] = useState(false) const [isGeneratingTTS, setIsGeneratingTTS] = useState(false)
// Dialog state for TTS generation
const [showTTSDialog, setShowTTSDialog] = useState(false)
const [dialogText, setDialogText] = useState('')
const [dialogVoice, setDialogVoice] = useState('alloy')
const [dialogLanguage, setDialogLanguage] = useState('es')
const [dialogInstructions, setDialogInstructions] = useState('pronunciar la frase claramente')
// Load voices for the card // Load voices for the card
const { data: voicesData, isLoading, refetch } = useQuery({ const { data: voicesData, isLoading, refetch } = useQuery({
@ -66,6 +84,7 @@ export function CardVoicesManager({
setShowAddForm(false) setShowAddForm(false)
setNewAudio(undefined) setNewAudio(undefined)
setNewLanguage('es') setNewLanguage('es')
setNewInstructions('')
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
console.error('❌ CardVoicesManager: addVoiceMutation.onError', error) console.error('❌ CardVoicesManager: addVoiceMutation.onError', error)
@ -95,30 +114,46 @@ export function CardVoicesManager({
} }
} }
// Generate TTS audio for card // Open TTS generation dialog
const handleGenerateTTS = async () => { const handleOpenTTSDialog = () => {
if (!cardOriginal || !cardOriginal.trim()) { if (!cardOriginal || !cardOriginal.trim()) {
toast.error('Card text is required for TTS generation') toast.error('Card text is required for TTS generation')
return return
} }
// Initialize dialog with default values
setDialogText(cardOriginal.trim())
setDialogVoice('alloy')
setDialogLanguage('es')
setDialogInstructions('pronunciar la frase claramente')
setShowTTSDialog(true)
}
// Generate TTS audio for card (called from dialog)
const handleConfirmGenerateTTS = async () => {
if (!dialogText || !dialogText.trim()) {
toast.error('Text is required for TTS generation')
return
}
if (!cardId) { if (!cardId) {
toast.error('Card ID is required') toast.error('Card ID is required')
return return
} }
setShowTTSDialog(false)
setIsGeneratingTTS(true) setIsGeneratingTTS(true)
try { try {
const text = cardOriginal.trim() const text = dialogText.trim()
// Use default voice 'alloy' for TTS generation const voice = dialogVoice || 'alloy'
// Language (newLanguage) is used for both TTS generation and saving the voice to the card const language = dialogLanguage || 'es'
const voice = 'alloy' const instructions = dialogInstructions || undefined
console.log('🔄 CardVoicesManager: Requesting TTS generation', { text, voice, language: newLanguage }) console.log('🔄 CardVoicesManager: Requesting TTS generation', { text, voice, language, instructions })
// Generate TTS via backend API // Generate TTS via backend API
// Backend calls voice-service (which handles caching) and saves to MinIO // Backend calls voice-service (which handles caching) and saves to MinIO
const ttsResponse = await voiceServiceApi.generateTTS(text, voice, newLanguage) const ttsResponse = await voiceServiceApi.generateTTS(text, voice, language, instructions)
console.log('✅ CardVoicesManager: TTS generated and saved to MinIO', { console.log('✅ CardVoicesManager: TTS generated and saved to MinIO', {
objectId: ttsResponse.objectId objectId: ttsResponse.objectId
}) })
@ -126,7 +161,7 @@ export function CardVoicesManager({
// Add voice to card using the objectId from backend // Add voice to card using the objectId from backend
await addVoiceMutation.mutateAsync({ await addVoiceMutation.mutateAsync({
voiceUrl: ttsResponse.objectId, voiceUrl: ttsResponse.objectId,
language: newLanguage || 'es', language: language,
}) })
} catch (error) { } catch (error) {
console.error('❌ CardVoicesManager: TTS generation failed', error) console.error('❌ CardVoicesManager: TTS generation failed', error)
@ -152,7 +187,7 @@ export function CardVoicesManager({
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleGenerateTTS} onClick={handleOpenTTSDialog}
disabled={disabled || isGeneratingTTS} disabled={disabled || isGeneratingTTS}
> >
{isGeneratingTTS ? ( {isGeneratingTTS ? (
@ -225,6 +260,7 @@ export function CardVoicesManager({
setShowAddForm(false) setShowAddForm(false)
setNewAudio(undefined) setNewAudio(undefined)
setNewLanguage('es') setNewLanguage('es')
setNewInstructions('')
}} }}
disabled={disabled || addVoiceMutation.isPending} disabled={disabled || addVoiceMutation.isPending}
> >
@ -235,6 +271,88 @@ export function CardVoicesManager({
</div> </div>
)} )}
{/* TTS Generation Dialog */}
<Dialog open={showTTSDialog} onOpenChange={setShowTTSDialog}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Generate TTS Audio</DialogTitle>
<DialogDescription>
Configure the text-to-speech generation parameters
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="dialog-text">Text</Label>
<Textarea
id="dialog-text"
value={dialogText}
onChange={(e) => setDialogText(e.target.value)}
placeholder="Enter text to convert to speech"
className="min-h-[100px]"
disabled={isGeneratingTTS}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="dialog-voice">Voice</Label>
<Input
id="dialog-voice"
value={dialogVoice}
onChange={(e) => setDialogVoice(e.target.value)}
placeholder="alloy"
disabled={isGeneratingTTS}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dialog-language">Language</Label>
<Input
id="dialog-language"
value={dialogLanguage}
onChange={(e) => setDialogLanguage(e.target.value)}
placeholder="es"
disabled={isGeneratingTTS}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="dialog-instructions">Instructions</Label>
<Textarea
id="dialog-instructions"
value={dialogInstructions}
onChange={(e) => setDialogInstructions(e.target.value)}
placeholder="pronunciar la frase claramente"
className="min-h-[80px]"
disabled={isGeneratingTTS}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setShowTTSDialog(false)}
disabled={isGeneratingTTS}
>
Cancel
</Button>
<Button
type="button"
onClick={handleConfirmGenerateTTS}
disabled={isGeneratingTTS || !dialogText.trim()}
>
{isGeneratingTTS ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Generating...
</>
) : (
'Generate'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Voices list */} {/* Voices list */}
{isLoading ? ( {isLoading ? (
<div className="text-center py-4 text-sm text-muted-foreground">Loading voices...</div> <div className="text-center py-4 text-sm text-muted-foreground">Loading voices...</div>

View file

@ -272,19 +272,6 @@ export function AudioUpload({
</> </>
)} )}
</div> </div>
{onLanguageChange && (
<div className="mt-2">
<Label htmlFor="language" className="text-xs">Language</Label>
<Input
id="language"
value={language}
onChange={(e) => onLanguageChange(e.target.value)}
placeholder="en"
className="mt-1"
disabled={disabled}
/>
</div>
)}
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{isUploading ? 'Uploading...' : 'Click to change audio file'} {isUploading ? 'Uploading...' : 'Click to change audio file'}
</p> </p>
@ -330,6 +317,21 @@ export function AudioUpload({
</div> </div>
</div> </div>
)} )}
{/* Language selector - show always when onLanguageChange is provided */}
{onLanguageChange && (
<div className="mt-2">
<Label htmlFor="language" className="text-xs">Language</Label>
<Input
id="language"
value={language}
onChange={(e) => onLanguageChange(e.target.value)}
placeholder="es"
className="mt-1"
disabled={disabled}
/>
</div>
)}
</div> </div>
) )
} }

View file

@ -510,13 +510,14 @@ class MediaApiV2 {
final text = json['text'] as String?; final text = json['text'] as String?;
final voice = json['voice'] as String?; final voice = json['voice'] as String?;
final language = json['language'] as String?; final language = json['language'] as String?;
final instructions = json['instructions'] as String?;
if (text == null || text.trim().isEmpty) { if (text == null || text.trim().isEmpty) {
return _badRequest('Text is required'); return _badRequest('Text is required');
} }
print( print(
'🔄 MediaApiV2: Requesting TTS from voice-service: text="${text.substring(0, text.length > 50 ? 50 : text.length)}", voice="${voice ?? 'default'}", language="${language ?? 'not provided'}"', '🔄 MediaApiV2: Requesting TTS from voice-service: text="${text.substring(0, text.length > 50 ? 50 : text.length)}", voice="${voice ?? 'default'}", language="${language ?? 'not provided'}", instructions="${instructions != null ? instructions.substring(0, instructions.length > 50 ? 50 : instructions.length) : 'not provided'}"',
); );
// Call voice-service // Call voice-service
@ -527,6 +528,7 @@ class MediaApiV2 {
if (voice != null) 'voice': voice, if (voice != null) 'voice': voice,
'format': 'mp3', 'format': 'mp3',
if (language != null) 'language': language, if (language != null) 'language': language,
if (instructions != null) 'instructions': instructions,
}, },
options: dio.Options( options: dio.Options(
responseType: dio.ResponseType.bytes, responseType: dio.ResponseType.bytes,

View file

@ -67,7 +67,38 @@ class MinioService {
required String contentType, required String contentType,
String? objectId, String? objectId,
}) async { }) async {
final id = objectId ?? _uuid.v4(); var id = objectId ?? _uuid.v4();
// Add file extension based on content type if not already present
if (!id.contains('.')) {
if (contentType.startsWith('audio/')) {
// Determine extension from content type
String extension = '.mp3'; // default for audio
if (contentType == 'audio/mpeg' || contentType == 'audio/mp3') {
extension = '.mp3';
} else if (contentType == 'audio/wav') {
extension = '.wav';
} else if (contentType == 'audio/ogg') {
extension = '.ogg';
} else if (contentType == 'audio/flac') {
extension = '.flac';
}
id = '$id$extension';
} else if (contentType.startsWith('image/')) {
// Determine extension from content type
String extension = '.png'; // default for images
if (contentType == 'image/jpeg' || contentType == 'image/jpg') {
extension = '.jpg';
} else if (contentType == 'image/png') {
extension = '.png';
} else if (contentType == 'image/webp') {
extension = '.webp';
} else if (contentType == 'image/gif') {
extension = '.gif';
}
id = '$id$extension';
}
}
try { try {
// Определяем Cache-Control в зависимости от типа файла // Определяем Cache-Control в зависимости от типа файла