mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix chatgpt memory graph rendering (#1393)
ChatGPT can omit nullable nested fields from structured tool results and does not reliably expose result metadata to MCP Apps. Keep graph data in `structuredContent`, tolerate missing document titles, and bundle React inside the widget so the graph renders consistently across hosts. - use the deployable `app-v4` resource URI - keep the graph response and widget schema aligned - remove the `esm.sh` runtime dependency Tested with typecheck, 42 unit tests, production widget build, 36 branch-local authenticated E2E tests, and a live ChatGPT graph render.
This commit is contained in:
parent
9e194fbc50
commit
48fb1969f8
10 changed files with 26 additions and 91 deletions
|
|
@ -82,7 +82,7 @@ These tools are available to the embedded MCP App and hidden from the model.
|
|||
| --- | --- | --- |
|
||||
| Resource | `supermemory://profile` | Profile facts in the effective space |
|
||||
| Resource | `supermemory://spaces` | Visible spaces |
|
||||
| Resource | `ui://supermemory/app-v3.html` | Embedded MCP App bundle |
|
||||
| Resource | `ui://supermemory/app-v4.html` | Embedded MCP App bundle |
|
||||
| Prompt | `context` | Profile and recent context for an optional space |
|
||||
|
||||
The App resource and tool metadata include both current nested `ui` metadata and
|
||||
|
|
|
|||
|
|
@ -19,16 +19,14 @@ describeWithAuth("MCP — graph, resources & prompts", () => {
|
|||
await s?.close()
|
||||
})
|
||||
|
||||
it("memory-graph returns a rendered widget summary", async () => {
|
||||
it("memory-graph returns a rendered graph widget", async () => {
|
||||
const res = await callTool(s.client, "memory-graph")
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(textOf(res)).toMatch(
|
||||
/The interactive Memory Graph MCP App is rendered and visible: \d+ documents/,
|
||||
)
|
||||
expect(textOf(res)).toMatch(/Rendered the interactive Memory Graph MCP App/)
|
||||
const result = graphViewSchema.safeParse(res.structuredContent)
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) throw result.error
|
||||
expect(result.data.rendered).toBe(true)
|
||||
expect(result.data.view).toBe("graph")
|
||||
})
|
||||
|
||||
it("fetch-graph-data returns paginated documents", async () => {
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@ import { SUPERMEMORY_RESOURCE_URI } from "../../shared/types"
|
|||
import { APP_RESOURCE_MIME_TYPE } from "../app-metadata"
|
||||
|
||||
const CSP_DOMAINS = [
|
||||
"https://esm.sh",
|
||||
"https://fonts.googleapis.com",
|
||||
"https://fonts.gstatic.com",
|
||||
]
|
||||
|
||||
const WIDGET_DESCRIPTION =
|
||||
"Interactive Supermemory view. The tool result identifies whether the mounted app is a memory graph, space picker, save form, upload form, or confirmation. When rendered is true, the interface is already visible to the user."
|
||||
"Interactive Supermemory view for memory graphs, space selection, guided saves, file uploads, and confirmations."
|
||||
|
||||
const RESOURCE_UI_META = {
|
||||
prefersBorder: true,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,12 @@ export function register(deps: ToolDeps) {
|
|||
)
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(data) }],
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Loaded ${data.documents.length} documents for the memory graph.`,
|
||||
},
|
||||
],
|
||||
structuredContent: data,
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -27,37 +27,28 @@ export function register(deps: ToolDeps) {
|
|||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
const containerTags = effectiveTag ? [effectiveTag] : undefined
|
||||
|
||||
const result = await client.getDocuments(containerTags, 1, 200)
|
||||
|
||||
const memoryCount = result.documents.reduce(
|
||||
(sum, d) => sum + d.memoryEntries.length,
|
||||
(sum, document) => sum + document.memoryEntries.length,
|
||||
0,
|
||||
)
|
||||
|
||||
const sc: ViewMessage = {
|
||||
view: "graph",
|
||||
viewId,
|
||||
...(effectiveTag ? { containerTag: effectiveTag } : {}),
|
||||
documentCount: result.documents.length,
|
||||
memoryCount,
|
||||
totalDocumentCount: result.pagination.totalItems,
|
||||
truncated: result.documents.length < result.pagination.totalItems,
|
||||
rendered: true,
|
||||
documents: result.documents,
|
||||
totalCount: result.pagination.totalItems,
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `The interactive Memory Graph MCP App is rendered and visible: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Space: ${effectiveTag}` : ""}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`,
|
||||
text: `Rendered the interactive Memory Graph MCP App: ${result.documents.length} documents, ${memoryCount} memories${effectiveTag ? `. Space: ${effectiveTag}` : ""}. Do not create a duplicate graph or artifact unless the user explicitly requests one.`,
|
||||
},
|
||||
],
|
||||
structuredContent: sc,
|
||||
_meta: {
|
||||
...appResultMeta(viewId),
|
||||
graphData: { documents: result.documents },
|
||||
},
|
||||
_meta: appResultMeta(viewId),
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export type DocumentMemoryEntry = z.infer<typeof documentMemoryEntrySchema>
|
|||
|
||||
export const documentWithMemoriesSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
title: z.string().nullable(),
|
||||
title: z.string().nullish(),
|
||||
summary: z.string().nullish(),
|
||||
type: z.string(),
|
||||
createdAt: z.string(),
|
||||
|
|
@ -169,11 +169,8 @@ export const graphViewSchema = z.object({
|
|||
view: z.literal("graph"),
|
||||
viewId: viewIdSchema,
|
||||
containerTag: z.string().optional(),
|
||||
documentCount: z.number().int().nonnegative(),
|
||||
memoryCount: z.number().int().nonnegative(),
|
||||
totalDocumentCount: z.number().int().nonnegative(),
|
||||
truncated: z.boolean(),
|
||||
rendered: z.literal(true),
|
||||
documents: z.array(documentWithMemoriesSchema),
|
||||
totalCount: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
export const viewMessageSchema = z.discriminatedUnion("view", [
|
||||
|
|
@ -188,15 +185,7 @@ export const viewMessageSchema = z.discriminatedUnion("view", [
|
|||
|
||||
export type ViewMessage = z.infer<typeof viewMessageSchema>
|
||||
|
||||
export const graphResultMetaSchema = z.looseObject({
|
||||
graphData: z.object({
|
||||
documents: z.array(documentWithMemoriesSchema),
|
||||
}),
|
||||
})
|
||||
|
||||
export type GraphResultMeta = z.infer<typeof graphResultMetaSchema>
|
||||
|
||||
export type ViewName = ViewMessage["view"]
|
||||
|
||||
// Hosts cache MCP UI resources by URI, so bump this when shipping a new widget bundle.
|
||||
export const SUPERMEMORY_RESOURCE_URI = "ui://supermemory/app-v3.html"
|
||||
export const SUPERMEMORY_RESOURCE_URI = "ui://supermemory/app-v4.html"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type ReactNode, useEffect } from "react"
|
||||
import type { GraphResultMeta, ViewMessage } from "../shared/types"
|
||||
import type { ViewMessage } from "../shared/types"
|
||||
import { useApplyHostTheme } from "./hooks/useApplyHostTheme"
|
||||
import { useLog } from "./hooks/useLog"
|
||||
import { useViewState } from "./hooks/useViewState"
|
||||
|
|
@ -50,7 +50,7 @@ export function App() {
|
|||
const isGraphView = state.message.view === "graph"
|
||||
return (
|
||||
<WidgetShell immersive={isGraphView}>
|
||||
{renderView(state.message, state.resultMeta, setView, setError)}
|
||||
{renderView(state.message, setView, setError)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -93,7 +93,6 @@ export function WidgetShell({
|
|||
|
||||
function renderView(
|
||||
msg: ViewMessage,
|
||||
resultMeta: GraphResultMeta | undefined,
|
||||
setView: (m: ViewMessage) => void,
|
||||
setError: (m: string) => void,
|
||||
) {
|
||||
|
|
@ -131,16 +130,7 @@ function renderView(
|
|||
/>
|
||||
)
|
||||
case "graph":
|
||||
if (!resultMeta) {
|
||||
return <ErrorView message="Memory graph data is unavailable." />
|
||||
}
|
||||
return (
|
||||
<Graph
|
||||
containerTag={msg.containerTag}
|
||||
documents={resultMeta.graphData.documents}
|
||||
totalCount={msg.totalDocumentCount}
|
||||
/>
|
||||
)
|
||||
return <Graph documents={msg.documents} totalCount={msg.totalCount} />
|
||||
case "confirmation":
|
||||
return <Confirmation containerTag={msg.containerTag} />
|
||||
case "save-success":
|
||||
|
|
|
|||
|
|
@ -10,17 +10,12 @@ import {
|
|||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import {
|
||||
graphResultMetaSchema,
|
||||
type GraphResultMeta,
|
||||
viewMessageSchema,
|
||||
type ViewMessage,
|
||||
} from "../shared/types"
|
||||
import { viewMessageSchema, type ViewMessage } from "../shared/types"
|
||||
import { loadViewCheckpoint, saveViewCheckpoint } from "./lib/viewCheckpoint"
|
||||
|
||||
export type ViewState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "view"; message: ViewMessage; resultMeta?: GraphResultMeta }
|
||||
| { kind: "view"; message: ViewMessage }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "raw"; structuredContent: unknown }
|
||||
|
||||
|
|
@ -95,23 +90,6 @@ export function McpAppProvider({ children }: { children: ReactNode }) {
|
|||
return
|
||||
}
|
||||
|
||||
if (message.view === "graph") {
|
||||
const parsedMeta = graphResultMetaSchema.safeParse(result._meta)
|
||||
if (!parsedMeta.success) {
|
||||
setState({
|
||||
kind: "error",
|
||||
message: "Memory graph data is unavailable.",
|
||||
})
|
||||
return
|
||||
}
|
||||
setState({
|
||||
kind: "view",
|
||||
message,
|
||||
resultMeta: parsedMeta.data,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState({ kind: "view", message })
|
||||
}
|
||||
createdApp.onhostcontextchanged = (next) => {
|
||||
|
|
|
|||
|
|
@ -83,7 +83,6 @@ function useGraphColors(theme: string): GraphThemeColors {
|
|||
interface Props {
|
||||
documents: DocumentWithMemories[]
|
||||
totalCount: number
|
||||
containerTag?: string
|
||||
}
|
||||
|
||||
// Map the widget's API shape (DocumentWithMemories) into the package's
|
||||
|
|
@ -115,7 +114,7 @@ function toGraphMemory(mem: DocumentMemoryEntry): GraphApiMemory {
|
|||
function toGraphDocument(doc: DocumentWithMemories): GraphApiDocument {
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
title: doc.title ?? null,
|
||||
summary: doc.summary ?? null,
|
||||
documentType: doc.type,
|
||||
createdAt: doc.createdAt,
|
||||
|
|
|
|||
|
|
@ -3,11 +3,6 @@ import react from "@vitejs/plugin-react"
|
|||
import { defineConfig } from "vite"
|
||||
import { viteSingleFile } from "vite-plugin-singlefile"
|
||||
|
||||
// PROD config: single-file HTML bundle for the MCP widget resource.
|
||||
// React/React-DOM are externalized to esm.sh — keeps the bundle small and
|
||||
// makes the host fetch them from a CDN. The widget resource declares
|
||||
// `_meta.ui.csp.resourceDomains: ["https://esm.sh"]` so the host allows it.
|
||||
// Tailwind 4 generates CSS at build time and is inlined by viteSingleFile.
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), react(), viteSingleFile()],
|
||||
build: {
|
||||
|
|
@ -15,15 +10,6 @@ export default defineConfig({
|
|||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: "src/widget/index.html",
|
||||
external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime"],
|
||||
output: {
|
||||
paths: {
|
||||
react: "https://esm.sh/react@19.2.4",
|
||||
"react-dom": "https://esm.sh/react-dom@19.2.4",
|
||||
"react-dom/client": "https://esm.sh/react-dom@19.2.4/client",
|
||||
"react/jsx-runtime": "https://esm.sh/react@19.2.4/jsx-runtime",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue