From 7683a5c278dfbcfb4e6131740a1559c4e07c0d88 Mon Sep 17 00:00:00 2001 From: 1shCha Date: Fri, 20 Feb 2026 21:41:27 -0500 Subject: [PATCH 1/2] quiz generation improved --- package.json | 1 + src/lib/ai/tools/quiz-tools.ts | 24 ++++++++++++++---------- src/lib/ai/workers/quiz-worker.ts | 11 ++++++++--- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 01f088b3..ae4b7002 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "sonner": "^2.0.7", "streamdown": "^2.2.0", "tailwind-merge": "^3.4.0", + "thinkex": "link:", "tw-shimmer": "^0.4.6", "zod": "^4.3.6", "zustand": "^5.0.11" diff --git a/src/lib/ai/tools/quiz-tools.ts b/src/lib/ai/tools/quiz-tools.ts index 4c2a6af2..a5629627 100644 --- a/src/lib/ai/tools/quiz-tools.ts +++ b/src/lib/ai/tools/quiz-tools.ts @@ -19,15 +19,16 @@ export function createQuizTool(ctx: WorkspaceToolContext) { description: "Create an interactive quiz. Extract topic from user message. Use selected cards as context if available.", inputSchema: zodSchema( z.object({ - topic: z.string().optional().describe("The topic for the quiz - extract from user's message"), + topic: z.string().optional().describe("The topic or specific focus instructions for the quiz - extract from user's message"), contextContent: z.string().optional().describe("Content from selected cards in system context if available"), + questionCount: z.number().int().min(1).max(50).optional().describe("The specific number of questions requested by the user, if provided"), sourceCardIds: z.array(z.string()).optional().describe("IDs of source cards"), sourceCardNames: z.array(z.string()).optional().describe("Names of source cards"), }) ), - execute: async (input: { topic?: string; contextContent?: string; sourceCardIds?: string[]; sourceCardNames?: string[] }) => { - const { topic, contextContent, sourceCardIds, sourceCardNames } = input; - logger.debug("🎯 [CREATE-QUIZ] Tool execution started:", { topic, hasContext: !!contextContent }); + execute: async (input: { topic?: string; contextContent?: string; questionCount?: number; sourceCardIds?: string[]; sourceCardNames?: string[] }) => { + const { topic, contextContent, questionCount, sourceCardIds, sourceCardNames } = input; + logger.debug("🎯 [CREATE-QUIZ] Tool execution started:", { topic, questionCount, hasContext: !!contextContent }); if (!ctx.workspaceId) { return { @@ -50,6 +51,7 @@ export function createQuizTool(ctx: WorkspaceToolContext) { contextLength: contextContent?.length, sourceCardNames, sourceCardIds, + questionCount, providedTopic: topic, }); @@ -57,7 +59,7 @@ export function createQuizTool(ctx: WorkspaceToolContext) { const quizResult = await quizWorker({ topic: topic || undefined, contextContent, - questionCount: 5, + questionCount: questionCount ?? 5, sourceCardIds, sourceCardNames, }); @@ -118,14 +120,15 @@ export function createUpdateQuizTool(ctx: WorkspaceToolContext) { title: z.string().optional().describe("New title for the quiz. If not provided, the existing title will be preserved."), topic: z.string().optional().describe("New topic for questions"), contextContent: z.string().optional().describe("Content from newly selected cards in system context"), + questionCount: z.number().int().min(1).max(50).optional().describe("The specific number of questions to add, if provided"), sourceCardIds: z.array(z.string()).optional().describe("IDs of source cards"), sourceCardNames: z.array(z.string()).optional().describe("Names of source cards"), }) ), - execute: async (input: { quizName: string; title?: string; topic?: string; contextContent?: string; sourceCardIds?: string[]; sourceCardNames?: string[] }) => { - const { quizName, title, topic: explicitTopic, contextContent, sourceCardIds, sourceCardNames } = input; + execute: async (input: { quizName: string; title?: string; topic?: string; contextContent?: string; questionCount?: number; sourceCardIds?: string[]; sourceCardNames?: string[] }) => { + const { quizName, title, topic: explicitTopic, contextContent, questionCount, sourceCardIds, sourceCardNames } = input; - logger.debug("🎯 [UPDATE-QUIZ] Tool execution started:", { quizName, explicitTopic, title }); + logger.debug("🎯 [UPDATE-QUIZ] Tool execution started:", { quizName, explicitTopic, questionCount, title }); if (!ctx.workspaceId) { return { @@ -223,13 +226,14 @@ export function createUpdateQuizTool(ctx: WorkspaceToolContext) { logger.debug("🎯 [UPDATE-QUIZ] Configuration:", { topic, hasContext: !!contextContent, + questionCount, sourceCards: sourceCardNames, isBlankQuiz, }); // For quizzes WITH existing questions: require explicit topic/context to add more // For BLANK quizzes: always generate questions using the topic (even if just quiz name) - if (!isBlankQuiz && !explicitTopic && !contextContent && !sourceCardIds) { + if (!isBlankQuiz && !explicitTopic && !contextContent && !sourceCardIds && questionCount === undefined) { return { success: true, quizId, @@ -249,7 +253,7 @@ export function createUpdateQuizTool(ctx: WorkspaceToolContext) { const quizResult = await quizWorker({ topic, contextContent, - questionCount: 5, + questionCount: questionCount ?? 5, existingQuestions, performanceTelemetry, sourceCardIds, diff --git a/src/lib/ai/workers/quiz-worker.ts b/src/lib/ai/workers/quiz-worker.ts index 58596278..4e75e8b0 100644 --- a/src/lib/ai/workers/quiz-worker.ts +++ b/src/lib/ai/workers/quiz-worker.ts @@ -5,10 +5,10 @@ import { logger } from "@/lib/utils/logger"; import { QuizQuestion, QuestionType } from "@/lib/workspace-state/types"; import { generateItemId } from "@/lib/workspace-state/item-helpers"; -const DEFAULT_CHAT_MODEL_ID = "gemini-2.5-flash-lite"; +const DEFAULT_CHAT_MODEL_ID = "gemini-2.5-flash"; export type QuizWorkerParams = { - topic?: string; // Used only if no context provided + topic?: string; // Optional topic or focus instructions, including for context-based quizzes contextContent?: string; // Aggregated content from selected cards sourceCardIds?: string[]; sourceCardNames?: string[]; @@ -79,7 +79,7 @@ For weak areas: export async function quizWorker(params: QuizWorkerParams): Promise<{ questions: QuizQuestion[]; title: string }> { try { - const questionCount = params.questionCount || 5; + const questionCount = params.questionCount ?? 5; const questionTypes = params.questionTypes || ["multiple_choice", "true_false"]; logger.debug("🎯 [QUIZ-WORKER] Starting quiz generation:", { @@ -96,6 +96,10 @@ export async function quizWorker(params: QuizWorkerParams): Promise<{ questions: if (params.contextContent) { // Context-based quiz generation + const topicInstruction = params.topic + ? `\nUSER'S SPECIFIC INSTRUCTIONS / TOPIC FOCUS:\n"${params.topic}"\n\nEnsure the generated questions specifically address these instructions within the provided content.` + : ""; + prompt = `You are a quiz generator. Create exactly ${questionCount} quiz questions based EXCLUSIVELY on the following content. Do NOT use any external knowledge. IMPORTANT: The content below is from workspace cards and includes metadata headers like "CARD 1:", "Card ID:", "METADATA:", "CONTENT:", etc. IGNORE ALL METADATA. Focus ONLY on the actual educational content within each card - the text, concepts, facts, and information being taught. Do NOT create questions about: @@ -106,6 +110,7 @@ IMPORTANT: The content below is from workspace cards and includes metadata heade CONTENT TO QUIZ ON: ${params.contextContent} +${topicInstruction} REQUIREMENTS: ${adaptiveInstructions} From ca9c034957be22c25f99fe18ff8aebf056e8af92 Mon Sep 17 00:00:00 2001 From: 1shCha Date: Fri, 20 Feb 2026 23:47:30 -0500 Subject: [PATCH 2/2] changes --- src/components/pdf/AppPdfViewer.tsx | 7 +- src/components/pdf/LightweightPdfPreview.tsx | 216 +++++++++++++++---- 2 files changed, 180 insertions(+), 43 deletions(-) diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx index 9018642e..0a607b24 100644 --- a/src/components/pdf/AppPdfViewer.tsx +++ b/src/components/pdf/AppPdfViewer.tsx @@ -40,6 +40,7 @@ interface PdfSavedState { scrollTop: number; scrollLeft: number; zoom: number; + currentPage: number; timestamp: number; } @@ -54,6 +55,7 @@ const restoreCompleted = new Set(); * Persists and restores PDF scroll position and zoom level to localStorage. */ const PdfStatePersister = ({ documentId, pdfSrc }: { documentId: string; pdfSrc: string }) => { + const { state: scrollState } = useScroll(documentId); const { provides: viewportCapability } = useViewportCapability(); const { state: zoomState, provides: zoomScope } = useZoom(documentId); const debounceRef = useRef(undefined); @@ -83,6 +85,7 @@ const PdfStatePersister = ({ documentId, pdfSrc }: { documentId: string; pdfSrc: scrollTop: metrics.scrollTop, scrollLeft: metrics.scrollLeft, zoom: zoomState.currentZoomLevel, + currentPage: scrollState?.currentPage ?? 1, timestamp: Date.now(), }; const stateStr = JSON.stringify(state); @@ -96,7 +99,7 @@ const PdfStatePersister = ({ documentId, pdfSrc }: { documentId: string; pdfSrc: } catch (e) { console.warn('[PdfStatePersister] Failed to save state:', e); } - }, [viewportCapability, zoomState.currentZoomLevel, documentId, storageKey, canSave]); + }, [viewportCapability, zoomState.currentZoomLevel, scrollState?.currentPage, documentId, storageKey, canSave]); // Restore state on mount (only once per document) useEffect(() => { @@ -945,4 +948,4 @@ const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName, ); }; -export default AppPdfViewer; \ No newline at end of file +export default AppPdfViewer; diff --git a/src/components/pdf/LightweightPdfPreview.tsx b/src/components/pdf/LightweightPdfPreview.tsx index 21b91008..fb4cbbd9 100644 --- a/src/components/pdf/LightweightPdfPreview.tsx +++ b/src/components/pdf/LightweightPdfPreview.tsx @@ -11,6 +11,7 @@ import { Loader2 } from 'lucide-react'; interface PdfSavedState { scrollTop: number; zoom: number; + currentPage?: number; timestamp: number; } @@ -28,6 +29,38 @@ interface PdfSnapshotRendererProps { pageCount: number; } +function getErrorDetails(error: unknown): Record { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + if (typeof error === 'object' && error !== null) { + const maybeError = error as Record; + return { + name: typeof maybeError.name === 'string' ? maybeError.name : undefined, + message: typeof maybeError.message === 'string' ? maybeError.message : undefined, + code: maybeError.code, + reason: maybeError.reason, + }; + } + + return { value: error }; +} + +function hasLikelyStaleCurrentPage(state: PdfSavedState, pageCount: number): boolean { + if (pageCount <= 1) return false; + if (typeof state.currentPage !== 'number' || state.currentPage < 1) return false; + if (state.currentPage > 1) return false; + + const zoom = state.zoom || 1.0; + const approxFirstPageHeight = 800 * zoom; + return state.scrollTop > approxFirstPageHeight * 1.25; +} + /** * Internal component that renders the PDF snapshot once document is loaded */ @@ -45,7 +78,6 @@ function PdfSnapshotRenderer({ const [scrollOffset, setScrollOffset] = useState(0); const [displayScale, setDisplayScale] = useState(1); // Track DPR to scale image back down const mountedRef = useRef(true); - const hasRenderedRef = useRef(false); // Get saved state from localStorage const savedState = useMemo((): PdfSavedState | null => { @@ -61,36 +93,61 @@ function PdfSnapshotRenderer({ return null; }, [pdfSrc]); - // Calculate page from saved scroll position + // Determine which page to display. + // Prefer the explicitly saved currentPage (1-indexed) over the approximate + // scrollTop heuristic, which drifts significantly on tall documents. const estimatedPage = useMemo(() => { if (!savedState) return 0; + const hasCurrentPage = typeof savedState.currentPage === 'number' && savedState.currentPage >= 1; + const currentPageLooksStale = hasCurrentPage && hasLikelyStaleCurrentPage(savedState, pageCount); + const exactCurrentPage = typeof savedState.currentPage === 'number' ? savedState.currentPage : 1; + + if (hasCurrentPage && !currentPageLooksStale) { + // Use exact saved page (convert 1-indexed → 0-indexed) + return Math.max(0, Math.min(exactCurrentPage - 1, pageCount - 1)); + } + + // Fallback for old cached states that don't have currentPage (or stale currentPage) const zoom = savedState.zoom || 1.0; - // Rough estimation: assume ~800px per page at zoom 1.0 const avgPageHeight = 800 * zoom; const page = Math.floor(savedState.scrollTop / avgPageHeight); return Math.max(0, Math.min(page, pageCount - 1)); }, [savedState, pageCount]); - // Render the pages when ready (prev, current, next) + // Keep refs always pointing at the latest values so the render effect can read + // them without needing them in its dependency array. This avoids the old race + // where hasRenderedRef blocked a re-render after estimatedPage settled while + // renderCapability was already available. + const estimatedPageRef = useRef(estimatedPage); + estimatedPageRef.current = estimatedPage; + const savedStateRef = useRef(savedState); + savedStateRef.current = savedState; + + // Render the pages when ready (prev, current, next). + // Depends only on renderCapability and pageCount — the truly async values that + // determine when we are ready to render. estimatedPage and savedState are read + // synchronously via refs inside the async callback. useEffect(() => { - if (!renderCapability || hasRenderedRef.current) return; + if (!renderCapability || pageCount === 0) return; mountedRef.current = true; - hasRenderedRef.current = true; const renderSnapshot = async () => { try { setIsRendering(true); - const currentPageIndex = estimatedPage; + // Read latest values via refs so this effect doesn't need to re-run + // every time estimatedPage or savedState changes. + const currentPageIndex = estimatedPageRef.current; + const currentSavedState = savedStateRef.current; // Set page info for display setPageInfo({ current: currentPageIndex + 1, total: pageCount }); // Get saved zoom or use default (don't cap to preserve user's view) // Multiply by device pixel ratio for sharp rendering on retina displays - const baseScale = savedState?.zoom || 0.5; + const baseScale = currentSavedState?.zoom || 0.5; const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; const renderDpr = Math.min(dpr, 2); // Cap DPR at 2x to avoid huge renders const scale = baseScale * renderDpr; @@ -112,7 +169,7 @@ function PdfSnapshotRenderer({ pagesToRender.push(currentPageIndex); if (currentPageIndex < pageCount - 1) pagesToRender.push(currentPageIndex + 1); - // Render all pages and stitch them together + // Render pages one-by-one. A failure on one page should not blank the entire preview. const renderPage = (pageIdx: number): Promise => { return new Promise((resolve, reject) => { const task = renderScope.renderPage({ @@ -129,30 +186,78 @@ function PdfSnapshotRenderer({ }); }; - // Render all pages in parallel - const blobs = await Promise.all(pagesToRender.map(renderPage)); + const renderedPages: Array<{ pageIdx: number; blob: Blob }> = []; + for (const pageIdx of pagesToRender) { + try { + const blob = await renderPage(pageIdx); + renderedPages.push({ pageIdx, blob }); + } catch (error) { + console.error('[LightweightPdfPreview] Failed to render page', { + documentId, + pageIdx, + pageCount, + requestedCurrentPage: currentPageIndex, + error: getErrorDetails(error), + }); + } + } + + if (renderedPages.length === 0) { + console.error('[LightweightPdfPreview] No pages rendered for snapshot', { + documentId, + pageCount, + requestedCurrentPage: currentPageIndex, + }); + setImageUrl(null); + setIsRendering(false); + return; + } if (!mountedRef.current) return; // Convert blobs to images and stitch them vertically - const images = await Promise.all(blobs.map(blob => { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = URL.createObjectURL(blob); + const images: Array<{ pageIdx: number; image: HTMLImageElement }> = []; + for (const { pageIdx, blob } of renderedPages) { + try { + const image = await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = URL.createObjectURL(blob); + }); + images.push({ pageIdx, image }); + } catch (error) { + console.error('[LightweightPdfPreview] Failed to decode rendered page image', { + documentId, + pageIdx, + error: getErrorDetails(error), + }); + } + } + + if (images.length === 0) { + console.error('[LightweightPdfPreview] All rendered page images failed to decode', { + documentId, + pageCount, + requestedCurrentPage: currentPageIndex, }); - })); + setImageUrl(null); + setIsRendering(false); + return; + } if (!mountedRef.current) { - images.forEach(img => URL.revokeObjectURL(img.src)); + images.forEach(({ image }) => URL.revokeObjectURL(image.src)); return; } // Calculate total canvas size - const maxWidth = Math.max(...images.map(img => img.width)); - const totalHeight = images.reduce((sum, img) => sum + img.height, 0); + const maxWidth = Math.max(...images.map(({ image }) => image.width)); + const totalHeight = images.reduce((sum, { image }) => sum + image.height, 0); const gap = 10 * renderDpr; // Small gap between pages + const pageOffsetMap = new Map(); + const hasCurrentPage = images.some(({ pageIdx }) => pageIdx === currentPageIndex); + const displayPageIndex = hasCurrentPage ? currentPageIndex : images[0].pageIdx; // Create canvas and stitch images const canvas = document.createElement('canvas'); @@ -165,42 +270,70 @@ function PdfSnapshotRenderer({ ctx.fillRect(0, 0, canvas.width, canvas.height); let yOffset = 0; - images.forEach((img, i) => { + images.forEach(({ pageIdx, image }) => { + pageOffsetMap.set(pageIdx, yOffset); // Center each page horizontally - const xOffset = (maxWidth - img.width) / 2; - ctx.drawImage(img, xOffset, yOffset); - yOffset += img.height + gap; + const xOffset = (maxWidth - image.width) / 2; + ctx.drawImage(image, xOffset, yOffset); + yOffset += image.height + gap; }); // Convert to blob canvas.toBlob((blob) => { if (blob && mountedRef.current) { const url = URL.createObjectURL(blob); - setImageUrl(url); - - // Calculate scroll offset - need to account for previous page - if (savedState) { - const zoom = savedState.zoom || 1.0; + setImageUrl((prevUrl) => { + if (prevUrl) URL.revokeObjectURL(prevUrl); + return url; + }); + setPageInfo({ current: displayPageIndex + 1, total: pageCount }); + + // Calculate scroll offset relative to the rendered page used for display. + // For modern saved state with exact currentPage, anchor to page top to avoid + // drift/overshoot artifacts from approximated per-page heights. + const pageTopOffset = pageOffsetMap.get(displayPageIndex) ?? 0; + const displayPageImage = images.find(({ pageIdx }) => pageIdx === displayPageIndex)?.image; + const displayPageHeight = displayPageImage?.height ?? 0; + const hasExactCurrentPage = + typeof currentSavedState?.currentPage === 'number' && + currentSavedState.currentPage >= 1 && + !hasLikelyStaleCurrentPage(currentSavedState, pageCount); + + if (currentSavedState && !hasExactCurrentPage) { + // Legacy state fallback: no saved currentPage. Keep old heuristic but + // constrain the offset to the active page bounds. + const zoom = currentSavedState.zoom || 1.0; const avgPageHeight = 800 * zoom; - const pageStartY = currentPageIndex * avgPageHeight; - const offsetWithinPage = savedState.scrollTop - pageStartY; - - // Add height of previous page(s) to offset - const prevPagesHeight = currentPageIndex > 0 ? images[0].height + gap : 0; - - setScrollOffset(prevPagesHeight + Math.max(0, offsetWithinPage * renderDpr)); + const pageStartY = displayPageIndex * avgPageHeight; + const offsetWithinPage = currentSavedState.scrollTop - pageStartY; + const rawOffset = pageTopOffset + Math.max(0, offsetWithinPage * renderDpr); + const pageMaxOffset = pageTopOffset + Math.max(0, displayPageHeight - 1); + const clampedOffset = Math.min(Math.max(pageTopOffset, rawOffset), pageMaxOffset); + setScrollOffset(clampedOffset); + } else { + // Exact state path (or no state): stable page-top preview. + const clampedOffset = Math.min(Math.max(0, pageTopOffset), Math.max(0, canvas.height - 1)); + setScrollOffset(clampedOffset); } + setIsRendering(false); + } else if (mountedRef.current) { + console.error('[LightweightPdfPreview] Failed to create snapshot blob', { + documentId, + pageCount, + requestedCurrentPage: currentPageIndex, + }); + setImageUrl(null); setIsRendering(false); } }, 'image/webp', 0.92); } // Cleanup temp image URLs - images.forEach(img => URL.revokeObjectURL(img.src)); + images.forEach(({ image }) => URL.revokeObjectURL(image.src)); } catch (e) { - console.error('[LightweightPdfPreview] Error:', e); + console.error('[LightweightPdfPreview] Error:', getErrorDetails(e)); setIsRendering(false); } }; @@ -210,7 +343,7 @@ function PdfSnapshotRenderer({ return () => { mountedRef.current = false; }; - }, [renderCapability, documentId, estimatedPage, savedState, pageCount]); + }, [renderCapability, documentId, pageCount]); // Cleanup image URL on unmount useEffect(() => { @@ -285,7 +418,8 @@ export function LightweightPdfPreview({ pdfSrc, className }: LightweightPdfPrevi // Minimal plugins - just enough to render a page image const plugins = useMemo(() => [ createPluginRegistration(DocumentManagerPluginPackage, { - initialDocuments: [{ url: pdfSrc }], + // Use full-fetch so renderPage can access deep pages in this minimal, non-scrolling instance. + initialDocuments: [{ url: pdfSrc, mode: 'full-fetch' }], }), createPluginRegistration(RenderPluginPackage, { withForms: true,