mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
151 lines
7.2 KiB
Python
151 lines
7.2 KiB
Python
"""Reconcile approved generation with placeholders and session-owned nodes.
|
|
|
|
Canvas presentation is irrelevant. Model identity and empty memory role prefixes
|
|
can be filled by the editor without user configuration. Other configuration must
|
|
still match the initial template. Keep the frontend Builder classifier aligned.
|
|
"""
|
|
|
|
from collections.abc import Collection
|
|
|
|
from core.dify_builder.models import Graph, MutationIntent
|
|
from services.dify_builder.graph_ops import apply_create_node, validate_intent_args
|
|
|
|
|
|
def _initial_data(data: dict) -> dict:
|
|
return {
|
|
key: value
|
|
for key, value in data.items()
|
|
if not key.startswith("_") and key not in {"title", "selected"} and not (key == "desc" and not value)
|
|
}
|
|
|
|
|
|
def is_initial_chatflow(graph: Graph, *, mode: str) -> bool:
|
|
nodes, edges = graph.get("nodes", []), graph.get("edges", [])
|
|
if mode != "advanced-chat" or len(nodes) != 3 or len(edges) != 2 or any(n.get("parentId") for n in nodes):
|
|
return False
|
|
by_type = {node.get("data", {}).get("type"): node for node in nodes}
|
|
if set(by_type) != {"start", "llm", "answer"}:
|
|
return False
|
|
start, llm, answer = (by_type[kind] for kind in ("start", "llm", "answer"))
|
|
if not all(node.get("id") for node in (start, llm, answer)):
|
|
return False
|
|
for source, target in ((start["id"], llm["id"]), (llm["id"], answer["id"])):
|
|
if not any(
|
|
edge.get("source") == source
|
|
and edge.get("target") == target
|
|
and edge.get("sourceHandle") in (None, "", "source")
|
|
and edge.get("targetHandle") in (None, "", "target")
|
|
for edge in edges
|
|
):
|
|
return False
|
|
llm_data = _initial_data(llm["data"])
|
|
model, memory = llm_data.get("model"), llm_data.get("memory")
|
|
if not isinstance(model, dict) or not isinstance(memory, dict):
|
|
return False
|
|
llm_data["model"] = {key: value for key, value in model.items() if key not in {"provider", "name"}}
|
|
llm_data["memory"] = {
|
|
key: value for key, value in memory.items() if key != "role_prefix" or value != {"user": "", "assistant": ""}
|
|
}
|
|
return (
|
|
_initial_data(start["data"]) == {"type": "start", "variables": []}
|
|
and _initial_data(answer["data"])
|
|
== {"type": "answer", "variables": [], "answer": f"{{{{#{llm['id']}.text#}}}}"}
|
|
and llm_data
|
|
== {
|
|
"type": "llm",
|
|
"model": {"mode": "chat", "completion_params": {"temperature": 0.7}},
|
|
"prompt_template": [{"role": "system", "text": ""}],
|
|
"context": {"enabled": False, "variable_selector": []},
|
|
"vision": {"enabled": False},
|
|
"memory": {
|
|
"window": {"enabled": False, "size": 10},
|
|
"query_prompt_template": "{{#sys.query#}}\n\n{{#sys.files#}}",
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def _owned_config_intents(node: dict, intent: MutationIntent) -> list[MutationIntent]:
|
|
"""Convert one owned same-type create into changed configuration paths.
|
|
|
|
The caller establishes ownership. Type, containment and ReactFlow component
|
|
must match; unsupported replacements raise ValueError before any write.
|
|
Reuse creation's defaults for explicitly generated fields, retaining all
|
|
unmentioned data and canvas presentation. Neither input is mutated.
|
|
"""
|
|
validate_intent_args(intent)
|
|
node_id, node_type = node["id"], intent.args["node_type"]
|
|
data = node.get("data", {})
|
|
if node_type != data.get("type"):
|
|
raise ValueError(f"Node {node_id!r} changes type; adjust the plan to keep its existing type before approving.")
|
|
if (intent.args.get("parent_id") or None) != (node.get("parentId") or None):
|
|
raise ValueError(f"Node {node_id!r} changes container; adjust the plan to keep its existing container.")
|
|
if intent.args.get("flow_type") and intent.args["flow_type"] != node.get("type", "custom"):
|
|
raise ValueError(f"Node {node_id!r} changes canvas component; adjust the plan to keep its existing component.")
|
|
replacement, _ = apply_create_node(
|
|
{"nodes": [], "edges": []},
|
|
node_type,
|
|
intent.args["config"],
|
|
node_id=node_id,
|
|
parent_id=intent.args.get("parent_id"),
|
|
flow_type=intent.args.get("flow_type"),
|
|
)
|
|
generated = replacement["nodes"][0]
|
|
if generated["type"] != node.get("type", "custom"):
|
|
raise ValueError(f"Node {node_id!r} changes canvas component; adjust the plan to keep its existing component.")
|
|
patches = []
|
|
for key in intent.args["config"]:
|
|
if key in {"isInIteration", "iteration_id", "isInLoop", "loop_id"} and generated["data"][key] != data.get(key):
|
|
raise ValueError(f"Node {node_id!r} changes containment configuration; keep its existing container.")
|
|
if key not in {"type", "title", "desc", "selected", "width", "height"} and not key.startswith("_"):
|
|
if "." in key or key.isdigit():
|
|
raise ValueError(f"Node {node_id!r} has an unsupported configuration field {key!r}.")
|
|
value = generated["data"][key]
|
|
if key not in data or data[key] != value:
|
|
patches.append(
|
|
MutationIntent(op="set_node_config", args={"node_id": node_id, "path": key, "value": value})
|
|
)
|
|
return patches
|
|
|
|
|
|
def build_intents_for_draft(
|
|
graph: Graph, intents: list[MutationIntent], *, mode: str, built_node_ids: Collection[str]
|
|
) -> list[MutationIntent]:
|
|
"""Replace placeholders or update explicitly generated owned configuration.
|
|
|
|
Delete and create stay in one apply_repair batch. Deleted IDs and their
|
|
incident edges must not suppress generated intents that reuse those IDs.
|
|
Existing IDs require session ownership and same-type/containment creates;
|
|
otherwise raise ValueError without mutating the graph or input intents.
|
|
Identical owned configuration and existing connections produce no intents.
|
|
"""
|
|
nodes = graph.get("nodes", [])
|
|
replace_initial = is_initial_chatflow(graph, mode=mode) or all(
|
|
node.get("data", {}).get("type") == "start" for node in nodes
|
|
)
|
|
deletes = (
|
|
[MutationIntent(op="delete_node", args={"node_id": node["id"]}) for node in nodes] if replace_initial else []
|
|
)
|
|
deleted_ids = {intent.args["node_id"] for intent in deletes}
|
|
existing = {node["id"]: node for node in nodes if node["id"] not in deleted_ids}
|
|
existing_edges = {
|
|
(edge.get("source"), edge.get("target"))
|
|
for edge in graph.get("edges", [])
|
|
if edge.get("source") not in deleted_ids and edge.get("target") not in deleted_ids
|
|
}
|
|
reconciled = list(deletes)
|
|
for intent in intents:
|
|
node_id = intent.args.get("node_id")
|
|
if intent.op == "create_node" and node_id in existing:
|
|
if node_id not in built_node_ids:
|
|
raise ValueError(
|
|
f"Node {node_id!r} already exists and was not built by this session. "
|
|
"Adjust the plan to use distinct node IDs before approving."
|
|
)
|
|
reconciled.extend(_owned_config_intents(existing[node_id], intent))
|
|
elif not (
|
|
intent.op == "connect" and (intent.args.get("from_node"), intent.args.get("to_node")) in existing_edges
|
|
):
|
|
reconciled.append(intent)
|
|
return reconciled
|