codelion commited on
Commit
338ce2e
·
verified ·
1 Parent(s): b3dddf7

Add persistent predictions dataset, ID-based share links, user feedback buttons

Browse files
Files changed (1) hide show
  1. app.py +345 -437
app.py CHANGED
@@ -1,22 +1,85 @@
1
  """HuggingFace Space for AI text detection using adaptive-classifier."""
2
 
3
- import io
4
  import re
5
  import urllib.parse
6
  import urllib.request
 
7
  from html.parser import HTMLParser
 
 
8
 
9
  import gradio as gr
10
  from PIL import Image, ImageDraw, ImageFont
11
  from adaptive_classifier import AdaptiveClassifier
 
12
 
13
- # Load model once at startup
 
 
14
  print("Loading model...")
15
  classifier = AdaptiveClassifier.from_pretrained(
16
  "adaptive-classifier/ai-detector", use_onnx=False
17
  )
18
  print("Model loaded!")
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  HEADERS = {
21
  "User-Agent": (
22
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
@@ -47,252 +110,128 @@ CSS = """
47
  --glow-blue: rgba(59, 130, 246, 0.15);
48
  }
49
 
50
- /* Global overrides */
51
  .gradio-container {
52
  background: var(--bg-deep) !important;
53
  font-family: 'Outfit', sans-serif !important;
54
  max-width: 820px !important;
55
  margin: 0 auto !important;
56
  }
57
-
58
- .main, .contain {
59
- background: transparent !important;
60
- }
61
-
62
  footer { display: none !important; }
63
 
64
- /* Header */
65
- .header-block {
66
- text-align: center;
67
- padding: 2rem 1rem 1rem;
68
- }
69
-
70
  .header-block h1 {
71
  font-family: 'DM Mono', monospace !important;
72
- font-size: 1.6rem !important;
73
- font-weight: 500 !important;
74
- color: var(--text-primary) !important;
75
- letter-spacing: 0.04em;
76
  margin-bottom: 0.4rem !important;
77
  }
78
-
79
  .header-block p {
80
- font-size: 0.85rem !important;
81
- color: var(--text-secondary) !important;
82
- line-height: 1.5 !important;
83
- max-width: 560px;
84
- margin: 0 auto !important;
85
  }
86
-
87
  .header-block a {
88
- color: var(--accent-cyan) !important;
89
- text-decoration: none !important;
90
  border-bottom: 1px solid rgba(6, 182, 212, 0.3);
91
  }
 
92
 
93
- .header-block a:hover {
94
- border-bottom-color: var(--accent-cyan);
95
- }
96
-
97
- /* Tabs */
98
- .tabs {
99
- background: transparent !important;
100
- border: none !important;
101
- }
102
-
103
  .tab-nav {
104
- background: transparent !important;
105
- border: none !important;
106
- justify-content: center !important;
107
- gap: 0.25rem !important;
108
  padding: 0.5rem 0 !important;
109
  }
110
-
111
  .tab-nav button {
112
  font-family: 'DM Mono', monospace !important;
113
- font-size: 0.8rem !important;
114
- font-weight: 400 !important;
115
- letter-spacing: 0.06em;
116
- text-transform: uppercase;
117
- color: var(--text-muted) !important;
118
- background: transparent !important;
119
  border: 1px solid var(--border-subtle) !important;
120
- border-radius: 6px !important;
121
- padding: 0.5rem 1.5rem !important;
122
  transition: all 0.2s ease !important;
123
  }
124
-
125
- .tab-nav button:hover {
126
- color: var(--text-secondary) !important;
127
- border-color: var(--text-muted) !important;
128
- }
129
-
130
  .tab-nav button.selected {
131
  color: var(--accent-cyan) !important;
132
  background: rgba(6, 182, 212, 0.08) !important;
133
  border-color: var(--accent-cyan) !important;
134
  }
 
135
 
136
- .tabitem {
137
- background: transparent !important;
138
- border: none !important;
139
- padding: 0 !important;
140
- min-height: 520px !important;
141
- }
142
-
143
- /* Input card */
144
  .input-card {
145
- background: var(--bg-card) !important;
146
- border: 1px solid var(--border-subtle) !important;
147
- border-radius: 10px !important;
148
- padding: 1.25rem !important;
149
- margin-top: 0.75rem !important;
150
  }
151
 
152
- /* Textbox overrides */
153
  textarea, input[type="text"] {
154
- font-family: 'DM Mono', monospace !important;
155
- font-size: 0.85rem !important;
156
- line-height: 1.65 !important;
157
- color: var(--text-primary) !important;
158
- background: var(--bg-input) !important;
159
- border: 1px solid var(--border-subtle) !important;
160
- border-radius: 8px !important;
161
- padding: 0.85rem 1rem !important;
162
  transition: border-color 0.2s ease !important;
163
  }
164
-
165
  textarea:focus, input[type="text"]:focus {
166
  border-color: var(--accent-blue) !important;
167
- box-shadow: 0 0 0 3px var(--glow-blue) !important;
168
- outline: none !important;
169
  }
170
-
171
  label span {
172
- font-family: 'DM Mono', monospace !important;
173
- font-size: 0.7rem !important;
174
- text-transform: uppercase !important;
175
- letter-spacing: 0.08em !important;
176
  color: var(--text-muted) !important;
177
  }
178
 
179
- /* Button */
180
  .detect-btn {
181
- font-family: 'DM Mono', monospace !important;
182
- font-size: 0.8rem !important;
183
- font-weight: 500 !important;
184
- letter-spacing: 0.06em !important;
185
  text-transform: uppercase !important;
186
  background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan)) !important;
187
- color: #fff !important;
188
- border: none !important;
189
- border-radius: 8px !important;
190
- padding: 0.7rem 2rem !important;
191
- cursor: pointer !important;
192
  transition: all 0.25s ease !important;
193
  box-shadow: 0 2px 12px rgba(59, 130, 246, 0.25) !important;
194
  }
 
195
 
196
- .detect-btn:hover {
197
- box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4) !important;
198
- transform: translateY(-1px) !important;
199
- }
200
-
201
- /* Result label */
202
  .result-card {
203
- background: var(--bg-card) !important;
204
- border: 1px solid var(--border-subtle) !important;
205
- border-radius: 10px !important;
206
- padding: 1.25rem !important;
207
- margin-top: 0.5rem !important;
208
- }
209
-
210
- .result-card .output-class {
211
- font-family: 'DM Mono', monospace !important;
212
- }
213
-
214
- /* Label component overrides */
215
- .output-label {
216
- background: transparent !important;
217
- }
218
-
219
- .output-label .label-name {
220
- font-family: 'DM Mono', monospace !important;
221
- font-size: 1.1rem !important;
222
  }
 
 
 
223
 
224
- /* Examples section */
225
  .examples-heading {
226
- font-family: 'DM Mono', monospace !important;
227
- font-size: 0.7rem !important;
228
- text-transform: uppercase !important;
229
- letter-spacing: 0.08em !important;
230
- color: var(--text-muted) !important;
231
- margin-top: 1.25rem !important;
232
- margin-bottom: 0.5rem !important;
233
  }
234
-
235
- /* Example buttons */
236
- .gallery {
237
- gap: 0.5rem !important;
238
- }
239
-
240
  .gallery .gallery-item {
241
- background: var(--bg-input) !important;
242
- border: 1px solid var(--border-subtle) !important;
243
- border-radius: 8px !important;
244
- padding: 0.75rem !important;
245
- transition: border-color 0.2s ease !important;
246
  }
 
 
 
 
247
 
248
- .gallery .gallery-item:hover {
249
- border-color: var(--text-muted) !important;
250
- }
251
-
252
- /* Preview textbox */
253
- .preview-box textarea {
254
- color: var(--text-secondary) !important;
255
- font-size: 0.78rem !important;
256
- opacity: 0.85;
257
- }
258
 
259
- /* Misc */
260
- .gr-group, .gr-block, .gr-box, .gr-panel {
261
- background: transparent !important;
262
- border: none !important;
263
- }
264
-
265
- .gr-padded {
266
- padding: 0 !important;
267
- }
268
-
269
- /* Info strip */
270
- .info-strip {
271
- text-align: center;
272
- padding: 1.25rem 1rem;
273
- }
274
-
275
- .info-strip p {
276
- font-family: 'DM Mono', monospace !important;
277
- font-size: 0.68rem !important;
278
- color: var(--text-muted) !important;
279
- letter-spacing: 0.03em;
280
- }
281
 
282
- .info-strip a {
283
- color: var(--text-secondary) !important;
284
- text-decoration: none !important;
285
- border-bottom: 1px dotted var(--text-muted);
286
  }
287
-
288
- /* Hide Gradio image toolbar (download/share/fullscreen) on result cards */
289
- #share-card .image-toolbar,
290
- #text-share-card .image-toolbar,
291
- #share-card .icon-buttons,
292
- #text-share-card .icon-buttons,
293
- #share-card button[aria-label],
294
- #text-share-card button[aria-label] {
295
- display: none !important;
296
  }
297
  """
298
 
@@ -321,13 +260,13 @@ AI_EXAMPLE = (
321
  )
322
 
323
 
 
 
 
324
  class _TextExtractor(HTMLParser):
325
- """Simple HTML to text extractor."""
326
-
327
  def __init__(self):
328
  super().__init__()
329
- self._parts = []
330
- self._skip = False
331
  self._skip_tags = {"script", "style", "nav", "header", "footer", "noscript"}
332
 
333
  def handle_starttag(self, tag, attrs):
@@ -345,13 +284,10 @@ class _TextExtractor(HTMLParser):
345
  self._parts.append(data)
346
 
347
  def get_text(self):
348
- text = " ".join(self._parts)
349
- text = re.sub(r"\s+", " ", text).strip()
350
- return text
351
 
352
 
353
  def fetch_url(url: str) -> str:
354
- """Fetch a URL and extract readable text."""
355
  if not url or not url.strip():
356
  return ""
357
  url = url.strip()
@@ -365,128 +301,110 @@ def fetch_url(url: str) -> str:
365
  return parser.get_text()
366
 
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  def _error_label(msg: str) -> dict:
369
- """Return an error as a valid gr.Label dict (needs numeric values)."""
370
  return {msg: 1.0}
371
 
372
 
373
- def detect_text(text: str) -> dict:
374
- """Classify text as human-written or AI-generated (label only)."""
375
  if not text or len(text.strip().split()) < 10:
376
  return _error_label("Please enter at least a few sentences (~50 words)")
377
  predictions = classifier.predict(text, k=2)
378
  return {label: round(score, 4) for label, score in predictions}
379
 
380
 
381
- def detect_text_full(text: str) -> tuple[dict, str, Image.Image | None]:
382
- """Classify text and generate share link + card."""
383
- result = detect_text(text)
384
- if len(result) != 2 or any(k.startswith("Please") for k in result):
385
- return result, "", None
386
- # Share link with text snippet as param
387
- snippet = text.strip()[:200]
388
- share_link = f"{SPACE_URL}/?text={urllib.parse.quote(snippet, safe='')}"
389
- # Card image
390
  top_label = max(result, key=result.get)
 
 
 
391
  preview = text.strip()[:80] + ("..." if len(text.strip()) > 80 else "")
392
  card = make_result_card(preview, top_label, result[top_label])
393
- return result, share_link, card
394
-
395
-
396
- SPACE_URL = "https://adaptive-classifier-ai-detector.hf.space"
397
-
398
-
399
- def make_result_card(url: str, label: str, confidence: float) -> Image.Image:
400
- """Generate a shareable result card image."""
401
- W, H = 800, 418 # Twitter card ratio ~1.91:1
402
- bg = "#0a0e17"
403
- card_bg = "#1a2234"
404
- human_color = "#10b981"
405
- ai_color = "#f59e0b"
406
- text_color = "#e2e8f0"
407
- muted_color = "#8892a6"
408
-
409
- img = Image.new("RGB", (W, H), bg)
410
- draw = ImageDraw.Draw(img)
411
-
412
- # Use default font (always available)
413
- try:
414
- font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
415
- font_med = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
416
- font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
417
- font_mono = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 16)
418
- except OSError:
419
- font_large = ImageFont.load_default()
420
- font_med = font_large
421
- font_small = font_large
422
- font_mono = font_large
423
-
424
- # Card background
425
- draw.rounded_rectangle([30, 30, W - 30, H - 30], radius=16, fill=card_bg)
426
-
427
- # Title
428
- draw.text((60, 55), "AI Text Detector", fill=text_color, font=font_large)
429
-
430
- # Result
431
- is_ai = label.lower() == "ai"
432
- result_color = ai_color if is_ai else human_color
433
- result_text = "AI-Generated" if is_ai else "Human-Written"
434
- pct = f"{confidence * 100:.1f}%"
435
-
436
- draw.text((60, 120), "Result:", fill=muted_color, font=font_med)
437
- draw.text((60, 155), result_text, fill=result_color, font=font_large)
438
- draw.text((60, 200), f"Confidence: {pct}", fill=text_color, font=font_med)
439
-
440
- # Confidence bar
441
- bar_x, bar_y, bar_w, bar_h = 60, 240, W - 120, 24
442
- draw.rounded_rectangle([bar_x, bar_y, bar_x + bar_w, bar_y + bar_h], radius=12, fill="#0f1729")
443
- fill_w = int(bar_w * confidence)
444
- if fill_w > 0:
445
- draw.rounded_rectangle([bar_x, bar_y, bar_x + fill_w, bar_y + bar_h], radius=12, fill=result_color)
446
-
447
- # Source text/URL
448
- display_src = url[:80] + "..." if len(url) > 80 else url
449
- is_url = url.startswith("http") or url.startswith("www")
450
- src_label = "URL analyzed:" if is_url else "Text analyzed:"
451
- draw.text((60, 285), src_label, fill=muted_color, font=font_small)
452
- draw.text((60, 308), display_src, fill=text_color, font=font_mono)
453
-
454
- # Footer
455
- draw.text((60, H - 60), "adaptive-classifier-ai-detector.hf.space", fill=muted_color, font=font_small)
456
-
457
- return img
458
 
459
 
460
- def detect_url(url: str) -> tuple[dict, str, str, Image.Image | None]:
461
- """Fetch a URL and classify its text content."""
462
  if not url or not url.strip():
463
- return _error_label("Please enter a URL"), "", "", None
464
  try:
465
  text = fetch_url(url)
466
  except Exception as e:
467
- return _error_label(f"Could not fetch URL: {e}"), "", "", None
468
- # Detect JS-only pages that return no usable content
469
  js_hints = ["javascript is not available", "enable javascript", "javascript is disabled",
470
  "please enable js", "requires javascript", "noscript",
471
  "if you are not redirected", "please click here"]
472
  text_lower = text.lower()
473
- if any(h in text_lower for h in js_hints) or len(text.split()) < 10:
474
- if any(h in text_lower for h in js_hints):
475
- return _error_label("This site requires JavaScript to load content (e.g. Twitter/X, SPAs). Try a different URL or paste the text directly."), "", "", None
476
- return _error_label("Not enough readable text found at that URL"), text[:500], "", None
477
  words = text.split()
478
  if len(words) > 2000:
479
  text = " ".join(words[:2000])
480
- result = detect_text(text)
481
- preview = text[:1500] + ("..." if len(text) > 1500 else "")
482
- share_link = f"{SPACE_URL}/?url={urllib.parse.quote(url.strip(), safe='')}"
483
- # Generate share card image
484
  top_label = max(result, key=result.get)
485
- card_img = make_result_card(url.strip(), top_label, result[top_label])
486
- return result, preview, share_link, card_img
487
-
 
 
 
488
 
489
 
 
 
 
490
  with gr.Blocks(css=CSS, title="AI Text Detector", theme=gr.themes.Base()) as demo:
491
 
492
  gr.HTML("""
@@ -502,170 +420,137 @@ with gr.Blocks(css=CSS, title="AI Text Detector", theme=gr.themes.Base()) as dem
502
  """)
503
 
504
  with gr.Tabs() as tabs:
 
505
  with gr.TabItem("Text", id="text-tab"):
506
  with gr.Group(elem_classes="input-card"):
507
- text_input = gr.Textbox(
508
- lines=7,
509
- placeholder="Paste text here to analyze...",
510
- label="Input Text",
511
- show_label=True,
512
- )
513
  text_btn = gr.Button("Analyze", variant="primary", elem_classes="detect-btn")
514
 
515
  with gr.Group(elem_classes="result-card"):
516
  text_output = gr.Label(num_top_classes=2, label="Result")
 
 
 
 
 
 
 
517
  text_share_link = gr.Textbox(visible=False, elem_id="text-share-url")
518
  with gr.Row(visible=False) as text_share_row:
519
  text_copy_link_btn = gr.Button("Copy Link", size="sm", elem_classes="detect-btn")
520
  text_copy_img_btn = gr.Button("Copy Image", size="sm", elem_classes="detect-btn")
521
  text_dl_img_btn = gr.Button("Download Image", size="sm", elem_classes="detect-btn")
522
- text_share_card = gr.Image(
523
- label="Result card",
524
- visible=False,
525
- type="pil",
526
- elem_id="text-share-card",
527
- )
528
 
529
- def run_text_detection(text):
530
- result, link, card = detect_text_full(text)
531
- has_result = bool(link)
532
  return (
533
- result,
534
- gr.update(value=link, visible=has_result),
535
- gr.update(visible=has_result),
536
- gr.update(value=card, visible=has_result),
537
  )
538
 
539
  text_btn.click(
540
- fn=run_text_detection,
541
- inputs=text_input,
542
- outputs=[text_output, text_share_link, text_share_row, text_share_card],
543
  api_name="detect",
544
  )
545
 
546
- text_copy_link_btn.click(
547
- fn=None, inputs=text_share_link, outputs=None,
548
- js="(url) => { navigator.clipboard.writeText(url); }",
549
- )
550
- text_copy_img_btn.click(
551
- fn=None, inputs=None, outputs=None,
552
- js="""() => {
553
- const img = document.querySelector('#text-share-card img');
554
- if (img) {
555
- const c = document.createElement('canvas');
556
- c.width = img.naturalWidth; c.height = img.naturalHeight;
557
- c.getContext('2d').drawImage(img, 0, 0);
558
- c.toBlob(b => navigator.clipboard.write([new ClipboardItem({'image/png': b})]), 'image/png');
559
- }
560
- }""",
561
- )
562
- text_dl_img_btn.click(
563
- fn=None, inputs=None, outputs=None,
564
- js="""() => {
565
- const img = document.querySelector('#text-share-card img');
566
- if (img) {
567
- const a = document.createElement('a');
568
- a.href = img.src; a.download = 'ai-detector-result.png';
569
- a.click();
570
- }
571
- }""",
572
- )
573
 
574
  gr.HTML('<div class="examples-heading">Try an example</div>')
575
- gr.Examples(
576
- examples=[[HUMAN_EXAMPLE], [AI_EXAMPLE]],
577
- inputs=text_input,
578
- label="",
579
- )
580
 
 
581
  with gr.TabItem("URL", id="url-tab"):
582
  with gr.Group(elem_classes="input-card"):
583
- url_input = gr.Textbox(
584
- lines=1,
585
- placeholder="https://example.com/article",
586
- label="Web Page URL",
587
- show_label=True,
588
- )
589
  url_btn = gr.Button("Fetch & Analyze", variant="primary", elem_classes="detect-btn")
590
 
591
  with gr.Group(elem_classes="result-card"):
592
  url_output = gr.Label(num_top_classes=2, label="Result")
 
 
 
 
 
 
 
593
  share_link = gr.Textbox(visible=False, elem_id="share-url")
594
  with gr.Row(visible=False) as share_row:
595
  copy_link_btn = gr.Button("Copy Link", size="sm", elem_classes="detect-btn")
596
  copy_img_btn = gr.Button("Copy Image", size="sm", elem_classes="detect-btn")
597
  dl_img_btn = gr.Button("Download Image", size="sm", elem_classes="detect-btn")
598
- share_card = gr.Image(
599
- label="Result card",
600
- visible=False,
601
- type="pil",
602
- elem_id="share-card",
603
- )
604
 
605
  with gr.Group(elem_classes="input-card"):
606
- url_preview = gr.Textbox(
607
- label="Extracted Text",
608
- lines=5,
609
- interactive=False,
610
- elem_classes="preview-box",
611
- )
612
 
613
- def run_url_detection(url):
614
- result, preview, link, card = detect_url(url)
615
- has_result = bool(link)
616
  return (
617
- result,
618
- gr.update(value=link, visible=has_result),
619
- gr.update(visible=has_result),
620
- gr.update(value=card, visible=has_result),
621
- preview,
622
  )
623
 
624
  url_btn.click(
625
- fn=run_url_detection,
626
- inputs=url_input,
627
- outputs=[url_output, share_link, share_row, share_card, url_preview],
628
  api_name="detect_url",
629
  )
630
 
631
- # Copy link to clipboard via JS
632
- copy_link_btn.click(
633
- fn=None,
634
- inputs=share_link,
635
- outputs=None,
636
- js="(url) => { navigator.clipboard.writeText(url); }",
637
- )
638
-
639
- # Copy image to clipboard via JS
640
- copy_img_btn.click(
641
- fn=None,
642
- inputs=None,
643
- outputs=None,
644
- js="""() => {
645
- const img = document.querySelector('#share-card img');
646
- if (img) {
647
- const canvas = document.createElement('canvas');
648
- canvas.width = img.naturalWidth;
649
- canvas.height = img.naturalHeight;
650
- canvas.getContext('2d').drawImage(img, 0, 0);
651
- canvas.toBlob(blob => {
652
- navigator.clipboard.write([new ClipboardItem({'image/png': blob})]);
653
- }, 'image/png');
654
- }
655
- }""",
656
- )
657
-
658
- dl_img_btn.click(
659
- fn=None, inputs=None, outputs=None,
660
- js="""() => {
661
- const img = document.querySelector('#share-card img');
662
- if (img) {
663
- const a = document.createElement('a');
664
- a.href = img.src; a.download = 'ai-detector-result.png';
665
- a.click();
666
- }
667
- }""",
668
- )
669
 
670
  gr.HTML('<div class="examples-heading">Try an example</div>')
671
  gr.Examples(
@@ -673,11 +558,9 @@ with gr.Blocks(css=CSS, title="AI Text Detector", theme=gr.themes.Base()) as dem
673
  ["https://en.wikipedia.org/wiki/Constitution_of_the_United_States"],
674
  ["https://garryslist.org/posts/richmond-just-voted-to-reinstate-their-flock-cameras-after-crime-spiked"],
675
  ],
676
- inputs=url_input,
677
- label="",
678
  )
