Skip to content

Files

Latest commit

0f48d98 · May 9, 2026

History

History
1128 lines (882 loc) · 44.5 KB

File metadata and controls

1128 lines (882 loc) · 44.5 KB

Implementation Handbook — Phases 2 through 4

Audience: AI agents (including cheaper models) executing Phases 2-4 without the principal-engineer agent in the loop.

Authority: This document is binding. Locked-in decisions here may NOT be relitigated without explicit maintainer approval. If a request seems to conflict, stop and ask.

How to use this doc: Read top-to-bottom once. Find your current phase. Execute step-by-step. Every step has acceptance criteria — don't move on until they're met.


0. Ground rules for any agent working from here

  1. Read these first, in order:
    1. AGENTS.md — operating manual, locked-in decisions
    2. ARCHITECTURE.md — system design source of truth
    3. ROADMAP.md — phase status
    4. This document — concrete implementation
  2. Never:
    • Add a backend the maintainer operates ($0 ops constraint)
    • Add <all_urls> to host_permissions (we use activeTab only)
    • Auto-scan as the user scrolls (click-to-scan only)
    • Send any data anywhere except the user's chosen BYO provider in Boost mode
    • Introduce new .js files in src/ (TypeScript only)
    • Delete anything in archive/
  3. Always:
    • Type every cross-context message via the discriminated union in src/shared/types.ts
    • Update CHANGELOG.md under [Unreleased] when you ship something
    • Run npm run typecheck && npm run lint && npm run test && npm run build before committing
    • Commit at "phase or sub-phase" granularity, push to main after each
  4. Commit message style: Honest. "Phase 2.1: wire offscreen → transformers.js (no model loaded yet)" beats "Implement ML."
  5. When in doubt: Stop and ask the maintainer. Do not invent product decisions. Do not soften an architectural constraint to make a task easier.

1. Locked-in decisions (master list)

These came out of explicit conversations with the maintainer. Treat as constraints.

1.1 Image detection (Phase 2)

# Decision Locked value
2.1 Default local model Organika/sdxl-detector — Swin-T, ~85 MB, well-supported in transformers.js
2.2 Quantization q8 (int8) — ~22 MB after quantization, tiny accuracy loss, huge UX win
2.3 Inference backend device: 'auto' — WebGPU when available, WASM fallback
2.4 Image fetch strategy Hybrid: canvas first, fall back to fetch(src, {credentials:'include'}) from offscreen
2.5 Model distribution HF primary, GitHub Releases fallback via scripts/mirror-model.sh (see §6)
2.6 First-scan UX Progress bar in popup with "Loading detector (~22 MB)..."
2.7 IndexedDB cache key SHA-256(bytes) + modelId + provider — 7-day TTL
2.8 Min image size for classification 96×96 px (visibility filter remains 40×40)
2.9 Power-user override modelId field in Options (advanced section)

1.2 Phase 2.5 — Benchmark (deferred per maintainer)

  • When: After Phase 2 ships and is in real personal use, before Phase 4.
  • What: Curate 200-image set with maintainer's help (~30 min). Test 3-4 candidate models. Pick winner.
  • Where: bench/ folder.
  • Candidates to test:
    • Organika/sdxl-detector (current default)
    • Ateeqq/ai-vs-human-image-detector (DINOv2-based)
    • umm-maybe/AI-image-detector (BEiT-based, larger)
    • One more newer model TBD at benchmark time

1.3 Text detection (Phase 3)

# Decision Locked value
3.1 Default local model Hello-SimpleAI/chatgpt-detector-roberta (~32 MB at q8)
3.2 Min selection length 100 characters. Below → "Selection too short for reliable detection." toast
3.3 Default confidence threshold 0.70 for text (vs 0.55 for images) — bias toward "Uncertain"
3.4 Long-text aggregation 512-token sliding window, 64-token overlap, mean of per-chunk probabilities
3.5 UX surfacing Toast only for v1 (no in-page highlights)
3.6 Skip behavior Skip <input> and <textarea> selections

1.4 BYO API key (Phase 4)

# Decision Locked value
4.1 LLM image classification Structured JSON output via Gemini's responseSchema and OpenAI's response_format: json_schema. HF uses real classifiers.
4.2 Output schema { label: 'ai' | 'real' | 'uncertain', confidence: number, reasoning: string }
4.3 Boost-mode failure → fall back to local with visible toast "Boost failed (rate limited) — using local model" — locked by maintainer
4.4 Client-side rate limiting 10 RPM per provider, queue and trickle, settings override
4.5 API key storage chrome.storage.sync (encrypted at rest by Chrome). Never logged, never sent anywhere except the chosen provider
4.6 Cache BYO results Yes, with provider in cache key
4.7 Provider precedence Try BYO first, fall back to local when boost is enabled

1.5 Cross-cutting

# Decision Locked value
X.1 Test strategy Mock the offscreen layer in unit tests. Real models live in bench/, run manually. CI never downloads ML weights.
X.2 Debug surface Hidden "Debug" view in popup, shows last 10 results with timing, source, errors. Reveal via 5x logo click.
X.3 Error reporting / Sentry Defer to Phase 7. Don't build telemetry pipes for users that don't exist.
X.4 Model versioning modelId in cache key + IndexedDB schema versioning. Old entries expire via TTL.
X.5 Failed sends from background Wrap chrome.tabs.sendMessage with chrome.runtime.lastError check; log silently.
X.6 Restricted-page detection Detect chrome://, chrome-extension://, about:, the Web Store, and PDF viewer. Show "This page can't be scanned" toast in the popup.

