tinet-agent-cli 0.1.0.dev36__py3-none-any.whl

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 (104) hide show
  1. taco/__init__.py +1 -0
  2. taco/agent_openapi/__init__.py +1 -0
  3. taco/agent_openapi/catalog.py +198 -0
  4. taco/agent_openapi/models.py +47 -0
  5. taco/agent_openapi/parameters.py +111 -0
  6. taco/agent_openapi/service.py +73 -0
  7. taco/aikb/__init__.py +4 -0
  8. taco/aikb/catalog.py +380 -0
  9. taco/aikb/executors.py +157 -0
  10. taco/aikb/file_input.py +55 -0
  11. taco/aikb/models.py +42 -0
  12. taco/aikb/parameters.py +135 -0
  13. taco/aikb/service.py +68 -0
  14. taco/assets/__init__.py +1 -0
  15. taco/assets/agent_openapi/catalog.json +14 -0
  16. taco/assets/agent_openapi/operations/conversation/list-conversations.json +67 -0
  17. taco/assets/agent_openapi/operations/message/list-conversation-messages.json +80 -0
  18. taco/assets/agent_openapi/operations/trace/list-message-traces.json +72 -0
  19. taco/assets/aikb/catalog.json +41 -0
  20. taco/assets/aikb/operations/conversation/chat-conversation-on-open.json +99 -0
  21. taco/assets/aikb/operations/directory/delete-directory.json +81 -0
  22. taco/assets/aikb/operations/directory/edit-directory.json +97 -0
  23. taco/assets/aikb/operations/directory/list-directory-tree.json +106 -0
  24. taco/assets/aikb/operations/directory/save-directory.json +109 -0
  25. taco/assets/aikb/operations/faq/create-faq.json +193 -0
  26. taco/assets/aikb/operations/faq/delete-faq.json +81 -0
  27. taco/assets/aikb/operations/faq/describe-faq.json +82 -0
  28. taco/assets/aikb/operations/faq/edit-faq.json +193 -0
  29. taco/assets/aikb/operations/faq/list-faqs.json +136 -0
  30. taco/assets/aikb/operations/file/create-file.json +145 -0
  31. taco/assets/aikb/operations/file/delete-files.json +110 -0
  32. taco/assets/aikb/operations/file/describe-file.json +82 -0
  33. taco/assets/aikb/operations/file/get-file-upload-url.json +154 -0
  34. taco/assets/aikb/operations/file/list-files.json +146 -0
  35. taco/assets/aikb/operations/media/describe-faq-media-url.json +98 -0
  36. taco/assets/aikb/operations/media/describe-file-media-url.json +90 -0
  37. taco/assets/aikb/operations/oss/signature-for-upload.json +85 -0
  38. taco/assets/aikb/operations/recycle-bin/list-recycled-items.json +151 -0
  39. taco/assets/aikb/operations/repository/describe-bot-repository.json +71 -0
  40. taco/assets/aikb/operations/repository/list-private-repositories.json +75 -0
  41. taco/assets/aikb/operations/repository/list-public-repositories.json +75 -0
  42. taco/assets/aikb/operations/search/search-knowledge-on-open.json +180 -0
  43. taco/assets/examples/README.md +3 -0
  44. taco/assets/examples/agent-basic.json +16 -0
  45. taco/assets/examples/agent-builtin-tool.json +28 -0
  46. taco/assets/examples/agent-workflow-tool.json +30 -0
  47. taco/assets/examples/chatflow-basic.json +24 -0
  48. taco/assets/examples/workflow-http.json +34 -0
  49. taco/assets/manifest.json +12 -0
  50. taco/assets/scenarios/README.md +3 -0
  51. taco/assets/scenarios/agent-basic.json +80 -0
  52. taco/assets/scenarios/agent-builtin-tool.json +116 -0
  53. taco/assets/scenarios/agent-workflow-tool.json +277 -0
  54. taco/assets/schemas/README.md +3 -0
  55. taco/assets/schemas/agent-tool.json +71 -0
  56. taco/assets/schemas/agent.json +199 -0
  57. taco/assets/schemas/chatflow.json +1048 -0
  58. taco/assets/schemas/workflow.http.json +1052 -0
  59. taco/assets/schemas/workflow.json +1052 -0
  60. taco/assets/skills/README.md +3 -0
  61. taco/assets/skills/taco/SKILL.md +68 -0
  62. taco/assets/skills/taco/manifest.json +10 -0
  63. taco/cli.py +127 -0
  64. taco/commands/__init__.py +1 -0
  65. taco/commands/agent.py +760 -0
  66. taco/commands/aikb.py +132 -0
  67. taco/commands/app.py +151 -0
  68. taco/commands/category.py +37 -0
  69. taco/commands/chatflow.py +183 -0
  70. taco/commands/credential.py +222 -0
  71. taco/commands/discovery.py +409 -0
  72. taco/commands/model.py +55 -0
  73. taco/commands/profile.py +124 -0
  74. taco/commands/tool.py +61 -0
  75. taco/commands/workflow.py +714 -0
  76. taco/core/__init__.py +1 -0
  77. taco/core/access_token_manager.py +98 -0
  78. taco/core/api_client.py +263 -0
  79. taco/core/assets.py +81 -0
  80. taco/core/config_store.py +209 -0
  81. taco/core/context.py +19 -0
  82. taco/core/developer_center_client.py +165 -0
  83. taco/core/errors.py +19 -0
  84. taco/core/file_lock.py +80 -0
  85. taco/core/http_headers.py +21 -0
  86. taco/core/openapi_signer.py +221 -0
  87. taco/core/output.py +72 -0
  88. taco/core/profile_manager.py +217 -0
  89. taco/core/profile_resolver.py +151 -0
  90. taco/core/secure_file.py +76 -0
  91. taco/release.py +363 -0
  92. taco/services/__init__.py +1 -0
  93. taco/services/agent_service.py +314 -0
  94. taco/services/app_service.py +153 -0
  95. taco/services/category_service.py +57 -0
  96. taco/services/model_service.py +117 -0
  97. taco/services/tool_service.py +482 -0
  98. taco/services/workflow_compiler.py +1665 -0
  99. taco/services/workflow_service.py +652 -0
  100. taco/services/workflow_tool_service.py +439 -0
  101. tinet_agent_cli-0.1.0.dev36.dist-info/METADATA +158 -0
  102. tinet_agent_cli-0.1.0.dev36.dist-info/RECORD +104 -0
  103. tinet_agent_cli-0.1.0.dev36.dist-info/WHEEL +4 -0
  104. tinet_agent_cli-0.1.0.dev36.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1665 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any, Mapping
