diff --git a/Limits.ts b/Limits.ts index beb23eb..d5948bf 100644 --- a/Limits.ts +++ b/Limits.ts @@ -1,26 +1,68 @@ -// PROJECT FREE +// ============================================================ +// Project Limits - Free Tier +// ============================================================ + +/** Maximum number of projects a free-tier user can create */ export const maxNumberOfProjectsFree = 1; -export const maxProjectSizeFree = 25000; -export const maxNumberOfProjectChatsFree = 3; -// PROJECT PRO +/** Maximum project size in characters for free-tier users */ +export const maxProjectSizeFree = 25_000; + +/** Maximum number of chats per project for free-tier users */ +export const maxNumberOfProjectChatsFree = 3; + +// ============================================================ +// Project Limits - Pro Tier +// ============================================================ + +/** Maximum number of projects a pro-tier user can create */ export const maxNumberOfProjectsPro = 15; -export const maxProjectSizePro = 100000; + +/** Maximum project size in characters for pro-tier users */ +export const maxProjectSizePro = 100_000; + +/** Maximum number of chats per project for pro-tier users */ export const maxNumberOfProjectChatsPro = 100; -// REGENERATE ARCHITECTURE FREE +// ============================================================ +// Architecture Regeneration Limits - Free Tier +// ============================================================ + +/** Maximum number of files changed allowed per regeneration for free-tier users */ export const maxFilesChangedFree = 50; -export const maxLinesChangedFree = 5000; + +/** Maximum number of lines changed allowed per regeneration for free-tier users */ +export const maxLinesChangedFree = 5_000; + +/** Maximum number of architecture regenerations for free-tier users */ export const maxFreeArchitectureRegenerations = 5; -// REGENERATE ARCHITECTURE PRO +// ============================================================ +// Architecture Regeneration Limits - Pro Tier +// ============================================================ + +/** Maximum number of files changed allowed per regeneration for pro-tier users */ export const maxFilesChangedPro = 300; -export const maxLinesChangedPro = 50000; -// CHAT FREE +/** Maximum number of lines changed allowed per regeneration for pro-tier users */ +export const maxLinesChangedPro = 50_000; + +// ============================================================ +// Chat Limits - Free Tier +// ============================================================ + +/** Maximum number of chats for free-tier users */ export const maxFreeChats = 3; -export const maxChatCharactersLimitFree = 20000; -// CHAT PRO -export const maxProChats = 100; -export const maxChatCharactersLimitPro = 100000; \ No newline at end of file +/** Maximum total characters across all messages in a chat for free-tier users */ +export const maxChatCharactersLimitFree = 20_000; + +// ============================================================ +// Chat Limits - Pro Tier +// ============================================================ + +/** Maximum number of chats for pro-tier users */ +export const maxProChats = 100; + +/** Maximum total characters across all messages in a chat for pro-tier users */ +export const maxChatCharactersLimitPro = 100_000; diff --git a/actions/chat.ts b/actions/chat.ts index c624554..afe7b13 100644 --- a/actions/chat.ts +++ b/actions/chat.ts @@ -3,6 +3,7 @@ import { db } from "@/lib/db"; import { auth } from "@clerk/nextjs/server"; import { revalidatePath } from "next/cache"; +import { CHAT_TITLE_FROM_MESSAGE_LENGTH } from "@/constants"; export interface ChatMessage { id: string; @@ -50,8 +51,8 @@ export async function createChatWithId(chatId: string, initialMessage: string) { id: chatId, userId: userId, messages: [initialMessageObj] as any, - title: initialMessage.length > 50 - ? initialMessage.substring(0, 50) + "..." + title: initialMessage.length > CHAT_TITLE_FROM_MESSAGE_LENGTH + ? initialMessage.substring(0, CHAT_TITLE_FROM_MESSAGE_LENGTH) + "..." : initialMessage, }, }); diff --git a/actions/project.ts b/actions/project.ts index 55a6ded..90f613a 100644 --- a/actions/project.ts +++ b/actions/project.ts @@ -6,6 +6,7 @@ import { ChatOpenAI } from "@langchain/openai"; import { PromptTemplate } from "@langchain/core/prompts"; import { StringOutputParser } from "@langchain/core/output_parsers"; import { generateEasyMediumPrompt, generateNthProjectPhase, generateProjectPlanDocs, initialDocsGenerationPrompt, ultraProjectChatBotPrompt } from "../prompts/ReverseArchitecture"; +import { LATEST_ARCHITECTURES_FETCH_LIMIT, CHAT_TITLE_PREVIEW_LENGTH } from "@/constants"; const openaiKey = process.env.OPENAI_API_KEY; const llm = new ChatOpenAI({ openAIApiKey: openaiKey, @@ -68,7 +69,7 @@ export async function getProject(projectId: string) { orderBy: { createdAt: 'desc', // Latest first }, - take: 5, // Only fetch latest 5 + take: LATEST_ARCHITECTURES_FETCH_LIMIT, }, detailedAnalysis: true, ProjectChat: { @@ -333,8 +334,8 @@ export async function addMessageToProjectChat(projectId: string, chatId: string, // If this is a user message and it's the first message in the chat, update the title if (message.type === 'user' && currentMessages.length === 0) { - const title = message.content.length > 20 - ? message.content.substring(0, 20) + '...' + const title = message.content.length > CHAT_TITLE_PREVIEW_LENGTH + ? message.content.substring(0, CHAT_TITLE_PREVIEW_LENGTH) + '...' : message.content; updateData.title = title; } @@ -663,8 +664,8 @@ export async function saveInitialMessageForInngestRevArchitecture( }; // Title from initial message - const title = initialMessage.length > 20 - ? initialMessage.substring(0, 20) + '...' + const title = initialMessage.length > CHAT_TITLE_PREVIEW_LENGTH + ? initialMessage.substring(0, CHAT_TITLE_PREVIEW_LENGTH) + '...' : initialMessage; const updatedProjectChat = await db.projectChat.update({ diff --git a/actions/projectDocs.ts b/actions/projectDocs.ts index 49016bb..8f1b7ad 100644 --- a/actions/projectDocs.ts +++ b/actions/projectDocs.ts @@ -15,13 +15,22 @@ import { webResearchAgentPrompt, summarizeProjectDocsContextPrompt } from "../pr import openai from "openai"; import { RunnableLambda } from "@langchain/core/runnables"; +import { + DEFAULT_MAX_INPUT_TOKENS, + DEFAULT_MAX_OUTPUT_TOKENS, + WEB_SEARCH_MAX_INPUT_TOKENS, + WEB_SEARCH_MAX_OUTPUT_TOKENS, + MIN_REMAINING_TOKENS_THRESHOLD, + FALLBACK_TRUNCATION_LENGTH, + TOKEN_TRUNCATION_SAFETY_MARGIN, +} from "@/constants"; // Custom context window manager class class ContextWindowManager { private maxInputTokens: number; private maxOutputTokens: number; - constructor(maxInputTokens: number = 8000, maxOutputTokens: number = 2000) { + constructor(maxInputTokens: number = DEFAULT_MAX_INPUT_TOKENS, maxOutputTokens: number = DEFAULT_MAX_OUTPUT_TOKENS) { this.maxInputTokens = maxInputTokens; this.maxOutputTokens = maxOutputTokens; } @@ -37,7 +46,7 @@ class ContextWindowManager { if (estimatedTokens <= maxTokens) return text; const ratio = maxTokens / estimatedTokens; - const truncatedLength = Math.floor(text.length * ratio * 0.9); // 10% safety margin + const truncatedLength = Math.floor(text.length * ratio * TOKEN_TRUNCATION_SAFETY_MARGIN); return text.substring(0, truncatedLength) + "...[truncated]"; } @@ -56,7 +65,7 @@ class ContextWindowManager { if (totalTokens + messageTokens > this.maxInputTokens) { // Truncate this message to fit remaining space const remainingTokens = this.maxInputTokens - totalTokens; - if (remainingTokens > 100) { // Only include if we have reasonable space + if (remainingTokens > MIN_REMAINING_TOKENS_THRESHOLD) { content = this.truncateToTokens(content, remainingTokens); processedMessages.unshift({ ...message, content }); } @@ -132,7 +141,7 @@ export async function summarizeProjectDocsContext(userQuery: string, projectFram export async function generateWebSearchDocs(summarizedContext: string, framework: string) { - const contextManager = new ContextWindowManager(12000, 5000); + const contextManager = new ContextWindowManager(WEB_SEARCH_MAX_INPUT_TOKENS, WEB_SEARCH_MAX_OUTPUT_TOKENS); @@ -169,7 +178,7 @@ export async function generateWebSearchDocs(summarizedContext: string, framework } catch (error) { console.error("Context window exceeded:", error); // Fallback with even more aggressive truncation - const veryShortContext = truncatedContext.substring(0, 1000); + const veryShortContext = truncatedContext.substring(0, FALLBACK_TRUNCATION_LENGTH); const response = await chain.invoke({ summarizedContext: veryShortContext, framework: framework diff --git a/src/app/project/[projectId]/page.tsx b/src/app/project/[projectId]/page.tsx index 55fee2b..ab436c9 100644 --- a/src/app/project/[projectId]/page.tsx +++ b/src/app/project/[projectId]/page.tsx @@ -20,6 +20,24 @@ import { submitFeedback } from '../../../../actions/feedback'; import { maxChatCharactersLimitFree, maxChatCharactersLimitPro, maxFreeChats, maxNumberOfProjectChatsFree, maxNumberOfProjectChatsPro } from '../../../../Limits'; import useUserSubscription from '@/hooks/useSubscription'; import PricingDialog from '@/components/PricingDialog'; +import { + POLLING_MAX_ATTEMPTS, + POLLING_INITIAL_PHASE_DURATION_MS, + POLLING_INITIAL_INTERVAL_MS, + POLLING_FINAL_INTERVAL_MS, + POSITION_SAVE_DEBOUNCE_MS, + FEEDBACK_SUCCESS_CLOSE_DELAY_MS, + COPY_FEEDBACK_RESET_DELAY_MS, + TEXTAREA_MAX_HEIGHT_PX, + PANEL_MIN_WIDTH_PERCENT, + PANEL_MAX_WIDTH_PERCENT, + MOBILE_BREAKPOINT_PX, + TREE_INDENT_PER_DEPTH_PX, + TREE_DIRECTORY_PADDING_PX, + TREE_FILE_PADDING_PX, + CHAT_TITLE_PREVIEW_LENGTH, + MAX_PROMPTS_PER_CHAT, +} from '@/constants'; interface ProjectChat { id: bigint; @@ -174,7 +192,7 @@ const ProjectPage = () => { setTimeout(() => { setIsFeedbackOpen(false); setFeedbackMessage(null); - }, 2000); + }, FEEDBACK_SUCCESS_CLOSE_DELAY_MS); } else { setFeedbackMessage({ type: 'error', @@ -304,7 +322,7 @@ const ProjectPage = () => { className={`flex items-center w-full py-1 px-2 rounded text-left group transition-colors ${ isHighlighted ? highlightStyles.bg : 'hover:bg-gray-800/50' }`} - style={{ paddingLeft: `${depth * 16 + 8}px` }} + style={{ paddingLeft: `${depth * TREE_INDENT_PER_DEPTH_PX + TREE_DIRECTORY_PADDING_PX}px` }} > { className={`flex items-center py-1 px-2 rounded cursor-default group transition-colors ${ isHighlighted ? highlightStyles.bg : 'hover:bg-gray-800/50' }`} - style={{ paddingLeft: `${depth * 16 + 24}px` }} + style={{ paddingLeft: `${depth * TREE_INDENT_PER_DEPTH_PX + TREE_FILE_PADDING_PX}px` }} title={filePath} > {getFileIcon(fileName)} @@ -447,7 +465,7 @@ const ProjectPage = () => { clearTimeout(debounceTimerRef.current); } - // Debounced save to database (only save after user stops dragging for 500ms) + // Debounced save to database (only save after user stops dragging) debounceTimerRef.current = setTimeout(async () => { if (projectId) { try { @@ -456,16 +474,16 @@ const ProjectPage = () => { console.error('Failed to save positions:', error); } } - }, 500); + }, POSITION_SAVE_DEBOUNCE_MS); }; // Function to poll for project architecture completion const pollForProjectArchitecture = async () => { - const maxAttempts = 120; // Poll for up to 10 minutes total + const maxAttempts = POLLING_MAX_ATTEMPTS; let attempts = 0; - const initialPhaseDuration = 4 * 60 * 1000; // 4 minutes in milliseconds - const initialPollInterval = 15 * 1000; // 15 seconds for first 4 minutes - const finalPollInterval = 5 * 1000; // 5 seconds after 4 minutes + const initialPhaseDuration = POLLING_INITIAL_PHASE_DURATION_MS; + const initialPollInterval = POLLING_INITIAL_INTERVAL_MS; + const finalPollInterval = POLLING_FINAL_INTERVAL_MS; const startTime = Date.now(); const poll = async () => { @@ -575,10 +593,10 @@ const ProjectPage = () => { await navigator.clipboard.writeText(prompt); setCopiedPrompts(prev => ({ ...prev, [messageId]: true })); - // Reset the copied state after 2 seconds + // Reset the copied state after delay setTimeout(() => { setCopiedPrompts(prev => ({ ...prev, [messageId]: false })); - }, 2000); + }, COPY_FEEDBACK_RESET_DELAY_MS); } catch (error) { console.error('Failed to copy prompt:', error); } @@ -886,7 +904,7 @@ const ProjectPage = () => { // Check if mobile on mount and resize useEffect(() => { const checkMobile = () => { - setIsMobile(window.innerWidth < 768); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT_PX); }; checkMobile(); @@ -905,7 +923,7 @@ const ProjectPage = () => { const totalCharacters = calculateTotalCharacters(messages); const existingPromptCount = messages.filter(m => m.type === 'assistant' && (m as any).prompt).length; setIsCharacterLimitReached(totalCharacters >= MAX_CHARACTERS); - setIsPromptLimitReached(existingPromptCount >= 3); + setIsPromptLimitReached(existingPromptCount >= MAX_PROMPTS_PER_CHAT); }, [messages]); @@ -918,8 +936,8 @@ const ProjectPage = () => { const containerWidth = containerRect.width; const newLeftWidth = (newX / containerWidth) * 100; - // Constrain between 25% and 75% - const constrainedWidth = Math.min(75, Math.max(25, newLeftWidth)); + // Constrain between min and max panel width + const constrainedWidth = Math.min(PANEL_MAX_WIDTH_PERCENT, Math.max(PANEL_MIN_WIDTH_PERCENT, newLeftWidth)); setLeftPanelWidth(constrainedWidth); } }; @@ -949,14 +967,12 @@ const ProjectPage = () => { const textarea = e.target; textarea.style.height = 'auto'; const scrollHeight = textarea.scrollHeight; - const maxHeight = 180; - - if (scrollHeight <= maxHeight) { + if (scrollHeight <= TEXTAREA_MAX_HEIGHT_PX) { textarea.style.height = scrollHeight + 'px'; setTextareaHeight(scrollHeight + 'px'); } else { - textarea.style.height = maxHeight + 'px'; - setTextareaHeight(maxHeight + 'px'); + textarea.style.height = TEXTAREA_MAX_HEIGHT_PX + 'px'; + setTextareaHeight(TEXTAREA_MAX_HEIGHT_PX + 'px'); } }; @@ -994,8 +1010,8 @@ const ProjectPage = () => { // Update chat title in local state if this is the first user message if (saveResult.success && messages.length === 0) { - const title = currentInput.length > 20 - ? currentInput.substring(0, 20) + '...' + const title = currentInput.length > CHAT_TITLE_PREVIEW_LENGTH + ? currentInput.substring(0, CHAT_TITLE_PREVIEW_LENGTH) + '...' : currentInput; setProjectChats(prevChats => diff --git a/src/app/project/page.tsx b/src/app/project/page.tsx index 20f126e..e5d232c 100644 --- a/src/app/project/page.tsx +++ b/src/app/project/page.tsx @@ -38,6 +38,13 @@ import { useEffect, useState } from "react" import { submitFeedback } from "../../../actions/feedback" import Nav from "@/components/core/Nav" import GithubOAuthDeprecatedNotice from "@/components/GithubOAuthDeprecatedNotice" +import { + SWR_DEDUPING_INTERVAL_MS, + SWR_REFRESH_INTERVAL_MS, + SWR_ERROR_RETRY_COUNT, + SWR_ERROR_RETRY_INTERVAL_MS, + FEEDBACK_SUCCESS_CLOSE_DELAY_MS, +} from '@/constants' interface Project { id: string; @@ -74,10 +81,10 @@ export default function ProjectsPage() { { revalidateOnFocus: false, // Don't refetch when window gains focus revalidateOnReconnect: true, // Refetch when reconnecting to the internet - dedupingInterval: 60000, // Dedupe requests within 1 minute - refreshInterval: 5 * 60 * 1000, // Refresh every 5 minutes - errorRetryCount: 3, // Retry on error 3 times - errorRetryInterval: 1000, // Wait 1 second between retries + dedupingInterval: SWR_DEDUPING_INTERVAL_MS, + refreshInterval: SWR_REFRESH_INTERVAL_MS, + errorRetryCount: SWR_ERROR_RETRY_COUNT, + errorRetryInterval: SWR_ERROR_RETRY_INTERVAL_MS, } ); @@ -102,7 +109,7 @@ export default function ProjectsPage() { setTimeout(() => { setIsFeedbackOpen(false); setFeedbackMessage(null); - }, 2000); + }, FEEDBACK_SUCCESS_CLOSE_DELAY_MS); } else { setFeedbackMessage({ type: 'error', diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..996edbf --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,128 @@ +// ============================================================ +// Polling Constants +// Used for architecture generation polling in project and dev pages +// ============================================================ + +/** Maximum number of polling attempts before timeout */ +export const POLLING_MAX_ATTEMPTS = 120; + +/** Duration of the initial polling phase in milliseconds (4 minutes) */ +export const POLLING_INITIAL_PHASE_DURATION_MS = 4 * 60 * 1000; + +/** Polling interval during the initial phase in milliseconds (15 seconds) */ +export const POLLING_INITIAL_INTERVAL_MS = 15 * 1000; + +/** Polling interval after the initial phase in milliseconds (5 seconds) */ +export const POLLING_FINAL_INTERVAL_MS = 5 * 1000; + +// ============================================================ +// Debounce & Timeout Constants +// ============================================================ + +/** Debounce delay for saving component positions after dragging (ms) */ +export const POSITION_SAVE_DEBOUNCE_MS = 500; + +/** Delay before closing feedback dialog after successful submission (ms) */ +export const FEEDBACK_SUCCESS_CLOSE_DELAY_MS = 2000; + +/** Delay before resetting copy-to-clipboard feedback state (ms) */ +export const COPY_FEEDBACK_RESET_DELAY_MS = 2000; + +/** Scroll debounce delay for grid opacity calculation (ms) */ +export const SCROLL_DEBOUNCE_DELAY_MS = 300; + +// ============================================================ +// SWR / Data Fetching Constants +// ============================================================ + +/** SWR deduplication interval in milliseconds (1 minute) */ +export const SWR_DEDUPING_INTERVAL_MS = 60_000; + +/** SWR auto-refresh interval in milliseconds (5 minutes) */ +export const SWR_REFRESH_INTERVAL_MS = 5 * 60 * 1000; + +/** Number of retry attempts on SWR fetch errors */ +export const SWR_ERROR_RETRY_COUNT = 3; + +/** Delay between SWR error retries in milliseconds */ +export const SWR_ERROR_RETRY_INTERVAL_MS = 1000; + +// ============================================================ +// UI Layout Constants +// ============================================================ + +/** Maximum height of the auto-resizing chat textarea in pixels */ +export const TEXTAREA_MAX_HEIGHT_PX = 180; + +/** Minimum panel width percentage for resizable panels */ +export const PANEL_MIN_WIDTH_PERCENT = 25; + +/** Maximum panel width percentage for resizable panels */ +export const PANEL_MAX_WIDTH_PERCENT = 75; + +/** Mobile breakpoint width in pixels */ +export const MOBILE_BREAKPOINT_PX = 768; + +/** Indentation per tree depth level in pixels */ +export const TREE_INDENT_PER_DEPTH_PX = 16; + +/** Base padding for directory items in the tree view (px) */ +export const TREE_DIRECTORY_PADDING_PX = 8; + +/** Base padding for file items in the tree view (px) */ +export const TREE_FILE_PADDING_PX = 24; + +// ============================================================ +// Chat & Content Truncation Constants +// ============================================================ + +/** Maximum character length for chat title preview */ +export const CHAT_TITLE_PREVIEW_LENGTH = 20; + +/** Maximum character length for initial message preview in chat title */ +export const CHAT_TITLE_FROM_MESSAGE_LENGTH = 50; + +/** Maximum number of prompts allowed per chat session */ +export const MAX_PROMPTS_PER_CHAT = 3; + +// ============================================================ +// Database Query Constants +// ============================================================ + +/** Number of latest architecture versions to fetch per project */ +export const LATEST_ARCHITECTURES_FETCH_LIMIT = 5; + +// ============================================================ +// LLM Context Window Constants +// ============================================================ + +/** Default maximum input tokens for LLM context window */ +export const DEFAULT_MAX_INPUT_TOKENS = 8000; + +/** Default maximum output tokens for LLM context window */ +export const DEFAULT_MAX_OUTPUT_TOKENS = 2000; + +/** Maximum input tokens for web search documentation generation */ +export const WEB_SEARCH_MAX_INPUT_TOKENS = 12_000; + +/** Maximum output tokens for web search documentation generation */ +export const WEB_SEARCH_MAX_OUTPUT_TOKENS = 5000; + +/** Minimum remaining tokens threshold to include a truncated message */ +export const MIN_REMAINING_TOKENS_THRESHOLD = 100; + +/** Character limit for aggressive fallback truncation */ +export const FALLBACK_TRUNCATION_LENGTH = 1000; + +/** Safety margin factor when truncating text to token limits (10% buffer) */ +export const TOKEN_TRUNCATION_SAFETY_MARGIN = 0.9; + +// ============================================================ +// Database Connection Constants +// ============================================================ + +/** Maximum wait time for acquiring a database transaction (ms) */ +export const DB_TRANSACTION_MAX_WAIT_MS = 15_000; + +/** Maximum execution time for a database transaction (ms) */ +export const DB_TRANSACTION_TIMEOUT_MS = 15_000; diff --git a/src/lib/db.ts b/src/lib/db.ts index bd9ee13..5c9828e 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,17 +1,17 @@ import { PrismaClient } from "@prisma/client"; +import { DB_TRANSACTION_MAX_WAIT_MS, DB_TRANSACTION_TIMEOUT_MS } from "@/constants"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; -// Increase transaction timeout from default 5000ms to 15000ms (15 seconds) +// Increase transaction timeout from default 5000ms to prevent "Transaction already closed" errors export const db = globalForPrisma.prisma ?? new PrismaClient({ - // Configure longer transaction timeout to prevent "Transaction already closed" errors transactionOptions: { - maxWait: 15000, // maximum time in ms to wait to acquire a transaction - timeout: 15000, // maximum time in ms for the transaction to finish + maxWait: DB_TRANSACTION_MAX_WAIT_MS, + timeout: DB_TRANSACTION_TIMEOUT_MS, }, });