Spaces:
Running
Running
Merge pull request #33 from biggraph/darabos-react
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- README.md +7 -6
- requirements.txt +1 -0
- server/crdt.py +91 -31
- server/executors/one_by_one.py +138 -114
- server/llm_ops.py +154 -125
- server/lynxkite_ops.py +177 -148
- server/ops.py +184 -149
- server/workspace.py +16 -17
- web/.gitignore +11 -0
- web/.vscode/extensions.json +0 -3
- web/README.md +4 -24
- web/eslint.config.js +28 -0
- web/index.html +4 -4
- web/package-lock.json +0 -0
- web/package.json +38 -27
- web/postcss.config.js +6 -0
- web/src/App.svelte +0 -22
- web/src/Directory.svelte +0 -177
- web/src/Directory.tsx +88 -0
- web/src/EnvironmentSelector.svelte +0 -14
- web/src/LynxKiteFlow.svelte +0 -230
- web/src/LynxKiteNode.svelte +0 -174
- web/src/NodeParameter.svelte +0 -69
- web/src/NodeSearch.svelte +0 -100
- web/src/NodeWithArea.svelte +0 -60
- web/src/NodeWithImage.svelte +0 -14
- web/src/NodeWithParams.svelte +0 -30
- web/src/NodeWithSubFlow.svelte +0 -37
- web/src/NodeWithTableView.svelte +0 -58
- web/src/NodeWithVisualization.svelte +0 -17
- web/src/Table.svelte +0 -22
- web/src/Workspace.svelte +0 -14
- web/src/apiTypes.ts +45 -0
- web/src/app.scss +0 -38
- web/{public → src/assets}/favicon.ico +0 -0
- web/src/directory.css +0 -10
- web/src/index.css +350 -0
- web/src/main.ts +0 -10
- web/src/main.tsx +19 -0
- web/src/vite-env.d.ts +0 -1
- web/src/workspace/EnvironmentSelector.tsx +15 -0
- web/src/workspace/LynxKiteState.ts +4 -0
- web/src/workspace/NodeSearch.tsx +75 -0
- web/src/workspace/Workspace.tsx +246 -0
- web/src/workspace/nodes/LynxKiteNode.tsx +89 -0
- web/src/workspace/nodes/NodeParameter.tsx +52 -0
- web/src/workspace/nodes/NodeWithImage.tsx +11 -0
- web/src/workspace/nodes/NodeWithParams.tsx +31 -0
- web/src/workspace/nodes/NodeWithTableView.tsx +46 -0
- 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 |
-
|
5 |
-
|
6 |
-
|
7 |
-
- More extensible backend. Make it easy to add new LynxKite boxes.
|
8 |
-
|
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 |
-
|
|
|
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 |
-
|
15 |
-
|
|
|
|
|
|
|
|
|
16 |
return True
|
17 |
|
|
|
18 |
class WebsocketServer(pycrdt_websocket.WebsocketServer):
|
19 |
async def init_room(self, name):
|
20 |
-
ystore = pycrdt_websocket.ystore.FileYStore(f
|
21 |
ydoc = pycrdt.Doc()
|
22 |
-
ydoc[
|
23 |
# Replay updates from the store.
|
24 |
try:
|
25 |
-
for update, timestamp in [
|
|
|
|
|
26 |
ydoc.apply_update(update)
|
27 |
except pycrdt_websocket.ystore.YDocNotFound:
|
28 |
pass
|
29 |
-
if
|
30 |
-
ws[
|
31 |
-
if
|
32 |
-
ws[
|
33 |
-
if
|
34 |
-
ws[
|
35 |
try_to_load_workspace(ws, name)
|
36 |
-
room = pycrdt_websocket.YRoom(
|
|
|
|
|
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(
|
95 |
|
96 |
|
97 |
def try_to_load_workspace(ws, name):
|
98 |
from . import workspace
|
99 |
-
|
|
|
100 |
if os.path.exists(json_path):
|
101 |
ws_pyd = workspace.load(json_path)
|
102 |
-
crdt_update(ws, ws_pyd.model_dump(), boxes={
|
|
|
103 |
|
104 |
-
|
105 |
-
|
|
|
|
|
|
|
106 |
from . import workspace
|
|
|
107 |
ws_pyd = workspace.Workspace.model_validate(ws_crdt.to_py())
|
|
|
|
|
108 |
clean_input(ws_pyd)
|
109 |
-
if ws_pyd ==
|
110 |
return
|
111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
112 |
await workspace.execute(ws_pyd)
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
|
|
|
|
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 |
-
|
|
|
|
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 |
-
|
12 |
-
|
13 |
-
|
|
|
|
|
14 |
|
15 |
class Output(ops.BaseConfig):
|
16 |
-
|
17 |
-
|
18 |
-
|
|
|
19 |
|
20 |
|
21 |
def df_to_list(df):
|
22 |
-
|
|
|
23 |
|
24 |
def has_ctx(op):
|
25 |
-
|
26 |
-
|
|
|
27 |
|
28 |
CACHES = {}
|
29 |
|
|
|
30 |
def register(env: str, cache: bool = True):
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
38 |
|
39 |
def get_stages(ws, catalog):
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
|
66 |
|
67 |
def _default_serializer(obj):
|
68 |
-
|
69 |
-
|
70 |
-
|
|
|
71 |
|
72 |
def make_cache_key(obj):
|
73 |
-
|
|
|
74 |
|
75 |
EXECUTOR_OUTPUT_CACHE = {}
|
76 |
|
|
|
77 |
async def await_if_needed(obj):
|
78 |
-
|
79 |
-
|
80 |
-
|
|
|
81 |
|
82 |
async def execute(ws, catalog, cache=None):
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
|
|
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 =
|
18 |
one_by_one.register(ENV)
|
19 |
op = ops.op_registration(ENV)
|
20 |
|
|
|
21 |
def chat(*args, **kwargs):
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
|
|
27 |
|
28 |
def embedding(*args, **kwargs):
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
|
|
35 |
|
36 |
@op("Input CSV")
|
37 |
def input_csv(*, filename: ops.PathStr, key: str):
|
38 |
-
|
|
|
39 |
|
40 |
@op("Input document")
|
41 |
def input_document(*, filename: ops.PathStr):
|
42 |
-
|
43 |
-
|
|
|
44 |
|
45 |
@op("Input chat")
|
46 |
def input_chat(*, chat: str):
|
47 |
-
|
|
|
48 |
|
49 |
@op("Split document")
|
50 |
-
def split_document(input, *, delimiter: str =
|
51 |
-
|
52 |
-
|
53 |
-
|
|
|
54 |
|
55 |
@ops.input_position(input="top")
|
56 |
@op("Build document graph")
|
57 |
def build_document_graph(input):
|
58 |
-
|
|
|
59 |
|
60 |
@ops.input_position(nodes="top", edges="top")
|
61 |
@op("Predict links")
|
62 |
def predict_links(nodes, edges):
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
|
|
73 |
|
74 |
@ops.input_position(nodes="top", edges="top")
|
75 |
@op("Add neighbors")
|
76 |
def add_neighbors(nodes, edges, item):
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
|
|
86 |
|
87 |
@op("Create prompt")
|
88 |
-
def create_prompt(input, *, save_as=
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
|
|
|
|
|
|
93 |
|
94 |
@op("Ask LLM")
|
95 |
def ask_llm(input, *, model: str, accepted_regex: str = None, max_tokens: int = 100):
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
|
|
112 |
|
113 |
@op("View", view="table_view")
|
114 |
def view(input, *, _ctx: one_by_one.Context):
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
|
|
|
|
|
|
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 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
|
|
138 |
|
139 |
-
@op(
|
140 |
def branch(input, *, expression: str):
|
141 |
-
|
142 |
-
|
|
|
143 |
|
144 |
class RagEngine(enum.Enum):
|
145 |
-
|
146 |
-
|
|
|
147 |
|
148 |
@ops.input_position(db="top")
|
149 |
-
@op(
|
150 |
def rag(
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
if
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
188 |
def run_python(input, *, template: str):
|
189 |
-
|
190 |
-
|
|
|
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 |
-
|
|
|
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(
|
|
|
13 |
|
14 |
@dataclasses.dataclass
|
15 |
class RelationDefinition:
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
|
25 |
@dataclasses.dataclass
|
26 |
class Bundle:
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
|
|
|
|
|
|
53 |
)
|
54 |
-
]
|
55 |
-
)
|
56 |
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
|
|
|
|
61 |
|
62 |
|
63 |
def nx_node_attribute_func(name):
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
|
|
|
|
|
|
74 |
|
75 |
|
76 |
def disambiguate_edges(ws):
|
77 |
-
|
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(
|
86 |
async def execute(ws):
|
87 |
-
catalog = ops.CATALOGS[
|
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 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
try:
|
115 |
-
|
116 |
except Exception as e:
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
if len(op.inputs) == 1 and op.inputs.get(
|
122 |
# It's a flexible input. Create n+1 handles.
|
123 |
-
data.inputs = {f
|
124 |
data.error = None
|
125 |
outputs[node.id] = output
|
126 |
-
if
|
|
|
|
|
|
|
|
|
127 |
data.display = output
|
128 |
|
|
|
129 |
@op("Import Parquet")
|
130 |
def import_parquet(*, filename: str):
|
131 |
-
|
132 |
-
|
|
|
133 |
|
134 |
@op("Create scale-free graph")
|
135 |
def create_scale_free_graph(*, nodes: int = 10):
|
136 |
-
|
137 |
-
|
|
|
138 |
|
139 |
@op("Compute PageRank")
|
140 |
-
@nx_node_attribute_func(
|
141 |
def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
|
142 |
-
|
|
|
143 |
|
144 |
@op("Discard loop edges")
|
145 |
def discard_loop_edges(graph: nx.Graph):
|
146 |
-
|
147 |
-
|
148 |
-
|
|
|
149 |
|
150 |
@op("Sample graph")
|
151 |
def sample_graph(graph: nx.Graph, *, nodes: int = 100):
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
|
|
164 |
|
165 |
def _map_color(value):
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
|
|
|
|
|
|
|
|
170 |
|
171 |
@op("Visualize graph", view="visualization")
|
172 |
def visualize_graph(graph: Bundle, *, color_nodes_by: ops.NodeAttribute = None):
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
|
|
|
|
|
|
213 |
|
214 |
@op("View tables", view="table_view")
|
215 |
def view_tables(bundle: Bundle):
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
|
|
|
|
|
|
|
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 |
-
|
|
|
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
|
|
|
|
|
14 |
def type_to_json(t):
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
]
|
23 |
-
LongStr = Annotated[
|
24 |
-
|
25 |
-
]
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
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 |
-
|
40 |
-
|
41 |
-
|
42 |
|
43 |
|
44 |
class Parameter(BaseConfig):
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
|
|
|
|
66 |
|
67 |
class Input(BaseConfig):
|
68 |
-
|
69 |
-
|
70 |
-
|
|
|
71 |
|
72 |
class Output(BaseConfig):
|
73 |
-
|
74 |
-
|
75 |
-
|
|
|
|
|
|
|
|
|
76 |
|
77 |
-
MULTI_INPUT = Input(name='multi', type='*')
|
78 |
def basic_inputs(*names):
|
79 |
-
|
|
|
|
|
80 |
def basic_outputs(*names):
|
81 |
-
|
82 |
|
83 |
|
84 |
class Op(BaseConfig):
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
133 |
|
134 |
def input_position(**kwargs):
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
|
|
|
|
|
|
142 |
|
143 |
def output_position(**kwargs):
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
|
|
|
|
|
|
151 |
|
152 |
def no_op(*args, **kwargs):
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
|
|
|
|
|
|
|
|
|
|
172 |
|
173 |
def register_executor(env: str):
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
|
|
|
|
|
|
179 |
|
180 |
def op_registration(env: str):
|
181 |
-
|
|
|
182 |
|
183 |
def passive_op_registration(env: str):
|
184 |
-
|
|
|
185 |
|
186 |
def register_area(env, name, params=[]):
|
187 |
-
|
188 |
-
|
189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
|
|
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=
|
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 |
-
|
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(
|
|
|
|
|
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 |
-
|
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 ==
|
93 |
data.error = None
|
94 |
else:
|
95 |
-
data.error =
|
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 |
-
|
2 |
|
3 |
-
|
4 |
|
5 |
-
|
6 |
|
7 |
-
|
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/
|
6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
-
<title>LynxKite
|
8 |
</head>
|
9 |
<body>
|
10 |
-
<div id="
|
11 |
-
<script type="module" src="/src/main.
|
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": "
|
3 |
"private": true,
|
4 |
"version": "0.0.0",
|
5 |
"type": "module",
|
6 |
"scripts": {
|
7 |
"dev": "vite",
|
8 |
-
"build": "vite build",
|
9 |
-
"
|
10 |
-
"
|
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.
|
29 |
-
"@
|
30 |
-
"@
|
31 |
-
"@
|
32 |
-
"
|
33 |
-
"
|
34 |
-
"
|
35 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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;
|