feat(ui): edit objective/constraint source in panel + UNSET wiring

This commit is contained in:
2026-01-29 02:49:04 +00:00
parent e2cfa0a3d9
commit 993c1ff17f
3 changed files with 170 additions and 18 deletions

View File

@@ -560,9 +560,10 @@ function SpecRendererInner({
// clear the target's source to avoid stale runnable config.
if (sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) {
updateNode(edge.target, {
// Setting to an empty object would violate schema; clear to placeholder
// and let validation catch missing wiring.
source: { extractor_id: '', output_name: '' },
// Objective/constraint.source is required by schema.
// Use explicit UNSET placeholders so validation can catch it
// without risking accidental execution.
source: { extractor_id: '__UNSET__', output_name: '__UNSET__' },
}).catch((err) => {
console.error('Failed to clear source on node:', err);
setError(err.message);

View File

@@ -820,6 +820,34 @@ interface ObjectiveNodeConfigProps {
}
function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) {
const spec = useSpec();
const extractors = spec?.extractors || [];
const currentExtractorId = node.source?.extractor_id || '__UNSET__';
const currentOutputName = node.source?.output_name || '__UNSET__';
const selectedExtractor = extractors.find((e) => e.id === currentExtractorId);
const outputOptions = selectedExtractor?.outputs?.map((o) => o.name) || [];
const handleExtractorChange = (extractorId: string) => {
// Reset output_name to a sensible default when extractor changes
const ext = extractors.find((e) => e.id === extractorId);
const outs = ext?.outputs?.map((o) => o.name) || [];
const preferred = outs.includes('value') ? 'value' : outs[0] || '__UNSET__';
onChange('source', {
extractor_id: extractorId,
output_name: preferred,
});
};
const handleOutputChange = (outputName: string) => {
onChange('source', {
extractor_id: currentExtractorId,
output_name: outputName,
});
};
return (
<>
<div>
@@ -832,6 +860,45 @@ function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) {
/>
</div>
<div>
<label className={labelClass}>Source Extractor</label>
<select
value={currentExtractorId}
onChange={(e) => handleExtractorChange(e.target.value)}
className={selectClass}
>
<option value="__UNSET__">(not connected)</option>
{extractors.map((ext) => (
<option key={ext.id} value={ext.id}>
{ext.id} {ext.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Source Output</label>
<select
value={currentOutputName}
onChange={(e) => handleOutputChange(e.target.value)}
className={selectClass}
disabled={currentExtractorId === '__UNSET__'}
>
{currentExtractorId === '__UNSET__' ? (
<option value="__UNSET__">(select an extractor)</option>
) : (
outputOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))
)}
</select>
<p className="text-xs text-dark-500 mt-1">
This drives execution. Canvas wires are just a visual check.
</p>
</div>
<div>
<label className={labelClass}>Direction</label>
<select
@@ -877,6 +944,33 @@ interface ConstraintNodeConfigProps {
}
function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
const spec = useSpec();
const extractors = spec?.extractors || [];
const currentExtractorId = node.source?.extractor_id || '__UNSET__';
const currentOutputName = node.source?.output_name || '__UNSET__';
const selectedExtractor = extractors.find((e) => e.id === currentExtractorId);
const outputOptions = selectedExtractor?.outputs?.map((o) => o.name) || [];
const handleExtractorChange = (extractorId: string) => {
const ext = extractors.find((e) => e.id === extractorId);
const outs = ext?.outputs?.map((o) => o.name) || [];
const preferred = outs.includes('value') ? 'value' : outs[0] || '__UNSET__';
onChange('source', {
extractor_id: extractorId,
output_name: preferred,
});
};
const handleOutputChange = (outputName: string) => {
onChange('source', {
extractor_id: currentExtractorId,
output_name: outputName,
});
};
return (
<>
<div>
@@ -889,6 +983,45 @@ function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
/>
</div>
<div>
<label className={labelClass}>Source Extractor</label>
<select
value={currentExtractorId}
onChange={(e) => handleExtractorChange(e.target.value)}
className={selectClass}
>
<option value="__UNSET__">(not connected)</option>
{extractors.map((ext) => (
<option key={ext.id} value={ext.id}>
{ext.id} {ext.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Source Output</label>
<select
value={currentOutputName}
onChange={(e) => handleOutputChange(e.target.value)}
className={selectClass}
disabled={currentExtractorId === '__UNSET__'}
>
{currentExtractorId === '__UNSET__' ? (
<option value="__UNSET__">(select an extractor)</option>
) : (
outputOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))
)}
</select>
<p className="text-xs text-dark-500 mt-1">
This drives execution. Canvas wires are just a visual check.
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Type</label>
@@ -897,13 +1030,27 @@ function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
onChange={(e) => onChange('type', e.target.value)}
className={selectClass}
>
<option value="less_than">&lt; Less than</option>
<option value="less_equal">&lt;= Less or equal</option>
<option value="greater_than">&gt; Greater than</option>
<option value="greater_equal">&gt;= Greater or equal</option>
<option value="equal">= Equal</option>
<option value="hard">Hard</option>
<option value="soft">Soft</option>
</select>
<p className="text-xs text-dark-500 mt-1">Spec type (hard/soft). Operator is set below.</p>
</div>
<div>
<label className={labelClass}>Operator</label>
<select
value={node.operator}
onChange={(e) => onChange('operator', e.target.value)}
className={selectClass}
>
<option value="<=">&lt;=</option>
<option value="<">&lt;</option>
<option value=">=">&gt;=</option>
<option value=">">&gt;</option>
<option value="==">==</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>Threshold</label>
<input
@@ -913,7 +1060,6 @@ function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
className={inputClass}
/>
</div>
</div>
</>
);

View File

@@ -178,9 +178,14 @@ const validationRules: ValidationRule[] = [
edge => edge.target === obj.id && edge.source.startsWith('ext_')
);
// Also check if source.extractor_id is set
const hasDirectSource = obj.source?.extractor_id &&
spec.extractors.some(e => e.id === obj.source.extractor_id);
// Also check if source.extractor_id is set (and not UNSET placeholders)
const extractorId = obj.source?.extractor_id;
const outputName = obj.source?.output_name;
const hasDirectSource = Boolean(extractorId) &&
extractorId !== '__UNSET__' &&
Boolean(outputName) &&
outputName !== '__UNSET__' &&
spec.extractors.some(e => e.id === extractorId);
if (!hasSource && !hasDirectSource) {
return {