/** * AddConnectionWizard * * Multi-step modal for adding a new connector with optional knowledge * ingestion consent and per-connection preferences (§2.6). * * Steps: * 0 — Connector wählen * 1 — Consent (Wissensdatenbank Ja/Nein) * 2 — Präferenzen (nur wenn Ja) * 3 — Zusammenfassung + OAuth starten */ import React, { useState } from 'react'; import { Modal } from '../UiComponents/Modal/Modal'; import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa'; import type { KnowledgePreferences } from '../../api/connectionApi'; import styles from './AddConnectionWizard.module.css'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type ConnectorType = 'google' | 'msft' | 'clickup'; interface WizardState { step: 0 | 1 | 2 | 3; connector: ConnectorType | null; knowledgeEnabled: boolean; prefs: KnowledgePreferences; } 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 = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp', }; const CONNECTOR_ICONS: Record = { google: , msft: , clickup: , }; // --------------------------------------------------------------------------- // Cost estimate helper // --------------------------------------------------------------------------- /** * 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 = { 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).', }; } // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- interface AddConnectionWizardProps { open: boolean; onClose: () => void; onConnect: ( type: ConnectorType, knowledgeEnabled: boolean, prefs: KnowledgePreferences | null, ) => Promise; isConnecting?: boolean; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export const AddConnectionWizard: React.FC = ({ open, onClose, onConnect, isConnecting = false, }) => { const [state, setState] = useState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS }, }); const reset = () => setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } }); const handleClose = () => { reset(); onClose(); }; const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step })); const setConnector = (connector: ConnectorType) => setState(s => ({ ...s, connector, step: 1 })); const setKnowledgeEnabled = (v: boolean) => setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 })); const updatePref = (key: K, value: KnowledgePreferences[K]) => setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } })); const handleConnect = async () => { if (!state.connector) return; await onConnect( state.connector, state.knowledgeEnabled, state.knowledgeEnabled ? state.prefs : null, ); reset(); onClose(); }; const visibleSteps = state.knowledgeEnabled ? [0, 1, 2, 3] : [0, 1, 3]; return ( {/* Stepper */}
{[0, 1, 2, 3].map(i => (
i ? styles.stepDotDone : '', !visibleSteps.includes(i) ? styles.stepDotHidden : '', ].join(' ')} > {state.step > i ? : i + 1}
))}
{/* ---- Step 0: Connector ---- */} {state.step === 0 && (

Anbieter wählen

Welchen Dienst möchtest du verbinden?

{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => ( ))}
)} {/* ---- Step 1: Consent ---- */} {state.step === 1 && (

Wissensdatenbank

Möchtest du Inhalte aus dieser Verbindung in deine persönliche Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen aus{' '} {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '} zurückgreifen kann?

Du kannst diese Einstellung später in den Verbindungsdetails ändern.

)} {/* ---- Step 2: Preferences ---- */} {state.step === 2 && (

Einstellungen

Steuere, welche Inhalte und in welcher Form sie indexiert werden.

Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt.

{(state.connector === 'google' || state.connector === 'msft') && ( <>
)} {state.connector === 'clickup' && (
)}

0 = kein Limit

)} {/* ---- Step 3: Summary ---- */} {state.step === 3 && (

Zusammenfassung

Anbieter {CONNECTOR_ICONS[state.connector!]}  {state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
Wissensdatenbank {state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
{state.knowledgeEnabled && ( <>
Anonymisierung {state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'}
{(state.connector === 'google' || state.connector === 'msft') && (
E-Mail-Tiefe {{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[ state.prefs.mailContentDepth ?? 'full' ] ?? state.prefs.mailContentDepth}
)} {state.connector === 'clickup' && (
Aufgaben-Inhalt {{ titles: 'Nur Titel', title_description: 'Titel + Beschreibung', with_comments: 'Titel + Beschreibung + Kommentare', }[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope}
)}
Zeitfenster {state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'}
)}
{/* Cost estimate — only shown when knowledge ingestion is enabled */} {state.knowledgeEnabled && (() => { const est = computeCostEstimate(state.connector, state.prefs); if (!est) return null; return (
Geschätzte Kosten (erster Sync) {est.neutralizationLow && ( )}
Embedding {est.embeddingLow} – {est.embeddingHigh}
Anonymisierung (Private LLM) {est.neutralizationLow} – {est.neutralizationHigh}
{est.neutralizationLow && ( ⚠ Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise). )} {est.note}
); })()}
)}
); }; export default AddConnectionWizard;