refactor: remove handbook sync state and related functionality
All checks were successful
On Push to Main / test (push) Successful in 31s
On Push to Main / publish (push) Successful in 1m4s
On Push to Main / deploy-dev (push) Successful in 1m31s

This commit is contained in:
RaymondVerhoef
2026-05-22 19:45:14 +02:00
parent dc628644b6
commit ca8945ea5b
12 changed files with 159 additions and 552 deletions

View File

@@ -4,8 +4,6 @@ import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon
import * as db from '../../lib/db';
import { callLLM } from '../../lib/llm';
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
import { analyzeHandbookDelta } from '../../lib/extractionPipeline';
import { getRepoFolder, getFileContent } from '../../lib/githubService';
import Button from '../ui/Button';
import SuggestionsQueue from './SuggestionsQueue';
@@ -20,10 +18,6 @@ const KnowledgeGraph = () => {
const [analyzeError, setAnalyzeError] = useState(null);
const [newRelData, setNewRelData] = useState({ target: '', type: 'related_to' });
const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
const [syncError, setSyncError] = useState(null);
const [syncProgress, setSyncProgress] = useState(null);
const [topics, setTopics] = useState([]);
const [relations, setRelations] = useState([]);
@@ -237,87 +231,6 @@ const KnowledgeGraph = () => {
)));
};
const syncHandbook = async () => {
setIsSyncing(true);
setSyncError(null);
setSyncResult(null);
try {
const files = await getRepoFolder('respellion', 'employee-handbook', 'docs');
const syncStates = await db.getHandbookSyncStates();
const added = [];
const modified = [];
const unchanged = [];
files.forEach(file => {
const state = syncStates.find(s => s.path === file.path);
if (!state) {
added.push(file);
} else if (state.sha !== file.sha) {
modified.push(file);
} else {
unchanged.push(file);
}
});
setSyncResult({ added, modified, unchanged, failed: [] });
const filesToProcess = [...added, ...modified];
if (filesToProcess.length > 0) {
const total = filesToProcess.length;
let done = 0;
const failed = [];
setSyncProgress(`Processing 0 of ${total} files...`);
// Run files in parallel with bounded concurrency. The extractionLimiter
// inside analyzeHandbookDelta still governs the actual API request rate;
// parallelism here just hides GitHub fetch latency and overlaps with the
// limiter's spacing instead of waiting serially.
const CONCURRENCY = 4;
const queue = filesToProcess.slice();
async function worker() {
while (queue.length > 0) {
const file = queue.shift();
if (!file) return;
try {
const rawContent = await getFileContent('respellion', 'employee-handbook', file.path);
// Skip near-empty files — they only burn an LLM call to extract nothing.
if (rawContent.trim().length < 50) {
await db.updateHandbookSyncState(file.path, file.sha);
} else {
await analyzeHandbookDelta(rawContent, file.path);
await db.updateHandbookSyncState(file.path, file.sha);
}
} catch (err) {
console.error('Failed to process file:', file.path, err);
failed.push({ path: file.path, message: err?.message || String(err) });
} finally {
done++;
setSyncProgress(`Processing ${done} of ${total} files (${failed.length} failed)...`);
}
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, total) }, worker));
setSyncResult({ added, modified, unchanged, failed });
setSyncProgress(
failed.length === 0
? 'Sync Complete! Click "Analyze & Optimize Graph" above to clean up and merge.'
: `Sync finished with ${failed.length} failure(s). See console for details.`
);
reloadKb();
}
} catch (e) {
setSyncError(e.message || 'Failed to check GitHub for updates.');
} finally {
setIsSyncing(false);
setTimeout(() => setSyncProgress(null), 10000); // Clear progress after 10s
}
};
const analyzeGraph = async () => {
if (!confirm('This will send the entire graph to the AI to evaluate content, merge duplicates, and create new logical relations. This may take a moment. Proceed?')) return;
@@ -466,43 +379,6 @@ ${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`;
</p>
)}
</div>
<div>
<Button
onClick={syncHandbook}
disabled={isSyncing}
variant="outline"
className="w-full flex justify-center items-center gap-2"
>
<RefreshCw size={16} className={isSyncing ? "animate-spin" : ""} />
{isSyncing ? 'Checking for updates...' : 'Sync Employee Handbook'}
</Button>
{syncError && (
<p className="mt-2 text-xs text-red-600 flex items-start gap-1">
<AlertCircle size={14} className="shrink-0 mt-0.5" />
{syncError}
</p>
)}
{syncResult && (
<div className="mt-2 text-xs text-fg-muted space-y-1 p-2 bg-bg rounded">
<p className="font-medium text-fg">GitHub Sync Status:</p>
<p>Added files: {syncResult.added.length}</p>
<p>Modified files: {syncResult.modified.length}</p>
<p>Unchanged: {syncResult.unchanged.length}</p>
{syncResult.failed?.length > 0 && (
<p className="text-red-600">Failed: {syncResult.failed.length}</p>
)}
</div>
)}
{syncProgress && (
<div className="mt-2 text-xs text-teal p-2 bg-teal/10 border border-teal/20 rounded">
<p className="font-medium flex items-center gap-2">
{syncProgress !== 'Sync Complete!' && <RefreshCw size={12} className="animate-spin" />}
{syncProgress}
</p>
</div>
)}
</div>
</div>
{/* R42 chatbot suggestions queue */}

View File

@@ -1,5 +1,5 @@
import { useState, useRef } from 'react';
import { UploadCloud, AlertCircle, CheckCircle, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { UploadCloud, CheckCircle, AlertCircle, Loader2, X, Clock, Minus } from 'lucide-react';
import { processSourceText } from '../../lib/extractionPipeline';
import Card from '../ui/Card';
import Button from '../ui/Button';
@@ -7,122 +7,187 @@ import Button from '../ui/Button';
// ── Main Component ────────────────────────────────────────────────────────────
const UploadZone = ({ onUploadComplete }) => {
const [isDragging, setIsDragging] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [status, setStatus] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const [queue, setQueue] = useState([]);
const [processingId, setProcessingId] = useState(null);
const [rejectedNote, setRejectedNote] = useState(null);
const fileInputRef = useRef(null);
const abortRef = useRef(null);
// ── File upload (drag & drop / browse) ────────────────────────────────────
// ── Processing loop ────────────────────────────────────────────────────────
useEffect(() => {
if (processingId !== null) return;
const next = queue.find((q) => q.status === 'pending');
if (!next) return;
setProcessingId(next.id);
setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'processing' } : item));
const processFile = async (file) => {
setIsProcessing(true);
setStatus(null);
const controller = new AbortController();
abortRef.current = controller;
try {
const text = await file.text();
await processSourceText(text, file.name, { signal: controller.signal });
setStatus({ type: 'success', msg: `Successfully processed: ${file.name}` });
if (onUploadComplete) onUploadComplete();
} catch (error) {
if (error?.name === 'AbortError') {
setStatus({ type: 'error', msg: `Cancelled: ${file.name}` });
next.file.text()
.then((text) => processSourceText(text, next.name, { signal: controller.signal }))
.then(() => {
setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'done' } : item));
if (onUploadComplete) onUploadComplete();
})
.catch((err) => {
const isCancelled = err?.name === 'AbortError';
setQueue((q) => q.map((item) =>
item.id === next.id
? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : err.message }
: item
));
})
.finally(() => {
abortRef.current = null;
setProcessingId(null);
});
}, [processingId, queue, onUploadComplete]);
// ── File ingestion ─────────────────────────────────────────────────────────
const addFiles = (fileList) => {
const accepted = [];
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 });
} else {
setStatus({ type: 'error', msg: `Error processing file: ${error.message}` });
rejected++;
}
} finally {
abortRef.current = null;
setIsProcessing(false);
}
if (accepted.length > 0) setQueue((q) => [...q, ...accepted]);
if (rejected > 0) {
setRejectedNote(`${rejected} file${rejected > 1 ? 's' : ''} skipped — only .txt and .md are supported.`);
setTimeout(() => setRejectedNote(null), 4000);
}
};
const cancelProcessing = () => {
const cancelCurrent = () => {
abortRef.current?.abort(new DOMException('Cancelled by user', 'AbortError'));
};
const clearDone = () => {
setQueue((q) => q.filter((item) => item.status === 'pending' || item.status === 'processing'));
};
// ── Drag & drop / browse ───────────────────────────────────────────────────
const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = () => setIsDragging(false);
const handleDrop = (e) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files?.length > 0) {
const file = e.dataTransfer.files[0];
if (file.type === 'text/plain' || file.name.endsWith('.md')) {
processFile(file);
} else {
setStatus({ type: 'error', msg: 'Only .txt and .md files are currently supported.' });
}
}
if (e.dataTransfer.files?.length > 0) addFiles(e.dataTransfer.files);
};
const handleFileSelect = (e) => {
if (e.target.files?.length > 0) processFile(e.target.files[0]);
if (e.target.files?.length > 0) {
addFiles(e.target.files);
e.target.value = '';
}
};
// ── Derived state ──────────────────────────────────────────────────────────
const hasDone = queue.some((q) => ['done', 'failed', 'cancelled'].includes(q.status));
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="space-y-6">
<div className="space-y-4">
{/* ─── Drag & Drop Upload ─── */}
{/* ─── Drag & Drop Zone ─── */}
<Card
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-12 cursor-pointer ${
className={`border-2 border-dashed transition-colors flex flex-col items-center justify-center py-10 cursor-pointer ${
isDragging ? 'border-teal bg-teal/5' : 'border-bg-warm bg-bg-warm/20'
} ${isProcessing ? 'opacity-50 pointer-events-none' : ''}`}
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<UploadCloud size={48} className="text-teal/40 mb-4" />
<p className="font-medium text-lg">Drag files here</p>
<p className="text-sm text-fg-muted mb-4">Supports .txt and .md (max 5MB)</p>
<Button variant="outline" type="button" disabled={isProcessing}>
{isProcessing ? 'AI extraction in progress...' : 'Browse files'}
</Button>
<UploadCloud size={40} className="text-teal/40 mb-3" />
<p className="font-medium">Drag files here</p>
<p className="text-sm text-fg-muted mb-4">Supports .txt and .md · max 5 MB each</p>
<Button variant="outline" type="button">Browse files</Button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept=".txt,.md"
multiple
className="hidden"
/>
</Card>
{/* Cancel sits outside the upload card so pointer-events-none doesn't disable it. */}
{isProcessing && (
<div className="flex justify-center">
<button
type="button"
onClick={cancelProcessing}
className="flex items-center gap-1 text-sm text-red-600 hover:text-red-700 underline"
>
<X size={14} /> Cancel extraction
</button>
</div>
{/* ─── Rejected file note ─── */}
{rejectedNote && (
<p className="text-xs text-amber-600">{rejectedNote}</p>
)}
{/* ─── Status messages ─── */}
{status && (
<div className={`p-4 rounded-[var(--r-sm)] flex items-start gap-3 ${
status.type === 'error'
? 'bg-red-50 text-red-900 border border-red-200'
: 'bg-teal-50 text-teal-900 border border-teal-200'
}`}>
{status.type === 'error'
? <AlertCircle className="text-red-500 flex-shrink-0" />
: <CheckCircle className="text-teal-600 flex-shrink-0" />}
<div>
<p className="font-medium">{status.type === 'error' ? 'Error' : 'Success'}</p>
<p className="text-sm">{status.msg}</p>
</div>
{/* ─── Queue ─── */}
{queue.length > 0 && (
<div className="space-y-1">
{queue.map((item) => (
<div
key={item.id}
className="flex items-center gap-3 px-3 py-2 rounded-[var(--r-sm)] bg-bg-warm/30 text-sm"
>
<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 === 'pending' && 'Pending'}
{item.status === 'done' && 'Done'}
{item.status === 'cancelled' && 'Cancelled'}
{item.status === 'failed' && (
<span className="text-red-500" title={item.error}>Failed</span>
)}
</span>
{item.status === 'processing' && (
<button
type="button"
onClick={cancelCurrent}
className="text-red-500 hover:text-red-600 shrink-0"
title="Cancel"
>
<X size={14} />
</button>
)}
</div>
))}
{hasDone && (
<div className="flex justify-end pt-1">
<button
type="button"
onClick={clearDone}
className="text-xs text-fg-muted hover:text-fg underline"
>
Clear done / failed
</button>
</div>
)}
</div>
)}
</div>
);
};
// ── Status icon ───────────────────────────────────────────────────────────────
const StatusIcon = ({ status }) => {
if (status === 'processing') return <Loader2 size={14} className="text-teal animate-spin shrink-0" />;
if (status === 'done') return <CheckCircle size={14} className="text-teal shrink-0" />;
if (status === 'failed') return <AlertCircle size={14} className="text-red-500 shrink-0" />;
if (status === 'cancelled') return <Minus size={14} className="text-fg-muted shrink-0" />;
return <Clock size={14} className="text-fg-muted shrink-0" />;
};
export default UploadZone;