Download src/components/study-chat/useStudyChatTutor.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 34.5 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/useStudyChatTutor.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/study-chat/useStudyChatTutor.ts
-
curl -L -o useStudyChatTutor.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/study-chat/useStudyChatTutor.ts
34.5 kB
| "use client"; | |
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import { useRouter, useSearchParams } from "next/navigation"; | |
| import { askDocDoe, generateBrainQuiz } from "@/lib/api/brain"; | |
| import { | |
| isChatOriginPage, | |
| sendChatMessage, | |
| streamChatMessage, | |
| type ChatOriginPage, | |
| type StudyChatAcademicContext, | |
| type StudyChatEvidence, | |
| type StudyChatMode, | |
| type WebSource, | |
| } from "@/lib/api/chat"; | |
| import { getDocument, uploadDocument } from "@/lib/api/documents"; | |
| import { isApiError, normalizeApiError } from "@/lib/api/client"; | |
| import { | |
| appendChatMessages, | |
| createChatSession, | |
| getChatSession, | |
| listChatSessions, | |
| type ChatSessionOut, | |
| } from "@/lib/api/chatHistory"; | |
| import { hasLiveSession } from "@/lib/api/mode"; | |
| import { | |
| getLocalStudentContext, | |
| loadStudentContext, | |
| type StudentContext, | |
| } from "@/lib/student-context"; | |
| import { addActivity, saveNote, saveQuiz } from "@/lib/student-activity"; | |
| import { validateStudentUpload } from "@/lib/uploads/document-upload-contract"; | |
| // ============================================================================= | |
| // Shared StudyChat tutor controller β one brain for both theme variants. | |
| // | |
| // Fixes the reliability gaps found in student testing: | |
| // - URL intent prompts (?prompt=β¦&run=1 from buildStudyIntentUrl / "Start | |
| // Today's Lesson") now auto-send instead of being silently ignored. | |
| // - A stuck "DocDoe is thinkingβ¦" can always be cancelled; failures show a | |
| // friendly error entry with one-click Retry, and the question is never lost. | |
| // - Successful answers persist to the real chat session history. | |
| // ============================================================================= | |
| let tutorEntrySequence = 0; | |
| function nextTutorEntryId() { | |
| tutorEntrySequence += 1; | |
| return `tutor-entry-${tutorEntrySequence}`; | |
| } | |
| export type TutorEntry = { id: string } & ( | |
| | { role: "user"; text: string } | |
| | { | |
| role: "assistant"; | |
| answer: string; | |
| sections: Array<{ title: string; body: string }>; | |
| suggestedActions: string[]; | |
| isFallback?: boolean; | |
| evidenceLabel?: string | null; | |
| webSources?: WebSource[]; | |
| evidence?: StudyChatEvidence | null; | |
| isStreaming?: boolean; | |
| } | |
| | { role: "error"; text: string; retryText: string } | |
| | { role: "info"; text: string } | |
| ); | |
| /** Structured upload lifecycle so the UI can show real loading/success/error/retry states. */ | |
| export type UploadState = | |
| | { phase: "idle" } | |
| | { phase: "uploading"; fileName: string } | |
| | { phase: "processing"; fileName: string; documentId: string } | |
| | { phase: "ready"; fileName: string; documentId: string } | |
| | { phase: "error"; fileName: string; message: string; documentId?: string }; | |
| type PendingHistorySync = { | |
| clientTurnId: string; | |
| question: string; | |
| answer: string; | |
| subject: string; | |
| sourceId: string | null; | |
| intent: string | null; | |
| evidenceLabel: string | null; | |
| webSources: WebSource[]; | |
| evidence: StudyChatEvidence | null; | |
| mode: StudyChatMode; | |
| contextData: StudyChatAcademicContext | null; | |
| }; | |
| function nextHistoryTurnId() { | |
| if (typeof crypto !== "undefined" && "randomUUID" in crypto) { | |
| return crypto.randomUUID(); | |
| } | |
| return `turn-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`; | |
| } | |
| function requestMode(question: string, sourceIds: string[]): StudyChatMode { | |
| if (parseCorrectionRequest(question)) return "answer_correction"; | |
| if (/\b(pyq|previous\s+year|past\s+paper)\b/i.test(question)) { | |
| return sourceIds.length ? "pyq_verified" : "pyq_style"; | |
| } | |
| return sourceIds.length ? "selected_sources" : "general_explanation"; | |
| } | |
| function parseCorrectionRequest(text: string): { question: string; studentAnswer: string } | null { | |
| const match = text.match(/Question:\s*([\s\S]+?)\n\s*My answer:\s*([\s\S]+)/i); | |
| if (!match?.[1]?.trim() || !match[2]?.trim()) return null; | |
| return { question: match[1].trim(), studentAnswer: match[2].trim() }; | |
| } | |
| // Image upload is intentionally NOT offered: production verification showed | |
| // the backend has no OCR/image-text-extraction service configured, so an | |
| // uploaded image can never reach a usable "ready" state β it always ends in | |
| // "failed". Showing a feature that cannot complete is worse than not showing | |
| // it. PDF (selectable text) is the one upload path that genuinely works end | |
| // to end. Re-enable image extensions here only once real OCR/multimodal | |
| // extraction is deployed and verified (see docs/adaptive-engine-audit.md / | |
| // memory for the OCR-unavailable finding). | |
| /** Client-side pre-check so obvious rejects fail instantly with a clear reason. */ | |
| function validateUploadFile(file: File): string | null { | |
| const validation = validateStudentUpload(file, "study-chat"); | |
| return validation.ok ? null : validation.message; | |
| } | |
| export function useStudyChatTutor(selectedSourceIds: string[] = []) { | |
| const router = useRouter(); | |
| const searchParams = useSearchParams(); | |
| const [message, setMessage] = useState(""); | |
| const [entries, setEntries] = useState<TutorEntry[]>([]); | |
| const [isAsking, setIsAsking] = useState(false); | |
| const [recentChats, setRecentChats] = useState<ChatSessionOut[]>([]); | |
| const [context, setContext] = useState<StudentContext>(() => getLocalStudentContext()); | |
| const [academicContext, setAcademicContext] = | |
| useState<StudyChatAcademicContext | null>(null); | |
| const [uploadStatus, setUploadStatus] = useState<string | null>(null); | |
| const [uploadState, setUploadState] = useState<UploadState>({ phase: "idle" }); | |
| const [historySyncError, setHistorySyncError] = useState<string | null>(null); | |
| const [historyLoadError, setHistoryLoadError] = useState<string | null>(null); | |
| const [isHistorySyncing, setIsHistorySyncing] = useState(false); | |
| const [currentSessionId, setCurrentSessionId] = useState<string | null>(null); | |
| const sessionIdRef = useRef<string | null>(null); | |
| const abortRef = useRef<AbortController | null>(null); | |
| const autoRanRef = useRef(false); | |
| // Upload poll guards: `mountedRef` stops state updates after unmount; | |
| // `uploadRunRef` is a generation token so only the most recent upload's poll | |
| // can write state β a rapid second upload supersedes the first, never runs a | |
| // duplicate loop that fights it. | |
| const mountedRef = useRef(true); | |
| const uploadRunRef = useRef(0); | |
| const pendingHistorySyncRef = useRef<PendingHistorySync[]>([]); | |
| useEffect(() => { | |
| mountedRef.current = true; | |
| return () => { | |
| mountedRef.current = false; | |
| }; | |
| }, []); | |
| /** Intent carried by the URL handoff β consumed by the next successful answer. */ | |
| const pendingIntentRef = useRef<string | null>(null); | |
| const pendingSourceIdRef = useRef<string | null>(null); | |
| const pendingOriginPageRef = useRef<ChatOriginPage | null>(null); | |
| const pendingAcademicContextRef = useRef<StudyChatAcademicContext | null>(null); | |
| const persistHistoryPair = useCallback(async (payload: PendingHistorySync) => { | |
| let activeSessionId = sessionIdRef.current; | |
| if (!activeSessionId) { | |
| const session = await createChatSession({ | |
| title: payload.question.slice(0, 60), | |
| subject: payload.contextData?.subject ?? payload.subject, | |
| source_id: payload.sourceId, | |
| context_data: payload.contextData, | |
| active_source_ids: payload.evidence?.source_ids ?? [], | |
| mode: payload.mode, | |
| }); | |
| activeSessionId = session.id; | |
| sessionIdRef.current = session.id; | |
| setCurrentSessionId(session.id); | |
| setRecentChats((prev) => [session, ...prev]); | |
| setAcademicContext(session.context_data ?? payload.contextData); | |
| } | |
| const persistedMessages = await appendChatMessages(activeSessionId, { | |
| user_content: payload.question, | |
| assistant_content: payload.answer, | |
| client_turn_id: payload.clientTurnId, | |
| intent: payload.intent ?? undefined, | |
| evidence_label: payload.evidenceLabel, | |
| web_sources: payload.webSources, | |
| evidence: payload.evidence, | |
| }); | |
| setRecentChats((prev) => { | |
| const index = prev.findIndex((session) => session.id === activeSessionId); | |
| if (index === -1) return prev; | |
| const updated: ChatSessionOut = { | |
| ...prev[index], | |
| message_count: prev[index].message_count + 2, | |
| updated_at: new Date().toISOString(), | |
| }; | |
| return [ | |
| updated, | |
| ...prev.slice(0, index), | |
| ...prev.slice(index + 1), | |
| ]; | |
| }); | |
| return persistedMessages.find((item) => item.role === "assistant")?.id ?? null; | |
| }, []); | |
| const handleSend = useCallback( | |
| async (text?: string) => { | |
| const question = (text ?? message).trim(); | |
| if (!question || abortRef.current) return; | |
| const historyTurnId = nextHistoryTurnId(); | |
| setMessage(""); | |
| setEntries((prev) => [...prev, { id: nextTutorEntryId(), role: "user", text: question }]); | |
| setIsAsking(true); | |
| const controller = new AbortController(); | |
| abortRef.current = controller; | |
| // Bind the URL handoff to this request. Failure and cancellation consume | |
| // it, so a later unrelated answer can never inherit a stale save action. | |
| const requestIntent = pendingIntentRef.current; | |
| const requestSourceId = pendingSourceIdRef.current; | |
| const requestOriginPage = pendingOriginPageRef.current; | |
| const requestAcademicContext = | |
| pendingAcademicContextRef.current ?? academicContext; | |
| const isRouteHandoff = Boolean( | |
| requestIntent || requestOriginPage || pendingAcademicContextRef.current, | |
| ); | |
| // A route handoff starts one exact academic thread. An old material that | |
| // happened to remain selected on this device must not silently ground a | |
| // Progress/Tuition/Learn Anything request. A source only crosses that | |
| // boundary when the handoff names it explicitly. | |
| const requestSourceIds = requestSourceId | |
| ? [requestSourceId] | |
| : isRouteHandoff | |
| ? [] | |
| : selectedSourceIds; | |
| const resolvedRequestMode = requestMode(question, requestSourceIds); | |
| const correctionRequest = parseCorrectionRequest(question); | |
| const requestSubject = requestAcademicContext?.subject ?? context.subject; | |
| pendingIntentRef.current = null; | |
| pendingSourceIdRef.current = null; | |
| pendingOriginPageRef.current = null; | |
| pendingAcademicContextRef.current = null; | |
| try { | |
| if (requestIntent === "generate_quiz") { | |
| const quizResponse = await generateBrainQuiz({ | |
| topic: requestAcademicContext?.topic?.trim() || question, | |
| subject: requestSubject, | |
| source_id: requestSourceIds[0] ?? null, | |
| source_ids: requestSourceIds, | |
| options: { num_questions: 5 }, | |
| }); | |
| if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError"); | |
| const questions = quizResponse.output.questions.filter( | |
| (item) => item.question.trim().length > 0 && item.answer.trim().length > 0, | |
| ); | |
| if (questions.length === 0) throw new Error("Quiz generator returned no gradable questions"); | |
| const isStarter = | |
| Boolean(quizResponse.is_fallback) || | |
| /offline|starter|sample/i.test(`${quizResponse.evidence_label ?? ""} ${quizResponse.trust_note ?? ""}`); | |
| const title = quizResponse.output.quiz_title?.trim() || `${quizResponse.topic || question} quiz`; | |
| const saved = saveQuiz({ | |
| title, | |
| topic: quizResponse.topic || question, | |
| subject: requestSubject ?? undefined, | |
| source: "study-chat", | |
| provenance: isStarter ? "starter" : "generated", | |
| questions: questions.map((item) => ({ | |
| question: item.question, | |
| answer: item.answer, | |
| topic: item.topic || quizResponse.topic || question, | |
| options: item.options, | |
| explanation: item.explanation, | |
| difficulty: item.difficulty, | |
| })), | |
| }); | |
| const answer = isStarter | |
| ? `Starter quiz saved: ${saved.title}. These are deterministic practice prompts, not a live AI result.` | |
| : `Quiz saved: ${saved.title}. Open Tests & Quizzes when you are ready to take it.`; | |
| const sections = saved.questions.map((item, index) => ({ | |
| title: `${index + 1}. ${item.question}`, | |
| body: [item.options?.length ? item.options.join("\n") : null, `Answer: ${item.answer}`, item.explanation] | |
| .filter(Boolean) | |
| .join("\n\n"), | |
| })); | |
| setEntries((prev) => [ | |
| ...prev, | |
| { | |
| id: nextTutorEntryId(), | |
| role: "assistant", | |
| answer, | |
| sections, | |
| suggestedActions: quizResponse.suggested_actions ?? [], | |
| isFallback: isStarter, | |
| }, | |
| ]); | |
| addActivity("question_asked", question, isStarter ? { sample: true } : undefined); | |
| return; | |
| } | |
| let finalAnswer = ""; | |
| let finalSections: Array<{ title: string; body: string }> = []; | |
| let finalActions: string[] = []; | |
| let finalIsFallback = false; | |
| let finalIntent: string | null = null; | |
| let finalEvidenceLabel: string | null = null; | |
| let finalWebSources: WebSource[] = []; | |
| let finalEvidence: StudyChatEvidence | null = null; | |
| if (hasLiveSession()) { | |
| let streamCompleted = false; | |
| let streamFailed = false; | |
| setEntries((prev) => [ | |
| ...prev, | |
| { id: nextTutorEntryId(), role: "assistant", answer: "", sections: [], suggestedActions: [], isStreaming: true }, | |
| ]); | |
| await streamChatMessage( | |
| { | |
| message: question, | |
| intent: requestIntent ?? undefined, | |
| subject: requestSubject ?? undefined, | |
| source_ids: requestSourceIds, | |
| origin_page: requestOriginPage ?? undefined, | |
| academic_context: requestAcademicContext ?? undefined, | |
| mode: resolvedRequestMode, | |
| correction_question: correctionRequest?.question, | |
| student_answer: correctionRequest?.studentAnswer, | |
| session_id: sessionIdRef.current ?? undefined, | |
| idempotency_key: historyTurnId, | |
| }, | |
| { | |
| onDelta: (token) => { | |
| finalAnswer += token; | |
| setEntries((prev) => { | |
| const next = [...prev]; | |
| const last = next[next.length - 1]; | |
| if (last?.role === "assistant") next[next.length - 1] = { ...last, answer: finalAnswer }; | |
| return next; | |
| }); | |
| }, | |
| onDone: (meta) => { | |
| finalIntent = meta.intent || null; | |
| finalEvidenceLabel = meta.evidenceLabel; | |
| finalWebSources = meta.webSources; | |
| finalEvidence = meta.evidence; | |
| streamCompleted = true; | |
| }, | |
| onError: () => { | |
| streamFailed = true; | |
| }, | |
| }, | |
| controller.signal, | |
| ); | |
| if (streamFailed || !streamCompleted || !finalAnswer.trim()) { | |
| setEntries((prev) => { | |
| const next = [...prev]; | |
| if (next[next.length - 1]?.role === "assistant") next.pop(); | |
| return next; | |
| }); | |
| try { | |
| // Stay on the same authoritative router when SSE is unavailable. | |
| // This preserves current-info Exa citations, uploaded-source | |
| // ownership checks, and the exact evidence label returned by | |
| // /chat instead of silently switching products to /ask. | |
| const response = await sendChatMessage( | |
| { | |
| message: question, | |
| intent: requestIntent ?? undefined, | |
| subject: requestSubject ?? undefined, | |
| source_ids: requestSourceIds, | |
| origin_page: requestOriginPage ?? undefined, | |
| academic_context: requestAcademicContext ?? undefined, | |
| mode: resolvedRequestMode, | |
| correction_question: correctionRequest?.question, | |
| student_answer: correctionRequest?.studentAnswer, | |
| session_id: sessionIdRef.current ?? undefined, | |
| idempotency_key: historyTurnId, | |
| }, | |
| { signal: controller.signal, timeoutMs: 60000 }, | |
| ); | |
| finalAnswer = response.answer; | |
| finalIntent = response.intent_detected || null; | |
| finalEvidenceLabel = response.evidence_label ?? null; | |
| finalWebSources = response.web_sources ?? []; | |
| finalEvidence = response.evidence; | |
| finalIsFallback = /mock|fallback|offline/i.test(response.model_used); | |
| } catch (error) { | |
| // A failed authoritative Study Chat request must remain failed. | |
| // Falling over to /ask would lose the server-resolved mode and | |
| // provenance envelope while presenting the result as success. | |
| throw error; | |
| } | |
| setEntries((prev) => [ | |
| ...prev, | |
| { | |
| id: nextTutorEntryId(), | |
| role: "assistant", | |
| answer: finalAnswer, | |
| sections: finalSections, | |
| suggestedActions: finalActions, | |
| isFallback: finalIsFallback, | |
| evidenceLabel: finalEvidenceLabel, | |
| webSources: finalWebSources, | |
| evidence: finalEvidence, | |
| }, | |
| ]); | |
| } else { | |
| setEntries((prev) => { | |
| const next = [...prev]; | |
| const last = next[next.length - 1]; | |
| if (last?.role === "assistant") { | |
| next[next.length - 1] = { | |
| ...last, | |
| isFallback: finalIsFallback, | |
| evidenceLabel: finalEvidenceLabel, | |
| webSources: finalWebSources, | |
| evidence: finalEvidence, | |
| isStreaming: false, | |
| }; | |
| } | |
| return next; | |
| }); | |
| } | |
| } else { | |
| const response = await askDocDoe( | |
| { | |
| question, | |
| source_id: requestSourceIds[0] ?? null, | |
| source_ids: requestSourceIds, | |
| }, | |
| { signal: controller.signal }, | |
| ); | |
| finalAnswer = response.answer; | |
| finalSections = response.sections; | |
| finalActions = response.suggested_actions; | |
| finalIsFallback = Boolean(response.is_fallback); | |
| finalEvidenceLabel = response.evidence_label ?? null; | |
| setEntries((prev) => [ | |
| ...prev, | |
| { | |
| id: nextTutorEntryId(), | |
| role: "assistant", | |
| answer: finalAnswer, | |
| sections: finalSections, | |
| suggestedActions: finalActions, | |
| isFallback: finalIsFallback, | |
| evidenceLabel: finalEvidenceLabel, | |
| }, | |
| ]); | |
| } | |
| if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError"); | |
| // Real activity: this question was actually asked and answered. | |
| addActivity("question_asked", question); | |
| // A notes-generation handoff saves its output as a real note. | |
| if (requestIntent === "generate_notes") { | |
| saveNote({ | |
| title: question.slice(0, 80), | |
| content: finalAnswer, | |
| topic: question.slice(0, 120), | |
| subject: requestSubject ?? undefined, | |
| sourceId: requestSourceIds[0] ?? undefined, | |
| source: "study-chat", | |
| provenance: finalIsFallback ? "starter" : "generated", | |
| }); | |
| } | |
| if (hasLiveSession()) { | |
| const historyPayload: PendingHistorySync = { | |
| clientTurnId: historyTurnId, | |
| question, | |
| answer: finalAnswer, | |
| subject: requestSubject ?? "", | |
| sourceId: requestSourceIds[0] ?? null, | |
| intent: finalIntent ?? requestIntent, | |
| evidenceLabel: finalEvidenceLabel, | |
| webSources: finalWebSources, | |
| evidence: finalEvidence, | |
| mode: resolvedRequestMode, | |
| contextData: requestAcademicContext, | |
| }; | |
| try { | |
| const persistedAssistantId = await persistHistoryPair(historyPayload); | |
| if (persistedAssistantId) { | |
| setEntries((current) => { | |
| const next = [...current]; | |
| for (let index = next.length - 1; index >= 0; index -= 1) { | |
| const entry = next[index]; | |
| if (entry.role === "assistant" && entry.answer === finalAnswer) { | |
| next[index] = { ...entry, id: persistedAssistantId }; | |
| break; | |
| } | |
| } | |
| return next; | |
| }); | |
| } | |
| // "N messages Β· <date>" until a full reload. | |
| } catch { | |
| pendingHistorySyncRef.current = [ | |
| ...pendingHistorySyncRef.current.filter( | |
| (item) => item.clientTurnId !== historyPayload.clientTurnId, | |
| ), | |
| historyPayload, | |
| ]; | |
| setHistorySyncError( | |
| "Your answer is ready, but it has not synced to chat history yet.", | |
| ); | |
| } | |
| } | |
| } catch (error) { | |
| if (controller.signal.aborted) { | |
| setEntries((prev) => [...prev, { id: nextTutorEntryId(), role: "info", text: "Cancelled. Your question is kept below β send it again anytime." }]); | |
| } else { | |
| const safeMessage = isApiError(error) | |
| ? `${normalizeApiError(error)}${error.requestId ? ` Reference ID: ${error.requestId}` : ""}` | |
| : "Could not get an answer. Check your connection and try again."; | |
| setEntries((prev) => [ | |
| ...prev, | |
| { | |
| id: nextTutorEntryId(), | |
| role: "error", | |
| text: safeMessage, | |
| retryText: question, | |
| }, | |
| ]); | |
| } | |
| // Either way, don't lose the student's words. | |
| setMessage(question); | |
| } finally { | |
| abortRef.current = null; | |
| setIsAsking(false); | |
| } | |
| }, | |
| [academicContext, context.subject, message, persistHistoryPair, selectedSourceIds], | |
| ); | |
| const retryHistorySync = useCallback(async () => { | |
| if (isHistorySyncing || pendingHistorySyncRef.current.length === 0) return; | |
| setIsHistorySyncing(true); | |
| const failed: PendingHistorySync[] = []; | |
| for (const pending of pendingHistorySyncRef.current) { | |
| try { | |
| await persistHistoryPair(pending); | |
| } catch { | |
| failed.push(pending); | |
| } | |
| } | |
| pendingHistorySyncRef.current = failed; | |
| setHistorySyncError( | |
| failed.length | |
| ? `${failed.length} answer${failed.length === 1 ? " is" : "s are"} still waiting to sync.` | |
| : null, | |
| ); | |
| setIsHistorySyncing(false); | |
| }, [isHistorySyncing, persistHistoryPair]); | |
| const handleCancel = useCallback(() => { | |
| abortRef.current?.abort(); | |
| }, []); | |
| const openChat = useCallback(async (sessionId: string) => { | |
| if (abortRef.current) return; | |
| const detail = await getChatSession(sessionId); | |
| sessionIdRef.current = detail.id; | |
| setCurrentSessionId(detail.id); | |
| setAcademicContext(detail.context_data); | |
| setEntries( | |
| detail.messages.map((item) => | |
| item.role === "user" | |
| ? ({ id: item.id, role: "user", text: item.content } as TutorEntry) | |
| : ({ | |
| id: item.id, | |
| role: "assistant", | |
| answer: item.content, | |
| sections: [], | |
| suggestedActions: [], | |
| evidenceLabel: item.evidence_label ?? null, | |
| webSources: item.web_sources ?? [], | |
| evidence: item.evidence ?? null, | |
| } as TutorEntry), | |
| ), | |
| ); | |
| setMessage(""); | |
| return detail; | |
| }, []); | |
| const startNewChat = useCallback(() => { | |
| if (abortRef.current) return; | |
| sessionIdRef.current = null; | |
| setCurrentSessionId(null); | |
| setEntries([]); | |
| setMessage(""); | |
| setUploadStatus(null); | |
| setAcademicContext(null); | |
| pendingAcademicContextRef.current = null; | |
| }, []); | |
| /** Save one assistant answer as a note; title comes from the question asked. */ | |
| const saveAnswerAsNote = useCallback( | |
| (title: string, content: string, options?: { isFallback?: boolean; topic?: string }) => { | |
| return saveNote({ | |
| title: title.slice(0, 80) || "StudyChat note", | |
| content, | |
| topic: options?.topic ?? title.slice(0, 120), | |
| subject: context.subject ?? undefined, | |
| source: "study-chat", | |
| provenance: options?.isFallback ? "starter" : "manual", | |
| }); | |
| }, | |
| [context.subject], | |
| ); | |
| const handleUpload = useCallback(async (file: File) => { | |
| // A new upload supersedes any in-flight poll: bump the generation and only | |
| // let this run write state while it is still the current one AND mounted. | |
| const run = ++uploadRunRef.current; | |
| const isCurrent = () => mountedRef.current && uploadRunRef.current === run; | |
| const apply = (state: UploadState, status: string) => { | |
| if (!isCurrent()) return; | |
| setUploadState(state); | |
| setUploadStatus(status); | |
| }; | |
| const rememberReadySource = (readyDocument: { | |
| id: string; | |
| title?: string | null; | |
| }) => { | |
| if (!isCurrent()) return; | |
| setAcademicContext((current) => { | |
| const next: StudyChatAcademicContext = { | |
| ...current, | |
| origin: current?.origin ?? "study-chat", | |
| subject: current?.subject ?? context.subject ?? null, | |
| source_id: readyDocument.id, | |
| source_ref: readyDocument.id, | |
| source_label: readyDocument.title || file.name, | |
| activity_type: "uploaded-material", | |
| return_href: current?.return_href ?? "/study-chat", | |
| captured_at: new Date().toISOString(), | |
| }; | |
| pendingSourceIdRef.current = readyDocument.id; | |
| pendingAcademicContextRef.current = next; | |
| return next; | |
| }); | |
| }; | |
| const clientError = validateUploadFile(file); | |
| if (clientError) { | |
| apply({ phase: "error", fileName: file.name, message: clientError }, clientError); | |
| return; | |
| } | |
| apply({ phase: "uploading", fileName: file.name }, `Uploading ${file.name}β¦`); | |
| let doc; | |
| try { | |
| doc = await uploadDocument({ file }); | |
| } catch (error) { | |
| // Surface the real reason (rate limit, size, auth) instead of swallowing it. | |
| const message = isApiError(error) | |
| ? normalizeApiError(error) | |
| : "Upload failed. Check your connection and try again."; | |
| apply({ phase: "error", fileName: file.name, message }, message); | |
| return; | |
| } | |
| if (!isCurrent()) return; // superseded or unmounted while uploading | |
| // The record exists; extraction runs async. Poll until it is usable so the | |
| // UI never claims "ready" while parsing is still in progress or has failed. | |
| apply( | |
| { phase: "processing", fileName: file.name, documentId: doc.id }, | |
| `Processing ${doc.title || file.name}β¦`, | |
| ); | |
| if (doc.status === "ready") { | |
| rememberReadySource(doc); | |
| apply( | |
| { phase: "ready", fileName: file.name, documentId: doc.id }, | |
| `${doc.title || file.name} is ready to study from.`, | |
| ); | |
| return; | |
| } | |
| for (let attempt = 0; attempt < 40; attempt++) { | |
| await new Promise((resolve) => setTimeout(resolve, 3000)); | |
| if (!isCurrent()) return; // unmounted or a newer upload took over β stop cleanly | |
| let latest; | |
| try { | |
| latest = await getDocument(doc.id); | |
| } catch { | |
| continue; // transient; keep polling | |
| } | |
| if (latest.status === "ready") { | |
| rememberReadySource(latest); | |
| apply( | |
| { phase: "ready", fileName: file.name, documentId: doc.id }, | |
| `${latest.title || file.name} is ready to study from.`, | |
| ); | |
| return; | |
| } | |
| if (latest.status === "failed") { | |
| apply( | |
| { | |
| phase: "error", | |
| fileName: file.name, | |
| documentId: doc.id, | |
| message: | |
| "DocDoe could not read this file. It may be scanned images without text, or corrupted. Try another file.", | |
| }, | |
| "Processing failed. Try another file.", | |
| ); | |
| return; | |
| } | |
| } | |
| // Still processing after the poll window β honest, not a false success. | |
| apply( | |
| { | |
| phase: "error", | |
| fileName: file.name, | |
| documentId: doc.id, | |
| message: "This file is taking longer than expected to process. It will appear in your materials when ready.", | |
| }, | |
| "Still processing β check your materials shortly.", | |
| ); | |
| }, [context.subject]); | |
| // Context + history restore. | |
| useEffect(() => { | |
| let cancelled = false; | |
| void loadStudentContext().then((merged) => { | |
| if (!cancelled) setContext(merged); | |
| }); | |
| if (!hasLiveSession()) { | |
| return () => { | |
| cancelled = true; | |
| }; | |
| } | |
| void listChatSessions() | |
| .then((sessions) => { | |
| if (cancelled) return; | |
| setRecentChats(sessions); | |
| setHistoryLoadError(null); | |
| if (autoRanRef.current) return; | |
| const mostRecent = sessions[0]; | |
| if (!mostRecent) return; | |
| return getChatSession(mostRecent.id).then((detail) => { | |
| if (cancelled) return; | |
| if (autoRanRef.current) return; | |
| sessionIdRef.current = detail.id; | |
| setCurrentSessionId(detail.id); | |
| setAcademicContext(detail.context_data); | |
| setEntries((prev) => | |
| prev.length > 0 | |
| ? prev | |
| : detail.messages.map((m) => | |
| m.role === "user" | |
| ? ({ id: m.id, role: "user", text: m.content } as TutorEntry) | |
| : ({ | |
| id: m.id, | |
| role: "assistant", | |
| answer: m.content, | |
| sections: [], | |
| suggestedActions: [], | |
| evidenceLabel: m.evidence_label ?? null, | |
| webSources: m.web_sources ?? [], | |
| evidence: m.evidence ?? null, | |
| } as TutorEntry), | |
| ), | |
| ); | |
| }); | |
| }) | |
| .catch(() => { | |
| if (cancelled) return; | |
| setHistoryLoadError( | |
| "We couldn't load your recent chat. Your saved chats are safe β refresh to try again, or start a new question below.", | |
| ); | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, []); | |
| // URL intent handoff: /study-chat?prompt=β¦&run=1 auto-sends; without run=1 | |
| // it prefills the composer. The params are stripped afterwards so a refresh | |
| // doesn't re-fire the lesson. | |
| useEffect(() => { | |
| if (autoRanRef.current) return; | |
| const requestedSessionId = searchParams.get("sessionId"); | |
| if (requestedSessionId) { | |
| autoRanRef.current = true; | |
| void openChat(requestedSessionId).catch(() => { | |
| setHistoryLoadError( | |
| "That shared chat could not open. Check your connection, then open it again from your chat list.", | |
| ); | |
| }); | |
| router.replace("/study-chat", { scroll: false }); | |
| return; | |
| } | |
| const prompt = searchParams.get("prompt"); | |
| if (!prompt) return; | |
| autoRanRef.current = true; | |
| const intent = searchParams.get("intent"); | |
| pendingIntentRef.current = intent; | |
| pendingSourceIdRef.current = searchParams.get("sourceId"); | |
| const originPage = searchParams.get("originPage"); | |
| pendingOriginPageRef.current = isChatOriginPage(originPage) ? originPage : null; | |
| const returnHref = searchParams.get("returnTo"); | |
| const handoffContext: StudyChatAcademicContext = { | |
| origin: pendingOriginPageRef.current, | |
| subject: searchParams.get("subject"), | |
| chapter: searchParams.get("chapter"), | |
| topic: searchParams.get("topic"), | |
| objective: searchParams.get("objective"), | |
| current_step: searchParams.get("currentStep"), | |
| relevant_context: searchParams.get("relevantContext"), | |
| source_id: pendingSourceIdRef.current, | |
| source_ref: searchParams.get("sourceRef"), | |
| source_label: searchParams.get("sourceLabel"), | |
| activity_type: searchParams.get("activity"), | |
| question_id: searchParams.get("questionId"), | |
| question_label: searchParams.get("questionLabel") ?? prompt, | |
| return_href: | |
| returnHref?.startsWith("/") && !returnHref.startsWith("//") | |
| ? returnHref | |
| : null, | |
| captured_at: new Date().toISOString(), | |
| }; | |
| if (Object.values(handoffContext).some(Boolean)) { | |
| pendingAcademicContextRef.current = handoffContext; | |
| setAcademicContext(handoffContext); | |
| // A route handoff is one academic thread, not an unrelated append to the | |
| // last chat that happened to be open on this device. | |
| sessionIdRef.current = null; | |
| setCurrentSessionId(null); | |
| setEntries([]); | |
| } | |
| const shouldRun = searchParams.get("run") === "1"; | |
| // A teach_topic handoff that auto-runs IS a lesson starting β record it | |
| // here so every entry point (Learn Anything, Tuition, Home) counts once. | |
| if (shouldRun && intent === "teach_topic") { | |
| // "Teach me X β today's lesson: Y. Cover: β¦" β keep only the readable head. | |
| addActivity("lesson_started", prompt.split(". Cover")[0].slice(0, 90)); | |
| } | |
| // Microtask (not setTimeout): router.replace below changes searchParams, | |
| // which re-runs this effect β a timer would be cancelled by the cleanup | |
| // before it ever fired. A microtask runs before that re-render. | |
| void Promise.resolve().then(() => { | |
| if (shouldRun) { | |
| void handleSend(prompt); | |
| } else { | |
| setMessage(prompt); | |
| } | |
| }); | |
| router.replace("/study-chat", { scroll: false }); | |
| }, [searchParams, router, handleSend, openChat]); | |
| return { | |
| message, | |
| setMessage, | |
| entries, | |
| isAsking, | |
| recentChats, | |
| context, | |
| academicContext, | |
| currentSessionId, | |
| uploadStatus, | |
| uploadState, | |
| historySyncError, | |
| historyLoadError, | |
| isHistorySyncing, | |
| handleSend, | |
| handleCancel, | |
| handleUpload, | |
| retryHistorySync, | |
| openChat, | |
| startNewChat, | |
| saveAnswerAsNote, | |
| }; | |
| } | |