mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
PR #840 fixed issue #815 in the Electron main process via
will-prevent-unload + preventDefault. The renderer-side workarounds
introduced by previous fix attempts (#642, #648) are no longer needed
and never had effect for their stated purpose.
Removed:
- configuration={ confirmExit: false } in DrawIoEmbed
confirmExit is not a recognized draw.io config key (zero matches in
jgraph/drawio source). This was always dead code.
- modified=0 / keepmodified=0 URL parameters
Per drawio source (app.min.js:14898), these only suppress the
post-save modified-flag clearing — they do not prevent edits from
setting editor.modified=true. They were ineffective for blocking
beforeunload prompts and actually prevented draw.io from clearing
its modified flag after save.
- canPersist / canPersistChecked state and isIndexedDBUsable() probe
Their only purpose was gating the dead config above. Removing them
also removes a startup delay before the iframe renders.
- handleDrawioAutoSave wrapper
After PR #780 stripped its body, it was a pure passthrough useCallback.
Now passes handleDiagramAutoSave directly to onAutoSave.
- withDB / isClosingError / resetDBPromise / onversionchange / onclose
/ terminated handlers in lib/session-storage.ts and lib/template-storage.ts
PR #648 added these to recover from 'IDBDatabase: connection is closing'
errors that PR #642's first land caused via db.close() on the shared
singleton. That bug was already fixed in c5de1a1 (re-land of #642),
three minutes before PR #648 commits started. The retry handlers
defend against multi-tab / version-change scenarios that cannot occur
in this single-instance Electron app (requestSingleInstanceLock).
template-storage.ts copied the same pattern when introduced by #773.
Verified:
- npx tsc --noEmit passes
- Manual test in dev mode: session save/load works, template create works,
diagram-only persistence works.
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
|
import { nanoid } from "nanoid"
|
|
import type { Template } from "./template-storage"
|
|
|
|
// Constants
|
|
const DB_NAME = "next-ai-drawio"
|
|
const DB_VERSION = 2
|
|
const STORE_NAME = "sessions"
|
|
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
|
|
const MAX_SESSIONS = 50
|
|
|
|
// Types
|
|
export interface ChatSession {
|
|
id: string
|
|
title: string
|
|
createdAt: number
|
|
updatedAt: number
|
|
messages: StoredMessage[]
|
|
xmlSnapshots: [number, string][]
|
|
diagramXml: string
|
|
thumbnailDataUrl?: string // Small PNG preview of the diagram
|
|
diagramHistory?: { svg: string; xml: string }[] // Version history of diagram edits
|
|
}
|
|
|
|
export interface StoredMessage {
|
|
id: string
|
|
role: "user" | "assistant" | "system"
|
|
parts: Array<{ type: string; [key: string]: unknown }>
|
|
}
|
|
|
|
export interface SessionMetadata {
|
|
id: string
|
|
title: string
|
|
createdAt: number
|
|
updatedAt: number
|
|
messageCount: number
|
|
hasDiagram: boolean
|
|
thumbnailDataUrl?: string
|
|
}
|
|
|
|
interface ChatSessionDB extends DBSchema {
|
|
sessions: {
|
|
key: string
|
|
value: ChatSession
|
|
indexes: { "by-updated": number }
|
|
}
|
|
templates: {
|
|
key: string
|
|
value: Template
|
|
indexes: {
|
|
"by-updated": number
|
|
"by-pinned": number
|
|
"by-run-count": number
|
|
"by-last-used": number
|
|
}
|
|
}
|
|
}
|
|
|
|
// Database singleton
|
|
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
|
|
|
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
|
if (!dbPromise) {
|
|
dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {
|
|
upgrade(db, oldVersion) {
|
|
if (oldVersion < 1) {
|
|
const store = db.createObjectStore(STORE_NAME, {
|
|
keyPath: "id",
|
|
})
|
|
store.createIndex("by-updated", "updatedAt")
|
|
}
|
|
// Version 2: templates store (added by template-storage.ts)
|
|
// Note: We also need to include this here to ensure the upgrade
|
|
// callback properly handles all migrations when opening from this file
|
|
if (oldVersion < 2) {
|
|
// Check if templates store already exists (created by template-storage.ts)
|
|
if (!db.objectStoreNames.contains("templates")) {
|
|
const templateStore = db.createObjectStore(
|
|
"templates",
|
|
{
|
|
keyPath: "id",
|
|
},
|
|
)
|
|
templateStore.createIndex("by-updated", "updatedAt")
|
|
templateStore.createIndex("by-pinned", "pinned")
|
|
templateStore.createIndex("by-run-count", "runCount")
|
|
templateStore.createIndex("by-last-used", "lastUsedAt")
|
|
}
|
|
}
|
|
},
|
|
})
|
|
}
|
|
return dbPromise
|
|
}
|
|
|
|
// Check if IndexedDB is available
|
|
export function isIndexedDBAvailable(): boolean {
|
|
if (typeof window === "undefined") return false
|
|
try {
|
|
return "indexedDB" in window && window.indexedDB !== null
|
|
} 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[] = []
|
|
|
|
// 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 []
|
|
}
|
|
}
|
|
|
|
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
|
|
} catch (error) {
|
|
console.error("Failed to get session:", error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function saveSession(session: ChatSession): Promise<boolean> {
|
|
if (!isIndexedDBAvailable()) return false
|
|
try {
|
|
const db = await getDB()
|
|
await db.put(STORE_NAME, session)
|
|
return true
|
|
} catch (error) {
|
|
// Handle quota exceeded
|
|
if (
|
|
error instanceof DOMException &&
|
|
error.name === "QuotaExceededError"
|
|
) {
|
|
console.warn("Storage quota exceeded, deleting oldest session...")
|
|
await deleteOldestSession()
|
|
// Retry once
|
|
try {
|
|
const db = await getDB()
|
|
await db.put(STORE_NAME, session)
|
|
return true
|
|
} catch (retryError) {
|
|
console.error(
|
|
"Failed to save session after cleanup:",
|
|
retryError,
|
|
)
|
|
return false
|
|
}
|
|
} else {
|
|
console.error("Failed to save session:", error)
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function deleteSession(id: string): Promise<void> {
|
|
if (!isIndexedDBAvailable()) return
|
|
try {
|
|
const db = await getDB()
|
|
await db.delete(STORE_NAME, id)
|
|
} catch (error) {
|
|
console.error("Failed to delete session:", error)
|
|
}
|
|
}
|
|
|
|
export async function getSessionCount(): Promise<number> {
|
|
if (!isIndexedDBAvailable()) return 0
|
|
try {
|
|
const db = await getDB()
|
|
return await db.count(STORE_NAME)
|
|
} catch (error) {
|
|
console.error("Failed to get session count:", error)
|
|
return 0
|
|
}
|
|
}
|
|
|
|
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
|
|
} catch (error) {
|
|
console.error("Failed to delete oldest session:", error)
|
|
}
|
|
}
|
|
|
|
// Enforce max sessions limit
|
|
export async function enforceSessionLimit(): Promise<void> {
|
|
const count = await getSessionCount()
|
|
if (count > MAX_SESSIONS) {
|
|
const toDelete = count - MAX_SESSIONS
|
|
for (let i = 0; i < toDelete; i++) {
|
|
await deleteOldestSession()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper: Create a new empty session
|
|
export function createEmptySession(): ChatSession {
|
|
return {
|
|
id: nanoid(),
|
|
title: "New Chat",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [],
|
|
xmlSnapshots: [],
|
|
diagramXml: "",
|
|
}
|
|
}
|
|
|
|
// Helper: Extract title from first user message (truncated to reasonable length)
|
|
const MAX_TITLE_LENGTH = 100
|
|
|
|
export function extractTitle(messages: StoredMessage[]): string {
|
|
const firstUserMessage = messages.find((m) => m.role === "user")
|
|
if (!firstUserMessage) return "New Chat"
|
|
|
|
const textPart = firstUserMessage.parts.find((p) => p.type === "text")
|
|
if (!textPart || typeof textPart.text !== "string") return "New Chat"
|
|
|
|
const text = textPart.text.trim()
|
|
if (!text) return "New Chat"
|
|
|
|
// Truncate long titles
|
|
if (text.length > MAX_TITLE_LENGTH) {
|
|
return text.slice(0, MAX_TITLE_LENGTH).trim() + "..."
|
|
}
|
|
return text
|
|
}
|
|
|
|
// Helper: Sanitize UIMessage to StoredMessage
|
|
export function sanitizeMessage(message: unknown): StoredMessage | null {
|
|
if (!message || typeof message !== "object") return null
|
|
|
|
const msg = message as Record<string, unknown>
|
|
if (!msg.id || !msg.role) return null
|
|
|
|
const role = msg.role as string
|
|
if (!["user", "assistant", "system"].includes(role)) return null
|
|
|
|
// Extract parts, removing streaming state artifacts
|
|
let parts: Array<{ type: string; [key: string]: unknown }> = []
|
|
if (Array.isArray(msg.parts)) {
|
|
parts = msg.parts.map((part: unknown) => {
|
|
if (!part || typeof part !== "object") return { type: "unknown" }
|
|
const p = part as Record<string, unknown>
|
|
// Remove streaming-related fields
|
|
const { isStreaming, streamingState, ...cleanPart } = p
|
|
return cleanPart as { type: string; [key: string]: unknown }
|
|
})
|
|
}
|
|
|
|
return {
|
|
id: msg.id as string,
|
|
role: role as "user" | "assistant" | "system",
|
|
parts,
|
|
}
|
|
}
|
|
|
|
export function sanitizeMessages(messages: unknown[]): StoredMessage[] {
|
|
return messages
|
|
.map(sanitizeMessage)
|
|
.filter((m): m is StoredMessage => m !== null)
|
|
}
|
|
|
|
// Migration from localStorage
|
|
export async function migrateFromLocalStorage(): Promise<string | null> {
|
|
if (typeof window === "undefined") return null
|
|
if (!isIndexedDBAvailable()) return null
|
|
|
|
// Check if already migrated
|
|
if (localStorage.getItem(MIGRATION_FLAG)) return null
|
|
|
|
try {
|
|
const savedMessages = localStorage.getItem("next-ai-draw-io-messages")
|
|
const savedSnapshots = localStorage.getItem(
|
|
"next-ai-draw-io-xml-snapshots",
|
|
)
|
|
const savedXml = localStorage.getItem("next-ai-draw-io-diagram-xml")
|
|
|
|
let newSessionId: string | null = null
|
|
let migrationSucceeded = false
|
|
|
|
if (savedMessages) {
|
|
const messages = JSON.parse(savedMessages)
|
|
if (Array.isArray(messages) && messages.length > 0) {
|
|
const sanitized = sanitizeMessages(messages)
|
|
const session: ChatSession = {
|
|
id: nanoid(),
|
|
title: extractTitle(sanitized),
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: sanitized,
|
|
xmlSnapshots: savedSnapshots
|
|
? JSON.parse(savedSnapshots)
|
|
: [],
|
|
diagramXml: savedXml || "",
|
|
}
|
|
const saved = await saveSession(session)
|
|
if (saved) {
|
|
// Verify the session was actually written
|
|
const verified = await getSession(session.id)
|
|
if (verified) {
|
|
newSessionId = session.id
|
|
migrationSucceeded = true
|
|
}
|
|
}
|
|
} else {
|
|
// Empty array or invalid data - nothing to migrate, mark as success
|
|
migrationSucceeded = true
|
|
}
|
|
} else {
|
|
// No data to migrate - mark as success
|
|
migrationSucceeded = true
|
|
}
|
|
|
|
// Only clean up old data if migration succeeded
|
|
if (migrationSucceeded) {
|
|
localStorage.setItem(MIGRATION_FLAG, "true")
|
|
localStorage.removeItem("next-ai-draw-io-messages")
|
|
localStorage.removeItem("next-ai-draw-io-xml-snapshots")
|
|
localStorage.removeItem("next-ai-draw-io-diagram-xml")
|
|
} else {
|
|
console.warn(
|
|
"Migration to IndexedDB failed - keeping localStorage data for retry",
|
|
)
|
|
}
|
|
|
|
return newSessionId
|
|
} catch (error) {
|
|
console.error("Migration failed:", error)
|
|
// Don't mark as migrated - allow retry on next load
|
|
return null
|
|
}
|
|
}
|