feat: add curriculum management admin dashboard with AI generation and draft approval workflows
This commit is contained in:
21
pb_migrations/1780700000_sources_progress.js
Normal file
21
pb_migrations/1780700000_sources_progress.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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);
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* ─── Orphaned Sources Warning ─── */}
|
||||
{orphanedSources.length > 0 && (
|
||||
<Card className="border-2 border-amber-300 bg-amber-50/50">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<AlertTriangle size={20} className="text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-800">Interrupted extraction{orphanedSources.length > 1 ? 's' : ''} detected</p>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
{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.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{orphanedSources.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-[var(--r-sm)] bg-white/60 text-sm">
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
{s.progress && (
|
||||
<span className="text-amber-600 ml-2 text-xs">
|
||||
(stopped at chunk {s.progress.current}/{s.progress.total})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleResetOrphan(s.id)}
|
||||
className="text-xs text-amber-700 hover:text-amber-900 underline"
|
||||
>
|
||||
Mark failed
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteOrphan(s.id)}
|
||||
className="p-1.5 text-red-500 hover:text-red-600 hover:bg-red-50 rounded-[var(--r-sm)] transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ─── Drag & Drop Zone ─── */}
|
||||
<Card
|
||||
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-10 cursor-pointer ${
|
||||
@@ -149,7 +248,11 @@ const UploadZone = ({ onUploadComplete }) => {
|
||||
<StatusIcon status={item.status} />
|
||||
<span className="flex-1 truncate text-fg">{item.name}</span>
|
||||
<span className="text-xs text-fg-muted shrink-0">
|
||||
{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'}
|
||||
|
||||
22
src/hooks/useBeforeUnload.js
Normal file
22
src/hooks/useBeforeUnload.js
Normal file
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user