This commit is contained in:
ValueOn AG 2026-04-29 23:13:01 +02:00
parent 70459d57e3
commit ad96c6d861
3 changed files with 324 additions and 1 deletions

View file

@ -332,6 +332,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
'filterExpression',
'attachmentBuilder',
'json',
'modelMultiSelect',
]);
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {

View file

@ -737,6 +737,113 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
);
};
const ModelMultiSelect: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const [models, setModels] = React.useState<Array<{ displayName: string; connectorType?: string }>>([]);
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 (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div
onClick={() => 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 && (
<span style={{ color: '#999', fontSize: 12 }}>{t('Alle erlaubten Modelle')}</span>
)}
{selected.map((name) => (
<span
key={name}
style={{
background: 'var(--primary-color, #2563eb)',
color: '#fff',
borderRadius: 3,
padding: '1px 6px',
fontSize: 11,
display: 'inline-flex',
alignItems: 'center',
gap: 3,
}}
>
{name}
<span
onClick={(e) => { e.stopPropagation(); _removeTag(name); }}
style={{ cursor: 'pointer', fontWeight: 700 }}
>
x
</span>
</span>
))}
</div>
{open && (
<div style={{ border: '1px solid #ddd', borderRadius: 4, marginTop: 4, maxHeight: 200, overflow: 'auto', background: '#fafafa', padding: 4 }}>
{loading && <div style={{ fontSize: 11, color: '#888', padding: 4 }}>{t('Lade Modelle...')}</div>}
{!loading && models.length === 0 && (
<div style={{ fontSize: 11, color: '#888', padding: 4 }}>{t('Keine Modelle verfügbar')}</div>
)}
{models.map((m) => (
<label
key={m.displayName}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 4px', fontSize: 12, cursor: 'pointer' }}
>
<input
type="checkbox"
checked={selected.includes(m.displayName)}
onChange={() => _toggle(m.displayName)}
/>
<span>{m.displayName}</span>
{m.connectorType && (
<span style={{ fontSize: 10, color: '#888' }}>({m.connectorType})</span>
)}
</label>
))}
</div>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
@ -752,6 +859,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
email: TextInput,
select: SelectInput,
multiselect: MultiSelectInput,
modelMultiSelect: ModelMultiSelect,
json: JsonEditor,
file: TextInput,
hidden: HiddenInput,

View file

@ -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<GeneralSettingsProps> = ({ instanceId }) => {
const { t } = useLanguage();
const { request } = useApiRequest();
@ -36,6 +47,16 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
});
const [inputValue, setInputValue] = useState<string>('');
// AI user settings
const [aiSettings, setAiSettings] = useState<AiUserSettings>({
requireNeutralization: false,
allowedProviders: [],
allowedModels: [],
});
const [aiSaving, setAiSaving] = useState(false);
const [availableModels, setAvailableModels] = useState<AiModelEntry[]>([]);
const [modelsOpen, setModelsOpen] = useState(false);
const _loadSettings = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
@ -56,9 +77,37 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ 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<GeneralSettingsProps> = ({ insta
setInputValue('');
};
const _saveAiSettings = async (patch: Partial<AiUserSettings>) => {
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 <div className={styles.loading}>{t('Lade Einstellungen')}</div>;
}
const hasOverride = inputValue.trim() !== '';
const providerNames = [...new Set(availableModels.map((m) => m.connectorType).filter(Boolean))] as string[];
return (
<div className={styles.settings}>
@ -151,6 +238,133 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
>
{saving ? t('Speichern') : t('Einstellungen speichern')}
</button>
{/* AI settings section */}
<div className={styles.section} style={{ marginTop: '1.5rem' }}>
<h3 className={styles.sectionTitle}>{t('KI-Einstellungen')}</h3>
<div className={styles.field}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aiSettings.requireNeutralization}
onChange={(e) => {
const val = e.target.checked;
setAiSettings((prev) => ({ ...prev, requireNeutralization: val }));
_saveAiSettings({ requireNeutralization: val });
}}
disabled={aiSaving}
/>
<span className={styles.label} style={{ marginBottom: 0 }}>
{t('Neutralisierung erzwingen')}
</span>
</label>
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: 4, display: 'block' }}>
{t('Erzwingt die Neutralisierung von Eingaben vor der KI-Verarbeitung.')}
</span>
</div>
{providerNames.length > 0 && (
<div className={styles.field}>
<label className={styles.label}>{t('Erlaubte Anbieter')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{providerNames.map((prov) => (
<label key={prov} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.85rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={aiSettings.allowedProviders.includes(prov)}
onChange={() => {
const next = aiSettings.allowedProviders.includes(prov)
? aiSettings.allowedProviders.filter((p) => p !== prov)
: [...aiSettings.allowedProviders, prov];
setAiSettings((prev) => ({ ...prev, allowedProviders: next }));
_saveAiSettings({ allowedProviders: next });
}}
disabled={aiSaving}
/>
{prov}
</label>
))}
</div>
{aiSettings.allowedProviders.length === 0 && (
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: 4, display: 'block' }}>
{t('Alle Anbieter erlaubt')}
</span>
)}
</div>
)}
<div className={styles.field}>
<label className={styles.label}>{t('Erlaubte Modelle')}</label>
<div
onClick={() => 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 && (
<span style={{ color: 'var(--text-secondary, #999)', fontSize: '0.85rem' }}>{t('Alle erlaubten Modelle')}</span>
)}
{aiSettings.allowedModels.map((name) => (
<span
key={name}
style={{
background: 'var(--primary-color, #2563eb)',
color: '#fff',
borderRadius: 4,
padding: '2px 8px',
fontSize: '0.8rem',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
>
{name}
<span
onClick={(e) => { e.stopPropagation(); _removeModelTag(name); }}
style={{ cursor: 'pointer', fontWeight: 700 }}
>
x
</span>
</span>
))}
</div>
{modelsOpen && (
<div style={{ border: '1px solid var(--border-color, #ddd)', borderRadius: 6, marginTop: 4, maxHeight: 220, overflow: 'auto', background: 'var(--bg-primary, #fafafa)', padding: 6 }}>
{availableModels.length === 0 && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', padding: 4 }}>{t('Keine Modelle verfügbar')}</div>
)}
{availableModels.map((m) => (
<label
key={m.displayName}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 6px', fontSize: '0.85rem', cursor: 'pointer' }}
>
<input
type="checkbox"
checked={aiSettings.allowedModels.includes(m.displayName)}
onChange={() => _toggleModel(m.displayName)}
disabled={aiSaving}
/>
<span>{m.displayName}</span>
{m.connectorType && (
<span style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #888)' }}>({m.connectorType})</span>
)}
</label>
))}
</div>
)}
</div>
</div>
</div>
);
};