darabos commited on
Commit
9fbd9fa
·
unverified ·
2 Parent(s): 6fa3690 0832c91

Merge pull request #33 from biggraph/darabos-react

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +7 -6
  2. requirements.txt +1 -0
  3. server/crdt.py +91 -31
  4. server/executors/one_by_one.py +138 -114
  5. server/llm_ops.py +154 -125
  6. server/lynxkite_ops.py +177 -148
  7. server/ops.py +184 -149
  8. server/workspace.py +16 -17
  9. web/.gitignore +11 -0
  10. web/.vscode/extensions.json +0 -3
  11. web/README.md +4 -24
  12. web/eslint.config.js +28 -0
  13. web/index.html +4 -4
  14. web/package-lock.json +0 -0
  15. web/package.json +38 -27
  16. web/postcss.config.js +6 -0
  17. web/src/App.svelte +0 -22
  18. web/src/Directory.svelte +0 -177
  19. web/src/Directory.tsx +88 -0
  20. web/src/EnvironmentSelector.svelte +0 -14
  21. web/src/LynxKiteFlow.svelte +0 -230
  22. web/src/LynxKiteNode.svelte +0 -174
  23. web/src/NodeParameter.svelte +0 -69
  24. web/src/NodeSearch.svelte +0 -100
  25. web/src/NodeWithArea.svelte +0 -60
  26. web/src/NodeWithImage.svelte +0 -14
  27. web/src/NodeWithParams.svelte +0 -30
  28. web/src/NodeWithSubFlow.svelte +0 -37
  29. web/src/NodeWithTableView.svelte +0 -58
  30. web/src/NodeWithVisualization.svelte +0 -17
  31. web/src/Table.svelte +0 -22
  32. web/src/Workspace.svelte +0 -14
  33. web/src/apiTypes.ts +45 -0
  34. web/src/app.scss +0 -38
  35. web/{public → src/assets}/favicon.ico +0 -0
  36. web/src/directory.css +0 -10
  37. web/src/index.css +350 -0
  38. web/src/main.ts +0 -10
  39. web/src/main.tsx +19 -0
  40. web/src/vite-env.d.ts +0 -1
  41. web/src/workspace/EnvironmentSelector.tsx +15 -0
  42. web/src/workspace/LynxKiteState.ts +4 -0
  43. web/src/workspace/NodeSearch.tsx +75 -0
  44. web/src/workspace/Workspace.tsx +246 -0
  45. web/src/workspace/nodes/LynxKiteNode.tsx +89 -0
  46. web/src/workspace/nodes/NodeParameter.tsx +52 -0
  47. web/src/workspace/nodes/NodeWithImage.tsx +11 -0
  48. web/src/workspace/nodes/NodeWithParams.tsx +31 -0
  49. web/src/workspace/nodes/NodeWithTableView.tsx +46 -0
  50. web/src/workspace/nodes/NodeWithVisualization.tsx +28 -0
README.md CHANGED
@@ -1,11 +1,11 @@
1
  # LynxKite 2024
2
 
3
- This is an experimental rewrite of [LynxKite](https://github.com/lynxkite/lynxkite).
4
- It is not compatible with the original LynxKite. The primary goals of this rewrite are:
5
- - Target GPU clusters instead of Hadoop clusters.
6
- We use Python instead of Scala, RAPIDS instead of Apache Spark.
7
- - More extensible backend. Make it easy to add new LynxKite boxes.
8
- Make it easy to use our frontend for other purposes, configuring and executing other pipelines.
9
 
10
  Current status: **PROTOTYPE**
11
 
@@ -15,6 +15,7 @@ To run the backend:
15
 
16
  ```bash
17
  pip install -r requirements.txt
 
18
  uvicorn server.main:app --reload
19
  ```
20
 
 
1
  # LynxKite 2024
2
 
3
+ This is an experimental rewrite of [LynxKite](https://github.com/lynxkite/lynxkite). It is not compatible with the
4
+ original LynxKite. The primary goals of this rewrite are:
5
+
6
+ - Target GPU clusters instead of Hadoop clusters. We use Python instead of Scala, RAPIDS instead of Apache Spark.
7
+ - More extensible backend. Make it easy to add new LynxKite boxes. Make it easy to use our frontend for other purposes,
8
+ configuring and executing other pipelines.
9
 
10
  Current status: **PROTOTYPE**
11
 
 
15
 
16
  ```bash
17
  pip install -r requirements.txt
18
+ PYTHONPATH=. pydantic2ts --module server.workspace --output ./web/src/apiTypes.ts
19
  uvicorn server.main:app --reload
20
  ```
21
 
requirements.txt CHANGED
@@ -4,6 +4,7 @@ networkx
4
  numpy
5
  orjson
6
  pandas
 
7
  scipy
8
  uvicorn[standard]
9
  pycrdt
 
4
  numpy
5
  orjson
6
  pandas
7
+ pydantic-to-typescript
8
  scipy
9
  uvicorn[standard]
10
  pycrdt
server/crdt.py CHANGED
@@ -1,42 +1,57 @@
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
 
@@ -47,19 +62,21 @@ class WebsocketServer(pycrdt_websocket.WebsocketServer):
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():
@@ -73,6 +90,8 @@ def crdt_update(crdt_obj, python_obj, boxes=set()):
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):
@@ -91,41 +110,82 @@ def crdt_update(crdt_obj, python_obj, boxes=set()):
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)
 
 
1
+ """CRDT is used to synchronize workspace state for backend and frontend(s)."""
2
+
3
  import asyncio
4
  import contextlib
5
+ import enum
6
  import fastapi
7
  import os.path
8
  import pycrdt
9
  import pycrdt_websocket
 
10
  import pycrdt_websocket.ystore
11
+ import uvicorn
12
+ import builtins
13
 
14
  router = fastapi.APIRouter()
15
 
16
+
17
  def ws_exception_handler(exception, log):
18
+ if isinstance(exception, builtins.ExceptionGroup):
19
+ for ex in exception.exceptions:
20
+ if not isinstance(ex, uvicorn.protocols.utils.ClientDisconnected):
21
+ log.exception(ex)
22
+ else:
23
+ log.exception(exception)
24
  return True
25
 
26
+
27
  class WebsocketServer(pycrdt_websocket.WebsocketServer):
28
  async def init_room(self, name):
29
+ ystore = pycrdt_websocket.ystore.FileYStore(f"crdt_data/{name}.crdt")
30
  ydoc = pycrdt.Doc()
31
+ ydoc["workspace"] = ws = pycrdt.Map()
32
  # Replay updates from the store.
33
  try:
34
+ for update, timestamp in [
35
+ (item[0], item[-1]) async for item in ystore.read()
36
+ ]:
37
  ydoc.apply_update(update)
38
  except pycrdt_websocket.ystore.YDocNotFound:
39
  pass
40
+ if "nodes" not in ws:
41
+ ws["nodes"] = pycrdt.Array()
42
+ if "edges" not in ws:
43
+ ws["edges"] = pycrdt.Array()
44
+ if "env" not in ws:
45
+ ws["env"] = "unset"
46
  try_to_load_workspace(ws, name)
47
+ room = pycrdt_websocket.YRoom(
48
+ ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
49
+ )
50
  room.ws = ws
51
+
52
  def on_change(changes):
53
+ asyncio.create_task(workspace_changed(name, changes, ws))
54
+
55
  ws.observe_deep(on_change)
56
  return room
57
 
 
62
  await self.start_room(room)
63
  return room
64
 
 
 
65
 
66
  last_ws_input = None
67
+
68
+
69
  def clean_input(ws_pyd):
70
  for node in ws_pyd.nodes:
71
  node.data.display = None
72
+ node.data.error = None
73
  node.position.x = 0
74
  node.position.y = 0
75
  if node.model_extra:
76
  for key in list(node.model_extra.keys()):
77
  delattr(node, key)
78
 
79
+
80
  def crdt_update(crdt_obj, python_obj, boxes=set()):
81
  if isinstance(python_obj, dict):
82
  for key, value in python_obj.items():
 
90
  if crdt_obj.get(key) is None:
91
  crdt_obj[key] = pycrdt.Array()
92
  crdt_update(crdt_obj[key], value, boxes)
93
+ elif isinstance(value, enum.Enum):
94
+ crdt_obj[key] = str(value)
95
  else:
96
  crdt_obj[key] = value
97
  elif isinstance(python_obj, list):
 
110
  else:
111
  crdt_obj[i] = value
112
  else:
113
+ raise ValueError("Invalid type:", python_obj)
114
 
115
 
116
  def try_to_load_workspace(ws, name):
117
  from . import workspace
118
+
119
+ json_path = f"data/{name}"
120
  if os.path.exists(json_path):
121
  ws_pyd = workspace.load(json_path)
122
+ crdt_update(ws, ws_pyd.model_dump(), boxes={"display"})
123
+
124
 
125
+ last_known_versions = {}
126
+ delayed_executions = {}
127
+
128
+
129
+ async def workspace_changed(name, changes, ws_crdt):
130
  from . import workspace
131
+
132
  ws_pyd = workspace.Workspace.model_validate(ws_crdt.to_py())
133
+ # Do not trigger execution for superficial changes.
134
+ # This is a quick solution until we build proper caching.
135
  clean_input(ws_pyd)
136
+ if ws_pyd == last_known_versions.get(name):
137
  return
138
+ last_known_versions[name] = ws_pyd.model_copy(deep=True)
139
+ # Frontend changes that result from typing are delayed to avoid
140
+ # rerunning the workspace for every keystroke.
141
+ if name in delayed_executions:
142
+ delayed_executions[name].cancel()
143
+ delay = min(
144
+ getattr(change, "keys", {}).get("__execution_delay", {}).get("newValue", 0)
145
+ for change in changes
146
+ )
147
+ if delay:
148
+ task = asyncio.create_task(execute(ws_crdt, ws_pyd, delay))
149
+ delayed_executions[name] = task
150
+ else:
151
+ await execute(ws_crdt, ws_pyd)
152
+
153
+
154
+ async def execute(ws_crdt, ws_pyd, delay=0):
155
+ from . import workspace
156
+
157
+ if delay:
158
+ try:
159
+ await asyncio.sleep(delay)
160
+ except asyncio.CancelledError:
161
+ return
162
  await workspace.execute(ws_pyd)
163
+ with ws_crdt.doc.transaction():
164
+ for nc, np in zip(ws_crdt["nodes"], ws_pyd.nodes):
165
+ if "data" not in nc:
166
+ nc["data"] = pycrdt.Map()
167
+ # Display is added as an opaque Box.
168
+ nc["data"]["display"] = np.data.display
169
+ nc["data"]["error"] = np.data.error
170
+
171
 
172
  @contextlib.asynccontextmanager
173
  async def lifespan(app):
174
+ global websocket_server
175
+ websocket_server = WebsocketServer(
176
+ auto_clean_rooms=False,
177
+ )
178
  async with websocket_server:
179
  yield
180
+ print("closing websocket server")
181
+
182
 
183
  def sanitize_path(path):
184
  return os.path.relpath(os.path.normpath(os.path.join("/", path)), "/")
185
 
186
+
187
  @router.websocket("/ws/crdt/{room_name}")
188
  async def crdt_websocket(websocket: fastapi.WebSocket, room_name: str):
189
  room_name = sanitize_path(room_name)
190
+ server = pycrdt_websocket.ASGIServer(websocket_server)
191
+ await server({"path": room_name}, websocket._receive, websocket._send)
server/executors/one_by_one.py CHANGED
@@ -7,142 +7,166 @@ import traceback
7
  import inspect
8
  import typing
9
 
 
10
  class Context(ops.BaseConfig):
11
- '''Passed to operation functions as "_ctx" if they have such a parameter.'''
12
- node: workspace.WorkspaceNode
13
- last_result: typing.Any = None
 
 
14
 
15
  class Output(ops.BaseConfig):
16
- '''Return this to send values to specific outputs of a node.'''
17
- output_handle: str
18
- value: dict
 
19
 
20
 
21
  def df_to_list(df):
22
- return df.to_dict(orient='records')
 
23
 
24
  def has_ctx(op):
25
- sig = inspect.signature(op.func)
26
- return '_ctx' in sig.parameters
 
27
 
28
  CACHES = {}
29
 
 
30
  def register(env: str, cache: bool = True):
31
- '''Registers the one-by-one executor.'''
32
- if cache:
33
- CACHES[env] = {}
34
- cache = CACHES[env]
35
- else:
36
- cache = None
37
- ops.EXECUTORS[env] = lambda ws: execute(ws, ops.CATALOGS[env], cache=cache)
 
38
 
39
  def get_stages(ws, catalog):
40
- '''Inputs on top/bottom are batch inputs. We decompose the graph into a DAG of components along these edges.'''
41
- nodes = {n.id: n for n in ws.nodes}
42
- batch_inputs = {}
43
- inputs = {}
44
- for edge in ws.edges:
45
- inputs.setdefault(edge.target, []).append(edge.source)
46
- node = nodes[edge.target]
47
- op = catalog[node.data.title]
48
- i = op.inputs[edge.targetHandle]
49
- if i.position in 'top or bottom':
50
- batch_inputs.setdefault(edge.target, []).append(edge.source)
51
- stages = []
52
- for bt, bss in batch_inputs.items():
53
- upstream = set(bss)
54
- new = set(bss)
55
- while new:
56
- n = new.pop()
57
- for i in inputs.get(n, []):
58
- if i not in upstream:
59
- upstream.add(i)
60
- new.add(i)
61
- stages.append(upstream)
62
- stages.sort(key=lambda s: len(s))
63
- stages.append(set(nodes))
64
- return stages
65
 
66
 
67
  def _default_serializer(obj):
68
- if isinstance(obj, pydantic.BaseModel):
69
- return obj.dict()
70
- return {"__nonserializable__": id(obj)}
 
71
 
72
  def make_cache_key(obj):
73
- return orjson.dumps(obj, default=_default_serializer)
 
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}
86
- for e in ws.edges:
87
- edges[e.source].append(e)
88
- tasks = {}
89
- NO_INPUT = object() # Marker for initial tasks.
90
- for node in ws.nodes:
91
- node.data.error = None
92
- op = catalog[node.data.title]
93
- # Start tasks for nodes that have no non-batch inputs.
94
- if all([i.position in 'top or bottom' for i in op.inputs.values()]):
95
- tasks[node.id] = [NO_INPUT]
96
- batch_inputs = {}
97
- # Run the rest until we run out of tasks.
98
- stages = get_stages(ws, catalog)
99
- for stage in stages:
100
- next_stage = {}
101
- while tasks:
102
- n, ts = tasks.popitem()
103
- if n not in stage:
104
- next_stage.setdefault(n, []).extend(ts)
105
- continue
106
- node = nodes[n]
107
- data = node.data
108
- op = catalog[data.title]
109
- params = {**data.params}
110
- if has_ctx(op):
111
- params['_ctx'] = contexts[node.id]
112
- results = []
113
- for task in ts:
114
- try:
115
- inputs = [
116
- batch_inputs[(n, i.name)] if i.position in 'top or bottom' else task
117
- for i in op.inputs.values()]
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)
128
- break
129
- contexts[node.id].last_result = result
130
- # Returned lists and DataFrames are considered multiple tasks.
131
- if isinstance(result, pd.DataFrame):
132
- result = df_to_list(result)
133
- elif not isinstance(result, list):
134
- result = [result]
135
- results.extend(result)
136
- else: # Finished all tasks without errors.
137
- if op.type == 'visualization' or op.type == 'table_view' or op.type == 'image':
138
- data.display = results[0]
139
- for edge in edges[node.id]:
140
- t = nodes[edge.target]
141
- op = catalog[t.data.title]
142
- i = op.inputs[edge.targetHandle]
143
- if i.position in 'top or bottom':
144
- batch_inputs.setdefault((edge.target, edge.targetHandle), []).extend(results)
145
- else:
146
- tasks.setdefault(edge.target, []).extend(results)
147
- tasks = next_stage
148
- return contexts
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  import inspect
8
  import typing
9
 
10
+
11
  class Context(ops.BaseConfig):
12
+ """Passed to operation functions as "_ctx" if they have such a parameter."""
13
+
14
+ node: workspace.WorkspaceNode
15
+ last_result: typing.Any = None
16
+
17
 
18
  class Output(ops.BaseConfig):
19
+ """Return this to send values to specific outputs of a node."""
20
+
21
+ output_handle: str
22
+ value: dict
23
 
24
 
25
  def df_to_list(df):
26
+ return df.to_dict(orient="records")
27
+
28
 
29
  def has_ctx(op):
30
+ sig = inspect.signature(op.func)
31
+ return "_ctx" in sig.parameters
32
+
33
 
34
  CACHES = {}
35
 
36
+
37
  def register(env: str, cache: bool = True):
38
+ """Registers the one-by-one executor."""
39
+ if cache:
40
+ CACHES[env] = {}
41
+ cache = CACHES[env]
42
+ else:
43
+ cache = None
44
+ ops.EXECUTORS[env] = lambda ws: execute(ws, ops.CATALOGS[env], cache=cache)
45
+
46
 
47
  def get_stages(ws, catalog):
48
+ """Inputs on top/bottom are batch inputs. We decompose the graph into a DAG of components along these edges."""
49
+ nodes = {n.id: n for n in ws.nodes}
50
+ batch_inputs = {}
51
+ inputs = {}
52
+ for edge in ws.edges:
53
+ inputs.setdefault(edge.target, []).append(edge.source)
54
+ node = nodes[edge.target]
55
+ op = catalog[node.data.title]
56
+ i = op.inputs[edge.targetHandle]
57
+ if i.position in "top or bottom":
58
+ batch_inputs.setdefault(edge.target, []).append(edge.source)
59
+ stages = []
60
+ for bt, bss in batch_inputs.items():
61
+ upstream = set(bss)
62
+ new = set(bss)
63
+ while new:
64
+ n = new.pop()
65
+ for i in inputs.get(n, []):
66
+ if i not in upstream:
67
+ upstream.add(i)
68
+ new.add(i)
69
+ stages.append(upstream)
70
+ stages.sort(key=lambda s: len(s))
71
+ stages.append(set(nodes))
72
+ return stages
73
 
74
 
75
  def _default_serializer(obj):
76
+ if isinstance(obj, pydantic.BaseModel):
77
+ return obj.dict()
78
+ return {"__nonserializable__": id(obj)}
79
+
80
 
