feat(teamsbot): voice selection with test button, browserBotUrl config
- TeamsbotSettingsView: voice dropdown with predefined Google TTS voices per language - Voice test button with audio preview (plays TTS sample in browser) - Language change auto-selects first matching voice - Updated config interfaces: bridgeUrl -> browserBotUrl - Added testVoice API function Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
30d23ac7df
commit
e1e83d7d93
2 changed files with 143 additions and 18 deletions
|
|
@ -57,7 +57,7 @@ export interface TeamsbotConfig {
|
||||||
responseMode: 'auto' | 'manual' | 'transcribeOnly';
|
responseMode: 'auto' | 'manual' | 'transcribeOnly';
|
||||||
language: string;
|
language: string;
|
||||||
voiceId?: string;
|
voiceId?: string;
|
||||||
bridgeUrl?: string;
|
browserBotUrl?: string;
|
||||||
triggerIntervalSeconds: number;
|
triggerIntervalSeconds: number;
|
||||||
triggerCooldownSeconds: number;
|
triggerCooldownSeconds: number;
|
||||||
contextWindowSegments: number;
|
contextWindowSegments: number;
|
||||||
|
|
@ -85,7 +85,7 @@ export interface ConfigUpdateRequest {
|
||||||
responseMode?: 'auto' | 'manual' | 'transcribeOnly';
|
responseMode?: 'auto' | 'manual' | 'transcribeOnly';
|
||||||
language?: string;
|
language?: string;
|
||||||
voiceId?: string;
|
voiceId?: string;
|
||||||
bridgeUrl?: string;
|
browserBotUrl?: string;
|
||||||
triggerIntervalSeconds?: number;
|
triggerIntervalSeconds?: number;
|
||||||
triggerCooldownSeconds?: number;
|
triggerCooldownSeconds?: number;
|
||||||
contextWindowSegments?: number;
|
contextWindowSegments?: number;
|
||||||
|
|
@ -172,6 +172,23 @@ export async function updateConfig(instanceId: string, updates: ConfigUpdateRequ
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test TTS voice with a sample text. Returns base64-encoded audio.
|
||||||
|
*/
|
||||||
|
export async function testVoice(
|
||||||
|
instanceId: string,
|
||||||
|
text: string,
|
||||||
|
language: string,
|
||||||
|
voiceId?: string,
|
||||||
|
): Promise<{ success: boolean; audio?: string; format?: string; error?: string }> {
|
||||||
|
const response = await api.post(`/api/teamsbot/${instanceId}/voice/test`, {
|
||||||
|
text,
|
||||||
|
language,
|
||||||
|
voiceId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an SSE EventSource for live session streaming.
|
* Create an SSE EventSource for live session streaming.
|
||||||
* Returns the EventSource instance for the caller to manage.
|
* Returns the EventSource instance for the caller to manage.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,55 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||||
import type { TeamsbotConfig, ConfigUpdateRequest } from '../../../api/teamsbotApi';
|
import type { TeamsbotConfig, ConfigUpdateRequest } from '../../../api/teamsbotApi';
|
||||||
|
import { FaPlay, FaSpinner } from 'react-icons/fa';
|
||||||
import styles from './Teamsbot.module.css';
|
import styles from './Teamsbot.module.css';
|
||||||
|
|
||||||
|
// Common Google TTS voices grouped by language
|
||||||
|
const VOICE_PRESETS: Record<string, Array<{ id: string; label: string }>> = {
|
||||||
|
'de-DE': [
|
||||||
|
{ id: 'de-DE-Wavenet-A', label: 'Frau A (Wavenet)' },
|
||||||
|
{ id: 'de-DE-Wavenet-B', label: 'Mann B (Wavenet)' },
|
||||||
|
{ id: 'de-DE-Wavenet-C', label: 'Frau C (Wavenet)' },
|
||||||
|
{ id: 'de-DE-Wavenet-D', label: 'Mann D (Wavenet)' },
|
||||||
|
{ id: 'de-DE-Neural2-A', label: 'Frau A (Neural)' },
|
||||||
|
{ id: 'de-DE-Neural2-B', label: 'Mann B (Neural)' },
|
||||||
|
],
|
||||||
|
'de-CH': [
|
||||||
|
{ id: 'de-DE-Wavenet-A', label: 'Frau A (Wavenet)' },
|
||||||
|
{ id: 'de-DE-Wavenet-B', label: 'Mann B (Wavenet)' },
|
||||||
|
],
|
||||||
|
'en-US': [
|
||||||
|
{ id: 'en-US-Wavenet-C', label: 'Female C (Wavenet)' },
|
||||||
|
{ id: 'en-US-Wavenet-D', label: 'Male D (Wavenet)' },
|
||||||
|
{ id: 'en-US-Neural2-C', label: 'Female C (Neural)' },
|
||||||
|
{ id: 'en-US-Neural2-D', label: 'Male D (Neural)' },
|
||||||
|
],
|
||||||
|
'en-GB': [
|
||||||
|
{ id: 'en-GB-Wavenet-A', label: 'Female A (Wavenet)' },
|
||||||
|
{ id: 'en-GB-Wavenet-B', label: 'Male B (Wavenet)' },
|
||||||
|
],
|
||||||
|
'fr-FR': [
|
||||||
|
{ id: 'fr-FR-Wavenet-A', label: 'Femme A (Wavenet)' },
|
||||||
|
{ id: 'fr-FR-Wavenet-B', label: 'Homme B (Wavenet)' },
|
||||||
|
{ id: 'fr-FR-Neural2-A', label: 'Femme A (Neural)' },
|
||||||
|
{ id: 'fr-FR-Neural2-D', label: 'Homme D (Neural)' },
|
||||||
|
],
|
||||||
|
'it-IT': [
|
||||||
|
{ id: 'it-IT-Wavenet-A', label: 'Donna A (Wavenet)' },
|
||||||
|
{ id: 'it-IT-Wavenet-C', label: 'Uomo C (Wavenet)' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_TEXTS: Record<string, string> = {
|
||||||
|
'de-DE': 'Hallo, ich bin der AI-Assistent. Wie kann ich Ihnen helfen?',
|
||||||
|
'de-CH': 'Grüezi, ich bin der AI-Assistent. Wie chan ich Ihne helfe?',
|
||||||
|
'en-US': 'Hello, I am the AI assistant. How can I help you?',
|
||||||
|
'en-GB': 'Hello, I am the AI assistant. How may I help you?',
|
||||||
|
'fr-FR': 'Bonjour, je suis l\'assistant IA. Comment puis-je vous aider?',
|
||||||
|
'it-IT': 'Ciao, sono l\'assistente AI. Come posso aiutarla?',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TeamsbotSettingsView - Bot configuration for a feature instance.
|
* TeamsbotSettingsView - Bot configuration for a feature instance.
|
||||||
*/
|
*/
|
||||||
|
|
@ -14,8 +60,10 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
const [_config, setConfig] = useState<TeamsbotConfig | null>(null);
|
const [_config, setConfig] = useState<TeamsbotConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testingVoice, setTestingVoice] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<ConfigUpdateRequest>({});
|
const [formData, setFormData] = useState<ConfigUpdateRequest>({});
|
||||||
|
|
@ -62,6 +110,49 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _handleLanguageChange = (language: string) => {
|
||||||
|
_updateField('language', language);
|
||||||
|
// Auto-select first voice for the new language
|
||||||
|
const voices = VOICE_PRESETS[language] || [];
|
||||||
|
if (voices.length > 0 && !voices.find(v => v.id === formData.voiceId)) {
|
||||||
|
_updateField('voiceId', voices[0].id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleTestVoice = async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setTestingVoice(true);
|
||||||
|
try {
|
||||||
|
const language = formData.language || 'de-DE';
|
||||||
|
const testText = TEST_TEXTS[language] || TEST_TEXTS['de-DE'];
|
||||||
|
const result = await teamsbotApi.testVoice(instanceId, testText, language, formData.voiceId);
|
||||||
|
|
||||||
|
if (result.success && result.audio) {
|
||||||
|
// Play audio from base64
|
||||||
|
const audioBlob = new Blob(
|
||||||
|
[Uint8Array.from(atob(result.audio), c => c.charCodeAt(0))],
|
||||||
|
{ type: 'audio/mp3' }
|
||||||
|
);
|
||||||
|
const audioUrl = URL.createObjectURL(audioBlob);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
}
|
||||||
|
const audio = new Audio(audioUrl);
|
||||||
|
audioRef.current = audio;
|
||||||
|
audio.play();
|
||||||
|
audio.onended = () => URL.revokeObjectURL(audioUrl);
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Stimmtest fehlgeschlagen');
|
||||||
|
setTimeout(() => setError(null), 3000);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Stimmtest fehlgeschlagen');
|
||||||
|
setTimeout(() => setError(null), 3000);
|
||||||
|
} finally {
|
||||||
|
setTestingVoice(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <div className={styles.loading}>Lade Konfiguration...</div>;
|
if (loading) return <div className={styles.loading}>Lade Konfiguration...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -140,7 +231,7 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
<select
|
<select
|
||||||
className={styles.select}
|
className={styles.select}
|
||||||
value={formData.language || 'de-DE'}
|
value={formData.language || 'de-DE'}
|
||||||
onChange={(e) => _updateField('language', e.target.value)}
|
onChange={(e) => _handleLanguageChange(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="de-DE">Deutsch (Deutschland)</option>
|
<option value="de-DE">Deutsch (Deutschland)</option>
|
||||||
<option value="de-CH">Deutsch (Schweiz)</option>
|
<option value="de-CH">Deutsch (Schweiz)</option>
|
||||||
|
|
@ -149,18 +240,35 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
<option value="fr-FR">Francais</option>
|
<option value="fr-FR">Francais</option>
|
||||||
<option value="it-IT">Italiano</option>
|
<option value="it-IT">Italiano</option>
|
||||||
</select>
|
</select>
|
||||||
|
<span className={styles.hint}>Sprache fuer Captions und Sprachausgabe</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label className={styles.label}>Stimme (Voice ID)</label>
|
<label className={styles.label}>Stimme</label>
|
||||||
<input
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
type="text"
|
<select
|
||||||
className={styles.input}
|
className={styles.select}
|
||||||
value={formData.voiceId || ''}
|
style={{ flex: 1 }}
|
||||||
onChange={(e) => _updateField('voiceId', e.target.value)}
|
value={formData.voiceId || ''}
|
||||||
placeholder="de-DE-Standard-A"
|
onChange={(e) => _updateField('voiceId', e.target.value)}
|
||||||
/>
|
>
|
||||||
<span className={styles.hint}>Google TTS Voice ID fuer die Sprachausgabe</span>
|
<option value="">Standard-Stimme</option>
|
||||||
|
{(VOICE_PRESETS[formData.language || 'de-DE'] || []).map(voice => (
|
||||||
|
<option key={voice.id} value={voice.id}>{voice.label} ({voice.id})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className={styles.testButton || styles.saveButton}
|
||||||
|
onClick={_handleTestVoice}
|
||||||
|
disabled={testingVoice}
|
||||||
|
title="Stimme testen"
|
||||||
|
style={{ minWidth: '44px', padding: '8px 12px', display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
{testingVoice ? <FaSpinner className={styles.spinner} /> : <FaPlay />}
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className={styles.hint}>Google TTS Stimme - klicke Test um eine Vorschau zu hoeren</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -210,15 +318,15 @@ export const TeamsbotSettingsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label className={styles.label}>Media Bridge URL</label>
|
<label className={styles.label}>Browser Bot URL (optional)</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
value={formData.bridgeUrl || ''}
|
value={formData.browserBotUrl || ''}
|
||||||
onChange={(e) => _updateField('bridgeUrl', e.target.value)}
|
onChange={(e) => _updateField('browserBotUrl', e.target.value)}
|
||||||
placeholder="https://bridge.example.com"
|
placeholder="Automatisch aus Umgebungskonfiguration"
|
||||||
/>
|
/>
|
||||||
<span className={styles.hint}>URL des .NET Media Bridge Service</span>
|
<span className={styles.hint}>URL des Browser Bot Service. Leer lassen fuer Standard-Konfiguration.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue