refactor: remove handbook sync state and related functionality
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user