mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
feat: refactor node spec and add mcp tools (#244)
* refactor: carve out extraction panel * refactor: create spec versions for node types * refactor: create a GenericNode and remove custom nodes * feat: add python and typescript sdk * add dograh sdk * fix: fetch draft workflow definition over published one * fix: fix routes of SDKs to use code gen * chore: remove doclink dependency to reduce image size * chore: format files * chore: bump pipecat * feat: let mcp fetch archived workflows on demand * chore: fix tests * feat: add sdk documentation * chore: change banner and add badge
This commit is contained in:
parent
0a61ef295f
commit
00a1a22b74
162 changed files with 14355 additions and 3554 deletions
105
api/services/workflow/layout.py
Normal file
105
api/services/workflow/layout.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Position reconciliation for LLM-edited workflows.
|
||||
|
||||
`save_workflow` re-parses LLM-authored TypeScript into workflow JSON,
|
||||
but the parser deliberately ignores positions (LLMs place nodes
|
||||
poorly, and the authoring surface stays tighter without coordinates).
|
||||
This module fills them back in by matching the newly-parsed nodes
|
||||
against the previously-stored workflow:
|
||||
|
||||
1. Named match: (type, data.name) — most reliable
|
||||
2. Unnamed match: (type, nth-occurrence-in-order) — best effort
|
||||
3. New nodes: placed adjacent to their first incoming neighbor
|
||||
(src.x + 400, src.y + 200), or (0, 0) if orphan
|
||||
|
||||
The UI has a proper dagre-based re-layout button
|
||||
(`ui/src/app/workflow/[workflowId]/utils/layoutNodes.ts`) users can
|
||||
invoke when they want a clean pass. This module only aims to avoid
|
||||
all-nodes-at-origin after a save.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_DEFAULT_POSITION: dict[str, float] = {"x": 0.0, "y": 0.0}
|
||||
# Horizontal / vertical offset for newly-introduced nodes relative to
|
||||
# their first incoming neighbor. Chosen to roughly match the UI layout's
|
||||
# node spacing without overlapping the neighbor's card.
|
||||
_NEW_NODE_DX: float = 400.0
|
||||
_NEW_NODE_DY: float = 200.0
|
||||
|
||||
|
||||
def reconcile_positions(
|
||||
new_wf: dict[str, Any],
|
||||
previous_wf: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return `new_wf` with positions filled from `previous_wf` where
|
||||
node identity matches, and approximate positions for genuinely new
|
||||
nodes. Mutates and returns the same dict (callers typically want
|
||||
the mutation)."""
|
||||
if not previous_wf:
|
||||
_place_new_nodes(new_wf)
|
||||
return new_wf
|
||||
|
||||
prev_nodes = previous_wf.get("nodes") or []
|
||||
named_positions: dict[tuple[str, str], dict[str, float]] = {}
|
||||
unnamed_positions: dict[str, list[dict[str, float]]] = {}
|
||||
|
||||
for n in prev_nodes:
|
||||
t = n.get("type") or ""
|
||||
name = ((n.get("data") or {}).get("name") or "").strip()
|
||||
pos = n.get("position") or dict(_DEFAULT_POSITION)
|
||||
if name:
|
||||
named_positions[(t, name)] = pos
|
||||
else:
|
||||
unnamed_positions.setdefault(t, []).append(pos)
|
||||
|
||||
unnamed_cursor: dict[str, int] = {}
|
||||
|
||||
for node in new_wf.get("nodes") or []:
|
||||
t = node.get("type") or ""
|
||||
name = ((node.get("data") or {}).get("name") or "").strip()
|
||||
|
||||
pos: dict[str, float] | None = None
|
||||
if name:
|
||||
pos = named_positions.get((t, name))
|
||||
if pos is None:
|
||||
idx = unnamed_cursor.get(t, 0)
|
||||
positions = unnamed_positions.get(t, [])
|
||||
if idx < len(positions):
|
||||
pos = positions[idx]
|
||||
unnamed_cursor[t] = idx + 1
|
||||
if pos is not None:
|
||||
node["position"] = dict(pos)
|
||||
|
||||
_place_new_nodes(new_wf)
|
||||
return new_wf
|
||||
|
||||
|
||||
def _place_new_nodes(wf: dict[str, Any]) -> None:
|
||||
"""For nodes still at (0, 0) — i.e. unmatched by any previous
|
||||
node — pick a position adjacent to the first incoming neighbor.
|
||||
Runs after named/unnamed matching so only genuinely-new nodes are
|
||||
affected."""
|
||||
nodes = wf.get("nodes") or []
|
||||
if not nodes:
|
||||
return
|
||||
id_to_node = {n["id"]: n for n in nodes}
|
||||
edges = wf.get("edges") or []
|
||||
|
||||
for node in nodes:
|
||||
pos = node.get("position") or {}
|
||||
if pos.get("x") or pos.get("y"):
|
||||
continue # already has a non-origin position
|
||||
src_id = next(
|
||||
(e["source"] for e in edges if e.get("target") == node["id"]),
|
||||
None,
|
||||
)
|
||||
if src_id and src_id in id_to_node:
|
||||
src_pos = id_to_node[src_id].get("position") or dict(_DEFAULT_POSITION)
|
||||
node["position"] = {
|
||||
"x": float(src_pos.get("x", 0.0)) + _NEW_NODE_DX,
|
||||
"y": float(src_pos.get("y", 0.0)) + _NEW_NODE_DY,
|
||||
}
|
||||
# Leaves truly orphan new nodes at (0, 0). The UI's re-layout
|
||||
# pass will pull them into the graph on next edit.
|
||||
Loading…
Add table
Add a link
Reference in a new issue