|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8" /> |
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" /> |
|
|
<title>OpenAI Text Origin Detector (Transformers.js + ONNX)</title> |
|
|
<style> |
|
|
:root{ |
|
|
--bg: #0b1021; |
|
|
--panel: #0f1531; |
|
|
--panel-2: #0c122b; |
|
|
--text: #e9eeff; |
|
|
--muted: #9fb3ff; |
|
|
--accent: #7aa2ff; |
|
|
--success: #2ecc71; |
|
|
--warn: #f39c12; |
|
|
--danger: #e74c3c; |
|
|
--border: 1px solid rgba(255,255,255,.08); |
|
|
--radius: 14px; |
|
|
--shadow: 0 10px 30px rgba(0,0,0,.35); |
|
|
} |
|
|
*{box-sizing:border-box} |
|
|
html,body{height:100%} |
|
|
body{ |
|
|
margin:0; background: |
|
|
radial-gradient(1200px 800px at 10% -10%, #1a2252 0%, transparent 60%), |
|
|
radial-gradient(1200px 800px at 110% 10%, #1b2d61 0%, transparent 60%), |
|
|
var(--bg); |
|
|
color:var(--text); |
|
|
font: 16px/1.6 system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial, sans-serif; |
|
|
padding: 24px; |
|
|
display:flex; flex-direction:column; gap:24px; |
|
|
} |
|
|
header{ |
|
|
display:flex; justify-content:space-between; align-items:center; gap:16px; |
|
|
padding: 18px 20px; border-radius: var(--radius); backdrop-filter: blur(6px); |
|
|
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); |
|
|
border: var(--border); box-shadow: var(--shadow); |
|
|
} |
|
|
h1{font-size: clamp(22px, 3.2vw, 28px); margin:0; letter-spacing:.2px} |
|
|
.subtitle{color:var(--muted); font-size:14px} |
|
|
|
|
|
.grid{display:grid; grid-template-columns:1.1fr .9fr; gap:22px} |
|
|
@media (max-width: 960px){ .grid{grid-template-columns:1fr; } } |
|
|
|
|
|
.card{ |
|
|
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); |
|
|
border:var(--border); border-radius:var(--radius); box-shadow:var(--shadow); padding:18px |
|
|
} |
|
|
|
|
|
textarea{ |
|
|
width:100%; height:220px; resize:vertical; background:var(--panel); |
|
|
color:var(--text); border-radius:12px; border:var(--border); padding:12px 14px; outline:none |
|
|
} |
|
|
|
|
|
.row{display:flex; align-items:center; gap:10px; flex-wrap:wrap} |
|
|
.row.split { margin-top: 8px; } |
|
|
|
|
|
button{ |
|
|
appearance:none; border:0; border-radius:12px; padding:10px 14px; cursor:pointer; font-weight:600; |
|
|
background: linear-gradient(180deg, #7aa2ff, #4e77ff); color:white; box-shadow: 0 6px 20px rgba(78,119,255,.35) |
|
|
} |
|
|
button.secondary{background: linear-gradient(180deg, #7780a6, #5a6284); box-shadow:none} |
|
|
button.ghost{background:transparent; border:var(--border); color:var(--text)} |
|
|
button:disabled{opacity:.55; cursor:not-allowed} |
|
|
|
|
|
.muted{color:var(--muted); font-size:12px} |
|
|
|
|
|
.progress-wrap{display:flex; align-items:center; gap:10px} |
|
|
progress{width:280px; height:12px; accent-color:#7aa2ff} |
|
|
|
|
|
#result{ |
|
|
margin-top:12px; padding:14px; background:var(--panel-2); border:var(--border); |
|
|
border-radius:12px; white-space:pre-wrap |
|
|
} |
|
|
|
|
|
.badge{ |
|
|
display:inline-flex; align-items:center; gap:6px; font-size:12px; border-radius:999px; padding:6px 10px; |
|
|
background:#1a234a; border:var(--border) |
|
|
} |
|
|
.badge.success{background: rgba(46, 204, 113, .12); border: 1px solid rgba(46, 204, 113, .35); color:#b9f6d0} |
|
|
.badge.warn{background: rgba(243, 156, 18, .12); border: 1px solid rgba(243, 156, 18, .35); color:#ffe2b9} |
|
|
.badge.danger{background: rgba(231, 76, 60, .12); border: 1px solid rgba(231, 76, 60, .35); color:#ffbdb4} |
|
|
|
|
|
|
|
|
.bars{display:grid; gap:8px; margin-top:10px} |
|
|
.bar{display:grid; gap:6px} |
|
|
.bar-head{display:flex; justify-content:space-between; align-items:center; font-size:13px} |
|
|
.bar-track{height:10px; border-radius:8px; background:rgba(255,255,255,.08); overflow:hidden} |
|
|
.bar-fill{height:100%; width:0; background: linear-gradient(90deg, #7aa2ff, #4e77ff); transition:width .25s ease} |
|
|
|
|
|
#log{ |
|
|
background:#060a1d; color:#b8d0ff; padding:12px; border-radius:12px; height:200px; |
|
|
overflow:auto; font-size:12px; border:var(--border) |
|
|
} |
|
|
|
|
|
.footer-note{opacity:.8; font-size:12px} |
|
|
.spacer{flex:1} |
|
|
|
|
|
|
|
|
.table { |
|
|
width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 14px; |
|
|
background: var(--panel-2); border: var(--border); border-radius: 12px; overflow: hidden; |
|
|
} |
|
|
.table thead { background: rgba(255,255,255,.04); } |
|
|
.table th, .table td { padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,.06); vertical-align: top; } |
|
|
.table th { text-align: left; color: var(--muted); font-weight: 700; } |
|
|
.table tr:last-child td { border-bottom: none; } |
|
|
.pill { |
|
|
display: inline-block; padding: 4px 8px; border-radius: 999px; font-weight: 700; font-size: 12px; |
|
|
border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.04); |
|
|
} |
|
|
.pill.ok { border-color: rgba(46, 204, 113, .35); background: rgba(46, 204, 113, .12); color:#b9f6d0; } |
|
|
.pill.bad{ border-color: rgba(231, 76, 60, .35); background: rgba(231, 76, 60, .12); color:#ffbdb4; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<header> |
|
|
<div> |
|
|
<h1>OpenAI Text Origin Detector</h1> |
|
|
<div class="subtitle">Transformers.js + ONNX Runtime (browser)</div> |
|
|
</div> |
|
|
<div class="row"> |
|
|
<button id="load-model-btn">Load model</button> |
|
|
<button id="cancel-load-btn" class="secondary" style="display:none;">Cancel (UI reset)</button> |
|
|
<button id="clear-log-btn" class="ghost">Clear log</button> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<div class="grid"> |
|
|
<section class="card"> |
|
|
<p> |
|
|
Load and initialize |
|
|
<code>onnx-community/roberta-base-openai-detector-ONNX</code>. |
|
|
First run downloads model/tokenizer files (cached afterwards). |
|
|
</p> |
|
|
|
|
|
|
|
|
<div id="loading" style="display:none; margin-bottom:8px;"> |
|
|
<div class="progress-wrap"> |
|
|
<progress id="progress" value="0" max="100"></progress> |
|
|
<span id="progress-label" class="muted">0%</span> |
|
|
</div> |
|
|
<div class="muted">Tip: WebGPU + q4 for fastest inference on supported browsers.</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="text-input-section" style="display:none;"> |
|
|
<textarea id="input-text" placeholder="Paste text (Single mode) or multiple prompts (Batch mode)… |
|
|
— Single mode: paste one prompt and click “Run Detection”. |
|
|
— Batch mode: choose a splitter below, paste multiple prompts, then click “Run Batch”. |
|
|
Long inputs are auto‑chunked into 510‑token windows (stride 50) and aggregated."></textarea> |
|
|
|
|
|
|
|
|
<div class="row split"> |
|
|
<span class="muted">Mode:</span> |
|
|
<button id="mode-single" class="ghost">Single</button> |
|
|
<button id="mode-batch" class="ghost">Batch</button> |
|
|
<span class="spacer"></span> |
|
|
|
|
|
<span id="splitter-wrap" class="row" style="display:none;"> |
|
|
<span class="muted">Split by</span> |
|
|
<button class="ghost" data-split="newline">Newline</button> |
|
|
<button class="ghost" data-split="blankline">Blank line</button> |
|
|
<button class="ghost" data-split="jsonl">JSONL ({"text":…})</button> |
|
|
</span> |
|
|
</div> |
|
|
|
|
|
<div class="row"> |
|
|
<button id="detect-btn">Run Detection</button> |
|
|
<button id="batch-btn" style="display:none;">Run Batch</button> |
|
|
<button id="stop-btn" class="secondary" style="display:none;">Stop</button> |
|
|
<button id="copy-btn" class="ghost">Copy Result</button> |
|
|
<button id="csv-btn" class="ghost" style="display:none;">Download CSV</button> |
|
|
<span id="status-chip" class="badge" style="display:none;"></span> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="result" aria-live="polite"></div> |
|
|
<div class="bars" id="bars" style="display:none;"></div> |
|
|
|
|
|
|
|
|
<table class="table" id="batch-table" style="display:none;"> |
|
|
<thead> |
|
|
<tr> |
|
|
<th>#</th> |
|
|
<th>Preview</th> |
|
|
<th>Top label</th> |
|
|
<th>Confidence</th> |
|
|
<th>Human score</th> |
|
|
<th>Model score</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody id="batch-tbody"></tbody> |
|
|
</table> |
|
|
|
|
|
<div id="batch-summary" class="muted" style="display:none;"></div> |
|
|
</div> |
|
|
</section> |
|
|
|
|
|
<aside class="card"> |
|
|
<h3 style="margin-top:6px">Debug Log</h3> |
|
|
<div id="log" aria-live="polite"></div> |
|
|
<div class="row" style="margin-top:10px"> |
|
|
<span class="footer-note">Model: onnx-community/roberta-base-openai-detector-ONNX · Auto‑chunk: 510 tokens, stride 50</span> |
|
|
<span class="spacer"></span> |
|
|
<button id="save-log-btn" class="ghost">Save Log</button> |
|
|
</div> |
|
|
</aside> |
|
|
</div> |
|
|
|
|
|
<script type="module"> |
|
|
|
|
|
|
|
|
|
|
|
const MODEL_ID = 'onnx-community/roberta-base-openai-detector-ONNX'; |
|
|
const CDN_PRIMARY = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0'; |
|
|
const CDN_FALLBACK = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2'; |
|
|
|
|
|
|
|
|
|
|
|
const CONTENT_WINDOW = 510; |
|
|
const STRIDE_TOKENS = 50; |
|
|
|
|
|
|
|
|
const MAX_ITEMS = 500; |
|
|
const TOP_K = 2; |
|
|
const AGG_METHOD = 'mean'; |
|
|
|
|
|
|
|
|
const synonyms = { |
|
|
human: /^(real|human|legit|gpt-?0|not[- ]?ai)$/i, |
|
|
ai: /^(fake|ai|generated|machine|model|gpt[- ]?2|gpt)/i, |
|
|
}; |
|
|
function toFriendly(raw) { |
|
|
if (!raw) return 'Unknown'; |
|
|
const s = String(raw).trim(); |
|
|
if (synonyms.human.test(s)) return 'Human-written'; |
|
|
if (synonyms.ai.test(s)) return 'Model-generated'; |
|
|
if (/label[_ ]?0/i.test(s)) return 'Class 0'; |
|
|
if (/label[_ ]?1/i.test(s)) return 'Class 1'; |
|
|
return s; |
|
|
} |
|
|
function canonicalLabel(raw) { |
|
|
const f = toFriendly(raw); |
|
|
if (/human/i.test(f)) return 'Human-written'; |
|
|
if (/(model|generated|ai|fake)/i.test(f)) return 'Model-generated'; |
|
|
return f; |
|
|
} |
|
|
function chipKindFor(label) { |
|
|
if (/human/i.test(label)) return 'success'; |
|
|
if (/model|generated|ai|fake/i.test(label)) return 'danger'; |
|
|
return 'warn'; |
|
|
} |
|
|
|
|
|
const $ = (sel) => document.querySelector(sel); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const loadBtn = $('#load-model-btn'); |
|
|
const cancelBtn = $('#cancel-load-btn'); |
|
|
const clearBtn = $('#clear-log-btn'); |
|
|
const section = $('#text-input-section'); |
|
|
const detectBtn = $('#detect-btn'); |
|
|
const batchBtn = $('#batch-btn'); |
|
|
const stopBtn = $('#stop-btn'); |
|
|
const copyBtn = $('#copy-btn'); |
|
|
const csvBtn = $('#csv-btn'); |
|
|
const resultDiv = $('#result'); |
|
|
const loadingUI = $('#loading'); |
|
|
const pbar = $('#progress'); |
|
|
const plabel = $('#progress-label'); |
|
|
const logDiv = $('#log'); |
|
|
const saveLog = $('#save-log-btn'); |
|
|
const chip = $('#status-chip'); |
|
|
const bars = $('#bars'); |
|
|
const inputEl = $('#input-text'); |
|
|
const table = $('#batch-table'); |
|
|
const tbody = $('#batch-tbody'); |
|
|
const summary = $('#batch-summary'); |
|
|
const modeSingleBtn = $('#mode-single'); |
|
|
const modeBatchBtn = $('#mode-batch'); |
|
|
const splitterWrap = $('#splitter-wrap'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let pipe = null; |
|
|
let tokenizer = null; |
|
|
let cancelled = false; |
|
|
let batchCancelled = false; |
|
|
let mode = 'single'; |
|
|
let splitter = 'newline'; |
|
|
let lastBatchRows = []; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function log(...args) { |
|
|
const line = args.map(a => (typeof a === 'string' ? a : JSON.stringify(a, null, 2))).join(' '); |
|
|
logDiv.textContent += line + '\n'; |
|
|
logDiv.scrollTop = logDiv.scrollHeight; |
|
|
console.log('[LOG]', ...args); |
|
|
} |
|
|
function setProgress(evt) { |
|
|
if (evt?.status === 'progress') { |
|
|
const pct = Math.max(0, Math.min(100, Math.round(evt.progress || 0))); |
|
|
pbar.value = pct; |
|
|
plabel.textContent = `${pct}% ${evt?.name || evt?.file || ''}`.trim(); |
|
|
} else if (evt?.status) { |
|
|
log(`status: ${evt.status} ${evt?.name || evt?.file || ''}`.trim()); |
|
|
} |
|
|
} |
|
|
function showError(err) { |
|
|
const msg = err?.stack || err?.message || String(err); |
|
|
log('❌ ERROR:', msg); |
|
|
} |
|
|
function setChip(kind, text) { |
|
|
chip.className = 'badge'; |
|
|
if (kind) chip.classList.add(kind); |
|
|
chip.textContent = text; |
|
|
chip.style.display = 'inline-flex'; |
|
|
} |
|
|
function clearChip(){ chip.style.display = 'none'; } |
|
|
function preview(str, n = 120) { |
|
|
const s = String(str).replace(/\s+/g, ' ').trim(); |
|
|
return s.length <= n ? s : s.slice(0, n - 1) + '…'; |
|
|
} |
|
|
function parseBatchInput(text) { |
|
|
const raw = String(text); |
|
|
if (splitter === 'jsonl') { |
|
|
const lines = raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean); |
|
|
const items = []; |
|
|
for (const line of lines) { |
|
|
try { |
|
|
const obj = JSON.parse(line); |
|
|
if (obj && typeof obj.text === 'string' && obj.text.trim()) { |
|
|
items.push(obj.text.trim()); |
|
|
} |
|
|
} catch {} |
|
|
} |
|
|
return items; |
|
|
} |
|
|
if (splitter === 'blankline') { |
|
|
return raw.split(/\n\s*\n/g).map(s => s.trim()).filter(Boolean); |
|
|
} |
|
|
return raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean); |
|
|
} |
|
|
function toCSV(rows) { |
|
|
const esc = (s) => `"${String(s).replace(/"/g, '""')}"`; |
|
|
const header = ['index','top_label','top_score','human_score','model_score','chunks','tokens','text'].map(esc).join(','); |
|
|
const body = rows.map(r => |
|
|
[r.index, r.top_label, r.top_score, r.human_score, r.model_score, r.chunks, r.tokens, r.text] |
|
|
.map(esc).join(',') |
|
|
).join('\n'); |
|
|
return header + '\n' + body; |
|
|
} |
|
|
|
|
|
async function importTransformers() { |
|
|
try { |
|
|
log('Trying to import:', CDN_PRIMARY); |
|
|
return await import(CDN_PRIMARY); |
|
|
} catch (e1) { |
|
|
showError(e1); |
|
|
log('Primary import failed. Falling back to:', CDN_FALLBACK); |
|
|
return await import(CDN_FALLBACK); |
|
|
} |
|
|
} |
|
|
|
|
|
// Global error capture |
|
|
window.addEventListener('error', e => log('window.error:', e.message, e.filename, `${e.lineno}:${e.colno}`)); |
|
|
window.addEventListener('unhandledrejection', e => log('unhandledrejection:', e.reason?.message || e.reason)); |
|
|
|
|
|
// ========================== |
|
|
// Auto-chunking helpers |
|
|
// ========================== |
|
|
async function tokenizeIds(text) { |
|
|
// No special tokens; we want raw content length in tokens |
|
|
const { input_ids } = await tokenizer(text, { add_special_tokens: false }); |
|
|
// input_ids dims: [1, N]; typed array length = N |
|
|
return Array.from(input_ids.data); |
|
|
} |
|
|
|
|
|
function makeWindows(totalLen, windowSize = CONTENT_WINDOW, stride = STRIDE_TOKENS) { |
|
|
if (totalLen <= windowSize) return [[0, totalLen]]; |
|
|
const wins = []; |
|
|
let start = 0; |
|
|
while (start < totalLen) { |
|
|
const end = Math.min(start + windowSize, totalLen); |
|
|
wins.push([start, end]); |
|
|
if (end === totalLen) break; |
|
|
const step = Math.max(1, Math.min(windowSize - 1, stride)); |
|
|
start = end - step; |
|
|
} |
|
|
return wins; |
|
|
} |
|
|
|
|
|
async function chunkByTokens(text, windowSize = CONTENT_WINDOW, stride = STRIDE_TOKENS) { |
|
|
const ids = await tokenizeIds(text); |
|
|
const total = ids.length; |
|
|
const wins = makeWindows(total, windowSize, stride); |
|
|
if (wins.length === 1) { |
|
|
return { chunks: [text], tokens: total, windows: wins }; |
|
|
} |
|
|
// Decode each window back to text |
|
|
const chunks = wins.map(([s, e]) => tokenizer.decode(ids.slice(s, e), { skip_special_tokens: true })); |
|
|
return { chunks, tokens: total, windows: wins }; |
|
|
} |
|
|
|
|
|
function aggregateChunks(predsPerChunk, method = AGG_METHOD) { |
|
|
// predsPerChunk: Array< Array<{label, score}> > |
|
|
const labels = new Set(); |
|
|
const arrs = predsPerChunk.map(preds => preds.map(p => ({label: canonicalLabel(p.label), score: Number(p.score) || 0}))); |
|
|
arrs.forEach(preds => preds.forEach(p => labels.add(p.label))); |
|
|
const L = Array.from(labels); |
|
|
|
|
|
const mean = Object.fromEntries(L.map(l => [l, 0])); |
|
|
const max = Object.fromEntries(L.map(l => [l, 0])); |
|
|
const votes= Object.fromEntries(L.map(l => [l, 0])); |
|
|
|
|
|
for (const preds of arrs) { |
|
|
const map = Object.fromEntries(preds.map(p => [p.label, p.score])); |
|
|
// accumulate |
|
|
for (const l of L) { |
|
|
const s = map[l] || 0; |
|
|
mean[l] += s; |
|
|
max[l] = Math.max(max[l], s); |
|
|
} |
|
|
// vote top |
|
|
const top = preds.reduce((a,b) => (a.score > b.score ? a : b), {label: null, score: -1}); |
|
|
if (top.label != null) votes[top.label] += 1; |
|
|
} |
|
|
|
|
|
let out; |
|
|
if (method === 'max') { |
|
|
out = L.map(l => ({ label: l, score: max[l] })); |
|
|
} else if (method === 'vote') { |
|
|
out = L.map(l => ({ label: l, score: votes[l] / arrs.length })); |
|
|
} else { |
|
|
out = L.map(l => ({ label: l, score: mean[l] / arrs.length })); // mean |
|
|
} |
|
|
return out.sort((a,b) => b.score - a.score); |
|
|
} |
|
|
|
|
|
// ========================== |
|
|
// Loader: force WebGPU + q4 with safe fallbacks |
|
|
// ========================== |
|
|
async function loadPipelineWithWebGPU(mod) { |
|
|
const { pipeline, AutoTokenizer } = mod; |
|
|
if (!('gpu' in navigator)) { |
|
|
log('⚠️ WebGPU not available. This page forces WebGPU — please enable it in your browser.'); |
|
|
} |
|
|
const baseOptions = { |
|
|
device: 'webgpu', // Force WebGPU |
|
|
progress_callback: setProgress |
|
|
}; |
|
|
const dtypes = ['q4', 'q4f16', 'fp16', 'fp32']; // try fastest/smallest first |
|
|
let lastErr = null; |
|
|
for (const dtype of dtypes) { |
|
|
try { |
|
|
log('Loading with', { device: 'webgpu', dtype }); |
|
|
const p = await pipeline('text-classification', MODEL_ID, { ...baseOptions, dtype }); |
|
|
tokenizer = await AutoTokenizer.from_pretrained(MODEL_ID); |
|
|
log('✓ Ready with dtype:', dtype); |
|
|
return p; |
|
|
} catch (e) { |
|
|
lastErr = e; |
|
|
log('× Failed on dtype', dtype, '→', e.message || e); |
|
|
} |
|
|
} |
|
|
throw lastErr || new Error('Unable to load model on WebGPU'); |
|
|
} |
|
|
|
|
|
// ========================== |
|
|
// Modes |
|
|
// ========================== |
|
|
function setMode(next) { |
|
|
mode = next; |
|
|
modeSingleBtn.classList.toggle('secondary', mode !== 'single'); |
|
|
modeBatchBtn.classList.toggle('secondary', mode !== 'batch'); |
|
|
|
|
|
resultDiv.style.display = mode === 'single' ? 'block' : 'none'; |
|
|
bars.style.display = mode === 'single' ? 'grid' : 'none'; |
|
|
|
|
|
splitterWrap.style.display = mode === 'batch' ? 'flex' : 'none'; |
|
|
table.style.display = mode === 'batch' ? 'table' : 'none'; |
|
|
summary.style.display = mode === 'batch' ? 'block' : 'none'; |
|
|
csvBtn.style.display = mode === 'batch' ? 'inline-block' : 'none'; |
|
|
batchBtn.style.display= mode === 'batch' ? 'inline-block' : 'none'; |
|
|
detectBtn.style.display = mode === 'single' ? 'inline-block' : 'none'; |
|
|
|
|
|
if (mode === 'single') { |
|
|
tbody.innerHTML = ''; |
|
|
summary.textContent = ''; |
|
|
} else { |
|
|
resultDiv.textContent = ''; |
|
|
bars.innerHTML = ''; |
|
|
} |
|
|
} |
|
|
|
|
|
// ========================== |
|
|
// Events |
|
|
// ========================== |
|
|
loadBtn.addEventListener('click', async () => { |
|
|
try { |
|
|
cancelled = false; |
|
|
loadBtn.style.display = 'none'; |
|
|
cancelBtn.style.display = 'inline-block'; |
|
|
loadingUI.style.display = 'block'; |
|
|
pbar.value = 0; plabel.textContent = '0%'; |
|
|
|
|
|
const mod = await importTransformers(); |
|
|
pipe = await loadPipelineWithWebGPU(mod); |
|
|
|
|
|
if (cancelled) { |
|
|
log('Note: Cancel only resets the UI; it cannot interrupt an in-flight pipeline load.'); |
|
|
} |
|
|
|
|
|
loadingUI.style.display = 'none'; |
|
|
cancelBtn.style.display = 'none'; |
|
|
section.style.display = 'block'; |
|
|
log('✅ Model ready on WebGPU (quantized).'); |
|
|
} catch (err) { |
|
|
loadingUI.style.display = 'none'; |
|
|
cancelBtn.style.display = 'none'; |
|
|
loadBtn.style.display = 'inline-block'; |
|
|
showError(err); |
|
|
if ((err?.message || '').includes('404')) { |
|
|
log('Repository not found / private / typo. MODEL_ID:', MODEL_ID); |
|
|
} |
|
|
log('If WebGPU is disabled, enable it or change device to "auto" in the loader.'); |
|
|
} |
|
|
}); |
|
|
|
|
|
cancelBtn.addEventListener('click', () => { |
|
|
cancelled = true; |
|
|
loadBtn.style.display = 'inline-block'; |
|
|
cancelBtn.style.display = 'none'; |
|
|
loadingUI.style.display = 'none'; |
|
|
log('Canceled (UI only). If downloads are in-flight, they may still complete/fail later.'); |
|
|
}); |
|
|
|
|
|
clearBtn.addEventListener('click', () => { logDiv.textContent = ''; }); |
|
|
|
|
|
// Mode toggles |
|
|
modeSingleBtn.addEventListener('click', () => setMode('single')); |
|
|
modeBatchBtn .addEventListener('click', () => setMode('batch')); |
|
|
setMode('single'); // default |
|
|
|
|
|
// Splitter buttons |
|
|
splitterWrap.addEventListener('click', (e) => { |
|
|
const btn = e.target.closest('button[data-split]'); |
|
|
if (!btn) return; |
|
|
splitter = btn.getAttribute('data-split'); |
|
|
splitterWrap.querySelectorAll('button[data-split]').forEach(b => b.classList.remove('secondary')); |
|
|
btn.classList.add('secondary'); |
|
|
}); |
|
|
splitterWrap.querySelector('button[data-split="newline"]').classList.add('secondary'); |
|
|
|
|
|
// -------- Single inference (auto‑chunk) -------- |
|
|
detectBtn.addEventListener('click', async () => { |
|
|
try { |
|
|
clearChip(); |
|
|
bars.style.display = 'none'; |
|
|
bars.innerHTML = ''; |
|
|
const text = inputEl.value.trim(); |
|
|
if (!text) { |
|
|
resultDiv.textContent = 'Please paste some text to classify.'; |
|
|
return; |
|
|
} |
|
|
if (!pipe || !tokenizer) { |
|
|
resultDiv.textContent = 'Model is not loaded yet.'; |
|
|
return; |
|
|
} |
|
|
|
|
|
// Chunking |
|
|
const { chunks, tokens, windows } = await chunkByTokens(text); |
|
|
const nChunks = chunks.length; |
|
|
|
|
|
// Run chunks (update progress UI) |
|
|
loadingUI.style.display = 'block'; |
|
|
pbar.value = 0; plabel.textContent = `0% (chunks)`; |
|
|
let outputs = []; |
|
|
|
|
|
if (nChunks === 1) { |
|
|
let out = await pipe(chunks[0], { top_k: TOP_K }); |
|
|
if (Array.isArray(out) && Array.isArray(out[0])) out = out[0]; |
|
|
outputs = [out]; |
|
|
pbar.value = 100; plabel.textContent = `100% (1/1)`; |
|
|
} else { |
|
|
for (let i = 0; i < nChunks; i++) { |
|
|
let out = await pipe(chunks[i], { top_k: TOP_K }); |
|
|
if (Array.isArray(out) && Array.isArray(out[0])) out = out[0]; |
|
|
outputs.push(out); |
|
|
const pct = Math.round(((i + 1) / nChunks) * 100); |
|
|
pbar.value = pct; plabel.textContent = `${pct}% (${i + 1}/${nChunks})`; |
|
|
} |
|
|
} |
|
|
loadingUI.style.display = 'none'; |
|
|
|
|
|
// Aggregate across chunks |
|
|
const aggregated = aggregateChunks(outputs, AGG_METHOD); |
|
|
const items = aggregated.slice(); // already sorted |
|
|
const top = items[0] || { label: 'Unknown', score: 0 }; |
|
|
const topLabel = top.label; |
|
|
const conf = (Number(top.score) || 0) * 100; |
|
|
|
|
|
setChip(chipKindFor(topLabel), topLabel); |
|
|
resultDiv.textContent = |
|
|
`Prediction: ${topLabel}\nConfidence: ${conf.toFixed(2)}%\n` + |
|
|
`Chunks: ${nChunks} · Tokens: ${tokens}`; |
|
|
|
|
|
// Bars |
|
|
if (items.length > 1) { |
|
|
bars.style.display = 'grid'; |
|
|
bars.innerHTML = ''; |
|
|
items.forEach(({ label, score }) => { |
|
|
const pct = Math.max(0, Math.min(100, Math.round((Number(score) || 0) * 100))); |
|
|
const row = document.createElement('div'); |
|
|
row.className = 'bar'; |
|
|
const head = document.createElement('div'); |
|
|
head.className = 'bar-head'; |
|
|
head.innerHTML = `<div>${label}</div><div class="muted">${pct}%</div>`; |
|
|
const track = document.createElement('div'); track.className = 'bar-track'; |
|
|
const fill = document.createElement('div'); fill.className = 'bar-fill'; fill.style.width = '0%'; |
|
|
requestAnimationFrame(() => { fill.style.width = pct + '%'; }); |
|
|
track.appendChild(fill); |
|
|
row.appendChild(head); row.appendChild(track); |
|
|
bars.appendChild(row); |
|
|
}); |
|
|
} |
|
|
} catch (err) { |
|
|
loadingUI.style.display = 'none'; |
|
|
showError(err); |
|
|
resultDiv.textContent = 'Detection failed. See Debug Log for details.'; |
|
|
} |
|
|
}); |
|
|
|
|
|
// -------- Batch inference (auto‑chunk per prompt) -------- |
|
|
batchBtn.addEventListener('click', async () => { |
|
|
try { |
|
|
if (!pipe || !tokenizer) { setChip('warn', 'Load the model first'); setTimeout(clearChip, 1200); return; } |
|
|
|
|
|
const items = parseBatchInput(inputEl.value); |
|
|
if (items.length === 0) { setChip('warn', 'No prompts found'); setTimeout(clearChip, 1200); return; } |
|
|
if (items.length > MAX_ITEMS) { |
|
|
setChip('warn', `Trimming to first ${MAX_ITEMS} items`); |
|
|
} |
|
|
const prompts = items.slice(0, MAX_ITEMS); |
|
|
|
|
|
// Prep UI |
|
|
tbody.innerHTML = ''; |
|
|
table.style.display = 'table'; |
|
|
summary.style.display = 'block'; |
|
|
csvBtn.style.display = 'inline-block'; |
|
|
lastBatchRows = []; |
|
|
batchCancelled = false; |
|
|
stopBtn.style.display = 'inline-block'; |
|
|
|
|
|
let humanCount = 0, modelCount = 0; |
|
|
let processed = 0; |
|
|
loadingUI.style.display = 'block'; |
|
|
pbar.value = 0; plabel.textContent = '0% (inference)'; |
|
|
|
|
|
for (let i = 0; i < prompts.length; i++) { |
|
|
if (batchCancelled) break; |
|
|
const text = prompts[i]; |
|
|
|
|
|
// auto-chunk this prompt |
|
|
const { chunks, tokens } = await chunkByTokens(text); |
|
|
let outputs = []; |
|
|
|
|
|
if (chunks.length === 1) { |
|
|
let out = await pipe(chunks[0], { top_k: TOP_K }); |
|
|
if (Array.isArray(out) && Array.isArray(out[0])) out = out[0]; |
|
|
outputs = [out]; |
|
|
} else { |
|
|
for (let c = 0; c < chunks.length; c++) { |
|
|
let out = await pipe(chunks[c], { top_k: TOP_K }); |
|
|
if (Array.isArray(out) && Array.isArray(out[0])) out = out[0]; |
|
|
outputs.push(out); |
|
|
} |
|
|
} |
|
|
|
|
|
const aggregated = aggregateChunks(outputs, AGG_METHOD); |
|
|
const itemsAgg = aggregated.slice(); |
|
|
const top = itemsAgg[0] || { label: 'Unknown', score: 0 }; |
|
|
const topLabel = top.label; |
|
|
const conf = (Number(top.score) || 0) * 100; |
|
|
|
|
|
const humanScore = (itemsAgg.find(x => /human/i.test(x.label))?.score ?? 0) * 100; |
|
|
const modelScore = (itemsAgg.find(x => /(model|generated|ai|fake)/i.test(x.label))?.score ?? 0) * 100; |
|
|
|
|
|
if (/human/i.test(topLabel)) humanCount++; |
|
|
else if (/(model|generated|ai|fake)/i.test(topLabel)) modelCount++; |
|
|
|
|
|
// Append table row |
|
|
const tr = document.createElement('tr'); |
|
|
tr.innerHTML = ` |
|
|
<td>${i + 1}</td> |
|
|
<td class="muted">${preview(text)}</td> |
|
|
<td>${ |
|
|
/human/i.test(topLabel) |
|
|
? '<span class="pill ok">Human</span>' |
|
|
: /(model|generated|ai|fake)/i.test(topLabel) |
|
|
? '<span class="pill bad">Model</span>' |
|
|
: '<span class="pill">' + topLabel + '</span>' |
|
|
}</td> |
|
|
<td>${conf.toFixed(2)}%</td> |
|
|
<td>${humanScore.toFixed(2)}%</td> |
|
|
<td>${modelScore.toFixed(2)}%</td>`; |
|
|
tbody.appendChild(tr); |
|
|
|
|
|
lastBatchRows.push({ |
|
|
index: i + 1, |
|
|
text, |
|
|
top_label: topLabel, |
|
|
top_score: conf.toFixed(4), |
|
|
human_score: humanScore.toFixed(4), |
|
|
model_score: modelScore.toFixed(4), |
|
|
chunks: chunks.length, |
|
|
tokens |
|
|
}); |
|
|
|
|
|
processed++; |
|
|
const pct = Math.round((processed / prompts.length) * 100); |
|
|
pbar.value = pct; plabel.textContent = `${pct}% (inference) ${processed}/${prompts.length}`; |
|
|
} |
|
|
|
|
|
loadingUI.style.display = 'none'; |
|
|
stopBtn.style.display = 'none'; |
|
|
const skipped = batchCancelled ? ` · Stopped at ${processed}/${prompts.length}` : ''; |
|
|
summary.textContent = `Batch complete: ${processed} items · Human: ${humanCount} · Model: ${modelCount}${skipped}`; |
|
|
} catch (err) { |
|
|
loadingUI.style.display = 'none'; |
|
|
stopBtn.style.display = 'none'; |
|
|
showError(err); |
|
|
summary.textContent = 'Batch failed. See Debug Log for details.'; |
|
|
} |
|
|
}); |
|
|
|
|
|
// Stop current batch loop |
|
|
stopBtn.addEventListener('click', () => { batchCancelled = true; setChip('warn', 'Stopping…'); setTimeout(clearChip, 1000); }); |
|
|
|
|
|
// Copy single result (or batch summary) |
|
|
copyBtn.addEventListener('click', async () => { |
|
|
const text = (mode === 'single') |
|
|
? resultDiv.textContent.trim() |
|
|
: summary.textContent.trim(); |
|
|
if (!text) return; |
|
|
try { await navigator.clipboard.writeText(text); setChip('success', 'Copied'); setTimeout(clearChip, 1200); } |
|
|
catch { setChip('warn', 'Copy failed'); setTimeout(clearChip, 1400); } |
|
|
}); |
|
|
|
|
|
// CSV export (batch) |
|
|
csvBtn.addEventListener('click', () => { |
|
|
if (!lastBatchRows.length) return; |
|
|
const blob = new Blob([toCSV(lastBatchRows)], { type: 'text/csv;charset=utf-8' }); |
|
|
const url = URL.createObjectURL(blob); |
|
|
const a = Object.assign(document.createElement('a'), { href: url, download: 'detector_results.csv' }); |
|
|
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); |
|
|
}); |
|
|
|
|
|
// Save debug log |
|
|
saveLog.addEventListener('click', () => { |
|
|
const blob = new Blob([logDiv.textContent], { type: 'text/plain' }); |
|
|
const url = URL.createObjectURL(blob); |
|
|
const a = Object.assign(document.createElement('a'), { href: url, download: 'openai-detector-log.txt' }); |
|
|
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|