Compare commits

..

1 Commits

Author SHA1 Message Date
Dayuan Jiang
df69fe075c Revert "fix: make draw.io built-in save button work (#293)"
This reverts commit bcc6684ecb.
2025-12-17 19:46:37 +09:00
13 changed files with 149 additions and 413 deletions

View File

@@ -1,47 +0,0 @@
name: Auto Format
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: write
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Biome
run: npm install --save-dev @biomejs/biome
- name: Run Biome format
run: npx @biomejs/biome check --write --no-errors-on-unmatched .
- name: Check for changes
id: changes
run: |
if git diff --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
- name: Commit changes
if: steps.changes.outputs.has_changes == 'true'
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "style: auto-format with Biome"
git push

View File

@@ -1,27 +1,26 @@
import type { MetadataRoute } from "next" import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: "Next AI Draw.io", name: 'Next AI Draw.io',
short_name: "AIDraw.io", short_name: 'AIDraw.io',
description: description: 'Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.',
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.", start_url: '/',
start_url: "/", display: 'standalone',
display: "standalone", background_color: '#f9fafb',
background_color: "#f9fafb", theme_color: '#171d26',
theme_color: "#171d26",
icons: [ icons: [
{ {
src: "/favicon-192x192.png", src: '/favicon-192x192.png',
sizes: "192x192", sizes: '192x192',
type: "image/png", type: 'image/png',
purpose: "any", purpose: 'any',
}, },
{ {
src: "/favicon-512x512.png", src: '/favicon-512x512.png',
sizes: "512x512", sizes: '512x512',
type: "image/png", type: 'image/png',
purpose: "any", purpose: 'any',
}, },
], ],
} }

View File

