@scenario-labs/sdk 3.3.1 → 3.4.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.
Files changed (142) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/client.d.mts +4 -4
  3. package/client.d.mts.map +1 -1
  4. package/client.d.ts +4 -4
  5. package/client.d.ts.map +1 -1
  6. package/client.js.map +1 -1
  7. package/client.mjs.map +1 -1
  8. package/index.d.mts +1 -1
  9. package/index.d.mts.map +1 -1
  10. package/index.d.ts +1 -1
  11. package/index.d.ts.map +1 -1
  12. package/index.js +2 -1
  13. package/index.js.map +1 -1
  14. package/index.mjs +1 -1
  15. package/index.mjs.map +1 -1
  16. package/lib/user_selection_limits.d.mts +13 -0
  17. package/lib/user_selection_limits.d.mts.map +1 -0
  18. package/lib/user_selection_limits.d.ts +13 -0
  19. package/lib/user_selection_limits.d.ts.map +1 -0
  20. package/lib/user_selection_limits.js +16 -0
  21. package/lib/user_selection_limits.js.map +1 -0
  22. package/lib/user_selection_limits.mjs +13 -0
  23. package/lib/user_selection_limits.mjs.map +1 -0
  24. package/lib/workflow_converter.d.mts +6 -0
  25. package/lib/workflow_converter.d.mts.map +1 -1
  26. package/lib/workflow_converter.d.ts +6 -0
  27. package/lib/workflow_converter.d.ts.map +1 -1
  28. package/lib/workflow_converter.js +154 -52
  29. package/lib/workflow_converter.js.map +1 -1
  30. package/lib/workflow_converter.mjs +154 -52
  31. package/lib/workflow_converter.mjs.map +1 -1
  32. package/lib/workflow_import_validator.d.mts +17 -2
  33. package/lib/workflow_import_validator.d.mts.map +1 -1
  34. package/lib/workflow_import_validator.d.ts +17 -2
  35. package/lib/workflow_import_validator.d.ts.map +1 -1
  36. package/lib/workflow_import_validator.js +100 -1
  37. package/lib/workflow_import_validator.js.map +1 -1
  38. package/lib/workflow_import_validator.mjs +99 -1
  39. package/lib/workflow_import_validator.mjs.map +1 -1
  40. package/lib/workflow_validator.d.mts +1 -1
  41. package/lib/workflow_validator.d.mts.map +1 -1
  42. package/lib/workflow_validator.d.ts +1 -1
  43. package/lib/workflow_validator.d.ts.map +1 -1
  44. package/lib/workflow_validator.js +10 -11
  45. package/lib/workflow_validator.js.map +1 -1
  46. package/lib/workflow_validator.mjs +7 -8
  47. package/lib/workflow_validator.mjs.map +1 -1
  48. package/package.json +1 -1
  49. package/resources/assets/assets.d.mts +126 -30
  50. package/resources/assets/assets.d.mts.map +1 -1
  51. package/resources/assets/assets.d.ts +126 -30
  52. package/resources/assets/assets.d.ts.map +1 -1
  53. package/resources/assets/assets.js +12 -0
  54. package/resources/assets/assets.js.map +1 -1
  55. package/resources/assets/assets.mjs +12 -0
  56. package/resources/assets/assets.mjs.map +1 -1
  57. package/resources/assets/index.d.mts +1 -1
  58. package/resources/assets/index.d.mts.map +1 -1
  59. package/resources/assets/index.d.ts +1 -1
  60. package/resources/assets/index.d.ts.map +1 -1
  61. package/resources/assets/index.js.map +1 -1
  62. package/resources/assets/index.mjs.map +1 -1
  63. package/resources/generate.d.mts +120 -9
  64. package/resources/generate.d.mts.map +1 -1
  65. package/resources/generate.d.ts +120 -9
  66. package/resources/generate.d.ts.map +1 -1
  67. package/resources/index.d.mts +2 -2
  68. package/resources/index.d.mts.map +1 -1
  69. package/resources/index.d.ts +2 -2
  70. package/resources/index.d.ts.map +1 -1
  71. package/resources/index.js.map +1 -1
  72. package/resources/index.mjs.map +1 -1
  73. package/resources/jobs.d.mts +36 -0
  74. package/resources/jobs.d.mts.map +1 -1
  75. package/resources/jobs.d.ts +36 -0
  76. package/resources/jobs.d.ts.map +1 -1
  77. package/resources/models/description.d.mts +4 -4
  78. package/resources/models/description.d.mts.map +1 -1
  79. package/resources/models/description.d.ts +4 -4
  80. package/resources/models/description.d.ts.map +1 -1
  81. package/resources/models/examples.d.mts +40 -6
  82. package/resources/models/examples.d.mts.map +1 -1
  83. package/resources/models/examples.d.ts +40 -6
  84. package/resources/models/examples.d.ts.map +1 -1
  85. package/resources/models/index.d.mts +1 -1
  86. package/resources/models/index.d.mts.map +1 -1
  87. package/resources/models/index.d.ts +1 -1
  88. package/resources/models/index.d.ts.map +1 -1
  89. package/resources/models/index.js.map +1 -1
  90. package/resources/models/index.mjs.map +1 -1
  91. package/resources/models/models.d.mts +1353 -199
  92. package/resources/models/models.d.mts.map +1 -1
  93. package/resources/models/models.d.ts +1353 -199
  94. package/resources/models/models.d.ts.map +1 -1
  95. package/resources/models/models.js +13 -0
  96. package/resources/models/models.js.map +1 -1
  97. package/resources/models/models.mjs +13 -0
  98. package/resources/models/models.mjs.map +1 -1
  99. package/resources/models/train.d.mts +36 -2
  100. package/resources/models/train.d.mts.map +1 -1
  101. package/resources/models/train.d.ts +36 -2
  102. package/resources/models/train.d.ts.map +1 -1
  103. package/resources/recommendations.d.mts +16 -5
  104. package/resources/recommendations.d.mts.map +1 -1
  105. package/resources/recommendations.d.ts +16 -5
  106. package/resources/recommendations.d.ts.map +1 -1
  107. package/resources/usages.d.mts +14 -2
  108. package/resources/usages.d.mts.map +1 -1
  109. package/resources/usages.d.ts +14 -2
  110. package/resources/usages.d.ts.map +1 -1
  111. package/resources/workflows.d.mts +152 -9
  112. package/resources/workflows.d.mts.map +1 -1
  113. package/resources/workflows.d.ts +152 -9
  114. package/resources/workflows.d.ts.map +1 -1
  115. package/resources/workflows.js +2 -3
  116. package/resources/workflows.js.map +1 -1
  117. package/resources/workflows.mjs +2 -3
  118. package/resources/workflows.mjs.map +1 -1
  119. package/src/client.ts +8 -0
  120. package/src/index.ts +1 -0
  121. package/src/lib/user_selection_limits.ts +14 -0
  122. package/src/lib/workflow_converter.ts +173 -54
  123. package/src/lib/workflow_import_validator.ts +116 -1
  124. package/src/lib/workflow_validator.ts +12 -8
  125. package/src/resources/assets/assets.ts +164 -4
  126. package/src/resources/assets/index.ts +2 -0
  127. package/src/resources/generate.ts +142 -0
  128. package/src/resources/index.ts +4 -0
  129. package/src/resources/jobs.ts +42 -0
  130. package/src/resources/models/description.ts +6 -0
  131. package/src/resources/models/examples.ts +48 -0
  132. package/src/resources/models/index.ts +2 -0
  133. package/src/resources/models/models.ts +1610 -114
  134. package/src/resources/models/train.ts +42 -0
  135. package/src/resources/recommendations.ts +18 -3
  136. package/src/resources/usages.ts +16 -0
  137. package/src/resources/workflows.ts +176 -9
  138. package/src/version.ts +1 -1
  139. package/version.d.mts +1 -1
  140. package/version.d.ts +1 -1
  141. package/version.js +1 -1
  142. package/version.mjs +1 -1
