darabos commited on
Commit
6fa3690
·
unverified ·
2 Parent(s): 795bd1b df0928d

Merge pull request #32 from biggraph/darabos-crdt-updated

Browse files
requirements.txt CHANGED
@@ -6,6 +6,8 @@ orjson
6
  pandas
7
  scipy
8
  uvicorn[standard]
 
 
9
  # For llm_ops
10
  chromadb
11
  Jinja2
 
6
  pandas
7
  scipy
8
  uvicorn[standard]
9
+ pycrdt
10
+ pycrdt-websocket
11
  # For llm_ops
12
  chromadb
13
  Jinja2
server/crdt.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ '''CRDT is used to synchronize workspace state for backend and frontend(s).'''
2
+ import asyncio
3
+ import contextlib
4
+ import fastapi
5
+ import os.path
6
+ import pycrdt
7
+ import pycrdt_websocket
8
+
9
+ import pycrdt_websocket.ystore
10
+
11
+ router = fastapi.APIRouter()
12
+
13
+ def ws_exception_handler(exception, log):
14
+ print('exception', exception)
15
+ log.exception(exception)
16
+ return True
17
+
18
+ class WebsocketServer(pycrdt_websocket.WebsocketServer):
19
+ async def init_room(self, name):
20
+ ystore = pycrdt_websocket.ystore.FileYStore(f'crdt_data/{name}.crdt')
21
+ ydoc = pycrdt.Doc()
22
+ ydoc['workspace'] = ws = pycrdt.Map()
23
+ # Replay updates from the store.
24
+ try:
25
+ for update, timestamp in [(item[0], item[-1]) async for item in ystore.read()]:
26
+ ydoc.apply_update(update)
27
+ except pycrdt_websocket.ystore.YDocNotFound:
28
+ pass
29
+ if 'nodes' not in ws:
30
+ ws['nodes'] = pycrdt.Array()
31
+ if 'edges' not in ws:
32
+ ws['edges'] = pycrdt.Array()
33
+ if 'env' not in ws:
34
+ ws['env'] = 'unset'
35
+ try_to_load_workspace(ws, name)
36
+ room = pycrdt_websocket.YRoom(ystore=ystore, ydoc=ydoc)
37
+ room.ws = ws
38
+ def on_change(changes):
39
+ asyncio.create_task(workspace_changed(changes, ws))
40
+ ws.observe_deep(on_change)
41
+ return room
42
+
43
+ async def get_room(self, name: str) -> pycrdt_websocket.YRoom:
44
+ if name not in self.rooms:
45
+ self.rooms[name] = await self.init_room(name)
46
+ room = self.rooms[name]
47
+ await self.start_room(room)
48
+ return room
49
+
50
+ websocket_server = WebsocketServer(exception_handler=ws_exception_handler, auto_clean_rooms=False)
51
+ asgi_server = pycrdt_websocket.ASGIServer(websocket_server)
52
+
53
+ last_ws_input = None
54
+ def clean_input(ws_pyd):
55
+ for node in ws_pyd.nodes:
56
+ node.data.display = None
57
+ node.position.x = 0
58
+ node.position.y = 0
59
+ if node.model_extra:
60
+ for key in list(node.model_extra.keys()):
61
+ delattr(node, key)
62
+
63
+ def crdt_update(crdt_obj, python_obj, boxes=set()):
64
+ if isinstance(python_obj, dict):
65
+ for key, value in python_obj.items():
66
+ if key in boxes:
67
+ crdt_obj[key] = value
68
+ elif isinstance(value, dict):
69
+ if crdt_obj.get(key) is None:
70
+ crdt_obj[key] = pycrdt.Map()
71
+ crdt_update(crdt_obj[key], value, boxes)
72
+ elif isinstance(value, list):
73
+ if crdt_obj.get(key) is None:
74
+ crdt_obj[key] = pycrdt.Array()
75
+ crdt_update(crdt_obj[key], value, boxes)
76
+ else:
77
+ crdt_obj[key] = value
78
+ elif isinstance(python_obj, list):
79
+ for i, value in enumerate(python_obj):
80
+ if isinstance(value, dict):
81
+ if i >= len(crdt_obj):
82
+ crdt_obj.append(pycrdt.Map())
83
+ crdt_update(crdt_obj[i], value, boxes)
84
+ elif isinstance(value, list):
85
+ if i >= len(crdt_obj):
86
+ crdt_obj.append(pycrdt.Array())
87
+ crdt_update(crdt_obj[i], value, boxes)
88
+ else:
89
+ if i >= len(crdt_obj):
90
+ crdt_obj.append(value)
91
+ else:
92
+ crdt_obj[i] = value
93
+ else:
94
+ raise ValueError('Invalid type:', python_obj)
95
+
96
+
97
+ def try_to_load_workspace(ws, name):
98
+ from . import workspace
99
+ json_path = f'data/{name}'
100
+ if os.path.exists(json_path):
101
+ ws_pyd = workspace.load(json_path)
102
+ crdt_update(ws, ws_pyd.model_dump(), boxes={'display'})
103
+
104
+ async def workspace_changed(e, ws_crdt):
105
+ global last_ws_input
106
+ from . import workspace
107
+ ws_pyd = workspace.Workspace.model_validate(ws_crdt.to_py())
108
+ clean_input(ws_pyd)
109
+ if ws_pyd == last_ws_input:
110
+ return
111
+ last_ws_input = ws_pyd.model_copy(deep=True)
112
+ await workspace.execute(ws_pyd)
113
+ for nc, np in zip(ws_crdt['nodes'], ws_pyd.nodes):
114
+ if 'data' not in nc:
115
+ nc['data'] = pycrdt.Map()
116
+ # Display is added as an opaque Box.
117
+ nc['data']['display'] = np.data.display
118
+ nc['data']['error'] = np.data.error
119
+
120
+ @contextlib.asynccontextmanager
121
+ async def lifespan(app):
122
+ async with websocket_server:
123
+ yield
124
+
125
+ def sanitize_path(path):
126
+ return os.path.relpath(os.path.normpath(os.path.join("/", path)), "/")
127
+
128
+ @router.websocket("/ws/crdt/{room_name}")
129
+ async def crdt_websocket(websocket: fastapi.WebSocket, room_name: str):
130
+ room_name = sanitize_path(room_name)
131
+ await asgi_server({'path': room_name}, websocket._receive, websocket._send)
server/executors/one_by_one.py CHANGED
@@ -74,7 +74,12 @@ def make_cache_key(obj):
74
 
