From dda5f287e0fa232986938e781960764595a0490d Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sat, 17 Jan 2026 23:24:15 -0500 Subject: [PATCH 01/29] dr and blocknote --- src/app/api/deep-research/status/route.ts | 53 ++-- src/components/editor/BlockNoteEditor.tsx | 144 +++++++---- src/lib/editor/markdown-to-blocks.ts | 295 +--------------------- src/lib/editor/math-helpers.ts | 292 +++++++++++++++++++++ 4 files changed, 415 insertions(+), 369 deletions(-) create mode 100644 src/lib/editor/math-helpers.ts diff --git a/src/app/api/deep-research/status/route.ts b/src/app/api/deep-research/status/route.ts index 75a7b054..7fac40ce 100644 --- a/src/app/api/deep-research/status/route.ts +++ b/src/app/api/deep-research/status/route.ts @@ -1,18 +1,14 @@ import { GoogleGenAI } from "@google/genai"; import { NextRequest, NextResponse } from "next/server"; - // Initialize client const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; if (!apiKey) { console.error("GOOGLE_GENERATIVE_AI_API_KEY is not set"); } - const client = new GoogleGenAI({ apiKey: apiKey, }); - export const dynamic = 'force-dynamic'; - /** * GET /api/deep-research/status * Poll endpoint to check the status of a deep research interaction @@ -21,32 +17,46 @@ export const dynamic = 'force-dynamic'; export async function GET(req: NextRequest) { const searchParams = req.nextUrl.searchParams; const interactionId = searchParams.get("interactionId"); - if (!interactionId) { return NextResponse.json({ error: "interactionId is required" }, { status: 400 }); } - try { // Get the interaction without streaming - this returns the current state const interaction = await client.interactions.get(interactionId); - + // DEBUG: Log the entire raw interaction object (optional, kept minimal) + console.log('[DEEP-RESEARCH-STATUS] ========== RAW INTERACTION PAYLOAD =========='); + console.log('[DEEP-RESEARCH-STATUS] Interaction ID:', interactionId); + console.log('[DEEP-RESEARCH-STATUS] State object:', (interaction as any).state); + console.log('[DEEP-RESEARCH-STATUS] Status field:', (interaction as any).status); // Extract status from interaction - check multiple possible properties - const rawStatus = (interaction as any).state?.status || - (interaction as any).status || - "unknown"; - + const rawStatus = (interaction as any).state?.status || + (interaction as any).status || + "unknown"; // Collect thoughts and report from events or content const thoughts: string[] = []; let report = ""; let error: string | undefined = undefined; - - // Try to get content directly first (if API returns full content) - if ((interaction as any).content) { + // PRIMARY: Extract from outputs array (this is where the API actually returns data) + const outputs = (interaction as any).outputs || []; + for (const output of outputs) { + if (output.type === "text" && output.text) { + // This is the final report + report = output.text; + } else if (output.type === "thought" && output.summary) { + // This contains the thought summaries + for (const thought of output.summary) { + if (thought.text && typeof thought.text === "string" && !thoughts.includes(thought.text)) { + thoughts.push(thought.text); + } + } + } + } + // FALLBACK: Try to get content directly (legacy format) + if (!report && (interaction as any).content) { const content = (interaction as any).content; if (typeof content === "string") { report = content; } else if (Array.isArray(content)) { - // Content might be an array of content blocks for (const block of content) { if (block.type === "text" && block.text) { report += block.text; @@ -54,12 +64,10 @@ export async function GET(req: NextRequest) { } } } - - // Process events to extract thoughts and report content + // FALLBACK: Process events to extract thoughts and report content (legacy format) const events = (interaction as any).events || []; for (const event of events) { const eventType = event.event_type || event.type; - if (eventType === "content.delta") { const delta = event.delta || {}; if (delta.type === "text" && delta.text) { @@ -72,12 +80,9 @@ export async function GET(req: NextRequest) { } } else if (eventType === "interaction.failed" || eventType === "interaction_failed") { error = event.error?.message || event.error || "Research failed"; - } else if (eventType === "interaction.complete" || eventType === "interaction_complete") { - // Research completed } } - - // Also check for thoughts in other possible locations + // FALLBACK: Also check for thoughts in other possible locations if ((interaction as any).thoughts && Array.isArray((interaction as any).thoughts)) { for (const thought of (interaction as any).thoughts) { const thoughtText = typeof thought === "string" ? thought : thought?.text || thought?.content; @@ -86,7 +91,6 @@ export async function GET(req: NextRequest) { } } } - // Map Google's status to our status format let mappedStatus: "researching" | "complete" | "failed" = "researching"; const statusLower = String(rawStatus).toLowerCase(); @@ -95,7 +99,6 @@ export async function GET(req: NextRequest) { } else if (statusLower === "failed" || statusLower === "error" || error) { mappedStatus = "failed"; } - return NextResponse.json({ status: mappedStatus, thoughts, @@ -105,7 +108,7 @@ export async function GET(req: NextRequest) { } catch (error: any) { console.error("[DEEP_RESEARCH_STATUS] Error:", error); return NextResponse.json( - { + { error: error?.message || "Failed to retrieve interaction status", status: "failed" as const, }, diff --git a/src/components/editor/BlockNoteEditor.tsx b/src/components/editor/BlockNoteEditor.tsx index 591cbb09..65d0bd08 100644 --- a/src/components/editor/BlockNoteEditor.tsx +++ b/src/components/editor/BlockNoteEditor.tsx @@ -10,6 +10,7 @@ import "@blocknote/shadcn/style.css"; import { useEffect, useRef, useCallback } from "react"; import { toast } from "sonner"; import { schema } from "./schema"; +import { normalizeMathSyntax, convertMathInBlocks } from "@/lib/editor/math-helpers"; import { uploadFile } from "@/lib/editor/upload-file"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useUIStore } from "@/lib/stores/ui-store"; @@ -81,9 +82,55 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca // Get clipboard text content const textContent = clipboardData.getData('text/plain'); + const markdownContent = clipboardData.getData('text/markdown'); + + const looksLikeMarkdown = (text: string) => { + return ( + /(^|\n)#{1,6}\s/.test(text) || + /(^|\n)\|.+\|/.test(text) || + /```/.test(text) || + /\[[^\]]+\]\([^)]+\)/.test(text) || + /(^|\n)(-|\*)\s/.test(text) + ); + }; + + const markdownText = markdownContent || (textContent && looksLikeMarkdown(textContent) ? textContent : ""); + + if (markdownText) { + const parseMarkdown = editor?.tryParseMarkdownToBlocks; + if (typeof parseMarkdown === "function") { + event.preventDefault(); + const currentBlock = editor.getTextCursorPosition().block; + void (async () => { + try { + const normalizedMarkdown = normalizeMathSyntax(markdownText); + const blocks = await editor.tryParseMarkdownToBlocks(normalizedMarkdown); + const processedBlocks = convertMathInBlocks(blocks); + + if (processedBlocks.length === 1 && processedBlocks[0].type === "paragraph") { + // If it's a single paragraph, insert it inline at the cursor + editor.insertInlineContent(processedBlocks[0].content); + } else { + // For multiple blocks or non-paragraphs + // Check if current block is empty (and is a paragraph) to replace it + const isEmpty = currentBlock.type === "paragraph" && (!currentBlock.content || currentBlock.content.length === 0); + + if (isEmpty) { + editor.replaceBlocks([currentBlock], processedBlocks); + } else { + editor.insertBlocks(processedBlocks, currentBlock, "after"); + } + } + } catch (error) { + console.error("[BlockNoteEditor] Markdown paste failed:", error); + editor.insertInlineContent([{ type: "text", text: markdownText }]); + } + })(); + return true; + } + } // Helper function to extract LaTeX from math delimiters - // Only matches if the entire content is just math (no surrounding text) const extractMathContent = (text: string): { type: 'block' | 'inline'; latex: string } | null => { const trimmed = text.trim(); @@ -94,7 +141,6 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca } // Check for inline math: $...$ or \(...\) - // Only match if the entire content is just the math (no surrounding text) const inlineMathMatch = trimmed.match(/^\$([^$\n]+?)\$$/) || trimmed.match(/^\\\(([\s\S]*?)\\\)$/); if (inlineMathMatch) { return { type: 'inline', latex: inlineMathMatch[1].trim() }; @@ -104,7 +150,6 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca }; // Check if clipboard contains only math content (no surrounding text) - // This handles cases where math is selected and pasted directly if (textContent) { const mathContent = extractMathContent(textContent); @@ -112,31 +157,26 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca const currentBlock = editor.getTextCursorPosition().block; if (mathContent.type === 'block') { - // Insert block math after current block - editor.insertBlocks( - [ - { - type: 'math', - props: { - latex: mathContent.latex, - }, - }, - ], - currentBlock, - 'after' - ); - return true; - } else if (mathContent.type === 'inline') { - // Insert inline math at cursor position - editor.insertInlineContent([ - { - type: 'inlineMath', - props: { - latex: mathContent.latex, - }, + // Insert block math + event.preventDefault(); // Prevent default paste behavior + + const isEmpty = currentBlock.type === "paragraph" && (!currentBlock.content || currentBlock.content.length === 0); + const mathBlock = { + type: 'math', + props: { + latex: mathContent.latex, }, - ]); + }; + + if (isEmpty) { + editor.replaceBlocks([currentBlock], [mathBlock]); + } else { + editor.insertBlocks([mathBlock], currentBlock, 'after'); + } return true; + } else if (mathContent.type === 'inline') { + // Let default handler handle inline text unless we want to force inline math insertion + // For now, let's just handle block math specifically } } } @@ -159,20 +199,20 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca .then((imageUrl) => { // Get the current block (where cursor is) const currentBlock = editor.getTextCursorPosition().block; + const isEmpty = currentBlock.type === "paragraph" && (!currentBlock.content || currentBlock.content.length === 0); + + const imageBlock = { + type: 'image', + props: { + url: imageUrl, + }, + }; - // Insert image block after current block - editor.insertBlocks( - [ - { - type: 'image', - props: { - url: imageUrl, - }, - }, - ], - currentBlock, - 'after' - ); + if (isEmpty) { + editor.replaceBlocks([currentBlock], [imageBlock]); + } else { + editor.insertBlocks([imageBlock], currentBlock, 'after'); + } // Show success toast toast.success('Image uploaded successfully!', { @@ -211,20 +251,20 @@ export default function BlockNoteEditor({ initialContent, onChange, readOnly, ca .then((imageUrl) => { // Get the current block (where cursor is) const currentBlock = editor.getTextCursorPosition().block; + const isEmpty = currentBlock.type === "paragraph" && (!currentBlock.content || currentBlock.content.length === 0); - // Insert image block after current block - editor.insertBlocks( - [ - { - type: 'image', - props: { - url: imageUrl, - }, - }, - ], - currentBlock, - 'after' - ); + const imageBlock = { + type: 'image', + props: { + url: imageUrl, + }, + }; + + if (isEmpty) { + editor.replaceBlocks([currentBlock], [imageBlock]); + } else { + editor.insertBlocks([imageBlock], currentBlock, 'after'); + } // Show success toast toast.success('Image uploaded successfully!', { diff --git a/src/lib/editor/markdown-to-blocks.ts b/src/lib/editor/markdown-to-blocks.ts index d2601f87..7f69ef00 100644 --- a/src/lib/editor/markdown-to-blocks.ts +++ b/src/lib/editor/markdown-to-blocks.ts @@ -1,10 +1,9 @@ import { ServerBlockNoteEditor } from "@blocknote/server-util"; +import { normalizeMathSyntax, convertMathInBlocks } from "./math-helpers"; // Block type from server-util (no custom schema needed) type ServerBlock = any; - - /** * Converts markdown content to BlockNote blocks, with comprehensive LaTeX math conversion. * @@ -34,293 +33,5 @@ export async function markdownToBlocks(markdown: string): Promise return processedBlocks; } -/** - * Normalizes various LaTeX math syntaxes to standard $$ format (Streamdown compatible) - * This ensures math is preserved in the BlockNote document - */ -function normalizeMathSyntax(markdown: string): string { - return markdown - // Convert \[...\] to $$...$$ (block math) - .replace(/\\\[([\\s\\S]*?)\\\]/g, (_, latex) => `$$${latex.trim()}$$`) - // Convert \(...\) to $$...$$ (inline math) - Streamdown uses $$ for both - .replace(/\\\(([\\s\\S]*?)\\\)/g, (_, latex) => `$$${latex.trim()}$$`) - // Keep existing $$...$$ as-is - ; -} - - -/** - * Regex pattern for matching Streamdown math: $$...$$ - * Matches $$ delimiters with content in between (non-greedy) - */ -const MATH_REGEX = /\$\$([^$]+?)\$\$/g; - -/** - * Regex to check if content is ONLY a single math expression (for block math detection) - * Matches: optional whitespace + $$...$$ + optional whitespace - */ -const BLOCK_MATH_ONLY_REGEX = /^\s*\$\$([^$]+?)\$\$\s*$/; - -/** - * Converts math in blocks: - * - Paragraphs with ONLY $$...$$ become block math - * - $$...$$ within text becomes inlineMath - */ -function convertMathInBlocks(blocks: ServerBlock[]): ServerBlock[] { - const result: ServerBlock[] = []; - - for (const block of blocks) { - const processed = processBlockForMath(block); - // processBlockForMath can return one block or an array of blocks - if (Array.isArray(processed)) { - result.push(...processed); - } else { - result.push(processed); - } - } - - return result; -} - -/** - * Process a single block for math conversion. - * Returns the processed block(s) - may return multiple blocks if splitting is needed. - */ -function processBlockForMath(block: ServerBlock): ServerBlock | ServerBlock[] { - // Check if this is a paragraph that should become a math block - if (block.type === "paragraph" && block.content && Array.isArray(block.content)) { - // Get the full text content of the paragraph - const fullText = block.content - .filter((item: any) => item.type === "text") - .map((item: any) => item.text || "") - .join(""); - - // Check if the paragraph contains ONLY a single math expression - const blockMathMatch = fullText.match(BLOCK_MATH_ONLY_REGEX); - if (blockMathMatch) { - // Convert to block math - const latex = blockMathMatch[1].trim(); - return { - id: block.id, - type: "math", - props: { - latex: latex, - }, - children: [], - }; - } - - // Otherwise, process inline math within the paragraph content - const processedContent = processInlineMathInContent(block.content); - const contentChanged = JSON.stringify(processedContent) !== JSON.stringify(block.content); - - if (contentChanged) { - return { - ...block, - content: processedContent, - children: block.children ? processChildBlocks(block.children) : [], - }; - } - } - - // For other block types, process their content for inline math - let processedBlock = { ...block }; - - // Process inline content (for headings, list items, etc.) - if (block.content && Array.isArray(block.content) && block.type !== "paragraph") { - const processedContent = processInlineMathInContent(block.content); - const contentChanged = JSON.stringify(processedContent) !== JSON.stringify(block.content); - if (contentChanged) { - processedBlock = { ...processedBlock, content: processedContent }; - } - } - - // Process table cells (special structure) - if (block.type === "table" && block.content) { - let rows: any[] = []; - - if (Array.isArray(block.content)) { - rows = block.content; - } else if (block.content && typeof block.content === 'object' && 'rows' in block.content) { - rows = (block.content as any).rows || []; - } - - if (rows.length > 0) { - const processedRows = rows.map((row: any) => { - if (!row || !row.cells || !Array.isArray(row.cells)) return row; - - const processedCells = row.cells.map((cell: any) => { - if (!cell || !cell.content || !Array.isArray(cell.content)) return cell; - - const processedCellContent = processInlineMathInContent(cell.content); - - const fullyProcessedContent = processedCellContent.map((item: any) => { - if (item && typeof item === 'object' && 'type' in item && 'id' in item && item.children) { - const result = processBlockForMath(item); - return Array.isArray(result) ? result[0] : result; - } - return item; - }); - - return { - ...cell, - content: fullyProcessedContent, - }; - }); - - return { - ...row, - cells: processedCells, - }; - }); - - const processedTableContent = Array.isArray(block.content) - ? processedRows - : { ...(block.content as any), rows: processedRows }; - - processedBlock = { - ...processedBlock, - content: processedTableContent, - }; - } - } - - // Recursively process children - if (block.children && Array.isArray(block.children) && block.children.length > 0) { - processedBlock = { - ...processedBlock, - children: processChildBlocks(block.children), - }; - } - - return processedBlock; -} - -/** - * Process an array of child blocks recursively. - */ -function processChildBlocks(blocks: ServerBlock[]): ServerBlock[] { - const result: ServerBlock[] = []; - for (const block of blocks) { - const processed = processBlockForMath(block); - if (Array.isArray(processed)) { - result.push(...processed); - } else { - result.push(processed); - } - } - return result; -} - -/** - * Processes Streamdown math patterns ($$...$$) in inline content array. - * Splits text items that contain math and converts them to inlineMath elements. - * - * Note: This is for inline math only - block math is handled at the block level. - * - * Example: - * Input: [{ type: "text", text: "The equation $$E=mc^2$$ is famous." }] - * Output: [ - * { type: "text", text: "The equation " }, - * { type: "inlineMath", props: { latex: "E=mc^2" } }, - * { type: "text", text: " is famous." } - * ] - */ -function processInlineMathInContent( - content: Array<{ type: string; text?: string;[key: string]: any }> -): Array { - const processed: any[] = []; - - for (const item of content) { - if (item.type === "text" && item.text) { - const text = item.text; - const parts: Array<{ type: string; text?: string; props?: { latex: string } }> = []; - let lastIndex = 0; - - // Find all math matches - let match: RegExpExecArray | null; - MATH_REGEX.lastIndex = 0; // Reset regex - - while ((match = MATH_REGEX.exec(text)) !== null) { - const matchStart = match.index; - const matchEnd = match.index + match[0].length; - const latex = match[1].trim(); - - // Add text before the math - if (matchStart > lastIndex) { - const beforeText = text.substring(lastIndex, matchStart); - if (beforeText) { - parts.push({ - type: "text", - text: beforeText, - // Preserve other properties (styles, etc.) - ...Object.fromEntries( - Object.entries(item).filter(([key]) => key !== "type" && key !== "text") - ), - }); - } - } - - // Add inline math element - parts.push({ - type: "inlineMath", - props: { latex }, - }); - - lastIndex = matchEnd; - } - - // Add remaining text after last match - if (lastIndex < text.length) { - const afterText = text.substring(lastIndex); - if (afterText) { - parts.push({ - type: "text", - text: afterText, - // Preserve other properties - ...Object.fromEntries( - Object.entries(item).filter(([key]) => key !== "type" && key !== "text") - ), - }); - } - } - - // If we found math, use processed parts; otherwise keep original item - if (parts.length > 0) { - processed.push(...parts); - } else { - processed.push(item); - } - } else { - // Non-text items are kept as-is - processed.push(item); - } - } - - // Remove periods (and leading spaces before them) that immediately follow inline math - const cleaned: any[] = []; - for (let i = 0; i < processed.length; i++) { - const current = processed[i]; - const prev = i > 0 ? processed[i - 1] : null; - - // If current is text and previous is inlineMath, remove leading periods and spaces before them - if (current.type === "text" && current.text && prev && prev.type === "inlineMath") { - // Remove leading spaces followed by periods (e.g., " ." or ".") - // But preserve other text like " and " between math expressions - const cleanedText = current.text.replace(/^\s*\.+/, ''); - - // Only add if there's remaining text after cleaning - if (cleanedText) { - cleaned.push({ - ...current, - text: cleanedText, - }); - } - // If text becomes empty after cleaning (was just ". " or "."), skip it entirely - } else { - cleaned.push(current); - } - } - - return cleaned; -} +// Re-export for convenience if needed, though mostly used internally or by BlockNoteEditor +export { normalizeMathSyntax, convertMathInBlocks }; diff --git a/src/lib/editor/math-helpers.ts b/src/lib/editor/math-helpers.ts new file mode 100644 index 00000000..e269a4fe --- /dev/null +++ b/src/lib/editor/math-helpers.ts @@ -0,0 +1,292 @@ +// Type definition to avoid dependency on server-util +export type MathBlock = any; + +/** + * Normalizes various LaTeX math syntaxes to standard $$ format (Streamdown compatible) + * This ensures math is preserved in the BlockNote document + */ +export function normalizeMathSyntax(markdown: string): string { + return markdown + // Convert \[...\] to $$...$$ (block math) + .replace(/\\\[([\\s\\S]*?)\\\]/g, (_, latex) => `$$${latex.trim()}$$`) + // Convert \(...\) to $$...$$ (inline math) - Streamdown uses $$ for both + .replace(/\\\(([\\s\\S]*?)\\\)/g, (_, latex) => `$$${latex.trim()}$$`) + // Keep existing $$...$$ as-is + ; +} + +/** + * Regex pattern for matching Streamdown math: $$...$$ + * Matches $$ delimiters with content in between (non-greedy) + */ +const MATH_REGEX = /\$\$([^$]+?)\$\$/g; + +/** + * Regex to check if content is ONLY a single math expression (for block math detection) + * Matches: optional whitespace + $$...$$ + optional whitespace + */ +const BLOCK_MATH_ONLY_REGEX = /^\s*\$\$([^$]+?)\$\$\s*$/; + +/** + * Converts math in blocks: + * - Paragraphs with ONLY $$...$$ become block math + * - $$...$$ within text becomes inlineMath + */ +export function convertMathInBlocks(blocks: MathBlock[]): MathBlock[] { + const result: MathBlock[] = []; + + for (const block of blocks) { + const processed = processBlockForMath(block); + // processBlockForMath can return one block or an array of blocks + if (Array.isArray(processed)) { + result.push(...processed); + } else { + result.push(processed); + } + } + + return result; +} + +/** + * Process a single block for math conversion. + * Returns the processed block(s) - may return multiple blocks if splitting is needed. + */ +function processBlockForMath(block: MathBlock): MathBlock | MathBlock[] { + // Check if this is a paragraph that should become a math block + if (block.type === "paragraph" && block.content && Array.isArray(block.content)) { + // Get the full text content of the paragraph + const fullText = block.content + .filter((item: any) => item.type === "text") + .map((item: any) => item.text || "") + .join(""); + + // Check if the paragraph contains ONLY a single math expression + const blockMathMatch = fullText.match(BLOCK_MATH_ONLY_REGEX); + if (blockMathMatch) { + // Convert to block math + const latex = blockMathMatch[1].trim(); + return { + id: block.id, + type: "math", + props: { + latex: latex, + }, + children: [], + }; + } + + // Otherwise, process inline math within the paragraph content + const processedContent = processInlineMathInContent(block.content); + const contentChanged = JSON.stringify(processedContent) !== JSON.stringify(block.content); + + if (contentChanged) { + return { + ...block, + content: processedContent, + children: block.children ? processChildBlocks(block.children) : [], + }; + } + } + + // For other block types, process their content for inline math + let processedBlock = { ...block }; + + // Process inline content (for headings, list items, etc.) + if (block.content && Array.isArray(block.content) && block.type !== "paragraph") { + const processedContent = processInlineMathInContent(block.content); + const contentChanged = JSON.stringify(processedContent) !== JSON.stringify(block.content); + if (contentChanged) { + processedBlock = { ...processedBlock, content: processedContent }; + } + } + + // Process table cells (special structure) + if (block.type === "table" && block.content) { + let rows: any[] = []; + + if (Array.isArray(block.content)) { + rows = block.content; + } else if (block.content && typeof block.content === 'object' && 'rows' in block.content) { + rows = (block.content as any).rows || []; + } + + if (rows.length > 0) { + const processedRows = rows.map((row: any) => { + if (!row || !row.cells || !Array.isArray(row.cells)) return row; + + const processedCells = row.cells.map((cell: any) => { + if (!cell || !cell.content || !Array.isArray(cell.content)) return cell; + + const processedCellContent = processInlineMathInContent(cell.content); + + const fullyProcessedContent = processedCellContent.map((item: any) => { + if (item && typeof item === 'object' && 'type' in item && 'id' in item && item.children) { + const result = processBlockForMath(item); + return Array.isArray(result) ? result[0] : result; + } + return item; + }); + + return { + ...cell, + content: fullyProcessedContent, + }; + }); + + return { + ...row, + cells: processedCells, + }; + }); + + const processedTableContent = Array.isArray(block.content) + ? processedRows + : { ...(block.content as any), rows: processedRows }; + + processedBlock = { + ...processedBlock, + content: processedTableContent, + }; + } + } + + // Recursively process children + if (block.children && Array.isArray(block.children) && block.children.length > 0) { + processedBlock = { + ...processedBlock, + children: processChildBlocks(block.children), + }; + } + + return processedBlock; +} + +/** + * Process an array of child blocks recursively. + */ +function processChildBlocks(blocks: MathBlock[]): MathBlock[] { + const result: MathBlock[] = []; + for (const block of blocks) { + const processed = processBlockForMath(block); + if (Array.isArray(processed)) { + result.push(...processed); + } else { + result.push(processed); + } + } + return result; +} + +/** + * Processes Streamdown math patterns ($$...$$) in inline content array. + * Splits text items that contain math and converts them to inlineMath elements. + * + * Note: This is for inline math only - block math is handled at the block level. + * + * Example: + * Input: [{ type: "text", text: "The equation $$E=mc^2$$ is famous." }] + * Output: [ + * { type: "text", text: "The equation " }, + * { type: "inlineMath", props: { latex: "E=mc^2" } }, + * { type: "text", text: " is famous." } + * ] + */ +function processInlineMathInContent( + content: Array<{ type: string; text?: string;[key: string]: any }> +): Array { + const processed: any[] = []; + + for (const item of content) { + if (item.type === "text" && item.text) { + const text = item.text; + const parts: Array<{ type: string; text?: string; props?: { latex: string } }> = []; + let lastIndex = 0; + + // Find all math matches + let match: RegExpExecArray | null; + MATH_REGEX.lastIndex = 0; // Reset regex + + while ((match = MATH_REGEX.exec(text)) !== null) { + const matchStart = match.index; + const matchEnd = match.index + match[0].length; + const latex = match[1].trim(); + + // Add text before the math + if (matchStart > lastIndex) { + const beforeText = text.substring(lastIndex, matchStart); + if (beforeText) { + parts.push({ + type: "text", + text: beforeText, + // Preserve other properties (styles, etc.) + ...Object.fromEntries( + Object.entries(item).filter(([key]) => key !== "type" && key !== "text") + ), + }); + } + } + + // Add inline math element + parts.push({ + type: "inlineMath", + props: { latex }, + }); + + lastIndex = matchEnd; + } + + // Add remaining text after last match + if (lastIndex < text.length) { + const afterText = text.substring(lastIndex); + if (afterText) { + parts.push({ + type: "text", + text: afterText, + // Preserve other properties + ...Object.fromEntries( + Object.entries(item).filter(([key]) => key !== "type" && key !== "text") + ), + }); + } + } + + // If we found math, use processed parts; otherwise keep original item + if (parts.length > 0) { + processed.push(...parts); + } else { + processed.push(item); + } + } else { + // Non-text items are kept as-is + processed.push(item); + } + } + + // Remove periods (and leading spaces before them) that immediately follow inline math + const cleaned: any[] = []; + for (let i = 0; i < processed.length; i++) { + const current = processed[i]; + const prev = i > 0 ? processed[i - 1] : null; + + // If current is text and previous is inlineMath, remove leading periods and spaces before them + if (current.type === "text" && current.text && prev && prev.type === "inlineMath") { + // Remove leading spaces followed by periods (e.g., " ." or ".") + // But preserve other text like " and " between math expressions + const cleanedText = current.text.replace(/^\s*\.+/, ''); + + // Only add if there's remaining text after cleaning + if (cleanedText) { + cleaned.push({ + ...current, + text: cleanedText, + }); + } + // If text becomes empty after cleaning (was just ". " or "."), skip it entirely + } else { + cleaned.push(current); + } + } + + return cleaned; +} From a90333b750028114ce9bcc6f74f0c0cbca03a33a Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sun, 18 Jan 2026 00:36:21 -0500 Subject: [PATCH 02/29] first iter of quizzes --- package.json | 2 + src/app/api/chat/route.ts | 274 +++++++++++ src/app/globals.css | 35 +- .../assistant-ui/CreateQuizToolUI.tsx | 283 ++++++++++++ .../assistant-ui/UpdateQuizToolUI.tsx | 137 ++++++ src/components/assistant-ui/thread.tsx | 4 + .../workspace-canvas/CardRenderer.tsx | 6 + .../workspace-canvas/QuizContent.tsx | 437 ++++++++++++++++++ .../workspace-canvas/WorkspaceCard.tsx | 20 + src/lib/ai/workers.ts | 385 ++++++++++++++- .../workspace-state/grid-layout-helpers.ts | 1 + src/lib/workspace-state/types.ts | 38 +- 12 files changed, 1615 insertions(+), 7 deletions(-) create mode 100644 src/components/assistant-ui/CreateQuizToolUI.tsx create mode 100644 src/components/assistant-ui/UpdateQuizToolUI.tsx create mode 100644 src/components/workspace-canvas/QuizContent.tsx diff --git a/package.json b/package.json index 25dd07f7..651df1f8 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "ai": "^6.0.5", "assistant-cloud": "^0.1.12", "better-auth": "^1.4.10", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.1.1", @@ -135,6 +136,7 @@ "@eslint/eslintrc": "^3.3.3", "@tailwindcss/postcss": "^4.1.18", "@types/bun": "^1.3.5", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^24.10.4", "@types/pg": "^8.16.0", "@types/react": "19.2.2", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 2c4a267f..4528b166 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -9,6 +9,7 @@ import { codeExecutionWorker, workspaceWorker, textSelectionWorker, + quizWorker, } from "@/lib/ai/workers"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import { auth } from "@/lib/auth"; @@ -1071,6 +1072,279 @@ Math is supported: use $$...$$ for inline and $$...$$ for display math within th }, }, + // TOOL 10: Create Quiz + createQuiz: { + description: "Create an interactive quiz in the workspace. Generates multiple-choice and true/false questions. If cards are selected in the context drawer, questions are generated EXCLUSIVELY from that content. If no context is selected, generates questions from general knowledge about the provided topic. Creates a quiz card with 10 questions that the user can take interactively.", + inputSchema: z.object({ + topic: z.string().optional().describe("Topic for quiz (only used if no context is selected)"), + difficulty: z.enum(["easy", "medium", "hard"]).default("medium").describe("Difficulty level affecting question complexity"), + }), + execute: async ({ topic, difficulty }: { topic?: string; difficulty: "easy" | "medium" | "hard" }) => { + logger.debug("🎯 [CREATE-QUIZ] Tool execution started:", { topic, difficulty }); + + if (!workspaceId) { + return { + success: false, + message: "No workspace context available", + }; + } + + try { + // Import quizWorker dynamically to avoid circular deps at top level + // const { quizWorker } = await import("@/lib/ai/workers"); + + // Check if we have context from selected cards + let contextContent: string | undefined; + let sourceCardIds: string[] | undefined; + let sourceCardNames: string[] | undefined; + + const extractSelectedCardsContext = (text: string) => { + const marker = "[[SELECTED_CARDS_MARKER]]"; + const match = text.match(new RegExp(`${marker}([\\s\\S]*?)${marker}`)); + if (!match) return null; + + const context = match[1]; + const nameMatches = context.matchAll(/CARD\s+\d+:\s+.*"([^"]+)"/g); + const idMatches = context.matchAll(/Card ID:\s*([a-zA-Z0-9_-]+)/g); + const names = Array.from(nameMatches).map(m => m[1]); + const ids = Array.from(idMatches).map(m => m[1]); + + return { context, names, ids }; + }; + + for (const msg of convertedMessages) { + if (typeof msg.content === "string") { + const extracted = extractSelectedCardsContext(msg.content); + if (extracted) { + contextContent = extracted.context; + sourceCardNames = extracted.names; + sourceCardIds = extracted.ids; + break; + } + } + + if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "text" && typeof part.text === "string") { + const extracted = extractSelectedCardsContext(part.text); + if (extracted) { + contextContent = extracted.context; + sourceCardNames = extracted.names; + sourceCardIds = extracted.ids; + break; + } + } + } + if (contextContent) break; + } + } + + // If no context and no topic, return error + if (!contextContent && !topic) { + return { + success: false, + message: "Please provide a topic or select cards to generate a quiz from.", + }; + } + + // Generate quiz questions + const quizResult = await quizWorker({ + topic: contextContent ? undefined : topic, + contextContent, + sourceCardIds, + sourceCardNames, + difficulty, + questionCount: 10, + }); + + // Create the quiz card + const result = await workspaceWorker("create", { + workspaceId, + title: quizResult.title, + itemType: "quiz", + quizData: { + title: quizResult.title, + difficulty, + sourceCardIds, + sourceCardNames, + questions: quizResult.questions, + }, + folderId: activeFolderId, + }); + + logger.debug("✅ [CREATE-QUIZ] Quiz created:", { + itemId: result.itemId, + title: quizResult.title, + }); + + return { + ...result, + title: quizResult.title, + questionCount: quizResult.questions.length, + }; + } catch (error) { + logger.error("❌ [CREATE-QUIZ] Error:", error); + return { + success: false, + message: `Error creating quiz: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }, + }, + + // TOOL 11: Update Quiz + updateQuiz: { + description: "Add more questions to an existing quiz. This tool analyzes the user's performance history (weak areas) to generate targeted follow-up questions. It should be called when the user asks to 'continue the quiz', 'add more questions', or 'practice my weak areas'.", + inputSchema: z.object({ + quizId: z.string().describe("ID of the quiz to update"), + }), + execute: async ({ quizId }: { quizId: string }) => { + logger.debug("🎯 [UPDATE-QUIZ] Tool execution started:", { quizId }); + + if (!workspaceId) { + return { + success: false, + message: "No workspace context available", + }; + } + + try { + // Import workers dynamically + // const { quizWorker } = await import("@/lib/ai/workers"); + + // Load current workspace state to find the quiz + const state = await loadWorkspaceState(workspaceId); + const quizItem = state.items.find(item => item.id === quizId); + + if (!quizItem || quizItem.type !== 'quiz') { + logger.warn("❌ [UPDATE-QUIZ] Quiz not found:", { + searchedId: quizId, + availableIds: state.items.filter(i => i.type === 'quiz').map(i => i.id) + }); + return { + success: false, + message: "Quiz not found. Please select a quiz card in the context drawer.", + }; + } + + logger.debug("✅ [UPDATE-QUIZ] Found quiz:", { + id: quizItem.id, + name: quizItem.name + }); + + const quizData = quizItem.data as any; + const existingQuestions = quizData.questions || []; + logger.debug("📋 [UPDATE-QUIZ] Existing questions:", { count: existingQuestions.length }); + + const session = quizData.session; + let performanceTelemetry: { + totalAnswered: number; + correctCount: number; + incorrectCount: number; + weakAreas?: Array<{ + questionText: string; + userSelectedOption: string; + correctOption: string; + }>; + } | undefined; + + if (session && Array.isArray(session.answeredQuestions) && session.answeredQuestions.length > 0) { + const answered = session.answeredQuestions; + const correctCount = answered.filter((a: any) => a.isCorrect).length; + const weakAreas = answered + .filter((a: any) => !a.isCorrect) + .map((a: any) => { + const question = existingQuestions.find((q: any) => q.id === a.questionId); + if (!question) return null; + return { + questionText: question.questionText || "", + userSelectedOption: question.options?.[a.userAnswer] || "Unknown", + correctOption: question.options?.[question.correctIndex] || "Unknown", + }; + }) + .filter((w: any) => Boolean(w)); + + performanceTelemetry = { + totalAnswered: answered.length, + correctCount, + incorrectCount: answered.length - correctCount, + weakAreas: weakAreas.length > 0 ? weakAreas : undefined, + }; + } + + // Generate 10 more questions using the same context/topic + let contextContent: string | undefined; + + // If original quiz was context-based, try to get that context again + if (quizData.sourceCardIds && quizData.sourceCardIds.length > 0) { + const sourceCards = state.items.filter(item => + quizData.sourceCardIds.includes(item.id) + ); + // Rebuild context from source cards + contextContent = sourceCards.map(card => { + if (card.type === 'note') { + const noteData = card.data as any; + return `## ${card.name}\n${noteData.field1 || ''}`; + } + return `## ${card.name}`; + }).join('\n\n'); + } + + const quizResult = await quizWorker({ + topic: contextContent ? undefined : quizData.title, // Fallback to title as topic + contextContent, + sourceCardIds: quizData.sourceCardIds, + sourceCardNames: quizData.sourceCardNames, + difficulty: quizData.difficulty || "medium", + questionCount: 10, + existingQuestions: existingQuestions.map((q: any) => ({ + id: q.id, + questionText: q.questionText, + correctIndex: q.correctIndex + })), + performanceTelemetry + }); + + // Update the quiz card + const result = await workspaceWorker("updateQuiz", { + workspaceId, + itemId: quizId, + questionsToAdd: quizResult.questions, + }); + + logger.info("📝 [UPDATE-QUIZ] workspaceWorker result:", { + success: result.success, + itemId: result.itemId, + totalQuestions: (result as any).totalQuestions, + }); + + if (!result.success) { + logger.error("❌ [UPDATE-QUIZ] workspaceWorker failed:", result.message); + return result; + } + + logger.info("✅ [UPDATE-QUIZ] Quiz successfully updated:", { + quizId, + previousCount: existingQuestions.length, + newCount: quizResult.questions.length, + totalQuestions: (result as any).totalQuestions || (existingQuestions.length + quizResult.questions.length), + }); + + return { + ...result, + questionsAdded: quizResult.questions.length, + totalQuestions: (result as any).totalQuestions || (existingQuestions.length + quizResult.questions.length), + }; + } catch (error) { + logger.error("❌ [UPDATE-QUIZ] Error:", error); + return { + success: false, + message: `Error updating quiz: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }, + }, + ...clientTools, }; diff --git a/src/app/globals.css b/src/app/globals.css index a5219d07..2e84e846 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1224,4 +1224,37 @@ body.marquee-selecting iframe { body:has(.card-detail-modal) .workspace-grid-container { opacity: 0; pointer-events: none; -} \ No newline at end of file +} +/* Animated gradient border - rotates colors around the border in a circle */ +@keyframes gradient-rotate { + 0% { background-position: 0% 0%; } + 25% { background-position: 100% 0%; } + 50% { background-position: 100% 100%; } + 75% { background-position: 0% 100%; } + 100% { background-position: 0% 0%; } +} +.gradient-border-animated { + background: linear-gradient( + 90deg, + #ef444480, + #eab30880, + #22c55e80, + #3b82f680, + #a855f780, + #ef444480 + ); + background-size: 200% 200%; + animation: gradient-rotate 10s ease-in-out infinite; +} +/* Ensure markdown content in cards looks good */ +.prose { + max-width: none; +} +.prose p { + margin-top: 0.5em; + margin-bottom: 0.5em; +} +.prose h1, .prose h2, .prose h3 { + margin-top: 1em; + margin-bottom: 0.5em; +} diff --git a/src/components/assistant-ui/CreateQuizToolUI.tsx b/src/components/assistant-ui/CreateQuizToolUI.tsx new file mode 100644 index 00000000..e1f5fbd1 --- /dev/null +++ b/src/components/assistant-ui/CreateQuizToolUI.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useEffect, useState, useMemo } from "react"; +import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useQueryClient } from "@tanstack/react-query"; +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { X, Eye, GraduationCap, FolderInput } from "lucide-react"; +import { logger } from "@/lib/utils/logger"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import ShinyText from "@/components/ShinyText"; +import MoveToDialog from "@/components/modals/MoveToDialog"; +import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; +import { toast } from "sonner"; +import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; +import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; +import { initialState } from "@/lib/workspace-state/state"; + +// Type definitions for the tool +type CreateQuizArgs = { + topic?: string; + difficulty: "easy" | "medium" | "hard"; +}; + +type CreateQuizResult = { + success: boolean; + message: string; + title?: string; + questionCount?: number; + difficulty?: "easy" | "medium" | "hard"; + isContextBased?: boolean; + itemId?: string; +}; + +interface CreateQuizReceiptProps { + args: CreateQuizArgs; + result: CreateQuizResult; + status: any; + moveItemToFolder?: (itemId: string, folderId: string | null) => void; + allItems?: any[]; + workspaceName?: string; + workspaceIcon?: string | null; + workspaceColor?: string | null; +} + +const difficultyColors = { + easy: "bg-green-500/10 text-green-600", + medium: "bg-yellow-500/10 text-yellow-600", + hard: "bg-red-500/10 text-red-600", +}; + +const CreateQuizReceipt = ({ + args, + result, + status, + moveItemToFolder, + allItems = [], + workspaceName = "Workspace", + workspaceIcon, + workspaceColor, +}: CreateQuizReceiptProps) => { + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const { state: workspaceState } = useWorkspaceState(workspaceId); + const navigateToItem = useNavigateToItem(); + + // State for MoveToDialog + const [showMoveDialog, setShowMoveDialog] = useState(false); + + // Get the current item from workspace state + const currentItem = useMemo(() => { + if (!result.itemId || !workspaceState?.items) return undefined; + return workspaceState.items.find((item: any) => item.id === result.itemId); + }, [result.itemId, workspaceState?.items]); + + // Get folder name if item is in a folder + const folderName = useMemo(() => { + if (!currentItem?.folderId || !workspaceState?.items) return null; + const folder = workspaceState.items.find((item: any) => item.id === currentItem.folderId); + return folder?.name || null; + }, [currentItem?.folderId, workspaceState?.items]); + + const handleViewCard = () => { + if (!result.itemId) return; + navigateToItem(result.itemId); + }; + + const handleMoveToFolder = (folderId: string | null) => { + if (moveItemToFolder && result.itemId) { + moveItemToFolder(result.itemId, folderId); + } + }; + + const difficulty = result.difficulty || args.difficulty || "medium"; + + return ( + <> +
+
+
+
+ {status?.type === "complete" ? ( + + ) : ( + + )} +
+
+ + {status?.type === "complete" ? "Quiz Created" : "Creation Cancelled"} + + {status?.type === "complete" && ( + + {folderName + ? `${result.questionCount} questions in ${folderName}` + : `${result.questionCount} questions`} + + )} +
+
+ {status?.type === "complete" && result.itemId && ( + + )} +
+ + {status?.type === "complete" && ( +
+
+
+

{result.title}

+
+ {(() => { + const difficultyClass = + difficultyColors[difficulty as keyof typeof difficultyColors] || + difficultyColors.medium; + return ( + + {difficulty} + + ); + })()} + + {result.isContextBased ? "From selected content" : "General knowledge"} + +
+
+ {moveItemToFolder && ( + + )} +
+
+ )} +
+ + {/* Move To Dialog */} + {currentItem && ( + + )} + + ); +}; + +export const CreateQuizToolUI = makeAssistantToolUI({ + toolName: "createQuiz", + render: function CreateQuizUI({ args, result, status }) { + const queryClient = useQueryClient(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const { state: workspaceState } = useWorkspaceState(workspaceId); + const operations = useWorkspaceOperations(workspaceId, workspaceState || initialState); + + // Get workspace metadata from context + const workspaceContext = useWorkspaceContext(); + const currentWorkspace = workspaceContext.workspaces.find(w => w.id === workspaceId); + + // Debug logging + useEffect(() => { + logger.debug("🎯 [CreateQuizTool] Render:", { args, result, status: status?.type }); + }, [args, result, status]); + + // Trigger refetch when result is available + useEffect(() => { + if (status?.type === "complete" && result && result.success) { + logger.debug("🔄 [CreateQuizTool] Triggering refetch for completed quiz"); + if (workspaceId) { + queryClient.invalidateQueries({ queryKey: ["workspace", workspaceId, "events"] }); + } else { + queryClient.invalidateQueries({ queryKey: ["workspace"] }); + } + } + }, [status, result, workspaceId, queryClient]); + + // Show receipt when result is available + if (result && result.success) { + return ( + + ); + } + + // Show loading state while tool is executing + if (status.type === "running") { + return ( +
+
+ +
+
+ ); + } + + // Show error state + if (status.type === "incomplete" && status.reason === "error") { + return ( +
+
+ +

+ Failed to create quiz +

+
+ {result && !result.success && result.message && ( +

{result.message}

+ )} +
+ ); + } + + return null; + }, +}); diff --git a/src/components/assistant-ui/UpdateQuizToolUI.tsx b/src/components/assistant-ui/UpdateQuizToolUI.tsx new file mode 100644 index 00000000..d58e39ef --- /dev/null +++ b/src/components/assistant-ui/UpdateQuizToolUI.tsx @@ -0,0 +1,137 @@ +import { useEffect, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { X, Plus, GraduationCap } from "lucide-react"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { cn } from "@/lib/utils"; +import ShinyText from "@/components/ShinyText"; + +// Type definitions for the tool +type UpdateQuizArgs = { + quizId: string; +}; + +type UpdateQuizResult = { + success: boolean; + message: string; + itemId?: string; + questionsAdded?: number; + totalQuestions?: number; +}; + +const UpdateQuizReceipt = ({ + result, + status, +}: { + result: UpdateQuizResult; + status: any; +}) => { + return ( +
+
+
+
+ {status?.type === "complete" ? ( + + ) : ( + + )} +
+
+ + {status?.type === "complete" ? "Quiz Expanded" : "Update Cancelled"} + + {status?.type === "complete" && ( + + Added {result.questionsAdded} question{result.questionsAdded !== 1 ? 's' : ''} + + )} +
+
+ {status?.type === "complete" && ( +
+ + {result.totalQuestions} total +
+ )} +
+
+ ); +}; + +export const UpdateQuizToolUI = makeAssistantToolUI({ + toolName: "updateQuiz", + render: function UpdateQuizUI({ args, result, status }) { + const queryClient = useQueryClient(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + + // Track if we've already triggered refetch for this result + const hasRefetchedRef = useRef(null); + + // Trigger refetch when result is available (only once per result) + useEffect(() => { + if (status?.type === "complete" && result && result.success && workspaceId) { + // Use itemId as unique identifier to prevent duplicate refetches + const refetchKey = `${result.itemId}-${result.totalQuestions}`; + if (hasRefetchedRef.current === refetchKey) { + return; // Already refetched for this result + } + hasRefetchedRef.current = refetchKey; + + // Refetch to get updated data + queryClient.refetchQueries({ + queryKey: ["workspace", workspaceId, "events"], + type: 'active' + }).catch((err) => { + console.error("❌ [UpdateQuizTool] Refetch failed:", err); + }); + } + }, [status, result, workspaceId, queryClient]); + + // Show receipt when result is available + if (result && result.success) { + return ; + } + + // Show loading state while tool is executing + if (status.type === "running") { + return ( +
+
+ +
+
+ ); + } + + // Show error state + if (status.type === "incomplete" && status.reason === "error") { + return ( +
+
+ +

+ Failed to add questions +

+
+ {result && !result.success && result.message && ( +

{result.message}

+ )} +
+ ); + } + + return null; + }, +}); diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index ca630217..dc05a409 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -56,6 +56,8 @@ import { import Link from "next/link"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { CreateQuizToolUI } from "@/components/assistant-ui/CreateQuizToolUI"; +import { UpdateQuizToolUI } from "@/components/assistant-ui/UpdateQuizToolUI"; import { CreateNoteToolUI } from "@/components/assistant-ui/CreateNoteToolUI"; import { CreateFlashcardToolUI } from "@/components/assistant-ui/CreateFlashcardToolUI"; import { UpdateFlashcardToolUI } from "@/components/assistant-ui/UpdateFlashcardToolUI"; @@ -124,6 +126,8 @@ export const Thread: FC = ({ items = [] }) => { {/* Register tool UI - this component mounts and registers the UI with the assistant runtime */} + + diff --git a/src/components/workspace-canvas/CardRenderer.tsx b/src/components/workspace-canvas/CardRenderer.tsx index 7b46db1c..cef05690 100644 --- a/src/components/workspace-canvas/CardRenderer.tsx +++ b/src/components/workspace-canvas/CardRenderer.tsx @@ -8,6 +8,8 @@ import { plainTextToBlocks, type Block } from "@/components/editor/BlockNoteEdit import FlashcardContent from "./FlashcardContent"; import YouTubeCardContent from "./YouTubeCardContent"; +import { QuizContent } from "./QuizContent"; + export function CardRenderer(props: { item: Item; onUpdateData: (updater: (prev: ItemData) => ItemData) => void; @@ -84,6 +86,10 @@ export function CardRenderer(props: { return ; } + if (item.type === "quiz") { + return ; + } + if (item.type === "youtube") { return ( ItemData) => void; +} + +export function QuizContent({ item, onUpdateData }: QuizContentProps) { + const quizData = item.data as QuizData; + const questions = quizData.questions || []; + + // Session state + const [currentIndex, setCurrentIndex] = useState(quizData.session?.currentIndex || 0); + const [selectedAnswer, setSelectedAnswer] = useState(null); + const [isSubmitted, setIsSubmitted] = useState(false); + const [showHint, setShowHint] = useState(false); + const [answeredQuestions, setAnsweredQuestions] = useState( + quizData.session?.answeredQuestions || [] + ); + const [showResults, setShowResults] = useState(false); + + // Track previous question count and IDs to detect when new questions are added + const prevQuestionCountRef = useRef(questions.length); + const prevQuestionIdsRef = useRef>(new Set(questions.map(q => q.id))); + + const currentQuestion = questions[currentIndex]; + const totalQuestions = questions.length; + + // Detect when new questions are added and handle all cases: + // Case 1: Quiz on first question or restarted + // Case 2: Quiz in progress (some answered, some not) + // Case 3: Quiz finished (showing results) + useEffect(() => { + const prevCount = prevQuestionCountRef.current; + const currentCount = questions.length; + const prevIds = prevQuestionIdsRef.current; + + // Detect if new questions were actually added (not just a re-render) + const currentIds = new Set(questions.map(q => q.id)); + const questionsAdded = questions.filter(q => !prevIds.has(q.id)).length; + + if (questionsAdded > 0 && currentCount > prevCount) { + // New questions were added - determine which case we're in + const answeredCount = answeredQuestions.length; + + if (showResults) { + // CASE 3: Quiz was finished, showing results + // Exit results and navigate to first new question + toast.success(`${questionsAdded} new question${questionsAdded > 1 ? 's' : ''} added! Continue your quiz.`); + setShowResults(false); + setCurrentIndex(prevCount); // Go to first new question + setSelectedAnswer(null); + setIsSubmitted(false); + setShowHint(false); + + // Persist the new currentIndex and clear completedAt so LLM context stays in sync + onUpdateData((prev) => { + const current = prev as QuizData; + return { + ...current, + session: { + ...current.session, + currentIndex: prevCount, + completedAt: undefined, // Clear since we're continuing + } as QuizSessionData, + }; + }); + } else if (answeredCount === 0 && currentIndex === 0) { + // CASE 1: Quiz just started or was restarted (on first question, nothing answered) + // Just show a notification, stay where they are + toast.success(`${questionsAdded} new question${questionsAdded > 1 ? 's' : ''} added to this quiz!`); + } else { + // CASE 2: Quiz in progress (some answered, some not) + // Show notification with info about new questions + toast.success(`${questionsAdded} new question${questionsAdded > 1 ? 's' : ''} added! You now have ${currentCount} total.`); + } + } + + // Update refs for next comparison + prevQuestionCountRef.current = currentCount; + prevQuestionIdsRef.current = currentIds; + }, [questions, showResults, answeredQuestions.length, currentIndex]); + + // Check if current question was already answered + const previousAnswer = useMemo(() => { + return answeredQuestions.find(a => a.questionId === currentQuestion?.id); + }, [answeredQuestions, currentQuestion?.id]); + + // Restore state when navigating to previously answered question + useEffect(() => { + if (previousAnswer) { + setSelectedAnswer(previousAnswer.userAnswer); + setIsSubmitted(true); + } else { + setSelectedAnswer(null); + setIsSubmitted(false); + } + setShowHint(false); + }, [currentIndex, previousAnswer]); + + // Persist session state + const persistSession = useCallback((updates: Partial) => { + onUpdateData((prev) => { + const current = prev as QuizData; + return { + ...current, + session: { + currentIndex, + answeredQuestions, + ...current.session, + ...updates, + }, + }; + }); + }, [onUpdateData, currentIndex, answeredQuestions]); + + // Handle answer selection + const handleSelectAnswer = (index: number) => { + if (isSubmitted) return; + setSelectedAnswer(index); + }; + + // Handle answer submission + const handleSubmit = () => { + if (selectedAnswer === null || isSubmitted) return; + + const isCorrect = selectedAnswer === currentQuestion.correctIndex; + const newAnswer = { + questionId: currentQuestion.id, + userAnswer: selectedAnswer, + isCorrect, + }; + + const newAnsweredQuestions = [ + ...answeredQuestions.filter(a => a.questionId !== currentQuestion.id), + newAnswer, + ]; + + setAnsweredQuestions(newAnsweredQuestions); + setIsSubmitted(true); + + // Persist to data + persistSession({ + currentIndex, + answeredQuestions: newAnsweredQuestions, + startedAt: quizData.session?.startedAt || Date.now(), + }); + }; + + // Navigation + const handleNext = () => { + if (currentIndex < totalQuestions - 1) { + const nextIndex = currentIndex + 1; + setCurrentIndex(nextIndex); + persistSession({ currentIndex: nextIndex }); + } else { + // Show results + setShowResults(true); + persistSession({ completedAt: Date.now() }); + } + }; + + const handlePrevious = () => { + if (currentIndex > 0) { + const prevIndex = currentIndex - 1; + setCurrentIndex(prevIndex); + persistSession({ currentIndex: prevIndex }); + } + }; + + const handleRestart = () => { + setCurrentIndex(0); + setSelectedAnswer(null); + setIsSubmitted(false); + setShowHint(false); + setAnsweredQuestions([]); + setShowResults(false); + onUpdateData((prev) => ({ + ...prev, + session: undefined, + })); + }; + + // Calculate score + const score = useMemo(() => { + return answeredQuestions.filter(a => a.isCorrect).length; + }, [answeredQuestions]); + + // Difficulty badge colors + const difficultyColors = { + easy: "bg-green-500/20 text-green-400 border-green-500/30", + medium: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", + hard: "bg-red-500/20 text-red-400 border-red-500/30", + }; + + // Prevent focus stealing from chat input + const preventFocusSteal = (e: React.MouseEvent) => { + e.preventDefault(); + }; + + if (!currentQuestion && !showResults) { + return ( +
+ No questions in this quiz +
+ ); + } + + // Results view + if (showResults) { + const percentage = Math.round((score / totalQuestions) * 100); + return ( +
+ = 80 ? "text-yellow-400" : percentage >= 50 ? "text-blue-400" : "text-white/50" + )} /> +

