Compare commits

..

9 Commits

Author SHA1 Message Date
Dayuan Jiang
ecf716125e feat: add MCP server notice to example panel 2025-12-17 14:48:36 +09:00
Dayuan Jiang
0a2e31b18c chore: remove release notes 2025-12-17 13:12:35 +09:00
Dayuan Jiang
78a1f978fc chore: bump version to 0.4.3 and add release notes 2025-12-17 13:10:40 +09:00
Dayuan Jiang
5ab751c986 docs: use @latest instead of -y flag for npx (match Playwright MCP style) 2025-12-17 13:00:26 +09:00
Dayuan Jiang
dad1480d8c fix: exclude packages from Next.js build 2025-12-17 12:56:43 +09:00
Dayuan Jiang
0c95e829f0 docs: add multi-client installation instructions for MCP server 2025-12-17 12:36:45 +09:00
Dayuan Jiang
f6eeeb0d5b docs: add MCP server section to README (preview feature) 2025-12-17 12:31:42 +09:00
Dayuan Jiang
c0952d6170 chore: bump version to 0.1.2 2025-12-17 12:19:27 +09:00
Dayuan Jiang
79aa8734f1 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
2025-12-17 12:15:16 +09:00
17 changed files with 148 additions and 546 deletions

View File

@@ -1,47 +0,0 @@
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,11 +80,8 @@ jobs:
- name: Push to ECR (triggers App Runner auto-deploy) - name: Push to ECR (triggers App Runner auto-deploy)
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env:
REPO_LOWER: ${{ github.repository }}
run: | run: |
REPO_LOWER=$(echo "$REPO_LOWER" | tr '[:upper:]' '[:lower:]') docker pull ghcr.io/${{ github.repository }}:latest
docker pull ghcr.io/${REPO_LOWER}:latest docker tag ghcr.io/${{ github.repository }}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io: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 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) ## MCP Server (Preview)
> **Preview Feature**: This feature is experimental and may not stable. > **Preview Feature**: This feature is experimental and may change.
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol). Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).

View File

@@ -1,28 +0,0 @@
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

