mirror of
https://github.com/github/awesome-copilot.git
synced 2026-06-13 11:33:32 +00:00
chore: publish from staged
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createCanvas, joinSession } from "@github/copilot-sdk/extension";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Per-instance state (ephemeral, lives in memory for session lifetime)
|
||||
const instances = new Map();
|
||||
|
||||
function getInstance(instanceId) {
|
||||
if (!instances.has(instanceId)) {
|
||||
instances.set(instanceId, {
|
||||
currentView: null,
|
||||
history: [],
|
||||
selectedNodeId: null,
|
||||
token: crypto.randomBytes(16).toString("hex"),
|
||||
});
|
||||
}
|
||||
return instances.get(instanceId);
|
||||
}
|
||||
|
||||
function getCurrentView(inst) {
|
||||
return inst.currentView;
|
||||
}
|
||||
|
||||
function pushView(inst, view) {
|
||||
if (inst.currentView) {
|
||||
inst.history.push(inst.currentView);
|
||||
}
|
||||
inst.currentView = view;
|
||||
inst.selectedNodeId = null;
|
||||
}
|
||||
|
||||
function replaceView(inst, view) {
|
||||
inst.currentView = view;
|
||||
inst.selectedNodeId = null;
|
||||
}
|
||||
|
||||
function popView(inst) {
|
||||
if (inst.history.length === 0) return null;
|
||||
inst.currentView = inst.history.pop();
|
||||
inst.selectedNodeId = null;
|
||||
return inst.currentView;
|
||||
}
|
||||
|
||||
// SSE clients per instance
|
||||
const sseClients = new Map();
|
||||
|
||||
function broadcast(instanceId, event, data) {
|
||||
const clients = sseClients.get(instanceId);
|
||||
if (!clients) return;
|
||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const res of clients) {
|
||||
res.write(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast the full view state to the iframe
|
||||
function broadcastView(instanceId, inst) {
|
||||
const view = getCurrentView(inst);
|
||||
broadcast(instanceId, "view", {
|
||||
...view,
|
||||
historyDepth: inst.history.length,
|
||||
breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []),
|
||||
});
|
||||
}
|
||||
|
||||
// HTTP helpers
|
||||
function readJson(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => resolve(body ? JSON.parse(body) : {}));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, code, data) {
|
||||
res.writeHead(code, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// HTTP server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get("token");
|
||||
const instanceId = url.searchParams.get("instance");
|
||||
|
||||
// Serve the HTML page
|
||||
if (req.method === "GET" && url.pathname === "/") {
|
||||
if (!instanceId || !validateToken(instanceId, token)) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8"));
|
||||
return;
|
||||
}
|
||||
|
||||
// SSE endpoint
|
||||
if (req.method === "GET" && url.pathname === "/events") {
|
||||
if (!instanceId || !validateToken(instanceId, token)) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
if (!sseClients.has(instanceId)) sseClients.set(instanceId, new Set());
|
||||
sseClients.get(instanceId).add(res);
|
||||
req.on("close", () => {
|
||||
const clients = sseClients.get(instanceId);
|
||||
if (clients) clients.delete(res);
|
||||
});
|
||||
// Send current view state immediately
|
||||
const inst = getInstance(instanceId);
|
||||
if (inst.currentView) {
|
||||
const view = getCurrentView(inst);
|
||||
res.write(`event: view\ndata: ${JSON.stringify({
|
||||
...view,
|
||||
historyDepth: inst.history.length,
|
||||
breadcrumbs: inst.history.map((v) => v.title).concat([view.title]),
|
||||
})}\n\n`);
|
||||
if (inst.selectedNodeId) {
|
||||
res.write(`event: select\ndata: ${JSON.stringify({ nodeId: inst.selectedNodeId })}\n\n`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// API: get full state
|
||||
if (req.method === "GET" && url.pathname === "/api/state") {
|
||||
if (!instanceId || !validateToken(instanceId, token)) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
const inst = getInstance(instanceId);
|
||||
const view = getCurrentView(inst);
|
||||
json(res, 200, {
|
||||
view,
|
||||
historyDepth: inst.history.length,
|
||||
breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []),
|
||||
selectedNodeId: inst.selectedNodeId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// API: node clicked — triggers drill-down
|
||||
if (req.method === "POST" && url.pathname === "/api/click") {
|
||||
if (!instanceId || !validateToken(instanceId, token)) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
const { nodeId } = await readJson(req);
|
||||
const inst = getInstance(instanceId);
|
||||
inst.selectedNodeId = nodeId;
|
||||
broadcast(instanceId, "select", { nodeId });
|
||||
|
||||
// Send prompt to agent to drill into the clicked node
|
||||
const view = getCurrentView(inst);
|
||||
const node = view?.diagram?.nodes?.find((n) => n.id === nodeId);
|
||||
if (node && session) {
|
||||
const diagramContext = view.diagram.nodes.map((n) => n.label).join(", ");
|
||||
session.send({
|
||||
prompt: `The user clicked on the "${node.label}" node in the Diagram Explorer canvas (id: "${node.id}", type: "${node.type || "default"}", description: "${node.description || "none"}"). The current diagram is "${view.title}" which contains: ${diagramContext}.
|
||||
|
||||
Do NOT explain in chat. Instead, use the canvas actions to respond visually:
|
||||
1. Use the render_diagram action with mode "push" to show a detailed sub-diagram of "${node.label}" — break it into its internal components, sub-systems, or key parts with their relationships.
|
||||
2. Use the show_explanation action to display a brief explanation panel on the canvas.
|
||||
|
||||
If you cannot create a meaningful sub-diagram (e.g. the node is already a leaf concept), use show_explanation to provide a detailed description on the canvas instead, without rendering a new diagram.`,
|
||||
});
|
||||
}
|
||||
|
||||
json(res, 200, { ok: true, selectedNodeId: nodeId });
|
||||
return;
|
||||
}
|
||||
|
||||
// API: navigate back
|
||||
if (req.method === "POST" && url.pathname === "/api/back") {
|
||||
if (!instanceId || !validateToken(instanceId, token)) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
const inst = getInstance(instanceId);
|
||||
const prev = popView(inst);
|
||||
if (prev) {
|
||||
broadcastView(instanceId, inst);
|
||||
}
|
||||
json(res, 200, { ok: true, view: prev });
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
function validateToken(instanceId, token) {
|
||||
const inst = instances.get(instanceId);
|
||||
return inst && inst.token === token;
|
||||
}
|
||||
|
||||
const port = await new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
|
||||
// Canvas declaration
|
||||
const canvas = createCanvas({
|
||||
id: "diagram",
|
||||
displayName: "Diagram Explorer",
|
||||
description:
|
||||
"Interactive diagram for exploring architecture, data flow, and relationships. Render nodes and edges, then click any node to get a detailed explanation from the agent.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Optional title for the initial diagram" },
|
||||
},
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
name: "render_diagram",
|
||||
description:
|
||||
"Render an interactive diagram with nodes and edges. Use mode 'push' to drill into a node (adds to history so user can navigate back), or 'replace' (default) to update the current view in place.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Diagram title" },
|
||||
nodes: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Unique node identifier" },
|
||||
label: { type: "string", description: "Display label" },
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Brief description shown on hover and used when drilling in",
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
description: "Node type for color coding (e.g. 'service', 'database', 'ui', 'api', 'config', 'external')",
|
||||
},
|
||||
},
|
||||
required: ["id", "label"],
|
||||
},
|
||||
},
|
||||
edges: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
from: { type: "string", description: "Source node id" },
|
||||
to: { type: "string", description: "Target node id" },
|
||||
label: { type: "string", description: "Optional edge label" },
|
||||
},
|
||||
required: ["from", "to"],
|
||||
},
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["push", "replace"],
|
||||
description: "Navigation mode. 'push' saves current view to history (for drill-down). 'replace' updates in place (default).",
|
||||
},
|
||||
explanation: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Explanation panel title" },
|
||||
text: { type: "string", description: "Explanation text (plain text)" },
|
||||
},
|
||||
description: "Optional explanation to show alongside the diagram",
|
||||
},
|
||||
},
|
||||
required: ["nodes", "edges"],
|
||||
},
|
||||
handler({ instanceId, input }) {
|
||||
const inst = getInstance(instanceId);
|
||||
const view = {
|
||||
title: input.title || "Diagram",
|
||||
diagram: { title: input.title || "Diagram", nodes: input.nodes, edges: input.edges },
|
||||
explanation: input.explanation || null,
|
||||
selectedNodeId: null,
|
||||
};
|
||||
|
||||
if (input.mode === "push") {
|
||||
pushView(inst, view);
|
||||
} else {
|
||||
replaceView(inst, view);
|
||||
}
|
||||
|
||||
broadcastView(instanceId, inst);
|
||||
return { ok: true, nodeCount: input.nodes.length, edgeCount: input.edges.length, historyDepth: inst.history.length };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "show_explanation",
|
||||
description:
|
||||
"Display an explanation panel on the canvas alongside the current diagram. Use this to provide context about the current view or a clicked node without changing the diagram.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Explanation panel title" },
|
||||
text: { type: "string", description: "Explanation content (plain text, can include line breaks)" },
|
||||
},
|
||||
required: ["title", "text"],
|
||||
},
|
||||
handler({ instanceId, input }) {
|
||||
const inst = getInstance(instanceId);
|
||||
const view = getCurrentView(inst);
|
||||
if (view) {
|
||||
view.explanation = { title: input.title, text: input.text };
|
||||
broadcast(instanceId, "explanation", view.explanation);
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_state",
|
||||
description:
|
||||
"Get the current diagram state including which node the user last clicked and the history depth.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
handler({ instanceId }) {
|
||||
const inst = getInstance(instanceId);
|
||||
const view = getCurrentView(inst);
|
||||
const selectedNode = inst.selectedNodeId
|
||||
? view?.diagram?.nodes?.find((n) => n.id === inst.selectedNodeId)
|
||||
: null;
|
||||
return {
|
||||
currentView: view,
|
||||
selectedNodeId: inst.selectedNodeId,
|
||||
selectedNode: selectedNode || null,
|
||||
historyDepth: inst.history.length,
|
||||
breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "highlight_node",
|
||||
description: "Highlight a specific node in the diagram (e.g. while explaining it).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nodeId: { type: "string", description: "The node id to highlight" },
|
||||
},
|
||||
required: ["nodeId"],
|
||||
},
|
||||
handler({ instanceId, input }) {
|
||||
const inst = getInstance(instanceId);
|
||||
inst.selectedNodeId = input.nodeId;
|
||||
broadcast(instanceId, "select", { nodeId: input.nodeId });
|
||||
return { ok: true, highlightedNodeId: input.nodeId };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Clear the diagram canvas and all history.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
handler({ instanceId }) {
|
||||
const inst = getInstance(instanceId);
|
||||
inst.currentView = null;
|
||||
inst.history = [];
|
||||
inst.selectedNodeId = null;
|
||||
broadcast(instanceId, "clear", {});
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
],
|
||||
open({ instanceId, input }) {
|
||||
const inst = getInstance(instanceId);
|
||||
const view = getCurrentView(inst);
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}?instance=${instanceId}&token=${inst.token}`,
|
||||
title: input?.title || "Diagram Explorer",
|
||||
status: view
|
||||
? `${view.diagram.nodes.length} nodes`
|
||||
: "Ready",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
let session = await joinSession({ canvases: [canvas] });
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"name": "diagram-viewer",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "diagram-viewer",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55.tgz",
|
||||
"integrity": "sha512-wqzI0L7krORW6jDAQPx7VnInka5BYN5yVgu+dpUK4w8xP5RgnOBa6kRoXpydj/9O1ufs0k6RKRtQjsVLp52TRw==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.55",
|
||||
"@github/copilot-darwin-x64": "1.0.55",
|
||||
"@github/copilot-linux-arm64": "1.0.55",
|
||||
"@github/copilot-linux-x64": "1.0.55",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.55",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.55",
|
||||
"@github/copilot-win32-arm64": "1.0.55",
|
||||
"@github/copilot-win32-x64": "1.0.55"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-v59pOpA7YO8j/lpDU/1E8l1Ag0hd26hIiEzTNbzqKd7tJpvhN0XTDWDCink50wXL656XIXt8lD8i8sGeD6yPfA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-darwin-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-x64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-XrJ9ent/9ogLk8yNp3TMsNVW0qTRDlkw/b34VnTgbAkJCaI3UVqaqpFn60Laa6J5mOPW0/JeKIkkva+7IJdqpQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-darwin-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-arm64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-5Q46Q72/l/U8KQRcBwYjzFPNXBCPG177FTmjEVOAH0qk7w58fMUDBEpnf9n1IpxYJDWQJ5BFGtLdfYgVVtkevw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linux-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-x64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-KWmMCDmKJivvOyDAAe5K8r7uSlVq8aZCh20VfrVXsc4bckO6KjXY/TOagrdBNqkk5rh8v63ghBbxFdWIOvEJRA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linux-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-arm64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-Jb5ug9Ic1pzxB2ZT1xoR8b3Ea1xnvCa4h8cBque51+TevXe6QF98vAfSUIwLe4xu+K6JKhiKEA0SD3w29Z74eA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linuxmusl-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-x64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-qMGIjHxKmW9q26EpoaNKWpmEVGyL/IM8ThVkh7yolDzv9lECFudPzT5yLX7f+VIiF6qWQlrQyzmamp7/fNQ2Zg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linuxmusl-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-sdk": {
|
||||
"version": "1.0.0-beta.9",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.9.tgz",
|
||||
"integrity": "sha512-D4yiGL4/faFCjL7bozhX7bgxt/x1wp2LZ2p9Tw+xrA5hbcLh5Be5kPen+bFA8NbVfgt1G2djDYFZlrZjXXmcBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.55-5",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55.tgz",
|
||||
"integrity": "sha512-TO4EJ8it6Qki7wMKYHqGUEDYmB0EAToy+pE5++OpydB6FijyQ31+/XwjvdnEFkuB4ZgPqu/6Y8hxMKucl2+FYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-win32-arm64": "copilot.exe"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-x64": {
|
||||
"version": "1.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55.tgz",
|
||||
"integrity": "sha512-TBMiSZMz8Dhx79JeSEM+7ONGxR5NmxfiDUdySo6thVbRmjS9D8msyAP8ucTsbLBJcTFeb7vsaeObD/ujYQgDtA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-win32-x64": "copilot.exe"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
|
||||
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "diagram-viewer",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Diagram Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--surface: #1a2028;
|
||||
--border: #2a3540;
|
||||
--text: #e2e8f0;
|
||||
--muted: #94a3b8;
|
||||
--accent: #0ea5e9;
|
||||
--coral: #ff7f50;
|
||||
--sage: #84cc16;
|
||||
--violet: #a78bfa;
|
||||
--amber: #f59e0b;
|
||||
--rose: #f43f5e;
|
||||
--font: 'DM Sans', sans-serif;
|
||||
--mono: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header with breadcrumbs */
|
||||
.header {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface);
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.back-btn.visible { display: flex; }
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breadcrumbs .crumb {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.breadcrumbs .crumb.current {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.breadcrumbs .sep {
|
||||
color: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header .badge {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
color: var(--accent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Main layout: diagram + optional explanation sidebar */
|
||||
.main-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Diagram area */
|
||||
.canvas-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-state .icon { font-size: 48px; opacity: 0.3; }
|
||||
.empty-state p { font-size: 13px; text-align: center; max-width: 280px; line-height: 1.6; }
|
||||
|
||||
/* Explanation sidebar */
|
||||
.explanation-panel {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.explanation-panel.visible {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.explanation-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explanation-header h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.explanation-close {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explanation-close:hover { background: rgba(255,255,255,0.05); color: var(--text); }
|
||||
|
||||
.explanation-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Node styles */
|
||||
.node-group { cursor: pointer; }
|
||||
.node-group:hover .node-rect { filter: brightness(1.2); }
|
||||
|
||||
.node-rect {
|
||||
rx: 8;
|
||||
ry: 8;
|
||||
stroke-width: 1.5;
|
||||
transition: filter 0.15s ease, stroke-width 0.15s ease;
|
||||
}
|
||||
|
||||
.node-group.selected .node-rect {
|
||||
stroke-width: 3;
|
||||
filter: brightness(1.3) drop-shadow(0 0 12px currentColor);
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
fill: var(--text);
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.node-type-badge {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 9px;
|
||||
fill: var(--muted);
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Edge styles */
|
||||
.edge-line {
|
||||
fill: none;
|
||||
stroke: var(--border);
|
||||
stroke-width: 1.5;
|
||||
marker-end: url(#arrowhead);
|
||||
}
|
||||
|
||||
.edge-label {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 9px;
|
||||
fill: var(--muted);
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
max-width: 240px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tooltip.visible { opacity: 1; }
|
||||
.tooltip .tt-label { font-weight: 600; margin-bottom: 4px; }
|
||||
.tooltip .tt-desc { color: var(--muted); line-height: 1.4; }
|
||||
.tooltip .tt-hint { margin-top: 6px; font-family: var(--mono); font-size: 9px; color: var(--accent); }
|
||||
|
||||
/* Loading indicator */
|
||||
.loading-indicator {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.loading-indicator.visible { display: flex; }
|
||||
|
||||
.loading-indicator .dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<button class="back-btn" id="back-btn" title="Go back">←</button>
|
||||
<div class="breadcrumbs" id="breadcrumbs">
|
||||
<span class="crumb current">Diagram Explorer</span>
|
||||
</div>
|
||||
<span class="badge" id="badge" style="display:none"></span>
|
||||
</div>
|
||||
|
||||
<div class="main-layout">
|
||||
<div class="canvas-area" id="canvas-area">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="icon">◇</div>
|
||||
<p>Ask Copilot about architecture or any topic, and an interactive diagram will appear here. Click nodes to drill in.</p>
|
||||
</div>
|
||||
<svg id="svg" xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#2a3540" />
|
||||
</marker>
|
||||
</defs>
|
||||
<g id="edges-layer"></g>
|
||||
<g id="nodes-layer"></g>
|
||||
</svg>
|
||||
<div class="tooltip" id="tooltip">
|
||||
<div class="tt-label"></div>
|
||||
<div class="tt-desc"></div>
|
||||
<div class="tt-hint">Click to drill in</div>
|
||||
</div>
|
||||
<div class="loading-indicator" id="loading">
|
||||
<span class="dot"></span>
|
||||
<span>Agent thinking…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explanation-panel" id="explanation-panel">
|
||||
<div class="explanation-header">
|
||||
<h3 id="explanation-title">Explanation</h3>
|
||||
<button class="explanation-close" id="explanation-close">×</button>
|
||||
</div>
|
||||
<div class="explanation-body" id="explanation-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const instanceId = params.get("instance");
|
||||
const token = params.get("token");
|
||||
|
||||
const NODE_COLORS = {
|
||||
service: { fill: "#1e3a5f", stroke: "#0ea5e9" },
|
||||
database: { fill: "#2d1f3d", stroke: "#a78bfa" },
|
||||
ui: { fill: "#1f2d1f", stroke: "#84cc16" },
|
||||
api: { fill: "#3d2a1a", stroke: "#ff7f50" },
|
||||
config: { fill: "#3d3a1a", stroke: "#f59e0b" },
|
||||
external: { fill: "#3d1a2a", stroke: "#f43f5e" },
|
||||
default: { fill: "#1a2028", stroke: "#475569" },
|
||||
};
|
||||
|
||||
let currentView = null;
|
||||
let selectedNodeId = null;
|
||||
let historyDepth = 0;
|
||||
|
||||
// Layout algorithm: hierarchical/layered
|
||||
function computeLayout(nodes, edges) {
|
||||
if (!nodes || nodes.length === 0) return new Map();
|
||||
|
||||
const inDegree = new Map(nodes.map((n) => [n.id, 0]));
|
||||
const outEdges = new Map(nodes.map((n) => [n.id, []]));
|
||||
|
||||
for (const edge of edges) {
|
||||
if (inDegree.has(edge.to)) inDegree.set(edge.to, inDegree.get(edge.to) + 1);
|
||||
if (outEdges.has(edge.from)) outEdges.get(edge.from).push(edge.to);
|
||||
}
|
||||
|
||||
// Assign layers via BFS from roots
|
||||
const layers = new Map();
|
||||
const queue = [];
|
||||
const visited = new Set();
|
||||
|
||||
for (const [id, deg] of inDegree) {
|
||||
if (deg === 0) { queue.push(id); layers.set(id, 0); visited.add(id); }
|
||||
}
|
||||
|
||||
if (queue.length === 0 && nodes.length > 0) {
|
||||
queue.push(nodes[0].id); layers.set(nodes[0].id, 0); visited.add(nodes[0].id);
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
const currentLayer = layers.get(current);
|
||||
for (const next of (outEdges.get(current) || [])) {
|
||||
const newLayer = currentLayer + 1;
|
||||
if (!visited.has(next) || layers.get(next) < newLayer) {
|
||||
layers.set(next, newLayer);
|
||||
if (!visited.has(next)) { visited.add(next); queue.push(next); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!layers.has(node.id)) layers.set(node.id, 0);
|
||||
}
|
||||
|
||||
// Group by layer
|
||||
const layerGroups = new Map();
|
||||
for (const [id, layer] of layers) {
|
||||
if (!layerGroups.has(layer)) layerGroups.set(layer, []);
|
||||
layerGroups.get(layer).push(id);
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 160;
|
||||
const NODE_HEIGHT = 56;
|
||||
const H_GAP = 40;
|
||||
const V_GAP = 80;
|
||||
const positions = new Map();
|
||||
|
||||
const maxLayer = Math.max(...layerGroups.keys());
|
||||
const svg = document.getElementById("svg");
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const availWidth = svgRect.width || 500;
|
||||
const availHeight = svgRect.height || 400;
|
||||
|
||||
const totalHeight = (maxLayer + 1) * (NODE_HEIGHT + V_GAP) - V_GAP;
|
||||
const startY = Math.max(40, (availHeight - totalHeight) / 2);
|
||||
|
||||
for (const [layer, nodeIds] of layerGroups) {
|
||||
const totalWidth = nodeIds.length * (NODE_WIDTH + H_GAP) - H_GAP;
|
||||
const startX = Math.max(20, (availWidth - totalWidth) / 2);
|
||||
|
||||
nodeIds.forEach((id, i) => {
|
||||
positions.set(id, {
|
||||
x: startX + i * (NODE_WIDTH + H_GAP) + NODE_WIDTH / 2,
|
||||
y: startY + layer * (NODE_HEIGHT + V_GAP) + NODE_HEIGHT / 2,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function getNodeColor(type) {
|
||||
return NODE_COLORS[type] || NODE_COLORS.default;
|
||||
}
|
||||
|
||||
function renderDiagram() {
|
||||
const diagram = currentView?.diagram;
|
||||
|
||||
if (!diagram || !diagram.nodes || diagram.nodes.length === 0) {
|
||||
document.getElementById("svg").style.display = "none";
|
||||
document.getElementById("empty-state").style.display = "flex";
|
||||
document.getElementById("badge").style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById("svg").style.display = "block";
|
||||
document.getElementById("empty-state").style.display = "none";
|
||||
document.getElementById("badge").textContent = `${diagram.nodes.length} nodes`;
|
||||
document.getElementById("badge").style.display = "inline";
|
||||
|
||||
const positions = computeLayout(diagram.nodes, diagram.edges);
|
||||
|
||||
// Render edges
|
||||
const edgesLayer = document.getElementById("edges-layer");
|
||||
edgesLayer.innerHTML = "";
|
||||
|
||||
for (const edge of diagram.edges) {
|
||||
const fromPos = positions.get(edge.from);
|
||||
const toPos = positions.get(edge.to);
|
||||
if (!fromPos || !toPos) continue;
|
||||
|
||||
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
const x1 = fromPos.x, y1 = fromPos.y + fromPos.height / 2;
|
||||
const x2 = toPos.x, y2 = toPos.y - toPos.height / 2;
|
||||
const midY = (y1 + y2) / 2;
|
||||
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("class", "edge-line");
|
||||
path.setAttribute("d", `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
|
||||
g.appendChild(path);
|
||||
|
||||
if (edge.label) {
|
||||
const labelEl = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
labelEl.setAttribute("class", "edge-label");
|
||||
labelEl.setAttribute("x", (x1 + x2) / 2);
|
||||
labelEl.setAttribute("y", midY - 8);
|
||||
labelEl.textContent = edge.label;
|
||||
g.appendChild(labelEl);
|
||||
}
|
||||
edgesLayer.appendChild(g);
|
||||
}
|
||||
|
||||
// Render nodes
|
||||
const nodesLayer = document.getElementById("nodes-layer");
|
||||
nodesLayer.innerHTML = "";
|
||||
|
||||
for (const node of diagram.nodes) {
|
||||
const pos = positions.get(node.id);
|
||||
if (!pos) continue;
|
||||
|
||||
const colors = getNodeColor(node.type);
|
||||
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
g.setAttribute("class", `node-group${selectedNodeId === node.id ? " selected" : ""}`);
|
||||
g.dataset.nodeId = node.id;
|
||||
|
||||
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
rect.setAttribute("class", "node-rect");
|
||||
rect.setAttribute("x", pos.x - pos.width / 2);
|
||||
rect.setAttribute("y", pos.y - pos.height / 2);
|
||||
rect.setAttribute("width", pos.width);
|
||||
rect.setAttribute("height", pos.height);
|
||||
rect.setAttribute("fill", colors.fill);
|
||||
rect.setAttribute("stroke", colors.stroke);
|
||||
rect.style.color = colors.stroke;
|
||||
g.appendChild(rect);
|
||||
|
||||
const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
label.setAttribute("class", "node-label");
|
||||
label.setAttribute("x", pos.x);
|
||||
label.setAttribute("y", pos.y - 4);
|
||||
label.textContent = node.label;
|
||||
g.appendChild(label);
|
||||
|
||||
if (node.type) {
|
||||
const badge = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
badge.setAttribute("class", "node-type-badge");
|
||||
badge.setAttribute("x", pos.x);
|
||||
badge.setAttribute("y", pos.y + 14);
|
||||
badge.textContent = node.type;
|
||||
g.appendChild(badge);
|
||||
}
|
||||
|
||||
g.addEventListener("mouseenter", (e) => showTooltip(e, node));
|
||||
g.addEventListener("mouseleave", hideTooltip);
|
||||
g.addEventListener("click", () => onNodeClick(node));
|
||||
|
||||
nodesLayer.appendChild(g);
|
||||
}
|
||||
}
|
||||
|
||||
function updateBreadcrumbs(breadcrumbs) {
|
||||
const container = document.getElementById("breadcrumbs");
|
||||
container.innerHTML = "";
|
||||
|
||||
if (!breadcrumbs || breadcrumbs.length === 0) {
|
||||
container.innerHTML = '<span class="crumb current">Diagram Explorer</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
breadcrumbs.forEach((crumb, i) => {
|
||||
if (i > 0) {
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "sep";
|
||||
sep.textContent = "›";
|
||||
container.appendChild(sep);
|
||||
}
|
||||
const el = document.createElement("span");
|
||||
el.className = i === breadcrumbs.length - 1 ? "crumb current" : "crumb";
|
||||
el.textContent = crumb;
|
||||
container.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function updateBackButton() {
|
||||
const btn = document.getElementById("back-btn");
|
||||
if (historyDepth > 0) {
|
||||
btn.classList.add("visible");
|
||||
} else {
|
||||
btn.classList.remove("visible");
|
||||
}
|
||||
}
|
||||
|
||||
function showExplanation(explanation) {
|
||||
if (!explanation) return;
|
||||
const panel = document.getElementById("explanation-panel");
|
||||
document.getElementById("explanation-title").textContent = explanation.title || "Explanation";
|
||||
document.getElementById("explanation-body").textContent = explanation.text || "";
|
||||
panel.classList.add("visible");
|
||||
}
|
||||
|
||||
function hideExplanation() {
|
||||
document.getElementById("explanation-panel").classList.remove("visible");
|
||||
}
|
||||
|
||||
function showTooltip(e, node) {
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
tooltip.querySelector(".tt-label").textContent = node.label;
|
||||
tooltip.querySelector(".tt-desc").textContent = node.description || node.type || "";
|
||||
tooltip.classList.add("visible");
|
||||
|
||||
const rect = document.getElementById("canvas-area").getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + 12;
|
||||
const y = e.clientY - rect.top + 12;
|
||||
tooltip.style.left = x + "px";
|
||||
tooltip.style.top = y + "px";
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
document.getElementById("tooltip").classList.remove("visible");
|
||||
}
|
||||
|
||||
function onNodeClick(node) {
|
||||
selectedNodeId = node.id;
|
||||
renderDiagram();
|
||||
|
||||
// Show loading state
|
||||
document.getElementById("loading").classList.add("visible");
|
||||
|
||||
// Notify extension
|
||||
fetch(`/api/click?instance=${instanceId}&token=${token}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nodeId: node.id }),
|
||||
});
|
||||
}
|
||||
|
||||
// Back button
|
||||
document.getElementById("back-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await fetch(`/api/back?instance=${instanceId}&token=${token}`, { method: "POST" });
|
||||
} catch (err) {
|
||||
console.error("Back navigation failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Close explanation
|
||||
document.getElementById("explanation-close").addEventListener("click", hideExplanation);
|
||||
|
||||
// Apply a full view state update
|
||||
function applyViewState(data) {
|
||||
currentView = data;
|
||||
selectedNodeId = null;
|
||||
historyDepth = data.historyDepth || 0;
|
||||
|
||||
// Hide loading
|
||||
document.getElementById("loading").classList.remove("visible");
|
||||
|
||||
updateBreadcrumbs(data.breadcrumbs);
|
||||
updateBackButton();
|
||||
renderDiagram();
|
||||
|
||||
if (data.explanation) {
|
||||
showExplanation(data.explanation);
|
||||
} else {
|
||||
hideExplanation();
|
||||
}
|
||||
}
|
||||
|
||||
// SSE connection
|
||||
function connectSSE() {
|
||||
const es = new EventSource(`/events?instance=${instanceId}&token=${token}`);
|
||||
|
||||
es.addEventListener("view", (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
applyViewState(data);
|
||||
});
|
||||
|
||||
es.addEventListener("explanation", (e) => {
|
||||
const explanation = JSON.parse(e.data);
|
||||
showExplanation(explanation);
|
||||
document.getElementById("loading").classList.remove("visible");
|
||||
});
|
||||
|
||||
es.addEventListener("select", (e) => {
|
||||
const { nodeId } = JSON.parse(e.data);
|
||||
selectedNodeId = nodeId;
|
||||
renderDiagram();
|
||||
});
|
||||
|
||||
es.addEventListener("clear", () => {
|
||||
currentView = null;
|
||||
selectedNodeId = null;
|
||||
historyDepth = 0;
|
||||
hideExplanation();
|
||||
updateBreadcrumbs([]);
|
||||
updateBackButton();
|
||||
renderDiagram();
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
setTimeout(connectSSE, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener("resize", () => {
|
||||
if (currentView) renderDiagram();
|
||||
});
|
||||
|
||||
// Init
|
||||
async function init() {
|
||||
try {
|
||||
const resp = await fetch(`/api/state?instance=${instanceId}&token=${token}`);
|
||||
const state = await resp.json();
|
||||
if (state.view) {
|
||||
currentView = state.view;
|
||||
historyDepth = state.historyDepth || 0;
|
||||
selectedNodeId = state.selectedNodeId;
|
||||
updateBreadcrumbs(state.breadcrumbs);
|
||||
updateBackButton();
|
||||
renderDiagram();
|
||||
if (state.view.explanation) showExplanation(state.view.explanation);
|
||||
}
|
||||
} catch {}
|
||||
connectSSE();
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user