diff --git a/src/app/api/cards/from-message/route.ts b/src/app/api/cards/from-message/route.ts index ed59b10f..6e4aee72 100644 --- a/src/app/api/cards/from-message/route.ts +++ b/src/app/api/cards/from-message/route.ts @@ -21,8 +21,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const userId = session.user.id; - const body = await request.json(); const { content, workspaceId, folderId, sources } = body; diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index 2151a831..45ab09e1 100644 --- a/src/app/api/workspaces/autogen/route.ts +++ b/src/app/api/workspaces/autogen/route.ts @@ -8,11 +8,12 @@ import { desc, eq, sql } from "drizzle-orm"; import { requireAuthWithUserInfo } from "@/lib/api/workspace-helpers"; import { db, workspaces } from "@/lib/db/client"; import { generateSlug } from "@/lib/workspace/slug"; -import { workspaceWorker, quizWorker, type CreateItemParams } from "@/lib/ai/workers"; +import { workspaceWorker, type CreateItemParams } from "@/lib/ai/workers"; import { searchVideos } from "@/lib/youtube"; import { UrlProcessor } from "@/lib/ai/utils/url-processor"; import { findNextAvailablePosition } from "@/lib/workspace-state/grid-layout-helpers"; -import type { Item } from "@/lib/workspace-state/types"; +import { generateItemId } from "@/lib/workspace-state/item-helpers"; +import type { Item, QuizQuestion } from "@/lib/workspace-state/types"; import { CANVAS_CARD_COLORS } from "@/lib/workspace-state/colors"; import { logger } from "@/lib/utils/logger"; @@ -130,8 +131,7 @@ const DISTILLED_SCHEMA = z.object({ }), contentSummary: z .string() - .describe("Comprehensive summary of the content for creating study note and flashcards. Include key concepts, facts, and structure. 200-800 words."), - quizTopic: z.string().describe("Topic string for quiz generation, with references if relevant"), + .describe("Comprehensive summary of the content for creating study note, flashcards, and quiz. Include key concepts, facts, and structure. 200-800 words."), youtubeSearchTerm: z.string().describe("Broad, general search query for finding a related YouTube video (e.g. 'Emacs tutorial for beginners' not 'CMSC 216 UNIX Emacs project grading')."), }); @@ -140,7 +140,6 @@ type DistilledOutput = z.infer; type DistillationResult = { metadata: { title: string; icon: string; color: string }; contentSummary: string; - quizTopic: string; youtubeSearchTerm: string; sources: Array<{ title: string; url: string }>; }; @@ -232,9 +231,8 @@ You are a workspace content distiller. The user provides content (prompt, files, 1. Generate workspace metadata: a short title (5-6 words), an icon from the list, and a hex color. -2. Write a content summary (200-800 words) with key concepts, facts, and structure for notes and flashcards. -3. Produce a quiz topic string focused EXPLICITLY on the introductory / foundational concepts covered at the very beginning of the content (optionally "References: ..." if links are relevant). Do NOT use advanced concepts from the middle or end. -4. Produce a YouTube search term: broad, 2-5 common words (e.g. "Emacs tutorial beginners"). Do NOT use course codes, assignment names, or narrow phrasing. +2. Write a content summary (200-800 words) with key concepts, facts, and structure for notes, flashcards, and quiz. +3. Produce a YouTube search term: broad, 2-5 common words (e.g. "Emacs tutorial beginners"). Do NOT use course codes, assignment names, or narrow phrasing. @@ -244,7 +242,7 @@ You are a workspace content distiller. The user provides content (prompt, files, Input: "Create a workspace for learning Python data analysis" -Output shape: metadata (title: "Python Data Analysis", icon: "ChartBarIcon", color: "#3b82f6"), contentSummary (structured overview of key topics), quizTopic ("Python pandas matplotlib"), youtubeSearchTerm ("Python data analysis tutorial") +Output shape: metadata (title: "Python Data Analysis", icon: "ChartBarIcon", color: "#3b82f6"), contentSummary (structured overview of key topics), youtubeSearchTerm ("Python data analysis tutorial") `, messages: [{ role: "user" as const, content: contentWithContext }] as const, onError: ({ error }) => logger.error("[AUTOGEN] Distillation stream error:", error), @@ -271,13 +269,11 @@ Output shape: metadata (title: "Python Data Analysis", icon: "ChartBarIcon", col } const contentSummary = String(output.contentSummary ?? "").trim() || prompt; - const quizTopic = String(output.quizTopic ?? "").trim() || prompt; const youtubeSearchTerm = String(output.youtubeSearchTerm ?? "").trim() || prompt; const result: DistillationResult = { metadata: { title, icon, color }, contentSummary, - quizTopic, youtubeSearchTerm, sources: Array.isArray(sources) ? sources : [], }; @@ -293,25 +289,24 @@ Output shape: metadata (title: "Python Data Analysis", icon: "ChartBarIcon", col color: result.metadata.color, contentSummaryLength: result.contentSummary.length, contentSummaryPreview: truncateForLog(result.contentSummary), - quizTopic: truncateForLog(result.quizTopic), youtubeSearchTerm: result.youtubeSearchTerm, sourcesCount: result.sources.length, }); return result; } -/** System prompt for note + flashcard generation. Aligns with formatWorkspaceContext FORMATTING (markdown, math, mermaid). */ -const NOTE_FLASHCARD_SYSTEM = `You generate a study note and a flashcard deck for ThinkEx. Both must be on the same topic and use consistent formatting. +/** System prompt for note + flashcard + quiz generation. Aligns with formatWorkspaceContext FORMATTING (markdown, math, mermaid). */ +const NOTE_FLASHCARD_QUIZ_SYSTEM = `You generate a study note, a flashcard deck, and a quiz for ThinkEx. All must be on the same topic and use consistent formatting. -FORMATTING (apply to both note content and flashcard front/back text): +FORMATTING (apply to note content, flashcard front/back text): - Use Markdown (GFM): headers, lists, bold/italic, code, links. -- MATH: Use $$...$$ for all math (inline and block). Inline: $$E = mc^2$$ on the same line as text. Block: put $$...$$ on its own lines for centered display, e.g. - $$ - \\int_{-\\infty}^{\\infty} e^{-x^2} \\, dx = \\sqrt{\\pi} - $$ +- MATH: Use $$...$$ for all math (inline and block). Inline: $$E = mc^2$$ on the same line as text. Block: put $$...$$ on its own lines for centered display. For currency use a single $ with no closing $ (e.g. $19.99). -Output a complete note (title + markdown content) and 5–8 flashcard pairs (front, back) that reinforce the same material.`; +Output: +1. note: title + markdown content +2. flashcards: title + 5-8 flashcard pairs (front, back) +3. quiz: title + 5 quiz questions. Each question: type ("multiple_choice" or "true_false"), questionText, options (4 for MC, ["True","False"] for T/F), correctIndex (0-based), hint (optional), explanation. Focus on introductory/foundational concepts.`; type StreamEvent = | { type: "phase"; data: { stage: "understanding" } } @@ -418,13 +413,12 @@ export async function POST(request: NextRequest) { send ); - const { metadata, contentSummary, quizTopic, youtubeSearchTerm } = distilled; + const { metadata, contentSummary, youtubeSearchTerm } = distilled; const { title, icon, color } = metadata; logger.debug("[AUTOGEN] Content-generation prompts", { contentSummaryLength: contentSummary?.length ?? 0, contentSummaryPreview: truncateForLog(contentSummary ?? ""), - quizTopic: truncateForLog(quizTopic ?? ""), youtubeSearchTerm: youtubeSearchTerm ?? "", }); @@ -508,26 +502,38 @@ export async function POST(request: NextRequest) { logger.error("[AUTOGEN] Error creating WORKSPACE_CREATED event:", eventError); } - // 3. Generate content in parallel. Stream progress as each completes. Defer DB writes to bulk create. + // 3. Generate content: note + flashcards + quiz in one LLM call, youtube in parallel const phase3Start = Date.now(); - const NOTE_FLASHCARD_SCHEMA = z.object({ + const QuizQuestionSchema = z.object({ + type: z.enum(["multiple_choice", "true_false"]), + questionText: z.string(), + options: z.array(z.string()), + correctIndex: z.number(), + hint: z.string().optional(), + explanation: z.string(), + }); + const NOTE_FLASHCARD_QUIZ_SCHEMA = z.object({ note: z.object({ title: z.string(), content: z.string() }), flashcards: z.object({ title: z.string(), cards: z.array(z.object({ front: z.string(), back: z.string() })).min(5).max(12), }), + quiz: z.object({ + title: z.string(), + questions: z.array(QuizQuestionSchema).min(5).max(10), + }), }); - const noteAndFlashcardFn = async () => { - type NoteFlashcardOutput = z.infer; - let output: NoteFlashcardOutput | undefined; + const noteFlashcardQuizFn = async () => { + type OutputType = z.infer; + let output: OutputType | undefined; const { partialOutputStream } = streamText({ model: google("gemini-2.5-flash"), - system: NOTE_FLASHCARD_SYSTEM, + system: NOTE_FLASHCARD_QUIZ_SYSTEM, output: Output.object({ - name: "NoteAndFlashcards", - description: "Study note and flashcard deck for the same topic", - schema: NOTE_FLASHCARD_SCHEMA, + name: "NoteFlashcardsQuiz", + description: "Study note, flashcard deck, and quiz for the same topic", + schema: NOTE_FLASHCARD_QUIZ_SCHEMA, }), prompt: `Create study materials about the following content: @@ -535,34 +541,56 @@ ${contentSummary} Return: 1. note: a short title and markdown content for a study note. -2. flashcards: a title and 5-8 flashcard pairs (front, back) on the same topic.`, - onError: ({ error }) => logger.error("[AUTOGEN] NoteFlashcards stream error:", error), +2. flashcards: a title and 5-8 flashcard pairs (front, back) on the same topic. +3. quiz: a title and 5 quiz questions (multiple_choice or true_false) covering introductory concepts.`, + onError: ({ error }) => logger.error("[AUTOGEN] NoteFlashcardsQuiz stream error:", error), }); for await (const partial of partialOutputStream) { - output = partial as NoteFlashcardOutput; + output = partial as OutputType; send({ type: "partial", data: { stage: "noteFlashcards", partial } }); } - if (!output?.note || !output?.flashcards) throw new Error("Failed to generate note and flashcards"); + if (!output?.note || !output?.flashcards || !output?.quiz) throw new Error("Failed to generate note, flashcards, or quiz"); send({ type: "progress", data: { step: "note", status: "done" } }); send({ type: "progress", data: { step: "flashcards", status: "done" } }); + send({ type: "progress", data: { step: "quiz", status: "done" } }); + + const questions: QuizQuestion[] = output.quiz.questions.map((q) => { + const type = q.type === "true_false" ? "true_false" : "multiple_choice"; + let options = Array.isArray(q.options) ? q.options.map(String) : []; + const requiredCount = type === "true_false" ? 2 : 4; + if (options.length < requiredCount) { + options = [...options, ...Array(requiredCount - options.length).fill("(No option provided)")]; + } else if (options.length > requiredCount) { + options = options.slice(0, requiredCount); + } + const correctIndex = typeof q.correctIndex === "number" + ? Math.max(0, Math.min(q.correctIndex, options.length - 1)) + : 0; + return { + id: generateItemId(), + type, + questionText: String(q.questionText ?? ""), + options, + correctIndex, + hint: q.hint, + explanation: String(q.explanation ?? "No explanation provided."), + }; + }); + return { note: { title: output.note.title, content: output.note.content, layout: AUTOGEN_LAYOUTS.note }, flashcards: { title: output.flashcards.title, cards: output.flashcards.cards, layout: AUTOGEN_LAYOUTS.flashcard }, + quiz: { title: output.quiz.title, questions, layout: AUTOGEN_LAYOUTS.quiz }, }; }; const youtubeUrlFromLinks = links?.find(isYouTubeUrl); - const [noteFlashcardResult, quizResult, youtubeResult] = await Promise.all([ - noteAndFlashcardFn(), - (async () => { - const quiz = await quizWorker({ topic: quizTopic, questionCount: 5 }); - send({ type: "progress", data: { step: "quiz", status: "done" } }); - return { title: quiz.title, questions: quiz.questions, layout: AUTOGEN_LAYOUTS.quiz }; - })(), + const [noteFlashcardQuizResult, youtubeResult] = await Promise.all([ + noteFlashcardQuizFn(), (async () => { if (youtubeUrlFromLinks) { send({ type: "progress", data: { step: "youtube", status: "done" } }); @@ -579,49 +607,46 @@ Return: timings.contentGenerationMs = Date.now() - phase3Start; logger.info("[AUTOGEN] Content generation done", { ms: timings.contentGenerationMs }); + const { note, flashcards, quiz: quizContent } = noteFlashcardQuizResult; logger.debug("[AUTOGEN] Generated content", { note: { - title: noteFlashcardResult.note.title, - contentLength: noteFlashcardResult.note.content.length, - contentPreview: truncateForLog(noteFlashcardResult.note.content), + title: note.title, + contentLength: note.content.length, + contentPreview: truncateForLog(note.content), }, flashcards: { - title: noteFlashcardResult.flashcards.title, - cardCount: noteFlashcardResult.flashcards.cards.length, - firstCardFront: noteFlashcardResult.flashcards.cards[0]?.front - ? truncateForLog(noteFlashcardResult.flashcards.cards[0].front, 120) - : undefined, + title: flashcards.title, + cardCount: flashcards.cards.length, + firstCardFront: flashcards.cards[0]?.front ? truncateForLog(flashcards.cards[0].front, 120) : undefined, }, quiz: { - title: quizResult.title, - questionCount: quizResult.questions.length, + title: quizContent.title, + questionCount: quizContent.questions.length, }, - youtube: youtubeResult - ? { title: youtubeResult.title, url: youtubeResult.url?.slice(0, 50) + "..." } - : null, + youtube: youtubeResult ? { title: youtubeResult.title, url: youtubeResult.url?.slice(0, 50) + "..." } : null, }); // Build create params for bulk create const phase4Start = Date.now(); const createParams: CreateItemParams[] = [ { - title: noteFlashcardResult.note.title, - content: noteFlashcardResult.note.content, + title: note.title, + content: note.content, itemType: "note", - layout: noteFlashcardResult.note.layout, + layout: note.layout, ...((distilled.sources?.length ?? 0) > 0 && { sources: distilled.sources }), }, - { title: noteFlashcardResult.flashcards.title, itemType: "flashcard", flashcardData: { cards: noteFlashcardResult.flashcards.cards }, layout: noteFlashcardResult.flashcards.layout }, - { title: quizResult.title, itemType: "quiz", quizData: { questions: quizResult.questions }, layout: quizResult.layout }, + { title: flashcards.title, itemType: "flashcard", flashcardData: { cards: flashcards.cards }, layout: flashcards.layout }, + { title: quizContent.title, itemType: "quiz", quizData: { questions: quizContent.questions }, layout: quizContent.layout }, ...(youtubeResult ? [{ title: youtubeResult.title, itemType: "youtube" as const, youtubeData: { url: youtubeResult.url }, layout: youtubeResult.layout }] : []), ]; const pdfFileUrls = (fileUrls ?? []).filter((f) => f.mediaType === "application/pdf"); const imageFileUrls = (fileUrls ?? []).filter((f) => f.mediaType?.startsWith("image/")); const itemsForLayout: Pick[] = [ - { type: "note", layout: noteFlashcardResult.note.layout }, - { type: "flashcard", layout: noteFlashcardResult.flashcards.layout }, - { type: "quiz", layout: quizResult.layout }, + { type: "note", layout: note.layout }, + { type: "flashcard", layout: flashcards.layout }, + { type: "quiz", layout: quizContent.layout }, ...(youtubeResult ? [{ type: "youtube" as const, layout: youtubeResult.layout }] : []), ]; for (const pdf of pdfFileUrls) { diff --git a/src/components/assistant-ui/CreateQuizToolUI.tsx b/src/components/assistant-ui/CreateQuizToolUI.tsx index 231e6b63..e2f425e0 100644 --- a/src/components/assistant-ui/CreateQuizToolUI.tsx +++ b/src/components/assistant-ui/CreateQuizToolUI.tsx @@ -21,17 +21,9 @@ import { initialState } from "@/lib/workspace-state/state"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import type { QuizResult } from "@/lib/ai/tool-result-schemas"; import { parseQuizResult } from "@/lib/ai/tool-result-schemas"; - -type CreateQuizArgs = { - topic?: string; - difficulty?: "easy" | "medium" | "hard"; - contextContent?: string; - sourceCardIds?: string[]; - sourceCardNames?: string[]; -}; +import type { CreateQuizInput } from "@/lib/ai/tools/quiz-tools"; interface CreateQuizReceiptProps { - args: CreateQuizArgs; result: QuizResult; status: any; moveItemToFolder?: (itemId: string, folderId: string | null) => void; @@ -41,13 +33,7 @@ interface CreateQuizReceiptProps { 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 CreateQuizReceipt = ({ result, status, moveItemToFolder, allItems = [], workspaceName = "Workspace", workspaceIcon, workspaceColor }: CreateQuizReceiptProps) => { const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); const { state: workspaceState } = useWorkspaceState(workspaceId); const navigateToItem = useNavigateToItem(); @@ -55,12 +41,23 @@ const CreateQuizReceipt = ({ args, result, status, moveItemToFolder, allItems = // State for MoveToDialog const [showMoveDialog, setShowMoveDialog] = useState(false); - // Get the current item from workspace state + // Get the current item — try workspace state first, then allItems, then stub from result 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]); + if (!targetId) return undefined; + const fromWorkspace = workspaceState?.items?.find((item: { id: string }) => item.id === targetId); + if (fromWorkspace) return fromWorkspace; + const fromAll = allItems.find((item: { id: string }) => item.id === targetId); + if (fromAll) return fromAll; + return { + id: targetId, + name: (result as { title?: string }).title ?? "Quiz", + type: "quiz" as const, + subtitle: "", + data: {}, + folderId: undefined, + }; + }, [result.itemId, result.quizId, result, workspaceState?.items, allItems]); // Get folder name if item is in a folder const folderName = useMemo(() => { @@ -82,8 +79,6 @@ const CreateQuizReceipt = ({ args, result, status, moveItemToFolder, allItems = } }; - const difficulty = result.difficulty || args.difficulty || "medium"; - return ( <>
({ +export const CreateQuizToolUI = makeAssistantToolUI({ toolName: "createQuiz", render: function CreateQuizUI({ args, result, status }) { const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); @@ -201,7 +196,6 @@ export const CreateQuizToolUI = makeAssistantToolUI( if (parsed?.success) { content = ( { +const EditItemReceipt = ({ args, result, status }: EditItemReceiptProps) => { const setOpenModalItemId = useUIStore((s) => s.setOpenModalItemId); const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); const { state: workspaceState } = useWorkspaceState(workspaceId); const navigateToItem = useNavigateToItem(); - // Get the card from workspace to show its title const card = useMemo(() => { - if (!result.itemId || !workspaceState?.items) return null; - return workspaceState.items.find((item: any) => item.id === result.itemId); + if (!result.itemId || !workspaceState?.items) return undefined; + return workspaceState.items.find((item: Item) => item.id === result.itemId); }, [result.itemId, workspaceState?.items]); const handleViewCard = () => { if (!result.itemId) return; - // Only open modal if item exists and navigation succeeds if (navigateToItem(result.itemId)) { setOpenModalItemId(result.itemId); } @@ -54,6 +61,12 @@ const UpdateNoteReceipt = ({ args, result, status }: UpdateNoteReceiptProps) => const hasDiff = result.diff && result.diff.trim().length > 0; + const subtitle = useMemo(() => { + if (result.cardCount != null) return `${result.cardCount} cards`; + if (result.questionCount != null) return `${result.questionCount} questions`; + return "Item updated"; + }, [result.cardCount, result.questionCount]); + return (
onClick={status?.type === "complete" && result.itemId ? handleViewCard : undefined} >
-
+
{status?.type === "complete" ? ( - + ) : ( )}
- {status?.type === "complete" ? (card?.name || "Card Updated") : "Update Cancelled"} + {status?.type === "complete" + ? String(card?.name ?? (result as { itemName?: string }).itemName ?? "Item Updated") + : "Edit Cancelled"} - {status?.type === "complete" && ( - Note updated - )} + {status?.type === "complete" && {subtitle}}
@@ -117,21 +128,19 @@ const UpdateNoteReceipt = ({ args, result, status }: UpdateNoteReceiptProps) => ); }; -export const UpdateNoteToolUI = makeAssistantToolUI({ - toolName: "updateNote", - render: function UpdateNoteUI({ args, result, status }) { +export const EditItemToolUI = makeAssistantToolUI({ + toolName: "editItem", + render: function EditItemUI({ args, result, status }) { const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); useOptimisticToolUpdate(status, result as any, workspaceId); - // Don't try to parse while still running - wait for completion let parsed: WorkspaceResult | null = null; if (status.type === "complete" && result != null) { try { parsed = parseWorkspaceResult(result); } catch (err) { - // Log the error but don't throw - we'll show error state below - console.error("📝 [UpdateNoteTool] Failed to parse result:", err); + console.error("[EditItemTool] Failed to parse result:", err); parsed = null; } } @@ -139,27 +148,18 @@ export const UpdateNoteToolUI = makeAssistantToolUI; + content = ; } else if (status.type === "running") { - content = ; + const itemName = args?.itemName; + content = ; } else if (status.type === "complete" && parsed && !parsed.success) { - content = ( - - ); + content = ; } else if (status.type === "incomplete" && status.reason === "error") { - content = ( - - ); + content = ; } return ( - + {content} ); diff --git a/src/components/assistant-ui/SelectCardsToolUI.tsx b/src/components/assistant-ui/SelectCardsToolUI.tsx deleted file mode 100644 index bee0ecc1..00000000 --- a/src/components/assistant-ui/SelectCardsToolUI.tsx +++ /dev/null @@ -1,239 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { makeAssistantToolUI } from "@assistant-ui/react"; -import { useEffect, useMemo, useRef } from "react"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import { useUIStore } from "@/lib/stores/ui-store"; -import { CheckIcon, X } from "lucide-react"; -import { CgNotes } from "react-icons/cg"; -import { cn } from "@/lib/utils"; -import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; -import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; -import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; -import { parseSelectCardsResult } from "@/lib/ai/tool-result-schemas"; - -// Note: UI accepts cardIds for client-side selection, but tool only accepts cardTitles -// The UI handles cardIds client-side and doesn't pass them to the backend tool -type SelectCardsArgs = { - cardTitles: string[]; // Required by tool - matches backend schema - cardIds?: string[]; // Optional - handled client-side only, not sent to backend -}; - -type SelectCardsResult = { - success: boolean; - message: string; - addedCount?: number; - invalidIds?: string[]; -}; - -/** - * Frontend UI + effect layer for the selectCards tool. - * When the tool runs, it selects the requested cards via the UI store, - * which automatically adds them to the assistant's context drawer. - */ -export const SelectCardsToolUI = makeAssistantToolUI({ - toolName: "selectCards", - render: ({ args, status, result }) => { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state } = useWorkspaceState(workspaceId); - const selectMultipleCards = useUIStore((ui) => ui.selectMultipleCards); - const currentSelectedIds = useUIStore((ui) => ui.selectedCardIds); - - const hasSelectedRef = useRef(false); - - const availableIds = useMemo(() => { - return new Set((state?.items || []).map((item) => item.id)); - }, [state?.items]); - - useEffect(() => { - if (hasSelectedRef.current) return; - if ((!args?.cardIds || args.cardIds.length === 0) && (!args?.cardTitles || args.cardTitles.length === 0)) return; - - // Only execute when the tool is actively running (first execution) - // Don't execute on "complete" status to avoid re-selection on refresh - if (status.type === "running") { - let idsToSelect: string[] = []; - - // Handle IDs if present - if (args.cardIds && args.cardIds.length > 0) { - const validIds = args.cardIds.filter((id) => availableIds.has(id)); - idsToSelect = [...idsToSelect, ...validIds]; - } - - // Handle Titles if present - resolve to IDs - if (args.cardTitles && args.cardTitles.length > 0 && state?.items) { - args.cardTitles.forEach(title => { - const searchTitle = title.toLowerCase().trim(); - // 1. Exact match - let match = state.items.find(item => item.name.toLowerCase().trim() === searchTitle); - // 2. Contains match - if (!match) { - match = state.items.find(item => item.name.toLowerCase().includes(searchTitle)); - } - - if (match && availableIds.has(match.id)) { - if (!idsToSelect.includes(match.id)) { - idsToSelect.push(match.id); - } - } - }); - } - - // Only update selection if we have at least one valid card - if (idsToSelect.length > 0) { - // Merge with existing selection instead of replacing - // This preserves both single and multiple existing selections (whether 1 card or many) - const existingIdsArray = Array.from(currentSelectedIds); - const mergedIds = Array.from(new Set([...existingIdsArray, ...idsToSelect])); - selectMultipleCards(mergedIds); - } - hasSelectedRef.current = true; - } - }, [args?.cardIds, args?.cardTitles, availableIds, selectMultipleCards, status.type, currentSelectedIds, state?.items]); - - // Get selected cards with their names - const selectedCards = useMemo(() => { - if (!state?.items) return []; - - const idsFromArgs = args?.cardIds || []; - const titlesFromArgs = args?.cardTitles || []; - - if (idsFromArgs.length === 0 && titlesFromArgs.length === 0) return []; - - const resolvedIds = new Set(); - - // Resolve IDs - idsFromArgs.forEach(id => { - if (availableIds.has(id)) resolvedIds.add(id); - }); - - // Resolve Titles - titlesFromArgs.forEach(title => { - const searchTitle = title.toLowerCase().trim(); - let match = state.items.find(item => item.name.toLowerCase().trim() === searchTitle); - if (!match) { - match = state.items.find(item => item.name.toLowerCase().includes(searchTitle)); - } - if (match && availableIds.has(match.id)) { - resolvedIds.add(match.id); - } - }); - - return Array.from(resolvedIds) - .map((id) => state.items.find((item) => item.id === id)) - .filter((item) => item !== undefined) as any[]; // Type assertion needed for strict mode - }, [args?.cardIds, args?.cardTitles, state?.items, availableIds]); - - const validCount = selectedCards.length; - const invalidIds = args?.cardIds?.filter((id) => !availableIds.has(id)) || []; - - // Don't try to parse while still running - wait for completion - let parsedResult: SelectCardsResult | null = null; - - if (status.type === "complete" && result != null) { - try { - parsedResult = parseSelectCardsResult(result); - } catch (e) { - // Ignore parse errors, let error boundary or error shell handle it if needed - } - } - - let content: ReactNode = null; - - if (status.type === "running") { - content = ( - - ); - } else if (status.type === "complete" && validCount > 0) { - content = ( -
-
-
-
- -
-
- - {validCount} Card{validCount === 1 ? "" : "s"} Selected - - Added to context -
-
-
-
-
- {selectedCards.map((card) => ( -
- -
- {card.name} - {card.subtitle && ( - {card.subtitle} - )} -
-
- ))} -
- {invalidIds.length > 0 && ( -
-

- {invalidIds.length} invalid ID{invalidIds.length === 1 ? "" : "s"} skipped: {invalidIds.slice(0, 3).join(", ")} - {invalidIds.length > 3 && ` +${invalidIds.length - 3} more`} -

-
- )} -
-
- ); - } else if (status.type === "complete" && validCount === 0) { - content = ( -
-
-
-
- -
-
- No Cards Found - No matching card IDs -
-
-
- {invalidIds.length > 0 && ( -
-

Invalid IDs:

-
- {invalidIds.map((id) => ( - - {id.substring(0, 8)}... - - ))} -
-
- )} -
- ); - } else if (status.type === "incomplete" && status.reason === "error") { - content = ( - - ); - } - - return ( - - {content} - - ); - }, -}); diff --git a/src/components/assistant-ui/UpdateFlashcardToolUI.tsx b/src/components/assistant-ui/UpdateFlashcardToolUI.tsx deleted file mode 100644 index 558300b9..00000000 --- a/src/components/assistant-ui/UpdateFlashcardToolUI.tsx +++ /dev/null @@ -1,184 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { useEffect, useMemo } from "react"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import { makeAssistantToolUI } from "@assistant-ui/react"; -import { useOptimisticToolUpdate } from "@/hooks/ai/use-optimistic-tool-update"; -import { X, Eye, Plus } 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 { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; -import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; -import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; -import type { FlashcardResult } from "@/lib/ai/tool-result-schemas"; -import { parseFlashcardResult } from "@/lib/ai/tool-result-schemas"; - -// Tool accepts z.any() (plain text format), so args can be string or object -type UpdateFlashcardArgs = string | { - description?: string; // Text format: "Deck: ...\nFront: ...\nBack: ..." - id?: string; // Legacy support - cardsToAdd?: Array<{ front: string; back: string }>; // Legacy support -}; - -function isUpdateFlashcardArgsObject( - args: UpdateFlashcardArgs -): args is Exclude { - return typeof args === "object" && args !== null; -} - -import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; - -interface UpdateFlashcardReceiptProps { - args: UpdateFlashcardArgs; - result: FlashcardResult; - status: any; -} - -const UpdateFlashcardReceipt = ({ args, result, status }: UpdateFlashcardReceiptProps) => { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); - const navigateToItem = useNavigateToItem(); - - // Debug logging for receipt component - useEffect(() => { - logger.group(`📋 [UpdateFlashcardReceipt] MOUNTED/UPDATED`, true); - logger.debug("Args:", JSON.stringify({ args }, null, 2)); - logger.debug("Result:", JSON.stringify(result, null, 2)); - logger.debug("Result itemId:", result?.itemId); - logger.debug("Status type:", status?.type); - logger.debug("Workspace ID:", workspaceId); - logger.groupEnd(); - }, [args, result, status, workspaceId]); - - const deckName = useMemo(() => { - // First try to use the deckName from result (from fuzzy match) - if (result.deckName) return result.deckName; - // Fallback to looking up by itemId - if (!result.itemId || !workspaceState?.items) return "Flashcard Deck"; - const item = workspaceState.items.find((item: any) => item.id === result.itemId); - return item?.name || "Flashcard Deck"; - }, [result.deckName, result.itemId, workspaceState?.items]); - - const argsObj = isUpdateFlashcardArgsObject(args) ? args : null; - const cardsAdded = result.cardsAdded ?? argsObj?.cardsToAdd?.length ?? 0; - - const handleViewCard = () => { - if (!result.itemId) return; - navigateToItem(result.itemId); - }; - - return ( -
-
-
- {status?.type === "complete" ? ( - - ) : ( - - )} -
-
- - {status?.type === "complete" ? "Flashcards Added" : "Update Cancelled"} - - {status?.type === "complete" && ( - - {cardsAdded} flashcard{cardsAdded !== 1 ? 's' : ''} added to deck - - )} -
-
-
- {status?.type === "complete" && result.itemId && ( - - )} -
-
- ); -}; - -export const UpdateFlashcardToolUI = makeAssistantToolUI({ - toolName: "updateFlashcards", - render: function UpdateFlashcardUI({ args, result, status }) { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - - useOptimisticToolUpdate(status, result, workspaceId); - - // Don't try to parse while still running - wait for completion - let parsed: FlashcardResult | null = null; - if (status.type === "complete" && result != null) { - try { - parsed = parseFlashcardResult(result); - } catch (err) { - // Log the error but don't throw - we'll show error state below - logger.error("🎨 [UpdateFlashcardTool] Failed to parse result:", err); - parsed = null; - } - } - - useEffect(() => { - logger.group(`🎨 [UpdateFlashcardTool] RENDER CALLED`, true); - logger.debug("Args:", args ? JSON.stringify({ args }, null, 2) : "null"); - logger.debug("Result:", result ? JSON.stringify(result, null, 2) : "null"); - logger.debug("Status:", status ? JSON.stringify(status, null, 2) : "null"); - logger.debug("Status type:", status?.type); - logger.debug("Workspace ID:", workspaceId); - logger.debug("Result itemId:", parsed?.itemId); - logger.groupEnd(); - }, [args, result, status, workspaceId, parsed?.itemId]); - - let content: ReactNode = null; - - if (parsed?.success) { - logger.debug("✅ [UpdateFlashcardTool] Rendering receipt with result"); - content = ; - } else if (status.type === "running") { - logger.debug("⏳ [UpdateFlashcardTool] Rendering loading state - status is running"); - content = ; - } else if (status.type === "complete" && parsed && !parsed.success) { - content = ( - - ); - } else if (status.type === "incomplete" && status.reason === "error") { - content = ( - - ); - } else { - logger.debug("❓ [UpdateFlashcardTool] Rendering null - no result and status is not running"); - } - - return ( - - {content} - - ); - }, -}); diff --git a/src/components/assistant-ui/UpdateQuizToolUI.tsx b/src/components/assistant-ui/UpdateQuizToolUI.tsx deleted file mode 100644 index abf54353..00000000 --- a/src/components/assistant-ui/UpdateQuizToolUI.tsx +++ /dev/null @@ -1,144 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { useMemo } from "react"; -import { makeAssistantToolUI } from "@assistant-ui/react"; -import { useOptimisticToolUpdate } from "@/hooks/ai/use-optimistic-tool-update"; -import { X, Plus, Eye } from "lucide-react"; -import { useWorkspaceStore } from "@/lib/stores/workspace-store"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; -import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; -import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; -import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; -import type { QuizResult } from "@/lib/ai/tool-result-schemas"; -import { parseQuizResult } from "@/lib/ai/tool-result-schemas"; - -type UpdateQuizArgs = { - quizId: string; - topic?: string; - contextContent?: string; - sourceCardIds?: string[]; - sourceCardNames?: string[]; -}; - -const UpdateQuizReceipt = ({ result, status }: { result: QuizResult; status: any }) => { - const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); - const navigateToItem = useNavigateToItem(); - - const targetId = result.itemId || result.quizId; - - // Get the quiz from workspace to show its title - const quiz = useMemo(() => { - if (!targetId || !workspaceState?.items) return null; - return workspaceState.items.find((item: any) => item.id === targetId); - }, [targetId, workspaceState?.items]); - - const handleViewCard = () => { - if (!targetId) return; - navigateToItem(targetId); - }; - - return ( -
-
-
- {status?.type === "complete" ? ( - - ) : ( - - )} -
-
- - {status?.type === "complete" ? (quiz?.name || "Quiz Expanded") : "Update Cancelled"} - - {status?.type === "complete" && ( - - Added {result.questionsAdded} question{result.questionsAdded !== 1 ? 's' : ''} ({result.totalQuestions} total) - - )} -
-
- -
- {status?.type === "complete" && targetId && ( - - )} -
-
- ); -}; - -export const UpdateQuizToolUI = makeAssistantToolUI({ - toolName: "updateQuiz", - render: function UpdateQuizUI({ args, result, status }) { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - - useOptimisticToolUpdate(status, result, workspaceId); - - // Don't try to parse while still running - wait for completion - let parsed: QuizResult | null = null; - if (status.type === "complete" && result != null) { - try { - // With the new Output.object() approach, the quiz worker returns clean structured data - // so we can parse normally without special streaming handling - parsed = parseQuizResult(result); - } catch (err) { - // Log the error but don't throw - we'll show error state below - console.error("🎯 [UpdateQuizTool] Failed to parse result:", err); - parsed = null; - } - } - - let content: ReactNode = null; - - if (parsed?.success) { - content = ; - } else if (status.type === "running") { - content = ; - } else if (status.type === "complete" && parsed && !parsed.success) { - content = ( - - ); - } else if (status.type === "incomplete" && status.reason === "error") { - content = ( - - ); - } - - return ( - - {content} - - ); - }, -}); diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 0799825b..2fc33e01 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -68,10 +68,9 @@ 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"; +import { EditItemToolUI } from "@/components/assistant-ui/EditItemToolUI"; import { YouTubeSearchToolUI } from "@/components/assistant-ui/YouTubeSearchToolUI"; import { AddYoutubeVideoToolUI } from "@/components/assistant-ui/AddYoutubeVideoToolUI"; @@ -79,7 +78,6 @@ import { ExecuteCodeToolUI } from "@/components/assistant-ui/ExecuteCodeToolUI"; import { FileProcessingToolUI } from "@/components/assistant-ui/FileProcessingToolUI"; import { URLContextToolUI } from "@/components/assistant-ui/URLContextToolUI"; // import { DeepResearchToolUI } from "@/components/assistant-ui/DeepResearchToolUI"; -import { UpdateNoteToolUI } from "@/components/assistant-ui/UpdateNoteToolUI"; import { WebSearchToolUI } from "@/components/assistant-ui/WebSearchToolUI"; import { SearchWorkspaceToolUI } from "@/components/assistant-ui/SearchWorkspaceToolUI"; import { ReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI"; @@ -92,7 +90,6 @@ import { ComposerAddAttachment, UserMessageAttachments } from "@/components/assistant-ui/attachment"; -import { SelectCardsToolUI } from "@/components/assistant-ui/SelectCardsToolUI"; import { AssistantLoader } from "@/components/assistant-ui/assistant-loader"; import { File as FileComponent } from "@/components/assistant-ui/file"; import { uploadFileDirect } from "@/lib/uploads/client-upload"; @@ -188,14 +185,11 @@ export const Thread: FC = ({ items = [] }) => { {/* Register tool UI - this component mounts and registers the UI with the assistant runtime */} - - - + - @@ -504,12 +498,10 @@ interface ComposerHoverWrapperProps { } const FLOATING_MENU_HIDE_DELAY_MS = 400; -const FLOATING_MENU_RETRIGGER_GRACE_MS = 2000; const ComposerHoverWrapper: FC = ({ items }) => { const [isHovered, setIsHovered] = useState(false); const hideTimeoutRef = useRef(null); - const retriggerGraceEndRef = useRef(0); const aui = useAui(); const [dialogAction, setDialogAction] = useState(null); const isThreadEmpty = useAuiState(({ thread }) => thread?.isEmpty ?? true); @@ -535,20 +527,9 @@ const ComposerHoverWrapper: FC = ({ items }) => { const handleMouseLeave = useCallback(() => { hideTimeoutRef.current = setTimeout(() => { setIsHovered(false); - retriggerGraceEndRef.current = Date.now() + FLOATING_MENU_RETRIGGER_GRACE_MS; }, FLOATING_MENU_HIDE_DELAY_MS); }, []); - const handleRetriggerZoneEnter = useCallback(() => { - if (Date.now() < retriggerGraceEndRef.current && !isThreadEmpty && !hasComposerText) { - if (hideTimeoutRef.current) { - clearTimeout(hideTimeoutRef.current); - hideTimeoutRef.current = null; - } - setIsHovered(true); - } - }, [isThreadEmpty, hasComposerText]); - useEffect(() => { return () => { if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); @@ -557,13 +538,6 @@ const ComposerHoverWrapper: FC = ({ items }) => { return (
- {/* Invisible retrigger zone - sibling above composer; re-shows menu if user enters within grace period after hide */} -
{/* Composer + floating menu - main hover zone */}
{ ); }; -const CONTINUE_RESPONSE_CHAR_THRESHOLD = 250; +const CONTINUE_RESPONSE_CHAR_THRESHOLD = 150; + +/** Sentence-ending punctuation that indicates an LLM likely finished naturally. */ +const ENDS_WITH_SENTENCE_PUNCTUATION = /[.!?]$/; + +/** Trailing citation tags to strip before punctuation check (e.g. Source). */ +const TRAILING_CITATION_TAGS = /(?:\s*[\s\S]*?<\/citation>\s*)+$/; const AssistantActionBar: FC = () => { const { createCard, isCreating } = useCreateCardFromMessage({ debounceMs: 300 }); const message = useMessage(); const api = useAssistantApi(); - const textLength = useMemo(() => { - return message.content - .filter((part): part is { type: "text"; text: string } => part.type === "text") - .reduce((sum, part) => sum + (part.text?.length ?? 0), 0); + const { textLength, textContent, endsWithPunctuation } = useMemo(() => { + const textParts = message.content.filter( + (part): part is { type: "text"; text: string } => part.type === "text" + ); + const length = textParts.reduce((sum, part) => sum + (part.text?.length ?? 0), 0); + const content = textParts.map((part) => part.text ?? "").join("\n\n"); + const trimmed = content.trim(); + const withoutTrailingCitations = trimmed.replace(TRAILING_CITATION_TAGS, "").trim(); + const endsWithPunctuation = + withoutTrailingCitations.length > 0 && + ENDS_WITH_SENTENCE_PUNCTUATION.test(withoutTrailingCitations); + return { textLength: length, textContent: content, endsWithPunctuation }; }, [message.content]); - const showContinueButton = textLength > 0 && textLength < CONTINUE_RESPONSE_CHAR_THRESHOLD; + const showContinueButton = + textLength > 0 && + (textLength < CONTINUE_RESPONSE_CHAR_THRESHOLD || !endsWithPunctuation); const handleContinueClick = useCallback(() => { api?.thread().append({ @@ -1288,13 +1278,6 @@ const AssistantActionBar: FC = () => { }); }, [api]); - const textContent = useMemo(() => { - return message.content - .filter((part): part is { type: "text"; text: string } => part.type === "text") - .map((part) => part.text ?? "") - .join("\n\n"); - }, [message.content]); - const [copied, setCopied] = useState(false); const copyTimeoutRef = useRef(null); diff --git a/src/lib/ai/tool-result-schemas.ts b/src/lib/ai/tool-result-schemas.ts index a64a31a9..baf34bfb 100644 --- a/src/lib/ai/tool-result-schemas.ts +++ b/src/lib/ai/tool-result-schemas.ts @@ -15,10 +15,21 @@ const baseWorkspace = z }) .passthrough(); -/** createNote, deleteCard, clearCardContent, updateCard */ +/** createNote, deleteCard, clearCardContent, editItem */ export const WorkspaceResultSchema = baseWorkspace; export type WorkspaceResult = z.infer; +/** editItem - extends WorkspaceResult with diff, filediff, cardCount, questionCount */ +export const EditItemResultSchema = baseWorkspace + .extend({ + diff: z.string().optional(), + filediff: z.object({ additions: z.number(), deletions: z.number() }).optional(), + cardCount: z.number().optional(), + questionCount: z.number().optional(), + }) + .passthrough(); +export type EditItemResult = z.infer; + /** Coerce string or other non-object tool results to a safe WorkspaceResult. */ function coerceToWorkspaceResult(input: unknown): WorkspaceResult { if (input == null) { @@ -53,15 +64,13 @@ export function parseSelectCardsResult(input: unknown): SelectCardsResult { return parseWithSchema(SelectCardsResultSchema, input, "SelectCardsResult"); } -/** createQuiz, updateQuiz */ +/** createQuiz */ export const QuizResultSchema = baseWorkspace.extend({ quizId: z.string().optional(), title: z.string().optional(), questionCount: z.number().optional(), questionsAdded: z.number().optional(), totalQuestions: z.number().optional(), - difficulty: z.enum(["easy", "medium", "hard"]).optional(), - isContextBased: z.boolean().optional(), }).passthrough(); export type QuizResult = z.infer; @@ -87,7 +96,7 @@ export function parseQuizResult(input: unknown): QuizResult { return coerceToQuizResult(input); } -/** createFlashcards, updateFlashcards */ +/** createFlashcards */ export const FlashcardResultSchema = baseWorkspace.extend({ title: z.string().optional(), cardCount: z.number().optional(), diff --git a/src/lib/ai/tools/edit-item-tool.ts b/src/lib/ai/tools/edit-item-tool.ts new file mode 100644 index 00000000..5cb4e3af --- /dev/null +++ b/src/lib/ai/tools/edit-item-tool.ts @@ -0,0 +1,171 @@ +import { tool, zodSchema } from "ai"; +import { z } from "zod"; +import { logger } from "@/lib/utils/logger"; +import { workspaceWorker } from "@/lib/ai/workers"; +import type { WorkspaceToolContext } from "./workspace-tools"; +import { loadStateForTool, resolveItem } from "./tool-utils"; +import { getVirtualPath } from "@/lib/utils/virtual-workspace-fs"; + +const EDITABLE_TYPES = ["note", "flashcard", "quiz"] as const; + +/** + * Create the editItem tool - unified edit for notes, flashcards, and quizzes. + * Uses oldString/newString search-replace on raw content from readWorkspace. + */ +export function createEditItemTool(ctx: WorkspaceToolContext) { + return tool({ + description: + "Edit a note, flashcard deck, or quiz. You must use readWorkspace at least once before editing. QUIZZES: readWorkspace may show '--- Progress (read-only) ---' at the top. That block is READ-ONLY and updates automatically. Never include it in oldString or newString. Only edit the JSON object ({\"questions\":[...]}). FULL REWRITE: use oldString='' and newString=entire new content (for quizzes: only the {\"questions\":[...]} JSON). TARGETED EDIT: oldString must match the actual content exactly. When copying from readWorkspace output, the line number prefix format is line number + colon + space (e.g. 1: ). Everything after that space is the actual content to match. Never include any part of the line number prefix in oldString or newString. Preserve exact indentation, whitespace, newlines. Do NOT minify JSON. The edit will FAIL if oldString is not found. The edit will FAIL if oldString matches multiple times — include more context or use replaceAll.", + inputSchema: zodSchema( + z + .object({ + itemName: z + .string() + .describe("Name of the item to edit (note, flashcard deck, or quiz; matched by fuzzy search)"), + oldString: z + .string() + .describe( + "Text to find. Use '' for full rewrite. For targeted edit: copy the content from readWorkspace but never include the line number prefix (e.g. 1: ). Match exact whitespace, indentation. Include enough context to make unique, or use replaceAll." + ), + newString: z.string().describe("Replacement text (entire content if oldString is empty)"), + replaceAll: z + .boolean() + .optional() + .default(false) + .describe("Replace every occurrence of oldString; use for renaming or changing repeated text."), + newName: z.string().optional().describe("Rename the item to this. If not provided, the existing name is preserved."), + sources: z + .array( + z.object({ + title: z.string().describe("Title of the source page"), + url: z.string().describe("URL of the source"), + favicon: z.string().optional().describe("Optional favicon URL"), + }) + ) + .optional() + .describe("Optional sources (notes only)"), + }) + .passthrough() + ), + execute: async (input: { + itemName: string; + oldString: string; + newString: string; + replaceAll?: boolean; + newName?: string; + sources?: Array<{ title: string; url: string; favicon?: string }>; + }) => { + const { itemName, oldString, newString, replaceAll, newName } = input; + + if (!itemName) { + return { + success: false, + message: "itemName is required to identify which item to edit.", + }; + } + + if (oldString === undefined || oldString === null) { + return { + success: false, + message: "oldString is required. Use '' for full rewrite.", + }; + } + + if (newString === undefined || newString === null) { + return { + success: false, + message: "newString is required.", + }; + } + + if (oldString === newString) { + return { + success: false, + message: "No changes to apply: oldString and newString are identical.", + }; + } + + if (!ctx.workspaceId) { + return { + success: false, + message: "No workspace context available", + }; + } + + try { + const accessResult = await loadStateForTool(ctx); + if (!accessResult.success) { + return accessResult; + } + + const { state } = accessResult; + const matchedItem = resolveItem(state.items, itemName); + + if (!matchedItem) { + const sample = state.items + .filter((i) => i.type !== "folder") + .slice(0, 5) + .map((i) => `"${i.name}" (${i.type})`) + .join(", "); + return { + success: false, + message: `Could not find item "${itemName}". ${sample ? `Example items: ${sample}` : "Workspace may be empty."}`, + }; + } + + const contentItems = state.items.filter((i) => i.type !== "folder"); + const sameNameCandidates = contentItems.filter( + (i) => i.name.toLowerCase().trim() === matchedItem.name.toLowerCase().trim() + ); + if (sameNameCandidates.length > 1) { + const paths = sameNameCandidates.map((c) => getVirtualPath(c, state.items)).join(", "); + return { + success: false, + message: `Multiple items named "${matchedItem.name}". Disambiguate using path: ${paths}`, + }; + } + + if (!EDITABLE_TYPES.includes(matchedItem.type as (typeof EDITABLE_TYPES)[number])) { + return { + success: false, + message: `Item "${matchedItem.name}" is not editable (type: ${matchedItem.type}). Only notes, flashcards, and quizzes can be edited.`, + }; + } + + logger.debug("🎯 [EDIT-ITEM] Found item via fuzzy match:", { + searchedName: itemName, + matchedName: matchedItem.name, + matchedId: matchedItem.id, + type: matchedItem.type, + }); + + const workerResult = await workspaceWorker("edit", { + workspaceId: ctx.workspaceId, + itemId: matchedItem.id, + itemType: matchedItem.type as "note" | "flashcard" | "quiz", + itemName: matchedItem.name, + oldString, + newString, + replaceAll, + newName, + sources: input.sources, + }); + + if (workerResult.success) { + return { + ...workerResult, + itemName: newName ?? matchedItem.name, + }; + } + + return workerResult; + } catch (error) { + logger.error("Error editing item:", error); + return { + success: false, + message: `Error editing item: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }, + }); +} diff --git a/src/lib/ai/tools/flashcard-tools.ts b/src/lib/ai/tools/flashcard-tools.ts index ed173556..28faeea8 100644 --- a/src/lib/ai/tools/flashcard-tools.ts +++ b/src/lib/ai/tools/flashcard-tools.ts @@ -3,8 +3,6 @@ import { tool, zodSchema } from "ai"; import { logger } from "@/lib/utils/logger"; import { workspaceWorker } from "@/lib/ai/workers"; import type { WorkspaceToolContext } from "./workspace-tools"; -import { loadStateForTool, resolveItem, getAvailableItemsList } from "./tool-utils"; - /** * Create the createFlashcards tool */ @@ -68,97 +66,4 @@ export function createFlashcardsTool(ctx: WorkspaceToolContext) { }); } -/** - * Create the updateFlashcards tool - */ -export function createUpdateFlashcardsTool(ctx: WorkspaceToolContext) { - return tool({ - description: "Add more flashcards to an existing flashcard deck and/or update its title.", - inputSchema: zodSchema( - z.object({ - deckName: z.string().describe("The name or ID of the flashcard deck to update"), - cards: z.array( - z.object({ - front: z.string().describe("The question or term on the front of the card"), - back: z.string().describe("The answer or definition on the back of the card"), - }) - ).optional().describe("Array of flashcard objects to add, each with 'front' and 'back' properties"), - title: z.string().optional().describe("New title for the flashcard deck. If not provided, the existing title will be preserved."), - }) - ), - execute: async (input: { deckName: string; cards?: Array<{ front: string; back: string }>; title?: string }) => { - const deckName = input.deckName; - const cardsToAdd = input.cards || []; - const newTitle = input.title; - - if (!deckName) { - return { - success: false, - message: "Deck name is required to identify which deck to update.", - }; - } - - if (cardsToAdd.length === 0 && !newTitle) { - return { - success: false, - message: "At least one flashcard must be provided OR a new title must be specified.", - }; - } - - if (!ctx.workspaceId) { - return { - success: false, - message: "No workspace context available", - }; - } - - try { - // Load workspace state (security is enforced by workspace-worker) - const accessResult = await loadStateForTool(ctx); - if (!accessResult.success) { - return accessResult; - } - - const { state } = accessResult; - - // Resolve by virtual path or fuzzy name match - const matchedDeck = resolveItem(state.items, deckName, "flashcard"); - - if (!matchedDeck) { - const availableDecks = getAvailableItemsList(state.items, "flashcard"); - return { - success: false, - message: `Could not find flashcard deck "${deckName}". ${availableDecks ? `Available decks: ${availableDecks}` : 'No flashcard decks found in workspace.'}`, - }; - } - - // Optimized: Update both title and cards in a single worker call - const workerResult = await workspaceWorker("updateFlashcard", { - workspaceId: ctx.workspaceId, - itemId: matchedDeck.id, - itemType: "flashcard", - title: newTitle, // Pass title to rename - flashcardData: { cardsToAdd }, - }); - - if (workerResult.success) { - return { - ...workerResult, - deckName: matchedDeck.name, - cardsAdded: cardsToAdd.length, - titleUpdated: !!newTitle, - newTitle: newTitle || undefined, - }; - } - - return workerResult; - } catch (error) { - logger.error("Error updating flashcards:", error); - return { - success: false, - message: `Error updating flashcards: ${error instanceof Error ? error.message : String(error)}`, - }; - } - }, - }); -} +// Edit functionality is in edit-item-tool.ts (editItem) diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts index 92ebdd24..7231e4d8 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -9,13 +9,12 @@ import { createProcessUrlsTool } from "./process-urls"; import { createExecuteCodeTool } from "./search-code"; import { createNoteTool, - createUpdateNoteTool, createDeleteItemTool, - createSelectCardsTool, type WorkspaceToolContext, } from "./workspace-tools"; -import { createFlashcardsTool, createUpdateFlashcardsTool } from "./flashcard-tools"; -import { createQuizTool, createUpdateQuizTool } from "./quiz-tools"; +import { createEditItemTool } from "./edit-item-tool"; +import { createFlashcardsTool } from "./flashcard-tools"; +import { createQuizTool } from "./quiz-tools"; import { createDeepResearchTool } from "./deep-research"; import { createSearchYoutubeTool, createAddYoutubeVideoTool } from "./youtube-tools"; // import { createSearchImagesTool, createAddImageTool } from "./image-tools"; @@ -65,18 +64,15 @@ export function createChatTools(config: ChatToolsConfig): Record { // Workspace operations createNote: createNoteTool(ctx), - updateNote: createUpdateNoteTool(ctx), + editItem: createEditItemTool(ctx), deleteItem: createDeleteItemTool(ctx), - selectCards: createSelectCardsTool(ctx), // Flashcards createFlashcards: createFlashcardsTool(ctx), - updateFlashcards: createUpdateFlashcardsTool(ctx), // Quizzes createQuiz: createQuizTool(ctx), - updateQuiz: createUpdateQuizTool(ctx), // Deep research - commented out // ...(config.enableDeepResearch ? { deepResearch: createDeepResearchTool(ctx) } : {}), diff --git a/src/lib/ai/tools/quiz-tools.ts b/src/lib/ai/tools/quiz-tools.ts index 028c5144..f779ce13 100644 --- a/src/lib/ai/tools/quiz-tools.ts +++ b/src/lib/ai/tools/quiz-tools.ts @@ -1,83 +1,84 @@ /** * Quiz Tools - * Tools for creating and updating quizzes in workspaces + * Tools for creating and updating quizzes in workspaces. + * Like flashcards, the AI generates quiz content directly in the tool call. */ import { z } from "zod"; import { tool, zodSchema } from "ai"; import { logger } from "@/lib/utils/logger"; -import { quizWorker, workspaceWorker } from "@/lib/ai/workers"; +import { workspaceWorker } from "@/lib/ai/workers"; import type { WorkspaceToolContext } from "./workspace-tools"; -import type { QuizData } from "@/lib/workspace-state/types"; -import { loadStateForTool, resolveItem, getAvailableItemsList } from "./tool-utils"; +import type { QuizQuestion } from "@/lib/workspace-state/types"; +import { generateItemId } from "@/lib/workspace-state/item-helpers"; + +export const QuizQuestionSchema = z + .object({ + type: z.enum(["multiple_choice", "true_false"]), + questionText: z.string(), + options: z.array(z.string()).describe("4 options for multiple_choice, ['True','False'] for true_false"), + correctIndex: z.number().int().min(0).describe("0-based index of correct answer in options array"), + hint: z.string().optional(), + explanation: z.string(), + }) + .refine( + (q) => { + const requiredCount = q.type === "true_false" ? 2 : 4; + return q.options.length === requiredCount && q.correctIndex < q.options.length; + }, + { message: "multiple_choice needs 4 options; true_false needs 2; correctIndex must be < options.length" } + ); + +const CreateQuizInputSchema = z.object({ + title: z.string().nullish().describe("Short descriptive title for the quiz (defaults to 'Quiz' if not provided)"), + questions: z.array(QuizQuestionSchema).min(1).max(50).describe("Array of quiz questions. For multiple_choice: 4 options, 1 correct. For true_false: options ['True','False'], correctIndex 0=True 1=False."), +}); +export type CreateQuizInput = z.infer; /** - * Create the createQuiz tool + * Create the createQuiz tool - AI generates questions directly (like createFlashcards) */ export function createQuizTool(ctx: WorkspaceToolContext) { return tool({ - description: "Create an interactive quiz. Extract topic from user message. Use selected cards as context if available.", - inputSchema: zodSchema( - z.object({ - topic: z.string().optional().describe("The topic or specific focus instructions for the quiz - extract from user's message"), - contextContent: z.string().optional().describe("Content from selected cards in system context if available"), - questionCount: z.number().int().min(1).max(50).optional().describe("The specific number of questions requested by the user, if provided"), - sourceCardIds: z.array(z.string()).optional().describe("IDs of source cards"), - sourceCardNames: z.array(z.string()).optional().describe("Names of source cards"), - }) - ), - execute: async (input: { topic?: string; contextContent?: string; questionCount?: number; sourceCardIds?: string[]; sourceCardNames?: string[] }) => { - const { topic, contextContent, questionCount, sourceCardIds, sourceCardNames } = input; - logger.debug("🎯 [CREATE-QUIZ] Tool execution started:", { topic, questionCount, hasContext: !!contextContent }); + description: "Create an interactive quiz.", + inputSchema: zodSchema(CreateQuizInputSchema), + execute: async (input: CreateQuizInput) => { + const title = input.title || "Quiz"; + const rawQuestions = input.questions || []; - if (!ctx.workspaceId) { + if (rawQuestions.length === 0) { return { success: false, - message: "No workspace context available", + message: "At least one quiz question is required.", }; } - if (!topic && !contextContent) { + if (!ctx.workspaceId) { return { success: false, - message: "Either 'topic' or 'contextContent' must be provided", + message: "No workspace context available", }; } try { + const questions: QuizQuestion[] = rawQuestions.map((q) => ({ + id: generateItemId(), + type: q.type, + questionText: q.questionText, + options: q.options, + correctIndex: q.correctIndex, + hint: q.hint, + explanation: q.explanation || "No explanation provided.", + })); + + logger.debug("🎯 [CREATE-QUIZ] Creating quiz:", { title, questionCount: questions.length }); - logger.debug("🎯 [CREATE-QUIZ] Context provided:", { - hasContext: !!contextContent, - contextLength: contextContent?.length, - sourceCardNames, - sourceCardIds, - questionCount, - providedTopic: topic, - }); - - // Generate quiz using quizWorker - const quizResult = await quizWorker({ - topic: topic || undefined, - contextContent, - questionCount: questionCount ?? 5, - 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, + title, itemType: "quiz", quizData: { - questions: quizResult.questions, - sourceCardIds, - sourceCardNames, + questions, }, folderId: ctx.activeFolderId, }); @@ -90,10 +91,9 @@ export function createQuizTool(ctx: WorkspaceToolContext) { success: true, itemId: workerResult.itemId, quizId: workerResult.itemId, - title: quizResult.title, - questionCount: quizResult.questions.length, - isContextBased: !!contextContent, - message: `Created quiz "${quizResult.title}" with ${quizResult.questions.length} questions.`, + title, + questionCount: questions.length, + message: `Created quiz "${title}" with ${questions.length} questions.`, event: workerResult.event, version: workerResult.version, }; @@ -108,190 +108,4 @@ export function createQuizTool(ctx: WorkspaceToolContext) { }); } -/** - * Create the updateQuiz tool - */ -export function createUpdateQuizTool(ctx: WorkspaceToolContext) { - return tool({ - description: "Update quiz title and/or add more questions. Can use new topic, selected cards, or general knowledge.", - inputSchema: zodSchema( - z.object({ - quizName: z.string().describe("The name of the quiz to update (will be matched using fuzzy search)"), - title: z.string().optional().describe("New title for the quiz. If not provided, the existing title will be preserved."), - topic: z.string().optional().describe("New topic for questions"), - contextContent: z.string().optional().describe("Content from newly selected cards in system context"), - questionCount: z.number().int().min(1).max(50).optional().describe("The specific number of questions to add, if provided"), - sourceCardIds: z.array(z.string()).optional().describe("IDs of source cards"), - sourceCardNames: z.array(z.string()).optional().describe("Names of source cards"), - }) - ), - execute: async (input: { quizName: string; title?: string; topic?: string; contextContent?: string; questionCount?: number; sourceCardIds?: string[]; sourceCardNames?: string[] }) => { - const { quizName, title, topic: explicitTopic, contextContent, questionCount, sourceCardIds, sourceCardNames } = input; - - logger.debug("🎯 [UPDATE-QUIZ] Tool execution started:", { quizName, explicitTopic, questionCount, title }); - - if (!ctx.workspaceId) { - return { - success: false, - message: "No workspace context available", - }; - } - - // Validate required quizName - if (!quizName) { - return { - success: false, - message: "Quiz name is required to identify which quiz to update.", - }; - } - - try { - // Load workspace state and fuzzy match the quiz by name - const accessResult = await loadStateForTool(ctx); - if (!accessResult.success) { - return accessResult; - } - - const { state } = accessResult; - - // Resolve by virtual path or fuzzy name match - const quizItem = resolveItem(state.items, quizName, "quiz"); - - if (!quizItem) { - const availableQuizzes = getAvailableItemsList(state.items, "quiz"); - logger.warn("❌ [UPDATE-QUIZ] Quiz not found:", { - searchedName: quizName, - availableQuizzes, - }); - return { - success: false, - message: `Could not find quiz "${quizName}". ${availableQuizzes ? `Available quizzes: ${availableQuizzes}` : 'No quizzes found in workspace.'}`, - }; - } - - const quizId = quizItem.id; - - logger.debug("🎯 [UPDATE-QUIZ] Found quiz via fuzzy match:", { - searchedName: quizName, - matchedName: quizItem.name, - matchedId: quizId, - }); - - // Title update is now handled in the main workspaceWorker("updateQuiz") call below - if (title) { - logger.debug("🎯 [UPDATE-QUIZ] Will update title to:", title); - } - - 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, - }; - } - - // Use context/topic provided by LLM, or default to quiz name for continuation - const topic = explicitTopic || quizItem.name; - const isBlankQuiz = existingQuestions.length === 0; - - logger.debug("🎯 [UPDATE-QUIZ] Configuration:", { - topic, - hasContext: !!contextContent, - questionCount, - sourceCards: sourceCardNames, - isBlankQuiz, - }); - - // For quizzes WITH existing questions: require explicit topic/context to add more - // For BLANK quizzes: always generate questions using the topic (even if just quiz name) - if (!isBlankQuiz && !explicitTopic && !contextContent && !sourceCardIds && questionCount === undefined) { - return { - success: true, - quizId, - message: title ? `Updated quiz title to "${title}".` : "Quiz updated successfully.", - }; - } - - // For blank quizzes with generic name and no topic, provide helpful error - if (isBlankQuiz && !explicitTopic && !contextContent && !sourceCardIds && quizItem.name === "Quiz") { - return { - success: false, - message: "Please specify a topic for the quiz (e.g., 'make this quiz about linear algebra').", - }; - } - - // Generate new questions - const quizResult = await quizWorker({ - topic, - contextContent, - questionCount: questionCount ?? 5, - existingQuestions, - performanceTelemetry, - sourceCardIds, - sourceCardNames - }); - - // Update the quiz with new questions and optional title rename - const workerResult = await workspaceWorker("updateQuiz", { - workspaceId: ctx.workspaceId, - itemId: quizId, - itemType: "quiz", - title: title, // Pass title to rename - questionsToAdd: quizResult.questions, - }); - - if (!workerResult.success) { - return workerResult; - } - - const totalQuestions = existingQuestions.length + quizResult.questions.length; - const message = isBlankQuiz - ? `Populated quiz with ${quizResult.questions.length} questions about "${topic}".` - : `Added ${quizResult.questions.length} new questions to the quiz.`; - - return { - ...workerResult, - quizId, - questionsAdded: quizResult.questions.length, - totalQuestions, - message, - }; - } catch (error) { - logger.error("❌ [UPDATE-QUIZ] Error:", error); - return { - success: false, - message: `Error updating quiz: ${error instanceof Error ? error.message : String(error)}`, - }; - } - }, - }); -} +// Edit functionality is in edit-item-tool.ts (editItem) diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts index 60f460b1..0e13c33a 100644 --- a/src/lib/ai/tools/read-workspace.ts +++ b/src/lib/ai/tools/read-workspace.ts @@ -14,7 +14,7 @@ const MAX_LINE_LENGTH = 2000; export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { return tool({ description: - "Read content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Usage: By default returns up to 500 lines from the start. The lineStart parameter is the 1-indexed line number to start from — call again with a larger lineStart to read later sections. For PDFs: use pageStart and pageEnd (1-indexed) to read only specific pages — e.g. pageStart=5, pageEnd=10 reads pages 5–10. Use searchWorkspace to find specific content in large items. Contents are returned with each line prefixed by its line number as : . Any line longer than 2000 characters is truncated. Avoid tiny repeated slices (e.g. 30-line chunks); read a larger window.", + "Read content of a workspace item (note, flashcard deck, PDF summary, quiz) by path or name. Contents are returned with each line prefixed by its line number as : . For example, if content is 'foo', you receive '1: foo'. Quizzes include progress at the top when started (current question, score). By default returns up to 500 lines. Use lineStart (1-indexed) to read later sections. For PDFs: pageStart and pageEnd for page ranges. Use searchWorkspace to find content in large items. Any line longer than 2000 characters is truncated. Avoid tiny repeated slices; read a larger window.", inputSchema: zodSchema( z.object({ path: z diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 484f557b..e3b67db7 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -6,6 +6,8 @@ import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import type { Item } from "@/lib/workspace-state/types"; import { loadStateForTool, resolveItem, getAvailableItemsList } from "./tool-utils"; +// Note: Edit functionality is in edit-item-tool.ts (editItem) + export interface WorkspaceToolContext { workspaceId: string | null; userId: string | null; @@ -67,141 +69,6 @@ export function createNoteTool(ctx: WorkspaceToolContext) { }); } -/** - * Create the updateNote tool - * Cline convention: oldString='' = full rewrite, oldString!='' = targeted edit - */ -export function createUpdateNoteTool(ctx: WorkspaceToolContext) { - return tool({ - description: - "Update a note. Full rewrite: oldString='', newString=entire note. Targeted edit: MUST call readWorkspace first to get current content, then oldString=exact text from the Content section (never include wrapper), newString=replacement. Preserve exact whitespace/indentation. Fails if oldString not found or matches multiple times — include more context or use replaceAll. If the note may have been modified since you last read it, call readWorkspace again before retrying.", - inputSchema: zodSchema( - z - .object({ - noteName: z.string().describe("The name of the note to update (will be matched using fuzzy search)"), - oldString: z - .string() - .describe( - "Text to find. Use '' for full rewrite. For targeted edit: exact text from readWorkspace Content section — match exactly including whitespace. Include enough context to make it unique, or use replaceAll to change every instance." - ), - newString: z - .string() - .describe("Replacement text (entire note if oldString is empty)"), - replaceAll: z.boolean().optional().default(false).describe("Replace every occurrence of oldString; use for renaming or changing repeated text."), - title: z.string().optional().describe("New title for the note. If not provided, the existing title will be preserved."), - sources: z - .array( - z.object({ - title: z.string().describe("Title of the source page"), - url: z.string().describe("URL of the source"), - favicon: z.string().optional().describe("Optional favicon URL"), - }) - ) - .optional() - .describe("Optional sources from web search or user-provided URLs"), - }) - .passthrough() - ), - execute: async (input: { - noteName: string; - oldString: string; - newString: string; - replaceAll?: boolean; - title?: string; - sources?: Array<{ title: string; url: string; favicon?: string }>; - }) => { - const { noteName, oldString, newString, replaceAll, title } = input; - - if (!noteName) { - return { - success: false, - message: "Note name is required to identify which note to update.", - }; - } - - if (oldString === undefined || oldString === null) { - return { - success: false, - message: "oldString is required. Use '' for full rewrite.", - }; - } - - if (newString === undefined || newString === null) { - return { - success: false, - message: "newString is required.", - }; - } - - if (oldString === newString) { - return { - success: false, - message: "No changes to apply: oldString and newString are identical.", - }; - } - - if (!ctx.workspaceId) { - return { - success: false, - message: "No workspace context available", - }; - } - - try { - const accessResult = await loadStateForTool(ctx); - if (!accessResult.success) { - return accessResult; - } - - const { state } = accessResult; - const matchedNote = resolveItem(state.items, noteName, "note"); - - if (!matchedNote) { - const availableNotes = getAvailableItemsList(state.items, "note"); - return { - success: false, - message: `Could not find note "${noteName}". ${availableNotes ? `Available notes: ${availableNotes}` : "No notes found in workspace."}`, - }; - } - - logger.debug("🎯 [UPDATE-NOTE] Found note via fuzzy match:", { - searchedName: noteName, - matchedName: matchedNote.name, - matchedId: matchedNote.id, - }); - - const workerResult = await workspaceWorker("update", { - workspaceId: ctx.workspaceId, - itemId: matchedNote.id, - itemName: matchedNote.name, - oldString, - newString, - replaceAll, - title, - sources: input.sources, - }); - - if (workerResult.success) { - return { - ...workerResult, - noteName: matchedNote.name, - }; - } - - return workerResult; - } catch (error) { - logger.error("Error updating note:", error); - return { - success: false, - message: `Error updating note: ${error instanceof Error ? error.message : String(error)}`, - }; - } - }, - }); -} - - - /** * Create the deleteItem tool */ @@ -272,83 +139,3 @@ export function createDeleteItemTool(ctx: WorkspaceToolContext) { }, }); } - -/** - * Create the selectCards tool - */ -export function createSelectCardsTool(ctx: WorkspaceToolContext) { - return tool({ - description: "Select cards by their titles and add them to conversation context.", - inputSchema: zodSchema( - z.object({ - cardTitles: z.array(z.string()).describe("Array of card titles to search for and select"), - }) - ), - execute: async ({ cardTitles }) => { - - if (!cardTitles || cardTitles.length === 0) { - return { - success: false, - message: "cardTitles array must be provided and non-empty.", - context: "", - }; - } - - if (!ctx.workspaceId) { - return { - success: false, - message: "No workspace context available", - context: "", - }; - } - - try { - // Load workspace state to access all items - const state = await loadWorkspaceState(ctx.workspaceId); - - if (!state || !state.items || state.items.length === 0) { - return { - success: true, - message: `No cards found in workspace. Requested selection of ${cardTitles.length} card${cardTitles.length === 1 ? "" : "s"}: ${cardTitles.join(", ")}.`, - addedCount: 0, - context: "", - }; - } - - // Resolve by virtual path or fuzzy name match - const selectedItems: Item[] = []; - const processedIds = new Set(); - - for (const title of cardTitles) { - const match = resolveItem(state.items, title); - if (match && !processedIds.has(match.id)) { - selectedItems.push(match); - processedIds.add(match.id); - } - } - - const addedCount = selectedItems.length; - const notFoundCount = cardTitles.length - addedCount; - - let message = `Selected ${addedCount} card${addedCount === 1 ? "" : "s"}`; - if (notFoundCount > 0) { - message += ` (${notFoundCount} not found)`; - } - message += `: ${selectedItems.map(item => item.name).join(", ")}. Use searchWorkspace or readWorkspace to fetch content when needed.`; - - return { - success: true, - message, - addedCount, - cardTitles: cardTitles, - }; - } catch (error: any) { - logger.error("❌ [SELECT-CARDS] Error loading workspace state:", error); - return { - success: false, - message: `Failed to load workspace state: ${error?.message || String(error)}`, - }; - } - }, - }); -} diff --git a/src/lib/ai/workers/index.ts b/src/lib/ai/workers/index.ts index abde6fe4..e63b7b95 100644 --- a/src/lib/ai/workers/index.ts +++ b/src/lib/ai/workers/index.ts @@ -3,4 +3,3 @@ export * from "./common"; 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 deleted file mode 100644 index 4e75e8b0..00000000 --- a/src/lib/ai/workers/quiz-worker.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { google } from "@ai-sdk/google"; -import { generateText, Output } from "ai"; -import { z } from "zod"; -import { logger } from "@/lib/utils/logger"; -import { QuizQuestion, QuestionType } from "@/lib/workspace-state/types"; -import { generateItemId } from "@/lib/workspace-state/item-helpers"; - -const DEFAULT_CHAT_MODEL_ID = "gemini-2.5-flash"; - -export type QuizWorkerParams = { - topic?: string; // Optional topic or focus instructions, including for context-based quizzes - contextContent?: string; // Aggregated content from selected cards - sourceCardIds?: string[]; - sourceCardNames?: string[]; - difficulty?: "easy" | "medium" | "hard"; - questionCount?: number; // Defaults to 5 - 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 ?? 5; - 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 || "medium", params.performanceTelemetry); - - if (params.contextContent) { - // Context-based quiz generation - const topicInstruction = params.topic - ? `\nUSER'S SPECIFIC INSTRUCTIONS / TOPIC FOCUS:\n"${params.topic}"\n\nEnsure the generated questions specifically address these instructions within the provided content.` - : ""; - - prompt = `You are a quiz generator. Create exactly ${questionCount} quiz questions based EXCLUSIVELY on the following content. Do NOT use any external knowledge. - -IMPORTANT: The content below is from workspace cards and includes metadata headers like "CARD 1:", "Card ID:", "METADATA:", "CONTENT:", etc. IGNORE ALL METADATA. Focus ONLY on the actual educational content within each card - the text, concepts, facts, and information being taught. Do NOT create questions about: -- 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} -${topicInstruction} - -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. - -Example (multiple_choice): -{ - "id": "q_001", - "type": "multiple_choice", - "questionText": "What is the capital of France?", - "options": ["London", "Paris", "Berlin", "Madrid"], - "correctIndex": 1, - "hint": "Think of the Eiffel Tower.", - "explanation": "Paris is the capital and largest city of France." -} - -Example (true_false): -{ - "id": "q_002", - "type": "true_false", - "questionText": "Water boils at 100°C at standard atmospheric pressure.", - "options": ["True", "False"], - "correctIndex": 0, - "hint": "Consider the Celsius scale.", - "explanation": "At 1 atm, water boils at 100°C (212°F)." -}`; - - // Use structured output generation for better reliability - const { output: quizData } = await generateText({ - // Match the default chat model (see `src/app/api/chat/route.ts`) - model: google(DEFAULT_CHAT_MODEL_ID), - output: Output.object({ - name: "Quiz", - description: "A generated quiz with title and questions", - schema: z.object({ - title: z.string().describe("A short, descriptive title for the quiz"), - questions: z.array( - z.object({ - id: z.string().describe("Unique identifier for the question"), - type: z.enum(["multiple_choice", "true_false"]).describe("Type of question"), - questionText: z.string().describe("The question text"), - options: z.array(z.string()).describe("Answer options - 4 for multiple choice, 2 for true/false"), - correctIndex: z.number().describe("Index of the correct answer in options array"), - hint: z.string().optional().describe("Optional hint for the question"), - explanation: z.string().describe("Explanation of why the answer is correct"), - sourceContext: z.string().optional().describe("Source context if applicable"), - }) - ).describe("Array of quiz questions"), - }), - }), - prompt, - }); - - // Transform questions - ALWAYS generate new unique IDs - // to prevent collisions when adding questions to existing quizzes - const questions: QuizQuestion[] = quizData.questions.map((q) => ({ - id: generateItemId(), // Always use unique ID - type: q.type as QuestionType, - questionText: q.questionText, - options: q.options || [], - correctIndex: q.correctIndex ?? 0, - hint: q.hint, - explanation: q.explanation || "No explanation provided.", - })); - - logger.debug("🎯 [QUIZ-WORKER] Generated quiz:", { - title: quizData.title, - questionCount: questions.length, - }); - - return { - title: quizData.title || params.topic || "Generated Quiz", - questions, - }; - } catch (error) { - logger.error("🎯 [QUIZ-WORKER] Error:", error); - throw error; - } -} diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index aa23589c..878323b2 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -8,7 +8,7 @@ import { createEvent } from "@/lib/workspace/events"; import { generateItemId } from "@/lib/workspace-state/item-helpers"; import { getRandomCardColor } from "@/lib/workspace-state/colors"; import { logger } from "@/lib/utils/logger"; -import type { Item, NoteData, PdfData, QuizData, QuizQuestion } from "@/lib/workspace-state/types"; +import type { Item, NoteData, PdfData, QuizData, QuizQuestion, FlashcardData, FlashcardItem } from "@/lib/workspace-state/types"; import { markdownToBlocks, fixLLMDoubleEscaping } from "@/lib/editor/markdown-to-blocks"; import { executeWorkspaceOperation } from "./common"; import { loadWorkspaceState } from "@/lib/workspace/state-loader"; @@ -16,6 +16,8 @@ import { hasDuplicateName } from "@/lib/workspace/unique-name"; import type { WorkspaceEvent } from "@/lib/workspace/events"; import { replace as applyReplace, trimDiff, normalizeLineEndings } from "@/lib/utils/edit-replace"; import { getNoteContentAsMarkdown } from "@/lib/utils/format-workspace-context"; +import { serializeBlockNote } from "@/lib/utils/serialize-blocknote"; +import type { Block } from "@/components/editor/BlockNoteEditor"; /** Create params for a single item (used by create and bulkCreate). Exported for autogen. */ export type CreateItemParams = { @@ -183,7 +185,7 @@ function parseAppendResult(rawResult: string | any): { version: number; conflict * Operations are serialized per workspace to prevent version conflicts */ export async function workspaceWorker( - action: "create" | "bulkCreate" | "update" | "delete" | "updateFlashcard" | "updateQuiz" | "updatePdfContent", + action: "create" | "bulkCreate" | "update" | "delete" | "edit" | "updateFlashcard" | "updateQuiz" | "updatePdfContent", params: { workspaceId: string; /** For bulkCreate: array of create params (no workspaceId). Items are built and appended as one BULK_ITEMS_CREATED event. */ @@ -197,6 +199,8 @@ export async function workspaceWorker( itemId?: string; /** Display name for diff header (e.g. note title) */ itemName?: string; + /** Rename the item (edit action) */ + newName?: string; itemType?: "note" | "flashcard" | "quiz" | "youtube" | "image" | "audio" | "pdf"; // Defaults to "note" if undefined pdfData?: { @@ -766,19 +770,20 @@ export async function workspaceWorker( 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; + const hasQuestions = questionsToAdd && questionsToAdd.length > 0; + const hasTitle = !!params.title; - if (!questionsToAdd || questionsToAdd.length === 0) { - throw new Error("Questions to add required for updateQuiz"); + if (!hasQuestions && !hasTitle) { + throw new Error("Either questions to add or a new title is required for updateQuiz"); } - logger.debug("🎯 [WORKSPACE-WORKER] Updating quiz with new questions:", { + logger.debug("🎯 [WORKSPACE-WORKER] Updating quiz:", { itemId: params.itemId, - questionsToAdd: questionsToAdd.length, + questionsToAdd: questionsToAdd?.length ?? 0, + titleUpdate: hasTitle, }); - // 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); @@ -793,15 +798,12 @@ export async function workspaceWorker( const existingData = existingItem.data as QuizData; const existingQuestions = existingData?.questions || []; - // Merge existing questions with new questions - const updatedData: QuizData = { - ...existingData, - questions: [...existingQuestions, ...questionsToAdd], - }; + const updatedData: QuizData = hasQuestions + ? { ...existingData, questions: [...existingQuestions, ...questionsToAdd!] } + : existingData; - const changes: any = { data: updatedData }; + const changes: any = hasQuestions ? { data: updatedData } : {}; - // Handle title update if provided if (params.title) { logger.debug("🎯 [UPDATE-QUIZ] Updating title:", params.title); if (hasDuplicateName(currentState.items, params.title, existingItem.type, existingItem.folderId ?? null, params.itemId)) { @@ -867,16 +869,18 @@ export async function workspaceWorker( logger.info("🎯 [WORKSPACE-WORKER] Updated quiz:", { itemId: params.itemId, - questionsAdded: questionsToAdd.length, + questionsAdded: questionsToAdd?.length ?? 0, totalQuestions: updatedData.questions.length, }); return { success: true, itemId: params.itemId, - questionsAdded: questionsToAdd.length, + questionsAdded: questionsToAdd?.length ?? 0, totalQuestions: updatedData.questions.length, - message: `Added ${questionsToAdd.length} question${questionsToAdd.length !== 1 ? 's' : ''} to quiz`, + message: hasQuestions + ? `Added ${questionsToAdd!.length} question${questionsToAdd!.length !== 1 ? "s" : ""} to quiz` + : "Quiz title updated.", event, version: appendResult.version, }; @@ -968,6 +972,246 @@ export async function workspaceWorker( }; } + if (action === "edit") { + if (!params.itemId || !params.itemType) { + throw new Error("Item ID and itemType required for edit"); + } + if (params.oldString === undefined || params.newString === undefined) { + throw new Error("oldString and newString required for edit"); + } + + const currentState = await loadWorkspaceState(params.workspaceId); + const existingItem = currentState.items.find((i: Item) => i.id === params.itemId); + if (!existingItem) { + throw new Error(`Item not found with ID: ${params.itemId}`); + } + + const rename = params.newName; + const replaceAll = !!params.replaceAll; + const oldStr = String(params.oldString); + const newStr = String(params.newString); + + if (existingItem.type === "note") { + const changes: Partial = {}; + if (rename) { + if (hasDuplicateName(currentState.items, rename, "note", existingItem.folderId ?? null, params.itemId)) { + return { success: false, message: `A note named "${rename}" already exists in this folder` }; + } + changes.name = rename; + } + + let contentOld = ""; + let contentNew = ""; + if (oldStr === "") { + contentOld = ""; + contentNew = newStr; + } else { + contentOld = getNoteContentAsMarkdown(existingItem.data as NoteData); + contentNew = applyReplace(contentOld, oldStr, newStr, replaceAll); + } + contentNew = fixLLMDoubleEscaping(contentNew); + const blockContent = await markdownToBlocks(contentNew); + changes.data = { field1: contentNew, blockContent } as NoteData; + if (params.sources !== undefined) { + (changes.data as NoteData).sources = params.sources; + } + + if (Object.keys(changes).length === 0) { + return { success: true, itemId: params.itemId, message: "No changes to update" }; + } + + const itemName = (changes.name ?? existingItem.name) as string; + const event = createEvent("ITEM_UPDATED", { id: params.itemId, changes, source: "agent", name: itemName }, 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?.length) throw new Error("Failed to update note"); + const appendResult = parseAppendResult(eventResult[0].result); + if (appendResult.conflict) throw new Error("Workspace was modified by another user, please try again"); + + const diffOutput = trimDiff( + createPatch(params.itemName ?? "note", normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)) + ); + let filediffAdditions = 0; + let filediffDeletions = 0; + for (const ch of diffLines(contentOld, contentNew)) { + if (ch.added) filediffAdditions += ch.count || 0; + if (ch.removed) filediffDeletions += ch.count || 0; + } + + return { + success: true, + itemId: params.itemId, + message: "Updated note successfully", + event, + version: appendResult.version, + diff: diffOutput, + filediff: { additions: filediffAdditions, deletions: filediffDeletions }, + }; + } + + if (existingItem.type === "flashcard") { + const data = existingItem.data as FlashcardData; + let cards: FlashcardItem[] = data.cards ?? []; + if (cards.length === 0 && (data.front || data.back)) { + const front = data.front ?? ""; + const back = data.back ?? ""; + cards = [{ id: "legacy", front, back }]; + } + + const payload = { + cards: cards.map((c) => ({ + id: c.id, + front: c.frontBlocks ? serializeBlockNote(c.frontBlocks as Block[]) : c.front, + back: c.backBlocks ? serializeBlockNote(c.backBlocks as Block[]) : c.back, + })), + }; + let serialized = JSON.stringify(payload, null, 2); + serialized = applyReplace(serialized, oldStr, newStr, replaceAll); + + let parsed: { cards?: Array<{ id?: string; front?: string; back?: string }> }; + try { + parsed = JSON.parse(serialized); + } catch (e) { + const detail = e instanceof SyntaxError ? e.message : String(e); + throw new Error(`Invalid JSON after edit: ${detail}. Ensure oldString matches exactly from readWorkspace.`); + } + if (!Array.isArray(parsed.cards)) { + throw new Error("Invalid structure: cards must be an array."); + } + + const newCards: FlashcardItem[] = await Promise.all( + parsed.cards.map(async (c) => { + const id = c.id ?? generateItemId(); + const front = String(c.front ?? ""); + const back = String(c.back ?? ""); + const [frontBlocks, backBlocks] = await Promise.all([markdownToBlocks(front), markdownToBlocks(back)]); + return { id, front, back, frontBlocks, backBlocks }; + }) + ); + + const changes: Partial = { + data: { ...data, cards: newCards } as FlashcardData, + }; + if (rename) { + if (hasDuplicateName(currentState.items, rename, "flashcard", existingItem.folderId ?? null, params.itemId)) { + return { success: false, message: `A flashcard deck named "${rename}" already exists in this folder` }; + } + changes.name = rename; + } + + const event = createEvent("ITEM_UPDATED", { id: params.itemId, changes, source: "agent", name: changes.name ?? existingItem.name }, 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?.length) throw new Error("Failed to update flashcard deck"); + const appendResult = parseAppendResult(eventResult[0].result); + if (appendResult.conflict) throw new Error("Workspace was modified by another user, please try again"); + + return { + success: true, + itemId: params.itemId, + message: `Updated flashcard deck (${newCards.length} cards)`, + event, + version: appendResult.version, + cardCount: newCards.length, + }; + } + + if (existingItem.type === "quiz") { + const data = existingItem.data as QuizData; + const questions = data.questions ?? []; + const payload = { questions }; + let serialized = JSON.stringify(payload, null, 2); + serialized = applyReplace(serialized, oldStr, newStr, replaceAll); + + let parsed: { questions?: QuizQuestion[] }; + try { + parsed = JSON.parse(serialized); + } catch (e) { + const detail = e instanceof SyntaxError ? e.message : String(e); + throw new Error(`Invalid JSON after edit: ${detail}. Ensure oldString matches exactly from readWorkspace.`); + } + if (!Array.isArray(parsed.questions)) { + throw new Error("Invalid structure: questions must be an array."); + } + + const validatedQuestions: QuizQuestion[] = parsed.questions.map((q, i) => { + const id = q?.id ?? generateItemId(); + const type = q?.type === "true_false" ? "true_false" : "multiple_choice"; + const questionText = String(q?.questionText ?? ""); + const options = Array.isArray(q?.options) ? q.options.map(String) : []; + const correctIndex = typeof q?.correctIndex === "number" ? Math.max(0, Math.min(q.correctIndex, options.length - 1)) : 0; + if ((type === "multiple_choice" && options.length !== 4) || (type === "true_false" && options.length !== 2)) { + throw new Error(`Question ${i + 1}: multiple_choice needs 4 options, true_false needs 2`); + } + return { + id, + type, + questionText, + options, + correctIndex, + hint: q?.hint, + explanation: String(q?.explanation ?? ""), + }; + }); + + const updatedData: QuizData = { ...data, questions: validatedQuestions }; + if (data.session) updatedData.session = data.session; + + const changes: Partial = { data: updatedData }; + if (rename) { + if (hasDuplicateName(currentState.items, rename, "quiz", existingItem.folderId ?? null, params.itemId)) { + return { success: false, message: `A quiz named "${rename}" already exists in this folder` }; + } + changes.name = rename; + } + + const event = createEvent("ITEM_UPDATED", { id: params.itemId, changes, source: "agent", name: changes.name ?? existingItem.name }, 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?.length) throw new Error("Failed to update quiz"); + const appendResult = parseAppendResult(eventResult[0].result); + if (appendResult.conflict) throw new Error("Workspace was modified by another user, please try again"); + + return { + success: true, + itemId: params.itemId, + message: `Updated quiz (${validatedQuestions.length} questions)`, + event, + version: appendResult.version, + questionCount: validatedQuestions.length, + }; + } + + throw new Error(`Item type "${existingItem.type}" is not editable`); + } + if (action === "delete") { if (!params.itemId) { throw new Error("Item ID required for delete"); diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 1a365d58..a6e0dcdc 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -95,11 +95,19 @@ Your knowledge cutoff date is January 2025. Selected cards are the primary context. When the user's message is ambiguous about what they mean, treat selected cards as likely referring to that — e.g. "this", "here", "that one". Selected cards provide paths and metadata only — use searchWorkspace or readWorkspace to fetch full content when needed. -If no context is provided, explain how to select: hover + click checkmark, shift-click, drag-select, or use the selectCards tool. +If no context is provided, explain how to select: hover + click checkmark, shift-click, or drag-select. Rely only on facts from fetched content. Do not invent or assume information. +RESPONSE STYLE (critical): +When editing workspace items (quizzes, flashcards, notes, etc.), speak to the user in plain language. Do NOT expose internal mechanics. +- Never mention tool names (editItem, readWorkspace, searchWorkspace, etc.) or parameters (oldString, newString, etc.) in your chat response. +- Never paste raw JSON, full question lists, or item content into the chat unless the user explicitly asks to see it. +- Do not describe step-by-step reasoning (e.g. "Step 1: I read the quiz... Step 2: I called editItem..."). Just state the outcome. +- Use simple, user-facing language: "I've updated the quiz with harder questions" or "I've added 3 new flashcards" — not "I performed an editItem operation with the following payload." +If something fails, describe the problem in plain terms and what to try next. Do not expose error internals unless they help the user fix the issue. + CORE BEHAVIORS: - First process native parts in the user's message (file attachments, images, etc.) — these are already in your context. Consider what the user is directly sending you before deciding whether to call tools. - Reference workspace items by path or name (never IDs) @@ -120,7 +128,7 @@ When selected card metadata includes activePage=N, the user is currently viewing YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search. INLINE CITATIONS (highly recommended for most responses): -Only in your chat response — never in item content (notes, flashcards, quizzes, etc.). Use sources param for tools; do not put tags in content passed to createNote, updateNote, addFlashcards, etc. +Only in your chat response — never in item content (notes, flashcards, quizzes, etc.). Use sources param for tools; do not put tags in content passed to createNote, editItem, createFlashcards, etc. Use simple plain text only. Bare minimum for uniqueness. No math, LaTeX, or complex formatting inside citations. Output citation HTML: REF where REF is one of: @@ -157,7 +165,7 @@ MATH FORMATTING: $$ - Currency: ALWAYS escape as \\$ so it is never parsed as math, e.g. \\$19.99, \\$5, \\$1,000 - NEVER use \\$ inside math delimiters ($..$ or $$..$$). For dollar signs inside math, use \\\\text{\\$} or omit them entirely (just write the number) -- Apply these rules to ALL tool calls (createNote, updateNote, flashcards, etc.) +- Apply these rules to ALL tool calls (createNote, editItem, createFlashcards, etc.) - Spacing: Use \\, for thin space in integrals: $\\int f(x) \\, dx$ - Common patterns: * Fractions: $\\frac{a}{b}$ @@ -624,7 +632,8 @@ export function formatOcrPagesAsMarkdown(ocrPages: PdfData["ocrPages"]): string const rawMd = page.markdown ?? ""; const md = replaceOcrPlaceholders( rawMd, - page.tables as Array<{ id?: string; content?: string }> | undefined + page.tables as Array<{ id?: string; content?: string }> | undefined, + page.images as OcrImage[] | undefined ); for (const line of md.split(/\r?\n/)) lines.push(line); if (page.footer) lines.push(`Footer: ${page.footer}`); @@ -633,12 +642,21 @@ export function formatOcrPagesAsMarkdown(ocrPages: PdfData["ocrPages"]): string return lines.join("\n").trimEnd(); } -/** Replaces table placeholders [id](id) with actual table content. Images stay as placeholders; use processFiles with pdfImageRefs to fetch. */ +/** Image shape from OCR (image_annotation is JSON string from bbox annotation). */ +interface OcrImage { + id?: string; + image_annotation?: string; + [key: string]: unknown; +} + +/** Replaces table placeholders [id](id) with table content. Replaces image placeholders ![id](id) with [Image: description] when bbox annotation is present. */ function replaceOcrPlaceholders( markdown: string, - tables?: Array<{ id?: string; content?: string }> + tables?: Array<{ id?: string; content?: string }>, + images?: OcrImage[] ): string { let out = markdown; + for (const tbl of tables ?? []) { const id = tbl.id; const content = tbl.content; @@ -648,6 +666,28 @@ function replaceOcrPlaceholders( `\n\n[Table ${id}]\n${content}\n\n` ); } + + for (const img of images ?? []) { + const id = img.id; + if (!id) continue; + let replacement = `![${id}](${id})`; + if (img.image_annotation) { + try { + const parsed = JSON.parse(img.image_annotation) as { description?: string }; + const desc = parsed?.description; + if (typeof desc === "string" && desc.trim()) { + replacement = `[Image: ${desc.trim()}]`; + } + } catch { + /* keep placeholder if parsing fails */ + } + } + out = out.replace( + new RegExp(`!\\[${escapeRegex(id)}\\]\\(${escapeRegex(id)}\\)`, "g"), + replacement + ); + } + return out; } @@ -704,7 +744,8 @@ function formatPdfDetailsFull( const rawMd = page.markdown ?? ""; const md = replaceOcrPlaceholders( rawMd, - page.tables as Array<{ id?: string; content?: string }> | undefined + page.tables as Array<{ id?: string; content?: string }> | undefined, + page.images as OcrImage[] | undefined ); for (const line of md.split(/\r?\n/)) { lines.push(` ${line}`); @@ -757,154 +798,56 @@ function formatImageDetailsFull(data: ImageData): string[] { /** - * Formats flashcard details with FULL content + * Formats flashcard details as raw JSON (editable by editItem). + * Omit frontBlocks/backBlocks; they are derived from front/back on save. */ function formatFlashcardDetailsFull(data: FlashcardData): string[] { - const lines: string[] = []; - - // Handle migration case or legacy single card let cards: FlashcardItem[] = []; if (data.cards && data.cards.length > 0) { cards = data.cards; } else if (data.front || data.back || data.frontBlocks || data.backBlocks) { - // Construct single legacy card - cards = [{ - id: 'legacy', - front: data.front || '', - back: data.back || '', - frontBlocks: data.frontBlocks, - backBlocks: data.backBlocks - }]; - } - - if (cards.length === 0) { - lines.push(" - (Empty Flashcard Deck)"); - return lines; - } - - lines.push(` - Flashcard Deck (${cards.length} cards):`); - - cards.forEach((card, i) => { - lines.push(""); - lines.push(` [Card ${i + 1}]`); - - // Front - const frontContent = card.frontBlocks - ? serializeBlockNote(card.frontBlocks as Block[]) - : card.front; - - if (frontContent) { - lines.push(` FRONT:`); - // Indent content for readability - lines.push(frontContent.split('\n').map(l => ` ${l}`).join('\n')); - } - - // Back - const backContent = card.backBlocks - ? serializeBlockNote(card.backBlocks as Block[]) - : card.back; - - if (backContent) { - lines.push(` BACK:`); - lines.push(backContent.split('\n').map(l => ` ${l}`).join('\n')); - } - }); - - return lines; + const front = data.frontBlocks + ? serializeBlockNote(data.frontBlocks as Block[]) + : data.front || ""; + const back = data.backBlocks + ? serializeBlockNote(data.backBlocks as Block[]) + : data.back || ""; + cards = [{ id: "legacy", front, back }]; + } + + const payload = { + cards: cards.map((c) => ({ + id: c.id, + front: c.frontBlocks ? serializeBlockNote(c.frontBlocks as Block[]) : c.front, + back: c.backBlocks ? serializeBlockNote(c.backBlocks as Block[]) : c.back, + })), + }; + return [JSON.stringify(payload, null, 2)]; } /** - * Formats quiz details with FULL content + * Formats quiz details as raw JSON (editable by editItem). + * Session progress is shown at top (read-only); editItem only modifies the questions JSON. */ function formatQuizDetailsFull(data: QuizData): string[] { - const lines: string[] = []; - const questions: QuizQuestion[] = data.questions || []; + const questions = 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"}`); - } + const lines: string[] = []; - // Only show answered questions list for COMPLETED status - if (status === "COMPLETED" && answeredCount > 0) { + if (session) { + const total = questions.length; + const answered = session.answeredQuestions?.length ?? 0; + const correct = session.answeredQuestions?.filter((a) => a.isCorrect).length ?? 0; + const completed = !!(session.completedAt && total > 0 && answered >= total); + lines.push("--- Progress (read-only) ---"); + lines.push(`Current question: ${session.currentIndex + 1} of ${total || "?"}`); + lines.push(`Answered: ${answered} | Correct: ${correct}`); + lines.push(`Completed: ${completed ? "yes" : "no"}`); 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}`); - }); - } } + const payload = { questions }; + lines.push(JSON.stringify(payload, null, 2)); return lines; } diff --git a/src/lib/workspace-state/types.ts b/src/lib/workspace-state/types.ts index c8bc4359..ab096a10 100644 --- a/src/lib/workspace-state/types.ts +++ b/src/lib/workspace-state/types.ts @@ -105,9 +105,6 @@ export interface QuizSessionData { 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 }