fixed integration graph editor ai

This commit is contained in:
ValueOn AG 2026-04-19 01:22:34 +02:00
parent 13f4574098
commit dcd1182521
11 changed files with 564 additions and 242 deletions

View file

@ -212,6 +212,8 @@ export interface FolderInfo {
mandateId?: string; mandateId?: string;
featureInstanceId?: string; featureInstanceId?: string;
createdAt?: number; createdAt?: number;
scope?: string;
neutralize?: boolean;
} }
export async function fetchFolders( export async function fetchFolders(

View file

@ -48,6 +48,7 @@ import { Automation2DataFlowProvider } from '../context/Automation2DataFlowConte
import { usePrompt } from '../../../hooks/usePrompt'; import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel'; import { EditorChatPanel } from './EditorChatPanel';
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel'; import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
import { EditorWorkflowChatList } from './EditorWorkflowChatList';
import { RunTracingPanel } from './RunTracingPanel'; import { RunTracingPanel } from './RunTracingPanel';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
@ -114,7 +115,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [tracingRunId, setTracingRunId] = useState<string | null>(null); const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({}); const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes'); const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes');
const [udbTab, setUdbTab] = useState<UdbTab>('chats'); type LeftTab = UdbTab | 'ai';
const [udbTab, setUdbTab] = useState<LeftTab>('ai');
const udbContext: UdbContext = useMemo(() => ({ const udbContext: UdbContext = useMemo(() => ({
instanceId, instanceId,
@ -649,19 +651,30 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
{leftPanelOpen && (<> {leftPanelOpen && (<>
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}> <div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
<div className={styles.rightTabBar}> <div className={styles.rightTabBar}>
{(['chats', 'files', 'sources'] as const).map((tab) => ( {(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
<button <button
key={tab} key={tab}
className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`} className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`}
onClick={() => setUdbTab(tab)} onClick={() => setUdbTab(tab)}
> >
{{ chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]} {{ ai: t('KI'), chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]}
</button> </button>
))} ))}
</div> </div>
<div style={{ flex: 1, overflow: 'hidden' }}> <div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
{udbTab === 'chats' ? ( {/*
KI-Panel bleibt gemountet, damit der Chatverlauf beim Tab-Wechsel
(Chats / Dateien / Quellen) erhalten bleibt. Nur per CSS umblenden.
`key={currentWorkflowId}` setzt den Verlauf sauber zurück, wenn der
Nutzer einen anderen Workflow wählt.
*/}
<div style={{
display: udbTab === 'ai' ? 'flex' : 'none',
flexDirection: 'column',
height: '100%',
}}>
<EditorChatPanel <EditorChatPanel
key={currentWorkflowId || '__noWorkflow__'}
instanceId={instanceId} instanceId={instanceId}
workflowId={currentWorkflowId} workflowId={currentWorkflowId}
onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }} onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }}
@ -670,11 +683,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
dataSources={dataSources} dataSources={dataSources}
featureDataSources={featureDataSources} featureDataSources={featureDataSources}
/> />
) : ( </div>
{udbTab === 'chats' && (
<EditorWorkflowChatList
workflows={workflows}
currentWorkflowId={currentWorkflowId}
onSelect={handleWorkflowSelect}
onNew={handleNew}
t={t}
/>
)}
{(udbTab === 'files' || udbTab === 'sources') && (
<UnifiedDataBar <UnifiedDataBar
context={udbContext} context={udbContext}
activeTab={udbTab} activeTab={udbTab as UdbTab}
onTabChange={setUdbTab} onTabChange={(tab) => setUdbTab(tab as LeftTab)}
hideTabs={['chats']} hideTabs={['chats']}
onFileSelect={onFileSelect} onFileSelect={onFileSelect}
onSourcesChanged={onSourcesChanged} onSourcesChanged={onSourcesChanged}

View file

@ -7,11 +7,25 @@
* - Files: drag & drop from FolderTree onto input area, or click in UDB * - Files: drag & drop from FolderTree onto input area, or click in UDB
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources) * - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
*/ */
import React, { useState, useCallback, useRef } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { startSseStream } from '../../../utils/sseClient'; import { startSseStream } from '../../../utils/sseClient';
import { ChatMessageList } from '../../Chat'; import { ChatMessageList } from '../../Chat';
import type { ChatMessage } from '../../Chat'; import type { ChatMessage } from '../../Chat';
import { getPageIcon } from '../../../config/pageRegistry'; import { getPageIcon } from '../../../config/pageRegistry';
import api from '../../../api';
interface PersistedEditorChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp?: number;
sequenceNr?: number;
}
interface PersistedEditorChatResponse {
chatWorkflowId: string | null;
messages: PersistedEditorChatMessage[];
}
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -58,16 +72,55 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [showSourcePicker, setShowSourcePicker] = useState(false); const [showSourcePicker, setShowSourcePicker] = useState(false);
const [treeDropOver, setTreeDropOver] = useState(false); const [treeDropOver, setTreeDropOver] = useState(false);
const [stopping, setStopping] = useState(false);
const abortRef = useRef<(() => void) | null>(null); const abortRef = useRef<(() => void) | null>(null);
const assistantIdRef = useRef<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null); const pickerRef = useRef<HTMLDivElement>(null);
// Load persisted chat history from the backend whenever the workflow changes.
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
// returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`.
// For an unsaved workflow (workflowId == null) we just clear the panel.
useEffect(() => {
if (!workflowId) {
setMessages([]);
return;
}
let cancelled = false;
const _loadHistory = async () => {
setHistoryLoading(true);
try {
const res = await api.get<PersistedEditorChatResponse>(
`/api/workflows/${instanceId}/${workflowId}/chat/messages`,
);
if (cancelled) return;
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
id: m.id || `persisted-${++_msgCounter}`,
role: m.role,
content: m.content,
timestamp: m.timestamp ? Math.round(Number(m.timestamp) * 1000) : Date.now(),
}));
setMessages(persisted);
} catch (err) {
if (cancelled) return;
console.warn('EditorChatPanel: failed to load chat history', err);
setMessages([]);
} finally {
if (!cancelled) setHistoryLoading(false);
}
};
_loadHistory();
return () => { cancelled = true; };
}, [instanceId, workflowId]);
const _toggleDataSource = useCallback((dsId: string) => { const _toggleDataSource = useCallback((dsId: string) => {
setAttachedDataSourceIds(prev => setAttachedDataSourceIds(prev =>
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId], prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
@ -85,9 +138,10 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
if (!workflowId || loading || !trimmed) return; if (!workflowId || loading || !trimmed) return;
const fileIds = pendingFiles.map(f => f.fileId); const fileIds = pendingFiles.map(f => f.fileId);
// Note: conversationHistory is no longer sent — the backend loads it
// server-side from the persisted ChatWorkflow (linkedWorkflowId).
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
message: trimmed, message: trimmed,
conversationHistory: messages.map(m => ({ role: m.role, message: m.content })),
userLanguage: navigator.language?.slice(0, 2) || 'de', userLanguage: navigator.language?.slice(0, 2) || 'de',
}; };
if (fileIds.length > 0) body.fileIds = fileIds; if (fileIds.length > 0) body.fileIds = fileIds;
@ -106,11 +160,13 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
setLoading(true); setLoading(true);
const assistantId = `asst-${++_msgCounter}`; const assistantId = `asst-${++_msgCounter}`;
assistantIdRef.current = assistantId;
let accumulated = ''; let accumulated = '';
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]); setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
const baseURL = api.defaults.baseURL || '';
const cleanup = startSseStream({ const cleanup = startSseStream({
url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`, url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`,
body, body,
handlers: { handlers: {
onChunk: (event) => { onChunk: (event) => {
@ -142,17 +198,40 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
} }
setLoading(false); setLoading(false);
}, },
onStopped: () => setLoading(false), onStopped: () => {
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Gestoppt.')}_` }
: m));
setLoading(false);
setStopping(false);
},
}, },
onConnectionError: (err) => { onConnectionError: (err) => {
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m)); setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m));
setLoading(false); setLoading(false);
setStopping(false);
}, },
onStreamEnd: () => setLoading(false), onStreamEnd: () => { setLoading(false); setStopping(false); },
}); });
abortRef.current = cleanup; abortRef.current = cleanup;
}, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]); }, [prompt, loading, workflowId, instanceId, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]);
const _handleStop = useCallback(async () => {
if (!workflowId || stopping) return;
setStopping(true);
const assistantId = assistantIdRef.current;
if (assistantId) {
setMessages(prev => prev.map(m => m.id === assistantId
? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Stoppen…')}_` }
: m));
}
try {
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`);
} catch {
}
abortRef.current?.();
}, [workflowId, instanceId, stopping, t]);
const _handleKeyDown = useCallback((e: React.KeyboardEvent) => { const _handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@ -188,8 +267,8 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
<ChatMessageList <ChatMessageList
messages={messages} messages={messages}
isProcessing={loading} isProcessing={loading || historyLoading}
emptyMessage={t('Beschreiben Sie, was Sie tun möchten')} emptyMessage={historyLoading ? t('Lade Verlauf…') : t('Beschreiben Sie, was Sie tun möchten')}
/> />
{/* Pending files (from UDB drag/click) */} {/* Pending files (from UDB drag/click) */}
@ -391,10 +470,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
)} )}
{loading ? ( {loading ? (
<button onClick={() => abortRef.current?.()} style={{ <button onClick={_handleStop} disabled={stopping} title={stopping ? t('Stoppen…') : t('Anfrage stoppen')} style={{
padding: '8px 14px', borderRadius: 8, border: 'none', padding: '8px 14px', borderRadius: 8, border: 'none',
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: 12, background: stopping ? '#9e9e9e' : '#f44336', color: '#fff',
}}>{t('Stopp')}</button> cursor: stopping ? 'wait' : 'pointer', fontWeight: 600, fontSize: 12,
opacity: stopping ? 0.7 : 1,
}}>{stopping ? t('Stoppen…') : t('Stopp')}</button>
) : ( ) : (
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{ <button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
padding: '8px 14px', borderRadius: 8, border: 'none', padding: '8px 14px', borderRadius: 8, border: 'none',

View file

@ -0,0 +1,127 @@
/**
* EditorWorkflowChatList
*
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated
* as one editor chat session. Lists workflows already loaded by the parent
* editor (no extra fetch), supports search and "+ Neu" to start a fresh
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
* GraphicalEditor data instead of the workspace endpoint.
*/
import React, { useMemo, useState } from 'react';
import type { Automation2Workflow } from '../../../api/workflowApi';
interface EditorWorkflowChatListProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
onSelect: (workflowId: string | null) => void;
onNew: () => void;
t: (key: string) => string;
}
function _formatRelative(ts?: number): string {
if (!ts) return '';
const date = new Date(ts * 1000);
if (isNaN(date.getTime())) return '';
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffH = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return 'gerade eben';
if (diffMin < 60) return `${diffMin}m`;
if (diffH < 24) return `${diffH}h`;
if (diffDays === 1) return 'gestern';
if (diffDays < 7) return `vor ${diffDays}d`;
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
workflows,
currentWorkflowId,
onSelect,
onNew,
t,
}) => {
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const list = q
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
: [...workflows];
list.sort((a, b) => (b.lastStartedAt || b.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0));
return list;
}, [workflows, search]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-primary, #fff)' }}>
<div style={{ padding: '8px 10px', display: 'flex', gap: 6, borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('Workflow suchen…')}
style={{
flex: 1, padding: '6px 8px', borderRadius: 6,
border: '1px solid var(--border-color, #ddd)', fontSize: 12, outline: 'none',
}}
/>
<button
onClick={onNew}
title={t('Neuer Workflow')}
style={{
padding: '6px 10px', borderRadius: 6, border: '1px solid var(--border-color, #ddd)',
background: 'var(--secondary-bg, #f5f5f5)', cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>+ {t('Neu')}</button>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{filtered.length === 0 ? (
<div style={{ padding: 16, fontSize: 12, color: '#999', textAlign: 'center' }}>
{workflows.length === 0
? t('Noch keine Workflows. Klicken Sie auf „+ Neu", um einen Workflow-Chat zu starten.')
: t('Keine Treffer.')}
</div>
) : (
filtered.map((wf) => {
const isActive = wf.id === currentWorkflowId;
const ts = wf.lastStartedAt || wf.createdAt;
return (
<div
key={wf.id}
onClick={() => onSelect(wf.id)}
style={{
padding: '10px 12px', cursor: 'pointer',
borderBottom: '1px solid var(--border-color-soft, #f0f0f0)',
background: isActive ? 'rgba(242, 88, 67, 0.08)' : 'transparent',
borderLeft: isActive ? '3px solid var(--primary-color, #F25843)' : '3px solid transparent',
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = '#f7f7f7'; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: '#333', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{wf.label || t('(unbenannt)')}
</span>
{wf.isRunning && (
<span title={t('läuft')} style={{
width: 8, height: 8, borderRadius: '50%', background: '#4caf50', flexShrink: 0,
}} />
)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4, fontSize: 11, color: '#999' }}>
{typeof wf.runCount === 'number' && (
<span>{wf.runCount} {wf.runCount === 1 ? t('Lauf') : t('Läufe')}</span>
)}
{ts ? <span>· {_formatRelative(ts)}</span> : null}
</div>
</div>
);
})
)}
</div>
</div>
);
};
export default EditorWorkflowChatList;

View file

@ -86,10 +86,21 @@
min-width: 0; min-width: 0;
} }
/* Right zone: contains dynamic on-hover actions + always-visible stable trio.
* The stable trio (chat / scope / neutralize) sits at the right edge in a
* fixed slot order so icons never jump. Dynamic actions appear on hover
* to the left of the trio without displacing it. */
.rightZone {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
.actions { .actions {
display: none; display: none;
gap: 2px; gap: 2px;
margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
} }
@ -97,6 +108,26 @@
display: flex; display: flex;
} }
.stableActions {
display: flex;
gap: 2px;
flex-shrink: 0;
align-items: center;
}
.iconSlot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 20px;
flex-shrink: 0;
}
.iconSlot.placeholder {
visibility: hidden;
}
.actionBtn { .actionBtn {
background: none; background: none;
border: none; border: none;
@ -148,25 +179,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
.scopeIcons {
display: flex;
gap: 2px;
flex-shrink: 0;
align-items: center;
}
.rightZone {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
.rightZone .actions {
margin-left: 0;
}
.rootActions { .rootActions {
display: flex; display: flex;
gap: 2px; gap: 2px;

View file

@ -30,6 +30,7 @@ export interface FolderNode {
isReadonly?: boolean; isReadonly?: boolean;
icon?: string; icon?: string;
neutralize?: boolean; neutralize?: boolean;
scope?: string;
} }
export interface FileNode { export interface FileNode {
@ -76,6 +77,7 @@ export interface FolderTreeProps {
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>; onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void; onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
onFolderScopeChange?: (folderId: string, newScope: string) => void;
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
} }
@ -186,6 +188,78 @@ interface SelectionCtx {
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
} }
/* Stable trio (chat | scope | neutralize)
* Always rendered in this order, always at the right edge of the row.
* Each slot has a fixed width so missing actions render an invisible
* placeholder icons never jump position between rows. */
interface StableTrioProps {
scope?: string;
neutralize?: boolean;
scopeLabels: Record<string, string>;
onChat?: () => void;
onScopeChange?: (newScope: string) => void;
onNeutralizeToggle?: (newValue: boolean) => void;
chatTitle: string;
}
function _StableTrio({
scope, neutralize,
scopeLabels,
onChat, onScopeChange, onNeutralizeToggle,
chatTitle,
}: StableTrioProps) {
const { t } = useLanguage();
const _cycleScope = (current: string | undefined) => {
const idx = _SCOPE_CYCLE.indexOf(current ?? 'personal');
return _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
};
return (
<span className={styles.stableActions}>
{/* Slot 1: Chat */}
{onChat ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onChat(); }}
title={chatTitle}
style={{ fontSize: 12 }}
>
{'\u{1F4AC}'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\u{1F4AC}'}</span>
)}
{/* Slot 2: Scope */}
{onScopeChange && scope != null ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onScopeChange(_cycleScope(scope)); }}
title={`${t('Scope')}: ${scopeLabels[scope] || scope} (${t('klicken zum Wechseln')})`}
style={{ fontSize: 14 }}
>
{_SCOPE_ICONS[scope] || _SCOPE_ICONS.personal}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{_SCOPE_ICONS.personal}</span>
)}
{/* Slot 3: Neutralize */}
{onNeutralizeToggle ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onNeutralizeToggle(!neutralize); }}
title={neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
style={{ fontSize: 14, opacity: neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\uD83D\uDD12'}</span>
)}
</span>
);
}
/* ── File node (leaf) ─────────────────────────────────────────────────── */ /* ── File node (leaf) ─────────────────────────────────────────────────── */
function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
@ -265,12 +339,12 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
)} )}
{!renaming && ( {!renaming && (
<span className={styles.rightZone}> <span className={styles.rightZone}>
{file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
<span className={styles.actions}> <span className={styles.actions}>
{sel.onSendToChat && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
{'\u{1F4AC}'}
</button>
)}
{sel.onRenameFile && !multiSelected && ( {sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}> <button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
<FaPen /> <FaPen />
@ -299,40 +373,15 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
) )
)} )}
</span> </span>
{file.fileSize != null && ( <_StableTrio
<span className={styles.fileSize}> scope={file.scope}
{(file.fileSize / 1024).toFixed(0)}K neutralize={file.neutralize}
</span> scopeLabels={scopeLabels}
)} onChat={sel.onSendToChat ? () => sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]) : undefined}
{file.scope != null && ( onScopeChange={sel.onScopeChange ? (next) => sel.onScopeChange!(file.id, next) : undefined}
<span className={styles.scopeIcons}> onNeutralizeToggle={sel.onNeutralizeToggle ? (next) => sel.onNeutralizeToggle!(file.id, next) : undefined}
<button chatTitle={t('In Chat senden')}
className={styles.actionBtn} />
onClick={(e) => {
e.stopPropagation();
if (!sel.onScopeChange) return;
const idx = _SCOPE_CYCLE.indexOf(file.scope!);
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
sel.onScopeChange(file.id, next);
}}
title={`${t('Scope')}: ${scopeLabels[file.scope!] || file.scope} (${t('klicken zum Wechseln')})`}
style={{ fontSize: 14 }}
>
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
</button>
<button
className={styles.actionBtn}
onClick={(e) => {
e.stopPropagation();
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
}}
title={file.neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
</span>
)}
</span> </span>
)} )}
</div> </div>
@ -360,6 +409,7 @@ interface TreeNodeProps {
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>; onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>; onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>; onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onFolderScopeChange?: (folderId: string, newScope: string) => void;
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
} }
@ -368,9 +418,15 @@ function _TreeNode({
promptFolderName, promptFolderName,
onToggle, onSelect, onToggle, onSelect,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onDownloadFolder, onFolderNeutralizeToggle, onDownloadFolder, onFolderScopeChange, onFolderNeutralizeToggle,
}: TreeNodeProps) { }: TreeNodeProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const scopeLabels = useMemo((): Record<string, string> => ({
personal: t('Persönlich'),
featureInstance: t('Instanz'),
mandate: t('Mandant'),
global: t('Global'),
}), [t]);
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(node.name); const [renameValue, setRenameValue] = useState(node.name);
const [dropOver, setDropOver] = useState(false); const [dropOver, setDropOver] = useState(false);
@ -523,57 +579,53 @@ function _TreeNode({
<span className={styles.folderName} style={notEditable ? { fontWeight: 600 } : undefined}>{node.name}</span> <span className={styles.folderName} style={notEditable ? { fontWeight: 600 } : undefined}>{node.name}</span>
)} )}
{!isProtected && ( {!isProtected && (
<span className={styles.actions}> <span className={styles.rightZone}>
{sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( <span className={styles.actions}>
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}> {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
{'\u{1F4AC}'} <button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
</button> <FaDownload />
)} </button>
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( )}
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}> {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<FaDownload /> <button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
</button> <FaPlus />
)} </button>
{onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( )}
<button {!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
className={styles.actionBtn} <button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}>
onClick={(e) => { e.stopPropagation(); onFolderNeutralizeToggle(node.id, !node.neutralize); }} <FaPen />
title={node.neutralize ? t('Ordner-Neutralisierung aktiv, klicken zum Deaktivieren') : t('Ordner-Neutralisierung aus, klicken zum Aktivieren')} </button>
style={{ fontSize: 14, opacity: node.neutralize ? 1 : 0.4 }} )}
> {isMultiSelected && sel.selectedItemIds.size > 1 ? (
{'\uD83D\uDD12'} <>
</button> {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
)} <button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} ${t('Ordner löschen')}`}>
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( <FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}> <span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
<FaPlus /> </button>
</button> )}
)} {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
{!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( <button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}>
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}> <FaTrash />
<FaPen /> <span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button> </button>
)} )}
{isMultiSelected && sel.selectedItemIds.size > 1 ? ( </>
<> ) : !notEditable && onDeleteFolder && (
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( <button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} ${t('Ordner löschen')}`}> <FaTrash />
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash /> </button>
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span> )}
</button> </span>
)} <_StableTrio
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( scope={node.scope}
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}> neutralize={node.neutralize}
<FaTrash /> scopeLabels={scopeLabels}
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span> onChat={(sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? () => sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]) : undefined}
</button> onScopeChange={(onFolderScopeChange && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderScopeChange(node.id, next) : undefined}
)} onNeutralizeToggle={(onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderNeutralizeToggle(node.id, next) : undefined}
</> chatTitle={t('In Chat senden')}
) : !notEditable && onDeleteFolder && ( />
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
<FaTrash />
</button>
)}
</span> </span>
)} )}
</div> </div>
@ -600,6 +652,7 @@ function _TreeNode({
onMoveFile={onMoveFile} onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles} onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder} onDownloadFolder={onDownloadFolder}
onFolderScopeChange={onFolderScopeChange}
onFolderNeutralizeToggle={onFolderNeutralizeToggle} onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/> />
))} ))}
@ -620,7 +673,7 @@ export default function FolderTree({
expandedIds: externalExpandedIds, onToggleExpand, expandedIds: externalExpandedIds, onToggleExpand,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
onScopeChange, onNeutralizeToggle, onFolderNeutralizeToggle, onSendToChat, onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat,
}: FolderTreeProps) { }: FolderTreeProps) {
const { t } = useLanguage(); const { t } = useLanguage();
@ -848,6 +901,7 @@ export default function FolderTree({
onMoveFile={onMoveFile} onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles} onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder} onDownloadFolder={onDownloadFolder}
onFolderScopeChange={onFolderScopeChange}
onFolderNeutralizeToggle={onFolderNeutralizeToggle} onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/> />
))} ))}

View file

@ -52,6 +52,22 @@ function _formatRelativeTime(dateStr?: string | number): string {
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
} }
/**
* Timestamp der letzten Aktivität eines Chats: `lastMessageAt` (Backend liefert
* den Zeitstempel der letzten Nachricht) bevorzugt, sonst Fallback auf
* `updatedAt` (Workflow-Lifecycle-Zeit). Wird sowohl für Anzeige als auch
* Sortierung verwendet, damit Liste und Label konsistent sind.
*/
function _lastTouchValue(chat: ChatItem): string | number | undefined {
return chat.lastMessageAt ?? chat.updatedAt;
}
function _lastTouchTs(chat: ChatItem): number {
const v = _lastTouchValue(chat);
if (v === undefined || v === null) return 0;
return typeof v === 'number' ? v : new Date(v).getTime();
}
const ChatsTab: React.FC<ChatsTabProps> = ({ context, const ChatsTab: React.FC<ChatsTabProps> = ({ context,
onSelectChat, onSelectChat,
onDragStart, onDragStart,
@ -113,13 +129,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
} }
const sorted = Array.from(groupMap.values()); const sorted = Array.from(groupMap.values());
sorted.forEach(g => sorted.forEach(g => g.chats.sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a)));
g.chats.sort((a, b) => {
const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime();
const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime();
return tb - ta;
}),
);
setGroups(sorted); setGroups(sorted);
if (expandedGroups.size === 0 && sorted.length > 0) { if (expandedGroups.size === 0 && sorted.length > 0) {
@ -218,16 +228,9 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
.map(g => ({ ...g, chats: _applyFilter(g.chats) })) .map(g => ({ ...g, chats: _applyFilter(g.chats) }))
.filter(g => g.chats.length > 0); .filter(g => g.chats.length > 0);
const _toTs = (v?: string | number): number =>
typeof v === 'number' ? v : new Date(v || 0).getTime();
const _allChats = _filteredGroups const _allChats = _filteredGroups
.flatMap(g => g.chats) .flatMap(g => g.chats)
.sort((a, b) => { .sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a));
const ta = _toTs(a.lastMessageAt ?? a.updatedAt);
const tb = _toTs(b.lastMessageAt ?? b.updatedAt);
return tb - ta;
});
const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0); const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0);
const _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0); const _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0);
@ -270,7 +273,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
) : ( ) : (
<> <>
<span className={styles.chatDate}> <span className={styles.chatDate}>
{_formatRelativeTime(chat.updatedAt)} {_formatRelativeTime(_lastTouchValue(chat))}
</span> </span>
<span <span
className={styles.chatLabel} className={styles.chatLabel}

View file

@ -47,7 +47,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
name: f.name, name: f.name,
parentId: f.parentId ?? null, parentId: f.parentId ?? null,
fileCount: f.fileCount ?? 0, fileCount: f.fileCount ?? 0,
neutralize: (f as any).neutralize ?? false, neutralize: f.neutralize ?? false,
scope: f.scope ?? 'personal',
})); }));
}, [folders]); }, [folders]);
@ -178,6 +179,16 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
} }
}, [refreshFolders, refreshTreeFiles]); }, [refreshFolders, refreshTreeFiles]);
const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
try {
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
await refreshFolders();
await refreshTreeFiles();
} catch (err) {
console.error('Failed to change folder scope:', err);
}
}, [refreshFolders, refreshTreeFiles]);
if (treeFilesLoading && treeFileNodes.length === 0) { if (treeFilesLoading && treeFileNodes.length === 0) {
return <div className={styles.loading}>{t('Dateien laden')}</div>; return <div className={styles.loading}>{t('Dateien laden')}</div>;
} }
@ -268,6 +279,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onDownloadFolder={handleDownloadFolder} onDownloadFolder={handleDownloadFolder}
onScopeChange={_onScopeChange} onScopeChange={_onScopeChange}
onNeutralizeToggle={_onNeutralizeToggle} onNeutralizeToggle={_onNeutralizeToggle}
onFolderScopeChange={_onFolderScopeChange}
onFolderNeutralizeToggle={_onFolderNeutralizeToggle} onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
onSendToChat={onSendToChat} onSendToChat={onSendToChat}
/> />

View file

@ -1097,12 +1097,24 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
{node.label} {node.label}
</span> </span>
{/* Chat-Senden: always visible */} {/* Dynamic action: Remove (only when DS exists) placed LEFT of the
* stable trio so the trio always anchors at the right edge. */}
{ds && (
<button
onClick={e => { e.stopPropagation(); onRemoveDs(ds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize (always in this order) ── */}
<button <button
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }} onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{ style={{
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: ds ? 0.7 : (hovered ? 0.5 : 0.25), opacity: ds ? 0.7 : (hovered ? 0.5 : 0.25),
color: 'var(--primary-color, #F25843)', color: 'var(--primary-color, #F25843)',
}} }}
@ -1110,8 +1122,6 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
> >
{'\u{1F4AC}'} {'\u{1F4AC}'}
</button> </button>
{/* Scope: own DS → cycle, no DS → create DS then cycle */}
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
@ -1121,13 +1131,11 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {} 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, 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 ? scopeCycleTitle(ds.scope) : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
> >
{_SCOPE_ICONS[ds?.scope || effectiveScope || 'personal']} {_SCOPE_ICONS[ds?.scope || effectiveScope || 'personal']}
</button> </button>
{/* Neutralize: own DS → toggle, no DS → create DS then toggle */}
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
@ -1139,7 +1147,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
}} }}
style={{ style={{
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, padding: '0 2px', lineHeight: 1, fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: (ds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35, opacity: (ds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
}} }}
title={(ds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')} title={(ds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
@ -1147,17 +1155,6 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
{'\uD83D\uDD12'} {'\uD83D\uDD12'}
</button> </button>
{/* Remove: only when DS exists */}
{ds && (
<button
onClick={e => { e.stopPropagation(); onRemoveDs(ds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
</div> </div>
{node.expanded && node.children && node.children.length > 0 && ( {node.expanded && node.children && node.children.length > 0 && (
@ -1366,7 +1363,18 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
{node.tableCount} {t('Tabellen')} {node.tableCount} {t('Tabellen')}
</span> </span>
{/* Chat: always visible */} {/* Dynamic Remove (left of stable trio) */}
{wildcardFds && (
<button
onClick={e => { e.stopPropagation(); onRemoveFds(wildcardFds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize ── */}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -1379,15 +1387,13 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
}} }}
style={{ style={{
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2', opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}} }}
title={t('Alle Tabellen in Chat senden')} title={t('Alle Tabellen in Chat senden')}
> >
{'\u{1F4AC}'} {'\u{1F4AC}'}
</button> </button>
{/* Scope: own wildcard-FDS → cycle, otherwise create then cycle */}
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
@ -1397,13 +1403,11 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope('personal') }); } catch {} try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope('personal') }); } catch {}
} }
}} }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, 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}` : t('Scope setzen')}
> >
{_SCOPE_ICONS[wildcardFds?.scope || 'personal']} {_SCOPE_ICONS[wildcardFds?.scope || 'personal']}
</button> </button>
{/* Neutralize: own wildcard-FDS → toggle, otherwise create then toggle */}
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
@ -1413,23 +1417,12 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {} try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
} }
}} }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: wildcardFds ? (wildcardFds.neutralize ? 1 : 0.35) : 0.35 }} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? (wildcardFds.neutralize ? 1 : 0.35) : 0.35 }}
title={wildcardFds?.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')} title={wildcardFds?.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
> >
{'\uD83D\uDD12'} {'\uD83D\uDD12'}
</button> </button>
{/* Remove: only when wildcard-FDS exists */}
{wildcardFds && (
<button
onClick={e => { e.stopPropagation(); onRemoveFds(wildcardFds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
</div> </div>
{node.expanded && node.tables && node.tables.length > 0 && ( {node.expanded && node.tables && node.tables.length > 0 && (
@ -1587,20 +1580,29 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
)} )}
</span> </span>
{/* Chat: always visible */} {/* Dynamic Remove (left of stable trio) */}
{fds && onRemoveFds && (
<button
onClick={e => { e.stopPropagation(); onRemoveFds(fds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize ── */}
<button <button
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }} onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{ style={{
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2', opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}} }}
title={t('In Chat senden')} title={t('In Chat senden')}
> >
{'\u{1F4AC}'} {'\u{1F4AC}'}
</button> </button>
{/* Scope: own FDS → cycle, otherwise create then cycle */}
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
@ -1610,13 +1612,11 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {} 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, 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 ? `${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>
{/* Neutralize: own FDS → toggle, otherwise create then toggle */}
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
@ -1628,7 +1628,7 @@ 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, fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: (fds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35, opacity: (fds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
}} }}
title={(fds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')} title={(fds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
@ -1636,17 +1636,6 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
{'\uD83D\uDD12'} {'\uD83D\uDD12'}
</button> </button>
{/* Remove: only when FDS exists */}
{fds && onRemoveFds && (
<button
onClick={e => { e.stopPropagation(); onRemoveFds(fds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
</div> </div>
{/* Expandable field sub-nodes */} {/* Expandable field sub-nodes */}
@ -1732,26 +1721,30 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
{fieldName} {fieldName}
</span> </span>
{/* Chat: always visible */} {/* ── Stable trio: chat | scope | neutralize ── */}
<button <button
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }} onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{ style={{
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 11, padding: '0 2px', flexShrink: 0, lineHeight: 1, fontSize: 11, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2', opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}} }}
title={t('Feld in Chat senden')} title={t('Feld in Chat senden')}
> >
{'\u{1F4AC}'} {'\u{1F4AC}'}
</button> </button>
<span
{/* Neutralize: own FDS → clickable, otherwise dimmed */} style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: 0.25, display: 'inline-block' }}
title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
>
{_SCOPE_ICONS[inheritedScope || 'personal']}
</span>
{fds && onToggleNeutralizeField ? ( {fds && onToggleNeutralizeField ? (
<button <button
onClick={e => { e.stopPropagation(); onToggleNeutralizeField(fds, fieldName); }} onClick={e => { e.stopPropagation(); onToggleNeutralizeField(fds, fieldName); }}
style={{ style={{
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 11, padding: '0 2px', lineHeight: 1, fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: isNeutralized ? 1 : 0.35, opacity: isNeutralized ? 1 : 0.35,
}} }}
title={isNeutralized ? t('Feld-Neutralisierung an') : t('Feld-Neutralisierung aus')} title={isNeutralized ? t('Feld-Neutralisierung an') : t('Feld-Neutralisierung aus')}
@ -1760,20 +1753,12 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
</button> </button>
) : ( ) : (
<span <span
style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, opacity: isNeutralized ? 0.5 : 0.15 }} style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: isNeutralized ? 0.5 : 0.15, display: 'inline-block' }}
title={isNeutralized ? t('Neutralisierung geerbt') : t('Neutralisierung aus')} title={isNeutralized ? t('Neutralisierung geerbt') : t('Neutralisierung aus')}
> >
{'\uD83D\uDD12'} {'\uD83D\uDD12'}
</span> </span>
)} )}
{/* Scope: inherited indicator */}
<span
style={{ fontSize: 10, padding: '0 2px', lineHeight: 1, opacity: 0.25 }}
title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
>
{_SCOPE_ICONS[inheritedScope || 'personal']}
</span>
</div> </div>
); );
}; };
@ -1949,7 +1934,18 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
{record.displayLabel} {record.displayLabel}
</span> </span>
{/* Chat: always visible */} {/* Dynamic Remove (left of stable trio) */}
{fds && onRemoveFds && (
<button
onClick={(e) => { e.stopPropagation(); onRemoveFds(fds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize ── */}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -1962,61 +1958,46 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
}} }}
style={{ style={{
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2', opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}} }}
title={t('In Chat senden')} title={t('In Chat senden')}
> >
{'\u{1F4AC}'} {'\u{1F4AC}'}
</button> </button>
{/* Scope: own FDS → clickable, otherwise dimmed */}
{fds && onCycleScope ? ( {fds && onCycleScope ? (
<button <button
onClick={(e) => { e.stopPropagation(); onCycleScope(fds); }} onClick={(e) => { e.stopPropagation(); onCycleScope(fds); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1 }} 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={`${t('Bereich')}: ${fds.scope}`}
> >
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal} {_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
</button> </button>
) : ( ) : (
<span <span
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: 0.25 }} style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: 0.25, display: 'inline-block' }}
title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')} title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
> >
{_SCOPE_ICONS[inheritedScope || 'personal']} {_SCOPE_ICONS[inheritedScope || 'personal']}
</span> </span>
)} )}
{/* Neutralize: own FDS → clickable, otherwise dimmed */}
{fds && onToggleNeutralize ? ( {fds && onToggleNeutralize ? (
<button <button
onClick={(e) => { e.stopPropagation(); onToggleNeutralize(fds); }} onClick={(e) => { e.stopPropagation(); onToggleNeutralize(fds); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, 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: fds.neutralize ? 1 : 0.35 }}
title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')} title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
> >
{'\uD83D\uDD12'} {'\uD83D\uDD12'}
</button> </button>
) : ( ) : (
<span <span
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: (inheritedNeutralize ?? false) ? 0.5 : 0.15 }} style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (inheritedNeutralize ?? false) ? 0.5 : 0.15, display: 'inline-block' }}
title={(inheritedNeutralize ?? false) ? t('Neutralisierung geerbt') : t('Neutralisierung aus')} title={(inheritedNeutralize ?? false) ? t('Neutralisierung geerbt') : t('Neutralisierung aus')}
> >
{'\uD83D\uDD12'} {'\uD83D\uDD12'}
</span> </span>
)} )}
{/* Remove: only when FDS exists */}
{fds && onRemoveFds && (
<button
onClick={(e) => { e.stopPropagation(); onRemoveFds(fds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
</div> </div>
{record.expanded && ( {record.expanded && (

View file

@ -142,6 +142,24 @@ export const FilesPage: React.FC = () => {
} }
}, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]); }, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]);
const _handleFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
try {
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]);
} catch (err) {
console.error('Failed to update folder scope:', err);
}
}, [refreshFolders, refreshTreeFiles, _tableRefetch]);
const _handleFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => {
try {
await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue });
await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]);
} catch (err) {
console.error('Failed to toggle folder neutralize:', err);
}
}, [refreshFolders, refreshTreeFiles, _tableRefetch]);
// ── Folder nodes for tree (real folders only) ──────────────────────── // ── Folder nodes for tree (real folders only) ────────────────────────
const folderNodes = useMemo(() => { const folderNodes = useMemo(() => {
return folders.map(f => ({ return folders.map(f => ({
@ -149,6 +167,8 @@ export const FilesPage: React.FC = () => {
name: f.name, name: f.name,
parentId: f.parentId ?? null, parentId: f.parentId ?? null,
fileCount: f.fileCount ?? 0, fileCount: f.fileCount ?? 0,
neutralize: f.neutralize ?? false,
scope: f.scope ?? 'personal',
})); }));
}, [folders]); }, [folders]);
@ -426,6 +446,8 @@ export const FilesPage: React.FC = () => {
onDownloadFolder={handleDownloadFolder} onDownloadFolder={handleDownloadFolder}
onScopeChange={_handleScopeChange} onScopeChange={_handleScopeChange}
onNeutralizeToggle={_handleNeutralizeToggle} onNeutralizeToggle={_handleNeutralizeToggle}
onFolderScopeChange={_handleFolderScopeChange}
onFolderNeutralizeToggle={_handleFolderNeutralizeToggle}
/> />
</div> </div>

View file

@ -52,6 +52,8 @@
--bg-primary: #ffffff; --bg-primary: #ffffff;
--bg-secondary: #F7FAFC; --bg-secondary: #F7FAFC;
--bg-dark: #EDF2F7; --bg-dark: #EDF2F7;
/* Canvas surface for the GraphicalEditor flow editor (slightly off-white). */
--canvas-bg: #FAFAFA;
/* Surface colors */ /* Surface colors */
--surface-color: #F7FAFC; --surface-color: #F7FAFC;
@ -126,6 +128,9 @@
--bg-primary: #1A202C; --bg-primary: #1A202C;
--bg-secondary: #2D3748; --bg-secondary: #2D3748;
--bg-dark: #171923; --bg-dark: #171923;
/* Canvas surface for the GraphicalEditor flow editor slightly darker than
--bg-primary so nodes and connection lines stay legible against it. */
--canvas-bg: #131820;
/* Surface colors */ /* Surface colors */
--surface-color: #2D3748; --surface-color: #2D3748;