diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b69c88..36a5aa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.0] - 2026-03-18 20:44 UTC + +### Added +- **Comprehensive SEO Audit Engine** — Industry-grade URL analysis modeled after Screaming Frog, Ahrefs, Sitebulb, and Semrush + - Full meta tag analysis: title (length/status), description (length/status), keywords, viewport, charset, robots directives + - Open Graph & Twitter Card validation + - Heading structure audit: H1 count check, hierarchy analysis (H1-H6), multiple H1 detection + - Link analysis: internal/external/nofollow counts, external link sampling + - Image audit: total count, alt text coverage %, lazy loading detection, missing alt samples + - Content analysis: word count, sentence count, paragraph count, avg words/sentence + - Structured data detection: JSON-LD, Microdata, 12+ schema types (Article, FAQ, Product, LocalBusiness, etc.) + - Performance signals: server info, response time, HTML size, external scripts/stylesheets, inline styles, preconnect/preload/dns-prefetch, async/defer detection + - Accessibility checks: lang attribute, ARIA labels, first image alt text + - Hreflang tag detection for international SEO + - Canonical tag validation with mismatch detection + - **Automated scoring**: Overall, Technical, Content, Performance, Social scores (0-100) + - **Issue detection**: Critical/Warning/Info severity levels across all categories +- **Plan-First Flow Fix** — Non-code agents (SEO, content, SMM, design, web, app) now bypass the plan-first workflow + - No more "Start Coding" or "Modify Plan" buttons for non-code agent responses + - SEO agent goes straight to preview mode after generating audit reports + - Plan card only shown for code/general agents + +### Changed +- `/api/fetch-url` route completely rewritten: 300+ lines of comprehensive HTML parsing and scoring +- SEO agent data injection now passes structured audit data with scores, issues, and category breakdowns + +### Technical Details +- Files modified: 5 (AIAssist.tsx, qwen-oauth.ts, ollama-cloud.ts, zai-plan.ts, openrouter.ts) +- Files rewritten: 1 (`app/api/fetch-url/route.ts` — complete rewrite) +- New component-level guard: `isCodeAgent` condition for plan-first workflow + ## [1.6.0] - 2026-03-18 20:34 UTC ### Added diff --git a/README.md b/README.md index b27df84..e8d5b8c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PromptArch: AI Orchestration Platform -> **Latest Version**: [v1.6.0](CHANGELOG.md#160---2026-03-18) (2026-03-18) +> **Latest Version**: [v1.7.0](CHANGELOG.md#170---2026-03-18) (2026-03-18) > **Development Note**: This entire platform was developed exclusively using [TRAE.AI IDE](https://trae.ai) powered by elite [GLM 4.7 model](https://z.ai/subscribe?ic=R0K78RJKNW). > **Learn more about this architecture [here](https://z.ai/subscribe?ic=R0K78RJKNW).** @@ -143,6 +143,7 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). | Version | Date | Highlights | |---------|------|------------| +| [1.7.0](CHANGELOG.md#170---2026-03-18) | 2026-03-18 20:44 UTC | Industry-grade SEO audit, plan flow fix for non-code agents | | [1.6.0](CHANGELOG.md#160---2026-03-18) | 2026-03-18 20:34 | SEO web audit, URL fetching, auto web search for SEO mode | | [1.5.0](CHANGELOG.md#150---2026-03-18) | 2026-03-18 20:29 | Modification progress overlay, preview blink fix | | [1.4.0](CHANGELOG.md#140---2026-03-18) | 2026-03-18 19:57 | Review Code button, web search grounding, responsive preview, model selector fix | diff --git a/app/api/fetch-url/route.ts b/app/api/fetch-url/route.ts index 9e95153..5c318aa 100644 --- a/app/api/fetch-url/route.ts +++ b/app/api/fetch-url/route.ts @@ -1,117 +1,269 @@ -/** - * Next.js API route: Fetch URL content for SEO/web auditing. - * Endpoint: GET /api/fetch-url?url=https://example.com - * Returns: { title, meta, headings, text, links, status } - */ - -import { NextRequest, NextResponse } from "next/server"; - -export async function GET(request: NextRequest) { - const targetUrl = request.nextUrl.searchParams.get("url"); - - if (!targetUrl) { - return NextResponse.json({ error: "URL parameter required" }, { status: 400 }); - } - - try { - new URL(targetUrl); - } catch { - return NextResponse.json({ error: "Invalid URL" }, { status: 400 }); - } - - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 12000); - - const res = await fetch(targetUrl, { - signal: controller.signal, - headers: { - "User-Agent": "PromptArch-SEO-Bot/1.5 (https://rommark.dev)", - Accept: "text/html,application/xhtml+xml,text/plain;q=0.9", - }, - }); - clearTimeout(timeout); - - if (!res.ok) { - return NextResponse.json({ error: `HTTP ${res.status}`, status: res.status }); - } - - const html = await res.text(); - - const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i); - const title = titleMatch ? titleMatch[1].trim() : ""; - - const descMatch = html.match(/]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i) - || html.match(/]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']description["']/i); - const metaDescription = descMatch ? descMatch[1].trim() : ""; - - const kwMatch = html.match(/]*name\s*=\s*["']keywords["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i) - || html.match(/]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']keywords["']/i); - const metaKeywords = kwMatch ? kwMatch[1].trim() : ""; - - const headings: { level: number; text: string }[] = []; - const headingRegex = /]*>([\s\S]*?)<\/h[1-6]>/gi; - let hMatch; - while ((hMatch = headingRegex.exec(html)) !== null) { - const text = hMatch[2].replace(/<[^>]*>/g, "").trim(); - if (text) headings.push({ level: parseInt(hMatch[1]), text }); - } - - const links: { href: string; text: string; internal: boolean }[] = []; - const linkRegex = /]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi; - let lMatch; - const baseDomain = new URL(targetUrl).hostname; - while ((lMatch = linkRegex.exec(html)) !== null) { - const href = lMatch[1].trim(); - const text = lMatch[2].replace(/<[^>]*>/g, "").trim().substring(0, 100); - if (!href || href.startsWith("#") || href.startsWith("javascript:")) continue; - try { - const linkDomain = new URL(href, targetUrl).hostname; - links.push({ href, text, internal: linkDomain === baseDomain }); - } catch { continue; } - } - - const images: { src: string; alt: string }[] = []; - const imgRegex = /]*src\s*=\s*["']([^"']*)["'][^>]*alt\s*=\s*["']([^"']*)["'][^>]*\/?>/gi; - let iMatch; - while ((iMatch = imgRegex.exec(html)) !== null) { - images.push({ src: iMatch[1], alt: iMatch[2] }); - } - - const plainText = html - .replace(//gi, "") - .replace(//gi, "") - .replace(/<[^>]*>/g, " ") - .replace(/\s+/g, " ") - .trim() - .substring(0, 5000); - - const canonicalMatch = html.match(/]*rel\s*=\s*["']canonical["'][^>]*href\s*=\s*["']([^"']*)["']/i); - const canonical = canonicalMatch ? canonicalMatch[1] : ""; - - const ogTitleMatch = html.match(/]*property\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i); - const ogTitle = ogTitleMatch ? ogTitleMatch[1].trim() : ""; - - const ogDescMatch = html.match(/]*property\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i); - const ogDescription = ogDescMatch ? ogDescMatch[1].trim() : ""; - - return NextResponse.json({ - url: targetUrl, - title, - metaDescription, - metaKeywords, - canonical, - ogTitle, - ogDescription, - headings, - links: links.slice(0, 100), - images: images.slice(0, 50), - text: plainText, - htmlLength: html.length, - status: res.status, - }); - } catch (error) { - const msg = error instanceof Error ? error.message : "Fetch failed"; - return NextResponse.json({ error: msg }, { status: 500 }); - } -} +/** + * Next.js API route: Fetch URL content for comprehensive SEO/GEO auditing. + * Endpoint: GET /api/fetch-url?url=https://example.com + * Returns: Full SEO audit data (technical, content, performance signals, accessibility) + */ + +import { NextRequest, NextResponse } from "next/server"; + +function countOccurrences(text: string, regex: RegExp): number { + return (text.match(regex) || []).length; +} + +export async function GET(request: NextRequest) { + const targetUrl = request.nextUrl.searchParams.get("url"); + + if (!targetUrl) { + return NextResponse.json({ error: "URL parameter required" }, { status: 400 }); + } + + let normalizedUrl = targetUrl; + if (!normalizedUrl.startsWith("http")) normalizedUrl = "https://" + normalizedUrl; + + try { + new URL(normalizedUrl); + } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }); + } + + const startTime = Date.now(); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + + const res = await fetch(normalizedUrl, { + signal: controller.signal, + headers: { + "User-Agent": "Mozilla/5.0 (compatible; PromptArch-SEOAudit/1.6; +https://rommark.dev)", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + }, + }); + clearTimeout(timeout); + + const responseTime = Date.now() - startTime; + + if (!res.ok) { + return NextResponse.json({ error: `HTTP ${res.status}`, status: res.status, url: normalizedUrl, responseTime }); + } + + const html = await res.text(); + const urlObj = new URL(normalizedUrl); + const baseDomain = urlObj.hostname; + + // === HTTP HEADERS === + const headers: Record = {}; + res.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; }); + const isHttps = normalizedUrl.startsWith("https://"); + const server = headers["server"] || "Unknown"; + const xFrameOptions = headers["x-frame-options"] || null; + const contentEncoding = headers["content-encoding"] || ""; + + // === ROBOTS & CANONICAL === + const robotsMeta = html.match(/]*name\s*=\s*["']robots["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i) + || html.match(/]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']robots["']/i); + const robotsDirectives = robotsMeta ? robotsMeta[1].trim() : null; + + const canonicalMatch = html.match(/]*rel\s*=\s*["']canonical["'][^>]*href\s*=\s*["']([^"']*)["']/i) + || html.match(/]*href\s*=\s*["']([^"']*)["'][^>]*rel\s*=\s*["']canonical["']/i); + const canonical = canonicalMatch ? canonicalMatch[1] : null; + const hasCanonicalMismatch = canonical && canonical !== normalizedUrl && !canonical.startsWith("/") && new URL(canonical).href !== new URL(normalizedUrl).href; + + // === META TAGS === + const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i); + const title = titleMatch ? titleMatch[1].trim() : null; + const titleLength = title ? title.length : 0; + + const descMatch = html.match(/]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i) + || html.match(/]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']description["']/i); + const metaDescription = descMatch ? descMatch[1].trim() : null; + const descLength = metaDescription ? metaDescription.length : 0; + + const kwMatch = html.match(/]*name\s*=\s*["']keywords["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i) + || html.match(/]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']keywords["']/i); + const metaKeywords = kwMatch ? kwMatch[1].trim() : null; + + const viewportMatch = html.match(/]*name\s*=\s*["']viewport["'][^>]*content\s*=\s*["']([^"']*)["']/i); + const viewport = viewportMatch ? viewportMatch[1].trim() : null; + + const charsetMatch = html.match(/]*charset\s*=\s*["']?([^"'\s>]+)/i) + || html.match(/]*content\s*=\s*["'][^"']*charset=([^"'\s]+)/i); + const charset = charsetMatch ? charsetMatch[1].trim() : null; + + // === OPEN GRAPH === + const ogTitle = html.match(/]*property\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']*)["']/i); + const ogDesc = html.match(/]*property\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']*)["']/i); + const ogImage = html.match(/]*property\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']*)["']/i); + const ogType = html.match(/]*property\s*=\s*["']og:type["'][^>]*content\s*=\s*["']([^"']*)["']/i); + const ogUrl = html.match(/]*property\s*=\s*["']og:url["'][^>]*content\s*=\s*["']([^"']*)["']/i); + + // === TWITTER CARD === + const twCard = html.match(/]*name\s*=\s*["']twitter:card["'][^>]*content\s*=\s*["']([^"']*)["']/i); + const twTitle = html.match(/]*name\s*=\s*["']twitter:title["'][^>]*content\s*=\s*["']([^"']*)["']/i); + const twDesc = html.match(/]*name\s*=\s*["']twitter:description["'][^>]*content\s*=\s*["']([^"']*)["']/i); + + // === HEADING STRUCTURE === + const headings: { level: number; text: string }[] = []; + const headingRegex = /]*>([\s\S]*?)<\/h[1-6]>/gi; + let hMatch; + while ((hMatch = headingRegex.exec(html)) !== null) { + const text = hMatch[2].replace(/<[^>]*>/g, "").trim(); + if (text) headings.push({ level: parseInt(hMatch[1]), text }); + } + const h1Count = headings.filter(h => h.level === 1).length; + const h2Count = headings.filter(h => h.level === 2).length; + const h3Count = headings.filter(h => h.level === 3).length; + const h4Count = headings.filter(h => h.level === 4).length; + const headingHierarchy = headings.map(h => ({ level: h.level, text: h.text.substring(0, 100) })); + + // === LINKS === + const links: { href: string; text: string; internal: boolean; nofollow: boolean }[] = []; + const linkRegex = /]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi; + let lMatch; + while ((lMatch = linkRegex.exec(html)) !== null) { + const href = lMatch[1].trim(); + const fullTag = lMatch[0]; + const text = lMatch[2].replace(/<[^>]*>/g, "").trim().substring(0, 100); + if (!href || href.startsWith("#") || href.startsWith("javascript:") || href.startsWith("mailto:")) continue; + const isNofollow = /rel\s*=\s*["'][^"']*nofollow/i.test(fullTag); + try { + const linkDomain = new URL(href, normalizedUrl).hostname; + links.push({ href, text, internal: linkDomain === baseDomain, nofollow: isNofollow }); + } catch { continue; } + } + const internalLinks = links.filter(l => l.internal); + const externalLinks = links.filter(l => !l.internal); + const nofollowLinks = links.filter(l => l.nofollow); + + // === IMAGES === + const images: { src: string; alt: string; loading?: string }[] = []; + const imgRegex = /]*)\/?>/gi; + let iMatch; + while ((iMatch = imgRegex.exec(html)) !== null) { + const attrs = iMatch[1]; + const srcMatch = attrs.match(/src\s*=\s*["']([^"']*)["']/i); + const altMatch = attrs.match(/alt\s*=\s*["']([^"']*)["']/i); + const loadMatch = attrs.match(/loading\s*=\s*["']([^"']*)["']/i); + if (srcMatch) { + images.push({ src: srcMatch[1], alt: altMatch ? altMatch[1] : "", loading: loadMatch ? loadMatch[1] : undefined }); + } + } + const imagesWithAlt = images.filter(img => img.alt && img.alt.trim().length > 0); + const imagesWithoutAlt = images.filter(img => !img.alt || !img.alt.trim()); + const lazyLoadedImages = images.filter(img => img.loading === "lazy"); + + // === CONTENT ANALYSIS === + const plainText = html + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<") + .replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'") + .replace(/\s+/g, " ").trim(); + + const wordCount = plainText ? plainText.split(/\s+/).length : 0; + const sentenceCount = plainText ? (plainText.match(/[.!?]+/g) || []).length : 0; + const paragraphCount = plainText ? (plainText.match(/\n\s*\n/g) || []).length : 0; + const avgWordsPerSentence = sentenceCount > 0 ? Math.round(wordCount / sentenceCount) : 0; + + // === STRUCTURED DATA === + const sdTypes = ["Article", "BlogPosting", "FAQPage", "HowTo", "Product", "LocalBusiness", "Organization", "BreadcrumbList", "WebSite", "SearchAction", "VideoObject", "Review"]; + const structuredData = sdTypes.map(sdType => ({ + type: sdType, + found: new RegExp('"@type"\\s*:\\s*"' + sdType + '"', "i").test(html) + || new RegExp('"@type"\\s*:\\s*\\["' + sdType + '"', "i").test(html), + })); + const hasJsonLd = /]*type\s*=\s*["']application\/ld\+json["']/i.test(html); + const hasMicrodata = /itemscope/i.test(html); + + // === HREFLANG === + const hreflangTags: { lang: string; href: string }[] = []; + const hlRegex = /]*rel\s*=\s*["']alternate["'][^>]*hreflang\s*=\s*["']([^"']*)["'][^>]*href\s*=\s*["']([^"']*)["']/i; + let hlMatch; + while ((hlMatch = hlRegex.exec(html)) !== null) { + hreflangTags.push({ lang: hlMatch[1], href: hlMatch[2] }); + } + + // === PERFORMANCE SIGNALS === + const htmlSize = html.length; + const inlineStyleCount = countOccurrences(html, /style\s*=\s*"/g); + const inlineScriptCount = countOccurrences(html, /]*src\s*=/gi); + const externalStylesheets = countOccurrences(html, /]*stylesheet/gi); + const hasPreconnect = /]*rel\s*=\s*["']preconnect["']/i.test(html); + const hasPreload = /]*rel\s*=\s*["']preload["']/i.test(html); + const hasDnsPrefetch = /]*rel\s*=\s*["']dns-prefetch["']/i.test(html); + const usesAsyncScripts = /async\s*=/.test(html); + const usesDeferScripts = /defer\s*=/.test(html); + + // === ACCESSIBILITY === + const hasLangAttr = /]*lang\s*=/i.test(html); + const hasAriaLabels = /aria-label|aria-labelledby|aria-describedby/i.test(html); + + // === SCORE CALCULATION === + let score = 100; + const issues: { severity: "critical" | "warning" | "info"; category: string; message: string }[] = []; + + if (!title) { score -= 10; issues.push({ severity: "critical", category: "Meta", message: "Missing title tag" }); } + else if (titleLength > 60) { score -= 3; issues.push({ severity: "warning", category: "Meta", message: "Title too long (" + titleLength + " chars, max 60)" }); } + if (!metaDescription) { score -= 10; issues.push({ severity: "critical", category: "Meta", message: "Missing meta description" }); } + else if (descLength > 160) { score -= 3; issues.push({ severity: "warning", category: "Meta", message: "Meta description too long (" + descLength + " chars, max 160)" }); } + if (h1Count === 0) { score -= 10; issues.push({ severity: "critical", category: "Content", message: "Missing H1 heading" }); } + if (h1Count > 1) { score -= 5; issues.push({ severity: "critical", category: "Content", message: "Multiple H1 tags (" + h1Count + " found)" }); } + if (!viewport) { score -= 10; issues.push({ severity: "critical", category: "Mobile", message: "Missing viewport meta tag" }); } + if (!isHttps) { score -= 10; issues.push({ severity: "critical", category: "Security", message: "Not using HTTPS" }); } + if (imagesWithoutAlt.length > 0) { score -= 5; issues.push({ severity: "warning", category: "Accessibility", message: imagesWithoutAlt.length + " images missing alt text" }); } + if (!canonical) { score -= 3; issues.push({ severity: "warning", category: "Technical", message: "Missing canonical tag" }); } + if (hasCanonicalMismatch) { score -= 5; issues.push({ severity: "warning", category: "Technical", message: "Canonical URL mismatch" }); } + if (inlineStyleCount > 10) { score -= 3; issues.push({ severity: "warning", category: "Performance", message: inlineStyleCount + " inline styles detected" }); } + if (!hasPreconnect && externalScripts > 3) { score -= 3; issues.push({ severity: "warning", category: "Performance", message: "Missing preconnect hints for external resources" }); } + if (wordCount < 300 && wordCount > 0) { score -= 3; issues.push({ severity: "warning", category: "Content", message: "Thin content (" + wordCount + " words)" }); } + if (!ogTitle && !ogDesc) { score -= 3; issues.push({ severity: "warning", category: "Social", message: "Missing Open Graph tags" }); } + if (!twCard) { score -= 2; issues.push({ severity: "warning", category: "Social", message: "Missing Twitter Card tags" }); } + if (externalLinks.length === 0) { score -= 2; issues.push({ severity: "warning", category: "Links", message: "No external links found" }); } + if (robotsDirectives && /noindex/i.test(robotsDirectives)) { score -= 10; issues.push({ severity: "critical", category: "Technical", message: "Page has noindex directive" }); } + if (!hasJsonLd && !hasMicrodata) { score -= 1; issues.push({ severity: "info", category: "Structured Data", message: "No structured data found" }); } + if (!hasLangAttr) { score -= 1; issues.push({ severity: "info", category: "Accessibility", message: "Missing html lang attribute" }); } + if (!lazyLoadedImages.length && images.length > 5) { score -= 2; issues.push({ severity: "info", category: "Performance", message: "Consider lazy loading for images" }); } + + score = Math.max(0, Math.min(100, score)); + + const technicalScore = Math.min(100, 100 - issues.filter(i => i.category === "Technical" || i.category === "Security").reduce((s, i) => s + (i.severity === "critical" ? 15 : i.severity === "warning" ? 7 : 2), 0)); + const contentScore = Math.min(100, 100 - issues.filter(i => i.category === "Content").reduce((s, i) => s + (i.severity === "critical" ? 15 : i.severity === "warning" ? 7 : 2), 0)); + const performanceScore = Math.min(100, 100 - issues.filter(i => i.category === "Performance" || i.category === "Mobile").reduce((s, i) => s + (i.severity === "critical" ? 15 : i.severity === "warning" ? 7 : 2), 0)); + const socialScore = Math.min(100, 100 - issues.filter(i => i.category === "Social" || i.category === "Structured Data").reduce((s, i) => s + (i.severity === "critical" ? 15 : i.severity === "warning" ? 7 : 2), 0)); + + return NextResponse.json({ + url: normalizedUrl, + domain: baseDomain, + protocol: isHttps ? "HTTPS" : "HTTP", + responseTime, + server, + htmlSize, + title, titleLength, + titleStatus: !title ? "missing" : titleLength > 60 ? "too_long" : "good", + metaDescription, descLength, + descStatus: !metaDescription ? "missing" : descLength > 160 ? "too_long" : "good", + metaKeywords, viewport, charset, robotsDirectives, + canonical, hasCanonicalMismatch, xFrameOptions, + openGraph: { title: ogTitle ? ogTitle[1] : null, description: ogDesc ? ogDesc[1] : null, image: ogImage ? ogImage[1] : null, type: ogType ? ogType[1] : null, url: ogUrl ? ogUrl[1] : null }, + twitterCard: { card: twCard ? twCard[1] : null, title: twTitle ? twTitle[1] : null, description: twDesc ? twDesc[1] : null }, + headings: headingHierarchy, h1Count, h2Count, h3Count, h4Count, + headingStatus: h1Count === 0 ? "missing_h1" : h1Count > 1 ? "multiple_h1" : "good", + links: { total: links.length, internal: internalLinks.length, external: externalLinks.length, nofollow: nofollowLinks.length, sampleExternal: externalLinks.slice(0, 20).map(l => ({ href: l.href, text: l.text, nofollow: l.nofollow })) }, + images: { total: images.length, withAlt: imagesWithAlt.length, withoutAlt: imagesWithoutAlt.length, lazyLoaded: lazyLoadedImages.length, altCoverage: images.length > 0 ? Math.round((imagesWithAlt.length / images.length) * 100) : 100, sampleWithoutAlt: imagesWithoutAlt.slice(0, 10).map(img => img.src) }, + content: { wordCount, sentenceCount, paragraphCount, avgWordsPerSentence, textPreview: plainText.substring(0, 2000) }, + structuredData: { hasJsonLd, hasMicrodata, types: structuredData }, + hreflang: hreflangTags, + performance: { inlineStyles: inlineStyleCount, inlineScripts: inlineScriptCount, externalScripts, externalStylesheets, hasPreconnect, hasPreload, hasDnsPrefetch, usesAsyncScripts, usesDeferScripts, contentEncoding }, + accessibility: { hasLangAttr, hasAriaLabels, hasAltOnFirstImage: images.length > 0 && images[0].alt && images[0].alt.trim().length > 0 }, + scores: { overall: score, technical: technicalScore, content: contentScore, performance: performanceScore, social: socialScore }, + issues, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Fetch failed"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/components/AIAssist.tsx b/components/AIAssist.tsx index 197238e..08a4742 100644 --- a/components/AIAssist.tsx +++ b/components/AIAssist.tsx @@ -638,12 +638,14 @@ export default function AIAssist() { setInput(""); } - // Capture whether this is the initial plan phase (before any code generation) - const wasIdle = !isApproval && (assistStep === "idle" || assistStep === "plan"); + // Plan-first workflow only applies to the "code" agent (software architect) + // SEO, content, smm, design, web, app agents go straight to preview + const isCodeAgent = currentAgent === "code" || currentAgent === "general"; + const wasIdle = !isApproval && isCodeAgent && (assistStep === "idle" || assistStep === "plan"); // Detect if user is modifying existing code if (assistStep === "preview") setIsModifying(true); setIsProcessing(true); - if (assistStep === "idle") setAssistStep("plan"); + if (assistStep === "idle" && isCodeAgent) setAssistStep("plan"); const assistantMsg: AIAssistMessage = { role: "assistant", @@ -695,7 +697,7 @@ export default function AIAssist() { setStatus(null); } - // SEO mode: auto-fetch URLs from user input for live auditing + // SEO mode: auto-fetch URLs from user input for comprehensive live auditing if (currentAgent === "seo") { const urlMatches = [...finalInput.matchAll(/https?:\/\/[^\s<>"')\]]+/gi)].map(m => m[0]); const uniqueUrls = [...new Set(urlMatches)].slice(0, 2); @@ -705,36 +707,72 @@ export default function AIAssist() { for (const url of uniqueUrls) { const auditRes = await fetch("/api/fetch-url?url=" + encodeURIComponent(url)); if (auditRes.ok) { - const auditData = await auditRes.json(); - enrichedInput += "\n\n[WEBSITE AUDIT DATA - " + url + "]\n"; - enrichedInput += "Title: " + (auditData.title || "N/A") + "\n"; - enrichedInput += "Meta Description: " + (auditData.metaDescription || "N/A") + "\n"; - enrichedInput += "Meta Keywords: " + (auditData.metaKeywords || "N/A") + "\n"; - enrichedInput += "Canonical: " + (auditData.canonical || "N/A") + "\n"; - enrichedInput += "OG Title: " + (auditData.ogTitle || "N/A") + "\n"; - enrichedInput += "OG Description: " + (auditData.ogDescription || "N/A") + "\n"; - enrichedInput += "Headings:\n"; - if (auditData.headings && auditData.headings.length > 0) { - for (const h of auditData.headings) { - enrichedInput += " H" + h.level + ": " + h.text + "\n"; + const d = await auditRes.json(); + enrichedInput += "\n\n[COMPREHENSIVE SEO AUDIT - " + url + "]\n"; + enrichedInput += "== OVERALL SCORE: " + (d.scores?.overall || "?") + "/100 ==\n"; + enrichedInput += "Technical: " + (d.scores?.technical || "?") + " | Content: " + (d.scores?.content || "?") + " | Performance: " + (d.scores?.performance || "?") + " | Social: " + (d.scores?.social || "?") + "\n\n"; + enrichedInput += "== META TAGS ==\n"; + enrichedInput += "Title: " + (d.title || "MISSING") + " (" + (d.titleLength || 0) + " chars) [" + (d.titleStatus || "?") + "]\n"; + enrichedInput += "Description: " + (d.metaDescription || "MISSING") + " (" + (d.descLength || 0) + " chars) [" + (d.descStatus || "?") + "]\n"; + enrichedInput += "Keywords: " + (d.metaKeywords || "None") + "\n"; + enrichedInput += "Viewport: " + (d.viewport || "MISSING") + " | Charset: " + (d.charset || "None") + "\n"; + enrichedInput += "Canonical: " + (d.canonical || "None") + (d.hasCanonicalMismatch ? " [MISMATCH!]" : "") + "\n"; + enrichedInput += "Robots: " + (d.robotsDirectives || "None") + "\n\n"; + enrichedInput += "== OPEN GRAPH & SOCIAL ==\n"; + enrichedInput += "OG Title: " + (d.openGraph?.title || "None") + " | OG Desc: " + (d.openGraph?.description || "None") + " | OG Image: " + (d.openGraph?.image || "None") + "\n"; + enrichedInput += "Twitter Card: " + (d.twitterCard?.card || "None") + "\n\n"; + enrichedInput += "== HEADINGS ==\n"; + enrichedInput += "H1 count: " + d.h1Count + " (should be 1) | H2: " + d.h2Count + " | H3: " + d.h3Count + " [" + (d.headingStatus || "?") + "]\n"; + if (d.headings && d.headings.length > 0) { + for (const h of d.headings.slice(0, 15)) enrichedInput += " H" + h.level + ": " + h.text + "\n"; + } + enrichedInput += "\n== LINKS ==\n"; + const lnks = d.links || {}; + enrichedInput += "Total: " + (lnks.total || 0) + " | Internal: " + (lnks.internal || 0) + " | External: " + (lnks.external || 0) + " | Nofollow: " + (lnks.nofollow || 0) + "\n"; + if (lnks.sampleExternal && lnks.sampleExternal.length > 0) { + enrichedInput += "Sample external: " + lnks.sampleExternal.slice(0, 10).map((l: { href: string }) => l.href).join(", ") + "\n"; + } + enrichedInput += "\n== IMAGES ==\n"; + const imgs = d.images || {}; + enrichedInput += "Total: " + (imgs.total || 0) + " | With alt: " + (imgs.withAlt || 0) + " | Without alt: " + (imgs.withoutAlt || 0) + " | Lazy loaded: " + (imgs.lazyLoaded || 0) + "\n"; + enrichedInput += "Alt text coverage: " + (imgs.altCoverage || 0) + "%\n"; + if (imgs.sampleWithoutAlt && imgs.sampleWithoutAlt.length > 0) { + enrichedInput += "Missing alt: " + imgs.sampleWithoutAlt.slice(0, 5).join(", ") + "\n"; + } + enrichedInput += "\n== CONTENT ==\n"; + const cnt = d.content || {}; + enrichedInput += "Words: " + (cnt.wordCount || 0) + " | Sentences: " + (cnt.sentenceCount || 0) + " | Paragraphs: " + (cnt.paragraphCount || 0) + " | Avg words/sentence: " + (cnt.avgWordsPerSentence || 0) + "\n\n"; + enrichedInput += "== STRUCTURED DATA ==\n"; + enrichedInput += "JSON-LD: " + (d.structuredData?.hasJsonLd ? "Yes" : "No") + " | Microdata: " + (d.structuredData?.hasMicrodata ? "Yes" : "No") + "\n"; + const foundTypes = (d.structuredData?.types || []).filter((t: { found: boolean }) => t.found).map((t: { type: string }) => t.type); + enrichedInput += "Schema types found: " + (foundTypes.length > 0 ? foundTypes.join(", ") : "None") + "\n\n"; + enrichedInput += "== PERFORMANCE SIGNALS ==\n"; + const perf = d.performance || {}; + enrichedInput += "Server: " + (d.server || "Unknown") + " | Response time: " + d.responseTime + "ms | HTML size: " + d.htmlSize + " bytes\n"; + enrichedInput += "External scripts: " + (perf.externalScripts || 0) + " | External stylesheets: " + (perf.externalStylesheets || 0) + " | Inline styles: " + (perf.inlineStyles || 0) + "\n"; + enrichedInput += "Preconnect: " + (perf.hasPreconnect ? "Yes" : "No") + " | Preload: " + (perf.hasPreload ? "Yes" : "No") + " | DNS prefetch: " + (perf.hasDnsPrefetch ? "Yes" : "No") + "\n"; + enrichedInput += "Async scripts: " + (perf.usesAsyncScripts ? "Yes" : "No") + " | Defer scripts: " + (perf.usesDeferScripts ? "Yes" : "No") + "\n\n"; + enrichedInput += "== ACCESSIBILITY ==\n"; + const acc = d.accessibility || {}; + enrichedInput += "Lang attr: " + (acc.hasLangAttr ? "Yes" : "No") + " | ARIA labels: " + (acc.hasAriaLabels ? "Yes" : "No") + " | First image alt: " + (acc.hasAltOnFirstImage ? "Yes" : "No") + "\n"; + if (d.hreflang && d.hreflang.length > 0) { + enrichedInput += "\n== HREFLANG ==\n" + d.hreflang.map((h: { lang: string; href: string }) => h.lang + " -> " + h.href).join("\n") + "\n"; + } + enrichedInput += "\n== ISSUES FOUND ==\n"; + if (d.issues && d.issues.length > 0) { + for (const issue of d.issues) { + enrichedInput += "[" + issue.severity.toUpperCase() + "] " + issue.category + ": " + issue.message + "\n"; } } else { - enrichedInput += " None found\n"; + enrichedInput += "No issues detected.\n"; } - const internalLinks = (auditData.links || []).filter((l: { internal: boolean }) => l.internal); - const externalLinks = (auditData.links || []).filter((l: { internal: boolean }) => !l.internal); - enrichedInput += "Links: " + internalLinks.length + " internal, " + externalLinks.length + " external (of " + (auditData.links || []).length + " total)\n"; - const imagesWithAlt = (auditData.images || []).filter((img: { alt: string }) => img.alt && img.alt.trim()); - const imagesNoAlt = (auditData.images || []).filter((img: { alt: string }) => !img.alt || !img.alt.trim()); - enrichedInput += "Images: " + (auditData.images || []).length + " total, " + imagesWithAlt.length + " with alt, " + imagesNoAlt.length + " without alt\n"; - enrichedInput += "Text content length: " + (auditData.text || "").length + " chars\n"; - enrichedInput += "HTML size: " + (auditData.htmlLength || 0) + " chars\n"; - enrichedInput += "[/WEBSITE AUDIT DATA]\n"; + enrichedInput += "[/COMPREHENSIVE SEO AUDIT]\n"; } } } catch (e) { console.warn("Website audit failed:", e); } setStatus(null); } + // If no URL found and web search not enabled, auto-enable web search for SEO if (uniqueUrls.length === 0 && !webSearchEnabled) { try { @@ -812,8 +850,9 @@ export default function AIAssist() { if (!response.success) throw new Error(response.error); - // When this was the initial request from idle/plan, ALWAYS show plan card + // Post-stream routing if (wasIdle) { + // Code agent: show plan card for approval setAssistStep("plan"); const { plan: parsedPlan } = parsePlanFromResponse(accumulated); if (parsedPlan) { @@ -822,10 +861,13 @@ export default function AIAssist() { setAiPlan({ rawText: accumulated, architecture: "", techStack: [], files: [], steps: [] }); } } else if ((lastParsedPreview as PreviewData | null)?.data) { - // After approval: show the generated preview + // Non-code agents or approved code: show the generated preview directly setAssistStep("preview"); setShowCanvas(true); if (isPreviewRenderable(lastParsedPreview)) setViewMode("preview"); + } else if (!isCodeAgent && !wasIdle) { + // Non-code agent without preview: just return to idle + setAssistStep("idle"); } else { setAssistStep("idle"); } @@ -1211,7 +1253,7 @@ export default function AIAssist() { {/* Agentic Plan Review Card */} - {msg.role === "assistant" && aiPlan && i === aiAssistHistory.length - 1 && assistStep === "plan" && ( + {msg.role === "assistant" && aiPlan && i === aiAssistHistory.length - 1 && assistStep === "plan" && (currentAgent === "code" || currentAgent === "general") && (

{t.proposedPlan} diff --git a/package.json b/package.json index 46f750f..1301438 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "promptarch", - "version": "1.6.0", + "version": "1.7.0", "description": "Transform vague ideas into production-ready prompts and PRDs", "scripts": { "dev": "next dev",