Compare commits

...

9 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
5 changed files with 186 additions and 77 deletions

View File

@@ -11,10 +11,16 @@ import {
} from "@/components/ui/resizable" } from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { i18n, type Locale } from "@/lib/i18n/config" import { i18n, type Locale } from "@/lib/i18n/config"
import { isIndexedDBUsable } from "@/lib/session-storage"
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } = const {
useDiagram() drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
onDrawioLoad,
resetDrawioReady,
} = useDiagram()
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh") // Extract current language from pathname (e.g., "/zh/about" → "zh")
@@ -26,6 +32,8 @@ export default function Home() {
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false) const [isElectron, setIsElectron] = useState(false)
const [canPersist, setCanPersist] = useState(false)
const [canPersistChecked, setCanPersistChecked] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState( const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net", process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
) )
@@ -76,6 +84,11 @@ export default function Home() {
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`) setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
} }
void (async () => {
const usable = await isIndexedDBUsable()
setCanPersist(usable)
setCanPersistChecked(true)
})()
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
@@ -84,6 +97,17 @@ export default function Home() {
onDrawioLoad() onDrawioLoad()
}, [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 handleDarkModeChange = () => {
const newValue = !darkMode const newValue = !darkMode
setDarkMode(newValue) setDarkMode(newValue)
@@ -167,20 +191,32 @@ export default function Home() {
}`} }`}
> >
<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 relative">
{isLoaded && ( {isLoaded && canPersistChecked && (
<div <div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`} className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
> >
<DrawIoEmbed <DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`} key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
ref={drawioRef} ref={drawioRef}
autosave
onAutoSave={handleDrawioAutoSave}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={handleDrawioLoad} onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
configuration={
canPersist
? { confirmExit: false }
: undefined
}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,
spin: false, spin: false,
libraries: false, libraries: false,
// Disable modified tracking only when persistence is available
...(canPersist && {
modified: false,
keepmodified: false,
}),
saveAndExit: false, saveAndExit: false,
noSaveBtn: true, noSaveBtn: true,
noExitBtn: true, noExitBtn: true,

View File

@@ -137,26 +137,22 @@ export default function ChatPanel({
const onFetchChart = (saveToHistory = true) => { const onFetchChart = (saveToHistory = true) => {
return Promise.race([ return Promise.race([
new Promise<string>((resolve) => { new Promise<string>((resolve) => {
if (resolverRef && "current" in resolverRef) { resolverRef.current = resolve
resolverRef.current = resolve
}
if (saveToHistory) { if (saveToHistory) {
onExport() onExport()
} else { } else {
handleExportWithoutHistory() handleExportWithoutHistory()
} }
}), }),
new Promise<string>((_, reject) => new Promise<string>((_, reject) => {
setTimeout( const currentResolver = resolverRef.current
() => setTimeout(() => {
reject( if (resolverRef.current === currentResolver) {
new Error( resolverRef.current = null
"Chart export timed out after 10 seconds", }
), reject(new Error("Chart export timed out after 10 seconds"))
), }, 10000)
10000, }),
),
),
]) ])
} }
@@ -585,7 +581,7 @@ export default function ChatPanel({
try { try {
const currentSession = sessionManager.currentSession const currentSession = sessionManager.currentSession
if (currentSession && currentSession.messages.length > 0) { if (currentSession) {
// Restore from session manager (IndexedDB) // Restore from session manager (IndexedDB)
justLoadedSessionRef.current = true justLoadedSessionRef.current = true
syncUIWithSession(currentSession) syncUIWithSession(currentSession)
@@ -622,7 +618,7 @@ export default function ChatPanel({
lastSyncedSessionIdRef.current = newSessionId lastSyncedSessionIdRef.current = newSessionId
// Sync UI with new session // Sync UI with new session
if (newSession && newSession.messages.length > 0) { if (newSession) {
justLoadedSessionRef.current = true justLoadedSessionRef.current = true
syncUIWithSession(newSession) syncUIWithSession(newSession)
} else if (!newSession) { } else if (!newSession) {
@@ -677,7 +673,7 @@ export default function ChatPanel({
// Debounce: save after 1 second of no changes // Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => { localStorageDebounceRef.current = setTimeout(async () => {
try { try {
if (messages.length > 0) { if (messages.length > 0 || hasDiagramNow) {
const sessionData = await buildSessionData({ const sessionData = await buildSessionData({
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session // Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
withThumbnail: hasDiagramNow && !isNodiagramSession, withThumbnail: hasDiagramNow && !isNodiagramSession,
@@ -699,6 +695,7 @@ export default function ChatPanel({
} }
} }
}, [ }, [
chartXML,
messages, messages,
status, status,
sessionIsAvailable, sessionIsAvailable,
@@ -729,7 +726,8 @@ export default function ChatPanel({
const handleVisibilityChange = async () => { const handleVisibilityChange = async () => {
if ( if (
document.visibilityState === "hidden" && document.visibilityState === "hidden" &&
messagesRef.current.length > 0 (messagesRef.current.length > 0 ||
isRealDiagram(chartXMLRef.current))
) { ) {
try { try {
// Attempt to save session - browser may not wait for completion // 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 loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void handleExport: () => void
handleExportWithoutHistory: () => void handleExportWithoutHistory: () => void
resolverRef: React.Ref<((value: string) => void) | null> resolverRef: React.MutableRefObject<((value: string) => void) | null>
drawioRef: React.Ref<DrawIoEmbedRef | null> drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void handleDiagramExport: (data: any) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void clearDiagram: () => void
saveDiagramToFile: ( saveDiagramToFile: (
filename: string, filename: string,
@@ -56,8 +57,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const pngResolverRef = useRef<((value: string) => void) | null>(null) const pngResolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false) 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 // Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("") const chartXMLRef = useRef<string>("")
@@ -79,22 +78,22 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}, [chartXML]) }, [chartXML])
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change) // 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(() => { useEffect(() => {
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle) if (!isDrawioReady || !drawioRef.current) return
if (!isDrawioReady) { // Only load if we have a real diagram and it's different from what we already loaded
hasDiagramRestoredRef.current = false if (
return 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 }, [isDrawioReady, chartXML])
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])
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ 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 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>` 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) // Skip validation for trusted internal template (loadDiagram also sets chartXML)
@@ -391,6 +400,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolverRef, resolverRef,
drawioRef, drawioRef,
handleDiagramExport, handleDiagramExport,
handleDiagramAutoSave,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
getThumbnailSvg, getThumbnailSvg,

