diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 8b04df2..d905bbd 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -95,14 +95,15 @@ function getSelectedCardsContext(body: any): string { return body.selectedCardsContext || ""; } -// Regex to detect createFrom auto-generated prompts -const CREATE_FROM_REGEX = /^Update the preexisting contents of this workspace to be about (.+)\. Only add one quality YouTube video\.$/; +// Regex to detect createFrom auto-generated prompts (YouTube suffix is optional for custom type selections) +const CREATE_FROM_REGEX = /^Update the preexisting contents of this workspace to be about (.+)\.(?:\s*Only add one quality YouTube video\.)?$/; /** * Detect if the first user message is a createFrom auto-generated prompt - * and return additional system instructions for better workspace curation + * and return additional system instructions for better workspace curation. + * When genTypes is provided, only instructions for those types are included. */ -function getCreateFromSystemPrompt(messages: any[]): string | null { +function getCreateFromSystemPrompt(messages: any[], genTypes: string[] | null): string | null { // Find the first user message const firstUserMessage = messages.find((m) => m.role === "user"); if (!firstUserMessage) return null; @@ -121,29 +122,52 @@ function getCreateFromSystemPrompt(messages: any[]): string | null { if (!match) return null; const topic = match[1]; + const allTypes = ['note', 'quiz', 'flashcard', 'youtube'] as const; + const validTypes = genTypes ? genTypes.filter(t => allTypes.includes(t as any)) : null; + const types = new Set(validTypes && validTypes.length > 0 ? validTypes : allTypes); + + // Build type-specific update instructions — always include title param + const updateInstructions: string[] = []; + if (types.has('note')) updateInstructions.push(` - Use \`updateNote\` with noteName "Update me" and a descriptive \`title\` (e.g. "${topic} Notes")`); + if (types.has('flashcard')) updateInstructions.push(` - Use \`updateFlashcards\` with deckName "Update me" and a descriptive \`title\` (e.g. "${topic} Flashcards")`); + if (types.has('quiz')) updateInstructions.push(` - Use \`updateQuiz\` with quizName "Update me" and a descriptive \`title\` (e.g. "${topic} Quiz")`); + + // Build type-specific quality guidelines + const qualityGuidelines: string[] = []; + if (types.has('note')) qualityGuidelines.push('- For notes: add a comprehensive summary of the topic'); + if (types.has('flashcard')) qualityGuidelines.push('- For flashcards: create exactly 5 meaningful question/answer pairs covering key concepts'); + if (types.has('quiz')) qualityGuidelines.push('- For quizzes: create challenging but fair questions that test understanding'); + + // YouTube section only if selected + let youtubeSection = ''; + if (types.has('youtube')) { + youtubeSection = ` +YOUTUBE VIDEO: +- Search with specific, relevant terms for the topic +- Prefer videos that are educational/explanatory +- Look for high view counts and reputable channels as quality signals`; + } + + // Build numbered instructions dynamically to avoid numbering gaps + const instructions: string[] = []; + let instrNum = 1; + if (updateInstructions.length > 0) { + instructions.push(`${instrNum++}. **Update ONLY these workspace items** about the topic:\n${updateInstructions.join('\n')}`); + } + instructions.push(`${instrNum++}. **Be thorough but focused** - Provide a solid foundation for understanding the topic without being overwhelming.`); + if (validTypes && validTypes.length > 0) { + instructions.push(`${instrNum++}. **Do NOT create or update any other item types** beyond what is listed above.`); + } + instructions.push(`${instrNum++}. **Do NOT ask the user questions** - This is an automated initialization, proceed directly with updating the workspace.`); return ` CREATE-FROM WORKSPACE INITIALIZATION MODE: -This is an automatic workspace initialization request. The user wants to transform this workspace into a curated learning/research space -ace about: "${topic}" +This is an automatic workspace initialization request. The user wants to transform this workspace into a curated learning/research space about: "${topic}" CRITICAL INSTRUCTIONS FOR WORKSPACE CURATION: -1. **For each of the existing workspace items** update the title and content to be about the topic: - - Use \`updateNote\` tool for notes - - Use \`updateFlashcards\` tool for flashcard sets - - Use \`updateQuiz\` tool for quizzes -2. **Be thorough but focused** - Provide a solid foundation for understanding the topic without being overwhelming. -3. **Do NOT ask the user questions** - This is an automated initialization, proceed directly with updating the workspace. - -QUALITY GUIDELINES FOR CONTENT: -- For notes: add a comprehensive summary of the topic -- For flashcards: create exactly 5 meaningful question/answer pairs covering key concepts -- For quizzes: create challenging but fair questions that test understanding - -QUALITY GUIDELINES FOR THE YOUTUBE VIDEO: -- Search with specific, relevant terms for the topic -- Prefer videos that are educational/explanatory -- Look for high view counts and reputable channels as quality signals +${instructions.join('\n')} + +${qualityGuidelines.length > 0 ? `QUALITY GUIDELINES FOR CONTENT:\n${qualityGuidelines.join('\n')}` : ''}${youtubeSection} `; } @@ -224,7 +248,8 @@ export async function POST(req: Request) { ]; // Inject createFrom workspace initialization prompt if detected - const createFromPrompt = getCreateFromSystemPrompt(cleanedMessages); + const genTypes: string[] | null = body.genTypes ? body.genTypes.split(',').map((t: string) => t.trim()).filter(Boolean) : null; + const createFromPrompt = getCreateFromSystemPrompt(cleanedMessages, genTypes); if (createFromPrompt) { systemPromptParts.push(`\n\n${createFromPrompt}`); } diff --git a/src/components/assistant-ui/AssistantPanel.tsx b/src/components/assistant-ui/AssistantPanel.tsx index c24712a..f6d67ca 100644 --- a/src/components/assistant-ui/AssistantPanel.tsx +++ b/src/components/assistant-ui/AssistantPanel.tsx @@ -123,13 +123,19 @@ function CreateFromPromptHandler({ const timeoutIdsRef = useRef[]>([]); const createFrom = searchParams.get("createFrom"); + const genTypes = searchParams.get("genTypes"); useEffect(() => { if (!createFrom || !workspaceId || isLoading || hasAutoSentRef.current) return; setIsChatExpanded?.(true); - const wrapped = `Update the preexisting contents of this workspace to be about ${createFrom}. Only add one quality YouTube video.`; + // Build type-aware message: only mention YouTube if selected (or auto mode) + const types = genTypes ? genTypes.split(',').map(s => s.trim()).filter(Boolean) : null; + const includeYoutube = !types || types.includes('youtube'); + const wrapped = includeYoutube + ? `Update the preexisting contents of this workspace to be about ${createFrom}. Only add one quality YouTube video.` + : `Update the preexisting contents of this workspace to be about ${createFrom}.`; let attempts = 0; const maxAttempts = 12; @@ -152,6 +158,7 @@ function CreateFromPromptHandler({ clearAll(); const url = new URL(window.location.href); url.searchParams.delete("createFrom"); + url.searchParams.delete("genTypes"); router.replace(url.pathname + url.search); return; } catch { @@ -168,7 +175,7 @@ function CreateFromPromptHandler({ ids.push(id); return () => clearAll(); - }, [createFrom, workspaceId, isLoading, aui, router, setIsChatExpanded]); + }, [createFrom, genTypes, workspaceId, isLoading, aui, router, setIsChatExpanded]); return null; } @@ -189,19 +196,31 @@ function GenerateStudyMaterialsHandler({ const timeoutIdsRef = useRef[]>([]); const action = searchParams.get("action"); + const genTypes = searchParams.get("genTypes"); useEffect(() => { if (action !== "generate_study_materials" || !workspaceId || isLoading || hasAutoSentRef.current) return; setIsChatExpanded?.(true); - const prompt = `First, process any PDF files in this workspace. - -Then, using the content: -1. Update the note with a comprehensive summary -2. Update the quiz with 5-10 relevant questions -3. Update the flashcards with key terms and concepts -4. Search and add one relevant YouTube video if possible`; + // Build type-aware prompt: only include steps for selected types + const types = genTypes ? new Set(genTypes.split(',').map(s => s.trim()).filter(Boolean)) : null; + const steps: string[] = []; + let n = 1; + if (!types || types.has('note')) steps.push(`${n++}. Update the note with a comprehensive summary`); + if (!types || types.has('quiz')) steps.push(`${n++}. Update the quiz with 5-10 relevant questions`); + if (!types || types.has('flashcard')) steps.push(`${n++}. Update the flashcards with key terms and concepts`); + if (!types || types.has('youtube')) steps.push(`${n++}. Search and add one relevant YouTube video if possible`); + + // If genTypes was set but contained no recognized values, fall back to all types + if (steps.length === 0) { + steps.push('1. Update the note with a comprehensive summary'); + steps.push('2. Update the quiz with 5-10 relevant questions'); + steps.push('3. Update the flashcards with key terms and concepts'); + steps.push('4. Search and add one relevant YouTube video if possible'); + } + + const prompt = `First, process any PDF files in this workspace.\n\nThen, using the content:\n${steps.join('\n')}`; let attempts = 0; const maxAttempts = 12; @@ -224,6 +243,7 @@ Then, using the content: clearAll(); const url = new URL(window.location.href); url.searchParams.delete("action"); + url.searchParams.delete("genTypes"); router.replace(url.pathname + url.search); return; } catch { @@ -240,7 +260,7 @@ Then, using the content: ids.push(id); return () => clearAll(); - }, [action, workspaceId, isLoading, aui, router, setIsChatExpanded]); + }, [action, genTypes, workspaceId, isLoading, aui, router, setIsChatExpanded]); return null; } diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx index cf32ee8..ede76fb 100644 --- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx +++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx @@ -3,6 +3,7 @@ import { AssistantCloud, AssistantRuntimeProvider } from "@assistant-ui/react"; import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk"; import { useMemo, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; import { AssistantAvailableProvider } from "@/contexts/AssistantAvailabilityContext"; import { useUIStore } from "@/lib/stores/ui-store"; import { useSession } from "@/lib/auth-client"; @@ -38,6 +39,11 @@ export function WorkspaceRuntimeProvider({ const selectedCardIdsSet = useUIStore((state) => state.selectedCardIds); const selectedActions = useUIStore((state) => state.selectedActions); const replySelections = useUIStore(useShallow((state) => state.replySelections)); + const searchParams = useSearchParams(); + // Only pass genTypes during auto-init flows (createFrom / generate_study_materials) + // to avoid leaking it into every subsequent chat request + const hasAutoInit = searchParams.has("createFrom") || searchParams.get("action") === "generate_study_materials"; + const genTypes = hasAutoInit ? searchParams.get("genTypes") : null; const { data: session } = useSession(); // Get workspace state to format selected cards context on client @@ -147,13 +153,14 @@ export function WorkspaceRuntimeProvider({ selectedCardsContext, // Pre-formatted context (client-side) instead of IDs replySelections, selectedActions, + genTypes, }, headers: { // Headers for static context if needed }, }); return transport; - }, [workspaceId, selectedModelId, activeFolderId, selectedCardsContext, replySelections, selectedActions]), + }, [workspaceId, selectedModelId, activeFolderId, selectedCardsContext, replySelections, selectedActions, genTypes]), adapters: { attachments: new SupabaseAttachmentAdapter(), }, diff --git a/src/components/home/HomePromptInput.tsx b/src/components/home/HomePromptInput.tsx index 90707ea..4410904 100644 --- a/src/components/home/HomePromptInput.tsx +++ b/src/components/home/HomePromptInput.tsx @@ -7,7 +7,7 @@ import { toast } from "sonner"; import { useCreateWorkspaceFromPrompt } from "@/hooks/workspace/use-create-workspace"; import type { UploadedPdfMetadata } from "@/hooks/workspace/use-pdf-upload"; // import { useImageUpload } from "@/hooks/workspace/use-image-upload"; -import { ArrowUp, FileText, Loader2, X, Link as LinkIcon } from "lucide-react"; +import { ArrowUp, FileText, Loader2, X, Link as LinkIcon, SlidersHorizontal } from "lucide-react"; // import { ImageIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; @@ -21,7 +21,97 @@ import { DialogFooter, DialogClose, } from "@/components/ui/dialog"; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import type { PdfData } from "@/lib/workspace-state/types"; +import { defaultDataFor } from "@/lib/workspace-state/item-helpers"; + +// --- Generation Settings --- +type GenContentType = 'note' | 'quiz' | 'flashcard' | 'youtube'; + +interface GenerationSettings { + auto: boolean; + types: GenContentType[]; +} + +const DEFAULT_GEN_SETTINGS: GenerationSettings = { + auto: true, + types: ['note', 'quiz', 'flashcard', 'youtube'], +}; + +const ALL_CONTENT_TYPES: { key: GenContentType; label: string }[] = [ + { key: 'note', label: 'Notes' }, + { key: 'quiz', label: 'Quizzes' }, + { key: 'flashcard', label: 'Flashcards' }, + { key: 'youtube', label: 'YouTube Videos' }, +]; + +const VALID_GEN_TYPES = new Set(ALL_CONTENT_TYPES.map(t => t.key)); + +/** + * Build placeholder workspace items for selected content types. + */ +function buildPlaceholderItems( + effectiveTypes: Set, + startY: number, + heights: { note: number; flashcard: number }, +): any[] { + const placeholders: any[] = []; + let currentY = startY; + + if (effectiveTypes.has('note')) { + placeholders.push({ + id: crypto.randomUUID(), + type: 'note', + name: 'Update me', + subtitle: '', + color: '#10B981', + layout: { x: 0, y: currentY, w: 4, h: heights.note }, + data: { blockContent: [], field1: '' }, + }); + currentY += heights.note; + } + + if (effectiveTypes.has('quiz')) { + placeholders.push({ + id: crypto.randomUUID(), + type: 'quiz', + name: 'Update me', + subtitle: '', + color: '#F59E0B', + layout: { x: 0, y: currentY, w: 2, h: 13 }, + data: { questions: [] }, + }); + } + + if (effectiveTypes.has('flashcard')) { + placeholders.push({ + id: crypto.randomUUID(), + type: 'flashcard', + name: 'Update me', + subtitle: '', + color: '#EC4899', + layout: { x: effectiveTypes.has('quiz') ? 2 : 0, y: currentY, w: 2, h: heights.flashcard }, + data: defaultDataFor('flashcard'), + }); + } + + return placeholders; +} + +function loadGenSettings(): GenerationSettings { + if (typeof window === 'undefined') return DEFAULT_GEN_SETTINGS; + try { + const stored = localStorage.getItem('thinkex-gen-settings'); + if (stored) { + const parsed = JSON.parse(stored); + return { + auto: typeof parsed.auto === 'boolean' ? parsed.auto : true, + types: Array.isArray(parsed.types) ? parsed.types.filter((t: string) => VALID_GEN_TYPES.has(t)) : DEFAULT_GEN_SETTINGS.types, + }; + } + } catch {} + return DEFAULT_GEN_SETTINGS; +} // import type { ImageData } from "@/lib/workspace-state/types"; const PLACEHOLDER_OPTIONS = [ @@ -69,9 +159,27 @@ export function HomePromptInput({ shouldFocus, uploadedFiles, isUploading, remov const [typedPrefix, setTypedPrefix] = useState(""); const [isUrlDialogOpen, setIsUrlDialogOpen] = useState(false); const [urlInput, setUrlInput] = useState(""); + const [genSettings, setGenSettings] = useState(loadGenSettings); const inputRef = useRef(null); const typingKeyRef = useRef(0); + // Persist generation settings to localStorage + useEffect(() => { + try { + localStorage.setItem('thinkex-gen-settings', JSON.stringify(genSettings)); + } catch {} + }, [genSettings]); + + const toggleGenType = (key: GenContentType) => { + setGenSettings(prev => { + const has = prev.types.includes(key); + return { + ...prev, + types: has ? prev.types.filter(t => t !== key) : [...prev.types, key], + }; + }); + }; + const createFromPrompt = useCreateWorkspaceFromPrompt(); // const { // uploadFiles: uploadImages, @@ -175,6 +283,15 @@ export function HomePromptInput({ shouldFocus, uploadedFiles, isUploading, remov const hasUploads = uploadedFiles.length > 0; + // Determine effective content types based on settings + // If auto or no valid types selected, use all defaults + const effectiveTypes = new Set( + !genSettings.auto && genSettings.types.length > 0 + ? genSettings.types + : ['note', 'quiz', 'flashcard', 'youtube'] + ); + const isCustom = !genSettings.auto && genSettings.types.length > 0; + // Construct initial state with file cards AND empty placeholder cards if files were uploaded let initialState = undefined; if (hasUploads) { @@ -186,7 +303,7 @@ export function HomePromptInput({ shouldFocus, uploadedFiles, isUploading, remov type: 'pdf' as const, name: file.name, subtitle: '', - color: '#6366F1' as const, // Indigo for PDFs + color: '#6366F1' as const, layout: { x: 0, y: index * fileHeight, w: 4, h: fileHeight }, lastSource: 'user' as const, data: { @@ -198,61 +315,11 @@ export function HomePromptInput({ shouldFocus, uploadedFiles, isUploading, remov const totalUploadY = uploadedFiles.length * fileHeight; - // const pdfEndY = uploadedFiles.length * fileHeight; - - // // Create Image card items (stacked below PDFs) - // const imageItems = uploadedImages.map((file, index) => ({ - // id: crypto.randomUUID(), - // type: 'image' as const, - // name: file.name, - // subtitle: '', - // color: '#8B5CF6' as const, // Violet for Images - // layout: { x: 0, y: pdfEndY + index * fileHeight, w: 4, h: fileHeight }, - // lastSource: 'user' as const, - // data: { - // fileUrl: file.fileUrl, - // filename: file.filename, - // fileSize: file.fileSize, - // } as ImageData, - // })); - - // const totalUploadY = pdfEndY + uploadedImages.length * fileHeight; - - // Create empty placeholder cards with fixed layout and colors - const emptyNote = { - id: crypto.randomUUID(), - type: 'note' as const, - name: 'Update me', - subtitle: '', - color: '#10B981' as const, - layout: { x: 0, y: totalUploadY, w: 4, h: 13 }, - lastSource: 'user' as const, - data: { blockContent: [], field1: '' }, - }; + // Build placeholder cards based on effective types, stacking layouts dynamically + const placeholders = buildPlaceholderItems(effectiveTypes, totalUploadY, { note: 13, flashcard: 8 }) + .map(item => ({ ...item, lastSource: 'user' as const })); - const emptyQuiz = { - id: crypto.randomUUID(), - type: 'quiz' as const, - name: 'Update me', - subtitle: '', - color: '#F59E0B' as const, - layout: { x: 0, y: totalUploadY + 13, w: 2, h: 13 }, - lastSource: 'user' as const, - data: { questions: [] }, - }; - - const emptyFlashcard = { - id: crypto.randomUUID(), - type: 'flashcard' as const, - name: 'Update me', - subtitle: '', - color: '#EC4899' as const, - layout: { x: 2, y: totalUploadY + 13, w: 2, h: 8 }, - lastSource: 'user' as const, - data: { cards: [] }, - }; - - const allItems = [...pdfItems, emptyNote, emptyQuiz, emptyFlashcard]; + const allItems: any[] = [...pdfItems, ...placeholders]; initialState = { workspaceId: '', @@ -263,13 +330,34 @@ export function HomePromptInput({ shouldFocus, uploadedFiles, isUploading, remov }; } + // For no-uploads path with custom settings, build a custom initial state + // instead of using the getting_started template + let template: "blank" | "getting_started"; + if (hasUploads) { + template = "blank"; + } else if (isCustom) { + template = "blank"; + const placeholders = buildPlaceholderItems(effectiveTypes, 0, { note: 9, flashcard: 9 }); + + if (placeholders.length > 0) { + initialState = { + workspaceId: '', + globalTitle: '', + globalDescription: '', + items: placeholders, + itemsCreated: placeholders.length, + }; + } + } else { + template = "getting_started"; + } + createFromPrompt.mutate(prompt, { - template: hasUploads ? "blank" : "getting_started", + template, initialState, onSuccess: (workspace) => { typingKeyRef.current += 1; clearFiles(); - // clearImages(); const url = `/workspace/${workspace.slug}`; const params = new URLSearchParams(); @@ -279,6 +367,11 @@ export function HomePromptInput({ shouldFocus, uploadedFiles, isUploading, remov params.set('createFrom', prompt); } + // Pass custom generation types so AssistantPanel builds the right prompt + if (isCustom) { + params.set('genTypes', Array.from(effectiveTypes).join(',')); + } + router.push(`${url}?${params.toString()}`); }, onError: (err) => { @@ -488,18 +581,97 @@ export function HomePromptInput({ shouldFocus, uploadedFiles, isUploading, remov Add URL - {/* */} + +
+ { + // Snap auto back on if user closes popover with no types selected + if (!open && !genSettings.auto && genSettings.types.length === 0) { + setGenSettings(prev => ({ ...prev, auto: true })); + } + }}> + + + + e.stopPropagation()} + > +
+ {ALL_CONTENT_TYPES.map(({ key, label }) => { + const active = genSettings.types.includes(key); + return ( + + ); + })} +
+
+ + + +