From 6eb40999d75d21542874142da03fa3f398c2e687 Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sat, 31 Jan 2026 19:00:58 -0500 Subject: [PATCH 1/7] note can contain sources --- src/app/api/chat/route.ts | 10 ++- .../workspace-canvas/CardRenderer.tsx | 37 +++++---- .../workspace-canvas/SourcesDisplay.tsx | 60 ++++++++++++++ .../workspace-canvas/WorkspaceCard.tsx | 78 +++++++++++++++---- src/lib/ai/tools/workspace-tools.ts | 12 ++- src/lib/ai/workers/workspace-worker.ts | 15 ++++ src/lib/workspace-state/types.ts | 11 +++ 7 files changed, 189 insertions(+), 34 deletions(-) create mode 100644 src/components/workspace-canvas/SourcesDisplay.tsx diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index e65a3d12..30d5699c 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -143,8 +143,16 @@ 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. + +SOURCE EXTRACTION REQUIREMENT: +When creating a note that uses information from web search or grounding results, you MUST extract the sources and pass them to the createNote tool using the 'sources' parameter. Each source should include: +- title: The title of the source article/page +- url: The full URL of the source +Format: Pass an array of source objects like [{ title: "Article Title", url: "https://..." }, ...] +This is MANDATORY whenever you use search results to generate note content.`); // Add file detection hint if file URLs are present if (fileUrls.length > 0) { diff --git a/src/components/workspace-canvas/CardRenderer.tsx b/src/components/workspace-canvas/CardRenderer.tsx index cef05690..5b4d08ec 100644 --- a/src/components/workspace-canvas/CardRenderer.tsx +++ b/src/components/workspace-canvas/CardRenderer.tsx @@ -7,6 +7,7 @@ import { DynamicBlockNoteEditor } from "@/components/editor/DynamicBlockNoteEdit import { plainTextToBlocks, type Block } from "@/components/editor/BlockNoteEditor"; import FlashcardContent from "./FlashcardContent"; import YouTubeCardContent from "./YouTubeCardContent"; +import { SourcesDisplay } from "./SourcesDisplay"; import { QuizContent } from "./QuizContent"; @@ -51,20 +52,28 @@ export function CardRenderer(props: { }] as unknown as Block[]; }, [noteData.blockContent, noteData.field1, item.id, item.lastSource]); return ( - { - onUpdateData(() => ({ - blockContent: blocks, - field1: blocksToPlainText(blocks), - })); - }} - /> + <> + { + onUpdateData(() => ({ + blockContent: blocks, + field1: blocksToPlainText(blocks), + })); + }} + /> + {/* Sources section - only shown if sources exist */} + {noteData.sources && noteData.sources.length > 0 && ( +
+ +
+ )} + ); } diff --git a/src/components/workspace-canvas/SourcesDisplay.tsx b/src/components/workspace-canvas/SourcesDisplay.tsx new file mode 100644 index 00000000..eb065745 --- /dev/null +++ b/src/components/workspace-canvas/SourcesDisplay.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { Source } from "@/lib/workspace-state/types"; +import { ExternalLink } from "lucide-react"; + +interface SourcesDisplayProps { + sources: Source[]; +} + +/** + * SourcesDisplay - Renders a polished "References" section for notes + * Displays sources as a grid of clickable cards at the bottom of note content + */ +export function SourcesDisplay({ sources }: SourcesDisplayProps) { + if (!sources || sources.length === 0) { + return null; + } + + return ( +
+

+ References ({sources.length}) +

+
+ {sources.map((source, index) => { + let hostname = ""; + try { + hostname = new URL(source.url).hostname; + } catch { + hostname = source.url; + } + + return ( + +
+ +
+
+
+ {source.title} +
+
+ {hostname} +
+
+
+ ); + })} +
+
+ ); +} + +export default SourcesDisplay; diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 4b059dd3..a9b29a62 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -1,5 +1,5 @@ import { QuizContent } from "./QuizContent"; -import { MoreVertical, Trash2, Palette, CheckCircle2, FolderInput, FileText, Copy, X, Pencil, Columns } from "lucide-react"; +import { MoreVertical, Trash2, Palette, CheckCircle2, FolderInput, FileText, Copy, X, Pencil, Columns, Link2 } from "lucide-react"; import { PiMouseScrollFill, PiMouseScrollBold } from "react-icons/pi"; import { useCallback, useState, memo, useRef, useEffect, useMemo } from "react"; import { toast } from "sonner"; @@ -743,22 +743,66 @@ function WorkspaceCard({
{item.type !== 'youtube' && !(item.type === 'pdf' && shouldShowPreview) && ( - - ) - } + <> + + + + + {/* Sources Section - only shown when card is wide */} + {item.type === 'note' && shouldShowPreview && (() => { + const noteData = item.data as NoteData; + const hasSources = noteData.sources && noteData.sources.length > 0; + + if (!hasSources) return null; + + return ( +
+ {noteData.sources!.map((source, index) => { + let hostname = ""; + try { + hostname = new URL(source.url).hostname.replace('www.', ''); + } catch { + hostname = source.url; + } + + return ( + { + e.stopPropagation(); + }} + onClick={(e) => { + e.stopPropagation(); + }} + > + + {hostname} + + ); + })} +
+ ); + })()} + + )} {/* Subtle type label for narrow cards without preview */} {/* Subtle type label for narrow cards without preview */} {(item.type === 'note' || item.type === 'pdf' || item.type === 'quiz') && !shouldShowPreview && ( diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 024e2695..963ac3e4 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -23,9 +23,16 @@ export function createNoteTool(ctx: WorkspaceToolContext) { z.object({ title: z.string().describe("The title of the note card"), content: z.string().describe("The markdown body content. DO NOT repeat title in content. Start with subheadings/text. No Mermaid. Use $$...$$ for ALL math (both inline and block). Single $ is for currency only."), + 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 deep research"), }) ), - execute: async ({ title, content }) => { + execute: async ({ title, content, sources }) => { // Validate inputs before use if (!title || typeof title !== 'string') { return { @@ -40,7 +47,7 @@ export function createNoteTool(ctx: WorkspaceToolContext) { }; } - logger.debug("🎯 [ORCHESTRATOR] Delegating to Workspace Worker (create note):", { title, contentLength: content.length }); + logger.debug("🎯 [ORCHESTRATOR] Delegating to Workspace Worker (create note):", { title, contentLength: content.length, sourcesCount: sources?.length }); if (!ctx.workspaceId) { return { @@ -53,6 +60,7 @@ export function createNoteTool(ctx: WorkspaceToolContext) { workspaceId: ctx.workspaceId, title, content, + sources, folderId: ctx.activeFolderId, }); }, diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 0db67c49..c6d4e614 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -88,6 +88,12 @@ export async function workspaceWorker( prompt: string; interactionId: string; }; + // Optional: sources from web search or deep research + sources?: Array<{ + title: string; + url: string; + favicon?: string; + }>; folderId?: string; } ): Promise<{ success: boolean; message: string; itemId?: string; cardsAdded?: number; cardCount?: number; event?: WorkspaceEvent; version?: number }> { @@ -214,6 +220,15 @@ export async function workspaceWorker( thoughts: [], }; } + + // If sources are provided, attach them + if (params.sources && params.sources.length > 0) { + itemData.sources = params.sources; + logger.debug("📚 [WORKSPACE-WORKER] Attaching sources to note:", { + count: params.sources.length, + sources: params.sources, + }); + } } const item: Item = { diff --git a/src/lib/workspace-state/types.ts b/src/lib/workspace-state/types.ts index ccccd3cb..e9b5e6e3 100644 --- a/src/lib/workspace-state/types.ts +++ b/src/lib/workspace-state/types.ts @@ -2,9 +2,20 @@ import type { CardColor } from './colors'; export type CardType = "note" | "pdf" | "flashcard" | "folder" | "youtube" | "quiz"; +/** + * Source attribution for notes created from web search or deep research + */ +export interface Source { + title: string; // Title of the source page + url: string; // URL of the source + favicon?: string; // Optional favicon URL +} + export interface NoteData { field1?: string; // textarea - legacy plain text format blockContent?: unknown; // BlockNote JSON blocks - new rich-text format + // Optional: Sources from web search or deep research + sources?: Source[]; // Optional: Deep Research metadata (when this note is a research result) deepResearch?: { prompt: string; // Original research prompt From 601784cb8be06f7c497662af27dadc6b6a1d1bcc Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sat, 31 Jan 2026 22:21:58 -0500 Subject: [PATCH 2/7] link for prompt to workspace --- src/app/api/chat/route.ts | 7 ++-- src/lib/ai/tools/workspace-tools.ts | 10 +++++- src/lib/ai/workers/workspace-worker.ts | 12 +++++++ src/lib/workspace/templates.ts | 44 +++++++++++++++++++++++--- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 30d5699c..7d971de1 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -148,11 +148,14 @@ CITATION REQUIREMENT: When using search results (grounding), you must include the date of each article/source if available. SOURCE EXTRACTION REQUIREMENT: -When creating a note that uses information from web search or grounding results, you MUST extract the sources and pass them to the createNote tool using the 'sources' parameter. Each source should include: +When creating OR updating a note, you MUST extract and pass sources using the 'sources' parameter in these scenarios: +1. **Web Search/Grounding**: If you used web search or grounding results to generate note content +2. **User-Provided URLs**: If the user provided a URL that you read/analyzed (via processUrls tool) to create or update the note +Each source should include: - title: The title of the source article/page - url: The full URL of the source Format: Pass an array of source objects like [{ title: "Article Title", url: "https://..." }, ...] -This is MANDATORY whenever you use search results to generate note content.`); +This is MANDATORY for both createNote AND updateNote tools when using web search results or user-provided URLs.`); // Add file detection hint if file URLs are present if (fileUrls.length > 0) { diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 963ac3e4..a6673839 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -78,9 +78,16 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) { noteName: z.string().describe("The name of the note to update (will be matched using fuzzy search)"), content: z.string().describe("The full note body ONLY (do not include the title as a header). Use $$...$$ for ALL math."), 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; content: string; title?: string }) => { + execute: async (input: { noteName: string; content: string; title?: string; sources?: Array<{ title: string; url: string; favicon?: string }> }) => { const noteName = input.noteName; const content = input.content; const title = input.title; @@ -137,6 +144,7 @@ export function createUpdateNoteTool(ctx: WorkspaceToolContext) { itemId: matchedNote.id, content: content, title: title, + sources: input.sources, }); if (workerResult.success) { diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index c6d4e614..d302348a 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -390,6 +390,18 @@ export async function workspaceWorker( } as NoteData; } + // Update sources if provided + if (params.sources !== undefined) { + if (!changes.data) { + changes.data = {} as NoteData; + } + (changes.data as NoteData).sources = params.sources; + logger.debug("📚 [UPDATE-NOTE] Updating sources:", { + count: params.sources.length, + sources: params.sources, + }); + } + // If no changes, return early if (Object.keys(changes).length === 0) { logger.warn("⚠️ [UPDATE-NOTE] No changes detected, returning early"); diff --git a/src/lib/workspace/templates.ts b/src/lib/workspace/templates.ts index afa47165..56236de3 100644 --- a/src/lib/workspace/templates.ts +++ b/src/lib/workspace/templates.ts @@ -39,19 +39,55 @@ export const WORKSPACE_TEMPLATES: TemplateDefinition[] = [ { id: "sample-note-1", type: "note", - name: "Note", - subtitle: "", + name: "Prompt to Workspace", + subtitle: "AI-Generated Note", data: { blockContent: [ { id: "nb1", type: "paragraph", props: { backgroundColor: "default", textColor: "default", textAlignment: "left" }, - content: [], + content: [ + { + type: "text", + text: "Try typing a prompt like ", + styles: {} + }, + { + type: "text", + text: "\"Research AI trends and create a note\"", + styles: { italic: true } + }, + { + type: "text", + text: " or ", + styles: {} + }, + { + type: "text", + text: "\"Summarize this article: [URL]\"", + styles: { italic: true } + }, + { + type: "text", + text: ". When notes are generated from web searches or URLs, they'll include source links like the ones shown above.", + styles: {} + } + ], children: [] } ], - field1: "" + field1: "", + sources: [ + { + title: "ThinkEx Documentation", + url: "https://github.com/thinkex/docs" + }, + { + title: "Getting Started Guide", + url: "https://example.com/getting-started" + } + ] }, color: sampleColors[0], layout: { x: 0, y: 0, w: 4, h: 9 }, From 6c008ae00c8908b9bbd6d7d59b6cf3252a205379 Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sun, 1 Feb 2026 02:41:06 -0500 Subject: [PATCH 3/7] prompt to workspace links fixed --- src/app/api/chat/route.ts | 82 ++++++++++++++++++++++++---- src/lib/ai/tools/web-search.ts | 87 +++++++++++++++++++++++++++++- src/lib/workspace/event-reducer.ts | 23 +++++--- 3 files changed, 174 insertions(+), 18 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 7d971de1..9d25049e 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -147,15 +147,79 @@ If you are uncertain about a fact's accuracy or currency, prefer to search rathe CITATION REQUIREMENT: When using search results (grounding), you must include the date of each article/source if available. -SOURCE EXTRACTION REQUIREMENT: -When creating OR updating a note, you MUST extract and pass sources using the 'sources' parameter in these scenarios: -1. **Web Search/Grounding**: If you used web search or grounding results to generate note content -2. **User-Provided URLs**: If the user provided a URL that you read/analyzed (via processUrls tool) to create or update the note -Each source should include: -- title: The title of the source article/page -- url: The full URL of the source -Format: Pass an array of source objects like [{ title: "Article Title", url: "https://..." }, ...] -This is MANDATORY for both createNote AND updateNote tools when using web search results or user-provided URLs.`); +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/lib/ai/tools/web-search.ts b/src/lib/ai/tools/web-search.ts index 1bcf5411..abf70660 100644 --- a/src/lib/ai/tools/web-search.ts +++ b/src/lib/ai/tools/web-search.ts @@ -2,6 +2,64 @@ import { z } from "zod"; import { tool, generateText, stepCountIs, zodSchema } from "ai"; import { google } from "@ai-sdk/google"; +const VERTEX_REDIRECT_HOST = "vertexaisearch.cloud.google.com"; +const VERTEX_REDIRECT_PATH = "/grounding-api-redirect/"; +const USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"; + +const isVertexRedirectUrl = (url: string) => { + try { + const parsed = new URL(url); + return parsed.hostname === VERTEX_REDIRECT_HOST && parsed.pathname.startsWith(VERTEX_REDIRECT_PATH); + } catch { + return false; + } +}; + +const resolveRedirectUrl = async (url: string) => { + const maxHops = 6; + let current = url; + + for (let i = 0; i < maxHops; i += 1) { + try { + const response = await fetch(current, { + method: "GET", + redirect: "manual", + headers: { + "user-agent": USER_AGENT, + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + }, + signal: AbortSignal.timeout(6000), + }); + + const location = response.headers.get("location"); + if (response.status >= 300 && response.status < 400 && location) { + const next = new URL(location, current).toString(); + if (next === current) break; + current = next; + continue; + } + + return response.url || current; + } catch { + break; + } + } + + try { + const response = await fetch(url, { + method: "GET", + redirect: "follow", + headers: { "user-agent": USER_AGENT }, + signal: AbortSignal.timeout(6000), + }); + return response.url || null; + } catch { + return null; + } +}; + /** * Create a custom web search tool that uses a lightweight model to perform the search * and synthesize results before returning to the main conversation. @@ -27,10 +85,37 @@ Please use the search tool to find current, accurate information and provide a d stopWhen: stepCountIs(10), // Allow the model steps to search and synthesize }); + const groundingMetadata = (providerMetadata?.google as any)?.groundingMetadata; + const groundingChunks = groundingMetadata?.groundingChunks as any[] | undefined; + + const resolvedChunks = groundingChunks + ? await Promise.all( + groundingChunks.map(async (chunk) => { + const uri = chunk?.web?.uri; + if (!uri || !isVertexRedirectUrl(uri)) return chunk; + + const resolved = await resolveRedirectUrl(uri); + if (!resolved || isVertexRedirectUrl(resolved)) return chunk; + + return { + ...chunk, + web: { + ...chunk.web, + uri: resolved, + }, + }; + }) + ) + : groundingChunks; + + const resolvedMetadata = groundingMetadata + ? { ...groundingMetadata, groundingChunks: resolvedChunks } + : groundingMetadata; + // Return both text and metadata as a JSON string for the UI to parse return JSON.stringify({ text, - groundingMetadata: (providerMetadata?.google as any)?.groundingMetadata + groundingMetadata: resolvedMetadata }); }, }); diff --git a/src/lib/workspace/event-reducer.ts b/src/lib/workspace/event-reducer.ts index c2166137..18c0dca2 100644 --- a/src/lib/workspace/event-reducer.ts +++ b/src/lib/workspace/event-reducer.ts @@ -30,6 +30,13 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta ? { ...item, ...event.payload.changes, + // Deep merge data field to preserve existing properties like sources + data: event.payload.changes.data + ? { + ...item.data, // Preserve existing data properties + ...event.payload.changes.data, // Apply new data updates + } + : item.data, lastSource: event.payload.source // Propagate source to item state } : item @@ -81,7 +88,7 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta if (event.payload.items) { // Full items array format (used for bulk delete and item add/remove operations) const newItems = event.payload.items; - + // Find folders that were deleted (existed in old state but not in new state) const newItemIds = new Set(newItems.map(item => item.id)); const deletedFolderIds = new Set( @@ -89,18 +96,18 @@ export function eventReducer(state: AgentState, event: WorkspaceEvent): AgentSta .filter(item => item.type === 'folder' && !newItemIds.has(item.id)) .map(item => item.id) ); - + // If any folders were deleted, clear folderId and layout on orphaned items // This prevents items from pointing to non-existent folders // Layout is cleared so items get fresh positioning in root (same as when moving to folder) const cleanedItems = deletedFolderIds.size > 0 - ? newItems.map(item => - item.folderId && deletedFolderIds.has(item.folderId) - ? { ...item, folderId: undefined, layout: undefined } - : item - ) + ? newItems.map(item => + item.folderId && deletedFolderIds.has(item.folderId) + ? { ...item, folderId: undefined, layout: undefined } + : item + ) : newItems; - + return { ...state, items: cleanedItems, From 77ac601d641abacf53a6aadac6fbe3557dc4e19c Mon Sep 17 00:00:00 2001 From: 1shCha Date: Sun, 1 Feb 2026 21:30:17 -0500 Subject: [PATCH 4/7] changes --- src/app/api/cards/from-message/route.ts | 3 +- src/app/api/notes/create-from-urls/route.ts | 151 +++++++++++++ src/components/modals/CreateArticleDialog.tsx | 200 ++++++++++++++++++ .../workspace-canvas/CardRenderer.tsx | 2 +- .../workspace-canvas/SourcesDisplay.tsx | 99 +++++---- .../workspace-canvas/WorkspaceCard.tsx | 45 +--- .../workspace-canvas/WorkspaceHeader.tsx | 39 +++- src/hooks/ai/use-create-card-from-message.ts | 112 +++++++++- 8 files changed, 569 insertions(+), 82 deletions(-) create mode 100644 src/app/api/notes/create-from-urls/route.ts create mode 100644 src/components/modals/CreateArticleDialog.tsx diff --git a/src/app/api/cards/from-message/route.ts b/src/app/api/cards/from-message/route.ts index 652f1808..1e4d86cc 100644 --- a/src/app/api/cards/from-message/route.ts +++ b/src/app/api/cards/from-message/route.ts @@ -24,7 +24,7 @@ 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; @@ -104,6 +104,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/notes/create-from-urls/route.ts b/src/app/api/notes/create-from-urls/route.ts new file mode 100644 index 00000000..c561fffb --- /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\n===/); + 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. + + + +
+
+ +