Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
3e053bc904 fix(deps): update dependency zod to v4 2026-01-01 05:46:19 +00:00
11 changed files with 413 additions and 3607 deletions

View File

@@ -38,7 +38,7 @@ jobs:
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm install run: npm ci
- name: Build and publish Electron app - name: Build and publish Electron app
run: npm run dist:${{ matrix.platform }} run: npm run dist:${{ matrix.platform }}

View File

@@ -35,7 +35,6 @@ export default function Home() {
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min") const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [darkMode, setDarkMode] = useState(false) const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [closeProtection, setCloseProtection] = useState(false) const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
@@ -105,18 +104,12 @@ export default function Home() {
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
const handleDrawioLoad = useCallback(() => {
setIsDrawioReady(true)
onDrawioLoad()
}, [onDrawioLoad])
const handleDarkModeChange = async () => { const handleDarkModeChange = async () => {
await saveDiagramToStorage() await saveDiagramToStorage()
const newValue = !darkMode const newValue = !darkMode
setDarkMode(newValue) setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue)) localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue) document.documentElement.classList.toggle("dark", newValue)
setIsDrawioReady(false)
resetDrawioReady() resetDrawioReady()
} }
@@ -125,7 +118,6 @@ export default function Home() {
const newUi = drawioUi === "min" ? "sketch" : "min" const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi) localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi) setDrawioUi(newUi)
setIsDrawioReady(false)
resetDrawioReady() resetDrawioReady()
} }
@@ -139,7 +131,6 @@ export default function Home() {
newIsMobile !== isMobileRef.current newIsMobile !== isMobileRef.current
) { ) {
saveDiagramToStorage().catch(() => {}) saveDiagramToStorage().catch(() => {})
setIsDrawioReady(false)
resetDrawioReady() resetDrawioReady()
} }
isMobileRef.current = newIsMobile isMobileRef.current = newIsMobile
@@ -215,35 +206,28 @@ export default function Home() {
mouseOverDrawioRef.current = false mouseOverDrawioRef.current = false
}} }}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
{isLoaded && ( {isLoaded ? (
<div <DrawIoEmbed
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`} key={`${drawioUi}-${darkMode}-${currentLang}`}
> ref={drawioRef}
<DrawIoEmbed onExport={handleDiagramExport}
key={`${drawioUi}-${darkMode}-${currentLang}`} onLoad={onDrawioLoad}
ref={drawioRef} onSave={handleDrawioSave}
onExport={handleDiagramExport} baseUrl={drawioBaseUrl}
onLoad={handleDrawioLoad} urlParameters={{
onSave={handleDrawioSave} ui: drawioUi,
baseUrl={drawioBaseUrl} spin: true,
urlParameters={{ libraries: false,
ui: drawioUi, saveAndExit: false,
spin: false, noExitBtn: true,
libraries: false, dark: darkMode,
saveAndExit: false, lang: currentLang,
noExitBtn: true, }}
dark: darkMode, />
lang: currentLang, ) : (
}} <div className="h-full w-full flex items-center justify-center bg-background">
/> <div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
)}
{(!isLoaded || !isDrawioReady) && (
<div className="h-full w-full bg-background flex items-center justify-center">
<span className="text-muted-foreground">
Draw.io panel is loading...
</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -193,7 +193,6 @@ interface ChatMessageDisplayProps {
onRegenerate?: (messageIndex: number) => void onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void onEditMessage?: (messageIndex: number, newText: string) => void
status?: "streaming" | "submitted" | "idle" | "error" | "ready" status?: "streaming" | "submitted" | "idle" | "error" | "ready"
isRestored?: boolean
} }
export function ChatMessageDisplay({ export function ChatMessageDisplay({
@@ -206,7 +205,6 @@ export function ChatMessageDisplay({
onRegenerate, onRegenerate,
onEditMessage, onEditMessage,
status = "idle", status = "idle",
isRestored = false,
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const dict = useDictionary() const dict = useDictionary()
const { chartXML, loadDiagram: onDisplayChart } = useDiagram() const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
@@ -252,15 +250,6 @@ export function ChatMessageDisplay({
const [expandedPdfSections, setExpandedPdfSections] = useState< const [expandedPdfSections, setExpandedPdfSections] = useState<
Record<string, boolean> Record<string, boolean>
>({}) >({})
// Track message IDs that were restored from localStorage (skip animation for these)
const restoredMessageIdsRef = useRef<Set<string> | null>(null)
// Capture restored message IDs once when isRestored becomes true
useEffect(() => {
if (isRestored && restoredMessageIdsRef.current === null) {
restoredMessageIdsRef.current = new Set(messages.map((m) => m.id))
}
}, [isRestored, messages])
const setCopyState = ( const setCopyState = (
messageId: string, messageId: string,
@@ -680,8 +669,7 @@ export function ChatMessageDisplay({
const renderToolPart = (part: ToolPartLike) => { const renderToolPart = (part: ToolPartLike) => {
const callId = part.toolCallId const callId = part.toolCallId
const { state, input, output } = part const { state, input, output } = part
// Default to collapsed if tool is complete, expanded if still streaming const isExpanded = expandedTools[callId] ?? true
const isExpanded = expandedTools[callId] ?? state !== "output-available"
const toolName = part.type?.replace("tool-", "") const toolName = part.type?.replace("tool-", "")
const isCopied = copiedToolCallId === callId const isCopied = copiedToolCallId === callId
@@ -871,9 +859,9 @@ export function ChatMessageDisplay({
return ( return (
<ScrollArea className="h-full w-full scrollbar-thin"> <ScrollArea className="h-full w-full scrollbar-thin">
{messages.length === 0 && isRestored ? ( {messages.length === 0 ? (
<ExamplePanel setInput={setInput} setFiles={setFiles} /> <ExamplePanel setInput={setInput} setFiles={setFiles} />
) : messages.length === 0 ? null : ( ) : (
<div className="py-4 px-4 space-y-4"> <div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => { {messages.map((message, messageIndex) => {
const userMessageText = const userMessageText =
@@ -893,23 +881,13 @@ export function ChatMessageDisplay({
.slice(messageIndex + 1) .slice(messageIndex + 1)
.every((m) => m.role !== "user")) .every((m) => m.role !== "user"))
const isEditing = editingMessageId === message.id const isEditing = editingMessageId === message.id
// Skip animation for restored messages
// If isRestored but ref not set yet, we're in first render after restoration - treat all as restored
const isRestoredMessage =
isRestored &&
(restoredMessageIdsRef.current === null ||
restoredMessageIdsRef.current.has(message.id))
return ( return (
<div <div
key={message.id} key={message.id}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`} className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={ style={{
isRestoredMessage animationDelay: `${messageIndex * 50}ms`,
? undefined }}
: {
animationDelay: `${messageIndex * 50}ms`,
}
}
> >
{message.role === "user" && {message.role === "user" &&
userMessageText && userMessageText &&
@@ -1006,9 +984,6 @@ export function ChatMessageDisplay({
isStreaming={ isStreaming={
isStreamingReasoning isStreamingReasoning
} }
defaultOpen={
!isRestoredMessage
}
> >
<ReasoningTrigger /> <ReasoningTrigger />
<ReasoningContent> <ReasoningContent>

View File

@@ -10,13 +10,7 @@ import {
} from "lucide-react" } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import type React from "react" import type React from "react"
import { import { useCallback, useEffect, useRef, useState } from "react"
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import { flushSync } from "react-dom" import { flushSync } from "react-dom"
import { Toaster, toast } from "sonner" import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
@@ -34,7 +28,7 @@ import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager" import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML } from "@/lib/utils" import { formatXML } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator" import { DevXmlSimulator } from "./dev-xml-simulator"
@@ -207,18 +201,6 @@ export default function ChatPanel({
// Flag to track if we've restored from localStorage // Flag to track if we've restored from localStorage
const hasRestoredRef = useRef(false) const hasRestoredRef = useRef(false)
const [isRestored, setIsRestored] = useState(false)
// Track previous isVisible to only animate when toggling (not on page load)
const prevIsVisibleRef = useRef(isVisible)
const [shouldAnimatePanel, setShouldAnimatePanel] = useState(false)
useEffect(() => {
// Only animate when visibility changes from false to true (not on initial load)
if (!prevIsVisibleRef.current && isVisible) {
setShouldAnimatePanel(true)
}
prevIsVisibleRef.current = isVisible
}, [isVisible])
// Ref to track latest chartXML for use in callbacks (avoids stale closure) // Ref to track latest chartXML for use in callbacks (avoids stale closure)
const chartXMLRef = useRef(chartXML) const chartXMLRef = useRef(chartXML)
@@ -447,8 +429,7 @@ export default function ChatPanel({
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
// Restore messages and XML snapshots from localStorage on mount // Restore messages and XML snapshots from localStorage on mount
// useLayoutEffect runs synchronously before browser paint, so messages appear immediately useEffect(() => {
useLayoutEffect(() => {
if (hasRestoredRef.current) return if (hasRestoredRef.current) return
hasRestoredRef.current = true hasRestoredRef.current = true
@@ -476,10 +457,8 @@ export default function ChatPanel({
localStorage.removeItem(STORAGE_MESSAGES_KEY) localStorage.removeItem(STORAGE_MESSAGES_KEY)
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY) localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
toast.error(dict.errors.sessionCorrupted) toast.error(dict.errors.sessionCorrupted)
} finally {
setIsRestored(true)
} }
}, [setMessages, dict.errors.sessionCorrupted]) }, [setMessages])
// 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(() => {
@@ -936,16 +915,12 @@ export default function ChatPanel({
// Full view // Full view
return ( return (
<div <div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
className={cn(
"h-full flex flex-col bg-card shadow-soft rounded-xl border border-border/30 relative",
shouldAnimatePanel && "animate-slide-in-right",
)}
>
<Toaster <Toaster
position="bottom-left" position="bottom-center"
richColors richColors
expand expand
style={{ position: "absolute" }}
toastOptions={{ toastOptions={{
style: { style: {
maxWidth: "480px", maxWidth: "480px",
@@ -1031,7 +1006,6 @@ export default function ChatPanel({
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
status={status} status={status}
onEditMessage={handleEditMessage} onEditMessage={handleEditMessage}
isRestored={isRestored}
/> />
</main> </main>

View File

@@ -11,7 +11,7 @@ services:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio # - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"] ports: ["3000:3000"]
env_file: .env env_file: .env
# environment: environment:
# # For subdirectory deployment, uncomment and set your path: # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio # NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio] depends_on: [drawio]

3759
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.8", "version": "0.4.7",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -71,7 +71,7 @@
"motion": "^12.23.25", "motion": "^12.23.25",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"next": "^16.0.7", "next": "^16.0.7",
"ollama-ai-provider-v2": "^2.0.0", "ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0", "pako": "^2.1.0",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
"react": "^19.1.2", "react": "^19.1.2",

View File

@@ -64,24 +64,6 @@ Add to Cursor MCP config (`~/.cursor/mcp.json`):
} }
``` ```
### Cline (VS Code Extension)
1. Click the **MCP Servers** icon in Cline's top menu bar
2. Select the **Configure** tab
3. Click **Configure MCP Servers** to edit `cline_mcp_settings.json`
4. Add the drawio server:
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Claude Code CLI ### Claude Code CLI
```bash ```bash
@@ -108,7 +90,7 @@ Use the standard MCP configuration with:
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc. - **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions - **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files - **Export**: Save diagrams as `.drawio` files
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`) - **Self-contained**: Embedded server, works offline (except draw.io UI which loads from embed.diagrams.net)
## Available Tools ## Available Tools
@@ -148,33 +130,6 @@ Use the standard MCP configuration with:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server | | `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
### Private Deployment (Self-hosted draw.io)
For security-sensitive environments that require private deployment of draw.io:
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"],
"env": {
"DRAWIO_BASE_URL": "https://drawio.your-company.com"
}
}
}
}
```
You can deploy your own draw.io instance using the official Docker image:
```bash
docker run -d -p 8080:8080 jgraph/drawio
```
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL).
## Troubleshooting ## Troubleshooting

View File

@@ -1,18 +1,18 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.6", "version": "0.1.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.6", "version": "0.1.11",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0", "linkedom": "^0.18.0",
"open": "^11.0.0", "open": "^11.0.0",
"zod": "^3.24.0" "zod": "^4.0.0"
}, },
"bin": { "bin": {
"next-ai-drawio-mcp": "dist/index.js" "next-ai-drawio-mcp": "dist/index.js"
@@ -2051,9 +2051,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.4.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {

View File

@@ -39,7 +39,7 @@
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0", "linkedom": "^0.18.0",
"open": "^11.0.0", "open": "^11.0.0",
"zod": "^3.24.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",

View File

@@ -13,45 +13,6 @@ import {
} from "./history.js" } from "./history.js"
import { log } from "./logger.js" import { log } from "./logger.js"
// Configurable draw.io embed URL for private deployments
const DRAWIO_BASE_URL =
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
// Extract origin (scheme + host + port) from URL for postMessage security check
function getOrigin(url: string): string {
try {
const parsed = new URL(url)
return `${parsed.protocol}//${parsed.host}`
} catch {
return url // Fallback if parsing fails
}
}
const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)
// Minimal blank diagram used to bootstrap new sessions.
// This avoids the draw.io embed spinner (spin=1) getting stuck when no `load(xml)` is ever sent.
const DEFAULT_DIAGRAM_XML = `<mxfile host="app.diagrams.net"><diagram id="blank" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Normalize URL for iframe src - ensure no double slashes
function normalizeUrl(url: string): string {
// Remove trailing slash to avoid double slashes
return url.replace(/\/$/, "")
}
function isLikelyMcpSessionId(sessionId: string): boolean {
// Keep this cheap and conservative to avoid creating state for arbitrary IDs.
return sessionId.startsWith("mcp-") && sessionId.length <= 128
}
function ensureSessionStateInitialized(sessionId: string): void {
if (!sessionId) return
if (!isLikelyMcpSessionId(sessionId)) return
if (stateStore.has(sessionId)) return
setState(sessionId, DEFAULT_DIAGRAM_XML)
}
interface SessionState { interface SessionState {
xml: string xml: string
version: number version: number
@@ -194,11 +155,8 @@ function handleRequest(
} }
if (url.pathname === "/" || url.pathname === "/index.html") { if (url.pathname === "/" || url.pathname === "/index.html") {
const sessionId = url.searchParams.get("mcp") || ""
ensureSessionStateInitialized(sessionId)
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(getHtmlPage(sessionId)) res.end(getHtmlPage(url.searchParams.get("mcp") || ""))
} else if (url.pathname === "/api/state") { } else if (url.pathname === "/api/state") {
handleStateApi(req, res, url) handleStateApi(req, res, url)
} else if (url.pathname === "/api/history") { } else if (url.pathname === "/api/history") {
@@ -225,7 +183,6 @@ function handleStateApi(
res.end(JSON.stringify({ error: "sessionId required" })) res.end(JSON.stringify({ error: "sessionId required" }))
return return
} }
ensureSessionStateInitialized(sessionId)
const state = stateStore.get(sessionId) const state = stateStore.get(sessionId)
res.writeHead(200, { "Content-Type": "application/json" }) res.writeHead(200, { "Content-Type": "application/json" })
res.end( res.end(
@@ -446,7 +403,7 @@ function getHtmlPage(sessionId: string): string {
</div> </div>
<div id="status" class="status disconnected">Connecting...</div> <div id="status" class="status disconnected">Connecting...</div>
</div> </div>
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1"></iframe> <iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
</div> </div>
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}> <button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -476,7 +433,7 @@ function getHtmlPage(sessionId: string): string {
let pendingAiSvg = false; let pendingAiSvg = false;
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
if (e.origin !== '${DRAWIO_ORIGIN}') return; if (e.origin !== 'https://embed.diagrams.net') return;
try { try {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.event === 'init') { if (msg.event === 'init') {