mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 12:39:52 +08:00
feat(mcp-server): canvas shell bundled from the web app's canvas with esbuild
The shell (packages/mcp-server/shell) renders DrawioFrame inside the shared providers without the chat: its config comes from window.__MCP_CONFIG__, the four dictionaries ship in the bundle, the theme is kept under an mcp: localStorage key, and system fonts stand in for the web fonts. scripts/build-shell.mjs bundles it into dist/shell with the root's esbuild and Tailwind; check-package caps shell.js at 1.5 MB and shell.css at 300 KB.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
import { CanvasStage } from "@/components/canvas/canvas-stage"
|
||||
import { CompareDialog } from "@/components/canvas/compare-dialog"
|
||||
import { VersionStrip } from "@/components/canvas/version-strip"
|
||||
import {
|
||||
VersionsProvider,
|
||||
type VersionsSource,
|
||||
} from "@/components/canvas/versions-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { DrawioSource } from "@/lib/drawio/drawio-config"
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
import type { ShellConfig } from "./runtime-config"
|
||||
import { setDark } from "./theme"
|
||||
|
||||
// No versions until the shell reads the server's History (a later step)
|
||||
const NO_VERSIONS: VersionsSource = {
|
||||
versions: [],
|
||||
onCanvasId: null,
|
||||
undoneId: null,
|
||||
isBusy: false,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
restore: () => {},
|
||||
undo: () => {},
|
||||
redo: () => {},
|
||||
}
|
||||
|
||||
/**
|
||||
* The MCP preview: the web app's canvas without the chat. A slim bar names
|
||||
* the session and the sync state; the versions strip and Compare come from
|
||||
* the shared canvas components.
|
||||
*/
|
||||
export function CanvasShell({ config }: { config: ShellConfig }) {
|
||||
const dict = useDictionary()
|
||||
const isDark = useSettingsStore((s) => s.isDark)
|
||||
const drawioSource = useMemo<DrawioSource>(
|
||||
() => ({
|
||||
editorUrl: config.drawioBaseUrl,
|
||||
ui: config.drawioUi || undefined,
|
||||
lang: config.drawioLang || undefined,
|
||||
}),
|
||||
[config],
|
||||
)
|
||||
|
||||
return (
|
||||
<VersionsProvider value={NO_VERSIONS}>
|
||||
<div className="flex h-dvh flex-col bg-background text-foreground">
|
||||
<header className="flex h-11 shrink-0 items-center gap-3 border-b border-border bg-surface-0 px-3.5">
|
||||
<span className="text-[13px] font-semibold tracking-[-0.01em]">
|
||||
Next AI Draw.io
|
||||
</span>
|
||||
{config.sessionId && (
|
||||
<span
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground tabular-nums"
|
||||
title={config.sessionId}
|
||||
data-testid="shell-session"
|
||||
>
|
||||
{config.sessionId.slice(-8)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
data-testid="shell-status"
|
||||
>
|
||||
{dict.shell.waiting}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDark(!isDark)}
|
||||
aria-label={dict.shell.toggleTheme}
|
||||
title={dict.shell.toggleTheme}
|
||||
className="ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
|
||||
data-testid="theme-toggle"
|
||||
>
|
||||
{isDark ? <Sun /> : <Moon />}
|
||||
</button>
|
||||
</header>
|
||||
<VersionStrip minVersions={1} />
|
||||
<div className="relative flex-1">
|
||||
<CanvasStage
|
||||
className="inset-0"
|
||||
drawioSource={drawioSource}
|
||||
/>
|
||||
</div>
|
||||
<CompareDialog />
|
||||
</div>
|
||||
</VersionsProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import type { Dictionary } from "@/lib/i18n/dictionaries"
|
||||
import en from "@/lib/i18n/dictionaries/en.json"
|
||||
import ja from "@/lib/i18n/dictionaries/ja.json"
|
||||
import zh from "@/lib/i18n/dictionaries/zh.json"
|
||||
import zhHant from "@/lib/i18n/dictionaries/zh-Hant.json"
|
||||
|
||||
// The web app loads one dictionary on the server (lib/i18n/dictionaries.ts
|
||||
// is server-only); the shell ships all four and picks in the browser
|
||||
const DICTIONARIES: Record<Locale, Dictionary> = {
|
||||
en,
|
||||
zh,
|
||||
ja,
|
||||
"zh-Hant": zhHant,
|
||||
}
|
||||
|
||||
/** Our locale for a language tag ("zh-TW", "ja-JP", "en-US"), or null */
|
||||
export function matchLocale(tag: string): Locale | null {
|
||||
const lower = tag.trim().toLowerCase()
|
||||
if (!lower) return null
|
||||
if (/^zh-(hant|tw|hk|mo)\b/.test(lower)) return "zh-Hant"
|
||||
const base = lower.split("-")[0]
|
||||
if (base === "zh") return "zh"
|
||||
if (base === "ja") return "ja"
|
||||
if (base === "en") return "en"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's language: the server's DRAWIO_LANG when it is one of ours,
|
||||
* else the browser's first language we have, else English.
|
||||
*/
|
||||
export function pickLocale(
|
||||
configLang: string,
|
||||
browserLangs: readonly string[],
|
||||
): Locale {
|
||||
for (const tag of [configLang, ...browserLangs]) {
|
||||
const locale = matchLocale(tag)
|
||||
if (locale) return locale
|
||||
}
|
||||
return i18n.defaultLocale
|
||||
}
|
||||
|
||||
export function getShellDictionary(locale: Locale): Dictionary {
|
||||
return DICTIONARIES[locale]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Next AI Draw.io</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/shell/shell.css">
|
||||
<script>window.__MCP_CONFIG__ = {{CONFIG_JSON}};</script>
|
||||
<script>
|
||||
// Dark class before the first paint (no white flash). The same rule
|
||||
// as shell/theme.ts: the choice stored under "mcp:dark-mode", else
|
||||
// the server's DRAWIO_DARK, else the system
|
||||
try {
|
||||
const config = window.__MCP_CONFIG__ || {}
|
||||
const stored = localStorage.getItem("mcp:dark-mode")
|
||||
const dark =
|
||||
stored === "true" ||
|
||||
(stored === null &&
|
||||
(config.drawioDark === "dark" ||
|
||||
(config.drawioDark !== "light" &&
|
||||
matchMedia("(prefers-color-scheme: dark)").matches)))
|
||||
if (dark) document.documentElement.classList.add("dark")
|
||||
} catch {}
|
||||
</script>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/shell/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
// Entry of the MCP's canvas shell, bundled by scripts/build-shell.mjs into
|
||||
// dist/shell/shell.js and served at /shell/ by http-server.ts
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { LocaleProvider } from "@/components/canvas/locale-context"
|
||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||
import { CanvasShell } from "./canvas-shell"
|
||||
import { getShellDictionary, pickLocale } from "./dictionary"
|
||||
import { pageShellConfig } from "./runtime-config"
|
||||
import { initTheme } from "./theme"
|
||||
|
||||
const config = pageShellConfig()
|
||||
const locale = pickLocale(config.lang, navigator.languages)
|
||||
// The stylesheet picks the CJK fonts by the document's language
|
||||
document.documentElement.lang = locale
|
||||
initTheme(config.drawioDark)
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (!root) throw new Error("The shell page has no #root element")
|
||||
createRoot(root).render(
|
||||
<DictionaryProvider dictionary={getShellDictionary(locale)}>
|
||||
<LocaleProvider locale={locale}>
|
||||
<DiagramProvider>
|
||||
<CanvasShell config={config} />
|
||||
</DiagramProvider>
|
||||
</LocaleProvider>
|
||||
</DictionaryProvider>,
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* What the MCP server writes into the shell page as window.__MCP_CONFIG__
|
||||
* (http-server.ts fills the {{CONFIG_JSON}} placeholder of index.html).
|
||||
*/
|
||||
export interface ShellConfig {
|
||||
sessionId: string
|
||||
/** Every /api request carries it in the X-Drawio-Token header */
|
||||
token: string
|
||||
/** Prefix of the server's API paths */
|
||||
apiBase: string
|
||||
/** The editor page: the bundled copy's index.html on this origin, or an
|
||||
* external draw.io */
|
||||
drawioBaseUrl: string
|
||||
/** draw.io theme fixed by DRAWIO_UI; "" leaves the pick to draw.io */
|
||||
drawioUi: string
|
||||
/** Editor language fixed by DRAWIO_LANG, in draw.io's naming; "" leaves
|
||||
* the pick to draw.io */
|
||||
drawioLang: string
|
||||
/** DRAWIO_DARK: the theme when the user has not chosen one here */
|
||||
drawioDark: "dark" | "light" | "auto"
|
||||
/** DRAWIO_LANG as given, for the shell's own texts */
|
||||
lang: string
|
||||
}
|
||||
|
||||
const DEFAULTS: ShellConfig = {
|
||||
sessionId: "",
|
||||
token: "",
|
||||
apiBase: "/api",
|
||||
drawioBaseUrl: "/drawio/index.html",
|
||||
drawioUi: "",
|
||||
drawioLang: "",
|
||||
drawioDark: "auto",
|
||||
lang: "",
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's config with every field present: a missing or malformed one
|
||||
* (another server version) takes its default.
|
||||
*/
|
||||
export function readShellConfig(source: unknown): ShellConfig {
|
||||
const given =
|
||||
source && typeof source === "object"
|
||||
? (source as Record<string, unknown>)
|
||||
: {}
|
||||
const config = { ...DEFAULTS }
|
||||
for (const key of Object.keys(DEFAULTS) as (keyof ShellConfig)[]) {
|
||||
const value = given[key]
|
||||
if (key === "drawioDark") {
|
||||
if (value === "dark" || value === "light" || value === "auto") {
|
||||
config.drawioDark = value
|
||||
}
|
||||
} else if (typeof value === "string") {
|
||||
config[key] = value
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
/** The config the server wrote into this page */
|
||||
export function pageShellConfig(): ShellConfig {
|
||||
return readShellConfig(
|
||||
(window as { __MCP_CONFIG__?: unknown }).__MCP_CONFIG__,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Stylesheet of the MCP's canvas shell: the web app's theme and Tailwind,
|
||||
* with the utility classes of the shared components the shell bundles.
|
||||
* Built by scripts/build-shell.mjs; Tailwind scans this directory on its
|
||||
* own, and the shared components through the @source lines.
|
||||
*/
|
||||
@import "../../../app/globals.css";
|
||||
@source "../../../components/canvas";
|
||||
@source "../../../components/ui";
|
||||
@source "../../../components/ai-elements";
|
||||
|
||||
/* System fonts stand in for the web fonts the Next.js layout loads */
|
||||
:root {
|
||||
--font-latin:
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
import type { ShellConfig } from "./runtime-config"
|
||||
|
||||
/**
|
||||
* localStorage key of the user's theme choice here: "true" dark, "false"
|
||||
* light, none: the server's DRAWIO_DARK, else the system. Its own prefix,
|
||||
* so a web app dev server on the same port keeps its own settings.
|
||||
* index.html repeats the key in its inline script (run before this bundle).
|
||||
*/
|
||||
export const THEME_STORAGE_KEY = "mcp:dark-mode"
|
||||
|
||||
/** Whether the shell is dark, from the choice stored here, the server's
|
||||
* setting and the system */
|
||||
export function isDarkPreferred(
|
||||
stored: string | null,
|
||||
drawioDark: ShellConfig["drawioDark"],
|
||||
systemDark: boolean,
|
||||
): boolean {
|
||||
if (stored === "true" || stored === "false") return stored === "true"
|
||||
if (drawioDark === "auto") return systemDark
|
||||
return drawioDark === "dark"
|
||||
}
|
||||
|
||||
function read(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(THEME_STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// The canvas components read the settings store's isDark (and the theme
|
||||
// class, set before the first paint by index.html)
|
||||
function apply(isDark: boolean) {
|
||||
document.documentElement.classList.toggle("dark", isDark)
|
||||
useSettingsStore.setState({
|
||||
hydrated: true,
|
||||
isDark,
|
||||
theme: isDark ? "dark" : "light",
|
||||
})
|
||||
}
|
||||
|
||||
/** Take the theme the inline script chose, and follow the system while
|
||||
* nothing else decides */
|
||||
export function initTheme(drawioDark: ShellConfig["drawioDark"]) {
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
apply(isDarkPreferred(read(), drawioDark, query.matches))
|
||||
query.addEventListener("change", (event) => {
|
||||
apply(isDarkPreferred(read(), drawioDark, event.matches))
|
||||
})
|
||||
}
|
||||
|
||||
/** The user's choice, kept for the next visit */
|
||||
export function setDark(isDark: boolean) {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, String(isDark))
|
||||
} catch {
|
||||
// Storage can be unavailable (private mode, quota)
|
||||
}
|
||||
apply(isDark)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"incremental": false,
|
||||
"plugins": []
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user