frontend_nyla/src/pages/views/teamsbot/TeamsbotSettingsView.tsx
2026-04-19 00:36:42 +02:00

382 lines
15 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi';
import type { VoiceLanguage } from '../../../api/voiceCatalogApi';
import { FaPlay, FaSpinner } from 'react-icons/fa';
import styles from './Teamsbot.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
/** Format voice name for display: "de-DE-Wavenet-A" -> "Wavenet A" + gender */
function _formatVoiceName(voice: VoiceOption): string {
const parts = voice.name.split('-');
const type = parts[2] || ''; // Wavenet, Neural2, Standard, etc.
const variant = parts[3] || '';
const gender = voice.ssmlGender === 'FEMALE' ? 'Frau' : voice.ssmlGender === 'MALE' ? 'Mann' : '';
return `${gender} ${variant} (${type})`.trim();
}
/**
* TeamsbotSettingsView - Bot configuration for a feature instance.
*/
export const TeamsbotSettingsView: React.FC = () => {
const { t } = useLanguage();
const { instance } = useCurrentInstance();
const instanceId = instance?.id || '';
const [_config, setConfig] = useState<TeamsbotConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testingVoice, setTestingVoice] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Form state
const [formData, setFormData] = useState<ConfigUpdateRequest>({});
// Voice catalog (single source of truth) + dynamic voices for the selected language
const [languages, setLanguages] = useState<VoiceLanguage[]>([]);
const [voices, setVoices] = useState<VoiceOption[]>([]);
const [loadingVoices, setLoadingVoices] = useState(false);
const _loadConfig = useCallback(async () => {
if (!instanceId) return;
try {
setLoading(true);
// Load per-user settings (merged with instance defaults)
const [settingsResult, languagesResult] = await Promise.all([
teamsbotApi.getUserSettings(instanceId),
teamsbotApi.fetchLanguages(),
]);
const effectiveConfig = settingsResult.effectiveConfig;
setConfig(effectiveConfig);
setFormData(effectiveConfig);
setLanguages(languagesResult);
// Load voices for the current language
const lang = effectiveConfig.language || 'de-DE';
const voicesResult = await teamsbotApi.fetchVoices(lang);
setVoices(voicesResult);
setError(null);
} catch (err: any) {
setError(err.message || 'Fehler beim Laden der Konfiguration');
} finally {
setLoading(false);
}
}, [instanceId]);
useEffect(() => {
_loadConfig();
}, [_loadConfig]);
const _handleSave = async () => {
if (!instanceId) return;
setSaving(true);
setError(null);
setSuccessMsg(null);
try {
// Save as per-user settings (not instance config)
const result = await teamsbotApi.updateUserSettings(instanceId, formData);
setConfig(result.effectiveConfig);
setFormData(result.effectiveConfig);
setSuccessMsg('Einstellungen gespeichert');
setTimeout(() => setSuccessMsg(null), 3000);
} catch (err: any) {
setError(err.message || 'Fehler beim Speichern');
} finally {
setSaving(false);
}
};
const _updateField = (field: keyof ConfigUpdateRequest, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const _handleLanguageChange = async (language: string) => {
_updateField('language', language);
// Load voices for the new language dynamically
setLoadingVoices(true);
try {
const voicesResult = await teamsbotApi.fetchVoices(language);
setVoices(voicesResult);
// Auto-select first voice if current voice doesn't match
if (voicesResult.length > 0 && !voicesResult.find(v => v.name === formData.voiceId)) {
_updateField('voiceId', voicesResult[0].name);
}
} catch {
setVoices([]);
} finally {
setLoadingVoices(false);
}
};
const _handleTestVoice = async () => {
if (!instanceId) return;
setTestingVoice(true);
try {
const language = formData.language || 'de-DE';
const botName = formData.botName || t('KI-Assistent');
const result = await teamsbotApi.testVoice(instanceId, botName, 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 || t('Stimmtest fehlgeschlagen'));
setTimeout(() => setError(null), 3000);
}
} catch (err: any) {
setError(err.message || t('Stimmtest fehlgeschlagen'));
setTimeout(() => setError(null), 3000);
} finally {
setTestingVoice(false);
}
};
if (loading) return <div className={styles.loading}>{t('Konfiguration laden')}</div>;
return (
<div className={styles.settingsContainer}>
<div className={styles.settingsCard}>
<h3 className={styles.cardTitle}>Bot-Einstellungen</h3>
{error && <div className={styles.errorBanner}>{error}</div>}
{successMsg && <div className={styles.successBanner}>{successMsg}</div>}
{/* Bot Identity */}
<div className={styles.settingsSection}>
<h4 className={styles.sectionTitle}>Bot-Identitaet</h4>
<div className={styles.formGroup}>
<label className={styles.label}>Bot-Name</label>
<input
type="text"
className={styles.input}
value={formData.botName || ''}
onChange={(e) => _updateField('botName', e.target.value)}
placeholder={t('AI-Assistent')}
/>
<span className={styles.hint}>
Default-Name fuer den Bot im Meeting. Falls keiner angegeben, wird der Name des System-Bots verwendet (z.B. "Nyla Larsson").
</span>
</div>
</div>
{/* AI Behavior */}
<div className={styles.settingsSection}>
<h4 className={styles.sectionTitle}>AI-Verhalten</h4>
<div className={styles.formGroup}>
<label className={styles.label}>System-Prompt</label>
<textarea
className={styles.textarea}
value={formData.aiSystemPrompt || ''}
onChange={(e) => _updateField('aiSystemPrompt', e.target.value)}
rows={6}
placeholder={t('Beschreibe, wie sich der Bot')}
/>
<span className={styles.hint}>{t('Instruktionen für den AI-Bot, wann')}</span>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>Antwort-Modus</label>
<select
className={styles.select}
value={formData.responseMode || 'auto'}
onChange={(e) => _updateField('responseMode', e.target.value)}
>
<option value="auto">{t('Automatisch (AI entscheidet selbst)')}</option>
<option value="manual">{t('Manuell (Antworten müssen bestätigt werden)')}</option>
<option value="transcribeOnly">{t('Nur Transkription (keine AI-Antworten)')}</option>
</select>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>Antwort-Kanal</label>
<select
className={styles.select}
value={formData.responseChannel || 'voice'}
onChange={(e) => _updateField('responseChannel', e.target.value)}
>
<option value="voice">{t('Nur Sprache (Bot antwortet per)')}</option>
<option value="chat">{t('Nur Chat (Bot antwortet per)')}</option>
<option value="both">{t('Sprache (Chat, Bot antwortet per)')}</option>
</select>
<span className={styles.hint}>{t('Wie soll der Bot im')}</span>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>Transfer-Modus</label>
<select
className={styles.select}
value={formData.transferMode || 'auto'}
onChange={(e) => _updateField('transferMode', e.target.value)}
>
<option value="auto">{t('Automatisch (System wählt basierend auf)')}</option>
<option value="caption">{t('Captions (Teams Livecaptions nur Englisch)')}</option>
<option value="audio">{t('Audiostream Echtzeitspracherkennung über Gateway (alle)')}</option>
</select>
<span className={styles.hint}>
Empfehlung: Audio-Stream fuer nicht-englische Meetings verwenden. Teams Live-Captions sind nur auf Englisch verfuegbar und oft ungenau.
</span>
</div>
</div>
{/* Voice Settings */}
<div className={styles.settingsSection}>
<h4 className={styles.sectionTitle}>Sprach-Einstellungen</h4>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Sprache')}</label>
<select
className={styles.select}
value={formData.language || 'de-DE'}
onChange={(e) => _handleLanguageChange(e.target.value)}
>
{languages.map(lang => (
<option key={lang.bcp47} value={lang.bcp47}>
{lang.flag ? `${lang.flag} ` : ''}{lang.label} ({lang.bcp47})
</option>
))}
</select>
<span className={styles.hint}>{t('Sprache für Captions und Sprachausgabe')} ({languages.length} {t('Sprachen verfügbar')})</span>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Stimme')}</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<select
className={styles.select}
style={{ flex: 1 }}
value={formData.voiceId || ''}
onChange={(e) => _updateField('voiceId', e.target.value)}
disabled={loadingVoices}
>
<option value="">Standard-Stimme</option>
{voices.map(voice => (
<option key={voice.name} value={voice.name}>
{_formatVoiceName(voice)} - {voice.name}
</option>
))}
</select>
<button
className={styles.testButton || styles.saveButton}
onClick={_handleTestVoice}
disabled={testingVoice || loadingVoices}
title={t('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}>
{loadingVoices ? 'Lade Stimmen...' : `${voices.length} Stimmen verfuegbar - klicke Test fuer Vorschau`}
</span>
</div>
</div>
{/* Advanced Settings */}
<div className={styles.settingsSection}>
<h4 className={styles.sectionTitle}>{t('Erweiterte Einstellungen')}</h4>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Analyseintervall (Sek.)')}</label>
<input
type="number"
className={styles.input}
value={formData.triggerIntervalSeconds || 10}
onChange={(e) => _updateField('triggerIntervalSeconds', parseInt(e.target.value))}
min={3}
max={60}
/>
<span className={styles.hint}>{t('Periodisches Analyseintervall')}</span>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Cooldown (Sek.)')}</label>
<input
type="number"
className={styles.input}
value={formData.triggerCooldownSeconds || 3}
onChange={(e) => _updateField('triggerCooldownSeconds', parseInt(e.target.value))}
min={1}
max={30}
/>
<span className={styles.hint}>{t('Min. Abstand zwischen AI-Calls')}</span>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Kontextfenster (Segmente)')}</label>
<input
type="number"
className={styles.input}
value={formData.contextWindowSegments || 20}
onChange={(e) => _updateField('contextWindowSegments', parseInt(e.target.value))}
min={5}
max={100}
/>
<span className={styles.hint}>{t('Anzahl Transkriptsegmente für AI-Kontext')}</span>
</div>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Browser-Bot-URL (optional)')}</label>
<input
type="url"
className={styles.input}
value={formData.browserBotUrl || ''}
onChange={(e) => _updateField('browserBotUrl', e.target.value)}
placeholder={t('Automatisch aus Umgebungskonfiguration')}
/>
<span className={styles.hint}>{t('URL des Browser-Bot-Service')}</span>
</div>
<div className={styles.formGroup}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.debugMode || false}
onChange={(e) => _updateField('debugMode', e.target.checked)}
style={{ width: '18px', height: '18px' }}
/>
<span className={styles.label} style={{ margin: 0 }}>Debug-Modus</span>
</label>
<span className={styles.hint}>
Aktiviert Screenshot-Erfassung bei jedem Join-Schritt fuer Diagnose. Screenshots koennen in der Session-Ansicht eingesehen werden.
</span>
</div>
</div>
{/* Save Button */}
<div className={styles.settingsActions}>
<button
className={styles.saveButton}
onClick={_handleSave}
disabled={saving}
>
{saving ? t('Speichern') : t('Konfiguration speichern')}
</button>
</div>
</div>
</div>
);
};
export default TeamsbotSettingsView;