mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 23:41:12 +08:00
## Summary Stabilizes the Go unit-test surface so the test suite can run reliably in CI and locally via \`bash build.sh --test\`. ## Verification \`\`\`bash bash build.sh --test -- -count=10 -run TestWithCancel_SequentialAgent ./internal/harness/core/ bash build.sh --test -- -count=5 -run TestSiliconflowChatExtracts ./internal/entity/models/ bash build.sh --test # full suite \`\`\` All previously failing packages (\`admin\`, \`cli\`, \`handler\`, \`parser\`, \`router\`, \`service\`, \`service/chunk\`) now build and test successfully. \`TestWithCancel_SequentialAgent\` passes 10/10 (was flaky). SiliconFlow reasoning test passes after switching the assertion to the SiliconFlow wire format. --------- Co-authored-by: Claude <noreply@anthropic.com>
871 lines
28 KiB
Go
871 lines
28 KiB
Go
//
|
||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||
//
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
|
||
// Package dsl — single-shape canvas normalizer.
|
||
//
|
||
// The RAGFlow agent DSL has exactly one canonical wire shape:
|
||
//
|
||
// {
|
||
// "globals": { ... },
|
||
// "graph": { "nodes": [...], "edges": [...] }, // React-Flow layout
|
||
// "variables": { ... },
|
||
// "components": { "<Name>:<UUID>": { // execution topology
|
||
// "downstream": [...], "upstream": [...],
|
||
// "obj": { "component_name": "Name", "params": {...} }
|
||
// }},
|
||
// "path": [...], "retrieval": {...}, "history": [...]
|
||
// }
|
||
//
|
||
// Go server code (handler/service/Compile) reads the `components` block —
|
||
// the Python server (agent/canvas.py) does the same. The `graph` block is
|
||
// consumed by the React-Flow front-end to render the canvas. Either side
|
||
// can be missing on a given row (e.g. a hand-imported v1 export from a
|
||
// Python server has `components` but no `graph`; a v1 fixture from the
|
||
// Go port test suite has `graph` and `components` but a slightly
|
||
// different internal layout convention).
|
||
//
|
||
// NormalizeForCanvas is the decoder-boundary entry point for every
|
||
// front-end-facing Go path (handler.AgentHandler, service.AgentService
|
||
// create/update/publish/reset, version reads). The function:
|
||
//
|
||
// 1. Repairs React-Flow handle ids on whatever `graph.edges` are present
|
||
// (source/target handle ids: source=start, target=end).
|
||
// 2. If `graph.nodes` is missing but `components` is non-empty, builds
|
||
// a default-layout graph from the components (deterministic order,
|
||
// 50/200/350 px column layout).
|
||
// 3. Repairs any historically leaked runtime-only Parallel /
|
||
// parallelNode canvas shape back to the front-end's Iteration /
|
||
// iterationNode protocol.
|
||
// 4. Returns a defensive copy of the input with all transforms
|
||
// applied. Never mutates its input.
|
||
//
|
||
// The function never panics on malformed input; unparseable entries are
|
||
// skipped and a best-effort graph is returned.
|
||
//
|
||
// IMPORTANT: this function preserves the front-end canvas protocol. It
|
||
// must not leak runtime-only node kinds (for example "Parallel" /
|
||
// "parallelNode") into `graph.nodes` or rewrite user-authored DSL
|
||
// semantics. Runtime-only folding lives in NormalizeForRun.
|
||
|
||
package dsl
|
||
|
||
import (
|
||
"regexp"
|
||
"sort"
|
||
)
|
||
|
||
// componentNameIteration / componentNameIterationItem are the legacy
|
||
// v1 names that the front-end may still emit. The Go port's runtime
|
||
// uses "Parallel" for the same concept; this constant is the
|
||
// pre-rename label.
|
||
const (
|
||
componentNameLoop = "Loop"
|
||
componentNameLoopItem = "LoopItem"
|
||
componentNameIteration = "Iteration"
|
||
componentNameIterationItem = "IterationItem"
|
||
componentNameParallel = "Parallel"
|
||
)
|
||
|
||
var legacyIterationAliasPattern = regexp.MustCompile(`IterationItem:[A-Za-z0-9_:-]+@(item|index)\b`)
|
||
|
||
// NormalizeForCanvas returns a defensive copy of dsl with a derived
|
||
// `graph.nodes`/`graph.edges` block when missing.
|
||
//
|
||
// Behaviour:
|
||
// - nil in, nil out.
|
||
// - graph.nodes already non-empty: handle ids are still repaired in
|
||
// place (idempotent). Otherwise graph is derived from components.
|
||
// - empty / components:{}: no-op, returns dsl as-is.
|
||
// - any components: builds graph only.
|
||
// - any historically leaked Parallel / parallelNode canvas state is
|
||
// repaired back to Iteration / iterationNode.
|
||
//
|
||
// The function never panics on malformed input; unparseable entries are
|
||
// skipped and a best-effort graph is returned.
|
||
func NormalizeForCanvas(dsl map[string]any) map[string]any {
|
||
return normalize(dsl, false)
|
||
}
|
||
|
||
// NormalizeForRun prepares a DSL for the runtime/compiler path. Unlike
|
||
// NormalizeForCanvas, it is allowed to fold legacy LoopItem /
|
||
// IterationItem children away and rename Iteration to Parallel because
|
||
// the returned map never goes back to the front-end.
|
||
func NormalizeForRun(dsl map[string]any) map[string]any {
|
||
return normalize(dsl, true)
|
||
}
|
||
|
||
func normalize(dsl map[string]any, foldLegacy bool) map[string]any {
|
||
if dsl == nil {
|
||
return nil
|
||
}
|
||
// Defensive deep copy: the normalize pipeline rewrites
|
||
// graph.edges[*].sourceHandle / targetHandle, deletes
|
||
// components entries, and mutates components[*].obj.component_name
|
||
// — all in place. Without the deep copy, callers that reuse
|
||
// the original decoded DSL map would observe side effects.
|
||
out := deepCopyDSL(dsl)
|
||
|
||
// (1) Repair React-Flow handle ids on whatever edges exist.
|
||
enforceHandleIds(out)
|
||
|
||
// (2) Build a default-layout graph if missing.
|
||
if !graphHasNodes(out) {
|
||
rawComps, _ := out["components"].(map[string]any)
|
||
if len(rawComps) > 0 {
|
||
nodes, edges, normComps := buildGraphFromComponents(rawComps)
|
||
if len(nodes) > 0 {
|
||
out["graph"] = map[string]any{
|
||
"nodes": nodes,
|
||
"edges": edges,
|
||
}
|
||
out["components"] = normComps
|
||
}
|
||
}
|
||
}
|
||
|
||
// (3) Repair any historically leaked runtime-only Parallel /
|
||
// parallelNode view back to the front-end's Iteration /
|
||
// iterationNode protocol. This keeps response payloads
|
||
// renderable without exposing backend implementation details.
|
||
repairParallelLeaksForCanvas(out)
|
||
|
||
if foldLegacy {
|
||
// (4) Runtime-only compatibility: fold legacy Loop+LoopItem and
|
||
// Iteration+IterationItem pairs in place. This step uses
|
||
// graph.nodes[*].parentId to discover parent/child
|
||
// relationships; if `graph` is still missing the fold degrades
|
||
// to a pure rename (component_name: "Iteration" → "Parallel";
|
||
// LoopItem/IterationItem names stay in components but
|
||
// downstream compile/expand paths must tolerate them).
|
||
foldLegacyLoopVariants(out)
|
||
|
||
rewriteLegacyIterationAliases(out)
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
// rewriteLegacyIterationAliases rewrites runtime-only references to the
|
||
// legacy IterationItem child's synthetic outputs back to the modern
|
||
// item/index aliases that CanvasState exposes. This runs only on the
|
||
// runtime-normalized copy, never on the front-end-facing canvas view.
|
||
func rewriteLegacyIterationAliases(dsl map[string]any) {
|
||
for k, v := range dsl {
|
||
switch x := v.(type) {
|
||
case string:
|
||
dsl[k] = replaceLegacyIterationAliasRefs(x)
|
||
case map[string]any:
|
||
rewriteLegacyIterationAliases(x)
|
||
case []any:
|
||
rewriteLegacyIterationAliasesInSlice(x)
|
||
}
|
||
}
|
||
}
|
||
|
||
func rewriteLegacyIterationAliasesInSlice(items []any) {
|
||
for i, v := range items {
|
||
switch x := v.(type) {
|
||
case string:
|
||
items[i] = replaceLegacyIterationAliasRefs(x)
|
||
case map[string]any:
|
||
rewriteLegacyIterationAliases(x)
|
||
case []any:
|
||
rewriteLegacyIterationAliasesInSlice(x)
|
||
}
|
||
}
|
||
}
|
||
|
||
func replaceLegacyIterationAliasRefs(s string) string {
|
||
return legacyIterationAliasPattern.ReplaceAllStringFunc(s, func(match string) string {
|
||
sub := legacyIterationAliasPattern.FindStringSubmatch(match)
|
||
if len(sub) != 2 {
|
||
return match
|
||
}
|
||
alias := sub[1]
|
||
switch alias {
|
||
case "item", "index":
|
||
return alias
|
||
default:
|
||
return match
|
||
}
|
||
})
|
||
}
|
||
|
||
// repairParallelLeaksForCanvas rewrites any historically leaked
|
||
// runtime-only Parallel / parallelNode view back to the front-end's
|
||
// Iteration / iterationNode protocol. This is a response-shape repair
|
||
// only; it does not perform parent/child folding.
|
||
func repairParallelLeaksForCanvas(dsl map[string]any) {
|
||
rawComps, _ := dsl["components"].(map[string]any)
|
||
for _, raw := range rawComps {
|
||
comp, _ := raw.(map[string]any)
|
||
if comp == nil {
|
||
continue
|
||
}
|
||
if obj, ok := comp["obj"].(map[string]any); ok {
|
||
if obj["component_name"] == componentNameParallel {
|
||
obj["component_name"] = componentNameIteration
|
||
}
|
||
}
|
||
if comp["name"] == componentNameParallel {
|
||
comp["name"] = componentNameIteration
|
||
}
|
||
}
|
||
|
||
graph, _ := dsl["graph"].(map[string]any)
|
||
if graph == nil {
|
||
return
|
||
}
|
||
nodes, _ := graph["nodes"].([]any)
|
||
for _, raw := range nodes {
|
||
node, _ := raw.(map[string]any)
|
||
if node == nil {
|
||
continue
|
||
}
|
||
if node["type"] == "parallelNode" {
|
||
node["type"] = "iterationNode"
|
||
}
|
||
data, _ := node["data"].(map[string]any)
|
||
if data == nil {
|
||
continue
|
||
}
|
||
if data["label"] == componentNameParallel {
|
||
data["label"] = componentNameIteration
|
||
}
|
||
if data["name"] == componentNameParallel {
|
||
data["name"] = componentNameIteration
|
||
}
|
||
}
|
||
}
|
||
|
||
// enforceHandleIds rewrites graph.edges[*].sourceHandle / targetHandle
|
||
// to the front-end's React Flow convention. Tool/agent handles (id !=
|
||
// "end" on source / != "start" on target) are left alone because they
|
||
// are not produced by the basic component DAG.
|
||
func enforceHandleIds(dsl map[string]any) {
|
||
graph, _ := dsl["graph"].(map[string]any)
|
||
if graph == nil {
|
||
return
|
||
}
|
||
edges, _ := graph["edges"].([]any)
|
||
if len(edges) == 0 {
|
||
return
|
||
}
|
||
for _, e := range edges {
|
||
m, _ := e.(map[string]any)
|
||
if m == nil {
|
||
continue
|
||
}
|
||
// Only rewrite plain "start"/"end" conventions. Agent/tool
|
||
// handles carry semantic info we must not stomp.
|
||
if src, _ := m["sourceHandle"].(string); src == "end" || src == "start" {
|
||
m["sourceHandle"] = "start"
|
||
}
|
||
if dst, _ := m["targetHandle"].(string); dst == "start" || dst == "end" {
|
||
m["targetHandle"] = "end"
|
||
}
|
||
}
|
||
}
|
||
|
||
// graphHasNodes reports whether the input already carries a non-empty
|
||
// React-Flow-shaped graph. Any missing / wrong-typed sub-key counts as
|
||
// "no graph", which is the conservative default.
|
||
func graphHasNodes(dsl map[string]any) bool {
|
||
graph, ok := dsl["graph"].(map[string]any)
|
||
if !ok {
|
||
return false
|
||
}
|
||
nodes, ok := graph["nodes"].([]any)
|
||
if !ok {
|
||
return false
|
||
}
|
||
return len(nodes) > 0
|
||
}
|
||
|
||
// buildGraphFromComponents converts the `components` block into
|
||
// React-Flow-shaped nodes + edges and a normalised (flat) components
|
||
// map keyed the same way the input was.
|
||
//
|
||
// Layout strategy: simple left-to-right single row, x = 50 + i*350,
|
||
// y = 200. Cycles are not detected — every component gets its own slot
|
||
// in iteration order. The user is expected to re-arrange via the
|
||
// front-end, which is consistent with how legacy data used to be
|
||
// surfaced to the editor before the bug-fix.
|
||
func buildGraphFromComponents(components map[string]any) (nodes []any, edges []any, normalized map[string]any) {
|
||
nodes = make([]any, 0, len(components))
|
||
edges = make([]any, 0)
|
||
normalized = make(map[string]any, len(components))
|
||
|
||
// Go's map iteration is randomised. Sort the component ids before
|
||
// iterating so the layout (x = 50 + i*350) is a stable function of
|
||
// the input dsl, not of Go's runtime iteration lottery. Two
|
||
// normalize passes over the same dsl also used to produce
|
||
// `components` and `graph.nodes` in different orders, which broke
|
||
// the dslToGraph equality invariant; sorting here closes that gap.
|
||
keys := make([]string, 0, len(components))
|
||
for k := range components {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
const xStep = 350.0
|
||
const yBase = 200.0
|
||
i := 0
|
||
for _, key := range keys {
|
||
raw := components[key]
|
||
comp, _ := raw.(map[string]any)
|
||
if comp == nil {
|
||
continue
|
||
}
|
||
name, params, downstream := extractComponent(comp)
|
||
if name == "" {
|
||
name = key
|
||
}
|
||
node := map[string]any{
|
||
"id": key,
|
||
"type": componentNameToNodeType(name),
|
||
"position": map[string]any{"x": 50.0 + float64(i)*xStep, "y": yBase},
|
||
// Always emit `data.form` (even when empty) so the React
|
||
// Flow node shape is byte-equal between the Python v1
|
||
// fallback (which reads `obj.params` and may be `{}`) and
|
||
// the Go v2 path. The same invariant applies to the
|
||
// normalised components map above.
|
||
"data": map[string]any{"label": name, "name": name, "form": params},
|
||
"sourcePosition": "right",
|
||
"targetPosition": "left",
|
||
}
|
||
nodes = append(nodes, node)
|
||
|
||
for _, dst := range downstream {
|
||
edges = append(edges, map[string]any{
|
||
"id": "xy-edge__" + key + "-" + dst,
|
||
"source": key,
|
||
"target": dst,
|
||
// Source/target handle ids match the front-end's React Flow
|
||
// convention (web/src/pages/agent/hooks/use-add-node.ts:114):
|
||
// source node renders its OUTPUT handle with id = "start"
|
||
// target node renders its INPUT handle with id = "end"
|
||
"sourceHandle": "start",
|
||
"targetHandle": "end",
|
||
})
|
||
}
|
||
|
||
flat := map[string]any{
|
||
"id": key,
|
||
"name": name,
|
||
"downstream": toStringSlice(comp["downstream"]),
|
||
"upstream": toStringSlice(comp["upstream"]),
|
||
// Always emit `params` (even when empty) so the normalised
|
||
// component shape matches the Python v1 server byte-for-byte.
|
||
"params": params,
|
||
}
|
||
normalized[key] = flat
|
||
i++
|
||
}
|
||
return nodes, edges, normalized
|
||
}
|
||
|
||
// extractComponent pulls (name, params, downstream) out of a component
|
||
// block. The Go port stores them flat (`name` / `params` at top
|
||
// level). Returns empty values for missing fields.
|
||
func extractComponent(comp map[string]any) (name string, params map[string]any, downstream []string) {
|
||
if obj, ok := comp["obj"].(map[string]any); ok {
|
||
name, _ = obj["component_name"].(string)
|
||
if p, ok := obj["params"].(map[string]any); ok {
|
||
params = p
|
||
}
|
||
// Read obj.downstream first; the trailing outer-downstream
|
||
// append below handles the case where the v1 writer put the
|
||
// topology on the outer field. Use a local var so the nil
|
||
// check stays unambiguous to the nilness analyser.
|
||
var ds []string
|
||
ds = toStringSlice(obj["downstream"])
|
||
if len(ds) > 0 {
|
||
downstream = ds
|
||
}
|
||
}
|
||
if name == "" {
|
||
name, _ = comp["name"].(string)
|
||
}
|
||
if params == nil {
|
||
if p, ok := comp["params"].(map[string]any); ok {
|
||
params = p
|
||
}
|
||
}
|
||
downstream = append(downstream, toStringSlice(comp["downstream"])...)
|
||
return name, params, downstream
|
||
}
|
||
|
||
func toStringSlice(v any) []string {
|
||
arr, ok := v.([]any)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
out := make([]string, 0, len(arr))
|
||
for _, x := range arr {
|
||
if s, ok := x.(string); ok && s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
// deepCopyDSL returns a deep copy of the parts of `dsl` that
|
||
// NormalizeForCanvas mutates: the top-level keys "graph" and
|
||
// "components", and within `graph` the "nodes" and "edges" slices.
|
||
// All other top-level keys (`globals`, `variables`, `path`,
|
||
// `retrieval`, `history`, `*`) are shallow-copied by reference —
|
||
// they are read-only and never modified by the normalize pipeline.
|
||
//
|
||
// The deep copy is required because:
|
||
// - enforceHandleIds rewrites graph.edges[*].sourceHandle /
|
||
// targetHandle in place.
|
||
// - foldLegacyLoopVariants deletes entries from components,
|
||
// rewrites components[*].obj.component_name, and rewrites
|
||
// graph.nodes[*].data.label / type.
|
||
//
|
||
// Without the deep copy, a caller that reuses the original
|
||
// decoded DSL map (e.g. for re-validation or diffing) would
|
||
// observe side effects that contradict the documented
|
||
// "never mutates its input" contract.
|
||
//
|
||
// Primitives and non-mutable values (string, number, bool) are
|
||
// shared by reference; only the maps and slices that the
|
||
// normalize pipeline touches are duplicated.
|
||
func deepCopyDSL(dsl map[string]any) map[string]any {
|
||
out := make(map[string]any, len(dsl)+1)
|
||
for k, v := range dsl {
|
||
switch k {
|
||
case "graph":
|
||
if g, ok := v.(map[string]any); ok {
|
||
out["graph"] = deepCopyGraph(g)
|
||
} else {
|
||
out["graph"] = v
|
||
}
|
||
case "components":
|
||
if c, ok := v.(map[string]any); ok {
|
||
out["components"] = deepCopyComponents(c)
|
||
} else {
|
||
out["components"] = v
|
||
}
|
||
default:
|
||
// Shallow: globals, variables, path, retrieval,
|
||
// history, and any other top-level key are not
|
||
// mutated by the normalize pipeline. Sharing the
|
||
// reference is safe.
|
||
out[k] = v
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// deepCopyAny returns a recursive deep copy of v. Maps and slices
|
||
// are duplicated recursively; primitives and nil are passed through.
|
||
// This ensures mutations to nested fields (e.g. data.label, data.name)
|
||
// never alias into the caller's original input.
|
||
func deepCopyAny(v any) any {
|
||
switch x := v.(type) {
|
||
case map[string]any:
|
||
out := make(map[string]any, len(x))
|
||
for k, val := range x {
|
||
out[k] = deepCopyAny(val)
|
||
}
|
||
return out
|
||
case []any:
|
||
out := make([]any, len(x))
|
||
for i, val := range x {
|
||
out[i] = deepCopyAny(val)
|
||
}
|
||
return out
|
||
default:
|
||
return v
|
||
}
|
||
}
|
||
|
||
// deepCopyGraph copies a graph block. Nodes and edges are deep-copied
|
||
// element-by-element so that later mutations (e.g. data.label rewrite
|
||
// in fixComponentNames) target the copy, not the caller's input.
|
||
func deepCopyGraph(g map[string]any) map[string]any {
|
||
out := make(map[string]any, len(g))
|
||
for k, v := range g {
|
||
switch k {
|
||
case "nodes":
|
||
if nodes, ok := v.([]any); ok {
|
||
copied := make([]any, len(nodes))
|
||
for i, n := range nodes {
|
||
copied[i] = deepCopyAny(n)
|
||
}
|
||
out["nodes"] = copied
|
||
} else {
|
||
out["nodes"] = v
|
||
}
|
||
case "edges":
|
||
if edges, ok := v.([]any); ok {
|
||
copied := make([]any, len(edges))
|
||
for i, e := range edges {
|
||
copied[i] = deepCopyAny(e)
|
||
}
|
||
out["edges"] = copied
|
||
} else {
|
||
out["edges"] = v
|
||
}
|
||
default:
|
||
out[k] = v
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// deepCopyComponents copies a components block. Each component
|
||
// entry is a new map; the `obj` sub-map (when present) is also
|
||
// deep-copied so rewrites to component_name / params land on
|
||
// the copy.
|
||
func deepCopyComponents(c map[string]any) map[string]any {
|
||
out := make(map[string]any, len(c))
|
||
for k, v := range c {
|
||
if cm, ok := v.(map[string]any); ok {
|
||
entry := deepCopyAny(cm).(map[string]any)
|
||
if obj, ok := cm["obj"].(map[string]any); ok {
|
||
entry["obj"] = deepCopyAny(obj)
|
||
}
|
||
out[k] = entry
|
||
} else {
|
||
out[k] = v
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// copyMapStringAny returns a shallow copy of m. The new map
|
||
// aliases the original values; callers that need a deeper copy
|
||
// recurse on their own (e.g. deepCopyGraph / deepCopyComponents
|
||
// recurse on `obj` and on each node / edge).
|
||
func copyMapStringAny(m map[string]any) map[string]any {
|
||
out := make(map[string]any, len(m))
|
||
for k, v := range m {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// stringsToAny is the inverse of toStringSlice: it packs a []string
|
||
// back into a []any so a downstream `.([]any)` type assertion
|
||
// succeeds. The fold step needs this because it computes a
|
||
// []string and the parent component's downstream slot is consumed
|
||
// elsewhere as []any.
|
||
func stringsToAny(s []string) []any {
|
||
out := make([]any, 0, len(s))
|
||
for _, x := range s {
|
||
if x == "" {
|
||
continue
|
||
}
|
||
out = append(out, x)
|
||
}
|
||
if len(out) == 0 {
|
||
return []any{}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// componentNameToNodeType maps a component_name to the front-end React
|
||
// Flow node type. Unknown names fall back to "agentNode" — the
|
||
// front-end re-derives the operator from `data.label`, so an unknown
|
||
// type is still rendered (just possibly in a generic shape). The user
|
||
// can re-pick a type from the operator palette to refine.
|
||
var componentNameToNodeTypeMap = map[string]string{
|
||
"Begin": "beginNode",
|
||
"Retrieval": "ragNode",
|
||
"Categorize": "categorizeNode",
|
||
"Message": "messageNode",
|
||
"Answer": "messageNode",
|
||
"RewriteQuestion": "rewriteNode",
|
||
"ExeSQL": "toolNode",
|
||
"Switch": "switchNode",
|
||
"Agent": "agentNode",
|
||
"Tool": "toolNode",
|
||
"File": "fileNode",
|
||
"Parser": "parserNode",
|
||
"Tokenizer": "tokenizerNode",
|
||
"TokenChunker": "chunkerNode",
|
||
"TitleChunker": "chunkerNode",
|
||
"Extractor": "contextNode",
|
||
"Loop": "loopNode",
|
||
"LoopStart": "loopStartNode",
|
||
"ExitLoop": "exitLoopNode",
|
||
"Iteration": "iterationNode",
|
||
"IterationStart": "iterationStartNode",
|
||
"Parallel": "parallelNode",
|
||
"DataOperations": "dataOperationsNode",
|
||
"ListOperations": "listOperationsNode",
|
||
"VariableAssigner": "variableAssignerNode",
|
||
"VariableAggregator": "variableAggregatorNode",
|
||
"Keyword": "keywordNode",
|
||
"Note": "noteNode",
|
||
"Placeholder": "placeholderNode",
|
||
"Code": "toolNode",
|
||
}
|
||
|
||
func componentNameToNodeType(name string) string {
|
||
if t, ok := componentNameToNodeTypeMap[name]; ok {
|
||
return t
|
||
}
|
||
return "agentNode"
|
||
}
|
||
|
||
// foldLegacyLoopVariants collapses Loop+LoopItem and Iteration+IterationItem
|
||
// node pairs into single Loop / Parallel nodes. The fold runs at the
|
||
// decoder boundary so every Go path (handler, service, future Compile)
|
||
// inherits the compatibility for free.
|
||
//
|
||
// The algorithm:
|
||
//
|
||
// 1. Build a childOf map from graph.nodes[*].parentId. If graph is
|
||
// missing, childOf is empty and the fold degrades to a pure rename.
|
||
// 2. For each component whose component_name is "LoopItem" or
|
||
// "IterationItem": drop it from components and, if its parent is
|
||
// known, append the child's downstream to the parent's downstream
|
||
// (preserving the React-Flow edge topology).
|
||
// 3. Rename remaining "Iteration" parents to "Parallel" so the
|
||
// downstream compile / expand paths only need to know about the
|
||
// modern names. Loop parents keep their canonical "Loop" name.
|
||
//
|
||
// Notes:
|
||
// - Child params are not merged into the parent. The control surface
|
||
// (loop_variables / loop_termination_condition / items_ref) lives
|
||
// on the parent; child params typically carry only `outputs` schema
|
||
// declarations that are derived at runtime, not stored in the dsl.
|
||
// - This function mutates `dsl` in place (the caller already gave us
|
||
// a defensive copy at the top of NormalizeForCanvas).
|
||
func foldLegacyLoopVariants(dsl map[string]any) {
|
||
rawComps, _ := dsl["components"].(map[string]any)
|
||
if len(rawComps) == 0 {
|
||
return
|
||
}
|
||
|
||
// Build parent-of map from graph.nodes. parentId is a React-Flow
|
||
// node-level field (verified in testdata/all.json:309).
|
||
childOf := buildParentMap(dsl)
|
||
|
||
// (1) Walk every component, drop legacy children, append their
|
||
// downstream to the parent's downstream.
|
||
for childID, raw := range rawComps {
|
||
comp, _ := raw.(map[string]any)
|
||
if comp == nil {
|
||
continue
|
||
}
|
||
childName := componentNameFromComp(comp)
|
||
if !isLegacyChildName(childName) {
|
||
continue
|
||
}
|
||
parentID, ok := childOf[childID]
|
||
if !ok {
|
||
// No parent visible in the graph via parentId
|
||
// mapping. Keep the child — deleting it could
|
||
// leave dangling downstream references in the
|
||
// parent component.
|
||
continue
|
||
}
|
||
parentRaw, ok := rawComps[parentID]
|
||
if !ok {
|
||
delete(rawComps, childID)
|
||
continue
|
||
}
|
||
parentComp, _ := parentRaw.(map[string]any)
|
||
if parentComp == nil {
|
||
delete(rawComps, childID)
|
||
continue
|
||
}
|
||
// Append child downstream to parent downstream, then drop
|
||
// the child id itself from the parent's downstream list —
|
||
// the child is the entry node, not an execution target, so
|
||
// once folded it must not appear in any edge. The result is
|
||
// stored as []any (not []string) so a consumer can do a
|
||
// `parent["downstream"].([]any)` type assertion without
|
||
// losing data.
|
||
childDS := toStringSlice(childCompDownstream(comp))
|
||
merged := mergeDownstream(toStringSlice(parentComp["downstream"]), childDS)
|
||
merged = removeFromSlice(merged, childID)
|
||
parentComp["downstream"] = stringsToAny(merged)
|
||
// Also append to the parent graph node's downstream, if we
|
||
// have a graph. This keeps the React-Flow edges in sync with
|
||
// the topology map.
|
||
if graph, _ := dsl["graph"].(map[string]any); graph != nil {
|
||
if nodes, _ := graph["nodes"].([]any); nodes != nil {
|
||
for _, n := range nodes {
|
||
nm, _ := n.(map[string]any)
|
||
if nm == nil {
|
||
continue
|
||
}
|
||
if id, _ := nm["id"].(string); id == parentID {
|
||
// The graph node's downstream isn't a
|
||
// standard field; the standard React-Flow
|
||
// topology is encoded in `edges`. Leave the
|
||
// graph node alone; the user will re-save
|
||
// to re-derive edges from components.
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Drop the child from components.
|
||
delete(rawComps, childID)
|
||
}
|
||
|
||
// (2) Rename "Iteration" parents to "Parallel". The
|
||
// `component_name` lives under `obj.component_name` (v1 shape) or
|
||
// `name` (Go flat shape); we rewrite both keys for safety.
|
||
for id, raw := range rawComps {
|
||
comp, _ := raw.(map[string]any)
|
||
if comp == nil {
|
||
continue
|
||
}
|
||
if componentNameFromComp(comp) != componentNameIteration {
|
||
continue
|
||
}
|
||
if obj, ok := comp["obj"].(map[string]any); ok {
|
||
obj["component_name"] = componentNameParallel
|
||
}
|
||
comp["name"] = componentNameParallel
|
||
// Also rewrite the graph node label so the React-Flow
|
||
// renderer's componentNameToNodeTypeMap lookup ("Parallel" →
|
||
// "parallelNode") succeeds on the next paint.
|
||
if graph, _ := dsl["graph"].(map[string]any); graph != nil {
|
||
if nodes, _ := graph["nodes"].([]any); nodes != nil {
|
||
for _, n := range nodes {
|
||
nm, _ := n.(map[string]any)
|
||
if nm == nil || nm["id"] != id {
|
||
continue
|
||
}
|
||
if data, _ := nm["data"].(map[string]any); data != nil {
|
||
data["label"] = componentNameParallel
|
||
data["name"] = componentNameParallel
|
||
}
|
||
nm["type"] = componentNameToNodeType(componentNameParallel)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// buildParentMap scans graph.nodes for React-Flow's parentId field and
|
||
// returns id → parentID. Returns an empty map if graph or nodes is
|
||
// missing.
|
||
func buildParentMap(dsl map[string]any) map[string]string {
|
||
out := map[string]string{}
|
||
graph, _ := dsl["graph"].(map[string]any)
|
||
if graph == nil {
|
||
return out
|
||
}
|
||
nodes, _ := graph["nodes"].([]any)
|
||
if len(nodes) == 0 {
|
||
return out
|
||
}
|
||
for _, n := range nodes {
|
||
nm, _ := n.(map[string]any)
|
||
if nm == nil {
|
||
continue
|
||
}
|
||
id, _ := nm["id"].(string)
|
||
parent, _ := nm["parentId"].(string)
|
||
if id != "" && parent != "" {
|
||
out[id] = parent
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// componentNameFromComp returns the component_name from either the
|
||
// nested `obj` (v1) or the flat `name` (Go) shape.
|
||
func componentNameFromComp(comp map[string]any) string {
|
||
if obj, ok := comp["obj"].(map[string]any); ok {
|
||
if n, _ := obj["component_name"].(string); n != "" {
|
||
return n
|
||
}
|
||
}
|
||
if n, _ := comp["name"].(string); n != "" {
|
||
return n
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// childCompDownstream returns a child component's downstream list,
|
||
// looking at the outer `downstream` (v1) and `obj.downstream` (legacy
|
||
// v1 double-write) keys.
|
||
func childCompDownstream(comp map[string]any) any {
|
||
if d, ok := comp["downstream"]; ok {
|
||
return d
|
||
}
|
||
if obj, ok := comp["obj"].(map[string]any); ok {
|
||
if d, ok := obj["downstream"]; ok {
|
||
return d
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// mergeDownstream returns parent ∪ child in stable order, with parent
|
||
// entries first. Duplicates dropped.
|
||
func mergeDownstream(parent, child []string) []string {
|
||
if len(child) == 0 {
|
||
return parent
|
||
}
|
||
seen := make(map[string]bool, len(parent)+len(child))
|
||
out := make([]string, 0, len(parent)+len(child))
|
||
for _, s := range parent {
|
||
if s == "" || seen[s] {
|
||
continue
|
||
}
|
||
seen[s] = true
|
||
out = append(out, s)
|
||
}
|
||
for _, s := range child {
|
||
if s == "" || seen[s] {
|
||
continue
|
||
}
|
||
seen[s] = true
|
||
out = append(out, s)
|
||
}
|
||
if len(out) == 0 {
|
||
return []string{}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// removeFromSlice returns a copy of s with the first occurrence of
|
||
// drop removed (or s unchanged if drop is absent). Used by the loop
|
||
// fold to filter the child id out of the parent's downstream list
|
||
// once the child has been merged in.
|
||
func removeFromSlice(s []string, drop string) []string {
|
||
if drop == "" {
|
||
return s
|
||
}
|
||
out := make([]string, 0, len(s))
|
||
for _, x := range s {
|
||
if x == drop {
|
||
continue
|
||
}
|
||
out = append(out, x)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// isLegacyChildName reports whether name is a legacy parent-child
|
||
// control node that should be folded away.
|
||
func isLegacyChildName(name string) bool {
|
||
return name == componentNameLoopItem || name == componentNameIterationItem
|
||
}
|