2. Pre-Phase-2 cleanup (do this first)

Phase 1 left some real bugs. Fix these as the first commit of Phase 2 work before any new features.

2.A Consolidate badge MutationObservers

Bug: src/content/badges.ts creates one MutationObserver per badge, all watching document.body with subtree:true. On a 50-image page that's 50 observers walking every DOM mutation. Major perf bug.

Fix:

  • Move to ONE module-level MutationObserver in src/content/index.ts (or a small src/content/badge-manager.ts).
  • The shared observer iterates all entries in activeBadges on each mutation and calls cleanup() for any whose img is no longer in document.body.
  • Remove the per-badge mutation observer from createBadge.
  • Keep per-badge ResizeObserver and scroll/resize listeners — they're cheap and per-badge specifically.

2.B Fix TDZ risk in createBadge

Bug: src/content/badges.ts:69 — the MutationObserver callback references cleanup which is declared after it.

Fix: This goes away when you do 2.A (no per-badge mutation observer). If you don't do 2.A first, declare cleanup before the observer that uses it.

2.C Replace null as unknown as HTMLImageElement text-toast hack

Bug: src/content/index.ts:171 abuses BadgeState for text toasts.

Fix: Introduce a discriminated union:

// in src/content/badges.ts
export type ScanEntry =
  | { kind: 'image'; img: HTMLImageElement; overlay: HTMLElement; badge: HTMLElement;
      timeoutId?: number; cleanup: () => void }
  | { kind: 'text'; toast: HTMLElement; timeoutId?: number; cleanup: () => void };

Update activeBadges: Map<string, ScanEntry>. RESULT handler switches on entry.kind. Drop BadgeState.

2.D Surface canvas-taint failures

Bug: imageToDataUrl silently returns null on SecurityError.

Fix: Have it return { ok: true; dataUrl } | { ok: false; reason: 'tainted' | 'too-small' | 'unsupported' }. The badge then shows a clearer message, e.g. "Couldn't read image (CORS)". Phase 2.E will then add a fetch-fallback.

2.E Add chrome.runtime.lastError handling on outbound sendMessages

Bug: Popup fires ANALYZE_ACTIVE_TAB on chrome:// pages and silently fails.

Fix: Wrap every chrome.tabs.sendMessage and chrome.runtime.sendMessage callsite in:

chrome.tabs.sendMessage(tabId, msg, (resp) => {
  if (chrome.runtime.lastError) {
    console.warn('[bg] sendMessage failed:', chrome.runtime.lastError.message);
    // Optional: surface a one-time toast in popup if applicable.
  }
});

For the popup specifically, detect restricted URLs (chrome.tabs.query returns the URL) and show "This page can't be scanned" instead of attempting.

2.F Tidy clearAllBadges iteration

Cosmetic: Replace simultaneous iteration+delete with:

for (const entry of activeBadges.values()) entry.cleanup();
activeBadges.clear();

Acceptance criteria for cleanup commit

  • npm run typecheck && npm run lint && npm run test && npm run build all green
  • Manual: open the extension on a page with 30+ images, verify only ONE MutationObserver is created (use Chrome DevTools Performance panel)
  • Manual: open the extension on chrome://extensions, click the popup → see "This page can't be scanned" toast
  • Commit message: Phase 2 prep: fix Phase 1 perf bugs and error surfacing

3. Phase 2 — Real local image detection

3.1 Goal

Replace the mock classifier with real ONNX inference via @huggingface/transformers running in the offscreen document. Default model: Organika/sdxl-detector at q8 quantization. First scan downloads the model (~22 MB), subsequent scans use the IndexedDB cache.

3.2 Step-by-step

Step 2.1 — Install transformers.js

npm install @huggingface/transformers

Important: transformers.js is ~3 MB. CRXJS will bundle it into the offscreen chunk only (lazy-loaded). Don't import it from popup, options, content, or background — only src/offscreen/.

Step 2.2 — Create src/offscreen/ml-engine.ts

This module owns all transformers.js state. Single source of truth for the ONNX session, used for both image (Phase 2) and text (Phase 3).

Skeleton:

import { pipeline, env, type ImageClassificationPipeline } from '@huggingface/transformers';

// Force remote-only (no local model resolution at runtime).
env.allowLocalModels = false;
env.allowRemoteModels = true;
env.useBrowserCache = true;

// HF primary, GitHub Releases fallback. See §6 for fallback wiring.
env.remoteHost = 'https://huggingface.co';

const DEFAULT_IMAGE_MODEL = 'Organika/sdxl-detector';
const MODEL_REVISION = 'main'; // TODO: pin to specific SHA before Phase 2 ships

let imageClassifier: ImageClassificationPipeline | null = null;
let imageLoading: Promise<ImageClassificationPipeline> | null = null;

export interface ProgressCallback {
  (info: { phase: 'downloading' | 'ready' | 'error'; pct?: number; error?: string }): void;
}

