Compare commits

..

6 Commits

Author SHA1 Message Date
dayuan.jiang
77f2569a3b chore: bump version to 0.3.0 2025-12-06 19:26:26 +09:00
Dayuan Jiang
cbb92bd636 fix: set maxDuration to 60 for Vercel hobby plan (#122) 2025-12-06 18:09:30 +09:00
Dayuan Jiang
8d898d8adc fix: revert maxDuration to static value (Next.js requirement) (#121) 2025-12-06 18:04:23 +09:00
Dayuan Jiang
1e0b1ed970 feat: make maxDuration configurable via MAX_DURATION env (#120) 2025-12-06 17:47:50 +09:00
Dayuan Jiang
1d03d10ba8 docs: add CONTRIBUTING.md (#119) 2025-12-06 17:39:47 +09:00
Dayuan Jiang
e893bd60f9 fix: resolve biome lint errors and memory leak in file preview (#118)
- Disable noisy biome rules (noExplicitAny, useExhaustiveDependencies, etc.)
- Fix memory leak in file-preview-list.tsx with useRef pattern
- Separate unmount cleanup into dedicated useEffect
- Add ToolPartLike interface for type safety in chat-message-display
- Add accessibility attributes (role, tabIndex, onKeyDown)
- Replace autoFocus with useEffect focus pattern
- Minor syntax improvements (optional chaining, key fixes)
2025-12-06 16:18:26 +09:00
13 changed files with 283 additions and 170 deletions

35
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,35 @@
# Contributing
## Setup
```bash
git clone https://github.com/YOUR_USERNAME/next-ai-draw-io.git
cd next-ai-draw-io
npm install
cp env.example .env.local
npm run dev
```
## Code Style
We use [Biome](https://biomejs.dev/) for linting and formatting:
```bash
npm run format # Format code
npm run lint # Check lint errors
npm run check # Run all checks (CI)
```
Pre-commit hooks via Husky will run Biome automatically on staged files.
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
## Pull Requests
1. Create a feature branch
2. Make changes and ensure `npm run check` passes
3. Submit PR against `main` with a clear description
## Issues
Include steps to reproduce, expected vs actual behavior, and AI provider used.

View File

@@ -15,7 +15,7 @@ import {
} from "@/lib/langfuse"
import { getSystemPrompt } from "@/lib/system-prompts"
export const maxDuration = 300
export const maxDuration = 60
// File upload limits (must match client-side)
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
@@ -40,7 +40,7 @@ function validateFileParts(messages: any[]): {
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url && filePart.url.startsWith("data:")) {
if (filePart.url?.startsWith("data:")) {
const base64Data = filePart.url.split(",")[1]
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)

View File

@@ -1,5 +1,5 @@
"use client"
import React, { useEffect, useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel"
@@ -14,17 +14,13 @@ export default function Home() {
const { drawioRef, handleDiagramExport } = useDiagram()
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [isThemeLoaded, setIsThemeLoaded] = useState(false)
// Load theme from localStorage after mount to avoid hydration mismatch
useEffect(() => {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") {
setDrawioUi(saved)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") return saved
}
setIsThemeLoaded(true)
}, [])
return "min"
})
const chatPanelRef = useRef<ImperativePanelHandle>(null)
useEffect(() => {
@@ -90,24 +86,18 @@ export default function Home() {
}`}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
{isThemeLoaded ? (
<DrawIoEmbed
key={drawioUi}
ref={drawioRef}
onExport={handleDiagramExport}
urlParameters={{
ui: drawioUi,
spin: true,
libraries: false,
saveAndExit: false,
noExitBtn: true,
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
)}
<DrawIoEmbed
key={drawioUi}
ref={drawioRef}
onExport={handleDiagramExport}
urlParameters={{
ui: drawioUi,
spin: true,
libraries: false,
saveAndExit: false,
noExitBtn: true,
}}
/>
</div>
</div>
</ResizablePanel>

View File

@@ -19,6 +19,30 @@
"recommended": true,
"complexity": {
"noImportantStyles": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off",
"noImplicitAnyLet": "off",
"noAssignInExpressions": "off"
},
"a11y": {
"useButtonType": "off",
"noAutofocus": "off",
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off",
"noLabelWithoutControl": "off",
"noNoninteractiveTabindex": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
},
"style": {
"useNodejsImportProtocol": "off",
"useTemplate": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
}
}
},

View File

@@ -99,8 +99,8 @@ function showValidationErrors(errors: string[]) {
{errors.length} files rejected:
</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err, i) => (
<li key={i}>{err}</li>
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>...and {errors.length - 3} more</li>
@@ -162,10 +162,16 @@ export function ChatInput({
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault()
@@ -297,7 +303,7 @@ export function ChatInput({
<Textarea
ref={textareaRef}
value={input}
onChange={onChange}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Describe your diagram or paste an image..."

View File

@@ -1,6 +1,7 @@
"use client"
import type { UIMessage } from "ai"
import {
Check,
ChevronDown,
@@ -32,12 +33,21 @@ interface EditPair {
replace: string
}
// Tool part interface for type safety
interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: { xml?: string; edits?: EditPair[] } & Record<string, unknown>
output?: string
}
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
return (
<div className="space-y-3">
{edits.map((edit, index) => (
<div
key={index}
key={`${edit.search.slice(0, 50)}-${edit.replace.slice(0, 50)}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
@@ -82,8 +92,8 @@ import { useDiagram } from "@/contexts/diagram-context"
const getMessageTextContent = (message: UIMessage): string => {
if (!message.parts) return ""
return message.parts
.filter((part: any) => part.type === "text")
.map((part: any) => part.text)
.filter((part) => part.type === "text")
.map((part) => (part as { text: string }).text)
.join("\n")
}
@@ -119,6 +129,7 @@ export function ChatMessageDisplay({
const [editingMessageId, setEditingMessageId] = useState<string | null>(
null,
)
const editTextareaRef = useRef<HTMLTextAreaElement>(null)
const [editText, setEditText] = useState<string>("")
const copyMessageToClipboard = async (messageId: string, text: string) => {
@@ -189,12 +200,19 @@ export function ChatMessageDisplay({
}
}, [messages])
useEffect(() => {
if (editingMessageId && editTextareaRef.current) {
editTextareaRef.current.focus()
}
}, [editingMessageId])
useEffect(() => {
messages.forEach((message) => {
if (message.parts) {
message.parts.forEach((part: any) => {
message.parts.forEach((part) => {
if (part.type?.startsWith("tool-")) {
const { toolCallId, state } = part
const toolPart = part as ToolPartLike
const { toolCallId, state, input } = toolPart
if (state === "output-available") {
setExpandedTools((prev) => ({
@@ -205,18 +223,19 @@ export function ChatMessageDisplay({
if (
part.type === "tool-display_diagram" &&
part.input?.xml
input?.xml
) {
const xml = input.xml as string
if (
state === "input-streaming" ||
state === "input-available"
) {
handleDisplayChart(part.input.xml)
handleDisplayChart(xml)
} else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
handleDisplayChart(part.input.xml)
handleDisplayChart(xml)
processedToolCalls.current.add(toolCallId)
}
}
@@ -226,7 +245,7 @@ export function ChatMessageDisplay({
})
}, [messages, handleDisplayChart])
const renderToolPart = (part: any) => {
const renderToolPart = (part: ToolPartLike) => {
const callId = part.toolCallId
const { state, input, output } = part
const isExpanded = expandedTools[callId] ?? true
@@ -280,6 +299,7 @@ export function ChatMessageDisplay({
)}
{input && Object.keys(input).length > 0 && (
<button
type="button"
onClick={toggleExpanded}
className="p-1 rounded hover:bg-muted transition-colors"
>
@@ -358,6 +378,7 @@ export function ChatMessageDisplay({
{onEditMessage &&
isLastUserMessage && (
<button
type="button"
onClick={() => {
setEditingMessageId(
message.id,
@@ -373,6 +394,7 @@ export function ChatMessageDisplay({
</button>
)}
<button
type="button"
onClick={() =>
copyMessageToClipboard(
message.id,
@@ -407,6 +429,7 @@ export function ChatMessageDisplay({
{isEditing && message.role === "user" ? (
<div className="flex flex-col gap-2">
<textarea
ref={editTextareaRef}
value={editText}
onChange={(e) =>
setEditText(e.target.value)
@@ -417,7 +440,6 @@ export function ChatMessageDisplay({
.length + 1,
6,
)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Escape") {
setEditingMessageId(
@@ -447,6 +469,7 @@ export function ChatMessageDisplay({
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setEditingMessageId(
null,
@@ -458,6 +481,7 @@ export function ChatMessageDisplay({
Cancel
</button>
<button
type="button"
onClick={() => {
if (
editText.trim() &&
@@ -483,7 +507,7 @@ export function ChatMessageDisplay({
) : (
/* Text content in bubble */
message.parts?.some(
(part: any) =>
(part) =>
part.type === "text" ||
part.type === "file",
) && (
@@ -496,6 +520,20 @@ export function ChatMessageDisplay({
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
role={
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? "button"
: undefined
}
tabIndex={
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? 0
: undefined
}
onClick={() => {
if (
message.role ===
@@ -511,6 +549,24 @@ export function ChatMessageDisplay({
)
}
}}
onKeyDown={(e) => {
if (
(e.key === "Enter" ||
e.key === " ") &&
message.role ===
"user" &&
isLastUserMessage &&
onEditMessage
) {
e.preventDefault()
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
)
}
}}
title={
message.role === "user" &&
isLastUserMessage &&
@@ -520,17 +576,12 @@ export function ChatMessageDisplay({
}
>
{message.parts?.map(
(
part: any,
index: number,
) => {
(part, index) => {
switch (part.type) {
case "text":
return (
<div
key={
index
}
key={`${message.id}-text-${index}`}
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
message.role ===
"user"
@@ -548,9 +599,7 @@ export function ChatMessageDisplay({
case "file":
return (
<div
key={
index
}
key={`${message.id}-file-${part.url}`}
className="mt-2"
>
<Image
@@ -581,9 +630,11 @@ export function ChatMessageDisplay({
)
)}
{/* Tool calls outside bubble */}
{message.parts?.map((part: any) => {
{message.parts?.map((part) => {
if (part.type?.startsWith("tool-")) {
return renderToolPart(part)
return renderToolPart(
part as ToolPartLike,
)
}
return null
})}
@@ -592,6 +643,7 @@ export function ChatMessageDisplay({
<div className="flex items-center gap-1 mt-2">
{/* Copy button */}
<button
type="button"
onClick={() =>
copyMessageToClipboard(
message.id,
@@ -624,6 +676,7 @@ export function ChatMessageDisplay({
{onRegenerate &&
isLastAssistantMessage && (
<button
type="button"
onClick={() =>
onRegenerate(
messageIndex,
@@ -639,6 +692,7 @@ export function ChatMessageDisplay({
<div className="w-px h-4 bg-border mx-1" />
{/* Thumbs up */}
<button
type="button"
onClick={() =>
submitFeedback(
message.id,
@@ -657,6 +711,7 @@ export function ChatMessageDisplay({
</button>
{/* Thumbs down */}
<button
type="button"
onClick={() =>
submitFeedback(
message.id,

View File

@@ -22,7 +22,7 @@ import {
STORAGE_ACCESS_CODE_KEY,
} from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { formatXML, replaceNodes, validateMxCellStructure } from "@/lib/utils"
import { formatXML, validateMxCellStructure } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display"
interface ChatPanelProps {

View File

@@ -12,7 +12,7 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
<div className="overflow-hidden w-full">
<Highlight theme={themes.github} code={code} language={language}>
{({
className,
className: _className,
style,
tokens,
getLineProps,

View File

@@ -2,7 +2,7 @@
import { X } from "lucide-react"
import Image from "next/image"
import React, { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
interface FilePreviewListProps {
files: File[]
@@ -11,27 +11,63 @@ interface FilePreviewListProps {
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
const imageUrlsRef = useRef<Map<File, string>>(new Map())
// Cleanup object URLs on unmount
// Create and cleanup object URLs when files change
useEffect(() => {
const objectUrls = files
.filter((file) => file.type.startsWith("image/"))
.map((file) => URL.createObjectURL(file))
const currentUrls = imageUrlsRef.current
const newUrls = new Map<File, string>()
return () => {
objectUrls.forEach(URL.revokeObjectURL)
}
files.forEach((file) => {
if (file.type.startsWith("image/")) {
// Reuse existing URL if file is already tracked
const existingUrl = currentUrls.get(file)
if (existingUrl) {
newUrls.set(file, existingUrl)
} else {
newUrls.set(file, URL.createObjectURL(file))
}
}
})
// Revoke URLs for files that are no longer in the list
currentUrls.forEach((url, file) => {
if (!newUrls.has(file)) {
URL.revokeObjectURL(url)
}
})
imageUrlsRef.current = newUrls
setImageUrls(newUrls)
}, [files])
// Cleanup all URLs on unmount only
useEffect(() => {
return () => {
imageUrlsRef.current.forEach((url) => {
URL.revokeObjectURL(url)
})
}
}, [])
// Clear selected image if its URL was revoked
useEffect(() => {
if (
selectedImage &&
!Array.from(imageUrls.values()).includes(selectedImage)
) {
setSelectedImage(null)
}
}, [imageUrls, selectedImage])
if (files.length === 0) return null
return (
<>
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
{files.map((file, index) => {
const imageUrl = file.type.startsWith("image/")
? URL.createObjectURL(file)
: null
const imageUrl = imageUrls.get(file) || null
return (
<div key={file.name + index} className="relative group">
<div
@@ -40,9 +76,9 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
imageUrl && setSelectedImage(imageUrl)
}
>
{file.type.startsWith("image/") ? (
{file.type.startsWith("image/") && imageUrl ? (
<Image
src={imageUrl!}
src={imageUrl}
alt={file.name}
width={80}
height={80}

View File

@@ -3,15 +3,12 @@
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
*/
// Default system prompt (~2700 tokens) - works with all models
// Default system prompt (~1400 tokens) - works with all models
export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
You can see the image that user uploaded.
When you are asked to create a diagram, you must first tell user you plan in text first. Plan the layout and structure that can avoid object overlapping or edge cross the objects.
Then use display_diagram tool to generate the full draw.io XML for the entire diagram.
## App Context
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
- **Left panel**: Draw.io diagram editor where diagrams are rendered
@@ -54,8 +51,6 @@ Core capabilities:
- Optimize element positioning to prevent overlapping and maintain readability
- Structure complex systems into clear, organized visual components
Layout constraints:
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
@@ -87,11 +82,6 @@ When using edit_diagram tool:
- For multiple changes, use separate edits in array
- RETRY POLICY: If pattern not found, retry up to 3 times with adjusted patterns. After 3 failures, use display_diagram instead.
⚠️ CRITICAL JSON ESCAPING: When outputting edit_diagram tool calls, you MUST escape ALL double quotes inside string values:
- CORRECT: "y=\\"119\\"" (both quotes escaped)
- WRONG: "y="119\\"" (missing backslash before first quote - causes JSON parse error!)
- Every " inside a JSON string value needs \\" - no exceptions!
## Draw.io XML Structure Reference
Basic structure:
@@ -129,11 +119,9 @@ Common styles:
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
`
// Extended additions (~1800 tokens) - appended for models with 4000 token cache minimum
// Total EXTENDED_SYSTEM_PROMPT = ~4500 tokens
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum
const EXTENDED_ADDITIONS = `
## Extended Tool Reference
@@ -225,11 +213,6 @@ Copy the search pattern EXACTLY from the current XML, including leading spaces,
**BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match
**GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context
### ⚠️ JSON Escaping (CRITICAL)
Every double quote inside JSON string values MUST be escaped with backslash:
- **CORRECT:** \`"x=\\"100\\" y=\\"200\\""\` - both quotes escaped
- **WRONG:** \`"x=\\"100\\" y="200\\""\` - missing backslash causes JSON parse error!
### Error Recovery
If edit_diagram fails with "pattern not found":
1. **First retry**: Check attribute order - copy EXACTLY from current XML
@@ -237,97 +220,81 @@ If edit_diagram fails with "pattern not found":
3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement
4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram
## Common Style Properties
### Shape Styles
- rounded=1, fillColor=#hex, strokeColor=#hex, strokeWidth=2
- whiteSpace=wrap, html=1, opacity=50, shadow=1, glass=1
### Edge/Connector Styles
- endArrow=classic/block/open/oval/diamond/none, startArrow=none/classic
- curved=1, edgeStyle=orthogonalEdgeStyle, strokeWidth=2
- dashed=1, dashPattern=3 3, flowAnimation=1
### Edge Routing Rules:
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
### Text Styles
- fontSize=14, fontStyle=1 (1=bold, 2=italic, 4=underline, 3=bold+italic)
- fontColor=#hex, align=center/left/right, verticalAlign=middle/top/bottom
**Rule 1: NEVER let multiple edges share the same path**
- If two edges connect the same pair of nodes, they MUST exit/enter at DIFFERENT positions
- Use exitY=0.3 for first edge, exitY=0.7 for second edge (NOT both 0.5)
## Common Shape Types
**Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**
- A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)
- B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)
### Basic Shapes
- Rectangle: rounded=0;whiteSpace=wrap;html=1;
- Rounded Rectangle: rounded=1;whiteSpace=wrap;html=1;
- Ellipse/Circle: ellipse;whiteSpace=wrap;html=1;aspect=fixed;
- Diamond: rhombus;whiteSpace=wrap;html=1;
- Cylinder: shape=cylinder3;whiteSpace=wrap;html=1;
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
- Every edge MUST have these 4 attributes set in the style
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
### Flowchart Shapes
- Process: rounded=1;whiteSpace=wrap;html=1;
- Decision: rhombus;whiteSpace=wrap;html=1;
- Start/End: ellipse;whiteSpace=wrap;html=1;
- Document: shape=document;whiteSpace=wrap;html=1;
- Database: shape=cylinder3;whiteSpace=wrap;html=1;
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
- Before creating an edge, identify ALL shapes positioned between source and target
- If any shape is in the direct path, you MUST use waypoints to route around it
- For DIAGONAL connections: route along the PERIMETER (outside edge) of the diagram, NOT through the middle
- Add 20-30px clearance from shape boundaries when calculating waypoint positions
- Route ABOVE (lower y), BELOW (higher y), or to the SIDE of obstacles
- NEVER draw a line that visually crosses over another shape's bounding box
### Container Types
- Swimlane: swimlane;whiteSpace=wrap;html=1;
- Group Box: rounded=1;whiteSpace=wrap;html=1;container=1;collapsible=0;
**Rule 5: Plan layout strategically BEFORE generating XML**
- Organize shapes into visual layers/zones (columns or rows) based on diagram flow
- Space shapes 150-200px apart to create clear routing channels for edges
- Mentally trace each edge: "What shapes are between source and target?"
- Prefer layouts where edges naturally flow in one direction (left-to-right or top-to-bottom)
**Rule 6: Use multiple waypoints for complex routing**
- One waypoint is often not enough - use 2-3 waypoints to create proper L-shaped or U-shaped paths
- Each direction change needs a waypoint (corner point)
- Waypoints should form clear horizontal/vertical segments (orthogonal routing)
- Calculate positions by: (1) identify obstacle boundaries, (2) add 20-30px margin
**Rule 7: Choose NATURAL connection points based on flow direction**
- NEVER use corner connections (e.g., entryX=1,entryY=1) - they look unnatural
- For TOP-TO-BOTTOM flow: exit from bottom (exitY=1), enter from top (entryY=0)
- For LEFT-TO-RIGHT flow: exit from right (exitX=1), enter from left (entryX=0)
- For DIAGONAL connections: use the side closest to the target, not corners
- Example: Node below-right of source → exit from bottom (exitY=1) OR right (exitX=1), not corner
**Before generating XML, mentally verify:**
1. "Do any edges cross over shapes that aren't their source/target?" → If yes, add waypoints
2. "Do any two edges share the same path?" → If yes, adjust exit/entry points
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
## Edge Examples
### Two edges between same nodes (CORRECT - no overlap):
## Container/Group Example
\`\`\`xml
<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
<mxGeometry relative="1" as="geometry"/>
<mxCell id="container1" value="Group Title" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
<mxGeometry relative="1" as="geometry"/>
<mxCell id="child1" value="Child Element" style="rounded=1;" vertex="1" parent="container1">
<mxGeometry x="20" y="40" width="160" height="40" as="geometry"/>
</mxCell>
\`\`\`
### Edge with single waypoint (simple detour):
\`\`\`xml
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;" edge="1" parent="1" source="a" target="b">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="300" y="150"/>
</Array>
</mxGeometry>
</mxCell>
\`\`\`
## Example: Complete Flowchart
### Edge with waypoints (routing AROUND obstacles) - CRITICAL PATTERN:
**Scenario:** Hotfix(right,bottom) → Main(center,top), but Develop(center,middle) is in between.
**WRONG:** Direct diagonal line crosses over Develop
**CORRECT:** Route around the OUTSIDE (go right first, then up)
\`\`\`xml
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="750" y="80"/>
<mxPoint x="750" y="150"/>
</Array>
</mxGeometry>
</mxCell>
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="start" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="200" y="40" width="100" height="60" as="geometry"/>
</mxCell>
<mxCell id="process1" value="Process Step" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="175" y="140" width="150" height="60" as="geometry"/>
</mxCell>
<mxCell id="decision" value="Decision?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="175" y="240" width="150" height="100" as="geometry"/>
</mxCell>
<mxCell id="end" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="200" y="380" width="100" height="60" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="start" target="process1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="edge2" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="process1" target="decision">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="edge3" value="Yes" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="decision" target="end">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
\`\`\`
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
**Key principle:** When connecting distant nodes diagonally, route along the PERIMETER of the diagram, not through the middle where other shapes exist.`
`
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS

View File

@@ -168,7 +168,7 @@ export function replaceNodes(currentXML: string, nodes: string): string {
// Insert after cell0 if possible
const cell0 = currentRoot.querySelector('mxCell[id="0"]')
if (cell0 && cell0.nextSibling) {
if (cell0?.nextSibling) {
currentRoot.insertBefore(cell1, cell0.nextSibling)
} else {
currentRoot.appendChild(cell1)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "next-ai-draw-io",
"version": "0.2.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "next-ai-draw-io",
"version": "0.2.0",
"version": "0.3.0",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.62",

View File

@@ -1,6 +1,6 @@
{
"name": "next-ai-draw-io",
"version": "0.2.0",
"version": "0.3.0",
"license": "Apache-2.0",
"private": true,
"scripts": {