darabos commited on
Commit
b6728a1
·
unverified ·
2 Parent(s): 0defc51 e4ff751

Merge pull request #89 from biggraph/darabos-upload

Browse files
Dockerfile CHANGED
@@ -11,6 +11,6 @@ RUN uv venv && uv pip install \
11
  -e lynxkite-graph-analytics \
12
  -e lynxkite-bio \
13
  -e lynxkite-pillow-example
14
- ENV LYNXKITE_DATA=examples
15
  ENV PORT=7860
16
  CMD ["uv", "run", "lynxkite"]
 
11
  -e lynxkite-graph-analytics \
12
  -e lynxkite-bio \
13
  -e lynxkite-pillow-example
14
+ WORKDIR $HOME/app/examples
15
  ENV PORT=7860
16
  CMD ["uv", "run", "lynxkite"]
README.md CHANGED
@@ -41,7 +41,7 @@ uv pip install -e lynxkite-core/[dev] -e lynxkite-app/[dev] -e lynxkite-graph-an
41
  This also builds the frontend, hopefully very quickly. To run it:
42
 
43
  ```bash
44
- LYNXKITE_DATA=examples LYNXKITE_RELOAD=1 lynxkite
45
  ```
46
 
47
  If you also want to make changes to the frontend with hot reloading:
 
41
  This also builds the frontend, hopefully very quickly. To run it:
42
 
43
  ```bash
44
+ cd examples && LYNXKITE_RELOAD=1 lynxkite
45
  ```
46
 
47
  If you also want to make changes to the frontend with hot reloading:
