backpack-viewer 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/bin/serve.js +159 -396
  2. package/dist/app/assets/index-D-H7agBH.js +12 -0
  3. package/dist/app/assets/index-DE73ngo-.css +1 -0
  4. package/dist/app/assets/index-DFW3OKgJ.js +6 -0
  5. package/dist/app/assets/layout-worker-4xak23M6.js +1 -0
  6. package/dist/app/index.html +2 -2
  7. package/dist/bridge.d.ts +22 -0
  8. package/dist/bridge.js +41 -0
  9. package/dist/canvas.d.ts +15 -0
  10. package/dist/canvas.js +352 -12
  11. package/dist/config.js +10 -0
  12. package/dist/copy-prompt.d.ts +17 -0
  13. package/dist/copy-prompt.js +81 -0
  14. package/dist/default-config.json +6 -1
  15. package/dist/dom-utils.d.ts +46 -0
  16. package/dist/dom-utils.js +57 -0
  17. package/dist/empty-state.js +63 -31
  18. package/dist/extensions/api.d.ts +15 -0
  19. package/dist/extensions/api.js +185 -0
  20. package/dist/extensions/chat/backpack-extension.json +23 -0
  21. package/dist/extensions/chat/src/index.js +32 -0
  22. package/dist/extensions/chat/src/panel.js +306 -0
  23. package/dist/extensions/chat/src/providers/anthropic.js +158 -0
  24. package/dist/extensions/chat/src/providers/types.js +15 -0
  25. package/dist/extensions/chat/src/tools.js +281 -0
  26. package/dist/extensions/chat/style.css +147 -0
  27. package/dist/extensions/event-bus.d.ts +12 -0
  28. package/dist/extensions/event-bus.js +30 -0
  29. package/dist/extensions/loader.d.ts +32 -0
  30. package/dist/extensions/loader.js +71 -0
  31. package/dist/extensions/manifest.d.ts +54 -0
  32. package/dist/extensions/manifest.js +116 -0
  33. package/dist/extensions/panel-mount.d.ts +26 -0
  34. package/dist/extensions/panel-mount.js +377 -0
  35. package/dist/extensions/taskbar.d.ts +29 -0
  36. package/dist/extensions/taskbar.js +64 -0
  37. package/dist/extensions/types.d.ts +182 -0
  38. package/dist/extensions/types.js +8 -0
  39. package/dist/info-panel.d.ts +2 -1
  40. package/dist/info-panel.js +78 -87
  41. package/dist/keybindings.d.ts +1 -1
  42. package/dist/keybindings.js +1 -0
  43. package/dist/layout-worker.d.ts +4 -1
  44. package/dist/layout-worker.js +51 -1
  45. package/dist/layout.d.ts +8 -0
  46. package/dist/layout.js +8 -1
  47. package/dist/main.js +216 -35
  48. package/dist/search.js +1 -1
  49. package/dist/server-api-routes.d.ts +56 -0
  50. package/dist/server-api-routes.js +442 -0
  51. package/dist/server-extensions.d.ts +126 -0
  52. package/dist/server-extensions.js +272 -0
  53. package/dist/server-viewer-state.d.ts +18 -0
  54. package/dist/server-viewer-state.js +33 -0
  55. package/dist/shortcuts.js +6 -2
  56. package/dist/sidebar.js +19 -7
  57. package/dist/style.css +356 -74
  58. package/dist/tools-pane.js +31 -14
  59. package/package.json +4 -3
  60. package/dist/app/assets/index-B3z5bBGl.css +0 -1
  61. package/dist/app/assets/index-CKYlU1zT.js +0 -35
  62. package/dist/app/assets/layout-worker-BZXiBoiC.js +0 -1