679
 
680
-
681
  gr.HTML("""
682
  <div class="info-strip">
683
  <p>
@@ -690,44 +573,69 @@ with gr.Blocks(css=CSS, title="AI Text Detector", theme=gr.themes.Base()) as dem
690
  </div>
691
  """)
692
 
 
693
  def _on_load(request: gr.Request):
694
- text_param = request.query_params.get("text", "")
695
  url_param = request.query_params.get("url", "")
696
- # Default: no changes
697
- no_change = (
698
- gr.update(), "", {}, gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
699
- "", {}, gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), "",
700
- )
701
- if text_param:
702
- result, link, card = detect_text_full(text_param)
703
- has = bool(link)
704
- return (
705
- gr.update(selected="text-tab"),
706
- text_param, result,
707
- gr.update(value=link, visible=has), gr.update(visible=has), gr.update(value=card, visible=has),
708
- "", {}, gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), "",
709
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
  if url_param:
711
- result, preview, link, card = detect_url(url_param)
712
  has = bool(link)
713
  return (
714
  gr.update(selected="url-tab"),
715
- "", {}, gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
716
- url_param, result,
717
- gr.update(value=link, visible=has), gr.update(visible=has), gr.update(value=card, visible=has),
718
- preview,
 
719
  )
720
- return no_change
 
