feat: add ai text demo & scraping and analyzing the web content from URL
This commit is contained in:
parent
757f1dc4ae
commit
3075681dc8
3
.gitignore
vendored
3
.gitignore
vendored
@ -41,6 +41,9 @@ certificates
|
|||||||
# claude code
|
# claude code
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
# kiro
|
||||||
|
.kiro
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -26,4 +26,4 @@
|
|||||||
".wrangler": true,
|
".wrangler": true,
|
||||||
".open-next": true
|
".open-next": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -181,3 +181,10 @@ FAL_API_KEY=""
|
|||||||
FIREWORKS_API_KEY=""
|
FIREWORKS_API_KEY=""
|
||||||
OPENAI_API_KEY=""
|
OPENAI_API_KEY=""
|
||||||
REPLICATE_API_TOKEN=""
|
REPLICATE_API_TOKEN=""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Web Content Analyzer (Firecrawl)
|
||||||
|
# https://firecrawl.dev/
|
||||||
|
# Get API key from https://firecrawl.dev/app
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FIRECRAWL_API_KEY=""
|
||||||
|
@ -988,9 +988,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AITextPage": {
|
"AITextPage": {
|
||||||
"title": "AI Text",
|
"title": "AI Text Demo",
|
||||||
"description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
|
"description": "Analyze web content with AI to extract key information, features, and insights",
|
||||||
"content": "Working in progress"
|
"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": {
|
"AIImagePage": {
|
||||||
"title": "AI Image",
|
"title": "AI Image",
|
||||||
|
@ -989,8 +989,52 @@
|
|||||||
},
|
},
|
||||||
"AITextPage": {
|
"AITextPage": {
|
||||||
"title": "AI 文本",
|
"title": "AI 文本",
|
||||||
"description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
|
"description": "使用 AI 分析网页内容,提取关键信息、功能和见解",
|
||||||
"content": "正在开发中"
|
"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": {
|
"AIImagePage": {
|
||||||
"title": "AI 图片",
|
"title": "AI 图片",
|
||||||
|
@ -37,6 +37,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^4.1.0",
|
"@hookform/resolvers": "^4.1.0",
|
||||||
"@marsidev/react-turnstile": "^1.1.0",
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
|
"@mendable/firecrawl-js": "^1.29.1",
|
||||||
"@next/third-parties": "^15.3.0",
|
"@next/third-parties": "^15.3.0",
|
||||||
"@openpanel/nextjs": "^1.0.7",
|
"@openpanel/nextjs": "^1.0.7",
|
||||||
"@orama/orama": "^3.1.4",
|
"@orama/orama": "^3.1.4",
|
||||||
|
98
pnpm-lock.yaml
generated
98
pnpm-lock.yaml
generated
@ -47,6 +47,9 @@ importers:
|
|||||||
'@marsidev/react-turnstile':
|
'@marsidev/react-turnstile':
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.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':
|
'@next/third-parties':
|
||||||
specifier: ^15.3.0
|
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)
|
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':
|
'@mdx-js/mdx@3.1.0':
|
||||||
resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==}
|
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':
|
'@napi-rs/wasm-runtime@0.2.11':
|
||||||
resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==}
|
resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==}
|
||||||
|
|
||||||
@ -4218,6 +4225,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
|
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
|
||||||
hasBin: true
|
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:
|
bail@2.0.2:
|
||||||
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
|
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
|
||||||
|
|
||||||
@ -4367,6 +4380,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
|
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
|
||||||
engines: {node: '>=12.5.0'}
|
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:
|
comma-separated-tokens@2.0.3:
|
||||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||||
|
|
||||||
@ -4504,6 +4521,10 @@ packages:
|
|||||||
defu@6.1.4:
|
defu@6.1.4:
|
||||||
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
|
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0:
|
||||||
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
dequal@2.0.3:
|
dequal@2.0.3:
|
||||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -4697,6 +4718,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
esast-util-from-estree@2.0.0:
|
||||||
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
|
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
|
||||||
|
|
||||||
@ -4807,10 +4832,23 @@ packages:
|
|||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
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:
|
foreground-child@3.3.0:
|
||||||
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
|
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
form-data@4.0.4:
|
||||||
|
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
formatly@0.2.4:
|
formatly@0.2.4:
|
||||||
resolution: {integrity: sha512-lIN7GpcvX/l/i24r/L9bnJ0I8Qn01qijWpQpDDvTLL29nKqSaJJu4h20+7VJ6m2CAhQ2/En/GbxDiHCzq/0MyA==}
|
resolution: {integrity: sha512-lIN7GpcvX/l/i24r/L9bnJ0I8Qn01qijWpQpDDvTLL29nKqSaJJu4h20+7VJ6m2CAhQ2/En/GbxDiHCzq/0MyA==}
|
||||||
engines: {node: '>=18.3.0'}
|
engines: {node: '>=18.3.0'}
|
||||||
@ -4965,6 +5003,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
has-tostringtag@1.0.2:
|
||||||
|
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
hash.js@1.1.7:
|
hash.js@1.1.7:
|
||||||
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
|
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
|
||||||
|
|
||||||
@ -5851,6 +5893,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==}
|
resolution: {integrity: sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0:
|
||||||
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
pvtsutils@1.3.6:
|
pvtsutils@1.3.6:
|
||||||
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
||||||
|
|
||||||
@ -6356,6 +6401,9 @@ packages:
|
|||||||
tw-animate-css@1.2.4:
|
tw-animate-css@1.2.4:
|
||||||
resolution: {integrity: sha512-yt+HkJB41NAvOffe4NweJU6fLqAlVx/mBX6XmHRp15kq0JxTtOKaIw8pVSWM1Z+n2nXtyi7cW6C9f0WG/F/QAQ==}
|
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:
|
typescript@5.8.3:
|
||||||
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
|
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@ -7489,6 +7537,15 @@ snapshots:
|
|||||||
- acorn
|
- acorn
|
||||||
- supports-color
|
- 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':
|
'@napi-rs/wasm-runtime@0.2.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.4.3
|
'@emnapi/core': 1.4.3
|
||||||
@ -10301,6 +10358,16 @@ snapshots:
|
|||||||
|
|
||||||
astring@1.9.0: {}
|
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: {}
|
bail@2.0.2: {}
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
@ -10465,6 +10532,10 @@ snapshots:
|
|||||||
color-string: 1.9.1
|
color-string: 1.9.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
comma-separated-tokens@2.0.3: {}
|
comma-separated-tokens@2.0.3: {}
|
||||||
|
|
||||||
commander@11.1.0: {}
|
commander@11.1.0: {}
|
||||||
@ -10574,6 +10645,8 @@ snapshots:
|
|||||||
|
|
||||||
defu@6.1.4: {}
|
defu@6.1.4: {}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
detect-libc@2.0.3: {}
|
detect-libc@2.0.3: {}
|
||||||
@ -10699,6 +10772,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
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:
|
esast-util-from-estree@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree-jsx': 1.0.5
|
'@types/estree-jsx': 1.0.5
|
||||||
@ -10932,11 +11012,21 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
|
|
||||||
|
follow-redirects@1.15.9: {}
|
||||||
|
|
||||||
foreground-child@3.3.0:
|
foreground-child@3.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
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:
|
formatly@0.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
fd-package-json: 2.0.0
|
fd-package-json: 2.0.0
|
||||||
@ -11129,6 +11219,10 @@ snapshots:
|
|||||||
|
|
||||||
has-symbols@1.1.0: {}
|
has-symbols@1.1.0: {}
|
||||||
|
|
||||||
|
has-tostringtag@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
has-symbols: 1.1.0
|
||||||
|
|
||||||
hash.js@1.1.7:
|
hash.js@1.1.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
@ -12326,6 +12420,8 @@ snapshots:
|
|||||||
'@types/node': 20.19.0
|
'@types/node': 20.19.0
|
||||||
long: 5.3.2
|
long: 5.3.2
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
pvtsutils@1.3.6:
|
pvtsutils@1.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@ -12997,6 +13093,8 @@ snapshots:
|
|||||||
|
|
||||||
tw-animate-css@1.2.4: {}
|
tw-animate-css@1.2.4: {}
|
||||||
|
|
||||||
|
typescript-event-target@1.1.1: {}
|
||||||
|
|
||||||
typescript@5.8.3: {}
|
typescript@5.8.3: {}
|
||||||
|
|
||||||
uncrypto@0.1.3: {}
|
uncrypto@0.1.3: {}
|
||||||
|
45
src/actions/check-web-content-analysis-credits.ts
Normal file
45
src/actions/check-web-content-analysis-credits.ts
Normal file
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
303
src/ai/text/components/analysis-results.tsx
Normal file
303
src/ai/text/components/analysis-results.tsx
Normal file
@ -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 (
|
||||||
|
<div ref={imageRef} className="relative">
|
||||||
|
{imageLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-muted rounded-lg">
|
||||||
|
<RefreshCwIcon className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative aspect-[4/3] overflow-hidden rounded-lg border bg-muted">
|
||||||
|
{isVisible && (
|
||||||
|
<Image
|
||||||
|
src={screenshot}
|
||||||
|
alt={`Screenshot of ${title}`}
|
||||||
|
fill
|
||||||
|
className="object-cover object-top transition-opacity duration-300"
|
||||||
|
style={{
|
||||||
|
opacity: imageLoading ? 0 : 1,
|
||||||
|
}}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
onError={handleImageError}
|
||||||
|
sizes="(max-width: 1024px) 100vw, 33vw"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="w-full max-w-4xl mx-auto space-y-6">
|
||||||
|
{/* Header Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<CardTitle className="text-2xl font-bold leading-tight">
|
||||||
|
{results.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
{results.description}
|
||||||
|
</CardDescription>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ExternalLinkIcon className="size-4" />
|
||||||
|
<a
|
||||||
|
href={results.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-foreground transition-colors underline"
|
||||||
|
>
|
||||||
|
{domain}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<CalendarIcon className="size-4" />
|
||||||
|
<span>Analyzed {formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Info section */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Introduction Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<InfoIcon className="size-5" />
|
||||||
|
Introduction
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{results.introduction}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
{results.features && results.features.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ListIcon className="size-5" />
|
||||||
|
Features
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{results.features.map((feature, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{feature}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Use Cases Section */}
|
||||||
|
{results.useCases && results.useCases.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TagIcon className="size-5" />
|
||||||
|
Use Cases
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{results.useCases.map((useCase, index) => (
|
||||||
|
<Badge key={index} variant="secondary" className="text-xs">
|
||||||
|
{useCase}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pricing Section */}
|
||||||
|
{results.pricing && results.pricing !== 'Not specified' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CreditCardIcon className="size-5" />
|
||||||
|
Pricing
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{results.pricing}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Screenshot Sidebar */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="sticky top-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="size-5" />
|
||||||
|
Screenshot
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{screenshot && !imageError ? (
|
||||||
|
<LazyScreenshot
|
||||||
|
screenshot={screenshot}
|
||||||
|
title={results.title}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="aspect-[4/3] flex items-center justify-center bg-muted rounded-lg border">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<ImageIcon className="size-8 text-muted-foreground mx-auto" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{imageError
|
||||||
|
? 'Failed to load screenshot'
|
||||||
|
: 'No screenshot available'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Section */}
|
||||||
|
<div className="py-6">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={onNewAnalysis}
|
||||||
|
size="lg"
|
||||||
|
className="w-full max-w-md cursor-pointer"
|
||||||
|
>
|
||||||
|
<SparklesIcon className="size-4" />
|
||||||
|
Analyze Another Website
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* <Separator className="my-6" /> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
315
src/ai/text/components/error-display.tsx
Normal file
315
src/ai/text/components/error-display.tsx
Normal file
@ -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 (
|
||||||
|
<Card className={cn('w-full max-w-2xl mx-auto', className)}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className={cn('rounded-lg border p-6', colors.border, colors.bg)}>
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className={cn('rounded-full p-2', colors.iconBg)}>
|
||||||
|
<Icon className={cn('size-5', colors.iconColor)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle
|
||||||
|
className={cn('text-lg font-semibold', colors.titleColor)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
<p className={cn('mt-2 text-sm', colors.textColor)}>
|
||||||
|
{error.userMessage}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Show technical details in development */}
|
||||||
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary
|
||||||
|
className={cn('text-xs cursor-pointer', colors.textColor)}
|
||||||
|
>
|
||||||
|
Technical Details
|
||||||
|
</summary>
|
||||||
|
<pre
|
||||||
|
className={cn(
|
||||||
|
'mt-2 text-xs whitespace-pre-wrap',
|
||||||
|
colors.textColor
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Type: {error.type}
|
||||||
|
{'\n'}Severity: {error.severity}
|
||||||
|
{'\n'}Retryable: {error.retryable ? 'Yes' : 'No'}
|
||||||
|
{'\n'}Message: {error.message}
|
||||||
|
{error.originalError &&
|
||||||
|
`\nOriginal: ${error.originalError.message}`}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{recoveryActions.map((action, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={action.primary ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAction(action.action)}
|
||||||
|
disabled={isRetrying && action.action === 'retry'}
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isRetrying && action.action === 'retry' ? (
|
||||||
|
<RefreshCwIcon className="size-4 animate-spin" />
|
||||||
|
) : action.action === 'retry' ? (
|
||||||
|
<RefreshCwIcon className="size-4" />
|
||||||
|
) : action.action === 'refresh' ? (
|
||||||
|
<RefreshCwIcon className="size-4" />
|
||||||
|
) : action.action === 'check_connection' ? (
|
||||||
|
<WifiOffIcon className="size-4" />
|
||||||
|
) : action.action === 'purchase_credits' ? (
|
||||||
|
<CreditCardIcon className="size-4" />
|
||||||
|
) : action.action === 'sign_in' ? (
|
||||||
|
<ShieldIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<InfoIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{onDismiss && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="ml-auto cursor-pointer"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 p-3 rounded-lg border',
|
||||||
|
colors.border,
|
||||||
|
colors.bg,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertCircleIcon
|
||||||
|
className={cn('size-4 flex-shrink-0', colors.iconColor)}
|
||||||
|
/>
|
||||||
|
<span className={cn('text-sm flex-1', colors.textColor)}>
|
||||||
|
{error.userMessage}
|
||||||
|
</span>
|
||||||
|
{error.retryable && onRetry && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
className={cn('cursor-pointer h-auto p-1', colors.textColor)}
|
||||||
|
>
|
||||||
|
{isRetrying ? (
|
||||||
|
<RefreshCwIcon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCwIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
5
src/ai/text/components/index.ts
Normal file
5
src/ai/text/components/index.ts
Normal file
@ -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';
|
155
src/ai/text/components/loading-states.tsx
Normal file
155
src/ai/text/components/loading-states.tsx
Normal file
@ -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 (
|
||||||
|
<div className="w-full max-w-2xl mx-auto">
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border p-6 ${config.bgColor} ${config.borderColor} transition-all duration-300`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
className={`rounded-full p-3 ${config.bgColor} ${config.borderColor} border`}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
className={`size-6 ${config.color} ${
|
||||||
|
stage === 'scraping' || stage === 'analyzing'
|
||||||
|
? 'animate-pulse'
|
||||||
|
: 'animate-spin'
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className={`text-lg font-semibold ${config.color}`}>
|
||||||
|
{config.title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-muted-foreground font-medium">
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{config.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Progress
|
||||||
|
value={progress}
|
||||||
|
className="h-2"
|
||||||
|
aria-label={`${config.title} ${Math.round(progress)}% complete`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
stage === 'scraping' || progress >= 60
|
||||||
|
? config.color
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Scraping content
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
stage === 'analyzing' || progress >= 60
|
||||||
|
? config.color
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
AI analysis
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
265
src/ai/text/components/url-input-form.tsx
Normal file
265
src/ai/text/components/url-input-form.tsx
Normal file
@ -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<typeof urlFormSchema>;
|
||||||
|
|
||||||
|
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<UrlFormData>({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="w-full max-w-2xl mx-auto">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<LinkIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground size-4" />
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com"
|
||||||
|
disabled={isFormDisabled}
|
||||||
|
className="pl-10"
|
||||||
|
autoComplete="url"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Credit Information - Only show for authenticated users */}
|
||||||
|
{isAuthenticated && creditInfo && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CoinsIcon className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Cost: {creditInfo.requiredCredits} credits
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
creditInfo.hasEnoughCredits
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-red-600 dark:text-red-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Balance: {creditInfo.currentCredits}
|
||||||
|
</span>
|
||||||
|
{!creditInfo.hasEnoughCredits && (
|
||||||
|
<AlertCircleIcon className="size-4 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Insufficient Credits Warning */}
|
||||||
|
{isAuthenticated && isInsufficientCredits && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-400">
|
||||||
|
<AlertCircleIcon className="size-4 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
Insufficient credits. You need {creditInfo.requiredCredits}{' '}
|
||||||
|
credits but only have {creditInfo.currentCredits}.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!mounted ? (
|
||||||
|
// Show loading state during hydration to prevent mismatch
|
||||||
|
<Button type="button" disabled className="w-full" size="lg">
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
<span>Loading...</span>
|
||||||
|
</Button>
|
||||||
|
) : isAuthenticated ? (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isFormDisabled || !urlValue.trim()}
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isAuthLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
<span>Loading...</span>
|
||||||
|
</>
|
||||||
|
) : isCheckingCredits ? (
|
||||||
|
<>
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
<span>Checking Credits...</span>
|
||||||
|
</>
|
||||||
|
) : isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
<span>Analyzing...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SparklesIcon className="size-4" />
|
||||||
|
<span>
|
||||||
|
Analyze Website
|
||||||
|
{creditInfo && ` (${creditInfo.requiredCredits} credits)`}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<LoginWrapper mode="modal" asChild callbackUrl={currentPath}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<LogInIcon className="size-4" />
|
||||||
|
<span>Sign In First</span>
|
||||||
|
</Button>
|
||||||
|
</LoginWrapper>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
456
src/ai/text/components/web-content-analyzer.tsx
Normal file
456
src/ai/text/components/web-content-analyzer.tsx
Normal file
@ -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 (
|
||||||
|
<div className="w-full max-w-2xl mx-auto">
|
||||||
|
<div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/20 p-6">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="rounded-full p-2 bg-red-100 dark:bg-red-900/30">
|
||||||
|
<svg
|
||||||
|
className="size-5 text-red-600 dark:text-red-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold text-red-800 dark:text-red-200">
|
||||||
|
Component Error
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||||
|
An unexpected error occurred. Please refresh the page and try
|
||||||
|
again.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
variant="outline"
|
||||||
|
className="text-red-700 dark:text-red-200 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 border-red-200 dark:border-red-800"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="size-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Refresh Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<WebContentAnalyzerError | null>(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 (
|
||||||
|
<ErrorBoundary onError={handleError}>
|
||||||
|
<div className={cn('w-full space-y-8', className)}>
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* URL Input Form - Always visible */}
|
||||||
|
{!state.results && (
|
||||||
|
<UrlInputForm
|
||||||
|
onSubmit={handleAnalyzeUrl}
|
||||||
|
isLoading={state.isLoading}
|
||||||
|
disabled={state.isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading States */}
|
||||||
|
{state.isLoading && state.loadingStage && (
|
||||||
|
<LoadingStates stage={state.loadingStage} url={state.url} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{state.error && !state.isLoading && (
|
||||||
|
<div className="w-full max-w-2xl mx-auto">
|
||||||
|
<div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/20 p-6">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="rounded-full p-2 bg-red-100 dark:bg-red-900/30">
|
||||||
|
<svg
|
||||||
|
className="size-5 text-red-600 dark:text-red-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold text-red-800 dark:text-red-200">
|
||||||
|
Analysis Failed
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleNewAnalysis}
|
||||||
|
variant="outline"
|
||||||
|
className="text-red-700 dark:text-red-200 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 border-red-200 dark:border-red-800"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="size-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Analysis Results */}
|
||||||
|
{state.results && !state.isLoading && (
|
||||||
|
<AnalysisResultsComponent
|
||||||
|
results={state.results}
|
||||||
|
screenshot={state.screenshot}
|
||||||
|
onNewAnalysis={handleNewAnalysis}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
358
src/ai/text/utils/error-handling.ts
Normal file
358
src/ai/text/utils/error-handling.ts
Normal file
@ -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<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
config: RetryConfig = defaultRetryConfig
|
||||||
|
): Promise<T> {
|
||||||
|
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<string, any>
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
251
src/ai/text/utils/performance.ts
Normal file
251
src/ai/text/utils/performance.ts
Normal file
@ -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<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(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<T extends (...args: any[]) => 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<T extends HTMLElement = HTMLDivElement>(
|
||||||
|
threshold = 0.1,
|
||||||
|
rootMargin = '0px'
|
||||||
|
): [React.RefObject<T | null>, boolean] {
|
||||||
|
const [isIntersecting, setIsIntersecting] = useState(false);
|
||||||
|
const ref = useRef<T | null>(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<T>(
|
||||||
|
factory: () => T,
|
||||||
|
deps: React.DependencyList
|
||||||
|
): T {
|
||||||
|
const [value, setValue] = useState<T>(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<T extends (...args: any[]) => 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<string, number>();
|
||||||
|
|
||||||
|
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<T>(label: string, fn: () => T): T {
|
||||||
|
PerformanceMonitor.start(label);
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
PerformanceMonitor.end(label);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async measureAsync<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
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<string, string>) => {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
147
src/ai/text/utils/web-content-analyzer-config.ts
Normal file
147
src/ai/text/utils/web-content-analyzer-config.ts
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
199
src/ai/text/utils/web-content-analyzer.ts
Normal file
199
src/ai/text/utils/web-content-analyzer.ts
Normal file
@ -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<typeof urlSchema>;
|
||||||
|
export type AnalyzeContentRequestInput = z.infer<
|
||||||
|
typeof analyzeContentRequestSchema
|
||||||
|
>;
|
||||||
|
export type AnalyzeContentResponseInput = z.infer<
|
||||||
|
typeof analyzeContentResponseSchema
|
||||||
|
>;
|
||||||
|
export type FirecrawlResponseInput = z.infer<typeof firecrawlResponseSchema>;
|
||||||
|
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);
|
||||||
|
};
|
@ -1,7 +1,7 @@
|
|||||||
import { ConsumeCreditCard } from '@/ai/text/components/consume-credit-card';
|
import { WebContentAnalyzer } from '@/ai/text/components/web-content-analyzer';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
||||||
import { constructMetadata } from '@/lib/metadata';
|
import { constructMetadata } from '@/lib/metadata';
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||||
|
import { BotIcon, FileTextIcon, GlobeIcon, ZapIcon } from 'lucide-react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import type { Locale } from 'next-intl';
|
import type { Locale } from 'next-intl';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
@ -26,32 +26,66 @@ export default async function AITextPage() {
|
|||||||
const t = await getTranslations('AITextPage');
|
const t = await getTranslations('AITextPage');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="min-h-screen bg-background rounded-lg">
|
||||||
{/* about section */}
|
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||||
<div className="relative max-w-(--breakpoint-md) mx-auto mb-24 mt-8 md:mt-16">
|
{/* Header Section */}
|
||||||
<div className="mx-auto flex flex-col justify-between gap-8">
|
<div className="text-center space-y-6 mb-12">
|
||||||
<div className="flex flex-row items-center gap-8">
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium">
|
||||||
{/* avatar and name */}
|
<ZapIcon className="size-4" />
|
||||||
<div className="flex items-center gap-8">
|
{t('title')}
|
||||||
<Avatar className="size-32 p-0.5">
|
|
||||||
<AvatarImage
|
|
||||||
className="rounded-full border-4 border-gray-200"
|
|
||||||
src="/logo.png"
|
|
||||||
alt="Avatar"
|
|
||||||
/>
|
|
||||||
<AvatarFallback>
|
|
||||||
<div className="size-32 text-muted-foreground" />
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl text-foreground">{t('content')}</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* simulate consume credits */}
|
<h1 className="text-4xl md:text-6xl font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
|
||||||
<ConsumeCreditCard />
|
{t('analyzer.title')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
|
||||||
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Web Content Analyzer Component */}
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<WebContentAnalyzer className="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<div className="mt-24 grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="inline-flex items-center justify-center size-12 rounded-lg bg-blue-100 dark:bg-blue-900/20">
|
||||||
|
<GlobeIcon className="size-6 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t('features.scraping.title')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('features.scraping.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="inline-flex items-center justify-center size-12 rounded-lg bg-green-100 dark:bg-green-900/20">
|
||||||
|
<BotIcon className="size-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t('features.analysis.title')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('features.analysis.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="inline-flex items-center justify-center size-12 rounded-lg bg-purple-100 dark:bg-purple-900/20">
|
||||||
|
<FileTextIcon className="size-6 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t('features.results.title')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('features.results.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
490
src/app/api/analyze-content/route.ts
Normal file
490
src/app/api/analyze-content/route.ts
Normal file
@ -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 = <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMillis: number
|
||||||
|
): Promise<T> => {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, 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<AnalysisResults> {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user