feat(canvas): prompt for extractor output on connect
This commit is contained in:
@@ -273,6 +273,15 @@ function SpecRendererInner({
|
|||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked');
|
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked');
|
||||||
|
|
||||||
|
// When connecting Extractor → Objective/Constraint and the extractor has multiple outputs,
|
||||||
|
// we prompt the user to choose which output_name to use.
|
||||||
|
const [pendingOutputSelect, setPendingOutputSelect] = useState<null | {
|
||||||
|
sourceId: string;
|
||||||
|
targetId: string;
|
||||||
|
outputNames: string[];
|
||||||
|
selected: string;
|
||||||
|
}>(null);
|
||||||
|
|
||||||
// Build trial history for sparklines (extract objective values from recent trials)
|
// Build trial history for sparklines (extract objective values from recent trials)
|
||||||
const trialHistory = useMemo(() => {
|
const trialHistory = useMemo(() => {
|
||||||
const history: Record<string, number[]> = {};
|
const history: Record<string, number[]> = {};
|
||||||
@@ -589,39 +598,43 @@ function SpecRendererInner({
|
|||||||
const targetType = classify(targetId);
|
const targetType = classify(targetId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Always persist the visual edge (for now)
|
|
||||||
await addEdge(sourceId, targetId);
|
|
||||||
|
|
||||||
// Option A truth model: objective/constraint source is the real linkage.
|
// Option A truth model: objective/constraint source is the real linkage.
|
||||||
// When user connects Extractor -> Objective/Constraint, update *.source accordingly.
|
// When user connects Extractor -> Objective/Constraint, we must choose an output_name.
|
||||||
if (spec && sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) {
|
if (spec && sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) {
|
||||||
const ext = spec.extractors.find((e) => e.id === sourceId);
|
const ext = spec.extractors.find((e) => e.id === sourceId);
|
||||||
|
const outputNames = (ext?.outputs || []).map((o) => o.name).filter(Boolean);
|
||||||
|
|
||||||
// Choose a sensible default output:
|
// If extractor has multiple outputs, prompt the user.
|
||||||
// - prefer 'value' if present
|
if (outputNames.length > 1) {
|
||||||
// - else if only one output, use it
|
const preferred = outputNames.includes('value') ? 'value' : outputNames[0];
|
||||||
// - else use first output
|
setPendingOutputSelect({
|
||||||
const outputs = ext?.outputs || [];
|
sourceId,
|
||||||
const preferred = outputs.find((o) => o.name === 'value')?.name;
|
targetId,
|
||||||
const outputName =
|
outputNames,
|
||||||
preferred || (outputs.length === 1 ? outputs[0].name : outputs.length > 0 ? outputs[0].name : 'value');
|
selected: preferred,
|
||||||
|
|
||||||
if (targetType === 'objective') {
|
|
||||||
await updateNode(targetId, {
|
|
||||||
source: { extractor_id: sourceId, output_name: outputName },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await updateNode(targetId, {
|
|
||||||
source: { extractor_id: sourceId, output_name: outputName },
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single (or zero) output: choose deterministically.
|
||||||
|
const outputName = outputNames[0] || 'value';
|
||||||
|
|
||||||
|
// Persist edge + runnable source.
|
||||||
|
await addEdge(sourceId, targetId);
|
||||||
|
await updateNode(targetId, {
|
||||||
|
source: { extractor_id: sourceId, output_name: outputName },
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default: just persist the visual edge.
|
||||||
|
await addEdge(sourceId, targetId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add connection:', err);
|
console.error('Failed to add connection:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Failed to add connection');
|
setError(err instanceof Error ? err.message : 'Failed to add connection');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editable, addEdge, setError, spec, updateNode]
|
[editable, addEdge, setError, spec, updateNode, setPendingOutputSelect]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle node clicks for selection
|
// Handle node clicks for selection
|
||||||
@@ -760,6 +773,34 @@ function SpecRendererInner({
|
|||||||
[editable, addNode, selectNode, setError, localNodes]
|
[editable, addNode, selectNode, setError, localNodes]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Output selection modal handlers (Extractor → Objective/Constraint)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const confirmOutputSelection = useCallback(async () => {
|
||||||
|
if (!pendingOutputSelect) return;
|
||||||
|
|
||||||
|
const { sourceId, targetId, selected } = pendingOutputSelect;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Persist edge + runnable source wiring
|
||||||
|
await addEdge(sourceId, targetId);
|
||||||
|
await updateNode(targetId, {
|
||||||
|
source: { extractor_id: sourceId, output_name: selected },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to apply output selection:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to apply output selection');
|
||||||
|
} finally {
|
||||||
|
setPendingOutputSelect(null);
|
||||||
|
}
|
||||||
|
}, [pendingOutputSelect, addEdge, updateNode, setError]);
|
||||||
|
|
||||||
|
const cancelOutputSelection = useCallback(() => {
|
||||||
|
// User canceled: do not create the edge, do not update source
|
||||||
|
setPendingOutputSelect(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (showLoadingOverlay && isLoading && !spec) {
|
if (showLoadingOverlay && isLoading && !spec) {
|
||||||
return (
|
return (
|
||||||
@@ -842,6 +883,55 @@ function SpecRendererInner({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Output selection modal (Extractor → Objective/Constraint) */}
|
||||||
|
{pendingOutputSelect && (
|
||||||
|
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="w-[520px] max-w-[90vw] bg-dark-850 border border-dark-600 rounded-xl shadow-2xl p-5">
|
||||||
|
<h3 className="text-white font-semibold text-lg">Select extractor output</h3>
|
||||||
|
<p className="text-sm text-dark-300 mt-1">
|
||||||
|
This extractor provides multiple outputs. Choose which output the target should use.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="block text-sm font-medium text-dark-300 mb-1">Output</label>
|
||||||
|
<select
|
||||||
|
value={pendingOutputSelect.selected}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPendingOutputSelect((prev) =>
|
||||||
|
prev ? { ...prev, selected: e.target.value } : prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white rounded-lg focus:border-primary-500 focus:outline-none transition-colors"
|
||||||
|
>
|
||||||
|
{pendingOutputSelect.outputNames.map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-dark-500 mt-2">
|
||||||
|
Tip: we default to <span className="text-dark-300 font-medium">value</span> when available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={cancelOutputSelection}
|
||||||
|
className="px-4 py-2 bg-dark-700 text-dark-200 hover:bg-dark-600 rounded-lg border border-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmOutputSelection}
|
||||||
|
className="px-4 py-2 bg-primary-600 text-white hover:bg-primary-500 rounded-lg border border-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={localNodes}
|
nodes={localNodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
|
|||||||
Reference in New Issue
Block a user