diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts
index 89c441eb..c0e40761 100644
--- a/src/app/api/chat/route.ts
+++ b/src/app/api/chat/route.ts
@@ -215,6 +215,7 @@ export async function POST(req: Request) {
userId,
activeFolderId,
clientTools: body.tools,
+ messages: cleanedMessages, // For quiz context extraction
});
// Stream the response
diff --git a/src/app/api/deep-research/status/route.ts b/src/app/api/deep-research/status/route.ts
index 75a7b054..90cc86e4 100644
--- a/src/app/api/deep-research/status/route.ts
+++ b/src/app/api/deep-research/status/route.ts
@@ -1,18 +1,15 @@
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) {
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 +18,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)
+ 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 ||
- "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 +65,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 +81,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 +92,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 +100,6 @@ export async function GET(req: NextRequest) {
} else if (statusLower === "failed" || statusLower === "error" || error) {
mappedStatus = "failed";
}
-
return NextResponse.json({
status: mappedStatus,
thoughts,
@@ -105,7 +109,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/app/globals.css b/src/app/globals.css
index b957be80..7a348654 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1239,4 +1239,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..7dc81868
--- /dev/null
+++ b/src/components/assistant-ui/CreateQuizToolUI.tsx
@@ -0,0 +1,288 @@
+"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;
+ quizId?: string; // Add quizId to match tool output
+};
+
+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(() => {
+ 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(() => {
+ 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 = () => {
+ const targetId = result.itemId || result.quizId;
+ if (!targetId) return;
+ navigateToItem(targetId);
+ };
+
+ const handleMoveToFolder = (folderId: string | null) => {
+ const targetId = result.itemId || result.quizId;
+ if (moveItemToFolder && targetId) {
+ moveItemToFolder(targetId, 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 || result.quizId) && (
+
+ )}
+
+
+ {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 (handles "incomplete" error OR "complete" but failure)
+ if ((status.type === "incomplete" && status.reason === "error") ||
+ (status.type === "complete" && result && !result.success)) {
+ 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..f5b3d7a1
--- /dev/null
+++ b/src/components/assistant-ui/UpdateQuizToolUI.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+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;
+ quizId?: string; // Add quizId to match tool output
+ 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
+ // Fallback to args.quizId if result doesn't have ID (though it should)
+ const targetId = result.quizId || result.itemId || args.quizId;
+ const refetchKey = `${targetId}-${result.totalQuestions}`;
+ if (hasRefetchedRef.current === refetchKey) {
+ return; // Already refetched for this result
+ }
+ hasRefetchedRef.current = refetchKey;
+
+ // Small delay to ensure database has processed the event
+ // Then invalidate cache and refetch to get fresh data
+ setTimeout(() => {
+ // Invalidate the cache to force fresh fetch
+ queryClient.invalidateQueries({
+ queryKey: ["workspace", workspaceId, "events"],
+ });
+ // Then refetch
+ queryClient.refetchQueries({
+ queryKey: ["workspace", workspaceId, "events"],
+ type: 'active'
+ }).catch((err) => {
+ console.error("❌ [UpdateQuizTool] Refetch failed:", err);
+ });
+ }, 500); // 500ms delay for database processing
+ }
+ }, [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 98bae91e..dd4447f9 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";
@@ -115,6 +117,8 @@ export const Thread: FC = ({ items = [] }) => {
{/* Register tool UI - this component mounts and registers the UI with the assistant runtime */}
+
+
@@ -521,6 +525,13 @@ const Composer: FC = ({ items }) => {
return (
{
+ // Focus the input when clicking anywhere in the composer area
+ // This allows users to easily return focus after interacting with quizzes or other cards
+ if (inputRef.current && !e.defaultPrevented) {
+ inputRef.current.focus();
+ }
+ }}
onSubmit={async (e) => {
e.preventDefault();
diff --git a/src/components/editor/BlockNoteEditor.tsx b/src/components/editor/BlockNoteEditor.tsx
index 3cf5f33b..8cdd0bc4 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/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;
+ isScrollLocked?: boolean;
+}
+
+export function QuizContent({ item, onUpdateData, isScrollLocked = false }: 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 || []
+ );
+
+ // Initialize showResults based on whether quiz is completed
+ // Quiz is completed ONLY if: completedAt exists AND all questions are answered
+ const isInitiallyCompleted = !!(
+ quizData.session?.completedAt &&
+ quizData.session?.answeredQuestions?.length &&
+ questions.length > 0 &&
+ quizData.session.answeredQuestions.length >= questions.length
+ );
+ const [showResults, setShowResults] = useState(isInitiallyCompleted);
+
+ // 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;
+
+ // Sync effect: ensure showResults state matches reality of questions vs answered
+ // and handle new questions being added
+ 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;
+
+ // Check if we have unanswered questions
+ const hasUnansweredQuestions = answeredQuestions.length < currentCount;
+
+ if (questionsAdded > 0 && currentCount > prevCount) {
+ // New questions were added
+
+ if (showResults) {
+ // If we were showing results, we need to hide them and let user answer new questions
+ 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);
+
+ // Clear completedAt since we're no longer complete
+ onUpdateData((prev) => {
+ const current = prev as QuizData;
+ return {
+ ...current,
+ session: {
+ ...current.session,
+ currentIndex: prevCount,
+ completedAt: undefined,
+ } as QuizSessionData,
+ };
+ });
+ } else {
+ // Just notify user
+ toast.success(`${questionsAdded} new question${questionsAdded > 1 ? 's' : ''} added!`);
+ }
+ } else if (showResults && hasUnansweredQuestions) {
+ // Sanity check: if showing results but we have unanswered questions (e.g. from sync mismatch),
+ // force exit results mode
+ setShowResults(false);
+ }
+
+ // Update refs for next comparison
+ prevQuestionCountRef.current = currentCount;
+ prevQuestionIdsRef.current = currentIds;
+ }, [questions, showResults, answeredQuestions.length, currentIndex, onUpdateData]);
+
+ // 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 = totalQuestions > 0 ? Math.round((score / totalQuestions) * 100) : 0;
+ 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 5fddc98b..04166ef1 100644
--- a/src/components/workspace-canvas/WorkspaceCard.tsx
+++ b/src/components/workspace-canvas/WorkspaceCard.tsx
@@ -1,3 +1,4 @@
+import { QuizContent } from "./QuizContent";
import { MoreVertical, Trash2, Palette, CheckCircle2, FolderInput, FileText, Copy, X, Pencil } from "lucide-react";
import { PiMouseScrollFill, PiMouseScrollBold } from "react-icons/pi";
import { useCallback, useState, memo, useRef, useEffect, useMemo } from "react";
@@ -519,6 +520,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)
@@ -757,18 +765,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'}
)}
@@ -806,6 +815,20 @@ function WorkspaceCard({
);
})()}
+ {/* Quiz Content - render interactive quiz */}
+ {item.type === 'quiz' && shouldShowPreview && (
+ e.stopPropagation()}
+ >
+ onUpdateItem(item.id, { data: updater(item.data) as any })}
+ isScrollLocked={isScrollLocked}
+ />
+
+ )}
+
{/* Flashcard Content - render interactive flashcard */}
{item.type === 'flashcard' && (() => {
const flashcardData = item.data as FlashcardData;
@@ -1075,6 +1098,11 @@ export const WorkspaceCardMemoized = memo(WorkspaceCard, (prevProps, nextProps)
const nextData = nextProps.item.data;
if (JSON.stringify(prevData) !== JSON.stringify(nextData)) return false;
}
+ if (prevProps.item.type === 'quiz' && nextProps.item.type === 'quiz') {
+ const prevData = prevProps.item.data;
+ const nextData = nextProps.item.data;
+ if (JSON.stringify(prevData) !== JSON.stringify(nextData)) return false;
+ }
// Compare layout (use lg breakpoint for comparison)
const prevLayout = getLayoutForBreakpoint(prevProps.item, 'lg');
diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx
index ff5bdfd8..a95df029 100644
--- a/src/components/workspace-canvas/WorkspaceGrid.tsx
+++ b/src/components/workspace-canvas/WorkspaceGrid.tsx
@@ -411,7 +411,7 @@ 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) {
@@ -422,7 +422,7 @@ export function WorkspaceGrid({
newItem.w = 1;
}
}
-
+
// Apply constraints based on final width
if (newItem.w >= 2) {
newItem.h = Math.max(newItem.h, 9);
diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts
index b00bcac6..759ea91e 100644
--- a/src/lib/ai/tools/index.ts
+++ b/src/lib/ai/tools/index.ts
@@ -16,6 +16,7 @@ import {
type WorkspaceToolContext,
} from "./workspace-tools";
import { createFlashcardsTool, createUpdateFlashcardsTool } from "./flashcard-tools";
+import { createQuizTool, createUpdateQuizTool } from "./quiz-tools";
import { createDeepResearchTool } from "./deep-research";
import { logger } from "@/lib/utils/logger";
@@ -24,6 +25,8 @@ export interface ChatToolsConfig {
userId: string | null;
activeFolderId?: string;
clientTools?: Record;
+ /** Messages from the conversation - needed for quiz context extraction */
+ messages?: any[];
}
/**
@@ -64,6 +67,10 @@ export function createChatTools(config: ChatToolsConfig): Record {
createFlashcards: createFlashcardsTool(ctx),
updateFlashcards: createUpdateFlashcardsTool(ctx),
+ // Quizzes
+ createQuiz: createQuizTool(ctx, config.messages || []),
+ updateQuiz: createUpdateQuizTool(ctx, config.messages || []),
+
// Deep research
deepResearch: createDeepResearchTool(ctx),
diff --git a/src/lib/ai/tools/quiz-tools.ts b/src/lib/ai/tools/quiz-tools.ts
new file mode 100644
index 00000000..14acf76e
--- /dev/null
+++ b/src/lib/ai/tools/quiz-tools.ts
@@ -0,0 +1,463 @@
+/**
+ * Quiz Tools
+ * Tools for creating and updating quizzes in workspaces
+ */
+
+import { z } from "zod";
+import { logger } from "@/lib/utils/logger";
+import { quizWorker, workspaceWorker } from "@/lib/ai/workers";
+import { loadWorkspaceState } from "@/lib/workspace/state-loader";
+import type { WorkspaceToolContext } from "./workspace-tools";
+import type { QuizData } from "@/lib/workspace-state/types";
+
+/**
+ * Extract content from selected cards context
+ * Returns null if no cards are selected (the marker contains "NO CARDS SELECTED")
+ */
+function extractSelectedCardsContext(text: string) {
+ const marker = "[[SELECTED_CARDS_MARKER]]";
+ const match = text.match(new RegExp(`${marker}([\\s\\S]*?)${marker}`));
+ if (!match) return null;
+
+ const rawContext = match[1];
+
+ // Check if this is the "no cards selected" case
+ // When no cards are selected, formatSelectedCardsContext returns:
+ // "\nNO CARDS SELECTED.\n..."
+ if (rawContext.includes("NO CARDS SELECTED")) {
+ logger.debug("🎯 [EXTRACT-CONTEXT] No cards selected, returning null");
+ return null;
+ }
+
+ // 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]);
+
+ // If we don't find any card IDs, there are no actual cards selected
+ if (ids.length === 0) {
+ logger.debug("🎯 [EXTRACT-CONTEXT] No card IDs found in context, returning null");
+ return null;
+ }
+
+ // Extract ONLY the actual content, not metadata
+ const contentSections: string[] = [];
+ const cardBlocks = rawContext.split(/━+/);
+
+ for (const block of cardBlocks) {
+ const contentMatch = block.match(/📄 CONTENT:\s*([\s\S]*?)(?=🔧 METADATA:|$)/);
+ if (contentMatch && contentMatch[1]) {
+ let content = contentMatch[1].trim();
+ content = content.replace(/^\s*-\s*Content:\s*/i, '');
+ content = content.replace(/^\s{2,}/gm, '');
+ if (content && content.length > 10) {
+ const cardNameMatch = block.match(/CARD\s+\d+:\s+[^\[]*\[([^\]]+)\]\s+"([^"]+)"/);
+ if (cardNameMatch) {
+ contentSections.push(`## ${cardNameMatch[2]}\n${content}`);
+ } else {
+ contentSections.push(content);
+ }
+ }
+ }
+ }
+
+ 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();
+
+ // Final check: if cleanedContext is empty or too short, return null
+ if (!cleanedContext || cleanedContext.length < 20) {
+ logger.debug("🎯 [EXTRACT-CONTEXT] Cleaned context too short, returning null");
+ return null;
+ }
+
+ return { context: cleanedContext, names, ids };
+}
+
+/**
+ * Extract the user's latest message content to use as topic source
+ * This captures what the user actually asked for when creating/updating a quiz
+ */
+function extractUserMessage(messages: any[]): string | undefined {
+ // Find the last user message (most recent request)
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i];
+ if (msg.role === 'user') {
+ if (typeof msg.content === 'string') {
+ // Remove any card markers and clean up
+ return cleanMessageForTopic(msg.content);
+ } else if (Array.isArray(msg.content)) {
+ // Handle multi-part messages
+ for (const part of msg.content) {
+ if (part.type === 'text' && typeof part.text === 'string') {
+ return cleanMessageForTopic(part.text);
+ }
+ }
+ }
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Clean a message to extract the topic intent
+ * Removes card markers, file markers, and other metadata
+ */
+function cleanMessageForTopic(text: string): string {
+ // Remove SELECTED_CARDS_MARKER blocks
+ const cardsMarker = "[[SELECTED_CARDS_MARKER]]";
+ let cleaned = text.replace(new RegExp(`${cardsMarker}[\\s\\S]*?${cardsMarker}`, 'g'), '');
+
+ // Remove FILE_URL markers
+ cleaned = cleaned.replace(/\[FILE_URL:[^\]]+\]/g, '');
+
+ // Remove URL_CONTEXT markers
+ cleaned = cleaned.replace(/\[URL_CONTEXT:[^\]]+\]/g, '');
+
+ // Trim and return
+ return cleaned.trim();
+}
+
+/**
+ * Create the createQuiz tool
+ */
+export function createQuizTool(ctx: WorkspaceToolContext, convertedMessages: any[]) {
+ return {
+ 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 topic the user specified in their message. Creates a quiz card with 10 questions that the user can take interactively. IMPORTANT: When calling this tool, you MUST extract the topic from the user's message and pass it as the 'topic' parameter.",
+ // 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 'topic' (string - REQUIRED: extract from user's message what they want the quiz to be about) and optional '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;
+ let topic = parsedArgs?.topic;
+ const difficulty = (parsedArgs?.difficulty as "easy" | "medium" | "hard") || "medium";
+ logger.debug("🎯 [CREATE-QUIZ] Tool execution started:", { topic, difficulty });
+
+ if (!ctx.workspaceId) {
+ return {
+ success: false,
+ message: "No workspace context available",
+ };
+ }
+
+ try {
+ // Check if we have context from selected cards
+ let contextContent: string | undefined;
+ let sourceCardIds: string[] | undefined;
+ let sourceCardNames: string[] | undefined;
+
+ for (const msg of convertedMessages) {
+ if (typeof msg.content === "string") {
+ const extracted = extractSelectedCardsContext(msg.content);
+ if (extracted && extracted.context) {
+ contextContent = extracted.context;
+ sourceCardNames = extracted.names;
+ sourceCardIds = extracted.ids;
+ break;
+ }
+ } else 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 && extracted.context) {
+ contextContent = extracted.context;
+ sourceCardNames = extracted.names;
+ sourceCardIds = extracted.ids;
+ break;
+ }
+ }
+ }
+ if (contextContent) break;
+ }
+ }
+
+ // If no context from cards and no explicit topic, extract from user's message
+ if (!contextContent && !topic) {
+ const userMessage = extractUserMessage(convertedMessages);
+ if (userMessage) {
+ topic = userMessage;
+ logger.debug("🎯 [CREATE-QUIZ] Using user message as topic:", { topic });
+ }
+ }
+
+ logger.debug("🎯 [CREATE-QUIZ] Context extracted:", {
+ hasContext: !!contextContent,
+ contextLength: contextContent?.length,
+ sourceCardNames,
+ sourceCardIds,
+ providedTopic: topic,
+ });
+
+ // Generate quiz using quizWorker
+ const quizResult = await quizWorker({
+ topic: topic || undefined,
+ contextContent,
+ difficulty,
+ questionCount: 10,
+ sourceCardIds,
+ sourceCardNames,
+ });
+
+ logger.debug("🎯 [CREATE-QUIZ] quizWorker returned:", {
+ title: quizResult.title,
+ questionCount: quizResult.questions?.length,
+ });
+
+ // Create quiz item in workspace
+ const workerResult = await workspaceWorker("create", {
+ workspaceId: ctx.workspaceId,
+ title: quizResult.title,
+ itemType: "quiz",
+ quizData: {
+ questions: quizResult.questions,
+ difficulty,
+ sourceCardIds,
+ sourceCardNames,
+ },
+ folderId: ctx.activeFolderId,
+ });
+
+ return {
+ success: true,
+ quizId: workerResult.itemId,
+ title: quizResult.title,
+ questionCount: quizResult.questions.length,
+ difficulty,
+ isContextBased: !!contextContent,
+ message: `Created quiz "${quizResult.title}" with ${quizResult.questions.length} questions.`,
+ };
+ } catch (error) {
+ logger.error("❌ [CREATE-QUIZ] Error:", error);
+ return {
+ success: false,
+ message: `Error creating quiz: ${error instanceof Error ? error.message : String(error)}`,
+ };
+ }
+ },
+ };
+}
+
+/**
+ * Create the updateQuiz tool
+ */
+export function createUpdateQuizTool(ctx: WorkspaceToolContext, convertedMessages: any[]) {
+ return {
+ description: "Add more questions to an existing quiz. This tool can generate questions based on: 1) The user's performance history (weak areas), 2) A new topic the user specifies, or 3) General knowledge continuation. It should be called when the user asks to 'continue the quiz', 'add more questions', 'practice my weak areas', or 'add questions about [topic]'. IMPORTANT: If the user specifies a new topic for the additional questions, you MUST pass it as the 'topic' parameter.",
+ // 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 'quizId' (string - REQUIRED: ID of the quiz to update) and optional 'topic' (string - if user specifies a new topic for additional questions)"),
+ execute: async (args: unknown) => {
+ // Manually extract and validate args since we're using z.any()
+ const parsedArgs = args as { quizId?: string; topic?: string } | null;
+ const quizId = parsedArgs?.quizId;
+ const explicitTopic = parsedArgs?.topic;
+
+ logger.debug("🎯 [UPDATE-QUIZ] Tool execution started:", { quizId, explicitTopic });
+
+ if (!ctx.workspaceId) {
+ return {
+ success: false,
+ message: "No workspace context available",
+ };
+ }
+
+ // Validate required quizId
+ if (!quizId) {
+ return {
+ success: false,
+ message: "Quiz ID is required. Please select a quiz card to update.",
+ };
+ }
+
+ try {
+ // Load current workspace state to find the quiz
+ const state = await loadWorkspaceState(ctx.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.",
+ };
+ }
+
+ const currentQuizData = quizItem.data as QuizData;
+ const existingQuestions = currentQuizData.questions || [];
+ const session = currentQuizData.session;
+
+ logger.debug("✅ [UPDATE-QUIZ] Found quiz:", {
+ id: quizItem.id,
+ name: quizItem.name,
+ questionCount: existingQuestions.length,
+ hasSession: !!session,
+ answeredCount: session?.answeredQuestions?.length,
+ });
+
+ // Build performance telemetry from answered questions
+ let performanceTelemetry;
+ if (session?.answeredQuestions && session.answeredQuestions.length > 0) {
+ const weakAreas = session.answeredQuestions
+ .filter(a => !a.isCorrect)
+ .map(a => {
+ const question = existingQuestions.find(q => q.id === a.questionId);
+ return question ? {
+ questionId: a.questionId,
+ questionText: question.questionText,
+ userSelectedOption: question.options?.[a.userAnswer] || "Unknown",
+ correctOption: question.options?.[question.correctIndex] || "Unknown",
+ } : null;
+ })
+ .filter((x): x is NonNullable => x !== null);
+
+ const correctCount = session.answeredQuestions.filter(a => a.isCorrect).length;
+ performanceTelemetry = {
+ correctCount,
+ incorrectCount: session.answeredQuestions.length - correctCount,
+ totalAnswered: session.answeredQuestions.length,
+ weakAreas,
+ };
+ }
+
+ // 3-MODE UPDATE LOGIC
+ // 1. Context Update: User selected NEW cards (e.g. A,B -> C,D)
+ // 2. Pivot: User specified NEW topic (no cards)
+ // 3. Continue: User wants more of the same (original topic/context)
+
+ // Step 1: Check for NEW selected cards context
+ // We need to look at the last user message to see if there's context attached
+ let newContextContent: string | undefined;
+ let newSourceCardIds: string[] = [];
+ let newSourceCardNames: string[] = [];
+
+ // Find the last user message with context
+ for (let i = convertedMessages.length - 1; i >= 0; i--) {
+ const msg = convertedMessages[i];
+ if (msg.role === 'user') {
+ let contentToScan = "";
+ if (typeof msg.content === 'string') {
+ contentToScan = msg.content;
+ } else if (Array.isArray(msg.content)) {
+ contentToScan = msg.content.map((p: any) => p.text || "").join("\n");
+ }
+
+ const extracted = extractSelectedCardsContext(contentToScan);
+ if (extracted) {
+ newContextContent = extracted.context;
+ newSourceCardIds = extracted.ids;
+ newSourceCardNames = extracted.names;
+ break; // specific context found
+ }
+ }
+ }
+
+ let quizTopic = explicitTopic;
+ let contextForWorker: string | undefined;
+ let sourceCardIdsForWorker: string[] | undefined;
+ let sourceCardNamesForWorker: string[] | undefined;
+
+ if (newContextContent && newSourceCardIds.length > 0) {
+ // MODE 1: CONTEXT UPDATE
+ // User selected specific cards - start fresh with this context
+ logger.debug("🎯 [UPDATE-QUIZ] Mode: CONTEXT UPDATE", { cards: newSourceCardNames });
+
+ contextForWorker = newContextContent;
+ sourceCardIdsForWorker = newSourceCardIds;
+ sourceCardNamesForWorker = newSourceCardNames;
+
+ // If no explicit topic, try to infer one or use "Updated content"
+ if (!quizTopic) {
+ const userMessage = extractUserMessage(convertedMessages);
+ quizTopic = userMessage || `Quiz on ${newSourceCardNames[0]}...`;
+ }
+
+ } else if (quizTopic) {
+ // MODE 2: PIVOT (Explicit Topic + No Context)
+ // User wants to change variables/topic completely
+ logger.debug("🎯 [UPDATE-QUIZ] Mode: TOPIC PIVOT", { topic: quizTopic });
+
+ // Force context extracted from original quiz to be IGNORED
+ // We want general knowledge about the new topic
+ contextForWorker = undefined;
+ sourceCardIdsForWorker = undefined;
+
+ } else {
+ // MODE 3: CONTINUE (No Topic + No New Context)
+ // Just add more of the same
+ logger.debug("🎯 [UPDATE-QUIZ] Mode: CONTINUATION");
+
+ // Use user message if present (e.g. "add more about history")
+ const userMessage = extractUserMessage(convertedMessages);
+ quizTopic = userMessage || quizItem.name;
+
+ // Reuse original source cards/context if available
+ // NOTE: We don't have the full extracted text of original cards stored,
+ // mainly just IDs/Names. We pass a prompt instruction instead.
+ sourceCardIdsForWorker = currentQuizData.sourceCardIds;
+ sourceCardNamesForWorker = currentQuizData.sourceCardNames;
+
+ if (currentQuizData.sourceCardIds?.length) {
+ contextForWorker = `Continue generating questions about: ${quizTopic}. Maintain consistency with original source cards: ${currentQuizData.sourceCardNames?.join(", ")}`;
+ } else {
+ contextForWorker = undefined; // General knowledge continuation
+ }
+ }
+
+ logger.debug("🎯 [UPDATE-QUIZ] Final configuration:", {
+ mode: newContextContent ? "Context" : quizTopic ? "Pivot" : "Continue",
+ topic: quizTopic,
+ hasContext: !!contextForWorker,
+ sourceCards: sourceCardNamesForWorker
+ });
+
+ // Generate new questions
+ const quizResult = await quizWorker({
+ topic: quizTopic,
+ contextContent: contextForWorker,
+ difficulty: currentQuizData.difficulty || "medium",
+ questionCount: 10,
+ existingQuestions,
+ performanceTelemetry,
+ sourceCardIds: sourceCardIdsForWorker,
+ sourceCardNames: sourceCardNamesForWorker
+ });
+
+ // Update the quiz with new questions
+ // Cast to any since workspaceWorker internal types use questionsToAdd
+ await workspaceWorker("updateQuiz", {
+ workspaceId: ctx.workspaceId,
+ itemId: quizId,
+ itemType: "quiz",
+ questionsToAdd: quizResult.questions,
+ });
+
+ return {
+ success: true,
+ quizId,
+ questionsAdded: quizResult.questions.length,
+ totalQuestions: existingQuestions.length + quizResult.questions.length,
+ message: `Added ${quizResult.questions.length} new questions to the quiz.`,
+ };
+ } catch (error) {
+ logger.error("❌ [UPDATE-QUIZ] Error:", error);
+ return {
+ success: false,
+ message: `Error updating quiz: ${error instanceof Error ? error.message : String(error)}`,
+ };
+ }
+ },
+ };
+}
diff --git a/src/lib/ai/workers.ts b/src/lib/ai/workers.ts
deleted file mode 100644
index 4cf2feab..00000000
--- a/src/lib/ai/workers.ts
+++ /dev/null
@@ -1,751 +0,0 @@
-/**
- * Specialized AI Workers
- * Each worker uses only ONE type of tool
- */
-
-import { google } from "@ai-sdk/google";
-import { generateText } from "ai";
-import type { GoogleGenerativeAIProviderMetadata } from "@ai-sdk/google";
-import { z } from "zod";
-import { headers } from "next/headers";
-import { auth } from "@/lib/auth";
-import { db, workspaces } from "@/lib/db/client";
-import { eq, sql } from "drizzle-orm";
-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 { markdownToBlocks } from "@/lib/editor/markdown-to-blocks";
-
-/**
- * Workspace operation queue to serialize concurrent operations
- * Maps workspaceId -> Promise representing the last operation
- */
-const workspaceOperationQueues = new Map>();
-
-/**
- * WORKER 1: Search Agent
- * Uses Google Search Grounding to find current information with sources
- */
-export async function searchWorker(query: string): Promise {
- try {
- logger.debug("🔍 [SEARCH-WORKER] Starting search for:", query);
-
- const { text, sources, providerMetadata } = await generateText({
- model: google("gemini-2.5-pro"),
- tools: {
- google_search: google.tools.googleSearch({}),
- },
- prompt: `Search for information about: ${query}
-
-Provide a comprehensive answer based on the search results. Include relevant details and context from the sources.`,
- });
-
- // Access grounding metadata for additional context
- const metadata = providerMetadata?.google as GoogleGenerativeAIProviderMetadata | undefined;
- const groundingMetadata = metadata?.groundingMetadata;
-
- logger.debug("🔍 [SEARCH-WORKER] Search completed", {
- hasGroundingMetadata: !!groundingMetadata,
- webSearchQueries: groundingMetadata?.webSearchQueries?.length || 0,
- groundingSupports: groundingMetadata?.groundingSupports?.length || 0,
- });
-
- // Enhanced response with source information
- let enhancedResponse = text;
-
- // Add sources information if available
- if (sources && sources.length > 0) {
- enhancedResponse += "\n\n**Sources:**\n";
- sources.forEach((source, index) => {
- // Check if source has a URL property (for web sources)
- // Google grounding sources may have different structures
- const sourceUrl = 'url' in source ? (source as any).url :
- 'id' in source && typeof source.id === 'string' && source.id.startsWith('http') ? source.id :
- undefined;
-
- if (sourceUrl) {
- enhancedResponse += `${index + 1}. [${sourceUrl}](${sourceUrl})\n`;
- } else if (source.title) {
- // Fallback to title if no URL available
- enhancedResponse += `${index + 1}. ${source.title}\n`;
- }
- });
- }
-
- return enhancedResponse;
- } catch (error) {
- logger.error("🔍 [SEARCH-WORKER] Error:", error);
- throw error;
- }
-}
-
-/**
- * WORKER 2: Code Execution Agent
- * Executes Python code for calculations and data processing
- */
-export async function codeExecutionWorker(task: string): Promise {
- try {
- logger.debug("⚙️ [CODE-WORKER] Starting code execution for:", task);
-
- const result = await generateText({
- model: google("gemini-2.5-flash"),
- tools: {
- code_execution: google.tools.codeExecution({}),
- },
- prompt: `${task}
-
-Use Python code execution to solve this problem. Show your work and explain the result.`,
- });
-
- logger.debug("⚙️ [CODE-WORKER] Code execution completed");
- return result.text;
- } catch (error) {
- logger.error("⚙️ [CODE-WORKER] Error:", error);
- throw error;
- }
-}
-
-/**
- * Execute workspace operation with serialization
- * Ensures operations on the same workspace are executed sequentially
- */
-async function executeWorkspaceOperation(
- workspaceId: string,
- operation: () => Promise
-): Promise {
- const queueSize = workspaceOperationQueues.size;
- const hasExistingQueue = workspaceOperationQueues.has(workspaceId);
-
- logger.debug("🔒 [QUEUE] Queuing operation for workspace:", {
- workspaceId: workspaceId.substring(0, 8),
- hasExistingQueue,
- totalQueues: queueSize,
- });
-
- // Get or create the queue for this workspace
- const currentQueue = workspaceOperationQueues.get(workspaceId) || Promise.resolve();
-
- // Chain the new operation after the current queue
- const newOperation = currentQueue
- .then(() => {
- logger.debug("🔓 [QUEUE] Executing queued operation for workspace:", workspaceId.substring(0, 8));
- return operation();
- })
- .catch((error) => {
- // Even if the previous operation failed, we still want to execute this one
- logger.debug("🔓 [QUEUE] Previous operation failed, executing anyway for workspace:", workspaceId.substring(0, 8));
- return operation();
- });
-
- // Update the queue
- workspaceOperationQueues.set(workspaceId, newOperation);
-
- // Clean up the queue after the operation completes
- newOperation.finally(() => {
- // Only delete if this is still the current operation
- if (workspaceOperationQueues.get(workspaceId) === newOperation) {
- workspaceOperationQueues.delete(workspaceId);
- logger.debug("✅ [QUEUE] Cleaned up queue for workspace:", workspaceId.substring(0, 8));
- }
- });
-
- return newOperation;
-}
-
-/**
- * WORKER 3: Workspace Management Agent
- * Manages workspace items (create, update, delete notes)
- * Operations are serialized per workspace to prevent version conflicts
- */
-export async function workspaceWorker(
- action: "create" | "update" | "delete" | "updateFlashcard",
- params: {
- workspaceId: string;
- title?: string;
- content?: string; // For notes
- itemId?: string;
-
- itemType?: "note" | "flashcard"; // Defaults to "note" if undefined
- flashcardData?: {
- cards?: { front: string; back: string }[]; // For creating flashcards
- cardsToAdd?: { front: string; back: string }[]; // For updating flashcards (appending)
- };
- // Optional: deep research metadata to attach to a note
- deepResearchData?: {
- prompt: string;
- interactionId: string;
- };
- folderId?: string;
- }
-): Promise<{ success: boolean; message: string; itemId?: string; cardsAdded?: number }> {
- // Serialize operations on the same workspace
- return executeWorkspaceOperation(params.workspaceId, async () => {
- try {
- logger.debug("📝 [WORKSPACE-WORKER] Action:", action, params);
-
- // Get current user
- const session = await auth.api.getSession({
- headers: await headers(),
- });
- if (!session) {
- throw new Error("User not authenticated");
- }
- const userId = session.user.id;
-
- // Verify workspace access - ownership only (sharing is fork-based)
- const workspace = await db
- .select({ userId: workspaces.userId })
- .from(workspaces)
- .where(eq(workspaces.id, params.workspaceId))
- .limit(1);
-
- if (!workspace[0]) {
- throw new Error("Workspace not found");
- }
-
- // Enforce strict ownership (sharing is fork-based)
- if (workspace[0].userId !== userId) {
- throw new Error("Access denied");
- }
-
-
- // Handle different actions
- if (action === "create") {
- const itemId = generateItemId();
- const itemType = params.itemType || "note";
-
- let itemData: any;
-
- if (itemType === "flashcard") {
- logger.debug("🎴 [WORKSPACE-WORKER] Creating flashcard deck with data:", {
- hasFlashcardData: !!params.flashcardData,
- hasCards: !!params.flashcardData?.cards,
- cardsType: typeof params.flashcardData?.cards,
- cardsIsArray: Array.isArray(params.flashcardData?.cards),
- });
-
- if (!params.flashcardData || !params.flashcardData.cards) {
- logger.error("❌ [WORKSPACE-WORKER] Flashcard data missing cards");
- throw new Error("Flashcard data required for flashcard creation");
- }
-
- // Generate IDs for each card in the deck
- const cardsWithIds = await Promise.all(params.flashcardData.cards.map(async (card, index) => {
- if (!card || typeof card !== 'object') {
- logger.error(`❌ [WORKSPACE-WORKER] Invalid card at index ${index}:`, card);
- }
-
- // Parse markdown/math into blocks for the frontend editor
- // This ensures LaTeX like $$...$$ is converted to inlineMath blocks
- const frontBlocks = await markdownToBlocks(card.front);
- const backBlocks = await markdownToBlocks(card.back);
-
- return {
- id: generateItemId(),
- front: card.front,
- back: card.back,
- frontBlocks,
- backBlocks
- };
- }));
-
- itemData = {
- cards: cardsWithIds
- };
- } else {
- // "note" type
- // Convert markdown to blocks on the server
- const blockContent = params.content
- ? await markdownToBlocks(params.content)
- : undefined;
-
- itemData = {
- field1: params.content || "", // Keep for backwards compatibility and search
- blockContent: blockContent, // Always store blocks
- };
-
- // If this is a deep research note, attach the metadata
- if (params.deepResearchData) {
- itemData.deepResearch = {
- prompt: params.deepResearchData.prompt,
- interactionId: params.deepResearchData.interactionId,
- status: "researching",
- thoughts: [],
- };
- }
- }
-
- const item: Item = {
- id: itemId,
- type: itemType,
- name: params.title || (itemType === "flashcard" ? "New Flashcard Deck" : "New Note"),
- subtitle: "",
- data: itemData,
- color: getRandomCardColor(),
- folderId: params.folderId,
- };
-
- const event = createEvent("ITEM_CREATED", { id: itemId, item }, userId);
-
- const currentVersionResult = await db.execute(sql`
- SELECT get_workspace_version(${params.workspaceId}::uuid) as version
- `);
-
- const baseVersion = currentVersionResult[0]?.version || 0;
-
- 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
- `);
-
- if (!eventResult || eventResult.length === 0) {
- throw new Error(`Failed to create ${itemType}`);
- }
-
- const result = eventResult[0].result as any;
- if (result && result.conflict) {
- throw new Error("Workspace was modified by another user, please try again");
- }
-
- logger.info(`📝 [WORKSPACE-WORKER] Created ${itemType}:`, item.name);
-
- // Include card count for flashcard decks
- const cardCount = itemType === "flashcard" && params.flashcardData?.cards
- ? params.flashcardData.cards.length
- : undefined;
-
- return {
- success: true,
- itemId,
- message: `Created ${itemType} "${item.name}" successfully`,
- cardCount,
- };
- }
-
- if (action === "update") {
- try {
- logger.group("📝 [UPDATE-NOTE] Starting update operation", true);
- logger.debug("Raw params received:", {
- paramsType: typeof params,
- paramsKeys: params ? Object.keys(params) : [],
- paramsValue: params,
- });
- logger.debug("Input parameters:", {
- itemId: params?.itemId,
- itemIdType: typeof params?.itemId,
- workspaceId: params?.workspaceId,
- workspaceIdType: typeof params?.workspaceId,
- userId,
- hasTitle: params?.title !== undefined,
- hasContent: params?.content !== undefined,
- titleType: typeof params?.title,
- contentType: typeof params?.content,
- titleLength: params?.title && typeof params.title === 'string' ? params.title.length : (params?.title ? String(params.title).length : 0),
- contentLength: params?.content && typeof params.content === 'string' ? params.content.length : (params?.content ? String(params.content).length : 0),
- });
- logger.groupEnd();
-
- if (!params) {
- logger.error("❌ [UPDATE-NOTE] Params object is missing");
- throw new Error("Params object is required for update");
- }
-
- if (!params.itemId) {
- logger.error("❌ [UPDATE-NOTE] Item ID required for update", {
- itemId: params.itemId,
- itemIdType: typeof params.itemId,
- });
- throw new Error("Item ID required for update");
- }
-
- if (typeof params.itemId !== 'string') {
- logger.error("❌ [UPDATE-NOTE] Item ID must be a string", {
- itemId: params.itemId,
- itemIdType: typeof params.itemId,
- });
- throw new Error("Item ID must be a string");
- }
-
- if (!params.workspaceId) {
- logger.error("❌ [UPDATE-NOTE] Workspace ID required for update", {
- workspaceId: params.workspaceId,
- workspaceIdType: typeof params.workspaceId,
- });
- throw new Error("Workspace ID required for update");
- }
-
- if (typeof params.workspaceId !== 'string') {
- logger.error("❌ [UPDATE-NOTE] Workspace ID must be a string", {
- workspaceId: params.workspaceId,
- workspaceIdType: typeof params.workspaceId,
- });
- throw new Error("Workspace ID must be a string");
- }
-
- const changes: Partial- = {};
-
- // Update title if provided
- if (params.title !== undefined) {
- const titleStr = typeof params.title === 'string' ? params.title : String(params.title);
- logger.debug("📝 [UPDATE-NOTE] Updating title:", {
- oldTitle: "N/A (not retrieved)",
- newTitle: titleStr,
- titleLength: titleStr.length,
- originalType: typeof params.title,
- });
- changes.name = titleStr;
- }
-
- // Update content if provided (allow empty string to clear content)
- if (params.content !== undefined) {
- const contentStr = typeof params.content === 'string' ? params.content : String(params.content);
- logger.debug("📝 [UPDATE-NOTE] Processing content update:", {
- contentLength: contentStr.length,
- contentPreview: contentStr.substring(0, 100) + (contentStr.length > 100 ? "..." : ""),
- isEmpty: contentStr.length === 0,
- originalType: typeof params.content,
- });
-
- logger.time("📝 [UPDATE-NOTE] markdownToBlocks conversion");
- const blockContent = await markdownToBlocks(contentStr);
- logger.timeEnd("📝 [UPDATE-NOTE] markdownToBlocks conversion");
-
- logger.debug("📝 [UPDATE-NOTE] Block content generated:", {
- blockCount: blockContent?.length || 0,
- firstBlockType: blockContent?.[0]?.type || "N/A",
- });
-
- changes.data = {
- field1: contentStr,
- blockContent: blockContent,
- } as NoteData;
- }
-
- // If no changes, return early
- if (Object.keys(changes).length === 0) {
- logger.warn("⚠️ [UPDATE-NOTE] No changes detected, returning early");
- return {
- success: true,
- message: "No changes to update",
- itemId: params.itemId,
- };
- }
-
- logger.debug("📝 [UPDATE-NOTE] Changes to apply:", {
- changeKeys: Object.keys(changes),
- hasNameChange: !!changes.name,
- hasDataChange: !!changes.data,
- });
-
- logger.time("📝 [UPDATE-NOTE] Event creation");
- const event = createEvent("ITEM_UPDATED", { id: params.itemId, changes, source: 'agent' }, userId);
- logger.timeEnd("📝 [UPDATE-NOTE] Event creation");
-
- logger.debug("📝 [UPDATE-NOTE] Event created:", {
- eventId: event.id,
- eventType: event.type,
- eventTimestamp: event.timestamp,
- payloadId: event.payload.id,
- });
-
- logger.time("📝 [UPDATE-NOTE] Get workspace version");
- const currentVersionResult = await db.execute(sql`
- SELECT get_workspace_version(${params.workspaceId}::uuid) as version
- `);
- logger.timeEnd("📝 [UPDATE-NOTE] Get workspace version");
-
- const baseVersion = currentVersionResult[0]?.version || 0;
- logger.debug("📝 [UPDATE-NOTE] Current workspace version:", baseVersion);
-
- logger.time("📝 [UPDATE-NOTE] 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.timeEnd("📝 [UPDATE-NOTE] Append workspace event");
-
- if (!eventResult || eventResult.length === 0) {
- logger.error("❌ [UPDATE-NOTE] Failed to append event - no result returned");
- throw new Error("Failed to update note");
- }
-
- const result = eventResult[0].result as any;
- logger.debug("📝 [UPDATE-NOTE] Event append result:", {
- hasResult: !!result,
- hasConflict: !!result?.conflict,
- resultKeys: result ? Object.keys(result) : [],
- });
-
- if (result && result.conflict) {
- logger.error("❌ [UPDATE-NOTE] Conflict detected - workspace was modified by another user");
- throw new Error("Workspace was modified by another user, please try again");
- }
-
- logger.group("✅ [UPDATE-NOTE] Update completed successfully", true);
- logger.info("Item ID:", params.itemId);
- logger.info("Workspace ID:", params.workspaceId);
- logger.info("Base version:", baseVersion);
- logger.info("Changes applied:", Object.keys(changes));
- logger.groupEnd();
-
- return {
- success: true,
- itemId: params.itemId,
- message: `Updated note successfully`,
- };
- } catch (error: any) {
- logger.group("❌ [UPDATE-NOTE] Error during update operation", false);
- logger.error("Error type:", error?.constructor?.name || typeof error);
- logger.error("Error message:", error?.message || String(error));
- logger.error("Error stack:", error?.stack);
- logger.error("Full error object:", error);
- logger.error("Params that caused error:", params);
- logger.error("UserId:", userId);
- logger.groupEnd();
-
- // Re-throw to be handled by the caller
- throw error;
- }
- }
-
- if (action === "updateFlashcard") {
- if (!params.itemId) {
- throw new Error("Item ID required for flashcard update");
- }
- if (!params.flashcardData?.cardsToAdd || params.flashcardData.cardsToAdd.length === 0) {
- throw new Error("Cards to add required for flashcard update");
- }
-
- // Get the latest snapshot state
- 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 events after the snapshot to compute current state
- 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(`Flashcard deck not found with ID: ${params.itemId}`);
- }
-
- if (existingItem.type !== "flashcard") {
- throw new Error(`Item "${existingItem.name}" is not a flashcard deck (type: ${existingItem.type})`);
- }
-
- const existingData = existingItem.data as { cards?: any[] };
- const existingCards = existingData?.cards || [];
-
- // Generate new cards with IDs and parsed blocks
- const newCards = await Promise.all(
- params.flashcardData.cardsToAdd.map(async (card) => {
- const frontBlocks = await markdownToBlocks(card.front);
- const backBlocks = await markdownToBlocks(card.back);
- return {
- id: generateItemId(),
- front: card.front,
- back: card.back,
- frontBlocks,
- backBlocks,
- };
- })
- );
-
- // Merge existing cards with new cards
- const updatedData = {
- ...existingData,
- cards: [...existingCards, ...newCards],
- };
-
- const changes = { data: updatedData };
- const event = createEvent("ITEM_UPDATED", { id: params.itemId, changes, source: 'agent' }, userId);
-
- const currentVersionResult = await db.execute(sql`
- SELECT get_workspace_version(${params.workspaceId}::uuid) as version
- `);
-
- const baseVersion = currentVersionResult[0]?.version || 0;
-
- 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
- `);
-
- if (!eventResult || eventResult.length === 0) {
- throw new Error("Failed to update flashcard deck: database returned no result");
- }
-
- const result = eventResult[0].result as any;
-
- if (result && result.conflict) {
- throw new Error("Workspace was modified by another user, please try again");
- }
-
- logger.info("🎴 [WORKSPACE-WORKER] Updated flashcard deck:", {
- itemId: params.itemId,
- cardsAdded: newCards.length,
- totalCards: updatedData.cards.length,
- });
-
- return {
- success: true,
- itemId: params.itemId,
- cardsAdded: newCards.length,
- message: `Added ${newCards.length} card${newCards.length !== 1 ? 's' : ''} to flashcard deck`,
- };
- }
-
- if (action === "delete") {
- if (!params.itemId) {
- throw new Error("Item ID required for delete");
- }
-
- const event = createEvent("ITEM_DELETED", { id: params.itemId }, userId);
-
- const currentVersionResult = await db.execute(sql`
- SELECT get_workspace_version(${params.workspaceId}::uuid) as version
- `);
-
- const baseVersion = currentVersionResult[0]?.version || 0;
-
- 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
- `);
-
- if (!eventResult || eventResult.length === 0) {
- throw new Error("Failed to delete note");
- }
-
- const result = eventResult[0].result as any;
- if (result && result.conflict) {
- throw new Error("Workspace was modified by another user, please try again");
- }
-
- logger.info("📝 [WORKSPACE-WORKER] Deleted note:", params.itemId);
-
- return {
- success: true,
- itemId: params.itemId,
- message: `Deleted note successfully`,
- };
- }
-
- // Fallback for unhandled actions
- return {
- success: false,
- message: `Action ${action} not implemented`,
- };
-
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
- logger.error("📝 [WORKSPACE-WORKER] Error:", errorMessage);
- return {
- success: false,
- message: `Failed: ${errorMessage}`,
- };
- }
- });
-}
-
-/**
- * WORKER 4: Text Selection Agent
- * Processes and transforms selected text (summarize, explain, rewrite, etc.)
- */
-export async function textSelectionWorker(
- action: "summarize" | "explain" | "rewrite" | "translate" | "improve",
- text: string,
- additionalContext?: string
-): Promise {
- try {
- logger.debug("📄 [TEXT-WORKER] Action:", action);
-
- const prompts = {
- summarize: `Summarize the following text concisely:\n\n${text}`,
- explain: `Explain the following text in simple terms:\n\n${text}`,
- rewrite: `Rewrite the following text to be clearer and more concise:\n\n${text}`,
- translate: `Translate the following text to ${additionalContext || "English"}:\n\n${text}`,
- improve: `Improve the following text for grammar, clarity, and style:\n\n${text}`,
- };
-
- const result = await generateText({
- model: google("gemini-2.5-flash"),
- prompt: prompts[action] + (additionalContext ? `\n\nAdditional context: ${additionalContext}` : ""),
- });
-
- logger.debug("📄 [TEXT-WORKER] Processing completed");
- return result.text;
- } catch (error) {
- logger.error("📄 [TEXT-WORKER] Error:", error);
- throw error;
- }
-}
diff --git a/src/lib/ai/workers/code-execution-worker.ts b/src/lib/ai/workers/code-execution-worker.ts
new file mode 100644
index 00000000..12c135e3
--- /dev/null
+++ b/src/lib/ai/workers/code-execution-worker.ts
@@ -0,0 +1,30 @@
+import { google } from "@ai-sdk/google";
+import { generateText } from "ai";
+import { logger } from "@/lib/utils/logger";
+
+/**
+ * WORKER 2: Code Execution Agent
+ * Executes Python code for calculations and data processing
+ */
+export async function codeExecutionWorker(task: string): Promise {
+ try {
+ logger.debug("⚙️ [CODE-WORKER] Starting code execution for:", task);
+
+ const result = await generateText({
+ model: google("gemini-2.5-flash"),
+ tools: {
+ code_execution: google.tools.codeExecution({}),
+ },
+ prompt: `${task}
+
+Use Python code execution to solve this problem. Show your work and explain the result.`,
+ });
+
+ logger.debug("⚙️ [CODE-WORKER] Code execution completed");
+ return result.text;
+ } catch (error) {
+ logger.error("⚙️ [CODE-WORKER] Error:", error);
+ throw error;
+ }
+}
+
diff --git a/src/lib/ai/workers/common.ts b/src/lib/ai/workers/common.ts
new file mode 100644
index 00000000..23a95341
--- /dev/null
+++ b/src/lib/ai/workers/common.ts
@@ -0,0 +1,56 @@
+import { logger } from "@/lib/utils/logger";
+
+/**
+ * Workspace operation queue to serialize concurrent operations
+ * Maps workspaceId -> Promise representing the last operation
+ */
+export const workspaceOperationQueues = new Map>();
+
+/**
+ * Execute workspace operation with serialization
+ * Ensures operations on the same workspace are executed sequentially
+ */
+export async function executeWorkspaceOperation(
+ workspaceId: string,
+ operation: () => Promise
+): Promise {
+ const queueSize = workspaceOperationQueues.size;
+ const hasExistingQueue = workspaceOperationQueues.has(workspaceId);
+
+ logger.debug("🔒 [QUEUE] Queuing operation for workspace:", {
+ workspaceId: workspaceId.substring(0, 8),
+ hasExistingQueue,
+ totalQueues: queueSize,
+ });
+
+ // Get or create the queue for this workspace
+ const currentQueue = workspaceOperationQueues.get(workspaceId) || Promise.resolve();
+
+ // Chain the new operation after the current queue
+ const newOperation = currentQueue
+ .then(() => {
+ logger.debug("🔓 [QUEUE] Executing queued operation for workspace:", workspaceId.substring(0, 8));
+ return operation();
+ })
+ .catch((error) => {
+ // Even if the previous operation failed, we still want to execute this one
+ logger.debug("🔓 [QUEUE] Previous operation failed, executing anyway for workspace:", workspaceId.substring(0, 8));
+ return operation();
+ });
+
+ // Update the queue
+ workspaceOperationQueues.set(workspaceId, newOperation);
+
+ // Clean up the queue after the operation completes
+ newOperation.finally(() => {
+ // Only delete if this is still the current operation
+ if (workspaceOperationQueues.get(workspaceId) === newOperation) {
+ workspaceOperationQueues.delete(workspaceId);
+ logger.debug("✅ [QUEUE] Cleaned up queue for workspace:", workspaceId.substring(0, 8));
+ }
+ });
+
+ return newOperation;
+}
+
+// loadCurrentState removed - use loadWorkspaceState from "@/lib/workspace/state-loader" instead
diff --git a/src/lib/ai/workers/index.ts b/src/lib/ai/workers/index.ts
new file mode 100644
index 00000000..1fa8f7e8
--- /dev/null
+++ b/src/lib/ai/workers/index.ts
@@ -0,0 +1,6 @@
+export * from "./common";
+export * from "./search-worker";
+export * from "./code-execution-worker";
+export * from "./workspace-worker";
+export * from "./text-selection-worker";
+export * from "./quiz-worker";
diff --git a/src/lib/ai/workers/quiz-worker.ts b/src/lib/ai/workers/quiz-worker.ts
new file mode 100644
index 00000000..5dc92264
--- /dev/null
+++ b/src/lib/ai/workers/quiz-worker.ts
@@ -0,0 +1,230 @@
+import { google } from "@ai-sdk/google";
+import { generateText } from "ai";
+import { logger } from "@/lib/utils/logger";
+import { QuizQuestion, QuestionType } from "@/lib/workspace-state/types";
+import { generateItemId } from "@/lib/workspace-state/item-helpers";
+
+export 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.
+
+IMPORTANT: The content below is from workspace cards and includes metadata headers like "CARD 1:", "Card ID:", "METADATA:", "CONTENT:", etc. IGNORE ALL METADATA. Focus ONLY on the actual educational content within each card - the text, concepts, facts, and information being taught. Do NOT create questions about:
+- Card IDs, card types, or card names
+- Metadata like "Type: note" or "Card ID: xyz"
+- System formatting like separators, emojis, or structural markers
+- The number of cards, questions, or any organizational details
+
+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 EDUCATIONAL content (not metadata)
+
+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 here",
+ "sourceContext": "Excerpt from source if applicable",
+ "question_text": "Legacy mapping",
+ "correct_index": "Legacy mapping"
+ }
+ ]
+}
+
+For true_false questions, options should be exactly ["True", "False"] and correctIndex 0 for True, 1 for False.`;
+
+
+ // Standard text-only generation
+ 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
+ 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 || "Generated Quiz",
+ questions,
+ };
+ } catch (error) {
+ logger.error("🎯 [QUIZ-WORKER] Error:", error);
+ throw error;
+ }
+}
diff --git a/src/lib/ai/workers/search-worker.ts b/src/lib/ai/workers/search-worker.ts
new file mode 100644
index 00000000..62f9faf5
--- /dev/null
+++ b/src/lib/ai/workers/search-worker.ts
@@ -0,0 +1,61 @@
+import { google } from "@ai-sdk/google";
+import { generateText } from "ai";
+import type { GoogleGenerativeAIProviderMetadata } from "@ai-sdk/google";
+import { logger } from "@/lib/utils/logger";
+
+/**
+ * WORKER 1: Search Agent
+ * Uses Google Search Grounding to find current information with sources
+ */
+export async function searchWorker(query: string): Promise {
+ try {
+ logger.debug("🔍 [SEARCH-WORKER] Starting search for:", query);
+
+ const { text, sources, providerMetadata } = await generateText({
+ model: google("gemini-2.5-pro"),
+ tools: {
+ google_search: google.tools.googleSearch({}),
+ },
+ prompt: `Search for information about: ${query}
+
+Provide a comprehensive answer based on the search results. Include relevant details and context from the sources.`,
+ });
+
+ // Access grounding metadata for additional context
+ const metadata = providerMetadata?.google as GoogleGenerativeAIProviderMetadata | undefined;
+ const groundingMetadata = metadata?.groundingMetadata;
+
+ logger.debug("🔍 [SEARCH-WORKER] Search completed", {
+ hasGroundingMetadata: !!groundingMetadata,
+ webSearchQueries: groundingMetadata?.webSearchQueries?.length || 0,
+ groundingSupports: groundingMetadata?.groundingSupports?.length || 0,
+ });
+
+ // Enhanced response with source information
+ let enhancedResponse = text;
+
+ // Add sources information if available
+ if (sources && sources.length > 0) {
+ enhancedResponse += "\n\n**Sources:**\n";
+ sources.forEach((source, index) => {
+ // Check if source has a URL property (for web sources)
+ // Google grounding sources may have different structures
+ const sourceUrl = 'url' in source ? (source as any).url :
+ 'id' in source && typeof source.id === 'string' && source.id.startsWith('http') ? source.id :
+ undefined;
+
+ if (sourceUrl) {
+ enhancedResponse += `${index + 1}. [${sourceUrl}](${sourceUrl})\n`;
+ } else if (source.title) {
+ // Fallback to title if no URL available
+ enhancedResponse += `${index + 1}. ${source.title}\n`;
+ }
+ });
+ }
+
+ return enhancedResponse;
+ } catch (error) {
+ logger.error("🔍 [SEARCH-WORKER] Error:", error);
+ throw error;
+ }
+}
diff --git a/src/lib/ai/workers/text-selection-worker.ts b/src/lib/ai/workers/text-selection-worker.ts
new file mode 100644
index 00000000..597dc83e
--- /dev/null
+++ b/src/lib/ai/workers/text-selection-worker.ts
@@ -0,0 +1,37 @@
+import { google } from "@ai-sdk/google";
+import { generateText } from "ai";
+import { logger } from "@/lib/utils/logger";
+
+/**
+ * WORKER 4: Text Selection Agent
+ * Processes and transforms selected text (summarize, explain, rewrite, etc.)
+ */
+export async function textSelectionWorker(
+ action: "summarize" | "explain" | "rewrite" | "translate" | "improve",
+ text: string,
+ additionalContext?: string
+): Promise {
+ try {
+ logger.debug("📄 [TEXT-WORKER] Action:", action);
+
+ const prompts = {
+ summarize: `Summarize the following text concisely:\n\n${text}`,
+ explain: `Explain the following text in simple terms:\n\n${text}`,
+ rewrite: `Rewrite the following text to be clearer and more concise:\n\n${text}`,
+ translate: `Translate the following text to ${additionalContext || "English"}:\n\n${text}`,
+ improve: `Improve the following text for grammar, clarity, and style:\n\n${text}`,
+ };
+
+ const result = await generateText({
+ model: google("gemini-2.5-flash"),
+ prompt: prompts[action] + (additionalContext ? `\n\nAdditional context: ${additionalContext}` : ""),
+ });
+
+ logger.debug("📄 [TEXT-WORKER] Processing completed");
+ return result.text;
+ } catch (error) {
+ logger.error("📄 [TEXT-WORKER] Error:", error);
+ throw error;
+ }
+}
+
diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts
new file mode 100644
index 00000000..5ff91a45
--- /dev/null
+++ b/src/lib/ai/workers/workspace-worker.ts
@@ -0,0 +1,604 @@
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth";
+import { db, workspaces } from "@/lib/db/client";
+import { eq, sql } from "drizzle-orm";
+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, QuizData, QuizQuestion } from "@/lib/workspace-state/types";
+import { markdownToBlocks } from "@/lib/editor/markdown-to-blocks";
+import { executeWorkspaceOperation } from "./common";
+import { loadWorkspaceState } from "@/lib/workspace/state-loader";
+
+/**
+ * WORKER 3: Workspace Management Agent
+ * Manages workspace items (create, update, delete notes)
+ * Operations are serialized per workspace to prevent version conflicts
+ */
+export async function workspaceWorker(
+ action: "create" | "update" | "delete" | "updateFlashcard" | "updateQuiz",
+ params: {
+ workspaceId: string;
+ title?: string;
+ content?: string; // For notes
+ itemId?: string;
+
+ 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;
+ interactionId: string;
+ };
+ folderId?: string;
+ }
+): Promise<{ success: boolean; message: string; itemId?: string; cardsAdded?: number }> {
+ // Serialize operations on the same workspace
+ return executeWorkspaceOperation(params.workspaceId, async () => {
+ try {
+ logger.debug("📝 [WORKSPACE-WORKER] Action:", action, params);
+
+ // Get current user
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+ if (!session) {
+ throw new Error("User not authenticated");
+ }
+ const userId = session.user.id;
+
+ // Verify workspace access - ownership only (sharing is fork-based)
+ const workspace = await db
+ .select({ userId: workspaces.userId })
+ .from(workspaces)
+ .where(eq(workspaces.id, params.workspaceId))
+ .limit(1);
+
+ if (!workspace[0]) {
+ throw new Error("Workspace not found");
+ }
+
+ // Enforce strict ownership (sharing is fork-based)
+ if (workspace[0].userId !== userId) {
+ throw new Error("Access denied");
+ }
+
+
+ // Handle different actions
+ if (action === "create") {
+ const itemId = generateItemId();
+ const itemType = params.itemType || "note";
+
+ let itemData: any;
+
+ if (itemType === "flashcard") {
+ logger.debug("🎴 [WORKSPACE-WORKER] Creating flashcard deck with data:", {
+ hasFlashcardData: !!params.flashcardData,
+ hasCards: !!params.flashcardData?.cards,
+ cardsType: typeof params.flashcardData?.cards,
+ cardsIsArray: Array.isArray(params.flashcardData?.cards),
+ });
+
+ if (!params.flashcardData || !params.flashcardData.cards) {
+ logger.error("❌ [WORKSPACE-WORKER] Flashcard data missing cards");
+ throw new Error("Flashcard data required for flashcard creation");
+ }
+
+ // Generate IDs for each card in the deck
+ const cardsWithIdsOrNull = await Promise.all(params.flashcardData.cards.map(async (card, index) => {
+ if (!card || typeof card !== 'object') {
+ logger.error(`❌ [WORKSPACE-WORKER] Invalid card at index ${index}:`, card);
+ return null; // Skip invalid cards
+ }
+
+ // Parse markdown/math into blocks for the frontend editor
+ // This ensures LaTeX like $$...$$ is converted to inlineMath blocks
+ const frontBlocks = await markdownToBlocks(card.front || "");
+ const backBlocks = await markdownToBlocks(card.back || "");
+
+ return {
+ id: generateItemId(),
+ front: card.front || "",
+ back: card.back || "",
+ frontBlocks,
+ backBlocks
+ };
+ }));
+
+ // Filter out null entries (invalid cards)
+ const cardsWithIds = cardsWithIdsOrNull.filter((card): card is NonNullable => card !== null);
+
+ 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
+ const blockContent = params.content
+ ? await markdownToBlocks(params.content)
+ : undefined;
+
+ itemData = {
+ field1: params.content || "", // Keep for backwards compatibility and search
+ blockContent: blockContent, // Always store blocks
+ };
+
+ // If this is a deep research note, attach the metadata
+ if (params.deepResearchData) {
+ itemData.deepResearch = {
+ prompt: params.deepResearchData.prompt,
+ interactionId: params.deepResearchData.interactionId,
+ status: "researching",
+ thoughts: [],
+ };
+ }
+ }
+
+ const item: Item = {
+ id: itemId,
+ type: itemType,
+ name: params.title || (itemType === "quiz" ? "New Quiz" : itemType === "flashcard" ? "New Flashcard Deck" : "New Note"),
+ subtitle: "",
+ data: itemData,
+ color: getRandomCardColor(),
+ folderId: params.folderId,
+ };
+
+ const event = createEvent("ITEM_CREATED", { id: itemId, item }, userId);
+
+ const currentVersionResult = await db.execute(sql`
+ SELECT get_workspace_version(${params.workspaceId}::uuid) as version
+ `);
+
+ const baseVersion = currentVersionResult[0]?.version || 0;
+
+ 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
+ `);
+
+ if (!eventResult || eventResult.length === 0) {
+ throw new Error(`Failed to create ${itemType}`);
+ }
+
+ const result = eventResult[0].result as any;
+ if (result && result.conflict) {
+ throw new Error("Workspace was modified by another user, please try again");
+ }
+
+ logger.info(`📝 [WORKSPACE-WORKER] Created ${itemType}:`, item.name);
+
+ // Include card count for flashcard decks
+ const cardCount = itemType === "flashcard" && params.flashcardData?.cards
+ ? params.flashcardData.cards.length
+ : undefined;
+
+ return {
+ success: true,
+ itemId,
+ message: `Created ${itemType} "${item.name}" successfully`,
+ cardCount,
+ };
+ }
+
+ if (action === "update") {
+ try {
+ logger.group("📝 [UPDATE-NOTE] Starting update operation", true);
+ logger.debug("Raw params received:", {
+ paramsType: typeof params,
+ paramsKeys: params ? Object.keys(params) : [],
+ paramsValue: params,
+ });
+ logger.debug("Input parameters:", {
+ itemId: params?.itemId,
+ itemIdType: typeof params?.itemId,
+ workspaceId: params?.workspaceId,
+ workspaceIdType: typeof params?.workspaceId,
+ userId,
+ });
+ logger.groupEnd();
+
+ if (!params) {
+ logger.error("❌ [UPDATE-NOTE] Params object is missing");
+ throw new Error("Params object is required for update");
+ }
+
+ if (!params.itemId) {
+ logger.error("❌ [UPDATE-NOTE] Item ID required for update");
+ throw new Error("Item ID required for update");
+ }
+
+ if (typeof params.itemId !== 'string') {
+ logger.error("❌ [UPDATE-NOTE] Item ID must be a string");
+ throw new Error("Item ID must be a string");
+ }
+
+ if (!params.workspaceId) {
+ logger.error("❌ [UPDATE-NOTE] Workspace ID required for update");
+ throw new Error("Workspace ID required for update");
+ }
+
+ if (typeof params.workspaceId !== 'string') {
+ logger.error("❌ [UPDATE-NOTE] Workspace ID must be a string");
+ throw new Error("Workspace ID must be a string");
+ }
+
+ const changes: Partial
- = {};
+
+ // Update title if provided
+ if (params.title !== undefined) {
+ const titleStr = typeof params.title === 'string' ? params.title : String(params.title);
+ logger.debug("📝 [UPDATE-NOTE] Updating title:", {
+ newTitle: titleStr,
+ });
+ changes.name = titleStr;
+ }
+
+ // Update content if provided (allow empty string to clear content)
+ if (params.content !== undefined) {
+ const contentStr = typeof params.content === 'string' ? params.content : String(params.content);
+
+ logger.time("📝 [UPDATE-NOTE] markdownToBlocks conversion");
+ const blockContent = await markdownToBlocks(contentStr);
+ logger.timeEnd("📝 [UPDATE-NOTE] markdownToBlocks conversion");
+
+ changes.data = {
+ field1: contentStr,
+ blockContent: blockContent,
+ } as NoteData;
+ }
+
+ // If no changes, return early
+ if (Object.keys(changes).length === 0) {
+ logger.warn("⚠️ [UPDATE-NOTE] No changes detected, returning early");
+ return {
+ success: true,
+ message: "No changes to update",
+ itemId: params.itemId,
+ };
+ }
+
+ logger.time("📝 [UPDATE-NOTE] Event creation");
+ const event = createEvent("ITEM_UPDATED", { id: params.itemId, changes, source: 'agent' }, userId);
+ logger.timeEnd("📝 [UPDATE-NOTE] Event creation");
+
+ logger.time("📝 [UPDATE-NOTE] Get workspace version");
+ const currentVersionResult = await db.execute(sql`
+ SELECT get_workspace_version(${params.workspaceId}::uuid) as version
+ `);
+ logger.timeEnd("📝 [UPDATE-NOTE] Get workspace version");
+
+ const baseVersion = currentVersionResult[0]?.version || 0;
+ logger.debug("📝 [UPDATE-NOTE] Current workspace version:", baseVersion);
+
+ logger.time("📝 [UPDATE-NOTE] 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.timeEnd("📝 [UPDATE-NOTE] Append workspace event");
+
+ if (!eventResult || eventResult.length === 0) {
+ logger.error("❌ [UPDATE-NOTE] Failed to append event - no result returned");
+ throw new Error("Failed to update note");
+ }
+
+ const result = eventResult[0].result as any;
+
+ if (result && result.conflict) {
+ logger.error("❌ [UPDATE-NOTE] Conflict detected - workspace was modified by another user");
+ throw new Error("Workspace was modified by another user, please try again");
+ }
+
+ logger.group("✅ [UPDATE-NOTE] Update completed successfully", true);
+ logger.groupEnd();
+
+ return {
+ success: true,
+ itemId: params.itemId,
+ message: `Updated note successfully`,
+ };
+ } catch (error: any) {
+ logger.group("❌ [UPDATE-NOTE] Error during update operation", false);
+ logger.error("Error type:", error?.constructor?.name || typeof error);
+ logger.error("Error message:", error?.message || String(error));
+ logger.error("Full error object:", error);
+ logger.groupEnd();
+
+ // Re-throw to be handled by the caller
+ throw error;
+ }
+ }
+
+ if (action === "updateFlashcard") {
+ if (!params.itemId) {
+ throw new Error("Item ID required for flashcard update");
+ }
+ if (!params.flashcardData?.cardsToAdd || params.flashcardData.cardsToAdd.length === 0) {
+ throw new Error("Cards to add required for flashcard update");
+ }
+
+ // Use helper to load current state (duplicated logic removed)
+ const currentState = await loadWorkspaceState(params.workspaceId);
+
+ const existingItem = currentState.items.find((i: any) => i.id === params.itemId);
+ if (!existingItem) {
+ throw new Error(`Flashcard deck not found with ID: ${params.itemId}`);
+ }
+
+ if (existingItem.type !== "flashcard") {
+ throw new Error(`Item "${existingItem.name}" is not a flashcard deck (type: ${existingItem.type})`);
+ }
+
+ const existingData = existingItem.data as { cards?: any[] };
+ const existingCards = existingData?.cards || [];
+
+ // Generate new cards with IDs and parsed blocks
+ const newCards = await Promise.all(
+ params.flashcardData.cardsToAdd.map(async (card) => {
+ const frontBlocks = await markdownToBlocks(card.front);
+ const backBlocks = await markdownToBlocks(card.back);
+ return {
+ id: generateItemId(),
+ front: card.front,
+ back: card.back,
+ frontBlocks,
+ backBlocks,
+ };
+ })
+ );
+
+ // Merge existing cards with new cards
+ const updatedData = {
+ ...existingData,
+ cards: [...existingCards, ...newCards],
+ };
+
+ const changes = { data: updatedData };
+ const event = createEvent("ITEM_UPDATED", { id: params.itemId, changes, source: 'agent' }, userId);
+
+ const currentVersionResult = await db.execute(sql`
+ SELECT get_workspace_version(${params.workspaceId}::uuid) as version
+ `);
+
+ const baseVersion = currentVersionResult[0]?.version || 0;
+
+ 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
+ `);
+
+ if (!eventResult || eventResult.length === 0) {
+ throw new Error("Failed to update flashcard deck: database returned no result");
+ }
+
+ const result = eventResult[0].result as any;
+
+ if (result && result.conflict) {
+ throw new Error("Workspace was modified by another user, please try again");
+ }
+
+ logger.info("🎴 [WORKSPACE-WORKER] Updated flashcard deck:", {
+ itemId: params.itemId,
+ cardsAdded: newCards.length,
+ totalCards: updatedData.cards.length,
+ });
+
+ return {
+ success: true,
+ itemId: params.itemId,
+ cardsAdded: newCards.length,
+ message: `Added ${newCards.length} card${newCards.length !== 1 ? 's' : ''} to flashcard deck`,
+ };
+ }
+
+ if (action === "updateQuiz") {
+ if (!params.itemId) {
+ throw new Error("Item ID required for updateQuiz");
+ }
+
+ // Allow questionsToAdd to be at top level or nested in quizData (for backward compatibility)
+ const questionsToAdd = params.questionsToAdd || (params.quizData as any)?.questionsToAdd;
+
+ if (!questionsToAdd || 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: questionsToAdd.length,
+ });
+
+ // Use helper to load current state (duplicated logic removed)
+ const currentState = await loadWorkspaceState(params.workspaceId);
+
+ const existingItem = currentState.items.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, ...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: questionsToAdd.length,
+ totalQuestions: updatedData.questions.length,
+ });
+
+ return {
+ success: true,
+ itemId: params.itemId,
+ questionsAdded: questionsToAdd.length,
+ totalQuestions: updatedData.questions.length,
+ message: `Added ${questionsToAdd.length} question${questionsToAdd.length !== 1 ? 's' : ''} to quiz`,
+ };
+ }
+
+ if (action === "delete") {
+ if (!params.itemId) {
+ throw new Error("Item ID required for delete");
+ }
+
+ const event = createEvent("ITEM_DELETED", { id: params.itemId }, userId);
+
+ const currentVersionResult = await db.execute(sql`
+ SELECT get_workspace_version(${params.workspaceId}::uuid) as version
+ `);
+
+ const baseVersion = currentVersionResult[0]?.version || 0;
+
+ 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
+ `);
+
+ if (!eventResult || eventResult.length === 0) {
+ throw new Error("Failed to delete note");
+ }
+
+ const result = eventResult[0].result as any;
+ if (result && result.conflict) {
+ throw new Error("Workspace was modified by another user, please try again");
+ }
+
+ logger.info("📝 [WORKSPACE-WORKER] Deleted note:", params.itemId);
+
+ return {
+ success: true,
+ itemId: params.itemId,
+ message: `Deleted note successfully`,
+ };
+ }
+
+ // Fallback for unhandled actions
+ return {
+ success: false,
+ message: `Action ${action} not implemented`,
+ };
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+ logger.error("📝 [WORKSPACE-WORKER] Error:", errorMessage);
+ return {
+ success: false,
+ message: `Failed: ${errorMessage}`,
+ };
+ }
+ });
+}
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..264dfa63
--- /dev/null
+++ b/src/lib/editor/math-helpers.ts
@@ -0,0 +1,269 @@
+// 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 = /\$\$([\s\S]+?)\$\$/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\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
+ // AND does not contain any non-text inline content (links, mentions, etc.)
+ const hasNonTextContent = block.content.some((item: any) => item.type !== "text");
+ const blockMathMatch = !hasNonTextContent && 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);
+ }
+ }
+
+ return processed;
+}
diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts
index 964a8b59..36a583e5 100644
--- a/src/lib/utils/format-workspace-context.ts
+++ b/src/lib/utils/format-workspace-context.ts
@@ -1,4 +1,4 @@
-import type { AgentState, Item, NoteData, PdfData, FlashcardData, FlashcardItem, YouTubeData } from "@/lib/workspace-state/types";
+import type { AgentState, Item, NoteData, PdfData, FlashcardData, FlashcardItem, YouTubeData, QuizData, QuizQuestion } from "@/lib/workspace-state/types";
import { serializeBlockNote } from "./serialize-blocknote";
import { type Block } from "@/components/editor/BlockNoteEditor";
@@ -294,7 +294,7 @@ function formatRichContentSection(richContent: RichContent): string {
}
lines.push("");
- lines.push("🎨 RICH CONTENT:");
+ lines.push("RICH CONTENT:");
// Format images
if (richContent.images.length > 0) {
@@ -422,21 +422,14 @@ ${singleSourceOfTruthWarning}
* Formats a single selected card with FULL content (no truncation)
*/
function formatSelectedCardFull(item: Item, index: number): string {
- const typeEmoji: Record = {
- note: "📝",
- pdf: "📄",
- flashcard: "🎴",
- youtube: "🎥"
- };
- const emoji = typeEmoji[item.type] || "📄";
const separator = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━";
const lines = [
separator,
- `CARD ${index}: ${emoji} [${item.type.charAt(0).toUpperCase() + item.type.slice(1)}] "${item.name}"`,
- `⚡ Card ID: ${item.id} ← USE THIS ID FOR ANY UPDATES TO THIS CARD`,
+ `CARD ${index}: [${item.type.charAt(0).toUpperCase() + item.type.slice(1)}] "${item.name}"`,
+ `Card ID: ${item.id} ← USE THIS ID FOR ANY UPDATES TO THIS CARD`,
""
];
@@ -445,7 +438,7 @@ function formatSelectedCardFull(item: Item, index: number): string {
lines.push(` Subtitle: ${item.subtitle}`);
}
- lines.push("📄 CONTENT:");
+ lines.push("CONTENT:");
// Add type-specific details with FULL content
switch (item.type) {
@@ -461,11 +454,14 @@ function formatSelectedCardFull(item: Item, index: number): string {
case "youtube":
lines.push(...formatYouTubeDetailsFull(item.data as YouTubeData));
break;
+ case "quiz":
+ lines.push(...formatQuizDetailsFull(item.data as QuizData));
+ break;
}
// Add Metadata Section
lines.push("");
- lines.push("🔧 METADATA:");
+ lines.push("METADATA:");
lines.push(` Card ID: ${item.id}`);
lines.push(` Type: ${item.type}`);
@@ -499,6 +495,7 @@ function formatNoteDetailsFull(data: NoteData): string[] {
/**
* Formats PDF details with FULL content
+ * Note: PDFs include a marker for Gemini to read the content directly via URL
*/
function formatPdfDetailsFull(data: PdfData): string[] {
const lines: string[] = [];
@@ -509,6 +506,7 @@ function formatPdfDetailsFull(data: PdfData): string[] {
if (data.fileUrl) {
lines.push(` - URL: ${data.fileUrl}`);
+
}
if (data.fileSize) {
@@ -624,3 +622,97 @@ function formatFlashcardDetailsFull(data: FlashcardData): string[] {
return lines;
}
+/**
+ * Formats quiz details with FULL content
+ */
+function formatQuizDetailsFull(data: QuizData): string[] {
+ const lines: string[] = [];
+ const questions: QuizQuestion[] = data.questions || [];
+ const session = data.session;
+ const totalQuestions = questions.length;
+
+ const answered = session?.answeredQuestions || [];
+ const answeredCount = answered.length;
+
+ // Determine completion status:
+ // 1. All questions answered (answeredCount >= totalQuestions)
+ // 2. OR completedAt exists AND most questions answered (handles race condition)
+ // But NOT if many questions were added after completion (answeredCount << totalQuestions)
+ const hasCompletedAt = !!session?.completedAt;
+ const mostQuestionsAnswered = answeredCount >= totalQuestions - 1; // Allow for off-by-one race
+ const questionsWereAdded = hasCompletedAt && answeredCount < totalQuestions * 0.9; // More than 10% new questions
+
+ const isCompleted = totalQuestions > 0 && (
+ answeredCount >= totalQuestions || // All answered
+ (hasCompletedAt && mostQuestionsAnswered && !questionsWereAdded) // Has completedAt, almost done, no new Qs
+ );
+ const isNotStarted = answeredCount === 0;
+
+ let status: "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED";
+ if (isCompleted) {
+ status = "COMPLETED";
+ } else if (isNotStarted) {
+ status = "NOT_STARTED";
+ } else {
+ status = "IN_PROGRESS";
+ }
+
+ lines.push(`STATUS: ${status}`);
+
+ let correctCount = 0;
+ let incorrectCount = 0;
+ if (answeredCount > 0) {
+ correctCount = answered.filter(a => a.isCorrect).length;
+ incorrectCount = answeredCount - correctCount;
+ }
+
+ if (status === "NOT_STARTED") {
+ lines.push(` - Questions: ${totalQuestions}`);
+ lines.push(` - Difficulty: ${data.difficulty || "Medium"}`);
+ if (data.sourceCardNames?.length) {
+ lines.push(` - Source: "${data.sourceCardNames.join('", "')}"`);
+ }
+ } else if (status === "IN_PROGRESS") {
+ const rawIndex = session?.currentIndex ?? answeredCount;
+ const currentIndex = totalQuestions > 0 ? Math.min(Math.max(rawIndex, 0), totalQuestions - 1) : 0;
+ lines.push(` - Question: ${totalQuestions > 0 ? currentIndex + 1 : 0} of ${totalQuestions}`);
+ lines.push(` - Score: ${correctCount} correct, ${incorrectCount} incorrect (${answeredCount} answered)`);
+ } else {
+ // COMPLETED
+ const percentage = totalQuestions > 0 ? Math.round((correctCount / totalQuestions) * 100) : 0;
+ lines.push(` - Final Score: ${correctCount}/${totalQuestions} (${percentage}%)`);
+ lines.push(` - Difficulty: ${data.difficulty || "Medium"}`);
+ }
+
+ // Only show answered questions list for COMPLETED status
+ if (status === "COMPLETED" && answeredCount > 0) {
+ lines.push("");
+ lines.push("ALL ANSWERS:");
+
+ const answeredMap = new Map();
+ answered.forEach(a => answeredMap.set(a.questionId, a.isCorrect));
+
+ questions.forEach((q, i) => {
+ const isCorrect = answeredMap.get(q.id);
+ const marker = isCorrect === true ? "✓" : isCorrect === false ? "✗" : "-";
+ lines.push(` ${i + 1}. ${marker} ${truncateText(q.questionText, 60)}`);
+ });
+ }
+
+ // Show current question for NOT_STARTED and IN_PROGRESS
+ if (status !== "COMPLETED") {
+ const rawIndex = session?.currentIndex ?? 0;
+ const currentIndex = totalQuestions > 0 ? Math.min(Math.max(rawIndex, 0), totalQuestions - 1) : 0;
+ const currentQ = questions[currentIndex];
+ if (currentQ) {
+ lines.push("");
+ lines.push("CURRENT QUESTION:");
+ lines.push(` ${currentIndex + 1}. ${currentQ.questionText}`);
+ currentQ.options.forEach((opt, i) => {
+ lines.push(` ${String.fromCharCode(65 + i)}) ${opt}`);
+ });
+ }
+ }
+
+ return lines;
+}
diff --git a/src/lib/workspace-state/grid-layout-helpers.ts b/src/lib/workspace-state/grid-layout-helpers.ts
index 04617c36..4caf1928 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)