diff --git a/apps/web/src/app/api/ai/chat/route.ts b/apps/web/src/app/api/ai/chat/route.ts index 9a9dacfb2..7d2064a4b 100644 --- a/apps/web/src/app/api/ai/chat/route.ts +++ b/apps/web/src/app/api/ai/chat/route.ts @@ -71,6 +71,13 @@ import { } from '@/lib/ai/core/stream-abort-registry'; import { validateUserMessageFileParts, hasFileParts } from '@/lib/ai/core/validate-image-parts'; import { hasVisionCapability } from '@/lib/ai/core/model-capabilities'; +import { + determineMessagesToInclude, + getContextWindowSize, + estimateSystemPromptTokens, + estimateToolDefinitionTokens, +} from '@pagespace/lib/ai-context-calculator'; +import { isContextLengthError } from '@/lib/ai/shared/error-messages'; // Allow streaming responses up to 5 minutes for complex AI agent interactions @@ -90,6 +97,7 @@ export async function POST(request: Request) { let selectedProvider: string | undefined; let selectedModel: string | undefined; let usagePromise: Promise | undefined; + let wasTruncated = false; const usageLogger = loggers.ai.child({ module: 'page-ai-usage' }); const permissionLogger = loggers.ai.child({ module: 'page-ai-permissions' }); @@ -741,13 +749,10 @@ export async function POST(request: Request) { }); } - // Convert UIMessages to ModelMessages for the AI model - // First sanitize messages to remove tool parts without results (prevents "input-available" state errors) + // Sanitize messages to remove tool parts without results (prevents "input-available" state errors) // NOTE: We use database-loaded messages, NOT messages from client + // modelMessages is computed after system prompt is built so we can apply context truncation const sanitizedMessages = sanitizeMessagesForModel(conversationHistory); - const modelMessages = convertToModelMessages(sanitizedMessages, { - tools: filteredTools // Use original tools - no wrapping needed - }); // Fetch user personalization for AI system prompt injection const personalization = await getUserPersonalization(userId); @@ -818,8 +823,66 @@ export async function POST(request: Request) { } loggers.ai.debug('AI Chat API: Tools configured for Page AI', { toolCount: Object.keys(filteredTools).length }); + + // Context-length guard: proactively truncate oldest messages to fit within the model's context window. + // This prevents AI_APICallError from providers when a conversation grows too long. + // We build modelMessages here (after system prompt) so we have accurate token budgeting. + const fullSystemPrompt = systemPrompt + timestampSystemPrompt + pageTreePrompt; + const contextWindow = getContextWindowSize(currentModel, currentProvider); + const systemPromptTokens = estimateSystemPromptTokens(fullSystemPrompt); + // Cast needed because filteredTools is a ToolSet (Vercel AI SDK type) but calculator expects plain object + const toolTokens = estimateToolDefinitionTokens(filteredTools as Record); + // Reserve 25% headroom for output tokens and tokenizer inaccuracies + const inputBudget = Math.floor(contextWindow * 0.75); + const truncationResult = determineMessagesToInclude( + sanitizedMessages, + inputBudget, + systemPromptTokens, + toolTokens + ); + const { includedMessages } = truncationResult; + wasTruncated = truncationResult.wasTruncated; + + if (wasTruncated) { + loggers.ai.warn('AI Chat API: Conversation truncated to fit context window', { + originalMessageCount: sanitizedMessages.length, + includedMessageCount: includedMessages.length, + model: currentModel, + provider: currentProvider, + contextWindow, + inputBudget, + systemPromptTokens, + toolTokens, + }); + } + + // Guard: if truncation left zero messages, the latest message alone exceeds the budget + if (includedMessages.length === 0) { + loggers.ai.error('AI Chat API: No messages fit within context budget', { + model: currentModel, + provider: currentProvider, + contextWindow, + inputBudget, + systemPromptTokens, + toolTokens, + originalMessageCount: sanitizedMessages.length, + }); + return NextResponse.json( + { + error: 'context_length_exceeded', + message: 'Your latest message is too large to fit within this model\'s context window. Try shortening your message or starting a new conversation.', + details: 'context_length_exceeded', + }, + { status: 413 } + ); + } + + const modelMessages = convertToModelMessages(includedMessages as UIMessage[], { + tools: filteredTools // Use original tools - no wrapping needed + }); + loggers.ai.info('AI Chat API: Starting streamText for Page AI', { model: currentModel, pageName: page.title }); - + // Create UI message stream with visual content injection support // This handles the case where tools return visual content that needs to be injected into the stream let result; @@ -1199,8 +1262,21 @@ export async function POST(request: Request) { }); // Return a proper error response - return NextResponse.json({ - error: 'Failed to process chat request. Please try again.' + const errorMsg = error instanceof Error ? error.message : ''; + if (isContextLengthError(errorMsg)) { + return NextResponse.json( + { + error: 'context_length_exceeded', + message: wasTruncated + ? 'The conversation still exceeds this model\'s context window even after trimming. Please start a new conversation.' + : 'The conversation is too long for this model\'s context window. Please start a new conversation or try a model with a larger context window.', + details: 'context_length_exceeded', + }, + { status: 413 } + ); + } + return NextResponse.json({ + error: 'Failed to process chat request. Please try again.' }, { status: 500 }); } } diff --git a/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx b/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx index 31a33a047..9e9fa3b61 100644 --- a/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx +++ b/apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; import { UIMessage } from 'ai'; +import { getAIErrorMessage } from '@/lib/ai/shared/error-messages'; import { usePathname } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { ChatInput, type ChatInputRef } from '@/components/ai/chat/input'; @@ -787,16 +788,7 @@ const SidebarChatTab: React.FC = () => { {error && showError && (

- {error.message?.includes('Unauthorized') || error.message?.includes('401') - ? 'Authentication failed. Please refresh the page and try again.' - : (error.message?.toLowerCase().includes('rate') || - error.message?.toLowerCase().includes('limit') || - error.message?.includes('429') || - error.message?.includes('402') || - error.message?.includes('Failed after') || - error.message?.includes('Provider returned error')) - ? 'Free tier rate limit hit. Please try again in a few seconds or subscribe for premium models and access.' - : 'Something went wrong. Please try again.'} + {getAIErrorMessage(error.message)}