@@ -1,5 +1,5 @@
"use client" "use client"
import { useCallback, useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel" import ChatPanel from "@/components/chat-panel"
@@ -21,8 +21,6 @@ export default function Home() {
onDrawioLoad, onDrawioLoad,
resetDrawioReady, resetDrawioReady,
saveDiagramToStorage, saveDiagramToStorage,
showSaveDialog,
setShowSaveDialog,
} = useDiagram() } = useDiagram()
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true) const [isChatVisible, setIsChatVisible] = useState(true)
@@ -32,29 +30,6 @@ export default function Home() {
const [closeProtection, setCloseProtection] = useState(false) const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isSavingRef = useRef(false)
const mouseOverDrawioRef = useRef(false)
const isMobileRef = useRef(false)
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
useEffect(() => {
if (!showSaveDialog) {
const timeout = setTimeout(() => {
isSavingRef.current = false
}, 1000)
return () => clearTimeout(timeout)
}
}, [showSaveDialog])
// Handle save from draw.io's built-in save button
// Note: draw.io sends save events for various reasons (focus changes, etc.)
// We use mouse position to determine if the user is interacting with draw.io
const handleDrawioSave = useCallback(() => {
if (!mouseOverDrawioRef.current) return
if (isSavingRef.current) return
isSavingRef.current = true
setShowSaveDialog(true)
}, [setShowSaveDialog])
// Load preferences from localStorage after mount // Load preferences from localStorage after mount
useEffect(() => { useEffect(() => {
@@ -103,32 +78,16 @@ export default function Home() {
resetDrawioReady() resetDrawioReady()
} }
// Check mobile - save diagram and reset draw.io before crossing breakpoint // Check mobile
const isInitialRenderRef = useRef(true)
useEffect(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => {
const newIsMobile = window.innerWidth < 768 setIsMobile(window.innerWidth < 768)
// If crossing the breakpoint (not initial render), save diagram and reset draw.io
if (
!isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current
) {
// Save diagram before remounting (fire and forget)
saveDiagramToStorage().catch(() => {
// Ignore timeout errors during resize
})
// Reset draw.io ready state so onLoad triggers again after remount
resetDrawioReady()
}
isMobileRef.current = newIsMobile
isInitialRenderRef.current = false
setIsMobile(newIsMobile)
} }
checkMobile() checkMobile()
window.addEventListener("resize", checkMobile) window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile) return () => window.removeEventListener("resize", checkMobile)
}, [saveDiagramToStorage, resetDrawioReady]) }, [])
const toggleChatPanel = () => { const toggleChatPanel = () => {
const panel = chatPanelRef.current const panel = chatPanelRef.current
@@ -174,6 +133,7 @@ export default function Home() {
<div className="h-screen bg-background relative overflow-hidden"> <div className="h-screen bg-background relative overflow-hidden">
<ResizablePanelGroup <ResizablePanelGroup
id="main-panel-group" id="main-panel-group"
key={isMobile ? "mobile" : "desktop"}
direction={isMobile ? "vertical" : "horizontal"} direction={isMobile ? "vertical" : "horizontal"}
className="h-full" className="h-full"
> >
@@ -187,12 +147,6 @@ export default function Home() {
className={`h-full relative ${ className={`h-full relative ${
isMobile ? "p-1" : "p-2" isMobile ? "p-1" : "p-2"
}`} }`}
onMouseEnter={() => {
mouseOverDrawioRef.current = true
}}
onMouseLeave={() => {
mouseOverDrawioRef.current = false
}}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
{isLoaded ? ( {isLoaded ? (
@@ -201,7 +155,6 @@ export default function Home() {
ref={drawioRef} ref={drawioRef}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={onDrawioLoad} onLoad={onDrawioLoad}
onSave={handleDrawioSave}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,
@@ -225,7 +178,6 @@ export default function Home() {
{/* Chat Panel */} {/* Chat Panel */}
<ResizablePanel <ResizablePanel
key={isMobile ? "mobile" : "desktop"}
id="chat-panel" id="chat-panel"
ref={chatPanelRef} ref={chatPanelRef}
defaultSize={isMobile ? 50 : 33} defaultSize={isMobile ? 50 : 33}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import type React from "react" import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react" import { createContext, useContext, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio" import type { DrawIoEmbedRef } from "react-drawio"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel" import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
@@ -27,8 +27,6 @@ interface DiagramContextType {
isDrawioReady: boolean isDrawioReady: boolean
onDrawioLoad: () => void onDrawioLoad: () => void
resetDrawioReady: () => void resetDrawioReady: () => void
showSaveDialog: boolean
setShowSaveDialog: (show: boolean) => void
} }
const DiagramContext = createContext<DiagramContextType | undefined>(undefined) const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
@@ -40,15 +38,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
{ svg: string; xml: string }[] { svg: string; xml: string }[]
>([]) >([])
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false) const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null) const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null) const resolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false) const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored from localStorage
const hasDiagramRestoredRef = useRef<boolean>(false)
const onDrawioLoad = () => { const onDrawioLoad = () => {
// Only set ready state once to prevent infinite loops // Only set ready state once to prevent infinite loops
@@ -64,48 +58,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setIsDrawioReady(false) setIsDrawioReady(false)
} }
// Restore diagram XML when DrawIO becomes ready
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
useEffect(() => {
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
if (!isDrawioReady) {
hasDiagramRestoredRef.current = false
setCanSaveDiagram(false)
return
}
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
try {
const savedDiagramXml = localStorage.getItem(
STORAGE_DIAGRAM_XML_KEY,
)
if (savedDiagramXml) {
// Skip validation for trusted saved diagrams
loadDiagram(savedDiagramXml, true)
}
} catch (error) {
console.error("Failed to restore diagram from localStorage:", error)
}
// Allow saving after restore is complete
setTimeout(() => {
setCanSaveDiagram(true)
}, 500)
}, [isDrawioReady])
// Save diagram XML to localStorage whenever it changes (debounced)
useEffect(() => {
if (!canSaveDiagram) return
if (!chartXML || chartXML.length <= 300) return
const timeoutId = setTimeout(() => {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
}, 1000)
return () => clearTimeout(timeoutId)
}, [chartXML, canSaveDiagram])
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null resolver: ((data: string) => void) | null
@@ -357,8 +309,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
isDrawioReady, isDrawioReady,
onDrawioLoad, onDrawioLoad,
resetDrawioReady, resetDrawioReady,
showSaveDialog,
setShowSaveDialog,
}} }}
> >
{children} {children}

View File

@@ -140,36 +140,17 @@ OLLAMA_BASE_URL=http://localhost:11434
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys. Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
**Basic Usage (Vercel-hosted Gateway):**
```bash ```bash
AI_GATEWAY_API_KEY=your_gateway_api_key AI_GATEWAY_API_KEY=your_gateway_api_key
AI_MODEL=openai/gpt-4o AI_MODEL=openai/gpt-4o
``` ```
**Custom Gateway URL (for local development or self-hosted Gateway):**
```bash
AI_GATEWAY_API_KEY=your_custom_api_key
AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai
AI_MODEL=openai/gpt-4o
```
Model format uses `provider/model` syntax: Model format uses `provider/model` syntax:
- `openai/gpt-4o` - OpenAI GPT-4o - `openai/gpt-4o` - OpenAI GPT-4o
- `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5 - `anthropic/claude-sonnet-4-5` - Anthropic Claude Sonnet 4.5
- `google/gemini-2.0-flash` - Google Gemini 2.0 Flash - `google/gemini-2.0-flash` - Google Gemini 2.0 Flash
**Configuration notes:**
- If `AI_GATEWAY_BASE_URL` is not set, the default Vercel Gateway URL (`https://ai-gateway.vercel.sh/v1/ai`) is used
- Custom base URL is useful for:
- Local development with a custom Gateway instance
- Self-hosted AI Gateway deployments
- Enterprise proxy configurations
- When using a custom base URL, you must also provide `AI_GATEWAY_API_KEY`
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway). Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
## Auto-Detection ## Auto-Detection