81
  def make_cache_key(obj):
82
+ return orjson.dumps(obj, default=_default_serializer)
83
+
84
 
85
  EXECUTOR_OUTPUT_CACHE = {}
86
 
87
+
88
  async def await_if_needed(obj):
89
+ if inspect.isawaitable(obj):
90
+ return await obj
91
+ return obj
92
+
93
 
94
  async def execute(ws, catalog, cache=None):
95
+ nodes = {n.id: n for n in ws.nodes}
96
+ contexts = {n.id: Context(node=n) for n in ws.nodes}
97
+ edges = {n.id: [] for n in ws.nodes}
98
+ for e in ws.edges:
99
+ edges[e.source].append(e)
100
+ tasks = {}
101
+ NO_INPUT = object() # Marker for initial tasks.
102
+ for node in ws.nodes:
103
+ node.data.error = None
104
+ op = catalog.get(node.data.title)
105
+ if op is None:
106
+ node.data.error = f'Operation "{node.data.title}" not found.'
107
+ continue
108
+ # Start tasks for nodes that have no non-batch inputs.
109
+ if all([i.position in "top or bottom" for i in op.inputs.values()]):
110
+ tasks[node.id] = [NO_INPUT]
111
+ batch_inputs = {}
112
+ # Run the rest until we run out of tasks.
113
+ stages = get_stages(ws, catalog)
114
+ for stage in stages:
115
+ next_stage = {}
116
+ while tasks:
117
+ n, ts = tasks.popitem()
118
+ if n not in stage:
119
+ next_stage.setdefault(n, []).extend(ts)
120
+ continue
121
+ node = nodes[n]
122
+ data = node.data
123
+ op = catalog[data.title]
124
+ params = {**data.params}
125
+ if has_ctx(op):
126
+ params["_ctx"] = contexts[node.id]
127
+ results = []
128
+ for task in ts:
129
+ try:
130
+ inputs = [
131
+ batch_inputs[(n, i.name)]
132
+ if i.position in "top or bottom"
133
+ else task
134
+ for i in op.inputs.values()
135
+ ]
136
+ if cache is not None:
137
+ key = make_cache_key((inputs, params))
138
+ if key not in cache:
139
+ cache[key] = await await_if_needed(op(*inputs, **params))
140
+ result = cache[key]
141
+ else:
142
+ result = await await_if_needed(op(*inputs, **params))
143
+ except Exception as e:
144
+ traceback.print_exc()
145
+ data.error = str(e)
146
+ break
147
+ contexts[node.id].last_result = result
148
+ # Returned lists and DataFrames are considered multiple tasks.
149
+ if isinstance(result, pd.DataFrame):
150
+ result = df_to_list(result)
151
+ elif not isinstance(result, list):
152
+ result = [result]
153
+ results.extend(result)
154
+ else: # Finished all tasks without errors.
155
+ if (
156
+ op.type == "visualization"
157
+ or op.type == "table_view"
158
+ or op.type == "image"
159
+ ):
160
+ data.display = results[0]
161
+ for edge in edges[node.id]:
162
+ t = nodes[edge.target]
163
+ op = catalog[t.data.title]
164
+ i = op.inputs[edge.targetHandle]
165
+ if i.position in "top or bottom":
166
+ batch_inputs.setdefault(
167
+ (edge.target, edge.targetHandle), []
168
+ ).extend(results)
169
+ else:
170
+ tasks.setdefault(edge.target, []).extend(results)
171
+ tasks = next_stage
172
+ return contexts
server/llm_ops.py CHANGED
@@ -1,4 +1,5 @@
1
- '''For specifying an LLM agent logic flow.'''
 
2
  from . import ops
3
  import chromadb
4
  import enum
@@ -14,177 +15,205 @@ embedding_client = openai.OpenAI(base_url="http://localhost:7997/")
14
  jinja = jinja2.Environment()
15
  chroma_client = chromadb.Client()
16
  LLM_CACHE = {}
17
- ENV = 'LLM logic'
18
  one_by_one.register(ENV)
19
  op = ops.op_registration(ENV)
20
 
 
21
  def chat(*args, **kwargs):
22
- key = json.dumps({'method': 'chat', 'args': args, 'kwargs': kwargs})
23
- if key not in LLM_CACHE:
24
- completion = chat_client.chat.completions.create(*args, **kwargs)
25
- LLM_CACHE[key] = [c.message.content for c in completion.choices]
26
- return LLM_CACHE[key]
 
27
 
28
  def embedding(*args, **kwargs):
29
- key = json.dumps({'method': 'embedding', 'args': args, 'kwargs': kwargs})
30
- if key not in LLM_CACHE:
31
- res = embedding_client.embeddings.create(*args, **kwargs)
32
- [data] = res.data
33
- LLM_CACHE[key] = data.embedding
34
- return LLM_CACHE[key]
 
35
 
36
  @op("Input CSV")
37
  def input_csv(*, filename: ops.PathStr, key: str):
38
- return pd.read_csv(filename).rename(columns={key: 'text'})
 
39
 
40
  @op("Input document")
41
  def input_document(*, filename: ops.PathStr):
42
- with open(filename) as f:
43
- return {'text': f.read()}
 
44
 
45
  @op("Input chat")
46
  def input_chat(*, chat: str):
47
- return {'text': chat}
 
48
 
49
  @op("Split document")
50
- def split_document(input, *, delimiter: str = '\\n\\n'):
51
- delimiter = delimiter.encode().decode('unicode_escape')
52
- chunks = input['text'].split(delimiter)
53
- return pd.DataFrame(chunks, columns=['text'])
 
54
 
55
  @ops.input_position(input="top")
56
  @op("Build document graph")
57
  def build_document_graph(input):
58
- return [{'source': i, 'target': i+1} for i in range(len(input)-1)]
 
59
 
60
  @ops.input_position(nodes="top", edges="top")
61
  @op("Predict links")
62
  def predict_links(nodes, edges):
63
- '''A placeholder for a real algorithm. For now just adds 2-hop neighbors.'''
64
- edge_map = {} # Source -> [Targets]
65
- for edge in edges:
66
- edge_map.setdefault(edge['source'], [])
67
- edge_map[edge['source']].append(edge['target'])
68
- new_edges = []
69
- for edge in edges:
70
- for t in edge_map.get(edge['target'], []):
71
- new_edges.append({'source': edge['source'], 'target': t})
72
- return edges + new_edges
 
73
 
74
  @ops.input_position(nodes="top", edges="top")
75
  @op("Add neighbors")
76
  def add_neighbors(nodes, edges, item):
77
- nodes = pd.DataFrame(nodes)
78
- edges = pd.DataFrame(edges)
79
- matches = item['rag']
80
- additional_matches = []
81
- for m in matches:
82
- node = nodes[nodes['text'] == m].index[0]
83
- neighbors = edges[edges['source'] == node]['target'].to_list()
84
- additional_matches.extend(nodes.loc[neighbors, 'text'])
85
- return {**item, 'rag': matches + additional_matches}
 
86
 
87
  @op("Create prompt")
88
- def create_prompt(input, *, save_as='prompt', template: ops.LongStr):
89
- assert template, 'Please specify the template. Refer to columns using the Jinja2 syntax.'
90
- t = jinja.from_string(template)
91
- prompt = t.render(**input)
92
- return {**input, save_as: prompt}
 
 
 
93
 
94
  @op("Ask LLM")
95
  def ask_llm(input, *, model: str, accepted_regex: str = None, max_tokens: int = 100):
96
- assert model, 'Please specify the model.'
97
- assert 'prompt' in input, 'Please create the prompt first.'
98
- options = {}
99
- if accepted_regex:
100
- options['extra_body'] = {
101
- "guided_regex": accepted_regex,
102
- }
103
- results = chat(
104
- model=model,
105
- max_tokens=max_tokens,
106
- messages=[
107
- {"role": "user", "content": input['prompt']},
108
- ],
109
- **options,
110
- )
111
- return [{**input, 'response': r} for r in results]
 
112
 
113
  @op("View", view="table_view")
114
  def view(input, *, _ctx: one_by_one.Context):
115
- v = _ctx.last_result
116
- if v:
117
- columns = v['dataframes']['df']['columns']
118
- v['dataframes']['df']['data'].append([input[c] for c in columns])
119
- else:
120
- columns = [str(c) for c in input.keys() if not str(c).startswith('_')]
121
- v = {
122
- 'dataframes': { 'df': {
123
- 'columns': columns,
124
- 'data': [[input[c] for c in columns]],
125
- }}
126
- }
127
- return v
 
 
 
128
 
129
  @ops.input_position(input="right")
130
  @ops.output_position(output="left")
131
  @op("Loop")
132
  def loop(input, *, max_iterations: int = 3, _ctx: one_by_one.Context):
133
- '''Data can flow back here max_iterations-1 times.'''
134
- key = f'iterations-{_ctx.node.id}'
135
- input[key] = input.get(key, 0) + 1
136
- if input[key] < max_iterations:
137
- return input
 
138
 
139
- @op('Branch', outputs=['true', 'false'])
140
  def branch(input, *, expression: str):
141
- res = eval(expression, input)
142
- return one_by_one.Output(output_handle=str(bool(res)).lower(), value=input)
 
143
 
144
  class RagEngine(enum.Enum):
145
- Chroma = 'Chroma'
146
- Custom = 'Custom'
 
147
 
148
  @ops.input_position(db="top")
149
- @op('RAG')
150
  def rag(
151
- input, db, *,
152
- engine: RagEngine = RagEngine.Chroma,
153
- input_field='text', db_field='text', num_matches: int = 10,
154
- _ctx: one_by_one.Context):
155
- if engine == RagEngine.Chroma:
156
- last = _ctx.last_result
157
- if last:
158
- collection = last['_collection']
159
- else:
160
- collection_name = _ctx.node.id.replace(' ', '_')
161
- for c in chroma_client.list_collections():
162
- if c.name == collection_name:
163
- chroma_client.delete_collection(name=collection_name)
164
- collection = chroma_client.create_collection(name=collection_name)
165
- collection.add(
166
- documents=[r[db_field] for r in db],
167
- ids=[str(i) for i in range(len(db))],
168
- )
169
- results = collection.query(
170
- query_texts=[input[input_field]],
171
- n_results=num_matches,
172
- )
173
- results = [db[int(r)] for r in results['ids'][0]]
174
- return {**input, 'rag': results, '_collection': collection}
175
- if engine == RagEngine.Custom:
176
- model = 'google/gemma-2-2b-it'
177
- chat = input[input_field]
178
- embeddings = [embedding(input=[r[db_field]], model=model) for r in db]
179
- q = embedding(input=[chat], model=model)
180
- def cosine_similarity(a, b):
181
- return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
182
- scores = [(i, cosine_similarity(q, e)) for i, e in enumerate(embeddings)]
183
- scores.sort(key=lambda x: -x[1])
184
- matches = [db[i][db_field] for i, _ in scores[:num_matches]]
185
- return {**input, 'rag': matches}
186
-
187
- @op('Run Python')
 
 
 
 
 
 
 
 
188
  def run_python(input, *, template: str):
189
- '''TODO: Implement.'''
190
- return input
 
1
+ """For specifying an LLM agent logic flow."""
2
+
3
  from . import ops
4
  import chromadb
5
  import enum
 
15
  jinja = jinja2.Environment()
16
  chroma_client = chromadb.Client()
17
  LLM_CACHE = {}
18
+ ENV = "LLM logic"
19
  one_by_one.register(ENV)
20
  op = ops.op_registration(ENV)
21
 
22
+
23
  def chat(*args, **kwargs):
24
+ key = json.dumps({"method": "chat", "args": args, "kwargs": kwargs})
25
+ if key not in LLM_CACHE:
26
+ completion = chat_client.chat.completions.create(*args, **kwargs)
27
+ LLM_CACHE[key] = [c.message.content for c in completion.choices]
28
+ return LLM_CACHE[key]
29
+
30
 
31
  def embedding(*args, **kwargs):
32
+ key = json.dumps({"method": "embedding", "args": args, "kwargs": kwargs})
33
+ if key not in LLM_CACHE:
34
+ res = embedding_client.embeddings.create(*args, **kwargs)
35
+ [data] = res.data
36
+ LLM_CACHE[key] = data.embedding
37
+ return LLM_CACHE[key]
38
+
39
 
40
  @op("Input CSV")
41
  def input_csv(*, filename: ops.PathStr, key: str):
42
+ return pd.read_csv(filename).rename(columns={key: "text"})
43
+
44
 
45
  @op("Input document")
46
  def input_document(*, filename: ops.PathStr):
47
+ with open(filename) as f:
48
+ return {"text": f.read()}
49
+
50
 
51
  @op("Input chat")
52
  def input_chat(*, chat: str):
53
+ return {"text": chat}
54
+
55
 
56
  @op("Split document")
57
+ def split_document(input, *, delimiter: str = "\\n\\n"):
58
+ delimiter = delimiter.encode().decode("unicode_escape")
59
+ chunks = input["text"].split(delimiter)
60
+ return pd.DataFrame(chunks, columns=["text"])
61
+
62
 
63
  @ops.input_position(input="top")
64
  @op("Build document graph")
65
  def build_document_graph(input):
66
+ return [{"source": i, "target": i + 1} for i in range(len(input) - 1)]
67
+
68
 
69
  @ops.input_position(nodes="top", edges="top")
70
  @op("Predict links")
71
  def predict_links(nodes, edges):
72
+ """A placeholder for a real algorithm. For now just adds 2-hop neighbors."""
73
+ edge_map = {} # Source -> [Targets]
74
+ for edge in edges:
75
+ edge_map.setdefault(edge["source"], [])
76
+ edge_map[edge["source"]].append(edge["target"])
77
+ new_edges = []
78
+ for edge in edges:
79
+ for t in edge_map.get(edge["target"], []):
80
+ new_edges.append({"source": edge["source"], "target": t})
81
+ return edges + new_edges
82
+
83
 
84
  @ops.input_position(nodes="top", edges="top")
85
  @op("Add neighbors")
86
  def add_neighbors(nodes, edges, item):
87
+ nodes = pd.DataFrame(nodes)
88
+ edges = pd.DataFrame(edges)
89
+ matches = item["rag"]
90
+ additional_matches = []
91
+ for m in matches:
92
+ node = nodes[nodes["text"] == m].index[0]
93
+ neighbors = edges[edges["source"] == node]["target"].to_list()
94
+ additional_matches.extend(nodes.loc[neighbors, "text"])
95
+ return {**item, "rag": matches + additional_matches}
96
+
97
 
98
  @op("Create prompt")
99
+ def create_prompt(input, *, save_as="prompt", template: ops.LongStr):
100
+ assert (
101
+ template
102
+ ), "Please specify the template. Refer to columns using the Jinja2 syntax."
103
+ t = jinja.from_string(template)
104
+ prompt = t.render(**input)
105
+ return {**input, save_as: prompt}
106
+
107
 
108
  @op("Ask LLM")
109
  def ask_llm(input, *, model: str, accepted_regex: str = None, max_tokens: int = 100):
110
+ assert model, "Please specify the model."
111
+ assert "prompt" in input, "Please create the prompt first."
112
+ options = {}
113
+ if accepted_regex:
114
+ options["extra_body"] = {
115
+ "guided_regex": accepted_regex,
116
+ }
117
+ results = chat(
118
+ model=model,
119
+ max_tokens=max_tokens,
120
+ messages=[
121
+ {"role": "user", "content": input["prompt"]},
122
+ ],
123
+ **options,
124
+ )
125
+ return [{**input, "response": r} for r in results]
126
+
127
 
128
  @op("View", view="table_view")
129
  def view(input, *, _ctx: one_by_one.Context):
130
+ v = _ctx.last_result
131
+ if v:
132
+ columns = v["dataframes"]["df"]["columns"]
133
+ v["dataframes"]["df"]["data"].append([input[c] for c in columns])
134
+ else:
135
+ columns = [str(c) for c in input.keys() if not str(c).startswith("_")]
136
+ v = {
137
+ "dataframes": {
138
+ "df": {
139
+ "columns": columns,
140
+ "data": [[input[c] for c in columns]],
141
+ }
142
+ }
143
+ }
144
+ return v
145
+
146
 
147
  @ops.input_position(input="right")
148
  @ops.output_position(output="left")
149
  @op("Loop")
150
  def loop(input, *, max_iterations: int = 3, _ctx: one_by_one.Context):
151
+ """Data can flow back here max_iterations-1 times."""
152
+ key = f"iterations-{_ctx.node.id}"
153
+ input[key] = input.get(key, 0) + 1
154
+ if input[key] < max_iterations:
155
+ return input
156
+
157
 
158
+ @op("Branch", outputs=["true", "false"])
159
  def branch(input, *, expression: str):
160
+ res = eval(expression, input)
161
+ return one_by_one.Output(output_handle=str(bool(res)).lower(), value=input)
162
+
163
 
164
  class RagEngine(enum.Enum):
165
+ Chroma = "Chroma"
166
+ Custom = "Custom"
167
+
168
 
169
  @ops.input_position(db="top")
170
+ @op("RAG")
171
  def rag(
172
+ input,
173
+ db,
174
+ *,
175
+ engine: RagEngine = RagEngine.Chroma,
176
+ input_field="text",
177
+ db_field="text",
178
+ num_matches: int = 10,
179
+ _ctx: one_by_one.Context,
180
+ ):
181
+ if engine == RagEngine.Chroma:
182
+ last = _ctx.last_result
183
+ if last:
184
+ collection = last["_collection"]
185
+ else:
186
+ collection_name = _ctx.node.id.replace(" ", "_")
187
+ for c in chroma_client.list_collections():
188
+ if c.name == collection_name:
189
+ chroma_client.delete_collection(name=collection_name)
190
+ collection = chroma_client.create_collection(name=collection_name)
191
+ collection.add(
192
+ documents=[r[db_field] for r in db],
193
+ ids=[str(i) for i in range(len(db))],
194
+ )
195
+ results = collection.query(
196
+ query_texts=[input[input_field]],
197
+ n_results=num_matches,
198
+ )
199
+ results = [db[int(r)] for r in results["ids"][0]]
200
+ return {**input, "rag": results, "_collection": collection}
201
+ if engine == RagEngine.Custom:
202
+ model = "google/gemma-2-2b-it"
203
+ chat = input[input_field]
204
+ embeddings = [embedding(input=[r[db_field]], model=model) for r in db]
205
+ q = embedding(input=[chat], model=model)
206
+
207
+ def cosine_similarity(a, b):
208
+ return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
209
+
210
+ scores = [(i, cosine_similarity(q, e)) for i, e in enumerate(embeddings)]
211
+ scores.sort(key=lambda x: -x[1])
212
+ matches = [db[i][db_field] for i, _ in scores[:num_matches]]
213
+ return {**input, "rag": matches}
214
+
215
+
216
+ @op("Run Python")
217
  def run_python(input, *, template: str):