package/dist/main.js CHANGED
@@ -2,14 +2,20 @@ import { listOntologies, loadOntology, saveOntology, renameOntology, listBranche
2
2
  import { initSidebar } from "./sidebar";
3
3
  import { initCanvas } from "./canvas";
4
4
  import { initInfoPanel } from "./info-panel";
5
+ import { createPanelMount } from "./extensions/panel-mount";
5
6
  import { initSearch } from "./search";
6
7
  import { initToolsPane } from "./tools-pane";
7
8
  import { setLayoutParams, getLayoutParams, autoLayoutParams } from "./layout";
8
9
  import { initShortcuts } from "./shortcuts";
9
10
  import { initEmptyState } from "./empty-state";
11
+ import { showToast } from "./dialog";
10
12
  import { createHistory } from "./history";
11
13
  import { matchKey } from "./keybindings";
12
14
  import { initContextMenu } from "./context-menu";
15
+ import { initCopyPromptButton } from "./copy-prompt";
16
+ import { publishViewerState } from "./bridge";
17
+ import { createEventBus } from "./extensions/event-bus";
18
+ import { loadExtensions } from "./extensions/loader";
13
19
  import defaultConfig from "./default-config.json";
14
20
  import "./style.css";
15
21
  let activeOntology = "";
@@ -68,6 +74,7 @@ async function main() {
68
74
  // Refresh sidebar counts
69
75
  const updated = await listOntologies();
70
76
  sidebar.setSummaries(updated);
77
+ eventBus.emit("graph-changed");
71
78
  }
72
79
  /** Snapshot current state, then save. Call this instead of save() for undoable actions. */
73
80
  async function undoableSave() {
@@ -88,7 +95,11 @@ async function main() {
88
95
  // canvas is used inside the navigate callback but declared below —
89
96
  // that's fine because the callback is only invoked after setup completes.
90
97
  let canvas;
91
- const infoPanel = initInfoPanel(canvasContainer, {
98
+ // Create the shared panel-mount up front. Both info-panel and the
99
+ // extension loader use this single instance so all panels share the
100
+ // same click-to-front z-stack and the persistent layer DOM element.
101
+ const panelMount = createPanelMount(canvasContainer);
102
+ const infoPanel = initInfoPanel(canvasContainer, panelMount, {
92
103
  onUpdateNode(nodeId, properties) {
93
104
  if (!currentData)
94
105
  return;
@@ -215,7 +226,7 @@ async function main() {
215
226
  pathBar.className = "path-bar hidden";
216
227
  canvasContainer.appendChild(pathBar);
217
228
  function showPathBar(path) {
218
- pathBar.innerHTML = "";
229
+ pathBar.replaceChildren();
219
230
  if (!currentData)
220
231
  return;
221
232
  for (let i = 0; i < path.nodeIds.length; i++) {
@@ -248,11 +259,27 @@ async function main() {
248
259
  }
249
260
  function hidePathBar() {
250
261
  pathBar.classList.add("hidden");
251
- pathBar.innerHTML = "";
262
+ pathBar.replaceChildren();
252
263
  canvas.clearHighlightedPath();
253
264
  }
265
+ // Event bus for extensions. Emitted from the same hooks that drive
266
+ // bridge publishing — selection changes, focus enter/exit, graph
267
+ // load, save. Subscribers run synchronously; errors in one don't
268
+ // affect others.
269
+ const eventBus = createEventBus();
270
+ function publishBridgeState() {
271
+ if (!activeOntology)
272
+ return;
273
+ publishViewerState({
274
+ graph: activeOntology,
275
+ selection: currentSelection,
276
+ focus: canvas?.getFocusInfo() ?? null,
277
+ });
278
+ }
254
279
  canvas = initCanvas(canvasContainer, (nodeIds) => {
255
280
  currentSelection = nodeIds ?? [];
281
+ publishBridgeState();
282
+ eventBus.emit("selection-changed");
256
283
  // Don't touch the path bar when walk mode is active — syncWalkTrail manages it
257
284
  if (!canvas.getWalkMode()) {
258
285
  if (nodeIds && nodeIds.length === 2) {
@@ -297,6 +324,8 @@ async function main() {
297
324
  updateUrl(activeOntology);
298
325
  syncWalkTrail();
299
326
  }
327
+ publishBridgeState();
328
+ eventBus.emit("focus-changed");
300
329
  }, { lod: cfg.lod, navigation: cfg.navigation, walk: cfg.walk });
301
330
  const search = initSearch(canvasContainer, {
302
331
  maxResults: cfg.limits.maxSearchResults,
@@ -447,15 +476,46 @@ async function main() {
447
476
  const toolsToggle = canvasContainer.querySelector(".tools-pane-toggle");
448
477
  if (toolsToggle)
449
478
  topLeft.appendChild(toolsToggle);
450
- // Move search overlay into center slot
451
- const searchOverlay = canvasContainer.querySelector(".search-overlay");
452
- if (searchOverlay)
453
- topCenter.appendChild(searchOverlay);
454
479
  // Move zoom controls and theme toggle into right slot
455
480
  const zoomControls = canvasContainer.querySelector(".zoom-controls");
456
481
  if (zoomControls)
457
482
  topRight.appendChild(zoomControls);
458
483
  topRight.appendChild(themeBtn);
484
+ // Extension taskbar slots — four hosted containers, one per
485
+ // supported icon position. The top slots flank the search overlay
486
+ // INSIDE top-center, alongside the copy-prompt button. The viewer's
487
+ // own controls in top-left/top-right (zoom, theme, tools toggle)
488
+ // stay visually separated. Bottom slots float in the canvas
489
+ // corners. All four start hidden — they're invisible until at
490
+ // least one extension registers into them, so empty slots take no
491
+ // space.
492
+ const extSlotTopLeft = document.createElement("div");
493
+ extSlotTopLeft.className = "ext-slot ext-slot-top-left";
494
+ topCenter.appendChild(extSlotTopLeft);
495
+ // Move search overlay into center slot, after the left ext slot so
496
+ // top-center reads: [ext-left, search, ext-right, copy-prompt].
497
+ const searchOverlay = canvasContainer.querySelector(".search-overlay");
498
+ if (searchOverlay)
499
+ topCenter.appendChild(searchOverlay);
500
+ const extSlotTopRight = document.createElement("div");
501
+ extSlotTopRight.className = "ext-slot ext-slot-top-right";
502
+ topCenter.appendChild(extSlotTopRight);
503
+ // Copy-prompt button — viewer-owned, sits at the rightmost end of
504
+ // the top-center group so any registered ext-right icons (like
505
+ // chat) appear immediately to its left.
506
+ const copyPromptBtn = initCopyPromptButton(() => ({
507
+ graphName: activeOntology,
508
+ data: currentData,
509
+ selection: currentSelection,
510
+ focus: canvas.getFocusInfo(),
511
+ }));
512
+ topCenter.appendChild(copyPromptBtn);
513
+ const extSlotBottomLeft = document.createElement("div");
514
+ extSlotBottomLeft.className = "ext-slot ext-slot-bottom-left";
515
+ canvasContainer.appendChild(extSlotBottomLeft);
516
+ const extSlotBottomRight = document.createElement("div");
517
+ extSlotBottomRight.className = "ext-slot ext-slot-bottom-right";
518
+ canvasContainer.appendChild(extSlotBottomRight);
459
519
  topBar.appendChild(topLeft);
460
520
  topBar.appendChild(topCenter);
461
521
  topBar.appendChild(topRight);
@@ -732,6 +792,9 @@ async function main() {
732
792
  toolsPane.setData(currentData);
733
793
  emptyState.hide();
734
794
  updateUrl(name);
795
+ publishBridgeState();
796
+ eventBus.emit("graph-switched");
797
+ eventBus.emit("graph-changed");
735
798
  // Load branches and snapshots — skipped for remote graphs (read-only,
736
799
  // no branch/snapshot/snippet APIs on the remote endpoint)
737
800
  if (!activeIsRemote) {
@@ -782,31 +845,129 @@ async function main() {
782
845
  }
783
846
  })
784
847
  .catch(() => { });
785
- // Load ontology list (local + remote in parallel)
786
- const [summaries, remotes] = await Promise.all([
787
- listOntologies(),
788
- listRemotes().catch(() => []),
789
- ]);
790
- sidebar.setSummaries(summaries);
791
- sidebar.setRemotes(remotes);
792
- remoteNames = new Set(remotes.map((r) => r.name));
793
- // Auto-load from URL hash, or first graph
794
- const initialUrl = parseUrl();
795
- const initialName = initialUrl.graph && summaries.some((s) => s.name === initialUrl.graph)
796
- ? initialUrl.graph
797
- : initialUrl.graph && remoteNames.has(initialUrl.graph)
798
- ? initialUrl.graph
799
- : summaries.length > 0
800
- ? summaries[0].name
801
- : remotes.length > 0
802
- ? remotes[0].name
803
- : null;
804
- if (initialName) {
805
- await selectGraph(initialName, initialUrl.nodes.length ? initialUrl.nodes : undefined, initialUrl.focus.length ? initialUrl.focus : undefined, initialUrl.hops);
848
+ // --- Share link detection ---
849
+ // If URL has ?share=TOKEN, load from the relay instead of local API.
850
+ // The #k=KEY fragment (if present) is used for client-side decryption.
851
+ const shareParams = new URLSearchParams(window.location.search);
852
+ const shareToken = shareParams.get("share");
853
+ if (shareToken) {
854
+ try {
855
+ const metaRes = await fetch(`/v1/share/${shareToken}/meta`);
856
+ if (!metaRes.ok)
857
+ throw new Error("Share link not found or expired");
858
+ const meta = await metaRes.json();
859
+ const dataRes = await fetch(`/v1/share/${shareToken}`);
860
+ if (!dataRes.ok)
861
+ throw new Error("Failed to download shared backpack");
862
+ // Parse the BPAK envelope (inline — avoids pulling Node.js deps from backpack-ontology)
863
+ const envelopeBytes = new Uint8Array(await dataRes.arrayBuffer());
864
+ if (envelopeBytes.length < 9 || envelopeBytes[0] !== 0x42 || envelopeBytes[1] !== 0x50 || envelopeBytes[2] !== 0x41 || envelopeBytes[3] !== 0x4B) {
865
+ throw new Error("Invalid share data: not a BPAK envelope");
866
+ }
867
+ const headerLen = new DataView(envelopeBytes.buffer, envelopeBytes.byteOffset, envelopeBytes.byteLength).getUint32(5, false);
868
+ if (9 + headerLen > envelopeBytes.length)
869
+ throw new Error("Invalid envelope: header length exceeds data");
870
+ const envelopeHeader = JSON.parse(new TextDecoder().decode(envelopeBytes.slice(9, 9 + headerLen)));
871
+ const envelopePayload = envelopeBytes.slice(9 + headerLen);
872
+ let graphData;
873
+ if (envelopeHeader.format !== "plaintext") {
874
+ // Encrypted — decrypt client-side using fragment key
875
+ const fragment = window.location.hash.slice(1);
876
+ const keyParam = new URLSearchParams(fragment).get("k") ?? fragment.split("k=")[1];
877
+ if (!keyParam)
878
+ throw new Error("Missing decryption key in URL fragment");
879
+ const { Decrypter } = await import("age-encryption");
880
+ const secretKey = atob(keyParam.replace(/-/g, "+").replace(/_/g, "/"));
881
+ const d = new Decrypter();
882
+ d.addIdentity(secretKey);
883
+ const plaintext = await d.decrypt(envelopePayload);
884
+ graphData = JSON.parse(new TextDecoder().decode(plaintext));
885
+ }
886
+ else {
887
+ graphData = JSON.parse(new TextDecoder().decode(envelopePayload));
888
+ }
889
+ // Render in read-only mode
890
+ activeOntology = meta.backpack_name || "Shared Backpack";
891
+ currentData = graphData;
892
+ canvas.loadGraph(graphData);
893
+ search.setLearningGraphData(graphData);
894
+ sidebar.setSummaries([{
895
+ name: activeOntology,
896
+ description: "",
897
+ nodeCount: graphData.nodes?.length ?? 0,
898
+ edgeCount: graphData.edges?.length ?? 0,
899
+ nodeTypes: [],
900
+ }]);
901
+ sidebar.setActive(activeOntology);
902
+ document.title = `${activeOntology} — Backpack`;
903
+ }
904
+ catch (err) {
905
+ const msg = err instanceof Error ? err.message : "Failed to load shared backpack";
906
+ showToast(msg, 5000);
907
+ emptyState.show();
908
+ }
806
909
  }
807
910
  else {
808
- emptyState.show();
911
+ // Normal mode — load from local/cloud API
912
+ const [summaries, remotes] = await Promise.all([
913
+ listOntologies(),
914
+ listRemotes().catch(() => []),
915
+ ]);
916
+ sidebar.setSummaries(summaries);
917
+ sidebar.setRemotes(remotes);
918
+ remoteNames = new Set(remotes.map((r) => r.name));
919
+ // Auto-load from URL hash, or first graph
920
+ const initialUrl = parseUrl();
921
+ const initialName = initialUrl.graph && summaries.some((s) => s.name === initialUrl.graph)
922
+ ? initialUrl.graph
923
+ : initialUrl.graph && remoteNames.has(initialUrl.graph)
924
+ ? initialUrl.graph
925
+ : summaries.length > 0
926
+ ? summaries[0].name
927
+ : remotes.length > 0
928
+ ? remotes[0].name
929
+ : null;
930
+ if (initialName) {
931
+ await selectGraph(initialName, initialUrl.nodes.length ? initialUrl.nodes : undefined, initialUrl.focus.length ? initialUrl.focus : undefined, initialUrl.hops);
932
+ }
933
+ else {
934
+ emptyState.show();
935
+ }
809
936
  }
937
+ // --- Extension system ---
938
+ // Load extensions after the viewer is fully initialized so any
939
+ // extension that immediately reads the current graph state gets a
940
+ // populated graph (not a startup race).
941
+ const host = {
942
+ getGraph: () => currentData,
943
+ getGraphName: () => activeOntology,
944
+ getSelection: () => [...currentSelection],
945
+ getFocus: () => canvas.getFocusInfo(),
946
+ saveCurrentGraph: async () => {
947
+ await save();
948
+ },
949
+ snapshotForUndo: () => {
950
+ if (currentData)
951
+ undoHistory.push(currentData);
952
+ },
953
+ panToNode: (id) => canvas.panToNode(id),
954
+ focusNodes: (ids, hops) => canvas.enterFocus(ids, hops),
955
+ exitFocus: () => {
956
+ if (canvas.isFocused())
957
+ canvas.exitFocus();
958
+ },
959
+ taskbarSlots: {
960
+ topLeft: extSlotTopLeft,
961
+ topRight: extSlotTopRight,
962
+ bottomLeft: extSlotBottomLeft,
963
+ bottomRight: extSlotBottomRight,
964
+ },
965
+ subscribe: (event, cb) => eventBus.subscribe(event, cb),
966
+ };
967
+ // Fire-and-forget — extension loading errors don't block startup
968
+ loadExtensions(host, panelMount).catch((err) => {
969
+ console.error("[backpack-viewer] extension loader failed:", err);
970
+ });
810
971
  // Keyboard shortcuts — dispatched via configurable bindings
811
972
  const actions = {
812
973
  search() { search.focus(); },
@@ -873,6 +1034,11 @@ async function main() {
873
1034
  clusteringIncrease() { const p = getLayoutParams(); setLayoutParams({ clusterStrength: Math.min(1, p.clusterStrength + 0.03) }); canvas.reheat(); },
874
1035
  help() { shortcuts.toggle(); },
875
1036
  toggleSidebar() { sidebar.toggle(); },
1037
+ resetPins() {
1038
+ const released = canvas.releaseAllPins();
1039
+ if (released)
1040
+ showToast("Manual layout reset — pins released");
1041
+ },
876
1042
  walkIsolate() {
877
1043
  if (!currentData)
878
1044
  return;
@@ -893,12 +1059,18 @@ async function main() {
893
1059
  walkBtn.classList.toggle("active", canvas.getWalkMode());
894
1060
  syncWalkTrail();
895
1061
  },
896
- escape() { if (canvas.isFocused()) {
897
- toolsPane.clearFocusSet();
898
- }
899
- else {
900
- shortcuts.hide();
901
- } },
1062
+ escape() {
1063
+ // Priority: 1. clear selection, 2. exit focus, 3. hide help modal
1064
+ if (canvas.getSelectedNodeIds().length > 0) {
1065
+ canvas.clearSelection();
1066
+ }
1067
+ else if (canvas.isFocused()) {
1068
+ toolsPane.clearFocusSet();
1069
+ }
1070
+ else {
1071
+ shortcuts.hide();
1072
+ }
1073
+ },
902
1074
  };
903
1075
  document.addEventListener("keydown", (e) => {
904
1076
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
@@ -940,6 +1112,12 @@ async function main() {
940
1112
  await refreshBackpacksAndGraphs();
941
1113
  });
942
1114
  import.meta.hot.on("ontology-change", async () => {
1115
+ // If the user had manually pinned nodes, the incoming data change
1116
+ // is going to reset their layout tweaks. Warn them so they know
1117
+ // the change wasn't their fault — this is the one involuntary
1118
+ // pin-release case (graph switches and focus toggles are all
1119
+ // user-initiated and don't need a toast).
1120
+ const hadPins = canvas.hasPinnedNodes();
943
1121
  const [updated, updatedRemotes] = await Promise.all([
944
1122
  listOntologies(),
945
1123
  listRemotes().catch(() => []),
@@ -971,6 +1149,9 @@ async function main() {
971
1149
  search.setLearningGraphData(currentData);
972
1150
  toolsPane.setData(currentData);
973
1151
  }
1152
+ if (hadPins) {
1153
+ showToast("Manual layout reset — new data arrived");
1154
+ }
974
1155
  });
975
1156
  }
976
1157
  }
package/dist/search.js CHANGED
@@ -68,7 +68,7 @@ export function initSearch(container, config) {
68
68
  updateResults();
69
69
  }
70
70
  function updateResults() {
71
- results.innerHTML = "";
71
+ results.replaceChildren();
72
72
  activeIndex = -1;
73
73
  const query = input.value.trim();
74
74
  if (!data || query.length === 0) {
@@ -0,0 +1,56 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type JsonFileBackend, type RemoteRegistry } from "backpack-ontology";
3
+ import type { ViewerConfig } from "./config.js";
4
+ /**
5
+ * Shared API route handler. Both `bin/serve.js` (production raw http)
6
+ * and `vite.config.ts`'s middleware plugin (dev) call `handleApiRequest`
7
+ * with the raw Node IncomingMessage/ServerResponse — they share the
8
+ * exact same shape. Each entry only owns the static-file serving and
9
+ * its own startup wiring.
10
+ *
11
+ * Before this module existed, every route below had two near-identical
12
+ * copies (one per entry file) and adding/changing an endpoint required
13
+ * editing both, with predictable drift bugs. Consolidated here into a
14
+ * single source of truth.
15
+ *
16
+ * Storage handling: the active backpack can be swapped at runtime via
17
+ * `/api/backpacks/switch`, which atomically replaces the storage
18
+ * backend. The context holds a mutable wrapper so the swap is visible
19
+ * to subsequent requests. Vite needs to broadcast a WebSocket event on
20
+ * the swap; production has no WS channel, so the hook is optional.
21
+ */
22
+ export interface BackpackEntry {
23
+ name: string;
24
+ path: string;
25
+ color: string;
26
+ }
27
+ export interface ApiContext {
28
+ /** Mutable wrapper around the storage backend so backpack-switch can swap it. */
29
+ storage: {
30
+ current: JsonFileBackend;
31
+ activeEntry: BackpackEntry | null;
32
+ };
33
+ remoteRegistry: RemoteRegistry;
34
+ viewerConfig: ViewerConfig;
35
+ /** Recreate the backend pointing at the active backpack. */
36
+ makeBackend: () => Promise<{
37
+ backend: JsonFileBackend;
38
+ entry: BackpackEntry;
39
+ }>;
40
+ /** Optional hook called after a successful backpack switch (vite uses this for WS broadcast). */
41
+ onActiveBackpackChange?: () => void;
42
+ /** How to answer GET /api/version-check. Differs between dev (always not-stale) and prod (cached npm lookup). */
43
+ versionCheck: () => Promise<{
44
+ current: string;
45
+ latest: string | null;
46
+ stale: boolean;
47
+ }>;
48
+ }
49
+ /**
50
+ * Try to match and handle an API request. Returns true if the request
51
+ * was handled (response written), false if no route matched and the
52
+ * caller should fall through to its own handlers (e.g., static files).
53
+ *
54
+ * Errors that escape route handlers result in a 500 with a JSON body.
55
+ */
56
+ export declare function handleApiRequest(req: IncomingMessage, res: ServerResponse, ctx: ApiContext): Promise<boolean>;