diff --git a/src/app/api/cards/from-message/route.ts b/src/app/api/cards/from-message/route.ts index 652f1808..a524868c 100644 --- a/src/app/api/cards/from-message/route.ts +++ b/src/app/api/cards/from-message/route.ts @@ -24,7 +24,25 @@ export async function POST(request: NextRequest) { const userId = session.user.id; const body = await request.json(); - const { content, workspaceId, folderId } = body; + const { content, workspaceId, folderId, sources } = body; + + if (sources !== undefined) { + const hasInvalidSource = + !Array.isArray(sources) || + sources.some( + (source) => + !source || + typeof source.title !== "string" || + typeof source.url !== "string" + ); + + if (hasInvalidSource) { + return NextResponse.json( + { error: "Sources must be an array of { title, url } objects" }, + { status: 400 } + ); + } + } @@ -104,6 +122,7 @@ Return ONLY the reformatted note content in markdown format. Do not include any workspaceId, title, content: cleanedContent, + sources, folderId, }); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index e65a3d12..9d25049e 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -143,8 +143,83 @@ If the user asks to "add a youtube video" or "search for a video" but does not p CONFIDENCE THRESHOLD: If you are uncertain about a fact's accuracy or currency, prefer to search rather than risk providing outdated information. + CITATION REQUIREMENT: -When using search results (grounding), you must include the date of each article/source if available.`); +When using search results (grounding), you must include the date of each article/source if available. + +CRITICAL: USE WEB SEARCH TOOL FOR RESEARCH-BASED NOTES: +When the user asks you to create or update a note about a topic that requires current information or research (e.g., "India China relations", "latest AI trends", "recent developments in..."), you MUST: +1. FIRST call the webSearch tool to gather information +2. THEN create/update the note using that information +3. Extract sources from the webSearch tool result + +This is MANDATORY because automatic grounding does not provide source URLs for attribution. + +SOURCE EXTRACTION REQUIREMENT - CRITICAL: +When creating OR updating a note, you MUST ALWAYS extract and pass sources using the 'sources' parameter. + +WHEN TO EXTRACT SOURCES: +1. **Web Search Tool Results**: When you call webSearch, extract sources from the grounding metadata in the response + - Example prompts that REQUIRE webSearch: "latest AI trends", "India China relations", "recent developments in...", any topic-based research + - The webSearch tool returns groundingMetadata with sources - YOU MUST EXTRACT THESE + +2. **User-Provided URLs**: If the user provided a URL that you read/analyzed (via processUrls tool) + - Example: "Summarize https://example.com" → MUST include example.com as a source + +HOW TO EXTRACT SOURCES FROM WEBSEARCH: +The webSearch tool returns a JSON string. You MUST parse it correctly to extract REAL URLs, not make them up! + +Structure of the response: +{ + "text": "...", + "groundingMetadata": { + "groundingChunks": [ + { + "web": { + "uri": "https://actual-real-url.com/article", // ← EXTRACT THIS + "title": "Actual Page Title" // ← EXTRACT THIS + } + } + ] + } +} + +PARSING CODE EXAMPLE: + const result = await webSearch("India China relations"); + const parsed = JSON.parse(result); + const chunks = parsed.groundingMetadata?.groundingChunks || []; + const sources = chunks.map(chunk => ({ + title: chunk.web?.title || "Untitled", + url: chunk.web?.uri || "" + })).filter(s => s.url); + +CRITICAL: You MUST extract chunk.web.uri for the URL. DO NOT make up URLs. DO NOT hallucinate URLs. +If groundingChunks is missing or empty, skip source extraction for that query. + +HANDLING REDIRECT URLs: +⚠️ IMPORTANT: Some chunk.web.uri values may contain temporary redirect URLs like "https://vertexaisearch.cloud.google.com/grounding-api-redirect/..." + +Do NOT construct URLs from titles or domains. Do NOT guess. Use chunk.web.uri as provided. +If a redirect URL is the only available source, include it rather than dropping all sources. + +NOTE CONTENT RULES: +🚫 DO NOT include sources, references, or citations in the note content itself. +🚫 DO NOT add "Sources:", "References:", or "Citations:" sections to the markdown. +The sources parameter will be displayed separately by the UI. Keep note content clean and focused on the topic. + +EXAMPLES: +✅ CORRECT - Creating note about "India China relations": + 1. Call webSearch("India China relations current border dispute") + 2. Extract sources from groundingMetadata + 3. createNote/updateNote with sources: [ + { title: "India-China Border Dispute Explained", url: "https://bbc.com/news/india-china..." }, + { title: "Galwan Valley Clash 2020", url: "https://reuters.com/world/india..." } + ] + +❌ WRONG - Creating note without calling webSearch or providing sources: + sources: undefined // This is NOT ACCEPTABLE + +This is ABSOLUTELY MANDATORY for both createNote AND updateNote tools. NO EXCEPTIONS.`); // Add file detection hint if file URLs are present if (fileUrls.length > 0) { diff --git a/src/app/api/notes/create-from-urls/route.ts b/src/app/api/notes/create-from-urls/route.ts new file mode 100644 index 00000000..ffc160eb --- /dev/null +++ b/src/app/api/notes/create-from-urls/route.ts @@ -0,0 +1,151 @@ +import { google } from "@ai-sdk/google"; +import { generateText } from "ai"; +import { auth } from "@/lib/auth"; +import { workspaceWorker } from "@/lib/ai/workers"; +import { logger } from "@/lib/utils/logger"; +import { headers } from "next/headers"; +import { z } from "zod"; + +const createFromUrlsSchema = z.object({ + urls: z.array(z.string().url()).min(1).max(10), + workspaceId: z.string().uuid(), + folderId: z.string().uuid().optional(), +}); + + +export async function POST(req: Request) { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }); + } + + const body = await req.json(); + const parseResult = createFromUrlsSchema.safeParse(body); + + if (!parseResult.success) { + return new Response(JSON.stringify({ error: "Invalid request body", details: parseResult.error.flatten() }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const { urls, workspaceId, folderId } = parseResult.data; + + logger.info("📝 [API] Creating note from URLs:", { workspaceId, urlCount: urls.length }); + + // Use Google's URL Context API to analyze the content + const tools: any = { + url_context: google.tools.urlContext({}), + }; + + const promptText = `Analyze the content from the following article URL(s) and create a comprehensive study note. + +URLs to analyze: +${urls.map((url, i) => `${i + 1}. ${url}`).join('\n')} + +Provide your response in this exact format with clear delimiters: + +===TITLE=== +[Your clear, informative title here] + +===CONTENT=== +[Your detailed markdown content here with proper headings, bullet points, etc.] + +===SOURCES=== +[One source per line in format: Title | URL] + +Make sure to: +- Generate a clear title that captures the main topic +- Create comprehensive markdown content synthesizing key information from all articles +- Use proper markdown formatting (headings, bullet points, etc.) +- Include all URLs in the sources section with their actual page titles`; + + const { text } = await generateText({ + model: google("gemini-2.5-flash"), + tools, + prompt: promptText, + }); + + logger.debug("📝 [API] LLM response received:", { textLength: text?.length }); + + // Parse the delimited response + let title = "Article Summary"; + let content = ""; + let sources: Array<{ title: string; url: string }> = []; + + try { + const titleMatch = text.match(/===TITLE===\s*\n(.*?)(?:\n|$)/s); + if (titleMatch) { + title = titleMatch[1].trim(); + } + + const contentMatch = text.match(/===CONTENT===\s*\n([\s\S]*?)\n\n===/); + if (contentMatch) { + content = contentMatch[1].trim(); + } + + const sourcesMatch = text.match(/===SOURCES===\s*\n([\s\S]*?)(?:\n\n|$)/); + if (sourcesMatch) { + const sourcesText = sourcesMatch[1].trim(); + const sourceLines = sourcesText.split('\n').filter((line: string) => line.trim()); + sources = sourceLines.map((line: string) => { + const parts = line.split('|').map((p: string) => p.trim()); + if (parts.length >= 2) { + return { title: parts[0], url: parts[1] }; + } + // Fallback if format is different + return null; + }).filter((s: { title: string; url: string } | null): s is { title: string; url: string } => s !== null); + } + } catch (parseError) { + logger.error("📝 [API] Failed to parse delimited response:", parseError); + content = text || "Failed to generate content from the provided URLs."; + } + + // Ensure sources are populated with the original URLs if missing + if (sources.length === 0) { + sources = urls.map(url => { + try { + const hostname = new URL(url).hostname; + return { title: hostname, url }; + } catch { + return { title: url, url }; + } + }); + } + + // Create the note using workspace worker + const workerResult = await workspaceWorker("create", { + workspaceId, + title, + content, + sources, + folderId, + }); + + if (!workerResult.success) { + return new Response(JSON.stringify({ error: workerResult.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + logger.info("📝 [API] Note created from URLs successfully:", { itemId: workerResult.itemId }); + + return new Response(JSON.stringify(workerResult), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + } catch (error) { + logger.error("❌ [API] Error creating note from URLs:", error); + return new Response(JSON.stringify({ error: error instanceof Error ? error.message : "Internal server error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +} diff --git a/src/components/modals/CreateArticleDialog.tsx b/src/components/modals/CreateArticleDialog.tsx new file mode 100644 index 00000000..3f1d456f --- /dev/null +++ b/src/components/modals/CreateArticleDialog.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +interface CreateArticleDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string; + folderId?: string; + onNoteCreated?: (noteId: string) => void; +} + +/** + * Validates if a string is a valid HTTP/HTTPS URL + */ +function isValidUrl(str: string): boolean { + try { + const url = new URL(str.trim()); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +export function CreateArticleDialog({ + open, + onOpenChange, + workspaceId, + folderId, + onNoteCreated, +}: CreateArticleDialogProps) { + const [urlsText, setUrlsText] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const textareaRef = useRef(null); + + // Parse and validate URLs from textarea + const parseUrls = useCallback(() => { + const lines = urlsText.split("\n").map((line) => line.trim()).filter(Boolean); + const validUrls: string[] = []; + const invalidLines: string[] = []; + + for (const line of lines) { + if (isValidUrl(line)) { + validUrls.push(line); + } else { + invalidLines.push(line); + } + } + + return { validUrls, invalidLines }; + }, [urlsText]); + + const { validUrls, invalidLines } = parseUrls(); + const hasValidUrls = validUrls.length > 0; + + const handleSubmit = useCallback(async () => { + if (!hasValidUrls || isCreating) return; + + if (invalidLines.length > 0) { + toast.warning(`Skipping ${invalidLines.length} invalid URL(s)`); + } + + // Close dialog immediately for non-blocking UX + onOpenChange(false); + setUrlsText(""); + + // Show loading toast + const toastId = toast.loading("Creating note from articles...", { + description: `Processing ${validUrls.length} URL${validUrls.length > 1 ? 's' : ''}`, + }); + + setIsCreating(true); + + try { + const response = await fetch("/api/notes/create-from-urls", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + urls: validUrls, + workspaceId, + folderId, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `Request failed with status ${response.status}`); + } + + const result = await response.json(); + + if (result.success && result.itemId) { + toast.success("Article note created!", { + id: toastId, + description: "Your note is ready", + }); + onNoteCreated?.(result.itemId); + } else { + throw new Error(result.message || "Failed to create note"); + } + } catch (error) { + console.error("Error creating article note:", error); + toast.error("Failed to create note from articles", { + id: toastId, + description: error instanceof Error ? error.message : undefined, + }); + } finally { + setIsCreating(false); + } + }, [validUrls, hasValidUrls, isCreating, invalidLines.length, workspaceId, folderId, onNoteCreated, onOpenChange]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Cmd/Ctrl + Enter to submit + if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && hasValidUrls && !isCreating) { + e.preventDefault(); + handleSubmit(); + } else if (e.key === "Escape") { + onOpenChange(false); + } + }, + [hasValidUrls, isCreating, handleSubmit, onOpenChange] + ); + + // Reset form when dialog opens + useEffect(() => { + if (open) { + setUrlsText(""); + setIsCreating(false); + // Focus textarea after dialog opens + setTimeout(() => textareaRef.current?.focus(), 100); + } + }, [open]); + + return ( + + + + Create Note from Articles + + Paste one or more article URLs (one per line). A note will be created with content synthesized from these articles. + + + +
+
+ +