218
+ """TODO: Implement."""
219
+ return input
server/lynxkite_ops.py CHANGED
@@ -1,4 +1,5 @@
1
- '''Some operations. To be split into separate files when we have more.'''
 
2
  from . import ops
3
  from collections import deque
4
  import dataclasses
@@ -9,72 +10,85 @@ import pandas as pd
9
  import traceback
10
  import typing
11
 
12
- op = ops.op_registration('LynxKite')
 
13
 
14
  @dataclasses.dataclass
15
  class RelationDefinition:
16
- '''Defines a set of edges.'''
17
- df: str # The DataFrame that contains the edges.
18
- source_column: str # The column in the edge DataFrame that contains the source node ID.
19
- target_column: str # The column in the edge DataFrame that contains the target node ID.
20
- source_table: str # The DataFrame that contains the source nodes.
21
- target_table: str # The DataFrame that contains the target nodes.
22
- source_key: str # The column in the source table that contains the node ID.
23
- target_key: str # The column in the target table that contains the node ID.
 
 
 
 
 
 
24
 
25
  @dataclasses.dataclass
26
  class Bundle:
27
- '''A collection of DataFrames and other data.
28
-
29
- Can efficiently represent a knowledge graph (homogeneous or heterogeneous) or tabular data.
30
- It can also carry other data, such as a trained model.
31
- '''
32
- dfs: dict[str, pd.DataFrame] = dataclasses.field(default_factory=dict)
33
- relations: list[RelationDefinition] = dataclasses.field(default_factory=list)
34
- other: dict[str, typing.Any] = None
35
-
36
- @classmethod
37
- def from_nx(cls, graph: nx.Graph):
38
- edges = nx.to_pandas_edgelist(graph)
39
- d = dict(graph.nodes(data=True))
40
- nodes = pd.DataFrame(d.values(), index=d.keys())
41
- nodes['id'] = nodes.index
42
- return cls(
43
- dfs={'edges': edges, 'nodes': nodes},
44
- relations=[
45
- RelationDefinition(
46
- df='edges',
47
- source_column='source',
48
- target_column='target',
49
- source_table='nodes',
50
- target_table='nodes',
51
- source_key='id',
52
- target_key='id',
 
 
 
53
  )
54
- ]
55
- )
56
 
57
- def to_nx(self):
58
- graph = nx.from_pandas_edgelist(self.dfs['edges'])
59
- nx.set_node_attributes(graph, self.dfs['nodes'].set_index('id').to_dict('index'))
60
- return graph
 
 
61
 
62
 
63
  def nx_node_attribute_func(name):
64
- '''Decorator for wrapping a function that adds a NetworkX node attribute.'''
65
- def decorator(func):
66
- @functools.wraps(func)
67
- def wrapper(graph: nx.Graph, **kwargs):
68
- graph = graph.copy()
69
- attr = func(graph, **kwargs)
70
- nx.set_node_attributes(graph, attr, name)
71
- return graph
72
- return wrapper
73
- return decorator
 
 
 
74
 
75
 
76
  def disambiguate_edges(ws):
77
- '''If an input plug is connected to multiple edges, keep only the last edge.'''
78
  seen = set()
79
  for edge in reversed(ws.edges):
80
  if (edge.target, edge.targetHandle) in seen:
@@ -82,20 +96,15 @@ def disambiguate_edges(ws):
82
  seen.add((edge.target, edge.targetHandle))
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]
90
  disambiguate_edges(ws)
91
- children = {}
92
- for n in ws.nodes:
93
- if n.parentId:
94
- children.setdefault(n.parentId, []).append(n)
95
  outputs = {}
96
  failed = 0
97
- while len(outputs) + failed < len(nodes):
98
- for node in nodes:
99
  if node.id in outputs:
100
  continue
101
  # TODO: Take the input/output handles into account.
@@ -107,118 +116,138 @@ async def execute(ws):
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):
111
- inputs[i] = x.to_nx()
112
- elif p.type == Bundle and isinstance(x, nx.Graph):
113
- inputs[i] = Bundle.from_nx(x)
114
  try:
115
- output = op(*inputs, **params)
116
  except Exception as e:
117
- traceback.print_exc()
118
- data.error = str(e)
119
- failed += 1
120
- continue
121
- if len(op.inputs) == 1 and op.inputs.get('multi') == '*':
122
  # It's a flexible input. Create n+1 handles.
123
- data.inputs = {f'input{i}': None for i in range(len(inputs) + 1)}
124
  data.error = None
125
  outputs[node.id] = output
126
- if op.type == 'visualization' or op.type == 'table_view' or op.type == 'image':
 
 
 
 
127
  data.display = output
128
 
 
129
  @op("Import Parquet")
130
  def import_parquet(*, filename: str):
131
- '''Imports a parquet file.'''
132
- return pd.read_parquet(filename)
 
133
 
134
  @op("Create scale-free graph")
135
  def create_scale_free_graph(*, nodes: int = 10):
136
- '''Creates a scale-free graph with the given number of nodes.'''
137
- return nx.scale_free_graph(nodes)
 
138
 
139
  @op("Compute PageRank")
140
- @nx_node_attribute_func('pagerank')
141
  def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
142
- return nx.pagerank(graph, alpha=damping, max_iter=iterations)
 
143
 
144
  @op("Discard loop edges")
145
  def discard_loop_edges(graph: nx.Graph):
146
- graph = graph.copy()
147
- graph.remove_edges_from(nx.selfloop_edges(graph))
148
- return graph
 
149
 
150
  @op("Sample graph")
151
  def sample_graph(graph: nx.Graph, *, nodes: int = 100):
152
- '''Takes a (preferably connected) subgraph.'''
153
- sample = set()
154
- to_expand = deque([0])
155
- while to_expand and len(sample) < nodes:
156
- node = to_expand.pop()
157
- for n in graph.neighbors(node):
158
- if n not in sample:
159
- sample.add(n)
160
- to_expand.append(n)
161
- if len(sample) == nodes:
162
- break
163
- return nx.Graph(graph.subgraph(sample))
 
164
 
165
  def _map_color(value):
166
- cmap = matplotlib.cm.get_cmap('viridis')
167
- value = (value - value.min()) / (value.max() - value.min())
168
- rgba = cmap(value)
169
- return ['#{:02x}{:02x}{:02x}'.format(int(r*255), int(g*255), int(b*255)) for r, g, b in rgba[:, :3]]
 
 
 
 
170
 
171
  @op("Visualize graph", view="visualization")
172
  def visualize_graph(graph: Bundle, *, color_nodes_by: ops.NodeAttribute = None):
173
- nodes = graph.dfs['nodes'].copy()
174
- if color_nodes_by:
175
- nodes['color'] = _map_color(nodes[color_nodes_by])
176
- nodes = nodes.to_records()
177
- edges = graph.dfs['edges'].drop_duplicates(['source', 'target'])
178
- edges = edges.to_records()
179
- pos = nx.spring_layout(graph.to_nx(), iterations=max(1, int(10000/len(nodes))))
180
- v = {
181
- 'animationDuration': 500,
182
- 'animationEasingUpdate': 'quinticInOut',
183
- 'series': [
184
- {
185
- 'type': 'graph',
186
- 'roam': True,
187
- 'lineStyle': {
188
- 'color': 'gray',
189
- 'curveness': 0.3,
190
- },
191
- 'emphasis': {
192
- 'focus': 'adjacency',
193
- 'lineStyle': {
194
- 'width': 10,
195
- }
196
- },
197
- 'data': [
198
- {
199
- 'id': str(n.id),
200
- 'x': float(pos[n.id][0]), 'y': float(pos[n.id][1]),
201
- # Adjust node size to cover the same area no matter how many nodes there are.
202
- 'symbolSize': 50 / len(nodes) ** 0.5,
203
- 'itemStyle': {'color': n.color} if color_nodes_by else {},
204
- }
205
- for n in nodes],
206
- 'links': [
207
- {'source': str(r.source), 'target': str(r.target)}
208
- for r in edges],
209
- },
210
- ],
211
- }
212
- return v
 
 
 
213
 
214
  @op("View tables", view="table_view")
215
  def view_tables(bundle: Bundle):
216
- v = {
217
- 'dataframes': { name: {
218
- 'columns': [str(c) for c in df.columns],
219
- 'data': df.values.tolist(),
220
- } for name, df in bundle.dfs.items() },
221
- 'relations': bundle.relations,
222
- 'other': bundle.other,
223
- }
224
- return v
 
 
 
 
1
+ """Some operations. To be split into separate files when we have more."""
2
+
3
  from . import ops
4
  from collections import deque
5
  import dataclasses
 
10
  import traceback
11
  import typing
12
 
13
+ op = ops.op_registration("LynxKite")
14
+
15
 
16
  @dataclasses.dataclass
17
  class RelationDefinition:
18
+ """Defines a set of edges."""
19
+
20
+ df: str # The DataFrame that contains the edges.
21
+ source_column: (
22
+ str # The column in the edge DataFrame that contains the source node ID.
23
+ )
24
+ target_column: (
25
+ str # The column in the edge DataFrame that contains the target node ID.
26
+ )
27
+ source_table: str # The DataFrame that contains the source nodes.
28
+ target_table: str # The DataFrame that contains the target nodes.
29
+ source_key: str # The column in the source table that contains the node ID.
30
+ target_key: str # The column in the target table that contains the node ID.
31
+
32
 
33
  @dataclasses.dataclass
34
  class Bundle:
35
+ """A collection of DataFrames and other data.
36
+
37
+ Can efficiently represent a knowledge graph (homogeneous or heterogeneous) or tabular data.
38
+ It can also carry other data, such as a trained model.
39
+ """
40
+
41
+ dfs: dict[str, pd.DataFrame] = dataclasses.field(default_factory=dict)
42
+ relations: list[RelationDefinition] = dataclasses.field(default_factory=list)
43
+ other: dict[str, typing.Any] = None
44
+
45
+ @classmethod
46
+ def from_nx(cls, graph: nx.Graph):
47
+ edges = nx.to_pandas_edgelist(graph)
48
+ d = dict(graph.nodes(data=True))
49
+ nodes = pd.DataFrame(d.values(), index=d.keys())
50
+ nodes["id"] = nodes.index
51
+ return cls(
52
+ dfs={"edges": edges, "nodes": nodes},
53
+ relations=[
54
+ RelationDefinition(
55
+ df="edges",
56
+ source_column="source",
57
+ target_column="target",
58
+ source_table="nodes",
59
+ target_table="nodes",
60
+ source_key="id",
61
+ target_key="id",
62
+ )
63
+ ],
64
  )
 
 
65
 
66
+ def to_nx(self):
67
+ graph = nx.from_pandas_edgelist(self.dfs["edges"])
68
+ nx.set_node_attributes(
69
+ graph, self.dfs["nodes"].set_index("id").to_dict("index")
70
+ )
71
+ return graph
72
 
73
 
74
  def nx_node_attribute_func(name):
75
+ """Decorator for wrapping a function that adds a NetworkX node attribute."""
76
+
77
+ def decorator(func):
78
+ @functools.wraps(func)
79
+ def wrapper(graph: nx.Graph, **kwargs):
80
+ graph = graph.copy()
81
+ attr = func(graph, **kwargs)
82
+ nx.set_node_attributes(graph, attr, name)
83
+ return graph
84
+
85
+ return wrapper
86
+
87
+ return decorator
88
 
89
 
90
  def disambiguate_edges(ws):
91
+ """If an input plug is connected to multiple edges, keep only the last edge."""
92
  seen = set()
93
  for edge in reversed(ws.edges):
94
  if (edge.target, edge.targetHandle) in seen:
 
96
  seen.add((edge.target, edge.targetHandle))
97
 
98
 
99
+ @ops.register_executor("LynxKite")
100
  async def execute(ws):
101
+ catalog = ops.CATALOGS["LynxKite"]
102
  # Nodes are responsible for interpreting/executing their child nodes.
 
103
  disambiguate_edges(ws)
 
 
 
 
104
  outputs = {}
105
  failed = 0
106
+ while len(outputs) + failed < len(ws.nodes):
107
+ for node in ws.nodes:
108
  if node.id in outputs:
109
  continue
110
  # TODO: Take the input/output handles into account.
 
116
  params = {**data.params}
117
  # Convert inputs.
118
  for i, (x, p) in enumerate(zip(inputs, op.inputs.values())):
119
+ if p.type == nx.Graph and isinstance(x, Bundle):
120
+ inputs[i] = x.to_nx()
121
+ elif p.type == Bundle and isinstance(x, nx.Graph):
122
+ inputs[i] = Bundle.from_nx(x)
123
  try:
124
+ output = op(*inputs, **params)
125
  except Exception as e:
126
+ traceback.print_exc()
127
+ data.error = str(e)
128
+ failed += 1
129
+ continue
130
+ if len(op.inputs) == 1 and op.inputs.get("multi") == "*":
131
  # It's a flexible input. Create n+1 handles.
132
+ data.inputs = {f"input{i}": None for i in range(len(inputs) + 1)}
133
  data.error = None
134
  outputs[node.id] = output
135
+ if (
136
+ op.type == "visualization"
137
+ or op.type == "table_view"
138
+ or op.type == "image"
139
+ ):
140
  data.display = output
141
 
142
+
143
  @op("Import Parquet")
144
  def import_parquet(*, filename: str):
145
+ """Imports a parquet file."""
146
+ return pd.read_parquet(filename)
147
+
148
 
149
  @op("Create scale-free graph")
150
  def create_scale_free_graph(*, nodes: int = 10):
151
+ """Creates a scale-free graph with the given number of nodes."""
152
+ return nx.scale_free_graph(nodes)
153
+
154
 
155
  @op("Compute PageRank")
156
+ @nx_node_attribute_func("pagerank")
157
  def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
158
+ return nx.pagerank(graph, alpha=damping, max_iter=iterations)
159
+
160
 
161
  @op("Discard loop edges")
162
  def discard_loop_edges(graph: nx.Graph):
163
+ graph = graph.copy()
164
+ graph.remove_edges_from(nx.selfloop_edges(graph))
165
+ return graph
166
+
167
 
168
  @op("Sample graph")
169
  def sample_graph(graph: nx.Graph, *, nodes: int = 100):
170
+ """Takes a (preferably connected) subgraph."""
171
+ sample = set()
172
+ to_expand = deque([0])
173
+ while to_expand and len(sample) < nodes:
174
+ node = to_expand.pop()
175
+ for n in graph.neighbors(node):
176
+ if n not in sample:
177
+ sample.add(n)
178
+ to_expand.append(n)
179
+ if len(sample) == nodes:
180
+ break
181
+ return nx.Graph(graph.subgraph(sample))
182
+
183
 
184
  def _map_color(value):
185
+ cmap = matplotlib.cm.get_cmap("viridis")
186
+ value = (value - value.min()) / (value.max() - value.min())
187
+ rgba = cmap(value)
188
+ return [
189
+ "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
190
+ for r, g, b in rgba[:, :3]
191
+ ]
192
+
193
 
194
  @op("Visualize graph", view="visualization")
195
  def visualize_graph(graph: Bundle, *, color_nodes_by: ops.NodeAttribute = None):
196
+ nodes = graph.dfs["nodes"].copy()
197
+ if color_nodes_by:
198
+ nodes["color"] = _map_color(nodes[color_nodes_by])
199
+ nodes = nodes.to_records()
200
+ edges = graph.dfs["edges"].drop_duplicates(["source", "target"])
201
+ edges = edges.to_records()
202
+ pos = nx.spring_layout(graph.to_nx(), iterations=max(1, int(10000 / len(nodes))))
203
+ v = {
204
+ "animationDuration": 500,
205
+ "animationEasingUpdate": "quinticInOut",
206
+ "series": [
207
+ {
208
+ "type": "graph",
209
+ "roam": True,
210
+ "lineStyle": {
211
+ "color": "gray",
212
+ "curveness": 0.3,
213
+ },
214
+ "emphasis": {
215
+ "focus": "adjacency",
216
+ "lineStyle": {
217
+ "width": 10,
218
+ },
219
+ },
220
+ "data": [
221
+ {
222
+ "id": str(n.id),
223
+ "x": float(pos[n.id][0]),
224
+ "y": float(pos[n.id][1]),
225
+ # Adjust node size to cover the same area no matter how many nodes there are.
226
+ "symbolSize": 50 / len(nodes) ** 0.5,
227
+ "itemStyle": {"color": n.color} if color_nodes_by else {},
228
+ }
229
+ for n in nodes
230
+ ],
231
+ "links": [
232
+ {"source": str(r.source), "target": str(r.target)} for r in edges
233
+ ],
234
+ },
235
+ ],
236
+ }
237
+ return v
238
+
239
 
240
  @op("View tables", view="table_view")
241
  def view_tables(bundle: Bundle):
242
+ v = {
243
+ "dataframes": {
244
+ name: {
245
+ "columns": [str(c) for c in df.columns],
246
+ "data": df.values.tolist(),
247
+ }
248
+ for name, df in bundle.dfs.items()
249
+ },
250
+ "relations": bundle.relations,
251
+ "other": bundle.other,
252
+ }
253
+ return v
server/ops.py CHANGED
@@ -1,4 +1,5 @@
1
- '''API for implementing LynxKite operations.'''
 
2
  from __future__ import annotations
3
  import enum
4
  import functools
@@ -10,180 +11,214 @@ from typing_extensions import Annotated
10
  CATALOGS = {}
11
  EXECUTORS = {}
12
 
13
- typeof = type # We have some arguments called "type".
 
 
14
  def type_to_json(t):
