import React,{useEffect,useMemo,useRef,useState}from "react" type ApiDetectResponse ={ok: boolean score: number // 0.1 (higher => more likely AI) model?: string details?:{entropy?: number burstiness?: number tokenCount?: number sentenceCount?: number}notes?: string[] error?: string}type ScanMode = "ai" | "readability" | "claims" | "repetition" | "custom" type HistoryItem ={id:string createdAt: number mode: ScanMode text: string score: number label: "good" | "mid" | "bad" snippet: string meta: Record<string,string>}function clamp(n: number,min = 0,max = 1){return Math.max(min,Math.min(max,n))}function pct(n01: number){return Math.round(clamp(n01) * 100)}function labelForScore(score01: number): "good" | "mid" | "bad"{const s = clamp(score01) if (s < .35) return "good" if (s < .65) return "mid" return "bad"}function shortId(){return Math.random().toString(16).slice(2,10)}function wordCount(text: string){const t = text.trim() if (!t) return 0 return t.split(/s+/).filter(Boolean).length}function sentenceSplit(text: string){return text .replace(/s+/g," ") .split(/(?<=[.!?])s+/) .map((s) => s.trim()) .filter(Boolean)}function fleschReadingEase(text: string){// rough heuristic (no syllable dictionary) const words = text.trim().split(/s+/).filter(Boolean) const sentences = sentenceSplit(text) if (!words.length || !sentences.length) return 0 const syllables = words.reduce((acc,w) => acc + estimateSyllables(w),0) const ASL = words.length / sentences.length const ASW = syllables / words.length // Flesch Reading Ease const score = 206.835 - 1.015 * ASL - 84.6 * ASW return score}function estimateSyllables(word: string){const w = word.toLowerCase().replace(/[^a-z]/g,"") if (!w) return 0 // very rough const groups = w.match(/[aeiouy]+/g) let count = groups ? groups.length : 0 if (w.endsWith("e")) count = Math.max(1,count - 1) return Math.max(1,count)}function repetitionScore(text: string){// flags repeated 3-gram reuse as "more copy/paste-ish" const t = text.toLowerCase().replace(/[^a-z0-9s]/g," ") const toks = t.split(/s+/).filter(Boolean) if (toks.length < 30) return 0 const grams = new Map<string,number>() for (let i = 0; i < toks.length - 2; i++){const g = `${toks[i]}${toks[i + 1]}${toks[i + 2]}` grams.set(g,(grams.get(g) ?? 0) + 1)}const values = [...grams.values()] const repeats = values.filter((v) => v >= 3).reduce((a,b) => a + b,0) const denom = Math.max(1,values.reduce((a,b) => a + b,0)) return clamp(repeats / denom)}function claimsScore(text: string){// heuristic: numbers,dates,named entities-ish capitalization density const sents = sentenceSplit(text) if (!sents.length) return 0 let hits = 0 for (const s of sents){const hasNumber = /\\d{2,}\/.test(s) || /\\d{4}\/.test(s) const hasPercent = /%/.test(s) const hasCurrency = /[$€£]s?\d/.test(s) const caps = (s.match(/\[A-Z][a-z]{2,}\/g) ?? []).length const hasClaimVerb = /\(confirmed|reported|said|stated|announced|claims?|according to|evidence)\/i.test(s) if (hasNumber || hasPercent || hasCurrency || caps >= 3 || hasClaimVerb) hits++}return clamp(hits / sents.length)}async function readFileAsText(file: File): Promise<string>{const name = (file.name || "").toLowerCase() const ext = name.split(".").pop() || "" if (ext === "txt"){return (await file.text()).trim()}if (ext === "docx"){try{const mammoth = await import("mammoth") const arrayBuffer = await file.arrayBuffer() const result = await (mammoth as any).extractRawText({arrayBuffer}) return (result?.value ?? "").trim()}catch{// Keep the app usable even if DOCX parsing isn't available in a given environment.
throw new Error("DOCX parsing failed. Please paste text directly or upload a .txt file.")}}throw new Error("Unsupported file type. Please upload a .txt or .docx file.")}export default function App(){const [theme,setTheme] = useState<"dark" | "light">("dark") const [mode,setMode] = useState<ScanMode>("ai") const [text,setText] = useState("") const [busy,setBusy] = useState(false) const [error,setError] = useState<string | null>(null) const [score01,setScore01] = useState(0) const [notes,setNotes] = useState<string[]>([]) const [meta,setMeta] = useState<Record<string,string>>({}) const [highlightedHtml,setHighlightedHtml] = useState<string>("") const [history,setHistory] = useState<HistoryItem[]>([]) const [dragOver,setDragOver] = useState(false) const [calibration,setCalibration] = useState<"balanced" | "strict" | "lenient">("balanced") const fileInputRef = useRef<HTMLInputElement | null>(null) useEffect(() => {document.documentElement.setAttribute("data-theme",theme)},[theme]) const wc = useMemo(() => wordCount(text),[text]) const endpointLabel = useMemo(() => {if (mode === "ai") return "POST /api/detect" return "local heuristic"},[mode]) function setSample(){setError(null) setText(`When people talk about “AI detection,” they often assume there’s a single magic signature. In reality,most detectors combine lightweight signals like token predictability,sentence rhythm,and repetition patterns.nnA practical way to use a detector is to treat it as a smoke alarm: it can tell you when something looks unusual,but it can’t prove intent. If you care about accuracy,keep your text specific,cite claims,vary sentence structure naturally,and avoid over-polished filler.`)}function buildHeatmap(sentences: string[],sScores: number[]){// Render simple sentence highlighting const parts: string[] = [] for (let i = 0; i < sentences.length; i++){const s = sentences[i] const sc = clamp(sScores[i] ?? 0) const cls = sc < .35 ? "good" : sc < .65 ? "mid" : "bad" parts.push(`<span class="sent ${cls}">${escapeHtml(s)}</span>`)}return parts.join(" ")}function escapeHtml(s: string){return s .replaceAll("&","&amp;") .replaceAll("<","&lt;") .replaceAll(">","&gt;") .replaceAll('"',"&quot;") .replaceAll("'","&#039;")}async function runAiDetect(input: string){const res = await fetch("/api/detect",{method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({text: input})}) const data = (await res.json()) as ApiDetectResponse if (!res.ok || !data.ok){throw new Error(data.error || `Request failed (${res.status})`)}return data}function applyCalibration(raw: number){// small bias,not a fake “accuracy” if (calibration === "strict") return clamp(raw + .08) if (calibration === "lenient") return clamp(raw - .08) return clamp(raw)}async function analyze(){setBusy(true) setError(null) setNotes([]) setMeta({}) setHighlightedHtml("") try{const input = text.trim() if (wordCount(input) < 1){throw new Error("Paste some text first.")}if (mode === "ai"){const data = await runAiDetect(input) const calibrated = applyCalibration(data.score) setScore01(calibrated) setNotes(data.notes ?? []) const d = data.details ??{}const m: Record<string,string> ={}if (typeof d.entropy === "number") m["entropy"] = d.entropy.toFixed(3) if (typeof d.burstiness === "number") m["burstiness"] = d.burstiness.toFixed(3) if (typeof d.tokenCount === "number") m["tokens"] = String(d.tokenCount) if (typeof d.sentenceCount === "number") m["sentences"] = String(d.sentenceCount) if (data.model) m["model"] = data.model setMeta(m) // Basic per-sentence pseudo heatmap (fallback: reuse overall) const sents = sentenceSplit(input) const local = sents.map((s) => clamp(repetitionScore(s) * .25 + claimsScore(s) * .15 + calibrated * .6)) setHighlightedHtml(buildHeatmap(sents,local))}else if (mode === "readability"){const fr = fleschReadingEase(input) // map: harder text => higher score (more “risky”) const mapped = clamp(1 - clamp(fr / 100)) setScore01(mapped) setMeta({"flesch": fr.toFixed(1),"sentences": String(sentenceSplit(input).length),"words": String(wordCount(input))}) const tips: string[] = [] if (fr < 50) tips.push("Readability looks dense. Try shorter sentences and simpler words.") if (fr >= 50 && fr < 70) tips.push("Readability is okay. Tighten long sentences for clarity.") if (fr >= 70) tips.push("Readability looks easy to follow.") setNotes(tips) const sents = sentenceSplit(input) const sScores = sents.map((s) => clamp(1 - clamp(fleschReadingEase(s) / 100))) setHighlightedHtml(buildHeatmap(sents,sScores))}else if (mode === "claims"){const cs = claimsScore(input) setScore01(cs) setMeta({"claim-density": pct(cs) + "%","sentences": String(sentenceSplit(input).length)}) setNotes([cs > .6 ? "High claim density. If this is informational, consider adding sources or grounding details." : "Claim density looks moderate."]) const sents = sentenceSplit(input) const sScores = sents.map((s) => clamp(claimsScore(s))) setHighlightedHtml(buildHeatmap(sents,sScores))}else if (mode === "repetition"){const rs = repetitionScore(input) setScore01(rs) setMeta({"3-gram reuse": pct(rs) + "%","words": String(wordCount(input))}) setNotes([rs > .45 ? "Lots of repeated phrasing. That can look templated. Reduce boilerplate and vary sentence starts." : "Repetition looks normal."]) const sents = sentenceSplit(input) const sScores = sents.map((s) => repetitionScore(s)) setHighlightedHtml(buildHeatmap(sents,sScores))}else if (mode === "custom"){// simple weighted blend the user can tune later const rs = repetitionScore(input) const cs = claimsScore(input) const fr = clamp(1 - clamp(fleschReadingEase(input) / 100)) const blended = clamp(rs * .4 + cs * .35 + fr * .25) setScore01(blended) setMeta({"repetition": pct(rs) + "%","claims": pct(cs) + "%","readability-risk": pct(fr) + "%"}) setNotes(["Custom blend (repetition + claims + readability). Use this for quick triage, not proof."]) const sents = sentenceSplit(input) const sScores = sents.map((s) => clamp(repetitionScore(s) * .4 + claimsScore(s) * .35 + (1 - clamp(fleschReadingEase(s) / 100)) * .25)) setHighlightedHtml(buildHeatmap(sents,sScores))}const s = clamp(score01) // note: this is stale in same tick;we compute below using last branch value const finalScore = mode === "ai" ? undefined : undefined const effectiveScore = (() => {// use current derived state if already set; else recompute quickly for history if (mode === "ai") return clamp(applyCalibration(score01)) if (mode === "readability") return clamp(1 - clamp(fleschReadingEase(text) / 100)) if (mode === "claims") return clamp(claimsScore(text)) if (mode === "repetition") return clamp(repetitionScore(text)) return clamp(repetitionScore(text) * .4 + claimsScore(text) * .35 + (1 - clamp(fleschReadingEase(text) / 100)) * .25)})() const item: HistoryItem ={id: shortId(),createdAt: Date.now(),mode,text,score: effectiveScore,label: labelForScore(effectiveScore),snippet: text.trim().slice(0,140) + (text.trim().length > 140 ? "…" : ""),meta:{mode,words: String(wordCount(text)),endpoint: endpointLabel}}setHistory((h) => [item,...h].slice(0,25))}catch (e: any){setError(e?.message || "Something went wrong.")}finally{setBusy(false)}}function openFilePicker(){fileInputRef.current?.click()}async function onFileSelected(file: File | null){if (!file) return setError(null) try{const extracted = await readFileAsText(file) setText(extracted)}catch (e: any){setError(e?.message || "Could not read file.")}}function onDrop(e: React.DragEvent){e.preventDefault() setDragOver(false) const f = e.dataTransfer.files?.[0] if (f) void onFileSelected(f)}function loadHistory(item: HistoryItem){setText(item.text) setMode(item.mode) // re-run for consistent UI void analyze()}const scorePct = pct(score01) const badge = labelForScore(score01) const scanModes:{key:ScanMode;title:string;desc:string;tag?: string}[] = [{key: "ai",title: "AI Detector",desc: "Edge endpoint + lightweight signals.",tag: "on"},{key: "readability",title: "Readability",desc: "Flesch + sentence heatmap.",tag: "ok"},{key: "claims",title: "Claims Risk",desc: "Numbers / entities density.",tag: "ok"},{key: "repetition",title: "Repetition",desc: "3-gram reuse patterns.",tag: "ok"},{key: "custom",title: "Custom Blend",desc: "Weighted triage scan.",tag: "ok"}] const donutDash = useMemo(() => {const r = 36 const c = 2 * Math.PI * r const p = clamp(score01) return {c,dash: `${c * p} ${c * (1 - p)}`}},[score01]) return (<div className="app" onDragEnter={(e) => {e.preventDefault() setDragOver(true)}} onDragOver={(e) => e.preventDefault()} onDragLeave={(e) => {e.preventDefault() setDragOver(false)}} onDrop={onDrop} > {dragOver && (<div className="dropOverlay" onDragOver={(e) => e.preventDefault()} onDrop={onDrop}> <div className="dropCard card"> <div className="dropTitle">Drop a .txt or .docx file</div> <div className="dropSub">We’ll extract text and paste it into the editor.</div> </div> </div>)} <input ref={fileInputRef} className="fileInput" type="file" accept=".txt,.docx" onChange={(e) => void onFileSelected(e.target.files?.[0] ?? null)} /> <header className="topbar"> <div className="brand"> <div className="logo">AI</div> <div> <div className="brandTitle">AI Text Detector (Cloudflare Pages)</div> <div className="brandSub">Open-source,edge-safe. Probabilistic score (not a guarantee).</div> </div> </div> <div className="topActions"> <button className="btn ghost" onClick={openFilePicker}> Upload </button> <button className="btn ghost" onClick={setSample}> Sample </button> <label className="toggle"> <input type="checkbox" checked={theme === "dark"} onChange={(e) => setTheme(e.target.checked ? "dark" : "light")} /> Dark </label> <select className="select" value={calibration} onChange={(e) => setCalibration(e.target.value as any)}> <option value="balanced">Balanced</option> <option value="strict">Strict</option> <option value="lenient">Lenient</option> </select> <button className="btn primary" onClick={() => void analyze()} disabled={busy}> {busy ? <span className="spinner" /> : null} Analyze </button> </div> </header> <main className="layout"> <section className="card"> <div className="cardHeader"> <div> <div className="cardTitle">Paste text</div> <div className="cardHint">40+ words recommended for stable signals.</div> </div> <div className="meta"> <span className="pill subtle">Endpoint: {endpointLabel}</span> <span className="pill">{wc} words</span> </div> </div> <div className="editorWrap"> <textarea className="textarea" value={text} onChange={(e) => setText(e.target.value)} placeholder="Paste text here (40+ words recommended)…" /> </div> <div className="editorFooter"> <div className="leftFoot"> <span className={`badge ${badge}`}> Score: {scorePct}% </span> <span className="badge neutral">{mode.toUpperCase()}</span> </div> <div className="rightFoot"> <button className="btn small" onClick={() => setText("")} disabled={busy || !text.trim()}> Clear </button> <button className="btn small" onClick={openFilePicker} disabled={busy}> Upload </button> </div> </div> {error && (<div className="alert bad"> <div className="alertTitle">Error</div> <div className="alertBody">{error}</div> </div>)} <div className="card resultsCard"> <div className="resultTop"> <div className="donut" aria-label="Score donut"> <svg width="92" height="92" viewBox="0 0 92 92"> <circle className="donutTrack" cx="46" cy="46" r="36" /> <circle className="donutValue" cx="46" cy="46" r="36" strokeDasharray={donutDash.dash} /> </svg> <div className="donutCenter"> <div className="donutPct">{scorePct}%</div> <div className="donutLabel">risk</div> </div> </div> <div className="resultSummary"> <div> <div className="resultScoreLabel">Probabilistic signal score</div> <div className="resultScoreValue">{scorePct}%</div> </div> <div className="scoreBarWrap"> <div className="scoreBar"> <div className="scoreFill" style={{width: `${scorePct}%`}} /> </div> <div className="scoreTicks"> <span>likely human</span> <span>mixed</span> <span>likely ai</span> </div> </div> </div> </div> <div className="grid3"> <div className="metric"> <div className="metricLabel">Mode</div> <div className="metricValue">{mode}</div> <div className="metricHint">Switch scan types in the sidebar.</div> </div> <div className="metric"> <div className="metricLabel">Words</div> <div className="metricValue">{wc}</div> <div className="metricHint">More text improves stability.</div> </div> <div className="metric"> <div className="metricLabel">Calibration</div> <div className="metricValue">{calibration}</div> <div className="metricHint">Small bias only (not accuracy).</div> </div> <div className="metric wide"> <div className="metricLabel">Signals</div> <div className="metricValue"> {Object.keys(meta).length ? Object.entries(meta).map(([k,v]) => `${k}: ${v}`).join(" • ") : "—"} </div> <div className="metricHint">Some signals only appear in AI mode.</div> </div> </div> <div className="notes"> <div className="notesTitle">Notes</div> {notes?.length ? (<ul> {notes.map((n,idx) => (<li key={idx}>{n}</li>))} </ul>) : (<div className="smallMuted">Run a scan to see guidance.</div>)} </div> </div> <div className="card previewCard"> <div className="cardHeader"> <div> <div className="cardTitle">Preview heatmap</div> <div className="cardHint">Sentence-level highlighting (heuristic).</div> </div> </div> <div className="previewBody" dangerouslySetInnerHTML={{__html: highlightedHtml || '<span class="smallMuted">No preview yet.</span>'}} /> </div> </section> <aside className="card sideCard"> <div className="sideHeader"> <div className="sideTitle">Scans</div> <div className="sideSub">Pick a scan type,then Analyze.</div> </div> <div className="sideTabs"> <button className={`tabBtn ${mode === "ai" ? "active" : ""}`} onClick={() => setMode("ai")} > Detector <span className="tabPill">API</span> </button> <button className={`tabBtn ${mode !== "ai" ? "active" : ""}`} onClick={() => setMode("readability")} > Heuristics <span className="tabPill">Local</span> </button> </div> <div className="scanList"> {scanModes.map((s) => (<button key={s.key} className={`scanItem ${mode === s.key ? "active" : ""}`} onClick={() => setMode(s.key)} > <div className="scanIcon">{s.title.slice(0,1)}</div> <div className="scanText"> <div className="scanName">{s.title}</div> <div className="scanDesc">{s.desc}</div> </div> <div className={`scanTag ${s.tag === "ok" ? "ok" : s.tag === "off" ? "off" : ""}`}> {s.key === "ai" ? "on" : "ok"} </div> </button>))} </div> <div className="historyTools"> <button className="btn small" onClick={() => setHistory([])} disabled={!history.length}> Clear history </button> </div> <div className="historyList"> {!history.length ? (<div className="empty"> <div className="emptyTitle">No runs yet</div> <div className="smallMuted">Analyze something and your results will show up here.</div> </div>) : (history.map((h) => (<button key={h.id} className={`historyItem ${h.label}`} onClick={() => loadHistory(h)} > <div className="historyTop"> <div className="historyScore">{pct(h.score)}%</div> <div className="historyMeta"> <span>{new Date(h.createdAt).toLocaleTimeString()}</span> <span>•</span> <span>{h.mode}</span> <span>•</span> <span>{h.meta.words}w</span> </div> </div> <div className="historySnippet">{h.snippet}</div> <div className="historyBottom"> <span className="miniPill">{h.meta.endpoint}</span> </div> </button>)))} </div> <div className="sideFooter"> <div className="smallMuted"> Tip: if you see a blank page after deploy,open DevTools → Console. A broken import (like <code>mammoth/mammoth.browser</code>) will stop React from rendering. </div> </div> </aside> </main> <div className="footer"> <div className="smallMuted"> This starter uses lightweight statistical signals to stay Edge-compatible. If you want stronger detection,add a second-pass method later. </div> </div> </div>)}