@@ -0,0 +1,14 @@
1
+ /** Inclusive lower bound for user-selection `min` / `max` and choose counts. */
2
+ export const USER_SELECTION_MIN_SELECT = 1;
3
+
4
+ /**
5
+ * Caps how many candidates may be offered (and how high `input.min` may be).
6
+ * Matches the API `MAX_USER_SELECTION_CANDIDATES` limit.
7
+ */
8
+ export const USER_SELECTION_MAX_CANDIDATES = 50;
9
+
10
+ /**
11
+ * Caps how many items the user may pick (`input.max`).
12
+ * Matches the API `USER_SELECTION_DEFAULT_MAX` (also the omitted-`max` default).
13
+ */
14
+ export const USER_SELECTION_DEFAULT_MAX = 100;
@@ -1,6 +1,8 @@
1
1
  // Converts a workflow editor state (nodes + edges) to a flow array suitable for the Scenario API.
2
2
  // Ported from fawkes useConvertWorkflowToApp — pure function, no React dependencies.
3
3
 
4
+ import { USER_SELECTION_MAX_CANDIDATES, USER_SELECTION_MIN_SELECT } from './user_selection_limits';
5
+
4
6
  // ---- Public types ----
5
7
 
6
8
  export type WorkflowEditorHandleInput = {
@@ -47,6 +49,7 @@ export type WorkflowEditorNode =
47
49
  }
48
50
  | { id: string; type: 'transformText'; data: NodeCommonData & { value?: string } }
49
51
  | { id: string; type: 'splitText'; data: NodeCommonData & { splitDelimiter?: string } }
52
+ | { id: string; type: 'joinTexts'; data: NodeCommonData & { joinDelimiter?: string } }
50
53
  | { id: string; type: 'aspectRatio'; data: NodeCommonData & { output?: string; quality?: string } }
51
54
  | { id: string; type: 'groupItems'; data: NodeCommonData & { value?: string } }
