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.
- package/bin/serve.js +159 -396
- package/dist/app/assets/index-D-H7agBH.js +12 -0
- package/dist/app/assets/index-DE73ngo-.css +1 -0
- package/dist/app/assets/index-DFW3OKgJ.js +6 -0
- package/dist/app/assets/layout-worker-4xak23M6.js +1 -0
- package/dist/app/index.html +2 -2
- package/dist/bridge.d.ts +22 -0
- package/dist/bridge.js +41 -0
- package/dist/canvas.d.ts +15 -0
- package/dist/canvas.js +352 -12
- package/dist/config.js +10 -0
- package/dist/copy-prompt.d.ts +17 -0
- package/dist/copy-prompt.js +81 -0
- package/dist/default-config.json +6 -1
- package/dist/dom-utils.d.ts +46 -0
- package/dist/dom-utils.js +57 -0
- package/dist/empty-state.js +63 -31
- package/dist/extensions/api.d.ts +15 -0
- package/dist/extensions/api.js +185 -0
- package/dist/extensions/chat/backpack-extension.json +23 -0
- package/dist/extensions/chat/src/index.js +32 -0
- package/dist/extensions/chat/src/panel.js +306 -0
- package/dist/extensions/chat/src/providers/anthropic.js +158 -0
- package/dist/extensions/chat/src/providers/types.js +15 -0
- package/dist/extensions/chat/src/tools.js +281 -0
- package/dist/extensions/chat/style.css +147 -0
- package/dist/extensions/event-bus.d.ts +12 -0
- package/dist/extensions/event-bus.js +30 -0
- package/dist/extensions/loader.d.ts +32 -0
- package/dist/extensions/loader.js +71 -0
- package/dist/extensions/manifest.d.ts +54 -0
- package/dist/extensions/manifest.js +116 -0
- package/dist/extensions/panel-mount.d.ts +26 -0
- package/dist/extensions/panel-mount.js +377 -0
- package/dist/extensions/taskbar.d.ts +29 -0
- package/dist/extensions/taskbar.js +64 -0
- package/dist/extensions/types.d.ts +182 -0
- package/dist/extensions/types.js +8 -0
- package/dist/info-panel.d.ts +2 -1
- package/dist/info-panel.js +78 -87
- package/dist/keybindings.d.ts +1 -1
- package/dist/keybindings.js +1 -0
- package/dist/layout-worker.d.ts +4 -1
- package/dist/layout-worker.js +51 -1
- package/dist/layout.d.ts +8 -0
- package/dist/layout.js +8 -1
- package/dist/main.js +216 -35
- package/dist/search.js +1 -1
- package/dist/server-api-routes.d.ts +56 -0
- package/dist/server-api-routes.js +442 -0
- package/dist/server-extensions.d.ts +126 -0
- package/dist/server-extensions.js +272 -0
- package/dist/server-viewer-state.d.ts +18 -0
- package/dist/server-viewer-state.js +33 -0
- package/dist/shortcuts.js +6 -2
- package/dist/sidebar.js +19 -7
- package/dist/style.css +356 -74
- package/dist/tools-pane.js +31 -14
- package/package.json +4 -3
- package/dist/app/assets/index-B3z5bBGl.css +0 -1
- package/dist/app/assets/index-CKYlU1zT.js +0 -35
- package/dist/app/assets/layout-worker-BZXiBoiC.js +0 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool definitions handed to the LLM. These are vendor-neutral —
|
|
3
|
+
* they're shaped like Anthropic's tool format because we're starting
|
|
4
|
+
* with Claude, but the same shape is trivially adaptable to OpenAI's
|
|
5
|
+
* function-calling format inside an OpenAI provider implementation.
|
|
6
|
+
*
|
|
7
|
+
* The execution side runs against `viewer` (the extension API), which
|
|
8
|
+
* gives us auto-undo, auto-persist, and auto-rerender for free — the
|
|
9
|
+
* same call paths the user's manual edits go through.
|
|
10
|
+
*/
|
|
11
|
+
export const TOOLS = [
|
|
12
|
+
{
|
|
13
|
+
name: "get_graph_summary",
|
|
14
|
+
description: "Get a summary of the current learning graph: total nodes, total edges, and counts by node type. Use this first to understand what's in the graph before drilling in.",
|
|
15
|
+
input_schema: { type: "object", properties: {}, required: [] },
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "search_nodes",
|
|
19
|
+
description: "Search nodes in the current graph by a substring match against any string property. Returns up to 20 matching nodes with id, type, and label.",
|
|
20
|
+
input_schema: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
query: { type: "string", description: "Substring to search for (case-insensitive)" },
|
|
24
|
+
type: { type: "string", description: "Optional: restrict to nodes of this type" },
|
|
25
|
+
},
|
|
26
|
+
required: ["query"],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "get_node",
|
|
31
|
+
description: "Get full details (all properties + type) for a specific node by id.",
|
|
32
|
+
input_schema: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: { nodeId: { type: "string" } },
|
|
35
|
+
required: ["nodeId"],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "get_neighbors",
|
|
40
|
+
description: "Get all nodes directly connected to the given node, with the edge type for each connection.",
|
|
41
|
+
input_schema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: { nodeId: { type: "string" } },
|
|
44
|
+
required: ["nodeId"],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "list_node_types",
|
|
49
|
+
description: "List all distinct node types in the current graph with counts.",
|
|
50
|
+
input_schema: { type: "object", properties: {}, required: [] },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "add_node",
|
|
54
|
+
description: "Add a new node to the current graph. Returns the new node id. Properties should follow the existing convention in the graph — use list_node_types and get_node first to see what shape similar nodes use.",
|
|
55
|
+
input_schema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {
|
|
58
|
+
type: { type: "string", description: "Node type (freeform)" },
|
|
59
|
+
properties: {
|
|
60
|
+
type: "object",
|
|
61
|
+
description: "Freeform key/value properties — usually includes a 'name' or 'title' field",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ["type", "properties"],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "update_node",
|
|
69
|
+
description: "Update properties on an existing node. Pass only the properties you want to change — others are preserved.",
|
|
70
|
+
input_schema: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
nodeId: { type: "string" },
|
|
74
|
+
properties: { type: "object" },
|
|
75
|
+
},
|
|
76
|
+
required: ["nodeId", "properties"],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "remove_node",
|
|
81
|
+
description: "Delete a node and all edges touching it. This is destructive — confirm with the user first if the node is non-trivial.",
|
|
82
|
+
input_schema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: { nodeId: { type: "string" } },
|
|
85
|
+
required: ["nodeId"],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "add_edge",
|
|
90
|
+
description: "Connect two existing nodes with a typed edge.",
|
|
91
|
+
input_schema: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
sourceId: { type: "string" },
|
|
95
|
+
targetId: { type: "string" },
|
|
96
|
+
type: { type: "string", description: "Edge type (freeform, e.g. 'contains', 'depends_on')" },
|
|
97
|
+
},
|
|
98
|
+
required: ["sourceId", "targetId", "type"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "remove_edge",
|
|
103
|
+
description: "Delete an edge by id.",
|
|
104
|
+
input_schema: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: { edgeId: { type: "string" } },
|
|
107
|
+
required: ["edgeId"],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "focus_nodes",
|
|
112
|
+
description: "Tell the viewer to enter focus mode on the given seed nodes, expanding to N hops of neighbors. Call this when the user is asking about a specific region of the graph so they can see what you're talking about.",
|
|
113
|
+
input_schema: {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: {
|
|
116
|
+
nodeIds: { type: "array", items: { type: "string" } },
|
|
117
|
+
hops: { type: "number", description: "Hop distance (default 1)" },
|
|
118
|
+
},
|
|
119
|
+
required: ["nodeIds"],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "pan_to_node",
|
|
124
|
+
description: "Pan and zoom the viewer to a specific node.",
|
|
125
|
+
input_schema: {
|
|
126
|
+
type: "object",
|
|
127
|
+
properties: { nodeId: { type: "string" } },
|
|
128
|
+
required: ["nodeId"],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
function labelOf(node) {
|
|
133
|
+
const first = Object.values(node.properties).find((v) => typeof v === "string");
|
|
134
|
+
return first ?? node.id;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Execute one tool call against the viewer extension API. Returns a
|
|
138
|
+
* string that gets fed back to the LLM as the tool_result. Throws on
|
|
139
|
+
* bad input — the caller turns the error into a tool_result with
|
|
140
|
+
* `is_error: true`.
|
|
141
|
+
*/
|
|
142
|
+
export async function executeTool(viewer, name, input) {
|
|
143
|
+
const data = viewer.getGraph();
|
|
144
|
+
if (!data)
|
|
145
|
+
throw new Error("no graph loaded in viewer");
|
|
146
|
+
switch (name) {
|
|
147
|
+
case "get_graph_summary": {
|
|
148
|
+
const typeCounts = new Map();
|
|
149
|
+
for (const n of data.nodes) {
|
|
150
|
+
typeCounts.set(n.type, (typeCounts.get(n.type) ?? 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
return JSON.stringify({
|
|
153
|
+
name: viewer.getGraphName(),
|
|
154
|
+
nodeCount: data.nodes.length,
|
|
155
|
+
edgeCount: data.edges.length,
|
|
156
|
+
nodeTypes: Object.fromEntries(typeCounts),
|
|
157
|
+
}, null, 2);
|
|
158
|
+
}
|
|
159
|
+
case "search_nodes": {
|
|
160
|
+
const query = String(input.query ?? "").toLowerCase();
|
|
161
|
+
const filterType = input.type ? String(input.type) : null;
|
|
162
|
+
if (!query)
|
|
163
|
+
throw new Error("query is required");
|
|
164
|
+
const matches = [];
|
|
165
|
+
for (const n of data.nodes) {
|
|
166
|
+
if (filterType && n.type !== filterType)
|
|
167
|
+
continue;
|
|
168
|
+
const haystack = Object.values(n.properties)
|
|
169
|
+
.filter((v) => typeof v === "string")
|
|
170
|
+
.join(" ")
|
|
171
|
+
.toLowerCase();
|
|
172
|
+
if (haystack.includes(query) || n.id.toLowerCase().includes(query)) {
|
|
173
|
+
matches.push({ id: n.id, type: n.type, label: labelOf(n) });
|
|
174
|
+
if (matches.length >= 20)
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return JSON.stringify({ query, matchCount: matches.length, matches }, null, 2);
|
|
179
|
+
}
|
|
180
|
+
case "get_node": {
|
|
181
|
+
const nodeId = String(input.nodeId ?? "");
|
|
182
|
+
const node = data.nodes.find((n) => n.id === nodeId);
|
|
183
|
+
if (!node)
|
|
184
|
+
throw new Error(`node not found: ${nodeId}`);
|
|
185
|
+
return JSON.stringify(node, null, 2);
|
|
186
|
+
}
|
|
187
|
+
case "get_neighbors": {
|
|
188
|
+
const nodeId = String(input.nodeId ?? "");
|
|
189
|
+
const node = data.nodes.find((n) => n.id === nodeId);
|
|
190
|
+
if (!node)
|
|
191
|
+
throw new Error(`node not found: ${nodeId}`);
|
|
192
|
+
const neighbors = [];
|
|
193
|
+
for (const e of data.edges) {
|
|
194
|
+
if (e.sourceId === nodeId) {
|
|
195
|
+
const other = data.nodes.find((n) => n.id === e.targetId);
|
|
196
|
+
if (other) {
|
|
197
|
+
neighbors.push({
|
|
198
|
+
edgeId: e.id,
|
|
199
|
+
edgeType: e.type,
|
|
200
|
+
direction: "out",
|
|
201
|
+
node: { id: other.id, type: other.type, label: labelOf(other) },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else if (e.targetId === nodeId) {
|
|
206
|
+
const other = data.nodes.find((n) => n.id === e.sourceId);
|
|
207
|
+
if (other) {
|
|
208
|
+
neighbors.push({
|
|
209
|
+
edgeId: e.id,
|
|
210
|
+
edgeType: e.type,
|
|
211
|
+
direction: "in",
|
|
212
|
+
node: { id: other.id, type: other.type, label: labelOf(other) },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return JSON.stringify({ nodeId, label: labelOf(node), neighborCount: neighbors.length, neighbors }, null, 2);
|
|
218
|
+
}
|
|
219
|
+
case "list_node_types": {
|
|
220
|
+
const counts = new Map();
|
|
221
|
+
for (const n of data.nodes)
|
|
222
|
+
counts.set(n.type, (counts.get(n.type) ?? 0) + 1);
|
|
223
|
+
return JSON.stringify(Object.fromEntries(counts), null, 2);
|
|
224
|
+
}
|
|
225
|
+
case "add_node": {
|
|
226
|
+
const type = String(input.type ?? "");
|
|
227
|
+
const properties = (input.properties ?? {});
|
|
228
|
+
if (!type)
|
|
229
|
+
throw new Error("type is required");
|
|
230
|
+
const id = await viewer.addNode(type, properties);
|
|
231
|
+
return JSON.stringify({ ok: true, id, type }, null, 2);
|
|
232
|
+
}
|
|
233
|
+
case "update_node": {
|
|
234
|
+
const nodeId = String(input.nodeId ?? "");
|
|
235
|
+
const properties = (input.properties ?? {});
|
|
236
|
+
await viewer.updateNode(nodeId, properties);
|
|
237
|
+
return JSON.stringify({ ok: true, id: nodeId }, null, 2);
|
|
238
|
+
}
|
|
239
|
+
case "remove_node": {
|
|
240
|
+
const nodeId = String(input.nodeId ?? "");
|
|
241
|
+
await viewer.removeNode(nodeId);
|
|
242
|
+
return JSON.stringify({ ok: true, removed: nodeId }, null, 2);
|
|
243
|
+
}
|
|
244
|
+
case "add_edge": {
|
|
245
|
+
const sourceId = String(input.sourceId ?? "");
|
|
246
|
+
const targetId = String(input.targetId ?? "");
|
|
247
|
+
const type = String(input.type ?? "");
|
|
248
|
+
if (!sourceId || !targetId || !type) {
|
|
249
|
+
throw new Error("sourceId, targetId, and type are required");
|
|
250
|
+
}
|
|
251
|
+
const id = await viewer.addEdge(sourceId, targetId, type);
|
|
252
|
+
return JSON.stringify({ ok: true, id }, null, 2);
|
|
253
|
+
}
|
|
254
|
+
case "remove_edge": {
|
|
255
|
+
const edgeId = String(input.edgeId ?? "");
|
|
256
|
+
await viewer.removeEdge(edgeId);
|
|
257
|
+
return JSON.stringify({ ok: true, removed: edgeId }, null, 2);
|
|
258
|
+
}
|
|
259
|
+
case "focus_nodes": {
|
|
260
|
+
const nodeIds = (input.nodeIds ?? []);
|
|
261
|
+
const hops = typeof input.hops === "number" ? input.hops : 1;
|
|
262
|
+
if (nodeIds.length === 0)
|
|
263
|
+
throw new Error("nodeIds is required");
|
|
264
|
+
const valid = nodeIds.filter((id) => data.nodes.some((n) => n.id === id));
|
|
265
|
+
if (valid.length === 0)
|
|
266
|
+
throw new Error("no valid node ids in focus_nodes");
|
|
267
|
+
viewer.focusNodes(valid, hops);
|
|
268
|
+
return JSON.stringify({ ok: true, focused: valid, hops }, null, 2);
|
|
269
|
+
}
|
|
270
|
+
case "pan_to_node": {
|
|
271
|
+
const nodeId = String(input.nodeId ?? "");
|
|
272
|
+
const node = data.nodes.find((n) => n.id === nodeId);
|
|
273
|
+
if (!node)
|
|
274
|
+
throw new Error(`node not found: ${nodeId}`);
|
|
275
|
+
viewer.panToNode(nodeId);
|
|
276
|
+
return JSON.stringify({ ok: true, panned: nodeId }, null, 2);
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
throw new Error(`unknown tool: ${name}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/* Chat extension stylesheet — loaded by the extension loader from
|
|
2
|
+
<viewer-origin>/extensions/chat/style.css. All styles are scoped to
|
|
3
|
+
chat-ext-* class names so they don't collide with other extensions
|
|
4
|
+
or the viewer core. */
|
|
5
|
+
|
|
6
|
+
.chat-ext-body {
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
flex: 1;
|
|
10
|
+
min-height: 320px;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.chat-ext-messages {
|
|
14
|
+
flex: 1;
|
|
15
|
+
overflow-y: auto;
|
|
16
|
+
padding: 12px;
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-direction: column;
|
|
19
|
+
gap: 8px;
|
|
20
|
+
min-height: 200px;
|
|
21
|
+
max-height: 60vh;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.chat-ext-intro {
|
|
25
|
+
font-size: 12px;
|
|
26
|
+
color: var(--text-muted);
|
|
27
|
+
line-height: 1.5;
|
|
28
|
+
padding: 8px 10px;
|
|
29
|
+
border-radius: 6px;
|
|
30
|
+
background: var(--bg-hover);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.chat-ext-msg {
|
|
34
|
+
display: flex;
|
|
35
|
+
font-size: 13px;
|
|
36
|
+
line-height: 1.5;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.chat-ext-msg-text {
|
|
40
|
+
padding: 8px 12px;
|
|
41
|
+
border-radius: 10px;
|
|
42
|
+
max-width: 90%;
|
|
43
|
+
white-space: pre-wrap;
|
|
44
|
+
word-wrap: break-word;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.chat-ext-msg-user {
|
|
48
|
+
justify-content: flex-end;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.chat-ext-msg-user .chat-ext-msg-text {
|
|
52
|
+
background: var(--accent, #d4a27f);
|
|
53
|
+
color: #1a1a1a;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.chat-ext-msg-assistant {
|
|
57
|
+
justify-content: flex-start;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.chat-ext-msg-assistant .chat-ext-msg-text {
|
|
61
|
+
background: var(--bg-hover);
|
|
62
|
+
color: var(--text);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.chat-ext-tool-call {
|
|
66
|
+
font-size: 11px;
|
|
67
|
+
font-family: monospace;
|
|
68
|
+
color: var(--text-muted);
|
|
69
|
+
padding: 4px 8px;
|
|
70
|
+
border-left: 2px solid var(--accent, #d4a27f);
|
|
71
|
+
margin-left: 8px;
|
|
72
|
+
opacity: 0.85;
|
|
73
|
+
word-break: break-all;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.chat-ext-tool-result {
|
|
77
|
+
font-size: 11px;
|
|
78
|
+
font-family: monospace;
|
|
79
|
+
color: var(--text-dim);
|
|
80
|
+
padding: 4px 8px;
|
|
81
|
+
border-left: 2px solid var(--border);
|
|
82
|
+
margin-left: 8px;
|
|
83
|
+
opacity: 0.7;
|
|
84
|
+
white-space: pre-wrap;
|
|
85
|
+
word-break: break-all;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.chat-ext-tool-error {
|
|
89
|
+
font-size: 11px;
|
|
90
|
+
color: #ef4444;
|
|
91
|
+
padding: 4px 8px;
|
|
92
|
+
border-left: 2px solid #ef4444;
|
|
93
|
+
margin-left: 8px;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.chat-ext-input-row {
|
|
97
|
+
display: flex;
|
|
98
|
+
gap: 8px;
|
|
99
|
+
padding: 10px 12px;
|
|
100
|
+
border-top: 1px solid var(--border);
|
|
101
|
+
flex-shrink: 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.chat-ext-input {
|
|
105
|
+
flex: 1;
|
|
106
|
+
background: var(--bg);
|
|
107
|
+
border: 1px solid var(--border);
|
|
108
|
+
border-radius: 6px;
|
|
109
|
+
color: var(--text);
|
|
110
|
+
padding: 6px 10px;
|
|
111
|
+
font-family: inherit;
|
|
112
|
+
font-size: 13px;
|
|
113
|
+
line-height: 1.4;
|
|
114
|
+
resize: none;
|
|
115
|
+
outline: none;
|
|
116
|
+
transition: border-color 0.15s;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.chat-ext-input:focus {
|
|
120
|
+
border-color: var(--accent, #d4a27f);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.chat-ext-input:disabled {
|
|
124
|
+
opacity: 0.5;
|
|
125
|
+
cursor: not-allowed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.chat-ext-send {
|
|
129
|
+
background: var(--accent, #d4a27f);
|
|
130
|
+
border: none;
|
|
131
|
+
border-radius: 6px;
|
|
132
|
+
color: #1a1a1a;
|
|
133
|
+
font-size: 12px;
|
|
134
|
+
font-weight: 500;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
padding: 6px 14px;
|
|
137
|
+
transition: opacity 0.15s;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.chat-ext-send:hover:not(:disabled) {
|
|
141
|
+
opacity: 0.85;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.chat-ext-send:disabled {
|
|
145
|
+
opacity: 0.4;
|
|
146
|
+
cursor: not-allowed;
|
|
147
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ViewerEvent } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Tiny pub/sub for viewer events. Used by main.ts to emit and by the
|
|
4
|
+
* extension API to subscribe. Synchronous — handlers run in registration
|
|
5
|
+
* order on the same tick as the emit. Errors in one handler don't affect
|
|
6
|
+
* others.
|
|
7
|
+
*/
|
|
8
|
+
export interface EventBus {
|
|
9
|
+
emit(event: ViewerEvent): void;
|
|
10
|
+
subscribe(event: ViewerEvent, cb: () => void): () => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function createEventBus(): EventBus;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function createEventBus() {
|
|
2
|
+
const handlers = new Map();
|
|
3
|
+
return {
|
|
4
|
+
emit(event) {
|
|
5
|
+
const set = handlers.get(event);
|
|
6
|
+
if (!set)
|
|
7
|
+
return;
|
|
8
|
+
for (const cb of set) {
|
|
9
|
+
try {
|
|
10
|
+
cb();
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
console.error(`[backpack-viewer] event handler for ${event} threw:`, err);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
subscribe(event, cb) {
|
|
18
|
+
let set = handlers.get(event);
|
|
19
|
+
if (!set) {
|
|
20
|
+
set = new Set();
|
|
21
|
+
handlers.set(event, set);
|
|
22
|
+
}
|
|
23
|
+
set.add(cb);
|
|
24
|
+
return () => {
|
|
25
|
+
const s = handlers.get(event);
|
|
26
|
+
s?.delete(cb);
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ViewerExtensionAPI, ViewerHost } from "./types";
|
|
2
|
+
import type { PanelMount } from "./panel-mount";
|
|
3
|
+
/**
|
|
4
|
+
* Browser-side extension loader.
|
|
5
|
+
*
|
|
6
|
+
* On startup, fetches `/api/extensions` to learn which extensions the
|
|
7
|
+
* server has loaded. For each one:
|
|
8
|
+
* 1. Constructs a per-extension API instance scoped to its name
|
|
9
|
+
* 2. Loads the extension's stylesheet (if any) by appending a <link>
|
|
10
|
+
* 3. Dynamic-imports the extension's entry file from
|
|
11
|
+
* `/extensions/<name>/<entry>`
|
|
12
|
+
* 4. Calls `module.activate(api)` inside a try/catch
|
|
13
|
+
*
|
|
14
|
+
* One extension's failure (load error, activate throw) does not affect
|
|
15
|
+
* the others. Errors are surfaced to the console with the extension
|
|
16
|
+
* name so the user can debug.
|
|
17
|
+
*/
|
|
18
|
+
interface ExtensionInfo {
|
|
19
|
+
name: string;
|
|
20
|
+
version: string;
|
|
21
|
+
viewerApi: string;
|
|
22
|
+
displayName?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
entry: string;
|
|
25
|
+
stylesheet?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface LoadedExtensionInstance {
|
|
28
|
+
info: ExtensionInfo;
|
|
29
|
+
api: ViewerExtensionAPI;
|
|
30
|
+
}
|
|
31
|
+
export declare function loadExtensions(host: ViewerHost, panelMount: PanelMount): Promise<LoadedExtensionInstance[]>;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createExtensionAPI } from "./api";
|
|
2
|
+
import { createTaskbar } from "./taskbar";
|
|
3
|
+
import { VIEWER_API_VERSION } from "./types";
|
|
4
|
+
export async function loadExtensions(host, panelMount) {
|
|
5
|
+
// Construct the taskbar (icons routed into one of four host-owned
|
|
6
|
+
// slot containers). Panel mount is provided by main.ts so the same
|
|
7
|
+
// instance is shared with built-in panels (info-panel) — that way
|
|
8
|
+
// the click-to-front z-stack and persistent storage work across the
|
|
9
|
+
// whole panel system.
|
|
10
|
+
const taskbar = createTaskbar(host.taskbarSlots);
|
|
11
|
+
let infos = [];
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetch("/api/extensions");
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
console.warn(`[backpack-viewer] /api/extensions returned ${res.status}; no extensions will be loaded`);
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
infos = (await res.json());
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
console.error(`[backpack-viewer] failed to fetch extensions list: ${err.message}`);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const loaded = [];
|
|
25
|
+
for (const info of infos) {
|
|
26
|
+
if (info.viewerApi !== VIEWER_API_VERSION) {
|
|
27
|
+
console.warn(`[backpack-viewer] extension "${info.name}" targets viewerApi "${info.viewerApi}" but this viewer supports "${VIEWER_API_VERSION}"; skipping`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
// Load stylesheet first so it's available before the extension's
|
|
31
|
+
// panel/widget mounts.
|
|
32
|
+
if (info.stylesheet) {
|
|
33
|
+
try {
|
|
34
|
+
loadStylesheet(info.name, info.stylesheet);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
console.error(`[backpack-viewer] extension "${info.name}" stylesheet load failed:`, err);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const api = createExtensionAPI(info.name, host, taskbar, panelMount);
|
|
41
|
+
try {
|
|
42
|
+
const moduleUrl = `/extensions/${encodeURIComponent(info.name)}/${info.entry}`;
|
|
43
|
+
const mod = await import(/* @vite-ignore */ moduleUrl);
|
|
44
|
+
const activate = mod.activate;
|
|
45
|
+
if (typeof activate !== "function") {
|
|
46
|
+
console.error(`[backpack-viewer] extension "${info.name}" has no exported activate(api) function; skipping`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
await activate(api);
|
|
50
|
+
loaded.push({ info, api });
|
|
51
|
+
console.log(`[backpack-viewer] loaded extension "${info.name}" v${info.version}`);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
console.error(`[backpack-viewer] extension "${info.name}" failed to activate:`, err);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return loaded;
|
|
58
|
+
}
|
|
59
|
+
function loadStylesheet(extName, stylesheet) {
|
|
60
|
+
// Reject anything that smells like a path traversal — the server
|
|
61
|
+
// already enforces this server-side, but we mirror it client-side
|
|
62
|
+
// for clarity.
|
|
63
|
+
if (stylesheet.includes("..")) {
|
|
64
|
+
throw new Error(`stylesheet path "${stylesheet}" is invalid`);
|
|
65
|
+
}
|
|
66
|
+
const link = document.createElement("link");
|
|
67
|
+
link.rel = "stylesheet";
|
|
68
|
+
link.href = `/extensions/${encodeURIComponent(extName)}/${stylesheet}`;
|
|
69
|
+
link.dataset.extension = extName;
|
|
70
|
+
document.head.appendChild(link);
|
|
71
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest schema for `backpack-extension.json`. Hand-rolled validation
|
|
3
|
+
* (no zod) because the manifest is small and we want zero runtime deps
|
|
4
|
+
* for the extension system.
|
|
5
|
+
*/
|
|
6
|
+
export interface NetworkPermission {
|
|
7
|
+
/** Origin the extension is allowed to call (e.g. "https://api.anthropic.com") */
|
|
8
|
+
origin: string;
|
|
9
|
+
/**
|
|
10
|
+
* Headers to inject server-side when this extension fetches the
|
|
11
|
+
* matching origin. Each value is either { fromEnv: ENV_VAR_NAME } —
|
|
12
|
+
* pulled from process.env, never sent to browser — or { literal: "..." }
|
|
13
|
+
* — a fixed string baked into the manifest. Used for things like API
|
|
14
|
+
* keys (env) or anthropic-version (literal).
|
|
15
|
+
*/
|
|
16
|
+
injectHeaders?: Record<string, {
|
|
17
|
+
fromEnv: string;
|
|
18
|
+
} | {
|
|
19
|
+
literal: string;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
export interface ManifestPermissions {
|
|
23
|
+
graph?: ("read" | "write")[];
|
|
24
|
+
viewer?: ("focus" | "pan")[];
|
|
25
|
+
settings?: boolean;
|
|
26
|
+
network?: NetworkPermission[];
|
|
27
|
+
}
|
|
28
|
+
export interface ExtensionManifest {
|
|
29
|
+
/** Extension id, must match its on-disk dir name. */
|
|
30
|
+
name: string;
|
|
31
|
+
/** Extension version (semver-ish, free-form). */
|
|
32
|
+
version: string;
|
|
33
|
+
/** Versioned API contract this extension targets. */
|
|
34
|
+
viewerApi: string;
|
|
35
|
+
/** Human-readable display name shown in the UI. */
|
|
36
|
+
displayName?: string;
|
|
37
|
+
/** Short user-facing description. */
|
|
38
|
+
description?: string;
|
|
39
|
+
/** Path to the JS entry file, relative to the extension directory. */
|
|
40
|
+
entry: string;
|
|
41
|
+
/** Optional CSS file relative to the extension dir. */
|
|
42
|
+
stylesheet?: string;
|
|
43
|
+
/** What this extension is allowed to do. */
|
|
44
|
+
permissions?: ManifestPermissions;
|
|
45
|
+
}
|
|
46
|
+
export declare class ManifestError extends Error {
|
|
47
|
+
constructor(extensionPath: string, problem: string);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Validate a parsed manifest object. Throws ManifestError on invalid input.
|
|
51
|
+
* Returns the typed manifest. Stricter than tsc since the file comes from
|
|
52
|
+
* disk; we want clear errors at load time, not silent misbehavior later.
|
|
53
|
+
*/
|
|
54
|
+
export declare function validateManifest(raw: unknown, extensionPath: string): ExtensionManifest;
|