721
 
722
  demo.load(
723
- fn=_on_load,
724
- inputs=None,
725
  outputs=[
726
  tabs,
727
- # text tab outputs
728
- text_input, text_output, text_share_link, text_share_row, text_share_card,
729
- # url tab outputs
730
- url_input, url_output, share_link, share_row, share_card, url_preview,
 
 
731
  ],
732
  )
733
 
 
1
  """HuggingFace Space for AI text detection using adaptive-classifier."""
2
 
3
+ import json
4
  import re
5
  import urllib.parse
6
  import urllib.request
7
+ from datetime import datetime
8
  from html.parser import HTMLParser
9
+ from pathlib import Path
10
+ from uuid import uuid4
11
 
12
  import gradio as gr
13
  from PIL import Image, ImageDraw, ImageFont
14
  from adaptive_classifier import AdaptiveClassifier
15
+ from huggingface_hub import CommitScheduler
16
 
17
+ # ---------------------------------------------------------------------------
18
+ # Model
19
+ # ---------------------------------------------------------------------------
20
  print("Loading model...")
21
  classifier = AdaptiveClassifier.from_pretrained(
22
  "adaptive-classifier/ai-detector", use_onnx=False
23
  )
24
  print("Model loaded!")
25
 
26
+ # ---------------------------------------------------------------------------
27
+ # Persistent dataset via CommitScheduler
28
+ # ---------------------------------------------------------------------------
29
+ DATA_DIR = Path("prediction_data")
30
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
31
+ DATA_FILE = DATA_DIR / f"predictions-{uuid4()}.jsonl"
32
+
33
+ scheduler = CommitScheduler(
34
+ repo_id="adaptive-classifier/ai-detector-data",
35
+ repo_type="dataset",
36
+ folder_path=DATA_DIR,
37
+ path_in_repo="data",
38
+ private=True,
39
+ )
40
+
41
+ # In-memory index for lookups by ID (populated from current session)
42
+ _predictions = {}
43
+
44
+
45
+ def save_prediction(pred_id: str, text: str, url: str, label: str, confidence: float):
46
+ """Save a prediction to the dataset."""
47
+ record = {
48
+ "id": pred_id,
49
+ "text": text,
50
+ "url": url,
51
+ "prediction": label,
52
+ "confidence": confidence,
53
+ "feedback": None,
54
+ "timestamp": datetime.now().isoformat(),
55
+ }
56
+ _predictions[pred_id] = record
57
+ with scheduler.lock:
58
+ with DATA_FILE.open("a") as f:
59
+ json.dump(record, f)
60
+ f.write("\n")
61
+
62
+
63
+ def save_feedback(pred_id: str, feedback: str):
64
+ """Save user feedback for a prediction."""
65
+ record = {
66
+ "id": pred_id,
67
+ "feedback": feedback,
68
+ "timestamp": datetime.now().isoformat(),
69
+ }
70
+ with scheduler.lock:
71
+ with DATA_FILE.open("a") as f:
72
+ json.dump(record, f)
73
+ f.write("\n")
74
+ if pred_id in _predictions:
75
+ _predictions[pred_id]["feedback"] = feedback
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Constants
80
+ # ---------------------------------------------------------------------------
81
+ SPACE_URL = "https://adaptive-classifier-ai-detector.hf.space"
82
+
83
  HEADERS = {
84
  "User-Agent": (
85
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
 
110
  --glow-blue: rgba(59, 130, 246, 0.15);
111
  }
112
 
 
113
  .gradio-container {
114
  background: var(--bg-deep) !important;
115
  font-family: 'Outfit', sans-serif !important;
116
  max-width: 820px !important;
117
  margin: 0 auto !important;
118
  }
119
+ .main, .contain { background: transparent !important; }
 
 
 
 
120
  footer { display: none !important; }
121
 
122
+ .header-block { text-align: center; padding: 2rem 1rem 1rem; }
 
 
 
 
 
123
  .header-block h1 {
124
  font-family: 'DM Mono', monospace !important;
125
+ font-size: 1.6rem !important; font-weight: 500 !important;
126
+ color: var(--text-primary) !important; letter-spacing: 0.04em;
 
 
127
  margin-bottom: 0.4rem !important;
128
  }
 
129
  .header-block p {
130
+ font-size: 0.85rem !important; color: var(--text-secondary) !important;
131
+ line-height: 1.5 !important; max-width: 560px; margin: 0 auto !important;
 
 
 
132
  }
 
133
  .header-block a {
134
+ color: var(--accent-cyan) !important; text-decoration: none !important;
 
135
  border-bottom: 1px solid rgba(6, 182, 212, 0.3);
136
  }
137
+ .header-block a:hover { border-bottom-color: var(--accent-cyan); }
138
 
139
+ .tabs { background: transparent !important; border: none !important; }
 
 
 
 
 
 
 
 
 
140
  .tab-nav {
141
+ background: transparent !important; border: none !important;
142
+ justify-content: center !important; gap: 0.25rem !important;
 
 
143
  padding: 0.5rem 0 !important;
144
  }
 
145
  .tab-nav button {
146
  font-family: 'DM Mono', monospace !important;
147
+ font-size: 0.8rem !important; font-weight: 400 !important;
148
+ letter-spacing: 0.06em; text-transform: uppercase;
149
+ color: var(--text-muted) !important; background: transparent !important;
 
 
 
150
  border: 1px solid var(--border-subtle) !important;
151
+ border-radius: 6px !important; padding: 0.5rem 1.5rem !important;
 
152
  transition: all 0.2s ease !important;
153
  }
154
+ .tab-nav button:hover { color: var(--text-secondary) !important; border-color: var(--text-muted) !important; }
 
 
 
 
 
155
  .tab-nav button.selected {
156
  color: var(--accent-cyan) !important;
157
  background: rgba(6, 182, 212, 0.08) !important;
158
  border-color: var(--accent-cyan) !important;
159
  }
160
+ .tabitem { background: transparent !important; border: none !important; padding: 0 !important; min-height: 520px !important; }
161
 
 
 
 
 
 
 
 
 
162
  .input-card {
163
+ background: var(--bg-card) !important; border: 1px solid var(--border-subtle) !important;
164
+ border-radius: 10px !important; padding: 1.25rem !important; margin-top: 0.75rem !important;
 
 
 
165
  }
166
 
 
167
  textarea, input[type="text"] {
168
+ font-family: 'DM Mono', monospace !important; font-size: 0.85rem !important;
169
+ line-height: 1.65 !important; color: var(--text-primary) !important;
170
+ background: var(--bg-input) !important; border: 1px solid var(--border-subtle) !important;
171
+ border-radius: 8px !important; padding: 0.85rem 1rem !important;
 
 
 
 
172
  transition: border-color 0.2s ease !important;
173
  }
 
174
  textarea:focus, input[type="text"]:focus {
175
  border-color: var(--accent-blue) !important;
176
+ box-shadow: 0 0 0 3px var(--glow-blue) !important; outline: none !important;
 
177
  }
 
178
  label span {
179
+ font-family: 'DM Mono', monospace !important; font-size: 0.7rem !important;
180
+ text-transform: uppercase !important; letter-spacing: 0.08em !important;
 
 
181
  color: var(--text-muted) !important;
182
  }
183
 
 
184
  .detect-btn {
185
+ font-family: 'DM Mono', monospace !important; font-size: 0.8rem !important;
186
+ font-weight: 500 !important; letter-spacing: 0.06em !important;
 
 
187
  text-transform: uppercase !important;
188
  background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan)) !important;
