fixes
This commit is contained in:
parent
70459d57e3
commit
ad96c6d861
3 changed files with 324 additions and 1 deletions
|
|
@ -332,6 +332,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
|
||||||
'filterExpression',
|
'filterExpression',
|
||||||
'attachmentBuilder',
|
'attachmentBuilder',
|
||||||
'json',
|
'json',
|
||||||
|
'modelMultiSelect',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {
|
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Registry
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -752,6 +859,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||||
email: TextInput,
|
email: TextInput,
|
||||||
select: SelectInput,
|
select: SelectInput,
|
||||||
multiselect: MultiSelectInput,
|
multiselect: MultiSelectInput,
|
||||||
|
modelMultiSelect: ModelMultiSelect,
|
||||||
json: JsonEditor,
|
json: JsonEditor,
|
||||||
file: TextInput,
|
file: TextInput,
|
||||||
hidden: HiddenInput,
|
hidden: HiddenInput,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,17 @@ interface MaxAgentRoundsInfo {
|
||||||
instanceDefault: number;
|
instanceDefault: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AiUserSettings {
|
||||||
|
requireNeutralization: boolean;
|
||||||
|
allowedProviders: string[];
|
||||||
|
allowedModels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiModelEntry {
|
||||||
|
displayName: string;
|
||||||
|
connectorType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ instanceId }) => {
|
export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ instanceId }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
|
@ -36,6 +47,16 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
|
||||||
});
|
});
|
||||||
const [inputValue, setInputValue] = useState<string>('');
|
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 () => {
|
const _loadSettings = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -56,9 +77,37 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
|
||||||
}
|
}
|
||||||
}, [instanceId, request]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
}, [_loadSettings]);
|
_loadAiSettings();
|
||||||
|
_loadAvailableModels();
|
||||||
|
}, [_loadSettings, _loadAiSettings, _loadAvailableModels]);
|
||||||
|
|
||||||
const _handleSave = async () => {
|
const _handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
@ -94,11 +143,49 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
|
||||||
setInputValue('');
|
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) {
|
if (loading) {
|
||||||
return <div className={styles.loading}>{t('Lade Einstellungen')}</div>;
|
return <div className={styles.loading}>{t('Lade Einstellungen')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasOverride = inputValue.trim() !== '';
|
const hasOverride = inputValue.trim() !== '';
|
||||||
|
const providerNames = [...new Set(availableModels.map((m) => m.connectorType).filter(Boolean))] as string[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.settings}>
|
<div className={styles.settings}>
|
||||||
|
|
@ -151,6 +238,133 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
|
||||||
>
|
>
|
||||||
{saving ? t('Speichern') : t('Einstellungen speichern')}
|
{saving ? t('Speichern') : t('Einstellungen speichern')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue