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 voice Voice identifier (optional, defaults to 'alloy')
* @param language Language code (optional)
* @param instructions Instructions for speech generation (optional)
* @returns Object ID and presigned URL
*/
async generateTTS(
text: string,
voice?: string,
language?: string
language?: string,
instructions?: string
): Promise<GenerateTTSResponse> {
const response = await adminApiClient.post<GenerateTTSResponse>(
'/api/v2/media/generate-tts',
@ -25,6 +27,7 @@ export const voiceServiceApi = {
text,
voice: voice || 'alloy',
...(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 { Button } from '@/components/ui/button'
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { X, Plus, Music, Play, Pause, Volume2, Loader2 } from 'lucide-react'
import type { AxiosError } from 'axios'
import { voiceServiceApi } from '@/api/voiceService'
@ -25,7 +35,15 @@ export function CardVoicesManager({
const [showAddForm, setShowAddForm] = useState(false)
const [newAudio, setNewAudio] = useState<string | undefined>(undefined)
const [newLanguage, setNewLanguage] = useState('es')
const [newInstructions, setNewInstructions] = useState('')
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
const { data: voicesData, isLoading, refetch } = useQuery({
@ -66,6 +84,7 @@ export function CardVoicesManager({
setShowAddForm(false)
setNewAudio(undefined)
setNewLanguage('es')
setNewInstructions('')
},
onError: (error: unknown) => {
console.error('❌ CardVoicesManager: addVoiceMutation.onError', error)
@ -95,30 +114,46 @@ export function CardVoicesManager({
}
}
// Generate TTS audio for card
const handleGenerateTTS = async () => {
// Open TTS generation dialog
const handleOpenTTSDialog = () => {
if (!cardOriginal || !cardOriginal.trim()) {
toast.error('Card text is required for TTS generation')
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) {
toast.error('Card ID is required')
return
}
setShowTTSDialog(false)
setIsGeneratingTTS(true)
try {
const text = cardOriginal.trim()
// Use default voice 'alloy' for TTS generation
// Language (newLanguage) is used for both TTS generation and saving the voice to the card
const voice = 'alloy'
const text = dialogText.trim()
const voice = dialogVoice || 'alloy'
const language = dialogLanguage || 'es'
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
// 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', {
objectId: ttsResponse.objectId
})
@ -126,7 +161,7 @@ export function CardVoicesManager({
// Add voice to card using the objectId from backend
await addVoiceMutation.mutateAsync({
voiceUrl: ttsResponse.objectId,
language: newLanguage || 'es',
language: language,
})
} catch (error) {
console.error('❌ CardVoicesManager: TTS generation failed', error)
@ -152,7 +187,7 @@ export function CardVoicesManager({
type="button"
variant="outline"
size="sm"
onClick={handleGenerateTTS}
onClick={handleOpenTTSDialog}
disabled={disabled || isGeneratingTTS}
>
{isGeneratingTTS ? (
@ -225,6 +260,7 @@ export function CardVoicesManager({
setShowAddForm(false)
setNewAudio(undefined)
setNewLanguage('es')
setNewInstructions('')
}}
disabled={disabled || addVoiceMutation.isPending}
>
@ -235,6 +271,88 @@ export function CardVoicesManager({
</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 */}
{isLoading ? (
<div className="text-center py-4 text-sm text-muted-foreground">Loading voices...</div>

View file

@ -272,19 +272,6 @@ export function AudioUpload({
</>
)}
</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">
{isUploading ? 'Uploading...' : 'Click to change audio file'}
</p>
@ -330,6 +317,21 @@ export function AudioUpload({
</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>
)
}

View file

@ -510,13 +510,14 @@ class MediaApiV2 {
final text = json['text'] as String?;
final voice = json['voice'] as String?;
final language = json['language'] as String?;
final instructions = json['instructions'] as String?;
if (text == null || text.trim().isEmpty) {
return _badRequest('Text is required');
}
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
@ -527,6 +528,7 @@ class MediaApiV2 {
if (voice != null) 'voice': voice,
'format': 'mp3',
if (language != null) 'language': language,
if (instructions != null) 'instructions': instructions,
},
options: dio.Options(
responseType: dio.ResponseType.bytes,

View file

@ -67,7 +67,38 @@ class MinioService {
required String contentType,
String? objectId,
}) 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 {
// Определяем Cache-Control в зависимости от типа файла