From 33dcbe29e58de18bb8d4842b03616f51d1d29c19 Mon Sep 17 00:00:00 2001 From: Fahad Kiani Date: Sat, 6 Jun 2026 01:52:01 +0000 Subject: [PATCH 1/3] feat(seo-audit): wire Railway LangGraph backend into open-seo UI shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 files — full A-Z product integration: DB / Schema: drizzle/0022_seo_graph_audits.sql — D1 migration for seo_graph_audits table src/db/app.schema.ts — seoGraphAudits table appended Types: src/types/schemas/seoGraph.ts — Zod schemas + Railway response types Server functions (Cloudflare Worker → Railway FastAPI): src/serverFunctions/seoGraph.ts — startSeoGraphAudit, getSeoGraphAuditStatus, getSeoGraphAuditHistory, deleteSeoGraphAudit API route: src/routes/api/seo-graph-stream/ — SSE proxy: /api/seo-graph-stream?run_id=... passes Railway EventSource stream to browser Client hooks: src/client/features/seo-graph/useSeoGraphAudit.ts — useStartSeoGraphAudit, useSeoGraphAudit (polls 2s while running), useSeoGraphAuditHistory, useDeleteSeoGraphAudit, parseRoutingPath, routingPathToProgress UI components: src/client/features/seo-graph/SeoGraphLauncher.tsx — LaunchView (form + history list) + AuditDetail (progress + stream + report) src/client/features/seo-graph/SeoGraphStream.tsx — Live SSE feed: NodeThinkingBlock per LLM node, auto-scroll, blink cursor src/client/features/seo-graph/SeoGraphReport.tsx — Full markdown report: extractThinkingBlocks, normalizeLlmMarkdown, MARKDOWN_COMPONENTS (stolen from open-seo MarkdownAnswer.tsx), collapse/expand at 600px, Download .md button Routes: src/routes/_project/p/$projectId/seo-audit.tsx — layout (Outlet) src/routes/_project/p/$projectId/seo-audit/index.tsx — page (LaunchView / AuditDetail) Navigation: src/client/navigation/items.ts — AI SEO Audit added to Domain group worker-configuration.d.ts — RAILWAY_SEO_API_URL + RAILWAY_SEO_API_KEY env vars Architecture: open-seo Cloudflare Worker └─ startSeoGraphAudit server fn └─ POST https://openclaw-api-k30t.onrender.com/api/v1/audit-graph → { run_id } └─ D1 seo_graph_audits row tracks status useSeoGraphAudit polls getSeoGraphAuditStatus every 2s SeoGraphStream subscribes EventSource → /api/seo-graph-stream (SSE proxy) SeoGraphReport renders client_report markdown on completion --- drizzle/0022_seo_graph_audits.sql | 17 + .../features/seo-graph/SeoGraphLauncher.tsx | 345 ++++++++++++++++++ .../features/seo-graph/SeoGraphReport.tsx | 217 +++++++++++ .../features/seo-graph/SeoGraphStream.tsx | 208 +++++++++++ .../features/seo-graph/useSeoGraphAudit.ts | 116 ++++++ src/client/navigation/items.ts | 11 +- src/db/app.schema.ts | 32 ++ .../_project/p/$projectId/seo-audit.tsx | 9 + .../_project/p/$projectId/seo-audit/index.tsx | 44 +++ src/routes/api/seo-graph-stream/index.ts | 72 ++++ src/serverFunctions/seoGraph.ts | 191 ++++++++++ src/types/schemas/seoGraph.ts | 50 +++ worker-configuration.d.ts | 2 + 13 files changed, 1313 insertions(+), 1 deletion(-) create mode 100644 drizzle/0022_seo_graph_audits.sql create mode 100644 src/client/features/seo-graph/SeoGraphLauncher.tsx create mode 100644 src/client/features/seo-graph/SeoGraphReport.tsx create mode 100644 src/client/features/seo-graph/SeoGraphStream.tsx create mode 100644 src/client/features/seo-graph/useSeoGraphAudit.ts create mode 100644 src/routes/_project/p/$projectId/seo-audit.tsx create mode 100644 src/routes/_project/p/$projectId/seo-audit/index.tsx create mode 100644 src/routes/api/seo-graph-stream/index.ts create mode 100644 src/serverFunctions/seoGraph.ts create mode 100644 src/types/schemas/seoGraph.ts diff --git a/drizzle/0022_seo_graph_audits.sql b/drizzle/0022_seo_graph_audits.sql new file mode 100644 index 0000000..693f9b0 --- /dev/null +++ b/drizzle/0022_seo_graph_audits.sql @@ -0,0 +1,17 @@ +CREATE TABLE `seo_graph_audits` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `started_by_user_id` text NOT NULL, + `domain` text NOT NULL, + `keywords_json` text NOT NULL DEFAULT '[]', + `run_id` text, + `status` text NOT NULL DEFAULT 'pending', + `routing_path` text NOT NULL DEFAULT '[]', + `client_report` text, + `error_message` text, + `started_at` text NOT NULL DEFAULT (current_timestamp), + `completed_at` text, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `seo_graph_audits_project_id_idx` ON `seo_graph_audits` (`project_id`); diff --git a/src/client/features/seo-graph/SeoGraphLauncher.tsx b/src/client/features/seo-graph/SeoGraphLauncher.tsx new file mode 100644 index 0000000..e39d2dd --- /dev/null +++ b/src/client/features/seo-graph/SeoGraphLauncher.tsx @@ -0,0 +1,345 @@ +'use client'; + +/** + * SeoGraphLauncher — full launch form + live audit detail view + * + * LaunchView: + * - Domain input + keywords textarea + * - History list of past audits + * - Submits via useStartSeoGraphAudit → Railway FastAPI + * + * AuditDetail: + * - Progress bar driven by routingPath length + * - RoutingPathFeed: animated node list + * - SeoGraphStream: live SSE thinking blocks (while running) + * - SeoGraphReport: full markdown report (on completion) + */ +import { useState, type FormEvent } from "react"; +import { + AlertCircle, + Bot, + ChevronRight, + Clock, + Loader2, + ScanSearch, + Trash2, +} from "lucide-react"; +import { + useStartSeoGraphAudit, + useSeoGraphAudit, + useSeoGraphAuditHistory, + useDeleteSeoGraphAudit, + parseRoutingPath, + routingPathToProgress, + type SeoGraphAuditRow, +} from "./useSeoGraphAudit"; +import { SeoGraphStream } from "./SeoGraphStream"; +import { SeoGraphReport } from "./SeoGraphReport"; + +// ─── StatusBadge ────────────────────────────────────────────────────────────── + +const STATUS_BADGE: Record = { + pending: "badge-warning", + running: "badge-info", + completed: "badge-success", + failed: "badge-error", +}; + +function StatusBadge({ status }: { status: SeoGraphAuditRow["status"] }) { + return ( + + {status === "running" && } + {status} + + ); +} + +// ─── RoutingPathFeed ────────────────────────────────────────────────────────── + +const NODE_LABELS: Record = { + gather_node: "Gathering agent data", + technical_node: "Technical analysis", + supervisor_node: "Supervisor routing", + crawlability_fix_node: "Crawlability fix plan", + authority_gap_node: "Authority gap analysis", + content_gap_node: "Content gap analysis", + strategy_node: "Strategy synthesis", + synthesis_node: "Writing client report", + flywheel_persist_node: "Persisting to ZIE flywheel", +}; + +function RoutingPathFeed({ routingPath }: { routingPath: string }) { + const nodes = parseRoutingPath(routingPath); + if (nodes.length === 0) return null; + + return ( +
+ {nodes.map((node, i) => ( +
+ + {NODE_LABELS[node] ?? node} +
+ ))} +
+ ); +} + +// ─── LaunchView ─────────────────────────────────────────────────────────────── + +type LaunchViewProps = { + projectId: string; + defaultDomain?: string; + onAuditStarted: (auditId: string) => void; +}; + +export function LaunchView({ projectId, defaultDomain = "", onAuditStarted }: LaunchViewProps) { + const [domain, setDomain] = useState(defaultDomain); + const [keywordsRaw, setKeywordsRaw] = useState(""); + const start = useStartSeoGraphAudit(projectId); + const history = useSeoGraphAuditHistory(projectId); + const deleteAudit = useDeleteSeoGraphAudit(projectId); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!domain.trim()) return; + const keywords = keywordsRaw + .split(",") + .map((k) => k.trim()) + .filter(Boolean); + const result = await start.mutateAsync({ domain: domain.trim(), keywords }); + onAuditStarted(result.auditId); + } + + return ( +
+ {/* Launch form */} +
+
+ +

