Spaces:
Running
Running
Merge remote-tracking branch 'public/main' into darabos-open-source-merge
Browse files- README.md +13 -52
- docs/contributing.md +57 -0
- docs/guides/analytics.md +82 -0
- docs/{usage → guides}/plugins.md +0 -0
- docs/{usage → guides}/quickstart.md +2 -0
- lynxkite-app/src/lynxkite_app/crdt.py +12 -1
- lynxkite-app/src/lynxkite_app/main.py +1 -29
- lynxkite-app/tests/test_main.py +0 -30
- lynxkite-app/uv.lock +0 -0
- lynxkite-app/web/src/Directory.tsx +1 -1
- lynxkite-app/web/src/Tooltip.tsx +2 -2
- lynxkite-app/web/src/apiTypes.ts +2 -0
- lynxkite-app/web/src/common.ts +9 -0
- lynxkite-app/web/src/index.css +50 -0
- lynxkite-app/web/src/workspace/NodeSearch.tsx +1 -1
- lynxkite-app/web/src/workspace/Workspace.tsx +204 -32
- lynxkite-app/web/src/workspace/nodes/Group.tsx +64 -0
- lynxkite-app/web/src/workspace/nodes/LynxKiteNode.tsx +4 -11
- lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx +1 -1
- lynxkite-app/web/tests/import.spec.ts +2 -1
- lynxkite-app/web/tests/lynxkite.ts +19 -13
- lynxkite-core/src/lynxkite/core/executors/one_by_one.py +2 -2
- lynxkite-core/src/lynxkite/core/workspace.py +18 -17
- lynxkite-core/tests/test_workspace.py +16 -28
- lynxkite-graph-analytics/pyproject.toml +4 -0
- lynxkite-graph-analytics/tests/test_lynxkite_ops.py +40 -56
- lynxkite-graph-analytics/tests/test_pytorch_model_ops.py +3 -10
- mkdocs.yml +7 -5
README.md
CHANGED
@@ -17,67 +17,28 @@ Features include:
|
|
17 |
- An extensive toolbox of graph analytics operations powered by NVIDIA RAPIDS (CUDA).
|
18 |
- An integrated collaborative code editor makes it easy to add new operations.
|
19 |
- An environment for visually designing neural network model architectures.
|
20 |
-
- The infrastructure for easily creating other workflow design environments. See `lynxkite-pillow-example` for a simple
|
|
|
21 |
|
22 |
-
This is the next evolution of the classical [LynxKite](https://github.com/lynxkite/lynxkite).
|
23 |
-
|
24 |
-
|
25 |
-
It targets CUDA instead of Apache Spark. It is much more extensible.
|
26 |
|
27 |
-
##
|
28 |
-
|
29 |
-
- `lynxkite-core`: Core types and utilities. Depend on this lightweight package if you are writing LynxKite plugins.
|
30 |
-
- `lynxkite-app`: The LynxKite web application. Install some plugins then run this to use LynxKite.
|
31 |
-
- `lynxkite-graph-analytics`: Graph analytics plugin. The classical LynxKite experience!
|
32 |
-
- `lynxkite-pillow`: A simple example plugin.
|
33 |
-
- `lynxkite-lynxscribe`: A plugin for building and running LynxScribe applications.
|
34 |
-
- `lynxkite-bio`: Bioinformatics additions for LynxKite Graph Analytics.
|
35 |
-
- `docs`: User-facing documentation. It's shared between all packages.
|
36 |
-
|
37 |
-
## Development
|
38 |
-
|
39 |
-
Install everything like this:
|
40 |
-
|
41 |
-
```bash
|
42 |
-
uv venv
|
43 |
-
source .venv/bin/activate
|
44 |
-
uvx pre-commit install
|
45 |
-
# The [dev] tag is only needed if you intend on running tests
|
46 |
-
uv pip install -e lynxkite-core/[dev] -e lynxkite-app/[dev] -e lynxkite-graph-analytics/[dev] -e lynxkite-bio -e lynxkite-lynxscribe/ -e lynxkite-pillow-example/
|
47 |
-
```
|
48 |
-
|
49 |
-
This also builds the frontend, hopefully very quickly. To run it:
|
50 |
|
51 |
```bash
|
52 |
-
|
53 |
```
|
54 |
|
55 |
-
|
56 |
-
|
57 |
-
```bash
|
58 |
-
cd lynxkite-app/web
|
59 |
-
npm run dev
|
60 |
-
```
|
61 |
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
```bash
|
67 |
-
./test.sh
|
68 |
-
```
|
69 |
-
|
70 |
-
## Documentation
|
71 |
-
|
72 |
-
To work on the documentation:
|
73 |
-
|
74 |
-
```bash
|
75 |
-
uv pip install mkdocs-material mkdocstrings[python]
|
76 |
-
mkdocs serve
|
77 |
-
```
|
78 |
|
79 |
## License
|
80 |
|
81 |
-
LynxKite 2000:MM Enterprise is built on top of the open-source
|
|
|
82 |
|
83 |
Inquire with [Lynx Analytics](https://www.lynxanalytics.com/) for the licensing of this repository.
|
|
|
17 |
- An extensive toolbox of graph analytics operations powered by NVIDIA RAPIDS (CUDA).
|
18 |
- An integrated collaborative code editor makes it easy to add new operations.
|
19 |
- An environment for visually designing neural network model architectures.
|
20 |
+
- The infrastructure for easily creating other workflow design environments. See `lynxkite-pillow-example` for a simple
|
21 |
+
example.
|
22 |
|
23 |
+
This is the next evolution of the classical [LynxKite](https://github.com/lynxkite/lynxkite). The two tools offer
|
24 |
+
similar functionality, but are not compatible. This version runs on GPU clusters instead of Hadoop clusters. It targets
|
25 |
+
CUDA instead of Apache Spark. It is much more extensible.
|
|
|
26 |
|
27 |
+
## Installation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
|
29 |
```bash
|
30 |
+
pip install lynxkite lynxkite-graph-analytics
|
31 |
```
|
32 |
|
33 |
+
## Getting started
|
|
|
|
|
|
|
|
|
|
|
34 |
|
35 |
+
- [Online demo](https://lynx-analytics-lynxkite.hf.space/)
|
36 |
+
- [Quickstart](https://lynxkite.github.io/lynxkite-2000/guides/quickstart/)
|
37 |
+
- [Contributing](https://lynxkite.github.io/lynxkite-2000/contributing/)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
|
39 |
## License
|
40 |
|
41 |
+
LynxKite 2000:MM Enterprise is built on top of the open-source
|
42 |
+
[LynxKite 2000:MM](https://github.com/lynxkite/lynxkite-2000).
|
43 |
|
44 |
Inquire with [Lynx Analytics](https://www.lynxanalytics.com/) for the licensing of this repository.
|
docs/contributing.md
ADDED
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Contributing
|
2 |
+
|
3 |
+
The LynxKite 2000:MM repository lives at
|
4 |
+
[https://github.com/lynxkite/lynxkite-2000](https://github.com/lynxkite/lynxkite-2000). Bug reports, feature requests,
|
5 |
+
and pull requests are welcome!
|
6 |
+
|
7 |
+
## Project structure
|
8 |
+
|
9 |
+
- `lynxkite-core`: Core types and utilities. Depend on this lightweight package if you are writing LynxKite plugins.
|
10 |
+
- `lynxkite-app`: The LynxKite web application. Install some plugins then run this to use LynxKite.
|
11 |
+
- `lynxkite-graph-analytics`: Graph analytics plugin. The classical LynxKite experience!
|
12 |
+
- `lynxkite-pillow`: A simple example plugin.
|
13 |
+
- `lynxkite-lynxscribe`: A plugin for building and running LynxScribe applications.
|
14 |
+
- `lynxkite-bio`: Bioinformatics additions for LynxKite Graph Analytics.
|
15 |
+
- `docs`: User-facing documentation. It's shared between all packages.
|
16 |
+
|
17 |
+
## Development setup
|
18 |
+
|
19 |
+
Install everything like this:
|
20 |
+
|
21 |
+
```bash
|
22 |
+
uv venv
|
23 |
+
source .venv/bin/activate
|
24 |
+
uvx pre-commit install
|
25 |
+
uv pip install -e 'lynxkite-core/[dev]' -e 'lynxkite-app/[dev]' -e 'lynxkite-graph-analytics/[dev]' -e lynxkite-pillow-example/ -e lynxkite-bio -e lynxkite-lynxscribe/
|
26 |
+
```
|
27 |
+
|
28 |
+
This also builds the frontend, hopefully very quickly. To run it:
|
29 |
+
|
30 |
+
```bash
|
31 |
+
cd examples
|
32 |
+
lynxkite
|
33 |
+
```
|
34 |
+
|
35 |
+
If you also want to make changes to the frontend with hot reloading:
|
36 |
+
|
37 |
+
```bash
|
38 |
+
cd lynxkite-app/web
|
39 |
+
npm run dev
|
40 |
+
```
|
41 |
+
|
42 |
+
## Executing tests
|
43 |
+
|
44 |
+
Run all tests with a single command, or look inside to see how to run them individually:
|
45 |
+
|
46 |
+
```bash
|
47 |
+
./test.sh
|
48 |
+
```
|
49 |
+
|
50 |
+
## Documentation
|
51 |
+
|
52 |
+
To work on the documentation:
|
53 |
+
|
54 |
+
```bash
|
55 |
+
uv pip install mkdocs-material mkdocstrings[python]
|
56 |
+
mkdocs serve
|
57 |
+
```
|
docs/guides/analytics.md
ADDED
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Graph analytics & data science
|
2 |
+
|
3 |
+
Install LynxKite with the graph analytics package:
|
4 |
+
|
5 |
+
```bash
|
6 |
+
pip install lynxkite lynxkite-graph-analytics
|
7 |
+
```
|
8 |
+
|
9 |
+
Run LynxKite in your data directory:
|
10 |
+
|
11 |
+
```bash
|
12 |
+
cd lynxkite-data
|
13 |
+
lynxkite
|
14 |
+
```
|
15 |
+
|
16 |
+
LynxKite by default runs on port 8000, so you can access it in your browser at
|
17 |
+
[http://localhost:8000](http://localhost:8000).
|
18 |
+
To run it on a different port, set the `PORT` environment variable (e.g., `PORT=8080 lynxkite`).
|
19 |
+
|
20 |
+
## Using a CUDA GPU
|
21 |
+
|
22 |
+
To make full use of your GPU, install the `lynxkite-graph-analytics` package with GPU support.
|
23 |
+
|
24 |
+
```bash
|
25 |
+
pip install lynxkite 'lynxkite-graph-analytics[gpu]'
|
26 |
+
```
|
27 |
+
|
28 |
+
And start it with the cuGraph backend for NetworkX:
|
29 |
+
|
30 |
+
```bash
|
31 |
+
NX_CUGRAPH_AUTOCONFIG=true lynxkite
|
32 |
+
```
|
33 |
+
|
34 |
+
## Directory browser
|
35 |
+
|
36 |
+
When you open the LynxKite web interface, you arrive in the directory browser. You see
|
37 |
+
the files and directories in your data directory — if you just created it, it will be empty.
|
38 |
+
|
39 |
+
You can create workspaces, [code files](plugins.md), and folders in the directory browser.
|
40 |
+
|
41 |
+
## Workspaces
|
42 |
+
|
43 |
+
A LynxKite workspace is the place where you build a data science pipeline.
|
44 |
+
Pipelines are built from boxes, which have inputs and outputs that can be connected to each other.
|
45 |
+
|
46 |
+
To place a box, click anywhere in the workspace. This opens a search menu where you can
|
47 |
+
find the box you want to add.
|
48 |
+
|
49 |
+
## Importing your data
|
50 |
+
|
51 |
+
To import CSV, Parquet, JSON, and Excel files, you can simply drag and drop them into the LynxKite workspace.
|
52 |
+
This will upload the file to the server and add an "Import file" box to the workspace.
|
53 |
+
|
54 |
+
You can also create "Import file" boxes manually and type the path to the file.
|
55 |
+
You can either use an absolute path, or a relative path from the data directory.
|
56 |
+
(Where you started LynxKite.)
|
57 |
+
|
58 |
+
## Neural network design
|
59 |
+
|
60 |
+
The graph analytics package includes two environments, _"LynxKite Graph Analytics"_, and _"PyTorch model"_.
|
61 |
+
Use the dropdown in the top right corner to switch to the "PyTorch model" environment.
|
62 |
+
This environment allows you to define neural network architectures visually.
|
63 |
+
|
64 |
+
The important parts of a neural network definition are:
|
65 |
+
|
66 |
+
- **Inputs**: These boxes stand for the inputs. You will connect them to actual data in the workspace that
|
67 |
+
uses this model.
|
68 |
+
- **Layers**: The heart of the model. Use the _"Repeat"_ box looping back from the output of a layer to the
|
69 |
+
input of an earlier layer to repeat a set of layers.
|
70 |
+
- **Outputs**: These boxes mark the place in the data flow that holds the predictions of the model.
|
71 |
+
- **Loss**: Build the loss computation after the output box. This part is only used during training.
|
72 |
+
- **Optimizer**: The result of the loss computation goes into the optimizer. Training is partially configured
|
73 |
+
in the optimizer box, partially in the training box in the workspace that uses the model.
|
74 |
+
|
75 |
+
Once the model is defined, you can use it in other workspaces.
|
76 |
+
|
77 |
+
- Load it with the _"Define model"_ box.
|
78 |
+
- Train it with the _"Train model"_ box.
|
79 |
+
- Generate predictions with the _"Model inference"_ box.
|
80 |
+
|
81 |
+
See the [_Model definition_ and _Model use_ workspaces](https://github.com/lynxkite/lynxkite-2000/tree/main/examples)
|
82 |
+
for a practical example.
|
docs/{usage → guides}/plugins.md
RENAMED
File without changes
|
docs/{usage → guides}/quickstart.md
RENAMED
@@ -23,3 +23,5 @@ lynxkite
|
|
23 |
```
|
24 |
|
25 |
Open [http://localhost:8000/](http://localhost:8000/) in your browser.
|
|
|
|
|
|
23 |
```
|
24 |
|
25 |
Open [http://localhost:8000/](http://localhost:8000/) in your browser.
|
26 |
+
|
27 |
+
Find example workspaces in the [`examples` folder](https://github.com/lynxkite/lynxkite-2000/tree/main/examples).
|
lynxkite-app/src/lynxkite_app/crdt.py
CHANGED
@@ -63,7 +63,10 @@ class WorkspaceWebsocketServer(pycrdt_websocket.WebsocketServer):
|
|
63 |
room.ws = ws
|
64 |
|
65 |
def on_change(changes):
|
66 |
-
asyncio.create_task(workspace_changed(name, changes, ws))
|
|
|
|
|
|
|
67 |
|
68 |
ws.observe_deep(on_change)
|
69 |
return room
|
@@ -117,6 +120,7 @@ last_ws_input = None
|
|
117 |
|
118 |
|
119 |
def clean_input(ws_pyd):
|
|
|
120 |
for node in ws_pyd.nodes:
|
121 |
node.data.display = None
|
122 |
node.data.input_metadata = None
|
@@ -125,6 +129,8 @@ def clean_input(ws_pyd):
|
|
125 |
for p in list(node.data.params):
|
126 |
if p.startswith("_"):
|
127 |
del node.data.params[p]
|
|
|
|
|
128 |
node.position.x = 0
|
129 |
node.position.y = 0
|
130 |
if node.model_extra:
|
@@ -296,6 +302,11 @@ async def lifespan(app):
|
|
296 |
print("closing websocket server")
|
297 |
|
298 |
|
|
|
|
|
|
|
|
|
|
|
299 |
def sanitize_path(path):
|
300 |
return os.path.relpath(os.path.normpath(os.path.join("/", path)), "/")
|
301 |
|
|
|
63 |
room.ws = ws
|
64 |
|
65 |
def on_change(changes):
|
66 |
+
task = asyncio.create_task(workspace_changed(name, changes, ws))
|
67 |
+
# We have no way to await workspace_changed(). The best we can do is to
|
68 |
+
# dereference its result after it's done, so exceptions are logged normally.
|
69 |
+
task.add_done_callback(lambda t: t.result())
|
70 |
|
71 |
ws.observe_deep(on_change)
|
72 |
return room
|
|
|
120 |
|
121 |
|
122 |
def clean_input(ws_pyd):
|
123 |
+
"""Delete everything that we want to ignore for the purposes of change detection."""
|
124 |
for node in ws_pyd.nodes:
|
125 |
node.data.display = None
|
126 |
node.data.input_metadata = None
|
|
|
129 |
for p in list(node.data.params):
|
130 |
if p.startswith("_"):
|
131 |
del node.data.params[p]
|
132 |
+
if node.data.title == "Comment":
|
133 |
+
node.data.params = {}
|
134 |
node.position.x = 0
|
135 |
node.position.y = 0
|
136 |
if node.model_extra:
|
|
|
302 |
print("closing websocket server")
|
303 |
|
304 |
|
305 |
+
def delete_room(name: str):
|
306 |
+
if name in ws_websocket_server.rooms:
|
307 |
+
del ws_websocket_server.rooms[name]
|
308 |
+
|
309 |
+
|
310 |
def sanitize_path(path):
|
311 |
return os.path.relpath(os.path.normpath(os.path.join("/", path)), "/")
|
312 |
|
lynxkite-app/src/lynxkite_app/main.py
CHANGED
@@ -46,29 +46,9 @@ def get_catalog(workspace: str):
|
|
46 |
return {env: _get_ops(env) for env in ops.CATALOGS}
|
47 |
|
48 |
|
49 |
-
class SaveRequest(workspace.BaseConfig):
|
50 |
-
path: str
|
51 |
-
ws: workspace.Workspace
|
52 |
-
|
53 |
-
|
54 |
data_path = pathlib.Path()
|
55 |
|
56 |
|
57 |
-
def save(req: SaveRequest):
|
58 |
-
path = data_path / req.path
|
59 |
-
assert path.is_relative_to(data_path), f"Path '{path}' is invalid"
|
60 |
-
req.ws.save(path)
|
61 |
-
|
62 |
-
|
63 |
-
@app.post("/api/save")
|
64 |
-
async def save_and_execute(req: SaveRequest):
|
65 |
-
save(req)
|
66 |
-
if req.ws.has_executor():
|
67 |
-
await req.ws.execute()
|
68 |
-
save(req)
|
69 |
-
return req.ws
|
70 |
-
|
71 |
-
|
72 |
@app.post("/api/delete")
|
73 |
async def delete_workspace(req: dict):
|
74 |
json_path: pathlib.Path = data_path / req["path"]
|
@@ -76,15 +56,7 @@ async def delete_workspace(req: dict):
|
|
76 |
assert json_path.is_relative_to(data_path), f"Path '{json_path}' is invalid"
|
77 |
json_path.unlink()
|
78 |
crdt_path.unlink()
|
79 |
-
|
80 |
-
|
81 |
-
@app.get("/api/load")
|
82 |
-
def load(path: str):
|
83 |
-
path = data_path / path
|
84 |
-
assert path.is_relative_to(data_path), f"Path '{path}' is invalid"
|
85 |
-
if not path.exists():
|
86 |
-
return workspace.Workspace()
|
87 |
-
return workspace.Workspace.load(path)
|
88 |
|
89 |
|
90 |
class DirectoryEntry(pydantic.BaseModel):
|
|
|
46 |
return {env: _get_ops(env) for env in ops.CATALOGS}
|
47 |
|
48 |
|
|
|
|
|
|
|
|
|
|
|
49 |
data_path = pathlib.Path()
|
50 |
|
51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
@app.post("/api/delete")
|
53 |
async def delete_workspace(req: dict):
|
54 |
json_path: pathlib.Path = data_path / req["path"]
|
|
|
56 |
assert json_path.is_relative_to(data_path), f"Path '{json_path}' is invalid"
|
57 |
json_path.unlink()
|
58 |
crdt_path.unlink()
|
59 |
+
crdt.delete_room(req["path"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
60 |
|
61 |
|
62 |
class DirectoryEntry(pydantic.BaseModel):
|
lynxkite-app/tests/test_main.py
CHANGED
@@ -28,36 +28,6 @@ def test_get_catalog():
|
|
28 |
assert response.status_code == 200
|
29 |
|
30 |
|
31 |
-
def test_save_and_load():
|
32 |
-
save_request = {
|
33 |
-
"path": "test",
|
34 |
-
"ws": {
|
35 |
-
"env": "test",
|
36 |
-
"nodes": [
|
37 |
-
{
|
38 |
-
"id": "Node_1",
|
39 |
-
"type": "basic",
|
40 |
-
"data": {
|
41 |
-
"display": None,
|
42 |
-
"input_metadata": None,
|
43 |
-
"error": "Unknown operation.",
|
44 |
-
"title": "Test node",
|
45 |
-
"params": {"param1": "value"},
|
46 |
-
},
|
47 |
-
"position": {"x": -493.5496596237119, "y": 20.90123252513356},
|
48 |
-
}
|
49 |
-
],
|
50 |
-
"edges": [],
|
51 |
-
},
|
52 |
-
}
|
53 |
-
response = client.post("/api/save", json=save_request)
|
54 |
-
saved_ws = response.json()
|
55 |
-
assert response.status_code == 200
|
56 |
-
response = client.get("/api/load?path=test")
|
57 |
-
assert response.status_code == 200
|
58 |
-
assert saved_ws == response.json()
|
59 |
-
|
60 |
-
|
61 |
def test_list_dir():
|
62 |
test_dir = pathlib.Path() / str(uuid.uuid4())
|
63 |
test_dir.mkdir(parents=True, exist_ok=True)
|
|
|
28 |
assert response.status_code == 200
|
29 |
|
30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
31 |
def test_list_dir():
|
32 |
test_dir = pathlib.Path() / str(uuid.uuid4())
|
33 |
test_dir.mkdir(parents=True, exist_ok=True)
|
lynxkite-app/uv.lock
CHANGED
The diff for this file is too large to render.
See raw diff
|
|
lynxkite-app/web/src/Directory.tsx
CHANGED
@@ -58,7 +58,7 @@ function EntryCreator(props: {
|
|
58 |
|
59 |
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
60 |
|
61 |
-
export default function () {
|
62 |
const path = usePath().replace(/^[/]$|^[/]dir$|^[/]dir[/]/, "");
|
63 |
const encodedPath = encodeURIComponent(path || "");
|
64 |
const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher, {
|
|
|
58 |
|
59 |
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
60 |
|
61 |
+
export default function Directory() {
|
62 |
const path = usePath().replace(/^[/]$|^[/]dir$|^[/]dir[/]/, "");
|
63 |
const encodedPath = encodeURIComponent(path || "");
|
64 |
const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher, {
|
lynxkite-app/web/src/Tooltip.tsx
CHANGED
@@ -7,9 +7,9 @@ export default function Tooltip(props: any) {
|
|
7 |
if (!props.doc) return null;
|
8 |
return (
|
9 |
<>
|
10 |
-
<
|
11 |
{props.children}
|
12 |
-
</
|
13 |
<ReactTooltip id={id} className="tooltip prose" place="top-end">
|
14 |
{props.doc.map?.(
|
15 |
(section: any, i: number) =>
|
|
|
7 |
if (!props.doc) return null;
|
8 |
return (
|
9 |
<>
|
10 |
+
<span data-tooltip-id={id} tabIndex={0}>
|
11 |
{props.children}
|
12 |
+
</span>
|
13 |
<ReactTooltip id={id} className="tooltip prose" place="top-end">
|
14 |
{props.doc.map?.(
|
15 |
(section: any, i: number) =>
|
lynxkite-app/web/src/apiTypes.ts
CHANGED
@@ -34,6 +34,8 @@ export interface WorkspaceNode {
|
|
34 |
type: string;
|
35 |
data: WorkspaceNodeData;
|
36 |
position: Position;
|
|
|
|
|
37 |
[k: string]: unknown;
|
38 |
}
|
39 |
export interface WorkspaceNodeData {
|
|
|
34 |
type: string;
|
35 |
data: WorkspaceNodeData;
|
36 |
position: Position;
|
37 |
+
width: number;
|
38 |
+
height: number;
|
39 |
[k: string]: unknown;
|
40 |
}
|
41 |
export interface WorkspaceNodeData {
|
lynxkite-app/web/src/common.ts
CHANGED
@@ -5,3 +5,12 @@ export function usePath() {
|
|
5 |
const path = decodeURIComponent(useLocation().pathname).replace(/[/]$/, "");
|
6 |
return path;
|
7 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
const path = decodeURIComponent(useLocation().pathname).replace(/[/]$/, "");
|
6 |
return path;
|
7 |
}
|
8 |
+
|
9 |
+
export const COLORS: { [key: string]: string } = {
|
10 |
+
gray: "oklch(95% 0 0)",
|
11 |
+
pink: "oklch(75% 0.2 0)",
|
12 |
+
orange: "oklch(75% 0.2 55)",
|
13 |
+
green: "oklch(75% 0.2 150)",
|
14 |
+
blue: "oklch(75% 0.2 230)",
|
15 |
+
purple: "oklch(75% 0.2 290)",
|
16 |
+
};
|
lynxkite-app/web/src/index.css
CHANGED
@@ -76,6 +76,7 @@ body {
|
|
76 |
.lynxkite-node {
|
77 |
box-shadow: 0px 5px 50px 0px rgba(0, 0, 0, 0.3);
|
78 |
border-radius: 4px;
|
|
|
79 |
background: white;
|
80 |
display: flex;
|
81 |
flex-direction: column;
|
@@ -88,6 +89,46 @@ body {
|
|
88 |
}
|
89 |
}
|
90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
91 |
.tooltip {
|
92 |
padding: 8px;
|
93 |
border-radius: 4px;
|
@@ -102,6 +143,10 @@ body {
|
|
102 |
max-width: 300px;
|
103 |
}
|
104 |
|
|
|
|
|
|
|
|
|
105 |
.expanded .lynxkite-node {
|
106 |
height: 100%;
|
107 |
}
|
@@ -526,12 +571,17 @@ body {
|
|
526 |
z-index: -10 !important;
|
527 |
}
|
528 |
|
|
|
529 |
.selected .comment-view,
|
530 |
.selected .lynxkite-node {
|
531 |
outline: var(--xy-selection-border, var(--xy-selection-border-default));
|
532 |
outline-offset: 7.5px;
|
533 |
}
|
534 |
|
|
|
|
|
|
|
|
|
535 |
.graph-creation-view {
|
536 |
display: flex;
|
537 |
width: 100%;
|
|
|
76 |
.lynxkite-node {
|
77 |
box-shadow: 0px 5px 50px 0px rgba(0, 0, 0, 0.3);
|
78 |
border-radius: 4px;
|
79 |
+
overflow: hidden;
|
80 |
background: white;
|
81 |
display: flex;
|
82 |
flex-direction: column;
|
|
|
89 |
}
|
90 |
}
|
91 |
|
92 |
+
.in-group .lynxkite-node {
|
93 |
+
box-shadow: 0px 1px 5px 0px rgba(0, 0, 0, 0.3);
|
94 |
+
opacity: 0.3;
|
95 |
+
transition: opacity 0.3s;
|
96 |
+
}
|
97 |
+
|
98 |
+
.in-group .lynxkite-node:hover {
|
99 |
+
opacity: 1;
|
100 |
+
}
|
101 |
+
|
102 |
+
.node-group {
|
103 |
+
box-shadow: 0px 3px 30px 0px rgba(0, 0, 0, 0.3);
|
104 |
+
border-radius: 20px;
|
105 |
+
border: none;
|
106 |
+
background-color: white;
|
107 |
+
opacity: 0.9;
|
108 |
+
display: flex;
|
109 |
+
flex-direction: column;
|
110 |
+
align-items: end;
|
111 |
+
padding: 10px 20px;
|
112 |
+
}
|
113 |
+
|
114 |
+
.node-group.in-group {
|
115 |
+
opacity: 0.5;
|
116 |
+
}
|
117 |
+
|
118 |
+
.node-group-color-picker-icon {
|
119 |
+
font-size: 30px;
|
120 |
+
opacity: 0.1;
|
121 |
+
transition: opacity 0.3s;
|
122 |
+
}
|
123 |
+
|
124 |
+
.node-group:hover .node-group-color-picker-icon {
|
125 |
+
opacity: 1;
|
126 |
+
}
|
127 |
+
|
128 |
+
.color-picker-button {
|
129 |
+
font-size: 30px;
|
130 |
+
}
|
131 |
+
|
132 |
.tooltip {
|
133 |
padding: 8px;
|
134 |
border-radius: 4px;
|
|
|
143 |
max-width: 300px;
|
144 |
}
|
145 |
|
146 |
+
.prose p {
|
147 |
+
margin-bottom: 0;
|
148 |
+
}
|
149 |
+
|
150 |
.expanded .lynxkite-node {
|
151 |
height: 100%;
|
152 |
}
|
|
|
571 |
z-index: -10 !important;
|
572 |
}
|
573 |
|
574 |
+
.selected .node-group,
|
575 |
.selected .comment-view,
|
576 |
.selected .lynxkite-node {
|
577 |
outline: var(--xy-selection-border, var(--xy-selection-border-default));
|
578 |
outline-offset: 7.5px;
|
579 |
}
|
580 |
|
581 |
+
.selected .node-group {
|
582 |
+
outline-offset: 20px;
|
583 |
+
}
|
584 |
+
|
585 |
.graph-creation-view {
|
586 |
display: flex;
|
587 |
width: 100%;
|
lynxkite-app/web/src/workspace/NodeSearch.tsx
CHANGED
@@ -10,7 +10,7 @@ export type OpsOp = {
|
|
10 |
export type Catalog = { [op: string]: OpsOp };
|
11 |
export type Catalogs = { [env: string]: Catalog };
|
12 |
|
13 |
-
export default function (props: {
|
14 |
boxes: Catalog;
|
15 |
onCancel: any;
|
16 |
onAdd: any;
|
|
|
10 |
export type Catalog = { [op: string]: OpsOp };
|
11 |
export type Catalogs = { [env: string]: Catalog };
|
12 |
|
13 |
+
export default function NodeSearch(props: {
|
14 |
boxes: Catalog;
|
15 |
onCancel: any;
|
16 |
onAdd: any;
|
lynxkite-app/web/src/workspace/Workspace.tsx
CHANGED
@@ -16,7 +16,7 @@ import {
|
|
16 |
useUpdateNodeInternals,
|
17 |
} from "@xyflow/react";
|
18 |
import axios from "axios";
|
19 |
-
import { type MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
|
20 |
import { Link } from "react-router";
|
21 |
import useSWR, { type Fetcher } from "swr";
|
22 |
import { WebsocketProvider } from "y-websocket";
|
@@ -25,10 +25,15 @@ import Atom from "~icons/tabler/atom.jsx";
|
|
25 |
// @ts-ignore
|
26 |
import Backspace from "~icons/tabler/backspace.jsx";
|
27 |
// @ts-ignore
|
|
|
|
|
|
|
|
|
28 |
import Restart from "~icons/tabler/rotate-clockwise.jsx";
|
29 |
// @ts-ignore
|
30 |
import Close from "~icons/tabler/x.jsx";
|
31 |
-
import
|
|
|
32 |
import favicon from "../assets/favicon.ico";
|
33 |
import { usePath } from "../common.ts";
|
34 |
// import NodeWithTableView from './NodeWithTableView';
|
@@ -37,6 +42,7 @@ import LynxKiteEdge from "./LynxKiteEdge.tsx";
|
|
37 |
import { LynxKiteState } from "./LynxKiteState";
|
38 |
import NodeSearch, { type OpsOp, type Catalog, type Catalogs } from "./NodeSearch.tsx";
|
39 |
import NodeWithGraphCreationView from "./nodes/GraphCreationNode.tsx";
|
|
|
40 |
import NodeWithComment from "./nodes/NodeWithComment.tsx";
|
41 |
import NodeWithImage from "./nodes/NodeWithImage.tsx";
|
42 |
import NodeWithMolecule from "./nodes/NodeWithMolecule.tsx";
|
@@ -44,7 +50,7 @@ import NodeWithParams from "./nodes/NodeWithParams";
|
|
44 |
import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
|
45 |
import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
|
46 |
|
47 |
-
export default function (props: any) {
|
48 |
return (
|
49 |
<ReactFlowProvider>
|
50 |
<LynxKiteFlow {...props} />
|
@@ -55,6 +61,7 @@ export default function (props: any) {
|
|
55 |
function LynxKiteFlow() {
|
56 |
const updateNodeInternals = useUpdateNodeInternals();
|
57 |
const reactFlow = useReactFlow();
|
|
|
58 |
const [nodes, setNodes] = useState([] as Node[]);
|
59 |
const [edges, setEdges] = useState([] as Edge[]);
|
60 |
const path = usePath().replace(/^[/]edit[/]/, "");
|
@@ -62,10 +69,10 @@ function LynxKiteFlow() {
|
|
62 |
.split("/")
|
63 |
.pop()!
|
64 |
.replace(/[.]lynxkite[.]json$/, "");
|
65 |
-
const [state, setState] = useState({ workspace: {} as
|
66 |
const [message, setMessage] = useState(null as string | null);
|
67 |
useEffect(() => {
|
68 |
-
const state = syncedStore({ workspace: {} as
|
69 |
setState(state);
|
70 |
const doc = getYjsDoc(state);
|
71 |
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
@@ -78,7 +85,7 @@ function LynxKiteFlow() {
|
|
78 |
if (!state.workspace.nodes) return;
|
79 |
if (!state.workspace.edges) return;
|
80 |
for (const n of state.workspace.nodes) {
|
81 |
-
if (n.dragHandle !== ".drag-handle") {
|
82 |
n.dragHandle = ".drag-handle";
|
83 |
}
|
84 |
}
|
@@ -185,6 +192,7 @@ function LynxKiteFlow() {
|
|
185 |
graph_creation_view: NodeWithGraphCreationView,
|
186 |
molecule: NodeWithMolecule,
|
187 |
comment: NodeWithComment,
|
|
|
188 |
}),
|
189 |
[],
|
190 |
);
|
@@ -203,7 +211,7 @@ function LynxKiteFlow() {
|
|
203 |
if (event.key === "/") {
|
204 |
event.preventDefault();
|
205 |
setNodeSearchSettings({
|
206 |
-
pos:
|
207 |
boxes: catalog.data![state.workspace.env!],
|
208 |
});
|
209 |
} else if (event.key === "r") {
|
@@ -218,6 +226,39 @@ function LynxKiteFlow() {
|
|
218 |
};
|
219 |
}, [catalog.data, nodeSearchSettings, state.workspace.env]);
|
220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
221 |
function isTypingInFormElement() {
|
222 |
const activeElement = document.activeElement;
|
223 |
return (
|
@@ -247,25 +288,27 @@ function LynxKiteFlow() {
|
|
247 |
},
|
248 |
[catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
|
249 |
);
|
250 |
-
function
|
251 |
-
const title = node.data?.title;
|
252 |
let i = 1;
|
253 |
-
|
254 |
-
const
|
255 |
-
while (
|
256 |
i += 1;
|
257 |
-
|
258 |
}
|
259 |
-
|
|
|
|
|
|
|
260 |
setNodes([...nodes, node as WorkspaceNode]);
|
261 |
}
|
262 |
function nodeFromMeta(meta: OpsOp): Partial<WorkspaceNode> {
|
263 |
const node: Partial<WorkspaceNode> = {
|
264 |
type: meta.type,
|
265 |
data: {
|
266 |
-
meta: meta,
|
267 |
title: meta.name,
|
268 |
-
params: Object.fromEntries(
|
269 |
},
|
270 |
};
|
271 |
return node;
|
@@ -278,7 +321,8 @@ function LynxKiteFlow() {
|
|
278 |
x: nss.pos.x,
|
279 |
y: nss.pos.y,
|
280 |
});
|
281 |
-
|
|
|
282 |
closeNodeSearch();
|
283 |
},
|
284 |
[nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
|
@@ -321,6 +365,7 @@ function LynxKiteFlow() {
|
|
321 |
setMessage(null);
|
322 |
const cat = catalog.data![state.workspace.env!];
|
323 |
const node = nodeFromMeta(cat["Import file"]);
|
|
|
324 |
node.position = reactFlow.screenToFlowPosition({
|
325 |
x: e.clientX,
|
326 |
y: e.clientY,
|
@@ -335,7 +380,7 @@ function LynxKiteFlow() {
|
|
335 |
} else if (file.name.includes(".xls")) {
|
336 |
node.data!.params.file_format = "excel";
|
337 |
}
|
338 |
-
addNode(node
|
339 |
} catch (error) {
|
340 |
setMessage("File upload failed.");
|
341 |
}
|
@@ -346,6 +391,106 @@ function LynxKiteFlow() {
|
|
346 |
setMessage("Workspace execution failed.");
|
347 |
}
|
348 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
349 |
return (
|
350 |
<div className="workspace">
|
351 |
<div className="top-bar bg-neutral">
|
@@ -362,21 +507,43 @@ function LynxKiteFlow() {
|
|
362 |
}}
|
363 |
/>
|
364 |
<div className="tools text-secondary">
|
365 |
-
|
366 |
-
<
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
377 |
</div>
|
378 |
</div>
|
379 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
380 |
<LynxKiteState.Provider value={state}>
|
381 |
<ReactFlow
|
382 |
nodes={nodes}
|
@@ -389,9 +556,13 @@ function LynxKiteFlow() {
|
|
389 |
onPaneClick={toggleNodeSearch}
|
390 |
onConnect={onConnect}
|
391 |
proOptions={{ hideAttribution: true }}
|
392 |
-
maxZoom={
|
393 |
minZoom={0.2}
|
394 |
zoomOnScroll={false}
|
|
|
|
|
|
|
|
|
395 |
preventScrolling={false}
|
396 |
defaultEdgeOptions={{
|
397 |
markerEnd: {
|
@@ -405,6 +576,7 @@ function LynxKiteFlow() {
|
|
405 |
stroke: "black",
|
406 |
},
|
407 |
}}
|
|
|
408 |
>
|
409 |
<Controls />
|
410 |
{nodeSearchSettings && (
|
|
|
16 |
useUpdateNodeInternals,
|
17 |
} from "@xyflow/react";
|
18 |
import axios from "axios";
|
19 |
+
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
20 |
import { Link } from "react-router";
|
21 |
import useSWR, { type Fetcher } from "swr";
|
22 |
import { WebsocketProvider } from "y-websocket";
|
|
|
25 |
// @ts-ignore
|
26 |
import Backspace from "~icons/tabler/backspace.jsx";
|
27 |
// @ts-ignore
|
28 |
+
import UngroupIcon from "~icons/tabler/library-minus.jsx";
|
29 |
+
// @ts-ignore
|
30 |
+
import GroupIcon from "~icons/tabler/library-plus.jsx";
|
31 |
+
// @ts-ignore
|
32 |
import Restart from "~icons/tabler/rotate-clockwise.jsx";
|
33 |
// @ts-ignore
|
34 |
import Close from "~icons/tabler/x.jsx";
|
35 |
+
import Tooltip from "../Tooltip.tsx";
|
36 |
+
import type { WorkspaceNode, Workspace as WorkspaceType } from "../apiTypes.ts";
|
37 |
import favicon from "../assets/favicon.ico";
|
38 |
import { usePath } from "../common.ts";
|
39 |
// import NodeWithTableView from './NodeWithTableView';
|
|
|
42 |
import { LynxKiteState } from "./LynxKiteState";
|
43 |
import NodeSearch, { type OpsOp, type Catalog, type Catalogs } from "./NodeSearch.tsx";
|
44 |
import NodeWithGraphCreationView from "./nodes/GraphCreationNode.tsx";
|
45 |
+
import Group from "./nodes/Group.tsx";
|
46 |
import NodeWithComment from "./nodes/NodeWithComment.tsx";
|
47 |
import NodeWithImage from "./nodes/NodeWithImage.tsx";
|
48 |
import NodeWithMolecule from "./nodes/NodeWithMolecule.tsx";
|
|
|
50 |
import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
|
51 |
import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
|
52 |
|
53 |
+
export default function Workspace(props: any) {
|
54 |
return (
|
55 |
<ReactFlowProvider>
|
56 |
<LynxKiteFlow {...props} />
|
|
|
61 |
function LynxKiteFlow() {
|
62 |
const updateNodeInternals = useUpdateNodeInternals();
|
63 |
const reactFlow = useReactFlow();
|
64 |
+
const reactFlowContainer = useRef<HTMLDivElement>(null);
|
65 |
const [nodes, setNodes] = useState([] as Node[]);
|
66 |
const [edges, setEdges] = useState([] as Edge[]);
|
67 |
const path = usePath().replace(/^[/]edit[/]/, "");
|
|
|
69 |
.split("/")
|
70 |
.pop()!
|
71 |
.replace(/[.]lynxkite[.]json$/, "");
|
72 |
+
const [state, setState] = useState({ workspace: {} as WorkspaceType });
|
73 |
const [message, setMessage] = useState(null as string | null);
|
74 |
useEffect(() => {
|
75 |
+
const state = syncedStore({ workspace: {} as WorkspaceType });
|
76 |
setState(state);
|
77 |
const doc = getYjsDoc(state);
|
78 |
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
|
85 |
if (!state.workspace.nodes) return;
|
86 |
if (!state.workspace.edges) return;
|
87 |
for (const n of state.workspace.nodes) {
|
88 |
+
if (n.type !== "node_group" && n.dragHandle !== ".drag-handle") {
|
89 |
n.dragHandle = ".drag-handle";
|
90 |
}
|
91 |
}
|
|
|
192 |
graph_creation_view: NodeWithGraphCreationView,
|
193 |
molecule: NodeWithMolecule,
|
194 |
comment: NodeWithComment,
|
195 |
+
node_group: Group,
|
196 |
}),
|
197 |
[],
|
198 |
);
|
|
|
211 |
if (event.key === "/") {
|
212 |
event.preventDefault();
|
213 |
setNodeSearchSettings({
|
214 |
+
pos: getBestPosition(),
|
215 |
boxes: catalog.data![state.workspace.env!],
|
216 |
});
|
217 |
} else if (event.key === "r") {
|
|
|
226 |
};
|
227 |
}, [catalog.data, nodeSearchSettings, state.workspace.env]);
|
228 |
|
229 |
+
function getBestPosition() {
|
230 |
+
const W = reactFlowContainer.current!.clientWidth;
|
231 |
+
const H = reactFlowContainer.current!.clientHeight;
|
232 |
+
const w = 200;
|
233 |
+
const h = 200;
|
234 |
+
const SPEED = 20;
|
235 |
+
const GAP = 50;
|
236 |
+
const pos = { x: 100, y: 100 };
|
237 |
+
while (pos.y < H) {
|
238 |
+
// Find a position that is not occupied by a node.
|
239 |
+
const fpos = reactFlow.screenToFlowPosition(pos);
|
240 |
+
const occupied = state.workspace.nodes!.some((n) => {
|
241 |
+
const np = n.position;
|
242 |
+
return (
|
243 |
+
np.x < fpos.x + w + GAP &&
|
244 |
+
np.x + n.width + GAP > fpos.x &&
|
245 |
+
np.y < fpos.y + h + GAP &&
|
246 |
+
np.y + n.height + GAP > fpos.y
|
247 |
+
);
|
248 |
+
});
|
249 |
+
if (!occupied) {
|
250 |
+
return pos;
|
251 |
+
}
|
252 |
+
// Move the position to the right and down until we find a free spot.
|
253 |
+
pos.x += SPEED;
|
254 |
+
if (pos.x + w > W) {
|
255 |
+
pos.x = 100;
|
256 |
+
pos.y += SPEED;
|
257 |
+
}
|
258 |
+
}
|
259 |
+
return { x: 100, y: 100 };
|
260 |
+
}
|
261 |
+
|
262 |
function isTypingInFormElement() {
|
263 |
const activeElement = document.activeElement;
|
264 |
return (
|
|
|
288 |
},
|
289 |
[catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
|
290 |
);
|
291 |
+
function findFreeId(prefix: string) {
|
|
|
292 |
let i = 1;
|
293 |
+
let id = `${prefix} ${i}`;
|
294 |
+
const used = new Set(state.workspace.nodes!.map((n) => n.id));
|
295 |
+
while (used.has(id)) {
|
296 |
i += 1;
|
297 |
+
id = `${prefix} ${i}`;
|
298 |
}
|
299 |
+
return id;
|
300 |
+
}
|
301 |
+
function addNode(node: Partial<WorkspaceNode>) {
|
302 |
+
state.workspace.nodes!.push(node as WorkspaceNode);
|
303 |
setNodes([...nodes, node as WorkspaceNode]);
|
304 |
}
|
305 |
function nodeFromMeta(meta: OpsOp): Partial<WorkspaceNode> {
|
306 |
const node: Partial<WorkspaceNode> = {
|
307 |
type: meta.type,
|
308 |
data: {
|
309 |
+
meta: { value: meta },
|
310 |
title: meta.name,
|
311 |
+
params: Object.fromEntries(meta.params.map((p) => [p.name, p.default])),
|
312 |
},
|
313 |
};
|
314 |
return node;
|
|
|
321 |
x: nss.pos.x,
|
322 |
y: nss.pos.y,
|
323 |
});
|
324 |
+
node.id = findFreeId(node.data!.title);
|
325 |
+
addNode(node);
|
326 |
closeNodeSearch();
|
327 |
},
|
328 |
[nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
|
|
|
365 |
setMessage(null);
|
366 |
const cat = catalog.data![state.workspace.env!];
|
367 |
const node = nodeFromMeta(cat["Import file"]);
|
368 |
+
node.id = findFreeId(node.data!.title);
|
369 |
node.position = reactFlow.screenToFlowPosition({
|
370 |
x: e.clientX,
|
371 |
y: e.clientY,
|
|
|
380 |
} else if (file.name.includes(".xls")) {
|
381 |
node.data!.params.file_format = "excel";
|
382 |
}
|
383 |
+
addNode(node);
|
384 |
} catch (error) {
|
385 |
setMessage("File upload failed.");
|
386 |
}
|
|
|
391 |
setMessage("Workspace execution failed.");
|
392 |
}
|
393 |
}
|
394 |
+
function deleteSelection() {
|
395 |
+
const selectedNodes = nodes.filter((n) => n.selected);
|
396 |
+
const selectedEdges = edges.filter((e) => e.selected);
|
397 |
+
reactFlow.deleteElements({ nodes: selectedNodes, edges: selectedEdges });
|
398 |
+
}
|
399 |
+
function groupSelection() {
|
400 |
+
const selectedNodes = nodes.filter((n) => n.selected && !n.parentId);
|
401 |
+
const groupNode = {
|
402 |
+
id: findFreeId("Group"),
|
403 |
+
type: "node_group",
|
404 |
+
position: { x: 0, y: 0 },
|
405 |
+
width: 0,
|
406 |
+
height: 0,
|
407 |
+
data: { title: "Group", params: {} },
|
408 |
+
};
|
409 |
+
let top = Number.POSITIVE_INFINITY;
|
410 |
+
let left = Number.POSITIVE_INFINITY;
|
411 |
+
let bottom = Number.NEGATIVE_INFINITY;
|
412 |
+
let right = Number.NEGATIVE_INFINITY;
|
413 |
+
const PAD = 10;
|
414 |
+
for (const node of selectedNodes) {
|
415 |
+
if (node.position.y - PAD < top) top = node.position.y - PAD;
|
416 |
+
if (node.position.x - PAD < left) left = node.position.x - PAD;
|
417 |
+
if (node.position.y + PAD + node.height! > bottom)
|
418 |
+
bottom = node.position.y + PAD + node.height!;
|
419 |
+
if (node.position.x + PAD + node.width! > right) right = node.position.x + PAD + node.width!;
|
420 |
+
}
|
421 |
+
groupNode.position = {
|
422 |
+
x: left,
|
423 |
+
y: top,
|
424 |
+
};
|
425 |
+
groupNode.width = right - left;
|
426 |
+
groupNode.height = bottom - top;
|
427 |
+
setNodes([
|
428 |
+
{ ...(groupNode as WorkspaceNode), selected: true },
|
429 |
+
...nodes.map((n) =>
|
430 |
+
n.selected
|
431 |
+
? {
|
432 |
+
...n,
|
433 |
+
position: { x: n.position.x - left, y: n.position.y - top },
|
434 |
+
parentId: groupNode.id,
|
435 |
+
extent: "parent" as const,
|
436 |
+
selected: false,
|
437 |
+
}
|
438 |
+
: n,
|
439 |
+
),
|
440 |
+
]);
|
441 |
+
getYjsDoc(state).transact(() => {
|
442 |
+
state.workspace.nodes!.unshift(groupNode as WorkspaceNode);
|
443 |
+
const selectedNodeIds = new Set(selectedNodes.map((n) => n.id));
|
444 |
+
for (const node of state.workspace.nodes!) {
|
445 |
+
if (selectedNodeIds.has(node.id)) {
|
446 |
+
node.position.x -= left;
|
447 |
+
node.position.y -= top;
|
448 |
+
node.parentId = groupNode.id;
|
449 |
+
node.extent = "parent";
|
450 |
+
node.selected = false;
|
451 |
+
}
|
452 |
+
}
|
453 |
+
});
|
454 |
+
}
|
455 |
+
function ungroupSelection() {
|
456 |
+
const groups = Object.fromEntries(
|
457 |
+
nodes
|
458 |
+
.filter((n) => n.selected && n.type === "node_group" && !n.parentId)
|
459 |
+
.map((n) => [n.id, n]),
|
460 |
+
);
|
461 |
+
setNodes(
|
462 |
+
nodes
|
463 |
+
.filter((n) => !groups[n.id])
|
464 |
+
.map((n) => {
|
465 |
+
const g = groups[n.parentId!];
|
466 |
+
if (!g) return n;
|
467 |
+
return {
|
468 |
+
...n,
|
469 |
+
position: { x: n.position.x + g.position.x, y: n.position.y + g.position.y },
|
470 |
+
parentId: undefined,
|
471 |
+
extent: undefined,
|
472 |
+
selected: true,
|
473 |
+
};
|
474 |
+
}),
|
475 |
+
);
|
476 |
+
getYjsDoc(state).transact(() => {
|
477 |
+
const wnodes = state.workspace.nodes!;
|
478 |
+
for (const node of state.workspace.nodes!) {
|
479 |
+
const g = groups[node.parentId as string];
|
480 |
+
if (!g) continue;
|
481 |
+
node.position.x += g.position.x;
|
482 |
+
node.position.y += g.position.y;
|
483 |
+
node.parentId = undefined;
|
484 |
+
node.extent = undefined;
|
485 |
+
}
|
486 |
+
for (const groupId in groups) {
|
487 |
+
const groupIdx = wnodes.findIndex((n) => n.id === groupId);
|
488 |
+
wnodes.splice(groupIdx, 1);
|
489 |
+
}
|
490 |
+
});
|
491 |
+
}
|
492 |
+
const areMultipleNodesSelected = nodes.filter((n) => n.selected).length > 1;
|
493 |
+
const isAnyGroupSelected = nodes.some((n) => n.selected && n.type === "node_group");
|
494 |
return (
|
495 |
<div className="workspace">
|
496 |
<div className="top-bar bg-neutral">
|
|
|
507 |
}}
|
508 |
/>
|
509 |
<div className="tools text-secondary">
|
510 |
+
{areMultipleNodesSelected && (
|
511 |
+
<Tooltip doc="Group selected nodes">
|
512 |
+
<button className="btn btn-link" onClick={groupSelection}>
|
513 |
+
<GroupIcon />
|
514 |
+
</button>
|
515 |
+
</Tooltip>
|
516 |
+
)}
|
517 |
+
{isAnyGroupSelected && (
|
518 |
+
<Tooltip doc="Ungroup selected nodes">
|
519 |
+
<button className="btn btn-link" onClick={ungroupSelection}>
|
520 |
+
<UngroupIcon />
|
521 |
+
</button>
|
522 |
+
</Tooltip>
|
523 |
+
)}
|
524 |
+
<Tooltip doc="Delete selected nodes and edges">
|
525 |
+
<button className="btn btn-link" onClick={deleteSelection}>
|
526 |
+
<Backspace />
|
527 |
+
</button>
|
528 |
+
</Tooltip>
|
529 |
+
<Tooltip doc="Re-run the workspace">
|
530 |
+
<button className="btn btn-link" onClick={executeWorkspace}>
|
531 |
+
<Restart />
|
532 |
+
</button>
|
533 |
+
</Tooltip>
|
534 |
+
<Tooltip doc="Close workspace">
|
535 |
+
<Link className="btn btn-link" to={`/dir/${parentDir}`} aria-label="close">
|
536 |
+
<Close />
|
537 |
+
</Link>
|
538 |
+
</Tooltip>
|
539 |
</div>
|
540 |
</div>
|
541 |
+
<div
|
542 |
+
style={{ height: "100%", width: "100vw" }}
|
543 |
+
onDragOver={onDragOver}
|
544 |
+
onDrop={onDrop}
|
545 |
+
ref={reactFlowContainer}
|
546 |
+
>
|
547 |
<LynxKiteState.Provider value={state}>
|
548 |
<ReactFlow
|
549 |
nodes={nodes}
|
|
|
556 |
onPaneClick={toggleNodeSearch}
|
557 |
onConnect={onConnect}
|
558 |
proOptions={{ hideAttribution: true }}
|
559 |
+
maxZoom={10}
|
560 |
minZoom={0.2}
|
561 |
zoomOnScroll={false}
|
562 |
+
panOnScroll={true}
|
563 |
+
panOnDrag={false}
|
564 |
+
selectionOnDrag={true}
|
565 |
+
panOnScrollSpeed={1}
|
566 |
preventScrolling={false}
|
567 |
defaultEdgeOptions={{
|
568 |
markerEnd: {
|
|
|
576 |
stroke: "black",
|
577 |
},
|
578 |
}}
|
579 |
+
fitViewOptions={{ maxZoom: 1 }}
|
580 |
>
|
581 |
<Controls />
|
582 |
{nodeSearchSettings && (
|
lynxkite-app/web/src/workspace/nodes/Group.tsx
ADDED
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useReactFlow } from "@xyflow/react";
|
2 |
+
import { useState } from "react";
|
3 |
+
// @ts-ignore
|
4 |
+
import Palette from "~icons/tabler/palette-filled.jsx";
|
5 |
+
// @ts-ignore
|
6 |
+
import Square from "~icons/tabler/square-filled.jsx";
|
7 |
+
import Tooltip from "../../Tooltip.tsx";
|
8 |
+
import { COLORS } from "../../common.ts";
|
9 |
+
|
10 |
+
export default function Group(props: any) {
|
11 |
+
const reactFlow = useReactFlow();
|
12 |
+
const [displayingColorPicker, setDisplayingColorPicker] = useState(false);
|
13 |
+
function setColor(newValue: string) {
|
14 |
+
reactFlow.updateNodeData(props.id, (prevData: any) => ({
|
15 |
+
...prevData,
|
16 |
+
params: { color: newValue },
|
17 |
+
}));
|
18 |
+
setDisplayingColorPicker(false);
|
19 |
+
}
|
20 |
+
function toggleColorPicker(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
|
21 |
+
e.stopPropagation();
|
22 |
+
setDisplayingColorPicker(!displayingColorPicker);
|
23 |
+
}
|
24 |
+
const currentColor = props.data?.params?.color || "gray";
|
25 |
+
return (
|
26 |
+
<div
|
27 |
+
className={`node-group ${props.parentId ? "in-group" : ""}`}
|
28 |
+
style={{
|
29 |
+
width: props.width,
|
30 |
+
height: props.height,
|
31 |
+
backgroundColor: COLORS[currentColor],
|
32 |
+
}}
|
33 |
+
>
|
34 |
+
<button
|
35 |
+
className="node-group-color-picker-icon"
|
36 |
+
onClick={toggleColorPicker}
|
37 |
+
aria-label="Change group color"
|
38 |
+
>
|
39 |
+
<Tooltip doc="Change color">
|
40 |
+
<Palette />
|
41 |
+
</Tooltip>
|
42 |
+
</button>
|
43 |
+
{displayingColorPicker && <ColorPicker currentColor={currentColor} onPick={setColor} />}
|
44 |
+
</div>
|
45 |
+
);
|
46 |
+
}
|
47 |
+
|
48 |
+
function ColorPicker(props: { currentColor: string; onPick: (color: string) => void }) {
|
49 |
+
const colors = Object.keys(COLORS).filter((color) => color !== props.currentColor);
|
50 |
+
return (
|
51 |
+
<div className="color-picker">
|
52 |
+
{colors.map((color) => (
|
53 |
+
<button
|
54 |
+
key={color}
|
55 |
+
className="color-picker-button"
|
56 |
+
style={{ color: COLORS[color] }}
|
57 |
+
onClick={() => props.onPick(color)}
|
58 |
+
>
|
59 |
+
<Square />
|
60 |
+
</button>
|
61 |
+
))}
|
62 |
+
</div>
|
63 |
+
);
|
64 |
+
}
|
lynxkite-app/web/src/workspace/nodes/LynxKiteNode.tsx
CHANGED
@@ -12,6 +12,7 @@ import Help from "~icons/tabler/question-mark.jsx";
|
|
12 |
// @ts-ignore
|
13 |
import Skull from "~icons/tabler/skull.jsx";
|
14 |
import Tooltip from "../../Tooltip";
|
|
|
15 |
|
16 |
interface LynxKiteNodeProps {
|
17 |
id: string;
|
@@ -20,6 +21,7 @@ interface LynxKiteNodeProps {
|
|
20 |
nodeStyle: any;
|
21 |
data: any;
|
22 |
children: any;
|
|
|
23 |
}
|
24 |
|
25 |
function getHandles(inputs: any[], outputs: any[]) {
|
@@ -53,15 +55,6 @@ function getHandles(inputs: any[], outputs: any[]) {
|
|
53 |
return handles;
|
54 |
}
|
55 |
|
56 |
-
const OP_COLORS: { [key: string]: string } = {
|
57 |
-
gray: "oklch(95% 0 0)",
|
58 |
-
pink: "oklch(75% 0.2 0)",
|
59 |
-
orange: "oklch(75% 0.2 55)",
|
60 |
-
green: "oklch(75% 0.2 150)",
|
61 |
-
blue: "oklch(75% 0.2 230)",
|
62 |
-
purple: "oklch(75% 0.2 290)",
|
63 |
-
};
|
64 |
-
|
65 |
function LynxKiteNodeComponent(props: LynxKiteNodeProps) {
|
66 |
const reactFlow = useReactFlow();
|
67 |
const data = props.data;
|
@@ -78,11 +71,11 @@ function LynxKiteNodeComponent(props: LynxKiteNodeProps) {
|
|
78 |
};
|
79 |
const titleStyle: { backgroundColor?: string } = {};
|
80 |
if (data.meta?.value?.color) {
|
81 |
-
titleStyle.backgroundColor =
|
82 |
}
|
83 |
return (
|
84 |
<div
|
85 |
-
className={`node-container ${expanded ? "expanded" : "collapsed"} `}
|
86 |
style={{
|
87 |
width: props.width || 200,
|
88 |
height: expanded ? props.height || 200 : undefined,
|
|
|
12 |
// @ts-ignore
|
13 |
import Skull from "~icons/tabler/skull.jsx";
|
14 |
import Tooltip from "../../Tooltip";
|
15 |
+
import { COLORS } from "../../common.ts";
|
16 |
|
17 |
interface LynxKiteNodeProps {
|
18 |
id: string;
|
|
|
21 |
nodeStyle: any;
|
22 |
data: any;
|
23 |
children: any;
|
24 |
+
parentId?: string;
|
25 |
}
|
26 |
|
27 |
function getHandles(inputs: any[], outputs: any[]) {
|
|
|
55 |
return handles;
|
56 |
}
|
57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
58 |
function LynxKiteNodeComponent(props: LynxKiteNodeProps) {
|
59 |
const reactFlow = useReactFlow();
|
60 |
const data = props.data;
|
|
|
71 |
};
|
72 |
const titleStyle: { backgroundColor?: string } = {};
|
73 |
if (data.meta?.value?.color) {
|
74 |
+
titleStyle.backgroundColor = COLORS[data.meta.value.color] || data.meta.value.color;
|
75 |
}
|
76 |
return (
|
77 |
<div
|
78 |
+
className={`node-container ${expanded ? "expanded" : "collapsed"} ${props.parentId ? "in-group" : ""}`}
|
79 |
style={{
|
80 |
width: props.width || 200,
|
81 |
height: expanded ? props.height || 200 : undefined,
|
lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx
CHANGED
@@ -237,7 +237,7 @@ export default function NodeParameter({ name, value, meta, data, setParam }: Nod
|
|
237 |
<textarea
|
238 |
className="textarea textarea-bordered w-full"
|
239 |
rows={6}
|
240 |
-
value={value}
|
241 |
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
242 |
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
243 |
/>
|
|
|
237 |
<textarea
|
238 |
className="textarea textarea-bordered w-full"
|
239 |
rows={6}
|
240 |
+
value={value || ""}
|
241 |
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
242 |
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
243 |
/>
|
lynxkite-app/web/tests/import.spec.ts
CHANGED
@@ -63,6 +63,7 @@ test("Can import a JSON file", async () => {
|
|
63 |
await validateImport(workspace, "import_test.json", "json");
|
64 |
});
|
65 |
|
66 |
-
|
|
|
67 |
await validateImport(workspace, "import_test.xlsx", "excel");
|
68 |
});
|
|
|
63 |
await validateImport(workspace, "import_test.json", "json");
|
64 |
});
|
65 |
|
66 |
+
// Needs openpyxl. It's the same code as the other formats, so not worth installing it in CI.
|
67 |
+
test.skip("Can import an Excel file", async () => {
|
68 |
await validateImport(workspace, "import_test.xlsx", "excel");
|
69 |
});
|
lynxkite-app/web/tests/lynxkite.ts
CHANGED
@@ -53,14 +53,8 @@ export class Workspace {
|
|
53 |
}
|
54 |
|
55 |
async addBox(boxName) {
|
56 |
-
//TODO: Support passing box parameters
|
57 |
const allBoxes = await this.getBoxes().all();
|
58 |
-
if (allBoxes) {
|
59 |
-
// Avoid overlapping with existing nodes
|
60 |
-
const numNodes = allBoxes.length || 1;
|
61 |
-
await this.page.mouse.wheel(0, numNodes * 400);
|
62 |
-
}
|
63 |
-
|
64 |
await this.page.locator(".ws-name").click();
|
65 |
await this.page.keyboard.press("/");
|
66 |
await this.page.locator(".node-search").getByText(boxName, { exact: true }).click();
|
@@ -102,11 +96,8 @@ export class Workspace {
|
|
102 |
return this.page.locator(".react-flow__node");
|
103 |
}
|
104 |
|
105 |
-
getBoxHandle(boxId: string, pos
|
106 |
-
|
107 |
-
return this.page.locator(`[data-id="${boxId}"] [data-handlepos="${pos}"]`);
|
108 |
-
}
|
109 |
-
return this.page.getByTestId(boxId);
|
110 |
}
|
111 |
|
112 |
async moveBox(
|
@@ -135,13 +126,28 @@ export class Workspace {
|
|
135 |
await this.page.mouse.up();
|
136 |
}
|
137 |
|
138 |
-
async
|
139 |
const sourceHandle = this.getBoxHandle(sourceId, "right");
|
140 |
const targetHandle = this.getBoxHandle(targetId, "left");
|
|
|
|
|
141 |
await sourceHandle.hover();
|
142 |
await this.page.mouse.down();
|
|
|
143 |
await targetHandle.hover();
|
144 |
await this.page.mouse.up();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
145 |
}
|
146 |
|
147 |
async execute() {
|
|
|
53 |
}
|
54 |
|
55 |
async addBox(boxName) {
|
56 |
+
// TODO: Support passing box parameters.
|
57 |
const allBoxes = await this.getBoxes().all();
|
|
|
|
|
|
|
|
|
|
|
|
|
58 |
await this.page.locator(".ws-name").click();
|
59 |
await this.page.keyboard.press("/");
|
60 |
await this.page.locator(".node-search").getByText(boxName, { exact: true }).click();
|
|
|
96 |
return this.page.locator(".react-flow__node");
|
97 |
}
|
98 |
|
99 |
+
getBoxHandle(boxId: string, pos: string) {
|
100 |
+
return this.page.locator(`.connectable[data-nodeid="${boxId}"][data-handlepos="${pos}"]`);
|
|
|
|
|
|
|
101 |
}
|
102 |
|
103 |
async moveBox(
|
|
|
126 |
await this.page.mouse.up();
|
127 |
}
|
128 |
|
129 |
+
async tryToConnectBoxes(sourceId: string, targetId: string) {
|
130 |
const sourceHandle = this.getBoxHandle(sourceId, "right");
|
131 |
const targetHandle = this.getBoxHandle(targetId, "left");
|
132 |
+
await expect(sourceHandle).toBeVisible();
|
133 |
+
await expect(targetHandle).toBeVisible();
|
134 |
await sourceHandle.hover();
|
135 |
await this.page.mouse.down();
|
136 |
+
await expect(this.page.locator(".react-flow__connectionline")).toBeAttached({ timeout: 1000 });
|
137 |
await targetHandle.hover();
|
138 |
await this.page.mouse.up();
|
139 |
+
await expect(
|
140 |
+
this.page.locator(`.react-flow__edge[aria-label="Edge from ${sourceId} to ${targetId}"]`),
|
141 |
+
).toBeAttached({ timeout: 1000 });
|
142 |
+
}
|
143 |
+
async connectBoxes(sourceId: string, targetId: string) {
|
144 |
+
// The method above is unreliable. I gave up after a lot of debugging and added these retries.
|
145 |
+
while (true) {
|
146 |
+
try {
|
147 |
+
await this.tryToConnectBoxes(sourceId, targetId);
|
148 |
+
return;
|
149 |
+
} catch (e) {}
|
150 |
+
}
|
151 |
}
|
152 |
|
153 |
async execute() {
|
lynxkite-core/src/lynxkite/core/executors/one_by_one.py
CHANGED
@@ -18,8 +18,8 @@ class Context(ops.BaseConfig):
|
|
18 |
Attributes:
|
19 |
node: The workspace node that this context is associated with.
|
20 |
last_result: The last result produced by the operation.
|
21 |
-
|
22 |
-
|
23 |
"""
|
24 |
|
25 |
node: workspace.WorkspaceNode
|
|
|
18 |
Attributes:
|
19 |
node: The workspace node that this context is associated with.
|
20 |
last_result: The last result produced by the operation.
|
21 |
+
This can be used to incrementally build a result, when the operation
|
22 |
+
is executed for multiple items.
|
23 |
"""
|
24 |
|
25 |
node: workspace.WorkspaceNode
|
lynxkite-core/src/lynxkite/core/workspace.py
CHANGED
@@ -40,12 +40,13 @@ class WorkspaceNodeData(BaseConfig):
|
|
40 |
|
41 |
|
42 |
class WorkspaceNode(BaseConfig):
|
43 |
-
#
|
44 |
-
# modyfing them will break the frontend.
|
45 |
id: str
|
46 |
type: str
|
47 |
data: WorkspaceNodeData
|
48 |
position: Position
|
|
|
|
|
49 |
_crdt: pycrdt.Map
|
50 |
|
51 |
def publish_started(self):
|
@@ -202,27 +203,27 @@ class Workspace(BaseConfig):
|
|
202 |
nc["data"] = pycrdt.Map()
|
203 |
np._crdt = nc
|
204 |
|
205 |
-
def add_node(self, func):
|
206 |
"""For convenience in e.g. tests."""
|
207 |
random_string = os.urandom(4).hex()
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
status=NodeStatus.planned,
|
218 |
-
),
|
219 |
-
position=Position(x=0, y=0),
|
220 |
-
)
|
221 |
self.nodes.append(node)
|
222 |
return node
|
223 |
|
224 |
def add_edge(
|
225 |
-
self,
|
|
|
|
|
|
|
|
|
226 |
):
|
227 |
"""For convenience in e.g. tests."""
|
228 |
edge = WorkspaceEdge(
|
|
|
40 |
|
41 |
|
42 |
class WorkspaceNode(BaseConfig):
|
43 |
+
# Most of these fields are shared with ReactFlow.
|
|
|
44 |
id: str
|
45 |
type: str
|
46 |
data: WorkspaceNodeData
|
47 |
position: Position
|
48 |
+
width: Optional[float] = None
|
49 |
+
height: Optional[float] = None
|
50 |
_crdt: pycrdt.Map
|
51 |
|
52 |
def publish_started(self):
|
|
|
203 |
nc["data"] = pycrdt.Map()
|
204 |
np._crdt = nc
|
205 |
|
206 |
+
def add_node(self, func=None, **kwargs):
|
207 |
"""For convenience in e.g. tests."""
|
208 |
random_string = os.urandom(4).hex()
|
209 |
+
if func:
|
210 |
+
kwargs["type"] = func.__op__.type
|
211 |
+
kwargs["data"] = WorkspaceNodeData(title=func.__op__.name, params={})
|
212 |
+
kwargs.setdefault("type", "basic")
|
213 |
+
kwargs.setdefault("id", f"{kwargs['data'].title} {random_string}")
|
214 |
+
kwargs.setdefault("position", Position(x=0, y=0))
|
215 |
+
kwargs.setdefault("width", 100)
|
216 |
+
kwargs.setdefault("height", 100)
|
217 |
+
node = WorkspaceNode(**kwargs)
|
|
|
|
|
|
|
|
|
218 |
self.nodes.append(node)
|
219 |
return node
|
220 |
|
221 |
def add_edge(
|
222 |
+
self,
|
223 |
+
source: WorkspaceNode,
|
224 |
+
sourceHandle: str,
|
225 |
+
target: WorkspaceNode,
|
226 |
+
targetHandle: str,
|
227 |
):
|
228 |
"""For convenience in e.g. tests."""
|
229 |
edge = WorkspaceEdge(
|
lynxkite-core/tests/test_workspace.py
CHANGED
@@ -6,21 +6,15 @@ from lynxkite.core import workspace
|
|
6 |
|
7 |
def test_save_load():
|
8 |
ws = workspace.Workspace(env="test")
|
9 |
-
ws.
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
data=workspace.WorkspaceNodeData(title="Node 1", params={}),
|
14 |
-
position=workspace.Position(x=0, y=0),
|
15 |
-
)
|
16 |
)
|
17 |
-
ws.
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
data=workspace.WorkspaceNodeData(title="Node 2", params={}),
|
22 |
-
position=workspace.Position(x=0, y=0),
|
23 |
-
)
|
24 |
)
|
25 |
ws.edges.append(
|
26 |
workspace.WorkspaceEdge(
|
@@ -72,21 +66,15 @@ def populate_ops_catalog():
|
|
72 |
|
73 |
def test_update_metadata():
|
74 |
ws = workspace.Workspace(env="test")
|
75 |
-
ws.
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
data=workspace.WorkspaceNodeData(title="Test Operation", params={}),
|
80 |
-
position=workspace.Position(x=0, y=0),
|
81 |
-
)
|
82 |
)
|
83 |
-
ws.
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
data=workspace.WorkspaceNodeData(title="Unknown Operation", params={}),
|
88 |
-
position=workspace.Position(x=0, y=0),
|
89 |
-
)
|
90 |
)
|
91 |
ws.update_metadata()
|
92 |
assert ws.nodes[0].data.meta.name == "Test Operation"
|
|
|
6 |
|
7 |
def test_save_load():
|
8 |
ws = workspace.Workspace(env="test")
|
9 |
+
ws.add_node(
|
10 |
+
id="1",
|
11 |
+
type="node_type",
|
12 |
+
data=workspace.WorkspaceNodeData(title="Node 1", params={}),
|
|
|
|
|
|
|
13 |
)
|
14 |
+
ws.add_node(
|
15 |
+
id="2",
|
16 |
+
type="node_type",
|
17 |
+
data=workspace.WorkspaceNodeData(title="Node 2", params={}),
|
|
|
|
|
|
|
18 |
)
|
19 |
ws.edges.append(
|
20 |
workspace.WorkspaceEdge(
|
|
|
66 |
|
67 |
def test_update_metadata():
|
68 |
ws = workspace.Workspace(env="test")
|
69 |
+
ws.add_node(
|
70 |
+
id="1",
|
71 |
+
type="basic",
|
72 |
+
data=workspace.WorkspaceNodeData(title="Test Operation", params={}),
|
|
|
|
|
|
|
73 |
)
|
74 |
+
ws.add_node(
|
75 |
+
id="2",
|
76 |
+
type="basic",
|
77 |
+
data=workspace.WorkspaceNodeData(title="Unknown Operation", params={}),
|
|
|
|
|
|
|
78 |
)
|
79 |
ws.update_metadata()
|
80 |
assert ws.nodes[0].data.meta.name == "Test Operation"
|
lynxkite-graph-analytics/pyproject.toml
CHANGED
@@ -19,6 +19,10 @@ dependencies = [
|
|
19 |
"torch-geometric>=2.6.1",
|
20 |
"umap-learn>=0.5.7",
|
21 |
]
|
|
|
|
|
|
|
|
|
22 |
|
23 |
[project.optional-dependencies]
|
24 |
dev = [
|
|
|
19 |
"torch-geometric>=2.6.1",
|
20 |
"umap-learn>=0.5.7",
|
21 |
]
|
22 |
+
classifiers = ["License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)"]
|
23 |
+
|
24 |
+
[project.urls]
|
25 |
+
Homepage = "https://github.com/lynxkite/lynxkite-2000/"
|
26 |
|
27 |
[project.optional-dependencies]
|
28 |
dev = [
|
lynxkite-graph-analytics/tests/test_lynxkite_ops.py
CHANGED
@@ -8,13 +8,11 @@ from lynxkite_graph_analytics.core import Bundle, execute, ENV
|
|
8 |
|
9 |
async def test_execute_operation_not_in_catalog():
|
10 |
ws = workspace.Workspace(env=ENV)
|
11 |
-
ws.
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
position=workspace.Position(x=0, y=0),
|
17 |
-
)
|
18 |
)
|
19 |
await execute(ws)
|
20 |
assert ws.nodes[0].data.error == "Operation not found in catalog"
|
@@ -43,37 +41,29 @@ async def test_execute_operation_inputs_correct_cast():
|
|
43 |
return bundle
|
44 |
|
45 |
ws = workspace.Workspace(env="test")
|
46 |
-
ws.
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
position=workspace.Position(x=0, y=0),
|
52 |
-
)
|
53 |
)
|
54 |
-
ws.
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
position=workspace.Position(x=100, y=0),
|
60 |
-
)
|
61 |
)
|
62 |
-
ws.
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
position=workspace.Position(x=200, y=0),
|
68 |
-
)
|
69 |
)
|
70 |
-
ws.
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
position=workspace.Position(x=300, y=0),
|
76 |
-
)
|
77 |
)
|
78 |
ws.edges = [
|
79 |
workspace.WorkspaceEdge(
|
@@ -109,29 +99,23 @@ async def test_multiple_inputs():
|
|
109 |
return a < b
|
110 |
|
111 |
ws = workspace.Workspace(env="test")
|
112 |
-
ws.
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
position=workspace.Position(x=0, y=0),
|
118 |
-
)
|
119 |
)
|
120 |
-
ws.
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
position=workspace.Position(x=100, y=0),
|
126 |
-
)
|
127 |
)
|
128 |
-
ws.
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
position=workspace.Position(x=200, y=0),
|
134 |
-
)
|
135 |
)
|
136 |
ws.edges = [
|
137 |
workspace.WorkspaceEdge(
|
|
|
8 |
|
9 |
async def test_execute_operation_not_in_catalog():
|
10 |
ws = workspace.Workspace(env=ENV)
|
11 |
+
ws.add_node(
|
12 |
+
id="1",
|
13 |
+
type="node_type",
|
14 |
+
data=workspace.WorkspaceNodeData(title="Non existing op", params={}),
|
15 |
+
position=workspace.Position(x=0, y=0),
|
|
|
|
|
16 |
)
|
17 |
await execute(ws)
|
18 |
assert ws.nodes[0].data.error == "Operation not found in catalog"
|
|
|
41 |
return bundle
|
42 |
|
43 |
ws = workspace.Workspace(env="test")
|
44 |
+
ws.add_node(
|
45 |
+
id="1",
|
46 |
+
type="node_type",
|
47 |
+
data=workspace.WorkspaceNodeData(title="Create Bundle", params={}),
|
48 |
+
position=workspace.Position(x=0, y=0),
|
|
|
|
|
49 |
)
|
50 |
+
ws.add_node(
|
51 |
+
id="2",
|
52 |
+
type="node_type",
|
53 |
+
data=workspace.WorkspaceNodeData(title="Bundle to Graph", params={}),
|
54 |
+
position=workspace.Position(x=100, y=0),
|
|
|
|
|
55 |
)
|
56 |
+
ws.add_node(
|
57 |
+
id="3",
|
58 |
+
type="node_type",
|
59 |
+
data=workspace.WorkspaceNodeData(title="Graph to Bundle", params={}),
|
60 |
+
position=workspace.Position(x=200, y=0),
|
|
|
|
|
61 |
)
|
62 |
+
ws.add_node(
|
63 |
+
id="4",
|
64 |
+
type="node_type",
|
65 |
+
data=workspace.WorkspaceNodeData(title="Dataframe to Bundle", params={}),
|
66 |
+
position=workspace.Position(x=300, y=0),
|
|
|
|
|
67 |
)
|
68 |
ws.edges = [
|
69 |
workspace.WorkspaceEdge(
|
|
|
99 |
return a < b
|
100 |
|
101 |
ws = workspace.Workspace(env="test")
|
102 |
+
ws.add_node(
|
103 |
+
id="one",
|
104 |
+
type="cool",
|
105 |
+
data=workspace.WorkspaceNodeData(title="One", params={}),
|
106 |
+
position=workspace.Position(x=0, y=0),
|
|
|
|
|
107 |
)
|
108 |
+
ws.add_node(
|
109 |
+
id="two",
|
110 |
+
type="cool",
|
111 |
+
data=workspace.WorkspaceNodeData(title="Two", params={}),
|
112 |
+
position=workspace.Position(x=100, y=0),
|
|
|
|
|
113 |
)
|
114 |
+
ws.add_node(
|
115 |
+
id="smaller",
|
116 |
+
type="cool",
|
117 |
+
data=workspace.WorkspaceNodeData(title="Smaller?", params={}),
|
118 |
+
position=workspace.Position(x=200, y=0),
|
|
|
|
|
119 |
)
|
120 |
ws.edges = [
|
121 |
workspace.WorkspaceEdge(
|
lynxkite-graph-analytics/tests/test_pytorch_model_ops.py
CHANGED
@@ -9,16 +9,9 @@ def make_ws(env, nodes: dict[str, dict], edges: list[tuple[str, str]]):
|
|
9 |
for id, data in nodes.items():
|
10 |
title = data["title"]
|
11 |
del data["title"]
|
12 |
-
ws.
|
13 |
-
|
14 |
-
|
15 |
-
type="basic",
|
16 |
-
data=workspace.WorkspaceNodeData(title=title, params=data),
|
17 |
-
position=workspace.Position(
|
18 |
-
x=data.get("x", 0),
|
19 |
-
y=data.get("y", 0),
|
20 |
-
),
|
21 |
-
)
|
22 |
)
|
23 |
ws.edges = [
|
24 |
workspace.WorkspaceEdge(
|
|
|
9 |
for id, data in nodes.items():
|
10 |
title = data["title"]
|
11 |
del data["title"]
|
12 |
+
ws.add_node(
|
13 |
+
id=id,
|
14 |
+
data=workspace.WorkspaceNodeData(title=title, params=data),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
15 |
)
|
16 |
ws.edges = [
|
17 |
workspace.WorkspaceEdge(
|
mkdocs.yml
CHANGED
@@ -4,12 +4,14 @@ repo_name: lynxkite/lynxkite-2000
|
|
4 |
watch: [mkdocs.yml, README.md, lynxkite-core, lynxkite-graph-analytics, lynxkite-app]
|
5 |
|
6 |
nav:
|
7 |
-
-
|
8 |
- Overview: index.md
|
9 |
-
-
|
10 |
-
-
|
11 |
-
|
12 |
-
-
|
|
|
|
|
13 |
- API reference:
|
14 |
- LynxKite Core:
|
15 |
- reference/lynxkite-core/ops.md
|
|
|
4 |
watch: [mkdocs.yml, README.md, lynxkite-core, lynxkite-graph-analytics, lynxkite-app]
|
5 |
|
6 |
nav:
|
7 |
+
- About LynxKite:
|
8 |
- Overview: index.md
|
9 |
+
- contributing.md
|
10 |
+
- license.md
|
11 |
+
- Guides:
|
12 |
+
- guides/quickstart.md
|
13 |
+
- guides/analytics.md
|
14 |
+
- guides/plugins.md
|
15 |
- API reference:
|
16 |
- LynxKite Core:
|
17 |
- reference/lynxkite-core/ops.md
|