mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-05 15:52:33 +08:00
* feat(session): add chat session history with IndexedDB storage - Add session-storage.ts with IndexedDB wrapper using idb library - Add use-session-manager.ts hook for session state management - Add session-history-dropdown.tsx for session selection UI - Integrate session system into chat-panel.tsx - Auto-generate session titles from first user message - Auto-save sessions on message completion - Support session switching, deletion, and creation - Migrate existing localStorage data to IndexedDB - Add i18n translations for session history UI * feat(session): improve history dropdown and persist diagram history - Add time-based grouping (Today, Yesterday, This Week, Earlier) - Add thumbnail previews using Next.js Image component - Add staggered entrance animations with fade-in effects - Improve active session indicator with left border accent - Fix scrolling by using native overflow instead of ScrollArea - Persist diagram version history to IndexedDB sessions - Remove redundant diagram XML from localStorage - Add i18n strings for time group labels (en, ja, zh) * fix(session): prevent data loss on theme change and tab close - Add isDrawioReady effect to restore diagram after DrawIO remount - Add visibilitychange handler to save session when page becomes hidden - Fix missing currentSessionId in saveCurrentSession dependency array - Remove unused sanitizeMessages import from use-session-manager * fix(session): fix diagram save and migration data loss bugs - Add diagramHistory to save effect dependency array so diagram-only edits trigger saves (previously only message changes did) - Destructure stable sessionManager values to prevent unnecessary effect re-runs on every render - Add try-catch wrapper around debounced async save operation - Make saveSession() return boolean to indicate success/failure - Verify IndexedDB write succeeded before deleting localStorage data during migration (prevents data loss if write silently fails) - Keep localStorage data for retry if migration fails instead of marking as complete anyway * refactor(session): extract helpers to reduce code duplication - Add syncUIWithSession helper to consolidate 4 duplicate UI sync blocks - Add buildSessionData helper to consolidate 4 duplicate save logic blocks - Remove unused saveTimeoutRef and its cleanup effect - Net reduction of ~80 lines of duplicate code * style(ui): improve history dropdown and delete dialog styling - Change destructive color from coral to muted rose for refined look - Make session history panel taller (400px fixed height) - Fix popover alignment to prevent truncation - Style delete button with soft red outline instead of solid fill - Make delete dialog more compact (max-w-sm) * fix(session): reset refs on new chat and show recent sessions - Fix cached example diagrams not displaying after creating new session - Reset previousXML, lastProcessedXmlRef and processedToolCalls when messages become empty (new chat or session switch) - Add recent chats section in empty chat state with collapsible examples - Pass sessions and onSelectSession to ChatMessageDisplay - Add loadedMessageIdsRef to skip animations on session restore - Add debug console.log for diagram processing flow * feat(session): add search bar and improve history UI - Remove session history dropdown, use main panel instead - Add search bar to filter history chats by title - Show minutes (Xm ago) instead of "Just now" for recent sessions - Scroll to top when switching to new/empty chat - Remove title truncation limit for better searchability - Remove debug console.log statements * refactor: remove redundant code and fix nested button hydration error - Remove unused 'sessions' from deleteSession dependency array - Remove unused 'switchedTo' variable and simplify return type - Remove unused 'restoredMessageIdsRef' (always empty) - Fix nested button hydration error by using div with role=button - Simplify handleDeleteSession callback * fix(session): fix migration bug, improve metadata perf, truncate titles - Fix migration retry loop when localStorage has empty array - Use cursor-based iteration for getAllSessionMetadata - Truncate session titles to 100 chars with ellipsis * refactor: remove dead code and extract diagram length constant - Remove unused exports: getAllSessions, createNewSession, updateSessionTitle - Remove write-only CURRENT_SESSION_KEY and all localStorage calls - Remove dead messagesEndRef and unused scroll effect - Extract magic number 300 to MIN_REAL_DIAGRAM_LENGTH constant - Add isRealDiagram() helper function for semantic clarity
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
|
import { nanoid } from "nanoid"
|
|
|
|
// Constants
|
|
const DB_NAME = "next-ai-drawio"
|
|
const DB_VERSION = 1
|
|
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 }
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
// Future migrations: if (oldVersion < 2) { ... }
|
|
},
|
|
})
|
|
}
|
|
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
|
|
}
|
|
}
|