8
+ from urllib.parse import urlsplit
9
+
10
+ import yaml
11
+ from jsonschema import Draft202012Validator
12
+
13
+ from taco.core.assets import load_json_asset
14
+ from taco.core.errors import TacoError
15
+ from taco.core.secure_file import SecureFileReadError, read_regular_file
16
+
17
+
18
+ _REFERENCE = re.compile(
19
+ r"\{\{[ \t]{0,16}([A-Za-z][A-Za-z0-9_]{0,49})"
20
+ r"\.([A-Za-z_][A-Za-z0-9_]{0,29})[ \t]{0,16}\}\}"
21
+ )
22
+ _SECRET_REFERENCE = re.compile(
23
+ r"\{\{[ \t]{0,16}secrets\.([A-Za-z_][A-Za-z0-9_]{0,29})"
24
+ r"[ \t]{0,16}\}\}"
25
+ )
26
+ _TEMPLATE = re.compile(r"\{\{[^{}\r\n]{1,128}\}\}")
27
+ _HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
28
+ _OUTPUT_FIELDS = frozenset({"body", "status_code", "headers", "files", "data"})
29
+ _HTTP_OUTPUT_FIELDS = _OUTPUT_FIELDS
30
+ _KNOWLEDGE_OUTPUT_FIELDS = frozenset({"result"})
31
+ _LLM_OUTPUT_FIELDS = frozenset({"text"})
32
+ _TEMPLATE_TRANSFORM_OUTPUT_FIELDS = frozenset({"output"})
33
+ _RESERVED_NODE_IDS = frozenset(
34
+ {
35
+ "start",
36
+ "end",
37
+ "answer",
38
+ "inputs",
39
+ "secrets",
40
+ "sys",
41
+ "env",
42
+ "conversation",
43
+ }
44
+ )
45
+ _INPUT_TYPES = {
46
+ "string": "text-input",
47
+ "number": "number",
48
+ "select": "select",
49
+ }
50
+ _DEFAULT_TIMEOUT = {
51
+ "connect": 10,
52
+ "read": 60,
53
+ "write": 20,
54
+ }
55
+ _DEFAULT_RETRY = {
56
+ "retry_enabled": True,
57
+ "max_retries": 3,
58
+ "retry_interval": 100,
59
+ }
60
+ _NODE_X_GAP = 320
61
+ _MAX_DSL_FILE_SIZE = 1024 * 1024
62
+ _MAX_STRUCTURE_DEPTH = 50
63
+
64
+
65
+ class _WorkflowSafeLoader(yaml.SafeLoader):
66
+ """SafeLoader variant rejecting aliases and duplicate mapping keys."""
67
+
68
+ def compose_node(self, parent: Any, index: Any) -> Any:
69
+ if self.check_event(yaml.AliasEvent):
70
+ event = self.peek_event()
71
+ raise yaml.composer.ComposerError(
72
+ None,
73
+ None,
74
+ "YAML alias is not allowed",
75
+ event.start_mark,
76
+ )
77
+ return super().compose_node(parent, index)
78
+
79
+ def construct_mapping(
80
+ self,
81
+ node: Any,
82
+ deep: bool = False,
83
+ ) -> dict[Any, Any]:
84
+ if not isinstance(node, yaml.MappingNode):
85
+ return super().construct_mapping(node, deep=deep)
86
+
87
+ self.flatten_mapping(node)
88
+ mapping: dict[Any, Any] = {}
89
+ for key_node, value_node in node.value:
90
+ key = self.construct_object(key_node, deep=deep)
91
+ try:
92
+ if key in mapping:
93
+ raise yaml.constructor.ConstructorError(
94
+ "while constructing a mapping",
95
+ node.start_mark,
96
+ "found duplicate mapping key",
97
+ key_node.start_mark,
98
+ )
99
+ except TypeError as error:
100
+ raise yaml.constructor.ConstructorError(
101
+ "while constructing a mapping",
102
+ node.start_mark,
103
+ "found unhashable mapping key",
104
+ key_node.start_mark,
105
+ ) from error
106
+ mapping[key] = self.construct_object(value_node, deep=deep)
107
+ return mapping
108
+
109
+
110
+ def compile_workflow(workflow: Mapping[str, Any]) -> dict[str, Any]:
111
+ """Validate a Workflow/Chatflow DSL object and compile it to a Dify graph."""
112
+ if not isinstance(workflow, dict):
113
+ raise _validation_error(["Workflow DSL 必须是对象"])
114
+
115
+ try:
116
+ _validate_json_compatible(workflow)
117
+ except RecursionError as error:
118
+ raise _validation_error(["Workflow DSL 嵌套层级过深"]) from error
119
+ normalized = _normalize_workflow_steps(dict(workflow))
120
+ kind = normalized.get("kind", "workflow")
121
+ if kind not in {"workflow", "chatflow"}:
122
+ raise _validation_error([f"不支持的 kind:{kind}"])
123
+ _validate_schema(normalized)
124
+ inputs = normalized["inputs"]
125
+ source_steps = normalized["nodes"]
126
+ all_ids = _collect_step_ids(source_steps)
127
+ _validate_unique_ids(all_ids)
128
+
129
+ builder = _GraphBuilder(inputs=inputs)
130
+ exits = builder.compile_steps(
131
+ source_steps,
132
+ predecessors=[("start", "start", "source")],
133
+ )
134
+ aggregator_ids = frozenset(
135
+ node_id
136
+ for node_id, dify_type in builder.node_types.items()
137
+ if dify_type == "variable-aggregator"
138
+ )
139
+ if kind == "chatflow":
140
+ terminal = _compile_answer_node(
141
+ normalized["answer"],
142
+ position=builder.next_position(),
143
+ context=_TemplateContext(
144
+ input_names=frozenset(inputs),
145
+ node_ids=frozenset(builder.business_node_ids),
146
+ node_output_fields=dict(builder.node_output_fields),
147
+ aggregator_ids=aggregator_ids,
148
+ ),
149
+ )
150
+ terminal_type = "answer"
151
+ terminal_id = "answer"
152
+ else:
153
+ terminal = _compile_end_node(
154
+ normalized["outputs"],
155
+ position=builder.next_position(),
156
+ node_ids=frozenset(builder.business_node_ids),
157
+ node_output_fields=builder.node_output_fields,
158
+ aggregator_ids=aggregator_ids,
159
+ )
160
+ terminal_type = "end"
161
+ terminal_id = "end"
162
+ builder.nodes.append(terminal)
163
+ for source_id, source_type, source_handle in exits:
164
+ builder.add_edge(
165
+ source=source_id,
166
+ target=terminal_id,
167
+ source_type=source_type,
168
+ target_type=terminal_type,
169
+ source_handle=source_handle,
170
+ )
171
+ return {
172
+ "nodes": builder.nodes,
173
+ "edges": builder.edges,
174
+ "viewport": {"x": 0, "y": 0, "zoom": 1},
175
+ }
176
+
177
+
178
+ class _GraphBuilder:
179
+ """Compile TACO IR steps into a Dify graph with control-flow expansion."""
180
+
181
+ def __init__(self, *, inputs: dict[str, dict[str, Any]]) -> None:
182
+ self.inputs = inputs
183
+ self.input_names = frozenset(inputs)
184
+ self.nodes: list[dict[str, Any]] = [_compile_start_node(inputs)]
185
+ self.edges: list[dict[str, Any]] = []
186
+ self._position = 0
187
+ self.node_output_fields: dict[str, frozenset[str]] = {}
188
+ self.node_types: dict[str, str] = {"start": "start"}
189
+ self.business_node_ids: set[str] = set()
190
+ self._known_ids: set[str] = {"start"}
191
+ self._iteration_stack: list[str] = []
192
+
193
+ def next_position(self) -> int:
194
+ self._position += 1
195
+ return self._position
196
+
197
+ def context(self) -> _TemplateContext:
198
+ aggregator_ids = frozenset(
199
+ node_id
200
+ for node_id, dify_type in self.node_types.items()
201
+ if dify_type == "variable-aggregator"
202
+ )
203
+ return _TemplateContext(
204
+ input_names=self.input_names,
205
+ node_ids=frozenset(self.business_node_ids),
206
+ node_output_fields=dict(self.node_output_fields),
207
+ aggregator_ids=aggregator_ids,
208
+ )
209
+
210
+ @property
211
+ def _current_iteration_id(self) -> str | None:
212
+ return self._iteration_stack[-1] if self._iteration_stack else None
213
+
214
+ def add_edge(
215
+ self,
216
+ *,
217
+ source: str,
218
+ target: str,
219
+ source_type: str,
220
+ target_type: str,
221
+ source_handle: str = "source",
222
+ iteration_id: str | None = None,
223
+ ) -> None:
224
+ edge_id = f"{source}-{source_handle}-{target}"
225
+ data: dict[str, Any] = {
226
+ "sourceType": source_type,
227
+ "targetType": target_type,
228
+ }
229
+ edge: dict[str, Any] = {
230
+ "id": edge_id,
231
+ "type": "custom",
232
+ "source": source,
233
+ "sourceHandle": source_handle,
234
+ "target": target,
235
+ "targetHandle": "target",
236
+ "data": data,
237
+ }
238
+ if iteration_id:
239
+ data["isInIteration"] = True
240
+ data["iteration_id"] = iteration_id
241
+ edge["zIndex"] = 1002
242
+ self.edges.append(edge)
243
+
244
+ def compile_steps(
245
+ self,
246
+ steps: list[dict[str, Any]],
247
+ *,
248
+ predecessors: list[tuple[str, str, str]],
249
+ ) -> list[tuple[str, str, str]]:
250
+ """Compile steps; predecessors are (id, dify_type, source_handle)."""
251
+ current = list(predecessors)
252
+ for step in steps:
253
+ current = self._compile_one_step(step, predecessors=current)
254
+ return current
255
+
256
+ def _compile_one_step(
257
+ self,
258
+ step: dict[str, Any],
259
+ *,
260
+ predecessors: list[tuple[str, str, str]],
261
+ ) -> list[tuple[str, str, str]]:
262
+ if "branch" in step:
263
+ return self._compile_branch(step, predecessors=predecessors)
264
+ if "parallel" in step:
265
+ return self._compile_parallel(step, predecessors=predecessors)
266
+ if "iterate" in step:
267
+ return self._compile_iterate(step, predecessors=predecessors)
268
+ return self._compile_business(step, predecessors=predecessors)
269
+
270
+ def _compile_business(
271
+ self,
272
+ step: dict[str, Any],
273
+ *,
274
+ predecessors: list[tuple[str, str, str]],
275
+ ) -> list[tuple[str, str, str]]:
276
+ compiled, dify_type, output_fields = _compile_step_node(
277
+ step,
278
+ position=self.next_position(),
279
+ context=self.context(),
280
+ )
281
+ iteration_id = self._current_iteration_id
282
+ if iteration_id:
283
+ compiled["parentId"] = iteration_id
284
+ compiled["extent"] = "parent"
285
+ compiled["zIndex"] = 1002
286
+ compiled["data"]["isInIteration"] = True
287
+ compiled["data"]["iteration_id"] = iteration_id
288
+ self.nodes.append(compiled)
289
+ node_id = step["id"]
290
+ self._register_node(node_id, dify_type, output_fields)
291
+ for source_id, source_type, source_handle in predecessors:
292
+ self.add_edge(
293
+ source=source_id,
294
+ target=node_id,
295
+ source_type=source_type,
296
+ target_type=dify_type,
297
+ source_handle=source_handle,
298
+ iteration_id=iteration_id,
299
+ )
300
+ return [(node_id, dify_type, "source")]
301
+
302
+ def _compile_branch(
303
+ self,
304
+ step: dict[str, Any],
305
+ *,
306
+ predecessors: list[tuple[str, str, str]],
307
+ ) -> list[tuple[str, str, str]]:
308
+ branch = step["branch"]
309
+ route_id = step["id"]
310
+ if_else = _compile_if_else_node(
311
+ step_id=route_id,
312
+ when=branch["when"],
313
+ position=self.next_position(),
314
+ context=self.context(),
315
+ )
316
+ self.nodes.append(if_else)
317
+ self._register_node(route_id, "if-else", frozenset())
318
+ for source_id, source_type, source_handle in predecessors:
319
+ self.add_edge(
320
+ source=source_id,
321
+ target=route_id,
322
+ source_type=source_type,
323
+ target_type="if-else",
324
+ source_handle=source_handle,
325
+ )
326
+ then_exits = self.compile_steps(
327
+ branch["then"],
328
+ predecessors=[(route_id, "if-else", "true")],
329
+ )
330
+ else_exits = self.compile_steps(
331
+ branch["else"],
332
+ predecessors=[(route_id, "if-else", "false")],
333
+ )
334
+ return [*then_exits, *else_exits]
335
+
336
+ def _compile_parallel(
337
+ self,
338
+ step: dict[str, Any],
339
+ *,
340
+ predecessors: list[tuple[str, str, str]],
341
+ ) -> list[tuple[str, str, str]]:
342
+ parallel = step["parallel"]
343
+ fan_id = step["id"]
344
+ branch_exits: list[tuple[str, str, str]] = []
345
+ tip_fields: list[tuple[str, frozenset[str]]] = []
346
+ for branch_steps in parallel["branches"]:
347
+ exits = self.compile_steps(branch_steps, predecessors=predecessors)
348
+ if len(exits) != 1:
349
+ raise _validation_error(
350
+ [f"parallel[{fan_id}] 每个分支必须恰好有一个出口节点"]
351
+ )
352
+ tip_id, tip_type, tip_handle = exits[0]
353
+ branch_exits.append((tip_id, tip_type, tip_handle))
354
+ tip_fields.append((tip_id, self.node_output_fields[tip_id]))
355
+
356
+ join_id = parallel.get("join")
357
+ if not join_id:
358
+ return branch_exits
359
+
360
+ if join_id in self._known_ids:
361
+ raise _validation_error([f"parallel.join ID 重复:{join_id}"])
362
+ join_node, join_fields = _compile_variable_aggregator_node(
363
+ join_id=join_id,
364
+ tip_fields=tip_fields,
365
+ position=self.next_position(),
366
+ )
367
+ self.nodes.append(join_node)
368
+ self._register_node(join_id, "variable-aggregator", join_fields)
369
+ for tip_id, tip_type, tip_handle in branch_exits:
370
+ self.add_edge(
371
+ source=tip_id,
372
+ target=join_id,
373
+ source_type=tip_type,
374
+ target_type="variable-aggregator",
375
+ source_handle=tip_handle,
376
+ )
377
+ return [(join_id, "variable-aggregator", "source")]
378
+
379
+ def _compile_iterate(
380
+ self,
381
+ step: dict[str, Any],
382
+ *,
383
+ predecessors: list[tuple[str, str, str]],
384
+ ) -> list[tuple[str, str, str]]:
385
+ if self._iteration_stack:
386
+ raise _validation_error(["iterate 暂不支持嵌套"])
387
+ iterate = step["iterate"]
388
+ iter_id = step["id"]
389
+ over = iterate["over"]
390
+ if not isinstance(over, str):
391
+ raise _validation_error(["iterate.over 必须是字符串模板引用"])
392
+ over_ref = _REFERENCE.fullmatch(over)
393
+ if over_ref is None:
394
+ raise _validation_error(
395
+ ["iterate.over 必须是单个模板引用,例如 {{ prep.chunks }}"]
396
+ )
397
+ over_ns, over_field = over_ref.groups()
398
+ iterator_selector = _resolve_value_selector(
399
+ over_ns,
400
+ over_field,
401
+ self.context(),
402
+ error_prefix="iterate.over",
403
+ )
404
+
405
+ start_id = f"{iter_id}start"
406
+ if start_id in self._known_ids or start_id in {s["id"] for s in iterate["steps"]}:
407
+ raise _validation_error(
408
+ [f"iterate[{iter_id}] 生成的起点 ID 冲突:{start_id}"]
409
+ )
410
+
411
+ position = self.next_position()
412
+ iteration_node = {
413
+ "id": iter_id,
414
+ "type": "custom",
415
+ "position": {"x": position * _NODE_X_GAP, "y": 0},
416
+ "width": 400,
417
+ "height": 200,
418
+ "data": {
419
+ "title": iter_id,
420
+ "desc": "",
421
+ "type": "iteration",
422
+ "start_node_id": start_id,
423
+ "iterator_selector": iterator_selector,
424
+ "output_selector": [],
425
+ "output_type": "array[string]",
426
+ "is_parallel": False,
427
+ "parallel_nums": 10,
428
+ "error_handle_mode": "terminated",
429
+ },
430
+ }
431
+ self.nodes.append(iteration_node)
432
+ self._register_node(
433
+ iter_id,
434
+ "iteration",
435
+ frozenset({"item", "index", "output"}),
436
+ )
437
+
438
+ start_node = {
439
+ "id": start_id,
440
+ "type": "custom-iteration-start",
441
+ "position": {"x": 24, "y": 68},
442
+ "parentId": iter_id,
443
+ "extent": "parent",
444
+ "zIndex": 1002,
445
+ "data": {
446
+ "title": "",
447
+ "desc": "",
448
+ "type": "iteration-start",
449
+ "isInIteration": True,
450
+ "iteration_id": iter_id,
451
+ },
452
+ "selectable": False,
453
+ "draggable": False,
454
+ }
455
+ self.nodes.append(start_node)
456
+ self._known_ids.add(start_id)
457
+ self.node_types[start_id] = "iteration-start"
458
+
459
+ for source_id, source_type, source_handle in predecessors:
460
+ self.add_edge(
461
+ source=source_id,
462
+ target=iter_id,
463
+ source_type=source_type,
464
+ target_type="iteration",
465
+ source_handle=source_handle,
466
+ )
467
+
468
+ self._iteration_stack.append(iter_id)
469
+ try:
470
+ inner_exits = self.compile_steps(
471
+ iterate["steps"],
472
+ predecessors=[(start_id, "iteration-start", "source")],
473
+ )
474
+ finally:
475
+ self._iteration_stack.pop()
476
+
477
+ if len(inner_exits) != 1:
478
+ raise _validation_error(
479
+ [f"iterate[{iter_id}].steps 必须是线性链路(恰好一个出口)"]
480
+ )
481
+
482
+ output_expr = iterate["output"]
483
+ if not isinstance(output_expr, str):
484
+ raise _validation_error(["iterate.output 必须是字符串模板引用"])
485
+ output_ref = _REFERENCE.fullmatch(output_expr)
486
+ if output_ref is None:
487
+ raise _validation_error(
488
+ ["iterate.output 必须是单个模板引用,例如 {{ call.body }}"]
489
+ )
490
+ out_ns, out_field = output_ref.groups()
491
+ inner_ids = {node["id"] for node in iterate["steps"]}
492
+ if out_ns not in inner_ids:
493
+ raise _validation_error(
494
+ ["iterate.output 必须引用 iterate.steps 内的节点"]
495
+ )
496
+ allowed = self.node_output_fields.get(out_ns, frozenset())
497
+ if out_field not in allowed:
498
+ raise _validation_error(
499
+ [f"iterate.output 节点 {out_ns} 不支持输出字段:{out_field}"]
500
+ )
501
+ iteration_node["data"]["output_selector"] = [out_ns, out_field]
502
+ iteration_node["data"]["output_type"] = _iteration_output_type(out_field)
503
+ iteration_node["data"]["startNodeType"] = "iteration-start"
504
+
505
+ return [(iter_id, "iteration", "source")]
506
+
507
+ def _register_node(
508
+ self,
509
+ node_id: str,
510
+ dify_type: str,
511
+ output_fields: frozenset[str],
512
+ ) -> None:
513
+ if node_id in self._known_ids:
514
+ raise _validation_error([f"节点 ID 重复:{node_id}"])
515
+ self._known_ids.add(node_id)
516
+ self.node_types[node_id] = dify_type
517
+ self.node_output_fields[node_id] = output_fields
518
+ if dify_type not in {"if-else"}:
519
+ self.business_node_ids.add(node_id)
520
+
521
+
522
+ def load_workflow_file(path: Path | str) -> dict[str, Any]:
523
+ """Safely load a regular Workflow YAML file without network access."""
524
+ dsl_path = Path(path)
525
+ try:
526
+ raw_content = read_regular_file(
527
+ dsl_path,
528
+ max_bytes=_MAX_DSL_FILE_SIZE,
529
+ )
530
+ content = raw_content.decode("utf-8")
531
+ except SecureFileReadError as error:
532
+ reasons = {
533
+ "symlink": "Workflow DSL 输入不能是符号链接",
534
+ "not_regular": "Workflow DSL 输入不是普通文件",
535
+ "too_large": "Workflow DSL 文件过大,最大允许 1 MiB",
536
+ "changed": "Workflow DSL 文件在安全打开期间发生变化",
537
+ "unreadable": "Workflow DSL 文件不存在或不可读",
538
+ }
539
+ raise _validation_error(
540
+ [reasons.get(error.reason, "无法安全读取 Workflow DSL 文件")]
541
+ ) from error
542
+ except TacoError:
543
+ raise
544
+ except (OSError, UnicodeError) as error:
545
+ raise _validation_error(["无法读取 Workflow DSL 文件"]) from error
546
+
547
+ try:
548
+ workflow = yaml.load(content, Loader=_WorkflowSafeLoader)
549
+ except (
550
+ yaml.YAMLError,
551
+ RecursionError,
552
+ ValueError,
553
+ OverflowError,
554
+ ) as error:
555
+ raise _validation_error(["Workflow DSL 不是合法 YAML"]) from error
556
+ if not isinstance(workflow, dict):
557
+ raise _validation_error(["Workflow DSL YAML 必须是对象"])
558
+ return workflow
559
+
560
+
561
+ def compile_workflow_file(path: Path | str) -> dict[str, Any]:
562
+ """Safely load and compile a regular Workflow YAML file."""
563
+ try:
564
+ return compile_workflow(load_workflow_file(path))
565
+ except RecursionError as error:
566
+ raise _validation_error(["Workflow DSL 嵌套层级过深"]) from error
567
+
568
+
569
+ def summarize_graph(graph: Mapping[str, Any]) -> dict[str, int]:
570
+ nodes = graph.get("nodes")
571
+ edges = graph.get("edges")
572
+ node_items = nodes if isinstance(nodes, list) else []
573
+ edge_items = edges if isinstance(edges, list) else []
574
+
575
+ def _type_of(node: object) -> str | None:
576
+ if not isinstance(node, dict):
577
+ return None
578
+ data = node.get("data")
579
+ if not isinstance(data, dict):
580
+ return None
581
+ raw = data.get("type")
582
+ return raw if isinstance(raw, str) else None
583
+
584
+ types = [_type_of(node) for node in node_items]
585
+ return {
586
+ "nodes": len(node_items),
587
+ "edges": len(edge_items),
588
+ "http_nodes": sum(1 for item in types if item == "http-request"),
589
+ "code_nodes": sum(1 for item in types if item == "code"),
590
+ "knowledge_nodes": sum(
591
+ 1 for item in types if item == "knowledge-retrieval"
592
+ ),
593
+ "if_else_nodes": sum(1 for item in types if item == "if-else"),
594
+ "aggregator_nodes": sum(
595
+ 1 for item in types if item == "variable-aggregator"
596
+ ),
597
+ "iteration_nodes": sum(1 for item in types if item == "iteration"),
598
+ "answer_nodes": sum(1 for item in types if item == "answer"),
599
+ "llm_nodes": sum(1 for item in types if item == "llm"),
600
+ "template_nodes": sum(
601
+ 1 for item in types if item == "template-transform"
602
+ ),
603
+ }
604
+
605
+
606
+ class _TemplateContext:
607
+ def __init__(
608
+ self,
609
+ *,
610
+ input_names: frozenset[str],
611
+ node_ids: frozenset[str],
612
+ node_output_fields: dict[str, frozenset[str]] | None = None,
613
+ aggregator_ids: frozenset[str] | None = None,
614
+ ) -> None:
615
+ self.input_names = input_names
616
+ self.node_ids = node_ids
617
+ self.node_output_fields = node_output_fields or {}
618
+ self.aggregator_ids = aggregator_ids or frozenset()
619
+
620
+
621
+ def _normalize_workflow_steps(workflow: dict[str, Any]) -> dict[str, Any]:
622
+ """Accept `nodes` or `steps` as the linear step list (M1)."""
623
+ has_nodes = "nodes" in workflow
624
+ has_steps = "steps" in workflow
625
+ if has_nodes and has_steps:
626
+ raise _validation_error(["不能同时提供 nodes 与 steps,请只保留其一"])
627
+ if not has_nodes and not has_steps:
628
+ raise _validation_error(["必须提供 nodes 或 steps(至少一个业务节点)"])
629
+ if has_steps:
630
+ workflow = {**workflow, "nodes": workflow["steps"]}
631
+ workflow.pop("steps", None)
632
+ return workflow
633
+
634
+
635
+ def _compile_step_node(
636
+ node: dict[str, Any],
637
+ *,
638
+ position: int,
639
+ context: _TemplateContext,
640
+ ) -> tuple[dict[str, Any], str, frozenset[str]]:
641
+ node_type = node.get("type")
642
+ if node_type == "http":
643
+ compiled = _compile_http_node(node, position=position, context=context)
644
+ return compiled, "http-request", _HTTP_OUTPUT_FIELDS
645
+ if node_type == "code":
646
+ compiled = _compile_code_node(node, position=position, context=context)
647
+ outputs = node.get("outputs")
648
+ fields = frozenset(outputs) if isinstance(outputs, dict) else frozenset()
649
+ return compiled, "code", fields
650
+ if node_type == "knowledge-retrieval":
651
+ compiled = _compile_knowledge_retrieval_node(
652
+ node,
653
+ position=position,
654
+ context=context,
655
+ )
656
+ return compiled, "knowledge-retrieval", _KNOWLEDGE_OUTPUT_FIELDS
657
+ if node_type == "llm":
658
+ compiled = _compile_llm_node(node, position=position, context=context)
659
+ return compiled, "llm", _LLM_OUTPUT_FIELDS
660
+ if node_type == "template-transform":
661
+ compiled = _compile_template_transform_node(
662
+ node,
663
+ position=position,
664
+ context=context,
665
+ )
666
+ return compiled, "template-transform", _TEMPLATE_TRANSFORM_OUTPUT_FIELDS
667
+ raise _validation_error([f"不支持的节点类型:{node_type}"])
668
+
669
+
670
+ def _compile_llm_node(
671
+ node: dict[str, Any],
672
+ *,
673
+ position: int,
674
+ context: _TemplateContext,
675
+ ) -> dict[str, Any]:
676
+ has_prompt = "prompt" in node
677
+ has_template = "prompt_template" in node
678
+ if has_prompt == has_template:
679
+ raise _validation_error(
680
+ ["llm 必须且只能提供 prompt 或 prompt_template 之一"]
681
+ )
682
+
683
+ if has_prompt:
684
+ prompt = node["prompt"]
685
+ if not isinstance(prompt, str) or not prompt.strip():
686
+ raise _validation_error(["llm.prompt 必须是非空字符串"])
687
+ prompt_template = [
688
+ {
689
+ "role": "user",
690
+ "text": _compile_template(prompt, context),
691
+ }
692
+ ]
693
+ else:
694
+ raw_messages = node["prompt_template"]
695
+ if not isinstance(raw_messages, list) or not raw_messages:
696
+ raise _validation_error(["llm.prompt_template 必须是非空数组"])
697
+ prompt_template = []
698
+ for index, message in enumerate(raw_messages):
699
+ if not isinstance(message, dict):
700
+ raise _validation_error(
701
+ [f"llm.prompt_template[{index}] 必须是对象"]
702
+ )
703
+ role = message.get("role")
704
+ text = message.get("text")
705
+ if role not in {"system", "user", "assistant"}:
706
+ raise _validation_error(
707
+ [f"llm.prompt_template[{index}].role 不受支持"]
708
+ )
709
+ if not isinstance(text, str) or not text.strip():
710
+ raise _validation_error(
711
+ [f"llm.prompt_template[{index}].text 必须是非空字符串"]
712
+ )
713
+ prompt_template.append(
714
+ {
715
+ "role": role,
716
+ "text": _compile_template(text, context),
717
+ }
718
+ )
719
+
720
+ mode = node.get("mode", "chat")
721
+ if mode not in {"chat", "completion"}:
722
+ raise _validation_error(["llm.mode 仅支持 chat 或 completion"])
723
+ completion_params = node.get("completion_params") or {}
724
+ if not isinstance(completion_params, dict):
725
+ raise _validation_error(["llm.completion_params 必须是对象"])
726
+
727
+ if mode == "completion":
728
+ # Dify completion mode expects a single prompt object.
729
+ joined = "\n".join(item["text"] for item in prompt_template)
730
+ compiled_prompt: Any = {"text": joined}
731
+ else:
732
+ compiled_prompt = prompt_template
733
+
734
+ return {
735
+ "id": node["id"],
736
+ "type": "custom",
737
+ "position": {"x": position * _NODE_X_GAP, "y": 0},
738
+ "data": {
739
+ "title": node["id"],
740
+ "desc": "",
741
+ "type": "llm",
742
+ "model": {
743
+ "provider": node["provider"],
744
+ "name": node["model"],
745
+ "mode": mode,
746
+ "completion_params": completion_params,
747
+ },
748
+ "prompt_template": compiled_prompt,
749
+ "context": {"enabled": False, "variable_selector": []},
750
+ "vision": {"enabled": False},
751
+ },
752
+ }
753
+
754
+
755
+ def _compile_template_transform_node(
756
+ node: dict[str, Any],
757
+ *,
758
+ position: int,
759
+ context: _TemplateContext,
760
+ ) -> dict[str, Any]:
761
+ variables_raw = node.get("variables") or {}
762
+ if not isinstance(variables_raw, dict) or not variables_raw:
763
+ raise _validation_error(["template-transform.variables 必须是非空对象"])
764
+ variables = []
765
+ for name, expression in variables_raw.items():
766
+ if not isinstance(expression, str):
767
+ raise _validation_error(
768
+ [f"template-transform.variables.{name} 必须是字符串引用"]
769
+ )
770
+ reference = _REFERENCE.fullmatch(expression)
771
+ if reference is None:
772
+ raise _validation_error(
773
+ [f"template-transform.variables.{name} 必须是单个模板引用"]
774
+ )
775
+ namespace, field = reference.groups()
776
+ value_selector = _resolve_value_selector(
777
+ namespace,
778
+ field,
779
+ context,
780
+ error_prefix=f"template-transform.variables.{name}",
781
+ )
782
+ variables.append({"variable": name, "value_selector": value_selector})
783
+
784
+ template = node.get("template")
785
+ if not isinstance(template, str) or not template.strip():
786
+ raise _validation_error(["template-transform.template 必须是非空字符串"])
787
+ # Jinja template must not contain TACO {{ ns.field }} refs; use variables.
788
+ if _REFERENCE.search(template) is not None:
789
+ raise _validation_error(
790
+ [
791
+ "template-transform.template 不能直接使用 TACO 节点引用,"
792
+ "请通过 variables 绑定后在模板中使用 {{ var }} 写法"
793
+ ]
794
+ )
795
+
796
+ return {
797
+ "id": node["id"],
798
+ "type": "custom",
799
+ "position": {"x": position * _NODE_X_GAP, "y": 0},
800
+ "data": {
801
+ "title": node["id"],
802
+ "desc": "",
803
+ "type": "template-transform",
804
+ "variables": variables,
805
+ "template": template,
806
+ },
807
+ }
808
+
809
+ def _compile_code_node(
810
+ node: dict[str, Any],
811
+ *,
812
+ position: int,
813
+ context: _TemplateContext,
814
+ ) -> dict[str, Any]:
815
+ variables_raw = node.get("variables") or {}
816
+ if not isinstance(variables_raw, dict):
817
+ raise _validation_error(["code.variables 必须是对象"])
818
+ variables = []
819
+ for name, expression in variables_raw.items():
820
+ if not isinstance(expression, str):
821
+ raise _validation_error([f"code.variables.{name} 必须是字符串引用"])
822
+ reference = _REFERENCE.fullmatch(expression)
823
+ if reference is None:
824
+ raise _validation_error(
825
+ [f"code.variables.{name} 必须是单个模板引用"]
826
+ )
827
+ namespace, field = reference.groups()
828
+ value_selector = _resolve_value_selector(
829
+ namespace,
830
+ field,
831
+ context,
832
+ error_prefix=f"code.variables.{name}",
833
+ )
834
+ variables.append({"variable": name, "value_selector": value_selector})
835
+
836
+ outputs_raw = node.get("outputs") or {}
837
+ outputs: dict[str, Any] = {}
838
+ for name, config in outputs_raw.items():
839
+ if not isinstance(config, dict) or "type" not in config:
840
+ raise _validation_error([f"code.outputs.{name} 必须包含 type"])
841
+ outputs[name] = {"type": config["type"], "children": None}
842
+
843
+ return {
844
+ "id": node["id"],
845
+ "type": "custom",
846
+ "position": {"x": position * _NODE_X_GAP, "y": 0},
847
+ "data": {
848
+ "title": node["id"],
849
+ "desc": "",
850
+ "type": "code",
851
+ "variables": variables,
852
+ "code_language": node["language"],
853
+ "code": node["code"],
854
+ "outputs": outputs,
855
+ },
856
+ }
857
+
858
+
859
+ def _compile_knowledge_retrieval_node(
860
+ node: dict[str, Any],
861
+ *,
862
+ position: int,
863
+ context: _TemplateContext,
864
+ ) -> dict[str, Any]:
865
+ query = node["query"]
866
+ if not isinstance(query, str):
867
+ raise _validation_error(["knowledge-retrieval.query 必须是字符串"])
868
+ reference = _REFERENCE.fullmatch(query)
869
+ if reference is None:
870
+ raise _validation_error(
871
+ ["knowledge-retrieval.query 必须是单个模板引用"]
872
+ )
873
+ namespace, field = reference.groups()
874
+ query_selector = _resolve_value_selector(
875
+ namespace,
876
+ field,
877
+ context,
878
+ error_prefix="knowledge-retrieval.query",
879
+ )
880
+ retrieval_mode = node.get("retrieval_mode", "multiple")
881
+ if retrieval_mode != "multiple":
882
+ raise _validation_error(
883
+ ["知识检索节点首期仅支持 retrieval_mode=multiple"]
884
+ )
885
+ multiple_config: dict[str, Any] = {
886
+ "top_k": node.get("top_k", 3),
887
+ "score_threshold": node.get("score_threshold"),
888
+ "reranking_enable": node.get("reranking_enable", False),
889
+ }
890
+ return {
891
+ "id": node["id"],
892
+ "type": "custom",
893
+ "position": {"x": position * _NODE_X_GAP, "y": 0},
894
+ "data": {
895
+ "title": node["id"],
896
+ "desc": "",
897
+ "type": "knowledge-retrieval",
898
+ "query_variable_selector": query_selector,
899
+ "dataset_ids": list(node["dataset_ids"]),
900
+ "retrieval_mode": "multiple",
901
+ "multiple_retrieval_config": multiple_config,
902
+ },
903
+ }
904
+
905
+
906
+ def _resolve_value_selector(
907
+ namespace: str,
908
+ field: str,
909
+ context: _TemplateContext,
910
+ *,
911
+ error_prefix: str,
912
+ ) -> list[str]:
913
+ if namespace == "inputs":
914
+ if field not in context.input_names:
915
+ raise _validation_error([f"{error_prefix} 未知输入:inputs.{field}"])
916
+ return ["start", field]
917
+ if namespace == "secrets":
918
+ raise _validation_error([f"{error_prefix} 不支持直接引用 secrets"])
919
+ if namespace not in context.node_ids:
920
+ raise _validation_error([f"{error_prefix} 未知节点:{namespace}"])
921
+ allowed = context.node_output_fields.get(namespace, frozenset())
922
+ if field not in allowed:
923
+ raise _validation_error(
924
+ [f"{error_prefix} 节点 {namespace} 不支持输出字段:{field}"]
925
+ )
926
+ if namespace in context.aggregator_ids:
927
+ return [namespace, field, "output"]
928
+ return [namespace, field]
929
+
930
+
931
+ def _collect_step_ids(steps: list[dict[str, Any]]) -> list[str]:
932
+ """Collect all step / nested node / join IDs for uniqueness checks."""
933
+ collected: list[str] = []
934
+ for step in steps:
935
+ collected.append(step["id"])
936
+ if "branch" in step:
937
+ branch = step["branch"]
938
+ for nested in [*branch.get("then", []), *branch.get("else", [])]:
939
+ collected.append(nested["id"])
940
+ elif "parallel" in step:
941
+ parallel = step["parallel"]
942
+ for branch_steps in parallel.get("branches", []):
943
+ for nested in branch_steps:
944
+ collected.append(nested["id"])
945
+ join_id = parallel.get("join")
946
+ if join_id:
947
+ collected.append(join_id)
948
+ elif "iterate" in step:
949
+ for nested in step["iterate"].get("steps", []):
950
+ collected.append(nested["id"])
951
+ return collected
952
+
953
+
954
+ def _iteration_output_type(field: str) -> str:
955
+ if field in {"result"}:
956
+ return "array[object]"
957
+ if field in {"status_code"}:
958
+ return "array[number]"
959
+ return "array[string]"
960
+
961
+
962
+ def _compile_answer_node(
963
+ answer: str,
964
+ *,
965
+ position: int,
966
+ context: _TemplateContext,
967
+ ) -> dict[str, Any]:
968
+ if not isinstance(answer, str) or not answer.strip():
969
+ raise _validation_error(["chatflow.answer 必须是非空字符串"])
970
+ compiled_answer = _compile_template(answer, context)
971
+ return {
972
+ "id": "answer",
973
+ "type": "custom",
974
+ "position": {"x": position * _NODE_X_GAP, "y": 0},
975
+ "data": {
976
+ "title": "Answer",
977
+ "desc": "",
978
+ "type": "answer",
979
+ "variables": [],
980
+ "answer": compiled_answer,
981
+ },
982
+ }
983
+
984
+
985
+ def _validate_unique_ids(ids: list[str]) -> None:
986
+ if not ids:
987
+ raise _validation_error(["nodes/steps 至少需要一个业务节点"])
988
+ seen: set[str] = set()
989
+ for node_id in ids:
990
+ if node_id in _RESERVED_NODE_IDS:
991
+ raise _validation_error(["节点 ID 不能使用保留名"])
992
+ if node_id in seen:
993
+ raise _validation_error([f"节点 ID 重复:{node_id}"])
994
+ seen.add(node_id)
995
+
996
+
997
+ _EMPTY_OPS = frozenset({"empty", "not empty"})
998
+ _AGGREGATOR_FIELD_PRIORITY = (
999
+ "body",
1000
+ "result",
1001
+ "output",
1002
+ "text",
1003
+ "data",
1004
+ )
1005
+
1006
+
1007
+ def _compile_if_else_node(
1008
+ *,
1009
+ step_id: str,
1010
+ when: dict[str, Any],
1011
+ position: int,
1012
+ context: _TemplateContext,
1013
+ ) -> dict[str, Any]:
1014
+ left = when.get("left")
1015
+ if not isinstance(left, str):
1016
+ raise _validation_error(["branch.when.left 必须是字符串模板引用"])
1017
+ reference = _REFERENCE.fullmatch(left)
1018
+ if reference is None:
1019
+ raise _validation_error(
1020
+ ["branch.when.left 必须是单个模板引用,例如 {{ inputs.x }}"]
1021
+ )
1022
+ namespace, field = reference.groups()
1023
+ selector = _resolve_value_selector(
1024
+ namespace,
1025
+ field,
1026
+ context,
1027
+ error_prefix="branch.when.left",
1028
+ )
1029
+ op = when["op"]
1030
+ condition: dict[str, Any] = {
1031
+ "id": f"{step_id}-cond-0",
1032
+ "varType": "string",
1033
+ "variable_selector": selector,
1034
+ "comparison_operator": op,
1035
+ "value": "",
1036
+ }
1037
+ if op not in _EMPTY_OPS:
1038
+ if "right" not in when:
1039
+ raise _validation_error(
1040
+ [f"branch.when.op={op} 时必须提供 right"]
1041
+ )
1042
+ right = when["right"]
1043
+ if right is None:
1044
+ condition["value"] = ""
1045
+ elif isinstance(right, bool):
1046
+ condition["value"] = "true" if right else "false"
1047
+ else:
1048
+ condition["value"] = right
1049
+ return {
1050
+ "id": step_id,
1051
+ "type": "custom",
1052
+ "position": {"x": position * _NODE_X_GAP, "y": 0},
1053
+ "data": {
1054
+ "title": step_id,
1055
+ "desc": "",
1056
+ "type": "if-else",
1057
+ "cases": [
1058
+ {
1059
+ "case_id": "true",
1060
+ "logical_operator": "and",
1061
+ "conditions": [condition],
1062
+ }
1063
+ ],
1064
+ },
1065
+ }
1066
+
1067
+
1068
+ def _primary_output_field(fields: frozenset[str], *, tip_id: str) -> str:
1069
+ for name in _AGGREGATOR_FIELD_PRIORITY:
1070
+ if name in fields:
1071
+ return name
1072
+ if not fields:
1073
+ raise _validation_error(
1074
+ [f"parallel join 无法聚合节点 {tip_id}:该节点没有可引用输出"]
1075
+ )
1076
+ return sorted(fields)[0]
1077
+
1078
+
1079
+ def _compile_variable_aggregator_node(
1080
+ *,
1081
+ join_id: str,
1082
+ tip_fields: list[tuple[str, frozenset[str]]],
1083
+ position: int,
1084
+ ) -> tuple[dict[str, Any], frozenset[str]]:
1085
+ groups = []
1086
+ group_names: list[str] = []
1087
+ for tip_id, fields in tip_fields:
1088
+ field = _primary_output_field(fields, tip_id=tip_id)
1089
+ group_names.append(tip_id)
1090
+ groups.append(
1091
+ {
1092
+ "groupId": tip_id,
1093
+ "group_name": tip_id,
1094
+ "output_type": "string",
1095
+ "variables": [[tip_id, field]],
1096
+ }
1097
+ )
1098
+ node = {
1099
+ "id": join_id,
1100
+ "type": "custom",
1101
+ "position": {"x": position * _NODE_X_GAP, "y": 0},
1102
+ "data": {
1103
+ "title": join_id,
1104
+ "desc": "",
1105
+ "type": "variable-aggregator",
1106
+ "output_type": "string",
1107
+ "variables": [],
1108
+ "advanced_settings": {
1109
+ "group_enabled": True,
1110
+ "groups": groups,
1111
+ },
1112
+ },
1113
+ }
1114
+ return node, frozenset(group_names)
1115
+
1116
+ def _validate_json_compatible(value: Any, *, depth: int = 0) -> None:
1117
+ if depth > _MAX_STRUCTURE_DEPTH:
1118
+ raise _validation_error(["Workflow DSL 嵌套层级过深"])
1119
+ if value is None or isinstance(value, (str, bool, int)):
1120
+ return
1121
+ if isinstance(value, float):
1122
+ if not math.isfinite(value):
1123
+ raise _validation_error(["Workflow DSL 包含非 JSON 数值"])
1124
+ return
1125
+ if isinstance(value, list):
1126
+ for item in value:
1127
+ _validate_json_compatible(item, depth=depth + 1)
1128
+ return
1129
+ if isinstance(value, dict):
1130
+ for key, item in value.items():
1131
+ if not isinstance(key, str):
1132
+ raise _validation_error(["Workflow DSL mapping key 必须是字符串"])
1133
+ _validate_json_compatible(item, depth=depth + 1)
1134
+ return
1135
+ raise _validation_error(["Workflow DSL 包含非 JSON 兼容值"])
1136
+
1137
+
1138
+ def _validate_schema(workflow: dict[str, Any]) -> None:
1139
+ kind = workflow.get("kind")
1140
+ try:
1141
+ if kind == "chatflow":
1142
+ schema = load_json_asset("schemas", "chatflow")
1143
+ else:
1144
+ schema = load_json_asset("schemas", "workflow")
1145
+ except TacoError:
1146
+ schema = load_json_asset("schemas", "workflow.http")
1147
+ validator = Draft202012Validator(schema)
1148
+ details = [
1149
+ _schema_error_message(error)
1150
+ for error in sorted(
1151
+ validator.iter_errors(workflow),
1152
+ key=lambda item: (
1153
+ tuple(str(part) for part in item.absolute_path),
1154
+ str(item.validator),
1155
+ ),
1156
+ )
1157
+ ]
1158
+ if details:
1159
+ raise _validation_error(details)
1160
+
1161
+
1162
+ def _schema_error_message(error: Any) -> str:
1163
+ path = _json_path(list(error.absolute_path)) or "$"
1164
+ if error.validator == "required" and isinstance(error.instance, dict):
1165
+ missing = sorted(
1166
+ field
1167
+ for field in error.validator_value
1168
+ if field not in error.instance
1169
+ )
1170
+ return f"{path} 缺少必填字段:{', '.join(missing)}"
1171
+ if error.validator == "additionalProperties":
1172
+ allowed = set(error.schema.get("properties", {}))
1173
+ unknown = sorted(set(error.instance) - allowed)
1174
+ suffix = f":{', '.join(unknown)}" if unknown else ""
1175
+ return f"{path} 包含不支持字段{suffix}"
1176
+ if error.validator == "minItems":
1177
+ if path in {"nodes", "steps"}:
1178
+ return f"{path} 至少需要一个业务节点"
1179
+ return f"{path} 至少需要一项"
1180
+ if error.validator in {"enum", "const"}:
1181
+ return f"{path} 的值不受支持"
1182
+ if error.validator == "type":
1183
+ return f"{path} 的类型不合法"
1184
+ if error.validator == "pattern":
1185
+ return f"{path} 的格式不合法"
1186
+ return f"{path} 不符合 Workflow schema 约束"
1187
+
1188
+
1189
+ def _validate_node_ids(nodes: list[dict[str, Any]]) -> list[str]:
1190
+ if not nodes:
1191
+ raise _validation_error(["nodes/steps 至少需要一个业务节点"])
1192
+ node_ids: list[str] = []
1193
+ seen: set[str] = set()
1194
+ for index, node in enumerate(nodes):
1195
+ node_id = node["id"]
1196
+ if node_id in _RESERVED_NODE_IDS:
1197
+ raise _validation_error(
1198
+ [f"nodes[{index}].id 不能使用保留 ID"]
1199
+ )
1200
+ if node_id in seen:
1201
+ raise _validation_error([f"nodes[{index}].id 重复"])
1202
+ seen.add(node_id)
1203
+ node_ids.append(node_id)
1204
+ return node_ids
1205
+
1206
+
1207
+ def _compile_start_node(inputs: dict[str, dict[str, Any]]) -> dict[str, Any]:
1208
+ variables = []
1209
+ for name, config in inputs.items():
1210
+ variables.append(
1211
+ {
1212
+ "variable": name,
1213
+ "label": config.get("description") or name,
1214
+ "type": _INPUT_TYPES[config["type"]],
1215
+ "required": config["required"],
1216
+ "max_length": config.get("max_length", 48),
1217
+ "options": list(config.get("options", [])),
1218
+ }
1219
+ )
1220
+ return {
1221
+ "id": "start",
1222
+ "type": "custom",
1223
+ "position": {"x": 0, "y": 0},
1224
+ "data": {
1225
+ "title": "Start",
1226
+ "desc": "",
1227
+ "type": "start",
1228
+ "variables": variables,
1229
+ },
1230
+ }
1231
+
1232
+
1233
+ def _compile_http_node(
1234
+ node: dict[str, Any],
1235
+ *,
1236
+ position: int,
1237
+ context: _TemplateContext,
1238
+ ) -> dict[str, Any]:
1239
+ timeout = {
1240
+ name: node.get("timeout", {}).get(name, default)
1241
+ for name, default in _DEFAULT_TIMEOUT.items()
1242
+ }
1243
+ timeout.update(
1244
+ {
1245
+ f"max_{name}_timeout": 0
1246
+ for name in timeout
1247
+ }
1248
+ )
1249
+ retry = node.get("retry", {})
1250
+ retry_config = {
1251
+ "retry_enabled": retry.get(
1252
+ "enabled",
1253
+ retry.get("retry_enabled", _DEFAULT_RETRY["retry_enabled"]),
1254
+ ),
1255
+ "max_retries": retry.get(
1256
+ "max_retries",
1257
+ _DEFAULT_RETRY["max_retries"],
1258
+ ),
1259
+ "retry_interval": retry.get(
1260
+ "interval",
1261
+ retry.get("retry_interval", _DEFAULT_RETRY["retry_interval"]),
1262
+ ),
1263
+ }
1264
+ return {
1265
+ "id": node["id"],
1266
+ "type": "custom",
1267
+ "position": {"x": position * _NODE_X_GAP, "y": 0},
1268
+ "data": {
1269
+ "title": node["id"],
1270
+ "desc": "",
1271
+ "type": "http-request",
1272
+ "variables": [],
1273
+ "method": node["method"].lower(),
1274
+ "url": _compile_url(node["url"], context),
1275
+ "authorization": _compile_authorization(node.get("authorization")),
1276
+ "headers": _compile_mapping(
1277
+ node.get("headers", {}),
1278
+ context,
1279
+ key_type="header",
1280
+ ),
1281
+ "params": _compile_mapping(
1282
+ node.get("query", {}),
1283
+ context,
1284
+ key_type="query",
1285
+ ),
1286
+ "body": _compile_body(node.get("body"), context),
1287
+ "timeout": timeout,
1288
+ "retry_config": retry_config,
1289
+ },
1290
+ }
1291
+
1292
+
1293
+ def _compile_url(value: str, context: _TemplateContext) -> str:
1294
+ compiled = _compile_template(value, context)
1295
+ template_free = _TEMPLATE.sub("template", value)
1296
+ try:
1297
+ parsed = urlsplit(template_free)
1298
+ raw_parsed = urlsplit(value)
1299
+ parsed.port
1300
+ except ValueError as error:
1301
+ raise _validation_error(["HTTP 节点 URL 不合法"]) from error
1302
+
1303
+ if (
1304
+ not value
1305
+ or value != value.strip()
1306
+ or any(character.isspace() for character in template_free)
1307
+ or parsed.scheme.lower() not in {"http", "https"}
1308
+ or not parsed.netloc
1309
+ or not parsed.hostname
1310
+ or "{{" in raw_parsed.netloc
1311
+ or "}}" in raw_parsed.netloc
1312
+ ):
1313
+ raise _validation_error(["HTTP 节点 URL 必须是包含 host 的 HTTP/HTTPS URL"])
1314
+ _, separator, remainder = compiled.partition(":")
1315
+ return f"{parsed.scheme.lower()}{separator}{remainder}"
1316
+
1317
+
1318
+ def _compile_authorization(
1319
+ authorization: dict[str, Any] | None,
1320
+ ) -> dict[str, Any]:
1321
+ if authorization is None:
1322
+ return {"type": "no-auth", "config": None}
1323
+
1324
+ auth_type = authorization["type"]
1325
+ config = authorization.get("config")
1326
+ if auth_type == "no-auth":
1327
+ if config is not None:
1328
+ raise _validation_error(
1329
+ ["no-auth 鉴权不允许 authorization.config"]
1330
+ )
1331
+ return {"type": "no-auth", "config": None}
1332
+
1333
+ if auth_type != "api-key" or not isinstance(config, dict):
1334
+ raise _validation_error(["authorization.config 是 api-key 鉴权的必填对象"])
1335
+
1336
+ api_key = config.get("api_key")
1337
+ secret_reference = (
1338
+ _SECRET_REFERENCE.fullmatch(api_key)
1339
+ if isinstance(api_key, str)
1340
+ else None
1341
+ )
1342
+ if secret_reference is None:
1343
+ raise _validation_error(
1344
+ ["authorization.config.api_key 必须是完整的 secrets 引用"]
1345
+ )
1346
+
1347
+ config_type = config.get("type")
1348
+ if config_type not in {"basic", "bearer", "custom"}:
1349
+ raise _validation_error(
1350
+ ["authorization.config.type 的值不受支持"]
1351
+ )
1352
+ header = config.get("header")
1353
+ if config_type == "custom" and (
1354
+ not isinstance(header, str) or not header
1355
+ ):
1356
+ raise _validation_error(
1357
+ ["custom api-key 鉴权必须提供 header"]
1358
+ )
1359
+ if "header" in config:
1360
+ _validate_header_name(header)
1361
+
1362
+ compiled_config = {
1363
+ "type": config_type,
1364
+ "api_key": f"{{{{#env.{secret_reference.group(1)}#}}}}",
1365
+ }
1366
+ if "header" in config:
1367
+ compiled_config["header"] = header
1368
+ return {
1369
+ "type": auth_type,
1370
+ "config": compiled_config,
1371
+ }
1372
+
1373
+
1374
+ def _compile_body(
1375
+ body: dict[str, Any] | None,
1376
+ context: _TemplateContext,
1377
+ ) -> dict[str, Any]:
1378
+ if body is None or body["type"] == "none":
1379
+ return {"type": "none", "data": []}
1380
+
1381
+ body_type = body["type"]
1382
+ data = body["data"]
1383
+ if body_type == "json":
1384
+ if isinstance(data, str):
1385
+ try:
1386
+ parsed_data = json.loads(
1387
+ data,
1388
+ parse_constant=_reject_json_constant,
1389
+ )
1390
+ _validate_json_compatible(parsed_data)
1391
+ except (ValueError, RecursionError) as error:
1392
+ raise _validation_error(
1393
+ ["json body.data 必须是合法 JSON 字符串"]
1394
+ ) from error
1395
+ compiled_data = parsed_data
1396
+ elif isinstance(data, dict):
1397
+ compiled_data = data
1398
+ else:
1399
+ raise _validation_error(
1400
+ ["json body.data 必须是对象或字符串"]
1401
+ )
1402
+ _validate_static_json(compiled_data)
1403
+ try:
1404
+ value = json.dumps(
1405
+ compiled_data,
1406
+ ensure_ascii=False,
1407
+ sort_keys=True,
1408
+ allow_nan=False,
1409
+ )
1410
+ except (TypeError, ValueError, RecursionError) as error:
1411
+ raise _validation_error(
1412
+ ["body.data 包含无法序列化的 JSON 值"]
1413
+ ) from error
1414
+ items = [
1415
+ {
1416
+ "key": "",
1417
+ "type": "text",
1418
+ "value": value,
1419
+ }
1420
+ ]
1421
+ elif body_type == "raw-text":
1422
+ if not isinstance(data, str):
1423
+ raise _validation_error(
1424
+ ["raw-text body.data 必须是字符串"]
1425
+ )
1426
+ items = [
1427
+ {
1428
+ "key": "",
1429
+ "type": "text",
1430
+ "value": _compile_template(data, context),
1431
+ }
1432
+ ]
1433
+ elif body_type == "x-www-form-urlencoded":
1434
+ if not isinstance(data, dict):
1435
+ raise _validation_error(
1436
+ ["x-www-form-urlencoded body.data 必须是对象"]
1437
+ )
1438
+ items = [
1439
+ {
1440
+ "key": key,
1441
+ "type": "text",
1442
+ "value": _compile_scalar(value, context),
1443
+ }
1444
+ for key, value in data.items()
1445
+ ]
1446
+ else:
1447
+ raise _validation_error([f"不支持的 body.type:{body_type}"])
1448
+ return {"type": body_type, "data": items}
1449
+
1450
+
1451
+ def _reject_json_constant(_: str) -> None:
1452
+ raise ValueError("non-standard JSON constant")
1453
+
1454
+
1455
+ def _compile_mapping(
1456
+ values: dict[str, Any],
1457
+ context: _TemplateContext,
1458
+ *,
1459
+ key_type: str,
1460
+ ) -> str:
1461
+ compiled_items = []
1462
+ for key, value in values.items():
1463
+ _validate_mapping_key(key, key_type=key_type)
1464
+ if isinstance(value, str):
1465
+ _validate_single_line_value(value)
1466
+ if key_type == "header" and _TEMPLATE.search(value):
1467
+ raise _validation_error(
1468
+ ["HTTP header value 不允许使用模板"]
1469
+ )
1470
+ compiled_value = _compile_scalar(value, context)
1471
+ _validate_single_line_value(compiled_value)
1472
+ compiled_items.append(f"{key}: {compiled_value}")
1473
+ return "\n".join(compiled_items)
1474
+
1475
+
1476
+ def _validate_mapping_key(key: Any, *, key_type: str) -> None:
1477
+ if not isinstance(key, str):
1478
+ raise _validation_error(["HTTP mapping key 必须是字符串"])
1479
+ if _contains_line_separator(key):
1480
+ raise _validation_error(["HTTP mapping key 不允许换行"])
1481
+ if key_type == "header":
1482
+ _validate_header_name(key)
1483
+ return
1484
+ if (
1485
+ not key
1486
+ or key != key.strip()
1487
+ or ":" in key
1488
+ ):
1489
+ raise _validation_error(["HTTP query key 不合法"])
1490
+
1491
+
1492
+ def _validate_header_name(value: Any) -> None:
1493
+ if not isinstance(value, str) or _HEADER_NAME.fullmatch(value) is None:
1494
+ raise _validation_error(["HTTP header 名称不合法"])
1495
+
1496
+
1497
+ def _validate_single_line_value(value: str) -> None:
1498
+ if _contains_line_separator(value):
1499
+ raise _validation_error(["HTTP header/query value 不允许换行"])
1500
+
1501
+
1502
+ def _contains_line_separator(value: str) -> bool:
1503
+ return bool(value) and value.splitlines() != [value]
1504
+
1505
+
1506
+ def _compile_scalar(value: Any, context: _TemplateContext) -> str:
1507
+ if isinstance(value, bool):
1508
+ text = "true" if value else "false"
1509
+ else:
1510
+ text = str(value)
1511
+ return _compile_template(text, context)
1512
+
1513
+
1514
+ def _validate_static_json(value: Any) -> None:
1515
+ if isinstance(value, str):
1516
+ _validate_template_syntax(value)
1517
+ for match in _TEMPLATE.finditer(value):
1518
+ if _REFERENCE.fullmatch(match.group(0)) is not None:
1519
+ raise _validation_error(
1520
+ [
1521
+ "当前 Dify 不支持安全动态 JSON body,"
1522
+ "请改用 query 或 x-www-form-urlencoded 传递动态值"
1523
+ ]
1524
+ )
1525
+ raise _validation_error(["存在不支持的模板引用"])
1526
+ if isinstance(value, list):
1527
+ for item in value:
1528
+ _validate_static_json(item)
1529
+ if isinstance(value, dict):
1530
+ for key, item in value.items():
1531
+ _validate_static_json(key)
1532
+ _validate_static_json(item)
1533
+
1534
+
1535
+ def _compile_template(value: str, context: _TemplateContext) -> str:
1536
+ _validate_template_syntax(value)
1537
+
1538
+ def replace(match: re.Match[str]) -> str:
1539
+ reference = _REFERENCE.fullmatch(match.group(0))
1540
+ if reference is None:
1541
+ raise _validation_error(["存在不支持的模板引用"])
1542
+ namespace, field = reference.groups()
1543
+ if namespace == "inputs":
1544
+ if field not in context.input_names:
1545
+ raise _validation_error([f"未知输入引用:inputs.{field}"])
1546
+ return f"{{{{#start.{field}#}}}}"
1547
+ if namespace == "secrets":
1548
+ return f"{{{{#env.{field}#}}}}"
1549
+ if namespace not in context.node_ids:
1550
+ raise _validation_error([f"未知节点引用:{namespace}"])
1551
+ allowed = context.node_output_fields.get(namespace)
1552
+ if allowed is None:
1553
+ allowed = _HTTP_OUTPUT_FIELDS
1554
+ if field not in allowed:
1555
+ raise _validation_error([f"节点 {namespace} 不支持输出字段:{field}"])
1556
+ if namespace in context.aggregator_ids:
1557
+ return f"{{{{#{namespace}.{field}.output#}}}}"
1558
+ return f"{{{{#{namespace}.{field}#}}}}"
1559
+
1560
+ return _TEMPLATE.sub(replace, value)
1561
+
1562
+
1563
+ def _validate_template_syntax(value: str) -> None:
1564
+ cursor = 0
1565
+ for match in _TEMPLATE.finditer(value):
1566
+ unmatched = value[cursor : match.start()]
1567
+ if (
1568
+ "{{" in unmatched
1569
+ or "}}" in unmatched
1570
+ or (match.start() > 0 and value[match.start() - 1] == "{")
1571
+ or (match.end() < len(value) and value[match.end()] == "}")
1572
+ ):
1573
+ raise _validation_error(["模板语法不合法"])
1574
+ cursor = match.end()
1575
+ remainder = value[cursor:]
1576
+ if "{{" in remainder or "}}" in remainder:
1577
+ raise _validation_error(["模板语法不合法"])
1578
+
1579
+
1580
+ def _compile_end_node(
1581
+ outputs: dict[str, str],
1582
+ *,
1583
+ position: int,
1584
+ node_ids: frozenset[str],
1585
+ node_output_fields: dict[str, frozenset[str]] | None = None,
1586
+ aggregator_ids: frozenset[str] | None = None,
1587
+ ) -> dict[str, Any]:
1588
+ output_fields = node_output_fields or {}
1589
+ aggregators = aggregator_ids or frozenset()
1590
+ compiled_outputs = []
1591
+ for variable, expression in outputs.items():
1592
+ reference = _REFERENCE.fullmatch(expression)
1593
+ if reference is None:
1594
+ raise _validation_error(
1595
+ [f"outputs.{variable} 必须是单个节点输出引用"]
1596
+ )
1597
+ node_id, field = reference.groups()
1598
+ if node_id not in node_ids:
1599
+ raise _validation_error([f"outputs.{variable} 引用了未知节点:{node_id}"])
1600
+ allowed = output_fields.get(node_id, _HTTP_OUTPUT_FIELDS)
1601
+ if field not in allowed:
1602
+ raise _validation_error(
1603
+ [f"outputs.{variable} 使用了非法输出字段:{field}"]
1604
+ )
1605
+ if node_id in aggregators:
1606
+ selector: list[str] = [node_id, field, "output"]
1607
+ else:
1608
+ selector = [node_id, field]
1609
+ compiled_outputs.append(
1610
+ {
1611
+ "variable": variable,
1612
+ "value_selector": selector,
1613
+ }
1614
+ )
1615
+ return {
1616
+ "id": "end",
1617
+ "type": "custom",
1618
+ "position": {"x": position * _NODE_X_GAP, "y": 0},
1619
+ "data": {
1620
+ "title": "End",
1621
+ "desc": "",
1622
+ "type": "end",
1623
+ "outputs": compiled_outputs,
1624
+ },
1625
+ }
1626
+
1627
+
1628
+ def _compile_edge(
1629
+ *,
1630
+ source: str,
1631
+ target: str,
1632
+ source_type: str,
1633
+ target_type: str,
1634
+ ) -> dict[str, Any]:
1635
+ return {
1636
+ "id": f"{source}-{target}",
1637
+ "type": "custom",
1638
+ "source": source,
1639
+ "sourceHandle": "source",
1640
+ "target": target,
1641
+ "targetHandle": "target",
1642
+ "data": {
1643
+ "sourceType": source_type,
1644
+ "targetType": target_type,
1645
+ },
1646
+ }
1647
+
1648
+
1649
+ def _json_path(parts: list[Any]) -> str:
1650
+ path = ""
1651
+ for part in parts:
1652
+ if isinstance(part, int):
1653
+ path += f"[{part}]"
1654
+ else:
1655
+ path += f".{part}" if path else str(part)
1656
+ return path
1657
+
1658
+
1659
+ def _validation_error(details: list[str]) -> TacoError:
1660
+ return TacoError(
1661
+ code="WORKFLOW_VALIDATION_FAILED",
1662
+ message="Workflow DSL 校验失败:" + ";".join(details),
1663
+ exit_code=6,
1664
+ hint="请执行 taco schema workflow -o json(或 workflow.http)后修正 YAML DSL。",
1665
+ )