From c21e13c446e61715f81f12d36ee7bd11d220088d Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:13:22 -0500 Subject: [PATCH 01/11] fix: quizzes --- src/app/api/workspaces/autogen/route.ts | 138 +++++----- .../assistant-ui/CreateQuizToolUI.tsx | 20 +- .../assistant-ui/UpdateQuizToolUI.tsx | 10 +- src/lib/ai/tool-result-schemas.ts | 2 - src/lib/ai/tools/quiz-tools.ts | 239 ++++++---------- src/lib/ai/workers/index.ts | 1 - src/lib/ai/workers/quiz-worker.ts | 257 ------------------ src/lib/ai/workers/workspace-worker.ts | 30 +- src/lib/utils/format-workspace-context.ts | 5 - src/lib/workspace-state/types.ts | 3 - 10 files changed, 170 insertions(+), 535 deletions(-) delete mode 100644 src/lib/ai/workers/quiz-worker.ts diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index 2151a831..d386e6d7 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,43 @@ ${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) => ({ + id: generateItemId(), + type: q.type, + questionText: q.questionText, + options: q.options || [], + correctIndex: q.correctIndex ?? 0, + hint: q.hint, + explanation: 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 +594,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..b018ca9b 100644 --- a/src/components/assistant-ui/CreateQuizToolUI.tsx +++ b/src/components/assistant-ui/CreateQuizToolUI.tsx @@ -22,16 +22,9 @@ 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[]; -}; +type CreateQuizArgs = Record; interface CreateQuizReceiptProps { - args: CreateQuizArgs; result: QuizResult; status: any; moveItemToFolder?: (itemId: string, folderId: string | null) => void; @@ -41,13 +34,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(); @@ -82,8 +69,6 @@ const CreateQuizReceipt = ({ args, result, status, moveItemToFolder, allItems = } }; - const difficulty = result.difficulty || args.difficulty || "medium"; - return ( <>
( if (parsed?.success) { content = ( ; const UpdateQuizReceipt = ({ result, status }: { result: QuizResult; status: any }) => { const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId); @@ -103,8 +97,6 @@ export const UpdateQuizToolUI = makeAssistantToolUI( 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 diff --git a/src/lib/ai/tool-result-schemas.ts b/src/lib/ai/tool-result-schemas.ts index a64a31a9..44f26998 100644 --- a/src/lib/ai/tool-result-schemas.ts +++ b/src/lib/ai/tool-result-schemas.ts @@ -60,8 +60,6 @@ export const QuizResultSchema = baseWorkspace.extend({ 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; diff --git a/src/lib/ai/tools/quiz-tools.ts b/src/lib/ai/tools/quiz-tools.ts index 028c5144..f5a2c0be 100644 --- a/src/lib/ai/tools/quiz-tools.ts +++ b/src/lib/ai/tools/quiz-tools.ts @@ -1,83 +1,76 @@ /** * 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 type { QuizData, QuizQuestion } from "@/lib/workspace-state/types"; import { loadStateForTool, resolveItem, getAvailableItemsList } from "./tool-utils"; +import { generateItemId } from "@/lib/workspace-state/item-helpers"; + +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().describe("0-based index of correct answer in options array"), + hint: z.string().optional(), + explanation: z.string(), +}); /** - * 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.", + description: "Create an interactive quiz.", 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"), + title: z.string().nullable().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."), }) ), - 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 }); + execute: async (input: { title?: string | null; questions: Array<{ type: "multiple_choice" | "true_false"; questionText: string; options: string[]; correctIndex: number; hint?: string; explanation: string }> }) => { + 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 ?? 0, + 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 +83,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, }; @@ -109,26 +101,20 @@ export function createQuizTool(ctx: WorkspaceToolContext) { } /** - * Create the updateQuiz tool + * Create the updateQuiz tool - AI generates new questions directly (like addFlashcards) */ 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.", + description: "Add more questions to an existing quiz and/or update its title. Generate the new questions yourself based on the user's request, quiz topic, or selected cards. Use readWorkspace to get content from selected cards.", 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"), + questions: z.array(QuizQuestionSchema).min(1).max(50).optional().describe("Array of new quiz questions to add. Same format as createQuiz. Generate questions that don't duplicate existing ones."), }) ), - 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 }); + execute: async (input: { quizName: string; title?: string; questions?: Array<{ type: "multiple_choice" | "true_false"; questionText: string; options: string[]; correctIndex: number; hint?: string; explanation: string }> }) => { + const { quizName, title, questions: rawQuestions } = input; if (!ctx.workspaceId) { return { @@ -137,7 +123,6 @@ export function createUpdateQuizTool(ctx: WorkspaceToolContext) { }; } - // Validate required quizName if (!quizName) { return { success: false, @@ -145,143 +130,73 @@ export function createUpdateQuizTool(ctx: WorkspaceToolContext) { }; } + if (!rawQuestions?.length && !title) { + return { + success: false, + message: "Either new questions or a new title must be provided.", + }; + } + 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.'}`, + 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, - }; + let questionsToAdd: QuizQuestion[] = []; + if (rawQuestions && rawQuestions.length > 0) { + questionsToAdd = rawQuestions.map((q) => ({ + id: generateItemId(), + type: q.type, + questionText: q.questionText, + options: q.options || [], + correctIndex: q.correctIndex ?? 0, + hint: q.hint, + explanation: q.explanation || "No explanation provided.", + })); } - // 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, + title, + questionsToAdd: questionsToAdd.length > 0 ? questionsToAdd : undefined, }); 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.`; + const totalQuestions = existingQuestions.length + questionsToAdd.length; + let message = "Quiz updated successfully."; + if (title && questionsToAdd.length > 0) { + message = `Updated title to "${title}" and added ${questionsToAdd.length} new questions.`; + } else if (title) { + message = `Updated quiz title to "${title}".`; + } else if (questionsToAdd.length > 0) { + message = `Added ${questionsToAdd.length} new question${questionsToAdd.length !== 1 ? "s" : ""} to the quiz.`; + } return { ...workerResult, quizId, - questionsAdded: quizResult.questions.length, + questionsAdded: questionsToAdd.length, totalQuestions, message, }; 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..d2ee7fe9 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -766,19 +766,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 +794,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)) { @@ -874,9 +872,11 @@ export async function workspaceWorker( 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, }; diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 1a365d58..10db29cd 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -859,10 +859,6 @@ function formatQuizDetailsFull(data: QuizData): string[] { 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; @@ -872,7 +868,6 @@ function formatQuizDetailsFull(data: QuizData): string[] { // COMPLETED const percentage = totalQuestions > 0 ? Math.round((correctCount / totalQuestions) * 100) : 0; lines.push(` - Final Score: ${correctCount}/${totalQuestions} (${percentage}%)`); - lines.push(` - Difficulty: ${data.difficulty || "Medium"}`); } // Only show answered questions list for COMPLETED status 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 } From 22351a69ccbe1e517891b16c9ad54e7d6c58af1e Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:24:43 -0500 Subject: [PATCH 02/11] refactor: add unified editItem tool; replace updateNote/updateFlashcard/updateQuiz; remove selectCards Made-with: Cursor --- ...pdateNoteToolUI.tsx => EditItemToolUI.tsx} | 78 +++--- .../assistant-ui/SelectCardsToolUI.tsx | 239 ----------------- .../assistant-ui/UpdateFlashcardToolUI.tsx | 184 ------------- .../assistant-ui/UpdateQuizToolUI.tsx | 136 ---------- src/components/assistant-ui/thread.tsx | 30 +-- src/lib/ai/tool-result-schemas.ts | 17 +- src/lib/ai/tools/edit-item-tool.ts | 158 +++++++++++ src/lib/ai/tools/flashcard-tools.ts | 97 +------ src/lib/ai/tools/index.ts | 12 +- src/lib/ai/tools/quiz-tools.ts | 112 +------- src/lib/ai/tools/workspace-tools.ts | 217 +-------------- src/lib/ai/workers/workspace-worker.ts | 252 +++++++++++++++++- 12 files changed, 470 insertions(+), 1062 deletions(-) rename src/components/assistant-ui/{UpdateNoteToolUI.tsx => EditItemToolUI.tsx} (68%) delete mode 100644 src/components/assistant-ui/SelectCardsToolUI.tsx delete mode 100644 src/components/assistant-ui/UpdateFlashcardToolUI.tsx delete mode 100644 src/components/assistant-ui/UpdateQuizToolUI.tsx create mode 100644 src/lib/ai/tools/edit-item-tool.ts diff --git a/src/components/assistant-ui/UpdateNoteToolUI.tsx b/src/components/assistant-ui/EditItemToolUI.tsx similarity index 68% rename from src/components/assistant-ui/UpdateNoteToolUI.tsx rename to src/components/assistant-ui/EditItemToolUI.tsx index 619f33fd..5de71b73 100644 --- a/src/components/assistant-ui/UpdateNoteToolUI.tsx +++ b/src/components/assistant-ui/EditItemToolUI.tsx @@ -6,7 +6,7 @@ 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 } from "lucide-react"; -import { CgNotes } from "react-icons/cg"; +import { Pencil } from "lucide-react"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -19,34 +19,40 @@ import { DiffViewer } from "@/components/assistant-ui/diff-viewer"; import type { WorkspaceResult } from "@/lib/ai/tool-result-schemas"; import { parseWorkspaceResult } from "@/lib/ai/tool-result-schemas"; -type UpdateNoteArgs = { noteName: string; oldString: string; newString: string; replaceAll?: boolean }; +type EditItemArgs = { + itemName: string; + oldString: string; + newString: string; + replaceAll?: boolean; + newName?: string; +}; -interface UpdateNoteResult extends WorkspaceResult { +interface EditItemResult extends WorkspaceResult { diff?: string; filediff?: { additions: number; deletions: number }; + cardCount?: number; + questionCount?: number; } -interface UpdateNoteReceiptProps { - args: UpdateNoteArgs; - result: UpdateNoteResult; - status: any; +interface EditItemReceiptProps { + args: EditItemArgs; + result: EditItemResult; + status: { type: string }; } -const UpdateNoteReceipt = ({ args, result, status }: UpdateNoteReceiptProps) => { +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); + return workspaceState.items.find((item: { id: string }) => 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 +60,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 +127,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 +147,17 @@ export const UpdateNoteToolUI = makeAssistantToolUI; + content = ; } else if (status.type === "running") { - content = ; + 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 f6b0235f..00000000 --- a/src/components/assistant-ui/UpdateQuizToolUI.tsx +++ /dev/null @@ -1,136 +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 = Record; - -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 { - 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..319de84c 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 */}
; +/** 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,7 +64,7 @@ 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(), @@ -85,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..56552e2b --- /dev/null +++ b/src/lib/ai/tools/edit-item-tool.ts @@ -0,0 +1,158 @@ +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, getAvailableItemsList } from "./tool-utils"; + +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."}`, + }; + } + + 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: 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 f5a2c0be..f85215fb 100644 --- a/src/lib/ai/tools/quiz-tools.ts +++ b/src/lib/ai/tools/quiz-tools.ts @@ -10,7 +10,6 @@ import { logger } from "@/lib/utils/logger"; import { workspaceWorker } from "@/lib/ai/workers"; import type { WorkspaceToolContext } from "./workspace-tools"; import type { QuizData, QuizQuestion } from "@/lib/workspace-state/types"; -import { loadStateForTool, resolveItem, getAvailableItemsList } from "./tool-utils"; import { generateItemId } from "@/lib/workspace-state/item-helpers"; const QuizQuestionSchema = z.object({ @@ -100,113 +99,4 @@ export function createQuizTool(ctx: WorkspaceToolContext) { }); } -/** - * Create the updateQuiz tool - AI generates new questions directly (like addFlashcards) - */ -export function createUpdateQuizTool(ctx: WorkspaceToolContext) { - return tool({ - description: "Add more questions to an existing quiz and/or update its title. Generate the new questions yourself based on the user's request, quiz topic, or selected cards. Use readWorkspace to get content from selected cards.", - 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."), - questions: z.array(QuizQuestionSchema).min(1).max(50).optional().describe("Array of new quiz questions to add. Same format as createQuiz. Generate questions that don't duplicate existing ones."), - }) - ), - execute: async (input: { quizName: string; title?: string; questions?: Array<{ type: "multiple_choice" | "true_false"; questionText: string; options: string[]; correctIndex: number; hint?: string; explanation: string }> }) => { - const { quizName, title, questions: rawQuestions } = input; - - if (!ctx.workspaceId) { - return { - success: false, - message: "No workspace context available", - }; - } - - if (!quizName) { - return { - success: false, - message: "Quiz name is required to identify which quiz to update.", - }; - } - - if (!rawQuestions?.length && !title) { - return { - success: false, - message: "Either new questions or a new title must be provided.", - }; - } - - try { - const accessResult = await loadStateForTool(ctx); - if (!accessResult.success) { - return accessResult; - } - - const { state } = accessResult; - const quizItem = resolveItem(state.items, quizName, "quiz"); - - if (!quizItem) { - const availableQuizzes = getAvailableItemsList(state.items, "quiz"); - return { - success: false, - message: `Could not find quiz "${quizName}". ${availableQuizzes ? `Available quizzes: ${availableQuizzes}` : "No quizzes found in workspace."}`, - }; - } - - const quizId = quizItem.id; - const currentQuizData = quizItem.data as QuizData; - const existingQuestions = currentQuizData.questions || []; - - let questionsToAdd: QuizQuestion[] = []; - if (rawQuestions && rawQuestions.length > 0) { - questionsToAdd = rawQuestions.map((q) => ({ - id: generateItemId(), - type: q.type, - questionText: q.questionText, - options: q.options || [], - correctIndex: q.correctIndex ?? 0, - hint: q.hint, - explanation: q.explanation || "No explanation provided.", - })); - } - - const workerResult = await workspaceWorker("updateQuiz", { - workspaceId: ctx.workspaceId, - itemId: quizId, - itemType: "quiz", - title, - questionsToAdd: questionsToAdd.length > 0 ? questionsToAdd : undefined, - }); - - if (!workerResult.success) { - return workerResult; - } - - const totalQuestions = existingQuestions.length + questionsToAdd.length; - let message = "Quiz updated successfully."; - if (title && questionsToAdd.length > 0) { - message = `Updated title to "${title}" and added ${questionsToAdd.length} new questions.`; - } else if (title) { - message = `Updated quiz title to "${title}".`; - } else if (questionsToAdd.length > 0) { - message = `Added ${questionsToAdd.length} new question${questionsToAdd.length !== 1 ? "s" : ""} to the quiz.`; - } - - return { - ...workerResult, - quizId, - questionsAdded: questionsToAdd.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/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/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index d2ee7fe9..9c55300e 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?: { @@ -968,6 +972,250 @@ 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; + if (hasDuplicateName(currentState.items, itemName, existingItem.type, existingItem.folderId ?? null, params.itemId)) { + return { success: false, message: `A ${existingItem.type} named "${itemName}" already exists in this folder` }; + } + + 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"); From c4a7360d20323a9e3c30b4c8a8c6875bc91ae1cb Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:24:45 -0500 Subject: [PATCH 03/11] fix: add agent response style; quiz progress in readWorkspace Made-with: Cursor --- src/lib/ai/tools/read-workspace.ts | 2 +- src/lib/utils/format-workspace-context.ts | 175 ++++++---------------- 2 files changed, 46 insertions(+), 131 deletions(-) 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/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 10db29cd..f4f45f0f 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}$ @@ -757,149 +765,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}`); - } 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}%)`); - } + 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; } From 3a9f20613c91632fb8f6bc712b836f9cd066c174 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:35:36 -0500 Subject: [PATCH 04/11] fix: updateQuiz logger crash when no questions; autogen quiz validation Made-with: Cursor --- src/app/api/workspaces/autogen/route.ts | 31 ++++++++++++++++++------- src/lib/ai/workers/workspace-worker.ts | 2 +- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index d386e6d7..45ab09e1 100644 --- a/src/app/api/workspaces/autogen/route.ts +++ b/src/app/api/workspaces/autogen/route.ts @@ -557,15 +557,28 @@ Return: send({ type: "progress", data: { step: "flashcards", status: "done" } }); send({ type: "progress", data: { step: "quiz", status: "done" } }); - const questions: QuizQuestion[] = output.quiz.questions.map((q) => ({ - id: generateItemId(), - type: q.type, - questionText: q.questionText, - options: q.options || [], - correctIndex: q.correctIndex ?? 0, - hint: q.hint, - explanation: q.explanation || "No explanation provided.", - })); + 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 }, diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 9c55300e..a210ef18 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -869,7 +869,7 @@ export async function workspaceWorker( logger.info("🎯 [WORKSPACE-WORKER] Updated quiz:", { itemId: params.itemId, - questionsAdded: questionsToAdd.length, + questionsAdded: questionsToAdd?.length ?? 0, totalQuestions: updatedData.questions.length, }); From 47cd68475aeea133a216e2d4aa31dba911384a18 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:35:38 -0500 Subject: [PATCH 05/11] chore: add tsconfig strict options; document lint vs tc Made-with: Cursor --- CONTRIBUTING.md | 10 ++++++++++ tsconfig.json | 36 +++++++++--------------------------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 087a9f38..abc2eea1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,3 +42,13 @@ cp .env.example .env pnpm db:push pnpm dev ``` + +### Lint and type check + +| Command | Purpose | +|---------|---------| +| `pnpm lint` | ESLint: style, best practices, potential bugs. Uses [Next.js ESLint config](https://nextjs.org/docs/app/building-your-application/configuring/eslint). | +| `pnpm tc` | TypeScript compiler: type-check only (`tsc --noEmit`). Catches type errors, no emit. | +| `pnpm lint:fix` | Auto-fix lint issues where possible. | + +Use both before committing. Lint handles code style and patterns; `tc` ensures types are correct. diff --git a/tsconfig.json b/tsconfig.json index ead11e1f..b8576718 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -17,27 +13,13 @@ "isolatedModules": true, "jsx": "react-jsx", "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": [ - "./src/*" - ] - } + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] }, + "noUnusedLocals": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" - ], - "exclude": [ - "node_modules", - "assistant-ui-main", - "tmp" - ] + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], + "exclude": ["node_modules", "assistant-ui-main", "tmp"] } From 7a3e8dbd0a0c84985888595aa10508902bc2659a Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:37:27 -0500 Subject: [PATCH 06/11] fix: editItem return final name, duplicate-name guard; quiz schema validation; CreateQuiz/EditItem UI fixes; remove redundant duplicate check Made-with: Cursor --- .../assistant-ui/CreateQuizToolUI.tsx | 24 ++++++++--- .../assistant-ui/EditItemToolUI.tsx | 8 ++-- src/lib/ai/tools/edit-item-tool.ts | 17 +++++++- src/lib/ai/tools/quiz-tools.ts | 43 +++++++++++-------- src/lib/ai/workers/workspace-worker.ts | 4 -- 5 files changed, 63 insertions(+), 33 deletions(-) diff --git a/src/components/assistant-ui/CreateQuizToolUI.tsx b/src/components/assistant-ui/CreateQuizToolUI.tsx index b018ca9b..e2f425e0 100644 --- a/src/components/assistant-ui/CreateQuizToolUI.tsx +++ b/src/components/assistant-ui/CreateQuizToolUI.tsx @@ -21,8 +21,7 @@ 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 = Record; +import type { CreateQuizInput } from "@/lib/ai/tools/quiz-tools"; interface CreateQuizReceiptProps { result: QuizResult; @@ -42,12 +41,23 @@ const CreateQuizReceipt = ({ result, status, moveItemToFolder, allItems = [], wo // 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(() => { @@ -152,7 +162,7 @@ const CreateQuizReceipt = ({ result, status, moveItemToFolder, allItems = [], wo ); }; -export const CreateQuizToolUI = makeAssistantToolUI({ +export const CreateQuizToolUI = makeAssistantToolUI({ toolName: "createQuiz", render: function CreateQuizUI({ args, result, status }) { const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); diff --git a/src/components/assistant-ui/EditItemToolUI.tsx b/src/components/assistant-ui/EditItemToolUI.tsx index 5de71b73..36929352 100644 --- a/src/components/assistant-ui/EditItemToolUI.tsx +++ b/src/components/assistant-ui/EditItemToolUI.tsx @@ -18,6 +18,7 @@ import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell" import { DiffViewer } from "@/components/assistant-ui/diff-viewer"; import type { WorkspaceResult } from "@/lib/ai/tool-result-schemas"; import { parseWorkspaceResult } from "@/lib/ai/tool-result-schemas"; +import type { Item } from "@/lib/workspace-state/types"; type EditItemArgs = { itemName: string; @@ -47,8 +48,8 @@ const EditItemReceipt = ({ args, result, status }: EditItemReceiptProps) => { const navigateToItem = useNavigateToItem(); const card = useMemo(() => { - if (!result.itemId || !workspaceState?.items) return null; - return workspaceState.items.find((item: { id: string }) => 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 = () => { @@ -149,7 +150,8 @@ export const EditItemToolUI = makeAssistantToolUI if (parsed?.success) { content = ; } else if (status.type === "running") { - content = ; + const itemName = args?.itemName; + content = ; } else if (status.type === "complete" && parsed && !parsed.success) { content = ; } else if (status.type === "incomplete" && status.reason === "error") { diff --git a/src/lib/ai/tools/edit-item-tool.ts b/src/lib/ai/tools/edit-item-tool.ts index 56552e2b..5cb4e3af 100644 --- a/src/lib/ai/tools/edit-item-tool.ts +++ b/src/lib/ai/tools/edit-item-tool.ts @@ -3,7 +3,8 @@ 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, getAvailableItemsList } from "./tool-utils"; +import { loadStateForTool, resolveItem } from "./tool-utils"; +import { getVirtualPath } from "@/lib/utils/virtual-workspace-fs"; const EDITABLE_TYPES = ["note", "flashcard", "quiz"] as const; @@ -112,6 +113,18 @@ export function createEditItemTool(ctx: WorkspaceToolContext) { }; } + 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, @@ -141,7 +154,7 @@ export function createEditItemTool(ctx: WorkspaceToolContext) { if (workerResult.success) { return { ...workerResult, - itemName: matchedItem.name, + itemName: newName ?? matchedItem.name, }; } diff --git a/src/lib/ai/tools/quiz-tools.ts b/src/lib/ai/tools/quiz-tools.ts index f85215fb..f779ce13 100644 --- a/src/lib/ai/tools/quiz-tools.ts +++ b/src/lib/ai/tools/quiz-tools.ts @@ -9,17 +9,31 @@ import { tool, zodSchema } from "ai"; import { logger } from "@/lib/utils/logger"; import { workspaceWorker } from "@/lib/ai/workers"; import type { WorkspaceToolContext } from "./workspace-tools"; -import type { QuizData, QuizQuestion } from "@/lib/workspace-state/types"; +import type { QuizQuestion } from "@/lib/workspace-state/types"; import { generateItemId } from "@/lib/workspace-state/item-helpers"; -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().describe("0-based index of correct answer in options array"), - hint: z.string().optional(), - explanation: z.string(), +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 - AI generates questions directly (like createFlashcards) @@ -27,13 +41,8 @@ const QuizQuestionSchema = z.object({ export function createQuizTool(ctx: WorkspaceToolContext) { return tool({ description: "Create an interactive quiz.", - inputSchema: zodSchema( - z.object({ - title: z.string().nullable().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."), - }) - ), - execute: async (input: { title?: string | null; questions: Array<{ type: "multiple_choice" | "true_false"; questionText: string; options: string[]; correctIndex: number; hint?: string; explanation: string }> }) => { + inputSchema: zodSchema(CreateQuizInputSchema), + execute: async (input: CreateQuizInput) => { const title = input.title || "Quiz"; const rawQuestions = input.questions || []; @@ -56,8 +65,8 @@ export function createQuizTool(ctx: WorkspaceToolContext) { id: generateItemId(), type: q.type, questionText: q.questionText, - options: q.options || [], - correctIndex: q.correctIndex ?? 0, + options: q.options, + correctIndex: q.correctIndex, hint: q.hint, explanation: q.explanation || "No explanation provided.", })); diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index a210ef18..878323b2 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -1021,10 +1021,6 @@ export async function workspaceWorker( } const itemName = (changes.name ?? existingItem.name) as string; - if (hasDuplicateName(currentState.items, itemName, existingItem.type, existingItem.folderId ?? null, params.itemId)) { - return { success: false, message: `A ${existingItem.type} named "${itemName}" already exists in this folder` }; - } - 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 From bf704c2cc77d8c87fccb64848c50dd81b6a824ce Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:38:14 -0500 Subject: [PATCH 07/11] Revert "chore: add tsconfig strict options; document lint vs tc" This reverts commit 47cd68475aeea133a216e2d4aa31dba911384a18. --- CONTRIBUTING.md | 10 ---------- tsconfig.json | 36 +++++++++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abc2eea1..087a9f38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,13 +42,3 @@ cp .env.example .env pnpm db:push pnpm dev ``` - -### Lint and type check - -| Command | Purpose | -|---------|---------| -| `pnpm lint` | ESLint: style, best practices, potential bugs. Uses [Next.js ESLint config](https://nextjs.org/docs/app/building-your-application/configuring/eslint). | -| `pnpm tc` | TypeScript compiler: type-check only (`tsc --noEmit`). Catches type errors, no emit. | -| `pnpm lint:fix` | Auto-fix lint issues where possible. | - -Use both before committing. Lint handles code style and patterns; `tc` ensures types are correct. diff --git a/tsconfig.json b/tsconfig.json index b8576718..ead11e1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -13,13 +17,27 @@ "isolatedModules": true, "jsx": "react-jsx", "incremental": true, - "plugins": [{ "name": "next" }], - "paths": { "@/*": ["./src/*"] }, - "noUnusedLocals": true, - "noUnusedParameters": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], - "exclude": ["node_modules", "assistant-ui-main", "tmp"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "assistant-ui-main", + "tmp" + ] } From 5e2a6ae6179df8e1648f592a640fbe94e26e528e Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:41:10 -0500 Subject: [PATCH 08/11] feat: improve assistant action bar Continue button; show when response lacks sentence-ending punctuation; lower char threshold to 150 Made-with: Cursor --- src/components/assistant-ui/thread.tsx | 29 ++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 319de84c..3f8c5660 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -1240,20 +1240,30 @@ const AssistantMessage: FC = () => { ); }; -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 = /[.!?]$/; 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 endsWithPunctuation = trimmed.length > 0 && ENDS_WITH_SENTENCE_PUNCTUATION.test(trimmed); + 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({ @@ -1262,13 +1272,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); From 2fba9e8941bd9c73a2c9c3a86e37616d5feb13e7 Mon Sep 17 00:00:00 2001 From: Vercel Date: Mon, 2 Mar 2026 00:56:48 +0000 Subject: [PATCH 09/11] Fix: Unused variable `userId` causes TypeScript compilation error that blocks the Vercel build. This commit fixes the issue reported at src/app/api/cards/from-message/route.ts:24 **Bug Explanation:** The file `src/app/api/cards/from-message/route.ts` declares a variable `userId` on line 24 (`const userId = session.user.id;`) but never uses it anywhere in the function. TypeScript's strict mode (specifically the `noUnusedLocals` compiler option or equivalent strict checking) treats unused variables as errors, not just warnings. This caused the Vercel build to fail with the error: ``` Type error: 'userId' is declared but its value is never read. ``` The variable appears to be leftover code from development - likely intended for future use or accidentally left behind after refactoring. The function authenticates the user and validates the session, but doesn't actually need to store the `userId` since none of the subsequent operations (AI content processing, workspace worker calls) require it as a parameter. **Fix:** Removed the unused variable declaration entirely. The line `const userId = session.user.id;` was deleted since: 1. The userId value is never used in the function 2. The session validation already ensures the user is authenticated 3. Keeping unused variables violates TypeScript's strict checking and blocks builds After the fix, the code flows directly from session validation to parsing the request body, which is the correct behavior. Co-authored-by: Vercel Co-authored-by: urjitc --- src/app/api/cards/from-message/route.ts | 2 -- 1 file changed, 2 deletions(-) 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; From 4666c1db4d7efbecc6ca92a53c579250573bd226 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:22:56 -0500 Subject: [PATCH 10/11] fix: surface image data from pdfs --- src/lib/utils/format-workspace-context.ts | 41 ++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index f4f45f0f..a6e0dcdc 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -632,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}`); @@ -641,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; @@ -656,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; } @@ -712,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}`); From d7210f4bdff301f82501f008cac4c3476a0f281a Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:36:48 -0500 Subject: [PATCH 11/11] fix: citation end of message detection --- src/components/assistant-ui/thread.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 3f8c5660..2fc33e01 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -1245,6 +1245,9 @@ 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(); @@ -1257,7 +1260,10 @@ const AssistantActionBar: FC = () => { 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 endsWithPunctuation = trimmed.length > 0 && ENDS_WITH_SENTENCE_PUNCTUATION.test(trimmed); + 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]);