0d34867a69
- New `features/doc-service` FastAPI microservice: PDF upload, async text extraction (pdfplumber), AI classification via Anthropic/Ollama/ LM Studio, per-user categories, file download - Alembic migration isolated with `alembic_version_doc_service` table - Main backend: httpx proxy routers for /api/documents/* and /api/documents/categories/*, admin settings API at /api/settings/* - Runtime config in /config/doc_service_config.json (shared Docker volume); api_key masking on reads; atomic write with os.replace() - Frontend: DocumentsPage, DocumentAdminSettingsPage, updated AppsPage launcher hub, simplified Nav (removed Settings link), new routes - docker-compose: doc-service service, doc_data + app_config volumes, removed internal:true from backend-net for outbound AI API calls - Fix pre-commit hook: probe Docker socket path so git subprocess picks up Docker Desktop on macOS - Fix security_check.py: use sys.executable for bandit so venv python is used instead of system python Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
import { useRef, useState, useEffect } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import Nav from "../components/Nav";
|
|
import {
|
|
listDocuments,
|
|
uploadDocument,
|
|
deleteDocument,
|
|
downloadDocument,
|
|
getDocumentStatus,
|
|
listCategories,
|
|
createCategory,
|
|
assignCategory,
|
|
removeCategory,
|
|
type DocumentOut,
|
|
type CategoryOut,
|
|
} from "../api/client";
|
|
|
|
function StatusBadge({ status }: { status: DocumentOut["status"] }) {
|
|
const colors: Record<DocumentOut["status"], string> = {
|
|
pending: "#f4a261",
|
|
processing: "#2196f3",
|
|
done: "#2a9d8f",
|
|
failed: "#e63946",
|
|
};
|
|
return (
|
|
<span style={{
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
padding: "2px 8px",
|
|
borderRadius: 4,
|
|
background: colors[status],
|
|
color: "#fff",
|
|
}}>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function DocumentRow({
|
|
doc,
|
|
categories,
|
|
onDelete,
|
|
}: {
|
|
doc: DocumentOut;
|
|
categories: CategoryOut[];
|
|
onDelete: (id: string) => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const qc = useQueryClient();
|
|
|
|
// Poll status while pending/processing
|
|
const { data: liveStatus } = useQuery({
|
|
queryKey: ["docStatus", doc.id],
|
|
queryFn: () => getDocumentStatus(doc.id),
|
|
// v5: refetchInterval receives the Query object; data lives in query.state.data
|
|
refetchInterval: (query) => {
|
|
const s = query.state.data?.status;
|
|
return s === "pending" || s === "processing" ? 3000 : false;
|
|
},
|
|
enabled: doc.status === "pending" || doc.status === "processing",
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (liveStatus?.status === "done" || liveStatus?.status === "failed") {
|
|
qc.invalidateQueries({ queryKey: ["documents"] });
|
|
}
|
|
}, [liveStatus?.status, qc]);
|
|
|
|
const assignMut = useMutation({
|
|
mutationFn: ({ catId }: { catId: string }) => assignCategory(doc.id, catId),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }),
|
|
});
|
|
|
|
const removeCatMut = useMutation({
|
|
mutationFn: ({ catId }: { catId: string }) => removeCategory(doc.id, catId),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }),
|
|
});
|
|
|
|
const assignedIds = new Set(doc.categories.map((c) => c.id));
|
|
const unassigned = categories.filter((c) => !assignedIds.has(c.id));
|
|
|
|
let extractedData: Record<string, unknown> | null = null;
|
|
if (doc.extracted_data) {
|
|
try {
|
|
extractedData = JSON.parse(doc.extracted_data);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const tags: string[] = [];
|
|
if (doc.tags) {
|
|
try {
|
|
const parsed = JSON.parse(doc.tags);
|
|
if (Array.isArray(parsed)) tags.push(...parsed);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ border: "1px solid #ddd", borderRadius: 6, marginBottom: 12 }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
padding: "12px 16px",
|
|
cursor: "pointer",
|
|
}}
|
|
onClick={() => setExpanded((e) => !e)}
|
|
>
|
|
<span style={{ flex: 1, fontWeight: 500 }}>{doc.filename}</span>
|
|
<StatusBadge status={doc.status} />
|
|
{doc.document_type && (
|
|
<span style={{ fontSize: 12, color: "#555" }}>{doc.document_type}</span>
|
|
)}
|
|
<span style={{ fontSize: 12, color: "#999" }}>
|
|
{(doc.file_size / 1024).toFixed(0)} KB
|
|
</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
downloadDocument(doc.id, doc.filename);
|
|
}}
|
|
style={{ fontSize: 12, cursor: "pointer" }}
|
|
>
|
|
Download
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (confirm(`Delete "${doc.filename}"?`)) onDelete(doc.id);
|
|
}}
|
|
style={{ fontSize: 12, color: "#c00", cursor: "pointer" }}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #eee" }}>
|
|
{tags.length > 0 && (
|
|
<div style={{ marginTop: 10 }}>
|
|
<strong>Tags:</strong>{" "}
|
|
{tags.map((t) => (
|
|
<span
|
|
key={t}
|
|
style={{
|
|
fontSize: 12,
|
|
background: "#eee",
|
|
borderRadius: 3,
|
|
padding: "2px 6px",
|
|
marginRight: 4,
|
|
}}
|
|
>
|
|
{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{extractedData && (
|
|
<div style={{ marginTop: 10 }}>
|
|
<strong>Extracted data:</strong>
|
|
<table style={{ marginTop: 6, fontSize: 13, borderCollapse: "collapse" }}>
|
|
<tbody>
|
|
{Object.entries(extractedData)
|
|
.filter(([k]) => k !== "tags")
|
|
.map(([k, v]) => (
|
|
<tr key={k}>
|
|
<td style={{ paddingRight: 16, color: "#666", verticalAlign: "top" }}>{k}</td>
|
|
<td>
|
|
{Array.isArray(v)
|
|
? v.length === 0
|
|
? "—"
|
|
: JSON.stringify(v, null, 2)
|
|
: v !== null && v !== undefined && v !== ""
|
|
? String(v)
|
|
: "—"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{doc.error_message && (
|
|
<div style={{ marginTop: 10, color: "#c00", fontSize: 13 }}>
|
|
Error: {doc.error_message}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginTop: 12 }}>
|
|
<strong style={{ fontSize: 13 }}>Categories:</strong>{" "}
|
|
{doc.categories.map((c) => (
|
|
<span
|
|
key={c.id}
|
|
style={{
|
|
fontSize: 12,
|
|
background: "#dce8ff",
|
|
borderRadius: 3,
|
|
padding: "2px 6px",
|
|
marginRight: 4,
|
|
}}
|
|
>
|
|
{c.name}{" "}
|
|
<button
|
|
onClick={() => removeCatMut.mutate({ catId: c.id })}
|
|
style={{ fontSize: 10, cursor: "pointer", color: "#555", background: "none", border: "none" }}
|
|
>
|
|
x
|
|
</button>
|
|
</span>
|
|
))}
|
|
{unassigned.length > 0 && (
|
|
<select
|
|
defaultValue=""
|
|
onChange={(e) => {
|
|
if (e.target.value) assignMut.mutate({ catId: e.target.value });
|
|
e.target.value = "";
|
|
}}
|
|
style={{ fontSize: 12, marginLeft: 4 }}
|
|
>
|
|
<option value="">+ add category</option>
|
|
{unassigned.map((c) => (
|
|
<option key={c.id} value={c.id}>{c.name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DocumentsPage() {
|
|
const qc = useQueryClient();
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const [newCatName, setNewCatName] = useState("");
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
|
|
const { data: documents = [], isLoading } = useQuery({
|
|
queryKey: ["documents"],
|
|
queryFn: listDocuments,
|
|
});
|
|
|
|
const { data: categories = [] } = useQuery({
|
|
queryKey: ["categories"],
|
|
queryFn: listCategories,
|
|
});
|
|
|
|
const uploadMut = useMutation({
|
|
mutationFn: uploadDocument,
|
|
onSuccess: () => {
|
|
setUploadError(null);
|
|
qc.invalidateQueries({ queryKey: ["documents"] });
|
|
},
|
|
onError: (err: unknown) => {
|
|
const msg =
|
|
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
|
"Upload failed";
|
|
setUploadError(msg);
|
|
},
|
|
});
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: deleteDocument,
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }),
|
|
});
|
|
|
|
const createCatMut = useMutation({
|
|
mutationFn: createCategory,
|
|
onSuccess: () => {
|
|
setNewCatName("");
|
|
qc.invalidateQueries({ queryKey: ["categories"] });
|
|
},
|
|
});
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) uploadMut.mutate(file);
|
|
e.target.value = "";
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Nav />
|
|
<div style={{ padding: 32, maxWidth: 900, margin: "0 auto" }}>
|
|
<h1>Documents</h1>
|
|
|
|
{/* Upload */}
|
|
<div style={{ marginBottom: 24 }}>
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept="application/pdf"
|
|
style={{ display: "none" }}
|
|
onChange={handleFileChange}
|
|
/>
|
|
<button
|
|
onClick={() => fileRef.current?.click()}
|
|
disabled={uploadMut.isPending}
|
|
style={{ padding: "8px 16px", cursor: "pointer" }}
|
|
>
|
|
{uploadMut.isPending ? "Uploading…" : "Upload PDF"}
|
|
</button>
|
|
{uploadError && (
|
|
<span style={{ marginLeft: 12, color: "#c00", fontSize: 13 }}>{uploadError}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Category management */}
|
|
<details style={{ marginBottom: 24 }}>
|
|
<summary style={{ cursor: "pointer", fontWeight: 500 }}>Manage categories</summary>
|
|
<div style={{ marginTop: 10, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
{categories.map((c) => (
|
|
<span
|
|
key={c.id}
|
|
style={{
|
|
fontSize: 13,
|
|
background: "#eee",
|
|
borderRadius: 4,
|
|
padding: "4px 10px",
|
|
}}
|
|
>
|
|
{c.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<form
|
|
style={{ marginTop: 10, display: "flex", gap: 8 }}
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (newCatName.trim()) createCatMut.mutate(newCatName.trim());
|
|
}}
|
|
>
|
|
<input
|
|
value={newCatName}
|
|
onChange={(e) => setNewCatName(e.target.value)}
|
|
placeholder="New category name"
|
|
style={{ padding: "6px 10px", fontSize: 13 }}
|
|
/>
|
|
<button type="submit" disabled={createCatMut.isPending} style={{ cursor: "pointer" }}>
|
|
Add
|
|
</button>
|
|
</form>
|
|
</details>
|
|
|
|
{/* Document list */}
|
|
{isLoading ? (
|
|
<p>Loading…</p>
|
|
) : documents.length === 0 ? (
|
|
<p style={{ color: "#666" }}>No documents yet. Upload a PDF to get started.</p>
|
|
) : (
|
|
documents.map((doc) => (
|
|
<DocumentRow
|
|
key={doc.id}
|
|
doc={doc}
|
|
categories={categories}
|
|
onDelete={(id) => deleteMut.mutate(id)}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|