export async function ensureImageClassifier(
  modelId: string = DEFAULT_IMAGE_MODEL,
  onProgress?: ProgressCallback,
): Promise<ImageClassificationPipeline> {
  if (imageClassifier) return imageClassifier;
  if (imageLoading) return imageLoading;

  imageLoading = (async () => {
    onProgress?.({ phase: 'downloading', pct: 0 });
    try {
      const pipe = await pipeline('image-classification', modelId, {
        dtype: 'q8',
        device: 'auto',
        revision: MODEL_REVISION,
        progress_callback: (p: any) => {
          if (p.status === 'progress' && typeof p.progress === 'number') {
            onProgress?.({ phase: 'downloading', pct: Math.round(p.progress) });
          }
        },
      });
      imageClassifier = pipe as ImageClassificationPipeline;
      onProgress?.({ phase: 'ready', pct: 100 });
      return imageClassifier;
    } catch (err) {
      onProgress?.({ phase: 'error', error: String(err) });
      imageLoading = null;
      throw err;
    }
  })();

  return imageLoading;
}

export async function classifyImageDataUrl(
  dataUrl: string,
  onProgress?: ProgressCallback,
): Promise<{ rawScores: Record<string, number>; modelId: string; durationMs: number }> {
  const t0 = performance.now();
  const pipe = await ensureImageClassifier(undefined, onProgress);

  // transformers.js accepts data URLs and HTML image elements.
  const out = await pipe(dataUrl, { top_k: 5 });
  const rawScores: Record<string, number> = {};
  for (const item of out as Array<{ label: string; score: number }>) {
    rawScores[item.label] = item.score;
  }

  return {
    rawScores,
    modelId: DEFAULT_IMAGE_MODEL,
    durationMs: performance.now() - t0,
  };
}

Critical pattern: imageLoading Promise dedupe. Multiple simultaneous classify calls during the first download must share ONE pipeline-load promise. Don't kick off N parallel downloads.

Step 2.3 — Map raw scores to ClassificationResult

The model returns label-score pairs whose meaning depends on the model. For Organika/sdxl-detector:

  • Label 'real' → real image
  • Label 'fake' → AI-generated

Create src/detectors/image/local.ts (replace the stub):

import { classifyImageDataUrl } from '@/offscreen/ml-engine';
import type { ClassificationResult } from '@/shared/types';

const FAKE_LABEL_ALIASES = new Set(['fake', 'ai', 'ai-generated', 'generated']);
const REAL_LABEL_ALIASES = new Set(['real', 'human', 'photo', 'photograph']);

const UNCERTAIN_BAND = 0.10; // ±0.10 from threshold = uncertain

export async function classifyImageLocal(
  dataUrl: string,
  threshold: number,
): Promise<ClassificationResult> {
  const { rawScores, modelId, durationMs } = await classifyImageDataUrl(dataUrl);

  let aiScore = 0;
  let realScore = 0;
  for (const [label, score] of Object.entries(rawScores)) {
    const norm = label.toLowerCase();
    if (FAKE_LABEL_ALIASES.has(norm)) aiScore = Math.max(aiScore, score);
    if (REAL_LABEL_ALIASES.has(norm)) realScore = Math.max(realScore, score);
  }

  // Decide label
  let label: ClassificationResult['label'];
  let confidence: number;
  if (aiScore > realScore + UNCERTAIN_BAND && aiScore > threshold) {
    label = 'ai';
    confidence = aiScore;
  } else if (realScore > aiScore + UNCERTAIN_BAND && realScore > threshold) {
    label = 'real';
    confidence = realScore;
  } else {
    label = 'uncertain';
    confidence = Math.max(aiScore, realScore);
  }

  return {
    label,
    confidence,
    source: 'local',
    modelId,
    rawScores,
    durationMs,
  };
}

Step 2.4 — Wire offscreen message handlers

Update src/offscreen/index.ts to handle CLASSIFY_IMAGE directly:

import { classifyImageLocal } from '@/detectors/image/local';
import { ensureImageClassifier } from './ml-engine';
import type { Message } from '@/shared/types';

chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => {
  if (message.type === 'CLASSIFY_IMAGE') {
    (async () => {
      try {
        const result = await classifyImageLocal(message.dataUrl, message.threshold ?? 0.55);
        sendResponse({ ok: true, requestId: message.requestId, result });
      } catch (err) {
        sendResponse({ ok: false, requestId: message.requestId, error: String(err) });
      }
    })();
    return true; // async sendResponse
  }

  if (message.type === 'PRELOAD_IMAGE_MODEL') {
    (async () => {
      await ensureImageClassifier(undefined, (info) => {
        chrome.runtime.sendMessage({ type: 'MODEL_STATUS', phase: info.phase, pct: info.pct });
      });
      sendResponse({ ok: true });
    })();
    return true;
  }
});

Add threshold to ClassifyImageMessage and PRELOAD_IMAGE_MODEL to the Message discriminated union in src/shared/types.ts.

Step 2.5 — Update background to route through offscreen

In src/background/index.ts, replace mockClassifyImage invocation with offscreen routing:

async function handleClassifyImage(msg: ClassifyImageMessage, sender: chrome.runtime.MessageSender) {
  await ensureOffscreen();
  const settings = await getSettings();

  // Phase 4 will branch here on settings.boostProvider.
  const response = await chrome.runtime.sendMessage({
    type: 'CLASSIFY_IMAGE',
    requestId: msg.requestId,
    dataUrl: msg.dataUrl,
    threshold: settings.confidenceThreshold,
  });

  if (sender.tab?.id != null) {
    chrome.tabs.sendMessage(sender.tab.id, {
      type: 'RESULT',
      requestId: msg.requestId,
      result: response?.ok ? response.result : {
        label: 'uncertain', confidence: 0, source: 'local',
        modelId: 'error', durationMs: 0,
        reasoning: response?.error ?? 'Unknown error',
      },
    }, (_) => { /* ignore lastError */ void chrome.runtime.lastError; });
  }
}