examples/AIMO CHANGED
@@ -786,7 +786,7 @@
786
  "data": {
787
  "title": "Input CSV",
788
  "params": {
789
- "filename": "data/aimo-examples.csv",
790
  "key": "problem"
791
  },
792
  "display": null,
 
786
  "data": {
787
  "title": "Input CSV",
788
  "params": {
789
+ "filename": "uploads/aimo-examples.csv",
790
  "key": "problem"
791
  },
792
  "display": null,
examples/Bio demo CHANGED
@@ -7,7 +7,7 @@
7
  "data": {
8
  "title": "Import CSV",
9
  "params": {
10
- "filename": "examples/drug_target_data_sample.csv",
11
  "separator": "<auto>",
12
  "columns": "<from file>"
13
  },
 
7
  "data": {
8
  "title": "Import CSV",
9
  "params": {
10
+ "filename": "uploads/drug_target_data_sample.csv",
11
  "separator": "<auto>",
12
  "columns": "<from file>"
13
  },
examples/Graph RAG CHANGED
@@ -7,7 +7,7 @@
7
  "data": {
8
  "title": "Input document",
9
  "params": {
10
- "filename": "examples/example-pizza.md"
11
  },
12
  "display": null,
13
  "error": null,
 
7
  "data": {
8
  "title": "Input document",
9
  "params": {
10
+ "filename": "uploads/example-pizza.md"
11
  },
12
  "display": null,
13
  "error": null,
examples/LynxScribe demo CHANGED
@@ -138,7 +138,7 @@
138
  "data": {
139
  "title": "Scenario selector",
140
  "params": {
141
- "scenario_file": "/home/darabos/nvme/lynxscribe/examples/chat_api/scenarios.yaml",
142
  "node_types": "intent_cluster"
143
  },
144
  "display": null,
@@ -252,9 +252,9 @@
252
  "data": {
253
  "title": "Knowledge base",
254
  "params": {
255
- "template_cluster_path": "/home/darabos/nvme/lynxscribe/examples/chat_api/data/lynx/tempclusters.pickle",
256
- "edges_path": "/home/darabos/nvme/lynxscribe/examples/chat_api/data/lynx/edges.pickle",
257
- "nodes_path": "/home/darabos/nvme/lynxscribe/examples/chat_api/data/lynx/nodes.pickle"
258
  },
259
  "display": null,
260
  "error": null,
 
138
  "data": {
139
  "title": "Scenario selector",
140
  "params": {
141
+ "scenario_file": "uploads/chat_api/scenarios.yaml",
142
  "node_types": "intent_cluster"
143
  },
144
  "display": null,
 
252
  "data": {
253
  "title": "Knowledge base",
254
  "params": {
255
+ "template_cluster_path": "uploads/chat_api/data/lynx/tempclusters.pickle",
256
+ "edges_path": "uploads/chat_api/data/lynx/edges.pickle",
257
+ "nodes_path": "uploads/chat_api/data/lynx/nodes.pickle"
258
  },
259
  "display": null,
260
  "error": null,
examples/{aimo-examples.csv → uploads/aimo-examples.csv} RENAMED
File without changes
examples/{drug_target_data_sample.csv → uploads/drug_target_data_sample.csv} RENAMED
File without changes
examples/{example-pizza.md → uploads/example-pizza.md} RENAMED
File without changes
lynxkite-app/README.md CHANGED
@@ -13,7 +13,7 @@ To run the backend:
13
 
14
  ```bash
15
  uv pip install -e .
16
- LYNXKITE_DATA=../examples LYNXKITE_RELOAD=1 lynxkite
17
  ```
18
 
19
  To run the frontend:
 
13
 
14
  ```bash
15
  uv pip install -e .
16
+ cd ../examples && LYNXKITE_RELOAD=1 lynxkite
17
  ```
18
 
19
  To run the frontend:
lynxkite-app/src/lynxkite_app/config.py DELETED
@@ -1,8 +0,0 @@
1
- """Some common configuration."""
2
-
3
- import os
4
- import pathlib
5
-
6
-
7
- DATA_PATH = pathlib.Path(os.environ.get("LYNXKITE_DATA", "lynxkite_data"))
8
- CRDT_PATH = pathlib.Path(os.environ.get("LYNXKITE_CRDT_DATA", "lynxkite_crdt_data"))
 
 
 
 
 
 
 
 
 
lynxkite-app/src/lynxkite_app/crdt.py CHANGED
@@ -3,6 +3,7 @@
3
  import asyncio
4
  import contextlib
5
  import enum
 
6
  import fastapi
7
  import os.path
8
  import pycrdt
@@ -11,7 +12,6 @@ import pycrdt_websocket.ystore
11
  import uvicorn
12
  import builtins
13
  from lynxkite.core import workspace, ops
14
- from . import config
15
 
16
  router = fastapi.APIRouter()
17
 
@@ -32,8 +32,9 @@ class WebsocketServer(pycrdt_websocket.WebsocketServer):
32
 
33
  The workspace is loaded from "crdt_data" if it exists there, or from "data", or a new workspace is created.
34
  """
35
- path = config.CRDT_PATH / f"{name}.crdt"
36
- assert path.is_relative_to(config.CRDT_PATH)
 
37
  ystore = pycrdt_websocket.ystore.FileYStore(path)
38
  ydoc = pycrdt.Doc()
39
  ydoc["workspace"] = ws = pycrdt.Map()
@@ -165,9 +166,8 @@ def try_to_load_workspace(ws: pycrdt.Map, name: str):
165
  ws: CRDT object to udpate with the workspace contents.
166
  name: Name of the workspace to load.
167
  """
168
- json_path = f"{config.DATA_PATH}/{name}"
169
- if os.path.exists(json_path):
170
- ws_pyd = workspace.load(json_path)
171
  # We treat the display field as a black box, since it is a large
172
  # dictionary that is meant to change as a whole.
173
  crdt_update(ws, ws_pyd.model_dump(), non_collaborative_fields={"display"})
@@ -225,8 +225,9 @@ async def execute(
225
  except asyncio.CancelledError:
226
  return
227
  print(f"Running {name} in {ws_pyd.env}...")
228
- path = config.DATA_PATH / name
229
- assert path.is_relative_to(config.DATA_PATH), "Provided workspace path is invalid"
 
230
  # Save user changes before executing, in case the execution fails.
231
  workspace.save(ws_pyd, path)
232
  ws_pyd._crdt = ws_crdt
 
3
  import asyncio
4
  import contextlib
5
  import enum
6
+ import pathlib
7
  import fastapi
8
  import os.path
9
  import pycrdt
 
12
  import uvicorn
13
  import builtins
14
  from lynxkite.core import workspace, ops
 
15
 
16
  router = fastapi.APIRouter()
17
 
 
32
 
33
  The workspace is loaded from "crdt_data" if it exists there, or from "data", or a new workspace is created.
34
  """
35
+ crdt_path = pathlib.Path(".crdt")
36
+ path = crdt_path / f"{name}.crdt"
37
+ assert path.is_relative_to(crdt_path)
38
  ystore = pycrdt_websocket.ystore.FileYStore(path)
39
  ydoc = pycrdt.Doc()
40
  ydoc["workspace"] = ws = pycrdt.Map()
 
166
  ws: CRDT object to udpate with the workspace contents.
167
  name: Name of the workspace to load.
168
  """
169
+ if os.path.exists(name):
170
+ ws_pyd = workspace.load(name)
 
171
  # We treat the display field as a black box, since it is a large
172
  # dictionary that is meant to change as a whole.
173
  crdt_update(ws, ws_pyd.model_dump(), non_collaborative_fields={"display"})
 
225
  except asyncio.CancelledError:
226
  return
227
  print(f"Running {name} in {ws_pyd.env}...")
228
+ cwd = pathlib.Path()
229
+ path = cwd / name
230
+ assert path.is_relative_to(cwd), "Provided workspace path is invalid"
231
  # Save user changes before executing, in case the execution fails.
232
  workspace.save(ws_pyd, path)
233
  ws_pyd._crdt = ws_crdt
lynxkite-app/src/lynxkite_app/main.py CHANGED
@@ -11,7 +11,7 @@ from fastapi.middleware.gzip import GZipMiddleware
11
  import starlette
12
  from lynxkite.core import ops
13
  from lynxkite.core import workspace
14
- from . import crdt, config
15
 
16
 
17
  def detect_plugins():
@@ -45,9 +45,12 @@ class SaveRequest(workspace.BaseConfig):
45
  ws: workspace.Workspace
46
 
47
 
 
 
 
48
  def save(req: SaveRequest):
49
- path = config.DATA_PATH / req.path
50
- assert path.is_relative_to(config.DATA_PATH)
51
  workspace.save(req.ws, path)
52
 
53
 
@@ -61,18 +64,17 @@ async def save_and_execute(req: SaveRequest):
61
 
62
  @app.post("/api/delete")
63
  async def delete_workspace(req: dict):
64
- json_path: pathlib.Path = config.DATA_PATH / req["path"]
65
- crdt_path: pathlib.Path = config.CRDT_PATH / f"{req['path']}.crdt"
66
- assert json_path.is_relative_to(config.DATA_PATH)
67
- assert crdt_path.is_relative_to(config.CRDT_PATH)
68
  json_path.unlink()
69
  crdt_path.unlink()
70
 
71
 
72
  @app.get("/api/load")
73
  def load(path: str):
74
- path = config.DATA_PATH / path
75
- assert path.is_relative_to(config.DATA_PATH)
76
  if not path.exists():
77
  return workspace.Workspace()
78
  return workspace.load(path)
@@ -85,15 +87,16 @@ class DirectoryEntry(pydantic.BaseModel):
85
 
86
  @app.get("/api/dir/list")
87
  def list_dir(path: str):
88
- path = config.DATA_PATH / path
89
- assert path.is_relative_to(config.DATA_PATH)
90
  return sorted(
91
  [
92
  DirectoryEntry(
93
- name=str(p.relative_to(config.DATA_PATH)),
94
  type="directory" if p.is_dir() else "workspace",
95
  )
96
  for p in path.iterdir()
 
97
  ],
98
  key=lambda x: x.name,
99
  )
@@ -101,16 +104,16 @@ def list_dir(path: str):
101
 
102
  @app.post("/api/dir/mkdir")
103
  def make_dir(req: dict):
104
- path = config.DATA_PATH / req["path"]
105
- assert path.is_relative_to(config.DATA_PATH)
106
  assert not path.exists(), f"{path} already exists"
107
  path.mkdir()
108
 
109
 
110
  @app.post("/api/dir/delete")
111
  def delete_dir(req: dict):
112
- path: pathlib.Path = config.DATA_PATH / req["path"]
113
- assert all([path.is_relative_to(config.DATA_PATH), path.exists(), path.is_dir()])
114
  shutil.rmtree(path)
115
 
116
 
@@ -128,6 +131,18 @@ async def service_post(req: fastapi.Request, module_path: str):
128
  return await module.api_service_post(req)
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  class SPAStaticFiles(StaticFiles):
132
  """Route everything to index.html. https://stackoverflow.com/a/73552966/3318517"""
133
 
 
11
  import starlette
12
  from lynxkite.core import ops
13
  from lynxkite.core import workspace
14
+ from . import crdt
15
 
16
 
17
  def detect_plugins():
 
45
  ws: workspace.Workspace
46
 
47
 
48
+ data_path = pathlib.Path()
49
+
50
+
51
  def save(req: SaveRequest):
52
+ path = data_path / req.path
53
+ assert path.is_relative_to(data_path)
54
  workspace.save(req.ws, path)
55
 
56
 
 
64
 
65
  @app.post("/api/delete")
66
  async def delete_workspace(req: dict):
67
+ json_path: pathlib.Path = data_path / req["path"]
68
+ crdt_path: pathlib.Path = data_path / ".crdt" / f"{req['path']}.crdt"
69
+ assert json_path.is_relative_to(data_path)
 
70
  json_path.unlink()
71
  crdt_path.unlink()
72
 
73
 
74
  @app.get("/api/load")
75
  def load(path: str):
76
+ path = data_path / path
77
+ assert path.is_relative_to(data_path)
78
  if not path.exists():
79
  return workspace.Workspace()
80
  return workspace.load(path)
 
87
 
88
  @app.get("/api/dir/list")
89
  def list_dir(path: str):
90
+ path = data_path / path
91
+ assert path.is_relative_to(data_path)
92
  return sorted(
93
  [
94
  DirectoryEntry(
95
+ name=str(p.relative_to(data_path)),
96
  type="directory" if p.is_dir() else "workspace",
97
  )
98
  for p in path.iterdir()
99
+ if not p.name.startswith(".")
100
  ],
101
  key=lambda x: x.name,
102
  )
 
104
 
105
  @app.post("/api/dir/mkdir")
106
  def make_dir(req: dict):
107
+ path = data_path / req["path"]
108
+ assert path.is_relative_to(data_path)
109
  assert not path.exists(), f"{path} already exists"
110
  path.mkdir()
111
 
112
 
113
  @app.post("/api/dir/delete")
114
  def delete_dir(req: dict):
115
+ path: pathlib.Path = data_path / req["path"]
116
+ assert all([path.is_relative_to(data_path), path.exists(), path.is_dir()])
117
  shutil.rmtree(path)
118
 
119
 
 
131
  return await module.api_service_post(req)
132
 
133
 
134
+ @app.post("/api/upload")
135
+ async def upload(req: fastapi.Request):
136
+ """Receives file uploads and stores them in DATA_PATH."""
137
+ form = await req.form()
138
+ for file in form.values():
139
+ file_path = data_path / "uploads" / file.filename
140
+ assert file_path.is_relative_to(data_path), "Invalid file path"
141
+ with file_path.open("wb") as buffer:
142
+ shutil.copyfileobj(file.file, buffer)
143
+ return {"status": "ok"}
144
+
145
+
146
  class SPAStaticFiles(StaticFiles):
147
  """Route everything to index.html. https://stackoverflow.com/a/73552966/3318517"""
148
 
lynxkite-app/tests/test_main.py CHANGED
@@ -1,7 +1,7 @@
 
1
  import uuid
2
  from fastapi.testclient import TestClient
3
  from lynxkite_app.main import app, detect_plugins
4
- from lynxkite_app.config import DATA_PATH
5
  import os
6
 
7
 
@@ -56,10 +56,9 @@ def test_save_and_load():
56
 
57
 
58
  def test_list_dir():
59
- test_dir = str(uuid.uuid4())
60
- test_dir_full_path = DATA_PATH / test_dir
61
- test_dir_full_path.mkdir(parents=True, exist_ok=True)
62
- test_file = test_dir_full_path / "test_file.txt"
63
  test_file.touch()
64
  response = client.get(f"/api/dir/list?path={str(test_dir)}")
65
  assert response.status_code == 200
@@ -67,12 +66,12 @@ def test_list_dir():
67
  assert response.json()[0]["name"] == f"{test_dir}/test_file.txt"
68
  assert response.json()[0]["type"] == "workspace"
69
  test_file.unlink()
70
- test_dir_full_path.rmdir()
71
 
72
 
73
  def test_make_dir():
74
  dir_name = str(uuid.uuid4())
75
  response = client.post("/api/dir/mkdir", json={"path": dir_name})
76
  assert response.status_code == 200
77
- assert os.path.exists(DATA_PATH / dir_name)
78
- os.rmdir(DATA_PATH / dir_name)
 
1
+ import pathlib
2
  import uuid
3
  from fastapi.testclient import TestClient
4
  from lynxkite_app.main import app, detect_plugins
 
5
  import os
6
 
7
 
 
56
 
57
 
58
  def test_list_dir():
59
+ test_dir = pathlib.Path() / str(uuid.uuid4())
60
+ test_dir.mkdir(parents=True, exist_ok=True)
61
+ test_file = test_dir / "test_file.txt"
 
62
  test_file.touch()
63
  response = client.get(f"/api/dir/list?path={str(test_dir)}")
64
  assert response.status_code == 200
 
66
  assert response.json()[0]["name"] == f"{test_dir}/test_file.txt"
67
  assert response.json()[0]["type"] == "workspace"
68
  test_file.unlink()
69
+ test_dir.rmdir()
70
 
71
 
72
  def test_make_dir():
73
  dir_name = str(uuid.uuid4())
74
  response = client.post("/api/dir/mkdir", json={"path": dir_name})
75
  assert response.status_code == 200
76
+ assert os.path.exists(dir_name)
77
+ os.rmdir(dir_name)
lynxkite-app/web/package-lock.json CHANGED
@@ -17,6 +17,7 @@
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",
@@ -2467,6 +2468,12 @@
2467
  "license": "MIT",
2468
  "optional": true
2469
  },
 
 
 
 
 
 
2470
  "node_modules/autoprefixer": {
2471
  "version": "10.4.20",
2472
  "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
@@ -2505,6 +2512,17 @@
2505
  "postcss": "^8.1.0"
2506
  }
2507
  },
 
 
 
 
 
 
 
 
 
 
 
2508
  "node_modules/bail": {
2509
  "version": "2.0.2",
2510
  "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -2637,6 +2655,19 @@
2637
  "ieee754": "^1.1.13"
2638
  }
2639
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
2640
  "node_modules/callsites": {
2641
  "version": "3.1.0",
2642
  "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2824,6 +2855,18 @@
2824
  "dev": true,
2825
  "license": "MIT"
2826
  },
 
 
 
 
 
 
 
 
 
 
 
 
2827
  "node_modules/comma-separated-tokens": {
2828
  "version": "2.0.3",
2829
  "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -3126,6 +3169,15 @@
3126
  "node": ">=6"
3127
  }
3128
  },
 
 
 
 
 
 
 
 
 
3129
  "node_modules/dequal": {
3130
  "version": "2.0.3",
3131
  "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -3172,6 +3224,20 @@
3172
  "tslib": "^2.0.3"
3173
  }
3174
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3175
  "node_modules/eastasianwidth": {
3176
  "version": "0.2.0",
3177
  "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -3252,6 +3318,51 @@
3252
  "is-arrayish": "^0.2.1"
3253
  }
3254
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3255
  "node_modules/esbuild": {
3256
  "version": "0.25.0",
3257
  "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
@@ -3639,6 +3750,26 @@
3639
  "dev": true,
3640
  "license": "ISC"
3641
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3642
  "node_modules/foreground-child": {
3643
  "version": "3.3.0",
3644
  "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
@@ -3656,6 +3787,21 @@
3656
  "url": "https://github.com/sponsors/isaacs"
3657
  }
3658
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3659
  "node_modules/fraction.js": {
3660
  "version": "4.3.7",
3661
  "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -3689,7 +3835,6 @@
3689
  "version": "1.1.2",
3690
  "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
3691
  "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
3692
- "dev": true,
3693
  "license": "MIT",
3694
  "funding": {
3695
  "url": "https://github.com/sponsors/ljharb"
@@ -3713,6 +3858,43 @@
3713
  "node": ">=6.9.0"
3714
  }
3715
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3716
  "node_modules/glob": {
3717
  "version": "10.4.5",
3718
  "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -3785,6 +3967,18 @@
3785
  "url": "https://github.com/sponsors/sindresorhus"
3786
  }
3787
  },
 
 
 
 
 
 
 
 
 
 
 
 
3788
  "node_modules/graphemer": {
3789
  "version": "1.4.0",
3790
  "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -3802,11 +3996,37 @@
3802
  "node": ">=8"
3803
  }
3804
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3805
  "node_modules/hasown": {
3806
  "version": "2.0.2",
3807
  "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
3808
  "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
3809
- "dev": true,
3810
  "license": "MIT",
3811
  "dependencies": {
3812
  "function-bind": "^1.1.2"
@@ -4523,6 +4743,15 @@
4523
  "license": "MIT",
4524
  "optional": true
4525
  },
 
 
 
 
 
 
 
 
 
4526
  "node_modules/mdast-util-from-markdown": {
4527
  "version": "2.0.2",
4528
  "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@@ -5142,6 +5371,27 @@
5142
  "node": ">=8.6"
5143
  }
5144
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5145
  "node_modules/minimatch": {
5146
  "version": "3.1.2",
5147
  "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -5766,6 +6016,12 @@
5766
  "url": "https://github.com/sponsors/wooorm"
5767
  }
5768
  },
 
 
 
 
 
 
5769
  "node_modules/prr": {
5770
  "version": "1.0.1",
5771
  "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
 
17
  "@syncedstore/react": "^0.6.0",
18
  "@types/node": "^22.10.1",
19
  "@xyflow/react": "^12.3.5",
20
+ "axios": "^1.8.2",
21
  "daisyui": "^4.12.20",
22
  "echarts": "^5.5.1",
23
  "fuse.js": "^7.0.0",
 
2468
  "license": "MIT",
2469
  "optional": true
2470
  },
2471
+ "node_modules/asynckit": {
2472
+ "version": "0.4.0",
2473
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
2474
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
2475
+ "license": "MIT"
2476
+ },
2477
  "node_modules/autoprefixer": {
2478
  "version": "10.4.20",
2479
  "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
 
2512
  "postcss": "^8.1.0"
2513
  }
2514
  },
2515
+ "node_modules/axios": {
2516
+ "version": "1.8.2",
2517
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
2518
+ "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
2519
+ "license": "MIT",
2520
+ "dependencies": {
2521
+ "follow-redirects": "^1.15.6",
2522
+ "form-data": "^4.0.0",
2523
+ "proxy-from-env": "^1.1.0"
2524
+ }
2525
+ },
2526
  "node_modules/bail": {
2527
  "version": "2.0.2",
2528
  "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
 
2655
  "ieee754": "^1.1.13"
2656
  }
2657
  },
2658
+ "node_modules/call-bind-apply-helpers": {
2659
+ "version": "1.0.2",
2660
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
2661
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
2662
+ "license": "MIT",
2663
+ "dependencies": {
2664
+ "es-errors": "^1.3.0",
2665
+ "function-bind": "^1.1.2"
2666
+ },
2667
+ "engines": {
2668
+ "node": ">= 0.4"
2669
+ }
2670
+ },
2671
  "node_modules/callsites": {
2672
  "version": "3.1.0",
2673
  "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
 
2855
  "dev": true,
2856
  "license": "MIT"
2857
  },
2858
+ "node_modules/combined-stream": {
2859
+ "version": "1.0.8",
2860
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
2861
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
2862
+ "license": "MIT",
2863
+ "dependencies": {
2864
+ "delayed-stream": "~1.0.0"
2865
+ },
2866
+ "engines": {
2867
+ "node": ">= 0.8"
2868
+ }
2869
+ },
2870
  "node_modules/comma-separated-tokens": {
2871
  "version": "2.0.3",
2872
  "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
 
3169
  "node": ">=6"
3170
  }
3171
  },
3172
+ "node_modules/delayed-stream": {
3173
+ "version": "1.0.0",
3174
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
3175
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
3176
+ "license": "MIT",
3177
+ "engines": {
3178
+ "node": ">=0.4.0"
3179
+ }
3180
+ },
3181
  "node_modules/dequal": {
3182
  "version": "2.0.3",
3183
  "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
 
3224
  "tslib": "^2.0.3"
3225
  }
3226
  },
3227
+ "node_modules/dunder-proto": {
3228
+ "version": "1.0.1",
3229
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
3230
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
3231
+ "license": "MIT",
3232
+ "dependencies": {
3233
+ "call-bind-apply-helpers": "^1.0.1",
3234
+ "es-errors": "^1.3.0",
3235
+ "gopd": "^1.2.0"
3236
+ },
3237
+ "engines": {
3238
+ "node": ">= 0.4"
3239
+ }
3240
+ },
3241
  "node_modules/eastasianwidth": {
3242
  "version": "0.2.0",
3243
  "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
 
3318
  "is-arrayish": "^0.2.1"
3319
  }
3320
  },
3321
+ "node_modules/es-define-property": {
3322
+ "version": "1.0.1",
3323
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
3324
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
3325
+ "license": "MIT",
3326
+ "engines": {
3327
+ "node": ">= 0.4"
3328
+ }
3329
+ },
3330
+ "node_modules/es-errors": {
3331
+ "version": "1.3.0",
3332
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
3333
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
3334
+ "license": "MIT",
3335
+ "engines": {
3336
+ "node": ">= 0.4"
3337
+ }
3338
+ },
3339
+ "node_modules/es-object-atoms": {
3340
+ "version": "1.1.1",
3341
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
3342
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
3343
+ "license": "MIT",
3344
+ "dependencies": {
3345
+ "es-errors": "^1.3.0"
3346
+ },
3347
+ "engines": {
3348
+ "node": ">= 0.4"
3349
+ }
3350
+ },
3351
+ "node_modules/es-set-tostringtag": {
3352
+ "version": "2.1.0",
3353
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
3354
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
3355
+ "license": "MIT",
3356
+ "dependencies": {
3357
+ "es-errors": "^1.3.0",
3358
+ "get-intrinsic": "^1.2.6",
3359
+ "has-tostringtag": "^1.0.2",
3360
+ "hasown": "^2.0.2"
3361
+ },
3362
+ "engines": {
3363
+ "node": ">= 0.4"
3364
+ }
3365
+ },
3366
  "node_modules/esbuild": {
3367
  "version": "0.25.0",
3368
  "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
 
3750
  "dev": true,
3751
  "license": "ISC"
3752
  },
3753
+ "node_modules/follow-redirects": {
3754
+ "version": "1.15.9",
3755
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
3756
+ "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
3757
+ "funding": [
3758
+ {
3759
+ "type": "individual",
3760
+ "url": "https://github.com/sponsors/RubenVerborgh"
3761
+ }
3762
+ ],
3763
+ "license": "MIT",
3764
+ "engines": {
3765
+ "node": ">=4.0"
3766
+ },
3767
+ "peerDependenciesMeta": {
3768
+ "debug": {
3769
+ "optional": true
3770
+ }
3771
+ }
3772
+ },
3773
  "node_modules/foreground-child": {
3774
  "version": "3.3.0",
3775
  "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
 
3787
  "url": "https://github.com/sponsors/isaacs"
3788
  }
3789
  },
3790
+ "node_modules/form-data": {
3791
+ "version": "4.0.2",
3792
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
3793
+ "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
3794
+ "license": "MIT",
3795
+ "dependencies": {
3796
+ "asynckit": "^0.4.0",
3797
+ "combined-stream": "^1.0.8",
3798
+ "es-set-tostringtag": "^2.1.0",
3799
+ "mime-types": "^2.1.12"
3800
+ },
3801
+ "engines": {
3802
+ "node": ">= 6"
3803
+ }
3804
+ },
3805
  "node_modules/fraction.js": {
3806
  "version": "4.3.7",
3807
  "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
 
3835
  "version": "1.1.2",
3836
  "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
3837
  "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
 
3838
  "license": "MIT",
3839
  "funding": {
3840
  "url": "https://github.com/sponsors/ljharb"
 
3858
  "node": ">=6.9.0"
3859
  }
3860
  },
3861
+ "node_modules/get-intrinsic": {
3862
+ "version": "1.3.0",
3863
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
3864
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
3865
+ "license": "MIT",
3866
+ "dependencies": {
3867
+ "call-bind-apply-helpers": "^1.0.2",
3868
+ "es-define-property": "^1.0.1",
3869
+ "es-errors": "^1.3.0",
3870
+ "es-object-atoms": "^1.1.1",
3871
+ "function-bind": "^1.1.2",
3872
+ "get-proto": "^1.0.1",
3873
+ "gopd": "^1.2.0",
3874
+ "has-symbols": "^1.1.0",
3875
+ "hasown": "^2.0.2",
3876
+ "math-intrinsics": "^1.1.0"
3877
+ },
3878
+ "engines": {
3879
+ "node": ">= 0.4"
3880
+ },
3881
+ "funding": {
3882
+ "url": "https://github.com/sponsors/ljharb"
3883
+ }
3884
+ },
3885
+ "node_modules/get-proto": {
3886
+ "version": "1.0.1",
3887
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
3888
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
3889
+ "license": "MIT",
3890
+ "dependencies": {
3891
+ "dunder-proto": "^1.0.1",
3892
+ "es-object-atoms": "^1.0.0"
3893
+ },
3894
+ "engines": {
3895
+ "node": ">= 0.4"
3896
+ }
3897
+ },
3898
  "node_modules/glob": {
3899
  "version": "10.4.5",
3900
  "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
 
3967
  "url": "https://github.com/sponsors/sindresorhus"
3968
  }
3969
  },
3970
+ "node_modules/gopd": {
3971
+ "version": "1.2.0",
3972
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
3973
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
3974
+ "license": "MIT",
3975
+ "engines": {
3976
+ "node": ">= 0.4"
3977
+ },
3978
+ "funding": {
3979
+ "url": "https://github.com/sponsors/ljharb"
3980
+ }
3981
+ },
3982
  "node_modules/graphemer": {
3983
  "version": "1.4.0",
3984
  "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
 
3996
  "node": ">=8"
3997
  }
3998
  },
3999
+ "node_modules/has-symbols": {
4000
+ "version": "1.1.0",
4001
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
4002
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
4003
+ "license": "MIT",
4004
+ "engines": {
4005
+ "node": ">= 0.4"
4006
+ },
4007
+ "funding": {
4008
+ "url": "https://github.com/sponsors/ljharb"
4009
+ }
4010
+ },
4011
+ "node_modules/has-tostringtag": {
4012
+ "version": "1.0.2",
4013
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
4014
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
4015
+ "license": "MIT",
4016
+ "dependencies": {
4017
+ "has-symbols": "^1.0.3"
4018
+ },
4019
+ "engines": {
4020
+ "node": ">= 0.4"
4021
+ },
4022
+ "funding": {
4023
+ "url": "https://github.com/sponsors/ljharb"
4024
+ }
4025
+ },
4026
  "node_modules/hasown": {
4027
  "version": "2.0.2",
4028
  "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
4029
  "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
 
4030
  "license": "MIT",
4031
  "dependencies": {
4032
  "function-bind": "^1.1.2"
 
4743
  "license": "MIT",
4744
  "optional": true
4745
  },
4746
+ "node_modules/math-intrinsics": {
4747
+ "version": "1.1.0",
4748
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
4749
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
4750
+ "license": "MIT",
4751
+ "engines": {
4752
+ "node": ">= 0.4"
4753
+ }
4754
+ },
4755
  "node_modules/mdast-util-from-markdown": {
4756
  "version": "2.0.2",
4757
  "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
 
5371
  "node": ">=8.6"
5372
  }
5373
  },
5374
+ "node_modules/mime-db": {
5375
+ "version": "1.52.0",
5376
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
5377
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
5378
+ "license": "MIT",
5379
+ "engines": {
5380
+ "node": ">= 0.6"
5381
+ }
5382
+ },
5383
+ "node_modules/mime-types": {
5384
+ "version": "2.1.35",
5385
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
5386
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
5387
+ "license": "MIT",
5388
+ "dependencies": {
5389
+ "mime-db": "1.52.0"
5390
+ },
5391
+ "engines": {
5392
+ "node": ">= 0.6"
5393
+ }
5394
+ },
5395
  "node_modules/minimatch": {
5396
  "version": "3.1.2",
5397
  "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
 
6016
  "url": "https://github.com/sponsors/wooorm"
6017
  }
6018
  },