View File

@@ -47,6 +47,33 @@ interface ChatSessionDB extends DBSchema {
// Database singleton // Database singleton
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null 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>> { async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) { if (!dbPromise) {
@@ -60,7 +87,23 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
} }
// Future migrations: if (oldVersion < 2) { ... } // Future migrations: if (oldVersion < 2) { ... }
}, },
terminated() {
resetDBPromise()
},
}) })
dbPromise
.then((db) => {
db.onversionchange = () => {
db.close()
resetDBPromise()
}
db.onclose = () => {
resetDBPromise()
}
})
.catch(() => {
resetDBPromise()
})
} }
return dbPromise 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 // CRUD Operations
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> { export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
if (!isIndexedDBAvailable()) return [] if (!isIndexedDBAvailable()) return []
try { try {
const db = await getDB() return await withDB(async (db) => {
const tx = db.transaction(STORE_NAME, "readonly") const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated") const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = [] const metadata: SessionMetadata[] = []
// Use cursor to read only metadata fields (avoids loading full messages/XML) // Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) { while (cursor) {
const s = cursor.value const s = cursor.value
metadata.push({ metadata.push({
id: s.id, id: s.id,
title: s.title, title: s.title,
createdAt: s.createdAt, createdAt: s.createdAt,
updatedAt: s.updatedAt, updatedAt: s.updatedAt,
messageCount: s.messages.length, messageCount: s.messages.length,
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0, hasDiagram:
thumbnailDataUrl: s.thumbnailDataUrl, !!s.diagramXml && s.diagramXml.trim().length > 0,
}) thumbnailDataUrl: s.thumbnailDataUrl,
cursor = await cursor.continue() })
} cursor = await cursor.continue()
return metadata }
return metadata
})
} catch (error) { } catch (error) {
console.error("Failed to get session metadata:", error) console.error("Failed to get session metadata:", error)
return [] return []
@@ -109,8 +167,9 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
export async function getSession(id: string): Promise<ChatSession | null> { export async function getSession(id: string): Promise<ChatSession | null> {
if (!isIndexedDBAvailable()) return null if (!isIndexedDBAvailable()) return null
try { try {
const db = await getDB() return await withDB(async (db) => {
return (await db.get(STORE_NAME, id)) || null return (await db.get(STORE_NAME, id)) || null
})
} catch (error) { } catch (error) {
console.error("Failed to get session:", error) console.error("Failed to get session:", error)
return null return null
@@ -120,8 +179,9 @@ export async function getSession(id: string): Promise<ChatSession | null> {
export async function saveSession(session: ChatSession): Promise<boolean> { export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false if (!isIndexedDBAvailable()) return false
try { try {
const db = await getDB() await withDB(async (db) => {
await db.put(STORE_NAME, session) await db.put(STORE_NAME, session)
})
return true return true
} catch (error) { } catch (error) {
// Handle quota exceeded // Handle quota exceeded
@@ -133,8 +193,9 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
await deleteOldestSession() await deleteOldestSession()
// Retry once // Retry once
try { try {
const db = await getDB() await withDB(async (db) => {
await db.put(STORE_NAME, session) await db.put(STORE_NAME, session)
})
return true return true
} catch (retryError) { } catch (retryError) {
console.error( console.error(
@@ -153,8 +214,9 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
export async function deleteSession(id: string): Promise<void> { export async function deleteSession(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return if (!isIndexedDBAvailable()) return
try { try {
const db = await getDB() await withDB(async (db) => {
await db.delete(STORE_NAME, id) await db.delete(STORE_NAME, id)
})
} catch (error) { } catch (error) {
console.error("Failed to delete session:", 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> { export async function getSessionCount(): Promise<number> {
if (!isIndexedDBAvailable()) return 0 if (!isIndexedDBAvailable()) return 0
try { try {
const db = await getDB() return await withDB(async (db) => {
return await db.count(STORE_NAME) return await db.count(STORE_NAME)
})
} catch (error) { } catch (error) {
console.error("Failed to get session count:", error) console.error("Failed to get session count:", error)
return 0 return 0
@@ -174,14 +237,15 @@ export async function getSessionCount(): Promise<number> {
export async function deleteOldestSession(): Promise<void> { export async function deleteOldestSession(): Promise<void> {
if (!isIndexedDBAvailable()) return if (!isIndexedDBAvailable()) return
try { try {
const db = await getDB() await withDB(async (db) => {
const tx = db.transaction(STORE_NAME, "readwrite") const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated") const index = tx.store.index("by-updated")
const cursor = await index.openCursor() const cursor = await index.openCursor()
if (cursor) { if (cursor) {
await cursor.delete() await cursor.delete()
} }
await tx.done await tx.done
})
} catch (error) { } catch (error) {
console.error("Failed to delete oldest session:", 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. 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. 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. 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. 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. After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.