189
+ color: #fff !important; border: none !important; border-radius: 8px !important;
190
+ padding: 0.7rem 2rem !important; cursor: pointer !important;
 
 
 
191
  transition: all 0.25s ease !important;
192
  box-shadow: 0 2px 12px rgba(59, 130, 246, 0.25) !important;
193
  }
194
+ .detect-btn:hover { box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4) !important; transform: translateY(-1px) !important; }
195
 
 
 
 
 
 
 
196
  .result-card {
197
+ background: var(--bg-card) !important; border: 1px solid var(--border-subtle) !important;
198
+ border-radius: 10px !important; padding: 1.25rem !important; margin-top: 0.5rem !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  }
200
+ .result-card .output-class { font-family: 'DM Mono', monospace !important; }
201
+ .output-label { background: transparent !important; }
202
+ .output-label .label-name { font-family: 'DM Mono', monospace !important; font-size: 1.1rem !important; }
203
 
 
204
  .examples-heading {
205
+ font-family: 'DM Mono', monospace !important; font-size: 0.7rem !important;
206
+ text-transform: uppercase !important; letter-spacing: 0.08em !important;
207
+ color: var(--text-muted) !important; margin-top: 1.25rem !important; margin-bottom: 0.5rem !important;
 
 
 
 
208
  }
