feat: industry-grade SEO audit engine + plan flow fix (v1.7.0)

This commit is contained in:
admin
2026-03-18 20:44:42 +00:00
Unverified
parent 55fa459f2d
commit 5e4927624e
5 changed files with 374 additions and 148 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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(/<title[^>]*>([\s\S]*?)<\/title>/i);
const title = titleMatch ? titleMatch[1].trim() : "";
const descMatch = html.match(/<meta[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i)
|| html.match(/<meta[^>]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']description["']/i);
const metaDescription = descMatch ? descMatch[1].trim() : "";
const kwMatch = html.match(/<meta[^>]*name\s*=\s*["']keywords["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i)
|| html.match(/<meta[^>]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']keywords["']/i);
const metaKeywords = kwMatch ? kwMatch[1].trim() : "";
const headings: { level: number; text: string }[] = [];
const headingRegex = /<h([1-6])[^>]*>([\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 = /<a[^>]*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 = /<img[^>]*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(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim()
.substring(0, 5000);
const canonicalMatch = html.match(/<link[^>]*rel\s*=\s*["']canonical["'][^>]*href\s*=\s*["']([^"']*)["']/i);
const canonical = canonicalMatch ? canonicalMatch[1] : "";
const ogTitleMatch = html.match(/<meta[^>]*property\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i);
const ogTitle = ogTitleMatch ? ogTitleMatch[1].trim() : "";
const ogDescMatch = html.match(/<meta[^>]*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<string, string> = {};
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(/<meta[^>]*name\s*=\s*["']robots["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i)
|| html.match(/<meta[^>]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']robots["']/i);
const robotsDirectives = robotsMeta ? robotsMeta[1].trim() : null;
const canonicalMatch = html.match(/<link[^>]*rel\s*=\s*["']canonical["'][^>]*href\s*=\s*["']([^"']*)["']/i)
|| html.match(/<link[^>]*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(/<title[^>]*>([\s\S]*?)<\/title>/i);
const title = titleMatch ? titleMatch[1].trim() : null;
const titleLength = title ? title.length : 0;
const descMatch = html.match(/<meta[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i)
|| html.match(/<meta[^>]*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(/<meta[^>]*name\s*=\s*["']keywords["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i)
|| html.match(/<meta[^>]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']keywords["']/i);
const metaKeywords = kwMatch ? kwMatch[1].trim() : null;
const viewportMatch = html.match(/<meta[^>]*name\s*=\s*["']viewport["'][^>]*content\s*=\s*["']([^"']*)["']/i);
const viewport = viewportMatch ? viewportMatch[1].trim() : null;
const charsetMatch = html.match(/<meta[^>]*charset\s*=\s*["']?([^"'\s>]+)/i)
|| html.match(/<meta[^>]*content\s*=\s*["'][^"']*charset=([^"'\s]+)/i);
const charset = charsetMatch ? charsetMatch[1].trim() : null;
// === OPEN GRAPH ===
const ogTitle = html.match(/<meta[^>]*property\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']*)["']/i);
const ogDesc = html.match(/<meta[^>]*property\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']*)["']/i);
const ogImage = html.match(/<meta[^>]*property\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']*)["']/i);
const ogType = html.match(/<meta[^>]*property\s*=\s*["']og:type["'][^>]*content\s*=\s*["']([^"']*)["']/i);
const ogUrl = html.match(/<meta[^>]*property\s*=\s*["']og:url["'][^>]*content\s*=\s*["']([^"']*)["']/i);
// === TWITTER CARD ===
const twCard = html.match(/<meta[^>]*name\s*=\s*["']twitter:card["'][^>]*content\s*=\s*["']([^"']*)["']/i);
const twTitle = html.match(/<meta[^>]*name\s*=\s*["']twitter:title["'][^>]*content\s*=\s*["']([^"']*)["']/i);
const twDesc = html.match(/<meta[^>]*name\s*=\s*["']twitter:description["'][^>]*content\s*=\s*["']([^"']*)["']/i);
// === HEADING STRUCTURE ===
const headings: { level: number; text: string }[] = [];
const headingRegex = /<h([1-6])[^>]*>([\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 = /<a[^>]*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 = /<img([^>]*)\/?>/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(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<")
.replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/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 = /<script[^>]*type\s*=\s*["']application\/ld\+json["']/i.test(html);
const hasMicrodata = /itemscope/i.test(html);
// === HREFLANG ===
const hreflangTags: { lang: string; href: string }[] = [];
const hlRegex = /<link[^>]*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, /<script(?!.*src)/gi);
const externalScripts = countOccurrences(html, /<script[^>]*src\s*=/gi);
const externalStylesheets = countOccurrences(html, /<link[^>]*stylesheet/gi);
const hasPreconnect = /<link[^>]*rel\s*=\s*["']preconnect["']/i.test(html);
const hasPreload = /<link[^>]*rel\s*=\s*["']preload["']/i.test(html);
const hasDnsPrefetch = /<link[^>]*rel\s*=\s*["']dns-prefetch["']/i.test(html);
const usesAsyncScripts = /async\s*=/.test(html);
const usesDeferScripts = /defer\s*=/.test(html);
// === ACCESSIBILITY ===
const hasLangAttr = /<html[^>]*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 });
}
}

View File

@@ -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() {
</div>
{/* 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") && (
<div className="mt-6 p-6 rounded-2xl bg-blue-500/5 border border-blue-500/20 backdrop-blur-sm animate-in zoom-in-95 duration-300">
<h3 className="text-sm font-black text-blue-400 uppercase tracking-widest mb-4 flex items-center gap-2">
<LayoutPanelLeft className="h-4 w-4" /> {t.proposedPlan}

View File

@@ -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",