diff --git a/pb_migrations/1780700000_sources_progress.js b/pb_migrations/1780700000_sources_progress.js new file mode 100644 index 0000000..f971385 --- /dev/null +++ b/pb_migrations/1780700000_sources_progress.js @@ -0,0 +1,21 @@ +/// +migrate((app) => { + const sources = app.findCollectionByNameOrId("sources"); + + sources.fields.add(new Field({ + "hidden": false, + "id": "json_progress", + "maxSize": 0, + "name": "progress", + "presentable": false, + "required": false, + "system": false, + "type": "json" + })); + + app.save(sources); +}, (app) => { + const sources = app.findCollectionByNameOrId("sources"); + sources.fields.removeById("json_progress"); + app.save(sources); +}); diff --git a/src/components/admin/CurriculumManager.jsx b/src/components/admin/CurriculumManager.jsx index 912b94c..b03bba4 100644 --- a/src/components/admin/CurriculumManager.jsx +++ b/src/components/admin/CurriculumManager.jsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useBeforeUnload } from '../../hooks/useBeforeUnload'; import { Calendar, Wand2, CheckCircle2, Loader, AlertTriangle, Play, Sparkles, X, Clock, Target } from 'lucide-react'; import Card from '../ui/Card'; import Button from '../ui/Button'; @@ -26,6 +27,9 @@ const CurriculumManager = () => { const [generationReason, setGenerationReason] = useState(''); + // Prevent tab close during long AI operations + useBeforeUnload(isGenerating || isEnriching); + const currentIsoWeek = (() => { const d = new Date(); d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); diff --git a/src/components/admin/UploadZone.jsx b/src/components/admin/UploadZone.jsx index b17e447..d677cb9 100644 --- a/src/components/admin/UploadZone.jsx +++ b/src/components/admin/UploadZone.jsx @@ -1,6 +1,9 @@ import { useEffect, useRef, useState } from 'react'; -import { UploadCloud, CheckCircle, AlertCircle, Loader2, X, Clock, Minus } from 'lucide-react'; +import { UploadCloud, CheckCircle, AlertCircle, Loader2, X, Clock, Minus, AlertTriangle, Trash2 } from 'lucide-react'; import { processSourceText } from '../../lib/extractionPipeline'; +import { pb } from '../../lib/pb'; +import * as db from '../../lib/db'; +import { useBeforeUnload } from '../../hooks/useBeforeUnload'; import Card from '../ui/Card'; import Button from '../ui/Button'; @@ -11,10 +14,31 @@ const UploadZone = ({ onUploadComplete }) => { const [queue, setQueue] = useState([]); const [processingId, setProcessingId] = useState(null); const [rejectedNote, setRejectedNote] = useState(null); + const [orphanedSources, setOrphanedSources] = useState([]); const fileInputRef = useRef(null); const abortRef = useRef(null); + // ── Tab-close guard ───────────────────────────────────────────────────────── + useBeforeUnload(processingId !== null); + + // ── Detect orphaned sources on mount ──────────────────────────────────────── + useEffect(() => { + db.getOrphanedSources().then(setOrphanedSources); + }, []); + + const handleResetOrphan = async (id) => { + await db.updateSourceStatus(id, 'failed', 'Interrupted — tab was closed during extraction'); + setOrphanedSources((prev) => prev.filter((s) => s.id !== id)); + if (onUploadComplete) onUploadComplete(); + }; + + const handleDeleteOrphan = async (id) => { + await db.deleteSource(id); + setOrphanedSources((prev) => prev.filter((s) => s.id !== id)); + if (onUploadComplete) onUploadComplete(); + }; + // ── Processing loop ──────────────────────────────────────────────────────── useEffect(() => { @@ -28,10 +52,38 @@ const UploadZone = ({ onUploadComplete }) => { const controller = new AbortController(); abortRef.current = controller; + // Poll for progress updates from PocketBase while processing + let progressInterval; + let lastSourceId = null; + next.file.text() - .then((text) => processSourceText(text, next.name, { signal: controller.signal })) + .then(async (text) => { + // Start processing — get source ID from the pipeline to poll progress + const resultPromise = processSourceText(text, next.name, { signal: controller.signal }); + + // Find the newly created source record to track its progress + const sources = await db.getSources(); + const sourceRec = sources.find(s => s.name === next.name && s.status === 'processing'); + if (sourceRec) { + lastSourceId = sourceRec.id; + progressInterval = setInterval(async () => { + try { + const updated = await pb.collection('sources').getOne(lastSourceId); + if (updated.progress) { + setQueue((q) => q.map((item) => + item.id === next.id + ? { ...item, progress: updated.progress } + : item + )); + } + } catch { /* source may have been deleted */ } + }, 2000); + } + + return resultPromise; + }) .then(() => { - setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'done' } : item)); + setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'done', progress: null } : item)); if (onUploadComplete) onUploadComplete(); }) .catch((err) => { @@ -45,11 +97,12 @@ const UploadZone = ({ onUploadComplete }) => { setQueue((q) => q.map((item) => item.id === next.id - ? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : errorMsg } + ? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : errorMsg, progress: null } : item )); }) .finally(() => { + if (progressInterval) clearInterval(progressInterval); abortRef.current = null; setProcessingId(null); }); @@ -62,7 +115,7 @@ const UploadZone = ({ onUploadComplete }) => { let rejected = 0; for (const file of fileList) { if (file.type === 'text/plain' || file.name.endsWith('.md')) { - accepted.push({ id: crypto.randomUUID(), file, name: file.name, status: 'pending', error: null }); + accepted.push({ id: crypto.randomUUID(), file, name: file.name, status: 'pending', error: null, progress: null }); } else { rejected++; } @@ -109,6 +162,52 @@ const UploadZone = ({ onUploadComplete }) => { return (
+ {/* ─── Orphaned Sources Warning ─── */} + {orphanedSources.length > 0 && ( + +
+ +
+

Interrupted extraction{orphanedSources.length > 1 ? 's' : ''} detected

+

+ {orphanedSources.length === 1 + ? 'A source extraction was interrupted (tab closed or browser crashed). You can delete it and re-upload the file.' + : `${orphanedSources.length} source extractions were interrupted. Delete and re-upload the files to retry.`} +

+
+
+
+ {orphanedSources.map((s) => ( +
+
+ {s.name} + {s.progress && ( + + (stopped at chunk {s.progress.current}/{s.progress.total}) + + )} +
+
+ + +
+
+ ))} +
+
+ )} + {/* ─── Drag & Drop Zone ─── */} { {item.name} - {item.status === 'processing' && 'Extracting…'} + {item.status === 'processing' && ( + item.progress + ? `Chunk ${item.progress.current + 1}/${item.progress.total}` + : 'Starting…' + )} {item.status === 'pending' && 'Pending'} {item.status === 'done' && 'Done'} {item.status === 'cancelled' && 'Cancelled'} diff --git a/src/hooks/useBeforeUnload.js b/src/hooks/useBeforeUnload.js new file mode 100644 index 0000000..7061cef --- /dev/null +++ b/src/hooks/useBeforeUnload.js @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +/** + * Prevent accidental tab/window closure while a long-running operation + * is in progress. Shows the browser's native "Leave site?" confirmation. + * + * @param {boolean} active — true while the operation is running + */ +export function useBeforeUnload(active) { + useEffect(() => { + if (!active) return; + + const handler = (e) => { + e.preventDefault(); + // Legacy browsers require returnValue to be set + e.returnValue = ''; + }; + + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [active]); +} diff --git a/src/lib/db.js b/src/lib/db.js index b3dabb2..9c0d11b 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -133,6 +133,24 @@ export async function updateSourceStatus(id, status, error = '') { return pb.collection('sources').update(id, { status, error }); } +export async function updateSourceProgress(id, progress) { + return pb.collection('sources').update(id, { progress }); +} + +/** + * Find sources stuck in 'processing' status — likely orphaned by a tab close. + * Sources older than 5 minutes in 'processing' state are considered stale. + */ +export async function getOrphanedSources() { + try { + const all = await pb.collection('sources').getFullList({ + filter: 'status="processing"', + }); + const fiveMinAgo = Date.now() - 5 * 60 * 1000; + return all.filter(s => new Date(s.updated || s.created).getTime() < fiveMinAgo); + } catch { return []; } +} + export async function deleteSource(id) { return pb.collection('sources').delete(id); } diff --git a/src/lib/extractionPipeline.js b/src/lib/extractionPipeline.js index fcc0adc..f3e93b3 100644 --- a/src/lib/extractionPipeline.js +++ b/src/lib/extractionPipeline.js @@ -128,6 +128,9 @@ export async function processSourceText(textContent, sourceName, { signal } = {} const chunks = chunkText(textContent); console.log(`[Pipeline] Split "${sourceName}" into ${chunks.length} chunks for processing.`); + // Persist initial progress so other sessions/reloads can see it + await db.updateSourceProgress(sourceId, { current: 0, total: chunks.length, message: 'Starting extraction...' }); + const existingTopics = await db.getTopics(); const knownTopics = existingTopics.map((t) => ({ id: t.id, label: t.label })); @@ -138,6 +141,14 @@ export async function processSourceText(textContent, sourceName, { signal } = {} if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError'); console.log(`[Pipeline] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`); + + // Update progress before each chunk + await db.updateSourceProgress(sourceId, { + current: i, + total: chunks.length, + message: `Extracting chunk ${i + 1} of ${chunks.length}...`, + }); + const hint = buildKnownIdsHint(knownTopics); const result = await callLLM({ task: 'extract.source', @@ -168,6 +179,7 @@ export async function processSourceText(textContent, sourceName, { signal } = {} } } + await db.updateSourceProgress(sourceId, { current: chunks.length, total: chunks.length, message: 'Merging results...' }); await mergeKnowledgeGraph(existingTopics, { topics: allExtractedTopics, relations: allExtractedRelations }); await db.updateSourceStatus(sourceId, 'completed');