mszel commited on
Commit
b3b67d2
·
unverified ·
2 Parent(s): a8b8b90 6216561

Merge branch 'main' into feature/image-search-v0

Browse files
Files changed (50) hide show
  1. .dockerignore +25 -0
  2. .gitignore +3 -2
  3. .pre-commit-config.yaml +2 -2
  4. Dockerfile +16 -0
  5. README.md +10 -1
  6. examples/AIMO +1 -1
  7. examples/Airlines demo +0 -0
  8. examples/Bio demo +1 -1
  9. examples/{BioNemo demo → BioNeMo demo} +0 -0
  10. examples/Graph RAG +1 -1
  11. examples/LynxScribe demo +4 -4
  12. examples/NetworkX demo +0 -0
  13. examples/{aimo-examples.csv → uploads/aimo-examples.csv} +0 -0
  14. examples/{drug_target_data_sample.csv → uploads/drug_target_data_sample.csv} +0 -0
  15. examples/{example-pizza.md → uploads/example-pizza.md} +0 -0
  16. lynxkite-app/README.md +1 -1
  17. lynxkite-app/src/lynxkite_app/__main__.py +1 -0
  18. lynxkite-app/src/lynxkite_app/config.py +0 -8
  19. lynxkite-app/src/lynxkite_app/crdt.py +16 -9
  20. lynxkite-app/src/lynxkite_app/main.py +33 -16
  21. lynxkite-app/tests/test_main.py +7 -8
  22. lynxkite-app/web/package-lock.json +276 -20
  23. lynxkite-app/web/package.json +4 -3
  24. lynxkite-app/web/playwright.config.ts +1 -1
  25. lynxkite-app/web/src/Directory.tsx +8 -6
  26. lynxkite-app/web/src/index.css +35 -0
  27. lynxkite-app/web/src/workspace/EnvironmentSelector.tsx +1 -1
  28. lynxkite-app/web/src/workspace/NodeSearch.tsx +6 -1
  29. lynxkite-app/web/src/workspace/Workspace.tsx +99 -29
  30. lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx +4 -0
  31. lynxkite-app/web/src/workspace/nodes/NodeWithParams.tsx +37 -24
  32. lynxkite-app/web/src/workspace/nodes/NodeWithVisualization.tsx +15 -7
  33. lynxkite-app/web/tests/basic.spec.ts +3 -6
  34. lynxkite-app/web/tests/errors.spec.ts +11 -11
  35. lynxkite-app/web/tests/examples.spec.ts +3 -10
  36. lynxkite-app/web/tests/graph_creation.spec.ts +6 -2
  37. lynxkite-app/web/tests/lynxkite.ts +10 -13
  38. lynxkite-bio/src/lynxkite_bio/__init__.py +1 -1
  39. lynxkite-core/src/lynxkite/core/executors/one_by_one.py +4 -4
  40. lynxkite-core/src/lynxkite/core/ops.py +21 -6
  41. lynxkite-core/src/lynxkite/core/workspace.py +3 -1
  42. lynxkite-graph-analytics/Dockerfile.bionemo +4 -0
  43. lynxkite-graph-analytics/README.md +1 -2
  44. lynxkite-graph-analytics/src/lynxkite_graph_analytics/bionemo_ops.py +1 -1
  45. lynxkite-graph-analytics/src/lynxkite_graph_analytics/core.py +57 -37
  46. lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py +1 -20
  47. lynxkite-graph-analytics/src/lynxkite_graph_analytics/networkx_ops.py +236 -24
  48. lynxkite-graph-analytics/tests/test_lynxkite_ops.py +71 -3
  49. lynxkite-lynxscribe/README.md +1 -1
  50. lynxkite-lynxscribe/src/lynxkite_lynxscribe/lynxscribe_ops.py +7 -9
.dockerignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv
2
+ .vscode/*
3
+ !.vscode/extensions.json
4
+ !.vscode/settings.json
5
+ .idea
6
+ .DS_Store
7
+ *.suo
8
+ *.ntvs*
9
+ *.njsproj
10
+ *.sln
11
+ *.sw?
12
+ __pycache__
13
+ node_modules
14
+ dist
15
+ build
16
+ joblib-cache
17
+ *.egg-info
18
+
19
+ lynxkite_crdt_data
20
+
21
+ # Playwright
22
+ test-results
23
+ playwright-report
24
+ /blob-report/
25
+ /playwright/.cache/
.gitignore CHANGED
@@ -1,3 +1,4 @@
 
1
  .vscode/*
2
  !.vscode/extensions.json
3
  !.vscode/settings.json
@@ -18,7 +19,7 @@ joblib-cache
18
  lynxkite_crdt_data
19
 
20
  # Playwright
21
- /test-results/
22
- /playwright-report/
23
  /blob-report/
24
  /playwright/.cache/
 
1
+ .venv
2
  .vscode/*
3
  !.vscode/extensions.json
4
  !.vscode/settings.json
 
19
  lynxkite_crdt_data
20
 
21
  # Playwright
22
+ test-results
23
+ playwright-report
24
  /blob-report/
25
  /playwright/.cache/
.pre-commit-config.yaml CHANGED
@@ -1,12 +1,12 @@
1
  repos:
2
  - repo: https://github.com/pre-commit/pre-commit-hooks
3
- rev: v4.5.0
4
  hooks:
5
  - id: trailing-whitespace
6
  - id: end-of-file-fixer
7
  - id: check-yaml
8
  - repo: https://github.com/astral-sh/ruff-pre-commit
9
- rev: v0.9.6
10
  hooks:
11
  - id: ruff
12
  args: [ --fix ]
 
1
  repos:
2
  - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
  hooks:
5
  - id: trailing-whitespace
6
  - id: end-of-file-fixer
7
  - id: check-yaml
8
  - repo: https://github.com/astral-sh/ruff-pre-commit
9
+ rev: v0.9.10
10
  hooks:
11
  - id: ruff
12
  args: [ --fix ]
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22
2
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
3
+ RUN apt-get update && apt-get install -y git
4
+ USER node
5
+ ENV HOME=/home/node PATH=/home/node/.local/bin:$PATH
6
+ WORKDIR $HOME/app
7
+ COPY --chown=node . $HOME/app
8
+ RUN uv venv && uv pip install \
9
+ -e lynxkite-core \
10
+ -e lynxkite-app \
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
@@ -1,3 +1,12 @@
 
 
 
 
 
 
 
 
 
1
  # LynxKite 2024
2
 
3
  This is an experimental rewrite of [LynxKite](https://github.com/lynxkite/lynxkite). It is not compatible with the
@@ -32,7 +41,7 @@ uv pip install -e lynxkite-core/[dev] -e lynxkite-app/[dev] -e lynxkite-graph-an
32
  This also builds the frontend, hopefully very quickly. To run it:
33
 
34
  ```bash
35
- LYNXKITE_DATA=examples LYNXKITE_RELOAD=1 lynxkite
36
  ```
37
 
38
  If you also want to make changes to the frontend with hot reloading:
 
1
+ ---
2
+ title: LynxKite 2000:MM
3
+ emoji: 🪁
4
+ colorFrom: purple
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
  # LynxKite 2024
11
 
12
  This is an experimental rewrite of [LynxKite](https://github.com/lynxkite/lynxkite). It is not compatible with the
 
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/Airlines demo CHANGED
The diff for this file is too large to render. See raw diff
 
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/{BioNemo demo → BioNeMo demo} RENAMED
File without changes
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/NetworkX demo CHANGED
The diff for this file is too large to render. See raw diff
 
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/__main__.py CHANGED
@@ -12,6 +12,7 @@ def main():
12
  port=port,
13
  reload=reload,
14
  loop="asyncio",
 
15
  )
16
 
17
 
 
12
  port=port,
13
  reload=reload,
14
  loop="asyncio",
15
+ proxy_headers=True,
16
  )
17
 
18
 
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()
@@ -54,6 +55,10 @@ class WebsocketServer(pycrdt_websocket.WebsocketServer):
54
  # We have two possible sources of truth for the workspaces, the YStore and the JSON files.
55
  # In case we didn't find the workspace in the YStore, we try to load it from the JSON files.
56
  try_to_load_workspace(ws, name)
 
 
 
 
57
  room = pycrdt_websocket.YRoom(
58
  ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
59
  )
@@ -161,9 +166,8 @@ def try_to_load_workspace(ws: pycrdt.Map, name: str):
161
  ws: CRDT object to udpate with the workspace contents.
162
  name: Name of the workspace to load.
163
  """
164
- json_path = f"{config.DATA_PATH}/{name}"
165
- if os.path.exists(json_path):
166
- ws_pyd = workspace.load(json_path)
167
  # We treat the display field as a black box, since it is a large
168
  # dictionary that is meant to change as a whole.
169
  crdt_update(ws, ws_pyd.model_dump(), non_collaborative_fields={"display"})
@@ -197,7 +201,6 @@ async def workspace_changed(name: str, changes: pycrdt.MapEvent, ws_crdt: pycrdt
197
  getattr(change, "keys", {}).get("__execution_delay", {}).get("newValue", 0)
198
  for change in changes
199
  )
200
- print(f"Running {name} in {ws_pyd.env}...")
201
  if delay:
202
  task = asyncio.create_task(execute(name, ws_crdt, ws_pyd, delay))
203
  delayed_executions[name] = task
@@ -221,10 +224,13 @@ async def execute(
221
  await asyncio.sleep(delay)
222
  except asyncio.CancelledError:
223
  return
224
- path = config.DATA_PATH / name
225
- assert path.is_relative_to(config.DATA_PATH), "Provided workspace path is invalid"
 
 
226
  # Save user changes before executing, in case the execution fails.
227
  workspace.save(ws_pyd, path)
 
228
  with ws_crdt.doc.transaction():
229
  for nc, np in zip(ws_crdt["nodes"], ws_pyd.nodes):
230
  if "data" not in nc:
@@ -234,6 +240,7 @@ async def execute(
234
  np._crdt = nc
235
  await workspace.execute(ws_pyd)
236
  workspace.save(ws_pyd, path)
 
237
 
238
 
239
  @contextlib.asynccontextmanager
 
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()
 
55
  # We have two possible sources of truth for the workspaces, the YStore and the JSON files.
56
  # In case we didn't find the workspace in the YStore, we try to load it from the JSON files.
57
  try_to_load_workspace(ws, name)
58
+ ws_simple = workspace.Workspace.model_validate(ws.to_py())
59
+ clean_input(ws_simple)
60
+ # Set the last known version to the current state, so we don't trigger a change event.
61
+ last_known_versions[name] = ws_simple
62
  room = pycrdt_websocket.YRoom(
63
  ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
64
  )
 
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"})
 
201
  getattr(change, "keys", {}).get("__execution_delay", {}).get("newValue", 0)
202
  for change in changes
203
  )
 
204
  if delay:
205
  task = asyncio.create_task(execute(name, ws_crdt, ws_pyd, delay))
206
  delayed_executions[name] = task
 
224
  await asyncio.sleep(delay)
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
234
  with ws_crdt.doc.transaction():
235
  for nc, np in zip(ws_crdt["nodes"], ws_pyd.nodes):
236
  if "data" not in nc:
 
240
  np._crdt = nc
241
  await workspace.execute(ws_pyd)
242
  workspace.save(ws_pyd, path)
243
+ print(f"Finished running {name} in {ws_pyd.env}.")
244
 
245
 
246
  @contextlib.asynccontextmanager
lynxkite-app/src/lynxkite_app/main.py CHANGED
@@ -7,10 +7,11 @@ import importlib
7
  import pathlib
8
  import pkgutil
9
  from fastapi.staticfiles import StaticFiles
 
10
  import starlette
11
  from lynxkite.core import ops
12
  from lynxkite.core import workspace
13
- from . import crdt, config
14
 
15
 
16
  def detect_plugins():
@@ -28,6 +29,7 @@ lynxkite_plugins = detect_plugins()
28
 
29
  app = fastapi.FastAPI(lifespan=crdt.lifespan)
30
  app.include_router(crdt.router)
 
31
 
32
 
33
  @app.get("/api/catalog")
@@ -43,9 +45,12 @@ class SaveRequest(workspace.BaseConfig):
43
  ws: workspace.Workspace
44
 
45
 
 
 
 
46
  def save(req: SaveRequest):
47
- path = config.DATA_PATH / req.path
48
- assert path.is_relative_to(config.DATA_PATH)
49
  workspace.save(req.ws, path)
50
 
51
 
@@ -59,18 +64,17 @@ async def save_and_execute(req: SaveRequest):
59
 
60
  @app.post("/api/delete")
61
  async def delete_workspace(req: dict):
62
- json_path: pathlib.Path = config.DATA_PATH / req["path"]
63
- crdt_path: pathlib.Path = config.CRDT_PATH / f"{req['path']}.crdt"
64
- assert json_path.is_relative_to(config.DATA_PATH)
65
- assert crdt_path.is_relative_to(config.CRDT_PATH)
66
  json_path.unlink()
67
  crdt_path.unlink()
68
 
69
 
70
  @app.get("/api/load")
71
  def load(path: str):
72
- path = config.DATA_PATH / path
73
- assert path.is_relative_to(config.DATA_PATH)
74
  if not path.exists():
75
  return workspace.Workspace()
76
  return workspace.load(path)
@@ -83,15 +87,16 @@ class DirectoryEntry(pydantic.BaseModel):
83
 
84
  @app.get("/api/dir/list")
85
  def list_dir(path: str):
86
- path = config.DATA_PATH / path
87
- assert path.is_relative_to(config.DATA_PATH)
88
  return sorted(
89
  [
90
  DirectoryEntry(
91
- name=str(p.relative_to(config.DATA_PATH)),
92
  type="directory" if p.is_dir() else "workspace",
93
  )
94
  for p in path.iterdir()
 
95
  ],
96
  key=lambda x: x.name,
97
  )
@@ -99,16 +104,16 @@ def list_dir(path: str):
99
 
100
  @app.post("/api/dir/mkdir")
101
  def make_dir(req: dict):
102
- path = config.DATA_PATH / req["path"]
103
- assert path.is_relative_to(config.DATA_PATH)
104
  assert not path.exists(), f"{path} already exists"
105
  path.mkdir()
106
 
107
 
108
  @app.post("/api/dir/delete")
109
  def delete_dir(req: dict):
110
- path: pathlib.Path = config.DATA_PATH / req["path"]
111
- assert all([path.is_relative_to(config.DATA_PATH), path.exists(), path.is_dir()])
112
  shutil.rmtree(path)
113
 
114
 
@@ -126,6 +131,18 @@ async def service_post(req: fastapi.Request, module_path: str):
126
  return await module.api_service_post(req)
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  class SPAStaticFiles(StaticFiles):
130
  """Route everything to index.html. https://stackoverflow.com/a/73552966/3318517"""
131
 
 
7
  import pathlib
8
  import pkgutil
9
  from fastapi.staticfiles import StaticFiles
10
+ 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
15
 
16
 
17
  def detect_plugins():
 
29
 
30
  app = fastapi.FastAPI(lifespan=crdt.lifespan)
31
  app.include_router(crdt.router)
32
+ app.add_middleware(GZipMiddleware)
33
 
34
 
35
  @app.get("/api/catalog")
 
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",
@@ -278,25 +279,25 @@
278
  }
279
  },
280
  "node_modules/@babel/helpers": {
281
- "version": "7.26.0",
282
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz",
283
- "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==",
284
  "license": "MIT",
285
  "dependencies": {
286
- "@babel/template": "^7.25.9",
287
- "@babel/types": "^7.26.0"
288
  },
289
  "engines": {
290
  "node": ">=6.9.0"
291
  }
292
  },
293
  "node_modules/@babel/parser": {
294
- "version": "7.26.3",
295
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz",
296
- "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==",
297
  "license": "MIT",
298
  "dependencies": {
299
- "@babel/types": "^7.26.3"
300
  },
301
  "bin": {
302
  "parser": "bin/babel-parser.js"
@@ -306,14 +307,14 @@
306
  }
307
  },
308
  "node_modules/@babel/template": {
309
- "version": "7.25.9",
310
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz",
311
- "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==",
312
  "license": "MIT",
313
  "dependencies": {
314
- "@babel/code-frame": "^7.25.9",
315
- "@babel/parser": "^7.25.9",
316
- "@babel/types": "^7.25.9"
317
  },
318
  "engines": {
319
  "node": ">=6.9.0"
@@ -347,9 +348,9 @@
347
  }
348
  },
349
  "node_modules/@babel/types": {
350
- "version": "7.26.3",
351
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz",
352
- "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==",
353
  "license": "MIT",
354
  "dependencies": {
355
  "@babel/helper-string-parser": "^7.25.9",
@@ -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",
 
279
  }
280
  },
281
  "node_modules/@babel/helpers": {
282
+ "version": "7.26.10",
283
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
284
+ "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
285
  "license": "MIT",
286
  "dependencies": {
287
+ "@babel/template": "^7.26.9",
288
+ "@babel/types": "^7.26.10"
289
  },
290
  "engines": {
291
  "node": ">=6.9.0"
292
  }
293
  },
294
  "node_modules/@babel/parser": {
295
+ "version": "7.26.10",
296
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
297
+ "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
298
  "license": "MIT",
299
  "dependencies": {
300
+ "@babel/types": "^7.26.10"
301
  },
302
  "bin": {
303
  "parser": "bin/babel-parser.js"
 
307
  }
308
  },
309
  "node_modules/@babel/template": {
310
+ "version": "7.26.9",
311
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
312
+ "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
313
  "license": "MIT",
314
  "dependencies": {
315
+ "@babel/code-frame": "^7.26.2",
316
+ "@babel/parser": "^7.26.9",
317
+ "@babel/types": "^7.26.9"
318
  },
319
  "engines": {
320
  "node": ">=6.9.0"
 
348
  }
349
  },
350
  "node_modules/@babel/types": {
351
+ "version": "7.26.10",
352
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
353
+ "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
354
  "license": "MIT",
355
  "dependencies": {
356
  "@babel/helper-string-parser": "^7.25.9",
 
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/Directory.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useState } from "react";
2
  // The directory browser.
3
- import { useNavigate, useParams } from "react-router";
4
  import useSWR from "swr";
5
  import type { DirectoryEntry } from "./apiTypes.ts";
6
 
@@ -23,7 +23,9 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json());
23
  export default function () {
24
  const { path } = useParams();
25
  const encodedPath = encodeURIComponent(path || "");
26
- const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher);
 
 
27
  const navigate = useNavigate();
28
  const [isCreatingDir, setIsCreatingDir] = useState(false);
29
  const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
@@ -173,19 +175,19 @@ export default function () {
173
 
174
  {path && (
175
  <div className="breadcrumbs">
176
- <a href="/dir/">
177
  <Home />
178
- </a>{" "}
179
  <span className="current-folder">{path}</span>
180
  </div>
181
  )}
182
 
183
  {list.data.map((item: DirectoryEntry) => (
184
  <div key={item.name} className="entry">
185
- <a key={link(item)} href={link(item)}>
186
  {item.type === "directory" ? <Folder /> : <File />}
187
  {shortName(item)}
188
- </a>
189
  <button
190
  type="button"
191
  onClick={() => {
 
1
  import { useState } from "react";
2
  // The directory browser.
3
+ import { Link, useNavigate, useParams } from "react-router";
4
  import useSWR from "swr";
5
  import type { DirectoryEntry } from "./apiTypes.ts";
6
 
 
23
  export default function () {
24
  const { path } = useParams();
25
  const encodedPath = encodeURIComponent(path || "");
26
+ const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher, {
27
+ dedupingInterval: 0,
28
+ });
29
  const navigate = useNavigate();
30
  const [isCreatingDir, setIsCreatingDir] = useState(false);
31
  const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
 
175
 
176
  {path && (
177
  <div className="breadcrumbs">
178
+ <Link to="/dir/">
179
  <Home />
180
+ </Link>{" "}
181
  <span className="current-folder">{path}</span>
182
  </div>
183
  )}
184
 
185
  {list.data.map((item: DirectoryEntry) => (
186
  <div key={item.name} className="entry">
187
+ <Link key={link(item)} to={link(item)}>
188
  {item.type === "directory" ? <Folder /> : <File />}
189
  {shortName(item)}
190
+ </Link>
191
  <button
192
  type="button"
193
  onClick={() => {
lynxkite-app/web/src/index.css CHANGED
@@ -235,6 +235,41 @@ body {
235
  margin: 10px;
236
  }
237
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  }
239
 
240
  .directory {
 
235
  margin: 10px;
236
  }
237
  }
238
+
239
+ .env-select {
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 {
262
+ font-size: 15px;
263
+ padding: 4px;
264
+ color: #000a;
265
+ }
266
+
267
+ .flippy {
268
+ transition: transform 0.5s;
269
+ }
270
+
271
+ .flippy.flippy-90 {
272
+ transform: rotate(-90deg);
273
  }
274
 
275
  .directory {
lynxkite-app/web/src/workspace/EnvironmentSelector.tsx CHANGED
@@ -6,7 +6,7 @@ export default function EnvironmentSelector(props: {
6
  return (
7
  <>
8
  <select
9
- className="select w-full max-w-xs"
10
  name="workspace-env"
11
  value={props.value}
12
  onChange={(evt) => props.onChange(evt.currentTarget.value)}
 
6
  return (
7
  <>
8
  <select
9
+ className="env-select select w-full max-w-xs"
10
  name="workspace-env"
11
  value={props.value}
12
  onChange={(evt) => props.onChange(evt.currentTarget.value)}
lynxkite-app/web/src/workspace/NodeSearch.tsx CHANGED
@@ -25,9 +25,14 @@ export default function (props: {
25
  }),
26
  [props.boxes],
27
  );
 
 
 
 
 
28
  const hits: { item: OpsOp }[] = searchText
29
  ? fuse.search<OpsOp>(searchText)
30
- : Object.values(props.boxes).map((box) => ({ item: box }));
31
  const [selectedIndex, setSelectedIndex] = useState(0);
32
  useEffect(() => searchBox.current.focus());
33
  function typed(text: string) {
 
25
  }),
26
  [props.boxes],
27
  );
28
+ const allOps = useMemo(() => {
29
+ const boxes = Object.values(props.boxes).map((box) => ({ item: box }));
30
+ boxes.sort((a, b) => a.item.name.localeCompare(b.item.name));
31
+ return boxes;
32
+ }, [props.boxes]);
33
  const hits: { item: OpsOp }[] = searchText
34
  ? fuse.search<OpsOp>(searchText)
35
+ : allOps;
36
  const [selectedIndex, setSelectedIndex] = useState(0);
37
  useEffect(() => searchBox.current.focus());
38
  function typed(text: string) {
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,
@@ -26,11 +27,11 @@ import { useParams } from "react-router";
26
  import useSWR, { type Fetcher } from "swr";
27
  import { WebsocketProvider } from "y-websocket";
28
  // @ts-ignore
29
- import ArrowBack from "~icons/tabler/arrow-back.jsx";
30
- // @ts-ignore
31
  import Atom from "~icons/tabler/atom.jsx";
32
  // @ts-ignore
33
  import Backspace from "~icons/tabler/backspace.jsx";
 
 
34
  import type { Workspace, WorkspaceNode } from "../apiTypes.ts";
35
  import favicon from "../assets/favicon.ico";
36
  // import NodeWithTableView from './NodeWithTableView';
@@ -62,12 +63,14 @@ 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);
68
  const doc = getYjsDoc(state);
 
69
  const wsProvider = new WebsocketProvider(
70
- `ws://${location.host}/ws/crdt`,
71
  path!,
72
  doc,
73
  );
@@ -79,7 +82,9 @@ function LynxKiteFlow() {
79
  if (!state.workspace.nodes) return;
80
  if (!state.workspace.edges) return;
81
  for (const n of state.workspace.nodes) {
82
- n.dragHandle = ".bg-primary";
 
 
83
  }
84
  setNodes([...state.workspace.nodes] as Node[]);
85
  setEdges([...state.workspace.edges] as Edge[]);
@@ -233,33 +238,44 @@ function LynxKiteFlow() {
233
  },
234
  [catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
235
  );
236
- const addNode = useCallback(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  (meta: OpsOp) => {
238
- const node: Partial<WorkspaceNode> = {
239
- type: meta.type,
240
- data: {
241
- meta: meta,
242
- title: meta.name,
243
- params: Object.fromEntries(
244
- Object.values(meta.params).map((p) => [p.name, p.default]),
245
- ),
246
- },
247
- };
248
  const nss = nodeSearchSettings!;
249
  node.position = reactFlow.screenToFlowPosition({
250
  x: nss.pos.x,
251
  y: nss.pos.y,
252
  });
253
- const title = meta.name;
254
- let i = 1;
255
- node.id = `${title} ${i}`;
256
- const wnodes = state.workspace.nodes!;
257
- while (wnodes.find((x) => x.id === node.id)) {
258
- i += 1;
259
- node.id = `${title} ${i}`;
260
- }
261
- wnodes.push(node as WorkspaceNode);
262
- setNodes([...nodes, node as WorkspaceNode]);
263
  closeNodeSearch();
264
  },
265
  [nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
@@ -281,6 +297,48 @@ function LynxKiteFlow() {
281
  [state],
282
  );
283
  const parentDir = path!.split("/").slice(0, -1).join("/");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  return (
285
  <div className="workspace">
286
  <div className="top-bar bg-neutral">
@@ -303,11 +361,15 @@ function LynxKiteFlow() {
303
  <Backspace />
304
  </a>
305
  <a href={`/dir/${parentDir}`}>
306
- <ArrowBack />
307
  </a>
308
  </div>
309
  </div>
310
- <div style={{ height: "100%", width: "100vw" }}>
 
 
 
 
311
  <LynxKiteState.Provider value={state}>
312
  <ReactFlow
313
  nodes={nodes}
@@ -319,7 +381,7 @@ function LynxKiteFlow() {
319
  onPaneClick={toggleNodeSearch}
320
  onConnect={onConnect}
321
  proOptions={{ hideAttribution: true }}
322
- maxZoom={3}
323
  minZoom={0.3}
324
  defaultEdgeOptions={{
325
  markerEnd: {
@@ -341,11 +403,19 @@ function LynxKiteFlow() {
341
  pos={nodeSearchSettings.pos}
342
  boxes={nodeSearchSettings.boxes}
343
  onCancel={closeNodeSearch}
344
- onAdd={addNode}
345
  />
346
  )}
347
  </ReactFlow>
348
  </LynxKiteState.Provider>
 
 
 
 
 
 
 
 
349
  </div>
350
  </div>
351
  );
 
14
  useReactFlow,
15
  useUpdateNodeInternals,
16
  } from "@xyflow/react";
17
+ import axios from "axios";
18
  import {
19
  type MouseEvent,
20
  useCallback,
 
27
  import useSWR, { type Fetcher } from "swr";
28
  import { WebsocketProvider } from "y-websocket";
29
  // @ts-ignore
 
 
30
  import Atom from "~icons/tabler/atom.jsx";
31
  // @ts-ignore
32
  import Backspace from "~icons/tabler/backspace.jsx";
33
+ // @ts-ignore
34
+ import Close from "~icons/tabler/x.jsx";
35
  import type { Workspace, WorkspaceNode } from "../apiTypes.ts";
36
  import favicon from "../assets/favicon.ico";
37
  // import NodeWithTableView from './NodeWithTableView';
 
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);
70
  const doc = getYjsDoc(state);
71
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
72
  const wsProvider = new WebsocketProvider(
73
+ `${proto}//${location.host}/ws/crdt`,
74
  path!,
75
  doc,
76
  );
 
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">
 
361
  <Backspace />
362
  </a>
363
  <a href={`/dir/${parentDir}`}>
364
+ <Close />
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/src/workspace/nodes/NodeParameter.tsx CHANGED
@@ -73,6 +73,10 @@ export default function NodeParameter({
73
  value={value || ""}
74
  onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
75
  onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
 
 
 
 
76
  />
77
  </>
78
  )}
 
73
  value={value || ""}
74
  onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
75
  onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
76
+ onKeyDown={(evt) =>
77
+ evt.code === "Enter" &&
78
+ onChange(evt.currentTarget.value, { delay: 0 })
79
+ }
80
  />
81
  </>
82
  )}
lynxkite-app/web/src/workspace/nodes/NodeWithParams.tsx CHANGED
@@ -1,4 +1,7 @@
1
  import { useReactFlow } from "@xyflow/react";
 
 
 
2
  import LynxKiteNode from "./LynxKiteNode";
3
  import NodeGroupParameter from "./NodeGroupParameter";
4
  import NodeParameter from "./NodeParameter";
@@ -8,6 +11,7 @@ export type UpdateOptions = { delay?: number };
8
  function NodeWithParams(props: any) {
9
  const reactFlow = useReactFlow();
10
  const metaParams = props.data.meta?.params;
 
11
 
12
  function setParam(name: string, newValue: any, opts: UpdateOptions) {
13
  reactFlow.updateNodeData(props.id, (prevData: any) => ({
@@ -31,31 +35,40 @@ function NodeWithParams(props: any) {
31
 
32
  return (
33
  <LynxKiteNode {...props}>
34
- {params.map(([name, value]) =>
35
- metaParams?.[name]?.type === "group" ? (
36
- <NodeGroupParameter
37
- key={name}
38
- value={value}
39
- meta={metaParams?.[name]}
40
- setParam={(name: string, value: any, opts?: UpdateOptions) =>
41
- setParam(name, value, opts || {})
42
- }
43
- deleteParam={(name: string, opts?: UpdateOptions) =>
44
- deleteParam(name, opts || {})
45
- }
46
- />
47
- ) : (
48
- <NodeParameter
49
- name={name}
50
- key={name}
51
- value={value}
52
- meta={metaParams?.[name]}
53
- onChange={(value: any, opts?: UpdateOptions) =>
54
- setParam(name, value, opts || {})
55
- }
56
- />
57
- ),
58
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  {props.children}
60
  </LynxKiteNode>
61
  );
 
1
  import { useReactFlow } from "@xyflow/react";
2
+ import React from "react";
3
+ // @ts-ignore
4
+ import Triangle from "~icons/tabler/triangle-inverted-filled.jsx";
5
  import LynxKiteNode from "./LynxKiteNode";
6
  import NodeGroupParameter from "./NodeGroupParameter";
7
  import NodeParameter from "./NodeParameter";
 
11
  function NodeWithParams(props: any) {
12
  const reactFlow = useReactFlow();
13
  const metaParams = props.data.meta?.params;
14
+ const [collapsed, setCollapsed] = React.useState(props.collapsed);
15
 
16
  function setParam(name: string, newValue: any, opts: UpdateOptions) {
17
  reactFlow.updateNodeData(props.id, (prevData: any) => ({
 
35
 
36
  return (
37
  <LynxKiteNode {...props}>
38
+ {props.collapsed && (
39
+ <div
40
+ className="params-expander"
41
+ onClick={() => setCollapsed(!collapsed)}
42
+ >
43
+ <Triangle className={`flippy ${collapsed ? "flippy-90" : ""}`} />
44
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  )}
46
+ {!collapsed &&
47
+ params.map(([name, value]) =>
48
+ metaParams?.[name]?.type === "group" ? (
49
+ <NodeGroupParameter
50
+ key={name}
51
+ value={value}
52
+ meta={metaParams?.[name]}
53
+ setParam={(name: string, value: any, opts?: UpdateOptions) =>
54
+ setParam(name, value, opts || {})
55
+ }
56
+ deleteParam={(name: string, opts?: UpdateOptions) =>
57
+ deleteParam(name, opts || {})
58
+ }
59
+ />
60
+ ) : (
61
+ <NodeParameter
62
+ name={name}
63
+ key={name}
64
+ value={value}
65
+ meta={metaParams?.[name]}
66
+ onChange={(value: any, opts?: UpdateOptions) =>
67
+ setParam(name, value, opts || {})
68
+ }
69
+ />
70
+ ),
71
+ )}
72
  {props.children}
73
  </LynxKiteNode>
74
  );
lynxkite-app/web/src/workspace/nodes/NodeWithVisualization.tsx CHANGED
@@ -10,20 +10,28 @@ const NodeWithVisualization = (props: any) => {
10
  if (!opts || !chartsRef.current) return;
11
  chartsInstanceRef.current = echarts.init(chartsRef.current, null, {
12
  renderer: "canvas",
13
- width: 800,
14
- height: 800,
15
  });
16
  chartsInstanceRef.current.setOption(opts);
17
- const onResize = () => chartsInstanceRef.current?.resize();
18
- window.addEventListener("resize", onResize);
 
 
 
 
 
 
19
  return () => {
20
- window.removeEventListener("resize", onResize);
21
  chartsInstanceRef.current?.dispose();
22
  };
23
  }, [props.data?.display?.value]);
 
 
24
  return (
25
- <NodeWithParams {...props}>
26
- <div className="box" draggable={false} ref={chartsRef} />
27
  </NodeWithParams>
28
  );
29
  };
 
10
  if (!opts || !chartsRef.current) return;
11
  chartsInstanceRef.current = echarts.init(chartsRef.current, null, {
12
  renderer: "canvas",
13
+ width: "auto",
14
+ height: "auto",
15
  });
16
  chartsInstanceRef.current.setOption(opts);
17
+ const resizeObserver = new ResizeObserver(() => {
18
+ const e = chartsRef.current!;
19
+ e.style.padding = "1px";
20
+ chartsInstanceRef.current?.resize();
21
+ e.style.padding = "0";
22
+ });
23
+ const observed = chartsRef.current;
24
+ resizeObserver.observe(observed);
25
  return () => {
26
+ resizeObserver.unobserve(observed);
27
  chartsInstanceRef.current?.dispose();
28
  };
29
  }, [props.data?.display?.value]);
30
+ const nodeStyle = { display: "flex", flexDirection: "column" };
31
+ const vizStyle = { flex: 1 };
32
  return (
33
+ <NodeWithParams nodeStyle={nodeStyle} collapsed {...props}>
34
+ <div style={vizStyle} ref={chartsRef} />
35
  </NodeWithParams>
36
  );
37
  };
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];
@@ -35,9 +32,9 @@ test("Box creation & deletion per env", async () => {
35
  });
36
 
37
  test("Delete multi-handle boxes", async () => {
38
- await workspace.addBox("Compute PageRank");
39
- await workspace.deleteBoxes(["Compute PageRank 1"]);
40
- await expect(workspace.getBox("Compute PageRank 1")).not.toBeVisible();
41
  });
42
 
43
  test("Drag box", async () => {
 
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];
 
32
  });
33
 
34
  test("Delete multi-handle boxes", async () => {
35
+ await workspace.addBox("NX PageRank");
36
+ await workspace.deleteBoxes(["NX PageRank 1"]);
37
+ await expect(workspace.getBox("NX PageRank 1")).not.toBeVisible();
38
  });
39
 
40
  test("Drag box", async () => {
lynxkite-app/web/tests/errors.spec.ts CHANGED
@@ -20,24 +20,24 @@ test.afterEach(async () => {
20
  test("missing parameter", async () => {
21
  // Test the correct error message is displayed when a required parameter is missing,
22
  // and that the error message is removed when the parameter is filled.
23
- await workspace.addBox("Create scale-free graph");
24
- const graphBox = workspace.getBox("Create scale-free graph 1");
25
- await graphBox.locator("input").fill("");
26
- expect(await graphBox.locator(".error").innerText()).toBe(
27
- "invalid literal for int() with base 10: ''",
28
- );
29
- await graphBox.locator("input").fill("10");
30
  await expect(graphBox.locator(".error")).not.toBeVisible();
31
  });
32
 
33
  test("unknown operation", async () => {
34
  // Test that the correct error is displayed when the operation does not belong to
35
  // the current environment.
36
- await workspace.addBox("Create scale-free graph");
 
 
37
  await workspace.setEnv("LynxScribe");
38
- const csvBox = workspace.getBox("Create scale-free graph 1");
39
- const errorText = await csvBox.locator(".error").innerText();
40
- expect(errorText).toBe('Operation "Create scale-free graph" not found.');
 
41
  await workspace.setEnv("LynxKite Graph Analytics");
42
  await expect(csvBox.locator(".error")).not.toBeVisible();
43
  });
 
20
  test("missing parameter", async () => {
21
  // Test the correct error message is displayed when a required parameter is missing,
22
  // and that the error message is removed when the parameter is filled.
23
+ await workspace.addBox("NX › Scale-Free Graph");
24
+ const graphBox = workspace.getBox("NX › Scale-Free Graph 1");
25
+ await expect(graphBox.locator(".error")).toHaveText("n is unset.");
26
+ await graphBox.getByLabel("n", { exact: true }).fill("10");
 
 
 
27
  await expect(graphBox.locator(".error")).not.toBeVisible();
28
  });
29
 
30
  test("unknown operation", async () => {
31
  // Test that the correct error is displayed when the operation does not belong to
32
  // the current environment.
33
+ await workspace.addBox("NX › Scale-Free Graph");
34
+ const graphBox = workspace.getBox("NX › Scale-Free Graph 1");
35
+ await graphBox.getByLabel("n", { exact: true }).fill("10");
36
  await workspace.setEnv("LynxScribe");
37
+ const csvBox = workspace.getBox("NX › Scale-Free Graph 1");
38
+ await expect(csvBox.locator(".error")).toHaveText(
39
+ 'Operation "NX › Scale-Free Graph" not found.',
40
+ );
41
  await workspace.setEnv("LynxKite Graph Analytics");
42
  await expect(csvBox.locator(".error")).not.toBeVisible();
43
  });
lynxkite-app/web/tests/examples.spec.ts CHANGED
@@ -17,30 +17,23 @@ 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
  });
25
 
26
- test.fail("LynxScribe example", async ({ page }) => {
27
  // Fails because of missing OPENAI_API_KEY
28
  const ws = await Workspace.open(page, "LynxScribe demo");
29
  await ws.expectErrorFree();
30
  });
31
 
32
- test.fail("Graph RAG", async ({ page }) => {
33
  // Fails due to some issue with ChromaDB
34
  const ws = await Workspace.open(page, "Graph RAG");
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
  });
24
 
25
+ test("LynxScribe example", async ({ page }) => {
26
  // Fails because of missing OPENAI_API_KEY
27
  const ws = await Workspace.open(page, "LynxScribe demo");
28
  await ws.expectErrorFree();
29
  });
30
 
31
+ test("Graph RAG", async ({ page }) => {
32
  // Fails due to some issue with ChromaDB
33
  const ws = await Workspace.open(page, "Graph RAG");
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/graph_creation.spec.ts CHANGED
@@ -9,9 +9,13 @@ test.beforeEach(async ({ browser }) => {
9
  await browser.newPage(),
10
  "graph_creation_spec_test",
11
  );
12
- await workspace.addBox("Create scale-free graph");
 
 
 
 
13
  await workspace.addBox("Create graph");
14
- await workspace.connectBoxes("Create scale-free graph 1", "Create graph 1");
15
  });
16
 
17
  test.afterEach(async () => {
 
9
  await browser.newPage(),
10
  "graph_creation_spec_test",
11
  );
12
+ await workspace.addBox("NX › Scale-Free Graph");
13
+ await workspace
14
+ .getBox("NX › Scale-Free Graph 1")
15
+ .getByLabel("n", { exact: true })
16
+ .fill("10");
17
  await workspace.addBox("Create graph");
18
+ await workspace.connectBoxes("NX › Scale-Free Graph 1", "Create graph 1");
19
  });
20
 
21
  test.afterEach(async () => {
lynxkite-app/web/tests/lynxkite.ts CHANGED
@@ -59,21 +59,23 @@ 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.locator(".node-search").getByText(boxName).click();
 
 
 
69
  await expect(this.getBoxes()).toHaveCount(allBoxes.length + 1);
70
  }
71
 
72
  async getCatalog() {
73
- await this.page.locator(".react-flow__pane").click();
74
- const catalog = await this.page
75
- .locator(".node-search .matches .search-result")
76
- .allInnerTexts();
 
77
  // Dismiss the catalog menu
78
  await this.page.keyboard.press("Escape");
79
  await expect(this.page.locator(".node-search")).not.toBeVisible();
@@ -150,11 +152,6 @@ export class Workspace {
150
  }
151
 
152
  async expectErrorFree(executionWaitTime?) {
153
- // TODO: Workaround, to account for workspace execution. Once
154
- // we have a load indicator we can use that instead.
155
- await new Promise((resolve) =>
156
- setTimeout(resolve, executionWaitTime ? executionWaitTime : 500),
157
- );
158
  await expect(this.getBoxes().locator(".error").first()).not.toBeVisible();
159
  }
160
 
@@ -185,7 +182,7 @@ export class Splash {
185
  }
186
 
187
  workspace(name: string) {
188
- return this.page.getByRole("link", { name: name });
189
  }
190
 
191
  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
67
+ .locator(".node-search")
68
+ .getByText(boxName, { exact: true })
69
+ .click();
70
  await expect(this.getBoxes()).toHaveCount(allBoxes.length + 1);
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-bio/src/lynxkite_bio/__init__.py CHANGED
@@ -10,7 +10,7 @@ import rdkit.Chem.rdFingerprintGenerator
10
  import rdkit.Chem.Fingerprints.ClusterMols
11
  import scipy
12
 
13
- mem = joblib.Memory("../joblib-cache")
14
  ENV = "LynxKite Graph Analytics"
15
  op = ops.op_registration(ENV)
16
 
 
10
  import rdkit.Chem.Fingerprints.ClusterMols
11
  import scipy
12
 
13
+ mem = joblib.Memory("joblib-cache")
14
  ENV = "LynxKite Graph Analytics"
15
  op = ops.op_registration(ENV)
16
 
lynxkite-core/src/lynxkite/core/executors/one_by_one.py CHANGED
@@ -142,12 +142,12 @@ async def execute(ws: workspace.Workspace, catalog, cache=None):
142
  key = make_cache_key((inputs, params))
143
  if key not in cache:
144
  result: ops.Result = op(*inputs, **params)
145
- output = await await_if_needed(result.output)
146
- cache[key] = output
147
- output = cache[key]
148
  else:
149
  result = op(*inputs, **params)
150
- output = await await_if_needed(result.output)
151
  except Exception as e:
152
  traceback.print_exc()
153
  node.publish_error(e)
 
142
  key = make_cache_key((inputs, params))
143
  if key not in cache:
144
  result: ops.Result = op(*inputs, **params)
145
+ result.output = await await_if_needed(result.output)
146
+ cache[key] = result
147
+ result = cache[key]
148
  else:
149
  result = op(*inputs, **params)
150
+ output = await await_if_needed(result.output)
151
  except Exception as e:
152
  traceback.print_exc()
153
  node.publish_error(e)
lynxkite-core/src/lynxkite/core/ops.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
  import enum
5
  import functools
6
  import inspect
 
7
  import pydantic
8
  import typing
9
  from dataclasses import dataclass
@@ -123,6 +124,25 @@ def basic_outputs(*names):
123
  return {name: Output(name=name, type=None) for name in names}
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  class Op(BaseConfig):
127
  func: typing.Callable = pydantic.Field(exclude=True)
128
  name: str
@@ -136,12 +156,7 @@ class Op(BaseConfig):
136
  # Convert parameters.
137
  for p in params:
138
  if p in self.params:
139
- if self.params[p].type is int:
140
- params[p] = int(params[p])
141
- elif self.params[p].type is float:
142
- params[p] = float(params[p])
143
- elif isinstance(self.params[p].type, enum.EnumMeta):
144
- params[p] = self.params[p].type[params[p]]
145
  res = self.func(*inputs, **params)
146
  if not isinstance(res, Result):
147
  # Automatically wrap the result in a Result object, if it isn't already.
 
4
  import enum
5
  import functools
6
  import inspect
7
+ import types
8
  import pydantic
9
  import typing
10
  from dataclasses import dataclass
 
124
  return {name: Output(name=name, type=None) for name in names}
125
 
126
 
127
+ def _param_to_type(name, value, type):
128
+ value = value or ""
129
+ if type is int:
130
+ assert value != "", f"{name} is unset."
131
+ return int(value)
132
+ if type is float:
133
+ assert value != "", f"{name} is unset."
134
+ return float(value)
135
+ if isinstance(type, enum.EnumMeta):
136
+ return type[value]
137
+ if isinstance(type, types.UnionType):
138
+ match type.__args__:
139
+ case (types.NoneType, type):
140
+ return None if value == "" else _param_to_type(name, value, type)
141
+ case (type, types.NoneType):
142
+ return None if value == "" else _param_to_type(name, value, type)
143
+ return value
144
+
145
+
146
  class Op(BaseConfig):
147
  func: typing.Callable = pydantic.Field(exclude=True)
148
  name: str
 
156
  # Convert parameters.
157
  for p in params:
158
  if p in self.params:
159
+ params[p] = _param_to_type(p, params[p], self.params[p].type)
 
 
 
 
 
160
  res = self.func(*inputs, **params)
161
  if not isinstance(res, Result):
162
  # Automatically wrap the result in a Result object, if it isn't already.
lynxkite-core/src/lynxkite/core/workspace.py CHANGED
@@ -92,6 +92,7 @@ class Workspace(BaseConfig):
92
  env: str = ""
93
  nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
94
  edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
 
95
 
96
 
97
  async def execute(ws: Workspace):
@@ -104,7 +105,8 @@ def save(ws: Workspace, path: str):
104
  j = ws.model_dump()
105
  j = json.dumps(j, indent=2, sort_keys=True) + "\n"
106
  dirname, basename = os.path.split(path)
107
- os.makedirs(dirname, exist_ok=True)
 
108
  # Create temp file in the same directory to make sure it's on the same filesystem.
109
  with tempfile.NamedTemporaryFile(
110
  "w", prefix=f".{basename}.", dir=dirname, delete=False
 
92
  env: str = ""
93
  nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
94
  edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
95
+ _crdt: pycrdt.Map
96
 
97
 
98
  async def execute(ws: Workspace):
 
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-graph-analytics/Dockerfile.bionemo CHANGED
@@ -15,3 +15,7 @@ RUN uv pip install -e lynxkite-core/[dev] -e lynxkite-app/[dev] -e lynxkite-grap
15
 
16
  # bionemo cellxgene_census needs this version of numpy
17
  RUN uv pip install numpy==1.26.4
 
 
 
 
 
15
 
16
  # bionemo cellxgene_census needs this version of numpy
17
  RUN uv pip install numpy==1.26.4
18
+
19
+ ENV LYNXKITE_DATA=examples
20
+
21
+ CMD ["lynxkite"]
lynxkite-graph-analytics/README.md CHANGED
@@ -28,13 +28,12 @@ To build the image:
28
  ```bash
29
  # in lynxkite-graph-analytics folder
30
  $ docker build -f Dockerfile.bionemo -t lynxkite-bionemo ..
 
31
  ```
32
 
33
  Take into account that this Dockerfile does not include the lynxkite-lynxscribe package. If you want to include it you will
34
  need to set up git credentials inside the container.
35
 
36
- Then, inside the image you can start LynxKite as usual.
37
-
38
  If you want to do some development, then it is recommend to use the [devcontainers](https://code.visualstudio.com/docs/devcontainers/containers)
39
  vscode extension. The following is a basic configuration to get started:
40
 
 
28
  ```bash
29
  # in lynxkite-graph-analytics folder
30
  $ docker build -f Dockerfile.bionemo -t lynxkite-bionemo ..
31
+ $ docker run --name bionemo -d -p8000:8000 --gpus=all lynxkite-bionemo
32
  ```
33
 
34
  Take into account that this Dockerfile does not include the lynxkite-lynxscribe package. If you want to include it you will
35
  need to set up git credentials inside the container.
36
 
 
 
37
  If you want to do some development, then it is recommend to use the [devcontainers](https://code.visualstudio.com/docs/devcontainers/containers)
38
  vscode extension. The following is a basic configuration to get started:
39
 
lynxkite-graph-analytics/src/lynxkite_graph_analytics/bionemo_ops.py CHANGED
@@ -40,7 +40,7 @@ from bionemo.scdl.io.single_cell_collection import SingleCellCollection
40
  import scanpy
41
 
42
 
43
- mem = joblib.Memory("../joblib-cache")
44
  op = ops.op_registration(core.ENV)
45
  DATA_PATH = Path("/workspace")
46
 
 
40
  import scanpy
41
 
42
 
43
+ mem = joblib.Memory("joblib-cache")
44
  op = ops.op_registration(core.ENV)
45
  DATA_PATH = Path("/workspace")
46
 
lynxkite-graph-analytics/src/lynxkite_graph_analytics/core.py CHANGED
@@ -1,6 +1,7 @@
1
  """Graph analytics executor and data types."""
2
 
3
- from lynxkite.core import ops
 
4
  import dataclasses
5
  import functools
6
  import networkx as nx
@@ -134,54 +135,73 @@ def nx_node_attribute_func(name):
134
  return decorator
135
 
136
 
137
- def disambiguate_edges(ws):
138
  """If an input plug is connected to multiple edges, keep only the last edge."""
139
  seen = set()
140
  for edge in reversed(ws.edges):
141
  if (edge.target, edge.targetHandle) in seen:
142
- ws.edges.remove(edge)
 
 
 
143
  seen.add((edge.target, edge.targetHandle))
144
 
145
 
146
  @ops.register_executor(ENV)
147
- async def execute(ws):
148
  catalog: dict[str, ops.Op] = ops.CATALOGS[ws.env]
149
  disambiguate_edges(ws)
150
  outputs = {}
151
- failed = 0
152
- while len(outputs) + failed < len(ws.nodes):
153
- for node in ws.nodes:
154
- if node.id in outputs:
155
- continue
156
- # TODO: Take the input/output handles into account.
157
- inputs = [edge.source for edge in ws.edges if edge.target == node.id]
158
- if all(input in outputs for input in inputs):
 
159
  # All inputs for this node are ready, we can compute the output.
160
- inputs = [outputs[input] for input in inputs]
161
- params = {**node.data.params}
162
- op = catalog.get(node.data.title)
163
- if not op:
164
- node.publish_error("Operation not found in catalog")
165
- failed += 1
166
- continue
167
- node.publish_started()
168
- try:
169
- # Convert inputs types to match operation signature.
170
- for i, (x, p) in enumerate(zip(inputs, op.inputs.values())):
171
- if p.type == nx.Graph and isinstance(x, Bundle):
172
- inputs[i] = x.to_nx()
173
- elif p.type == Bundle and isinstance(x, nx.Graph):
174
- inputs[i] = Bundle.from_nx(x)
175
- elif p.type == Bundle and isinstance(x, pd.DataFrame):
176
- inputs[i] = Bundle.from_df(x)
177
- result = op(*inputs, **params)
178
- except Exception as e:
179
- traceback.print_exc()
180
- node.publish_error(e)
181
- failed += 1
182
- continue
183
- outputs[node.id] = result.output
184
- node.publish_result(result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
 
187
  def df_for_frontend(df: pd.DataFrame, limit: int) -> pd.DataFrame:
 
1
  """Graph analytics executor and data types."""
2
 
3
+ import os
4
+ from lynxkite.core import ops, workspace
5
  import dataclasses
6
  import functools
7
  import networkx as nx
 
135
  return decorator
136
 
137
 
138
+ def disambiguate_edges(ws: workspace.Workspace):
139
  """If an input plug is connected to multiple edges, keep only the last edge."""
140
  seen = set()
141
  for edge in reversed(ws.edges):
142
  if (edge.target, edge.targetHandle) in seen:
143
+ i = ws.edges.index(edge)
144
+ del ws.edges[i]
145
+ if hasattr(ws, "_crdt"):
146
+ del ws._crdt["edges"][i]
147
  seen.add((edge.target, edge.targetHandle))
148
 
149
 
150
  @ops.register_executor(ENV)
151
+ async def execute(ws: workspace.Workspace):
152
  catalog: dict[str, ops.Op] = ops.CATALOGS[ws.env]
153
  disambiguate_edges(ws)
154
  outputs = {}
155
+ nodes = {node.id: node for node in ws.nodes}
156
+ todo = set(nodes.keys())
157
+ progress = True
158
+ while progress:
159
+ progress = False
160
+ for id in list(todo):
161
+ node = nodes[id]
162
+ input_nodes = [edge.source for edge in ws.edges if edge.target == id]
163
+ if all(input in outputs for input in input_nodes):
164
  # All inputs for this node are ready, we can compute the output.
165
+ todo.remove(id)
166
+ progress = True
167
+ _execute_node(node, ws, catalog, outputs)
168
+
169
+
170
+ def _execute_node(node, ws, catalog, outputs):
171
+ params = {**node.data.params}
172
+ op = catalog.get(node.data.title)
173
+ if not op:
174
+ node.publish_error("Operation not found in catalog")
175
+ return
176
+ node.publish_started()
177
+ input_map = {
178
+ edge.targetHandle: outputs[edge.source]
179
+ for edge in ws.edges
180
+ if edge.target == node.id
181
+ }
182
+ try:
183
+ # Convert inputs types to match operation signature.
184
+ inputs = []
185
+ for p in op.inputs.values():
186
+ if p.name not in input_map:
187
+ node.publish_error(f"Missing input: {p.name}")
188
+ return
189
+ x = input_map[p.name]
190
+ if p.type == nx.Graph and isinstance(x, Bundle):
191
+ x = x.to_nx()
192
+ elif p.type == Bundle and isinstance(x, nx.Graph):
193
+ x = Bundle.from_nx(x)
194
+ elif p.type == Bundle and isinstance(x, pd.DataFrame):
195
+ x = Bundle.from_df(x)
196
+ inputs.append(x)
197
+ result = op(*inputs, **params)
198
+ except Exception as e:
199
+ if os.environ.get("LYNXKITE_LOG_OP_ERRORS"):
200
+ traceback.print_exc()
201
+ node.publish_error(e)
202
+ return
203
+ outputs[node.id] = result.output
204
+ node.publish_result(result)
205
 
206
 
207
  def df_for_frontend(df: pd.DataFrame, limit: int) -> pd.DataFrame:
lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py CHANGED
@@ -15,7 +15,7 @@ import polars as pl
15
  import json
16
 
17
 
18
- mem = joblib.Memory("../joblib-cache")
19
  op = ops.op_registration(core.ENV)
20
 
21
 
@@ -122,25 +122,6 @@ def import_osm(*, location: str):
122
  return ox.graph.graph_from_place(location, network_type="drive")
123
 
124
 
125
- @op("Create scale-free graph")
126
- def create_scale_free_graph(*, nodes: int = 10):
127
- """Creates a scale-free graph with the given number of nodes."""
128
- return nx.scale_free_graph(nodes)
129
-
130
-
131
- @op("Compute PageRank")
132
- @core.nx_node_attribute_func("pagerank")
133
- def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
134
- # TODO: This requires scipy to be installed.
135
- return nx.pagerank(graph, alpha=damping, max_iter=iterations)
136
-
137
-
138
- @op("Compute betweenness centrality")
139
- @core.nx_node_attribute_func("betweenness_centrality")
140
- def compute_betweenness_centrality(graph: nx.Graph, *, k=10):
141
- return nx.betweenness_centrality(graph, k=k)
142
-
143
-
144
  @op("Discard loop edges")
145
  def discard_loop_edges(graph: nx.Graph):
146
  graph = graph.copy()
 
15
  import json
16
 
17
 
18
+ mem = joblib.Memory("joblib-cache")
19
  op = ops.op_registration(core.ENV)
20
 
21
 
 
122
  return ox.graph.graph_from_place(location, network_type="drive")
123
 
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  @op("Discard loop edges")
126
  def discard_loop_edges(graph: nx.Graph):
127
  graph = graph.copy()
lynxkite-graph-analytics/src/lynxkite_graph_analytics/networkx_ops.py CHANGED
@@ -1,13 +1,155 @@
1
  """Automatically wraps all NetworkX functions as LynxKite operations."""
2
 
 
 
3
  from lynxkite.core import ops
4
  import functools
5
  import inspect
6
  import networkx as nx
 
 
 
7
 
8
  ENV = "LynxKite Graph Analytics"
9
 
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  def wrapped(name: str, func):
12
  @functools.wraps(func)
13
  def wrapper(*args, **kwargs):
@@ -15,48 +157,118 @@ def wrapped(name: str, func):
15
  if v == "None":
16
  kwargs[k] = None
17
  res = func(*args, **kwargs)
 
18
  if isinstance(res, nx.Graph):
19
  return res
20
- # Otherwise it's a node attribute.
21
- graph = args[0].copy()
22
- nx.set_node_attributes(graph, values=res, name=name)
23
- return graph
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  return wrapper
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  def register_networkx(env: str):
29
  cat = ops.CATALOGS.setdefault(env, {})
 
30
  for name, func in nx.__dict__.items():
31
  if hasattr(func, "graphs"):
32
- sig = inspect.signature(func)
 
 
 
33
  inputs = {k: ops.Input(name=k, type=nx.Graph) for k in func.graphs}
34
- params = {
35
- name: ops.Parameter.basic(
36
- name,
37
- str(param.default)
38
- if type(param.default) in [str, int, float]
39
- else None,
40
- param.annotation,
41
- )
42
- for name, param in sig.parameters.items()
43
- if name not in ["G", "backend", "backend_kwargs", "create_using"]
44
- }
45
- for p in params.values():
46
- if not p.type:
47
- # Guess the type based on the name.
48
- if len(p.name) == 1:
49
- p.type = int
50
- name = "NX › " + name.replace("_", " ").title()
51
  op = ops.Op(
52
  func=wrapped(name, func),
53
- name=name,
54
  params=params,
55
  inputs=inputs,
56
  outputs={"output": ops.Output(name="output", type=nx.Graph)},
57
  type="basic",
58
  )
59
- cat[name] = op
 
 
60
 
61
 
62
  register_networkx(ENV)
 
1
  """Automatically wraps all NetworkX functions as LynxKite operations."""
2
 
3
+ import collections
4
+ import types
5
  from lynxkite.core import ops
6
  import functools
7
  import inspect
8
  import networkx as nx
9
+ import re
10
+
11
+ import pandas as pd
12
 
13
  ENV = "LynxKite Graph Analytics"
14
 
15
 
16
+ class UnsupportedParameterType(Exception):
17
+ pass
18
+
19
+
20
+ _UNSUPPORTED = object()
21
+ _SKIP = object()
22
+
23
+
24
+ def doc_to_type(name: str, type_hint: str) -> type:
25
+ type_hint = type_hint.lower()
26
+ type_hint = re.sub("[(][^)]+[)]", "", type_hint).strip().strip(".")
27
+ if " " in name or "http" in name:
28
+ return _UNSUPPORTED # Not a parameter type.
29
+ if type_hint.endswith(", optional"):
30
+ w = doc_to_type(name, type_hint.removesuffix(", optional").strip())
31
+ if w is _UNSUPPORTED:
32
+ return _SKIP
33
+ return w if w is _SKIP else w | None
34
+ if type_hint in [
35
+ "a digraph or multidigraph",
36
+ "a graph g",
37
+ "graph",
38
+ "graphs",
39
+ "networkx graph instance",
40
+ "networkx graph",
41
+ "networkx undirected graph",
42
+ "nx.graph",
43
+ "undirected graph",
44
+ "undirected networkx graph",
45
+ ] or type_hint.startswith("networkx graph"):
46
+ return nx.Graph
47
+ elif type_hint in [
48
+ "digraph-like",
49
+ "digraph",
50
+ "directed graph",
51
+ "networkx digraph",
52
+ "networkx directed graph",
53
+ "nx.digraph",
54
+ ]:
55
+ return nx.DiGraph
56
+ elif type_hint == "node":
57
+ return _UNSUPPORTED
58
+ elif type_hint == '"node (optional)"':
59
+ return _SKIP
60
+ elif type_hint == '"edge"':
61
+ return _UNSUPPORTED
62
+ elif type_hint == '"edge (optional)"':
63
+ return _SKIP
64
+ elif type_hint in ["class", "data type"]:
65
+ return _UNSUPPORTED
66
+ elif type_hint in ["string", "str", "node label"]:
67
+ return str
68
+ elif type_hint in ["string or none", "none or string", "string, or none"]:
69
+ return str | None
70
+ elif type_hint in ["int", "integer"]:
71
+ return int
72
+ elif type_hint in ["bool", "boolean"]:
73
+ return bool
74
+ elif type_hint == "tuple":
75
+ return _UNSUPPORTED
76
+ elif type_hint == "set":
77
+ return _UNSUPPORTED
78
+ elif type_hint == "list of floats":
79
+ return _UNSUPPORTED
80
+ elif type_hint == "list of floats or float":
81
+ return float
82
+ elif type_hint in ["dict", "dictionary"]:
83
+ return _UNSUPPORTED
84
+ elif type_hint == "scalar or dictionary":
85
+ return float
86
+ elif type_hint == "none or dict":
87
+ return _SKIP
88
+ elif type_hint in ["function", "callable"]:
89
+ return _UNSUPPORTED
90
+ elif type_hint in [
91
+ "collection",
92
+ "container of nodes",
93
+ "list of nodes",
94
+ ]:
95
+ return _UNSUPPORTED
96
+ elif type_hint in [
97
+ "container",
98
+ "generator",
99
+ "iterable",
100
+ "iterator",
101
+ "list or iterable container",
102
+ "list or iterable",
103
+ "list or set",
104
+ "list or tuple",
105
+ "list",
106
+ ]:
107
+ return _UNSUPPORTED
108
+ elif type_hint == "generator of sets":
109
+ return _UNSUPPORTED
110
+ elif type_hint == "dict or a set of 2 or 3 tuples":
111
+ return _UNSUPPORTED
112
+ elif type_hint == "set of 2 or 3 tuples":
113
+ return _UNSUPPORTED
114
+ elif type_hint == "none, string or function":
115
+ return str | None
116
+ elif type_hint == "string or function" and name == "weight":
117
+ return str
118
+ elif type_hint == "integer, float, or none":
119
+ return float | None
120
+ elif type_hint in [
121
+ "float",
122
+ "int or float",
123
+ "integer or float",
124
+ "integer, float",
125
+ "number",
126
+ "numeric",
127
+ "real",
128
+ "scalar",
129
+ ]:
130
+ return float
131
+ elif type_hint in ["integer or none", "int or none"]:
132
+ return int | None
133
+ elif name == "seed":
134
+ return int | None
135
+ elif name == "weight":
136
+ return str
137
+ elif type_hint == "object":
138
+ return _UNSUPPORTED
139
+ return _SKIP
140
+
141
+
142
+ def types_from_doc(doc: str) -> dict[str, type]:
143
+ types = {}
144
+ for line in doc.splitlines():
145
+ if ":" in line:
146
+ a, b = line.split(":", 1)
147
+ for a in a.split(","):
148
+ a = a.strip()
149
+ types[a] = doc_to_type(a, b)
150
+ return types
151
+
152
+
153
  def wrapped(name: str, func):
154
  @functools.wraps(func)
155
  def wrapper(*args, **kwargs):
 
157
  if v == "None":
158
  kwargs[k] = None
159
  res = func(*args, **kwargs)
160
+ # Figure out what the returned value is.
161
  if isinstance(res, nx.Graph):
162
  return res
163
+ if isinstance(res, types.GeneratorType):
164
+ res = list(res)
165
+ if name in ["articulation_points"]:
166
+ graph = args[0].copy()
167
+ nx.set_node_attributes(graph, 0, name=name)
168
+ nx.set_node_attributes(graph, {r: 1 for r in res}, name=name)
169
+ return graph
170
+ if isinstance(res, collections.abc.Sized):
171
+ if len(res) == 0:
172
+ return pd.DataFrame()
173
+ for a in args:
174
+ if isinstance(a, nx.Graph):
175
+ if a.number_of_nodes() == len(res):
176
+ graph = a.copy()
177
+ nx.set_node_attributes(graph, values=res, name=name)
178
+ return graph
179
+ if a.number_of_edges() == len(res):
180
+ graph = a.copy()
181
+ nx.set_edge_attributes(graph, values=res, name=name)
182
+ return graph
183
+ return pd.DataFrame({name: res})
184
+ return pd.DataFrame({name: [res]})
185
 
186
  return wrapper
187
 
188
 
189
+ def _get_params(func) -> dict | None:
190
+ sig = inspect.signature(func)
191
+ # Get types from docstring.
192
+ types = types_from_doc(func.__doc__)
193
+ # Always hide these.
194
+ for k in ["backend", "backend_kwargs", "create_using"]:
195
+ types[k] = _SKIP
196
+ # Add in types based on signature.
197
+ for k, param in sig.parameters.items():
198
+ if k in types:
199
+ continue
200
+ if param.annotation is not param.empty:
201
+ types[k] = param.annotation
202
+ if k in ["i", "j", "n"]:
203
+ types[k] = int
204
+ params = {}
205
+ for name, param in sig.parameters.items():
206
+ _type = types.get(name, _UNSUPPORTED)
207
+ if _type is _UNSUPPORTED:
208
+ raise UnsupportedParameterType(name)
209
+ if _type is _SKIP or _type in [nx.Graph, nx.DiGraph]:
210
+ continue
211
+ params[name] = ops.Parameter.basic(
212
+ name=name,
213
+ default=str(param.default)
214
+ if type(param.default) in [str, int, float]
215
+ else None,
216
+ type=_type,
217
+ )
218
+ return params
219
+
220
+
221
+ _REPLACEMENTS = [
222
+ ("Barabasi Albert", "Barabasi–Albert"),
223
+ ("Bellman Ford", "Bellman–Ford"),
224
+ ("Bethe Hessian", "Bethe–Hessian"),
225
+ ("Bfs", "BFS"),
226
+ ("Dag ", "DAG "),
227
+ ("Dfs", "DFS"),
228
+ ("Dorogovtsev Goltsev Mendes", "Dorogovtsev–Goltsev–Mendes"),
229
+ ("Erdos Renyi", "Erdos–Renyi"),
230
+ ("Floyd Warshall", "Floyd–Warshall"),
231
+ ("Gnc", "G(n,c)"),
232
+ ("Gnm", "G(n,m)"),
233
+ ("Gnp", "G(n,p)"),
234
+ ("Gnr", "G(n,r)"),
235
+ ("Havel Hakimi", "Havel–Hakimi"),
236
+ ("Hkn", "H(k,n)"),
237
+ ("Hnm", "H(n,m)"),
238
+ ("Kl ", "KL "),
239
+ ("Moebius Kantor", "Moebius–Kantor"),
240
+ ("Pagerank", "PageRank"),
241
+ ("Scale Free", "Scale-Free"),
242
+ ("Vf2Pp", "VF2++"),
243
+ ("Watts Strogatz", "Watts–Strogatz"),
244
+ ("Weisfeiler Lehman", "Weisfeiler–Lehman"),
245
+ ]
246
+
247
+
248
  def register_networkx(env: str):
249
  cat = ops.CATALOGS.setdefault(env, {})
250
+ counter = 0
251
  for name, func in nx.__dict__.items():
252
  if hasattr(func, "graphs"):
253
+ try:
254
+ params = _get_params(func)
255
+ except UnsupportedParameterType:
256
+ continue
257
  inputs = {k: ops.Input(name=k, type=nx.Graph) for k in func.graphs}
258
+ nicename = "NX › " + name.replace("_", " ").title()
259
+ for a, b in _REPLACEMENTS:
260
+ nicename = nicename.replace(a, b)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  op = ops.Op(
262
  func=wrapped(name, func),
263
+ name=nicename,
264
  params=params,
265
  inputs=inputs,
266
  outputs={"output": ops.Output(name="output", type=nx.Graph)},
267
  type="basic",
268
  )
269
+ cat[nicename] = op
270
+ counter += 1
271
+ print(f"Registered {counter} NetworkX operations.")
272
 
273
 
274
  register_networkx(ENV)
lynxkite-graph-analytics/tests/test_lynxkite_ops.py CHANGED
@@ -77,13 +77,13 @@ async def test_execute_operation_inputs_correct_cast():
77
  )
78
  ws.edges = [
79
  workspace.WorkspaceEdge(
80
- id="1", source="1", target="2", sourceHandle="1", targetHandle="2"
81
  ),
82
  workspace.WorkspaceEdge(
83
- id="2", source="2", target="3", sourceHandle="2", targetHandle="3"
84
  ),
85
  workspace.WorkspaceEdge(
86
- id="3", source="3", target="4", sourceHandle="3", targetHandle="4"
87
  ),
88
  ]
89
 
@@ -92,5 +92,73 @@ async def test_execute_operation_inputs_correct_cast():
92
  assert all([node.data.error is None for node in ws.nodes])
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  if __name__ == "__main__":
96
  pytest.main()
 
77
  )
78
  ws.edges = [
79
  workspace.WorkspaceEdge(
80
+ id="1", source="1", target="2", sourceHandle="output", targetHandle="graph"
81
  ),
82
  workspace.WorkspaceEdge(
83
+ id="2", source="2", target="3", sourceHandle="output", targetHandle="bundle"
84
  ),
85
  workspace.WorkspaceEdge(
86
+ id="3", source="3", target="4", sourceHandle="output", targetHandle="bundle"
87
  ),
88
  ]
89
 
 
92
  assert all([node.data.error is None for node in ws.nodes])
93
 
94
 
95
+ async def test_multiple_inputs():
96
+ """Make sure each input goes to the right argument."""
97
+ op = ops.op_registration("test")
98
+
99
+ @op("One")
100
+ def one():
101
+ return 1
102
+
103
+ @op("Two")
104
+ def two():
105
+ return 2
106
+
107
+ @op("Smaller?", view="visualization")
108
+ def is_smaller(a, b):
109
+ return a < b
110
+
111
+ ws = workspace.Workspace(env="test")
112
+ ws.nodes.append(
113
+ workspace.WorkspaceNode(
114
+ id="one",
115
+ type="cool",
116
+ data=workspace.WorkspaceNodeData(title="One", params={}),
117
+ position=workspace.Position(x=0, y=0),
118
+ )
119
+ )
120
+ ws.nodes.append(
121
+ workspace.WorkspaceNode(
122
+ id="two",
123
+ type="cool",
124
+ data=workspace.WorkspaceNodeData(title="Two", params={}),
125
+ position=workspace.Position(x=100, y=0),
126
+ )
127
+ )
128
+ ws.nodes.append(
129
+ workspace.WorkspaceNode(
130
+ id="smaller",
131
+ type="cool",
132
+ data=workspace.WorkspaceNodeData(title="Smaller?", params={}),
133
+ position=workspace.Position(x=200, y=0),
134
+ )
135
+ )
136
+ ws.edges = [
137
+ workspace.WorkspaceEdge(
138
+ id="one",
139
+ source="one",
140
+ target="smaller",
141
+ sourceHandle="output",
142
+ targetHandle="a",
143
+ ),
144
+ workspace.WorkspaceEdge(
145
+ id="two",
146
+ source="two",
147
+ target="smaller",
148
+ sourceHandle="output",
149
+ targetHandle="b",
150
+ ),
151
+ ]
152
+
153
+ await execute(ws)
154
+
155
+ assert ws.nodes[-1].data.display is True
156
+ # Flip the inputs.
157
+ ws.edges[0].targetHandle = "b"
158
+ ws.edges[1].targetHandle = "a"
159
+ await execute(ws)
160
+ assert ws.nodes[-1].data.display is False
161
+
162
+
163
  if __name__ == "__main__":
164
  pytest.main()
lynxkite-lynxscribe/README.md CHANGED
@@ -5,7 +5,7 @@ LynxKite UI for building LynxScribe chat applications. Also runs the chat applic
5
  To run a chat UI for LynxScribe workspaces:
6
 
7
  ```bash
8
- WEBUI_AUTH=false OPENAI_API_BASE_URL=http://localhost:8000/api/service/lynxscribe/lynxscribe_ops uvx open-webui serve
9
  ```
10
 
11
  Or use [Lynx WebUI](https://github.com/biggraph/lynx-webui/) instead of Open WebUI.
 
5
  To run a chat UI for LynxScribe workspaces:
6
 
7
  ```bash
8
+ WEBUI_AUTH=false OPENAI_API_BASE_URL=http://localhost:8000/api/service/lynxkite_lynxscribe uvx open-webui serve
9
  ```
10
 
11
  Or use [Lynx WebUI](https://github.com/biggraph/lynx-webui/) instead of Open WebUI.
lynxkite-lynxscribe/src/lynxkite_lynxscribe/lynxscribe_ops.py CHANGED
@@ -9,6 +9,7 @@ import pandas as pd
9
  import os
10
  import joblib
11
 
 
12
  from lynxscribe.core.llm.base import get_llm_engine
13
  from lynxscribe.core.vector_store.base import get_vector_store
14
  from lynxscribe.common.config import load_config
@@ -483,13 +484,12 @@ def view(input):
483
  return v
484
 
485
 
486
- async def get_chat_api(ws):
487
- import pathlib
488
  from lynxkite.core import workspace
489
 
490
- DATA_PATH = pathlib.Path.cwd() / "data"
491
- path = DATA_PATH / ws
492
- assert path.is_relative_to(DATA_PATH)
493
  assert path.exists(), f"Workspace {path} does not exist"
494
  ws = workspace.load(path)
495
  contexts = await ops.EXECUTORS[ENV](ws)
@@ -549,17 +549,15 @@ async def api_service_get(request):
549
 
550
 
551
  def get_lynxscribe_workspaces():
552
- import pathlib
553
  from lynxkite.core import workspace
554
 
555
- DATA_DIR = pathlib.Path.cwd() / "data"
556
  workspaces = []
557
- for p in DATA_DIR.glob("**/*"):
558
  if p.is_file():
