diff --git a/src/components/FlowEditor/editor/NodeConfigPanel.tsx b/src/components/FlowEditor/editor/NodeConfigPanel.tsx index b9799f2..ce6f3f1 100644 --- a/src/components/FlowEditor/editor/NodeConfigPanel.tsx +++ b/src/components/FlowEditor/editor/NodeConfigPanel.tsx @@ -332,6 +332,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([ 'filterExpression', 'attachmentBuilder', 'json', + 'modelMultiSelect', ]); function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] { diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx index fdd8de8..2654804 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx @@ -737,6 +737,113 @@ const FilterExpressionEditor: React.FC = ({ param, value, on ); }; +const ModelMultiSelect: React.FC = ({ param, value, onChange }) => { + const { t } = useLanguage(); + const [models, setModels] = React.useState>([]); + const [loading, setLoading] = React.useState(false); + const [open, setOpen] = React.useState(false); + const selected: string[] = Array.isArray(value) ? value : []; + + React.useEffect(() => { + let cancelled = false; + setLoading(true); + fetch('/api/system/ai-models', { credentials: 'include' }) + .then((r) => r.json()) + .then((data) => { + if (cancelled) return; + const items = (data?.models ?? []) as Array<{ displayName: string; connectorType?: string }>; + setModels(items); + }) + .catch(() => { if (!cancelled) setModels([]); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, []); + + const _toggle = (name: string) => { + const next = selected.includes(name) + ? selected.filter((v) => v !== name) + : [...selected, name]; + onChange(next); + }; + + const _removeTag = (name: string) => { + onChange(selected.filter((v) => v !== name)); + }; + + return ( +
+ +
setOpen((o) => !o)} + style={{ + width: '100%', + minHeight: 32, + padding: '4px 8px', + borderRadius: 4, + border: '1px solid #ccc', + cursor: 'pointer', + display: 'flex', + flexWrap: 'wrap', + gap: 4, + alignItems: 'center', + background: '#fff', + }} + > + {selected.length === 0 && ( + {t('Alle erlaubten Modelle')} + )} + {selected.map((name) => ( + + {name} + { e.stopPropagation(); _removeTag(name); }} + style={{ cursor: 'pointer', fontWeight: 700 }} + > + x + + + ))} +
+ {open && ( +
+ {loading &&
{t('Lade Modelle...')}
} + {!loading && models.length === 0 && ( +
{t('Keine Modelle verfügbar')}
+ )} + {models.map((m) => ( + + ))} +
+ )} +
+ ); +}; + // --------------------------------------------------------------------------- // Registry // --------------------------------------------------------------------------- @@ -752,6 +859,7 @@ export const FRONTEND_TYPE_RENDERERS: Record = { email: TextInput, select: SelectInput, multiselect: MultiSelectInput, + modelMultiSelect: ModelMultiSelect, json: JsonEditor, file: TextInput, hidden: HiddenInput, diff --git a/src/pages/views/workspace/WorkspaceGeneralSettings.tsx b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx index 5ceb8cd..c60ca56 100644 --- a/src/pages/views/workspace/WorkspaceGeneralSettings.tsx +++ b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx @@ -20,6 +20,17 @@ interface MaxAgentRoundsInfo { instanceDefault: number; } +interface AiUserSettings { + requireNeutralization: boolean; + allowedProviders: string[]; + allowedModels: string[]; +} + +interface AiModelEntry { + displayName: string; + connectorType?: string; +} + export const WorkspaceGeneralSettings: React.FC = ({ instanceId }) => { const { t } = useLanguage(); const { request } = useApiRequest(); @@ -36,6 +47,16 @@ export const WorkspaceGeneralSettings: React.FC = ({ insta }); const [inputValue, setInputValue] = useState(''); + // AI user settings + const [aiSettings, setAiSettings] = useState({ + requireNeutralization: false, + allowedProviders: [], + allowedModels: [], + }); + const [aiSaving, setAiSaving] = useState(false); + const [availableModels, setAvailableModels] = useState([]); + const [modelsOpen, setModelsOpen] = useState(false); + const _loadSettings = useCallback(async () => { if (!instanceId) return; setLoading(true); @@ -56,9 +77,37 @@ export const WorkspaceGeneralSettings: React.FC = ({ insta } }, [instanceId, request]); + const _loadAiSettings = useCallback(async () => { + if (!instanceId) return; + try { + const data = await request({ + url: `/api/workspace/${instanceId}/user-settings`, + method: 'get', + }) as AiUserSettings; + setAiSettings({ + requireNeutralization: data?.requireNeutralization ?? false, + allowedProviders: data?.allowedProviders ?? [], + allowedModels: data?.allowedModels ?? [], + }); + } catch (err: any) { + console.error('[WorkspaceGeneralSettings] AI settings load failed', err); + } + }, [instanceId, request]); + + const _loadAvailableModels = useCallback(async () => { + try { + const data = await request({ url: '/api/system/ai-models', method: 'get' }) as { models?: AiModelEntry[] }; + setAvailableModels(data?.models ?? []); + } catch { + setAvailableModels([]); + } + }, [request]); + useEffect(() => { _loadSettings(); - }, [_loadSettings]); + _loadAiSettings(); + _loadAvailableModels(); + }, [_loadSettings, _loadAiSettings, _loadAvailableModels]); const _handleSave = async () => { setSaving(true); @@ -94,11 +143,49 @@ export const WorkspaceGeneralSettings: React.FC = ({ insta setInputValue(''); }; + const _saveAiSettings = async (patch: Partial) => { + setAiSaving(true); + setError(null); + try { + const data = await request({ + url: `/api/workspace/${instanceId}/user-settings`, + method: 'put', + data: patch, + }) as AiUserSettings; + setAiSettings({ + requireNeutralization: data?.requireNeutralization ?? false, + allowedProviders: data?.allowedProviders ?? [], + allowedModels: data?.allowedModels ?? [], + }); + setSuccess(t('KI-Einstellungen gespeichert')); + setTimeout(() => setSuccess(null), 3000); + } catch (err: any) { + setError(err?.message || 'Fehler beim Speichern der KI-Einstellungen'); + } finally { + setAiSaving(false); + } + }; + + const _toggleModel = (name: string) => { + const next = aiSettings.allowedModels.includes(name) + ? aiSettings.allowedModels.filter((m) => m !== name) + : [...aiSettings.allowedModels, name]; + setAiSettings((prev) => ({ ...prev, allowedModels: next })); + _saveAiSettings({ allowedModels: next }); + }; + + const _removeModelTag = (name: string) => { + const next = aiSettings.allowedModels.filter((m) => m !== name); + setAiSettings((prev) => ({ ...prev, allowedModels: next })); + _saveAiSettings({ allowedModels: next }); + }; + if (loading) { return
{t('Lade Einstellungen')}
; } const hasOverride = inputValue.trim() !== ''; + const providerNames = [...new Set(availableModels.map((m) => m.connectorType).filter(Boolean))] as string[]; return (
@@ -151,6 +238,133 @@ export const WorkspaceGeneralSettings: React.FC = ({ insta > {saving ? t('Speichern') : t('Einstellungen speichern')} + + {/* AI settings section */} +
+

{t('KI-Einstellungen')}

+ +
+ + + {t('Erzwingt die Neutralisierung von Eingaben vor der KI-Verarbeitung.')} + +
+ + {providerNames.length > 0 && ( +
+ +
+ {providerNames.map((prov) => ( + + ))} +
+ {aiSettings.allowedProviders.length === 0 && ( + + {t('Alle Anbieter erlaubt')} + + )} +
+ )} + +
+ +
setModelsOpen((o) => !o)} + style={{ + width: '100%', + minHeight: 36, + padding: '6px 10px', + borderRadius: 6, + border: '1px solid var(--border-color, #d0d0d0)', + cursor: 'pointer', + display: 'flex', + flexWrap: 'wrap', + gap: 4, + alignItems: 'center', + background: 'var(--bg-primary, #fff)', + }} + > + {aiSettings.allowedModels.length === 0 && ( + {t('Alle erlaubten Modelle')} + )} + {aiSettings.allowedModels.map((name) => ( + + {name} + { e.stopPropagation(); _removeModelTag(name); }} + style={{ cursor: 'pointer', fontWeight: 700 }} + > + x + + + ))} +
+ {modelsOpen && ( +
+ {availableModels.length === 0 && ( +
{t('Keine Modelle verfügbar')}
+ )} + {availableModels.map((m) => ( + + ))} +
+ )} +
+
); };