diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index dc64b40..7a03b4e 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -30,6 +30,8 @@ export interface PortField { description: string | Record; required: boolean; enumValues?: string[] | null; + /** When true, surface at the top of the DataPicker as the most common/recommended pick. */ + recommended?: boolean; } export interface PortSchema { diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css index 67a4261..1b32bfb 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css @@ -1725,6 +1725,35 @@ margin-left: 4px; } +/* Type-mismatch warning badge (⚠) — shown instead of hiding incompatible fields. */ +.dataPickerMismatchBadge { + font-size: 10px; + margin-left: 4px; + color: var(--color-warning, #f59e0b); + flex-shrink: 0; +} + +/* Recommended pick: subtle highlight on the row */ +.dataPickerLeafRecommended { + font-weight: 500; +} + +/* "Empfohlen" pill shown on recommended entries */ +.dataPickerRecommendedPill { + display: inline-block; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 1px 5px; + border-radius: 10px; + margin-left: 5px; + background: var(--color-primary-light, #dbeafe); + color: var(--color-primary, #2563eb); + flex-shrink: 0; + vertical-align: middle; +} + /* "iterieren" affordance — visually distinct (subtle accent), readable on * the picker's white background and on the leaf's blue hover background. */ .dataPickerIterateBtn { diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/ContextBuilderRenderer.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/ContextBuilderRenderer.tsx new file mode 100644 index 0000000..1785ee1 --- /dev/null +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/ContextBuilderRenderer.tsx @@ -0,0 +1,181 @@ +/** + * ContextBuilderRenderer — multi-select context binding for AI nodes. + * + * Renders a list of DataRef entries (each pointing to an upstream node's output + * path). On execution the backend serialises each ref, joins them with double + * newlines and prepends the result to the AI prompt. + * + * Stored value shape: + * [ { type: "ref", nodeId: "...", path: [...], expectedType: "..." }, … ] + */ + +import React from 'react'; +import { useLanguage } from '../../../../providers/language/LanguageContext'; +import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; +import { DataPicker } from '../shared/DataPicker'; +import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef'; +import type { FieldRendererProps } from './index'; + +function isRefEntry(v: unknown): v is DataRef { + return isRef(v); +} + +function toRefList(raw: unknown): DataRef[] { + if (!raw) return []; + if (Array.isArray(raw)) return raw.filter(isRefEntry); + if (isRefEntry(raw)) return [raw]; + return []; +} + +const CHIP_STYLE: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 6, + padding: '3px 6px 3px 10px', + background: '#eaf6e8', + border: '1px solid #5cb85c', + borderRadius: 4, + fontSize: 12, + marginBottom: 4, +}; + +const REMOVE_BTN: React.CSSProperties = { + padding: '0 5px', + border: '1px solid #5cb85c', + borderRadius: 3, + background: '#fff', + color: '#3c763d', + cursor: 'pointer', + fontSize: 11, + marginLeft: 'auto', +}; + +export const ContextBuilderRenderer: React.FC = ({ param, value, onChange }) => { + const { t } = useLanguage(); + const dataFlow = useAutomation2DataFlow(); + const [pickerOpen, setPickerOpen] = React.useState(false); + const dragIndex = React.useRef(null); + + const entries = toRefList(value); + const sourceIds = dataFlow?.getAvailableSourceIds() ?? []; + const hasSources = sourceIds.some((id) => { + const n = dataFlow?.nodes.find((x) => x.id === id); + return n?.type !== 'trigger.manual'; + }); + + const getRefLabel = (ref: DataRef): string => { + const nodeLabel = + dataFlow?.getNodeLabel( + dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId }, + ) ?? ref.nodeId; + const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null; + return pathStr ? `${nodeLabel} → ${pathStr}` : nodeLabel; + }; + + const addRef = (picked: DataRef | SystemVarRef) => { + if (!isRefEntry(picked)) return; + const alreadyIn = entries.some( + (e) => e.nodeId === picked.nodeId && e.path.join('.') === picked.path.join('.'), + ); + if (!alreadyIn) { + onChange([...entries, picked]); + } + setPickerOpen(false); + }; + + const removeRef = (index: number) => { + const next = entries.filter((_, i) => i !== index); + onChange(next.length ? next : undefined); + }; + + const moveRef = (fromIndex: number, toIndex: number) => { + if (fromIndex === toIndex) return; + const next = [...entries]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + onChange(next); + }; + + return ( +
+ + + {entries.length > 0 && ( +
+ {entries.map((ref, i) => ( +
{ dragIndex.current = i; }} + onDragOver={(e) => { e.preventDefault(); }} + onDrop={() => { + if (dragIndex.current != null) moveRef(dragIndex.current, i); + dragIndex.current = null; + }} + onDragEnd={() => { dragIndex.current = null; }} + > + + {getRefLabel(ref)} + + +
+ ))} +
+ )} + + {entries.length === 0 && ( +
+ {t('Noch keine Quellen gewählt — wähle Daten aus vorherigen Schritten.')} +
+ )} + + + + {dataFlow && ( + setPickerOpen(false)} + onPick={addRef} + availableSourceIds={sourceIds} + nodes={dataFlow.nodes} + nodeOutputsPreview={dataFlow.nodeOutputsPreview} + getNodeLabel={dataFlow.getNodeLabel} + expectedParamType={param.type} + /> + )} +
+ ); +}; diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx index d95c5f7..7ac9d92 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx @@ -32,6 +32,7 @@ import { toApiGraph } from '../shared/graphUtils'; import { postUpstreamPaths } from '../../../../api/workflowApi'; import type { CanvasNode } from '../../editor/FlowCanvas'; import { DataRefRenderer } from './DataRefRenderer'; +import { ContextBuilderRenderer } from './ContextBuilderRenderer'; import { FeatureInstancePicker } from './FeatureInstancePicker'; import { TemplateTextareaRenderer } from './TemplateTextareaRenderer'; import { getApiBaseUrl } from '../../../../../config/config'; @@ -547,62 +548,98 @@ const FieldBuilderEditor: React.FC = ({ param, value, onChan next[idx] = { ...(next[idx] as Record), [field]: val }; onChange(next); }; + const inputStyle: React.CSSProperties = { + width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd', + fontSize: 12, boxSizing: 'border-box', background: '#fff', + }; + const selectStyle: React.CSSProperties = { ...inputStyle }; + return (
- + {fields.map((f: Record, i: number) => ( -
- updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} /> - - updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} /> - - +
+ {/* Row 1: Bezeichnung + delete */} +
+ updateField(i, 'label', e.target.value)} + style={{ ...inputStyle, flex: 1, fontWeight: 500 }} + /> + +
+ {/* Row 2: Name + Typ + Pflicht */} +
+
+
Name (intern)
+ updateField(i, 'name', e.target.value)} + style={inputStyle} + /> +
+
+
Typ
+ +
+ +
{String(f.type) === 'group' && ( -
-
{t('Unterfelder')}
+
+
{t('Unterfelder')}
{(Array.isArray(f.fields) ? f.fields : []).map((sub: Record, j: number) => ( -
- { - const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; - nextFields[j] = { ...sub, name: e.target.value }; - updateField(i, 'fields', nextFields); - }} - style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} - /> - - +
+
+ { + const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; + nextFields[j] = { ...sub, name: e.target.value }; + updateField(i, 'fields', nextFields); + }} + style={{ ...inputStyle, flex: 1 }} + /> + + +
))}
)}
))} - +
); }; @@ -869,6 +912,7 @@ export const FRONTEND_TYPE_RENDERERS: Record = { file: TextInput, hidden: HiddenInput, dataRef: DataRefRenderer, + contextBuilder: ContextBuilderRenderer, userConnection: ConnectionPicker, featureInstance: FeatureInstancePicker, sharepointFolder: SharepointPathPicker, diff --git a/src/components/FlowEditor/nodes/shared/DataPicker.tsx b/src/components/FlowEditor/nodes/shared/DataPicker.tsx index 4ca2877..b4637fb 100644 --- a/src/components/FlowEditor/nodes/shared/DataPicker.tsx +++ b/src/components/FlowEditor/nodes/shared/DataPicker.tsx @@ -35,6 +35,10 @@ interface PickablePath { /** True iff this path produces `List[X]` and the consumer expects `X` — * picking with iterate=true appends the wildcard segment. */ iterable?: boolean; + /** Annotated after strict-filter pass: type exists but doesn't match the expected param type. */ + typeMismatch?: boolean; + /** Surfaced at the top of the list as the most common / recommended pick. */ + recommended?: boolean; } const _LIST_INNER_RE = /^List\[(.+)\]$/; @@ -47,10 +51,22 @@ function _buildPathsFromSchema( ): PickablePath[] { if (!schema || !schema.fields || depth > 8) return []; const result: PickablePath[] = []; + + // For form schemas (kind=fromGraph), expose the whole `payload` object as a + // top-level pickable entry so the user can pass the entire form at once. + if (depth === 0 && schema.name?.startsWith('FormPayload')) { + result.push({ + path: ['payload'], + label: 'Gesamtes Formular', + type: 'object', + recommended: true, + }); + } + for (const field of schema.fields) { const fieldPath = [...basePath, field.name]; const label = fieldPath.map(String).join(' → '); - result.push({ path: fieldPath, label, type: field.type }); + result.push({ path: fieldPath, label, type: field.type, recommended: field.recommended ?? false }); const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null; const inner = m?.[1]?.trim(); if (inner && catalog[inner]) { @@ -326,15 +342,15 @@ export const DataPicker: React.FC = ({ open,
{loopLabel}
{loopPaths.map((p, i) => { - const compat = expectedParamType && p.type - ? isCompatible(p.type, expectedParamType) - : 'ok'; + const mismatch = + Boolean(expectedParamType) && + Boolean(p.type) && + isCompatible(p.type!, expectedParamType!) === 'mismatch'; return ( ); })} @@ -392,7 +416,11 @@ export const DataPicker: React.FC = ({ open, } return filteredIds.map((nodeId) => { const node = nodes.find((n) => n.id === nodeId); - const label = node ? getNodeLabel(node) : nodeId; + // User-defined step title (or node-type label as fallback) + const stepTitle = node ? getNodeLabel(node) : nodeId; + const nodeTypeDef = node?.type ? nodeTypes.find((nt) => nt.id === node.type) : undefined; + // Human-readable type label (e.g. "Formular", "Web-Recherche") + const typeLabel = nodeTypeDef?.label ?? node?.type ?? ''; const isExpanded = expandedNodes.has(nodeId); const resolvedSchema = _resolveSchemaForNode( @@ -411,13 +439,21 @@ export const DataPicker: React.FC = ({ open, : _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')), expectedParamType, ); - const paths = strictFilter && expectedParamType - ? annotated.filter((p) => { - if (p.iterable) return true; - if (!p.type) return false; - return isCompatible(p.type, expectedParamType) !== 'mismatch'; - }) - : annotated; + // Always show all paths; mark mismatches as a visual warning instead of hiding them. + // Recommended entries bubble to the top. + const markedPaths = annotated.map((p) => ({ + ...p, + typeMismatch: + strictFilter && + Boolean(expectedParamType) && + Boolean(p.type) && + !p.iterable && + isCompatible(p.type!, expectedParamType!) === 'mismatch', + })); + const paths = [ + ...markedPaths.filter((p) => p.recommended), + ...markedPaths.filter((p) => !p.recommended), + ]; return (
@@ -427,10 +463,10 @@ export const DataPicker: React.FC = ({ open, onClick={() => toggleExpand(nodeId)} > {isExpanded ? '▼' : '▶'} - {label} - {resolvedSchema && ( + {stepTitle} + {typeLabel && ( - ({resolvedSchema.name}) + {typeLabel} )} @@ -438,12 +474,10 @@ export const DataPicker: React.FC = ({ open,
{paths.length === 0 && (
- {t('(keine kompatiblen Felder — Filter „Nur kompatible“ deaktivieren)')} + {t('(keine Felder verfügbar)')}
)} {paths.map((p, i) => { - const compat = - expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok'; return (
= ({ open, > {p.iterable && (