This commit is contained in:
ValueOn AG 2026-05-12 15:19:07 +02:00
parent 791d575b7d
commit a6b37ed684
29 changed files with 1319 additions and 1174 deletions

View file

@ -44,6 +44,7 @@ import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage'; import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
import { RagInventoryPage } from './pages/RagInventoryPage';
import { ComplianceAuditPage } from './pages/ComplianceAuditPage'; import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
function App() { function App() {
// Load saved theme preference and set app name on app mount // Load saved theme preference and set app name on app mount
@ -127,6 +128,11 @@ function App() {
{/* ============================================== */} {/* ============================================== */}
<Route path="automations" element={<AutomationsDashboardPage />} /> <Route path="automations" element={<AutomationsDashboardPage />} />
{/* ============================================== */}
{/* RAG INVENTORY */}
{/* ============================================== */}
<Route path="rag-inventory" element={<RagInventoryPage />} />
{/* Legacy top-level routes redirect to dashboard (migrated to feature-instance routes) */} {/* Legacy top-level routes redirect to dashboard (migrated to feature-instance routes) */}
<Route path="chatbot" element={<Navigate to="/" replace />} /> <Route path="chatbot" element={<Navigate to="/" replace />} />
<Route path="pek" element={<Navigate to="/" replace />} /> <Route path="pek" element={<Navigate to="/" replace />} />

View file

@ -6,17 +6,11 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface KnowledgePreferences { export interface KnowledgePreferences {
schemaVersion?: number; schemaVersion?: number;
neutralizeBeforeEmbed?: boolean;
mailContentDepth?: 'metadata' | 'snippet' | 'full'; mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean; mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean; filesIndexBinaries?: boolean;
mimeAllowlist?: string[];
clickupScope?: 'titles' | 'title_description' | 'with_comments'; clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean; clickupIndexAttachments?: boolean;
surfaceToggles?: {
google?: { gmail?: boolean; drive?: boolean };
msft?: { sharepoint?: boolean; outlook?: boolean };
};
maxAgeDays?: number; maxAgeDays?: number;
} }
@ -292,3 +286,110 @@ export async function submitInfomaniakToken(
}); });
} }
// ============================================================================
// RAG KNOWLEDGE CONSENT & CONTROL
// ============================================================================
export async function patchKnowledgeConsent(
request: ApiRequestFunction,
connectionId: string,
enabled: boolean
): Promise<{ connectionId: string; knowledgeIngestionEnabled: boolean; purged?: any; cancelledJobs?: number; bootstrapEnqueued?: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-consent`,
method: 'patch',
data: { enabled }
});
}
export async function patchKnowledgePreferences(
request: ApiRequestFunction,
connectionId: string,
preferences: KnowledgePreferences
): Promise<{ connectionId: string; knowledgePreferences: KnowledgePreferences; updated: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-preferences`,
method: 'patch',
data: { preferences }
});
}
export async function postKnowledgeStop(
request: ApiRequestFunction,
connectionId: string
): Promise<{ connectionId: string; cancelled: number }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-stop`,
method: 'post'
});
}
export async function patchDataSourceRagIndex(
request: ApiRequestFunction,
dataSourceId: string,
ragIndexEnabled: boolean
): Promise<{ sourceId: string; ragIndexEnabled: boolean; updated: boolean }> {
return await request({
url: `/api/datasources/${dataSourceId}/rag-index`,
method: 'patch',
data: { ragIndexEnabled }
});
}
// ============================================================================
// RAG INVENTORY
// ============================================================================
export interface RagDataSourceDto {
id: string;
label: string;
path: string;
sourceType: string;
ragIndexEnabled: boolean;
neutralize: boolean;
lastIndexed: number | null;
chunkCount: number;
}
export interface RagConnectionDto {
id: string;
authority: string;
externalEmail: string;
knowledgeIngestionEnabled: boolean;
preferences: KnowledgePreferences;
dataSources: RagDataSourceDto[];
totalChunks: number;
runningJobs: { jobId: string; progress: number; progressMessage: string }[];
lastError?: { jobId: string; errorMessage: string } | null;
}
export interface RagInventoryDto {
connections: RagConnectionDto[];
totals: { chunks: number; bytes?: number };
}
export interface RagActiveJobDto {
jobId: string;
connectionId: string;
connectionLabel?: string;
jobType: string;
progress: number | null;
progressMessage: string;
}
export async function getRagInventoryMe(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/me', method: 'get' });
}
export async function getRagInventoryMandate(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/mandate', method: 'get' });
}
export async function getRagInventoryPlatform(request: ApiRequestFunction): Promise<any> {
return await request({ url: '/api/rag/inventory/platform', method: 'get' });
}
export async function getRagActiveJobs(request: ApiRequestFunction): Promise<RagActiveJobDto[]> {
return await request({ url: '/api/rag/inventory/jobs', method: 'get' });
}

View file

@ -73,13 +73,12 @@
/* Connector grid (Step 0) */ /* Connector grid (Step 0) */
.connectorGrid { .connectorGrid {
display: flex; display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
.connectorCard { .connectorCard {
flex: 1 1 140px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -447,6 +446,22 @@
cursor: not-allowed; cursor: not-allowed;
} }
.patInput {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.9rem;
font-family: monospace;
margin: 12px 0 16px;
}
.patInput:focus {
outline: none;
border-color: var(--primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
/* Dark theme */ /* Dark theme */
:global(.dark-theme) .connectorCard { :global(.dark-theme) .connectorCard {
background: var(--surface-color); background: var(--surface-color);

View file

@ -1,153 +1,52 @@
/** /**
* AddConnectionWizard * AddConnectionWizard
* *
* Multi-step modal for adding a new connector with optional knowledge * Streamlined multi-step modal for adding a new connector.
* ingestion consent and per-connection preferences (§2.6). * Steps are connector-type-aware:
* * Base: Connector Consent Connect
* Steps: * Microsoft: Connector Consent Admin Consent (optional) Connect
* 0 Connector wählen * Infomaniak: Connector Consent PAT Input (done)
* 1 Consent (Wissensdatenbank Ja/Nein)
* 2 Präferenzen (nur wenn Ja)
* 3 Zusammenfassung + OAuth starten
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal'; import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa'; import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
import type { KnowledgePreferences } from '../../api/connectionApi';
import styles from './AddConnectionWizard.module.css'; import styles from './AddConnectionWizard.module.css';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type ConnectorType = 'google' | 'msft' | 'clickup'; export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
interface WizardState { interface WizardState {
step: 0 | 1 | 2 | 3; currentStep: StepId;
connector: ConnectorType | null; connector: ConnectorType | null;
knowledgeEnabled: boolean; knowledgeEnabled: boolean;
prefs: KnowledgePreferences; infomaniakToken: string;
adminConsentDone: boolean;
} }
const DEFAULT_PREFS: KnowledgePreferences = {
schemaVersion: 1,
neutralizeBeforeEmbed: false,
mailContentDepth: 'full',
mailIndexAttachments: false,
filesIndexBinaries: true,
clickupScope: 'title_description',
clickupIndexAttachments: false,
maxAgeDays: 90,
};
const CONNECTOR_LABELS: Record<ConnectorType, string> = { const CONNECTOR_LABELS: Record<ConnectorType, string> = {
google: 'Google', google: 'Google',
msft: 'Microsoft 365', msft: 'Microsoft 365',
clickup: 'ClickUp', clickup: 'ClickUp',
infomaniak: 'Infomaniak',
}; };
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = { const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />, google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />, msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />, clickup: <FaTasks style={{ color: '#7b68ee' }} />,
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
}; };
// --------------------------------------------------------------------------- function _getSteps(connector: ConnectorType | null): StepId[] {
// Cost estimate helper if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
// --------------------------------------------------------------------------- if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
return ['connector', 'consent', 'connect'];
/**
* Returns a cost estimate broken into two lines:
*
* 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) always tiny.
* 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call)
* this is the DOMINANT cost when enabled. One call per email/task for
* short content; several calls for long threads or files.
*
* Numbers are conservative ranges. Subsequent syncs are cheaper because
* unchanged content is deduplicated before any LLM/embedding call.
*/
function computeCostEstimate(
connector: ConnectorType | null,
prefs: KnowledgePreferences,
): {
embeddingLow: string;
embeddingHigh: string;
neutralizationLow: string | null;
neutralizationHigh: string | null;
note: string;
} | null {
if (!connector) return null;
// ---- Embedding (OpenAI, USD) ----
const EMBED_USD_PER_M = 0.02;
const tokensPerMail: Record<string, number> = { metadata: 30, snippet: 120, full: 500 };
const depth = prefs.mailContentDepth ?? 'full';
const maxAge = prefs.maxAgeDays ?? 90;
const mailCount = Math.min(500, Math.round((maxAge / 90) * 500));
const taskCount = Math.min(500, Math.round((maxAge / 90) * 300));
let embedLowTokens = 0;
let embedHighTokens = 0;
if (connector === 'google' || connector === 'msft') {
const mailTokens = mailCount * tokensPerMail[depth];
embedLowTokens += mailTokens * 0.6;
embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint
if (prefs.mailIndexAttachments) embedHighTokens += 200_000;
} else if (connector === 'clickup') {
const scope = prefs.clickupScope ?? 'title_description';
const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400;
embedLowTokens += taskCount * tpt * 0.6;
embedHighTokens += taskCount * tpt * 1.5;
}
const fmtUsd = (tokens: number) => {
const usd = (tokens / 1_000_000) * EMBED_USD_PER_M;
if (usd < 0.001) return '< 0.01 $';
if (usd < 0.10) return `~${usd.toFixed(3)} $`;
return `~${usd.toFixed(2)} $`;
};
// ---- Neutralization (Private LLM, CHF 0.01/call) ----
// Each item (email / task / file) = 1 LLM call for short content,
// 2-4 for long threads/documents.
const NEUT_CHF_PER_CALL = 0.01;
let neutLow: string | null = null;
let neutHigh: string | null = null;
if (prefs.neutralizeBeforeEmbed) {
let lowCalls = 0;
let highCalls = 0;
if (connector === 'google' || connector === 'msft') {
lowCalls += mailCount * 1; // 1 call / short email
highCalls += mailCount * 3; // up to 3 calls / long thread
lowCalls += 20; // Drive/SharePoint files (low)
highCalls += 200; // Drive/SharePoint files (high, large PDFs)
} else if (connector === 'clickup') {
lowCalls += taskCount * 1;
highCalls += taskCount * 2;
}
const fmtChf = (calls: number) => {
const chf = calls * NEUT_CHF_PER_CALL;
if (chf < 0.01) return '< 0.01 CHF';
return `~${chf.toFixed(2)} CHF`;
};
neutLow = fmtChf(lowCalls);
neutHigh = fmtChf(highCalls);
}
return {
embeddingLow: fmtUsd(embedLowTokens),
embeddingHigh: fmtUsd(embedHighTokens),
neutralizationLow: neutLow,
neutralizationHigh: neutHigh,
note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).',
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -157,11 +56,9 @@ function computeCostEstimate(
interface AddConnectionWizardProps { interface AddConnectionWizardProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onConnect: ( onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
type: ConnectorType, onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
knowledgeEnabled: boolean, onMsftAdminConsent?: () => void;
prefs: KnowledgePreferences | null,
) => Promise<void>;
isConnecting?: boolean; isConnecting?: boolean;
} }
@ -173,84 +70,91 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open, open,
onClose, onClose,
onConnect, onConnect,
onInfomaniakConnect,
onMsftAdminConsent,
isConnecting = false, isConnecting = false,
}) => { }) => {
const [state, setState] = useState<WizardState>({ const [state, setState] = useState<WizardState>({
step: 0, currentStep: 'connector',
connector: null, connector: null,
knowledgeEnabled: false, knowledgeEnabled: false,
prefs: { ...DEFAULT_PREFS }, infomaniakToken: '',
adminConsentDone: false,
}); });
const reset = () => const reset = () =>
setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } }); setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
const handleClose = () => { const handleClose = () => { reset(); onClose(); };
reset();
onClose(); const steps = _getSteps(state.connector);
const stepIndex = steps.indexOf(state.currentStep);
const goNext = () => {
const nextIdx = stepIndex + 1;
if (nextIdx < steps.length) {
setState(s => ({ ...s, currentStep: steps[nextIdx] }));
}
}; };
const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step })); const goBack = () => {
const setConnector = (connector: ConnectorType) => const prevIdx = stepIndex - 1;
setState(s => ({ ...s, connector, step: 1 })); if (prevIdx >= 0) {
const setKnowledgeEnabled = (v: boolean) => setState(s => ({ ...s, currentStep: steps[prevIdx] }));
setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 })); }
const updatePref = <K extends keyof KnowledgePreferences>(key: K, value: KnowledgePreferences[K]) => };
setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } }));
const handleConnect = async () => { const selectConnector = (c: ConnectorType) => {
setState(s => ({ ...s, connector: c, currentStep: 'consent' }));
};
const setConsent = (enabled: boolean) => {
setState(s => ({ ...s, knowledgeEnabled: enabled }));
goNext();
};
const handleFinalConnect = async () => {
if (!state.connector) return; if (!state.connector) return;
await onConnect( if (state.connector === 'infomaniak' && onInfomaniakConnect) {
state.connector, await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
state.knowledgeEnabled, } else {
state.knowledgeEnabled ? state.prefs : null, await onConnect(state.connector, state.knowledgeEnabled);
); }
reset(); reset();
onClose(); onClose();
}; };
const visibleSteps = state.knowledgeEnabled
? [0, 1, 2, 3]
: [0, 1, 3];
return ( return (
<Modal <Modal open={open} onClose={handleClose} title="Verbindung hinzufügen" size="md" closeOnEscape>
open={open}
onClose={handleClose}
title="Verbindung hinzufügen"
size="md"
closeOnEscape
>
{/* Stepper */} {/* Stepper */}
<div className={styles.stepper}> <div className={styles.stepper}>
{[0, 1, 2, 3].map(i => ( {steps.map((s, i) => (
<div <div
key={i} key={s}
className={[ className={[
styles.stepDot, styles.stepDot,
state.step === i ? styles.stepDotActive : '', stepIndex === i ? styles.stepDotActive : '',
state.step > i ? styles.stepDotDone : '', stepIndex > i ? styles.stepDotDone : '',
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
].join(' ')} ].join(' ')}
> >
{state.step > i ? <FaCheck size={10} /> : i + 1} {stepIndex > i ? <FaCheck size={10} /> : i + 1}
</div> </div>
))} ))}
</div> </div>
<div className={styles.body}> <div className={styles.body}>
{/* ---- Step 0: Connector ---- */} {/* ---- Step: Connector ---- */}
{state.step === 0 && ( {state.currentStep === 'connector' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Anbieter wählen</h3> <h3 className={styles.stepTitle}>Anbieter wählen</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p> <p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
<div className={styles.connectorGrid}> <div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => ( {(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
<button <button
key={type} key={type}
type="button" type="button"
className={styles.connectorCard} className={styles.connectorCard}
onClick={() => setConnector(type)} onClick={() => selectConnector(type)}
> >
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span> <span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span> <span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
@ -260,151 +164,103 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
</div> </div>
)} )}
{/* ---- Step 1: Consent ---- */} {/* ---- Step: Consent ---- */}
{state.step === 1 && ( {state.currentStep === 'consent' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3> <h3 className={styles.stepTitle}>Wissensdatenbank</h3>
<p className={styles.stepBody}> <p className={styles.stepBody}>
Möchtest du Inhalte aus dieser Verbindung in deine persönliche Möchtest du Inhalte aus dieser Verbindung in deine persönliche
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus{' '} aus {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'} zurückgreifen kann?
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
zurückgreifen kann?
</p> </p>
<p className={styles.stepHint}> <p className={styles.stepHint}>
Du kannst diese Einstellung später in den Verbindungsdetails ändern. Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.
</p>
<div className={styles.consentButtons}>
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
<FaCheck /> Ja, aktivieren
</button>
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
Nein, überspringen
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
</div>
</div>
)}
{/* ---- Step: MSFT Admin Consent ---- */}
{state.currentStep === 'msftAdminConsent' && (
<div className={styles.stepContent}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
</div>
<h3 className={styles.stepTitle}>Organisations-Zustimmung (optional)</h3>
<p className={styles.stepBody}>
Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen.
So müssen andere Benutzer nicht einzeln bestätigen.
</p>
<p className={styles.stepHint}>
Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.
</p> </p>
<div className={styles.consentButtons}> <div className={styles.consentButtons}>
<button <button
type="button" type="button"
className={styles.consentButtonYes} className={styles.consentButtonYes}
onClick={() => setKnowledgeEnabled(true)} onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
> >
<FaCheck /> Ja, aufnehmen <FaShieldAlt /> Admin-Zustimmung erteilen
</button> </button>
<button <button type="button" className={styles.consentButtonNo} onClick={goNext}>
type="button" Überspringen
className={styles.consentButtonNo}
onClick={() => setKnowledgeEnabled(false)}
>
Nein, überspringen
</button> </button>
</div> </div>
<div className={styles.stepNavLeft}> <div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={() => setStep(0)}> <button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
Zurück
</button>
</div> </div>
</div> </div>
)} )}
{/* ---- Step 2: Preferences ---- */} {/* ---- Step: Infomaniak PAT ---- */}
{state.step === 2 && ( {state.currentStep === 'infomaniakPat' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Einstellungen</h3> <h3 className={styles.stepTitle}>Infomaniak Personal Access Token</h3>
<p className={styles.stepHint}> <p className={styles.stepBody}>
Steuere, welche Inhalte und in welcher Form sie indexiert werden. Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.
</p> </p>
<input
<div className={styles.prefGroup}> type="password"
<label className={styles.prefLabel}> placeholder="pat_..."
<FaShieldAlt className={styles.prefIcon} /> value={state.infomaniakToken}
Anonymisierung vor dem Indexieren onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
<input className={styles.patInput}
type="checkbox" autoFocus
checked={!!state.prefs.neutralizeBeforeEmbed} />
onChange={e => updatePref('neutralizeBeforeEmbed', e.target.checked)}
className={styles.prefCheck}
/>
</label>
<p className={styles.prefHint}>
Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt.
</p>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<>
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
E-Mail-Inhalt
<select
value={state.prefs.mailContentDepth ?? 'full'}
onChange={e => updatePref('mailContentDepth', e.target.value as any)}
className={styles.prefSelect}
>
<option value="metadata">Nur Metadaten (Betreff, Absender, Datum)</option>
<option value="snippet">Vorschautext (ca. 250 Zeichen)</option>
<option value="full">Vollständiger Text</option>
</select>
</label>
</div>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
E-Mail-Anhänge indexieren
<input
type="checkbox"
checked={!!state.prefs.mailIndexAttachments}
onChange={e => updatePref('mailIndexAttachments', e.target.checked)}
className={styles.prefCheck}
/>
</label>
</div>
</>
)}
{state.connector === 'clickup' && (
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Aufgaben-Inhalt
<select
value={state.prefs.clickupScope ?? 'title_description'}
onChange={e => updatePref('clickupScope', e.target.value as any)}
className={styles.prefSelect}
>
<option value="titles">Nur Aufgabentitel</option>
<option value="title_description">Titel + Beschreibung</option>
<option value="with_comments">Titel + Beschreibung + Kommentare</option>
</select>
</label>
</div>
)}
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Zeitfenster (Tage)
<input
type="number"
min={0}
max={3650}
value={state.prefs.maxAgeDays ?? 90}
onChange={e => updatePref('maxAgeDays', parseInt(e.target.value, 10) || 0)}
className={styles.prefNumber}
/>
</label>
<p className={styles.prefHint}>0 = kein Limit</p>
</div>
<div className={styles.stepNav}> <div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={() => setStep(1)}> <button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
Zurück <button
</button> type="button"
<button type="button" className={styles.navNext} onClick={() => setStep(3)}> className={styles.navConnect}
Weiter <FaArrowRight size={12} /> onClick={handleFinalConnect}
disabled={isConnecting || !state.infomaniakToken.trim()}
>
{isConnecting ? 'Verbinden…' : 'Verbinden'}
{!isConnecting && <FaArrowRight size={12} />}
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* ---- Step 3: Summary ---- */} {/* ---- Step: Connect ---- */}
{state.step === 3 && ( {state.currentStep === 'connect' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Zusammenfassung</h3> <h3 className={styles.stepTitle}>Verbindung herstellen</h3>
<div className={styles.summary}> <div className={styles.summary}>
<div className={styles.summaryRow}> <div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anbieter</span> <span className={styles.summaryKey}>Anbieter</span>
<span className={styles.summaryVal}> <span className={styles.summaryVal}>
{CONNECTOR_ICONS[state.connector!]}&nbsp; {state.connector && CONNECTOR_ICONS[state.connector]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'} {state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span> </span>
</div> </div>
@ -414,96 +270,13 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'} {state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
</span> </span>
</div> </div>
{state.knowledgeEnabled && (
<>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anonymisierung</span>
<span className={styles.summaryVal}>
{state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'}
</span>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>E-Mail-Tiefe</span>
<span className={styles.summaryVal}>
{{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[
state.prefs.mailContentDepth ?? 'full'
] ?? state.prefs.mailContentDepth}
</span>
</div>
)}
{state.connector === 'clickup' && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Aufgaben-Inhalt</span>
<span className={styles.summaryVal}>
{{
titles: 'Nur Titel',
title_description: 'Titel + Beschreibung',
with_comments: 'Titel + Beschreibung + Kommentare',
}[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope}
</span>
</div>
)}
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Zeitfenster</span>
<span className={styles.summaryVal}>
{state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'}
</span>
</div>
</>
)}
</div> </div>
{/* Cost estimate — only shown when knowledge ingestion is enabled */}
{state.knowledgeEnabled && (() => {
const est = computeCostEstimate(state.connector, state.prefs);
if (!est) return null;
return (
<div className={styles.costHint}>
<FaInfoCircle className={styles.costHintIcon} />
<div>
<span className={styles.costHintTitle}>Geschätzte Kosten (erster Sync)</span>
<table className={styles.costTable}>
<tbody>
<tr>
<td className={styles.costLabel}>Embedding</td>
<td className={styles.costVal}>
{est.embeddingLow} {est.embeddingHigh}
</td>
</tr>
{est.neutralizationLow && (
<tr className={styles.costRowNeut}>
<td className={styles.costLabel}>Anonymisierung (Private LLM)</td>
<td className={styles.costVal}>
{est.neutralizationLow} {est.neutralizationHigh}
</td>
</tr>
)}
</tbody>
</table>
{est.neutralizationLow && (
<span className={styles.costHintWarn}>
Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise).
</span>
)}
<span className={styles.costHintNote}>{est.note}</span>
</div>
</div>
);
})()}
<div className={styles.stepNav}> <div className={styles.stepNav}>
<button <button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
type="button"
className={styles.navBack}
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
>
Zurück
</button>
<button <button
type="button" type="button"
className={styles.navConnect} className={styles.navConnect}
onClick={handleConnect} onClick={handleFinalConnect}
disabled={isConnecting} disabled={isConnecting}
> >
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`} {isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}

View file

@ -101,9 +101,8 @@
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
min-width: 0; min-width: 0;
/* Share remaining viewport among expanded groups; scroll when many groups */ flex: 1 1 400px;
flex: 1 1 280px; min-height: 350px;
min-height: 0;
} }
.groupSectionCollapsed { .groupSectionCollapsed {

View file

@ -681,7 +681,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
resizable = true, resizable = true,
pagination = true, pagination = true,
pageSize = 10, pageSize = 10,
pageSizeOptions = [10, 25, 50, 100, 500], pageSizeOptions = [10, 25, 50, 100, 500, 1000, 2000, 10000],
showPageSizeSelector = true, showPageSizeSelector = true,
onRowClick, onRowClick,
onRowSelect, onRowSelect,
@ -740,13 +740,16 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const [activeViewKey, setActiveViewKey] = useState<string | null>(null); const [activeViewKey, setActiveViewKey] = useState<string | null>(null);
const [activeViewId, setActiveViewId] = useState<string | null>(null); const [activeViewId, setActiveViewId] = useState<string | null>(null);
const [groupByLevels, setGroupByLevels] = useState<GroupByLevelSpec[]>([]); const [groupByLevels, setGroupByLevels] = useState<GroupByLevelSpec[]>([]);
const useSectionsGroupLayout = const [groupLayoutMode, setGroupLayoutMode] = useState<'inline' | 'sections'>(tableGroupLayoutMode ?? 'inline');
tableGroupLayoutMode === 'sections' &&
const canUseSections =
!!tableContextKey && !!tableContextKey &&
groupByLevels.length === 1 && groupByLevels.length > 0 &&
typeof hookDataProp?.fetchGroupSectionSummaries === 'function' && typeof hookDataProp?.fetchGroupSectionSummaries === 'function' &&
typeof hookDataProp?.refetchForSection === 'function'; typeof hookDataProp?.refetchForSection === 'function';
const useSectionsGroupLayout = canUseSections && groupLayoutMode === 'sections';
const [sectionSummaries, setSectionSummaries] = useState< const [sectionSummaries, setSectionSummaries] = useState<
Array<{ value: string | null; label: string; totalCount: number }> Array<{ value: string | null; label: string; totalCount: number }>
>([]); >([]);
@ -1360,6 +1363,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
viewKey: activeViewKey, viewKey: activeViewKey,
groupField: spec.field, groupField: spec.field,
groupDirection: spec.direction || 'asc', groupDirection: spec.direction || 'asc',
groupByLevels: groupLevelsToApiPayload(groupByLevels),
}); });
if (!cancelled) setSectionSummaries(Array.isArray(list) ? list : []); if (!cancelled) setSectionSummaries(Array.isArray(list) ? list : []);
} catch (e) { } catch (e) {
@ -2750,6 +2754,27 @@ export function FormGeneratorTable<T extends Record<string, any>>({
onUpdateViewGrouping={(id, levels) => void handleUpdateViewGrouping(id, levels)} onUpdateViewGrouping={(id, levels) => void handleUpdateViewGrouping(id, levels)}
onDeleteView={(id) => void handleDeleteView(id)} onDeleteView={(id) => void handleDeleteView(id)}
onReloadViews={() => void reloadViews()} onReloadViews={() => void reloadViews()}
canUseSections={canUseSections}
groupLayoutMode={groupLayoutMode}
onGroupLayoutModeChange={(mode) => {
setGroupLayoutMode(mode);
setCollapsedGroups(new Set());
setCollapsedSectionKeys(new Set());
setCurrentPage(1);
}}
hasGroupBands={!!effectiveGroupLayout && effectiveGroupLayout.bands.length > 0}
onCollapseAll={() => {
if (effectiveGroupLayout) {
setCollapsedGroups(new Set(effectiveGroupLayout.bands.map((b) => b.path.join('///'))));
}
if (useSectionsGroupLayout) {
setCollapsedSectionKeys(new Set(sectionSummaries.map((g) => g.value === null || g.value === undefined ? '__empty__' : String(g.value))));
}
}}
onExpandAll={() => {
setCollapsedGroups(new Set());
setCollapsedSectionKeys(new Set());
}}
/> />
)} )}
@ -3341,13 +3366,23 @@ export function FormGeneratorTable<T extends Record<string, any>>({
)} )}
<div className={styles.groupSections}> <div className={styles.groupSections}>
{sectionSummaries.map((g) => { {sectionSummaries.map((g) => {
const field = groupByLevels[0].field; const isMultiLevel = groupByLevels.length > 1 && (g as any).filters;
const sectionFilter: Record<string, unknown> = { const sectionFilter: Record<string, unknown> = isMultiLevel
[field]: g.value === null || g.value === undefined ? null : g.value, ? (g as any).filters
}; : { [groupByLevels[0].field]: g.value === null || g.value === undefined ? null : g.value };
const groupFields = isMultiLevel
? groupByLevels.map((l) => l.field)
: [groupByLevels[0].field];
const sk = const sk =
g.value === null || g.value === undefined ? '__empty__' : String(g.value); g.value === null || g.value === undefined ? '__empty__' : String(g.value);
const sectionCollapsed = collapsedSectionKeys.has(sk); const sectionCollapsed = collapsedSectionKeys.has(sk);
const groupFieldSet = new Set(groupFields);
const sectionColumns = providedColumns.map((col: any) =>
groupFieldSet.has(col.key) ? { ...col, filterable: false } : col,
);
const sectionInitialFilters = Object.fromEntries(
Object.entries(filters).filter(([k]) => !groupFieldSet.has(k)),
);
return ( return (
<section <section
key={sk} key={sk}
@ -3382,9 +3417,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
</button> </button>
{!sectionCollapsed && ( {!sectionCollapsed && (
<FormGeneratorTable<T> <FormGeneratorTable<T>
key={`${sk}-r${refreshNonce}-${JSON.stringify(filters)}-${JSON.stringify(sortConfigs)}-${activeViewKey ?? ''}`} key={`${sk}-r${refreshNonce}-${JSON.stringify(sectionInitialFilters)}-${JSON.stringify(sortConfigs)}-${activeViewKey ?? ''}`}
className={styles.groupSectionTableWrap} className={styles.groupSectionTableWrap}
columns={providedColumns} columns={sectionColumns}
data={[]} data={[]}
searchable={false} searchable={false}
filterable={filterable} filterable={filterable}
@ -3415,7 +3450,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
localDataMode localDataMode
viewKeyForQueries={activeViewKey} viewKeyForQueries={activeViewKey}
initialSearchTerm={debouncedSearchTerm} initialSearchTerm={debouncedSearchTerm}
initialFilters={filters} initialFilters={sectionInitialFilters}
initialSort={sortConfigs} initialSort={sortConfigs}
apiEndpoint={apiEndpoint} apiEndpoint={apiEndpoint}
csvExportQueryParams={hookDataProp?.csvExportQueryParams} csvExportQueryParams={hookDataProp?.csvExportQueryParams}
@ -3427,13 +3462,13 @@ export function FormGeneratorTable<T extends Record<string, any>>({
if (!hookDataProp?.refetchForSection) { if (!hookDataProp?.refetchForSection) {
return { items: [], pagination: null }; return { items: [], pagination: null };
} }
return hookDataProp.refetchForSection(p, sectionFilter, filters); return hookDataProp.refetchForSection(p, sectionFilter, sectionInitialFilters);
}, },
...(hookDataProp?.fetchFilterValues && typeof hookDataProp.fetchFilterValues === 'function' ...(hookDataProp?.fetchFilterValues && typeof hookDataProp.fetchFilterValues === 'function'
? { ? {
fetchFilterValues: async (columnKey: string, crossFilters?: Record<string, any>) => { fetchFilterValues: async (columnKey: string, crossFilters?: Record<string, any>) => {
const merged: Record<string, any> = { const merged: Record<string, any> = {
...filters, ...sectionInitialFilters,
...(crossFilters || {}), ...(crossFilters || {}),
...sectionFilter, ...sectionFilter,
}; };

View file

@ -153,6 +153,51 @@
white-space: nowrap; white-space: nowrap;
} }
.layoutToggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--color-text, #0f172a);
cursor: pointer;
font-size: 18px;
transition: background 0.15s, border-color 0.15s;
}
.layoutToggle:hover {
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
border-color: var(--color-primary, #64748b);
}
.collapseExpandGroup {
display: inline-flex;
gap: 2px;
}
.collapseBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 6px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--text-secondary, #94a3b8);
cursor: pointer;
font-size: 16px;
transition: background 0.15s, color 0.15s;
}
.collapseBtn:hover {
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
color: var(--color-text, #0f172a);
}
.viewBlock { .viewBlock {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View file

@ -1,5 +1,7 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { FaLayerGroup, FaTrash } from 'react-icons/fa'; import { FaLayerGroup, FaTrash } from 'react-icons/fa';
import { TbLayoutList, TbLayoutRows } from 'react-icons/tb';
import { FiChevronsDown, FiChevronsUp } from 'react-icons/fi';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './TableViewsBar.module.css'; import styles from './TableViewsBar.module.css';
@ -30,6 +32,12 @@ export interface TableViewsBarProps {
onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>; onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>;
onDeleteView?: (viewId: string) => void | Promise<void>; onDeleteView?: (viewId: string) => void | Promise<void>;
onReloadViews: () => void; onReloadViews: () => void;
canUseSections?: boolean;
groupLayoutMode?: 'inline' | 'sections';
onGroupLayoutModeChange?: (mode: 'inline' | 'sections') => void;
hasGroupBands?: boolean;
onCollapseAll?: () => void;
onExpandAll?: () => void;
} }
function slugify(name: string): string { function slugify(name: string): string {
@ -74,6 +82,12 @@ export function TableViewsBar({
onUpdateViewGrouping, onUpdateViewGrouping,
onDeleteView, onDeleteView,
onReloadViews, onReloadViews,
canUseSections,
groupLayoutMode,
onGroupLayoutModeChange,
hasGroupBands,
onCollapseAll,
onExpandAll,
}: TableViewsBarProps) { }: TableViewsBarProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const [groupMenuOpen, setGroupMenuOpen] = useState(false); const [groupMenuOpen, setGroupMenuOpen] = useState(false);
@ -249,6 +263,41 @@ export function TableViewsBar({
: `${t('Aktiv')}: ${summary}`} : `${t('Aktiv')}: ${summary}`}
</span> </span>
{canUseSections && groupByLevels.length > 0 && onGroupLayoutModeChange && (
<button
type="button"
className={styles.layoutToggle}
title={groupLayoutMode === 'inline' ? t('Zu Sektionen wechseln') : t('Zu Inline wechseln')}
aria-label={groupLayoutMode === 'inline' ? t('Zu Sektionen wechseln') : t('Zu Inline wechseln')}
onClick={() => onGroupLayoutModeChange(groupLayoutMode === 'inline' ? 'sections' : 'inline')}
>
{groupLayoutMode === 'inline' ? <TbLayoutRows /> : <TbLayoutList />}
</button>
)}
{hasGroupBands && onCollapseAll && onExpandAll && (
<div className={styles.collapseExpandGroup}>
<button
type="button"
className={styles.collapseBtn}
title={t('Alle zuklappen')}
aria-label={t('Alle zuklappen')}
onClick={onCollapseAll}
>
<FiChevronsUp />
</button>
<button
type="button"
className={styles.collapseBtn}
title={t('Alle aufklappen')}
aria-label={t('Alle aufklappen')}
onClick={onExpandAll}
>
<FiChevronsDown />
</button>
</div>
)}
<div className={styles.viewBlock}> <div className={styles.viewBlock}>
<span className={styles.viewLabel}>{t('Ansicht')}</span> <span className={styles.viewLabel}>{t('Ansicht')}</span>
<select <select

View file

@ -0,0 +1,95 @@
.badgeContainer {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9000;
}
.badge {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--primary-color, #F25843);
color: #fff;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: transform 0.15s, box-shadow 0.15s;
}
.badge:hover {
transform: scale(1.04);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.pulseIcon {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
.badgeText {
white-space: nowrap;
}
.dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 240px;
max-height: 200px;
overflow-y: auto;
}
.dropdownHeader {
padding: 10px 14px 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary, #666);
border-bottom: 1px solid var(--border-color, #eee);
}
.jobRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
font-size: 12px;
}
.jobRow:not(:last-child) {
border-bottom: 1px solid var(--border-color, #f0f0f0);
}
.jobLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jobProgress {
flex-shrink: 0;
margin-left: 8px;
font-weight: 600;
color: var(--primary-color, #F25843);
}

View file

@ -0,0 +1,71 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useApiRequest } from '../../hooks/useApi';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './RagRunningBadge.module.css';
interface _RagJob {
jobId: string;
connectionId: string;
connectionLabel?: string;
jobType: string;
progress: number | null;
progressMessage: string;
}
const _POLL_INTERVAL_MS = 60_000;
export const RagRunningBadge: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [jobs, setJobs] = useState<_RagJob[]>([]);
const [expanded, setExpanded] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const _fetchJobs = useCallback(async () => {
try {
const result = await request({ url: '/api/rag/inventory/jobs', method: 'get' });
setJobs(Array.isArray(result) ? result : []);
} catch {
setJobs([]);
}
}, [request]);
useEffect(() => {
_fetchJobs();
timerRef.current = setInterval(_fetchJobs, _POLL_INTERVAL_MS);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [_fetchJobs]);
if (jobs.length === 0) return null;
return (
<div className={styles.badgeContainer}>
<button
className={styles.badge}
onClick={() => setExpanded(prev => !prev)}
title={t('RAG-Indexierung aktiv')}
>
<span className={styles.pulseIcon} />
<span className={styles.badgeText}>
{jobs.length} {jobs.length === 1 ? t('Job') : t('Jobs')}
</span>
</button>
{expanded && (
<div className={styles.dropdown}>
<div className={styles.dropdownHeader}>
{t('Aktive RAG-Jobs')}
</div>
{jobs.map(job => (
<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 * 100)}%` : '...'}
</span>
</div>
))}
</div>
)}
</div>
);
};

View file

@ -42,6 +42,7 @@ interface UdbDataSource {
displayPath?: string; displayPath?: string;
scope: string; scope: string;
neutralize: boolean; neutralize: boolean;
ragIndexEnabled?: boolean;
} }
interface UdbFeatureDataSource { interface UdbFeatureDataSource {
@ -689,6 +690,17 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
} }
}, []); }, []);
/* ── RAG-Index toggle (personal data source, optimistic) ── */
const _togglePersonalRagIndex = useCallback(async (ds: UdbDataSource) => {
const newValue = !ds.ragIndexEnabled;
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: newValue } : d));
try {
await api.patch(`/api/datasources/${ds.id}/rag-index`, { ragIndexEnabled: newValue });
} catch {
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: ds.ragIndexEnabled } : d));
}
}, []);
/* ── Scope change (feature data source, optimistic) ── */ /* ── Scope change (feature data source, optimistic) ── */
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => { const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
const newScope = _nextScope(fds.scope); const newScope = _nextScope(fds.scope);
@ -1018,6 +1030,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
dataSources={dataSources} dataSources={dataSources}
onCycleScope={_cyclePersonalScope} onCycleScope={_cyclePersonalScope}
onToggleNeutralize={_togglePersonalNeutralize} onToggleNeutralize={_togglePersonalNeutralize}
onToggleRagIndex={_togglePersonalRagIndex}
onSendToChat={_sendNodeToChat} onSendToChat={_sendNodeToChat}
scopeCycleTitle={_scopeCycleTitle} scopeCycleTitle={_scopeCycleTitle}
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
@ -1105,18 +1118,20 @@ interface _TreeNodeViewProps {
dataSources: UdbDataSource[]; dataSources: UdbDataSource[];
onCycleScope: (ds: UdbDataSource) => void; onCycleScope: (ds: UdbDataSource) => void;
onToggleNeutralize: (ds: UdbDataSource) => void; onToggleNeutralize: (ds: UdbDataSource) => void;
onToggleRagIndex: (ds: UdbDataSource) => void;
onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void; onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
scopeCycleTitle: (scope: string) => string; scopeCycleTitle: (scope: string) => string;
selectedKeys: Set<string>; selectedKeys: Set<string>;
onSelect: (node: TreeNode, e: React.MouseEvent) => void; onSelect: (node: TreeNode, e: React.MouseEvent) => void;
inheritedScope?: string; inheritedScope?: string;
inheritedNeutralize?: boolean; inheritedNeutralize?: boolean;
inheritedRagIndex?: boolean;
} }
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
node, depth, onToggle, onEnsureDs, isAdded, addingPath, node, depth, onToggle, onEnsureDs, isAdded, addingPath,
dataSources, onCycleScope, onToggleNeutralize, onSendToChat, scopeCycleTitle, dataSources, onCycleScope, onToggleNeutralize, onToggleRagIndex, onSendToChat, scopeCycleTitle,
selectedKeys, onSelect, inheritedScope, inheritedNeutralize, selectedKeys, onSelect, inheritedScope, inheritedNeutralize, inheritedRagIndex,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@ -1128,8 +1143,10 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
const effectiveScope = ds?.scope ?? inheritedScope; const effectiveScope = ds?.scope ?? inheritedScope;
const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false; const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false;
const effectiveRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex ?? false;
const childInheritedScope = ds?.scope ?? inheritedScope; const childInheritedScope = ds?.scope ?? inheritedScope;
const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize; const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize;
const childInheritedRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex;
const _dragPayload = { const _dragPayload = {
connectionId: node.connectionId, connectionId: node.connectionId,
@ -1261,6 +1278,24 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
> >
{'\uD83D\uDD12'} {'\uD83D\uDD12'}
</button> </button>
<button
onClick={async (e) => {
e.stopPropagation();
if (ds) { onToggleRagIndex(ds); return; }
const newId = await onEnsureDs(node);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/rag-index`, { ragIndexEnabled: !effectiveRagIndex }); } catch {}
}
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: (ds?.ragIndexEnabled ?? effectiveRagIndex) ? 1 : 0.35,
}}
title={(ds?.ragIndexEnabled ?? effectiveRagIndex) ? t('RAG-Indexierung an') : t('RAG-Indexierung aus')}
>
{'\uD83E\uDDE0'}
</button>
</div> </div>
@ -1278,12 +1313,14 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
dataSources={dataSources} dataSources={dataSources}
onCycleScope={onCycleScope} onCycleScope={onCycleScope}
onToggleNeutralize={onToggleNeutralize} onToggleNeutralize={onToggleNeutralize}
onToggleRagIndex={onToggleRagIndex}
onSendToChat={onSendToChat} onSendToChat={onSendToChat}
scopeCycleTitle={scopeCycleTitle} scopeCycleTitle={scopeCycleTitle}
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
onSelect={onSelect} onSelect={onSelect}
inheritedScope={childInheritedScope} inheritedScope={childInheritedScope}
inheritedNeutralize={childInheritedNeutralize} inheritedNeutralize={childInheritedNeutralize}
inheritedRagIndex={childInheritedRagIndex}
/> />
))} ))}
</div> </div>

View file

@ -53,6 +53,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.system.billingAdmin': <FaMoneyBillAlt />, 'page.system.billingAdmin': <FaMoneyBillAlt />,
'page.system.statistics': <FaChartBar />, 'page.system.statistics': <FaChartBar />,
'page.system.automations': <FaRobot />, 'page.system.automations': <FaRobot />,
'page.system.ragInventory': <FaDatabase />,
// Billing pages (legacy compat) // Billing pages (legacy compat)
'page.billing.dashboard': <FaWallet />, 'page.billing.dashboard': <FaWallet />,

View file

@ -101,17 +101,15 @@ export function useConnections() {
viewKey?: string | null; viewKey?: string | null;
groupField: string; groupField: string;
groupDirection?: 'asc' | 'desc'; groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => { }) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = { const pObj: Record<string, unknown> = {
page: 1, page: 1,
pageSize: 25, pageSize: 25,
groupByLevels: [ groupByLevels: levels,
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
}; };
if (base.search) (pObj as { search?: string }).search = base.search; if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters; if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;

View file

@ -149,17 +149,15 @@ export function useUserFiles() {
viewKey?: string | null; viewKey?: string | null;
groupField: string; groupField: string;
groupDirection?: 'asc' | 'desc'; groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => { }) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = { const pObj: Record<string, unknown> = {
page: 1, page: 1,
pageSize: 25, pageSize: 25,
groupByLevels: [ groupByLevels: levels,
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
}; };
if (base.search) (pObj as { search?: string }).search = base.search; if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters; if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;

View file

@ -98,17 +98,15 @@ export function usePrompts() {
viewKey?: string | null; viewKey?: string | null;
groupField: string; groupField: string;
groupDirection?: 'asc' | 'desc'; groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => { }) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = { const pObj: Record<string, unknown> = {
page: 1, page: 1,
pageSize: 25, pageSize: 25,
groupByLevels: [ groupByLevels: levels,
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
}; };
if (base.search) (pObj as { search?: string }).search = base.search; if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters; if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;

View file

@ -14,6 +14,7 @@ import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive'
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive'; import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive'; import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive'; import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge';
import styles from './MainLayout.module.css'; import styles from './MainLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
@ -132,6 +133,8 @@ const MainLayoutInner: React.FC = () => {
<Outlet /> <Outlet />
</div> </div>
</main> </main>
<RagRunningBadge />
</div> </div>
); );
}; };

View file

@ -35,7 +35,6 @@ import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalE
import { WorkspacePage } from './views/workspace/WorkspacePage'; import { WorkspacePage } from './views/workspace/WorkspacePage';
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage'; import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage'; import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsightsPage';
// Teamsbot Views // Teamsbot Views
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView'; import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
@ -155,7 +154,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
workspace: { workspace: {
dashboard: WorkspacePage, dashboard: WorkspacePage,
editor: WorkspaceEditorPage, editor: WorkspaceEditorPage,
'rag-insights': WorkspaceRagInsightsPage,
settings: WorkspaceSettingsPage, settings: WorkspaceSettingsPage,
}, },
teamsbot: { teamsbot: {

View file

@ -0,0 +1,334 @@
.page {
padding: 24px 32px;
max-width: 1100px;
}
/* ── Page Header ── */
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
flex-wrap: wrap;
}
.headerLeft {
display: flex;
align-items: center;
gap: 14px;
}
.headerIcon {
font-size: 24px;
color: var(--color-primary, #2563eb);
flex-shrink: 0;
}
.pageTitle {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.pageDesc {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
margin: 2px 0 0;
}
.headerRight {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.filterGroup {
display: flex;
align-items: center;
gap: 8px;
}
.filterLabel {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
}
.scopeSelect {
padding: 7px 12px;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
background: var(--color-surface, #fff);
color: var(--color-text, #111);
cursor: pointer;
min-width: 180px;
}
.scopeSelect:focus {
outline: none;
border-color: var(--color-primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);
}
.checkboxLabel {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
cursor: pointer;
white-space: nowrap;
}
.checkboxLabel input {
cursor: pointer;
}
/* ── Loading / Error ── */
.loading {
padding: 32px;
text-align: center;
color: var(--color-text-muted, #6b7280);
}
.error {
padding: 12px 16px;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 0.875rem;
margin-bottom: 16px;
}
/* ── Totals ── */
.totals {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
font-size: 0.875rem;
}
.totalLabel {
color: var(--color-text-muted, #6b7280);
}
.totalValue {
font-size: 1.125rem;
}
.totalBytes {
color: var(--color-text-muted, #6b7280);
font-size: 0.8125rem;
}
/* ── Content ── */
.content {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Connection Card ── */
.connectionCard {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 16px;
background: var(--color-surface, #fff);
}
.connectionHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.authority {
font-weight: 600;
font-size: 0.875rem;
text-transform: capitalize;
}
.email {
color: var(--color-text-muted, #6b7280);
font-size: 0.8125rem;
flex: 1;
}
.connChunks {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-primary, #2563eb);
background: var(--color-info-bg, #eff6ff);
padding: 2px 8px;
border-radius: 10px;
}
.consentToggle {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary, #2563eb);
display: flex;
align-items: center;
}
/* ── Consent Warning ── */
.consentWarning {
padding: 8px 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8125rem;
color: #92400e;
}
/* ── Error Banner ── */
.errorBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8125rem;
color: #b91c1c;
}
.reindexBtn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: var(--color-info-bg, #eff6ff);
color: var(--color-primary, #2563eb);
border: 1px solid var(--color-primary-light, #93c5fd);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
margin-left: auto;
white-space: nowrap;
}
.reindexBtn:hover {
background: #dbeafe;
}
.reindexHint {
display: flex;
padding: 6px 0;
margin-bottom: 4px;
}
/* ── Job Banner ── */
.jobBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-info-bg, #eff6ff);
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.8125rem;
}
.spinIcon {
animation: spin 1.5s linear infinite;
color: var(--color-primary, #2563eb);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.stopBtn {
margin-left: auto;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
}
.stopBtn:hover {
background: #fee2e2;
}
/* ── DataSource List ── */
.dsList {
display: flex;
flex-direction: column;
gap: 0;
}
.dsRow {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
border-bottom: 1px solid var(--color-border, #f3f4f6);
font-size: 0.8125rem;
}
.dsRow:last-child {
border-bottom: none;
}
.dsActive {
background: rgba(37, 99, 235, 0.03);
}
.dsLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dsType {
color: var(--color-text-muted, #6b7280);
font-size: 0.75rem;
min-width: 90px;
}
.dsChunks {
font-size: 0.75rem;
min-width: 70px;
text-align: right;
}
.dsIndex {
width: 24px;
text-align: center;
}
.dsEmpty {
padding: 12px;
text-align: center;
color: var(--color-text-muted, #9ca3af);
font-size: 0.8125rem;
font-style: italic;
}
/* ── Empty State ── */
.emptyState {
padding: 48px;
text-align: center;
color: var(--color-text-muted, #9ca3af);
font-size: 0.9rem;
}

View file

@ -0,0 +1,255 @@
/**
* RagInventoryPage Global RAG knowledge store management.
*
* Accessible via Start > Nutzung > RAG-Inventar.
* Context selector top-right (same pattern as BillingDataView / Statistiken):
* Dropdown: "Meine Verbindungen" | "Mandant: XY" | "Plattform (alle)"
* Checkbox: "nur meine Daten"
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
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 { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import styles from './RagInventoryPage.module.css';
export const RagInventoryPage: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { fetchMandates } = useUserMandates();
const [mandates, setMandates] = useState<any[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
const [selectedScope, setSelectedScope] = useState<string>('personal');
const [onlyMyData, setOnlyMyData] = useState(false);
const [inventory, setInventory] = useState<RagInventoryDto | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setMandatesLoading(true);
try {
const data = await fetchMandates();
if (!cancelled) {
const list = Array.isArray(data) ? data : [];
setMandates(list);
if (list.length === 1) setSelectedScope(list[0].id);
}
} catch {}
finally { if (!cancelled) setMandatesLoading(false); }
})();
return () => { cancelled = true; };
}, [fetchMandates]);
const _apiEndpoint = useMemo(() => {
if (selectedScope === 'personal') return '/api/rag/inventory/me';
if (selectedScope === 'platform') return '/api/rag/inventory/platform';
return '/api/rag/inventory/mandate';
}, [selectedScope]);
const _fetchInventory = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params: Record<string, string> = {};
if (selectedScope !== 'personal' && selectedScope !== 'platform') {
params.mandateId = selectedScope;
}
if (onlyMyData) params.onlyMine = 'true';
const data = await request({ url: _apiEndpoint, method: 'get', params });
setInventory(data);
} catch (err: any) {
if (err?.message?.includes('403')) {
setError(t('Keine Berechtigung für diese Sicht.'));
} else {
setError(err?.message || t('Fehler beim Laden'));
}
setInventory(null);
} finally {
setLoading(false);
}
}, [request, _apiEndpoint, selectedScope, onlyMyData, t]);
useEffect(() => {
_fetchInventory();
}, [_fetchInventory]);
useEffect(() => {
pollRef.current = setInterval(() => {
if (document.visibilityState === 'visible') _fetchInventory();
}, 60000);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, [_fetchInventory]);
const _handleStop = async (connectionId: string) => {
try {
await request({ url: `/api/connections/${connectionId}/knowledge-stop`, method: 'post' });
_fetchInventory();
} catch {}
};
const _handleReindex = async (connectionId: string) => {
try {
await request({ url: `/api/rag/inventory/reindex/${connectionId}`, method: 'post' });
_fetchInventory();
} catch {}
};
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
if (!currentEnabled || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) {
try {
await request({
url: `/api/connections/${connectionId}/knowledge-consent`,
method: 'patch',
data: { enabled: !currentEnabled },
});
_fetchInventory();
} catch {}
}
};
const scopeOptions = useMemo(() => {
const opts: { value: string; label: string }[] = [
{ value: 'personal', label: t('Meine Verbindungen') },
];
for (const m of mandates) {
opts.push({ value: m.id, label: t('Mandant: {name}', { name: mandateDisplayLabel(m) }) });
}
opts.push({ value: 'platform', label: t('Plattform (alle)') });
return opts;
}, [mandates, t]);
return (
<div className={styles.page}>
<header className={styles.pageHeader}>
<div className={styles.headerLeft}>
<FaDatabase className={styles.headerIcon} />
<div>
<h1 className={styles.pageTitle}>{t('RAG-Inventar')}</h1>
<p className={styles.pageDesc}>
{t('Übersicht und Steuerung der indexierten Wissensdaten.')}
</p>
</div>
</div>
<div className={styles.headerRight}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Kontext:')}</label>
<select
className={styles.scopeSelect}
value={selectedScope}
onChange={e => setSelectedScope(e.target.value)}
disabled={mandatesLoading}
>
{scopeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={onlyMyData}
onChange={e => setOnlyMyData(e.target.checked)}
/>
{t('nur meine Daten')}
</label>
</div>
</header>
{loading && !inventory && <div className={styles.loading}>{t('Laden...')}</div>}
{error && <div className={styles.error}>{error}</div>}
{inventory && (
<div className={styles.content}>
<div className={styles.totals}>
<span className={styles.totalLabel}>{t('Total Chunks')}:</span>
<strong className={styles.totalValue}>{inventory.totals?.chunks ?? 0}</strong>
{inventory.totals?.bytes != null && inventory.totals.bytes > 0 && (
<span className={styles.totalBytes}>{(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB</span>
)}
</div>
{(inventory.connections || []).map((conn: RagConnectionDto) => (
<div key={conn.id} className={styles.connectionCard}>
<div className={styles.connectionHeader}>
<span className={styles.authority}>{conn.authority}</span>
<span className={styles.email}>{conn.externalEmail}</span>
{conn.totalChunks > 0 && (
<span className={styles.connChunks}>{conn.totalChunks} chunks</span>
)}
<button
className={styles.consentToggle}
onClick={() => _handleConsentToggle(conn.id, conn.knowledgeIngestionEnabled)}
title={conn.knowledgeIngestionEnabled ? t('Wissensdatenbank deaktivieren') : t('Wissensdatenbank aktivieren')}
>
{conn.knowledgeIngestionEnabled ? <FaToggleOn size={20} /> : <FaToggleOff size={20} />}
</button>
</div>
{!conn.knowledgeIngestionEnabled && conn.dataSources.length > 0 && (
<div className={styles.consentWarning}>
{t('Wissensdatenbank deaktiviert — Indexierung pausiert. Toggle aktivieren um Daten zu indexieren.')}
</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 && (
<div className={styles.jobBanner}>
<FaSync className={styles.spinIcon} />
<span>{conn.runningJobs[0].progressMessage || `${Math.round(conn.runningJobs[0].progress * 100)}%`}</span>
<button className={styles.stopBtn} onClick={() => _handleStop(conn.id)} title={t('Indexierung stoppen')}>
<FaStop size={12} /> {t('Stop')}
</button>
</div>
)}
{!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>
)}
<div className={styles.dsList}>
{conn.dataSources.map(ds => (
<div key={ds.id} className={`${styles.dsRow} ${ds.ragIndexEnabled ? styles.dsActive : ''}`}>
<span className={styles.dsLabel}>{ds.label || ds.path}</span>
<span className={styles.dsType}>{ds.sourceType}</span>
<span className={styles.dsChunks}>{ds.chunkCount} chunks</span>
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
</div>
))}
{conn.dataSources.length === 0 && (
<div className={styles.dsEmpty}>{t('Keine Datenquellen konfiguriert')}</div>
)}
</div>
</div>
))}
{(inventory.connections || []).length === 0 && (
<div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div>
)}
</div>
)}
</div>
);
};
export default RagInventoryPage;

View file

@ -9,8 +9,7 @@ import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useConnections, type Connection } from '../../hooks/useConnections'; import { useConnections, type Connection } from '../../hooks/useConnections';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaLink, FaRedo, FaShieldAlt, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaCloud } from 'react-icons/fa'; import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt } from 'react-icons/fa';
import { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css'; import styles from '../admin/Admin.module.css';
import bannerStyles from './ConnectionsPage.module.css'; import bannerStyles from './ConnectionsPage.module.css';
import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard'; import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard';
@ -42,8 +41,6 @@ export const ConnectionsPage: React.FC = () => {
deleteConnection, deleteConnection,
handleInlineUpdate, handleInlineUpdate,
createConnectionAndAuth, createConnectionAndAuth,
createInfomaniakConnection,
submitInfomaniakToken,
connectWithPopup, connectWithPopup,
refreshMicrosoftToken, refreshMicrosoftToken,
refreshGoogleToken, refreshGoogleToken,
@ -54,7 +51,6 @@ export const ConnectionsPage: React.FC = () => {
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set()); const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set()); const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set()); const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
const [adminConsentPending, setAdminConsentPending] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
// Banner shown while knowledge bootstrap is running in the background // Banner shown while knowledge bootstrap is running in the background
const [syncBanner, setSyncBanner] = useState<{ const [syncBanner, setSyncBanner] = useState<{
@ -73,15 +69,6 @@ export const ConnectionsPage: React.FC = () => {
setSyncBanner(null); setSyncBanner(null);
}; };
// Infomaniak PAT modal: holds the pending connectionId (created up-front so the
// user only commits if they actually paste a valid token; on cancel we delete it).
const [infomaniakModal, setInfomaniakModal] = useState<{
connectionId: string;
token: string;
submitting: boolean;
error: string | null;
} | null>(null);
// Initial fetch // Initial fetch
useEffect(() => { useEffect(() => {
refetch(); refetch();
@ -242,76 +229,6 @@ export const ConnectionsPage: React.FC = () => {
} }
}; };
const handleCreateInfomaniak = async () => {
if (isConnecting || infomaniakModal) return;
try {
const newConnection = await createInfomaniakConnection();
setInfomaniakModal({
connectionId: newConnection.id,
token: '',
submitting: false,
error: null,
});
refetch();
} catch (error) {
console.error('Error creating Infomaniak connection:', error);
}
};
const handleInfomaniakCancel = async () => {
if (!infomaniakModal) return;
const { connectionId, submitting } = infomaniakModal;
if (submitting) return;
setInfomaniakModal(null);
try {
await deleteConnection(connectionId);
refetch();
} catch (error) {
console.error('Error rolling back pending Infomaniak connection:', error);
}
};
const handleInfomaniakSubmit = async () => {
if (!infomaniakModal) return;
const trimmed = infomaniakModal.token.trim();
if (!trimmed) {
setInfomaniakModal({ ...infomaniakModal, error: t('Bitte Personal Access Token einfügen') });
return;
}
setInfomaniakModal({ ...infomaniakModal, submitting: true, error: null });
try {
await submitInfomaniakToken(infomaniakModal.connectionId, trimmed);
setInfomaniakModal(null);
refetch();
} catch (error: any) {
const detail =
error?.response?.data?.detail ||
error?.message ||
t('Token konnte nicht gespeichert werden');
setInfomaniakModal((prev) =>
prev ? { ...prev, submitting: false, error: String(detail) } : prev
);
}
};
// Open Microsoft Admin Consent flow in a popup
const handleAdminConsent = () => {
setAdminConsentPending(true);
const consentUrl = `${getApiBaseUrl()}/api/msft/adminconsent`;
const popup = window.open(consentUrl, 'msft-admin-consent', 'width=600,height=700,scrollbars=yes,resizable=yes');
if (!popup) {
setAdminConsentPending(false);
return;
}
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
setAdminConsentPending(false);
refetch();
}
}, 1000);
};
// Form attributes for edit modal // Form attributes for edit modal
const formAttributes = useMemo(() => { const formAttributes = useMemo(() => {
const excludedFields = [ const excludedFields = [
@ -348,14 +265,6 @@ export const ConnectionsPage: React.FC = () => {
</p> </p>
</div> </div>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={handleAdminConsent}
disabled={adminConsentPending}
title={t('Microsoft Admin-Zustimmung für die gesamte Organisation erteilen')}
>
<FaShieldAlt /> {t('Admin-Zustimmung')}
</button>
<button <button
className={styles.secondaryButton} className={styles.secondaryButton}
onClick={() => refetch()} onClick={() => refetch()}
@ -364,25 +273,14 @@ export const ConnectionsPage: React.FC = () => {
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')} <FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button> </button>
{canCreate && ( {canCreate && (
<> <button
<button type="button"
type="button" className={styles.primaryButton}
className={styles.primaryButton} onClick={() => setWizardOpen(true)}
onClick={() => setWizardOpen(true)} disabled={isConnecting}
disabled={isConnecting} >
> <FaPlus /> {t('Verbindung hinzufügen')}
<FaPlus /> {t('Verbindung hinzufügen')} </button>
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={handleCreateInfomaniak}
disabled={isConnecting}
title={t('Infomaniak-Konto verbinden (kDrive + Mail)')}
>
<FaCloud /> Infomaniak
</button>
</>
)} )}
</div> </div>
</div> </div>
@ -419,7 +317,7 @@ export const ConnectionsPage: React.FC = () => {
columns={columns} columns={columns}
apiEndpoint="/api/connections/" apiEndpoint="/api/connections/"
tableContextKey="connections" tableContextKey="connections"
tableGroupLayoutMode="sections" tableGroupLayoutMode="inline"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}
@ -519,137 +417,6 @@ export const ConnectionsPage: React.FC = () => {
</div> </div>
)} )}
{/* Infomaniak Personal Access Token Modal */}
{infomaniakModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal} style={{ maxWidth: 640 }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Infomaniak verbinden')}</h2>
<button
className={styles.modalClose}
onClick={handleInfomaniakCancel}
disabled={infomaniakModal.submitting}
>
</button>
</div>
<div className={styles.modalContent}>
<p style={{ marginTop: 0 }}>
{t(
'Infomaniak nutzt für kDrive und kSuite keine OAuth-Anmeldung, sondern ein persönliches API-Token (PAT). Erstelle das Token einmalig im Infomaniak Manager und füge es unten ein.'
)}
</p>
<ol style={{ paddingLeft: 20 }}>
<li>
{t('Öffne den Infomaniak-Manager:')}{' '}
<a
href="https://manager.infomaniak.com/v3/ng/accounts/token/list"
target="_blank"
rel="noopener noreferrer"
>
manager.infomaniak.com API-Tokens
</a>
</li>
<li>
{t('Klicke auf')} <code>{t('Token erstellen')}</code>{' '}
{t('und vergib einen aussagekräftigen Namen, z. B.')}{' '}
<code>PowerOn</code>.{' '}
{t('Application bleibt auf')} <code>Default application</code>.
</li>
<li>
{t('Suche im Scope-Feld nach')}{' '}
<strong>{t('allen vier')}</strong>{' '}
{t('Berechtigungen und kreuze sie an:')}
<ul style={{ marginTop: 6, marginBottom: 6, paddingLeft: 20 }}>
<li>
<code>drive</code> {t('kDrive (Pflicht, heute aktiv)')}
</li>
<li>
<code>workspace:calendar</code> {' '}
{t('Kalender (Pflicht, heute aktiv)')}
</li>
<li>
<code>workspace:contact</code> {' '}
{t('Kontakte (heute aktiv)')}
</li>
<li>
<code>workspace:mail</code> {' '}
{t('Mail (in Vorbereitung, Scope schon mitnehmen)')}
</li>
</ul>
<em>
{t(
'Nicht "All" auswählen und nicht user_info / accounts — wird nicht benötigt.'
)}
</em>
</li>
<li>
{t(
'Kopiere das Token sofort (Infomaniak zeigt es nur einmal) und füge es hier ein:'
)}
</li>
</ol>
<input
type="password"
value={infomaniakModal.token}
onChange={(e) =>
setInfomaniakModal((prev) =>
prev ? { ...prev, token: e.target.value, error: null } : prev
)
}
placeholder={t('Personal Access Token einfügen')}
disabled={infomaniakModal.submitting}
autoFocus
style={{
width: '100%',
padding: '8px 10px',
fontFamily: 'monospace',
fontSize: 13,
border: '1px solid var(--border, #ccc)',
borderRadius: 4,
marginBottom: 12,
}}
/>
{infomaniakModal.error && (
<div className={styles.errorMessage} style={{ marginBottom: 12 }}>
{infomaniakModal.error}
</div>
)}
<p style={{ fontSize: 12, color: 'var(--text-secondary, #666)' }}>
{t(
'Das Token wird verschlüsselt gespeichert und nur für API-Aufrufe an Infomaniak verwendet.'
)}
</p>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
marginTop: 16,
}}
>
<button
type="button"
className={styles.secondaryButton}
onClick={handleInfomaniakCancel}
disabled={infomaniakModal.submitting}
>
{t('Abbrechen')}
</button>
<button
type="button"
className={styles.primaryButton}
onClick={handleInfomaniakSubmit}
disabled={infomaniakModal.submitting || !infomaniakModal.token.trim()}
>
{infomaniakModal.submitting ? t('Prüfen…') : t('Verbinden')}
</button>
</div>
</div>
</div>
</div>
)}
<AddConnectionWizard <AddConnectionWizard
open={wizardOpen} open={wizardOpen}
onClose={() => setWizardOpen(false)} onClose={() => setWizardOpen(false)}

View file

@ -448,7 +448,7 @@ export const FilesPage: React.FC = () => {
columns={columns} columns={columns}
apiEndpoint="/api/files/list" apiEndpoint="/api/files/list"
tableContextKey="files/list" tableContextKey="files/list"
tableGroupLayoutMode="sections" tableGroupLayoutMode="inline"
loading={tableLoading} loading={tableLoading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -209,7 +209,7 @@ export const PromptsPage: React.FC = () => {
columns={columns} columns={columns}
apiEndpoint="/api/prompts" apiEndpoint="/api/prompts"
tableContextKey="prompts" tableContextKey="prompts"
tableGroupLayoutMode="sections" tableGroupLayoutMode="inline"
loading={loading} loading={loading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -527,17 +527,15 @@ export const BillingDataView: React.FC = () => {
viewKey?: string | null; viewKey?: string | null;
groupField: string; groupField: string;
groupDirection?: 'asc' | 'desc'; groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
}) => { }) => {
const levels = base.groupByLevels?.length
? base.groupByLevels
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
const pObj: Record<string, unknown> = { const pObj: Record<string, unknown> = {
page: 1, page: 1,
pageSize: 25, pageSize: 25,
groupByLevels: [ groupByLevels: levels,
{
field: base.groupField,
nullLabel: '—',
direction: base.groupDirection || 'asc',
},
],
}; };
if (base.search) (pObj as { search?: string }).search = base.search; if (base.search) (pObj as { search?: string }).search = base.search;
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters; if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
@ -837,7 +835,7 @@ export const BillingDataView: React.FC = () => {
columns={columns} columns={columns}
apiEndpoint="/api/billing/view/users/transactions" apiEndpoint="/api/billing/view/users/transactions"
tableContextKey="billing/view/users/transactions" tableContextKey="billing/view/users/transactions"
tableGroupLayoutMode="sections" tableGroupLayoutMode="inline"
loading={transactionsLoading} loading={transactionsLoading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -1,6 +1,13 @@
/* CommCoach Shared Styles — Assistant, Modules, Session views */ /* CommCoach Shared Styles — Assistant, Modules, Session views */
.assistantContainer, .assistantContainer {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
}
.modulesContainer { .modulesContainer {
padding: 1.5rem; padding: 1.5rem;
display: flex; display: flex;
@ -14,6 +21,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
}
.wizardHeaderRight {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
} }
.stepIndicator { .stepIndicator {
@ -33,7 +48,6 @@
} }
.wizardContent { .wizardContent {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -98,9 +112,8 @@
.wizardActions { .wizardActions {
display: flex; display: flex;
gap: 1rem; gap: 0.5rem;
padding-top: 1rem; align-items: center;
border-top: 1px solid var(--border-color, #e0e0e0);
} }
.wizardHint { .wizardHint {

View file

@ -75,10 +75,24 @@ export const CommcoachAssistantView: React.FC = () => {
<div className={styles.assistantContainer}> <div className={styles.assistantContainer}>
<div className={styles.wizardHeader}> <div className={styles.wizardHeader}>
<h2>{t('Neues Modul erstellen')}</h2> <h2>{t('Neues Modul erstellen')}</h2>
<div className={styles.stepIndicator}> <div className={styles.wizardHeaderRight}>
{STEPS.map((s, i) => ( <div className={styles.stepIndicator}>
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} /> {STEPS.map((s, i) => (
))} <div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
))}
</div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button className={styles.btnPrimary} onClick={_handleCreate} disabled={loading}>
{loading ? t('Erstelle...') : t('Modul erstellen & erste Session starten')}
</button>
)}
</div>
</div> </div>
</div> </div>
@ -156,19 +170,6 @@ export const CommcoachAssistantView: React.FC = () => {
)} )}
</div> </div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
<div style={{ flex: 1 }} />
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button className={styles.btnPrimary} onClick={_handleCreate} disabled={loading}>
{loading ? t('Erstelle...') : t('Modul erstellen & erste Session starten')}
</button>
)}
</div>
</div> </div>
); );
}; };

View file

@ -1372,7 +1372,14 @@
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.assistantContainer, .assistantContainer {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
}
.modulesContainer { .modulesContainer {
padding: 1.5rem; padding: 1.5rem;
display: flex; display: flex;
@ -1386,6 +1393,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
}
.wizardHeaderRight {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
} }
.stepIndicator { .stepIndicator {
@ -1405,7 +1420,6 @@
} }
.wizardContent { .wizardContent {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -1438,9 +1452,8 @@
.wizardActions { .wizardActions {
display: flex; display: flex;
gap: 1rem; gap: 0.5rem;
padding-top: 1rem; align-items: center;
border-top: 1px solid var(--border-color, #e0e0e0);
} }
.moduleChoice { .moduleChoice {

View file

@ -149,10 +149,32 @@ export const TeamsbotAssistantView: React.FC = () => {
<div className={styles.assistantContainer}> <div className={styles.assistantContainer}>
<div className={styles.wizardHeader}> <div className={styles.wizardHeader}>
<h2>{t('Neues Meeting starten')}</h2> <h2>{t('Neues Meeting starten')}</h2>
<div className={styles.stepIndicator}> <div className={styles.wizardHeaderRight}>
{STEPS.map((s, i) => ( <div className={styles.stepIndicator}>
<div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} /> {STEPS.map((s, i) => (
))} <div key={s} className={`${styles.stepDot} ${i <= stepIdx ? styles.stepActive : ''}`} />
))}
</div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button
className={styles.btnPrimary}
onClick={_handleStart}
disabled={
loading
|| savingCredentials
|| (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))
}
>
{loading ? t('Starte...') : t('Bot starten')}
</button>
)}
</div>
</div> </div>
</div> </div>
@ -328,27 +350,6 @@ export const TeamsbotAssistantView: React.FC = () => {
)} )}
</div> </div>
<div className={styles.wizardActions}>
{stepIdx > 0 && (
<button className={styles.btnSecondary} onClick={_handleBack}>{t('Zurück')}</button>
)}
<div style={{ flex: 1 }} />
{step !== 'confirm' ? (
<button className={styles.btnPrimary} onClick={_handleNext}>{t('Weiter')}</button>
) : (
<button
className={styles.btnPrimary}
onClick={_handleStart}
disabled={
loading
|| savingCredentials
|| (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))
}
>
{loading ? t('Starte...') : t('Bot starten')}
</button>
)}
</div>
</div> </div>
); );
}; };

View file

@ -1,107 +0,0 @@
.wrap {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 1200px;
}
.disclaimer {
font-size: 0.85rem;
line-height: 1.45;
color: var(--text-secondary, #666);
padding: 0.75rem 1rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
border: 1px solid var(--border-color, #e8e8e8);
}
.kpiGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.kpiCard {
padding: 1rem;
border-radius: 8px;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.kpiValue {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1a1a);
margin: 0 0 0.25rem;
}
.kpiLabel {
font-size: 0.8rem;
color: var(--text-secondary, #666);
margin: 0;
line-height: 1.3;
}
.chartBlock {
padding: 1rem;
border-radius: 8px;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
min-height: 280px;
}
.chartTitle {
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 0.75rem;
color: var(--text-primary, #1a1a1a);
}
.row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 900px) {
.row2 {
grid-template-columns: 1fr;
}
}
.meta {
font-size: 0.75rem;
color: var(--text-secondary, #888);
margin-top: 0.5rem;
}
.error {
color: #c62828;
padding: 1rem;
}
.recentTable {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.recentTable th {
text-align: left;
font-weight: 600;
color: var(--text-secondary, #666);
padding: 0.5rem 0.75rem;
border-bottom: 2px solid var(--border-color, #e0e0e0);
white-space: nowrap;
}
.recentTable td {
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--border-color, #f0f0f0);
color: var(--text-primary, #1a1a1a);
}
.recentTable tbody tr:hover {
background: var(--bg-secondary, #fafafa);
}

View file

@ -1,352 +0,0 @@
/**
* WorkspaceRagInsightsPage Aggregierte, nicht personenbezogene Kennzahlen zum
* Knowledge Store / RAG dieser Workspace-Instanz (Präsentationen, Monitoring).
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
BarChart,
Bar,
PieChart,
Pie,
Cell,
} from 'recharts';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import styles from './WorkspaceRagInsightsPage.module.css';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import { useLanguage } from '../../../providers/language/LanguageContext';
function _mimeLabel(key: string, t: (k: string) => string): string {
switch (key) {
case 'pdf': return t('PDF');
case 'office_doc': return t('Office (Text)');
case 'office_sheet': return t('Office (Tabellen)');
case 'office_slides': return t('Office (Folien)');
case 'text': return t('Text');
case 'image': return t('Bild');
case 'html': return t('HTML');
case 'other': return t('Sonstige');
default: return key;
}
}
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
function _formatTimestamp(ts: number | null | undefined): string {
if (ts == null || ts <= 0) return '';
try {
const d = new Date(ts * 1000);
return d.toLocaleString('de-CH', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
} catch {
return '';
}
}
function _shortMime(mime: string): string {
const m = (mime || '').toLowerCase();
if (m.includes('pdf')) return 'PDF';
if (m.includes('wordprocessing') || m.includes('msword')) return 'Word';
if (m.includes('spreadsheet') || m.includes('excel')) return 'Excel';
if (m.includes('presentation') || m.includes('powerpoint')) return 'PowerPoint';
if (m.startsWith('text/')) return 'Text';
if (m.startsWith('image/')) return 'Bild';
if (m.includes('html')) return 'HTML';
return mime || '';
}
const _STATUS_COLORS: Record<string, string> = {
indexed: '#2e7d32',
extracted: '#1565c0',
embedding: '#6a1b9a',
pending: '#e65100',
failed: '#c62828',
};
interface RagKpis {
indexedDocuments: number;
indexedBytesTotal: number;
contributorUsers: number;
contentChunks: number;
chunksWithEmbedding: number;
embeddingCoveragePercent: number;
workflowEntities: number;
}
interface RecentlyIndexedDoc {
fileName: string;
mimeType: string;
status: string;
extractedAt: number | null;
totalSize: number;
}
interface RagStatsResponse {
error?: string;
scope?: {
featureInstanceId?: string;
mandateScopedShared?: boolean;
workspaceFileIdsResolved?: number;
};
kpis?: RagKpis;
indexedDocumentsByStatus?: Record<string, number>;
documentsByMimeCategory?: Record<string, number>;
chunksByContentType?: Record<string, number>;
timelineIndexedDocuments?: Array<{ date: string; indexedDocuments: number }>;
recentlyIndexedDocuments?: RecentlyIndexedDoc[];
generatedAtUtc?: string;
}
export const WorkspaceRagInsightsPage: React.FC = () => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const { request } = useApiRequest();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stats, setStats] = useState<RagStatsResponse | null>(null);
const load = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
setError(null);
try {
const data = (await request({
url: `/api/workspace/${instanceId}/rag-statistics`,
method: 'get',
})) as RagStatsResponse;
if (data?.error) {
setError(String(data.error));
setStats(null);
} else {
setStats(data ?? null);
}
} catch (e) {
setError(e instanceof Error ? e.message : t('Laden fehlgeschlagen'));
setStats(null);
} finally {
setLoading(false);
}
}, [instanceId, request, t]);
useEffect(() => {
void load();
}, [load]);
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
{t('Keine Workspace-Instanz ausgewählt.')}
</div>
);
}
if (loading) {
return <div className={styles.wrap} style={{ padding: 24 }}>{t('Lade Kennzahlen')}</div>;
}
if (error) {
return <div className={styles.error}>{error}</div>;
}
const kpis = stats?.kpis;
const timeline = stats?.timelineIndexedDocuments ?? [];
const mimeRows = Object.entries(stats?.documentsByMimeCategory ?? {}).map(([key, value]) => ({
name: _mimeLabel(key, t),
value,
}));
const statusRows = Object.entries(stats?.indexedDocumentsByStatus ?? {}).map(([name, value]) => ({
name,
value,
}));
const chunkTypeRows = Object.entries(stats?.chunksByContentType ?? {}).map(([name, value]) => ({
name,
value,
}));
return (
<div className={styles.wrap}>
<p className={styles.disclaimer}>
{t(
'Dargestellt sind ausschliesslich aggregierte technische Masszahlen dieser Instanz (Anzahl Dokumente, Fragmente, Speicherumfang, Verteilungen). Es werden keine Inhalte, Dateinamen oder personenbezogene Angaben ausgewiesen. Geeignet für interne Berichte und Präsentationen.',
)}
</p>
{stats?.scope?.workspaceFileIdsResolved !== undefined && (
<p className={styles.meta} style={{ marginTop: 0 }}>
{t(
'Zuordnung Knowledge ↔ Dateien: {workspaceFileIdsResolved} Datei-ID(s) mit dieser Feature-Instanz in der Dateiverwaltung. Neu indexierte Uploads erhalten die Instanz automatisch; ältere Einträge ohne Zuordnung erscheinen erst nach erneuter Indexierung.',
{ workspaceFileIdsResolved: stats.scope.workspaceFileIdsResolved },
)}
</p>
)}
{kpis && (
<div className={styles.kpiGrid}>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.indexedDocuments}</p>
<p className={styles.kpiLabel}>{t('Indexierte Dokumente')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{formatBinaryDataSizeBytes(kpis.indexedBytesTotal)}</p>
<p className={styles.kpiLabel}>{t('Indexiertes Datenvolumen (geschätzt)')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.contentChunks}</p>
<p className={styles.kpiLabel}>{t('Inhaltsfragmente (Chunks)')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>
{kpis.embeddingCoveragePercent}%
</p>
<p className={styles.kpiLabel}>{t('Anteil Fragmente mit Embedding')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.contributorUsers}</p>
<p className={styles.kpiLabel}>{t('Beitragende Benutzeranzahl')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.workflowEntities}</p>
<p className={styles.kpiLabel}>{t('Workflowentitäten-Cache')}</p>
</div>
</div>
)}
{(stats?.recentlyIndexedDocuments ?? []).length > 0 && (
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Zuletzt indexierte Dokumente')}</h3>
<div style={{ overflowX: 'auto' }}>
<table className={styles.recentTable}>
<thead>
<tr>
<th>{t('Dateiname')}</th>
<th>{t('Format')}</th>
<th>{t('Grösse')}</th>
<th>{t('Status')}</th>
<th>{t('Indexiert am')}</th>
</tr>
</thead>
<tbody>
{(stats?.recentlyIndexedDocuments ?? []).map((doc, i) => (
<tr key={i}>
<td title={doc.fileName} style={{ maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.fileName || ''}
</td>
<td>{_shortMime(doc.mimeType)}</td>
<td style={{ whiteSpace: 'nowrap' }}>{formatBinaryDataSizeBytes(doc.totalSize)}</td>
<td>
<span style={{
color: _STATUS_COLORS[doc.status] ?? '#666',
fontWeight: 500,
}}>
{doc.status}
</span>
</td>
<td style={{ whiteSpace: 'nowrap' }}>{_formatTimestamp(doc.extractedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Neu indexierte Dokumente pro Tag')}</h3>
{timeline.length === 0 ? (
<p className={styles.meta}>{t('Keine Zeitreihendaten für den gewählten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={timeline}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="indexedDocuments" name={t('Dokumente')} stroke="#1976d2" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.row2}>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Dokumente nach Formatkategorie')}</h3>
{mimeRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={mimeRows} layout="vertical" margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" allowDecimals={false} />
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" name={t('Anzahl')} fill="#00897b" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Index-Status')}</h3>
{statusRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={statusRows}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={88}
label={({ name, percent }) =>
`${name ?? ''} ${percent != null ? (percent * 100).toFixed(0) : '0'}%`}
>
{statusRows.map((_, i) => (
<Cell key={`st-${i}`} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Fragmente nach Inhaltstyp')}</h3>
{chunkTypeRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Chunkdaten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={chunkTypeRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" name={t('Fragmente')} fill="#6a1b9a" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{stats?.generatedAtUtc && (
<p className={styles.meta}>
{t('Stand (UTC):')} {stats.generatedAtUtc}
</p>
)}
</div>
);
};