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
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import {
|
|
type ChatSession,
|
|
createEmptySession,
|
|
deleteSession as deleteSessionFromDB,
|
|
enforceSessionLimit,
|
|
extractTitle,
|
|
getAllSessionMetadata,
|
|
getSession,
|
|
isIndexedDBAvailable,
|
|
migrateFromLocalStorage,
|
|
type SessionMetadata,
|
|
type StoredMessage,
|
|
saveSession,
|
|
} from "@/lib/session-storage"
|
|
|
|
export interface SessionData {
|
|
messages: StoredMessage[]
|
|
xmlSnapshots: [number, string][]
|
|
diagramXml: string
|
|
thumbnailDataUrl?: string
|
|
diagramHistory?: { svg: string; xml: string }[]
|
|
}
|
|
|
|
export interface UseSessionManagerReturn {
|
|
// State
|
|
sessions: SessionMetadata[]
|
|
currentSessionId: string | null
|
|
currentSession: ChatSession | null
|
|
isLoading: boolean
|
|
isAvailable: boolean
|
|
|
|
// Actions
|
|
switchSession: (id: string) => Promise<SessionData | null>
|
|
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
|
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
|
|
saveCurrentSession: (
|
|
data: SessionData,
|
|
forSessionId?: string | null,
|
|
) => Promise<void>
|
|
refreshSessions: () => Promise<void>
|
|
clearCurrentSession: () => void
|
|
}
|
|
|
|
interface UseSessionManagerOptions {
|
|
/** Session ID from URL param - if provided, load this session; if null, start blank */
|
|
initialSessionId?: string | null
|
|
}
|
|
|
|
export function useSessionManager(
|
|
options: UseSessionManagerOptions = {},
|
|
): UseSessionManagerReturn {
|
|
const { initialSessionId } = options
|
|
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
|
null,
|
|
)
|
|
const [currentSession, setCurrentSession] = useState<ChatSession | null>(
|
|
null,
|
|
)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isAvailable, setIsAvailable] = useState(false)
|
|
|
|
const isInitializedRef = useRef(false)
|
|
// Sequence guard for URL changes - prevents out-of-order async resolution
|
|
const urlChangeSequenceRef = useRef(0)
|
|
|
|
// Load sessions list
|
|
const refreshSessions = useCallback(async () => {
|
|
if (!isIndexedDBAvailable()) return
|
|
try {
|
|
const metadata = await getAllSessionMetadata()
|
|
setSessions(metadata)
|
|
} catch (error) {
|
|
console.error("Failed to refresh sessions:", error)
|
|
}
|
|
}, [])
|
|
|
|
// Initialize on mount
|
|
useEffect(() => {
|
|
if (isInitializedRef.current) return
|
|
isInitializedRef.current = true
|
|
|
|
async function init() {
|
|
setIsLoading(true)
|
|
|
|
if (!isIndexedDBAvailable()) {
|
|
setIsAvailable(false)
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
setIsAvailable(true)
|
|
|
|
try {
|
|
// Run migration first (one-time conversion from localStorage)
|
|
await migrateFromLocalStorage()
|
|
|
|
// Load sessions list
|
|
const metadata = await getAllSessionMetadata()
|
|
setSessions(metadata)
|
|
|
|
// Only load a session if initialSessionId is provided (from URL param)
|
|
if (initialSessionId) {
|
|
const session = await getSession(initialSessionId)
|
|
if (session) {
|
|
setCurrentSession(session)
|
|
setCurrentSessionId(session.id)
|
|
}
|
|
// If session not found, stay in blank state (URL has invalid session ID)
|
|
}
|
|
// If no initialSessionId, start with blank state (no auto-restore)
|
|
} catch (error) {
|
|
console.error("Failed to initialize session manager:", error)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
init()
|
|
}, [initialSessionId])
|
|
|
|
// Handle URL session ID changes after initialization
|
|
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
|
// when clearCurrentSession() is called before URL updates
|
|
useEffect(() => {
|
|
if (!isInitializedRef.current) return // Wait for initial load
|
|
if (!isAvailable) return
|
|
|
|
// Increment sequence to invalidate any pending async operations
|
|
urlChangeSequenceRef.current++
|
|
const currentSequence = urlChangeSequenceRef.current
|
|
|
|
async function handleSessionIdChange() {
|
|
if (initialSessionId) {
|
|
// URL has session ID - load it
|
|
const session = await getSession(initialSessionId)
|
|
|
|
// Check if this request is still the latest (sequence guard)
|
|
// If not, a newer URL change happened while we were loading
|
|
if (currentSequence !== urlChangeSequenceRef.current) {
|
|
return
|
|
}
|
|
|
|
if (session) {
|
|
// Only update if the session is different from current
|
|
setCurrentSessionId((current) => {
|
|
if (current !== session.id) {
|
|
setCurrentSession(session)
|
|
return session.id
|
|
}
|
|
return current
|
|
})
|
|
}
|
|
}
|
|
// Removed: else clause that clears session
|
|
// Clearing is now handled explicitly by clearCurrentSession()
|
|
// This prevents race conditions when URL update is async
|
|
}
|
|
|
|
handleSessionIdChange()
|
|
}, [initialSessionId, isAvailable])
|
|
|
|
// Refresh sessions on window focus (multi-tab sync)
|
|
useEffect(() => {
|
|
const handleFocus = () => {
|
|
refreshSessions()
|
|
}
|
|
window.addEventListener("focus", handleFocus)
|
|
return () => window.removeEventListener("focus", handleFocus)
|
|
}, [refreshSessions])
|
|
|
|
// Switch to a different session
|
|
const switchSession = useCallback(
|
|
async (id: string): Promise<SessionData | null> => {
|
|
if (id === currentSessionId) return null
|
|
|
|
// Save current session first if it has messages
|
|
if (currentSession && currentSession.messages.length > 0) {
|
|
await saveSession(currentSession)
|
|
}
|
|
|
|
// Load the target session
|
|
const session = await getSession(id)
|
|
if (!session) {
|
|
console.error("Session not found:", id)
|
|
return null
|
|
}
|
|
|
|
// Update state
|
|
setCurrentSession(session)
|
|
setCurrentSessionId(session.id)
|
|
|
|
return {
|
|
messages: session.messages,
|
|
xmlSnapshots: session.xmlSnapshots,
|
|
diagramXml: session.diagramXml,
|
|
thumbnailDataUrl: session.thumbnailDataUrl,
|
|
diagramHistory: session.diagramHistory,
|
|
}
|
|
},
|
|
[currentSessionId, currentSession],
|
|
)
|
|
|
|
// Delete a session
|
|
const deleteSession = useCallback(
|
|
async (id: string): Promise<{ wasCurrentSession: boolean }> => {
|
|
const wasCurrentSession = id === currentSessionId
|
|
await deleteSessionFromDB(id)
|
|
|
|
// If deleting current session, clear state (caller will show new empty session)
|
|
if (wasCurrentSession) {
|
|
setCurrentSession(null)
|
|
setCurrentSessionId(null)
|
|
}
|
|
|
|
await refreshSessions()
|
|
|
|
return { wasCurrentSession }
|
|
},
|
|
[currentSessionId, refreshSessions],
|
|
)
|
|
|
|
// Save current session data (debounced externally by caller)
|
|
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
|
|
const saveCurrentSession = useCallback(
|
|
async (
|
|
data: SessionData,
|
|
forSessionId?: string | null,
|
|
): Promise<void> => {
|
|
// If forSessionId is provided, verify it matches current session
|
|
// This prevents stale debounced saves from overwriting a newly switched session
|
|
if (
|
|
forSessionId !== undefined &&
|
|
forSessionId !== currentSessionId
|
|
) {
|
|
return
|
|
}
|
|
|
|
if (!currentSession) {
|
|
// Create a new session if none exists
|
|
const newSession: ChatSession = {
|
|
...createEmptySession(),
|
|
messages: data.messages,
|
|
xmlSnapshots: data.xmlSnapshots,
|
|
diagramXml: data.diagramXml,
|
|
thumbnailDataUrl: data.thumbnailDataUrl,
|
|
diagramHistory: data.diagramHistory,
|
|
title: extractTitle(data.messages),
|
|
}
|
|
await saveSession(newSession)
|
|
await enforceSessionLimit()
|
|
setCurrentSession(newSession)
|
|
setCurrentSessionId(newSession.id)
|
|
await refreshSessions()
|
|
return
|
|
}
|
|
|
|
// Update existing session
|
|
const updatedSession: ChatSession = {
|
|
...currentSession,
|
|
messages: data.messages,
|
|
xmlSnapshots: data.xmlSnapshots,
|
|
diagramXml: data.diagramXml,
|
|
thumbnailDataUrl:
|
|
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
|
|
diagramHistory:
|
|
data.diagramHistory ?? currentSession.diagramHistory,
|
|
updatedAt: Date.now(),
|
|
// Update title if it's still default and we have messages
|
|
title:
|
|
currentSession.title === "New Chat" &&
|
|
data.messages.length > 0
|
|
? extractTitle(data.messages)
|
|
: currentSession.title,
|
|
}
|
|
|
|
await saveSession(updatedSession)
|
|
setCurrentSession(updatedSession)
|
|
|
|
// Update sessions list metadata
|
|
setSessions((prev) =>
|
|
prev.map((s) =>
|
|
s.id === updatedSession.id
|
|
? {
|
|
...s,
|
|
title: updatedSession.title,
|
|
updatedAt: updatedSession.updatedAt,
|
|
messageCount: updatedSession.messages.length,
|
|
hasDiagram:
|
|
!!updatedSession.diagramXml &&
|
|
updatedSession.diagramXml.trim().length > 0,
|
|
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
|
|
}
|
|
: s,
|
|
),
|
|
)
|
|
},
|
|
[currentSession, currentSessionId, refreshSessions],
|
|
)
|
|
|
|
// Clear current session state (for starting fresh without loading another session)
|
|
const clearCurrentSession = useCallback(() => {
|
|
setCurrentSession(null)
|
|
setCurrentSessionId(null)
|
|
}, [])
|
|
|
|
return {
|
|
sessions,
|
|
currentSessionId,
|
|
currentSession,
|
|
isLoading,
|
|
isAvailable,
|
|
switchSession,
|
|
deleteSession,
|
|
saveCurrentSession,
|
|
refreshSessions,
|
|
clearCurrentSession,
|
|
}
|
|
}
|