| 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 (
+
+
+ ✓
+ {t('Sync abgeschlossen')}
+
+
+ );
+ }
return (
@@ -60,7 +95,7 @@ export const RagRunningBadge: React.FC = () => {
{job.connectionLabel || job.jobType}
- {job.progress != null ? `${Math.round(job.progress)}%` : '...'}
+ {job.progressMessage || t('läuft...')}
))}
diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx
index 63c4486..f2cc679 100644
--- a/src/components/UnifiedDataBar/SourcesTab.tsx
+++ b/src/components/UnifiedDataBar/SourcesTab.tsx
@@ -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
= {
- msft: '\uD83D\uDFE6',
- google: '\uD83D\uDFE9',
- clickup: '\uD83D\uDCCB',
- infomaniak: '\uD83D\uDFE5',
- 'local:ftp': '\uD83D\uDD17',
- 'local:jira': '\uD83D\uDD27',
+const _AUTHORITY_ICONS: Record = {
+ msft: ,
+ google: ,
+ clickup: ,
+ infomaniak: ,
+ 'local:ftp': ,
+ 'local:jira': ,
};
-const _SERVICE_ICONS: Record = {
- 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 = {
+ sharepoint: ,
+ onedrive: ,
+ outlook: ,
+ teams: ,
+ drive: ,
+ gmail: ,
+ files: ,
+ clickup: ,
+ kdrive: ,
+ mail: ,
+ calendar: ,
+ contact: ,
};
/* ─── 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] || ,
type: 'service' as const,
expanded: false,
loading: false,
@@ -546,7 +548,7 @@ const SourcesTab: React.FC = ({ 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] || ,
type: 'connection' as const,
expanded: false,
loading: false,
@@ -1226,7 +1228,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
{node.loading ? _Spinner() : chevron}
- {node.icon}
+ {node.icon}
= {
'page.admin.database-health': ,
'page.admin.demoConfig': ,
'page.admin.demo-config': ,
+ 'page.admin.sttBenchmark': ,
+ 'page.admin.stt-benchmark': ,
'page.admin.mandate-wizard': ,
'page.admin.mandateWizard': ,
'page.admin.invitation-wizard': ,
diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx
index cc0b8cb..d86de0b 100644
--- a/src/pages/FeatureView.tsx
+++ b/src/pages/FeatureView.tsx
@@ -227,7 +227,7 @@ export const FeatureViewPage: React.FC = ({ 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;
}
diff --git a/src/pages/RagInventoryPage.module.css b/src/pages/RagInventoryPage.module.css
index b74811d..a04d275 100644
--- a/src/pages/RagInventoryPage.module.css
+++ b/src/pages/RagInventoryPage.module.css
@@ -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;
diff --git a/src/pages/RagInventoryPage.tsx b/src/pages/RagInventoryPage.tsx
index 81ca776..92dad83 100644
--- a/src/pages/RagInventoryPage.tsx
+++ b/src/pages/RagInventoryPage.tsx
@@ -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 = () => {
)}
- {conn.lastError && conn.runningJobs.length === 0 && (
-
-
- {t('Letzter Job fehlgeschlagen')}: {conn.lastError.errorMessage || t('unbekannter Fehler')}
- _handleReindex(conn.id)} title={t('Neu indexieren')}>
- {t('Neu indexieren')}
-
-
- )}
-
- {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 ? (
- {conn.runningJobs[0].progressMessage || `${Math.round(conn.runningJobs[0].progress)}%`}
+ {conn.runningJobs[0].progressMessage || t('Synchronisierung läuft...')}
_handleStop(conn.id)} title={t('Indexierung stoppen')}>
{t('Stop')}
- )}
+ ) : (() => {
+ 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 && (
-
- _handleReindex(conn.id)} title={t('Indexierung starten')}>
- {t('Indexierung starten')}
-
-
- )}
+ if (errorIsNewer) {
+ return (
+
+
+
+ {t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {conn.lastError?.errorMessage || t('unbekannter Fehler')}
+
+ _handleReindex(conn.id)} title={t('Neu indexieren')}>
+ {t('Neu indexieren')}
+
+
+ );
+ }
+
+ 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 (
+
+
+
+ {t('Sync erfolgreich')} {_formatRelative(okAt)}
+ {stats && <> — {stats}>}
+ {s.durationMs > 0 && ({_formatDuration(s.durationMs)}) }
+
+ _handleReindex(conn.id)} title={t('Erneut indexieren')}>
+ {t('Erneut indexieren')}
+
+
+ );
+ }
+
+ if (conn.dataSources.some(ds => ds.ragIndexEnabled) && conn.knowledgeIngestionEnabled) {
+ return (
+
+ _handleReindex(conn.id)} title={t('Indexierung starten')}>
+ {t('Indexierung starten')}
+
+
+ );
+ }
+ return null;
+ })()}
{conn.dataSources.map(ds => (
diff --git a/src/pages/admin/SttBenchmarkPage.tsx b/src/pages/admin/SttBenchmarkPage.tsx
new file mode 100644
index 0000000..92b297e
--- /dev/null
+++ b/src/pages/admin/SttBenchmarkPage.tsx
@@ -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
(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(null);
+
+ const [recording, setRecording] = useState(false);
+ const [audioBlob, setAudioBlob] = useState(null);
+ const [audioUrl, setAudioUrl] = useState(null);
+ const mediaRecorderRef = useRef(null);
+ const chunksRef = useRef([]);
+ const fileInputRef = useRef(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) => {
+ 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 (
+
+ );
+ }
+ const res = r as BenchmarkResult;
+ const topTranscript = res.results?.[0]?.transcript || '(no result)';
+ const topConfidence = res.results?.[0]?.confidence ?? 0;
+ return (
+
+
{label}
+
+
{t('Modell')}: {res.model}
+
{t('Latenz')}: {res.latencyMs} ms
+
{t('Konfidenz')}: {(topConfidence * 100).toFixed(1)}%
+
{t('Alternativen')}: {res.results?.length || 0}
+ {res.location &&
{t('Region')}: {res.location}
}
+
+
+ {topTranscript}
+
+ {res.results?.length > 1 && (
+
+ {t('Weitere Alternativen')}
+ {res.results.slice(1).map((alt, i) => (
+
+ [{(alt.confidence * 100).toFixed(1)}%] {alt.transcript}
+
+ ))}
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
{t('STT Benchmark')}
+
+ {t('Vergleiche Speech-to-Text v1 (latest_long) mit v2 (Chirp 2). Lade eine Audio-Datei hoch oder nimm direkt auf.')}
+
+
+
+
+
+ {t('Sprache')}:
+ setLanguage(e.target.value)} style={{ marginLeft: 8 }}>
+ {(models?.languages || [{ value: 'de-DE', label: 'Deutsch' }]).map(l => (
+ {l.label}
+ ))}
+
+
+
+ v1 {t('Modell')}:
+ setV1Model(e.target.value)} style={{ marginLeft: 8 }}>
+ {(models?.v1Models || [{ value: 'latest_long', label: 'latest_long' }]).map(m => (
+ {m.label}
+ ))}
+
+
+
+ v2 {t('Modell')}:
+ setV2Model(e.target.value)} style={{ marginLeft: 8 }}>
+ {(models?.v2Models || [{ value: 'chirp_2', label: 'Chirp 2' }]).map(m => (
+ {m.label}
+ ))}
+
+
+
+ {t('Region')} (v2):
+ setV2Location(e.target.value)} style={{ marginLeft: 8 }}>
+ {(models?.locations || [{ value: 'europe-west4', label: 'Europe West' }]).map(l => (
+ {l.label}
+ ))}
+
+
+
+
+
+ {!recording ? (
+
+ {t('Aufnehmen')}
+
+ ) : (
+
+ {t('Stoppen')}
+
+ )}
+
+
fileInputRef.current?.click()} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#3498db', color: '#fff', border: 'none', borderRadius: 6 }}>
+ {t('Datei hochladen')}
+
+
+
+ {audioBlob && (
+ <>
+
+ {audioBlob instanceof File ? audioBlob.name : 'recording.webm'} ({(audioBlob.size / 1024).toFixed(0)} KB)
+
+ {audioUrl &&
}
+ >
+ )}
+
+
+
+ {running ? : }
+ {running ? t('Benchmark laeuft...') : t('Benchmark starten')}
+
+
+ {result && (
+
+
{t('Ergebnis')}
+
+ {result.filename} ({(result.fileSizeBytes / 1024).toFixed(0)} KB) — {result.language}
+
+
+ {_renderResult(`v1 — ${(result.v1 as any).model || v1Model}`, result.v1)}
+ {_renderResult(`v2 — ${(result.v2 as any).model || v2Model}`, result.v2)}
+
+
+ )}
+
+ );
+};
+
+export default SttBenchmarkPage;
diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts
index 74bc916..2f5a6db 100644
--- a/src/pages/admin/index.ts
+++ b/src/pages/admin/index.ts
@@ -19,3 +19,4 @@ export { AdminLogsPage } from './AdminLogsPage';
export { AdminLanguagesPage } from './AdminLanguagesPage';
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';
+export { SttBenchmarkPage } from './SttBenchmarkPage';
diff --git a/src/types/mandate.ts b/src/types/mandate.ts
index fe3f03c..f777c13 100644
--- a/src/types/mandate.ts
+++ b/src/types/mandate.ts
@@ -295,7 +295,6 @@ export const FEATURE_REGISTRY: Record = {
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' },
]
},