mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 14:52:28 +08:00
Compare commits
12 Commits
fix/flash-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc22b7c315 | ||
|
|
8c1cc19d94 | ||
|
|
03c3ae6d5b | ||
|
|
ddde0654a6 | ||
|
|
bc5709267c | ||
|
|
6fbc7b340f | ||
|
|
3c8f420c3c | ||
|
|
f240c494ac | ||
|
|
a22d7025a3 | ||
|
|
2159db5586 | ||
|
|
ada06260db | ||
|
|
02527526ba |
2
.github/workflows/electron-release.yml
vendored
2
.github/workflows/electron-release.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
|||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm install
|
||||||
|
|
||||||
- name: Build and publish Electron app
|
- name: Build and publish Electron app
|
||||||
run: npm run dist:${{ matrix.platform }}
|
run: npm run dist:${{ matrix.platform }}
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ 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 { useCallback, useEffect, useRef, useState } from "react"
|
import {
|
||||||
|
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"
|
||||||
@@ -28,7 +34,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 { formatXML } from "@/lib/utils"
|
import { cn, 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"
|
||||||
|
|
||||||
@@ -203,6 +209,17 @@ export default function ChatPanel({
|
|||||||
const hasRestoredRef = useRef(false)
|
const hasRestoredRef = useRef(false)
|
||||||
const [isRestored, setIsRestored] = useState(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)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -430,7 +447,8 @@ 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
|
||||||
useEffect(() => {
|
// useLayoutEffect runs synchronously before browser paint, so messages appear immediately
|
||||||
|
useLayoutEffect(() => {
|
||||||
if (hasRestoredRef.current) return
|
if (hasRestoredRef.current) return
|
||||||
hasRestoredRef.current = true
|
hasRestoredRef.current = true
|
||||||
|
|
||||||
@@ -461,7 +479,7 @@ export default function ChatPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsRestored(true)
|
setIsRestored(true)
|
||||||
}
|
}
|
||||||
}, [setMessages])
|
}, [setMessages, dict.errors.sessionCorrupted])
|
||||||
|
|
||||||
// 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(() => {
|
||||||
@@ -918,12 +936,16 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// Full view
|
// Full view
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
|
<div
|
||||||
|
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-center"
|
position="bottom-left"
|
||||||
richColors
|
richColors
|
||||||
expand
|
expand
|
||||||
style={{ position: "absolute" }}
|
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
style: {
|
style: {
|
||||||
maxWidth: "480px",
|
maxWidth: "480px",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
3631
package-lock.json
generated
3631
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.7",
|
"version": "0.4.8",
|
||||||
"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": "^1.5.4",
|
"ollama-ai-provider-v2": "^2.0.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ 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
|
||||||
|
|||||||
@@ -29,12 +29,29 @@ function getOrigin(url: string): string {
|
|||||||
|
|
||||||
const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)
|
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
|
// Normalize URL for iframe src - ensure no double slashes
|
||||||
function normalizeUrl(url: string): string {
|
function normalizeUrl(url: string): string {
|
||||||
// Remove trailing slash to avoid double slashes
|
// Remove trailing slash to avoid double slashes
|
||||||
return url.replace(/\/$/, "")
|
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
|
||||||
@@ -177,8 +194,11 @@ 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(url.searchParams.get("mcp") || ""))
|
res.end(getHtmlPage(sessionId))
|
||||||
} 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") {
|
||||||
@@ -205,6 +225,7 @@ 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(
|
||||||
|
|||||||
Reference in New Issue
Block a user