mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-07 08:42:28 +08:00
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,
|
||
|
|
}
|
||
|
|
}
|