15
- if isinstance(t, type) and issubclass(t, enum.Enum):
16
- return {'enum': list(t.__members__.keys())}
17
- if getattr(t, '__metadata__', None):
18
- return t.__metadata__[-1]
19
- return {'type': str(t)}
20
- Type = Annotated[
21
- typing.Any, pydantic.PlainSerializer(type_to_json, return_type=dict)
22
- ]
23
- LongStr = Annotated[
24
- str, {'format': 'textarea'}
25
- ]
26
- PathStr = Annotated[
27
- str, {'format': 'path'}
28
- ]
29
- CollapsedStr = Annotated[
30
- str, {'format': 'collapsed'}
31
- ]
32
- NodeAttribute = Annotated[
33
- str, {'format': 'node attribute'}
34
- ]
35
- EdgeAttribute = Annotated[
36
- str, {'format': 'edge attribute'}
37
- ]
38
  class BaseConfig(pydantic.BaseModel):
39
- model_config = pydantic.ConfigDict(
40
- arbitrary_types_allowed=True,
41
- )
42
 
43
 
44
  class Parameter(BaseConfig):
45
- '''Defines a parameter for an operation.'''
46
- name: str
47
- default: typing.Any
48
- type: Type = None
49
-
50
- @staticmethod
51
- def options(name, options, default=None):
52
- e = enum.Enum(f'OptionsFor_{name}', options)
53
- return Parameter.basic(name, e[default or options[0]], e)
54
-
55
- @staticmethod
56
- def collapsed(name, default, type=None):
57
- return Parameter.basic(name, default, CollapsedStr)
58
-
59
- @staticmethod
60
- def basic(name, default=None, type=None):
61
- if default is inspect._empty:
62
- default = None
63
- if type is None or type is inspect._empty:
64
- type = typeof(default) if default is not None else None
65
- return Parameter(name=name, default=default, type=type)
 
 
66
 
67
  class Input(BaseConfig):
68
- name: str
69
- type: Type
70
- position: str = 'left'
 
71
 
72
  class Output(BaseConfig):
73
- name: str
74
- type: Type
75
- position: str = 'right'
 
 
 
 
76
 
77
- MULTI_INPUT = Input(name='multi', type='*')
78
  def basic_inputs(*names):
79
- return {name: Input(name=name, type=None) for name in names}
 
 
80
  def basic_outputs(*names):
81
- return {name: Output(name=name, type=None) for name in names}
82
 
83
 
84
  class Op(BaseConfig):
85
- func: typing.Callable = pydantic.Field(exclude=True)
86
- name: str
87
- params: dict[str, Parameter]
88
- inputs: dict[str, Input]
89
- outputs: dict[str, Output]
90
- type: str = 'basic' # The UI to use for this operation.
91
- sub_nodes: list[Op] = None # If set, these nodes can be placed inside the operation's node.
92
-
93
- def __call__(self, *inputs, **params):
94
- # Convert parameters.
95
- for p in params:
96
- if p in self.params:
97
- if self.params[p].type == int:
98
- params[p] = int(params[p])
99
- elif self.params[p].type == float:
100
- params[p] = float(params[p])
101
- elif isinstance(self.params[p].type, enum.EnumMeta):
102
- params[p] = self.params[p].type[params[p]]
103
- res = self.func(*inputs, **params)
104
- return res
105
-
106
-
107
- def op(env: str, name: str, *, view='basic', sub_nodes=None, outputs=None):
108
- '''Decorator for defining an operation.'''
109
- def decorator(func):
110
- sig = inspect.signature(func)
111
- # Positional arguments are inputs.
112
- inputs = {
113
- name: Input(name=name, type=param.annotation)
114
- for name, param in sig.parameters.items()
115
- if param.kind != param.KEYWORD_ONLY}
116
- params = {}
117
- for n, param in sig.parameters.items():
118
- if param.kind == param.KEYWORD_ONLY and not n.startswith('_'):
119
- params[n] = Parameter.basic(n, param.default, param.annotation)
120
- if outputs:
121
- _outputs = {name: Output(name=name, type=None) for name in outputs}
122
- else:
123
- _outputs = {'output': Output(name='output', type=None)} if view == 'basic' else {}
124
- op = Op(func=func, name=name, params=params, inputs=inputs, outputs=_outputs, type=view)
125
- if sub_nodes is not None:
126
- op.sub_nodes = sub_nodes
127
- op.type = 'sub_flow'
128
- CATALOGS.setdefault(env, {})
129
- CATALOGS[env][name] = op
130
- func.__op__ = op
131
- return func
132
- return decorator
 
 
 
 
 
 
 
 
 
133
 
134
  def input_position(**kwargs):
135
- '''Decorator for specifying unusual positions for the inputs.'''
136
- def decorator(func):
137
- op = func.__op__
138
- for k, v in kwargs.items():
139
- op.inputs[k].position = v
140
- return func
141
- return decorator
 
 
 
142
 
143
  def output_position(**kwargs):
144
- '''Decorator for specifying unusual positions for the outputs.'''
145
- def decorator(func):
146
- op = func.__op__
147
- for k, v in kwargs.items():
148
- op.outputs[k].position = v
149
- return func
150
- return decorator
 
 
 
151
 
152
  def no_op(*args, **kwargs):
153
- if args:
154
- return args[0]
155
- return None
156
-
157
- def register_passive_op(env: str, name: str, inputs=[], outputs=['output'], params=[]):
158
- '''A passive operation has no associated code.'''
159
- op = Op(
160
- func=no_op,
161
- name=name,
162
- params={p.name: p for p in params},
163
- inputs=dict(
164
- (i, Input(name=i, type=None)) if isinstance(i, str)
165
- else (i.name, i) for i in inputs),
166
- outputs=dict(
167
- (o, Output(name=o, type=None)) if isinstance(o, str)
168
- else (o.name, o) for o in outputs))
169
- CATALOGS.setdefault(env, {})
170
- CATALOGS[env][name] = op
171
- return op
 
 
 
 
 
172
 
173
  def register_executor(env: str):
174
- '''Decorator for registering an executor.'''
175
- def decorator(func):
176
- EXECUTORS[env] = func
177
- return func
178
- return decorator
 
 
 
179
 
180
  def op_registration(env: str):
181
- return functools.partial(op, env)
 
182
 
183
  def passive_op_registration(env: str):
184
- return functools.partial(register_passive_op, env)
 
185
 
186
  def register_area(env, name, params=[]):
187
- '''A node that represents an area. It can contain other nodes, but does not restrict movement in any way.'''
188
- op = Op(func=no_op, name=name, params={p.name: p for p in params}, inputs={}, outputs={}, type='area')
189
- CATALOGS[env][name] = op
 
 
 
 
 
 
 
 
1
+ """API for implementing LynxKite operations."""
2
+
3
  from __future__ import annotations
4
  import enum
5
  import functools
 
11
  CATALOGS = {}
12
  EXECUTORS = {}
13
 
14
+ typeof = type # We have some arguments called "type".
15
+
16
+
17
  def type_to_json(t):
18
+ if isinstance(t, type) and issubclass(t, enum.Enum):
19
+ return {"enum": list(t.__members__.keys())}
20
+ if getattr(t, "__metadata__", None):
21
+ return t.__metadata__[-1]
22
+ return {"type": str(t)}
23
+
24
+
25
+ Type = Annotated[typing.Any, pydantic.PlainSerializer(type_to_json, return_type=dict)]
26
+ LongStr = Annotated[str, {"format": "textarea"}]
27
+ PathStr = Annotated[str, {"format": "path"}]
28
+ CollapsedStr = Annotated[str, {"format": "collapsed"}]
29
+ NodeAttribute = Annotated[str, {"format": "node attribute"}]
30
+ EdgeAttribute = Annotated[str, {"format": "edge attribute"}]
31
+
32
+
 
 
 
 
 
 
 
 
33
  class BaseConfig(pydantic.BaseModel):
34
+ model_config = pydantic.ConfigDict(
35
+ arbitrary_types_allowed=True,
36
+ )
37
 
38
 
39
  class Parameter(BaseConfig):
40
+ """Defines a parameter for an operation."""
41
+
42
+ name: str
43
+ default: typing.Any
44
+ type: Type = None
45
+
46
+ @staticmethod
47
+ def options(name, options, default=None):
48
+ e = enum.Enum(f"OptionsFor_{name}", options)
49
+ return Parameter.basic(name, e[default or options[0]], e)
50
+
51
+ @staticmethod
52
+ def collapsed(name, default, type=None):
53
+ return Parameter.basic(name, default, CollapsedStr)
54
+
55
+ @staticmethod
56
+ def basic(name, default=None, type=None):
57
+ if default is inspect._empty:
58
+ default = None
59
+ if type is None or type is inspect._empty:
60
+ type = typeof(default) if default is not None else None
61
+ return Parameter(name=name, default=default, type=type)
62
+
63
 
64
  class Input(BaseConfig):
65
+ name: str
66
+ type: Type
67
+ position: str = "left"
68
+
69
 
70
  class Output(BaseConfig):
71
+ name: str
72
+ type: Type
73
+ position: str = "right"
74
+
75
+
76
+ MULTI_INPUT = Input(name="multi", type="*")
77
+
78
 
 
79
  def basic_inputs(*names):
80
+ return {name: Input(name=name, type=None) for name in names}
81
+
82
+
83
  def basic_outputs(*names):
84
+ return {name: Output(name=name, type=None) for name in names}
85
 
86
 
87
  class Op(BaseConfig):
88
+ func: typing.Callable = pydantic.Field(exclude=True)
89
+ name: str
90
+ params: dict[str, Parameter]
91
+ inputs: dict[str, Input]
92
+ outputs: dict[str, Output]
93
+ type: str = "basic" # The UI to use for this operation.
94
+
95
+ def __call__(self, *inputs, **params):
96
+ # Convert parameters.
97
+ for p in params:
98
+ if p in self.params:
99
+ if self.params[p].type == int:
100
+ params[p] = int(params[p])
101
+ elif self.params[p].type == float:
102
+ params[p] = float(params[p])
103
+ elif isinstance(self.params[p].type, enum.EnumMeta):
104
+ params[p] = self.params[p].type[params[p]]
105
+ res = self.func(*inputs, **params)
106
+ return res
107
+
108
+
109
+ def op(env: str, name: str, *, view="basic", outputs=None):
110
+ """Decorator for defining an operation."""
111
+
112
+ def decorator(func):
113
+ sig = inspect.signature(func)
114
+ # Positional arguments are inputs.
115
+ inputs = {
116
+ name: Input(name=name, type=param.annotation)
117
+ for name, param in sig.parameters.items()
118
+ if param.kind != param.KEYWORD_ONLY
119
+ }
120
+ params = {}
121
+ for n, param in sig.parameters.items():
122
+ if param.kind == param.KEYWORD_ONLY and not n.startswith("_"):
123
+ params[n] = Parameter.basic(n, param.default, param.annotation)
124
+ if outputs:
125
+ _outputs = {name: Output(name=name, type=None) for name in outputs}
126
+ else:
127
+ _outputs = (
128
+ {"output": Output(name="output", type=None)} if view == "basic" else {}
129
+ )
130
+ op = Op(
131
+ func=func,
132
+ name=name,
133
+ params=params,
134
+ inputs=inputs,
135
+ outputs=_outputs,
136
+ type=view,
137
+ )
138
+ CATALOGS.setdefault(env, {})
139
+ CATALOGS[env][name] = op
140
+ func.__op__ = op
141
+ return func
142
+
143
+ return decorator
144
+
145
 
146
  def input_position(**kwargs):
147
+ """Decorator for specifying unusual positions for the inputs."""
148
+
149
+ def decorator(func):
150
+ op = func.__op__
151
+ for k, v in kwargs.items():
152
+ op.inputs[k].position = v
153
+ return func
154
+
155
+ return decorator
156
+
157
 
158
  def output_position(**kwargs):
159
+ """Decorator for specifying unusual positions for the outputs."""
160
+
161
+ def decorator(func):
162
+ op = func.__op__
163
+ for k, v in kwargs.items():
164
+ op.outputs[k].position = v
165
+ return func
166
+
167
+ return decorator
168
+
169
 
170
  def no_op(*args, **kwargs):
171
+ if args:
172
+ return args[0]
173
+ return None
174
+
175
+
176
+ def register_passive_op(env: str, name: str, inputs=[], outputs=["output"], params=[]):
177
+ """A passive operation has no associated code."""
178
+ op = Op(
179
+ func=no_op,
180
+ name=name,
181
+ params={p.name: p for p in params},
182
+ inputs=dict(
183
+ (i, Input(name=i, type=None)) if isinstance(i, str) else (i.name, i)
184
+ for i in inputs
185
+ ),
186
+ outputs=dict(
187
+ (o, Output(name=o, type=None)) if isinstance(o, str) else (o.name, o)
188
+ for o in outputs
189
+ ),
190
+ )
191
+ CATALOGS.setdefault(env, {})
192
+ CATALOGS[env][name] = op
193
+ return op
194
+
195
 
196
  def register_executor(env: str):
197
+ """Decorator for registering an executor."""
198
+
199
+ def decorator(func):
200
+ EXECUTORS[env] = func
201
+ return func
202
+
203
+ return decorator
204
+
205
 
206
  def op_registration(env: str):
207
+ return functools.partial(op, env)
208
+
209
 
210
  def passive_op_registration(env: str):
211
+ return functools.partial(register_passive_op, env)
212
+
213
 
214
  def register_area(env, name, params=[]):
215
+ """A node that represents an area. It can contain other nodes, but does not restrict movement in any way."""
216
+ op = Op(
217
+ func=no_op,
218
+ name=name,
219
+ params={p.name: p for p in params},
220
+ inputs={},
221
+ outputs={},
222
+ type="area",
223
+ )
224
+ CATALOGS[env][name] = op
server/workspace.py CHANGED
@@ -1,4 +1,5 @@
1
- '''For working with LynxKite workspaces.'''
 
2
  from typing import Optional
3
  import dataclasses
4
  import os
@@ -6,15 +7,18 @@ import pydantic
6
  import tempfile
7
  from . import ops
8
 
 
9
  class BaseConfig(pydantic.BaseModel):
10
  model_config = pydantic.ConfigDict(
11
- extra='allow',
12
  )
13
 
 
14
  class Position(BaseConfig):
15
  x: float
16
  y: float
17
 
 
18
  class WorkspaceNodeData(BaseConfig):
19
  title: str
20
  params: dict
@@ -23,12 +27,13 @@ class WorkspaceNodeData(BaseConfig):
23
  # Also contains a "meta" field when going out.
24
  # This is ignored when coming back from the frontend.
25
 
 
26
  class WorkspaceNode(BaseConfig):
27
  id: str
28
  type: str
29
  data: WorkspaceNodeData
30
  position: Position
31
- parentId: Optional[str] = None
32
 
33
  class WorkspaceEdge(BaseConfig):
34
  id: str
@@ -37,8 +42,9 @@ class WorkspaceEdge(BaseConfig):
37
  sourceHandle: str
38
  targetHandle: str
39
 
 
40
  class Workspace(BaseConfig):
41
- env: str = ''
42
  nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
43
  edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
44
 
@@ -52,7 +58,9 @@ def save(ws: Workspace, path: str):
52
  j = ws.model_dump_json(indent=2)
53
  dirname, basename = os.path.split(path)
54
  # Create temp file in the same directory to make sure it's on the same filesystem.
55
- with tempfile.NamedTemporaryFile('w', prefix=f'.{basename}.', dir=dirname, delete_on_close=False) as f:
 
 
56
  f.write(j)
57
  f.close()
58
  os.replace(f.name, path)
@@ -76,22 +84,13 @@ def _update_metadata(ws):
76
  if node.id in done:
77
  continue
78
  data = node.data
79
- if node.parentId is None:
80
- op = catalog.get(data.title)
81
- elif node.parentId not in nodes:
82
- data.error = f'Parent not found: {node.parentId}'
83
- done.add(node.id)
84
- continue
85
- elif node.parentId in done:
86
- op = nodes[node.parentId].data.meta.sub_nodes[data.title]
87
- else:
88
- continue
89
  if op:
90
  data.meta = op
91
  node.type = op.type
92
- if data.error == 'Unknown operation.':
93
  data.error = None
94
  else:
95
- data.error = 'Unknown operation.'
96
  done.add(node.id)
97
  return ws
 
1
+ """For working with LynxKite workspaces."""
2
+
3
  from typing import Optional
4
  import dataclasses
5
  import os
 
7
  import tempfile
8
  from . import ops
9
 
10
+
11
  class BaseConfig(pydantic.BaseModel):
12
  model_config = pydantic.ConfigDict(
13
+ extra="allow",
14
  )
15
 
16
+
17
  class Position(BaseConfig):
18
  x: float
19
  y: float
20
 
21
+
22
  class WorkspaceNodeData(BaseConfig):
23
  title: str
24
  params: dict
 
27
  # Also contains a "meta" field when going out.
28
  # This is ignored when coming back from the frontend.
29
 
30
+
31
  class WorkspaceNode(BaseConfig):
32
  id: str
33
  type: str
34
  data: WorkspaceNodeData
35
  position: Position
36
+
37
 
38
  class WorkspaceEdge(BaseConfig):
39
  id: str
 
42
  sourceHandle: str
43
  targetHandle: str
44
 
45
+
46
  class Workspace(BaseConfig):
47
+ env: str = ""
48
  nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
49
  edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
50
 
 
58
  j = ws.model_dump_json(indent=2)
59
  dirname, basename = os.path.split(path)
60
  # Create temp file in the same directory to make sure it's on the same filesystem.
61
+ with tempfile.NamedTemporaryFile(
62
+ "w", prefix=f".{basename}.", dir=dirname, delete_on_close=False
63
+ ) as f:
64
  f.write(j)
65
  f.close()
66
  os.replace(f.name, path)
 
84
  if node.id in done:
85
  continue
86
  data = node.data
87
+ op = catalog.get(data.title)
 
 
 
 
 
 
 
 
 
88
  if op:
89
  data.meta = op
90
  node.type = op.type
91
+ if data.error == "Unknown operation.":
92
  data.error = None
93
  else:
94
+ data.error = "Unknown operation."
95
  done.add(node.id)
96
  return ws
web/.gitignore CHANGED
@@ -11,3 +11,14 @@ node_modules
11
  dist
12
  dist-ssr
13
  *.local
 
 
 
 
 
 
 
 
 
 
 
 
11
  dist
12
  dist-ssr
13
  *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
web/.vscode/extensions.json DELETED
@@ -1,3 +0,0 @@
1
- {
2
- "recommendations": ["svelte.svelte-vscode"]
3
- }
 
 
 
 
web/README.md CHANGED
@@ -1,27 +1,7 @@
1
- # Vite Svelte Flow Template
2
 