209
+ .gallery { gap: 0.5rem !important; }
 
 
 
 
 
210
  .gallery .gallery-item {
211
+ background: var(--bg-input) !important; border: 1px solid var(--border-subtle) !important;
212
+ border-radius: 8px !important; padding: 0.75rem !important; transition: border-color 0.2s ease !important;
 
 
 
213
  }
214
+ .gallery .gallery-item:hover { border-color: var(--text-muted) !important; }
215
+ .preview-box textarea { color: var(--text-secondary) !important; font-size: 0.78rem !important; opacity: 0.85; }
216
+ .gr-group, .gr-block, .gr-box, .gr-panel { background: transparent !important; border: none !important; }
217
+ .gr-padded { padding: 0 !important; }
218
 
219
+ .info-strip { text-align: center; padding: 1.25rem 1rem; }
220
+ .info-strip p { font-family: 'DM Mono', monospace !important; font-size: 0.68rem !important; color: var(--text-muted) !important; letter-spacing: 0.03em; }
221
+ .info-strip a { color: var(--text-secondary) !important; text-decoration: none !important; border-bottom: 1px dotted var(--text-muted); }
 
 
 
 
 
 
 
222
 
223
+ #share-card .image-toolbar, #text-share-card .image-toolbar,
224
+ #share-card .icon-buttons, #text-share-card .icon-buttons,
225
+ #share-card button[aria-label], #text-share-card button[aria-label] { display: none !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
227
+ .feedback-btn {
228
+ font-family: 'DM Mono', monospace !important; font-size: 0.75rem !important;
229
+ border-radius: 6px !important; padding: 0.4rem 1rem !important;
230
+ cursor: pointer !important; transition: all 0.2s ease !important;
231
  }
