fixed toggle icons udb
This commit is contained in:
parent
f37774ff36
commit
65170d9e4c
8 changed files with 752 additions and 135 deletions
|
|
@ -324,11 +324,60 @@ export async function postKnowledgeStop(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RagLimits {
|
||||||
|
maxItems?: number;
|
||||||
|
maxBytes?: number;
|
||||||
|
maxFileSize?: number;
|
||||||
|
maxDepth?: number;
|
||||||
|
// ClickUp variant
|
||||||
|
maxTasks?: number;
|
||||||
|
maxWorkspaces?: number;
|
||||||
|
maxListsPerWorkspace?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataSourceSettings {
|
||||||
|
ragLimits?: RagLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostEstimate {
|
||||||
|
estimatedTokens: number;
|
||||||
|
estimatedUsd: number;
|
||||||
|
basis: {
|
||||||
|
kind: string;
|
||||||
|
limits: Record<string, number>;
|
||||||
|
assumptions: Record<string, any>;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
sourceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchDataSourceSettings(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
dataSourceId: string,
|
||||||
|
settings: DataSourceSettings
|
||||||
|
): Promise<{ sourceId: string; settings: DataSourceSettings; updated: boolean }> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/datasources/${dataSourceId}/settings`,
|
||||||
|
method: 'patch',
|
||||||
|
data: { settings }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDataSourceCostEstimate(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
dataSourceId: string
|
||||||
|
): Promise<CostEstimate> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/datasources/${dataSourceId}/cost-estimate`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function patchDataSourceRagIndex(
|
export async function patchDataSourceRagIndex(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
dataSourceId: string,
|
dataSourceId: string,
|
||||||
ragIndexEnabled: boolean
|
ragIndexEnabled: boolean | null
|
||||||
): Promise<{ sourceId: string; ragIndexEnabled: boolean; updated: boolean }> {
|
): Promise<{ sourceId: string; ragIndexEnabled: boolean | null; updated: boolean; cascadedDescendants?: number }> {
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/datasources/${dataSourceId}/rag-index`,
|
url: `/api/datasources/${dataSourceId}/rag-index`,
|
||||||
method: 'patch',
|
method: 'patch',
|
||||||
|
|
@ -345,8 +394,9 @@ export interface RagDataSourceDto {
|
||||||
label: string;
|
label: string;
|
||||||
path: string;
|
path: string;
|
||||||
sourceType: string;
|
sourceType: string;
|
||||||
ragIndexEnabled: boolean;
|
/** Three-state inherit semantics on backend; UI reads as effective boolean from RAG inventory aggregator. */
|
||||||
neutralize: boolean;
|
ragIndexEnabled: boolean | null;
|
||||||
|
neutralize: boolean | null;
|
||||||
lastIndexed: number | null;
|
lastIndexed: number | null;
|
||||||
/** Distinct files indexed for this DataSource (one row per source document). */
|
/** Distinct files indexed for this DataSource (one row per source document). */
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
|
|
@ -363,7 +413,12 @@ export interface RagConnectionDto {
|
||||||
dataSources: RagDataSourceDto[];
|
dataSources: RagDataSourceDto[];
|
||||||
totalFiles: number;
|
totalFiles: number;
|
||||||
totalChunks: number;
|
totalChunks: number;
|
||||||
runningJobs: { jobId: string; progress: number; progressMessage: string }[];
|
runningJobs: {
|
||||||
|
jobId: string;
|
||||||
|
progress: number;
|
||||||
|
/** Already translated server-side. */
|
||||||
|
progressMessage: string;
|
||||||
|
}[];
|
||||||
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
||||||
lastSuccess?: {
|
lastSuccess?: {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
|
@ -392,6 +447,7 @@ export interface RagActiveJobDto {
|
||||||
connectionLabel?: string;
|
connectionLabel?: string;
|
||||||
jobType: string;
|
jobType: string;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
|
/** Already translated server-side. */
|
||||||
progressMessage: string;
|
progressMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -864,7 +864,14 @@ export async function syncPositionsToAccounting(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
positionIds: string[],
|
positionIds: string[],
|
||||||
opts?: { pollMs?: number; onProgress?: (progress: number, message?: string | null) => void }
|
opts?: {
|
||||||
|
pollMs?: number;
|
||||||
|
/**
|
||||||
|
* `message` is already translated server-side by the job route handler
|
||||||
|
* (`resolveJobMessage`). Render it 1:1; never feed it through `t()`.
|
||||||
|
*/
|
||||||
|
onProgress?: (progress: number, message?: string | null) => void;
|
||||||
|
}
|
||||||
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
|
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
|
||||||
const submission = await request({
|
const submission = await request({
|
||||||
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
|
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface _RagJob {
|
||||||
connectionLabel?: string;
|
connectionLabel?: string;
|
||||||
jobType: string;
|
jobType: string;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
|
/** Already translated server-side (route handler runs `resolveJobMessage`). */
|
||||||
progressMessage: string;
|
progressMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
320
src/components/UnifiedDataBar/DataSourceSettingsModal.tsx
Normal file
320
src/components/UnifiedDataBar/DataSourceSettingsModal.tsx
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
/**
|
||||||
|
* 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 USD 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 {
|
||||||
|
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 [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 = window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. 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 (
|
||||||
|
<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.estimatedUsd.toFixed(4)} USD</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;
|
||||||
|
|
@ -28,6 +28,7 @@ import type { UdbContext } from './UnifiedDataBar';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { getPageIcon } from '../../config/pageRegistry';
|
import { getPageIcon } from '../../config/pageRegistry';
|
||||||
import styles from './SourcesTab.module.css';
|
import styles from './SourcesTab.module.css';
|
||||||
|
import { DataSourceSettingsModal } from './DataSourceSettingsModal';
|
||||||
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaEnvelope, FaComments, FaCalendarAlt, FaUser, FaCloudUploadAlt } from 'react-icons/fa';
|
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaEnvelope, FaComments, FaCalendarAlt, FaUser, FaCloudUploadAlt } from 'react-icons/fa';
|
||||||
import { SiJira } from 'react-icons/si';
|
import { SiJira } from 'react-icons/si';
|
||||||
|
|
||||||
|
|
@ -42,9 +43,13 @@ interface UdbDataSource {
|
||||||
path: string;
|
path: string;
|
||||||
label: string;
|
label: string;
|
||||||
displayPath?: string;
|
displayPath?: string;
|
||||||
scope: string;
|
/** Three-state cascade-inherit. null = inherit from nearest ancestor. */
|
||||||
neutralize: boolean;
|
scope: string | null;
|
||||||
ragIndexEnabled?: boolean;
|
/** Three-state cascade-inherit. null = inherit. */
|
||||||
|
neutralize: boolean | null;
|
||||||
|
/** Three-state cascade-inherit. null = inherit. */
|
||||||
|
ragIndexEnabled: boolean | null;
|
||||||
|
settings?: Record<string, any> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UdbFeatureDataSource {
|
interface UdbFeatureDataSource {
|
||||||
|
|
@ -54,8 +59,10 @@ interface UdbFeatureDataSource {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
objectKey: string;
|
objectKey: string;
|
||||||
label: string;
|
label: string;
|
||||||
scope: string;
|
/** Three-state cascade-inherit. null = inherit from nearest ancestor FDS. */
|
||||||
neutralize: boolean;
|
scope: string | null;
|
||||||
|
/** Three-state cascade-inherit. null = inherit. */
|
||||||
|
neutralize: boolean | null;
|
||||||
neutralizeFields?: string[];
|
neutralizeFields?: string[];
|
||||||
recordFilter?: Record<string, string>;
|
recordFilter?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +80,7 @@ interface TreeNode {
|
||||||
path?: string;
|
path?: string;
|
||||||
displayPath?: string;
|
displayPath?: string;
|
||||||
authority?: string;
|
authority?: string;
|
||||||
|
knowledgeIngestionEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FeatureConnectionNode {
|
interface FeatureConnectionNode {
|
||||||
|
|
@ -450,6 +458,16 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
||||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
const lastClickedKeyRef = useRef<string | null>(null);
|
const lastClickedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
/* ── DataSource Settings modal ── */
|
||||||
|
const [settingsModal, setSettingsModal] = useState<{
|
||||||
|
dataSourceId?: string;
|
||||||
|
connectionId?: string;
|
||||||
|
title: string;
|
||||||
|
initialKnowledgeIngestionEnabled?: boolean;
|
||||||
|
initialRagLimits?: any;
|
||||||
|
showRagSection?: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const _flattenVisibleKeys = useCallback((nodes: TreeNode[]): string[] => {
|
const _flattenVisibleKeys = useCallback((nodes: TreeNode[]): string[] => {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
|
|
@ -499,8 +517,10 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
||||||
path: d.path,
|
path: d.path,
|
||||||
label: d.label,
|
label: d.label,
|
||||||
displayPath: d.displayPath,
|
displayPath: d.displayPath,
|
||||||
scope: d.scope || 'personal',
|
scope: d.scope ?? null,
|
||||||
neutralize: d.neutralize ?? false,
|
neutralize: d.neutralize ?? null,
|
||||||
|
ragIndexEnabled: d.ragIndexEnabled ?? null,
|
||||||
|
settings: d.settings ?? null,
|
||||||
}));
|
}));
|
||||||
list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||||
setDataSources(list);
|
setDataSources(list);
|
||||||
|
|
@ -521,8 +541,8 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
||||||
tableName: d.tableName,
|
tableName: d.tableName,
|
||||||
objectKey: d.objectKey,
|
objectKey: d.objectKey,
|
||||||
label: d.label,
|
label: d.label,
|
||||||
scope: d.scope || 'personal',
|
scope: d.scope ?? null,
|
||||||
neutralize: d.neutralize ?? false,
|
neutralize: d.neutralize ?? null,
|
||||||
neutralizeFields: d.neutralizeFields || undefined,
|
neutralizeFields: d.neutralizeFields || undefined,
|
||||||
recordFilter: d.recordFilter || undefined,
|
recordFilter: d.recordFilter || undefined,
|
||||||
}));
|
}));
|
||||||
|
|
@ -555,6 +575,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
||||||
children: null,
|
children: null,
|
||||||
connectionId: c.id,
|
connectionId: c.id,
|
||||||
authority: c.authority,
|
authority: c.authority,
|
||||||
|
knowledgeIngestionEnabled: !!c.knowledgeIngestionEnabled,
|
||||||
}))
|
}))
|
||||||
.sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
.sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||||
setTree(nodes);
|
setTree(nodes);
|
||||||
|
|
@ -677,60 +698,118 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
||||||
}
|
}
|
||||||
}, [instanceId, dataSources, onAttachDataSource, _fetchDataSources, onSourcesChanged]);
|
}, [instanceId, dataSources, onAttachDataSource, _fetchDataSources, onSourcesChanged]);
|
||||||
|
|
||||||
/* ── Scope change (personal data source, optimistic) ── */
|
/* ── Node-based toggles (auto-create DS if missing) ────────────────────
|
||||||
const _cyclePersonalScope = useCallback(async (ds: UdbDataSource) => {
|
* Logik: Klick auf jedem Knoten togglt nur diesen Knoten. Children erben
|
||||||
const newScope = _nextScope(ds.scope);
|
* visuell. Wenn der Knoten noch keinen DataSource-Record hat, wird einer
|
||||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: newScope } : d));
|
* angelegt UND mit dem neuen Wert befüllt — atomar im UI-State, damit
|
||||||
try {
|
* keine Race-Condition zwischen POST/PATCH und UI-Refetch entsteht. */
|
||||||
await api.patch(`/api/datasources/${ds.id}/scope`, { scope: newScope });
|
/**
|
||||||
} catch {
|
* Toggle on a node:
|
||||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: ds.scope } : d));
|
* - Compute newValue = !currentEffective (inverse of what user sees right now).
|
||||||
|
* - PATCH the explicit value; backend cascades and resets explicit descendants
|
||||||
|
* of this node back to NULL (= inherit).
|
||||||
|
* - Local state: refetch after PATCH so cascade-reset descendants update.
|
||||||
|
*/
|
||||||
|
const _toggleNeutralizeOnNode = useCallback(async (node: TreeNode, currentEffective: boolean) => {
|
||||||
|
const newValue = !currentEffective;
|
||||||
|
let ds = _findDs(dataSources, node);
|
||||||
|
let dsId = ds?.id;
|
||||||
|
if (!dsId) {
|
||||||
|
const newId = await _addAsDataSource(node);
|
||||||
|
if (!newId) return;
|
||||||
|
dsId = newId;
|
||||||
}
|
}
|
||||||
}, []);
|
setDataSources(prev => prev.map(d => d.id === dsId ? { ...d, neutralize: newValue } : d));
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/datasources/${dsId}/neutralize`, { neutralize: newValue });
|
||||||
|
_fetchDataSources();
|
||||||
|
} catch {
|
||||||
|
_fetchDataSources();
|
||||||
|
}
|
||||||
|
}, [dataSources, _addAsDataSource, _fetchDataSources]);
|
||||||
|
|
||||||
/* ── Neutralize toggle (personal data source, optimistic) ── */
|
const _toggleRagIndexOnNode = useCallback(async (node: TreeNode, currentEffective: boolean) => {
|
||||||
const _togglePersonalNeutralize = useCallback(async (ds: UdbDataSource) => {
|
const newValue = !currentEffective;
|
||||||
const newValue = !ds.neutralize;
|
let ds = _findDs(dataSources, node);
|
||||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: newValue } : d));
|
let dsId = ds?.id;
|
||||||
try {
|
if (!dsId) {
|
||||||
await api.patch(`/api/datasources/${ds.id}/neutralize`, { neutralize: newValue });
|
const newId = await _addAsDataSource(node);
|
||||||
} catch {
|
if (!newId) return;
|
||||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: ds.neutralize } : d));
|
dsId = newId;
|
||||||
}
|
}
|
||||||
}, []);
|
setDataSources(prev => prev.map(d => d.id === dsId ? { ...d, ragIndexEnabled: newValue } : d));
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/datasources/${dsId}/rag-index`, { ragIndexEnabled: newValue });
|
||||||
|
_fetchDataSources();
|
||||||
|
} catch {
|
||||||
|
_fetchDataSources();
|
||||||
|
}
|
||||||
|
}, [dataSources, _addAsDataSource, _fetchDataSources]);
|
||||||
|
|
||||||
/* ── RAG-Index toggle (personal data source, optimistic) ── */
|
const _cycleScopeOnNode = useCallback(async (node: TreeNode, currentEffective: string | undefined) => {
|
||||||
const _togglePersonalRagIndex = useCallback(async (ds: UdbDataSource) => {
|
const newScope = _nextScope(currentEffective || 'personal');
|
||||||
const newValue = !ds.ragIndexEnabled;
|
let ds = _findDs(dataSources, node);
|
||||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: newValue } : d));
|
let dsId = ds?.id;
|
||||||
try {
|
if (!dsId) {
|
||||||
await api.patch(`/api/datasources/${ds.id}/rag-index`, { ragIndexEnabled: newValue });
|
const newId = await _addAsDataSource(node);
|
||||||
} catch {
|
if (!newId) return;
|
||||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: ds.ragIndexEnabled } : d));
|
dsId = newId;
|
||||||
}
|
}
|
||||||
}, []);
|
setDataSources(prev => prev.map(d => d.id === dsId ? { ...d, scope: newScope } : d));
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/datasources/${dsId}/scope`, { scope: newScope });
|
||||||
|
_fetchDataSources();
|
||||||
|
} catch {
|
||||||
|
_fetchDataSources();
|
||||||
|
}
|
||||||
|
}, [dataSources, _addAsDataSource, _fetchDataSources]);
|
||||||
|
|
||||||
|
const _openSettingsForNode = useCallback(async (node: TreeNode) => {
|
||||||
|
const ds = _findDs(dataSources, node);
|
||||||
|
let dataSourceId = ds?.id;
|
||||||
|
if (!dataSourceId && node.type !== 'connection') {
|
||||||
|
const ensured = await _addAsDataSource(node);
|
||||||
|
if (ensured) dataSourceId = ensured;
|
||||||
|
}
|
||||||
|
const connNode = tree.find(n => n.connectionId === node.connectionId);
|
||||||
|
// RAG-Limits only on DataSource-Root (Level 2 = service node).
|
||||||
|
// Sub-elements (folder/file) inherit their parent's walker limits.
|
||||||
|
const isDataSourceRoot = node.type === 'service';
|
||||||
|
setSettingsModal({
|
||||||
|
dataSourceId,
|
||||||
|
connectionId: node.connectionId,
|
||||||
|
title: node.label || node.connectionId || t('Einstellungen'),
|
||||||
|
initialKnowledgeIngestionEnabled: connNode?.knowledgeIngestionEnabled ?? false,
|
||||||
|
initialRagLimits: (ds?.settings?.ragLimits ?? null) as any,
|
||||||
|
showRagSection: isDataSourceRoot,
|
||||||
|
});
|
||||||
|
}, [dataSources, tree, _addAsDataSource, t]);
|
||||||
|
|
||||||
/* ── Scope change (feature data source, optimistic) ── */
|
/* ── Scope change (feature data source, optimistic) ── */
|
||||||
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
|
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource, currentEffective?: string) => {
|
||||||
const newScope = _nextScope(fds.scope);
|
const baseline = currentEffective || fds.scope || 'personal';
|
||||||
|
const newScope = _nextScope(baseline);
|
||||||
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d));
|
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d));
|
||||||
try {
|
try {
|
||||||
await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope });
|
await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope });
|
||||||
|
_fetchFeatureDataSources();
|
||||||
} catch {
|
} catch {
|
||||||
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: fds.scope } : d));
|
_fetchFeatureDataSources();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [_fetchFeatureDataSources]);
|
||||||
|
|
||||||
/* ── Neutralize toggle (feature data source, optimistic) ── */
|
/* ── Neutralize toggle (feature data source, optimistic) ── */
|
||||||
const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource) => {
|
const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource, currentEffective?: boolean) => {
|
||||||
const newValue = !fds.neutralize;
|
const baseline = currentEffective !== undefined ? currentEffective : !!fds.neutralize;
|
||||||
|
const newValue = !baseline;
|
||||||
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d));
|
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d));
|
||||||
try {
|
try {
|
||||||
await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue });
|
await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue });
|
||||||
|
_fetchFeatureDataSources();
|
||||||
} catch {
|
} catch {
|
||||||
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: fds.neutralize } : d));
|
_fetchFeatureDataSources();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [_fetchFeatureDataSources]);
|
||||||
|
|
||||||
/* ── Neutralize fields toggle (field-level, optimistic) ── */
|
/* ── Neutralize fields toggle (field-level, optimistic) ── */
|
||||||
const _toggleNeutralizeField = useCallback(async (fds: UdbFeatureDataSource, fieldName: string) => {
|
const _toggleNeutralizeField = useCallback(async (fds: UdbFeatureDataSource, fieldName: string) => {
|
||||||
|
|
@ -1033,13 +1112,13 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
||||||
node={node}
|
node={node}
|
||||||
depth={0}
|
depth={0}
|
||||||
onToggle={_toggleNode}
|
onToggle={_toggleNode}
|
||||||
onEnsureDs={_addAsDataSource}
|
|
||||||
isAdded={_isAdded}
|
isAdded={_isAdded}
|
||||||
addingPath={addingPath}
|
addingPath={addingPath}
|
||||||
dataSources={dataSources}
|
dataSources={dataSources}
|
||||||
onCycleScope={_cyclePersonalScope}
|
onCycleScopeOnNode={_cycleScopeOnNode}
|
||||||
onToggleNeutralize={_togglePersonalNeutralize}
|
onToggleNeutralizeOnNode={_toggleNeutralizeOnNode}
|
||||||
onToggleRagIndex={_togglePersonalRagIndex}
|
onToggleRagIndexOnNode={_toggleRagIndexOnNode}
|
||||||
|
onOpenSettings={_openSettingsForNode}
|
||||||
onSendToChat={_sendNodeToChat}
|
onSendToChat={_sendNodeToChat}
|
||||||
scopeCycleTitle={_scopeCycleTitle}
|
scopeCycleTitle={_scopeCycleTitle}
|
||||||
selectedKeys={selectedKeys}
|
selectedKeys={selectedKeys}
|
||||||
|
|
@ -1102,6 +1181,21 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
||||||
featureTree={featureTree}
|
featureTree={featureTree}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<DataSourceSettingsModal
|
||||||
|
open={!!settingsModal}
|
||||||
|
title={settingsModal?.title || ''}
|
||||||
|
dataSourceId={settingsModal?.dataSourceId}
|
||||||
|
connectionId={settingsModal?.connectionId}
|
||||||
|
initialKnowledgeIngestionEnabled={settingsModal?.initialKnowledgeIngestionEnabled}
|
||||||
|
initialRagLimits={settingsModal?.initialRagLimits}
|
||||||
|
showRagSection={settingsModal?.showRagSection ?? false}
|
||||||
|
onSaved={() => {
|
||||||
|
_loadConnections();
|
||||||
|
_fetchDataSources();
|
||||||
|
}}
|
||||||
|
onClose={() => setSettingsModal(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1109,25 +1203,79 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
||||||
/* ─── TreeNodeView (recursive — Browse Sources side) ─────────────────── */
|
/* ─── TreeNodeView (recursive — Browse Sources side) ─────────────────── */
|
||||||
|
|
||||||
function _findDs(dataSources: UdbDataSource[], node: TreeNode): UdbDataSource | undefined {
|
function _findDs(dataSources: UdbDataSource[], node: TreeNode): UdbDataSource | undefined {
|
||||||
const expectedSourceType = node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : undefined;
|
// Discriminator per node level:
|
||||||
|
// - connection (Level 1): sourceType = authority string ('msft', 'google', 'clickup', ...)
|
||||||
|
// - service / folder / file (Level 2+): sourceType from _SERVICE_TO_SOURCE_TYPE mapping
|
||||||
|
// sourceType is mandatory — otherwise a Level 1 connection DS would shadow Level 2
|
||||||
|
// service DS sharing the same connectionId+path='/'.
|
||||||
|
let expectedSourceType: string | undefined;
|
||||||
|
if (node.type === 'connection') {
|
||||||
|
expectedSourceType = node.authority || undefined;
|
||||||
|
} else if (node.service) {
|
||||||
|
expectedSourceType = _SERVICE_TO_SOURCE_TYPE[node.service] || node.service;
|
||||||
|
}
|
||||||
|
if (!expectedSourceType) return undefined;
|
||||||
return dataSources.find(ds =>
|
return dataSources.find(ds =>
|
||||||
ds.connectionId === node.connectionId &&
|
ds.connectionId === node.connectionId &&
|
||||||
ds.path === (node.path || '/') &&
|
ds.path === (node.path || '/') &&
|
||||||
(!expectedSourceType || ds.sourceType === expectedSourceType),
|
ds.sourceType === expectedSourceType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection-root DataSources carry the authority as their sourceType.
|
||||||
|
// They sit above all service DataSources of the same connection in the
|
||||||
|
// visual tree, so inheritance crosses sourceType for that specific case.
|
||||||
|
const _AUTHORITY_SOURCE_TYPES = new Set(['local', 'google', 'msft', 'clickup', 'infomaniak']);
|
||||||
|
|
||||||
|
/** Nearest ancestor DS in the connection — same-sourceType path-prefix first,
|
||||||
|
* connection-root (sourceType = authority, path='/') as the cross-tree fallback. */
|
||||||
|
function _findAncestorDs(dataSources: UdbDataSource[], ds: UdbDataSource): UdbDataSource | undefined {
|
||||||
|
const sameType = dataSources.filter(d =>
|
||||||
|
d.id !== ds.id &&
|
||||||
|
d.connectionId === ds.connectionId &&
|
||||||
|
d.sourceType === ds.sourceType &&
|
||||||
|
ds.path !== d.path &&
|
||||||
|
(d.path === '/' ? ds.path !== '/' : ds.path.startsWith(d.path + '/'))
|
||||||
|
);
|
||||||
|
sameType.sort((a, b) => b.path.length - a.path.length);
|
||||||
|
if (sameType[0]) return sameType[0];
|
||||||
|
|
||||||
|
const dsIsConnectionRoot = _AUTHORITY_SOURCE_TYPES.has(ds.sourceType) && ds.path === '/';
|
||||||
|
if (dsIsConnectionRoot) return undefined;
|
||||||
|
return dataSources.find(d =>
|
||||||
|
d.id !== ds.id &&
|
||||||
|
d.connectionId === ds.connectionId &&
|
||||||
|
d.path === '/' &&
|
||||||
|
_AUTHORITY_SOURCE_TYPES.has(d.sourceType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve effective value of a flag: own (if explicit) → ancestor chain → static default. */
|
||||||
|
function _effectiveFlag<K extends 'neutralize' | 'ragIndexEnabled' | 'scope'>(
|
||||||
|
ds: UdbDataSource | undefined,
|
||||||
|
dataSources: UdbDataSource[],
|
||||||
|
flag: K,
|
||||||
|
): UdbDataSource[K] {
|
||||||
|
const fallback = (flag === 'scope' ? 'personal' : false) as UdbDataSource[K];
|
||||||
|
if (!ds) return fallback;
|
||||||
|
const own = ds[flag];
|
||||||
|
if (own !== null && own !== undefined && own !== '') return own;
|
||||||
|
const ancestor = _findAncestorDs(dataSources, ds);
|
||||||
|
if (ancestor) return _effectiveFlag(ancestor, dataSources, flag);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
interface _TreeNodeViewProps {
|
interface _TreeNodeViewProps {
|
||||||
node: TreeNode;
|
node: TreeNode;
|
||||||
depth: number;
|
depth: number;
|
||||||
onToggle: (node: TreeNode) => void;
|
onToggle: (node: TreeNode) => void;
|
||||||
onEnsureDs: (node: TreeNode) => Promise<string | null>;
|
|
||||||
isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean;
|
isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean;
|
||||||
addingPath: string | null;
|
addingPath: string | null;
|
||||||
dataSources: UdbDataSource[];
|
dataSources: UdbDataSource[];
|
||||||
onCycleScope: (ds: UdbDataSource) => void;
|
onCycleScopeOnNode: (node: TreeNode, currentEffective: string | undefined) => void;
|
||||||
onToggleNeutralize: (ds: UdbDataSource) => void;
|
onToggleNeutralizeOnNode: (node: TreeNode, currentEffective: boolean) => void;
|
||||||
onToggleRagIndex: (ds: UdbDataSource) => void;
|
onToggleRagIndexOnNode: (node: TreeNode, currentEffective: boolean) => void;
|
||||||
|
onOpenSettings: (node: TreeNode) => 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>;
|
||||||
|
|
@ -1138,8 +1286,8 @@ interface _TreeNodeViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
node, depth, onToggle, onEnsureDs, isAdded, addingPath,
|
node, depth, onToggle, isAdded, addingPath,
|
||||||
dataSources, onCycleScope, onToggleNeutralize, onToggleRagIndex, onSendToChat, scopeCycleTitle,
|
dataSources, onCycleScopeOnNode, onToggleNeutralizeOnNode, onToggleRagIndexOnNode, onOpenSettings, onSendToChat, scopeCycleTitle,
|
||||||
selectedKeys, onSelect, inheritedScope, inheritedNeutralize, inheritedRagIndex,
|
selectedKeys, onSelect, inheritedScope, inheritedNeutralize, inheritedRagIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -1150,12 +1298,17 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
: '\u00A0\u00A0';
|
: '\u00A0\u00A0';
|
||||||
const ds = _findDs(dataSources, node);
|
const ds = _findDs(dataSources, node);
|
||||||
|
|
||||||
const effectiveScope = ds?.scope ?? inheritedScope;
|
// Effective values: own (if explicit) → DS-ancestor chain → tree-inherited (UI-only) → default.
|
||||||
const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false;
|
// Tree-inherited values cover the case where children don't have their own DS record yet.
|
||||||
const effectiveRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex ?? false;
|
const effectiveScope =
|
||||||
const childInheritedScope = ds?.scope ?? inheritedScope;
|
(ds && _effectiveFlag(ds, dataSources, 'scope')) ?? inheritedScope ?? 'personal';
|
||||||
const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize;
|
const effectiveNeutralize =
|
||||||
const childInheritedRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex;
|
(ds ? (_effectiveFlag(ds, dataSources, 'neutralize') as boolean) : undefined) ?? inheritedNeutralize ?? false;
|
||||||
|
const effectiveRagIndex =
|
||||||
|
(ds ? (_effectiveFlag(ds, dataSources, 'ragIndexEnabled') as boolean) : undefined) ?? inheritedRagIndex ?? false;
|
||||||
|
const childInheritedScope = effectiveScope;
|
||||||
|
const childInheritedNeutralize = effectiveNeutralize;
|
||||||
|
const childInheritedRagIndex = effectiveRagIndex;
|
||||||
|
|
||||||
const _dragPayload = {
|
const _dragPayload = {
|
||||||
connectionId: node.connectionId,
|
connectionId: node.connectionId,
|
||||||
|
|
@ -1239,14 +1392,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={(e) => { e.stopPropagation(); onToggleRagIndexOnNode(node, effectiveRagIndex); }}
|
||||||
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={{
|
style={{
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
||||||
|
|
@ -1256,6 +1402,17 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
>
|
>
|
||||||
{'\uD83E\uDDE0'}
|
{'\uD83E\uDDE0'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onOpenSettings(node); }}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
||||||
|
opacity: (ds?.settings && Object.keys(ds.settings).length > 0) ? 1 : (hovered ? 0.6 : 0.35),
|
||||||
|
}}
|
||||||
|
title={t('Einstellungen (RAG-Limits, Wissensdatenbank-Master)')}
|
||||||
|
>
|
||||||
|
{'\u2699\uFE0F'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
|
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1269,28 +1426,14 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
{'\u{1F4AC}'}
|
{'\u{1F4AC}'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={(e) => { e.stopPropagation(); onCycleScopeOnNode(node, effectiveScope); }}
|
||||||
e.stopPropagation();
|
|
||||||
if (ds) { onCycleScope(ds); return; }
|
|
||||||
const newId = await onEnsureDs(node);
|
|
||||||
if (newId) {
|
|
||||||
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: ds ? 1 : 0.35 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: ds ? 1 : 0.35 }}
|
||||||
title={ds ? scopeCycleTitle(ds.scope) : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
|
title={ds && ds.scope ? scopeCycleTitle(ds.scope) : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
|
||||||
>
|
>
|
||||||
{_SCOPE_ICONS[ds?.scope || effectiveScope || 'personal']}
|
{_SCOPE_ICONS[ds?.scope || effectiveScope || 'personal']}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={(e) => { e.stopPropagation(); onToggleNeutralizeOnNode(node, effectiveNeutralize); }}
|
||||||
e.stopPropagation();
|
|
||||||
if (ds) { onToggleNeutralize(ds); return; }
|
|
||||||
const newId = await onEnsureDs(node);
|
|
||||||
if (newId) {
|
|
||||||
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effectiveNeutralize }); } catch {}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
||||||
|
|
@ -1311,13 +1454,13 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
node={child}
|
node={child}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onEnsureDs={onEnsureDs}
|
|
||||||
isAdded={isAdded}
|
isAdded={isAdded}
|
||||||
addingPath={addingPath}
|
addingPath={addingPath}
|
||||||
dataSources={dataSources}
|
dataSources={dataSources}
|
||||||
onCycleScope={onCycleScope}
|
onCycleScopeOnNode={onCycleScopeOnNode}
|
||||||
onToggleNeutralize={onToggleNeutralize}
|
onToggleNeutralizeOnNode={onToggleNeutralizeOnNode}
|
||||||
onToggleRagIndex={onToggleRagIndex}
|
onToggleRagIndexOnNode={onToggleRagIndexOnNode}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
onSendToChat={onSendToChat}
|
onSendToChat={onSendToChat}
|
||||||
scopeCycleTitle={scopeCycleTitle}
|
scopeCycleTitle={scopeCycleTitle}
|
||||||
selectedKeys={selectedKeys}
|
selectedKeys={selectedKeys}
|
||||||
|
|
@ -1368,8 +1511,8 @@ interface _FeatureActionContext {
|
||||||
addingKey: string | null;
|
addingKey: string | null;
|
||||||
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
|
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
|
||||||
featureDataSources: UdbFeatureDataSource[];
|
featureDataSources: UdbFeatureDataSource[];
|
||||||
onCycleScope: (fds: UdbFeatureDataSource) => void;
|
onCycleScope: (fds: UdbFeatureDataSource, currentEffective?: string) => void;
|
||||||
onToggleNeutralize: (fds: UdbFeatureDataSource) => void;
|
onToggleNeutralize: (fds: UdbFeatureDataSource, currentEffective?: boolean) => void;
|
||||||
onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void;
|
onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void;
|
||||||
featureTree: MandateGroupNode[];
|
featureTree: MandateGroupNode[];
|
||||||
}
|
}
|
||||||
|
|
@ -1515,21 +1658,22 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
|
const effective = wildcardFds?.scope || 'personal';
|
||||||
|
if (wildcardFds) { ctx.onCycleScope(wildcardFds, effective); return; }
|
||||||
const newId = await ctx.onAddFeatureTable(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
|
const newId = await ctx.onAddFeatureTable(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
|
||||||
if (newId) {
|
if (newId) {
|
||||||
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope('personal') }); } catch {}
|
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effective) }); } catch {}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
|
||||||
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : t('Scope setzen')}
|
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope ?? 'personal'}` : t('Scope setzen')}
|
||||||
>
|
>
|
||||||
{_SCOPE_ICONS[wildcardFds?.scope || 'personal']}
|
{_SCOPE_ICONS[wildcardFds?.scope || 'personal']}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
|
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds, !!wildcardFds.neutralize); return; }
|
||||||
const newId = await ctx.onAddFeatureTable(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
|
const newId = await ctx.onAddFeatureTable(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
|
||||||
if (newId) {
|
if (newId) {
|
||||||
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
|
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
|
||||||
|
|
@ -1552,8 +1696,8 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => {
|
||||||
item={item}
|
item={item}
|
||||||
pathSegments={[]}
|
pathSegments={[]}
|
||||||
depth={1}
|
depth={1}
|
||||||
inheritedScope={wildcardFds?.scope}
|
inheritedScope={wildcardFds?.scope ?? undefined}
|
||||||
inheritedNeutralize={wildcardFds?.neutralize}
|
inheritedNeutralize={wildcardFds?.neutralize ?? undefined}
|
||||||
{...ctx}
|
{...ctx}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1726,30 +1870,32 @@ const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
|
const effective = (wildcardFds?.scope || inheritedScope || 'personal') as string;
|
||||||
|
if (wildcardFds) { ctx.onCycleScope(wildcardFds, effective); return; }
|
||||||
const newId = await ctx.onAddFeatureTable(
|
const newId = await ctx.onAddFeatureTable(
|
||||||
featureNode,
|
featureNode,
|
||||||
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
|
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
|
||||||
);
|
);
|
||||||
if (newId) {
|
if (newId) {
|
||||||
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || 'personal') }); } catch {}
|
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effective) }); } catch {}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
|
||||||
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
|
title={wildcardFds?.scope ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
|
||||||
>
|
>
|
||||||
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
|
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
|
const effective = !!(wildcardFds?.neutralize ?? inheritedNeutralize);
|
||||||
|
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds, effective); return; }
|
||||||
const newId = await ctx.onAddFeatureTable(
|
const newId = await ctx.onAddFeatureTable(
|
||||||
featureNode,
|
featureNode,
|
||||||
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
|
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
|
||||||
);
|
);
|
||||||
if (newId) {
|
if (newId) {
|
||||||
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
|
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effective }); } catch {}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
|
||||||
|
|
@ -1884,32 +2030,34 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
|
const effective = (wildcardFds?.scope || inheritedScope || 'personal') as string;
|
||||||
|
if (wildcardFds) { ctx.onCycleScope(wildcardFds, effective); return; }
|
||||||
const newId = await ctx.onAddFeatureTable(
|
const newId = await ctx.onAddFeatureTable(
|
||||||
featureNode,
|
featureNode,
|
||||||
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
|
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
|
||||||
{ labelOverride: _chatPayload.label },
|
{ labelOverride: _chatPayload.label },
|
||||||
);
|
);
|
||||||
if (newId) {
|
if (newId) {
|
||||||
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || 'personal') }); } catch {}
|
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effective) }); } catch {}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
|
||||||
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
|
title={wildcardFds?.scope ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
|
||||||
>
|
>
|
||||||
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
|
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
|
const effective = !!(wildcardFds?.neutralize ?? inheritedNeutralize);
|
||||||
|
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds, effective); return; }
|
||||||
const newId = await ctx.onAddFeatureTable(
|
const newId = await ctx.onAddFeatureTable(
|
||||||
featureNode,
|
featureNode,
|
||||||
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
|
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
|
||||||
{ labelOverride: _chatPayload.label },
|
{ labelOverride: _chatPayload.label },
|
||||||
);
|
);
|
||||||
if (newId) {
|
if (newId) {
|
||||||
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
|
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effective }); } catch {}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
|
||||||
|
|
@ -1977,6 +2125,11 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
|
||||||
&& f.recordFilter?.id === record.id,
|
&& f.recordFilter?.id === record.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Effective values: own explicit > inherited from parent FDS in tree.
|
||||||
|
// null on own fds means "inherit" (cascade-reset by backend).
|
||||||
|
const effectiveScope: string = (fds?.scope ?? inheritedScope ?? 'personal') as string;
|
||||||
|
const effectiveNeutralize: boolean = (fds?.neutralize ?? inheritedNeutralize ?? false) as boolean;
|
||||||
|
|
||||||
const childItems = useMemo(
|
const childItems = useMemo(
|
||||||
() => _childrenForRecord(featureNode.tables || [], parentTable.tableName),
|
() => _childrenForRecord(featureNode.tables || [], parentTable.tableName),
|
||||||
[featureNode.tables, parentTable.tableName],
|
[featureNode.tables, parentTable.tableName],
|
||||||
|
|
@ -2067,11 +2220,11 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
|
||||||
</button>
|
</button>
|
||||||
{fds ? (
|
{fds ? (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); ctx.onCycleScope(fds); }}
|
onClick={(e) => { e.stopPropagation(); ctx.onCycleScope(fds, effectiveScope); }}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center' }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center' }}
|
||||||
title={`${t('Bereich')}: ${fds.scope}`}
|
title={fds.scope ? `${t('Bereich')}: ${fds.scope}` : `${t('Geerbt')}: ${effectiveScope}`}
|
||||||
>
|
>
|
||||||
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
|
{_SCOPE_ICONS[effectiveScope]}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
|
|
@ -2083,9 +2236,9 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
|
||||||
)}
|
)}
|
||||||
{fds ? (
|
{fds ? (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); ctx.onToggleNeutralize(fds); }}
|
onClick={(e) => { e.stopPropagation(); ctx.onToggleNeutralize(fds, effectiveNeutralize); }}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds.neutralize ? 1 : 0.35 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: effectiveNeutralize ? 1 : 0.35 }}
|
||||||
title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
title={effectiveNeutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||||
>
|
>
|
||||||
{'\uD83D\uDD12'}
|
{'\uD83D\uDD12'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2108,8 +2261,8 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
|
||||||
item={sub}
|
item={sub}
|
||||||
pathSegments={segments}
|
pathSegments={segments}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
inheritedScope={fds?.scope ?? inheritedScope}
|
inheritedScope={effectiveScope}
|
||||||
inheritedNeutralize={fds?.neutralize ?? inheritedNeutralize}
|
inheritedNeutralize={effectiveNeutralize}
|
||||||
{...ctx}
|
{...ctx}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -2132,8 +2285,8 @@ interface _FeatureTableRowProps {
|
||||||
) => Promise<string | null>;
|
) => Promise<string | null>;
|
||||||
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
|
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
|
||||||
featureDataSources: UdbFeatureDataSource[];
|
featureDataSources: UdbFeatureDataSource[];
|
||||||
onCycleScope: (fds: UdbFeatureDataSource) => void;
|
onCycleScope: (fds: UdbFeatureDataSource, currentEffective?: string) => void;
|
||||||
onToggleNeutralize: (fds: UdbFeatureDataSource) => void;
|
onToggleNeutralize: (fds: UdbFeatureDataSource, currentEffective?: boolean) => void;
|
||||||
onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void;
|
onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void;
|
||||||
featureTree: MandateGroupNode[];
|
featureTree: MandateGroupNode[];
|
||||||
inheritedScope?: string;
|
inheritedScope?: string;
|
||||||
|
|
@ -2225,21 +2378,22 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (fds) { onCycleScope(fds); return; }
|
const effective = (effectiveScope || 'personal') as string;
|
||||||
|
if (fds) { onCycleScope(fds, effective); return; }
|
||||||
const newId = await onAddFeatureTable(featureNode, table);
|
const newId = await onAddFeatureTable(featureNode, table);
|
||||||
if (newId) {
|
if (newId) {
|
||||||
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
|
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effective) }); } catch {}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds ? 1 : 0.35 }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds ? 1 : 0.35 }}
|
||||||
title={fds ? `${t('Bereich')}: ${fds.scope}` : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
|
title={fds?.scope ? `${t('Bereich')}: ${fds.scope}` : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
|
||||||
>
|
>
|
||||||
{_SCOPE_ICONS[fds?.scope || effectiveScope || 'personal']}
|
{_SCOPE_ICONS[fds?.scope || effectiveScope || 'personal']}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (fds) { onToggleNeutralize(fds); return; }
|
if (fds) { onToggleNeutralize(fds, effectiveNeutralize); return; }
|
||||||
const newId = await onAddFeatureTable(featureNode, table);
|
const newId = await onAddFeatureTable(featureNode, table);
|
||||||
if (newId) {
|
if (newId) {
|
||||||
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effectiveNeutralize }); } catch {}
|
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effectiveNeutralize }); } catch {}
|
||||||
|
|
@ -2248,9 +2402,9 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
|
||||||
style={{
|
style={{
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
||||||
opacity: (fds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
|
opacity: effectiveNeutralize ? 1 : 0.35,
|
||||||
}}
|
}}
|
||||||
title={(fds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
title={effectiveNeutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||||
>
|
>
|
||||||
{'\uD83D\uDD12'}
|
{'\uD83D\uDD12'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ export interface BackgroundJob {
|
||||||
triggeredBy?: string | null;
|
triggeredBy?: string | null;
|
||||||
status: BackgroundJobStatus;
|
status: BackgroundJobStatus;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
/**
|
||||||
|
* Walker progress text, already translated by the route handler
|
||||||
|
* (`resolveJobMessage` server-side). Render 1:1 -- do NOT pass through
|
||||||
|
* `t()`; the i18n key lives in the backend, not in user code here.
|
||||||
|
*/
|
||||||
progressMessage?: string | null;
|
progressMessage?: string | null;
|
||||||
payload?: Record<string, any>;
|
payload?: Record<string, any>;
|
||||||
result?: Record<string, any> | null;
|
result?: Record<string, any> | null;
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useApiRequest } from '../hooks/useApi';
|
import { useApiRequest } from '../hooks/useApi';
|
||||||
import { useUserMandates } from '../hooks/useUserMandates';
|
import { useUserMandates } from '../hooks/useUserMandates';
|
||||||
import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi';
|
import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi';
|
||||||
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
|
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH } from 'react-icons/fa';
|
||||||
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
|
import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal';
|
||||||
import styles from './RagInventoryPage.module.css';
|
import styles from './RagInventoryPage.module.css';
|
||||||
|
|
||||||
export const RagInventoryPage: React.FC = () => {
|
export const RagInventoryPage: React.FC = () => {
|
||||||
|
|
@ -31,6 +32,23 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const [settingsModal, setSettingsModal] = useState<{
|
||||||
|
dataSourceId?: string;
|
||||||
|
connectionId?: string;
|
||||||
|
title: string;
|
||||||
|
initialKnowledgeIngestionEnabled?: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const _openSettingsForConnection = useCallback((conn: RagConnectionDto) => {
|
||||||
|
const activeDs = (conn.dataSources || []).find(ds => ds.ragIndexEnabled) || (conn.dataSources || [])[0];
|
||||||
|
setSettingsModal({
|
||||||
|
dataSourceId: activeDs?.id,
|
||||||
|
connectionId: conn.id,
|
||||||
|
title: `${conn.authority} · ${conn.externalEmail || conn.id}`,
|
||||||
|
initialKnowledgeIngestionEnabled: conn.knowledgeIngestionEnabled,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -296,8 +314,11 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
{' — '}
|
{' — '}
|
||||||
{t('Limit {l} erreicht', { l: limitText })}.
|
{t('Limit {l} erreicht', { l: limitText })}.
|
||||||
{stats && <> {stats}.</>}{' '}
|
{stats && <> {stats}.</>}{' '}
|
||||||
{t('Weitere Dateien wurden NICHT indexiert. Limit erhöhen oder DataSource enger eingrenzen, dann erneut starten.')}
|
{t('Weitere Dateien wurden NICHT indexiert.')}
|
||||||
</span>
|
</span>
|
||||||
|
<button className={styles.reindexBtn} onClick={() => _openSettingsForConnection(conn)} title={t('Limit für diese Datenquelle anpassen')}>
|
||||||
|
<FaSlidersH size={12} /> {t('Limit anpassen')}
|
||||||
|
</button>
|
||||||
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Erneut indexieren')}>
|
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Erneut indexieren')}>
|
||||||
<FaRedo size={12} /> {t('Erneut indexieren')}
|
<FaRedo size={12} /> {t('Erneut indexieren')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -358,6 +379,17 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DataSourceSettingsModal
|
||||||
|
open={!!settingsModal}
|
||||||
|
title={settingsModal?.title || ''}
|
||||||
|
dataSourceId={settingsModal?.dataSourceId}
|
||||||
|
connectionId={settingsModal?.connectionId}
|
||||||
|
initialKnowledgeIngestionEnabled={settingsModal?.initialKnowledgeIngestionEnabled}
|
||||||
|
showRagSection
|
||||||
|
onSaved={() => _fetchInventory()}
|
||||||
|
onClose={() => setSettingsModal(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,14 @@ 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, FaPlus, FaSpinner, FaTimes, FaSyncAlt } from 'react-icons/fa';
|
import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaToggleOn, FaToggleOff } from 'react-icons/fa';
|
||||||
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';
|
||||||
import type { ConnectorType } from '../../components/AddConnectionWizard/AddConnectionWizard';
|
import type { ConnectorType } from '../../components/AddConnectionWizard/AddConnectionWizard';
|
||||||
import type { KnowledgePreferences } from '../../api/connectionApi';
|
import { patchKnowledgeConsent, type KnowledgePreferences } from '../../api/connectionApi';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
import { getApiBaseUrl } from '../../../config/config';
|
import { getApiBaseUrl } from '../../../config/config';
|
||||||
|
|
||||||
|
|
@ -52,7 +53,10 @@ 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 [togglingConsent, setTogglingConsent] = useState<Set<string>>(new Set());
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
|
||||||
|
const { request } = useApiRequest();
|
||||||
// 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<{
|
||||||
connector: string;
|
connector: string;
|
||||||
|
|
@ -235,6 +239,28 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
window.open(url, 'msft-admin-consent', 'width=560,height=720,scrollbars=yes,resizable=yes');
|
window.open(url, 'msft-admin-consent', 'width=560,height=720,scrollbars=yes,resizable=yes');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConsentToggle = async (connection: Connection) => {
|
||||||
|
const currentEnabled = !!(connection as any).knowledgeIngestionEnabled;
|
||||||
|
const newEnabled = !currentEnabled;
|
||||||
|
if (currentEnabled) {
|
||||||
|
const ok = window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'));
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
setTogglingConsent(prev => new Set(prev).add(connection.id));
|
||||||
|
try {
|
||||||
|
await patchKnowledgeConsent(request, connection.id, newEnabled);
|
||||||
|
await refetch();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling knowledge consent:', error);
|
||||||
|
} finally {
|
||||||
|
setTogglingConsent(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(connection.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Form attributes for edit modal
|
// Form attributes for edit modal
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = [
|
const excludedFields = [
|
||||||
|
|
@ -344,6 +370,22 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
}] : []),
|
}] : []),
|
||||||
]}
|
]}
|
||||||
customActions={[
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'knowledge-consent-on',
|
||||||
|
icon: <FaToggleOn />,
|
||||||
|
onClick: handleConsentToggle,
|
||||||
|
title: t('Wissensdatenbank aktiv — klicken zum Deaktivieren'),
|
||||||
|
visible: (row: Connection) => !!(row as any).knowledgeIngestionEnabled,
|
||||||
|
loading: (row: Connection) => togglingConsent.has(row.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'knowledge-consent-off',
|
||||||
|
icon: <FaToggleOff />,
|
||||||
|
onClick: handleConsentToggle,
|
||||||
|
title: t('Wissensdatenbank inaktiv — klicken zum Aktivieren'),
|
||||||
|
visible: (row: Connection) => !(row as any).knowledgeIngestionEnabled,
|
||||||
|
loading: (row: Connection) => togglingConsent.has(row.id),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'connect',
|
id: 'connect',
|
||||||
icon: <FaLink />,
|
icon: <FaLink />,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue