rag enhancements

This commit is contained in:
ValueOn AG 2026-05-16 22:55:48 +02:00
parent abdb499067
commit 12e10350d9
12 changed files with 466 additions and 59 deletions

View file

@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } from './pages/admin';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage, SttBenchmarkPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
@ -173,7 +173,6 @@ function App() {
{/* Workspace + Automation2 Editor */}
<Route path="editor" element={<FeatureViewPage view="editor" />} />
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
{/* Automation2 Workflows & Tasks */}
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
@ -226,6 +225,7 @@ function App() {
<Route path="languages" element={null} />
<Route path="database-health" element={<AdminDatabaseHealthPage />} />
<Route path="demo-config" element={<AdminDemoConfigPage />} />
<Route path="stt-benchmark" element={<SttBenchmarkPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
</Route>

View file

@ -360,7 +360,16 @@ export interface RagConnectionDto {
dataSources: RagDataSourceDto[];
totalChunks: number;
runningJobs: { jobId: string; progress: number; progressMessage: string }[];
lastError?: { jobId: string; errorMessage: string } | null;
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
lastSuccess?: {
jobId: string;
finishedAt: number | null;
indexed: number;
skippedDuplicate: number;
skippedPolicy: number;
failed: number;
durationMs: number;
} | null;
}
export interface RagInventoryDto {

View file

@ -34,6 +34,27 @@
animation: pulse 1.5s infinite;
}
.badgeDone {
background: #16a34a;
animation: doneFadeIn 0.25s ease-out;
}
.doneIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
font-weight: 700;
font-size: 12px;
line-height: 1;
}
@keyframes doneFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }

View file

@ -12,19 +12,34 @@ interface _RagJob {
progressMessage: string;
}
const _POLL_INTERVAL_MS = 60_000;
const _POLL_INTERVAL_ACTIVE_MS = 5_000;
const _POLL_INTERVAL_IDLE_MS = 60_000;
const _DONE_TOAST_MS = 4_000;
export const RagRunningBadge: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [jobs, setJobs] = useState<_RagJob[]>([]);
const [justFinished, setJustFinished] = useState(false);
const [expanded, setExpanded] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const previousJobCount = useRef(0);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const _fetchJobs = useCallback(async () => {
try {
const result = await request({ url: '/api/rag/inventory/jobs', method: 'get' });
setJobs(Array.isArray(result) ? result : []);
const list = Array.isArray(result) ? (result as _RagJob[]) : [];
// Detect "all running jobs just completed" → flash a brief success toast
// so the user gets visible confirmation that the work actually finished
// instead of the spinner just silently disappearing.
if (previousJobCount.current > 0 && list.length === 0) {
setJustFinished(true);
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
toastTimerRef.current = setTimeout(() => setJustFinished(false), _DONE_TOAST_MS);
}
previousJobCount.current = list.length;
setJobs(list);
} catch {
setJobs([]);
}
@ -32,11 +47,31 @@ export const RagRunningBadge: React.FC = () => {
useEffect(() => {
_fetchJobs();
timerRef.current = setInterval(_fetchJobs, _POLL_INTERVAL_MS);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [_fetchJobs]);
if (jobs.length === 0) return null;
useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current);
const interval = jobs.length > 0 ? _POLL_INTERVAL_ACTIVE_MS : _POLL_INTERVAL_IDLE_MS;
timerRef.current = setInterval(_fetchJobs, interval);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [_fetchJobs, jobs.length]);
useEffect(() => {
return () => { if (toastTimerRef.current) clearTimeout(toastTimerRef.current); };
}, []);
if (jobs.length === 0 && !justFinished) return null;
if (jobs.length === 0 && justFinished) {
return (
<div className={styles.badgeContainer}>
<div className={`${styles.badge} ${styles.badgeDone}`} title={t('Sync abgeschlossen')}>
<span className={styles.doneIcon}></span>
<span className={styles.badgeText}>{t('Sync abgeschlossen')}</span>
</div>
</div>
);
}
return (
<div className={styles.badgeContainer}>
@ -60,7 +95,7 @@ export const RagRunningBadge: React.FC = () => {
<div key={job.jobId} className={styles.jobRow}>
<span className={styles.jobLabel}>{job.connectionLabel || job.jobType}</span>
<span className={styles.jobProgress}>
{job.progress != null ? `${Math.round(job.progress)}%` : '...'}
{job.progressMessage || t('läuft...')}
</span>
</div>
))}

View file

@ -28,6 +28,8 @@ import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
import { getPageIcon } from '../../config/pageRegistry';
import styles from './SourcesTab.module.css';
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaEnvelope, FaComments, FaCalendarAlt, FaUser, FaCloudUploadAlt } from 'react-icons/fa';
import { SiJira } from 'react-icons/si';
import { useLanguage } from '../../providers/language/LanguageContext';
@ -61,7 +63,7 @@ interface UdbFeatureDataSource {
interface TreeNode {
key: string;
label: string;
icon: string;
icon: React.ReactNode;
type: 'connection' | 'service' | 'folder' | 'file';
expanded: boolean;
loading: boolean;
@ -123,28 +125,28 @@ interface SourcesTabProps {
/* ─── Icons ──────────────────────────────────────────────────────────── */
const _AUTHORITY_ICONS: Record<string, string> = {
msft: '\uD83D\uDFE6',
google: '\uD83D\uDFE9',
clickup: '\uD83D\uDCCB',
infomaniak: '\uD83D\uDFE5',
'local:ftp': '\uD83D\uDD17',
'local:jira': '\uD83D\uDD27',
const _AUTHORITY_ICONS: Record<string, React.ReactNode> = {
msft: <FaMicrosoft style={{ color: '#00a4ef', fontSize: 12 }} />,
google: <FaGoogle style={{ color: '#4285f4', fontSize: 12 }} />,
clickup: <FaTasks style={{ color: '#7b68ee', fontSize: 12 }} />,
infomaniak: <FaCloud style={{ color: '#0098db', fontSize: 12 }} />,
'local:ftp': <FaLink style={{ color: '#795548', fontSize: 12 }} />,
'local:jira': <SiJira style={{ color: '#0052CC', fontSize: 12 }} />,
};
const _SERVICE_ICONS: Record<string, string> = {
sharepoint: '\uD83D\uDCC1',
onedrive: '\u2601\uFE0F',
outlook: '\uD83D\uDCE7',
teams: '\uD83D\uDCAC',
drive: '\uD83D\uDCC2',
gmail: '\uD83D\uDCE8',
files: '\uD83D\uDCC2',
clickup: '\uD83D\uDCCB',
kdrive: '\uD83D\uDCC2',
mail: '\uD83D\uDCE7',
calendar: '\uD83D\uDCC5',
contact: '\uD83D\uDC64',
const _SERVICE_ICONS: Record<string, React.ReactNode> = {
sharepoint: <FaFolder style={{ color: '#0078d4', fontSize: 11 }} />,
onedrive: <FaCloudUploadAlt style={{ color: '#0078d4', fontSize: 11 }} />,
outlook: <FaEnvelope style={{ color: '#0078d4', fontSize: 11 }} />,
teams: <FaComments style={{ color: '#5b5fc7', fontSize: 11 }} />,
drive: <FaFolder style={{ color: '#34a853', fontSize: 11 }} />,
gmail: <FaEnvelope style={{ color: '#ea4335', fontSize: 11 }} />,
files: <FaFolder style={{ color: '#795548', fontSize: 11 }} />,
clickup: <FaTasks style={{ color: '#7b68ee', fontSize: 11 }} />,
kdrive: <FaFolder style={{ color: '#0098FF', fontSize: 11 }} />,
mail: <FaEnvelope style={{ color: '#0098FF', fontSize: 11 }} />,
calendar: <FaCalendarAlt style={{ color: '#0098FF', fontSize: 11 }} />,
contact: <FaUser style={{ color: '#0098FF', fontSize: 11 }} />,
};
/* ─── Source colors & icons ──────────────────────────────────────────── */
@ -334,7 +336,7 @@ async function _loadServices(instanceId: string, connectionId: string): Promise<
return services.map((s: any) => ({
key: `svc-${connectionId}-${s.service}`,
label: s.label || s.service,
icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
icon: _SERVICE_ICONS[s.service] || <FaFolder style={{ color: '#888', fontSize: 11 }} />,
type: 'service' as const,
expanded: false,
loading: false,
@ -546,7 +548,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
.map((c: any) => ({
key: `conn-${c.id}`,
label: c.externalEmail || c.externalUsername || c.authority,
icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17',
icon: _AUTHORITY_ICONS[c.authority] || <FaLink style={{ color: '#888', fontSize: 12 }} />,
type: 'connection' as const,
expanded: false,
loading: false,
@ -1226,7 +1228,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{node.loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 14, flexShrink: 0 }}>{node.icon}</span>
<span style={{ fontSize: 14, flexShrink: 0, display: 'flex', alignItems: 'center' }}>{node.icon}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',

View file

@ -19,7 +19,7 @@ import {
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase, FaMicrophone,
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
FaFileContract, FaRobot, FaGlobe, FaClipboardCheck,
@ -88,6 +88,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.database-health': <FaDatabase />,
'page.admin.demoConfig': <FaCubes />,
'page.admin.demo-config': <FaCubes />,
'page.admin.sttBenchmark': <FaMicrophone />,
'page.admin.stt-benchmark': <FaMicrophone />,
'page.admin.mandate-wizard': <FaHatWizard />,
'page.admin.mandateWizard': <FaHatWizard />,
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,

View file

@ -227,7 +227,7 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor' && view !== 'rag-insights') {
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') {
return null;
}

View file

@ -201,6 +201,25 @@
color: #b91c1c;
}
.successBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8125rem;
color: #166534;
}
.successBanner .duration {
color: #65a30d;
margin-left: 6px;
opacity: 0.85;
}
.reindexBtn {
display: flex;
align-items: center;

View file

@ -12,7 +12,7 @@ import { useLanguage } from '../providers/language/LanguageContext';
import { useApiRequest } from '../hooks/useApi';
import { useUserMandates } from '../hooks/useUserMandates';
import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi';
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle } from 'react-icons/fa';
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import styles from './RagInventoryPage.module.css';
@ -81,12 +81,19 @@ export const RagInventoryPage: React.FC = () => {
_fetchInventory();
}, [_fetchInventory]);
const _hasActiveJobs = !!inventory?.connections?.some(c => (c.runningJobs?.length || 0) > 0);
useEffect(() => {
if (pollRef.current) clearInterval(pollRef.current);
// Fast poll (5s) while a sync is in flight so the user gets a snappy
// success/error confirmation; slow poll (60s) at rest to keep the DB
// load low. Visibility check skips polling for backgrounded tabs.
const intervalMs = _hasActiveJobs ? 5000 : 60000;
pollRef.current = setInterval(() => {
if (document.visibilityState === 'visible') _fetchInventory();
}, 60000);
}, intervalMs);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, [_fetchInventory]);
}, [_fetchInventory, _hasActiveJobs]);
const _handleStop = async (connectionId: string) => {
try {
@ -115,6 +122,23 @@ export const RagInventoryPage: React.FC = () => {
}
};
const _formatRelative = useCallback((finishedAt: number | null | undefined): string => {
if (!finishedAt) return '';
const nowSec = Date.now() / 1000;
const diff = Math.max(0, nowSec - finishedAt);
if (diff < 45) return t('gerade eben');
if (diff < 3600) return t('vor {n} Min', { n: Math.floor(diff / 60) });
if (diff < 86400) return t('vor {n} Std', { n: Math.floor(diff / 3600) });
return t('vor {n} Tag(en)', { n: Math.floor(diff / 86400) });
}, [t]);
const _formatDuration = useCallback((ms: number | undefined): string => {
if (!ms || ms <= 0) return '';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
}, []);
const scopeOptions = useMemo(() => {
const opts: { value: string; label: string }[] = [
{ value: 'personal', label: t('Meine Verbindungen') },
@ -199,33 +223,70 @@ export const RagInventoryPage: React.FC = () => {
</div>
)}
{conn.lastError && conn.runningJobs.length === 0 && (
<div className={styles.errorBanner}>
<FaExclamationTriangle />
<span>{t('Letzter Job fehlgeschlagen')}: {conn.lastError.errorMessage || t('unbekannter Fehler')}</span>
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Neu indexieren')}>
<FaRedo size={12} /> {t('Neu indexieren')}
</button>
</div>
)}
{conn.runningJobs.length > 0 && (
{/* Status banner: priority is Running > Error-newer-than-Success > Success > Reindex-Hint.
This way a stale error doesn't override a fresh successful resync, and the
spinner is never shown without a real job behind it. */}
{conn.runningJobs.length > 0 ? (
<div className={styles.jobBanner}>
<FaSync className={styles.spinIcon} />
<span>{conn.runningJobs[0].progressMessage || `${Math.round(conn.runningJobs[0].progress)}%`}</span>
<span>{conn.runningJobs[0].progressMessage || t('Synchronisierung läuft...')}</span>
<button className={styles.stopBtn} onClick={() => _handleStop(conn.id)} title={t('Indexierung stoppen')}>
<FaStop size={12} /> {t('Stop')}
</button>
</div>
)}
) : (() => {
const errAt = conn.lastError?.finishedAt ?? 0;
const okAt = conn.lastSuccess?.finishedAt ?? 0;
const errorIsNewer = !!conn.lastError && errAt > okAt;
{!conn.lastError && conn.runningJobs.length === 0 && conn.dataSources.some(ds => ds.ragIndexEnabled) && conn.totalChunks === 0 && conn.knowledgeIngestionEnabled && (
<div className={styles.reindexHint}>
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Indexierung starten')}>
<FaRedo size={12} /> {t('Indexierung starten')}
</button>
</div>
)}
if (errorIsNewer) {
return (
<div className={styles.errorBanner}>
<FaExclamationTriangle />
<span>
{t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {conn.lastError?.errorMessage || t('unbekannter Fehler')}
</span>
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Neu indexieren')}>
<FaRedo size={12} /> {t('Neu indexieren')}
</button>
</div>
);
}
if (conn.lastSuccess) {
const s = conn.lastSuccess;
const stats = [
s.indexed > 0 ? t('{n} neu indexiert', { n: s.indexed }) : null,
s.skippedDuplicate > 0 ? t('{n} unverändert', { n: s.skippedDuplicate }) : null,
s.skippedPolicy > 0 ? t('{n} übersprungen', { n: s.skippedPolicy }) : null,
s.failed > 0 ? t('{n} fehler', { n: s.failed }) : null,
].filter(Boolean).join(' · ');
return (
<div className={styles.successBanner}>
<FaCheckCircle />
<span>
{t('Sync erfolgreich')} {_formatRelative(okAt)}
{stats && <> {stats}</>}
{s.durationMs > 0 && <span className={styles.duration}> ({_formatDuration(s.durationMs)})</span>}
</span>
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Erneut indexieren')}>
<FaRedo size={12} /> {t('Erneut indexieren')}
</button>
</div>
);
}
if (conn.dataSources.some(ds => ds.ragIndexEnabled) && conn.knowledgeIngestionEnabled) {
return (
<div className={styles.reindexHint}>
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Indexierung starten')}>
<FaRedo size={12} /> {t('Indexierung starten')}
</button>
</div>
);
}
return null;
})()}
<div className={styles.dsList}>
{conn.dataSources.map(ds => (

View file

@ -0,0 +1,258 @@
/**
* SttBenchmarkPage Compare STT v1 (latest_long) vs v2 (Chirp 2).
* SysAdmin only. Upload audio, run both engines, compare results.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FaMicrophone, FaUpload, FaPlay, FaStop, FaSpinner } from 'react-icons/fa';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useApiRequest } from '../../hooks/useApi';
import styles from '../admin/Admin.module.css';
interface ModelOption { value: string; label: string }
interface BenchmarkResult {
api: string;
model: string;
latencyMs: number;
results: { transcript: string; confidence: number; words: number }[];
resultCount: number;
location?: string;
error?: string;
}
interface BenchmarkResponse {
filename: string;
fileSizeBytes: number;
language: string;
v1: BenchmarkResult | { error: string };
v2: BenchmarkResult | { error: string };
}
interface ModelsResponse {
v1Models: ModelOption[];
v2Models: ModelOption[];
locations: ModelOption[];
languages: ModelOption[];
}
export const SttBenchmarkPage: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [models, setModels] = useState<ModelsResponse | null>(null);
const [language, setLanguage] = useState('de-DE');
const [v1Model, setV1Model] = useState('latest_long');
const [v2Model, setV2Model] = useState('chirp_2');
const [v2Location, setV2Location] = useState('europe-west4');
const [running, setRunning] = useState(false);
const [result, setResult] = useState<BenchmarkResponse | null>(null);
const [recording, setRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
request({ url: '/api/admin/stt-benchmark/models', method: 'get' })
.then((data: any) => setModels(data))
.catch(() => {});
}, []);
const _startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
chunksRef.current = [];
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob));
stream.getTracks().forEach(t => t.stop());
};
mediaRecorderRef.current = recorder;
recorder.start();
setRecording(true);
} catch (err) {
console.error('Microphone access denied', err);
}
}, []);
const _stopRecording = useCallback(() => {
mediaRecorderRef.current?.stop();
setRecording(false);
}, []);
const _handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAudioBlob(file);
setAudioUrl(URL.createObjectURL(file));
}, []);
const _runBenchmark = useCallback(async () => {
if (!audioBlob) return;
setRunning(true);
setResult(null);
try {
const formData = new FormData();
const filename = audioBlob instanceof File ? audioBlob.name : 'recording.webm';
formData.append('file', audioBlob, filename);
formData.append('language', language);
formData.append('v1Model', v1Model);
formData.append('v2Model', v2Model);
formData.append('v2Location', v2Location);
const resp = await fetch('/api/admin/stt-benchmark/run', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data: BenchmarkResponse = await resp.json();
setResult(data);
} catch (err: any) {
console.error('Benchmark failed:', err);
} finally {
setRunning(false);
}
}, [audioBlob, language, v1Model, v2Model, v2Location]);
const _renderResult = (label: string, r: BenchmarkResult | { error: string }) => {
if ('error' in r && r.error) {
return (
<div style={{ flex: 1, padding: 16, border: '1px solid #e74c3c', borderRadius: 8, background: '#fdf2f2' }}>
<h3>{label}</h3>
<p style={{ color: '#e74c3c' }}>{r.error}</p>
</div>
);
}
const res = r as BenchmarkResult;
const topTranscript = res.results?.[0]?.transcript || '(no result)';
const topConfidence = res.results?.[0]?.confidence ?? 0;
return (
<div style={{ flex: 1, padding: 16, border: '1px solid #ddd', borderRadius: 8, background: '#fafafa' }}>
<h3 style={{ margin: '0 0 8px' }}>{label}</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 12 }}>
<div><strong>{t('Modell')}:</strong> {res.model}</div>
<div><strong>{t('Latenz')}:</strong> {res.latencyMs} ms</div>
<div><strong>{t('Konfidenz')}:</strong> {(topConfidence * 100).toFixed(1)}%</div>
<div><strong>{t('Alternativen')}:</strong> {res.results?.length || 0}</div>
{res.location && <div><strong>{t('Region')}:</strong> {res.location}</div>}
</div>
<div style={{ background: '#fff', padding: 12, borderRadius: 6, border: '1px solid #eee', fontSize: 15 }}>
{topTranscript}
</div>
{res.results?.length > 1 && (
<details style={{ marginTop: 8 }}>
<summary style={{ cursor: 'pointer', fontSize: 13, color: '#666' }}>{t('Weitere Alternativen')}</summary>
{res.results.slice(1).map((alt, i) => (
<div key={i} style={{ marginTop: 4, fontSize: 13, color: '#888' }}>
[{(alt.confidence * 100).toFixed(1)}%] {alt.transcript}
</div>
))}
</details>
)}
</div>
);
};
return (
<div className={styles.adminPage}>
<div className={styles.adminHeader}>
<h1><FaMicrophone style={{ marginRight: 8 }} /> {t('STT Benchmark')}</h1>
<p style={{ color: '#666', margin: '4px 0 0' }}>
{t('Vergleiche Speech-to-Text v1 (latest_long) mit v2 (Chirp 2). Lade eine Audio-Datei hoch oder nimm direkt auf.')}
</p>
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', margin: '20px 0' }}>
<label>
<strong>{t('Sprache')}:</strong>
<select value={language} onChange={e => setLanguage(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.languages || [{ value: 'de-DE', label: 'Deutsch' }]).map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</label>
<label>
<strong>v1 {t('Modell')}:</strong>
<select value={v1Model} onChange={e => setV1Model(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.v1Models || [{ value: 'latest_long', label: 'latest_long' }]).map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</label>
<label>
<strong>v2 {t('Modell')}:</strong>
<select value={v2Model} onChange={e => setV2Model(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.v2Models || [{ value: 'chirp_2', label: 'Chirp 2' }]).map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</label>
<label>
<strong>{t('Region')} (v2):</strong>
<select value={v2Location} onChange={e => setV2Location(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.locations || [{ value: 'europe-west4', label: 'Europe West' }]).map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</label>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', margin: '16px 0' }}>
{!recording ? (
<button onClick={_startRecording} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#e74c3c', color: '#fff', border: 'none', borderRadius: 6 }}>
<FaMicrophone /> {t('Aufnehmen')}
</button>
) : (
<button onClick={_stopRecording} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#333', color: '#fff', border: 'none', borderRadius: 6, animation: 'pulse 1s infinite' }}>
<FaStop /> {t('Stoppen')}
</button>
)}
<button onClick={() => fileInputRef.current?.click()} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#3498db', color: '#fff', border: 'none', borderRadius: 6 }}>
<FaUpload /> {t('Datei hochladen')}
</button>
<input ref={fileInputRef} type="file" accept="audio/*" onChange={_handleFileSelect} style={{ display: 'none' }} />
{audioBlob && (
<>
<span style={{ color: '#666', fontSize: 13 }}>
{audioBlob instanceof File ? audioBlob.name : 'recording.webm'} ({(audioBlob.size / 1024).toFixed(0)} KB)
</span>
{audioUrl && <audio src={audioUrl} controls style={{ height: 32 }} />}
</>
)}
</div>
<button
onClick={_runBenchmark}
disabled={!audioBlob || running}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 24px', cursor: audioBlob && !running ? 'pointer' : 'not-allowed',
background: audioBlob && !running ? '#27ae60' : '#bdc3c7', color: '#fff', border: 'none', borderRadius: 6, fontSize: 15, fontWeight: 600,
}}
>
{running ? <FaSpinner className="fa-spin" /> : <FaPlay />}
{running ? t('Benchmark laeuft...') : t('Benchmark starten')}
</button>
{result && (
<div style={{ marginTop: 24 }}>
<h2>{t('Ergebnis')}</h2>
<p style={{ fontSize: 13, color: '#888' }}>
{result.filename} ({(result.fileSizeBytes / 1024).toFixed(0)} KB) {result.language}
</p>
<div style={{ display: 'flex', gap: 16, marginTop: 12 }}>
{_renderResult(`v1 — ${(result.v1 as any).model || v1Model}`, result.v1)}
{_renderResult(`v2 — ${(result.v2 as any).model || v2Model}`, result.v2)}
</div>
</div>
)}
</div>
);
};
export default SttBenchmarkPage;

View file

@ -19,3 +19,4 @@ export { AdminLogsPage } from './AdminLogsPage';
export { AdminLanguagesPage } from './AdminLanguagesPage';
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';
export { SttBenchmarkPage } from './SttBenchmarkPage';

View file

@ -295,7 +295,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
views: [
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
{ code: 'editor', label: 'Editor', path: 'editor' },
{ code: 'rag-insights', label: 'Wissens-Insights', path: 'rag-insights' },
{ code: 'settings', label: 'Einstellungen', path: 'settings' },
]
},