587 lines
23 KiB
TypeScript
587 lines
23 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, AuthTestResults, AuthTestResult } from '../../../api/teamsbotApi';
|
|
import { FaPlay, FaSpinner, FaFlask, FaImage, FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
|
import styles from './Teamsbot.module.css';
|
|
|
|
/** 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 { 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>({});
|
|
|
|
// Dynamic voice data from Google TTS API
|
|
const [languages, setLanguages] = useState<string[]>([]);
|
|
const [voices, setVoices] = useState<VoiceOption[]>([]);
|
|
const [loadingVoices, setLoadingVoices] = useState(false);
|
|
|
|
// Auth detection test state
|
|
const [testMeetingUrl, setTestMeetingUrl] = useState('');
|
|
const [testRunning, setTestRunning] = useState(false);
|
|
const [testResults, setTestResults] = useState<AuthTestResults | null>(null);
|
|
const [testError, setTestError] = useState<string | null>(null);
|
|
const [screenshotPreview, setScreenshotPreview] = useState<{ src: string; caption: string } | null>(null);
|
|
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
|
|
|
|
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 || 'AI Assistant';
|
|
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 || 'Stimmtest fehlgeschlagen');
|
|
setTimeout(() => setError(null), 3000);
|
|
}
|
|
} catch (err: any) {
|
|
setError(err.message || 'Stimmtest fehlgeschlagen');
|
|
setTimeout(() => setError(null), 3000);
|
|
} finally {
|
|
setTestingVoice(false);
|
|
}
|
|
};
|
|
|
|
const _handleRunAuthTest = async () => {
|
|
if (!instanceId || !testMeetingUrl.trim()) return;
|
|
setTestRunning(true);
|
|
setTestResults(null);
|
|
setTestError(null);
|
|
|
|
try {
|
|
const results = await teamsbotApi.testAuth(instanceId, testMeetingUrl.trim());
|
|
setTestResults(results);
|
|
} catch (err: any) {
|
|
setTestError(err.message || 'Auth-Test fehlgeschlagen');
|
|
} finally {
|
|
setTestRunning(false);
|
|
}
|
|
};
|
|
|
|
const _getPageTypeBadge = (result: AuthTestResult) => {
|
|
switch (result.pageType) {
|
|
case 'v2':
|
|
return <span className={styles.testBadgeV2}>/v2/</span>;
|
|
case 'lightMeetings':
|
|
return <span className={styles.testBadgeLightMeetings}>light-meetings</span>;
|
|
case 'error':
|
|
return <span className={styles.testBadgeError}>Fehler</span>;
|
|
default:
|
|
return <span className={styles.testBadgeUnknown}>Unbekannt</span>;
|
|
}
|
|
};
|
|
|
|
const _getCheckmark = (value: boolean) => {
|
|
return value
|
|
? <span className={styles.testCheckmark}>✓</span>
|
|
: <span className={styles.testCross}>✗</span>;
|
|
};
|
|
|
|
const _formatDuration = (ms: number) => {
|
|
if (ms < 1000) return `${ms}ms`;
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
};
|
|
|
|
const _toggleLogs = (variantId: string) => {
|
|
setExpandedLogs(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(variantId)) {
|
|
next.delete(variantId);
|
|
} else {
|
|
next.add(variantId);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const _getLogLevelClass = (log: string): string => {
|
|
if (log.startsWith('[ERROR]') || log.startsWith('[CONSOLE:error]') || log.startsWith('[PAGE_ERROR]')) {
|
|
return styles.testLogError;
|
|
}
|
|
if (log.startsWith('[WARN]') || log.startsWith('[CONSOLE:warning]')) {
|
|
return styles.testLogWarn;
|
|
}
|
|
return styles.testLogInfo;
|
|
};
|
|
|
|
if (loading) return <div className={styles.loading}>Lade Konfiguration...</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>}
|
|
|
|
{/* Auth Detection Test */}
|
|
<div className={styles.testSection}>
|
|
<div className={styles.testSectionHeader}>
|
|
<FaFlask />
|
|
<h4 className={styles.testSectionTitle}>Auth-Erkennung testen</h4>
|
|
</div>
|
|
<span className={styles.hint}>
|
|
Testet Direct-/v2/-Navigation gegen einen aktiven Teams-Call.
|
|
Prueft ob Teams /v2/ (Auth moeglich) oder light-meetings (anonym erzwungen) liefert.
|
|
Der Bot tritt dem Meeting NICHT bei.
|
|
</span>
|
|
|
|
<div className={styles.testInputRow} style={{ marginTop: '0.75rem' }}>
|
|
<input
|
|
type="url"
|
|
className={styles.input}
|
|
value={testMeetingUrl}
|
|
onChange={(e) => setTestMeetingUrl(e.target.value)}
|
|
placeholder="https://teams.microsoft.com/meet/..."
|
|
disabled={testRunning}
|
|
/>
|
|
<button
|
|
className={styles.testButton}
|
|
onClick={_handleRunAuthTest}
|
|
disabled={testRunning || !testMeetingUrl.trim()}
|
|
>
|
|
{testRunning ? <FaSpinner className={styles.spinner} /> : <FaFlask />}
|
|
{testRunning ? 'Teste...' : 'Testen'}
|
|
</button>
|
|
</div>
|
|
|
|
{testRunning && (
|
|
<div className={styles.testProgress}>
|
|
<FaSpinner className={styles.spinner} />
|
|
Test laeuft (~30-45 Sekunden)...
|
|
</div>
|
|
)}
|
|
|
|
{testError && <div className={styles.errorBanner}>{testError}</div>}
|
|
|
|
{testResults && (
|
|
<>
|
|
<table className={styles.testResultsTable}>
|
|
<thead>
|
|
<tr>
|
|
<th>Variante</th>
|
|
<th>Seite</th>
|
|
<th>Sign-in</th>
|
|
<th>Name-Input</th>
|
|
<th>Join</th>
|
|
<th>Auth</th>
|
|
<th>Dauer</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{testResults.variants.map((result) => (
|
|
<React.Fragment key={result.variantId}>
|
|
<tr>
|
|
<td>
|
|
<div className={styles.testVariantCell}>
|
|
{result.logs && result.logs.length > 0 && (
|
|
<button
|
|
className={styles.testLogToggle}
|
|
onClick={() => _toggleLogs(result.variantId)}
|
|
title="Logs anzeigen/ausblenden"
|
|
>
|
|
{expandedLogs.has(result.variantId) ? <FaChevronDown /> : <FaChevronRight />}
|
|
</button>
|
|
)}
|
|
<span className={styles.testVariantName}>{result.variantName}</span>
|
|
</div>
|
|
{result.error && (
|
|
<div className={styles.testErrorText} title={result.error}>
|
|
{result.error.length > 120 ? result.error.substring(0, 120) + '...' : result.error}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td>{_getPageTypeBadge(result)}</td>
|
|
<td>{result.success ? _getCheckmark(result.hasSignInLink) : <span className={styles.testDash}>—</span>}</td>
|
|
<td>{result.success ? _getCheckmark(result.hasNameInput) : <span className={styles.testDash}>—</span>}</td>
|
|
<td>{result.success ? _getCheckmark(result.hasJoinButton) : <span className={styles.testDash}>—</span>}</td>
|
|
<td>
|
|
{result.authAttempted
|
|
? (result.authSuccess ? <span className={styles.testCheckmark}>Erfolgreich</span> : <span className={styles.testCross}>Fehlgeschlagen</span>)
|
|
: <span className={styles.testDash}>—</span>
|
|
}
|
|
</td>
|
|
<td><span className={styles.testDuration}>{_formatDuration(result.durationMs)}</span></td>
|
|
<td>
|
|
{result.screenshot && (
|
|
<button
|
|
className={styles.testScreenshotButton}
|
|
onClick={() => setScreenshotPreview({
|
|
src: `data:image/jpeg;base64,${result.screenshot}`,
|
|
caption: result.variantName,
|
|
})}
|
|
>
|
|
<FaImage /> Bild
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
{expandedLogs.has(result.variantId) && result.logs && result.logs.length > 0 && (
|
|
<tr className={styles.testLogRow}>
|
|
<td colSpan={8}>
|
|
<div className={styles.testLogContainer}>
|
|
{result.logs.map((log, idx) => (
|
|
<div key={idx} className={`${styles.testLogLine} ${_getLogLevelClass(log)}`}>
|
|
{log}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{testResults.recommendation && (
|
|
<div className={styles.testRecommendation}>
|
|
<strong>Empfehlung:</strong> {testResults.recommendation}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Screenshot Overlay */}
|
|
{screenshotPreview && (
|
|
<div
|
|
className={styles.testScreenshotOverlay}
|
|
onClick={() => setScreenshotPreview(null)}
|
|
>
|
|
<img
|
|
src={screenshotPreview.src}
|
|
alt={screenshotPreview.caption}
|
|
className={styles.testScreenshotImage}
|
|
/>
|
|
<div className={styles.testScreenshotCaption}>
|
|
{screenshotPreview.caption} — Klicken zum Schliessen
|
|
</div>
|
|
</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="AI Assistant"
|
|
/>
|
|
<span className={styles.hint}>Wird als Teilnehmer-Name im Meeting angezeigt</span>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.label}>Hintergrundbild-URL</label>
|
|
<input
|
|
type="url"
|
|
className={styles.input}
|
|
value={formData.backgroundImageUrl || ''}
|
|
onChange={(e) => _updateField('backgroundImageUrl', e.target.value)}
|
|
placeholder="https://example.com/background.jpg"
|
|
/>
|
|
<span className={styles.hint}>Hintergrundbild fuer den Video-Feed des Bots</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="Beschreibe, wie sich der Bot im Meeting verhalten soll..."
|
|
/>
|
|
<span className={styles.hint}>Instruktionen fuer den AI-Bot: Wann und wie soll er antworten?</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">Automatisch - AI entscheidet selbst</option>
|
|
<option value="manual">Manuell - Antworten muessen bestaetigt werden</option>
|
|
<option value="transcribeOnly">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">Nur Sprache - Bot antwortet per Audio</option>
|
|
<option value="chat">Nur Chat - Bot antwortet per Textnachricht</option>
|
|
<option value="both">Sprache + Chat - Bot antwortet per Audio und Text</option>
|
|
</select>
|
|
<span className={styles.hint}>Wie soll der Bot im Meeting antworten?</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Voice Settings */}
|
|
<div className={styles.settingsSection}>
|
|
<h4 className={styles.sectionTitle}>Sprach-Einstellungen</h4>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.label}>Sprache</label>
|
|
<select
|
|
className={styles.select}
|
|
value={formData.language || 'de-DE'}
|
|
onChange={(e) => _handleLanguageChange(e.target.value)}
|
|
>
|
|
{languages.length > 0 ? (
|
|
languages.map((langCode, idx) => (
|
|
<option key={`${langCode}-${idx}`} value={langCode}>{langCode}</option>
|
|
))
|
|
) : (
|
|
<>
|
|
<option value="de-DE">Deutsch (Deutschland)</option>
|
|
<option value="en-US">English (US)</option>
|
|
<option value="fr-FR">Francais</option>
|
|
</>
|
|
)}
|
|
</select>
|
|
<span className={styles.hint}>Sprache fuer Captions und Sprachausgabe ({languages.length} Sprachen verfuegbar)</span>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.label}>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="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>
|
|
|
|
{/* Bot Account - managed by admin, read-only display */}
|
|
<div className={styles.settingsSection}>
|
|
<h4 className={styles.sectionTitle}>Bot-Account (Microsoft)</h4>
|
|
<span className={styles.hint}>
|
|
System-Bot-Accounts werden vom Administrator verwaltet und sind nicht direkt editierbar.
|
|
Waehle beim Session-Start den gewuenschten Join-Modus.
|
|
</span>
|
|
</div>
|
|
|
|
{/* Advanced Settings */}
|
|
<div className={styles.settingsSection}>
|
|
<h4 className={styles.sectionTitle}>Erweiterte Einstellungen</h4>
|
|
|
|
<div className={styles.formRow}>
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.label}>Analyse-Intervall (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}>Periodisches Analyse-Intervall</span>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.label}>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}>Min. Abstand zwischen AI-Calls</span>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.label}>Kontext-Fenster (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}>Anzahl Transkript-Segmente fuer AI-Kontext</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.formGroup}>
|
|
<label className={styles.label}>Browser Bot URL (optional)</label>
|
|
<input
|
|
type="url"
|
|
className={styles.input}
|
|
value={formData.browserBotUrl || ''}
|
|
onChange={(e) => _updateField('browserBotUrl', e.target.value)}
|
|
placeholder="Automatisch aus Umgebungskonfiguration"
|
|
/>
|
|
<span className={styles.hint}>URL des Browser Bot Service. Leer lassen fuer Standard-Konfiguration.</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save Button */}
|
|
<div className={styles.settingsActions}>
|
|
<button
|
|
className={styles.saveButton}
|
|
onClick={_handleSave}
|
|
disabled={saving}
|
|
>
|
|
{saving ? 'Speichern...' : 'Konfiguration speichern'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TeamsbotSettingsView;
|