Quiz Complete!

+

+ {score} / {totalQuestions} +

+

{percentage}% correct

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ {quizData.title && ( + + {quizData.title} + + )} + + {quizData.difficulty} + +
+ + {currentIndex + 1} / {totalQuestions} + +
+ {/* Progress bar */} +
+
+
+
+ + {/* Question */} +
+
+ + {currentQuestion.type === "true_false" ? "True or False" : "Multiple Choice"} + +
+ + {currentQuestion.questionText} + +
+
+ + {/* Options */} +
+ {currentQuestion.options.map((option, index) => { + const isSelected = selectedAnswer === index; + const isCorrect = index === currentQuestion.correctIndex; + const showCorrectness = isSubmitted; + + return ( + + ); + })} +
+ + {/* Hint */} + {currentQuestion.hint && !isSubmitted && ( + + )} + {showHint && currentQuestion.hint && ( +
+ {currentQuestion.hint} +
+ )} + + {/* Explanation */} + {isSubmitted && ( +
+
+ {selectedAnswer === currentQuestion.correctIndex ? ( + <> + + Correct! + + ) : ( + <> + + Incorrect + + )} +
+
+ + {currentQuestion.explanation} + +
+
+ )} +
+ + {/* Footer */} +
+
+ + +
+ {!isSubmitted ? ( + + ) : ( + + )} +
+ + +
+
+
+ ); +} + +export default QuizContent; diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index a0f72b7b..914a5e26 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -1,4 +1,6 @@ +import { QuizContent } from "./QuizContent"; import { MoreVertical, Trash2, Palette, CheckCircle2, FolderInput, FileText, Copy, X } from "lucide-react"; + import { PiMouseScrollFill, PiMouseScrollBold } from "react-icons/pi"; import { useCallback, useState, memo, useRef, useEffect, useMemo } from "react"; import { toast } from "sonner"; @@ -512,6 +514,13 @@ function WorkspaceCard({ } } + // Prevent opening modal for quiz cards as they are interactive + if (item.type === 'quiz') { + e.preventDefault(); + e.stopPropagation(); + return; + } + // For YouTube cards, handle click to play if (item.type === 'youtube') { // If we got here, it wasn't a drag (checked above) @@ -795,6 +804,17 @@ function WorkspaceCard({ ); })()} + {/* Quiz Content - render interactive quiz */} + {item.type === 'quiz' && ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + onUpdateItem(item.id, { data: updater(item.data) as any })} /> +
+ )} + {/* Flashcard Content - render interactive flashcard */} {item.type === 'flashcard' && (() => { const flashcardData = item.data as FlashcardData; diff --git a/src/lib/ai/workers.ts b/src/lib/ai/workers.ts index 1478affd..6db346fb 100644 --- a/src/lib/ai/workers.ts +++ b/src/lib/ai/workers.ts @@ -16,7 +16,7 @@ import { createEvent } from "@/lib/workspace/events"; import { generateItemId } from "@/lib/workspace-state/item-helpers"; import { getRandomCardColor } from "@/lib/workspace-state/colors"; import { logger } from "@/lib/utils/logger"; -import type { Item, NoteData } from "@/lib/workspace-state/types"; +import type { Item, NoteData, QuizData, QuizQuestion } from "@/lib/workspace-state/types"; import { markdownToBlocks } from "@/lib/editor/markdown-to-blocks"; /** @@ -161,18 +161,20 @@ async function executeWorkspaceOperation( * Operations are serialized per workspace to prevent version conflicts */ export async function workspaceWorker( - action: "create" | "update" | "delete" | "updateFlashcard", + action: "create" | "update" | "delete" | "updateFlashcard" | "updateQuiz", params: { workspaceId: string; title?: string; content?: string; // For notes itemId?: string; - itemType?: "note" | "flashcard"; // Defaults to "note" if undefined + itemType?: "note" | "flashcard" | "quiz"; // Defaults to "note" if undefined flashcardData?: { cards?: { front: string; back: string }[]; // For creating flashcards cardsToAdd?: { front: string; back: string }[]; // For updating flashcards (appending) }; + quizData?: QuizData; // For creating quizzes + questionsToAdd?: QuizQuestion[]; // For updating quizzes (appending questions) // Optional: deep research metadata to attach to a note deepResearchData?: { prompt: string; @@ -255,6 +257,19 @@ export async function workspaceWorker( itemData = { cards: cardsWithIds }; + } else if (itemType === "quiz") { + // Quiz type + if (!params.quizData) { + throw new Error("Quiz data required for quiz creation"); + } + + logger.debug("🎯 [WORKSPACE-WORKER] Creating quiz with data:", { + title: params.quizData.title, + difficulty: params.quizData.difficulty, + questionCount: params.quizData.questions?.length, + }); + + itemData = params.quizData; } else { // "note" type // Convert markdown to blocks on the server @@ -281,7 +296,7 @@ export async function workspaceWorker( const item: Item = { id: itemId, type: itemType, - name: params.title || (itemType === "flashcard" ? "New Flashcard Deck" : "New Note"), + name: params.title || (itemType === "quiz" ? "New Quiz" : itemType === "flashcard" ? "New Flashcard Deck" : "New Note"), subtitle: "", data: itemData, color: getRandomCardColor(), @@ -657,6 +672,150 @@ export async function workspaceWorker( }; } + if (action === "updateQuiz") { + if (!params.itemId) { + throw new Error("Item ID required for updateQuiz"); + } + + if (!params.questionsToAdd || params.questionsToAdd.length === 0) { + throw new Error("Questions to add required for updateQuiz"); + } + + logger.debug("🎯 [WORKSPACE-WORKER] Updating quiz with new questions:", { + itemId: params.itemId, + questionsToAdd: params.questionsToAdd.length, + }); + + // Get the latest snapshot state using the optimized function + const snapshotResult = await db.execute(sql` + SELECT state + FROM get_latest_snapshot_fast(${params.workspaceId}::uuid) + `); + + const snapshotState = snapshotResult[0]?.state as { items?: any[] } | null; + + // Get snapshot version separately + const snapshotVersionResult = await db.execute(sql` + SELECT snapshot_version as "snapshotVersion" + FROM get_latest_snapshot_fast(${params.workspaceId}::uuid) + `); + const snapshotVersion = (snapshotVersionResult[0]?.snapshotVersion as number) || 0; + + // Get events after the snapshot + const eventsResult = await db.execute(sql` + SELECT event_type as "eventType", payload + FROM workspace_events + WHERE workspace_id = ${params.workspaceId}::uuid + AND version > ${snapshotVersion} + ORDER BY version ASC + `); + + // Apply events to snapshot state to get current state + let currentItems = snapshotState?.items || []; + + for (const row of eventsResult) { + const event = row as { eventType: string; payload: any }; + + if (event.eventType === 'ITEM_CREATED' && event.payload?.item) { + currentItems = [...currentItems, event.payload.item]; + } else if (event.eventType === 'ITEM_UPDATED' && event.payload?.id) { + currentItems = currentItems.map((item: any) => + item.id === event.payload.id + ? { ...item, ...event.payload.changes } + : item + ); + } else if (event.eventType === 'ITEM_DELETED' && event.payload?.id) { + currentItems = currentItems.filter((item: any) => item.id !== event.payload.id); + } + } + + const existingItem = currentItems.find((i: any) => i.id === params.itemId); + if (!existingItem) { + throw new Error(`Quiz not found with ID: ${params.itemId}`); + } + + if (existingItem.type !== "quiz") { + throw new Error(`Item "${existingItem.name}" is not a quiz (type: ${existingItem.type})`); + } + + const existingData = existingItem.data as QuizData; + const existingQuestions = existingData?.questions || []; + + // Merge existing questions with new questions + const updatedData: QuizData = { + ...existingData, + questions: [...existingQuestions, ...params.questionsToAdd], + }; + + const changes = { data: updatedData }; + const event = createEvent("ITEM_UPDATED", { id: params.itemId, changes, source: 'agent' }, userId); + + logger.debug("📝 [UPDATE-QUIZ-DB] Created event:", { + eventId: event.id, + eventType: event.type, + payloadId: event.payload.id, + questionsInPayload: (event.payload.changes?.data as any)?.questions?.length, + }); + + const currentVersionResult = await db.execute(sql` + SELECT get_workspace_version(${params.workspaceId}::uuid) as version + `); + + const baseVersion = currentVersionResult[0]?.version || 0; + logger.debug("📝 [UPDATE-QUIZ-DB] Current version:", { baseVersion }); + + logger.debug("📝 [UPDATE-QUIZ-DB] Calling append_workspace_event..."); + const eventResult = await db.execute(sql` + SELECT append_workspace_event( + ${params.workspaceId}::uuid, + ${event.id}::text, + ${event.type}::text, + ${JSON.stringify(event.payload)}::jsonb, + ${event.timestamp}::bigint, + ${event.userId}::text, + ${baseVersion}::integer, + ${null}::text + ) as result + `); + + logger.info("📝 [UPDATE-QUIZ-DB] append_workspace_event result:", { + hasResult: !!eventResult, + resultLength: eventResult?.length, + rawResult: JSON.stringify(eventResult), + }); + + if (!eventResult || eventResult.length === 0) { + logger.error("❌ [UPDATE-QUIZ-DB] No result from append_workspace_event"); + throw new Error("Failed to update quiz: database returned no result"); + } + + const result = eventResult[0].result as any; + logger.info("📝 [UPDATE-QUIZ-DB] Parsed result:", { + result: JSON.stringify(result), + hasConflict: !!result?.conflict, + resultVersion: result?.version, + }); + + if (result && result.conflict) { + logger.error("❌ [UPDATE-QUIZ-DB] Version conflict detected"); + throw new Error("Workspace was modified by another user, please try again"); + } + + logger.info("🎯 [WORKSPACE-WORKER] Updated quiz:", { + itemId: params.itemId, + questionsAdded: params.questionsToAdd.length, + totalQuestions: updatedData.questions.length, + }); + + return { + success: true, + itemId: params.itemId, + questionsAdded: params.questionsToAdd.length, + totalQuestions: updatedData.questions.length, + message: `Added ${params.questionsToAdd.length} question${params.questionsToAdd.length !== 1 ? 's' : ''} to quiz`, + }; + } + if (action === "delete") { if (!params.itemId) { throw new Error("Item ID required for delete"); @@ -750,3 +909,221 @@ export async function textSelectionWorker( throw error; } } + +/** + * WORKER 5: Quiz Generation Agent + * Generates quiz questions using Gemini with structured JSON output + */ +type QuizWorkerParams = { + topic?: string; // Used only if no context provided + contextContent?: string; // Aggregated content from selected cards + sourceCardIds?: string[]; + sourceCardNames?: string[]; + difficulty: "easy" | "medium" | "hard"; + questionCount?: number; // Defaults to 10 + questionTypes?: ("multiple_choice" | "true_false")[]; + existingQuestions?: Array<{ + id: string; + questionText: string; + correctIndex: number; + }>; + performanceTelemetry?: { + totalAnswered: number; + correctCount: number; + incorrectCount: number; + weakAreas?: Array<{ + questionText: string; + userSelectedOption: string; + correctOption: string; + }>; + }; +}; + +function buildAdaptiveInstructions( + baseDifficulty: "easy" | "medium" | "hard", + telemetry?: QuizWorkerParams["performanceTelemetry"] +): string { + if (!telemetry || telemetry.totalAnswered === 0) { + return `- Difficulty: ${baseDifficulty.toUpperCase()}`; + } + + const successRate = telemetry.totalAnswered > 0 + ? telemetry.correctCount / telemetry.totalAnswered + : 0; + + let adjustedDifficulty: "easy" | "medium" | "hard"; + let cognitiveLevel: string; + + if (successRate >= 0.8) { + adjustedDifficulty = baseDifficulty === "hard" ? "hard" : baseDifficulty === "medium" ? "hard" : "medium"; + cognitiveLevel = "Analysis and synthesis - test deeper understanding"; + } else if (successRate >= 0.5) { + adjustedDifficulty = baseDifficulty; + cognitiveLevel = "Application - focus on practical usage"; + } else { + adjustedDifficulty = baseDifficulty === "easy" ? "easy" : baseDifficulty === "medium" ? "easy" : "medium"; + cognitiveLevel = "Recall and understanding - reinforce fundamentals"; + } + + let instructions = `- Adaptive Difficulty: ${adjustedDifficulty.toUpperCase()} (adjusted from ${baseDifficulty}) +- User Performance: ${Math.round(successRate * 100)}% correct (${telemetry.correctCount}/${telemetry.totalAnswered}) +- Cognitive Level: ${cognitiveLevel}`; + + if (telemetry.weakAreas && telemetry.weakAreas.length > 0 && successRate < 0.7) { + instructions += ` +LEARNING GAPS DETECTED - Include scaffolding questions for these weak areas: +${telemetry.weakAreas.slice(0, 3).map((w, i) => + `${i + 1}. Topic: "${w.questionText.substring(0, 50)}..." - User confused "${w.userSelectedOption}" with "${w.correctOption}"` + ).join('\n')} +For weak areas: +- Include 2-3 foundational questions that build up to the concept +- Add extra hints for these topics +- Break complex concepts into smaller parts`; + } + + return instructions; +} + +export async function quizWorker(params: QuizWorkerParams): Promise<{ questions: QuizQuestion[]; title: string }> { + try { + const questionCount = params.questionCount || 10; + const questionTypes = params.questionTypes || ["multiple_choice", "true_false"]; + + logger.debug("🎯 [QUIZ-WORKER] Starting quiz generation:", { + hasContext: !!params.contextContent, + hasTopic: !!params.topic, + difficulty: params.difficulty, + questionCount, + questionTypes, + }); + + // Build the prompt based on whether we have context or topic + let prompt: string; + const adaptiveInstructions = buildAdaptiveInstructions(params.difficulty, params.performanceTelemetry); + + if (params.contextContent) { + // Context-based quiz generation + prompt = `You are a quiz generator. Create exactly ${questionCount} quiz questions based EXCLUSIVELY on the following content. Do NOT use any external knowledge. + +CONTENT TO QUIZ ON: +${params.contextContent} + +REQUIREMENTS: +${adaptiveInstructions} +- Difficulty guide: + - Easy: Basic recall and recognition questions + - Medium: Understanding and application questions + - Hard: Analysis, synthesis, and critical thinking questions +- Question types allowed: ${questionTypes.join(", ")} +- For multiple_choice: provide exactly 4 options with only 1 correct answer +- For true_false: provide exactly 2 options (["True", "False"]) +- Each question must have a clear, specific explanation +- Each question should optionally have a helpful hint +- Questions must be directly answerable from the provided content + +SOURCE: ${params.sourceCardNames?.join(", ") || "Selected content"}`; + } else if (params.topic) { + // Topic-based quiz generation from LLM knowledge + prompt = `You are a quiz generator. Create exactly ${questionCount} quiz questions about: ${params.topic} + +REQUIREMENTS: +${adaptiveInstructions} +- Difficulty guide: + - Easy: Basic facts and definitions + - Medium: Understanding concepts and relationships + - Hard: Complex analysis and application +- Question types allowed: ${questionTypes.join(", ")} +- For multiple_choice: provide exactly 4 options with only 1 correct answer +- For true_false: provide exactly 2 options (["True", "False"]) +- Each question must have a clear, specific explanation +- Each question should optionally have a helpful hint +- Questions should cover diverse aspects of the topic`; + } else { + throw new Error("Either topic or contextContent must be provided"); + } + + if (params.existingQuestions && params.existingQuestions.length > 0) { + prompt += ` + +EXISTING QUESTIONS (DO NOT DUPLICATE): +The following ${params.existingQuestions.length} questions already exist. Generate NEW questions that: +1. Cover DIFFERENT aspects of the topic +2. Do NOT ask the same thing with different wording +3. Do NOT repeat any correct answers +Existing questions to avoid: +${params.existingQuestions.map((q, i) => `${i + 1}. ${q.questionText}`).join('\n')}`; + } + + prompt += ` + +OUTPUT FORMAT (strict JSON): +{ + "title": "Quiz Title", + "questions": [ + { + "id": "q_001", + "type": "multiple_choice", + "questionText": "Question text here?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "correctIndex": 0, + "hint": "Optional hint text", + "explanation": "Explanation of why this is correct" + } + ] +} + +Generate the quiz now:`; + + const result = await generateText({ + model: google("gemini-2.5-flash"), + prompt, + }); + + // Parse the JSON response + let parsed: { title: string; questions: any[] }; + try { + // Extract JSON from potential markdown code blocks + let jsonText = result.text.trim(); + if (jsonText.startsWith("```json")) { + jsonText = jsonText.slice(7); + } else if (jsonText.startsWith("```")) { + jsonText = jsonText.slice(3); + } + if (jsonText.endsWith("```")) { + jsonText = jsonText.slice(0, -3); + } + jsonText = jsonText.trim(); + + parsed = JSON.parse(jsonText); + } catch (parseError) { + logger.error("🎯 [QUIZ-WORKER] Failed to parse JSON:", parseError); + logger.error("🎯 [QUIZ-WORKER] Raw response:", result.text); + throw new Error("Failed to parse quiz generation response"); + } + + // Validate and transform questions - ALWAYS generate new unique IDs + // to prevent collisions when adding questions to existing quizzes + const questions: QuizQuestion[] = parsed.questions.map((q) => ({ + id: generateItemId(), // Always use unique ID, ignore AI-generated IDs (they're predictable like q_001) + type: q.type as QuestionType, + questionText: q.questionText || q.question_text || q.text || "", + options: q.options || [], + correctIndex: q.correctIndex ?? q.correct_index ?? 0, + hint: q.hint, + explanation: q.explanation || "No explanation provided.", + })); + + logger.debug("🎯 [QUIZ-WORKER] Generated quiz:", { + title: parsed.title, + questionCount: questions.length, + }); + + return { + title: parsed.title || params.topic || "Quiz", + questions, + }; + } catch (error) { + logger.error("🎯 [QUIZ-WORKER] Error:", error); + throw error; + } +} diff --git a/src/lib/workspace-state/grid-layout-helpers.ts b/src/lib/workspace-state/grid-layout-helpers.ts index d54da685..76128d10 100644 --- a/src/lib/workspace-state/grid-layout-helpers.ts +++ b/src/lib/workspace-state/grid-layout-helpers.ts @@ -10,6 +10,7 @@ export const DEFAULT_CARD_DIMENSIONS: Record flashcard: { w: 2, h: 5 }, folder: { w: 1, h: 4 }, youtube: { w: 2, h: 5 }, + quiz: { w: 2, h: 5 }, }; /** diff --git a/src/lib/workspace-state/types.ts b/src/lib/workspace-state/types.ts index 54d80843..25448eb4 100644 --- a/src/lib/workspace-state/types.ts +++ b/src/lib/workspace-state/types.ts @@ -1,6 +1,6 @@ import type { CardColor } from './colors'; -export type CardType = "note" | "pdf" | "flashcard" | "folder" | "youtube"; +export type CardType = "note" | "pdf" | "flashcard" | "folder" | "youtube" | "quiz"; export interface NoteData { field1?: string; // textarea - legacy plain text format @@ -47,7 +47,41 @@ export interface YouTubeData { url: string; // YouTube video URL } -export type ItemData = NoteData | PdfData | FlashcardData | FolderData | YouTubeData; +// Quiz Types +export type QuestionType = "multiple_choice" | "true_false"; + +export interface QuizQuestion { + id: string; + type: QuestionType; + questionText: string; + options: string[]; // Answer options (4 for MC, 2 for T/F) + correctIndex: number; // Index of correct answer in options array + hint?: string; // Optional hint text + explanation: string; // Explanation shown after answering + sourceContext?: string; // Optional: excerpt from source material +} + +export interface QuizSessionData { + currentIndex: number; + answeredQuestions: { + questionId: string; + userAnswer: number; // Index selected by user + isCorrect: boolean; + }[]; + startedAt?: number; // Timestamp when quiz was started + completedAt?: number; // Timestamp when quiz was completed +} + +export interface QuizData { + title?: string; + difficulty: "easy" | "medium" | "hard"; + sourceCardIds?: string[]; // IDs of cards used to generate (if context-based) + sourceCardNames?: string[]; // Names for display + questions: QuizQuestion[]; + session?: QuizSessionData; // Session state for resuming +} + +export type ItemData = NoteData | PdfData | FlashcardData | FolderData | YouTubeData | QuizData; // ===================================================== // FOLDER TYPES (DEPRECATED) From 18259f6ca150f336b79dd6ea2c67c6e9be7db77b Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sun, 18 Jan 2026 20:13:41 -0500 Subject: [PATCH 03/29] quiz merge --- src/app/api/chat/route.ts | 73 ++++++++-- .../SafeAssistantRuntimeProvider.tsx | 136 +++++++++++------- .../assistant-ui/UpdateQuizToolUI.tsx | 22 ++- src/components/assistant-ui/thread.tsx | 7 + .../workspace-canvas/QuizContent.tsx | 11 +- .../workspace-canvas/WorkspaceCard.tsx | 11 +- src/lib/ai/workers.ts | 62 +++++++- src/lib/utils/format-workspace-context.ts | 105 +++++++++++++- 8 files changed, 349 insertions(+), 78 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 4528b166..7d2e2a27 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1075,11 +1075,14 @@ Math is supported: use $$...$$ for inline and $$...$$ for display math within th // TOOL 10: Create Quiz createQuiz: { description: "Create an interactive quiz in the workspace. Generates multiple-choice and true/false questions. If cards are selected in the context drawer, questions are generated EXCLUSIVELY from that content. If no context is selected, generates questions from general knowledge about the provided topic. Creates a quiz card with 10 questions that the user can take interactively.", - inputSchema: z.object({ - topic: z.string().optional().describe("Topic for quiz (only used if no context is selected)"), - difficulty: z.enum(["easy", "medium", "hard"]).default("medium").describe("Difficulty level affecting question complexity"), - }), - execute: async ({ topic, difficulty }: { topic?: string; difficulty: "easy" | "medium" | "hard" }) => { + // Use z.any() to avoid streaming validation errors when Gemini sends properties in random order + // The error "argsText can only be appended" happens because strict z.object() validates during streaming + inputSchema: z.any().describe("Object with optional 'topic' (string) and 'difficulty' ('easy'|'medium'|'hard', default 'medium')"), + execute: async (args: unknown) => { + // Manually extract and validate args since we're using z.any() + const parsedArgs = args as { topic?: string; difficulty?: string } | null; + const topic = parsedArgs?.topic; + const difficulty = (parsedArgs?.difficulty as "easy" | "medium" | "hard") || "medium"; logger.debug("🎯 [CREATE-QUIZ] Tool execution started:", { topic, difficulty }); if (!workspaceId) { @@ -1103,19 +1106,63 @@ Math is supported: use $$...$$ for inline and $$...$$ for display math within th const match = text.match(new RegExp(`${marker}([\\s\\S]*?)${marker}`)); if (!match) return null; - const context = match[1]; - const nameMatches = context.matchAll(/CARD\s+\d+:\s+.*"([^"]+)"/g); - const idMatches = context.matchAll(/Card ID:\s*([a-zA-Z0-9_-]+)/g); + const rawContext = match[1]; + + // Extract card names and IDs + const nameMatches = rawContext.matchAll(/CARD\s+\d+:\s+.*"([^"]+)"/g); + const idMatches = rawContext.matchAll(/Card ID:\s*([a-zA-Z0-9_-]+)/g); const names = Array.from(nameMatches).map(m => m[1]); const ids = Array.from(idMatches).map(m => m[1]); - return { context, names, ids }; + // Extract ONLY the actual content, not metadata + // Look for "📄 CONTENT:" sections and extract what follows until the next section + const contentSections: string[] = []; + + // Split by card separators and process each card + const cardBlocks = rawContext.split(/━+/); + + for (const block of cardBlocks) { + // Find content section - it starts after "📄 CONTENT:" and ends at "🔧 METADATA:" or end of block + const contentMatch = block.match(/📄 CONTENT:\s*([\s\S]*?)(?=🔧 METADATA:|$)/); + if (contentMatch && contentMatch[1]) { + let content = contentMatch[1].trim(); + // Remove the "- Content:" prefix if present + content = content.replace(/^\s*-\s*Content:\s*/i, ''); + // Remove leading indentation + content = content.replace(/^\s{2,}/gm, ''); + if (content && content.length > 10) { // Only add meaningful content + // Also extract the card name for context + const cardNameMatch = block.match(/CARD\s+\d+:\s+[^\[]*\[([^\]]+)\]\s+"([^"]+)"/); + if (cardNameMatch) { + contentSections.push(`## ${cardNameMatch[2]}\n${content}`); + } else { + contentSections.push(content); + } + } + } + } + + // If no content was extracted using the structured approach, fall back to the raw context + // but strip obvious metadata patterns + let cleanedContext = contentSections.length > 0 + ? contentSections.join('\n\n') + : rawContext + .replace(/CARD\s+\d+:.*$/gm, '') + .replace(/⚡ Card ID:.*$/gm, '') + .replace(/🔧 METADATA:[\s\S]*?(?=CARD|$)/g, '') + .replace(/📄 CONTENT:/g, '') + .replace(/━+/g, '') + .replace(/Card ID:\s*[a-zA-Z0-9_-]+/g, '') + .replace(/Type:\s*\w+/g, '') + .trim(); + + return { context: cleanedContext, names, ids }; }; for (const msg of convertedMessages) { if (typeof msg.content === "string") { const extracted = extractSelectedCardsContext(msg.content); - if (extracted) { + if (extracted && extracted.context) { contextContent = extracted.context; sourceCardNames = extracted.names; sourceCardIds = extracted.ids; @@ -1127,7 +1174,7 @@ Math is supported: use $$...$$ for inline and $$...$$ for display math within th for (const part of msg.content) { if (part.type === "text" && typeof part.text === "string") { const extracted = extractSelectedCardsContext(part.text); - if (extracted) { + if (extracted && extracted.context) { contextContent = extracted.context; sourceCardNames = extracted.names; sourceCardIds = extracted.ids; @@ -1148,8 +1195,10 @@ Math is supported: use $$...$$ for inline and $$...$$ for display math within th } // Generate quiz questions + // IMPORTANT: If user provides a topic, use it (context supplements but doesn't override) + // If no topic but context exists, generate from context const quizResult = await quizWorker({ - topic: contextContent ? undefined : topic, + topic: topic || (contextContent ? undefined : undefined), contextContent, sourceCardIds, sourceCardNames, diff --git a/src/components/assistant-ui/SafeAssistantRuntimeProvider.tsx b/src/components/assistant-ui/SafeAssistantRuntimeProvider.tsx index 468bcef4..b6d4803f 100644 --- a/src/components/assistant-ui/SafeAssistantRuntimeProvider.tsx +++ b/src/components/assistant-ui/SafeAssistantRuntimeProvider.tsx @@ -3,6 +3,7 @@ import { Component, ReactNode } from "react"; import { AssistantRuntimeProvider } from "@assistant-ui/react"; import { AssistantRuntime } from "@assistant-ui/react"; +import { toast } from "sonner"; interface Props { runtime: AssistantRuntime; @@ -11,90 +12,125 @@ interface Props { interface State { hasError: boolean; - remountKey: number; + errorCount: number; + isRecovering: boolean; } export class SafeAssistantRuntimeProvider extends Component { + private recoveryTimeout: NodeJS.Timeout | null = null; + + private static isTransientErrorMessage(errorMessage: string): boolean { + return ( + errorMessage.includes("tapLookupResources") || + errorMessage.includes("Resource not found") || + errorMessage.includes("argsText can only be appended") || + errorMessage.includes("does not start with") + ); + } + constructor(props: Props) { super(props); - this.state = { hasError: false, remountKey: 0 }; + this.state = { hasError: false, errorCount: 0, isRecovering: false }; } static getDerivedStateFromError(error: unknown) { - return { hasError: true }; + // Check if this is a known transient error that we can recover from + const errorMessage = error instanceof Error ? error.message : String(error); + const isTransientError = SafeAssistantRuntimeProvider.isTransientErrorMessage(errorMessage); + + // For transient errors, return NULL to avoid any state change + // This completely suppresses the error and prevents re-renders that cause focus loss + if (isTransientError) { + console.warn("[SafeAssistantRuntimeProvider] Suppressing transient error:", + errorMessage.substring(0, 100)); + return null; // No state change - error is completely suppressed + } + + return { hasError: true, isRecovering: false }; } componentDidCatch(error: unknown, errorInfo: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); - // Check if this is a known transient runtime error - const isTransientError = - errorMessage.includes("tapLookupResources") || - errorMessage.includes("Resource not found") || - errorMessage.includes("ThreadListItemRuntime is not available"); + // List of known transient errors that we can safely suppress and recover from + const isTransientError = SafeAssistantRuntimeProvider.isTransientErrorMessage(errorMessage); if (isTransientError) { - console.warn("[SafeAssistantRuntimeProvider] Caught transient runtime error, attempting graceful recovery:", error); - // Attempt to recover by resetting error state and incrementing remount key after a short delay - // This allows the tree to re-mount with a fresh key, hopefully after the race condition passes - setTimeout(() => { - if (this.state.hasError) { - this.setState({ - hasError: false, - remountKey: this.state.remountKey + 1 - }); - } - }, 100); + // For transient errors, just log and return - no state changes + // The error has already been suppressed in getDerivedStateFromError + console.warn("[SafeAssistantRuntimeProvider] Transient error suppressed, continuing:", + errorMessage.substring(0, 100)); + return; // Do nothing - error is completely swallowed } else { console.error("[SafeAssistantRuntimeProvider] Unhandled error:", error, errorInfo); - // For non-transient errors, still try to recover after a longer delay - setTimeout(() => { + + // Clear any existing recovery timeout + if (this.recoveryTimeout) { + clearTimeout(this.recoveryTimeout); + } + + // For non-transient errors, still try to recover after a delay + this.recoveryTimeout = setTimeout(() => { if (this.state.hasError) { - this.setState({ + this.setState((prev) => ({ hasError: false, - remountKey: this.state.remountKey + 1 - }); + errorCount: prev.errorCount + 1 + })); } - }, 500); + }, 100); } } componentDidUpdate(prevProps: Props) { - // If runtime changed, try to recover from error state - if (prevProps.runtime !== this.props.runtime && this.state.hasError) { - this.setState({ - hasError: false, - remountKey: this.state.remountKey + 1 - }); + // If runtime changed, reset all error states + if (prevProps.runtime !== this.props.runtime) { + if (this.state.hasError || this.state.isRecovering) { + this.setState({ hasError: false, isRecovering: false, errorCount: 0 }); + } + } + } + + componentWillUnmount() { + if (this.recoveryTimeout) { + clearTimeout(this.recoveryTimeout); } } render() { + // During recovery or minor errors, keep rendering children + // This prevents the blank screen issue + if (this.state.isRecovering) { + // Keep rendering - the recovery timeout will clear this state quickly + return ( + + {this.props.children} + + ); + } + if (this.state.hasError) { - // During error state, render a minimal invisible placeholder - // This prevents gray screen while avoiding hook errors from children - // The setTimeout in componentDidCatch will trigger recovery + // For serious errors, still try to render but with the runtime + // This is better than returning null which causes blank screen + if (this.state.errorCount < 3) { + // Try to render anyway - the componentDidCatch recovery will kick in + return ( + + {this.props.children} + + ); + } + + // After 3 failed attempts, show a minimal fallback + console.error("[SafeAssistantRuntimeProvider] Multiple recovery attempts failed, rendering fallback"); return ( - {/* Question */} -
+
{currentQuestion.type === "true_false" ? "True or False" : "Multiple Choice"} diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index c6183493..040320e3 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -807,10 +807,14 @@ function WorkspaceCard({ {/* Quiz Content - render interactive quiz */} {item.type === 'quiz' && (
e.stopPropagation()} > - onUpdateItem(item.id, { data: updater(item.data) as any })} /> + onUpdateItem(item.id, { data: updater(item.data) as any })} + isScrollLocked={isScrollLocked} + />
)} From 4679f8de8f62818ae9743fa2db09be0d3f1126c5 Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sun, 18 Jan 2026 21:00:43 -0500 Subject: [PATCH 05/29] style: cleanup minimized quiz card appearance --- src/components/workspace-canvas/WorkspaceCard.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 040320e3..038c2f68 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -755,18 +755,19 @@ function WorkspaceCard({ onNameChange={handleNameChange} onNameCommit={handleNameCommit} onSubtitleChange={handleSubtitleChange} - readOnly={(item.type === 'note' || item.type === 'pdf') && !shouldShowPreview} + readOnly={(item.type === 'note' || item.type === 'pdf' || item.type === 'quiz') && !shouldShowPreview} noMargin={true} onTitleFocus={handleTitleFocus} onTitleBlur={handleTitleBlur} - allowWrap={(item.type === 'note' || item.type === 'pdf') && !shouldShowPreview} + allowWrap={(item.type === 'note' || item.type === 'pdf' || item.type === 'quiz') && !shouldShowPreview} /> ) } {/* Subtle type label for narrow cards without preview */} - {(item.type === 'note' || item.type === 'pdf') && !shouldShowPreview && ( + {/* Subtle type label for narrow cards without preview */} + {(item.type === 'note' || item.type === 'pdf' || item.type === 'quiz') && !shouldShowPreview && ( - {item.type === 'note' ? 'Note' : 'PDF'} + {item.type === 'note' ? 'Note' : item.type === 'pdf' ? 'PDF' : 'Quiz'} )}
@@ -805,7 +806,7 @@ function WorkspaceCard({ })()} {/* Quiz Content - render interactive quiz */} - {item.type === 'quiz' && ( + {item.type === 'quiz' && shouldShowPreview && (
e.stopPropagation()} From 1da92245ed462151cc94181aa97f5ba9be0958eb Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sun, 18 Jan 2026 21:34:06 -0500 Subject: [PATCH 06/29] style: adjust vertical resize sensitivity (less sensitive expanded/collapsed thresholds) --- src/components/workspace-canvas/WorkspaceGrid.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index 1f6755de..5a5a11ab 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -410,21 +410,24 @@ export function WorkspaceGrid({ // Compact mode: w=1, h=4 | Expanded mode: w>=2, h>=9 const wasCompact = oldItem.w === 1; const widthChanged = oldItem.w !== newItem.w; - + // Check for mode transitions triggered by height-only resize if (!widthChanged) { - if (wasCompact && newItem.h > 4) { + if (wasCompact && newItem.h > 6) { // Growing a compact card taller → expand to wide mode + // Increased threshold from 4 to 6 to make it less sensitive to accidental drags newItem.w = 2; - } else if (!wasCompact && newItem.h < 9) { + } else if (!wasCompact && newItem.h < 6) { // Shrinking a wide card shorter → collapse to compact mode + // Decreased threshold from 9 to 6 to allow shorter cards without minimizing newItem.w = 1; } } - + // Apply constraints based on final width if (newItem.w >= 2) { - newItem.h = Math.max(newItem.h, 9); + // Allow expanded cards to be as short as 6 rows (was 9) + newItem.h = Math.max(newItem.h, 6); } else { newItem.h = 4; } From 81380a63ef43642297c0f0d8c6d3043be025510e Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sun, 18 Jan 2026 22:18:19 -0500 Subject: [PATCH 07/29] changes --- src/components/workspace-canvas/WorkspaceGrid.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index 5a5a11ab..56e9b316 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -413,21 +413,18 @@ export function WorkspaceGrid({ // Check for mode transitions triggered by height-only resize if (!widthChanged) { - if (wasCompact && newItem.h > 6) { + if (wasCompact && newItem.h > 4) { // Growing a compact card taller → expand to wide mode - // Increased threshold from 4 to 6 to make it less sensitive to accidental drags newItem.w = 2; - } else if (!wasCompact && newItem.h < 6) { + } else if (!wasCompact && newItem.h < 9) { // Shrinking a wide card shorter → collapse to compact mode - // Decreased threshold from 9 to 6 to allow shorter cards without minimizing newItem.w = 1; } } // Apply constraints based on final width if (newItem.w >= 2) { - // Allow expanded cards to be as short as 6 rows (was 9) - newItem.h = Math.max(newItem.h, 6); + newItem.h = Math.max(newItem.h, 9); } else { newItem.h = 4; } From ee44f46d021784473efd6bbceb5760b969f238ca Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 18 Jan 2026 23:28:47 -0500 Subject: [PATCH 08/29] fix: replace console logs with debug logger in deep research status route --- src/app/api/deep-research/status/route.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/api/deep-research/status/route.ts b/src/app/api/deep-research/status/route.ts index 7fac40ce..90cc86e4 100644 --- a/src/app/api/deep-research/status/route.ts +++ b/src/app/api/deep-research/status/route.ts @@ -1,5 +1,6 @@ import { GoogleGenAI } from "@google/genai"; import { NextRequest, NextResponse } from "next/server"; +import { logger } from "@/lib/utils/logger"; // Initialize client const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; if (!apiKey) { @@ -24,10 +25,10 @@ export async function GET(req: NextRequest) { // Get the interaction without streaming - this returns the current state const interaction = await client.interactions.get(interactionId); // DEBUG: Log the entire raw interaction object (optional, kept minimal) - console.log('[DEEP-RESEARCH-STATUS] ========== RAW INTERACTION PAYLOAD =========='); - console.log('[DEEP-RESEARCH-STATUS] Interaction ID:', interactionId); - console.log('[DEEP-RESEARCH-STATUS] State object:', (interaction as any).state); - console.log('[DEEP-RESEARCH-STATUS] Status field:', (interaction as any).status); + logger.debug('[DEEP-RESEARCH-STATUS] ========== RAW INTERACTION PAYLOAD =========='); + logger.debug('[DEEP-RESEARCH-STATUS] Interaction ID:', interactionId); + logger.debug('[DEEP-RESEARCH-STATUS] State object:', (interaction as any).state); + logger.debug('[DEEP-RESEARCH-STATUS] Status field:', (interaction as any).status); // Extract status from interaction - check multiple possible properties const rawStatus = (interaction as any).state?.status || (interaction as any).status || From daca01a9d1ccca50e035c8b2e29d78d1faa23cb7 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 18 Jan 2026 23:33:57 -0500 Subject: [PATCH 09/29] fix(quiz-tool): resolve UI mismatch, add error handling and context flag --- .../assistant-ui/CreateQuizToolUI.tsx | 25 +++++++++++-------- src/lib/ai/tools/quiz-tools.ts | 1 + 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/assistant-ui/CreateQuizToolUI.tsx b/src/components/assistant-ui/CreateQuizToolUI.tsx index e1f5fbd1..7dc81868 100644 --- a/src/components/assistant-ui/CreateQuizToolUI.tsx +++ b/src/components/assistant-ui/CreateQuizToolUI.tsx @@ -31,6 +31,7 @@ type CreateQuizResult = { difficulty?: "easy" | "medium" | "hard"; isContextBased?: boolean; itemId?: string; + quizId?: string; // Add quizId to match tool output }; interface CreateQuizReceiptProps { @@ -69,9 +70,10 @@ const CreateQuizReceipt = ({ // Get the current item from workspace state const currentItem = useMemo(() => { - if (!result.itemId || !workspaceState?.items) return undefined; - return workspaceState.items.find((item: any) => item.id === result.itemId); - }, [result.itemId, workspaceState?.items]); + const targetId = result.itemId || result.quizId; + if (!targetId || !workspaceState?.items) return undefined; + return workspaceState.items.find((item: any) => item.id === targetId); + }, [result.itemId, result.quizId, workspaceState?.items]); // Get folder name if item is in a folder const folderName = useMemo(() => { @@ -81,13 +83,15 @@ const CreateQuizReceipt = ({ }, [currentItem?.folderId, workspaceState?.items]); const handleViewCard = () => { - if (!result.itemId) return; - navigateToItem(result.itemId); + const targetId = result.itemId || result.quizId; + if (!targetId) return; + navigateToItem(targetId); }; const handleMoveToFolder = (folderId: string | null) => { - if (moveItemToFolder && result.itemId) { - moveItemToFolder(result.itemId, folderId); + const targetId = result.itemId || result.quizId; + if (moveItemToFolder && targetId) { + moveItemToFolder(targetId, folderId); } }; @@ -124,7 +128,7 @@ const CreateQuizReceipt = ({ )}
- {status?.type === "complete" && result.itemId && ( + {status?.type === "complete" && (result.itemId || result.quizId) && (