diff --git a/index.html b/index.html index 89b20fd..935e63b 100644 --- a/index.html +++ b/index.html @@ -21,6 +21,14 @@ + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..6cfc978 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "UnRobot", + "short_name": "UnRobot", + "description": "Détection et nettoyage de texte IA — 100% local, hors ligne", + "start_url": "/", + "display": "standalone", + "background_color": "#09090b", + "theme_color": "#6366f1", + "orientation": "any", + "lang": "fr", + "categories": ["productivity", "utilities"], + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} \ No newline at end of file diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..bd8953e --- /dev/null +++ b/public/sw.js @@ -0,0 +1,134 @@ +/// + +const CACHE_NAME = "unrobot-v1"; + +// App shell — ressources à mettre en cache au premier chargement +const APP_SHELL = [ + "/", + "/index.html", + "/manifest.json", + "/icons/icon-192.png", + "/icons/icon-512.png", +]; + +// Ressources externes préconnectées (fonts) +const FONT_ORIGIN = "https://fonts.googleapis.com"; +const FONT_STATIC_ORIGIN = "https://fonts.gstatic.com"; + +// Extensions à mettre en cache avec stratégie cache-first +const CACHE_FIRST_EXTENSIONS = [ + ".js", + ".css", + ".png", + ".svg", + ".woff", + ".woff2", + ".wasm", +]; + +// Installation : pré-cache l'app shell +self.addEventListener("install", (event: ExtendableEvent) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(APP_SHELL)) + .then(() => (self as unknown as ServiceWorkerGlobalScope).skipWaiting()) + ); +}); + +// Activation : nettoie les anciens caches +self.addEventListener("activate", (event: ExtendableEvent) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)) + ) + ) + .then(() => (self as unknown as ServiceWorkerGlobalScope).clients.claim()) + ); +}); + +// Fetch : stratégie hybride +self.addEventListener("fetch", (event: FetchEvent) => { + const url = new URL(event.request.url); + + // Ne pas intercepter les requêtes non-GET + if (event.request.method !== "GET") return; + + // Ne pas intercepter les requêtes d'API ou Chrome extensions + if (url.protocol === "chrome-extension:") return; + + // Fonts : cache-first avec cache séparé + if (url.origin === FONT_ORIGIN || url.origin === FONT_STATIC_ORIGIN) { + event.respondWith(cacheFirst(event.request, "unrobot-fonts")); + return; + } + + // Fichiers statiques (.js, .css, images, wasm) : cache-first + const isStaticAsset = CACHE_FIRST_EXTENSIONS.some((ext) => + url.pathname.endsWith(ext) + ); + if (isStaticAsset && url.origin === self.location.origin) { + event.respondWith(cacheFirst(event.request, CACHE_NAME)); + return; + } + + // HTML (navigation) : network-first avec fallback cache + if (event.request.mode === "navigate") { + event.respondWith(networkFirst(event.request, CACHE_NAME)); + return; + } +}); + +/** + * Cache-first : cherche en cache, sinon fetch et met en cache. + */ +async function cacheFirst( + request: Request, + cacheName: string +): Promise { + const cached = await caches.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + } + return response; + } catch { + // Hors ligne et pas en cache : retourner une réponse vide + return new Response("Hors ligne", { status: 503, statusText: "Hors ligne" }); + } +} + +/** + * Network-first : essaie le réseau, fallback sur le cache. + */ +async function networkFirst( + request: Request, + cacheName: string +): Promise { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await caches.match(request); + if (cached) return cached; + // Dernier recours : l'app shell HTML + const shell = await caches.match("/index.html"); + if (shell) return shell; + return new Response("Hors ligne", { status: 503, statusText: "Hors ligne" }); + } +} + +export {}; \ No newline at end of file diff --git a/src/components/TextCleaner/ReadabilityLab.tsx b/src/components/TextCleaner/ReadabilityLab.tsx new file mode 100644 index 0000000..69c2a5a --- /dev/null +++ b/src/components/TextCleaner/ReadabilityLab.tsx @@ -0,0 +1,229 @@ +import React, { useMemo, useState } from "react"; +import { BookOpen, ChevronDown, ChevronUp, Info } from "lucide-react"; +import { analyzeReadability, type ReadabilityResult } from "@/lib/readability"; +import { Progress } from "@/components/ui/progress"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +interface ReadabilityLabProps { + text: string; +} + +const COLOR_MAP = { + green: "text-green-500", + yellow: "text-yellow-500", + orange: "text-orange-500", + red: "text-red-500", +} as const; + +const PROGRESS_COLOR_MAP = { + green: "bg-green-500", + yellow: "bg-yellow-500", + orange: "bg-orange-500", + red: "bg-red-500", +} as const; + +interface IndexInfo { + key: keyof Pick; + label: string; + unit: string; + inverted: boolean; // vrai si un score bas = meilleure lisibilité + description: string; + range: string; +} + +const INDICES: IndexInfo[] = [ + { + key: "fleschKincaid", + label: "Flesch-Kincaid", + unit: "/100", + inverted: false, + description: + "Adapté au français (Kandel & Moles, 1958). Prend en compte la longueur des phrases et le nombre de syllabes par mot. Score élevé = texte plus lisible.", + range: "90+ : très simple · 60–90 : accessible · 30–60 : intermédiaire · < 30 : complexe", + }, + { + key: "lix", + label: "LIX", + unit: "", + inverted: true, + description: + "Läsbarhetsindex (Björnsson, 1968). Langue-agnostique. Mesure la proportion de mots longs (≥ 7 caractères). Score bas = texte plus lisible.", + range: "< 25 : très simple · 25–40 : simple · 40–55 : intermédiaire · > 55 : difficile", + }, + { + key: "gunningFog", + label: "Gunning Fog", + unit: " ans", + inverted: true, + description: + "Estimation des années d'études nécessaires. Adapté au français via les mots de 3 syllabes ou plus comme proxy de complexité. Valeur basse = texte plus accessible.", + range: "6 : école primaire · 10 : collège · 12 : lycée · 16+ : universitaire", + }, + { + key: "colemanLiau", + label: "Coleman-Liau", + unit: " ans", + inverted: true, + description: + "Opère sur les caractères plutôt que les syllabes, ce qui le rend robuste sur toutes les langues. Estimation des années d'études. Valeur basse = texte plus accessible.", + range: "6–8 : école primaire · 9–10 : collège · 11–12 : lycée · 13+ : universitaire", + }, +]; + +function indexProgress(key: IndexInfo["key"], value: number): number { + // Normaliser chaque indice en 0–100 pour la barre de progression + // (sens croissant = meilleure lisibilité, cohérent avec la barre) + switch (key) { + case "fleschKincaid": + return Math.min(100, Math.max(0, value)); + case "lix": + // LIX : 0–70+, inverser + return Math.min(100, Math.max(0, 100 - (value / 70) * 100)); + case "gunningFog": + // GF : 6–20+, inverser, normaliser + return Math.min(100, Math.max(0, 100 - ((value - 6) / 14) * 100)); + case "colemanLiau": + // CLI : 1–16+, inverser, normaliser + return Math.min(100, Math.max(0, 100 - ((value - 1) / 15) * 100)); + default: + return 50; + } +} + +function indexColor(progress: number): "green" | "yellow" | "orange" | "red" { + if (progress >= 75) return "green"; + if (progress >= 50) return "yellow"; + if (progress >= 30) return "orange"; + return "red"; +} + +export const ReadabilityLab: React.FC = ({ text }) => { + const [open, setOpen] = useState(false); + + const result = useMemo(() => analyzeReadability(text), [text]); + + if (!result) return null; + + const { globalScore, label, color, metrics } = result; + + return ( + +
+ {/* En-tête cliquable */} + + + {/* Barre de progression globale toujours visible */} +
+
+
+ + {open && ( +
+ {/* Grille des 4 indices */} +
+ {INDICES.map(({ key, label: indexLabel, unit, inverted, description, range }) => { + const raw = result[key] as number; + const progress = indexProgress(key, raw); + const iColor = indexColor(progress); + return ( +
+
+ + {indexLabel} + + + + + + + +

{description}

+

{range}

+ {inverted && ( +

+ ↓ Valeur basse = meilleur +

+ )} +
+
+
+ + {raw} + {unit} + +
+
+
+
+
+ ); + })} +
+ + {/* Métriques brutes */} +
+

Métriques du texte

+
+ {[ + { label: "Mots", value: metrics.wordCount }, + { label: "Phrases", value: metrics.sentenceCount }, + { label: "Mots/phrase", value: metrics.avgWordsPerSentence }, + { label: "Syllabes/mot", value: metrics.avgSyllablesPerWord }, + { label: "Mots longs", value: `${metrics.longWordRatio}%` }, + { label: "Chars/mot", value: metrics.avgCharsPerWord }, + ].map(({ label: mLabel, value }) => ( +
+ {value} + {mLabel} +
+ ))} +
+
+ + {/* Note épistémique */} +

+ Ces indices fournissent des estimations comparatives, pas des verdicts absolus. + Un texte technique spécialisé peut obtenir un score faible sans que cela soit un défaut + pour son audience cible. +

+
+ )} +
+ + ); +}; diff --git a/src/components/TextCleaner/TransferLearningPanel.tsx b/src/components/TextCleaner/TransferLearningPanel.tsx index 264168d..f9428d3 100755 --- a/src/components/TextCleaner/TransferLearningPanel.tsx +++ b/src/components/TextCleaner/TransferLearningPanel.tsx @@ -1,8 +1,8 @@ import React, { useState, useCallback } from "react"; import { toast } from "sonner"; import { - trainCustomModel, loadModelsFromIDB, deleteModelFromIDB, saveDatasetToIDB, - loadDatasetFromIDB, listDatasetsFromIDB, + trainCustomModel, loadModelsFromIDB, deleteModelFromIDB, saveModelToIDB, + saveDatasetToIDB, loadDatasetFromIDB, listDatasetsFromIDB, type LabeledText, type TrainingProgress, type CustomModel, DEFAULT_TRAINING_CONFIG, } from "@/lib/transfer"; import { Brain, Play, Square, Trash2, Download, Upload, Check, X, Loader2, Cpu, Save, FolderOpen } from "lucide-react"; @@ -117,7 +117,7 @@ export const TransferLearningPanel: React.FC = ({ on

Modèle personnalisé

-

Transfer Learning in-browser · TensorFlow.js

+

Transfer Learning in-browser · Pur JavaScript

{activeModel && ( diff --git a/src/components/TextCleaner/index.tsx b/src/components/TextCleaner/index.tsx index 635d403..9319c7a 100644 --- a/src/components/TextCleaner/index.tsx +++ b/src/components/TextCleaner/index.tsx @@ -16,12 +16,15 @@ import { AIAnalysis } from "./AIAnalysis"; import { ModeSelector } from "./ModeSelector"; import { WriterProfilePanel } from "./WriterProfilePanel"; import { PlagiarismPanel } from "./PlagiarismPanel"; +import { TransferLearningPanel } from "./TransferLearningPanel"; +import { ReadabilityLab } from "./ReadabilityLab"; import { EXAMPLE_TEXTS } from "@/data/exampleTexts"; import { Button } from "@/components/ui/button"; -import { FileText, FileJson, FileDown } from "lucide-react"; +import { FileText, FileJson, FileDown, ShieldCheck, Brain } from "lucide-react"; import { downloadReportJSON, downloadReportPDF } from "@/lib/report"; import { downloadBlob } from "@/lib/utils"; import type { HybridAnalysis } from "@/lib/ml/types"; +import type { CustomModel } from "@/lib/transfer"; export const TextCleaner: React.FC = () => { const { @@ -49,7 +52,7 @@ export const TextCleaner: React.FC = () => { } = useTextCleaner(); const { analyzeText } = useAIDetector(); - const { modelState, modelInfo, analyzeWithML, isMLInitializing } = useMLDetector(); + const { modelState, modelInfo, analyzeWithML, isMLInitializing, customModel, setCustomModel } = useMLDetector(); const { checkPlagiarism, addRef, removeRef, clearAllRefs, references, corpusSize, lastResult: plagiarismResult, isChecking: isCheckingPlagiarism, importFile } = usePlagiarism(); const [isCopied, setIsCopied] = useState(false); @@ -57,6 +60,7 @@ export const TextCleaner: React.FC = () => { const [analysis, setAnalysis] = useState(null); const [hybrid, setHybrid] = useState(null); const [showPlagiarism, setShowPlagiarism] = useState(false); + const [showTransferLearning, setShowTransferLearning] = useState(false); const hasText = text.trim().length > 0; @@ -194,6 +198,16 @@ export const TextCleaner: React.FC = () => { isCheckingPlagiarism={isCheckingPlagiarism} /> +
+ Outils : + + +
+
Rapport :