diff --git a/.gitignore b/.gitignore index db544e5..c5900c6 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ certificates # claude code .claude +# kiro +.kiro + # typescript *.tsbuildinfo next-env.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 88531ea..39ee75d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,4 +26,4 @@ ".wrangler": true, ".open-next": true } -} \ No newline at end of file +} diff --git a/env.example b/env.example index 94d339e..93eb301 100644 --- a/env.example +++ b/env.example @@ -181,3 +181,10 @@ FAL_API_KEY="" FIREWORKS_API_KEY="" OPENAI_API_KEY="" REPLICATE_API_TOKEN="" + +# ----------------------------------------------------------------------------- +# Web Content Analyzer (Firecrawl) +# https://firecrawl.dev/ +# Get API key from https://firecrawl.dev/app +# ----------------------------------------------------------------------------- +FIRECRAWL_API_KEY="" diff --git a/messages/en.json b/messages/en.json index 3baa3e2..8f351a9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -988,9 +988,53 @@ } }, "AITextPage": { - "title": "AI Text", - "description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly", - "content": "Working in progress" + "title": "AI Text Demo", + "description": "Analyze web content with AI to extract key information, features, and insights", + "content": "Web Content Analyzer", + "subtitle": "Enter a website URL to get AI-powered analysis of its content", + "analyzer": { + "title": "Web Content Analyzer", + "description": "Analyze any website content using AI to extract structured information", + "placeholder": "Enter website URL (e.g., https://example.com)", + "button": "Analyze Website", + "loading": { + "scraping": "Scraping website content...", + "analyzing": "Analyzing content with AI..." + }, + "results": { + "title": "Analysis Results", + "newAnalysis": "Analyze Another Website", + "sections": { + "title": "Title", + "description": "Description", + "introduction": "Introduction", + "features": "Features", + "pricing": "Pricing", + "useCases": "Use Cases", + "screenshot": "Website Screenshot" + } + }, + "errors": { + "invalidUrl": "Please enter a valid URL starting with http:// or https://", + "analysisError": "Failed to analyze website. Please try again.", + "networkError": "Network error. Please check your connection and try again.", + "insufficientCredits": "Insufficient credits. Please purchase more credits to continue." + } + }, + "features": { + "scraping": { + "title": "Smart Web Scraping", + "description": "Advanced web scraping technology extracts clean, structured content from any website" + }, + "analysis": { + "title": "AI-Powered Analysis", + "description": "Intelligent AI analysis extracts key insights, features, and structured information" + }, + "results": { + "title": "Structured Results", + "description": "Get organized, easy-to-read results with clear sections and actionable insights" + } + } }, "AIImagePage": { "title": "AI Image", diff --git a/messages/zh.json b/messages/zh.json index 6c8dc9d..c0e3157 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -989,8 +989,52 @@ }, "AITextPage": { "title": "AI 文本", - "description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力", - "content": "正在开发中" + "description": "使用 AI 分析网页内容,提取关键信息、功能和见解", + "content": "网页内容分析器", + "subtitle": "输入网站 URL,使用 AI 分析其内容", + "analyzer": { + "title": "网页内容分析器", + "description": "使用 AI 分析任何网站的内容,提取结构化信息", + "placeholder": "输入网站 URL(例如:https://example.com)", + "button": "分析网站", + "loading": { + "scraping": "正在抓取网站内容...", + "analyzing": "正在使用 AI 分析内容..." + }, + "results": { + "title": "分析结果", + "newAnalysis": "分析其他网站", + "sections": { + "title": "标题", + "description": "描述", + "introduction": "介绍", + "features": "功能", + "pricing": "定价", + "useCases": "使用场景", + "screenshot": "网站截图" + } + }, + "errors": { + "invalidUrl": "请输入以 http:// 或 https:// 开头的有效 URL", + "analysisError": "分析网站失败,请重试。", + "networkError": "网络错误,请检查您的连接并重试。", + "insufficientCredits": "积分不足,请购买更多积分以继续。" + } + }, + "features": { + "scraping": { + "title": "智能网页抓取", + "description": "先进的网页抓取技术从任何网站提取干净、结构化的内容" + }, + "analysis": { + "title": "AI 驱动分析", + "description": "智能 AI 分析提取关键见解、功能和结构化信息" + }, + "results": { + "title": "结构化结果", + "description": "获得有组织、易于阅读的结果,包含清晰的部分和可操作的见解" + } + } }, "AIImagePage": { "title": "AI 图片", diff --git a/package.json b/package.json index 853cfeb..5574c3f 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^4.1.0", "@marsidev/react-turnstile": "^1.1.0", + "@mendable/firecrawl-js": "^1.29.1", "@next/third-parties": "^15.3.0", "@openpanel/nextjs": "^1.0.7", "@orama/orama": "^3.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa72161..0981dd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@marsidev/react-turnstile': specifier: ^1.1.0 version: 1.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@mendable/firecrawl-js': + specifier: ^1.29.1 + version: 1.29.1 '@next/third-parties': specifier: ^15.3.0 version: 15.3.0(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) @@ -1618,6 +1621,10 @@ packages: '@mdx-js/mdx@3.1.0': resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} + '@mendable/firecrawl-js@1.29.1': + resolution: {integrity: sha512-w7mXja6hSNL6li7BHgY6LQLnBJ9RIxWkmZ16y2MCOr3w6MlR7k2ZcTxro6vEJrUoshhyoOqhcFCyD1P0ckBuRw==} + engines: {node: '>=22.0.0'} + '@napi-rs/wasm-runtime@0.2.11': resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} @@ -4218,6 +4225,12 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.10.0: + resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -4367,6 +4380,10 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -4504,6 +4521,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4697,6 +4718,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -4807,10 +4832,23 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.0: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + formatly@0.2.4: resolution: {integrity: sha512-lIN7GpcvX/l/i24r/L9bnJ0I8Qn01qijWpQpDDvTLL29nKqSaJJu4h20+7VJ6m2CAhQ2/En/GbxDiHCzq/0MyA==} engines: {node: '>=18.3.0'} @@ -4965,6 +5003,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} @@ -5851,6 +5893,9 @@ packages: resolution: {integrity: sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==} engines: {node: '>=12.0.0'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -6356,6 +6401,9 @@ packages: tw-animate-css@1.2.4: resolution: {integrity: sha512-yt+HkJB41NAvOffe4NweJU6fLqAlVx/mBX6XmHRp15kq0JxTtOKaIw8pVSWM1Z+n2nXtyi7cW6C9f0WG/F/QAQ==} + typescript-event-target@1.1.1: + resolution: {integrity: sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg==} + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -7489,6 +7537,15 @@ snapshots: - acorn - supports-color + '@mendable/firecrawl-js@1.29.1': + dependencies: + axios: 1.10.0 + typescript-event-target: 1.1.1 + zod: 3.25.64 + zod-to-json-schema: 3.24.2(zod@3.25.64) + transitivePeerDependencies: + - debug + '@napi-rs/wasm-runtime@0.2.11': dependencies: '@emnapi/core': 1.4.3 @@ -10301,6 +10358,16 @@ snapshots: astring@1.9.0: {} + asynckit@0.4.0: {} + + axios@1.10.0: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -10465,6 +10532,10 @@ snapshots: color-string: 1.9.1 optional: true + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@11.1.0: {} @@ -10574,6 +10645,8 @@ snapshots: defu@6.1.4: {} + delayed-stream@1.0.0: {} + dequal@2.0.3: {} detect-libc@2.0.3: {} @@ -10699,6 +10772,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -10932,11 +11012,21 @@ snapshots: dependencies: to-regex-range: 5.0.1 + follow-redirects@1.15.9: {} + foreground-child@3.3.0: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + formatly@0.2.4: dependencies: fd-package-json: 2.0.0 @@ -11129,6 +11219,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hash.js@1.1.7: dependencies: inherits: 2.0.4 @@ -12326,6 +12420,8 @@ snapshots: '@types/node': 20.19.0 long: 5.3.2 + proxy-from-env@1.1.0: {} + pvtsutils@1.3.6: dependencies: tslib: 2.8.1 @@ -12997,6 +13093,8 @@ snapshots: tw-animate-css@1.2.4: {} + typescript-event-target@1.1.1: {} + typescript@5.8.3: {} uncrypto@0.1.3: {} diff --git a/src/actions/check-web-content-analysis-credits.ts b/src/actions/check-web-content-analysis-credits.ts new file mode 100644 index 0000000..5d253da --- /dev/null +++ b/src/actions/check-web-content-analysis-credits.ts @@ -0,0 +1,45 @@ +'use server'; + +import { getWebContentAnalysisCost } from '@/ai/text/utils/web-content-analyzer-config'; +import { getUserCredits, hasEnoughCredits } from '@/credits/credits'; +import { getSession } from '@/lib/server'; +import { createSafeActionClient } from 'next-safe-action'; + +const actionClient = createSafeActionClient(); + +/** + * Check if user has enough credits for web content analysis + */ +export const checkWebContentAnalysisCreditsAction = actionClient.action( + async () => { + const session = await getSession(); + if (!session) { + console.warn( + 'unauthorized request to check web content analysis credits' + ); + return { success: false, error: 'Unauthorized' }; + } + + try { + const requiredCredits = getWebContentAnalysisCost(); + const currentCredits = await getUserCredits(session.user.id); + const hasCredits = await hasEnoughCredits({ + userId: session.user.id, + requiredCredits, + }); + + return { + success: true, + hasEnoughCredits: hasCredits, + currentCredits, + requiredCredits, + }; + } catch (error) { + console.error('check web content analysis credits error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Something went wrong', + }; + } + } +); diff --git a/src/ai/text/components/analysis-results.tsx b/src/ai/text/components/analysis-results.tsx new file mode 100644 index 0000000..b533fa3 --- /dev/null +++ b/src/ai/text/components/analysis-results.tsx @@ -0,0 +1,303 @@ +'use client'; + +import type { AnalysisResultsProps } from '@/ai/text/utils/web-content-analyzer'; +import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { + CalendarIcon, + CreditCardIcon, + ExternalLinkIcon, + ImageIcon, + InfoIcon, + ListIcon, + PlusIcon, + RefreshCwIcon, + SparklesIcon, + TagIcon, +} from 'lucide-react'; +import Image from 'next/image'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { + ImageOptimization, + useLazyLoading, + useStableCallback, +} from '../utils/performance'; + +// Memoized screenshot component for better performance +const LazyScreenshot = memo( + ({ + screenshot, + title, + onLoad, + onError, + }: { + screenshot: string; + title: string; + onLoad: () => void; + onError: () => void; + }) => { + const [imageRef, isVisible] = useLazyLoading( + webContentAnalyzerConfig.performance.lazyLoadingThreshold + ); + const [imageLoading, setImageLoading] = useState(true); + + const handleImageLoad = useCallback(() => { + setImageLoading(false); + onLoad(); + }, [onLoad]); + + const handleImageError = useCallback(() => { + setImageLoading(false); + onError(); + }, [onError]); + + return ( +
+ {imageLoading && ( +
+ +
+ )} +
+ {isVisible && ( + {`Screenshot + )} +
+
+ ); + } +); + +LazyScreenshot.displayName = 'LazyScreenshot'; + +export const AnalysisResults = memo(function AnalysisResults({ + results, + screenshot, + onNewAnalysis, +}: AnalysisResultsProps) { + const [imageError, setImageError] = useState(false); + + // Memoized utility functions to prevent re-creation on every render + const formatDate = useCallback((dateString: string) => { + try { + return new Date(dateString).toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return 'Recently'; + } + }, []); + + const getDomainFromUrl = useCallback((url: string) => { + try { + return new URL(url).hostname; + } catch { + return url; + } + }, []); + + const handleImageLoad = useCallback(() => { + // Image loaded successfully + }, []); + + const handleImageError = useCallback(() => { + setImageError(true); + }, []); + + // Memoized domain and formatted date to prevent recalculation + const domain = getDomainFromUrl(results.url); + const formattedDate = formatDate(results.analyzedAt); + + return ( +
+ {/* Header Section */} + + +
+
+ + {results.title} + + + {results.description} + +
+ +
+ + Analyzed {formattedDate} +
+
+
+
+
+
+ + {/* Info section */} +
+ {/* Main Content */} +
+ {/* Introduction Section */} + + + + + Introduction + + + +

+ {results.introduction} +

+
+
+ + {/* Features Section */} + {results.features && results.features.length > 0 && ( + + + + + Features + + + +
+ {results.features.map((feature, index) => ( +
+
+

+ {feature} +

+
+ ))} +
+ + + )} + + {/* Use Cases Section */} + {results.useCases && results.useCases.length > 0 && ( + + + + + Use Cases + + + +
+ {results.useCases.map((useCase, index) => ( + + {useCase} + + ))} +
+
+
+ )} + + {/* Pricing Section */} + {results.pricing && results.pricing !== 'Not specified' && ( + + + + + Pricing + + + +

+ {results.pricing} +

+
+
+ )} +
+ + {/* Screenshot Sidebar */} +
+ + + + + Screenshot + + + + {screenshot && !imageError ? ( + + ) : ( +
+
+ +

+ {imageError + ? 'Failed to load screenshot' + : 'No screenshot available'} +

+
+
+ )} +
+
+
+
+ + {/* Action Section */} +
+
+ +
+ {/* */} +
+
+ ); +}); diff --git a/src/ai/text/components/error-display.tsx b/src/ai/text/components/error-display.tsx new file mode 100644 index 0000000..489ddd6 --- /dev/null +++ b/src/ai/text/components/error-display.tsx @@ -0,0 +1,315 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { + AlertCircleIcon, + AlertTriangleIcon, + ClockIcon, + CreditCardIcon, + HelpCircleIcon, + InfoIcon, + RefreshCwIcon, + ServerIcon, + ShieldIcon, + WifiOffIcon, +} from 'lucide-react'; +import { useState } from 'react'; +import { + ErrorSeverity, + ErrorType, + type WebContentAnalyzerError, + getRecoveryActions, +} from '../utils/error-handling'; + +interface ErrorDisplayProps { + error: WebContentAnalyzerError; + onRetry?: () => void; + onDismiss?: () => void; + className?: string; +} + +// Error icon mapping +const errorIcons = { + [ErrorType.VALIDATION]: AlertCircleIcon, + [ErrorType.NETWORK]: WifiOffIcon, + [ErrorType.CREDITS]: CreditCardIcon, + [ErrorType.SCRAPING]: ServerIcon, + [ErrorType.ANALYSIS]: HelpCircleIcon, + [ErrorType.TIMEOUT]: ClockIcon, + [ErrorType.RATE_LIMIT]: ClockIcon, + [ErrorType.AUTHENTICATION]: ShieldIcon, + [ErrorType.SERVICE_UNAVAILABLE]: ServerIcon, + [ErrorType.UNKNOWN]: AlertTriangleIcon, +}; + +// Severity color mapping +const severityColors = { + [ErrorSeverity.LOW]: { + border: 'border-blue-200 dark:border-blue-800', + bg: 'bg-blue-50 dark:bg-blue-950/20', + iconBg: 'bg-blue-100 dark:bg-blue-900/30', + iconColor: 'text-blue-600 dark:text-blue-400', + titleColor: 'text-blue-800 dark:text-blue-200', + textColor: 'text-blue-700 dark:text-blue-300', + }, + [ErrorSeverity.MEDIUM]: { + border: 'border-yellow-200 dark:border-yellow-800', + bg: 'bg-yellow-50 dark:bg-yellow-950/20', + iconBg: 'bg-yellow-100 dark:bg-yellow-900/30', + iconColor: 'text-yellow-600 dark:text-yellow-400', + titleColor: 'text-yellow-800 dark:text-yellow-200', + textColor: 'text-yellow-700 dark:text-yellow-300', + }, + [ErrorSeverity.HIGH]: { + border: 'border-red-200 dark:border-red-800', + bg: 'bg-red-50 dark:bg-red-950/20', + iconBg: 'bg-red-100 dark:bg-red-900/30', + iconColor: 'text-red-600 dark:text-red-400', + titleColor: 'text-red-800 dark:text-red-200', + textColor: 'text-red-700 dark:text-red-300', + }, + [ErrorSeverity.CRITICAL]: { + border: 'border-red-200 dark:border-red-800', + bg: 'bg-red-50 dark:bg-red-950/20', + iconBg: 'bg-red-100 dark:bg-red-900/30', + iconColor: 'text-red-600 dark:text-red-400', + titleColor: 'text-red-800 dark:text-red-200', + textColor: 'text-red-700 dark:text-red-300', + }, +}; + +// Error title mapping +const errorTitles = { + [ErrorType.VALIDATION]: 'Invalid Input', + [ErrorType.NETWORK]: 'Connection Error', + [ErrorType.CREDITS]: 'Insufficient Credits', + [ErrorType.SCRAPING]: 'Unable to Access Website', + [ErrorType.ANALYSIS]: 'Analysis Failed', + [ErrorType.TIMEOUT]: 'Request Timed Out', + [ErrorType.RATE_LIMIT]: 'Rate Limit Exceeded', + [ErrorType.AUTHENTICATION]: 'Authentication Required', + [ErrorType.SERVICE_UNAVAILABLE]: 'Service Unavailable', + [ErrorType.UNKNOWN]: 'Unexpected Error', +}; + +export function ErrorDisplay({ + error, + onRetry, + onDismiss, + className, +}: ErrorDisplayProps) { + const [isRetrying, setIsRetrying] = useState(false); + + const Icon = errorIcons[error.type]; + const colors = severityColors[error.severity]; + const title = errorTitles[error.type]; + const recoveryActions = getRecoveryActions(error); + + const handleRetry = async () => { + if (!onRetry) return; + + setIsRetrying(true); + try { + await onRetry(); + } finally { + setIsRetrying(false); + } + }; + + const handleAction = (action: string) => { + switch (action) { + case 'retry': + handleRetry(); + break; + case 'refresh': + window.location.reload(); + break; + case 'check_connection': + // Could open a network diagnostic or help page + window.open('https://www.google.com', '_blank'); + break; + case 'purchase_credits': + // Navigate to credits purchase page + window.location.href = '/settings/billing'; + break; + case 'check_balance': + // Navigate to dashboard + window.location.href = '/dashboard'; + break; + case 'sign_in': + // Navigate to sign in + window.location.href = '/auth/login'; + break; + case 'check_status': + // Could open status page + console.log('Check service status'); + break; + case 'report_issue': + // Could open support form + console.log('Report issue'); + break; + case 'wait_retry': + // Wait a bit then retry + setTimeout(handleRetry, 5000); + break; + case 'try_later': + onDismiss?.(); + break; + default: + handleRetry(); + } + }; + + return ( + + +
+
+
+
+ +
+
+
+ + {title} + +

+ {error.userMessage} +

+ + {/* Show technical details in development */} + {process.env.NODE_ENV === 'development' && ( +
+ + Technical Details + +
+                    Type: {error.type}
+                    {'\n'}Severity: {error.severity}
+                    {'\n'}Retryable: {error.retryable ? 'Yes' : 'No'}
+                    {'\n'}Message: {error.message}
+                    {error.originalError &&
+                      `\nOriginal: ${error.originalError.message}`}
+                  
+
+ )} +
+
+
+
+ + +
+ {recoveryActions.map((action, index) => ( + + ))} + + {onDismiss && ( + + )} +
+
+
+ ); +} + +// Simplified error display for inline use +export function InlineErrorDisplay({ + error, + onRetry, + className, +}: { + error: WebContentAnalyzerError; + onRetry?: () => void; + className?: string; +}) { + const [isRetrying, setIsRetrying] = useState(false); + const colors = severityColors[error.severity]; + + const handleRetry = async () => { + if (!onRetry) return; + + setIsRetrying(true); + try { + await onRetry(); + } finally { + setIsRetrying(false); + } + }; + + return ( +
+ + + {error.userMessage} + + {error.retryable && onRetry && ( + + )} +
+ ); +} diff --git a/src/ai/text/components/index.ts b/src/ai/text/components/index.ts new file mode 100644 index 0000000..d0cea3d --- /dev/null +++ b/src/ai/text/components/index.ts @@ -0,0 +1,5 @@ +export { AnalysisResults } from './analysis-results'; +export { ConsumeCreditCard } from './consume-credit-card'; +export { LoadingStates } from './loading-states'; +export { UrlInputForm } from './url-input-form'; +export { WebContentAnalyzer } from './web-content-analyzer'; diff --git a/src/ai/text/components/loading-states.tsx b/src/ai/text/components/loading-states.tsx new file mode 100644 index 0000000..0af349c --- /dev/null +++ b/src/ai/text/components/loading-states.tsx @@ -0,0 +1,155 @@ +'use client'; + +import type { LoadingStatesProps } from '@/ai/text/utils/web-content-analyzer'; +import { Progress } from '@/components/ui/progress'; +import { BotIcon, Globe2Icon, Loader2Icon, SearchIcon } from 'lucide-react'; +import { memo, useEffect, useMemo, useState } from 'react'; + +export const LoadingStates = memo(function LoadingStates({ + stage, + url, +}: LoadingStatesProps) { + const [progress, setProgress] = useState(0); + + // Simulate progress animation + useEffect(() => { + const interval = setInterval(() => { + setProgress((prev) => { + if (stage === 'scraping') { + // Scraping progress: 0-60% + return prev < 60 ? prev + 2 : 60; + } + if (stage === 'analyzing') { + // Analyzing progress: 60-100% + return prev < 100 ? prev + 1.5 : 100; + } + return prev; + }); + }, 100); + + return () => clearInterval(interval); + }, [stage]); + + // Reset progress when stage changes + useEffect(() => { + if (stage === 'scraping') { + setProgress(0); + } else if (stage === 'analyzing') { + setProgress(60); + } + }, [stage]); + + // Memoize stage configuration to prevent unnecessary recalculations + const config = useMemo(() => { + const hostname = url + ? (() => { + try { + return new URL(url).hostname; + } catch { + return 'the webpage'; + } + })() + : 'the webpage'; + + switch (stage) { + case 'scraping': + return { + icon: Globe2Icon, + title: 'Scraping URL...', + description: `Extracting content from ${hostname}`, + color: 'text-blue-600 dark:text-blue-400', + bgColor: 'bg-blue-50 dark:bg-blue-950/20', + borderColor: 'border-blue-200 dark:border-blue-800', + }; + case 'analyzing': + return { + icon: BotIcon, + title: 'Analyzing content...', + description: 'AI is processing and structuring the webpage content', + color: 'text-purple-600 dark:text-purple-400', + bgColor: 'bg-purple-50 dark:bg-purple-950/20', + borderColor: 'border-purple-200 dark:border-purple-800', + }; + default: + return { + icon: Loader2Icon, + title: 'Processing...', + description: 'Please wait while we process your request', + color: 'text-gray-600 dark:text-gray-400', + bgColor: 'bg-gray-50 dark:bg-gray-950/20', + borderColor: 'border-gray-200 dark:border-gray-800', + }; + } + }, [stage, url]); + + const IconComponent = config.icon; + + return ( +
+
+
+
+
+
+
+ +
+
+

+ {config.title} +

+ + {Math.round(progress)}% + +
+ +

+ {config.description} +

+ +
+ + +
+ = 60 + ? config.color + : 'text-muted-foreground' + } + > + Scraping content + + = 60 + ? config.color + : 'text-muted-foreground' + } + > + AI analysis + +
+
+
+
+
+
+ ); +}); diff --git a/src/ai/text/components/url-input-form.tsx b/src/ai/text/components/url-input-form.tsx new file mode 100644 index 0000000..e5c9090 --- /dev/null +++ b/src/ai/text/components/url-input-form.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { checkWebContentAnalysisCreditsAction } from '@/actions/check-web-content-analysis-credits'; +import { + type UrlInputFormProps, + urlSchema, +} from '@/ai/text/utils/web-content-analyzer'; +import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config'; +import { LoginWrapper } from '@/components/auth/login-wrapper'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { useLocalePathname } from '@/i18n/navigation'; +import { authClient } from '@/lib/auth-client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + AlertCircleIcon, + CoinsIcon, + LinkIcon, + Loader2Icon, + LogInIcon, + SparklesIcon, +} from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { useDebounce } from '../utils/performance'; + +// Form schema for URL input +const urlFormSchema = z.object({ + url: urlSchema, +}); + +type UrlFormData = z.infer; + +export function UrlInputForm({ + onSubmit, + isLoading, + disabled = false, +}: UrlInputFormProps) { + const [creditInfo, setCreditInfo] = useState<{ + hasEnoughCredits: boolean; + currentCredits: number; + requiredCredits: number; + } | null>(null); + const [mounted, setMounted] = useState(false); + + // Get authentication status and current path for callback + const { data: session, isPending: isAuthLoading } = authClient.useSession(); + const isAuthenticated = !!session?.user; + const currentPath = useLocalePathname(); + + // Prevent hydration mismatch by only rendering auth-dependent content after mount + useEffect(() => { + setMounted(true); + }, []); + + const form = useForm({ + resolver: zodResolver(urlFormSchema), + defaultValues: { + url: '', + }, + mode: 'onSubmit', // Only validate on submit to avoid premature errors + }); + + // Watch the URL field for debouncing + const urlValue = form.watch('url'); + const debouncedUrl = useDebounce( + urlValue, + webContentAnalyzerConfig.performance.urlInputDebounceMs + ); + + const { execute: checkCredits, isExecuting: isCheckingCredits } = useAction( + checkWebContentAnalysisCreditsAction, + { + onSuccess: (result) => { + if (result.data?.success) { + setCreditInfo({ + hasEnoughCredits: result.data.hasEnoughCredits ?? false, + currentCredits: result.data.currentCredits ?? 0, + requiredCredits: result.data.requiredCredits ?? 0, + }); + } else { + // Only show error toast if it's not an auth error + if (result.data?.error !== 'Unauthorized') { + setTimeout(() => { + toast.error(result.data?.error || 'Failed to check credits'); + }, 0); + } + } + }, + onError: (error) => { + console.error('Credit check error:', error); + // Only show error toast for non-auth errors + setTimeout(() => { + toast.error('Failed to check credits'); + }, 0); + }, + } + ); + + // Check credits only when user is authenticated + useEffect(() => { + if (isAuthenticated && !isAuthLoading) { + checkCredits(); + } + }, [isAuthenticated, isAuthLoading, checkCredits]); + + // Debounced URL validation effect + useEffect(() => { + if (debouncedUrl && debouncedUrl !== urlValue) { + // Trigger validation when debounced value changes + form.trigger('url'); + } + }, [debouncedUrl, urlValue, form]); + + const handleSubmit = (data: UrlFormData) => { + // For authenticated users, check credits before submitting + if (creditInfo && !creditInfo.hasEnoughCredits) { + // Defer toast to avoid flushSync during render + setTimeout(() => { + toast.error( + `Insufficient credits. You need ${creditInfo.requiredCredits} credits but only have ${creditInfo.currentCredits}.` + ); + }, 0); + return; + } + onSubmit(data.url); + }; + + const handleFormSubmit = form.handleSubmit(handleSubmit); + + const isInsufficientCredits = creditInfo && !creditInfo.hasEnoughCredits; + const isFormDisabled = isLoading || disabled || !!isInsufficientCredits; + + return ( + <> +
+
+ + ( + + +
+ + +
+
+ +
+ )} + /> + + {/* Credit Information - Only show for authenticated users */} + {isAuthenticated && creditInfo && ( +
+
+ + + Cost: {creditInfo.requiredCredits} credits + +
+
+ + Balance: {creditInfo.currentCredits} + + {!creditInfo.hasEnoughCredits && ( + + )} +
+
+ )} + + {/* Insufficient Credits Warning */} + {isAuthenticated && isInsufficientCredits && ( +
+ + + Insufficient credits. You need {creditInfo.requiredCredits}{' '} + credits but only have {creditInfo.currentCredits}. + +
+ )} + + {!mounted ? ( + // Show loading state during hydration to prevent mismatch + + ) : isAuthenticated ? ( + + ) : ( + + + + )} + + +
+ + ); +} diff --git a/src/ai/text/components/web-content-analyzer.tsx b/src/ai/text/components/web-content-analyzer.tsx new file mode 100644 index 0000000..1c32944 --- /dev/null +++ b/src/ai/text/components/web-content-analyzer.tsx @@ -0,0 +1,456 @@ +'use client'; + +import type { + AnalysisState, + AnalyzeContentResponse, + WebContentAnalyzerProps, +} from '@/ai/text/utils/web-content-analyzer'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { Component, useCallback, useReducer, useState } from 'react'; +import { toast } from 'sonner'; +import { + ErrorSeverity, + ErrorType, + WebContentAnalyzerError, + classifyError, + logError, + withRetry, +} from '../utils/error-handling'; +import { AnalysisResults as AnalysisResultsComponent } from './analysis-results'; +import { LoadingStates } from './loading-states'; +import { UrlInputForm } from './url-input-form'; + +// Action types for state reducer +type AnalysisAction = + | { type: 'START_ANALYSIS'; payload: { url: string } } + | { type: 'SET_LOADING_STAGE'; payload: { stage: 'scraping' | 'analyzing' } } + | { + type: 'SET_RESULTS'; + payload: { results: AnalysisState['results']; screenshot?: string }; + } + | { type: 'SET_ERROR'; payload: { error: string } } + | { type: 'RESET' }; + +// State reducer for better state management and performance +function analysisReducer( + state: AnalysisState, + action: AnalysisAction +): AnalysisState { + switch (action.type) { + case 'START_ANALYSIS': + return { + ...state, + url: action.payload.url, + isLoading: true, + loadingStage: 'scraping', + results: null, + error: null, + screenshot: undefined, + }; + case 'SET_LOADING_STAGE': + return { + ...state, + loadingStage: action.payload.stage, + }; + case 'SET_RESULTS': + return { + ...state, + isLoading: false, + loadingStage: null, + results: action.payload.results, + screenshot: action.payload.screenshot, + error: null, + }; + case 'SET_ERROR': + return { + ...state, + isLoading: false, + loadingStage: null, + error: action.payload.error, + }; + case 'RESET': + return { + url: '', + isLoading: false, + loadingStage: null, + results: null, + error: null, + screenshot: undefined, + }; + default: + return state; + } +} + +// Initial state +const initialState: AnalysisState = { + url: '', + isLoading: false, + loadingStage: null, + results: null, + error: null, + screenshot: undefined, +}; + +// Error boundary component for handling component errors +class ErrorBoundary extends Component< + { + children: React.ReactNode; + onError: (error: Error) => void; + }, + { hasError: boolean } +> { + constructor(props: { + children: React.ReactNode; + onError: (error: Error) => void; + }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(_: Error) { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error( + 'WebContentAnalyzer Error Boundary caught an error:', + error, + errorInfo + ); + this.props.onError(error); + } + + render() { + if (this.state.hasError) { + return ( +
+
+
+
+
+ +
+
+
+

+ Component Error +

+

+ An unexpected error occurred. Please refresh the page and try + again. +

+
+ +
+
+
+
+
+ ); + } + + return this.props.children; + } +} + +export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) { + // Use reducer for better state management and performance + const [state, dispatch] = useReducer(analysisReducer, initialState); + + // Enhanced error state + const [analyzedError, setAnalyzedError] = + useState(null); + + // Handle analysis submission with enhanced error handling + const handleAnalyzeUrl = useCallback(async (url: string) => { + // Reset state and start analysis + dispatch({ type: 'START_ANALYSIS', payload: { url } }); + setAnalyzedError(null); + + try { + // Use retry mechanism for the API call + const result = await withRetry(async () => { + const response = await fetch('/api/analyze-content', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ url }), + }); + + const data: AnalyzeContentResponse = await response.json(); + + // Handle HTTP errors + if (!response.ok) { + // Create specific error based on status code + let errorType = ErrorType.UNKNOWN; + let severity = ErrorSeverity.MEDIUM; + let retryable = true; + + switch (response.status) { + case 400: + errorType = ErrorType.VALIDATION; + retryable = false; + break; + case 401: + errorType = ErrorType.AUTHENTICATION; + severity = ErrorSeverity.HIGH; + retryable = false; + break; + case 402: + errorType = ErrorType.CREDITS; + severity = ErrorSeverity.HIGH; + retryable = false; + break; + case 408: + errorType = ErrorType.TIMEOUT; + break; + case 422: + errorType = ErrorType.SCRAPING; + break; + case 429: + errorType = ErrorType.RATE_LIMIT; + break; + case 503: + errorType = ErrorType.SERVICE_UNAVAILABLE; + severity = ErrorSeverity.HIGH; + break; + default: + errorType = ErrorType.NETWORK; + } + + throw new WebContentAnalyzerError( + errorType, + data.error || `HTTP ${response.status}: ${response.statusText}`, + data.error || 'Failed to analyze website. Please try again.', + severity, + retryable + ); + } + + if (!data.success || !data.data) { + throw new WebContentAnalyzerError( + ErrorType.ANALYSIS, + data.error || 'Analysis failed', + data.error || + 'Failed to analyze website content. Please try again.', + ErrorSeverity.MEDIUM, + true + ); + } + + return data; + }); + + // Update state to analyzing stage + dispatch({ type: 'SET_LOADING_STAGE', payload: { stage: 'analyzing' } }); + + // Simulate a brief delay for analyzing stage to show progress + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Set results and complete analysis + dispatch({ + type: 'SET_RESULTS', + payload: { + results: result.data!.analysis, + screenshot: result.data!.screenshot, + }, + }); + + // Show success toast - defer to avoid flushSync during render + setTimeout(() => { + toast.success('Website analysis completed successfully!', { + description: `Analyzed ${new URL(url).hostname}`, + }); + }, 0); + } catch (error) { + // Classify the error + const analyzedError = + error instanceof WebContentAnalyzerError ? error : classifyError(error); + + // Log the error + logError(analyzedError, { url, component: 'WebContentAnalyzer' }); + + // Update state with error + dispatch({ + type: 'SET_ERROR', + payload: { error: analyzedError.userMessage }, + }); + + // Set the analyzed error for the ErrorDisplay component + setAnalyzedError(analyzedError); + + // Show error toast with appropriate severity - defer to avoid flushSync during render + const toastOptions = { + description: analyzedError.userMessage, + }; + + setTimeout(() => { + switch (analyzedError.severity) { + case ErrorSeverity.CRITICAL: + case ErrorSeverity.HIGH: + toast.error('Analysis Failed', toastOptions); + break; + case ErrorSeverity.MEDIUM: + toast.warning('Analysis Failed', toastOptions); + break; + case ErrorSeverity.LOW: + toast.info('Analysis Issue', toastOptions); + break; + } + }, 0); + } + }, []); + + // Handle starting a new analysis + const handleNewAnalysis = useCallback(() => { + dispatch({ type: 'RESET' }); + setAnalyzedError(null); + }, []); + + // Handle component errors + const handleError = useCallback((error: Error) => { + console.error('WebContentAnalyzer component error:', error); + + dispatch({ + type: 'SET_ERROR', + payload: { + error: + 'An unexpected error occurred. Please refresh the page and try again.', + }, + }); + + // Defer toast to avoid flushSync during render + setTimeout(() => { + toast.error('Component error', { + description: 'An unexpected error occurred. Please refresh the page.', + }); + }, 0); + }, []); + + return ( + +
+ {/* Main Content Area */} +
+ {/* URL Input Form - Always visible */} + {!state.results && ( + + )} + + {/* Loading States */} + {state.isLoading && state.loadingStage && ( + + )} + + {/* Error State */} + {state.error && !state.isLoading && ( +
+
+
+
+
+ +
+
+
+

+ Analysis Failed +

+

+ {state.error} +

+
+ +
+
+
+
+
+ )} + + {/* Analysis Results */} + {state.results && !state.isLoading && ( + + )} +
+
+
+ ); +} diff --git a/src/ai/text/utils/error-handling.ts b/src/ai/text/utils/error-handling.ts new file mode 100644 index 0000000..8896d5e --- /dev/null +++ b/src/ai/text/utils/error-handling.ts @@ -0,0 +1,358 @@ +/** + * Error handling utilities for web content analyzer + */ + +// Import configuration for performance settings +import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config'; + +// Error types for different failure scenarios +export enum ErrorType { + VALIDATION = 'validation', + NETWORK = 'network', + CREDITS = 'credits', + SCRAPING = 'scraping', + ANALYSIS = 'analysis', + TIMEOUT = 'timeout', + RATE_LIMIT = 'rate_limit', + AUTHENTICATION = 'authentication', + SERVICE_UNAVAILABLE = 'service_unavailable', + UNKNOWN = 'unknown', +} + +// Error severity levels +export enum ErrorSeverity { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + CRITICAL = 'critical', +} + +// Custom error class for web content analyzer +export class WebContentAnalyzerError extends Error { + public readonly type: ErrorType; + public readonly severity: ErrorSeverity; + public readonly retryable: boolean; + public readonly userMessage: string; + public readonly originalError?: Error; + + constructor( + type: ErrorType, + message: string, + userMessage: string, + severity: ErrorSeverity = ErrorSeverity.MEDIUM, + retryable = false, + originalError?: Error + ) { + super(message); + this.name = 'WebContentAnalyzerError'; + this.type = type; + this.severity = severity; + this.retryable = retryable; + this.userMessage = userMessage; + this.originalError = originalError; + } +} + +// Error classification function +export function classifyError(error: unknown): WebContentAnalyzerError { + if (error instanceof WebContentAnalyzerError) { + return error; + } + + if (error instanceof Error) { + const message = error.message.toLowerCase(); + + // Network errors + if ( + message.includes('network') || + message.includes('fetch') || + message.includes('connection') || + message.includes('econnreset') || + message.includes('enotfound') + ) { + return new WebContentAnalyzerError( + ErrorType.NETWORK, + error.message, + 'Network connection failed. Please check your internet connection and try again.', + ErrorSeverity.MEDIUM, + true, + error + ); + } + + // Timeout errors + if ( + message.includes('timeout') || + message.includes('timed out') || + message.includes('aborted') + ) { + return new WebContentAnalyzerError( + ErrorType.TIMEOUT, + error.message, + 'Request timed out. Please try again with a simpler webpage.', + ErrorSeverity.MEDIUM, + true, + error + ); + } + + // Credit errors + if ( + message.includes('credit') || + message.includes('insufficient') || + message.includes('balance') + ) { + return new WebContentAnalyzerError( + ErrorType.CREDITS, + error.message, + 'Insufficient credits to perform analysis. Please purchase more credits.', + ErrorSeverity.HIGH, + false, + error + ); + } + + // Scraping errors + if ( + message.includes('scrape') || + message.includes('firecrawl') || + message.includes('webpage') || + message.includes('content not found') + ) { + return new WebContentAnalyzerError( + ErrorType.SCRAPING, + error.message, + 'Unable to access the webpage. Please check the URL and try again.', + ErrorSeverity.MEDIUM, + true, + error + ); + } + + // Analysis errors + if ( + message.includes('analyze') || + message.includes('openai') || + message.includes('ai') || + message.includes('model') + ) { + return new WebContentAnalyzerError( + ErrorType.ANALYSIS, + error.message, + 'Failed to analyze webpage content. Please try again.', + ErrorSeverity.MEDIUM, + true, + error + ); + } + + // Rate limit errors + if ( + message.includes('rate limit') || + message.includes('too many requests') || + message.includes('quota') + ) { + return new WebContentAnalyzerError( + ErrorType.RATE_LIMIT, + error.message, + 'Too many requests. Please wait a moment and try again.', + ErrorSeverity.MEDIUM, + true, + error + ); + } + + // Authentication errors + if ( + message.includes('unauthorized') || + message.includes('authentication') || + message.includes('token') + ) { + return new WebContentAnalyzerError( + ErrorType.AUTHENTICATION, + error.message, + 'Authentication failed. Please refresh the page and try again.', + ErrorSeverity.HIGH, + false, + error + ); + } + + // Service unavailable errors + if ( + message.includes('service unavailable') || + message.includes('503') || + message.includes('502') || + message.includes('500') + ) { + return new WebContentAnalyzerError( + ErrorType.SERVICE_UNAVAILABLE, + error.message, + 'Service is temporarily unavailable. Please try again later.', + ErrorSeverity.HIGH, + true, + error + ); + } + } + + // Unknown error + return new WebContentAnalyzerError( + ErrorType.UNKNOWN, + error instanceof Error ? error.message : 'Unknown error occurred', + 'An unexpected error occurred. Please try again.', + ErrorSeverity.MEDIUM, + true, + error instanceof Error ? error : undefined + ); +} + +// Retry configuration +export interface RetryConfig { + maxAttempts: number; + baseDelay: number; + maxDelay: number; + backoffMultiplier: number; +} + +export const defaultRetryConfig: RetryConfig = { + maxAttempts: webContentAnalyzerConfig.performance.maxRetryAttempts, + baseDelay: webContentAnalyzerConfig.performance.retryDelayMs, + maxDelay: 10000, // 10 seconds + backoffMultiplier: 2, +}; + +// Retry utility with exponential backoff +export async function withRetry( + operation: () => Promise, + config: RetryConfig = defaultRetryConfig +): Promise { + let lastError: WebContentAnalyzerError; + + for (let attempt = 1; attempt <= config.maxAttempts; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = classifyError(error); + + // Don't retry if error is not retryable or this is the last attempt + if (!lastError.retryable || attempt === config.maxAttempts) { + throw lastError; + } + + // Calculate delay with exponential backoff + const delay = Math.min( + config.baseDelay * config.backoffMultiplier ** (attempt - 1), + config.maxDelay + ); + + console.warn( + `Attempt ${attempt} failed, retrying in ${delay}ms:`, + lastError.message + ); + + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError!; +} + +// Error recovery suggestions +export function getRecoveryActions(error: WebContentAnalyzerError): Array<{ + label: string; + action: string; + primary?: boolean; +}> { + switch (error.type) { + case ErrorType.NETWORK: + return [ + { label: 'Try Again', action: 'retry', primary: true }, + { label: 'Check Connection', action: 'check_connection' }, + ]; + + case ErrorType.TIMEOUT: + return [ + { label: 'Try Again', action: 'retry', primary: true }, + { label: 'Try Simpler URL', action: 'simplify_url' }, + ]; + + case ErrorType.CREDITS: + return [ + { + label: 'Purchase Credits', + action: 'purchase_credits', + primary: true, + }, + { label: 'Check Balance', action: 'check_balance' }, + ]; + + case ErrorType.SCRAPING: + return [ + { label: 'Try Again', action: 'retry', primary: true }, + { label: 'Check URL', action: 'check_url' }, + ]; + + case ErrorType.ANALYSIS: + return [ + { label: 'Try Again', action: 'retry', primary: true }, + { label: 'Report Issue', action: 'report_issue' }, + ]; + + case ErrorType.RATE_LIMIT: + return [{ label: 'Wait and Retry', action: 'wait_retry', primary: true }]; + + case ErrorType.AUTHENTICATION: + return [ + { label: 'Refresh Page', action: 'refresh', primary: true }, + { label: 'Sign In Again', action: 'sign_in' }, + ]; + + case ErrorType.SERVICE_UNAVAILABLE: + return [ + { label: 'Try Later', action: 'try_later', primary: true }, + { label: 'Check Status', action: 'check_status' }, + ]; + + default: + return [ + { label: 'Try Again', action: 'retry', primary: true }, + { label: 'Refresh Page', action: 'refresh' }, + ]; + } +} + +// Error logging utility +export function logError( + error: WebContentAnalyzerError, + context?: Record +) { + const logData = { + type: error.type, + severity: error.severity, + message: error.message, + userMessage: error.userMessage, + retryable: error.retryable, + context, + stack: error.stack, + originalError: error.originalError?.message, + timestamp: new Date().toISOString(), + }; + + // Log based on severity + switch (error.severity) { + case ErrorSeverity.CRITICAL: + console.error('CRITICAL WebContentAnalyzer Error:', logData); + break; + case ErrorSeverity.HIGH: + console.error('HIGH WebContentAnalyzer Error:', logData); + break; + case ErrorSeverity.MEDIUM: + console.warn('MEDIUM WebContentAnalyzer Error:', logData); + break; + case ErrorSeverity.LOW: + console.info('LOW WebContentAnalyzer Error:', logData); + break; + } +} diff --git a/src/ai/text/utils/performance.ts b/src/ai/text/utils/performance.ts new file mode 100644 index 0000000..e629621 --- /dev/null +++ b/src/ai/text/utils/performance.ts @@ -0,0 +1,251 @@ +/** + * Performance optimization utilities for the web content analyzer + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Custom hook for debouncing values + * @param value - The value to debounce + * @param delay - Delay in milliseconds + * @returns The debounced value + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +/** + * Custom hook for throttling function calls + * @param callback - The function to throttle + * @param delay - Delay in milliseconds + * @returns The throttled function + */ +export function useThrottle any>( + callback: T, + delay: number +): T { + const lastRun = useRef(Date.now()); + + return useCallback( + ((...args) => { + if (Date.now() - lastRun.current >= delay) { + callback(...args); + lastRun.current = Date.now(); + } + }) as T, + [callback, delay] + ); +} + +/** + * Custom hook for lazy loading with Intersection Observer + * @param threshold - Intersection threshold (0-1) + * @param rootMargin - Root margin for the observer + * @returns [ref, isIntersecting] tuple + */ +export function useLazyLoading( + threshold = 0.1, + rootMargin = '0px' +): [React.RefObject, boolean] { + const [isIntersecting, setIsIntersecting] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsIntersecting(true); + observer.disconnect(); + } + }, + { threshold, rootMargin } + ); + + if (ref.current) { + observer.observe(ref.current); + } + + return () => observer.disconnect(); + }, [threshold, rootMargin]); + + return [ref, isIntersecting]; +} + +/** + * Custom hook for memoizing expensive calculations + * @param factory - Function that returns the value to memoize + * @param deps - Dependencies array + * @returns The memoized value + */ +export function useMemoizedValue( + factory: () => T, + deps: React.DependencyList +): T { + const [value, setValue] = useState(factory); + const depsRef = useRef(deps); + + useEffect(() => { + // Check if dependencies have changed + const hasChanged = deps.some( + (dep, index) => dep !== depsRef.current[index] + ); + + if (hasChanged) { + setValue(factory()); + depsRef.current = deps; + } + }, deps); + + return value; +} + +/** + * Utility function to truncate text at word boundaries + * @param text - Text to truncate + * @param maxLength - Maximum length + * @param suffix - Suffix to add when truncated + * @returns Truncated text + */ +export function truncateAtWordBoundary( + text: string, + maxLength: number, + suffix = '...' +): string { + if (text.length <= maxLength) { + return text; + } + + const truncated = text.substring(0, maxLength - suffix.length); + const lastSpace = truncated.lastIndexOf(' '); + + if (lastSpace > maxLength * 0.8) { + return truncated.substring(0, lastSpace) + suffix; + } + + return truncated + suffix; +} + +/** + * Utility function to create a stable callback reference + * @param callback - The callback function + * @param deps - Dependencies array + * @returns Stable callback reference + */ +export function useStableCallback any>( + callback: T, + deps: React.DependencyList +): T { + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }, deps); + + return useCallback(((...args) => callbackRef.current(...args)) as T, []); +} + +/** + * Performance monitoring utility + */ +const timers = new Map(); + +export const PerformanceMonitor = { + start(label: string): void { + timers.set(label, performance.now()); + }, + + end(label: string): number { + const startTime = timers.get(label); + if (!startTime) { + console.warn(`Performance timer '${label}' was not started`); + return 0; + } + + const duration = performance.now() - startTime; + timers.delete(label); + + if (process.env.NODE_ENV === 'development') { + console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`); + } + + return duration; + }, + + measure(label: string, fn: () => T): T { + PerformanceMonitor.start(label); + try { + return fn(); + } finally { + PerformanceMonitor.end(label); + } + }, + + async measureAsync(label: string, fn: () => Promise): Promise { + PerformanceMonitor.start(label); + try { + return await fn(); + } finally { + PerformanceMonitor.end(label); + } + }, +}; + +/** + * Image optimization utilities + */ +export const ImageOptimization = { + /** + * Create optimized image loading attributes + */ + getOptimizedImageProps: (src: string, alt: string, priority = false) => ({ + src, + alt, + loading: priority ? 'eager' : ('lazy' as const), + decoding: 'async' as const, + style: { contentVisibility: 'auto' } as React.CSSProperties, + }), + + /** + * Generate responsive image sizes + */ + getResponsiveSizes: (breakpoints: Record) => { + return Object.entries(breakpoints) + .map(([breakpoint, size]) => `(max-width: ${breakpoint}) ${size}`) + .join(', '); + }, +}; + +/** + * Content optimization utilities + */ +export const ContentOptimization = { + /** + * Optimize content for display by removing excessive whitespace + */ + optimizeContent: (content: string): string => { + return content + .replace(/\s+/g, ' ') // Replace multiple spaces with single space + .replace(/\n\s*\n/g, '\n\n') // Normalize paragraph breaks + .trim(); + }, + + /** + * Extract preview text from content + */ + extractPreview: (content: string, maxLength = 150): string => { + const cleaned = content.replace(/[#*_`]/g, '').trim(); + return truncateAtWordBoundary(cleaned, maxLength); + }, +}; diff --git a/src/ai/text/utils/web-content-analyzer-config.ts b/src/ai/text/utils/web-content-analyzer-config.ts new file mode 100644 index 0000000..784acef --- /dev/null +++ b/src/ai/text/utils/web-content-analyzer-config.ts @@ -0,0 +1,147 @@ +/** + * Web Content Analyzer Configuration + * + * This file contains configuration settings for the web content analyzer feature, + * including credit costs and other operational parameters. + */ + +export const webContentAnalyzerConfig = { + /** + * Credit cost for performing a web content analysis + */ + creditsCost: 100, + + /** + * Maximum content length for AI analysis (in characters) + * Optimized to prevent token limit issues while maintaining quality + */ + maxContentLength: 8000, + + /** + * Content truncation settings for performance optimization + */ + contentTruncation: { + /** + * Preferred truncation point as percentage of max length + * Try to truncate at sentence boundaries when possible + */ + preferredTruncationPoint: 0.8, + + /** + * Minimum content length to consider for truncation + */ + minContentLength: 1000, + + /** + * Maximum number of sentences to preserve when truncating + */ + maxSentences: 50, + }, + + /** + * Request timeout in milliseconds + */ + timeoutMillis: 55 * 1000, // 55 seconds + + /** + * Performance optimization settings + */ + performance: { + /** + * Debounce delay for URL input (in milliseconds) + */ + urlInputDebounceMs: 500, + + /** + * Image lazy loading threshold (intersection observer) + */ + lazyLoadingThreshold: 0.1, + + /** + * Maximum number of retry attempts for failed requests + */ + maxRetryAttempts: 3, + + /** + * Delay between retry attempts (in milliseconds) + */ + retryDelayMs: 1000, + }, + + /** + * Firecrawl API configuration and scraping options + */ + firecrawl: { + // API Configuration + apiKey: process.env.FIRECRAWL_API_KEY, + baseUrl: 'https://api.firecrawl.dev', + + // Default scraping options + formats: ['markdown', 'screenshot'], + includeTags: ['title', 'meta', 'h1', 'h2', 'h3', 'p', 'article'], + excludeTags: ['script', 'style', 'nav', 'footer', 'aside'], + onlyMainContent: true, + waitFor: 2000, + + // Screenshot optimization settings + screenshot: { + quality: 80, // Reduce quality for faster loading + fullPage: false, // Only capture viewport for performance + }, + + // Rate limiting and timeout settings + rateLimit: { + maxConcurrentRequests: 3, + requestDelay: 1000, // 1 second between requests + }, + + // Maximum content size (in characters) + maxContentSize: 100000, // 100KB of text content + }, + + /** + * OpenAI analysis options + */ + openai: { + model: 'gpt-4o-mini', + temperature: 0.1, // Low temperature for consistent results + /** + * Token optimization settings + */ + maxTokens: 2000, // Limit response tokens for performance + }, +} as const; + +/** + * Get the credit cost for web content analysis + */ +export function getWebContentAnalysisCost(): number { + return webContentAnalyzerConfig.creditsCost; +} + +/** + * Validates if the Firecrawl API key is configured + */ +export function validateFirecrawlConfig(): boolean { + if (!webContentAnalyzerConfig.firecrawl.apiKey) { + console.warn( + 'FIRECRAWL_API_KEY is not configured. Web content analysis features will not work.' + ); + return false; + } + return true; +} + +/** + * Validate if the web content analyzer is properly configured + */ +export function validateWebContentAnalyzerConfig(): boolean { + return ( + typeof webContentAnalyzerConfig.creditsCost === 'number' && + webContentAnalyzerConfig.creditsCost > 0 && + typeof webContentAnalyzerConfig.maxContentLength === 'number' && + webContentAnalyzerConfig.maxContentLength > 0 && + typeof webContentAnalyzerConfig.timeoutMillis === 'number' && + webContentAnalyzerConfig.timeoutMillis > 0 + ); +} diff --git a/src/ai/text/utils/web-content-analyzer.ts b/src/ai/text/utils/web-content-analyzer.ts new file mode 100644 index 0000000..a0929d8 --- /dev/null +++ b/src/ai/text/utils/web-content-analyzer.ts @@ -0,0 +1,199 @@ +import { z } from 'zod'; + +// Core Analysis Results Interface +export interface AnalysisResults { + title: string; + description: string; + introduction: string; + features: string[]; + pricing: string; + useCases: string[]; + url: string; + analyzedAt: string; +} + +// API Request/Response Interfaces +export interface AnalyzeContentRequest { + url: string; +} + +export interface AnalyzeContentResponse { + success: boolean; + data?: { + analysis: AnalysisResults; + screenshot?: string; + }; + error?: string; + creditsConsumed?: number; +} + +// Firecrawl Response Type Definitions +export interface FirecrawlResponse { + success: boolean; + data?: { + markdown: string; + screenshot?: string; + metadata?: { + title?: string; + description?: string; + url?: string; + ogTitle?: string; + ogDescription?: string; + ogImage?: string; + }; + }; + error?: string; +} + +export interface FirecrawlScrapeOptions { + formats?: ('markdown' | 'html' | 'rawHtml' | 'screenshot')[]; + includeTags?: string[]; + excludeTags?: string[]; + onlyMainContent?: boolean; + screenshot?: boolean; + fullPageScreenshot?: boolean; + waitFor?: number; +} + +// Analysis State Interface for Component State Management +export interface AnalysisState { + url: string; + isLoading: boolean; + loadingStage: 'scraping' | 'analyzing' | null; + results: AnalysisResults | null; + error: string | null; + screenshot?: string; +} + +// Component Props Interfaces +export interface WebContentAnalyzerProps { + className?: string; +} + +export interface UrlInputFormProps { + onSubmit: (url: string) => void; + isLoading: boolean; + disabled?: boolean; +} + +export interface AnalysisResultsProps { + results: AnalysisResults; + screenshot?: string; + onNewAnalysis: () => void; +} + +export interface LoadingStatesProps { + stage: 'scraping' | 'analyzing'; + url?: string; +} + +// Zod Validation Schemas + +// URL Validation Schema +export const urlSchema = z + .string() + .min(1, 'URL is required') + .url('Please enter a valid URL') + .refine( + (url) => url.startsWith('http://') || url.startsWith('https://'), + 'URL must start with http:// or https://' + ); + +// Analysis Results Schema +export const analysisResultsSchema = z.object({ + title: z.string().min(1, 'Title is required'), + description: z.string().min(1, 'Description is required'), + introduction: z.string().min(1, 'Introduction is required'), + features: z.array(z.string()).default([]), + pricing: z.string().default('Not specified'), + useCases: z.array(z.string()).default([]), + url: urlSchema, + analyzedAt: z.string().datetime(), +}); + +// API Request Schema +export const analyzeContentRequestSchema = z.object({ + url: urlSchema, +}); + +// API Response Schema +export const analyzeContentResponseSchema = z.object({ + success: z.boolean(), + data: z + .object({ + analysis: analysisResultsSchema, + screenshot: z.string().optional(), + }) + .optional(), + error: z.string().optional(), + creditsConsumed: z.number().optional(), +}); + +// Firecrawl Response Schema +export const firecrawlResponseSchema = z.object({ + success: z.boolean(), + data: z + .object({ + markdown: z.string(), + screenshot: z.string().optional(), + metadata: z + .object({ + title: z.string().optional(), + description: z.string().optional(), + url: z.string().optional(), + ogTitle: z.string().optional(), + ogDescription: z.string().optional(), + ogImage: z.string().optional(), + }) + .optional(), + }) + .optional(), + error: z.string().optional(), +}); + +// Firecrawl Scrape Options Schema +export const firecrawlScrapeOptionsSchema = z.object({ + formats: z + .array(z.enum(['markdown', 'html', 'rawHtml', 'screenshot'])) + .optional(), + includeTags: z.array(z.string()).optional(), + excludeTags: z.array(z.string()).optional(), + onlyMainContent: z.boolean().optional(), + screenshot: z.boolean().optional(), + fullPageScreenshot: z.boolean().optional(), + waitFor: z.number().optional(), +}); + +// Type exports for Zod inferred types +export type UrlInput = z.infer; +export type AnalyzeContentRequestInput = z.infer< + typeof analyzeContentRequestSchema +>; +export type AnalyzeContentResponseInput = z.infer< + typeof analyzeContentResponseSchema +>; +export type FirecrawlResponseInput = z.infer; +export type FirecrawlScrapeOptionsInput = z.infer< + typeof firecrawlScrapeOptionsSchema +>; + +// Validation helper functions +export const validateUrl = (url: string) => { + return urlSchema.safeParse(url); +}; + +export const validateAnalyzeContentRequest = (data: unknown) => { + return analyzeContentRequestSchema.safeParse(data); +}; + +export const validateAnalyzeContentResponse = (data: unknown) => { + return analyzeContentResponseSchema.safeParse(data); +}; + +export const validateFirecrawlResponse = (data: unknown) => { + return firecrawlResponseSchema.safeParse(data); +}; + +export const validateAnalysisResults = (data: unknown) => { + return analysisResultsSchema.safeParse(data); +}; diff --git a/src/app/[locale]/(marketing)/ai/text/page.tsx b/src/app/[locale]/(marketing)/ai/text/page.tsx index 5702bfc..248e850 100644 --- a/src/app/[locale]/(marketing)/ai/text/page.tsx +++ b/src/app/[locale]/(marketing)/ai/text/page.tsx @@ -1,7 +1,7 @@ -import { ConsumeCreditCard } from '@/ai/text/components/consume-credit-card'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { WebContentAnalyzer } from '@/ai/text/components/web-content-analyzer'; import { constructMetadata } from '@/lib/metadata'; import { getUrlWithLocale } from '@/lib/urls/urls'; +import { BotIcon, FileTextIcon, GlobeIcon, ZapIcon } from 'lucide-react'; import type { Metadata } from 'next'; import type { Locale } from 'next-intl'; import { getTranslations } from 'next-intl/server'; @@ -26,32 +26,66 @@ export default async function AITextPage() { const t = await getTranslations('AITextPage'); return ( -
- {/* about section */} -
-
-
- {/* avatar and name */} -
- - - -
- - - -
-

{t('content')}

-
-
+
+
+ {/* Header Section */} +
+
+ + {t('title')}
- {/* simulate consume credits */} - +

+ {t('analyzer.title')} +

+ +

+ {t('subtitle')} +

+
+ + {/* Web Content Analyzer Component */} +
+ +
+ + {/* Features Section */} +
+
+
+ +
+

+ {t('features.scraping.title')} +

+

+ {t('features.scraping.description')} +

+
+ +
+
+ +
+

+ {t('features.analysis.title')} +

+

+ {t('features.analysis.description')} +

+
+ +
+
+ +
+

+ {t('features.results.title')} +

+

+ {t('features.results.description')} +

+
diff --git a/src/app/api/analyze-content/route.ts b/src/app/api/analyze-content/route.ts new file mode 100644 index 0000000..455c29d --- /dev/null +++ b/src/app/api/analyze-content/route.ts @@ -0,0 +1,490 @@ +import { + ErrorSeverity, + ErrorType, + WebContentAnalyzerError, + classifyError, + logError, + withRetry, +} from '@/ai/text/utils/error-handling'; +import { + type AnalysisResults, + type AnalyzeContentResponse, + analyzeContentRequestSchema, + validateUrl, +} from '@/ai/text/utils/web-content-analyzer'; +import { + getWebContentAnalysisCost, + validateFirecrawlConfig, + webContentAnalyzerConfig, +} from '@/ai/text/utils/web-content-analyzer-config'; +import { consumeCredits, hasEnoughCredits } from '@/credits/credits'; +import { getSession } from '@/lib/server'; +import { openai } from '@ai-sdk/openai'; +import FirecrawlApp from '@mendable/firecrawl-js'; +import { generateObject } from 'ai'; +import { type NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +// Constants from configuration +const TIMEOUT_MILLIS = webContentAnalyzerConfig.timeoutMillis; +const CREDITS_COST = getWebContentAnalysisCost(); +const MAX_CONTENT_LENGTH = webContentAnalyzerConfig.maxContentLength; + +// Initialize Firecrawl client +const getFirecrawlClient = () => { + if (!validateFirecrawlConfig()) { + throw new Error('Firecrawl API key is not configured'); + } + return new FirecrawlApp({ + apiKey: webContentAnalyzerConfig.firecrawl.apiKey, + }); +}; + +// AI analysis schema for structured output +const analysisSchema = z.object({ + title: z.string().describe('Main title or product name from the webpage'), + description: z.string().describe('Brief description in 1-2 sentences'), + introduction: z + .string() + .describe('Detailed introduction paragraph about the content'), + features: z.array(z.string()).describe('List of key features or highlights'), + pricing: z + .string() + .describe('Pricing information or "Not specified" if unavailable'), + useCases: z.array(z.string()).describe('List of use cases or applications'), +}); + +// Timeout wrapper +const withTimeout = ( + promise: Promise, + timeoutMillis: number +): Promise => { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('Request timed out')), timeoutMillis) + ), + ]); +}; + +// Enhanced content truncation with intelligent boundary detection +const truncateContent = (content: string, maxLength: number): string => { + if (content.length <= maxLength) { + return content; + } + + const { contentTruncation } = webContentAnalyzerConfig; + const preferredLength = Math.floor( + maxLength * contentTruncation.preferredTruncationPoint + ); + + // If content is shorter than minimum threshold, use simple truncation + if (content.length < contentTruncation.minContentLength) { + return content.substring(0, maxLength) + '...'; + } + + // Try to find the best truncation point + const truncated = content.substring(0, preferredLength); + + // First, try to truncate at sentence boundaries + const sentences = content.split(/[.!?]+/); + if (sentences.length > 1) { + let sentenceLength = 0; + let sentenceCount = 0; + + for (const sentence of sentences) { + const nextLength = sentenceLength + sentence.length + 1; // +1 for punctuation + + if ( + nextLength > maxLength || + sentenceCount >= contentTruncation.maxSentences + ) { + break; + } + + sentenceLength = nextLength; + sentenceCount++; + } + + if (sentenceLength > preferredLength) { + return sentences.slice(0, sentenceCount).join('.') + '.'; + } + } + + // If sentence boundary doesn't work well, try paragraph boundaries + const paragraphs = content.split(/\n\s*\n/); + if (paragraphs.length > 1) { + let paragraphLength = 0; + + for (let i = 0; i < paragraphs.length; i++) { + const nextLength = paragraphLength + paragraphs[i].length + 2; // +2 for \n\n + + if (nextLength > maxLength) { + break; + } + + paragraphLength = nextLength; + + if (paragraphLength > preferredLength) { + return paragraphs.slice(0, i + 1).join('\n\n'); + } + } + } + + // Fallback to word boundary truncation + const words = truncated.split(' '); + const lastCompleteWord = words.slice(0, -1).join(' '); + + if (lastCompleteWord.length > preferredLength) { + return lastCompleteWord + '...'; + } + + // Final fallback to character truncation + return content.substring(0, maxLength) + '...'; +}; + +// Scrape webpage using Firecrawl with retry logic +async function scrapeWebpage( + url: string +): Promise<{ content: string; screenshot?: string }> { + return withRetry(async () => { + const firecrawl = getFirecrawlClient(); + + try { + const scrapeResponse = await firecrawl.scrapeUrl(url, { + formats: ['markdown', 'screenshot'], + onlyMainContent: webContentAnalyzerConfig.firecrawl.onlyMainContent, + waitFor: webContentAnalyzerConfig.firecrawl.waitFor, + }); + + if (!scrapeResponse.success) { + throw new WebContentAnalyzerError( + ErrorType.SCRAPING, + scrapeResponse.error || 'Failed to scrape webpage', + 'Unable to access the webpage. Please check the URL and try again.', + ErrorSeverity.MEDIUM, + true + ); + } + + const content = scrapeResponse.markdown || ''; + const screenshot = scrapeResponse.screenshot; + + if (!content.trim()) { + throw new WebContentAnalyzerError( + ErrorType.SCRAPING, + 'No content found on the webpage', + 'The webpage appears to be empty or inaccessible. Please try a different URL.', + ErrorSeverity.MEDIUM, + false + ); + } + + return { + content: truncateContent(content, MAX_CONTENT_LENGTH), + screenshot, + }; + } catch (error) { + if (error instanceof WebContentAnalyzerError) { + throw error; + } + + // Classify and throw the error + throw classifyError(error); + } + }); +} + +// Analyze content using OpenAI with retry logic +async function analyzeContent( + content: string, + url: string +): Promise { + return withRetry(async () => { + try { + const { object } = await generateObject({ + model: openai(webContentAnalyzerConfig.openai.model), + schema: analysisSchema, + prompt: ` + Analyze the following webpage content and extract structured information. + + URL: ${url} + Content: ${content} + + Please provide accurate and relevant information based on the content. If certain information is not available, use appropriate defaults: + - For pricing: use "Not specified" if no pricing information is found + - For features and use cases: provide empty arrays if none are found + - Ensure the title and description are meaningful and based on the actual content + `, + temperature: webContentAnalyzerConfig.openai.temperature, + maxTokens: webContentAnalyzerConfig.openai.maxTokens, + }); + + return { + ...object, + url, + analyzedAt: new Date().toISOString(), + }; + } catch (error) { + if (error instanceof WebContentAnalyzerError) { + throw error; + } + + // Check for specific OpenAI/AI errors + if (error instanceof Error) { + const message = error.message.toLowerCase(); + + if (message.includes('rate limit') || message.includes('quota')) { + throw new WebContentAnalyzerError( + ErrorType.RATE_LIMIT, + error.message, + 'AI service is temporarily overloaded. Please wait a moment and try again.', + ErrorSeverity.MEDIUM, + true, + error + ); + } + + if (message.includes('timeout') || message.includes('aborted')) { + throw new WebContentAnalyzerError( + ErrorType.TIMEOUT, + error.message, + 'AI analysis timed out. Please try again with a shorter webpage.', + ErrorSeverity.MEDIUM, + true, + error + ); + } + } + + // Classify and throw the error + throw classifyError(error); + } + }); +} + +export async function POST(req: NextRequest) { + const requestId = Math.random().toString(36).substring(7); + const startTime = performance.now(); + + try { + // Parse and validate request + const body = await req.json(); + const validationResult = analyzeContentRequestSchema.safeParse(body); + + if (!validationResult.success) { + const validationError = new WebContentAnalyzerError( + ErrorType.VALIDATION, + 'Invalid request parameters', + 'Please provide a valid URL.', + ErrorSeverity.MEDIUM, + false + ); + + logError(validationError, { + requestId, + validationErrors: validationResult.error, + }); + + return NextResponse.json( + { + success: false, + error: validationError.userMessage, + } satisfies AnalyzeContentResponse, + { status: 400 } + ); + } + + const { url } = validationResult.data; + + // Additional URL validation + const urlValidation = validateUrl(url); + if (!urlValidation.success) { + const urlError = new WebContentAnalyzerError( + ErrorType.VALIDATION, + urlValidation.error.errors[0]?.message || 'Invalid URL', + 'Please enter a valid URL starting with http:// or https://', + ErrorSeverity.MEDIUM, + false + ); + + logError(urlError, { requestId, url }); + + return NextResponse.json( + { + success: false, + error: urlError.userMessage, + } satisfies AnalyzeContentResponse, + { status: 400 } + ); + } + + // Check authentication + const session = await getSession(); + if (!session) { + const authError = new WebContentAnalyzerError( + ErrorType.AUTHENTICATION, + 'Authentication required', + 'Please sign in to analyze web content.', + ErrorSeverity.HIGH, + false + ); + + logError(authError, { requestId }); + + return NextResponse.json( + { + success: false, + error: authError.userMessage, + } satisfies AnalyzeContentResponse, + { status: 401 } + ); + } + + // Check if Firecrawl is configured + if (!validateFirecrawlConfig()) { + const configError = new WebContentAnalyzerError( + ErrorType.SERVICE_UNAVAILABLE, + 'Firecrawl API key is not configured', + 'Web content analysis service is temporarily unavailable.', + ErrorSeverity.CRITICAL, + false + ); + + logError(configError, { requestId }); + + return NextResponse.json( + { + success: false, + error: configError.userMessage, + } satisfies AnalyzeContentResponse, + { status: 503 } + ); + } + + // Check if user has sufficient credits before starting analysis + const hasCredits = await hasEnoughCredits({ + userId: session.user.id, + requiredCredits: CREDITS_COST, + }); + + if (!hasCredits) { + const creditError = new WebContentAnalyzerError( + ErrorType.CREDITS, + 'Insufficient credits to perform analysis', + "You don't have enough credits to analyze this webpage. Please purchase more credits.", + ErrorSeverity.HIGH, + false + ); + + logError(creditError, { + requestId, + userId: session.user.id, + requiredCredits: CREDITS_COST, + }); + + return NextResponse.json( + { + success: false, + error: creditError.userMessage, + } satisfies AnalyzeContentResponse, + { status: 402 } + ); + } + + console.log( + `Starting analysis [requestId=${requestId}, url=${url}, userId=${session.user.id}]` + ); + + // Perform analysis with timeout and enhanced error handling + const analysisPromise = (async () => { + try { + // Step 1: Scrape webpage + const { content, screenshot } = await scrapeWebpage(url); + + // Step 2: Analyze content with AI + const analysis = await analyzeContent(content, url); + + // Step 3: Consume credits (only on successful analysis) + await consumeCredits({ + userId: session.user.id, + amount: CREDITS_COST, + description: `Web content analysis: ${url}`, + }); + + return { analysis, screenshot }; + } catch (error) { + // If it's already a WebContentAnalyzerError, just re-throw + if (error instanceof WebContentAnalyzerError) { + throw error; + } + + // Otherwise classify the error + throw classifyError(error); + } + })(); + + // Apply timeout wrapper + const result = await withTimeout(analysisPromise, TIMEOUT_MILLIS); + + const elapsed = ((performance.now() - startTime) / 1000).toFixed(1); + console.log( + `Analysis completed [requestId=${requestId}, elapsed=${elapsed}s]` + ); + + return NextResponse.json({ + success: true, + data: result, + creditsConsumed: CREDITS_COST, + } satisfies AnalyzeContentResponse); + } catch (error) { + const elapsed = ((performance.now() - startTime) / 1000).toFixed(1); + + // Classify the error if it's not already a WebContentAnalyzerError + const analyzedError = + error instanceof WebContentAnalyzerError ? error : classifyError(error); + + // Log the error with context + logError(analyzedError, { + requestId, + elapsed: `${elapsed}s`, + url: req.url, + }); + + // Determine status code based on error type + let statusCode = 500; + switch (analyzedError.type) { + case ErrorType.VALIDATION: + statusCode = 400; + break; + case ErrorType.AUTHENTICATION: + statusCode = 401; + break; + case ErrorType.CREDITS: + statusCode = 402; + break; + case ErrorType.TIMEOUT: + statusCode = 408; + break; + case ErrorType.SCRAPING: + statusCode = 422; + break; + case ErrorType.RATE_LIMIT: + statusCode = 429; + break; + case ErrorType.SERVICE_UNAVAILABLE: + statusCode = 503; + break; + default: + statusCode = 500; + } + + return NextResponse.json( + { + success: false, + error: analyzedError.userMessage, + } satisfies AnalyzeContentResponse, + { status: statusCode } + ); + } +}