232
+ .feedback-msg {
233
+ font-family: 'DM Mono', monospace !important; font-size: 0.75rem !important;
234
+ color: var(--accent-cyan) !important; padding: 0.4rem 0 !important;
 
 
 
 
 
 
235
  }
236
  """
237
 
 
260
  )
261
 
262
 
263
+ # ---------------------------------------------------------------------------
264
+ # HTML extraction
265
+ # ---------------------------------------------------------------------------
266
  class _TextExtractor(HTMLParser):
 
 
267
  def __init__(self):
268
  super().__init__()
269
+ self._parts, self._skip = [], False
 
270
  self._skip_tags = {"script", "style", "nav", "header", "footer", "noscript"}
271
 
272
  def handle_starttag(self, tag, attrs):
 
284
  self._parts.append(data)
285
 
286
  def get_text(self):
287
+ return re.sub(r"\s+", " ", " ".join(self._parts)).strip()
 
 
288
 
289
 
290
  def fetch_url(url: str) -> str:
 
291
  if not url or not url.strip():
292
  return ""
293
  url = url.strip()
 
301
  return parser.get_text()
302
 
303
 
304
+ # ---------------------------------------------------------------------------
305
+ # Result card image
306
+ # ---------------------------------------------------------------------------
307
+ def make_result_card(source: str, label: str, confidence: float) -> Image.Image:
308
+ W, H = 800, 418
309
+ bg, card_bg = "#0a0e17", "#1a2234"
310
+ human_color, ai_color = "#10b981", "#f59e0b"
311
+ text_color, muted_color = "#e2e8f0", "#8892a6"
312
+
313
+ img = Image.new("RGB", (W, H), bg)
314
+ draw = ImageDraw.Draw(img)
315
+ try:
316
+ fl = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
317
+ fm = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
318
+ fs = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
319
+ fmono = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 16)
320
+ except OSError:
321
+ fl = fm = fs = fmono = ImageFont.load_default()
322
+
323
+ draw.rounded_rectangle([30, 30, W - 30, H - 30], radius=16, fill=card_bg)
324
+ draw.text((60, 55), "AI Text Detector", fill=text_color, font=fl)
325
+
326
+ is_ai = label.lower() == "ai"
327
+ rc = ai_color if is_ai else human_color
328
+ draw.text((60, 120), "Result:", fill=muted_color, font=fm)
329
+ draw.text((60, 155), "AI-Generated" if is_ai else "Human-Written", fill=rc, font=fl)
330
+ draw.text((60, 200), f"Confidence: {confidence * 100:.1f}%", fill=text_color, font=fm)
331
+
332
+ bx, by, bw, bh = 60, 240, W - 120, 24
333
+ draw.rounded_rectangle([bx, by, bx + bw, by + bh], radius=12, fill="#0f1729")
334
+ fw = int(bw * confidence)
335
+ if fw > 0:
336
+ draw.rounded_rectangle([bx, by, bx + fw, by + bh], radius=12, fill=rc)
337
+
338
+ disp = source[:80] + "..." if len(source) > 80 else source
339
+ is_url = source.startswith("http") or source.startswith("www")
340
+ draw.text((60, 285), "URL analyzed:" if is_url else "Text analyzed:", fill=muted_color, font=fs)
341
+ draw.text((60, 308), disp, fill=text_color, font=fmono)
342
+ draw.text((60, H - 60), "adaptive-classifier-ai-detector.hf.space", fill=muted_color, font=fs)
343
+ return img
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # Detection logic
348
+ # ---------------------------------------------------------------------------
349
  def _error_label(msg: str) -> dict:
 
350
  return {msg: 1.0}
351
 
352
 
353
+ def _classify(text: str) -> dict:
 
354
  if not text or len(text.strip().split()) < 10:
355
  return _error_label("Please enter at least a few sentences (~50 words)")
356
  predictions = classifier.predict(text, k=2)
357
  return {label: round(score, 4) for label, score in predictions}
358
 
359
 
360
+ def detect_text_full(text: str):
361
+ """Returns (result, share_link, card, pred_id)"""
362
+ result = _classify(text)
363
+ if any(k.startswith("Please") for k in result):
364
+ return result, "", None, ""
 
 
 
 
365
  top_label = max(result, key=result.get)
366
+ pred_id = uuid4().hex[:12]
367
+ save_prediction(pred_id, text, "", top_label, result[top_label])
368
+ share_link = f"{SPACE_URL}/?id={pred_id}"
369
  preview = text.strip()[:80] + ("..." if len(text.strip()) > 80 else "")
370
  card = make_result_card(preview, top_label, result[top_label])
371
+ return result, share_link, card, pred_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
 
373
 
374
+ def detect_url_full(url: str):
375
+ """Returns (result, preview_text, share_link, card, pred_id)"""
376
  if not url or not url.strip():
377
+ return _error_label("Please enter a URL"), "", "", None, ""
378
  try:
379
  text = fetch_url(url)
380
  except Exception as e:
381
+ return _error_label(f"Could not fetch URL: {e}"), "", "", None, ""
 
382
  js_hints = ["javascript is not available", "enable javascript", "javascript is disabled",
383
  "please enable js", "requires javascript", "noscript",
384
  "if you are not redirected", "please click here"]
385
  text_lower = text.lower()
386
+ if any(h in text_lower for h in js_hints):
387
+ return _error_label("This site requires JavaScript (e.g. Twitter/X). Paste the text directly instead."), "", "", None, ""
388
+ if len(text.split()) < 10:
389
+ return _error_label("Not enough readable text found at that URL"), text[:500], "", None, ""
390
  words = text.split()
391
  if len(words) > 2000:
392
  text = " ".join(words[:2000])
393
+ result = _classify(text)
394
+ if any(k.startswith("Please") or k.startswith("Not enough") for k in result):
395
+ return result, text[:500], "", None, ""
 
396
  top_label = max(result, key=result.get)
397
+ pred_id = uuid4().hex[:12]
398
+ save_prediction(pred_id, text, url.strip(), top_label, result[top_label])
399
+ preview = text[:1500] + ("..." if len(text) > 1500 else "")
400
+ share_link = f"{SPACE_URL}/?id={pred_id}"
401
+ card = make_result_card(url.strip(), top_label, result[top_label])
402
+ return result, preview, share_link, card, pred_id
403
 
404
 
405
+ # ---------------------------------------------------------------------------
406
+ # UI
407
+ # ---------------------------------------------------------------------------
408
  with gr.Blocks(css=CSS, title="AI Text Detector", theme=gr.themes.Base()) as demo:
409
 
410
  gr.HTML("""
 