6019
+ "node_modules/proxy-from-env": {
6020
+ "version": "1.1.0",
6021
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
6022
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
6023
+ "license": "MIT"
6024
+ },
6025
  "node_modules/prr": {
6026
  "version": "1.0.1",
6027
  "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
lynxkite-app/web/package.json CHANGED
@@ -20,6 +20,7 @@
20
  "@syncedstore/react": "^0.6.0",
21
  "@types/node": "^22.10.1",
22
  "@xyflow/react": "^12.3.5",
 
23
  "daisyui": "^4.12.20",
24
  "echarts": "^5.5.1",
25
  "fuse.js": "^7.0.0",
@@ -35,6 +36,8 @@
35
  },
36
  "devDependencies": {
37
  "@eslint/js": "^9.15.0",
 
 
38
  "@types/react": "^18.3.14",
39
  "@types/react-dom": "^18.3.2",
40
  "@vitejs/plugin-react-swc": "^3.5.0",
@@ -47,9 +50,7 @@
47
  "tailwindcss": "^3.4.16",
48
  "typescript": "~5.6.2",
49
  "typescript-eslint": "^8.15.0",
50
- "vite": "^6.2.0",
51
- "@playwright/test": "^1.50.1",
52
- "@types/node": "^22.13.1"
53
  },
54
  "optionalDependencies": {
55
  "@rollup/rollup-linux-x64-gnu": "^4.28.1"
 
20
  "@syncedstore/react": "^0.6.0",
21
  "@types/node": "^22.10.1",
22
  "@xyflow/react": "^12.3.5",
23
+ "axios": "^1.8.2",
24
  "daisyui": "^4.12.20",
25
  "echarts": "^5.5.1",
26
  "fuse.js": "^7.0.0",
 
36
  },
37
  "devDependencies": {
38
  "@eslint/js": "^9.15.0",
39
+ "@playwright/test": "^1.50.1",
40
+ "@types/node": "^22.13.1",
41
  "@types/react": "^18.3.14",
42
  "@types/react-dom": "^18.3.2",
43
  "@vitejs/plugin-react-swc": "^3.5.0",
 
50
  "tailwindcss": "^3.4.16",
51
  "typescript": "~5.6.2",
52
  "typescript-eslint": "^8.15.0",
53
+ "vite": "^6.2.0"
 
 
54
  },
55
  "optionalDependencies": {
56
  "@rollup/rollup-linux-x64-gnu": "^4.28.1"
lynxkite-app/web/playwright.config.ts CHANGED
@@ -23,7 +23,7 @@ export default defineConfig({
23
  },
24
  ],
25
  webServer: {
26
- command: "cd ../.. && LYNXKITE_DATA=examples lynxkite",
27
  url: "http://127.0.0.1:8000",
28
  reuseExistingServer: false,
29
  },
 
23
  },
