520 lines
19 KiB
TypeScript
520 lines
19 KiB
TypeScript
/**
|
||
* 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<ConnectorType, string> = {
|
||
google: 'Google',
|
||
msft: 'Microsoft 365',
|
||
clickup: 'ClickUp',
|
||
};
|
||
|
||
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
|
||
google: <FaGoogle style={{ color: '#4285f4' }} />,
|
||
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
|
||
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<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).',
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Props
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface AddConnectionWizardProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onConnect: (
|
||
type: ConnectorType,
|
||
knowledgeEnabled: boolean,
|
||
prefs: KnowledgePreferences | null,
|
||
) => Promise<void>;
|
||
isConnecting?: boolean;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Component
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
||
open,
|
||
onClose,
|
||
onConnect,
|
||
isConnecting = false,
|
||
}) => {
|
||
const [state, setState] = useState<WizardState>({
|
||
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 = <K extends keyof KnowledgePreferences>(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 (
|
||
<Modal
|
||
open={open}
|
||
onClose={handleClose}
|
||
title="Verbindung hinzufügen"
|
||
size="md"
|
||
closeOnEscape
|
||
>
|
||
{/* Stepper */}
|
||
<div className={styles.stepper}>
|
||
{[0, 1, 2, 3].map(i => (
|
||
<div
|
||
key={i}
|
||
className={[
|
||
styles.stepDot,
|
||
state.step === i ? styles.stepDotActive : '',
|
||
state.step > i ? styles.stepDotDone : '',
|
||
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
|
||
].join(' ')}
|
||
>
|
||
{state.step > i ? <FaCheck size={10} /> : i + 1}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className={styles.body}>
|
||
{/* ---- Step 0: Connector ---- */}
|
||
{state.step === 0 && (
|
||
<div className={styles.stepContent}>
|
||
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
|
||
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
|
||
<div className={styles.connectorGrid}>
|
||
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => (
|
||
<button
|
||
key={type}
|
||
type="button"
|
||
className={styles.connectorCard}
|
||
onClick={() => setConnector(type)}
|
||
>
|
||
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
|
||
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- Step 1: Consent ---- */}
|
||
{state.step === 1 && (
|
||
<div className={styles.stepContent}>
|
||
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
|
||
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
|
||
<p className={styles.stepBody}>
|
||
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?
|
||
</p>
|
||
<p className={styles.stepHint}>
|
||
Du kannst diese Einstellung später in den Verbindungsdetails ändern.
|
||
</p>
|
||
<div className={styles.consentButtons}>
|
||
<button
|
||
type="button"
|
||
className={styles.consentButtonYes}
|
||
onClick={() => setKnowledgeEnabled(true)}
|
||
>
|
||
<FaCheck /> Ja, aufnehmen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={styles.consentButtonNo}
|
||
onClick={() => setKnowledgeEnabled(false)}
|
||
>
|
||
Nein, überspringen
|
||
</button>
|
||
</div>
|
||
<div className={styles.stepNavLeft}>
|
||
<button type="button" className={styles.navBack} onClick={() => setStep(0)}>
|
||
Zurück
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- Step 2: Preferences ---- */}
|
||
{state.step === 2 && (
|
||
<div className={styles.stepContent}>
|
||
<h3 className={styles.stepTitle}>Einstellungen</h3>
|
||
<p className={styles.stepHint}>
|
||
Steuere, welche Inhalte und in welcher Form sie indexiert werden.
|
||
</p>
|
||
|
||
<div className={styles.prefGroup}>
|
||
<label className={styles.prefLabel}>
|
||
<FaShieldAlt className={styles.prefIcon} />
|
||
Anonymisierung vor dem Indexieren
|
||
<input
|
||
type="checkbox"
|
||
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}>
|
||
<button type="button" className={styles.navBack} onClick={() => setStep(1)}>
|
||
Zurück
|
||
</button>
|
||
<button type="button" className={styles.navNext} onClick={() => setStep(3)}>
|
||
Weiter <FaArrowRight size={12} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---- Step 3: Summary ---- */}
|
||
{state.step === 3 && (
|
||
<div className={styles.stepContent}>
|
||
<h3 className={styles.stepTitle}>Zusammenfassung</h3>
|
||
<div className={styles.summary}>
|
||
<div className={styles.summaryRow}>
|
||
<span className={styles.summaryKey}>Anbieter</span>
|
||
<span className={styles.summaryVal}>
|
||
{CONNECTOR_ICONS[state.connector!]}
|
||
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
|
||
</span>
|
||
</div>
|
||
<div className={styles.summaryRow}>
|
||
<span className={styles.summaryKey}>Wissensdatenbank</span>
|
||
<span className={styles.summaryVal}>
|
||
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
|
||
</span>
|
||
</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>
|
||
|
||
{/* 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}>
|
||
<button
|
||
type="button"
|
||
className={styles.navBack}
|
||
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
|
||
>
|
||
Zurück
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={styles.navConnect}
|
||
onClick={handleConnect}
|
||
disabled={isConnecting}
|
||
>
|
||
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}
|
||
{!isConnecting && <FaArrowRight size={12} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default AddConnectionWizard;
|