mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-13 02:24:34 +00:00
feat(mcp): add interactive memory graph MCP App visualization (#763)
### feat(mcp): add memory graph visualization MCP App #### Summary - Add interactive force-directed graph visualization as an MCP App using `force-graph` + `d3-force-3d` - Register `memory-graph` and `fetch-graph-data` tools with MCP Apps SDK UI resources - Add graph API client methods (`getGraphBounds`, `getGraphViewport`) to `SupermemoryClient` - Set up Vite + `vite-plugin-singlefile` build pipeline to produce a self-contained HTML bundle - Support light/dark theme, zoom controls, node popups, and legend #### Files changed - `apps/mcp/src/server.ts` — register app tools + HTML resource - `apps/mcp/src/client.ts` — add graph API types and methods - `apps/mcp/src/ui/mcp-app.ts` — force-graph visualization app - `apps/mcp/src/ui/global.css`, `mcp-app.css` — styling - `apps/mcp/mcp-app.html` — entry HTML for Vite - `apps/mcp/vite.config.ts` — Vite single-file build config - `apps/mcp/package.json` — add UI deps and build scripts - `apps/mcp/wrangler.jsonc` — add HTML text import rule
This commit is contained in:
parent
5e5d446659
commit
2bbc7fc7a5
13 changed files with 1193 additions and 4 deletions
69
apps/mcp/mcp-app.html
Normal file
69
apps/mcp/mcp-app.html
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Memory Graph</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="graph"></div>
|
||||
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading memory graph...</span>
|
||||
</div>
|
||||
|
||||
<div id="stats"></div>
|
||||
|
||||
<div id="popup">
|
||||
<span id="popup-type"></span>
|
||||
<div id="popup-title"></div>
|
||||
<div id="popup-content"></div>
|
||||
<div id="popup-meta"></div>
|
||||
</div>
|
||||
|
||||
<div id="controls">
|
||||
<button id="fit-btn" title="Fit to view">⊞</button>
|
||||
<button id="zoom-in" title="Zoom in">+</button>
|
||||
<button id="zoom-out" title="Zoom out">−</button>
|
||||
</div>
|
||||
|
||||
<div id="legend">
|
||||
<div class="legend-row">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#3B73B8" stroke-width="1.2"/></svg>
|
||||
<span>Memory</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#10B981" stroke-width="1.2"/></svg>
|
||||
<span>Recent (<1d)</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#F59E0B" stroke-width="1.2"/></svg>
|
||||
<span>Expiring</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><polygon points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" fill="#0D2034" stroke="#EF4444" stroke-width="1.2"/></svg>
|
||||
<span>Forgotten</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<div class="legend-doc"></div>
|
||||
<span>Document</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<div class="legend-line" style="border-color: #4A5568;"></div>
|
||||
<span>Doc → Memory</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<div class="legend-line" style="border-color: #8B5CF6; border-width: 2px;"></div>
|
||||
<span>Version</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<div class="legend-line dashed" style="border-color: #00D4B8;"></div>
|
||||
<span>Similarity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/ui/mcp-app.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -3,12 +3,14 @@
|
|||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy --minify",
|
||||
"build:ui": "vite build",
|
||||
"dev": "vite build && wrangler dev",
|
||||
"deploy": "vite build && wrangler deploy --minify",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-oauth-provider": "^0.2.2",
|
||||
"@modelcontextprotocol/ext-apps": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"agents": "^0.3.5",
|
||||
"hono": "^4.11.1",
|
||||
|
|
@ -18,7 +20,11 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250620.0",
|
||||
"d3-force-3d": "^3.0.5",
|
||||
"force-graph": "^1.49.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"wrangler": "^4.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,54 @@ export interface Project {
|
|||
documentCount?: number
|
||||
}
|
||||
|
||||
// Graph API types
|
||||
export interface GraphApiMemory {
|
||||
id: string
|
||||
memory: string
|
||||
isStatic: boolean
|
||||
isLatest: boolean
|
||||
isForgotten: boolean
|
||||
forgetAfter: string | null
|
||||
version: number
|
||||
parentMemoryId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface GraphApiDocument {
|
||||
id: string
|
||||
title: string | null
|
||||
summary: string | null
|
||||
documentType: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
x: number
|
||||
y: number
|
||||
memories: GraphApiMemory[]
|
||||
}
|
||||
|
||||
export interface GraphApiEdge {
|
||||
source: string
|
||||
target: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
export interface GraphViewportResponse {
|
||||
documents: GraphApiDocument[]
|
||||
edges: GraphApiEdge[]
|
||||
viewport: { minX: number; maxX: number; minY: number; maxY: number }
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export interface GraphBoundsResponse {
|
||||
bounds: {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
} | null
|
||||
}
|
||||
|
||||
function limitByChars(text: string, maxChars = MAX_CHARS): string {
|
||||
return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text
|
||||
}
|
||||
|
|
@ -215,6 +263,58 @@ export class SupermemoryClient {
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch graph bounds for coordinate range
|
||||
async getGraphBounds(containerTags?: string[]): Promise<GraphBoundsResponse> {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (containerTags?.length) {
|
||||
params.set("containerTags", JSON.stringify(containerTags))
|
||||
}
|
||||
const url = `${this.apiUrl}/v3/graph/bounds${params.toString() ? `?${params}` : ""}`
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bearerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error("Failed to fetch graph bounds"), {
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
return (await response.json()) as GraphBoundsResponse
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch graph data for a viewport region
|
||||
async getGraphViewport(
|
||||
viewport: { minX: number; maxX: number; minY: number; maxY: number },
|
||||
containerTags?: string[],
|
||||
limit = 200,
|
||||
): Promise<GraphViewportResponse> {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/v3/graph/viewport`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bearerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ viewport, containerTags, limit }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error("Failed to fetch graph viewport"), {
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
return (await response.json()) as GraphViewportResponse
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: unknown): never {
|
||||
// Handle network/fetch errors
|
||||
if (error instanceof TypeError) {
|
||||
|
|
|
|||
4
apps/mcp/src/html.d.ts
vendored
Normal file
4
apps/mcp/src/html.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module "*.html" {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
import { McpAgent } from "agents/mcp"
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import {
|
||||
registerAppTool,
|
||||
registerAppResource,
|
||||
RESOURCE_MIME_TYPE,
|
||||
} from "@modelcontextprotocol/ext-apps/server"
|
||||
import { SupermemoryClient } from "./client"
|
||||
import { initPosthog, posthog } from "./posthog"
|
||||
import { z } from "zod"
|
||||
// @ts-expect-error - wrangler handles HTML imports as text modules via rules config
|
||||
import mcpAppHtml from "../dist/mcp-app.html"
|
||||
|
||||
type Env = {
|
||||
MCP_SERVER: DurableObjectNamespace
|
||||
|
|
@ -275,6 +282,170 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
},
|
||||
)
|
||||
|
||||
// Register memory-graph tool with MCP App UI
|
||||
const memoryGraphResourceUri = "ui://memory-graph/mcp-app.html"
|
||||
|
||||
const memoryGraphSchema = z.object({
|
||||
...(hasRootContainerTag ? {} : containerTagField),
|
||||
})
|
||||
|
||||
type MemoryGraphArgs = z.infer<typeof memoryGraphSchema>
|
||||
|
||||
registerAppTool(
|
||||
this.server,
|
||||
"memory-graph",
|
||||
{
|
||||
title: "Memory Graph",
|
||||
description:
|
||||
"Visualize the user's memory graph as an interactive force-directed graph showing documents, memories, and their relationships.",
|
||||
inputSchema: memoryGraphSchema,
|
||||
_meta: { ui: { resourceUri: memoryGraphResourceUri } },
|
||||
},
|
||||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
async (args: MemoryGraphArgs) => {
|
||||
try {
|
||||
const effectiveContainerTag =
|
||||
(args as { containerTag?: string }).containerTag ||
|
||||
this.props?.containerTag
|
||||
const client = this.getClient(effectiveContainerTag)
|
||||
const containerTags = effectiveContainerTag
|
||||
? [effectiveContainerTag]
|
||||
: undefined
|
||||
|
||||
const [bounds, viewport] = await Promise.all([
|
||||
client.getGraphBounds(containerTags),
|
||||
client.getGraphViewport(
|
||||
{ minX: 0, maxX: 1000, minY: 0, maxY: 1000 },
|
||||
containerTags,
|
||||
200,
|
||||
),
|
||||
])
|
||||
|
||||
const memoryCount = viewport.documents.reduce(
|
||||
(sum, d) => sum + d.memories.length,
|
||||
0,
|
||||
)
|
||||
const textParts = [
|
||||
`Memory Graph: ${viewport.documents.length} documents, ${memoryCount} memories, ${viewport.edges.length} connections`,
|
||||
]
|
||||
if (effectiveContainerTag) {
|
||||
textParts.push(`Project: ${effectiveContainerTag}`)
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: textParts.join(". ") }],
|
||||
structuredContent: {
|
||||
containerTag: effectiveContainerTag,
|
||||
bounds: bounds.bounds,
|
||||
documents: viewport.documents,
|
||||
edges: viewport.edges,
|
||||
totalCount: viewport.totalCount,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred"
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Error loading memory graph: ${message}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// App-only tool for the UI to fetch additional graph data
|
||||
registerAppTool(
|
||||
this.server,
|
||||
"fetch-graph-data",
|
||||
{
|
||||
description: "Fetch graph data for a viewport region",
|
||||
inputSchema: z.object({
|
||||
containerTag: z.string().optional(),
|
||||
viewport: z.object({
|
||||
minX: z.number(),
|
||||
maxX: z.number(),
|
||||
minY: z.number(),
|
||||
maxY: z.number(),
|
||||
}),
|
||||
limit: z.number().optional().default(200),
|
||||
}),
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: memoryGraphResourceUri,
|
||||
visibility: ["app"],
|
||||
},
|
||||
},
|
||||
},
|
||||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
async (args: {
|
||||
containerTag?: string
|
||||
viewport: {
|
||||
minX: number
|
||||
maxX: number
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
limit?: number
|
||||
}) => {
|
||||
try {
|
||||
const effectiveContainerTag =
|
||||
args.containerTag || this.props?.containerTag
|
||||
const client = this.getClient(effectiveContainerTag)
|
||||
const containerTags = effectiveContainerTag
|
||||
? [effectiveContainerTag]
|
||||
: undefined
|
||||
const data = await client.getGraphViewport(
|
||||
args.viewport,
|
||||
containerTags,
|
||||
args.limit,
|
||||
)
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(data) }],
|
||||
structuredContent: data,
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred"
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Error fetching graph data: ${message}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Register HTML resource for the memory graph UI
|
||||
registerAppResource(
|
||||
this.server,
|
||||
"Memory Graph UI",
|
||||
memoryGraphResourceUri,
|
||||
{ mimeType: RESOURCE_MIME_TYPE },
|
||||
async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: memoryGraphResourceUri,
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
text: mcpAppHtml as string,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
this.server.registerPrompt(
|
||||
"context",
|
||||
{
|
||||
|
|
|
|||
46
apps/mcp/src/ui/global.css
Normal file
46
apps/mcp/src/ui/global.css
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
min-height: 600px;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--bg-secondary: #1a1f29;
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
--border: #2a2f36;
|
||||
--accent: #3b73b8;
|
||||
--hex-fill: #0d2034;
|
||||
--doc-fill: #1b1f24;
|
||||
--doc-stroke: #2a2f36;
|
||||
--doc-inner: #13161a;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--accent: #2563eb;
|
||||
--hex-fill: #e8f0fe;
|
||||
--doc-fill: #f1f5f9;
|
||||
--doc-stroke: #cbd5e1;
|
||||
--doc-inner: #e2e8f0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
194
apps/mcp/src/ui/mcp-app.css
Normal file
194
apps/mcp/src/ui/mcp-app.css
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
#graph {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
#loading {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#loading .spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#stats {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
z-index: 10;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
#popup {
|
||||
display: none;
|
||||
position: fixed;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#popup-type {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
#popup-type.document {
|
||||
background: rgba(59, 115, 184, 0.15);
|
||||
color: #3b73b8;
|
||||
}
|
||||
|
||||
#popup-type.memory {
|
||||
background: rgba(59, 115, 184, 0.15);
|
||||
color: #3b73b8;
|
||||
}
|
||||
|
||||
#popup-type.forgotten {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
#popup-type.latest {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
#popup-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#popup-content {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
word-wrap: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#popup-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#controls {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#controls button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
#controls button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
#controls button:active {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
#legend {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
z-index: 10;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.legend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.legend-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.legend-doc {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: var(--doc-fill);
|
||||
border: 1.5px solid var(--doc-stroke);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-line {
|
||||
width: 16px;
|
||||
height: 0;
|
||||
border-top: 1.5px solid;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-line.dashed {
|
||||
border-top-style: dashed;
|
||||
}
|
||||
580
apps/mcp/src/ui/mcp-app.ts
Normal file
580
apps/mcp/src/ui/mcp-app.ts
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
/**
|
||||
* Memory Graph MCP App - Interactive force-directed graph visualization
|
||||
*/
|
||||
import {
|
||||
App,
|
||||
applyDocumentTheme,
|
||||
applyHostFonts,
|
||||
applyHostStyleVariables,
|
||||
type McpUiHostContext,
|
||||
} from "@modelcontextprotocol/ext-apps"
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
||||
import ForceGraph, { type LinkObject, type NodeObject } from "force-graph"
|
||||
import {
|
||||
forceCenter,
|
||||
forceCollide,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
forceRadial,
|
||||
} from "d3-force-3d"
|
||||
import "./global.css"
|
||||
import "./mcp-app.css"
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
interface GraphApiMemory {
|
||||
id: string
|
||||
memory: string
|
||||
isStatic: boolean
|
||||
isLatest: boolean
|
||||
isForgotten: boolean
|
||||
forgetAfter: string | null
|
||||
version: number
|
||||
parentMemoryId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface GraphApiDocument {
|
||||
id: string
|
||||
title: string | null
|
||||
summary: string | null
|
||||
documentType: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
x: number
|
||||
y: number
|
||||
memories: GraphApiMemory[]
|
||||
}
|
||||
|
||||
interface GraphApiEdge {
|
||||
source: string
|
||||
target: string
|
||||
similarity: number
|
||||
}
|
||||
|
||||
interface ToolResultData {
|
||||
containerTag?: string
|
||||
bounds: { minX: number; maxX: number; minY: number; maxY: number } | null
|
||||
documents: GraphApiDocument[]
|
||||
edges: GraphApiEdge[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
interface MemoryNode extends NodeObject {
|
||||
id: string
|
||||
nodeType: "memory"
|
||||
memory: string
|
||||
documentId: string
|
||||
isLatest: boolean
|
||||
isForgotten: boolean
|
||||
forgetAfter: string | null
|
||||
version: number
|
||||
parentMemoryId: string | null
|
||||
createdAt: string
|
||||
borderColor: string
|
||||
}
|
||||
|
||||
interface DocumentNode extends NodeObject {
|
||||
id: string
|
||||
nodeType: "document"
|
||||
title: string
|
||||
summary: string | null
|
||||
docType: string
|
||||
createdAt: string
|
||||
memoryCount: number
|
||||
}
|
||||
|
||||
type GraphNode = MemoryNode | DocumentNode
|
||||
|
||||
interface GraphLink extends LinkObject {
|
||||
source: string | GraphNode
|
||||
target: string | GraphNode
|
||||
edgeType: "doc-memory" | "version" | "similarity"
|
||||
similarity?: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
const MEMORY_BORDER = {
|
||||
forgotten: "#EF4444",
|
||||
expiring: "#F59E0B",
|
||||
recent: "#10B981",
|
||||
default: "#3B73B8",
|
||||
}
|
||||
|
||||
const EDGE_COLORS = {
|
||||
dark: {
|
||||
"doc-memory": "#4A5568",
|
||||
version: "#8B5CF6",
|
||||
similarity: "#00D4B8",
|
||||
},
|
||||
light: {
|
||||
"doc-memory": "#A0AEC0",
|
||||
version: "#8B5CF6",
|
||||
similarity: "#0D9488",
|
||||
},
|
||||
}
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const CLUSTER_SPREAD = 120
|
||||
|
||||
// =============================================================================
|
||||
// State
|
||||
// =============================================================================
|
||||
let isDark = true
|
||||
let selectedNode: GraphNode | null = null
|
||||
|
||||
// =============================================================================
|
||||
// DOM References
|
||||
// =============================================================================
|
||||
const container = document.getElementById("graph")!
|
||||
const popup = document.getElementById("popup")!
|
||||
const popupType = document.getElementById("popup-type")!
|
||||
const popupTitle = document.getElementById("popup-title")!
|
||||
const popupContent = document.getElementById("popup-content")!
|
||||
const popupMeta = document.getElementById("popup-meta")!
|
||||
const loadingEl = document.getElementById("loading")!
|
||||
const statsEl = document.getElementById("stats")!
|
||||
const zoomInBtn = document.getElementById("zoom-in")!
|
||||
const zoomOutBtn = document.getElementById("zoom-out")!
|
||||
const fitBtn = document.getElementById("fit-btn")!
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
function getMemoryBorderColor(mem: GraphApiMemory): string {
|
||||
if (mem.isForgotten) return MEMORY_BORDER.forgotten
|
||||
if (mem.forgetAfter) {
|
||||
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
|
||||
if (msLeft < SEVEN_DAYS_MS) return MEMORY_BORDER.expiring
|
||||
}
|
||||
const age = Date.now() - new Date(mem.createdAt).getTime()
|
||||
if (age < ONE_DAY_MS) return MEMORY_BORDER.recent
|
||||
return MEMORY_BORDER.default
|
||||
}
|
||||
|
||||
function normalizeDocCoordinates(
|
||||
documents: GraphApiDocument[],
|
||||
): GraphApiDocument[] {
|
||||
if (documents.length <= 1) return documents
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
for (const doc of documents) {
|
||||
minX = Math.min(minX, doc.x)
|
||||
maxX = Math.max(maxX, doc.x)
|
||||
minY = Math.min(minY, doc.y)
|
||||
maxY = Math.max(maxY, doc.y)
|
||||
}
|
||||
|
||||
const rangeX = maxX - minX || 1
|
||||
const rangeY = maxY - minY || 1
|
||||
// Small spread so documents start near each other.
|
||||
// The force simulation will naturally separate them.
|
||||
const SPREAD = 50
|
||||
|
||||
return documents.map((doc) => ({
|
||||
...doc,
|
||||
x: ((doc.x - minX) / rangeX - 0.5) * SPREAD,
|
||||
y: ((doc.y - minY) / rangeY - 0.5) * SPREAD,
|
||||
}))
|
||||
}
|
||||
|
||||
function transformData(data: ToolResultData): {
|
||||
nodes: GraphNode[]
|
||||
links: GraphLink[]
|
||||
} {
|
||||
const nodes: GraphNode[] = []
|
||||
const links: GraphLink[] = []
|
||||
const nodeIds = new Set<string>()
|
||||
|
||||
const normalizedDocs = normalizeDocCoordinates(data.documents)
|
||||
|
||||
for (const doc of normalizedDocs) {
|
||||
nodes.push({
|
||||
id: doc.id,
|
||||
nodeType: "document",
|
||||
title: doc.title || "Untitled",
|
||||
summary: doc.summary,
|
||||
docType: doc.documentType,
|
||||
createdAt: doc.createdAt,
|
||||
memoryCount: doc.memories.length,
|
||||
x: doc.x,
|
||||
y: doc.y,
|
||||
} as DocumentNode)
|
||||
nodeIds.add(doc.id)
|
||||
|
||||
const memCount = doc.memories.length
|
||||
for (let i = 0; i < memCount; i++) {
|
||||
const mem = doc.memories[i]!
|
||||
const angle = (i / memCount) * 2 * Math.PI
|
||||
|
||||
nodes.push({
|
||||
id: mem.id,
|
||||
nodeType: "memory",
|
||||
memory: mem.memory,
|
||||
documentId: doc.id,
|
||||
isLatest: mem.isLatest,
|
||||
isForgotten: mem.isForgotten,
|
||||
forgetAfter: mem.forgetAfter,
|
||||
version: mem.version,
|
||||
parentMemoryId: mem.parentMemoryId,
|
||||
createdAt: mem.createdAt,
|
||||
borderColor: getMemoryBorderColor(mem),
|
||||
x: doc.x + Math.cos(angle) * CLUSTER_SPREAD,
|
||||
y: doc.y + Math.sin(angle) * CLUSTER_SPREAD,
|
||||
} as MemoryNode)
|
||||
nodeIds.add(mem.id)
|
||||
|
||||
// Doc-memory link
|
||||
links.push({ source: doc.id, target: mem.id, edgeType: "doc-memory" })
|
||||
|
||||
// Version chain link
|
||||
if (mem.parentMemoryId && nodeIds.has(mem.parentMemoryId)) {
|
||||
links.push({
|
||||
source: mem.parentMemoryId,
|
||||
target: mem.id,
|
||||
edgeType: "version",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Similarity edges from API
|
||||
for (const edge of data.edges) {
|
||||
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
|
||||
links.push({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
edgeType: "similarity",
|
||||
similarity: edge.similarity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, links }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Drawing
|
||||
// =============================================================================
|
||||
function drawHexagon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
strokeColor: string,
|
||||
) {
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i - Math.PI / 6
|
||||
const px = x + radius * Math.cos(angle)
|
||||
const py = y + radius * Math.sin(angle)
|
||||
if (i === 0) ctx.moveTo(px, py)
|
||||
else ctx.lineTo(px, py)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = isDark ? "#0D2034" : "#E8F0FE"
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = strokeColor
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawDocumentNode(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
) {
|
||||
const half = size / 2
|
||||
// Outer rounded rect
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x - half, y - half, size, size, 3)
|
||||
ctx.fillStyle = isDark ? "#1B1F24" : "#F1F5F9"
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = isDark ? "#2A2F36" : "#CBD5E1"
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
|
||||
// Inner icon area
|
||||
const iconSize = size * 0.5
|
||||
const iconHalf = iconSize / 2
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x - iconHalf, y - iconHalf, iconSize, iconSize, 2)
|
||||
ctx.fillStyle = isDark ? "#13161A" : "#E2E8F0"
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Force Graph Setup
|
||||
// =============================================================================
|
||||
function getLinkColor(link: GraphLink): string {
|
||||
const palette = isDark ? EDGE_COLORS.dark : EDGE_COLORS.light
|
||||
return palette[link.edgeType] || palette["doc-memory"]
|
||||
}
|
||||
|
||||
const graph = new ForceGraph<GraphNode, GraphLink>(container)
|
||||
.nodeId("id")
|
||||
.nodeCanvasObject(
|
||||
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const x = node.x!
|
||||
const y = node.y!
|
||||
|
||||
if (node.nodeType === "memory") {
|
||||
const mem = node as MemoryNode
|
||||
drawHexagon(ctx, x, y, 10, mem.borderColor)
|
||||
|
||||
if (globalScale > 2) {
|
||||
const label = mem.memory.slice(0, 30)
|
||||
ctx.font = `${Math.max(4, 10 / globalScale)}px system-ui, sans-serif`
|
||||
ctx.fillStyle = isDark ? "#94a3b8" : "#64748b"
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "top"
|
||||
ctx.fillText(label, x, y + 12)
|
||||
}
|
||||
} else {
|
||||
const doc = node as DocumentNode
|
||||
drawDocumentNode(ctx, x, y, 22)
|
||||
|
||||
if (globalScale > 1.2) {
|
||||
const label = (doc.title || "").slice(0, 25)
|
||||
ctx.font = `600 ${Math.max(4, 11 / globalScale)}px system-ui, sans-serif`
|
||||
ctx.fillStyle = isDark ? "#e2e8f0" : "#1e293b"
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "top"
|
||||
ctx.fillText(label, x, y + 14)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.nodeCanvasObjectMode(() => "replace")
|
||||
.nodePointerAreaPaint(
|
||||
(node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.beginPath()
|
||||
ctx.arc(
|
||||
node.x!,
|
||||
node.y!,
|
||||
node.nodeType === "document" ? 12 : 11,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
)
|
||||
ctx.fill()
|
||||
},
|
||||
)
|
||||
.linkWidth((link: GraphLink) => {
|
||||
if (link.edgeType === "version") return 2
|
||||
if (link.edgeType === "similarity")
|
||||
return 0.5 + (link.similarity || 0) * 1.5
|
||||
return 1
|
||||
})
|
||||
.linkColor(getLinkColor)
|
||||
.linkLineDash((link: GraphLink) => {
|
||||
if (link.edgeType === "similarity") return [4, 2]
|
||||
return null as unknown as number[]
|
||||
})
|
||||
.linkDirectionalArrowLength((link: GraphLink) =>
|
||||
link.edgeType === "version" ? 4 : 0,
|
||||
)
|
||||
.linkDirectionalArrowRelPos(1)
|
||||
.onNodeClick(handleNodeClick)
|
||||
.onBackgroundClick(() => hidePopup())
|
||||
.d3Force(
|
||||
"charge",
|
||||
forceManyBody().strength((node: GraphNode) =>
|
||||
node.nodeType === "document" ? -15 : -200,
|
||||
),
|
||||
)
|
||||
.d3Force(
|
||||
"link",
|
||||
forceLink()
|
||||
.distance((l: GraphLink) => (l.edgeType === "doc-memory" ? 40 : 80))
|
||||
.strength((l: GraphLink) => {
|
||||
if (l.edgeType === "doc-memory") return 0.8
|
||||
if (l.edgeType === "version") return 1.0
|
||||
return (l.similarity || 0.3) * 0.3
|
||||
}),
|
||||
)
|
||||
.d3Force("collide", forceCollide(18))
|
||||
.d3Force("center", forceCenter())
|
||||
.d3Force("bound", forceRadial(60).strength(0.3))
|
||||
.d3VelocityDecay(0.4)
|
||||
.warmupTicks(50)
|
||||
.cooldownTime(3000)
|
||||
|
||||
// =============================================================================
|
||||
// Resize
|
||||
// =============================================================================
|
||||
function handleResize() {
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
graph.width(width).height(height)
|
||||
}
|
||||
window.addEventListener("resize", handleResize)
|
||||
handleResize()
|
||||
|
||||
// =============================================================================
|
||||
// Popup
|
||||
// =============================================================================
|
||||
function handleNodeClick(node: GraphNode, event: MouseEvent) {
|
||||
if (selectedNode?.id === node.id) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
selectedNode = node
|
||||
showPopup(node, event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
function showPopup(node: GraphNode, x: number, y: number) {
|
||||
if (node.nodeType === "document") {
|
||||
const doc = node as DocumentNode
|
||||
popupType.textContent = "Document"
|
||||
popupType.className = "document"
|
||||
popupTitle.textContent = doc.title
|
||||
popupContent.textContent = doc.summary || "No summary available"
|
||||
popupMeta.textContent = `${doc.memoryCount} memories \u00b7 ${doc.docType} \u00b7 ${new Date(doc.createdAt).toLocaleDateString()}`
|
||||
} else {
|
||||
const mem = node as MemoryNode
|
||||
const typeLabel = mem.isForgotten
|
||||
? "Forgotten"
|
||||
: mem.isLatest
|
||||
? "Latest"
|
||||
: `v${mem.version}`
|
||||
popupType.textContent = typeLabel
|
||||
popupType.className = `memory${mem.isForgotten ? " forgotten" : mem.isLatest ? " latest" : ""}`
|
||||
popupTitle.textContent =
|
||||
mem.memory.length > 120 ? `${mem.memory.slice(0, 120)}...` : mem.memory
|
||||
popupContent.textContent = mem.memory.length > 120 ? mem.memory : ""
|
||||
|
||||
const statusParts: string[] = [`Version ${mem.version}`]
|
||||
if (mem.isForgotten) statusParts.push("Forgotten")
|
||||
else if (mem.forgetAfter)
|
||||
statusParts.push(
|
||||
`Expires ${new Date(mem.forgetAfter).toLocaleDateString()}`,
|
||||
)
|
||||
statusParts.push(new Date(mem.createdAt).toLocaleDateString())
|
||||
popupMeta.textContent = statusParts.join(" \u00b7 ")
|
||||
}
|
||||
|
||||
popup.style.display = "block"
|
||||
|
||||
const rect = popup.getBoundingClientRect()
|
||||
const gap = 15
|
||||
const left = x < window.innerWidth / 2 ? x + gap : x - rect.width - gap
|
||||
const top = y < window.innerHeight / 2 ? y + gap : y - rect.height - gap
|
||||
popup.style.left = `${Math.max(8, left)}px`
|
||||
popup.style.top = `${Math.max(8, top)}px`
|
||||
}
|
||||
|
||||
function hidePopup() {
|
||||
popup.style.display = "none"
|
||||
selectedNode = null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Controls
|
||||
// =============================================================================
|
||||
const ZOOM_FACTOR = 1.5
|
||||
zoomInBtn.addEventListener("click", () =>
|
||||
graph.zoom(graph.zoom() * ZOOM_FACTOR, 200),
|
||||
)
|
||||
zoomOutBtn.addEventListener("click", () =>
|
||||
graph.zoom(graph.zoom() / ZOOM_FACTOR, 200),
|
||||
)
|
||||
fitBtn.addEventListener("click", () => graph.zoomToFit(400, 40))
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") hidePopup()
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Theme
|
||||
// =============================================================================
|
||||
function applyTheme(theme: "light" | "dark") {
|
||||
isDark = theme === "dark"
|
||||
document.documentElement.setAttribute("data-theme", theme)
|
||||
graph.backgroundColor(isDark ? "#0f1419" : "#ffffff")
|
||||
}
|
||||
|
||||
// Detect system theme
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
applyTheme(prefersDark.matches ? "dark" : "light")
|
||||
prefersDark.addEventListener("change", (e) =>
|
||||
applyTheme(e.matches ? "dark" : "light"),
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// MCP App SDK
|
||||
// =============================================================================
|
||||
const app = new App({ name: "Memory Graph", version: "1.0.0" })
|
||||
|
||||
app.ontoolinput = () => {
|
||||
loadingEl.style.display = "flex"
|
||||
statsEl.textContent = "Loading graph data..."
|
||||
}
|
||||
|
||||
app.ontoolresult = (result: CallToolResult) => {
|
||||
loadingEl.style.display = "none"
|
||||
|
||||
if (result.isError) {
|
||||
statsEl.textContent = "Error loading graph"
|
||||
return
|
||||
}
|
||||
|
||||
const data = result.structuredContent as unknown as ToolResultData
|
||||
if (!data?.documents) {
|
||||
statsEl.textContent = "No graph data available"
|
||||
return
|
||||
}
|
||||
|
||||
const { nodes, links } = transformData(data)
|
||||
const memCount = nodes.filter((n) => n.nodeType === "memory").length
|
||||
const docCount = nodes.filter((n) => n.nodeType === "document").length
|
||||
|
||||
statsEl.textContent = `${docCount} docs \u00b7 ${memCount} memories \u00b7 ${links.length} connections`
|
||||
|
||||
graph.graphData({ nodes, links })
|
||||
|
||||
// Fit to view after layout stabilizes
|
||||
setTimeout(() => graph.zoomToFit(400, 40), 600)
|
||||
}
|
||||
|
||||
app.ontoolcancelled = () => {
|
||||
loadingEl.style.display = "none"
|
||||
statsEl.textContent = "Cancelled"
|
||||
}
|
||||
|
||||
function handleHostContext(ctx: McpUiHostContext) {
|
||||
if (ctx.theme) {
|
||||
applyDocumentTheme(ctx.theme)
|
||||
applyTheme(ctx.theme)
|
||||
}
|
||||
if (ctx.styles?.variables) {
|
||||
applyHostStyleVariables(ctx.styles.variables)
|
||||
}
|
||||
if (ctx.styles?.css?.fonts) {
|
||||
applyHostFonts(ctx.styles.css.fonts)
|
||||
}
|
||||
if (ctx.safeAreaInsets) {
|
||||
const { top, right, bottom, left } = ctx.safeAreaInsets
|
||||
document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`
|
||||
}
|
||||
}
|
||||
|
||||
app.onhostcontextchanged = handleHostContext
|
||||
|
||||
app.onteardown = async () => ({})
|
||||
|
||||
app.onerror = console.error
|
||||
|
||||
// Connect to host
|
||||
app.connect().then(() => {
|
||||
const ctx = app.getHostContext()
|
||||
if (ctx) handleHostContext(ctx)
|
||||
})
|
||||
13
apps/mcp/vite.config.ts
Normal file
13
apps/mcp/vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from "vite"
|
||||
import { viteSingleFile } from "vite-plugin-singlefile"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [viteSingleFile()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: "mcp-app.html",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "supermemory-mcp",
|
||||
"build": {
|
||||
"command": "bun run build:ui"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-01-01",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
|
||||
"rules": [{ "type": "Text", "globs": ["**/*.html"], "fallthrough": false }],
|
||||
|
||||
"vars": {
|
||||
"API_URL": "https://api.supermemory.ai"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -415,7 +415,7 @@ export default function LoginPage() {
|
|||
"aria-invalid": error ? "true" : "false",
|
||||
disabled: isLoading,
|
||||
id: "email",
|
||||
onChange: (e) => {
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value)
|
||||
error && setError(null)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@
|
|||
"motion": "^12.23.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vanilla-extract/vite-plugin": "^5.1.1",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { cn } from "@lib/utils";
|
||||
import { Button } from "@ui/components/button";
|
||||
|
||||
interface ExternalAuthButtonProps extends React.ComponentProps<typeof Button> {
|
||||
export interface ExternalAuthButtonProps extends React.ComponentProps<"button"> {
|
||||
authProvider: string;
|
||||
authIcon: React.ReactNode;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue