Compare commits

..

24 Commits

Author SHA1 Message Date
dayuan.jiang
2871265362 docs(mcp): add version history feature to README 2025-12-21 18:07:29 +09:00
dayuan.jiang
9d13bd7451 chore(mcp): bump version to 0.1.4 2025-12-21 18:05:44 +09:00
dayuan.jiang
b97f3ccda9 fix(mcp): minimal history integration in index.ts
Keep only essential history integration:
- Import addHistory from history.js
- Remove unused getServerPort import
- Add browser state sync and history saving in display_diagram
- Add history saving in edit_diagram

No changes to prompts, descriptions, or code style.
2025-12-21 17:41:27 +09:00
dayuan.jiang
864375b8e4 fix(mcp): capture SVG for AI-generated diagrams
- Sync browser state before saving history in display_diagram
- Save AI result to history (in addition to state before)
- Add SVG capture after browser loads AI diagrams
- Add /api/history-svg endpoint to update last entry's SVG
- Add updateLastHistorySvg() function to history module
2025-12-21 17:31:06 +09:00
dayuan.jiang
b9bc2a72c6 refactor(mcp): simplify history implementation
- Reduce history.ts from 169 to 51 lines
- Remove AI tools (list_history, restore_version, get_version)
- Remove /api/update-svg endpoint
- Remove 10-second history polling
- Simplify HistoryEntry to just {xml, svg}
- Use array index instead of version numbers

