ReactFlow前端拖拽模型与参数配置功能MVP

This commit is contained in:
ljz committed 2026-07-11 10:49:11 +08:00
1 parent 70c91ed019
commit d3b99304fb
2001 files changed
+349044 -548

No files matched your search

+328 -283
View File
@@ -1,330 +1,375 @@
from __future__ import annotations
import base64
from datetime import datetime, timezone
import json
import os
import urllib.error
import urllib.request
import zlib
from pathlib import Path
from typing import Iterable
from urllib.parse import unquote, urljoin
from typing import Any
from xml.etree import ElementTree as ET
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field
app = FastAPI(title="System Simulation App")
INDEX_TEMPLATE = Path(__file__).parent / "static" / "index.html"
DRAWIO_UPSTREAM_URL = (
os.getenv("DRAWIO_UPSTREAM_URL")
or os.getenv("DRAWIO_BASE_URL")
or "http://127.0.0.1:8081/"
)
COMPONENT_LIBRARY = [
{
"title": "\u8d2e\u7bb1",
"w": 80,
"h": 80,
"xml": (
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>'
'<object id="tank" label="\u8d2e\u7bb1" componentType="tank" '
'medium="N2" volume="1.0"><mxCell style="shape=cylinder;'
"whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;"
'size=15;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" '
'parent="1"><mxGeometry width="80" height="80" as="geometry"/>'
"</mxCell></object></root></mxGraphModel>"
),
},
{
"title": "\u9600\u95e8",
"w": 72,
"h": 56,
"xml": (
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>'
'<object id="valve" label="\u9600\u95e8" componentType="valve" '
'valveType="manual" nominalDiameter="10"><mxCell style="rhombus;'
'whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" '
'vertex="1" parent="1"><mxGeometry width="72" height="56" '
'as="geometry"/></mxCell></object></root></mxGraphModel>'
),
},
{
"title": "\u7ba1\u6bb5",
"w": 96,
"h": 36,
"xml": (
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>'
'<object id="pipe" label="\u7ba1\u6bb5" componentType="pipe" '
'length="1.0" diameter="0.01"><mxCell style="rounded=1;'
"whiteSpace=wrap;html=1;arcSize=12;fillColor=#d5e8d4;"
'strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry '
'width="96" height="36" as="geometry"/></mxCell></object>'
"</root></mxGraphModel>"
),
},
]
app = FastAPI(title="System Simulation ReactFlow App")
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects"
class DiagramPayload(BaseModel):
xml: str
class ReactFlowPosition(BaseModel):
x: float = 0.0
y: float = 0.0
class ReactFlowNodeData(BaseModel):
label: str = ""
componentType: str = "component"
modelType: str = "component"
ports: list[str] = Field(default_factory=list)
parameters: dict[str, Any] = Field(default_factory=dict)
class ReactFlowNodePayload(BaseModel):
id: str
type: str | None = None
position: ReactFlowPosition = Field(default_factory=ReactFlowPosition)
data: ReactFlowNodeData
class ReactFlowEdgePayload(BaseModel):
id: str
source: str
target: str
sourceHandle: str | None = None
targetHandle: str | None = None
class ReactFlowSimulationConfig(BaseModel):
t_start: float = 0.0
t_stop: float = 2.0
step: float = 0.1
max_step: float = 0.005
method: str = "BDF"
class ReactFlowProjectPayload(BaseModel):
name: str = "untitled"
nodes: list[ReactFlowNodePayload] = Field(default_factory=list)
edges: list[ReactFlowEdgePayload] = Field(default_factory=list)
simulation: ReactFlowSimulationConfig = Field(default_factory=ReactFlowSimulationConfig)
def pydantic_to_jsonable(model: BaseModel) -> dict[str, Any]:
if hasattr(model, "model_dump"):
return model.model_dump(mode="json")
return model.dict()
@app.get("/")
def index(request: Request) -> HTMLResponse:
html = INDEX_TEMPLATE.read_text(encoding="utf-8")
drawio_base_url = default_drawio_base_url(request)
html = html.replace("__DRAWIO_BASE_URL__", json.dumps(drawio_base_url))
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
def index() -> HTMLResponse:
frontend_index = FRONTEND_DIST_DIR / "index.html"
if frontend_index.exists():
return HTMLResponse(
frontend_index.read_text(encoding="utf-8"),
headers={"Cache-Control": "no-store"},
)
@app.get("/api/component-library.drawiolib")
def component_library() -> Response:
library = ET.Element("mxlibrary", {"title": "System Components"})
library.text = json.dumps(COMPONENT_LIBRARY, ensure_ascii=False)
return Response(
content=ET.tostring(library, encoding="utf-8"),
media_type="application/xml",
headers={
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
},
return HTMLResponse(
"""
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>System Simulation Flow</title>
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1f2933;
background: #f8fafc;
}
main {
max-width: 720px;
margin: 96px auto;
padding: 0 24px;
}
code {
padding: 2px 6px;
border-radius: 4px;
background: #e5e7eb;
}
</style>
</head>
<body>
<main>
<h1>System Simulation Flow</h1>
<p>ReactFlow 前端未构建。开发时请在 <code>frontend</code> 目录运行 <code>npm run dev</code>,然后打开 <code>http://127.0.0.1:5173</code>。</p>
<p>如果要让 FastAPI 直接服务页面,请先运行 <code>npm run build</code> 生成 <code>frontend/dist</code>。</p>
</main>
</body>
</html>
""",
headers={"Cache-Control": "no-store"},
)
@app.api_route("/drawio", methods=["GET", "POST", "HEAD", "OPTIONS"])
@app.api_route("/drawio/{path:path}", methods=["GET", "POST", "HEAD", "OPTIONS"])
async def drawio_proxy(request: Request, path: str = "") -> Response:
upstream_url = build_drawio_upstream_url(path, request.url.query)
body = await request.body()
headers = {
key: value
for key, value in request.headers.items()
if key.lower()
not in {
"host",
"connection",
"content-length",
"accept-encoding",
}
@app.get("/assets/{path:path}")
def frontend_asset(path: str) -> FileResponse:
asset_path = (FRONTEND_DIST_DIR / "assets" / path).resolve()
assets_root = (FRONTEND_DIST_DIR / "assets").resolve()
try:
asset_path.relative_to(assets_root)
except ValueError as exc:
raise HTTPException(status_code=404, detail="Frontend asset was not found.") from exc
if not asset_path.exists():
raise HTTPException(status_code=404, detail="Frontend asset was not found.")
return FileResponse(asset_path)
@app.post("/api/reactflow/system-xml")
def export_reactflow_system_xml(payload: ReactFlowProjectPayload) -> Response:
return Response(
content=build_reactflow_system_xml(payload),
media_type="application/xml",
)
@app.get("/api/reactflow/projects")
def list_reactflow_projects() -> dict[str, object]:
PROJECT_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
projects = []
for path in sorted(PROJECT_STORAGE_DIR.glob("*.json")):
projects.append(
{
"id": path.stem,
"updatedAt": datetime.fromtimestamp(
path.stat().st_mtime,
timezone.utc,
).isoformat(),
}
)
return {"projects": projects}
@app.get("/api/reactflow/projects/{project_id}")
def load_reactflow_project(project_id: str) -> dict[str, object]:
path = reactflow_project_path(project_id)
if not path.exists():
raise HTTPException(status_code=404, detail="Project was not found.")
return json.loads(path.read_text(encoding="utf-8"))
@app.post("/api/reactflow/projects/{project_id}")
def save_reactflow_project(
project_id: str,
payload: ReactFlowProjectPayload,
) -> dict[str, object]:
path = reactflow_project_path(project_id)
PROJECT_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
data = pydantic_to_jsonable(payload)
data["name"] = payload.name or project_id
path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return {
"id": path.stem,
"path": str(path),
"componentCount": len(payload.nodes),
"connectionCount": len(payload.edges),
}
upstream_request = urllib.request.Request(
upstream_url,
data=body if body else None,
headers=headers,
method=request.method,
)
@app.post("/api/reactflow/simulate-testmodel")
def simulate_reactflow_testmodel(payload: ReactFlowProjectPayload) -> dict[str, object]:
try:
with urllib.request.urlopen(upstream_request, timeout=30) as upstream_response:
content = upstream_response.read()
return Response(
content=content,
status_code=upstream_response.status,
headers=filter_proxy_headers(upstream_response.headers),
)
except urllib.error.HTTPError as exc:
return Response(
content=exc.read(),
status_code=exc.code,
headers=filter_proxy_headers(exc.headers),
)
except urllib.error.URLError as exc:
raise HTTPException(
status_code=502,
detail=f"Unable to reach draw.io upstream: {exc.reason}",
) from exc
@app.post("/api/system-xml")
def export_system_xml(payload: DiagramPayload) -> Response:
try:
model = read_graph_model(payload.xml)
system_xml = build_system_xml(model)
result = run_reactflow_testmodel(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return Response(content=system_xml, media_type="application/xml")
return result
def default_drawio_base_url(request: Request) -> str:
hostname = request.url.hostname or "127.0.0.1"
if ":" in hostname and not hostname.startswith("["):
hostname = f"[{hostname}]"
port = f":{request.url.port}" if request.url.port else ""
return f"{request.url.scheme}://{hostname}{port}/drawio/"
def build_drawio_upstream_url(path: str, query: str) -> str:
target = urljoin(DRAWIO_UPSTREAM_URL, path)
if query:
target = f"{target}?{query}"
return target
def filter_proxy_headers(headers) -> dict[str, str]:
excluded = {
"connection",
"content-encoding",
"content-length",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
return {
key: value
for key, value in headers.items()
if key.lower() not in excluded
}
def read_graph_model(xml_text: str) -> ET.Element:
xml_text = xml_text.strip()
if not xml_text:
raise ValueError("Diagram XML is empty.")
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
raise ValueError("Diagram XML is invalid.") from exc
if tag_name(root) == "mxGraphModel":
return root
if tag_name(root) != "mxfile":
raise ValueError("Expected mxGraphModel or mxfile XML.")
diagram = next((child for child in root if tag_name(child) == "diagram"), None)
if diagram is None:
raise ValueError("mxfile does not contain a diagram.")
embedded_model = next(
(child for child in diagram if tag_name(child) == "mxGraphModel"),
None,
)
if embedded_model is not None:
return embedded_model
if not diagram.text:
raise ValueError("diagram node is empty.")
return decode_diagram_payload(diagram.text.strip())
def decode_diagram_payload(payload: str) -> ET.Element:
if payload.startswith("<"):
return parse_graph_model(payload)
if payload.startswith("%3C"):
return parse_graph_model(unquote(payload))
try:
padded = payload + ("=" * (-len(payload) % 4))
compressed = base64.b64decode(padded)
try:
decoded = zlib.decompress(compressed, -15)
except zlib.error:
decoded = zlib.decompress(compressed)
return parse_graph_model(unquote(decoded.decode("utf-8")))
except Exception as exc: # noqa: BLE001 - keep API error small and stable.
raise ValueError("Unable to decode draw.io diagram payload.") from exc
def parse_graph_model(xml_text: str) -> ET.Element:
try:
model = ET.fromstring(xml_text)
except ET.ParseError as exc:
raise ValueError("Decoded diagram XML is invalid.") from exc
if tag_name(model) != "mxGraphModel":
raise ValueError("Decoded diagram is not an mxGraphModel.")
return model
def build_system_xml(model: ET.Element) -> bytes:
root = next((child for child in model if tag_name(child) == "root"), None)
if root is None:
raise ValueError("mxGraphModel does not contain a root node.")
system = ET.Element("System")
def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
system = ET.Element("System", {"name": project.name})
components_node = ET.SubElement(system, "Components")
connections_node = ET.SubElement(system, "Connections")
for component in read_components(root):
for node in project.nodes:
component_node = ET.SubElement(
components_node,
"Component",
{
"id": component["id"],
"type": component["type"],
"label": component["label"],
"id": node.id,
"name": node.data.label,
"type": node.data.modelType,
"componentType": node.data.componentType,
"x": f"{node.position.x:g}",
"y": f"{node.position.y:g}",
},
)
for name, value in component["properties"].items():
for port in node.data.ports:
ET.SubElement(component_node, "Port", {"name": port})
for name, value in node.data.parameters.items():
ET.SubElement(
component_node,
"Property",
{"name": name, "value": value},
"Parameter",
{"name": name, "value": str(value)},
)
for edge in read_connections(root):
ET.SubElement(connections_node, "Connection", edge)
for edge in project.edges:
ET.SubElement(
connections_node,
"Connection",
{
"id": edge.id,
"source": edge.source,
"sourcePort": edge.sourceHandle or "",
"target": edge.target,
"targetPort": edge.targetHandle or "",
},
)
ET.indent(system, space=" ")
return ET.tostring(system, encoding="utf-8", xml_declaration=True)
def read_components(root: ET.Element) -> Iterable[dict]:
for element in root:
if tag_name(element) != "object":
continue
cell = next((child for child in element if tag_name(child) == "mxCell"), None)
if cell is None or cell.attrib.get("vertex") != "1":
continue
component_id = element.attrib.get("id") or cell.attrib.get("id")
if not component_id:
continue
properties = {
key: value
for key, value in element.attrib.items()
if key not in {"id", "label", "componentType"}
}
yield {
"id": component_id,
"type": element.attrib.get("componentType", "component"),
"label": element.attrib.get("label", ""),
"properties": properties,
}
def reactflow_project_path(project_id: str) -> Path:
safe_id = sanitize_project_id(project_id)
return PROJECT_STORAGE_DIR / f"{safe_id}.json"
def read_connections(root: ET.Element) -> Iterable[dict[str, str]]:
for cell in root.iter():
if tag_name(cell) != "mxCell" or cell.attrib.get("edge") != "1":
continue
source = cell.attrib.get("source")
target = cell.attrib.get("target")
if not source or not target:
continue
edge = {"source": source, "target": target}
if "id" in cell.attrib:
edge["id"] = cell.attrib["id"]
yield edge
def sanitize_project_id(project_id: str) -> str:
safe_id = "".join(
character
for character in project_id.strip()
if character.isalnum() or character in {"-", "_"}
)
if not safe_id:
raise HTTPException(status_code=400, detail="Project id cannot be empty.")
return safe_id[:80]
def tag_name(element: ET.Element) -> str:
return element.tag.rsplit("}", 1)[-1]
def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, object]:
from PythonModels.core.solver import SolveIVPConfig
from PythonModels.scripts.run_testmodel import (
TestModelExecutionConfig,
TestModelRunConfig,
TestModelSamplingConfig,
run_testmodel,
)
from PythonModels.systems.testmodel import (
BranchConfig,
CylinderConfig,
OrificeConfig,
PipeConfig,
TankConfig,
TestModelConfig,
)
nodes_by_type: dict[str, list[ReactFlowNodePayload]] = {}
for node in project.nodes:
nodes_by_type.setdefault(node.data.modelType, []).append(node)
cylinder = first_node(nodes_by_type, "cylinder")
tank = first_node(nodes_by_type, "tank")
orifices = nodes_by_type.get("orifice", [])
pipes = nodes_by_type.get("pipe", [])
model_config = TestModelConfig(
cylinder=CylinderConfig(
volume=parameter_float(cylinder, "volume", 0.01),
p0=parameter_float(cylinder, "p0", 35e6),
T0=parameter_float(cylinder, "T0", 300.0),
),
upper_branch=BranchConfig(
orifice=OrificeConfig(K=parameter_float(node_at(orifices, 0), "K", 1e-5)),
pipe=pipe_config_from_node(node_at(pipes, 0), PipeConfig),
),
lower_branch=BranchConfig(
orifice=OrificeConfig(K=parameter_float(node_at(orifices, 1), "K", 1e-5)),
pipe=pipe_config_from_node(node_at(pipes, 1), PipeConfig),
),
tank=TankConfig(
volume=parameter_float(tank, "volume", 0.1),
p0=parameter_float(tank, "p0", 1e5),
T0=parameter_float(tank, "T0", 300.0),
),
)
run_config = TestModelRunConfig(
model=model_config,
solver=SolveIVPConfig(
t_start=project.simulation.t_start,
t_stop=project.simulation.t_stop,
method=project.simulation.method,
max_step=project.simulation.max_step,
),
sampling=TestModelSamplingConfig(step=project.simulation.step),
execution=TestModelExecutionConfig(use_modelica_reference_if_available=False),
)
result = run_testmodel(run_config=run_config)
series_keys = ("time", "mytank.p", "mytank.T", "mycylinder.p", "mycylinder.T")
series = {
key: [float(value) for value in result.series[key]]
for key in series_keys
if key in result.series
}
return {
"success": bool(result.solution.success),
"message": str(result.solution.message),
"usedModelicaReference": result.used_modelica_reference,
"final": {
"time": series["time"][-1],
"tankPressure": series["mytank.p"][-1],
"tankTemperature": series["mytank.T"][-1],
"cylinderPressure": series["mycylinder.p"][-1],
"cylinderTemperature": series["mycylinder.T"][-1],
},
"series": series,
"artifacts": {
"primaryCsv": str(result.artifacts.primary_csv_path),
"temperatureCsv": str(result.artifacts.temperature_csv_path),
"temperatureSvg": str(result.artifacts.temperature_svg_path),
"runReport": str(result.artifacts.run_report_path),
},
"networkSummary": result.system.network.summary(),
}
def first_node(
nodes_by_type: dict[str, list[ReactFlowNodePayload]],
model_type: str,
) -> ReactFlowNodePayload | None:
nodes = nodes_by_type.get(model_type, [])
return nodes[0] if nodes else None
def node_at(
nodes: list[ReactFlowNodePayload],
index: int,
) -> ReactFlowNodePayload | None:
return nodes[index] if index < len(nodes) else None
def parameter_float(
node: ReactFlowNodePayload | None,
name: str,
default: float,
) -> float:
if node is None:
return default
value = node.data.parameters.get(name, default)
try:
return float(value)
except (TypeError, ValueError):
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.")
def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type):
return pipe_config_type(
length=parameter_float(node, "length", 5.0),
diameter=parameter_float(node, "diameter", 0.02),
lambda_darcy=parameter_float(node, "lambda_darcy", 0.02),
p0=parameter_float(node, "p0", 1e5),
T0=parameter_float(node, "T0", 300.0),
)