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 [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)
|
||||
const trialHistory = useMemo(() => {
|
||||
const history: Record<string, number[]> = {};
|
||||
@@ -589,39 +598,43 @@ function SpecRendererInner({
|
||||
const targetType = classify(targetId);
|
||||
|
||||
try {
|
||||
// Always persist the visual edge (for now)
|
||||
await addEdge(sourceId, targetId);
|
||||
|
||||
// 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')) {
|
||||
const ext = spec.extractors.find((e) => e.id === sourceId);
|
||||
const outputNames = (ext?.outputs || []).map((o) => o.name).filter(Boolean);
|
||||
|
||||
// Choose a sensible default output:
|
||||
// - prefer 'value' if present
|
||||
// - else if only one output, use it
|
||||
// - else use first output
|
||||
const outputs = ext?.outputs || [];
|
||||
const preferred = outputs.find((o) => o.name === 'value')?.name;
|
||||
const outputName =
|
||||
preferred || (outputs.length === 1 ? outputs[0].name : outputs.length > 0 ? outputs[0].name : 'value');
|
||||
|
||||
if (targetType === 'objective') {
|
||||
await updateNode(targetId, {
|
||||
source: { extractor_id: sourceId, output_name: outputName },
|
||||
});
|
||||
} else {
|
||||
await updateNode(targetId, {
|
||||
source: { extractor_id: sourceId, output_name: outputName },
|
||||
// If extractor has multiple outputs, prompt the user.
|
||||
if (outputNames.length > 1) {
|
||||
const preferred = outputNames.includes('value') ? 'value' : outputNames[0];
|
||||
setPendingOutputSelect({
|
||||
sourceId,
|
||||
targetId,
|
||||
outputNames,
|
||||
selected: preferred,
|
||||
});
|
||||
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) {
|
||||
console.error('Failed to add connection:', err);
|
||||
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
|
||||
@@ -760,6 +773,34 @@ function SpecRendererInner({
|
||||
[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
|
||||
if (showLoadingOverlay && isLoading && !spec) {
|
||||
return (
|
||||
@@ -842,6 +883,55 @@ function SpecRendererInner({
|
||||
</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
|
||||
nodes={localNodes}
|
||||
edges={edges}
|
||||
|
||||
Reference in New Issue
Block a user