Compare commits

..

9 Commits

Author SHA1 Message Date
dayuan.jiang
d6931077f4 fix: handle edge cases in chat panel and message display
- Handle undefined edit.search/edit.replace in EditDiffDisplay
- Handle empty chartXML when displaying diagrams
- Add missing access code header to regenerate and edit message handlers
2025-12-07 01:27:57 +09:00
Dayuan Jiang
46567cb0b8 feat: verify access code with server before saving (#128) 2025-12-07 00:21:59 +09:00
Dayuan Jiang
9f77199272 feat: add configurable close protection setting (#123)
- Add Close Protection toggle to Settings dialog
- Save setting to localStorage (default: enabled)
- Make beforeunload confirmation conditional
- Settings button now always visible in header
- Add shadcn Switch and Label components
2025-12-06 21:42:28 +09:00
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
17 changed files with 725 additions and 190 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" } from "@/lib/langfuse"
import { getSystemPrompt } from "@/lib/system-prompts" import { getSystemPrompt } from "@/lib/system-prompts"
export const maxDuration = 300 export const maxDuration = 60
// 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 && filePart.url.startsWith("data:")) { if (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

@@ -0,0 +1,32 @@
export async function POST(req: Request) {
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
// If no access codes configured, verification always passes
if (accessCodes.length === 0) {
return Response.json({
valid: true,
message: "No access code required",
})
}
const accessCodeHeader = req.headers.get("x-access-code")
if (!accessCodeHeader) {
return Response.json(
{ valid: false, message: "Access code is required" },
{ status: 401 },
)
}
if (!accessCodes.includes(accessCodeHeader)) {
return Response.json(
{ valid: false, message: "Invalid access code" },
{ status: 401 },
)
}
return Response.json({ valid: true, message: "Access code is valid" })
}

View File

@@ -1,8 +1,9 @@
"use client" "use client"
import React, { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel" import ChatPanel from "@/components/chat-panel"
import { STORAGE_CLOSE_PROTECTION_KEY } from "@/components/settings-dialog"
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
@@ -14,17 +15,20 @@ 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">("min") const [drawioUi, setDrawioUi] = useState<"min" | "sketch">(() => {
const [isThemeLoaded, setIsThemeLoaded] = useState(false) if (typeof window !== "undefined") {
const saved = localStorage.getItem("drawio-theme")
// Load theme from localStorage after mount to avoid hydration mismatch if (saved === "min" || saved === "sketch") return saved
useEffect(() => {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") {
setDrawioUi(saved)
} }
setIsThemeLoaded(true) return "min"
}, []) })
const [closeProtection, setCloseProtection] = useState(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(STORAGE_CLOSE_PROTECTION_KEY)
return saved !== "false" // Default to true
}
return true
})
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
useEffect(() => { useEffect(() => {
@@ -65,6 +69,8 @@ export default function Home() {
// Show confirmation dialog when user tries to leave the page // Show confirmation dialog when user tries to leave the page
// This helps prevent accidental navigation from browser back gestures // This helps prevent accidental navigation from browser back gestures
useEffect(() => { useEffect(() => {
if (!closeProtection) return
const handleBeforeUnload = (event: BeforeUnloadEvent) => { const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault() event.preventDefault()
return "" return ""
@@ -73,7 +79,7 @@ export default function Home() {
window.addEventListener("beforeunload", handleBeforeUnload) window.addEventListener("beforeunload", handleBeforeUnload)
return () => return () =>
window.removeEventListener("beforeunload", handleBeforeUnload) window.removeEventListener("beforeunload", handleBeforeUnload)
}, []) }, [closeProtection])
return ( return (
<div className="h-screen bg-background relative overflow-hidden"> <div className="h-screen bg-background relative overflow-hidden">
@@ -90,24 +96,18 @@ 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">
{isThemeLoaded ? ( <DrawIoEmbed
<DrawIoEmbed key={drawioUi}
key={drawioUi} ref={drawioRef}
ref={drawioRef} onExport={handleDiagramExport}
onExport={handleDiagramExport} urlParameters={{
urlParameters={{ ui: drawioUi,
ui: drawioUi, spin: true,
spin: true, libraries: false,
libraries: false, saveAndExit: false,
saveAndExit: false, noExitBtn: true,
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>
@@ -137,6 +137,7 @@ export default function Home() {
setDrawioUi(newTheme) setDrawioUi(newTheme)
}} }}
isMobile={isMobile} isMobile={isMobile}
onCloseProtectionChange={setCloseProtection}
/> />
</div> </div>
</ResizablePanel> </ResizablePanel>

View File

@@ -19,6 +19,30 @@
"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, i) => ( {errors.slice(0, 3).map((err) => (
<li key={i}>{err}</li> <li key={err}>{err}</li>
))} ))}
{errors.length > 3 && ( {errors.length > 3 && (
<li>...and {errors.length - 3} more</li> <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(() => { 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()
@@ -297,7 +303,7 @@ export function ChatInput({
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={input} value={input}
onChange={onChange} onChange={handleChange}
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,6 +1,7 @@
"use client" "use client"
import type { UIMessage } from "ai" import type { UIMessage } from "ai"
import { import {
Check, Check,
ChevronDown, ChevronDown,
@@ -32,12 +33,21 @@ 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={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" 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">
@@ -82,8 +92,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: any) => part.type === "text") .filter((part) => part.type === "text")
.map((part: any) => part.text) .map((part) => (part as { text: string }).text)
.join("\n") .join("\n")
} }
@@ -119,6 +129,7 @@ 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) => {
@@ -166,7 +177,10 @@ export function ChatMessageDisplay({
const currentXml = xml || "" const currentXml = xml || ""
const convertedXml = convertToLegalXml(currentXml) const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) { if (convertedXml !== previousXML.current) {
const replacedXML = replaceNodes(chartXML, convertedXml) // If chartXML is empty, use the converted XML directly
const replacedXML = chartXML
? replaceNodes(chartXML, convertedXml)
: convertedXml
const validationError = validateMxCellStructure(replacedXML) const validationError = validateMxCellStructure(replacedXML)
if (!validationError) { if (!validationError) {
@@ -189,12 +203,19 @@ 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: any) => { message.parts.forEach((part) => {
if (part.type?.startsWith("tool-")) { if (part.type?.startsWith("tool-")) {
const { toolCallId, state } = part const toolPart = part as ToolPartLike
const { toolCallId, state, input } = toolPart
if (state === "output-available") { if (state === "output-available") {
setExpandedTools((prev) => ({ setExpandedTools((prev) => ({
@@ -205,18 +226,19 @@ export function ChatMessageDisplay({
if ( if (
part.type === "tool-display_diagram" && part.type === "tool-display_diagram" &&
part.input?.xml input?.xml
) { ) {
const xml = input.xml as string
if ( if (
state === "input-streaming" || state === "input-streaming" ||
state === "input-available" state === "input-available"
) { ) {
handleDisplayChart(part.input.xml) handleDisplayChart(xml)
} else if ( } else if (
state === "output-available" && state === "output-available" &&
!processedToolCalls.current.has(toolCallId) !processedToolCalls.current.has(toolCallId)
) { ) {
handleDisplayChart(part.input.xml) handleDisplayChart(xml)
processedToolCalls.current.add(toolCallId) processedToolCalls.current.add(toolCallId)
} }
} }
@@ -226,7 +248,7 @@ export function ChatMessageDisplay({
}) })
}, [messages, handleDisplayChart]) }, [messages, handleDisplayChart])
const renderToolPart = (part: any) => { const renderToolPart = (part: ToolPartLike) => {
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
@@ -280,6 +302,7 @@ 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"
> >
@@ -358,6 +381,7 @@ export function ChatMessageDisplay({
{onEditMessage && {onEditMessage &&
isLastUserMessage && ( isLastUserMessage && (
<button <button
type="button"
onClick={() => { onClick={() => {
setEditingMessageId( setEditingMessageId(
message.id, message.id,
@@ -373,6 +397,7 @@ export function ChatMessageDisplay({
</button> </button>
)} )}
<button <button
type="button"
onClick={() => onClick={() =>
copyMessageToClipboard( copyMessageToClipboard(
message.id, message.id,
@@ -407,6 +432,7 @@ 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)
@@ -417,7 +443,6 @@ export function ChatMessageDisplay({
.length + 1, .length + 1,
6, 6,
)} )}
autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
setEditingMessageId( setEditingMessageId(
@@ -447,6 +472,7 @@ 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,
@@ -458,6 +484,7 @@ export function ChatMessageDisplay({
Cancel Cancel
</button> </button>
<button <button
type="button"
onClick={() => { onClick={() => {
if ( if (
editText.trim() && editText.trim() &&
@@ -483,7 +510,7 @@ export function ChatMessageDisplay({
) : ( ) : (
/* Text content in bubble */ /* Text content in bubble */
message.parts?.some( message.parts?.some(
(part: any) => (part) =>
part.type === "text" || part.type === "text" ||
part.type === "file", part.type === "file",
) && ( ) && (
@@ -496,6 +523,20 @@ 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 ===
@@ -511,6 +552,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={ title={
message.role === "user" && message.role === "user" &&
isLastUserMessage && isLastUserMessage &&
@@ -520,17 +579,12 @@ 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={ key={`${message.id}-text-${index}`}
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"
@@ -548,9 +602,7 @@ export function ChatMessageDisplay({
case "file": case "file":
return ( return (
<div <div
key={ key={`${message.id}-file-${part.url}`}
index
}
className="mt-2" className="mt-2"
> >
<Image <Image
@@ -581,9 +633,11 @@ export function ChatMessageDisplay({
) )
)} )}
{/* Tool calls outside bubble */} {/* Tool calls outside bubble */}
{message.parts?.map((part: any) => { {message.parts?.map((part) => {
if (part.type?.startsWith("tool-")) { if (part.type?.startsWith("tool-")) {
return renderToolPart(part) return renderToolPart(
part as ToolPartLike,
)
} }
return null return null
})} })}
@@ -592,6 +646,7 @@ 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,
@@ -624,6 +679,7 @@ export function ChatMessageDisplay({
{onRegenerate && {onRegenerate &&
isLastAssistantMessage && ( isLastAssistantMessage && (
<button <button
type="button"
onClick={() => onClick={() =>
onRegenerate( onRegenerate(
messageIndex, messageIndex,
@@ -639,6 +695,7 @@ 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,
@@ -657,6 +714,7 @@ 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, replaceNodes, validateMxCellStructure } from "@/lib/utils" import { formatXML, validateMxCellStructure } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
interface ChatPanelProps { interface ChatPanelProps {
@@ -31,6 +31,7 @@ interface ChatPanelProps {
drawioUi: "min" | "sketch" drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void onToggleDrawioUi: () => void
isMobile?: boolean isMobile?: boolean
onCloseProtectionChange?: (enabled: boolean) => void
} }
export default function ChatPanel({ export default function ChatPanel({
@@ -39,6 +40,7 @@ export default function ChatPanel({
drawioUi, drawioUi,
onToggleDrawioUi, onToggleDrawioUi,
isMobile = false, isMobile = false,
onCloseProtectionChange,
}: ChatPanelProps) { }: ChatPanelProps) {
const { const {
loadDiagram: onDisplayChart, loadDiagram: onDisplayChart,
@@ -345,6 +347,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
}) })
// Now send the message after state is guaranteed to be updated // Now send the message after state is guaranteed to be updated
const accessCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
sendMessage( sendMessage(
{ parts: userParts }, { parts: userParts },
{ {
@@ -352,6 +355,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: savedXml, xml: savedXml,
sessionId, sessionId,
}, },
headers: {
"x-access-code": accessCode,
},
}, },
) )
} }
@@ -402,6 +408,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
}) })
// Now send the edited message after state is guaranteed to be updated // Now send the edited message after state is guaranteed to be updated
const accessCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
sendMessage( sendMessage(
{ parts: newParts }, { parts: newParts },
{ {
@@ -409,6 +416,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: savedXml, xml: savedXml,
sessionId, sessionId,
}, },
headers: {
"x-access-code": accessCode,
},
}, },
) )
} }
@@ -497,19 +507,17 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`} className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/> />
</a> </a>
{accessCodeRequired && ( <ButtonWithTooltip
<ButtonWithTooltip tooltipContent="Settings"
tooltipContent="Settings" variant="ghost"
variant="ghost" size="icon"
size="icon" onClick={() => setShowSettingsDialog(true)}
onClick={() => setShowSettingsDialog(true)} className="hover:bg-accent"
className="hover:bg-accent" >
> <Settings
<Settings className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`} />
/> </ButtonWithTooltip>
</ButtonWithTooltip>
)}
{!isMobile && ( {!isMobile && (
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent="Hide chat panel (Ctrl+B)" tooltipContent="Hide chat panel (Ctrl+B)"
@@ -570,6 +578,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
<SettingsDialog <SettingsDialog
open={showSettingsDialog} open={showSettingsDialog}
onOpenChange={setShowSettingsDialog} onOpenChange={setShowSettingsDialog}
onCloseProtectionChange={onCloseProtectionChange}
/> />
</div> </div>
) )

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

View File

@@ -11,28 +11,77 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onCloseProtectionChange?: (enabled: boolean) => void
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { export function SettingsDialog({
open,
onOpenChange,
onCloseProtectionChange,
}: SettingsDialogProps) {
const [accessCode, setAccessCode] = useState("") const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState("")
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const storedCode = const storedCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "" localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode) setAccessCode(storedCode)
const storedCloseProtection = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY,
)
// Default to true if not set
setCloseProtection(storedCloseProtection !== "false")
setError("")
} }
}, [open]) }, [open])
const handleSave = () => { const handleSave = async () => {
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim()) setError("")
onOpenChange(false) setIsVerifying(true)
try {
// Verify access code with server
const response = await fetch("/api/verify-access-code", {
method: "POST",
headers: {
"x-access-code": accessCode.trim(),
},
})
const data = await response.json()
if (!data.valid) {
setError(data.message || "Invalid access code")
setIsVerifying(false)
return
}
// Save settings only if verification passes
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
closeProtection.toString(),
)
onCloseProtectionChange?.(closeProtection)
onOpenChange(false)
} catch {
setError("Failed to verify access code")
} finally {
setIsVerifying(false)
}
} }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -67,6 +116,26 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
<p className="text-[0.8rem] text-muted-foreground"> <p className="text-[0.8rem] text-muted-foreground">
Required if the server has enabled access control. Required if the server has enabled access control.
</p> </p>
{error && (
<p className="text-[0.8rem] text-destructive">
{error}
</p>
)}
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="close-protection">
Close Protection
</Label>
<p className="text-[0.8rem] text-muted-foreground">
Show confirmation when leaving the page.
</p>
</div>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={setCloseProtection}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
@@ -76,7 +145,9 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
> >
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave}>Save</Button> <Button onClick={handleSave} disabled={isVerifying}>
{isVerifying ? "Verifying..." : "Save"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

31
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -3,15 +3,12 @@
* 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 (~2700 tokens) - works with all models // Default system prompt (~1400 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
@@ -54,8 +51,6 @@ 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
@@ -87,11 +82,6 @@ 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:
@@ -129,11 +119,9 @@ 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 (~1800 tokens) - appended for models with 4000 token cache minimum // Extended additions (~2600 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
@@ -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 **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
@@ -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 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
### Edge Routing Rules: ### Text Styles
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines: - 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** ## Common Shape Types
- 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)
**Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides** ### Basic Shapes
- A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0) - Rectangle: rounded=0;whiteSpace=wrap;html=1;
- B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=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** ### Flowchart Shapes
- Every edge MUST have these 4 attributes set in the style - Process: rounded=1;whiteSpace=wrap;html=1;
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" - 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!** ### Container Types
- Before creating an edge, identify ALL shapes positioned between source and target - Swimlane: swimlane;whiteSpace=wrap;html=1;
- If any shape is in the direct path, you MUST use waypoints to route around it - Group Box: rounded=1;whiteSpace=wrap;html=1;container=1;collapsible=0;
- 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
**Rule 5: Plan layout strategically BEFORE generating XML** ## Container/Group Example
- 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="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"> <mxCell id="container1" value="Group Title" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry relative="1" as="geometry"/> <mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell> </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"> <mxCell id="child1" value="Child Element" style="rounded=1;" vertex="1" parent="container1">
<mxGeometry relative="1" as="geometry"/> <mxGeometry x="20" y="40" width="160" height="40" as="geometry"/>
</mxCell> </mxCell>
\`\`\` \`\`\`
### Edge with single waypoint (simple detour): ## Example: Complete Flowchart
\`\`\`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>
\`\`\`
### 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 \`\`\`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"> <root>
<mxGeometry relative="1" as="geometry"> <mxCell id="0"/>
<Array as="points"> <mxCell id="1" parent="0"/>
<mxPoint x="750" y="80"/> <mxCell id="start" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxPoint x="750" 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>
\`\`\` \`\`\`
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 && cell0.nextSibling) { if (cell0?.nextSibling) {
currentRoot.insertBefore(cell1, cell0.nextSibling) currentRoot.insertBefore(cell1, cell0.nextSibling)
} else { } else {
currentRoot.appendChild(cell1) currentRoot.appendChild(cell1)

243
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.2.0", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.2.0", "version": "0.3.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",
@@ -24,9 +24,11 @@
"@openrouter/ai-sdk-provider": "^1.2.3", "@openrouter/ai-sdk-provider": "^1.2.3",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"@xmldom/xmldom": "^0.9.8", "@xmldom/xmldom": "^0.9.8",
@@ -3400,6 +3402,85 @@
} }
} }
}, },
"node_modules/@radix-ui/react-label": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
"integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
@@ -3989,6 +4070,164 @@
} }
} }
}, },
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.2.0", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -28,9 +28,11 @@
"@openrouter/ai-sdk-provider": "^1.2.3", "@openrouter/ai-sdk-provider": "^1.2.3",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"@xmldom/xmldom": "^0.9.8", "@xmldom/xmldom": "^0.9.8",