All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m22s
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
/**
|
|
* DataSourceSettingsModal
|
|
*
|
|
* Single modal for editing DataSource-scoped + Connection-scoped settings
|
|
* from the UDB tree (Settings ⚙️ icon). Three sections:
|
|
*
|
|
* 1. Connection — knowledgeIngestionEnabled master switch + mail/clickup prefs
|
|
* 2. DataSource RAG-Limits — maxBytes/maxFileSize/maxItems/maxDepth (or clickup variants)
|
|
* 3. Cost estimate — indicative, non-binding CHF figure
|
|
*
|
|
* Why a single modal:
|
|
* - The architectural rule is "no icon inflation in the UDB". One ⚙️ opens
|
|
* the only place where ANY setting for a node is managed.
|
|
*
|
|
* Why both scopes in one modal:
|
|
* - Editing a DataSource without seeing whether the parent Connection's
|
|
* master switch is on is confusing. Surface both, with a clear visual
|
|
* separation between Connection vs. DataSource sections.
|
|
*/
|
|
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { FaTimes, FaToggleOn, FaToggleOff } from 'react-icons/fa';
|
|
import { useApiRequest } from '../../hooks/useApi';
|
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
|
import { useConfirm } from '../../hooks/useConfirm';
|
|
import {
|
|
patchDataSourceSettings,
|
|
getDataSourceCostEstimate,
|
|
patchKnowledgeConsent,
|
|
type RagLimits,
|
|
type CostEstimate,
|
|
} from '../../api/connectionApi';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
title: string;
|
|
dataSourceId?: string;
|
|
connectionId?: string;
|
|
initialKnowledgeIngestionEnabled?: boolean;
|
|
initialRagLimits?: RagLimits | null;
|
|
/**
|
|
* When false the RAG-Limits and Cost-Estimate sections are hidden.
|
|
* Only the DataSource-Root (Level 2 in the UDB tree) should show RAG
|
|
* settings — sub-elements inherit their parent's limits via the walker.
|
|
*/
|
|
showRagSection?: boolean;
|
|
/** Triggered after a successful save so the parent can refetch its lists. */
|
|
onSaved?: () => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const _CLICKUP_KEYS: (keyof RagLimits)[] = ['maxTasks', 'maxWorkspaces', 'maxListsPerWorkspace'];
|
|
const _FILES_KEYS: (keyof RagLimits)[] = ['maxItems', 'maxBytes', 'maxFileSize', 'maxDepth'];
|
|
|
|
function _isByteLimit(key: keyof RagLimits): boolean {
|
|
return key === 'maxBytes' || key === 'maxFileSize';
|
|
}
|
|
|
|
function _displayValue(key: keyof RagLimits, value: number | undefined): string {
|
|
if (value == null) return '';
|
|
if (_isByteLimit(key)) {
|
|
return String(Math.round(value / 1024 / 1024));
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function _parseInput(key: keyof RagLimits, raw: string): number | null {
|
|
if (raw == null || raw === '') return null;
|
|
const n = Number(raw);
|
|
if (!Number.isFinite(n) || n <= 0) return null;
|
|
return _isByteLimit(key) ? Math.round(n * 1024 * 1024) : Math.round(n);
|
|
}
|
|
|
|
function _labelFor(key: keyof RagLimits, t: (k: string) => string): string {
|
|
switch (key) {
|
|
case 'maxBytes': return t('Max. Datenvolumen (MB)');
|
|
case 'maxFileSize': return t('Max. Dateigrösse (MB)');
|
|
case 'maxItems': return t('Max. Dateien (Anzahl)');
|
|
case 'maxDepth': return t('Max. Ordnertiefe');
|
|
case 'maxTasks': return t('Max. Tasks (Anzahl)');
|
|
case 'maxWorkspaces': return t('Max. Workspaces');
|
|
case 'maxListsPerWorkspace':return t('Max. Listen pro Workspace');
|
|
default: return String(key);
|
|
}
|
|
}
|
|
|
|
export const DataSourceSettingsModal: React.FC<Props> = ({
|
|
open, title, dataSourceId, connectionId,
|
|
initialKnowledgeIngestionEnabled, initialRagLimits, showRagSection = true,
|
|
onSaved, onClose,
|
|
}) => {
|
|
const { t } = useLanguage();
|
|
const { request } = useApiRequest();
|
|
const { confirm, ConfirmDialog } = useConfirm();
|
|
|
|
const [knowledgeOn, setKnowledgeOn] = useState<boolean>(!!initialKnowledgeIngestionEnabled);
|
|
const [ragLimits, setRagLimits] = useState<RagLimits>(initialRagLimits || {});
|
|
const [cost, setCost] = useState<CostEstimate | null>(null);
|
|
const [costLoading, setCostLoading] = useState<boolean>(false);
|
|
const [saving, setSaving] = useState<boolean>(false);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
|
|
const limitKeys: (keyof RagLimits)[] = useMemo(() => {
|
|
if (cost?.basis?.kind === 'clickup') return _CLICKUP_KEYS;
|
|
if (ragLimits.maxTasks != null) return _CLICKUP_KEYS;
|
|
return _FILES_KEYS;
|
|
}, [cost, ragLimits]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setKnowledgeOn(!!initialKnowledgeIngestionEnabled);
|
|
setRagLimits(initialRagLimits || {});
|
|
setErrorMsg(null);
|
|
setCost(null);
|
|
if (!dataSourceId) return;
|
|
setCostLoading(true);
|
|
getDataSourceCostEstimate(request, dataSourceId)
|
|
.then(result => {
|
|
setCost(result);
|
|
if (Object.keys(initialRagLimits || {}).length === 0 && result?.basis?.limits) {
|
|
setRagLimits(result.basis.limits as RagLimits);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
setErrorMsg(typeof err === 'string' ? err : (err?.message || t('Kostenschätzung konnte nicht geladen werden.')));
|
|
})
|
|
.finally(() => setCostLoading(false));
|
|
}, [open, dataSourceId, initialKnowledgeIngestionEnabled, initialRagLimits, request, t]);
|
|
|
|
if (!open) return null;
|
|
|
|
const _handleConsentToggle = async () => {
|
|
if (!connectionId) return;
|
|
const newValue = !knowledgeOn;
|
|
if (!newValue) {
|
|
const ok = await confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'), { variant: 'danger', confirmLabel: t('Fortfahren') });
|
|
if (!ok) return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await patchKnowledgeConsent(request, connectionId, newValue);
|
|
setKnowledgeOn(newValue);
|
|
onSaved?.();
|
|
} catch (err: any) {
|
|
setErrorMsg(err?.message || t('Master-Switch konnte nicht geändert werden.'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const _handleLimitChange = (key: keyof RagLimits, raw: string) => {
|
|
setRagLimits(prev => {
|
|
const next = { ...prev };
|
|
if (raw === '') { delete next[key]; return next; }
|
|
const parsed = _parseInput(key, raw);
|
|
if (parsed == null) return prev;
|
|
next[key] = parsed;
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const _handleSaveLimits = async () => {
|
|
if (!dataSourceId) return;
|
|
setSaving(true);
|
|
setErrorMsg(null);
|
|
try {
|
|
const cleaned: RagLimits = {};
|
|
for (const k of limitKeys) {
|
|
const v = ragLimits[k];
|
|
if (v != null) cleaned[k] = v;
|
|
}
|
|
await patchDataSourceSettings(request, dataSourceId, { ragLimits: cleaned });
|
|
const refreshed = await getDataSourceCostEstimate(request, dataSourceId);
|
|
setCost(refreshed);
|
|
onSaved?.();
|
|
} catch (err: any) {
|
|
setErrorMsg(err?.message || t('Speichern fehlgeschlagen.'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<ConfirmDialog />
|
|
<div
|
|
onClick={onClose}
|
|
style={{
|
|
position: 'fixed', inset: 0, zIndex: 1000,
|
|
background: 'rgba(0,0,0,0.45)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}
|
|
>
|
|
<div
|
|
onClick={e => e.stopPropagation()}
|
|
style={{
|
|
background: '#fff', borderRadius: 8, padding: 0,
|
|
width: 'min(540px, 92vw)', maxHeight: '85vh', display: 'flex', flexDirection: 'column',
|
|
boxShadow: '0 12px 40px rgba(0,0,0,0.2)',
|
|
}}
|
|
>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '14px 18px', borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
|
}}>
|
|
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>
|
|
{'\u2699\uFE0F '}{t('Einstellungen')} — {title}
|
|
</h3>
|
|
<button
|
|
onClick={onClose}
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 16, color: '#666' }}
|
|
title={t('Schliessen')}
|
|
>
|
|
<FaTimes />
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ padding: 18, overflowY: 'auto' }}>
|
|
{errorMsg && (
|
|
<div style={{
|
|
background: '#fef2f2', color: '#991b1b', padding: '8px 12px',
|
|
borderRadius: 4, marginBottom: 14, fontSize: 13,
|
|
}}>{errorMsg}</div>
|
|
)}
|
|
|
|
{/* --- Section: Connection --- */}
|
|
{connectionId && (
|
|
<section style={{ marginBottom: 22 }}>
|
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, fontWeight: 700, color: '#666', textTransform: 'uppercase' }}>
|
|
{t('Verbindung')}
|
|
</h4>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 0' }}>
|
|
<div>
|
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('Wissensdatenbank aktiv')}</div>
|
|
<div style={{ fontSize: 11, color: '#777' }}>
|
|
{t('Master-Schalter — wirkt auf ALLE Datenquellen dieser Verbindung.')}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={_handleConsentToggle}
|
|
disabled={saving}
|
|
style={{
|
|
background: 'none', border: 'none', cursor: saving ? 'wait' : 'pointer',
|
|
fontSize: 22, color: knowledgeOn ? 'var(--primary-color, #F25843)' : '#999',
|
|
}}
|
|
title={knowledgeOn ? t('Wissensdatenbank deaktivieren') : t('Wissensdatenbank aktivieren')}
|
|
>
|
|
{knowledgeOn ? <FaToggleOn /> : <FaToggleOff />}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* --- Section: RAG Limits (only on DataSource-Root, not sub-elements) --- */}
|
|
{dataSourceId && showRagSection && (
|
|
<section style={{ marginBottom: 22 }}>
|
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, fontWeight: 700, color: '#666', textTransform: 'uppercase' }}>
|
|
{t('RAG-Indexierungs-Limits')}
|
|
</h4>
|
|
<div style={{ fontSize: 11, color: '#777', marginBottom: 10 }}>
|
|
{t('Walker stoppt bei den ersten erreichten Limit. Defaults greifen, wenn ein Feld leer ist.')}
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px', gap: 8, alignItems: 'center' }}>
|
|
{limitKeys.map(key => (
|
|
<React.Fragment key={key}>
|
|
<label style={{ fontSize: 13 }}>{_labelFor(key, t)}</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
value={_displayValue(key, ragLimits[key])}
|
|
onChange={e => _handleLimitChange(key, e.target.value)}
|
|
placeholder={cost?.basis?.limits?.[key] != null ? _displayValue(key, cost.basis.limits[key]) : ''}
|
|
style={{
|
|
padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4,
|
|
textAlign: 'right',
|
|
}}
|
|
/>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
<div style={{ marginTop: 10, textAlign: 'right' }}>
|
|
<button
|
|
onClick={_handleSaveLimits}
|
|
disabled={saving}
|
|
style={{
|
|
background: 'var(--primary-color, #F25843)', color: '#fff', border: 'none',
|
|
padding: '7px 14px', borderRadius: 4, cursor: saving ? 'wait' : 'pointer', fontSize: 13,
|
|
}}
|
|
>
|
|
{saving ? t('Speichern…') : t('Limits speichern')}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* --- Section: Cost estimate (only on DataSource-Root) --- */}
|
|
{dataSourceId && showRagSection && (
|
|
<section>
|
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, fontWeight: 700, color: '#666', textTransform: 'uppercase' }}>
|
|
{t('Kostenschätzung (indikativ)')}
|
|
</h4>
|
|
{costLoading && <div style={{ fontSize: 12, color: '#999' }}>{t('Wird berechnet…')}</div>}
|
|
{!costLoading && cost && (
|
|
<div style={{
|
|
background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 4, padding: '10px 12px',
|
|
}}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
<span style={{ fontSize: 13 }}>{t('Voll-Sync (geschätzt)')}</span>
|
|
<span style={{ fontSize: 16, fontWeight: 600 }}>~ {cost.estimatedChf.toFixed(4)} CHF</span>
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#777', marginTop: 4 }}>
|
|
~ {cost.estimatedTokens.toLocaleString()} {t('Tokens')} · {cost.basis.notes}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default DataSourceSettingsModal;
|