@flowdrop/flowdrop 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/components/ConfigForm.svelte +41 -21
- package/dist/components/ConfigPanel.svelte +7 -1
- package/dist/components/NodeSwapPicker.svelte +5 -1
- package/dist/components/PipelineStatus.svelte +11 -2
- package/dist/components/SchemaForm.svelte +28 -16
- package/dist/components/SettingsPanel.svelte +5 -1
- package/dist/components/WorkflowEditor.svelte +5 -1
- package/dist/components/chat/AIChatPanel.svelte +1 -5
- package/dist/components/form/FormAutocomplete.svelte +23 -12
- package/dist/components/interrupt/ChoicePrompt.svelte +5 -1
- package/dist/components/interrupt/InterruptBubble.svelte +4 -5
- package/dist/components/nodes/AtomNode.svelte +280 -0
- package/dist/components/nodes/AtomNode.svelte.d.ts +26 -0
- package/dist/components/playground/ChatBubble.svelte +6 -8
- package/dist/components/playground/ChatInput.svelte +11 -5
- package/dist/components/playground/ControlPanel.svelte +42 -29
- package/dist/components/playground/ExecutionConsole.svelte +5 -1
- package/dist/components/playground/ExecutionConsole.svelte.d.ts +2 -0
- package/dist/components/playground/ExecutionList.svelte +7 -2
- package/dist/components/playground/LogRow.svelte +2 -1
- package/dist/components/playground/MessageBubble.svelte +1 -4
- package/dist/components/playground/MessageCard.svelte +2 -1
- package/dist/components/playground/MessageMarkdown.svelte +15 -5
- package/dist/components/playground/MessageNotice.svelte +2 -1
- package/dist/components/playground/MessageStream.svelte +138 -17
- package/dist/components/playground/MessageStream.svelte.d.ts +5 -0
- package/dist/components/playground/MessageTagChip.svelte +24 -6
- package/dist/components/playground/PipelineKanbanView.svelte +40 -11
- package/dist/components/playground/PipelinePanel.svelte +5 -1
- package/dist/components/playground/PipelineTableView.svelte +20 -6
- package/dist/components/playground/Playground.svelte +94 -27
- package/dist/components/playground/PlaygroundStudio.svelte +21 -7
- package/dist/components/playground/pipelineViewUtils.svelte.js +11 -4
- package/dist/helpers/proximityConnect.d.ts +4 -1
- package/dist/helpers/proximityConnect.js +17 -1
- package/dist/openapi/v1/openapi.yaml +6466 -0
- package/dist/playground/mount.js +2 -2
- package/dist/registry/builtinNodes.d.ts +1 -1
- package/dist/registry/builtinNodes.js +13 -0
- package/dist/schemas/v1/workflow.schema.json +86 -3
- package/dist/services/playgroundService.d.ts +23 -4
- package/dist/services/playgroundService.js +22 -9
- package/dist/stores/playgroundStore.svelte.d.ts +29 -2
- package/dist/stores/playgroundStore.svelte.js +120 -35
- package/dist/types/index.d.ts +38 -3
- package/dist/types/playground.d.ts +36 -2
- package/dist/utils/formMerge.d.ts +36 -0
- package/dist/utils/formMerge.js +70 -0
- package/dist/utils/nodeTypes.js +1 -0
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -158,6 +158,11 @@ FlowDrop provides tree-shakeable sub-module exports so you can import only what
|
|
|
158
158
|
| `@flowdrop/flowdrop/settings` | SettingsPanel, stores, services |
|
|
159
159
|
| `@flowdrop/flowdrop/styles` | Base CSS stylesheet |
|
|
160
160
|
| `@flowdrop/flowdrop/schema` | Workflow JSON schema |
|
|
161
|
+
| `@flowdrop/flowdrop/openapi` | OpenAPI spec (YAML) for the FlowDrop backend API |
|
|
162
|
+
|
|
163
|
+
### OpenAPI spec
|
|
164
|
+
|
|
165
|
+
The full OpenAPI spec for the FlowDrop backend API ships with the package, version-matched to your installed release. It defines the node-config / form-element schema (`ConfigProperty`), playground messages, and every endpoint. Resolve it from `@flowdrop/flowdrop/openapi`, or read it directly at `node_modules/@flowdrop/flowdrop/dist/openapi/v1/openapi.yaml`. Point your AI assistant at that file when authoring node config schemas. The latest spec is also browsable at [api.flowdrop.io](https://api.flowdrop.io).
|
|
161
166
|
|
|
162
167
|
## Integration
|
|
163
168
|
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
import { logger } from '../utils/logger.js';
|
|
48
48
|
import { getDataTypeColorToken, getPortBackgroundColor } from '../utils/colors.js';
|
|
49
49
|
import { applyPortOrder } from '../utils/portUtils.js';
|
|
50
|
+
import { mergeWithDefaults, cascadeClearAutocompleteDependents } from '../utils/formMerge.js';
|
|
50
51
|
|
|
51
52
|
interface Props {
|
|
52
53
|
/** Optional workflow node (if provided, schema and values are derived from it) */
|
|
@@ -187,12 +188,30 @@
|
|
|
187
188
|
const initialConfig = $derived(values ?? node?.data.config ?? {});
|
|
188
189
|
|
|
189
190
|
/**
|
|
190
|
-
*
|
|
191
|
-
*
|
|
191
|
+
* User edits to config — only keys the user has touched since the current
|
|
192
|
+
* schema was loaded. configValues is derived from props + edits, so children
|
|
193
|
+
* mount with the correct values already in place (no parent→child race
|
|
194
|
+
* during the initial flush). Never assign to configValues directly.
|
|
192
195
|
*/
|
|
193
|
-
let
|
|
196
|
+
let edits = $state<Record<string, unknown>>({});
|
|
197
|
+
|
|
198
|
+
const configValues = $derived(mergeWithDefaults(configSchema, initialConfig, edits));
|
|
199
|
+
|
|
194
200
|
setContext<() => Record<string, unknown>>('flowdrop:getFormValues', () => configValues);
|
|
195
201
|
|
|
202
|
+
// Drop edits when the schema reference changes — covers dynamic-schema load
|
|
203
|
+
// (configSchema flips from undefined → loaded) and "different node opened"
|
|
204
|
+
// (node prop change → metadata.configSchema reference change). Identity
|
|
205
|
+
// comparison only — value churn in `initialConfig` preserves in-flight edits.
|
|
206
|
+
// svelte-ignore state_referenced_locally — capturing the initial derived reference is intentional; later changes are picked up by the effect below
|
|
207
|
+
let prevSchemaRef = configSchema;
|
|
208
|
+
$effect.pre(() => {
|
|
209
|
+
if (configSchema !== prevSchemaRef) {
|
|
210
|
+
prevSchemaRef = configSchema;
|
|
211
|
+
edits = {};
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
196
215
|
/**
|
|
197
216
|
* UI Extension values for display settings
|
|
198
217
|
* Merges node type defaults with instance overrides
|
|
@@ -294,22 +313,6 @@
|
|
|
294
313
|
}
|
|
295
314
|
});
|
|
296
315
|
|
|
297
|
-
/**
|
|
298
|
-
* Initialize config values when node/schema changes
|
|
299
|
-
*/
|
|
300
|
-
$effect(() => {
|
|
301
|
-
if (configSchema?.properties) {
|
|
302
|
-
const mergedConfig: Record<string, unknown> = {};
|
|
303
|
-
Object.entries(configSchema.properties).forEach(([key, field]) => {
|
|
304
|
-
const fieldConfig = field as Record<string, unknown>;
|
|
305
|
-
// Use existing value if available, otherwise use default
|
|
306
|
-
mergedConfig[key] =
|
|
307
|
-
initialConfig[key] !== undefined ? initialConfig[key] : fieldConfig.default;
|
|
308
|
-
});
|
|
309
|
-
configValues = mergedConfig;
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
|
|
313
316
|
/**
|
|
314
317
|
* Initialize UI extension values when node changes
|
|
315
318
|
*/
|
|
@@ -419,10 +422,22 @@
|
|
|
419
422
|
}
|
|
420
423
|
|
|
421
424
|
/**
|
|
422
|
-
* Handle field value changes from FormField components
|
|
425
|
+
* Handle field value changes from FormField components.
|
|
426
|
+
*
|
|
427
|
+
* When a field changes, also clear any sibling autocomplete that declared
|
|
428
|
+
* this field in its `autocomplete.params` map — its previous value was
|
|
429
|
+
* computed against the old dependency value and is now stale. The cascade
|
|
430
|
+
* runs only on user-driven edits via this codepath; undo/redo and external
|
|
431
|
+
* config replacement flow through `initialConfig` and don't trigger it (#33).
|
|
423
432
|
*/
|
|
424
433
|
function handleFieldChange(key: string, value: unknown): void {
|
|
425
|
-
configValues[key]
|
|
434
|
+
const previous = configValues[key];
|
|
435
|
+
edits[key] = value;
|
|
436
|
+
if (previous === value) return;
|
|
437
|
+
const dependents = cascadeClearAutocompleteDependents(configSchema, key);
|
|
438
|
+
for (const [depKey, depValue] of Object.entries(dependents)) {
|
|
439
|
+
edits[depKey] = depValue;
|
|
440
|
+
}
|
|
426
441
|
}
|
|
427
442
|
|
|
428
443
|
/**
|
|
@@ -434,6 +449,11 @@
|
|
|
434
449
|
if (onChange) {
|
|
435
450
|
const extensions = showUIExtensions && node ? uiExtensionValues : undefined;
|
|
436
451
|
onChange({ ...configValues }, extensions);
|
|
452
|
+
// Discharge the edits buffer at the commit boundary. Subsequent prop
|
|
453
|
+
// changes (parent absorbing the commit, undo/redo, collaboration) then
|
|
454
|
+
// flow through `initialConfig` cleanly instead of being shadowed by a
|
|
455
|
+
// stale local edit.
|
|
456
|
+
edits = {};
|
|
437
457
|
}
|
|
438
458
|
}
|
|
439
459
|
|
|
@@ -77,7 +77,13 @@
|
|
|
77
77
|
<Icon icon="heroicons:arrows-right-left" />
|
|
78
78
|
</button>
|
|
79
79
|
{/if}
|
|
80
|
-
<button
|
|
80
|
+
<button
|
|
81
|
+
class="config-panel__close"
|
|
82
|
+
onclick={onClose}
|
|
83
|
+
aria-label={m().layout.closeConfigPanel}
|
|
84
|
+
>
|
|
85
|
+
×
|
|
86
|
+
</button>
|
|
81
87
|
</div>
|
|
82
88
|
</div>
|
|
83
89
|
|
|
@@ -81,7 +81,11 @@
|
|
|
81
81
|
<div class="swap-picker">
|
|
82
82
|
<!-- Header -->
|
|
83
83
|
<div class="swap-picker__header">
|
|
84
|
-
<button
|
|
84
|
+
<button
|
|
85
|
+
class="swap-picker__back"
|
|
86
|
+
onclick={onCancel}
|
|
87
|
+
aria-label={m().layout.backToConfiguration}
|
|
88
|
+
>
|
|
85
89
|
<Icon icon="heroicons:arrow-left" />
|
|
86
90
|
</button>
|
|
87
91
|
<h2 class="swap-picker__title">Swap Node</h2>
|
|
@@ -37,8 +37,17 @@
|
|
|
37
37
|
) => void;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
let {
|
|
41
|
-
|
|
40
|
+
let {
|
|
41
|
+
pipelineId,
|
|
42
|
+
workflow,
|
|
43
|
+
apiClient,
|
|
44
|
+
baseUrl,
|
|
45
|
+
endpointConfig,
|
|
46
|
+
onActionsReady,
|
|
47
|
+
runLabel,
|
|
48
|
+
isEmbedded = false,
|
|
49
|
+
refreshTrigger = 0
|
|
50
|
+
}: Props = $props();
|
|
42
51
|
|
|
43
52
|
// Track previous trigger value so the $effect only fires on increments, not on initial mount.
|
|
44
53
|
// svelte-ignore state_referenced_locally
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
import FormUISchemaRenderer from './form/FormUISchemaRenderer.svelte';
|
|
63
63
|
import type { FieldSchema } from './form/index.js';
|
|
64
64
|
import { m, warnDeprecatedProp } from '../messages/index.js';
|
|
65
|
+
import { mergeWithDefaults, cascadeClearAutocompleteDependents } from '../utils/formMerge.js';
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
68
|
* Props interface for SchemaForm component
|
|
@@ -194,24 +195,24 @@
|
|
|
194
195
|
let formRef: HTMLFormElement | undefined = $state();
|
|
195
196
|
|
|
196
197
|
/**
|
|
197
|
-
*
|
|
198
|
+
* User edits — only keys the user has changed since the current schema
|
|
199
|
+
* was loaded. formValues is derived from props + edits, never synchronised
|
|
200
|
+
* via an effect, so children mount with the correct values already in place.
|
|
198
201
|
*/
|
|
199
|
-
let
|
|
202
|
+
let edits = $state<Record<string, unknown>>({});
|
|
203
|
+
|
|
204
|
+
const formValues = $derived(mergeWithDefaults(schema, values, edits));
|
|
205
|
+
|
|
200
206
|
setContext<() => Record<string, unknown>>('flowdrop:getFormValues', () => formValues);
|
|
201
207
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
$effect(() => {
|
|
207
|
-
if (schema
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const fieldConfig = field as Record<string, unknown>;
|
|
211
|
-
// Use provided value if available, otherwise use schema default
|
|
212
|
-
mergedValues[key] = values[key] !== undefined ? values[key] : fieldConfig.default;
|
|
213
|
-
});
|
|
214
|
-
formValues = mergedValues;
|
|
208
|
+
// Drop edits when the schema reference changes (different form mounted).
|
|
209
|
+
// Identity comparison only — value churn in `values` preserves in-flight edits.
|
|
210
|
+
// svelte-ignore state_referenced_locally — capturing the initial prop reference is intentional; later changes are picked up by the effect below
|
|
211
|
+
let prevSchemaRef = schema;
|
|
212
|
+
$effect.pre(() => {
|
|
213
|
+
if (schema !== prevSchemaRef) {
|
|
214
|
+
prevSchemaRef = schema;
|
|
215
|
+
edits = {};
|
|
215
216
|
}
|
|
216
217
|
});
|
|
217
218
|
|
|
@@ -234,7 +235,18 @@
|
|
|
234
235
|
* @param value - New field value
|
|
235
236
|
*/
|
|
236
237
|
function handleFieldChange(key: string, value: unknown): void {
|
|
237
|
-
formValues[key]
|
|
238
|
+
const previous = formValues[key];
|
|
239
|
+
edits[key] = value;
|
|
240
|
+
|
|
241
|
+
// Cascade-clear any autocomplete whose `params` references this field.
|
|
242
|
+
// Only runs on user-driven edits; undo/redo and external value-prop
|
|
243
|
+
// replacement flow through `values` and bypass this path (#33).
|
|
244
|
+
if (previous !== value) {
|
|
245
|
+
const dependents = cascadeClearAutocompleteDependents(schema, key);
|
|
246
|
+
for (const [depKey, depValue] of Object.entries(dependents)) {
|
|
247
|
+
edits[depKey] = depValue;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
238
250
|
|
|
239
251
|
// Notify parent of the change
|
|
240
252
|
if (onChange) {
|
|
@@ -362,7 +362,11 @@
|
|
|
362
362
|
|
|
363
363
|
<div class="flowdrop-settings-panel {className}">
|
|
364
364
|
<!-- Tab Navigation -->
|
|
365
|
-
<div
|
|
365
|
+
<div
|
|
366
|
+
class="flowdrop-settings-panel__tabs"
|
|
367
|
+
role="tablist"
|
|
368
|
+
aria-label={m().layout.settingsCategories}
|
|
369
|
+
>
|
|
366
370
|
{#each categories as category, index (category)}
|
|
367
371
|
<button
|
|
368
372
|
class="flowdrop-settings-panel__tab"
|
|
@@ -277,7 +277,11 @@
|
|
|
277
277
|
const rawStatus = statuses[node.id];
|
|
278
278
|
if (!rawStatus) return node;
|
|
279
279
|
|
|
280
|
-
const existing = node.data.executionInfo ?? {
|
|
280
|
+
const existing = node.data.executionInfo ?? {
|
|
281
|
+
status: 'idle' as const,
|
|
282
|
+
executionCount: 0,
|
|
283
|
+
isExecuting: false
|
|
284
|
+
};
|
|
281
285
|
return {
|
|
282
286
|
...node,
|
|
283
287
|
data: {
|
|
@@ -288,11 +288,7 @@
|
|
|
288
288
|
return;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
if (
|
|
292
|
-
getBehaviorSettings().chatAutoRetry &&
|
|
293
|
-
workflowId &&
|
|
294
|
-
autoRetryCount < MAX_AUTO_RETRIES
|
|
295
|
-
) {
|
|
291
|
+
if (getBehaviorSettings().chatAutoRetry && workflowId && autoRetryCount < MAX_AUTO_RETRIES) {
|
|
296
292
|
autoRetryCount++;
|
|
297
293
|
const errorText = buildBatchErrorMessage(
|
|
298
294
|
completedCount,
|
|
@@ -121,6 +121,7 @@
|
|
|
121
121
|
// Stable fingerprint — any change triggers selection clearing.
|
|
122
122
|
// JSON.stringify gives a canonical string without null-byte ambiguity.
|
|
123
123
|
const depFingerprint = $derived(JSON.stringify(depParamValues));
|
|
124
|
+
// svelte-ignore state_referenced_locally — intentional initial snapshot; the effect below tracks subsequent changes
|
|
124
125
|
let prevDepFingerprint = depFingerprint;
|
|
125
126
|
|
|
126
127
|
$effect(() => {
|
|
@@ -308,10 +309,9 @@
|
|
|
308
309
|
// Open dropdown
|
|
309
310
|
showDropdown();
|
|
310
311
|
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
312
|
+
// Free-text single mode commits on intent signals (Enter, blur, option select),
|
|
313
|
+
// NOT per keystroke — see issue #32. Treating the search-box text as the field's
|
|
314
|
+
// committed value pollutes the parent form's edits buffer and history.
|
|
315
315
|
|
|
316
316
|
// Fetch suggestions with debounce
|
|
317
317
|
debouncedFetch(inputValue);
|
|
@@ -352,6 +352,19 @@
|
|
|
352
352
|
inputValue = '';
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
|
+
|
|
356
|
+
// Free-text single mode — commit on blur if the user typed something
|
|
357
|
+
// that differs from the current value. An empty inputValue is just the
|
|
358
|
+
// resting state of the search box (the value lives in the chip), so it
|
|
359
|
+
// is NOT an intent signal; clearing happens via the X button or
|
|
360
|
+
// Backspace-on-empty. Replaces the per-keystroke commit dropped from
|
|
361
|
+
// handleInput; see issue #32.
|
|
362
|
+
if (allowFreeText && !multiple && inputValue !== '') {
|
|
363
|
+
const currentSingle = selectedValues[0] ?? '';
|
|
364
|
+
if (inputValue !== currentSingle) {
|
|
365
|
+
onChange(inputValue);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
355
368
|
}, 200);
|
|
356
369
|
}
|
|
357
370
|
|
|
@@ -591,12 +604,14 @@
|
|
|
591
604
|
const current = depFingerprint;
|
|
592
605
|
if (current === prevDepFingerprint) return;
|
|
593
606
|
prevDepFingerprint = current;
|
|
594
|
-
// A dependency field changed — abort any in-flight fetch
|
|
607
|
+
// A dependency field changed — abort any in-flight fetch and invalidate
|
|
608
|
+
// cached suggestions/labels (they were keyed by the previous dependency
|
|
609
|
+
// values). Do NOT clear this field's value here: that policy lives in the
|
|
610
|
+
// parent form's handleFieldChange so it only fires on user-driven changes,
|
|
611
|
+
// not on undo/redo, programmatic resets, or collaborative edits (#33).
|
|
595
612
|
abortController?.abort();
|
|
596
613
|
suggestions = [];
|
|
597
614
|
labelCache = new Map();
|
|
598
|
-
if (multiple) onChange([]);
|
|
599
|
-
else onChange('');
|
|
600
615
|
});
|
|
601
616
|
|
|
602
617
|
/**
|
|
@@ -721,11 +736,7 @@
|
|
|
721
736
|
style={popoverStyle}
|
|
722
737
|
onmousedown={(e) => e.preventDefault()}
|
|
723
738
|
>
|
|
724
|
-
<ul
|
|
725
|
-
class="form-autocomplete__listbox"
|
|
726
|
-
role="listbox"
|
|
727
|
-
aria-label={t.suggestions}
|
|
728
|
-
>
|
|
739
|
+
<ul class="form-autocomplete__listbox" role="listbox" aria-label={t.suggestions}>
|
|
729
740
|
{#if isLoading}
|
|
730
741
|
<li class="form-autocomplete__status form-autocomplete__status--loading">
|
|
731
742
|
<Icon icon="heroicons:arrow-path" class="form-autocomplete__status-icon" />
|
|
@@ -135,7 +135,11 @@
|
|
|
135
135
|
{/if}
|
|
136
136
|
|
|
137
137
|
<!-- Options -->
|
|
138
|
-
<div
|
|
138
|
+
<div
|
|
139
|
+
class="choice-prompt__options"
|
|
140
|
+
role={isMultiple ? 'group' : 'radiogroup'}
|
|
141
|
+
aria-label={config.message}
|
|
142
|
+
>
|
|
139
143
|
{#each config.options as option (option.value)}
|
|
140
144
|
{@const isChecked = isResolved ? isOptionResolved(option) : selectedValues.has(option.value)}
|
|
141
145
|
<label
|
|
@@ -16,10 +16,7 @@
|
|
|
16
16
|
import ReviewPrompt from './ReviewPrompt.svelte';
|
|
17
17
|
import MessageTagStrip from '../playground/MessageTagStrip.svelte';
|
|
18
18
|
import HierarchyTrail from '../playground/HierarchyTrail.svelte';
|
|
19
|
-
import type {
|
|
20
|
-
MessageHierarchyItem,
|
|
21
|
-
MessageTag
|
|
22
|
-
} from '../../types/playground.js';
|
|
19
|
+
import type { MessageHierarchyItem, MessageTag } from '../../types/playground.js';
|
|
23
20
|
import type {
|
|
24
21
|
Interrupt,
|
|
25
22
|
InterruptType,
|
|
@@ -304,7 +301,9 @@
|
|
|
304
301
|
<time
|
|
305
302
|
class="interrupt-bubble__timestamp"
|
|
306
303
|
datetime={currentInterrupt.resolvedAt ?? currentInterrupt.createdAt}
|
|
307
|
-
aria-label="sent at {formatTimestamp(
|
|
304
|
+
aria-label="sent at {formatTimestamp(
|
|
305
|
+
currentInterrupt.resolvedAt ?? currentInterrupt.createdAt
|
|
306
|
+
)}"
|
|
308
307
|
>
|
|
309
308
|
{formatTimestamp(currentInterrupt.resolvedAt ?? currentInterrupt.createdAt)}
|
|
310
309
|
</time>
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Atom Node Component
|
|
3
|
+
Minimalist, label-only node for "supplies a value" atoms (Constant now, Cast later).
|
|
4
|
+
Renders as a pill that hugs its content with handles driven by the node's ports.
|
|
5
|
+
|
|
6
|
+
The body text and the output port's dataType are both driven by config via
|
|
7
|
+
`extensions.ui.atom` (see AtomUIConfig). This component owns no domain semantics —
|
|
8
|
+
meaning comes entirely from the node's NodeMetadata.
|
|
9
|
+
-->
|
|
10
|
+
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import { Position, Handle } from '@xyflow/svelte';
|
|
13
|
+
import type {
|
|
14
|
+
ConfigValues,
|
|
15
|
+
ConfigSchema,
|
|
16
|
+
NodeMetadata,
|
|
17
|
+
NodeExtensions,
|
|
18
|
+
NodePort,
|
|
19
|
+
AtomUIConfig,
|
|
20
|
+
WorkflowNode as WorkflowNodeType
|
|
21
|
+
} from '../../types/index.js';
|
|
22
|
+
import { getDataTypeColor } from '../../utils/colors.js';
|
|
23
|
+
import { getConnectedHandles } from '../../stores/workflowStore.svelte.js';
|
|
24
|
+
import { applyPortOrder, isPortVisible } from '../../utils/portUtils.js';
|
|
25
|
+
import { ProximityConnectHelper } from '../../helpers/proximityConnect.js';
|
|
26
|
+
|
|
27
|
+
interface AtomNodeData {
|
|
28
|
+
label: string;
|
|
29
|
+
config: ConfigValues;
|
|
30
|
+
metadata: NodeMetadata;
|
|
31
|
+
nodeId?: string;
|
|
32
|
+
extensions?: NodeExtensions;
|
|
33
|
+
onConfigOpen?: (node: {
|
|
34
|
+
id: string;
|
|
35
|
+
type: string;
|
|
36
|
+
data: { label: string; config: ConfigValues; metadata: NodeMetadata };
|
|
37
|
+
}) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
data: AtomNodeData;
|
|
42
|
+
selected?: boolean;
|
|
43
|
+
isProcessing?: boolean;
|
|
44
|
+
isError?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let { data, selected, isProcessing, isError }: Props = $props();
|
|
48
|
+
|
|
49
|
+
const nodeId = $derived(data.nodeId ?? 'unknown');
|
|
50
|
+
const nodeType = $derived(data.metadata?.type ?? 'atom');
|
|
51
|
+
|
|
52
|
+
// Instance extensions override node-type defaults.
|
|
53
|
+
const atomCfg = $derived<AtomUIConfig>(
|
|
54
|
+
data.extensions?.ui?.atom ?? data.metadata?.extensions?.ui?.atom ?? {}
|
|
55
|
+
);
|
|
56
|
+
const hideUnconnectedHandles = $derived(
|
|
57
|
+
data.extensions?.ui?.hideUnconnectedHandles ??
|
|
58
|
+
data.metadata?.extensions?.ui?.hideUnconnectedHandles ??
|
|
59
|
+
false
|
|
60
|
+
);
|
|
61
|
+
const hiddenPorts = $derived(
|
|
62
|
+
data.extensions?.ui?.hiddenPorts ?? data.metadata?.extensions?.ui?.hiddenPorts ?? {}
|
|
63
|
+
);
|
|
64
|
+
const portOrder = $derived(
|
|
65
|
+
data.extensions?.ui?.portOrder ?? data.metadata?.extensions?.ui?.portOrder ?? {}
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Optional, server-driven accent. When unset the inline custom property is
|
|
69
|
+
// omitted, so CSS falls back to the neutral border — uncolored atoms are
|
|
70
|
+
// unchanged. Mirrors ToolNode: node definition (metadata) wins over instance.
|
|
71
|
+
const atomColor = $derived(data.metadata?.color ?? (data.config?.color as string | undefined));
|
|
72
|
+
const isRect = $derived(atomCfg.shape === 'rectangle');
|
|
73
|
+
|
|
74
|
+
// Only the dynamic bits live inline; max-width and accent both optional.
|
|
75
|
+
const nodeStyle = $derived(
|
|
76
|
+
[
|
|
77
|
+
atomCfg.maxWidth ? `max-width: ${atomCfg.maxWidth}px` : '',
|
|
78
|
+
atomColor ? `--fd-atom-node-color: ${atomColor}` : ''
|
|
79
|
+
]
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join('; ')
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// The node slice getAllPorts needs — keeps port resolution in one place,
|
|
85
|
+
// shared with proximity-connect and coordinate/validation logic. No cast: the
|
|
86
|
+
// helper's signature documents that `type` + `data` are all it reads.
|
|
87
|
+
const nodeLike = $derived<Pick<WorkflowNodeType, 'type' | 'data'>>({ type: nodeType, data });
|
|
88
|
+
|
|
89
|
+
const inPorts = $derived(
|
|
90
|
+
applyPortOrder(ProximityConnectHelper.getAllPorts(nodeLike, 'input'), portOrder.inputs).filter(
|
|
91
|
+
(p: NodePort) =>
|
|
92
|
+
isPortVisible(
|
|
93
|
+
p,
|
|
94
|
+
'input',
|
|
95
|
+
hiddenPorts,
|
|
96
|
+
hideUnconnectedHandles,
|
|
97
|
+
getConnectedHandles(),
|
|
98
|
+
nodeId
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
);
|
|
102
|
+
const outPorts = $derived(
|
|
103
|
+
applyPortOrder(
|
|
104
|
+
ProximityConnectHelper.getAllPorts(nodeLike, 'output'),
|
|
105
|
+
portOrder.outputs
|
|
106
|
+
).filter((p: NodePort) =>
|
|
107
|
+
isPortVisible(p, 'output', hiddenPorts, hideUnconnectedHandles, getConnectedHandles(), nodeId)
|
|
108
|
+
)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
/** Friendly label for a value, using the field's oneOf titles when present. */
|
|
112
|
+
function resolveDisplay(schema: ConfigSchema | undefined, key: string, raw: unknown): string {
|
|
113
|
+
const prop = schema?.properties?.[key] as
|
|
114
|
+
| { oneOf?: Array<{ const: unknown; title?: string }> }
|
|
115
|
+
| undefined;
|
|
116
|
+
const match = prop?.oneOf?.find((o) => o.const === raw);
|
|
117
|
+
if (match?.title) return match.title;
|
|
118
|
+
if (typeof raw === 'boolean') return raw ? 'true' : 'false';
|
|
119
|
+
return String(raw);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Body text: config[valueKey] (label-resolved), else node label. When neither
|
|
123
|
+
// resolves, fall back to the placeholder and render it dimmed.
|
|
124
|
+
const display = $derived.by(() => {
|
|
125
|
+
const key = atomCfg.valueKey;
|
|
126
|
+
const raw = key ? data.config?.[key] : undefined;
|
|
127
|
+
if (key && raw !== undefined && raw !== null && raw !== '') {
|
|
128
|
+
return { text: resolveDisplay(data.metadata?.configSchema, key, raw), empty: false };
|
|
129
|
+
}
|
|
130
|
+
if (data.label) return { text: data.label, empty: false };
|
|
131
|
+
return { text: atomCfg.placeholder ?? '', empty: true };
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Pill height is content-driven (~28px), so fixed px offsets don't fit.
|
|
135
|
+
// Distribute handles as a % of node height: 50% for one port, evenly otherwise.
|
|
136
|
+
function portTopPct(index: number, count: number): number {
|
|
137
|
+
return ((index + 1) / (count + 1)) * 100;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function openConfig(): void {
|
|
141
|
+
data.onConfigOpen?.({ id: nodeId, type: nodeType, data });
|
|
142
|
+
}
|
|
143
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
144
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
openConfig();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
</script>
|
|
150
|
+
|
|
151
|
+
{#each inPorts as port, index}
|
|
152
|
+
<Handle
|
|
153
|
+
type="target"
|
|
154
|
+
position={Position.Left}
|
|
155
|
+
id={`${nodeId}-input-${port.id}`}
|
|
156
|
+
style="--fd-handle-fill: var(--fd-port-skin-color, {getDataTypeColor(
|
|
157
|
+
port.dataType
|
|
158
|
+
)}); top: {portTopPct(index, inPorts.length)}%;"
|
|
159
|
+
/>
|
|
160
|
+
{/each}
|
|
161
|
+
|
|
162
|
+
<div
|
|
163
|
+
class="flowdrop-atom-node"
|
|
164
|
+
class:flowdrop-atom-node--selected={selected}
|
|
165
|
+
class:flowdrop-atom-node--processing={isProcessing}
|
|
166
|
+
class:flowdrop-atom-node--error={isError}
|
|
167
|
+
class:flowdrop-atom-node--empty={display.empty}
|
|
168
|
+
class:flowdrop-atom-node--rect={isRect}
|
|
169
|
+
style={nodeStyle}
|
|
170
|
+
onclick={openConfig}
|
|
171
|
+
onkeydown={handleKeydown}
|
|
172
|
+
role="button"
|
|
173
|
+
tabindex="0"
|
|
174
|
+
>
|
|
175
|
+
{#if atomCfg.prefix && !display.empty}
|
|
176
|
+
<span class="flowdrop-atom-node__prefix" aria-hidden="true">{atomCfg.prefix}</span>
|
|
177
|
+
{/if}
|
|
178
|
+
<span class="flowdrop-atom-node__body" title={display.text}>{display.text}</span>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{#each outPorts as port, index}
|
|
182
|
+
<Handle
|
|
183
|
+
type="source"
|
|
184
|
+
position={Position.Right}
|
|
185
|
+
id={`${nodeId}-output-${port.id}`}
|
|
186
|
+
style="--fd-handle-fill: var(--fd-port-skin-color, {getDataTypeColor(
|
|
187
|
+
port.dataType
|
|
188
|
+
)}); top: {portTopPct(index, outPorts.length)}%;"
|
|
189
|
+
/>
|
|
190
|
+
{/each}
|
|
191
|
+
|
|
192
|
+
<style>
|
|
193
|
+
.flowdrop-atom-node {
|
|
194
|
+
position: relative;
|
|
195
|
+
display: inline-flex;
|
|
196
|
+
align-items: center;
|
|
197
|
+
width: fit-content;
|
|
198
|
+
min-width: 2rem;
|
|
199
|
+
min-height: 28px;
|
|
200
|
+
padding: 2px var(--fd-space-sm);
|
|
201
|
+
background-color: var(--fd-card);
|
|
202
|
+
/* --fd-atom-node-color is set inline only when the server provides a color;
|
|
203
|
+
otherwise it falls back to the neutral border token. */
|
|
204
|
+
border: 1.5px solid var(--fd-atom-node-color, var(--fd-node-border));
|
|
205
|
+
border-radius: 999px;
|
|
206
|
+
box-shadow: var(--fd-shadow-sm);
|
|
207
|
+
color: var(--fd-foreground);
|
|
208
|
+
cursor: pointer;
|
|
209
|
+
transition:
|
|
210
|
+
box-shadow var(--fd-transition-fast),
|
|
211
|
+
border-color var(--fd-transition-fast);
|
|
212
|
+
z-index: 10;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.flowdrop-atom-node--rect {
|
|
216
|
+
border-radius: var(--fd-radius-md);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.flowdrop-atom-node:hover {
|
|
220
|
+
box-shadow: var(--fd-shadow-md);
|
|
221
|
+
border-color: var(--fd-atom-node-color, var(--fd-node-border-hover));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.flowdrop-atom-node--selected {
|
|
225
|
+
box-shadow:
|
|
226
|
+
0 0 0 2px color-mix(in srgb, var(--fd-atom-node-color, var(--fd-primary)) 30%, transparent),
|
|
227
|
+
var(--fd-shadow-md);
|
|
228
|
+
border-color: var(--fd-atom-node-color, var(--fd-primary));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.flowdrop-atom-node:focus-visible {
|
|
232
|
+
outline: 2px solid var(--fd-ring);
|
|
233
|
+
outline-offset: 2px;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.flowdrop-atom-node--processing {
|
|
237
|
+
opacity: 0.7;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.flowdrop-atom-node--error {
|
|
241
|
+
border-color: var(--fd-error) !important;
|
|
242
|
+
background-color: var(--fd-error-muted) !important;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.flowdrop-atom-node--empty .flowdrop-atom-node__body {
|
|
246
|
+
color: var(--fd-muted-foreground);
|
|
247
|
+
font-style: italic;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.flowdrop-atom-node__prefix {
|
|
251
|
+
flex-shrink: 0;
|
|
252
|
+
margin-right: 2px;
|
|
253
|
+
color: var(--fd-muted-foreground);
|
|
254
|
+
font-size: var(--fd-text-sm);
|
|
255
|
+
line-height: 1.2;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.flowdrop-atom-node__body {
|
|
259
|
+
/* min-width:0 lets the body ellipsize as a flex sibling of the prefix */
|
|
260
|
+
min-width: 0;
|
|
261
|
+
font-size: var(--fd-text-sm);
|
|
262
|
+
line-height: 1.2;
|
|
263
|
+
white-space: nowrap;
|
|
264
|
+
overflow: hidden;
|
|
265
|
+
text-overflow: ellipsis;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* `top` is set inline (dynamic, must beat svelte-flow defaults); transform and
|
|
269
|
+
stacking live here so the hover rule can compose instead of fighting !important. */
|
|
270
|
+
:global(.svelte-flow__node-atom .svelte-flow__handle) {
|
|
271
|
+
--fd-handle-border-color: var(--fd-handle-border);
|
|
272
|
+
transform: translateY(-50%);
|
|
273
|
+
z-index: 20 !important;
|
|
274
|
+
pointer-events: auto !important;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
:global(.svelte-flow__node-atom .svelte-flow__handle:hover) {
|
|
278
|
+
transform: translateY(-50%) scale(1.2);
|
|
279
|
+
}
|
|
280
|
+
</style>
|