rag enhancements
This commit is contained in:
parent
abdb499067
commit
12e10350d9
12 changed files with 466 additions and 59 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
|
|
|
|||
258
src/pages/admin/SttBenchmarkPage.tsx
Normal file
258
src/pages/admin/SttBenchmarkPage.tsx
Normal 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;
|
||||
|
|
@ -19,3 +19,4 @@ export { AdminLogsPage } from './AdminLogsPage';
|
|||
export { AdminLanguagesPage } from './AdminLanguagesPage';
|
||||
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
|
||||
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';
|
||||
export { SttBenchmarkPage } from './SttBenchmarkPage';
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue