darabos commited on
Commit
b80a35a
Β·
unverified Β·
2 Parent(s): 4e3e9df 1b098e4

Merge pull request #125 from biggraph/darabos-code-files

Browse files
Files changed (38) hide show
  1. biome.json +4 -0
  2. examples/{AIMO β†’ AIMO.lynxkite.json} +0 -0
  3. examples/{Airlines demo β†’ Airlines demo.lynxkite.json} +0 -0
  4. examples/{Bio demo β†’ Bio demo.lynxkite.json} +0 -0
  5. examples/{BioNeMo demo β†’ BioNeMo demo.lynxkite.json} +0 -0
  6. examples/{Generative drug screening β†’ Generative drug screening.lynxkite.json} +0 -0
  7. examples/{Graph RAG β†’ Graph RAG.lynxkite.json} +0 -0
  8. examples/{Image processing β†’ Image processing.lynxkite.json} +0 -0
  9. examples/{LynxScribe demo β†’ LynxScribe demo.lynxkite.json} +0 -0
  10. examples/{Model definition β†’ Model definition.lynxkite.json} +0 -0
  11. examples/{Model use β†’ Model use.lynxkite.json} +0 -0
  12. examples/{NetworkX demo β†’ NetworkX demo.lynxkite.json} +0 -0
  13. examples/{ODE-GNN experiment β†’ ODE-GNN experiment.lynxkite.json} +0 -0
  14. examples/{ODE-GNN β†’ ODE-GNN.lynxkite.json} +0 -0
  15. examples/{PyTorch demo β†’ PyTorch demo.lynxkite.json} +0 -0
  16. examples/{RAG chatbot app β†’ RAG chatbot app.lynxkite.json} +0 -0
  17. examples/Word2vec.lynxkite.json +0 -0
  18. examples/fake_data.py +16 -0
  19. examples/requirements.txt +1 -0
  20. examples/{sql β†’ sql.lynxkite.json} +0 -0
  21. examples/word2vec.py +26 -0
  22. lynxkite-app/src/lynxkite_app/crdt.py +55 -9
  23. lynxkite-app/src/lynxkite_app/main.py +13 -2
  24. lynxkite-app/tests/test_main.py +15 -8
  25. lynxkite-app/web/index.html +0 -1
  26. lynxkite-app/web/package-lock.json +55 -0
  27. lynxkite-app/web/package.json +2 -0
  28. lynxkite-app/web/playwright.config.ts +1 -0
  29. lynxkite-app/web/src/Code.tsx +101 -0
  30. lynxkite-app/web/src/Directory.tsx +103 -91
  31. lynxkite-app/web/src/code-theme.ts +38 -0
  32. lynxkite-app/web/src/index.css +29 -0
  33. lynxkite-app/web/src/main.tsx +2 -0
  34. lynxkite-app/web/src/workspace/Workspace.tsx +9 -3
  35. lynxkite-app/web/tests/directory.spec.ts +0 -12
  36. lynxkite-app/web/tests/lynxkite.ts +10 -18
  37. lynxkite-app/web/vite.config.ts +1 -1
  38. lynxkite-core/src/lynxkite/core/ops.py +64 -2
biome.json CHANGED
@@ -1,4 +1,7 @@
1
  {
 
 
 
2
  "formatter": {
3
  "ignore": ["**/node_modules/**", "**/dist/**"],
4
  "lineWidth": 100,
@@ -21,6 +24,7 @@
21
  "useKeyWithClickEvents": "off",
22
  "useValidAnchor": "off",
23
  "useButtonType": "off",
 
24
  "noNoninteractiveTabindex": "off"
25
  }
26
  }
 
1
  {
2
+ "files": {
3
+ "ignore": ["**/*.lynxkite.json"]
4
+ },
5
  "formatter": {
6
  "ignore": ["**/node_modules/**", "**/dist/**"],
7
  "lineWidth": 100,
 
24
  "useKeyWithClickEvents": "off",
25
  "useValidAnchor": "off",
26
  "useButtonType": "off",
27
+ "noAutofocus": "off",
28
  "noNoninteractiveTabindex": "off"
29
  }
30
  }
examples/{AIMO β†’ AIMO.lynxkite.json} RENAMED
File without changes
examples/{Airlines demo β†’ Airlines demo.lynxkite.json} RENAMED
File without changes
examples/{Bio demo β†’ Bio demo.lynxkite.json} RENAMED
File without changes
examples/{BioNeMo demo β†’ BioNeMo demo.lynxkite.json} RENAMED
File without changes
examples/{Generative drug screening β†’ Generative drug screening.lynxkite.json} RENAMED
File without changes
examples/{Graph RAG β†’ Graph RAG.lynxkite.json} RENAMED
File without changes
examples/{Image processing β†’ Image processing.lynxkite.json} RENAMED
File without changes
examples/{LynxScribe demo β†’ LynxScribe demo.lynxkite.json} RENAMED
File without changes
examples/{Model definition β†’ Model definition.lynxkite.json} RENAMED
File without changes
examples/{Model use β†’ Model use.lynxkite.json} RENAMED
File without changes
examples/{NetworkX demo β†’ NetworkX demo.lynxkite.json} RENAMED
File without changes
examples/{ODE-GNN experiment β†’ ODE-GNN experiment.lynxkite.json} RENAMED
File without changes
examples/{ODE-GNN β†’ ODE-GNN.lynxkite.json} RENAMED
File without changes
examples/{PyTorch demo β†’ PyTorch demo.lynxkite.json} RENAMED
File without changes
examples/{RAG chatbot app β†’ RAG chatbot app.lynxkite.json} RENAMED
File without changes
examples/Word2vec.lynxkite.json ADDED
The diff for this file is too large to render. See raw diff
 
examples/fake_data.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from lynxkite.core.ops import op
2
+ from faker import Faker
3
+ import pandas as pd
4
+
5
+ faker = Faker()
6
+
7
+
8
+ @op("LynxKite Graph Analytics", "Fake data")
9
+ def fake(*, n=10):
10
+ df = pd.DataFrame(
11
+ {
12
+ "name": [faker.name() for _ in range(n)],
13
+ "address": [faker.address() for _ in range(n)],
14
+ }
15
+ )
16
+ return df
examples/requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Faker
examples/{sql β†’ sql.lynxkite.json} RENAMED
File without changes
examples/word2vec.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from lynxkite.core.ops import op
2
+ import staticvectors
3
+ import pandas as pd
4
+
5
+ ENV = "LynxKite Graph Analytics"
6
+
7
+
8
+ @op(ENV, "Word2vec for the top 1000 words", slow=True)
9
+ def word2vec_1000():
10
+ model = staticvectors.StaticVectors("neuml/word2vec-quantized")
11
+ df = pd.read_csv(
12
+ "https://gist.githubusercontent.com/deekayen/4148741/raw/98d35708fa344717d8eee15d11987de6c8e26d7d/1-1000.txt",
13
+ names=["word"],
14
+ )
15
+ df["embedding"] = model.embeddings(df.word.tolist()).tolist()
16
+ return df
17
+
18
+
19
+ @op(ENV, "Take first N")
20
+ def first_n(df: pd.DataFrame, *, n=10):
21
+ return df.head(n)
22
+
23
+
24
+ @op(ENV, "Sample N")
25
+ def sample_n(df: pd.DataFrame, *, n=10):
26
+ return df.sample(n)
lynxkite-app/src/lynxkite_app/crdt.py CHANGED
@@ -26,11 +26,11 @@ def ws_exception_handler(exception, log):
26
  return True
27
 
28
 
29
- class WebsocketServer(pycrdt_websocket.WebsocketServer):
30
  async def init_room(self, name: str) -> pycrdt_websocket.YRoom:
31
  """Initialize a room for the workspace with the given name.
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"
@@ -82,6 +82,37 @@ class WebsocketServer(pycrdt_websocket.WebsocketServer):
82
  return room
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  last_ws_input = None
86
 
87
 
@@ -233,6 +264,7 @@ async def execute(name: str, ws_crdt: pycrdt.Map, ws_pyd: workspace.Workspace, d
233
  assert path.is_relative_to(cwd), "Provided workspace path is invalid"
234
  # Save user changes before executing, in case the execution fails.
235
  workspace.save(ws_pyd, path)
 
236
  ws_pyd._crdt = ws_crdt
237
  with ws_crdt.doc.transaction():
238
  for nc, np in zip(ws_crdt["nodes"], ws_pyd.nodes):
@@ -246,14 +278,21 @@ async def execute(name: str, ws_crdt: pycrdt.Map, ws_pyd: workspace.Workspace, d
246
  print(f"Finished running {name} in {ws_pyd.env}.")
247
 
248
 
 
 
 
 
 
 
249
  @contextlib.asynccontextmanager
250
  async def lifespan(app):
251
- global websocket_server
252
- websocket_server = WebsocketServer(
253
- auto_clean_rooms=False,
254
- )
255
- async with websocket_server:
256
- yield
 
257
  print("closing websocket server")
258
 
259
 
@@ -264,5 +303,12 @@ def sanitize_path(path):
264
  @router.websocket("/ws/crdt/{room_name}")
265
  async def crdt_websocket(websocket: fastapi.WebSocket, room_name: str):
266
  room_name = sanitize_path(room_name)
267
- server = pycrdt_websocket.ASGIServer(websocket_server)
 
 
 
 
 
 
 
268
  await server({"path": room_name}, websocket._receive, websocket._send)
 
26
  return True
27
 
28
 
29
+ class WorkspaceWebsocketServer(pycrdt_websocket.WebsocketServer):
30
  async def init_room(self, name: str) -> pycrdt_websocket.YRoom:
31
  """Initialize a room for the workspace with the given name.
32
 