24
  ],
25
  webServer: {
26
+ command: "cd ../../examples && lynxkite",
27
  url: "http://127.0.0.1:8000",
28
  reuseExistingServer: false,
29
  },
lynxkite-app/web/src/index.css CHANGED
@@ -240,6 +240,22 @@ body {
240
  background: transparent;
241
  color: #39bcf3;
242
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  }
244
 
245
  .params-expander {
 
240
  background: transparent;
241
  color: #39bcf3;
242
  }
243
+
244
+ .workspace-message {
245
+ position: absolute;
246
+ left: 50%;
247
+ bottom: 20px;
248
+ transform: translateX(-50%);
249
+ box-shadow: 0 5px 50px 0px #8008;
250
+ padding: 10px 40px 10px 20px;
251
+ border-radius: 5px;
252
+
253
+ .close {
254
+ position: absolute;
255
+ right: 10px;
256
+ cursor: pointer;
257
+ }
258
+ }
259
  }
260
 
261
  .params-expander {
lynxkite-app/web/src/workspace/Workspace.tsx CHANGED
@@ -14,6 +14,7 @@ import {
14
  useReactFlow,
15
  useUpdateNodeInternals,
16
  } from "@xyflow/react";
 
17
  import {
18
  type MouseEvent,
19
  useCallback,
@@ -62,6 +63,7 @@ function LynxKiteFlow() {
62
  const [edges, setEdges] = useState([] as Edge[]);
63
  const { path } = useParams();
64
  const [state, setState] = useState({ workspace: {} as Workspace });
 
65
  useEffect(() => {
66
  const state = syncedStore({ workspace: {} as Workspace });
67
  setState(state);
@@ -80,7 +82,9 @@ function LynxKiteFlow() {
80
  if (!state.workspace.nodes) return;
81
  if (!state.workspace.edges) return;
82
  for (const n of state.workspace.nodes) {
83
- n.dragHandle = ".bg-primary";
 
 
84
  }
85
  setNodes([...state.workspace.nodes] as Node[]);
86
  setEdges([...state.workspace.edges] as Edge[]);
@@ -234,33 +238,44 @@ function LynxKiteFlow() {
234
  },
235
  [catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
236
  );
237
- const addNode = useCallback(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  (meta: OpsOp) => {
239
- const node: Partial<WorkspaceNode> = {
240
- type: meta.type,
241
- data: {
242
- meta: meta,
243
- title: meta.name,
244
- params: Object.fromEntries(
245
- Object.values(meta.params).map((p) => [p.name, p.default]),
246
- ),
247
- },
248
- };
249
  const nss = nodeSearchSettings!;
250
  node.position = reactFlow.screenToFlowPosition({
251
  x: nss.pos.x,
252
  y: nss.pos.y,
253
  });
254
- const title = meta.name;
255
- let i = 1;
256
- node.id = `${title} ${i}`;
257
- const wnodes = state.workspace.nodes!;
258
- while (wnodes.find((x) => x.id === node.id)) {
259
- i += 1;
260
- node.id = `${title} ${i}`;
261
- }
262
- wnodes.push(node as WorkspaceNode);
263
- setNodes([...nodes, node as WorkspaceNode]);
264
  closeNodeSearch();
265
  },
266
  [nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
@@ -282,6 +297,48 @@ function LynxKiteFlow() {
282
  [state],
283
  );
284
  const parentDir = path!.split("/").slice(0, -1).join("/");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  return (
286
  <div className="workspace">
287
  <div className="top-bar bg-neutral">
@@ -308,7 +365,11 @@ function LynxKiteFlow() {
308
  </a>
309
  </div>
310
  </div>
311
- <div style={{ height: "100%", width: "100vw" }}>
 
 
 
 
312
  <LynxKiteState.Provider value={state}>
313
  <ReactFlow
314
  nodes={nodes}
@@ -320,7 +381,7 @@ function LynxKiteFlow() {
320
  onPaneClick={toggleNodeSearch}
321
  onConnect={onConnect}
322
  proOptions={{ hideAttribution: true }}
323
- maxZoom={3}
324
  minZoom={0.3}
325
  defaultEdgeOptions={{
326
  markerEnd: {
@@ -342,11 +403,19 @@ function LynxKiteFlow() {
342
  pos={nodeSearchSettings.pos}
343
  boxes={nodeSearchSettings.boxes}
344
  onCancel={closeNodeSearch}
345
- onAdd={addNode}
346
  />
347
  )}
348
  </ReactFlow>
349
  </LynxKiteState.Provider>
 
 
 
 
 
 
 
 
350
  </div>
351
  </div>
352
  );
 
14
  useReactFlow,
15
  useUpdateNodeInternals,
16
  } from "@xyflow/react";
17
+ import axios from "axios";
18
  import {
19
  type MouseEvent,
20
  useCallback,
 
63
  const [edges, setEdges] = useState([] as Edge[]);
64
  const { path } = useParams();
65
  const [state, setState] = useState({ workspace: {} as Workspace });
66
+ const [message, setMessage] = useState(null as string | null);
67
  useEffect(() => {
68
  const state = syncedStore({ workspace: {} as Workspace });
69
  setState(state);
 
82
  if (!state.workspace.nodes) return;
83
  if (!state.workspace.edges) return;
84
  for (const n of state.workspace.nodes) {
85
+ if (n.dragHandle !== ".bg-primary") {
86
+ n.dragHandle = ".bg-primary";
87
+ }
88
  }
89
  setNodes([...state.workspace.nodes] as Node[]);
90
  setEdges([...state.workspace.edges] as Edge[]);
 
238
  },
239
  [catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
240
  );
241
+ function addNode(
242
+ node: Partial<WorkspaceNode>,
243
+ state: { workspace: Workspace },
244
+ nodes: Node[],
245
+ ) {
246
+ const title = node.data?.title;
247
+ let i = 1;
248
+ node.id = `${title} ${i}`;
249
+ const wnodes = state.workspace.nodes!;
250
+ while (wnodes.find((x) => x.id === node.id)) {
251
+ i += 1;
252
+ node.id = `${title} ${i}`;
253
+ }
254
+ wnodes.push(node as WorkspaceNode);
255
+ setNodes([...nodes, node as WorkspaceNode]);
256
+ }
257
+ function nodeFromMeta(meta: OpsOp): Partial<WorkspaceNode> {
258
+ const node: Partial<WorkspaceNode> = {
259
+ type: meta.type,
260
+ data: {
261
+ meta: meta,
262
+ title: meta.name,
263
+ params: Object.fromEntries(
264
+ Object.values(meta.params).map((p) => [p.name, p.default]),
265
+ ),
266
+ },
267
+ };
268
+ return node;
269
+ }
270
+ const addNodeFromSearch = useCallback(
271
  (meta: OpsOp) => {
272
+ const node = nodeFromMeta(meta);
 
 
 
 
 
 
 
 
 
273
  const nss = nodeSearchSettings!;
274
  node.position = reactFlow.screenToFlowPosition({
275
  x: nss.pos.x,
276
  y: nss.pos.y,
277
  });
278
+ addNode(node, state, nodes);
 
 
 
 
 
 
 
 
 
279
  closeNodeSearch();
280
  },
281
  [nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
 
297
  [state],
298
  );
299
  const parentDir = path!.split("/").slice(0, -1).join("/");
300
+ function onDragOver(e: React.DragEvent<HTMLDivElement>) {
301
+ e.stopPropagation();
302
+ e.preventDefault();
303
+ }
304
+ async function onDrop(e: React.DragEvent<HTMLDivElement>) {
305
+ e.stopPropagation();
306
+ e.preventDefault();
307
+ const file = e.dataTransfer.files[0];
308
+ const formData = new FormData();
309
+ formData.append("file", file);
310
+ try {
311
+ await axios.post("/api/upload", formData, {
312
+ onUploadProgress: (progressEvent) => {
313
+ const percentCompleted = Math.round(
314
+ (100 * progressEvent.loaded) / progressEvent.total!,
315
+ );
316
+ if (percentCompleted === 100) setMessage("Processing file...");
317
+ else setMessage(`Uploading ${percentCompleted}%`);
318
+ },
319
+ });
320
+ setMessage(null);
321
+ const cat = catalog.data![state.workspace.env!];
322
+ const node = nodeFromMeta(cat["Import file"]);
323
+ node.position = reactFlow.screenToFlowPosition({
324
+ x: e.clientX,
325
+ y: e.clientY,
326
+ });
327
+ node.data!.params.file_path = `uploads/${file.name}`;
328
+ if (file.name.includes(".csv")) {
329
+ node.data!.params.file_format = "csv";
330
+ } else if (file.name.includes(".parquet")) {
331
+ node.data!.params.file_format = "parquet";
332
+ } else if (file.name.includes(".json")) {
333
+ node.data!.params.file_format = "json";
334
+ } else if (file.name.includes(".xls")) {
335
+ node.data!.params.file_format = "excel";
336
+ }
337
+ addNode(node, state, nodes);
338
+ } catch (error) {
339
+ setMessage("File upload failed.");
340
+ }
341
+ }
342
  return (
343
  <div className="workspace">
344
  <div className="top-bar bg-neutral">
 
365
  </a>
366
  </div>
367
  </div>
368
+ <div
369
+ style={{ height: "100%", width: "100vw" }}
370
+ onDragOver={onDragOver}
371
+ onDrop={onDrop}
372
+ >
373
  <LynxKiteState.Provider value={state}>
374
  <ReactFlow
375
  nodes={nodes}
 
381
  onPaneClick={toggleNodeSearch}
382
  onConnect={onConnect}
383
  proOptions={{ hideAttribution: true }}
384
+ maxZoom={1}
385
  minZoom={0.3}
386
  defaultEdgeOptions={{
387
  markerEnd: {
 
403
  pos={nodeSearchSettings.pos}
404
  boxes={nodeSearchSettings.boxes}
405
  onCancel={closeNodeSearch}
406
+ onAdd={addNodeFromSearch}
407
  />
408
  )}
409
  </ReactFlow>
410
  </LynxKiteState.Provider>
411
+ {message && (
412
+ <div className="workspace-message">
413
+ <span className="close" onClick={() => setMessage(null)}>
414
+ <Close />
415
+ </span>
416
+ {message}
417
+ </div>
418
+ )}
419
  </div>
420
  </div>
421
  );
lynxkite-app/web/tests/basic.spec.ts CHANGED
@@ -21,9 +21,6 @@ test("Box creation & deletion per env", async () => {
21
  const envs = await workspace.getEnvs();
22
  for (const env of envs) {
23
  await workspace.setEnv(env);
24
- // TODO: Opening the catalog immediately after setting the env can fail.
25
- // Let's fix this!
26
- await new Promise((resolve) => setTimeout(resolve, 500));
27
  const catalog = await workspace.getCatalog();
28
  expect(catalog).not.toHaveLength(0);
29
  const op = catalog[0];
 
21
  const envs = await workspace.getEnvs();
22
  for (const env of envs) {
23
  await workspace.setEnv(env);
 
 
 
24
  const catalog = await workspace.getCatalog();
25
  expect(catalog).not.toHaveLength(0);
26
  const op = catalog[0];
lynxkite-app/web/tests/examples.spec.ts CHANGED
@@ -17,8 +17,7 @@ test("Pytorch example", async ({ page }) => {
17
  await ws.expectErrorFree();
18
  });
19
 
20
- test.fail("AIMO example", async ({ page }) => {
21
- // Fails because of missing OPENAI_API_KEY
22
  const ws = await Workspace.open(page, "AIMO");
23
  await ws.expectErrorFree();
24
  });
@@ -35,12 +34,6 @@ test("Graph RAG", async ({ page }) => {
35
  await ws.expectErrorFree(process.env.CI ? 2000 : 500);
36
  });
37
 
38
- test.fail("RAG chatbot app", async ({ page }) => {
39
- // Fail due to all operation being unknown
40
- const ws = await Workspace.open(page, "RAG chatbot app");
41
- await ws.expectErrorFree();
42
- });
43
-
44
  test("Airlines demo", async ({ page }) => {
45
  const ws = await Workspace.open(page, "Airlines demo");
46
  await ws.expectErrorFree(process.env.CI ? 10000 : 500);
 
17
  await ws.expectErrorFree();
18
  });
19
 
20
+ test("AIMO example", async ({ page }) => {
 
21
  const ws = await Workspace.open(page, "AIMO");
22
  await ws.expectErrorFree();
23
  });
 
34
  await ws.expectErrorFree(process.env.CI ? 2000 : 500);
35
  });
36
 
 
 
 
 
 
 
37
  test("Airlines demo", async ({ page }) => {
38
  const ws = await Workspace.open(page, "Airlines demo");
39
  await ws.expectErrorFree(process.env.CI ? 10000 : 500);
lynxkite-app/web/tests/lynxkite.ts CHANGED
@@ -59,10 +59,8 @@ export class Workspace {
59
  // Avoid overlapping with existing nodes
60
  const numNodes = allBoxes.length || 1;
61
  await this.page.mouse.wheel(0, numNodes * 400);
62
- await new Promise((resolve) => setTimeout(resolve, 200));
63
  }
64
 
65
- // Some x,y offset, otherwise the box handle may fall outside the viewport.
66
  await this.page.locator(".ws-name").click();
67
  await this.page.keyboard.press("/");
68
  await this.page
@@ -73,10 +71,11 @@ export class Workspace {
73
  }
74
 
75
  async getCatalog() {
76
- await this.page.locator(".react-flow__pane").click();
77
- const catalog = await this.page
78
- .locator(".node-search .matches .search-result")
79
- .allInnerTexts();
 
80
  // Dismiss the catalog menu
81
  await this.page.keyboard.press("Escape");
82
  await expect(this.page.locator(".node-search")).not.toBeVisible();
@@ -153,11 +152,6 @@ export class Workspace {
153
  }
154
 
155
  async expectErrorFree(executionWaitTime?) {
156
- // TODO: Workaround, to account for workspace execution. Once
157
- // we have a load indicator we can use that instead.
158
- await new Promise((resolve) =>
159
- setTimeout(resolve, executionWaitTime ? executionWaitTime : 500),
160
- );
161
  await expect(this.getBoxes().locator(".error").first()).not.toBeVisible();
162
  }
163
 
@@ -188,7 +182,7 @@ export class Splash {
188
  }
189
 
190
  workspace(name: string) {
191
- return this.page.getByRole("link", { name: name });
192
  }
193
 
194
  getEntry(name: string) {
 
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
 
71
  }
72
 
73
  async getCatalog() {
74
+ await this.page.locator(".ws-name").click();
75
+ await this.page.keyboard.press("/");
76
+ const results = this.page.locator(".node-search .matches .search-result");
77
+ await expect(results.first()).toBeVisible();
78
+ const catalog = await results.allInnerTexts();
79
  // Dismiss the catalog menu
80
  await this.page.keyboard.press("Escape");
81
  await expect(this.page.locator(".node-search")).not.toBeVisible();
 
152
  }
153
 
154
  async expectErrorFree(executionWaitTime?) {
 
 
 
 
 
155
  await expect(this.getBoxes().locator(".error").first()).not.toBeVisible();
156
  }
157
 
 
182
  }
183
 
184
  workspace(name: string) {
185
+ return this.page.getByRole("link", { name: name, exact: true });
186
  }
187
 
188
  getEntry(name: string) {
lynxkite-core/src/lynxkite/core/workspace.py CHANGED
@@ -105,7 +105,8 @@ def save(ws: Workspace, path: str):
105
  j = ws.model_dump()
106
  j = json.dumps(j, indent=2, sort_keys=True) + "\n"
107
  dirname, basename = os.path.split(path)
108
- os.makedirs(dirname, exist_ok=True)
 
109
  # Create temp file in the same directory to make sure it's on the same filesystem.
110
  with tempfile.NamedTemporaryFile(
111
  "w", prefix=f".{basename}.", dir=dirname, delete=False
 
105
  j = ws.model_dump()
106
  j = json.dumps(j, indent=2, sort_keys=True) + "\n"
107
  dirname, basename = os.path.split(path)
108
+ if dirname:
109
+ os.makedirs(dirname, exist_ok=True)
110
  # Create temp file in the same directory to make sure it's on the same filesystem.
111
  with tempfile.NamedTemporaryFile(
112
  "w", prefix=f".{basename}.", dir=dirname, delete=False
lynxkite-lynxscribe/src/lynxkite_lynxscribe/lynxscribe_ops.py CHANGED
@@ -2,7 +2,6 @@
2
  LynxScribe configuration and testing in LynxKite.
3
  """
4
 
5
- import os
6
  import pathlib
7
  from lynxscribe.core.llm.base import get_llm_engine
8
  from lynxscribe.core.vector_store.base import get_vector_store
@@ -222,11 +221,12 @@ def view(input):
222
  return v
223
 
224
 
225
- async def get_chat_api(ws):
226
  from lynxkite.core import workspace
227
 
228
- path = DATA_PATH / ws
229
- assert path.is_relative_to(DATA_PATH)
 
230
  assert path.exists(), f"Workspace {path} does not exist"
231
  ws = workspace.load(path)
232
  contexts = await ops.EXECUTORS[ENV](ws)
@@ -285,19 +285,16 @@ async def api_service_get(request):
285
  return {"error": "Not found"}
286
 
287
 
288
- DATA_PATH = pathlib.Path(os.environ.get("LYNXKITE_DATA", "lynxkite_data"))
289
-
290
-
291
  def get_lynxscribe_workspaces():
292
  from lynxkite.core import workspace
293
 
294
  workspaces = []
295
- for p in DATA_PATH.glob("**/*"):
296
  if p.is_file():
297
  try:
298
  ws = workspace.load(p)
299
  if ws.env == ENV:
300
- workspaces.append(p.relative_to(DATA_PATH))
301
  except Exception:
302
  pass # Ignore files that are not valid workspaces.
303
  workspaces.sort()
 
2
  LynxScribe configuration and testing in LynxKite.
3
  """
4
 
 
5
  import pathlib
6
  from lynxscribe.core.llm.base import get_llm_engine
7
  from lynxscribe.core.vector_store.base import get_vector_store
 
221
  return v
222
 
223
 
224
+ async def get_chat_api(ws: str):
225
  from lynxkite.core import workspace
226
 
227
+ cwd = pathlib.Path()
228
+ path = cwd / ws
229
+ assert path.is_relative_to(cwd)
230
  assert path.exists(), f"Workspace {path} does not exist"
231
  ws = workspace.load(path)
232
  contexts = await ops.EXECUTORS[ENV](ws)
 
285
  return {"error": "Not found"}
286
 
287
 
 
 
 
288
  def get_lynxscribe_workspaces():
289
  from lynxkite.core import workspace
290
 
291
  workspaces = []
292
+ for p in pathlib.Path().glob("**/*"):
293
  if p.is_file():
294
  try:
295
  ws = workspace.load(p)
296
  if ws.env == ENV:
297
+ workspaces.append(p)
298
  except Exception:
299
  pass # Ignore files that are not valid workspaces.
300
  workspaces.sort()