75
  EXECUTOR_OUTPUT_CACHE = {}
76
 
77
- def execute(ws, catalog, cache=None):
 
 
 
 
 
78
  nodes = {n.id: n for n in ws.nodes}
79
  contexts = {n.id: Context(node=n) for n in ws.nodes}
80
  edges = {n.id: [] for n in ws.nodes}
@@ -113,10 +118,10 @@ def execute(ws, catalog, cache=None):
113
  if cache is not None:
114
  key = make_cache_key((inputs, params))
115
  if key not in cache:
116
- cache[key] = op(*inputs, **params)
117
  result = cache[key]
118
  else:
119
- result = op(*inputs, **params)
120
  except Exception as e:
121
  traceback.print_exc()
122
  data.error = str(e)
 
74
 
75
  EXECUTOR_OUTPUT_CACHE = {}
76
 
77
+ async def await_if_needed(obj):
78
+ if inspect.isawaitable(obj):
79
+ return await obj
80
+ return obj
81
+
82
+ async def execute(ws, catalog, cache=None):
83
  nodes = {n.id: n for n in ws.nodes}
84
  contexts = {n.id: Context(node=n) for n in ws.nodes}
85
  edges = {n.id: [] for n in ws.nodes}
 
118
  if cache is not None:
119
  key = make_cache_key((inputs, params))
120
  if key not in cache:
121
+ cache[key] = await await_if_needed(op(*inputs, **params))
122
  result = cache[key]
123
  else:
124
+ result = await await_if_needed(op(*inputs, **params))
125
  except Exception as e:
126
  traceback.print_exc()
127
  data.error = str(e)
server/lynxkite_ops.py CHANGED
@@ -83,7 +83,7 @@ def disambiguate_edges(ws):
83
 
84
 
85
  @ops.register_executor('LynxKite')
86
- def execute(ws):
87
  catalog = ops.CATALOGS['LynxKite']
88
  # Nodes are responsible for interpreting/executing their child nodes.
89
  nodes = [n for n in ws.nodes if not n.parentId]
@@ -105,11 +105,6 @@ def execute(ws):
105
  data = node.data
106
  op = catalog[data.title]
107
  params = {**data.params}
108
- if op.sub_nodes:
109
- sub_nodes = children.get(node.id, [])
110
- sub_node_ids = [node.id for node in sub_nodes]
111
- sub_edges = [edge for edge in ws.edges if edge.source in sub_node_ids]
112
- params['sub_flow'] = {'nodes': sub_nodes, 'edges': sub_edges}
113
  # Convert inputs.
114
  for i, (x, p) in enumerate(zip(inputs, op.inputs.values())):
115
  if p.type == nx.Graph and isinstance(x, Bundle):
 
83
 
84
 
85
  @ops.register_executor('LynxKite')
86
+ async def execute(ws):
87
  catalog = ops.CATALOGS['LynxKite']
88
  # Nodes are responsible for interpreting/executing their child nodes.
89
  nodes = [n for n in ws.nodes if not n.parentId]
 
105
  data = node.data
106
  op = catalog[data.title]
107
  params = {**data.params}
 
 
 
 
 
108
  # Convert inputs.
109
  for i, (x, p) in enumerate(zip(inputs, op.inputs.values())):
110
  if p.type == nx.Graph and isinstance(x, Bundle):
server/main.py CHANGED
@@ -3,6 +3,7 @@ import fastapi
3
  import importlib
4
  import pathlib
5
  import pkgutil
 
6
  from . import ops
7
  from . import workspace
8
 
@@ -14,8 +15,8 @@ for _, name, _ in pkgutil.iter_modules([str(here)]):
14
  name = f"server.{name}"
15
  lynxkite_modules[name] = importlib.import_module(name)
