mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
Compare commits
3 Commits
3e053bc904
...
fix/flash-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc7a74084 | ||
|
|
77a2f6f6fa | ||
|
|
493ee168b1 |
@@ -35,6 +35,7 @@ export default function Home() {
|
||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const [closeProtection, setCloseProtection] = useState(false)
|
||||
|
||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
@@ -104,12 +105,18 @@ export default function Home() {
|
||||
setIsLoaded(true)
|
||||
}, [pathname, router])
|
||||
|
||||
const handleDrawioLoad = useCallback(() => {
|
||||
setIsDrawioReady(true)
|
||||
onDrawioLoad()
|
||||
}, [onDrawioLoad])
|
||||
|
||||
const handleDarkModeChange = async () => {
|
||||
await saveDiagramToStorage()
|
||||
const newValue = !darkMode
|
||||
setDarkMode(newValue)
|
||||
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
||||
document.documentElement.classList.toggle("dark", newValue)
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
@@ -118,6 +125,7 @@ export default function Home() {
|
||||
const newUi = drawioUi === "min" ? "sketch" : "min"
|
||||
localStorage.setItem("drawio-theme", newUi)
|
||||
setDrawioUi(newUi)
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
@@ -131,6 +139,7 @@ export default function Home() {
|
||||
newIsMobile !== isMobileRef.current
|
||||
) {
|
||||
saveDiagramToStorage().catch(() => {})
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
isMobileRef.current = newIsMobile
|
||||
@@ -206,28 +215,35 @@ export default function Home() {
|
||||
mouseOverDrawioRef.current = false
|
||||
}}
|
||||
>
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
|
||||
{isLoaded ? (
|
||||
<DrawIoEmbed
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}`}
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={onDrawioLoad}
|
||||
onSave={handleDrawioSave}
|
||||
baseUrl={drawioBaseUrl}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: true,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
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 className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||
{isLoaded && (
|
||||
<div
|
||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||
>
|
||||
<DrawIoEmbed
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}`}
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={handleDrawioLoad}
|
||||
onSave={handleDrawioSave}
|
||||
baseUrl={drawioBaseUrl}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: false,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noExitBtn: true,
|
||||
dark: darkMode,
|
||||
lang: currentLang,
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -193,6 +193,7 @@ interface ChatMessageDisplayProps {
|
||||
onRegenerate?: (messageIndex: number) => void
|
||||
onEditMessage?: (messageIndex: number, newText: string) => void
|
||||
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
||||
isRestored?: boolean
|
||||
}
|
||||
|
||||
export function ChatMessageDisplay({
|
||||
@@ -205,6 +206,7 @@ export function ChatMessageDisplay({
|
||||
onRegenerate,
|
||||
onEditMessage,
|
||||
status = "idle",
|
||||
isRestored = false,
|
||||
}: ChatMessageDisplayProps) {
|
||||
const dict = useDictionary()
|
||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||
@@ -250,6 +252,15 @@ export function ChatMessageDisplay({
|
||||
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
||||
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 = (
|
||||
messageId: string,
|
||||
@@ -669,7 +680,8 @@ export function ChatMessageDisplay({
|
||||
const renderToolPart = (part: ToolPartLike) => {
|
||||
const callId = part.toolCallId
|
||||
const { state, input, output } = part
|
||||
const isExpanded = expandedTools[callId] ?? true
|
||||
// Default to collapsed if tool is complete, expanded if still streaming
|
||||
const isExpanded = expandedTools[callId] ?? state !== "output-available"
|
||||
const toolName = part.type?.replace("tool-", "")
|
||||
const isCopied = copiedToolCallId === callId
|
||||
|
||||
@@ -859,9 +871,9 @@ export function ChatMessageDisplay({
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||
{messages.length === 0 ? (
|
||||
{messages.length === 0 && isRestored ? (
|
||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||
) : (
|
||||
) : messages.length === 0 ? null : (
|
||||
<div className="py-4 px-4 space-y-4">
|
||||
{messages.map((message, messageIndex) => {
|
||||
const userMessageText =
|
||||
@@ -881,13 +893,23 @@ export function ChatMessageDisplay({
|
||||
.slice(messageIndex + 1)
|
||||
.every((m) => m.role !== "user"))
|
||||
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 (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
||||
style={{
|
||||
animationDelay: `${messageIndex * 50}ms`,
|
||||
}}
|
||||
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`}
|
||||
style={
|
||||
isRestoredMessage
|
||||
? undefined
|
||||
: {
|
||||
animationDelay: `${messageIndex * 50}ms`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{message.role === "user" &&
|
||||
userMessageText &&
|
||||
@@ -984,6 +1006,9 @@ export function ChatMessageDisplay({
|
||||
isStreaming={
|
||||
isStreamingReasoning
|
||||
}
|
||||
defaultOpen={
|
||||
!isRestoredMessage
|
||||
}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
|
||||
@@ -201,6 +201,7 @@ export default function ChatPanel({
|
||||
|
||||
// Flag to track if we've restored from localStorage
|
||||
const hasRestoredRef = useRef(false)
|
||||
const [isRestored, setIsRestored] = useState(false)
|
||||
|
||||
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
|
||||
const chartXMLRef = useRef(chartXML)
|
||||
@@ -457,6 +458,8 @@ export default function ChatPanel({
|
||||
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
||||
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
||||
toast.error(dict.errors.sessionCorrupted)
|
||||
} finally {
|
||||
setIsRestored(true)
|
||||
}
|
||||
}, [setMessages])
|
||||
|
||||
@@ -1006,6 +1009,7 @@ export default function ChatPanel({
|
||||
onRegenerate={handleRegenerate}
|
||||
status={status}
|
||||
onEditMessage={handleEditMessage}
|
||||
isRestored={isRestored}
|
||||
/>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ Use the standard MCP configuration with:
|
||||
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
||||
- **Edit Support**: Modify existing diagrams with natural language instructions
|
||||
- **Export**: Save diagrams as `.drawio` files
|
||||
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from embed.diagrams.net)
|
||||
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
|
||||
|
||||
## Available Tools
|
||||
|
||||
@@ -130,6 +130,33 @@ Use the standard MCP configuration with:
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `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
|
||||
|
||||
|
||||
@@ -13,6 +13,28 @@ import {
|
||||
} from "./history.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)
|
||||
|
||||
// Normalize URL for iframe src - ensure no double slashes
|
||||
function normalizeUrl(url: string): string {
|
||||
// Remove trailing slash to avoid double slashes
|
||||
return url.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
xml: string
|
||||
version: number
|
||||
@@ -403,7 +425,7 @@ function getHtmlPage(sessionId: string): string {
|
||||
</div>
|
||||
<div id="status" class="status disconnected">Connecting...</div>
|
||||
</div>
|
||||
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
||||
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
||||
</div>
|
||||
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -433,7 +455,7 @@ function getHtmlPage(sessionId: string): string {
|
||||
let pendingAiSvg = false;
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.origin !== 'https://embed.diagrams.net') return;
|
||||
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.event === 'init') {
|
||||
|
||||
Reference in New Issue
Block a user