3
- This template creates a very basic [Svelte Flow](https://svelteflow.dev) app with [Vite](https://vite.dev).
4
 
5
- ## Get it!
6
 
7
- ```sh
8
- npx degit xyflow/vite-svelte-flow-template app-name
9
- ```
10
-
11
- ## Installation
12
-
13
- ```sh
14
- npm install
15
- ```
16
-
17
- ## Development
18
-
19
- ```sh
20
- npm run dev
21
- ```
22
-
23
- ## Build
24
-
25
- ```sh
26
- npm run build
27
- ```
 
1
+ To set up:
2
 
3
+ npm i
4
 
5
+ To start dev server:
6
 
7
+ npm run dev
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/eslint.config.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+
7
+ export default tseslint.config(
8
+ { ignores: ['dist'] },
9
+ {
10
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
11
+ files: ['**/*.{ts,tsx}'],
12
+ languageOptions: {
13
+ ecmaVersion: 2020,
14
+ globals: globals.browser,
15
+ },
16
+ plugins: {
17
+ 'react-hooks': reactHooks,
18
+ 'react-refresh': reactRefresh,
19
+ },
20
+ rules: {
21
+ ...reactHooks.configs.recommended.rules,
22
+ 'react-refresh/only-export-components': [
23
+ 'warn',
24
+ { allowConstantExport: true },
25
+ ],
26
+ },
27
+ },
28
+ )
web/index.html CHANGED
@@ -2,12 +2,12 @@
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
5
- <link rel="icon" type="image/png" href="/favicon.ico" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>LynxKite 2024</title>
8
  </head>
9
  <body>
10
- <div id="app"></div>
11
- <script type="module" src="/src/main.ts"></script>
12
  </body>
13
  </html>
 
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/src/assets/favicon.ico" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>LynxKite 2025</title>
8
  </head>
9
  <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
  </body>
13
  </html>
web/package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
web/package.json CHANGED
@@ -1,37 +1,48 @@
1
  {
2
- "name": "vite-svelte-flow-template",
3
  "private": true,
4
  "version": "0.0.0",
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
8
- "build": "vite build",
9
- "preview": "vite preview",
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
  }
 
1
  {
2
+ "name": "lynxkite",
3
  "private": true,
4
  "version": "0.0.0",
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  },
12
  "dependencies": {
13
+ "@iconify-json/tabler": "^1.2.10",
14
+ "@svgr/core": "^8.1.0",
15
+ "@svgr/plugin-jsx": "^8.1.0",
16
+ "@syncedstore/core": "^0.6.0",
17
+ "@syncedstore/react": "^0.6.0",
18
+ "@types/node": "^22.10.1",
19
+ "@xyflow/react": "^12.3.5",
20
+ "daisyui": "^4.12.20",
21
+ "echarts": "^5.5.1",
22
+ "fuse.js": "^7.0.0",
23
+ "react": "^18.3.1",
24
+ "react-dom": "^18.3.1",
25
+ "react-markdown": "^9.0.1",
26
+ "react-router-dom": "^7.0.2",
27
+ "swr": "^2.2.5",
28
+ "unplugin-icons": "^0.21.0",
29
+ "y-websocket": "^2.0.4",
30
+ "yjs": "^13.6.20"
31
+ },
32
+ "devDependencies": {
33
+ "@eslint/js": "^9.15.0",
34
+ "@types/react": "^18.3.14",
35
+ "@types/react-dom": "^18.3.2",
36
+ "@vitejs/plugin-react-swc": "^3.5.0",
37
+ "autoprefixer": "^10.4.20",
38
+ "eslint": "^9.15.0",
39
+ "eslint-plugin-react-hooks": "^5.0.0",
40
+ "eslint-plugin-react-refresh": "^0.4.14",
41
+ "globals": "^15.12.0",
42
+ "postcss": "^8.4.49",
43
+ "tailwindcss": "^3.4.16",
44
+ "typescript": "~5.6.2",
45
+ "typescript-eslint": "^8.15.0",
46
+ "vite": "^6.0.1"
47
  }
48
  }
