feat: Add OCR Processing Layers Viewer UI and log proxy middleware
This commit is contained in:
1 parent
54b46fc35f
commit
aa3233e411
11 files changed
+964
-30
No files matched your search
@@ -57,7 +57,7 @@ services:
|
|||||||
- PIPELINE_PORT=8090
|
- PIPELINE_PORT=8090
|
||||||
- PIPELINE_DEVICE=gpu:0
|
- PIPELINE_DEVICE=gpu:0
|
||||||
- CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}
|
- CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}
|
||||||
- VLLM_SERVER_URL=http://paddleocr-vllm-server:8118/v1
|
- VLLM_SERVER_URL=http://paddleocr-pfm-web-app:3000/api/vllm-proxy/v1
|
||||||
- PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
|
- PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
|
||||||
# No exposed ports; internal only
|
# No exposed ports; internal only
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { query } from "../../../../../db";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> | { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Handle both Promise and synchronous params for Next.js version compatibility
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const { id } = resolvedParams;
|
||||||
|
const docId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (isNaN(docId)) {
|
||||||
|
return NextResponse.json({ error: "Invalid document ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await query(
|
||||||
|
"SELECT filename, processing_logs FROM documents WHERE id = $1",
|
||||||
|
[docId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.rowCount === 0 || !res.rows[0]) {
|
||||||
|
return NextResponse.json({ error: "Document not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
filename: res.rows[0].filename,
|
||||||
|
processing_logs: res.rows[0].processing_logs || null
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error fetching document logs:", error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,14 @@ import { query, cleanupAndReindexItems, resolveStoreFromText } from "../../../db
|
|||||||
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
|
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
let safeFile = "";
|
||||||
try {
|
try {
|
||||||
const { filename } = await req.json();
|
const { filename } = await req.json();
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
|
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeFile = path.basename(filename);
|
safeFile = path.basename(filename);
|
||||||
|
|
||||||
// 1. Resolve path to the public assets folder or fallback to /uploads
|
// 1. Resolve path to the public assets folder or fallback to /uploads
|
||||||
let filePath = path.join(process.cwd(), "public", "do-pfm", safeFile);
|
let filePath = path.join(process.cwd(), "public", "do-pfm", safeFile);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { query, resolveStoreFromText } from "../../../db";
|
import { query, resolveStoreFromText } from "../../../db";
|
||||||
import { parseDOMetadata } from "../../../utils/parser";
|
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
|
||||||
|
import { startActiveLog, getActiveLog, clearActiveLog } from "../../../utils/active-log";
|
||||||
|
|
||||||
const UPLOADS_DIR = "/uploads";
|
const UPLOADS_DIR = "/uploads";
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const pipelineUrl = process.env.PIPELINE_URL || "http://paddleocr-pipeline-api:8090/layout-parsing";
|
const pipelineUrl = process.env.PIPELINE_URL || "http://paddleocr-pipeline-api:8090/layout-parsing";
|
||||||
console.log(`Forwarding uploaded file ${filename} to pipeline: ${pipelineUrl}`);
|
console.log(`Forwarding uploaded file ${filename} to pipeline: ${pipelineUrl}`);
|
||||||
|
startActiveLog(filename);
|
||||||
|
|
||||||
const response = await fetch(pipelineUrl, {
|
const response = await fetch(pipelineUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -85,6 +87,7 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
clearActiveLog();
|
||||||
const errText = await response.text();
|
const errText = await response.text();
|
||||||
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
|
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
|
||||||
}
|
}
|
||||||
@@ -131,19 +134,46 @@ export async function POST(req: NextRequest) {
|
|||||||
(docMetadata as any).orderUntuk = resolvedStore.orderUntuk;
|
(docMetadata as any).orderUntuk = resolvedStore.orderUntuk;
|
||||||
(docMetadata as any).alamat = resolvedStore.alamat;
|
(docMetadata as any).alamat = resolvedStore.alamat;
|
||||||
|
|
||||||
|
// Stage 2 Filtering: Sanitize parsed metadata
|
||||||
|
const sanitizedMetadata = sanitizeParsedMetadata(docMetadata as any);
|
||||||
|
|
||||||
|
// Construct client response representation
|
||||||
|
const wrappedResult = {
|
||||||
|
errorCode: 0,
|
||||||
|
errorMsg: "Success",
|
||||||
|
result: pipelineResult
|
||||||
|
};
|
||||||
|
const clientResponse = {
|
||||||
|
filename,
|
||||||
|
result: wrappedResult
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retrieve and finalize active log data
|
||||||
|
const activeLog = getActiveLog();
|
||||||
|
let logsPayload: any = null;
|
||||||
|
if (activeLog && activeLog.filename === filename) {
|
||||||
|
activeLog.ocr_raw = pipelineResult;
|
||||||
|
activeLog.stage_1_output = docMetadata;
|
||||||
|
activeLog.stage_2_output = sanitizedMetadata;
|
||||||
|
activeLog.frontend_response = clientResponse;
|
||||||
|
logsPayload = activeLog;
|
||||||
|
}
|
||||||
|
clearActiveLog();
|
||||||
|
|
||||||
const insertDocRes = await query(`
|
const insertDocRes = await query(`
|
||||||
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash)
|
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, [
|
`, [
|
||||||
filename,
|
filename,
|
||||||
new Date(),
|
new Date(),
|
||||||
buffer.length,
|
buffer.length,
|
||||||
true,
|
true,
|
||||||
JSON.stringify(docMetadata),
|
JSON.stringify(sanitizedMetadata),
|
||||||
JSON.stringify(pipelineResult),
|
JSON.stringify(pipelineResult),
|
||||||
false,
|
false,
|
||||||
fileHash
|
fileHash,
|
||||||
|
logsPayload ? JSON.stringify(logsPayload) : null
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const docId = insertDocRes.rows[0].id;
|
const docId = insertDocRes.rows[0].id;
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { logVllmCall } from "../../../../utils/active-log";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handleProxy(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handleProxy(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
return handleProxy(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
return handleProxy(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProxy(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const pathname = req.nextUrl.pathname;
|
||||||
|
// Extract everything after /api/vllm-proxy
|
||||||
|
const relPath = pathname.replace(/^\/api\/vllm-proxy/, "");
|
||||||
|
|
||||||
|
// The real vLLM server is at paddleocr-vllm-server:8118 inside docker compose
|
||||||
|
const realBaseUrl = process.env.VLLM_SERVER_REAL_URL || "http://paddleocr-vllm-server:8118";
|
||||||
|
|
||||||
|
// Construct the destination URL
|
||||||
|
const destUrl = `${realBaseUrl}${relPath}${req.nextUrl.search}`;
|
||||||
|
|
||||||
|
console.log(`[vllm-proxy] Routing request from ${pathname} to ${destUrl}`);
|
||||||
|
|
||||||
|
// Read the request body if present
|
||||||
|
let reqBody: any = null;
|
||||||
|
let reqBodyBuffer: Buffer | null = null;
|
||||||
|
|
||||||
|
if (req.body) {
|
||||||
|
const arrayBuffer = await req.arrayBuffer();
|
||||||
|
reqBodyBuffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
const contentType = req.headers.get("content-type") || "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
try {
|
||||||
|
reqBody = JSON.parse(reqBodyBuffer.toString("utf-8"));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[vllm-proxy] Failed to parse request body as JSON:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct headers, filtering out headers that might cause issues (e.g. Host)
|
||||||
|
const headers = new Headers();
|
||||||
|
req.headers.forEach((value, key) => {
|
||||||
|
if (key.toLowerCase() !== "host" && key.toLowerCase() !== "content-length") {
|
||||||
|
headers.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make the actual call to the real vLLM server
|
||||||
|
const forwardResponse = await fetch(destUrl, {
|
||||||
|
method: req.method,
|
||||||
|
headers: headers,
|
||||||
|
body: reqBodyBuffer ? new Uint8Array(reqBodyBuffer) : null,
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: "half"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read the response content
|
||||||
|
const resBodyBuffer = Buffer.from(await forwardResponse.arrayBuffer());
|
||||||
|
let resBody: any = null;
|
||||||
|
|
||||||
|
const resContentType = forwardResponse.headers.get("content-type") || "";
|
||||||
|
if (resContentType.includes("application/json")) {
|
||||||
|
try {
|
||||||
|
resBody = JSON.parse(resBodyBuffer.toString("utf-8"));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[vllm-proxy] Failed to parse response body as JSON:", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resBody = resBodyBuffer.toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the interaction if it looks like a completion call
|
||||||
|
if (pathname.includes("/chat/completions") || pathname.includes("/completions")) {
|
||||||
|
// Make a clean copy of the request to log (hiding huge base64 images if they clutter logs)
|
||||||
|
const cleanReq = sanitizeLogPayload(reqBody);
|
||||||
|
logVllmCall(cleanReq, resBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the response back to pipeline-api
|
||||||
|
const responseHeaders = new Headers();
|
||||||
|
forwardResponse.headers.forEach((value, key) => {
|
||||||
|
responseHeaders.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(resBodyBuffer, {
|
||||||
|
status: forwardResponse.status,
|
||||||
|
statusText: forwardResponse.statusText,
|
||||||
|
headers: responseHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[vllm-proxy] Error forwarding request:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to proxy request to vLLM server" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to keep log sizes reasonable by truncating huge base64 image strings
|
||||||
|
function sanitizeLogPayload(payload: any): any {
|
||||||
|
if (!payload) return payload;
|
||||||
|
try {
|
||||||
|
const copy = JSON.parse(JSON.stringify(payload));
|
||||||
|
if (copy.messages && Array.isArray(copy.messages)) {
|
||||||
|
for (const msg of copy.messages) {
|
||||||
|
if (msg.content && Array.isArray(msg.content)) {
|
||||||
|
for (const part of msg.content) {
|
||||||
|
if (part.type === "image_url" && part.image_url && part.image_url.url) {
|
||||||
|
const url = part.image_url.url;
|
||||||
|
if (url.startsWith("data:") && url.length > 200) {
|
||||||
|
part.image_url.url = url.substring(0, 100) + "...[TRUNCATED BASE64]..." + url.substring(url.length - 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
} catch (e) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #0f172a;
|
||||||
--foreground: #171717;
|
--foreground: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "AI OCR Delivery Order",
|
title: "AI OCR Delivery Order",
|
||||||
description: "Generated by create next app",
|
description: "Generated by create next app",
|
||||||
@@ -25,9 +14,10 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className="h-full antialiased text-slate-100 bg-slate-950"
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col font-sans">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,697 @@
|
|||||||
export const dynamic = "force-dynamic";
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface HistoryItem {
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
uploadTime: string;
|
||||||
|
size: number;
|
||||||
|
parsed: boolean;
|
||||||
|
isSample: boolean;
|
||||||
|
metadata: any;
|
||||||
|
totalItems: number;
|
||||||
|
flaggedItems: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<HistoryItem | null>(null);
|
||||||
|
const [selectedDetails, setSelectedDetails] = useState<any>(null);
|
||||||
|
const [selectedLogs, setSelectedLogs] = useState<any>(null);
|
||||||
|
const [activeStep, setActiveStep] = useState<number>(0);
|
||||||
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
|
const [dragActive, setDragActive] = useState<boolean>(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Fetch history list
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/history?all=true");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setHistory(data.history || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch history:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHistory();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch details and logs for a selected document
|
||||||
|
const handleSelectItem = async (item: HistoryItem) => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setSelectedDetails(null);
|
||||||
|
setSelectedLogs(null);
|
||||||
|
setErrorMsg(null);
|
||||||
|
setActiveStep(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch items and doc details
|
||||||
|
const detailsRes = await fetch(`/api/history?file=${encodeURIComponent(item.filename)}`);
|
||||||
|
if (detailsRes.ok) {
|
||||||
|
const detailsData = await detailsRes.json();
|
||||||
|
setSelectedDetails(detailsData);
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to load document details");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch processing logs
|
||||||
|
const logsRes = await fetch(`/api/documents/${item.id}/logs`);
|
||||||
|
if (logsRes.ok) {
|
||||||
|
const logsData = await logsRes.json();
|
||||||
|
setSelectedLogs(logsData.processing_logs);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Error loading document:", err);
|
||||||
|
setErrorMsg(err.message || "Error loading document details");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Drag & Drop events
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
setDragActive(true);
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
setDragActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragActive(false);
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
|
await handleUploadFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
await handleUploadFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadFile = async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
setErrorMsg(null);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
console.log("Uploading file to gateway...");
|
||||||
|
const res = await fetch("/api/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || "Failed to process image");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
console.log("Upload response received:", data);
|
||||||
|
|
||||||
|
// Refresh list and select new document
|
||||||
|
await fetchHistory();
|
||||||
|
|
||||||
|
// Attempt to find the newly uploaded item in history list
|
||||||
|
// Next.js history is sorted descending, so it will likely be first
|
||||||
|
const updatedRes = await fetch("/api/history?all=true");
|
||||||
|
if (updatedRes.ok) {
|
||||||
|
const updatedData = await updatedRes.json();
|
||||||
|
const newHistory = updatedData.history || [];
|
||||||
|
setHistory(newHistory);
|
||||||
|
const match = newHistory.find((h: HistoryItem) => h.filename === data.filename);
|
||||||
|
if (match) {
|
||||||
|
handleSelectItem(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Upload failed:", err);
|
||||||
|
setErrorMsg(err.message || "Failed to upload and process file.");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteItem = async (e: React.MouseEvent, item: HistoryItem) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm(`Are you sure you want to delete ${item.filename}?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/history", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ filename: item.filename })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
if (selectedItem?.id === item.id) {
|
||||||
|
setSelectedItem(null);
|
||||||
|
setSelectedDetails(null);
|
||||||
|
setSelectedLogs(null);
|
||||||
|
}
|
||||||
|
fetchHistory();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete document:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format bytes to human readable format
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["Bytes", "KB", "MB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup Stepper Steps
|
||||||
|
const getSteps = () => {
|
||||||
|
const vllmCallsCount = selectedLogs?.vllm_calls?.length || 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "1. Upload & Preprocess",
|
||||||
|
description: "Verify document metadata & files",
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-semibold text-teal-400">Uploaded Document File Info</h4>
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs space-y-2 text-slate-300">
|
||||||
|
<p><span className="text-slate-500">Filename:</span> {selectedItem?.filename}</p>
|
||||||
|
<p><span className="text-slate-500">File Size:</span> {formatBytes(selectedItem?.size || 0)}</p>
|
||||||
|
<p><span className="text-slate-500">Upload Date:</span> {selectedItem?.uploadTime ? new Date(selectedItem.uploadTime).toLocaleString() : "N/A"}</p>
|
||||||
|
<p><span className="text-slate-500">File Hash:</span> {selectedItem?.metadata?.fileHash || "Processed content hash saved"}</p>
|
||||||
|
<p><span className="text-slate-500">Is Sample DO:</span> {selectedItem?.isSample ? "True" : "False"}</p>
|
||||||
|
</div>
|
||||||
|
{selectedItem?.filename && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-teal-400 mb-2">Original Document Source View</h4>
|
||||||
|
<div className="border border-slate-700 rounded-lg overflow-hidden max-h-96 flex justify-center bg-slate-950">
|
||||||
|
<img
|
||||||
|
src={selectedItem.isSample ? `/do-pfm/${selectedItem.filename}` : `/api/history?file=${encodeURIComponent(selectedItem.filename)}`}
|
||||||
|
alt="Original Document"
|
||||||
|
className="object-contain max-h-96 w-full"
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback if direct image access requires alternative endpoint
|
||||||
|
(e.target as HTMLImageElement).src = `https://placehold.co/600x800/1e293b/f8fafc?text=${encodeURIComponent(selectedItem.filename)}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `2. vLLM Prompt ("Before")`,
|
||||||
|
description: `Inspect prompt parameters & system role (${vllmCallsCount} call${vllmCallsCount > 1 ? "s" : ""})`,
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-semibold text-teal-400">Payload Sent to vLLM Server</h4>
|
||||||
|
{selectedLogs?.vllm_calls && selectedLogs.vllm_calls.length > 0 ? (
|
||||||
|
selectedLogs.vllm_calls.map((call: any, idx: number) => (
|
||||||
|
<div key={idx} className="space-y-2 border-b border-slate-700/40 pb-4 last:border-0 last:pb-0">
|
||||||
|
<div className="flex justify-between items-center text-xs text-slate-400">
|
||||||
|
<span className="bg-slate-800 text-teal-300 px-2 py-0.5 rounded-full font-semibold">Call #{idx + 1}</span>
|
||||||
|
<span>{new Date(call.timestamp).toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
|
||||||
|
<pre>{JSON.stringify(call.request, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
|
||||||
|
No vLLM API calls logged. (If this is a sample/re-indexed document, logs may not be recorded yet).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `3. vLLM Response ("After")`,
|
||||||
|
description: "Inspect raw output returned by VLM",
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-semibold text-teal-400">Raw Text Generated by vLLM (PaddleOCR-VL)</h4>
|
||||||
|
{selectedLogs?.vllm_calls && selectedLogs.vllm_calls.length > 0 ? (
|
||||||
|
selectedLogs.vllm_calls.map((call: any, idx: number) => (
|
||||||
|
<div key={idx} className="space-y-2 border-b border-slate-700/40 pb-4 last:border-0 last:pb-0">
|
||||||
|
<div className="flex justify-between items-center text-xs text-slate-400">
|
||||||
|
<span className="bg-slate-800 text-teal-300 px-2 py-0.5 rounded-full font-semibold">Response #{idx + 1}</span>
|
||||||
|
<span>{new Date(call.timestamp).toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
|
||||||
|
<pre>{JSON.stringify(call.response, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
|
||||||
|
No vLLM API responses logged.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "4. Raw OCR Layout Markdown",
|
||||||
|
description: "View parsed layout markdown structure",
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-semibold text-teal-400">Document Layout Parsing Markdown Output</h4>
|
||||||
|
{selectedDetails?.result || selectedLogs?.ocr_raw ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
|
||||||
|
<pre className="whitespace-pre-wrap">
|
||||||
|
{(() => {
|
||||||
|
const res = selectedDetails?.result || selectedLogs?.ocr_raw;
|
||||||
|
const page0 = res?.layoutParsingResults?.[0] || {};
|
||||||
|
return page0?.markdown?.text || "No markdown text output found in results.";
|
||||||
|
})()}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
|
||||||
|
No raw layout results parsed.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "5. Stage 1 Extracted Fields",
|
||||||
|
description: "View output of regex schema matching",
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-semibold text-teal-400">Regex Extracted Fields (Metadata & Items)</h4>
|
||||||
|
{selectedLogs?.stage_1_output ? (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
|
||||||
|
<pre>{JSON.stringify(selectedLogs.stage_1_output, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
|
||||||
|
No Stage 1 log output saved.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "6. Stage 2 Sanitized Output",
|
||||||
|
description: "View results after validation checks",
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-semibold text-teal-400">Sanitized & Validated Metadata (Final database fields)</h4>
|
||||||
|
{selectedLogs?.stage_2_output ? (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
|
||||||
|
<pre>{JSON.stringify(selectedLogs.stage_2_output, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
|
||||||
|
No Stage 2 log output saved.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "7. Client Gateway Response",
|
||||||
|
description: "Inspect JSON payload sent to frontends",
|
||||||
|
content: (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-semibold text-teal-400">Final Gateway JSON Response</h4>
|
||||||
|
{selectedLogs?.frontend_response || selectedDetails ? (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 font-mono text-xs overflow-auto max-h-96 text-slate-300">
|
||||||
|
<pre>
|
||||||
|
{JSON.stringify(
|
||||||
|
selectedLogs?.frontend_response || {
|
||||||
|
filename: selectedItem?.filename,
|
||||||
|
result: selectedDetails
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-400 italic">
|
||||||
|
No response payload logged.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = getSteps();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: 'system-ui, sans-serif', padding: '2rem', textAlign: 'center' }}>
|
<div className="flex-1 flex flex-col bg-slate-950 text-slate-100 min-h-screen">
|
||||||
<h1>Prima Fresh Mart OCR API Gateway</h1>
|
{/* Sleek Top Navigation Bar */}
|
||||||
<p>Status: Running</p>
|
<header className="border-b border-slate-800 bg-slate-900/80 backdrop-blur-md px-6 py-4 flex justify-between items-center sticky top-0 z-50">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="h-8 w-8 bg-gradient-to-tr from-teal-500 to-indigo-600 rounded-lg flex items-center justify-center font-bold text-white shadow-lg shadow-teal-500/20">
|
||||||
|
P
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-md font-bold bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-200 to-slate-400">
|
||||||
|
Prima Fresh Mart AI OCR Gateway
|
||||||
|
</h1>
|
||||||
|
<p className="text-[10px] text-teal-400 font-mono tracking-wider uppercase">
|
||||||
|
Pipeline Process Layers Dashboard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Indicators */}
|
||||||
|
<div className="flex items-center space-x-4 text-xs">
|
||||||
|
<div className="flex items-center space-x-2 bg-slate-800/80 px-3 py-1 rounded-full border border-slate-700/40">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||||
|
<span className="text-slate-400 font-mono">DB: Online</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 bg-slate-800/80 px-3 py-1 rounded-full border border-slate-700/40">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||||
|
<span className="text-slate-400 font-mono">vLLM: Connected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Layout Grid */}
|
||||||
|
<main className="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 p-6 overflow-hidden">
|
||||||
|
|
||||||
|
{/* LEFT COLUMN: Upload Panel & History List (4 Cols) */}
|
||||||
|
<section className="lg:col-span-4 flex flex-col space-y-6 overflow-y-auto max-h-[calc(100vh-120px)] pr-2">
|
||||||
|
|
||||||
|
{/* Upload Zone Card */}
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-2xl p-6 transition-all duration-300 cursor-pointer flex flex-col items-center justify-center ${
|
||||||
|
dragActive
|
||||||
|
? "border-teal-400 bg-teal-950/20 shadow-md shadow-teal-500/10"
|
||||||
|
: "border-slate-800 hover:border-slate-700 bg-slate-900/40 hover:bg-slate-900/60"
|
||||||
|
}`}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={triggerFileInput}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{uploading ? (
|
||||||
|
<div className="flex flex-col items-center py-4 space-y-3">
|
||||||
|
<div className="h-8 w-8 border-4 border-t-teal-400 border-slate-700 rounded-full animate-spin"></div>
|
||||||
|
<p className="text-xs text-teal-400 font-mono">Running OCR Pipeline...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 space-y-3">
|
||||||
|
<div className="text-3xl text-slate-400">📤</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-200">Upload Delivery Order Image</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Drag and drop file, or click to browse</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History List Card */}
|
||||||
|
<div className="bg-slate-900/30 border border-slate-850 rounded-2xl p-5 flex flex-col flex-grow min-h-[350px]">
|
||||||
|
<h3 className="text-sm font-bold text-slate-400 font-mono uppercase tracking-wider mb-4">
|
||||||
|
Upload History
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3 overflow-y-auto max-h-[450px] flex-grow pr-1">
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-xs text-slate-500 italic">
|
||||||
|
No uploads processed yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
history.map((item) => {
|
||||||
|
const isSelected = selectedItem?.id === item.id;
|
||||||
|
const dateFormatted = new Date(item.uploadTime).toLocaleDateString("id-ID", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`p-3.5 rounded-xl border transition-all duration-200 cursor-pointer flex justify-between items-start ${
|
||||||
|
isSelected
|
||||||
|
? "bg-slate-900 border-teal-500/60 shadow-lg shadow-teal-500/5"
|
||||||
|
: "bg-slate-900/50 border-slate-800 hover:border-slate-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectItem(item)}
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5 min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold text-slate-200 truncate pr-2" title={item.filename}>
|
||||||
|
{item.filename.substring(item.filename.indexOf("-") + 1)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center space-x-2 text-[10px] text-slate-500 font-mono">
|
||||||
|
<span>{dateFormatted}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatBytes(item.size)}</span>
|
||||||
|
</div>
|
||||||
|
{item.metadata && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{item.metadata.noPO && item.metadata.noPO !== "Not Found" && (
|
||||||
|
<span className="bg-teal-950/50 text-teal-400 border border-teal-900/60 px-1.5 py-0.5 rounded text-[9px] font-mono font-semibold">
|
||||||
|
{item.metadata.noPO}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.totalItems > 0 && (
|
||||||
|
<span className="bg-slate-800 text-slate-300 px-1.5 py-0.5 rounded text-[9px] font-mono">
|
||||||
|
{item.totalItems} Items
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.flaggedItems > 0 && (
|
||||||
|
<span className="bg-rose-950/30 text-rose-400 border border-rose-900/40 px-1.5 py-0.5 rounded text-[9px] font-mono font-semibold">
|
||||||
|
{item.flaggedItems} Warning
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDeleteItem(e, item)}
|
||||||
|
className="text-slate-600 hover:text-rose-400 p-1.5 rounded-lg hover:bg-rose-950/20 transition-colors"
|
||||||
|
title="Delete document"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN: Interactive Stepper & Document Viewer (8 Cols) */}
|
||||||
|
<section className="lg:col-span-8 flex flex-col space-y-6 overflow-y-auto max-h-[calc(100vh-120px)]">
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="bg-rose-950/30 border border-rose-800/40 p-4 rounded-xl text-xs text-rose-400 flex items-start space-x-2">
|
||||||
|
<span className="text-base leading-none">⚠️</span>
|
||||||
|
<span>{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedItem ? (
|
||||||
|
<div className="flex-grow bg-slate-900/20 border border-slate-900/60 rounded-2xl p-12 flex flex-col justify-center items-center text-center">
|
||||||
|
<div className="text-5xl mb-4 text-slate-700 animate-pulse">📁</div>
|
||||||
|
<h3 className="text-md font-bold text-slate-400">No Document Selected</h3>
|
||||||
|
<p className="text-xs text-slate-500 max-w-sm mt-2">
|
||||||
|
Upload a new Delivery Order image, or select one from the history panel to inspect its processing stages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Document Overview Metadata Card */}
|
||||||
|
<div className="bg-slate-900/40 border border-slate-850 rounded-2xl p-6 shadow-xl">
|
||||||
|
<div className="flex justify-between items-start border-b border-slate-800 pb-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-md font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<span>📄</span>
|
||||||
|
<span className="truncate max-w-md">{selectedItem.filename.substring(selectedItem.filename.indexOf("-") + 1)}</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Parsed Overview results from Postgres Database</p>
|
||||||
|
</div>
|
||||||
|
{selectedItem.isSample && (
|
||||||
|
<span className="bg-blue-950/40 text-blue-400 border border-blue-900/40 px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wider font-mono">
|
||||||
|
Sample DO
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedDetails?.result?.metadata || selectedItem.metadata ? (
|
||||||
|
(() => {
|
||||||
|
const meta = selectedDetails?.result?.metadata || selectedItem.metadata;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
|
||||||
|
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">PO Number</p>
|
||||||
|
<p className="text-slate-200 font-semibold">{meta.noPO || "Not Found"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
|
||||||
|
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">SO Number</p>
|
||||||
|
<p className="text-slate-200 font-semibold">{meta.noSO || "Not Found"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
|
||||||
|
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">DO Number</p>
|
||||||
|
<p className="text-slate-200 font-semibold">{meta.noDO || "Not Found"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850 md:col-span-3">
|
||||||
|
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Order Untuk (Store)</p>
|
||||||
|
<p className="text-slate-200 font-semibold text-sm">{meta.orderUntuk || "Not Found"}</p>
|
||||||
|
<p className="text-slate-400 mt-1 text-[10px] truncate">{meta.alamat || ""}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
|
||||||
|
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Date</p>
|
||||||
|
<p className="text-slate-200 font-semibold">{meta.tanggal || "Not Found"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
|
||||||
|
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Truck Plate</p>
|
||||||
|
<p className="text-slate-200 font-semibold">{meta.platTruk || "Not Found"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-950/40 p-3 rounded-lg border border-slate-850">
|
||||||
|
<p className="text-slate-500 uppercase text-[9px] tracking-wider mb-1 font-bold">Total SKUs</p>
|
||||||
|
<p className="text-slate-200 font-semibold">{selectedDetails?.items?.length || 0} SKU Items</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500 italic">No metadata available</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Parsed Items List */}
|
||||||
|
{selectedDetails?.items && selectedDetails.items.length > 0 && (
|
||||||
|
<div className="mt-5 border-t border-slate-800 pt-4">
|
||||||
|
<h4 className="text-xs font-bold font-mono text-slate-400 uppercase mb-2">Parsed SKU Table Rows</h4>
|
||||||
|
<div className="overflow-x-auto border border-slate-850 rounded-xl">
|
||||||
|
<table className="min-w-full text-xs text-left text-slate-300">
|
||||||
|
<thead className="bg-slate-950/60 font-mono text-[9px] uppercase text-slate-500 border-b border-slate-800">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2">No</th>
|
||||||
|
<th className="px-4 py-2">SKU Code</th>
|
||||||
|
<th className="px-4 py-2">Item Description</th>
|
||||||
|
<th className="px-4 py-2 text-right">Qty</th>
|
||||||
|
<th className="px-4 py-2 text-right">Price/Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/40 bg-slate-950/10">
|
||||||
|
{selectedDetails.items.map((item: any, idx: number) => {
|
||||||
|
const isFlagged = selectedDetails.flagged?.[idx];
|
||||||
|
const remark = selectedDetails.remarks?.[idx];
|
||||||
|
return (
|
||||||
|
<tr key={idx} className={isFlagged ? "bg-rose-950/10" : "hover:bg-slate-900/20"}>
|
||||||
|
<td className="px-4 py-2 text-slate-500">{idx + 1}</td>
|
||||||
|
<td className={`px-4 py-2 font-mono font-semibold ${isFlagged ? "text-rose-400" : "text-teal-400"}`}>
|
||||||
|
{item.kodeBarang}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 pr-6">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-200">{item.namaBarang}</p>
|
||||||
|
{remark && <p className="text-[10px] text-rose-400 italic mt-0.5">{remark}</p>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono font-semibold">{item.banyak}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono text-slate-400">{item.jumlah}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TIMELINE / STEPPER LAYERS CARD */}
|
||||||
|
<div className="bg-slate-900/40 border border-slate-850 rounded-2xl p-6 shadow-xl flex flex-col space-y-6">
|
||||||
|
<div className="border-b border-slate-800 pb-3">
|
||||||
|
<h3 className="text-md font-bold text-slate-100 flex items-center space-x-2">
|
||||||
|
<span>🔬</span>
|
||||||
|
<span>AI OCR Pipeline Process Layers</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Interactive vertical timeline inspecting how data flows from original image to clean JSON response</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Process Timeline Stepper Container */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||||
|
|
||||||
|
{/* Stepper Steps (4 Cols) */}
|
||||||
|
<div className="md:col-span-4 flex flex-col space-y-2 border-r border-slate-800/60 pr-4">
|
||||||
|
{steps.map((step, idx) => {
|
||||||
|
const isActive = activeStep === idx;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`p-3 rounded-xl cursor-pointer transition-all duration-200 border text-left ${
|
||||||
|
isActive
|
||||||
|
? "bg-slate-900 border-teal-500 text-slate-200 shadow-md shadow-teal-500/5"
|
||||||
|
: "bg-slate-950/20 border-transparent text-slate-500 hover:text-slate-300"
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveStep(idx)}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-bold font-mono tracking-wide">{step.title}</p>
|
||||||
|
<p className="text-[10px] font-normal truncate mt-0.5">{step.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Detail Content Panel (8 Cols) */}
|
||||||
|
<div className="md:col-span-8 bg-slate-950/40 border border-slate-850 rounded-xl p-5 min-h-[350px]">
|
||||||
|
<div className="flex justify-between items-center mb-4 border-b border-slate-800/80 pb-2">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 font-mono">
|
||||||
|
Layer Detail view
|
||||||
|
</h4>
|
||||||
|
<span className="text-[10px] bg-slate-800 text-slate-400 px-2 py-0.5 rounded-full font-mono font-semibold">
|
||||||
|
Layer {activeStep + 1} of 7
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-200">
|
||||||
|
{steps[activeStep].content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -19,15 +19,17 @@ export async function initDb(pool: Pool) {
|
|||||||
metadata JSONB,
|
metadata JSONB,
|
||||||
layout_parsing_result JSONB,
|
layout_parsing_result JSONB,
|
||||||
is_sample BOOLEAN NOT NULL DEFAULT FALSE,
|
is_sample BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
file_hash VARCHAR(64)
|
file_hash VARCHAR(64),
|
||||||
|
processing_logs JSONB
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Ensure file_hash exists on existing tables
|
// Ensure file_hash and processing_logs exist on existing tables
|
||||||
try {
|
try {
|
||||||
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_hash VARCHAR(64);");
|
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_hash VARCHAR(64);");
|
||||||
|
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS processing_logs JSONB;");
|
||||||
} catch (alterErr) {
|
} catch (alterErr) {
|
||||||
console.error("Failed to alter documents table for file_hash:", alterErr);
|
console.error("Failed to alter documents table for schema upgrade:", alterErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Active Log Tracker for OCR Processing Layers
|
||||||
|
|
||||||
|
export interface VllmCall {
|
||||||
|
request: any;
|
||||||
|
response: any;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveUploadLog {
|
||||||
|
filename: string;
|
||||||
|
vllm_calls: VllmCall[];
|
||||||
|
ocr_raw?: any;
|
||||||
|
stage_1_output?: any;
|
||||||
|
stage_2_output?: any;
|
||||||
|
frontend_response?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store active log in global context to persist across Next.js hot-reloads
|
||||||
|
const globalForActiveLog = global as unknown as {
|
||||||
|
activeLog: ActiveUploadLog | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startActiveLog(filename: string) {
|
||||||
|
globalForActiveLog.activeLog = {
|
||||||
|
filename,
|
||||||
|
vllm_calls: []
|
||||||
|
};
|
||||||
|
console.log(`[ActiveLog] Started tracking log for ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logVllmCall(request: any, response: any) {
|
||||||
|
if (globalForActiveLog.activeLog) {
|
||||||
|
globalForActiveLog.activeLog.vllm_calls.push({
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
console.log(`[ActiveLog] Logged vLLM call for ${globalForActiveLog.activeLog.filename} (total calls: ${globalForActiveLog.activeLog.vllm_calls.length})`);
|
||||||
|
} else {
|
||||||
|
console.log("[ActiveLog] Warning: Attempted to log vLLM call but no active log session is running.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveLog(): ActiveUploadLog | null {
|
||||||
|
return globalForActiveLog.activeLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearActiveLog() {
|
||||||
|
globalForActiveLog.activeLog = null;
|
||||||
|
console.log("[ActiveLog] Cleared active log tracking context");
|
||||||
|
}
|
||||||
+1
-1
@@ -55,7 +55,7 @@ services:
|
|||||||
- PIPELINE_PORT=8090
|
- PIPELINE_PORT=8090
|
||||||
- PIPELINE_DEVICE=gpu:0
|
- PIPELINE_DEVICE=gpu:0
|
||||||
- CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}
|
- CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}
|
||||||
- VLLM_SERVER_URL=http://paddleocr-vllm-server:8118/v1
|
- VLLM_SERVER_URL=http://paddleocr-pfm-web-app:3000/api/vllm-proxy/v1
|
||||||
- PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
|
- PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
Reference in new issue
Block a user