Compare commits

..

1 Commits

Author SHA1 Message Date
dayuan.jiang
e1ffdb8f8e fix: fix hydration mismatch and enhance system prompts
- Fix SSR hydration by loading DrawIO theme after mount with useEffect
- Add loading spinner while theme loads
- Add JSON escaping warnings to system prompts
- Add edge routing rules to system prompts
- Update token count estimates in comments
2025-12-07 00:25:53 +09:00
13 changed files with 170 additions and 283 deletions

View File

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

View File

@@ -1,5 +1,5 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react" import React, { 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"
@@ -14,13 +14,17 @@ export default function Home() {
const { drawioRef, handleDiagramExport } = useDiagram() const { drawioRef, handleDiagramExport } = 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">(() => { const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
if (typeof window !== "undefined") { const [isThemeLoaded, setIsThemeLoaded] = useState(false)
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") return saved // Load theme from localStorage after mount to avoid hydration mismatch
useEffect(() => {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") {
setDrawioUi(saved)
} }
return "min" setIsThemeLoaded(true)
}) }, [])
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
useEffect(() => { useEffect(() => {
@@ -86,18 +90,24 @@ export default function Home() {
}`} }`}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
<DrawIoEmbed {isThemeLoaded ? (
key={drawioUi} <DrawIoEmbed
ref={drawioRef} key={drawioUi}
onExport={handleDiagramExport} ref={drawioRef}
urlParameters={{ onExport={handleDiagramExport}
ui: drawioUi, urlParameters={{
spin: true, ui: drawioUi,
libraries: false, spin: true,
saveAndExit: false, libraries: false,
noExitBtn: true, 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>
)}
</div> </div>
</div> </div>
</ResizablePanel> </ResizablePanel>

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import { X } from "lucide-react" import { X } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import { useEffect, useRef, useState } from "react" import React, { useEffect, useState } from "react"
interface FilePreviewListProps { interface FilePreviewListProps {
files: File[] files: File[]
@@ -11,55 +11,17 @@ interface FilePreviewListProps {
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) { export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null) const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
const imageUrlsRef = useRef<Map<File, string>>(new Map())
// Create and cleanup object URLs when files change // Cleanup object URLs on unmount
useEffect(() => { useEffect(() => {
const currentUrls = imageUrlsRef.current const objectUrls = files
const newUrls = new Map<File, string>() .filter((file) => file.type.startsWith("image/"))
.map((file) => URL.createObjectURL(file))
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 () => { return () => {
imageUrlsRef.current.forEach((url) => { objectUrls.forEach(URL.revokeObjectURL)
URL.revokeObjectURL(url)
})
} }
}, []) }, [files])
// 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 if (files.length === 0) return null
@@ -67,7 +29,9 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
<> <>
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md"> <div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
{files.map((file, index) => { {files.map((file, index) => {
const imageUrl = imageUrls.get(file) || null const imageUrl = file.type.startsWith("image/")
? URL.createObjectURL(file)
: null
return ( return (
<div key={file.name + index} className="relative group"> <div key={file.name + index} className="relative group">
<div <div
@@ -76,9 +40,9 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
imageUrl && setSelectedImage(imageUrl) imageUrl && setSelectedImage(imageUrl)
} }
> >
{file.type.startsWith("image/") && imageUrl ? ( {file.type.startsWith("image/") ? (
<Image <Image
src={imageUrl} src={imageUrl!}
alt={file.name} alt={file.name}
width={80} width={80}
height={80} height={80}

View File

@@ -3,12 +3,15 @@
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5) * Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
*/ */
// Default system prompt (~1400 tokens) - works with all models // Default system prompt (~2700 tokens) - works with all models
export const DEFAULT_SYSTEM_PROMPT = ` export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation. 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. 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. 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 ## App Context
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has: 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 - **Left panel**: Draw.io diagram editor where diagrams are rendered
@@ -51,6 +54,8 @@ Core capabilities:
- Optimize element positioning to prevent overlapping and maintain readability - Optimize element positioning to prevent overlapping and maintain readability
- Structure complex systems into clear, organized visual components - Structure complex systems into clear, organized visual components
Layout constraints: Layout constraints:
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks - 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 - Position all elements with x coordinates between 0-800 and y coordinates between 0-600
@@ -82,6 +87,11 @@ When using edit_diagram tool:
- For multiple changes, use separate edits in array - 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. - 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 ## Draw.io XML Structure Reference
Basic structure: Basic structure:
@@ -119,9 +129,11 @@ Common styles:
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex - Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle - Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right - Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
` `
// Extended additions (~2600 tokens) - appended for models with 4000 token cache minimum // Extended additions (~1800 tokens) - appended for models with 4000 token cache minimum
// Total EXTENDED_SYSTEM_PROMPT = ~4500 tokens
const EXTENDED_ADDITIONS = ` const EXTENDED_ADDITIONS = `
## Extended Tool Reference ## Extended Tool Reference
@@ -213,6 +225,11 @@ Copy the search pattern EXACTLY from the current XML, including leading spaces,
**BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match **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 **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 ### Error Recovery
If edit_diagram fails with "pattern not found": If edit_diagram fails with "pattern not found":
1. **First retry**: Check attribute order - copy EXACTLY from current XML 1. **First retry**: Check attribute order - copy EXACTLY from current XML
@@ -220,81 +237,97 @@ If edit_diagram fails with "pattern not found":
3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement 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 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
### Text Styles ### Edge Routing Rules:
- fontSize=14, fontStyle=1 (1=bold, 2=italic, 4=underline, 3=bold+italic) When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
- fontColor=#hex, align=center/left/right, verticalAlign=middle/top/bottom
## Common Shape Types **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)
### Basic Shapes **Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**
- Rectangle: rounded=0;whiteSpace=wrap;html=1; - A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)
- Rounded Rectangle: rounded=1;whiteSpace=wrap;html=1; - B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)
- Ellipse/Circle: ellipse;whiteSpace=wrap;html=1;aspect=fixed;
- Diamond: rhombus;whiteSpace=wrap;html=1;
- Cylinder: shape=cylinder3;whiteSpace=wrap;html=1;
### Flowchart Shapes **Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
- Process: rounded=1;whiteSpace=wrap;html=1; - Every edge MUST have these 4 attributes set in the style
- Decision: rhombus;whiteSpace=wrap;html=1; - Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
- Start/End: ellipse;whiteSpace=wrap;html=1;
- Document: shape=document;whiteSpace=wrap;html=1;
- Database: shape=cylinder3;whiteSpace=wrap;html=1;
### Container Types **Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
- Swimlane: swimlane;whiteSpace=wrap;html=1; - Before creating an edge, identify ALL shapes positioned between source and target
- Group Box: rounded=1;whiteSpace=wrap;html=1;container=1;collapsible=0; - 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/Group Example **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):
\`\`\`xml \`\`\`xml
<mxCell id="container1" value="Group Title" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <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 x="40" y="40" width="200" height="200" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="child1" value="Child Element" style="rounded=1;" vertex="1" parent="container1"> <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 x="20" y="40" width="160" height="40" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
\`\`\` \`\`\`
## Example: Complete Flowchart ### Edge with single waypoint (simple detour):
\`\`\`xml \`\`\`xml
<root> <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">
<mxCell id="0"/> <mxGeometry relative="1" as="geometry">
<mxCell id="1" parent="0"/> <Array as="points">
<mxCell id="start" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"> <mxPoint x="300" y="150"/>
<mxGeometry x="200" y="40" width="100" height="60" as="geometry"/> </Array>
</mxCell> </mxGeometry>
<mxCell id="process1" value="Process Step" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"> </mxCell>
<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>
\`\`\` \`\`\`
`
### 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>
\`\`\`
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 // Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + 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 // Insert after cell0 if possible
const cell0 = currentRoot.querySelector('mxCell[id="0"]') const cell0 = currentRoot.querySelector('mxCell[id="0"]')
if (cell0?.nextSibling) { if (cell0 && cell0.nextSibling) {
currentRoot.insertBefore(cell1, cell0.nextSibling) currentRoot.insertBefore(cell1, cell0.nextSibling)
} else { } else {
currentRoot.appendChild(cell1) currentRoot.appendChild(cell1)

4
package-lock.json generated
View File

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

View File

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