AI SEO Audit

+
+ +
+
+ + setDomain(e.target.value)} + disabled={start.isPending} + className="input input-bordered input-sm w-full" + /> +
+
+ + setKeywordsRaw(e.target.value)} + disabled={start.isPending} + className="input input-bordered input-sm w-full" + /> +
+ + {start.error && ( +
+ + {start.error instanceof Error ? start.error.message : "Failed to start audit"} +
+ )} + + +
+ + {start.isPending && ( +
+ + Submitting to Railway FastAPI — returns run_id in <50ms +
+ )} +
+ + {/* History */} + {history.data && history.data.length > 0 && ( +
+

+ Recent Audits +

+
+ {history.data.map((row) => ( +
+ + +
+ ))} +
+
+ )} +
+ ); +} + +// ─── AuditDetail ────────────────────────────────────────────────────────────── + +type AuditDetailProps = { + projectId: string; + auditId: string; + onBack: () => void; +}; + +export function AuditDetail({ projectId, auditId, onBack }: AuditDetailProps) { + const { data: audit, isLoading, error } = useSeoGraphAudit(projectId, auditId); + const [liveReport, setLiveReport] = useState(null); + + if (isLoading) { + return ( +
+ + Loading audit… +
+ ); + } + + if (error || !audit) { + return ( +
+ {error instanceof Error ? error.message : "Audit not found"} +
+ ); + } + + const progress = routingPathToProgress(audit.routingPath); + const isRunning = audit.status === "pending" || audit.status === "running"; + const report = audit.clientReport ?? liveReport; + + return ( +
+ {/* Header */} +
+
+ +

{audit.domain}

+

+ {new Date(audit.startedAt).toLocaleString()} + {audit.runId && ( + + run: {audit.runId.slice(0, 8)}… + + )} +

+
+ +
+ + {/* Progress bar */} + {isRunning && ( +
+ + +
+ )} + + {/* Completed routing path */} + {!isRunning && parseRoutingPath(audit.routingPath).length > 0 && ( +
+

+ Routing Path +

+
+ {parseRoutingPath(audit.routingPath).map((node, i) => ( + + {NODE_LABELS[node] ?? node} + + ))} +
+
+ )} + + {/* Live SSE stream (while running and run_id available) */} + {isRunning && audit.runId && ( +
+

+ Live LangGraph Stream +

+ setLiveReport(r)} + /> +
+ )} + + {/* Error */} + {audit.status === "failed" && audit.errorMessage && ( +
+ + {audit.errorMessage} +
+ )} + + {/* Final report */} + {report && ( + + )} +
+ ); +} diff --git a/src/client/features/seo-graph/SeoGraphReport.tsx b/src/client/features/seo-graph/SeoGraphReport.tsx new file mode 100644 index 0000000..f3edd3f --- /dev/null +++ b/src/client/features/seo-graph/SeoGraphReport.tsx @@ -0,0 +1,217 @@ +'use client'; + +/** + * SeoGraphReport — renders the LangGraph synthesis client_report + * + * Stolen from open-seo MarkdownAnswer.tsx: + * - extractThinkingBlocks() — strips ... into collapsible blocks + * - normalizeLlmMarkdown() — fixes malformed list markers from LLMs + * - MARKDOWN_COMPONENTS — full Tailwind/DaisyUI component overrides + * - Collapse/expand with gradient fade at COLLAPSED_MAX_PX = 600 + * - Download .md button + */ +import { + useLayoutEffect, + useRef, + useState, + type ComponentPropsWithoutRef, + type ReactNode, +} from "react"; +import { ChevronDown, ChevronUp, Download } from "lucide-react"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +const COLLAPSED_MAX_PX = 600; + +type Props = { + domain: string; + clientReport: string; +}; + +export function SeoGraphReport({ domain, clientReport }: Props) { + const contentRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const [needsCollapse, setNeedsCollapse] = useState(false); + const { thinking, body } = extractThinkingBlocks(clientReport); + const normalized = normalizeLlmMarkdown(body); + + useLayoutEffect(() => { + const el = contentRef.current; + if (!el) return; + setNeedsCollapse(el.scrollHeight > COLLAPSED_MAX_PX + 8); + }, [normalized]); + + function handleDownload() { + const blob = new Blob([clientReport], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `seo-report-${domain}-${new Date().toISOString().slice(0, 10)}.md`; + a.click(); + URL.revokeObjectURL(url); + } + + const isCollapsed = needsCollapse && !expanded; + + return ( +
+ {/* Header */} +
+

+ AI SEO Report — {domain} +

+ +
+ + {/* Thinking blocks (Nemotron chain-of-thought) */} + {thinking.map((block, i) => ( + + ))} + + {/* Report body */} + {normalized.trim().length > 0 && ( +
+
+ + {normalized} + +
+ + {isCollapsed && ( +
+ )} +
+ )} + + {needsCollapse && ( + + )} +
+ ); +} + +// ─── ThinkingBlock ──────────────────────────────────────────────────────────── + +function ThinkingBlock({ text }: { text: string }) { + return ( +
+ + + Model Thinking + +
+        {text}
+      
+
+ ); +} + +// ─── Helpers (stolen verbatim from open-seo MarkdownAnswer.tsx) ─────────────── + +function extractThinkingBlocks(text: string): { thinking: string[]; body: string } { + const thinking: string[] = []; + let body = text; + body = body.replace(/([\s\S]*?)<\/think>/gi, (_, inner: string) => { + thinking.push(inner.trim()); + return ""; + }); + body = body.replace(/([\s\S]*)$/i, (_, inner: string) => { + thinking.push(inner.trim()); + return ""; + }); + return { thinking, body }; +} + +function normalizeLlmMarkdown(text: string): string { + return text.replace( + /^([ \t]*)([-*+]|\d+\.)[ \t]*\r?\n[ \t]*\r?\n(?=\S)(?![ \t]*(?:[-*+]|\d+\.)[ \t])/gm, + "$1$2 ", + ); +} + +type AnchorProps = ComponentPropsWithoutRef<"a">; + +function SafeAnchor({ href, children, ...rest }: AnchorProps) { + const safeHref = isHttpUrl(href) ? href : undefined; + if (!safeHref) return {children}; + return ( + + {children} + + ); +} + +function isHttpUrl(value: string | undefined): value is string { + if (!value) return false; + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + if (url.username || url.password) return false; + return true; + } catch { return false; } +} + +const MARKDOWN_COMPONENTS = { + h1: ({ children }: { children?: ReactNode }) =>

{children}

, + h2: ({ children }: { children?: ReactNode }) =>

{children}

, + h3: ({ children }: { children?: ReactNode }) =>

{children}

, + h4: ({ children }: { children?: ReactNode }) =>

{children}

, + p: ({ children }: { children?: ReactNode }) =>

{children}

, + ul: ({ children }: { children?: ReactNode }) =>
    {children}
, + ol: ({ children }: { children?: ReactNode }) =>
    {children}
, + li: ({ children }: { children?: ReactNode }) =>
  • {children}
  • , + a: SafeAnchor, + strong: ({ children }: { children?: ReactNode }) => {children}, + em: ({ children }: { children?: ReactNode }) => {children}, + blockquote: ({ children }: { children?: ReactNode }) => ( +
    {children}
    + ), + hr: () =>
    , + code: ({ children, className }: ComponentPropsWithoutRef<"code">) => { + if (typeof className === "string" && className.startsWith("language-")) { + return {children}; + } + return {children}; + }, + pre: ({ children }: { children?: ReactNode }) => ( +
    {children}
    + ), + table: ({ children }: { children?: ReactNode }) => ( +
    + {children}
    +
    + ), + thead: ({ children }: { children?: ReactNode }) => {children}, + tbody: ({ children }: { children?: ReactNode }) => {children}, + tr: ({ children }: { children?: ReactNode }) => {children}, + th: ({ children }: { children?: ReactNode }) => {children}, + td: ({ children }: { children?: ReactNode }) => {children}, +}; diff --git a/src/client/features/seo-graph/SeoGraphStream.tsx b/src/client/features/seo-graph/SeoGraphStream.tsx new file mode 100644 index 0000000..c703ff9 --- /dev/null +++ b/src/client/features/seo-graph/SeoGraphStream.tsx @@ -0,0 +1,208 @@ +'use client'; + +/** + * SeoGraphStream — live SSE feed from Railway LangGraph worker + * + * Subscribes to /api/seo-graph-stream?run_id={runId} (our proxy route). + * Renders one ThinkingBlock per LLM node with: + * - Auto-scroll to bottom as tokens arrive + * - Blinking cursor while node is active + * - Collapse toggle on node_complete + * + * Events consumed: + * node_start { node, timestamp } + * thinking { node, chunk } + * node_complete { node, timestamp } + * done { client_report } + * error { message } + */ +import { useEffect, useRef, useState } from "react"; +import { ChevronDown, ChevronUp, Loader2 } from "lucide-react"; + +type NodeBlock = { + node: string; + chunks: string[]; + complete: boolean; + startedAt: string; + completedAt?: string; +}; + +type Props = { + runId: string; + onDone?: (clientReport: string) => void; + onError?: (message: string) => void; +}; + +const NODE_LABELS: Record = { + gather_node: "Gathering agent data", + technical_node: "Technical analysis", + supervisor_node: "Supervisor routing", + crawlability_fix_node: "Crawlability fix plan", + authority_gap_node: "Authority gap analysis", + content_gap_node: "Content gap analysis", + strategy_node: "Strategy synthesis", + synthesis_node: "Writing client report", + flywheel_persist_node: "Persisting to ZIE flywheel", +}; + +export function SeoGraphStream({ runId, onDone, onError }: Props) { + const [blocks, setBlocks] = useState([]); + const [streamError, setStreamError] = useState(null); + const [isDone, setIsDone] = useState(false); + const bottomRef = useRef(null); + const esRef = useRef(null); + + useEffect(() => { + if (!runId) return; + + const es = new EventSource(`/api/seo-graph-stream/?run_id=${runId}`); + esRef.current = es; + + es.addEventListener("node_start", (e) => { + const data = JSON.parse(e.data) as { node: string; timestamp: string }; + setBlocks((prev) => [ + ...prev, + { node: data.node, chunks: [], complete: false, startedAt: data.timestamp }, + ]); + }); + + es.addEventListener("thinking", (e) => { + const data = JSON.parse(e.data) as { node: string; chunk: string }; + setBlocks((prev) => + prev.map((b) => + b.node === data.node && !b.complete + ? { ...b, chunks: [...b.chunks, data.chunk] } + : b, + ), + ); + // Auto-scroll + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }); + + es.addEventListener("node_complete", (e) => { + const data = JSON.parse(e.data) as { node: string; timestamp: string }; + setBlocks((prev) => + prev.map((b) => + b.node === data.node ? { ...b, complete: true, completedAt: data.timestamp } : b, + ), + ); + }); + + es.addEventListener("done", (e) => { + const data = JSON.parse(e.data) as { client_report: string }; + setIsDone(true); + es.close(); + onDone?.(data.client_report); + }); + + es.addEventListener("error", (e) => { + let msg = "Stream error"; + try { + const data = JSON.parse((e as MessageEvent).data) as { message?: string }; + msg = data.message ?? msg; + } catch { /* ignore */ } + setStreamError(msg); + es.close(); + onError?.(msg); + }); + + es.onerror = () => { + if (!isDone) { + setStreamError("Connection to Railway lost"); + es.close(); + } + }; + + return () => { + es.close(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [runId]); + + if (streamError) { + return ( +
    + Stream error: {streamError} +
    + ); + } + + if (blocks.length === 0) { + return ( +
    + + Waiting for LangGraph worker… +
    + ); + } + + return ( +
    + {blocks.map((block, i) => ( + + ))} +
    +
    + ); +} + +// ─── NodeThinkingBlock ──────────────────────────────────────────────────────── + +function NodeThinkingBlock({ block }: { block: NodeBlock }) { + const [collapsed, setCollapsed] = useState(false); + const preRef = useRef(null); + const text = block.chunks.join(""); + const label = NODE_LABELS[block.node] ?? block.node; + + // Auto-scroll pre to bottom as chunks arrive + useEffect(() => { + if (!block.complete && preRef.current) { + preRef.current.scrollTop = preRef.current.scrollHeight; + } + }, [block.chunks, block.complete]); + + // Collapse automatically when node completes (if no thinking tokens) + useEffect(() => { + if (block.complete && text.trim().length === 0) { + setCollapsed(true); + } + }, [block.complete, text]); + + return ( +
    + {/* Header */} + + + {/* Thinking content */} + {!collapsed && text.trim().length > 0 && ( +
    +          {text}
    +          {!block.complete && (
    +            
    +          )}
    +        
    + )} +
    + ); +} diff --git a/src/client/features/seo-graph/useSeoGraphAudit.ts b/src/client/features/seo-graph/useSeoGraphAudit.ts new file mode 100644 index 0000000..182f12f --- /dev/null +++ b/src/client/features/seo-graph/useSeoGraphAudit.ts @@ -0,0 +1,116 @@ +'use client'; + +/** + * useSeoGraphAudit — TanStack Query v5 hook + * + * Polls getSeoGraphAuditStatus every 2s while status is pending/running. + * Stops polling automatically on completed/failed. + */ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + startSeoGraphAudit, + getSeoGraphAuditStatus, + getSeoGraphAuditHistory, + deleteSeoGraphAudit, +} from "@/serverFunctions/seoGraph"; +import type { StartSeoGraphAuditInput } from "@/types/schemas/seoGraph"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type SeoGraphAuditRow = { + id: string; + projectId: string; + startedByUserId: string; + domain: string; + keywordsJson: string; + runId: string | null; + status: "pending" | "running" | "completed" | "failed"; + routingPath: string; // JSON array string + clientReport: string | null; + errorMessage: string | null; + startedAt: string; + completedAt: string | null; +}; + +// ─── useSeoGraphAudit ───────────────────────────────────────────────────────── + +export function useSeoGraphAudit(projectId: string, auditId: string | null) { + return useQuery({ + queryKey: ["seo-graph-audit", projectId, auditId], + queryFn: () => + getSeoGraphAuditStatus({ + data: { projectId, auditId: auditId! }, + }) as Promise, + enabled: !!auditId, + refetchInterval: (query) => { + const status = (query.state.data as SeoGraphAuditRow | undefined)?.status; + return status === "running" || status === "pending" ? 2000 : false; + }, + retry: (failureCount) => failureCount < 3, + staleTime: 0, + }); +} + +// ─── useSeoGraphAuditHistory ────────────────────────────────────────────────── + +export function useSeoGraphAuditHistory(projectId: string) { + return useQuery({ + queryKey: ["seo-graph-audit-history", projectId], + queryFn: () => + getSeoGraphAuditHistory({ data: { projectId } }) as Promise< + SeoGraphAuditRow[] + >, + staleTime: 30_000, + }); +} + +// ─── useStartSeoGraphAudit ──────────────────────────────────────────────────── + +export function useStartSeoGraphAudit(projectId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: Omit) => + startSeoGraphAudit({ data: { ...input, projectId } }) as Promise<{ + auditId: string; + runId: string; + }>, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["seo-graph-audit-history", projectId], + }); + }, + }); +} + +// ─── useDeleteSeoGraphAudit ─────────────────────────────────────────────────── + +export function useDeleteSeoGraphAudit(projectId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (auditId: string) => + deleteSeoGraphAudit({ data: { projectId, auditId } }), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["seo-graph-audit-history", projectId], + }); + }, + }); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +export function parseRoutingPath(routingPath: string): string[] { + try { + return JSON.parse(routingPath) as string[]; + } catch { + return []; + } +} + +export function routingPathToProgress(routingPath: string): number { + const nodes = parseRoutingPath(routingPath); + // 9 total nodes in the graph + return Math.min(Math.round((nodes.length / 9) * 100), 95); +} diff --git a/src/client/navigation/items.ts b/src/client/navigation/items.ts index 74158fc..e337d0a 100644 --- a/src/client/navigation/items.ts +++ b/src/client/navigation/items.ts @@ -8,6 +8,7 @@ import { Search, Sparkles, TrendingUp, + Zap, } from "lucide-react"; import { linkOptions } from "@tanstack/react-router"; @@ -48,6 +49,13 @@ const projectNavItems = [ icon: ClipboardCheck, matchSegment: "/audit", }, + // ─── AI SEO Audit (LangGraph / Railway backend) ─────────────────────────── + { + to: "/p/$projectId/seo-audit" as const, + label: "AI SEO Audit", + icon: Zap, + matchSegment: "/seo-audit", + }, { to: "/p/$projectId/brand-lookup" as const, label: "Brand Lookup", @@ -99,11 +107,12 @@ export function getProjectNavGroups(projectId: string) { type: "group" as const, label: "Domain", icon: Globe, - matchSegments: ["/domain", "/backlinks", "/audit"], + matchSegments: ["/domain", "/backlinks", "/audit", "/seo-audit"], items: [ bySegment("/domain"), bySegment("/backlinks"), bySegment("/audit"), + bySegment("/seo-audit"), ], }, { diff --git a/src/db/app.schema.ts b/src/db/app.schema.ts index 5a8b299..fc6fca5 100644 --- a/src/db/app.schema.ts +++ b/src/db/app.schema.ts @@ -447,3 +447,35 @@ export const auditLighthouseResults = sqliteTable( }, (table) => [index("audit_lighthouse_results_audit_id_idx").on(table.auditId)], ); + +// ─── SEO Graph Audits (LangGraph / Railway backend) ─────────────────────────── +export const seoGraphAudits = sqliteTable( + "seo_graph_audits", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + startedByUserId: text("started_by_user_id").notNull(), + domain: text("domain").notNull(), + keywordsJson: text("keywords_json").notNull().default("[]"), + // run_id returned by Railway FastAPI after POST /api/v1/audit-graph + runId: text("run_id"), + status: text("status", { + enum: ["pending", "running", "completed", "failed"], + }) + .notNull() + .default("pending"), + // JSON array of node names in order visited, e.g. ["gather","technical","supervisor","crawlability_fix","strategy","synthesis"] + routingPath: text("routing_path").notNull().default("[]"), + clientReport: text("client_report"), + errorMessage: text("error_message"), + startedAt: text("started_at") + .notNull() + .default(sql`(current_timestamp)`), + completedAt: text("completed_at"), + }, + (table) => [ + index("seo_graph_audits_project_id_idx").on(table.projectId), + ], +); diff --git a/src/routes/_project/p/$projectId/seo-audit.tsx b/src/routes/_project/p/$projectId/seo-audit.tsx new file mode 100644 index 0000000..2489792 --- /dev/null +++ b/src/routes/_project/p/$projectId/seo-audit.tsx @@ -0,0 +1,9 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_project/p/$projectId/seo-audit")({ + component: SeoAuditLayout, +}); + +function SeoAuditLayout() { + return ; +} diff --git a/src/routes/_project/p/$projectId/seo-audit/index.tsx b/src/routes/_project/p/$projectId/seo-audit/index.tsx new file mode 100644 index 0000000..805c2c6 --- /dev/null +++ b/src/routes/_project/p/$projectId/seo-audit/index.tsx @@ -0,0 +1,44 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { seoGraphSearchSchema } from "@/types/schemas/seoGraph"; +import { LaunchView, AuditDetail } from "@/client/features/seo-graph/SeoGraphLauncher"; + +export const Route = createFileRoute<"/_project/p/$projectId/seo-audit/">( + "/_project/p/$projectId/seo-audit/", +)({ + validateSearch: seoGraphSearchSchema, + component: SeoAuditPage, +}); + +function SeoAuditPage() { + const { projectId } = Route.useParams(); + const { auditId } = Route.useSearch(); + const navigate = useNavigate({ from: Route.fullPath }); + + const setAuditId = useCallback( + (id: string | undefined) => { + void navigate({ + search: (prev) => ({ ...prev, auditId: id }), + replace: true, + }); + }, + [navigate], + ); + + if (!auditId) { + return ( + setAuditId(id)} + /> + ); + } + + return ( + setAuditId(undefined)} + /> + ); +} diff --git a/src/routes/api/seo-graph-stream/index.ts b/src/routes/api/seo-graph-stream/index.ts new file mode 100644 index 0000000..e690738 --- /dev/null +++ b/src/routes/api/seo-graph-stream/index.ts @@ -0,0 +1,72 @@ +/** + * /api/seo-graph-stream?run_id={runId} + * + * Proxies the SSE stream from Railway FastAPI: + * GET /api/v1/audit-graph/{run_id}/stream + * + * Events emitted by Railway: + * node_start { node, timestamp } + * thinking { node, chunk } (Nemotron streaming tokens) + * node_complete { node, timestamp } + * done { client_report } + * error { message } + * + * Cloudflare Workers support native EventSource / ReadableStream passthrough. + */ +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "cloudflare:workers"; + +const RAILWAY_BASE = + (env as unknown as { RAILWAY_SEO_API_URL?: string }).RAILWAY_SEO_API_URL ?? + "https://openclaw-api-k30t.onrender.com"; + +const RAILWAY_API_KEY = + (env as unknown as { RAILWAY_SEO_API_KEY?: string }).RAILWAY_SEO_API_KEY ?? + "test"; + +export const Route = createFileRoute("/api/seo-graph-stream/")({ + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + const url = new URL(request.url); + const runId = url.searchParams.get("run_id"); + + if (!runId) { + return new Response( + JSON.stringify({ error: "run_id query param required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + // Proxy the upstream SSE stream from Railway + const upstream = await fetch( + `${RAILWAY_BASE}/api/v1/audit-graph/${runId}/stream`, + { + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${RAILWAY_API_KEY}`, + }, + }, + ); + + if (!upstream.ok || !upstream.body) { + return new Response( + JSON.stringify({ error: `Railway returned ${upstream.status}` }), + { status: upstream.status, headers: { "Content-Type": "application/json" } }, + ); + } + + // Pass the ReadableStream through with SSE headers + return new Response(upstream.body, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Access-Control-Allow-Origin": "*", + }, + }); + }, + }, + }, +}); diff --git a/src/serverFunctions/seoGraph.ts b/src/serverFunctions/seoGraph.ts new file mode 100644 index 0000000..c726f7d --- /dev/null +++ b/src/serverFunctions/seoGraph.ts @@ -0,0 +1,191 @@ +import { createServerFn } from "@tanstack/react-start"; +import { env } from "cloudflare:workers"; +import { eq, desc } from "drizzle-orm"; +import { db } from "@/db"; +import { seoGraphAudits } from "@/db/app.schema"; +import { requireProjectContext } from "@/serverFunctions/middleware"; +import { + startSeoGraphAuditSchema, + getSeoGraphAuditStatusSchema, + getSeoGraphAuditHistorySchema, + deleteSeoGraphAuditSchema, + railwaySeoStatusSchema, +} from "@/types/schemas/seoGraph"; + +// ─── Railway FastAPI base URL ───────────────────────────────────────────────── +// Set RAILWAY_SEO_API_URL in wrangler.jsonc / Cloudflare dashboard. +// Falls back to the known Railway deployment URL. +const RAILWAY_BASE = + (env as unknown as { RAILWAY_SEO_API_URL?: string }).RAILWAY_SEO_API_URL ?? + "https://openclaw-api-k30t.onrender.com"; + +const RAILWAY_API_KEY = + (env as unknown as { RAILWAY_SEO_API_KEY?: string }).RAILWAY_SEO_API_KEY ?? + "test"; + +function railwayHeaders() { + return { + "Content-Type": "application/json", + Authorization: `Bearer ${RAILWAY_API_KEY}`, + }; +} + +// ─── startSeoGraphAudit ─────────────────────────────────────────────────────── +// 1. Inserts a D1 tracking row (status=pending) +// 2. POSTs to Railway FastAPI → gets run_id +// 3. Updates D1 row with run_id + status=running +// Returns the D1 row id (auditId) for the client to poll. +export const startSeoGraphAudit = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => startSeoGraphAuditSchema.parse(data)) + .handler(async ({ data, context }) => { + const auditId = crypto.randomUUID(); + + // Insert D1 tracking row + await db.insert(seoGraphAudits).values({ + id: auditId, + projectId: context.projectId, + startedByUserId: context.userId, + domain: data.domain, + keywordsJson: JSON.stringify(data.keywords), + status: "pending", + }); + + // POST to Railway FastAPI + let runId: string | null = null; + try { + const res = await fetch(`${RAILWAY_BASE}/api/v1/audit-graph`, { + method: "POST", + headers: railwayHeaders(), + body: JSON.stringify({ + domain: data.domain, + tenant_id: context.organizationId, + workspace_id: context.projectId, + keywords: data.keywords, + }), + }); + + if (res.ok) { + const json = (await res.json()) as { run_id?: string }; + runId = json.run_id ?? null; + } + } catch { + // Railway unreachable — mark failed + await db + .update(seoGraphAudits) + .set({ status: "failed", errorMessage: "Railway API unreachable" }) + .where(eq(seoGraphAudits.id, auditId)); + throw new Error("Railway SEO API is unreachable. Check RAILWAY_SEO_API_URL."); + } + + if (!runId) { + await db + .update(seoGraphAudits) + .set({ status: "failed", errorMessage: "Railway did not return run_id" }) + .where(eq(seoGraphAudits.id, auditId)); + throw new Error("Railway SEO API did not return a run_id."); + } + + // Update D1 with run_id + running status + await db + .update(seoGraphAudits) + .set({ runId, status: "running" }) + .where(eq(seoGraphAudits.id, auditId)); + + return { auditId, runId }; + }); + +// ─── getSeoGraphAuditStatus ─────────────────────────────────────────────────── +// Reads D1 row, polls Railway if still running, syncs D1 on terminal state. +export const getSeoGraphAuditStatus = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => getSeoGraphAuditStatusSchema.parse(data)) + .handler(async ({ data, context }) => { + const row = await db.query.seoGraphAudits.findFirst({ + where: (t, { and, eq: deq }) => + and(deq(t.id, data.auditId), deq(t.projectId, context.projectId)), + }); + + if (!row) throw new Error("Audit not found"); + + // If already terminal, return D1 state directly + if (row.status === "completed" || row.status === "failed") { + return row; + } + + // Poll Railway for live status + if (row.runId) { + try { + const res = await fetch( + `${RAILWAY_BASE}/api/v1/audit-graph/${row.runId}/status`, + { headers: railwayHeaders() }, + ); + + if (res.ok) { + const raw = await res.json(); + const parsed = railwaySeoStatusSchema.safeParse(raw); + + if (parsed.success) { + const { status, routing_path, client_report, error } = parsed.data; + + // Sync D1 on terminal state + if (status === "completed" || status === "failed") { + await db + .update(seoGraphAudits) + .set({ + status, + routingPath: JSON.stringify(routing_path), + clientReport: client_report ?? null, + errorMessage: error ?? null, + completedAt: new Date().toISOString(), + }) + .where(eq(seoGraphAudits.id, data.auditId)); + + return { ...row, status, routingPath: JSON.stringify(routing_path), clientReport: client_report ?? null }; + } + + // Still running — update routing_path in D1 for progress display + if (routing_path.length > 0) { + await db + .update(seoGraphAudits) + .set({ routingPath: JSON.stringify(routing_path) }) + .where(eq(seoGraphAudits.id, data.auditId)); + } + + return { ...row, status, routingPath: JSON.stringify(routing_path) }; + } + } + } catch { + // Railway unreachable — return stale D1 state + } + } + + return row; + }); + +// ─── getSeoGraphAuditHistory ────────────────────────────────────────────────── +export const getSeoGraphAuditHistory = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => getSeoGraphAuditHistorySchema.parse(data)) + .handler(async ({ context }) => { + return db + .select() + .from(seoGraphAudits) + .where(eq(seoGraphAudits.projectId, context.projectId)) + .orderBy(desc(seoGraphAudits.startedAt)) + .limit(20); + }); + +// ─── deleteSeoGraphAudit ────────────────────────────────────────────────────── +export const deleteSeoGraphAudit = createServerFn({ method: "POST" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => deleteSeoGraphAuditSchema.parse(data)) + .handler(async ({ data, context }) => { + await db + .delete(seoGraphAudits) + .where( + eq(seoGraphAudits.id, data.auditId) && + eq(seoGraphAudits.projectId, context.projectId), + ); + return { success: true }; + }); diff --git a/src/types/schemas/seoGraph.ts b/src/types/schemas/seoGraph.ts new file mode 100644 index 0000000..0b8c2f7 --- /dev/null +++ b/src/types/schemas/seoGraph.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +// ─── Server function input schemas ────────────────────────────────────────── + +export const startSeoGraphAuditSchema = z.object({ + projectId: z.string().min(1), + domain: z + .string() + .min(1, "Domain is required") + .max(253) + .transform((d) => d.replace(/^https?:\/\//, "").replace(/\/$/, "")), + keywords: z.array(z.string().min(1).max(200)).max(20).default([]), +}); + +export const getSeoGraphAuditStatusSchema = z.object({ + projectId: z.string().min(1), + auditId: z.string().min(1), +}); + +export const getSeoGraphAuditHistorySchema = z.object({ + projectId: z.string().min(1), +}); + +export const deleteSeoGraphAuditSchema = z.object({ + projectId: z.string().min(1), + auditId: z.string().min(1), +}); + +// ─── URL search params schema for /p/$projectId/seo-audit ──────────────────── + +export const seoGraphSearchSchema = z.object({ + auditId: z.string().optional(), +}); + +// ─── Railway API response types ─────────────────────────────────────────────── + +export const railwaySeoStatusSchema = z.object({ + status: z.enum(["pending", "running", "completed", "failed"]), + routing_path: z.array(z.string()).default([]), + loop_counter: z.number().int().default(0), + client_report: z.string().nullable().optional(), + error: z.string().nullable().optional(), +}); + +export type StartSeoGraphAuditInput = z.infer; +export type GetSeoGraphAuditStatusInput = z.infer; +export type GetSeoGraphAuditHistoryInput = z.infer; +export type DeleteSeoGraphAuditInput = z.infer; +export type SeoGraphSearchParams = z.infer; +export type RailwaySeoStatus = z.infer; diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 146e210..ff28ec0 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -10,6 +10,8 @@ declare namespace Cloudflare { R2: R2Bucket; DB: D1Database; DATAFORSEO_API_KEY: string; + RAILWAY_SEO_API_URL?: string; + RAILWAY_SEO_API_KEY?: string; PORT: string; AUTH_MODE: string; BETTER_AUTH_SECRET: string; From 8b4f2c24aedcf22391b61be420f85150bee410b2 Mon Sep 17 00:00:00 2001 From: Fahad Kiani Date: Sat, 6 Jun 2026 01:53:23 +0000 Subject: [PATCH 2/3] fix(seo-audit): use drizzle and() in deleteSeoGraphAudit, not JS && MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS && short-circuits to a boolean — drizzle needs and() from drizzle-orm. Also import and from drizzle-orm at top of file. --- src/serverFunctions/seoGraph.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/serverFunctions/seoGraph.ts b/src/serverFunctions/seoGraph.ts index c726f7d..bbd17f9 100644 --- a/src/serverFunctions/seoGraph.ts +++ b/src/serverFunctions/seoGraph.ts @@ -1,6 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; import { env } from "cloudflare:workers"; -import { eq, desc } from "drizzle-orm"; +import { and, eq, desc } from "drizzle-orm"; import { db } from "@/db"; import { seoGraphAudits } from "@/db/app.schema"; import { requireProjectContext } from "@/serverFunctions/middleware"; @@ -102,8 +102,8 @@ export const getSeoGraphAuditStatus = createServerFn({ method: "POST" }) .inputValidator((data: unknown) => getSeoGraphAuditStatusSchema.parse(data)) .handler(async ({ data, context }) => { const row = await db.query.seoGraphAudits.findFirst({ - where: (t, { and, eq: deq }) => - and(deq(t.id, data.auditId), deq(t.projectId, context.projectId)), + where: (t, { and: dand, eq: deq }) => + dand(deq(t.id, data.auditId), deq(t.projectId, context.projectId)), }); if (!row) throw new Error("Audit not found"); @@ -141,7 +141,12 @@ export const getSeoGraphAuditStatus = createServerFn({ method: "POST" }) }) .where(eq(seoGraphAudits.id, data.auditId)); - return { ...row, status, routingPath: JSON.stringify(routing_path), clientReport: client_report ?? null }; + return { + ...row, + status, + routingPath: JSON.stringify(routing_path), + clientReport: client_report ?? null, + }; } // Still running — update routing_path in D1 for progress display @@ -184,8 +189,10 @@ export const deleteSeoGraphAudit = createServerFn({ method: "POST" }) await db .delete(seoGraphAudits) .where( - eq(seoGraphAudits.id, data.auditId) && + and( + eq(seoGraphAudits.id, data.auditId), eq(seoGraphAudits.projectId, context.projectId), + ), ); return { success: true }; }); From 054ff42c3414b3518997a708a804a3ba64a9402a Mon Sep 17 00:00:00 2001 From: Fahad Kiani Date: Sat, 6 Jun 2026 02:22:56 +0000 Subject: [PATCH 3/3] fix(seo-audit): update default RAILWAY_SEO_API_URL to seo-ai-api-hsaz.onrender.com --- src/serverFunctions/seoGraph.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serverFunctions/seoGraph.ts b/src/serverFunctions/seoGraph.ts index bbd17f9..064c860 100644 --- a/src/serverFunctions/seoGraph.ts +++ b/src/serverFunctions/seoGraph.ts @@ -17,7 +17,7 @@ import { // Falls back to the known Railway deployment URL. const RAILWAY_BASE = (env as unknown as { RAILWAY_SEO_API_URL?: string }).RAILWAY_SEO_API_URL ?? - "https://openclaw-api-k30t.onrender.com"; + "https://seo-ai-api-hsaz.onrender.com"; const RAILWAY_API_KEY = (env as unknown as { RAILWAY_SEO_API_KEY?: string }).RAILWAY_SEO_API_KEY ??