Compare commits

...

8 Commits

Author SHA1 Message Date
Dayuan Jiang
f240c494ac fix: use npm install instead of npm ci in electron workflow (#487) 2026-01-01 17:42:39 +09:00
Dayuan Jiang
a22d7025a3 fix: sync package-lock.json (#486) 2026-01-01 17:32:25 +09:00
Dayuan Jiang
2159db5586 chore: bump version to 0.4.8 (#485) 2026-01-01 17:23:42 +09:00
Dayuan Jiang
ada06260db fix: faster message restore and skip panel animation on refresh (#483)
* fix: faster message restore and skip panel animation on refresh

- Use useLayoutEffect for localStorage restore (runs before paint)
- Track visibility changes to only animate panel when toggling, not on page load
- Use cn() utility for cleaner conditional className

* fix: reset animation state after completion for re-animation support

* revert: remove unnecessary animation reset timer
2026-01-01 16:25:39 +09:00
Dayuan Jiang
02527526ba fix: prevent flash of example panel and animations on page refresh (#482)
- Add isRestored state to track when localStorage restoration completes
- Show example panel only after confirming no saved messages exist
- Skip message animations for restored messages
- Default tool calls and reasoning blocks to collapsed for restored messages
2026-01-01 15:42:48 +09:00
Dayuan Jiang
77a2f6f6fa fix: hide Draw.io loading flash with placeholder (#481)
* fix: hide Draw.io loading flash with placeholder

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-01 15:20:00 +09:00
LiuJing
493ee168b1 feat(mcp-server): add DRAWIO_BASE_URL env for private deployments (#467)
* feat(mcp-server): add DRAWIO_BASE_URL env for private deployments

* Fix postMessage origin check and URL normalization

- Add getOrigin() function to extract scheme+host+port from DRAWIO_BASE_URL
- Use DRAWIO_ORIGIN for postMessage security check instead of full URL
- Add normalizeUrl() to remove trailing slash and avoid double slashes
- This fixes issues when users configure DRAWIO_BASE_URL with trailing slash or path
2026-01-01 14:47:39 +09:00
Dayuan Jiang
037f32973a fix: resolve biome lint errors blocking CI (#480)
- Update biome schema version from 2.3.8 to 2.3.10
- Add radix parameter to parseInt in mcp-server
- Remove unnecessary React fragment in model-config-dialog
- Fix unused variable errors (err -> _err)
- Auto-format code with biome
2026-01-01 14:45:46 +09:00
14 changed files with 957 additions and 875 deletions

View File

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

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",

View File

@@ -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,
@@ -283,7 +294,7 @@ export function ChatMessageDisplay({
try {
await navigator.clipboard.writeText(text)
setCopyState(messageId, isToolCall, true)
} catch (err) {
} catch (_err) {
// Fallback for non-secure contexts (HTTP) or permission denied
const textarea = document.createElement("textarea")
textarea.value = text
@@ -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>

View File

@@ -10,7 +10,13 @@ import {
} from "lucide-react"
import Image from "next/image"
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 { Toaster, toast } from "sonner"
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 { type FileData, useFileProcessor } from "@/lib/use-file-processor"
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 { DevXmlSimulator } from "./dev-xml-simulator"
@@ -201,6 +207,18 @@ export default function ChatPanel({
// Flag to track if we've restored from localStorage
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)
const chartXMLRef = useRef(chartXML)
@@ -429,7 +447,8 @@ export default function ChatPanel({
const messagesEndRef = useRef<HTMLDivElement>(null)
// 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
hasRestoredRef.current = true
@@ -457,8 +476,10 @@ export default function ChatPanel({
localStorage.removeItem(STORAGE_MESSAGES_KEY)
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
toast.error(dict.errors.sessionCorrupted)
} finally {
setIsRestored(true)
}
}, [setMessages])
}, [setMessages, dict.errors.sessionCorrupted])
// Save messages to localStorage whenever they change (debounced to prevent blocking during streaming)
useEffect(() => {
@@ -915,7 +936,12 @@ export default function ChatPanel({
// Full view
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
position="bottom-center"
richColors
@@ -1006,6 +1032,7 @@ export default function ChatPanel({
onRegenerate={handleRegenerate}
status={status}
onEditMessage={handleEditMessage}
isRestored={isRestored}
/>
</main>

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ import {
flattenModels,
type ModelConfig,
type MultiModelConfig,
PROVIDER_INFO,
type ProviderConfig,
type ProviderName,
} from "@/lib/types/model-config"

View File

@@ -786,7 +786,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`data: ${JSON.stringify(data)}\n\n`,
),
)
} catch (e) {
} catch (_e) {
// If parsing fails, forward the original message to avoid breaking the stream.
controller.enqueue(
new TextEncoder().encode(

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "next-ai-draw-io",
"version": "0.4.7",
"version": "0.4.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "next-ai-draw-io",
"version": "0.4.7",
"version": "0.4.8",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "next-ai-draw-io",
"version": "0.4.7",
"version": "0.4.8",
"license": "Apache-2.0",
"private": true,
"main": "dist-electron/main/index.js",

View File

@@ -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

View File

@@ -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') {

View File

@@ -48,7 +48,7 @@ import { validateAndFixXml } from "./xml-validation.js"
// Server configuration
const config = {
port: parseInt(process.env.PORT || "6002"),
port: parseInt(process.env.PORT || "6002", 10),
}
// Session state (single session for simplicity)

View File

@@ -253,7 +253,7 @@ async function main() {
},
)
console.log("👀 Watching for preset configuration changes...")
} catch (err) {
} catch (_err) {
// File might not exist yet, that's ok
setTimeout(setupConfigWatcher, 5000)
}