@@ -1,5 +1,5 @@
"use client" "use client"
import { useCallback, useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel" import ChatPanel from "@/components/chat-panel"
@@ -15,15 +15,8 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net" process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() { export default function Home() {
const { const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
drawioRef, useDiagram()
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
saveDiagramToStorage,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true) const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min") const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
@@ -32,29 +25,6 @@ export default function Home() {
const [closeProtection, setCloseProtection] = useState(false) const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null) 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 // Load preferences from localStorage after mount
useEffect(() => { useEffect(() => {
@@ -65,10 +35,12 @@ export default function Home() {
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode") const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
if (savedDarkMode !== null) { if (savedDarkMode !== null) {
// Use saved preference
const isDark = savedDarkMode === "true" const isDark = savedDarkMode === "true"
setDarkMode(isDark) setDarkMode(isDark)
document.documentElement.classList.toggle("dark", isDark) document.documentElement.classList.toggle("dark", isDark)
} else { } else {
// First visit: match browser preference
const prefersDark = window.matchMedia( const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)", "(prefers-color-scheme: dark)",
).matches ).matches
@@ -86,49 +58,25 @@ export default function Home() {
setIsLoaded(true) setIsLoaded(true)
}, []) }, [])
const handleDarkModeChange = async () => { const toggleDarkMode = () => {
await saveDiagramToStorage()
const newValue = !darkMode const newValue = !darkMode
setDarkMode(newValue) setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue)) localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue) document.documentElement.classList.toggle("dark", newValue)
// Reset so onDrawioLoad fires again after remount
resetDrawioReady() resetDrawioReady()
} }
const handleDrawioUiChange = async () => { // Check mobile
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(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => {
const newIsMobile = window.innerWidth < 768 setIsMobile(window.innerWidth < 768)
// If crossing the breakpoint (not initial render), save diagram and reset draw.io
if (
!isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current
) {
// Save diagram before remounting (fire and forget)
saveDiagramToStorage().catch(() => {
// Ignore timeout errors during resize
})
// Reset draw.io ready state so onLoad triggers again after remount
resetDrawioReady()
}
isMobileRef.current = newIsMobile
isInitialRenderRef.current = false
setIsMobile(newIsMobile)
} }
checkMobile() checkMobile()
window.addEventListener("resize", checkMobile) window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile) return () => window.removeEventListener("resize", checkMobile)
}, [saveDiagramToStorage, resetDrawioReady]) }, [])
const toggleChatPanel = () => { const toggleChatPanel = () => {
const panel = chatPanelRef.current const panel = chatPanelRef.current
@@ -174,6 +122,7 @@ export default function Home() {
<div className="h-screen bg-background relative overflow-hidden"> <div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup <ResizablePanelGroup
id="main-panel-group" id="main-panel-group"
key={isMobile ? "mobile" : "desktop"}
direction={isMobile ? "vertical" : "horizontal"} direction={isMobile ? "vertical" : "horizontal"}
className="h-full" className="h-full"
> >
@@ -187,12 +136,6 @@ export default function Home() {
className={`h-full relative ${ className={`h-full relative ${
isMobile ? "p-1" : "p-2" 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"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
{isLoaded ? ( {isLoaded ? (
@@ -201,7 +144,6 @@ export default function Home() {
ref={drawioRef} ref={drawioRef}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={onDrawioLoad} onLoad={onDrawioLoad}
onSave={handleDrawioSave}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,
@@ -225,7 +167,6 @@ export default function Home() {
{/* Chat Panel */} {/* Chat Panel */}
<ResizablePanel <ResizablePanel
key={isMobile ? "mobile" : "desktop"}
id="chat-panel" id="chat-panel"
ref={chatPanelRef} ref={chatPanelRef}
defaultSize={isMobile ? 50 : 33} defaultSize={isMobile ? 50 : 33}
@@ -241,9 +182,15 @@ export default function Home() {
isVisible={isChatVisible} isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel} onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi} drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange} onToggleDrawioUi={() => {
const newUi =
drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
resetDrawioReady()
}}
darkMode={darkMode} darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange} onToggleDarkMode={toggleDarkMode}
isMobile={isMobile} isMobile={isMobile}
onCloseProtectionChange={setCloseProtection} onCloseProtectionChange={setCloseProtection}
/> />

View File

@@ -155,16 +155,12 @@ export function ChatInput({
minimalStyle = false, minimalStyle = false,
onMinimalStyleChange = () => {}, onMinimalStyleChange = () => {},
}: ChatInputProps) { }: ChatInputProps) {
const { const { diagramHistory, saveDiagramToFile } = useDiagram()
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = 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") // Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled = const isDisabled =
@@ -335,7 +331,7 @@ export function ChatInput({
{/* Action bar */} {/* Action bar */}
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50"> <div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
{/* Left actions */} {/* Left actions */}
<div className="flex items-center gap-1 overflow-x-hidden"> <div className="flex items-center gap-1">
<ButtonWithTooltip <ButtonWithTooltip
type="button" type="button"
variant="ghost" variant="ghost"
@@ -386,7 +382,7 @@ export function ChatInput({
</div> </div>
{/* Right actions */} {/* Right actions */}
<div className="flex items-center gap-1 overflow-hidden justify-end"> <div className="flex items-center gap-1">
<ButtonWithTooltip <ButtonWithTooltip
type="button" type="button"
variant="ghost" variant="ghost"

View File

@@ -35,9 +35,6 @@ const STORAGE_XML_SNAPSHOTS_KEY = "next-ai-draw-io-xml-snapshots"
const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id" const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
export const STORAGE_DIAGRAM_XML_KEY = "next-ai-draw-io-diagram-xml" 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) // Type for message parts (tool calls and their states)
interface MessagePart { interface MessagePart {
type: string type: string
@@ -109,6 +106,7 @@ export default function ChatPanel({
resolverRef, resolverRef,
chartXML, chartXML,
clearDiagram, clearDiagram,
isDrawioReady,
} = useDiagram() } = useDiagram()
const onFetchChart = (saveToHistory = true) => { const onFetchChart = (saveToHistory = true) => {
@@ -150,14 +148,6 @@ export default function ChatPanel({
const [showNewChatDialog, setShowNewChatDialog] = useState(false) const [showNewChatDialog, setShowNewChatDialog] = useState(false)
const [minimalStyle, setMinimalStyle] = 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 // Check config on mount
useEffect(() => { useEffect(() => {
fetch("/api/config") fetch("/api/config")
@@ -220,6 +210,9 @@ export default function ChatPanel({
const localStorageDebounceRef = useRef<ReturnType< const localStorageDebounceRef = useRef<ReturnType<
typeof setTimeout typeof setTimeout
> | null>(null) > | null>(null)
const xmlStorageDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
const { const {
@@ -732,6 +725,47 @@ Continue from EXACTLY where you stopped.`,
} }
}, [setMessages]) }, [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) // Save messages to localStorage whenever they change (debounced to prevent blocking during streaming)
useEffect(() => { useEffect(() => {
if (!hasRestoredRef.current) return if (!hasRestoredRef.current) return
@@ -761,6 +795,28 @@ Continue from EXACTLY where you stopped.`,
} }
}, [messages]) }, [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 // Save XML snapshots to localStorage whenever they change
const saveXmlSnapshots = useCallback(() => { const saveXmlSnapshots = useCallback(() => {
try { try {
@@ -860,7 +916,6 @@ Continue from EXACTLY where you stopped.`,
}, },
] as any) ] as any)
setInput("") setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([]) setFiles([])
return return
} }
@@ -908,7 +963,6 @@ Continue from EXACTLY where you stopped.`,
// Token count is tracked in onFinish with actual server usage // Token count is tracked in onFinish with actual server usage
setInput("") setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([]) setFiles([])
} catch (error) { } catch (error) {
console.error("Error fetching chart data:", error) console.error("Error fetching chart data:", error)
@@ -931,7 +985,6 @@ Continue from EXACTLY where you stopped.`,
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY) localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY) localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId) localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId)
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
toast.success("Started a fresh chat") toast.success("Started a fresh chat")
} catch (error) { } catch (error) {
console.error("Failed to clear localStorage:", error) console.error("Failed to clear localStorage:", error)
@@ -946,14 +999,9 @@ Continue from EXACTLY where you stopped.`,
const handleInputChange = ( const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => { ) => {
saveInputToSessionStorage(e.target.value)
setInput(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) // Helper functions for message actions (regenerate/edit)
// Extract previous XML snapshot before a given message index // Extract previous XML snapshot before a given message index
const getPreviousXml = (beforeIndex: number): string => { const getPreviousXml = (beforeIndex: number): string => {
@@ -1229,14 +1277,14 @@ Continue from EXACTLY where you stopped.`,
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`} 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 justify-between">
<div className="flex items-center gap-2 overflow-x-hidden"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Image <Image
src="/favicon.ico" src="/favicon.ico"
alt="Next AI Drawio" alt="Next AI Drawio"
width={isMobile ? 24 : 28} width={isMobile ? 24 : 28}
height={isMobile ? 24 : 28} height={isMobile ? 24 : 28}
className="rounded flex-shrink-0" className="rounded"
/> />
<h1 <h1
className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`} className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}
@@ -1271,7 +1319,7 @@ Continue from EXACTLY where you stopped.`,
</Link> </Link>
)} )}
</div> </div>
<div className="flex items-center gap-1 justify-end overflow-x-hidden"> <div className="flex items-center gap-1">
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent="Start fresh chat" tooltipContent="Start fresh chat"
variant="ghost" variant="ghost"

View File

@@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@@ -73,9 +72,6 @@ export function SaveDialog({
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Save Diagram</DialogTitle> <DialogTitle>Save Diagram</DialogTitle>
<DialogDescription>
Choose a format and filename to save your diagram.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import type React from "react" import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react" import { createContext, useContext, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio" import type { DrawIoEmbedRef } from "react-drawio"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel" import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
@@ -23,12 +23,9 @@ interface DiagramContextType {
format: ExportFormat, format: ExportFormat,
sessionId?: string, sessionId?: string,
) => void ) => void
saveDiagramToStorage: () => Promise<void>
isDrawioReady: boolean isDrawioReady: boolean
onDrawioLoad: () => void onDrawioLoad: () => void
resetDrawioReady: () => void resetDrawioReady: () => void
showSaveDialog: boolean
setShowSaveDialog: (show: boolean) => void
} }
const DiagramContext = createContext<DiagramContextType | undefined>(undefined) const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
@@ -40,15 +37,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
{ svg: string; xml: string }[] { svg: string; xml: string }[]
>([]) >([])
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false) const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null) const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null) const resolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false) const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored from localStorage
const hasDiagramRestoredRef = useRef<boolean>(false)
const onDrawioLoad = () => { const onDrawioLoad = () => {
// Only set ready state once to prevent infinite loops // Only set ready state once to prevent infinite loops
@@ -64,48 +57,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setIsDrawioReady(false) 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) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null resolver: ((data: string) => void) | null
@@ -131,30 +82,6 @@ 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 = ( const loadDiagram = (
chart: string, chart: string,
skipValidation?: boolean, skipValidation?: boolean,
@@ -353,12 +280,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
handleDiagramExport, handleDiagramExport,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
saveDiagramToStorage,
isDrawioReady, isDrawioReady,
onDrawioLoad, onDrawioLoad,
resetDrawioReady, resetDrawioReady,
showSaveDialog,
setShowSaveDialog,
}} }}
> >
{children} {children}

View File

@@ -136,42 +136,6 @@ Optional custom URL:
OLLAMA_BASE_URL=http://localhost:11434 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 ## 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`. If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
@@ -179,7 +143,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`: If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash ```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama, gateway AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, azure, bedrock, openrouter, ollama
``` ```
## Model Capability Requirements ## Model Capability Requirements

View File

@@ -1,6 +1,6 @@
# AI Provider Configuration # AI Provider Configuration
# AI_PROVIDER: Which provider to use # AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway # Options: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow
# Default: bedrock # Default: bedrock
AI_PROVIDER=bedrock AI_PROVIDER=bedrock
@@ -68,13 +68,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SILICONFLOW_API_KEY=sk-... # SILICONFLOW_API_KEY=sk-...
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed # 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) # Langfuse Observability (Optional)
# Enable LLM tracing and analytics - https://langfuse.com # Enable LLM tracing and analytics - https://langfuse.com
# LANGFUSE_PUBLIC_KEY=pk-lf-... # LANGFUSE_PUBLIC_KEY=pk-lf-...

View File

@@ -2,7 +2,6 @@ import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic" import { createAnthropic } from "@ai-sdk/anthropic"
import { azure, createAzure } from "@ai-sdk/azure" import { azure, createAzure } from "@ai-sdk/azure"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway, gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google" import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createOpenAI, openai } from "@ai-sdk/openai" import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
@@ -19,7 +18,6 @@ export type ProviderName =
| "openrouter" | "openrouter"
| "deepseek" | "deepseek"
| "siliconflow" | "siliconflow"
| "gateway"
interface ModelConfig { interface ModelConfig {
model: any model: any
@@ -44,7 +42,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"openrouter", "openrouter",
"deepseek", "deepseek",
"siliconflow", "siliconflow",
"gateway",
] ]
// Bedrock provider options for Anthropic beta features // Bedrock provider options for Anthropic beta features
@@ -336,10 +333,8 @@ function buildProviderOptions(
case "deepseek": case "deepseek":
case "openrouter": case "openrouter":
case "siliconflow": case "siliconflow": {
case "gateway": {
// These providers don't have reasoning configs in AI SDK yet // These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs
break break
} }
@@ -361,7 +356,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY", deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY", siliconflow: "SILICONFLOW_API_KEY",
gateway: "AI_GATEWAY_API_KEY",
} }
/** /**
@@ -501,7 +495,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
if (configured.length === 0) { if (configured.length === 0) {
throw new Error( throw new Error(
`No AI provider configured. Please set one of the following API keys in your .env.local file:\n` + `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` + `- DEEPSEEK_API_KEY for DeepSeek\n` +
`- OPENAI_API_KEY for OpenAI\n` + `- OPENAI_API_KEY for OpenAI\n` +
`- ANTHROPIC_API_KEY for Anthropic\n` + `- ANTHROPIC_API_KEY for Anthropic\n` +
@@ -679,30 +672,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break 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: default:
throw new Error( throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`, `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow`,
) )
} }

49
package-lock.json generated
View File

@@ -1,19 +1,18 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.3", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.3", "version": "0.4.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.70", "@ai-sdk/amazon-bedrock": "^3.0.70",
"@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/azure": "^2.0.69", "@ai-sdk/azure": "^2.0.69",
"@ai-sdk/deepseek": "^1.0.30", "@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/gateway": "^2.0.21",
"@ai-sdk/google": "^2.0.0", "@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19", "@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.107", "@ai-sdk/react": "^2.0.107",
@@ -63,7 +62,7 @@
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "^2.3.8", "@biomejs/biome": "2.3.8",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/node": "^20", "@types/node": "^20",
@@ -200,13 +199,13 @@
} }
}, },
"node_modules/@ai-sdk/gateway": { "node_modules/@ai-sdk/gateway": {
"version": "2.0.21", "version": "2.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.21.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.18.tgz",
"integrity": "sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA==", "integrity": "sha512-sDQcW+6ck2m0pTIHW6BPHD7S125WD3qNkx/B8sEzJp/hurocmJ5Cni0ybExg6sQMGo+fr/GWOwpHF1cmCdg5rQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "2.0.0", "@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.19", "@ai-sdk/provider-utils": "3.0.18",
"@vercel/oidc": "3.0.5" "@vercel/oidc": "3.0.5"
}, },
"engines": { "engines": {
@@ -216,23 +215,6 @@
"zod": "^3.25.76 || ^4.1.8" "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": { "node_modules/@ai-sdk/google": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-2.0.0.tgz",
@@ -6148,23 +6130,6 @@
"zod": "^3.25.76 || ^4.1.8" "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": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",

View File

@@ -17,7 +17,6 @@
"@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/azure": "^2.0.69", "@ai-sdk/azure": "^2.0.69",
"@ai-sdk/deepseek": "^1.0.30", "@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/gateway": "^2.0.21",
"@ai-sdk/google": "^2.0.0", "@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^2.0.19", "@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^2.0.107", "@ai-sdk/react": "^2.0.107",
@@ -73,7 +72,7 @@
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "^2.3.8", "@biomejs/biome": "2.3.8",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/node": "^20", "@types/node": "^20",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

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