16
 
17
- app = fastapi.FastAPI()
18
-
19
 
20
 
21
  @app.get("/api/catalog")
@@ -38,9 +39,9 @@ def save(req: SaveRequest):
38
 
39
 
40
  @app.post("/api/save")
41
- def save_and_execute(req: SaveRequest):
42
  save(req)
43
- workspace.execute(req.ws)
44
  save(req)
45
  return req.ws
46
 
 
3
  import importlib
4
  import pathlib
5
  import pkgutil
6
+ from . import crdt
7
  from . import ops
8
  from . import workspace
9
 
 
15
  name = f"server.{name}"
16
  lynxkite_modules[name] = importlib.import_module(name)
17
 
18
+ app = fastapi.FastAPI(lifespan=crdt.lifespan)
19
+ app.include_router(crdt.router)
20
 
21
 
22
  @app.get("/api/catalog")
 
39
 
40
 
41
  @app.post("/api/save")
42
+ async def save_and_execute(req: SaveRequest):
43
  save(req)
44
+ await workspace.execute(req.ws)
45
  save(req)
46
  return req.ws
47
 
server/workspace.py CHANGED
@@ -43,9 +43,9 @@ class Workspace(BaseConfig):
43
  edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
44
 
45
 
46
- def execute(ws: Workspace):
47
  if ws.env in ops.EXECUTORS:
48
- ops.EXECUTORS[ws.env](ws)
49
 
50
 
51
  def save(ws: Workspace, path: str):
 
43
  edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
44
 
45
 
46
+ async def execute(ws: Workspace):
47
  if ws.env in ops.EXECUTORS:
48
+ await ops.EXECUTORS[ws.env](ws)
49
 
50
 
51
  def save(ws: Workspace, path: str):
web/package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
web/package.json CHANGED
@@ -10,27 +10,28 @@
10
  "check": "svelte-check --tsconfig ./tsconfig.json"
11
  },
12
  "devDependencies": {
13
- "@sveltejs/vite-plugin-svelte": "^3.0.2",
14
- "@tsconfig/svelte": "^5.0.4",
15
- "sass": "^1.77.2",
16
- "svelte": "^4.2.12",
17
- "svelte-check": "^3.6.9",
 
 
18
  "svelte-markdown": "^0.4.1",
19
- "tslib": "^2.6.2",
20
- "typescript": "^5.4.4",
21
- "unplugin-icons": "^0.18.5",
22
- "vite": "^5.2.8"
 
23
  },
24
  "dependencies": {
25
- "@iconify-json/tabler": "^1.1.110",
26
- "@popperjs/core": "^2.11.8",
27
- "@sveltestack/svelte-query": "^1.6.0",
28
- "@xyflow/svelte": "^0.1.3",
29
- "bootstrap": "^5.3.3",
30
- "deep-object-diff": "^1.1.9",
31
- "echarts": "^5.5.0",
32
- "fuse.js": "^7.0.0",
33
- "svelte-echarts": "^1.0.0-rc1",
34
- "tabulator-tables": "^6.2.1"
35
  }
36
  }
 
10
  "check": "svelte-check --tsconfig ./tsconfig.json"
11
  },
12
  "devDependencies": {
13
+ "@sveltejs/vite-plugin-svelte": "3.1.2",
14
+ "@syncedstore/core": "0.6.0",
15
+ "@syncedstore/svelte": "0.6.0",
16
+ "@tsconfig/svelte": "5.0.4",
17
+ "sass": "1.79.5",
18
+ "svelte": "4.2.19",
19
+ "svelte-check": "4.0.5",
20
  "svelte-markdown": "^0.4.1",
21
+ "tslib": "2.7.0",
22
+ "typescript": "5.6.3",
23
+ "unplugin-icons": "0.19.3",
24
+ "vite": "5.4.9",
25
+ "y-websocket": "2.0.4"
26
  },
27
  "dependencies": {
28
+ "@iconify-json/tabler": "1.2.5",
29
+ "@popperjs/core": "2.11.8",
30
+ "@sveltestack/svelte-query": "1.6.0",
31
+ "@xyflow/svelte": "0.1.21",
32
+ "bootstrap": "5.3.3",
33
+ "echarts": "5.5.1",
34
+ "fuse.js": "7.0.0",
35
+ "svelte-echarts": "^1.0.0-rc3"
 
 
36
  }
37
  }
web/src/LynxKiteFlow.svelte CHANGED
@@ -1,12 +1,13 @@
1
  <script lang="ts">
2
- import { diff } from 'deep-object-diff';
3
- import { writable, derived } from 'svelte/store';
4
  import {
5
  SvelteFlow,
6
  Controls,
7
  MiniMap,
8
  MarkerType,
9
  useSvelteFlow,
 
10
  type XYPosition,
11
  type Node,
12
  type Edge,
@@ -16,7 +17,7 @@
16
  import ArrowBack from 'virtual:icons/tabler/arrow-back'
17
  import Backspace from 'virtual:icons/tabler/backspace'
18
  import Atom from 'virtual:icons/tabler/Atom'
19
- import { useQuery, useMutation, useQueryClient } from '@sveltestack/svelte-query';
20
  import NodeWithParams from './NodeWithParams.svelte';
21
  import NodeWithVisualization from './NodeWithVisualization.svelte';
22
  import NodeWithImage from './NodeWithImage.svelte';
@@ -26,27 +27,30 @@
26
  import NodeSearch from './NodeSearch.svelte';
27
  import EnvironmentSelector from './EnvironmentSelector.svelte';
28
  import '@xyflow/svelte/dist/style.css';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  export let path = '';
31
 
32
  const { screenToFlowPosition } = useSvelteFlow();
33
- const queryClient = useQueryClient();
34
- const backendWorkspace = useQuery(['workspace', path], async () => {
35
- const res = await fetch(`/api/load?path=${path}`);
36
- return res.json();
37
- }, {staleTime: 10000, retry: false});
38
- const mutation = useMutation(async(update) => {
39
- const res = await fetch('/api/save', {
40
- method: 'POST',
41
- headers: {
42
- 'Content-Type': 'application/json',
43
- },
44
- body: JSON.stringify(update),
45
- });
46
- return await res.json();
47
- }, {
48
- onSuccess: data => queryClient.setQueryData(['workspace', path], data),
49
- });
50
 
51
  const nodeTypes: NodeTypes = {
52
  basic: NodeWithParams,
@@ -59,13 +63,6 @@
59
 
60
  const nodes = writable<Node[]>([]);
61
  const edges = writable<Edge[]>([]);
62
- let doNotSave = true;
63
- $: if ($backendWorkspace.isSuccess) {
64
- doNotSave = true; // Change is coming from the backend.
65
- nodes.set(JSON.parse(JSON.stringify($backendWorkspace.data?.nodes || [])));
66
- edges.set(JSON.parse(JSON.stringify($backendWorkspace.data?.edges || [])));
67
- doNotSave = false;
68
- }
69
 
70
  function closeNodeSearch() {
71
  nodeSearchSettings = undefined;
@@ -78,37 +75,36 @@
78
  event.preventDefault();
79
  nodeSearchSettings = {
80
  pos: { x: event.clientX, y: event.clientY },
81
- boxes: $catalog.data[$backendWorkspace.data?.env],
82
  };
83
  }
84
  function addNode(e) {
85
  const meta = {...e.detail};
86
- nodes.update((n) => {
87
- const node = {
88
- type: meta.type,
89
- data: {
90
- meta: meta,
91
- title: meta.name,
92
- params: Object.fromEntries(
93
- Object.values(meta.params).map((p) => [p.name, p.default])),
94
- },
95
- };
96
- node.position = screenToFlowPosition({x: nodeSearchSettings.pos.x, y: nodeSearchSettings.pos.y});
97
- const title = node.data.title;
98
- let i = 1;
 
 
 
99
  node.id = `${title} ${i}`;
100
- while (n.find((x) => x.id === node.id)) {
101
- i += 1;
102
- node.id = `${title} ${i}`;
103
- }
104
- node.parentId = nodeSearchSettings.parentId;
105
- if (node.parentId) {
106
- node.extent = 'parent';
107
- const parent = n.find((x) => x.id === node.parentId);
108
- node.position = { x: node.position.x - parent.position.x, y: node.position.y - parent.position.y };
109
- }
110
- return [...n, node]
111
- });
112
  closeNodeSearch();
113
  }
114
  const catalog = useQuery(['catalog'], async () => {
@@ -122,46 +118,6 @@
122
  parentId: string,
123
  };
124
 
125
- const graph = derived([nodes, edges], ([nodes, edges]) => ({ nodes, edges }));
126
- // Like JSON.stringify, but with keys sorted.
127
- function orderedJSON(obj: any) {
128
- const allKeys = new Set();
129
- JSON.stringify(obj, (key, value) => (allKeys.add(key), value));
130
- return JSON.stringify(obj, Array.from(allKeys).sort());
131
- }
132
- graph.subscribe(async (g) => {
133
- if (doNotSave) return;
134
- const dragging = g.nodes.find((n) => n.dragging);
135
- if (dragging) return;
136
- const resizing = g.nodes.find((n) => n.data?.beingResized);
137
- if (resizing) return;
138
- scheduleSave(g);
139
- });
140
- let saveTimeout;
141
- function scheduleSave(g) {
142
- // A slight delay, so we don't send a million requests when a node is resized, for example.
143
- clearTimeout(saveTimeout);
144
- saveTimeout = setTimeout(() => save(g), 500);
145
- }
146
- function save(g) {
147
- g = JSON.parse(JSON.stringify(g));
148
- for (const node of g.nodes) {
149
- delete node.measured;
150
- delete node.selected;
151
- delete node.dragging;
152
- delete node.beingResized;
153
- }
154
- for (const node of g.edges) {
155
- delete node.markerEnd;
156
- delete node.selected;
157
- }
158
- g.env = $backendWorkspace.data?.env;
159
- const ws = orderedJSON(g);
160
- const bd = orderedJSON($backendWorkspace.data);
161
- if (ws === bd) return;
162
- console.log('changed', JSON.stringify(diff(g, $backendWorkspace.data), null, 2));
163
- $mutation.mutate({ path, ws: g });
164
- }
165
  function nodeClick(e) {
166
  const node = e.detail.node;
167
  const meta = node.data.meta;
@@ -176,11 +132,32 @@
176
  parentId: node.id,
177
  };
178
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  $: parentDir = path.split('/').slice(0, -1).join('/');
180
-
181
  </script>
182
 
183
  <div class="page">
 
184
  <div class="top-bar">
185
  <div class="ws-name">
186
  <a href><img src="/favicon.ico"></a>
@@ -189,8 +166,10 @@
189
  <div class="tools">
190
  <EnvironmentSelector
191
  options={Object.keys($catalog.data || {})}
192
- value={$backendWorkspace.data?.env}
193
- onChange={(env) => $mutation.mutate({ path, ws: { ...$backendWorkspace.data, env } })}
 
 
194
  />
195
  <a href><Atom /></a>
196
  <a href><Backspace /></a>
@@ -201,6 +180,8 @@
201
  <SvelteFlow {nodes} {edges} {nodeTypes} fitView
202
  on:paneclick={toggleNodeSearch}
203
  on:nodeclick={nodeClick}
 
 
204
  proOptions={{ hideAttribution: true }}
205
  maxZoom={3}
206
  minZoom={0.3}
@@ -213,6 +194,7 @@
213
  {/if}
214
  </SvelteFlow>
215
  </div>
 
216
  </div>
217
 
218
  <style>
 
1
  <script lang="ts">
2
+ import { setContext } from 'svelte';
3
+ import { writable } from 'svelte/store';
4
  import {
5
  SvelteFlow,
6
  Controls,
7
  MiniMap,
8
  MarkerType,
9
  useSvelteFlow,
10
+ useUpdateNodeInternals,
11
  type XYPosition,
12
  type Node,
13
  type Edge,
 
17
  import ArrowBack from 'virtual:icons/tabler/arrow-back'
18
  import Backspace from 'virtual:icons/tabler/backspace'
19
  import Atom from 'virtual:icons/tabler/Atom'
20
+ import { useQuery } from '@sveltestack/svelte-query';
21
  import NodeWithParams from './NodeWithParams.svelte';
22
  import NodeWithVisualization from './NodeWithVisualization.svelte';
23
  import NodeWithImage from './NodeWithImage.svelte';
 
27
  import NodeSearch from './NodeSearch.svelte';
28
  import EnvironmentSelector from './EnvironmentSelector.svelte';
29
  import '@xyflow/svelte/dist/style.css';
30
+ import { syncedStore, getYjsDoc } from "@syncedstore/core";
31
+ import { svelteSyncedStore } from "@syncedstore/svelte";
32
+ import { WebsocketProvider } from "y-websocket";
33
+ const updateNodeInternals = useUpdateNodeInternals();
34
+
35
+ function getCRDTStore(path) {
36
+ const sstore = syncedStore({ workspace: {} });
37
+ const doc = getYjsDoc(sstore);
38
+ const wsProvider = new WebsocketProvider("ws://localhost:8000/ws/crdt", path, doc);
39
+ return {store: svelteSyncedStore(sstore), sstore, doc};
40
+ }
41
+ $: connection = getCRDTStore(path);
42
+ $: store = connection.store;
43
+ $: store.subscribe((value) => {
44
+ if (!value?.workspace?.edges) return;
45
+ $nodes = [...value.workspace.nodes];
46
+ $edges = [...value.workspace.edges];
47
+ updateNodeInternals();
48
+ });
49
+ $: setContext('LynxKite store', store);
50
 
51
  export let path = '';
52
 
53
  const { screenToFlowPosition } = useSvelteFlow();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  const nodeTypes: NodeTypes = {
56
  basic: NodeWithParams,
 
63
 
64
  const nodes = writable<Node[]>([]);
65
  const edges = writable<Edge[]>([]);
 
 
 
 
 
 
 
66
 
67
  function closeNodeSearch() {
68
  nodeSearchSettings = undefined;
 
75
  event.preventDefault();
76
  nodeSearchSettings = {
77
  pos: { x: event.clientX, y: event.clientY },
78
+ boxes: $catalog.data[$store.workspace.env],
79
  };
80
  }
81
  function addNode(e) {
82
  const meta = {...e.detail};
83
+ const node = {
84
+ type: meta.type,
85
+ data: {
86
+ meta: meta,
87
+ title: meta.name,
88
+ params: Object.fromEntries(
89
+ Object.values(meta.params).map((p) => [p.name, p.default])),
90
+ },
91
+ };
92
+ node.position = screenToFlowPosition({x: nodeSearchSettings.pos.x, y: nodeSearchSettings.pos.y});
93
+ const title = node.data.title;
94
+ let i = 1;
95
+ node.id = `${title} ${i}`;
96
+ const nodes = $store.workspace.nodes;
97
+ while (nodes.find((x) => x.id === node.id)) {
98
+ i += 1;
99
  node.id = `${title} ${i}`;
100
+ }
101
+ node.parentId = nodeSearchSettings.parentId;
102
+ if (node.parentId) {
103
+ node.extent = 'parent';
104
+ const parent = nodes.find((x) => x.id === node.parentId);
105
+ node.position = { x: node.position.x - parent.position.x, y: node.position.y - parent.position.y };
106
+ }
107
+ nodes.push(node);
 
 
 
 
108
  closeNodeSearch();
109
  }
110
  const catalog = useQuery(['catalog'], async () => {
 
118
  parentId: string,
119
  };
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  function nodeClick(e) {
122
  const node = e.detail.node;
123
  const meta = node.data.meta;
 
132
  parentId: node.id,
133
  };
134
  }
135
+ function onConnect(params: Connection) {
136
+ const edge = {
137
+ id: `${params.source} ${params.target}`,
138
+ source: params.source,
139
+ sourceHandle: params.sourceHandle,
140
+ target: params.target,
141
+ targetHandle: params.targetHandle,
142
+ };
143
+ $store.workspace.edges.push(edge);
144
+ }
145
+ function onDelete(params) {
146
+ const { nodes, edges } = params;
147
+ for (const node of nodes) {
148
+ const index = $store.workspace.nodes.findIndex((x) => x.id === node.id);
149
+ if (index !== -1) $store.workspace.nodes.splice(index, 1);
150
+ }
151
+ for (const edge of edges) {
152
+ const index = $store.workspace.edges.findIndex((x) => x.id === edge.id);
153
+ if (index !== -1) $store.workspace.edges.splice(index, 1);
154
+ }
155
+ }
156
  $: parentDir = path.split('/').slice(0, -1).join('/');
 
157
  </script>
158
 
159
  <div class="page">
160
+ {#if $store.workspace !== undefined}
161
  <div class="top-bar">
162
  <div class="ws-name">
163
  <a href><img src="/favicon.ico"></a>
 
166
  <div class="tools">
167
  <EnvironmentSelector
168
  options={Object.keys($catalog.data || {})}
169
+ value={$store.workspace.env}
170
+ onChange={(env) => {
171
+ $store.workspace.env = env;
172
+ }}
173
  />
174
  <a href><Atom /></a>
175
  <a href><Backspace /></a>
 
180
  <SvelteFlow {nodes} {edges} {nodeTypes} fitView
181
  on:paneclick={toggleNodeSearch}
182
  on:nodeclick={nodeClick}
183
+ onconnect={onConnect}
184
+ ondelete={onDelete}
185
  proOptions={{ hideAttribution: true }}
186
  maxZoom={3}
187
  minZoom={0.3}
 
194
  {/if}
195
  </SvelteFlow>
196
  </div>
197
+ {/if}
198
  </div>
199
 
200
  <style>
web/src/LynxKiteNode.svelte CHANGED
@@ -1,4 +1,5 @@
1
  <script lang="ts">
 
2
  import { Handle, useSvelteFlow, useUpdateNodeInternals, type NodeProps, NodeResizeControl } from '@xyflow/svelte';
3
  import ChevronDownRight from 'virtual:icons/tabler/chevron-down-right';
4
 
@@ -10,6 +11,10 @@
10
  export let containerStyle = '';
11
  export let id: $$Props['id']; id;
12
  export let data: $$Props['data'];
 
 
 
 
13
  export let dragHandle: $$Props['dragHandle'] = undefined; dragHandle;
14
  export let type: $$Props['type'] = undefined; type;
15
  export let selected: $$Props['selected'] = undefined; selected;
@@ -24,11 +29,14 @@
24
  export let positionAbsoluteY: $$Props['positionAbsoluteY'] = undefined; positionAbsoluteY;
25
  export let onToggle = () => {};
26
 
 
27
  $: expanded = !data.collapsed;
28
  function titleClicked() {
29
- updateNodeData(id, { collapsed: expanded });
30
- data = data;
31
  onToggle({ expanded });
 
 
32
  updateNodeInternals();
33
  }
34
  function asPx(n: number | undefined) {
 
1
  <script lang="ts">
2
+ import { getContext } from 'svelte';
3
  import { Handle, useSvelteFlow, useUpdateNodeInternals, type NodeProps, NodeResizeControl } from '@xyflow/svelte';
4
  import ChevronDownRight from 'virtual:icons/tabler/chevron-down-right';
5
 
 
11
  export let containerStyle = '';
12
  export let id: $$Props['id']; id;
13
  export let data: $$Props['data'];
14
+ export let deletable: $$Props['deletable'] = undefined; deletable;
15
+ export let draggable: $$Props['draggable'] = undefined; draggable;
16
+ export let parentId: $$Props['parentId'] = undefined; parentId;
17
+ export let selectable: $$Props['selectable'] = undefined; selectable;
18
  export let dragHandle: $$Props['dragHandle'] = undefined; dragHandle;
19
  export let type: $$Props['type'] = undefined; type;
20
  export let selected: $$Props['selected'] = undefined; selected;
 
29
  export let positionAbsoluteY: $$Props['positionAbsoluteY'] = undefined; positionAbsoluteY;
30
  export let onToggle = () => {};
31
 
32
+ $: store = getContext('LynxKite store');
33
  $: expanded = !data.collapsed;
34
  function titleClicked() {
35
+ const i = $store.workspace.nodes.findIndex((n) => n.id === id);
36
+ $store.workspace.nodes[i].data.collapsed = expanded;
37
  onToggle({ expanded });
38
+ // Trigger update.
39
+ data = data;
40
  updateNodeInternals();
41
  }
42
  function asPx(n: number | undefined) {
web/src/NodeWithParams.svelte CHANGED
@@ -1,21 +1,29 @@
1
  <script lang="ts">
2
- import { type NodeProps, useSvelteFlow } from '@xyflow/svelte';
 
3
  import LynxKiteNode from './LynxKiteNode.svelte';
4
  import NodeParameter from './NodeParameter.svelte';
5
  type $$Props = NodeProps;
6
  export let id: $$Props['id'];
7
  export let data: $$Props['data'];
8
- const { updateNodeData } = useSvelteFlow();
9
  $: metaParams = data.meta?.params;
 
 
 
 
 
 
 
 
10
  </script>
11
 
12
- <LynxKiteNode {...$$props}>
13
- {#each Object.entries(data.params) as [name, value]}
14
  <NodeParameter
15
  {name}
16
  {value}
17
  meta={metaParams?.[name]}
18
- onChange={(newValue) => updateNodeData(id, { params: { ...data.params, [name]: newValue } })}
19
  />
20
  {/each}
21
  <slot />
 
1
  <script lang="ts">
2
+ import { getContext } from 'svelte';
3
+ import { type NodeProps, useNodes } from '@xyflow/svelte';
4
  import LynxKiteNode from './LynxKiteNode.svelte';
5
  import NodeParameter from './NodeParameter.svelte';
6
  type $$Props = NodeProps;
7
  export let id: $$Props['id'];
8
  export let data: $$Props['data'];
 
9
  $: metaParams = data.meta?.params;
10
+ $: store = getContext('LynxKite store');
11
+ function setParam(name, newValue) {
12
+ const i = $store.workspace.nodes.findIndex((n) => n.id === id);
13
+ $store.workspace.nodes[i].data.params[name] = newValue;
14
+ }
15
+ $: params = $nodes && data?.params ? Object.entries(data.params) : [];
16
+ const nodes = useNodes(); // We don't properly get updates to "data". This is a hack.
17
+ $: props = $nodes && $$props;
18
  </script>
19
 
20
+ <LynxKiteNode {...props}>
21
+ {#each params as [name, value]}
22
  <NodeParameter
23
  {name}
24
  {value}
25
  meta={metaParams?.[name]}
26
+ onChange={(value) => setParam(name, value)}
27
  />
28
  {/each}
29
  <slot />
web/src/NodeWithTableView.svelte CHANGED
@@ -1,12 +1,14 @@
1
  <script lang="ts">
2
- import { type NodeProps } from '@xyflow/svelte';
3
  import LynxKiteNode from './LynxKiteNode.svelte';
4
  import Table from './Table.svelte';
5
  import SvelteMarkdown from 'svelte-markdown'
6
  type $$Props = NodeProps;
7
  export let data: $$Props['data'];
 
 
8
  const open = {};
9
- $: single = data.display?.dataframes && Object.keys(data.display.dataframes).length === 1;
10
  function toMD(v) {
11
  if (typeof v === 'string') {
12
  return v;
@@ -19,8 +21,8 @@
19
  </script>
20
 
21
  <LynxKiteNode {...$$props}>
22
- {#if data.display}
23
- {#each Object.entries(data.display.dataframes || {}) as [name, df]}
24
  {#if !single}<div class="df-head" on:click={() => open[name] = !open[name]}>{name}</div>{/if}
25
  {#if single || open[name]}
26
  {#if df.data.length > 1}
@@ -35,7 +37,7 @@
35
  {/if}
36
  {/if}
37
  {/each}
38
- {#each Object.entries(data.display.others || {}) as [name, o]}
39
  <div class="df-head" on:click={() => open[name] = !open[name]}>{name}</div>
40
  {#if open[name]}
41
  <pre>{o}</pre>
 
1
  <script lang="ts">
2
+ import { useNodes, type NodeProps } from '@xyflow/svelte';
3
  import LynxKiteNode from './LynxKiteNode.svelte';
4
  import Table from './Table.svelte';
5
  import SvelteMarkdown from 'svelte-markdown'
6
  type $$Props = NodeProps;
7
  export let data: $$Props['data'];
8
+ const nodes = useNodes(); // We don't properly get updates to "data". This is a hack.
9
+ $: D = $nodes && data;
10
  const open = {};
11
+ $: single = D.display?.value?.dataframes && Object.keys(D.display.value.dataframes).length === 1;
12
  function toMD(v) {
13
  if (typeof v === 'string') {
14
  return v;
 
21
  </script>
22
 
23
  <LynxKiteNode {...$$props}>
24
+ {#if D?.display?.value}
25
+ {#each Object.entries(D.display.value.dataframes || {}) as [name, df]}
26
  {#if !single}<div class="df-head" on:click={() => open[name] = !open[name]}>{name}</div>{/if}
27
  {#if single || open[name]}
28
  {#if df.data.length > 1}
 
37
  {/if}
38
  {/if}
39
  {/each}
40
+ {#each Object.entries(D.display.value.others || {}) as [name, o]}
41
  <div class="df-head" on:click={() => open[name] = !open[name]}>{name}</div>
42
  {#if open[name]}
43
  <pre>{o}</pre>
web/src/NodeWithVisualization.svelte CHANGED
@@ -1,16 +1,17 @@
1
  <script lang="ts">
2
- import { type NodeProps } from '@xyflow/svelte';
3
  import NodeWithParams from './NodeWithParams.svelte';
4
  import { Chart } from 'svelte-echarts';
5
  import { init } from 'echarts';
6
  type $$Props = NodeProps;
7
  export let data: $$Props['data'];
 
 
 
8
  </script>
9
 
10
  <NodeWithParams {...$$props}>
11
- {#if data.display}
12
- <Chart {init} options={data.display} initOptions={{renderer: 'canvas', width: 250, height: 250}}/>
13
- {/if}
14
  </NodeWithParams>
15
  <style>
16
  </style>
 
1
  <script lang="ts">
2
+ import { useNodes, type NodeProps } from '@xyflow/svelte';
3
  import NodeWithParams from './NodeWithParams.svelte';
4
  import { Chart } from 'svelte-echarts';
5
  import { init } from 'echarts';
6
  type $$Props = NodeProps;
7
  export let data: $$Props['data'];
8
+
9
+ const nodes = useNodes(); // We don't properly get updates to "data". This is a hack.
10
+ $: D = $nodes && data;
11
  </script>
12
 
13
  <NodeWithParams {...$$props}>
14
+ <Chart {init} options={D?.display?.value || {}} initOptions={{renderer: 'canvas', width: 250, height: 250}}/>
 
 
15
  </NodeWithParams>
16
  <style>
17
  </style>
web/src/Table.svelte CHANGED
@@ -1,38 +1,22 @@
1
  <script lang="ts">
2
- import {TabulatorFull as Tabulator} from 'tabulator-tables';
3
- import {onMount} from 'svelte';
4
-
5
  export let data, columns;
6
-
7
- let tableComponent;
8
- let tab;
9
-
10
- // The rows in the data are arrays, but Tabulator expects objects.
11
- const objs = [];
12
- $: {
13
- objs.splice();
14
- for (const row of data) {
15
- const obj = {};
16
- for (let i = 0; i < columns.length; i++) {
17
- let d = row[i];
18
- if (typeof d !== 'string' && typeof d !== 'number') {
19
- d = JSON.stringify(d);
20
- }
21
- obj[columns[i]] = d;
22
- }
23
- objs.push(obj);
24
- }
25
- }
26
-
27
- onMount(() => {
28
- tab = new Tabulator(tableComponent, {
29
- data: objs,
30
- columns: columns.map(c => ({title: c, field: c, widthGrow: 1})),
31
- height: '311px',
32
- reactiveData: true,
33
- layout: "fitColumns",
34
- });
35
- });
36
  </script>
37
 
38
- <div bind:this={tableComponent}></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  <script lang="ts">
 
 
 
2
  export let data, columns;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  </script>
4
 
5
+ <table>
6
+ <thead>
7
+ <tr>
8
+ {#each columns as column}
9
+ <th>{column}</th>
10
+ {/each}
11
+ </tr>
12
+ </thead>
13
+ <tbody>
14
+ {#each data as row}
15
+ <tr>
16
+ {#each columns as column}
17
+ <td>{row[column]}</td>
18
+ {/each}
19
+ </tr>
20
+ {/each}
21
+ </tbody>
22
+ </table>
web/src/app.scss CHANGED
@@ -1,7 +1,6 @@
1
  // Import all of Bootstrap's CSS
2
  $form-select-indicator-color: oklch(90% 0.01 55);
3
  @import "bootstrap/scss/bootstrap";
4
- @import "tabulator-tables";
5
  :root {
6
  --bs-border-color: oklch(90% 0.01 55);
7
  }
 
1
  // Import all of Bootstrap's CSS
2
  $form-select-indicator-color: oklch(90% 0.01 55);
3
  @import "bootstrap/scss/bootstrap";
 
4
  :root {
5
  --bs-border-color: oklch(90% 0.01 55);
6
  }
web/vite.config.ts CHANGED
@@ -9,6 +9,13 @@ export default defineConfig({
9
  Icons({ compiler: 'svelte', defaultStyle: 'vertical-align: sub;' }),
10
  ],
11
  server: {
12
- proxy: { '/api': 'http://127.0.0.1:8000' },
 
 
 
 
 
 
 
13
  },
14
  })
 
9
  Icons({ compiler: 'svelte', defaultStyle: 'vertical-align: sub;' }),
10
  ],
11
  server: {
12
+ proxy: {
13
+ '/api': 'http://127.0.0.1:8000',
14
+ '/ws': {
15
+ target: 'ws://127.0.0.1:8000',
16
+ ws: true,
17
+ changeOrigin: true,
18
+ },
19
+ },
20
  },
21
  })