View File

@@ -72,8 +72,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Get your API key from: https://vercel.com/ai-gateway # Get your API key from: https://vercel.com/ai-gateway
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" # Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
# AI_GATEWAY_API_KEY=... # AI_GATEWAY_API_KEY=...
# AI_GATEWAY_BASE_URL=https://your-custom-gateway.com/v1/ai # Optional: Custom Gateway URL (for local dev or self-hosted Gateway)
# # If not set, uses Vercel default: https://ai-gateway.vercel.sh/v1/ai
# Langfuse Observability (Optional) # Langfuse Observability (Optional)
# Enable LLM tracing and analytics - https://langfuse.com # Enable LLM tracing and analytics - https://langfuse.com

View File

@@ -2,7 +2,7 @@ import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic" import { createAnthropic } from "@ai-sdk/anthropic"
import { azure, createAzure } from "@ai-sdk/azure" import { azure, createAzure } from "@ai-sdk/azure"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway, gateway } from "@ai-sdk/gateway" import { gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google" import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createOpenAI, openai } from "@ai-sdk/openai" import { createOpenAI, openai } from "@ai-sdk/openai"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
@@ -683,20 +683,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// Vercel AI Gateway - unified access to multiple AI providers // Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" // Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
// See: https://vercel.com/ai-gateway // See: https://vercel.com/ai-gateway
const apiKey = overrides?.apiKey || process.env.AI_GATEWAY_API_KEY
const baseURL =
overrides?.baseUrl || process.env.AI_GATEWAY_BASE_URL
// Only use custom configuration if explicitly set (local dev or custom Gateway)
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
if (baseURL || overrides?.apiKey) {
const customGateway = createGateway({
apiKey,
...(baseURL && { baseURL }),
})
model = customGateway(modelId)
} else {
model = gateway(modelId) model = gateway(modelId)
}
break break
} }

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.3", "version": "0.4.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.3", "version": "0.4.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.70", "@ai-sdk/amazon-bedrock": "^3.0.70",
@@ -63,7 +63,7 @@
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "^2.3.8", "@biomejs/biome": "2.3.8",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/node": "^20", "@types/node": "^20",

View File

@@ -73,7 +73,7 @@
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "^2.3.8", "@biomejs/biome": "2.3.8",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/node": "^20", "@types/node": "^20",

View File

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