Step 2.6 — Image fetch hybrid (canvas → fetch fallback)

In src/content/index.ts, modify imageToDataUrl to try createImageBitmap(img) first. On SecurityError (canvas tainted), send a FETCH_IMAGE_BYTES message to the background. Background does the fetch(src, {credentials:'include'}), returns the bytes as a dataUrl. Content script then resizes and continues.

Skeleton:

// Updated imageToDataUrl
async function imageToDataUrl(img: HTMLImageElement): Promise<string | null> {
  const src = img.currentSrc || img.src;
  if (!src) return null;

  // Skip data: URLs > 2 MB and SVGs (raster-only models can't handle SVG cleanly)
  if (src.startsWith('data:image/svg')) return null;
  if (src.startsWith('data:') && src.length > 2 * 1024 * 1024) return null;

  // Path A: try canvas (fast)
  try {
    const bitmap = await createImageBitmap(img, {
      resizeWidth: 224, resizeHeight: 224, resizeQuality: 'medium',
    });
    const canvas = new OffscreenCanvas(224, 224);
    const ctx = canvas.getContext('2d')!;
    ctx.drawImage(bitmap, 0, 0);
    bitmap.close();
    const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 });
    return await blobToDataUrl(blob);
  } catch (e) {
    // Path B: fall back to background fetch
    return new Promise((resolve) => {
      chrome.runtime.sendMessage({ type: 'FETCH_IMAGE_BYTES', src }, (resp) => {
        if (chrome.runtime.lastError || !resp?.ok) return resolve(null);
        resolve(resp.dataUrl);
      });
    });
  }
}

Background handles FETCH_IMAGE_BYTES:

if (message.type === 'FETCH_IMAGE_BYTES') {
  (async () => {
    try {
      const r = await fetch(message.src, { credentials: 'include' });
      if (!r.ok) return sendResponse({ ok: false, error: `HTTP ${r.status}` });
      const blob = await r.blob();
      const reader = new FileReader();
      reader.onload = () => sendResponse({ ok: true, dataUrl: reader.result });
      reader.onerror = () => sendResponse({ ok: false, error: 'read-failed' });
      reader.readAsDataURL(blob);
    } catch (err) {
      sendResponse({ ok: false, error: String(err) });
    }
  })();
  return true;
}

Note: fetch from the service worker doesn't replay browser-page cookies for cross-origin requests. For most cases (CDN images, public URLs) this works. For login-gated images, neither path will work without an opaque-response approach. Phase 2 ships with this limitation; document it.

Step 2.7 — IndexedDB result cache

Create src/shared/cache.ts:

import type { ClassificationResult } from './types';

const DB_NAME = 'aicd-cache-v1';
const STORE = 'results';
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days

interface CacheEntry {
  key: string; // sha256(bytes) + ':' + modelId + ':' + provider
  result: ClassificationResult;
  expiresAt: number;
}

let dbPromise: Promise<IDBDatabase> | null = null;

function openDb(): Promise<IDBDatabase> {
  if (dbPromise) return dbPromise;
  dbPromise = new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => {
      req.result.createObjectStore(STORE, { keyPath: 'key' });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
  return dbPromise;
}

export async function sha256Hex(input: string | ArrayBuffer): Promise<string> {
  const buf = typeof input === 'string' ? new TextEncoder().encode(input) : input;
  const hash = await crypto.subtle.digest('SHA-256', buf);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

export function cacheKey(contentHash: string, modelId: string, provider: string): string {
  return `${contentHash}:${modelId}:${provider}`;
}

export async function cacheGet(key: string): Promise<ClassificationResult | null> {
  const db = await openDb();
  return new Promise((resolve) => {
    const tx = db.transaction(STORE, 'readonly');
    const req = tx.objectStore(STORE).get(key);
    req.onsuccess = () => {
      const entry = req.result as CacheEntry | undefined;
      if (!entry) return resolve(null);
      if (entry.expiresAt < Date.now()) return resolve(null);
      resolve(entry.result);
    };
    req.onerror = () => resolve(null);
  });
}

export async function cacheSet(key: string, result: ClassificationResult): Promise<void> {
  const db = await openDb();
  return new Promise((resolve) => {
    const tx = db.transaction(STORE, 'readwrite');
    tx.objectStore(STORE).put({ key, result, expiresAt: Date.now() + TTL_MS });
    tx.oncomplete = () => resolve();
    tx.onerror = () => resolve();
  });
}

Use this in the offscreen handler around classifyImageLocal. Compute sha256Hex(dataUrl) (data URL is fine as a content hash for our purposes), build cache key, hit cacheGet, fall through to inference, cacheSet.

Step 2.8 — Popup model status indicator

Update src/popup/App.tsx:

  • On mount: chrome.runtime.sendMessage({ type: 'PRELOAD_IMAGE_MODEL' }) (fire-and-forget; the model loads in the background while the user reads the popup).
  • Listen for MODEL_STATUS messages and show a progress bar.
  • Replace "Mock mode (Phase 1)" pill with Local · ready / Local · downloading 47% / Boost · Gemini / Local · error.

Step 2.9 — Add modelId override (advanced section in Options)

In src/options/App.tsx, add an <details> "Advanced" section with a freeform modelId input. Default empty (uses Organika/sdxl-detector). Add localImageModel: string to ExtensionSettings in types.ts.

3.3 Acceptance criteria for Phase 2

  • npm run typecheck && npm run lint && npm run test && npm run build green
  • Manual: extension loads, popup shows "Local · downloading 0% → ... → ready" on first scan
  • Manual: scan visible images on a public page (e.g., a news site) shows real "AI" vs "Real" badges with confidence
  • Manual: second scan of the same images is instant (cache hit)
  • Manual: scan a Wikipedia article — ~all images get a result (not "Couldn't read")
  • Manual: scan an Instagram post (logged in) — at least 50% of images get a real result via fetch fallback
  • CI never downloads a model (unit tests mock ensureImageClassifier)
  • Sub-commits as you go, e.g.:
    • Phase 2.1: install transformers.js, scaffold ml-engine
    • Phase 2.2: real image inference via offscreen doc
    • Phase 2.3: IndexedDB result cache
    • Phase 2.4: image fetch fallback via background
    • Phase 2.5: popup model status indicator + modelId override

3.4 Phase 2 known limitations to document in CHANGELOG

  • Login-gated images on third-party sites (Discord channels, private LinkedIn posts) often won't classify. Acceptable for v1.
  • First scan after install takes 5-15 sec to download model. Show progress.
  • Newer image generators (Flux, SD3, Sora frames, GPT-Image) leak past Organika/sdxl-detector. This is the explicit reason for Phase 2.5 benchmark.

4. Phase 2.5 — Local image model benchmark

4.1 Goal

Replace Organika/sdxl-detector with the best-performing model under 100 MB on a maintainer-curated test set. Run after Phase 2 ships and is in real use.

4.2 Scaffold

Create bench/ with:

bench/
  README.md           # how to run
  curate.md           # maintainer-facing instructions for collecting test images
  fixtures/
    ai/               # ~30 known-AI images (gitignored; use git-lfs or external storage if checked in)
    real/             # ~30 known-real images
  run.ts              # node script: load each model, classify each fixture, output CSV
  results/            # per-run output, gitignored

4.3 Required maintainer involvement

Before running:

  1. Maintainer collects ~30 known-AI images (mix of generators they actually encounter) and ~30 known-real photos.
  2. Drops them into bench/fixtures/{ai,real}/.
  3. Agent runs npm run bench.

4.4 Models to benchmark (locked at decision time)

  • Organika/sdxl-detector (current default, baseline)
  • Ateeqq/ai-vs-human-image-detector
  • umm-maybe/AI-image-detector
  • One newer model TBD (search HF for "ai-detector" updated within 6 months)

4.5 Output

Write bench/results/YYYY-MM-DD.md with:

  • Table per model: accuracy, precision, recall, F1, mean confidence on correct preds, mean confidence on wrong preds, model size, mean inference time
  • Recommendation: keep current default, OR switch to model X with rationale
  • Switch only happens after maintainer reviews and approves

4.6 Acceptance criteria

  • Maintainer reviews benchmark report
  • If switch approved: bump default in src/offscreen/ml-engine.ts, pin new revision SHA, update CHANGELOG, run scripts/mirror-model.sh for the new model
  • If switch rejected: leave default as is, document rationale in bench/results/

5. Phase 3 — Real local text detection

5.1 Goal

"Detect AI in selection" (right-click context menu + popup button) returns a real classification using Hello-SimpleAI/chatgpt-detector-roberta running locally.

5.2 Step-by-step

Step 3.1 — Extend ml-engine with text classifier

Add to src/offscreen/ml-engine.ts:

import { pipeline, type TextClassificationPipeline } from '@huggingface/transformers';

const DEFAULT_TEXT_MODEL = 'Hello-SimpleAI/chatgpt-detector-roberta';

let textClassifier: TextClassificationPipeline | null = null;
let textLoading: Promise<TextClassificationPipeline> | null = null;

export async function ensureTextClassifier(
  modelId = DEFAULT_TEXT_MODEL,
  onProgress?: ProgressCallback,
): Promise<TextClassificationPipeline> {
  if (textClassifier) return textClassifier;
  if (textLoading) return textLoading;

  textLoading = (async () => {
    const pipe = await pipeline('text-classification', modelId, {
      dtype: 'q8',
      device: 'auto',
      progress_callback: (p: any) => {
        if (p.status === 'progress' && typeof p.progress === 'number') {
          onProgress?.({ phase: 'downloading', pct: Math.round(p.progress) });
        }
      },
    });
    textClassifier = pipe as TextClassificationPipeline;
    onProgress?.({ phase: 'ready', pct: 100 });
    return textClassifier;
  })();

  return textLoading;
}

Step 3.2 — Implement classifyTextLocal with sliding window

Create src/detectors/text/local.ts:

import { ensureTextClassifier } from '@/offscreen/ml-engine';
import type { ClassificationResult } from '@/shared/types';

const MIN_LENGTH = 100;

// Approx token count via 4-char heuristic (good enough for chunking; not exact)
function approxTokens(s: string): number { return Math.ceil(s.length / 4); }

function chunkText(text: string, maxTokens = 512, overlap = 64): string[] {
  if (approxTokens(text) <= maxTokens) return [text];
  const chunkSize = maxTokens * 4;
  const overlapSize = overlap * 4;
  const chunks: string[] = [];
  for (let start = 0; start < text.length; start += (chunkSize - overlapSize)) {
    chunks.push(text.slice(start, start + chunkSize));
    if (start + chunkSize >= text.length) break;
  }
  return chunks;
}

export async function classifyTextLocal(
  text: string,
  threshold: number,
): Promise<ClassificationResult | { tooShort: true }> {
  if (text.trim().length < MIN_LENGTH) return { tooShort: true };

  const t0 = performance.now();
  const pipe = await ensureTextClassifier();
  const chunks = chunkText(text);

  let aiSum = 0;
  let humanSum = 0;
  for (const chunk of chunks) {
    const result = (await pipe(chunk)) as Array<{ label: string; score: number }>;
    for (const r of result) {
      const label = r.label.toLowerCase();
      if (label.includes('ai') || label.includes('chatgpt') || label === 'fake') aiSum += r.score;
      if (label.includes('human') || label === 'real') humanSum += r.score;
    }
  }

  const aiAvg = aiSum / chunks.length;
  const humanAvg = humanSum / chunks.length;

  let label: ClassificationResult['label'];
  let confidence: number;
  if (aiAvg > threshold && aiAvg > humanAvg + 0.10) {
    label = 'ai'; confidence = aiAvg;
  } else if (humanAvg > threshold && humanAvg > aiAvg + 0.10) {
    label = 'real'; confidence = humanAvg;
  } else {
    label = 'uncertain'; confidence = Math.max(aiAvg, humanAvg);
  }

  return {
    label, confidence,
    source: 'local',
    modelId: 'Hello-SimpleAI/chatgpt-detector-roberta',
    rawScores: { ai: aiAvg, human: humanAvg },
    durationMs: performance.now() - t0,
  };
}

Step 3.3 — Wire into offscreen + background + content (same pattern as image)

  • Offscreen: handle CLASSIFY_TEXT similarly to CLASSIFY_IMAGE. If tooShort, return special response.
  • Background: route to offscreen.
  • Content script: if response is tooShort, show toast "Selection too short for reliable detection (min 100 chars)" instead of a result badge.
  • Content script handleTextSelection: skip if document.activeElement is <input> or <textarea> (selection from there is the user's own typed text).

Step 3.4 — Threshold default

In src/shared/types.ts, ExtensionSettings becomes:

export interface ExtensionSettings {
  boostProvider: BoostProvider;
  apiKey: string;
  imageThreshold: number;  // default 0.55
  textThreshold: number;   // default 0.70
  showUncertain: boolean;
  localImageModel: string;
  localTextModel: string;
  // Phase 4 additions:
  fallbackOnBoostFailure: boolean;     // default true
  rateLimitRpm: number;                // default 10
}

Add a settings migration — when loading old settings without textThreshold, default it to 0.70.

5.3 Acceptance criteria for Phase 3

  • npm run typecheck && npm run lint && npm run test && npm run build green
  • Manual: select a paragraph from a news article → "Detect AI in selection" → real classification toast appears
  • Manual: select 5 chars → "Selection too short" toast
  • Manual: type in a textarea, select → no scan triggered
  • Manual: pasting a known ChatGPT essay → labeled AI with high confidence
  • Manual: pasting a Wikipedia paragraph → labeled "real" or "uncertain"

6. Phase 4 — BYO API key boost mode

6.1 Goal

When a user pastes a Gemini / OpenAI / HF key in Options, image and text scans go to that provider with structured-JSON output. On failure, fall back to local with a visible toast.

6.2 Provider adapters

Create one file per provider per modality, all implementing the same shape:

// src/detectors/image/gemini.ts
import type { ClassificationResult } from '@/shared/types';

export async function classifyImageGemini(
  apiKey: string,
  dataUrl: string,
): Promise<ClassificationResult> {
  const t0 = performance.now();

  // Strip "data:image/jpeg;base64," prefix
  const [, mimeAndEncoding, base64] = dataUrl.match(/^data:(.+);base64,(.+)$/) || [];
  if (!base64) throw new Error('Invalid data URL');
  const mimeType = mimeAndEncoding?.split(';')[0] ?? 'image/jpeg';

  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`;
  const body = {
    contents: [{
      parts: [
        { text: 'Classify whether this image was generated by AI or is a real photograph. Respond with structured JSON.' },
        { inlineData: { mimeType, data: base64 } },
      ],
    }],
    generationConfig: {
      responseMimeType: 'application/json',
      responseSchema: {
        type: 'object',
        required: ['label', 'confidence', 'reasoning'],
        properties: {
          label: { type: 'string', enum: ['ai', 'real', 'uncertain'] },
          confidence: { type: 'number' },
          reasoning: { type: 'string' },
        },
      },
    },
  };

  const r = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`Gemini API ${r.status}: ${await r.text()}`);
  const data = await r.json();
  const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}';
  const parsed = JSON.parse(text);

  return {
    label: parsed.label,
    confidence: Math.max(0, Math.min(1, Number(parsed.confidence) || 0)),
    source: 'gemini',
    modelId: 'gemini-2.5-flash',
    reasoning: parsed.reasoning,
    durationMs: performance.now() - t0,
  };
}

Mirror the pattern for:

  • src/detectors/image/openai.ts — POST https://api.openai.com/v1/chat/completions with model: 'gpt-4o-mini', response_format: { type: 'json_schema', json_schema: { ... } }, image as data:image/...;base64,...
  • src/detectors/image/hf.ts — POST to https://api-inference.huggingface.co/models/{modelId} with raw bytes, parse the classifier output. Default modelId for HF: same as localImageModel.
  • src/detectors/text/{gemini,openai,hf}.ts — same pattern minus image bits.

6.3 Detector router

Update src/detectors/index.ts:

import { classifyImageLocal } from './image/local';
import { classifyImageGemini } from './image/gemini';
import { classifyImageOpenAI } from './image/openai';
import { classifyImageHF } from './image/hf';
import type { ClassificationResult, ExtensionSettings } from '@/shared/types';

export async function classifyImage(
  dataUrl: string,
  settings: ExtensionSettings,
  emitToast: (msg: string) => void,
): Promise<ClassificationResult> {
  const useBoost = settings.boostProvider !== 'none' && settings.apiKey;

  if (useBoost) {
    try {
      switch (settings.boostProvider) {
        case 'gemini': return await classifyImageGemini(settings.apiKey, dataUrl);
        case 'openai': return await classifyImageOpenAI(settings.apiKey, dataUrl);
        case 'hf':     return await classifyImageHF(settings.apiKey, dataUrl, settings.localImageModel);
      }
    } catch (err) {
      if (settings.fallbackOnBoostFailure) {
        emitToast(`Boost failed (${humanizeError(err)}) — using local model`);
        return classifyImageLocal(dataUrl, settings.imageThreshold);
      }
      throw err;
    }
  }

  return classifyImageLocal(dataUrl, settings.imageThreshold);
}

Where router runs: in the offscreen document, NOT background. Offscreen is the only context that can call classifyImageLocal (transformers.js is loaded there). For BYO providers, fetch() works in offscreen too.

6.4 Rate limiting

Create src/shared/rate-limit.ts:

class RateLimiter {
  private timestamps: number[] = [];
  constructor(private rpm: number) {}

  async acquire(): Promise<void> {
    const now = Date.now();
    this.timestamps = this.timestamps.filter((t) => now - t < 60_000);
    if (this.timestamps.length >= this.rpm) {
      const waitMs = 60_000 - (now - this.timestamps[0]) + 50;
      await new Promise((r) => setTimeout(r, waitMs));
      return this.acquire();
    }
    this.timestamps.push(now);
  }
}

const limiters: Record<string, RateLimiter> = {};

export async function acquireRateLimit(provider: string, rpm: number): Promise<void> {
  if (!limiters[provider]) limiters[provider] = new RateLimiter(rpm);
  await limiters[provider].acquire();
}

Call acquireRateLimit(settings.boostProvider, settings.rateLimitRpm) at the top of each provider adapter.

6.5 Boost-failure toast (locked behavior)

The router calls emitToast(). The offscreen sends a BOOST_TOAST message to the background, which forwards it to the active tab's content script. Content script renders it as a 3-sec floating toast styled the same as the text-result toast.

6.6 Options UI updates

Add to src/options/App.tsx:

  • "Test API key" button — calls a tiny diagnostic ("respond with {label: 'real', confidence: 1, reasoning: 'test'}") to verify the key works
  • Toggle: "Fall back to local model on boost failure" (default on)
  • Number input: "Rate limit (requests/min)" (default 10, min 1, max 60)

6.7 Acceptance criteria for Phase 4

  • npm run typecheck && npm run lint && npm run test && npm run build green
  • Manual: paste a Gemini key, click Test → "Test successful · 234 ms"
  • Manual: scan images with Gemini configured → badges show Gemini · 94%
  • Manual: paste an invalid key, scan → toast "Boost failed (invalid key) — using local model", badge shows local result
  • Manual: rate-limited burst (set rpm=2, scan 10 images) → trickles cleanly, no errors
  • BYO provider keys never appear in console.log, network requests, or stored anywhere except chrome.storage.sync
  • CI tests for provider adapters use mocked fetch (no real API calls)

7. Cross-cutting reference

7.1 Test strategy

Unit tests live in tests/unit/. They MUST NOT:

  • Download ML models
  • Call real APIs
  • Open real browser contexts

They SHOULD:

  • Mock @huggingface/transformers via vi.mock to return fixed pipeline outputs
  • Mock fetch for provider adapters
  • Test the label-mapping logic in classifyImageLocal / classifyTextLocal thoroughly
  • Test the cache (use fake-indexeddb if needed)
  • Test the rate limiter

Example mocking pattern:

import { vi } from 'vitest';

vi.mock('@huggingface/transformers', () => ({
  pipeline: vi.fn().mockResolvedValue(
    vi.fn().mockResolvedValue([
      { label: 'fake', score: 0.92 },
      { label: 'real', score: 0.08 },
    ]),
  ),
  env: { allowLocalModels: false, allowRemoteModels: true, useBrowserCache: true },
}));

Real-model tests live in bench/, run manually.

7.2 Settings migrations

When you add a field to ExtensionSettings, update src/shared/storage.ts to fill defaults from DEFAULT_SETTINGS. Already does spread-with-defaults, so existing users don't lose data on upgrade.

7.3 Common pitfalls

Pitfall Avoid by
Importing @huggingface/transformers from popup/options/content/background Only import from src/offscreen/. Will bloat other bundles otherwise.
Calling chrome.offscreen.createDocument when one already exists Always check chrome.offscreen.hasDocument() first
Sending messages from offscreen → content script directly Don't. Route through background SW. Offscreen ↔ background ↔ content.
Service worker dies, breaks ongoing classification Each CLASSIFY_* request must be self-contained — no SW-level state assumed beyond the offscreen doc itself
chrome.runtime.sendMessage with an undefined receiver Wrap with .catch(() => {}) or check chrome.runtime.lastError in callback
Holding the model pipeline as a top-level singleton OK because offscreen is one document; just don't accidentally re-init on every call
Assuming canvas works on cross-origin images Always have the fetch fallback ready
Logging API keys NEVER console.log settings. Pick fields explicitly.

7.4 Anti-patterns (don't do these)

// ❌ ANTI-PATTERN: re-implementing the message bus
chrome.runtime.onMessage.addListener((m: any) => { /* untyped */ });

// ✅ DO: use the typed Message union
chrome.runtime.onMessage.addListener((m: Message, sender, sendResponse) => {
  if (m.type === 'CLASSIFY_IMAGE') { /* ... */ }
});
// ❌ ANTI-PATTERN: per-instance MutationObserver on document.body
new MutationObserver(...).observe(document.body, { childList: true, subtree: true });

// ✅ DO: ONE shared observer in the content-script module
// ❌ ANTI-PATTERN: fall-through types
type Entry = { img: HTMLImageElement | null; ... };
if (!entry.img) { /* it's a text toast */ }

// ✅ DO: discriminated unions
type Entry = { kind: 'image'; img: HTMLImageElement } | { kind: 'text'; toast: HTMLElement };
if (entry.kind === 'text') { /* known text */ }
// ❌ ANTI-PATTERN: pessimistic invalidation
const result = await classify(image); // never cached

// ✅ DO: cache hit → skip inference
const cached = await cacheGet(key);
if (cached) return cached;
const result = await classify(image);
await cacheSet(key, result);
return result;

7.5 Reference: external docs


8. Mirror script (scripts/mirror-model.sh)

Create this in Phase 2.5 or earlier when wiring the GH Releases fallback.

#!/usr/bin/env bash
# scripts/mirror-model.sh
# Usage: ./scripts/mirror-model.sh <hf-model-id> <revision> <release-tag>
# Example: ./scripts/mirror-model.sh Organika/sdxl-detector main models-v1
set -euo pipefail

MODEL_ID="${1:?HF model id required}"
REVISION="${2:-main}"
RELEASE_TAG="${3:-models-v1}"

WORKDIR="$(mktemp -d)"
cd "$WORKDIR"

echo "Downloading $MODEL_ID @ $REVISION..."
# Use huggingface-cli or curl. ONNX file is what we ship.
curl -L -o model.onnx "https://huggingface.co/$MODEL_ID/resolve/$REVISION/onnx/model_quantized.onnx"
curl -L -o config.json "https://huggingface.co/$MODEL_ID/resolve/$REVISION/config.json"
# Other files: tokenizer.json (for text), preprocessor_config.json (for image), etc.

echo "Attaching to GitHub Release $RELEASE_TAG..."
gh release upload "$RELEASE_TAG" model.onnx config.json --repo rithwikgokhale/image-ai-detector --clobber

echo "Done. Files mirrored to: https://github.com/rithwikgokhale/image-ai-detector/releases/tag/$RELEASE_TAG"
echo "Update src/offscreen/ml-engine.ts to point env.remoteHost fallback at this release."

Then in ml-engine.ts, on a network error during model load, retry with env.remoteHost = 'https://github.com/rithwikgokhale/image-ai-detector/releases/download/models-v1'.


9. When to escalate to the maintainer

Stop and ask the maintainer if:

  • A locked-in decision in §1 seems wrong for the current task
  • A model returns wildly different label names than expected (Organika/sdxl-detector returning LABEL_0 / LABEL_1 instead of fake/real would mean a different model variant was downloaded — confirm before mapping)
  • Phase 2 acceptance criteria are not all met after honest effort
  • A new permission would be needed in manifest.config.ts
  • An existing CI check would need to be relaxed
  • Model download size exceeds 100 MB
  • Boost provider response shape doesn't match the expected schema reliably

Don't ask:

  • Naming of internal symbols (just be sensible)
  • Choice of styling for new UI (match existing Tailwind patterns)
  • Order of implementing the steps within a phase (follow this doc)

10. Phase 1 → 4 commit cadence

Roughly one push per sub-phase:

Phase 2 prep: fix Phase 1 perf bugs and error surfacing       [commit + push]
Phase 2.1: install transformers.js, scaffold ml-engine        [commit + push]
Phase 2.2: real image inference via offscreen doc             [commit + push]
Phase 2.3: IndexedDB result cache                             [commit + push]
Phase 2.4: image fetch fallback via background                [commit + push]
Phase 2.5: popup model status + modelId override              [commit + push]

Phase 2.5 (benchmark): scaffold bench/ + run.ts               [commit + push]
[after maintainer collects test images]
Phase 2.5 (benchmark): results + decision                     [commit + push]

Phase 3.1: text classifier in ml-engine                       [commit + push]
Phase 3.2: text local detector + sliding window               [commit + push]
Phase 3.3: text UX wiring                                     [commit + push]

Phase 4.1: provider adapter interface + Gemini image          [commit + push]
Phase 4.2: OpenAI + HF image                                  [commit + push]
Phase 4.3: text providers                                     [commit + push]
Phase 4.4: detector router + rate limiting                    [commit + push]
Phase 4.5: Options UI for boost mode + Test button            [commit + push]

Update CHANGELOG.md [Unreleased] → keep adding bullets.

Update ROADMAP.md ✅ checkmarks as phases finish.


End of handbook. Ping the principal-engineer agent (or the maintainer directly) for anything not covered here.