mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 06:42:27 +08:00
Compare commits
1 Commits
chore/add-
...
fix/drawio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4bbda1ccf |
47
.github/workflows/auto-format.yml
vendored
47
.github/workflows/auto-format.yml
vendored
@@ -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
|
|
||||||
@@ -1,28 +1,27 @@
|
|||||||
import type { MetadataRoute } from "next"
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: "Next AI Draw.io",
|
name: 'Next AI Draw.io',
|
||||||
short_name: "AIDraw.io",
|
short_name: 'AIDraw.io',
|
||||||
description:
|
description: 'Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.',
|
||||||
"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: '/',
|
||||||
start_url: "/",
|
display: 'standalone',
|
||||||
display: "standalone",
|
background_color: '#f9fafb',
|
||||||
background_color: "#f9fafb",
|
theme_color: '#171d26',
|
||||||
theme_color: "#171d26",
|
icons: [
|
||||||
icons: [
|
{
|
||||||
{
|
src: '/favicon-192x192.png',
|
||||||
src: "/favicon-192x192.png",
|
sizes: '192x192',
|
||||||
sizes: "192x192",
|
type: 'image/png',
|
||||||
type: "image/png",
|
purpose: 'any',
|
||||||
purpose: "any",
|
},
|
||||||
},
|
{
|
||||||
{
|
src: '/favicon-512x512.png',
|
||||||
src: "/favicon-512x512.png",
|
sizes: '512x512',
|
||||||
sizes: "512x512",
|
type: 'image/png',
|
||||||
type: "image/png",
|
purpose: 'any',
|
||||||
purpose: "any",
|
},
|
||||||
},
|
],
|
||||||
],
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/page.tsx
37
app/page.tsx
@@ -33,12 +33,10 @@ export default function Home() {
|
|||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
const isSavingRef = useRef(false)
|
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)
|
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSaveDialog) {
|
if (!showSaveDialog && isSavingRef.current) {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
isSavingRef.current = false
|
isSavingRef.current = false
|
||||||
}, 1000)
|
}, 1000)
|
||||||
@@ -47,10 +45,7 @@ export default function Home() {
|
|||||||
}, [showSaveDialog])
|
}, [showSaveDialog])
|
||||||
|
|
||||||
// Handle save from draw.io's built-in save button
|
// 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(() => {
|
const handleDrawioSave = useCallback(() => {
|
||||||
if (!mouseOverDrawioRef.current) return
|
|
||||||
if (isSavingRef.current) return
|
if (isSavingRef.current) return
|
||||||
isSavingRef.current = true
|
isSavingRef.current = true
|
||||||
setShowSaveDialog(true)
|
setShowSaveDialog(true)
|
||||||
@@ -103,32 +98,16 @@ export default function Home() {
|
|||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check mobile - save diagram and reset draw.io before crossing breakpoint
|
// Check mobile
|
||||||
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 +153,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 +167,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 ? (
|
||||||
@@ -225,7 +199,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}
|
||||||
|
|||||||
@@ -335,7 +335,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 +386,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"
|
||||||
@@ -405,7 +405,7 @@ export function ChatInput({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSaveDialog(true)}
|
onClick={() => setShowSaveDialog(true)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
tooltipContent="Save diagram"
|
tooltipContent="Save diagram (deprecated: use Save button in upper right corner of draw.io)"
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -40,15 +40,12 @@ 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 [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 +61,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
|
||||||
|
|||||||
@@ -140,36 +140,17 @@ OLLAMA_BASE_URL=http://localhost:11434
|
|||||||
|
|
||||||
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.
|
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
|
```bash
|
||||||
AI_GATEWAY_API_KEY=your_gateway_api_key
|
AI_GATEWAY_API_KEY=your_gateway_api_key
|
||||||
AI_MODEL=openai/gpt-4o
|
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:
|
Model format uses `provider/model` syntax:
|
||||||
|
|
||||||
- `openai/gpt-4o` - OpenAI GPT-4o
|
- `openai/gpt-4o` - OpenAI GPT-4o
|
||||||
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
|
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
|
||||||
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
|
- `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).
|
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
|
||||||
|
|
||||||
## Auto-Detection
|
## Auto-Detection
|
||||||
|
|||||||
@@ -72,8 +72,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Get your API key from: https://vercel.com/ai-gateway
|
# Get your API key from: https://vercel.com/ai-gateway
|
||||||
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||||
# AI_GATEWAY_API_KEY=...
|
# 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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 { 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"
|
||||||
@@ -683,20 +683,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
// Vercel AI Gateway - unified access to multiple AI providers
|
// Vercel AI Gateway - unified access to multiple AI providers
|
||||||
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||||
// See: https://vercel.com/ai-gateway
|
// See: https://vercel.com/ai-gateway
|
||||||
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
|
model = gateway(modelId)
|
||||||
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.3",
|
"version": "0.4.2",
|
||||||
"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.2",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^3.0.70",
|
"@ai-sdk/amazon-bedrock": "^3.0.70",
|
||||||
@@ -63,7 +63,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",
|
||||||
|
|||||||
@@ -73,7 +73,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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user