From 54196683c6a28c3e1341a8997c4a97ee88812ff2 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 18 Jan 2026 15:24:20 -0500 Subject: [PATCH 1/3] refactor: extract chat tools into modular files; create tool factory pattern --- src/lib/ai/tools/deep-research.ts | 72 +++++++ src/lib/ai/tools/flashcard-tools.ts | 245 ++++++++++++++++++++++++ src/lib/ai/tools/index.ts | 76 ++++++++ src/lib/ai/tools/process-files.ts | 178 ++++++++++++++++++ src/lib/ai/tools/process-urls.ts | 98 ++++++++++ src/lib/ai/tools/search-code.ts | 34 ++++ src/lib/ai/tools/workspace-tools.ts | 280 ++++++++++++++++++++++++++++ 7 files changed, 983 insertions(+) create mode 100644 src/lib/ai/tools/deep-research.ts create mode 100644 src/lib/ai/tools/flashcard-tools.ts create mode 100644 src/lib/ai/tools/index.ts create mode 100644 src/lib/ai/tools/process-files.ts create mode 100644 src/lib/ai/tools/process-urls.ts create mode 100644 src/lib/ai/tools/search-code.ts create mode 100644 src/lib/ai/tools/workspace-tools.ts diff --git a/src/lib/ai/tools/deep-research.ts b/src/lib/ai/tools/deep-research.ts new file mode 100644 index 00000000..30ebf132 --- /dev/null +++ b/src/lib/ai/tools/deep-research.ts @@ -0,0 +1,72 @@ +import { GoogleGenAI } from "@google/genai"; +import { z } from "zod"; +import { logger } from "@/lib/utils/logger"; +import { workspaceWorker } from "@/lib/ai/workers"; +import type { WorkspaceToolContext } from "./workspace-tools"; + +/** + * Create the deepResearch tool + */ +export function createDeepResearchTool(ctx: WorkspaceToolContext) { + return { + description: "Perform deep, multi-step research on a complex topic. Use this when the user explicitly asks for 'deep research' or when a simple web search is insufficient for the depth required. This tool IMMEDIATELY creates a special research card in the workspace that will stream progress and display the final report. You should ask clarifying questions BEFORE calling this tool if the request is vague. Once ready, call this tool with the refined topic/prompt.", + inputSchema: z.object({ + prompt: z.string().describe("The detailed research topic and instructions."), + }), + execute: async ({ prompt }: { prompt: string }) => { + logger.debug("🎯 [DEEP-RESEARCH] Starting deep research for:", prompt); + + try { + const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; + if (!apiKey) { + throw new Error("GOOGLE_GENERATIVE_AI_API_KEY is not set"); + } + + if (!ctx.workspaceId) { + throw new Error("No workspace context available"); + } + + const client = new GoogleGenAI({ + apiKey: apiKey, + }); + + // Start the deep research interaction in background mode with thinking enabled + const interaction = await client.interactions.create({ + input: prompt, + agent: "deep-research-pro-preview-12-2025", + background: true, + agent_config: { + type: 'deep-research', + thinking_summaries: 'auto' + } + }); + + logger.debug("🎯 [DEEP-RESEARCH] Interaction started:", interaction.id); + + // Create a note with deep research metadata immediately + const noteResult = await workspaceWorker("create", { + workspaceId: ctx.workspaceId, + title: `Research: ${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}`, + deepResearchData: { + prompt, + interactionId: interaction.id, + }, + folderId: ctx.activeFolderId, + }); + + logger.debug("🎯 [DEEP-RESEARCH] Research note created:", noteResult.itemId); + + return { + noteId: noteResult.itemId, + interactionId: interaction.id, + message: "Deep research started. Check the new research card in your workspace to see progress.", + }; + } catch (error: any) { + logger.error("❌ [DEEP-RESEARCH] Error:", error); + return { + error: error?.message || "Failed to start deep research" + }; + } + }, + }; +} diff --git a/src/lib/ai/tools/flashcard-tools.ts b/src/lib/ai/tools/flashcard-tools.ts new file mode 100644 index 00000000..911daa08 --- /dev/null +++ b/src/lib/ai/tools/flashcard-tools.ts @@ -0,0 +1,245 @@ +import { z } from "zod"; +import { logger } from "@/lib/utils/logger"; +import { workspaceWorker } from "@/lib/ai/workers"; +import { loadWorkspaceState } from "@/lib/workspace/state-loader"; +import type { WorkspaceToolContext } from "./workspace-tools"; + +/** + * Parse flashcard text format into structured data + */ +function parseFlashcardText(rawInput: any): { title?: string; deckName?: string; cards: Array<{ front: string; back: string }> } { + let title: string | undefined; + let deckName: string | undefined; + const cards: Array<{ front: string; back: string }> = []; + + try { + let text = typeof rawInput === 'string' + ? rawInput + : (rawInput?.description || rawInput?.text || rawInput?.content || JSON.stringify(rawInput)); + + // Convert escaped newlines to actual newlines + text = text.replace(/\\n/g, '\n'); + + // Extract title (for creation) + const titleMatch = text.match(/Title:\s*(.+?)(?:\n|$)/i); + if (titleMatch) { + title = titleMatch[1].trim(); + } + + // Extract deck name (for updates) + const deckMatch = text.match(/Deck:\s*(.+?)(?:\n|$)/i); + if (deckMatch) { + deckName = deckMatch[1].trim(); + } + + // Extract Front/Back pairs using a pattern that captures content between markers + const cardPattern = /Front:\s*([\s\S]*?)(?=\nBack:)\nBack:\s*([\s\S]*?)(?=\n\nFront:|\n*$)/gi; + let match; + + while ((match = cardPattern.exec(text)) !== null) { + const front = match[1].trim(); + const back = match[2].trim(); + if (front && back) { + cards.push({ front, back }); + } + } + + // Fallback: try a simpler line-by-line pattern if the above didn't match + if (cards.length === 0) { + const lines = text.split('\n'); + let currentFront: string | null = null; + + for (const line of lines) { + const frontMatch = line.match(/^Front:\s*(.+)/i); + const backMatch = line.match(/^Back:\s*(.+)/i); + + if (frontMatch) { + currentFront = frontMatch[1].trim(); + } else if (backMatch && currentFront) { + cards.push({ front: currentFront, back: backMatch[1].trim() }); + currentFront = null; + } + } + } + } catch (parseError) { + logger.error("Error parsing flashcard input:", parseError); + } + + return { title, deckName, cards }; +} + +/** + * Create the createFlashcards tool + */ +export function createFlashcardsTool(ctx: WorkspaceToolContext) { + return { + description: `Create a new flashcard deck in the workspace. Use this when the user asks to generate flashcards or study materials. + +IMPORTANT: Use this simple text format (NOT JSON): + +Title: [Your Deck Title] + +Front: [Question or term for card 1] +Back: [Answer or definition for card 1] + +Front: [Question or term for card 2] +Back: [Answer or definition for card 2] + +EXAMPLE: +Title: Biology Cell Structure + +Front: What is the function of mitochondria? +Back: Mitochondria are the powerhouses of the cell. They produce ATP through cellular respiration. + +Front: Define photosynthesis +Back: Photosynthesis is the process by which plants convert light energy into chemical energy. + +Math is supported: use $$...$$ for inline and $$...$$ for display math within the Front/Back content.`, + inputSchema: z.any().describe( + "Plain text in the format: Title: [title]\\n\\nFront: [question]\\nBack: [answer]\\n\\nFront: [question]\\nBack: [answer]\\n... Use this simple text format, NOT JSON." + ), + execute: async (rawInput: any) => { + logger.debug("🎴 [CREATE-FLASHCARDS] Tool execution started"); + + const { title = "Flashcard Deck", cards } = parseFlashcardText(rawInput); + + if (cards.length === 0) { + logger.error("❌ [CREATE-FLASHCARDS] No valid cards found in input"); + return { + success: false, + message: "No valid flashcards found. Please use the format: Front: [question]\\nBack: [answer]", + }; + } + + logger.debug("🎯 [ORCHESTRATOR] Delegating to Workspace Worker (create flashcard):", { title, cardCount: cards.length }); + + if (!ctx.workspaceId) { + logger.error("❌ [CREATE-FLASHCARDS] No workspace context available"); + return { + success: false, + message: "No workspace context available", + }; + } + + try { + const result = await workspaceWorker("create", { + workspaceId: ctx.workspaceId, + title, + itemType: "flashcard", + flashcardData: { cards }, + folderId: ctx.activeFolderId, + }); + + logger.debug("✅ [CREATE-FLASHCARDS] Worker result:", result); + return result; + } catch (error) { + logger.error("❌ [CREATE-FLASHCARDS] Error executing worker:", error); + throw error; + } + }, + }; +} + +/** + * Create the updateFlashcards tool + */ +export function createUpdateFlashcardsTool(ctx: WorkspaceToolContext) { + return { + description: `Add more flashcards to an existing flashcard deck. Use this when the user wants to expand an existing deck with additional cards. + +IMPORTANT: Use this simple text format: + +Deck: [Name of the existing deck to add cards to] + +Front: [Question or term for new card 1] +Back: [Answer or definition for new card 1] + +Front: [Question or term for new card 2] +Back: [Answer or definition for new card 2] + +EXAMPLE: +Deck: Biology Cell Structure + +Front: What is the nucleus? +Back: The nucleus is the control center of the cell containing DNA. + +Front: What is the cytoplasm? +Back: The cytoplasm is the gel-like substance inside the cell membrane. + +The deck name will be matched using fuzzy search. Math is supported: use $$...$$ for inline and $$...$$ for display math.`, + inputSchema: z.any().describe( + "Plain text in the format: Deck: [deck name]\\n\\nFront: [question]\\nBack: [answer]\\n\\nFront: [question]\\nBack: [answer]\\n..." + ), + execute: async (rawInput: any) => { + const { deckName, cards: cardsToAdd } = parseFlashcardText(rawInput); + + if (!deckName) { + return { + success: false, + message: "Deck name is required. Use 'Deck: [name]' to specify which deck to update.", + }; + } + + if (cardsToAdd.length === 0) { + return { + success: false, + message: "No valid cards found. Use 'Front: [question]\\nBack: [answer]' format.", + }; + } + + if (!ctx.workspaceId) { + return { + success: false, + message: "No workspace context available", + }; + } + + try { + const state = await loadWorkspaceState(ctx.workspaceId); + const searchName = deckName.toLowerCase().trim(); + + const flashcardDecks = state.items.filter(item => item.type === 'flashcard'); + + // Fuzzy matching + let matchedDeck = flashcardDecks.find(item => item.name.toLowerCase().trim() === searchName); + if (!matchedDeck) { + matchedDeck = flashcardDecks.find(item => item.name.toLowerCase().includes(searchName)); + } + if (!matchedDeck) { + matchedDeck = flashcardDecks.find(item => searchName.includes(item.name.toLowerCase().trim())); + } + + if (!matchedDeck) { + const availableDecks = flashcardDecks.map(d => `"${d.name}"`).join(", "); + return { + success: false, + message: `Could not find flashcard deck "${deckName}". ${availableDecks ? `Available decks: ${availableDecks}` : 'No flashcard decks found in workspace.'}`, + }; + } + + const workerResult = await workspaceWorker("updateFlashcard", { + workspaceId: ctx.workspaceId, + itemId: matchedDeck.id, + itemType: "flashcard", + flashcardData: { cardsToAdd }, + }); + + if (workerResult.success) { + return { + ...workerResult, + deckName: matchedDeck.name, + cardsAdded: cardsToAdd.length, + }; + } + + return workerResult; + } catch (error) { + logger.error("Error updating flashcards:", error); + return { + success: false, + message: `Error updating flashcards: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }, + }; +} diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts new file mode 100644 index 00000000..b00bcac6 --- /dev/null +++ b/src/lib/ai/tools/index.ts @@ -0,0 +1,76 @@ +/** + * Chat Tools Index + * Factory function to create all chat tools with shared context + */ + +import { frontendTools } from "@assistant-ui/react-ai-sdk"; +import { createProcessFilesTool } from "./process-files"; +import { createProcessUrlsTool } from "./process-urls"; +import { createSearchWebTool, createExecuteCodeTool } from "./search-code"; +import { + createNoteTool, + createUpdateCardTool, + createClearCardContentTool, + createDeleteCardTool, + createSelectCardsTool, + type WorkspaceToolContext, +} from "./workspace-tools"; +import { createFlashcardsTool, createUpdateFlashcardsTool } from "./flashcard-tools"; +import { createDeepResearchTool } from "./deep-research"; +import { logger } from "@/lib/utils/logger"; + +export interface ChatToolsConfig { + workspaceId: string | null; + userId: string | null; + activeFolderId?: string; + clientTools?: Record; +} + +/** + * Create all chat tools with the given context + */ +export function createChatTools(config: ChatToolsConfig): Record { + const ctx: WorkspaceToolContext = { + workspaceId: config.workspaceId, + userId: config.userId, + activeFolderId: config.activeFolderId, + }; + + // Safeguard frontendTools + let frontendClientTools = {}; + try { + frontendClientTools = frontendTools(config.clientTools || {}); + } catch (e) { + logger.error("❌ frontendTools failed:", e); + } + + return { + // File & URL processing + processFiles: createProcessFilesTool(), + processUrls: createProcessUrlsTool(), + + // Search & code execution + searchWeb: createSearchWebTool(), + executeCode: createExecuteCodeTool(), + + // Workspace operations + createNote: createNoteTool(ctx), + updateCard: createUpdateCardTool(ctx), + clearCardContent: createClearCardContentTool(ctx), + deleteCard: createDeleteCardTool(ctx), + selectCards: createSelectCardsTool(ctx), + + // Flashcards + createFlashcards: createFlashcardsTool(ctx), + updateFlashcards: createUpdateFlashcardsTool(ctx), + + // Deep research + deepResearch: createDeepResearchTool(ctx), + + // Client tools from frontend + ...frontendClientTools, + }; +} + +// Re-export types +export type { WorkspaceToolContext } from "./workspace-tools"; diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts new file mode 100644 index 00000000..17e75eba --- /dev/null +++ b/src/lib/ai/tools/process-files.ts @@ -0,0 +1,178 @@ +import { google } from "@ai-sdk/google"; +import { generateText } from "ai"; +import { z } from "zod"; +import { logger } from "@/lib/utils/logger"; + +type FileInfo = { fileUrl: string; filename: string; mediaType: string }; + +/** + * Helper function to determine media type from URL + */ +function getMediaTypeFromUrl(url: string): string { + const urlLower = url.toLowerCase(); + if (urlLower.endsWith('.pdf')) return 'application/pdf'; + if (urlLower.match(/\.(jpg|jpeg)$/)) return 'image/jpeg'; + if (urlLower.endsWith('.png')) return 'image/png'; + if (urlLower.endsWith('.gif')) return 'image/gif'; + if (urlLower.endsWith('.webp')) return 'image/webp'; + if (urlLower.endsWith('.svg')) return 'image/svg+xml'; + if (urlLower.match(/\.(mp4|mov|avi)$/)) return 'video/mp4'; + if (urlLower.match(/\.(mp3|wav|ogg)$/)) return 'audio/mpeg'; + if (urlLower.match(/\.(doc|docx)$/)) return 'application/msword'; + if (urlLower.endsWith('.txt')) return 'text/plain'; + return 'application/octet-stream'; +} + +/** + * Process Supabase storage files by sending URLs directly to Gemini + */ +async function processSupabaseFiles( + supabaseUrls: string[], + instruction?: string +): Promise { + const fileInfos: FileInfo[] = supabaseUrls.map((fileUrl: string) => { + const filename = decodeURIComponent(fileUrl.split('/').pop() || 'file'); + const mediaType = getMediaTypeFromUrl(fileUrl); + return { fileUrl, filename, mediaType }; + }); + + const fileListText = fileInfos.map((f, i) => `${i + 1}. ${f.filename}`).join('\n'); + + const batchPrompt = instruction + ? `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${instruction}\n\nProvide your analysis for each file, clearly labeled with the filename.` + : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\nFor each file, extract and summarize:\n- Main topics, themes, or subject matter\n- Key information, data, or details\n- Important facts or insights\n- Any structured data, lists, or specific information\n\nProvide a clear, comprehensive analysis for each file, clearly labeled with the filename.`; + + const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [ + { type: "text", text: batchPrompt }, + ...fileInfos.map((f) => ({ + type: "file" as const, + data: f.fileUrl, + mediaType: f.mediaType, + filename: f.filename, + })), + ]; + + logger.debug("📁 [FILE_TOOL] Sending batched analysis request for", fileInfos.length, "files with URLs"); + + const { text: batchAnalysis } = await generateText({ + model: google("gemini-2.5-flash"), + messages: [{ + role: "user", + content: messageContent, + }], + }); + + logger.debug("📁 [FILE_TOOL] Successfully analyzed", fileInfos.length, "files in batch"); + return batchAnalysis; +} + +/** + * Process a YouTube video using Gemini's native video support + */ +async function processYouTubeVideo( + youtubeUrl: string, + instruction?: string +): Promise { + logger.debug("📁 [FILE_TOOL] Processing YouTube URL natively:", youtubeUrl); + + const videoPrompt = instruction + ? `Analyze this video. ${instruction}` + : `Analyze this video. Extract and summarize: +- Main topics and key points +- Important details and visual information +- Any specific data or insights relevant to the user's question + +Provide a clear, comprehensive analysis of the video content.`; + + const { text: videoAnalysis } = await generateText({ + model: google("gemini-2.5-flash"), + messages: [{ + role: "user", + content: [ + { type: "text", text: videoPrompt }, + { + type: "file", + data: youtubeUrl, + mediaType: "video/mp4", + }, + ], + }], + }); + + logger.debug("📁 [FILE_TOOL] Successfully processed YouTube video:", youtubeUrl); + return `**Video: ${youtubeUrl}**\n\n${videoAnalysis}`; +} + +/** + * Create the processFiles tool + */ +export function createProcessFilesTool() { + return { + description: "Process and analyze files including PDFs, images, documents, and videos. Handles Supabase storage URLs (files uploaded to your workspace) and YouTube videos. Files are downloaded and analyzed directly by Gemini. You can provide custom instructions for what to extract or focus on. Use this for file URLs and video URLs, NOT for regular web pages.", + inputSchema: z.object({ + jsonInput: z.string().describe("JSON string containing an object with 'urls' (array of file/video URLs) and optional 'instruction' (string for custom analysis). Example: '{\"urls\": [\"https://...storage.../file.pdf\"], \"instruction\": \"summarize key points\"}'"), + }), + execute: async ({ jsonInput }: { jsonInput: string }) => { + let parsed; + try { + parsed = JSON.parse(jsonInput); + } catch (e) { + logger.error("❌ [FILE_TOOL] Failed to parse JSON input:", e); + return "Error: Input must be a valid JSON string."; + } + + const urlList = parsed.urls || []; + const instruction = parsed.instruction; + + if (!urlList || urlList.length === 0) { + return "No file URLs provided"; + } + + if (urlList.length > 20) { + return `Too many files (${urlList.length}). Maximum 20 files allowed.`; + } + + // Separate Supabase file URLs from YouTube URLs + const supabaseUrls = urlList.filter((url: string) => url.includes('supabase.co/storage')); + const youtubeUrls = urlList.filter((url: string) => url.match(/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+/)); + + const fileResults: string[] = []; + + // Handle Supabase file URLs + if (supabaseUrls.length > 0) { + try { + const result = await processSupabaseFiles(supabaseUrls, instruction); + fileResults.push(result); + } catch (error) { + logger.error("📁 [FILE_TOOL] Error in Supabase file processing:", error); + fileResults.push(`Error processing Supabase files: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // LIMITATION: Gemini only supports one video file per request + if (youtubeUrls.length > 1) { + logger.warn("📁 [FILE_TOOL] Gemini supports only one video per request. Processing first, ignoring others."); + fileResults.push(`⚠️ Note: Only one video can be processed at a time. Processing the first video, others were ignored.`); + } + + if (youtubeUrls.length > 0) { + try { + const result = await processYouTubeVideo(youtubeUrls[0], instruction); + fileResults.push(result); + } catch (videoError) { + logger.error("📁 [FILE_TOOL] Error processing YouTube video:", { + url: youtubeUrls[0], + error: videoError instanceof Error ? videoError.message : String(videoError), + }); + fileResults.push(`Error processing video ${youtubeUrls[0]}: ${videoError instanceof Error ? videoError.message : String(videoError)}`); + } + } + + if (fileResults.length === 0) { + return "No files were successfully processed"; + } + + return fileResults.join('\n\n---\n\n'); + }, + }; +} diff --git a/src/lib/ai/tools/process-urls.ts b/src/lib/ai/tools/process-urls.ts new file mode 100644 index 00000000..313486c1 --- /dev/null +++ b/src/lib/ai/tools/process-urls.ts @@ -0,0 +1,98 @@ +import { google } from "@ai-sdk/google"; +import { generateText } from "ai"; +import { z } from "zod"; +import { logger } from "@/lib/utils/logger"; + +/** + * Create the processUrls tool for analyzing web pages + */ +export function createProcessUrlsTool() { + return { + description: "Analyze web pages using Google's URL Context API. Extracts content, key information, and metadata from regular web URLs (http/https). Use this for web pages, articles, documentation, and other web content. For files (PDFs, images, documents) or videos, use the processFiles tool instead.", + inputSchema: z.object({ + jsonInput: z.string().describe("JSON string containing an object with 'urls' (array of web URLs). Example: '{\"urls\": [\"https://example.com\"]}'"), + }), + execute: async ({ jsonInput }: { jsonInput: string }) => { + let parsed; + try { + parsed = JSON.parse(jsonInput); + } catch (e) { + logger.error("❌ [URL_TOOL] Failed to parse JSON input:", e); + return "Error: Input must be a valid JSON string."; + } + + const urlList = parsed.urls || []; + + logger.debug("🔗 [URL_TOOL] Processing web URLs:", urlList); + + if (!urlList || urlList.length === 0) { + return "No URLs provided"; + } + + if (urlList.length > 20) { + return `Too many URLs (${urlList.length}). Maximum 20 URLs allowed.`; + } + + const fileUrls = urlList.filter((url: string) => + url.includes('supabase.co/storage') || + url.match(/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+/) + ); + + if (fileUrls.length > 0) { + logger.warn("🔗 [URL_TOOL] File/video URLs detected, suggesting processFiles tool:", fileUrls); + return `Error: This tool only handles web URLs. Please use the processFiles tool for file URLs (${fileUrls.join(', ')})`; + } + + try { + const tools: any = { + url_context: google.tools.urlContext({}), + }; + + const promptText = `Analyze the content from the following URL${urlList.length > 1 ? 's' : ''}: +${urlList.map((url: string, i: number) => `${i + 1}. ${url}`).join('\n')} + +Please provide: +- What each URL/page is about +- Key information, features, specifications, or data +- Important details relevant to the user's question +- Publication dates or last updated information + +Provide a clear, accurate answer based on the URL content.`; + + const { text, sources, providerMetadata } = await generateText({ + model: google("gemini-2.5-flash"), + tools, + prompt: promptText, + }); + + const urlMetadata = providerMetadata?.urlContext?.urlMetadata || null; + const groundingChunks = providerMetadata?.urlContext?.groundingChunks || null; + + return { + text, + metadata: { + urlMetadata: Array.isArray(urlMetadata) ? (urlMetadata as Array<{ retrievedUrl: string; urlRetrievalStatus: string }>) : null, + groundingChunks: Array.isArray(groundingChunks) ? (groundingChunks as Array) : null, + sources: Array.isArray(sources) ? (sources as Array) : null, + }, + }; + } catch (error) { + logger.error("🔗 [URL_TOOL] Error processing web URLs:", { + error: error instanceof Error ? error.message : String(error), + errorType: error instanceof Error ? error.constructor.name : typeof error, + errorStack: error instanceof Error ? error.stack : undefined, + urls: urlList, + fullError: error, + }); + return { + text: `Error processing web URLs: ${error instanceof Error ? error.message : String(error)}`, + metadata: { + urlMetadata: null, + groundingChunks: null, + sources: null, + }, + }; + } + }, + }; +} diff --git a/src/lib/ai/tools/search-code.ts b/src/lib/ai/tools/search-code.ts new file mode 100644 index 00000000..893505ed --- /dev/null +++ b/src/lib/ai/tools/search-code.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import { logger } from "@/lib/utils/logger"; +import { searchWorker, codeExecutionWorker } from "@/lib/ai/workers"; + +/** + * Create the searchWeb tool + */ +export function createSearchWebTool() { + return { + description: "Search the web for current information, facts, news, or research. Use this when you need up-to-date information from the internet.", + inputSchema: z.object({ + query: z.string().describe("The search query"), + }), + execute: async ({ query }: { query: string }) => { + return await searchWorker(query); + }, + }; +} + +/** + * Create the executeCode tool + */ +export function createExecuteCodeTool() { + return { + description: "Execute Python code for calculations, data processing, algorithms, or mathematical computations.", + inputSchema: z.object({ + task: z.string().describe("Description of the task to solve with code"), + }), + execute: async ({ task }: { task: string }) => { + logger.debug("🎯 [ORCHESTRATOR] Delegating to Code Execution Worker:", task); + return await codeExecutionWorker(task); + }, + }; +} diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts new file mode 100644 index 00000000..66101e28 --- /dev/null +++ b/src/lib/ai/tools/workspace-tools.ts @@ -0,0 +1,280 @@ +import { z } from "zod"; +import { logger } from "@/lib/utils/logger"; +import { workspaceWorker } from "@/lib/ai/workers"; +import { loadWorkspaceState } from "@/lib/workspace/state-loader"; +import { db, workspaces } from "@/lib/db/client"; +import { eq } from "drizzle-orm"; + +export interface WorkspaceToolContext { + workspaceId: string | null; + userId: string | null; + activeFolderId?: string; +} + +/** + * Create the createNote tool + */ +export function createNoteTool(ctx: WorkspaceToolContext) { + return { + description: "Create a note card. returns success message.\n\nCRITICAL CONSTRAINTS:\n1. 'content' MUST NOT start with the title.\n2. Start directly with body text.\n3. Math: use $$...$$ for inline and $$\\n...\\n$$ for block.\n4. NO Mermaid diagrams.", + inputSchema: z.any().describe( + "JSON {title, content}. 'content': markdown body. DO NOT repeat title in content. Start with subheadings/text. Math: $$...$$ inline, $$\\n...\\n$$ block. No Mermaid." + ), + execute: async ({ title, content }: { title: string; content: string }) => { + logger.debug("🎯 [ORCHESTRATOR] Delegating to Workspace Worker (create note):", { title, contentLength: content.length }); + + if (!ctx.workspaceId) { + return { + success: false, + message: "No workspace context available", + }; + } + + return await workspaceWorker("create", { + workspaceId: ctx.workspaceId, + title, + content, + folderId: ctx.activeFolderId, + }); + }, + }; +} + +/** + * Create the updateCard tool + */ +export function createUpdateCardTool(ctx: WorkspaceToolContext) { + return { + description: "Update the content of an existing card. This tool COMPLETELY REPLACES the existing content. You must synthesize the FULL new content by combining the existing card content (from your context) with the user's requested changes. Do not just provide the diff; provide the complete new markdown content.", + inputSchema: z.any().describe( + "A JSON object with 'id' (string) and 'markdown' (string) or 'content' (string) fields. The 'id' uniquely identifies the note to update. The 'markdown' or 'content' field contains the full note body ONLY (do not include the title as a header). The markdown may include LaTeX math: use $$...$$ for inline math (with proper spacing) and $$...$$ for display math. Ensure math inside lists and tables has spaces around the $$ symbols. Do not place punctuation immediately after math expressions." + ), + execute: async (input: any) => { + logger.group("🎯 [UPDATE-CARD] Tool execution started", true); + logger.debug("Raw input received:", { + inputType: typeof input, + inputKeys: input ? Object.keys(input) : [], + hasId: !!input?.id, + hasMarkdown: !!input?.markdown, + hasContent: !!input?.content, + }); + logger.groupEnd(); + + try { + const id = input?.id; + const markdown = input?.markdown ?? input?.content; + + if (!id || typeof id !== 'string') { + logger.error("❌ [UPDATE-CARD] Invalid or missing id parameter:", { id, idType: typeof id }); + return { + success: false, + message: "Card ID is required and must be a string", + }; + } + + if (markdown === undefined || markdown === null) { + logger.error("❌ [UPDATE-CARD] Missing markdown/content parameter"); + return { + success: false, + message: "Markdown content is required (use 'markdown' or 'content' field)", + }; + } + + if (typeof markdown !== 'string') { + logger.error("❌ [UPDATE-CARD] Invalid markdown/content type:", { markdownType: typeof markdown }); + return { + success: false, + message: "Markdown content must be a string", + }; + } + + logger.debug("🎯 [UPDATE-CARD] Delegating to Workspace Worker (update):", { + id, + contentLength: markdown.length, + }); + + if (!ctx.workspaceId) { + logger.error("❌ [UPDATE-CARD] No workspace context available"); + return { + success: false, + message: "No workspace context available", + }; + } + + const result = await workspaceWorker("update", { + workspaceId: ctx.workspaceId, + itemId: id, + content: markdown, + }); + + logger.debug("✅ [UPDATE-CARD] Workspace worker returned:", { success: result?.success }); + return result; + } catch (error: any) { + logger.error("❌ [UPDATE-CARD] Error during execution:", error?.message || String(error)); + return { + success: false, + message: `Failed to update card: ${error?.message || String(error)}`, + }; + } + }, + }; +} + +/** + * Create the clearCardContent tool + */ +export function createClearCardContentTool(ctx: WorkspaceToolContext) { + return { + description: "Clear/delete the content of a card while preserving its title. Use this when the user wants to delete the contents of a card.", + inputSchema: z.object({ + id: z.string().describe("The ID of the card to clear"), + }), + execute: async ({ id }: { id: string }) => { + logger.debug("🎯 [ORCHESTRATOR] Delegating to Workspace Worker (clear):", { id }); + + if (!ctx.workspaceId) { + return { + success: false, + message: "No workspace context available", + }; + } + + return await workspaceWorker("update", { + workspaceId: ctx.workspaceId, + itemId: id, + content: "", + }); + }, + }; +} + +/** + * Create the deleteCard tool + */ +export function createDeleteCardTool(ctx: WorkspaceToolContext) { + return { + description: "Permanently delete a card/note from the workspace. Use this when the user explicitly asks to delete or remove a card.", + inputSchema: z.object({ + id: z.string().describe("The ID of the card to delete"), + }), + execute: async ({ id }: { id: string }) => { + logger.debug("🎯 [ORCHESTRATOR] Delegating to Workspace Worker (delete):", { id }); + + if (!ctx.workspaceId) { + return { + success: false, + message: "No workspace context available", + }; + } + + return await workspaceWorker("delete", { + workspaceId: ctx.workspaceId, + itemId: id, + }); + }, + }; +} + +/** + * Create the selectCards tool + */ +export function createSelectCardsTool(ctx: WorkspaceToolContext) { + return { + description: + "Select one or more cards by their TITLES and add them to the conversation context. This tool helps you surface specific cards when the user refers to them. The tool will find the best matching cards for the titles you provide and add them to the system context.", + inputSchema: z.object({ + cardTitles: z.array(z.string()).describe("Array of card titles to search for and select"), + }), + execute: async (input: { cardTitles: string[] }) => { + const { cardTitles } = input; + + if (!ctx.workspaceId) { + return { + success: false, + message: "No workspace context available", + }; + } + + if (!cardTitles || cardTitles.length === 0) { + return { + success: false, + message: "cardTitles array must be provided and non-empty.", + }; + } + + try { + if (!ctx.userId) { + return { success: false, message: "User not authenticated" }; + } + + const workspace = await db + .select({ userId: workspaces.userId }) + .from(workspaces) + .where(eq(workspaces.id, ctx.workspaceId)) + .limit(1); + + if (!workspace[0]) { + return { success: false, message: "Workspace not found" }; + } + + if (workspace[0].userId !== ctx.userId) { + logger.warn(`🔒 [SELECT-CARDS] Access denied for user ${ctx.userId} to workspace ${ctx.workspaceId}`); + return { + success: false, + message: "Access denied. You do not have permission to view cards in this workspace.", + }; + } + + const state = await loadWorkspaceState(ctx.workspaceId); + const foundCardIds = new Set(); + const notFoundTitles: string[] = []; + + cardTitles.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) { + foundCardIds.add(match.id); + } else { + notFoundTitles.push(title); + } + }); + + const validCardIds = Array.from(foundCardIds); + + if (validCardIds.length === 0) { + const availableCards = state.items.map(i => `"${i.name}"`).join(", "); + return { + success: false, + message: `No cards found matching your request. ${notFoundTitles.length > 0 ? `Could not find: ${notFoundTitles.join(", ")}. ` : ""}Available cards: ${availableCards}`, + cardContent: "", + }; + } + + const selectedCards = state.items.filter((item) => + validCardIds.includes(item.id) + ); + + const message = `Selected ${selectedCards.length} card${selectedCards.length === 1 ? "" : "s"}. ${notFoundTitles.length > 0 ? `(Could not find: ${notFoundTitles.join(", ")}) ` : ""}NOTE: This selection was made at the time of this tool call. For the current active selection, checking the 'CARDS IN CONTEXT DRAWER' section in your system context is recommended.`; + + return { + success: true, + message, + selectedCount: selectedCards.length, + selectedCardNames: selectedCards.map((c) => c.name), + selectedCardIds: selectedCards.map((c) => c.id), + }; + } catch (error) { + logger.error("Error loading cards for selectCards tool:", error); + return { + success: false, + message: `Error selecting cards: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } + }, + }; +} From 418f6bfa4d8d1ea37bd243f924e8df5cb18c3f37 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 18 Jan 2026 15:24:25 -0500 Subject: [PATCH 2/3] refactor: simplify chat route using modular tools; fix waterfall with Promise.all --- src/app/api/chat/route.ts | 1224 ++++----------------------- src/lib/ai/tools/flashcard-tools.ts | 5 +- 2 files changed, 148 insertions(+), 1081 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 2c4a267f..20438e84 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,34 +1,18 @@ -import { google, type GoogleGenerativeAIProviderMetadata } from "@ai-sdk/google"; -import { GoogleGenAI } from "@google/genai"; -import { frontendTools } from "@assistant-ui/react-ai-sdk"; -import { streamText, generateText, convertToModelMessages, stepCountIs } from "ai"; -import { z } from "zod"; +import { google } from "@ai-sdk/google"; +import { streamText, convertToModelMessages, stepCountIs } from "ai"; import { logger } from "@/lib/utils/logger"; -import { - searchWorker, - codeExecutionWorker, - workspaceWorker, - textSelectionWorker, -} from "@/lib/ai/workers"; -import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; -import { cookies } from "next/headers"; -import { randomUUID } from "crypto"; -import { db, workspaces } from "@/lib/db/client"; -import { eq } from "drizzle-orm"; +import { createChatTools } from "@/lib/ai/tools"; /** * Extract workspaceId from system context or request body - * The workspaceId is passed from the frontend through the request body or system context */ function extractWorkspaceId(body: any): string | null { - // Check if workspaceId is directly passed in the request body if (body.workspaceId) { return body.workspaceId; } - // Extract from system context - look for "Workspace ID: " pattern const system = body.system || ""; const workspaceIdMatch = system.match(/Workspace ID: ([a-f0-9-]{36})/); if (workspaceIdMatch) { @@ -38,1067 +22,93 @@ function extractWorkspaceId(body: any): string | null { return null; } -export async function POST(req: Request) { - let workspaceId: string | null = null; - let activeFolderId: string | undefined; - try { - // Get authenticated user ID from better-auth (supports anonymous users) - const session = await auth.api.getSession({ - headers: await headers(), - }); - // Allow anonymous users - userId can be from anonymous session - const userId = session?.user?.id || null; - - const body = await req.json(); - - const messages = body.messages || []; - const system = body.system || ""; - workspaceId = extractWorkspaceId(body); - activeFolderId = body.activeFolderId; - - let convertedMessages; - try { - convertedMessages = await convertToModelMessages(messages); - } catch (convertError) { - logger.error("❌ [CHAT-API] convertToModelMessages FAILED:", { - error: convertError instanceof Error ? convertError.message : String(convertError), - stack: convertError instanceof Error ? convertError.stack : undefined, +/** + * Extract file URLs from FILE_URL markers in messages + */ +function extractFileUrls(messages: any[]): string[] { + const fileUrls: string[] = []; + + messages.forEach((message) => { + if (message.content && Array.isArray(message.content)) { + message.content.forEach((part: any) => { + if (part.type === "text" && typeof part.text === "string") { + // Create regex inside loop to reset lastIndex state for each iteration + const fileUrlRegex = /\[FILE_URL:([^|]+)\|mediaType:([^|]*)\|filename:([^\]]*)\]/g; + let match; + while ((match = fileUrlRegex.exec(part.text)) !== null) { + fileUrls.push(match[1]); + } + } }); - throw convertError; } + }); - // Extract file URLs from FILE_URL markers for the processFiles tool hint - // Keep markers as text - the model will use processFiles tool to handle them - const fileUrls: string[] = []; - const fileUrlRegex = /\[FILE_URL:([^|]+)\|mediaType:([^|]*)\|filename:([^\]]*)\]/g; - convertedMessages.forEach((message) => { - if (message.content && Array.isArray(message.content)) { - message.content.forEach((part) => { - if (part.type === "text" && typeof part.text === "string") { - let match; - while ((match = fileUrlRegex.exec(part.text)) !== null) { - fileUrls.push(match[1]); - } - } - }); - } - }); - - // Extract URLs from messages to detect if URLs are present - // The agent will use the processUrls tool to handle them (which shows in UI) - const urlContextUrls: string[] = []; - convertedMessages.forEach((message) => { - if (message.content && Array.isArray(message.content)) { - message.content.forEach((part) => { - if (part.type === "text" && typeof part.text === "string") { - // Look for [URL_CONTEXT:...] markers - const urlMatches = part.text.matchAll(/\[URL_CONTEXT:(.+?)\]/g); - for (const match of urlMatches) { - const url = match[1]; - if (url && !urlContextUrls.includes(url)) { - urlContextUrls.push(url); - } - } - // Also look for direct URLs in text - const directUrlMatches = part.text.matchAll(/https?:\/\/[^\s]+/g); - for (const match of directUrlMatches) { - const url = match[0]; - if (url && !urlContextUrls.includes(url)) { - urlContextUrls.push(url); - } - } - } - }); - } - }); - - // Main workspace processing (using custom tools only) - // Use the model selected by the user, with fallback to gemini-2.5-pro - const modelId = body.modelId || "gemini-2.5-pro"; - const model = google(modelId); - - - // Safeguard frontendTools - let clientTools = {}; - try { - clientTools = frontendTools(body.tools || {}); - } catch (e) { - logger.error("❌ frontendTools failed:", e); - } - - // Build tools object - custom tools only - const tools: any = { - // TOOL 0: Process Files (handles Supabase storage files and YouTube videos) - processFiles: { - description: "Process and analyze files including PDFs, images, documents, and videos. Handles Supabase storage URLs (files uploaded to your workspace) and YouTube videos. Files are downloaded and analyzed directly by Gemini. You can provide custom instructions for what to extract or focus on. Use this for file URLs and video URLs, NOT for regular web pages.", - inputSchema: z.object({ - jsonInput: z.string().describe("JSON string containing an object with 'urls' (array of file/video URLs) and optional 'instruction' (string for custom analysis). Example: '{\"urls\": [\"https://...storage.../file.pdf\"], \"instruction\": \"summarize key points\"}'"), - }), - execute: async ({ jsonInput }: { jsonInput: string }) => { - let parsed; - try { - parsed = JSON.parse(jsonInput); - } catch (e) { - logger.error("❌ [FILE_TOOL] Failed to parse JSON input:", e); - return "Error: Input must be a valid JSON string."; - } - - const urlList = parsed.urls || []; - const instruction = parsed.instruction; - - - if (!urlList || urlList.length === 0) { - return "No file URLs provided"; - } - - if (urlList.length > 20) { - return `Too many files (${urlList.length}). Maximum 20 files allowed.`; - } - - // Separate Supabase file URLs from YouTube URLs - const supabaseUrls = urlList.filter((url: string) => url.includes('supabase.co/storage')); - const youtubeUrls = urlList.filter((url: string) => url.match(/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+/)); - - const fileResults: string[] = []; - - // Handle Supabase file URLs by sending URLs directly to Gemini - if (supabaseUrls.length > 0) { - try { - // Helper function to determine media type from URL - const getMediaTypeFromUrl = (url: string): string => { - const urlLower = url.toLowerCase(); - if (urlLower.endsWith('.pdf')) { - return 'application/pdf'; - } else if (urlLower.match(/\.(jpg|jpeg)$/)) { - return 'image/jpeg'; - } else if (urlLower.endsWith('.png')) { - return 'image/png'; - } else if (urlLower.endsWith('.gif')) { - return 'image/gif'; - } else if (urlLower.endsWith('.webp')) { - return 'image/webp'; - } else if (urlLower.endsWith('.svg')) { - return 'image/svg+xml'; - } else if (urlLower.match(/\.(mp4|mov|avi)$/)) { - return 'video/mp4'; - } else if (urlLower.match(/\.(mp3|wav|ogg)$/)) { - return 'audio/mpeg'; - } else if (urlLower.match(/\.(doc|docx)$/)) { - return 'application/msword'; - } else if (urlLower.endsWith('.txt')) { - return 'text/plain'; - } - return 'application/octet-stream'; - }; - - // Prepare file info with URLs directly - type FileInfo = { fileUrl: string; filename: string; mediaType: string }; - const fileInfos: FileInfo[] = supabaseUrls.map((fileUrl: string) => { - const filename = decodeURIComponent(fileUrl.split('/').pop() || 'file'); - const mediaType = getMediaTypeFromUrl(fileUrl); - return { fileUrl, filename, mediaType }; - }); - - // Analyze all files in a SINGLE batched AI call - try { - const fileListText = fileInfos.map((f: FileInfo, i: number) => `${i + 1}. ${f.filename}`).join('\n'); - - const batchPrompt = instruction - ? `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\n${instruction}\n\nProvide your analysis for each file, clearly labeled with the filename.` - : `Analyze the following ${fileInfos.length} file(s):\n${fileListText}\n\nFor each file, extract and summarize:\n- Main topics, themes, or subject matter\n- Key information, data, or details\n- Important facts or insights\n- Any structured data, lists, or specific information\n\nProvide a clear, comprehensive analysis for each file, clearly labeled with the filename.`; - - // Build message content with all files using URLs directly - const messageContent: Array<{ type: "text"; text: string } | { type: "file"; data: string; mediaType: string; filename?: string }> = [ - { type: "text", text: batchPrompt }, - ...fileInfos.map((f: FileInfo) => ({ - type: "file" as const, - data: f.fileUrl, - mediaType: f.mediaType, - filename: f.filename, - })), - ]; - - logger.debug("📁 [FILE_TOOL] Sending batched analysis request for", fileInfos.length, "files with URLs"); - - const { text: batchAnalysis } = await generateText({ - model: google("gemini-2.5-flash"), - messages: [{ - role: "user", - content: messageContent, - }], - }); - - logger.debug("📁 [FILE_TOOL] Successfully analyzed", fileInfos.length, "files in batch"); - fileResults.push(batchAnalysis); - } catch (analysisError) { - logger.error("📁 [FILE_TOOL] Error in batched analysis:", analysisError); - fileResults.push(`Error analyzing files: ${analysisError instanceof Error ? analysisError.message : String(analysisError)}`); - } - } catch (error) { - logger.error("📁 [FILE_TOOL] Error in Supabase file processing:", error); - fileResults.push(`Error processing Supabase files: ${error instanceof Error ? error.message : String(error)}`); - } - } - - // LIMITATION: Gemini only supports one video file per request - // Process the FIRST YouTube URL, report error for extras - if (youtubeUrls.length > 1) { - logger.warn("📁 [FILE_TOOL] Gemini supports only one video per request. Processing first, ignoring others."); - fileResults.push(`⚠️ Note: Only one video can be processed at a time. Processing the first video, others were ignored.`); - } - - if (youtubeUrls.length > 0) { - const youtubeUrl = youtubeUrls[0]; - logger.debug("📁 [FILE_TOOL] Processing YouTube URL natively:", youtubeUrl); - - try { - const videoPrompt = instruction - ? `Analyze this video. ${instruction}` - : `Analyze this video. Extract and summarize: -- Main topics and key points -- Important details and visual information -- Any specific data or insights relevant to the user's question - -Provide a clear, comprehensive analysis of the video content.`; - - const { text: videoAnalysis } = await generateText({ - model: google("gemini-2.5-flash"), - messages: [{ - role: "user", - content: [ - { type: "text", text: videoPrompt }, - { - type: "file", - data: youtubeUrl, - mediaType: "video/mp4", - }, - ], - }], - }); - - fileResults.push(`**Video: ${youtubeUrl}**\n\n${videoAnalysis}`); - logger.debug("📁 [FILE_TOOL] Successfully processed YouTube video:", youtubeUrl); - } catch (videoError) { - logger.error("📁 [FILE_TOOL] Error processing YouTube video:", { - url: youtubeUrl, - error: videoError instanceof Error ? videoError.message : String(videoError), - }); - fileResults.push(`Error processing video ${youtubeUrl}: ${videoError instanceof Error ? videoError.message : String(videoError)}`); - } - } - - // Return combined results - if (fileResults.length === 0) { - return "No files were successfully processed"; - } - - return fileResults.join('\n\n---\n\n'); - }, - }, - - // TOOL 1: Process URLs (handles web URLs only using Google's URL Context API) - - processUrls: { - description: "Analyze web pages using Google's URL Context API. Extracts content, key information, and metadata from regular web URLs (http/https). Use this for web pages, articles, documentation, and other web content. For files (PDFs, images, documents) or videos, use the processFiles tool instead.", - inputSchema: z.object({ - jsonInput: z.string().describe("JSON string containing an object with 'urls' (array of web URLs). Example: '{\"urls\": [\"https://example.com\"]}'"), - }), - execute: async ({ jsonInput }: { jsonInput: string }) => { - let parsed; - try { - parsed = JSON.parse(jsonInput); - } catch (e) { - logger.error("❌ [URL_TOOL] Failed to parse JSON input:", e); - return "Error: Input must be a valid JSON string."; - } - - const urlList = parsed.urls || []; - - logger.debug("🔗 [URL_TOOL] Processing web URLs:", urlList); - - if (!urlList || urlList.length === 0) { - return "No URLs provided"; - } - - if (urlList.length > 20) { - return `Too many URLs (${urlList.length}). Maximum 20 URLs allowed.`; - } - - const fileUrls = urlList.filter((url: string) => - url.includes('supabase.co/storage') || - url.match(/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+/) - ); - - if (fileUrls.length > 0) { - logger.warn("🔗 [URL_TOOL] File/video URLs detected, suggesting processFiles tool:", fileUrls); - return `Error: This tool only handles web URLs. Please use the processFiles tool for file URLs (${fileUrls.join(', ')})`; - } - - try { - const tools: any = { - url_context: google.tools.urlContext({}), - }; - - const promptText = `Analyze the content from the following URL${urlList.length > 1 ? 's' : ''}: -${urlList.map((url: string, i: number) => `${i + 1}. ${url}`).join('\n')} - -Please provide: -- What each URL/page is about -- Key information, features, specifications, or data -- Important details relevant to the user's question -- Publication dates or last updated information - -Provide a clear, accurate answer based on the URL content.`; - - const { text, sources, providerMetadata } = await generateText({ - model: google("gemini-2.5-flash"), - tools, - prompt: promptText, - }); - - const urlMetadata = providerMetadata?.urlContext?.urlMetadata || null; - const groundingChunks = providerMetadata?.urlContext?.groundingChunks || null; - - - return { - text, - metadata: { - urlMetadata: Array.isArray(urlMetadata) ? (urlMetadata as Array<{ retrievedUrl: string; urlRetrievalStatus: string }>) : null, - groundingChunks: Array.isArray(groundingChunks) ? (groundingChunks as Array) : null, - sources: Array.isArray(sources) ? (sources as Array) : null, - }, - }; - } catch (error) { - logger.error("🔗 [URL_TOOL] Error processing web URLs:", { - error: error instanceof Error ? error.message : String(error), - errorType: error instanceof Error ? error.constructor.name : typeof error, - errorStack: error instanceof Error ? error.stack : undefined, - urls: urlList, - fullError: error, - }); - return { - text: `Error processing web URLs: ${error instanceof Error ? error.message : String(error)}`, - metadata: { - urlMetadata: null, - groundingChunks: null, - sources: null, - }, - }; - } - }, - }, - - // TOOL 2: Delegate to Search Worker - - searchWeb: { - description: "Search the web for current information, facts, news, or research. Use this when you need up-to-date information from the internet.", - inputSchema: z.object({ - query: z.string().describe("The search query"), - }), - execute: async ({ query }: { query: string }) => { - return await searchWorker(query); - }, - }, - - // TOOL 2: Delegate to Code Execution Worker - executeCode: { - description: "Execute Python code for calculations, data processing, algorithms, or mathematical computations.", - inputSchema: z.object({ - task: z.string().describe("Description of the task to solve with code"), - }), - execute: async ({ task }: { task: string }) => { - logger.debug("🎯 [ORCHESTRATOR] Delegating to Code Execution Worker:", task); - return await codeExecutionWorker(task); - }, - }, - - // TOOL 3: Create Note - createNote: { - description: "Create a note card. returns success message.\n\nCRITICAL CONSTRAINTS:\n1. 'content' MUST NOT start with the title.\n2. Start directly with body text.\n3. Math: use $$...$$ for inline and $$\\n...\\n$$ for block.\n4. NO Mermaid diagrams.", - inputSchema: z.any().describe( - "JSON {title, content}. 'content': markdown body. DO NOT repeat title in content. Start with subheadings/text. Math: $$...$$ inline, $$\\n...\\n$$ block. No Mermaid." - ), - execute: async ({ title, content }: { title: string; content: string }) => { - logger.debug("🎯 [ORCHESTRATOR] Delegating to Workspace Worker (create note):", { title, contentLength: content.length }); - - if (!workspaceId) { - return { - success: false, - message: "No workspace context available", - }; - } - - return await workspaceWorker("create", { - workspaceId, - title, - content, - folderId: activeFolderId, - }); - }, - }, - - // TOOL 4: Update Card - updateCard: { - description: "Update the content of an existing card. This tool COMPLETELY REPLACES the existing content. You must synthesize the FULL new content by combining the existing card content (from your context) with the user's requested changes. Do not just provide the diff; provide the complete new markdown content.", - inputSchema: z.any().describe( - "A JSON object with 'id' (string) and 'markdown' (string) or 'content' (string) fields. The 'id' uniquely identifies the note to update. The 'markdown' or 'content' field contains the full note body ONLY (do not include the title as a header). The markdown may include LaTeX math: use $$...$$ for inline math (with proper spacing) and $$...$$ for display math. Ensure math inside lists and tables has spaces around the $$ symbols. Do not place punctuation immediately after math expressions." - ), - execute: async (input: any) => { - logger.group("🎯 [UPDATE-CARD] Tool execution started", true); - logger.debug("Raw input received:", { - inputType: typeof input, - inputKeys: input ? Object.keys(input) : [], - hasId: !!input?.id, - hasMarkdown: !!input?.markdown, - hasContent: !!input?.content, - idType: typeof input?.id, - markdownType: typeof input?.markdown, - contentType: typeof input?.content, - idValue: input?.id, - markdownPreview: (input?.markdown || input?.content) ? (typeof (input.markdown || input.content) === 'string' ? (input.markdown || input.content).substring(0, 50) + '...' : String(input.markdown || input.content).substring(0, 50)) : 'undefined', - }); - logger.groupEnd(); - - try { - // Safely extract parameters with validation - // Accept both 'markdown' and 'content' as valid parameter names - const id = input?.id; - const markdown = input?.markdown ?? input?.content; - - logger.debug("🎯 [UPDATE-CARD] Parameter extraction:", { - hasId: !!id, - hasMarkdown: !!input?.markdown, - hasContent: !!input?.content, - usingMarkdown: !!input?.markdown, - usingContent: !input?.markdown && !!input?.content, - }); - - if (!id || typeof id !== 'string') { - logger.error("❌ [UPDATE-CARD] Invalid or missing id parameter:", { id, idType: typeof id }); - return { - success: false, - message: "Card ID is required and must be a string", - }; - } - - if (markdown === undefined || markdown === null) { - logger.error("❌ [UPDATE-CARD] Missing markdown/content parameter:", { - hasMarkdown: !!input?.markdown, - hasContent: !!input?.content, - markdownType: typeof input?.markdown, - contentType: typeof input?.content, - allInputKeys: input ? Object.keys(input) : [], - }); - return { - success: false, - message: "Markdown content is required (use 'markdown' or 'content' field)", - }; - } - - if (typeof markdown !== 'string') { - logger.error("❌ [UPDATE-CARD] Invalid markdown/content type:", { - markdown, - markdownType: typeof markdown, - originalMarkdownType: typeof input?.markdown, - originalContentType: typeof input?.content, - }); - return { - success: false, - message: "Markdown content must be a string", - }; - } - - logger.debug("🎯 [UPDATE-CARD] Delegating to Workspace Worker (update):", { - id, - contentLength: markdown.length, - contentPreview: markdown.substring(0, 100) + (markdown.length > 100 ? '...' : ''), - }); - - if (!workspaceId) { - logger.error("❌ [UPDATE-CARD] No workspace context available"); - return { - success: false, - message: "No workspace context available", - }; - } - - logger.debug("🎯 [UPDATE-CARD] Calling workspaceWorker with params:", { - action: "update", - workspaceId, - itemId: id, - hasContent: !!markdown, - contentLength: markdown.length, - }); - - const result = await workspaceWorker("update", { - workspaceId, - itemId: id, - content: markdown, - // We don't pass title, so it will be preserved - }); - - logger.debug("✅ [UPDATE-CARD] Workspace worker returned:", { - success: result?.success, - hasMessage: !!result?.message, - hasItemId: !!result?.itemId, - }); - - return result; - } catch (error: any) { - logger.group("❌ [UPDATE-CARD] Error during execution", false); - logger.error("Error type:", error?.constructor?.name || typeof error); - logger.error("Error message:", error?.message || String(error)); - logger.error("Error stack:", error?.stack); - logger.error("Full error object:", error); - logger.error("Input that caused error:", input); - logger.groupEnd(); - - return { - success: false, - message: `Failed to update card: ${error?.message || String(error)}`, - }; - } - }, - }, - - // TOOL 5: Clear Card Content - clearCardContent: { - description: "Clear/delete the content of a card while preserving its title. Use this when the user wants to delete the contents of a card.", - inputSchema: z.object({ - id: z.string().describe("The ID of the card to clear"), - }), - execute: async ({ id }: { id: string }) => { - logger.debug("🎯 [ORCHESTRATOR] Delegating to Workspace Worker (clear):", { id }); - - if (!workspaceId) { - return { - success: false, - message: "No workspace context available", - }; - } - - return await workspaceWorker("update", { - workspaceId, - itemId: id, - content: "", // Clear content - // Title preserved - }); - }, - }, - // TOOL 6: Delete Card - deleteCard: { - description: "Permanently delete a card/note from the workspace. Use this when the user explicitly asks to delete or remove a card.", - inputSchema: z.object({ - id: z.string().describe("The ID of the card to delete"), - }), - execute: async ({ id }: { id: string }) => { - logger.debug("🎯 [ORCHESTRATOR] Delegating to Workspace Worker (delete):", { id }); - - if (!workspaceId) { - return { - success: false, - message: "No workspace context available", - }; - } - - return await workspaceWorker("delete", { - workspaceId, - itemId: id, - }); - }, - }, - - // TOOL 7A: Update Flashcard Deck (add cards to existing deck) - updateFlashcards: { - description: `Add more flashcards to an existing flashcard deck. Use this when the user wants to expand an existing deck with additional cards. - -IMPORTANT: Use this simple text format: - -Deck: [Name of the existing deck to add cards to] - -Front: [Question or term for new card 1] -Back: [Answer or definition for new card 1] - -Front: [Question or term for new card 2] -Back: [Answer or definition for new card 2] - -EXAMPLE: -Deck: Biology Cell Structure - -Front: What is the nucleus? -Back: The nucleus is the control center of the cell containing DNA. - -Front: What is the cytoplasm? -Back: The cytoplasm is the gel-like substance inside the cell membrane. - -The deck name will be matched using fuzzy search. Math is supported: use $$...$$ for inline and $$...$$ for display math.`, - inputSchema: z.any().describe( - "Plain text in the format: Deck: [deck name]\\n\\nFront: [question]\\nBack: [answer]\\n\\nFront: [question]\\nBack: [answer]\\n..." - ), - execute: async (rawInput: any) => { - // Parse the input - let deckName: string | undefined; - let cardsToAdd: { front: string; back: string }[] = []; - - try { - // Extract text from various possible input formats - let text = typeof rawInput === 'string' - ? rawInput - : (rawInput?.description || rawInput?.text || rawInput?.content || JSON.stringify(rawInput)); - - // Convert escaped newlines to actual newlines - text = text.replace(/\\n/g, '\n'); - - // Extract deck name - const deckMatch = text.match(/Deck:\s*(.+?)(?:\n|$)/i); - if (deckMatch) { - deckName = deckMatch[1].trim(); - } - - // Extract Front/Back pairs - const cardPattern = /Front:\s*([\s\S]*?)(?=\nBack:)\nBack:\s*([\s\S]*?)(?=\n\nFront:|\n*$)/gi; - let match; - - while ((match = cardPattern.exec(text)) !== null) { - const front = match[1].trim(); - const back = match[2].trim(); - if (front && back) { - cardsToAdd.push({ front, back }); - } - } - - // Fallback: simpler line-by-line pattern - if (cardsToAdd.length === 0) { - const lines = text.split('\n'); - let currentFront: string | null = null; - - for (const line of lines) { - const frontMatch = line.match(/^Front:\s*(.+)/i); - const backMatch = line.match(/^Back:\s*(.+)/i); + return fileUrls; +} - if (frontMatch) { - currentFront = frontMatch[1].trim(); - } else if (backMatch && currentFront) { - cardsToAdd.push({ front: currentFront, back: backMatch[1].trim() }); - currentFront = null; - } - } +/** + * Extract URLs from URL_CONTEXT markers and direct URLs in messages + */ +function extractUrlContextUrls(messages: any[]): string[] { + const urlContextUrls: string[] = []; + + messages.forEach((message) => { + if (message.content && Array.isArray(message.content)) { + message.content.forEach((part: any) => { + if (part.type === "text" && typeof part.text === "string") { + // Look for [URL_CONTEXT:...] markers + const urlMatches = part.text.matchAll(/\[URL_CONTEXT:(.+?)\]/g); + for (const match of urlMatches) { + const url = match[1]; + if (url && !urlContextUrls.includes(url)) { + urlContextUrls.push(url); } - } catch (parseError) { - logger.error("Error parsing updateFlashcards input:", parseError); - } - - if (!deckName) { - return { - success: false, - message: "Deck name is required. Use 'Deck: [name]' to specify which deck to update.", - }; - } - - if (cardsToAdd.length === 0) { - return { - success: false, - message: "No valid cards found. Use 'Front: [question]\\nBack: [answer]' format.", - }; - } - - if (!workspaceId) { - return { - success: false, - message: "No workspace context available", - }; } - - try { - // Fuzzy match the deck name - const state = await loadWorkspaceState(workspaceId); - const searchName = deckName.toLowerCase().trim(); - - // Find flashcard decks only - const flashcardDecks = state.items.filter(item => item.type === 'flashcard'); - - // 1. Exact match (case insensitive) - let matchedDeck = flashcardDecks.find(item => item.name.toLowerCase().trim() === searchName); - - // 2. Contains match (if exact fails) - if (!matchedDeck) { - matchedDeck = flashcardDecks.find(item => item.name.toLowerCase().includes(searchName)); - } - - // 3. Reverse contains (deck name contains search term) - if (!matchedDeck) { - matchedDeck = flashcardDecks.find(item => searchName.includes(item.name.toLowerCase().trim())); + // Also look for direct URLs in text + const directUrlMatches = part.text.matchAll(/https?:\/\/[^\s]+/g); + for (const match of directUrlMatches) { + const url = match[0]; + if (url && !urlContextUrls.includes(url)) { + urlContextUrls.push(url); } - - if (!matchedDeck) { - const availableDecks = flashcardDecks.map(d => `"${d.name}"`).join(", "); - return { - success: false, - message: `Could not find flashcard deck "${deckName}". ${availableDecks ? `Available decks: ${availableDecks}` : 'No flashcard decks found in workspace.'}`, - }; - } - - const workerResult = await workspaceWorker("updateFlashcard", { - workspaceId, - itemId: matchedDeck.id, - itemType: "flashcard", - flashcardData: { - cardsToAdd, - }, - }); - - if (workerResult.success) { - return { - ...workerResult, - deckName: matchedDeck.name, - cardsAdded: cardsToAdd.length, - }; - } - - return workerResult; - } catch (error) { - logger.error("Error updating flashcards:", error); - return { - success: false, - message: `Error updating flashcards: ${error instanceof Error ? error.message : String(error)}`, - }; - } - }, - }, - - // TOOL 7B: Select Cards into Context - selectCards: { - description: - "Select one or more cards by their TITLES and add them to the conversation context. This tool helps you surface specific cards when the user refers to them. The tool will find the best matching cards for the titles you provide and add them to the system context.", - inputSchema: z.object({ - cardTitles: z.array(z.string()).describe("Array of card titles to search for and select"), - }), - execute: async (input: { cardTitles: string[] }) => { - const { cardTitles } = input; - - if (!workspaceId) { - return { - success: false, - message: "No workspace context available", - }; } + } + }); + } + }); - if (!cardTitles || cardTitles.length === 0) { - return { - success: false, - message: "cardTitles array must be provided and non-empty.", - }; - } - - try { - // Verify permission before loading workspace state - if (!userId) { - return { success: false, message: "User not authenticated" }; - } - - // Check if user is owner - const workspace = await db - .select({ userId: workspaces.userId }) - .from(workspaces) - .where(eq(workspaces.id, workspaceId)) - .limit(1); - - if (!workspace[0]) { - return { success: false, message: "Workspace not found" }; - } - - if (workspace[0].userId !== userId) { - logger.warn(`🔒 [SELECT-CARDS] Access denied for user ${userId} to workspace ${workspaceId}`); - return { - success: false, - message: "Access denied. You do not have permission to view cards in this workspace.", - }; - } - - const state = await loadWorkspaceState(workspaceId); - const foundCardIds = new Set(); - const notFoundTitles: string[] = []; - - // Process Titles - cardTitles.forEach(title => { - const searchTitle = title.toLowerCase().trim(); - // 1. Exact match (case insensitive) - let match = state.items.find(item => item.name.toLowerCase().trim() === searchTitle); - - // 2. Contains match (if exact fails) - if (!match) { - match = state.items.find(item => item.name.toLowerCase().includes(searchTitle)); - } - - if (match) { - foundCardIds.add(match.id); - } else { - notFoundTitles.push(title); - } - }); - - const validCardIds = Array.from(foundCardIds); - - if (validCardIds.length === 0) { - const availableCards = state.items.map(i => `"${i.name}"`).join(", "); - return { - success: false, - message: `No cards found matching your request. ${notFoundTitles.length > 0 ? `Could not find: ${notFoundTitles.join(", ")}. ` : ""}Available cards: ${availableCards}`, - cardContent: "", - }; - } - - const selectedCards = state.items.filter((item) => - validCardIds.includes(item.id) - ); - - const message = `Selected ${selectedCards.length} card${selectedCards.length === 1 ? "" : "s"}. ${notFoundTitles.length > 0 ? `(Could not find: ${notFoundTitles.join(", ")}) ` : ""}NOTE: This selection was made at the time of this tool call. For the current active selection, checking the 'CARDS IN CONTEXT DRAWER' section in your system context is recommended.`; - - return { - success: true, - message, - selectedCount: selectedCards.length, - selectedCardNames: selectedCards.map((c) => c.name), - selectedCardIds: selectedCards.map((c) => c.id), - }; - } catch (error) { - logger.error("Error loading cards for selectCards tool:", error); - return { - success: false, - message: `Error selecting cards: ${error instanceof Error ? error.message : "Unknown error"}`, - }; - } - }, - }, - - - // TOOL 8: Create Flashcard Deck - createFlashcards: { - description: `Create a new flashcard deck in the workspace. Use this when the user asks to generate flashcards or study materials. - -IMPORTANT: Use this simple text format (NOT JSON): - -Title: [Your Deck Title] - -Front: [Question or term for card 1] -Back: [Answer or definition for card 1] - -Front: [Question or term for card 2] -Back: [Answer or definition for card 2] - -EXAMPLE: -Title: Biology Cell Structure - -Front: What is the function of mitochondria? -Back: Mitochondria are the powerhouses of the cell. They produce ATP through cellular respiration. - -Front: Define photosynthesis -Back: Photosynthesis is the process by which plants convert light energy into chemical energy. - -Front: What is the cell membrane made of? -Back: The cell membrane is composed of a phospholipid bilayer with embedded proteins. + return urlContextUrls; +} -Math is supported: use $$...$$ for inline and $$...$$ for display math within the Front/Back content.`, - inputSchema: z.any().describe( - "Plain text in the format: Title: [title]\\n\\nFront: [question]\\nBack: [answer]\\n\\nFront: [question]\\nBack: [answer]\\n... Use this simple text format, NOT JSON." - ), - execute: async (rawInput: any) => { - logger.group("🎴 [CREATE-FLASHCARDS] Tool execution started", true); - logger.debug("Raw input received:", { - rawInputType: typeof rawInput, - rawInputKeys: rawInput && typeof rawInput === 'object' ? Object.keys(rawInput) : 'N/A', - rawInputPreview: typeof rawInput === 'string' ? rawInput.substring(0, 200) : JSON.stringify(rawInput).substring(0, 200) +/** + * Clean URL_CONTEXT markers from messages + */ +function cleanMessages(messages: any[]): any[] { + return messages.map((message) => { + if (message.content && Array.isArray(message.content)) { + const updatedContent = message.content.map((part: any) => { + if (part.type === "text" && typeof part.text === "string") { + const updatedText = part.text.replace(/\[URL_CONTEXT:(.+?)\]/g, (_match: string, url: string) => { + return url; }); + return { ...part, text: updatedText }; + } + return part; + }); + return { ...message, content: updatedContent } as typeof message; + } + return message; + }); +} - // Parse the input as text format - let title: string = "Flashcard Deck"; - let cards: Array<{ front: string; back: string }> = []; - - try { - // Extract text from various possible input formats - let text = typeof rawInput === 'string' - ? rawInput - : (rawInput?.description || rawInput?.text || rawInput?.content || JSON.stringify(rawInput)); - - // Convert escaped newlines to actual newlines - text = text.replace(/\\n/g, '\n'); - - // Extract title - const titleMatch = text.match(/Title:\s*(.+?)(?:\n|$)/i); - if (titleMatch) { - title = titleMatch[1].trim(); - } - - // Extract Front/Back pairs using a pattern that captures content between markers - const cardPattern = /Front:\s*([\s\S]*?)(?=\nBack:)\nBack:\s*([\s\S]*?)(?=\n\nFront:|\n*$)/gi; - let match; - - while ((match = cardPattern.exec(text)) !== null) { - const front = match[1].trim(); - const back = match[2].trim(); - if (front && back) { - cards.push({ front, back }); - } - } - - // Fallback: try a simpler line-by-line pattern if the above didn't match - if (cards.length === 0) { - const lines = text.split('\n'); - let currentFront: string | null = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const frontMatch = line.match(/^Front:\s*(.+)/i); - const backMatch = line.match(/^Back:\s*(.+)/i); - - if (frontMatch) { - currentFront = frontMatch[1].trim(); - } else if (backMatch && currentFront) { - cards.push({ front: currentFront, back: backMatch[1].trim() }); - currentFront = null; - } - } - } - - logger.debug("🎴 [CREATE-FLASHCARDS] Parsed text format:", { title, cardCount: cards.length }); - } catch (parseError) { - logger.error("❌ [CREATE-FLASHCARDS] Error parsing input:", parseError); - } - - // Validation - if (cards.length === 0) { - logger.error("❌ [CREATE-FLASHCARDS] No valid cards found in input"); - logger.groupEnd(); - return { - success: false, - message: "No valid flashcards found. Please use the format: Front: [question]\\nBack: [answer]", - }; - } - - logger.debug("🎯 [ORCHESTRATOR] Delegating to Workspace Worker (create flashcard):", { title, cardCount: cards.length }); - - if (!workspaceId) { - logger.error("❌ [CREATE-FLASHCARDS] No workspace context available"); - logger.groupEnd(); - return { - success: false, - message: "No workspace context available", - }; - } - - try { - const result = await workspaceWorker("create", { - workspaceId, - title, - itemType: "flashcard", - flashcardData: { - cards - }, - folderId: activeFolderId, - }); - - logger.debug("✅ [CREATE-FLASHCARDS] Worker result:", result); - logger.groupEnd(); - return result; - } catch (error) { - logger.error("❌ [CREATE-FLASHCARDS] Error executing worker:", error); - logger.groupEnd(); - throw error; - } - }, - }, - - // TOOL 9: Deep Research - deepResearch: { - description: "Perform deep, multi-step research on a complex topic. Use this when the user explicitly asks for 'deep research' or when a simple web search is insufficient for the depth required. This tool IMMEDIATELY creates a special research card in the workspace that will stream progress and display the final report. You should ask clarifying questions BEFORE calling this tool if the request is vague. Once ready, call this tool with the refined topic/prompt.", - inputSchema: z.object({ - prompt: z.string().describe("The detailed research topic and instructions."), - }), - execute: async ({ prompt }: { prompt: string }) => { - logger.debug("🎯 [DEEP-RESEARCH] Starting deep research for:", prompt); - - try { - const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; - if (!apiKey) { - throw new Error("GOOGLE_GENERATIVE_AI_API_KEY is not set"); - } - - if (!workspaceId) { - throw new Error("No workspace context available"); - } - - const client = new GoogleGenAI({ - apiKey: apiKey, - }); - - // Start the deep research interaction in background mode with thinking enabled - const interaction = await client.interactions.create({ - input: prompt, - agent: "deep-research-pro-preview-12-2025", - background: true, - agent_config: { - type: 'deep-research', - thinking_summaries: 'auto' - } - }); - - logger.debug("🎯 [DEEP-RESEARCH] Interaction started:", interaction.id); - - // Create a note with deep research metadata immediately - const noteResult = await workspaceWorker("create", { - workspaceId, - title: `Research: ${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}`, - deepResearchData: { - prompt, - interactionId: interaction.id, - }, - folderId: activeFolderId, - }); - - logger.debug("🎯 [DEEP-RESEARCH] Research note created:", noteResult.itemId); - - return { - noteId: noteResult.itemId, - interactionId: interaction.id, - message: "Deep research started. Check the new research card in your workspace to see progress.", - }; - } catch (error: any) { - logger.error("❌ [DEEP-RESEARCH] Error:", error); - return { - error: error?.message || "Failed to start deep research" - }; - } - }, - }, - - ...clientTools, - }; - - - - // Replace [URL_CONTEXT:...] markers with clean URL references so agent can see URLs - const cleanedMessages = convertedMessages.map((message) => { - if (message.content && Array.isArray(message.content)) { - const updatedContent = message.content.map((part) => { - if (part.type === "text" && typeof part.text === "string") { - // Replace [URL_CONTEXT:...] markers with clean URL references - const updatedText = part.text.replace(/\[URL_CONTEXT:(.+?)\]/g, (_match: string, url: string) => { - return url; // Replace marker with just the URL - }); - return { ...part, text: updatedText }; - } - return part; - }); - return { ...message, content: updatedContent } as typeof message; - } - return message; - }); - - // Append detection hints for files and URLs - let finalSystemPrompt = system; +/** + * Build the enhanced system prompt with guidelines and detection hints + */ +function buildSystemPrompt(baseSystem: string, fileUrls: string[], urlContextUrls: string[]): string { + let finalSystemPrompt = baseSystem; - // Add web search decision-making guidelines - finalSystemPrompt += ` + // Add web search decision-making guidelines + finalSystemPrompt += ` WEB SEARCH DECISION GUIDELINES: You have access to the searchWeb tool. Use the following guidelines to decide when to search vs use internal knowledge: @@ -1126,30 +136,83 @@ CONFIDENCE THRESHOLD: If you are uncertain about a fact's accuracy or currency, prefer to search rather than risk providing outdated information. `; - // Add file detection hint if file URLs are present - if (fileUrls.length > 0) { - finalSystemPrompt += `\n\nFILE DETECTION: The user's message contains ${fileUrls.length} file(s). You MUST call the processFiles tool with these URLs to analyze them: ${fileUrls.join(', ')}`; - } + // Add file detection hint if file URLs are present + if (fileUrls.length > 0) { + finalSystemPrompt += `\n\nFILE DETECTION: The user's message contains ${fileUrls.length} file(s). You MUST call the processFiles tool with these URLs to analyze them: ${fileUrls.join(', ')}`; + } + + // Add URL detection hint if URLs are present + if (urlContextUrls.length > 0) { + finalSystemPrompt += `\n\nURL DETECTION: The user's message contains ${urlContextUrls.length} URL(s): ${urlContextUrls.join(', ')}. You should call the processUrls tool with these URLs to analyze them.`; + } + + return finalSystemPrompt; +} + +export async function POST(req: Request) { + let workspaceId: string | null = null; + let activeFolderId: string | undefined; + + try { + // FIX: Parallelize headers() and req.json() to eliminate waterfall + const [headersObj, body] = await Promise.all([ + headers(), + req.json() + ]); + + // Get authenticated user ID + const session = await auth.api.getSession({ headers: headersObj }); + const userId = session?.user?.id || null; + + const messages = body.messages || []; + const system = body.system || ""; + workspaceId = extractWorkspaceId(body); + activeFolderId = body.activeFolderId; - // Add URL detection hint if URLs are present - if (urlContextUrls.length > 0) { - finalSystemPrompt += `\n\nURL DETECTION: The user's message contains ${urlContextUrls.length} URL(s): ${urlContextUrls.join(', ')}. You should call the processUrls tool with these URLs to analyze them.`; + // Convert messages + let convertedMessages; + try { + convertedMessages = await convertToModelMessages(messages); + } catch (convertError) { + logger.error("❌ [CHAT-API] convertToModelMessages FAILED:", { + error: convertError instanceof Error ? convertError.message : String(convertError), + stack: convertError instanceof Error ? convertError.stack : undefined, + }); + throw convertError; } + // Extract URLs and files from messages + const fileUrls = extractFileUrls(convertedMessages); + const urlContextUrls = extractUrlContextUrls(convertedMessages); + // Clean messages + const cleanedMessages = cleanMessages(convertedMessages); - // Debug: Log final messages before streamText + // Build system prompt + const finalSystemPrompt = buildSystemPrompt(system, fileUrls, urlContextUrls); + + // Get model + const modelId = body.modelId || "gemini-2.5-pro"; + const model = google(modelId); + + // Create tools using the modular factory + const tools = createChatTools({ + workspaceId, + userId, + activeFolderId, + clientTools: body.tools, + }); + + // Stream the response logger.debug("🔍 [CHAT-API] Final cleanedMessages before streamText:", { count: cleanedMessages.length, - lastMessage: cleanedMessages[cleanedMessages.length - 1], - lastMessageKeys: cleanedMessages[cleanedMessages.length - 1] ? Object.keys(cleanedMessages[cleanedMessages.length - 1]) : [], }); const result = streamText({ model: model, system: finalSystemPrompt, messages: cleanedMessages, - stopWhen: stepCountIs(25), // Allow up to 25 steps for tool calls + final response + stopWhen: stepCountIs(25), tools, }); @@ -1157,10 +220,11 @@ If you are uncertain about a fact's accuracy or currency, prefer to search rathe const response = result.toUIMessageStreamResponse(); logger.debug("🔍 [CHAT-API] toUIMessageStreamResponse succeeded"); return response; + } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - // Detect timeout errors - Vercel throws specific errors when maxDuration is exceeded + // Detect timeout errors const isTimeout = errorMessage.includes('timeout') || errorMessage.includes('TIMEOUT') || @@ -1179,7 +243,7 @@ If you are uncertain about a fact's accuracy or currency, prefer to search rathe message: "The request took too long to process (exceeded 30 seconds). This can happen with complex queries that require multiple tool calls or extensive processing. Please try breaking your question into smaller parts or simplifying your request.", code: "TIMEOUT", }), { - status: 504, // Gateway Timeout + status: 504, headers: { "Content-Type": "application/json" }, }); } diff --git a/src/lib/ai/tools/flashcard-tools.ts b/src/lib/ai/tools/flashcard-tools.ts index 911daa08..87f724f0 100644 --- a/src/lib/ai/tools/flashcard-tools.ts +++ b/src/lib/ai/tools/flashcard-tools.ts @@ -134,7 +134,10 @@ Math is supported: use $$...$$ for inline and $$...$$ for display math within th return result; } catch (error) { logger.error("❌ [CREATE-FLASHCARDS] Error executing worker:", error); - throw error; + return { + success: false, + message: `Error creating flashcards: ${error instanceof Error ? error.message : String(error)}`, + }; } }, }; From f0c840376d2035859f5d542972b1ff976d0a2887 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 18 Jan 2026 15:38:56 -0500 Subject: [PATCH 3/3] fix: address code review issues; add input validation and security checks --- src/lib/ai/tools/flashcard-tools.ts | 29 +++++++++++++++++++- src/lib/ai/tools/process-files.ts | 42 ++++++++++++++++++++--------- src/lib/ai/tools/process-urls.ts | 18 +++++++++++-- src/lib/ai/tools/workspace-tools.ts | 14 ++++++++++ 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/src/lib/ai/tools/flashcard-tools.ts b/src/lib/ai/tools/flashcard-tools.ts index 87f724f0..1d4e2646 100644 --- a/src/lib/ai/tools/flashcard-tools.ts +++ b/src/lib/ai/tools/flashcard-tools.ts @@ -33,7 +33,8 @@ function parseFlashcardText(rawInput: any): { title?: string; deckName?: string; } // Extract Front/Back pairs using a pattern that captures content between markers - const cardPattern = /Front:\s*([\s\S]*?)(?=\nBack:)\nBack:\s*([\s\S]*?)(?=\n\nFront:|\n*$)/gi; + // Fixed regex: use $ end anchor properly to capture last card + const cardPattern = /Front:\s*([\s\S]*?)(?=\nBack:)\nBack:\s*([\s\S]*?)(?=\n\nFront:|$)/gi; let match; while ((match = cardPattern.exec(text)) !== null) { @@ -198,6 +199,32 @@ The deck name will be matched using fuzzy search. Math is supported: use $$...$$ } try { + // Security: Verify workspace ownership before loading state + if (!ctx.userId) { + return { success: false, message: "User not authenticated" }; + } + + const { db, workspaces } = await import("@/lib/db/client"); + const { eq } = await import("drizzle-orm"); + + const workspace = await db + .select({ userId: workspaces.userId }) + .from(workspaces) + .where(eq(workspaces.id, ctx.workspaceId)) + .limit(1); + + if (!workspace[0]) { + return { success: false, message: "Workspace not found" }; + } + + if (workspace[0].userId !== ctx.userId) { + logger.warn(`🔒 [UPDATE-FLASHCARDS] Access denied for user ${ctx.userId} to workspace ${ctx.workspaceId}`); + return { + success: false, + message: "Access denied. You do not have permission to update flashcards in this workspace.", + }; + } + const state = await loadWorkspaceState(ctx.workspaceId); const searchName = deckName.toLowerCase().trim(); diff --git a/src/lib/ai/tools/process-files.ts b/src/lib/ai/tools/process-files.ts index 17e75eba..912e5795 100644 --- a/src/lib/ai/tools/process-files.ts +++ b/src/lib/ai/tools/process-files.ts @@ -9,17 +9,24 @@ type FileInfo = { fileUrl: string; filename: string; mediaType: string }; * Helper function to determine media type from URL */ function getMediaTypeFromUrl(url: string): string { - const urlLower = url.toLowerCase(); - if (urlLower.endsWith('.pdf')) return 'application/pdf'; - if (urlLower.match(/\.(jpg|jpeg)$/)) return 'image/jpeg'; - if (urlLower.endsWith('.png')) return 'image/png'; - if (urlLower.endsWith('.gif')) return 'image/gif'; - if (urlLower.endsWith('.webp')) return 'image/webp'; - if (urlLower.endsWith('.svg')) return 'image/svg+xml'; - if (urlLower.match(/\.(mp4|mov|avi)$/)) return 'video/mp4'; - if (urlLower.match(/\.(mp3|wav|ogg)$/)) return 'audio/mpeg'; - if (urlLower.match(/\.(doc|docx)$/)) return 'application/msword'; - if (urlLower.endsWith('.txt')) return 'text/plain'; + // Strip query string and fragment before checking extension + const urlPath = url.split('?')[0].split('#')[0].toLowerCase(); + + if (urlPath.endsWith('.pdf')) return 'application/pdf'; + if (urlPath.match(/\.(jpg|jpeg)$/)) return 'image/jpeg'; + if (urlPath.endsWith('.png')) return 'image/png'; + if (urlPath.endsWith('.gif')) return 'image/gif'; + if (urlPath.endsWith('.webp')) return 'image/webp'; + if (urlPath.endsWith('.svg')) return 'image/svg+xml'; + if (urlPath.endsWith('.mp4')) return 'video/mp4'; + if (urlPath.endsWith('.mov')) return 'video/quicktime'; + if (urlPath.endsWith('.avi')) return 'video/x-msvideo'; + if (urlPath.endsWith('.mp3')) return 'audio/mpeg'; + if (urlPath.endsWith('.wav')) return 'audio/wav'; + if (urlPath.endsWith('.ogg')) return 'audio/ogg'; + if (urlPath.endsWith('.doc')) return 'application/msword'; + if (urlPath.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + if (urlPath.endsWith('.txt')) return 'text/plain'; return 'application/octet-stream'; } @@ -121,10 +128,19 @@ export function createProcessFilesTool() { return "Error: Input must be a valid JSON string."; } - const urlList = parsed.urls || []; + // Validate parsed JSON shape + if (typeof parsed !== 'object' || parsed === null) { + return "Error: Input must be a JSON object with 'urls' array."; + } + + const urlList = parsed.urls; const instruction = parsed.instruction; - if (!urlList || urlList.length === 0) { + if (!Array.isArray(urlList)) { + return "Error: 'urls' must be an array."; + } + + if (urlList.length === 0) { return "No file URLs provided"; } diff --git a/src/lib/ai/tools/process-urls.ts b/src/lib/ai/tools/process-urls.ts index 313486c1..253ec44d 100644 --- a/src/lib/ai/tools/process-urls.ts +++ b/src/lib/ai/tools/process-urls.ts @@ -21,11 +21,25 @@ export function createProcessUrlsTool() { return "Error: Input must be a valid JSON string."; } - const urlList = parsed.urls || []; + // Validate parsed JSON shape + if (typeof parsed !== 'object' || parsed === null) { + return "Error: Input must be a JSON object with 'urls' array."; + } + + const urlList = parsed.urls; + + if (!Array.isArray(urlList)) { + return "Error: 'urls' must be an array."; + } + + // Validate all items are strings + if (!urlList.every((url: unknown) => typeof url === 'string')) { + return "Error: All URLs must be strings."; + } logger.debug("🔗 [URL_TOOL] Processing web URLs:", urlList); - if (!urlList || urlList.length === 0) { + if (urlList.length === 0) { return "No URLs provided"; } diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 66101e28..99233720 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -21,6 +21,20 @@ export function createNoteTool(ctx: WorkspaceToolContext) { "JSON {title, content}. 'content': markdown body. DO NOT repeat title in content. Start with subheadings/text. Math: $$...$$ inline, $$\\n...\\n$$ block. No Mermaid." ), execute: async ({ title, content }: { title: string; content: string }) => { + // Validate inputs before use + if (!title || typeof title !== 'string') { + return { + success: false, + message: "Title is required and must be a string", + }; + } + if (content === undefined || content === null || typeof content !== 'string') { + return { + success: false, + message: "Content is required and must be a string", + }; + } + logger.debug("🎯 [ORCHESTRATOR] Delegating to Workspace Worker (create note):", { title, contentLength: content.length }); if (!ctx.workspaceId) {