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(
|
||||
request: ApiRequestFunction,
|
||||
dataSourceId: string,
|
||||
ragIndexEnabled: boolean
|
||||
): Promise<{ sourceId: string; ragIndexEnabled: boolean; updated: boolean }> {
|
||||
ragIndexEnabled: boolean | null
|
||||
): Promise<{ sourceId: string; ragIndexEnabled: boolean | null; updated: boolean; cascadedDescendants?: number }> {
|
||||
return await request({
|
||||
url: `/api/datasources/${dataSourceId}/rag-index`,
|
||||
method: 'patch',
|
||||
|
|
@ -345,8 +394,9 @@ export interface RagDataSourceDto {
|
|||
label: string;
|
||||
path: string;
|
||||
sourceType: string;
|
||||
ragIndexEnabled: boolean;
|
||||
neutralize: boolean;
|
||||
/** Three-state inherit semantics on backend; UI reads as effective boolean from RAG inventory aggregator. */
|
||||
ragIndexEnabled: boolean | null;
|
||||
neutralize: boolean | null;
|
||||
lastIndexed: number | null;
|
||||
/** Distinct files indexed for this DataSource (one row per source document). */
|
||||
fileCount: number;
|
||||
|
|
@ -363,7 +413,12 @@ export interface RagConnectionDto {
|
|||
dataSources: RagDataSourceDto[];
|
||||
totalFiles: 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;
|
||||
lastSuccess?: {
|
||||
jobId: string;
|
||||
|
|
@ -392,6 +447,7 @@ export interface RagActiveJobDto {
|
|||
connectionLabel?: string;
|
||||
jobType: string;
|
||||
progress: number | null;
|
||||
/** Already translated server-side. */
|
||||
progressMessage: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -864,7 +864,14 @@ export async function syncPositionsToAccounting(
|
|||
request: ApiRequestFunction,
|
||||
instanceId: 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[] }> {
|
||||
const submission = await request({
|
||||
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ interface _RagJob {
|
|||
connectionLabel?: string;
|
||||
jobType: string;
|
||||
progress: number | null;
|
||||
/** Already translated server-side (route handler runs `resolveJobMessage`). */
|
||||
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 { getPageIcon } from '../../config/pageRegistry';
|
||||
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 { SiJira } from 'react-icons/si';
|
||||
|
||||
|
|
@ -42,9 +43,13 @@ interface UdbDataSource {
|
|||
path: string;
|
||||
label: string;
|
||||
displayPath?: string;
|
||||
scope: string;
|
||||
neutralize: boolean;
|
||||
ragIndexEnabled?: boolean;
|
||||
/** Three-state cascade-inherit. null = inherit from nearest ancestor. */
|
||||
scope: string | null;
|
||||
/** 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 {
|
||||
|
|
@ -54,8 +59,10 @@ interface UdbFeatureDataSource {
|
|||
tableName: string;
|
||||
objectKey: string;
|
||||
label: string;
|
||||
scope: string;
|
||||
neutralize: boolean;
|
||||
/** Three-state cascade-inherit. null = inherit from nearest ancestor FDS. */
|
||||
scope: string | null;
|
||||
/** Three-state cascade-inherit. null = inherit. */
|
||||
neutralize: boolean | null;
|
||||
neutralizeFields?: string[];
|
||||
recordFilter?: Record<string, string>;
|
||||
}
|
||||
|
|
@ -73,6 +80,7 @@ interface TreeNode {
|
|||
path?: string;
|
||||
displayPath?: string;
|
||||
authority?: string;
|
||||
knowledgeIngestionEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface FeatureConnectionNode {
|
||||
|
|
@ -450,6 +458,16 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
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 result: string[] = [];
|
||||
for (const n of nodes) {
|
||||
|
|
@ -499,8 +517,10 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
path: d.path,
|
||||
label: d.label,
|
||||
displayPath: d.displayPath,
|
||||
scope: d.scope || 'personal',
|
||||
neutralize: d.neutralize ?? false,
|
||||
scope: d.scope ?? null,
|
||||
neutralize: d.neutralize ?? null,
|
||||
ragIndexEnabled: d.ragIndexEnabled ?? null,
|
||||
settings: d.settings ?? null,
|
||||
}));
|
||||
list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||
setDataSources(list);
|
||||
|
|
@ -521,8 +541,8 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
tableName: d.tableName,
|
||||
objectKey: d.objectKey,
|
||||
label: d.label,
|
||||
scope: d.scope || 'personal',
|
||||
neutralize: d.neutralize ?? false,
|
||||
scope: d.scope ?? null,
|
||||
neutralize: d.neutralize ?? null,
|
||||
neutralizeFields: d.neutralizeFields || undefined,
|
||||
recordFilter: d.recordFilter || undefined,
|
||||
}));
|
||||
|
|
@ -555,6 +575,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
children: null,
|
||||
connectionId: c.id,
|
||||
authority: c.authority,
|
||||
knowledgeIngestionEnabled: !!c.knowledgeIngestionEnabled,
|
||||
}))
|
||||
.sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||
setTree(nodes);
|
||||
|
|
@ -677,60 +698,118 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
}
|
||||
}, [instanceId, dataSources, onAttachDataSource, _fetchDataSources, onSourcesChanged]);
|
||||
|
||||
/* ── Scope change (personal data source, optimistic) ── */
|
||||
const _cyclePersonalScope = useCallback(async (ds: UdbDataSource) => {
|
||||
const newScope = _nextScope(ds.scope);
|
||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: newScope } : d));
|
||||
try {
|
||||
await api.patch(`/api/datasources/${ds.id}/scope`, { scope: newScope });
|
||||
} catch {
|
||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: ds.scope } : d));
|
||||
/* ── Node-based toggles (auto-create DS if missing) ────────────────────
|
||||
* Logik: Klick auf jedem Knoten togglt nur diesen Knoten. Children erben
|
||||
* visuell. Wenn der Knoten noch keinen DataSource-Record hat, wird einer
|
||||
* angelegt UND mit dem neuen Wert befüllt — atomar im UI-State, damit
|
||||
* keine Race-Condition zwischen POST/PATCH und UI-Refetch entsteht. */
|
||||
/**
|
||||
* Toggle on a node:
|
||||
* - 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 _togglePersonalNeutralize = useCallback(async (ds: UdbDataSource) => {
|
||||
const newValue = !ds.neutralize;
|
||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: newValue } : d));
|
||||
try {
|
||||
await api.patch(`/api/datasources/${ds.id}/neutralize`, { neutralize: newValue });
|
||||
} catch {
|
||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: ds.neutralize } : d));
|
||||
const _toggleRagIndexOnNode = 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, 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 _togglePersonalRagIndex = useCallback(async (ds: UdbDataSource) => {
|
||||
const newValue = !ds.ragIndexEnabled;
|
||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: newValue } : d));
|
||||
try {
|
||||
await api.patch(`/api/datasources/${ds.id}/rag-index`, { ragIndexEnabled: newValue });
|
||||
} catch {
|
||||
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: ds.ragIndexEnabled } : d));
|
||||
const _cycleScopeOnNode = useCallback(async (node: TreeNode, currentEffective: string | undefined) => {
|
||||
const newScope = _nextScope(currentEffective || 'personal');
|
||||
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, 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) ── */
|
||||
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
|
||||
const newScope = _nextScope(fds.scope);
|
||||
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource, currentEffective?: string) => {
|
||||
const baseline = currentEffective || fds.scope || 'personal';
|
||||
const newScope = _nextScope(baseline);
|
||||
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d));
|
||||
try {
|
||||
await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope });
|
||||
_fetchFeatureDataSources();
|
||||
} catch {
|
||||
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: fds.scope } : d));
|
||||
_fetchFeatureDataSources();
|
||||
}
|
||||
}, []);
|
||||
}, [_fetchFeatureDataSources]);
|
||||
|
||||
/* ── Neutralize toggle (feature data source, optimistic) ── */
|
||||
const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource) => {
|
||||
const newValue = !fds.neutralize;
|
||||
const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource, currentEffective?: boolean) => {
|
||||
const baseline = currentEffective !== undefined ? currentEffective : !!fds.neutralize;
|
||||
const newValue = !baseline;
|
||||
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d));
|
||||
try {
|
||||
await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue });
|
||||
_fetchFeatureDataSources();
|
||||
} catch {
|
||||
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: fds.neutralize } : d));
|
||||
_fetchFeatureDataSources();
|
||||
}
|
||||
}, []);
|
||||
}, [_fetchFeatureDataSources]);
|
||||
|
||||
/* ── Neutralize fields toggle (field-level, optimistic) ── */
|
||||
const _toggleNeutralizeField = useCallback(async (fds: UdbFeatureDataSource, fieldName: string) => {
|
||||
|
|
@ -1033,13 +1112,13 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
node={node}
|
||||
depth={0}
|
||||
onToggle={_toggleNode}
|
||||
onEnsureDs={_addAsDataSource}
|
||||
isAdded={_isAdded}
|
||||
addingPath={addingPath}
|
||||
dataSources={dataSources}
|
||||
onCycleScope={_cyclePersonalScope}
|
||||
onToggleNeutralize={_togglePersonalNeutralize}
|
||||
onToggleRagIndex={_togglePersonalRagIndex}
|
||||
onCycleScopeOnNode={_cycleScopeOnNode}
|
||||
onToggleNeutralizeOnNode={_toggleNeutralizeOnNode}
|
||||
onToggleRagIndexOnNode={_toggleRagIndexOnNode}
|
||||
onOpenSettings={_openSettingsForNode}
|
||||
onSendToChat={_sendNodeToChat}
|
||||
scopeCycleTitle={_scopeCycleTitle}
|
||||
selectedKeys={selectedKeys}
|
||||
|
|
@ -1102,6 +1181,21 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1109,25 +1203,79 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
|
|||
/* ─── TreeNodeView (recursive — Browse Sources side) ─────────────────── */
|
||||
|
||||
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 =>
|
||||
ds.connectionId === node.connectionId &&
|
||||
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 {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
onToggle: (node: TreeNode) => void;
|
||||
onEnsureDs: (node: TreeNode) => Promise<string | null>;
|
||||
isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean;
|
||||
addingPath: string | null;
|
||||
dataSources: UdbDataSource[];
|
||||
onCycleScope: (ds: UdbDataSource) => void;
|
||||
onToggleNeutralize: (ds: UdbDataSource) => void;
|
||||
onToggleRagIndex: (ds: UdbDataSource) => void;
|
||||
onCycleScopeOnNode: (node: TreeNode, currentEffective: string | undefined) => void;
|
||||
onToggleNeutralizeOnNode: (node: TreeNode, currentEffective: boolean) => void;
|
||||
onToggleRagIndexOnNode: (node: TreeNode, currentEffective: boolean) => void;
|
||||
onOpenSettings: (node: TreeNode) => void;
|
||||
onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
|
||||
scopeCycleTitle: (scope: string) => string;
|
||||
selectedKeys: Set<string>;
|
||||
|
|
@ -1138,8 +1286,8 @@ interface _TreeNodeViewProps {
|
|||
}
|
||||
|
||||
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||
node, depth, onToggle, onEnsureDs, isAdded, addingPath,
|
||||
dataSources, onCycleScope, onToggleNeutralize, onToggleRagIndex, onSendToChat, scopeCycleTitle,
|
||||
node, depth, onToggle, isAdded, addingPath,
|
||||
dataSources, onCycleScopeOnNode, onToggleNeutralizeOnNode, onToggleRagIndexOnNode, onOpenSettings, onSendToChat, scopeCycleTitle,
|
||||
selectedKeys, onSelect, inheritedScope, inheritedNeutralize, inheritedRagIndex,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -1150,12 +1298,17 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
: '\u00A0\u00A0';
|
||||
const ds = _findDs(dataSources, node);
|
||||
|
||||
const effectiveScope = ds?.scope ?? inheritedScope;
|
||||
const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false;
|
||||
const effectiveRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex ?? false;
|
||||
const childInheritedScope = ds?.scope ?? inheritedScope;
|
||||
const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize;
|
||||
const childInheritedRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex;
|
||||
// Effective values: own (if explicit) → DS-ancestor chain → tree-inherited (UI-only) → default.
|
||||
// Tree-inherited values cover the case where children don't have their own DS record yet.
|
||||
const effectiveScope =
|
||||
(ds && _effectiveFlag(ds, dataSources, 'scope')) ?? inheritedScope ?? 'personal';
|
||||
const effectiveNeutralize =
|
||||
(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 = {
|
||||
connectionId: node.connectionId,
|
||||
|
|
@ -1239,14 +1392,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
</span>
|
||||
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (ds) { onToggleRagIndex(ds); return; }
|
||||
const newId = await onEnsureDs(node);
|
||||
if (newId) {
|
||||
try { await api.patch(`/api/datasources/${newId}/rag-index`, { ragIndexEnabled: !effectiveRagIndex }); } catch {}
|
||||
}
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleRagIndexOnNode(node, effectiveRagIndex); }}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
||||
|
|
@ -1256,6 +1402,17 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
>
|
||||
{'\uD83E\uDDE0'}
|
||||
</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
|
||||
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
|
||||
style={{
|
||||
|
|
@ -1269,28 +1426,14 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
{'\u{1F4AC}'}
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
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 {}
|
||||
}
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); onCycleScopeOnNode(node, effectiveScope); }}
|
||||
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']}
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
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 {}
|
||||
}
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleNeutralizeOnNode(node, effectiveNeutralize); }}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
||||
|
|
@ -1311,13 +1454,13 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
node={child}
|
||||
depth={depth + 1}
|
||||
onToggle={onToggle}
|
||||
onEnsureDs={onEnsureDs}
|
||||
isAdded={isAdded}
|
||||
addingPath={addingPath}
|
||||
dataSources={dataSources}
|
||||
onCycleScope={onCycleScope}
|
||||
onToggleNeutralize={onToggleNeutralize}
|
||||
onToggleRagIndex={onToggleRagIndex}
|
||||
onCycleScopeOnNode={onCycleScopeOnNode}
|
||||
onToggleNeutralizeOnNode={onToggleNeutralizeOnNode}
|
||||
onToggleRagIndexOnNode={onToggleRagIndexOnNode}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onSendToChat={onSendToChat}
|
||||
scopeCycleTitle={scopeCycleTitle}
|
||||
selectedKeys={selectedKeys}
|
||||
|
|
@ -1368,8 +1511,8 @@ interface _FeatureActionContext {
|
|||
addingKey: string | null;
|
||||
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
|
||||
featureDataSources: UdbFeatureDataSource[];
|
||||
onCycleScope: (fds: UdbFeatureDataSource) => void;
|
||||
onToggleNeutralize: (fds: UdbFeatureDataSource) => void;
|
||||
onCycleScope: (fds: UdbFeatureDataSource, currentEffective?: string) => void;
|
||||
onToggleNeutralize: (fds: UdbFeatureDataSource, currentEffective?: boolean) => void;
|
||||
onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void;
|
||||
featureTree: MandateGroupNode[];
|
||||
}
|
||||
|
|
@ -1515,21 +1658,22 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => {
|
|||
<button
|
||||
onClick={async (e) => {
|
||||
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);
|
||||
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 }}
|
||||
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : t('Scope setzen')}
|
||||
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope ?? 'personal'}` : t('Scope setzen')}
|
||||
>
|
||||
{_SCOPE_ICONS[wildcardFds?.scope || 'personal']}
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
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);
|
||||
if (newId) {
|
||||
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
|
||||
|
|
@ -1552,8 +1696,8 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => {
|
|||
item={item}
|
||||
pathSegments={[]}
|
||||
depth={1}
|
||||
inheritedScope={wildcardFds?.scope}
|
||||
inheritedNeutralize={wildcardFds?.neutralize}
|
||||
inheritedScope={wildcardFds?.scope ?? undefined}
|
||||
inheritedNeutralize={wildcardFds?.neutralize ?? undefined}
|
||||
{...ctx}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -1726,30 +1870,32 @@ const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => {
|
|||
<button
|
||||
onClick={async (e) => {
|
||||
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(
|
||||
featureNode,
|
||||
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
|
||||
);
|
||||
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 }}
|
||||
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']}
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
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(
|
||||
featureNode,
|
||||
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
|
||||
);
|
||||
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 }}
|
||||
|
|
@ -1884,32 +2030,34 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => {
|
|||
<button
|
||||
onClick={async (e) => {
|
||||
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(
|
||||
featureNode,
|
||||
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
|
||||
{ labelOverride: _chatPayload.label },
|
||||
);
|
||||
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 }}
|
||||
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']}
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
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(
|
||||
featureNode,
|
||||
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
|
||||
{ labelOverride: _chatPayload.label },
|
||||
);
|
||||
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 }}
|
||||
|
|
@ -1977,6 +2125,11 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
|
|||
&& 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(
|
||||
() => _childrenForRecord(featureNode.tables || [], parentTable.tableName),
|
||||
[featureNode.tables, parentTable.tableName],
|
||||
|
|
@ -2067,11 +2220,11 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
|
|||
</button>
|
||||
{fds ? (
|
||||
<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' }}
|
||||
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>
|
||||
) : (
|
||||
<span
|
||||
|
|
@ -2083,9 +2236,9 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
|
|||
)}
|
||||
{fds ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); ctx.onToggleNeutralize(fds); }}
|
||||
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 }}
|
||||
title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||
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: effectiveNeutralize ? 1 : 0.35 }}
|
||||
title={effectiveNeutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</button>
|
||||
|
|
@ -2108,8 +2261,8 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
|
|||
item={sub}
|
||||
pathSegments={segments}
|
||||
depth={depth + 1}
|
||||
inheritedScope={fds?.scope ?? inheritedScope}
|
||||
inheritedNeutralize={fds?.neutralize ?? inheritedNeutralize}
|
||||
inheritedScope={effectiveScope}
|
||||
inheritedNeutralize={effectiveNeutralize}
|
||||
{...ctx}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -2132,8 +2285,8 @@ interface _FeatureTableRowProps {
|
|||
) => Promise<string | null>;
|
||||
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
|
||||
featureDataSources: UdbFeatureDataSource[];
|
||||
onCycleScope: (fds: UdbFeatureDataSource) => void;
|
||||
onToggleNeutralize: (fds: UdbFeatureDataSource) => void;
|
||||
onCycleScope: (fds: UdbFeatureDataSource, currentEffective?: string) => void;
|
||||
onToggleNeutralize: (fds: UdbFeatureDataSource, currentEffective?: boolean) => void;
|
||||
onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void;
|
||||
featureTree: MandateGroupNode[];
|
||||
inheritedScope?: string;
|
||||
|
|
@ -2225,21 +2378,22 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
|
|||
<button
|
||||
onClick={async (e) => {
|
||||
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);
|
||||
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 }}
|
||||
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']}
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (fds) { onToggleNeutralize(fds); return; }
|
||||
if (fds) { onToggleNeutralize(fds, effectiveNeutralize); return; }
|
||||
const newId = await onAddFeatureTable(featureNode, table);
|
||||
if (newId) {
|
||||
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effectiveNeutralize }); } catch {}
|
||||
|
|
@ -2248,9 +2402,9 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
|
|||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
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'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ export interface BackgroundJob {
|
|||
triggeredBy?: string | null;
|
||||
status: BackgroundJobStatus;
|
||||
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;
|
||||
payload?: Record<string, any>;
|
||||
result?: Record<string, any> | null;
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ import { useLanguage } from '../providers/language/LanguageContext';
|
|||
import { useApiRequest } from '../hooks/useApi';
|
||||
import { useUserMandates } from '../hooks/useUserMandates';
|
||||
import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi';
|
||||
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, 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 { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal';
|
||||
import styles from './RagInventoryPage.module.css';
|
||||
|
||||
export const RagInventoryPage: React.FC = () => {
|
||||
|
|
@ -31,6 +32,23 @@ export const RagInventoryPage: React.FC = () => {
|
|||
const [error, setError] = useState<string | 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(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
|
|
@ -296,8 +314,11 @@ export const RagInventoryPage: React.FC = () => {
|
|||
{' — '}
|
||||
{t('Limit {l} erreicht', { l: limitText })}.
|
||||
{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>
|
||||
<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')}>
|
||||
<FaRedo size={12} /> {t('Erneut indexieren')}
|
||||
</button>
|
||||
|
|
@ -358,6 +379,17 @@ export const RagInventoryPage: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataSourceSettingsModal
|
||||
open={!!settingsModal}
|
||||
title={settingsModal?.title || ''}
|
||||
dataSourceId={settingsModal?.dataSourceId}
|
||||
connectionId={settingsModal?.connectionId}
|
||||
initialKnowledgeIngestionEnabled={settingsModal?.initialKnowledgeIngestionEnabled}
|
||||
showRagSection
|
||||
onSaved={() => _fetchInventory()}
|
||||
onClose={() => setSettingsModal(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import React, { useState, useMemo, useEffect, useRef } from 'react';
|
|||
import { useConnections, type Connection } from '../../hooks/useConnections';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
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 bannerStyles from './ConnectionsPage.module.css';
|
||||
import { AddConnectionWizard } 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 { useApiRequest } from '../../hooks/useApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import { getApiBaseUrl } from '../../../config/config';
|
||||
|
||||
|
|
@ -52,7 +53,10 @@ export const ConnectionsPage: React.FC = () => {
|
|||
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
|
||||
const [refreshingConnections, setRefreshingConnections] = 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 { request } = useApiRequest();
|
||||
// Banner shown while knowledge bootstrap is running in the background
|
||||
const [syncBanner, setSyncBanner] = useState<{
|
||||
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');
|
||||
};
|
||||
|
||||
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
|
||||
const formAttributes = useMemo(() => {
|
||||
const excludedFields = [
|
||||
|
|
@ -344,6 +370,22 @@ export const ConnectionsPage: React.FC = () => {
|
|||
}] : []),
|
||||
]}
|
||||
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',
|
||||
icon: <FaLink />,
|
||||
|
|
|
|||
Loading…
Reference in a new issue