HarbourSOFT's picture
Update index.html
55a0110 verified
<!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}
/* lightweight bars for top-k breakdown */
.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}
/* Batch results table */
.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>
<!-- Loading state -->
<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>
<!-- Inference UI -->
<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>
<!-- Mode & splitter -->
<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>
<!-- Single result -->
<div id="result" aria-live="polite"></div>
<div class="bars" id="bars" style="display:none;"></div>
<!-- Batch table -->
<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">
// ==========================
// Configuration
// ==========================
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';
// Auto-chunk settings (tokens, not characters)
// RoBERTa effective content window ~510 tokens (512 total incl. specials)
const CONTENT_WINDOW = 510;
const STRIDE_TOKENS = 50;
// Batch parameters
const MAX_ITEMS = 500;
const TOP_K = 2;
const AGG_METHOD = 'mean'; // 'mean' | 'max' | 'vote'
// Friendly label normalization
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);
// ==========================
// DOM
// ==========================
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');
// ==========================
// State
// ==========================
let pipe = null;
let tokenizer = null;
let cancelled = false;
let batchCancelled = false;
let mode = 'single'; // 'single' | 'batch'
let splitter = 'newline'; // 'newline' | 'blankline' | 'jsonl'
let lastBatchRows = []; // for CSV export
// ==========================
// Utilities
// ==========================
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>