diff --git a/src/App.tsx b/src/App.tsx index ecba5f5..4c1c1ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 */} } /> - } /> {/* Automation2 Workflows & Tasks */} } /> @@ -226,6 +225,7 @@ function App() { } /> } /> + } /> } /> } /> diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index 5190f4b..72b5097 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -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 { diff --git a/src/components/RagRunningBadge/RagRunningBadge.module.css b/src/components/RagRunningBadge/RagRunningBadge.module.css index 473a0b2..6361ff9 100644 --- a/src/components/RagRunningBadge/RagRunningBadge.module.css +++ b/src/components/RagRunningBadge/RagRunningBadge.module.css @@ -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); } diff --git a/src/components/RagRunningBadge/RagRunningBadge.tsx b/src/components/RagRunningBadge/RagRunningBadge.tsx index 03dc329..5d450d6 100644 --- a/src/components/RagRunningBadge/RagRunningBadge.tsx +++ b/src/components/RagRunningBadge/RagRunningBadge.tsx @@ -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 | null>(null); + const previousJobCount = useRef(0); + const toastTimerRef = useRef | 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')} - -
- )} - - {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...')}
- )} + ) : (() => { + 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 && ( -
- -
- )} + if (errorIsNewer) { + return ( +
+ + + {t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {conn.lastError?.errorMessage || t('unbekannter Fehler')} + + +
+ ); + } + + 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)})} + + +
+ ); + } + + if (conn.dataSources.some(ds => ds.ragIndexEnabled) && conn.knowledgeIngestionEnabled) { + return ( +
+ +
+ ); + } + 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 ( +
+

{label}

+

{r.error}

+
+ ); + } + 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.')} +

+
+ +
+ + + + +
+ +
+ {!recording ? ( + + ) : ( + + )} + + + + + {audioBlob && ( + <> + + {audioBlob instanceof File ? audioBlob.name : 'recording.webm'} ({(audioBlob.size / 1024).toFixed(0)} KB) + + {audioUrl &&
+ + + + {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' }, ] },