web/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
web/src/App.svelte DELETED
@@ -1,22 +0,0 @@
1
- <script lang="ts">
2
- import Directory from './Directory.svelte';
3
- import Workspace from './Workspace.svelte';
4
- let page = '';
5
- let parameters = {};
6
- function onHashChange() {
7
- const parts = location.hash.split('?');
8
- page = parts[0].substring(1);
9
- parameters = {};
10
- if (parts.length > 1) {
11
- parameters = Object.fromEntries(new URLSearchParams(parts[1]));
12
- }
13
- }
14
- onHashChange();
15
- </script>
16
-
17
- <svelte:window on:hashchange={onHashChange} />
18
- {#if page === 'edit'}
19
- <Workspace {...parameters} />
20
- {:else}
21
- <Directory {...parameters} />
22
- {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/Directory.svelte DELETED
@@ -1,177 +0,0 @@
1
- <script lang="ts">
2
- // The directory browser.
3
- import logo from './assets/logo.png';
4
- import Home from 'virtual:icons/tabler/home'
5
- import Folder from 'virtual:icons/tabler/folder'
6
- import FolderPlus from 'virtual:icons/tabler/folder-plus'
7
- import File from 'virtual:icons/tabler/file'
8
- import FilePlus from 'virtual:icons/tabler/file-plus'
9
-
10
- export let path = '';
11
- async function fetchList(path) {
12
- const encodedPath = encodeURIComponent(path || '');
13
- const res = await fetch(`/api/dir/list?path=${encodedPath}`);
14
- const j = await res.json();
15
- return j;
16
- }
17
- $: list = fetchList(path);
18
- function link(item) {
19
- if (item.type === 'directory') {
20
- return `#dir?path=${item.name}`;
21
- } else {
22
- return `#edit?path=${item.name}`;
23
- }
24
- }
25
- function shortName(item) {
26
- return item.name.split('/').pop();
27
- }
28
- function newName(list) {
29
- let i = 0;
30
- while (true) {
31
- const name = `Untitled${i ? ` ${i}` : ''}`;
32
- if (!list.find(item => item.name === name)) {
33
- return name;
34
- }
35
- i++;
36
- }
37
- }
38
- function newWorkspaceIn(path, list) {
39
- const pathSlash = path ? `${path}/` : '';
40
- return `#edit?path=${pathSlash}${newName(list)}`;
41
- }
42
- async function newFolderIn(path, list) {
43
- const pathSlash = path ? `${path}/` : '';
44
- const name = newName(list);
45
- const res = await fetch(`/api/dir/mkdir`, {
46
- method: 'POST',
47
- headers: { 'Content-Type': 'application/json' },
48
- body: JSON.stringify({ path: pathSlash + name }),
49
- });
50
- list = await res.json();
51
- }
52
- </script>
53
-
54
- <div class="directory-page">
55
- <div class="logo">
56
- <a href="https://lynxkite.com/"><img src="{logo}" class="logo-image"></a>
57
- <div class="tagline">The Complete Graph Data Science Platform</div>
58
- </div>
59
- <div class="entry-list">
60
- {#await list}
61
- <div class="loading spinner-border" role="status">
62
- <span class="visually-hidden">Loading...</span>
63
- </div>
64
- {:then list}
65
- <div class="actions">
66
- <a href="{newWorkspaceIn(path, list)}"><FilePlus /> New workspace</a>
67
- <a href on:click="{newFolderIn(path, list)}"><FolderPlus /> New folder</a>
68
- </div>
69
- {#if path} <div class="breadcrumbs"><a href="#dir"><Home /></a> {path} </div> {/if}
70
- {#each list as item}
71
- <a class="entry" href={link(item)}>
72
- {#if item.type === 'directory'}
73
- <Folder />
74
- {:else}
75
- <File />
76
- {/if}
77
- {shortName(item)}
78
- </a>
79
- {/each}
80
- {:catch error}
81
- <p style="color: red">{error.message}</p>
82
- {/await}
83
- </div>
84
- </div>
85
-
86
- <style>
87
- .entry-list {
88
- width: 100%;
89
- margin: 10px auto;
90
- background-color: white;
91
- border-radius: 10px;
92
- box-shadow: 0px 2px 4px;
93
- padding: 0 0 10px 0;
94
- }
95
- @media (min-width: 768px) {
96
- .entry-list {
97
- width: 768px;
98
- }
99
- }
100
- @media (min-width: 960px) {
101
- .entry-list {
102
- width: 80%;
103
- }
104
- }
105
-
106
- .logo {
107
- margin: 0;
108
- padding-top: 50px;
109
- text-align: center;
110
- }
111
- .logo-image {
112
- max-width: 50%;
113
- }
114
- .tagline {
115
- color: #39bcf3;
116
- font-size: 14px;
117
- font-weight: 500;
118
- }
119
- @media (min-width: 1400px) {
120
- .tagline {
121
- font-size: 18px;
122
- }
123
- }
124
-
125
- .actions {
126
- display: flex;
127
- justify-content: space-evenly;
128
- padding: 5px;
129
- }
130
- .actions a {
131
- padding: 2px 10px;
132
- border-radius: 5px;
133
- }
134
- .actions a:hover {
135
- background: #39bcf3;
136
- color: white;
137
- }
138
-
139
- .breadcrumbs {
140
- padding-left: 10px;
141
- font-size: 20px;
142
- background: #002a4c20;
143
- }
144
- .breadcrumbs a:hover {
145
- color: #39bcf3;
146
- }
147
- .entry-list .entry {
148
- display: block;
149
- border-bottom: 1px solid whitesmoke;
150
- padding-left: 10px;
151
- color: #004165;
152
- cursor: pointer;
153
- user-select: none;
154
- text-decoration: none;
155
- }
156
- .entry-list .open .entry,
157
- .entry-list .entry:hover,
158
- .entry-list .entry:focus {
159
- background: #39bcf3;
160
- color: white;
161
- }
162
- .entry-list .entry:last-child {
163
- border-bottom: none;
164
- }
165
- .directory-page {
166
- background: #002a4c;
167
- height: 100vh;
168
- }
169
- a {
170
- color: black;
171
- text-decoration: none;
172
- }
173
- .loading {
174
- color: #39bcf3;
175
- margin: 10px;
176
- }
177
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/Directory.tsx ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The directory browser.
2
+ import { useParams } from "react-router";
3
+ import useSWR from 'swr'
4
+
5
+ import logo from './assets/logo.png';
6
+ // @ts-ignore
7
+ import Home from '~icons/tabler/home'
8
+ // @ts-ignore
9
+ import Folder from '~icons/tabler/folder'
10
+ // @ts-ignore
11
+ import FolderPlus from '~icons/tabler/folder-plus'
12
+ // @ts-ignore
13
+ import File from '~icons/tabler/file'
14
+ // @ts-ignore
15
+ import FilePlus from '~icons/tabler/file-plus'
16
+
17
+ const fetcher = (url: string) => fetch(url).then((res) => res.json());
18
+
19
+ export default function () {
20
+ const { path } = useParams();
21
+ const encodedPath = encodeURIComponent(path || '');
22
+ const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher)
23
+ function link(item: any) {
24
+ if (item.type === 'directory') {
25
+ return `/dir/${item.name}`;
26
+ } else {
27
+ return `/edit/${item.name}`;
28
+ }
29
+ }
30
+ function shortName(item: any) {
31
+ return item.name.split('/').pop();
32
+ }
33
+ function newName(list: any[]) {
34
+ let i = 0;
35
+ while (true) {
36
+ const name = `Untitled${i ? ` ${i}` : ''}`;
37
+ if (!list.find(item => item.name === name)) {
38
+ return name;
39
+ }
40
+ i++;
41
+ }
42
+ }
43
+ function newWorkspaceIn(path: string, list: any[]) {
44
+ const pathSlash = path ? `${path}/` : '';
45
+ return `/edit/${pathSlash}${newName(list)}`;
46
+ }
47
+ async function newFolderIn(path: string, list: any[]) {
48
+ const pathSlash = path ? `${path}/` : '';
49
+ const name = newName(list);
50
+ const res = await fetch(`/api/dir/mkdir`, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({ path: pathSlash + name }),
54
+ });
55
+ list = await res.json();
56
+ }
57
+
58
+ return (
59
+ <div className="directory">
60
+ <div className="logo">
61
+ <a href="https://lynxkite.com/"><img src={logo} className="logo-image" alt="LynxKite logo" /></a>
62
+ <div className="tagline">The Complete Graph Data Science Platform</div>
63
+ </div>
64
+ <div className="entry-list">
65
+ {list.error && <p className="error">{list.error.message}</p>}
66
+ {list.isLoading &&
67
+ <div className="loading spinner-border" role="status">
68
+ <span className="visually-hidden">Loading...</span>
69
+ </div>}
70
+ {list.data &&
71
+ <>
72
+ <div className="actions">
73
+ <a href={newWorkspaceIn(path || "", list.data)}><FilePlus /> New workspace</a>
74
+ <a href="" onClick={() => newFolderIn(path || "", list.data)}><FolderPlus /> New folder</a>
75
+ </div>
76
+ {path && <div className="breadcrumbs"><a href="/dir/"><Home /></a> {path} </div>}
77
+ {list.data.map((item: any) =>
78
+ <a key={link(item)} className="entry" href={link(item)}>
79
+ {item.type === 'directory' ? <Folder /> : <File />}
80
+ {shortName(item)}
81
+ </a>
82
+ )}
83
+ </>
84
+ }
85
+ </div>
86
+ </div>
87
+ );
88
+ }
web/src/EnvironmentSelector.svelte DELETED
@@ -1,14 +0,0 @@
1
- <script lang="ts">
2
- export let options;
3
- export let value;
4
- export let onChange;
5
- </script>
6
-
7
- <select class="form-select form-select-sm"
8
- value={value}
9
- on:change={(evt) => onChange(evt.currentTarget.value)}
10
- >
11
- {#each options as option}
12
- <option value={option}>{option}</option>
13
- {/each}
14
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/LynxKiteFlow.svelte DELETED
@@ -1,230 +0,0 @@
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,
14
- type Connection,
15
- type NodeTypes,
16
- } from '@xyflow/svelte';
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';
24
- import NodeWithTableView from './NodeWithTableView.svelte';
25
- import NodeWithSubFlow from './NodeWithSubFlow.svelte';
26
- import NodeWithArea from './NodeWithArea.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,
57
- visualization: NodeWithVisualization,
58
- image: NodeWithImage,
59
- table_view: NodeWithTableView,
60
- sub_flow: NodeWithSubFlow,
61
- area: NodeWithArea,
62
- };
63
-
64
- const nodes = writable<Node[]>([]);
65
- const edges = writable<Edge[]>([]);
66
-
67
- function closeNodeSearch() {
68
- nodeSearchSettings = undefined;
69
- }
70
- function toggleNodeSearch({ detail: { event } }) {
71
- if (nodeSearchSettings) {
72
- closeNodeSearch();
73
- return;
74
- }
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 () => {
111
- const res = await fetch('/api/catalog');
112
- return res.json();
113
- }, {staleTime: 60000, retry: false});
114
-
115
- let nodeSearchSettings: {
116
- pos: XYPosition,
117
- boxes: any[],
118
- parentId: string,
119
- };
120
-
121
- function nodeClick(e) {
122
- const node = e.detail.node;
123
- const meta = node.data.meta;
124
- if (!meta) return;
125
- const sub_nodes = meta.sub_nodes;
126
- if (!sub_nodes) return;
127
- const event = e.detail.event;
128
- if (event.target.classList.contains('title')) return;
129
- nodeSearchSettings = {
130
- pos: { x: event.clientX, y: event.clientY },
131
- boxes: sub_nodes,
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>
164
- {path}
165
- </div>
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>
176
- <a href="#dir?path={parentDir}"><ArrowBack /></a>
177
- </div>
178
- </div>
179
- <div style:height="100%">
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}
188
- defaultEdgeOptions={{ markerEnd: { type: MarkerType.Arrow } }}
189
- >
190
- <Controls />
191
- <MiniMap />
192
- {#if nodeSearchSettings}
193
- <NodeSearch pos={nodeSearchSettings.pos} boxes={nodeSearchSettings.boxes} on:cancel={closeNodeSearch} on:add={addNode} />
194
- {/if}
195
- </SvelteFlow>
196
- </div>
197
- {/if}
198
- </div>
199
-
200
- <style>
201
- .top-bar {
202
- display: flex;
203
- justify-content: space-between;
204
- background: oklch(30% 0.13 230);
205
- color: white;
206
- }
207
- .ws-name {
208
- font-size: 1.5em;
209
- }
210
- .ws-name img {
211
- height: 1.5em;
212
- vertical-align: middle;
213
- margin: 4px;
214
- }
215
- .page {
216
- display: flex;
217
- flex-direction: column;
218
- height: 100vh;
219
- }
220
-
221
- .tools {
222
- display: flex;
223
- align-items: center;
224
- }
225
- .tools a {
226
- color: oklch(75% 0.13 230);
227
- font-size: 1.5em;
228
- padding: 0 10px;
229
- }
230
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/LynxKiteNode.svelte DELETED
@@ -1,174 +0,0 @@
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
-
6
- const { updateNodeData } = useSvelteFlow();
7
- const updateNodeInternals = useUpdateNodeInternals();
8
- type $$Props = NodeProps;
9
-
10
- export let nodeStyle = '';
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;
21
- export let isConnectable: $$Props['isConnectable'] = undefined; isConnectable;
22
- export let zIndex: $$Props['zIndex'] = undefined; zIndex;
23
- export let width: $$Props['width'] = undefined; width;
24
- export let height: $$Props['height'] = undefined; height;
25
- export let dragging: $$Props['dragging']; dragging;
26
- export let targetPosition: $$Props['targetPosition'] = undefined; targetPosition;
27
- export let sourcePosition: $$Props['sourcePosition'] = undefined; sourcePosition;
28
- export let positionAbsoluteX: $$Props['positionAbsoluteX'] = undefined; positionAbsoluteX;
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) {
43
- return n ? n + 'px' : undefined;
44
- }
45
- function getHandles(inputs, outputs) {
46
- const handles: {
47
- position: 'top' | 'bottom' | 'left' | 'right',
48
- name: string,
49
- index: number,
50
- offsetPercentage: number,
51
- showLabel: boolean,
52
- }[] = [];
53
- for (const e of Object.values(inputs)) {
54
- handles.push({ ...e, type: 'target' });
55
- }
56
- for (const e of Object.values(outputs)) {
57
- handles.push({ ...e, type: 'source' });
58
- }
59
- const counts = { top: 0, bottom: 0, left: 0, right: 0 };
60
- for (const e of handles) {
61
- e.index = counts[e.position];
62
- counts[e.position]++;
63
- }
64
- for (const e of handles) {
65
- e.offsetPercentage = 100 * (e.index + 1) / (counts[e.position] + 1);
66
- const simpleHorizontal = counts.top === 0 && counts.bottom === 0 && handles.length <= 2;
67
- const simpleVertical = counts.left === 0 && counts.right === 0 && handles.length <= 2;
68
- e.showLabel = !simpleHorizontal && !simpleVertical;
69
- }
70
- return handles;
71
- }
72
- $: handles = getHandles(data.meta?.inputs || {}, data.meta?.outputs || {});
73
- const handleOffsetDirection = { top: 'left', bottom: 'left', left: 'top', right: 'top' };
74
- </script>
75
-
76
- <div class="node-container" class:expanded={expanded}
77
- style:width={asPx(width)} style:height={asPx(expanded ? height : undefined)} style={containerStyle}>
78
- <div class="lynxkite-node" style={nodeStyle}>
79
- <div class="title" on:click={titleClicked}>
80
- {data.title}
81
- {#if data.error}<span class="title-icon">⚠️</span>{/if}
82
- {#if !expanded}<span class="title-icon">⋯</span>{/if}
83
- </div>
84
- {#if expanded}
85
- {#if data.error}
86
- <div class="error">{data.error}</div>
87
- {/if}
88
- <slot />
89
- {/if}
90
- {#each handles as handle}
91
- <Handle
92
- id={handle.name} type={handle.type} position={handle.position}
93
- style="{handleOffsetDirection[handle.position]}: {handle.offsetPercentage}%">
94
- {#if handle.showLabel}<span class="handle-name">{handle.name.replace(/_/g, " ")}</span>{/if}
95
- </Handle>
96
- {/each}
97
- </div>
98
- {#if expanded}
99
- <NodeResizeControl
100
- minWidth={100}
101
- minHeight={50}
102
- style="background: transparent; border: none;"
103
- onResizeStart={() => updateNodeData(id, { beingResized: true })}
104
- onResizeEnd={() => updateNodeData(id, { beingResized: false })}
105
- >
106
- <ChevronDownRight class="node-resizer" />
107
- </NodeResizeControl>
108
- {/if}
109
- </div>
110
-
111
- <style>
112
- .error {
113
- background: #ffdddd;
114
- padding: 8px;
115
- font-size: 12px;
116
- }
117
- .title-icon {
118
- margin-left: 5px;
119
- float: right;
120
- }
121
- .node-container {
122
- padding: 8px;
123
- position: relative;
124
- }
125
- .lynxkite-node {
126
- box-shadow: 0px 5px 50px 0px rgba(0, 0, 0, 0.3);
127
- border-radius: 4px;
128
- background: white;
129
- }
130
- .expanded .lynxkite-node {
131
- overflow-y: auto;
132
- height: 100%;
133
- }
134
- .title {
135
- background: oklch(75% 0.2 55);
136
- font-weight: bold;
137
- padding: 8px;
138
- }
139
- .handle-name {
140
- font-size: 10px;
141
- color: black;
142
- letter-spacing: 0.05em;
143
- text-align: right;
144
- white-space: nowrap;
145
- position: absolute;
146
- top: -5px;
147
- backdrop-filter: blur(10px);
148
- padding: 2px 8px;
149
- border-radius: 4px;
150
- visibility: hidden;
151
- }
152
- :global(.left) .handle-name {
153
- right: 20px;
154
- }
155
- :global(.right) .handle-name {
156
- left: 20px;
157
- }
158
- :global(.top) .handle-name,
159
- :global(.bottom) .handle-name {
160
- top: -5px;
161
- left: 5px;
162
- backdrop-filter: none;
163
- }
164
- .node-container:hover .handle-name {
165
- visibility: visible;
166
- }
167
- :global(.node-resizer) {
168
- position: absolute;
169
- bottom: 8px;
170
- right: 8px;
171
- cursor: nwse-resize;
172
- color: var(--bs-border-color);
173
- }
174
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/NodeParameter.svelte DELETED
@@ -1,69 +0,0 @@
1
- <script lang="ts">
2
- export let name: string;
3
- export let value;
4
- export let meta;
5
- export let onChange;
6
- const BOOLEAN = "<class 'bool'>";
7
- </script>
8
-
9
- <label class="param">
10
- {#if meta?.type?.format === 'collapsed'}
11
- <span class="param-name">{name.replace(/_/g, ' ')}</span>
12
- <button class="collapsed-param form-control form-control-sm">
13
-
14
- </button>
15
- {:else if meta?.type?.format === 'textarea'}
16
- <span class="param-name">{name.replace(/_/g, ' ')}</span>
17
- <textarea class="form-control form-control-sm"
18
- rows="6"
19
- value={value}
20
- on:change={(evt) => onChange(evt.currentTarget.value)}
21
- />
22
- {:else if meta?.type?.enum}
23
- <span class="param-name">{name.replace(/_/g, ' ')}</span>
24
- <select class="form-select form-select-sm"
25
- value={value || meta.type.enum[0]}
26
- on:change={(evt) => onChange(evt.currentTarget.value)}
27
- >
28
- {#each meta.type.enum as option}
29
- <option value={option}>{option}</option>
30
- {/each}
31
- </select>
32
- {:else if meta?.type?.type === BOOLEAN}
33
- <label class="form-check-label">
34
- <input class="form-check-input"
35
- type="checkbox"
36
- checked={value}
37
- on:change={(evt) => onChange(evt.currentTarget.checked)}
38
- />
39
- {name.replace(/_/g, ' ')}
40
- </label>
41
- {:else}
42
- <span class="param-name">{name.replace(/_/g, ' ')}</span>
43
- <input class="form-control form-control-sm"
44
- value={value}
45
- on:change={(evt) => onChange(evt.currentTarget.value)}
46
- />
47
- {/if}
48
- </label>
49
-
50
- <style>
51
- .param {
52
- padding: 4px 8px 4px 8px;
53
- display: block;
54
- }
55
- .param-name {
56
- display: block;
57
- font-size: 10px;
58
- letter-spacing: 0.05em;
59
- margin-left: 10px;
60
- background: var(--bs-border-color);
61
- width: fit-content;
62
- padding: 2px 8px;
63
- border-radius: 4px 4px 0 0;
64
- }
65
- .collapsed-param {
66
- min-height: 20px;
67
- line-height: 10px;
68
- }
69
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/NodeSearch.svelte DELETED
@@ -1,100 +0,0 @@
1
- <script lang="ts">
2
- import { createEventDispatcher, onMount } from 'svelte';
3
- import Fuse from 'fuse.js'
4
- const dispatch = createEventDispatcher();
5
- export let pos;
6
- export let boxes;
7
- let searchBox: HTMLInputElement;
8
- let hits = Object.values(boxes).map(box => ({item: box}));
9
- let selectedIndex = 0;
10
- onMount(() => searchBox.focus());
11
- $: fuse = new Fuse(Object.values(boxes), {
12
- keys: ['name']
13
- })
14
- function onInput() {
15
- hits = fuse.search(searchBox.value);
16
- selectedIndex = Math.max(0, Math.min(selectedIndex, hits.length - 1));
17
- }
18
- function onKeyDown(e) {
19
- if (e.key === 'ArrowDown') {
20
- e.preventDefault();
21
- selectedIndex = Math.min(selectedIndex + 1, hits.length - 1);
22
- } else if (e.key === 'ArrowUp') {
23
- e.preventDefault();
24
- selectedIndex = Math.max(selectedIndex - 1, 0);
25
- } else if (e.key === 'Enter') {
26
- addSelected();
27
- } else if (e.key === 'Escape') {
28
- dispatch('cancel');
29
- }
30
- }
31
- function addSelected() {
32
- const node = {...hits[selectedIndex].item};
33
- delete node.sub_nodes;
34
- node.position = pos;
35
- dispatch('add', node);
36
- }
37
- async function lostFocus(e) {
38
- // If it's a click on a result, let the click handler handle it.
39
- if (e.relatedTarget && e.relatedTarget.closest('.node-search')) return;
40
- dispatch('cancel');
41
- }
42
-
43
- </script>
44
-
45
- <div class="node-search" style="top: {pos.y}px; left: {pos.x}px;">
46
- <input
47
- bind:this={searchBox}
48
- on:input={onInput}
49
- on:keydown={onKeyDown}
50
- on:focusout={lostFocus}
51
- placeholder="Search for box">
52
- <div class="matches">
53
- {#each hits as box, index}
54
- <div
55
- tabindex="0"
56
- on:focus={() => selectedIndex = index}
57
- on:mouseenter={() => selectedIndex = index}
58
- on:click={addSelected}
59
- class="search-result"
60
- class:selected={index == selectedIndex}>
61
- {box.item.name}
62
- </div>
63
- {/each}
64
- </div>
65
- </div>
66
-
67
- <style>
68
- input {
69
- width: calc(100% - 26px);
70
- font-size: 20px;
71
- padding: 8px;
72
- border-radius: 4px;
73
- border: 1px solid #eee;
74
- margin: 4px;
75
- }
76
- .search-result {
77
- padding: 4px;
78
- cursor: pointer;
79
- }
80
- .search-result.selected {
81
- background-color: oklch(75% 0.2 55);
82
- border-radius: 4px;
83
- }
84
- .node-search {
85
- position: fixed;
86
- width: 300px;
87
- z-index: 5;
88
- padding: 4px;
89
- border-radius: 4px;
90
- border: 1px solid #888;
91
- background-color: white;
92
- max-height: -webkit-fill-available;
93
- max-height: -moz-available;
94
- display: flex;
95
- flex-direction: column;
96
- }
97
- .matches {
98
- overflow-y: auto;
99
- }
100
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/NodeWithArea.svelte DELETED
@@ -1,60 +0,0 @@
1
- <script lang="ts">
2
- import { type NodeProps, useSvelteFlow } from '@xyflow/svelte';
3
- import NodeParameter from './NodeParameter.svelte';
4
-
5
- type $$Props = NodeProps;
6
-
7
- export let containerStyle = '';
8
- export let id: $$Props['id']; id;
9
- export let data: $$Props['data'];
10
- export let dragHandle: $$Props['dragHandle'] = undefined; dragHandle;
11
- export let type: $$Props['type'] = undefined; type;
12
- export let selected: $$Props['selected'] = undefined; selected;
13
- export let isConnectable: $$Props['isConnectable'] = undefined; isConnectable;
14
- export let zIndex: $$Props['zIndex'] = undefined; zIndex;
15
- export let width: $$Props['width'] = undefined; width;
16
- export let height: $$Props['height'] = undefined; height;
17
- export let dragging: $$Props['dragging']; dragging;
18
- export let targetPosition: $$Props['targetPosition'] = undefined; targetPosition;
19
- export let sourcePosition: $$Props['sourcePosition'] = undefined; sourcePosition;
20
- export let positionAbsoluteX: $$Props['positionAbsoluteX'] = undefined; positionAbsoluteX;
21
- export let positionAbsoluteY: $$Props['positionAbsoluteY'] = undefined; positionAbsoluteY;
22
-
23
- function asPx(n: number | undefined) {
24
- return n ? n + 'px' : undefined;
25
- }
26
- const { updateNodeData } = useSvelteFlow();
27
- $: metaParams = data.meta?.params;
28
- </script>
29
-
30
- <div class="area" style:width={asPx(width)} style:height={asPx(height)} style={containerStyle}>
31
- <div class="title">
32
- {data.title}
33
- </div>
34
- {#each Object.entries(data.params) as [name, value]}
35
- <NodeParameter
36
- {name}
37
- {value}
38
- meta={metaParams?.[name]}
39
- onChange={(newValue) => updateNodeData(id, { params: { ...data.params, [name]: newValue } })}
40
- />
41
- {/each}
42
- </div>
43
-
44
- <style>
45
- .area {
46
- border-radius: 10px;
47
- border: 3px dashed oklch(75% 0.2 55);
48
- z-index: 0 !important;
49
- }
50
- .title {
51
- color: oklch(75% 0.2 55);
52
- width: 100%;
53
- text-align: center;
54
- top: -1.5em;
55
- position: absolute;
56
- -webkit-text-stroke: 5px white;
57
- paint-order: stroke fill;
58
- font-weight: bold;
59
- }
60
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/NodeWithImage.svelte DELETED
@@ -1,14 +0,0 @@
1
- <script lang="ts">
2
- import { type NodeProps } from '@xyflow/svelte';
3
- import NodeWithParams from './NodeWithParams.svelte';
4
- type $$Props = NodeProps;
5
- export let data: $$Props['data'];
6
- </script>
7
-
8
- <NodeWithParams {...$$props}>
9
- {#if data.display}
10
- <img src={data.display}/>
11
- {/if}
12
- </NodeWithParams>
13
- <style>
14
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/NodeWithParams.svelte DELETED
@@ -1,30 +0,0 @@
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 />
30
- </LynxKiteNode>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/NodeWithSubFlow.svelte DELETED
@@ -1,37 +0,0 @@
1
- <script lang="ts">
2
- import { type NodeProps, useNodes } from '@xyflow/svelte';
3
- import LynxKiteNode from './LynxKiteNode.svelte';
4
- type $$Props = NodeProps;
5
- const nodes = useNodes();
6
- export let id: $$Props['id'];
7
- export let data: $$Props['data'];
8
- let isExpanded = true;
9
- function onToggle({ expanded }) {
10
- isExpanded = expanded;
11
- nodes.update((n) =>
12
- n.map((node) =>
13
- node.parentId === id
14
- ? { ...node, hidden: !expanded }
15
- : node));
16
- }
17
- function computeSize(nodes) {
18
- let width = 200;
19
- let height = 200;
20
- for (const node of nodes) {
21
- if (node.parentId === id) {
22
- width = Math.max(width, node.position.x + 300);
23
- height = Math.max(height, node.position.y + 200);
24
- }
25
- }
26
- return { width, height };
27
- }
28
- $: ({ width, height } = computeSize($nodes));
29
- </script>
30
-
31
- <LynxKiteNode
32
- {...$$props}
33
- width={isExpanded && width} height={isExpanded && height}
34
- nodeStyle="background: transparent;" containerStyle="max-width: none; max-height: none;" {onToggle}>
35
- </LynxKiteNode>
36
- <style>
37
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/NodeWithTableView.svelte DELETED
@@ -1,58 +0,0 @@
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;
15
- }
16
- if (Array.isArray(v)) {
17
- return v.map(toMD).join('\n\n');
18
- }
19
- return JSON.stringify(v);
20
- }
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}
29
- <Table columns={df.columns} data={df.data} />
30
- {:else}
31
- <dl>
32
- {#each df.columns as c, i}
33
- <dt>{c}</dt>
34
- <dd><SvelteMarkdown source={toMD(df.data[0][i])} /></dd>
35
- {/each}
36
- </dl>
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>
44
- {/if}
45
- {/each}
46
- {/if}
47
- </LynxKiteNode>
48
- <style>
49
- .df-head {
50
- font-weight: bold;
51
- padding: 8px;
52
- background: #f0f0f0;
53
- cursor: pointer;
54
- }
55
- dl {
56
- margin: 10px;
57
- }
58
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/NodeWithVisualization.svelte DELETED
@@ -1,17 +0,0 @@
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 DELETED
@@ -1,22 +0,0 @@
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/Workspace.svelte DELETED
@@ -1,14 +0,0 @@
1
- <script lang="ts">
2
- // This is the whole LynxKite workspace editor page.
3
- import { QueryClient, QueryClientProvider } from '@sveltestack/svelte-query'
4
- import { SvelteFlowProvider } from '@xyflow/svelte';
5
- import LynxKiteFlow from './LynxKiteFlow.svelte';
6
- export let path = '';
7
- const queryClient = new QueryClient()
8
- </script>
9
-
10
- <QueryClientProvider client={queryClient}>
11
- <SvelteFlowProvider>
12
- <LynxKiteFlow path={path} />
13
- </SvelteFlowProvider>
14
- </QueryClientProvider>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/apiTypes.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+ /**
4
+ /* This file was automatically generated from pydantic models by running pydantic2ts.
5
+ /* Do not modify it by hand - just update the pydantic models and then re-run the script
6
+ */
7
+
8
+ export interface BaseConfig {
9
+ [k: string]: unknown;
10
+ }
11
+ export interface Position {
12
+ x: number;
13
+ y: number;
14
+ [k: string]: unknown;
15
+ }
16
+ export interface Workspace {
17
+ env?: string;
18
+ nodes?: WorkspaceNode[];
19
+ edges?: WorkspaceEdge[];
20
+ [k: string]: unknown;
21
+ }
22
+ export interface WorkspaceNode {
23
+ id: string;
24
+ type: string;
25
+ data: WorkspaceNodeData;
26
+ position: Position;
27
+ [k: string]: unknown;
28
+ }
29
+ export interface WorkspaceNodeData {
30
+ title: string;
31
+ params: {
32
+ [k: string]: unknown;
33
+ };
34
+ display?: unknown;
35
+ error?: string | null;
36
+ [k: string]: unknown;
37
+ }
38
+ export interface WorkspaceEdge {
39
+ id: string;
40
+ source: string;
41
+ target: string;
42
+ sourceHandle: string;
43
+ targetHandle: string;
44
+ [k: string]: unknown;
45
+ }
web/src/app.scss DELETED
@@ -1,38 +0,0 @@
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
- }
7
-
8
- path.svelte-flow__edge-path {
9
- stroke-width: 2;
10
- stroke: black;
11
- }
12
- .svelte-flow__edge.selected path.svelte-flow__edge-path {
13
- outline: var(--xy-selection-border, var(--xy-selection-border-default));
14
- outline-offset: 10px;
15
- border-radius: 1px;
16
- }
17
- .svelte-flow__handle {
18
- border-color: black;
19
- background: white;
20
- width: 10px;
21
- height: 10px;
22
- }
23
- .svelte-flow__arrowhead * {
24
- stroke: none;
25
- fill: black;
26
- }
27
- // We want the area node to be above the sub-flow node if its inside the sub-flow.
28
- // This will need some more thinking for a general solution.
29
- .svelte-flow__node-sub_flow {
30
- z-index: -20 !important;
31
- }
32
- .svelte-flow__node-area {
33
- z-index: -10 !important;
34
- }
35
- .selected .lynxkite-node {
36
- outline: var(--xy-selection-border, var(--xy-selection-border-default));
37
- outline-offset: 7.5px;
38
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/{public → src/assets}/favicon.ico RENAMED
File without changes
web/src/directory.css DELETED
@@ -1,10 +0,0 @@
1
- @media (min-width: 640px) {
2
- .directory {
3
- width: 100%;
4
- }
5
- }
6
-
7
- .directory {
8
- width: 800px;
9
- background: white;
10
- }
 
 
 
 
 
 
 
 
 
 
 
web/src/index.css ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
7
+ line-height: 1.5;
8
+ font-weight: 400;
9
+
10
+ font-synthesis: none;
11
+ text-rendering: optimizeLegibility;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+
15
+ background: #002a4c;
16
+ }
17
+
18
+ img,
19
+ svg {
20
+ display: inline-block;
21
+ }
22
+
23
+ body {
24
+ color: var(--foreground);
25
+ background: var(--background);
26
+ font-family: Arial, Helvetica, sans-serif;
27
+ }
28
+
29
+ .workspace {
30
+ background: white;
31
+ display: flex;
32
+ flex-direction: column;
33
+ height: 100vh;
34
+
35
+ .top-bar {
36
+ display: flex;
37
+ justify-content: space-between;
38
+ align-items: center;
39
+ background: #002a4c;
40
+
41
+ .ws-name {
42
+ font-size: 1.5em;
43
+ flex: 1;
44
+ color: white;
45
+ }
46
+
47
+ .logo img {
48
+ height: 2em;
49
+ vertical-align: middle;
50
+ margin: 4px;
51
+ }
52
+
53
+ .tools {
54
+ display: flex;
55
+ align-items: center;
56
+
57
+ a {
58
+ color: oklch(75% 0.13 230);
59
+ font-size: 1.5em;
60
+ padding: 0 10px;
61
+ }
62
+ }
63
+ }
64
+
65
+ .error {
66
+ background: #ffdddd;
67
+ padding: 8px;
68
+ font-size: 12px;
69
+ }
70
+
71
+ .title-icon {
72
+ margin-left: 5px;
73
+ float: right;
74
+ }
75
+
76
+ .node-container {
77
+ padding: 8px;
78
+ position: relative;
79
+ }
80
+
81
+ .lynxkite-node {
82
+ box-shadow: 0px 5px 50px 0px rgba(0, 0, 0, 0.3);
83
+ border-radius: 4px;
84
+ background: white;
85
+ }
86
+
87
+ .expanded .lynxkite-node {
88
+ overflow-y: auto;
89
+ height: 100%;
90
+ }
91
+
92
+ .lynxkite-node .title {
93
+ /* background: oklch(75% 0.2 55); */
94
+ font-weight: bold;
95
+ padding: 8px;
96
+ }
97
+
98
+ .handle-name {
99
+ font-size: 10px;
100
+ color: black;
101
+ letter-spacing: 0.05em;
102
+ text-align: right;
103
+ white-space: nowrap;
104
+ position: absolute;
105
+ top: -5px;
106
+ backdrop-filter: blur(10px);
107
+ padding: 2px 8px;
108
+ border-radius: 4px;
109
+ visibility: hidden;
110
+ }
111
+
112
+ .react-flow__handle-left .handle-name {
113
+ right: 20px;
114
+ }
115
+
116
+ .react-flow__handle-right .handle-name {
117
+ left: 20px;
118
+ }
119
+
120
+ .react-flow__handle-top .handle-name {
121
+ top: -20px;
122
+ left: 5px;
123
+ backdrop-filter: none;
124
+ }
125
+
126
+ .react-flow__handle-bottom .handle-name {
127
+ top: 0px;
128
+ left: 5px;
129
+ backdrop-filter: none;
130
+ }
131
+
132
+ .node-container:hover .handle-name {
133
+ visibility: visible;
134
+ }
135
+
136
+ .node-resizer {
137
+ position: absolute;
138
+ bottom: 8px;
139
+ right: 8px;
140
+ cursor: nwse-resize;
141
+ }
142
+
143
+ .lynxkite-node {
144
+ .param {
145
+ padding: 4px 8px 4px 8px;
146
+ display: block;
147
+ }
148
+
149
+ .param-name {
150
+ display: block;
151
+ font-size: 10px;
152
+ letter-spacing: 0.05em;
153
+ margin-left: 10px;
154
+ width: fit-content;
155
+ padding: 2px 8px;
156
+ border-radius: 4px 4px 0 0;
157
+ ;
158
+ }
159
+
160
+ .collapsed-param {
161
+ min-height: 20px;
162
+ line-height: 10px;
163
+ }
164
+ }
165
+
166
+ .node-search {
167
+ position: fixed;
168
+ width: 300px;
169
+ z-index: 5;
170
+ padding: 4px;
171
+ border-radius: 4px;
172
+ border: 1px solid #888;
173
+ background-color: white;
174
+ max-height: -webkit-fill-available;
175
+ max-height: -moz-available;
176
+ display: flex;
177
+ flex-direction: column;
178
+
179
+ input {
180
+ width: calc(100% - 26px);
181
+ font-size: 20px;
182
+ padding: 8px;
183
+ border-radius: 4px;
184
+ border: 1px solid #eee;
185
+ margin: 4px;
186
+ }
187
+
188
+ .search-result {
189
+ padding: 4px;
190
+ cursor: pointer;
191
+ }
192
+
193
+ .search-result.selected {
194
+ background-color: oklch(75% 0.2 55);
195
+ border-radius: 4px;
196
+ }
197
+
198
+ .matches {
199
+ overflow-y: auto;
200
+ }
201
+ }
202
+
203
+ .react-flow__node-table_view {
204
+ .df-head {
205
+ font-weight: bold;
206
+ padding: 8px;
207
+ background: #f0f0f0;
208
+ cursor: pointer;
209
+ }
210
+
211
+ dl {
212
+ margin: 10px;
213
+ }
214
+ }
215
+ }
216
+
217
+ .directory {
218
+ .entry-list {
219
+ width: 100%;
220
+ margin: 10px auto;
221
+ background-color: white;
222
+ border-radius: 10px;
223
+ box-shadow: 0px 2px 4px;
224
+ padding: 0 0 10px 0;
225
+ }
226
+
227
+ @media (min-width: 768px) {
228
+ .entry-list {
229
+ width: 768px;
230
+ }
231
+ }
232
+
233
+ @media (min-width: 960px) {
234
+ .entry-list {
235
+ width: 80%;
236
+ }
237
+ }
238
+
239
+ .logo {
240
+ margin: 0;
241
+ padding-top: 50px;
242
+ text-align: center;
243
+ }
244
+
245
+ .logo-image {
246
+ max-width: 50%;
247
+ }
248
+
249
+ .tagline {
250
+ color: #39bcf3;
251
+ font-size: 14px;
252
+ font-weight: 500;
253
+ }
254
+
255
+ @media (min-width: 1400px) {
256
+ .tagline {
257
+ font-size: 18px;
258
+ }
259
+ }
260
+
261
+ .actions {
262
+ display: flex;
263
+ justify-content: space-evenly;
264
+ padding: 5px;
265
+ }
266
+
267
+ .actions a {
268
+ padding: 2px 10px;
269
+ border-radius: 5px;
270
+ }
271
+
272
+ .actions a:hover {
273
+ background: #39bcf3;
274
+ color: white;
275
+ }
276
+
277
+ .breadcrumbs {
278
+ padding-left: 10px;
279
+ font-size: 20px;
280
+ background: #002a4c20;
281
+ }
282
+
283
+ .breadcrumbs a:hover {
284
+ color: #39bcf3;
285
+ }
286
+
287
+ .entry-list .entry {
288
+ display: block;
289
+ border-bottom: 1px solid whitesmoke;
290
+ padding-left: 10px;
291
+ color: #004165;
292
+ cursor: pointer;
293
+ user-select: none;
294
+ text-decoration: none;
295
+ }
296
+
297
+ .entry-list .open .entry,
298
+ .entry-list .entry:hover,
299
+ .entry-list .entry:focus {
300
+ background: #39bcf3;
301
+ color: white;
302
+ }
303
+
304
+ .entry-list .entry:last-child {
305
+ border-bottom: none;
306
+ }
307
+
308
+ a {
309
+ color: black;
310
+ text-decoration: none;
311
+ }
312
+
313
+ .loading {
314
+ color: #39bcf3;
315
+ margin: 10px;
316
+ }
317
+ }
318
+
319
+
320
+ path.react-flow__edge-path {
321
+ stroke-width: 2;
322
+ stroke: black;
323
+ }
324
+
325
+ .react-flow__edge.selected path.react-flow__edge-path {
326
+ outline: var(--xy-selection-border, var(--xy-selection-border-default));
327
+ outline-offset: 10px;
328
+ border-radius: 1px;
329
+ }
330
+
331
+ .react-flow__handle {
332
+ border-color: black;
333
+ background: white;
334
+ width: 10px;
335
+ height: 10px;
336
+ }
337
+
338
+ .react-flow__arrowhead * {
339
+ stroke: none;
340
+ fill: black;
341
+ }
342
+
343
+ .react-flow__node-area {
344
+ z-index: -10 !important;
345
+ }
346
+
347
+ .selected .lynxkite-node {
348
+ outline: var(--xy-selection-border, var(--xy-selection-border-default));
349
+ outline-offset: 7.5px;
350
+ }
web/src/main.ts DELETED
@@ -1,10 +0,0 @@
1
- import App from './App.svelte';
2
-
3
- import './app.scss';
4
- import * as bootstrap from 'bootstrap';
5
-
6
- const app = new App({
7
- target: document.getElementById('app')!,
8
- });
9
-
10
- export default app;
 
 
 
 
 
 
 
 
 
 
 
web/src/main.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import Directory from './Directory.tsx'
5
+ import Workspace from './workspace/Workspace.tsx'
6
+ import { BrowserRouter, Routes, Route } from "react-router";
7
+
8
+ createRoot(document.getElementById('root')!).render(
9
+ <StrictMode>
10
+ <BrowserRouter>
11
+ <Routes>
12
+ <Route path="/" element={<Directory />} />
13
+ <Route path="/dir" element={<Directory />} />
14
+ <Route path="/dir/:path" element={<Directory />} />
15
+ <Route path="/edit/:path" element={<Workspace />} />
16
+ </Routes>
17
+ </BrowserRouter>
18
+ </StrictMode>,
19
+ )
web/src/vite-env.d.ts CHANGED
@@ -1,2 +1 @@
1
- /// <reference types="svelte" />
2
  /// <reference types="vite/client" />
 
 
1
  /// <reference types="vite/client" />
web/src/workspace/EnvironmentSelector.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function EnvironmentSelector(props: { options: string[], value: string, onChange: (val: string) => void }) {
2
+ return (
3
+ <>
4
+ <select className="select w-full max-w-xs"
5
+ name="workspace-env"
6
+ value={props.value}
7
+ onChange={(evt) => props.onChange(evt.currentTarget.value)}
8
+ >
9
+ {props.options.map(option =>
10
+ <option key={option} value={option}>{option}</option>
11
+ )}
12
+ </select>
13
+ </>
14
+ );
15
+ }
web/src/workspace/LynxKiteState.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import { createContext } from "react";
2
+ import { Workspace } from "../apiTypes.ts";
3
+
4
+ export const LynxKiteState = createContext({ workspace: {} as Workspace });
web/src/workspace/NodeSearch.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Fuse from 'fuse.js'
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+
4
+ export type OpsOp = {
5
+ name: string
6
+ type: string
7
+ position: { x: number, y: number }
8
+ params: { name: string, default: any }[]
9
+ }
10
+ export type Catalog = { [op: string]: OpsOp };
11
+ export type Catalogs = { [env: string]: Catalog };
12
+
13
+ export default function (props: { boxes: Catalog, onCancel: any, onAdd: any, pos: { x: number, y: number } }) {
14
+ const searchBox = useRef(null as unknown as HTMLInputElement);
15
+ const [searchText, setSearchText] = useState('');
16
+ const fuse = useMemo(() => new Fuse(Object.values(props.boxes), {
17
+ keys: ['name']
18
+ }), [props.boxes]);
19
+ const hits: { item: OpsOp }[] = searchText ? fuse.search<OpsOp>(searchText) : Object.values(props.boxes).map(box => ({ item: box }));
20
+ const [selectedIndex, setSelectedIndex] = useState(0);
21
+ useEffect(() => searchBox.current.focus());
22
+ function typed(text: string) {
23
+ setSearchText(text);
24
+ setSelectedIndex(Math.max(0, Math.min(selectedIndex, hits.length - 1)));
25
+ }
26
+ function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
27
+ if (e.key === 'ArrowDown') {
28
+ e.preventDefault();
29
+ setSelectedIndex(Math.min(selectedIndex + 1, hits.length - 1));
30
+ } else if (e.key === 'ArrowUp') {
31
+ e.preventDefault();
32
+ setSelectedIndex(Math.max(selectedIndex - 1, 0));
33
+ } else if (e.key === 'Enter') {
34
+ addSelected();
35
+ } else if (e.key === 'Escape') {
36
+ props.onCancel();
37
+ }
38
+ }
39
+ function addSelected() {
40
+ const node = { ...hits[selectedIndex].item };
41
+ node.position = props.pos;
42
+ props.onAdd(node);
43
+ }
44
+ async function lostFocus(e: any) {
45
+ // If it's a click on a result, let the click handler handle it.
46
+ if (e.relatedTarget && e.relatedTarget.closest('.node-search')) return;
47
+ props.onCancel();
48
+ }
49
+
50
+
51
+ return (
52
+ <div className="node-search" style={{ top: props.pos.y, left: props.pos.x }}>
53
+ <input
54
+ ref={searchBox}
55
+ value={searchText}
56
+ onChange={event => typed(event.target.value)}
57
+ onKeyDown={onKeyDown}
58
+ onBlur={lostFocus}
59
+ placeholder="Search for box" />
60
+ <div className="matches">
61
+ {hits.map((box, index) =>
62
+ <div
63
+ key={box.item.name}
64
+ tabIndex={0}
65
+ onFocus={() => setSelectedIndex(index)}
66
+ onMouseEnter={() => setSelectedIndex(index)}
67
+ onClick={addSelected}
68
+ className={`search-result ${index === selectedIndex ? 'selected' : ''}`}>
69
+ {box.item.name}
70
+ </div>
71
+ )}
72
+ </div>
73
+ </div >
74
+ );
75
+ }
web/src/workspace/Workspace.tsx ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The LynxKite workspace editor.
2
+ import { useParams } from "react-router";
3
+ import useSWR, { Fetcher } from 'swr';
4
+ import { useEffect, useMemo, useCallback, useState, MouseEvent } from "react";
5
+ import favicon from '../assets/favicon.ico';
6
+ import {
7
+ ReactFlow,
8
+ Controls,
9
+ MarkerType,
10
+ ReactFlowProvider,
11
+ applyEdgeChanges,
12
+ applyNodeChanges,
13
+ useUpdateNodeInternals,
14
+ type XYPosition,
15
+ type Node,
16
+ type Edge,
17
+ type Connection,
18
+ useReactFlow,
19
+ MiniMap,
20
+ } from '@xyflow/react';
21
+ // @ts-ignore
22
+ import ArrowBack from '~icons/tabler/arrow-back.jsx';
23
+ // @ts-ignore
24
+ import Backspace from '~icons/tabler/backspace.jsx';
25
+ // @ts-ignore
26
+ import Atom from '~icons/tabler/atom.jsx';
27
+ import { syncedStore, getYjsDoc } from "@syncedstore/core";
28
+ import { WebsocketProvider } from "y-websocket";
29
+ import NodeWithParams from './nodes/NodeWithParams';
30
+ // import NodeWithTableView from './NodeWithTableView';
31
+ import EnvironmentSelector from './EnvironmentSelector';
32
+ import { LynxKiteState } from './LynxKiteState';
33
+ import '@xyflow/react/dist/style.css';
34
+ import { Workspace, WorkspaceNode } from "../apiTypes.ts";
35
+ import NodeSearch, { OpsOp, Catalog, Catalogs } from "./NodeSearch.tsx";
36
+ import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
37
+ import NodeWithImage from "./nodes/NodeWithImage.tsx";
38
+ import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
39
+
40
+ export default function (props: any) {
41
+ return (
42
+ <ReactFlowProvider>
43
+ <LynxKiteFlow {...props} />
44
+ </ReactFlowProvider>
45
+ );
46
+ }
47
+
48
+
49
+ function LynxKiteFlow() {
50
+ const updateNodeInternals = useUpdateNodeInternals()
51
+ const reactFlow = useReactFlow();
52
+ const [nodes, setNodes] = useState([] as Node[]);
53
+ const [edges, setEdges] = useState([] as Edge[]);
54
+ const { path } = useParams();
55
+ const [state, setState] = useState({ workspace: {} as Workspace });
56
+ useEffect(() => {
57
+ const state = syncedStore({ workspace: {} as Workspace });
58
+ setState(state);
59
+ const doc = getYjsDoc(state);
60
+ const wsProvider = new WebsocketProvider("ws://localhost:8000/ws/crdt", path!, doc);
61
+ const onChange = (_update: any, origin: any, _doc: any, _tr: any) => {
62
+ if (origin === wsProvider) {
63
+ // An update from the CRDT. Apply it to the local state.
64
+ // This is only necessary because ReactFlow keeps secret internal copies of our stuff.
65
+ if (!state.workspace) return;
66
+ if (!state.workspace.nodes) return;
67
+ if (!state.workspace.edges) return;
68
+ console.log('update', JSON.parse(JSON.stringify(state.workspace)));
69
+ setNodes([...state.workspace.nodes] as Node[]);
70
+ setEdges([...state.workspace.edges] as Edge[]);
71
+ for (const node of state.workspace.nodes) {
72
+ // Make sure the internal copies are updated.
73
+ updateNodeInternals(node.id);
74
+ }
75
+ }
76
+ };
77
+ doc.on('update', onChange);
78
+ return () => {
79
+ doc.destroy();
80
+ wsProvider.destroy();
81
+ }
82
+ }, [path]);
83
+
84
+ const onNodesChange = useCallback((changes: any[]) => {
85
+ // An update from the UI. Apply it to the local state...
86
+ setNodes((nds) => applyNodeChanges(changes, nds));
87
+ // ...and to the CRDT state. (Which could be the same, except for ReactFlow's internal copies.)
88
+ const wnodes = state.workspace?.nodes;
89
+ if (!wnodes) return;
90
+ for (const ch of changes) {
91
+ const nodeIndex = wnodes.findIndex((n) => n.id === ch.id);
92
+ if (nodeIndex === -1) continue;
93
+ const node = wnodes[nodeIndex];
94
+ if (!node) continue;
95
+ // Position events sometimes come with NaN values. Ignore them.
96
+ if (ch.type === 'position' && !isNaN(ch.position.x) && !isNaN(ch.position.y)) {
97
+ getYjsDoc(state).transact(() => {
98
+ Object.assign(node.position, ch.position);
99
+ });
100
+ } else if (ch.type === 'select') {
101
+ } else if (ch.type === 'dimensions') {
102
+ getYjsDoc(state).transact(() => Object.assign(node, ch.dimensions));
103
+ } else if (ch.type === 'remove') {
104
+ wnodes.splice(nodeIndex, 1);
105
+ } else if (ch.type === 'replace') {
106
+ // Ideally we would only update the parameter that changed. But ReactFlow does not give us that detail.
107
+ const u = {
108
+ collapsed: ch.item.data.collapsed,
109
+ // The "..." expansion on a Y.map returns an empty object. Copying with fromEntries/entries instead.
110
+ params: { ...Object.fromEntries(Object.entries(ch.item.data.params)) },
111
+ __execution_delay: ch.item.data.__execution_delay,
112
+ };
113
+ getYjsDoc(state).transact(() => Object.assign(node.data, u));
114
+ } else {
115
+ console.log('Unknown node change', ch);
116
+ }
117
+ }
118
+ }, [state]);
119
+ const onEdgesChange = useCallback((changes: any[]) => {
120
+ setEdges((eds) => applyEdgeChanges(changes, eds));
121
+ const wedges = state.workspace?.edges;
122
+ if (!wedges) return;
123
+ for (const ch of changes) {
124
+ console.log('edge change', ch);
125
+ const edgeIndex = wedges.findIndex((e) => e.id === ch.id);
126
+ if (ch.type === 'remove') {
127
+ wedges.splice(edgeIndex, 1);
128
+ } else {
129
+ console.log('Unknown edge change', ch);
130
+ }
131
+ }
132
+ }, [state]);
133
+
134
+ const fetcher: Fetcher<Catalogs> = (resource: string, init?: RequestInit) => fetch(resource, init).then(res => res.json());
135
+ const catalog = useSWR('/api/catalog', fetcher);
136
+ const [suppressSearchUntil, setSuppressSearchUntil] = useState(0);
137
+ const [nodeSearchSettings, setNodeSearchSettings] = useState(undefined as {
138
+ pos: XYPosition,
139
+ boxes: Catalog,
140
+ } | undefined);
141
+ const nodeTypes = useMemo(() => ({
142
+ basic: NodeWithParams,
143
+ visualization: NodeWithVisualization,
144
+ image: NodeWithImage,
145
+ table_view: NodeWithTableView,
146
+ }), []);
147
+ const closeNodeSearch = useCallback(() => {
148
+ setNodeSearchSettings(undefined);
149
+ setSuppressSearchUntil(Date.now() + 200);
150
+ }, [setNodeSearchSettings, setSuppressSearchUntil]);
151
+ const toggleNodeSearch = useCallback((event: MouseEvent) => {
152
+ if (suppressSearchUntil > Date.now()) return;
153
+ if (nodeSearchSettings) {
154
+ closeNodeSearch();
155
+ return;
156
+ }
157
+ event.preventDefault();
158
+ setNodeSearchSettings({
159
+ pos: { x: event.clientX, y: event.clientY },
160
+ boxes: catalog.data![state.workspace.env!],
161
+ });
162
+ }, [catalog, state, setNodeSearchSettings, suppressSearchUntil]);
163
+ const addNode = useCallback((meta: OpsOp) => {
164
+ const node: Partial<WorkspaceNode> = {
165
+ type: meta.type,
166
+ data: {
167
+ meta: meta,
168
+ title: meta.name,
169
+ params: Object.fromEntries(
170
+ Object.values(meta.params).map((p) => [p.name, p.default])),
171
+ },
172
+ };
173
+ const nss = nodeSearchSettings!;
174
+ node.position = reactFlow.screenToFlowPosition({ x: nss.pos.x, y: nss.pos.y });
175
+ const title = meta.name;
176
+ let i = 1;
177
+ node.id = `${title} ${i}`;
178
+ const wnodes = state.workspace.nodes!;
179
+ while (wnodes.find((x) => x.id === node.id)) {
180
+ i += 1;
181
+ node.id = `${title} ${i}`;
182
+ }
183
+ wnodes.push(node as WorkspaceNode);
184
+ setNodes([...nodes, node as WorkspaceNode]);
185
+ closeNodeSearch();
186
+ }, [nodeSearchSettings, state, reactFlow, setNodes]);
187
+
188
+ const onConnect = useCallback((connection: Connection) => {
189
+ setSuppressSearchUntil(Date.now() + 200);
190
+ const edge = {
191
+ id: `${connection.source} ${connection.target}`,
192
+ source: connection.source,
193
+ sourceHandle: connection.sourceHandle!,
194
+ target: connection.target,
195
+ targetHandle: connection.targetHandle!,
196
+ };
197
+ console.log(JSON.stringify(edge));
198
+ state.workspace.edges!.push(edge);
199
+ setEdges((oldEdges) => [...oldEdges, edge]);
200
+ }, [state, setEdges]);
201
+ const parentDir = path!.split('/').slice(0, -1).join('/');
202
+ return (
203
+ <div className="workspace">
204
+ <div className="top-bar bg-neutral">
205
+ <a className="logo" href=""><img src={favicon} /></a>
206
+ <div className="ws-name">
207
+ {path}
208
+ </div>
209
+ <EnvironmentSelector
210
+ options={Object.keys(catalog.data || {})}
211
+ value={state.workspace.env!}
212
+ onChange={(env) => { state.workspace.env = env; }}
213
+ />
214
+ <div className="tools text-secondary">
215
+ <a href=""><Atom /></a>
216
+ <a href=""><Backspace /></a>
217
+ <a href={'/dir/' + parentDir}><ArrowBack /></a>
218
+ </div>
219
+ </div>
220
+ <div style={{ height: "100%", width: '100vw' }}>
221
+ <LynxKiteState.Provider value={state}>
222
+ <ReactFlow
223
+ nodes={nodes}
224
+ edges={edges}
225
+ nodeTypes={nodeTypes} fitView
226
+ onNodesChange={onNodesChange}
227
+ onEdgesChange={onEdgesChange}
228
+ onPaneClick={toggleNodeSearch}
229
+ onConnect={onConnect}
230
+ proOptions={{ hideAttribution: true }}
231
+ maxZoom={3}
232
+ minZoom={0.3}
233
+ defaultEdgeOptions={{ markerEnd: { type: MarkerType.Arrow } }}
234
+ >
235
+ <Controls />
236
+ <MiniMap />
237
+ {nodeSearchSettings &&
238
+ <NodeSearch pos={nodeSearchSettings.pos} boxes={nodeSearchSettings.boxes} onCancel={closeNodeSearch} onAdd={addNode} />
239
+ }
240
+ </ReactFlow>
241
+ </LynxKiteState.Provider>
242
+ </div>
243
+ </div>
244
+
245
+ );
246
+ }
web/src/workspace/nodes/LynxKiteNode.tsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useReactFlow, Handle, NodeResizeControl, Position } from '@xyflow/react';
2
+ // @ts-ignore
3
+ import ChevronDownRight from '~icons/tabler/chevron-down-right.jsx';
4
+
5
+ interface LynxKiteNodeProps {
6
+ id: string;
7
+ width: number;
8
+ height: number;
9
+ nodeStyle: any;
10
+ data: any;
11
+ children: any;
12
+ }
13
+
14
+ function getHandles(inputs: object, outputs: object) {
15
+ const handles: {
16
+ position: 'top' | 'bottom' | 'left' | 'right',
17
+ name: string,
18
+ index: number,
19
+ offsetPercentage: number,
20
+ showLabel: boolean,
21
+ type: 'source' | 'target',
22
+ }[] = [];
23
+ for (const e of Object.values(inputs)) {
24
+ handles.push({ ...e, type: 'target' });
25
+ }
26
+ for (const e of Object.values(outputs)) {
27
+ handles.push({ ...e, type: 'source' });
28
+ }
29
+ const counts = { top: 0, bottom: 0, left: 0, right: 0 };
30
+ for (const e of handles) {
31
+ e.index = counts[e.position];
32
+ counts[e.position]++;
33
+ }
34
+ for (const e of handles) {
35
+ e.offsetPercentage = 100 * (e.index + 1) / (counts[e.position] + 1);
36
+ const simpleHorizontal = counts.top === 0 && counts.bottom === 0 && handles.length <= 2;
37
+ const simpleVertical = counts.left === 0 && counts.right === 0 && handles.length <= 2;
38
+ e.showLabel = !simpleHorizontal && !simpleVertical;
39
+ }
40
+ return handles;
41
+ }
42
+
43
+ export default function LynxKiteNode(props: LynxKiteNodeProps) {
44
+ const reactFlow = useReactFlow();
45
+ const data = props.data;
46
+ const expanded = !data.collapsed;
47
+ const handles = getHandles(data.meta?.inputs || {}, data.meta?.outputs || {});
48
+ function asPx(n: number | undefined) {
49
+ return (n ? n + 'px' : undefined) || '200px';
50
+ }
51
+ function titleClicked() {
52
+ reactFlow.updateNodeData(props.id, { collapsed: expanded });
53
+ }
54
+ const handleOffsetDirection = { top: 'left', bottom: 'left', left: 'top', right: 'top' };
55
+
56
+ return (
57
+ <div className={'node-container ' + (expanded ? 'expanded' : 'collapsed')}
58
+ style={{ width: asPx(props.width), height: asPx(expanded ? props.height : undefined) }}>
59
+ <div className="lynxkite-node" style={props.nodeStyle}>
60
+ <div className="title bg-primary" onClick={titleClicked}>
61
+ {data.title}
62
+ {data.error && <span className="title-icon">⚠️</span>}
63
+ {expanded || <span className="title-icon">⋯</span>}
64
+ </div>
65
+ {expanded && <>
66
+ {data.error &&
67
+ <div className="error">{data.error}</div>
68
+ }
69
+ {props.children}
70
+ {handles.map(handle => (
71
+ <Handle
72
+ key={handle.name}
73
+ id={handle.name} type={handle.type} position={handle.position as Position}
74
+ style={{ [handleOffsetDirection[handle.position]]: handle.offsetPercentage + '%' }}>
75
+ {handle.showLabel && <span className="handle-name">{handle.name.replace(/_/g, " ")}</span>}
76
+ </Handle >
77
+ ))}
78
+ <NodeResizeControl
79
+ minWidth={100}
80
+ minHeight={50}
81
+ style={{ 'background': 'transparent', 'border': 'none' }}
82
+ >
83
+ <ChevronDownRight className="node-resizer" />
84
+ </NodeResizeControl>
85
+ </>}
86
+ </div>
87
+ </div>
88
+ );
89
+ }
web/src/workspace/nodes/NodeParameter.tsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const BOOLEAN = "<class 'bool'>";
2
+
3
+ function ParamName({ name }) {
4
+ return <span className="param-name bg-base-200">{name.replace(/_/g, ' ')}</span>;
5
+ }
6
+
7
+ export default function NodeParameter({ name, value, meta, onChange }) {
8
+ return (
9
+ <label className="param">
10
+ {meta?.type?.format === 'collapsed' ? <>
11
+ <ParamName name={name} />
12
+ <button className="collapsed-param">
13
+
14
+ </button>
15
+ </> : meta?.type?.format === 'textarea' ? <>
16
+ <ParamName name={name} />
17
+ <textarea className="textarea textarea-bordered w-full max-w-xs"
18
+ rows={6}
19
+ value={value}
20
+ onChange={(evt) => onChange(evt.currentTarget.value)}
21
+ />
22
+ </> : meta?.type?.enum ? <>
23
+ <ParamName name={name} />
24
+ <select className="select select-bordered w-full max-w-xs"
25
+ value={value || meta.type.enum[0]}
26
+ onChange={(evt) => onChange(evt.currentTarget.value)}
27
+ >
28
+ {meta.type.enum.map(option =>
29
+ <option key={option} value={option}>{option}</option>
30
+ )}
31
+ </select>
32
+ </> : meta?.type?.type === BOOLEAN ? <div className="form-control">
33
+ <label className="label cursor-pointer">
34
+ <input className="checkbox"
35
+ type="checkbox"
36
+ checked={value}
37
+ onChange={(evt) => onChange(evt.currentTarget.checked)}
38
+ />
39
+ {name.replace(/_/g, ' ')}
40
+ </label>
41
+ </div> : <>
42
+ <ParamName name={name} />
43
+ <input className="input input-bordered w-full max-w-xs"
44
+ value={value || ""}
45
+ onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
46
+ onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
47
+ />
48
+ </>
49
+ }
50
+ </label >
51
+ );
52
+ }
web/src/workspace/nodes/NodeWithImage.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import NodeWithParams from './NodeWithParams';
2
+
3
+ const NodeWithImage = (props: any) => {
4
+ return (
5
+ <NodeWithParams {...props}>
6
+ {props.data.display && <img src={props.data.display} alt="Node Display" />}
7
+ </NodeWithParams>
8
+ );
9
+ };
10
+
11
+ export default NodeWithImage;
web/src/workspace/nodes/NodeWithParams.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import LynxKiteNode from './LynxKiteNode';
2
+ import { useReactFlow } from '@xyflow/react';
3
+ import NodeParameter from './NodeParameter';
4
+
5
+ export type UpdateOptions = { delay?: number };
6
+
7
+ function NodeWithParams(props: any) {
8
+ const reactFlow = useReactFlow();
9
+ const metaParams = props.data.meta?.params;
10
+ function setParam(name: string, newValue: any, opts: UpdateOptions) {
11
+ reactFlow.updateNodeData(props.id, { params: { ...props.data.params, [name]: newValue }, __execution_delay: opts.delay || 0 });
12
+ }
13
+ const params = props.data?.params ? Object.entries(props.data.params) : [];
14
+
15
+ return (
16
+ <LynxKiteNode {...props}>
17
+ {params.map(([name, value]) =>
18
+ <NodeParameter
19
+ name={name}
20
+ key={name}
21
+ value={value}
22
+ meta={metaParams?.[name]}
23
+ onChange={(value: any, opts?: UpdateOptions) => setParam(name, value, opts || {})}
24
+ />
25
+ )}
26
+ {props.children}
27
+ </LynxKiteNode >
28
+ );
29
+ }
30
+
31
+ export default NodeWithParams;
web/src/workspace/nodes/NodeWithTableView.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import Markdown from 'react-markdown'
3
+ import LynxKiteNode from './LynxKiteNode';
4
+ import Table from './Table';
5
+ import React from 'react';
6
+
7
+ function toMD(v: any): string {
8
+ if (typeof v === 'string') {
9
+ return v;
10
+ }
11
+ if (Array.isArray(v)) {
12
+ return v.map(toMD).join('\n\n');
13
+ }
14
+ return JSON.stringify(v);
15
+ }
16
+
17
+ export default function NodeWithTableView(props: any) {
18
+ const [open, setOpen] = useState({} as { [name: string]: boolean });
19
+ const display = props.data.display?.value;
20
+ const single = display?.dataframes && Object.keys(display?.dataframes).length === 1;
21
+ return (
22
+ <LynxKiteNode {...props}>
23
+ {display && [
24
+ Object.entries(display.dataframes || {}).map(([name, df]: [string, any]) => <React.Fragment key={name}>
25
+ {!single && <div key={name} className="df-head" onClick={() => setOpen({ ...open, [name]: !open[name] })}>{name}</div>}
26
+ {(single || open[name]) &&
27
+ (df.data.length > 1 ?
28
+ <Table key={name} columns={df.columns} data={df.data} />
29
+ :
30
+ <dl key={name}>
31
+ {df.columns.map((c: string, i: number) =>
32
+ <React.Fragment key={name + '-' + c}>
33
+ <dt>{c}</dt>
34
+ <dd><Markdown>{toMD(df.data[0][i])}</Markdown></dd>
35
+ </React.Fragment>)
36
+ }
37
+ </dl>)}
38
+ </React.Fragment>),
39
+ Object.entries(display.others || {}).map(([name, o]) => <>
40
+ <div className="df-head" onClick={() => setOpen({ ...open, [name]: !open[name] })}>{name}</div>
41
+ {open[name] && <pre>{(o as any).toString()}</pre>}
42
+ </>
43
+ )]}
44
+ </LynxKiteNode >
45
+ );
46
+ }
web/src/workspace/nodes/NodeWithVisualization.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect } from 'react';
2
+ import NodeWithParams from './NodeWithParams';
3
+ import * as echarts from 'echarts';
4
+
5
+ const NodeWithVisualization = (props: any) => {
6
+ const chartsRef = React.useRef<HTMLDivElement>(null);
7
+ const chartsInstanceRef = React.useRef<echarts.ECharts>();
8
+ useEffect(() => {
9
+ const opts = props.data?.display?.value;
10
+ if (!opts || !chartsRef.current) return;
11
+ console.log(chartsRef.current);
12
+ chartsInstanceRef.current = echarts.init(chartsRef.current, null, { renderer: 'canvas', width: 250, height: 250 });
13
+ chartsInstanceRef.current.setOption(opts);
14
+ const onResize = () => chartsInstanceRef.current?.resize();
15
+ window.addEventListener('resize', onResize);
16
+ return () => {
17
+ window.removeEventListener('resize', onResize);
18
+ chartsInstanceRef.current?.dispose();
19
+ };
20
+ }, [props.data?.display?.value]);
21
+ return (
22
+ <NodeWithParams {...props}>
23
+ <div className="box" draggable={false} ref={chartsRef} />;
24
+ </NodeWithParams>
25
+ );
26
+ };
27
+
28
+ export default NodeWithVisualization;