Add wiki-explorer-server example (#122)
Interactive Wikipedia link graph visualization using force-graph with: - Force-directed graph layout for exploring page connections - Node expansion to reveal first-degree Wikipedia links - Visual state tracking (blue=default, green=expanded, red=error) - Direct browser access to Wikipedia pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
402bf48145
commit
366c5d5a6b
11 changed files with 3772 additions and 2820 deletions
66
examples/wiki-explorer-server/README.md
Normal file
66
examples/wiki-explorer-server/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Example: Wiki Explorer
|
||||
|
||||
Visualizes Wikipedia link graphs using a force-directed layout. Explore how Wikipedia pages are connected by expanding nodes to reveal first-degree links.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/01-zoomed.png"><img src="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/01-zoomed.png" alt="Zoomed" width="100%"></a></td>
|
||||
<td><a href="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/02-pop-up.png"><img src="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/02-pop-up.png" alt="Pop-up" width="100%"></a></td>
|
||||
<td><a href="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/03-expanded-graph.png"><img src="https://modelcontextprotocol.github.io/ext-apps/screenshots/wiki-explorer-server/03-expanded-graph.png" alt="Expanded graph" width="100%"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Features
|
||||
|
||||
- **Force-directed graph visualization**: Interactive graph powered by [`force-graph`](https://github.com/vasturiano/force-graph)
|
||||
- **Node expansion**: Click any node to expand and see all pages it links to
|
||||
- **Visual state tracking**: Nodes change color based on state (blue = default, green = expanded, red = error)
|
||||
- **Direct page access**: Open any Wikipedia page in your browser
|
||||
|
||||
## Running
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Build and start the server:
|
||||
|
||||
```bash
|
||||
npm run start:http # for Streamable HTTP transport
|
||||
# OR
|
||||
npm run start:stdio # for stdio transport
|
||||
```
|
||||
|
||||
3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host.
|
||||
|
||||
### Tool Input
|
||||
|
||||
To test the example, call the `get-first-degree-links` tool with a Wikipedia URL:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://en.wikipedia.org/wiki/Graph_theory"
|
||||
}
|
||||
```
|
||||
|
||||
Click nodes in the graph to **Open** (view in browser) or **Expand** (visualize linked pages).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Server (`server.ts`)
|
||||
|
||||
MCP server that fetches Wikipedia pages and extracts internal links.
|
||||
|
||||
Exposes one tool:
|
||||
|
||||
- `get-first-degree-links` - Returns links to other Wikipedia pages from a given page
|
||||
|
||||
### App (`src/mcp-app.ts`)
|
||||
|
||||
Vanilla TypeScript app using force-graph for visualization that:
|
||||
|
||||
- Receives tool inputs via the MCP App SDK
|
||||
- Renders an interactive force-directed graph
|
||||
- Supports node expansion to explore link relationships
|
||||
27
examples/wiki-explorer-server/mcp-app.html
Normal file
27
examples/wiki-explorer-server/mcp-app.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Wiki Explorer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="graph"></div>
|
||||
<div id="popup">
|
||||
<div class="popup-title"></div>
|
||||
<div class="popup-error"></div>
|
||||
<div class="popup-buttons">
|
||||
<button id="open-btn">Open</button>
|
||||
<button id="expand-btn">Expand</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<button id="reset-graph" title="Reset graph">↺</button>
|
||||
<div id="zoom-controls">
|
||||
<button id="zoom-in" title="Zoom in">+</button>
|
||||
<button id="zoom-out" title="Zoom out">−</button>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/mcp-app.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
examples/wiki-explorer-server/package.json
Normal file
35
examples/wiki-explorer-server/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "wiki-explorer-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "INPUT=mcp-app.html vite build",
|
||||
"watch": "INPUT=mcp-app.html vite build --watch",
|
||||
"serve:http": "bun server.ts",
|
||||
"serve:stdio": "bun server.ts --stdio",
|
||||
"start": "npm run start:http",
|
||||
"start:http": "NODE_ENV=development npm run build && npm run serve:http",
|
||||
"start:stdio": "NODE_ENV=development npm run build && npm run serve:stdio",
|
||||
"dev": "NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "../..",
|
||||
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"bun": "^1.3.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"force-graph": "^1.49.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.0"
|
||||
}
|
||||
}
|
||||
199
examples/wiki-explorer-server/server.ts
Normal file
199
examples/wiki-explorer-server/server.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import type {
|
||||
CallToolResult,
|
||||
ReadResourceResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import * as cheerio from "cheerio";
|
||||
import cors from "cors";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "../../dist/src/app";
|
||||
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
|
||||
const DIST_DIR = path.join(import.meta.dirname, "dist");
|
||||
|
||||
type PageInfo = { url: string; title: string };
|
||||
|
||||
// Helper to derive title from Wikipedia URL
|
||||
function extractTitleFromUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const path = urlObj.pathname;
|
||||
const title = path.replace("/wiki/", "");
|
||||
return decodeURIComponent(title).replace(/_/g, " ");
|
||||
} catch {
|
||||
return url; // Fallback to URL if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
// Wikipedia namespace prefixes to exclude from link extraction
|
||||
const EXCLUDED_PREFIXES = [
|
||||
"Wikipedia:",
|
||||
"Help:",
|
||||
"File:",
|
||||
"Special:",
|
||||
"Talk:",
|
||||
"Template:",
|
||||
"Category:",
|
||||
"Portal:",
|
||||
"Draft:",
|
||||
"Module:",
|
||||
"MediaWiki:",
|
||||
"User:",
|
||||
"Main_Page",
|
||||
];
|
||||
|
||||
// Extract wiki links from HTML, filtering out special pages and self-links
|
||||
function extractWikiLinks(pageUrl: URL, html: string): PageInfo[] {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
$('a[href^="/wiki/"]')
|
||||
.map((_, el) => $(el).attr("href"))
|
||||
.get()
|
||||
.filter(
|
||||
(href): href is string =>
|
||||
href !== undefined &&
|
||||
href !== pageUrl.pathname &&
|
||||
!href.includes("#") &&
|
||||
!EXCLUDED_PREFIXES.some((prefix) => href.includes(prefix)),
|
||||
),
|
||||
),
|
||||
].map((href) => ({
|
||||
url: `${pageUrl.origin}${href}`,
|
||||
title: extractTitleFromUrl(`${pageUrl.origin}${href}`),
|
||||
}));
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: "Wiki Explorer",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// Register the get-first-degree-links tool and its associated UI resource
|
||||
{
|
||||
const resourceUri = "ui://wiki-explorer/mcp-app.html";
|
||||
|
||||
server.registerTool(
|
||||
"get-first-degree-links",
|
||||
{
|
||||
title: "Get First-Degree Links",
|
||||
description:
|
||||
"Returns all Wikipedia pages that the given page links to directly.",
|
||||
inputSchema: z.object({
|
||||
url: z.string().url().describe("Wikipedia page URL"),
|
||||
}),
|
||||
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
|
||||
},
|
||||
async ({ url }): Promise<CallToolResult> => {
|
||||
let title = url;
|
||||
|
||||
try {
|
||||
if (!url.match(/^https?:\/\/[a-z]+\.wikipedia\.org\/wiki\//)) {
|
||||
throw new Error("Not a valid Wikipedia URL");
|
||||
}
|
||||
|
||||
title = extractTitleFromUrl(url);
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
response.status === 404
|
||||
? "Page not found"
|
||||
: `Fetch failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const links = extractWikiLinks(new URL(url), html);
|
||||
|
||||
const result = { page: { url, title }, links, error: null };
|
||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
const result = { page: { url, title }, links: [], error };
|
||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
resourceUri,
|
||||
resourceUri,
|
||||
{},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
const html = await fs.readFile(
|
||||
path.join(DIST_DIR, "mcp-app.html"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.includes("--stdio")) {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Wiki Explorer server running in stdio mode");
|
||||
} else {
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.post("/mcp", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
res.on("close", () => {
|
||||
transport.close();
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error("Error handling MCP request:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32603, message: "Internal server error" },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const httpServer = app.listen(PORT, () => {
|
||||
console.log(
|
||||
`Wiki Explorer server listening on http://localhost:${PORT}/mcp`,
|
||||
);
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
console.log("\nShutting down...");
|
||||
httpServer.close(() => {
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
28
examples/wiki-explorer-server/src/d3-force-3d.d.ts
vendored
Normal file
28
examples/wiki-explorer-server/src/d3-force-3d.d.ts
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
declare module "d3-force-3d" {
|
||||
interface Force {
|
||||
(alpha: number): void;
|
||||
initialize?: (nodes: unknown[], ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
interface ManyBodyForce extends Force {
|
||||
strength(value: number): ManyBodyForce;
|
||||
}
|
||||
|
||||
interface LinkForce extends Force {
|
||||
distance(value: number): LinkForce;
|
||||
}
|
||||
|
||||
interface CollideForce extends Force {
|
||||
radius(value: number): CollideForce;
|
||||
}
|
||||
|
||||
interface CenterForce extends Force {
|
||||
x(value: number): CenterForce;
|
||||
y(value: number): CenterForce;
|
||||
}
|
||||
|
||||
export function forceManyBody(): ManyBodyForce;
|
||||
export function forceLink(): LinkForce;
|
||||
export function forceCollide(radius?: number): CollideForce;
|
||||
export function forceCenter(x?: number, y?: number): CenterForce;
|
||||
}
|
||||
36
examples/wiki-explorer-server/src/global.css
Normal file
36
examples/wiki-explorer-server/src/global.css
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-color: #ffffff;
|
||||
--text-color: #333333;
|
||||
--border-color: #dddddd;
|
||||
--node-default: #4a90d9;
|
||||
--node-expanded: #2ecc71;
|
||||
--node-error: #e74c3c;
|
||||
--link-color: rgba(128, 128, 128, 0.5);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-color: #1e1e1e;
|
||||
--text-color: #e0e0e0;
|
||||
--border-color: #444444;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
147
examples/wiki-explorer-server/src/mcp-app.css
Normal file
147
examples/wiki-explorer-server/src/mcp-app.css
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
#graph {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#popup {
|
||||
display: none;
|
||||
position: fixed;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
word-wrap: break-word;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.popup-error {
|
||||
display: none;
|
||||
color: var(--node-error);
|
||||
font-size: 14px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.popup-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.popup-buttons button {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.popup-buttons button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#open-btn {
|
||||
background-color: var(--node-default);
|
||||
color: white;
|
||||
}
|
||||
|
||||
#expand-btn {
|
||||
background-color: var(--node-expanded);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Disabled state for buttons */
|
||||
.popup-buttons button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Controls container */
|
||||
#controls {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Reset button */
|
||||
#reset-graph {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-color);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
#reset-graph:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
#reset-graph:active {
|
||||
background: var(--border-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Zoom controls */
|
||||
#zoom-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#zoom-controls button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
#zoom-controls button:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
#zoom-controls button:active {
|
||||
background: var(--border-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#zoom-in {
|
||||
border-radius: 4px 4px 0 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#zoom-out {
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
370
examples/wiki-explorer-server/src/mcp-app.ts
Normal file
370
examples/wiki-explorer-server/src/mcp-app.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
/**
|
||||
* Wiki Explorer - Force-directed graph visualization of Wikipedia link networks
|
||||
*/
|
||||
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
forceCenter,
|
||||
forceCollide,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
} from "d3-force-3d";
|
||||
import ForceGraph, { type LinkObject, type NodeObject } from "force-graph";
|
||||
import "./global.css";
|
||||
import "./mcp-app.css";
|
||||
|
||||
// Helper to resolve CSS variables for canvas rendering
|
||||
function getCSSColor(varName: string): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim() || "#000"
|
||||
);
|
||||
}
|
||||
|
||||
// Types
|
||||
type NodeState = "default" | "expanded" | "error";
|
||||
|
||||
interface NodeData extends NodeObject {
|
||||
url: string;
|
||||
title: string;
|
||||
state: NodeState;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface LinkData extends LinkObject {
|
||||
source: string | NodeData;
|
||||
target: string | NodeData;
|
||||
}
|
||||
|
||||
interface GraphData {
|
||||
nodes: NodeData[];
|
||||
links: LinkData[];
|
||||
}
|
||||
|
||||
type PageInfo = { url: string; title: string };
|
||||
type ToolResponse = {
|
||||
page: PageInfo;
|
||||
links: PageInfo[];
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
// Graph state
|
||||
const graphData: GraphData = { nodes: [], links: [] };
|
||||
let selectedNodeUrl: string | null = null;
|
||||
let initialUrl: string | null = null;
|
||||
|
||||
// DOM elements
|
||||
const container = document.getElementById("graph")!;
|
||||
const popup = document.getElementById("popup")!;
|
||||
const popupTitle = popup.querySelector(".popup-title")!;
|
||||
const popupError = popup.querySelector(".popup-error")! as HTMLElement;
|
||||
const openBtn = document.getElementById("open-btn")!;
|
||||
const expandBtn = document.getElementById("expand-btn")!;
|
||||
const zoomInBtn = document.getElementById("zoom-in")!;
|
||||
const zoomOutBtn = document.getElementById("zoom-out")!;
|
||||
const resetBtn = document.getElementById("reset-graph")!;
|
||||
|
||||
// Initialize force-graph
|
||||
const graph = new ForceGraph<NodeData, LinkData>(container)
|
||||
.nodeId("url")
|
||||
.nodeLabel("title")
|
||||
.nodeColor((node: NodeData) => {
|
||||
switch (node.state) {
|
||||
case "expanded":
|
||||
return getCSSColor("--node-expanded");
|
||||
case "error":
|
||||
return getCSSColor("--node-error");
|
||||
default:
|
||||
return getCSSColor("--node-default");
|
||||
}
|
||||
})
|
||||
.nodeVal(8)
|
||||
.linkDirectionalArrowLength(6)
|
||||
.linkDirectionalArrowRelPos(1)
|
||||
.linkColor(() => getCSSColor("--link-color"))
|
||||
.onNodeClick(handleNodeClick)
|
||||
.onBackgroundClick(() => hidePopup())
|
||||
// Configure forces for better node spreading
|
||||
.d3Force("charge", forceManyBody().strength(-80))
|
||||
.d3Force("link", forceLink().distance(60))
|
||||
.d3Force("collide", forceCollide(12))
|
||||
.d3Force("center", forceCenter())
|
||||
.d3VelocityDecay(0.3)
|
||||
.cooldownTime(Infinity)
|
||||
.d3AlphaMin(0)
|
||||
.d3Force("ambient", () => {
|
||||
for (const node of graphData.nodes) {
|
||||
if (node.vx !== undefined && node.vy !== undefined) {
|
||||
node.vx += (Math.random() - 0.5) * 0.1;
|
||||
node.vy += (Math.random() - 0.5) * 0.1;
|
||||
}
|
||||
}
|
||||
})
|
||||
.graphData(graphData);
|
||||
|
||||
// Handle window resize
|
||||
function handleResize() {
|
||||
const { width, height } = container.getBoundingClientRect();
|
||||
graph.width(width).height(height);
|
||||
}
|
||||
window.addEventListener("resize", handleResize);
|
||||
handleResize();
|
||||
|
||||
// Node management functions
|
||||
function addNode(
|
||||
url: string,
|
||||
title: string,
|
||||
state: NodeState = "default",
|
||||
initialPos?: { x: number; y: number },
|
||||
): boolean {
|
||||
const existing = graphData.nodes.find((n) => n.url === url);
|
||||
if (existing) {
|
||||
return false;
|
||||
}
|
||||
const node: NodeData = { url, title, state };
|
||||
if (initialPos) {
|
||||
// Small random jitter so nodes don't stack exactly
|
||||
node.x = initialPos.x + (Math.random() - 0.5) * 20;
|
||||
node.y = initialPos.y + (Math.random() - 0.5) * 20;
|
||||
}
|
||||
graphData.nodes.push(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateNodeTitle(url: string, title: string): void {
|
||||
const node = graphData.nodes.find((n) => n.url === url);
|
||||
if (node) {
|
||||
node.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
function setNodeState(
|
||||
url: string,
|
||||
state: NodeState,
|
||||
errorMessage?: string,
|
||||
): void {
|
||||
const node = graphData.nodes.find((n) => n.url === url);
|
||||
if (node) {
|
||||
node.state = state;
|
||||
node.errorMessage = errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
function addEdge(sourceUrl: string, targetUrl: string): boolean {
|
||||
const existing = graphData.links.find((l) => {
|
||||
const src =
|
||||
typeof l.source === "string" ? l.source : (l.source as NodeData).url;
|
||||
const tgt =
|
||||
typeof l.target === "string" ? l.target : (l.target as NodeData).url;
|
||||
return src === sourceUrl && tgt === targetUrl;
|
||||
});
|
||||
if (existing) {
|
||||
return false;
|
||||
}
|
||||
graphData.links.push({ source: sourceUrl, target: targetUrl });
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateGraph(): void {
|
||||
graph.graphData({ nodes: [...graphData.nodes], links: [...graphData.links] });
|
||||
}
|
||||
|
||||
// Popup management
|
||||
function showPopup(node: NodeData, x: number, y: number): void {
|
||||
popupTitle.textContent = node.title;
|
||||
|
||||
if (node.state === "error") {
|
||||
popupError.textContent = node.errorMessage || "Failed to load page";
|
||||
popupError.style.display = "block";
|
||||
expandBtn.style.display = "none";
|
||||
} else {
|
||||
popupError.style.display = "none";
|
||||
expandBtn.style.display = "inline-block";
|
||||
|
||||
if (node.state === "expanded") {
|
||||
expandBtn.setAttribute("disabled", "true");
|
||||
expandBtn.textContent = "Expanded";
|
||||
} else {
|
||||
expandBtn.removeAttribute("disabled");
|
||||
expandBtn.textContent = "Expand";
|
||||
}
|
||||
}
|
||||
|
||||
popup.style.display = "block";
|
||||
const rect = popup.getBoundingClientRect();
|
||||
const gap = 15;
|
||||
|
||||
// Place popup on opposite side of cursor from screen center
|
||||
const left =
|
||||
x < window.innerWidth / 2
|
||||
? x + gap // cursor on left half → popup to right
|
||||
: x - rect.width - gap; // cursor on right half → popup to left
|
||||
|
||||
const top =
|
||||
y < window.innerHeight / 2
|
||||
? y + gap // cursor on top half → popup below
|
||||
: y - rect.height - gap; // cursor on bottom half → popup above
|
||||
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function hidePopup(): void {
|
||||
popup.style.display = "none";
|
||||
selectedNodeUrl = null;
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function handleNodeClick(node: NodeData, event: MouseEvent): void {
|
||||
// Toggle popup if clicking same node
|
||||
if (selectedNodeUrl === node.url) {
|
||||
hidePopup();
|
||||
return;
|
||||
}
|
||||
selectedNodeUrl = node.url;
|
||||
showPopup(node, event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
// Close popup on Escape key
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && popup.style.display === "block") {
|
||||
hidePopup();
|
||||
}
|
||||
});
|
||||
|
||||
// Zoom controls
|
||||
const ZOOM_FACTOR = 1.5;
|
||||
zoomInBtn.addEventListener("click", () => {
|
||||
const currentZoom = graph.zoom();
|
||||
graph.zoom(currentZoom * ZOOM_FACTOR, 200);
|
||||
});
|
||||
|
||||
zoomOutBtn.addEventListener("click", () => {
|
||||
const currentZoom = graph.zoom();
|
||||
graph.zoom(currentZoom / ZOOM_FACTOR, 200);
|
||||
});
|
||||
|
||||
// Initialize App SDK
|
||||
const app = new App({ name: "Wiki Explorer", version: "1.0.0" });
|
||||
|
||||
// Reset button - clears graph and reloads from initial URL
|
||||
resetBtn.addEventListener("click", async () => {
|
||||
if (!initialUrl) return;
|
||||
|
||||
// Fetch fresh data first, then clear and repopulate
|
||||
const result = await app.callServerTool({
|
||||
name: "get-first-degree-links",
|
||||
arguments: { url: initialUrl },
|
||||
});
|
||||
|
||||
// Clear current graph and repopulate with result
|
||||
graphData.nodes = [];
|
||||
graphData.links = [];
|
||||
addNode(initialUrl, initialUrl, "default", { x: 0, y: 0 });
|
||||
graph.warmupTicks(100);
|
||||
handleToolResultData(result);
|
||||
graph.centerAt(0, 0, 500);
|
||||
});
|
||||
|
||||
// Open button - opens the Wikipedia page in browser
|
||||
openBtn.addEventListener("click", async () => {
|
||||
if (selectedNodeUrl) {
|
||||
await app.sendOpenLink({ url: selectedNodeUrl });
|
||||
hidePopup();
|
||||
}
|
||||
});
|
||||
|
||||
// Expand button - fetches and displays linked pages
|
||||
expandBtn.addEventListener("click", async () => {
|
||||
if (!selectedNodeUrl) return;
|
||||
|
||||
const sourceUrl = selectedNodeUrl;
|
||||
expandBtn.setAttribute("disabled", "true");
|
||||
expandBtn.textContent = "Loading...";
|
||||
|
||||
try {
|
||||
const result = await app.callServerTool({
|
||||
name: "get-first-degree-links",
|
||||
arguments: { url: sourceUrl },
|
||||
});
|
||||
|
||||
graph.warmupTicks(0);
|
||||
handleToolResultData(result);
|
||||
} catch (e) {
|
||||
console.error("Expand error:", e);
|
||||
setNodeState(sourceUrl, "error", "Request failed");
|
||||
updateGraph();
|
||||
} finally {
|
||||
expandBtn.removeAttribute("disabled");
|
||||
expandBtn.textContent = "Expand";
|
||||
hidePopup();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle tool input - create initial node with URL as placeholder title
|
||||
app.ontoolinput = (params) => {
|
||||
const args = params.arguments as { url?: string } | undefined;
|
||||
if (args?.url) {
|
||||
initialUrl = args.url; // Store for reset functionality
|
||||
addNode(args.url, args.url, "default", { x: 0, y: 0 });
|
||||
graph.warmupTicks(100);
|
||||
updateGraph();
|
||||
// Center on the new node
|
||||
graph.centerAt(0, 0, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle tool result - update node and add linked pages (host-initiated, initial load)
|
||||
app.ontoolresult = (result) => {
|
||||
graph.warmupTicks(100);
|
||||
handleToolResultData(result);
|
||||
};
|
||||
|
||||
function handleToolResultData(result: CallToolResult): void {
|
||||
if (
|
||||
result.isError ||
|
||||
!result.content?.[0] ||
|
||||
result.content[0].type !== "text"
|
||||
) {
|
||||
console.error("Tool result error:", result);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response: ToolResponse = JSON.parse(result.content[0].text);
|
||||
const { page, links, error } = response;
|
||||
|
||||
// Ensure the source node exists
|
||||
addNode(page.url, page.title);
|
||||
updateNodeTitle(page.url, page.title);
|
||||
|
||||
if (error) {
|
||||
setNodeState(page.url, "error", error);
|
||||
} else {
|
||||
// Get source node position so new nodes appear nearby
|
||||
const sourceNode = graphData.nodes.find((n) => n.url === page.url);
|
||||
const sourcePos = sourceNode
|
||||
? { x: sourceNode.x ?? 0, y: sourceNode.y ?? 0 }
|
||||
: undefined;
|
||||
|
||||
// Add all linked nodes and edges
|
||||
for (const link of links) {
|
||||
addNode(link.url, link.title, "default", sourcePos);
|
||||
addEdge(page.url, link.url);
|
||||
}
|
||||
setNodeState(page.url, "expanded");
|
||||
}
|
||||
|
||||
updateGraph();
|
||||
} catch (e) {
|
||||
console.error("Failed to parse tool result:", e);
|
||||
}
|
||||
}
|
||||
|
||||
app.onerror = (err) => {
|
||||
console.error("[Wiki Explorer] App error:", err);
|
||||
};
|
||||
|
||||
// Connect to host
|
||||
app.connect(new PostMessageTransport(window.parent));
|
||||
19
examples/wiki-explorer-server/tsconfig.json
Normal file
19
examples/wiki-explorer-server/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "server.ts"]
|
||||
}
|
||||
24
examples/wiki-explorer-server/vite.config.ts
Normal file
24
examples/wiki-explorer-server/vite.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
|
||||
const INPUT = process.env.INPUT;
|
||||
if (!INPUT) {
|
||||
throw new Error("INPUT environment variable is not set");
|
||||
}
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [viteSingleFile()],
|
||||
build: {
|
||||
sourcemap: isDevelopment ? "inline" : undefined,
|
||||
cssMinify: !isDevelopment,
|
||||
minify: !isDevelopment,
|
||||
|
||||
rollupOptions: {
|
||||
input: INPUT,
|
||||
},
|
||||
outDir: "dist",
|
||||
emptyOutDir: false,
|
||||
},
|
||||
});
|
||||
2913
package-lock.json
generated
2913
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue