diff --git a/app/rgpv/[branch]/[semester]/[subject]/ai/page.tsx b/app/rgpv/[branch]/[semester]/[subject]/ai/page.tsx index 1fb3658..c7e15b2 100644 --- a/app/rgpv/[branch]/[semester]/[subject]/ai/page.tsx +++ b/app/rgpv/[branch]/[semester]/[subject]/ai/page.tsx @@ -130,6 +130,7 @@ export default async function AIPage({ params, searchParams }: AIPageProps) {
0, + skipPersist: true, + explanationRequested: true, + }; + } + + if (cachedExplanation) { + return { + ...empty, + topicExplanation: cachedExplanation, + messages: [buildInitialExplanationMessage(cachedExplanation)], + explanationRequested: true, + }; + } + + return empty; +} + export default function WorkspaceChat({ subjectCode, branch, @@ -54,35 +134,46 @@ export default function WorkspaceChat({ topicId && branch && semester && topicTitle && moduleTitle ); + const [initialWorkspace] = useState(() => + getWorkspaceInitialState({ + topicId, + branch, + semester, + subjectCode, + cachedExplanation, + }) + ); + const [topicExplanation, setTopicExplanation] = useState( - cachedExplanation ?? "" + initialWorkspace.topicExplanation ); const [explanationCached, setExplanationCached] = useState( initialExplanationCached ); const [explanationLoading, setExplanationLoading] = useState(false); - - const isFollowupMode = isTopicContextReady && Boolean(topicExplanation); - - const [messages, setMessages] = useState(() => { - if (!cachedExplanation) return []; - - return [ - { - id: "initial-cached-explanation", - role: "assistant", - content: cachedExplanation, - timestamp: new Date(), - }, - ]; - }); + const [messages, setMessages] = useState( + initialWorkspace.messages + ); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [lastStudiedAt, setLastStudiedAt] = useState( + initialWorkspace.lastStudiedAt + ); + const [sessionSaved, setSessionSaved] = useState( + initialWorkspace.sessionSaved + ); + const [showContinueLearning, setShowContinueLearning] = useState( + initialWorkspace.showContinueLearning + ); const messagesEndRef = useRef(null); const inputRef = useRef(null); const abortControllerRef = useRef(null); + const explanationRequestedRef = useRef(initialWorkspace.explanationRequested); + const skipPersistRef = useRef(initialWorkspace.skipPersist); + + const isFollowupMode = isTopicContextReady && Boolean(topicExplanation); const activeExplanation = topicExplanation || @@ -95,26 +186,24 @@ export default function WorkspaceChat({ followUpCount >= followupLimit && !explanationLoading; const messageCount = messages.length; + const hasConversation = followUpCount > 0; - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }; + const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { + messagesEndRef.current?.scrollIntoView({ behavior }); + }, []); useEffect(() => { scrollToBottom(); - }, [messageCount, limitReached, loading]); + }, [messageCount, limitReached, loading, scrollToBottom]); useEffect(() => { inputRef.current?.focus(); }, []); - const explanationRequestedRef = useRef(false); - useEffect(() => { if ( !isTopicContextReady || topicExplanation || - cachedExplanation || explanationRequestedRef.current ) { return; @@ -150,14 +239,7 @@ export default function WorkspaceChat({ setTopicExplanation(data.answer); setExplanationCached(data.cached); - setMessages([ - { - id: "initial-cached-explanation", - role: "assistant", - content: data.answer, - timestamp: new Date(), - }, - ]); + setMessages([buildInitialExplanationMessage(data.answer)]); } catch (err: unknown) { if (!cancelled) { setError( @@ -181,7 +263,6 @@ export default function WorkspaceChat({ }, [ apiEndpoint, branch, - cachedExplanation, isTopicContextReady, semester, subjectCode, @@ -189,6 +270,48 @@ export default function WorkspaceChat({ topicId, ]); + useEffect(() => { + if ( + !isTopicContextReady || + !topicId || + !branch || + !semester || + !activeExplanation + ) { + return; + } + + if (skipPersistRef.current) { + skipPersistRef.current = false; + return; + } + + const now = new Date().toISOString(); + + saveSession({ + topicId, + branch, + semester, + subjectCode, + cachedExplanation: activeExplanation, + messages: toStoredMessages(messages), + questionCount: followUpCount, + updatedAt: now, + }); + + setLastStudiedAt(now); + setSessionSaved(true); + }, [ + activeExplanation, + branch, + followUpCount, + isTopicContextReady, + messages, + semester, + subjectCode, + topicId, + ]); + const sendMessage = async (content: string) => { if (!content.trim() || limitReached) return; @@ -197,6 +320,8 @@ export default function WorkspaceChat({ return; } + setShowContinueLearning(false); + const userMessage: Message = { id: crypto.randomUUID(), role: "user", @@ -308,6 +433,11 @@ export default function WorkspaceChat({ } }; + const handleContinueLearning = () => { + setShowContinueLearning(false); + scrollToBottom(); + }; + const followupPlaceholder = isFollowupMode ? `Ask a follow-up about ${topicTitle}...` : inputPlaceholder; @@ -323,15 +453,27 @@ export default function WorkspaceChat({ ? `${followupLimit} / ${followupLimit} Completed` : `${followUpCount} / ${followupLimit}`; + const lastStudied = lastStudiedAt ? formatLastStudied(lastStudiedAt) : null; + return (
-
-

Hyper AI Workspace

-

+

+
+

+ Hyper AI Workspace +

+ {sessionSaved && isTopicContextReady && ( + + + Saved on this device + + )} +
+

{isFollowupMode ? topicTitle : `Ask questions about ${subjectCode.toUpperCase()}`} @@ -363,6 +505,15 @@ export default function WorkspaceChat({ transition={{ duration: 0.35, ease: "easeOut" }} />

+ {lastStudied && ( +

+ Last studied{" "} + + {lastStudied.prefix} + {lastStudied.detail ? ` ยท ${lastStudied.detail}` : ""} + +

+ )}
)} @@ -404,6 +555,29 @@ export default function WorkspaceChat({
) : ( <> + {showContinueLearning && lastStudiedAt && hasConversation && ( + +

+ Continue Learning +

+

+ Last opened {formatContinueLearningLabel(lastStudiedAt)}. +

+ +
+ )} + {isTopicContextReady && explanationCached !== undefined && (

{explanationLoading @@ -454,6 +628,20 @@ export default function WorkspaceChat({ )} + {isFollowupMode && + !hasConversation && + !explanationLoading && + !limitReached && ( +

+

+ Start asking questions about this topic. +

+

+ Your progress will be remembered on this device. +

+
+ )} + {isFollowupMode && !limitReached && !explanationLoading && diff --git a/lib/ai/session-service.ts b/lib/ai/session-service.ts new file mode 100644 index 0000000..d7ab20d --- /dev/null +++ b/lib/ai/session-service.ts @@ -0,0 +1,262 @@ +import type { + ChatMessage, + LearningSession, + StoredChatMessage, +} from "@/types/ai"; + +export const SESSION_KEY_PREFIX = "hyper-ai:"; +export const SESSION_INDEX_KEY = "hyper-ai:__index__"; +export const SESSION_MAX_COUNT = 100; +export const SESSION_EXPIRY_DAYS = 30; + +interface SessionIndexEntry { + topicId: string; + updatedAt: string; +} + +function isBrowser(): boolean { + return typeof window !== "undefined" && typeof localStorage !== "undefined"; +} + +export function buildSessionKey(topicId: string): string { + return `${SESSION_KEY_PREFIX}${topicId}`; +} + +function readIndex(): SessionIndexEntry[] { + if (!isBrowser()) return []; + + try { + const raw = localStorage.getItem(SESSION_INDEX_KEY); + if (!raw) return []; + + const parsed = JSON.parse(raw) as SessionIndexEntry[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function writeIndex(entries: SessionIndexEntry[]): void { + if (!isBrowser()) return; + localStorage.setItem(SESSION_INDEX_KEY, JSON.stringify(entries)); +} + +function removeSessionKey(topicId: string): void { + if (!isBrowser()) return; + localStorage.removeItem(buildSessionKey(topicId)); +} + +function enforceMaxSessions(index: SessionIndexEntry[]): SessionIndexEntry[] { + const sorted = [...index].sort( + (a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() + ); + + while (sorted.length > SESSION_MAX_COUNT) { + const oldest = sorted.shift(); + if (oldest) { + removeSessionKey(oldest.topicId); + } + } + + return sorted; +} + +export function cleanupExpiredSessions(): void { + if (!isBrowser()) return; + + const cutoff = Date.now() - SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000; + const index = readIndex(); + const kept: SessionIndexEntry[] = []; + + for (const entry of index) { + if (new Date(entry.updatedAt).getTime() < cutoff) { + removeSessionKey(entry.topicId); + } else { + kept.push(entry); + } + } + + writeIndex(kept); +} + +export function loadSession(topicId: string): LearningSession | null { + if (!isBrowser() || !topicId) return null; + + cleanupExpiredSessions(); + + try { + const raw = localStorage.getItem(buildSessionKey(topicId)); + if (!raw) return null; + + const session = JSON.parse(raw) as LearningSession; + + if ( + !session?.topicId || + !session.cachedExplanation || + !Array.isArray(session.messages) + ) { + return null; + } + + const cutoff = Date.now() - SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000; + if (new Date(session.updatedAt).getTime() < cutoff) { + deleteSession(topicId); + return null; + } + + return session; + } catch { + return null; + } +} + +export function saveSession(session: LearningSession): void { + if (!isBrowser() || !session.topicId) return; + + cleanupExpiredSessions(); + + const updatedAt = session.updatedAt || new Date().toISOString(); + const payload: LearningSession = { + ...session, + updatedAt, + questionCount: session.messages.filter((m) => m.role === "user").length, + }; + + localStorage.setItem( + buildSessionKey(session.topicId), + JSON.stringify(payload) + ); + + const index = readIndex().filter( + (entry) => entry.topicId !== session.topicId + ); + index.push({ topicId: session.topicId, updatedAt }); + writeIndex(enforceMaxSessions(index)); +} + +export function deleteSession(topicId: string): void { + if (!isBrowser() || !topicId) return; + + removeSessionKey(topicId); + writeIndex(readIndex().filter((entry) => entry.topicId !== topicId)); +} + +export function sessionMatchesContext( + session: LearningSession, + context: { + branch: string; + semester: string; + subjectCode: string; + } +): boolean { + return ( + session.branch === context.branch && + session.semester === context.semester && + session.subjectCode === context.subjectCode + ); +} + +export function toStoredMessages( + messages: Array<{ + id: string; + role: "user" | "assistant"; + content: string; + timestamp: Date; + }> +): StoredChatMessage[] { + return messages.map((message) => ({ + id: message.id, + role: message.role, + content: message.content, + timestamp: message.timestamp.toISOString(), + })); +} + +export function fromStoredMessages(messages: StoredChatMessage[]): Array<{ + id: string; + role: "user" | "assistant"; + content: string; + timestamp: Date; +}> { + return messages.map((message) => ({ + id: message.id, + role: message.role, + content: message.content, + timestamp: new Date(message.timestamp), + })); +} + +export function toChatMessages(messages: StoredChatMessage[]): ChatMessage[] { + return messages.map((message) => ({ + role: message.role, + content: message.content, + })); +} + +export interface LastStudiedLabel { + prefix: string; + detail: string; +} + +export function formatLastStudied(updatedAt: string): LastStudiedLabel { + const date = new Date(updatedAt); + const now = new Date(); + + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + ); + const startOfDate = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ); + + const dayDiff = Math.round( + (startOfToday.getTime() - startOfDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + const timeDetail = date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + + if (dayDiff === 0) { + return { prefix: "Today", detail: timeDetail }; + } + + if (dayDiff === 1) { + return { prefix: "Yesterday", detail: timeDetail }; + } + + if (dayDiff < 14) { + return { prefix: `${dayDiff} days ago`, detail: "" }; + } + + const weeks = Math.floor(dayDiff / 7); + if (weeks < 8) { + return { + prefix: `${weeks} week${weeks === 1 ? "" : "s"} ago`, + detail: "", + }; + } + + return { + prefix: date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }), + detail: "", + }; +} + +export function formatContinueLearningLabel(updatedAt: string): string { + const { prefix } = formatLastStudied(updatedAt); + + if (prefix === "Today") return "earlier today"; + if (prefix === "Yesterday") return "yesterday"; + + return prefix.toLowerCase(); +} diff --git a/types/ai.ts b/types/ai.ts index 5f88aa8..3cbc56f 100644 --- a/types/ai.ts +++ b/types/ai.ts @@ -140,3 +140,25 @@ export interface FollowupResponse { answer?: string; error?: string; } + +// ========================= +// Local Learning Session +// ========================= + +export interface StoredChatMessage { + id: string; + role: "user" | "assistant"; + content: string; + timestamp: string; +} + +export interface LearningSession { + topicId: string; + branch: string; + semester: string; + subjectCode: string; + cachedExplanation: string; + messages: StoredChatMessage[]; + questionCount: number; + updatedAt: string; +}