420
  """)
421
 
422
  with gr.Tabs() as tabs:
423
+ # ---- TEXT TAB ----
424
  with gr.TabItem("Text", id="text-tab"):
425
  with gr.Group(elem_classes="input-card"):
426
+ text_input = gr.Textbox(lines=7, placeholder="Paste text here to analyze...", label="Input Text", show_label=True)
 
 
 
 
 
427
  text_btn = gr.Button("Analyze", variant="primary", elem_classes="detect-btn")
428
 
429
  with gr.Group(elem_classes="result-card"):
430
  text_output = gr.Label(num_top_classes=2, label="Result")
431
+ text_pred_id = gr.Textbox(visible=False, elem_id="text-pred-id")
432
+ # Feedback
433
+ with gr.Row(visible=False) as text_fb_row:
434
+ text_fb_up = gr.Button("Correct", size="sm", elem_classes="feedback-btn")
435
+ text_fb_down = gr.Button("Incorrect", size="sm", elem_classes="feedback-btn")
436
+ text_fb_msg = gr.HTML(visible=False, elem_classes="feedback-msg")
437
+ # Share
438
  text_share_link = gr.Textbox(visible=False, elem_id="text-share-url")
439
  with gr.Row(visible=False) as text_share_row:
440
  text_copy_link_btn = gr.Button("Copy Link", size="sm", elem_classes="detect-btn")
441
  text_copy_img_btn = gr.Button("Copy Image", size="sm", elem_classes="detect-btn")
442
  text_dl_img_btn = gr.Button("Download Image", size="sm", elem_classes="detect-btn")
443
+ text_share_card = gr.Image(label="Result card", visible=False, type="pil", elem_id="text-share-card")
 
 
 
 
 
444
 
445
+ def run_text(text):
446
+ result, link, card, pid = detect_text_full(text)
447
+ has = bool(link)
448
  return (
449
+ result, gr.update(value=pid, visible=False),
450
+ gr.update(visible=has), gr.update(visible=False),
451
+ gr.update(value=link, visible=has), gr.update(visible=has),
452
+ gr.update(value=card, visible=has),
453
  )
454
 
455
  text_btn.click(
456
+ fn=run_text, inputs=text_input,
457
+ outputs=[text_output, text_pred_id, text_fb_row, text_fb_msg, text_share_link, text_share_row, text_share_card],
 
458
  api_name="detect",
459
  )
460
 
461
+ def text_fb_positive(pid):
462
+ if pid:
463
+ save_feedback(pid, "correct")
464
+ return gr.update(visible=False), gr.update(value='<span class="feedback-msg">Thanks for your feedback!</span>', visible=True)
465
+
466
+ def text_fb_negative(pid):
467
+ if pid:
468
+ save_feedback(pid, "incorrect")
469
+ return gr.update(visible=False), gr.update(value='<span class="feedback-msg">Thanks for your feedback!</span>', visible=True)
470
+
471
+ text_fb_up.click(fn=text_fb_positive, inputs=text_pred_id, outputs=[text_fb_row, text_fb_msg])
472
+ text_fb_down.click(fn=text_fb_negative, inputs=text_pred_id, outputs=[text_fb_row, text_fb_msg])
473
+
474
+ text_copy_link_btn.click(fn=None, inputs=text_share_link, js="(u) => { navigator.clipboard.writeText(u); }")
475
+ text_copy_img_btn.click(fn=None, js="""() => {
476
+ const img = document.querySelector('#text-share-card img');
477
+ if (img) { const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight;
478
+ c.getContext('2d').drawImage(img, 0, 0);
479
+ c.toBlob(b => navigator.clipboard.write([new ClipboardItem({'image/png': b})]), 'image/png'); }
480
+ }""")
481
+ text_dl_img_btn.click(fn=None, js="""() => {
482
+ const img = document.querySelector('#text-share-card img');
483
+ if (img) { const a = document.createElement('a'); a.href = img.src; a.download = 'ai-detector-result.png'; a.click(); }
484
+ }""")
 
 
 
485
 
486
  gr.HTML('<div class="examples-heading">Try an example</div>')
487
+ gr.Examples(examples=[[HUMAN_EXAMPLE], [AI_EXAMPLE]], inputs=text_input, label="")
 
 
 
 
488
 
489
+ # ---- URL TAB ----
490
  with gr.TabItem("URL", id="url-tab"):
491
  with gr.Group(elem_classes="input-card"):
492
+ url_input = gr.Textbox(lines=1, placeholder="https://example.com/article", label="Web Page URL", show_label=True)
 
 
 
 
 
493
  url_btn = gr.Button("Fetch & Analyze", variant="primary", elem_classes="detect-btn")
494
 
495
  with gr.Group(elem_classes="result-card"):
496
  url_output = gr.Label(num_top_classes=2, label="Result")
497
+ url_pred_id = gr.Textbox(visible=False, elem_id="url-pred-id")
498
+ # Feedback
499
+ with gr.Row(visible=False) as url_fb_row:
500
+ url_fb_up = gr.Button("Correct", size="sm", elem_classes="feedback-btn")
501
+ url_fb_down = gr.Button("Incorrect", size="sm", elem_classes="feedback-btn")
502
+ url_fb_msg = gr.HTML(visible=False, elem_classes="feedback-msg")
503
+ # Share
504
  share_link = gr.Textbox(visible=False, elem_id="share-url")
505
  with gr.Row(visible=False) as share_row:
506
  copy_link_btn = gr.Button("Copy Link", size="sm", elem_classes="detect-btn")
507
  copy_img_btn = gr.Button("Copy Image", size="sm", elem_classes="detect-btn")
508
  dl_img_btn = gr.Button("Download Image", size="sm", elem_classes="detect-btn")
509
+ share_card = gr.Image(label="Result card", visible=False, type="pil", elem_id="share-card")
 
 
 
 
 
510
 
511
  with gr.Group(elem_classes="input-card"):
512
+ url_preview = gr.Textbox(label="Extracted Text", lines=5, interactive=False, elem_classes="preview-box")
 
 
 
 
 
513
 
514
+ def run_url(url):
515
+ result, preview, link, card, pid = detect_url_full(url)
516
+ has = bool(link)
517
  return (
518
+ result, gr.update(value=pid, visible=False),
519
+ gr.update(visible=has), gr.update(visible=False),
520
+ gr.update(value=link, visible=has), gr.update(visible=has),
521
+ gr.update(value=card, visible=has), preview,
 
522
  )
523
 
524
  url_btn.click(
525
+ fn=run_url, inputs=url_input,
526
+ outputs=[url_output, url_pred_id, url_fb_row, url_fb_msg, share_link, share_row, share_card, url_preview],
 
527
  api_name="detect_url",
528
  )
529
 
530
+ def url_fb_positive(pid):
531
+ if pid:
532
+ save_feedback(pid, "correct")
533
+ return gr.update(visible=False), gr.update(value='<span class="feedback-msg">Thanks for your feedback!</span>', visible=True)
534
+
535
+ def url_fb_negative(pid):
536
+ if pid:
537
+ save_feedback(pid, "incorrect")
538
+ return gr.update(visible=False), gr.update(value='<span class="feedback-msg">Thanks for your feedback!</span>', visible=True)
539
+
540
+ url_fb_up.click(fn=url_fb_positive, inputs=url_pred_id, outputs=[url_fb_row, url_fb_msg])
541
+ url_fb_down.click(fn=url_fb_negative, inputs=url_pred_id, outputs=[url_fb_row, url_fb_msg])
542
+
543
+ copy_link_btn.click(fn=None, inputs=share_link, js="(u) => { navigator.clipboard.writeText(u); }")
544
+ copy_img_btn.click(fn=None, js="""() => {
545
+ const img = document.querySelector('#share-card img');
546
+ if (img) { const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight;
547
+ c.getContext('2d').drawImage(img, 0, 0);
548
+ c.toBlob(b => navigator.clipboard.write([new ClipboardItem({'image/png': b})]), 'image/png'); }
549
+ }""")
550
+ dl_img_btn.click(fn=None, js="""() => {
551
+ const img = document.querySelector('#share-card img');
552
+ if (img) { const a = document.createElement('a'); a.href = img.src; a.download = 'ai-detector-result.png'; a.click(); }
553
+ }""")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
 
555
  gr.HTML('<div class="examples-heading">Try an example</div>')
556
  gr.Examples(
 
558
  ["https://en.wikipedia.org/wiki/Constitution_of_the_United_States"],
559
  ["https://garryslist.org/posts/richmond-just-voted-to-reinstate-their-flock-cameras-after-crime-spiked"],
560
  ],
561
+ inputs=url_input, label="",
 
562
  )
563
 
 
564
  gr.HTML("""