52
55
  | {
@@ -136,8 +139,6 @@ type ModelInputNodeData = NodeCommonData & {
136
139
 
137
140
  const RUNNABLE_TYPES = new Set(['model', 'llm', 'forEach', 'forEachEnd']);
138
141
  const MODEL_CUSTOM_INPUT_HANDLE_SUFFIX = '-source-custom';
139
- const USER_SELECTION_MIN_SELECT = 1;
140
- const USER_SELECTION_MAX_SELECT = 100;
141
142
  const DEFAULT_SLICE_ASSETS_CHOOSE_MESSAGE = 'Select the items to continue';
142
143
 
143
144
  type SliceAssetsMode = 'default' | 'randomize' | 'choose';
@@ -232,7 +233,12 @@ function toCelIdentifier(name: string): string {
232
233
  }
233
234
 
234
235
  function escapeCELString(value: string): string {
235
- return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n');
236
+ return value
237
+ .replace(/\\/g, '\\\\')
238
+ .replace(/'/g, "\\'")
239
+ .replace(/\n/g, '\\n')
240
+ .replace(/\r/g, '\\r')
241
+ .replace(/\t/g, '\\t');
236
242
  }
237
243
 
238
244
  function formatTextAsCELString(text: string): string {
@@ -391,6 +397,7 @@ function getForEachFlowListInputDescriptor(args: {
391
397
  listProvider?.type === 'text' ||
392
398
  listProvider?.type === 'llm' ||
393
399
  listProvider?.type === 'transformText' ||
400
+ listProvider?.type === 'joinTexts' ||
394
401
  (outputHandle?.type === 'text' && !!outputHandle.isArray);
395
402
  return isText ?
396
403
  { name: `text${inputIndex}`, type: 'string_array' }
@@ -592,21 +599,50 @@ function getIsSliceAssetsItemsSourceHandle(nodeId: string, sourceHandle: string
592
599
  }
593
600
 
594
601
  /**
595
- * Prompt Builder (transformText) binds upstream vars for CEL. Reuse the same
596
- * list-producer rules as concat/slice (`groupItems`, `sliceAssets`, `allOutputs`,
597
- * split-text, LLM "All Results", `isArray` handles): those nodes always emit CEL
598
- * lists (via concat/slice/randomSample), so Prompt Builder must bind them as
599
- * `string_array` for indexing. Scalar text (plain text nodes, forEach outputs)
600
- * stays `string`.
602
+ * True when `expression` treats `varName` as a CEL list via indexing, `size(x)`,
603
+ * or list macros (`.map` / `.filter` / …). This is a converter tolerance for
604
+ * flows that index a list producer (editor First/Last/Nth stores `x[0]` on
605
+ * `isArray` handles; agent/MCP/import flows may index without that flag). It is
606
+ * not Fawkes's preview rule: the webapp binds a list only when the handle has
607
+ * `isArray`. Intentionally narrow so Prompt Builder cannot be steered into
608
+ * list semantics through other CEL list idioms.
609
+ */
610
+ function expressionUsesListVariable(expression: string, varName: string): boolean {
611
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
612
+ return (
613
+ new RegExp(`${escaped}\\s*\\[`).test(expression) ||
614
+ new RegExp(`\\bsize\\s*\\(\\s*${escaped}\\s*\\)`).test(expression) ||
615
+ new RegExp(`${escaped}\\s*\\.\\s*(map|filter|all|exists|exists_one)\\s*\\(`).test(expression)
616
+ );
617
+ }
618
+
619
+ /**
620
+ * Prompt Builder (transformText) binds upstream vars for CEL. List producers
621
+ * bind as `string_array` when the source handle is `isArray`, or when the
622
+ * expression indexes/uses the variable as a list (see
623
+ * `expressionUsesListVariable`). Otherwise bare refs stay `string` so the API
624
+ * collapses the list to its first item.
625
+ *
626
+ * Fawkes today omits `isArray` on Slice/Group Items on purpose: only `isArray`
627
+ * handles get an indexed insert (`x[0]`); every other list insert is bare `x`
628
+ * and must collapse to the first item. Adding `isArray` to those handles later
629
+ * requires migrating stored expressions (`x` → `x[0]`) in the same Fawkes
630
+ * release, or both preview and published flows break on `'…' + x`.
601
631
  */
602
632
  function getPromptBuilderInputType(
603
633
  targetNode: WorkflowEditorNode,
604
634
  outHandle: WorkflowEditorHandleOutput | undefined,
605
635
  outHandleName: string,
636
+ expression: string,
637
+ varName: string,
606
638
  ): 'string' | 'string_array' {
607
- return getConcatListSourceDescriptor(targetNode, outHandle, outHandleName).inputType === 'string_array' ?
608
- 'string_array'
609
- : 'string';
639
+ if (getConcatListSourceDescriptor(targetNode, outHandle, outHandleName).inputType !== 'string_array') {
640
+ return 'string';
641
+ }
642
+ if (outHandle?.isArray || expressionUsesListVariable(expression, varName)) {
643
+ return 'string_array';
644
+ }
645
+ return 'string';
610
646
  }
611
647
 
612
648
  function getConcatListSourceDescriptor(
@@ -903,17 +939,65 @@ function applyAspectRatioInputs(args: {
903
939
  }
904
940
  }
905
941
 
942
+ /**
943
+ * Neighbours of every editor node, built once per conversion. Edges read
944
+ * `{ source: consumer, target: provider }`: an input handle on `source` reads an
945
+ * output handle on `target`. `consumers` keeps the internal for-each → end edge,
946
+ * which lets the body walk reach a nested loop from its end. `dataProviders` and
947
+ * `dataConsumers` drop it: it carries no data, and it closes a cycle
948
+ * (loop → body → end → loop) that a walk must not follow. They also drop the
949
+ * edge of an approval: it gates the node it is attached to and follows it into
950
+ * a loop body, so it is not a consumer through which a draw escapes the loop.
951
+ */
952
+ type LoopGraph = {
953
+ nodeById: Map<string, WorkflowEditorNode>;
954
+ consumers: Map<string, string[]>;
955
+ dataProviders: Map<string, string[]>;
956
+ dataConsumers: Map<string, string[]>;
957
+ };
958
+
959
+ function buildLoopGraph(nodes: WorkflowEditorNode[], edges: WorkflowEditorEdge[]): LoopGraph {
960
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
961
+ const isInternalForEachEdge = (e: WorkflowEditorEdge): boolean => {
962
+ const target = nodeById.get(e.target);
963
+ return (
964
+ nodeById.get(e.source)?.type === 'forEach' &&
965
+ target?.type === 'forEachEnd' &&
966
+ target.data.parentNodeId === e.source
967
+ );
968
+ };
969
+ const push = (map: Map<string, string[]>, key: string, value: string) => {
970
+ const list = map.get(key);
971
+ if (list) list.push(value);
972
+ else map.set(key, [value]);
973
+ };
974
+ const graph: LoopGraph = {
975
+ nodeById,
976
+ consumers: new Map(),
977
+ dataProviders: new Map(),
978
+ dataConsumers: new Map(),
979
+ };
980
+ for (const e of edges) {
981
+ push(graph.consumers, e.target, e.source);
982
+ if (isInternalForEachEdge(e) || nodeById.get(e.source)?.type === 'approval') continue;
983
+ push(graph.dataProviders, e.source, e.target);
984
+ push(graph.dataConsumers, e.target, e.source);
985
+ }
986
+ return graph;
987
+ }
988
+
906
989
  function getForEachBodyEditorNodeIds(
907
990
  forEachId: string,
908
991
  forEachEndId: string,
909
992
  edges: WorkflowEditorEdge[],
910
- nodes: WorkflowEditorNode[],
993
+ graph: LoopGraph,
911
994
  ): Set<string> {
912
995
  const bodyNodeIds = new Set<string>();
913
996
  const queue: string[] = [];
914
997
  const escaped = forEachId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
998
+ const itemHandle = new RegExp(`^${escaped}-output-\\d+$`);
915
999
  for (const edge of edges) {
916
- if (edge.target === forEachId && edge.targetHandle?.match(new RegExp(`^${escaped}-output-\\d+$`))) {
1000
+ if (edge.target === forEachId && edge.targetHandle && itemHandle.test(edge.targetHandle)) {
917
1001
  queue.push(edge.source);
918
1002
  }
919
1003
  }
@@ -921,14 +1005,12 @@ function getForEachBodyEditorNodeIds(
921
1005
  const nodeId = queue.pop()!;
922
1006
  if (nodeId === forEachEndId || bodyNodeIds.has(nodeId)) continue;
923
1007
  bodyNodeIds.add(nodeId);
924
- for (const edge of edges) {
925
- if (edge.target === nodeId && !bodyNodeIds.has(edge.source) && edge.source !== forEachEndId) {
926
- queue.push(edge.source);
927
- }
1008
+ for (const consumer of graph.consumers.get(nodeId) ?? []) {
1009
+ if (!bodyNodeIds.has(consumer) && consumer !== forEachEndId) queue.push(consumer);
928
1010
  }
929
1011
  }
930
1012
 
931
- addRandomizeClosureToBody(bodyNodeIds, forEachEndId, edges, nodes);
1013
+ addRandomizeClosureToBody(bodyNodeIds, forEachEndId, graph);
932
1014
 
933
1015
  return bodyNodeIds;
934
1016
  }
@@ -944,36 +1026,36 @@ function getForEachBodyEditorNodeIds(
944
1026
  * for an UNLOCKED randomize node we mutate `bodyNodeIds` in place to also include
945
1027
  * that node plus the deterministic chain down to the body, so the runtime clones
946
1028
  * it per iteration and each iteration re-draws. We only pull it in when *every*
947
- * path out of it lands in the body — if it also feeds a node outside the loop,
948
- * cloning it would break that external consumer, so we leave it shared.
1029
+ * path out of it lands in the body or in this loop's results — if it also feeds a
1030
+ * node outside the loop, cloning it would break that external consumer, so we
1031
+ * leave it shared.
1032
+ *
1033
+ * Loop nodes (any for-each or loop end outside the body) stop both walks: a
1034
+ * loop's list is computed once, before the loop runs, so neither a loop nor what
1035
+ * feeds its list may be cloned with another loop's body. A for-each in its own
1036
+ * body would even expand again on every iteration. A node that reaches one stays
1037
+ * shared.
949
1038
  */
950
- function addRandomizeClosureToBody(
951
- bodyNodeIds: Set<string>,
952
- forEachEndId: string,
953
- edges: WorkflowEditorEdge[],
954
- nodes: WorkflowEditorNode[],
955
- ): void {
956
- const nodeById = new Map(nodes.map((n) => [n.id, n]));
1039
+ function addRandomizeClosureToBody(bodyNodeIds: Set<string>, forEachEndId: string, graph: LoopGraph): void {
1040
+ const isLoopNode = (id: string): boolean => {
1041
+ const type = graph.nodeById.get(id)?.type;
1042
+ return type === 'forEach' || type === 'forEachEnd';
1043
+ };
957
1044
  const isUnlockedRandomize = (id: string): boolean => {
958
- const data = nodeById.get(id)?.data as
1045
+ const data = graph.nodeById.get(id)?.data as
959
1046
  | { mode?: SliceAssetsMode; isRandomize?: boolean; isSeedLocked?: boolean }
960
1047
  | undefined;
961
1048
  return !!data && getSliceAssetsMode(data) === 'randomize' && data.isSeedLocked !== true;
962
1049
  };
963
- // edge convention: `{ source: consumer, target: provider }` — an input handle
964
- // on `source` reads an output handle on `target`.
965
- const providersOf = (id: string): string[] => edges.filter((e) => e.source === id).map((e) => e.target);
966
- const consumersOf = (id: string): string[] => edges.filter((e) => e.target === id).map((e) => e.source);
967
1050
 
968
- // Every node strictly upstream of the body (its transitive input providers).
1051
+ // The nodes that feed the body without going through a loop node.
969
1052
  const upstream = new Set<string>();
970
1053
  const upstreamQueue = [...bodyNodeIds];
971
1054
  while (upstreamQueue.length > 0) {
972
- for (const provider of providersOf(upstreamQueue.pop()!)) {
973
- if (provider !== forEachEndId && !bodyNodeIds.has(provider) && !upstream.has(provider)) {
974
- upstream.add(provider);
975
- upstreamQueue.push(provider);
976
- }
1055
+ for (const provider of graph.dataProviders.get(upstreamQueue.pop()!) ?? []) {
1056
+ if (bodyNodeIds.has(provider) || upstream.has(provider) || isLoopNode(provider)) continue;
1057
+ upstream.add(provider);
1058
+ upstreamQueue.push(provider);
977
1059
  }
978
1060
  }
979
1061
 
@@ -985,16 +1067,16 @@ function addRandomizeClosureToBody(
985
1067
  let escapesLoop = false;
986
1068
  while (chainQueue.length > 0) {
987
1069
  const id = chainQueue.pop()!;
988
- if (bodyNodeIds.has(id)) continue; // reached the loop body — a valid sink
989
- chain.add(id);
990
- const consumers = consumersOf(id);
991
- if (consumers.length === 0) {
992
- escapesLoop = true; // dead-end outside the loop → do not internalize
1070
+ // The body and this loop's results are where a per-iteration draw belongs.
1071
+ if (bodyNodeIds.has(id) || id === forEachEndId || chain.has(id)) continue;
1072
+ const consumers = graph.dataConsumers.get(id) ?? [];
1073
+ // Another loop, or a dead end outside the loop: the draw must stay shared.
1074
+ if (isLoopNode(id) || consumers.length === 0) {
1075
+ escapesLoop = true;
993
1076
  break;
994
1077
  }
995
- for (const consumer of consumers) {
996
- if (!bodyNodeIds.has(consumer)) chainQueue.push(consumer);
997
- }
1078
+ chain.add(id);
1079
+ chainQueue.push(...consumers);
998
1080
  }
999
1081
  if (!escapesLoop) for (const id of chain) bodyNodeIds.add(id);
1000
1082
  }
@@ -1275,13 +1357,14 @@ export function convertWorkflowEditorToFlow(
1275
1357
  const forEachLoopFlowIds = new Map<string, string[]>();
1276
1358
  const forEachFlowItemRefs = new Map<string, WorkflowEditorFlowItem>();
1277
1359
 
1360
+ const loopGraph = buildLoopGraph(nodes, edges);
1278
1361
  for (const node of sortedNodes) {
1279
1362
  if (node.type !== 'forEach') continue;
1280
1363
  const endNode = nodes.find(
1281
1364
  (n) => n.type === 'forEachEnd' && (n.data as { parentNodeId?: string }).parentNodeId === node.id,
1282
1365
  );
1283
1366
  if (!endNode) continue;
1284
- forEachBodyEditorNodeIds.set(node.id, getForEachBodyEditorNodeIds(node.id, endNode.id, edges, nodes));
1367
+ forEachBodyEditorNodeIds.set(node.id, getForEachBodyEditorNodeIds(node.id, endNode.id, edges, loopGraph));
1285
1368
  forEachLoopFlowIds.set(node.id, []);
1286
1369
  }
1287
1370
 
@@ -1660,9 +1743,10 @@ export function convertWorkflowEditorToFlow(
1660
1743
  const outHandle = targetNode.data.outputHandles?.find((h) => h.id === edge.targetHandle);
1661
1744
  const outHandleName = outHandle?.name ?? 'output';
1662
1745
  const targetFlowId = nodeIdToFlowIdMap.get(targetNode.id) ?? targetNode.id;
1746
+ const varName = `${targetFlowId}_${outHandleName}`;
1663
1747
  flowInputs.push({
1664
- name: `${targetFlowId}_${outHandleName}`,
1665
- type: getPromptBuilderInputType(targetNode, outHandle, outHandleName),
1748
+ name: varName,
1749
+ type: getPromptBuilderInputType(targetNode, outHandle, outHandleName, value, varName),
1666
1750
  ref,
1667
1751
  });
1668
1752
  }
@@ -1704,6 +1788,35 @@ export function convertWorkflowEditorToFlow(
1704
1788
  inputs: flowInputs.length > 0 ? flowInputs : undefined,
1705
1789
  });
1706
1790
 
1791
+ // ------------------------------------
1792
+ // JoinTexts -> join string list into a single string
1793
+ // ------------------------------------
1794
+ } else if (node.type === 'joinTexts') {
1795
+ const flowInputs: WorkflowEditorFlowInput[] = [];
1796
+ const conditionalHandleId = getConditionalInputHandleId(node.id);
1797
+ let listSourceVarName: string | undefined;
1798
+
1799
+ for (const edge of incomingEdges) {
1800
+ if (edge.sourceHandle === conditionalHandleId) continue;
1801
+ const targetNode = nodes.find((n) => n.id === edge.target);
1802
+ if (!targetNode) continue;
1803
+ const ref = getSourceRef(edge, ctx);
1804
+ if (!ref) continue;
1805
+ const varName = toCelIdentifier(targetNode.id);
1806
+ flowInputs.push({ name: varName, type: 'string_array', ref });
1807
+ listSourceVarName = varName;
1808
+ }
1809
+ flowInputs.push(...collectConditionalConnectionInputs(incomingEdges, node.id, ctx));
1810
+ const delim = (node.data as { joinDelimiter?: string }).joinDelimiter ?? ', ';
1811
+ flow.push({
1812
+ id: flowId,
1813
+ type: 'transform',
1814
+ logic: {
1815
+ transform: `${listSourceVarName ?? '[]'}.join(${formatTextAsCELString(delim)})`,
1816
+ },
1817
+ inputs: flowInputs.length > 0 ? flowInputs : undefined,
1818
+ });
1819
+
1707
1820
  // ------------------------------------
1708
1821
  // GroupItems -> array concatenation
1709
1822
  // ------------------------------------
@@ -1767,7 +1880,10 @@ export function convertWorkflowEditorToFlow(
1767
1880
  getOutputHandleVarName,
1768
1881
  );
1769
1882
  const count = clampUserSelectionCount(parseInt(String(sliceData.count ?? ''), 10) || 1);
1770
- const message = sliceData.message?.trim() || DEFAULT_SLICE_ASSETS_CHOOSE_MESSAGE;
1883
+ const message =
1884
+ typeof sliceData.message === 'string' ?
1885
+ sliceData.message.trim() || DEFAULT_SLICE_ASSETS_CHOOSE_MESSAGE
1886
+ : DEFAULT_SLICE_ASSETS_CHOOSE_MESSAGE;
1771
1887
  const arrayType = getUserSelectionArrayType(flowInputs);
1772
1888
 
1773
1889
  // Single array-typed source can feed user-selection directly; scalars must be wrapped.
@@ -1903,6 +2019,9 @@ export function convertWorkflowEditorToFlow(
1903
2019
  }
1904
2020
  }
1905
2021
 
2022
+ // CEL conditions need hyphen→underscore identifiers, but the shared input-name
2023
+ // map must keep the real workflow input keys (`text-1`, not `text_1`).
2024
+ const celFieldNameMap = new Map<string, string>();
1906
2025
  const edgesIntoIfElse = edges.filter((e) => e.target === node.id);
1907
2026
  for (const fieldKey of fieldsUsed) {
1908
2027
  const resolved = resolveIfElseConditionField(fieldKey, nodes);
@@ -1917,7 +2036,7 @@ export function convertWorkflowEditorToFlow(
1917
2036
  : 'output';
1918
2037
 
1919
2038
  const celVarName = getOutputHandleVarName(fieldNodeId, outputHandleName);
1920
- nodeIdToInputNameMap.set(fieldKey, celVarName);
2039
+ celFieldNameMap.set(fieldKey, celVarName);
1921
2040
 
1922
2041
  const edgeToIfElse =
1923
2042
  edgesIntoIfElse
@@ -1953,7 +2072,7 @@ export function convertWorkflowEditorToFlow(
1953
2072
 
1954
2073
  const cases: { condition: string; value: string }[] = [];
1955
2074
  for (let i = 0; i < conditionBlocks.length; i++) {
1956
- const condition = conditionBlockToCEL(conditionBlocks[i]!, nodeIdToInputNameMap);
2075
+ const condition = conditionBlockToCEL(conditionBlocks[i]!, celFieldNameMap);
1957
2076
  if (condition) cases.push({ condition, value: String(i + 2) });
1958
2077
  }
1959
2078
 
@@ -2100,7 +2219,7 @@ function getSliceAssetsMode(data: { mode?: SliceAssetsMode; isRandomize?: boolea
2100
2219
  }
2101
2220
 
2102
2221
  function clampUserSelectionCount(count: number): number {
2103
- return Math.min(USER_SELECTION_MAX_SELECT, Math.max(USER_SELECTION_MIN_SELECT, count));
2222
+ return Math.min(USER_SELECTION_MAX_CANDIDATES, Math.max(USER_SELECTION_MIN_SELECT, count));
2104
2223
  }
2105
2224
 
2106
2225
  function getUserSelectionArrayType(flowInputs: WorkflowEditorFlowInput[]): 'file_array' | 'string_array' {
@@ -1,3 +1,5 @@
1
+ import { evaluateCel } from './cel';
2
+
1
3
  export class WorkflowImportError extends Error {
2
4
  constructor(message: string) {
3
5
  super(message);
@@ -13,6 +15,7 @@ export type WorkflowEditorNodeType =
13
15
  | 'llm'
14
16
  | 'transformText'
15
17
  | 'splitText'
18
+ | 'joinTexts'
16
19
  | 'ifElse'
17
20
  | 'groupItems'
18
21
  | 'sliceAssets'
@@ -30,6 +33,7 @@ export const VALID_EDITOR_NODE_TYPES: readonly WorkflowEditorNodeType[] = [
30
33
  'llm',
31
34
  'transformText',
32
35
  'splitText',
36
+ 'joinTexts',
33
37
  'ifElse',
34
38
  'groupItems',
35
39
  'sliceAssets',
@@ -40,6 +44,12 @@ export const VALID_EDITOR_NODE_TYPES: readonly WorkflowEditorNodeType[] = [
40
44
  'modelInput',
41
45
  ];
42
46
 
47
+ export interface WorkflowNodeGroup {
48
+ title: string;
49
+ color: string;
50
+ locked?: boolean;
51
+ }
52
+
43
53
  export interface WorkflowExportData {
44
54
  version: '1.0';
45
55
  name: string;
@@ -48,6 +58,7 @@ export interface WorkflowExportData {
48
58
  nodes: Array<{ type: WorkflowEditorNodeType; [key: string]: unknown }>;
49
59
  edges: Array<{ [key: string]: unknown }>;
50
60
  inputKeys: string[];
61
+ nodeGroups?: Record<string, WorkflowNodeGroup>;
51
62
  };
52
63
  inputs: unknown[];
53
64
  tagSet: string[];
@@ -67,7 +78,10 @@ export interface WorkflowExportData {
67
78
  * 2. `version` field is present and equals "1.0"
68
79
  * 3. `editorInfo` has non-empty `nodes` and `edges` arrays
69
80
  * 4. Each node's `type` is a recognized editor node type
70
- * 5. `inputs` is an array
81
+ * 5. `sliceAssets` nodes with a `data.message` require that field to be a string
82
+ * 6. `inputs` is an array
83
+ * 7. Each `transformText` node's `data.value` is supported by the Prompt
84
+ * Builder visual editor (single-quoted string templates)
71
85
  */
72
86
  export function validateEditorInfo(data: unknown): WorkflowExportData {
73
87
  if (typeof data !== 'object' || data === null) {
@@ -104,7 +118,108 @@ export function validateEditorInfo(data: unknown): WorkflowExportData {
104
118
  if (!(VALID_EDITOR_NODE_TYPES as readonly string[]).includes(node.type)) {
105
119
  throw new WorkflowImportError(`Invalid node type: ${node.type}`);
106
120
  }
121
+
122
+ if (node.type === 'sliceAssets') {
123
+ const nodeData = node['data'];
124
+ if (
125
+ nodeData !== null &&
126
+ typeof nodeData === 'object' &&
127
+ 'message' in nodeData &&
128
+ (nodeData as { message?: unknown }).message != null &&
129
+ typeof (nodeData as { message?: unknown }).message !== 'string'
130
+ ) {
131
+ const nodeId = typeof node['id'] === 'string' && node['id'] ? node['id'] : '(unknown)';
132
+ throw new WorkflowImportError(`Invalid sliceAssets node "${nodeId}": data.message must be a string`);
133
+ }
134
+ }
135
+
136
+ if (node.type === 'transformText') {
137
+ const value = getTransformTextValue(node);
138
+ if (value === undefined) continue;
139
+ if (typeof value !== 'string' || !getIsPromptBuilderVisualEditorSupported(value)) {
140
+ throw new WorkflowImportError(
141
+ `Node "${getNodeIdLabel(
142
+ node,
143
+ )}": Prompt Builder value is not supported by the visual editor; use a single-quoted template like 'sheep ' + text1_output`,
144
+ );
145
+ }
146
+ }
107
147
  }
108
148
 
109
149
  return workflow as WorkflowExportData;
110
150
  }
151
+
152
+ // ------------------------------------
153
+
154
+ /**
155
+ * True if `value` is safe for the Prompt Builder chip editor.
156
+ * Only single-quoted string templates like `'lit' + var` are supported;
157
+ * double-quoted strings or CEL lists/numbers/bools will break or be removed.
158
+ */
159
+ export function getIsPromptBuilderVisualEditorSupported(value: string): boolean {
160
+ const trimmed = value.trim();
161
+ if (trimmed.length === 0 || trimmed === "''") return true;
162
+
163
+ // List / map literals never come from the visual editor and confuse the
164
+ // quote/variable scanner (`["sheep", "on"]`, `{a: 1}`, …).
165
+ if (/^[[{]/.test(trimmed)) return false;
166
+
167
+ if (getHasDoubleQuotedCelStringLiteral(trimmed)) return false;
168
+
169
+ try {
170
+ const result = evaluateCel(trimmed, {});
171
+ if (typeof result !== 'string') return false;
172
+ } catch {
173
+ // Unbound variables (`text1_output`) and similar fail evaluation with an
174
+ // empty binding map — that is still a normal canvas template.
175
+ }
176
+
177
+ return true;
178
+ }
179
+
180
+ /** True when the expression contains a `"` / `r"..."` string literal outside of `'...'`. */
181
+ function getHasDoubleQuotedCelStringLiteral(input: string): boolean {
182
+ let i = 0;
183
+ while (i < input.length) {
184
+ const ch = input[i];
185
+
186
+ if ((ch === 'r' || ch === 'R') && (input[i + 1] === '"' || input[i + 1] === "'")) {
187
+ if (input[i + 1] === '"') return true;
188
+ i += 2;
189
+ while (i < input.length && input[i] !== "'") i++;
190
+ if (i < input.length) i++;
191
+ continue;
192
+ }
193
+
194
+ if (ch === "'") {
195
+ i++;
196
+ while (i < input.length) {
197
+ if (input[i] === '\\' && i + 1 < input.length) {
198
+ i += 2;
199
+ continue;
200
+ }
201
+ if (input[i] === "'") {
202
+ i++;
203
+ break;
204
+ }
205
+ i++;
206
+ }
207
+ continue;
208
+ }
209
+
210
+ if (ch === '"') return true;
211
+ i++;
212
+ }
213
+ return false;
214
+ }
215
+
216
+ function getTransformTextValue(node: { [key: string]: unknown }): unknown {
217
+ const data = node['data'];
218
+ if (data === undefined || data === null) return undefined;
219
+ if (typeof data !== 'object') return undefined;
220
+ return (data as { [key: string]: unknown })['value'];
221
+ }
222
+
223
+ function getNodeIdLabel(node: { [key: string]: unknown }): string {
224
+ return typeof node['id'] === 'string' ? node['id'] : 'unknown';
225
+ }
@@ -1,3 +1,9 @@
1
+ import {
2
+ USER_SELECTION_DEFAULT_MAX,
3
+ USER_SELECTION_MAX_CANDIDATES,
4
+ USER_SELECTION_MIN_SELECT,
5
+ } from './user_selection_limits';
6
+
1
7
  const VALID_NODE_TYPES = new Set([
2
8
  'custom-model',
3
9
  'for-each',
@@ -12,8 +18,6 @@ const VALID_NODE_TYPES = new Set([
12
18
  'workflow',
13
19
  ]);
14
20
 
15
- const USER_SELECTION_MIN_SELECT = 1;
16
- const USER_SELECTION_MAX_SELECT = 100;
17
21
  const USER_SELECTION_INPUT_TYPES = new Set(['file_array', 'string_array']);
18
22
 
19
23
  type RawRef = {
@@ -58,7 +62,7 @@ type RawNode = {
58
62
  * - logic → logicType
59
63
  * - for-each → loopBodyNodeIds (non-empty) OR count (>0)
60
64
  * - user-selection → exactly one file_array/string_array input (optional connection);
61
- * input.min ≤ input.max
65
+ * input.min in 1..50, input.max in 1..100, input.min ≤ input.max
62
66
  * 5. Cross-references all point to node IDs that exist in the flow:
63
67
  * - dependsOn entries
64
68
  * - input ref.node (excluding "workflow" which is a reserved keyword)
@@ -321,26 +325,26 @@ function validateUserSelectionNode(label: string, node: RawNode): void {
321
325
  const input = selectionInputs[0]!;
322
326
 
323
327
  const min = input.min === undefined ? USER_SELECTION_MIN_SELECT : input.min;
324
- const max = input.max === undefined ? USER_SELECTION_MAX_SELECT : input.max;
328
+ const max = input.max === undefined ? USER_SELECTION_DEFAULT_MAX : input.max;
325
329
 
326
330
  if (
327
331
  typeof min !== 'number' ||
328
332
  !Number.isInteger(min) ||
329
333
  min < USER_SELECTION_MIN_SELECT ||
330
- min > USER_SELECTION_MAX_SELECT
334
+ min > USER_SELECTION_MAX_CANDIDATES
331
335
  ) {
332
336
  throw new Error(
333
- `Node ${label} (user-selection) input.min must be an integer between ${USER_SELECTION_MIN_SELECT} and ${USER_SELECTION_MAX_SELECT}`,
337
+ `Node ${label} (user-selection) input.min must be an integer between ${USER_SELECTION_MIN_SELECT} and ${USER_SELECTION_MAX_CANDIDATES}`,
334
338
  );
335
339
  }
336
340
  if (
337
341
  typeof max !== 'number' ||
338
342
  !Number.isInteger(max) ||
339
343
  max < USER_SELECTION_MIN_SELECT ||
340
- max > USER_SELECTION_MAX_SELECT
344
+ max > USER_SELECTION_DEFAULT_MAX
341
345
  ) {
342
346
  throw new Error(
343
- `Node ${label} (user-selection) input.max must be an integer between ${USER_SELECTION_MIN_SELECT} and ${USER_SELECTION_MAX_SELECT}`,
347
+ `Node ${label} (user-selection) input.max must be an integer between ${USER_SELECTION_MIN_SELECT} and ${USER_SELECTION_DEFAULT_MAX}`,
344
348
  );
345
349
  }
346
350
  if (min > max) {