33
+ The workspace is loaded from ".crdt" if it exists there, or from a JSON file, or a new workspace is created.
34
  """
35
  crdt_path = pathlib.Path(".crdt")
36
  path = crdt_path / f"{name}.crdt"
 
82
  return room
83
 
84
 
85
+ class CodeWebsocketServer(WorkspaceWebsocketServer):
86
+ async def init_room(self, name: str) -> pycrdt_websocket.YRoom:
87
+ """Initialize a room for a text document with the given name."""
88
+ crdt_path = pathlib.Path(".crdt")
89
+ path = crdt_path / f"{name}.crdt"
90
+ assert path.is_relative_to(crdt_path)
91
+ ystore = pycrdt_websocket.ystore.FileYStore(path)
92
+ ydoc = pycrdt.Doc()
93
+ ydoc["text"] = text = pycrdt.Text()
94
+ # Replay updates from the store.
95
+ try:
96
+ for update, timestamp in [(item[0], item[-1]) async for item in ystore.read()]:
97
+ ydoc.apply_update(update)
98
+ except pycrdt_websocket.ystore.YDocNotFound:
99
+ pass
100
+ if len(text) == 0:
101
+ if os.path.exists(name):
102
+ with open(name) as f:
103
+ text += f.read()
104
+ room = pycrdt_websocket.YRoom(
105
+ ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
106
+ )
107
+ room.text = text
108
+
109
+ def on_change(changes):
110
+ asyncio.create_task(code_changed(name, changes, text))
111
+
112
+ text.observe(on_change)
113
+ return room
114
+
115
+
116
  last_ws_input = None
117
 
118
 
 
264
  assert path.is_relative_to(cwd), "Provided workspace path is invalid"
265
  # Save user changes before executing, in case the execution fails.
266
  workspace.save(ws_pyd, path)
267
+ ops.load_user_scripts(name)
268
  ws_pyd._crdt = ws_crdt
269
  with ws_crdt.doc.transaction():
270
  for nc, np in zip(ws_crdt["nodes"], ws_pyd.nodes):
 
278
  print(f"Finished running {name} in {ws_pyd.env}.")
279
 
280
 
281
+ async def code_changed(name: str, changes: pycrdt.TextEvent, text: pycrdt.Text):
282
+ # TODO: Make this more fancy?
283
+ with open(name, "w") as f:
284
+ f.write(str(text).strip() + "\n")
285
+
286
+
287
  @contextlib.asynccontextmanager
288
  async def lifespan(app):
289
+ global ws_websocket_server
290
+ global code_websocket_server
291
+ ws_websocket_server = WorkspaceWebsocketServer(auto_clean_rooms=False)
292
+ code_websocket_server = CodeWebsocketServer(auto_clean_rooms=False)
293
+ async with ws_websocket_server:
294
+ async with code_websocket_server:
295
+ yield
296
  print("closing websocket server")
297
 
298
 
 
303
  @router.websocket("/ws/crdt/{room_name}")
304
  async def crdt_websocket(websocket: fastapi.WebSocket, room_name: str):
305
  room_name = sanitize_path(room_name)
306
+ server = pycrdt_websocket.ASGIServer(ws_websocket_server)
307
+ await server({"path": room_name}, websocket._receive, websocket._send)
308
+
309
+
310
+ @router.websocket("/ws/code/crdt/{room_name}")
311
+ async def code_crdt_websocket(websocket: fastapi.WebSocket, room_name: str):
312
+ room_name = sanitize_path(room_name)
313
+ server = pycrdt_websocket.ASGIServer(code_websocket_server)
314
  await server({"path": room_name}, websocket._receive, websocket._send)
lynxkite-app/src/lynxkite_app/main.py CHANGED
@@ -26,6 +26,7 @@ def detect_plugins():
26
 
27
 
28
  lynxkite_plugins = detect_plugins()
 
29
 
30
  app = fastapi.FastAPI(lifespan=crdt.lifespan)
31
  app.include_router(crdt.router)
@@ -33,7 +34,8 @@ app.add_middleware(GZipMiddleware)
33
 
34
 
35
  @app.get("/api/catalog")
36
- def get_catalog():
 
37
  return {k: {op.name: op.model_dump() for op in v.values()} for k, v in ops.CATALOGS.items()}
38
 
39
 
@@ -82,6 +84,15 @@ class DirectoryEntry(pydantic.BaseModel):
82
  type: str
83
 
84
 
 
 
 
 
 
 
 
 
 
85
  @app.get("/api/dir/list")
86
  def list_dir(path: str):
87
  path = data_path / path
@@ -90,7 +101,7 @@ def list_dir(path: str):
90
  [
91
  DirectoryEntry(
92
  name=str(p.relative_to(data_path)),
93
- type="directory" if p.is_dir() else "workspace",
94
  )
95
  for p in path.iterdir()
96
  if not p.name.startswith(".")
 
26
 
27
 
28
  lynxkite_plugins = detect_plugins()
29
+ ops.save_catalogs("plugins loaded")
30
 
31
  app = fastapi.FastAPI(lifespan=crdt.lifespan)
32
  app.include_router(crdt.router)
 
34
 
35
 
36
  @app.get("/api/catalog")
37
+ def get_catalog(workspace: str):
38
+ ops.load_user_scripts(workspace)
39
  return {k: {op.name: op.model_dump() for op in v.values()} for k, v in ops.CATALOGS.items()}
40
 
41
 
 
84
  type: str
85
 
86
 
87
+ def _get_path_type(path: pathlib.Path) -> str:
88
+ if path.is_dir():
89
+ return "directory"
90
+ elif path.suffixes[-2:] == [".lynxkite", ".json"]:
91
+ return "workspace"
92
+ else:
93
+ return "file"
94
+
95
+
96
  @app.get("/api/dir/list")
97
  def list_dir(path: str):
98
  path = data_path / path
 
101
  [
102
  DirectoryEntry(
103
  name=str(p.relative_to(data_path)),
104
+ type=_get_path_type(p),
105
  )
106
  for p in path.iterdir()
107
  if not p.name.startswith(".")
lynxkite-app/tests/test_main.py CHANGED
@@ -22,7 +22,7 @@ def test_detect_plugins_with_plugins():
22
 
23
 
24
  def test_get_catalog():
25
- response = client.get("/api/catalog")
26
  assert response.status_code == 200
27
 
28
 
@@ -59,15 +59,22 @@ def test_save_and_load():
59
  def test_list_dir():
60
  test_dir = pathlib.Path() / str(uuid.uuid4())
61
  test_dir.mkdir(parents=True, exist_ok=True)
62
- test_file = test_dir / "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
66
- assert len(response.json()) == 1
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.rmdir()
 
 
 
71
 
72
 
73
  def test_make_dir():
 
22
 
23
 
24
  def test_get_catalog():
25
+ response = client.get("/api/catalog?workspace=test")
26
  assert response.status_code == 200
27
 
28
 
 
59
  def test_list_dir():
60
  test_dir = pathlib.Path() / str(uuid.uuid4())
61
  test_dir.mkdir(parents=True, exist_ok=True)
62
+ dir = test_dir / "test_dir"
63
+ dir.mkdir(exist_ok=True)
64
+ file = test_dir / "test_file.txt"
65
+ file.touch()
66
+ ws = test_dir / "test_workspace.lynxkite.json"
67
+ ws.touch()
68
  response = client.get(f"/api/dir/list?path={str(test_dir)}")
69
  assert response.status_code == 200
70
+ assert response.json() == [
71
+ {"name": f"{test_dir}/test_dir", "type": "directory"},
72
+ {"name": f"{test_dir}/test_file.txt", "type": "file"},
73
+ {"name": f"{test_dir}/test_workspace.lynxkite.json", "type": "workspace"},
74
+ ]
75
+ file.unlink()
76
+ ws.unlink()
77
+ dir.rmdir()
78
 
79
 
80
  def test_make_dir():
lynxkite-app/web/index.html CHANGED
@@ -4,7 +4,6 @@
4
  <meta charset="UTF-8" />
5
  <link rel="icon" type="image/svg+xml" href="/src/assets/favicon.ico" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>LynxKite 2025</title>
8
  </head>
9
  <body>
10
  <div id="root"></div>
 
4
  <meta charset="UTF-8" />
5
  <link rel="icon" type="image/svg+xml" href="/src/assets/favicon.ico" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
7
  </head>
8
  <body>
9
  <div id="root"></div>
lynxkite-app/web/package-lock.json CHANGED
@@ -10,6 +10,7 @@
10
  "dependencies": {
11
  "@esbuild/linux-x64": "^0.25.0",
12
  "@iconify-json/tabler": "^1.2.10",
 
13
  "@svgr/core": "^8.1.0",
14
  "@svgr/plugin-jsx": "^8.1.0",
15
  "@swc/core": "^1.10.1",
@@ -28,6 +29,7 @@
28
  "react-router-dom": "^7.0.2",
29
  "swr": "^2.2.5",
30
  "unplugin-icons": "^0.21.0",
 
31
  "y-websocket": "^2.0.4",
32
  "yjs": "^13.6.20"
33
  },
@@ -1105,6 +1107,29 @@
1105
  "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
1106
  "license": "MIT"
1107
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1108
  "node_modules/@nodelib/fs.scandir": {
1109
  "version": "2.1.5",
1110
  "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5436,6 +5461,13 @@
5436
  "ufo": "^1.5.4"
5437
  }
5438
  },
 
 
 
 
 
 
 
5439
  "node_modules/ms": {
5440
  "version": "2.1.3",
5441
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6437,6 +6469,12 @@
6437
  "url": "https://github.com/sponsors/wooorm"
6438
  }
6439
  },
 
 
 
 
 
 
6440
  "node_modules/string_decoder": {
6441
  "version": "1.3.0",
6442
  "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -7360,6 +7398,23 @@
7360
  "yjs": "^13.0.0"
7361
  }
7362
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7363
  "node_modules/y-protocols": {
7364
  "version": "1.0.6",
7365
  "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz",
 
10
  "dependencies": {
11
  "@esbuild/linux-x64": "^0.25.0",
12
  "@iconify-json/tabler": "^1.2.10",
13
+ "@monaco-editor/react": "^4.7.0",
14
  "@svgr/core": "^8.1.0",
15
  "@svgr/plugin-jsx": "^8.1.0",
16
  "@swc/core": "^1.10.1",
 
29
  "react-router-dom": "^7.0.2",
30
  "swr": "^2.2.5",
31
  "unplugin-icons": "^0.21.0",
32
+ "y-monaco": "^0.1.6",
33
  "y-websocket": "^2.0.4",
34
  "yjs": "^13.6.20"
35
  },
 
1107
  "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
1108
  "license": "MIT"
1109
  },
1110
+ "node_modules/@monaco-editor/loader": {
1111
+ "version": "1.5.0",
1112
+ "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
1113
+ "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==",
1114
+ "license": "MIT",
1115
+ "dependencies": {
1116
+ "state-local": "^1.0.6"
1117
+ }
1118
+ },
1119
+ "node_modules/@monaco-editor/react": {
1120
+ "version": "4.7.0",
1121
+ "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
1122
+ "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
1123
+ "license": "MIT",
1124
+ "dependencies": {
1125
+ "@monaco-editor/loader": "^1.5.0"
1126
+ },
1127
+ "peerDependencies": {
1128
+ "monaco-editor": ">= 0.25.0 < 1",
1129
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
1130
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
1131
+ }
1132
+ },
1133
  "node_modules/@nodelib/fs.scandir": {
1134
  "version": "2.1.5",
1135
  "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
 
5461
  "ufo": "^1.5.4"
5462
  }
5463
  },
5464
+ "node_modules/monaco-editor": {
5465
+ "version": "0.52.2",
5466
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
5467
+ "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
5468
+ "license": "MIT",
5469
+ "peer": true
5470
+ },
5471
  "node_modules/ms": {
5472
  "version": "2.1.3",
5473
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
 
6469
  "url": "https://github.com/sponsors/wooorm"
6470
  }
6471
  },
6472
+ "node_modules/state-local": {
6473
+ "version": "1.0.7",
6474
+ "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
6475
+ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
6476
+ "license": "MIT"
6477
+ },
6478
  "node_modules/string_decoder": {
6479
  "version": "1.3.0",
6480
  "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
 
7398
  "yjs": "^13.0.0"
7399
  }
7400
  },
7401
+ "node_modules/y-monaco": {
7402
+ "version": "0.1.6",
7403
+ "resolved": "https://registry.npmjs.org/y-monaco/-/y-monaco-0.1.6.tgz",
7404
+ "integrity": "sha512-sYRywMmcylt+Nupl+11AvizD2am06ST8lkVbUXuaEmrtV6Tf+TD4rsEm6u9YGGowYue+Vfg1IJ97SUP2J+PVXg==",
7405
+ "license": "MIT",
7406
+ "dependencies": {
7407
+ "lib0": "^0.2.43"
7408
+ },
7409
+ "engines": {
7410
+ "node": ">=12.0.0",
7411
+ "npm": ">=6.0.0"
7412
+ },
7413
+ "peerDependencies": {
7414
+ "monaco-editor": ">=0.20.0",
7415
+ "yjs": "^13.3.1"
7416
+ }
7417
+ },
7418
  "node_modules/y-protocols": {
7419
  "version": "1.0.6",
7420
  "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz",
lynxkite-app/web/package.json CHANGED
@@ -13,6 +13,7 @@
13
  "dependencies": {
14
  "@esbuild/linux-x64": "^0.25.0",
15
  "@iconify-json/tabler": "^1.2.10",
 
16
  "@svgr/core": "^8.1.0",
17
  "@svgr/plugin-jsx": "^8.1.0",
18
  "@swc/core": "^1.10.1",
@@ -31,6 +32,7 @@
31
  "react-router-dom": "^7.0.2",
32
  "swr": "^2.2.5",
33
  "unplugin-icons": "^0.21.0",
 
34
  "y-websocket": "^2.0.4",
35
  "yjs": "^13.6.20"
36
  },
 
13
  "dependencies": {
14
  "@esbuild/linux-x64": "^0.25.0",
15
  "@iconify-json/tabler": "^1.2.10",
16
+ "@monaco-editor/react": "^4.7.0",
17
  "@svgr/core": "^8.1.0",
18
  "@svgr/plugin-jsx": "^8.1.0",
19
  "@swc/core": "^1.10.1",
 
32
  "react-router-dom": "^7.0.2",
33
  "swr": "^2.2.5",
34
  "unplugin-icons": "^0.21.0",
35
+ "y-monaco": "^0.1.6",
36
  "y-websocket": "^2.0.4",
37
  "yjs": "^13.6.20"
38
  },
lynxkite-app/web/playwright.config.ts CHANGED
@@ -7,6 +7,7 @@ export default defineConfig({
7
  /* Fail the build on CI if you accidentally left test.only in the source code. */
8
  forbidOnly: !!process.env.CI,
9
  retries: process.env.CI ? 1 : 0,
 
10
  workers: 1,
11
  reporter: process.env.CI ? [["github"], ["html"]] : "html",
12
  use: {
 
7
  /* Fail the build on CI if you accidentally left test.only in the source code. */
8
  forbidOnly: !!process.env.CI,
9
  retries: process.env.CI ? 1 : 0,
10
+ maxFailures: 5,
11
  workers: 1,
12
  reporter: process.env.CI ? [["github"], ["html"]] : "html",
13
  use: {
lynxkite-app/web/src/Code.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Full-page editor for code files.
2
+
3
+ import Editor, { type Monaco } from "@monaco-editor/react";
4
+ import type { editor } from "monaco-editor";
5
+ import { useEffect, useRef } from "react";
6
+ import { useParams } from "react-router";
7
+ import { WebsocketProvider } from "y-websocket";
8
+ import * as Y from "yjs";
9
+ // @ts-ignore
10
+ import Atom from "~icons/tabler/atom.jsx";
11
+ // @ts-ignore
12
+ import Backspace from "~icons/tabler/backspace.jsx";
13
+ // @ts-ignore
14
+ import Close from "~icons/tabler/x.jsx";
15
+ import favicon from "./assets/favicon.ico";
16
+ import theme from "./code-theme.ts";
17
+
18
+ export default function Code() {
19
+ const { path } = useParams();
20
+ const parentDir = path!.split("/").slice(0, -1).join("/");
21
+ const yDocRef = useRef<any>();
22
+ const wsProviderRef = useRef<any>();
23
+ const monacoBindingRef = useRef<any>();
24
+ const yMonacoRef = useRef<any>();
25
+ const editorRef = useRef<any>();
26
+ useEffect(() => {
27
+ const loadMonaco = async () => {
28
+ // y-monaco is gigantic. The other Monaco packages are small.
29
+ yMonacoRef.current = await import("y-monaco");
30
+ initCRDT();
31
+ };
32
+ loadMonaco();
33
+ }, []);
34
+ function beforeMount(monaco: Monaco) {
35
+ monaco.editor.defineTheme("lynxkite", theme);
36
+ }
37
+ function onMount(_editor: editor.IStandaloneCodeEditor) {
38
+ editorRef.current = _editor;
39
+ initCRDT();
40
+ }
41
+ function initCRDT() {
42
+ if (!yMonacoRef.current || !editorRef.current) return;
43
+ if (yDocRef.current) return;
44
+ yDocRef.current = new Y.Doc();
45
+ const text = yDocRef.current.getText("text");
46
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
47
+ wsProviderRef.current = new WebsocketProvider(
48
+ `${proto}//${location.host}/ws/code/crdt`,
49
+ path!,
50
+ yDocRef.current,
51
+ );
52
+ monacoBindingRef.current = new yMonacoRef.current.MonacoBinding(
53
+ text,
54
+ editorRef.current.getModel()!,
55
+ new Set([editorRef.current]),
56
+ wsProviderRef.current.awareness,
57
+ );
58
+ }
59
+ useEffect(() => {
60
+ return () => {
61
+ yDocRef.current?.destroy();
62
+ wsProviderRef.current?.destroy();
63
+ monacoBindingRef.current?.destroy();
64
+ };
65
+ });
66
+ return (
67
+ <div className="workspace">
68
+ <div className="top-bar bg-neutral">
69
+ <a className="logo" href="">
70
+ <img alt="" src={favicon} />
71
+ </a>
72
+ <div className="ws-name">{path}</div>
73
+ <div className="tools text-secondary">
74
+ <a href="">
75
+ <Atom />
76
+ </a>
77
+ <a href="">
78
+ <Backspace />
79
+ </a>
80
+ <a href={`/dir/${parentDir}`}>
81
+ <Close />
82
+ </a>
83
+ </div>
84
+ </div>
85
+ <Editor
86
+ defaultLanguage="python"
87
+ theme="lynxkite"
88
+ path={path}
89
+ beforeMount={beforeMount}
90
+ onMount={onMount}
91
+ loading={null}
92
+ options={{
93
+ cursorStyle: "block",
94
+ cursorBlinking: "solid",
95
+ minimap: { enabled: false },
96
+ renderLineHighlight: "none",
97
+ }}
98
+ />
99
+ </div>
100
+ );
101
+ }
lynxkite-app/web/src/Directory.tsx CHANGED
@@ -15,9 +15,46 @@ import FolderPlus from "~icons/tabler/folder-plus";
15
  // @ts-ignore
16
  import Home from "~icons/tabler/home";
17
  // @ts-ignore
 
 
 
 
18
  import Trash from "~icons/tabler/trash";
19
  import logo from "./assets/logo.png";
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  const fetcher = (url: string) => fetch(url).then((res) => res.json());
22
 
23
  export default function () {
@@ -27,48 +64,41 @@ export default function () {
27
  dedupingInterval: 0,
28
  });
29
  const navigate = useNavigate();
30
- const [isCreatingDir, setIsCreatingDir] = useState(false);
31
- const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
32
 
33
  function link(item: DirectoryEntry) {
34
  if (item.type === "directory") {
35
  return `/dir/${item.name}`;
36
  }
37
- return `/edit/${item.name}`;
 
 
 
38
  }
39
 
40
  function shortName(item: DirectoryEntry) {
41
- return item.name.split("/").pop();
 
 
 
42
  }
43
 
44
- function newName(list: DirectoryEntry[], baseName = "Untitled") {
45
- let i = 0;
46
- while (true) {
47
- const name = `${baseName}${i ? ` ${i}` : ""}`;
48
- if (!list.find((item) => item.name === name)) {
49
- return name;
50
- }
51
- i++;
52
- }
53
  }
54
-
55
- function newWorkspaceIn(path: string, list: DirectoryEntry[], workspaceName?: string) {
56
  const pathSlash = path ? `${path}/` : "";
57
- const name = workspaceName || newName(list);
58
- navigate(`/edit/${pathSlash}${name}`, { replace: true });
59
  }
60
-
61
- async function newFolderIn(path: string, list: DirectoryEntry[], folderName?: string) {
62
- const name = folderName || newName(list, "New Folder");
63
  const pathSlash = path ? `${path}/` : "";
64
-
65
  const res = await fetch("/api/dir/mkdir", {
66
  method: "POST",
67
  headers: { "Content-Type": "application/json" },
68
- body: JSON.stringify({ path: pathSlash + name }),
69
  });
70
  if (res.ok) {
71
- navigate(`/dir/${pathSlash}${name}`);
72
  } else {
73
  alert("Failed to create folder.");
74
  }
@@ -105,84 +135,66 @@ export default function () {
105
  {list.data && (
106
  <>
107
  <div className="actions">
108
- <div className="new-workspace">
109
- {isCreatingWorkspace && (
110
- // @ts-ignore
111
- <form
112
- onSubmit={(e) => {
113
- e.preventDefault();
114
- newWorkspaceIn(
115
- path || "",
116
- list.data,
117
- (e.target as HTMLFormElement).workspaceName.value.trim(),
118
- );
119
- }}
120
- >
121
- <input
122
- type="text"
123
- name="workspaceName"
124
- defaultValue={newName(list.data)}
125
- placeholder={newName(list.data)}
126
- />
127
- </form>
128
- )}
129
- <button type="button" onClick={() => setIsCreatingWorkspace(true)}>
130
- <FolderPlus /> New workspace
131
- </button>
132
- </div>
133
-
134
- <div className="new-folder">
135
- {isCreatingDir && (
136
- // @ts-ignore
137
- <form
138
- onSubmit={(e) => {
139
- e.preventDefault();
140
- newFolderIn(
141
- path || "",
142
- list.data,
143
- (e.target as HTMLFormElement).folderName.value.trim(),
144
- );
145
- }}
146
- >
147
- <input
148
- type="text"
149
- name="folderName"
150
- defaultValue={newName(list.data)}
151
- placeholder={newName(list.data)}
152
- />
153
- </form>
154
- )}
155
- <button type="button" onClick={() => setIsCreatingDir(true)}>
156
- <FolderPlus /> New folder
157
- </button>
158
- </div>
159
  </div>
160
 
161
- {path && (
162
  <div className="breadcrumbs">
163
  <Link to="/dir/">
164
  <Home />
165
  </Link>{" "}
166
  <span className="current-folder">{path}</span>
 
167
  </div>
 
 
168
  )}
169
 
170
- {list.data.map((item: DirectoryEntry) => (
171
- <div key={item.name} className="entry">
172
- <Link key={link(item)} to={link(item)}>
173
- {item.type === "directory" ? <Folder /> : <File />}
174
- {shortName(item)}
175
- </Link>
176
- <button
177
- type="button"
178
- onClick={() => {
179
- deleteItem(item);
180
- }}
181
- >
182
- <Trash />
183
- </button>
184
- </div>
185
- ))}
 
 
 
 
 
 
 
 
 
186
  </>
187
  )}
188
  </div>{" "}
 
15
  // @ts-ignore
16
  import Home from "~icons/tabler/home";
17
  // @ts-ignore
18
+ import LayoutGrid from "~icons/tabler/layout-grid";
19
+ // @ts-ignore
20
+ import LayoutGridAdd from "~icons/tabler/layout-grid-add";
21
+ // @ts-ignore
22
  import Trash from "~icons/tabler/trash";
23
  import logo from "./assets/logo.png";
24
 
25
+ function EntryCreator(props: {
26
+ label: string;
27
+ icon: JSX.Element;
28
+ onCreate: (name: string) => void;
29
+ }) {
30
+ const [isCreating, setIsCreating] = useState(false);
31
+ return (
32
+ <>
33
+ {isCreating ? (
34
+ <form
35
+ onSubmit={(e) => {
36
+ e.preventDefault();
37
+ props.onCreate((e.target as HTMLFormElement).entryName.value.trim());
38
+ }}
39
+ >
40
+ <input
41
+ className="input input-ghost w-full"
42
+ autoFocus
43
+ type="text"
44
+ name="entryName"
45
+ onBlur={() => setIsCreating(false)}
46
+ placeholder={`${props.label} name`}
47
+ />
48
+ </form>
49
+ ) : (
50
+ <button type="button" onClick={() => setIsCreating(true)}>
51
+ {props.icon} {props.label}
52
+ </button>
53
+ )}
54
+ </>
55
+ );
56
+ }
57
+
58
  const fetcher = (url: string) => fetch(url).then((res) => res.json());
59
 
60
  export default function () {
 
64
  dedupingInterval: 0,
65
  });
66
  const navigate = useNavigate();
 
 
67
 
68
  function link(item: DirectoryEntry) {
69
  if (item.type === "directory") {
70
  return `/dir/${item.name}`;
71
  }
72
+ if (item.type === "workspace") {
73
+ return `/edit/${item.name}`;
74
+ }
75
+ return `/code/${item.name}`;
76
  }
77
 
78
  function shortName(item: DirectoryEntry) {
79
+ return item.name
80
+ .split("/")
81
+ .pop()
82
+ ?.replace(/[.]lynxkite[.]json$/, "");
83
  }
84
 
85
+ function newWorkspaceIn(path: string, workspaceName: string) {
86
+ const pathSlash = path ? `${path}/` : "";
87
+ navigate(`/edit/${pathSlash}${workspaceName}.lynxkite.json`, { replace: true });
 
 
 
 
 
 
88
  }
89
+ function newCodeFile(path: string, name: string) {
 
90
  const pathSlash = path ? `${path}/` : "";
91
+ navigate(`/code/${pathSlash}${name}`, { replace: true });
 
92
  }
93
+ async function newFolderIn(path: string, folderName: string) {
 
 
94
  const pathSlash = path ? `${path}/` : "";
 
95
  const res = await fetch("/api/dir/mkdir", {
96
  method: "POST",
97
  headers: { "Content-Type": "application/json" },
98
+ body: JSON.stringify({ path: pathSlash + folderName }),
99
  });
100
  if (res.ok) {
101
+ navigate(`/dir/${pathSlash}${folderName}`);
102
  } else {
103
  alert("Failed to create folder.");
104
  }
 
135
  {list.data && (
136
  <>
137
  <div className="actions">
138
+ <EntryCreator
139
+ onCreate={(name) => {
140
+ newWorkspaceIn(path || "", name);
141
+ }}
142
+ icon={<LayoutGridAdd />}
143
+ label="New workspace"
144
+ />
145
+ <EntryCreator
146
+ onCreate={(name) => {
147
+ newCodeFile(path || "", name);
148
+ }}
149
+ icon={<FilePlus />}
150
+ label="New code file"
151
+ />
152
+ <EntryCreator
153
+ onCreate={(name: string) => {
154
+ newFolderIn(path || "", name);
155
+ }}
156
+ icon={<FolderPlus />}
157
+ label="New folder"
158
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </div>
160
 
161
+ {path ? (
162
  <div className="breadcrumbs">
163
  <Link to="/dir/">
164
  <Home />
165
  </Link>{" "}
166
  <span className="current-folder">{path}</span>
167
+ <title>{path}</title>
168
  </div>
169
+ ) : (
170
+ <title>LynxKite 2000:MM</title>
171
  )}
172
 
173
+ {list.data.map(
174
+ (item: DirectoryEntry) =>
175
+ !shortName(item)?.startsWith("__") && (
176
+ <div key={item.name} className="entry">
177
+ <Link key={link(item)} to={link(item)}>
178
+ {item.type === "directory" ? (
179
+ <Folder />
180
+ ) : item.type === "workspace" ? (
181
+ <LayoutGrid />
182
+ ) : (
183
+ <File />
184
+ )}
185
+ {shortName(item)}
186
+ </Link>
187
+ <button
188
+ type="button"
189
+ onClick={() => {
190
+ deleteItem(item);
191
+ }}
192
+ >
193
+ <Trash />
194
+ </button>
195
+ </div>
196
+ ),
197
+ )}
198
  </>
199
  )}
200
  </div>{" "}
lynxkite-app/web/src/code-theme.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // A simple theme using the LynxKite colors.
2
+
3
+ import type { editor } from "monaco-editor/esm/vs/editor/editor.api";
4
+
5
+ const theme: editor.IStandaloneThemeData = {
6
+ base: "vs-dark",
7
+ inherit: true,
8
+ rules: [
9
+ {
10
+ foreground: "ff8800",
11
+ token: "keyword",
12
+ },
13
+ {
14
+ foreground: "0088ff",
15
+ fontStyle: "italic",
16
+ token: "comment",
17
+ },
18
+ {
19
+ foreground: "39bcf3",
20
+ token: "string",
21
+ },
22
+ {
23
+ foreground: "ffc600",
24
+ token: "",
25
+ },
26
+ ],
27
+ colors: {
28
+ "editor.foreground": "#FFFFFF",
29
+ "editor.background": "#002a4c",
30
+ "editor.selectionBackground": "#0050a4",
31
+ "editor.lineHighlightBackground": "#1f4662",
32
+ "editorCursor.foreground": "#ffc600",
33
+ "editorWhitespace.foreground": "#7f7f7fb2",
34
+ "editorIndentGuide.background": "#3b5364",
35
+ "editorIndentGuide.activeBackground": "#ffc600",
36
+ },
37
+ };
38
+ export default theme;
lynxkite-app/web/src/index.css CHANGED
@@ -326,7 +326,14 @@ body {
326
  .actions {
327
  display: flex;
328
  justify-content: space-evenly;
 
 
329
  padding: 5px;
 
 
 
 
 
330
  }
331
 
332
  .actions a {
@@ -534,3 +541,25 @@ body {
534
  .add-relationship-button:hover {
535
  background-color: #218838;
536
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  .actions {
327
  display: flex;
328
  justify-content: space-evenly;
329
+ align-items: center;
330
+ height: 50px;
331
  padding: 5px;
332
+
333
+ form,
334
+ button {
335
+ flex: 1;
336
+ }
337
  }
338
 
339
  .actions a {
 
541
  .add-relationship-button:hover {
542
  background-color: #218838;
543
  }
544
+
545
+ .yRemoteSelection {
546
+ background-color: rgb(250, 129, 0, 0.5);
547
+ }
548
+
549
+ .yRemoteSelectionHead {
550
+ position: absolute;
551
+ border-left: #ff8800 solid 2px;
552
+ border-top: #ff8800 solid 2px;
553
+ border-bottom: #ff8800 solid 2px;
554
+ height: 100%;
555
+ box-sizing: border-box;
556
+ }
557
+
558
+ .yRemoteSelectionHead::after {
559
+ position: absolute;
560
+ content: " ";
561
+ border: 3px solid #ff8800;
562
+ border-radius: 4px;
563
+ left: -4px;
564
+ top: -5px;
565
+ }
lynxkite-app/web/src/main.tsx CHANGED
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
3
  import "@xyflow/react/dist/style.css";
4
  import "./index.css";
5
  import { BrowserRouter, Route, Routes } from "react-router";
 
6
  import Directory from "./Directory.tsx";
7
  import Workspace from "./workspace/Workspace.tsx";
8
 
@@ -14,6 +15,7 @@ createRoot(document.getElementById("root")!).render(
14
  <Route path="/dir" element={<Directory />} />
15
  <Route path="/dir/:path" element={<Directory />} />
16
  <Route path="/edit/:path" element={<Workspace />} />
 
17
  </Routes>
18
  </BrowserRouter>
19
  </StrictMode>,
 
3
  import "@xyflow/react/dist/style.css";
4
  import "./index.css";
5
  import { BrowserRouter, Route, Routes } from "react-router";
6
+ import Code from "./Code.tsx";
7
  import Directory from "./Directory.tsx";
8
  import Workspace from "./workspace/Workspace.tsx";
9
 
 
15
  <Route path="/dir" element={<Directory />} />
16
  <Route path="/dir/:path" element={<Directory />} />
17
  <Route path="/edit/:path" element={<Workspace />} />
18
+ <Route path="/code/:path" element={<Code />} />
19
  </Routes>
20
  </BrowserRouter>
21
  </StrictMode>,
lynxkite-app/web/src/workspace/Workspace.tsx CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import { getYjsDoc, syncedStore } from "@syncedstore/core";
2
  import {
3
  type Connection,
@@ -16,7 +18,6 @@ import {
16
  } from "@xyflow/react";
17
  import axios from "axios";
18
  import { type MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
19
- // The LynxKite workspace editor.
20
  import { useParams } from "react-router";
21
  import useSWR, { type Fetcher } from "swr";
22
  import { WebsocketProvider } from "y-websocket";
@@ -52,6 +53,10 @@ function LynxKiteFlow() {
52
  const [nodes, setNodes] = useState([] as Node[]);
53
  const [edges, setEdges] = useState([] as Edge[]);
54
  const { path } = useParams();
 
 
 
 
55
  const [state, setState] = useState({ workspace: {} as Workspace });
56
  const [message, setMessage] = useState(null as string | null);
57
  useEffect(() => {
@@ -151,7 +156,7 @@ function LynxKiteFlow() {
151
 
152
  const fetcher: Fetcher<Catalogs> = (resource: string, init?: RequestInit) =>
153
  fetch(resource, init).then((res) => res.json());
154
- const catalog = useSWR("/api/catalog", fetcher);
155
  const [suppressSearchUntil, setSuppressSearchUntil] = useState(0);
156
  const [nodeSearchSettings, setNodeSearchSettings] = useState(
157
  undefined as
@@ -319,7 +324,8 @@ function LynxKiteFlow() {
319
  <a className="logo" href="">
320
  <img alt="" src={favicon} />
321
  </a>
322
- <div className="ws-name">{path}</div>
 
323
  <EnvironmentSelector
324
  options={Object.keys(catalog.data || {})}
325
  value={state.workspace.env!}
 
1
+ // The LynxKite workspace editor.
2
+
3
  import { getYjsDoc, syncedStore } from "@syncedstore/core";
4
  import {
5
  type Connection,
 
18
  } from "@xyflow/react";
19
  import axios from "axios";
20
  import { type MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
 
21
  import { useParams } from "react-router";
22
  import useSWR, { type Fetcher } from "swr";
23
  import { WebsocketProvider } from "y-websocket";
 
53
  const [nodes, setNodes] = useState([] as Node[]);
54
  const [edges, setEdges] = useState([] as Edge[]);
55
  const { path } = useParams();
56
+ const shortPath = path!
57
+ .split("/")
58
+ .pop()!
59
+ .replace(/[.]lynxkite[.]json$/, "");
60
  const [state, setState] = useState({ workspace: {} as Workspace });
61
  const [message, setMessage] = useState(null as string | null);
62
  useEffect(() => {
 
156
 
157
  const fetcher: Fetcher<Catalogs> = (resource: string, init?: RequestInit) =>
158
  fetch(resource, init).then((res) => res.json());
159
+ const catalog = useSWR(`/api/catalog?workspace=${path}`, fetcher);
160
  const [suppressSearchUntil, setSuppressSearchUntil] = useState(0);
161
  const [nodeSearchSettings, setNodeSearchSettings] = useState(
162
  undefined as
 
324
  <a className="logo" href="">
325
  <img alt="" src={favicon} />
326
  </a>
327
+ <div className="ws-name">{shortPath}</div>
328
+ <title>{shortPath}</title>
329
  <EnvironmentSelector
330
  options={Object.keys(catalog.data || {})}
331
  value={state.workspace.env!}
lynxkite-app/web/tests/directory.spec.ts CHANGED
@@ -14,13 +14,6 @@ test.describe("Directory operations", () => {
14
  splash = await Splash.open(page);
15
  });
16
 
17
- test("Create workspace with default name", async () => {
18
- const workspace = await Workspace.empty(splash.page);
19
- // Not checking for exact match, since there may be pre-existing "Untitled" workspaces
20
- expect(workspace.name).toContain("Untitled");
21
- await workspace.close();
22
- });
23
-
24
  test("Create & delete workspace", async () => {
25
  const workspaceName = `TestWorkspace-${Date.now()}`;
26
  const workspace = await Workspace.empty(splash.page, workspaceName);
@@ -40,11 +33,6 @@ test.describe("Directory operations", () => {
40
  await splash.deleteEntry(folderName);
41
  await expect(splash.getEntry(folderName)).not.toBeVisible();
42
  });
43
-
44
- test("Create folder with default name", async () => {
45
- await splash.createFolder();
46
- await expect(splash.currentFolder()).toContainText("Untitled");
47
- });
48
  });
49
 
50
  test.describe
 
14
  splash = await Splash.open(page);
15
  });
16
 
 
 
 
 
 
 
 
17
  test("Create & delete workspace", async () => {
18
  const workspaceName = `TestWorkspace-${Date.now()}`;
19
  const workspace = await Workspace.empty(splash.page, workspaceName);
 
33
  await splash.deleteEntry(folderName);
34
  await expect(splash.getEntry(folderName)).not.toBeVisible();
35
  });
 
 
 
 
 
36
  });
37
 
38
  test.describe
lynxkite-app/web/tests/lynxkite.ts CHANGED
@@ -18,7 +18,7 @@ export class Workspace {
18
  }
19
 
20
  // Starts with a brand new workspace.
21
- static async empty(page: Page, workspaceName?: string): Promise<Workspace> {
22
  const splash = await Splash.open(page);
23
  return await splash.createWorkspace(workspaceName);
24
  }
@@ -182,18 +182,12 @@ export class Splash {
182
  return this.page.locator(".entry").filter({ hasText: name }).first();
183
  }
184
 
185
- async createWorkspace(name?: string) {
186
  await this.page.getByRole("button", { name: "New workspace" }).click();
187
- await this.page.locator('input[name="workspaceName"]').click();
188
- let workspaceName: string;
189
- if (name) {
190
- workspaceName = name;
191
- await this.page.locator('input[name="workspaceName"]').fill(name);
192
- } else {
193
- workspaceName = await this.page.locator('input[name="workspaceName"]').inputValue();
194
- }
195
- await this.page.locator('input[name="workspaceName"]').press("Enter");
196
- const ws = new Workspace(this.page, workspaceName);
197
  await ws.setEnv("LynxKite Graph Analytics");
198
  return ws;
199
  }
@@ -203,13 +197,11 @@ export class Splash {
203
  return new Workspace(this.page, name);
204
  }
205
 
206
- async createFolder(folderName?: string) {
207
  await this.page.getByRole("button", { name: "New folder" }).click();
208
- await this.page.locator('input[name="folderName"]').click();
209
- if (folderName) {
210
- await this.page.locator('input[name="folderName"]').fill(folderName);
211
- }
212
- await this.page.locator('input[name="folderName"]').press("Enter");
213
  }
214
 
215
  async deleteEntry(entryName: string) {
 
18
  }
19
 
20
  // Starts with a brand new workspace.
21
+ static async empty(page: Page, workspaceName: string): Promise<Workspace> {
22
  const splash = await Splash.open(page);
23
  return await splash.createWorkspace(workspaceName);
24
  }
 
182
  return this.page.locator(".entry").filter({ hasText: name }).first();
183
  }
184
 
185
+ async createWorkspace(name: string) {
186
  await this.page.getByRole("button", { name: "New workspace" }).click();
187
+ const nameBox = this.page.locator('input[name="entryName"]');
188
+ await nameBox.fill(name);
189
+ await nameBox.press("Enter");
190
+ const ws = new Workspace(this.page, name);
 
 
 
 
 
 
191
  await ws.setEnv("LynxKite Graph Analytics");
192
  return ws;
193
  }
 
197
  return new Workspace(this.page, name);
198
  }
199
 
200
+ async createFolder(folderName: string) {
201
  await this.page.getByRole("button", { name: "New folder" }).click();
202
+ const nameBox = this.page.locator('input[name="entryName"]');
203
+ await nameBox.fill(folderName);
204
+ await nameBox.press("Enter");
 
 
205
  }
206
 
207
  async deleteEntry(entryName: string) {
lynxkite-app/web/vite.config.ts CHANGED
@@ -5,7 +5,7 @@ import { defineConfig } from "vite";
5
  // https://vite.dev/config/
6
  export default defineConfig({
7
  build: {
8
- chunkSizeWarningLimit: 2048,
9
  },
10
  esbuild: {
11
  supported: {
 
5
  // https://vite.dev/config/
6
  export default defineConfig({
7
  build: {
8
+ chunkSizeWarningLimit: 3000,
9
  },
10
  esbuild: {
11
  supported: {
lynxkite-core/src/lynxkite/core/ops.py CHANGED
@@ -4,7 +4,12 @@ from __future__ import annotations
4
  import asyncio
5
  import enum
6
  import functools
 
7
  import inspect
 
 
 
 
8
  import types
9
  import pydantic
10
  import typing
@@ -14,8 +19,11 @@ from typing_extensions import Annotated
14
  if typing.TYPE_CHECKING:
15
  from . import workspace
16
 
17
- CATALOGS: dict[str, dict[str, "Op"]] = {}
 
 
18
  EXECUTORS = {}
 
19
 
20
  typeof = type # We have some arguments called "type".
21
 
@@ -189,11 +197,14 @@ class Op(BaseConfig):
189
  return res
190
 
191
 
192
- def op(env: str, name: str, *, view="basic", outputs=None, params=None):
193
  """Decorator for defining an operation."""
194
 
195
  def decorator(func):
196
  sig = inspect.signature(func)
 
 
 
197
  # Positional arguments are inputs.
198
  inputs = {
199
  name: Input(name=name, type=param.annotation)
@@ -308,3 +319,54 @@ def slow(func):
308
  return await asyncio.to_thread(func, *args, **kwargs)
309
 
310
  return wrapper
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import asyncio
5
  import enum
6
  import functools
7
+ import importlib
8
  import inspect
9
+ import pathlib
10
+ import subprocess
11
+ import traceback
12
+ import joblib
13
  import types
14
  import pydantic
15
  import typing
 
19
  if typing.TYPE_CHECKING:
20
  from . import workspace
21
 
22
+ Catalog = dict[str, "Op"]
23
+ Catalogs = dict[str, Catalog]
24
+ CATALOGS: Catalogs = {}
25
  EXECUTORS = {}
26
+ mem = joblib.Memory(".joblib-cache")
27
 
28
  typeof = type # We have some arguments called "type".
29
 
 
197
  return res
198
 
199
 
200
+ def op(env: str, name: str, *, view="basic", outputs=None, params=None, slow=False):
201
  """Decorator for defining an operation."""
202
 
203
  def decorator(func):
204
  sig = inspect.signature(func)
205
+ if slow:
206
+ func = mem.cache(func)
207
+ func = _global_slow(func)
208
  # Positional arguments are inputs.
209
  inputs = {
210
  name: Input(name=name, type=param.annotation)
 
319
  return await asyncio.to_thread(func, *args, **kwargs)
320
 
321
  return wrapper
322
+
323
+
324
+ _global_slow = slow # For access inside op().
325
+ CATALOGS_SNAPSHOTS: dict[str, Catalogs] = {}
326
+
327
+
328
+ def save_catalogs(snapshot_name: str):
329
+ CATALOGS_SNAPSHOTS[snapshot_name] = {k: dict(v) for k, v in CATALOGS.items()}
330
+
331
+
332
+ def load_catalogs(snapshot_name: str):
333
+ global CATALOGS
334
+ snap = CATALOGS_SNAPSHOTS[snapshot_name]
335
+ CATALOGS = {k: dict(v) for k, v in snap.items()}
336
+
337
+
338
+ def load_user_scripts(workspace: str):
339
+ """Reloads the *.py in the workspace's directory and higher-level directories."""
340
+ if "plugins loaded" in CATALOGS_SNAPSHOTS:
341
+ load_catalogs("plugins loaded")
342
+ cwd = pathlib.Path()
343
+ path = cwd / workspace
344
+ assert path.is_relative_to(cwd), "Provided workspace path is invalid"
345
+ for p in path.parents:
346
+ print("checking user scripts in", p)
347
+ for f in p.glob("*.py"):
348
+ try:
349
+ run_user_script(f)
350
+ except Exception:
351
+ traceback.print_exc()
352
+ req = p / "requirements.txt"
353
+ if req.exists():
354
+ try:
355
+ install_requirements(req)
356
+ except Exception:
357
+ traceback.print_exc()
358
+ if p == cwd:
359
+ break
360
+
361
+
362
+ def install_requirements(req: pathlib.Path):
363
+ cmd = ["uv", "pip", "install", "-r", str(req)]
364
+ print(f"Running {' '.join(cmd)}")
365
+ subprocess.check_call(cmd)
366
+
367
+
368
+ def run_user_script(script_path: pathlib.Path):
369
+ print(f"Running {script_path}...")
370
+ spec = importlib.util.spec_from_file_location(script_path.stem, str(script_path))
371
+ module = importlib.util.module_from_spec(spec)
372
+ spec.loader.exec_module(module)