559
  try:
560
  ws = workspace.load(p)
561
  if ws.env == ENV:
562
- workspaces.append(p.relative_to(DATA_DIR))
563
  except Exception:
564
  pass # Ignore files that are not valid workspaces.
565
  workspaces.sort()
 
9
  import os
10
  import joblib
11
 
12
+ import pathlib
13
  from lynxscribe.core.llm.base import get_llm_engine
14
  from lynxscribe.core.vector_store.base import get_vector_store
15
  from lynxscribe.common.config import load_config
 
484
  return v
485
 
486
 
487
+ async def get_chat_api(ws: str):
 
488
  from lynxkite.core import workspace
489
 
490
+ cwd = pathlib.Path()
491
+ path = cwd / ws
492
+ assert path.is_relative_to(cwd)
493
  assert path.exists(), f"Workspace {path} does not exist"
494
  ws = workspace.load(path)
495
  contexts = await ops.EXECUTORS[ENV](ws)
 
549
 
550
 
551
  def get_lynxscribe_workspaces():
 
552
  from lynxkite.core import workspace
553
 
 
554
  workspaces = []
555
+ for p in pathlib.Path().glob("**/*"):
556
  if p.is_file():
557
  try:
558
  ws = workspace.load(p)
559
  if ws.env == ENV:
560
+ workspaces.append(p)
561
  except Exception:
562
  pass # Ignore files that are not valid workspaces.
563
  workspaces.sort()