565
  <div class="info-strip">
566
  <p>
 
573
  </div>
574
  """)
575
 
576
+ # Handle ?id= and ?url= share links on page load
577
  def _on_load(request: gr.Request):
578
+ pred_id = request.query_params.get("id", "")
579
  url_param = request.query_params.get("url", "")
580
+
581
+ # Defaults for all outputs (13 total)
582
+ d = gr.update()
583
+ n = ""
584
+ hide = gr.update(visible=False)
585
+ empty = {}
586
+ defaults = (d, n, empty, hide, hide, hide, n, hide, hide, n, empty, hide, hide, hide, hide, hide, n)
587
+
588
+ if pred_id and pred_id in _predictions:
589
+ rec = _predictions[pred_id]
590
+ result = {rec["prediction"]: rec["confidence"]}
591
+ other = "human" if rec["prediction"] == "ai" else "ai"
592
+ result[other] = round(1.0 - rec["confidence"], 4)
593
+ source = rec["url"] or rec["text"][:80] + "..."
594
+ card = make_result_card(source, rec["prediction"], rec["confidence"])
595
+ link = f"{SPACE_URL}/?id={pred_id}"
596
+ if rec["url"]:
597
+ return (
598
+ gr.update(selected="url-tab"),
599
+ n, empty, hide, hide, hide, n, hide, hide,
600
+ rec["url"], result, gr.update(value=pred_id, visible=False),
601
+ gr.update(visible=True), hide,
602
+ gr.update(value=link, visible=True), gr.update(visible=True),
603
+ gr.update(value=card, visible=True), rec["text"][:1500],
604
+ )
605
+ else:
606
+ return (
607
+ gr.update(selected="text-tab"),
608
+ rec["text"], result, gr.update(value=pred_id, visible=False),
609
+ gr.update(visible=True), hide,
610
+ gr.update(value=link, visible=True), gr.update(visible=True),
611
+ gr.update(value=card, visible=True),
612
+ n, empty, hide, hide, hide, hide, hide, hide, n,
613
+ )
614
+
615
  if url_param:
616
+ result, preview, link, card, pid = detect_url_full(url_param)
617
  has = bool(link)
618
  return (
619
  gr.update(selected="url-tab"),
620
+ n, empty, hide, hide, hide, n, hide, hide,
621
+ url_param, result, gr.update(value=pid, visible=False),
622
+ gr.update(visible=has), hide,
623
+ gr.update(value=link, visible=has), gr.update(visible=has),
624
+ gr.update(value=card, visible=has), preview,
625
  )
626
+
627
+ return defaults
628
 
629
  demo.load(
630
+ fn=_on_load, inputs=None,
 
631
  outputs=[
632
  tabs,
633
+ # text tab
634
+ text_input, text_output, text_pred_id, text_fb_row, text_fb_msg,
635
+ text_share_link, text_share_row, text_share_card,
636
+ # url tab
637
+ url_input, url_output, url_pred_id, url_fb_row, url_fb_msg,
638
+ share_link, share_row, share_card, url_preview,
639
  ],
640
  )
641