Compare commits

...

16 Commits

Author SHA1 Message Date
dayuan.jiang
f19dc919e1 fix: prevent autosave from overwriting pending IDB restore 2026-01-27 13:43:25 +09:00
dayuan.jiang
8d92474f73 fix: recover IDB on closing and restore diagram 2026-01-27 12:07:35 +09:00
dayuan.jiang
cc6f09615d fix: restore diagram when chartXML changes after iframe ready
The previous logic only restored on iframe ready, but chartXML might
be set AFTER the iframe is ready (session loaded after iframe).

Now we track the last restored XML and load whenever chartXML changes
to a new real diagram while iframe is ready.
2026-01-27 10:10:43 +09:00
dayuan.jiang
be20c09c89 fix: wait for canPersist check before rendering DrawIoEmbed
Fixes diagram not restoring after refresh. The issue was:
1. DrawIoEmbed rendered with canPersist=false
2. Session restore loaded diagram into iframe
3. canPersist changed to true, causing iframe remount
4. New iframe loaded empty, losing the diagram

Now we wait for isIndexedDBUsable() to complete before rendering,
avoiding the remount entirely.
2026-01-27 09:56:33 +09:00
dayuan.jiang
76487fb7ea fix: include canPersist in DrawIoEmbed key for proper remount
Address review comments: canPersist is set async after mount, so DrawIoEmbed
needs to remount when it resolves to apply correct configuration and URL params.
2026-01-27 09:38:35 +09:00
dayuan.jiang
c5de1a16ad fix(electron): prevent beforeunload prompt by using autosave
- Enable draw.io autosave and handle autosave events to update chartXML
- Clear modified state after autosave to avoid beforeunload prompts
- Disable confirmExit in draw.io configuration
- Set modified=false and keepmodified=false URL parameters
- Fix session save condition to also save when only diagram exists
- Fix: Do not close shared IndexedDB connection in isIndexedDBUsable()
2026-01-27 09:23:03 +09:00
Dayuan Jiang
cb0c0fbcda Revert "fix(electron): prevent beforeunload prompt by using autosave (#642)" (#646)
This reverts commit e7c29fb410.

The PR introduced an IndexedDB error: 'Failed to execute transaction on IDBDatabase: The database connection is closing.'
2026-01-27 09:20:42 +09:00
Dayuan Jiang
e7c29fb410 fix(electron): prevent beforeunload prompt by using autosave (#642)
* fix(electron): prevent beforeunload prompt by using autosave

- Enable draw.io autosave and handle autosave events to update chartXML
- Clear modified state after autosave to avoid beforeunload prompts
- Disable confirmExit in draw.io configuration
- Set modified=false and keepmodified=false URL parameters
- Fix session save condition to also save when only diagram exists

* fix: persist diagram-only saves and ref typing

* fix: harden persistence checks and export timeout
2026-01-26 22:04:41 +09:00
Subhajeetch
f0dd199cd1 feat(prompt): add language-aware response rules with english fallback (#641)
* feat(prompt): add language-aware response rules with english fallback

Added language handling rules for user interactions.

* refactor(prompt): simplify language matching instruction
2026-01-26 15:32:07 +09:00
Dayuan Jiang
7656b64018 chore: switch to release signing policy for Windows builds (#639) 2026-01-24 20:47:44 +09:00
Dayuan Jiang
c3f88e54fe chore: bump version to 0.4.12 (#638) 2026-01-24 19:25:47 +09:00
Dayuan Jiang
a55ef7adf9 feat(electron): bundle draw.io for offline support (#629)
* feat(electron): bundle draw.io for offline support

- Download draw.io static files during CI build (v29.3.5)
- Detect Electron and use local draw.io files instead of CDN
- Add offline=1 parameter to disable external service calls
- Skip /drawio path from i18n middleware redirect
- Add public/drawio/ to .gitignore (downloaded during build)

This allows the Electron app to work completely offline.

* chore: bump version to 0.4.12-beta.3 for offline test

* fix(electron): ad-hoc sign macOS app for bundled draw.io compatibility

* chore: bump version to 0.4.12-beta.4 for ad-hoc sign test

* fix(electron): disable electron-builder signing to use custom ad-hoc signing

* chore: bump version to 0.4.12-beta.5
2026-01-24 12:47:51 +09:00
Dayuan Jiang
984eaae04d feat(mcp): update UI with project logo and modern dialog styles (#636)
* feat(mcp): update UI with project logo and modern dialog styles

- Replace Next.js logo with project favicon-white.svg
- Modernize modal dialogs with backdrop blur, animations, and DM Sans font
- Update button styles to match header design
- Change title to "Next AI Draw.io"
- Rename "Save" to "Download" for clarity
- Bump version to 0.1.15

* chore: remove dead code (unused download helpers and pendingManualSave)
2026-01-24 12:43:39 +09:00
Dayuan Jiang
0ed06360aa fix(mcp): auto-redirect to active session when no sessionId provided (#634)
Fixes #633 - browser showing infinite loading spinner when accessing
the MCP server URL without the session parameter.

When users access http://localhost:6002 directly (without ?mcp=xxx),
the page now auto-redirects to the most recent active session instead
of showing "No session" with an infinite loading spinner.
2026-01-24 11:03:58 +09:00
Biki Kalita
b0313eb2dc feat: implement collapsible view for extracted URL content in chat (#616)
* feat: implement collapsible view for extracted URL content in chat

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor: simplify iconColor ternary by removing redundant url condition

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-24 10:44:11 +09:00
Dayuan Jiang
dc37ce7fb1 fix: upgrade wrangler to ^4.60.0 to address CVE-2026-0933 (#628)
Upgrade wrangler from 4.58.0 to ^4.60.0 to fix the command injection
vulnerability (CWE-78) in the --commit-hash parameter of wrangler pages
deploy command.

Fixes #627
2026-01-23 08:38:07 +09:00
15 changed files with 687 additions and 755 deletions

View File

@@ -34,6 +34,15 @@ jobs:
node-version: 24
cache: "npm"
- name: Download draw.io static files for offline use
run: |
rm -rf public/drawio
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies
run: npm install
@@ -57,6 +66,16 @@ jobs:
node-version: 24
cache: "npm"
- name: Download draw.io static files for offline use
shell: bash
run: |
rm -rf public/drawio
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies
run: npm install
@@ -80,7 +99,7 @@ jobs:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
project-slug: 'next-ai-draw-io'
signing-policy-slug: 'test-signing'
signing-policy-slug: 'release-signing'
artifact-configuration-slug: 'windows-exe'
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true

2
.gitignore vendored
View File

@@ -56,6 +56,8 @@ push-via-ec2.sh
/dist-electron/
/release/
/electron-standalone/
# Draw.io static files (downloaded during CI build)
public/drawio/
*.dmg
*.exe
*.AppImage

View File

@@ -11,13 +11,16 @@ import {
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
import { i18n, type Locale } from "@/lib/i18n/config"
const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
import { isIndexedDBUsable } from "@/lib/session-storage"
export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
useDiagram()
const {
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
onDrawioLoad,
resetDrawioReady,
} = useDiagram()
const router = useRouter()
const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh")
@@ -28,6 +31,12 @@ export default function Home() {
const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false)
const [canPersist, setCanPersist] = useState(false)
const [canPersistChecked, setCanPersistChecked] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false)
@@ -64,6 +73,22 @@ export default function Home() {
document.documentElement.classList.toggle("dark", prefersDark)
}
// Detect Electron and use bundled draw.io files for offline use
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
// Include /index.html because Next.js doesn't auto-serve index.html for directories
const electronDetected =
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
!!(window as unknown as { electronAPI?: unknown }).electronAPI
if (electronDetected) {
setIsElectron(true)
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
}
void (async () => {
const usable = await isIndexedDBUsable()
setCanPersist(usable)
setCanPersistChecked(true)
})()
setIsLoaded(true)
}, [pathname, router])
@@ -72,6 +97,17 @@ export default function Home() {
onDrawioLoad()
}, [onDrawioLoad])
const handleDrawioAutoSave = useCallback(
(data: { xml?: string }) => {
handleDiagramAutoSave(data)
// Only suppress modified state when persistence is available
if (canPersist) {
drawioRef.current?.status({ message: "", modified: false })
}
},
[canPersist, drawioRef, handleDiagramAutoSave],
)
const handleDarkModeChange = () => {
const newValue = !darkMode
setDarkMode(newValue)
@@ -155,25 +191,41 @@ export default function Home() {
}`}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
{isLoaded && (
{isLoaded && canPersistChecked && (
<div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
>
<DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}`}
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
ref={drawioRef}
autosave
onAutoSave={handleDrawioAutoSave}
onExport={handleDiagramExport}
onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl}
configuration={
canPersist
? { confirmExit: false }
: undefined
}
urlParameters={{
ui: drawioUi,
spin: false,
libraries: false,
// Disable modified tracking only when persistence is available
...(canPersist && {
modified: false,
keepmodified: false,
}),
saveAndExit: false,
noSaveBtn: true,
noExitBtn: true,
dark: darkMode,
lang: currentLang,
// Enable offline mode in Electron to disable external service calls
...(isElectron && {
offline: true,
}),
}}
/>
</div>

View File

@@ -9,6 +9,7 @@ import {
Copy,
FileCode,
FileText,
Link,
Pencil,
RotateCcw,
ThumbsDown,
@@ -59,20 +60,20 @@ function getCompleteOperations(
import { useDiagram } from "@/contexts/diagram-context"
// Helper to split text content into regular text and file sections (PDF or text files)
// Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs)
interface TextSection {
type: "text" | "file"
type: "text" | "file" | "url"
content: string
filename?: string
charCount?: number
fileType?: "pdf" | "text"
fileType?: "pdf" | "text" | "url"
}
function splitTextIntoFileSections(text: string): TextSection[] {
const sections: TextSection[] = []
// Match [PDF: filename] or [File: filename] patterns
// Match [PDF: filename], [File: filename], or [URL: url] patterns
const filePattern =
/\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
let lastIndex = 0
let match
@@ -83,28 +84,34 @@ function splitTextIntoFileSections(text: string): TextSection[] {
sections.push({ type: "text", content: beforeText })
}
// Add file section
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
// Add file/url section
const sectionType = match[1].toLowerCase()
const fileType =
sectionType === "pdf"
? "pdf"
: sectionType === "url"
? "url"
: "text"
const filename = match[2].trim()
const fileContent = match[3].trim()
const content = match[3].trim()
sections.push({
type: "file",
content: fileContent,
type: sectionType === "url" ? "url" : "file",
content: content,
filename,
charCount: fileContent.length,
charCount: content.length,
fileType,
})
lastIndex = match.index + match[0].length
}
// Add remaining text after last file section
// Add remaining text after last section
const remainingText = text.slice(lastIndex).trim()
if (remainingText) {
sections.push({ type: "text", content: remainingText })
}
// If no file sections found, return original text
// If no file/url sections found, return original text
if (sections.length === 0) {
sections.push({ type: "text", content: text })
}
@@ -123,8 +130,8 @@ const getMessageTextContent = (message: UIMessage): string => {
// Get only the user's original text, excluding appended file content
const getUserOriginalText = (message: UIMessage): string => {
const fullText = getMessageTextContent(message)
// Strip out [PDF: ...] and [File: ...] sections that were appended
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
return fullText.replace(filePattern, "").trim()
}
@@ -1084,12 +1091,14 @@ export function ChatMessageDisplay({
) => {
if (
section.type ===
"file"
"file" ||
section.type ===
"url"
) {
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}`
const isExpanded =
expandedPdfSections[
pdfKey
sectionKey
] ??
false
const charDisplay =
@@ -1098,10 +1107,27 @@ export function ChatMessageDisplay({
1000
? `${(section.charCount / 1000).toFixed(1)}k`
: section.charCount
// Icon selector
const Icon =
section.fileType ===
"pdf"
? FileText
: section.fileType ===
"url"
? Link
: FileCode
const iconColor =
section.fileType ===
"pdf"
? "text-red-500"
: "text-blue-700"
return (
<div
key={
pdfKey
sectionKey
}
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
>
@@ -1116,7 +1142,7 @@ export function ChatMessageDisplay({
prev,
) => ({
...prev,
[pdfKey]:
[sectionKey]:
!isExpanded,
}),
)
@@ -1124,13 +1150,10 @@ export function ChatMessageDisplay({
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
{section.fileType ===
"pdf" ? (
<FileText className="h-4 w-4 text-red-500" />
) : (
<FileCode className="h-4 w-4 text-blue-500" />
)}
<span className="text-xs font-medium">
<Icon
className={`h-4 w-4 ${iconColor}`}
/>
<span className="text-xs font-medium truncate max-w-[200px]">
{
section.filename
}

View File

@@ -137,26 +137,22 @@ export default function ChatPanel({
const onFetchChart = (saveToHistory = true) => {
return Promise.race([
new Promise<string>((resolve) => {
if (resolverRef && "current" in resolverRef) {
resolverRef.current = resolve
}
resolverRef.current = resolve
if (saveToHistory) {
onExport()
} else {
handleExportWithoutHistory()
}
}),
new Promise<string>((_, reject) =>
setTimeout(
() =>
reject(
new Error(
"Chart export timed out after 10 seconds",
),
),
10000,
),
),
new Promise<string>((_, reject) => {
const currentResolver = resolverRef.current
setTimeout(() => {
if (resolverRef.current === currentResolver) {
resolverRef.current = null
}
reject(new Error("Chart export timed out after 10 seconds"))
}, 10000)
}),
])
}
@@ -585,7 +581,7 @@ export default function ChatPanel({
try {
const currentSession = sessionManager.currentSession
if (currentSession && currentSession.messages.length > 0) {
if (currentSession) {
// Restore from session manager (IndexedDB)
justLoadedSessionRef.current = true
syncUIWithSession(currentSession)
@@ -622,7 +618,7 @@ export default function ChatPanel({
lastSyncedSessionIdRef.current = newSessionId
// Sync UI with new session
if (newSession && newSession.messages.length > 0) {
if (newSession) {
justLoadedSessionRef.current = true
syncUIWithSession(newSession)
} else if (!newSession) {
@@ -677,7 +673,7 @@ export default function ChatPanel({
// Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => {
try {
if (messages.length > 0) {
if (messages.length > 0 || hasDiagramNow) {
const sessionData = await buildSessionData({
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
withThumbnail: hasDiagramNow && !isNodiagramSession,
@@ -699,6 +695,7 @@ export default function ChatPanel({
}
}
}, [
chartXML,
messages,
status,
sessionIsAvailable,
@@ -729,7 +726,8 @@ export default function ChatPanel({
const handleVisibilityChange = async () => {
if (
document.visibilityState === "hidden" &&
messagesRef.current.length > 0
(messagesRef.current.length > 0 ||
isRealDiagram(chartXMLRef.current))
) {
try {
// Attempt to save session - browser may not wait for completion

View File

@@ -20,9 +20,10 @@ interface DiagramContextType {
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void
handleExportWithoutHistory: () => void
resolverRef: React.Ref<((value: string) => void) | null>
drawioRef: React.Ref<DrawIoEmbedRef | null>
resolverRef: React.MutableRefObject<((value: string) => void) | null>
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void
saveDiagramToFile: (
filename: string,
@@ -56,8 +57,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const pngResolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
const hasDiagramRestoredRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("")
@@ -79,22 +78,22 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}, [chartXML])
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
// Also restore when chartXML changes while DrawIO is ready (e.g., session loaded after iframe ready)
const lastRestoredXmlRef = useRef<string>("")
useEffect(() => {
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
if (!isDrawioReady) {
hasDiagramRestoredRef.current = false
return
if (!isDrawioReady || !drawioRef.current) return
// Only load if we have a real diagram and it's different from what we already loaded
if (
isRealDiagram(chartXML) &&
chartXML !== lastRestoredXmlRef.current
) {
lastRestoredXmlRef.current = chartXML
drawioRef.current.load({ xml: chartXML })
} else if (!isRealDiagram(chartXML)) {
// Reset when diagram is cleared so a future restore can re-load the same XML.
lastRestoredXmlRef.current = ""
}
// Only restore once per ready cycle
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
// Restore diagram from ref if we have one
const xmlToRestore = chartXMLRef.current
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
drawioRef.current.load({ xml: xmlToRestore })
}
}, [isDrawioReady])
}, [isDrawioReady, chartXML])
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
@@ -267,6 +266,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return
// Don't overwrite a pending restore - if we have a real diagram in state
// but DrawIO isn't ready yet, it means we're waiting to restore
if (!isDrawioReady && isRealDiagram(chartXML)) {
return
}
setChartXML(data.xml)
}
const clearDiagram = () => {
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
@@ -391,6 +400,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolverRef,
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
clearDiagram,
saveDiagramToFile,
getThumbnailSvg,

View File

@@ -37,10 +37,11 @@ mac:
arch:
- x64
- arm64
hardenedRuntime: true
# Disable electron-builder's signing - we use custom ad-hoc signing in afterPack
# to properly sign nested bundles with --deep flag for bundled draw.io files
identity: null
hardenedRuntime: false
gatekeeperAssess: false
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
dmg:
contents:

View File

@@ -47,6 +47,33 @@ interface ChatSessionDB extends DBSchema {
// Database singleton
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
const resetDBPromise = () => {
dbPromise = null
}
const isClosingError = (error: unknown): boolean => {
return (
error instanceof DOMException &&
error.name === "InvalidStateError" &&
/closing/i.test(error.message)
)
}
const withDB = async <T>(
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
): Promise<T> => {
try {
const db = await getDB()
return await action(db)
} catch (error) {
if (isClosingError(error)) {
resetDBPromise()
const db = await getDB()
return await action(db)
}
throw error
}
}
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) {
@@ -60,7 +87,23 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
}
// Future migrations: if (oldVersion < 2) { ... }
},
terminated() {
resetDBPromise()
},
})
dbPromise
.then((db) => {
db.onversionchange = () => {
db.close()
resetDBPromise()
}
db.onclose = () => {
resetDBPromise()
}
})
.catch(() => {
resetDBPromise()
})
}
return dbPromise
}
@@ -75,31 +118,46 @@ export function isIndexedDBAvailable(): boolean {
}
}
// Check if IndexedDB is actually usable (not just present).
// Note: Do NOT close the db here - getDB() returns a shared singleton connection
// that other code depends on.
export async function isIndexedDBUsable(): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
await getDB()
return true
} catch {
return false
}
}
// CRUD Operations
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
if (!isIndexedDBAvailable()) return []
try {
const db = await getDB()
const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = []
return await withDB(async (db) => {
const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = []
// Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) {
const s = cursor.value
metadata.push({
id: s.id,
title: s.title,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
messageCount: s.messages.length,
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
thumbnailDataUrl: s.thumbnailDataUrl,
})
cursor = await cursor.continue()
}
return metadata
// Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) {
const s = cursor.value
metadata.push({
id: s.id,
title: s.title,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
messageCount: s.messages.length,
hasDiagram:
!!s.diagramXml && s.diagramXml.trim().length > 0,
thumbnailDataUrl: s.thumbnailDataUrl,
})
cursor = await cursor.continue()
}
return metadata
})
} catch (error) {
console.error("Failed to get session metadata:", error)
return []
@@ -109,8 +167,9 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
export async function getSession(id: string): Promise<ChatSession | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
return (await db.get(STORE_NAME, id)) || null
return await withDB(async (db) => {
return (await db.get(STORE_NAME, id)) || null
})
} catch (error) {
console.error("Failed to get session:", error)
return null
@@ -120,8 +179,9 @@ export async function getSession(id: string): Promise<ChatSession | null> {
export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
const db = await getDB()
await db.put(STORE_NAME, session)
await withDB(async (db) => {
await db.put(STORE_NAME, session)
})
return true
} catch (error) {
// Handle quota exceeded
@@ -133,8 +193,9 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
await deleteOldestSession()
// Retry once
try {
const db = await getDB()
await db.put(STORE_NAME, session)
await withDB(async (db) => {
await db.put(STORE_NAME, session)
})
return true
} catch (retryError) {
console.error(
@@ -153,8 +214,9 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
export async function deleteSession(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
await db.delete(STORE_NAME, id)
await withDB(async (db) => {
await db.delete(STORE_NAME, id)
})
} catch (error) {
console.error("Failed to delete session:", error)
}
@@ -163,8 +225,9 @@ export async function deleteSession(id: string): Promise<void> {
export async function getSessionCount(): Promise<number> {
if (!isIndexedDBAvailable()) return 0
try {
const db = await getDB()
return await db.count(STORE_NAME)
return await withDB(async (db) => {
return await db.count(STORE_NAME)
})
} catch (error) {
console.error("Failed to get session count:", error)
return 0
@@ -174,14 +237,15 @@ export async function getSessionCount(): Promise<number> {
export async function deleteOldestSession(): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated")
const cursor = await index.openCursor()
if (cursor) {
await cursor.delete()
}
await tx.done
await withDB(async (db) => {
const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated")
const cursor = await index.openCursor()
if (cursor) {
await cursor.delete()
}
await tx.done
})
} catch (error) {
console.error("Failed to delete oldest session:", error)
}

View File

@@ -11,6 +11,7 @@ export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
ALWAYS respond in the same language as the user's last message.
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.

676
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",
"version": "0.4.11",
"version": "0.4.12",
"license": "Apache-2.0",
"private": true,
"main": "dist-electron/main/index.js",
@@ -139,7 +139,7 @@
"vite-tsconfig-paths": "^6.0.3",
"vitest": "^4.0.16",
"wait-on": "^9.0.3",
"wrangler": "4.58.0"
"wrangler": "^4.60.0"
},
"overrides": {
"@openrouter/ai-sdk-provider": {

View File

@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.13",
"version": "0.1.15",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",

View File

@@ -44,6 +44,17 @@ function isLikelyMcpSessionId(sessionId: string): boolean {
return sessionId.startsWith("mcp-") && sessionId.length <= 128
}
// Find the most recent active session (for auto-redirect when no sessionId provided)
function getMostRecentSessionId(): string | null {
let mostRecent: { id: string; lastUpdated: Date } | null = null
for (const [sessionId, state] of stateStore) {
if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) {
mostRecent = { id: sessionId, lastUpdated: state.lastUpdated }
}
}
return mostRecent?.id || null
}
function ensureSessionStateInitialized(sessionId: string): void {
if (!sessionId) return
if (!isLikelyMcpSessionId(sessionId)) return
@@ -195,6 +206,17 @@ function handleRequest(
if (url.pathname === "/" || url.pathname === "/index.html") {
const sessionId = url.searchParams.get("mcp") || ""
// Auto-redirect to most recent session if no sessionId provided
if (!sessionId) {
const recentSessionId = getMostRecentSessionId()
if (recentSessionId) {
res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })
res.end()
return
}
}
ensureSessionStateInitialized(sessionId)
res.writeHead(200, { "Content-Type": "text/html" })
@@ -375,85 +397,202 @@ function getHtmlPage(sessionId: string): string {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Draw.io MCP</title>
<title>Next AI Draw.io</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
#header {
padding: 8px 16px; background: #1a1a2e; color: #eee;
font-family: system-ui, sans-serif; font-size: 14px;
padding: 0 20px; height: 52px;
background: linear-gradient(to bottom, #ffffff, #fafbfc);
border-bottom: 1px solid #e8ecf0;
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
display: flex; justify-content: space-between; align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
position: relative; z-index: 10;
}
#header .brand {
display: flex; align-items: center; gap: 10px;
}
#header .logo {
width: 28px; height: 28px; border-radius: 6px;
background: #18181b;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
#header .title {
font-size: 15px; font-weight: 600; color: #1a1a2e;
letter-spacing: -0.3px;
}
#header .session {
font-size: 11px; color: #8b95a5; font-weight: 400;
background: #f1f3f9; padding: 3px 8px; border-radius: 4px;
margin-left: 12px; font-family: 'SF Mono', Monaco, monospace;
}
#header .right { display: flex; align-items: center; gap: 12px; }
#save-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; font-size: 13px;
background: linear-gradient(to bottom, #18181b, #27272a);
color: white; border: none; cursor: pointer;
font-weight: 500; font-family: inherit;
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
transition: all 0.15s ease;
}
#save-btn svg { width: 14px; height: 14px; }
#save-btn:hover {
background: linear-gradient(to bottom, #27272a, #3f3f46);
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
}
#save-btn:active { transform: translateY(0); }
#save-btn:disabled, #history-btn:disabled {
background: #e5e7eb; color: #9ca3af;
cursor: not-allowed; transform: none; box-shadow: none;
}
#header .session { color: #888; font-size: 12px; }
#header .status { font-size: 12px; }
#header .status.connected { color: #4ade80; }
#header .status.disconnected { color: #f87171; }
#drawio { flex: 1; border: none; }
#history-btn {
position: fixed; bottom: 24px; right: 24px;
width: 48px; height: 48px; border-radius: 50%;
background: #3b82f6; color: white; border: none; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; font-size: 13px;
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
cursor: pointer; font-weight: 500; font-family: inherit;
transition: all 0.15s ease;
}
#history-btn:hover { background: #2563eb; }
#history-btn:disabled { background: #6b7280; cursor: not-allowed; }
#history-btn svg { width: 24px; height: 24px; }
#history-modal {
#history-btn svg { width: 14px; height: 14px; }
#history-btn:hover {
background: #e4e4e7; border-color: #d4d4d8;
}
#drawio { flex: 1; border: none; }
#history-modal, #save-modal {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.5); z-index: 2000;
align-items: center; justify-content: center;
background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
z-index: 2000; align-items: center; justify-content: center;
}
#history-modal.open { display: flex; }
#history-modal.open, #save-modal.open { display: flex; }
.modal-content {
background: white; border-radius: 12px;
width: 90%; max-width: 500px; max-height: 70vh;
background: white; border-radius: 16px;
width: 90%; max-width: 480px; max-height: 70vh;
display: flex; flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
animation: modalIn 0.2s ease-out;
}
.modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
.modal-header h2 { font-size: 18px; margin: 0; }
.modal-body { flex: 1; overflow-y: auto; padding: 16px; }
.modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-header {
padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5;
}
.modal-header h2 {
font-size: 17px; font-weight: 600; margin: 0; color: #18181b;
letter-spacing: -0.3px;
}
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
.modal-footer {
padding: 16px 24px; border-top: 1px solid #f1f3f5;
display: flex; gap: 10px; justify-content: flex-end;
}
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.history-item {
border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px;
cursor: pointer; text-align: center;
border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px;
cursor: pointer; text-align: center; transition: all 0.15s ease;
background: #fafafa;
}
.history-item:hover { border-color: #a1a1aa; background: white; }
.history-item.selected {
border-color: #18181b; background: white;
box-shadow: 0 0 0 3px rgba(24,24,27,0.1);
}
.history-item:hover { border-color: #3b82f6; }
.history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
.history-item .thumb {
aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px;
aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
margin-bottom: 4px; overflow: hidden;
margin-bottom: 6px; overflow: hidden;
}
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
.history-item .label { font-size: 12px; color: #666; }
.btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-secondary { background: #f3f4f6; color: #374151; }
.empty { text-align: center; padding: 40px; color: #666; }
.history-item .label { font-size: 11px; color: #71717a; font-weight: 500; }
.btn {
padding: 9px 18px; border-radius: 8px; font-size: 13px;
cursor: pointer; border: none; font-weight: 500;
font-family: inherit; transition: all 0.15s ease;
}
.btn-primary {
background: linear-gradient(to bottom, #18181b, #27272a);
color: white;
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
}
.btn-primary:hover {
background: linear-gradient(to bottom, #27272a, #3f3f46);
transform: translateY(-1px);
}
.btn-primary:disabled {
background: #e4e4e7; color: #a1a1aa;
cursor: not-allowed; transform: none; box-shadow: none;
}
.btn-secondary {
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
}
.btn-secondary:hover { background: #e4e4e7; }
.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; }
.form-group { margin-bottom: 18px; }
.form-group label {
display: block; font-size: 13px; font-weight: 500;
margin-bottom: 8px; color: #3f3f46;
}
.form-group select, .form-group input {
width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7;
border-radius: 8px; font-size: 14px; outline: none;
font-family: inherit; background: white;
transition: all 0.15s ease;
}
.form-group select:focus, .form-group input:focus {
border-color: #18181b;
box-shadow: 0 0 0 3px rgba(24,24,27,0.08);
}
.filename-group { display: flex; }
.filename-group input { border-radius: 8px 0 0 8px; border-right: none; }
.filename-group .ext {
padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7;
border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a;
font-family: 'SF Mono', Monaco, monospace;
}
</style>
</head>
<body>
<div id="container">
<div id="header">
<div>
<strong>Draw.io MCP</strong>
<span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span>
<div class="brand">
<div class="logo">
<svg viewBox="0 0 1536 1536" fill="#ffffff">
<g transform="translate(0,1536) scale(0.1,-0.1)">
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431 -560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356 319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85 -17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529 -2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128 -82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303 -177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276 286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242 241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337 -25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22 -5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204 -383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72 -167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300 6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884 -15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534 c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154 11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448 -20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10 -47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293 -369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6 39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52 -149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415 20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50 118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0 535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539 376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70 290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
</g>
</svg>
</div>
<span class="title">Next AI Draw.io</span>
${sessionId ? `<span class="session">${sessionId.slice(-8)}</span>` : ""}
</div>
<div class="right">
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
History
</button>
<button id="save-btn" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Download
</button>
</div>
<div id="status" class="status disconnected">Connecting...</div>
</div>
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?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&noSaveBtn=1&noExitBtn=1&saveAndExit=0"></iframe>
</div>
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
<div id="history-modal">
<div class="modal-content">
<div class="modal-header"><h2>History</h2></div>
@@ -467,10 +606,35 @@ function getHtmlPage(sessionId: string): string {
</div>
</div>
</div>
<div id="save-modal">
<div class="modal-content">
<div class="modal-header"><h2>Download Diagram</h2></div>
<div class="modal-body">
<div class="form-group">
<label>Format</label>
<select id="save-format">
<option value="drawio">Draw.io (.drawio)</option>
<option value="png">PNG Image (.png)</option>
<option value="svg">SVG Vector (.svg)</option>
</select>
</div>
<div class="form-group">
<label>Filename</label>
<div class="filename-group">
<input type="text" id="save-filename" value="diagram" placeholder="Enter filename">
<span class="ext" id="save-ext">.drawio</span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="save-cancel-btn">Cancel</button>
<button class="btn btn-primary" id="save-confirm-btn">Save</button>
</div>
</div>
</div>
<script>
const sessionId = "${sessionId}";
const iframe = document.getElementById('drawio');
const statusEl = document.getElementById('status');
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
let pendingSvgExport = null;
let pendingAiSvg = false;
@@ -481,8 +645,6 @@ function getHtmlPage(sessionId: string): string {
const msg = JSON.parse(e.data);
if (msg.event === 'init') {
isReady = true;
statusEl.textContent = 'Ready';
statusEl.className = 'status connected';
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
// Request SVG export, then push state with SVG
@@ -491,6 +653,23 @@ function getHtmlPage(sessionId: string): string {
// Fallback if export doesn't respond
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
} else if (msg.event === 'export' && msg.data) {
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
const dl = pendingDownload;
pendingDownload = null;
let dataUrl = msg.data;
if (!dataUrl.startsWith('data:')) {
const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml';
dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data)));
}
const a = document.createElement('a');
a.href = dataUrl; a.download = dl.filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
saveModal.classList.remove('open');
saveConfirmBtn.disabled = false;
saveConfirmBtn.textContent = 'Save';
return;
}
// Handle sync export (XML format) - server requested fresh state
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
pendingSyncExport = false;
@@ -563,6 +742,64 @@ function getHtmlPage(sessionId: string): string {
if (sessionId) { poll(); setInterval(poll, 2000); }
// Save modal
const saveBtn = document.getElementById('save-btn');
const saveModal = document.getElementById('save-modal');
const saveFormat = document.getElementById('save-format');
const saveFilename = document.getElementById('save-filename');
const saveExt = document.getElementById('save-ext');
const saveCancelBtn = document.getElementById('save-cancel-btn');
const saveConfirmBtn = document.getElementById('save-confirm-btn');
let pendingDownload = null;
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg' };
saveBtn.onclick = () => {
if (!sessionId || !isReady) return;
saveModal.classList.add('open');
saveFilename.focus();
saveFilename.select();
};
saveFormat.onchange = () => {
saveExt.textContent = extMap[saveFormat.value] || '.drawio';
};
saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); };
saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); };
saveConfirmBtn.onclick = () => {
const format = saveFormat.value;
const filename = (saveFilename.value.trim() || 'diagram') + extMap[format];
saveConfirmBtn.disabled = true;
saveConfirmBtn.textContent = 'Exporting...';
if (format === 'drawio') {
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
let xmlData = lastXml || '';
if (xmlData && !xmlData.includes('<mxfile')) {
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
}
const blob = new Blob([xmlData], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
saveModal.classList.remove('open');
saveConfirmBtn.disabled = false;
saveConfirmBtn.textContent = 'Save';
} else if (format === 'png') {
pendingDownload = { format: 'png', filename };
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2 }), '*');
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
} else if (format === 'svg') {
pendingDownload = { format: 'svg', filename };
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
}
};
// History UI
const historyBtn = document.getElementById('history-btn');
const historyModal = document.getElementById('history-modal');

View File

@@ -31,6 +31,7 @@ export function proxy(request: NextRequest) {
if (
pathname.startsWith("/api/") ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/drawio") ||
pathname.includes("/favicon") ||
/\.(.*)$/.test(pathname)
) {

View File

@@ -1,6 +1,7 @@
/**
* electron-builder afterPack hook
* Copies node_modules to the standalone directory in the packaged app
* and ad-hoc signs macOS apps for offline draw.io bundle compatibility
*/
const {
@@ -12,6 +13,7 @@ const {
statSync,
} = require("fs")
const path = require("path")
const { execSync } = require("child_process")
/**
* Copy directory recursively, converting symlinks to regular files/directories.
@@ -80,4 +82,22 @@ module.exports = async (context) => {
"Ensure 'npm run electron:prepare' was run before building.",
)
}
// Ad-hoc sign macOS apps to fix signature issues with bundled draw.io files
if (context.packager.platform.name === "mac") {
const appPath = path.join(
appOutDir,
`${context.packager.appInfo.productFilename}.app`,
)
console.log(`[afterPack] Ad-hoc signing macOS app: ${appPath}`)
try {
execSync(`codesign --force --deep --sign - "${appPath}"`, {
stdio: "inherit",
})
console.log("[afterPack] Ad-hoc signing completed successfully")
} catch (error) {
console.error("[afterPack] Ad-hoc signing failed:", error.message)
throw error
}
}
}