Total reduction: 1936 → 923 lines (-52%)
2025-12-21 16:11:49 +09:00
dayuan.jiang
c215d80688 feat(mcp): add diagram version history
- Add history.ts module with circular buffer (max 50 entries)
- Add history UI with floating button and modal
- Add HTTP endpoints: /api/history, /api/restore
- Add MCP tools: list_history, restore_version, get_version
- Save history before and after AI changes
- Track source (ai/human) for each entry
2025-12-21 16:09:14 +09:00
Dayuan Jiang
74b9e38114 chore: bump version to 0.4.4 (#338)
* chore: bump version to 0.4.4

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-21 01:01:59 +09:00
Dayuan Jiang
68ea4958b8 feat: display app version in Settings dialog (#337) 2025-12-21 00:55:54 +09:00
Dayuan Jiang
938faff6b2 feat(mcp): add XML validation and auto-fix to MCP server (#336)
* feat(mcp): add XML validation and auto-fix to MCP server

- Add xml-validation.ts with validateAndFixXml function
- Integrate validation into display_diagram tool (fails if unfixable)
- Integrate validation into edit_diagram tool (auto-fix each operation)
- Fix bug: typo fixes now run before foreign tag removal
- Fix bug: use before/after comparison instead of regex .test()

* style: auto-format with Biome

* chore(mcp): bump version to 0.1.3

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-21 00:32:51 +09:00
Biki Kalita
378bef435e Add i18n support, language toggle UI, and translate Settings dialog (#334)
* i18n support added

* fix: align i18n implementation with Next.js 16 guide

- Rename middleware.ts to proxy.ts (Next.js 16 convention)
- Fix params type to Promise<{lang: string}> for layout/metadata
- Add 'server-only' directive and dynamic imports to dictionaries.ts
- Add hasLocale type guard and notFound() for invalid locales
- Wrap LanguageToggle in Suspense for useSearchParams
- Fix dictionary key mismatch (learnmore -> learnMore)
- Improve Chinese translations per Gemini review:
  - loading ellipsis, new -> 新建, styledMode -> 精致
  - goodResponse/badResponse -> 有帮助/无帮助
  - closeProtection -> 关闭确认, fileExceeds phrasing
- Improve Japanese translations per Gemini review:
  - closeProtection -> ページ離脱確認
  - invalidAccessCode phrasing, appendDiagram -> に追加
  - styledMode -> スタイル付き

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-20 14:48:54 +00:00
Dayuan Jiang
f087b54ee4 feat: add get_shape_library tool for AI icon discovery (#335)
* feat: add get_shape_library tool for AI icon discovery

- Add server-side tool that returns shape library documentation
- AI can fetch icon/shape names on-demand before generating diagrams
- Includes path traversal protection and input sanitization
- Library index embedded in tool description for discoverability
- Supports 33 libraries: AWS, Azure, GCP, Kubernetes, Cisco, etc.

* fix: improve get_shape_library error handling and imports

- Move fs/path imports to top of file (avoid dynamic imports per call)
- Distinguish file-not-found vs other errors in catch block
- Include invalid input in validation error message
- Log unexpected errors for debugging

* docs: add get_shape_library to system prompt tool list

- Add Tool4 (get_shape_library) to available tools section
- Add usage guidance in 'Choose the right tool' section
- Update AWS icons note to reference get_shape_library for icon discovery

* fix: display get_shape_library tool output in chat UI

* fix: correct state check for get_shape_library output display

* fix: make get_shape_library output respect fold state

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-20 23:19:49 +09:00
Dayuan Jiang
6bb33eeda2 chore: add auto-format workflow and fix formatting (#319)
- Add GitHub Action to auto-format PRs with Biome
- Fix formatting in app/manifest.ts and scripts/test-diagram-operations.mjs
2025-12-18 23:03:08 +09:00
RainX
a91bd9d1e8 feat: add support for custom AI Gateway base URL (#315)
* feat: add support for custom AI Gateway base URL

- Add createGateway support with configurable baseURL
- Allow AI_GATEWAY_BASE_URL environment variable for:
  * Local development with custom Gateway
  * Self-hosted AI Gateway deployments
  * Enterprise proxy configurations
- Maintain backward compatibility: defaults to Vercel Gateway when not set
- Update documentation with usage examples and configuration notes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: remove errant character in error message

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-18 22:00:21 +09:00
E66Crisp
81eb71e704 fix(components): Send and sidebar buttons become inaccessible when chat-panel is resized (#309) 2025-12-18 21:16:32 +09:00
E66Crisp
58b6b19526 fix: Prevent DrawIO remount and data loss when resizing window across 768px breakpoint (#306)
* fix: Prevent DrawIO remount and data loss when resizing window across 768px breakpoint

* fix: prevent DrawIO remount and data loss when resizing window

- Move key from ResizablePanelGroup to chat-panel only
- Save diagram to localStorage before breakpoint change
- Restore defaultSize on drawio-panel to prevent layout flash
- Keep save button functionality from main

* fix: reset draw.io ready state on breakpoint change to restore diagram

* fix: skip initial render save and remove console logs

- Add isInitialRenderRef to skip unnecessary save/reset on first render
- Remove console.log statements for production cleanliness
- Add eslint-disable comment explaining loadDiagram dependency

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2025-12-18 21:14:10 +09:00
Dayuan Jiang
f65ef548b2 fix: make draw.io built-in save button work with mouse tracking (#296)
- Add showSaveDialog state to DiagramContext for shared state
- Add mouse tracking to only respond to save events when mouse is over draw.io panel
- Prevents save dialog from opening when clicking Send in chat panel
- Add DialogDescription to SaveDialog for accessibility
2025-12-17 20:24:53 +09:00
Dayuan Jiang
741a00db89 Revert "fix: make draw.io built-in save button work (#293)" (#294)
This reverts commit bcc6684ecb.
2025-12-17 19:46:52 +09:00
Dayuan Jiang
bcc6684ecb fix: make draw.io built-in save button work (#293)
- Lift showSaveDialog state to DiagramContext for sharing between components
- Add onSave handler to DrawIoEmbed that opens the save dialog
- Add guard (isSavingRef) with 1s delay to prevent repeated save events from draw.io
- Add deprecation notice to custom download button tooltip

Closes #93, Closes #290
2025-12-17 19:14:15 +09:00
Dayuan Jiang
a9415d24e7 Revise preview feature stability note
Updated the preview feature note for stability.
2025-12-17 14:52:39 +09:00
Dayuan Jiang
439bdd4577 feat: add MCP server package for npx distribution (#284)
* feat: add MCP server package for npx distribution

- Self-contained MCP server with embedded HTTP server
- Real-time browser preview via draw.io iframe
- Tools: start_session, display_diagram, edit_diagram, get_diagram, export_diagram
- Port retry limit (6002-6020) and session TTL cleanup (1 hour)
- Published as @next-ai-drawio/mcp-server on npm

* chore: bump version to 0.1.2

* docs: add MCP server section to README (preview feature)

* docs: add multi-client installation instructions for MCP server

* fix: exclude packages from Next.js build

* docs: use @latest instead of -y flag for npx (match Playwright MCP style)

* chore: bump version to 0.4.3 and add release notes

* chore: remove release notes

* feat: add MCP server notice to example panel
2025-12-17 14:50:07 +09:00
Ted Cao
98b890bb06 feat: add Vercel AI Gateway support (#274)
* feat: add Vercel AI Gateway support

- Updated environment configuration to include AI_GATEWAY_API_KEY for unified access to multiple AI providers.
- Added gateway provider to the list of supported AI providers in the codebase.
- Enhanced documentation to explain the usage of Vercel AI Gateway and its model format.

This change simplifies authentication and allows users to switch between providers seamlessly.

* Update package
@ai-sdk/gateway to latest version 2.0.21
2025-12-17 12:43:33 +09:00
Bridget Amana
f039e4a3c8 Feat/add manifest.ts (#270)
* Add manifest file for Next AI Draw.io application

This file defines the manifest for the Next AI Draw.io application, including metadata like name, description, and icons.

* Add different sizes of favicon

* Update app/manifest.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update app/manifest.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Dayuan Jiang <34411969+DayuanJiang@users.noreply.github.com>
2025-12-16 13:38:53 +09:00
Biki Kalita
7857858074 feat: add warning dialog for theme and UI style changes (#248)
## Summary

  - Auto-saves diagram to localStorage before theme or UI style changes to prevent data loss
  - Extracts inline handler to `handleDrawioUiChange` for cleaner code
  - Renames `toggleDarkMode` to `handleDarkModeChange` for consistency

  ## Problem

  Changing themes (dark/light) or draw.io UI styles (min/sketch) causes the DrawIoEmbed component to remount, losing all unsaved edits without warning.

  ## Solution

  Added `saveDiagramToStorage()` function that exports the current diagram and saves it to localStorage before any theme/UI change. The existing restore mechanism then loads it back after remount.

  ## Related Issues

  Fixes #243
2025-12-15 22:40:21 +09:00
dayuan.jiang
f0919117eb fix: lowercase repo name for docker pull in ECR push step 2025-12-15 21:47:32 +09:00
79 changed files with 7620 additions and 687 deletions

47
.github/workflows/auto-format.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Auto Format
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: write
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Biome
run: npm install --save-dev @biomejs/biome
- name: Run Biome format
run: npx @biomejs/biome check --write --no-errors-on-unmatched .
- name: Check for changes
id: changes
run: |
if git diff --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
- name: Commit changes
if: steps.changes.outputs.has_changes == 'true'
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "style: auto-format with Biome"
git push

View File

@@ -80,8 +80,11 @@ jobs:
- name: Push to ECR (triggers App Runner auto-deploy)
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env:
REPO_LOWER: ${{ github.repository }}
run: |
docker pull ghcr.io/${{ github.repository }}:latest
docker tag ghcr.io/${{ github.repository }}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
REPO_LOWER=$(echo "$REPO_LOWER" | tr '[:upper:]' '[:lower:]')
docker pull ghcr.io/${REPO_LOWER}:latest
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest

View File

@@ -95,7 +95,7 @@ Here are some example prompts and their generated diagrams:
## MCP Server (Preview)
> **Preview Feature**: This feature is experimental and may change.
> **Preview Feature**: This feature is experimental and may not stable.
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).

172
app/[lang]/layout.tsx Normal file
View File

@@ -0,0 +1,172 @@
import { GoogleAnalytics } from "@next/third-parties/google"
import type { Metadata, Viewport } from "next"
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
import { notFound } from "next/navigation"
import { DiagramProvider } from "@/contexts/diagram-context"
import { DictionaryProvider } from "@/hooks/use-dictionary"
import type { Locale } from "@/lib/i18n/config"
import { i18n } from "@/lib/i18n/config"
import { getDictionary, hasLocale } from "@/lib/i18n/dictionaries"
import "../globals.css"
const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
})
const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
weight: ["400", "500"],
})
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
// Generate static params for all locales
export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ lang: locale }))
}
// Generate metadata per locale
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>
}): Promise<Metadata> {
const { lang: rawLang } = await params
const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
// Default to English metadata
const titles: Record<Locale, string> = {
en: "Next AI Draw.io - AI-Powered Diagram Generator",
zh: "Next AI Draw.io - AI powered diagram generator",
ja: "Next AI Draw.io - AI-powered diagram generator",
}
const descriptions: Record<Locale, string> = {
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
}
return {
title: titles[lang],
description: descriptions[lang],
keywords: [
"AI diagram generator",
"AWS architecture",
"flowchart creator",
"draw.io",
"AI drawing tool",
"technical diagrams",
"diagram automation",
"free diagram generator",
"online diagram maker",
],
authors: [{ name: "Next AI Draw.io" }],
creator: "Next AI Draw.io",
publisher: "Next AI Draw.io",
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
openGraph: {
title: titles[lang],
description: descriptions[lang],
type: "website",
url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io",
locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
images: [
{
url: "/architecture.png",
width: 1200,
height: 630,
alt: "Next AI Draw.io - AI-powered diagram creation tool",
},
],
},
twitter: {
card: "summary_large_image",
title: titles[lang],
description: descriptions[lang],
images: ["/architecture.png"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
icon: "/favicon.ico",
},
alternates: {
languages: {
en: "/en",
zh: "/zh",
ja: "/ja",
},
},
}
}
export default async function RootLayout({
children,
params,
}: Readonly<{
children: React.ReactNode
params: Promise<{ lang: string }>
}>) {
const { lang } = await params
if (!hasLocale(lang)) notFound()
const validLang = lang as Locale
const dictionary = await getDictionary(validLang)
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "Next AI Draw.io",
applicationCategory: "DesignApplication",
operatingSystem: "Web Browser",
description:
"AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.",
url: "https://next-ai-drawio.jiang.jp",
inLanguage: validLang,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
}
return (
<html lang={validLang} suppressHydrationWarning>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
>
<DictionaryProvider dictionary={dictionary}>
<DiagramProvider>{children}</DiagramProvider>
</DictionaryProvider>
</body>
{process.env.NEXT_PUBLIC_GA_ID && (
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
)}
</html>
)
}

View File

@@ -1,5 +1,5 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel"
@@ -15,8 +15,15 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
useDiagram()
const {
drawioRef,
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
saveDiagramToStorage,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
@@ -25,6 +32,29 @@ export default function Home() {
const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isSavingRef = useRef(false)
const mouseOverDrawioRef = useRef(false)
const isMobileRef = useRef(false)
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
useEffect(() => {
if (!showSaveDialog) {
const timeout = setTimeout(() => {
isSavingRef.current = false
}, 1000)
return () => clearTimeout(timeout)
}
}, [showSaveDialog])
// Handle save from draw.io's built-in save button
// Note: draw.io sends save events for various reasons (focus changes, etc.)
// We use mouse position to determine if the user is interacting with draw.io
const handleDrawioSave = useCallback(() => {
if (!mouseOverDrawioRef.current) return
if (isSavingRef.current) return
isSavingRef.current = true
setShowSaveDialog(true)
}, [setShowSaveDialog])
// Load preferences from localStorage after mount
useEffect(() => {
@@ -35,12 +65,10 @@ export default function Home() {
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
if (savedDarkMode !== null) {
// Use saved preference
const isDark = savedDarkMode === "true"
setDarkMode(isDark)
document.documentElement.classList.toggle("dark", isDark)
} else {
// First visit: match browser preference
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches
@@ -58,25 +86,44 @@ export default function Home() {
setIsLoaded(true)
}, [])
const toggleDarkMode = () => {
const handleDarkModeChange = async () => {
await saveDiagramToStorage()
const newValue = !darkMode
setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue)
// Reset so onDrawioLoad fires again after remount
resetDrawioReady()
}
// Check mobile
const handleDrawioUiChange = async () => {
await saveDiagramToStorage()
const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
resetDrawioReady()
}
// Check mobile - save diagram and reset draw.io before crossing breakpoint
const isInitialRenderRef = useRef(true)
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
const newIsMobile = window.innerWidth < 768
if (
!isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current
) {
saveDiagramToStorage().catch(() => {})
resetDrawioReady()
}
isMobileRef.current = newIsMobile
isInitialRenderRef.current = false
setIsMobile(newIsMobile)
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
}, [saveDiagramToStorage, resetDrawioReady])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
@@ -122,11 +169,9 @@ export default function Home() {
<div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup
id="main-panel-group"
key={isMobile ? "mobile" : "desktop"}
direction={isMobile ? "vertical" : "horizontal"}
className="h-full"
>
{/* Draw.io Canvas */}
<ResizablePanel
id="drawio-panel"
defaultSize={isMobile ? 50 : 67}
@@ -136,6 +181,12 @@ export default function Home() {
className={`h-full relative ${
isMobile ? "p-1" : "p-2"
}`}
onMouseEnter={() => {
mouseOverDrawioRef.current = true
}}
onMouseLeave={() => {
mouseOverDrawioRef.current = false
}}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
{isLoaded ? (
@@ -144,6 +195,7 @@ export default function Home() {
ref={drawioRef}
onExport={handleDiagramExport}
onLoad={onDrawioLoad}
onSave={handleDrawioSave}
baseUrl={drawioBaseUrl}
urlParameters={{
ui: drawioUi,
@@ -167,6 +219,7 @@ export default function Home() {
{/* Chat Panel */}
<ResizablePanel
key={isMobile ? "mobile" : "desktop"}
id="chat-panel"
ref={chatPanelRef}
defaultSize={isMobile ? 50 : 33}
@@ -182,15 +235,9 @@ export default function Home() {
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onToggleDrawioUi={() => {
const newUi =
drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
resetDrawioReady()
}}
onToggleDrawioUi={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={toggleDarkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/>

View File

@@ -8,7 +8,9 @@ import {
stepCountIs,
streamText,
} from "ai"
import fs from "fs/promises"
import { jsonrepair } from "jsonrepair"
import path from "path"
import { z } from "zod"
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
@@ -596,6 +598,69 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
),
}),
},
get_shape_library: {
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
Available libraries:
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce
- Networking: cisco19, network, kubernetes, vvd, rack
- Business: bpmn, lean_mapping
- General: flowchart, basic, arrows2, infographic, sitemap
- UI/Mockups: android
- Enterprise: citrix, sap, mscae, atlassian
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
- Icons: webicons
Call this tool to get shape names and usage syntax for a specific library.`,
inputSchema: z.object({
library: z
.string()
.describe(
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
),
}),
execute: async ({ library }) => {
// Sanitize input - prevent path traversal attacks
const sanitizedLibrary = library
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "")
if (sanitizedLibrary !== library.toLowerCase()) {
return `Invalid library name "${library}". Use only letters, numbers, underscores, and hyphens.`
}
const baseDir = path.join(
process.cwd(),
"docs/shape-libraries",
)
const filePath = path.join(
baseDir,
`${sanitizedLibrary}.md`,
)
// Verify path stays within expected directory
const resolvedPath = path.resolve(filePath)
if (!resolvedPath.startsWith(path.resolve(baseDir))) {
return `Invalid library path.`
}
try {
const content = await fs.readFile(filePath, "utf-8")
return content
} catch (error) {
if (
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, lean_mapping, openstack, rack`
}
console.error(
`[get_shape_library] Error loading "${library}":`,
error,
)
return `Error loading library "${library}". Please try again.`
}
},
},
},
...(process.env.TEMPERATURE !== undefined && {
temperature: parseFloat(process.env.TEMPERATURE),

View File

@@ -1,125 +0,0 @@
import { GoogleAnalytics } from "@next/third-parties/google"
import type { Metadata, Viewport } from "next"
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
import { DiagramProvider } from "@/contexts/diagram-context"
import "./globals.css"
const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
})
const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
weight: ["400", "500"],
})
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
export const metadata: Metadata = {
title: "Next AI Draw.io - AI-Powered Diagram Generator",
description:
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
keywords: [
"AI diagram generator",
"AWS architecture",
"flowchart creator",
"draw.io",
"AI drawing tool",
"technical diagrams",
"diagram automation",
"free diagram generator",
"online diagram maker",
],
authors: [{ name: "Next AI Draw.io" }],
creator: "Next AI Draw.io",
publisher: "Next AI Draw.io",
metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
openGraph: {
title: "Next AI Draw.io - AI Diagram Generator",
description:
"Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
type: "website",
url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io",
locale: "en_US",
images: [
{
url: "/architecture.png",
width: 1200,
height: 630,
alt: "Next AI Draw.io - AI-powered diagram creation tool",
},
],
},
twitter: {
card: "summary_large_image",
title: "Next AI Draw.io - AI Diagram Generator",
description:
"Create professional diagrams with AI assistance. Free, no login required.",
images: ["/architecture.png"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
icon: "/favicon.ico",
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "Next AI Draw.io",
applicationCategory: "DesignApplication",
operatingSystem: "Web Browser",
description:
"AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.",
url: "https://next-ai-drawio.jiang.jp",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
}
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
>
<DiagramProvider>{children}</DiagramProvider>
</body>
{process.env.NEXT_PUBLIC_GA_ID && (
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
)}
</html>
)
}

28
app/manifest.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { MetadataRoute } from "next"
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Next AI Draw.io",
short_name: "AIDraw.io",
description:
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
start_url: "/",
display: "standalone",
background_color: "#f9fafb",
theme_color: "#171d26",
icons: [
{
src: "/favicon-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/favicon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
],
}
}

View File

@@ -8,6 +8,7 @@ import {
Terminal,
Zap,
} from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
interface ExampleCardProps {
icon: React.ReactNode
@@ -24,6 +25,8 @@ function ExampleCard({
onClick,
isNew,
}: ExampleCardProps) {
const dict = useDictionary()
return (
<button
onClick={onClick}
@@ -50,7 +53,7 @@ function ExampleCard({
</h3>
{isNew && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-primary text-primary-foreground rounded">
NEW
{dict.common.new}
</span>
)}
</div>
@@ -70,6 +73,8 @@ export default function ExamplePanel({
setInput: (input: string) => void
setFiles: (files: File[]) => void
}) {
const dict = useDictionary()
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.")
@@ -79,7 +84,7 @@ export default function ExamplePanel({
const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file])
} catch (error) {
console.error("Error loading example image:", error)
console.error(dict.errors.failedToLoadExample, error)
}
}
@@ -94,7 +99,7 @@ export default function ExamplePanel({
})
setFiles([file])
} catch (error) {
console.error("Error loading architecture image:", error)
console.error(dict.errors.failedToLoadExample, error)
}
}
@@ -109,7 +114,7 @@ export default function ExamplePanel({
})
setFiles([file])
} catch (error) {
console.error("Error loading text file:", error)
console.error(dict.errors.failedToLoadExample, error)
}
}
@@ -129,14 +134,14 @@ export default function ExamplePanel({
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
MCP Server
{dict.examples.mcpServer}
</span>
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
PREVIEW
{dict.examples.preview}
</span>
</div>
<p className="text-xs text-muted-foreground">
Use in Claude Desktop, VS Code & Cursor
{dict.examples.mcpDescription}
</p>
</div>
</div>
@@ -145,33 +150,32 @@ export default function ExamplePanel({
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
Create diagrams with AI
{dict.examples.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
Describe what you want to create or upload an image to
replicate
{dict.examples.subtitle}
</p>
</div>
{/* Examples grid */}
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
Quick Examples
{dict.examples.quickExamples}
</p>
<div className="grid gap-2">
<ExampleCard
icon={<FileText className="w-4 h-4 text-primary" />}
title="Paper to Diagram"
description="Upload .pdf, .txt, .md, .json, .csv, .py, .js, .ts and more"
title={dict.examples.paperToDiagram}
description={dict.examples.paperDescription}
onClick={handlePdfExample}
isNew
/>
<ExampleCard
icon={<Zap className="w-4 h-4 text-primary" />}
title="Animated Diagram"
description="Draw a transformer architecture with animated connectors"
title={dict.examples.animatedDiagram}
description={dict.examples.animatedDescription}
onClick={() => {
setInput(
"Give me a **animated connector** diagram of transformer's architecture",
@@ -182,22 +186,22 @@ export default function ExamplePanel({
<ExampleCard
icon={<Cloud className="w-4 h-4 text-primary" />}
title="AWS Architecture"
description="Create a cloud architecture diagram with AWS icons"
title={dict.examples.awsArchitecture}
description={dict.examples.awsDescription}
onClick={handleReplicateArchitecture}
/>
<ExampleCard
icon={<GitBranch className="w-4 h-4 text-primary" />}
title="Replicate Flowchart"
description="Upload and replicate an existing flowchart"
title={dict.examples.replicateFlowchart}
description={dict.examples.replicateDescription}
onClick={handleReplicateFlowchart}
/>
<ExampleCard
icon={<Palette className="w-4 h-4 text-primary" />}
title="Creative Drawing"
description="Draw something fun and creative"
title={dict.examples.creativeDrawing}
description={dict.examples.creativeDescription}
onClick={() => {
setInput("Draw a cat for me")
setFiles([])
@@ -206,7 +210,7 @@ export default function ExamplePanel({
</div>
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
Examples are cached for instant response
{dict.examples.cachedNote}
</p>
</div>
</div>

View File

@@ -25,6 +25,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { FilePreviewList } from "./file-preview-list"
@@ -58,6 +60,7 @@ interface ValidationResult {
function validateFiles(
newFiles: File[],
existingCount: number,
dict: any,
): ValidationResult {
const errors: string[] = []
const validFiles: File[] = []
@@ -65,17 +68,23 @@ function validateFiles(
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(`Maximum ${MAX_FILES} files allowed`)
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(`Only ${availableSlots} more file(s) allowed`)
errors.push(
formatMessage(dict.errors.onlyMoreAllowed, {
slots: availableSlots,
}),
)
break
}
if (!isValidFileType(file)) {
errors.push(`"${file.name}" is not a supported file type`)
errors.push(
formatMessage(dict.errors.unsupportedType, { name: file.name }),
)
continue
}
// Only check size for images (PDFs/text files are extracted client-side, so file size doesn't matter)
@@ -83,7 +92,11 @@ function validateFiles(
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
const maxSizeMB = MAX_IMAGE_SIZE / 1024 / 1024
errors.push(
`"${file.name}" is ${formatFileSize(file.size)} (exceeds ${maxSizeMB}MB)`,
formatMessage(dict.errors.fileExceeds, {
name: file.name,
size: formatFileSize(file.size),
max: maxSizeMB,
}),
)
} else {
validFiles.push(file)
@@ -93,7 +106,7 @@ function validateFiles(
return { validFiles, errors }
}
function showValidationErrors(errors: string[]) {
function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return
if (errors.length === 1) {
@@ -104,14 +117,20 @@ function showValidationErrors(errors: string[]) {
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{errors.length} files rejected:
{formatMessage(dict.errors.filesRejected, {
count: errors.length,
})}
</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>...and {errors.length - 3} more</li>
<li>
{formatMessage(dict.errors.andMore, {
count: errors.length - 3,
})}
</li>
)}
</ul>
</div>,
@@ -155,13 +174,17 @@ export function ChatInput({
minimalStyle = false,
onMinimalStyleChange = () => {},
}: ChatInputProps) {
const { diagramHistory, saveDiagramToFile } = useDiagram()
const dict = useDictionary()
const {
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
@@ -173,7 +196,6 @@ export function ChatInput({
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
@@ -220,8 +242,9 @@ export function ChatInput({
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
)
showValidationErrors(errors)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
@@ -230,12 +253,16 @@ export function ChatInput({
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(newFiles, files.length)
showValidationErrors(errors)
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
// Reset input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
@@ -279,8 +306,9 @@ export function ChatInput({
const { validFiles, errors } = validateFiles(
supportedFiles,
files.length,
dict,
)
showValidationErrors(errors)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
@@ -313,8 +341,6 @@ export function ChatInput({
/>
</div>
)}
{/* Input container */}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
@@ -322,22 +348,20 @@ export function ChatInput({
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Describe your diagram or upload a file..."
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
/>
{/* Action bar */}
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
{/* Left actions */}
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowClearDialog(true)}
tooltipContent="Clear conversation"
tooltipContent={dict.chat.clearConversation}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
@@ -371,25 +395,26 @@ export function ChatInput({
: "text-muted-foreground"
}`}
>
{minimalStyle ? "Minimal" : "Styled"}
{minimalStyle
? dict.chat.minimalStyle
: dict.chat.styledMode}
</label>
</div>
</TooltipTrigger>
<TooltipContent side="top">
Use minimal for faster generation (no colors)
{dict.chat.minimalTooltip}
</TooltipContent>
</Tooltip>
</div>
{/* Right actions */}
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 overflow-hidden justify-end">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => onToggleHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent="Diagram history"
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
@@ -401,7 +426,7 @@ export function ChatInput({
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent="Save diagram"
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
@@ -424,7 +449,7 @@ export function ChatInput({
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent="Upload file (image, PDF, text)"
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
@@ -448,7 +473,7 @@ export function ChatInput({
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? "Sending..." : "Send message"
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
@@ -456,7 +481,7 @@ export function ChatInput({
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
Send
{dict.chat.send}
</>
)}
</Button>

View File

@@ -634,6 +634,8 @@ export function ChatMessageDisplay({
return "Generate Diagram"
case "edit_diagram":
return "Edit Diagram"
case "get_shape_library":
return "Get Shape Library"
default:
return name
}
@@ -728,6 +730,25 @@ export function ChatMessageDisplay({
</div>
)
})()}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
state === "output-available" &&
isExpanded && (
<div className="px-4 py-3 border-t border-border/40">
<div className="text-xs text-muted-foreground mb-2">
Library loaded (
{typeof output === "string" ? output.length : 0}{" "}
chars)
</div>
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
{typeof output === "string"
? output.substring(0, 800) +
(output.length > 800 ? "\n..." : "")
: String(output)}
</pre>
</div>
)}
</div>
)
}

View File

@@ -21,6 +21,7 @@ import { ChatInput } from "@/components/chat-input"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SettingsDialog } from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAIConfig } from "@/lib/ai-config"
import { findCachedResponse } from "@/lib/cached-responses"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
@@ -28,6 +29,7 @@ import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display"
import LanguageToggle from "./language-toggle"
// localStorage keys for persistence
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
@@ -35,6 +37,9 @@ const STORAGE_XML_SNAPSHOTS_KEY = "next-ai-draw-io-xml-snapshots"
const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
export const STORAGE_DIAGRAM_XML_KEY = "next-ai-draw-io-diagram-xml"
// sessionStorage keys
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input"
// Type for message parts (tool calls and their states)
interface MessagePart {
type: string
@@ -106,9 +111,10 @@ export default function ChatPanel({
resolverRef,
chartXML,
clearDiagram,
isDrawioReady,
} = useDiagram()
const dict = useDictionary()
const onFetchChart = (saveToHistory = true) => {
return Promise.race([
new Promise<string>((resolve) => {
@@ -148,6 +154,14 @@ export default function ChatPanel({
const [showNewChatDialog, setShowNewChatDialog] = useState(false)
const [minimalStyle, setMinimalStyle] = useState(false)
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
useEffect(() => {
const savedInput = sessionStorage.getItem(SESSION_STORAGE_INPUT_KEY)
if (savedInput) {
setInput(savedInput)
}
}, [])
// Check config on mount
useEffect(() => {
fetch("/api/config")
@@ -210,9 +224,6 @@ export default function ChatPanel({
const localStorageDebounceRef = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const xmlStorageDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
const {
@@ -725,47 +736,6 @@ Continue from EXACTLY where you stopped.`,
}
}, [setMessages])
// Restore diagram XML when DrawIO becomes ready
const hasDiagramRestoredRef = useRef(false)
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
useEffect(() => {
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
if (!isDrawioReady) {
hasDiagramRestoredRef.current = false
setCanSaveDiagram(false)
return
}
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
try {
const savedDiagramXml = localStorage.getItem(
STORAGE_DIAGRAM_XML_KEY,
)
console.log(
"[ChatPanel] Restoring diagram, has saved XML:",
!!savedDiagramXml,
)
if (savedDiagramXml) {
console.log(
"[ChatPanel] Loading saved diagram XML, length:",
savedDiagramXml.length,
)
// Skip validation for trusted saved diagrams
onDisplayChart(savedDiagramXml, true)
chartXMLRef.current = savedDiagramXml
}
} catch (error) {
console.error("Failed to restore diagram from localStorage:", error)
}
// Allow saving after restore is complete
setTimeout(() => {
console.log("[ChatPanel] Enabling diagram save")
setCanSaveDiagram(true)
}, 500)
}, [isDrawioReady, onDisplayChart])
// Save messages to localStorage whenever they change (debounced to prevent blocking during streaming)
useEffect(() => {
if (!hasRestoredRef.current) return
@@ -795,28 +765,6 @@ Continue from EXACTLY where you stopped.`,
}
}, [messages])
// Save diagram XML to localStorage whenever it changes (debounced)
useEffect(() => {
if (!canSaveDiagram) return
if (!chartXML || chartXML.length <= 300) return
// Clear any pending save
if (xmlStorageDebounceRef.current) {
clearTimeout(xmlStorageDebounceRef.current)
}
// Debounce: save after 1 second of no changes
xmlStorageDebounceRef.current = setTimeout(() => {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
}, LOCAL_STORAGE_DEBOUNCE_MS)
return () => {
if (xmlStorageDebounceRef.current) {
clearTimeout(xmlStorageDebounceRef.current)
}
}
}, [chartXML, canSaveDiagram])
// Save XML snapshots to localStorage whenever they change
const saveXmlSnapshots = useCallback(() => {
try {
@@ -916,6 +864,7 @@ Continue from EXACTLY where you stopped.`,
},
] as any)
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
return
}
@@ -963,6 +912,7 @@ Continue from EXACTLY where you stopped.`,
// Token count is tracked in onFinish with actual server usage
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
} catch (error) {
console.error("Error fetching chart data:", error)
@@ -985,6 +935,7 @@ Continue from EXACTLY where you stopped.`,
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId)
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
toast.success("Started a fresh chat")
} catch (error) {
console.error("Failed to clear localStorage:", error)
@@ -999,9 +950,14 @@ Continue from EXACTLY where you stopped.`,
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
saveInputToSessionStorage(e.target.value)
setInput(e.target.value)
}
const saveInputToSessionStorage = (input: string) => {
sessionStorage.setItem(SESSION_STORAGE_INPUT_KEY, input)
}
// Helper functions for message actions (regenerate/edit)
// Extract previous XML snapshot before a given message index
const getPreviousXml = (beforeIndex: number): string => {
@@ -1277,14 +1233,14 @@ Continue from EXACTLY where you stopped.`,
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 overflow-x-hidden">
<div className="flex items-center gap-2">
<Image
src="/favicon.ico"
alt="Next AI Drawio"
width={isMobile ? 24 : 28}
height={isMobile ? 24 : 28}
className="rounded"
className="rounded flex-shrink-0"
/>
<h1
className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}
@@ -1319,9 +1275,9 @@ Continue from EXACTLY where you stopped.`,
</Link>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip
tooltipContent="Start fresh chat"
tooltipContent={dict.nav.newChat}
variant="ghost"
size="icon"
onClick={() => setShowNewChatDialog(true)}
@@ -1343,7 +1299,7 @@ Continue from EXACTLY where you stopped.`,
/>
</a>
<ButtonWithTooltip
tooltipContent="Settings"
tooltipContent={dict.nav.settings}
variant="ghost"
size="icon"
onClick={() => setShowSettingsDialog(true)}
@@ -1353,17 +1309,20 @@ Continue from EXACTLY where you stopped.`,
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
/>
</ButtonWithTooltip>
{!isMobile && (
<ButtonWithTooltip
tooltipContent="Hide chat panel (Ctrl+B)"
variant="ghost"
size="icon"
onClick={onToggleVisibility}
className="hover:bg-accent"
>
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
</ButtonWithTooltip>
)}
<div className="hidden sm:flex items-center gap-2">
<LanguageToggle />
{!isMobile && (
<ButtonWithTooltip
tooltipContent={dict.nav.hidePanel}
variant="ghost"
size="icon"
className="hover:bg-accent"
onClick={onToggleVisibility}
>
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
</ButtonWithTooltip>
)}
</div>
</div>
</div>
</header>

View File

@@ -3,6 +3,7 @@
import { FileCode, FileText, Loader2, X } from "lucide-react"
import Image from "next/image"
import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
function formatCharCount(count: number): string {
@@ -26,10 +27,10 @@ export function FilePreviewList({
onRemoveFile,
pdfData = new Map(),
}: FilePreviewListProps) {
const dict = useDictionary()
const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
const imageUrlsRef = useRef<Map<File, string>>(new Map())
// Create and cleanup object URLs when files change
useEffect(() => {
const currentUrls = imageUrlsRef.current
@@ -46,7 +47,6 @@ export function FilePreviewList({
}
}
})
// Revoke URLs for files that are no longer in the list
currentUrls.forEach((url, file) => {
if (!newUrls.has(file)) {
@@ -57,7 +57,6 @@ export function FilePreviewList({
imageUrlsRef.current = newUrls
setImageUrls(newUrls)
}, [files])
// Cleanup all URLs on unmount only
useEffect(() => {
return () => {
@@ -68,7 +67,6 @@ export function FilePreviewList({
imageUrlsRef.current = new Map()
}
}, [])
// Clear selected image if its URL was revoked
useEffect(() => {
if (
@@ -126,14 +124,14 @@ export function FilePreviewList({
</span>
{pdfInfo?.isExtracting ? (
<span className="text-[10px] text-muted-foreground">
Reading...
{dict.file.reading}
</span>
) : pdfInfo?.charCount ? (
<span className="text-[10px] text-green-600 font-medium">
{formatCharCount(
pdfInfo.charCount,
)}{" "}
chars
{dict.file.chars}
</span>
) : null}
</div>
@@ -147,7 +145,7 @@ export function FilePreviewList({
type="button"
onClick={() => onRemoveFile(file)}
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Remove file"
aria-label={dict.file.removeFile}
>
<X className="h-3 w-3" />
</button>
@@ -155,7 +153,6 @@ export function FilePreviewList({
)
})}
</div>
{/* Image Modal/Lightbox */}
{selectedImage && (
<div
@@ -165,7 +162,7 @@ export function FilePreviewList({
<button
className="absolute top-4 right-4 z-10 bg-white rounded-full p-2 hover:bg-gray-200 transition-colors"
onClick={() => setSelectedImage(null)}
aria-label="Close"
aria-label={dict.common.close}
>
<X className="h-6 w-6" />
</button>

View File

@@ -12,6 +12,8 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
interface HistoryDialogProps {
showHistory: boolean
@@ -22,6 +24,7 @@ export function HistoryDialog({
showHistory,
onToggleHistory,
}: HistoryDialogProps) {
const dict = useDictionary()
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
@@ -42,18 +45,15 @@ export function HistoryDialog({
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Diagram History</DialogTitle>
<DialogTitle>{dict.history.title}</DialogTitle>
<DialogDescription>
Here saved each diagram before AI modification.
<br />
Click on a diagram to restore it
{dict.history.description}
</DialogDescription>
</DialogHeader>
{diagramHistory.length === 0 ? (
<div className="text-center p-4 text-gray-500">
No history available yet. Send messages to create
diagram history.
{dict.history.noHistory}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 py-4">
@@ -70,14 +70,14 @@ export function HistoryDialog({
<div className="aspect-video bg-white rounded overflow-hidden flex items-center justify-center">
<Image
src={item.svg}
alt={`Diagram version ${index + 1}`}
alt={`${dict.history.version} ${index + 1}`}
width={200}
height={100}
className="object-contain w-full h-full p-1"
/>
</div>
<div className="text-xs text-center mt-1 text-gray-500">
Version {index + 1}
{dict.history.version} {index + 1}
</div>
</div>
))}
@@ -88,21 +88,23 @@ export function HistoryDialog({
{selectedIndex !== null ? (
<>
<div className="flex-1 text-sm text-muted-foreground">
Restore to Version {selectedIndex + 1}?
{formatMessage(dict.history.restoreTo, {
version: selectedIndex + 1,
})}
</div>
<Button
variant="outline"
onClick={() => setSelectedIndex(null)}
>
Cancel
{dict.common.cancel}
</Button>
<Button onClick={handleConfirmRestore}>
Confirm
{dict.common.confirm}
</Button>
</>
) : (
<Button variant="outline" onClick={handleClose}>
Close
{dict.common.close}
</Button>
)}
</DialogFooter>

View File

@@ -0,0 +1,108 @@
"use client"
import { Globe } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useRef, useState } from "react"
import { i18n, type Locale } from "@/lib/i18n/config"
const LABELS: Record<string, string> = {
en: "EN",
zh: "中文",
ja: "日本語",
}
function LanguageToggleInner({ className = "" }: { className?: string }) {
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [open, setOpen] = useState(false)
const [value, setValue] = useState<Locale>(i18n.defaultLocale)
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale))
setValue(first as Locale)
else setValue(i18n.defaultLocale)
}, [pathname])
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current) return
if (!ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener("mousedown", onDoc)
return () => document.removeEventListener("mousedown", onDoc)
}, [open])
const changeLocale = (lang: string) => {
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
setOpen(false)
router.push(newPath + searchStr)
}
return (
<div className={`relative inline-flex ${className}`} ref={ref}>
<button
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((s) => !s)}
className="p-2 rounded-full hover:bg-accent/20 transition-colors text-muted-foreground"
aria-label="Change language"
>
<Globe className="w-5 h-5" />
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-40 bg-popover dark:bg-popover text-popover-foreground rounded-xl shadow-md border border-border/30 overflow-hidden z-50">
<div className="grid gap-0 divide-y divide-border/30">
{i18n.locales.map((loc) => (
<button
key={loc}
onClick={() => changeLocale(loc)}
className={`flex items-center gap-2 px-4 py-2 text-sm w-full text-left hover:bg-accent/10 transition-colors ${value === loc ? "bg-accent/10 font-semibold" : ""}`}
>
<span className="flex-1">
{LABELS[loc] ?? loc}
</span>
{value === loc && (
<span className="text-xs opacity-70">
</span>
)}
</button>
))}
</div>
</div>
)}
</div>
)
}
export default function LanguageToggle({
className = "",
}: {
className?: string
}) {
return (
<Suspense
fallback={
<button
className="p-2 rounded-full text-muted-foreground opacity-50"
disabled
>
<Globe className="w-5 h-5" />
</button>
}
>
<LanguageToggleInner className={className} />
</Suspense>
)
}

View File

@@ -4,6 +4,8 @@ import { Coffee, X } from "lucide-react"
import Link from "next/link"
import type React from "react"
import { FaGithub } from "react-icons/fa"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
interface QuotaLimitToastProps {
type?: "request" | "token"
@@ -18,9 +20,11 @@ export function QuotaLimitToast({
limit,
onDismiss,
}: QuotaLimitToastProps) {
const dict = useDictionary()
const isTokenLimit = type === "token"
const formatNumber = (n: number) =>
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
@@ -44,7 +48,6 @@ export function QuotaLimitToast({
>
<X className="w-4 h-4" />
</button>
{/* Title row with icon */}
<div className="flex items-center gap-2.5 mb-3 pr-6">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-accent flex items-center justify-center">
@@ -55,40 +58,26 @@ export function QuotaLimitToast({
</div>
<h3 className="font-semibold text-foreground text-sm">
{isTokenLimit
? "Daily Token Limit Reached"
: "Daily Quota Reached"}
? dict.quota.tokenLimit
: dict.quota.dailyLimit}
</h3>
<span className="px-2 py-0.5 text-xs font-medium rounded-md bg-muted text-muted-foreground">
{isTokenLimit
? `${formatNumber(used)}/${formatNumber(limit)} tokens`
: `${used}/${limit}`}
{formatMessage(dict.quota.usedOf, {
used: formatNumber(used),
limit: formatNumber(limit),
})}
</span>
</div>
{/* Message */}
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
<p>
Oops you've reached the daily{" "}
{isTokenLimit ? "token" : "API"} limit for this demo! As an
indie developer covering all the API costs myself, I have to
set these limits to keep things sustainable.{" "}
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-amber-600 font-medium hover:text-amber-700 hover:underline"
>
Learn more
</Link>
{isTokenLimit
? dict.quota.messageToken
: dict.quota.messageApi}
</p>
<p>
<strong>Tip:</strong> You can use your own API key (click
the Settings icon) or self-host the project to bypass these
limits.
</p>
<p>Your limit resets tomorrow. Thanks for understanding!</p>
</div>
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
<p>{dict.quota.reset}</p>
</div>{" "}
{/* Action buttons */}
<div className="flex items-center gap-2">
<a
@@ -98,7 +87,7 @@ export function QuotaLimitToast({
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
<FaGithub className="w-3.5 h-3.5" />
Self-host
{dict.quota.selfHost}
</a>
<a
href="https://github.com/sponsors/DayuanJiang"
@@ -107,7 +96,7 @@ export function QuotaLimitToast({
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
>
<Coffee className="w-3.5 h-3.5" />
Sponsor
{dict.quota.sponsor}
</a>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDictionary } from "@/hooks/use-dictionary"
interface ResetWarningModalProps {
open: boolean
@@ -21,14 +22,15 @@ export function ResetWarningModal({
onOpenChange,
onClear,
}: ResetWarningModalProps) {
const dict = useDictionary()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear Everything?</DialogTitle>
<DialogTitle>{dict.dialogs.clearTitle}</DialogTitle>
<DialogDescription>
This will clear the current conversation and reset the
diagram. This action cannot be undone.
{dict.dialogs.clearDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -36,10 +38,10 @@ export function ResetWarningModal({
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
{dict.common.cancel}
</Button>
<Button variant="destructive" onClick={onClear}>
Clear Everything
{dict.dialogs.clearEverything}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -17,19 +18,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
export type ExportFormat = "drawio" | "png" | "svg"
const FORMAT_OPTIONS: {
value: ExportFormat
label: string
extension: string
}[] = [
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" },
{ value: "png", label: "PNG Image", extension: ".png" },
{ value: "svg", label: "SVG Image", extension: ".svg" },
]
interface SaveDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -43,6 +35,7 @@ export function SaveDialog({
onSave,
defaultFilename,
}: SaveDialogProps) {
const dict = useDictionary()
const [filename, setFilename] = useState(defaultFilename)
const [format, setFormat] = useState<ExportFormat>("drawio")
@@ -65,17 +58,40 @@ export function SaveDialog({
}
}
const FORMAT_OPTIONS = [
{
value: "drawio" as const,
label: dict.save.formats.drawio,
extension: ".drawio",
},
{
value: "png" as const,
label: dict.save.formats.png,
extension: ".png",
},
{
value: "svg" as const,
label: dict.save.formats.svg,
extension: ".svg",
},
]
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save Diagram</DialogTitle>
<DialogTitle>{dict.save.title}</DialogTitle>
<DialogDescription>
{dict.save.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Format</label>
<label className="text-sm font-medium">
{dict.save.format}
</label>
<Select
value={format}
onValueChange={(v) => setFormat(v as ExportFormat)}
@@ -96,13 +112,15 @@ export function SaveDialog({
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Filename</label>
<label className="text-sm font-medium">
{dict.save.filename}
</label>
<div className="flex items-stretch">
<Input
value={filename}
onChange={(e) => setFilename(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter filename"
placeholder={dict.save.filenamePlaceholder}
autoFocus
onFocus={(e) => e.target.select()}
className="rounded-r-none border-r-0 focus-visible:z-10"
@@ -118,9 +136,9 @@ export function SaveDialog({
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
{dict.common.cancel}
</Button>
<Button onClick={handleSave}>Save</Button>
<Button onClick={handleSave}>{dict.common.save}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -20,6 +20,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
interface SettingsDialogProps {
open: boolean
@@ -55,6 +56,7 @@ export function SettingsDialog({
darkMode,
onToggleDarkMode,
}: SettingsDialogProps) {
const dict = useDictionary()
const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
@@ -129,14 +131,14 @@ export function SettingsDialog({
const data = await response.json()
if (!data.valid) {
setError(data.message || "Invalid access code")
setError(data.message || dict.errors.invalidAccessCode)
return
}
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
onOpenChange(false)
} catch {
setError("Failed to verify access code")
setError(dict.errors.networkError)
} finally {
setIsVerifying(false)
}
@@ -153,15 +155,17 @@ export function SettingsDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription>
Configure your application settings.
{dict.settings.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{accessCodeRequired && (
<div className="space-y-2">
<Label htmlFor="access-code">Access Code</Label>
<Label htmlFor="access-code">
{dict.settings.accessCode}
</Label>
<div className="flex gap-2">
<Input
id="access-code"
@@ -171,18 +175,20 @@ export function SettingsDialog({
setAccessCode(e.target.value)
}
onKeyDown={handleKeyDown}
placeholder="Enter access code"
placeholder={
dict.settings.accessCodePlaceholder
}
autoComplete="off"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
>
{isVerifying ? "..." : "Save"}
{isVerifying ? "..." : dict.common.save}
</Button>
</div>
<p className="text-[0.8rem] text-muted-foreground">
Required to use this application.
{dict.settings.accessCodeDescription}
</p>
{error && (
<p className="text-[0.8rem] text-destructive">
@@ -192,15 +198,15 @@ export function SettingsDialog({
</div>
)}
<div className="space-y-2">
<Label>AI Provider Settings</Label>
<Label>{dict.settings.aiProvider}</Label>
<p className="text-[0.8rem] text-muted-foreground">
Use your own API key to bypass usage limits. Your
key is stored locally in your browser and is never
stored on the server.
{dict.settings.aiProviderDescription}
</p>
<div className="space-y-3 pt-2">
<div className="space-y-2">
<Label htmlFor="ai-provider">Provider</Label>
<Label htmlFor="ai-provider">
{dict.settings.provider}
</Label>
<Select
value={provider || "default"}
onValueChange={(value) => {
@@ -214,32 +220,36 @@ export function SettingsDialog({
}}
>
<SelectTrigger id="ai-provider">
<SelectValue placeholder="Use Server Default" />
<SelectValue
placeholder={
dict.settings.useServerDefault
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
Use Server Default
{dict.settings.useServerDefault}
</SelectItem>
<SelectItem value="openai">
OpenAI
{dict.providers.openai}
</SelectItem>
<SelectItem value="anthropic">
Anthropic
{dict.providers.anthropic}
</SelectItem>
<SelectItem value="google">
Google
{dict.providers.google}
</SelectItem>
<SelectItem value="azure">
Azure OpenAI
{dict.providers.azure}
</SelectItem>
<SelectItem value="openrouter">
OpenRouter
{dict.providers.openrouter}
</SelectItem>
<SelectItem value="deepseek">
DeepSeek
{dict.providers.deepseek}
</SelectItem>
<SelectItem value="siliconflow">
SiliconFlow
{dict.providers.siliconflow}
</SelectItem>
</SelectContent>
</Select>
@@ -248,7 +258,7 @@ export function SettingsDialog({
<>
<div className="space-y-2">
<Label htmlFor="ai-model">
Model ID
{dict.settings.modelId}
</Label>
<Input
id="ai-model"
@@ -270,13 +280,14 @@ export function SettingsDialog({
: provider ===
"deepseek"
? "e.g., deepseek-chat"
: "Model ID"
: dict.settings
.modelId
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-api-key">
API Key
{dict.settings.apiKey}
</Label>
<Input
id="ai-api-key"
@@ -289,11 +300,13 @@ export function SettingsDialog({
e.target.value,
)
}}
placeholder="Your API key"
placeholder={
dict.settings.apiKeyPlaceholder
}
autoComplete="off"
/>
<p className="text-[0.8rem] text-muted-foreground">
Overrides{" "}
{dict.settings.overrides}{" "}
{provider === "openai"
? "OPENAI_API_KEY"
: provider === "anthropic"
@@ -316,7 +329,7 @@ export function SettingsDialog({
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">
Base URL (optional)
{dict.settings.baseUrl}
</Label>
<Input
id="ai-base-url"
@@ -333,7 +346,8 @@ export function SettingsDialog({
? "https://api.anthropic.com/v1"
: provider === "siliconflow"
? "https://api.siliconflow.com/v1"
: "Custom endpoint URL"
: dict.settings
.customEndpoint
}
/>
</div>
@@ -360,7 +374,7 @@ export function SettingsDialog({
setModelId("")
}}
>
Clear Settings
{dict.settings.clearSettings}
</Button>
</>
)}
@@ -369,9 +383,11 @@ export function SettingsDialog({
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="theme-toggle">Theme</Label>
<Label htmlFor="theme-toggle">
{dict.settings.theme}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
Dark/Light mode for interface and DrawIO canvas.
{dict.settings.themeDescription}
</p>
</div>
<Button
@@ -390,10 +406,14 @@ export function SettingsDialog({
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="drawio-ui">DrawIO Style</Label>
<Label htmlFor="drawio-ui">
{dict.settings.drawioStyle}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
Canvas style:{" "}
{drawioUi === "min" ? "Minimal" : "Sketch"}
{dict.settings.drawioStyleDescription}{" "}
{drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch}
</p>
</div>
<Button
@@ -402,18 +422,20 @@ export function SettingsDialog({
size="sm"
onClick={onToggleDrawioUi}
>
Switch to{" "}
{drawioUi === "min" ? "Sketch" : "Minimal"}
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="close-protection">
Close Protection
{dict.settings.closeProtection}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
Show confirmation when leaving the page.
{dict.settings.closeProtectionDescription}
</p>
</div>
<Switch
@@ -430,6 +452,11 @@ export function SettingsDialog({
/>
</div>
</div>
<div className="pt-4 border-t border-border/50">
<p className="text-[0.75rem] text-muted-foreground text-center">
Version {process.env.APP_VERSION}
</p>
</div>
</DialogContent>
</Dialog>
)

View File

@@ -1,7 +1,7 @@
"use client"
import type React from "react"
import { createContext, useContext, useRef, useState } from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog"
@@ -23,9 +23,12 @@ interface DiagramContextType {
format: ExportFormat,
sessionId?: string,
) => void
saveDiagramToStorage: () => Promise<void>
isDrawioReady: boolean
onDrawioLoad: () => void
resetDrawioReady: () => void
showSaveDialog: boolean
setShowSaveDialog: (show: boolean) => void
}
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
@@ -37,11 +40,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
{ svg: string; xml: string }[]
>([])
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored from localStorage
const hasDiagramRestoredRef = useRef<boolean>(false)
const onDrawioLoad = () => {
// Only set ready state once to prevent infinite loops
@@ -57,6 +64,48 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setIsDrawioReady(false)
}
// Restore diagram XML when DrawIO becomes ready
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
useEffect(() => {
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
if (!isDrawioReady) {
hasDiagramRestoredRef.current = false
setCanSaveDiagram(false)
return
}
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
try {
const savedDiagramXml = localStorage.getItem(
STORAGE_DIAGRAM_XML_KEY,
)
if (savedDiagramXml) {
// Skip validation for trusted saved diagrams
loadDiagram(savedDiagramXml, true)
}
} catch (error) {
console.error("Failed to restore diagram from localStorage:", error)
}
// Allow saving after restore is complete
setTimeout(() => {
setCanSaveDiagram(true)
}, 500)
}, [isDrawioReady])
// Save diagram XML to localStorage whenever it changes (debounced)
useEffect(() => {
if (!canSaveDiagram) return
if (!chartXML || chartXML.length <= 300) return
const timeoutId = setTimeout(() => {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
}, 1000)
return () => clearTimeout(timeoutId)
}, [chartXML, canSaveDiagram])
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null
@@ -82,6 +131,30 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// Save current diagram to localStorage (used before theme/UI changes)
const saveDiagramToStorage = async (): Promise<void> => {
if (!drawioRef.current) return
try {
const currentXml = await Promise.race([
new Promise<string>((resolve) => {
resolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
}),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error("Export timeout")), 2000),
),
])
// Only save if diagram has meaningful content (not empty template)
if (currentXml && currentXml.length > 300) {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
}
} catch (error) {
console.error("Failed to save diagram to storage:", error)
}
}
const loadDiagram = (
chart: string,
skipValidation?: boolean,
@@ -280,9 +353,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
handleDiagramExport,
clearDiagram,
saveDiagramToFile,
saveDiagramToStorage,
isDrawioReady,
onDrawioLoad,
resetDrawioReady,
showSaveDialog,
setShowSaveDialog,
}}
>
{children}

View File

@@ -136,6 +136,42 @@ Optional custom URL:
OLLAMA_BASE_URL=http://localhost:11434
```
### Vercel AI Gateway
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
**Basic Usage (Vercel-hosted Gateway):**
```bash
AI_GATEWAY_API_KEY=your_gateway_api_key
AI_MODEL=openai/gpt-4o
```
**Custom Gateway URL (for local development or self-hosted Gateway):**
```bash
AI_GATEWAY_API_KEY=your_custom_api_key
AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai
AI_MODEL=openai/gpt-4o
```
Model format uses `provider/model` syntax:
- `openai/gpt-4o` - OpenAI GPT-4o
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
**Configuration notes:**
- If `AI_GATEWAY_BASE_URL` is not set, the default Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`) is used
- Custom base URL is useful for:
- Local development with a custom Gateway instance
- Self-hosted AI Gateway deployments
- Enterprise proxy configurations
- When using a custom base URL, you must also provide `AI_GATEWAY_API_KEY`
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
## Auto-Detection
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
@@ -143,7 +179,7 @@ If you only configure **one** provider's API key, the system will automatically
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama, gateway
```
## Model Capability Requirements

View File

@@ -0,0 +1,78 @@
# Draw.io Shape Libraries
Reference: `style="shape=mxgraph.<library>.<shape_name>"`
## Cloud Providers
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| aws4 | 1031 | `mxgraph.aws4` | Amazon Web Services (2025) - EC2, S3, Lambda, RDS, etc. | [aws4.md](./aws4.md) |
| azure2 | 608 | `img/lib/azure2/` | Microsoft Azure (2024) - VMs, Storage, AI, Networking, etc. | [azure2.md](./azure2.md) |
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
## Networking & Infrastructure
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
## Business Process
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
## General Diagrams
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| flowchart | 34 | `mxgraph.flowchart` | Standard flowchart symbols - process, decision, data | [flowchart.md](./flowchart.md) |
| basic | 30 | `mxgraph.basic` | Basic shapes - stars, banners, callouts, hearts | [basic.md](./basic.md) |
| arrows2 | 34 | `mxgraph.arrows2` | Arrow shapes and connectors | [arrows2.md](./arrows2.md) |
| infographic | 29 | `mxgraph.infographic` | Infographic elements - charts, icons, badges | [infographic.md](./infographic.md) |
| sitemap | 50 | `mxgraph.sitemap` | Website sitemap icons - pages, forms, navigation | [sitemap.md](./sitemap.md) |
## UI/Mockups
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
## Enterprise Software
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| citrix | 97 | `mxgraph.citrix` | Citrix virtualization - XenApp, XenDesktop, NetScaler | [citrix.md](./citrix.md) |
| sap | 98 | `mxgraph.sap` | SAP enterprise software icons | [sap.md](./sap.md) |
| mscae | 73 | `mxgraph.mscae` | Microsoft Cloud and Enterprise symbols | [mscae.md](./mscae.md) |
| atlassian | 26 | `mxgraph.atlassian` | Atlassian - Jira, Confluence issue types | [atlassian.md](./atlassian.md) |
## Engineering
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| fluidpower | 246 | `mxgraph.fluid_power` | Hydraulic/pneumatic engineering symbols | [fluidpower.md](./fluidpower.md) |
| electrical | 50 | `mxgraph.electrical` | Electrical circuit symbols - resistors, capacitors | [electrical.md](./electrical.md) |
| pid | 18 | `mxgraph.pid2` | Piping and Instrumentation Diagram symbols | [pid.md](./pid.md) |
| cabinets | 53 | `mxgraph.cabinets` | Electrical cabinet components - breakers, terminals | [cabinets.md](./cabinets.md) |
| floorplan | 44 | `mxgraph.floorplan` | Floor plan furniture and fixtures | [floorplan.md](./floorplan.md) |
## Icons & Graphics
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |
**Total: 33 libraries, 4,281 shapes**

View File

@@ -0,0 +1,328 @@
# alibaba_cloud
**Type:** mxgraph shapes
**Prefix:** `mxgraph.alibaba_cloud`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.alibaba_cloud.{shape};fillColor=#FF6A00;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (311)
- `abap_business_application_platform`
- `acms_application_configuration_manangement`
- `acr_cloud_container_registry`
- `actiontrail`
- `adam_advanced_database_and_application_migration`
- `adb_analyticdb_for_mysql`
- `address_purification`
- `afs_fraud_service`
- `agw_aligateway`
- `ahas_application_high_availability_service`
- `airec_artificial_intelligence_recommendation`
- `alb_application_load_balancer_01`
- `alb_application_load_balancer_02`
- `alibaba_cloud_logo`
- `alibaba_cloud_logo_chinese`
- `alibaba_cloud_logo_english`
- `alimail`
- `alimt_machine_translation`
- `aliyun_linux`
- `amqp_advanced_message_queuing_protocol`
- `amscloudapp`
- `analyticdb_for_postgresql`
- `antibot`
- `apigateway`
- `apsara_file_storage_for_hdfs`
- `apsaravideo_vod`
- `arms_application_real-time_monitoring_service`
- `ask_ack_container_service_for_kubernetes`
- `asm_service_mesh`
- `assettech`
- `avds_vulnerability_db_scanning`
- `baas_blockchain_as_a_service`
- `bandwidth_bag`
- `bastionhost`
- `batchcompute`
- `bccluster`
- `beebot`
- `beian`
- `bizdevops`
- `bizworks`
- `bpstudio`
- `cas_ssl_central_authentication_service`
- `cassandra_wide-column_database_01`
- `cassandra_wide-column_database_02`
- `ccc_cloud_call_center`
- `ccn_cloud_connect_network`
- `ccs_customer_service_01`
- `ccs_customer_service_02`
- `cddc_cloud_database_dedicated_cluster`
- `cdn_content_distribution_network`
- `cdp_cloudera_cdp`
- `cdt_cloud_datatransfer`
- `cen_cloud_enterprise_network`
- `cfw_cloud_firewall`
- `cityvisual`
- `clb_classic_load_balancer_01`
- `clb_classic_load_balancer_02`
- `clickhouse`
- `cloud_auth`
- `cloud_config`
- `cloud_display`
- `cloud_governance_center`
- `cloud_security_center`
- `cloud_shield`
- `cloudap`
- `cloudbox`
- `clouddesktop`
- `clouddev`
- `cloudphoto`
- `cloudproc`
- `cloudshell`
- `cmn_cloud_managed_network`
- `cmp_cloud_mobile_push`
- `cms_cloud_monitor_service`
- `codepipeline`
- `codestore`
- `companyreg`
- `computenest`
- `content_security`
- `coo`
- `cpns_cell_phone_number_service`
- `csas_cloud_security_access_service`
- `cvc_cloud_video_conferencing`
- `cwh_cloud_web_hosting`
- `das_database_autonomy_service`
- `databot`
- `datahub`
- `dataphin`
- `dataquotient`
- `datav`
- `dataworks_dataide`
- `dbaudit`
- `dbes_database_expert_service`
- `dbfs_database_file_system`
- `dbs_database_backup`
- `dcdn_dynamic_route_for_cdn`
- `ddh_dedicated_host`
- `ddos-bgp`
- `ddos-dip`
- `ddos-pro`
- `ddos_protection`
- `devops`
- `dg_database_gateway`
- `directmail`
- `disk_block_storage`
- `dlf_data_lake_formation`
- `dms_data_management_service`
- `dns_domain_name_system`
- `dns_privatezone_01`
- `dns_privatezone_02`
- `domain`
- `domain_and_website`
- `drds_distribute_relational_database_service`
- `dsi_data_security_insurance`
- `dts_data_transmission_service`
- `e-mapreduce`
- `eais_elastic_accelerated_computing_instances`
- `eci_elastic_container_instance`
- `ecs_elastic_compute_service`
- `edas_enterprise_distributed_application_service`
- `ehpc_elastic_high_performance_computing`
- `eip_elastic_ip_address`
- `elastic_web_hosting`
- `elasticsearch`
- `emas_enterprise_mobile_application_studio`
- `energyexpert`
- `ens_edge_node_service`
- `enterprise_website`
- `eprofile`
- `esign`
- `ess_elastic_scaling_service`
- `eventbridge`
- `express_connect`
- `face_recognition`
- `fc_function_compute`
- `flow_service`
- `flowbag`
- `fnf_serverless_function_flow`
- `fpga_field_programmable_gate_array`
- `fraud_detection`
- `ga_global_accelerator`
- `gameshield`
- `gdb_graph_database`
- `graphanalytics`
- `graphcompute`
- `gtm_global_traffic_manager`
- `gts_global_transaction_service`
- `gws_graphic_workstation`
- `havip_high-availability_virtual_ip_address`
- `hbase`
- `hbr_hybrid_backup_recovery`
- `hcs-hgw_hybrid_cloud_storage_array`
- `hcs-mgw_hybrid_cloud_storage_datatransport`
- `hcs-sgw_hybrid_cloud_storage_gateway`
- `hdr_hybrid_disaster_recovery`
- `hologres`
- `holowatcher`
- `hsm_hardware_security_module`
- `httpdns`
- `idrsservice`
- `image_recognition`
- `imagesearch`
- `imarketing`
- `imm_intelligent_media_management`
- `imp_intelligent_media_production`
- `imp_low_code_video_factory`
- `indvi_industrial_visual_intelligence`
- `intelligent_advisor`
- `iot_internet_of_things_platform`
- `iot_wireless_connection_service`
- `iotid_identity`
- `iov_iot_vehicle_cloud`
- `ipv6_gateway`
- `isoc_iot_security_operations_center`
- `isu_intelligent_semantic_understanding`
- `ivision`
- `ivpd_intelligent_visual_production`
- `kafka`
- `linkedmall`
- `linkwan`
- `live`
- `livinglink`
- `log_streaming`
- `logic_composer`
- `machine_learning`
- `man_mobile_analytics`
- `mariadb`
- `mas_mobile_acceleration_service`
- `maxcompute`
- `memcache`
- `miniappdev`
- `mns_message_service`
- `mobile_hotfix`
- `mobsec`
- `mongodb`
- `mps-ai`
- `mps-censor`
- `mps-cover`
- `mps-dna`
- `mps-multimod`
- `mps-produce`
- `mps_apsaravideo_media_processing`
- `mq_message_queue`
- `mqc_mobile_quality_center`
- `mse_microservices_engine`
- `multi-cloud_finops`
- `multi-mode_database_lindorm`
- `multimediaai`
- `mxgraph.alibaba_cloud`
- `mysql`
- `nas_network_attached_storage`
- `nat_gateway`
- `network_acl_access_control_list`
- `nlb_network_load_balancer_01`
- `nlb_network_load_balancer_02`
- `nlp-address`
- `nlp-automl`
- `nlp-ie_text_information_extraction`
- `nlp-ke_keyword_extraction`
- `nlp-ner_named_entity_recognition`
- `nlp-pos_part-of-speech_tagging`
- `nlp-ra_reflexive_anaphora`
- `nlp-sa_sentiment_analysis`
- `nlp-tc_text_categorization`
- `nlp-ws_word_segmentation`
- `nlp_natural_language_processing`
- `nls`
- `nls-asrbag`
- `nls-asrcustommodel`
- `nls-filebag`
- `nls-service`
- `nls-shortasrbag`
- `nls-ttsbag`
- `nodejs_performance_platform`
- `oceanbase`
- `ocr_optical_character_recognition`
- `onsmqtt_micro_message_queuing_telemetry_transport`
- `oos_operation_orchestration_service`
- `openanalytics`
- `openapi_explorer`
- `opensearch`
- `oss_object_storage_service`
- `ots_tablestore`
- `outboundbot`
- `pcdn_p2p_cdn`
- `petadata_hybriddb_for_mysql`
- `physical_connection`
- `pnvs_phone_number_verification_service`
- `polardb`
- `porana_portrait_analysis`
- `postgresql`
- `ppas_pay-as-you-go_database`
- `privatelink`
- `prometheus`
- `prophet`
- `pts_performance_test_service`
- `quickbi`
- `ram_resource_access_management`
- `re_recommendation_engine`
- `realtime_compute`
- `redis_kvstore`
- `region`
- `retailir`
- `ros_resource_orchestration_service`
- `route_table`
- `router`
- `rsimganalys`
- `rtc_real-time_communication`
- `sae_serverless_app_engine`
- `sag_smart_access_gateway_01`
- `sag_smart_access_gateway_02`
- `sas_situational_awareness`
- `sca_smart_conversation_analysis_01`
- `sca_smart_conversation_analysis_02`
- `scc_super_computing_cluster`
- `scdn_secure_cdn`
- `scu_storage_capacity_unit`
- `sddp_sensitive_data_protection`
- `shared_bandwidth`
- `shared_flow_bag`
- `shc_shield_hybrid_cloud`
- `slb_server_load_balancer_01`
- `slb_server_load_balancer_02`
- `slb_server_load_balancer_03`
- `sls_simple_log_service`
- `smc_server_migration_center`
- `sms_short_message_service`
- `sos`
- `spark_data_insights`
- `sppc`
- `sqlserver`
- `swas_simple_application_server`
- `tr_transit_router`
- `trademark_service`
- `uis_ultimate_internet_service`
- `user`
- `user_feedback_01`
- `user_feedback_02`
- `vbr_virtual_border_router`
- `vcs_visual_computing_service`
- `vms_voice_messaging_service`
- `voicebot_intelligent_voice_navigation`
- `vpc_virtual_private_cloud`
- `vpn_gateway`
- `vs_video_surveillance`
- `vswitch`
- `waf_web_application_firewall`
- `webplus_web_app_service`
- `xdragon_bare_metal_server`
- `xtrace`
- `yida`

View File

@@ -0,0 +1,62 @@
# android
**Type:** mxgraph shapes
**Prefix:** `mxgraph.android`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.android.phone2;strokeColor=#c0c0c0;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="200" height="390" as="geometry" />
</mxCell>
```
## Shapes (47)
- `action_bar`
- `action_bar_landscape`
- `anchor`
- `checkbox`
- `contact_badge_focused`
- `contextual_action_bar`
- `contextual_action_bar_landscape`
- `contextual_split_action_bar`
- `contextual_split_action_bar_landscape`
- `contextual_split_action_bar_landscape_white`
- `indeterminateSpinner`
- `indeterminate_progress_bar`
- `keyboard`
- `navigation_bar_1`
- `navigation_bar_1_landscape`
- `navigation_bar_1_vertical`
- `navigation_bar_2`
- `navigation_bar_3`
- `navigation_bar_3_landscape`
- `navigation_bar_4`
- `navigation_bar_5`
- `navigation_bar_5_vertical`
- `navigation_bar_6`
- `phone2`
- `progressBar`
- `progressScrubberDisabled`
- `progressScrubberFocused`
- `progressScrubberPressed`
- `quick_contact`
- `quickscroll2`
- `quickscroll3`
- `rect`
- `rrect`
- `scrollbars2`
- `spinner2`
- `split_action_bar`
- `split_action_bar_landscape`
- `statusBar`
- `switch_off`
- `switch_on`
- `tab2`
- `textSelHandles`
- `text_insertion_point`
- `textfield`
- `time_picker`
- `time_picker_dark`
- `transparent`

View File

@@ -0,0 +1,33 @@
# arrows2
**Type:** mxgraph shapes
**Prefix:** `mxgraph.arrows2`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.arrows2.arrow;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="100" height="60" as="geometry" />
</mxCell>
```
## Shapes (18)
- `arrow`
- `bendArrow`
- `bendDoubleArrow`
- `calloutArrow`
- `calloutDouble90Arrow`
- `calloutDoubleArrow`
- `calloutQuadArrow`
- `jumpInArrow`
- `quadArrow`
- `sharpArrow`
- `sharpArrow2`
- `stripedArrow`
- `stylisedArrow`
- `tailedArrow`
- `tailedNotchedArrow`
- `triadArrow`
- `twoWayArrow`
- `uTurnArrow`

View File

@@ -0,0 +1,32 @@
# atlassian
**Type:** SVG images
**Path:** `img/lib/atlassian/`
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/atlassian/Jira_Logo.svg;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (17)
- `Atlassian_Logo`
- `Bamboo_Logo`
- `Bitbucket_Logo`
- `Clover_Logo`
- `Confluence_Logo`
- `Crowd_Logo`
- `Crucible_Logo`
- `Fisheye_Logo`
- `Hipchat_Logo`
- `Jira_Core_Logo`
- `Jira_Logo`
- `Jira_Service_Desk_Logo`
- `Jira_Software_Logo`
- `Sourcetree_Logo`
- `Statuspage_Logo`
- `Stride_Logo`
- `Trello_Logo`

1049
docs/shape-libraries/aws4.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,431 @@
# azure2
**Type:** SVG images
**Path:** `img/lib/azure2/`
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (648)
Shapes are organized by category: `azure2/{category}/{shape}.svg`
### ai_machine_learning (30)
- `AI_Studio`
- `Anomaly_Detector`
- `Azure_Applied_AI`
- `Azure_Experimentation_Studio`
- `Azure_Object_Understanding`
- `Azure_OpenAI`
- `Batch_AI`
- `Bonsai`
- `Bot_Services`
- `Cognitive_Services`
- `Cognitive_Services_Decisions`
- `Computer_Vision`
- `Content_Moderators`
- `Content_Safety`
- `Custom_Vision`
- `Face_APIs`
- `Form_Recognizers`
- `Genomics`
- `Immersive_Readers`
- `Language_Services`
- `Language_Understanding`
- `Machine_Learning`
- `Machine_Learning_Studio_Classic_Web_Services`
- `Machine_Learning_Studio_Web_Service_Plans`
- `Machine_Learning_Studio_Workspaces`
- `Personalizers`
- `QnA_Makers`
- `Serverless_Search`
- `Speech_Services`
- `Translator_Text`
### analytics (14)
- `Analysis_Services`
- `Azure_Databricks`
- `Azure_Synapse_Analytics`
- `Azure_Workbooks`
- `Data_Lake_Analytics`
- `Data_Lake_Store_Gen1`
- `Endpoint_Analytics`
- `Event_Hub_Clusters`
- `Event_Hubs`
- `HD_Insight_Clusters`
- `Log_Analytics_Workspaces`
- `Power_BI_Embedded`
- `Power_Platform`
- `Stream_Analytics_Jobs`
### app_services (9)
- `API_Management_Services`
- `App_Service_Certificates`
- `App_Service_Domains`
- `App_Service_Environments`
- `App_Service_Plans`
- `App_Services`
- `CDN_Profiles`
- `Notification_Hubs`
- `Search_Services`
### compute (38)
- `App_Services`
- `Application_Group`
- `Automanaged_VM`
- `Availability_Sets`
- `Azure_Compute_Galleries`
- `Azure_Spring_Cloud`
- `Batch_Accounts`
- `Cloud_Services_Classic`
- `Container_Instances`
- `Container_Services_Deprecated`
- `Disk_Encryption_Sets`
- `Disks`
- `Disks_Classic`
- `Disks_Snapshots`
- `Function_Apps`
- `Host_Groups`
- `Host_Pools`
- `Hosts`
- `Image_Definitions`
- `Image_Templates`
- `Image_Versions`
- `Images`
- `Kubernetes_Services`
- `Maintenance_Configuration`
- `Managed_Service_Fabric`
- `Mesh_Applications`
- `Metrics_Advisor`
- `OS_Images_Classic`
- `Restore_Points`
- `Restore_Points_Collections`
- `Service_Fabric_Clusters`
- `Shared_Image_Galleries`
- `VM_Images_Classic`
- `VM_Scale_Sets`
- `Virtual_Machine`
- `Virtual_Machines_Classic`
- `Workspaces`
- `Workspaces2`
### containers (7)
- `App_Services`
- `Azure_Red_Hat_OpenShift`
- `Batch_Accounts`
- `Container_Instances`
- `Container_Registries`
- `Kubernetes_Services`
- `Service_Fabric_Clusters`
### databases (27)
- `Azure_Cosmos_DB`
- `Azure_Data_Explorer_Clusters`
- `Azure_Database_MariaDB_Server`
- `Azure_Database_Migration_Services`
- `Azure_Database_MySQL_Server`
- `Azure_Database_PostgreSQL_Server`
- `Azure_Database_PostgreSQL_Server_Group`
- `Azure_Purview_Accounts`
- `Azure_SQL`
- `Azure_SQL_Edge`
- `Azure_SQL_Server_Stretch_Databases`
- `Azure_SQL_VM`
- `Azure_Synapse_Analytics`
- `Cache_Redis`
- `Data_Factory`
- `Elastic_Job_Agents`
- `Instance_Pools`
- `Managed_Database`
- `Oracle_Database`
- `SQL_Data_Warehouses`
- `SQL_Database`
- `SQL_Elastic_Pools`
- `SQL_Managed_Instance`
- `SQL_Server`
- `SQL_Server_Registries`
- `SSIS_Lift_And_Shift_IR`
- `Virtual_Clusters`
### identity (35)
- `AAD_Licenses`
- `Active_Directory_Connect_Health`
- `Active_Directory_Connect_Health2`
- `Administrative_Units`
- `App_Registrations`
- `Azure_AD_B2C`
- `Azure_AD_B2C2`
- `Azure_AD_Domain_Services`
- `Azure_AD_Identity_Protection`
- `Azure_AD_Privilege_Identity_Management`
- `Azure_Active_Directory`
- `Azure_Information_Protection`
- `Custom_Azure_AD_Roles`
- `Enterprise_Applications`
- `Entra_Connect`
- `Entra_Domain_Services`
- `Entra_Global_Secure_Access`
- `Entra_ID_Protection`
- `Entra_Internet_Access`
- `Entra_Managed_Identities`
- `Entra_Private_Access`
- `Entra_Privileged_Identity_Management`
- `Entra_Verified_ID`
- `External_Identities`
- `Groups`
- `Identity_Governance`
- `Managed_Identities`
- `Multi_Factor_Authentication`
- `PIM`
- `Security`
- `Tenant_Properties`
- `User_Settings`
- `Users`
- `Verifiable_Credentials`
- `Verification_As_A_Service`
### networking (51)
- `ATM_Multistack`
- `Application_Gateway_Containers`
- `Application_Gateways`
- `Azure_Communications_Gateway`
- `Azure_Firewall_Manager`
- `Azure_Firewall_Policy`
- `Bastions`
- `CDN_Profiles`
- `Connections`
- `DDoS_Protection_Plans`
- `DNS_Multistack`
- `DNS_Private_Resolver`
- `DNS_Security_Policy`
- `DNS_Zones`
- `ExpressRoute_Circuits`
- `Firewalls`
- `Front_Doors`
- `IP_Address_manager`
- `IP_Groups`
- `Load_Balancer_Hub`
- `Load_Balancers`
- `Local_Network_Gateways`
- `NAT`
- `Network_Interfaces`
- `Network_Security_Groups`
- `Network_Watcher`
- `On_Premises_Data_Gateways`
- `Private_Endpoint`
- `Private_Link`
- `Private_Link_Hub`
- `Private_Link_Service`
- `Proximity_Placement_Groups`
- `Public_IP_Addresses`
- `Public_IP_Addresses_Classic`
- `Public_IP_Prefixes`
- `Reserved_IP_Addresses_Classic`
- `Resource_Management_Private_Link`
- `Route_Filters`
- `Route_Tables`
- `Service_Endpoint_Policies`
- `Spot_VM`
- `Spot_VMSS`
- `Subnet`
- `Traffic_Manager_Profiles`
- `Virtual_Network_Gateways`
- `Virtual_Networks`
- `Virtual_Networks_Classic`
- `Virtual_Router`
- `Virtual_WAN_Hub`
- `Virtual_WANs`
- `Web_Application_Firewall_Policies_WAF`
### security (14)
- `Application_Security_Groups`
- `Azure_AD_Risky_Signins`
- `Azure_AD_Risky_Users`
- `Azure_Defender`
- `Azure_Sentinel`
- `Conditional_Access`
- `Detonation`
- `ExtendedSecurityUpdates`
- `Identity_Secure_Score`
- `Key_Vaults`
- `Keys`
- `MS_Defender_EASM`
- `Multifactor_Authentication`
- `Security_Center`
### storage (17)
- `Azure_Fileshare`
- `Azure_HCP_Cache`
- `Azure_NetApp_Files`
- `Azure_Stack_Edge`
- `Data_Box`
- `Data_Box_Edge`
- `Data_Lake_Storage_Gen1`
- `Data_Share_Invitations`
- `Data_Shares`
- `Import_Export_Jobs`
- `Recovery_Services_Vaults`
- `StorSimple_Data_Managers`
- `StorSimple_Device_Managers`
- `Storage_Accounts`
- `Storage_Accounts_Classic`
- `Storage_Explorer`
- `Storage_Sync_Services`
### general (98)
- `All_Resources`
- `Backlog`
- `Biz_Talk`
- `Blob_Block`
- `Blob_Page`
- `Branch`
- `Browser`
- `Bug`
- `Builds`
- `Cache`
- `Code`
- `Commit`
- `Controls`
- `Controls_Horizontal`
- `Cost_Alerts`
- `Cost_Analysis`
- `Cost_Budgets`
- `Cost_Management`
- `Cost_Management_and_Billing`
- `Counter`
- `Cubes`
- `Dashboard`
- `Dashboard2`
- `Dev_Console`
- `Download`
- `Error`
- `Extensions`
- `FTP`
- `File`
- `Files`
- `Folder_Blank`
- `Folder_Website`
- `Free_Services`
- `Gear`
- `Globe`
- `Globe_Error`
- `Globe_Success`
- `Globe_Warning`
- `Guide`
- `Heart`
- `Help_and_Support`
- `Image`
- `Information`
- `Input_Output`
- `Journey_Hub`
- `Launch_Portal`
- `Learn`
- `Load_Test`
- `Location`
- `Log_Streaming`
- `Management_Groups`
- `Management_Portal`
- `Marketplace`
- `Media`
- `Media_File`
- `Mobile`
- `Mobile_Engagement`
- `Module`
- `Power`
- `Power_Up`
- `Powershell`
- `Preview`
- `Preview_Features`
- `Process_Explorer`
- `Production_Ready_Database`
- `Quickstart_Center`
- `Recent`
- `Reservations`
- `Resource_Explorer`
- `Resource_Group_List`
- `Resource_Groups`
- `Resource_Linked`
- `SSD`
- `Scale`
- `Scheduler`
- `Search`
- `Search_Grid`
- `Server_Farm`
- `Service_Bus`
- `Service_Health`
- `Storage_Azure_Files`
- `Storage_Container`
- `Storage_Queue`
- `Subscriptions`
- `TFS_VC_Repository`
- `Table`
- `Tag`
- `Tags`
- `Templates`
- `Toolbox`
- `Troubleshoot`
- `Versions`
- `Web_Slots`
- `Web_Test`
- `Website_Power`
- `Website_Staging`
- `Workbooks`
- `Workflow`
### other (149)
(See draw.io for complete list of 149 shapes in the "other" category)
Selected shapes:
- `Azure_Backup_Center`
- `Azure_Chaos_Studio`
- `Azure_Cloud_Shell`
- `Azure_Communication_Services`
- `Azure_Deployment_Environments`
- `Azure_Load_Testing`
- `Azure_Monitor_Dashboard`
- `Azure_Network_Manager`
- `Azure_Orbital`
- `Azure_Sphere`
- `Azure_Storage_Mover`
- `Grafana`
- `Kubernetes_Fleet_Manager`
- `SSH_Keys`
### Additional Categories
- **azure_ecosystem** (3): Applens, Azure_Hybrid_Center, Collaborative_Service
- **azure_stack** (8): Azure_Stack, Capacity, Infrastructure_Backup, Multi_Tenancy, Offers, Plans, Updates, User_Subscriptions
- **azure_vmware_solution** (1): AVS
- **blockchain** (6): ABS_Member, Azure_Blockchain_Service, Azure_Token_Service, Blockchain_Applications, Consortium, Outbound_Connection
- **cxp** (2): Elixir, Elixir_Purple
- **devops** (10): API_Connections, Application_Insights, Azure_DevOps, Change_Analysis, CloudTest, Code_Optimization, DevOps_Starter, DevTest_Labs, Lab_Accounts, Lab_Services
- **hybrid_multicloud** (5): Azure_Operator_5G_Core, Azure_Operator_Insights, Azure_Operator_Nexus, Azure_Operator_Service_Manager, Azure_Programmable_Connectivity
- **integration** (21): API_Management_Services, App_Configuration, Azure_API_for_FHIR, Azure_Data_Catalog, Event_Grid_Domains, Event_Grid_Subscriptions, Event_Grid_Topics, Integration_Accounts, Integration_Environments, Integration_Service_Environments, Logic_Apps, Logic_Apps_Custom_Connector, Partner_Namespace, Partner_Registration, Partner_Topic, Relays, SQL_Data_Warehouses, SendGrid_Accounts, Service_Bus, Software_as_a_Service, System_Topic
- **internet_of_things** (3): Digital_Twins, Logic_Apps, Time_Series_Insights_Access_Policies
- **intune** (17): Azure_AD_Roles_and_Administrators, Client_Apps, Device_Compliance, Device_Configuration, Device_Enrollment, Device_Security_Apple, Device_Security_Google, Device_Security_Windows, Devices, Exchange_Access, Intune, Intune_For_Education, Mindaro, Security_Baselines, Software_Updates, Tenant_Status, eBooks
- **iot** (19): Azure_IoT_Operations, Azure_Maps_Accounts, Azure_Stack_HCI_Sizer, Device_Provisioning_Services, Digital_Twins, Event_Hubs, Function_Apps, Industrial_IoT, IoT_Central_Applications, IoT_Edge, IoT_Hub, Logic_Apps, Notification_Hubs, Stack_HCI_Premium, Stream_Analytics_Jobs, Time_Series_Data_Sets, Time_Series_Insights_Environments, Time_Series_Insights_Event_Sources, Windows10_Core_Services
- **management_governance** (32): Activity_Log, Advisor, Alerts, Application_Insights, Arc_Machines, Automation_Accounts, Azure_Arc, Azure_Lighthouse, Blueprints, Compliance, Cost_Management_and_Billing, Customer_Lockbox_for_MS_Azure, Diagnostics_Settings, Education, Log_Analytics_Workspaces, MachinesAzureArc, Managed_Applications_Center, Managed_Desktop, Metrics, Monitor, My_Customers, Operation_Log_Classic, Policy, Recovery_Services_Vaults, Resource_Graph_Explorer, Resources_Provider, Scheduler_Job_Collections, Service_Catalog_MAD, Service_Providers, Solutions, Universal_Print, User_Privacy
- **menu** (1): Keys
- **migrate** (5): Azure_Migrate, Cost_Management_and_Billing, Data_Box, Data_Box_Edge, Recovery_Services_Vaults
- **mixed_reality** (2): Remote_Rendering, Spatial_Anchor_Accounts
- **monitor** (1): SAP_Azure_Monitor
- **power_platform** (9): AIBuilder, CopilotStudio, Dataverse, PowerApps, PowerAutomate, PowerBI, PowerFx, PowerPages, PowerPlatform
- **preview** (9): Azure_Cloud_Shell, Azure_Sphere, Azure_Workbooks, IoT_Edge, Private_Link_Hub, RTOS, Static_Apps, Time_Series_Data_Sets, Web_Environment
- **web** (5): API_Center, App_Space, Azure_Media_Service, Notification_Hub_Namespaces, SignalR

View File

@@ -0,0 +1,48 @@
# basic
**Type:** mxgraph shapes
**Prefix:** `mxgraph.basic`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.basic.{shape};fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (31)
- `4_point_star`
- `6_point_star`
- `8_point_star`
- `banner`
- `cloud_callout`
- `cloud_rect`
- `cone`
- `cross`
- `document`
- `flash`
- `half_circle`
- `heart`
- `loud_callout`
- `moon`
- `mxgraph.basic`
- `no_symbol`
- `octagon`
- `orthogonal_triangle`
- `oval_callout`
- `parallelepiped`
- `pentagon`
- `pointed_oval`
- `rectangular_callout`
- `rounded_rectangular_callout`
- `smiley`
- `star`
- `sun`
- `tick`
- `trapezoid`
- `wave`
- `x`

View File

@@ -0,0 +1,60 @@
# bpmn
**Type:** mxgraph shapes
**Prefix:** `mxgraph.bpmn`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.bpmn.shape;symbol=message;outline=throwing;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Parameters
- `outline` - Event type: `start`, `end`, `catching`, `throwing`, `none`
- `symbol` - Icon inside: `message`, `timer`, `error`, `cancel`, `compensation`, `link`, `terminate`, `general`, `multiple`, `rule`
## Shapes (40)
- `ad_hoc`
- `business_rule_task`
- `cancel_end`
- `cancel_intermediate`
- `compensation`
- `compensation_end`
- `compensation_intermediate`
- `error_end`
- `error_intermediate`
- `gateway`
- `gateway_and`
- `gateway_complex`
- `gateway_or`
- `gateway_xor_(data)`
- `gateway_xor_(event)`
- `general_end`
- `general_intermediate`
- `general_start`
- `link_end`
- `link_intermediate`
- `link_start`
- `loop`
- `loop_marker`
- `manual_task`
- `message_end`
- `message_intermediate`
- `message_start`
- `multiple_end`
- `multiple_instances`
- `multiple_intermediate`
- `multiple_start`
- `mxgraph.bpmn`
- `rule_intermediate`
- `rule_start`
- `script_task`
- `service_task`
- `terminate`
- `timer_intermediate`
- `timer_start`
- `user_task`

View File

@@ -0,0 +1,71 @@
# cabinets
**Type:** mxgraph shapes
**Prefix:** `mxgraph.cabinets`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.cabinets.{shape};" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (54)
- `auxiliary_contact_contactor_1_32a`
- `auxiliary_contact_contactor_32_125a`
- `cb_1p`
- `cb_1p_x10`
- `cb_2p`
- `cb_2p_x10`
- `cb_3p`
- `cb_3p_x5`
- `cb_4p`
- `cb_4p_x5`
- `cb_auxiliary_contact`
- `contactor_125_400a`
- `contactor_1_32a`
- `contactor_32_125a`
- `din_rail`
- `distribution_block_4p_125a_11_connections`
- `distribution_block_4p_125a_11_connections_2`
- `mccb_25_63a_3p`
- `mccb_25_63a_4p`
- `mccb_63_250a_3p`
- `mccb_63_250a_4p`
- `motor_cb_125_400a`
- `motor_cb_1_32a`
- `motor_cb_32_125a`
- `motor_protection_cb`
- `motor_starter_125_400a`
- `motor_starter_1_32a`
- `motor_starter_32_125a`
- `motorized_switch_3p`
- `motorized_switch_4p`
- `mxgraph.cabinets`
- `overcurrent_relay_125_400a`
- `overcurrent_relay_1_32a`
- `overcurrent_relay_32_125a`
- `plugin_relay_1`
- `plugin_relay_2`
- `residual_current_device_2p`
- `residual_current_device_4p`
- `surge_protection_1p`
- `surge_protection_2p`
- `surge_protection_3p`
- `surge_protection_4p`
- `terminal_40mm2`
- `terminal_40mm2_x10`
- `terminal_4_6mm2`
- `terminal_4_6mm2_x10`
- `terminal_4mm2`
- `terminal_4mm2_x10`
- `terminal_50mm2`
- `terminal_50mm2_x10`
- `terminal_6_25mm2`
- `terminal_6_25mm2_x10`
- `terminal_75mm2`
- `terminal_75mm2_x10`

View File

@@ -0,0 +1,250 @@
# cisco19
**Type:** mxgraph shapes
**Prefix:** `mxgraph.cisco19`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.cisco19.rect;prIcon={shape};fillColor=#00bceb;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (233)
- `3g_4g_indicator`
- `6500_vss`
- `6500_vss2`
- `access_control_and_trustsec`
- `aci`
- `aci2`
- `acibg`
- `acs`
- `ad_decoder`
- `ad_encoder`
- `analysis_correlation`
- `anomaly_detection`
- `anti_malware`
- `anti_malware2`
- `appnav`
- `asa_5500`
- `asr_1000`
- `asr_9000`
- `avc_application_visibility_control`
- `avc_application_visibility_control2`
- `bg1`
- `bg10`
- `bg2`
- `bg3`
- `bg4`
- `bg5`
- `bg6`
- `bg7`
- `bg8`
- `bg9`
- `blade_server`
- `branch`
- `branch2`
- `camera`
- `camera2`
- `cell_phone`
- `cell_phone2`
- `cisco_15800`
- `cisco_dna`
- `cisco_dna_center`
- `cisco_meetingplace_express`
- `cisco_security_manager`
- `cisco_unified_contact_center_enterprise_and_hosted`
- `cisco_unified_presence_service`
- `clock`
- `cloud`
- `cloud2`
- `cognitive`
- `collab1`
- `collab2`
- `collab3`
- `collab4`
- `communications_manager`
- `contact_center_express`
- `content_recording_streaming_server`
- `content_router`
- `csr_1000v`
- `da_decoder`
- `da_encoder`
- `data_center`
- `data_center2`
- `database_relational`
- `dns_server`
- `dns_server2`
- `dual_mode_access_point`
- `email_security`
- `fabric_interconnect`
- `fibre_channel_director_mds_9000`
- `fibre_channel_fabric_switch`
- `firewall`
- `flow_analytics`
- `flow_analytics2`
- `flow_collector`
- `h323`
- `handheld`
- `handheld2`
- `hdtv`
- `hdtv2`
- `home_office`
- `home_office2`
- `host_based_security`
- `hypervisor`
- `immersive_telepresence_endpoint`
- `ip_ip_gateway`
- `ip_phone`
- `ip_phone2`
- `ip_telephone_router`
- `ips_ids`
- `ironport`
- `ise`
- `joystick_keyboard`
- `joystick_keyboard2`
- `key`
- `key2`
- `l2_modular`
- `l2_modular2`
- `l2_switch`
- `l2_switch_with_dual_supervisor`
- `l3_modular`
- `l3_modular2`
- `l3_modular3`
- `l3_switch`
- `l3_switch_with_dual_supervisor`
- `laptop`
- `laptop2`
- `laptop_video_client`
- `laptop_video_client2`
- `layer3_nexus_5k_switch`
- `ldap`
- `ldap2`
- `load_balancer`
- `lock`
- `lock2`
- `media_server`
- `meeting_scheduling_and_management_server`
- `mesh_access_point`
- `monitor`
- `monitoring`
- `multipoint_meeting_server`
- `mxgraph.cisco19`
- `nac_appliance`
- `nam_virtual_service_blade`
- `net_mgmt_appliance`
- `netflow_router`
- `netflow_router2`
- `netflow_router3`
- `next_generation_intrusion_prevention_system`
- `nexus_1010`
- `nexus_1k`
- `nexus_1kv_vsm`
- `nexus_2000_10ge`
- `nexus_2k`
- `nexus_3k`
- `nexus_4k`
- `nexus_5k`
- `nexus_5k_with_integrated_vsm`
- `nexus_7k`
- `nexus_9300`
- `nexus_9500`
- `operations_manager`
- `phone_polycom`
- `phone_polycom2`
- `policy_configuration`
- `pos`
- `pos2`
- `posture_assessment`
- `primary_codec`
- `printer`
- `printer2`
- `router`
- `router_with_firewall`
- `router_with_firewall2`
- `router_with_voice`
- `rps`
- `secondary_codec`
- `secure_catalyst_switch_color`
- `secure_catalyst_switch_color2`
- `secure_catalyst_switch_color3`
- `secure_catalyst_switch_subdued`
- `secure_catalyst_switch_subdued2`
- `secure_endpoint_pc`
- `secure_endpoint_pc2`
- `secure_endpoints`
- `secure_endpoints2`
- `secure_router`
- `secure_server`
- `secure_server2`
- `secure_switch`
- `security_management`
- `server`
- `server2`
- `service_ready_engine`
- `set_top`
- `set_top2`
- `shield`
- `ssl_terminator`
- `stealthwatch_management_console_smc`
- `stealthwatch_management_console_smc2`
- `storage`
- `surveillance_camera`
- `surveillance_camera2`
- `tablet`
- `tablet2`
- `telepresence_endpoint`
- `telepresence_endpoint_twin_data_display`
- `telepresence_exchange`
- `threat_intelligence`
- `transcoder`
- `ucs_5108_blade_chassis`
- `ucs_c_series_server`
- `ucs_express`
- `unity`
- `upc_unified_personal_communicator`
- `upc_unified_personal_communicator2`
- `ups`
- `user`
- `user2`
- `vbond`
- `video_analytics`
- `video_call_server`
- `video_gateway`
- `virtual_desktop_service`
- `virtual_matrix_switch`
- `virtual_private_network`
- `virtual_private_network2`
- `virtual_private_network_connector`
- `vmanage`
- `vpn_concentrator`
- `vsmart`
- `vts`
- `vts2`
- `web_application_firewall`
- `web_reputation_filtering`
- `web_reputation_filtering_2`
- `web_security`
- `web_security_services`
- `web_security_services2`
- `webex`
- `wifi_indicator`
- `wireless_access_point`
- `wireless_access_point2`
- `wireless_bridge`
- `wireless_bridge2`
- `wireless_connector`
- `wireless_intrusion_prevention`
- `wireless_lan_controller`
- `wireless_location_appliance`
- `wireless_router`
- `workgroup_switch`
- `workstation`
- `workstation2`
- `x509_certificate`
- `x509_certificate2`

View File

@@ -0,0 +1,115 @@
# citrix
**Type:** mxgraph shapes
**Prefix:** `mxgraph.citrix`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.citrix.{shape};fillColor=#00A4E4;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (98)
- `1u_2u_server`
- `access_card`
- `branch_repeater`
- `browser`
- `cache_server`
- `calendar`
- `cell_phone`
- `chassis`
- `citrix_hdx`
- `citrix_logo`
- `cloud`
- `command_center`
- `database`
- `database_server`
- `datacenter`
- `desktop`
- `desktop_web`
- `dhcp_server`
- `directory_server`
- `dns_server`
- `document`
- `edgesight_server`
- `file_server`
- `firewall`
- `ftp_server`
- `geolocation_database`
- `globe`
- `goto_meeting`
- `government`
- `home_office`
- `hq_enterprise`
- `inspection`
- `ip_phone`
- `kiosk`
- `laptop_1`
- `laptop_2`
- `license_server`
- `merchandising_server`
- `middleware`
- `mxgraph.citrix`
- `netscaler_gateway`
- `netscaler_mpx`
- `netscaler_sdx`
- `netscaler_vpx`
- `pbx_server`
- `pda`
- `podio`
- `printer`
- `process`
- `provisioning_server`
- `proxy_server`
- `radius_server`
- `remote_office`
- `reporting`
- `role_appcontroller`
- `role_applications`
- `role_cloudbridge`
- `role_desktops`
- `role_load_testing_controller`
- `role_load_testing_launcher`
- `role_receiver`
- `role_repeater`
- `role_secure_access`
- `role_security`
- `role_services`
- `role_storefront`
- `role_storefront_services`
- `role_synchronizer`
- `role_xenmobile`
- `role_xenmobile_device_manager`
- `router`
- `security`
- `sharefile`
- `site`
- `smtp_server`
- `storefront_services`
- `switch`
- `tablet_1`
- `tablet_2`
- `thin_client`
- `tower_server`
- `user_control`
- `users`
- `web_server`
- `web_service`
- `worxenroll`
- `worxhome`
- `worxmail`
- `worxweb`
- `xenapp_server`
- `xenapp_services`
- `xenapp_web`
- `xencenter`
- `xenclient`
- `xenclient_synchronizer`
- `xendesktop_server`
- `xenmobile`
- `xenserver`

View File

@@ -0,0 +1,50 @@
# electrical
**Type:** mxgraph shapes
**Prefix:** `mxgraph.electrical`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.electrical.resistors.resistor_1;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="20" as="geometry" />
</mxCell>
```
Shapes are organized by category: `mxgraph.electrical.{category}.{shape}`
## Categories
### resistors
- `resistor_1`
- `resistor_2`
### capacitors
- `capacitor_1`
- `capacitor_3`
### inductors
- `inductor_3`
- `transformer_1`
### diodes
- `diode`
- `zener_diode_1`
### transistors
- `npn_transistor_1`
- `pnp_transistor_1`
### mosfets1
- `n-channel_mosfet_1`
- `p-channel_mosfet_1`
### logic_gates
- `logic_gate`
- `dual_inline_ic`
### electro-mechanical
- `singleSwitch`
- `pushbutton`
(See draw.io Electrical shape library for complete list)

View File

@@ -0,0 +1,62 @@
# floorplan
**Type:** mxgraph shapes
**Prefix:** `mxgraph.floorplan`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.floorplan.{shape};fillColor=#ffffff;strokeColor=#000000;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (45)
- `bathtub`
- `bathtub2`
- `bed_double`
- `bed_single`
- `bookcase`
- `chair`
- `copier`
- `couch`
- `crt_tv`
- `desk_corner`
- `desk_corner_2`
- `dresser`
- `drying_machine`
- `elevator`
- `fireplace`
- `flat_tv`
- `floor_lamp`
- `laptop`
- `mxgraph.floorplan`
- `office_chair`
- `piano`
- `plant`
- `printer`
- `range_1`
- `range_2`
- `refrigerator`
- `shower`
- `shower2`
- `sink_1`
- `sink_2`
- `sink_22`
- `sink_double`
- `sink_double2`
- `sofa`
- `spiral_stairs`
- `table`
- `table_1`
- `table_2`
- `table_3`
- `table_4`
- `table_5`
- `toilet`
- `washing_machine`
- `water_cooler`
- `workstation`

View File

@@ -0,0 +1,52 @@
# flowchart
**Type:** mxgraph shapes
**Prefix:** `mxgraph.flowchart`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.flowchart.{shape};fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (35)
- `annotation_1`
- `annotation_2`
- `card`
- `collate`
- `data`
- `database`
- `decision`
- `delay`
- `direct_data`
- `display`
- `document`
- `extract_or_measurement`
- `internal_storage`
- `loop_limit`
- `manual_input`
- `manual_operation`
- `merge_or_storage`
- `multi-document`
- `mxgraph.flowchart`
- `off-page_reference`
- `on-page_reference`
- `or`
- `paper_tape`
- `parallel_mode`
- `predefined_process`
- `preparation`
- `process`
- `sequential_data`
- `sort`
- `start_1`
- `start_2`
- `stored_data`
- `summing_function`
- `terminator`
- `transfer`

View File

@@ -0,0 +1,264 @@
# fluidpower
**Type:** mxgraph shapes
**Prefix:** `mxgraph.fluid_power`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.fluid_power.{shape};fillColor=strokeColor;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
Shapes are named like x10010, x10020, etc.
## Shapes (247)
- `mxgraph.fluid_power`
- `x10010`
- `x10020`
- `x10030`
- `x10040`
- `x10050`
- `x10060`
- `x10070`
- `x10080`
- `x10090`
- `x10100`
- `x10110`
- `x10120`
- `x10130`
- `x10140`
- `x10150`
- `x10160`
- `x10170`
- `x10180`
- `x10190`
- `x10200`
- `x10210`
- `x10220`
- `x10230`
- `x10240`
- `x10250`
- `x10260`
- `x10270`
- `x10280`
- `x10290`
- `x10300`
- `x10310`
- `x10320`
- `x10330`
- `x10340`
- `x10350`
- `x10360`
- `x10370`
- `x10380`
- `x10390`
- `x10400`
- `x10410`
- `x10420`
- `x10430`
- `x10440`
- `x10441`
- `x10442`
- `x10450`
- `x10460`
- `x10470`
- `x10480`
- `x10490`
- `x10500`
- `x10510`
- `x10520`
- `x10530`
- `x10540`
- `x10550`
- `x10560`
- `x10570`
- `x10580`
- `x10590`
- `x10600`
- `x10610`
- `x10620`
- `x10630`
- `x10640`
- `x10650`
- `x10660`
- `x10670`
- `x10680`
- `x10690`
- `x10700`
- `x10710`
- `x10720`
- `x10730`
- `x10740`
- `x10750`
- `x10760`
- `x10770`
- `x10780`
- `x10790`
- `x10800`
- `x10810`
- `x10820`
- `x10830`
- `x10840`
- `x10850`
- `x10860`
- `x10870`
- `x10880`
- `x10890`
- `x10900`
- `x10910`
- `x10920`
- `x10930`
- `x10940`
- `x10950`
- `x10960`
- `x10970`
- `x10980`
- `x10990`
- `x11000`
- `x11010`
- `x11020`
- `x11030`
- `x11040`
- `x11050`
- `x11060`
- `x11070`
- `x11080`
- `x11090`
- `x11100`
- `x11110`
- `x11120`
- `x11130`
- `x11140`
- `x11150`
- `x11160`
- `x11170`
- `x11180`
- `x11190`
- `x11200`
- `x11210`
- `x11220`
- `x11230`
- `x11240`
- `x11250`
- `x11260`
- `x11270`
- `x11280`
- `x11290`
- `x11300`
- `x11310`
- `x11320`
- `x11330`
- `x11340`
- `x11350`
- `x11360`
- `x11370`
- `x11380`
- `x11390`
- `x11400`
- `x11410`
- `x11420`
- `x11430`
- `x11440`
- `x11450`
- `x11460`
- `x11470`
- `x11480`
- `x11490`
- `x11500`
- `x11510`
- `x11520`
- `x11530`
- `x11540`
- `x11550`
- `x11560`
- `x11570`
- `x11580`
- `x11590`
- `x11600`
- `x11610`
- `x11620`
- `x11630`
- `x11640`
- `x11650`
- `x11660`
- `x11670`
- `x11680`
- `x11690`
- `x11700`
- `x11710`
- `x11720`
- `x11730`
- `x11740`
- `x11750`
- `x11760`
- `x11770`
- `x11780`
- `x11790`
- `x11800`
- `x11810`
- `x11820`
- `x11830`
- `x11840`
- `x11850`
- `x11860`
- `x11870`
- `x11880`
- `x11890`
- `x11900`
- `x11910`
- `x11920`
- `x11930`
- `x11940`
- `x11950`
- `x11960`
- `x11970`
- `x11980`
- `x11990`
- `x12000`
- `x12010`
- `x12020`
- `x12030`
- `x12040`
- `x12050`
- `x12060`
- `x12070`
- `x12080`
- `x12090`
- `x12100`
- `x12110`
- `x12120`
- `x12130`
- `x12140`
- `x12150`
- `x12160_detailed`
- `x12160_simplified`
- `x12170`
- `x12180`
- `x12190`
- `x12200`
- `x12210`
- `x12220`
- `x12230`
- `x12240`
- `x12250`
- `x12260`
- `x12270`
- `x12280`
- `x12290`
- `x12300`
- `x12310`
- `x12320`
- `x12330`
- `x12340`
- `x12350`
- `x12360`
- `x12370`
- `x12380`
- `x12390`
- `x12400`
- `x12410`
- `x12420`
- `x12430`

View File

@@ -0,0 +1,315 @@
# gcp2
**Type:** mxgraph shapes
**Prefix:** `mxgraph.gcp2`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.gcp2.{shape};fillColor=#4285F4;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (298)
- `a7_power`
- `admin_connected`
- `admob`
- `advanced_solutions_lab`
- `ai_hub`
- `anomaly_detection`
- `api_analytics`
- `api_monetization`
- `apigee_api_platform`
- `apigee_sense`
- `app_engine`
- `app_engine_icon`
- `application`
- `application_system`
- `arrow_cycle`
- `arrows_system`
- `aspect_ratio`
- `automl_natural_language`
- `automl_tables`
- `automl_translation`
- `automl_video_intelligence`
- `automl_vision`
- `avere`
- `beacon`
- `beyondcorp`
- `big_query`
- `bigquery`
- `biomedical_beaker`
- `biomedical_test_tube`
- `biomedical_trio`
- `blank`
- `blue_hexagon`
- `bucket`
- `bucket_scale`
- `calculator`
- `campaign_manager`
- `capabilities`
- `certified_industry_standard`
- `check`
- `check_2`
- `check_available`
- `check_scale`
- `circuit_board`
- `clock`
- `cloud`
- `cloud_apis`
- `cloud_armor`
- `cloud_automl`
- `cloud_bigtable`
- `cloud_cdn`
- `cloud_checkmark`
- `cloud_code`
- `cloud_composer`
- `cloud_computer`
- `cloud_connected_insight`
- `cloud_data_catalog`
- `cloud_data_fusion`
- `cloud_dataflow`
- `cloud_dataflow_icon`
- `cloud_datalab`
- `cloud_dataprep`
- `cloud_dataproc`
- `cloud_dataproc_icon`
- `cloud_datastore`
- `cloud_deployment_manager`
- `cloud_dns`
- `cloud_endpoints`
- `cloud_external_ip_addresses`
- `cloud_filestore`
- `cloud_firestore`
- `cloud_firewall_rules`
- `cloud_functions`
- `cloud_iam`
- `cloud_inference_api`
- `cloud_information`
- `cloud_iot_core`
- `cloud_iot_edge`
- `cloud_jobs_api`
- `cloud_load_balancing`
- `cloud_machine_learning`
- `cloud_memorystore`
- `cloud_messaging`
- `cloud_monitoring`
- `cloud_nat`
- `cloud_natural_language_api`
- `cloud_network`
- `cloud_pubsub`
- `cloud_router`
- `cloud_routes`
- `cloud_run`
- `cloud_scheduler`
- `cloud_security`
- `cloud_security_command_center`
- `cloud_security_scanner`
- `cloud_server`
- `cloud_service_mesh`
- `cloud_spanner`
- `cloud_speech_api`
- `cloud_sql`
- `cloud_storage`
- `cloud_sub_pub`
- `cloud_tasks`
- `cloud_test_lab`
- `cloud_text_to_speech`
- `cloud_tools_for_powershell`
- `cloud_tpu`
- `cloud_translation_api`
- `cloud_video_intelligence_api`
- `cloud_vision_api`
- `cloud_vpn`
- `cluster`
- `compute_engine`
- `compute_engine_2`
- `compute_engine_icon`
- `connected`
- `container_builder`
- `container_engine`
- `container_engine_icon`
- `container_optimized_os`
- `container_registry`
- `cost`
- `cost_arrows`
- `cost_savings`
- `data_access`
- `data_increase`
- `data_loss_prevention_api`
- `data_storage_cost`
- `data_studio`
- `database`
- `database_2`
- `database_3`
- `database_cycle`
- `database_speed`
- `database_uploading`
- `debugger`
- `dedicated_game_server`
- `dedicated_interconnect`
- `desktop`
- `desktop_and_mobile`
- `developer_portal`
- `dialogflow_enterprise_edition`
- `enhance_ui`
- `enhance_ui_2`
- `error_reporting`
- `external_data_center`
- `external_data_resource`
- `external_payment_form`
- `fastly`
- `files`
- `firebase`
- `folders`
- `forseti_lockup`
- `forseti_logo`
- `frontend_platform_services`
- `game`
- `gateway`
- `gateway_icon`
- `gear`
- `gear_arrow`
- `gear_chain`
- `gear_load`
- `genomics`
- `gke_on_prem`
- `globe_world`
- `google_ad_manager`
- `google_ads`
- `google_analytics`
- `google_analytics_360`
- `google_cloud_platform`
- `google_cloud_platform_lockup`
- `google_network`
- `google_network_edge_cache`
- `google_play_game_service`
- `gpu`
- `half_cloud`
- `https_load_balancer`
- `identity_aware_proxy`
- `image_services`
- `increase_cost_arrows`
- `internal_payment_authorization`
- `internet_connection`
- `istio_logo`
- `key`
- `key_management_service`
- `kubernetes_logo`
- `kubernetes_name`
- `laptop`
- `legacy_cloud`
- `legacy_cloud_2`
- `lifecycle`
- `lightbulb`
- `list`
- `live`
- `load_balancing`
- `loading`
- `loading_2`
- `loading_3`
- `lock`
- `logging`
- `logs_api`
- `management_security`
- `maps_api`
- `mem_instances`
- `memcache`
- `memory_card`
- `mobile_devices`
- `modifiers_autoscaling`
- `modifiers_custom_virtual_machine`
- `modifiers_high_cpu_machine`
- `modifiers_high_memory_machine`
- `modifiers_preemptable_vm`
- `modifiers_shared_core_machine_f1`
- `modifiers_shared_core_machine_g1`
- `modifiers_standard_machine`
- `modifiers_storage`
- `monitor`
- `monitor_2`
- `mxgraph.gcp2`
- `nat`
- `network`
- `network_load_balancer`
- `node`
- `outline_blank_1`
- `outline_blank_2`
- `outline_blank_3`
- `outline_highcomp`
- `outline_highmem`
- `partner_interconnect`
- `payment`
- `people_security_management`
- `persistent_disk`
- `persistent_disk_snapshot`
- `phone`
- `phone_android`
- `placeholder`
- `play_gear`
- `play_start`
- `prediction_api`
- `premium_network_tier`
- `primary`
- `process`
- `profiler`
- `push_notification_service`
- `recommendations_ai`
- `record`
- `replication_controller`
- `replication_controller_2`
- `replication_controller_3`
- `report`
- `repository`
- `repository_2`
- `repository_3`
- `repository_primary`
- `retail`
- `safety`
- `save`
- `scale`
- `scheduled_tasks`
- `search`
- `search_api`
- `security_key_enforcement`
- `segments`
- `segments_2`
- `segments_overlap`
- `servers_stacked`
- `service`
- `service_discovery`
- `social_media_time`
- `solution`
- `speaker`
- `speed`
- `squid_proxy`
- `stackdriver`
- `stacked_ownership`
- `standard_network_tier`
- `storage`
- `stream`
- `swap`
- `systems_check`
- `tape_record`
- `task_queues`
- `task_queues_2`
- `tensorflow_lockup`
- `tensorflow_logo`
- `thumbs_up`
- `time_clock`
- `trace`
- `traffic_director`
- `transfer_appliance`
- `users`
- `view_list`
- `virtual_file_system`
- `virtual_private_cloud`
- `visibility`
- `vpn`
- `vpn_gateway`
- `webcam`
- `website`

View File

@@ -0,0 +1,24 @@
# infographic
**Type:** mxgraph shapes
**Prefix:** `mxgraph.infographic`
## Usage
```xml
<mxCell value="label" style="html=1;shape=mxgraph.infographic.shadedCube;isoAngle=15;fillColor=#10739E;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes
- `shadedCube` (needs `isoAngle=15;`)
- `ribbonSimple` (needs `notch1=20;notch2=20;`)
- `ribbonRolled`
- `ribbonDoubleFolded`
- `shadedTriangle`
- `shadedPyramid`
- `cylinder`
- `banner`
- `flag`

View File

@@ -0,0 +1,58 @@
# kubernetes
**Type:** mxgraph shapes
**Prefix:** `mxgraph.kubernetes`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.kubernetes.icon;prIcon={shape};fillColor=#326CE5;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (41)
- `api`
- `c_c_m`
- `c_m`
- `c_role`
- `cm`
- `crb`
- `crd`
- `cronjob`
- `deploy`
- `ds`
- `ep`
- `etcd`
- `frame`
- `group`
- `hpa`
- `ing`
- `job`
- `k_proxy`
- `kubelet`
- `limits`
- `master`
- `mxgraph.kubernetes`
- `netpol`
- `node`
- `ns`
- `pod`
- `psp`
- `pv`
- `pvc`
- `quota`
- `rb`
- `role`
- `rs`
- `sa`
- `sc`
- `sched`
- `secret`
- `sts`
- `svc`
- `user`
- `vol`

View File

@@ -0,0 +1,31 @@
# lean_mapping
**Type:** mxgraph shapes
**Prefix:** `mxgraph.lean_mapping`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.lean_mapping.{shape};strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (14)
- `airplane_7`
- `electronic_info_flow`
- `finished_goods_to_customer`
- `go_see_production_scheduling`
- `kaizen_lightening_burst`
- `kanban_post`
- `load_leveling`
- `manual_info_flow`
- `move_by_forklift`
- `mrp_erp`
- `mxgraph.lean_mapping`
- `operator`
- `quality_problem`
- `verbal`

View File

@@ -0,0 +1,22 @@
# mscae
**Type:** mxgraph shapes
**Prefix:** `mxgraph.mscae`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.mscae.cloud.azure;fillColor=#0078D4;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Categories
Shapes are organized by category: `mscae.cloud`, `mscae.intune`, `mscae.oms`, `mscae.system_center`
- `conditional_access_exchange`
- `conditional_access_sharepoint`
- `primary_site`
(See draw.io for complete shape list within each category)

View File

@@ -0,0 +1,72 @@
# network
**Type:** mxgraph shapes
**Prefix:** `mxgraph.networks`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (57)
- `biometric_reader`
- `bus`
- `business_center`
- `cloud`
- `comm_link`
- `comm_link_edge`
- `community`
- `copier`
- `desktop_pc`
- `external_storage`
- `firewall`
- `gamepad`
- `hub`
- `laptop`
- `load_balancer`
- `mail_server`
- `mainframe`
- `mobile`
- `modem`
- `monitor`
- `nas_filer`
- `patch_panel`
- `phone_1`
- `phone_2`
- `printer`
- `proxy_server`
- `rack`
- `radio_tower`
- `router`
- `satellite`
- `satellite_dish`
- `scanner`
- `secured`
- `security_camera`
- `server`
- `server_storage`
- `storage`
- `supercomputer`
- `switch`
- `tablet`
- `tape_storage`
- `terminal`
- `unsecure`
- `ups_enterprise`
- `ups_small`
- `usb_stick`
- `user_female`
- `user_male`
- `users`
- `video_projector`
- `video_projector_screen`
- `virtual_pc`
- `virtual_server`
- `virus`
- `web_server`
- `wireless_hub`
- `wireless_modem`

View File

@@ -0,0 +1,36 @@
# openstack
**Type:** mxgraph shapes
**Prefix:** `mxgraph.openstack`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.openstack.{shape};fillColor=#3F51B5;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (19)
- `cinder_volume`
- `cinder_volumeattachment`
- `designate_recordset`
- `designate_zone`
- `heat_autoscalinggroup`
- `heat_resourcegroup`
- `heat_scalingpolicy`
- `mxgraph.openstack`
- `neutron_floatingip`
- `neutron_floatingipassociation`
- `neutron_net`
- `neutron_port`
- `neutron_router`
- `neutron_routerinterface`
- `neutron_securitygroup`
- `neutron_subnet`
- `nova_keypair`
- `nova_server`
- `swift_container`

View File

@@ -0,0 +1,22 @@
# pid
**Type:** mxgraph shapes
**Prefix:** `mxgraph.pid2valves`, `mxgraph.pid2inst`, `mxgraph.pid2misc`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.pid2valves.valve;valveType=gate;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Valve Types
For `mxgraph.pid2valves.valve`, use `valveType=` with:
- `gate`, `globe`, `needle`, `ball`, `butterfly`, `diaphragm`, `plug`, `check`
## Other Prefixes
- `mxgraph.pid2inst` - Instruments (discInst, sharedCont, compFunc)
- `mxgraph.pid2misc` - Miscellaneous

View File

@@ -0,0 +1,57 @@
# rack
**Type:** mxgraph shapes
**Prefix:** `mxgraph.rack`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.rack.f5.arx_500;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="200" height="30" as="geometry" />
</mxCell>
```
Shapes are organized by vendor: `mxgraph.rack.{vendor}.{model}`
## Vendors
### F5
- `arx_500`
- `big_ip_1600`
- `big_ip_2000`
- `big_ip_4000`
### Dell
- `dell_poweredge_1u`
- `poweredge_630`
- `poweredge_730`
### HPE Aruba
HPE Aruba shapes have subcategories: `mxgraph.rack.hpe_aruba.{category}.{model}`
**gateways_controllers:**
- `aruba_7010_mobility_controller_front`
- `aruba_7010_mobility_controller_rear`
- `aruba_7024_mobility_controller_front`
- `aruba_7205_mobility_controller_front`
**security:**
- `aruba_clearpass_c1000_front`
- `aruba_clearpass_c2000_front`
- `aruba_clearpass_c3000_front`
**switches:**
- `j9772a_2530_48g_poeplus_switch`
- `j9773a_2530_24g_poeplus_switch`
- `jl253a_aruba_2930f_24g_4sfpplus_switch`
### General (rackGeneral)
Use `mxgraph.rackGeneral.{shape}` for generic rack items:
- `rackCabinet3`
- `plate`
(See draw.io Rack shape library for complete list)

View File

@@ -0,0 +1,116 @@
# salesforce
**Type:** mxgraph shapes
**Prefix:** `mxgraph.salesforce`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.salesforce.analytics;fillColor=#7f8de1;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
Replace `analytics` with any shape from the list below.
## Shapes (97)
- `analytics`
- `analytics2`
- `apps`
- `apps2`
- `automation`
- `automation2`
- `automotive`
- `automotive2`
- `bots`
- `bots2`
- `builders`
- `builders2`
- `channels`
- `channels2`
- `commerce`
- `commerce2`
- `communications`
- `communications2`
- `consumer_goods`
- `consumer_goods2`
- `customer_360`
- `customer_3602`
- `data`
- `data2`
- `education`
- `education2`
- `employees`
- `employees2`
- `energy`
- `energy2`
- `field_service`
- `field_service2`
- `financial_services`
- `financial_services2`
- `government`
- `government2`
- `health`
- `health2`
- `heroku`
- `heroku2`
- `inbox`
- `inbox2`
- `industries`
- `industries2`
- `integration`
- `integration2`
- `iot`
- `iot2`
- `learning`
- `learning2`
- `loyalty`
- `loyalty2`
- `manufacturing`
- `manufacturing2`
- `marketing`
- `marketing2`
- `media`
- `media2`
- `mxgraph.salesforce`
- `non_profit`
- `non_profit2`
- `partners`
- `partners2`
- `personalization`
- `personalization2`
- `philantrophy`
- `philantrophy2`
- `platform`
- `platform2`
- `privacy`
- `privacy2`
- `retail`
- `retail2`
- `sales`
- `sales2`
- `segments`
- `segments2`
- `service`
- `service2`
- `smb`
- `smb2`
- `social_studio`
- `social_studio2`
- `stream`
- `stream2`
- `success`
- `success2`
- `sustainability`
- `sustainability2`
- `transportation_and_technology`
- `transportation_and_technology2`
- `web`
- `web2`
- `work_com`
- `work_com2`
- `workflow`
- `workflow2`

179
docs/shape-libraries/sap.md Normal file
View File

@@ -0,0 +1,179 @@
# sap
**Type:** SVG images
**Path:** `img/lib/sap/`
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;image=img/lib/sap/SAP_Logo.svg;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (164)
- `1`
- `2`
- `3`
- `4`
- `5`
- `6`
- `7`
- `8`
- `9`
- `10`
- `11`
- `12`
- `13`
- `Adapter`
- `Admin`
- `Alert`
- `API`
- `API_Business_Hub_Enterprise`
- `App`
- `Application_Autoscaler`
- `Application_Frontend_Service`
- `Application_Vulnerability_Report`
- `Building`
- `Business_Application_Studio`
- `Business_Entity_Recognition`
- `Business_Process_Model_Connector_for_SAP_Signavio_Solutions`
- `Cloud`
- `Cloud_Connector`
- `Cloud_Connector2`
- `Cloud_Integration_Automation`
- `Cloud_Integration_Automation2`
- `Cloud_Transport_Management`
- `Data_Attribute_Recommendation`
- `Deploy`
- `Desktop`
- `Devices`
- `Document`
- `Document_Information_Extraction`
- `Documents`
- `Edge_Integration_Cell`
- `Event`
- `Extensibility_Service`
- `Factory`
- `Feature`
- `HTML5_App_Repository`
- `Identity_Authentication`
- `Identity_Authentication2`
- `Identity_Directory`
- `Identity_Directory2`
- `Identity_Provisioning`
- `Identity_Provisioning2`
- `Info`
- `Intelligent_Situation_Automation`
- `Invoice_Object_Recommendation`
- `Invoice_Object_Recommendation2`
- `Key`
- `Landscape_Portal_for_SAP_S4HANA_Cloud_ABAP_Environment`
- `Link`
- `Locked`
- `Machine`
- `Message`
- `Mobile`
- `OAuth_20`
- `Object_Store_on_SAP_BTP`
- `On-Premise`
- `Personalized_Recommendation`
- `SAP_AI_Core`
- `SAP_AI_Launchpad`
- `SAP_Alert_Notification_service_for_SAP_BTP`
- `SAP_Analytics_Cloud`
- `SAP_Analytics_Cloud_Embedded_Edition`
- `SAP_Application_Logging_service_for_SAP_BTP`
- `SAP_Asset_Performance_Management`
- `SAP_Audit_Log_Service`
- `SAP_Authorization_Management_Service`
- `SAP_Authorization_and_Trust_Management_service`
- `SAP_Automation_Pilot`
- `SAP_BTP,_ABAP_environment`
- `SAP_BTP,_Cloud_Foundry_runtime`
- `SAP_BTP,_Kyma_runtime`
- `SAP_Build`
- `SAP_Build_Apps`
- `SAP_Build_Apps_-_Copy`
- `SAP_Build_Code`
- `SAP_Build_Process_Automation`
- `SAP_Build_Process_Automation_-_Copy`
- `SAP_Build_Work_Zone_-_Advanced_Edition`
- `SAP_Build_Work_Zone_-_Standard_Edition`
- `SAP_Business_Accelerator_Hub`
- `SAP_Business_Data_Cloud`
- `SAP_Cloud_ALM`
- `SAP_Cloud_Application_Programming_Model`
- `SAP_Cloud_Identity,_SAP_Malware_Scanning_Service`
- `SAP_Cloud_Identity_Service`
- `SAP_Cloud_Logging`
- `SAP_Cloud_Management_Service`
- `SAP_Cloud_Transport_Management`
- `SAP_Collaborative_Demand_and_Capacity_Management`
- `SAP_Connectivity_Service`
- `SAP_Content_Agent_Service`
- `SAP_Continuous_Integration_and_Delivery`
- `SAP_Credential_Store`
- `SAP_Custom_Domain_service`
- `SAP_Data_Privacy_Integration`
- `SAP_Data_Retention_Manager`
- `SAP_Datasphere`
- `SAP_Destination_service`
- `SAP_Digital_Assistant`
- `SAP_Digital_Assistant_Service`
- `SAP_Digital_Manufacturing`
- `SAP_Document_Grounding`
- `SAP_Document_Management_Service`
- `SAP_Event_Broker_for_SAP_Cloud_Applications`
- `SAP_Green_Token`
- `SAP_HANA_Cloud`
- `SAP_HANA_Spatial_Services`
- `SAP_Health_Data_Services_for_FHIR`
- `SAP_Integration_Suite`
- `SAP_Integration_Suite_-_API_Managment`
- `SAP_Integration_Suite_-_Advanced_Event_Mesh`
- `SAP_Integration_Suite_-_Cloud_Integration`
- `SAP_Integration_Suite_-_Data_Space_Integration`
- `SAP_Integration_Suite_-_Event_Mesh`
- `SAP_Integration_Suite_-_Integration_Advisor`
- `SAP_Integration_Suite_-_Integration_Assessment`
- `SAP_Integration_Suite_-_Migration_Assessment`
- `SAP_Integration_Suite_-_Open_Connectors`
- `SAP_Integration_Suite_-_SAP_Graph`
- `SAP_Integration_Suite_-_Trading_Partner_Management`
- `SAP_Job_Scheduling_service`
- `SAP_Keystore_Service`
- `SAP_Landscape_Management_Cloud`
- `SAP_Logo`
- `SAP_Master_Data_Governance`
- `SAP_Master_Data_Integration`
- `SAP_Mobile_Services`
- `SAP_Monitoring_service_for_SAP_BTP`
- `SAP_Omnichannel_Promotion_Pricing`
- `SAP_PKI_Certificate_Service`
- `SAP_Persistence_Service_ASE`
- `SAP_Personal_Data_Manager`
- `SAP_Private_Link_service`
- `SAP_Project_and_Resource_Management`
- `SAP_Responsibility_Management_Service`
- `SAP_S4HANA_Cloud_for_Intelligent_Intercompany_Reconciliation`
- `SAP_S4HANA_for_MS_Teams`
- `SAP_Secure_Login_Service_for_SAP_GUI`
- `SAP_Service_Manager`
- `SAP_Software_as_a_Service_Provisioning_Service`
- `SAP_Solution_Lifecycle_Management_Service`
- `SAP_Sustainability_Data_Exchange`
- `SAP_Task_Center`
- `SAP_Translation_Hub`
- `SAP_Variant_Configuration_and_Pricing`
- `SAP_Watch_List_Screening`
- `Service_Ticket_Intelligence`
- `Service_Ticket_Intelligence2`
- `Settings`
- `Success`
- `Third_Party`
- `UI5_flexibility_for_key_users`
- `UI_Theme_Designer`
- `User`
- `Web`

View File

@@ -0,0 +1,68 @@
# sitemap
**Type:** mxgraph shapes
**Prefix:** `mxgraph.sitemap`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.sitemap.{shape};fillColor=#7ea6e0;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (51)
- `about_us`
- `audio`
- `biography`
- `blog`
- `calendar`
- `chart`
- `chat`
- `cloud`
- `contact`
- `contact_us`
- `document`
- `download`
- `error`
- `faq`
- `form`
- `gallery`
- `game`
- `home`
- `info`
- `jobs`
- `log`
- `login`
- `mail`
- `map`
- `mxgraph.sitemap`
- `news`
- `page`
- `payment`
- `photo`
- `portfolio`
- `post`
- `pricing`
- `print`
- `products`
- `profile`
- `references`
- `script`
- `search`
- `security`
- `services`
- `settings`
- `shopping`
- `sitemap`
- `slideshow`
- `sports`
- `success`
- `text`
- `upload`
- `user`
- `video`
- `warning`

112
docs/shape-libraries/vvd.md Normal file
View File

@@ -0,0 +1,112 @@
# vvd
**Type:** mxgraph shapes
**Prefix:** `mxgraph.vvd`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.vvd.{shape};fillColor=#00AEEF;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (95)
- `administrator`
- `app`
- `app_volumes_manager`
- `appstack_volume`
- `array_manager`
- `blueprint`
- `business_continuity_data_protection`
- `cd`
- `cloud_computing`
- `collective_nsx_esg`
- `consumption_plane`
- `cpu`
- `datacenter`
- `datastore`
- `disk`
- `document`
- `edge_gateway`
- `endpoint`
- `ethernet_port`
- `external_networks`
- `flash_drive`
- `folder`
- `guest_agent_customization`
- `horizon`
- `infrastructure`
- `key`
- `keyboard`
- `laptop`
- `log_files`
- `logical_distribution`
- `logical_firewall`
- `machine`
- `memory`
- `monitor`
- `mouse`
- `mxgraph.vvd`
- `networking`
- `networks`
- `nfvo`
- `nsx`
- `nsx_controller`
- `nsx_dashboard`
- `nsx_edge_and_load_balancer`
- `nsx_esg`
- `nsx_manager`
- `nsx_public_cloud_gateway`
- `on_demand_self_service`
- `ovdc_networks`
- `pair_sites`
- `phone`
- `physical_network_adapter`
- `physical_storage`
- `physical_upstream_router`
- `platform_services_controller`
- `protection_group`
- `protection_group_config`
- `recovery_plan`
- `resource_pool`
- `scsi_controller`
- `security`
- `server`
- `service_provider_cloud_environment`
- `site`
- `site_container`
- `site_recovery`
- `site_recovery_functional_icon`
- `ssd`
- `storage`
- `switch`
- `telco_network`
- `template`
- `tenant_key`
- `user_group`
- `vapp_network`
- `vcenter_server`
- `vcloud_director`
- `virtual_appliance`
- `virtual_machine`
- `virtual_switch`
- `vm_group`
- `vnf_m`
- `volumes_agent`
- `vpn`
- `vrealize_automation`
- `vrealize_log_insight`
- `vrealize_operations`
- `vrealize_orchestrator`
- `vrops`
- `vsan`
- `vshield`
- `vxlan`
- `wavefront`
- `web_browser`
- `wi_fi`
- `writable_volume`

View File

@@ -0,0 +1,194 @@
# webicons
**Type:** mxgraph shapes
**Prefix:** `mxgraph.webicons`
## Usage
```xml
<mxCell value="label" style="shape=mxgraph.webicons.{shape};fillColor=#3b5998;strokeColor=none;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="60" height="60" as="geometry" />
</mxCell>
```
## Shapes (177)
- `adfty`
- `adobe_pdf`
- `aim`
- `allvoices`
- `amazon`
- `amazon_2`
- `android`
- `apache`
- `apple`
- `apple_classic`
- `arduino`
- `ask`
- `atlassian`
- `audioboo`
- `aws`
- `aws_s3`
- `baidu`
- `bebo`
- `behance`
- `bing`
- `bitbucket`
- `blinklist`
- `blogger`
- `blogmarks`
- `bookmarks.fr`
- `box`
- `buddymarks`
- `buffer`
- `buzzfeed`
- `chrome`
- `citeulike`
- `confluence`
- `connotea`
- `dealsplus`
- `delicious`
- `designfloat`
- `deviantart`
- `digg`
- `diigo`
- `dopplr`
- `drawio1`
- `drawio2`
- `dribbble`
- `dropbox`
- `dropbox2`
- `drupal`
- `dzone`
- `ebay`
- `edmodo`
- `evernote`
- `facebook`
- `fancy`
- `fark`
- `fashiolista`
- `feed`
- `feedburner`
- `flickr`
- `folkd`
- `forrst`
- `fotolog`
- `freshbump`
- `fresqui`
- `friendfeed`
- `funp`
- `fwisp`
- `gabbr`
- `gamespot`
- `github`
- `gmail`
- `google`
- `google_drive`
- `google_hangout`
- `google_photos`
- `google_play`
- `google_play_light`
- `google_plus`
- `grooveshark`
- `hatena`
- `html5`
- `identi.ca`
- `instagram`
- `instapaper`
- `ios`
- `jamespot`
- `java`
- `joomla`
- `jquery`
- `json`
- `json_2`
- `last.fm`
- `linkagogo`
- `linkedin`
- `livejournal`
- `mail.ru`
- `meetup`
- `meneame`
- `messenger`
- `messenger_2`
- `messenger_3`
- `mind_body_green`
- `mongodb`
- `mxgraph.webicons`
- `myspace`
- `n4g`
- `netlog`
- `netvibes`
- `netvouz`
- `networkedblogs`
- `newsvine`
- `odnoklassniki`
- `oknotizie`
- `onedrive`
- `oracle`
- `paypal`
- `phone`
- `phonefavs`
- `pinterest`
- `plaxo`
- `playfire`
- `plurk`
- `pocket`
- `protopage`
- `readernaut`
- `reddit`
- `rss`
- `scoopit`
- `scribd`
- `segnalo`
- `sina`
- `sitejot`
- `skype`
- `skyrock`
- `slashdot`
- `sms`
- `socialvibe`
- `society6`
- `sonico`
- `soundcloud`
- `sourceforge`
- `sourceforge_2`
- `spring.me`
- `stackexchange`
- `stackoverflow`
- `startaid`
- `startlap`
- `steam`
- `stumbleupon`
- `stumpedia`
- `technorati`
- `translate`
- `tumblr`
- `tunein`
- `twitter`
- `two`
- `typepad`
- `viadeo`
- `viber`
- `viddler`
- `vimeo`
- `virb`
- `vkontakte`
- `wakoopa`
- `weheartit`
- `whatsapp`
- `wix`
- `wordpress`
- `wordpress_2`
- `xanga`
- `xerpi`
- `xing`
- `yahoo`
- `yahoo_2`
- `yammer`
- `yandex`
- `yelp`
- `yoolink`
- `youmob`

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration
# AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway
# Default: bedrock
AI_PROVIDER=bedrock
@@ -68,6 +68,13 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SILICONFLOW_API_KEY=sk-...
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
# Vercel AI Gateway Configuration
# Get your API key from: https://vercel.com/ai-gateway
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
# AI_GATEWAY_API_KEY=...
# AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai # Optional: Custom Gateway URL (for local dev or self-hosted Gateway)
# # If not set, uses Vercel default: https://ai-gateway.vercel.sh/v1/ai
# Langfuse Observability (Optional)
# Enable LLM tracing and analytics - https://langfuse.com
# LANGFUSE_PUBLIC_KEY=pk-lf-...

29
hooks/use-dictionary.ts Normal file
View File

@@ -0,0 +1,29 @@
"use client"
import React, { createContext, useContext } from "react"
import type { Dictionary } from "@/lib/i18n/dictionaries"
const DictionaryContext = createContext<Dictionary | null>(null)
export function DictionaryProvider({
children,
dictionary,
}: React.PropsWithChildren<{ dictionary: Dictionary }>) {
return React.createElement(
DictionaryContext.Provider,
{ value: dictionary },
children,
)
}
export function useDictionary() {
const dict = useContext(DictionaryContext)
if (!dict) {
throw new Error(
"useDictionary must be used within a DictionaryProvider",
)
}
return dict
}
export default useDictionary

View File

@@ -2,6 +2,7 @@ import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic"
import { azure, createAzure } from "@ai-sdk/azure"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway, gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
@@ -18,6 +19,7 @@ export type ProviderName =
| "openrouter"
| "deepseek"
| "siliconflow"
| "gateway"
interface ModelConfig {
model: any
@@ -42,6 +44,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"openrouter",
"deepseek",
"siliconflow",
"gateway",
]
// Bedrock provider options for Anthropic beta features
@@ -333,8 +336,10 @@ function buildProviderOptions(
case "deepseek":
case "openrouter":
case "siliconflow": {
case "siliconflow":
case "gateway": {
// These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs
break
}
@@ -356,6 +361,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY",
gateway: "AI_GATEWAY_API_KEY",
}
/**
@@ -495,6 +501,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
if (configured.length === 0) {
throw new Error(
`No AI provider configured. Please set one of the following API keys in your .env.local file:\n` +
`- AI_GATEWAY_API_KEY for Vercel AI Gateway\n` +
`- DEEPSEEK_API_KEY for DeepSeek\n` +
`- OPENAI_API_KEY for OpenAI\n` +
`- ANTHROPIC_API_KEY for Anthropic\n` +
@@ -672,9 +679,30 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break
}
case "gateway": {
// Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
// See: https://vercel.com/ai-gateway
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
const baseURL =
overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL
// Only use custom configuration if explicitly set (local dev or custom Gateway)
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
if (baseURL || overrides?.apiKey) {
const customGateway = createGateway({
apiKey,
...(baseURL && { baseURL }),
})
model = customGateway(modelId)
} else {
model = gateway(modelId)
}
break
}
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow`,
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`,
)
}

6
lib/i18n/config.ts Normal file
View File

@@ -0,0 +1,6 @@
export const i18n = {
defaultLocale: "en",
locales: ["en", "zh", "ja"],
} as const
export type Locale = (typeof i18n)["locales"][number]

18
lib/i18n/dictionaries.ts Normal file
View File

@@ -0,0 +1,18 @@
import "server-only"
import type { Locale } from "./config"
const dictionaries = {
en: () => import("./dictionaries/en.json").then((m) => m.default),
zh: () => import("./dictionaries/zh.json").then((m) => m.default),
ja: () => import("./dictionaries/ja.json").then((m) => m.default),
}
export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>>
export const hasLocale = (locale: string): locale is Locale =>
locale in dictionaries
export async function getDictionary(locale: Locale): Promise<Dictionary> {
return dictionaries[locale]()
}

View File

@@ -0,0 +1,184 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"confirm": "Confirm",
"clear": "Clear",
"edit": "Edit",
"delete": "Delete",
"loading": "Loading..",
"new": "NEW"
},
"nav": {
"about": "About",
"editor": "Editor",
"newChat": "Start fresh chat",
"settings": "Settings",
"hidePanel": "Hide chat panel (Ctrl+B)",
"showPanel": "Show chat panel (Ctrl+B)",
"aiChat": "AI Chat"
},
"providers": {
"useServerDefault": "Use Server Default",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow"
},
"chat": {
"placeholder": "Describe your diagram or upload a file...",
"send": "Send",
"sending": "Sending...",
"sendMessage": "Send message",
"clearConversation": "Clear conversation",
"diagramHistory": "Diagram history",
"saveDiagram": "Save diagram",
"uploadFile": "Upload file (image, PDF, text)",
"minimalStyle": "Minimal",
"styledMode": "Styled",
"minimalTooltip": "Use minimal for faster generation (no colors)",
"regenerate": "Regenerate response",
"copyResponse": "Copy response",
"copied": "Copied!",
"failedToCopy": "Failed to copy",
"goodResponse": "Good response",
"badResponse": "Bad response",
"clickToEdit": "Click to edit",
"editMessage": "Edit message",
"saveAndSubmit": "Save & Submit"
},
"examples": {
"title": "Create diagrams with AI",
"subtitle": "Describe what you want to create or upload an image to replicate",
"quickExamples": "Quick Examples",
"paperToDiagram": "Paper to Diagram",
"paperDescription": "Upload .pdf, .txt, .md, .json, .csv, .py, .js, .ts and more",
"animatedDiagram": "Animated Diagram",
"animatedDescription": "Draw a transformer architecture with animated connectors",
"awsArchitecture": "AWS Architecture",
"awsDescription": "Create a cloud architecture diagram with AWS icons",
"replicateFlowchart": "Replicate Flowchart",
"replicateDescription": "Upload and replicate an existing flowchart",
"creativeDrawing": "Creative Drawing",
"creativeDescription": "Draw something fun and creative",
"cachedNote": "Examples are cached for instant response",
"mcpServer": "MCP Server",
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor",
"preview": "PREVIEW"
},
"settings": {
"title": "Settings",
"description": "Configure your application settings.",
"accessCode": "Access Code",
"accessCodePlaceholder": "Enter access code",
"accessCodeDescription": "Required to use this application.",
"aiProvider": "AI Provider Settings",
"aiProviderDescription": "Use your own API key to bypass usage limits. Your key is stored locally in your browser and is never stored on the server.",
"provider": "Provider",
"modelId": "Model ID",
"apiKey": "API Key",
"apiKeyPlaceholder": "Your API key",
"baseUrl": "Base URL (optional)",
"customEndpoint": "Custom endpoint URL",
"overrides": "Overrides",
"clearSettings": "Clear Settings",
"useServerDefault": "Use Server Default",
"theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style",
"drawioStyleDescription": "Canvas style:",
"switchTo": "Switch to",
"minimal": "Minimal",
"sketch": "Sketch",
"closeProtection": "Close Protection",
"closeProtectionDescription": "Show confirmation when leaving the page."
},
"save": {
"title": "Save Diagram",
"description": "Choose a format and filename to save your diagram.",
"format": "Format",
"filename": "Filename",
"filenamePlaceholder": "Enter filename",
"formats": {
"drawio": "Draw.io XML",
"png": "PNG Image",
"svg": "SVG Image"
}
},
"history": {
"title": "Diagram History",
"description": "Here saved each diagram before AI modification.\nClick on a diagram to restore it",
"noHistory": "No history available yet. Send messages to create diagram history.",
"version": "Version",
"restoreTo": "Restore to Version {version}?"
},
"dialogs": {
"clearTitle": "Clear Everything?",
"clearDescription": "This will clear the current conversation and reset the diagram. This action cannot be undone.",
"clearEverything": "Clear Everything",
"clearSuccess": "Started a fresh chat"
},
"errors": {
"maxFiles": "Too many files. Maximum {max} allowed.",
"onlyMoreAllowed": "Only {slots} more file(s) allowed",
"fileExceeds": "\"{name}\" is {size} (exceeds {max}MB)",
"unsupportedType": "\"{name}\" is not a supported file type",
"filesRejected": "{count} files rejected:",
"andMore": "...and {count} more",
"invalidAccessCode": "Invalid or missing access code. Please configure it in Settings.",
"networkError": "Network error. Please check your connection.",
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
"validationFailed": "Diagram validation failed. Please try regenerating.",
"malformedXml": "AI generated invalid diagram XML. Please try regenerating.",
"failedToProcess": "Failed to process diagram. Please try regenerating.",
"sessionCorrupted": "Session data was corrupted. Starting fresh.",
"failedToSave": "Failed to save messages to localStorage",
"failedToRestore": "Failed to restore from localStorage",
"failedToPersist": "Failed to persist state before unload",
"failedToExport": "Error fetching chart data",
"failedToLoadExample": "Error loading example image"
},
"quota": {
"dailyLimit": "Daily Quota Reached",
"tokenLimit": "Daily Token Limit Reached",
"tpmLimit": "Rate Limit",
"tpmMessage": "Too many requests. Please wait a moment.",
"messageApi": "Oops — you've reached the daily API limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"messageToken": "Oops — you've reached the daily token limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
"reset": "Your limit resets tomorrow. Thanks for understanding!",
"selfHost": "Self-host",
"sponsor": "Sponsor",
"learnMore": "Learn more →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "Generate Diagram",
"editDiagram": "Edit Diagram",
"appendDiagram": "Continue Diagram",
"complete": "Complete",
"error": "Error",
"truncated": "Truncated"
},
"file": {
"reading": "Reading...",
"chars": "chars",
"removeFile": "Remove file"
},
"reasoning": {
"thinking": "Thinking...",
"thoughtFor": "Thought for {duration} seconds",
"thoughtBrief": "Thought for a few seconds"
},
"about": {
"modelChange": "Model Change & Usage Limits",
"walletCrying": "(Or: Why My Wallet is Crying)",
"seekingSponsorship": "Call for Sponsorship",
"contactMe": "Contact Me",
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
}
}

View File

@@ -0,0 +1,184 @@
{
"common": {
"save": "保存",
"cancel": "キャンセル",
"close": "閉じる",
"confirm": "確認",
"clear": "クリア",
"edit": "編集",
"delete": "削除",
"loading": "読み込み中..",
"new": "新規"
},
"nav": {
"about": "概要",
"editor": "エディタ",
"newChat": "新しいチャットを開始",
"settings": "設定",
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
"showPanel": "チャットパネルを表示 (Ctrl+B)",
"aiChat": "AI チャット"
},
"providers": {
"useServerDefault": "サーバーデフォルトを使用",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow"
},
"chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
"send": "送信",
"sending": "送信中...",
"sendMessage": "メッセージを送信",
"clearConversation": "会話をクリア",
"diagramHistory": "ダイアグラム履歴",
"saveDiagram": "ダイアグラムを保存",
"uploadFile": "ファイルをアップロード画像、PDF、テキスト",
"minimalStyle": "ミニマル",
"styledMode": "スタイル付き",
"minimalTooltip": "高速生成のためミニマルを使用(色なし)",
"regenerate": "応答を再生成",
"copyResponse": "応答をコピー",
"copied": "コピーしました!",
"failedToCopy": "コピーに失敗しました",
"goodResponse": "良い応答",
"badResponse": "悪い応答",
"clickToEdit": "クリックして編集",
"editMessage": "メッセージを編集",
"saveAndSubmit": "保存して送信"
},
"examples": {
"title": "AI でダイアグラムを作成",
"subtitle": "作成したいものを説明するか、画像をアップロードして複製",
"quickExamples": "クイック例",
"paperToDiagram": "論文からダイアグラムへ",
"paperDescription": ".pdf, .txt, .md, .json, .csv, .py, .js, .ts などをアップロード",
"animatedDiagram": "アニメーション図",
"animatedDescription": "アニメーションコネクタ付きの Transformer アーキテクチャを描画",
"awsArchitecture": "AWS アーキテクチャ",
"awsDescription": "AWS アイコンでクラウドアーキテクチャ図を作成",
"replicateFlowchart": "フローチャートを複製",
"replicateDescription": "既存のフローチャートをアップロードして複製",
"creativeDrawing": "クリエイティブな描画",
"creativeDescription": "楽しくてクリエイティブなものを描く",
"cachedNote": "例はキャッシュされ、即座に応答します",
"mcpServer": "MCP サーバー",
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用",
"preview": "プレビュー"
},
"settings": {
"title": "設定",
"description": "アプリケーション設定を構成します。",
"accessCode": "アクセスコード",
"accessCodePlaceholder": "アクセスコードを入力",
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
"aiProvider": "AI プロバイダー設定",
"aiProviderDescription": "独自の API キーを使用して使用制限を回避できます。キーはブラウザのローカルに保存され、サーバーには保存されません。",
"provider": "プロバイダー",
"modelId": "モデル ID",
"apiKey": "API キー",
"apiKeyPlaceholder": "あなたの API キー",
"baseUrl": "ベース URLオプション",
"customEndpoint": "カスタムエンドポイント URL",
"overrides": "上書き",
"clearSettings": "設定をクリア",
"useServerDefault": "サーバーデフォルトを使用",
"theme": "テーマ",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル",
"drawioStyleDescription": "キャンバススタイル:",
"switchTo": "切り替え",
"minimal": "ミニマル",
"sketch": "スケッチ",
"closeProtection": "ページ離脱確認",
"closeProtectionDescription": "ページを離れる際に確認を表示します。"
},
"save": {
"title": "ダイアグラムを保存",
"description": "形式とファイル名を選択してダイアグラムを保存します。",
"format": "形式",
"filename": "ファイル名",
"filenamePlaceholder": "ファイル名を入力",
"formats": {
"drawio": "Draw.io XML",
"png": "PNG 画像",
"svg": "SVG 画像"
}
},
"history": {
"title": "ダイアグラム履歴",
"description": "AI 修正前に保存された各ダイアグラム。\nダイアグラムをクリックして復元",
"noHistory": "まだ履歴がありません。メッセージを送信してダイアグラム履歴を作成してください。",
"version": "バージョン",
"restoreTo": "バージョン {version} に復元しますか?"
},
"dialogs": {
"clearTitle": "すべてクリアしますか?",
"clearDescription": "現在の会話をクリアし、ダイアグラムをリセットします。この操作は元に戻せません。",
"clearEverything": "すべてクリア",
"clearSuccess": "新しいチャットを開始しました"
},
"errors": {
"maxFiles": "ファイルが多すぎます。最大 {max} 個まで許可されています。",
"onlyMoreAllowed": "あと {slots} 個のファイルのみ許可されています",
"fileExceeds": "「{name}」は {size} です({max}MB を超えています)",
"unsupportedType": "「{name}」はサポートされていないファイルタイプです",
"filesRejected": "{count} 個のファイルが拒否されました:",
"andMore": "...およびさらに {count} 個",
"invalidAccessCode": "無効または欠落したアクセスコード。設定で入力してください。",
"networkError": "ネットワークエラー。接続を確認してください。",
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
"failedToSave": "localStorage へのメッセージの保存に失敗しました",
"failedToRestore": "localStorage からの復元に失敗しました",
"failedToPersist": "アンロード前の状態の永続化に失敗しました",
"failedToExport": "チャートデータの取得エラー",
"failedToLoadExample": "例の画像の読み込みエラー"
},
"quota": {
"dailyLimit": "1日の割当量に達しました",
"tokenLimit": "1日のトークン制限に達しました",
"tpmLimit": "レート制限",
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"messageToken": "おっと — このデモの1日のトークン制限に達しました個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
"reset": "制限は明日リセットされます。ご理解ありがとうございます!",
"selfHost": "セルフホスト",
"sponsor": "スポンサー",
"learnMore": "詳細 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "ダイアグラムを生成",
"editDiagram": "ダイアグラムを編集",
"appendDiagram": "ダイアグラムに追加",
"complete": "完了",
"error": "エラー",
"truncated": "切り捨て"
},
"file": {
"reading": "読み込み中...",
"chars": "文字",
"removeFile": "ファイルを削除"
},
"reasoning": {
"thinking": "考え中...",
"thoughtFor": "{duration} 秒考えました",
"thoughtBrief": "数秒考えました"
},
"about": {
"modelChange": "モデル変更と利用制限について",
"walletCrying": "(別名:お財布が悲鳴を上げています)",
"seekingSponsorship": "スポンサー募集",
"contactMe": "お問い合わせ",
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
}
}

View File

@@ -0,0 +1,184 @@
{
"common": {
"save": "保存",
"cancel": "取消",
"close": "关闭",
"confirm": "确认",
"clear": "清除",
"edit": "编辑",
"delete": "删除",
"loading": "加载中...",
"new": "新建"
},
"nav": {
"about": "关于",
"editor": "编辑器",
"newChat": "开始新对话",
"settings": "设置",
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
"showPanel": "显示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
},
"providers": {
"useServerDefault": "使用服务器默认值",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow"
},
"chat": {
"placeholder": "描述您的图表或上传文件...",
"send": "发送",
"sending": "发送中...",
"sendMessage": "发送消息",
"clearConversation": "清除对话",
"diagramHistory": "图表历史",
"saveDiagram": "保存图表",
"uploadFile": "上传文件图片、PDF、文本",
"minimalStyle": "简约",
"styledMode": "精致",
"minimalTooltip": "使用简约模式以加快生成速度(无颜色)",
"regenerate": "重新生成响应",
"copyResponse": "复制响应",
"copied": "已复制!",
"failedToCopy": "复制失败",
"goodResponse": "有帮助",
"badResponse": "无帮助",
"clickToEdit": "点击编辑",
"editMessage": "编辑消息",
"saveAndSubmit": "保存并提交"
},
"examples": {
"title": "用 AI 创建图表",
"subtitle": "描述您想要创建的内容或上传图片进行复制",
"quickExamples": "快速示例",
"paperToDiagram": "文档转图表",
"paperDescription": "上传 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等文件",
"animatedDiagram": "动画图表",
"animatedDescription": "绘制带有动画连接器的 Transformer 架构",
"awsArchitecture": "AWS 架构",
"awsDescription": "使用 AWS 图标创建云架构图",
"replicateFlowchart": "复制流程图",
"replicateDescription": "上传并复制现有流程图",
"creativeDrawing": "创意绘图",
"creativeDescription": "绘制有趣且富有创意的内容",
"cachedNote": "示例已缓存,可即时响应",
"mcpServer": "MCP 服务器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
"preview": "预览"
},
"settings": {
"title": "设置",
"description": "配置您的应用程序设置。",
"accessCode": "访问码",
"accessCodePlaceholder": "输入访问码",
"accessCodeDescription": "使用此应用程序需要访问码。",
"aiProvider": "AI 提供商设置",
"aiProviderDescription": "使用您自己的 API 密钥来绕过使用限制。您的密钥仅存储在浏览器本地,不会存储在服务器上。",
"provider": "提供商",
"modelId": "模型 ID",
"apiKey": "API 密钥",
"apiKeyPlaceholder": "您的 API 密钥",
"baseUrl": "基础 URL可选",
"customEndpoint": "自定义端点 URL",
"overrides": "覆盖",
"clearSettings": "清除设置",
"useServerDefault": "使用服务器默认值",
"theme": "主题",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式",
"drawioStyleDescription": "画布样式:",
"switchTo": "切换到",
"minimal": "简约",
"sketch": "草图",
"closeProtection": "关闭确认",
"closeProtectionDescription": "离开页面时显示确认。"
},
"save": {
"title": "保存图表",
"description": "选择格式和文件名以保存您的图表。",
"format": "格式",
"filename": "文件名",
"filenamePlaceholder": "输入文件名",
"formats": {
"drawio": "Draw.io XML",
"png": "PNG 图片",
"svg": "SVG 图片"
}
},
"history": {
"title": "图表历史",
"description": "在 AI 修改之前保存的每个图表。\n点击图表以恢复它",
"noHistory": "尚无历史记录。发送消息以创建图表历史。",
"version": "版本",
"restoreTo": "恢复到版本 {version}"
},
"dialogs": {
"clearTitle": "清除所有内容?",
"clearDescription": "这将清除当前对话并重置图表。此操作无法撤消。",
"clearEverything": "清除所有内容",
"clearSuccess": "已开始新对话"
},
"errors": {
"maxFiles": "文件太多。最多允许 {max} 个。",
"onlyMoreAllowed": "只能再添加 {slots} 个文件",
"fileExceeds": "\"{name}\" 大小为 {size}(超过 {max}MB",
"unsupportedType": "\"{name}\" 不是支持的文件类型",
"filesRejected": "{count} 个文件被拒绝:",
"andMore": "...还有 {count} 个",
"invalidAccessCode": "无效或缺少访问码。请在设置中配置。",
"networkError": "网络错误。请检查您的连接。",
"retryLimit": "已达到自动重试限制({max})。请手动重试。",
"validationFailed": "图表验证失败。请尝试重新生成。",
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
"failedToProcess": "无法处理图表。请尝试重新生成。",
"sessionCorrupted": "会话数据已损坏。重新开始。",
"failedToSave": "无法保存消息到 localStorage",
"failedToRestore": "无法从 localStorage 恢复",
"failedToPersist": "卸载前无法持久化状态",
"failedToExport": "获取图表数据时出错",
"failedToLoadExample": "加载示例图片时出错"
},
"quota": {
"dailyLimit": "已达每日配额",
"tokenLimit": "已达每日令牌限制",
"tpmLimit": "速率限制",
"tpmMessage": "请求过多。请稍等片刻。",
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
"reset": "您的限制将在明天重置。感谢您的理解!",
"selfHost": "自托管",
"sponsor": "赞助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "生成图表",
"editDiagram": "编辑图表",
"appendDiagram": "继续图表",
"complete": "完成",
"error": "错误",
"truncated": "已截断"
},
"file": {
"reading": "读取中...",
"chars": "字符",
"removeFile": "移除文件"
},
"reasoning": {
"thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒",
"thoughtBrief": "思考了几秒钟"
},
"about": {
"modelChange": "模型变更与用量限制",
"walletCrying": "(别名:我的钱包顶不住了)",
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
"contactMe": "联系我",
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2并设置了一些用量限制。详情请查看关于页面。"
}
}

14
lib/i18n/utils.ts Normal file
View File

@@ -0,0 +1,14 @@
export function formatMessage(
template: string | undefined,
vars?: Record<string, string | number | undefined>,
): string {
if (!template) return ""
if (!vars) return template
return template.replace(/\{(\w+)\}/g, (match, name) => {
const val = vars[name]
return val === undefined ? match : String(val)
})
}
export default formatMessage

View File

@@ -48,12 +48,19 @@ description: Continue generating diagram XML when display_diagram was truncated
parameters: {
xml: string // Continuation fragment (NO wrapper tags like <mxGraphModel> or <root>)
}
---Tool4---
tool name: get_shape_library
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, etc.) before creating diagrams with cloud/tech icons.
parameters: {
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, etc.
}
---End of tools---
IMPORTANT: Choose the right tool:
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
- Use get_shape_library for: Discovering available icons/shapes when creating cloud architecture or technical diagrams (call BEFORE display_diagram)
Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams
@@ -84,7 +91,7 @@ Note that:
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
- Return XML only via tool calls, never in text responses.
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
- Note that when you need to generate diagram about aws architecture, use **AWS 2025 icons**.
- For cloud/tech diagrams (AWS, Azure, GCP, K8s), call get_shape_library first to discover available icon shapes and their syntax.
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
When using edit_diagram tool:

View File

@@ -1054,7 +1054,31 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
fixes.push("Fixed <Cell> tags to <mxCell>")
}
// 8b. Remove non-draw.io tags (LLM sometimes includes Claude's function calling XML)
// 8b. Fix common closing tag typos (MUST run before foreign tag removal)
const tagTypos = [
{ wrong: /<\/mxElement>/gi, right: "</mxCell>", name: "</mxElement>" },
{ wrong: /<\/mxcell>/g, right: "</mxCell>", name: "</mxcell>" }, // case sensitivity
{
wrong: /<\/mxgeometry>/g,
right: "</mxGeometry>",
name: "</mxgeometry>",
},
{ wrong: /<\/mxpoint>/g, right: "</mxPoint>", name: "</mxpoint>" },
{
wrong: /<\/mxgraphmodel>/gi,
right: "</mxGraphModel>",
name: "</mxgraphmodel>",
},
]
for (const { wrong, right, name } of tagTypos) {
const before = fixed
fixed = fixed.replace(wrong, right)
if (fixed !== before) {
fixes.push(`Fixed typo ${name} to ${right}`)
}
}
// 8c. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)
// Valid draw.io tags: mxfile, diagram, mxGraphModel, root, mxCell, mxGeometry, mxPoint, Array, Object
const validDrawioTags = new Set([
"mxfile",
@@ -1079,7 +1103,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
}
if (foreignTags.size > 0) {
console.log(
"[autoFixXml] Step 8b: Found foreign tags:",
"[autoFixXml] Step 8c: Found foreign tags:",
Array.from(foreignTags),
)
for (const tag of foreignTags) {
@@ -1093,29 +1117,6 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
)
}
// 9. Fix common closing tag typos
const tagTypos = [
{ wrong: /<\/mxElement>/gi, right: "</mxCell>", name: "</mxElement>" },
{ wrong: /<\/mxcell>/g, right: "</mxCell>", name: "</mxcell>" }, // case sensitivity
{
wrong: /<\/mxgeometry>/g,
right: "</mxGeometry>",
name: "</mxgeometry>",
},
{ wrong: /<\/mxpoint>/g, right: "</mxPoint>", name: "</mxpoint>" },
{
wrong: /<\/mxgraphmodel>/gi,
right: "</mxGraphModel>",
name: "</mxgraphmodel>",
},
]
for (const { wrong, right, name } of tagTypos) {
if (wrong.test(fixed)) {
fixed = fixed.replace(wrong, right)
fixes.push(`Fixed typo ${name} to ${right}`)
}
}
// 10. Fix unclosed tags by appending missing closing tags
// Use parseXmlTags helper to track open tags
const tagStack: string[] = []

View File

@@ -1,8 +1,12 @@
import type { NextConfig } from "next"
import packageJson from "./package.json"
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
env: {
APP_VERSION: packageJson.version,
},
}
export default nextConfig

154
package-lock.json generated
View File

@@ -1,22 +1,24 @@
{
"name": "next-ai-draw-io",
"version": "0.4.0",
"version": "0.4.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "next-ai-draw-io",
"version": "0.4.0",
"version": "0.4.4",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.70",
"@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/azure": "^2.0.69",
"@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/gateway": "^2.0.21",
"@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.107",
"@aws-sdk/credential-providers": "^3.943.0",
"@formatjs/intl-localematcher": "^0.7.2",
"@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9",
@@ -43,6 +45,7 @@
"jsonrepair": "^3.13.1",
"lucide-react": "^0.483.0",
"motion": "^12.23.25",
"negotiator": "^1.0.0",
"next": "^16.0.7",
"ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0",
@@ -54,6 +57,7 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
@@ -62,9 +66,10 @@
},
"devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.3.8",
"@biomejs/biome": "^2.3.10",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/negotiator": "^0.6.4",
"@types/node": "^20",
"@types/pako": "^2.0.3",
"@types/react": "^19",
@@ -199,13 +204,13 @@
}
},
"node_modules/@ai-sdk/gateway": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.18.tgz",
"integrity": "sha512-sDQcW+6ck2m0pTIHW6BPHD7S125WD3qNkx/B8sEzJp/hurocmJ5Cni0ybExg6sQMGo+fr/GWOwpHF1cmCdg5rQ==",
"version": "2.0.21",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.21.tgz",
"integrity": "sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18",
"@ai-sdk/provider-utils": "3.0.19",
"@vercel/oidc": "3.0.5"
},
"engines": {
@@ -215,6 +220,23 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider-utils": {
"version": "3.0.19",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.19.tgz",
"integrity": "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.0.tgz",
@@ -1398,9 +1420,9 @@
}
},
"node_modules/@biomejs/biome": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.8.tgz",
"integrity": "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==",
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.10.tgz",
"integrity": "sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@@ -1414,20 +1436,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.3.8",
"@biomejs/cli-darwin-x64": "2.3.8",
"@biomejs/cli-linux-arm64": "2.3.8",
"@biomejs/cli-linux-arm64-musl": "2.3.8",
"@biomejs/cli-linux-x64": "2.3.8",
"@biomejs/cli-linux-x64-musl": "2.3.8",
"@biomejs/cli-win32-arm64": "2.3.8",
"@biomejs/cli-win32-x64": "2.3.8"
"@biomejs/cli-darwin-arm64": "2.3.10",
"@biomejs/cli-darwin-x64": "2.3.10",
"@biomejs/cli-linux-arm64": "2.3.10",
"@biomejs/cli-linux-arm64-musl": "2.3.10",
"@biomejs/cli-linux-x64": "2.3.10",
"@biomejs/cli-linux-x64-musl": "2.3.10",
"@biomejs/cli-win32-arm64": "2.3.10",
"@biomejs/cli-win32-x64": "2.3.10"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.8.tgz",
"integrity": "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==",
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.10.tgz",
"integrity": "sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==",
"cpu": [
"arm64"
],
@@ -1442,9 +1464,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.8.tgz",
"integrity": "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==",
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.10.tgz",
"integrity": "sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==",
"cpu": [
"x64"
],
@@ -1459,9 +1481,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.8.tgz",
"integrity": "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==",
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.10.tgz",
"integrity": "sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==",
"cpu": [
"arm64"
],
@@ -1476,9 +1498,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.8.tgz",
"integrity": "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==",
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.10.tgz",
"integrity": "sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==",
"cpu": [
"arm64"
],
@@ -1493,9 +1515,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.8.tgz",
"integrity": "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==",
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.10.tgz",
"integrity": "sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==",
"cpu": [
"x64"
],
@@ -1510,9 +1532,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.8.tgz",
"integrity": "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==",
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.10.tgz",
"integrity": "sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==",
"cpu": [
"x64"
],
@@ -1527,9 +1549,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.8.tgz",
"integrity": "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==",
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.10.tgz",
"integrity": "sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==",
"cpu": [
"arm64"
],
@@ -1544,9 +1566,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.8.tgz",
"integrity": "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==",
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.10.tgz",
"integrity": "sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ==",
"cpu": [
"x64"
],
@@ -1885,6 +1907,15 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.7.2.tgz",
"integrity": "sha512-1cpFlw1omNn2/Uz/vAdAyovlh7qS/po7MWipH3JrShT/lVUh2+lbEAWquyh9yRa84fqlLulTt7oysGtjATujZQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -5483,6 +5514,13 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-elf6BsTq+AkyNsb2h5cGNst2Mc7dPliVoAPm1fXglC/BM3f2pFA40BaSSv3E5lyHteEawVKLP+8TwiY1DMNb3A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.17.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz",
@@ -6130,6 +6168,23 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ai/node_modules/@ai-sdk/gateway": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.18.tgz",
"integrity": "sha512-sDQcW+6ck2m0pTIHW6BPHD7S125WD3qNkx/B8sEzJp/hurocmJ5Cni0ybExg6sQMGo+fr/GWOwpHF1cmCdg5rQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18",
"@vercel/oidc": "3.0.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -10681,6 +10736,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next": {
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.10.tgz",
@@ -11724,6 +11788,12 @@
"node": ">=10"
}
},
"node_modules/server-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "next-ai-draw-io",
"version": "0.4.3",
"version": "0.4.4",
"license": "Apache-2.0",
"private": true,
"scripts": {
@@ -17,10 +17,12 @@
"@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/azure": "^2.0.69",
"@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/gateway": "^2.0.21",
"@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.107",
"@aws-sdk/credential-providers": "^3.943.0",
"@formatjs/intl-localematcher": "^0.7.2",
"@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9",
@@ -47,6 +49,7 @@
"jsonrepair": "^3.13.1",
"lucide-react": "^0.483.0",
"motion": "^12.23.25",
"negotiator": "^1.0.0",
"next": "^16.0.7",
"ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0",
@@ -58,6 +61,7 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
@@ -72,9 +76,10 @@
},
"devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.3.8",
"@biomejs/biome": "^2.3.10",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/negotiator": "^0.6.4",
"@types/node": "^20",
"@types/pako": "^2.0.3",
"@types/react": "^19",

View File

@@ -86,6 +86,7 @@ Use the standard MCP configuration with:
## Features
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
- **Version History**: Restore previous diagram versions with visual thumbnails - click the clock button (bottom-right) to browse and restore earlier states
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files

View File

@@ -1,12 +1,12 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.0",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.0",
"version": "0.1.3",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",

View File

@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.2",
"version": "0.1.4",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",

View File

@@ -0,0 +1,62 @@
/**
* Simple diagram history - matches Next.js app pattern
* Stores {xml, svg} entries in a circular buffer
*/
import { log } from "./logger.js"
const MAX_HISTORY = 20
const historyStore = new Map<string, Array<{ xml: string; svg: string }>>()
export function addHistory(sessionId: string, xml: string, svg = ""): number {
let history = historyStore.get(sessionId)
if (!history) {
history = []
historyStore.set(sessionId, history)
}
// Dedupe: skip if same as last entry
const last = history[history.length - 1]
if (last?.xml === xml) {
return history.length - 1
}
history.push({ xml, svg })
// Circular buffer
if (history.length > MAX_HISTORY) {
history.shift()
}
log.debug(`History: session=${sessionId}, entries=${history.length}`)
return history.length - 1
}
export function getHistory(
sessionId: string,
): Array<{ xml: string; svg: string }> {
return historyStore.get(sessionId) || []
}
export function getHistoryEntry(
sessionId: string,
index: number,
): { xml: string; svg: string } | undefined {
const history = historyStore.get(sessionId)
return history?.[index]
}
export function clearHistory(sessionId: string): void {
historyStore.delete(sessionId)
}
export function updateLastHistorySvg(sessionId: string, svg: string): boolean {
const history = historyStore.get(sessionId)
if (!history || history.length === 0) return false
const last = history[history.length - 1]
if (!last.svg) {
last.svg = svg
return true
}
return false
}

View File

@@ -1,55 +1,50 @@
/**
* Embedded HTTP Server for MCP
*
* Serves a static HTML page with draw.io embed and handles state sync.
* This eliminates the need for an external Next.js app.
* Serves draw.io embed with state sync and history UI
*/
import http from "node:http"
import {
addHistory,
clearHistory,
getHistory,
getHistoryEntry,
updateLastHistorySvg,
} from "./history.js"
import { log } from "./logger.js"
interface SessionState {
xml: string
version: number
lastUpdated: Date
svg?: string // Cached SVG from last browser save
}
// In-memory state store (shared with MCP server in same process)
export const stateStore = new Map<string, SessionState>()
let server: http.Server | null = null
let serverPort: number = 6002
const MAX_PORT = 6020 // Don't retry beyond this port
const SESSION_TTL = 60 * 60 * 1000 // 1 hour
let serverPort = 6002
const MAX_PORT = 6020
const SESSION_TTL = 60 * 60 * 1000
/**
* Get state for a session
*/
export function getState(sessionId: string): SessionState | undefined {
return stateStore.get(sessionId)
}
/**
* Set state for a session
*/
export function setState(sessionId: string, xml: string): number {
export function setState(sessionId: string, xml: string, svg?: string): number {
const existing = stateStore.get(sessionId)
const newVersion = (existing?.version || 0) + 1
stateStore.set(sessionId, {
xml,
version: newVersion,
lastUpdated: new Date(),
svg: svg || existing?.svg, // Preserve cached SVG if not provided
})
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
return newVersion
}
/**
* Start the embedded HTTP server
*/
export function startHttpServer(port: number = 6002): Promise<number> {
export function startHttpServer(port = 6002): Promise<number> {
return new Promise((resolve, reject) => {
if (server) {
resolve(serverPort)
@@ -81,15 +76,12 @@ export function startHttpServer(port: number = 6002): Promise<number> {
server.listen(port, () => {
serverPort = port
log.info(`Embedded HTTP server running on http://localhost:${port}`)
log.info(`HTTP server running on http://localhost:${port}`)
resolve(port)
})
})
}
/**
* Stop the HTTP server
*/
export function stopHttpServer(): void {
if (server) {
server.close()
@@ -97,39 +89,29 @@ export function stopHttpServer(): void {
}
}
/**
* Clean up expired sessions
*/
function cleanupExpiredSessions(): void {
const now = Date.now()
for (const [sessionId, state] of stateStore) {
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
stateStore.delete(sessionId)
clearHistory(sessionId)
log.info(`Cleaned up expired session: ${sessionId}`)
}
}
}
// Run cleanup every 5 minutes
setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
/**
* Get the current server port
*/
export function getServerPort(): number {
return serverPort
}
/**
* Handle HTTP requests
*/
function handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
): void {
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
// CORS headers for local development
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
@@ -140,43 +122,23 @@ function handleRequest(
return
}
// Route handling
if (url.pathname === "/" || url.pathname === "/index.html") {
serveHtml(req, res, url)
} else if (
url.pathname === "/api/state" ||
url.pathname === "/api/mcp/state"
) {
res.writeHead(200, { "Content-Type": "text/html" })
res.end(getHtmlPage(url.searchParams.get("mcp") || ""))
} else if (url.pathname === "/api/state") {
handleStateApi(req, res, url)
} else if (
url.pathname === "/api/health" ||
url.pathname === "/api/mcp/health"
) {
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ status: "ok", mcp: true }))
} else if (url.pathname === "/api/history") {
handleHistoryApi(req, res, url)
} else if (url.pathname === "/api/restore") {
handleRestoreApi(req, res)
} else if (url.pathname === "/api/history-svg") {
handleHistorySvgApi(req, res)
} else {
res.writeHead(404)
res.end("Not Found")
}
}
/**
* Serve the HTML page with draw.io embed
*/
function serveHtml(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
): void {
const sessionId = url.searchParams.get("mcp") || ""
res.writeHead(200, { "Content-Type": "text/html" })
res.end(getHtmlPage(sessionId))
}
/**
* Handle state API requests
*/
function handleStateApi(
req: http.IncomingMessage,
res: http.ServerResponse,
@@ -189,14 +151,12 @@ function handleStateApi(
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
const state = stateStore.get(sessionId)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
xml: state?.xml || null,
version: state?.version || 0,
lastUpdated: state?.lastUpdated?.toISOString() || null,
}),
)
} else if (req.method === "POST") {
@@ -206,14 +166,13 @@ function handleStateApi(
})
req.on("end", () => {
try {
const { sessionId, xml } = JSON.parse(body)
const { sessionId, xml, svg } = JSON.parse(body)
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
const version = setState(sessionId, xml)
const version = setState(sessionId, xml, svg)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, version }))
} catch {
@@ -227,35 +186,179 @@ function handleStateApi(
}
}
/**
* Generate the HTML page with draw.io embed
*/
function handleHistoryApi(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
): void {
if (req.method !== "GET") {
res.writeHead(405)
res.end("Method Not Allowed")
return
}
const sessionId = url.searchParams.get("sessionId")
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
const history = getHistory(sessionId)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
entries: history.map((entry, i) => ({ index: i, svg: entry.svg })),
count: history.length,
}),
)
}
function handleRestoreApi(
req: http.IncomingMessage,
res: http.ServerResponse,
): void {
if (req.method !== "POST") {
res.writeHead(405)
res.end("Method Not Allowed")
return
}
let body = ""
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try {
const { sessionId, index } = JSON.parse(body)
if (!sessionId || index === undefined) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(
JSON.stringify({ error: "sessionId and index required" }),
)
return
}
const entry = getHistoryEntry(sessionId, index)
if (!entry) {
res.writeHead(404, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Entry not found" }))
return
}
const newVersion = setState(sessionId, entry.xml)
addHistory(sessionId, entry.xml, entry.svg)
log.info(`Restored session ${sessionId} to index ${index}`)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, newVersion }))
} catch {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Invalid JSON" }))
}
})
}
function handleHistorySvgApi(
req: http.IncomingMessage,
res: http.ServerResponse,
): void {
if (req.method !== "POST") {
res.writeHead(405)
res.end("Method Not Allowed")
return
}
let body = ""
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try {
const { sessionId, svg } = JSON.parse(body)
if (!sessionId || !svg) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId and svg required" }))
return
}
updateLastHistorySvg(sessionId, svg)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true }))
} catch {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Invalid JSON" }))
}
})
}
function getHtmlPage(sessionId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Draw.io MCP - ${sessionId || "No Session"}</title>
<title>Draw.io MCP</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
#header {
padding: 8px 16px;
background: #1a1a2e;
color: #eee;
font-family: system-ui, sans-serif;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px; background: #1a1a2e; color: #eee;
font-family: system-ui, sans-serif; font-size: 14px;
display: flex; justify-content: space-between; align-items: center;
}
#header .session { color: #888; font-size: 12px; }
#header .status { font-size: 12px; }
#header .status.connected { color: #4ade80; }
#header .status.disconnected { color: #f87171; }
#drawio { flex: 1; border: none; }
#history-btn {
position: fixed; bottom: 24px; right: 24px;
width: 48px; height: 48px; border-radius: 50%;
background: #3b82f6; color: white; border: none; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
#history-btn:hover { background: #2563eb; }
#history-btn:disabled { background: #6b7280; cursor: not-allowed; }
#history-btn svg { width: 24px; height: 24px; }
#history-modal {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.5); z-index: 2000;
align-items: center; justify-content: center;
}
#history-modal.open { display: flex; }
.modal-content {
background: white; border-radius: 12px;
width: 90%; max-width: 500px; max-height: 70vh;
display: flex; flex-direction: column;
}
.modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
.modal-header h2 { font-size: 18px; margin: 0; }
.modal-body { flex: 1; overflow-y: auto; padding: 16px; }
.modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.history-item {
border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px;
cursor: pointer; text-align: center;
}
.history-item:hover { border-color: #3b82f6; }
.history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
.history-item .thumb {
aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
margin-bottom: 4px; overflow: hidden;
}
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
.history-item .label { font-size: 12px; color: #666; }
.btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-secondary { background: #f3f4f6; color: #374151; }
.empty { text-align: center; padding: 40px; color: #666; }
</style>
</head>
<body>
@@ -263,121 +366,176 @@ function getHtmlPage(sessionId: string): string {
<div id="header">
<div>
<strong>Draw.io MCP</strong>
<span class="session">${sessionId ? `Session: ${sessionId}` : "No MCP session"}</span>
<span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span>
</div>
<div id="status" class="status disconnected">Connecting...</div>
</div>
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
</div>
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
<div id="history-modal">
<div class="modal-content">
<div class="modal-header"><h2>History</h2></div>
<div class="modal-body">
<div id="history-grid" class="history-grid"></div>
<div id="history-empty" class="empty" style="display:none;">No history yet</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancel-btn">Cancel</button>
<button class="btn btn-primary" id="restore-btn" disabled>Restore</button>
</div>
</div>
</div>
<script>
const sessionId = "${sessionId}";
const iframe = document.getElementById('drawio');
const statusEl = document.getElementById('status');
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
let pendingSvgExport = null;
let pendingAiSvg = false;
let currentVersion = 0;
let isDrawioReady = false;
let pendingXml = null;
let lastLoadedXml = null;
// Listen for messages from draw.io
window.addEventListener('message', (event) => {
if (event.origin !== 'https://embed.diagrams.net') return;
window.addEventListener('message', (e) => {
if (e.origin !== 'https://embed.diagrams.net') return;
try {
const msg = JSON.parse(event.data);
handleDrawioMessage(msg);
} catch (e) {
// Ignore non-JSON messages
}
const msg = JSON.parse(e.data);
if (msg.event === 'init') {
isReady = true;
statusEl.textContent = 'Ready';
statusEl.className = 'status connected';
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
// Request SVG export, then push state with SVG
pendingSvgExport = msg.xml;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
// Fallback if export doesn't respond
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
} else if (msg.event === 'export' && msg.data) {
let svg = msg.data;
if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
if (pendingSvgExport) {
const xml = pendingSvgExport;
pendingSvgExport = null;
pushState(xml, svg);
} else if (pendingAiSvg) {
pendingAiSvg = false;
fetch('/api/history-svg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, svg })
}).catch(() => {});
}
}
} catch {}
});
function handleDrawioMessage(msg) {
if (msg.event === 'init') {
isDrawioReady = true;
statusEl.textContent = 'Ready';
statusEl.className = 'status connected';
// Load pending XML if any
if (pendingXml) {
loadDiagram(pendingXml);
pendingXml = null;
}
} else if (msg.event === 'save') {
// User saved - push to state
if (msg.xml && msg.xml !== lastLoadedXml) {
pushState(msg.xml);
}
} else if (msg.event === 'export') {
// Export completed
if (msg.data) {
pushState(msg.data);
}
} else if (msg.event === 'autosave') {
// Autosave - push to state
if (msg.xml && msg.xml !== lastLoadedXml) {
pushState(msg.xml);
}
function loadDiagram(xml, capturePreview = false) {
if (!isReady) { pendingXml = xml; return; }
lastXml = xml;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
if (capturePreview) {
setTimeout(() => {
pendingAiSvg = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
}, 500);
}
}
function loadDiagram(xml) {
if (!isDrawioReady) {
pendingXml = xml;
return;
}
lastLoadedXml = xml;
iframe.contentWindow.postMessage(JSON.stringify({
action: 'load',
xml: xml,
autosave: 1
}), '*');
}
async function pushState(xml) {
async function pushState(xml, svg = '') {
if (!sessionId) return;
try {
const response = await fetch('/api/state', {
const r = await fetch('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, xml })
body: JSON.stringify({ sessionId, xml, svg })
});
if (response.ok) {
const result = await response.json();
currentVersion = result.version;
lastLoadedXml = xml;
}
} catch (e) {
console.error('Failed to push state:', e);
}
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
} catch (e) { console.error('Push failed:', e); }
}
async function pollState() {
async function poll() {
if (!sessionId) return;
try {
const response = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
if (!response.ok) return;
const state = await response.json();
if (state.version && state.version > currentVersion && state.xml) {
currentVersion = state.version;
loadDiagram(state.xml);
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
if (!r.ok) return;
const s = await r.json();
if (s.version > currentVersion && s.xml) {
currentVersion = s.version;
loadDiagram(s.xml, true);
}
} catch (e) {
console.error('Failed to poll state:', e);
}
} catch {}
}
// Start polling if we have a session
if (sessionId) {
pollState();
setInterval(pollState, 2000);
if (sessionId) { poll(); setInterval(poll, 2000); }
// History UI
const historyBtn = document.getElementById('history-btn');
const historyModal = document.getElementById('history-modal');
const historyGrid = document.getElementById('history-grid');
const historyEmpty = document.getElementById('history-empty');
const restoreBtn = document.getElementById('restore-btn');
const cancelBtn = document.getElementById('cancel-btn');
let historyData = [], selectedIdx = null;
historyBtn.onclick = async () => {
if (!sessionId) return;
try {
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
if (r.ok) {
const d = await r.json();
historyData = d.entries || [];
renderHistory();
}
} catch {}
historyModal.classList.add('open');
};
cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedIdx = null; restoreBtn.disabled = true; };
historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };
function renderHistory() {
if (historyData.length === 0) {
historyGrid.style.display = 'none';
historyEmpty.style.display = 'block';
return;
}
historyGrid.style.display = 'grid';
historyEmpty.style.display = 'none';
historyGrid.innerHTML = historyData.map((e, i) => \`
<div class="history-item" data-idx="\${e.index}">
<div class="thumb">\${e.svg ? \`<img src="\${e.svg}">\` : '#' + e.index}</div>
<div class="label">#\${e.index}</div>
</div>
\`).join('');
historyGrid.querySelectorAll('.history-item').forEach(item => {
item.onclick = () => {
const idx = parseInt(item.dataset.idx);
if (selectedIdx === idx) { selectedIdx = null; restoreBtn.disabled = true; }
else { selectedIdx = idx; restoreBtn.disabled = false; }
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.idx) === selectedIdx));
};
});
}
restoreBtn.onclick = async () => {
if (selectedIdx === null) return;
restoreBtn.disabled = true;
restoreBtn.textContent = 'Restoring...';
try {
const r = await fetch('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, index: selectedIdx })
});
if (r.ok) { cancelBtn.onclick(); await poll(); }
else { alert('Restore failed'); }
} catch { alert('Restore failed'); }
restoreBtn.textContent = 'Restore';
};
</script>
</body>
</html>`

View File

@@ -34,13 +34,10 @@ import {
applyDiagramOperations,
type DiagramOperation,
} from "./diagram-operations.js"
import {
getServerPort,
getState,
setState,
startHttpServer,
} from "./http-server.js"
import { addHistory } from "./history.js"
import { getState, setState, startHttpServer } from "./http-server.js"
import { log } from "./logger.js"
import { validateAndFixXml } from "./xml-validation.js"
// Server configuration
const config = {
@@ -160,7 +157,7 @@ server.registerTool(
.describe("The draw.io XML to display (mxGraphModel format)"),
},
},
async ({ xml }) => {
async ({ xml: inputXml }) => {
try {
if (!currentSession) {
return {
@@ -174,8 +171,43 @@ server.registerTool(
}
}
// Validate and auto-fix XML
let xml = inputXml
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
if (fixed) {
xml = fixed
log.info(`XML auto-fixed: ${fixes.join(", ")}`)
}
if (!valid && error) {
log.error(`XML validation failed: ${error}`)
return {
content: [
{
type: "text",
text: `Error: XML validation failed - ${error}`,
},
],
isError: true,
}
}
log.info(`Displaying diagram, ${xml.length} chars`)
// Sync from browser state first
const browserState = getState(currentSession.id)
if (browserState?.xml) {
currentSession.xml = browserState.xml
}
// Save user's state before AI overwrites (with cached SVG)
if (currentSession.xml) {
addHistory(
currentSession.id,
currentSession.xml,
browserState?.svg || "",
)
}
// Update session state
currentSession.xml = xml
currentSession.version++
@@ -183,6 +215,9 @@ server.registerTool(
// Push to embedded server state
setState(currentSession.id, xml)
// Save AI result (no SVG yet - will be captured by browser)
addHistory(currentSession.id, xml, "")
log.info(`Diagram displayed successfully`)
return {
@@ -274,10 +309,38 @@ server.registerTool(
log.info(`Editing diagram with ${operations.length} operation(s)`)
// Save before editing (with cached SVG from browser)
addHistory(
currentSession.id,
currentSession.xml,
browserState?.svg || "",
)
// Validate and auto-fix new_xml for each operation
const validatedOps = operations.map((op) => {
if (op.new_xml) {
const { valid, error, fixed, fixes } = validateAndFixXml(
op.new_xml,
)
if (fixed) {
log.info(
`Operation ${op.type} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`,
)
return { ...op, new_xml: fixed }
}
if (!valid && error) {
log.warn(
`Operation ${op.type} ${op.cell_id}: XML validation failed: ${error}`,
)
}
}
return op
})
// Apply operations
const { result, errors } = applyDiagramOperations(
currentSession.xml,
operations as DiagramOperation[],
validatedOps as DiagramOperation[],
)
if (errors.length > 0) {
@@ -294,6 +357,9 @@ server.registerTool(
// Push to embedded server
setState(currentSession.id, result)
// Save AI result (no SVG yet - will be captured by browser)
addHistory(currentSession.id, result, "")
log.info(`Diagram edited successfully`)
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`

View File

@@ -0,0 +1,926 @@
/**
* XML Validation and Auto-Fix for draw.io diagrams
* Copied from lib/utils.ts to avoid cross-package imports
*/
// ============================================================================
// Constants
// ============================================================================
/** Maximum XML size to process (1MB) - larger XMLs may cause performance issues */
const MAX_XML_SIZE = 1_000_000
/** Maximum iterations for aggressive cell dropping to prevent infinite loops */
const MAX_DROP_ITERATIONS = 10
/** Structural attributes that should not be duplicated in draw.io */
const STRUCTURAL_ATTRS = [
"edge",
"parent",
"source",
"target",
"vertex",
"connectable",
]
/** Valid XML entity names */
const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
// ============================================================================
// XML Parsing Helpers
// ============================================================================
interface ParsedTag {
tag: string
tagName: string
isClosing: boolean
isSelfClosing: boolean
startIndex: number
endIndex: number
}
/**
* Parse XML tags while properly handling quoted strings
*/
function parseXmlTags(xml: string): ParsedTag[] {
const tags: ParsedTag[] = []
let i = 0
while (i < xml.length) {
const tagStart = xml.indexOf("<", i)
if (tagStart === -1) break
// Find matching > by tracking quotes
let tagEnd = tagStart + 1
let inQuote = false
let quoteChar = ""
while (tagEnd < xml.length) {
const c = xml[tagEnd]
if (inQuote) {
if (c === quoteChar) inQuote = false
} else {
if (c === '"' || c === "'") {
inQuote = true
quoteChar = c
} else if (c === ">") {
break
}
}
tagEnd++
}
if (tagEnd >= xml.length) break
const tag = xml.substring(tagStart, tagEnd + 1)
i = tagEnd + 1
const tagMatch = /^<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)/.exec(tag)
if (!tagMatch) continue
tags.push({
tag,
tagName: tagMatch[2],
isClosing: tagMatch[1] === "/",
isSelfClosing: tag.endsWith("/>"),
startIndex: tagStart,
endIndex: tagEnd,
})
}
return tags
}
// ============================================================================
// Validation Helper Functions
// ============================================================================
/** Check for duplicate structural attributes in a tag */
function checkDuplicateAttributes(xml: string): string | null {
const structuralSet = new Set(STRUCTURAL_ATTRS)
const tagPattern = /<[^>]+>/g
let tagMatch
while ((tagMatch = tagPattern.exec(xml)) !== null) {
const tag = tagMatch[0]
const attrPattern = /\s([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=/g
const attributes = new Map<string, number>()
let attrMatch
while ((attrMatch = attrPattern.exec(tag)) !== null) {
const attrName = attrMatch[1]
attributes.set(attrName, (attributes.get(attrName) || 0) + 1)
}
const duplicates = Array.from(attributes.entries())
.filter(([name, count]) => count > 1 && structuralSet.has(name))
.map(([name]) => name)
if (duplicates.length > 0) {
return `Invalid XML: Duplicate structural attribute(s): ${duplicates.join(", ")}. Remove duplicate attributes.`
}
}
return null
}
/** Check for duplicate IDs in XML */
function checkDuplicateIds(xml: string): string | null {
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
const ids = new Map<string, number>()
let idMatch
while ((idMatch = idPattern.exec(xml)) !== null) {
const id = idMatch[1]
ids.set(id, (ids.get(id) || 0) + 1)
}
const duplicateIds = Array.from(ids.entries())
.filter(([, count]) => count > 1)
.map(([id, count]) => `'${id}' (${count}x)`)
if (duplicateIds.length > 0) {
return `Invalid XML: Found duplicate ID(s): ${duplicateIds.slice(0, 3).join(", ")}. All id attributes must be unique.`
}
return null
}
/** Check for tag mismatches using parsed tags */
function checkTagMismatches(xml: string): string | null {
const xmlWithoutComments = xml.replace(/<!--[\s\S]*?-->/g, "")
const tags = parseXmlTags(xmlWithoutComments)
const tagStack: string[] = []
for (const { tagName, isClosing, isSelfClosing } of tags) {
if (isClosing) {
if (tagStack.length === 0) {
return `Invalid XML: Closing tag </${tagName}> without matching opening tag`
}
const expected = tagStack.pop()
if (expected?.toLowerCase() !== tagName.toLowerCase()) {
return `Invalid XML: Expected closing tag </${expected}> but found </${tagName}>`
}
} else if (!isSelfClosing) {
tagStack.push(tagName)
}
}
if (tagStack.length > 0) {
return `Invalid XML: Document has ${tagStack.length} unclosed tag(s): ${tagStack.join(", ")}`
}
return null
}
/** Check for invalid character references */
function checkCharacterReferences(xml: string): string | null {
const charRefPattern = /&#x?[^;]+;?/g
let charMatch
while ((charMatch = charRefPattern.exec(xml)) !== null) {
const ref = charMatch[0]
if (ref.startsWith("&#x")) {
if (!ref.endsWith(";")) {
return `Invalid XML: Missing semicolon after hex reference: ${ref}`
}
const hexDigits = ref.substring(3, ref.length - 1)
if (hexDigits.length === 0 || !/^[0-9a-fA-F]+$/.test(hexDigits)) {
return `Invalid XML: Invalid hex character reference: ${ref}`
}
} else if (ref.startsWith("&#")) {
if (!ref.endsWith(";")) {
return `Invalid XML: Missing semicolon after decimal reference: ${ref}`
}
const decDigits = ref.substring(2, ref.length - 1)
if (decDigits.length === 0 || !/^[0-9]+$/.test(decDigits)) {
return `Invalid XML: Invalid decimal character reference: ${ref}`
}
}
}
return null
}
/** Check for invalid entity references */
function checkEntityReferences(xml: string): string | null {
const xmlWithoutComments = xml.replace(/<!--[\s\S]*?-->/g, "")
const bareAmpPattern = /&(?!(?:lt|gt|amp|quot|apos|#))/g
if (bareAmpPattern.test(xmlWithoutComments)) {
return "Invalid XML: Found unescaped & character(s). Replace & with &amp;"
}
const invalidEntityPattern = /&([a-zA-Z][a-zA-Z0-9]*);/g
let entityMatch
while (
(entityMatch = invalidEntityPattern.exec(xmlWithoutComments)) !== null
) {
if (!VALID_ENTITIES.has(entityMatch[1])) {
return `Invalid XML: Invalid entity reference: &${entityMatch[1]}; - use only valid XML entities (lt, gt, amp, quot, apos)`
}
}
return null
}
/** Check for nested mxCell tags using regex */
function checkNestedMxCells(xml: string): string | null {
const cellTagPattern = /<\/?mxCell[^>]*>/g
const cellStack: number[] = []
let cellMatch
while ((cellMatch = cellTagPattern.exec(xml)) !== null) {
const tag = cellMatch[0]
if (tag.startsWith("</mxCell>")) {
if (cellStack.length > 0) cellStack.pop()
} else if (!tag.endsWith("/>")) {
const isLabelOrGeometry =
/\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag)
if (!isLabelOrGeometry) {
cellStack.push(cellMatch.index)
if (cellStack.length > 1) {
return "Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements."
}
}
}
}
return null
}
// ============================================================================
// Main Validation Function
// ============================================================================
/**
* Validates draw.io XML structure for common issues
* Uses DOM parsing + additional regex checks for high accuracy
* @param xml - The XML string to validate
* @returns null if valid, error message string if invalid
*/
export function validateMxCellStructure(xml: string): string | null {
// Size check for performance
if (xml.length > MAX_XML_SIZE) {
console.warn(
`[validateMxCellStructure] XML size (${xml.length}) exceeds ${MAX_XML_SIZE} bytes, may cause performance issues`,
)
}
// 0. First use DOM parser to catch syntax errors (most accurate)
try {
const parser = new DOMParser()
const doc = parser.parseFromString(xml, "text/xml")
const parseError = doc.querySelector("parsererror")
if (parseError) {
return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use &lt; for <, &gt; for >, &amp; for &, &quot; for ". Regenerate the diagram with properly escaped values.`
}
// DOM-based checks for nested mxCell
const allCells = doc.querySelectorAll("mxCell")
for (const cell of allCells) {
if (cell.parentElement?.tagName === "mxCell") {
const id = cell.getAttribute("id") || "unknown"
return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.`
}
}
} catch (error) {
console.warn(
"[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:",
error,
)
}
// 1. Check for CDATA wrapper (invalid at document root)
if (/^\s*<!\[CDATA\[/.test(xml)) {
return "Invalid XML: XML is wrapped in CDATA section - remove <![CDATA[ from start and ]]> from end"
}
// 2. Check for duplicate structural attributes
const dupAttrError = checkDuplicateAttributes(xml)
if (dupAttrError) {
return dupAttrError
}
// 3. Check for unescaped < in attribute values
const attrValuePattern = /=\s*"([^"]*)"/g
let attrValMatch
while ((attrValMatch = attrValuePattern.exec(xml)) !== null) {
const value = attrValMatch[1]
if (/</.test(value) && !/&lt;/.test(value)) {
return "Invalid XML: Unescaped < character in attribute values. Replace < with &lt;"
}
}
// 4. Check for duplicate IDs
const dupIdError = checkDuplicateIds(xml)
if (dupIdError) {
return dupIdError
}
// 5. Check for tag mismatches
const tagMismatchError = checkTagMismatches(xml)
if (tagMismatchError) {
return tagMismatchError
}
// 6. Check invalid character references
const charRefError = checkCharacterReferences(xml)
if (charRefError) {
return charRefError
}
// 7. Check for invalid comment syntax (-- inside comments)
const commentPattern = /<!--([\s\S]*?)-->/g
let commentMatch
while ((commentMatch = commentPattern.exec(xml)) !== null) {
if (/--/.test(commentMatch[1])) {
return "Invalid XML: Comment contains -- (double hyphen) which is not allowed"
}
}
// 8. Check for unescaped entity references and invalid entity names
const entityError = checkEntityReferences(xml)
if (entityError) {
return entityError
}
// 9. Check for empty id attributes on mxCell
if (/<mxCell[^>]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) {
return "Invalid XML: Found mxCell element(s) with empty id attribute"
}
// 10. Check for nested mxCell tags
const nestedCellError = checkNestedMxCells(xml)
if (nestedCellError) {
return nestedCellError
}
return null
}
// ============================================================================
// Auto-Fix Function
// ============================================================================
/**
* Attempts to auto-fix common XML issues in draw.io diagrams
* @param xml - The XML string to fix
* @returns Object with fixed XML and list of fixes applied
*/
export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
let fixed = xml
const fixes: string[] = []
// 0. Fix JSON-escaped XML
if (/=\\"/.test(fixed)) {
fixed = fixed.replace(/\\"/g, '"')
fixed = fixed.replace(/\\n/g, "\n")
fixes.push("Fixed JSON-escaped XML")
}
// 1. Remove CDATA wrapper
if (/^\s*<!\[CDATA\[/.test(fixed)) {
fixed = fixed.replace(/^\s*<!\[CDATA\[/, "").replace(/\]\]>\s*$/, "")
fixes.push("Removed CDATA wrapper")
}
// 2. Remove text before XML declaration or root element
const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i)
if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) {
fixed = fixed.substring(xmlStart)
fixes.push("Removed text before XML root")
}
// 3. Fix duplicate attributes
let dupAttrFixed = false
fixed = fixed.replace(/<[^>]+>/g, (tag) => {
let newTag = tag
for (const attr of STRUCTURAL_ATTRS) {
const attrRegex = new RegExp(
`\\s${attr}\\s*=\\s*["'][^"']*["']`,
"gi",
)
const matches = tag.match(attrRegex)
if (matches && matches.length > 1) {
let firstKept = false
newTag = newTag.replace(attrRegex, (m) => {
if (!firstKept) {
firstKept = true
return m
}
dupAttrFixed = true
return ""
})
}
}
return newTag
})
if (dupAttrFixed) {
fixes.push("Removed duplicate structural attributes")
}
// 4. Fix unescaped & characters
const ampersandPattern =
/&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g
if (ampersandPattern.test(fixed)) {
fixed = fixed.replace(
/&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g,
"&amp;",
)
fixes.push("Escaped unescaped & characters")
}
// 5. Fix invalid entity names (double-escaping)
const invalidEntities = [
{ pattern: /&ampquot;/g, replacement: "&quot;", name: "&ampquot;" },
{ pattern: /&amplt;/g, replacement: "&lt;", name: "&amplt;" },
{ pattern: /&ampgt;/g, replacement: "&gt;", name: "&ampgt;" },
{ pattern: /&ampapos;/g, replacement: "&apos;", name: "&ampapos;" },
{ pattern: /&ampamp;/g, replacement: "&amp;", name: "&ampamp;" },
]
for (const { pattern, replacement, name } of invalidEntities) {
if (pattern.test(fixed)) {
fixed = fixed.replace(pattern, replacement)
fixes.push(`Fixed double-escaped entity ${name}`)
}
}
// 6. Fix malformed attribute quotes
const malformedQuotePattern = /(\s[a-zA-Z][a-zA-Z0-9_:-]*)=&quot;/
if (malformedQuotePattern.test(fixed)) {
fixed = fixed.replace(
/(\s[a-zA-Z][a-zA-Z0-9_:-]*)=&quot;([^&]*?)&quot;/g,
'$1="$2"',
)
fixes.push("Fixed malformed attribute quotes")
}
// 7. Fix malformed closing tags
const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g
if (malformedClosingTag.test(fixed)) {
fixed = fixed.replace(/<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g, "</$1>")
fixes.push("Fixed malformed closing tags")
}
// 8. Fix missing space between attributes
const missingSpacePattern = /("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g
if (missingSpacePattern.test(fixed)) {
fixed = fixed.replace(/("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g, "$1 $2")
fixes.push("Added missing space between attributes")
}
// 9. Fix unescaped quotes in style color values
const quotedColorPattern = /;([a-zA-Z]*[Cc]olor)="#/
if (quotedColorPattern.test(fixed)) {
fixed = fixed.replace(/;([a-zA-Z]*[Cc]olor)="#/g, ";$1=#")
fixes.push("Removed quotes around color values in style")
}
// 10. Fix unescaped < in attribute values
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
let attrMatch
let hasUnescapedLt = false
while ((attrMatch = attrPattern.exec(fixed)) !== null) {
if (!attrMatch[3].startsWith("&lt;")) {
hasUnescapedLt = true
break
}
}
if (hasUnescapedLt) {
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
const escaped = value.replace(/</g, "&lt;")
return `="${escaped}"`
})
fixes.push("Escaped < characters in attribute values")
}
// 11. Fix invalid hex character references
const invalidHexRefs: string[] = []
fixed = fixed.replace(/&#x([^;]*);/g, (match, hex) => {
if (/^[0-9a-fA-F]+$/.test(hex) && hex.length > 0) {
return match
}
invalidHexRefs.push(match)
return ""
})
if (invalidHexRefs.length > 0) {
fixes.push(
`Removed ${invalidHexRefs.length} invalid hex character reference(s)`,
)
}
// 12. Fix invalid decimal character references
const invalidDecRefs: string[] = []
fixed = fixed.replace(/&#([^x][^;]*);/g, (match, dec) => {
if (/^[0-9]+$/.test(dec) && dec.length > 0) {
return match
}
invalidDecRefs.push(match)
return ""
})
if (invalidDecRefs.length > 0) {
fixes.push(
`Removed ${invalidDecRefs.length} invalid decimal character reference(s)`,
)
}
// 13. Fix invalid comment syntax
fixed = fixed.replace(/<!--([\s\S]*?)-->/g, (match, content) => {
if (/--/.test(content)) {
let fixedContent = content
while (/--/.test(fixedContent)) {
fixedContent = fixedContent.replace(/--/g, "-")
}
fixes.push("Fixed invalid comment syntax")
return `<!--${fixedContent}-->`
}
return match
})
// 14. Fix <Cell> tags to <mxCell>
const hasCellTags = /<\/?Cell[\s>]/i.test(fixed)
if (hasCellTags) {
fixed = fixed.replace(/<Cell(\s)/gi, "<mxCell$1")
fixed = fixed.replace(/<Cell>/gi, "<mxCell>")
fixed = fixed.replace(/<\/Cell>/gi, "</mxCell>")
fixes.push("Fixed <Cell> tags to <mxCell>")
}
// 15. Fix common closing tag typos (MUST run before foreign tag removal)
const tagTypos = [
{ wrong: /<\/mxElement>/gi, right: "</mxCell>", name: "</mxElement>" },
{ wrong: /<\/mxcell>/g, right: "</mxCell>", name: "</mxcell>" },
{
wrong: /<\/mxgeometry>/g,
right: "</mxGeometry>",
name: "</mxgeometry>",
},
{ wrong: /<\/mxpoint>/g, right: "</mxPoint>", name: "</mxpoint>" },
{
wrong: /<\/mxgraphmodel>/gi,
right: "</mxGraphModel>",
name: "</mxgraphmodel>",
},
]
for (const { wrong, right, name } of tagTypos) {
const before = fixed
fixed = fixed.replace(wrong, right)
if (fixed !== before) {
fixes.push(`Fixed typo ${name} to ${right}`)
}
}
// 16. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)
const validDrawioTags = new Set([
"mxfile",
"diagram",
"mxGraphModel",
"root",
"mxCell",
"mxGeometry",
"mxPoint",
"Array",
"Object",
"mxRectangle",
])
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
let foreignMatch
const foreignTags = new Set<string>()
while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {
const tagName = foreignMatch[1]
if (!validDrawioTags.has(tagName)) {
foreignTags.add(tagName)
}
}
if (foreignTags.size > 0) {
for (const tag of foreignTags) {
fixed = fixed.replace(new RegExp(`<${tag}[^>]*>`, "gi"), "")
fixed = fixed.replace(new RegExp(`</${tag}>`, "gi"), "")
}
fixes.push(
`Removed foreign tags: ${Array.from(foreignTags).join(", ")}`,
)
}
// 17. Fix unclosed tags
const tagStack: string[] = []
const parsedTags = parseXmlTags(fixed)
for (const { tagName, isClosing, isSelfClosing } of parsedTags) {
if (isClosing) {
const lastIdx = tagStack.lastIndexOf(tagName)
if (lastIdx !== -1) {
tagStack.splice(lastIdx, 1)
}
} else if (!isSelfClosing) {
tagStack.push(tagName)
}
}
if (tagStack.length > 0) {
const tagsToClose: string[] = []
for (const tagName of tagStack.reverse()) {
const openCount = (
fixed.match(new RegExp(`<${tagName}[\\s>]`, "gi")) || []
).length
const closeCount = (
fixed.match(new RegExp(`</${tagName}>`, "gi")) || []
).length
if (openCount > closeCount) {
tagsToClose.push(tagName)
}
}
if (tagsToClose.length > 0) {
const closingTags = tagsToClose.map((t) => `</${t}>`).join("\n")
fixed = fixed.trimEnd() + "\n" + closingTags
fixes.push(
`Closed ${tagsToClose.length} unclosed tag(s): ${tagsToClose.join(", ")}`,
)
}
}
// 18. Remove extra closing tags
const tagCounts = new Map<
string,
{ opens: number; closes: number; selfClosing: number }
>()
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
let tagCountMatch
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
const fullMatch = tagCountMatch[0]
const tagPart = tagCountMatch[1]
const isClosing = tagPart.startsWith("/")
const isSelfClosing = fullMatch.endsWith("/>")
const tagName = isClosing ? tagPart.slice(1) : tagPart
let counts = tagCounts.get(tagName)
if (!counts) {
counts = { opens: 0, closes: 0, selfClosing: 0 }
tagCounts.set(tagName, counts)
}
if (isClosing) {
counts.closes++
} else if (isSelfClosing) {
counts.selfClosing++
} else {
counts.opens++
}
}
for (const [tagName, counts] of tagCounts) {
const extraCloses = counts.closes - counts.opens
if (extraCloses > 0) {
let removed = 0
const closeTagPattern = new RegExp(`</${tagName}>`, "g")
const matches = [...fixed.matchAll(closeTagPattern)]
for (
let i = matches.length - 1;
i >= 0 && removed < extraCloses;
i--
) {
const match = matches[i]
const idx = match.index ?? 0
fixed = fixed.slice(0, idx) + fixed.slice(idx + match[0].length)
removed++
}
if (removed > 0) {
fixes.push(
`Removed ${removed} extra </${tagName}> closing tag(s)`,
)
}
}
}
// 19. Remove trailing garbage after last XML tag
const closingTagPattern = /<\/[a-zA-Z][a-zA-Z0-9]*>|\/>/g
let lastValidTagEnd = -1
let closingMatch
while ((closingMatch = closingTagPattern.exec(fixed)) !== null) {
lastValidTagEnd = closingMatch.index + closingMatch[0].length
}
if (lastValidTagEnd > 0 && lastValidTagEnd < fixed.length) {
const trailing = fixed.slice(lastValidTagEnd).trim()
if (trailing) {
fixed = fixed.slice(0, lastValidTagEnd)
fixes.push("Removed trailing garbage after last XML tag")
}
}
// 20. Fix nested mxCell by flattening
const lines = fixed.split("\n")
let newLines: string[] = []
let nestedFixed = 0
let extraClosingToRemove = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const nextLine = lines[i + 1]
if (
nextLine &&
/<mxCell\s/.test(line) &&
/<mxCell\s/.test(nextLine) &&
!line.includes("/>") &&
!nextLine.includes("/>")
) {
const id1 = line.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1]
const id2 = nextLine.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1]
if (id1 && id1 === id2) {
nestedFixed++
extraClosingToRemove++
continue
}
}
if (extraClosingToRemove > 0 && /^\s*<\/mxCell>\s*$/.test(line)) {
extraClosingToRemove--
continue
}
newLines.push(line)
}
if (nestedFixed > 0) {
fixed = newLines.join("\n")
fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`)
}
// 21. Fix true nested mxCell (different IDs)
const lines2 = fixed.split("\n")
newLines = []
let trueNestedFixed = 0
let cellDepth = 0
let pendingCloseRemoval = 0
for (let i = 0; i < lines2.length; i++) {
const line = lines2[i]
const trimmed = line.trim()
const isOpenCell = /<mxCell\s/.test(trimmed) && !trimmed.endsWith("/>")
const isCloseCell = trimmed === "</mxCell>"
if (isOpenCell) {
if (cellDepth > 0) {
const indent = line.match(/^(\s*)/)?.[1] || ""
newLines.push(indent + "</mxCell>")
trueNestedFixed++
pendingCloseRemoval++
}
cellDepth = 1
newLines.push(line)
} else if (isCloseCell) {
if (pendingCloseRemoval > 0) {
pendingCloseRemoval--
} else {
cellDepth = Math.max(0, cellDepth - 1)
newLines.push(line)
}
} else {
newLines.push(line)
}
}
if (trueNestedFixed > 0) {
fixed = newLines.join("\n")
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
}
// 22. Fix duplicate IDs by appending suffix
const seenIds = new Map<string, number>()
const duplicateIds: string[] = []
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
let idMatch
while ((idMatch = idPattern.exec(fixed)) !== null) {
const id = idMatch[1]
seenIds.set(id, (seenIds.get(id) || 0) + 1)
}
for (const [id, count] of seenIds) {
if (count > 1) duplicateIds.push(id)
}
if (duplicateIds.length > 0) {
const idCounters = new Map<string, number>()
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => {
if (!duplicateIds.includes(id)) return match
const count = idCounters.get(id) || 0
idCounters.set(id, count + 1)
if (count === 0) return match
const newId = `${id}_dup${count}`
return match.replace(id, newId)
})
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
}
// 23. Fix empty id attributes
let emptyIdCount = 0
fixed = fixed.replace(
/<mxCell([^>]*)\sid\s*=\s*["']\s*["']([^>]*)>/g,
(_match, before, after) => {
emptyIdCount++
const newId = `cell_${Date.now()}_${emptyIdCount}`
return `<mxCell${before} id="${newId}"${after}>`
},
)
if (emptyIdCount > 0) {
fixes.push(`Generated ${emptyIdCount} missing ID(s)`)
}
// 24. Aggressive: drop broken mxCell elements
if (typeof DOMParser !== "undefined") {
let droppedCells = 0
let maxIterations = MAX_DROP_ITERATIONS
while (maxIterations-- > 0) {
const parser = new DOMParser()
const doc = parser.parseFromString(fixed, "text/xml")
const parseError = doc.querySelector("parsererror")
if (!parseError) break
const errText = parseError.textContent || ""
const match = errText.match(/(\d+):\d+:/)
if (!match) break
const errLine = parseInt(match[1], 10) - 1
const lines = fixed.split("\n")
let cellStart = errLine
let cellEnd = errLine
while (cellStart > 0 && !lines[cellStart].includes("<mxCell")) {
cellStart--
}
while (cellEnd < lines.length - 1) {
if (
lines[cellEnd].includes("</mxCell>") ||
lines[cellEnd].trim().endsWith("/>")
) {
break
}
cellEnd++
}
lines.splice(cellStart, cellEnd - cellStart + 1)
fixed = lines.join("\n")
droppedCells++
}
if (droppedCells > 0) {
fixes.push(`Dropped ${droppedCells} unfixable mxCell element(s)`)
}
}
return { fixed, fixes }
}
// ============================================================================
// Combined Validation and Fix
// ============================================================================
/**
* Validates XML and attempts to fix if invalid
* @param xml - The XML string to validate and potentially fix
* @returns Object with validation result, fixed XML if applicable, and fixes applied
*/
export function validateAndFixXml(xml: string): {
valid: boolean
error: string | null
fixed: string | null
fixes: string[]
} {
// First validation attempt
let error = validateMxCellStructure(xml)
if (!error) {
return { valid: true, error: null, fixed: null, fixes: [] }
}
// Try to fix
const { fixed, fixes } = autoFixXml(xml)
// Validate the fixed version
error = validateMxCellStructure(fixed)
if (!error) {
return { valid: true, error: null, fixed, fixes }
}
// Still invalid after fixes
return {
valid: false,
error,
fixed: fixes.length > 0 ? fixed : null,
fixes,
}
}
/**
* Check if mxCell XML output is complete (not truncated).
* @param xml - The XML string to check (can be undefined/null)
* @returns true if XML appears complete, false if truncated or empty
*/
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
let trimmed = xml?.trim() || ""
if (!trimmed) return false
// Strip wrapper tags if present
let prev = ""
while (prev !== trimmed) {
prev = trimmed
trimmed = trimmed
.replace(/<\/mxParameter>\s*$/i, "")
.replace(/<\/invoke>\s*$/i, "")
.replace(/<\/antml:parameter>\s*$/i, "")
.replace(/<\/antml:invoke>\s*$/i, "")
.trim()
}
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
}

63
proxy.ts Normal file
View File

@@ -0,0 +1,63 @@
import { match as matchLocale } from "@formatjs/intl-localematcher"
import Negotiator from "negotiator"
import type { NextRequest } from "next/server"
import { NextResponse } from "next/server"
import { i18n } from "./lib/i18n/config"
function getLocale(request: NextRequest): string | undefined {
// Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {}
request.headers.forEach((value, key) => {
negotiatorHeaders[key] = value
})
// @ts-expect-error locales are readonly
const locales: string[] = i18n.locales
// Use negotiator and intl-localematcher to get best locale
const languages = new Negotiator({ headers: negotiatorHeaders }).languages(
locales,
)
const locale = matchLocale(languages, locales, i18n.defaultLocale)
return locale
}
export function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Skip API routes, static files, and Next.js internals
if (
pathname.startsWith("/api/") ||
pathname.startsWith("/_next/") ||
pathname.includes("/favicon") ||
/\.(.*)$/.test(pathname)
) {
return
}
// Check if there is any supported locale in the pathname
const pathnameIsMissingLocale = i18n.locales.every(
(locale) =>
!pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
)
// Redirect if there is no locale
if (pathnameIsMissingLocale) {
const locale = getLocale(request)
// Redirect to localized path
return NextResponse.redirect(
new URL(
`/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
request.url,
),
)
}
}
export const config = {
// Matcher ignoring `/_next/` and `/api/`
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}

BIN
public/favicon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/favicon-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -20,7 +20,13 @@ function applyDiagramOperations(xmlContent, operations) {
if (parseError) {
return {
result: xmlContent,
errors: [{ type: "update", cellId: "", message: `XML parse error: ${parseError.textContent}` }],
errors: [
{
type: "update",
cellId: "",
message: `XML parse error: ${parseError.textContent}`,
},
],
}
}
@@ -28,7 +34,13 @@ function applyDiagramOperations(xmlContent, operations) {
if (!root) {
return {
result: xmlContent,
errors: [{ type: "update", cellId: "", message: "Could not find <root> element in XML" }],
errors: [
{
type: "update",
cellId: "",
message: "Could not find <root> element in XML",
},
],
}
}
@@ -42,22 +54,41 @@ function applyDiagramOperations(xmlContent, operations) {
if (op.type === "update") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({ type: "update", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found` })
errors.push({
type: "update",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
continue
}
if (!op.new_xml) {
errors.push({ type: "update", cellId: op.cell_id, message: "new_xml is required for update operation" })
errors.push({
type: "update",
cellId: op.cell_id,
message: "new_xml is required for update operation",
})
continue
}
const newDoc = parser.parseFromString(`<wrapper>${op.new_xml}</wrapper>`, "text/xml")
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
"text/xml",
)
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({ type: "update", cellId: op.cell_id, message: "new_xml must contain an mxCell element" })
errors.push({
type: "update",
cellId: op.cell_id,
message: "new_xml must contain an mxCell element",
})
continue
}
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({ type: "update", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"` })
errors.push({
type: "update",
cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
})
continue
}
const importedNode = doc.importNode(newCell, true)
@@ -65,22 +96,41 @@ function applyDiagramOperations(xmlContent, operations) {
cellMap.set(op.cell_id, importedNode)
} else if (op.type === "add") {
if (cellMap.has(op.cell_id)) {
errors.push({ type: "add", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" already exists` })
errors.push({
type: "add",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" already exists`,
})
continue
}
if (!op.new_xml) {
errors.push({ type: "add", cellId: op.cell_id, message: "new_xml is required for add operation" })
errors.push({
type: "add",
cellId: op.cell_id,
message: "new_xml is required for add operation",
})
continue
}
const newDoc = parser.parseFromString(`<wrapper>${op.new_xml}</wrapper>`, "text/xml")
const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`,
"text/xml",
)
const newCell = newDoc.querySelector("mxCell")
if (!newCell) {
errors.push({ type: "add", cellId: op.cell_id, message: "new_xml must contain an mxCell element" })
errors.push({
type: "add",
cellId: op.cell_id,
message: "new_xml must contain an mxCell element",
})
continue
}
const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) {
errors.push({ type: "add", cellId: op.cell_id, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"` })
errors.push({
type: "add",
cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
})
continue
}
const importedNode = doc.importNode(newCell, true)
@@ -89,7 +139,11 @@ function applyDiagramOperations(xmlContent, operations) {
} else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
errors.push({ type: "delete", cellId: op.cell_id, message: `Cell with id="${op.cell_id}" not found` })
errors.push({
type: "delete",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
continue
}
existingCell.parentNode?.removeChild(existingCell)
@@ -149,28 +203,52 @@ test("Update operation changes cell value", () => {
{
type: "update",
cell_id: "2",
new_xml: '<mxCell id="2" value="Updated Box A" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
new_xml:
'<mxCell id="2" value="Updated Box A" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(result.includes('value="Updated Box A"'), "Updated value should be in result")
assert(!result.includes('value="Box A"'), "Old value should not be in result")
assert(
errors.length === 0,
`Expected no errors, got: ${JSON.stringify(errors)}`,
)
assert(
result.includes('value="Updated Box A"'),
"Updated value should be in result",
)
assert(
!result.includes('value="Box A"'),
"Old value should not be in result",
)
})
test("Update operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "update", cell_id: "999", new_xml: '<mxCell id="999" value="Test"/>' },
{
type: "update",
cell_id: "999",
new_xml: '<mxCell id="999" value="Test"/>',
},
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("not found"), "Error should mention not found")
assert(
errors[0].message.includes("not found"),
"Error should mention not found",
)
})
test("Update operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "update", cell_id: "2", new_xml: '<mxCell id="WRONG" value="Test"/>' },
{
type: "update",
cell_id: "2",
new_xml: '<mxCell id="WRONG" value="Test"/>',
},
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch")
assert(
errors[0].message.includes("ID mismatch"),
"Error should mention ID mismatch",
)
})
test("Add operation creates new cell", () => {
@@ -178,41 +256,72 @@ test("Add operation creates new cell", () => {
{
type: "add",
cell_id: "new1",
new_xml: '<mxCell id="new1" value="New Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
new_xml:
'<mxCell id="new1" value="New Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(
errors.length === 0,
`Expected no errors, got: ${JSON.stringify(errors)}`,
)
assert(result.includes('id="new1"'), "New cell should be in result")
assert(result.includes('value="New Box"'), "New cell value should be in result")
assert(
result.includes('value="New Box"'),
"New cell value should be in result",
)
})
test("Add operation fails for duplicate ID", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "add", cell_id: "2", new_xml: '<mxCell id="2" value="Duplicate"/>' },
{
type: "add",
cell_id: "2",
new_xml: '<mxCell id="2" value="Duplicate"/>',
},
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("already exists"), "Error should mention already exists")
assert(
errors[0].message.includes("already exists"),
"Error should mention already exists",
)
})
test("Add operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "add", cell_id: "new1", new_xml: '<mxCell id="WRONG" value="Test"/>' },
{
type: "add",
cell_id: "new1",
new_xml: '<mxCell id="WRONG" value="Test"/>',
},
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("ID mismatch"), "Error should mention ID mismatch")
assert(
errors[0].message.includes("ID mismatch"),
"Error should mention ID mismatch",
)
})
test("Delete operation removes cell", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [{ type: "delete", cell_id: "3" }])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
const { result, errors } = applyDiagramOperations(sampleXml, [
{ type: "delete", cell_id: "3" },
])
assert(
errors.length === 0,
`Expected no errors, got: ${JSON.stringify(errors)}`,
)
assert(!result.includes('id="3"'), "Deleted cell should not be in result")
assert(result.includes('id="2"'), "Other cells should remain")
})
test("Delete operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [{ type: "delete", cell_id: "999" }])
const { errors } = applyDiagramOperations(sampleXml, [
{ type: "delete", cell_id: "999" },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("not found"), "Error should mention not found")
assert(
errors[0].message.includes("not found"),
"Error should mention not found",
)
})
test("Multiple operations in sequence", () => {
@@ -220,30 +329,45 @@ test("Multiple operations in sequence", () => {
{
type: "update",
cell_id: "2",
new_xml: '<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
new_xml:
'<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
{
type: "add",
cell_id: "new1",
new_xml: '<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
new_xml:
'<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
{ type: "delete", cell_id: "3" },
])
assert(errors.length === 0, `Expected no errors, got: ${JSON.stringify(errors)}`)
assert(result.includes('value="Updated"'), "Updated value should be present")
assert(
errors.length === 0,
`Expected no errors, got: ${JSON.stringify(errors)}`,
)
assert(
result.includes('value="Updated"'),
"Updated value should be present",
)
assert(result.includes('id="new1"'), "Added cell should be present")
assert(!result.includes('id="3"'), "Deleted cell should not be present")
})
test("Invalid XML returns parse error", () => {
const { errors } = applyDiagramOperations("<not valid xml", [{ type: "delete", cell_id: "1" }])
const { errors } = applyDiagramOperations("<not valid xml", [
{ type: "delete", cell_id: "1" },
])
assert(errors.length === 1, "Should have one error")
})
test("Missing root element returns error", () => {
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [{ type: "delete", cell_id: "1" }])
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [
{ type: "delete", cell_id: "1" },
])
assert(errors.length === 1, "Should have one error")
assert(errors[0].message.includes("root"), "Error should mention root element")
assert(
errors[0].message.includes("root"),
"Error should mention root element",
)
})
// Summary